救我於水深火熱的「熱修復」

語言: CN / TW / HK

上週五線上專案出現了緊急缺陷,無奈之下週六苦逼加班發補丁:sob:,唯一值得欣慰的是由於出現缺陷的功能會在今天通過 ABTest 下發,補丁趕在了大推之前。剛好週日在家閒著,就寫一下「救我於水深火熱的熱修復」。

希望當你看完這篇文章之後,能夠了解到應用 熱修復 它並不難,也不需要自己造輪子,業界很多優秀的框架如 TinkerRobustSophix 等。

如果專案還沒有支援這個熱更能力,希望你能嘗試折騰慢慢接入,這不僅僅能學習到新知識也能為服務專案提供容錯能力。

文章篇幅比較長,希望各位看官能耐心看完,掌握整體思路並有所收穫。

程式設計是為了業務解決問題,學習程式設計的核心是掌握程式實現的思路,而程式碼只是一種實現程式的工具。

下面從文章圍繞 技術原理 - 技術選型實踐流程 展開。

技術原理

熱修復按照類修復時機可分為 類冷修復類熱更新

所謂 類冷修復 是指應用重啟之後通過載入修復後的類檔案來修復類已知的問題,而 類熱更新 則是不需要重啟應用前提下修復類已知的問題。

另外熱更修復的物件還可包括 SO庫資原始檔

下面針對 類冷修復類熱更新SO庫修復資原始檔修復 進行了解。

類冷修復

一個 Class檔案 若已被 JVM虛擬機器 所載入,只能通過重啟手段解決來清除虛擬機器中已儲存的類資訊。

我們以 QZone 插樁方案微信 tinker方案 方案為分析,並引用 Sophix方案 的法做對比。

QZone方案

一個 ClassLoader 可載入多個 DEX檔案 ,每一個 DEX檔案 被載入後在記憶體中表現為一個 Element 物件,多個 DEX檔案 被載入後則排列成一個有序陣列 dexElements 。 對於 ClassLoader 不熟悉的朋友,建議先看看連結裡的儲備知識。

如果類已被 ClassLoader 載入,那麼查詢其對應 class 物件是通過呼叫 findClass(String name, List<Throwable> suppressed) 方法實現。整個過程中如果存在已查詢的 class 物件 ,則直接返回該 class 物件。所以 QZone 方案是把修復過的類打包成新的 DEX檔案 ,把該檔案優先載入後插到 dexElements 中且排在了待修復類所在 Element 物件前面。

這個方案涉及到類校驗,可能會因為 DEX檔案 被優化而導致異常:當我們第一次安裝 APK 時,虛擬機器如果檢測到有一項 verify 引數被開啟,則會對 DEX檔案 執行 dexopt 優化。如果使用上述方案插入一個 DEX檔案 ,則會先執行 dexopt ,這個過程可能會丟擲異常 dvmThrowIllegalAccessError

通過擷取 DexPrepare.cpp#verifyAndOptimizeClass 核心程式碼並做註釋闡述:

static void verifyAndOptimizeClass(DexFile* pDexFile, ClassObject* clazz,
    const DexClassDef* pClassDef, bool doVerify, bool doOpt)
{
    if (doVerify) {
  
         // 目的在於防止類外部被篡改。
         // 會對類的 static 方法,private 方法,建構函式,虛擬函式(可被繼承的函式) 進行校驗。
         // 如果類的所有方法中直接引用到的第一層類和當前類是在同一個 dex 檔案,則會返回 true
        if (dvmVerifyClass(clazz)) {
  
            // 如果滿足校驗規則,則打上 CLASS_ISPREVERIFIED,設定 verified 為 true
            ((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISPREVERIFIED;	 
            verified = true;
        } 
    }
    if (doOpt) {
        bool needVerify = (gDvm.dexOptMode == OPTIMIZE_MODE_VERIFIED ||
                           gDvm.dexOptMode == OPTIMIZE_MODE_FULL);
  
        if (verified || needVerify) {
  
            //把部分指令優化成虛擬機器內部指令,為了提升方法的執行速度。
            dvmOptimizeClass(clazz, false);  //Optimize class
            
            // 再打上 CLASS_ISOPTIMIZED
            ((DexClassDef*)pClassDef)->accessFlags |= CLASS_ISOPTIMIZED; 
        }
    }
}

所以只需要理解如果一個類滿足校驗條件,就會被打上 CLASS_ISPREVERIFIED 。具體做法是去校驗 Class 所有 directMethodvirtualMethod ,包含了:

  • static 方法
  • private 方法
  • 構造器方法
  • 虛擬函式
  • ...

這些方法中第一層級關係引用到的類是在同一個 DEX檔案 ,則會被打上校驗通過被打上 CLASS_ISPREVERIFIED

那麼被打上 CLASS_ISPREVERIFIED 那麼為何會有異常呢?

假如原先有個 DEX檔案 中類 B 引用了類 A ,舊的類 A 與類 B 在同一個 DEX檔案 ,則 B 會被打上 CLASS_ISPREVERIFIED ,現在修復 DEX檔案 包含了類 A ,當類 B 某個方法引用到類 A 時嘗試去解析類 A

通過擷取 Resolve.cpp#dvmResolveClass 核心程式碼並做註釋闡述:

ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,bool fromUnverifiedConstant){
    if (resClass != NULL) {
  
        //此時 B 類已經被打上 CLASS_ISPREVERIFIED,滿足條件
        if (!fromUnverifiedConstant &&
            IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED)) 
        {
            //被引用類 A
            ClassObject* resClassCheck = resClass;   
      
            //發現類 A 和 類 B 不在同一個 dex
            if (referrer->pDvmDex != resClassCheck->pDvmDex &&
                resClassCheck->classLoader != NULL)  
            {
                dvmThrowIllegalAccessError(
                    "Class ref in pre-verified class resolved to unexpected "
                    "implementation");
                return NULL;
            }
        }
        dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);
    } 
}

