天天看點

深入探索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 方法。

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

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

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

總結下:

  • 動态注冊的 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 實時修複。一張圖說明

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

實測發現 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

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

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

深入探索Android熱修複技術原理讀書筆記 —— so庫熱修複技術
深入探索Android熱修複技術原理讀書筆記 —— 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 提供 了解注冊的接口。

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

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

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

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

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

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

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 庫的修複後的新方法。一張圖說明情況

深入探索Android熱修複技術原理讀書筆記 —— 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 目錄下的 so 庫。
深入探索Android熱修複技術原理讀書筆記 —— so庫熱修複技術

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

  • 優點:不需要對不同 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 實作如下

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

可以發現會周遊 nativeLibraryDirectories 數組,如果找到了 loUtils.canOpenReadOnly (path)傳回為 true, 那麼就直接傳回該 path, loUtils.canOpenReadOnly (path)傳回為 true 的前提肯定是需要 path 表示的 so 檔案存 在的。那麼我們可以采取類似類修複反射注入方式,隻要把我們的更新檔 so 庫的路徑插入到 nativeLibraryDirectories 數組的最前面就能夠達到加載 so 庫的時候是更新檔 庫而不是原來 so 庫的目錄,進而達到修複的目的。

sdk>=23 DexPathList.findLibrary 實作如下

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

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

深入探索Android熱修複技術原理讀書筆記 —— so庫熱修複技術
  • 優點:可以修複三方庫的 so 庫。同時接入方不需要像方案1 —樣強制侵入用 戶接口調用
  • 缺點:需要不斷的對 sdk 進行适配,如上 sdk23 為分界線,findLibrary 接口實作已經發生了變化。

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

4. 如何正确複制更新檔 SO庫

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

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

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

深入探索Android熱修複技術原理讀書筆記 —— so庫熱修複技術
深入探索Android熱修複技術原理讀書筆記 —— so庫熱修複技術
  • sdk>=21 時,直接反射拿到 Applicationinfo 對象的 primaryCpuAbi 即可
  • sdk<21 時,由于此時不支援 64 位,是以直接把Build.CPU_ABI, Build.CPU_ABI2 作為 primaryCpuAbi 即可

5. 本章小結

對于 so 庫的修複方案目前更多采取的是接口調用替換方式,需要強制侵入使用者 接口調用。目前我們的 so 檔案修複方案采取的是反射注入的方案,重新開機生效。具有更好的普遍性。如果有 so 檔案修複實時生效的需求,也是可以做到的,隻是有些限制情況。

樹林美麗、幽暗而深邃,但我有諾言尚待實作,還要奔行百裡方可沉睡。 -- 羅伯特·弗羅斯特