跳到正文
Willh的个人博客 记录 · 思考 · 分享
《Flutter实战·第二版》

14.7 绘制(三)Layer 实例

本节通过优化之前“绘制棋盘示例“来像大家展示如何在自定义组件中使用Layer。

14.7.1

我们之前绘制棋盘示例是使用的CustomPaint组件,然后再painter的paint方法中同时实现了绘制棋盘和棋子,实际上这里可以有一个优化,因为棋盘是不会变化的,所以理想的方式就是当绘制区域不发生变化时,棋盘只需要绘制一次,当棋子发生变化时,每次只需要绘制棋子信息即可。

在实际开发中,要实现上述功能还是优先使用Flutter建议的”Widget组合“的方式:比如棋盘和棋子分别绘制在两个Widget中,然后把他们添加到一个Stack中,这样做到分层渲染。不过,本节主要是为了说明Flutter自定义组件中如何使用Layer,所以我们采用自定义RenderObject的方式来实现。

  1. 首先我们定义一个ChessWidget,因为它并非容器类组件,所以继承自 LeafRenderObjectWidget 。

    1class ChessWidget extends LeafRenderObjectWidget {
    2  
    3  RenderObject createRenderObject(BuildContext context) {
    4    // 返回Render对象
    5    return RenderChess();
    6  }
    7  //...省略updateRenderObject函数实现
    8}

    由于自定义的 RenderChess 对象不接受任何参数,所以我们可以在ChessWidget 中不用实现updateRenderObject方法。

  2. 实现 RenderChess;我们先直接实现一个未缓存棋盘的原始版本,随后我们再一点点添加代码,直到把它改造成可以缓存棋盘的对象。

    1class RenderChess extends RenderBox {
    2  
    3  void performLayout() {
    4    //确定ChessWidget的大小
    5    size = constraints.constrain(
    6      constraints.isTight ? Size.infinite : Size(150, 150),
    7    );
    8  }
    9
    10  
    11  void paint(PaintingContext context, Offset offset) {
    12    Rect rect = offset & size;
    13    drawChessboard(canvas, rect); // 绘制棋盘
    14    drawPieces(context.canvas, rect);//绘制棋子
    15  }
    16}
  3. 接下来我们需要实现棋盘缓存,我们的思路是:

    • 创建一个 Layer 专门绘制棋盘,然后缓存。
    • 当重绘触发时,如果绘制区域发生了变化,则重新绘制棋盘并缓存;如果绘制区域未变,则直接使用之前的Layer

    为此,我们需要定义一个PictureLayer来缓存棋盘,然后添加一个 _checkIfChessboardNeedsUpdate 函数来实现上述逻辑:

    1// 保存之前的棋盘大小
    2Rect _rect = Rect.zero;
    3PictureLayer _layer = PictureLayer()
    4
    5_checkIfChessboardNeedsUpdate(Rect rect) {
    6  // 如果绘制区域大小没发生变化,则无需重绘棋盘
    7  if (_rect == rect) return;
    8  
    9  // 绘制区域发生了变化,需要重新绘制并缓存棋盘
    10  _rect = rect;
    11  print("paint chessboard");
    12 
    13  // 新建一个PictureLayer,用于缓存棋盘的绘制结果,并添加到layer中
    14  ui.PictureRecorder recorder = ui.PictureRecorder();
    15  Canvas canvas = Canvas(recorder);
    16  drawChessboard(canvas, rect); //绘制棋盘
    17  // 将绘制产物保存在pictureLayer中
    18  _layer = PictureLayer(Rect.zero)..picture = recorder.endRecording();
    19}
    20
    21
    22void paint(PaintingContext context, Offset offset) {
    23  Rect rect = offset & size;
    24  //检查棋盘大小是否需要变化,如果变化,则需要重新绘制棋盘并缓存
    25  _checkIfChessboardNeedsUpdate(rect);
    26  //将缓存棋盘的layer添加到context中,每次重绘都要调用,原因下面会解释
    27  context.addLayer(_layer);
    28  //再画棋子
    29  print("paint pieces");
    30  drawPieces(context.canvas, rect);
    31}

    具体的实现逻辑见注释,这里不再赘述,需要特别解释的是在 paint 方法中,每次重绘都需要调用 context.addLayer(_layer) 将棋盘layer添加到当前的Layer树中,通过上一节的介绍,我们知道,实际上是添加到了当前节点的第一个绘制边界节点的Layer中。可能会有读者疑惑,如果棋盘不变的话,添加一次不就行了,为什么每次重绘都要添加?实际上这个问题我们上一节已经解释过了,因为重绘是当前节点的第一个父级向下发起的,而每次重绘前,该节点都会先清空所有的孩子,代码见 PaintingContext.repaintCompositedChild 方法,所以我们需要每次重绘时都要添加一下。

    思考题:为什么父级绘制边界节点每次重绘前都要先清空其layer的所有孩子?

    OK,现在我们已经实现了棋盘缓存了,下面我们来验证一下。

  4. 我们创建一个测试 Demo 来验证一下,我们创建一个 ChessWidget 和一个 ElevatedButton,因为ElevatedButton在点击时会执行水波动画,所以会发起一连串的重绘请求,而根据上一节的知识,我们知道ChessWidget 和 ElevatedButton 会在同一个Layer上绘制,所以 ElevatedButton 重绘也会导致ChessWidget 的重绘。另外我们在绘制棋子和棋盘时都加了日志,所以我们只需要点击 ElevatedButton,然后查看日志就能验证棋盘缓存是否生效。

    在当前版本的Flutter中,ElevatedButton 的实现中并没有添加 RepaintBoundary,所以它才会和ChessWidget 在同一个 Layer 上渲染,如果后续 Flutter SDK中给 ElevatedButton 添加了RepaintBoundary,则不能通过本例来验证。

    1class PaintTest extends StatefulWidget {
    2  const PaintTest({Key? key}) : super(key: key);
    3
    4  
    5  State<PaintTest> createState() => _PaintTestState();
    6}
    7
    8class _PaintTestState extends State<PaintTest> {
    9  ByteData? byteData;
    10
    11  
    12  Widget build(BuildContext context) {
    13    return Center(
    14      child: Column(
    15        mainAxisSize: MainAxisSize.min,
    16        children: [
    17          ChessWidget(),
    18          ElevatedButton(
    19            onPressed: () {
    20              setState(() => null);
    21            },
    22            child: Text("setState"),
    23          ),
    24        ],
    25      ),
    26    );
    27  }
    28}

    点击按钮后:发现,棋盘、棋子都可以正常显示:

    ChessWidget测试

    同时日志面板输出了很多"paint pieces",并没有"paint chessboard",可见棋盘缓存生效了。

    好的,貌似我们预期的功能已经实现了,但是别高兴太早,上面的代码还有一个内存泄露的坑,我们在下面LayerHandle 部分介绍。

14.7.2 LayerHandle

上面 RenderChess 实现中,我们将棋盘绘制信息缓存到了 layer 中,因为 layer 中保存的绘制产物是需要调用dispose方法释放的,如果UI变化需要ChessWidget销毁时没有释放则会发生内存泄露,所以们需要在组件销毁时,手动释放一下,给RenderChess中添加如下代码:

1
2void dispose() {
3  _layer.dispose();
4  super.dispose();
5}

上面的场景比较简单,实际上,在Flutter中一个layer可能会反复被添加到多个容器类Layer中,或从容器中移除,这样一来有些时候我们可能会搞不清楚一个layer是否还被使用,为了解决这个问题,Flutter中定义了一个LayerHandle 类来专门管理layer,内部是通过引用计数的方式来跟踪layer是否还有使用者,一旦没有使用者,会自动调用layer.dispose来释放资源。为了符合Flutter规范,强烈建议读者在需要使用layer的时候通过LayerHandle来管理它。现在我们来修改一下上面的代码,RenderChess中定义一个 layerHandle,然后将_layer 全部替换为 layerHandle.layer:

1// 定义一个新的 layerHandle
2final layerHandle = LayerHandle<PictureLayer>();
3 
4_checkIfChessboardNeedsUpdate(Rect rect) {
5    ...
6    layerHandle.layer = PictureLayer(Rect.zero)..picture = recorder.endRecording();
7  }
8
9  
10  void paint(PaintingContext context, Offset offset) {
11    ...
12    //将缓存棋盘的layer添加到context中
13    context.addLayer(layerHandle.layer!);
14    ...
15  }
16
17  
18  void dispose() {
19    //layer通过引用计数的方式来跟踪自身是否还被layerHandle持有,
20    //如果不被持有则会释放资源,所以我们必须手动置空,该set操作会
21    //解除layerHandle对layer的持有。
22    layerHandle.layer = null;
23    super.dispose();
24  }

OK,这样就很好了!不过先别急着庆祝,现在我们再来回想一下上一节介绍的内容,每一个 RenderObject 都有一个layer 属性,我们能否直接使用它来保存棋盘layer呢?下面我们看看 RenderObject 中关于 layer 的定义:

1
2set layer(ContainerLayer? newLayer) {
3  _layerHandle.layer = newLayer;
4}
5
6final LayerHandle<ContainerLayer> _layerHandle = LayerHandle<ContainerLayer>();

可以发现,我们RenderObject 中已经定义了一个 _layerHandle了,它会去管理 layer;同时 layer 是一个setter,会自动将新 layer 赋值到 _layerHandle 上,那么我们是否可以在 RenderChess 中直接使用父类定义好的 _layerHandle,这样的话我们就无需再自定义一个 layerHandle 了。读者可以先结合上一节的内容思考一分钟,然后我们再往下看。

答案是:取决于当前节点的 isRepaintBoundary 属性是否为 true(即当前节点是否为绘制边界节点) ,如果为 true 则不可以,如果不为 true,则可以。上一节中讲过,Flutter在执行 flushPaint 重绘时遇到绘制边界节点:

  1. 先检查其 layer 是否为空,如果不为空,则会先清空该 layer 的孩子节点,然后会使用该 layer 创建一个 PaintingContext,传递给 paint 方法。
  2. 如果其 layer 为空,会创建一个 OffsetLayer 给它。

如果我们要将棋盘layer保存到预定义的 layer变量中的话,得先创建一个ContainerLayer,然后将绘制棋盘的PictureLayer作为子节点添加到新创建的ContainerLayer中,然后赋值给 layer变量。这样一来:

  1. 如果我们设置 RenderChess 的 isRepaintBoundary 为true,那么在每次重绘时,flutter 框架都会将 layer 子节点清空,这样的话,我们的棋盘Picturelayer就会被移除,接下来就会触发异常。
  2. 如果 RenderChess 的 isRepaintBoundary 为 false(默认值),则在重绘过程中 flutter 框架不会使用到 layer 属性,这中情况没有问题。

虽然,本例中 RenderChess 的 isRepaintBoundary 为 false,直接使用 layer是可以的,但我不建议这么做,原因有二:

  1. RenderObject 中的 layer 字段在 Flutter 框架中是专门为绘制流程而设计的,按照职责分离原则,我们也不应该去蹭它。即使现在能蹭成功,万一哪天Flutter的绘制流发生变化,比如也开始使用非绘制边界节点的layer字段,那么我们的代码将会出问题。
  2. 即使去使用,我们也需要先创建一个ContainerLayer,既然如此,我们还不如乖乖创建一个LayerHandle,这省不了多少工夫。

现在考虑最后一个问题,在上面示例中,我们点击按钮后,虽然棋盘不会重绘了,但棋子还是会重绘,这并不合理,我们希望棋盘区域不受外界干扰,只有新的落子行为时(点击在棋盘区域)时再重绘棋子。相信看到着,解决方案就呼之欲出了,我们有两种选择:

  1. RenderChess 的 isRepaintBoundary 返回 true;将当前节点变为一个绘制边界,这样 ChessWidget 就会和按钮分别在不同的 layer 上绘制,也就不会相互影响。
  2. 在使用 ChessWidget 时,给它套一个RepaintBoundary组件,和1的原理差不多的,只不过这种方式是将ChessWidget的父节点(RepaintBoundary)变为了绘制边界(而不是自身),这样也会创建一个新的 layer 来隔离按钮的绘制。

具体应该选哪种应该根据情况而定,第二种方案会更灵活,但第一种方案的实际效果往往会比较好,因为如果我们封装的复杂自绘控件中没有设置 isRepaintBoundary 为 true,我们很难保证使用者在使用时会给我们的空间添加RepaintBoundary,所以这种细节还是对使用者屏蔽掉会比较好。