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

go die

RocooFix

https://github.com/dodola/RocooFix

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

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

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

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

整体运行架构如图:

hotfix architecture
hotfix architecture

安全策略

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

jspatch safe
jspatch safe

所以需要在生成补丁后通过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

CC BY-NC 4.0