Android熱修復升級探索——代碼修復冷啓動方案

前言java

前面一篇文檔, 咱們提到熱部署修復方案有諸多特色(有關熱部署修復方案實現, Android熱修復升級探索——追尋極致的代碼熱替換)。其根本原理是基於native層方法的替換, 因此當類結構變化時,如新增減小類method/field在熱部署模式下會受到限制。 但冷部署能突破這種約束, 能夠更好地達到修復目的, 再加上冷部署在穩定性上具備的獨特優點, 所以能夠做爲熱部署的有利補充而存在。android

冷啓動實現方案概述c++

冷啓動重啓生效,如今通常有如下兩種實現方案, 同時給出他們各自的優缺點:
方案一算法

原理: 爲了解決Dalvik下unexpected dex problem異常而採用插樁的方式, 單獨放一個幫助類在獨立的dex中讓其餘類調用, 阻止了類被打上CLASS_ISPREVERIFIED標誌從而規避問題的出現。 最後加載補丁dex獲得dexFile對象做爲參數構建一個Element對象插入到dexElements數組的最前面。
提供dex差量包, 總體替換dex的方案。 差量的方式給出patch.dex, 而後將patch.dex與應用的classes.dex合併成一個完整的dex, 完整dex加載獲得的dexFile對象做爲參數構建一個Element對象而後總體替換掉舊的dexElements數組。數組

優勢:沒有合成整包,產物比較小,比較靈活。自研dex差別算法, 補丁包很小, dex merge成完整dex, Dalvik不影響類加載性能, Art下也不存在必須包含父類/ 引用類的狀況;安全

缺點:Dalvik下影響類加載性能,Art下類地址寫死, 致使必須包含父類/引用, 最後補丁包很大。dex合併內存消耗在vm heap上, 容易OOM, 最後致使dex合併失敗。微信

方案二函數

原理:提供dex差量包, 總體替換dex的方案。 差量的方式給出patch.dex, 而後將patch.dex與應用的classes.dex合併成一個完整的dex, 完整dex加載獲得的dexFile對象做爲參數構建一個Element對象而後總體替換掉舊的dexElements數組。工具

優勢:自研dex差別算法, 補丁包很小, dex merge成完整dex, Dalvik不影響類加載性能, Art下也不存在必須包含父類/引用類的狀況;性能

缺點:dex合併內存消耗在vm heap上, 容易OOM, 最後致使dex合併失敗。

咱們能清晰的看到兩個方案的缺點都很明顯。 這裏對tinker方案dex merge缺陷進行簡單說明一下: dex merge操做是在java層面進行,全部對象的分配都是在java heap上, 若是此時進程申請的java heap對象超過了vm heap規定的大小, 那麼進程發生OOM, 那麼系統memory killer可能會殺掉該進程, 致使dex合成失敗。 另一方面咱們知道jni層面C++ new/malloc申請的內存, 分配在native heap, native heap的增加並不受vm heap大小的限制, 只受限於RAM, 若是RAM不足那麼進程也會被殺死致使閃退。 因此若是隻是從dex merge方面思考,在jni層面進行dex merge, 從而能夠避免OOM提升dex合併的成功率。 理論上固然能夠,只是jni層實現起來比較複雜而已。

文章的開頭咱們說過, 咱們的需求是冷啓動模式是熱部署模式的補充兜底方案, 因此這兩個方案使用的應該是同一套補丁, 另一個方面跟代碼修復熱部署方案同樣, 咱們追求的是不侵入打包。 上述兩種方案都須要侵入應用打包過程, 同時補丁的結構也不同, 這兩套方案對咱們來講都是不適用。 因此咱們須要另闢蹊徑冷啓動修復, 尋求一種既能無侵入打包又能作熱部署模式下兜底補充的解決方案, 下面將對Dalvik虛擬機和Art虛擬機的冷啓動方案分別進行介紹。

Dalvik下冷啓動實現

插樁實現的來龍去脈

衆所周知, 若是僅僅把補丁類打入補丁包中而不作任何處理的話, 那麼運行時類加載的時候就會異常退出, 接下來先來看下拋這個異常的來龍去脈。

加載一個dex文件到本地內存的時候, 若是不存在odex文件, 那麼首先會執行dexopt, dexopt的入口在davilk/opt/OptMain.cpp的main方法, 最後調用到verifyAndOptimizeClass執行真正的verify/optimize操做。

