Flutter调优工具使用及Flutter高性能编程部分要点分析

1,065 阅读25分钟

我正在参加「掘金·启航计划」

在做项目性能优化的过程中,结合实际项目,总结了部分平时开发过程中应该注意的一些小细节。大家也可以一起看看。这些细节有些繁琐,但是在App开发进入了中后期阶段,追求的不是快速开发,而是稳健,这些细节还是要多多注意。

常用调用工具使用

工欲善其事,必先利其器!在调优阶段,对于Flutter提供的调试工具,必须要好好掌握。先简单介绍一下,我在平时性能调优阶段常用的工具。工具主要是用来帮助我们快速准确的发现问题和定位问题。

  1. 查看页面repaint视图

如果想快速发现我们调优的页面,哪些组件是做没有必要的绘制。那就这用这个工具。其实是组件对应的渲染对象在绘制,我们简单点说,就说组件在重绘吧。

使用方式

打开Flutter Inspector工具视图,点击Highlight repaints按钮,然后观察页面,发现开启这个功能之后,页面上所有的组件都加了一个边框。如果组件在重复绘制,那这个边框的颜色就会改变。

  1. 查看组件重复构建数量

打开Flutter Performance工具视图,点击Track widget rebuilds 复选按钮,然后操作目标页面并观察下面列表。

  • 列表的每一行都可以点击,点击跳到组件对应源码的位置。很方便啊!
  • Last Frame代表上一帧该组件的绘制次数,一般对于动画组件,列表重新build,数量会大于1
  • Current Screen代表当前屏幕上,特定组件的构建数量
  • Widget前面的灰色圆圈部分,如果显示黄色旋转圆圈,说明 重建次数过多。

  1. 查看离屏渲染组件

我们要知道Flutter对于离屏渲染,很昂贵,原因后文再说。先看如何检查出使用了离屏渲染的组件。并且在优化的过程中尽量避免这类组件的使用。

① 检测项目中使用了离屏渲染之方法「一」

如果使用了离屏渲染(saveLayer) 的 Widget 会自动显示为棋盘格式,并随着页面刷新而闪烁。

MaterialApp(
  checkerboardOffscreenLayers: true,
  ...
)

查看页面,分析工具就会自动帮我们检测多视图叠加的情况。如果使用了离屏渲染(saveLayer) 的 Widget 会自动显示为棋盘格式,并随着页面刷新而闪烁。

② 检测项目中使用了离屏渲染之方法「二」

Flutter调试工具其实提供了跟踪Skia绘制步骤的能力,这个方法主要是查看Skia Canvas的绘制流程,所以也可以捕捉saveLayer。大家了解即可。

第一步

使用profile调试项目,并且加入参数

flutter run --profile --trace-skia

成功之后,我们就可以进入Observatory模式。并且记录这个url

第二步

新开一个终端,执行以下命令。--observatory-url后面的参数就是上一步的url

注意,执行这个命令需要在你想调试的页面上进行。

flutter screenshot --type=skia --observatoryurl=http://127.0.0.1:54504/W6bm6tGHExU=/#/vm

执行这个命令就是捕捉这一帧的渲染序列文件flutter_01.skp,这个文件会保存在当前目录。

第三步

打开 debugger.skia.org 网站。把刚那个拓展为skp文件上传到这个网站,就可以看到skia的绘制步骤了。因为我在测试页面特地使用了Opacity组件,很明显会触发调用saveLayer方法

  1. 查看超大图渲染

打开Flutter Inspector工具视图,点击Highlight oversized images按钮,然后观察页面中的图片,如果发生了倒立并且颜色也变量,那就说明渲染的图片太大了,比如把一张10001000像素的图片显示在100100的卡片上。那就会出现这种情况,导致内存暴增。

这类问题有一个通用解决方法

  • 对于网络图片进行CDN尺寸压缩
  • 给图片组件传入cacheHeightcacheWidth参数,这样Flutter引擎就会以指定的大小解码图像,减少内存使用。
 return Image.network(
      'https://xxxxxxxx.com/test.jpg?imageclip100',
      cacheHeight: 100,
      cacheWidth: 100,
    );
  1. 查看每帧绘制耗时柱状图

这个耗时柱状图也就是Performance frames chart。开启Flutter DevTools,Android Studio提供了不少入口,调试阶段点击就可以进入DevTools。

