美团热更新方案Robust介绍

4,991 阅读9分钟

前言

由于工作需要,我反编译了某个App的代码并分析某个业务功能的实现。然后,我注意到源码里几乎在每一个函数的开头都能看到类似这样的代码: image.png 查阅了一些资料得知,这些是美团的热更新方案Robust自动生成的代码。这个项目已经开源到了github上(github.com/Meituan-Dia…),在里面我们可以看到这样的介绍: Robust是一个有着高兼容性以及高稳定性的安卓热修复解决方案,它可以立刻修复bug,甚至不需要重新启动应用

不需要重新启动应用就可以改变代码逻辑?出于好奇,我去研究了一下这个框架的使用方法,并进一步探究了它的实现原理。

热更新方案产生背景

我们都知道,客户端App有发版的概念,当线上出现一些紧急事故的时候,如果走正常的发版流程,让所有用户去重新下载安装一个问题已被修复的版本,那估计用户早都全跑完了。尤其是像美团这样的App,其庞大的用户量以及O2O交易场景的复杂性决定了这个App的稳定性必须达到近乎苛刻的要求。然而,就算再完善的开发测试流程也不可能保证不会把Bug带到线上,于是乎,热更新方案由此应运而生。有了热更新,客户端App就能实现不通过发版就可以实时修复线上问题,同时也拥有用户无感知、节省用户流量、修复成功率高等优点。

Robust框架使用方法

个人认为,要探究一个框架的原理,首先要学会使用这个框架,观察其表现效果。这样到时候带着疑问去探究其代码实现时,从代码反推回现象,可能会更容易理解。如果想直接看原理的也可以直接移步下方Robust框架原理解析一节。

首先我们新建一个简单的Demo工程,界面只有两个按钮,点击Jump按钮,会跳到另一个Activity,显示一行文字 error occur

image.png

image.png

假如我们现在想要接入Robust框架,通过热补丁的方式来改变这行文字的显示,应该怎么做呢?

大致可以分为以下步骤:

  1. 在app module的build.gradle,加入如下依赖:

image.png

image.png

  1. 在整个项目的build.gradle加入classpath:

image.png

  1. 在项目的 src 同级目录下配置robust.xml文件,具体项参考github上面的robust.xml文件,我们只将这两个地方修改成我们自己工程的包名:

image.png

  1. 编写热修复相关代码 我们来为Patch按钮添加热修复相关逻辑

image.png

image.png

PatchManipulateImp继承PatchManipulate,主要做一些拉取补丁以及校验文件的操作

image.png

RobustCallBackSample实现了RobustCallBack接口

image.png

PatchExecutor的run()方法

image.png

  1. 制作补丁

完成上述步骤,我们就算接入Robust热修复框架了。我们把混淆功能打开,打一个签名的release包, 安装到手机上,接下来就可以开始制作补丁。

在build目录下我们可以看到自动生成了个robust文件夹,我们在src的同级目录新建一个名为robust的文件夹,然后把mapping.txtmethodsMap.robust这两个文件拷贝进去

image.png

image.png

然后apply插件,用于自动化生成补丁

image.png

然后开始修改我们的代码,在修改的方法处加上注解 @Modify,若是新增方法则注解 @Add

image.png

接着重新打一次release包,会报错,但是只要出现下方红框里的字样就说明补丁生成成功了

image.png

我们把生成的补丁push到之前在PatchManipulateImp的fetchPatchList方法里指定好的目录,模拟补丁推送成功的过程

adb push ./app/build/outputs/robust/patch.jar /sdcard/robust/patch.jar

这时候我们打开app,点击JUMP_SECOND_ACTIVITY,界面显示error occur,我们点击PATCH按钮,然后再点击JUMP_SECOND_ACTIVITY,就会发现此时界面上显示的内容变成了error fixed!

demo.gif

观察log也可以看到补丁是加载成功了的

image.png

Robust框架原理解析

Robust 最关键的技术点其实是借鉴了AS 2.0的一个新特性Instant Run,其原理并不复杂,可以简单描述为两点:

  1. 打基础包时插桩,在每个类里添加一个类型为 ChangeQuickRedirect 静态变量,在每个方法前插入一段相关的逻辑(如果这个静态变量是null,走正常逻辑,否则走补丁的修复逻辑)
  2. 加载补丁时,从补丁包中读取要替换的类及具体替换的方法实现,新建 ClassLoader 加载补丁dex

