Android熱修復升級探索——SO庫修復方案

摘要: 一般狀況下,大多數人但願android下熱補丁方案可以作到補丁的全方位修復,包括類修復/資源修復/so庫的修復。 這裏主要介紹熱補丁之so庫修復思路。java

1、前言
一般狀況下,大多數人但願android下熱補丁方案可以作到補丁的全方位修復,包括類修復/資源修復/so庫的修復。 這裏主要介紹熱補丁之so庫修復思路。android

2、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方法調用完成。app

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

3、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庫修復的冷部署重啓生效方案。

4、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庫文件將是重點所在。

5、若是正確複製補丁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便可 。

6、小結
最後作一個簡單的小結:

so文件修復方案目前更多采起的是接口調用替換方式, 須要強制侵入用戶接口調用。 目前咱們的so文件修復方案採起的是反射注入的方案, 重啓生效, 具備更好的廣泛性。

同時若是有so文件修復實時生效的需求, 也是能夠作到的,只是有些限制狀況, 詳見以上分析。

相關文章
相關標籤/搜索