关于美团:Robust-20支持Android-R8的升级版热修复框架

7次阅读

共计 9581 个字符,预计需要花费 24 分钟才能阅读完成。

2016 年,咱们对美团 Android 热更新计划 Robust 的技术原理做了具体介绍。近几年,Google 推出了新的代码优化混同工具 R8,Android 热修复补丁制作依赖二次构建包和线上包比照,须要对 Proguard 切换到 R8 提前进行适配和革新,本文分享 Robust 在适配 R8 以及优化改良中的一些思路和教训,心愿能对大家有所帮忙或者启发。

1. 背景

美团 Robust 是基于办法插桩的实时热修复框架,次要劣势是实时失效、零 Hook 兼容所有 Android 版本。2016 年,咱们在《Android 热更新计划 Robust》一文中对技术原理做了具体介绍,次要通过给每个办法插入 IF 分支来动态控制代码逻辑,进而实现热修复。其外围次要有两局部:一个是代码插桩,一个是主动补丁。

  • 代码插桩这部分随着 Javassist、ASM 工具的宽泛应用,整体计划比拟成熟了,迭代改良次要是针对插桩代码体积和性能的优化;
  • 主动补丁这部分在理论应用过程中始终在迭代,跟业界支流热修复计划一样,自动化补丁工具作制作机会是在 Proguard 混同之后,因为 Proguard 会对代码进行代码优化和混同解决,在 Proguard 后制作补丁可能升高补丁生成的复杂性。

近年来,Google 推出了新的代码优化混同工具 R8,用于取代第三方的代码优化混同工具 Proguard,通过多年性能迭代和缺点改良,R8 在性能上根本能够代替 Proguard,在后果上更为杰出(优化生成的 Android 字节码体积更小)。Google 曾经在新版本的构建工具中强制应用 R8,国内外已有多个出名 App 实现了 R8 适配并上线,比方微信 Android 在往年正式从 Proguard 切换到了 R8(通过降级 Android 构建工具链)。Android 热修复补丁制作依赖二次构建包和线上包比照,须要对 Proguard 切换到 R8 提前进行适配和革新,本文分享了美团平台技术部 Robust 在适配 R8 以及优化改良中的一些思路和教训。

2. 次要挑战

Android 热修复补丁的大抵制作流程:首先基于线上代码进行逻辑修复并二次打包,而后补丁生成工具主动比拟修复包和线上包的差别,最初制作出轻量的补丁包。因而在补丁制作的过程中,须要解决两个次要问题:

  • 对于没有变动的代码,如何在二次打包时保障和线上包统一;
  • 对于本次修复的代码,如何在通过编译、优化、混同之后精确辨认进去并生成补丁代码。

要解决这两个问题,须要对 Android 编译和构建过程有肯定理解,弄清楚问题产生的起因。下图 1 是一个 Android 我的项目从源码到 APK(Android 利用安装包)的构建过程(椭圆形对应构建工具链):

