Android進階(八)熱修復基本原理

村口大爺發表於2019-02-15

一、程式碼修復

1、類載入方案

(1)Dex分包原理

單個Dex檔案裡面方法數不能超過65536個方法。

(1)原因:
因為android會把每一個類的方法id檢索起來,存在一個連結串列結構裡面。但是這個連結串列的長度是用一個short型別來儲存的, short佔兩個位元組(儲存-2的15次方到2的15次方-1,即-32768~32767),最大儲存的數量就是65536。

(2)解決方案:

  • 精簡方法數量,刪除沒用到的類、方法、第三方庫。
  • 使用ProGuard去掉一些未使用的程式碼
  • 對部分模組採用本地外掛化的方式。
  • 分割Dex

Dex分包方案主要做的是在打包時將應用程式碼分成多個Dex,將應用啟動時必須用到的類和這些類的直接引用類放到主Dex中,其他程式碼放到次Dex中。當應用啟動時先載入主Dex,等到應用啟動後再動態的載入次Dex。

(2)類載入修復方案

如果Key.Class檔案中存在異常,將該Class檔案修復後,將其打入Patch.dex的補丁包
(1) 方案一:
通過反射獲取到PathClassLoader中的DexPathList,然後再拿到 DexPathList中的Element陣列,將Patch.dex放在Element陣列dexElements的第一個元素,最後將陣列進行合併後並重新設定回去。在進行類載入的時候,由於ClassLoader的雙親委託機制,該類只被載入一次,也就是說Patch.dex中的Key.Class會被載入。

Android進階(八)熱修復基本原理
(2)方案二:
提供dex差量包patch.dex,將patch.dex與應用的classes.dex合併成一個完整的dex,完整dex載入後得到dexFile物件,作為引數構建一個Element物件,然後整體替換掉舊的dex-Elements陣列。(Tinker)
Android進階(八)熱修復基本原理

(3)類載入方案的限制

方案一:

  • 由於類是無法進行解除安裝,所以類如果需要重新載入,則需要重啟App,所以類載入修復方案不是即時生效的。
  • 在ART模式下,如果類修改了結構,就會出現記憶體錯亂的問題。為了解決這個問題,就必須把所有相關的呼叫類、父類子類等等全部載入到patch.dex中,導致補丁包大,耗時嚴重。

方案二:

  • 下次啟動修復
  • dex合併記憶體消耗可能導致OOM,最終dex合併失敗

2、底層替換方案

(1)基本方案

主要是在Native層替換原有方法,ArtMethod結構體中包含了Java方法的所有資訊,包括執行入口、訪問許可權、所屬類和程式碼執行地址等。替換ArtMethod結構體中的欄位或者替換整個ArtMethod結構體,就是底層替換方案。由於直接替換了方法,可以立即生效不需要重啟。

(2)優缺點

(1)缺點

  • 不能夠增減原有類的方法和欄位,如果我們增加了方法數,那麼方法索引數也會增加,這樣訪問方法時會無法通過索引找到正確的方法。
  • 平臺相容性問題,如果廠商對ArtMethod結構體進行了修改,替換機制就有問題。

(2)優點

  • Bug修復的即時性
  • 生成的PATCH體積小,效能影響低

二、資源修復

1、Instant Run

核心程式碼:runtime/MonkeyPatcher.java