為了解決類校驗的問題,需要避免類被打上 CLASS_ISPREVERIFIED ,那麼只需要保證 dvmVerifyClass 返回 false 即可。

QZone 的做法是使用位元組碼修改技術,在所有 class 構造器中引用一個 幫助類 ,該類單獨存放在一個 DEX檔案 中,就可以實現所有類都不會被打上 CLASS_ISPREVERIFIED 標誌,進而避免在 dvmResolveClass 解析中出現異常。

上述例子類 B 由於引用類幫助類進而不會被打上 CLASS_ISPREVERIFIED ,所以載入修復後的類 A 也不會有問題。

當然這樣的做法也存在的問題與限制:由於類的載入涉及 dvmResolveClassdvmLinkClassdvmInitClass 三個階段。

dvmInitClass 會在類解析完並嘗試初始化類時執行,如果類沒有被打上 CLASS_ISPREVERIFIEDCLASS_ISOPTIMIZED ,校驗和優化都會在該階段進行。

正常情況下類的校驗和優化應該在 APK 第一次安裝的時候執行 dexopt 操作時執行,但是我們干預了 CLASS_ISPREVERIFIED 的設定流程導致在同一時間載入大量類且進行校驗及優化,容易在應用啟動時出現白屏。

手Q方案

為了避免插樁帶來的效能問題,手Q則選擇在 dvmResolveClass 避開了 CLASS_ISPREVERIFIED 相關邏輯。

參考上面 Resolve.cpp#dvmResolveClass 的核心邏輯可知:

ClassObject* dvmResolveClass(const ClassObject* referrer, u4 classIdx,bool fromUnverifiedConstant){

	DvmDex* pDvmDex = referrer->pDvmDex;
	
	 //從dex快取中查詢類 class,則直接返回
	 resClass = dvmDexGetResolvedClass(pDvmDex, classIdx);
    if (resClass != NULL)
        return resClass;
     
     //... resClass賦值工作
    if (resClass != NULL) {

       //記住 fromUnverifiedConstant 這個變數
       if (!fromUnverifiedConstant &&IS_CLASS_FLAG_SET(referrer, CLASS_ISPREVERIFIED)){
          //...類校驗流程
        }
  
		     //已經解析的類放入 dex 快取
       dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);
    }
}
  • dvmDexGetResolvedClass 方法是嘗試從 dex 快取中查詢引用的類,找到了就直接返回;
  • dvmDexSetResolvedClass 方法是將已經解析的類存入 dex 快取中。

所以只需要將補丁類 A 提前解析並設定 fromUnverifiedConstant 為 true 繞過類校驗,然後把 A 儲存 dex 快取中就可以達到效果。這一步可以通過 jni 主動呼叫 dalvik#dvmRsolveClass 方法實現。

後續引用到該補丁類 A 的時候就可以直接從 dex 快取中找到。當類 B 在校驗是否和類 A 在同一個 dex 時是通過以下條件:

referrer->pDvmDex != resClassCheck->pDvmDex

如果不打破這個條件,依然會出現異常。所以對補丁類 A 進行 dex 快取時拿到的 pDvmDex 應該指向原來類 A 所在的 dex 。

那麼在 dalvik#dvmRsolveClass 的過程中, referrerclassIdx 要怎麼確定?

  • referrer 為和原類****同個 dex 下的一個任意類即可。但是需要呼叫 dvmFindLoadedClass 來實現,在補丁注入之後,在每個 dex 中找一個已經成功載入的引用類的描述符作為引數來實現。比如主 dex 就用 Application 類描述符。其他 dex, 手Q 確保了每一個份 dex 有一個空類完成初始化,使用的是空類的描述符。
  • classIdx 為原類 A 在所 dex 下的類索引 ID,通過 dexdump -h 指令獲取。

這套方案可完美避開插樁所帶來的類校驗影響,但假如在某個待修復多型類中新增方法,可能會導致修復前類的 vtable 的索引與修復後類的 vtable 索引對不上。因此修復後的類不能新增 public 函式,同樣 QZone 也存在這樣的問題。所以只能尋找全量合成新 dex 檔案的方案。

Tinker方案

tinker方案是全量替換 DEX 檔案。

使用自研演算法通過計算重新生成新的 DEX檔案 與待修復的 DEX檔案 差異進而得到新的 DEX檔案 ,該 DEX檔案 檔案被下發到客戶端與待修復的 DEX檔案 重新進行合併生成新的全量 DEX檔案 ,並把其載入後插到 dexElements 陣列的最前面。

QZone 方案不一樣的是,由於被修復的類與原類是在同一個 DEX檔案 ,所以不存在類校驗問題。

