如何设计一套纯Native动态化方案

语言: CN / TW / HK

为什么会有纯Native的动态化方案

业内很多的动态化方案都是通过JS虚拟机来实现的,好处有很多,逻辑可以实现动态化,有现成的JavaScriptCore(iOS)或者V8(Android)来做动态化引擎,能够覆盖90%的场景诉求

但是对于核心页面,比如首页Feeds,小黄车,下单,商详这类页面,通过这类动态化方案就会存在稳定性和性能问题(毕竟JS作为解释性语言以及单线程存在天然瓶颈,基于寄存器的指令集,导致内存消耗更多,异步回调也是主线程派发到工作线程处理后的消息通知机制实现,再加上bridge底层也是通过调用Native的方法来实现,还有做JS和Native的类型转换)

我用ReactNative官方demo做了些改动,机型iPhoneX,使用FlatList(RN的高性能list组件)快速滑动下帧率表现如下,快速滑动的时候最低帧率在52帧

j7t5t-mnfk5.gif

做了一个类似的Native列表,滑动表现如下,最低帧率58帧

kvazq-hvucr.gif

布局是两个label加一个imageView,同时cell根据数据来展示不同高度来模拟不定高的情况,属于非常典型的UI结构比较简单的场景。这次情况下Native和RN的性能差异也会比较明显,所以在cell结构比较复杂的情况下差异肯定会更加明显了

对比完业界通用方案后,作为ReactNative场景的补充,页面有动态化需求,且对逻辑的动态性要求没有那么高,渲染性能好的Native动态化方案也就有业务价值了

高性能的Native动态化方案一般是通过约定好的二进制文件格式,使用定制的解码器在app内将二进制文件转换成OriginTree,然后流水线生成视图树最终渲染出一个Native的View。

对比下自定义二进制以及通用文件格式的优劣

| 能力对比 | 通用文件比如JSON、XML | 自定义二进制文件 | | ------------- | -------------- | ------------------------ | | 通用性 | 是 | 否 | | 文件大小(以弹窗为例) | 17KB | 2KB | | 解析同一文件iOS耗时比例 | 6 | 1 | | 安全性 | 差 | 比较好,不知道解析规则的情况下无法获取对应内容 | | 需要额外开发环境 | 不用 | 需要前端搭建编写环境、服务端,客户端定制编解码器 | | 拓展性 | 差 | 高 |

对比以上优劣点,大型APP在资源充足的情况下往往更关注性能、安全性以及后续扩展性方面。

接下来我会大致聊聊端上相关的开发思路。

制定文件格式

我们可以参考https://zhuanlan.zhihu.com/p/20693043 进行二进制文件格式设计

客户端可以利用JSON来描述UI:

//ShopBannerComponent { "componentName": "ViewComponent", "width": "375", "height": "70", "backgroundColor": "#fff", "onClick": "customClick(mdnGetData(data.jumpUrl))", "children": [ { "componentName": "ListComponent", "width": "100%", "height": "50", "listData": "mdnGetData(data.list)", "orientation": "horizontal", "children": [ { "componentName": "TextComponent", "width": "mdnGetSubData(item.width)", "height": "mdnGetSubData(item.height)", "maxLines": "1", "textSize": "15", "textColor": "#fff", "text": "mdnGetSubData(item.content)" } ] }, { "componentName": "ImageComponent", "width": "100%", "height": "20", "contentMode": "aspectFill", "imageUrl": "mdnGetData(data.backgroudPic)" }, { "componentName": "TextComponent", "width": "44", "height": "15", "maxLines": "1", "textSize": "15", "textColor": "#fff", "text": "mdnGetData(data.desc)" } ] }

经过和后端协商定制协议后,生成的二进制文件如下:

Header(固定大小区域)

  • 标志符:也叫MagicNumber,判断是否是指定文件格式
  • MainVersion:用来判断二进制文件编译的版本号,和本地解码器版本做对比,当二进制版本号大于本地时,判断文件不可用,最大值1btye,也就是版本号不能大于127
  • SubVersion:当新增feature的时候需要升级,本地解码器根据版本做逻辑判断,最大值不能大于short的最大值32767

大的版本迭代比如1.0升级到2.0,规定必须是基于核心逻辑的升级,整个二进制文件结构可能会重新设计,这时候通过主版本号比对,假如版本号小于文件版本号,那么就直接不读取,返回为空。小的迭代比如二进制文件内新增了某个小feature,在对应SDK内部逻辑添加一个版本判断,大于指定版本就读取对应区域,使用新的feature,老版本还是能够正常使用基本功能,做到向上兼容。

  • ExtraData:预留空间,用于后续扩展,可以包含文件大小,checksum等内容,用来检验文件是否被篡改

Body

FileNameLength用于读取文件名长度,然后根据FileNameLength读取具体文件名,比如FileNameLength为19,往后读取19byte长度数据,UTF8Decode成对应文件名ShopBannerComponent

读取流程

大致流程图

参考Flutter的渲染管线机制,设置如下流程图

整个渲染流程都是在一个流水线内执行,可以保证从任意节点开始到任意节点结束

日常运用场景比如:我们在TableView里要尽快的返回Cell的高度,这时候流水线执行到MDNRenderStageCalculateFrame即可,同时会按照indexPath进行索引值Cache,后续需要返回cell的时候,取到对应indexPath的Component,后续再执行MDNRenderStageFlatten以及后面逻辑,保证每个component的各个节点只会执行一次,大致流程如下

流水线执行始终围绕在Component,只不过每道工序都会让Component更接近NativeView

