【騰訊Bugly乾貨分享】QFix探索之路—手Q熱補丁輕量級方案

NO IMAGE

本文來自於騰訊bugly開發者社群,非經作者同意,請勿轉載,原文地址:http://dev.qq.com/topic/57ff5…

導語

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/xwl19893…

主要思路是:每當系統呼叫到這個方法,通過 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 層直接呼叫:

  1. dvmResolveClass 方法是在 dalvik 的系統庫 /system/lib/libdvm.so 裡,通過 dlopen 即可獲取該系統庫的控制代碼

  2. 通過 dlsym 獲取 dvmResolveClass 這個方法的地址

  3. 設定 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 層的,我們也通過眾測方式對相容性做了充分的驗證:

  1. 不同系統版本匯出符號:
    在2.x版本dalvik是用C寫的,2.3以上的4.x版本是用C 寫的,基於C name mangling原理, dvmFindLoadedClass在編譯後會變為_Z18dvmFindLoadedClassPKc,但經IDA反彙編libdvm.so分析,dvmResolveClass沒有變化

  2. yunos ROM的相容性問題:
    在第一次眾測任務中,有446位使用者參與,其中有6位反饋補丁不生效的問題,從反饋的結果碼看都是libdvm.so載入成功,但是符號匯出為NULL導致的,後來發現這6位使用者安裝的都是yunos的rom,經分析定位到原因如下:

可以看到dlopen libdvm.so時將庫的名字改為了libvmkid_lemur.so,yunos的dalvik實現實際上在後面這個庫裡,而且通過反彙編發現匯出的符號名也變化了,但內部的實現邏輯沒有變化:

dvmResolveClass -> vResolveClass
_Z18dvmFindLoadedClassPKc -> _Z18kvmFindLoadedClassPKc

在dlsym呼叫時考慮以上兩種可能的符號名即可,經本地和以上問題使用者的再次驗證,已成功解決。

  1. x86平臺的相容性問題:
    解決了yunos的相容問題後,在第二次眾測任務中,有1884位使用者參與,有3位反饋異常,發現問題使用者都是x86平臺的,由於最開始未對x86平臺作相容,arm平臺的動態庫在x86手機上執行的異常有兩種:

a) 部分手機一直卡在黑屏介面,經日誌定位,這些手機都安裝了houndini的第三方庫,會自動將arm的so轉換為x86平臺相容的,so載入及符號匯出都沒問題,在成功獲取dvmResolveClass符號地址後,就一直卡在dvmResolveClass的呼叫邏輯裡,應該是houndini庫的轉換問題
b) 部分手機執行正常,但匯出符號都為NULL
在提供x86平臺的so後,以上兩個問題也成功解決了。

結語

本文探討的主要是為解決補丁java方案在dalvik下”unexpected DEX”異常提供一個新的思路,在整個android補丁大的技術框架下,只是其中一個環節,有問題,歡迎大家多多交流!


更多精彩內容歡迎關注bugly的微信公眾賬號:

騰訊 Bugly是一款專為移動開發者打造的質量監控工具,幫助開發者快速,便捷的定位線上應用崩潰的情況以及解決方案。智慧合併功能幫助開發同學把每天上報的數千條 Crash 根據根因合併分類,每日日報會列出影響使用者數最多的崩潰,精準定位功能幫助開發同學定位到出問題的程式碼行,實時上報可以在釋出後快速的瞭解應用的質量情況,適配最新的 iOS, Android 官方作業系統,鵝廠的工程師都在使用,快來加入我們吧!