网易新闻热补丁技术实践
两年之前android hot fix技术炒的非常热,各大厂商也是花样百出,各自献计,一时间出现了百家齐鸣的现象。但是各家都有各家的问题。当然网易其实也不甘落后,公司不同部门也研发了自己的热更新热补丁方案。网易新闻也是不断的探索未知,先后实践过一些方案, 目前使用的还是基于AOP的方案。
到目前为止,新闻android客户端上线热补丁技术也已经将近两年的时间了。也曾经为部门立下赫赫战功。一直想给这套方案起一个优雅的名字,但是始终没有想到满意的。直到今天早上,偶然间看到了一个单词Vulcan([‘vʌlkən]伏尔加,罗马神话中的火神与金工神),对这个词非常有感觉,那我就暂时已Vulcan对我们这套热补丁方案命名吧。
其实,很早之前就想写一下这套实践方案的思路,一直手懒没有写,今天看到了Vulcan这个词,一时兴起,提笔书之。
原理
原理来说很简单,简单来说就一句话:利用了java的 AOP技术,对字节码进行处理。 AOP技术已经不是一个新技术了,而是一个比较成熟的技术。也正因如此我们可以很快上手。java方面的aop技术有很多,如何选择呢? 经过调研我们最终选的了AspectJ的方案。也许有人看到了AspectJ就感觉这个用来做热补丁是不是太重了,但是不得不说AspectJ对java确实太友好了,让我们不得不选择它。
一、 构建时利用aop技术对每个方法进行插桩的操作
如上图所示,gradle进行构建的时候,在Java源码编译完成之后,生成dex文件之前,我们调用AspectJ的编译器进行插桩。插桩的目的是给每一个方法注入一段寻找patch方法的逻辑。
简单来说就是每个方法执行时先去根据自己方法的签名寻找是否有自己对应的patch方法,如果有执行patch方法,没有执行自己原有的逻辑。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public Object weaveJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
try {
String e = joinPoint.toLongString(); //获取当前方法的签名
PatchDebug.printMethodDesByFile(e);
if(PatchUtils.hasMethodPatch(e)) { //查询是否有自己方法的patch方法
Object target = joinPoint.getTarget(); //获取当前的运行对象,方便设置给patch方法
Object[] params = joinPoint.getArgs(); //获取运行参数
................................. //省略一些处理逻辑
return joinPoint.proceed();//执行原有逻辑
}
} catch (Exception var8) {
var8.printStackTrace();
}
return joinPoint.proceed();
}
I 对于如何进行插桩?
大家自行了解AOP的使用即可。
II 何时进行插桩?
最基本的原则是:java源码编译完成之后,Dex文件生成之前。
III 如何基于构建插桩
对于Ant打包估计已经很少有人再用了,这里就先忽略了。
对于gradle的构建, 其实也有很多方法,我就说一下网易新闻这边采用过的方案吧
最开始使用的是直接hook java Compiler的Task,在java源码编译完成之后执行AspectJ的编译器,进行字节码插桩操作。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21project.android.applicationVariants.all { variant ->
if (variant.buildType.name != "release") {
log.debug("Skipping non-release build type '${variant.buildType.name}'.")
return;
}
JavaCompile javaCompile = variant.javaCompile
javaCompile.doLast {
String[] args = ["-showWeaveInfo",
"-1.5",
"-inpath", javaCompile.destinationDir.toString(),
"-aspectpath", javaCompile.classpath.asPath,
"-d", javaCompile.destinationDir.toString(),
"-classpath", javaCompile.classpath.asPath,
"-bootclasspath", project.android.bootClasspath.join(File.pathSeparator)]
log.debug "ajc args: " + Arrays.toString(args)
MessageHandler handler = new MessageHandler(true);
new Main().run(args, handler);
}
}
}
}gradle Transform API方案
对于上述方法存在一些问题,对于一些jar包,其实已经是字节码了不会走这个过程,因此对一些jar的插桩操作不是很好处理。因此我们采用了Transform API的方案。 transform api是Android gradle plugin 1.5之后新api, 作用就是在生成dex之前,给开发者一个机会能够统一进行修改字节码。
用这个方案写了一个实现了Transform API的gradle 插件,具体对哪些jar包进行插桩我们通过gradle配置的方式实现。这就大大的方便了我们对自己的代码和第三方的代码进行字节码的处理。
二、创建patch补丁包
出现bug时将需要进行替换的方法放到指定类,然后生成一个只含有此java类字节码的apk包,进行下发
patch 类sample1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19public class PicShowFragmentPatch {
private String TAG = "PicShowFragmentPatch";
private PicShowFragment mTarget;
//接受当前运行对象
public void setTarget(PicShowFragment target) {
mTarget = target;
}
/**注意:如果需打patch的类被混淆过 @PatchAnnotation 的信息需要填写混淆过之后的对应路径
*注解的value值为方法的签名,签名中要注意写类的全路径
* #before 先执行patch代码逻辑 再执行原代码逻辑
* #replace 只执行patch代码逻辑
* #after 先执行原代码逻辑 再执行patch代码逻辑
*/
@PatchAnnotation(value = "public void com.netease.nr.biz.pics.PicShowFragment.onLocalLoadFinished(java.lang.Object)", intercept = "after")
public void onLocalLoadFinished(Object object){
Toast.makeText(mTarget.getContext(), "onLocalLoadFinished patch stat", Toast.LENGTH_LONG).show();
...................................... //执行patch方法的逻辑
}
}
三、 补丁包的传输和加载
对于补丁包的传输,没有什么好说的。但是需要提示的是在补丁包加载之前一定要注意安全性校验、安全行校验、安全性校验。重要的事情已经说完。
加载patch包的方式: 由于最终的patch包的形式是一个apk,因此加载也很简单直接使用android的DexFile.loadDex()将apk加载。加载完成之后遍历每一个Enumeration, 并反射获取所有class的Method,进行缓存起来,方便每个方法在缓存中查找。
优点和缺点
优点 | 缺点 |
---|---|
无兼容行问题; 时时生效;下发的patch包体积非常小 | apk方法数增大;插桩操作后apk体积增大;打release包时间变长 |
- AspectJ是对java完全兼容的,所以Vulcan的patch方案不存在兼容行问题。
- 每个方法执行时都会寻找对应的patch方法,因此是时时生效的;
- 下发的补丁,我们移除了apk中的资源文件,只保留了patch类的class文件和AndroidMainfest.xml中的版本信息,因此将体积做到了极限。
- apk方法数增大,这个是aop技术的最大弊端之一,再加上android有65535的方法数问题,导致我们不得不对apk进行多dex处理
- 由于对字节码的修改导致了dex体积增大,随之而来导致了apk体积的增大;目前网易新闻插桩与不插桩体积相差再1M左右。
- 编译时间变长,这个无需解释了吧,每个类字节码的处理是需要时间的。
TODO
- 探索其他的轻量级的aop技术,减小对字节码体积的影响;
- 探索其他aop技术,减少对java方法数的影响