进入DevTools之后,点击Perfromance标签,然后点击开始按钮,就可以跟踪CPU线程(下图浅蓝色部分)和GPU线程(下图深蓝色部分)的耗时,柱状图y轴画了两个线,分别表示16.6ms和33ms。我们知道如果一帧绘制超过了16.6ms,就说明这个组件的绘制太耗时,以至于需要跨帧绘制。一般来说如果绘制超过20ms,那就会导致肉眼可见的卡顿。在柱状图上,表现在这个柱状图上就是红色和深红色。点击这个红色的柱状图,就可以看到整个绘制图,就是我们常说的火焰图。

① Enhance Tracing 工具细节说明

Track Widget Builds跟踪Widget树build()时间,一般这个耗时多,说明在build()做了耗时计算。

Track Layouts 跟踪Widget树对应元素约束布局时间

Track Paints跟踪绘制时间

② More debugging optons工具说明

这个工具主要是用来对比查看绘制阶段的问题。

Render Clip layers 关闭就不会绘制裁剪效果,可以开关对比,如果关闭效果好就改代码,去掉Clip

Render Opacity layers关闭就不绘制透明效果,可以开关对比,如果关闭效果好就改代码,去掉Opacity

Render Physical Shape layers关闭就不会绘制PhysicalModel相关属性。可以开关对比,如果关闭效果好就改代码,去掉投影相关的代码

PhysicalModel可能很多伙伴不知道,下图可以大致说明其功能。

③ 查看火焰图,基本操作

  1. 搜索: 我们直接通过搜索项目中的SLF和STF组件类名就可以直接定位到。

比如下图中,我们就可以发现DynamicWidget这个组件的耗时比较多,然后就可以去项目中定位这个组件。还是那句话,工具只是帮助我们发现问题。

  1. 快捷键: 使用使用ASDW快捷键来快速定位区域

④ 常见火焰图分析

如下是典型的光栅线程(GPU线程)卡顿,一般GPU线程的卡顿都是由于绘制过于复杂,特别是saveLayer方法的使用,我们需要配合UI中显示的组件查看代码就行优化。去掉saveLayer,clip这类方法。

如下是CPU线程卡顿,可以看到Layout耗时比较多,一般都是由于一帧创建的Widget树过多,并且层级多深。可以结合业务代码进行优化,比如对于提前已知道显示宽高的组件,加上宽高紧约束,已提升Layout耗时。

⑤ 导出数据

对于需要优化的火焰图,我们需要多人共同查看的时候,就可以考虑把数据导出来。

这样就会生成一个如下的文件。

image.png

使用Chrome浏览器,在地址栏输入如下地址并加载上一步生成的文件即可。

chrome://tracing/

查看方式基本上同Devtools


Flutter高性能编程要点

讲完了相关工具的使用,如果把工具使用的好,大多数问题我们都可以定位到,但是这不代表我们就可以解决问题了,下面分享一些用来解决性能问题的常用要点。

一、避免没必要的repaint

Flutter inspector调试工具给我们提供了这个能力。用来查看没有必要的绘制。使用起来很直观,开启这个工具会给组件绘制一个边框。如果有重绘绘制,边框的颜色就会改变。这样我们就很容易发现哪些组件在做没有必要的绘制。

实际案例使用方式

比如我们需要观察项目中聊天公屏在源源不断新消息过来时候,页面的重绘情况。

打开Flutter Inspector工具视图,点击Highlight repaints按钮,然后观察页面中组件边框颜色就行了。如下右图是已经优化之后的效果,新消息来时,目前基本上只会改变绿色部分的颜色。对比之前来新消息的时候,整个页面都是重绘。表现就是所有边框的颜色每次都在改变。比如下面的输入框。现在已经优化为只重新绘制列表。

Snipaste_2022-10-24_09-51-00.png

修改代码块

相应的代码不能贴出来,但是可以说一下思路,就是把父子结果Widget树,变为兄弟结构Widget树,减少刷新范围。

通过查看性能图对比,在相同频道内这样修改收益还是很大的,CPU线程的峰值削去了一部分,并且CPU绘制也相对更稀疏一些。

1.png

小结

使用Highlight repaints工具,结合业务代码,配合GetBuilder(),ValueListenableBuilder()等这类组件尽量做到最小更新,提升页面丝滑度。

二、尽量使用const Widget

使用const修饰的常量,在Dart VM编辑期间已经确定好内存地址,放在一个常量地址区域中。也可以叫做编译时常量。再次使用的时候,不会创建新实例。对于在Widget树中,相同位置,页面在进行setState的时候,不会rebuild。

顺带对比说一个final,这个修饰的是运行时常量。

示例

我们通过一个例子来说明一下,下面的代码是一个很简单的页面,点击右下角的按钮,更新计数。区别就是在BackgroundWidget组件是否使用const修饰。

