从渲染原理剖析如何提高 Flutter 应用性能

2021-04-07 09:10:05 浏览数 (1)

1. Flutter 性能概述

1.1 Flutter 基本渲染原理

在我们讨论如何对 Flutter 进行性能优化之前,首先得掌握 Flutter 的渲染原理,这样才能更好的对症下药。本文将主要讲讨论 UI 线程中的性能优化,由于 GPU 线程涉及底层 Skia 图形引擎的调用,相较于 UI 线程而言更加繁琐,对其感兴趣的同学可以观看 Google 官方的《深入了解 Flutter 的高性能图形渲染》。

渲染流程图.png

根据上图,我们可知 Flutter 的主要渲染流程:在初次渲染时,我们会根据我们自己的业务代码,分别构建 Widget、 Element 以及 RenderObject 三棵树,其次对 RenderObjective Tree 的每个节点进行遍历,再对发生改变的节点处进行标脏处理,执行 paint 操作,形成一个 Layer Tree,最后把形成好的 Layer Tree 发送给 GPU 线程,GPU 线程在接收到 Layer Tree 之后,将 Layer Tree 转成为 GPU 的可执行指令。

1.2 Flutter 性能调试

我们在命令行中输入flutter run --profile的指令,即可在 profile 模式下对我们的应用进行调试,在执行该命令后会产生一个链接,打开该链接后如下图所示。我们在 UI 线程和 GPU 线程调试都会用到该页面

UI 线程和 GPU 线程观测所进行的操作是不同的,具体不同如下:

  • UI 线程:我们在UI线程中性能调试主要是通过下图里面的 timeline 进行调试。timeline 具体所呈现的内容如下图所示,进入 timeline 之后,Record Streams Profile 值选择 Flutter Developer 即可,里面可以很清晰的看到每个渲染步骤所花的时长,我们可以根据所花费的时间长短,来找到我们的应用的性能瓶颈。
  • GPU 线程:由于 GPU 线程相较于 UI 线程属于更加底层,因此我们得需要去分析 Skia 的调用,我们现在命令行输入flutter run --profile --trace-skia运行我们的应用,然后继续通过 timeline 去进行分析,不过 Record Streams Profile 的值换成了 All,这里可以查看到许多的 Skia 函数的调用,我们可以分析每一个 Skia 函数的调用次数。

观测台

timeline

1.3 为什么我们说 Flutter 是高性能前端框架?

架构对比

上面这张图我们可以很清楚看到,Flutter 框架可以直接调用 Skia 图形引擎,这也是 Flutter 性能能够媲美原生的重要原因;而不是像 react-native 那样首先得先通过 JSBridge 调用 Java 代码,然后再通过 Java 代码去调用 Skia 图形引擎,相较于 Flutter 多一层调用,所以性能也会存在丢失。

至此,我们可知 Flutter 在 UI 线程中渲染主要涉及到 build、 layout 以及 paint 阶段,我们下面将会根据这三个阶段来介绍的具体过程以及性能优化方式。

2 build 阶段的性能优化

2.1 build 更新具体过程

Element Tree 中的 Element 主要涉及到两种类型,分别是:

  1. ComponentElement
  2. RenderObjectElement

其中,ComponentElement 主要做组合,不会直接参与布局。而 RendObjectElement 则用来对 RenderObject 树上的 RenderObject 节点做连接。当我们对 Widget 树里面的某一个节点进行更新时,因为 Widget 是不可改变的,所以我们在改变的时候,只能扔掉旧的树,然后重新去创建一个新的 Widget 树;在创建完新的 Widget 树之后,再对上一帧的 Element 树做遍历,在 Element 类上有一个 updateChild 的方法,它可以对子节点进行比较并操作,通过查看当前的子节点类型和上一帧的子节点类型是否一致,如果不一致,直接扔掉创建一个新的 Element 节点,反之则对自己的子节点进行更新。

即:

  1. 如果 Element 节点的类型是 ComponentElement,会直接对 StatefulElement 和 StatelessElement 做更新,也就是执行 build 操作。
  2. 如果 Element 节点的类型是 RenderObjectElement 的话,则通过调用 RenderObjectWidget 的 updateRenderObject 方法来进行更新。该方法会执行 RenderObjectElement 的所有 setter 方法,根据自己的 setter 逻辑查看自己是否需要被标脏,经过这一步后,build 阶段也基本执行完成了。

