Android热修复架构杂谈
2016年8月17日 23:13与前后端相比, 移动端对热修复 的需求更为迫切和复杂. 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
RocooFix
https://github.com/dodola/RocooFix
某日老司机发我一链接, 叫我看看这个热修复方案, 遂开始研究RocooFix.
RocooFix是基于Qzone热修复方案 的实践, 与之相似的还有Nuwa , HotFix . Qzone方案的修复范围Class级的替换(仅包括java文件, 不支持替换资源文件, so文件等), 而且需要在获取补丁后再次启动方可生效, 并将影响app启动速度(如果项目已经分包就忽略这条), 插桩具有一定的侵入性. 整个方案包括运行时工具和编译时工具, 运行时工具大致就是一套multidex框架, 编译时工具则主要是生成差异包和插桩.
RocooFix这套工具比较基础, 为了完善的热修复方案需要考虑到一些点:
- 安全策略;
- 重启管理;
- 补丁更新;
- 应用版本升级.
整体运行架构如图:
安全策略
想起微信团队对JSPatch 安全处理方式是采用RSA签名验证, 并且Android发布release包的时候也是需要签名的. 所以何不利用Android keystore对补丁签名, 然后在下发时进行校验.
所以需要在生成补丁后通过SigningConfig
获取本地keystore签名信息并使用jarsigner
对补丁进行签名. 为了方便, 使用中直接修改RocooFix的gradle插件模块的代码, 并发布到内网maven服务器上使用.
/**
* 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
文章作者: qbeenslee