上图有些工具已被新呈现的工具所取代,然而整体的流程并没有太大变动。对照这个图,咱们剖析一下其中对补丁制作 / 二次打包有影响的几个环节:

  1. 资源编译器(aapt/aapt2):资源编译环节会生成一个 R.java 文件(记录着资源 id,便于代码中援用),个别为了解决 R field 过多以及缩小包大小,大型 Android 我的项目会在构建过程中会将资源 id 间接内联到调用处(产生在 javac 和 proguard 之间)。如果前后两次打包呈现资源 id 不统一,会影响 diff 辨认的后果。
  2. 代码编译器(javac):Java 代码通过 javac 编译成字节码之后,除了有一些简略的优化(如常量表达式折叠、条件编译),还有一些根底的脱糖(Java 8 之前的语法个性)操作会生成一些新的类 / 办法 / 指令,如匿名外部类会被编译成一个名为 OuterClass$1.class 的新类,以及命名为 access$200 之类的桥办法。如果改变波及外部类、泛型,二次打包 $ 前面的数字编号可能和线上包呈现乱序。
  3. 代码优化器(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 的名称重命名为一个无意义的短字符,减少逆向难度,缩小包大小。二次打包须要保障和线上包的混同保持一致,不然补丁加载后因调用异样而产生解体。
  4. 脱糖工具(图中未标出,旧版本应用三方插件 Lambda/Desugar,新版本中应用自带的 R8):因为低版本 Android 设施不反对 Java 8+ 语法个性,这一步须要将 Lambda 表达式、办法援用、默认和动态接口办法等高版本的语法个性转为低版本实现。其中 Lambda 表达式会被编译成一个外部类,会有相似(2)中的问题。

至此,咱们对本章结尾提到的 2 个问题的产生起因有了肯定意识,通过 Android 构建过程生成的字节码相比源码在 class/field/method/code 维度上有了“结构性”的变动,比方修复代码中调用的 class/field/method 在线上包中不存在(被 shrink、被 merge、被 inline),或者源码中能够拜访、但在补丁中无法访问的 field/method(修饰符被标记为 private)、method parameter 列表匹配不上(之前没有被用到的 parameter 被裁剪了)等等。

Proguard 提供的这些优化项是可选的,个别状况下大型 Android 我的项目中会结合实际收益、稳定性以及构建耗时等多方因素综合考量后,会禁用一部分优化项,但并不是齐全禁用。因而,二次打包时和线上包会产生一些差别,补丁制作准确性会受此影响。过来 Robust 补丁制作过程常常遇到此类问题,通过特殊字符检测、白名单等形式可能晋升辨认的准确性,但实现计划不够自动化。Robust 补丁制作流程如下:

如果将 Android 我的项目的构建工具链(Android Gradle Plugin)降级到官网较新版本,上图中的 Proguard(Java 字节码优化和)+ Dex(Android 字节码生成)两个环节将被合并成一个,并被替换成 R8:

上述构建工具链的降级变动,给 Robust 补丁制作带来 2 个新的问题:

  1. 没有适合机会制作补丁。如果将基于 JAR 的改变辨认计划,改成基于 DEX 或者 Smali,等同于更换补丁制作计划,前者须要基于 DEX 文件格式和指令,后者须要解决大量寄存器,更容易出错,兼容性和稳定性不够好。
  2. 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 团队轻松排查并解决您可能遇到的任何问题。

截至目前,仍能在网上搜到不少因 R8 优化带来的问题,没有公开文档介绍优化规定的应用和禁用阐明。只能通过浏览 ProGuard 官网文档和 R8 源码,比照剖析两者优化规定的类似和差别。通过 R8 源码发现能够通过暗藏的构建参数、反射或者间接批改 R8 源码实现一部分规定禁用,尽管 R8 的优化规定并不是和 Proguard 一一对应,但也根本能够实现和之前应用 Proguard 时雷同的优化成果。

com.android.tools.r8.utils.InternalOptions.enableEnumUnboxing
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

一些规定能够通过构建参数 -Dcom.android.tools.r8.disableMarkingMethodsFinal 来管制敞开 / 开启,其余不反对的参数也能够参考如下形式简略革新一下:

如果某个我的项目中不心愿禁用这些规定呢?在之前的补丁制作流程中,可能会影响改变辨认的准确性。而在新的补丁制作流程中,改变辨认不受影响,但在辨认之后,还须要联合线上 APK 查看补丁中的内部调用是否非法。进一步仔细分析这些优化规定,能够分为 class、field、method、code 四类,其中对 Robust 补丁制作影响较大的是办法内联、参数移除、被标记为 private,前面的大节里将会介绍相应的解决办法。

3.2.2“真”“假”改变辨认

如果源码中有匿名外部类,javac 会编译生成一个命名为 {外部类名}${数字编号} 的类,前面的数字编号是依据该匿名外部类在外部类中呈现的先后顺序,顺次累加计算出来的。

当修复代码中有新增 / 删除匿名外部类时,仅通过类名无奈比拟(所以在一些以类为最小粒度的热修复框架应用文档里,会看到相似“不反对新增匿名外部类”、“只反对在外部类的开端减少匿名外部类”之类的形容),这时候 Robust 会含糊解决前面的数字编号,通过字节码比照进一步查找到实在变动的匿名外部类,辨认出哪些是真改,哪些是假改。

此外,如果嵌套类之间波及公有 field/method 拜访,javac 编译器会生成命名规定为 access$100access$200 的桥接办法,access$ 前面的数字编号(和呈现的先后顺序无关)也会影响改变辨认(最终 R8 会将修饰符改成 public 并删除桥接办法),这里的解决办法和下面辨认实在外部类改变的形式相似。

还有一种状况值得注意,大一点的 Android 我的项目通常会采纳组件化的形式,每个组件以 AAR 模式参加 App 构建打包,在组件二进制发版(源码 -> AAR)过程中,能够应用 R8 进行脱糖(For Android)失去 Java 7 字节码,典型的例子是 Lambda 表达式,通过脱糖解决生成 {外部类}$$ExternalSyntheticLambda{数字}(甚至有多重数字的状况如 $2$1)之类的 class,以及在外部类中生成命名规定为 lambda${办法名}${数字} 的静态方法(不同的脱糖器,命名规定不一样),补丁生成工具解决办法和下面相似。

最终辨认进去的代码改变,不仅蕴含源码有改变的办法或者新增办法 / 类(如果有),还包含与之无关的、由 javac 编译器脱糖生成的字节码,以及由组件二进制发版过程中经 R8 脱糖生成的字节码。

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 {线上包的 mapping.txt}

但在某些非凡情景下,比方删了一个旧办法、同时又减少了一个新办法,或者是 ApplyMapping 的缺点,还是会呈现补丁中的混同和线上混同理论并不统一的状况,因而在生成补丁之后,还须要依据线上 APK 进行比照校验,如果发现错误混同,进一步反编译成 Smali 之后进行字符替换。

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");
jvalue args[] = {};
jobject result = env->CallNonvirtualObjectMethod(parentObj, parentClass, methodID, args);

(2)\<init> 函数的插桩与修复

在局部子类 \<init> 函数会显式调用父类的构造函数 super(),且 super() 必须是子类 \<init> 函数中的第一句语句,否则编译失败。因而对于 \<init> 函数,不能在第一行进行 Robust 插桩,须要在父类的构造函数 super() 之后插桩。

那么 \<init> 函数如何修复呢?原始类 \<init> 函数批改后,在 patch 类也是 \<init> 函数,这里须要将该 \<init> 函数拷贝成一般函数,并将原始类的 Robust 插桩关联到该一般函数。

复制构造函数并将其转换为办法须要留神:

  • 原始类函数名称 \<init> 须要改成一般办法名称,防止与 patch 类的 \<init> 函数抵触。
  • 原始类 \<init> 函数如果有办法参数,则须要保留成统一的。
  • patch 类新办法的 return type 是 void。
  • 原始类 \<init> 函数如果有调用 this() 或 super() 构造函数,则须要在 patch 新办法里删除它们。

(3)\<clinit> 函数的插桩与修复

\<clinit> 函数是由编译器生成的一个非凡的动态构造方法,它被用来初始化类中的动态变量和简单的动态表达式。如果在一个类中定义了动态变量或代码块,那么编译器会为这些动态变量和代码块生成一个 \<clinit> 函数。\<clinit> 函数只会被执行一次,虚构机会保障只有一个线程可能执行 \<clinit> 办法,确保对共享的类级别变量的线程平安拜访。

因而,对 \<clinit> 函数进行插桩和修复时,须要特地留神 \<clinit> 办法的执行机会:

  • 在类实例化时,如果该类的 \<clinit> 办法还没有执行,则会执行该办法,以初始化类的动态变量和简单的动态表达式。
  • 在通过反射获取该类的某个动态成员时,如果该类的 \<clinit> 办法还没有执行,则会执行该办法,以初始化类的动态变量和简单的动态表达式。
  • 如果该类被子类继承,而子类中也定义了 \<clinit> 办法,则在创立子类实例时,会先执行父类的 \<clinit> 办法,而后再执行子类的 \<clinit> 办法。

根据上述 \<clinit> 函数执行机会剖析,插桩时不能拜访类的动态成员变量(拜访动态变量时 clinit 函数就曾经执行了,无奈被无效修复),因而无奈借助于 Robust 惯例插桩办法(给 Class 插入一个动态接口 Field),须要借助一个辅助类 ClintPatchProxy 来实现插桩逻辑。

/**
 * 线上 MainActiviy clinit 插桩
 */
public class MainActivity {
    static {
        String classLongName = "com.app.MainActivity";
        if (ClintPatchProxy.isSupport(classLongName)) {ClintPatchProxy.accessDispatch(classLongName);
        } else {// MainActitiy Clinit origin code}
        

clinit 函数修复时,在补丁入口类的动态代码块外面设置好 ClintPatchProxy 的跳转接口实现即可,原 MainActivity 的 clinit 代码将不再执行,转而执行 MainActivityPatch 的 clinit 代码(对应 MainActivity 的新 clinit 代码)。

(4)修复新增类 / 新增成员变量 / 新增办法

基于办法插桩的办法,人造反对新增类;对于新增 Field 和新增 Method,分两种状况:动态的 Field 和 Method 能够用一个新增类来包裹;新增非动态 Field 能够应用一个辅助类来维持 this 对象与该 Field 的映射关系,补丁外面本来应用 this.newFieldName 的代码,通过字节码工具转换为 FieldHelper.get(this).getNewFieldName() 即可。

4 总结

回顾 Robust 热修复补丁制作过程,次要是对构建编译过程和字节码编辑技术的奇妙联合。通过剖析 Android 利用打包过程、Java 语言编译和优化过程,补丁制作过程中可能会遇到的各种问题就有了答案,再借助字节码工具剖析、解决就可能生成一个热修复补丁。当然,这其中波及大量的细节解决,仅通过一篇文章不足以涵盖各种细节,还须要结合实际我的项目能力有更全面的理解。

5 本文作者

常强,美团平台 -App 技术部工程师。

| 本文系美团技术团队出品,著作权归属美团。欢送出于分享和交换等非商业目标转载或应用本文内容,敬请注明“内容转载自美团技术团队”。本文未经许可,不得进行商业性转载或者应用。任何商用行为,请发送邮件至 tech@meituan.com 申请受权。

正文完
 0