这个时候,可以使用前文中说过的Track widget rebuilds工具,可以很直观的看到页面组件重绘情况。

没有加const修饰的BackgroundWidget组件在每次按钮点击的时候,重绘数量一直在递增,如下图左所示。

加了const修饰的BackgroundWidget组件在每次按钮点击的时候,重绘的数量一直是1,如下右图所示。

BackgroundWidget()const BackgroundWidget()

小结

1.减少内存使用,并且在setState时会更快。

2.避免没有必要的重复绘制,降低CPU线程和GPU线程的消耗,这个很重要。

3.因为在编译期间已经决定好常量,hot reload速度也会加快

三、尽量不要使用函数创建组件

对于一些小组件,我们经常使用函数来创建,这样代码写起来也少,使用起来也方便。如下面的代码所示。

反面示例

每次点击按钮进行setState()的时候,都会重新创建3个组件。

Widget build(BuildContext context) {
  return Scaffold(
    floatingActionButton: FloatingActionButton(
      onPressed: () {
        setState(() {});
      },
      child: Icon(Icons.colorize),
    ),
    body: Padding(
      padding: const EdgeInsets.all(15.0),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          _buildNavWidget(),
          _buildMainWidget(context),
          _buildFooterWidget(),
        ],
      ),
    ),
  );
}

这样使用从代码层面看的确没有什么问题。但真实情况这样的方式会带来更多的性能问题。我一一说明其中原因。

  1. 无法配合const使用。创建SLF组件,可以更方便的配合const使用
 const NavWidget(),
 const MainWidget(),
  1. 在函数创建的组件中使用setState,容易导致整个页面rebuild,是会造成CPU资源浪费

如下测试代码,其实只是想改一个名字,结果导致整个页面的rebuild。试想如果里面有动画,那就更加糟糕了。

Widget _buildMainWidget(BuildContext context) {
  return Expanded(
    child: Container(
      width: double.infinity,
      color: Colors.grey[700],
      child: Column(
        children: [
          Text(name,
              style: const TextStyle(fontSize: 20, color: Colors.blue)),
          ElevatedButton(
              onPressed: () {
                setState(() {
                  name = "Hello, lin1.zhou";
                });
              },
              child: const Text("change name")),
          Builder(builder: (context2){
            //使用哪个context?
         }),
        ],
      ),
    ),
  );
}
  1. 使用Devtools或代码错误堆栈也很难定位问题代码

这个很好理解,使用函数创建组件树结构可能如下

Container
    Container

而使用StatelessWidget代替函数,widget树结构就如下

  HeaderWidget
   Container
    MainWidget
      Container

这样的树结构可以很好的定位问题。

实际项目中的应用

  1. 比如在聊天会话页面中,新消息会有一条分割线

之前

if (show_divider_line)
  xxxClass.newMessageDivider(),

之后

if (show_divider_line)
  const NewMessageDivider(),

小结

可以很容易使用const来修饰我们的StatelessWidget组件,函数却不能。这个在上一节已经有说明。容易实现更小的界面更新。所以平时写代码的时候,对于创建的小部件,多用类而不是函数。

针对这个问题,stackoverflow也有专门的讨论。甚至Provider的作者也说过,使用函数创建Widget除了简单没有任何好处。

四、使用nil来创建占位

对于留白占位组件,我们经常使用const SizedBox()。这样的确足够简单,但是还不够,通过查看SizedBox的源码,发现SizedBox最终还是会对应创建RenderConstrainedBox渲染对象,这就免不了去paint()。

那有没有办法,只占位,不绘制呢? 答案是有!

使用: nil

小知识点performRebuild

正常执行到performRebuild的时候,就行真正执行重新build的地方,因此每种Element的实现都会有所不同。我们要知道的就是元素首次创建或者元素被标记为dirty=true,都会执行这个方法,然后重新build组件。继承关系如下。

2.png 有了上面的知识储备。我们来看一下这个库的源码。核心就是重载performRebuild()方法,并且什么都不做,就不会去build了,build都不执行,背后的xxxElement和xxxRenderView也不会创建了。不过实属优雅的骚操作。


const nil = Nil();

/// A widget which is not in the layout and does nothing.
/// It is useful when you have to return a widget and can't return null.
class Nil extends Widget {
  /// Creates a [Nil] widget.
  const Nil({Key? key}) : super(key: key);

  @override
  Element createElement() => _NilElement(this);
}

class _NilElement extends Element {
  _NilElement(Nil widget) : super(widget);

