一篇文章搞懂熱修復類載入方案原理

騎摩托馬斯發表於2019-02-13

ClassLoader 型別

Java 中的 ClassLoader 可以載入 jar 檔案和 Class檔案(本質是載入 Class 檔案),這一點在 Android 中並不適用,因為無論 DVM 還是 ART 它們載入的不再是 Class 檔案,而是 dex 檔案。

Android 中的 ClassLoader 型別和 Java 中的 ClassLoader 型別類似,也分為兩種型別,分別是系統 ClassLoader自定義 ClassLoader。其中 Android 系統 ClassLoader 包括三種分別是 BootClassLoaderPathClassLoaderDexClassLoader,而 Java 系統類載入器也包括3種,分別是 Bootstrap ClassLoaderExtensions ClassLoaderApp ClassLoader

BootClassLoader

Android 系統啟動時會使用 BootClassLoader 來預載入常用類,與 Java 中的 BootClassLoader 不同,它並是由 C/C++ 程式碼實現,而是由 Java 實現的,1BootClassLoade1 的程式碼如下所示

// libcore/ojluni/src/main/java/java/lang/ClassLoader.java
class BootClassLoader extends ClassLoader {

    private static BootClassLoader instance;

    @FindBugsSuppressWarnings("DP_CREATE_CLASSLOADER_INSIDE_DO_PRIVILEGED")
    public static synchronized BootClassLoader getInstance() {
        if (instance == null) {
            instance = new BootClassLoader();
        }

        return instance;
    }

    public BootClassLoader() {
        super(null);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        return Class.classForName(name, false, null);
    }

    ...
}
複製程式碼

BootClassLoaderClassLoader 的內部類,並繼承自 ClassLoaderBootClassLoader 是一個單例類,需要注意的是 BootClassLoader 的訪問修飾符是預設的,只有在同一個包中才可以訪問,因此我們在應用程式中是無法直接呼叫的

PathClassLoader

Android 系統使用 PathClassLoader 來載入系統類和應用程式的類,如果是載入非系統應用程式類,則會載入 data/app/$packagename下的 dex 檔案以及包含 dex 的 apk 檔案或 jar 檔案,不管是載入哪種檔案,最終都是要載入 dex 檔案,在這裡為了方便理解,我們將 dex 檔案以及包含 dex 的 apk 檔案或 jar 檔案統稱為 dex 相關檔案。PathClassLoader 不建議開發直接使用。

// libcore/dalvik/src/main/java/dalvik/system/PathClassLoader.java

public class PathClassLoader extends BaseDexClassLoader {
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

    public PathClassLoader(String dexPath, String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}
複製程式碼

PathClassLoader繼承自 BaseDexClassLoader,很明顯 PathClassLoader 的方法實現都在 BaseDexClassLoader 中。

PathClassLoader 的構造方法有三個引數:

  • dexPath:dex 檔案以及包含 dex 的 apk 檔案或 jar 檔案的路徑集合,多個路徑用檔案分隔符分隔,預設檔案分隔符為‘:’。
  • librarySearchPath:包含 C/C++ 庫的路徑集合,多個路徑用檔案分隔符分隔分割,可以為 null
  • parent:ClassLoader 的 parent

DexClassLoader

DexClassLoader 可以載入 dex 檔案以及包含 dex 的 apk 檔案或 jar 檔案,也支援從 SD 卡進行載入,這也就意味著 DexClassLoader 可以在應用未安裝的情況下載入 dex 相關檔案。因此,它是熱修復和外掛化技術的基礎。

public class DexClassLoader extends BaseDexClassLoader {
    
