Android进阶笔记热修复(代码资源动态链接库)
Posted 冬天的毛毛雨
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android进阶笔记热修复(代码资源动态链接库)相关的知识,希望对你有一定的参考价值。
好文推荐:
作者:pren2016
1. 热修复
1.1. 作用
- 严重的Bug,需要立即解决,而不用重新打包上架。
- 解决版本升级率不高,Bug会一直影响不升级版本的用户。
- 实现小功能短时间版本覆盖,如节日活动。
1.2. 主流热修复框架
1.2.1. 主流框架
派系 | 框架 |
---|---|
阿里系 | AndFix⚠️、Dexposed⚠️、HotFix⚠️、Sophix |
腾讯系 | Tinker、超级补丁、QFix |
知名公司 | 美团Robust、饿了么Amigo⚠️、蘑菇街Aceso⚠️ |
其他 | RocooFix⚠️、Nuwa⚠️、AnoleFix⚠️ |
1.2.2. 框架对比
特性 | Sophix | Tinker | 超级补丁 | Robust |
---|---|---|---|---|
即时生效 | 支持 | 不支持 | 不支持 | 不支持 |
方法替换 | 支持 | 支持 | 支持 | 支持 |
类替换 | 支持 | 支持 | 支持 | 不支持 |
类结构修改 | 支持 | 支持 | 不支持 | 不支持 |
资源替换 | 支持 | 支持 | 支持 | 不支持 |
so替换 | 支持 | 支持 | 不支持 | 不支持 |
补丁包大小 | 较小 | 较小 | 较大 | 一般 |
性能损耗 | 较小 | 较大 | 较大 | 较小 |
侵入式打包 | 无侵入 | 侵入 | 侵入 | 侵入 |
2. 代码修复
代码修复主要有3中方案:类加载方案、底层替换方案、Instant Run方案。
2.1. 类加载方案
采用类加载方案的主要是 Tinker、超级补丁、QFix、Amigo 和 Nuwa 等。
2.1.1. Dex 分包机制
【方法数 65536 限制】
由于 DVM 指令集的方法调用指令 invoke-kind
索引为16bits,即最多能引用 65535 个方法。因此,当一个 dex 文件中法方法数超过 65535 个时,就会抛出编译期异常:com.android.dex.DexIndexOverflowException: method ID not in [0, 0xffff]: 65536
。
【LinearAlloc 限制】
DVM 中的 LinearAlloc 是一个固定的缓存区,当方法数超出了缓存区的大小时会报错。因此,在安装应用时可能会提示 INSTALL_FAILED_DEXOPT
。
【分包方案】
- 解决 65536 限制和 LinearAlloc 限制。
- 打包时将应用代码分成多个 Dex,将应用启动时必须用到的类和这些类的直接引用类放到主 Dex 中,其他代码放到次 Dex 中。
- 当应用启动时先加载主 Dex,等到应用启动后再动态地加载次 Dex,从而缓解了主 Dex 的 65536 限制和 LinearAlloc 限制。
2.1.2. 类加载
在 Android 的类加载过程中,一个重要环节是调用 DexPathList
的 findClass
方法。
// 每一个dex文件,都对应一个Element元素,并有序排列
private Element[] dexElements;
public Class<?> findClass(String name, List<Throwable> suppressed) {
// 依次遍历dex文件数组
for (Element element : dexElements) {
// element.findClass内部会调用DexFile的loadClassBinaryName方法查找类
Class<?> clazz = element.findClass(name, definingContext, suppressed);
// 找到了这个类,就返回
if (clazz != null) {
return clazz;
}
}
if (dexElementsSuppressedExceptions != null) {
suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
}
return null;
}
2.1.3. 修复方案
根据类的查找流程:
- 将有 Bug 的类
Key.class
进行修改,再将Key.class
打包成补丁包Patch.dex
。 - 将
Patch.dex
放在dexElements
数组的第一个元素。 - 根据双亲委派,会首先找到
Patch.dex
中的Key.class
会优先加载,而存在 Bug 的Key.class
就不会被加载。
具体到实现细节上,不同的框架就有些差异了。
- 超级补丁:将 Patch.dex 放到
dexElements
数组的第一个元素。 - Tinker:将新旧 apk 做 diff,得到一个 patch.dex,再将 patch.dex 与手机 apk 中的 classes.dex 进行合并,生成 fix_classess.dex,再将 fix_classess.dex 放到
dexElements
数组的第一个元素。
2.2. 底层替换方案
采用底层替换方案的主要是 AndFix、Dexposed、HotFix 和 Sophix。
优点:不需要重启 APP,立即生效。
2.2.1. ArtMethod
Java 层的每个方法在 ART 中都对应着一个 ArtMethod 的结构体(包含 Java 方法的所有信息,包括执行入口、访问权限、所属类和代码执行地址等),只要把原方法的结构体内容替换为新的结构体内容,则在调用原来方法的时候,真正执行的指令是新方法的指令,就可以实现热修复。
在 art/runtime/art_method.h
文件中,定义了 ArtMethod 的结构体内容。
class ArtMethod FINAL {
/* ... */
GcRoot<mirror::Class> declaring_class_;
std::atomic<std::uint32_t> access_flags_;
uint32_t dex_method_index_;
uint16_t method_index_;
uint16_t hotness_count_;
uint16_t imt_index_;
struct PtrSizedFields {
ArtMethod** dex_cache_resolved_methods_;
void* data_;
void* entry_point_from_quick_compiled_code_;
} ptr_sized_fields_;
}
2.2.2. 修复方案
- 方法①:将待修复的 java 方法对应的 ArtMethod 结构体中的每个字段进行替换。
- 方法②:将待修复的 java 方法对应的 ArtMethod 结构体整个进行替换。
不同的框架采用了不同方案:
- AndFix 采用方法①,不同版本和不同厂商 ArtMethod 可能不同,存在兼容问题,导致方法替换失败。
- Sophix 采用方法②,不存在兼容问题 。
无论采用哪种方案,由于类加载后,类的结构和方法数量就已经固定了,因此该方案有以下不适场景:
- 增加或减少方法和字段的个数。
- 改变原有类的结构。
Sophix 结合了底层替换方案和类加载方案各自的优点,以底层替换方案为主,类加载方案为辅,在热部署无法使用的情况下,自动降级为冷部署。
2.3. Instant Run 方案
在 Android Studio 2.0 版本上,支持了一个新特性 Instant Run,实现了对代码修改的实时生效(热插拔)。
采用 Instant Run 方案的主要是 Robust 和 Aceso。
2.3.1. Instant Run 原理
在第一次构建 Apk 时:
- 在每一个类中注入了一个
$change
的成员变量,它实现了IncrementalChange
接口。 - 在每一个方法的第一行,插入了一段判断执行逻辑。
public class TestActivity {
// 注入一个类型为IncrementalChange的成员
IncrementalChange localIncrementalChange = $change;
public void onCreate(Bundle savedInstanceState){
// 当localIncrementalChange不为null时,可能会执行到access$dispatch从而替换掉之前老的逻辑
if (localIncrementalChange != null) {
localIncrementalChange.access$dispatch(
"onCreate.(Landroid/os/Bundle;)V", new Object[] { this, paramBundle });
return;
}
super.onCreate(savedInstanceState);
}
}
当我们点击 Android Studio 的 InstantRun 按钮时:
- 如果方法没有变化,则
$change
为null
,执行方法中的旧逻辑。 - 如果方法有变化,则:
- 动态生成替换类
TestActivity$override
和AppPatchesLoaderImpl
类。 AppPatchesLoaderImpl
类的getPatchedClasses
方法会返回被修改的类的列表,根据这个列表,TestActivity
中的$change
会被赋值为TestActivity$override
。- 判断条件成立,
access$dispatch()
方法会执行TestActivity$override
类中的onCreate
方法,从而实现对现有onCreate
方法的修改。
- 动态生成替换类
2.3.2. 修复方案
以 Robust 为例
- 在编译打包阶段对每个方法都自动的插入了一段代码。
- 动态下发包含有
PatchesInfoImpl.java
和Patch.java
的patch.dex
到客户端,用DexClassLoader
加载patch.dex
,反射拿到PatchesInfoImpl.java
这个 class 并创建对象。 - 然后通过这个对象的
getPatchedClassesInfo
方法,获得需要修复的 class 的混淆后名字,再反射得到当前运行环境中的该 class。 - 其中的
changeQuickRedirect
字段赋值为用patch.dex
中的Patch.java
这个 classnew
出来的对象。
3. 资源修复
很多热修复框架的资源修复都参考了 Instant Run 的资源修复原理。由于 Instant Run 不是 Android 的源码,需要反编译才能知道。
Instant Run 资源修复的核心逻辑在 MonkeyPatcher
类的 monkeyPatchExistingResources
方法中。
public class MonkeyPatcher {
public static void monkeyPatchExistingResources(
Context context, String externalResourceFile, Collection<Activity> activities) {
if (externalResourceFile == null) {
return;
}
try {
// 反射创建新的AssetManager
AssetManager newAssetManager = AssetManager.class.getConstructor(
new Class[0]).newInstance(new Object[0]);
Method mAddAssetPath = AssetManager.class.getDeclaredMethod(
"addAssetPath", new Class[]{String.class});
mAddAssetPath.setAccessible(true);
// 反射调用addAssetPath方法加载外部资源
if (((Integer) mAddAssetPath.invoke(
newAssetManager, new Object[]{externalResourceFile})).intValue() == 0) {
throw new IllegalStateException("Could not create new AssetManager");
}
Method mEnsureStringBlocks = AssetManager.class.getDeclaredMethod(
"ensureStringBlocks", new Class[0]);
mEnsureStringBlocks.setAccessible(true);
mEnsureStringBlocks.invoke(newAssetManager, new Object[0]);
if (activities != null) {
for (Activity activity : activities) {
Resources resources = activity.getResources();
try {
// 把Resources中的mAssets替换为newAssetManager
Field mAssets = Resources.class.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
/* ... */
}
// 获取Activity的主题
Resources.Theme theme = activity.getTheme();
try {
try {
// 把Resources.Theme中的mAssets替换为newAssetManager
Field ma = Resources.Theme.class.getDeclaredField("mAssets");
ma.setAccessible(true);
ma.set(theme, newAssetManager);
} catch (NoSuchFieldException ignore) {
/* ... */
}
/* ... */
} catch (Throwable e) {
/* ... */
}
}
Collection<WeakReference<Resources>> references = null;
/* ...根据不同SDK版本,用不同方式得到Resources的弱引用集合 */
for (WeakReference<Resources> wr : references) {
Resources resources = wr.get();
if (resources != null) {
try {
// 把每个Resources中的mAssets替换为newAssetManager
Field mAssets = Resources.class.getDeclaredField("mAssets");
mAssets.setAccessible(true);
mAssets.set(resources, newAssetManager);
} catch (Throwable ignore) {
/* ... */
}
resources.updateConfiguration(
resources.getConfiguration(), resources.getDisplayMetrics());
}
}
}
} catch (Throwable e) {
throw new IllegalStateException(e);
}
}
}
资源热修复总结为两个步骤:
- 反射创建新的 AssetManager 对象,反射调用
addAssetPath
方法加载外部的资源。 - 将 AssetManager 类型的
mAssets
字段的引用全部替换为新创建的 AssetManager 对象。
4. 动态链接库修复
Android 的动态链接库主要是 so 库。
4.1. so 的加载
加载 so 主要用到了 System
类的 load
和 loadLibarary
方法。
public final class System {
// 传入so的名字,会直接从系统的目录去加载so文件,
// 系统的路径包括/data/data/${package_name}/lib、/system/lib、/vender/lib等
public static void load(String filename) {
Runtime.getRuntime().load0(Reflection.getCallerClass(), filename);
}
// 传入so的绝对路径,直接从这个路径加载自定义外部so文件
public static void loadLibrary(String libname) {
Runtime.getRuntime().loadLibrary0(Reflection.getCallerClass(), libname);
}
}
实际上这两个方法最后都调用 nativeLoad
这个 native 方法去加载 so 库,参数 fileName
为 so 库在磁盘中的完整路径名。
而 nativeLoad 会调用 LoadNativeLibrary 函数来实现 so 的加载:
- 判断 so 是否被加载过,两次 ClassLoader 是否是同一个,避免 so 重复加载。
- 打开 so 并得到 so 句柄,如果 so 句柄获取失败,就返回false。创建新的 SharedLibrary,如果传入 path 对应的 library 为空指针,就将新创建的 SharedLibrary 赋值给 library,并将 library 存储到 libraries_ 中。
- 查找 JNI_OnLoad 的函数指针,根据不同情况设置 was_successful 的值,最终返回该 was_ successful。
4.2. 注册 Native 方法
4.2.1. 静态注册Native方法
通过 javah -jni 命令生成的包含 JNI 的头文件,接口的命名方式一般是 Java_<PackageName>_<ClassName>_<MethodName>
,程序执行时系统会根据这种命名规则来调用对应的 Native 方法。
- 注册方式方便简单。
- JNI函数名过长,可读性差,不能灵活改变。
4.2.2. 动态注册
在加载函数库(.a或.so)的时候进行注册,即在 JNI_OnLoad 方法里进行注册。
- 注册方式麻烦。
- JNI函数名可自由命名。
4.3. 修复方案
- 将 so 补丁插入到 NativeLibraryElement 数组的前部,让 so 补丁的路径先被返回和加载。
- 调用
System.load
方法来接管 so 的加载入口。
Tinker 就是使用 System.load
方法加载自定义.so文件。
- 使用 BSdiff 算法,比较新旧 so 文件,得到差分包 patch.so。
- 使用 BSpatch 算法,将 patch.so 与应用的原始 so 文件合并,生成 fix.so。
- 将 fix.so 保存到磁盘目录,再使用
System.load
加载该目录下的 so 文件。
最后
大家如果还想了解更多Android 相关的更多知识点,可以点进我的GitHub项目中:https://github.com/733gh/GH-Android-Review-master自行查看,里面记录了许多的Android 知识点。最后还请大家点点赞支持下!!!
以上是关于Android进阶笔记热修复(代码资源动态链接库)的主要内容,如果未能解决你的问题,请参考以下文章