想写这篇文章挺久了,今天我们来聊一聊热更新。
0x01.前言
国内开发者对于热更新不可谓不热衷,前仆后继地发明一个又一个新思路,我自己也是对热更新特别感兴趣,尤其对iOS来说,这更是一片敏感的灰色地带,自从jspatch
被苹果警告之后,各家公司的热更需求依然没有减弱,因此这个话题变得相当微妙,各家公司都有一些自己的方案,并且不再开源,因为开源意味着会像jspatch
一样变成把子,业界最著名的DynamicCocoa就是一个非常典型的例子,时至今日,再去打开这个仓库的issue
依然会让你捧腹大笑。
0x02.演进过程
其实一路走来我对热更新演进过程的总结就是两个字 —— 内卷
,虽然这两个戏谑的字不是那么合适,但事实就是如此,热更的方案变得更加多元化,防审核能力也变得更强。开发大佬们也已经不再局限于现成的脚本语言,转而自己去开发新的脚本语言。
众所周知一个apple应用是静态编译成二进制文件的(AOT),在线上去修改此类文件是不现实的,因此就需要用到脚本语言(JIT)+ 动态化语言的能力,这就是热更的本质。几乎所有热更的流程基本都符合下面这张流程图的逻辑(ReactNative
和Weex
等跨平台方案今天不在我们的讨论范围内,他们确实有具备热更的能力,但我认为他们不够trick
。时至今日,此类的跨平台框架基本已经被apple认可,因为他们已经是非常成熟的框架,风险也相对可控)
接下来,我会拿两个有代表性的方案,进行一些技术细节的阐述。
0x03. JSPatch
第一个例子拿JSPatch
我觉得是毫无疑问的,国内目前最有影响力的框架我依然觉得是JSPatch
,作者也一直是我的偶像,blog的文章我也经常拜读。作者将JS
这种现成的语言作为热更脚本,再加上平台提供的JScontext
能够快速地进行与native的通信。站在巨人的肩膀上决定了JSPatch
从开始就是一个轻量高性能的热更框架。下面来简单说说JSPatch
的执行逻辑和原理。
0x031.JSContext
JSContext
, 一个JS
执行环境的对象,在一个JSContext
对象创建时,会在其中内置一个JSVirtualMachine(JS虚拟机)
,你可以非常方便快速地在native执行一段JS或者进行两种语言的互通,在这之中你不需要依赖任何WebView(就像node.js一样提供一个node环境)。就像下面这样:
1
2
3
4
5
JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"var name = 'wbq'; var age = '18'; function addFunc(value1, value2){ return value1 + value2};"];
JSValue *value = [context[@"addFunc"] callWithArguments:@[@12, @323]];
NSLog(@"%@", value.toString);
每个JSContext
对象默认是隔离的,也就是他们都是单独的环境,彼此之前不能传递信息,当然你也可以通过:- (instancetype)initWithVirtualMachine:(JSVirtualMachine *)virtualMachine;
来创建多个JSContext
对象来共享一个虚拟机,这里不展开。
0x032.引擎启动
引擎启动之后,框架会通过上述的JSContext
执行一个叫作JSPatch.js的文件,这个文件是框架的重点之一,我把他归结为两个功能:1.收集并解析热更脚本的数据。2.将解析完的数据交还给Native处理。
0x033.解析脚本/方法替换
拉取脚本我觉得没什么好说的,下面讲讲脚本解析,挑demo中最简单的一个例子来讲
1
2
3
4
5
6
7
8
9
require('UIViewController');
defineClass('JPViewController', {
handleBtn: function(sender) {
console.log(this);
var tableViewCtrl = UIViewController.alloc().init()
self.navigationController().pushViewController_animated(tableViewCtrl, YES)
},
})
-
这里为什么对
UIViewController
要用require
,是因为在下面的handleBtn
这个方法中使用了这个类,JSPatch.js中的require
方法在当前全局中保存了UIViewContrller
这个对象,防止在进行UIViewController.alloc().init()
方法的链式调用中不会出现UIViewContrller is not defined
的报错。 -
JSPatch.js的
defineClass = function(declaration, properties, instMethods, clsMethods){...}
方法传入的是类的声明,属性、实例方法、类方法。而例子中第二个参数传入是一个实例方法的map,是因为作者在其中发现第二个参数不是数组的情况下,就会把第二个参数当作实例方法往前提。之后会把解析完的脚本数据通过native早就准备好的方法_OC_defineClass
,进行处理:1 2 3
context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) { return defineClass(classDeclaration, instanceMethods, classMethods); };
其实OC的
defineClass
方法内部的细节很多,当然本质就是进行方法替换,作者先将将方法转发的方法- (void)forwardInvocation:(NSInvocation *)anInvocation
进行了替换,再将热更脚本收集的方法全部挂到替换的方法转发方法
上(第一次会有点饶,可以多看几遍源码),NSInvocation
对象包含所有的方法入参和方法签名,非常适合在这里进行方法的调用。 -
当然
UIViewController.alloc().init()
类似的语句是做不到直接调用的,因为js中压根没有这些方法,作者博客也有讲到,所以另辟蹊径。在这一步,JSPatch.js会通过正则
对热更脚本进行一轮胶水代码的添加,在解析阶段,上述这句话会变成UIViewController.__c("alloc")().__c("init")()
,这样通过一个__c
的统一的胶水函数,就能非常方便地把诸如alloc
、init
方法交还给native
进行调用了。
以上就是大致的执行流程了,当前其中还有很多复杂的细节,比如返回值的处理,上面只是讲到无返回值类型最简单的一种。各种参数类型的处理,各种语法的处理比如block,结构体,用libffi处理C函数等等。
前段时间,在重读源码的过程中,逮住了一只野生的bang大佬,问了几个细节问题,收到了解答也是非常的开心。
0x34 小结
总的来说JSPatch
是一个非常优秀的热更框架,作者也是一步步精益求精,满足了广大开发者的各种诉求。思路和实现都非常值得阅读和学习,目前github还在维护的热更框架采用JS
作为脚本语言的基本都是学习了JSPatch
思路,例如这个TTPatch(真没看出啥大区别…)。当然此类的解决方案虽然还是有过审的可能,但是大家渐渐地开始不太敢用了,毕竟被下架付出的成本远远比线上bug的成本高多了,用JS
和JSContext
进行动态化太容易被机审扫到了。
0x04.Mango
第二个为什么选这个框架呢,因为第一次看到这个框架的时候,还是比较眼前一亮的,这个框架就是我所说的自制语言和自制编译器/解释器的代表型框架。具体可以看作者原理与使用介绍MangoFix:iOS热修复另辟蹊径,总的来说,开发者可以使用一种以.mg
为后缀的脚本文件,文件内容类似OC的语法,所以可以很快上手,之后同样对脚本进行解析,不同于JSPatch
的方法转发,Mango是直接通过libffi
进行了方法替换。在研究框架的过程中,我发现如此优秀的轮子,网上关于的源码阅读和解释的文章缺很少,所以今天我想特别来说说。
![img](https://upload-images.jianshu.io/upload_images/1709476-16c9960a772355e4.png?imageMogr2/auto-orient/strip | imageView2/2/w/810/format/webp) |
原理部分,我照搬了作者的原图。接下来具体来说说技术的实现点。
0x041.Lex&&Yacc
作者在脚本的解析中用到了该工具,Lex&&Yacc
可以让你轻易的解析复杂的语言,当你需要读取一个配置文件时,或者你需要编写一个你自己使用的语言的编译器时,你不用手工写解析器,他可以直接帮你做到你想要的事。lex
负责词法的分析,将脚本切割成一个个token
,yacc
负责解析语法分析,将token
按照你写的规则生成抽象语法树。注意 Lex 和 Yacc 都是基于正则表达,后面讲到。
0x042.BNF
什么是BNF?总的来说BNF是一个描述语法规则的语言
。具体可以我上一篇编译原理(1)简单地介绍了一下BNF,不是啥新东西,Yacc
同样可以通过BNF提取语法规则。
0x043.脚本解析
掌握了以上几个知识点,去理解原理也就变得不那么难了,还是找一个最简单的例子来聊聊。
1
2
3
4
5
6
class ViewController:UIViewController {
- (void)sequentialStatementExample{
NSString *text = @"1";
self.resultView.text = text;
}
}
上面是一个mg文件,可以看到和OC非常像,但是又有一些不一样。可以看到,该脚本目的是改掉原始ViewController
的sequentialStatementExample
方法。
1.Lex
部分:首先通过正则把关键字匹配出来,比如class
、ViewController
、:
、void
、@
、"1"
一个个切出来,就是上述的token
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<INITIAL>"void" {return VOID; } //void关键字
<INITIAL>"class" {return CLASS; } //class关键字
<INITIAL>[A-Za-z_$][A-Za-z_$0-9]* {
NSString *identifier = mf_create_identifier(yytext);
yylval.identifier = (__bridge_retained void *)identifier;
return IDENTIFIER;
} //标识符 例如ViewController、UIViewController
<INITIAL>":" {return COLON; } //分号
<INITIAL>\" {
mf_open_string_literal_buf();
BEGIN STRING_LITERAL_STATE;
} //第一次匹配到'"'开始进入字符串收集状态
<STRING_LITERAL_STATE>. {
mf_append_string_literal(yytext[0]);
} //匹配换行符之外的任意字符,字符串进行拼接
<STRING_LITERAL_STATE>\" {
MFExpression *expression = mf_create_expression(MF_STRING_EXPRESSION);
expression.cstringValue = mf_end_string_literal();
yylval.expression = (__bridge_retained void *)expression;
BEGIN INITIAL;
return STRING_LITERAL;
} //在字符串状态匹配到'"',意味字符串匹配结束,继续进入首字符匹配模式
....
yylval
其中保存着相关的信息,这个信息就是在词法分析文件中(lex)进行设置的,而在语法分析文件中(yacc)就直接采用了
yytext
是指向所匹配的字符串的指针(以 NULL 结尾),yytext[0]
就是当前首字符,而 yyleng
是这个字符串的长度。
2.Yacc
部分刚开始啃确实会有一点难理解,先来个简单的,如何匹配一个实例方法
1
2
3
4
5
6
7
8
9
10
instance_method_definition: annotation_if SUB LP type_specifier RP method_name block_statement
{
MFExpression *annotaionIfConditionExpr = (__bridge_transfer MFExpression *)$1;
MFTypeSpecifier *returnTypeSpecifier = (__bridge_transfer MFTypeSpecifier *)$4;
NSArray *items = (__bridge_transfer NSArray *)$6;
MFBlockBody *block = (__bridge_transfer MFBlockBody *)$7;
MFMethodDefinition *methodDefinition = mf_create_method_definition(annotaionIfConditionExpr, NO, returnTypeSpecifier, items, block);
$$ = (__bridge_retained void *)methodDefinition;
}
;
annotation_if 可以是empty,SUB = ‘-‘, LP = ‘{‘ ,type_specifier = ‘返回类型’ ,RP = ‘}’ ,method_name = ‘函数名’ ,block_statement = ‘函数体’ ,这样就可以通过instance_method_definition
匹配出一个实例方法了。但真正能把上述这个简单的脚本匹配出来需要还经过层层匹配,我稍微总结了一下:
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
member_definition_list : member_definition | member_definition_list member_definition;
member_definition : property_definition | method_definition
method_definition : instance_method_definition | class_method_definition;
instance_method_definition : annotation_if "-" "(" type_specifier ")" method_name block_statement
type_specifier : void | BOOL | Class | id | ...
block_statement : "{" statement_list "}"
statement_list : statement | statement_list statement
statement : declaration_statement | if_statement | switch_statement | for_statement | foreach_statement | while_statement | do_while_statement | break_statement | continue_statement | return_statement | expression_statement
expression_statement : expression ";"
expression : assign_expression
assign_expression : ternary_operator_expression | primary_expression assignment_operator ternary_operator_expression
ternary_operator_expression : logic_or_expression | logic_or_expression "?" ternary_operator_expression ":" ternary_operator_expression |
logic_or_expression "?" ":" ternary_operator_expression
logic_or_expression : logic_and_expression | logic_or_expression "||" logic_and_expression
logic_and_expression : equality_expression | logic_and_expression "&&" equality_expression
equality_expression : relational_expression | equality_expression "==" relational_expression | equality_expression "!=" relational_expression
relational_expression: additive_expression | relational_expression "<"| "<=" | ">" | ">=" additive_expression
additive_expression : multiplication_expression | additive_expression "+" multiplication_expression | additive_expression "-" multiplication_expression
multiplication_expression : unary_expression | multiplication_expression "*" unary_expression
unary_expression : postfix_expression | "!" unary_expression | "-" unary_expression
postfix_expression : primary_expression | primary_expression "++" | primary_expression "--"
primary_expression : IDENTIFIER | "&" IDENTIFIER | primary_expression "." IDENTIFIER | primary_expression "." key_work_identifier | primary_expression "." selector "()" | primary_expression "." selector "(" expression_list ")"
| primary_expression "(" ")" | ...
就一个简单的实例方法需要经过这么多规则的筛选,不得不佩服作者细腻的逻辑能力,其实这之中也有非常多的细节,你要做很多二义性的判断,各种特殊情况的处理,我觉得这个工作量绝对是不小的。
话说回来光匹配是不够的,在匹配过程中,还需要生成AST(抽象语法树)。比如$1、$2
这类就是获取匹配出来的yylval.expression
,数字代表顺序代表匹配的token顺序,例如上述MFTypeSpecifier *returnTypeSpecifier = (__bridge_transfer MFTypeSpecifier *)$4;
就是把第四个token,方法的返回类型,用returnTypeSpecifier
接收。又比如$$
就代表把收集的语义封装的对象继续向上传递。
PS:我自己有个疑问,yacc在层层向上匹配的过程中,如果发现自己匹配错了会怎么样呢?是回溯吗?,还是在匹配过程中,他已经已经知道自己要走哪条路了?
解析完之后,会得到一个个的AST的对象保存在内存中,比如方法对象就是保存在NSMutableDictionary当中。之后通过libffi进行方法替换。
0x044.方法执行
方法执行的逻辑也挺复杂,不断地计算AST的节点。各个语法树节点对象的类都需要具备类型(赋值、if语句、for循环、switch等等条件都会进到不一样的执行逻辑当中)和执行的表达式。eval方法将计算与以该节点为根的子树对应的语句、表达式及子表达式,并返回执行结果。一直执行,执行到根节点为止。作者在这一块可以说实现了一整个解释器/虚拟机。
0x045.小结
其实这个框架让我佩服的点是他完成了自制语言整个编译+解释的过程,虽不知道作者是何许人也,但是大佬的底层功力不可谓不深厚。尤其是编译原理,搞得相当的明白,我这种入门级小白只能顶礼膜拜。业界也有在大佬基础上进行更进一步探索的比如:OCRunner,和该作者简单的聊了一下,作者本身只想做一个将OC语言翻译成mg文件的工具,最后干脆自己直接实现了从OC到AST的过程,我认为这个方向也很不错,毕竟开发者上手一门新的脚本语言,还是需要学习成本的。但是作者也表示,用Lex&&Yacc
来作为OC的翻译工具还是有很多坑,比如识别*
——NSString *a 和 a * b
具有二义性的需要特殊处理, 也处理不了头文件的展开。作者最后说如果有机会可能会直接上clang,我:…………………..
0x05.总结
热更经过这么多年的发展,方案五花八门,但以上讲到的两种方案是最具代表性的,只要将热更做到安全可控,我相信热更还是有未来的,篇幅有限,先到这里。你知道的越多,你不知道的越多。