圖片描述

apk第一次安裝的時候, 會對原dex執行dexopt, 此時假如apk只存在一個dex, 因此dvmVerifyClass(clazz)結果爲true。 因此apk中全部的類都會被打上CLASS_ISPREVERIFIED標誌,接下來執行dvmOptimizeClass, 類接着被打上CLASS_ISOPTIMIZED標誌。

dvmVerifyClass: 類校驗, 類校驗的目的簡單來講就是爲了防止類被篡改校驗類的合法性。 此時會對類的每一個方法進行校驗, 這裏咱們只須要知道若是類的全部方法中直接引用到的類(第一層級關係,不會進行遞歸搜索)和當前類都在同一個dex中的話, dvmVerifyClass就返回true。
dvmOptimizeClass: 類優化, 簡單來講這個過程會把部分指令優化成虛擬機內部指令, 好比方法調用指令: invoke-指令變成了invoke--quick, quick指令會從類的vtable表中直接取, vtable簡單來講就是類的全部方法的一張大表(包括繼承自父類的方法)。所以加快了方法的執行速率。
如今假如A類是補丁類, 因此補丁A類在單獨的dex中。 類B中的某個方法引用到補丁類A, 因此執行到該方法會嘗試解析類A。

圖片描述

上面的代碼很容易看出來, 類B因爲被打上了CLASS_ISPREVERIFIED標誌, 接下來referrer是類B, resClassCheck是補丁類A, 他們屬於不一樣的dex, 因此dvmThrowIllegalAccessError。 爲了解決這個問題, 一個單獨無關幫助類放到一個單獨的dex中, 原dex中全部類的構造函數都引用這個類,通常的實現方法都是侵入dex打包流程, 利用.class字節碼修改技術, 在全部.class文件的構造函數中引用這個幫助類, 插樁由此而來。 根據前面的介紹, dexopt過程當中dvmVerifyClass類校驗返回false, 原dex中全部的類都沒有CLASS_ISPREVERIFIED標誌, 所以解決運行時這個異常。

可是插樁是會給類加載效率帶來比較嚴重的影響的。 熟悉Dalvik虛擬機的同窗知道, 一個類的加載一般有三個階段, dvmResolveClass->dvmLinkClass->dvmInitClass, 這個三個階段不一一詳細進行說明。 dvmInitClass階段在類解析完畢嘗試初始化類的時候執行, 這個方法主要完成父類的初始化,當前類的初始化, static變量的初始化賦值等等操做。

能夠看到除了上面說的類初始化以外, 若是類沒被打上CLASS_ISPREVERIFIED/CLASS_ISOPTIMIZED標誌, 那麼類的Verify和Optimize都將在類的初始化階段進行。 正常狀況下類的Verify和Optimize都僅僅只是在apk第一次安裝執行dexopt的時候進行, 類的Verify其實是很重的, 由於會對類的全部方法中的全部指令都進行校驗, 單個類加載來看類Verify並不耗時, 可是若是同一時間點加載大量類的狀況下, 這個耗時就會被放大。 因此這也是插樁給類的加載效率帶來比較大影響的後果, 接下來來看下具體會給類加載帶來多大的影響。

更多有關Dalvik虛擬機的原理, 能夠自行下載源碼閱讀: https://android.googlesource.com 推薦姿式: sublime text + ctags

插樁致使類加載性能影響

圖片描述

上一小節的介紹, 咱們知道若採用插樁致使全部類都非preverify,這致使verify與optimize操做會在加載類時觸發。 這就會致使類加載有必定的性能損耗,微信作過一次測試, 分別採用優化和不優化兩種方式作過兩種測試, 分別採用插樁與不插樁兩種方式進行兩種測試,一是連續加載700個50行左右的類,一是統計應用啓動完成的整個耗時。

不插樁 插樁
700個類 84ms 685ms
啓動耗時 4934ms 7240ms
平均每一個類verify+optimize(跟類的大小有關係)的耗時並不長,並且這個耗時每一個類只有一次(類只會加載一次)。但因爲應用剛啓動時這種場景下通常會同時加載大量的類,在這個狀況影響仍是比較大的, 啓動的時候就容易白屏, 這點是無法容忍的。

另闢蹊徑解決方案

方案1 強制繞過類Verify階段

