QFix探索之路—手Q熱補丁輕量級方案
QFix 是手Q團隊近期推出的一種新的 Android 熱補丁方案,在不影響 app 運行時性能(無需插樁去 preverify)的前提下有效地規避了 dalvik 下”unexpected DEX”的異常,而且還是很輕量級的實現:只需調用一個很簡單的方法就能辦到。
熱補丁方案及手Q上的使用
自2015年 Android 熱補丁技術開始出現,之后各種方案和框架層出不窮,原創性的技術方案主要有以下幾種:
手Q從去年開始研究補丁方案,當時微信的 Tinker 還沒有推出,考慮到兼容性和穩定性,就選用了 java 反射 hack classloader 的方案,而且和當時已經很成熟的分 dex 從原理上很類似,主要的難點是如何解決 Qzone 發現的 dalvik 下”unexpected DEX”異常,由于沒有研究出其它方法,就沿用了 Qzone 原創的插樁去 preverify 的解決方案,自2016年1月熱補丁開始在手Q正式版本投入使用,至今解決問題十多個,修復效果十分明顯,穩定性也很好。
性能無法提升,需要改變
插樁的解決方案會影響到運行時性能的原因在于:app 內的所有類都預埋引用一個獨立 dex 的空類,導致安裝 dexopt 階段的 preverify 失敗,運行時將再次 verify+optimize。近期我們通過 ReDex 嘗試優化手Q的啟動性能時發現:
-
保留手Q現有的插樁,啟動性能沒有任何優化效果
-
去掉插樁,優化手Q啟動相關類的 dex 分布,啟動性能提升 30%
另外即使后期手Q的發布版本實際上無需發布補丁,我們也需要預埋插樁的邏輯,這本身也是不合理的一點,所以確實有必要去探索新的方向,既保留補丁的能力,同時去掉插樁帶來的負面影響。
重新分析”unexpected DEX”異常
尋找新的解決方案,還是需要回過頭來分析下這個異常出現的條件:
這是 dalvik 的一段源碼,當補丁安裝后,首次使用到補丁里的類時會調用到這里,需要同時滿足圖中標出來的三個條件,才能出現異常,這三個條件的含義如下:
可以看出,Qzone 的插樁方案是突破了 條件2 的限制(統一去掉了所有引用類的 preverify 標志),而微信 Tinker 的 dex 增量合成方案是突破了條件3的限制(將補丁和 app dex 合成后替換,原先 app 里在同一個 dex 的兩個類,其中一個后來打在補丁里,合成后還是會在同一個 dex里),那有沒有辦法從條件1入手呢? 條件1 中 fromUnverifiedConstant 為 true 就行,其實之前就有從這個條件進行突破的方案:
http://blog.csdn.net/xwl198937/article/details/49801975
主要思路是:每當系統調用到這個方法,通過 native hook 攔截這個系統方法,更改這個方法的入口參數,將 fromUnverifiedConstant 統一改為 true,但和 Andfix 類似,native hook 方式存在各種兼容性和穩定性問題,而且攔截的是一個涉及 dalvik 基礎功能同時調用很頻繁的方法,無疑風險會大很多。
找到新的“大陸”
這段邏輯所在的方法是 dvmResolveClass,通過類之間的引用會調用這個方法,入口參數分別是引用類的 ClassObject,被引用類的 classIdx,以及引用關聯的 dalvik 指令是否為 const-class/instance-of,返回的是被引用類的 ClassObject,經反復閱讀分析,終于發現了一個可以利用的細節:
dvmResolveClass 在最開始會優先從當前 dex 已解析類的緩存里找被引用類,找到了直接返回,找不到時說明被引用類還沒有被加載,接著加載成功后,會往當前 dex 緩存里設置上這個類的引用,后續所有對補丁類的解析引用都不會走到后面的“unexpected DEX”異常邏輯里,至于 dex 里已解析類 get/set 的相關邏輯如下:
結合以上分析,我想到一個思路:只需首次引用到補丁類時能夠成功突破上述三個條件之一的限制即可,Qzone 突破 條件2 和 Tinker 突破 條件3 的方法操作過重,而且帶來的影響是持續性的,而從 條件1 入手很簡單:補丁安裝后,預先以 const-class/instance-of 方式主動引用補丁類,這次引用會觸發加載補丁類并將引用放入 dex 的已解析類緩存里,后續 app 實際業務邏輯引用到補丁類時,直接從已解析緩存里就能取到,這樣很簡單地就繞開了“unexpected DEX”異常,而且這里只是很簡單地執行了一條輕量級的語句,并沒有其它額外的影響。
另外考慮多 dex 的情況,補丁類很可能被多個不同 dex 里的類引用,那么需要在每個 dex 里找到一個引用類來預先引用補丁類嗎?如果 app 里引用類和補丁類原本是在同一個 dex 里,引用類有可能是 preverify 的,這種情況是需要預先引用的;如果原本就不是一個 dex 里的,引用類由于有對其它 dex 類的依賴,就肯定不是 preverify 的,這種情況條件2本來就是不滿足的,就沒有必要預先引用了,所以可以推斷出只需要針對補丁類在原先 app 所對應的 dex 進行預先引用即可。
梳理了思路后,馬上在一個簡單的 demo 上驗證:
demo 里補丁包含的類是 BugObject,通過對比,如果代碼不包含上圖紅框里的預先引用的邏輯,出現了預期的“unexpected DEX”異常,如果加上這一行代碼,demo 運行正常,而且補丁的修復功能也生效。通過 dexdump 查看,確實是優先通過 const-class 指令引用補丁類的。
沒那么簡單,初步方案行不通
上面的 demo 預埋了補丁里包含的類,但在實際運用中我們是無法預先設定哪些類要打補丁的,dex 里對補丁類 const-class/instance-of 方式的引用指令是編譯時確定的,但具體是哪些類又需要在運行時動態確定,所以這種動態方式行不通,最初想到的是類似插樁的做法,預先把 app 里所有類都以 const-class 方式引用一遍,但很明顯有以下問題:
1)由于 app 里類的數量很多,所有類的預先引用統一放在一個地方肯定不現實,需要分散在多個區,只對補丁類所在的少數幾個區執行預先引用的操作,但這里如何劃分的粒度不好把握,而且 app 里的類及數量一直變化,我們做過一些嘗試,但沒有比較理想的可考量的方案。
2)預先引用解析所有類,會增加引用類的加載耗時和引用語句本身的執行耗時,對于執行耗時,可以通過添加條件判斷來優化,如果要解析的類在補丁類名列表里就執行該語句,否則就不執行,對于加載耗時,初步的測試結果如下(這里一個劃分的區包含500個左右的類,并進一步區分了是否 preverify,而測試的補丁包里包含2個類):
從測試數據看,加載的耗時較長,而且補丁類不可預期,如果不巧分布在多個區里,累計耗時的影響將會嚴重得多。
3)該方案實現起來特別繁瑣,不實用
確定最終方案
新的方案在 java 層找不到可行的實現方式,就嘗試從 native 層切入,只需首次引用解析補丁類時,直接通過 jni 調用 dalvik 的 dvmResolveClass 這個方法,當然傳入的參數 fromUnverifiedConstant 需要設為 true,這個思路與前面說的 native hook 方式不同,不會去 hook 這個系統方法,而是從 native 層直接調用:
-
dvmResolveClass 方法是在 dalvik 的系統庫 /system/lib/libdvm.so 里,通過 dlopen 即可獲取該系統庫的句柄
-
通過 dlsym 獲取 dvmResolveClass 這個方法的地址
-
設定 dvmResolveClass 這個方法的三個入口參數,再調用 dvmResolveClass:
1)引用類 referrer 的 ClassObject:這里需要設定一個引用類,并且能夠獲取到該類的 ClassObject
2)補丁類的 classIdx:需要獲取補丁類在 app 原先所在 dex 的 classIdx,通過這個 classIdx 可以在 dex 里找到已解析的類或者獲取類的名字
3)布爾值 fromUnverifiedConstant:在C/C++層,這個值可以固定設置為1或者 true
這里的關鍵是能獲取到前兩個參數的值,第一個參數引用類的 ClassObject,最初借鑒的是 dvmResolveClass 里調用的 dvmFindClassNoInit 這個方法,但這個方法獲取一個類的 ClassObject 需要兩個參數,其中類名很容易構造,但需要額外的操作獲取引用類的 ClassLoader 對象的地址,之后又找到一個更便利的方法 dvmFindLoadedClass:
這個方法只用傳入類的描述符即可,但必須是已經加載成功的類,在補丁注入成功后,在每個 dex 里找一個固定的已經加載成功的引用類并不難。對于主dex,直接用 XXXApplication 類就行,對于其它分 dex,手Q的分 dex 方案有這樣的邏輯:每當一個分 dex 完成注入,手Q都會嘗試加載該 dex 里的一個固定空類來驗證分 dex 是否注入成功了,所以這個固定的空類可以作為補丁的引用類使用。第二個參數 classIdx,可以通過 dexdump -h 獲取:
這個過程可以通過一個小程序自動進行:
輸入:原有 apk 的所有 dex、補丁包所有的類名
輸出:補丁包每個類所在 dex 的編號以及 classIdx 的值
注1:如果在補丁新增原 app 不存在的類,運行時新增類只會被補丁 dex 即同一個 dex 里的類所引用,所以新增的補丁類無需預先解析引用。
注2:由于”unexpected DEX”異常出現在 dalvik 的實現里,art 模式下不會存在,以上預先引用補丁類的邏輯只需用在5.0以下的系統。
最終新方案的整體實現流程如下圖所示:
可以看出,新的方案是很輕量級的實現,只需一個很簡單的 jni 方法調用就能解決問題,既不用構建時預先插樁去 preverify,也不用下載補丁后進行 dex 的全量合成。
兼容性問題及解決
這個方案由于是 native 層的,我們也通過眾測方式對兼容性做了充分的驗證:
-
不同系統版本導出符號:
在2.x版本dalvik是用C寫的,2.3以上的4.x版本是用C++寫的,基于C++ name mangling原理, dvmFindLoadedClass在編譯后會變為_Z18dvmFindLoadedClassPKc,但經IDA反匯編libdvm.so分析,dvmResolveClass沒有變化
-
yunos ROM的兼容性問題:
在第一次眾測任務中,有446位用戶參與,其中有6位反饋補丁不生效的問題,從反饋的結果碼看都是libdvm.so加載成功,但是符號導出為NULL導致的,后來發現這6位用戶安裝的都是yunos的rom,經分析定位到原因如下:
可以看到dlopen libdvm.so時將庫的名字改為了libvmkid_lemur.so,yunos的dalvik實現實際上在后面這個庫里,而且通過反匯編發現導出的符號名也變化了,但內部的實現邏輯沒有變化:
dvmResolveClass -> vResolveClass _Z18dvmFindLoadedClassPKc -> _Z18kvmFindLoadedClassPKc
在dlsym調用時考慮以上兩種可能的符號名即可,經本地和以上問題用戶的再次驗證,已成功解決。
-
x86平臺的兼容性問題:
解決了yunos的兼容問題后,在第二次眾測任務中,有1884位用戶參與,有3位反饋異常,發現問題用戶都是x86平臺的,由于最開始未對x86平臺作兼容,arm平臺的動態庫在x86手機上運行的異常有兩種:
a)部分手機一直卡在黑屏界面,經日志定位,這些手機都安裝了houndini的第三方庫,會自動將arm的so轉換為x86平臺兼容的,so加載及符號導出都沒問題,在成功獲取dvmResolveClass符號地址后,就一直卡在dvmResolveClass的調用邏輯里,應該是houndini庫的轉換問題
b)部分手機運行正常,但導出符號都為NULL
在提供x86平臺的so后,以上兩個問題也成功解決了。
來自:http://mp.weixin.qq.com/s?__biz=MzA3NTYzODYzMg==&mid=2653577964&idx=1&sn=bac5c8883b7aaaf7d7d9ea227f200412&chksm=84b3b0ebb3c439fd56a502a27e1adc18f600b875718e537191ef109e2d18dae1c52e5e36f2d9&scene=4#wechat_redirect