深入探索Android熱修復技術原理讀書筆記 —— so庫熱修復技術


熱修復系列文章:

深入探索Android熱修復技術原理讀書筆記 —— 熱修復技術介紹

深入探索Android熱修復技術原理讀書筆記 —— 代碼熱修復技術

深入探索Android熱修復技術原理讀書筆記 —— 資源熱修復技術

1. SO庫加載原理

Java Api 提供以下兩個接口加載一個 so 庫

  • System. loadLibrary (String libName):傳進去的參數:so 庫名稱, 表示的 so 庫文件,位於apk壓縮文件中的 libs 目錄,最后復制到 apk 安裝目錄下。

  • System, load (String pathName):傳進去的參數:  so 庫在磁盤中的完整 路徑。加載一個自定義外部 so 庫文件。

上述兩種方式加載一個 so 庫,實際上最后都調用 nativeLoad 這個 native 法去加載 so 庫,這個方法的 fileName:so 庫在磁盤中的完整路徑名。

代碼+圖文的方式簡述 so 庫加載原理,下面的代碼示例,stringFromJNI -> Java_com_taobao_jni_MainActivity_stringFromJNI 靜態注冊的 native 法,test->test 動態注冊的 native 方法。

我們知道 JNI 編程中,動態注冊的 native 方法必須實現 JNI_OnLoad 方法,同時實現一個 JNINativeMethod [] 數組,靜態注冊的 native 方法必須是 Java+完整路徑+方法名的格式。

 

總結下:

  • 動態注冊的 native 方法映射通過加載 so 庫過程中調用 JNI_onLoad 方法調用完成。

  • 靜態注冊的 native 方法映射是在該 native 方法第一次執行的時候才完成映射,當然前提是該 so 庫已經 load 過。

2. SO庫熱部署實時生效可行性分析

2.1. 動態注冊 native 方法實時生效

前面我們分析過 so 庫的加載原理,我們知道動態注冊的 native 方法調用一次 JNI_OnLoad 方法都會重新完成一次映射,所以我們是否只要先加載原來的 so , 然后再加載補丁 so 庫,就能完成Java層 native 方法到 native 層 patch 后的新方法映射,這樣就完成動態注冊 native 方法的 patch 實時修復。一張圖說明

 

實測發現 art 下這樣是可以做到實時生效的,但是 Dalvik 下做不到實時生效,通 過代碼測試我們發現,實際上 Dalvik 下第二次 load 補丁 so 庫,執行的仍然是原來 so 庫的 JNI_0nLoad 方法,而不是補丁 so 庫的 JNI_OnLoad 方法,所以 Dalvik 下做不到實時生效。我們來簡單分析下,既然拿到的是原來 so 庫的 JNI_OnLoad 法,那么我們首先懷疑以下兩個函數是否有問題。

  • • dlopen() :返回給我們一個動態鏈接庫的句柄

  • • disym() :通過一個 dlopen 得到的動態連接庫句柄,來查找一個 symbol

首先來看下 Dalvik 虛擬機下面 dlopen 的實現,源碼在 /bionic/linker/dlfcn.cpp 文件,方法調用鏈路:dlopen -> do_d.lopen -> find_library -> find_library_internal

findloadedlibrary 方法判斷 name 表示的 so 庫是否已經被加載過,如果加載過直接返回之前加載 so 庫的句柄,沒有加載過,調用 load_library 嘗試加載 so 庫

看代碼注釋,也知道其實這是Dalvik虛擬機下的一個 bug,這里它是通過 basename 去做查找,傳進來的參數 name 實際上是 so 庫所在磁盤的完整路徑,比如此時修復后的 so 庫的路徑為 /data/data/com. taobao. jni/files/libnative-lib.so。 但是此時是通過 bname : libnative-lib.so 作為 key 去查找, 我們知道第一次加載原來的 so 庫 System.loadLibrary ( "native-lib");實際上已經在 solist 表中存在了 native-lib 這個 key,所以 Dalvik 下面加載修復后的補丁 so 拿到的還是原 so 庫文件的句柄,所以執行的仍然是原來 so 庫的 JNI_ OnLoad 方法,Art 下不存在這個問題,是因為 Art 下這個地方是以 name 作為 key 去查找而不是 bname,所以 art 重新 load —遍補丁 so 庫:拿到的是補丁 so 的句柄,然后執行補丁庫的 JNI OnLoad。

所以為了解決 Dalvik 下面的這個問題,那么如果嘗試對補丁 so 進行改名,比如 此處補丁 so 庫的完整路徑修改之后變成 /data/data/com.taobao.jni/files/ libnative-lib-123333.so,后面一串數字是當前時間戳,確保這個 bname 全局唯一的,按照上面的分析,在 solist 中查找的 key 已經是唯一的,所以此時可以做到 Dalvik 下面動態注冊的 native 方法的實時生效。

2.2. 靜態注冊 native 方法實時生效

上面通過嘗試對補丁 so 庫進行重命名為全局唯一的名稱可以確保第二次加載補丁 so 庫可以做到 Dalvik 下和 Art 下動態注冊方法的實時生效,但要做到靜態注冊 native 方法的實時生效還需要更多工作。

