熱修復原理學習(5)Dalvik下完整dex方案探索與初始化時機選擇

1.Dalvik下完整dex方案的新探索

1.1 冷啓動類加載修復

對於Android下的冷啓動類加載修復,最早的實現方案是QQ空間提出的dex插入方案。該方案的主要思想,就是把dex插入到ClassLoader索引路徑的最前面。這樣在加載一個類的時候,就會優先查找補丁中的類。後來微信的Tinker和手Q的QFix方案都基於該方案做了改進,而這類插入dex的方案,都會遇到一個嚴重的問題,那就是如果解決DVM下類的pre-verify問題。

如果一個類中直接引用到所有非系統類和該類在同一個dex中的話,這個類就會被打上 CLASS_ISPREVERIFIED標識,具體判定代碼可見虛擬機中的 verifyAndOptiomizeClass()函數。

我們先來看看騰訊的三大修複方案是如何解決這個問題的:

  • QQ空間
    在每個類中插入一個來自其它dex的hack.class,此讓所有類都無法滿足pre-verified條件
  • Tinker
    合成全量dex文件,這樣所有類在全量dex中解決,從而消除類重複帶來的衝突
  • QFix
    獲取虛擬機的某些底層函數,提前解析所有補丁類。以此繞過 pre-verify檢查

以上三種方法裏,QQ空間方案會侵入到打包流程,並且爲hack添加了一些臃腫的代碼。
QFix需要獲取底層虛擬機的函數,不夠穩定可靠。並且和空間方案一樣,有個大問題是無法新增public函數,後文會詳細講解。

現在看來比較好的方式,就是像Tinker那樣全量合成完整的新dex。Tinker的合成方案是從dex的方法和指令維度進行全量合成,雖然可以大大節省空間,但是由於對dex內容的比較粒度過細 ,實現較爲複雜,因此性能消耗比較嚴重。
實際上,dex的大小佔整個APK的比例是比較低的,而佔空間大的主要還是APK的資源文件。因此,Tinker方案的時空代價轉換的性價比不高。

其實,dex比較的最佳粒度,應該是在類的維度,它既不像方法和指令維度那樣細微,也不想bsbiff比較那般粗糙。在類的維度,可以達到時間和控件平衡的最佳效果。

1.2 一種新的全量Dex方案

一般來說,合成完整dex,思路就是把原有的dex和補丁包裏的dex重新合併成一個。
然而Sophix的思路是反過來的。

可以這樣考慮,既然補丁中已經有變動的類了,那麼只要在原先基線包的dex中,去掉補丁中也有的類。這樣,補丁+去除補丁類的基線包,不就等於新App中的所有類了嗎。

參照Android原生multi-dex的實現再來看這個方案,會有很好的理解。multi-dex是把一個APK裏用到的所有類拆分到 classes.dexclasses2.dexclasses3.dex等之中,而每個dex都只包含了部分的類定義,但單個dex也是可以加載的,因爲只要把所有的dex都加載進去,本dex中不存在的類就可以在運行期間在其他的dex中找到。

同理,基線包在dex在去掉了補丁中的類後,原有需要發生變更的類就被消除了,基線包dex裏就包含不變的類了。而這些不變的類在用到補丁中的新類時會自動地找到補丁dex,補丁包中的新類在需要用到不變的類時也會只找到基線包dex的類。
這樣基線包裏面不使用補丁類的類仍舊可以按照原來的邏輯做odex,最大程度地保證了dexopt的效果。

這麼一來,我們就不需要像傳統合成思路那樣去判斷類的增加和修改情況,也不需要處理合成時方法數超了的情況,對於dex的結構也不用進行破壞性重構。

現在,合成完整dex的問題就簡化爲如果在基線包dex裏面去掉補丁包中包含的所有類,接下來我們看一下dex中去除指定類的具體實現。

首先,來看下dex文件中header的結構:

struct DexHeader {
    u1  magic[8];           /* 版本標識 */
    u4  checksum;           /* adler32 檢驗 */
    u1  signature[kSHA1DigestLen]; /* SHA-1 哈希值 */
    u4  fileSize;           /* 整個文件大小 */
    u4  headerSize;         /* DexHeader 大小 */
    u4  endianTag;          /* 字節序標記 */
    u4  linkSize;           /* 鏈接段大小 */
    u4  linkOff;            /* 鏈接段偏移 */
    u4  mapOff;             /* DexMapList 的文件偏移 */
    u4  stringIdsSize;      /* DexStringId 的個數 */
    u4  stringIdsOff;       /* DexStringId 的文件偏移 */
    u4  typeIdsSize;        /* DexTypeId 的個數 */
    u4  typeIdsOff;         /* DexTypeId 的文件偏移 */
    u4  protoIdsSize;       /* DexProtoId 的個數 */
    u4  protoIdsOff;        /* DexProtoId 的文件偏移 */
    u4  fieldIdsSize;       /* DexFieldId 的個數 */
    u4  fieldIdsOff;        /* DexFieldId 的文件偏移 */
    u4  methodIdsSize;      /* DexMethodId 的個數 */
    u4  methodIdsOff;       /* DexMethodId 的文件偏移 */
    u4  classDefsSize;      /* DexClassDef 的個數 */
    u4  classDefsOff;       /* DexClassDef 的文件偏移 */
    u4  dataSize;           /* 數據段的大小 */
    u4  dataOff;            /* 數據段的文件偏移 */
};

DexHeader 是dex文件的頭部,用來描述整個dex文件中每個屬性在dex結構中的具體位置,從這些描述信息,我們能看出dex具備很多個屬性。如下所示:
在這裏插入圖片描述

數據名稱 解釋
header dex文件頭部,記錄整個dex文件的相關屬性
string_ids 字符串數據索引,記錄了每個字符串在數據區的偏移量
type_ids 類似數據索引,記錄了每個類型的字符串索引
proto_ids 原型數據索引,記錄了方法聲明的字符串,返回類型字符串,參數列表
field_ids 字段數據索引,記錄了所屬類,類型以及方法名
method_ids 類方法索引,記錄方法所屬類名,方法聲明以及方法名等信息
class_defs 類定義數據索引,記錄指定類各類信息,包括接口,超類,類數據偏移量
data 數據區,保存了各個類的真實數據
link_data 靜態鏈接文件中使用的數據。

這裏我們打算去除dex中的類,因此我們最關心的自然是這裏的 class_defs屬性。

需要注意的是,並不是要把某個類的所有信息都從dex移除,因爲如果這麼做,可能會導致dex的各個部分都發生變化,從而需要大量調整offset,這樣就會變得費時費力了,我們要做的,僅僅是使得在解析這個dex的時候找不到這個類的定義就可以了。
因此,只需要移除定義的入口,對於類的具體內容不進行刪除,這樣可以最大限度減少offset的修改。

我們來看虛擬機在dexopt的時候是如果找到某個dex中所有的類定義的,它是在 verifyAndOptimizeClasses()中,注意,和之前的 verifyAndOptimizeClass()是兩個方法,它也是在安裝Apk時調用的:

// dalvik/vm/analysis/DexPrepare.cpp
static void verifyAndOptimizeClasses(DexFile* pDexFile, bool doVerify,
    bool doOpt)
{
    u4 count = pDexFile->pHeader->classDefsSize;
    u4 idx;

    for (idx = 0; idx < count; idx++) {
        const DexClassDef* pClassDef;
        const char* classDescriptor;
        ClassObject* clazz;

        pClassDef = dexGetClassDef(pDexFile, idx);  // 1
        classDescriptor = dexStringByTypeIdx(pDexFile, pClassDef->classIdx);

        clazz = dvmLookupClass(classDescriptor, NULL, false);
        if (clazz != NULL) {
            verifyAndOptimizeClass(pDexFile, clazz, pClassDef, doVerify, doOpt); // 2

        } else {
            ALOGV("DexOpt: not optimizing unavailable class '%s'",
                classDescriptor);
        }
    }
}

註釋1:返回了 pDexFile下第idx個類的定義。
註釋2:調用我們熟知的 verifyAndOptimizeClass來對這個類進行類校驗和類優化。

我們來看下注釋1的 dexGetClassDef()方法:

// dalvik/libdex/DexFile.h
DEX_INLINE const DexClassDef* dexGetClassDef(const DexFile* pDexFile, u4 idx) {
    assert(idx < pDexFile->pHeader->classDefsSize);
    return &pDexFile->pClassDefs[idx]; //返回 pClassDefs的第idx元素
}

而這裏的 pClassDefs是這麼來的呢?下面是dex的賦值:

// dalvik/libdex/DexFile.cpp 
void dexFileSetupBasicPointers(DexFile* pDexFile, const u1* data) {
    DexHeader *pHeader = (DexHeader*) data;
    ...
    pDexFile->pClassDefs = (const DexClassDef*) (data + pHeader->classDefsOff);
    ...
}

由此可以看出,一個類的所DexClassDef,也就是類定義,是從 pHeader->classDefsOff偏移處開始的,依次呈線性排列的,一個dex裏面一共有 pHeader->classDefsSize個類定義。

因此,我們就可以直接找到pHead->classDefsOff偏移處,遍歷所有的DexClassDef,如果發現這個 DexClassDef的類名包含在補丁中,就把它移除,實現下圖所示的效果:
在這裏插入圖片描述
接下來,只要修改 pHeader->classDefsSize,把dex中類的數目改爲去除補丁中的類之後的數目即可。

我們只是去除了類的定義,而對於類的方法實體以及其他dex信息不做移除,雖然這樣會把這個被移除類的無用信息殘留在dex文件中,但這些信息並不佔用太多空間。移除類操作的方法對dex的處理速度提升幫助是很大的。

1.3 對於Application的處理

由此,我們實現了完整的dex合成。但仍然有個問題,這個問題所有完整dex替換方案都會遇到,那就是對Application的處理。

總所周知,Application是整個App的入口,因此,在進入到替換的完整dex之前,一定會通過Application的代碼,然而Application必然是加載在原來的dex裏面的。只有在補丁加載後使用的類,會在新的完整dex裏面找到。

因此,在加載補丁後,如果Application類使用其他新dex裏的類,由於在不同的一個dex裏,如果Application被打上了pre-verified標識,這時就會拋出異常。

對此,我們解決辦法很簡單,既然被打上了pre-verified標識,那麼,清除它就是了。

類的標識位於 ClassObjectaccessFlags成員中,而 pre-verifiyed標識的定義是 CLASS_ISPREVERIFIED = (1 << 16),因此,我們只需要在JNI層清除掉它即可:

classObj->accessFlags &= ~CLASS_ISPREVERIFIED;

這樣,在 dvmResolveClass()中找到新的dex裏的類後,由於 CLASS_ISPREVERIFIED標識被清空,就不會判斷所在dex是否相同,從而成功避免拋出異常。

接下來,我們來比對目前市場上其他完整dex方案是怎麼做的。

(1)Tinker
Tinker的方案是在 AndroidManifest.xml聲明中就要求開發者將自己的Application直接替換成 TinkerApplication。而對於真正App的Application,要在初始化TinkerApplication時作爲參數傳入。這樣 TinkerApplication會接管這個傳入的Application,在生命週期回調時通過反射的方式調用實際 Application的相關回調邏輯。這麼做確實很好地將入口Application和用戶代碼隔離開,不過需要改造原有的Application,如果對Application有更多擴展,接入成本也是比較高的。

(2)Amigo
Amigo的方案是在編譯過程中,用Amigo自定義的gradle插件將App的Application替換成了 Amigo自己的另一個Application,並且將原來的Application的name保存了起來,該修復的問題都修復完後再調用之前保存的Application的 attach(context)。將它回調到loadedApk()中,最後調用它的onCreate(),執行原有Application的邏輯,這種方式只是在代碼層面開發者無感知,但其實在編譯期間偷偷幫用戶做了替換,有點掩耳盜鈴的意思,並且這種對系統做反射替換本身也是由一定的風險的。

相比之下,Sophix的Application處理方案既沒有侵入編譯過程,也不需要進行反射替換,所有的兼容操作都在運行期自動做好。接入過程及其順滑。

1.4 dvmOptReslveClass問題與對策

然而Sophix這種清除標識的方案並非一帆風順,在開發過程中發現,如果這個入口Application是沒有pre-verified,反而有更大的問題。

這個問題是,DVM如果發現某個類沒有 pre-verified,就會在初始化這個類的時候做verify操作,將會掃描這個類的所有代碼,在掃描過程中對這個類代碼使用到的類都要進行 dvmOptResolveClass()操作。

這個 dvmOptResolveClass()正是罪魁禍首,它會在解析的時候對使用到的類進行初始化,而這個邏輯是發生在Application類初始化的時候。此時補丁還沒有進行加載,所以就會提前加載到原始dex中的類。接下來當補丁類加載完畢後,當這些已經加載的類用到新dex中的類,並且又是 pre-verified時就會報錯。

這裏最大的問題是在於我們無法把補丁加載提前到 dvmOptResolveClass之前,因爲在一個App的生命週期裏,沒有可能到達比入口Application初始化更早的時期了。

而這個問題常見於多dex情形,當存在多dex時,無法保證Application用到的類和它處於同個dex中。如果只有一個dex,一般就不會有這個問題。

多dex情況下要想解決這個問題,有兩種辦法:

  • 讓Application用到的所有非系統的類和Application位於同一個dex中,這就可以保證pre-verified標識被打上,避免進入 dvmOptResolveClass,而在補丁加載完之後,我們再清楚pre-verified標識,使得接下來使用其他類也不會報錯
  • 把Applicaiton裏面除了熱修復框架代碼以外的其他代碼都剝離開,單獨提出放到一個其他類裏面,這樣使得Application不會直接用到過多的非系統類,這樣,保證這個單獨拿出來的類和Application處於同一個dex的概率還是比較大的。如果想要更保險,Application可以採用反射方式方式訪問這個單獨得類,這樣就徹底把Application和其他類隔絕開了。

第一種方法實現較爲簡單,因爲Android官方multi-dex機制會自動將Application用到的類都打包到主dex中,所以只要把熱修復初始化放在 attachBaseContext()的最前面,一般都沒有問題。
而第二種方法稍加繁瑣,是在代碼架構層面進行重新設計,不過可以一勞永逸的解決問題。

2. 入口類與初始化時機選擇

2.1 初始化時機

冷啓動完整修複方案,本質就是替換掉整個原有的dex文件。然而“完整替換”只是一種理想化的設想,實際上無法做到“完整的”。原因是熱修復的初始化本身也是一段代碼。必須調用到這段代碼,熱修復才能執行完成,因此調用到熱修復的類,肯定是使用者自己的類,這個類是無法被熱修復影響到的,並且它只存在於原始安裝包的 classes.dex中。如果要使熱修復類之前使用的其他類最少,只能放在Application類入口中。

那麼,放在Activity類裏面是不是也可以呢?當然,如果你的App裏面沒有Application,放到Activity裏面似乎也沒有太大的問題,並且簡單測試好像也能正常工作。

但是,如果你的AndroidManifest中註冊了ContentProvider,事情就沒有那麼順利了。ContentProvider的onCreate方法優先調用於Activity的onCreate方法。這就使得我們可能還沒有完成熱修復替換,就先執行到了 ContentProvider中的業務邏輯代碼,導致某些類被提前引入。提前引入其他類的危害我們在之前的章節已經說明,這不僅會導致這些類無法修復,更可能引起 pre-verify異常,因此,只有把初始化放在Application類中,才能保證不會錯誤地提早引入類。

如果放在Application中,又有兩種選擇:放在onCreate()中或者放在 attachBaseContext()中。

放在 attachBaseContext()中自然是沒有問題的,因爲他是Application中最早被執行的代碼,但需要注意的是,在attachBaseContext裏面有很多限制,此時App申請的權限還沒有授予完成,所以會遇到無法訪問網絡之類的問題,因此在attachBaseContext裏面可以執行初始化,但是不可以進行網絡請求下載新補丁。

那放在Application的onCreate中可以嗎?簡單測試似乎沒有什麼問題。然而,它和之前的Activity的onCreate方法一樣,執行時間會晚於ContentProvider的onCreate方法。

當然,如果你的AndroidManifest裏面沒有註冊過ContentProvider,並且能夠保證引入的第三方庫的AndoridManifest裏面也沒有註冊,放在onCreate裏面就沒有什麼問題。不過保險起見,爲了避免以後某天項目在無意中引入,還是放在attachBaseContext裏面最好。

2.2 防不勝防的細節錯誤

在進行初始化的時候,經常容易錯誤地提早引入其他類。

下面這段代碼是Sophix的熱修復初始化代碼,SophixManager需要在設置各種屬性後調用 initialize()方法進行初始化,就以這段代碼爲例:

public class SampleApplication extends Application {
    LocalStorageUtil localStorageUtil = new LocalStorageUtil();

    @Override
    protected void attachBaseContext(Context base) {
        CrashReport.initCrashReport(this);
        SophixWrApper.init(this);
        MultiDex.install(this);
        localStorageUtil.init(this);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        SophixWrApper.query();
    }

    public LocalStorageUtil getLocalStorageUtil() {
        return localStorageUtil;
    }

