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

上週五線上項目出現了緊急缺陷,無奈之下週六苦逼加班發補丁😭,惟一值得欣慰的是因爲出現缺陷的功能會在今天經過 ABTest 下發,補丁趕在了大推以前。恰好週日在家閒着,就寫一下「救我於水深火熱的熱修復」。java

但願當你看完這篇文章以後,可以瞭解到應用熱修復它並不難,也不須要本身造輪子,業界不少優秀的框架如TinkerRobustSophix等。android

若是項目尚未支持這個熱更能力,但願你能嘗試折騰慢慢接入,這不只僅能學習到新知識也能爲服務項目提供容錯能力。git

文章篇幅比較長,但願各位看官能耐心看完,掌握總體思路並有所收穫。github

編程是爲了業務解決問題,學習編程的核心是掌握程序實現的思路,而代碼只是一種實現程序的工具。算法

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

技術原理

熱修復按照類修復時機可分爲類冷修復類熱更新編程

所謂類冷修復是指應用重啓以後經過加載修復後的類文件來修復類已知的問題,而類熱更新則是不須要重啓應用前提下修復類已知的問題。api

另外熱更修復的對象還可包括SO庫資源文件數組

下面針對類冷修復類熱更新SO庫修復資源文件修復進行了解。緩存

類冷修復

一個Class文件若已被JVM虛擬機所加載,只能經過重啓手段解決來清除虛擬機中已保存的類信息。

咱們以QZone插樁方案微信tinker方案 方案爲分析,並引用Sophix方案的法作對比。

QZone方案

一個ClassLoader可加載多個DEX文件,每個DEX文件被加載後在內存中表現爲一個 Element 對象,多個DEX文件被加載後則排列成一個有序數組 dexElements。 對於 ClassLoader 不熟悉的朋友,建議先看看連接裏的儲備知識。

若是類已被ClassLoader加載,那麼查找其對應 class 對象是經過調用 findClass(String name, List 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. 保留原來修復前classesNdexDex 文件
  2. 獲取修復後的classedNdexFixdex 文件
  3. 使用算法計算獲得classesNdexPatch補丁文件
  4. 下發classesNdexPatch補丁文件在客戶端與classesNdexDEX 文件進行合併,獲得classedNdexFixDex 文件
  5. 重啓應用,提早加載classedNdexFixDex 文件修復問題。

這種全量合成修復 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 偏移,需修正代碼處的資源引用。

舉個🌰。

好比原來有一個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 來實時拉取配置信息,進而構建配置信息。

不管經過哪一種方式來構建配置信息,後續都須要完成如下流程:

file

調試與日誌支持

調試除了 IDE 的 Debug 以後,還可支持線上應用某些入口支持加載配置信息並可手動調試補丁。好比說在某些業務無相關的頁面如 關於頁面的某個view在連續快速點擊達到必定次數後彈出對話框,在對話框輸入內部測試碼以後就可進入調試界面

file

另外在 分發平臺配置補丁信息章節中涉及的配置信息下載或補丁下載 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之禪」公衆號,和你分享有價值有思考的技術文章。 可添加微信 「Ming_Lyan」備註 「進羣」 加入技術交流羣,討論技術問題嚴禁一切廣告灌水。 若有 Android 領域有遇到技術難題亦或對將來職業規劃有疑惑,一塊兒討論交流。 歡迎來擾。

相關文章
相關標籤/搜索