Android热修复架构杂谈

与前后端相比, 移动端对热修复的需求更为迫切和复杂. iOS上已有JSPatch这套成熟并被官方默许的解决方案. 然而Android上的热修复方案的演进则曲折的多, 简述一下鄙人的心路历程.

Dexposed

https://github.com/alibaba/dexposed

最早是在百川大会上听说这个方案, 但整个接入过程体验了一下真是吐了一口老血. 由于Dexposed不支持ART Runtime一开始就被放弃了.

Andfix

https://github.com/alibaba/andfix

真正尝试接入的热修复方案. 刚开始有其他公司的基友说是测试几百台手机都没有问题(幼稚的我竟然相信了), 然后就开始研究Andfix.

Andfix是一个可以实时修复的方案, 修复范围是方法, 具体是通过native hook java的方法反射运行补丁包中相应的方法来进行修复.

尼玛, 随着使用与测试的深入发现了许多坑一个一个填掉, 到最后上线还是遇到一些机型进行修复造成crash后立马下线掉了(兼容性问题). 其中还碰到ART模式下两个补丁同时修复同一个类会crash.

下面是测试后得出Andfix的修复范围:

修复内容是否支持备注
修改方法(使用的代码中不引入新的类)
新增方法
跨版本修复版本名变化会删除差异包
添加新的域(成员变量)差异包编译不通过
添加新的类NoClassDefFoundError
在方法内添加新的内部类NoClassDefFoundError
修改内部类里的方法(看操作的地方的this是否指向内部类)NoClassDefFoundError
inline方法
参数超过8个或带有long,double或者float的方法

如有错误请给予指正

其实Andfix是一套比较完善的热修复方案, 里面有许多思路值得借鉴. 但是考虑到如果继续投入精力在Andfix上所耗费的成本具有很大的不确定性, 遂放弃了Andfix.

Ps: 现在github上开源的Andfix已经差不多是KPI项目了, 阿里内部已经出第二个版本了, 而且内部人士表示不会开源. 如果有后来者想继续研究Andfix, 可以反编译支付宝Android端的代码学习借鉴一下.

PPs: 安利一个反编译工具 jadx

go die

RocooFix

https://github.com/dodola/RocooFix

某日老司机发我一链接, 叫我看看这个热修复方案, 遂开始研究RocooFix.

RocooFix是基于Qzone热修复方案的实践, 与之相似的还有Nuwa, HotFix. Qzone方案的修复范围Class级的替换(仅包括java文件, 不支持替换资源文件, so文件等), 而且需要在获取补丁后再次启动方可生效, 并将影响app启动速度(如果项目已经分包就忽略这条), 插桩具有一定的侵入性. 整个方案包括运行时工具和编译时工具, 运行时工具大致就是一套multidex框架, 编译时工具则主要是生成差异包和插桩.

RocooFix这套工具比较基础, 为了完善的热修复方案需要考虑到一些点:

  • 安全策略;
  • 重启管理;
  • 补丁更新;
  • 应用版本升级.

整体运行架构如图:

framework

安全策略

想起微信团队对JSPatch安全处理方式是采用RSA签名验证, 并且Android发布release包的时候也是需要签名的. 所以何不利用Android keystore对补丁签名, 然后在下发时进行校验.

jspatch safe

所以需要在生成补丁后通过SigningConfig获取本地keystore签名信息并使用jarsigner对补丁进行签名. 为了方便, 使用中直接修改RocooFix的gradle插件模块的代码, 并发布到内网maven服务器上使用.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
/**
* Signing Jar with Android Keystore
* @param patchDir patch temple data directory
* @param variant build.gradle signing configuration
* @return true, signing jar success
*/
public static boolean signJar(File patchDir, BaseVariant variant) {
def config = variant.getBuildType().getSigningConfig()
def buildTypeName = config.getName()
if (!buildTypeName.equalsIgnoreCase('release')) {
return false
}
def patchFile = new File(patchDir, "patch.jar")
if (patchFile.exists()) {
def exec = JavaEnvUtils.getJdkExecutable('jarsigner')
if ("jarsigner".equals(exec)) {
throw new RuntimeException("Cannot find JAVA_HOME in environment variables")
}
def storeFile = config.getStoreFile()
def storePassword = config.getStorePassword()
def keyAlias = config.getKeyAlias()
def keyPassword = config.getKeyPassword()
def signPatchFile = new File(patchDir, "patch_signed.jar")
copyJarFile(patchFile, signPatchFile)
def command = "${exec} -tsa http://timestamp.digicert.com -keystore ${storeFile.absolutePath} -storepass ${storePassword} -keypass ${keyPassword} ${signPatchFile.absolutePath} ${keyAlias}"
def proc = command.execute()
proc.waitFor()
if (proc.exitValue() != 0) {
println "Std Out: ${proc.in.text}"
println "CommandLine: ${command}"
throw new RuntimeException(proc.err.text);
} else {
println("build signed jar success")
return true
}
} else {
println("WARNING: PATCH FILE IS NOT EXISTS!!!")
}
return false
}
static void copyJarFile(File src, File dest) {
def input = src.newDataInputStream()
def output = dest.newDataOutputStream()
output << input
input.close()
output.close()
}

为了安全性, 实际使用中还采用了加密https来下发补丁文件.

管理逻辑

现在要实现的最优目标是无感知的将bug给修复掉, 当然也可以在接收到新补丁后弹框让用户选择强制重启(万不得以的时候).

为实现无感知的修复bug避免重启给用户造成的困惑, 实际中会监听App退到后台, 此时如果存在新的补丁的话kill掉进程. 判断应用处于后台我是采用ActivityLifecycleCallbacks (API level 14), 其他方案也可以参考AndroidProcess这个项目.

为了提高补丁的下发概率最好能配合推送服务.

Ps: 以上都不包括RocooFix中实时修复的部分.

Tinker

https://github.com/zzz40500/Tinker_imitator

还在研究中…

本文为原创,转载请遵守本站版权声明

更新于:2017年3月24日 01:03