#MonkeyPatcher
public static void monkeyPatchExistingResources(@Nullable Context context,
                                                @Nullable String externalResourceFile,
                                                @Nullable Collection<Activity> activities) {
    ......                                        
    try {
        // Create a new AssetManager instance and point it to the resources installed under
        // (1)通過反射建立了一個newAssetManager,呼叫addAssetPath新增了sdcard上的資源包
        AssetManager newAssetManager = AssetManager.class.getConstructor().newInstance();
        Method mAddAssetPath = AssetManager.class.getDeclaredMethod("addAssetPath", String.class);
        mAddAssetPath.setAccessible(true);
        if (((Integer) mAddAssetPath.invoke(newAssetManager, externalResourceFile)) == 0) {
            throw new IllegalStateException("Could not create new AssetManager");
        }
        // Kitkat needs this method call, Lollipop doesn't. However, it doesn't seem to cause any harm
        // in L, so we do it unconditionally.
        Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod("ensureStringBlocks");
        mEnsureStringBlocks.setAccessible(true);
        mEnsureStringBlocks.invoke(newAssetManager);
        if (activities != null) {
            //(2)反射獲取Activity中AssetManager的引用,替換成新建立的newAssetManager
            for (Activity activity : activities) {
                Resources resources = activity.getResources();
                try {
                    Field mAssets = Resources.class.getDeclaredField("mAssets");
                    mAssets.setAccessible(true);
                    mAssets.set(resources, newAssetManager);
                } catch (Throwable ignore) {
                    Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
                    mResourcesImpl.setAccessible(true);
                    Object resourceImpl = mResourcesImpl.get(resources);
                    Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
                    implAssets.setAccessible(true);
                    implAssets.set(resourceImpl, newAssetManager);
                }
                Resources.Theme theme = activity.getTheme();
                try {
                    try {
                        Field ma = Resources.Theme.class.getDeclaredField("mAssets");
                        ma.setAccessible(true);
                        ma.set(theme, newAssetManager);
                    } catch (NoSuchFieldException ignore) {
                        Field themeField = Resources.Theme.class.getDeclaredField("mThemeImpl");
                        themeField.setAccessible(true);
                        Object impl = themeField.get(theme);
                        Field ma = impl.getClass().getDeclaredField("mAssets");
                        ma.setAccessible(true);
                        ma.set(impl, newAssetManager);
                    }
                ......
        }
        //(3)遍歷Resource弱引用的集合,將AssetManager替換成newAssetManager
        for (WeakReference<Resources> wr : references) {
            Resources resources = wr.get();
            if (resources != null) {
                // Set the AssetManager of the Resources instance to our brand new one
                try {
                    Field mAssets = Resources.class.getDeclaredField("mAssets");
                    mAssets.setAccessible(true);
                    mAssets.set(resources, newAssetManager);
                } catch (Throwable ignore) {
                    Field mResourcesImpl = Resources.class.getDeclaredField("mResourcesImpl");
                    mResourcesImpl.setAccessible(true);
                    Object resourceImpl = mResourcesImpl.get(resources);
                    Field implAssets = resourceImpl.getClass().getDeclaredField("mAssets");
                    implAssets.setAccessible(true);
                    implAssets.set(resourceImpl, newAssetManager);
                }
                resources.updateConfiguration(resources.getConfiguration(), resources.getDisplayMetrics());
            }
        }
    } catch (Throwable e) {
        throw new IllegalStateException(e);
    }
}
複製程式碼
  • 反射構建新的AssetManager,並反射呼叫addAssertPath載入sdcard中的新資源包,這樣就得到一個含有所有新資源的AssetManager
  • 將原來引用到AssetManager的地方,通過反射把引用處替換為新的AssetManager

2、資源包替換(Sophix)

預設由Android SDK編譯出來的apk,其資源包的package id為0x7f。framework-res.jar的資源package id為0x01

  • 構造一個package id為0x66的資源包(非0x7f和0x01),只包含已經改變的資源項。
  • 由於不與已經載入的Ox7f衝突,所以可以通過原有的AssetManager的addAssetPath載入這個包。

三、SO庫修復

本質是對native方法的修復和替換

1、so庫載入

(1)通過以下方法載入so庫

#System
public static void loadLibrary(String libname) {
    Runtime.getRuntime().loadLibrary0(VMStack.getCallingClassLoader(), libname);
}
引數為so庫名稱,位於apk的lib目錄下

public static void load(String filename) {
    Runtime.getRuntime().load0(VMStack.getStackClass1(), filename);
}
載入外部自定義so庫檔案,引數為so庫在磁碟中的完整路徑
複製程式碼
private static native String nativeLoad(String filename, ClassLoader loader, String librarySearchPath);
複製程式碼

最終都是呼叫了native方法nativeLoad,引數fileName為so在磁碟中的完整路徑名

(2)遍歷nativeLibraryDirectories目錄

#DexPathList
public String findLibrary(String libraryName) {
    String fileName = System.mapLibraryName(libraryName);
    for (File directory : nativeLibraryDirectories) {
        File file = new File(directory, fileName);
        if (file.exists() && file.isFile() && file.canRead()) {
            return file.getPath();
        }
    }
    return null;
}
複製程式碼

類似於類載入的findClass方法,在陣列中每一個元素對應一個so庫,最終返回了so的路徑。如果將so補丁新增到陣列的最前面,在呼叫方法載入so庫時,會先將補丁so的路徑返回。

2、SO修復方案

(1)介面替換

提供方法替代System.loadLibrary方法

  • 如果存在補丁so,則載入補丁so庫,不去載入apk安裝目錄下的so庫
  • 如果不存在補丁so,呼叫System.loadLibrary去載入安裝apk目錄下的so庫

(2)反射注入

因為載入so庫會遍歷nativeLibraryDirectories

  • 通過反射將補丁so庫的路徑插入到nativeLibraryDirectories陣列的最前面
  • 遍歷nativeLibraryDirectories時,就會將補丁so庫進行返回並載入,從而達到修復目的

四、熱修復框架分析

Android進階(八)熱修復基本原理

  • 底層替換方案:阿里的AndFix、HotFix
  • 類載入方案:QQ空間補丁技術、微信的Tinker方案、餓了麼的Amigo
  • 二者結合:Sophix

參考資料:

相關文章