
可能说起 Flutter 绘制,大家第一反应就是用 CustomPaint 组件,自定义 CustomPainter 对象来画。Flutter 中所有可以看得到的组件,比如 Text、Image、Switch、Slider 等等,追其根源都是画出来的,但通过查看源码可以发现,Flutter 中绝大多数组件并不是使用 CustomPaint 组件来画的,其实 CustomPaint 组件是对框架底层绘制的一层封装。这个系列便是对 Flutter 绘制的探索,通过测试、调试及源码分析来给出一些在绘制时被忽略或从未知晓的东西,而有些要点如果被忽略,就很可能出现问题。
前面说过,由于 shouldRepaint 只会在 RenderCustomPaint 渲染对象重新设置画板时而触发。所以它控制画布刷新的场景仅限于上层 element#rebuild,最常见的场景是 State#setState。经过测试,发现仍存在一些莫名的 paint 被重绘的场景。本文就来深入探究一下这些情况,已及对应的解决方案。
如下,通过一个 SingleChildScrollView 包含一个自定义的画板组件。并在 ShapePainter#paint 中打印绘制日志,页面中并未涉及任何的刷新逻辑。可以发现,随着滑动,ShapePainter#paint 在一直执行。想当年 FlutterUnit 的 CustomPaint 详情页就是这个问题,滑动时非常卡顿。那么为什么会发生这么不可思议的事呢?又该怎样解决呢?

void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: HomePage());
}
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: SingleChildScrollView(
child: Column(
children: [
Container(
height: 150,
width: MediaQuery.of(context).size.width,
child: CustomPaint( painter: ShapePainter(color: Colors.red))) ),
Container( height: 900, color: Colors.green,)
],
),
),
);
}
}
class ShapePainter extends CustomPainter {
final Color color;
ShapePainter({this.color});
@override
void paint(Canvas canvas, Size size) {
print('-------paint----${color.value}---${DateTime.now()}---');
Paint paint = Paint()..color = color;
canvas.drawCircle(Offset(80, 80), 50, paint);
}
@override
bool shouldRepaint(covariant ShapePainter oldDelegate) {
return oldDelegate.color!=color;
}
}
复制代码既然是触发了ShapePainter#paint,那么必然冤有头,债有主,肯定有哪里执行了 RenderCustomPaint#paint 。所以分析的最好方法就是打个断点,调试一下。从 RendererBinding.drawFrame 开始看,执行到 ShapePainter#paint 方法栈情况如下:

目前待渲染列表中,只有 _RenderSingleChildViewport 。它是由 SingleChildScrollView 间接创建的,在它的绘制中,会触发绘制孩子。

它的 child 属性是 RenderFlex ,是由 Colunm 创建的。

最后在 PaintingContext.paintChild 中 RenderCustomPaint 作为孩子被绘制。而引发 ShapePainter#paint 绘制的执行。

代码处理起来非常简单,在 CustomPaint 之上添加 RepaintBoundary 即可。这样滑动时,就不会触发 ShapePainter#paint 的重绘,这时,你的心里肯定会有一个大大的问号,Why? 下面就来一起探索吧。

