447
技術社區[雲棲]
Android熱修複升級探索——SO庫修複方案
一、前言
通常情況下,大多數人希望android下熱補丁方案能夠做到補丁的全方位修複,包括類修複/資源修複/so庫的修複。 這裏主要介紹熱補丁之so庫修複思路。
二、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過。
三、so庫熱部署實時生效可行性分析
1.動態注冊native方法實時生效
前麵我們分析過so庫的加載原理, 我們知道動態注冊的native方法調用一次JNI_OnLoad方法都會重新完成一次映射, 所以我們是否隻要先加載原來的so庫,,然後再加載補丁so庫,就能完成Java層native方法到native層patch後的新方法映射, 這樣就完成動態注冊native方法的patch實時修複。一張圖說明:
實測發現art下這樣是可以做到實時生效的,但是Dalvik下做不到實時生效,通過代碼測試我們發現, 實際上Dalvik下第二次load補丁so庫, 執行的仍然是原來so庫的JNI_OnLoad方法, 而不是補丁so庫的JNI_OnLoad方法, 所以Dalvik下做不到實時生效。 我們來簡單分析下, 既然拿到的是原來so庫的JNI_OnLoad方法, 那麼我們首先懷疑以下兩個函數是否有問題。
dlopen():返回給我們一個動態鏈接庫的句柄
dlsym(): 通過一個dlopen得到的動態連接庫句柄,來查找一個symbol
首先來看下Dalvik虛擬機下麵dlopen的實現, 源碼在/bionic/linker/dlfcn.cpp文件, 方法調用鏈路:dlopen-> do_dlopen -> 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庫的句柄, 然後執行補丁so庫的JNI_OnLoad**。
所以為了解決Dalvik下麵的這個問題, 那麼如果**嚐試對補丁so進行改名**,比如此處補丁so庫的完整路徑修改之後變成/data/data/com.taobao.jni/files/libnative-lib-123333.so, 後麵一串數字是當前時間戳, 確保這個bname是全局唯一的, 按照上麵的分析, 在solist中查找的key已經是唯一的,所以此時可以做到Dalvik下麵動態注冊的native方法的實時生效。
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方法就能夠得到修複, 位置如果靠後就得不到修複。
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庫修複的冷部署重啟生效方案。
四、so庫冷部署重啟生效實現方案
為了更好的兼容通用性, 我們嚐試通過冷部署重啟生效的角度分析下補丁so庫的修複方案。
方案1. 接口調用替換
sdk提供接口替換System默認加載so庫接口
SOPatchManager.loadLibrary接口加載so庫的時候優先嚐試去加載**sdk指定目錄下的補丁so**, 加載策略如下:
如果存在則加載補丁so庫而不會去加載安裝apk安裝目錄下的so庫。
如果不存在補丁so, 那麼調用System.loadLibrary去加載安裝apk目錄下的so庫。
我們可以很清楚的看到這個方案的優缺點:
優點:不需要對不同sdk版本進行兼容, 因為所有的sdk版本都有System.loadLibrary這個接口。
缺點: 調用方需要替換掉System默認加載so庫接口為sdk提供的接口, 如果是已經編譯混淆好的三方庫的so庫需要patch, 那麼是很難做到接口的替換。
雖然這種方案實現簡單, 同時不需要對不同sdk版本區分處理,但是有一定的局限性沒法修複三方包的so庫同時需要強製侵入接入方接口調用, 所以來看下**方案2. 反射注入**。
方案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數組, 如果找到了IoUtils.canOpenReadOnly(path)返回為true, 那麼就直接返回該path, IoUtils.canOpenReadOnly(path)返回為true的前提肯定是需要path表示的so文件存在的。 那麼**我們可以采取類似類修複反射注入方式, 隻要把我們的補丁so庫的路徑插入到nativeLibraryDirectories數組的最前麵就能夠達到加載so庫的時候是補丁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庫文件將是重點所在。
五、如果正確複製補丁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即可 。
六、小結
最後做一個簡單的小結:
so文件修複方案目前更多采取的是接口調用替換方式, 需要強製侵入用戶接口調用。 目前我們的so文件修複方案采取的是反射注入的方案, 重啟生效, 具有更好的普遍性。
同時如果有so文件修複實時生效的需求, 也是可以做到的,隻是有些限製情況, 詳見以上分析。
最後更新:2017-09-26 19:03:34