前言
由于工作需要,我反编译了某个App的代码并分析某个业务功能的实现。然后,我注意到源码里几乎在每一个函数的开头都能看到类似这样的代码:
查阅了一些资料得知,这些是美团的热更新方案Robust自动生成的代码。这个项目已经开源到了github上(github.com/Meituan-Dia…),在里面我们可以看到这样的介绍:
Robust是一个有着高兼容性以及高稳定性的安卓热修复解决方案,它可以立刻修复bug,甚至不需要重新启动应用
不需要重新启动应用就可以改变代码逻辑?出于好奇,我去研究了一下这个框架的使用方法,并进一步探究了它的实现原理。
热更新方案产生背景
我们都知道,客户端App有发版的概念,当线上出现一些紧急事故的时候,如果走正常的发版流程,让所有用户去重新下载安装一个问题已被修复的版本,那估计用户早都全跑完了。尤其是像美团这样的App,其庞大的用户量以及O2O交易场景的复杂性决定了这个App的稳定性必须达到近乎苛刻的要求。然而,就算再完善的开发测试流程也不可能保证不会把Bug带到线上,于是乎,热更新方案由此应运而生。有了热更新,客户端App就能实现不通过发版就可以实时修复线上问题,同时也拥有用户无感知、节省用户流量、修复成功率高等优点。
Robust框架使用方法
个人认为,要探究一个框架的原理,首先要学会使用这个框架,观察其表现效果。这样到时候带着疑问去探究其代码实现时,从代码反推回现象,可能会更容易理解。如果想直接看原理的也可以直接移步下方Robust框架原理解析一节。
首先我们新建一个简单的Demo工程,界面只有两个按钮,点击Jump按钮,会跳到另一个Activity,显示一行文字 error occur
假如我们现在想要接入Robust框架,通过热补丁的方式来改变这行文字的显示,应该怎么做呢?
大致可以分为以下步骤:
- 在app module的build.gradle,加入如下依赖:
- 在整个项目的build.gradle加入classpath:
- 在项目的 src 同级目录下配置robust.xml文件,具体项参考github上面的robust.xml文件,我们只将这两个地方修改成我们自己工程的包名:
- 编写热修复相关代码 我们来为Patch按钮添加热修复相关逻辑
PatchManipulateImp继承PatchManipulate,主要做一些拉取补丁以及校验文件的操作
RobustCallBackSample实现了RobustCallBack接口
PatchExecutor的run()方法
- 制作补丁
完成上述步骤,我们就算接入Robust热修复框架了。我们把混淆功能打开,打一个签名的release包, 安装到手机上,接下来就可以开始制作补丁。
在build目录下我们可以看到自动生成了个robust文件夹,我们在src的同级目录新建一个名为robust的文件夹,然后把mapping.txt跟methodsMap.robust这两个文件拷贝进去
然后apply插件,用于自动化生成补丁
然后开始修改我们的代码,在修改的方法处加上注解 @Modify,若是新增方法则注解 @Add
接着重新打一次release包,会报错,但是只要出现下方红框里的字样就说明补丁生成成功了
我们把生成的补丁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!
观察log也可以看到补丁是加载成功了的
Robust框架原理解析
Robust 最关键的技术点其实是借鉴了AS 2.0的一个新特性Instant Run,其原理并不复杂,可以简单描述为两点:
- 打基础包时插桩,在每个类里添加一个类型为 ChangeQuickRedirect 静态变量,在每个方法前插入一段相关的逻辑(如果这个静态变量是null,走正常逻辑,否则走补丁的修复逻辑)
- 加载补丁时,从补丁包中读取要替换的类及具体替换的方法实现,新建 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文件需要备份。
补丁加载过程分析
在介绍补丁加载过程之前,先介绍下一个补丁里需要有哪些文件:
- PatchesInfoImpl
这个类用于记录要修改的类以及其对应的 ChangeQuickRedirect 接口的实现,我们反编译补丁包得出以下结果:
- xxxPatchControl
这种类是ChangeQuickRedirect 接口的具体实现,是一个代理,具体的替换方法是在 xxxPatch 类中
- xxxPatch
这种就是具体的替换了方法实现的类,根据我们修改的类自动生成
补丁加载的过程可以描述为:
在补丁下载成功以后,会开启一个子线程,通过指定的路径去读patch文件的jar包(patch文件可以为多个,每个patch文件对应一个 DexClassLoader 去加载),加载时通过反射得到PatchesInfoImpl class,并创建其对象,然后通过getPatchedClassesInfo()方法得到那些要修改的类,然后遍历其中的类信息,进而再通过反射修改其中 ChangeQuickRedirect 对象的值,修改为xxxPatchControl.java 这个class new 出来的对象。
我们再反编译打出来的release包,可以看到这样的代码:
这样就可以解释为什么不需要重启应用就能够实时生效了:在补丁加载完以后,changeQuickRedirect 被赋值了不再是 null,这时如果判断到当前方法是要热补的方法(通过方法对应的编号来判断),isSupported 会返回 true,进而会走到 changeQuickRedirect 的 accessPatch 逻辑,然后走到secondActivityPatch 的 getTextInfo 逻辑,而这就是我们自己写的修复后的逻辑。
附一张官方链接的图:
关于自动化补丁工具
其实在Robust推广的初期,补丁基本是手动生成,一个补丁的制作和测试经常需要一天的时间,大大降低了系统对线上问题的反应速度,这也成为制约Robust热更新系统推广的一个因素。于是Robust团队经过不懈努力,开发了一个自动化补丁工具,研发只需要正常修改代码,然后加入一些注解,然后花一次打包的时间就可以生成稳定可用的补丁。
自动化补丁工具主要做了以下事情:
- 读取被 @Add、@Modify、RobustModify.modify() 标注的方法或类并记录
- 解析 mapping 文件并记录每个类和类中方法混淆前后对应的信息
- 根据得到的信息,通过 generatePatch 方法实际生成补丁
- 将生成的补丁class打包成jar包
其中最关键的是第三步,因为要处理的代码风格迥异,需要支持Java的各种语法,并且还要处理各种因为Java编译器优化以及ProGuard的优化工作导致的问题,例如混淆类名、方法名、字段名,修改方法、字段的访问性,方法的内联,甚至是减少方法的参数(这就改变了方法签名)等等,这些都极大地增加了自动化补丁的难度。可以说,Robust最核心的工作都在这个自动化补丁工具上。
感兴趣可以看这两篇文章:
Android热更新方案Robust开源,新增自动化补丁工具
其他常见热修复框架
热修复框架按照原理大致可以分为三类:
- 基于 multidex 机制来干预 ClassLoader 加载 dex,例如微信的 Tinker
- 通过native层hook来实现方法的替换,例如阿里的 AndFix
- instant-run 插桩方案,例如美团的 Robust
Robust的优劣:
优点:
- 正常使用DexClassLoader,兼容性和稳定性更好
- 即时生效,不需要重启
- 支持方法级别的修复,支持静态方法
- 支持新增方法和类
- 支持自动化生成补丁,可以处理ProGuard优化(混淆、内联等)以及Java编译器优化(桥方法、lambda、内部类等)导致的各种问题
缺点:
- 暂时不支持新增字段,但可以通过新增类解决
- 接入流程较复杂
- 为每个函数都插入了一段逻辑,会对运行效率、方法数、包体积还是产生了一些副作用(从数据上看影响较小)
- 没有安全校验,需要开发者在加载补丁之前自己做验证
总的来说,Robust还是一款有着高兼容性、高稳定性、高修复成功率的优秀的热修复框架。