深入理解Android熱修復(fù)技術(shù)原理之so庫(kù)熱修復(fù)技術(shù)
一、SO庫(kù)加載原理
Java Api 提供以下兩個(gè)接口加載一個(gè) so 庫(kù)
- System. loadLibrary (String libName):傳進(jìn)去的參數(shù):so庫(kù)名稱(chēng), 表示的so 庫(kù)文件,位于apk壓縮文件中的 libs 目錄,最后復(fù)制到 apk安裝目錄下。
- System, load (String pathName):傳進(jìn)去的參數(shù): so庫(kù)在磁盤(pán)中的完整 路徑。加載一個(gè)自定義外部 so庫(kù)文件。
上述兩種方式加載一個(gè) so 庫(kù),實(shí)際上最后都調(diào)用 nativeLoad 這個(gè) native方法去加載 so庫(kù),這個(gè)方法的 fileName:so 庫(kù)在磁盤(pán)中的完整路徑名。
代碼+圖文的方式簡(jiǎn)述 so 庫(kù)加載原理,下面的代碼示例,stringFromJNI -> Java_com_taobao_jni_MainActivity_stringFromJNI 靜態(tài)注冊(cè)的 native 方 法,test->test 動(dòng)態(tài)注冊(cè)的 native 方法。
我們知道 JNI 編程中,動(dòng)態(tài)注冊(cè)的 native 方法必須實(shí)現(xiàn) JNI_OnLoad方法,同時(shí)實(shí)現(xiàn)一個(gè)JNINativeMethod [] 數(shù)組,靜態(tài)注冊(cè)的 native 方法必須是Java+類(lèi)完整路徑+方法名的格式。
總結(jié)下:
- 動(dòng)態(tài)注冊(cè)的 native 方法映射通過(guò)加載 so 庫(kù)過(guò)程中調(diào)用 JNI_onLoad 方法調(diào)用完成。
- 靜態(tài)注冊(cè)的 native 方法映射是在該native方法第一次執(zhí)行的時(shí)候才完成映射,當(dāng)然前提是該 so 庫(kù)已經(jīng) load 過(guò)。
二、SO庫(kù)熱部署實(shí)時(shí)生效可行性分析
2.1、動(dòng)態(tài)注冊(cè) native 方法實(shí)時(shí)生效
前面我們分析過(guò) so 庫(kù)的加載原理,我們知道動(dòng)態(tài)注冊(cè)的 native方法調(diào)用一次 JNI_OnLoad 方法都會(huì)重新完成一次映射,所以我們是否只要先加載原來(lái)的 so庫(kù), 然后再加載補(bǔ)丁 so 庫(kù),就能完成Java層 native 方法到 native 層 patch后的新方法映射,這樣就完成動(dòng)態(tài)注冊(cè)native 方法的 patch 實(shí)時(shí)修復(fù)。一張圖說(shuō)明
實(shí)測(cè)發(fā)現(xiàn) art 下這樣是可以做到實(shí)時(shí)生效的,但是 Dalvik下做不到實(shí)時(shí)生效,通 過(guò)代碼測(cè)試我們發(fā)現(xiàn),實(shí)際上Dalvik 下第二次 load補(bǔ)丁 so庫(kù),執(zhí)行的仍然是原來(lái)so 庫(kù)的 JNI_0nLoad方法,而不是補(bǔ)丁so 庫(kù)的 JNI_OnLoad 方法,所以 Dalvik 下做不到實(shí)時(shí)生效。我們來(lái)簡(jiǎn)單分析下,既然拿到的是原來(lái) so 庫(kù)的 JNI_OnLoad方法,那么我們首先懷疑以下兩個(gè)函數(shù)是否有問(wèn)題。
- dlopen() :返回給我們一個(gè)動(dòng)態(tài)鏈接庫(kù)的句柄
- disym() :通過(guò)一個(gè) dlopen 得到的動(dòng)態(tài)連接庫(kù)句柄,來(lái)查找一個(gè) symbol
首先來(lái)看下 Dalvik 虛擬機(jī)下面 dlopen 的實(shí)現(xiàn),源碼在 /bionic/linker/dlfcn.cpp 文件,方法調(diào)用鏈路:dlopen -> do_d.lopen -> find_library -> find_library_internal
findloadedlibrary 方法判斷 name 表示的 so庫(kù)是否已經(jīng)被加載過(guò),如果加載過(guò)直接返回之前加載 so庫(kù)的句柄,沒(méi)有加載過(guò),調(diào)用 load_library嘗試加載 so庫(kù)
看代碼注釋?zhuān)仓榔鋵?shí)這是Dalvik虛擬機(jī)下的一個(gè) bug,這里它是通過(guò) basename 去做查找,傳進(jìn)來(lái)的參數(shù) name 實(shí)際上是 so庫(kù)所在磁盤(pán)的完整路徑,比如此時(shí)修復(fù)后的so庫(kù)的路徑為 /data/data/com. taobao. jni/files/libnative-lib.so。但是此時(shí)是通過(guò) bname : libnative-lib.so 作為 key 去查找, 我們知道第一次加載原來(lái)的 so庫(kù) System.loadLibrary ( "native-lib");實(shí)際上已經(jīng)在solist表中存在了 native-lib 這個(gè) key,所以 Dalvik下面加載修復(fù)后的補(bǔ)丁so拿到的還是原so庫(kù)文件的句柄,所以執(zhí)行的仍然是原來(lái) so庫(kù)的JNI_ OnLoad方法,Art下不存在這個(gè)問(wèn)題,是因?yàn)锳rt下這個(gè)地方是以name作為key 去查找而不是bname,所以art 重新load —遍補(bǔ)丁 so庫(kù):拿到的是補(bǔ)丁 so庫(kù)的句柄,然后執(zhí)行補(bǔ)丁庫(kù)的JNI OnLoad。
所以為了解決 Dalvik 下面的這個(gè)問(wèn)題,那么如果嘗試對(duì)補(bǔ)丁 so進(jìn)行改名,比如 此處補(bǔ)丁so 庫(kù)的完整路徑修改之后變成 /data/data/com.taobao.jni/files/ libnative-lib-123333.so,后面一串?dāng)?shù)字是當(dāng)前時(shí)間戳,確保這個(gè) bname是全局唯一的,按照上面的分析,在solist 中查找的 key已經(jīng)是唯一的,所以此時(shí)可以做到Dalvik 下面動(dòng)態(tài)注冊(cè)的 native 方法的實(shí)時(shí)生效。
2.2、靜態(tài)注冊(cè) native 方法實(shí)時(shí)生效
上面通過(guò)嘗試對(duì)補(bǔ)丁 so庫(kù)進(jìn)行重命名為全局唯一的名稱(chēng)可以確保第二次加載補(bǔ)丁so 庫(kù)可以做到 Dalvik 下和 Art下動(dòng)態(tài)注冊(cè)方法的實(shí)時(shí)生效,但要做到靜態(tài)注冊(cè) native 方法的實(shí)時(shí)生效還需要更多工作。
前面我們說(shuō)過(guò)靜態(tài)注冊(cè) native 方法的映射是在 native方法第一次執(zhí)行的時(shí)候就完成了映射,所以如果native方法在加載補(bǔ)丁 so 庫(kù)之前已經(jīng)執(zhí)行過(guò)了,那么是否這種時(shí)候這個(gè)靜態(tài)注冊(cè)的 native 方法一定得不到修復(fù)?幸運(yùn)的是,系統(tǒng) JNI API提供 了解注冊(cè)的接口。
UnregisterNatives 函數(shù)會(huì)把 jclazz 所在類(lèi)的所有 native 方法都重新指向?yàn)?dvmResolveNativeMethod,所以調(diào)用 UnregisterNatives 之后不管是靜態(tài)注冊(cè)還是動(dòng)態(tài)注冊(cè)的native方法之前是否執(zhí)行過(guò)在加載補(bǔ)丁 so的時(shí)候都會(huì)重新去做映射。所以我們只需要以下調(diào)用。
這里有一個(gè)難點(diǎn),因?yàn)?native 方法的修改是在 so庫(kù)中,所以我們的補(bǔ)丁工具很難檢測(cè)出到底是哪個(gè)Java 類(lèi)需要解注冊(cè) native 方法。這個(gè)問(wèn)題暫且放下。假設(shè)我們能知道哪個(gè)類(lèi)需要解注冊(cè)native方法,然后 load補(bǔ)丁 so庫(kù)之后,再次執(zhí)行該 native 方法,這樣看起來(lái)是可以讓該 native方法實(shí)時(shí)生效,但是測(cè)試發(fā)現(xiàn),在補(bǔ)丁 so 庫(kù)重命名的前提下,java 層 native 方法可能映射到原so庫(kù)的方法,也可能映射到補(bǔ)丁 so 庫(kù)的修復(fù)后的新方法。
首先靜態(tài)注冊(cè)的 native方法之前從未執(zhí)行,首先嘗試解析該方法。或者調(diào)用了 unregisterJNINativeMethods 解注冊(cè)方法,那么該方法將指向 meth->nativeFunc = dvmResolveNativeMethod,那么真正運(yùn)行該方法的時(shí)候,實(shí)際上執(zhí)行的是dvmResolveNativeMethod 函數(shù)。這個(gè)函數(shù)主要完成 java 層 native方法和native 層方法的映射邏輯。
gDvm.nativeLibs 是一個(gè)全局變量,它是一個(gè)hashtable,存放著整個(gè)虛擬機(jī)加載 so庫(kù)的 SharedLib 結(jié)構(gòu)指針。然后該變量作為參數(shù)傳遞給 dvmHashForeach 函數(shù)進(jìn)行 hashtable 遍歷。執(zhí)行 findMethodInLib 函數(shù)看是否找到對(duì)應(yīng)的 native函 數(shù)指針,如果第一個(gè)找到就直接return,不在進(jìn)行下次的查找。
這個(gè)結(jié)構(gòu)很重要,在虛擬機(jī)中大量使用到了 hashtable 這個(gè)數(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),簡(jiǎn)單說(shuō)下 java.hashtable 和 c.hashtable 的異同點(diǎn):
- 共同點(diǎn):兩者實(shí)際上都是數(shù)組實(shí)現(xiàn),hashtable容量如果超過(guò)默認(rèn)值都會(huì)進(jìn)行擴(kuò)容,都是對(duì)key進(jìn)行hash計(jì)算然后跟hashtable的長(zhǎng)度進(jìn)行取模作為 bucket。
- 不同點(diǎn):Dalvik 虛擬機(jī)下 hashtable put/get操作實(shí)現(xiàn)方法,實(shí)際上實(shí)現(xiàn)要 比java hashmap 的實(shí)現(xiàn)要簡(jiǎn)單一些,java hashmap 的 put實(shí)現(xiàn)需要處理 hash沖突的情況,一般情況下會(huì)通過(guò)在沖突節(jié)點(diǎn)上新增一個(gè)鏈表處理沖突, 然后get實(shí)現(xiàn)會(huì)遍歷這個(gè)鏈表通過(guò)equals方法比較value是否一致進(jìn)行查找,davlik 下 hashtable 的 put 實(shí)現(xiàn)上 (doAdd=true) 只是簡(jiǎn)單的把指針 下移直到下一個(gè)空節(jié)點(diǎn)。get 實(shí)現(xiàn) (doAdd=false) 首先根據(jù) hash值計(jì)算出 bucket 位置,然后通過(guò) cmpFunc函數(shù)比較值是否一致,不一致,指針下移。 hashtable 的遍歷實(shí)際就是數(shù)組遍歷實(shí)現(xiàn)
知道了 davlik 下 hashtable的實(shí)現(xiàn)原理,那我們?cè)賮?lái)看下前面提到的:補(bǔ)丁 so庫(kù)重命名的前提下,為什么 java 層 native 方法可能映射到原 so 庫(kù)的方法也可能映射到補(bǔ)丁 so庫(kù)的修復(fù)后的新方法。一張圖說(shuō)明情況
所以我們可以得到結(jié)論:
對(duì)補(bǔ)丁 so庫(kù)進(jìn)行重命名后,如果這個(gè)補(bǔ)丁 so庫(kù)在hashtable中的位置比原 so庫(kù)的位置靠前,那么這個(gè)靜態(tài)注冊(cè)native方法就能夠得到修復(fù),位置如果靠后就得不到修復(fù)。
2.3、SO實(shí)時(shí)生效方案總結(jié)
基于上面的分析,so庫(kù)的實(shí)時(shí)生效必須滿(mǎn)足以下幾點(diǎn):
- so庫(kù)為了兼容Dalvik虛擬機(jī)下動(dòng)態(tài)注冊(cè)native方法的實(shí)時(shí)生效,必須對(duì)so 文件進(jìn)行改名。
- 針對(duì)so庫(kù)靜態(tài)注冊(cè)native方法的實(shí)時(shí)生效,首先需要解注冊(cè)靜態(tài)注冊(cè)的 native方法,這個(gè)也是難點(diǎn),因?yàn)槲覀兒茈y知道so庫(kù)中哪幾個(gè)靜態(tài)注冊(cè)的 native方法發(fā)生了變更。假設(shè)就算我們知道如果靜態(tài)注冊(cè)的native方法需要解注冊(cè),重新load補(bǔ)丁 so庫(kù)也有可能被修復(fù)也有可能不被修復(fù)。
- 上面對(duì)補(bǔ)丁 so進(jìn)行了第二次加載,那么肯定是多消耗了一次本地內(nèi)存,如果 補(bǔ)丁 so庫(kù)夠大,補(bǔ)丁 so夠多,那么JNI層的OOM也不是沒(méi)可能
- 另外一方面補(bǔ)丁 so如果新增了一個(gè)動(dòng)態(tài)注冊(cè)的方法而dex中沒(méi)有相應(yīng)方法, 直接去加載這個(gè)補(bǔ)丁 so文件會(huì)報(bào)NoSuchMethodError異常,具體邏輯在 dvmRegisterJNIMethod中。我們知道如果dex如果新增了—native 方法,那么走不了熱部署只能冷啟動(dòng)重啟生效,所以此時(shí)補(bǔ)丁so就不能第二 次load 了。這種情況下so庫(kù)的修復(fù)嚴(yán)重依賴(lài)于dex的修復(fù)方案。
可以看到 so庫(kù)實(shí)時(shí)生效方案,對(duì)于靜態(tài)注冊(cè)的native方法有一定的局限性, 不能滿(mǎn)足一般的通用性,所以最后我們放棄了 so庫(kù)的實(shí)時(shí)生效需求,轉(zhuǎn)而求次實(shí)現(xiàn) so庫(kù)修復(fù)的冷部署重啟生效方案。
三、SO庫(kù)冷部署重啟生效實(shí)現(xiàn)方案
為了更好的兼容通用性,我們嘗試通過(guò)冷部署重啟生效的角度分析下補(bǔ)丁 so庫(kù)的修復(fù)方案。
3.1、接口調(diào)用替換方案
sdk提供接口替換System默認(rèn)加載so庫(kù)接口
SOPatchManager.loadLibrary(String libName) -> System.loadLibrary(String libName)
SOPatchManager.loadLibrary接口加載 so庫(kù)的時(shí)候優(yōu)先嘗試去加載sdk 指定目錄下的補(bǔ)丁so,加載策略如下:
如果存在則加載補(bǔ)丁 so庫(kù)而不會(huì)去加載安裝apk安裝目錄下的so庫(kù)
如果不存在補(bǔ)丁so,那么調(diào)用System.loadLibrary去加載安裝apk目錄下的 so庫(kù)。
我們可以很清楚的看到這個(gè)方案的優(yōu)缺點(diǎn):
- 優(yōu)點(diǎn):不需要對(duì)不同 sdk 版本進(jìn)行兼容,因?yàn)樗械?sdk 版本都有 System.loadLibrary 這個(gè)接口。
- 缺點(diǎn):調(diào)用方需要替換掉 System 默認(rèn)加載 so 庫(kù)接口為 sdk提供的接口, 如果是已經(jīng)編譯混淆好的三方庫(kù)的so 庫(kù)需要 patch,那么是很難做到接口的替換。
雖然這種方案實(shí)現(xiàn)簡(jiǎn)單,同時(shí)不需要對(duì)不同 sdk版本區(qū)分處理,但是有一定的局限性沒(méi)法修復(fù)三方包的so庫(kù)同時(shí)需要強(qiáng)制侵入接入方接口調(diào)用,接著我們來(lái)看下反射注入方案。
3.2、反射注入方案
前面介紹過(guò) System. loadLibrary ( "native-lib"); 加載 so庫(kù)的原理,其實(shí)native-lib 這個(gè) so 庫(kù)最終傳給 native 方法執(zhí)行的參數(shù)是 so庫(kù)在磁盤(pán)中的完整路徑,比如:/data/app-lib/com.taobao.jni-2/libnative-lib.so, so庫(kù)會(huì)在 DexPathList.nativeLibraryDirectories/nativeLibraryPathElements 變量所表示的目錄下去遍歷搜索。
sdk<23 DexPathList.findLibrary 實(shí)現(xiàn)如下
可以發(fā)現(xiàn)會(huì)遍歷 nativeLibraryDirectories數(shù)組,如果找到了 loUtils.canOpenReadOnly (path)返回為 true, 那么就直接返回該 path, loUtils.canOpenReadOnly (path)返回為 true 的前提肯定是需要 path 表示的 so文件存 在的。那么我們可以采取類(lèi)似類(lèi)修復(fù)反射注入方式,只要把我們的補(bǔ)丁so庫(kù)的路徑插入到nativeLibraryDirectories數(shù)組的最前面就能夠達(dá)到加載so庫(kù)的時(shí)候是補(bǔ)丁 庫(kù)而不是原來(lái)so庫(kù)的目錄,從而達(dá)到修復(fù)的目的。
sdk>=23 DexPathList.findLibrary 實(shí)現(xiàn)如下
sdk23 以上 findLibrary 實(shí)現(xiàn)已經(jīng)發(fā)生了變化,如上所示,那么我們只需要把補(bǔ)丁so庫(kù)的完整路徑作為參數(shù)構(gòu)建一個(gè)Element對(duì)象,然后再插入到nativeLibraryPathElements 數(shù)組的最前面就好了。
- 優(yōu)點(diǎn):可以修復(fù)三方庫(kù)的so庫(kù)。同時(shí)接入方不需要像方案1 —樣強(qiáng)制侵入用 戶(hù)接口調(diào)用
- 缺點(diǎn):需要不斷的對(duì) sdk 進(jìn)行適配,如上 sdk23 為分界線(xiàn),findLibrary接口實(shí)現(xiàn)已經(jīng)發(fā)生了變化。
我們知道在不管是在補(bǔ)丁包中還是 apk 中一個(gè) so 庫(kù)都存在多種 cpu 架構(gòu)的 so 文件,比如"armeabi","arm64-v8a","x86"等。加載肯定是加載其中一個(gè) so庫(kù)文件的,如何選擇機(jī)型對(duì)應(yīng)的 so 庫(kù)文件將是重點(diǎn)所在。
四、如何正確復(fù)制補(bǔ)丁 SO庫(kù)
上面提到的一個(gè)問(wèn)題,這里不打算詳細(xì)介紹。有需要的參考文檔:Android動(dòng)態(tài) 鏈接庫(kù)加載原理及HotFix方案介紹,這篇文檔有些觀點(diǎn)不盡正確,但是我也能知道虛擬機(jī)究竟選擇哪個(gè)abis目錄作為參數(shù)構(gòu)建PathClassLoader對(duì)象,一張圖簡(jiǎn)單了解下原理:
實(shí)際上補(bǔ)丁 so也存在類(lèi)似的問(wèn)題,我們的補(bǔ)丁 so庫(kù)文件放到補(bǔ)丁包的libs目錄下面,libs目錄和.dex文件和res資源文件一起打包成一個(gè)壓縮文件作為最后的補(bǔ)丁包,libs目錄可能也包含多種abis目錄。所以我們需要選擇手機(jī)最合適的 primaryCpuAbi,然后從libs目錄下面選擇這個(gè)primaryCpuAbi子目錄插入到 nativeLibraryDirectories/nativeLibraryPathElements 數(shù)組中。所以怎么選擇primaryCpuAbi是關(guān)鍵,來(lái)看下我們sdk具體的實(shí)現(xiàn)
- sdk>=21 時(shí),直接反射拿到 Applicationinfo 對(duì)象的 primaryCpuAbi 即可
- sdk<21 時(shí),由于此時(shí)不支持 64 位,所以直接把Build.CPU_ABI, Build.CPU_ABI2 作為 primaryCpuAbi 即可
五、本章小結(jié)
對(duì)于 so庫(kù)的修復(fù)方案目前更多采取的是接口調(diào)用替換方式,需要強(qiáng)制侵入用戶(hù) 接口調(diào)用。目前我們的so文件修復(fù)方案采取的是反射注入的方案,重啟生效。具有更好的普遍性。如果有so文件修復(fù)實(shí)時(shí)生效的需求,也是可以做到的,只是有些限制情況。
以上就是深入理解Android熱修復(fù)技術(shù)原理之so庫(kù)熱修復(fù)技術(shù)的詳細(xì)內(nèi)容,更多關(guān)于Android so庫(kù)熱修復(fù)的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Android實(shí)現(xiàn)圓形圖片或者圓角圖片
這篇文章主要為大家詳細(xì)介紹了Android實(shí)現(xiàn)圓形圖片或者圓角圖片的代碼,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2018-06-06Android自定義控件ViewFipper實(shí)現(xiàn)豎直跑馬燈效果
這篇文章主要為大家詳細(xì)介紹了Android自定義控件ViewFipper實(shí)現(xiàn)豎直跑馬燈效果,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-12-12Android開(kāi)發(fā)實(shí)現(xiàn)的圓角按鈕、文字陰影按鈕效果示例
這篇文章主要介紹了Android開(kāi)發(fā)實(shí)現(xiàn)的圓角按鈕、文字陰影按鈕效果,涉及Android界面布局與屬性設(shè)置相關(guān)操作技巧,需要的朋友可以參考下2019-04-04Android應(yīng)用開(kāi)發(fā)中RecyclerView組件使用入門(mén)教程
這篇文章主要介紹了Android應(yīng)用開(kāi)發(fā)中RecyclerView組件使用的入門(mén)教程,RecyclerView主要針對(duì)安卓5.0以上的material design開(kāi)發(fā)提供支持,需要的朋友可以參考下2016-02-02AndroidStudio中重載方法@Override的使用詳解
這篇文章主要介紹了AndroidStudio中重載方法@Override的使用詳解,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-04-04仿餓了嗎點(diǎn)餐界面兩個(gè)ListView聯(lián)動(dòng)效果
這篇文章主要介紹了仿餓了點(diǎn)餐界面2個(gè)ListView聯(lián)動(dòng)效果的相關(guān)資料,非常不錯(cuò),具有參考借鑒價(jià)值,需要的朋友可以參考下2016-09-09Android使用MediaRecorder類(lèi)實(shí)現(xiàn)視頻和音頻錄制功能
Android提供了MediaRecorder這一個(gè)類(lèi)來(lái)實(shí)現(xiàn)視頻和音頻的錄制功能,本文通過(guò)實(shí)例代碼給大家介紹的非常詳細(xì),具有一定的參考借鑒價(jià)值,需要的朋友參考下吧2018-07-07Android使用CardView作為RecyclerView的Item并實(shí)現(xiàn)拖拽和左滑刪除
這篇文章主要介紹了Android使用CardView作為RecyclerView的Item并實(shí)現(xiàn)拖拽和左滑刪除,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-11-11