从渲染流程解说Flutter老鸟也常犯的错误——多次重建

2,453 阅读4分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

Widget 渲染过程

Flutter 把视图数据的组织和渲染抽象为三部分,即 Widget,Element 和 RenderObject。

img

Flutter 渲染过程,可以分为这么三步:首先,通过 Widget 树生成对应的 Element 树;然后,创建相应的 RenderObject 并关联到 Element.renderObject 属性上;最后,构建成 RenderObject 树,以完成最终的渲染。

屏幕每秒刷新60帧,Flutter 第一次将页面绘制到屏幕上,它需要找出屏幕上每个元素的位置、颜色、文本等。也就是说,在第一次渲染中,需要配置屏幕上的每一个像素。

对于后续的屏幕刷新和绘制,如果没有任何更改,Flutter 会使用上一次的绘制信息,并快速的在屏幕上绘制出来。而当每次屏幕刷新都需要计算屏幕上的所有内容时,才会出现掉帧。

Widget

Widget 是对视图的一种结构化描述,里面存储的是有关视图渲染的配置信息,包括布局、渲染属性、事件响应信息等。

Widget 设计成不可变的,所以当视图渲染的配置信息发生变化时,Flutter 会选择重建 Widget 树的方式进行数据更新,以数据驱动 UI 构建的方式简单高效。虽然重建涉及到大量对象的销毁和重建,会对垃圾回收造成压力,不过,Widget 本身并不涉及实际渲染位图,所以它只是一份轻量级的数据结构,重建的成本很低。另外由于 Widget 的不可变性,可以以较低成本进行渲染节点复用,一个Widget对象可以对应多个 Element 对象。

Element

Element 是 Widget 的一个实例化对象,它承载了视图构建的上下文数据,是连接结构化的配置信息到完成最终渲染的桥梁。

Element 同时持有 Widget 和 RenderObject。而无论是 Widget 还是 Element,其实都不负责最后的渲染,只负责发号施令,真正去干活儿的只有 RenderObject。

因为 Widget 具有不可变性,但 Element 却是可变的。实际上,Element 树这一层将 Widget 树的变化(类似 React 虚拟 DOM diff)做了抽象,可以只将真正需要修改的部分同步到真实的 RenderObject 树中,最大程度降低对真实渲染视图的修改,提高渲染效率,而不是销毁整个渲染视图树重建。这,就是 Element 树存在的意义。

BuildContext 就是 Widget 对应的 Element。

Element 树不会在每次调用build(){...}方法时重建。

RenderObject

从其名字,我们就可以很直观地知道,RenderObject 是主要负责实现视图渲染的对象。

Flutter 通过控件树(Widget 树)中的每个控件(Widget)创建不同类型的渲染对象,组成渲染对象树。而渲染对象树在 Flutter 的展示过程分为四个阶段,即布局、绘制、合成和渲染。 其中,布局和绘制在 RenderObject 中完成,Flutter 采用深度优先机制遍历渲染对象树,确定树中各个对象的位置和尺寸,并把它们绘制到不同的图层上。绘制完毕后,合成和渲染的工作则交给 Skia 搞定。

  • 每当 Flutter 遇到一个之前没有被渲染的元素时,它就会通过 Widget 树中的配置,在元素树中创建一个元素。
  • 渲染树也不会经常重建。
  • 除了布局、绘制、合成和渲染阶段,它还有另一个阶段,将监听器附加到 Widget 上,这样我们就可以监听事件。

build()的构建过程

每当状态发生变化时,Flutter 就会调用方法build()。一般有两种出发重建的条件:

  1. 有状态的 Widget 中调用setState(){...}方法。会导致build(){...}方法的调用。

  2. 其次,每当有MediaQuery调用或Theme.of(...)...调用、软键盘出现/消失等,只要这些数据发生变化,就会自动触发build(){...}方法。

调用setState(){...}将相应的元素标记为 dirty 。对于下一次刷新(每秒发生60次),Flutter 会将build(){...}方法创建的新配置进行分析,然后更新屏幕。

反最佳实践的例子

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    debugPrint('=======_MyHomePageState.build:');
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Row(
              children: <Widget>[
                Text('我是第一行'),
                Text('我是第二行'),
                Text('我是第三行'),
              ],
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

}

使用 Flutter 创建的计数器项目,然后添加几个text,通常为了避免地狱回调,我们通常会把嵌套的组件抽离成一个方法, Flutter Outline 也有快捷操作:

            _buildRow(),
            // Row(
            //   children: <Widget>[
            //     Text('我是第一行'),
            //     Text('我是第二行'),
            //     Text('我是第三行'),
            //   ],
            // ),
  Row _buildRow() {
    return Row(
            children: <Widget>[
              Text('我是第一行'),
              Text('我是第二行'),
              Text('我是第三行'),
            ],
          );
  }

看着真棒,去掉了地域嵌套,方法行数减少,结构清晰。说真的,过去的一年多我都是这么干的。

问题

直到看到这个:

xp4ntmcDkl1GeAJ

Wm 是 Flutter 的开发人员倡导者。

每当值发生变化时,Flutter 都会调用setState()。这触发了 Widget 调用build()方法重建。然后调用_buildRow()重建这个方法返回的控件。_incrementCounter``build``_buildRow()``_counter

前面渲染流程我们说过,build()的时候 Element 不一定重建。但是因为这个方法,会导致每次都会重建它。重建不需要重建的东西时浪费了宝贵的CPU周期。

解决方案

解决方案很简单:不是将构建方法拆分为更小的方法,而是将其拆分为小部件 - 无状态小部件。

上面的 Demo 最终是这样:

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    debugPrint('=======_MyHomePageState.build:');
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            _NonsenseWidget(),
            // _buildRow(),
            // Row(
            //   children: <Widget>[
            //     Text('我是第一行'),
            //     Text('我是第二行'),
            //     Text('我是第三行'),
            //   ],
            // ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ),
    );
  }

  Row _buildRow() {
    return Row(
      children: <Widget>[
        Text('我是第一行'),
        Text('我是第二行'),
        Text('我是第三行'),
      ],
    );
  }
}

class _NonsenseWidget extends StatelessWidget {
  const _NonsenseWidget();
  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        Text('我是第一行'),
        Text('我是第二行'),
        Text('我是第三行'),
      ],
    );
  }
}

当是一个 Widget 时,Flutter 会对比是否需要重建,因为是 相同类型的 StatelessWidget,所以会复用。避免了重建。