本篇是“说说”系列第一篇,另两篇链接奉上:
起因
一个懒洋洋的下午,偶然间看到了这篇Flutter 踩坑记录,作者的问题引起了我的好奇。作者的问题描述如下:
一个聊天对话页面,由于对话框形状需要自定义,因此采用了
CustomPainter
来自定义绘制对话框。测试过程中发现在ipad mini
上不停地上下滚动对话框列表竟然出现了crash,进一步测试发现聊天过程中也会频繁出现crash。
在对作者的遭遇表示同情时,也让我联想到了自己使用CustomPainter
的地方。
寻找问题
在flutter_deer中有这么一个页面:
页面最外层是个SingleChildScrollView
,上方的环形图是一个自定义CustomPainter
,下方是个ListView
列表。
实现这个环形图并不复杂。继承CustomPainter
,重写paint
与shouldRepaint
方法即可。paint
方法负责绘制具体的图形,shouldRepaint
方法负责告诉Flutter刷新布局时是否重绘。一般的策略是在shouldRepaint
方法中,我们通过对比前后数据是否相同来判定是否需要重绘。
当我滑动页面时,发现自定义环形图中的paint
方法不断在执行。???shouldRepaint
方法失效了?其实注释文档写的很清楚了,只怪自己没有仔细阅读。(本篇源码基于Flutter SDK版本 v1.12.13+hotfix.3)
/// If the method returns false, then the [paint] call might be optimized
/// away.
///
/// It's possible that the [paint] method will get called even if
/// [shouldRepaint] returns false (e.g. if an ancestor or descendant needed to
/// be repainted). It's also possible that the [paint] method will get called
/// without [shouldRepaint] being called at all (e.g. if the box changes
/// size).
///
/// If a custom delegate has a particularly expensive paint function such that
/// repaints should be avoided as much as possible, a [RepaintBoundary] or
/// [RenderRepaintBoundary] (or other render object with
/// [RenderObject.isRepaintBoundary] set to true) might be helpful.
///
/// The `oldDelegate` argument will never be null.
bool shouldRepaint(covariant CustomPainter oldDelegate);
注释中提到两点:
-
即使
shouldRepaint
返回false,也有可能调用paint
方法(例如:如果组件的大小改变了)。 -
如果你的自定义View比较复杂,应该尽可能的避免重绘。使用
RepaintBoundary
或者RenderObject.isRepaintBoundary
为true可能会有对你有所帮助。
显然我碰到的问题就是第一点。翻看SingleChildScrollView
源码我们发现了问题:
@override
void paint(PaintingContext context, Offset offset) {
if (child != null) {
final Offset paintOffset = _paintOffset;
void paintContents(PaintingContext context, Offset offset) {
context.paintChild(child, offset + paintOffset); <----
}
if (_shouldClipAtPaintOffset(paintOffset)) {
context.pushClipRect(needsCompositing, offset, Offset.zero & size, paintContents);
} else {
paintContents(context, offset);
}
}
}
在SingleChildScrollView
的滑动中必然需要绘制它的child,也就是最终执行到paintChild
方法。
void paintChild(RenderObject child, Offset offset) {
if (child.isRepaintBoundary) {
stopRecordingIfNeeded();
_compositeChild(child, offset);
} else {
child._paintWithContext(this, offset);
}
}
void _paintWithContext(PaintingContext context, Offset offset) {
...
_needsPaint = false;
try {
paint(context, offset); //<-----
} catch (e, stack) {
_debugReportException('paint', e, stack);
}
}
在paintChild
方法中,只要child.isRepaintBoundary
为false,那么就会执行paint
方法,这里就直接跳过了shouldRepaint
。
解决问题
isRepaintBoundary
在上面的注释中提到过,也就是说isRepaintBoundary
为true时,我们可以直接合成视图,避免重绘。Flutter为我们提供了RepaintBoundary,它是对这一操作的封装,便于我们的使用。
class RepaintBoundary extends SingleChildRenderObjectWidget {
const RepaintBoundary({ Key key, Widget child }) : super(key: key, child: child);
@override
RenderRepaintBoundary createRenderObject(BuildContext context) => RenderRepaintBoundary();
}
class RenderRepaintBoundary extends RenderProxyBox {
RenderRepaintBoundary({ RenderBox child }) : super(child);
@override
bool get isRepaintBoundary => true; /// <-----
}
那么解决问题的方法很简单:在CustomPaint
外层套一个RepaintBoundary
。详细的源码点击这里。
性能对比
其实之前没有到发现这个问题,因为整个页面滑动流畅。
为了对比清楚的对比前后的性能,我在这一页面上重复添加十个这样的环形图来滑动测试。下图是timeline的结果:
优化前的滑动会有明显的不流畅感,实际每帧绘制需要近16ms,优化后只有1ms。在这个场景例子中,并没有达到大量的绘制,GPU完全没有压力。如果只是之前的一个环形图,这步优化其实可有可无,只是做到了更优,避免不必要的绘制。
在查找相关资料时,我在stackoverflow
上发现了一个有趣的例子。
作者在屏幕上绘制了5000个彩色的圆来组成一个类似“万花筒”效果的背景图。
class ExpensivePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
print("Doing expensive paint job");
Random rand = new Random(12345);
List<Color> colors = [
Colors.red,
Colors.blue,
Colors.yellow,
Colors.green,
Colors.white,
];
for (int i = 0; i < 5000; i++) {
canvas.drawCircle(
new Offset(
rand.nextDouble() * size.width, rand.nextDouble() * size.height),
10 + rand.nextDouble() * 20,
new Paint()
..color = colors[rand.nextInt(colors.length)].withOpacity(0.2));
}
}
@override
bool shouldRepaint(ExpensivePainter other) => false;
}
同时屏幕上有个小黑点会跟随着手指滑动。但是每次的滑动都会导致背景图的重绘。优化的方法和上面的一样,我测试了一下这个Demo,得到了下面的结果。
这个场景例子中,绘制5000个圆给GPU带来了不小的压力,随着RepaintBoundary
的使用,优化的效果很明显。
一探究竟
那么RepaintBoundary
到底是什么?RepaintBoundary
就是重绘边界,用于重绘时独立于父布局的。
在Flutter SDK中有部分Widget做了这个处理,比如TextField
、SingleChildScrollView
、AndroidView
、UiKitView
等。最常用的ListView
在item上默认也使用了RepaintBoundary
:
大家可以思考一下为什么这些组件使用了RepaintBoundary
。
接着上面的源码中child.isRepaintBoundary
为true的地方,我们看到会调用_compositeChild
方法;
void _compositeChild(RenderObject child, Offset offset) {
...
// Create a layer for our child, and paint the child into it.
if (child._needsPaint) {
repaintCompositedChild(child, debugAlsoPaintedParent: true); // <---- 1
}
final OffsetLayer childOffsetLayer = child._layer;
childOffsetLayer.offset = offset;
appendLayer(child._layer);
}
static void repaintCompositedChild(RenderObject child, { bool debugAlsoPaintedParent = false }) {
_repaintCompositedChild( // <---- 2
child,
debugAlsoPaintedParent: debugAlsoPaintedParent,
);
}
static void _repaintCompositedChild(
RenderObject child, {
bool debugAlsoPaintedParent = false,
PaintingContext childContext,
}) {
...
OffsetLayer childLayer = child._layer;
if (childLayer == null) {
child._layer = childLayer = OffsetLayer(); // <---- 3
} else {
childLayer.removeAllChildren();
}
childContext ??= PaintingContext(child._layer, child.paintBounds);
/// 创建完成,进行绘制
child._paintWithContext(childContext, Offset.zero);
childContext.stopRecordingIfNeeded();
}
child._needsPaint
为true时会最终通过_repaintCompositedChild
方法在当前child创建一个图层(layer)。
这里说到的图层还是很抽象的,如何直观的观察到它呢?我们可以在程序的main方法中将debugRepaintRainbowEnabled
变量置为true。它可以帮助我们可视化应用程序中渲染树的重绘。原理其实就是在执行上面的stopRecordingIfNeeded
方法时,额外绘制了一个彩色矩形:
@protected
@mustCallSuper
void stopRecordingIfNeeded() {
if (!_isRecording)
return;
assert(() {
if (debugRepaintRainbowEnabled) { // <-----
final Paint paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 6.0
..color = debugCurrentRepaintColor.toColor();
canvas.drawRect(estimatedBounds.deflate(3.0), paint);
}
return true;
}());
}
效果如下:
不同的颜色代表不同的图层。当发生重绘时,对应的矩形框也会发生颜色变化。
在重绘前,需要markNeedsPaint
方法标记重绘的节点。
void markNeedsPaint() {
if (_needsPaint)
return;
_needsPaint = true;
if (isRepaintBoundary) {
// If we always have our own layer, then we can just repaint
// ourselves without involving any other nodes.
assert(_layer is OffsetLayer);
if (owner != null) {
owner._nodesNeedingPaint.add(this);
owner.requestVisualUpdate(); // 更新绘制
}
} else if (parent is RenderObject) {
final RenderObject parent = this.parent;
parent.markNeedsPaint();
assert(parent == this.parent);
} else {
if (owner != null)
owner.requestVisualUpdate();
}
}
markNeedsPaint
方法中如果isRepaintBoundary
为false,就会调用父节点的markNeedsPaint
方法,直到isRepaintBoundary
为 true时,才将当前RenderObject
添加至_nodesNeedingPaint
中。
在绘制每帧时,调用flushPaint
方法更新视图。
void flushPaint() {
try {
final List<RenderObject> dirtyNodes = _nodesNeedingPaint; <-- 获取需要绘制的脏节点
_nodesNeedingPaint = <RenderObject>[];
// Sort the dirty nodes in reverse order (deepest first).
for (RenderObject node in dirtyNodes..sort((RenderObject a, RenderObject b) => b.depth - a.depth)) {
assert(node._layer != null);
if (node._needsPaint && node.owner == this) {
if (node._layer.attached) {
PaintingContext.repaintCompositedChild(node); <--- 这里重绘,深度优先
} else {
node._skippedPaintingOnLayer();
}
}
}
} finally {
if (!kReleaseMode) {
Timeline.finishSync();
}
}
}
这样就实现了局部的重绘,将子节点与父节点的重绘分隔开。
tips:这里需要注意一点,通常我们点击按钮的水波纹效果会导致距离它上级最近的图层发生重绘。我们需要根据页面的具体情况去做处理。这一点在官方的项目flutter_gallery中就有做类似处理。
总结
其实总结起来就是一句话,根据场景合理使用RepaintBoundary
,它可以帮你带来性能的提升。 其实优化方向不止RepaintBoundary
,还有RelayoutBoundary
。那这里就不介绍了,感兴趣的可以查看文末的链接。
如果本篇对你有所启发和帮助,多多点赞支持!最后也希望大家支持我的Flutter开源项目flutter_deer,我会将我关于Flutter的实践都放在其中。
本篇应该是今年的最后一篇博客了,因为没有专门写年度总结的习惯,就顺便在这来个年度总结。总的来说,今年定的目标不仅完成了,甚至还有点超额完成。明年的目标也已经明确了,那么就努力去完成吧!(这总结就是留给自己看的,不必在意。。。)