Flutter (96): Drawing (three) Layer example

Time: Column:Mobile & Frontend views:196

1 Implementing Drawing Cache Using Layers

In this section, we will demonstrate how to use Layers in custom components by optimizing the previous "Drawing Chessboard Example."

We initially used the CustomPaint component to draw the chessboard and the pieces within the painter's paint method. However, this can be optimized since the chessboard does not change. Ideally, the chessboard should be drawn only once when the drawing area remains the same, and only the pieces should be redrawn when changes occur.

Note: In actual development, it is recommended to achieve the above functionality using Flutter's suggested approach of "Widget Composition": for example, drawing the chessboard and pieces in two separate Widgets, wrapping them with a RepaintBoundary, and then adding them to a Stack. However, this section mainly aims to illustrate how to use Layers in Flutter's custom components, so we will implement it using a custom RenderObject.

First, we define a ChessWidget. Since it is not a container component, it inherits from LeafRenderObjectWidget.

class ChessWidget extends LeafRenderObjectWidget {
  @override
  RenderObject createRenderObject(BuildContext context) {
    // Return Render object
    return RenderChess();
  }
  //... Omit the implementation of updateRenderObject
}

Since the custom RenderChess object does not accept any parameters, we do not need to implement the updateRenderObject method in ChessWidget.

Next, we implement RenderChess. We will first create a basic version without caching the chessboard, and then we will gradually add code to transform it into a version that can cache the chessboard.

class RenderChess extends RenderBox {
  @override
  void performLayout() {
    // Determine the size of ChessWidget
    size = constraints.constrain(
      constraints.isTight ? Size.infinite : Size(150, 150),
    );
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    Rect rect = offset & size;
    drawChessboard(canvas, rect); // Draw the chessboard
    drawPieces(context.canvas, rect); // Draw the pieces
  }
}

Next, we need to implement the chessboard caching. Our approach is to:

  1. Create a Layer specifically for drawing the chessboard and caching it.

  2. When a redraw is triggered, if the drawing area has changed, redraw the chessboard and cache it; if the area remains unchanged, use the previous Layer.

To do this, we need to define a PictureLayer to cache the chessboard and add a _checkIfChessboardNeedsUpdate function to implement the above logic:

// Store the previous size of the chessboard
Rect _rect = Rect.zero;
PictureLayer _layer = PictureLayer();

_checkIfChessboardNeedsUpdate(Rect rect) {
  // If the drawing area size hasn't changed, no need to redraw the chessboard
  if (_rect == rect) return;
  
  // The drawing area has changed; need to redraw and cache the chessboard
  _rect = rect;
  print("paint chessboard");

  // Create a new PictureLayer to cache the drawing result of the chessboard
  ui.PictureRecorder recorder = ui.PictureRecorder();
  Canvas canvas = Canvas(recorder);
  drawChessboard(canvas, rect); // Draw the chessboard
  // Save the drawing product in the PictureLayer
  _layer = PictureLayer(Rect.zero)..picture = recorder.endRecording();
}

@override
void paint(PaintingContext context, Offset offset) {
  Rect rect = offset & size;
  // Check if the chessboard size needs to change; if it does, redraw and cache it
  _checkIfChessboardNeedsUpdate(rect);
  // Add the cached chessboard layer to the context; this needs to be called on every redraw
  context.addLayer(_layer);
  // Draw the pieces
  print("paint pieces");
  drawPieces(context.canvas, rect);
}

The specific implementation logic is detailed in the comments, so I won't elaborate further. It's important to note that in the paint method, context.addLayer(_layer) must be called to add the chessboard layer to the current Layer tree during every redraw. As explained in the previous section, this effectively adds it to the Layer of the current node's first rendering boundary node. Some readers might wonder why we need to add it every time if the chessboard doesn't change. This has already been explained in the last section: redraws are initiated downwards from the first parent of the current node, and before each redraw, this node clears all its children (see the PaintingContext.repaintCompositedChild method). Thus, we need to add it again during each redraw.

Question: Why does the parent rendering boundary node clear all of its layer's children before each redraw?

Now that we have implemented chessboard caching, let's verify it.

We will create a test demo that includes a ChessWidget and an ElevatedButton. Since the ElevatedButton triggers a ripple animation on click, it will initiate a series of redraw requests. According to the knowledge from the previous section, we know that ChessWidget and ElevatedButton will be rendered on the same Layer, meaning the redraw of the ElevatedButton will also cause a redraw of the ChessWidget. Additionally, since we have added logging to both the piece and chessboard drawings, we only need to click the ElevatedButton and check the logs to confirm whether the chessboard caching is effective.

Note: In the current version (3.0) of Flutter, the implementation of ElevatedButton does not include a RepaintBoundary, which is why it renders on the same Layer as the ChessWidget. If a RepaintBoundary is added to ElevatedButton in a future version of the Flutter SDK, this example will no longer be valid for verification.

class PaintTest extends StatefulWidget {
  const PaintTest({Key? key}) : super(key: key);

  @override
  State<PaintTest> createState() => _PaintTestState();
}

class _PaintTestState extends State<PaintTest> {
  ByteData? byteData;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const ChessWidget(),
          ElevatedButton(
            onPressed: () {
              setState(() => null);
            },
            child: Text("setState"),
          ),
        ],
      ),
    );
  }
}