    public DexClassLoader(String dexPath, String optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(dexPath, null, librarySearchPath, parent);
    }
}
複製程式碼

DexClassLoader 構造方法的引數要比 PathClassLoader 多一個 optimizedDirectory 引數,引數 optimizedDirectory 代表什麼呢?應用程式在第一次被載入的時候,為了提高以後的啟動速度和執行效率,Android 系統會對 dex 相關檔案做一定程度的優化,並生成一個 ODEX 檔案,此後再執行這個應用程式的時候,只要載入優化過的 ODEX 檔案就行了,省去了每次都要優化的時間,而引數 optimizedDirectory 就是代表儲存 ODEX 檔案的路徑,這個路徑必須是一個內部儲存路徑。PathClassLoader 沒有引數 optimizedDirectory,這是因為 PathClassLoader 已經預設了引數 optimizedDirectory 的路徑為:/data/dalvik-cacheDexClassLoader 也繼承自 BaseDexClassLoader ,方法實現也都在 BaseDexClassLoader 中。

關於以上 ClassLoader 在 Android 系統中的建立過程,這裡牽扯到 Zygote 程式,非本文的重點,故不在此進行討論。

ClassLoader 繼承關係

一篇文章搞懂熱修復類載入方案原理

  • ClassLoader 是一個抽象類,其中定義了 ClassLoader 的主要功能。BootClassLoader 是它的內部類。
  • SecureClassLoader類和 JDK8 中的 SecureClassLoader 類的程式碼是一樣的,它繼承了抽象類 ClassLoaderSecureClassLoader 並不是 ClassLoader 的實現類,而是擴充了 ClassLoader 類加入了許可權方面的功能,加強了 ClassLoader 的安全性。
  • URLClassLoader 類和 JDK8 中的 URLClassLoader 類的程式碼是一樣的,它繼承自 SecureClassLoader,用來通過URl路徑從 jar 檔案和資料夾中載入類和資源。
  • BaseDexClassLoader 繼承自 ClassLoader,是抽象類 ClassLoader 的具體實現類,PathClassLoaderDexClassLoader 都繼承它。

下面看看執行一個 Android 程式需要用到幾種型別的類載入器

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        var classLoader = this.classLoader

        // 列印 ClassLoader 繼承關係
        while (classLoader != null) {
            Log.d("MainActivity", classLoader.toString())
            classLoader = classLoader.parent
        }
    }
}
複製程式碼

MainActivity 的類載入器列印出來,並且列印當前類載入器的父載入器,直到沒有父載入器,則終止迴圈。列印結果如下:

com.zhgqthomas.github.hotfixdemo D/MainActivity: dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.zhgqthomas.github.hotfixdemo-2/base.apk"],nativeLibraryDirectories=[/data/app/com.zhgqthomas.github.hotfixdemo-2/lib/arm64, /oem/lib64, /system/lib64, /vendor/lib64]]]

com.zhgqthomas.github.hotfixdemo D/MainActivity: java.lang.BootClassLoader@4d7e926
複製程式碼

可以看到有兩種類載入器,一種是 PathClassLoader,另一種則是 BootClassLoaderDexPathList 中包含了很多路徑,其中 /data/app/com.zhgqthomas.github.hotfixdemo-2/base.apk 就是示例應用安裝在手機上的位置。

雙親委託模式

類載入器查詢 Class 所採用的是雙親委託模式,**所謂雙親委託模式就是首先判斷該 Class 是否已經載入,如果沒有則不是自身去查詢而是委託給父載入器進行查詢,這樣依次的進行遞迴,直到委託到最頂層的BootstrapClassLoader,如果 BootstrapClassLoader 找到了該 Class,就會直接返回,如果沒找到,則繼續依次向下查詢,如果還沒找到則最後會交由自身去查詢。這是 JDK 中 ClassLoader 的實現邏輯,Android 中的 ClassLoaderfindBootstrapClassOrNull 方法的邏輯處理上存在差異。

// ClassLoader.java

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        // 委託父載入器進行查詢
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                }
            }
            return c;
    }
複製程式碼

上面的程式碼很容易理解,首先會查詢載入類是否已經被載入了,如果是直接返回,否則委託給父載入器進行查詢,直到沒有父載入器則會呼叫 findBootstrapClassOrNull 方法。

下面看一下 findBootstrapClassOrNullJDKAndroid 中分別是如果實現的