child: RepaintBoundary( <--- 添加 RepaintBoundary
child: CustomPaint(
painter: ShapePainter(color: Colors.red),
),
),既然是范围,那必然会有上界和下界。我们回想一下 Flutter 绘制探索 3 | 深入分析 CustomPainter 类 中,一个 RenderObject 对象被收录到待重绘列表中的情景。事情发生在 RenderObject#markNeedsPaint 。每个 RenderObject 对象都会有一个 isRepaintBoundary 的布尔属性,默认为 false ,其作用就是用于判断是否是绘制的边界。那么绘制的边界到底是什么意思呢?
下面代码可以看出:当一个 RenderObject 对象执行 markNeedsPaint 时,如果自身 isRepaintBoundary 为 false,会向上寻找父级,直到有 isRepaintBoundary=true 为止。然后该父级节点被加入 _nodesNeedingPaint 列表中。
---->[RenderObject#markNeedsPaint]----
void markNeedsPaint() {
if (_needsPaint)
return;
_needsPaint = true;
if (isRepaintBoundary) {
if (owner != null) {
owner!._nodesNeedingPaint.add(this); //<--- 自己被加入 待渲染列表
owner!.requestVisualUpdate();
}
} else if (parent is RenderObject) {
final RenderObject parent = this.parent as RenderObject;
parent.markNeedsPaint();
} else {
if (owner != null)
owner!.requestVisualUpdate();
}
}
bool get isRepaintBoundary => false;如下图,如果 4 节点执行了 markNeedsPaint ,由于它的 isRepaintBoundary=false ,就会执行 parent.markNeedsPaint,同理向上追溯发现 2 节点的 isRepaintBoundary=true 所以,就会将 2 加入_nodesNeedingPaint 列表中。 如果 3 执行 markNeedsPaint,也是 2 加入_nodesNeedingPaint 列表中。如果是 5 执行 markNeedsPaint,其本身是 isRepaintBoundary , 则 5 加入_nodesNeedingPaint 列表中。这也就是渲染对象的上界 需要是一个 isRepaintBoundary=true 的可渲染对象。

在 RenderObject#paintChild 中可以发现,只有当 child.isRepaintBoundary 成立时,才不会继续绘制绘制孩子,这就是说,如果 2 被加入 _nodesNeedingPaint 列表,在 2 节点触发绘制时,会绘制孩子,如果此时 5 是 isRepaintBoundary,那么就不会向下绘制,这样 6 就不会绘制,这就是 绘制的下界。

---->[RenderObject#paintChild]----
void paintChild(RenderObject child, Offset offset) {
if (child.isRepaintBoundary) {
stopRecordingIfNeeded();
_compositeChild(child, offset);
} else {
child._paintWithContext(this, offset);
}
}唯鹿 兄在 说说Flutter中的RepaintBoundary 也介绍过 RepaintBoundary,但感觉没有点出绘制上下界的概念。不过他可能是最早分享 RepaintBoundary 使用的人吧,很感谢他的分享。这里通过这个探索系列,相信大家能对此有一个更深刻的认识。
其实原理超级简单,比如在旧版的里面,在 2 节点绘制时,会触发 5 的重绘。 想要不让 5 绘制,只要在 5 之前加个挡箭牌 就行了,RepaintBoundary 就是干这个事的,其创建的 RenderRepaintBoundary 对象的 isRepaintBoundary 为 true。就这么简单。

class RepaintBoundary extends SingleChildRenderObjectWidget {
/// Creates a widget that isolates repaints.
const RepaintBoundary({ Key key, Widget child }) : super(key: key, child: child);
@override
RenderRepaintBoundary createRenderObject(BuildContext context) => RenderRepaintBoundary();
// 略...
}
class RenderRepaintBoundary extends RenderProxyBox {
/// Creates a repaint boundary around [child].
RenderRepaintBoundary({ RenderBox? child }) : super(child);
@override
bool get isRepaintBoundary => true;
复制代码有人也许有疑问,既然如此,所有节点都加 RepaintBoundary ,自己负责绘制自己,别牵连别人不好吗?我们来看一下,如果 isRepaintBoundary 成立,虽然之后的节点不会绘制,但会发生什么。
---->[RenderObject#paintChild]----
void paintChild(RenderObject child, Offset offset) {
if (child.isRepaintBoundary) {
stopRecordingIfNeeded();
_compositeChild(child, offset); <---
} else {
child._paintWithContext(this, offset);
}
}会进行 _compositeChild ,最终将 child._layer 添加到 _containerLayer 中。如果 RepaintBoundary 非常多,就会导致非常多的 Layer。所以是药三分毒, RepaintBoundary 也不是来瞎用的。最常见的就是用于 滑动时,让自己绘制的复杂画板不频繁刷新。
void _compositeChild(RenderObject child, Offset offset) {
if (child._needsPaint) {
repaintCompositedChild(child, debugAlsoPaintedParent: true);
} else {
final OffsetLayer childOffsetLayer = child._layer as OffsetLayer;
childOffsetLayer.offset = offset;
appendLayer(child._layer!);
}
@protected
void appendLayer(Layer layer) {
assert(!_isRecording);
layer.remove();
_containerLayer.append(layer);
}俗话说,以史为镜,可正衣冠。 看源码是最正的,我们最信任的应该是源码,但也要保留一分质疑。下面就来看一下,源码中对于 RepaintBoundary 的使用,以此借鉴。
_CupertinoScrollbarState这个组件是 CupertinoScrollbar,和滑动相关, 在使用 ScrollbarPainter 时,将 CustomPaint 夹在了两个 RepaintBoundary 之间。

_ScrollbarState这个对于的组件是 Scrollbar,和滑动相关, 在使用 ScrollbarPainter 时,将 CustomPaint 夹在了两个 RepaintBoundary 之间。

_TextFieldState 和 _CupertinoTextFieldState分别是 TextField 和 CupertinoTextField,由于输入框的游标频闪,使用需要加 RepaintBoundary 进行限制。

_GlowingOverscrollIndicatorState滑动到顶底的指示器,也是和滑动相关, 在使用 _GlowingOverscrollIndicatorPainter 时,将 CustomPaint 夹在了两个 RepaintBoundary 之间。

Sliver 相关ListView 、GridView 的本质都是 Sliver 相关的组件。在 SliverChildBuilderDelegate 中都默认会套上 RepaintBoundary,因为 addRepaintBoundaries 默认为 true 。从这可以看出这是列表类滑动组件的默认行为,RepaintBoundary 并没有那么昂贵。

你可以做一个测试,将 SingleChildScrollView 替换成 ListView 。这样在滑动时也不会触发画板的频繁绘制,原因就在于 SliverChildBuilderDelegate 中的 RepaintBoundary 处理。

在 Flow 中,其传入的 children ,会通过 RepaintBoundary.wrapAll 对每个组件进行包裹。

RawMaterialButton 系列的组件,底层都依赖于 InkWell ,在测试中发现水波纹效果会触发自定义画板的不断重绘。如下:

class HomePage extends StatelessWidget{
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
// debugDumpRenderTree();
},
),
body: CustomPaint(
painter: ShapePainter(color: Colors.red),
),
);
}
}
class ShapePainter extends CustomPainter {
final Color color;
ShapePainter({this.color});
@override
void paint(Canvas canvas, Size size) {
print("----paint--------${DateTime.now()}-------");
Paint paint = Paint()..color = color;
canvas.drawCircle(Offset(100, 100), 50, paint);
}
@override
bool shouldRepaint(covariant ShapePainter oldDelegate) {
return oldDelegate.color != color;
}
}
复制代码调试一下可以看到,上界如下,不知道是官方少加了 RepaintBoundary 下界,还是另有考虑。解决方案是在绘制的组件上套一个 RepaintBoundary 。

在输入框收起打开时,会触发自定义画板的绘制,而且随着打开次数的增加,绘制越多,感觉像是 bug 。同样解决方案是在绘制的组件上套一个 RepaintBoundary ,就不会出现重绘现象。目前版本,最新稳定版 Flutter 1.22.5 。

class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CustomPaint(
size: Size(300,150),
painter: ShapePainter(color: Colors.red),
),
TextField(),
],
),
);
}
}
复制代码当你在通过 CustomPaint 组件自定义绘制时,需要注意这几类组件:1、滑动类型; 2、InkWell 相关 ;3、 输入框 。当然这些只是我遇到的,当你自定义的绘制出现卡顿或频繁重绘时,也要注意一下。
通过本文,你应该对 Flutter 中的绘制范围有了更深的认识。如果你的绘制中出现了频繁触发的异常重绘,那么 RepaintBoundary 一定会帮助你。本文就到这里,下一篇将会讲解另一个 shouldRepaint 无法控制的画板重绘,不过这个无法控制是我们的需求,那就是基于 repaint 对画板绘制的原理。前面虽然有所涉及,但我觉得有必要用一篇文章详述一下可监听对象与画板的关系,再对 CustomPaint 组件的其他属性进行探索。
@张风捷特烈 2021.01.15 未允禁转
我的公众号:编程之王
联系我--邮箱:1981462002@qq.com --
~ END ~