可能说起 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 ~