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

语言: CN / TW / HK

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

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

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

我用ReactNative官方demo做了些改动,机型iPhoneX,使用FlatList(RN的高性能list组件)快速滑动下帧率表现如下,快速滑动的时候最低帧率在52帧(PS:掘金不支持gif宽高编辑😓,只能先这么大了)

j7t5t-mnfk5.gif

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

kvazq-hvucr.gif

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

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

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

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

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

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

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

制定文件格式

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

自定义前端用法:

``` //ShopBannerComponent



```

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

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

``` //读取文件名长度 long length = [self readTextLengthWithLength:1]; //读取指定长度然后转换成string NSString *fileName = [self readStringWithLength:length]:

  • (long)readTextLengthWithLength:(int)length { unsigned char result = 0; if (self.location <= (int)(self.data.length - length)) { [self.data getBytes:&result range:NSMakeRange(self.location, length)]; self.location += length; } return (long)result; }

  • (NSString )readStringWithLength:(int)length { NSString result = nil; if (length > 0 && self.location <= (int)(self.data.length - length)) { NSData *data = [self.data subdataWithRange:NSMakeRange(self.location, length)]; result = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; self.location += length; } return result; } ```

大致流程图

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

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

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

typedef NS_ENUM(uint32_t, MDNRenderStage) { MDNRenderStageParse, // 解析模板 MDNRenderStageBind, //绑定数据 MDNRenderStageCalculateFrame, // 计算布局 MDNRenderStageFlatten, // 扁平化 MDNRenderStageDiff, // diff MDNRenderStageRender, // 渲染 }; int from = MDNRenderStageParse MDNRootView *olderView; for (; from <= to; from++){ switch(from){ case MDNRenderStageParse :{ //将二进制转化成原型树 component = [_binaryParser parseData:binaryData]; } break; case MDNRenderStageBind:{ //将数据绑定到未处理的扁平树上 [_dataBinder bindData:data component:component]; } break; case MDNRenderStageCalculateFrame { //计算frame [_layoutManager layout:component]; } break; case MDNRenderStageFlatten { //层级扁平,生成真正的扁平树 [_layoutManager flatten:component]; } break; case MDNPipelineStageDiff{ //diff [_differ diff:component olderView:olderView]; } case MDNRenderStageRender { //生成渲染树 [_renderManager render:component]; } break; } }

组件解析

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

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

SDK需要内置大量基础UI组件,满足日常开发需求,比如Text、Image、List、ScrollView、Switch,不同类型的Component布局策略不一样,同时也支持不同业务方扩展业务组件,通过继承自基类比如MDNBaseComponent,重写相关方法

``` //通过4byte大小的32位数来保存约束模式以及约束值,好处是省了两个property的内存占用(16byte),相比于通过msgSend,位运算性能更佳 //在复杂视图中体现的内存优化更明显 //前30位存值,后2位存约束模式 //缺点是可读性比较差 typedef int32_t MDNMeasureConstraint;

//每个property对应全局唯一的id,并且用数值来进行比对,占用空间更小,性能更佳 typedef int32_t MDNHashID;

define MDNMeasureConstraintShift 30 //32-2偏移距

define MDNMeasureConstraintMask (int32_t)(0x3 << MDNMeasureConstraintShift) //1100000....(-1073741824)

define MDNMeasureConstraintMaxValue (int32_t)(INT32_MAX & ~MDNMeasureConstraintMask) //001111111...

typedef NS_ENUM(int32_t, MDNMeasureConstraint) { MDNMeasureConstraintUnspecified = 0 << MDNMeasureConstraintShift, // 不受父容器约束、可以大于父容器 MDNMeasureConstraintMatchParent = 1 << MDNMeasureConstraintShift, // 受父容器约束,小于等于父容器 MDNMeasureConstraintExactly = 2 << MDNMeasureConstraintShift, // 固定值 };

typedef NS_ENUM(MDNHashID, MDNPropertyType) { MDN_height = 1111111, MDN_width = 22222, MDN_backgroundColor = -123456, ... }

@interface MDNBaseComponent : NSObject { //尽量减少不必要的property,降低包大小 @public MDNMeasureConstraint _widthConstrains; //宽度约束,用于根据父容器来算宽高 MDNMeasureConstraint _heightConstrains; int measuredWidth; //经过数据绑定后以及父容器约束后算出来的宽度 int meaturedHeight; int width; // 0且有动态表达式:根据数据动态计算, -1:代表match_content -2:代表match_parent >0:代表固定值 int height; MDNBaseComponent parent; //父组件 NSArray * childrens; //子组件数组 UIColor backgroundColor; //背景色 }

//输入宽高约束,得到计算后的宽高 - (void)onMeasureSizeWithWidthConstraint:(MDNMeasureConstraint)widthConstrains heightConstrains:(MDNMeasureConstraint)heightConstrains; //输入计算好的父类宽高,计算组件的具体位置 - (void)onLayout:(CGRect)rect; //创建Native的时机 - (void)onCreate; //渲染NativeView的时机 - (void)onRender:(UIView )view; //收到事件的回调,子类可拦截 - ((BOOL)onEvent:(MDNEvent )event; //设置对应属性值 - (void)onSetPropertyWithID:(MDNPropertyType)property value:(id)value; ```

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

<ImageComponent width="375" height="20" resizeMode="cover" imageUrl="@data{data.backgroudPic}"/>

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

组件动态绑定

当ViewComponent需要进行动态绑定,将表达式进行遍历扫描,以@customClick{@data{data.jumpUrl}}为例,在二进制文件中,会通过对应的key解析成事件表达式Node,然后@data{data.jumpUrl}在二进制文件中,解析成方法表达式Node,最后在方法表达式里data.jumpUrl会进行以下操作,伪代码如下

``` typedef NS_ENUM(NSInteger,CurrentTokenState) { CurrentTokenStateChar = 0, //英文字符 CurrentTokenStateNum, //数字 CurrentTokenStateDot, // . CurrentTokenStateOpenSquareBrackets, // [ CurrentTokenStateCloseSquareBrackets, // ] CurrentTokenStateDone, //处理完成 CurrentTokenStateError, //处理报错 };

static int transitionArray[7][7]={ // c n . [ ] E D {1,0,1,1,0,1,1}, //char {0,1,0,0,1,1,0}, //num {1,0,0,0,0,0,0}, //. {0,1,0,0,0,0,0}, //[ {0,0,1,1,1,1,1}, //] {0,0,0,0,0,1,0}, //Error {0,0,0,0,0,0,0} //Done };

static int stateTransitionValid(CurrentTokenState form,CurrentTokenState to) { return transitionArray[form][to]; }

-(id)getData:(NSString )expr data:(NSDictionary )curData NSString *exprStr = @"data.jumpUrl";//表达式 int left = 0; CurrentTokenState cState; for (int i = 0; i < exprStr.length;cState != CurrentTokenStateError; i++) { switch (exprStr[i]) { case '.': //假如是.,就直接取对应的key,这类忽略了大量的边界处理 { if(!stateTransitionValid(cState,CurrentTokenStateDot)){ cState = CurrentTokenStateError
} else { curData = [curData objectForKey:exprStr.subStr(left,i)]; cState = CurrentTokenStateDot; } } break; case '[':{ //假如是[,说明是取的数组,先取对应的值,再直接重新计算leftIdx curData = [curData objectForKey:exprStr.subStr(left,i)]; cState = CurrentTokenStateOpenSquareBrackets; left = i+1; } break; case ']': //假如是],说明数组取值逻辑结束,直接取对应索引值,需要注意边界处理 curData = curData[exprStr.subStr(left,i).toInt]; cState = CurrentTokenStateCloseSquareBrackets; break; default: { //再分别判断是字母还是数字 left++; } break; } id ret if(stateTransitionValid(cState,CurrentTokenStateDone){ ret = [curData objectForKey:exprStr.subStr(left,i)]; } } return ret } ```

tips:事件表达式就是比如点击、组件出现、scroll这类表达式,方法表达式就是对数据进行处理的表达式,可以通过继承相关类进行业务逻辑定制处理

我写的伪代码逻辑相对简单,但是里面有很多状态切换的情况需要考虑,比如如何从上个扫描的字符串到当前扫描字符串的状态切换是合法的

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

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

组件宽高计算&布局

绑定好最终的属性后,就可以计算组件以及子组件的宽高了,以最简单的固定宽高的父容器为例,伪代码如下:

//计算frame,其实会有三种情况 //- 组件以及子组件宽高固定 //- 组件宽高取决于子组件 //- 子组件宽高取决于父组件 + (void)calculteSize:(ViewComponent *)viewComponent maxWidth:(CGFloat)maxWidth maxHeight:(CGFloat)maxHeight{ for (int i = 0; i <viewComponent.childrens.count; i++) { (ViewComponent *)childComponent = viewComponent.childrens[i]; //让子类根据父类宽高来计算宽高 childComponent.measuredWidth = [childComponent.class calculteWidth:childComponent maxWidth:viewComponent.width]; childComponent.measuredHeight = [childComponent.class calculteWidth:childComponent maxHeight:viewComponent.height]; } //当父容器需要根据子容器内容来决定尺寸,再重新根据子组件宽高计算自身宽高 if(viewComponent.matchContent){ [self recalculateWithChildren:viewComponent.children]; } //当容器需要和父容器保持一致 else if (viewComponent.matchParent){ self.measuredWidth = maxWidth; self.measuredHeight = maxHeight; } else { self.measuredWidth = self.width; self.measuredHeight = self.height; } }

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

+ (void)flattenComponent:(ViewComponent *)component parentX:(CGFloat)parentX parentY:(CGFloat)parentY rootComponent:(ViewComponent *)root { BOOL needFlatten = YES; //背景色、有事件、边框颜色则不扁平化 if (component.backgroundColor || component.events.count || component.cornerRadius || component.borderWidth) { needFlatten = NO; } if (needFlatten) { //重新连接节点 component.parent.childrens = component.childrens; component.childrens = nil; //重新计算x,y,然后递归扁平树 for (ViewComponent *childComponent in component.parent.childrens) { childComponent.x = component.x + childComponent.x; childComponent.y = component.y + childComponent.y; [self flattenComponent:childComponent parentX:childComponent.x parentY:childComponent.y rootComponent:root]; } } }

组件渲染

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

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

``` + (void)diffWithNewComponent:(ViewComponent )newComponent oldComponent:(ViewComponent )oldComponent { //当新节点没有值,但是老节点有值,直接移除老节点的view,然后复用 if (!newComponent.childrens.count && oldComponent.childrens.count) { [oldComponent.view.subviews makeObjectsPerformSelector:@selector(removeFromSuperview)] return; } // 需要一个IndexInfo保存Component对应ID //positonArray确认newComponent对应位置是否在oldComponent有值 NSMutableDictionary indexDic = [NSMutableDictionary dictionary]; NSMutableArray positonArray = [NSMutableArray array]; int idIndex = 0; for (ViewComponent *childComponent in newComponent.childrens) { indexDic[@(childComponent.componentId)] = @(idIndex++); } for (int i = 0; i <newComponent.childrens.count; i++) { [positonArray addObject:@(-1)]; }

for (int i = 0; i < oldComponent.childrens.count; i++) {
    ViewComponent *childComponent = oldComponent.childrens[i];
    NSNumber *idIndex = @(childComponent.componentId);
    NSNumber *newIndexNum = indexDic[idIndex];
    //说明新老不一致
    if (!newIndexNum) {
        [childComponent.view removeFromSuperView];
    }
    else{
        //更新索引
        positonArray[newIndexNum] = @(i);
    }
}

//找到没有被oldComponent对应的位置,如果有,那么是新增节点,后续去render,否则复用
for ( i = 0;; i < newComponent.childrens.count; ++i) {
    NSInteger oldIdx = [positonArray[i] integerValue];
    ViewComponent *newChild = newComponent.childrens[i];
    if (oldIdx == -1) {
        continue;
    }
    ViewComponent *oldChild = oldComponent.childrens[oldIdx];
    // 这里直接把旧节点的view拷贝到新节点
    newChild.view = oldChild.view;
    if (newChild.children.count || oldChild.children.count) {
        [self diff:newChild oldWidget:oldChild];
    }
}

} ```

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

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

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