插桩过程分析

类似 InstantRun , Robust 也是使用 Transform API 修改字节码文件,该 API 允许第三方插件在 .class 文件打包为 dex 文件之前操作编译好的 .class 字节码文件。

我们看下‘robust’这个gradle插件的相关实现:

class RobustTransform extends Transform implements Plugin<Project> {
    ...
    @Override
    void apply(Project target) {
        //解析项目下robust.xml配置文件
        robust = new XmlSlurper().parse(new File("${project.projectDir}/${Constants.ROBUST_XML}"))
        ...
        project.android.registerTransform(this)
        project.afterEvaluate(new RobustApkHashAction())
    }
        
    @Override
    void transform(Context context, Collection<TransformInput> inputs, Collection<TransformInput> referencedInputs, TransformOutputProvider outputProvider, boolean isIncremental) throws IOException, TransformException, InterruptedException {
        ...
        ClassPool classPool = new ClassPool()
        project.android.bootClasspath.each {
            logger.debug "android.bootClasspath   " + (String) it.absolutePath
            classPool.appendClassPath((String) it.absolutePath)
        }
        ...
        def box = ConvertUtils.toCtClasses(inputs, classPool)
        insertRobustCode(box, jarFile)
        writeMap2File(methodMap, Constants.METHOD_MAP_OUT_PATH)
        ...
    }
}

首先读取 robust.xml 配置文件并初始化,可配置选项主要包括:

  • 一些开关选项
  • 需要热补的包名或者类名,这些包名下的所有类都被会插入代码
  • 不需要热补的包名或者类名,可以剔除指定的类或者包

然后通过 Transform API 调用 transform() 方法,扫描所有类加入到 classPool 中,调用 insertRobustCode() 方法,这个方法做了以下事情:

  • 将class设置为public
  • 过滤掉不需要插桩的类跟方法,包括:接口、无方法类、robust.xml文件中配置的不需要热补的类,以及构造方法、抽象方法、native方法、synthetic方法等
  • 在类中插入 public static ChangeQuickRedirect changeQuickRedirect这个静态变量
if (!addIncrementalChange) {
    //插入 public static ChangeQuickRedirect changeQuickRedirect;
    addIncrementalChange = true;
    ClassPool classPool = it.declaringClass.classPool
    CtClass type = classPool.getOrNull(Constants.INTERFACE_NAME);
    CtField ctField = new CtField(type, Constants.INSERT_FIELD_NAME, ctClass);
    ctField.setModifiers(AccessFlag.PUBLIC | AccessFlag.STATIC)
    ctClass.addField(ctField)
    logger.debug "ctClass: " + ctClass.getName();
}
  • 在方法中插入逻辑代码段
if (ctBehavior.getMethodInfo().isMethod()) {
    boolean isStatic = ctBehavior.getModifiers() & AccessFlag.STATIC;
    CtClass returnType = ctBehavior.getReturnType0();
    String returnTypeString = returnType.getName();
    def body = "if (${Constants.INSERT_FIELD_NAME} != null) {"
    body += "Object argThis = null;"
    if (!isStatic) {
        body += "argThis = $0;"
    }
    body += "   if (com.meituan.robust.PatchProxy.isSupport($args, argThis, ${Constants.INSERT_FIELD_NAME}, $isStatic, " + methodMap.get(ctBehavior.longName) + ")) {"
    body += getReturnStatement(returnTypeString, isStatic, methodMap.get(ctBehavior.longName));
    body += "   }"
    body += "}"
    ctBehavior.insertBefore(body);
}
  • 通过 zipFile() 方法写回class文件

最后调用 writeMap2File() 方法将插桩的方法信息写入 robust/methodsMap.robust 文件中,此文件和混淆的mapping文件需要备份。

补丁加载过程分析

在介绍补丁加载过程之前,先介绍下一个补丁里需要有哪些文件:

  1. PatchesInfoImpl

