一个懒洋洋的下午,偶然间看到了这篇Flutter 踩坑记录,做者的问题引发了个人好奇。做者的问题描述以下:html
一个聊天对话页面,因为对话框形状须要自定义,所以采用了
CustomPainter
来自定义绘制对话框。测试过程当中发如今ipad mini
上不停地上下滚动对话框列表居然出现了crash,进一步测试发现聊天过程当中也会频繁出现crash。java
在对做者的遭遇表示同情时,也让我联想到了本身使用CustomPainter
的地方。node
在flutter_deer中有这么一个页面:git
页面最外层是个SingleChildScrollView
,上方的环形图是一个自定义CustomPainter
,下方是个ListView
列表。github
实现这个环形图并不复杂。继承CustomPainter
,重写paint
与shouldRepaint
方法便可。paint
方法负责绘制具体的图形,shouldRepaint
方法负责告诉Flutter刷新布局时是否重绘。通常的策略是在shouldRepaint
方法中,咱们经过对比先后数据是否相同来断定是否须要重绘。canvas
当我滑动页面时,发现自定义环形图中的paint
方法不断在执行。???shouldRepaint
方法失效了?其实注释文档写的很清楚了,只怪本身没有仔细阅读。(本篇源码基于Flutter SDK版本 v1.12.13+hotfix.3)api
/// 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); 复制代码
注释中提到两点:markdown
即便shouldRepaint
返回false,也有可能调用paint
方法(例如:若是组件的大小改变了)。app
若是你的自定义View比较复杂,应该尽量的避免重绘。使用RepaintBoundary
或者RenderObject.isRepaintBoundary
为true可能会有对你有所帮助。dom
显然我碰到的问题就是第一点。翻看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,获得了下面的结果。
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的实践都放在其中。
本篇应该是今年的最后一篇博客了,由于没有专门写年度总结的习惯,就顺便在这来个年度总结。总的来讲,今年定的目标不只完成了,甚至还有点超额完成。明年的目标也已经明确了,那么就努力去完成吧!(这总结就是留给本身看的,没必要在乎。。。)