蘑菇街 Android 熱修復探索之路
文章包含三部分:
-
業界各方案簡介;
-
蘑菇街HotFix:Q-Zone篇,介紹ART Runtime對Q-Zone方案的限制;
-
蘑菇街HotFix:Aceso篇,介紹Aceso在InstantRun方案上的各種優化。
1. 業界各方案簡介
在Dalvik時代,只有Dexposed跟Q-Zone兩家的方案,進入ART時代后各種Android熱修復方案如雨后春筍般冒出來。
1.1 Dexposed
Xposed的非root版本,實現本進程的AOP, Dalvik上近乎完美,patch難寫些, 但是不支持ART,現在已被AndFix取代。原理是Hook了Dalvik虛擬機的method->nativeFunc指針,如下:
1.2 Q-Zone
原理是Hook了ClassLoader.pathList.dexElements[]。因為ClassLoader的findClass是通過遍歷dexElements[]中的dex來尋找類的。當然為了支持4.x的機型,需要打包的時候進行插樁。
1.3 Tinker
服務端做dex差量,將差量包下發到客戶端,在ART模式的機型上本地跟原apk中的classes.dex做merge,merge成為一個新的merge.dex后將merge.dex插入pathClassLoader的dexElement,原理類同Q-Zone,為了實現差量包的最小化,Tinker自研了DexDiff/DexMerge算法。Tinker還支持資源和So包的更新,原理類同InstantRun,這里不詳解。
1.4 Robust
美團即將開源的Robust方案是個好東西,他是在AOSP的InstantRun方案的基礎上進行的極致優化,由于是基于.class級別的AOP方式,兼容性相比其他方案天然有很大的優勢,而且下載即生效也是個優勢,美中不足的是對包大小和磁盤占用(Android 5.x上OAT文件會比較大)仍然有影響,雖然相比InstantRun方案已經有了很大的優化。
大體流程如下,在原Dex中對所有可能需要HotFix的方法注入一段代碼,這段代碼判斷當前method是否被HotFix了,如果是,那么去patch.dex中執行對應的HotFix代碼,否則繼續執行原來的邏輯。
1.5 AndFix
其原理是替換了dex_cache中的目標ArtMethod,但是由于Android 7.0的Inline的優化,導致AndFix不能支持Android 7.0,阿里那邊是采用的動態部署進行Android 7.0上的HotFix的。AndFix的原理后面會繼續分析。
1.6 Amigo
Amigo的基本原理是直接下發新的Dex,資源,so包到客戶端,然后開啟一個新的ClassLoader加載新的Dex,開啟一個新的AssetManager加載新的Resources,可謂暴力而簡單。缺點是流量跟磁盤占用比較大,下發一次HotFix相當于安裝了兩遍APK。為了節省流量,Amigo推薦大家用BsDiff。
2. 蘑菇街HotFix:Q-Zone篇
蘑菇街這邊開始做HotFix方案的時候,當時業界只有Q-Zone跟Dexposed方案,后來是采用了Q-Zone的方案的,但是對Q-Zone方案心里不是很有底,所以去看了下ART虛擬機的方法加載機制,從虛擬機層面弄清楚了其可靠性。大致總結如下:
1. Dalvik上解釋執行:
1.1 field通過field name查找
1.2 method通過簽名查找
1.3 注入前被加載的類不能被patch
1.4 const變量會被優化為常量,不能被patch
1.5 下載patch后下次啟動才能生效
2. ART實現AOT優化:
2.1 同DEX中的field/method通過偏移查找,修改類結構將帶來找錯或者找不到的問題。
這樣,只要限定死不修改類結構,即只修改函數體,那么理論上Q-Zone方案是不會有問題的。
2.1 method調用類型
為了確認Q-Zone方案在ART上的可靠性,我們研究了所有類型method的調用方式,如下:
2.2 Art運行時的主要數據結構
以下是Art Runtime Native的主要數據結構。
每個Dex都對應一個DexFile/DexCache。
每一個類都對應有一個class結構,包含加載的ClassLoader,一張IfTable即Interface-table,vTable即virtual-table,該class所有的Field和method分別保存在ArtField/ArtMethod中。
2.3 invoke_direct調用過程(貍貓換太子)
一個dexfile中的類如果從來沒有被加載過,那么defineClass會為這個dexFile創建一個DexCache,這個DexCache包含一個指針數組resolved_methods_,數組的大小就是當前dexFile中所有的method方法數:
HeapReference<PointerArray> resolved_methods_;
并且初始化所有的指針都指向了一個叫做Runtime method的ArtMethod,這個Runtime method就是個“貍貓”,他只是一個代理,后面會將真正的“太子”方法請回來,好,這個Runtime method也是一個ArtMethod, 它有三個跳轉指針變量,其中最重要的entry_point_from_quick_compiled_code_指向一段匯編指令,最后回調到蹦床函數artQuickResolutionTrampoline()
void* entry_point_from_quick_compiled_code_;
如果是同一個dex內部跳轉,在類首次被調用時,那么它的函數調用接口最終都走到了這個artQuickResolutionTrampoline()蹦床函數.
比如class A的caller()調用到了類B的callee(),A.caller()->B.callee(),dex2oat在生成A.caller()的native code時,其調用B.callee()最后就偏移到了class B所在的dexcache中callee對應的ArtMethod的entry_point_from_quick_compiled_code_地址。dex2oat如何知道callee對應的ArtMethod呢?因為Dex文件里面保存了callee在該Dex中的信息,包括簽名和methodId,而dexcache中的ArtMethods數組就是按照這個methodId來對應到各個method的。所以, dex2oat在解析了Dex文件后根據methodId就知道到哪個ArtMethod去調用entry_point_from_quick_compiled_code_了。
總結下如上圖:
-
Dex_cache被初始化的時候,resolved_methods數組里面所有的ARTMethods都指向同一個“貍貓”ArtMethod,這個ArtMethod的 entry_point_from_quick_compiled_code_ 指向的是一個蹦床函數 artQuickResolutionTrampline 。
-
在方法首次被調用的時候,先運行的是這個蹦床函數,如果class還未link,在這個蹦床函數里面去resolve/link Class, 創建virtual_methods, direct_methods/ifTableArray三張表,這時候這個類所有方法對應的ArtMethod就被創建好了。
-
找到"太子"ArtMethod,然后將其回填到DexCache中的resolved_methods,下次運行的時候就是從“太子”ArtMethods獲取 entry_point_from_quick_compiled_code_ 了,這個指向OAT文件中對應該method的native code。
invoke_virtual和invoke_interface調用跟invoke_direct類似,所不同的是查找“太子"ArtMethod時是從virtual_methods/ifTableArray中查找。
2.4 Virtual-Table創建
invoke_direct, invoke_static, invoke_super都是在direct_methods中查找到對應的ArtMethod的。
invoke_virtual和invoke_interface指令是通過在virtual_methods和ifTableArray中查找到對應的ArtMethod的。
創建過程如下:
1. 讀取當前類的所有public方法和父類的virtual table;
2. 將父類virtual table的ArtMethod放在當前類的virtual table前面;
3. 比較當前類的public 方法的簽名,有相同的就覆蓋;
4. 將剩下的public方法的ARTMethods append到virtual table后面;
5. 賦予每個Virtual table中的ArtMethod一個method_id,用來標記在vitual Table中的偏移。
2.5 IfTable創建
跟virtual table的創建類似,所不同的是不僅僅IfTable相同的需要覆蓋,有相同簽名的ArtMethod也需要覆蓋。如下:
2.6 Filed訪問
1. Constant變量,在編譯為.class的時候就會被優化為常量。
2. Instant和static變量,dex2oat會以其在類中的偏移來訪問。
2.7 跨Dex訪問
以上總結的都是同一個Dex中的訪問方式。對于跨Dex的訪問,method和field都是通過簽名來訪問的。
2.8 總結
采用Q-Zone方案,即將HotFix的dex插到PathClassLoader的前面。限制HotFix不能修改類結構,即只能修改函數體的情況下:
1. Dalvik模式:采用解釋執行,即通過方法簽名和field名字來查找,理論上能夠支持。唯有Const變量不能修改。
2. ART模式:從原dex訪問HotFix中的方法和field,函數和field將會根據類的偏移來訪問,由于類結構沒變化,理論上支持;從HotFix訪問原dex,將采用的是跨dex的解釋模式訪問,理論上支持。
2.9 AndFix原理
順便,這里我們來看下AndFix的原理, Andfix主要實現代碼如下:
其核心思想就是替換了dex_cache中的目標ArtMethod(dmeth)的內容,將smeth的內容替換成為dmeth的內容,包括native代碼的在OAT文件的偏移,classloader等。
1. 缺陷:
受限于類結構不能改變,AndFix不支持新增方法,新增類,新增field等
由于采用注入方式,適配性是個考驗。AndFix1.0幾乎不可用,到了2016年中的AndFix2.0才把適配的坑基本填完。
2. 優勢:
針對method級別的HotFix,patch中只需要帶上需要改變的方法,其HotFix的size是非常小的。且立即生效,不用等下次重啟。
3. 蘑菇街HotFix:Aceso篇
3.1 Aceso方案誕生背景
Q-Zone方案在蘑菇街平臺上良好運行了半年左右,一切的改變發生在Android N的問世,由于Android N采用的是JIT+AOT profile的混合編譯方式,Tinker跟Q-Zone方案跪了,Google為了解決ART上首次安裝APK就做OAT帶來的性能和IO占用的問題,采用了混合編譯的折中方案,在APK首次運行時采用解釋模式,然后運行期去收集”熱代碼“,通過JobScheduler對“熱代碼”做OAT,同時生成一個叫做Base.art的索引文件,里面保存了已經編譯好的”熱代碼“在OAT中的索引,在應用啟動的時候預先加載這些“熱點類”到ClassLoader的dexcache緩存中,由于提前將這些類加載到了cache中,這樣會導致這些“熱點類”的方法永遠沒辦法被替換。下圖從Tinker那邊拷貝過來的,AndroidN混合編譯就像一個小的生態:
后來跟Tinker負責人進行探討,找到了完美的解決方案:
1. 為了繞開混合編譯帶來的影響,我們拋棄原來的PathClassLoader,轉而創建一個新的ClassLoader(AndroidNClassLoader)來加載絕大部分類,PathClassLoader將只加載ProxyApp,然后在ProxyApp中使用AndroidNClassLoader反射調用到realApp,這樣從realApp開始,所有被realApp直接或者間接調用到的類都將被AndroidNClassLoader加載了,然后將HotFix的Dex插入AndroidNClassLoader的dexElements[]即可;末了,將AndroidNClassLoader注入PackageInfo中,這樣系統以后也會用AndroidNClassLoader來進行類加載了,主要是Android四大組件的加載。
2. 為了繼續利用系統本身已經生成好的OAT文件,防止重新生成一份OAT文件來耗磁盤,在AndroidNClassLoader中需要重新創建DexFile, 并調用makePathElements方法將老的Dex跟OAT文件的路徑作為參數傳入,這樣就能夠復用以前的OAT文件了。
當時天真的以為Game Over了,直到Tinker負責人告知又有新問題了,原來Android 7.0上inline進行了很大的優化,請參考Tinker的文章:
"ART下的方法內聯策略及其對Android熱修復方案的影響分析"
http://dwz.cn/5hajFl
好了,Q-Zone方案繼續跪,Tinker因為采用分平臺合成也跪,AndFix方案也跪了,因為大家都沒辦法修復被inline掉的代碼;為了解決Inline問題,Tinker是從原來IO占用比較小的分平臺合成轉而做了IO很耗的全量合成的方案,AndFix方面根本沒辦法修復,手淘那邊是采用了動態部署方案兜底,即7.0上采用動態部署(類似Tinker做全量合成), 而Q-Zone方案沒有辦法能夠解決。當我們正在猶豫是否要接入Tinker/Amigo這種重量級產品之際(猶豫是因為用Tinker/Amigo做熱修復簡直就是大炮打蚊子,Dex,資源都做全量合成,IO占用非常耗,Tinker/Amigo比較適合做功能升級, 其實蘑菇街這邊后來是接了Tinker來做類似Atlas的動態部署的事情),這個時候美團的Robust方案的文章面世了(基于InstantRun方案的改造),并得知已經在美團十幾個App上運行了半年之久,這里非常感謝Tinker負責人張紹文同學的幫助跟美團那邊的技術分享,說實話不想重復造輪子,但是Robust開源不知道什么時候,而且是否靠譜心里不是很有底,于是我們花了一個近月時間自研了跟Robust原理一樣的Aceso方案來驗證其可靠性。這里取名Aceso--希臘神話中的健康女神。
此方案有2個比較明顯好處,一個是下載即生效,另外一個是兼容性好,由于是使用ASM注入的方式,有希望1,2年內都不用再跟在Google后面做兼容性適配了。
以下總結當前主流的HotFix方案對比:
3.2 原生InstantRun方案的基本原理
InstantRun在打HotFix包時,會為每個類塞入一個change變量。開發過程中,如果這個類一直沒有變化,那么change一直為空。如果類發生變化,change將被塞入patch的類實例。舉個例子:
通過ASM注入后的HotFixActivity.java
再看下patch的實現類:
InstantRun方案其實原理很簡單,關鍵是填坑,為了暴露這些坑,盡量發現足夠多的應用場景,我們采用暴力測試的方法,一次HotFix了900多個類,包括圖片庫,網絡庫這種比較頻繁調用的庫,后來又陸陸續續適配了蘑菇街其他組件,并把暴力測試的這些類灰度到線上,直到發現的坑都踩完填完為止。 另外,使用原生InstantRun方案會帶來2個問題,一個是包會增大很多,蘑菇街apk從42M增加到了60M;另外一個是由于采用反射導致性能影響比較大,這對于頻繁調用的方法來說就是噩夢。
我們通過不停優化,最后包大小只增加了1.5M,性能方面,同樣暴力熱修復了900多個類在線上線下并沒有發現性能問題,事實上,InstantRun機制本身需要的反射被大部分優化掉后,理論上不會有性能瓶頸了。
3.3 Aceso的結果
-
本地暴力測試(修復1500+類,覆蓋4.x-7.x機型);
-
Testin和Monkey通過,線上灰度3w用戶(暴力修復900+常用類),蘑菇街線上發了20多個HotFix,沒看到問題;
-
包大小增加1.5M,線上線下未發現性能問題。
3.4 原生InstantRun方案的那些坑
首先是包大小問題,主要包括三個方面
-
InstantRun插樁增加的包大小;
-
為支持super.method()增加的包大小;
-
為兼容super(), this()增加的包大小。
最后我們將原InstantRun方案需要增加的18M降低到了1.5M。
3.5 InstantRun插樁增加的包大小解決方案
InstantRun方案為每個類增加一個靜態變量,并且會為每個函數插樁,增加了指令數和字符串,其中,多了字符串是主要原因。
為了解決字符串的問題,此處借用了Proguard的思想,在編譯期間將所有的方法都映射為一個int值,然后將映射關系保存在一個mapping文件中。
3.6 為支持super.method()增加的包大小
InstantRun是在一個叫override類去調用被修類的各種方法。但遇到如super.method的情況,它是處理不了的,因為沒有辦法調用另外一個對象的super.xxx。
為了解決這個問題,InstantRun在原類中增加一個超大的代理函數。這個函數中有一個switch,switch的每個case對應了一個父類方法,也就是說這個類的父類有多少個方法,那么這個switch就有多少個case。然后根據傳入的參數來決定要調用父類的哪個方法。
我們借鑒了Robust的方案,將需要調用super.method()的地方通過字節碼處理工具將作用對象換為原對象,并且將override的父類改為被HotFix類的父類,就能夠調用原有對象的super方法。例如 假設JustTest類的父類是activity,那在override類也要繼承activity,并且將調用super.toString的地方,將作用對象換為JustTest的實例。這里的justTest.super()是偽代碼,代表了它字節碼層面是這個含義。
3.7 為兼容super(), this()增加的包大小
InstantRun為了在override類中調用原有類的super()方法和 this()方法,會在每個類中增加一個構造方法。然后在override類中會生成兩個方法args 和bodys ,arg返回一個字符串,代表要調用哪個this或super方法,body中則是原來構造函數中的除this或super調用的其他邏輯。當一個類的構造方法被HotFix時,在它的構造方法中會先調用override類的args,將args返回的字符串傳遞給新生成的構造方法,在新生成的構造方法里會根據字符串決定要調用哪個函數。之后再調用body方法。
為了兼容this調用,InstantRun會讓每個類額外的增加一個構造方法。我們最后選擇放棄對構造函數的支持,因為使用InstantRun的增加一個構造函數的方案會使得包大小額外增加1M,另外最重要的構造函數被修的概率很低,咨詢了下負責發HotFix的同學,以前從來沒有修過構造函數。所以權衡之下,我們決定放棄對構造函數的支持。
3.8 HotFix粒度
我們將HotFix的粒度從class級別降為了method級別,并要求寫HotFix的同學在寫HotFix代碼時,在需要修復的method方法上申明一個annotation。
將HotFix的粒度從class級別降為了method級別有三個好處:
1. 如果HotFix出了問題,能盡量降低其帶來的負面影響,
2. 減少HotFix帶來的性能消耗,
3. 減少即時生效時的時間窗口大小。
3.9 HotFix類的按需加載問題
InstantRun原有機制是可能導致被HotFix類提前加載到虛擬機中的,這會導致一些問題(比如說一個類的靜態方法中有用到mgapplicatiion類的sApp這個靜態變量,如果我們加載HotFix的時候,sApp還沒賦值,那就會NPE)。
這里我們改為,當方法被調用的時候去檢查該類是否被HotFix,如被HotFix,才加載HotFix類。具體流程如下:
在patch被安裝的時候,會將需要修復的method以及其所在的class保存在一個PatchBuffer中。所有method在被調用到的時候會有一個判斷:如果發現當前method以及所屬class都在PatchBuffer中,就會加載HotFix類并調用被patch中方法,否則還是走原來的邏輯。
3.9 性能問題
因為要在override類的對象中去訪問原有類的屬性,所以必定會涉及到訪問權限問題。
InstantRun會在編譯期間將:
1. 所有的protected以及默認訪問權限的方法和字段改為public。可以不用反射,直接訪問。
2. 對于private的方法,InstantRun會將被HotFix類的所有private方法拷貝到override類中,當override中的方法需要調用被HotFix類的私有方法時,直接調用override類的私有方法就好了。但是對于framework層的protected方法和字段(如activity的protected方法),就只能通過反射去調用(因為我們不能修改framework層的訪問權限)。對于所有的private字段也是通過反射去訪問。
因此,存在一定的性能問題,我們通過兩個手段來優化這個問題:
1. 用一個全局的Lru Cache存儲查找反射過的字段與方法,這樣盡量保證反射只被調用一次,
2. 全局將private字段改為public,做到對private字段都以public方式直接訪問;
3. 最后剩下Framework中的private方法還有JNI的private方法需要反射調用,這些對性能會有影響。
由于篇幅有限,還有不少坑這里不一一列舉了。
最后,Aceso經過線上幾個版本的驗證已經完全穩定,我們決定開源分享出來,歡迎圍觀。開源地址:
https://github.com/meili/Aceso
來自:http://mp.weixin.qq.com/s/GuzbU1M1LY1VKmN7PyVbHQ