  @override
  void mount(Element? parent, dynamic newSlot) {
    assert(parent is! MultiChildRenderObjectElement, """
        You are using Nil under a MultiChildRenderObjectElement.
        This suggests a possibility that the Nil is not needed or is being used improperly.
        Make sure it can't be replaced with an inline conditional or
        omission of the target widget from a list.
        """);

    super.mount(parent, newSlot);
  }

  @override
  bool get debugDoingBuild => false;

  @override
  void performRebuild() {}
}

使用方式

return Builder(
  builder: (_) {
    if (condition) {
      return const MyWidget();
    } else {
      return nil;
    }
  },
);

五、尽量不要使用离屏渲染

离屏渲染是什么

这个主要是针对GPU线程来说。想要进行离屏渲染,首先要创建一个新的缓冲区,屏幕渲染会有一个上下文环境的一个概念,离屏渲染的整个过程需要切换上下文环境,先从当前屏幕切换到离屏,等结束后,又要将上下文环境切换回来。这个渲染目标切换对于GPU来说,代价有点大。

比如有时候,我们发现CPU线程很流畅,但GPU线程却很耗时。也就是说绘制上的压力比较大,这时候,我们就可以怀疑,项目中可能包括对Skia CanvassaveLayerclipPath等耗时函数调用。

为什么需要离屏渲染

saveLayer是Canvas里面的方法,用来绘制多视图叠加会用到,在比如半透明效果,阴影效果。

调用 saveLayer() 会在GPU中分配一个屏幕外缓冲区,将内容绘制到屏幕外缓冲区可能会触发渲染目标切换。

再来看看Flutter 官方对saveLayer方法的说明。可以简单理解为如果想要绘制复杂的效果,比如半透明,那就需要使用saveLayer方法,不使用的话,效果就不好,绘制出来的结果会很黑。

如何避免

说白了就是少用以下组件

  • Opacity —对于除0.0和1.0之外的不透明度值,这个组件相对昂贵,因为它需要将子级绘制到中间缓冲区中。 对于值0.0,根本不绘制子级。 对于值1.0,将立即绘制没有中间缓冲区的子对象。
  • ShaderMask
  • ColorFilter
  • Chip— 如果disabledColorAlpha != 0xff,就会触发saveLayer()
  • Text— 如果设置了overflowShader,就会触发saveLayer()

官方文档

Performance considerations

Generally speaking, saveLayer is relatively expensive.

There are a several different hardware architectures for GPUs (graphics processing units, the hardware that handles graphics), but most of them involve batching commands and reordering them for performance. When layers are used, they cause the rendering pipeline to have to switch render target (from one layer to another). Render target switches can flush the GPU's command buffer, which typically means that optimizations that one could get with larger batching are lost. Render target switches also generate a lot of memory churn because the GPU needs to copy out the current frame buffer contents from the part of memory that's optimized for writing, and then needs to copy it back in once the previous render target (layer) is restored.

示例

用前面工具使用部分提到的方法一,分析工具就会自动帮我们检测多视图叠加的情况。如果使用了离屏渲染(saveLayer) 的 Widget 会自动显示为棋盘格式,并随着页面刷新而闪烁。如下图。

3.png

然后我们就可以很容易定位到相应组件,查看是否可以使用别的替代方式。比如上面的三个点图标周围的阴影,可以考虑直接使用一个带阴影的icon,而不是在代码层面使用Opacity和ColorFiltered这类组件来加阴影。定位到相关源码如下。

 Opacity(
  opacity: opacity,
  child: ColorFiltered(
    colorFilter: ColorFilter.mode(color, BlendMode.srcATop),
    child: child,
  ),
)

强调一下,这个地方只是建议,因为要达到产品要求的透明加颜色滤镜效果,一般都是这样做。如果的确因为这个引起了性能问题,再改!

六、尽量不要在高层级树进行setState

setState()干了什么

调用setState()会把当前元素标记为dirty,并且加入由BuildOwner实例管理的脏元素列表_dirtyElements中,然后会向引擎发送一个帧调度的请求,告诉引擎,我脏了,需要再刷一下。在 VSync 下一帧信号触发时, Flutter 框架中可以通过回调监听到。并且会执行到BuildOwner中的buildScope()方法,buildScope方法对脏元素进行rebuild()rebuild()中会触发 performRebuildperformRebuild方法前文有说明。最终会触发widget的build()方法。整个流程大致就是这样。

接下来一步一步来来看看源码。

setState源码浅析

去除断言,其实就两行代码,很简单。

tag1其实就是调用参数callback。

