Robust 2.0:支持Android R8的升级版热修复框架
- 1. 背景
- 2. 主要挑战
- 3 解决思路
- 3.1 整体方案介绍
- 3.2 问题和解决方法
- 4 总结
- 5 本文作者
1. 背景
美团 Robust 是基于方法插桩的实时热修复框架,主要优势是实时生效、零 Hook 兼容所有 Android 版本。2016 年,我们在《 Android 热更新方案 Robust 》一文中对技术原理做了详细介绍,主要通过给每个方法插入 IF 分支来动态控制代码逻辑,进而实现热修复。其核心主要有两部分:一个是代码插桩,一个是自动补丁。- 代码插桩这部分随着 Javassist、ASM 工具的广泛使用,整体方案比较成熟了,迭代改进主要是针对插桩代码体积和性能的优化;
- 自动补丁这部分在实际使用过程中一直在迭代,跟业界主流热修复方案一样,自动化补丁工具作制作时机是在 Proguard 混淆之后,由于 Proguard 会对代码进行代码优化和混淆处理,在 Proguard 后制作补丁能够降低补丁生成的复杂性。
2. 主要挑战
Android 热修复补丁的大致制作流程:首先基于线上代码进行逻辑修复并二次打包,然后补丁生成工具自动比较修复包和线上包的差异,最后制作出轻量的补丁包。因此在补丁制作的过程中,需要解决两个主要问题:- 对于没有变动的代码,如何在二次打包时保证和线上包一致;
- 对于本次修复的代码,如何在经过编译、优化、混淆之后准确识别出来并生成补丁代码。
- 资源编译器(aapt/aapt2) :资源编译环节会生成一个 R.java 文件( 记录着资源 id,便于代码中引用 ),一般为了解决 R field 过多以及减少包大小,大型 Android 项目会在构建过程中会将资源 id 直接内联到调用处( 发生在 javac 和 proguard 之间 )。如果前后两次打包出现资源 id 不一致,会影响 diff 识别的结果。
-
代码编译器(javac)
:Java 代码经过 javac 编译成字节码之后,除了有一些简单的优化(
如常量表达式折叠、条件编译
),还有一些基础的脱糖(J
ava 8 之前的语法特性
)操作会生成一些新的类/方法/指令,如匿名内部类会被编译成一个名为
OuterClass$1.class的新类,以及命名为access$200之类的桥方法。如果改动涉及内部类、泛型,二次打包$后面的数字编号可能和线上包出现乱序。 - 代码优化器(ProGuard/R8) :目前主要使用第三方开源工具 ProGuard ( Google 推出 R8 计划取代 Proguard ),通过 30+ 可选优化项,对前面生成的 Java 字节码进一步压缩、优化、混淆,可以使得 Android 安装包更小、更加安全、运行效率更高:
- 压缩 :通过静态分析并删除未被使用的 class/field/method,即源码中存在的 class/field/method,线上包中不一定存在。
- 优化 :通过一系列优化算法或者模版,对字节码进行优化,使得构建产物更小、运行更高效/安全,优化手段有合并类/接口、内联短方法、裁剪方法参数、删除不可达分支、外联代码( R8 新增 )、删除无副作用代码( 如 Log.d() )、修改方法/变量可见性等等。优化后的字节码相比源码,可能出现 class/field/method 数量减少、field/method 访问修饰符发生变化、method 签名发生变化、code 指令变少,另外二次构建优化结果可能和线上包不一致。
- 混淆 :通过将 class/field/method 的名称重命名为一个无意义的短字符,增加逆向难度,减少包大小。二次打包需要保证和线上包的混淆保持一致,不然补丁加载后因调用异常而发生崩溃。
- 没有合适时机制作补丁。如果将基于 JAR 的改动识别方案,改成基于 DEX 或者 Smali,等同于更换补丁制作方案,前者需要基于 DEX 文件格式和指令,后者需要处理大量寄存器,更容易出错,兼容性和稳定性不够好。
- Proguard 可以禁用一部分优化选项,但是 R8 官方文档明确表示不支持禁用部分优化,相比之前会产生更多的差异,对改动识别造成干扰。
3 解决思路
| 3.1 整体方案介绍
基于 R8 构建的补丁制作思路是将改动识别提到优化混淆之前,对比 Java 字节码,同时结合对线上 APK 结构化解析( class/field/method ),校正补丁代码对线上代码的调用,得到 patch.jar,最后借助 R8 对 patch.jar 进行混淆( applymapping )、脱糖、生成 Dex,打包得到 patch.apk,完整流程如下图所示:| 3.2 问题和解决方法
3.2.1 R8 与 Proguard 优化对比
部分 ProGuard 的配置项在切换到 R8 后失效,R8 官方文档对此做出的解释是:随着 R8 的不断改进,维护标准的优化行为有助于 Android Studio 团队轻松排查并解决您可能遇到的任何问题。com.android.tools.r8.utils.InternalOptions.enableEnumUnboxing一些规则可以通过构建参数-Dcom.android.tools.r8.disableMarkingMethodsFinal 来控制关闭/开启,其他不支持的参数也可以参考如下方式简单改造一下:
com.android.tools.r8.utils.InternalOptions.enableVerticalClassMerging
com.android.tools.r8.utils.InternalOptions.enableClassInlining
com.android.tools.r8.utils.InternalOptions.inlinerOptions().enableInlining//方法内联
com.android.tools.r8.utils.InternalOptions.outline.enabled)//方法外联
com.android.tools.r8.utils.InternalOptions.testing.disableMarkingMethodsFinal
com.android.tools.r8.utils.InternalOptions.testing.disableMarkingClassesFinal
3.2.2 “真”“假”改动识别
如果源码中有匿名内部类,javac 会编译生成一个命名为 {外部类名}${数字编号} 的类,后面的数字编号是根据该匿名内部类在外部类中出现的先后顺序,依次累加计算出来的。3.2.3 内联识别与处理
通过第二章节的介绍,可以看到线上代码在经 javac 编译之后还会经过字节码优化、混淆等处理,因此,通过上面字节码比对识别出来的代码变更( class/method 维度 ),如果涉及对线上代码的调用,还需要确保这些 Field/Method 的调用是“合法”的,避免运行时崩溃。 在众多优化项当中,主要需要关注的是 class/field/method 是否存在、是否可访问。如果线上包中不存在( 上次构建过程中被移除或者被内联 ),补丁生成阶段需要当做新增类/方法加进来;如果线上包中不可以被外部访问( 上次构建过程中 public 被改为 private ),补丁生成阶段需要将直接调用改成反射调用;如果线上包中方法签名发生变化( 上次构建过程中参数被裁剪 ),需要修改调用或者当做新方法加进来。 由于 Dex 文件与标准的 class 文件在结构设计上有着本质的区别( Dex 工具将所有的 class 文件整合到一个或几个 Dex 文件,目的是其中各个类能够共享数据,使得文件结构更加经凑 ),两者无法直接对比。具体检测方法是先通过 ASM 分析补丁 class 中的外部引用,然后借助 dexlib2 库解析 APK 中的 Dex,提取出 class/field/method 结构化信息( 还需反混淆处理 ),最后再兼容性分析和处理。 R8 外联优化是一种高级优化技术,生效条件非常苛刻,需要在合适的环境下合理使用,R8的外联优化会将多个方法中的相同代码提取到新方法中,以降低代码体积,但是会增加一次方法调用开销。如果恰好想修复的代码是被外联出去的方法,直接将外联方法当成新增方法来修复即可。3.2.4 混淆问题与优化
不同于前面对在二次打包过程中对整个项目进行 ApplyMapping,这里只需要对少数发生变更的类进行 ApplyMapping,出现混淆不一致的概率会小很多。Robust 补丁制作过程中,仅将改动的类传递给 Proguard 进行二次混淆,这个过程中会自动应用线上包的 mapping 文件:但在某些特殊情形下,比如删了一个旧方法、同时又增加了一个新方法,或者是 ApplyMapping 的缺陷,还是会出现补丁中的混淆和线上混淆实际并不一致的情况,因此在生成补丁之后,还需要根据线上 APK 进行对比校验,如果发现错误混淆,进一步反编译成 Smali 之后进行字符替换。-applymapping {线上包的 mapping.txt}
3.2.5 其他方面的优化
(1)super 指令 在 Android 开发中,invoke-super 指令经常被用来重写某个系统方法,同时保留父类方法中的一些逻辑。以 Activity 类的 onCreate 方法为例:public class MyActivity extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState); // 调用父类的 onCreate 方法
}
}
其中 super.onCreate(savedInstanceState) 就是一种典型的 super 调用,经过 Dex 编译后,在 Smali 语法层面看到的就是 invoke-super 指令。但在 patch 类中,无法编写类似 myActivity.super.onCreate(savedInstanceState),因为 super 只能在原类使用;即使采用字节码技术强行编写了,在运行时会提示 java.lang.NoSuchMethodError,因为 patch 不是目标方法的子类。
为了模拟实现 JVM 的 invoke-super 指令,需要为每个 patch 类生成一个继承了被修复类父类的辅助类(
解决 super 调用只能在目标子类使用的问题
),并且在辅助类里面将 patch.onCreate 转换为原始类的调用 origin.super.onCreate。Robust 早期是在 Smali 层面进行处理的,需要将 Dex 转换为 Smali,处理完以后,再把 Smali 转换为 Dex。用 ASM 字节码直接对 Class 字节码进行处理更方便,不需要再转换为 Smali,针对该辅助类的ASM字节码转换关键代码如下:
public class SuperMethodVisitor extends MethodVisitor {
...
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
if (opcode == Opcodes.INVOKEVIRTUAL) {
// 将 INVOKEVIRTUAL 指令替换成 INVOKESPECIAL
super.visitMethodInsn(Opcodes.INVOKESPECIAL, owner, name, desc, itf);
} else {
super.visitMethodInsn(opcode, owner, name, desc, itf);
}
}
@Override
public void visitVarInsn(int opcode, int var) {
if (opcode == Opcodes.ALOAD && var == 0) {
//保证super调用在原始类
mv.visitVarInsn(opcode, 1);
return;
}
mv.visitVarInsn(opcode, var);
}
...
}
上述方式是采用了一个辅助类来实现的,下面介绍另一种改进的方法。
在 JNI 层,常见的 CallObjectMethod 函数适用于调用虚方法,即调用方法时依赖于对象的类层次结构,类似于 Java 的 invoke-virtual;与之对应的是 CallNonvirtualObjectMethod 函数,它适用于非虚方法调用,即调用的对象为指定的类的对象,无论这个类有没有被继承或覆盖,也就是说可以通过 CallNonvirtualObjectMethod 调用父类 super 方法。
Java 语言中的 invoke-super 指令可以通过 CallNonvirtualObjectMethod、GetMethodID 组合来实现,关键代码如下:
jmethodID methodID = env->GetMethodID(parentClass, "superMethodName", "()V");(2)<init> 函数的插桩与修复 在部分子类 <init> 函数会显式调用父类的构造函数 super() ,且 super() 必须是子类 <init> 函数中的第一句语句,否则编译失败。因此对于 <init> 函数,不能在第一行进行 Robust 插桩,需要在父类的构造函数 super() 之后插桩。 那么 <init> 函数如何修复呢?原始类 <init> 函数修改后,在 patch 类也是 <init> 函数,这里需要将该 <init> 函数拷贝成普通函数,并将原始类的 Robust 插桩关联到该普通函数。 复制构造函数并将其转换为方法需要注意:
jvalue args[] = {};
jobject result = env->CallNonvirtualObjectMethod(parentObj, parentClass, methodID, args);
- 原始类函数名称 <init> 需要改成普通方法名称,避免与 patch 类的 <init> 函数冲突。
- 原始类 <init> 函数如果有方法参数,则需要保留成一致的。
- patch 类新方法的 return type 是 void。
- 原始类 <init> 函数如果有调用 this() 或 super() 构造函数,则需要在 patch 新方法里删除它们。
- 在类实例化时,如果该类的 <clinit> 方法还没有执行,则会执行该方法,以初始化类的静态变量和复杂的静态表达式。
- 在通过反射获取该类的某个静态成员时,如果该类的 <clinit> 方法还没有执行,则会执行该方法,以初始化类的静态变量和复杂的静态表达式。
- 如果该类被子类继承,而子类中也定义了 <clinit> 方法,则在创建子类实例时,会先执行父类的 <clinit> 方法,然后再执行子类的 <clinit> 方法。
/**clinit 函数修复时,在补丁入口类的静态代码块里面设置好 ClintPatchProxy 的跳转接口实现即可,原 MainActivity 的 clinit 代码将不再执行,转而执行 MainActivityPatch的clinit 代码( 对应 MainActivity 的新 clinit 代码 )。 (4) 修复新增类/新增成员变量/新增方法 基于方法插桩的方法,天然支持新增类;对于新增 Field 和新增 Method,分两种情况:静态的 Field 和 Method 可以用一个新增类来包裹;新增非静态 Field 可以使用一个辅助类来维持 this 对象与该 Field 的映射关系,补丁里面原本使用this.newFieldName的代码,通过字节码工具转换为 FieldHelper.get(this).getNewFieldName() 即可。
* 线上 MainActiviy clinit 插桩
*/
public class MainActivity {
static {
String classLongName = "com.app.MainActivity";
if (ClintPatchProxy.isSupport(classLongName)) {
ClintPatchProxy.accessDispatch(classLongName);
} else {
// MainActitiy Clinit origin code
}
4 总结
回顾 Robust 热修复补丁制作过程,主要是对构建编译过程和字节码编辑技术的巧妙结合。通过分析 Android 应用打包过程、Java 语言编译和优化过程,补丁制作过程中可能会遇到的各种问题就有了答案,再借助字节码工具分析、处理就能够生成一个热修复补丁。 当然,这其中涉及大量的细节处理,仅通过一篇文章不足以涵盖各种细节,还需要结合实际项目才能有更全面的了解。5 本文作者
常强,美团平台-App技术部工程师。 ---------- END ----------推荐阅读
| Android对so体积优化的探索与实践 | Android静态代码扫描效率优化与实践 | Android兼容Java 8语法特性的原理分析