引言
从Qzone的超级补丁,阿里的AndFix, 到微信的Tinker再到美团的Robust,热修复经历了dex分包,方法hook到dex全量替换以及利用Instant Run原理往每个方法中插入一段跳转代码来实现,而这4种方法中,除了阿里的AndFix之外,其他三种都依赖DexClassLoader,这样做容易带来的问题有:
- 兼容性问题,比如国产手机厂商对于ROM进行了魔改,那么就可能会在某些机型上失效甚至crash
- 审核无法通过的问题,由于国内应用商店其实还处于群龙无首的激烈竞争状态,所以一般国内的应用商店不会对App有太严格的审核要求,但是对于那些想要进入国外市场的App,则会遭遇到Google Play严苛的审核,其中热修复又是最为敏感的,而且DexClassLoader类也不可能进行混淆,所以很容易被扫描到。
考虑到AndFix在5.0之后其实就已经由于ART的兼容性问题力不从心(虽然后面的Sophix在此基础上做了很多改进,但是在最新的系统上还是有一些兼容性问题),如果想要实现稳定的热修复,同时又能够使App能够顺利通过Google Play的审核,有什么好的办法呢?
今天要介绍的Pinoc,就通过精心设计的动态语言Zlang, 达到了上面的要求。
Pinoc简介
Pinoc是一种新型的无需使用类加载器的动态代码修改方案,目前已经开源,github地址为 pinoc
Pinoc可以在Java方法入口处进行代码注入,对整个方法进行替换,也可以新增方法。
它的优点如下:
- 提供一种无需ClassLoader进行类加载的热修复方案
- 提供一种新型的动态事件跟踪方案
- 兼容性强,能在任何基于Java虚拟机的平台上运行
- 实时生效,一旦读取配置文件便可以立即替换或修改对应方法
Pinoc进行热修复的流程如下:
使用及Demo
使用Pinoc,请在build.gradle
中添加如下代码:
|
|
另外,你可以暂时禁用Pinoc,只需在项目或者模块的的gradle.properties
中,添加如下配置:
|
|
假设有如下代码:
|
|
上线后发现如下问题:
- init()方法中intent.getStringExtra(“tmp”)中的key有误,其实应该是”temp”
- onClick()方法中忘了添加统计信息
为了修复这个空指针错误为了修复这个空指针错误,同时为点击操作增加统计代码,使用Pinoc的话,只需要下发如下json信息:
|
|
其中targets字段对应要修复的类名+方法名+签名,以及修复所使用的libraries字段数组中的index; 而libraries字段中就是修复所使用的Zlang代码,关于Zlang语言的介绍和原理在本文的后面分析,这里先讲解Pinoc的使用。
到这里可以看到,使用Pinoc进行热修复是极为方便和快捷的,而且可以不用担心兼容性问题。
可能对于业务方来说,唯一不便的地方就是Zlang的学习成本吧,不过Zlang是一种极为简单的动态语言,相信你们即使还没学习过它的语法,上面的代码也能看懂大半,实际上它的学习成本也很低,有一个学习就完全有搞定。
Pinoc原理
Pinoc的原理为,在App构建的过程中,使用gradle插件将app中的每个Java方法都替换成它的变种方法(这一点其实跟Robust是一样的),如下图所示:
经过gradle插件处理后的代码如下(以DemoActivity为例):
|
|
可以看到,在app运行时,当一个方法被调用,实际上是调用了原始方法的变种方法,再由变种方法调用原始方法。不过在这之前,变种方法首先将原始方法的调用信息传给Pinoc,Pinoc根据配置文件决定是否替换或修改原始方法,这个配置文件可能是从服务器下发的。
为避免Java类加载器产生的一些麻烦,Pinoc不采用Java的类加载器来执行方法的替换体或者修改体,所以方法的替换体或者修改体不是用Java语言编写的。它们是用Zlang编写的,Zlang是一种运行在Java虚拟机的动态灵活的编程语言,支持Java对象的调用,在运行时可以与Java环境交互。开发者可以轻易地将Java代码转化为Zlang代码。
所以在运行时,如果Pinoc决定修改或替换某个方法,它将方法的替代体或者修改体用Zlang编译器编译成Zlang字节码,传入Zlang执行器执行。这样替换体或者修改体就被执行了,原方法也被替换或修改了。
具体的实现在PinocCore的onEnterMethod()方法中:
|
|
这个方法的逻辑非常简单,就是将配置信息与当前方法信息进行对比,如果发现当前方法需要替换,就执行配置文件中的Zlang方法(都是main方法,所以是library.execute(“main”,objects)),同时,对于方法的执行,可以选择在当前线程、主线程和背景线程中执行。
至于其他读取配置信息之类的代码都很简单,就不赘述了,查看Pinoc的更多信息请访问:Pinoc 设计原理
Zlang简介
Zlang是一种运行于JVM上并且能够在运行时访问java对象的动态语言。它有如下特性:
- 容易学习和使用
- 动态类型
- 能够在运行时与java交互,从而提供了无需classloader即可实现热修复的能力
- 在未来将支持函数式编程
- 未来将支持面向对象功能(目前跟C语言一样,只支持函数调用)
目前Zlang已经开源,github地址为Zlang
Zlang使用
接入方式
首先是添加gradle依赖:
|
|
然后是构建library,在调用一个Zlang函数之前,需要先构建library:
|
|
之后就可以开始调用了:
|
|
基本语法
如下是Zlang的Hello World:
|
|
可以看到跟java的语法很相似。
如下是一个复杂一点的示例:
|
|
当然,对于递归也是支持的,如下是计算阶乘的方法:
|
|
如下是一个二维数组的使用示例:
|
|
java对象访问
如下是利用Zlang打印当前时间的方法:
|
|
又比如,有如下类定义:
|
|
现在,我们可以通过修改dynamic_fun这个Zlang函数来达到动态修改Foo对象的目的:
|
|
这里,其实就是通过Zlang拥有热修复能力的原因。
Zlang编译器设计原理
Zlang编译器简介
在讲Zlang之前,以一个简单的语句为例,看一下普通的编译器的编译流程:
可见,一个典型的编译器需要具备词法分析、语法分析、语义分析、中间代码生成、代码优化以及目标代码生成的功能。
而Zlang说白了就是一个栈指令解释器,在java运行时对Zlang代码进行编译,然后在执行时不断地从栈中取出指令进行执行。
再加上Zlang目前只支持函数(不支持类和对象)调用,并且支持的文法也较简单,所以Zlang的实现也简单得多,把词法分析、语法分析和语义分析都融合在一起实现在Compiler类中,总代码量也就不到800行。
Zlang指令及符号表
它有如下指令:
|
|
而符号表如下:
|
|
与符号表对应的保留字如下:
|
|
Zlang文法
Zlang目前支持的文法如下:
|
|
是很典型的上下文无关文法,显然文法支持了或语句、与语句、比较语句、四则运算。
Zlang编译过程分析
其实确定了文法,编译过程的分析就简单了,至少词法分析是有迹可循的。
入口是compile()方法:
|
|
这个方法很简单,就是循环地调用function()来编译Zlang代码中的各个函数,直到遇到结束符。
function()方法则稍微复杂一些,代码如下:
|
|
结合我在代码中的注释,其实就很好理解了,function()中主要做了以下几件事:
- 首先是一些变量(比如符号表)的初始化
- 然后读取第一个符号,这个符号如果不是Symbol.FUNCTION的话,就不符合Zlang的文法了,从而抛出异常
- 然后读取第二个符号,这个符号只能是函数名(所以必须是Symbol.ID类型),否则便违反了Zlang的文法
- 再读取第三个符号,类似地,必须是左圆括号
- 之后在遇到右圆括号之前,循环读取函数参数
- 之后读取大括号,并且产生Fct.INT这个指令,表示需要分配空间
- 在大括号内部的就是Zlang语句了,所以后面进入statement()中
- 所有语句分析完成之后,产生一个Fct.VOID_RETURN指令(如果有返回值,会在这之前产生Fct.FUN_RETURN指令)
- 最后是将编译结果codes存储到library中
function()是Compiler中最核心的一个方法,这个方法分析完之后,后面的流程从statement()出发,很容易就能梳理出来,这里不再赘述。
如下是编译过程的主流程图:
其中function()在前面分析过,而statement()方法最终会调用到comparisonExpression(), numericExpression(), term()和factor()方法,这4个方法和前面提到的文法相对应。
到这里,编译流程就基本梳理完毕了,可以看到Zlang使用极简的代码就实现了一个自顶向下的LL(2)编译器。