【Android 修炼手册】常用技术篇 -- Android 热修复解析
這是【Android 修煉手冊】第 8 篇文章,如果還沒有看過前面系列文章,歡迎點擊 這里 查看~
預備知識
看完本文可以達到什么程度
閱讀前準備工作
文章概覽
一、熱修復和插件化
插件化和熱修復的原理,都是動態(tài)加載 dex/apk 中的類/資源,兩者的目的不同。插件化目標在于加載 activity 等組件,達到動態(tài)下發(fā)組件的功能,熱修復目標在修復已有的問題。目標不同,也就導致其實現(xiàn)方式上的差別。由于目標是動態(tài)加載組件,所以插件化重在解決組件的生命周期,以及資源的問題。而熱修復重在解決替換已有的有問題的類/方法/資源等。 關于插件化,可以看前面分享的文章Android 插件化分析
二、使用 gradle 簡化插件開發(fā)流程
如果看過Android 插件化分析里的 gradle 簡化插件開發(fā)流程,這里可以略過~
在學習和開發(fā)熱修復的時候,我們需要動態(tài)去加載補丁 apk,所以開發(fā)過程中一般需要有兩個 apk,一個是宿主 apk,一個是補丁 apk,對應的就需要有宿主項目和補丁項目。
在 CommonTec 這里創(chuàng)建了 app 作為宿主項目,plugin 為插件項目。為了方便,我們直接把生成的插件 apk 放到宿主 apk 中的 assets 中,apk 啟動時直接放到內部存儲空間中方便加載。
這樣的項目結構,我們調試問題時的流程就是下面這樣:
修改插件項目 -> 編譯生成插件 apk -> 拷貝插件 apk 到宿主 assets -> 修改宿主項目 -> 編譯生成宿主 apk -> 安裝宿主 apk -> 驗證問題
如果每次我們修改一個很小的問題,都經歷這么長的流程,那么耐心很快就耗盡了。最好是可以直接編譯宿主 apk 的時候自動打包插件 apk 并拷貝到宿主 assets 目錄下,這樣我們不管修改什么,都直接編譯宿主項目就好了。如何實現(xiàn)呢?還記得我們之前講解過的 gradle 系列么?現(xiàn)在就是學以致用的時候了。
首先在 plugin 項目的 build.gradle 添加下面的代碼:
這段代碼是在 afterEvaluate 的時候,遍歷項目的 task,找到打包 task 也就是 assembleDebug,然后在打包之后,把生成的 apk 拷貝到宿主項目的 assets 目錄下,并且重命名為 plugin.apk。
然后在 app 項目的 build.gradle 添加下面的代碼:
project.afterEvaluate {project.tasks.each {if (it.name == 'mergeDebugAssets') {it.dependsOn ':patch:assembleDebug'}} } 復制代碼找到宿主打包的 mergeDebugAssets 任務,依賴插件項目的打包,這樣每次編譯宿主項目的時候,會先編譯插件項目,然后拷貝插件 apk 到宿主 apk 的 assets 目錄下,以后每次修改,只要編譯宿主項目就可以了。
三、ClassLoader
如果看過Android 插件化分析里的 ClassLoader 分析,這里可以略過~
ClassLoader 是熱修復和插件化中必須要掌握的,因為插件是未安裝的 apk,系統(tǒng)不會處理其中的類,所以需要我們自己來處理。
3.1 java 中的 ClassLoader
BootstrapClassLoader 負責加載 JVM 運行時的核心類,比如 JAVA_HOME/lib/rt.jar 等等
ExtensionClassLoader 負責加載 JVM 的擴展類,比如 JAVA_HOME/lib/ext 下面的 jar 包
AppClassLoader 負責加載 classpath 里的 jar 包和目錄
3.2 android 中的 ClassLoader
在這里,我們統(tǒng)稱 dex 文件,包含 dex 的 apk 文件以及 jar 文件為 dex 文件 PathClassLoader 用來加載系統(tǒng)類和應用程序類,用來加載 dex 文件,但是 dex2oat 生成的 odex 文件只能放在系統(tǒng)的默認目錄。
DexClassLoader 用來加載 dex 文件,可以從存儲空間加載 dex 文件,可以指定 odex 文件的存放目錄。
我們在插件化中一般使用的是 DexClassLoader。
3.3 雙親委派機制
每一個 ClassLoader 中都有一個 parent 對象,代表的是父類加載器,在加載一個類的時候,會先使用父類加載器去加載,如果在父類加載器中沒有找到,自己再進行加載,如果 parent 為空,那么就用系統(tǒng)類加載器來加載。通過這樣的機制可以保證系統(tǒng)類都是由系統(tǒng)類加載器加載的。 下面是 ClassLoader 的 loadClass 方法的具體實現(xiàn)。
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException{// First, check if the class has already been loadedClass<?> c = findLoadedClass(name);if (c == null) {try {if (parent != null) {// 先從父類加載器中進行加載c = parent.loadClass(name, false);} else {c = findBootstrapClassOrNull(name);}} catch (ClassNotFoundException e) {// ClassNotFoundException thrown if class not found// from the non-null parent class loader}if (c == null) {// 沒有找到,再自己加載c = findClass(name);}}return c;} 復制代碼3.4 如何加載插件中的類
要加載插件中的類,我們首先要創(chuàng)建一個 DexClassLoader,先看下 DexClassLoader 的構造函數(shù)需要那些參數(shù)。
public class DexClassLoader extends BaseDexClassLoader {public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {// ...} } 復制代碼構造函數(shù)需要四個參數(shù):
dexPath 是需要加載的 dex / apk / jar 文件路徑
optimizedDirectory 是 dex 優(yōu)化后存放的位置,在 ART 上,會執(zhí)行 oat 對 dex 進行優(yōu)化,生成機器碼,這里就是存放優(yōu)化后的 odex 文件的位置
librarySearchPath 是 native 依賴的位置
parent 就是父類加載器,默認會先從 parent 加載對應的類
創(chuàng)建出 DexClassLaoder 實例以后,只要調用其 loadClass(className) 方法就可以加載插件中的類了。具體的實現(xiàn)在下面:
// 從 assets 中拿出插件 apk 放到內部存儲空間private fun extractPlugin() {var inputStream = assets.open("plugin.apk")File(filesDir.absolutePath, "plugin.apk").writeBytes(inputStream.readBytes())}private fun init() {extractPlugin()pluginPath = File(filesDir.absolutePath, "plugin.apk").absolutePathnativeLibDir = File(filesDir, "pluginlib").absolutePathdexOutPath = File(filesDir, "dexout").absolutePath// 生成 DexClassLoader 用來加載插件類pluginClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader)} 復制代碼四、熱修復需要解決的難點
熱修復不同于插件化,不需要考慮各種組件的生命周期,唯一需要考慮的就是如何能將問題的方法/類/資源/so 替換為補丁中的新方法/類/資源/so。
其中最重要的是方法和類的替換,所以有不少熱修復框架只做了方法和類的替換,而沒有對資源和 so 進行處理。
五、主流的熱修復框架對比
這里選取幾個比較主流的熱修復框架進行對比
| dex 修復 | y | y | y | y | y |
| so 修復 | n | n | n | y | y |
| 資源修復 | n | n | n | y | y |
| 全平臺支持 | y | n | y | y | y |
| 即時生效 | n | y | y | n | 同時支持 |
| 補丁包大小 | 大 | 小 | 小 | 小 | 小 |
上面是熱修復框架的一些對比,如果按照實現(xiàn) dex 修復的原理來劃分的話,大概能分成下面幾種:
native hook
Andfix
dex 插樁
Qzone
Nuwa
InstantRun Robust
Aceso
全量替換 dex
Tinker
混合方案
Sophix
下面對這幾種熱修復的方案進行詳細分析。
六、dex 熱修復方案
6.1 native hook 替換 ArtMethod 內容
6.1.1 原理
在解釋 native hook 原理之前,先介紹一下虛擬機的一些簡單實現(xiàn)。java 中的類,方法,變量,對應到虛擬機里的實現(xiàn)是 Class,ArtMethod,ArtField。以 Android N 為例,簡單看一下這幾個類的一些結構。
class Class: public Object { public:// ...// classloader 指針uint32_t class_loader_;// 數(shù)組的類型表示uint32_t component_type_;// 解析 dex 生成的緩存uint32_t dex_cache_;// interface table,保存了實現(xiàn)的接口方法uint32_t iftable_;// 類描述符,例如:java.lang.Classuint32_t name_;// 父類uint32_t super_class_;// virtual method table,虛方法表,指令 invoke-virtual 會用到,保存著父類方法以及子類復寫或者覆蓋的方法,是 java 多態(tài)的基礎uint32_t vtable_;// public private uint32_t access_flags_;// 成員變量uint64_t ifields_;// 保存了所有方法,包括 static,final,virtual 方法uint64_t methods_;// 靜態(tài)變量uint64_t sfields_;// class 當前的狀態(tài),加載,解析,初始化等等Status status_;static uint32_t java_lang_Class_; };class ArtField { public:uint32_t declaring_class_;uint32_t access_flags_;uint32_t field_dex_idx_;uint32_t offset_; };class ArtMethod { public:uint32_t declaring_class_;uint32_t access_flags_;// 方法字節(jié)碼的偏移uint32_t dex_code_item_offset_;// 方法在 dex 中的 indexuint32_t dex_method_index_;// 在 vtable 或者 iftable 中的 indexuint16_t method_index_;// 方法的調用入口struct PACKED(4) PtrSizedFields {ArtMethod** dex_cache_resolved_methods_;GcRoot<mirror::Class>* dex_cache_resolved_types_;void* entry_point_from_jni_;void* entry_point_from_quick_compiled_code_;} ptr_sized_fields_; }; 復制代碼上面列出了三個結構的一部分變量,其實從這些變量可以比較清楚的看到,Class 中的 iftable_,vtable_,methods_ 里面保存了所有的類方法,sfields_,ifields_ 保存了所有的成員變量。而在 ArtMethod 中,ptr_sized_fields_ 變量指向了方法的調用入口,也就是執(zhí)行字節(jié)碼的地方。在虛擬機內部,調用一個方法的時候,可以簡單的理解為會找到 ptr_sized_fields_ 指向的位置,跳轉過去執(zhí)行對應的方法字節(jié)碼或者機器碼。簡圖如下:
這里也順便說一下上面三個結構的內容是什么時候填充的,就是在 ClassLoader 加載類的時候。簡圖如下:
其實到這里,我們就簡單理解了虛擬機的內部實現(xiàn),也就很容易想到 native hook 的原理了。既然每次調用方法的時候,都是通過 ArtMethod 找到方法,然后跳轉到其對應的字節(jié)碼/機器碼位置去執(zhí)行,那么我們只要更改了跳轉的目標位置,那么自然方法的實現(xiàn)也就被改變了。簡圖如下:
所以 native hook 的本質就是把舊方法的 ArtMethod 內容替換成新方法的 ArtMethod 內容。 具體的實現(xiàn)代碼在這里(只實現(xiàn)了 Android N 上的修復),下面看一些重點代碼。
6.1.2 實現(xiàn)代碼
通過上述方法的替換,再次調用舊方法,就會跳轉到新方法的入口,自然也就執(zhí)行新方法的邏輯了。
6.1.3 優(yōu)缺點
優(yōu)點:
補丁可以實時生效
缺點:
6.2 dex 插樁
6.2.1 原理
dex 插樁的實現(xiàn),是 Qzone 團隊提出來的,Nuwa 框架采用這種實現(xiàn)并且開源。
系統(tǒng)默認使用的是 PathClassLoader,繼承自 BaseDexClassLoader,在 BaseDexClassLoader 里,有一個 DexPathList 變量,在 DexPathList 的實現(xiàn)里,有一個 Element[] dexElements 變量,這里面保存了所有的 dex。在加載 Class 的時候,就遍歷 dexElements 成員,依次查找 Class,找到以后就返回。
下面是重點代碼。
public class PathClassLoader extends BaseDexClassLoader { }public class BaseDexClassLoader extends ClassLoader {private final DexPathList pathList; }final class DexPathList {// 保存了 dex 的列表private Element[] dexElements;public Class findClass(String name, List<Throwable> suppressed) {// 遍歷 dexElementsfor (Element element : dexElements) {DexFile dex = element.dexFile;if (dex != null) {// 從 DexFile 中查找 ClassClass clazz = dex.loadClassBinaryName(name, definingContext, suppressed);if (clazz != null) {return clazz;}}}// ...return null;} } 復制代碼從上面 ClassLoader 的實現(xiàn)我們可以知道,查找 Class 的關鍵就是遍歷 dexElements,那么自然就想到了把補丁 dex 插入到 dexElements 最前面,這樣遍歷 dexElements 就會優(yōu)先從補丁 dex 中查找 Class 了。
具體的實現(xiàn)在這里,下面放一些重點代碼。
6.2.2 實現(xiàn)代碼
public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {// 創(chuàng)建補丁 dex 的 classloader,目的是使用其中的補丁 dexElementsDexClassLoader dexClassLoader = new DexClassLoader(dexPath, defaultDexOptPath, dexPath, getPathClassLoader());// 獲取到舊的 classloader 的 pathlist.dexElements 變量Object baseDexElements = getDexElements(getPathList(getPathClassLoader()));// 獲取到補丁 classloader 的 pathlist.dexElements 變量Object newDexElements = getDexElements(getPathList(dexClassLoader));// 將補丁 的 dexElements 插入到舊的 classloader.pathlist.dexElements 前面Object allDexElements = combineArray(newDexElements, baseDexElements);}private static PathClassLoader getPathClassLoader() {PathClassLoader pathClassLoader = (PathClassLoader) InsertDexUtils.class.getClassLoader();return pathClassLoader;}private static Object getDexElements(Object paramObject)throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException {return Reflect.on(paramObject).get("dexElements");}private static Object getPathList(Object baseDexClassLoader)throws IllegalArgumentException, NoSuchFieldException, IllegalAccessException, ClassNotFoundException {return Reflect.on(baseDexClassLoader).get("pathList");}private static Object combineArray(Object firstArray, Object secondArray) {Class<?> localClass = firstArray.getClass().getComponentType();int firstArrayLength = Array.getLength(firstArray);int allLength = firstArrayLength + Array.getLength(secondArray);Object result = Array.newInstance(localClass, allLength);for (int k = 0; k < allLength; ++k) {if (k < firstArrayLength) {Array.set(result, k, Array.get(firstArray, k));} else {Array.set(result, k, Array.get(secondArray, k - firstArrayLength));}}return result;} 復制代碼6.2.3 優(yōu)缺點
優(yōu)點:
缺點:
6.3 dex 替換
dex 替換的方案,主要是 tinker 在使用,這里生成的補丁包不只是需要修改的類,而是包含了整個 app 所有的類,在替換時原理和 dex 插樁類似,也是替換掉 dexElements 中的內容即可,這里就不詳細說了。
6.4 InstantRun
6.4.1 原理
InstantRun 是 AndroidStudio 2.0 新增的功能,方便快速的增量編譯應用并部署,美團參照其原理實現(xiàn)了 Robust 熱修復框架。 其中的原理是,給每個 Class 中新增一個 changeQuickRedirect 的靜態(tài)變量,并在每個方法執(zhí)行之前,對這個變量進行了判斷,如果這個變量被賦值了,就調用補丁類中的方法,如果沒有被賦值,還是調用舊方法。 原理比較簡單,下面看看實現(xiàn)。具體實現(xiàn)在這里。
6.4.2 實現(xiàn)代碼
public class InstantRunUtils {// 上文中說的 changeQuickRedirect 變量,改了一下名字public static PatchRedirect patchRedirect;// 需要補丁的方法public int getValue() {// 判斷 patchRedirect 是否為空if (patchRedirect != null) {// 不為空,說明方法需要打補丁,由于一個類中有很多方法,所以這里需要判斷此方法是否需要補丁if (patchRedirect.needPatch("getValue")) {// 需要補丁,就調用補丁中的方法return (String) patchRedirect.invokePatchMethod("getValue");}}return 100;}// 注入補丁public static void inject(ClassLoader classLoader) {try {// 獲取到補丁中的補丁信息Class patchInfoClass = classLoader.loadClass("com.zy.hotfix.instant_run.PatchInfo");patchInfoClass.getMethod("init").invoke(null);// patchMap 中存著 className -> PatchRedirect,即需要補丁的類描述符和對應的 PatchRedirectMap<String, Object> patchMap = (Map<String, Object>) patchInfoClass.getField("patchMap").get(null);for (String key: patchMap.keySet()) {PatchRedirect redirect = (PatchRedirect) patchMap.get(key);Class clazz = Class.forName(key);// 替換 class 中的 PatchRedirectclazz.getField("patchRedirect").set(null, redirect);}} catch (Exception e) {e.printStackTrace();}} } 復制代碼然后我們看看補丁中的 PatchRefirect 是怎么實現(xiàn)的
public class InstantRunUtilsRedirect extends PatchRedirect {public Object invokePatchMethod(String methodName, Object... params) {// 根據(jù)方法描述符調用對應的方法if (methodName.equals("getValue")) {return getValue();}return null;}public boolean needPatch(String methodName) {// 判斷方法是否需要補丁if ("getValue".equals(methodName)) {return true;}return false;}// 補丁方法,返回正確的值public int getValue() {return 200;} } 復制代碼6.4.3 優(yōu)缺點
優(yōu)點:
缺點:
七、資源熱修復方案
關于資源的修復方案,沒有像代碼修復一樣方法繁多,基本上集中在對 AssetManager 的修改上。
7.1 替換 AssetManager
這個是 InstantRun 采用的方案,就是構造一個新的 AssetManager,反射調用其 addAssetPath 函數(shù),把新的補丁資源包添加到 AssetManager 中,從而得到含有完整補丁資源的 AssetManager,然后找到所有引用 AssetManager 的地方,通過反射將其替換為新的 AssetManager。
7.2 添加修改的資源到 AssetManager 中,并重新初始化
這個是 Sophix 采用的方案,原理是構造一個 package id 為 0x66 的資源包,只含有改變的資源,將其直接添加到原有的 AssetManager 中,這樣不會與原來的 package id 0x7f 沖突。然后將原來的 AssetManager 重新進行初始化即可,就不需要進行繁瑣的反射替換操作了。
八、so 熱修復方案
8.1 對加載過程進行封裝,替換 System.loadLibrary
在加載 so 庫的時候,系統(tǒng)提供了兩個接口
System.loadLibrary(String libName):用來加載已經安裝的 apk 中的 so System.load(String pathName):可以加載自定義路徑下的 so 復制代碼通過上面兩個方法,我們可以想到,如果有補丁 so 下發(fā),我們就調用 System.load 去加載,如果沒有補丁 so 沒有下發(fā),那么還是調用 System.loadLibrary 去加載系統(tǒng)目錄下的 so,原理比較簡單,但是我們需要再上面進行一層封裝,并對調用 System.loadLibrary 的地方都進行替換。
8.2 反射注入補丁 so 路徑
還記得上面 dex 插樁的原理么?在 DexPathList 中有 dexElements 變量,代表著所有 dex 文件,其實 DexPathList 中還有另一個變量就是 Element[] nativeLibraryPathElements,代表的是 so 的路徑,在加載 so 的時候也會遍歷 nativeLibraryPathElements 進行加載,代碼如下:
public String findLibrary(String libraryName) {String fileName = System.mapLibraryName(libraryName);// 遍歷 nativeLibraryPathElements for (Element element : nativeLibraryPathElements) {String path = element.findNativeLibrary(fileName);if (path != null) {return path;}}return null;} 復制代碼看到這里我們就知道如何去做了吧,就像 dex 插樁一樣的方法,將 so 的路徑插入到 nativeLibraryPathElements 之前即可。
九、總結
參考資料
www.cnblogs.com/popfisher/p…
tech.meituan.com/2016/09/14/…
深入探索Android熱修復技術原理
歡迎關注下面賬號,獲取最新技術文章:
Github
掘金
知乎
總結
以上是生活随笔為你收集整理的【Android 修炼手册】常用技术篇 -- Android 热修复解析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 税务筹划的基本步骤
- 下一篇: 【Android 修炼手册】常用技术篇