原生动态化探讨与实现

2019 年 5 月 5 日 CocoaChina

作者:折腾范儿の味精

现在随着大前端的流行,Native小程序,RN,等看似打着原生旗号的动态化,但开发过程中都会发现非常非常的像在写前端,各种 margin padding align 等,尤其是各大框架看起来都在反复提一个词 FlexBox ,于是我们深入的聊聊在移动端,动态化与跨平台这两个词的发展。

编译动态化


每一行代码本质上他就是一个字符串,他不具备任何的可运行的能力,之所以能被运行是因为他经过了一系列的编译,把一行行人能读懂的有逻辑有语意的字符串,变成了机器可以读懂的一条条指令集

  • 输入:代码字符串

  • 编译:(简单介绍一下不展开 http://awhisper.github.io/2017/02/26/扯淡:大白话聊聊编译那点事儿/

    • 编译前端

    • 词法分析 一句话解释:识别字符串中的语言关键字

    • 语法分析 :识别循环,判断,跳转,调用等代码逻辑

    • 生成抽象语法树 AST :将代码字符串转化为机器可理解可遍历可处理的语法树

    • 编译后端

    • 生成中间码 IR :将语法树向可执行结果产物进行转化,先产生个中间产物

    • 生成结果 :将中间码根据个平台的差异,生成不同的运行结果

  • 输出:平台可运行的结果

    • iOS:可执行二进制 Mach-O 文件(汇编代码装载进入内存后即可执行)

    • 安卓:JVM的字节码(在任何有JVM的平台中都可以执行)

编译结果动态化

编译的最终结果想要执行,都得装载到运行环境里去,iOS是直接装载到内存上,而安卓是加载到JVM虚拟机里,而这个加载过程天然是支持“动态”加载的,并且这种动态化的开发模式是非常贴近原生的。

  • 安卓:插件化技术,dexloader 加载

  • iOS:动态链接库技术,dlopen 加载


安卓我不了解就不细说了,但iOS确实值得咬文嚼字一下,dylib 的全程叫动态链接库,我们有时候自己做一些通用组件库的时候都可以选择创建一个“静态链接库”又或者“动态链接库”,差异取决在是否在app启动截断就第一时间加载。但次动态指的是动态“链接(加载)” 而非 “动态更新”。

静态库中的代码都会与主程序编译到同一个可执行文件中,如果多个程序引用了同一个静态库,那么这个静态库是会在多个程序的可执行文件中存在多个副本。而动态库他的本质是希望让多个可执行文件间共用代码段,在链接装载期间,通过把独立于主程序可执行文件之外的 dylib 链接到主程序的虚拟地址上,故名:动态链接

但既然是动态链接,那么在本次加载的时候通过网络下载了最新的 dylib 文件,在下次加载最新文件,自然也能做到 “动态更新”,下面看一个在运行期间用代码,手动链接 dylib 的例子

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
#define Dylib_PATH  "/System/Library/PrivateFrameworks/xxxxx/xxxxx/xxxxx"

- (void)dynamicLibraryLoad
{
// dlopen 去动态加载 dylib
void *kit = dlopen(Dylib_PATH,RTLD_LAZY);   

// 在运行期动态加载的dylib应该如何调用里面的代码? OC有运行时与反射

Class pacteraClass = NSClassFromString(@"DynamicOpenMenth");
if (!pacteraClass) {
NSLog(@"Unable to get TestDylib class");
return;
}
NSObject *pacteraObject = [pacteraClass new];
[pacteraObject performSelector:@selector(startWithObject:withBundle:) withObject:self withObject:frameworkBundle];

// 只有OC的代码才能动态调用么?C代码一样有办法 dlsym 通过函数符号获取动态加载后的函数地址,进行调用

NSString *imsi = nil;
int (*CTSIMSupportCopyMobileSubscriberIdentity)() = dlsym(kit, "CTSIMSupportCopyMobileSubscriberIdentity");
imsi = (NSString*)CTSIMSupportCopyMobileSubscriberIdentity(nil);
 
// 卸载动态库
dlclose(kit);
}

只可惜这条看似最佳的动态化的路被苹果堵死了,dlopen 这个函数在 debug 包下运行以上代码,动态加载畅通无阻,但在 release 的包上,就会被苹果加入签名校验逻辑,凡事没经过签名的dylib/framework,都会动态链接失败,所以这条路在 iOS 上是被封死的,但是在安卓上,各种插件化的方案还在继续

编译结果跨平台

本身 Java 就被设计为一种一次编译处处运行的语言,只要对应平台有 JVM 这个虚拟机运行环境,换句话说跨平台的差异被 JVM 给打平了,但很可惜 iOS 平台并没有选择 JVM ,所以也就不跨到了 iOS 上。

那么剩下的跨平台的语言就是 C/C++ 了,安卓与iOS都支持用C与C++的编译产物,在iOS上 OC 与 C 和 C++ 几乎是无缝支持,而在安卓上需要 JNI 来进行桥梁与 java 互通。早年间流行的游戏引擎 cocos2dx 就是用 C++ 进行的逻辑与渲染(lua容后再表),Flutter的渲染平台就是用 C++ 写的 skia 渲染引擎做的,安卓与iOS两个平台其实都存在很多知名的库或者本地算法,底层是用 C++ 实现的。

本质上:编译后台的设计分为,先编译出中间码,再由中间码根据平台差异编译成最终结果,本身就是一个为了跨平台而做的设计“增加一个中间层”,但由于很多语言在发展过程中,不同语言的作者在做开发语言编译器的时候,使用的IR中间码,并不是统一的一套,所以设计很丰满,现实很骨干,IR中间码并没有完全解决跨平台的问题。

Web技术动态化 - “不要写死”

我们在开发的过程中即便是纯native开发,也经常会伴随着一些开发理念“不要写死”

  • 界面的文案?不要写死,服务器下发

  • 界面的文案的样式?不要写死,阴影,加粗,斜体,下划线服务器下发

  • tableView 的Cell?不要写死,七八种cell模板,服务器下发啥渲染啥

  • 程序逻辑?不要写死,根据服务器返回的type开关,看情况跳转

  • 功能模块?不要写死,看开关可以整体隐藏关闭

在编译运行的过程中,代码字符串被编译成了可执行的编译结果,在对应平台上运行。在“不要写死”这个思路中,描述配置信息等json字符串,被通过网络在程序运行的过程中,实时根据配置改变运行结果。

所有的这些“不要写死”,其实是一种可以通过网络下发的描述性值的信息,以json形式,从代码逻辑通过读取这些描述信息,去改变预先准备好的功能逻辑,从而做到功能改变。甚至有些功能页面例如“账单详情”,他的现在几乎进入了高度后端可配的状态,面对不同商家不同种类交易,完全无需前端迭代,纯靠后端强大的配置平台即可完成频繁的各业务方接入需求。

原则上讲,如果我们的描述配置语言设计的足够全面,应用程序中固化的那部分识别描述配置并且执行的固有能力也足够全面。既能考虑到任意当前已规划的需求,以及胜任未来的功能需求,那么我们的描述配置语言,无形中其实就是一种动态更新,这种描述配置语言其实也就形成了 DSL 领域专用语言,在你的应用程序配置体系中专用的描述语言。

布局引擎 - 界面动态化

在界面这一块确实就存在这种几乎能够完美涵盖所有界面需求的“DSL”设计,也存在着能够解析这种完美“DSL”设计的native固定代码“布局引擎”,从而做到一套布局引擎的程序代码,根据输入的 “DSL” 不同,呈现出风格迥异的页面。

布局引擎并不是因为动态化的诉求而产生的,本质上是为了解决屏幕多尺寸适配的问题,为了能用一种“通用”的描述方式,通过传入的窗口区域大小,实时的运算出每一个子元素在当前窗口里的 x y w h 绝对坐标,这个过程便是布局算法

类似的布局算法有很多种,但可以确定的是,浏览器内核中的布局引擎是眼下最完美的,并且还在被 W3C 以及浏览器厂商不断的完善,浏览器内核的布局引擎的输入是 HTML 这种文件来表达界面元素层级描述 CSS 这种文件来表达布局约束信息与样式信息,并且支持绝对布局,相对布局,盒子模型,弹性盒子(知名的 FlexBox),网格布局等多种布局算法相互之间嵌套使用~本质上是一种多种算法可扩展的布局引擎。(后面会略微详细介绍一下布局算法)

这种布局引擎本质上还是 native 的(浏览器内核是用c++)写的,如果不是直接使用 WebView,也有很多有着几乎一样思路的 native 布局 SDK

  • iOS 的 AutoLayout :iOS中的 xib 布局文件,本质上就是一种树型的 XML,它里面包含着界面层级信息与样式信息,经过原生代码 initWithNibName: 来读取这种 xml 最终生成界面。而如果不使用 xib,而是代码创建约束,那么原生 VFL 其实本质上也是一种DSL,而他内部的算法是通过计算约束 N 元一次方程组得出最终渲染坐标的,其算法性能出了名的差,在很多情况下即便原生 autolyout 非常的卡(在iOS 12以后得到优化)

  • 安卓的 XML 写界面:安卓的布局也很像浏览器内核,他也支持用 XML 去写,并且同样支持几种布局,从而满足丰富的界面展现需求

  • DTCoreText:基于 iOS原生 CoreText 排版库的上层封装,可以识别 HTML 从而直接渲染出native的

  • RN / Weex 的渲染框架:实际上也是 HTML/CSS 来通过 FlexBox 弹性盒子这种布局算法,构建出Native界面

  • Texture 框架:原名 AsyncDisplayKit ,FB 出品,也是基于 FlexBox 弹性盒子这种布局算法,其实还有 YogaKit , ComponentsKit 等,归根结底还是来自 FB 开源的 FlexBox 布局算法库 “Yoga”(我们的鸟巢也是来源自 yoga 同一个布局内核)


所以本质上,HTML+CSS 这种网页的界面编写模式,和安卓原生的 xml 写界面,和 iOS 原生的 xib 渲染界面本质上是没区别的,甚至有时候还比原生快(iOS Autolayout被人喷太慢了) ,浏览器与H5慢是多方复杂原因共同导致的,但在布局渲染这块,Web的技术体系在UI的描述能力以及灵活度上确实设计得非常优秀,越来越多的原生动态化方案,在渲染这块都还离不开这一套技术体系

解释执行 - 逻辑动态化

单纯通过界面动态化,这样我们做出来的这样的一个动态App,一个界面的 DSL 中给可点击交互的元素加上一个 scheme 的路由属性,每当这个元素被点击的时候,就跳转这个路由,打开一个新界面,而新的界面又是全新的一个新下载下来的 DSL,从而实现了纯展示型页面跳转的 “动态” App。仔细分析一下,这其实就是早期最原始的浏览器的网页,每个网页由一个 url 去下载 html/css ,然后布局展现页面,每个元素的点击交互的效果是跳转一个新的 url,所以可以认为这样的一种 “动态界面” App,是最纯正的网页技术,虽然他是 Native 的。

但这种动态远不是我们所希望,我们希望可以在不同的交互与生命周期下进行:

  • 发出一个网络请求,获取最新最全的数据

  • 进行本地数据存储读取,数据持久化

  • 针对数据进行多方逻辑判断,根据逻辑结果,呈现给用户不通的表现

  • 等等

纯界面动态化是不可能满足这种需求的,俺着我们的上面探讨的思路,我们需要一种“能够描述逻辑的表达式”,以字符串的形式下发到客户端,然后通过客户端内置的一套固定的“运行环境”,来展现出不同的运行结果。

对比一下界面动态

  • “能够描述界面的表达式-字符串”:就是一种描述语言 DSL (领域特定语言)

  • “运行环境”:执行描述表达式需要一套用 native 代码编写的布局引擎,包含各种布局算法

逻辑动态会复杂的多

  • “能够描述逻辑的表达式-字符串”:单纯是 DSL 已经无法满足需求,我们需要正经代码编程的语言(脚本语言)

  • “运行环境”:编程语言的编译结果能否再任何平台下直接执行?我们需要的是一个用c编写的,脚本语言(JS/Lua)的虚拟机

脚本语言的虚拟机会严格遵照编译原理中的编译前端,即便是主体程序运行期间输入一段目标编程语言的代码逻辑,它也会先经过词法/语法分析,从而生成抽象语法树 AST,最终也会把程序转化为一条一条的运行指令,在虚拟机运行期把编译出来的指令集按顺序执行完并最终得到执行结果。(推荐一本书:《Lua设计与实现》

而脚本语言的内存控制统一被虚拟机进行整体控制,随着指令集在执行的过程中进行对象与内存空间的分配与管理,因此脚本语言的内存是跑在虚拟机的一个上下文之中,这个上下文与原生内存是隔离开的。

大家上大学的时候是否在学习计算机数据结构的时候,被老师要求做一个计算器,输入一个 “1+2*3-4/5”这种字符串,用栈这种数据结构去解析这个字符串运算出结果?这个过程就非常的像解释执行

词法分析:使用栈或者二叉树,一个字符一个字符的读取,读到数字压栈,读到符号/括号,压入符号栈

语法分析:根据你规划的 + - * / 的运算符优先级,以及括号的优先级,构建一个运算树

抽象语法树AST:这个树就可以执行出运算结果

目前最广泛使用的解释执行编程语言就是 JavaScript ~

Binding:

脚本语言能运行,只能执行逻辑,for 循环 / if 选择判断 / 函数方法 / 执行回调 等语言逻辑,但是想要命令设备去执行一些操作,还是需要调用原生平台的各类 API ,比如磁盘读写,比如网络请求,比如图形渲染。 脚本语言 JS 再怎么编写各种三方js库,也都只是在虚拟机内的上下文中进行运行,无法操作设备。想要让 js 代码所执行的虚拟机能够操作原生环境的硬件,就得构建一个桥梁叫做 Binding(对,其实就是是jsbridge,但 Binding 是更科学的叫法)

  • js 是没有发起网络请求的能力的,浏览器之所以能用 js 写 ajax 网络请求,是因为本身浏览器用 c/c++ 写了网络模块,然后把这个模块 binding 给了 js ,并且在js里封装成了 XMLHttpRequest 对象与 API

  • js 是没有能力改变界面渲染的,是的没错!浏览器内布局引擎与脚本引擎是2个独立的模块,布局引擎纯native的,js是没有能力去直接访问的,但是浏览器专门把布局引擎 binding 给了js,并且在 js里封装成了 Document 对象与 Dom 操作 API

  • js 是没有能力进行本地存储的,浏览器之所以能够用 js 写 localstorage ,就是因为浏览器把本地建值存储的模块 binding 给了 js ,并且在 js 里封装成了 localstorage API

  • 等等



我们平时给webview 写的各种 jsbridge ,其实就是浏览器里面对js 的 binding 概念的延伸,只不过浏览器里面 binding 的各种能力,都是经过 W3C 协会沉淀了十几年的标准,必须具备通用型和可扩展性,不是你想要啥功能都能给你 binding上 的。但我们做客户端的就有这个优势,在我们的自主 App 内,我们就可以利用 native 开发,自行扩展 js 的任意 native 业务能力。

这也就是所谓的前端开发受限于浏览器,浏览器不支持,前端就做不到,但客户端开发本身就在开发浏览器,前端想做而做不到的我们都能补上。

Tips:


问题:如果说所有 native 能力全都 binding 给 js,把每一个 iOS 的系统 Api(可能几万个)都 binding 给 js,那么是不是就可以直接用纯 js 直接写原生 app 的全功能了?把图形渲染能力也 binding 给 js 是不是就不需要 html/css 外加一个复杂的布局引擎,直接用 js 调用 binding 好的 UIView initWithView , addSubview ,就能写出任意的界面了?

回答:说的没错,但这样也是有代价的,每调用一次 binding 的 api 都是一次跨越上下文的接口调用,这个过程非常的耗时过程,在JS上下文的对象,需要进行序列化,然后通过一个通道与原生内存的上下文进行通信,然后在原生内存中还得反序列化才能会被正常的原生代码进行处理(js 与 lua 这个通道的底层处理都是在 c 这一层通过 data buffer 的压栈出栈处理传递数据),所以理论可行的用 js / lua 直接编写原生 App 性能还是存在问题的

  • 在JSPatch 没被封杀的时候,不仅拿 JSPatch 来 hotfix bug,直接用 JSPatch 构建全新的动态更新的功能页面,就是这个原理,只不过区别是 JSPatch 不需要把几万个 iOS 系统原生 Api 都 binding 了,因为 iOS 有 runtime,只需要把 Objc_msgSend 和 NSInvocation 给 binding 了,就可以做到用 js 代码,调用任意原生 iOS API,安卓也有反射,安卓也可以做到只用 js 代码,写出并调用任意安卓原生 API (以前我们写过在 app 黑盒运行期的调试工具,可以输入任意代码,动态调试安卓 与 iOS app,非常方便在bug现场的情况下任意调试代码,调试内存,快速追踪问题)

  • C++的游戏引擎 cocos2dx-lua 也是这个思路,c2d引擎小组的成员真的把一整个引擎的所有 C++ API 统统 binding 给了 lua,所以在游戏圈里,大量用纯 Lua 开发游戏,只有在引擎C++控件不满足需求的时候,才需要少量C++开发的开发模式。

Web技术思路下的原生动态化与跨平台

布局引擎 + 脚本引擎,构成了Web技术最重要的2大模块,同时也是浏览器内核最重要的2个核心,而后续所有的非编译结果的那种动态化方案,全都是Web技术思路下而产生的动态化方案。

并且这些方案本来就是跨平台的,底层用c去实现内核,实现脚本引擎,只要底层环境在各个平台都有,那么上层的 DSL 与 JS 就可以跨平台。

  • WebKit:如上图,输入 HTML / CSS / JS ,由浏览器内核实现

  • ReactNative:

    • 输入的是 JSX 但实际上,也是HTML CSS JS 混合在了一起

    • 布局引擎是 c 写的 FB 的开源 Yoga Flexbox 布局引擎

    • 渲染是通过 binding 模式直接调用各平台原生渲染 API ,构造 View ,Add View

    • 通过 javascriptCore 这个开源 js 解析引擎来作为虚拟机,运行构建 js 上下文

    • 通过把各种丰富的原生能力,原生界面组件,binding 给 js,从而丰富各种原生能力能够动态调用

  • 鸟巢:

    • 输入是 HTML / CSS / JS ,但是会在服务器端被分析合成一个 json 树结构模板,下发到客户端

    • 客户端SDK输入是 json 树结构,遍历进行布局与渲染

    • 布局引擎是 c 写的 FB 的开源 Yoga Flexbox 布局引擎

    • 渲染是通过 binding 模式直接调用各平台原生渲染 API ,构造 View ,Add View

    • 通过 dukTape 这个开源嵌入式 js 解析引擎来作为虚拟机,运行构建 js 上下文

    • 接口 API 上提供扩展,让接入鸟巢SDK的业务方也很方便能 binding 各种 native 能力

一些原生动态化技术工作流程分析

相信 Weex 与 ReactNative 的源码已经有无数人分析过了,大家也或多或少的看了很多各种架构与设计分析,其实还有很多大公司内部有一套自己自研,更适合自身业务的原生动态化技术。这些技术摆脱了开源的可维护与可扩展性以及易学习的包袱重担,很多内部自研技术在适当耦合了自身业务与后端服务的前提下,根据公司业务特点取舍性的设计原生动态化能力,反而可能获得一些性能上的优势。

简单地说,就是不求设计一个大而全,牛逼的原生动态化框架与丰富的组件生态。而是根据自身业务需求量身定制,在一样的原理和思路下,根据业务量体裁衣,做出最适合自己业务的轻量级原生动态化框架。

很多公司内部都开发过直接用 HTML + CSS + JS 的网页写法,直接转 Native 原生动态化的技术,比如百度内部的自研技术 HtmlNative ,滴滴的@戴铭 老师也介绍过滴滴那边直接写 html 代码生成 native 的技术,还有蚂蚁的鸟巢。

这类技术让前端开发用传统的 HTML + CSS + JS 来快速实现可动态部署的 native 页面。其工作机制上和浏览器内核的原理一模一样,也是将 HTML & CSS 在内核中整到一个树上,并且把 JS 整体输入虚拟机,但各自细微机制的取舍上还是存在一些差异。

服务器远端处理Dom

  • Dom 流程差异:

    • 鸟巢:解析识别 HTML & CSS 的过程不发生在客户端,是在服务端/ 开发阶段 IDE 中进行,最后把界面元素与样式合并成一个json树状结构:JSON格式 Dom 下发给客户端,行程树状内存 Dom 对象

    • WebKit:解析识别 HTML & CSS 的过程发生在客户端,网络模块只下载 HTML CSS 源码,解析识别后最终生成 Dom 树状结构:内存中树状格式的 Dom 对象

  • JS 流程的差别:

    • 鸟巢:JS代码最后被一起放入了 JSON格式的 Dom 之中,当下载到客户端本地的时候,从 Dom 中取出来直接输入虚拟机的上下文运行环境之中

    • Webkit:支持解析 HTML 中的 script 标签,也支持直接加载 js 代码,网络模块下载到的 js 源码,会直接输入虚拟机的上下文运行环境之中

简单的说,鸟巢是先构建 Dom 后下载整个 Dom树,而 WebKit 是先下载所有代码资源文件,后在客户端本地构建 Dom树,在鸟巢里一个模板id 一个包,一个网络链接下载下来,并且辅助以 native 的缓存与更新机制。在 WebKit 里无论是 html css 还是 js,都可以按文件拆分,每个文件都可以由浏览器独立进行下载,并且按着浏览器的缓存机制按着 Header 的 cache-control 等信息进行缓存控制。

客户端本地内核模块

上面是简单的画了一下鸟巢的结构图,总共分2个 Bundle ,一个是鸟巢的主模块,一个是鸟巢核心依赖的3个三方 C 库。

  • BirdNest

    • View Builder: 算是作为整个鸟巢内核的使用入口,提供一体化的模板处理入口,内部包含 Template Manager 的模板管理模块,而 View Builder 经过一体化的处理,产出结果是 Document 对象

    • Template Manager: 是鸟巢模板的管理模块,主要包括模板的下载模板,下载队列控制,版本更新判断,模板读取。鸟巢模板后台提供灰度能力,统一由服务器下发模板的更新信息,根据不同的网络环境以及本地模板状态判断,模板更新流程

    • FBView: Dom树会经过布局流程算出每一个子 View 的 frame,然后通过 getView 的方法,按着计算出来的 frame ,一个个创建出对应的 native View,然后汇总成最终的用于展示的 Native 界面,这里会根据 Div 的种类,创造出 Button Image Input Switch Vedio 等,并且支持横向扩展

    • FBDocument: 是鸟巢的Dom 对象,本身继承自 ViewController ,统一管理模板生成出来的的 VM - Dom 树,由 Dom 树进行布局生成的 View ,以及 各个 View的 UI 交互触摸事件的响应

  • BirdNestBase

    • layout:FBDocument内部进行布局流程的时候,主要依赖的 C++ Flexbox 布局算法,从代码里看来自 RN 与 Weex 都使用过的 Yoga layout.c 代码片段,但已经被二次改造过

    • duktap:一个轻量级嵌入式 js 解释引擎,不像RN 与 Weex 使用的是 JavascriptCore 这个开源 js 引擎,鸟巢使用的是一个更轻量级的

    • fbJSON:服务器生成的 JSON 格式的 Dom 结构,也就是模板,需要进行 JSON 解析,这个是所依赖的底层 JSON 解析库

客户端本地更新流程

Dom 树本地工作流

View Builder 的主要工作就是构建一整个 FBDocument ,而 FBDocument 前边介绍了,他是整个鸟巢界面的核心,本身包含着整个界面的 ViewModel 数据,也就是 Dom 树

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
- (id)initWithHtml:(NSString*)html withData:(NSString*)data andDelegate:(id)delegate andFallbackDelegate:(id)fallbackDelegate andOption:(long)option docName:(NSString *)docName {

self = [super init];

// 一大堆 property 对象的 初始化
self.xxx = xxx;
self.xxx = xx;
// 等等...

//主要是 fb_node.c 对象,每个node 就是一个dom节点,需要计算整体布局就递归调用 layout.c 的算法
if (!_core) {
//构建排版内核对象
_core = fb_core_new();
//设置布局计算完毕后的回掉
_core->core_layout_notify = core_layout_notify;
//设置 meta 信息处理的外部回掉
_core->tpl_content_handler = platform_content_handler;
//设置整个 Dom 处理完毕后的回掉
_core->core_load_finish_notify = core_load_finish_notify;
//保存当前 Document 对象指针给 core ,便于 core 的一些处理
_core->context = (__bridge void *)(self);

}
// 开始加载 模板
// - 传入数据 json 形态的 dom 数据(html参数)
// - 传入数据 json 形态的 data 数据(data参数)
fb_core_load_l(_core, [html UTF8String], [data UTF8String], false, option);
return self;
}

这就是 FBDocument 的初始化流程

而这其中最核心的就是 fb_core_load_l 这个方法,这个方法干的事情比较多,精简一下代码,然后解释一下流程,对关键环节进行逻辑标注

  • 解析 data json

  • 解析 html json 为 body header

  • 构建 js 虚拟机上下文,把 body header data中的 js 标签里的 js 代码,执行进入虚拟机

  • 用layout.c的flexbox排版算法,递归整个 body 的 Dom 树,算出每个ui元素的 frame坐标

  • 发出load finish事件,触发页面渲染构建


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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
bool fb_core_load_l(fb_core_t* core,
const char *page,
const char *data,
bool js_debuggable,
const long flags) {

// ... 省略部分代码

// 解析传入的 data json 数据
if (data != NULL) {
core->last_data = fbJSON_Parse(data);
}

// ... 省略部分代码

// 解析传入的 html json 数据
core->html = fb_parser_json(core, page);

// ... 省略部分代码

// html 解析结果里面分拆出 body 与 head
core->head = fb_node_by_tag(core->html, FB_tag_head);
core->body = fb_node_by_tag(core->html, FB_tag_body);

// ... 省略部分代码

//### 如果head或者body为空,那么就无法正常渲染了,直接返回失败
if (!core->head || !core->body) {
return false;
}

// ... 省略部分代码

//无 js 的鸟巢业务,即精简模式下不构建 js 虚拟机,非精简模式,构建 js 虚拟机上下文
if (!core->isLiteMode) {
fb_core_init_script_engine(core);
}

// ... 省略部分代码

// 非精简模式下,遍历 data body head 中的 script 标签数据,读取出js代码,输入 duktape 虚拟机上下文
if (!core->isLiteMode) {
const bool dataIsJson = fb_tools_is_json(data);
fb_script_execute_string_l(core->scriptEngine, data, flags);

for (int i = 0; i < core->head->cssNode.children_count; i++) {
fb_node_t *node = core->head->subNodes[i];
if (node->tag == FB_tag_script) {

const char *src = fb_node_get_attr(node, "src");
if (src) {
char *data = fb_platform_load_file(node->core, src);
fb_script_execute_string(core->scriptEngine, data);
if (data && strlen(data) != 0) {
free(data);
}
}

const char *text = fb_node_get_attr(node, "text");
if (text) {
fb_script_execute_string(core->scriptEngine, text);
}
}
}

// ... 省略部分代码

//查看整个节点中是否包含 js 的 onload 事件,如果有则执行 onload
fb_script_cb_t *onload = fb_node_get_event(core->body, "onload");
if (onload) {
fb_script_execute(core->scriptEngine, onload, NULL);
}

//查看整个节点中是否包含 js 的 onreload 事件,如果有则执行 onreload
if (data && dataIsJson) {
fb_script_cb_t *onreload = fb_node_get_event(core->body, "onreload");
if (onreload && data) {
core->hasOnReload = true;
fb_script_execute_javascript_with_json(core->scriptEngine,
onreload->js_callback,
data);
}
}
}

// ... 省略部分代码

//### 最终layout一次,递归整个树,让每个 dom(即 node 节点对象,都经过layout.c的算法计算好了frame)
fb_core_layout(core);

// ... 省略部分代码

// 发送 Document Load Finish 回掉,触发后续的渲染流程
if (core->core_load_finish_notify) {
core->core_load_finish_notify(core);
}

return true;
}

FlexBox布局算法简单介绍

上面介绍的代码流程里 fb_core_layout 就是进行布局计算的算法,可以仔细看他的源码,他的核心就是调用 layout.c 这个 BirdNestBase 里面的布局库的 layoutNode 方法,传入了 core-> body -> cssNode 的树的根节点,然后这个算法会层层递归完整个树,计算每个节点的UI元素应有的 frame

layout.c 代码本来是源自 ReactNative 里面的源码,后续 Weex 以及聚划算的 LuaView 都曾经使用过 layout.c 这个纯 FlexBox算法的开源库,并且进行了自己的优化修改。而 FaceBook 也在不断优化,重新整理封装独立开源成了名为 Yoga 的库。

无论是哪一家的 FlexBox 的算法原理都差不多,有兴趣的可以详细了解一下,主要核心就是 FlexBox 把界面划分为横纵两个轴,视 css 的值来决定主次,然后在算法里会沿着主次轴,对内部元素进行弹性填充计算

参考资料:https://halfrost.com/weex_flexbox/

这里我就简单的过一下,不深入详解 FlexBox 算法了

  • 递归节点,判断是否使用缓存,还是重新layout

    • 计算盒子边框边距的基础参数

    • 针对主侧轴,分别判断在边距边框下的可用size

    • 沿着主轴遍历子视图,摆放子视图

    • 子视图是否可拉伸,取决于递归子视图直到子视图拥有最大或最小或确定的尺寸

    • 在主轴上通过 flex 的一些 css 属性来确定可受弹性变化的子视图的位置微调

    • 在侧轴上通过 flex 的一些 css 属性来确定子视图的位置微调

    • 确定子视图绝对布局坐标 frame

整个算法由7个主要的大循环组成,不详细分析代码了,已经给出参考链接

FlexBox弹性盒子布局算法,只是 WebKit 布局能力的一部分,可以说 FlexBox 的布局能力只是 WebKit的子集

所以可以认为鸟巢(还包括 RN Weex 等所有用FlexBox的框架)的界面布局表达能力,只是WebView的界面布局表达能力的子集,但如果这个子集足够满足大部分的需求,那么也可以满足业务需要

原生页面的创建与渲染

当 layoutNode 布局计算结束后,会触发 core->core_load_finish_notify(core) 回掉,就会回到 FBDocument 这个类里去调用 updateLayout 这个 OC 方法,进行渲染

渲染过程依赖一个核心队列 _core->actionSeq 这个队列其实是在服务器构建 JSON 化的 Dom 数据的时候,就被服务器创建好了,简单的说一下这里面的内容其实就是根据 Dom 树的层级,生成一个渲染指令队列,例如

  • 创建一个根视图 RootView

  • 创建一个子视图 AView

  • 更新子视图 AView 的属性,会用到布局计算的 frame结果,来更新frame属性

  • 将子视图 AView 添加到 RootView 上

  • 创建一个子视图 BImage

  • 更新子视图 BImage 的属性,会用到布局计算的 frame结果,来更新frame属性

  • 将子视图 BImage 添加到 RootView 上

所以我们来分析一下原生页面创建与渲染的代码工作流程

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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
- (void)updateLayout
{
//循环指令队列,执行每个指令
for (int i = 0; i < _core->actionSeq->length; ++i) {
// ... 省略部分代码

//### 指令已经无效,跳过
if (op->node == NULL) {
continue;
}

//判断指令类型
switch (op->op) {

//### 创建view
case DOM_CREATE: {

FBView *view = nil;

//创建 root View
if (op->node->tag == FB_tag_body) {

_root = [[FBView alloc] initWithNode:_core->body
withDocument:self
withView:nil];

view = _root;
}
else {
// 创建其他嵌入 View
// 识别子View 的native ui 类型
switch (op->node->tag) {

// ... 省略部分代码

//容器空 View
case FB_tag_div: {
view = [[FBView alloc] initWithNode:op->node
withDocument:self
withView:nil];
break;
}

// ... 省略部分代码

//原生勾选框 View
case FB_tag_checkbox: {
view = [[FBCheckbox alloc] initWithNode:op->node
withDocument:self
withView:nil];
break;
}

// ... 省略部分代码

//原生开关 View
case FB_tag_switch: {
view = [[FBSwitch alloc] initWithNode:op->node
withDocument:self
withView:nil];
break;
}
// ... 省略部分代码

//原生文本标签 View
case FB_tag_label: {

NSString *key = [NSString stringWithFormat:@"%llx", (long long)op->node];
view = [self.dictLabel objectForKey:key];
if (view == nil) {
view = [[FBLabel alloc] initWithNode:op->node
withDocument:self
withView:nil];

[self.dictLabel setObject:view forKey:key];
} else {
[view associateNode:op->node];
}
break;
}
// ... 省略部分代码

//原生图片 View
case FB_tag_img: {

NSString *key = [NSString stringWithFormat:@"%llx", (long long)op->node];
view = [self.dictImg objectForKey:key];
if (view == nil) {
view = [[FBImg alloc] initWithNode:op->node
withDocument:self
withView:nil];

[self.dictImg setObject:view forKey:key];
} else {
[view associateNode:op->node];
}

break;
}
// ... 省略部分代码

//原生按钮 View
case FB_tag_button: {
view = [[FBButton alloc] initWithNode:op->node
withDocument:self
withView:nil];
break;
}

// 可以横向扩展各种原生 View

// ... 省略部分代码

default: {

break;
}
}
}

// ... 省略部分代码

break;
}

// 把一个 View add 到另一个View 成为子 View
case DOM_ADDVIEW: {
FBView *subView = [self findViewByNode:op->node];
FBView *superView = [self findViewByNode:op->node->superNode];

if (superView && subView) {
[superView addSubview:subView];
}
else {
//assert(false);
}
break;
}

// 删除一个 View
case DOM_DELETEVIEW: {
FBView *subView = [self findViewByNode:op->node];
[subView removeFromSuperview];

//### label 需要从 cache中移除
unsigned tag = op->tag;
if (tag == FB_tag_label) {
NSString *key = [NSString stringWithFormat:@"%llx", (long long)op->node];
[self.dictLabel removeObjectForKey:key];
}

//### img 需要从 cache中移除
if (tag == FB_tag_img) {
NSString *key = [NSString stringWithFormat:@"%llx", (long long)op->node];
[self.dictImg removeObjectForKey:key];
}

[self removeView:op->node fbView:subView];

break;
}

//### 更新 view 的 rect,rect 来自 layout.c 的计算结果
case DOM_UPDATE_RECT: {
FBView *view = [self findViewByNode:op->node];
[view updateRect];
break;
}

//### 更新 view 的其他 css属性
case DOM_UPDATE_CSS: {
FBView *view = [self findViewByNode:op->node];

NSString *style = [NSString stringWithUTF8String:(char*)op->param];
NSArray *arrKeyAndValue = [style componentsSeparatedByString:PARAM_DELIMITER_OC];
if (arrKeyAndValue.count == 2) {
NSString *key = [arrKeyAndValue objectAtIndex:0];
NSString *value = [arrKeyAndValue objectAtIndex:1];
[view updateCSS:key withValue:value];
}
break;
}

//部分View 的native属性,比如字体粗细,比如背景色,需要进行设置
case DOM_UPDATE_ATTR: {
FBView *view = [self findViewByNode:op->node];

NSString *style = [NSString stringWithUTF8String:(char*)op->param];
NSArray *arrKeyAndValue = [style componentsSeparatedByString:PARAM_DELIMITER_OC];
if (arrKeyAndValue.count == 2) {
NSString *key = [arrKeyAndValue objectAtIndex:0];
NSString *value = [arrKeyAndValue objectAtIndex:1];
[view updateAttr:key withValue:value];
}
break;
}

//部分 View 可以接受交互事件,比如点击事件 onClick,需要把原生的点击事件,与点击事件响应的js 绑定起来
//因此这个 action 用于绑定 js 函数给 native 的点击事件
case DOM_UPDATE_EVENT: {
FBView *view = [self findViewByNode:op->node];

NSString *style = [NSString stringWithUTF8String:(char*)op->param];
NSArray *arrKeyAndValue = [style componentsSeparatedByString:PARAM_DELIMITER_OC];
if (arrKeyAndValue.count == 2) {
NSString *key = [arrKeyAndValue objectAtIndex:0];
NSString *value = [arrKeyAndValue objectAtIndex:1];
[view updateEvent:key withValue:value];
}

break;
}

//部分 View 可以接受 JS 代码控制,提供给 JS Native 方法可以调用
//因此这个 action 用于绑定 native 方法给 js
case DOM_UPDATE_FUNC: {
FBView *view = [self findViewByNode:op->node];

[view updateFunc:[NSString stringWithUTF8String:(char*)op->param] withValue:PARAM_DELIMITER_OC];
}

default:
break;
}

// ... 省略部分代码
}

// ... 省略部分代码
}

当 updateLayout 执行完毕后,对 FBDocument 调用他的 getView 方法,就能直接获取 RootView,也就是整个鸟巢界面了。

原生控件与JS代码之间交互

  • DOM_UPDATE_EVENT

  • DOM_UPDATE_FUNC

其实就是上面介绍的2个核心的 actionSeq 指令,而这两个指令会分别调用 FBView 的下面两个方法

1
2
- (void)updateEvent:(NSString*)key withValue:(NSString*)value;
- (void)updateFunc:(NSString*)key withValue:(NSString*)value;

FBView 会有2个空方法实现,不同的原生 View 组件会根据自己的组件设计去重写这两个方法,从而识别正确下发的 key 与 value

  • DOM_UPDATE_EVENT

拿 FBImage 举例,FBImg 就是一个可以接受点击,触发点击事件的原生组件,所以在 FBImg 的 updateEvent 代码中,native 代码会识别 value 为“onClick” 的时候,知道要给 FBImg 构建一个 tap 手势识别器,当发生点击的时候,会调用 onClicked:方法,触发 fb_platform_onclick 来调用 js

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

- (void)updateEvent:(NSString *)key withValue:(NSString *)value
{
if ([value isEqualToString:@"onclick"]) {
[self.view setUserInteractionEnabled:YES];
if (_tapRecognizer == nil) {
_tapRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(onClicked:)];
[self.view addGestureRecognizer:_tapRecognizer];
const fb_node_t *fbNode = [self getFbNode];
if (fbNode != nil && fbNode->_id != NULL) {
NSString *seed = [[NSString alloc] initWithUTF8String:[self getFbNode]->_id];
SEL selector = @selector(setActionName:);
if ([_tapRecognizer respondsToSelector:selector]) {
[_tapRecognizer performSelector:selector withObject:seed];
}
}
}
}
}

- (void)onClicked:(id)sender
{
if (_imageView)
self.doc.focusView = _imageView;

fb_platform_onclick([self getFbNode]);
}

fb_platform_onclick 这个方法核心是用 js 的 duktap 解释器,调用 fb_script_execute 去执行 js 代码,类似于 WebView 的 evaluateScript

  • DOM_UPDATE_FUNC

拿 FBInput 举例,FBInput 就是一个可以被 js 主动调用 focus/unfocus 2个方法的控件,native 应该把聚焦/失焦,以 focus/blur 2个命名,提供给 JS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

- (void)updateFunc:(NSString*)key withValue:(NSString*)value {

if ([key isEqualToString:@"focus"]) {

[_textfield becomeFirstResponder];
}
else if ([key isEqualToString:@"blur"]) {

[_textfield resignFirstResponder];
}
else {
[super updateFunc:key withValue:value];
}
}

在这种 JS 主动调用 native 的情况下,真正的链接 native 与 js duktape 引擎的是两个 fb_script.c & fb_script_ductape.c 的类,这两个类的主要工作是进行 js 的内存数据序列化反序列化成 native 的数据,对识别出来的数据,从而调用对应的native 方法,传递数据。

略微展开一下就是:

  • fb_script_duktape 中的 fb_script_register_doc 方法,把所有的 native可提供的方法表的名字字符绑定到了js环境,当任意 js 代码调用了这些绑定名,会触发 fb_script_doc_func 方法

  • fb_script_doc_func 方法会触发 fb_script_bind_node 方法

  • fb_script_bind_node 方法会触发 fb_script_node_func 方法

  • fb_script_node_func 中会识别方法名,如果是 ‘blur’ / “focus” 会触发 fb_core_add_action

  • fb_core_add_action 的作用就是创建 DOM_UPDATE_FUNC 事件,并且放入执行队列

这块还是比较复杂,涵盖整个 js 与 oc 2个上下文交互,可以的话推荐阅读这两个类的源码

动态化方案对比思考

思考技术发展的趋势脉络


好多人都会画这种对比表格,来表明哪种技术方案优劣,但仔细看这个表格,你会发现在 Web 技术动态化这个方向上,所有的技术方案不是割裂的,而是一脉相承的。

  • Native 是动态化之路的起点,动态能力几乎为0

  • WebView 是动态化之路的终点,动态能力堪称完美(毕竟是W3C标准组织沉淀了一二十年)

但因为移动端的机器性能不及PC电脑,所以在PC端已经极致成熟的Web庞大功能,在移动端施展不开。于是就在移动端,重新根据移动端设备的性能,沿着 Web 标准发展路线,优先选取最需要的动态能力,融入移动端

  • Native 是动态化之路的起点,动态能力几乎为0

    • –> 从 Native 朝着 WebView 去发展 去扩充

    • 只选取性能最高的 FlexBox 布局能力

    • 极简 JS 引擎框架

    • 整包整体下载后离线运行

    • – > 形成了鸟巢

    • 扩充了更完善的Native UI组件

    • 扩充了JS层高效的 React 开发框架

    • jsbundle的整包分包拆包更新机制

    • 更加合理的整套框架设计

    • – > 形成了 RN

  • WebView 是动态化之路的终点,动态能力堪称完美(毕竟是W3C标准组织沉淀了一二十年)

    • – > 从 WebView 朝着 Native 去发展 去删减不必要的功能开销

    • 删减掉 WebView 的每个文件细粒度实时更新控制与缓存控制,改为离线包本地加载

    • 在CSS这块尽量推荐使用 FlexBox 的布局设计

    • 在JS这块强制框架引入 MVVM 框架与 dom diff 算法,禁止任意操作dom导致性能下降

    • 多 WebView 共用 service worker 统一逻辑 js 上下文异步运行js

    • 在 WebView 的基础上 融入 原生UI组件,Hybrid 渲染,提高一些特殊组件的性能效果

    • – > 形成了 WebView 小程序


一个共识是现阶段原生动态化大家相对都认可的:那就是看齐Web的几个标准。因为Web的技术体系在UI的描述能力以及灵活度上确实设计得很优秀的,而且相关的开发人员也好招。所以,如果说混合开发指的是Native里运行一个Web标准,来看齐Runtime来写GUI,并桥接一部分Native能力给这个Runtime来调用的话,那么它应该是一个永恒的潮流。
引用自: http://awhisper.github.io/2016/06/16/前端10年读后感/

本公众号转载内容已尽可能注明出处,如未能核实来源或转发内容图片有权利瑕疵的,请及时联系本公众号进行修改或删除【联系方式QQ : 3442093904  邮箱:support@cocoachina.com】。文章内容为作者独立观点,不代表本公众号立场。版权归原作者所有,如申请授权请联系作者,因文章侵权本公众号不承担任何法律及连带责任。

---END---

登录查看更多
1

相关内容

浏览器内核
浏览器最重要或者说核心的部分是“Rendering Engine”,可大概译为“渲染引擎”,不过我们一般习惯将之称为“浏览器内核”。负责对网页语法的解释(如标准通用标记语言下的一个应用HTML、JavaScript)并渲染(显示)网页。 所以,通常所谓的浏览器内核也就是浏览器所采用的渲染引擎,渲染引擎决定了浏览器如何显示网页的内容以及页面的格式信息。不同的浏览器内核对网页编写语法的解释也有不同,因此同一网页在不同的内核的浏览器里的渲染(显示)效果也可能不同,这也是网页编写者需要在不同内核的浏览器中测试网页显示效果的原因。
【2020新书】使用高级C# 提升你的编程技能,412页pdf
专知会员服务
57+阅读 · 2020年6月26日
FPGA加速系统开发工具设计:综述与实践
专知会员服务
65+阅读 · 2020年6月24日
【实用书】Python技术手册,第三版767页pdf
专知会员服务
234+阅读 · 2020年5月21日
【实用书】流数据处理,Streaming Data,219页pdf
专知会员服务
76+阅读 · 2020年4月24日
【SIGMOD2020-腾讯】Web规模本体可扩展构建
专知会员服务
29+阅读 · 2020年4月12日
TensorFlow Lite指南实战《TensorFlow Lite A primer》,附48页PPT
专知会员服务
69+阅读 · 2020年1月17日
【电子书】C++ Primer Plus 第6版,附PDF
专知会员服务
87+阅读 · 2019年11月25日
【电子书】Flutter实战305页PDF免费下载
专知会员服务
22+阅读 · 2019年11月7日
msf实现linux shell反弹
黑白之道
49+阅读 · 2019年8月16日
美团:基于跨平台框架Flutter的动态化平台建设
前端之巅
14+阅读 · 2019年6月17日
硬核实践经验 - 企鹅辅导 RN 迁移及优化总结
IMWeb前端社区
5+阅读 · 2019年5月6日
7 款实用到哭的App,只说一遍
高效率工具搜罗
84+阅读 · 2019年4月30日
职人沙龙 | 走进小打卡,小程序技术实战交流
使用 C# 和 Blazor 进行全栈开发
DotNet
6+阅读 · 2019年4月15日
支持多标签页的Windows终端:Fluent 终端
Python程序员
7+阅读 · 2019年4月15日
React Native 分包哪家强?看这文就够了!
程序人生
13+阅读 · 2019年1月16日
Compositional Generalization in Image Captioning
Arxiv
3+阅读 · 2019年9月16日
Attend More Times for Image Captioning
Arxiv
6+阅读 · 2018年12月8日
VIP会员
相关VIP内容
【2020新书】使用高级C# 提升你的编程技能,412页pdf
专知会员服务
57+阅读 · 2020年6月26日
FPGA加速系统开发工具设计:综述与实践
专知会员服务
65+阅读 · 2020年6月24日
【实用书】Python技术手册,第三版767页pdf
专知会员服务
234+阅读 · 2020年5月21日
【实用书】流数据处理,Streaming Data,219页pdf
专知会员服务
76+阅读 · 2020年4月24日
【SIGMOD2020-腾讯】Web规模本体可扩展构建
专知会员服务
29+阅读 · 2020年4月12日
TensorFlow Lite指南实战《TensorFlow Lite A primer》,附48页PPT
专知会员服务
69+阅读 · 2020年1月17日
【电子书】C++ Primer Plus 第6版,附PDF
专知会员服务
87+阅读 · 2019年11月25日
【电子书】Flutter实战305页PDF免费下载
专知会员服务
22+阅读 · 2019年11月7日
相关资讯
msf实现linux shell反弹
黑白之道
49+阅读 · 2019年8月16日
美团:基于跨平台框架Flutter的动态化平台建设
前端之巅
14+阅读 · 2019年6月17日
硬核实践经验 - 企鹅辅导 RN 迁移及优化总结
IMWeb前端社区
5+阅读 · 2019年5月6日
7 款实用到哭的App,只说一遍
高效率工具搜罗
84+阅读 · 2019年4月30日
职人沙龙 | 走进小打卡,小程序技术实战交流
使用 C# 和 Blazor 进行全栈开发
DotNet
6+阅读 · 2019年4月15日
支持多标签页的Windows终端:Fluent 终端
Python程序员
7+阅读 · 2019年4月15日
React Native 分包哪家强?看这文就够了!
程序人生
13+阅读 · 2019年1月16日
Top
微信扫码咨询专知VIP会员