Android热修复升级探索——SO库修复方案
摘要: 通常情況下,大多數(shù)人希望android下熱補(bǔ)丁方案能夠做到補(bǔ)丁的全方位修復(fù),包括類修復(fù)/資源修復(fù)/so庫的修復(fù)。 這里主要介紹熱補(bǔ)丁之so庫修復(fù)思路。
一、前言
通常情況下,大多數(shù)人希望android下熱補(bǔ)丁方案能夠做到補(bǔ)丁的全方位修復(fù),包括類修復(fù)/資源修復(fù)/so庫的修復(fù)。 這里主要介紹熱補(bǔ)丁之so庫修復(fù)思路。
二、so庫加載原理
Java Api提供以下兩個接口加載一個so庫
System.loadLibrary(String libName):傳進(jìn)去的參數(shù):so庫名稱, 表示的so庫文件,位于apk壓縮文件中的libs目錄,最后復(fù)制到apk安裝目錄下。
System.load(String pathName):傳進(jìn)去的參數(shù):so庫在磁盤中的完整路徑, 加載一個自定義外部so庫文件 。
上述兩種方式加載一個so庫,實(shí)際上最后都調(diào)用nativeLoad這個native方法去加載so庫, 這個方法的參數(shù)fileName:so庫在磁盤中的完整路徑名,代碼+圖文的方式簡述so庫加載原理,下面的代碼示例,stringFromJNI-> Java_com_taobao_jni_MainActivity_stringFromJNI靜態(tài)注冊的native方法,test->test動態(tài)注冊的native方法. 。
我們知道JNI編程中,動態(tài)注冊的native方法必須實(shí)現(xiàn)JNI_OnLoad方法,同時實(shí)現(xiàn)一個JNINativeMethod[]數(shù)組, 靜態(tài)注冊的native方法必須是Java+類完整路徑+方法名的格式。
總結(jié)下:
動態(tài)注冊的native方法映射通過加載so庫過程中調(diào)用JNI_OnLoad方法調(diào)用完成。
靜態(tài)注冊的native方法映射是在該native方法第一次執(zhí)行的時候才完成映射,當(dāng)然前提是該so庫已經(jīng)load過。
三、so庫熱部署實(shí)時生效可行性分析
1.動態(tài)注冊native方法實(shí)時生效
前面我們分析過so庫的加載原理, 我們知道動態(tài)注冊的native方法調(diào)用一次JNI_OnLoad方法都會重新完成一次映射, 所以我們是否只要先加載原來的so庫,,然后再加載補(bǔ)丁so庫,就能完成Java層native方法到native層patch后的新方法映射, 這樣就完成動態(tài)注冊native方法的patch實(shí)時修復(fù)。一張圖說明:
實(shí)測發(fā)現(xiàn)art下這樣是可以做到實(shí)時生效的,但是Dalvik下做不到實(shí)時生效,通過代碼測試我們發(fā)現(xiàn), 實(shí)際上Dalvik下第二次load補(bǔ)丁so庫, 執(zhí)行的仍然是原來so庫的JNI_OnLoad方法, 而不是補(bǔ)丁so庫的JNI_OnLoad方法, 所以Dalvik下做不到實(shí)時生效。 我們來簡單分析下, 既然拿到的是原來so庫的JNI_OnLoad方法, 那么我們首先懷疑以下兩個函數(shù)是否有問題。
dlopen():返回給我們一個動態(tài)鏈接庫的句柄
dlsym(): 通過一個dlopen得到的動態(tài)連接庫句柄,來查找一個symbol
首先來看下Dalvik虛擬機(jī)下面dlopen的實(shí)現(xiàn), 源碼在/bionic/linker/dlfcn.cpp文件, 方法調(diào)用鏈路:dlopen-> do_dlopen -> find_library -> find_library_internal
findloadedlibrary方法判斷name表示的so庫是否已經(jīng)被加載過, 如果加載過直接返回之前加載so庫的句柄,沒有加載過, 調(diào)用load_library嘗試加載so庫 。
看代碼注釋, 也知道其實(shí)這是Dalvik虛擬機(jī)下的一個bug,這里它是通過basename去做查找, 傳進(jìn)來的參數(shù)name實(shí)際上是so庫所在磁盤的完整路徑, 比如此時修復(fù)后的so庫的路徑為/data/data/com.taobao.jni/files/libnative-lib.so. 但是此時是通過bname:libnative-lib.so作為key去查找, 我們知道第一次加載原來的so庫System.loadLibrary("native-lib");實(shí)際上已經(jīng)在solist表中存在了native-lib這個key, 所以Dalvik下面加載修復(fù)后的補(bǔ)丁so拿到的還是原so庫文件的句柄, 所以執(zhí)行的仍然是原來SO庫的JNI_OnLoad方法,Art下不存在這個問題, 是因為Art下這個地方是以name作為key去查找而不是bname, 所以art下重新load一遍補(bǔ)丁so庫, 拿到的是補(bǔ)丁so庫的句柄, 然后執(zhí)行補(bǔ)丁so庫的JNI_OnLoad。
所以為了解決Dalvik下面的這個問題, 那么如果嘗試對補(bǔ)丁so進(jìn)行改名,比如此處補(bǔ)丁so庫的完整路徑修改之后變成/data/data/com.taobao.jni/files/libnative-lib-123333.so, 后面一串?dāng)?shù)字是當(dāng)前時間戳, 確保這個bname是全局唯一的, 按照上面的分析, 在solist中查找的key已經(jīng)是唯一的,所以此時可以做到Dalvik下面動態(tài)注冊的native方法的實(shí)時生效。
2. 靜態(tài)注冊native方法實(shí)時生效
上面通過嘗試對補(bǔ)丁so庫進(jìn)行重命名為全局唯一的名稱可以確保第二次加載補(bǔ)丁so庫可以做到Dalvik下和Art下動態(tài)注冊方法的實(shí)時生效, 但要做到靜態(tài)注冊native方法的實(shí)時生效還需要更多工作。
前面我們說過靜態(tài)注冊native方法的映射是在native方法第一次執(zhí)行的時候就完成了映射, 所以如果native方法在加載補(bǔ)丁so庫之前已經(jīng)執(zhí)行過了, 那么是否這種時候這個靜態(tài)注冊的native方法一定得不到修復(fù)? 幸運(yùn)的是, 系統(tǒng)JNI API提供了解注冊的接口。
UnregisterNatives函數(shù)會把jclazz所在類的所有native方法都重新指向為dvmResolveNativeMethod, 所以調(diào)用UnregisterNatives之后不管是靜態(tài)注冊還是動態(tài)注冊的native方法之前是否執(zhí)行過在加載補(bǔ)丁so的時候都會重新去做映射。 所以我們只需要以下調(diào)用。
這里有一個難點(diǎn), 因為native方法的修改是在SO庫中, 所以我們的補(bǔ)丁工具很難檢測出到底是哪個Java類需要解注冊native方法。 這個問題暫且放下, 假設(shè)我們能知道哪個類需要解注冊native方法, 然后load補(bǔ)丁so庫之后,再次執(zhí)行該native方法,這樣看起來是可以讓該native方法實(shí)時生效, 但是測試發(fā)現(xiàn), 在補(bǔ)丁so庫重命名的前提下, java層native方法可能映射到原so庫的方法, 也可能映射到補(bǔ)丁so庫的修復(fù)后的新方法。
首先靜態(tài)注冊的native方法之前從未執(zhí)行, 首先嘗試解析該方法。或者調(diào)用了unregisterJNINativeMethods解注冊方法,那么該方法將指向meth->nativeFunc = dvmResolveNativeMethod,那么真正運(yùn)行該方法的時候, 實(shí)際上執(zhí)行的是dvmResolveNativeMethod函數(shù)。這個函數(shù)主要完成java層native方法和native層方法的映射邏輯。
gDvm.nativeLibs是一個全局變量, 它是一個hashtable, 存放著整個虛擬機(jī)加載so庫的SharedLib結(jié)構(gòu)指針。 然后該變量作為參數(shù)傳遞給dvmHashForeach函數(shù)進(jìn)行hashtable遍歷。 執(zhí)行findMethodInLib函數(shù)看是否找到對應(yīng)的native函數(shù)指針, 如果第一個找到就直接return, 不在進(jìn)行下次的查找。
這個結(jié)構(gòu)很重要, 在虛擬機(jī)中大量使用到了hashtable這個數(shù)據(jù)結(jié)構(gòu), hashtable的實(shí)現(xiàn)源碼在dalvik/vm/Hash.h和dalvik/vm/Hash.cpp文件中, 有興趣可以自行查看源碼, 這里不進(jìn)行詳細(xì)分析。 hashtable的遍歷和插入都是在dvmHashTableLookup方法中實(shí)現(xiàn), 簡單說下java.hashtable和c.hashtable的異同點(diǎn):
共同點(diǎn): 兩者實(shí)際上都是數(shù)組實(shí)現(xiàn), hashtable容量如果超過默認(rèn)值都會進(jìn)行擴(kuò)容, 都是對key進(jìn)行hash計算然后跟hashtable的長度進(jìn)行取模作為bucket。
不同點(diǎn): Dalvik虛擬機(jī)下hashtable put/get操作實(shí)現(xiàn)方法,實(shí)際上實(shí)現(xiàn)要比java hashmap的實(shí)現(xiàn)要簡單一些, java hashmap的put實(shí)現(xiàn)需要處理hash沖突的情況, 一般情況下會通過在沖突節(jié)點(diǎn)上新增一個鏈表處理沖突, 然后get實(shí)現(xiàn)會遍歷這個鏈表通過equals方法比較value是否一致進(jìn)行查找, davlik下hashtable的put實(shí)現(xiàn)上(doAdd=true)只是簡單的把指針下移直到下一個空節(jié)點(diǎn)。 get實(shí)現(xiàn)(doAdd=false)首先根據(jù)hash值計算出bucket位置, 然后通過cmpFunc函數(shù)比較值是否一致, 不一致, 指針下移。 hashtable的遍歷實(shí)際就是數(shù)組遍歷實(shí)現(xiàn)。
知道了davlik下hashtable的實(shí)現(xiàn)原理, 那我們再來看下前面提到的: 補(bǔ)丁so庫重命名的前提下, 為什么java層native方法可能映射到原so庫的方法也可能映射到補(bǔ)丁so庫的修復(fù)后的新方法。 一張圖說明情況 :
所以我們可以得到結(jié)論: 
對補(bǔ)丁so庫進(jìn)行重命名后, 如果這個補(bǔ)丁so庫在hashtable中的位置比原so庫的位置靠前, 那么這個靜態(tài)注冊native方法就能夠得到修復(fù), 位置如果靠后就得不到修復(fù)。
3. SO實(shí)時生效方案總結(jié)
基于上面的分析, so庫的實(shí)時生效必須滿足以下幾點(diǎn):
so庫為了兼容Dalvik虛擬機(jī)下動態(tài)注冊native方法的實(shí)時生效, 必須對so文件進(jìn)行改名。
針對so庫靜態(tài)注冊native方法的實(shí)時生效, 首先需要解注冊靜態(tài)注冊的native方法, 這個也是難點(diǎn), 因為我們很難知道so庫中哪幾個靜態(tài)注冊的native方法發(fā)生了變更。 假設(shè)就算我們知道如果靜態(tài)注冊的native方法需要解注冊, 重新load補(bǔ)丁so庫也有可能被修復(fù)也有可能不被修復(fù)。
上面對補(bǔ)丁so進(jìn)行了第二次加載, 那么肯定是多消耗了一次本地內(nèi)存, 如果補(bǔ)丁so庫夠大, 補(bǔ)丁so夠多,那么JNI層的OOM也不是沒可能。
另外一方面補(bǔ)丁so如果新增了一個動態(tài)注冊的方法而dex中沒有相應(yīng)方法,直接去加載這個補(bǔ)丁so文件會報NoSuchMethodError異常, 具體邏輯在dvmRegisterJNIMethod中。 我們知道如果dex如果新增了一個native方法, 那么走不了熱部署只能冷啟動重啟生效, 所以此時補(bǔ)丁so就不能第二次load了。 這種情況下so庫的修復(fù)嚴(yán)重依賴于dex的修復(fù)方案。
可以看到SO庫實(shí)時生效方案, 對于靜態(tài)注冊的native方法有一定的局限性, 不能滿足一般的通用性, 所以最后我們放棄了so庫的實(shí)時生效需求,轉(zhuǎn)而求次實(shí)現(xiàn)so庫修復(fù)的冷部署重啟生效方案。
四、so庫冷部署重啟生效實(shí)現(xiàn)方案
為了更好的兼容通用性, 我們嘗試通過冷部署重啟生效的角度分析下補(bǔ)丁so庫的修復(fù)方案。
方案1. 接口調(diào)用替換
sdk提供接口替換System默認(rèn)加載so庫接口
SOPatchManager.loadLibrary接口加載so庫的時候優(yōu)先嘗試去加載sdk指定目錄下的補(bǔ)丁so, 加載策略如下:
如果存在則加載補(bǔ)丁so庫而不會去加載安裝apk安裝目錄下的so庫。
如果不存在補(bǔ)丁so, 那么調(diào)用System.loadLibrary去加載安裝apk目錄下的so庫。
我們可以很清楚的看到這個方案的優(yōu)缺點(diǎn):
優(yōu)點(diǎn):不需要對不同sdk版本進(jìn)行兼容, 因為所有的sdk版本都有System.loadLibrary這個接口。
缺點(diǎn): 調(diào)用方需要替換掉System默認(rèn)加載so庫接口為sdk提供的接口, 如果是已經(jīng)編譯混淆好的三方庫的so庫需要patch, 那么是很難做到接口的替換。
雖然這種方案實(shí)現(xiàn)簡單, 同時不需要對不同sdk版本區(qū)分處理,但是有一定的局限性沒法修復(fù)三方包的so庫同時需要強(qiáng)制侵入接入方接口調(diào)用, 所以來看下方案2. 反射注入。
方案2. 反射注入
前面介紹過System.loadLibrary("native-lib");加載so庫的原理, 其實(shí)native-lib這個so庫最終傳給native方法執(zhí)行的參數(shù)是so庫在磁盤中的完整路徑, 比如: /data/app-lib/com.taobao.jni-2/libnative-lib.so, so庫會在DexPathList.nativeLibraryDirectories/nativeLibraryPathElements變量所表示的目錄下去遍歷搜索。
sdk<23 DexPathList.findLibrary實(shí)現(xiàn)如下:
可以發(fā)現(xiàn)會遍歷nativeLibraryDirectories數(shù)組, 如果找到了IoUtils.canOpenReadOnly(path)返回為true, 那么就直接返回該path, IoUtils.canOpenReadOnly(path)返回為true的前提肯定是需要path表示的so文件存在的。 那么我們可以采取類似類修復(fù)反射注入方式, 只要把我們的補(bǔ)丁so庫的路徑插入到nativeLibraryDirectories數(shù)組的最前面就能夠達(dá)到加載so庫的時候是補(bǔ)丁so庫而不是原來so庫的目錄, 從而達(dá)到修復(fù)的目的。
sdk>=23 DexPathList.findLibrary實(shí)現(xiàn)如下 :
sdk23以上findLibrary實(shí)現(xiàn)已經(jīng)發(fā)生了變化, 如上所示, 那么我們只需要把補(bǔ)丁so庫的完整路徑作為參數(shù)構(gòu)建一個Element對象, 然后再插入到nativeLibraryPathElements數(shù)組的最前面就好了。
優(yōu)點(diǎn): 可以修復(fù)三方庫的so庫。 同時接入方不需要像方案1一樣強(qiáng)制侵入用戶接口調(diào)用。
缺點(diǎn): 需要不斷的對sdk進(jìn)行適配, 如上sdk23為分界線, findLibrary接口實(shí)現(xiàn)已經(jīng)發(fā)生了變化。
我們知道在不管是在補(bǔ)丁包中還是apk中一個so庫都存在多種cpu架構(gòu)的so文件, 比如"armeabi","arm64-v8a", "x86"等。 加載肯定是加載其中一個so庫文件的, 如何選擇機(jī)型對應(yīng)的so庫文件將是重點(diǎn)所在。
五、如果正確復(fù)制補(bǔ)丁so庫?
上面提到的一個問題, 這里不打算詳細(xì)介紹。 有需要的參考文檔: Android 動態(tài)鏈接庫加載原理及 HotFix 方案介紹, 這篇文檔有些觀點(diǎn)不盡正確, 但是我也能知道虛擬機(jī)究竟選擇哪個abis目錄作為參數(shù)構(gòu)建PathClassLoader對象, 一張圖簡單了解下原理:
實(shí)際上補(bǔ)丁so也存在類似的問題, 我們的補(bǔ)丁so庫文件放到補(bǔ)丁包的libs目錄下面, libs目錄和.dex文件和res資源文件一起打包成一個壓縮文件作為最后的補(bǔ)丁包, libs目錄可能也包含多種abis目錄。 所以我們需要選擇手機(jī)最合適的primaryCpuAbi, 然后從libs目錄下面選擇這個primaryCpuAbi子目錄插入到nativeLibraryDirectories/nativeLibraryPathElements數(shù)組中。 所以怎么選擇primaryCpuAbi是關(guān)鍵, 來看下我們sdk具體的實(shí)現(xiàn)。
sdk>=21下, 直接反射拿到ApplicationInfo對象的primaryCpuAbi即可
sdk<21下, 由于此時不支持64位, 所以直接把Build.CPU_ABI, Build.CPU_ABI2作為primaryCpuAbi即可 。
六、小結(jié)
最后做一個簡單的小結(jié):
so文件修復(fù)方案目前更多采取的是接口調(diào)用替換方式, 需要強(qiáng)制侵入用戶接口調(diào)用。 目前我們的so文件修復(fù)方案采取的是反射注入的方案, 重啟生效, 具有更好的普遍性。
同時如果有so文件修復(fù)實(shí)時生效的需求, 也是可以做到的,只是有些限制情況, 詳見以上分析。
總結(jié)
以上是生活随笔為你收集整理的Android热修复升级探索——SO库修复方案的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
                            
                        - 上一篇: 如何使用Spring管理Filter和S
 - 下一篇: 让PIP源使用国内镜像,提升下载速度和安