    static private class SophixWrApper {
        static void init(Application context) {
            final SophixManager instance = SophixManager.getInstance();
            instance.setContext(context)
                    .setAppVersion(BuildConfig.VERSION_NAME)
                    .setPatchLoadStatusStub(new PatchLoadStatusListener() {
                        @Override
                        public void onLoad(final int mode,
                                           final int code,
                                           final String info,
                                           final int handlePatchVersion) {
                            if (code == PatchStatus.CODE_LOAD_SUCCESS) {
                                MyLogger.d("", "Sophix load patch success");
                            } else if (code == PatchStatus.CODE_LOAD_RELAUNCH) {
                                MyLogger.d("", "Sophix preload patch success");
                            }
                        }
                    });
            instance.initialze();
        }

        static void query() {
            SophixManager.getInstance().queryAndLoadNewPatch();
        }
    }
}

這段簡單的代碼裏面,包含了許多開發者都會出現的錯誤,這裏一一指出每個問題:

  1. CreashReport.initCrashReport(this)在Sophix熱修復初始化之前提早引入了,必然是不行的
  2. 雖然初始化確實是在attachBaseContext裏面,但是包裝了一個SophixWrApper類,這會導致初始化之前提前引入類,因此Sophix的初始化不可以包裝在其他類中。
  3. setAppVersion的時候使用了BuildConfig類,這個BuildConfig類是Android編譯期間動態生成的,也屬於非系統類,如果在這裏使用就會有提前引入的問題,這裏建議用PackageManager來獲取版本號。
  4. LocalStorageUtil直接在聲明處賦值了它的示例,這個賦值其實是隱式發生在對象構造函數中的,這個時候甚至是更早與attachBaseContext的,因此也是不行的,需要在初始化之後才能進行賦值
  5. 在回調用中使用了MyLogger,在回調狀態的時候引入很可能熱修復還未初始化完畢,因此這裏需要換位系統類android.utils.log
  6. MultiDex.install(this)調用放在了熱修復初始化後,這樣做雖然沒有引入類的問題,但是可能會導致後面熱修復框架初始化的時候找不到其他不在主dex中的熱修復框架內部類,因此需要把它提前到熱修復初始化之前。而提早引入MultiDex類不會帶來問題,因爲在熱修復初始化之後,再也沒有調用到這個MultiDex類的地方。
  7. 最後,經常會有人一樓了 syper.attachBaseContext(base),如果缺少它,後面都無法正常運行。

現在來看一下修改後的代碼:

public class SampleApplication extends Application {
    LocalStorageUtil localStorageUtil;

    @Override
    protected void attachBaseContext(Context base) {
        super.attachBaseContext(base);
        MultiDex.install(this);
        initSophix(this);
        CrashReport.initCrashReport(this);
        initlocalStorageUtil();
    }
    
    @Override
    public void onCreate() {
        super.onCreate();
        SophixManager.getInstance().queryAndLoadNewPatch();
    }
    
    private void initlocalStorageUtil(){
        localStorageUtil = new LocalStorageUtil();
        localStorageUtil.init(this);
    }

    public LocalStorageUtil getLocalStorageUtil() {
        return localStorageUtil;
    }
    
    private void initSophix(Application context) {
        String AppVersion = "0";
        try {
            AppVersion = this.getPackageManager()
                    .getPackageInfo(this.getPackageName(),0)
                    .versionName;
        } catch (PackageManager.NameNotFoundException e) {
            e.printStackTrace();
        }
        final SophixManager instance = SophixManager.getInstance();
        instance.setContext(context)
                .setAppVersion(AppVersion)
                .setAppKey(null)
                .setEnableDebug(false)
                .setEnableFullLog()
                .setPatchLoadStatusStub(new PatchLoadStatusListener() {
                    @Override
                    public void onLoad(final int mode,
                                       final int code,
                                       final String info,
                                       final int handlePatchVersion) {
                        if (code == PatchStatus.CODE_LOAD_SUCCESS) {
                            Log.d("", "Sophix load patch success");
                        } else if (code == PatchStatus.CODE_LOAD_RELAUNCH) {
                            Log.d("", "Sophix preload patch success");
                        }
                    }
                });
        instance.initialze();
    }
}

這樣就萬無一失了。這裏初始化放到獨立的initSophix是沒有關係的,因爲是入口Application自己的方法,所以不會新引入任何其他類。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章