強制hook Dalvik虛擬機的dvmVerifyClass函數,讓其直接返回true,從而繞過加載的時候沒必要要的校驗機制,從而達到加快應用的啓動速度的目的。 實際上集團安所有已經有這樣的方案。 具體參考: dalvikUpSpeed技術介紹--加快android移動端低端機的啓動性能

可是這種方案也存在明顯的缺陷: 此時native hook的是一個涉及dalvik基礎功能同時調用很頻繁的方法,無疑可能存在比較大的風險。 另一方面這個仍是須要插樁的, 須要侵入打包流程, 打包時修改.class字節碼文件, 因爲咱們熱修復的基調是徹底不侵入打包流程, 因此須要尋求另一種更優雅的解決方案。

方案2 優雅實現避免插樁

手Q熱補丁輕量級方案給了咱們實現的思路, 簡單來說:

圖片描述

怎麼讓dvmDexGetResolvedClass返回的結果不爲null,只要調用過一次dvmDexSetResolvedClass(pDvmDex, classIdx, resClass);就好了,舉個例子簡單說明下。

圖片描述

咱們此時須要patch的類是類A, 因此類A被打入到一個獨立的補丁dex中。那麼執行到類B的test方法時, 執行到A.a()這行代碼時就會嘗試去解析類A, 此時dvmResolveClass(const ClassObject* referrer, u4 classIdx, bool fromUnverifiedConstant)

referrer: 實際上就是類B
classIdx:類A在原dex文件結構類區中的索引id
fromUnverifiedConstant: 是否const-class/instance-of指令
此時是調用的是A的靜態a方法, invoke-static指令不屬於const-class/instance-of這兩個指令中的一個。 不作任何處理的話, dvmDexGetResolvedClass一開始是null的。 而後A是從補丁dex中解析加載, B是在原Dex中, A在補丁dex中, 因此B->pDvmDex != A->pDvmDex, 接下來執行到dvmThrowIllegalAccessError從而致使運行時異常。 因此咱們要作的是, 必需要在一開始的時候, 就把補丁A類添加到原來dex(pDvmDex)的pResClasses數組中。 這樣就確保了執行B類test方法的時候, dvmDexGetResolvedClass不爲null, 就不會執行後面類A和類B的dex一致性校驗了。

具體實現, 首先咱們經過補丁工具反編譯dex爲smali文件拿到:

preResolveClz: 須要patch的類A的描述符, 非必須, 爲了調試方便加上該參數而已. --> Lcom/taobao/patch/demo/A;
refererClz: 須要patch的類A所在的dex的任何一個類描述符, 注意這裏不限定必須是引用補丁類A的某個類, 實際上只要同一個dex中的任何一個類均可以。 因此咱們直接拿原dex中的第一個類便可. --> Landroid/support/annotation/AnimRes;
classIdx: 須要patch的類A在原來dex文件中的類索引id. --> 2425
而後經過dlopen拿到libdvm.so庫的句柄, 而後經過dlsym拿到該so庫的dvmResolveClass/dvmFindLoadedClass函數指針。 首先須要預加載引用類->android/support/annotation/AnimRes, 這樣dvmFindLoadedClass("android/support/annotation/AnimRes")纔不爲null, dvmFindLoadedClass執行結果獲得的ClassObject作爲第一個參數執行dvmResolveClass(AnimRes, 2425, true)便可。
簡單看下JNI層代碼部分實現。 實際上能夠看到preResolveClz參數是非必須的。

圖片描述

完美解決。 這個思路與前面方案一的native hook方式不一樣,不會去hook某個系統方法,而是從native層直接調用, 同時更不須要插樁。 具體實現須要注意如下三點:

dvmResolveClass的第三個參數fromUnverifiedConstant必須爲true。
apk多dex狀況下,dvmResolveClass第一個參數referrer類必須跟須要patch的類在同一個dex, 可是他們兩個類不須要存在任何引用關係,任何一個在同一個dex中的類做爲referrer均可以。
referrer類必須提早加載。
Art下冷啓動實現

前面說過補丁熱部署模式下是一個完整的類, 補丁的粒度是類。 如今咱們的需求是補丁既能走熱部署模式也能走冷啓動模式, 爲了減小補丁包的大小, 並無爲熱部署和冷啓動分別準備一套補丁, 而是同一個熱部署模式下的補丁可以降級直接走冷啓動, 因此咱們不須要作dex merge。 可是前面咱們知道爲了解決Art下類地址寫死的問題, tinker經過dex merge成一個全新完整的新dex整個替換掉舊的dexElements數組。 事實上咱們並不須要這樣作, Art虛擬機下面默認已經支持多dex壓縮文件的加載了。

咱們分別來看下Dalvik下和Art下對DexFile.loadDex嘗試把一個dex文件解析加載到native內存都發生了什麼,實際上都是調用了DexFile.openDexFileNative這個native方法。 看下Native層對應的c/c++代碼具體實現。

Dalvik虛擬機下面:

圖片描述

static const char* kDexInJarName = "classes.dex"; 很明顯Dalvik嘗試加載一個壓縮文件的時候只會去把classes.dex加載到內存中... 若是此時壓縮文件中有多dex, 那麼除了classes.dex以外的其它dex被直接忽略掉。

Art虛擬機下面: 方法調用鏈DexFile_openDexFileNative-> OpenDexFilesFromOat -> LoadDexFiles

圖片描述

上面代碼咱們大概能夠看出來Art下面默認已經支持加載壓縮文件中包含多個dex, 首先確定優先加載primary dex其實就是classes.dex, 後續會加載其它的dex, 因此補丁類只須要放到classes.dex便可。 後續出如今其它dex中的"補丁類"是不會被重複加載的。 因此咱們獲得Art下最終的冷啓動解決方案: 咱們只要把補丁dex命名爲classes.dex. 原apk中的dex依次命名爲classes(2,3,4...).dex就行了, 而後一塊兒打包爲一個壓縮文件, 而後DexFile.loadDex獲得DexFile對象, 最後把該DexFile對象整個替換舊的dexElements數組就能夠了。

一張圖來看下咱們的方案和方案二的不一樣:

圖片描述

須要注意一點:

補丁dex必須命名爲classes.dex
loadDex獲得的DexFile完整替換掉dexElements數組而不是插入
不得不說的其它點

咱們知道DexFile.loadDex嘗試把一個dex文件解析並加載到native內存, 在加載到native內存以前, 若是dex不存在對應的odex, 那麼Dalvik下會執行dexopt, Art下會執行dexoat, 最後獲得的都是一個優化後的odex。 實際上最後虛擬機執行的是這個odex而不是dex。

如今有這麼一個問題,若是dex足夠大那麼dexopt/dexoat其實是很耗時的,根據上面咱們提到的方案, Dalvik下實際上影響比較小, 由於loadDex僅僅是補丁包。 可是Art下影響是很是大的, 由於loadDex是補丁dex和apk中原dex合併成的一個完整補丁壓縮包, 因此dexoat很是耗時。 因此若是優化後的odex文件沒生成或者沒生成一個完整的odex文件, 那麼loadDex便不能在應用啓動的時候進行的, 由於會阻塞loadDex線程, 通常是主線程。 因此爲了解決這個問題, 咱們把loadDex當作一個事務來看, 若是中途被打斷, 那麼就刪除odex文件, 重啓的時候若是發現存在odex文件, loadDex完以後, 反射注入/替換dexElements數組, 實現patch。 若是不存在odex文件, 那麼重啓另外一個子線程loadDex, 重啓以後再生效。

另一方面爲了patch補丁的安全性, 雖然對補丁包進行簽名校驗, 這個時候可以防止整個補丁包被篡改, 可是實際上由於虛擬機執行的是odex而不是dex, 還須要對odex文件進行md5完整性校驗, 若是匹配, 則直接加載。 不匹配,則從新生成一遍odex文件, 防止odex文件被篡改。

小結

代碼修復冷啓動方案因爲它的高兼容性, 幾乎能夠修復任何代碼修復的場景, 可是注入前被加載的類(好比:Application類)確定是不能被修復的。 因此咱們把它做爲一個兜底的方案, 在無法走熱部署或者熱部署失敗的狀況, 最後都會走代碼冷啓動重啓生效, 因此咱們的補丁是同一套的。 具體實施方案對Dalvik下和Art下分別作了處理:

Dalvik下經過巧妙的方式避免插樁, 沒有帶來任何類加載效率的影響。Art下本質上虛擬機已經支持多dex的加載, 咱們要作的僅僅是把補丁dex做爲主dex(classes.dex)加載而已。

相關文章
相關標籤/搜索