前面我們說過靜態注冊 native 方法的映射是在 native 方法第一次執行的時候就完成了映射,所以如果 native 方法在加載補丁 so 庫之前已經執行過了,那么是否這種時候這個靜態注冊的 native 方法一定得不到修復?幸運的是,系統 JNI API 提供 了解注冊的接口。

UnregisterNatives 函數會把 jclazz 所在類的所有 native 方法都重新指向 dvmResolveNativeMethod,所以調用 UnregisterNatives 之后不管是靜態注冊還是動態注冊的 native 方法之前是否執行過在加載補丁 so 的時候都會重新去做映射。所以我們只需要以下調用。

 

這里有一個難點,因為 native 方法的修改是在 so 庫中,所以我們的補丁工具很難檢測出到底是哪個 Java 類需要解注冊 native 方法。這個問題暫且放下。假設我們能知道哪個類需要解注冊native方法,然后 load 補丁 so 庫之后,再次執行該 native 方法,這樣看起來是可以讓該 native 方法實時生效,但是測試發現,在補丁 so 庫重命名的前提下,java 層 native 方法可能映射到原so 庫的方法,也可能映射到補丁 so 庫的修復后的新方法。

首先靜態注冊的 native 方法之前從未執行,首先嘗試解析該方法。或者調用 unregisterJNINativeMethods 解注冊方法,那么該方法將指向 meth->nativeFunc = dvmResolveNativeMethod,那么真正運行該方法的時候,實際上執行的是 dvmResolveNativeMethod 函數。這個函數主要完成 java 層 native 法和 native 層方法的映射邏輯。

 

gDvm.nativeLibs 是一個全局變量,它是一個hashtable,存放着整個虛擬機加載 so 庫的 SharedLib 結構指針。然后該變量作為參數傳遞給 dvmHashForeach 函數進行 hashtable 遍歷。執行 findMethodInLib 函數看是否找到對應的 native  數指針,如果第一個找到就直接 return,不在進行下次的查找。

這個結構很重要,在虛擬機中大量使用到了 hashtable 這個數據結構,hashtable 的實現源碼在 dalvik/vm/Hash.h dalvik/vm/Hash.cpp 文件中,有興趣可以自行查看源碼,這里不進行詳細分析。hashtable 的遍歷和插入都是在 dvmHashTableLookup 方法中實現,簡單說下 java.hashtable c.hashtable 的異同點:

  • 共同點:兩者實際上都是數組實現,hashtable 容量如果超過默認值都會進行擴容,都是對 key 進行 hash 計算然后跟 hashtable 的長度進行取模作 bucket。

  • 不同點:Dalvik 虛擬機下 hashtable put/get 操作實現方法,實際上實現要 比 java hashmap 的實現要簡單一些,java hashmap 的 put 實現需要處理 hash沖突的情況,一般情況下會通過在沖突節點上新增一個鏈表處理沖突, 然后get實現會遍歷這個鏈表通過 equals 方法比較 value 是否一致進行查找,davlik 下 hashtable 的 put 實現上 (doAdd=true) 只是簡單的把指針 下移直到下一個空節點。get 實現 (doAdd=false) 首先根據 hash 值計算出 bucket 位置,然后通過 cmpFunc 函數比較值是否一致,不一致,指針下移。 hashtable 的遍歷實際就是數組遍歷實現

知道了 davlik 下 hashtable 的實現原理,那我們再來看下前面提到的:補丁 so 庫重命名的前提下,為什么 java 層 native 方法可能映射到原 so 庫的方法也可能映射到補丁 so 庫的修復后的新方法。一張圖說明情況

 

所以我們可以得到結論:

  • 對補丁 so庫進行重命名后,如果這個補丁 so 庫在 hashtable 中的位置比原 so 庫的位置靠前,那么這個靜態注冊 native 方法就能夠得到修復,位置如果靠后就得不到修復。

2.3. SO 實時生效方案總結

基於上面的分析,so 庫的實時生效必須滿足以下幾點:

  • so 庫為了兼容 Dalvik 虛擬機下動態注冊 native 方法的實時生效,必須對 so 文件進行改名。

  • 針對 so 庫靜態注冊 native 方法的實時生效,首先需要解注冊靜態注冊的 native 方法,這個也是難點,因為我們很難知道 so 庫中哪幾個靜態注冊的 native 方法發生了變更。假設就算我們知道如果靜態注冊的 native 方法需要解注冊,重新 load 補丁 so 庫也有可能被修復也有可能不被修復。

  • 上面對補丁 so 進行了第二次加載,那么肯定是多消耗了一次本地內存,如果 補丁 so 庫夠大,補丁 so 夠多,那么 JNI 層的 OOM 也不是沒可能

  • 另外一方面補丁 so 如果新增了一個動態注冊的方法而dex中沒有相應方法, 直接去加載這個補丁 so 文件會報 NoSuchMethodError 異常,具體邏輯在 dvmRegisterJNIMethod 中。我們知道如果dex如果新增了—native 方法,那么走不了熱部署只能冷啟動重啟生效,所以此時補丁 so 就不能第二 次 load 了。這種情況下 so 庫的修復嚴重依賴於dex的修復方案。

