這是【Android 修煉手冊】第 8 篇文章,若是尚未看過前面系列文章,歡迎點擊 這裏 查看~html
插件化和熱修復的原理,都是動態加載 dex/apk 中的類/資源,二者的目的不一樣。插件化目標在於加載 activity 等組件,達到動態下發組件的功能,熱修復目標在修復已有的問題。目標不一樣,也就致使其實現方式上的差異。因爲目標是動態加載組件,因此插件化重在解決組件的生命週期,以及資源的問題。而熱修復重在解決替換已有的有問題的類/方法/資源等。 關於插件化,能夠看前面分享的文章Android 插件化分析java
若是看過Android 插件化分析裏的 gradle 簡化插件開發流程,這裏能夠略過~android
在學習和開發熱修復的時候,咱們須要動態去加載補丁 apk,因此開發過程當中通常須要有兩個 apk,一個是宿主 apk,一個是補丁 apk,對應的就須要有宿主項目和補丁項目。
在 CommonTec 這裏建立了 app 做爲宿主項目,plugin 爲插件項目。爲了方便,咱們直接把生成的插件 apk 放到宿主 apk 中的 assets 中,apk 啓動時直接放到內部存儲空間中方便加載。
這樣的項目結構,咱們調試問題時的流程就是下面這樣:
修改插件項目 -> 編譯生成插件 apk -> 拷貝插件 apk 到宿主 assets -> 修改宿主項目 -> 編譯生成宿主 apk -> 安裝宿主 apk -> 驗證問題
若是每次咱們修改一個很小的問題,都經歷這麼長的流程,那麼耐心很快就耗盡了。最好是能夠直接編譯宿主 apk 的時候自動打包插件 apk 並拷貝到宿主 assets 目錄下,這樣咱們無論修改什麼,都直接編譯宿主項目就行了。如何實現呢?還記得咱們以前講解過的 gradle 系列麼?如今就是學以至用的時候了。
首先在 plugin 項目的 build.gradle 添加下面的代碼:c++
project.afterEvaluate {
project.tasks.each {
if (it.name == "assembleDebug") {
it.doLast {
copy {
from new File(project.getBuildDir(), 'outputs/patch/debug/patch-debug.apk').absolutePath
into new File(project.getRootProject().getProjectDir(), 'hotfix/src/main/assets')
rename 'patch-debug.apk', 'patch.apk'
}
}
}
}
}
複製代碼
這段代碼是在 afterEvaluate 的時候,遍歷項目的 task,找到打包 task 也就是 assembleDebug,而後在打包以後,把生成的 apk 拷貝到宿主項目的 assets 目錄下,而且重命名爲 plugin.apk。git
而後在 app 項目的 build.gradle 添加下面的代碼:github
project.afterEvaluate {
project.tasks.each {
if (it.name == 'mergeDebugAssets') {
it.dependsOn ':patch:assembleDebug'
}
}
}
複製代碼
找到宿主打包的 mergeDebugAssets 任務,依賴插件項目的打包,這樣每次編譯宿主項目的時候,會先編譯插件項目,而後拷貝插件 apk 到宿主 apk 的 assets 目錄下,之後每次修改,只要編譯宿主項目就能夠了。數組
若是看過Android 插件化分析裏的 ClassLoader 分析,這裏能夠略過~緩存
ClassLoader 是熱修復和插件化中必需要掌握的,由於插件是未安裝的 apk,系統不會處理其中的類,因此須要咱們本身來處理。app
BootstrapClassLoader 負責加載 JVM 運行時的核心類,好比 JAVA_HOME/lib/rt.jar 等等框架
ExtensionClassLoader 負責加載 JVM 的擴展類,好比 JAVA_HOME/lib/ext 下面的 jar 包
AppClassLoader 負責加載 classpath 裏的 jar 包和目錄
在這裏,咱們統稱 dex 文件,包含 dex 的 apk 文件以及 jar 文件爲 dex 文件 PathClassLoader 用來加載系統類和應用程序類,用來加載 dex 文件,可是 dex2oat 生成的 odex 文件只能放在系統的默認目錄。
DexClassLoader 用來加載 dex 文件,能夠從存儲空間加載 dex 文件,能夠指定 odex 文件的存放目錄。
咱們在插件化中通常使用的是 DexClassLoader。
每個 ClassLoader 中都有一個 parent 對象,表明的是父類加載器,在加載一個類的時候,會先使用父類加載器去加載,若是在父類加載器中沒有找到,本身再進行加載,若是 parent 爲空,那麼就用系統類加載器來加載。經過這樣的機制能夠保證系統類都是由系統類加載器加載的。 下面是 ClassLoader 的 loadClass 方法的具體實現。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// First, check if the class has already been loaded
Class<?> 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;
}
複製代碼
要加載插件中的類,咱們首先要建立一個 DexClassLoader,先看下 DexClassLoader 的構造函數須要那些參數。
public class DexClassLoader extends BaseDexClassLoader {
public DexClassLoader(String dexPath, String optimizedDirectory, String librarySearchPath, ClassLoader parent) {
// ...
}
}
複製代碼
構造函數須要四個參數:
dexPath 是須要加載的 dex / apk / jar 文件路徑
optimizedDirectory 是 dex 優化後存放的位置,在 ART 上,會執行 oat 對 dex 進行優化,生成機器碼,這裏就是存放優化後的 odex 文件的位置
librarySearchPath 是 native 依賴的位置
parent 就是父類加載器,默認會先從 parent 加載對應的類
建立出 DexClassLaoder 實例之後,只要調用其 loadClass(className) 方法就能夠加載插件中的類了。具體的實如今下面:
// 從 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").absolutePath
nativeLibDir = File(filesDir, "pluginlib").absolutePath
dexOutPath = File(filesDir, "dexout").absolutePath
// 生成 DexClassLoader 用來加載插件類
pluginClassLoader = DexClassLoader(pluginPath, dexOutPath, nativeLibDir, this::class.java.classLoader) } 複製代碼
熱修復不一樣於插件化,不須要考慮各類組件的生命週期,惟一須要考慮的就是如何能將問題的方法/類/資源/so 替換爲補丁中的新方法/類/資源/so。
其中最重要的是方法和類的替換,因此有很多熱修復框架只作了方法和類的替換,而沒有對資源和 so 進行處理。
這裏選取幾個比較主流的熱修復框架進行對比
Qzone/Nuwa | AndFix | Robust | Tinker | Sophix | |
---|---|---|---|---|---|
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 | 同時支持 |
補丁包大小 | 大 | 小 | 小 | 小 | 小 |
上面是熱修復框架的一些對比,若是按照實現 dex 修復的原理來劃分的話,大概能分紅下面幾種:
native hook
Andfix
dex 插樁
Qzone
Nuwa
InstantRun Robust
Aceso
全量替換 dex
Tinker
混合方案
Sophix
下面對這幾種熱修復的方案進行詳細分析。
在解釋 native hook 原理以前,先介紹一下虛擬機的一些簡單實現。java 中的類,方法,變量,對應到虛擬機裏的實現是 Class,ArtMethod,ArtField。以 Android N 爲例,簡單看一下這幾個類的一些結構。
class Class: public Object {
public:
// ...
// classloader 指針
uint32_t class_loader_;
// 數組的類型表示
uint32_t component_type_;
// 解析 dex 生成的緩存
uint32_t dex_cache_;
// interface table,保存了實現的接口方法
uint32_t iftable_;
// 類描述符,例如:java.lang.Class
uint32_t name_;
// 父類
uint32_t super_class_;
// virtual method table,虛方法表,指令 invoke-virtual 會用到,保存着父類方法以及子類複寫或者覆蓋的方法,是 java 多態的基礎
uint32_t vtable_;
// public private
uint32_t access_flags_;
// 成員變量
uint64_t ifields_;
// 保存了全部方法,包括 static,final,virtual 方法
uint64_t methods_;
// 靜態變量
uint64_t sfields_;
// class 當前的狀態,加載,解析,初始化等等
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_;
// 方法字節碼的偏移
uint32_t dex_code_item_offset_;
// 方法在 dex 中的 index
uint32_t dex_method_index_;
// 在 vtable 或者 iftable 中的 index
uint16_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_ 變量指向了方法的調用入口,也就是執行字節碼的地方。在虛擬機內部,調用一個方法的時候,能夠簡單的理解爲會找到 ptr_sized_fields_ 指向的位置,跳轉過去執行對應的方法字節碼或者機器碼。簡圖以下:
這裏也順便說一下上面三個結構的內容是何時填充的,就是在 ClassLoader 加載類的時候。簡圖以下:
其實到這裏,咱們就簡單理解了虛擬機的內部實現,也就很容易想到 native hook 的原理了。既然每次調用方法的時候,都是經過 ArtMethod 找到方法,而後跳轉到其對應的字節碼/機器碼位置去執行,那麼咱們只要更改了跳轉的目標位置,那麼天然方法的實現也就被改變了。簡圖以下:
因此 native hook 的本質就是把舊方法的 ArtMethod 內容替換成新方法的 ArtMethod 內容。 具體的實現代碼在這裏(只實現了 Android N 上的修復),下面看一些重點代碼。
// 建立補丁的 ClassLoader
pluginClassLoader = DexClassLoader(pluginPath, dexOutPath.absolutePath, nativeLibDir.absolutePath, this::class.java.classLoader) // 經過補丁 ClassLoader 加載新方法 val toMethod = pluginClassLoader.loadClass("com.zy.hotfix.native_hook.PatchNativeHookUtils").getMethod("getMsg")
// 反射獲取到須要修改的舊方法
val fromMethod = nativeHookUtils.javaClass.getMethod("getMsg")
複製代碼
nativeHookUtils.patch(fromMethod, toMethod)
複製代碼
Java_com_zy_hotfix_native_1hook_NativeHookUtils_patch(JNIEnv* env, jobject clazz, jobject src, jobject dest) {
// 獲取到 java 方法對應的 ArtMethod
art::mirror::ArtMethod* smeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(src);
art::mirror::ArtMethod* dmeth =
(art::mirror::ArtMethod*) env->FromReflectedMethod(dest);
reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->clinit_thread_id_ =
reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->clinit_thread_id_;
reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->status_ =
static_cast<art::mirror::Class::Status>(reinterpret_cast<art::mirror::Class*>(smeth->declaring_class_)->status_ -1);
//for reflection invoke
reinterpret_cast<art::mirror::Class*>(dmeth->declaring_class_)->super_class_ = 0;
// 替換方法中的內容
smeth->declaring_class_ = dmeth->declaring_class_;
smeth->access_flags_ = dmeth->access_flags_ | 0x0001;
smeth->dex_code_item_offset_ = dmeth->dex_code_item_offset_;
smeth->dex_method_index_ = dmeth->dex_method_index_;
smeth->method_index_ = dmeth->method_index_;
smeth->hotness_count_ = dmeth->hotness_count_;
// 替換方法的入口
smeth->ptr_sized_fields_.dex_cache_resolved_methods_ =
dmeth->ptr_sized_fields_.dex_cache_resolved_methods_;
smeth->ptr_sized_fields_.dex_cache_resolved_types_ =
dmeth->ptr_sized_fields_.dex_cache_resolved_types_;
smeth->ptr_sized_fields_.entry_point_from_jni_ =
dmeth->ptr_sized_fields_.entry_point_from_jni_;
smeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_ =
dmeth->ptr_sized_fields_.entry_point_from_quick_compiled_code_;
}
複製代碼
經過上述方法的替換,再次調用舊方法,就會跳轉到新方法的入口,天然也就執行新方法的邏輯了。
優勢:
補丁能夠實時生效
缺點:
dex 插樁的實現,是 Qzone 團隊提出來的,Nuwa 框架採用這種實現而且開源。
系統默認使用的是 PathClassLoader,繼承自 BaseDexClassLoader,在 BaseDexClassLoader 裏,有一個 DexPathList 變量,在 DexPathList 的實現裏,有一個 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) {
// 遍歷 dexElements
for (Element element : dexElements) {
DexFile dex = element.dexFile;
if (dex != null) {
// 從 DexFile 中查找 Class
Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
if (clazz != null) {
return clazz;
}
}
}
// ...
return null;
}
}
複製代碼
從上面 ClassLoader 的實現咱們能夠知道,查找 Class 的關鍵就是遍歷 dexElements,那麼天然就想到了把補丁 dex 插入到 dexElements 最前面,這樣遍歷 dexElements 就會優先從補丁 dex 中查找 Class 了。
具體的實如今這裏,下面放一些重點代碼。
public static void injectDexAtFirst(String dexPath, String defaultDexOptPath) throws NoSuchFieldException, IllegalAccessException, ClassNotFoundException {
// 建立補丁 dex 的 classloader,目的是使用其中的補丁 dexElements
DexClassLoader 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;
}
複製代碼
優勢:
缺點:
dex 替換的方案,主要是 tinker 在使用,這裏生成的補丁包不僅是須要修改的類,而是包含了整個 app 全部的類,在替換時原理和 dex 插樁相似,也是替換掉 dexElements 中的內容便可,這裏就不詳細說了。
InstantRun 是 AndroidStudio 2.0 新增的功能,方便快速的增量編譯應用並部署,美團參照其原理實現了 Robust 熱修復框架。 其中的原理是,給每一個 Class 中新增一個 changeQuickRedirect 的靜態變量,並在每一個方法執行以前,對這個變量進行了判斷,若是這個變量被賦值了,就調用補丁類中的方法,若是沒有被賦值,仍是調用舊方法。 原理比較簡單,下面看看實現。具體實如今這裏。
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,即須要補丁的類描述符和對應的 PatchRedirect
Map<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 中的 PatchRedirect
clazz.getField("patchRedirect").set(null, redirect);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
複製代碼
而後咱們看看補丁中的 PatchRefirect 是怎麼實現的
public class InstantRunUtilsRedirect extends PatchRedirect {
@Override
public Object invokePatchMethod(String methodName, Object... params) {
// 根據方法描述符調用對應的方法
if (methodName.equals("getValue")) {
return getValue();
}
return null;
}
@Override
public boolean needPatch(String methodName) {
// 判斷方法是否須要補丁
if ("getValue".equals(methodName)) {
return true;
}
return false;
}
// 補丁方法,返回正確的值
public int getValue() {
return 200;
}
}
複製代碼
優勢:
缺點:
關於資源的修復方案,沒有像代碼修復同樣方法繁多,基本上集中在對 AssetManager 的修改上。
這個是 InstantRun 採用的方案,就是構造一個新的 AssetManager,反射調用其 addAssetPath 函數,把新的補丁資源包添加到 AssetManager 中,從而獲得含有完整補丁資源的 AssetManager,而後找到全部引用 AssetManager 的地方,經過反射將其替換爲新的 AssetManager。
這個是 Sophix 採用的方案,原理是構造一個 package id 爲 0x66 的資源包,只含有改變的資源,將其直接添加到原有的 AssetManager 中,這樣不會與原來的 package id 0x7f 衝突。而後將原來的 AssetManager 從新進行初始化便可,就不須要進行繁瑣的反射替換操做了。
在加載 so 庫的時候,系統提供了兩個接口
System.loadLibrary(String libName):用來加載已經安裝的 apk 中的 so
System.load(String pathName):能夠加載自定義路徑下的 so
複製代碼
經過上面兩個方法,咱們能夠想到,若是有補丁 so 下發,咱們就調用 System.load 去加載,若是沒有補丁 so 沒有下發,那麼仍是調用 System.loadLibrary 去加載系統目錄下的 so,原理比較簡單,可是咱們須要再上面進行一層封裝,並對調用 System.loadLibrary 的地方都進行替換。
還記得上面 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熱修復技術原理