由於不同 Android 虛擬機器下采用不同的 DEX 載入邏輯,所以在處理全量 DEX 時也有差異。

比如 Dalvik虛擬機器 呼叫 Dalvik_dalvik_system_DexFile_openDexFileNative 來載入 DEX 檔案,如果是一個壓縮包則只會載入第一個 DEX 檔案。而 art虛擬機器 則是呼叫 LoadDexFiles , 載入的是 oat 中多個 DEX 檔案。

Art虛擬機器 載入的壓縮包下,可能存在多個 DEX檔案 ,main dex為 classes.dex ,其他的 DEX檔案 依次命名為 classes(2,3,4...)dex 。假如某個 classesNdex 出現了問題,tinker 會重新合成 classesNdex 。修復流程為:

  1. 保留原來修復前 classesNdex Dex 檔案
  2. 獲取修復後的 classedNdexFix dex 檔案
  3. 使用演算法計算得到 classesNdexPatch 補丁檔案
  4. 下發 classesNdexPatch 補丁檔案在客戶端與 classesNdex DEX 檔案進行合併,得到 classedNdexFix Dex 檔案
  5. 重啟應用,提前載入 classedNdexFix Dex 檔案修復問題。

這種全量合成修復 DEX檔案 的做法,確保了復前後的類在同一個 DEX檔案 中,遵循原來虛擬機器所有校驗方式,避開了 QZone方案 面臨的類校驗問題。

Sophix方案

阿里 Sophix 方案認為

既然 art 能載入壓縮檔案中的多個 dex 且優先載入 classes.dex,如果把補丁 dex 作為 classes.dex,然後 apk 中原來的 dex 改成 classes(2,3,4...)dex,然後重新打包壓縮檔案,讓 DexFile.loadDex 得到 DexFile 物件,並最終替換掉舊的 dexElements 陣列就可以了。

但是這種方案下, Art虛擬機器 需要重新載入整個壓縮檔案,針對每一個 dex 執行 dexoat 來得到 odex 的過程是很耗時的。需要把整個過程事務化,在接收到服務端補丁之後再啟動一個子執行緒在後臺進行非同步處理。如果下次重啟之後發現存在處理完的完整 odex 檔案集,才進行處理。

同時認為

針對 dalvik 下,全量合成 dex 可參照 multi-dex 方案,在原來 dex 檔案中剔除需要修復的類,然後再合併進修復的類。並不需要像 tinker 方案中針對 dex 的所有內容進行比較,粒度非常細也非常複雜,以類作為粒度作為替換是較佳選擇。

但是如果 Application 載入了新 dex 的類 Application 剛好被打上 CLASS_ISPREVERIFIED ,那麼就會面臨前面 QZone 方案的類校驗問題,實際上所有全量合成的方案都會面臨這個問題。 tinker 使用的是 TinkerApplication 接管應用 Application 並在生命週期回撥的時候反射呼叫原 Application 的對應方案。而 Sophix 也是使用 SohpixStubApplication 做了類似的事情。

小結一波

由於涉及的技術非常多,細緻的實現可參考其各框架方案的開原始碼,重點了解大致流程。冷啟動方案几乎可以修復任何程式碼場景,但是補丁注入前已經被載入的類,如 Application 等是無法被修復的。綜合上面的多種方案可以得到針對不同虛擬機器的優先冷啟動方案:

  • Dalvik 虛擬機器下使用類 multi-dex 全量方案避免插樁的方案
  • Art 虛擬機器下使用補丁類作為 classes.dex 重新打包壓縮檔案進行載入的方案

類熱更新

類熱更新指的是在不需要重啟應用的前提下修復類的已知問題。

如果一個類已被虛擬機器所載入後要修正該類的某些方法,只能通過實現 類熱更新 來實現:在 navite 層替換到對應被虛擬機器載入過的類的方法。

以阿里 開源專案AndfixSophix 方案為分析。

  1. AndFix#replaceMethod(Method src,Method dest) 為 Java 層替換錯誤方法的入口,通過 JNI 呼叫 Navite 層程式碼
  2. andifx#replaceMethod 為 Navite 層被上層所呼叫的程式碼,對虛擬機器內的方法進行 ”替換“
static void replaceMethod(JNIEnv* env, jclass clazz, jobject src,jobject dest) {
  if (isArt) {
    art_replaceMethod(env, src, dest);
  } else {
    dalvik_replaceMethod(env, src, dest);
  }
}

程式碼區分了 Dalvi虛擬機器Art虛擬機器 的不同實現。

extern void __attribute__ ((visibility ("hidden"))) art_replaceMethod(JNIEnv* env, jobject src, jobject dest) {
    if (apilevel > 23) {
        replace_7_0(env, src, dest);
    } else if (apilevel > 22) {
    replace_6_0(env, src, dest);
  } else if (apilevel > 21) {
    replace_5_1(env, src, dest);
  } else if (apilevel > 19) {
    replace_5_0(env, src, dest);
    }else{
        replace_4_4(env, src, dest);
    }
}

但是不同虛擬機器版本,由於虛擬機器底層資料結構並不相同,所以還進一步針對不同 Android 版本再做區分。

這就頭大了啊。這裡以 6.0 版本的 Art虛擬機器 的替換流程簡單講一下。

