Pinoc:热修复的另一种可能性

引言

从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中添加如下代码:

1
2
3
4
5
6
7
8
9
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.iqiyi:pinoc-plugin:0.2.1'
}
}
apply plugin: "pinoc"

另外,你可以暂时禁用Pinoc,只需在项目或者模块的的gradle.properties中,添加如下配置:

1
pinoc-plugin.enabled=false // default is true

假设有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DemoActivity extends AppCompatActivity {
private void init() {
Intent intent = getIntent();
TextView textView = (TextView) findViewById(com.iqiyi.pinocdemo.R.id.tv);
textView.setText(intent.getStringExtra("tmp").toUpperCase());
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(com.iqiyi.pinocdemo.R.layout.activity_demo);
init();
findViewById(com.iqiyi.pinocdemo.R.id.bn).setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Toast.makeText(getApplicationContext(), "Click", Toast.LENGTH_SHORT).show();
}
});
}
}

上线后发现如下问题:

  • init()方法中intent.getStringExtra(“tmp”)中的key有误,其实应该是”temp”
  • onClick()方法中忘了添加统计信息

为了修复这个空指针错误为了修复这个空指针错误,同时为点击操作增加统计代码,使用Pinoc的话,只需要下发如下json信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{targets:[
{class: "com/iqiyi/trojantest/DemoActivity", method_name: "init", method_sig: "()V", library: 0},
{class: "com/iqiyi/trojantest/DemoActivity$1", method_name: "onClick", method_sig: "(Landroid/view/View;)V", library: 1}
],
libraries:[
"function main(className, methodName, methodSignature, this, parameters) \{\
intent = _invoke_method(this, \"getIntent\");\
tmp = _invoke_public_method(intent, \"getStringExtra\", \"temp\");\
if (tmp != null) \{\
textView = _invoke_method(this, \"findViewById\", _get_static_field(\"com.iqiyi.trojantest.R$id\", \"tv\"));\
_invoke_public_method(textView, \"setText\", _invoke_public_method(tmp, \"toUpperCase\"));\
\}\
return null;\
\}",
"function main(className, methodName, methodSignature, this, parameters) \{\
context = get_outer_context(this);\
id = _invoke_public_method(parameters[0], \"getId\");\
track(_get_class_name(context), id);\
\}"
]}

其中targets字段对应要修复的类名+方法名+签名,以及修复所使用的libraries字段数组中的index; 而libraries字段中就是修复所使用的Zlang代码,关于Zlang语言的介绍和原理在本文的后面分析,这里先讲解Pinoc的使用。

到这里可以看到,使用Pinoc进行热修复是极为方便和快捷的,而且可以不用担心兼容性问题。

可能对于业务方来说,唯一不便的地方就是Zlang的学习成本吧,不过Zlang是一种极为简单的动态语言,相信你们即使还没学习过它的语法,上面的代码也能看懂大半,实际上它的学习成本也很低,有一个学习就完全有搞定。

Pinoc原理

Pinoc的原理为,在App构建的过程中,使用gradle插件将app中的每个Java方法都替换成它的变种方法(这一点其实跟Robust是一样的),如下图所示:

