随着大前端技术栈的演变,各种跨平台框架层出不穷,本文选用 Hippy 作为跨平台框架,以 Hippy/Vue 为切入点,深入分析从前端到终端整个链路上的渲染流程。
一、为什么选择 Hippy
- Hippy 性能高:性能优化可到秒开,对比 H5 优势非常明显
- HIppy 集成小:Android 增量共 3.8M(so占2.9M),iOS 增量1M,对比 Flutter Android 集成增量 5.16M,iOS增量14.2M
- Hippy 支持大:专门中台维护保证新功能迭代/问题响应
二、Hippy 整体架构
Hippy 从技术栈的角度上划分为三层:
- Java/OC:应用框架层,主要框架的 module、views、adapter 提供和绑定,以及使用的流程等
- C++:内核层,主要的是对 V8/JSC 引擎的封装和运行,以及提供了 JSI 绑定的 基础模块,和 JS Bridge 和 JNI Bridge 两层模块的交互等
- JS:驱动层,主要是支持 react/vue 在 hippy 上运行和渲染,并封装了对齐 H5 的基础组件和样式等
三、Hippy 框架剖析
1、Hippy/Vue 如何生成 DOM Tree
1)节点映射
我们书写一个页面的代码,并打印它最终传输到终端的数据如下:
终端就是根据右边的 node 数据进行渲染的,它会组成一个 DOM Tree:
其中有每个节点的终端组件名称和属性样式,以及节点之间的关系包括自身节点和父子节点。那这个 hippy/vue 是如何生成一颗传给终端的 DOM Tree 的?
2)hippy/vue 运行原理
- 编译阶段:提前确定、不变的部分提前转换为终端识别的组件或属性
- 运行阶段:监听 node-ops,构建 DOM Tree
- 绘制阶段:insert/remove/update 放入 batchNodes, $nextTick 批量传输给终端
3)hippy/vue 模版生成
- 基于 Vue 框架提供的 node-ops 钩子函数,拦截 DOM 的增、删、改操作,可应用在 Web、Server 或 Hippy 渲染中,这里转到 Hippy 框架中进行组装
- Hippy/Vue 构造了一个 ViewNode 树,并保存在一个数组结构中,放入数组中的 node 已经是转换为 NativeNode 的终端结构,包括样式匹配、终端组件名称映射
- 并在每个 tick 会检查是否有需要传输的节点,若有则批量传输给终端
4)hippy/vue 样式匹配
- 编译时:CSS 样式文本会编译成 AST Tree,并放入 global[GLOBAL_STYLE_NAME] 全局变量中
- 运行时:
- 将 CSSRules 构建成 CSS 选择器,可被 id、tag、class 查询
- 在 ViewNode 转成 NativeNode 时,会根据 id、tag、class 查询和获取节点的样式信息,并组装到 NativeNode 的 props.style 属性中
2、如何传输 DOM Tree
- Hippy/Vue、Hippy/React 节点构建统一调用 UIManager 批量传输
- js-native 提供 Hippy.bridge.callNative 等通用能力通过 JSBinding 到 C++,再调用到 Java/OC 层
3、如何根据 DOM Tree 渲染
终端接收到 DOM Tree 数据后,整体会经过两个阶段,第一个阶段是序列化还原到 DOM Tree 结构,第二个阶段是把 DOM Node 批量转换到终端真实的 Render View 组件并渲染。
① dom tree 构建:DOMManager 接收 UIManager 发送过来的节点数据组装还原 DOM Tree
② node 布局计算:调用 flex 布局引擎计算每个 node 的节点位置
③ render tree 构建:将 DOM Node 转换到终端的渲染节点 RenderNode,并传入对应的坐标位置和属性
④ render view 构建:批量将 RenderNode 节点转换到终端真实的组件 Render View 节点进行创建
⑤ render view 更新:更新 Render View,进行 children 的添加、调用 props 方法和 layout 排版
4、模块调用优化(JSBinding & JSI)
JS 到业务应用层一般要经过 JS -> C++ -> Java/OC,Android 比 iOS 多一层 JNI 传输,对于传输的数据比较大时,序列化、反序列化的耗时会成为性能瓶颈。于是有 JSBinding 和 JSI 的优化。
1)JSBinding
对于需要传给 Java 层的一般通过统一的 bridge 调用,有序列化、反序列化和 JNI 的耗时,于是就有了直接在 C++ 层直接实现对应的 Module,通过 JSBinding 的机制绑定调用,省去了 C++ <-> Java 的损耗,但使用范围有限,只能局限于 SDK 内部的 Module,对于业务应用层的 Module 调用跑不通。->
2)JSI
- JSI 在 C++ 层通过 JSBinding 的 NamedPropertyHandlerConfiguration 拦截调用的对象名和方法名,转发给 HostObjectProxy 进行分发
- 同时在 C++ 侧动态创建和持有 Java 层的 module 对象,被 TurboModuleManager 管理
- HostObjectProxy 将 Get/Set 的属性/方法处理后分发给 HostObject,它同时持有对应的 Java Module 对象,以此实现 JS 的 同步调用
JSI 适用于调用频繁,数据对象简单,耗时较少需要同步的场景。
四、Hippy 常用组件
1、常用 Hippy 组件
以下是 Hippy 经常使用的 标签跟终端组件的映射关系:
2、ul 复用优化
ul 的使用是非常频繁的,重点讲下它的复用逻辑:
ul 在Android 端使用的是 RecyclerView 组件,这里主要讲它的一级 Cache 缓存和三级 RecyclerPool 缓存。
- Cache 缓存:当前刚刚滑出屏幕的缓存,默认是 2个,根据 position 查找复用,可用来上下滑动时能够快速复用,被复用后可以直接拿过来使用,无需经过 Create 和 Bind
- RecyclerPool 缓存:缓存池的复用,根据 type 查找,每个 type 默认是5个,被复用后需要拿出来重新 Bind,即对 UI 根据数据重新刷新
- 未命中缓存:需要重新走 Create 和 Bind 流程
如果存在刷新时闪烁的情况,可以检查是否使用到了视频组件,此时可能会由于视频组件的 Surface 销毁创建造成闪烁。在全局数据刷新时:
由于全局刷新时是不知道哪些 item 有变更的,那么根据 position 拿屏幕上的缓存时可能由于类型不一样就会发生错误。所以它只能把屏幕上的 View 都设置为 invalid 状态,对应的 View 会被 remove 再 add,这对于带 Surface 的视频组件会监听到当前宿主发生了变化,造成销毁和创建,导致闪烁。
那么我们可以把 hasStableIds
设置为 true ,相当于强制所有 Item View 都设置了唯一的 id,这样在全局刷新时,获取屏幕上的 View 缓存,是明确知道哪些 positin 位置上的 View 完全没发生变化,就不会有 remove 和 add 的问题了。