就和汽车工厂里一样,最开始只有一个车架,后面通过按照引擎、零部件、喷漆等等工序最终组装成我们可以驾驶的汽车

组件解析

将本地二进制文件转化原始视图树,这个阶段不会绑定动态数据,通过全局缓存一份, 后续以Copy的形式生成对应副本,可以有效的提高性能以及降低内存,然后在副本进行数据绑定以及生成IntermediateTree

  • OriginObjectTree:直接通过二进制数据解析出来的树,全局只有一个,类似于Flutter的WidgetTree
  • IntermediateTree:通过OriginObjectTree克隆后,将数据填充进去计算布局后,然后经过层级剪枝的树,将没有点击事件以及无特殊UI效果的Node进行合并,目的是为了降低渲染树生成真实view的视图层级,减少View实例,避免了创建无用view 对象的资源消耗,CPU生成更少的bitmap,顺带降低了内存占用,GPU 避免了多张 texture 合成和渲染的消耗,降低Vsync期间的耗时
  • RenderTree:和IntermediateTree一一对应,递归生成原生View

和ReactNative类似,所有的组件都继承自基类,基类提供一些生命周期方法让子类重写

@interface MDNBaseComponent : NSObject { //子类重写测量方法 - (void)onMeasureSizeWidth:(MDNMeasureValue)widthValue height:(MDNMeasureValue)heightValue; //子类重写布局方法 - (void)onLayout:(CGRect)rect; //子类重写渲染对应的NativeView方法 - (void)onRender:(UIView *)view; //子类重写事件相关方法 - ((BOOL)onEvent:(MDNEvent *)event; //子类被加载的方法 - (void)componentDidLoad; //子类被卸载的方法 - (void)componentDidUnload;

  • 字符串存储区域存的是对应的常量、枚举、事件、方法、表达式,比如代码中宽度375 ,枚举值aspectFill,表达式mdnGetData(data.backgroudPic),这些值都会有对应的key,用于组件解析的时候进行绑定对应属性

{ "componentName": "ImageComponent", "width": "100%", "height": "20", "contentMode": "aspectFill", "imageUrl": "mdnGetData(data.backgroudPic)" }

  • 表达式区域存储的是全部用到的表达式字段,每个表达式都有对应的key,与component的属性进行关联,因为表达式可以互相嵌套,因此我们可以考虑设置成树型结构。startToken以及endToken代表表达式的开始和结束,通过遍历将表达式exprNode入栈,同时将入栈的exprNode添加到之前栈顶的exprNodechildren,形成一个单节点树,方便表达式组合使用
  • 组件区域是按照DSL代码顺序,从上往下遍历,因为Component也是可以互相嵌套,也是树形结构,通过startToken以及endToken代表一个component的开始和结束,客户端层面也是按照区域顺序读取,遇到startToken创建一个component,期间会绑定属性、事件、方法,以及动态表达式,然后入栈,遇到endToken出栈,同时设置栈顶的Component为父组件,最终得到一个ComponentOriginTree

组件动态绑定

当ViewComponent需要进行动态绑定,将表达式进行遍历扫描,以customClick(mdnGetData(data.jumpUrl))为例,在二进制文件中,会通过对应的key解析成事件表达式Node,然后mdnGetData(data.jumpUrl)在二进制文件中,解析成方法表达式Node,最后在方法表达式里data.jumpUrl会进行以下操作,大致流程如下:

这个解析流程参考了SQL的解析原理

注意:合法判断里面有很多状态切换的情况需要考虑,比如如何从上个扫描的字符串到当前扫描字符串的状态切换是合法的

  • 前一个是a-z,A-Z相关的字母,那么后面的扫描结果也只能是a-z,A-Z、[、.,假如扫描到了],就是非法的
  • 前一个是[,那么后面的扫描只能是0-9
  • 前一个是0-9,后面则只能是0-9、]

由于一个组件内肯定有大量的表达式逻辑,进行上千乃至上万次遍历是很正常的情况,这种状态判断积累的性能损耗也是很大的,因此这种状态判断逻辑最好是通过矩阵来做from到to的处理,达到优化性能的效果,经测试,随机状态执行一万次,执行时间缩短了20%

组件宽高计算&布局

绑定好最终的属性后,就可以计算组件以及子组件的宽高了,以最简单的固定宽高的父容器为例,父容器遍历子视图传递自身的约束条件,比如父容器的最大宽高,子容器根据父容器的约束来计算自身的size,然后根据DFS算法进行约束递归最终确定各个子视图的布局

拿图一的布局来做示范

计算完所有Component的布局后,就需要将无用的层级Component进行剪枝,避免渲染树层级过高,优化复杂视图结构的性能

组件渲染

当我们拿到完整的扁平树后,就可以递归生成对应Native的View了,渲染前我们需要进行diff,尽可能减少UIView的创建和销毁,有助于提升性能,尤其是在低端机且视图结构复杂的组件上,复用能降低大量的渲染时间

同时因为安卓iOS对View的操作必须在主线程,因此假如提前创建View,并对数据或者布局进行修改,会触发很多无用transcation提交,因此将数据以及frame算好后,最后只设置一次能保证性能最优

diff算法可以参考flutter的diff,通过O(n)遍历,决定每个子节点是否能被复用

diff完毕后,就是将Component对应的frame,以及事件绑定到对应的view上,比如

ViewComponent对应MDNView ListComponent对应MDNCollectionView ImageComponent对应MDNImageView TextComponent对应MDNLabelView

最后我们就得到了一个纯端上逻辑支持点击手势的动态化View啦\

参考文档:

ParseSQLToken

FlutterInside

动态界面:DSL & 布局引擎