2.2 如何提高 build 的效率

我们提高 build 效率的核心本质是:

  1. 降低我们开始遍历的节点
  2. 提前结束树的遍历

在具体的实际业务开发中,我们可以在代码的任意处加上debugProfileBuildsEnabled = true,这可以帮助我们通过 timeline 发现 build 过程中的具体性能瓶颈。如下图所示,timeline 中可以清晰的看到 build 更新时哪些节点发生了遍历,再根据图中找到我们应用的性能瓶颈。

build 阶段 timeline

在我们业务开发中,我们遵循以下方法,可以有效的控制 build 的耗时:

  1. 在创建 build 时,我们得让 build 十分纯粹,不能有其他的副作用。
  2. 尽量避免写出嵌套很深的 Widget,应该把他们一个一个独立出来,这样可以有效地降低我们开始遍历的节点。

2.3 具体优化方法

2.3.1 降低开始遍历的节点

实例代码

代码语言:javascript复制
class BeginWidget extends StatefulWidget{
  BeginWidget({Key key}):super(key: key);
  @override
  _beginWidgetState createState() => _beginWidgetState();
}

class _beginWidgetState extends State<BeginWidget>{
  int time = 60;
  @override
  void initState(){
    super.initState();
    if(mounted){
      setState((){
        time = time - 1;
      });
    }
  }

  Widget build(BuildContext context) {
    return Column(
      children: [
        Expanded(child: Container(child: Text('123'))),
        Expanded(
          child: Container(
            width: 100,
            height: 100,
            child:Row(
              children: [
                Text('倒计时'),
                Text('$time')
              ],
            ),
            decoration: BoxDecoration(color: Colors.blue),
          )
        )
      ],
    );
  }
}

优化后代码:

代码语言:javascript复制
class AfterWidget extends StatelessWidget{
  const AfterWidget({Key key}) : super(key: key);
  Widget build(BuildContext context){
    return Column(
      children: [
        Expanded(child: Container(child: Text('123'))),
        Expanded(
          child: Container(
            width: 100,
            height: 100,
            child:Row(
              children: [
                Text('倒计时'),
                TimeWidget(),
              ],
            ),
            decoration: BoxDecoration(color: Colors.blue),
          )
        )
      ],
    );
  }
}

class TimeWidget extends StatefulWidget{
  TimeWidget({Key key}):super(key: key);
  @override
  _timeWidgetState createState() => _timeWidgetState();
}

class _timeWidgetState extends State<TimeWidget>{
  int time = 60;

  @override
  void initState(){
    super.initState();
    if(mounted){
      setState((){
        time = time - 1;
      });
    }
  }

  Widget build(BuildContext context) {
    return Text('$time');
  }
}

上述代码通过将 Widget 的粒度细化,能够有效地降低遍历的起点位置。当 Widget 数过于复杂时,我们应该尽量将 Widget 抽离出来,单个 Widget 树最好不要太多,这样既有利于提高代码的可读性,也有利于 Widget 树更新时,避免不必要的 Widget 节点重新构建。

2.3.2 提前结束子树的遍历
代码语言:javascript复制
 Selector(
  selector: (context, DataModel dataModel) {
    return dataModel.xxx;
  },
  builder: (context, xxx, child) {
    return Container(
      child: Row(
        Text(xxx),
        child,
      ),
    );
  },
  child: Container(child: Text('123')),
)

在使用 Provider 的 Selector 类时,其 build 的 child 参数就是通过提前结束子树的遍历来进行性能优化的,当数据更新时,Widget 树将重新进行构建,遇到 child 的地方直接将之前写好的 child 树连接上,当然这不会对 child 里面的节点进行重新遍历。Provider 通过 Selector 代替 Consumer 本身也是一种提高性能的方式,它是通过上面所说的降低遍历的起始点,使得在数据更新后,对极小需要更新数据的地方重新进行遍历。除 Selector 之外,还有许多地方都有提供 child 的属性,他们大多数的目的都是为了能够让 Widget 复用,提前结束子树的遍历。

3. layout 阶段的性能优化

3.1 layout 的具体过程