可以看到 so 庫實時生效方案,對於靜態注冊的 native 方法有一定的局限性, 不能滿足一般的通用性,所以最后我們放棄了 so 庫的實時生效需求,轉而求次實現 so庫修復的冷部署重啟生效方案。

3. SO庫冷部署重啟生效實現方案

為了更好的兼容通用性,我們嘗試通過冷部署重啟生效的角度分析下補丁 so 的修復方案。

3.1. 接口調用替換方案

sdk 提供接口替換 System 默認加載 so 庫接口

SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName) 

SOPatchManager.loadLibrary 接口加載 so 庫的時候優先嘗試去加載 sdk 指定目錄下的補丁 so加載策略如下:

  • 如果存在則加載補丁 so 庫而不會去加載安裝 apk 安裝目錄下的 so 

  • 如果不存在補丁 so,那么調用 System.loadLibrary 去加載安裝 apk 錄下的 s庫。

我們可以很清楚的看到這個方案的優缺點:

  • 優點:不需要對不同 sdk 版本進行兼容,因為所有的 sdk 版本都有 System.loadLibrary 這個接口。

  • 缺點:調用方需要替換掉 System 默認加載 so 庫接口為 sdk 提供的接口, 如果是已經編譯混淆好的三方庫的 so 庫需要 patch,那么是很難做到接口的替換。

雖然這種方案實現簡單,同時不需要對不同 sdk 版本區分處理,但是有一定的局限性沒法修復三方包的 so 庫同時需要強制侵入接入方接口調用,接着我們來看下反射注入方案。

3.2. 反射注入方案

前面介紹過 System. loadLibrary ( "native-lib"); 加載 so 庫的原理,其實 native-lib 這個 so 庫最終傳給 native 方法執行的參數是 so 庫在磁盤中的完整路徑,比如:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so 會在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 變量所表示的目錄下去遍歷搜索。

sdk<23 DexPathList.findLibrary 實現如下

  

可以發現會遍歷 nativeLibraryDirectories 數組,如果找到了 loUtils.canOpenReadOnly (path)返回為 true, 那么就直接返回該 path, loUtils.canOpenReadOnly (path)返回為 true 的前提肯定是需要 path 表示的 so 文件存 在的。那么我們可以采取類似類修復反射注入方式,只要把我們的補丁 so 庫的路徑插入到 nativeLibraryDirectories 數組的最前面就能夠達到加載 so 庫的時候是補丁 庫而不是原來 so 庫的目錄,從而達到修復的目的。

sdk>=23 DexPathList.findLibrary 實現如下

 

sdk23 以上 findLibrary 實現已經發生了變化,如上所示,那么我們只需要把補丁 so 庫的完整路徑作為參數構建一個 Element 對象,然后再插入到 nativeLibraryPathElements 數組的最前面就好了。 

  • 優點:可以修復三方庫的 so 庫。同時接入方不需要像方案1 —樣強制侵入用 戶接口調用

  • 缺點:需要不斷的對 sdk 進行適配,如上 sdk23 為分界線,findLibrary 口實現已經發生了變化。

我們知道在不管是在補丁包中還是 apk 中一個 so 庫都存在多種 cpu 架構的 so 文件,比如"armeabi","arm64-v8a","x86"等。加載肯定是加載其中一個 so 庫文件的,如何選擇機型對應的 so 庫文件將是重點所在。

4. 如何正確復制補丁 SO

上面提到的一個問題,這里不打算詳細介紹。有需要的參考文檔:Android動態 鏈接庫加載原理及HotFix方案介紹,這篇文檔有些觀點不盡正確,但是我也能知道虛擬機究竟選擇哪個 abis 目錄作為參數構建 PathClassLoader 對象,一張圖簡單了解下原理

實際上補丁 so 也存在類似的問題,我們的補丁 so 庫文件放到補丁包的 libs 錄下面,libs 目錄和 .dex 文件和 res 資源文件一起打包成一個壓縮文件作為最后的補丁包,libs 目錄可能也包含多種 abis 目錄。所以我們需要選擇手機最合適的 primaryCpuAbi,然后從 libs 目錄下面選擇這個 primaryCpuAbi 子目錄插入到 nativeLibraryDirectories/nativeLibraryPathElements 數組中。所以怎么選擇 primaryCpuAbi 是關鍵,來看下我們 sdk 具體的實現

  • sdk>=21 時,直接反射拿到 Applicationinfo 對象的 primaryCpuAbi 即可

  • sdk<21 時,由於此時不支持 64 位,所以直接把Build.CPU_ABI, Build.CPU_ABI2 作為 primaryCpuAbi 即可

5. 本章小結

對於 so 庫的修復方案目前更多采取的是接口調用替換方式,需要強制侵入用戶 接口調用。目前我們的 so 文件修復方案采取的是反射注入的方案,重啟生效。具有更好的普遍性。如果有 so 文件修復實時生效的需求,也是可以做到的,只是有些限制情況。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM