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:
Create a Layer specifically for drawing the chessboard and caching it.
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:
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:
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 thepaint
method.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
'sisRepaintBoundary
to true, the Flutter framework will clear the child nodes of the layer on every redraw. Consequently, our chessboardPictureLayer
would be removed, leading to an exception.If
RenderChess
'sisRepaintBoundary
is false (the default), the Flutter framework will not utilize thelayer
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:
The
layer
field inRenderObject
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 thelayer
field for non-repaint boundary nodes, our code may break.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 aLayerHandle
, 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:
Set
RenderChess
'sisRepaintBoundary
to true; this will turn the current node into a repaint boundary, allowingChessWidget
and the button to be rendered on different layers, thus avoiding mutual influence.When using
ChessWidget
, wrap it with aRepaintBoundary
component. This works similarly to option 1, except this method makes the parent node ofChessWidget
(theRepaintBoundary
) 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.