閱讀417 返回首頁    go 阿裏雲 go 技術社區[雲棲]


Android熱修複升級探索——代碼修複冷啟動方案

前言

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

冷啟動實現方案概述

冷啟動重啟生效,現在一般有以下兩種實現方案, 同時給出他們各自的優缺點:
1. QQ空間

原理: 為了解決Dalvik下unexpected dex problem異常而采用插樁的方式, 單獨放一個幫助類在獨立的dex中讓其他類調用, 阻止了類被打上CLASS_ISPREVERIFIED標誌從而規避問題的出現。 最後加載補丁dex得到dexFile對象作為參數構建一個Element對象插入到dexElements數組的最前麵。
參考文檔: QQ空間Android熱補丁動態修複技術介紹

提供dex差量包, 整體替換dex的方案。 差量的方式給出patch.dex, 然後將patch.dex與應用的classes.dex合並成一個完整的dex, 完整dex加載得到的dexFile對象作為參數構建一個Element對象然後整體替換掉舊的dexElements數組。
參考文檔: 微信Android熱補丁實踐演進之路

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

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

2. Tinker

原理:提供dex差量包, 整體替換dex的方案。 差量的方式給出patch.dex, 然後將patch.dex與應用的classes.dex合並成一個完整的dex, 完整dex加載得到的dexFile對象作為參數構建一個Element對象然後整體替換掉舊的dexElements數組。
參考文檔: 微信Android熱補丁實踐演進之路

優點:自研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操作。

_1

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。

_2

上麵的代碼很容易看出來, 類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變量的初始化賦值等等操作。

_3

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

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

插樁導致類加載性能影響

_4

上一小節的介紹, 我們知道若采用插樁導致所有類都非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熱補丁輕量級方案給了我們實現的思路, 簡單來講:

_5

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

_6

我們此時需要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參數是非必須的。

_7

完美解決。 這個思路與前麵方案一的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虛擬機下麵:

_8

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

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

_9

上麵代碼我們大概可以看出來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數組就可以了。

一張圖來看下我們的方案和tinker方案的不同:

_10

需要注意一點:

  • 補丁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)加載而已。

最後更新:2017-10-10 16:03:20

  上一篇:go  10.11杭州Clouder lab 十分鍾搭建共享應用1:函數計算及表格存儲操作說明
  下一篇:go 投資機會:雲棲大會召開在即 機器視覺站上風口