After clicking the button, we observe that both the chessboard and pieces are displayed correctly, as shown in Figure:

Flutter (96): Drawing (three) Layer example

The logs also output many "paint pieces" messages without any "paint chessboard" messages, indicating that the chessboard caching is functioning effectively.

Alright, it seems that we have achieved the expected functionality, but don't celebrate too early—there's a memory leak issue in the code that we will discuss in the LayerHandle section below.

2 LayerHandle

In the implementation of RenderChess above, we cached the chessboard drawing information in a layer. Since the drawing products saved in the layer need to call the dispose method for release, if the ChessWidget is destroyed without releasing it, a memory leak will occur. Therefore, we need to manually release it when the component is destroyed by adding the following code in RenderChess:

@override
void dispose() {
  _layer.dispose();
  super.dispose();
}

The scenario above is relatively simple. In reality, a layer in Flutter might be repeatedly added to multiple container layers or removed from them. Sometimes, we might not be clear if a layer is still in use. To address this issue, Flutter defines a LayerHandle class specifically to manage layers. It internally uses reference counting to track whether a layer still has users. Once there are no users, it automatically calls layer.dispose to release resources. To comply with Flutter standards, it is strongly recommended that readers manage layers through LayerHandle when needed. Now, let’s modify the code above to define a layerHandle in RenderChess, replacing _layer with layerHandle.layer:

// Define a new layerHandle
final layerHandle = LayerHandle<PictureLayer>();

_checkIfChessboardNeedsUpdate(Rect rect) {
    ...
    layerHandle.layer = PictureLayer(Rect.zero)..picture = recorder.endRecording();
}

@override
void paint(PaintingContext context, Offset offset) {
    ...
    // Add the cached chessboard layer to the context
    context.addLayer(layerHandle.layer!);
    ...
}

@override
void dispose() {
    // The layer tracks whether it is still held by the layerHandle using reference counting.
    // If not held, it will release resources, so we must manually nullify it; this set operation
    // will release the layerHandle's hold on the layer.
    layerHandle.layer = null;
    super.dispose();
}

Great, that works! But don't celebrate just yet. Now let’s recall the content from the previous section: every RenderObject has a layer property. Can we directly use it to save the chessboard layer? Let's check the definition of layer in RenderObject:

@protected
set layer(ContainerLayer? newLayer) {
  _layerHandle.layer = newLayer;
}

final LayerHandle<ContainerLayer> _layerHandle = LayerHandle<ContainerLayer>();

We can see that _layerHandle is already defined in our RenderObject to manage the layer. The layer property is a setter that automatically assigns the new layer to _layerHandle. So, can we use the predefined _layerHandle in RenderChess, thereby eliminating the need to define a new layerHandle? Readers can take a minute to think about this before we proceed.

The answer is: it depends on whether the current node’s isRepaintBoundary property is true (i.e., whether the current node is a repaint boundary node). If true, it cannot be used; if not, it can be. As mentioned in the previous section, when Flutter executes flushPaint during a redraw and encounters a repaint boundary node:

  1. It first checks if its layer is null. If not, it clears the layer's child nodes before using that layer to create a PaintingContext, which is passed to the paint method.

  2. If the layer is null, it creates an OffsetLayer for it.

If we want to save the chessboard layer to a predefined layer variable, we must first create a ContainerLayer, then add the PictureLayer used for drawing the chessboard as a child to the newly created ContainerLayer, and finally assign it to the layer variable. This way:

  • If we set RenderChess's isRepaintBoundary to true, the Flutter framework will clear the child nodes of the layer on every redraw. Consequently, our chessboard PictureLayer would be removed, leading to an exception.

  • If RenderChess's isRepaintBoundary is false (the default), the Flutter framework will not utilize the layer property during redraws, which is fine.

Although in this example, since RenderChess has isRepaintBoundary set to false, directly using the layer is permissible, I do not recommend this for two reasons:

  1. The layer field in RenderObject is specifically designed for the drawing process within the Flutter framework. According to the principle of separation of concerns, we should not override it. Even if it works now, if the Flutter drawing process changes to utilize the layer field for non-repaint boundary nodes, our code may break.

  2. If we want to use a layer, we still need to create a ContainerLayer. Since this is the case, we might as well directly create a LayerHandle, which is more convenient.

Now, let’s consider the final issue: in the example above, when we click the button, although the chessboard does not redraw, the pieces still do, which is not ideal. We want the chessboard area to be unaffected by external actions, only redrawing the pieces when a new move is made (when clicking in the chessboard area). The solution is evident, and we have two options:

  1. Set RenderChess's isRepaintBoundary to true; this will turn the current node into a repaint boundary, allowing ChessWidget and the button to be rendered on different layers, thus avoiding mutual influence.

  2. When using ChessWidget, wrap it with a RepaintBoundary component. This works similarly to option 1, except this method makes the parent node of ChessWidget (the RepaintBoundary) the repaint boundary instead, thus creating a new layer to isolate the button's drawing.

The choice of which option to use should depend on the situation. The second option is more flexible, but the first often yields better practical results because if we don't set isRepaintBoundary to true in a complex self-drawing widget, it’s challenging to ensure that users will add a RepaintBoundary to our space. Therefore, it is better to shield such details from the users.