layout 的过程主要是为了计算出节点真正所占的大小。在建立 layout tree 的过程中,首先父节点会给出一个宽高大小的限制,然后子节点再来决定自己的大小。在 Layout 中存在一个 Relayout boundary 的概念,它可以产生一个边界,确保在边界内的布局发生改变时,不会让边界外的部分也重新计算,这样也可以在某些特定情况下提高我们应用的性能。除此之外,在我们书写 Widget 的时候,如果能够给出 Widget 宽高的话,尽量给出来,因为在布局中,宽度的计算也会占用一定的时间。比如在使用 ListView 这样的滑动组件时,我们应该给出滑块的高度,即 itemExtend 的值,这样在滑动的时候,UI 线程不会花费大量的时间在计算高度上。

3.2 具体代码演示

代码语言:javascript复制
class ListViewShow extends StatelessWidget {
  const ListViewShow({Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListView(
      itemExtent: 30, // 指定 children 每一个 child 的高度
      children: <Widget>[
        Container(
          child: Text('ListView 1'),
        ),
        Container(
          child: Text('ListView 2'),
        ),
        Container(
          child: Text('ListView 3'),
        ),
      ],
    );
  }
}

4. paint 阶段的性能优化

4.1 paint 的具体过程

在 RenderObject 标脏后,paint 会对已经标脏的 RenderObject 图层重新进行绘制。这里和 Layout 相似,存在一个 Repaint boundary 的概念,它的原理和 layout 里面的 Relayout boundary 的基本相似,区别是它在 paint 的时候产生一个边界,防止页面大范围重新绘制。如果页面是频繁更新的页面,例如包含定时器的页面,在使用倒计时这样的控件时,我们可以在最小控件范围外包一层 RepaintBoundary 来与周围图层进行隔离。同 build 阶段一样,我们可以在代码里面加入debugProfilePaintsEnabled = true来在 timeline 里面观看 paint 阶段有哪些不必要的图层发生了更新。

paint 阶段 timeline

4.2 具体代码演示

代码语言:javascript复制
import 'package:flutter/material.dart';
import 'dart:async';

class HoursTitle extends StatefulWidget {
  @override
  HoursTitleState createState() => HoursTitleState();
}

class HoursTitleState extends State<HoursTitle> {
  static Timer timer;
  int minutes = DateTime.now().minute;
  int seconds = DateTime.now().second;
  Duration duration = Duration(seconds: 1);

  void _startTimer() {
    timer?.cancel();
    timer = Timer.periodic(duration, (timer) {
      if (seconds == 0) {
        if (minutes == 0) {
          minutes = 59;
          seconds = 59;
        } else {
          minutes--;
          seconds = 59;
        }
      } else {
        seconds--;
      }
      setState(() {
        minutes = minutes;
        seconds = seconds;
      });
    });
  }

  @override
  void initState() {
    super.initState();
    if (!mounted) {
      return;
    }
    _startTimer();
  }

  @override
  void dispose() {
    super.dispose();
    timer?.cancel();
  }

  Widget build(BuildContext context) {
    // 通过RepaintBoundary增加一个绘制边界
    return Container(
      child: Row(
        children: [
          RepaintBoundary(
            child: Container(
              width: 200,
              height: 30,
              child: Text.rich(
                TextSpan(
                  text: '距本小时结束',
                  children: [
                    TextSpan(
                      text: '$minutes : $seconds',
                    ),
                  ],
                ),
              ),
            ),
          ),
          Text('123'),
          Text('465'),
        ],
      ),
    );
  }
}

这里的定时器只会导致 RepaintBoundary 包裹的 Widget 重新绘制,不会导致到周围其他的 Widget 的重新绘制,这在图层很大的时候,会非常有用,当然 Flutter 的一些组件页支持了图层划分,比如 ListView 里面的 isRepaintBoundary 属性,可以直接帮我们合成视图,避免了不必要的重新 paint。

写在最后

Flutter 性能优化涉及到方方面面,本文从渲染原理的角度进行切入讲解其优化手段。还有 Flutter 组件选择等其他方面也是有所讲究的,例如 ListView 和 ListView.builder 之间的选择;还有在实际的业务开发中,对于 Opacity 这样大量消耗性能的 Widget 最好尽量少用,因为它会调用saveLayer()方法,这个方法它会很大程度上影响 GPU 线程的效率。至于其后章节,笔者未来会出文进行全面讲解,请期待该系列的下一篇文章。

0 人点赞