一個懶洋洋的下午,偶然間看到了這篇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的實踐都放在其中。
本篇應該是今年的最後一篇博客了,由於沒有專門寫年度總結的習慣,就順便在這來個年度總結。總的來講,今年定的目標不只完成了,甚至還有點超額完成。明年的目標也已經明確了,那麼就努力去完成吧!(這總結就是留給本身看的,沒必要在乎。。。)