经过gradle插件处理后的代码如下(以DemoActivity为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class DemoActivity extends AppCompatActivity {
private void init() {
if (Pinoc.onEnterMethod("com/iqiyi/pinocdemo/DemoActivity", "init", "()V", this, new Object[0]) == Library.NO_RETURN_VALUE) {
((TextView) findViewById(R.id.tv)).setText(getIntent().getStringExtra("tmp").toUpperCase());
}
}
protected void onCreate(Bundle bundle) {
if (Pinoc.onEnterMethod("com/iqiyi/pinocdemo/DemoActivity", "onCreate", "(Landroid/os/Bundle;)V", this, new Object[]{bundle}) == Library.NO_RETURN_VALUE) {
super.onCreate(bundle);
setContentView((int) R.layout.activity_demo);
init();
findViewById(R.id.bn).setOnClickListener(new OnClickListener() {
public void onClick(View view) {
if (Pinoc.onEnterMethod("com/iqiyi/pinocdemo/DemoActivity$1", "onClick", "(Landroid/view/View;)V", this, new Object[]{view}) == Library.NO_RETURN_VALUE) {
Toast.makeText(DemoActivity.this.getApplicationContext(), "Click", 0).show();
}
}
});
}
}
}

可以看到,在app运行时,当一个方法被调用,实际上是调用了原始方法的变种方法,再由变种方法调用原始方法。不过在这之前,变种方法首先将原始方法的调用信息传给Pinoc,Pinoc根据配置文件决定是否替换或修改原始方法,这个配置文件可能是从服务器下发的。

为避免Java类加载器产生的一些麻烦,Pinoc不采用Java的类加载器来执行方法的替换体或者修改体,所以方法的替换体或者修改体不是用Java语言编写的。它们是用Zlang编写的,Zlang是一种运行在Java虚拟机的动态灵活的编程语言,支持Java对象的调用,在运行时可以与Java环境交互。开发者可以轻易地将Java代码转化为Zlang代码。

所以在运行时,如果Pinoc决定修改或替换某个方法,它将方法的替代体或者修改体用Zlang编译器编译成Zlang字节码,传入Zlang执行器执行。这样替换体或者修改体就被执行了,原方法也被替换或修改了。

具体的实现在PinocCore的onEnterMethod()方法中:

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
Object onEnterMethod(String className, String methodName, String methodSignature, Object thiz, Object[] parameters) {
//Logger.i(TAG, "enter " + className + " " + methodName + " " + methodSignature);
// We can *not* append a target to the string because if the target class has override toString, it will cause a stack-over-flow.
ConcurrentHashMap<String, LibraryWrapper> libraries = mLibraries.get(className);
if (libraries == null) {
return Library.NO_RETURN_VALUE;
}
LibraryWrapper wrapper = libraries.get(getMethodId(methodName, methodSignature));
final Library library = wrapper.mLibrary;
if (library == null) {
return Library.NO_RETURN_VALUE;
}
final Object[] objects = new Object[]{className, methodName, methodSignature, thiz, parameters};
Callable callable = new Callable() {
@Override
public Object call() throws Exception {
return library.execute("main", objects);
}
};
Object result = Library.NO_RETURN_VALUE;
try {
switch (wrapper.mMode.getMode()) {
case ThreadMode.CURRENT:
result = callable.call();
break;
case ThreadMode.MAIN:
result = Schedulers.MAIN.call(callable);
break;
case ThreadMode.BACKGROUND:
result = Schedulers.BACKGROUND.call(callable);
break;
}
} catch (ZlangRuntimeException e) {
Logger.e(TAG, "Execution error.", e);
return Library.NO_RETURN_VALUE;
} catch (Throwable t) {
Logger.e(TAG, "Unknown error.", t);
return Library.NO_RETURN_VALUE;
}
return result;
}

这个方法的逻辑非常简单,就是将配置信息与当前方法信息进行对比,如果发现当前方法需要替换,就执行配置文件中的Zlang方法(都是main方法,所以是library.execute(“main”,objects)),同时,对于方法的执行,可以选择在当前线程、主线程和背景线程中执行。

至于其他读取配置信息之类的代码都很简单,就不赘述了,查看Pinoc的更多信息请访问:Pinoc 设计原理

Zlang简介

Zlang是一种运行于JVM上并且能够在运行时访问java对象的动态语言。它有如下特性:

  • 容易学习和使用
  • 动态类型
  • 能够在运行时与java交互,从而提供了无需classloader即可实现热修复的能力
  • 在未来将支持函数式编程
  • 未来将支持面向对象功能(目前跟C语言一样,只支持函数调用)

目前Zlang已经开源,github地址为Zlang

Zlang使用

接入方式

首先是添加gradle依赖:

1
compile 'xiaofei.library:zlang:1.0.0'

然后是构建library,在调用一个Zlang函数之前,需要先构建library:

1
2
3
4
Library library = new Library.Builder()
.addFunctions("function f(a) {return a + 1;} function g(a) {return a + 2;}")
.addFunctions("function h(a) {return a + 3;}")
.build();

之后就可以开始调用了:

1
2
int a = (int) library.execute("f", new Object[]{3});
System.out.println(a);

基本语法

如下是Zlang的Hello World:

1
2
3
function print_hello_world(){
println("Hello World!");
}

可以看到跟java的语法很相似。

如下是一个复杂一点的示例:

1
2
3
4
5
6
7
8
9
10
11
function sum(a, b) {
result = 0;
for i = a to b step 1 {
result = result + i;
}
return result;
}
function print_sum(a, b) {
println(sum(a, b));
}

当然,对于递归也是支持的,如下是计算阶乘的方法:

1
2
3
4
5
6
7
function factorial(n) {
if (n == 0) {
return 1;
} else {
return n * factorial(n - 1);
}
}

如下是一个二维数组的使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function max(arr) {
len1 = _length(arr);
/* Get the minimum integer. */
result = _get_static_field("java.lang.Integer", "MIN_VALUE");
for i = 0 to len1 - 1 step 1 {
len2 = _length(arr[i]);
for j = 0 to len2 - 1 step 1 {
if (arr[i][j] > result) {
result = arr[i][j];
}
}
}
return result;
}

java对象访问

如下是利用Zlang打印当前时间的方法:

1
2
3
4
5
function print_time() {
calendar = _invoke_static_method("java.util.Calendar", "getInstance");
format = _new_instance("java.text.SimpleDateFormat", "yyyy-MM-dd");
println(_invoke_method(format, "format", _invoke_method(calendar, "getTime")));
}

又比如,有如下类定义:

1
2
3
4
5
6
7
8
9
public class Foo {
private int f;
public void fun() {
Library library = new Library.Builder()
.addFunctions(ZlangFunctions.getFunction())
.build();
library.execute("dynamic_fun", new Object[]{this});
}
}

现在,我们可以通过修改dynamic_fun这个Zlang函数来达到动态修改Foo对象的目的:

1
2
3
4
5
function dynamic_fun(this) {
f = _get_field(this, "f");
_set_field(this, "f", f + 1);
println("The value changes from " + f + " to " + (f + 1));
}

这里,其实就是通过Zlang拥有热修复能力的原因。

Zlang编译器设计原理

Zlang编译器简介

在讲Zlang之前,以一个简单的语句为例,看一下普通的编译器的编译流程:

可见,一个典型的编译器需要具备词法分析、语法分析、语义分析、中间代码生成、代码优化以及目标代码生成的功能。

而Zlang说白了就是一个栈指令解释器,在java运行时对Zlang代码进行编译,然后在执行时不断地从栈中取出指令进行执行。

再加上Zlang目前只支持函数(不支持类和对象)调用,并且支持的文法也较简单,所以Zlang的实现也简单得多,把词法分析、语法分析和语义分析都融合在一起实现在Compiler类中,总代码量也就不到800行。

Zlang指令及符号表

它有如下指令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum Fct {
LIT, //常量,或者说字面值
LOD, //加载变量的指令,表示要加载变量到栈顶
ALOD, //将数组中的各元素加载到栈顶
STO, //栈顶值赋值给变量,注意,此时不弹栈顶
ASTO, //将栈顶内容赋值给数组各元素
OPR, //操作符
INT, //活动记录分配栈空间
JMP, //跳转指令
JPF, //如果是false就跳转
JPF_SC, // Short circuit,SC就是short circuit的缩写,表示短路计算
JPT_SC, // Short circuit
FUN, //方法调用
PROC, //产生式
FUN_RETURN, //返回值
VOID_RETURN, //无返回值
}

而符号表如下:

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
enum Symbol {
END,
FUNCTION,
IF,
ELSE,
WHILE,
FOR,
TO,
STEP,
BREAK,
CONTINUE,
RETURN,
ID,
BOOLEAN,
NULL,
NUMBER,
CHARACTER,
STRING,
LESS_EQUAL,
LESS,
GREATER_EQUAL,
GREATER,
EQUAL,
ASSIGN,
NOT_EQUAL,
NOT,
AND,
OR,
COMMA,
SEMICOLON,
LEFT_PARENTHESIS, //左圆括号
RIGHT_PARENTHESIS, //右圆括号
LEFT_BRACE, // {
RIGHT_BRACE,
LEFT_BRACKET, // [
RIGHT_BRACKET,
PLUS,
MINUS,
TIMES, //乘法
DIVIDE, //除法
}

与符号表对应的保留字如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static final HashMap<String, Symbol> RESERVED_WORDS_SYMBOLS = new HashMap<String, Symbol>() {
{
put("END", Symbol.END);
put("function", Symbol.FUNCTION);
put("if", Symbol.IF);
put("else", Symbol.ELSE);
put("while", Symbol.WHILE);
put("for", Symbol.FOR);
put("to", Symbol.TO);
put("step", Symbol.STEP);
put("break", Symbol.BREAK);
put("continue",Symbol.CONTINUE);
put("return", Symbol.RETURN);
}
};

Zlang文法

Zlang目前支持的文法如下:

1
2
3
4
5
6
7
8
9
or_expression = and_exp || and_exp
and_exp = comparison_exp && comparison_exp
comparison_exp = numeric_exp > numeric_exp
numeric_exp = term + term
term = factor * factor

是很典型的上下文无关文法,显然文法支持了或语句、与语句、比较语句、四则运算。

Zlang编译过程分析

其实确定了文法,编译过程的分析就简单了,至少词法分析是有迹可循的。

入口是compile()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void compile() {
program += "END ";
do {
function();
if (nextSymbol == Symbol.END) {
break;
} else if (nextSymbol != Symbol.FUNCTION) {
throw new CompileException(CompileError.MISSING_SYMBOL, linePos == 0 ? lineNumber - 1 : lineNumber, previousLinePos, "function");
}
} while (true);
// library.compileDependencies();
for (FunctionWrapper functionWrapper : neededFunctions) {
if (!library.containsFunction(functionWrapper.functionName, functionWrapper.parameterNumber)) {
throw new CompileException(
CompileError.UNDEFINED_FUNCTION, linePos == 0 ? lineNumber - 1 : lineNumber, previousLinePos,
"Function name: " + functionWrapper.functionName
+ " Parameter number: " + functionWrapper.parameterNumber);
}
}
}

这个方法很简单,就是循环地调用function()来编译Zlang代码中的各个函数,直到遇到结束符。

function()方法则稍微复杂一些,代码如下:

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
private void function() {
breakRecorder.init();
continueRecorder.init();
symbolTable.clear();
codes = new ArrayList<>();
codeIndex = -1;
if (nextSymbol == null) {
moveToNextSymbol(); //获取第一个符号,要求必须是Symbol.FUNCTION
}
if (nextSymbol != Symbol.FUNCTION) { //第一个符号必须是Symbol.FUNCTION,因为目前Zlang只支持函数
throw new CompileException(CompileError.MISSING_SYMBOL, linePos == 0 ? lineNumber - 1 : lineNumber, previousLinePos, "function");
}
moveToNextSymbol(); //获取第二个符号,要求必须是函数名
if (nextSymbol != Symbol.ID) { //第二个符号必须是ID(其实是必须为函数名),注意ID包含函数名,变量名等
throw new CompileException(CompileError.ILLEGAL_SYMBOL, linePos == 0 ? lineNumber - 1 : lineNumber, previousLinePos, "" + nextSymbol);
}
String functionName = (String) nextObject;
moveToNextSymbol();
int parameterNumber = 0;
offset = -1;
if (nextSymbol == Symbol.LEFT_PARENTHESIS) { //函数名之后的下一个符号必须是左括号,如果不是就要抛异常
moveToNextSymbol();
} else {
throw new CompileException(CompileError.MISSING_SYMBOL, linePos == 0 ? lineNumber - 1 : lineNumber, previousLinePos, "(");
}
while (nextSymbol != Symbol.RIGHT_PARENTHESIS) { //函数名-->左括号-->参数名或右括号,所以如果不是右括号的话就必须是变量定义
if (nextSymbol != Symbol.ID) {
throw new CompileException(CompileError.ILLEGAL_SYMBOL, linePos == 0 ? lineNumber - 1 : lineNumber, previousLinePos, "" + nextSymbol);
}
String id = (String) nextObject;
++parameterNumber;
++offset;
symbolTable.put(id, offset); //将这个参数放入符号表,比如参数a
moveToNextSymbol();
if (nextSymbol != Symbol.RIGHT_PARENTHESIS && nextSymbol != Symbol.COMMA) { //下一个符号只能是逗号,右括号或参数名中的一个,如果都不是就抛出异常
throw new CompileException(CompileError.MISSING_SYMBOL, linePos == 0 ? lineNumber - 1 : lineNumber, previousLinePos, ") or ,");
}
if (nextSymbol == Symbol.COMMA) { //如果是逗号,就读取下一个符号(其实是下一个参数)
moveToNextSymbol();
}
}
moveToNextSymbol(); //读取大括号
generateCode(Fct.INT, 0); //函数开始处,操作数为0
int tmp = codeIndex;
statement(false);
generateCode(Fct.VOID_RETURN, 0); //函数执行完,操作数归0
modifyCodeOperand(tmp, offset + 1);
library.put(functionName, parameterNumber, codes);
}

结合我在代码中的注释,其实就很好理解了,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)编译器。