每一個 Java 方法在 Art虛擬機器 內都對應一個 art_method 結構,用於記錄 Java 方法的所有資訊,包括歸屬類,訪問許可權,程式碼執行地址等。然後對這些資訊進行逐一替換,替換完之後再次呼叫替換方法就可直接走新方法邏輯。

當 Java Code 被編譯處理成 Dex Code 之後, Art虛擬機器 載入並可通過解釋模式或者 AOT 模式執行。要在熱更之後呼叫新方法就得替換方法執行入口。

解釋模式下通過獲取 art_method.entry_point_from_jni_ 方法獲取執行入口,而 AOT 模式模式則呼叫 art_method.entry_point_from_jni_ 獲取。

除了獲取執行入口替換外,還需要保證方案使用的 art_method_replace_6_0#replace_6_0 資料結構與安卓原始碼 art_method 資料結構完全一致才可以。但由於各種廠商存在對 ROM 進行魔改,難以保證能夠修復成功。

針對上述相容問題, Sophix 探索出了一種突破底層結構差異的方法。

這種方法把一個 art_method 看成了一個整體進行替換而不必針對每個版本 ArtMethod 嚴格控制內容。換句話說,只要知道當前裝置 art_method 的長度,就可以把整個結構體完全替換掉。

由於 ArtMethod 是緊密排列的,所以相鄰兩個 ArtMethod 的起始地址差值就是 ArtMethod 的大小。通過定義一個簡單類 NativeMethodCal 來模擬計算。

public class NativeMethodCal{
  final public static void f1(){}
  final public static void f2(){}
}

兩個方法屬於 static 方法 且該類只有這兩個方法,所以必定相鄰,Native 層的替換可為

void replacee(JNIEnv* env, jobject src, jobject dest) {

  //...
  size_t firMid = (size_t) env->GetStaticMethodID(nativeMethodCalClazz,"f1","()V");
  size_t secMid = (size_t) env->GetStaticMethodID(nativeMethodCalClazz,"f2","()V");
  size_t methodSize = secMid - firMid
  memcpy(smeth,dmeth, methodSize);
}

小結一波

瞭解了兩種方案在 Native 層的類熱更思路及作用,但這兩種方案也存在一些限制與問題:

  1. 針對反射呼叫非靜態方法產生的問題。這類問題只能通過冷啟動修復,原因是反射呼叫的 invoke 底層回撥用到 InvokeMethod ,該方法會校驗反射的物件和是不是 ArtMethod 的一個例項,但方案替換了 ArtMethod 導致校驗失敗。
  2. 不適合類發生結構變化的修改。比如增刪方法可能引起類及 Dex 方法數變化,進而改變方法索引。同樣地,增刪欄位也會更改方法索引。

資源修復

資源修復是很常見的操作,資源修復方案很多參考 InstantRun 的實現, InstantRun 資源修復核心流程大致如下:

  1. 構建一個新的 AssetManager 物件,並呼叫 addAssetPath 新增新的資源包;
  2. 修改所有 ActivityActivity.mAssets(AssetManager例項) 的引用指向新構建的 AssetManager 物件;
  3. 修改所有 ResourceResource.mAssets(AssetManager例項) 的引用指向新構建的 AssetManager 物件.

對於任意的資源包,被 AssetManager#addAssetPath 新增之後,解析 resourecs.asrc 並在 Native 層 mResources 側儲存起來。可參考 AssetManager.h 的實現。

實際上 mResources 是一個 ResTable 結構體,存放 resourecs.asrc 資訊用的。而且一個程序只會有一個 ResTable

ResTable可載入多個資源包,一個資源包都包含一個 resourecs.asrc ,每一個 resourecs.asrc 記錄了該包的所有資源資訊,每一個資源對應一個 ResChunk

每一個 ResChunk 都有唯一的編號,由該編號由三部分構成,比如 0x7f0e0000 ,可以隨便找一個 APK 解包檢視 resourecs.asrc 檔案。

  • 前兩位 0x7f 為 package id,用於區分是哪個資源包
  • 接著兩位 0x0e 為 type id,用於區分是哪型別資源,比如 drawable,string 等
  • 最後四位 0x0000 為 entry id,用於表示一個資源項,第一個為 0x0000 ,第二個為 0x0001 依次遞增。

值得注意的是,系統的資源包的 package id 為 0x01,我們的 apk 為 0x7f

在應用啟動之後, ResourceManager 在構建 AssetManager 時候就已經載入了 APK 包的資源和系統的資源。

補丁下發的資源 packageId 也會是 0x7f ,我們使用已有的 AssetManager 進行載入,在 Android L 版本之後這些內容會繼續追加到已經解析資源的後面。

由於相同的 packageId 的原因,有可能在獲取某個資源是原 APK 已經存在近而忽略了補丁的新資源。故 類InstantRun方案 只有 AssetManager 被完全替換才有效。

假如完整替換 AssetManager ,則需要完整的資源包。補丁包需要通過修復前後的資源包經過差異計算之後下發,客戶端接收併合成完整的新資源包,執行時可能會耗費較多的時間和記憶體。

Sophix給出了一種可以不用重新合成資源包的方案,該方案可被應用到 Android L 及後續版本。