// JDK ClassLoader.java

    private Class<?> findBootstrapClassOrNull(String name)
    {
        if (!checkName(name)) return null;

        return findBootstrapClass(name);
    }

複製程式碼

JDKfindBootstrapClassOrNull 會最終交由 BootstrapClassLoader 去查詢 Class 檔案,上面提到過 BootstrapClassLoader 是由 C++ 實現的,所以 findBootstrapClass 是一個 native 的方法

// JDK ClassLoader.java

    private native Class<?> findBootstrapClass(String name);
複製程式碼

在 Android 中 findBootstrapClassOrNull 的實現跟 JDK 是有差別的

// Android 
    private Class<?> findBootstrapClassOrNull(String name)
    {
        return null;
    }
複製程式碼

Android 中因為不需要使用到 BootstrapClassLoader 所以該方法直接返回來 null

正是利用類載入器查詢 Class 採用的雙親委託模式,所以可以利用反射修改類載入器載入 dex 相關檔案的順序,從而達到熱修復的目的

類載入過程

通過上面分析可知

  • PathClassLoader 可以載入 Android 系統中的 dex 檔案
  • DexClassLoader 可以載入任意目錄的 dex/zip/apk/jar 檔案,但是要指定optimizedDirectory

通過程式碼可知這兩個類只是繼承了 BaseDexClassLoader,具體的實現依舊是由 BaseDexClassLoader 來完成。

BaseDexClassLoader

// libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java

public class BaseDexClassLoader extends ClassLoader {

    ...
    
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        this(dexPath, optimizedDirectory, librarySearchPath, parent, false);
    }

    /**
     * @hide
     */
    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String librarySearchPath, ClassLoader parent, boolean isTrusted) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, null, isTrusted);

        if (reporter != null) {
            reportClassLoaderChain();
        }
    }
    
    ...
    
    public BaseDexClassLoader(ByteBuffer[] dexFiles, ClassLoader parent) {
        // TODO We should support giving this a library search path maybe.
        super(parent);
        this.pathList = new DexPathList(this, dexFiles);
    }
    
    ...
}
複製程式碼

通過 BaseDexClassLoader 構造方法可以知道,最重要的是去初始化 pathList 也就是 DexPathList 這個類,該類主要是用於管理 dex 相關檔案

// libcore/dalvik/src/main/java/dalvik/system/BaseDexClassLoader.java

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions); // 查詢邏輯交給 DexPathList
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException(
                    "Didn't find class \"" + name + "\" on path: " + pathList);
            for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }
複製程式碼

BaseDexClassLoader 中最重要的是這個 findClass 方法,這個方法用來載入 dex 檔案中對應的 class 檔案。而最終是交由 DexPathList 類來處理實現 findClass

DexPathList

// libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

final class DexPathList {
    ...

    /** class definition context */
    private final ClassLoader definingContext;

    /**
     * List of dex/resource (class path) elements.
     * Should be called pathElements, but the Facebook app uses reflection
     * to modify 'dexElements' (http://b/7726934).
     */
    private Element[] dexElements;
    
    ...
    
    
    DexPathList(ClassLoader definingContext, String dexPath,
            String librarySearchPath, File optimizedDirectory, boolean isTrusted) {
       ...

        this.definingContext = definingContext;

        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        // save dexPath for BaseDexClassLoader
        this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory,
                                           suppressedExceptions, definingContext, isTrusted);
        ...
    }

}
複製程式碼

檢視 DexPathList 核心建構函式的程式碼可知,DexPathList 類通過 Element 來儲存 dex 路徑 ,並且通過 makeDexElements 函式來載入 dex 相關檔案,並返回 Element 集合

// libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

    private static Element[] makeDexElements(List<File> files, File optimizedDirectory,
            List<IOException> suppressedExceptions, ClassLoader loader, boolean isTrusted) {
      Element[] elements = new Element[files.size()];
      int elementsPos = 0;
      /*
       * Open all files and load the (direct or contained) dex files up front.
       */
      for (File file : files) {
          if (file.isDirectory()) {
              // We support directories for looking up resources. Looking up resources in
              // directories is useful for running libcore tests.
              elements[elementsPos++] = new Element(file);
          } else if (file.isFile()) {
              String name = file.getName();

              DexFile dex = null;
              if (name.endsWith(DEX_SUFFIX)) { // 判斷是否是 dex 檔案
                  // Raw dex file (not inside a zip/jar).
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                      if (dex != null) {
                          elements[elementsPos++] = new Element(dex, null);
                      }
                  } catch (IOException suppressed) {
                      System.logE("Unable to load dex file: " + file, suppressed);
                      suppressedExceptions.add(suppressed);
                  }
              } else { // 如果是 apk, jar, zip 等檔案
                  try {
                      dex = loadDexFile(file, optimizedDirectory, loader, elements);
                  } catch (IOException suppressed) {
                      /*
                       * IOException might get thrown "legitimately" by the DexFile constructor if
                       * the zip file turns out to be resource-only (that is, no classes.dex file
                       * in it).
                       * Let dex == null and hang on to the exception to add to the tea-leaves for
                       * when findClass returns null.
                       */
                      suppressedExceptions.add(suppressed);
                  }

                    // 將 dex 檔案或壓縮檔案包裝成 Element 物件,並新增到 Element 集合中
                  if (dex == null) {
                      elements[elementsPos++] = new Element(file);
                  } else {
                      elements[elementsPos++] = new Element(dex, file);
                  }
              }
              if (dex != null && isTrusted) {
                dex.setTrusted();
              }
          } else {
              System.logW("ClassLoader referenced unknown path: " + file);
          }
      }
      if (elementsPos != elements.length) {
          elements = Arrays.copyOf(elements, elementsPos);
      }
      return elements;
    }
複製程式碼

總體來說,DexPathList 的建構函式是將 dex 相關檔案(可能是 dex、apk、jar、zip , 這些型別在一開始時就定義好了)封裝成一個 Element 物件,最後新增到 Element 集合中

其實,Android 的類載入器不管是 PathClassLoader,還是 DexClassLoader,它們最後只認 dex 檔案,而 loadDexFile是載入 dex 檔案的核心方法,可以從 jar、apk、zip 中提取出 dex

// libcore/dalvik/src/main/java/dalvik/system/DexPathList.java

    public Class<?> findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            Class<?> clazz = element.findClass(name, definingContext, suppressed);
            if (clazz != null) {
                return clazz;
            }
        }

        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
    }
複製程式碼

DexPathList 的建構函式中已經初始化了 dexElements,所以這個方法就很好理解了,只是對 Element 陣列進行遍歷,一旦找到類名與 name 相同的類時,就直接返回這個 class,找不到則返回 null

熱修復實現

通過上面的分析可以知道執行一個 Android 程式是使用到 PathClassLoader,即 BaseDexClassLoader,而 apk 中的 dex 相關檔案都會儲存在 BaseDexClassLoaderpathList 物件的 dexElements 屬性中。

那麼熱修復的原理就是將改好 bug 的 dex 相關檔案放進 dexElements 集合的頭部,這樣遍歷時會首先遍歷修復好的 dex 並找到修復好的類,因為類載入器的雙親委託模式,舊 dex 中的存有 bug 的 class 是沒有機會上場的。這樣就能實現在沒有釋出新版本的情況下,修復現有的 bug class

手動實現熱修復功能

根據上面熱修復的原理,對應的思路可歸納如下

  1. 建立 BaseDexClassLoader 的子類 DexClassLoader 載入器
  2. 載入修復好的 class.dex (伺服器下載的修復包)
  3. 將自有的和系統的 dexElements 進行合併,並設定自由的 dexElements 優先順序
  4. 通過反射技術,賦值給系統的 pathList

熱修復 Demo 推薦

可以參考 Github 上的這個專案

參考

相關文章