1. UI组件树
以一段普通的代码为例,里面有个空布局,它的长宽都为父控件的80%,并设置了背景色。经过编译运行后,通过ArkUI的载入后,它的内部数据结构如图1所示。
@Entry @Component struct Index { build() { Stack() { }.width('80%').height('80%') .backgroundColor('#fff111') } }图1 UI组件树示意图
最上面的一层是根节点,所有系统组件的节点的数据结构都是FrameNode,第二层也是个FrameNode,但是整个Page的根节点,一个Ability有不同的Page组成,当不同的Page载入的时候,实际就是把自己挂在根节点下,然后渲染,这样会变成为新的页面,第三个节点则是CustomNode,即用户定义的节点,这个时候刻意看到@Entry关键字了,此时读者应该明白了,这个就是Index节点,Index下有个Stack组件。所以真正意思上来说,@Entry节点只是第三层的节点,它的上面还有两个节点,一个是根节点,当应用启动时,ArkUI框架会初始化应用UI根节点;另外一个就是Page的节点,表示当前应用加载的是哪个页面。
2. 三棵树
为了实现局部的最小化更新,老版本的ArkUI定义了三棵树模型,这三棵树分别是Component、Element和Render,它们的组合实现了数据驱动UI的最小化更新。它的核心思路是:在状态发生变化的时候,比对Element树和Component树的差异点,形成差异树,在差异树中,选取可以渲染的节点,生成Render树,最后把Render树交给渲染管线去渲染。
在UI首次创建时,会先生成Component树,基于Component树会生成最初的Element树和Render树,Render树在生成时会忽略非渲染节点对象(比如自定义的Component,本身没有显示内容,不参与渲染),这些对象会生成页面唯一的id标识,用于后续的更新。
1)树的生成
一段有@Component注解的代码,在经过编译后,最后到ArkUI后,首次会生成一颗树形结构,这个就是Component树,基于Component树,复刻一个树,就是Element树。记住,这是首次创建的时候,如下代码就会生成一颗如图2的一棵树。
@Component struct MyComponent { @State needShowSecond: boolean = true @State message: string = "hello world" build() { Column() { Text(this.message) if (this.needShowSecond) { Text(this.message) } } } }图2 MyComponent生成的Component树
可以看出,needShowSecond这个变量此时是true,所以if判断条件成立,Column容器下有两个子节点,其中右边的子节点是依据该变量从而决定是否显示。同时可以看出,build()函数的里面的层级决定了树的深度,所以在了解清楚这个原理后,读者在后期的开发中,一定要能尽量降低层级,减少树的深度,从而提高树的遍历效率,这样后期的渲染会更加高效。
上述只是一个简单的自定义组件的树生成情况,那么带到一个完整的页面中,除了要生成树的结构之外,还需要对每一个节点做Id的标记,这样每个节点可以唯一被表示。从UI根节点开始,完整的生成的Component树如图3所示。同时在ArkUI还会克隆出一颗一模一样的Element树,从标号1开始,就是build()里面的Column容器,每深入一个层级,标号就多一位,兄弟层级之间,尾号加1,为了后面方便讲解。后续的节点树都从build()函数的第一个容器开始,省去根节点到@Entry节点,它们不是消失了,只是不展示,因为同一个页面的刷新,不涉及到它们,减少整体展示图的大小。
@Component struct MyComponent { @State needShowSecond: boolean = true @State message: string = "hello world" build() { Column() { Text(this.message).onClick(() => { this.needShowSecond = !this.needShowSecond }) if (this.needShowSecond) { Text(this.message) } } } } @Entry @Component struct Index { @State message: string = 'Hello World' build() { Column() { Row() { Stack() { Text(this.message) } } MyComponent() } } }图3 Index页面的完整树形图
那么第三棵树,渲染树是怎么样的呢?之前提过,渲染树会删除一些非渲染的节点,也就是1-2编号的节点,它的作用是支撑起完整的树形结构,在ArkUI里面,这样的节点称为Composed对象(非渲染节点),Composed对象会生成页面唯一的id标识,用于后续的更新。当1-2节点因为是Composed对象,所以被渲染树移除后,原来的1-2-1节点,也就是MyCompoent里面的Column节点,它的层级就会上升,成为渲染树中的1-2节点,如图4所示。至此三棵树在初始阶段创建完成。
图4 Index页面创建时所对应的渲染树
2)树的更新
首次创建的时候,树一定是全量的,如果此时点击了MyComponent里面的第一个Text,就会把needShowSecond取反,因为@State的变化了,就会触发MyComponent的刷新,此时Component树的结构发生了变化,1-2-1-2节点被删除了,它就会和原来的旧的Element树进行比较,生成新的Render树,此时可以看到,Render树比较小,只有两个节点要更新,那么此时只需要对这两个节点渲染即可,其它节点不需要动,从而实现了最小化刷新。同步地,旧的Element树也更新为新的Element树了。其流程示意如图5所示。
图5 三颗树的更新流程示意图
纵观整个例子,读者可以发现,整个的思路是非常清晰的,即不断比对新旧两棵树的差异,生成渲染树,最后交给渲染管线去渲染即可。
3)小结与思考
对上述几棵树的功能做个小结,三棵树分别承担着不同的责任:
(1)Component树:
每次创建或者更新时都会重新生成相应的子树结构,成员方法提供了创建Element和Render节点,成员变量保存相应的属性值。
(2)Element树:
维持UI组件树形结构,承载计算树之间差异的任务,在新的Component子树生成并请求更新时,会基于老的树形结构进行差异计算,来实现树形结构更新和渲染节点属性更新。
(3)Render树:
承载布局渲染任务,保存Component结构中的属性值,基于保存的属性值驱动内容布局和渲染。
进一步思考:
然而,读者也应该有一个感觉,这三棵树的渲染机制,虽然能解决最小化刷新问题,但是也会带来一些缺陷:
(1)对于每个页面,都有三棵树,会带来额外的内存的开销,其中Component树和Element树高度相似,略有冗余。
(2)在列表滑动等实时性要求严苛的场景下(如快速滑动列表,这也是自媒体非常喜欢测试流畅度的场景之一),列表动态创建相应的列表项时需要创建更多的对象(Component、Element和Render,同时属性值也需要进行多次拷贝赋值,属性值在创建Component组件时会先复制到Component内的成员中,在Element和Render节点创建完成后,上述的属性值会再次拷贝到Render节点中以便进行内容布局和绘制),复杂场景下可能带来帧率的下降。
所以如何砍掉多余的树,也同时能够保持最小化刷新,将是要考虑和解决的难题!