这个类用于记录要修改的类以及其对应的 ChangeQuickRedirect 接口的实现,我们反编译补丁包得出以下结果:

image.png

  1. xxxPatchControl

这种类是ChangeQuickRedirect 接口的具体实现,是一个代理,具体的替换方法是在 xxxPatch 类中

image.png

  1. xxxPatch

这种就是具体的替换了方法实现的类,根据我们修改的类自动生成

补丁加载的过程可以描述为:

在补丁下载成功以后,会开启一个子线程,通过指定的路径去读patch文件的jar包(patch文件可以为多个,每个patch文件对应一个 DexClassLoader 去加载),加载时通过反射得到PatchesInfoImpl class,并创建其对象,然后通过getPatchedClassesInfo()方法得到那些要修改的类,然后遍历其中的类信息,进而再通过反射修改其中 ChangeQuickRedirect 对象的值,修改为xxxPatchControl.java 这个class new 出来的对象。

我们再反编译打出来的release包,可以看到这样的代码:

image.png

这样就可以解释为什么不需要重启应用就能够实时生效了:在补丁加载完以后,changeQuickRedirect 被赋值了不再是 null,这时如果判断到当前方法是要热补的方法(通过方法对应的编号来判断),isSupported 会返回 true,进而会走到 changeQuickRedirect 的 accessPatch 逻辑,然后走到secondActivityPatch 的 getTextInfo 逻辑,而这就是我们自己写的修复后的逻辑。

附一张官方链接的图:

image.png

关于自动化补丁工具

其实在Robust推广的初期,补丁基本是手动生成,一个补丁的制作和测试经常需要一天的时间,大大降低了系统对线上问题的反应速度,这也成为制约Robust热更新系统推广的一个因素。于是Robust团队经过不懈努力,开发了一个自动化补丁工具,研发只需要正常修改代码,然后加入一些注解,然后花一次打包的时间就可以生成稳定可用的补丁。

自动化补丁工具主要做了以下事情:

  1. 读取被 @Add、@Modify、RobustModify.modify() 标注的方法或类并记录
  2. 解析 mapping 文件并记录每个类和类中方法混淆前后对应的信息
  3. 根据得到的信息,通过 generatePatch 方法实际生成补丁
  4. 将生成的补丁class打包成jar包

其中最关键的是第三步,因为要处理的代码风格迥异,需要支持Java的各种语法,并且还要处理各种因为Java编译器优化以及ProGuard的优化工作导致的问题,例如混淆类名、方法名、字段名,修改方法、字段的访问性,方法的内联,甚至是减少方法的参数(这就改变了方法签名)等等,这些都极大地增加了自动化补丁的难度。可以说,Robust最核心的工作都在这个自动化补丁工具上。

感兴趣可以看这两篇文章:

Android热更新方案Robust开源,新增自动化补丁工具

Android热补丁之Robust(二)自动化补丁原理解析

其他常见热修复框架

热修复框架按照原理大致可以分为三类:

  1. 基于 multidex 机制来干预 ClassLoader 加载 dex,例如微信的 Tinker
  2. 通过native层hook来实现方法的替换,例如阿里的 AndFix
  3. instant-run 插桩方案,例如美团的 Robust

image.png

Robust的优劣:

优点:

  • 正常使用DexClassLoader,兼容性和稳定性更好
  • 即时生效,不需要重启
  • 支持方法级别的修复,支持静态方法
  • 支持新增方法和类
  • 支持自动化生成补丁,可以处理ProGuard优化(混淆、内联等)以及Java编译器优化(桥方法、lambda、内部类等)导致的各种问题

缺点:

  • 暂时不支持新增字段,但可以通过新增类解决
  • 接入流程较复杂
  • 为每个函数都插入了一段逻辑,会对运行效率、方法数、包体积还是产生了一些副作用(从数据上看影响较小)
  • 没有安全校验,需要开发者在加载补丁之前自己做验证

总的来说,Robust还是一款有着高兼容性、高稳定性、高修复成功率的优秀的热修复框架。

参考文献

Instant Run 浅析

Android热补丁之Robust原理解析(一)

Android热更新方案Robust

【Android 热修复】美团Robust热修复框架原理解析