說說Flutter中的RepaintBoundary

NO IMAGE

起因

一個懶洋洋的下午,偶然間看到了這篇Flutter 踩坑記錄,作者的問題引起了我的好奇。作者的問題描述如下:

一個聊天對話頁面,由於對話框形狀需要自定義,因此採用了CustomPainter來自定義繪製對話框。測試過程中發現在ipad mini上不停地上下滾動對話框列表竟然出現了crash,進一步測試發現聊天過程中也會頻繁出現crash。

在對作者的遭遇表示同情時,也讓我聯想到了自己使用CustomPainter的地方。

尋找問題

flutter_deer中有這麼一個頁面:

說說Flutter中的RepaintBoundary

頁面最外層是個SingleChildScrollView,上方的環形圖是一個自定義CustomPainter,下方是個ListView列表。

實現這個環形圖並不複雜。繼承CustomPainter,重寫paintshouldRepaint方法即可。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);

註釋中提到兩點:

  1. 即使shouldRepaint返回false,也有可能調用paint方法(例如:如果組件的大小改變了)。

  2. 如果你的自定義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的結果:

說說Flutter中的RepaintBoundary

說說Flutter中的RepaintBoundary

優化前的滑動會有明顯的不流暢感,實際每幀繪製需要近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,得到了下面的結果。

說說Flutter中的RepaintBoundary

這個場景例子中,繪製5000個圓給GPU帶來了不小的壓力,隨著RepaintBoundary的使用,優化的效果很明顯。

一探究竟

那麼RepaintBoundary到底是什麼?RepaintBoundary就是重繪邊界,用於重繪時獨立於父佈局的。

在Flutter SDK中有部分Widget做了這個處理,比如TextFieldSingleChildScrollViewAndroidViewUiKitView等。最常用的ListView在item上默認也使用了RepaintBoundary

說說Flutter中的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;
}());
}

效果如下:

說說Flutter中的RepaintBoundary

不同的顏色代表不同的圖層。當發生重繪時,對應的矩形框也會發生顏色變化。

在重繪前,需要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的實踐都放在其中。


本篇應該是今年的最後一篇博客了,因為沒有專門寫年度總結的習慣,就順便在這來個年度總結。總的來說,今年定的目標不僅完成了,甚至還有點超額完成。明年的目標也已經明確了,那麼就努力去完成吧!(這總結就是留給自己看的,不必在意。。。)

參考

相關文章

如何封裝一個flutter的多語言plugin

我在真實項目中使用了AST大法!

SpringBoot如何優雅的校驗參數

數據結構棧