同樣是比較新舊資源包得到補丁資源包,然後通過修改補丁資源包的 packageId0x66 ,並利用已有的 AssetManager 直接使用。這個補丁資源包要遵循以下規則: 補丁包只包含新增的資源,包含純新增的資源和修改舊包的資源,不包含舊包需要刪除的資源

  • 純新增的資源,程式碼處直接引用該資源;
  • 舊包需要修改的資源,則新增修改後的對應資源,然後把程式碼處資源引用指向修改後資源;
  • 舊包需要刪除的資源,則程式碼處不引用該資源就好。(雖然會佔著坑)

使用新資源包進行編譯,程式碼中可能出現資源 ID 偏移,需修正程式碼處的資源引用。

舉個:chestnut:。

比如原來有一個 Drawable 在程式碼的引用為 0x7f0002 ,由於新資源包新增了一個 Drawable ,導致原 Drawable 在程式碼的引用為 0x7f0003

這個時候就需要把程式碼引用更改回原來的 0x7f0002 。因為 Sophix 載入的是 packageId0x66 的補丁包而不是重新合成新的資源包。同時,對於使用到補丁包內的資源,其引用也需改成對應補丁資源引用 0x66???? (????為可改變)。

但是這種做法會導致構建補丁資源時非常複雜,需要懂得分析新舊資源包的 resources.asrc 及對系統資源載入流程十分了解才行。

針對 Android KitKat 及以下版本,為了避免和 InstantRun 一樣建立新的 AssetManager 並做大量反射修改工作,對原 AssetManager 物件析構和重構。

具體做法是讓 Native 層的 AssetManager 釋放所有已載入的舊資源,然後把 Java 層的 AssetManager 對其的引用設定為 null 。同時 Java 層的 AssetManager 重新呼叫 init 方法驅動 Native 建立一個沒有載入過資源的 AssetManager

這樣一來,java 層上層程式碼對 AssetManager 引用就不需要修改了,然後在對其呼叫 AddAssetPath 新增所有資源包就可以了。

小結一波

資源修復整體是圍繞 AssetManager 展開,本文也只是記錄了大體的思路,學習一下著名框架的設計思路及解決問題方法。中間細節自然存有一些難點相容點需被攻克,感興趣可檢視文章末端參考資料中的書籍。

SO修復

要理解 so 如何被修復得先了解系統如何載入 so 庫。

安卓有兩種載入 so 庫的方法。

  1. 呼叫 System.loadLibrary 方法,接收一個 so 的名稱作為引數進行載入。對於 APK 而言,其 libs 目錄下的 so 檔案會被複制到應用安裝目錄並完成載入;
  2. 呼叫 System.load 方法 方法,接收一個 so 的完整路徑作為引數進行載入。

系統載入完 so 庫之後需要進行註冊,註冊也分 靜態註冊動態註冊

靜態註冊使用 Java_{類完整路徑}_{方法名} 作為 native 的方法名。當 so 已經被載入之後,native 方法在第一次被執行時候就會完成註冊。

public class Test{
  public static native String test();
}
extern "C" jstring Java_com_effective_android_test(JNIEnv *env,jclass clazz)

動態註冊藉助 JNI_OnLoad 方法完成繫結。當 so 被載入時會呼叫 JNI_OnLoad 方法進行註冊。

public class Test{
  public static native void testJni();
}
void test(JNIEnv *env,jclass clazz){
  //native 實現邏輯
}

//申明列表
JNINativeMethod nativeMethods[] = {
  {"test","()V",(void *) test}
}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm,void *reserved){
  
  //實現註冊
  jclass clz = env->FindClass("com/effective/android/Test");
  if(env->RegisterNatives(clz, nativeMethods,sizeOf(nativeMethods)/sizeOf(nativeMethods[0])) != JNI_OK){
    return JNI_ERR;
  }
  //...
}

在修復在上述兩種註冊場景的 so 會存在侷限:

針對動態註冊場景

  • 對於 Art虛擬機器 需要再次載入補丁 so 來完成方法對映的更新;
  • Dalvik虛擬機器 則需要對補丁 so 重新命名來完成 Art 下方法對映的更新。

針對靜態註冊場景

  • 解除已經完成靜態註冊的方法工作難度大;
  • so 中哪些靜態註冊的方法需要更新也很難得知。

由於涉及補丁 so 的二次載入,記憶體損耗大,可能導致 JNI OOM 出現。同時如果動態註冊 so 場景下中新增了一些方法但是對應的 DEX檔案 中沒有與之對應的方法,則會出現 NoSuchMethodError 異常。

雖然困難,但是方案也是有的。

假如在在應用載入 so 之前能夠先嚐試載入補丁 so 再載入應用 so,就可以實現修復。

比如自定義一個方法,替換掉 System.loadLibrary 方法來完成這個邏輯,但是存在一個缺點就是很難修復已經混淆編譯的第三方庫。

所以最後採取的是類似 類修復 的注入方案。so 庫被載入之後,最終會在 DexPathList.nativeLibararyDirectories/nativeLiraryPathElements 變數所表示的目錄下遍歷搜尋到。前者 nativeLibararyDirectoriesSDK<23 時的目錄,後者 nativeLibararyDirectoriesSDK>=23 時的目錄,只需要把補丁 so 的路徑插入到他們目錄的最前面即可。