tag2就是调用元素基类Element中的markNeedsBuild方法。

void setState(VoidCallback fn) {
    final Object? result = fn() as dynamic;  //tag1
    _element!.markNeedsBuild();   //tag2
}

这里顺道提一嘴,以下两种方式是我们平时已经用到的,大家有没有想过种方式好?结论是debug模式,方法二好,因为里面有断言,对元素生命周期进行判定,保证了setState在mounted的生命周期才给属性赋值。但是对于release模式断言无效,两种方式一样。

//方法一
name = "星河滚烫";
setState()

//方法二
setState({
    name = "星河滚烫";
})

言归正传,继续来看markNeedsBuild()函数,还是一样,删除了断言。代码也不多,主要做了两件事。

把元素标记为_dirty=true。这里面有一个细节就是如果_dirty=true就直接返回。

用元素自身为参数传递到BuildOwner中的scheduleBuildFor方法。

void markNeedsBuild() {
if (dirty)
  return;
 _dirty = true;
 owner!.scheduleBuildFor(this);
}

好。接着继续看scheduleBuildFor方法。

tag1: 判定元素是否在标脏列表中。是的做下面的事情就没有必要做了

tag3: 把元素加到_dirtyElements列表中

tag2: onBuildSchedule其实就是一个callback。这个callback指向就是WidgetsBinding类的的_handleBuildScheduled方法,在Flutter项目启动的时候就已经绑定好了。

 void scheduleBuildFor(Element element) {
    if (element._inDirtyList) {  //tag1
        return true;
    }
    if (!_scheduledFlushDirtyElements && onBuildScheduled != null) {
      _scheduledFlushDirtyElements = true;
      onBuildScheduled!();  //tag2
    }
    _dirtyElements.add(element);  //tag3
    element._inDirtyList = true;
  }

继续看_handleBuildScheduled函数,最终会发现调用了window.scheduleFrame();这个是向引擎发送帧调度请求。为下一帧消费脏队列做准备。

void scheduleFrame() {
  if (_hasScheduledFrame || !framesEnabled)
    return;
  ensureFrameCallbacksRegistered();
  window.scheduleFrame();
  _hasScheduledFrame = true;
}

至此,setState()事情就做完了。总结下来就是缓存脏元素,通知渲染两件事。

引擎收到渲染通知之后,或者说收到垂直同步 Vsync信号时,会通过Dart VM的入口函数_drawFrame(),这个函数实现渲染通知分发。

vm:entry-point备注的函数都可以认为是引擎可以直接调用的。

如下图所示,看看调用堆栈,最终会调用BuildOwner实例中的buildScope()函数。而buildScope()函数其实就是消费脏元素列表。

还是继续看看buildScope()函数的源码,还是一样,以下源码我已经去除了一部分断言和容错代码。

tag1: 对脏元素列表先排序,排序的目的就是把元素深度(depth)更小的排在前面

tag2: 执行元素的rebuild()rebuild()这个方法最终走到performRebuild()

tag3:清空当前脏列表

