https://juejin.im/post/6884833291740905480?utm_source=gold_browser_extension
I. 方案简介
OCPack
是一种 iOS 平台上 App 动态化技术方案,用户可以使用 Objective-C 语言编写待动态化的功能逻辑(生成.m
文件),然后通过OCPack
提供的工具链生成 patch 文件(.bin
格式)。客户端则内置了一个基于 Native 环境的的虚拟栈机,它可以动态加载并执行存储在客户端的 patch 文件中的方法。Patch 文件可根据业务需要随时下载、更新并由虚拟机重新加载、运行。
此方案的主要优点:
-
开发效率
可以使开发者像写普通功能代码一样,使用熟悉的 Xcode IDE 和 Objective-C 语言进行开发、调试,在开发完成后使用工具链即可方便地生成 patch 文件,提高开发 patch 的效率。
-
语法覆盖
考虑到使用者的方便性和开发周期的平衡,目前
OCPack
的实现覆盖了c
语言的基本语法和 Objective-C 中比较常用的语法,保证开发者在使用中大部分常用的写法都能直接支持,而部分不能支持的语法也有相应的替代实现方式。 -
问题定位
对于暂不支持的语法,
OCPack
的工具链能够明确地给出错误原因提示及错误代码位置,方便定位开发中遇到的问题。上线后也可以调用相应接口获取虚拟机在各线程的调用栈信息,结合编译过程中生成的符号文件,开发就能够准确定位到当时虚拟机调用到的源文件代码行数,方便定位、解决线上问题。符号解析工具也包含在工具链中。
-
性能
由于是基于 Native 环境,且自定义的栈机指令集意义明确、设计精简,并且大部分与 Native 环境之间的交互都是直接操作内存地址,省去了像 JSPatch 一样频繁字符串解析及 Box/Unbox 的开销。其中 OC 桥接调用效率近似原生。
-
内存及稳定性
JavaScript 的 GC 的内存管理机制会导致内存不能及时释放,而如果强行释放掉 JSContext 会导致线上出现一些诡异的崩溃,难以定位和解决。
此方案支持 ARC 内存管理,能够与客户端的 ARC/MRC 内存管理机制正确配合,消除了GC时机不可控的问题。
而且结合项目本身的特点,将主要使用到的内存都放到文件映射(
mmap
)中,尽量不占用应用程序的内存配额,提高应用的稳定性。
II. 技术方案
OCPack
复用 clang 前端来分析目标 Objective-C 代码的语法树,通过自定义 ASTFrontendAction 来遍历语法树,生成自定义指令集的汇编程序。在客户端,由自研的虚拟栈机来解释执行汇编程序中的二进制指令。
生成 patch 文件的基本数据流程是:
Objective-C 源码会首先通过
OCPack
编译模块转换为自定义指令集的汇编程序(.s)。此过程主要是通过解析 LLVM 生成的 Objective-C 代码语法树(AST)实现的。汇编程序(.s)随后会通过一个自研汇编工具(smc)被转换为二进制的 patch 文件(.bin)。
注:
OCPack
自定义的栈机汇编指令主要有67
条,除基本指令以外,主要是依据语法树结点类型设计。
在运行时,客户端内置的虚拟栈机能够根据用户需求加载指定的 patch 文件,然后就可以执行其中任意方法了。
以下分模块来介绍主要技术点:
编译模块:
功能:Objective-C程序(.m
) -> 语法树 -> 汇编程序(.s
)
1. 独立的编译程序
主要使用 clang 的 libTooling 接口,实现了 AST FrontendAction,通过实现自定义的 ASTConsumer 递归遍历语法树,对不同的节点类型作相应处理,生成可执行的汇编指令程序。
-
编译选项
要调用 clang 的模块为目标 Objective-C 源文件(.m)生成语法树,需要先提供编译此 .m 所需的编译选项。对于集成方来说,目标文件可能需要依赖很多相关的.h或有其他编译开关,为了方便集成方得到完整的编译选项列表,我们制作了相应的工具,可以方便地从集成方工程的编译日志中得到目标文件所需的编译选项的完整列表。
-
编译错误和警告
OCPack
支持 Objective-C 语言中常用的语法类型,对于不支持的语法,在编译期间会生成相应的日志文件,具体标明了错误类型和错误位置 ,方便开发定位问题。
注:为了进一步提高开发效率,
OCPack
还实现了一个独立的 clang plugin,可以通过给工程添加一个 .xcconfig 文件(替换默认的 clang),实现在 Xcode 中显示相关的编译错误,并能一键生成 .bin文件,省去了获取编译选项和手工查看错误日志的步骤,简化了开发流程。
2. 栈机汇编指令集
为了连接包含有 Objective-C 代码逻辑的语法树和客户端运行的虚拟机,OCPack
需要定义一套比较完整的汇编指令集。该指令集应该满足以下两个条件:
提供足够的功能支持,用以实现预定义的 Objective-C 语法范围。具体地,对于指定的语法树节点类型集合,能够通过编译逻辑生成相应的汇编指令组合,等价地完成原 Objective-C 代码所要实现的逻辑功能。
尽量减少指令集的复杂性:一方面应尽量减少指令的条数,以降低虚拟机实现的复杂性;另一方面应尽量降低单条指令本身的语义复杂性,每条指令应完成明确而有限的功能。
以下简要介绍几个比较典型的指令的设计方案:
2.1 push 和 pop 指令
栈机中最基本的部件是操作栈,用于存放正在进行中的操作数和操作结果。如:要计算 1 + 2,栈机需要执行类似以下指令:
push instant 1
push instant 2
add
复制代码
先将两个操作数1
和2
依次 push 进操作栈,再执行 add 操作。add 操作负责先 pop 对应的操作数,经过加法计算后再将结果 push 进栈。以上指令执行完后,操作栈顶存放的就是操作结果3
。
但只有操作栈是不够的,程序逻辑的复杂性要求像局部变量、方法参数等数据拥有确定不变的内存地址,因此OCPack
将局部变量、静态变量、常量、指针、立即数等分别对应一个段,每种类型的变量对应于所属段中的一个序号(index)。
-
段:用于存放各种非临时数据(可取到地址的数据),这些数据又分线程相关与线程无关,其中:
线程相关的数据段主要包括:local //局部变量 arg //方法实参 this //存放self(用于super的实现) that //用于实现struct的成员变量 pointer //用于辅助实现this, that //注: 线程相关的段数据存放在thread_context(线程局部存储)中,只对本线程可见 复制代码
线程无关的数据段主要包括:
const //常量字符串 static //静态变量 instant //立即数 //注:线程无关的段数据存放在machine中,各线程都可见 复制代码
在对语法树进行遍历的过程中,OCPack
编译器会维护一个符号表,对每个变量声明(VarDecl)建立相应的符号表项,存放其段名和序号(index)。
对语法树中的变量引用(VarDeclRef 结点),OCPack
编译器会找到其相应的 VarDecl 的符号表项,生成相应的 push、pop 指令。
push和pop指令的参数就是段名和序号(index):
-
push
segment
index
—— 将 segment 段中的 index 处数据 push 到操作栈顶 -
pop
segment
index
—— 将操作栈顶的数据 pop 到 segment 段中的 index 处
2.2 prolog 指令
prolog 指令是每个虚拟机中方法的第一条指令,它会根据其指令参数为当前方法栈帧中的local段开辟相应大小的段空间,并记录当前栈帧的返回地址,然后计算并记录参数列表(arg 段)起始地址,再将调用者的栈顶指针指向参数表之前,最后切换到被调用者的栈帧。
-
格式:prolog
arg_size
local_size
其中 arg_size 表示所有参数的总长度,用于计算参数列表的起始地址
local_size 表示局部变量段的长度
方法调用和传参这块的设计需要一些特别的考虑,主要需要满足几个要求:
调用者只需将参数和返回值按要求 push 到操作栈上,然后直接跳转到被调方法的起始地址,程序就可顺利执行,调用方不应承担其他不必要的责任
根据栈机的一般调用逻辑,被调函数返回时,刚才push进来的参数和返回值应该已由被调者pop,此时栈顶应该只有返回值,栈顶以下应该是跟这次调用无关的其他数据
被调方需要知道参数和返回地址的具体位置,参数需要有固定地址,支持随机访问,不能是只靠 pop 得到的暂存值
局部变量也需要随机访问,其大小需要在函数执行一开始就分配好
为了满足这些需求,OCPack
中设计了 prolog 这一指令:
在每个方法头部加此指令,调用者一跳到当前方法就执行此指令,相关设置都在此指令中执行,尽量减少对调用者的要求。
根据约定,执行到 prolog 时,栈顶存放的是返回地址,栈顶以下是倒排的参数表, prolog 指令先 pop 返回地址并保存下来,然后将调用者的栈桢的 sp 往回调整参数表的长度(此长度作为指令参数由编译时确定,调用者无需通过栈来传递此信息),也即指向第一个参数的位置。注意此时还没有生成被调用者的栈桢,所有的操作都还在调用者的栈桢上下文内。这样就能够保证被调者返回时调用者的 sp 是在合适的位置,到时候直接 push 返回值就可以。
此时 prolog 根据返回地址及调用者的栈桢信息生成新栈桢,新栈帧中建立的 arg 段直接指向参数表的起始位置,之后访问参数即可使用 push arg i 或 pop arg i 等指令。
同时,局部变量的 local 段也需要建立,其大小也由编译时确定,即是 prolog 指令的 local_size 参数,在建好栈桢并切换当前栈帧后,即完成了方法调用的过渡阶段,程序流程便可继续进行。
2.3 ret 指令
ret 指令是虚拟机中方法的最后一条指令,与 prolog 相对应,用于回退栈帧(unwind frame),并将返回值数据由被调方的栈顶拷贝到 unwind 以后调用方的操作栈顶,以实现调用完成后返回值位于当前栈顶的调用约定。
格式:ret
retSize
此处有一次数据拷贝,拷贝大小即为返回值的大小。为尽量减少对调用者的影响,在编译期给 ret 方法增加 retSize 参数,以便在执行 ret 的时候就能完成数据拷贝,栈帧回退到调用者后,调用者可以预期返回值就在自己操作栈的栈顶,后续逻辑不受当前栈顶值是由方法调用返回还是自行 push 得到的影响,逻辑较清晰。
2.4 跳转指令
为了实现条件判断 if/else 和 for 循环等流程控制语法,OCPack
指令集定义了jmp
和jmp_if
指令,根据语法树中对应类型的节点具体情况,生成相应的跳转指令和跳转 label。这些文本跳转 label 会被存储在 .s 文件中,然后在下一阶段(Assembler 将 .s 转换为 .bin时)被替换成相应的偏移地址。
2.5 switch指令
1) switch跳转表
switch需要运行时决定跳到哪个 case label 对应的地址,只用jmp_if
需要在 case列表代码尾部插入多条比较语句,而栈机又需要每次比较前都push相应的参数,实现比较繁琐而且性能较差,因此OCPack
在指令集中增加了cmp_n
、resolve_label
和jmp_tos
指令。
首先,
OCPack
编译器在生成指令时会先将 switch 要比较的目标 push 进操作栈,然后再将各个 case 的值 push 进栈,然后添加cmp_n n
指令。在运行时,cmp_n n
指令会从栈上 pop 出n
个数据,与栈顶的数据(即 switch 的目标)进行比较,如果与第i
个相等,则将i
push到栈顶。后面再添加指令
resolve_label label_prefix
。此指令在运行时会将 label_prefix 与栈顶的i
进行字符串拼接,生成目标 label 名,并在 machine 中进行查找,找到对应的 label 地址,push 到栈顶。其中 label_prefix 是每个 switch 语句唯一的,可以支持 switch 嵌套。然后再添加指令
jmp_tos
。此指令在运行时会跳转到栈顶的地址,从而实现 switch 的功能。
2) continue和break的支持:
分别维护一个 break和 continue 的 label 栈,栈顶元素为当前 break 或 continue 调用时应该 jmp 到的目标 label,在目标表达式开始和结束时进行入栈和出栈操作。在遇到语法树上结点为 break 或 continue 时,取出当前栈顶的目标 label,生成jmp 目标label
指令。
2.6 call指令
使用 libffi 实现动态 c 方法调用。对于每个被调用的 c 方法,在 .s 会有一项DECL_C_FUNC 的声明,声明包含此方法的名称、签名(包括参数个数和类型)等信息。
.s 中的参数类型是
OCPack
自定义的字符串,一一对应到 libffi 的类型。对于 struct 来说,生成指令时需要递归找到 struct 所有成员的类型,拼成相应的字符串,然后在运行期反解字符串,构造出 libffi 所需的数据类型。对于变参的方法,方法名相同而参数个数或类型不同的,在 DECL_C_FUNC 时会对应不同的条目,虚拟机在运行时会根据对应的条目去构造相应的 libffi 参数数据。
2.7 基本一元、二元运算符指令
指令集中对算术、逻辑、移位等等基本运算符都有对应的指令,指令参数包括返回值类型、操作数类型等。
在虚拟机的实现代码中将各种运算、数据类型的组合分配到相应的c语言实现,运行时就根据传入的指令和参数调用其相应实现。
注: 此指令只支持整型、浮点型等基本数据类型的运算,不支持自定义类型重载的运算符
2.8 左右值转换
指令集有左值转右值的指令,其参数为右值的 size。此指令的作用为:先 pop 操作栈顶存储的地址(addr),然后取地址为 addr 的大小为 size 的内存数据,push 到操作栈顶。
在 clang 生成的 AST 中,所有 VarDeclRef 其实对应的是变量的地址,对于访问变量内容(变量右值)的代码,AST 中 VarDeclRef 的父节点都是左右值转换节点。因此
OCPack
中 push 指令,类似push seg index
都是将 seg 段 index 处的地址 push 进操作栈,而取对应地址处的具体内容由左右值转换指令来完成。
注: 在实现初期,
OCPack
的 push 指令是直接将 seg 段 index 处变量的右值 push 进操作栈(即这种情况下忽略左右值转换的结点),但后来发现在类似赋值操作中的左值变量的情况下,AST 中没有左右值转换结点,如果对这些情况特殊处理,逻辑会变得较为复杂且难以保证覆盖完全,后来决定完全依照 AST 中结点的排布逻辑,将 push 操作的对象改成了对应变量的左值,牺牲部分性能换取程序的可靠性。
2.9 Objective-C 方法调用指令
指令集中有专用于调用 Objective-C 方法的指令 OBJC_MSG_CLASS/OBJC_MSG_INST。
虚拟机在解释执行此指令时,先取得存储在栈上的所有参数,然后构造相应的 NSInvocation,通过 invoke 来实现对 Objective-C runtime 的调用。
指令实现中,对于 target 和参数都采用 __unsafe_unretained 方式进行引用,即不改变其生命周期。返回值则一律使用 autorelease 方式,确保返回值在返回给直接调用者时是有效的。
注: 实现过程中,Objective-C 调用指令所需的输入数据的内存排布顺序也经历了一番修改。因为对于 Objective-C 方法来说,只有拿到 selector 才能知道具体有多少个参数,所以之前设计是参数表倒着放,即第一个参数放栈顶,第二个参数依次往下排。这样可以稳定地 pop 两次就得到 selector 的声明,然后再根据 selector 中指明的参数个数及大小 pop 所有的参数。但这种方法在参数大小大于 64 bit 的情况下(如 struct)就比较难处理了,因为要得到正确的 struct 数据,程序需要 pop 对应个数的64 bit,然后做拼接,烦琐而且容易出错。经权衡,还是在指令参数中增加了参数表长度(编译期得到),在调用 OBJC_MSG_CLASS/OBJC_MSG_INST 指令前,参数还是按顺序push(即第一个参数先 push,栈顶是最后一个参数),在指令的实现中,根据指令参数中提供的参数表长度,直接从 sp 算出第一个参数的起始位置,这样所有的参数都可以用指针访问,而不用关心其大小了。原先需要的多次 pop 指令,变成只需在指令退出前,将 sp 回退参数表长度即可。
汇编模块
功能:汇编程序(.s) -> 二进制补丁程序(.bin)
解析整个 .s 文本,将文本 token 转换为对应的二进制数据,主要包括:
立即数从文本到二进制数据的转换
跳转 label 到地址的转换
常量字符串的转换,此处一开始直接在 .s 中存储了字符串,后来遇到 '\n\t' 等情况不能很好地支持,就改成了直接存储字节码
生成导出函数表,表中记录了虚拟机中定义的方法名和地址的对应关系
生成导入函数表,包含了所有调用到的c方法声明及其 index,代码段中调用 c 方法时直接调用此处的 index
static 数据段大小和全局区的总大小,因为虚拟机初始化时需要将全局区放到一段 shared & anonymous mmap 内存上,故需要此 size
存储 GUID 值
存储 Target arch,此值用于验证 32bit 和 64bit,确保平台和 .bin 文件的匹配
文本指令转化为二进制指令
转换完成后将各数据存入内存中相应的数据段,再将整个内存 dump 成一个二进制文件。
注: 二进程文件在运行时所需的大部分数据其排布都与 .bin 文件里的排布完全相同,这样能方便地使用内存映射来实现 .bin 文件的加载,从而可以减少私有内存的占用量。
加载模块
功能:二进制补丁程序(.bin)加载
在调用 load_image 时,虚拟机会先将 .bin 文件 mmap 到一段内存中,检测 magic number, bin version 及 arch 是否匹配。
然后按全局区的大小申请一段 shared anonymous mmap 内存。
然后分别加载各个数据段,建立必要的运行时内存数据,主要的数据段包括:
常量字符串段,将全局区对应大小的内存分配给常量段,并将对应的 index 指向对应的字符串起始地址
静态数据段,将全局区对应大小的内存分配给静态段
导出符号表
导入符号表
代码段
GUID数据
注:
bin
文件具体格式如下: [图片上传中...(image-1b9c27-1603349931409-1)]
执行模块
功能:二进制补丁程序(.bin)的执行
1) 虚拟机基本信息
栈和各个段都以 64bit 为单位
调用方法前,需要将对应的参数 push 到操作栈上
调用完成后,栈顶放的就是返回值
-
运行时上下文(thread_context)和栈帧
thread_context 维护一个栈帧的链表,用以存放调用关系
-
栈帧用于存放当前方法的运行时信息,主要包括:
- 栈帧基地址
- 返回地址
- 指向调用方栈帧基地址的指针
- 段表基地址
- 操作栈基址
- 操作栈指针 sp
- 程序计数器 ip
注:VM 函数栈帧具体内存布局如下:
[图片上传中...(image-370312-1603349931409-0)]
2) Objective-C 调用虚拟机方法
Objective-C 代码通过向虚拟机 的 callFunctionWithArgs: 方法传入要调用函数名及其参数(此处的参数为真正参数的地址)并得到返回值来与虚拟机进行交互。
callFunctionWithArgs: 方法内部会通过函数名查找导出表,找到其方法签名和地址。然后根据方法签名中指定的参数大小,将传入的参数地址处对应大小的数据 push 到操作栈上,然后再跳转到被调方法的开始地址处,开始执行汇编指令。
虚拟机执行完所有汇编指令后返回到 callFunctionWithArgs: 中,该方法再负责把栈顶的返回值数据拷贝到调用方传入的返回值地址处。
调用完成后,虚拟机的栈顶地址应该保持与调用前完全一致。
如果虚拟机方法的返回值是 NSObject* 类型,
OCPack
会根据存储返回值的变量是否是强引用而决定是否需要对返回的对象做__brige_retained
操作,用以中和调用方对 strong 类型变量的 release 操作。其他情况下因为返回的都是 autorelease 的对象,返回时不做特殊处理(详见编译模块
小节中 2.9 段Objective-C方法调用指令
)。
3) 虚拟机调用 OC 方法(f1),f1 又调用到了虚拟机的方法(f2)
要支持此流程,需保证 f2 调用完成后虚拟机当前栈帧的 sp 与调用前完全一致,以保证 f1 的执行不受影响。
4) 虚拟机方法间互相调用
在调用OC方法时,会先检测对应的方法是否在导出函数表中,如果在,则走此流程。这也要求调用虚拟机方法时的参数表应该与直接调用 OC 方法是一致的,否则还需要重新拷贝参数做适配,降低虚拟机性能。
5) 多线程支持
运行时上下文(thread_context)指针放在线程局部存储中,每个线程在读、写上下文中数据时都是操作当前线程自己的数据,这样就能保证各个线程之间运行状态相互隔离,从而支持多线程的调用场景。
OCPack
注册了线程退出的回调函数,当一个线程退出时OCPack
会删除所有虚拟机在此线程中的上下文相关数据。
6) 内存占用
二进制文件加载使用 mmap,全局数据区使用shared & anonymous mmap,常量字符串数据直接指向 .bin 中的地址。
运行时上下文(thread_context)是每个线程一份,都使用shared & anonymous mmap。
运行时的虚拟机类本身只维护导入函数和导出函数数据以及少量指针数据。
7) 崩溃时的栈回溯
运行时上下文(thread_context)中维护有一个栈帧结构(thread_frame)的链表,对应的就是虚拟机中方法的调用关系。
崩溃时遍历所有线程中所有虚拟机的 thread_context,遍历其栈帧结构链表,取出每个栈帧中存储的 ip 地址写到崩溃日志中。
崩溃日志中按照线程组织崩溃栈,还会记录每个虚拟机的地址和其加载的 .bin 文件的 GUID,用以正确区分线程、虚拟机的实例和 bin 文件。
8) 崩溃符号解析
由OCPack
编译器生成或指定一个 GUID,后续生成的所有相关文件(包括.s、.sym、.bin以及运行时生成的崩溃 log)中都存有此 GUID。线上的崩溃日志发送回来后,崩溃解析服务器能够根据日志中的 GUID 查找到相应的符号文件进行符号解析。同时构建服务器数据库中存储了对应 GUID 的 bin 文件打包时所有依赖项的源信息(包括对应的 .s 文件、bin 代码对应的源代码版本、OCPack
工具链的版本等),方便开发重现、定位相关问题。
9) Hook Objective-C 方法
原理与 JSPatch 类似,通过将目标类的目标方法替换为 objc_msgForward,同时将 forwardInvocation 替换为自定义的 forward 方法,实现当目标方法被调用时,转向 forwardInvocation 的自定义实现。在自定义的 forward 的实现中,将 NSInvocation 传给内置的虚拟机,虚拟机会取出其参数并调用相应的虚拟机方法,最后将返回值设回给 NSInvocation,即完成了 Hook 的功能。
与 JSPatch 相比,用
OCPack
的方法调用省去了大量的字符串解析操作,参数大都可直接传入虚拟机进行处理,方法调用的整体开销比 JSPatch 小。
性能优化
- 二进制程序的大小优化
-
OCPack
在实现初期,采用了模板类的方式实现一、二元操作符的对不同操作数和返回值类型的支持,这样调试起来比较方便。但后来发现这种方案会导致代码体积会暴增。模板方法会根据输入、输出参数的类型生成大量的方法,而其中大部分方法都只有很短的几条指令,从最后的二进制内容分析看,光方法名就占用了大量内存。- 在功能基本稳定后,在单元测试的保证下,将模板改为了宏实现,大幅地减小代码段和数据段的体积,framework 文件的大小从3.5M减到了不到150k。
- 性能优化
-
汇编代码优化
由语法树直接生成的汇编代码里面会有很多无用的 push 操作,主要原因是某些表达式的返回值没有被用到,此时 push 是多余的,而且在比较大的循环还可能会使栈长度暴增,影响性能和稳定性
优化方法是尽量去掉无用的 push 指令。主要是通过定义一些规则来判别某指令的返回值是否被用到,对于无用的表达式返回值不进行 push 操作。
-
虚拟机性能优化
将频繁用到的数据缓存下来:取运行时上下文和读写栈帧是频繁操作,涉及线程局部存储数据的读取,运行时上下文结构的指针只会在一个线程中访问,通过对代码进行重构,将它缓存到 executor 类中,提高了运行时效率。
尽量减少核心循环处代码的内存访问次数:将栈帧中的部分数据(如:ip)提出来放到 executor 类中,减少频繁读写ip时导致的不必要的内存操作次数。
尽量提高核心循环处代码效率:用数组代替 map 实现指令到指令处理器的映射关系,提高运行时查询效率。
优化后比优化前提高了将近一倍
III. 未来计划
链接器
- 支持多个 .m 对应的多个 .bin 链接成一个 .bin 文件,且各个 .bin 文件之间能够互相调用
其他语法支持
- 支持声明 block 的语法
性能优化
- 去掉虚函数调用
- 指令长度对齐
- 保证 sp、ip 等频繁操作的数据都放入寄存器——汇编实现相关核心方法、或者改变代码结构
- 生成汇编代码的优化(窥孔优化等)
- 其他
Debug工具
- 因为虚拟机执行时是按指令执行的,不容易直接对应到 Objective-C 代码,调试起来有点麻烦,后续计划做一些功能,调试时方便地显示指令地址到 Objective-C 源代码的对应关系。
作者:zhangjiezhi_
链接:https://juejin.im/post/6884833291740905480
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。