但是 so 庫檔案存在多種 CPU 架構,補丁和 apk 一樣都存在需要選擇哪個 abi 的 so 來執行的問題。

Sophix提供了一種思路, 通過從多個 abis 目錄中選擇一個合適的 primaryCpuAbi 目錄插到 nativeLibararyDirectories/nativeLiraryPathElements 陣列中。

  1. SDK>=21 時直接反射拿到 ApplicationInfo 物件的 primaryCpuAbi

  2. SDK<21 時由於不支援 64 位所以直接把 Build.CPU_ABI, Build.CPU_ABI2 作為 primaryCpuAbi

具體可實現為以下邏輯。

ApplicationInfo mAppInfo = pm.getApplicationInfo(mApp.getPackageName(),0);
if(mAppInfo != null){
   // SDK>=21               
  if(Build.VERSION>SDK_INT >= Build.VERSION_CODES>LOLLIPOP){
    File thirdFiled = ApplicationInfo.class.getDeclaredFiled("primaryCpuAbi");
    thirdFiled.setAccessable(true);
    String cupAbi = (String) thirdFiled.get(mAppInfo);
    primaryCpuAbis = new String[](cpuAbi "");
  }else{
    primaryCpuAbis = new String[](Build.CPU_ABI,Build.CPU_ABI2 "");
  }
}

方案選型

兩年前在舊的團隊預研熱修復的時候,我們選擇了 tinker 。現在所在的團隊也還是 tinker

對於中小團隊而言,我們選擇方案一般需要: 相容性強修復範圍廣免費開源社群活躍

  • 相容性強,需要相容 Android 的所有版本,我們也嘗試過 AndFixQZone 等方案,基本 Android N 之後就放棄了;
  • 修復範圍廣,除了能修復類場景,資源,so 也需要考慮;
  • 免費,一開始 AndFix 時最簡單易用,後面轉 sophix 後收費就放棄了。如果有金主爸爸可以忽略, sophix 非常簡單易用,上述原理技術也參考了 sophix 的技術方案,非常優秀;
  • 社群活躍,目前 tinker 的開源維護還算不錯。

故我們最終選擇以 tinker 作為熱修復方案技術框架來實現熱修功能。

整合與實踐流程

Tinker 整合

在我們專案中, tinker 相關程式碼是作為 Service 層中的一個模組。模組包含以下資訊:

  • 程式碼目錄 ,包含 tinker 提供的所有庫及專案封裝的程式碼,涉及下載,載入,除錯,日誌上報等場景;
  • Gradle指令碼 ,配置資訊等;
  • 基線資源 ,用於存放未加固包,Mapping檔案,R檔案等;
  • Shell指令碼 ,用於打包補丁的指令碼,提供給 Jenkins 使用,用於讀取基線資源聯合 tinker 提供的外掛進行補丁生成。

主端專案由於我們使用 ApplicationLike 進行代理,所以是否開啟熱修復,都需要 tinker 來代理我們的 Application 。主端根據是否開啟熱修復功能動態 apply Gradle 指令碼及對 DefaultLifeCycle.flag 進行開關切換。

實踐流程

在生產環境中,我們通過 Jenkins 平臺輸出產物,並先把產物輸出到內部測試平臺。如需要對外發布則同時上傳產物到 CDN 檔案伺服器。

另外,內部維護的 CMS平臺 可對補丁資訊進行分發,客戶端通過讀取 CMS配置資訊 來獲取補丁資訊,進而驅動客戶端修復行為。

下面梳理了線上涉及補丁業務的所有流程,完全可複用到任何專案場景:

  1. release分支保留基線資源
  2. 修復線上緊急缺陷
  3. 生成補丁上傳到伺服器
  4. 分發平臺配置補丁資訊
  5. 客戶端載入補丁資訊
  6. 除錯與日誌支援

每個模組都涉及到真實專案的流程。

release 分支保留基線資源

一般的 Git 開發流程可參考 Git Flow 一文,核心的分支概念主要由以下五類分支:

  • master主分支 ,釋出線上應用及版本 Tag;
  • develop開發分支 ,開發總分支;
  • feature功能分支 ,版本功能開發測試分支;
  • hotfix補丁分支 ,緊急 Bug 修復分支;
  • release預發分支 ,功能測試迴歸預發版分支。

一般一個版本可能需要開發多個功能,可從 develop 拉取一個該版本的總 feature 分支,然後該總 feature 分支再拉取各個子分支給團隊內部人員開發。這樣可儘可能避免或減少分支的合併衝突。

下面以我們團隊日常開發分支實踐展開,同時區分常規發版及補丁發版來修復緊急 Bug 來梳理整個版本的開發流程,見下圖(強烈建議認真看一下)。

如果同一個版本存在多個補丁,比如 release 1.0.0 出現 Bug 需要修復,則可衍生出 hotfix 1.0.0.1 作為第一個補丁的分支,hotfix 1.0.0.2 作為第二個補丁分支一次類推。

release 測試迴歸結束後,需要輸出發版分支前, Jenkins 開啟輸出基線資源的配置,基線資源就會跟隨打包產物一起釋出到內部測試平臺。

這些資源會通過一個序列號進行關聯區分,在命名上體現。我們團隊使用的是 Git 提交記錄來作為序列號區分。