逻辑很清爽啊。

 void buildScope(Element context, [ VoidCallback? callback ]) {
    if (callback == null && _dirtyElements.isEmpty)
      return;
   
    try {
      _dirtyElements.sort(Element._sort); //tag1
      int dirtyCount = _dirtyElements.length;
      int index = 0;
      while (index < dirtyCount) {
        final Element element = _dirtyElements[index];
        try {
          element.rebuild();  //tag2
        } catch (e, stack) {}
        index += 1;
      }
    } finally {
      for (final Element element in _dirtyElements) {
        element._inDirtyList = false;
      }
      _dirtyElements.clear(); //tag3
  }

performRebuild()在前面使用nil来创建占位也提到过的核心功能。现在我们从源码层面解读一下。

performRebuild是定义在Element中的一个抽象方法。不同子类有不同的实现。元素依照是否可以渲染可以分为组合元素CompantElement和和渲染元素RenderObjectElement两类。

我们以CompantElement中的performRebuild为例

tag1: 对于STF组件,其实就是调用state.build()方法。创建Widget实例。

tag2: 将这个新build()出来的widget作为newWidget和之前挂载在Element树上的_child(Element类型)作为参数,传入updateChild(_child, built, slot)中。setState()更新页面的核心逻辑就在 updateChild(_child, built, slot)函数中。

 void performRebuild() {
    Widget? built;
    try {
      built = build();  //tag1
    } 
    try {
      _child = updateChild(_child, built, slot);  //tag2
    } catch (e, stack) {
      
    }
  }

OK!终于到达核心逻辑了。不容易!

updateChild(_child, built, slot),可以函数可以称为组件挂载(mounted)的轮回之门,源码位置Element#updateChild。这个方法逻辑还挺多,但是其实官方对这个函数的核心思想有注释,我整理了一下,应该比较清晰了。

4.png

updateChild源码如下,大家可以结合上面的表格中说明再回味一下。

 Element? updateChild(Element? child, Widget? newWidget, Object? newSlot) {
    if (newWidget == null) {
      if (child != null)
        deactivateChild(child); //tag2
      return null;   //tag1
    }
    final Element newChild;
    if (child != null) {
      bool hasSameSuperclass = true;

      if (hasSameSuperclass && child.widget == newWidget) { //tag4
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
        newChild = child;
      } else if (hasSameSuperclass && Widget.canUpdate(child.widget, newWidget)) {
        if (child.slot != newSlot)
          updateSlotForChild(child, newSlot);
       
        child.update(newWidget);  //tag5
        newChild = child;
      } else {
        deactivateChild(child);  //tag6
        newChild = inflateWidget(newWidget, newSlot);
      }
    } else {
      newChild = inflateWidget(newWidget, newSlot);
    }
    return newChild;  //tag3
  }

重点来看看setState是如何从上往下更新或者叫挂载组件的。我们关注一下上面的tag5,也就是 child.update(newWidget)方法。这个方法也是定义在Element类中的抽象方法,也就是说不同类型的Element子类有不同的实现。我们以SingleChildRenderObjectElement中的update()为例子

tag1: 调用父类,其实就是_widget = newWidget;

tag2:继续递归调用updateChild,当这个时候函数返回,其实已经把_child赋值为元素树中下一层级元素了。直到_child=null结束递归,就说明已经更新到树的叶子节点了。

好! 到这,一次完整的setState就形成了闭环。

@override
void update(SingleChildRenderObjectWidget newWidget) {
  super.update(newWidget);  //tag1
  _child = updateChild(_child, widget.child, null);  //tag2
}

现在可以解释一下,尽量不要在高层级树进行setState 的原因了。其实理解了上面的源码分析已经说明问题了。但是画一个图吧,一图胜千言万语。如下图中的红框部分,setSate()在树中的层级越到,递归次数就越多。

Flowchart-w300

题外话

仔细看完源码,让我对这个优化有了新的认知。 setState 的本质是什么,是元素节点的更新。元素节点更新时,会重新创建 Widget实例 ,很多时候,元素树、渲染树中的节点仍是之前的对象,只是对渲染对象的属性进行重新设置而已。所以如果setState并没有改变元素树的结构,对于现在手机CPU来说,只是给对象的属性重新赋值,这就谈不上高层setState存在的性能瓶颈。但是其实很多时候,build函数创建的新Widget其实就比较耗时的话,该条策略还是很适用的。

优化的时候使用一些状态管理库,比如GetX, Provider来替代setState()。

示例

  1. 聊天公屏,点击卡片消息进行回复

比如在之前的代码中,为了在卡片下面显示一个“回复”按钮,使用了setState()。 这样会导致列表都会重新build,有点牛刀杀鸡的感觉。

 setState(() {
    selectedMessage = current;
});

可以使用ValueListenableBuilder组件监听卡片是否显示回复按钮。这样就实现了局部刷新。

ValueListenableBuilder(
  valueListenable: model.selectedMessage,
  builder: (_, value, __) => Visibility(
      visible: model?.selectedMessage?.value ?? false
      child:  Row(
          children: const [
            ReplyButton(),
            Spacer(),
          ...
),

七、AnimatedBuilder尽量配合child使用

在使用AnimatedBuilder组件的时候,如果子组件不依赖动画提供的数据,那么为其提供一个child。这样在进行动画的时候,我们就可以重用这个child。减少没必要的重绘。

官方文档

Performance optimizations

If your builder function contains a subtree that does not depend on the animation, it's more efficient to build that subtree once instead of rebuilding it on every animation tick.

If you pass the pre-built subtree as the child parameter, the AnimatedBuilder will pass it back to your builder function so that you can incorporate it into your build.

Using this pre-built child is entirely optional, but can improve performance significantly in some cases and is therefore a good practice.

AnimatedBuilder源码小结

首先看下AnimatedBuilder的继承关系图,AnimatedBuilder最终会继承STF组件,STF组件其实就可以保存状态,并且可以使用setState()更新组件

5.png

AnimatedWidget主要作用是创建监听流,并且通知界面进行更新。其中的_handleChange函数只做了一件事情,就是触发setState(),在动画的过程中就实现了组件刷新,就形成了动画。

下面我们来关注一下AnimatedBuilder的源码,为什么传入child可以复用了,而不需要重新创建和动画无关的组件了。前面我们说了,其实AnimatedBuilder是一个STF组件,所以如果传入了child Widget。在build的时候,会直接把child作为builder的参数传入,这样尽管动画一直在触发builder方法,但其实和动画无关的子child并没有重复创建。

拓展一下

ValueListenableBuilder其实也可以传入child参数。原理其实和我们上面分析的一样,就不再赘述了。使用的时候也需要注意这一点,ValueListenableBuilder监听的值如果经常在变化,最好还是考虑把和监听值无关的子组件作为child传入。

示例

比如聊天会话窗口置顶消息的时候,目前使用的是一下代码。在置顶的时候,最底层Builder()方法,其实不需要使用动画数据,但是这样写会导致在动画过程中重复的绘制这个Builder(),消耗CPU和GPU线程。

我打印了一些日志,可以看到情况和我们分析的一样。

Widget _buildStickBanner() {
  return GetBuilder<StickMessageController>(
      builder: (stickMessageController) {
        logger.info("11111111111111111111 builder");
        return AnimatedBuilder(
            animation: stickMessageController.animationController,
            builder: (context, child) {
              logger.info("333333333333333333333 builder");
              return FadeTransition(
                opacity: stickMessageController.animationController,
                child: SizeTransition(
                  sizeFactor: stickMessageController.animationController,
                  axisAlignment: -1,
                  child: Builder(
                    builder: (context) {
                      logger.info("44444444444444444 builder");
                       ...
                       ...
}

flutter: Logger [HTTP] /api/message/top start
flutter: Logger [HTTP] /api/message/top done in 328ms
flutter: Logger 11111111111111111111 builder
flutter: Logger 333333333333333333333 builder
flutter: Logger 44444444444444444 builder
flutter: Logger 333333333333333333333 builder
flutter: Logger 44444444444444444 builder
flutter: Logger 333333333333333333333 builder
flutter: Logger 44444444444444444 builder
flutter: Logger 333333333333333333333 builder
......

既然最底层的Builder()和动画数据无关,那么就把他提出来。如下所示。通过日志发现,在顶置动画过程中,Builder()只绘制了一次,嗯!很好!

Widget _buildStickBanner() {
  return GetBuilder<StickMessageController>(
      init: StickMessageController.to(channelId: widget.model.channelId),
      tag: widget.model.channelId,
      builder: (stickMessageController) {
        logger.info(
            "11111111111111111111 builder");
        final child = Builder(
          builder: (context) {
            logger.info("44444444444444444 builder");
            .....
            .....
        );
        return AnimatedBuilder(
            animation: stickMessageController.animationController,
            child: child,
            builder: (context, child) {
              logger.info("333333333333333333333 builder");
              return FadeTransition(
                opacity: stickMessageController.animationController,
                child: SizeTransition(
                  sizeFactor: stickMessageController.animationController,
                  axisAlignment: -1,
                  child: child,
                ),
              );
            });
      });
}

flutter: Logger [HTTP] /api/message/top done in 271ms
flutter: Logger 11111111111111111111 builder
flutter: Logger 333333333333333333333 builder
flutter: Logger 44444444444444444 builder
flutter: Logger 333333333333333333333 builder
flutter: Logger 333333333333333333333 builder
flutter: Logger 333333333333333333333 builder
flutter: Logger 333333333333333333333 builder
......

八、谨慎使用KeepAlive

SliverList的SliverChildBuilderDelegate,有一个参数值得我们注意addAutomaticKeepAlives,默认设置为true。配合AutomaticKeepAliveClientMixin混入用来缓存列表的每一个卡片的渲染对象。

KeepAlive源码浅析

_keepAliveBucket实现源码,可查看rendering/sliver_multi_box_adaptor.dart:215

复用思路其实很简单,在要释放的时候,把它存起来,在需要使用的时候优先从缓存中取。很幸运,Flutter底层也是这个思路。

  1. 如何存起来,释放的时候

阅读源码的时候,首先不要看的太细致,先要大致理解其中的整体思路,让自己对新知识有一个轮廓上认知再去看细节。

void _destroyOrCacheChild(RenderBox child) {
  final SliverMultiBoxAdaptorParentData childParentData = child.parentData! as SliverMultiBoxAdaptorParentData;
  if (childParentData.keepAlive) {
    assert(!childParentData._keptAlive);
    remove(child);
    _keepAliveBucket[childParentData.index!] = child;
    child.parentData = childParentData;
    super.adoptChild(child);
    childParentData._keptAlive = true;
  } else {
    assert(child.parent == this);
    _childManager.removeChild(child);
    assert(child.parent == null);
  }
}
  1. 如何取出来,创建的时候
void _createOrObtainChild(int index, { required RenderBox? after }) {
  invokeLayoutCallback<SliverConstraints>((SliverConstraints constraints) {
    assert(constraints == this.constraints);
    if (_keepAliveBucket.containsKey(index)) {
      final RenderBox child = _keepAliveBucket.remove(index)!;
      final SliverMultiBoxAdaptorParentData childParentData = child.parentData! as SliverMultiBoxAdaptorParentData;
      assert(childParentData._keptAlive);
      dropChild(child);
      child.parentData = childParentData;
      insert(child, after: after);
      childParentData._keptAlive = false;
    } else {
      _childManager.createChild(index, after: after);
    }
  });
}

使用

针对ListView,虽然在创建SliverChildBuilderDelegate时候把addAutomaticKeepAlives属性设置为true。但是SliverList中的每一个卡片,并没有混入AutomaticKeepAliveClientMixin,这样其实并没有缓存列表中的渲染对象,每次滑动的时候,离开缓冲区位置的元素和渲染对象都会释放。

假如想在列表中缓存卡片的渲染对象,可以使用以下组件包裹列表中的每一个卡片。这样在列表滑动的时候理论上会好一些,因为少了渲染对象的重复创建。但是这样有一个问题,就是如果列表中很多图片,一直缓存的话,查看会导致APP内存无限增大。所以说这个地方谨慎使用。目前我想到的是,根据列表中卡片的类型决定是否缓存,比如图片肯定不缓存。文本,纯文字富文本之类的组件可以考虑使用缓存。当然还可以加上其他的策略。

class _TextItemBucketWidget extends StatefulWidget {
  // MessageEntity message;
  final bool needKeepAlive;
  final Widget childItem;
  const _TextItemBucketWidget(
      {Key key, @required this.childItem, this.needKeepAlive = false})
      : super(key: key);

  @override
  State<_TextItemBucketWidget> createState() => _TextItemBucketWidgetState();
}

class _TextItemBucketWidgetState extends State<_TextItemBucketWidget>
    with AutomaticKeepAliveClientMixin {
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return widget.childItem;
  }

  @override
  bool get wantKeepAlive => widget.needKeepAlive;
}

九、常见第三方库性能问题

HiveBox过度使用导致的性能问题

这个我之前的文章有详细分析,大家进去可以看看细节。 HiveBox是纯Dart编写的轻量级快速键值对数据库。

项目中曾一度大量使用HiveBox来缓存数据。HiveBox使用内存和磁盘相互映射,存储和读取速度都十分优秀,不过随着项目的缓存数据量的增加,就出现了由于HiveBox过度使用导致的性能问题。其中主要问题表现形式就是HiveBox的磁盘缓存文件巨大,出现过2G以上,导致的openbox很慢。因为HiveBox在open的时候,会把磁盘中的数据流式写到内存中,中间的文件读取,解码,实例化对象需要花不少时间和空间。

想知道其中缘由,我们先看看HiveBox的数据储存结构

下面是测试的数据。

配合源码我们可以得出以下性能瓶颈结论

  • 在整个流程中,我们可以发现hive的整体思想就是用空间换时间。比如添加,删除key,并不是真实意义上的添加,删除相应的数据,而是直接在数据流中追加新数据,这样的话,对于内存中的数据没什么影响,但是因为直接追加写文件,对于磁盘中的文件大小影响巨大,特别是value是数组或者是字典的时候。
  • 分析发现其实openbox的时候,做了不少的事情,读取帧长,读取key, crc32校验,读取value,对于自定义对象,读取value还是进一步转换,这样解析下来,对于数据量太大的box, 打开时间还是难以忍受。

总结

本文是在平时对Flutter项目的列表优化的工作中,阅读了ListView部分源码,并结合社区大佬的一些优质文章而成。文章的例子有部分已经应用到项目中,效果也比较好。并且基本上每一个优化点都写了底层原由,方便大家深入理解。

ListView性能优化,任重道远!但是其实对于每个Widget都有最优使用方式,在调优的过程中,我们要注重各个小细节的优化,积小成多,到最后产品性能也会有质的飞越!

参考资料

Using the Performance view

What is the difference between functions and classes to create reusable widgets?

How to improve the performance of your Flutter app | Codemagic Blog

weilu.blog.csdn.net