一個懶洋洋的下午,偶然間看到了這篇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的實踐都放在其中。
參考
- Flutter視圖的Layout與Paint
- Flutter源碼分析系列(三):自定義控件(RenderBox)指南