修復線上緊急缺陷

從原發布版本對應的 release 分支中拉出 hotfix 分支,針對緊急缺陷進行修復。

同時從內部測試平臺下載 基線資源 存放到規定的目錄後,把該分支推送到 remote 遠端。這裡使用的是 tinkerPatchRelease 進行補丁合成,所有合成工作邏輯都寫在了 Shell指令碼 中連同專案一起推上遠端,等待被 Jenkins 執行處理。

生成補丁上傳

Jenkins建立一個 Job 用於生產補丁。每次構建補丁前,把 修復線上緊急缺陷 步驟對應的分支名寫到 Job 配置資訊中。

Job 執行時會先從 remote 遠端拉取 hotfix 分支,然後執行 shell指令碼基線資源 進行讀取並完成 Gradle 指令碼的配置,再呼叫 tinkerPatchRelease 進行補丁合成,最後對補丁產物進行重新命名後上傳到內部測試平臺。

分發平臺配置補丁資訊

首先明確應用與版本,補丁間的關係:

  • 一個應用存在多個版本
  • 一個應用版本可存在多個補丁,同個版本的補丁可以互相覆蓋

根據這個關係,我們需要設計對應資料結構來承載補丁資訊。

定義補丁資訊,版本補丁資訊,應用補丁資訊

public class PatchInfo {
    public String appPackageName;
    public String appVersionName;
    //灰度或者全量,在(0-10000]之間
    public int percent = Constants.VERSION_INVALID;     
    //補丁版本,有效的版本應該是(1-正無窮),0為回滾,如果找到patchData下的補丁version匹配,則修復,否則跳過
    public long version = Constants.VERSION_INVALID;  
    //補丁包大小
    public long size;    
    //補丁描述
    public String desc;          
    //補丁建立時間
    public long createTime;   
    //補丁下載連結
    public String downloadUrl;  
    //補丁檔案 md5		
    public String md5;			                                   										
  }
public class VersionPatchInfo {
    //應用包名
    public String packageName;
    //應用版本
    public String versionName;
    //目標補丁版本
    public long targetPatchVersion;
    //某個版本下的多個補丁資訊,一個版本可有多個補丁
    public List<PatchInfo> patchList;
}
public class PatchScriptInfo {
    //應用報名
    public String packageName;              
    //當前所有補丁列表,按版本區分
    public Map<String, VersionPatchInfo> versionPatchList;  
}

則三者的類關係為:

定義一份配置資訊檔案,用於宣告全平臺所有版本的補丁資訊。

則我們的分發平臺 CMS 會根據規則通過配置項來構建上述這份配置檔案,客戶端通過 CMS 提供的 Api 來請求這份配置資訊檔案。

客戶端載入補丁資訊

除了主動拉取 CMS 配置資訊檔案外,一般還需要支援被動接收推送資訊。

  • 被動接收推送,客戶端通過接收推動資訊來構建配置資訊;
  • 主動拉取配置,通過 CMS 提供的 Api 來實時拉取配置資訊,進而構建配置資訊。

無論通過哪種方式來構建配置資訊,後續都需要完成以下流程:

除錯與日誌支援

除錯除了 IDE 的 Debug 之後,還可支援線上應用某些入口支援載入配置資訊並可手動除錯補丁。比如說在某些業務無相關的頁面如 關於頁面 的某個 view 在連續快速點選達到一定次數後彈出對話方塊,在對話方塊輸入內部測試碼之後就可進入 除錯介面

另外在 分發平臺配置補丁資訊 章節中涉及的配置資訊下載或補丁下載 downloadUrl ,可自定義協議擴充套件進行多場景支援。

  • cms協議 ,通過內部的 CMS 檔案協議來獲取檔案或者 Api 介面來請求,如果 URL 是以 cms: 開頭的協議則固定從 CMS 檔案伺服器讀取。
  • http/https協議 ,如果 URL 是常規 http:/https: 開頭的協議則預設需要下載。
  • sdcard協議 ,以裝置的 SDCARD 根目錄為起點進行檢索,如果 URL 是以 sdcard: 開頭的協議則預設讀取 SDCARD 本地檔案。該協議用於測試使用,比如 /sdcard/patch/config.txt 等。

除錯介面在掃描補丁指令碼配置時,只需要輸入滿足上述 3 種協議中一種的 URL 來獲取補丁資訊。除此之外,整個載入流程都會定義流程碼進行標示,可定義列舉類來支援,以下僅供參考。

public enum ReportStep {

    /**
     * 獲取指令碼,1開頭
     */
    STEP_FETCH_SCRIPT(1, "獲取熱修復配置指令碼"),
    STEP_FETCH_SCRIPT_REMOTE(10, "獲取遠端配置指令碼"),
    STEP_FETCH_SCRIPT_LOCAL(11, "獲取本地配置指令碼"),
    STEP_FETCH_SCRIPT_CMS(12, "獲取CMS配置指令碼"),
    STEP_FETCH_SCRIPT_SUCCESS(100, "獲取配置成功", Level.DEBUG),
    STEP_FETCH_SCRIPT_FAIL(101, "獲取配置失敗", Level.ERROR),

