阿里妹导读:谈到移动端开发,大家心中肯定会涌现出一系列名词:iOS、Android、Weex,H5... 那为何还使用 Flutter?其实,Flutter 通过自建绘制引擎,具备与 Native 媲美的性能指数,且有很好的两端一致性,因此 Flutter 提供了一种新的可选项。闲鱼宝贝详情页实践上线也证明了这点,可以在性能无损前提下降低 iOS&Android 开发成本。
本文由闲鱼技术团队出品。它将为你深入介绍 Flutter framework 关于视图树的创建与管理机制、布局、渲染的原理,以及 Flutter 布局与渲染相关性能优化的设计思路的文章。同时介绍在使用 Flutter 开发过程中,遇到的一些坑和相应的解决方案。
Flutter 框架简介
-
跨平台应用的框架,没有使用 WebView 或者系统平台自带的控件,使用自身的高性能渲染引擎(Skia)自绘。
-
界面开发语言使用 dart,底层渲染引擎使用C, C++。
-
组合大于继承,控件本身通常由许多小型、单用途的控件组成,结合起来产生强大的效果,类的层次结构是扁平的,以***化可能的组合数量。
Rendering Pipeline
本文主要介绍 build、layout、paint 的三个阶段。
视图树
Widget&Element&RenderObject
Flutter 视图树包含了三种树,上图只是介绍了三颗树的基础 class 的对应关系和功能介绍。
创建树
-
创建 widget 树
-
调用 runApp (rootWidget),将 rootWidget 传给 rootElement,做为 rootElement 的子节点,生成 Element 树,由 Element 树生成 Render 树
-
Widget:存放渲染内容、视图布局信息,widget 的属性***都是 immutable (如何更新数据呢?查看后续内容)
-
Element:存放上下文,通过 Element 遍历视图树,Element 同时持有 Widget 和 RenderObject
-
RenderObject:根据 Widget 的布局属性进行 layout,paint Widget 传人的内容
更新树
★为什么 widget 都是 immutable?
Flutter 界面开发是一种响应式编程,主张 simple is fast,Flutter 设计的初衷希望数据变更时发送通知到对应的可变更节点(可能是一个 StatefullWidget 子节点,也可以是 rootWidget),由上到下重新 create widget 树进行刷新,这种思路比较简单,不用关心数据变更会影响到哪些节点。
★widget 重新创建,element 树和 renderObject 树是否也重新创建?
widget 只是一个配置数据结构,创建是非常轻量的,加上 Flutter 团队对 widget 的创建/销毁做了优化,不用担心整个 widget 树重新创建所带来的性能问题,但是 renderobject 就不一样了,renderobject 涉及到 layout、paint 等复杂操作,是一个真正渲染的 view,整个 view 树重新创建开销就比较大,所以答案是否定的。
★树的更新规则
-
找到 widget 对应的 element 节点,设置 element 为 dirty,触发 drawframe, drawframe 会调用 element 的 performRebuild ()进行树重建
-
widget.build () == null, deactive element.child,删除子树,流程结束
-
element.child.widget == NULL, mount 的新子树,流程结束
-
element.child.widget == widget.build () 无需重建,否则进入流程5
-
Widget.canUpdate (element.child.widget, newWidget) == true,更新 child 的 slot,element.child.update (newWidget)(如果 child 还有子节点,则递归上面的流程进行子树更新),流程结束,否则转6
-
Widget.canUpdate (element.child.widget, newWidget) != true(widget 的 classtype 或者 key 不相等),deactivew element.child,mount 新子树
注意事项:
-
element.child.widget == widget.build (),不会触发子树的 update,当触发 update 的时候,如果没有生效,要注意 widget 是否使用旧 widget,没有 new widget,导致 update 流程走到该 widget 就停止了。
-
子树的深度变化,会引起子树重建,如果子树是一个复杂度很高的树,可以使用 GlobalKey 做为子树 widget 的 key。GlobalKey 具有缓存功能。
★如何触发树更新
-
全局更新:调用 runApp (rootWidget),一般 flutter 启动时调用后不再会调用。
-
局部子树更新, 将该子树做 StatefullWidget 的一个子 widget,并创建对应的 State 类实例,通过调用 state.setState () 触发该子树的刷新。
Widget
StatefullWidget vs StatelessWidget
-
StatelessWidget:无中间状态变化的 widget,需要更新展示内容就得通过重新 new,Flutter 推荐尽量使用 StatelessWidget。
-
StatefullWidget:存在中间状态变化,那么问题来了,widget 不是都 immutable 的,状态变化存储在哪里?Flutter 引入 state 的类用于存放中间态,通过调用 state.setState ()进行此节点及以下的整个子树更新。
State 生命周期
-
initState (): state create 之后被 insert 到 tree 时调用的
-
didUpdateWidget (newWidget):祖先节点 rebuild widget 时调用
-
deactivate ():widget 被 remove 的时候调用,一个 widget 从 tree 中 remove 掉,可以在 dispose 接口被调用前,重新 instert 到一个新 tree 中
-
didChangeDependencies ():
-
初始化时,在 initState ()之后立刻调用
-
当依赖的 InheritedWidget rebuild,会触发此接口被调用
-
build ():
-
After calling [initState].
-
After calling [didUpdateWidget].
-
After receiving a call to [setState].
-
After a dependency of this [State] object changes (e.g., an[InheritedWidget] referenced by the previous [build] changes).
-
After calling [deactivate] and then reinserting the [State] object into the tree at another location.
-
dispose ():Widget 彻底销毁时调用
-
reassemble (): hot reload 调用
注意事项:
-
A页面 push 一个新的页面B,A页面的 widget 树中的所有 state 会依次调用 deactivate (), didUpdateWidget (newWidget)、build ()(这里怀疑是 bug,A页面 push 一个新页面,理论上并没有将A页面进行 remove 操作),当然从功能上,没有看出来有什么异常。
-
当 ListView 中的 item 滚动出可显示区域的时候,item 会被从树中 remove 掉,此 item 子树中所有的 state 都会被 dispose,state 记录的数据都会销毁,item 滚动回可显示区域时,会重新创建全新的 state、element、renderobject。
-
使用 hot reload 功能时,要特别注意 state 实例是没有重新创建的,如果该 state 中存在一下复杂的资源更新需要重新加载才能生效,那么需要在 reassemble ()添加处理,不然当你使用 hot reload 时候可能会出现一些意想不到的结果,例如,要将显示本地文件的内容到屏幕上,当你开发过程中,替换了文件中的内容,但是 hot reload 没有触发重新读取文件内容,页面显示还是原来的旧内容。
数据流转
★从上往下
数据从根往下传数据,常规做法是一层层往下,当深度变大,数据的传输变的困难,Flutter 提供 InheritedWidget 用于子节点向祖先节点获取数据的机制,如下例子:
child 及其以下的节点可以通过调用下面的接口读取 color 数据:
说明:BuildContext 就是 Element 的一个接口类
context.inheritFromWidgetOfExactType (FrogColor)其实是通过 context/element 往上遍历树,查找到***个 FrogColor 的祖先节点,取该节点的 widget 对象。
★从下往上
子节点状态变更,向上上报通过发送通知的方式
-
定义通知类,继承至 Notification
-
父节点使用 NotificationListener 进行监听捕获通知
-
子节点有数据变更调用下面接口进行数据上报
★闲鱼 Flutter 的界面框架设计
Layout
★Size 计算
parent 传入约束条件,在 dramframe 的 layout 阶段,child 根据自身的渲染内容返回 size。
问题:在 build ()阶段获取不到 size,很多时候需要提前知道部分 widget size 来进行布局,解决方案当 widget 在对应 renderobject 的 layout 阶段之后,发送一个 LayoutChangeNotification,参考 SizeChangedLayoutNotifier class,但是 SizeChangedLayoutNotifier 没有上报 init layout size,可以自己参考这个实现封装一个 Notifier。
★Offset 计算
-
renderObject 拿到计算好的 size,再加上一些布局属性(align、paddig)等,计算 child 相对 parent 的 offset。
-
offset 存放在每个 child renderObject 的 BoxParentData 中。
-
当 parent 拥有 mutil children 时,BoxParentData 还用来存 children 兄弟节点之间的遍历顺序。
★Relayout boundary
renderObject 在 layout 阶段做了 Relayout boundary 的优化,当子树进行 relayout 时,满足下面三种中的一种:
-
parentUsesSize == false
-
sizedByParent == true
-
constraints.isTight
那么该 renderObject 设置为 Relayout boundary,也就是该 renderObject 的重新 layout 不触发 parent 的 layout,一般情况下开发人员不需要关心 Relayout boundary,除非是使用 CustomMultiChildLayout。
Paint
★Layer
iOS 的每一个 UIView 都有一个 layer,Flutter 的 render object 不一定存在 layer,一般情况下一个 renderObject 子树都渲染在一个 layer 上,那么什么 renderObject 具有 layer,子 renderObject 怎么渲染到这个 layer?
1. 当一个 renderObject 的
或者
,renderOject 会有对应的 compositing layer。
2. 子 renderObject 会对目标 layer 返回对应的 offsetLayer, 目标 compositing layer 再根据 offset 合成一个渲染的纹理 buffer。
★Repaint Boundary
类似 Relayout boundary,Paint 阶段也有 Repaint Boundary,目的和 layout 一样,就是对应子树的 paint 不会导致外部的 repaint,但是 Relayout boundary 需要开发人员自己设置,使用 RepaintBoundary widget 进行设置,ListView 在渲染的 item 默认都是使用了 RepaintBoundary,显而易见 ListView 的 children 之间都是相互独立的。Flutter 建议复杂的 image 渲染使用 RepaintBoundary,image 的渲染需要 io 操作,然后解码,***渲染,使用 RepaintBoundary 可以进行 gpu 的缓存,但是不一定就会缓存,engine 会判断这个 image 是否足够复杂,毕竟 gpu 缓存还是非常珍贵的,同时 RepaintBoundary 还会对一些反复渲染的 layer 进行缓存处理(反复渲染 3 次及以上,这个是 Flutter 的视频中提到的)。
结语
Flutter 还处于 Beta 阶段,有些界面编程的接口设计还不够成熟,相比 iOS 和安卓生态还很不成熟,需要我们共同的创建,Flutter 提供的调试工具相比一开始接触的时候,已经完善很多,让我们给 Flutter 更多的耐心和包容,期待 Flutter 越来越完善。
参考资料