weex新版Layout引擎以及渲染逻辑探究
一、背景
原来weex sdk使用Facebook yoga进行基础css布局,但是由于开源协议问题选择基于Google的FlexboxLayout了自研,此处按下不表。
一言以蔽之,Layout引擎目的是通过递归的方式将节点的css属性约束析构,然后计算出节点正确的位置等基础属性。也就是说需要先明确一点,Layout引擎只负责计算外部传进来一棵节点树,仅此而已。
而想要研究整体的渲染机制,单是Layout引擎远远不够,其中最起码还包括:脏节点染色机制、更新机制、特殊节点处理、多属性共同决定同一属性的优先级等等。本文只是我近两周在解决上百个Layout引擎内核bug时管中窥豹有所得,部分细节处有感兴趣的同学可以一同讨论。
二、渲染机制
如果是要表述整套渲染逻辑,涉及的细节处就未免太多太琐碎了,因此,在这里我只选取部分重要的实现机制来讲讲这些机制是如何运作,又是如何配合将渲染工作处理完成的,同时也带出我自己的一些思考,便于后续研究和探讨。
1、基于节点的flex layout渲染引擎
渲染引擎包括布局的核心计算,主要任务是计算某个节点树。输入某个节点A,输出整棵以节点A作为根节点的节点树。
抽象来看,计算某节点的伪代码如下:
void calculateLayout(node){
initBFCContext(); //将node节点下子节点划分为BFC列表和非BFC列表
measureNonBFCsIfDirty(); //如果node被染色需要重新计算,则计算子节点中所有非BFC的布局
layout(left,top,right,bottom); //设置自己的大小并布局非BFC子节点的位置
for(childNode in BFCList){
calculateLayout(childNode); //递归计算node节点下划分为BFC的子节点
}
}
简单来说,BFC是一个不受父亲flex属性影响的块结构 ,详细概念可见这篇文章。
由此可见,节点的布局是通过计算NonBFC列表中子节点属性以及自己本身的属性约束决定的。而BFC因为不影响父亲的布局,也不受父亲flex属性影响,因此,递归可得BFC下子树的布局。
所以计算自身节点的布局主要就看measureNonBFCsIfDirty这个方法中怎么处理的。
但是因为css中属性本身就不是单一属性决定布局的,而是由自己的属性、父亲的属性、孩子的属性等共同决定的,因此measureNonBFCsIfDirty只是靠一次遍历递归计算很难得到正确的布局。因此,measure方法的结构思路如下:
void measure{
//初步根据节点的大小约束属性递归计算各个子节点的大小,此时获得子节点因为自身属性得到的布局。
recursiveMeasureDirtySubNodes(width,height);
if(dirty){
//如果在上一步由于子节点计算使得父布局发生变化被染色,则需要计算布局内子节点是否收到新布局的约束
recursiveCheckChildConstrainSize();
}
//根据子节点计算当前节点维持的flexLine列表的主轴和侧轴长度最终长度,并根据flexLine列表判断子节点是否需要进行布局扩展。
determineFlexLinesSizeAndExpandChild();
//递归计算被染色的子节点。
recursiveMeasureDirtySubNodes(width,height);
}
其中flexLine就是flexbox布局中的主轴概念,如下图所示:
父亲div1中有4个子节点,布局方式是横向布局,虚线部分是flexline,可以理解为容纳子节点主轴上的单排虚拟容器,此时,因为子节点横向布局时受到父布局的宽度限制,因此会自动产生新的flexline以容纳超出的子节点。
因此,measure方法主要包括了两次计算。第一次递归计算了子节点布局,并从叶子节点往上计算得到父节点的flexline列表。然后根据计算中父节点的统计的flexline列表得到父节点自己的布局并检查子节点是否需要扩展布局,需要则染色。然后第二次递归计算所有被染色的子节点得到最终布局。
至此,基本上整体递归的框架就出来了,所需的就是将flex中的属性布局规则按照优先级填充进框架中,完善recursiveMeasureDirtySubNodes的计算逻辑,在此就不多赘述,对flex属性感兴趣的可以参考w3c标准。
2、特殊节点处理
对于div而言,本身的布局就是由自身style、父亲的flex限制和孩子的大小综合得到的,但是很多特殊节点的计算方式有些许不同,这种特殊节点的计算方式并不会记入Layout引擎中,因此Layout引擎提供了外部传入measureFunc方法参与到节点计算的形式。下面挑几个大致讲一下:
text
text组件一般作为叶子节点,但是绝大多数时候,它是由attributes中value的值和style中的lines等属性决定布局的,因此,textComponent中需要将attributeString的计算方式的方法指针传入Layout引擎中,先根据传入的value得到文字布局,然后再根据自身style和父亲约束决定最终布局。
list
在iOS中,list是一个非常特殊的组件。因为list组件的底层实现是UITableView,对于tableView而言,系统提供了一整套复用机制和cell管理机制,因此上层不能简单的手动控制每个cell的布局,而是必须通过tableview的delegate委托事件进行控制。
因此,如果将list产生的子节点树根据component树逻辑加入到根节点下,由Layout引擎控制布局,则会与系统原生tableView布局机制冲突,失去复用甚至布局错乱。但是,list下面的cell或者cell内部元素同时也是使用css属性布局的,也应该满足Layout引擎的逻辑规则。
那么,两者需要怎么兼容呢?需求上是ListComponent既要利用Layout引擎计算整体子树的布局,但是又不需要从页面的根节点递归控制到cell的大小位置,因此,思路如下图:
原有逻辑是将WXComponentManager管理下的node节点(也就是weexInstance下根节点)作为输入填进Layout引擎,从而计算出整张图的布局,并由WXComponentManager管理的Component树递归布局好。
而新逻辑则是设置标识位,让listComponent原来持有的node节点变成root节点下的叶子节点,原来的cell节点不加入到root节点树下,布局不会受到WXComponentManager的直接控制。ListComponent会新建一个新节点作为根节点,传入Layout引擎中获取了list下整个node树的布局(普通的component只维护一个node,ListComponent会产生两个node)。然后ListComponent将计算完毕的list node树委托给系统的UITableView的回调事件进行处理,由系统维护cell的初始化、复用、销毁。
这样做有两个好处:
- 使用原生的机制,tableView不会一次性将所有cell渲染出来,节省开销。
- 使用原生tableView,不需要重复实现tableView本身的各种复杂的机制。
但是这种实现方式还有局限:
- cell的使用还只是往上堆叠WXView,并没有真正发挥cell的复用性。
- 因为分割了node树,因此component树和node树并不是完全对应的,逻辑性有割裂,可能导致后续更新Layout引擎布局逻辑时,组件需要因此不断打补丁。
但是,还是不得不说,现有的方案,在不需要重新实现一套tableView的情况下用最小的代价完成了整体的布局逻辑,还是比较巧妙的。
embed
WXEmbedComponent是一个异类。它是weex sdk提供的组件中唯一一个自身持有了一个WXSDKInstance的组件,这就意味着它本身即是dom树下的一个子节点,同时也是一个weex容器。它在设计之初就是为了实现weex页面的嵌套。因为在现有业务中大量使用到tab形式实现会场页面,这种页面最大的特点就是tab作为导航,tab下有完整的整套页面逻辑,因此最适合使用页面嵌套的形式处理。
因此传入一个页面的url,embed组件就能够通过内部的WXSDKInstance将子页面渲染出来并加载到自身节点上。
3、脏节点染色机制
随着界面的复杂度不断增加,整个节点树的复杂度会不断增加,因此刷新一次节点树的布局耗费的时间成本就会不断上升,此时,如果因为某个子节点或者叶子节点属性发生变化,触发刷新的时候重新遍历计算了整一棵节点树明显不合适,会产生很多兄弟节点的重复计算。因此,必须有标志标示需要更新的节点,这就是脏节点染色机制。
现有的染色规则下,当节点A产生以下行为时会将节点A染色并递归染色此分支上节点A的所有直系祖先节点:
- 初始化节点style
- 移除或者增加节点下的子节点
- 更新了margin、padding、border、left、right、top、bottom、width、height等布局属性
- 更新了position、flexDirection、flexWrap、alignItems、flex等布局方式
但是,上述四点只是通用的节点染色机制,对于特殊节点,例如Slider更新数据、text更新value等内容发生变化也会导致节点被染色。
4、更新机制
首先得先说明一下整个weex大致的运作,weex页面是由WXSDKInstance的实例控制整个生命周期的,而管理页面下整个component树以及node树的是WXSDKInstance实例中WXComponentManager对象。
WXComponentManager实例中持有了一个CADisplayLink对象,本质上就是持有了一个计时器,这个计时器与屏幕的绘制刷新保持同步,即正常流畅情况下1/60s的频次。在WXComponentManager对象实例被创建的时候它就会将CADisplayLink对象创建出来并加入到当前的RunLoop中,以每秒60次的频率检测当前component列表中是否有节点被染色。如果有,将根节点传入Layout引擎中,重新计算当前布局中被染脏的节点以及对应的影响,然后视图树重新布局作为事务传入UI事件队列中排队处理。关于weex事件队列,下一节会有讲述。如果UI事件队列中1s内没有任何事务传入,CADisplayLink将会暂停,直到下次触发ComponentTask才会被唤醒。
如下图:
这套机制保证了被染色的脏节点能够及时被处理,同时又不会因为加入了CADisplayLink导致当前RunLoop一直在空转中。
5、异步与时序
在weex中,计算和触发更新是一个频繁进行的操作,如果这个时候在主线程中进行操作,很有可能使得主线程卡顿。同时,如果此时主线程已经被占用,事件的更新就会被阻塞,因此需要异步处理JsContext传递的更新事件。
但是仅仅使用GCD异步处理容易造成时序混乱的问题,因此,需要底层自身维护一个子线程用与事件的异步线性化处理。
weex本身维护了一个component线程,计算相关都在此线程完成,包括递归生成component树和node树、计算节点树布局等。可以说,主体任务都是由component线程做处理的,当conponent线程处理完毕后得到整个布局树后会切到UI线程进行布局设置。
三、现有机制的问题以及一些思考
只向上对父亲以及祖先染色合适么
现在的染色逻辑是对上的,假设发生更新的是节点A,在染色自身后A会逐级向父亲节点染色,然后再由根节点逐级向下计算被染色节点布局更新。但是问题在于,此时的计算会涉及到发生更新的节点A,A的直系祖先节点,或者A的父亲更新时影响到A的兄弟节点的布局,但是,对于A的子孙节点就无法被更新到了。如果此时A里面嵌套了多层,某个子孙节点受到A的布局影响,A更新了,但是无法使得此节点得到更新,因为A的子节点判断没有被影响,A的更新逻辑就截止了。
这个问题的情况出现在我的几个bug排查记录中,有兴趣的可以看看,也可以找我讨论下。这个问题暂时没有找到好的解法,前端同学最好还是要注意不要写太多层级嵌套,并且约束是跨越好几层的这种情况,避免踩坑。
如何节约运算
现有的机制下,因为flex属性,所以必须使得产生两次节点树的运算,咋一看,这种计算好像是无法避免的,因为需要统计flex约束,就必须先统计各个子节点的布局,然后根据flex属性定义更新子节点布局约束,然后再重新计算节点布局。
但是换个角度思考,这种无法避免的原因是在于基础属性和flex属性不是在一个维度的东西,或者说,基础属性是定值,而flex属性更像是一种“约束”。约束依靠定值才能产生作用。
但是,能不能将这种“约束”换成某种规则的组合,然后在第一次计算的时候就使得约束也能够计算进去呢?甚至是不是在这种规则下,在组件更新时候依照这种规则的组合可以直接更新到对应组件,不需要计算没有被“约束住”的组件呢?这块暂时只有个想法,具体还没有实现。有兴趣的可以找我讨论下。
node树和component树分离带来的问题
虽然因为一些特殊节点定制化的原因(例见上文list组件),node树和component树出现了不一致的情况。这种情况现在由于定制化所以单独处理了,但是这种分离助长了组件的特异性,出现了子Component单独管理一棵节点树的情况,而Manager此时并不能获取到整棵节点树,给后来属性扩充埋下了兼容性的隐患。
我觉得此时是不是可以利用代理模式,将系统原生的代理传递给cell这种形式,用以维持node树的完整性呢。毕竟node树只是布局树,不应该因为视图的使用方式不同将node树截断或扭曲。想来可行性应该挺高的。
逻辑的碎片化与规则的可插拔性
现有w3c的css属性规则太复杂了,尤其是组合逻辑,造成Layout引擎的逻辑非常难以维护,并且牵一发动全身,应该用规则插件化的思想结构化这些规则,或者大部分规则,这样可读性和维护性应该都能提高,甚至单规则或者组合规则的单测都好做很多,可作为后期Layout架构优化的方向。
四、结尾
weex还在不断更新中,网络上类似的跨平台布局解决方案也在不断丰富中,这块探究其实还缺少个各个平台的对比,后续再开系列吧。