聊聊热更新

Posted by wbq on June 21, 2021

想写这篇文章挺久了,今天我们来聊一聊热更新。

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)
  },
})
  1. 这里为什么对UIViewController要用require,是因为在下面的handleBtn这个方法中使用了这个类,JSPatch.js中的require方法在当前全局中保存了UIViewContrller这个对象,防止在进行UIViewController.alloc().init()方法的链式调用中不会出现UIViewContrller is not defined的报错。

  2. JSPatch.jsdefineClass = 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对象包含所有的方法入参和方法签名,非常适合在这里进行方法的调用。

  3. 当然UIViewController.alloc().init()类似的语句是做不到直接调用的,因为js中压根没有这些方法,作者博客也有讲到,所以另辟蹊径。在这一步,JSPatch.js会通过正则对热更脚本进行一轮胶水代码的添加,在解析阶段,上述这句话会变成UIViewController.__c("alloc")().__c("init")(),这样通过一个__c的统一的胶水函数,就能非常方便地把诸如allocinit方法交还给native进行调用了。

以上就是大致的执行流程了,当前其中还有很多复杂的细节,比如返回值的处理,上面只是讲到无返回值类型最简单的一种。各种参数类型的处理,各种语法的处理比如block,结构体,用libffi处理C函数等等。

前段时间,在重读源码的过程中,逮住了一只野生的bang大佬,问了几个细节问题,收到了解答也是非常的开心。

0x34 小结

总的来说JSPatch是一个非常优秀的热更框架,作者也是一步步精益求精,满足了广大开发者的各种诉求。思路和实现都非常值得阅读和学习,目前github还在维护的热更框架采用JS作为脚本语言的基本都是学习了JSPatch思路,例如这个TTPatch(真没看出啥大区别…)。当然此类的解决方案虽然还是有过审的可能,但是大家渐渐地开始不太敢用了,毕竟被下架付出的成本远远比线上bug的成本高多了,用JSJSContext进行动态化太容易被机审扫到了。

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负责词法的分析,将脚本切割成一个个tokenyacc负责解析语法分析,将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非常像,但是又有一些不一样。可以看到,该脚本目的是改掉原始ViewControllersequentialStatementExample方法。

1.Lex 部分:首先通过正则把关键字匹配出来,比如classViewController: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.总结

热更经过这么多年的发展,方案五花八门,但以上讲到的两种方案是最具代表性的,只要将热更做到安全可控,我相信热更还是有未来的,篇幅有限,先到这里。你知道的越多,你不知道的越多。

0x06.参考

bang590/JSPatch

YPLiang19/Mango