    /**
     * 解析指令碼,2開頭
     */
    STEP_RESOLVING_SCRIPT(2, "解析熱修復配置指令碼"),
    STEP_RESOLVING_SCRIPT_REMOTE(20, "解析遠端配置指令碼"),
    STEP_RESOLVING_SCRIPT_LOCAL(21, "解析本地配置指令碼"),
    STEP_RESOLVING_SCRIPT_CMS(22, "解析CMS配置指令碼"),
    STEP_RESOLVING_SCRIPT_LOCAL_SUCCESS(200, "解析成功", Level.DEBUG),
    STEP_RESOLVING_SCRIPT_LOCAL_FAIL(201, "解析失敗", Level.ERROR),
    STEP_RESOLVING_SCRIPT_MISS_CUR_PATCH_VERSION(2000, "當前客戶端版本找不到目標補丁", Level.ERROR),
    STEP_RESOLVING_SCRIPT_CUR_PATCH_INVALID(2001, "補丁為無效補丁,補丁配置資訊配置錯誤", Level.ERROR),
    STEP_RESOLVING_SCRIPT_CUR_PATCH_CANT_HIT(2002, "客戶端版本目標補丁未命中灰度", Level.ERROR),
    STEP_RESOLVING_SCRIPT_CUR_PATCH_IS_REDUCTION(2003, "目標補丁為回滾補丁", Level.DEBUG),
    STEP_RESOLVING_SCRIPT_CUR_PATCH_HAS_PATCHED(2004, "目標補丁已經被載入過,跳過", Level.DEBUG),
    STEP_RESOLVING_SCRIPT_HAS_SAME_NAME_FILE_BUT_MD5(2005, "本地補丁目錄查詢到與目標補丁同名的檔案,但md5校驗失敗", Level.ERROR),
    STEP_RESOLVING_SCRIPT_HAS_SAME_NAME_FILE_AND_MATCH_MD5(2006, "本地補丁目錄查詢到與目標補丁同名的檔案,md5校驗成功", Level.DEBUG),

    /**
     * 獲取補丁,3開頭
     */
    STEP_FETCH_PATCH_FILE(3, "獲取補丁"),
    STEP_FETCH_PATCH_FILE_REMOTE(30, "從遠端獲取下載補丁檔案"),
    STEP_FETCH_PATCH_FILE_LOCAL(31, "從本地目錄獲取補丁檔案"),
    STEP_FETCH_PATCH_SUCCESS(300, "獲取補丁檔案成功", Level.DEBUG),
    STEP_FETCH_PATCH_FAIL(301, "獲取補丁檔案失敗", Level.ERROR),
    STEP_FETCH_PATCH_MATCH_MD5(3000, "校驗補丁檔案 md5 成功", Level.DEBUG),
    STEP_FETCH_PATCH_MISS_MD5(3001, "校驗補丁檔案 md5 失敗", Level.ERROR),
    STEP_FETCH_PATCH_WRITE_DISK_SUCCESS(3002, "補丁檔案寫入補丁目錄成功", Level.DEBUG),
    STEP_FETCH_PATCH_WRITE_DISK_FAIL(3003, "補丁檔案寫入補丁目錄失敗", Level.ERROR),


    /**
     * 修復補丁,4開頭
     */
    STEP_PATCH(4, "補丁修復"),
    STEP_PATCH_LOAD_SUCCESS(40, "讀取補丁檔案成功", Level.DEBUG),
    STEP_PATCH_LOAD_FAIL(41, "讀取補丁檔案失敗", Level.ERROR),
    STEP_PATCH_RESULT_SUCCESS(400, "補丁修復成功", Level.DEBUG),
    STEP_PATCH_RESULT_FAIL(4001, "補丁修復失敗", Level.ERROR),


    /**
     * 補丁回滾,4開頭
     */
    STEP_ROLLBACK(5, "補丁回滾"),
    STEP_ROLLBACK_RESULT_SUCCESS(50, "補丁回滾成功", Level.DEBUG),
    STEP_ROLLBACK_RESULT_FAIL(51, "補丁回滾失敗", Level.ERROR);


    public int step;
    public String desc;
    @Level
    public int logLevel;

    ReportStep(int step, String desc) {
        this(step, desc, Level.INFO);
    }

    ReportStep(int step, String desc, int logLevel) {
        this.step = step;
        this.desc = desc;
        this.logLevel = logLevel;
    }
}

在補丁流程的每一個節點都進行 Log 日誌輸出,除了輸出到 IDE 和 除錯介面 外,還需上傳到每個專案的日誌伺服器以便分析線上補丁流程的具體情況及補丁效果。

到這,從 技術原理-技術選型-實踐流程 整體思路上希望會大家有幫助~。

碼字不易,如對你有價值,點贊支援一下吧~

專注 Android 進階技術分享,記錄架構師野蠻成長之路

如果在Android領域有遇到任何問題,包括專案遇到的技術問題,面試及簡歷描述問題,亦或對未來職業規劃有疑惑,可新增我微信 「Ming_Lyan」 或關注公眾號 「Android之禪」,會盡自所能和你討論解決。 後續會針對 “Android 領域的必備進階技術”,“Android高可用架構設計及實踐” ,“業務中的疑難雜症及解決方案” 等實用內容進行分享。 也會分享作為技術者如何在公司野蠻成長,包括技術進步,職級及收入的提升。 歡迎來撩。