上週五線上項目出現了緊急缺陷,無奈之下週六苦逼加班發補丁😭,惟一值得欣慰的是因爲出現缺陷的功能會在今天經過 ABTest 下發,補丁趕在了大推以前。恰好週日在家閒着,就寫一下「救我於水深火熱的熱修復」。java
但願當你看完這篇文章以後,可以瞭解到應用熱修復它並不難,也不須要本身造輪子,業界不少優秀的框架如Tinker,Robust,Sophix等。android
若是項目尚未支持這個熱更能力,但願你能嘗試折騰慢慢接入,這不只僅能學習到新知識也能爲服務項目提供容錯能力。git
文章篇幅比較長,但願各位看官能耐心看完,掌握總體思路並有所收穫。github
編程是爲了業務解決問題,學習編程的核心是掌握程序實現的思路,而代碼只是一種實現程序的工具。算法
下面從文章圍繞 技術原理-技術選型及實踐流程展開。shell
熱修復按照類修復時機可分爲類冷修復及類熱更新。編程
所謂類冷修復是指應用重啓以後經過加載修復後的類文件來修復類已知的問題,而類熱更新則是不須要重啓應用前提下修復類已知的問題。api
另外熱更修復的對象還可包括SO庫及資源文件。數組
下面針對類冷修復,類熱更新,SO庫修復及資源文件修復進行了解。緩存
一個Class文件若已被JVM虛擬機所加載,只能經過重啓手段解決來清除虛擬機中已保存的類信息。
咱們以QZone插樁方案 和微信tinker方案 方案爲分析,並引用Sophix方案的法作對比。
一個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 全部 directMethod 和 virtualMethod,包含了:
這些方法中第一層級關係引用到的類是在同一個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也不會有問題。
固然這樣的作法也存在的問題與限制:因爲類的加載涉及 dvmResolveClass,dvmLinkClass 和 dvmInitClass 三個階段。
dvmInitClass 會在類解析完並嘗試初始化類時執行,若是類沒有被打上CLASS_ISPREVERIFIED 或 CLASS_ISOPTIMIZED,校驗和優化都會在該階段進行。
正常狀況下類的校驗和優化應該在 APK 第一次安裝的時候執行 dexopt 操做時執行,可是咱們干預了CLASS_ISPREVERIFIED的設置流程致使在同一時間加載大量類且進行校驗及優化,容易在應用啓動時出現白屏。
爲了不插樁帶來的性能問題,手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);
}
}
複製代碼
因此只須要將補丁類A提早解析並設置 fromUnverifiedConstant 爲 true 繞過類校驗,而後把A存儲 dex 緩存中就能夠達到效果。這一步能夠經過 jni 主動調用 dalvik#dvmRsolveClass 方法實現。
後續引用到該補丁類 A 的時候就能夠直接從 dex 緩存中找到。當類B在校驗是否和類A在同一個 dex時是經過如下條件:
referrer->pDvmDex != resClassCheck->pDvmDex
若是不打破這個條件,依然會出現異常。因此對補丁類A進行 dex 緩存時拿到的 pDvmDex 應該指向原來類A所在的 dex 。
那麼在 dalvik#dvmRsolveClass 的過程當中,referrer 和 classIdx 要怎麼肯定?
這套方案可完美避開插樁所帶來的類校驗影響,但假如在某個待修復多態類中新增方法,可能會致使修復前類的 vtable 的索引與修復後類的 vtable 索引對不上。所以修復後的類不能新增 public 函數,一樣QZone也存在這樣的問題。因此只能尋找全量合成新 dex文件的方案。
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 。修復流程爲:
這種全量合成修復 DEX文件 的作法,確保了復先後的類在同一個DEX文件中,遵循原來虛擬機全部校驗方式,避開了QZone方案面臨的類校驗問題。
阿里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 層替換到對應被虛擬機加載過的類的方法。
以阿里開源項目Andfix及Sophix方案爲分析。
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 層的類熱更思路及做用,但這兩種方案也存在一些限制與問題:
資源修復是很常見的操做,資源修復方案不少參考InstantRun的實現,InstantRun資源修復核心流程大體以下:
對於任意的資源包,被 AssetManager#addAssetPath 添加以後,解析resourecs.asrc並在 Native 層 mResources 側保存起來。可參考 AssetManager.h 的實現。
實際上 mResources 是一個ResTable結構體,存放resourecs.asrc信息用的。並且一個進程只會有一個ResTable。
ResTable 可加載多個資源包,一個資源包都包含一個resourecs.asrc ,每個resourecs.asrc 記錄了該包的全部資源信息,每個資源對應一個ResChunk。
每個ResChunk都有惟一的編號,由該編號由三部分構成,好比0x7f0e0000,能夠隨便找一個 APK 解包查看 resourecs.asrc 文件。
值得注意的是,系統的資源包的 package id 爲 0x01,咱們的 apk 爲 0x7f
在應用啓動以後,ResourceManager在構建AssetManager時候就已經加載了 APK 包的資源和系統的資源。
補丁下發的資源 packageId 也會是 0x7f ,咱們使用已有的AssetManager進行加載,在Android L版本以後這些內容會繼續追加到已經解析資源的後面。
因爲相同的 packageId 的緣由,有可能在獲取某個資源是原 APK 已經存在近而忽略了補丁的新資源。故 類InstantRun方案只有AssetManager被徹底替換纔有效。
假如完整替換AssetManager ,則須要完整的資源包。補丁包須要經過修復先後的資源包通過差別計算以後下發,客戶端接收併合成完整的新資源包,運行時可能會耗費較多的時間和內存。
Sophix給出了一種能夠不用從新合成資源包的方案,該方案可被應用到Android L及後續版本。
一樣是比較新舊資源包獲得補丁資源包,而後經過修改補丁資源包的 packageId 爲 0x66 ,並利用已有的AssetManager直接使用。這個補丁資源包要遵循如下規則:補丁包只包含新增的資源,包含純新增的資源和修改舊包的資源,不包含舊包須要刪除的資源。
使用新資源包進行編譯,代碼中可能出現資源 ID 偏移,需修正代碼處的資源引用。
舉個🌰。
好比原來有一個Drawable在代碼的引用爲 0x7f0002,因爲新資源包新增了一個Drawable,致使原Drawable在代碼的引用爲0x7f0003。
這個時候就須要把代碼引用更改回原來的 0x7f0002。由於Sophix 加載的是 packageId 爲 0x66 的補丁包而不是從新合成新的資源包。同時,對於使用到補丁包內的資源,其引用也需改爲對應補丁資源引用 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 庫以後須要進行註冊,註冊也分靜態註冊和動態註冊
靜態註冊使用 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 會存在侷限:
針對動態註冊場景
針對靜態註冊場景
因爲涉及補丁 so 的二次加載,內存損耗大,可能致使JNI OOM出現。同時若是動態註冊 so 場景下中新增了一些方法可是對應的 DEX文件 中沒有與之對應的方法,則會出現 NoSuchMethodError 異常。
雖然困難,可是方案也是有的。
假如在在應用加載 so 以前可以先嚐試加載補丁 so 再加載應用 so,就能夠實現修復。
好比自定義一個方法,替換掉 System.loadLibrary 方法來完成這個邏輯,可是存在一個缺點就是很難修復已經混淆編譯的第三方庫。
因此最後採起的是相似類修復的注入方案。so 庫被加載以後,最終會在 DexPathList.nativeLibararyDirectories/nativeLiraryPathElements 變量所表示的目錄下遍歷搜索到。前者nativeLibararyDirectories是 SDK<23 時的目錄,後者nativeLibararyDirectories是 SDK>=23 時的目錄,只須要把補丁 so 的路徑插入到他們目錄的最前面便可。
可是 so 庫文件存在多種 CPU 架構,補丁和 apk 同樣都存在須要選擇哪一個abi的 so 來執行的問題。
Sophix 提供了一種思路, 經過從多個abis目錄中選擇一個合適的primaryCpuAbi目錄插到 nativeLibararyDirectories/nativeLiraryPathElements 數組中。
SDK>=21時直接反射拿到 ApplicationInfo 對象的primaryCpuAbi。
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。
對於中小團隊而言,咱們選擇方案通常須要:兼容性強,修復範圍廣,免費,開源社區活躍。
故咱們最終選擇以tinker做爲熱修復方案技術框架來實現熱修功能。
在咱們項目中,tinker相關代碼是做爲Service層中的一個模塊。模塊包含如下信息:
主端項目因爲咱們使用ApplicationLike進行代理,因此是否開啓熱修復,都須要 tinker 來代理咱們的 Application。主端根據是否打開熱修復功能動態 apply Gradle 腳本及對 DefaultLifeCycle.flag 進行開關切換。
在生產環境中,咱們經過Jenkins平臺輸出產物,並先把產物輸出到內部測試平臺。如須要對外發布則同時上傳產物到CDN文件服務器。
另外,內部維護的CMS平臺可對補丁信息進行分發,客戶端經過讀取CMS配置信息來獲取補丁信息,進而驅動客戶端修復行爲。
下面梳理了線上涉及補丁業務的全部流程,徹底可複用到任何項目場景:
每一個模塊都涉及到真實項目的流程。
通常的Git開發流程可參考 Git Flow 一文,核心的分支概念主要由如下五類分支:
通常一個版本可能須要開發多個功能,可從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配置信息文件外,通常還須要支持被動接收推送信息。
不管經過哪一種方式來構建配置信息,後續都須要完成如下流程:
調試除了 IDE 的 Debug 以後,還可支持線上應用某些入口支持加載配置信息並可手動調試補丁。好比說在某些業務無相關的頁面如 關於頁面的某個view在連續快速點擊達到必定次數後彈出對話框,在對話框輸入內部測試碼以後就可進入調試界面。
另外在 分發平臺配置補丁信息章節中涉及的配置信息下載或補丁下載 downloadUrl,可自定義協議擴展進行多場景支持。
調試界面在掃描補丁腳本配置時,只須要輸入知足上述 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 領域有遇到技術難題亦或對將來職業規劃有疑惑,一塊兒討論交流。 歡迎來擾。