How to design a pure Native dynamic solution

Why there is a pure native dynamic solution

Many dynamic solutions in the industry are implemented through JS virtual machines. There are many benefits. The logic can be dynamic. There are ready-made JavaScriptCore (iOS) or V8 (Android) as the dynamic engine, which can cover 90% of the scenes. appeal

But for core pages, such as home page Feeds, small yellow car, ordering, business details and other pages, there will be stability and performance problems through such dynamic solutions (after all, JS, as an interpreted language and single thread, has a natural bottleneck, The register-based instruction set leads to more memory consumption. Asynchronous callback is also realized by the message notification mechanism after the main thread is dispatched to the worker thread for processing. In addition, the bottom layer of the bridge is also realized by calling the Native method, as well as JS and Native. type conversion)

I made some changes with ReactNative's official demo. The model is iPhoneX, and the frame rate is as follows when using FlatList (RN's high-performance list component) to quickly slide. The minimum frame rate is 52 frames when quickly sliding.

j7t5t-mnfk5.gif

Made a similar Native list, the sliding performance is as follows, the minimum frame rate is 58 frames

kvazq-hvucr.gif

The layout is two labels plus an imageView, and the cell displays different heights according to the data to simulate the situation of variable height, which is a very typical scene with a relatively simple UI structure. In this case, the performance difference between Native and RN will be more obvious, so the difference will definitely be more obvious when the cell structure is more complicated.

After comparing the common solutions in the industry, as a supplement to the ReactNative scene, the page has dynamic requirements, and the dynamic requirements of the logic are not so high. The native dynamic solution with good rendering performance has business value.

The high-performance native dynamic solution generally uses the agreed binary file format, uses a custom decoder to convert the binary file into OriginTree in the app, and then pipelines the generated view tree to finally render a Native View.

Compare the pros and cons of custom binary and general file formats

Ability comparison Common files such as JSON, XML custom binaries
generality Yes no
File size (take pop-up window as an example) 17KB 2KB
Time-consuming ratio of parsing the same file iOS 6 1
safety Difference Better, you can't get the corresponding content without knowing the parsing rules
Requires additional development environment Need not 需要前端搭建编写环境、服务端,客户端定制编解码器
拓展性

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

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

制定文件格式

我们可以参考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、[、.,假如扫描到了],就是非法的
  • The previous one is [, then the subsequent scan can only be 0-9
  • The former is 0-9, the latter can only be 0-9,]

Since there must be a large amount of expression logic in a component, it is normal to perform thousands or even tens of thousands of traversals, and the performance loss accumulated by this state judgment is also very large, so this kind of state judgment logic is best through the matrix. To do the processing from to to, to achieve the effect of optimizing performance, after testing, the random state is executed 10,000 times, and the execution time is shortened by 20%

Component width and height calculation & layout

After binding the final properties, you can calculate the width and height of the component and child components. Taking the simplest fixed width and height parent container as an example, the parent container traverses the child views to pass their own constraints, such as the maximum width of the parent container. High, the child container calculates its own size according to the constraints of the parent container, and then performs constraint recursion according to the DFS algorithm to finally determine the layout of each child view

Take the layout in Figure 1 as an example

After calculating the layout of all components, it is necessary to prune the useless hierarchical components to avoid the rendering tree level being too high and optimize the performance of complex view structures

Component rendering

When we get the complete flat tree, we can recursively generate the View corresponding to the Native. Before rendering, we need to diff to reduce the creation and destruction of UIView as much as possible, which helps to improve performance, especially in low-end machines and views. On complex components, reuse can reduce a lot of rendering time

At the same time, because the operation of the View on Android and iOS must be done on the main thread, if you create a View in advance and modify the data or layout, it will trigger a lot of useless transcation submissions. Therefore, after calculating the data and frame, you can only set it once to ensure performance. optimal

The diff algorithm can refer to flutter's diff, and determine whether each child node can be reused through O(n) traversal

After the diff is completed, the frame corresponding to the Component and the event are bound to the corresponding view, such as

ViewComponent对应MDNView
ListComponent对应MDNCollectionView
ImageComponent对应MDNImageView
TextComponent对应MDNLabelView
复制代码

Finally, we get a dynamic View that supports click gestures with pure logic on the end.\

Reference documentation:

ParseSQLToken

FlutterInside

Dynamic Interface: DSL & Layout Engine

Guess you like

Origin juejin.im/post/7119781637440667678