通过Layer实现绘制缓存
之前绘制棋盘示例是使用的CustomPaint组件,然后再painter的paint方法中同时实现了绘制棋盘的棋子,这里可以做一个优化,因为棋盘是不会变化的,所以理想的方式是当绘制区域不发生变化时,棋盘只需要绘制一次,当棋子发生变化时,每次只需要绘制棋子信息即可。
注意:在实际开发中,要实现上述功能还是优先使用Flutter建议的Widget组合的方式,比如棋盘棋子分别在两个Widget中,然后包上RepaintBoundary组件后把它们添加到Stack中,这样做到了分层渲染。
- 首先定义一个ClessWidget,因为它并非容器类组件,所以继承自LeafRenderObjectWidget。
class ChessWidget extends LeafRenderObjectWidget {
@override
RenderObject createRenderObject(BuildContext context) {
// 返回Render对象
return RenderChess();
}
//...省略updateRenderObject函数实现
}
由于自定义的RenderChess对象不接受任何参数,所以可以在ChessWidget中不用实现UpdateRenderObject方法。 2. 实现RenderChess;先直接实现一个为缓存棋盘的原始版本,随后再添加代码,直到把它改造成可以缓存棋盘的对象。
class RenderChess extends RenderBox {
@override
void performLayout() {
//确定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); // 绘制棋盘
drawPieces(context.canvas, rect);//绘制棋子
}
}
- 接下来需要实现棋盘缓存,思路是:
- 创建一个Layer专门绘制棋盘,然后缓存。
- 当重绘触发时,如果绘制区域发生了变化,则重新绘制棋盘并缓存;如果绘制区域未变,则直接使用之前的layer
为此,需要定义一个PictureLayer来缓存棋盘,然后添加一个_checkIfChessboardNeedsUpdate函数来实现逻辑:
// 保存之前的棋盘大小
Rect _rect = Rect.zero;
PictureLayer _layer = PictureLayer()
_checkIfChessboardNeedsUpdate(Rect rect) {
// 如果绘制区域大小没发生变化,则无需重绘棋盘
if (_rect == rect) return;
// 绘制区域发生了变化,需要重新绘制并缓存棋盘
_rect = rect;
print("paint chessboard");
// 新建一个PictureLayer,用于缓存棋盘的绘制结果,并添加到layer中
ui.PictureRecorder recorder = ui.PictureRecorder();
Canvas canvas = Canvas(recorder);
drawChessboard(canvas, rect); //绘制棋盘
// 将绘制产物保存在pictureLayer中
_layer = PictureLayer(Rect.zero)..picture = recorder.endRecording();
}
@override
void paint(PaintingContext context, Offset offset) {
Rect rect = offset & size;
//检查棋盘大小是否需要变化,如果变化,则需要重新绘制棋盘并缓存
_checkIfChessboardNeedsUpdate(rect);
//将缓存棋盘的layer添加到context中,每次重绘都要调用,原因下面会解释
context.addLayer(_layer);
//再画棋子
print("paint pieces");
drawPieces(context.canvas, rect);
}
需要注意的是:在paint方法中,每次重绘都需要调用context.addLayer(_layer)将棋盘layer添加到当前的layer树中,实际上是添加到了当前节点的第一个绘制边界节点的Layer中。为什么每次都要添加,而不是添加一次?因为重绘时当前节点的第一个父级向下发起的,而每次重绘前,该节点都会先清空所有的孩子,代码见PaintingContext.repaintCompositedChild方法,所以每次重绘都需要添加一下。 4. 创建一个测试Demo来验证,创建一个ChessWidget和一个ElevatedButton,因为ElevatedButton在点击时会执行水波纹动画,所以会发起一连串的绘制请求,由于ChessWidget和ElevatedButton会在同一个Layer上绘制,所以ElevatedButton重绘也会导致ChessWidget的重绘。在绘制棋子和棋盘上加日志,点击ElevatedButton,查看日志验证棋盘缓存是否生效。
当前版本ElevatedButton的实现中并没有添加RepaintBoundary,所以才会和ChessWidget在同一个Layer上渲染,如果后续Flutter SDK中给ElevatedButton加上RepaintBoundary,则不能通过验证。
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"),
),
],
),
);
}
}
测试并没有Paint chessboard,可见棋盘缓存生效了。但是上面代码还存在内存泄露的坑。
LayerHandle
上面RenderChess实现中,将棋盘绘制信息缓存到了Layer中,因为Layer中保存的绘制产物是需要调用dispose方法释放的,如果ChessWidget销毁时没有释放则会发生内存泄露,所以需要在组件销毁时,手动释放一下,给RenderChess中添加如下代码:
@override
void dispose() {
_layer.dispose();
super.dispose();
}
实际上,在Flutter中一个layer可能会反复被添加到多个容器类Layer中,或从容器中移除,这样一来有些时候会搞不清楚一个layer是否还被使用,为了解决这个问题,Flutter中定义了一个LayerHandle类来专门管理Layer,内部是通过引用计数的方式来跟踪layer是否还要使用者,一旦没有使用者,会自动调用layer.dispose来释放资源。为了符合Flutter规范,强烈建议在需要使用layer的时候通过LayerHandle来管理它。因此,优化上面代码,在RenderChess中定义一个layerHandle,然后将_layer全部替换为layerHandle.layer:
// 定义一个新的 layerHandle
final layerHandle = LayerHandle<PictureLayer>();
_checkIfChessboardNeedsUpdate(Rect rect) {
...
layerHandle.layer = PictureLayer(Rect.zero)..picture = recorder.endRecording();
}
@override
void paint(PaintingContext context, Offset offset) {
...
//将缓存棋盘的layer添加到context中
context.addLayer(layerHandle.layer!);
...
}
@override
void dispose() {
//layer通过引用计数的方式来跟踪自身是否还被layerHandle持有,
//如果不被持有则会释放资源,所以我们必须手动置空,该set操作会
//解除layerHandle对layer的持有。
layerHandle.layer = null;
super.dispose();
}
每一个RenderObject都有一个layer属性,是否能使用它来保存棋盘layer,看下RenderObject中关于Layer的定义:
@protected
set layer(ContainerLayer? newLayer) {
_layerHandle.layer = newLayer;
}
final LayerHandle<ContainerLayer> _layerHandle = LayerHandle<ContainerLayer>();
看到RenderObject中已经定义一个_layerHandle了,它会去管理layer;同时layer是一个Setter,会自动将新layer赋值到_layerHandle上,那么是否可以在RenderChess中直接使用父类定义好的_layerHandle,这样的话就无需再定义一个layerHandle了。这取决于当前节点的isRepaintBoundary属性是否为true,如果为true则不可以,如果不为true则可以,因为Flutter在执行flushPaint重绘时遇到绘制边界节点:
- 先检查其layer是否为空,如果不为空,则会先清空该layer的孩子节点,然后会使用layer创建一个PaintingContext,传递给paint方法
- 如果layer为空,会创建一个OffsetLayer给它
如果要将棋盘layer保存到预定义的layer变量中的话,得先创建一个ContainerLayer,然后将绘制棋盘的PictureLayer作为子节点添加到新创建的ContainerLayer中,然后赋值给Layer变量,这样一来:
- 如果设置RenderChess的isRepaintBoundary为true,那么每次重绘时,flutter框架都会将layer子节点情况,这样的话,棋盘PictureLayer就会被移除,接下来就会触发异常。
- 如果RenderChess的isRepaintBoundary为false(默认值),则在重绘过程中flutter框架不会使用到layer属性,这时是没问题的。
虽然大多情况下RenderChess的isRepaintBoundary为false,直接使用layer是可以的,但是不建议这么做:
- RenderObject中的layer字段在flutter框架中专门为绘制流程而设计的,按照职责分离原则,也不应该去动它。即使能复用成功,万一Flutter的绘制流程发生变化,比如开始使用非绘制边界节点的layer字段,那么代码会出现问题
- 如果要使用layer,也需要先创建一个ContainerLayer,既然如此,还不如直接创建一个LayerHandle。
最后:虽然棋盘不会重绘了,但是棋子还是会重绘,这样也不合理,理想状态是棋盘不受外界区域影响,只有落子时才会重绘棋子。解决方案有两种:
- RenderChess的isRepaintBpundary返回true;将当前节点变为一个绘制边界,这样ChessWidget就会和按钮分别在不同的layer上绘制,也就互不影响。
- 在使用ChessWidget时,给它套上一个RepaintBoundary组件,原理差不多,这不过这种方式是将ChessWidget的父节点(RepaintBoundary)变为了绘制边界(而不是自身),这样也会创建一个新的layer来隔离按钮的绘制。