起因
一個懶洋洋的下午杯瞻,偶然間看到了這篇Flutter 踩坑記錄柑爸,作者的問題引起了我的好奇让腹。作者的問題描述如下:
一個聊天對話頁面肾砂,由于對話框形狀需要自定義列赎,因此采用了
CustomPainter
來自定義繪制對話框。測試過程中發(fā)現(xiàn)在ipad mini
上不停地上下滾動對話框列表竟然出現(xiàn)了crash,進一步測試發(fā)現(xiàn)聊天過程中也會頻繁出現(xiàn)crash包吝。
在對作者的遭遇表示同情時饼煞,也讓我聯(lián)想到了自己使用CustomPainter
的地方。
尋找問題
在flutter_deer中有這么一個頁面:
頁面最外層是個SingleChildScrollView
诗越,上方的環(huán)形圖是一個自定義CustomPainter
砖瞧,下方是個ListView
列表。
實現(xiàn)這個環(huán)形圖并不復雜嚷狞。繼承CustomPainter
块促,重寫paint
與shouldRepaint
方法即可。paint
方法負責繪制具體的圖形床未,shouldRepaint
方法負責告訴Flutter刷新布局時是否重繪竭翠。一般的策略是在shouldRepaint
方法中,我們通過對比前后數(shù)據(jù)是否相同來判定是否需要重繪薇搁。
當我滑動頁面時斋扰,發(fā)現(xiàn)自定義環(huán)形圖中的paint
方法不斷在執(zhí)行。啃洋?传货??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绝编,也有可能調(diào)用paint
方法(例如:如果組件的大小改變了)僻澎。如果你的自定義View比較復雜貌踏,應該盡可能的避免重繪十饥。使用
RepaintBoundary
或者RenderObject.isRepaintBoundary
為true可能會有對你有所幫助。
顯然我碰到的問題就是第一點祖乳。翻看SingleChildScrollView
源碼我們發(fā)現(xiàn)了問題:
@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逗堵,也就是最終執(zhí)行到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蜒秤,那么就會執(zhí)行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
蚤假。詳細的源碼點擊這里。
性能對比
其實之前沒有到發(fā)現(xiàn)這個問題吧兔,因為整個頁面滑動流暢磷仰。
為了對比清楚的對比前后的性能,我在這一頁面上重復添加十個這樣的環(huán)形圖來滑動測試境蔼。下圖是timeline的結(jié)果:
優(yōu)化前的滑動會有明顯的不流暢感灶平,實際每幀繪制需要近16ms,優(yōu)化后只有1ms欧穴。在這個場景例子中民逼,并沒有達到大量的繪制,GPU完全沒有壓力涮帘。如果只是之前的一個環(huán)形圖拼苍,這步優(yōu)化其實可有可無,只是做到了更優(yōu)调缨,避免不必要的繪制疮鲫。
在查找相關資料時,我在stackoverflow
上發(fā)現(xiàn)了一個有趣的例子弦叶。
作者在屏幕上繪制了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;
}
同時屏幕上有個小黑點會跟隨著手指滑動。但是每次的滑動都會導致背景圖的重繪伤哺。優(yōu)化的方法和上面的一樣燕侠,我測試了一下這個Demo,得到了下面的結(jié)果立莉。
這個場景例子中绢彤,繪制5000個圓給GPU帶來了不小的壓力,隨著
RepaintBoundary
的使用蜓耻,優(yōu)化的效果很明顯茫舶。
一探究竟
那么RepaintBoundary
到底是什么?RepaintBoundary
就是重繪邊界刹淌,用于重繪時獨立于父布局的饶氏。
在Flutter SDK中有部分Widget做了這個處理,比如TextField
有勾、SingleChildScrollView
疹启、AndroidView
、UiKitView
等蔼卡。最常用的ListView
在item上默認也使用了RepaintBoundary
:
大家可以思考一下為什么這些組件使用了
RepaintBoundary
喊崖。
接著上面的源碼中child.isRepaintBoundary
為true的地方,我們看到會調(diào)用_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);
/// 創(chuàng)建完成贷祈,進行繪制
child._paintWithContext(childContext, Offset.zero);
childContext.stopRecordingIfNeeded();
}
child._needsPaint
為true時會最終通過_repaintCompositedChild
方法在當前child創(chuàng)建一個圖層(layer)趋急。
這里說到的圖層還是很抽象的,如何直觀的觀察到它呢势誊?我們可以在程序的main方法中將debugRepaintRainbowEnabled
變量置為true呜达。它可以幫助我們可視化應用程序中渲染樹的重繪。原理其實就是在執(zhí)行上面的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;
}());
}
效果如下:
不同的顏色代表不同的圖層霜威。當發(fā)生重繪時,對應的矩形框也會發(fā)生顏色變化册烈。
在重繪前戈泼,需要markNeedsPaint
方法標記重繪的節(jié)點。
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赏僧,就會調(diào)用父節(jié)點的markNeedsPaint
方法大猛,直到isRepaintBoundary
為 true時,才將當前RenderObject
添加至_nodesNeedingPaint
中淀零。
在繪制每幀時挽绩,調(diào)用flushPaint
方法更新視圖。
void flushPaint() {
try {
final List<RenderObject> dirtyNodes = _nodesNeedingPaint; <-- 獲取需要繪制的臟節(jié)點
_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); <--- 這里重繪驾中,深度優(yōu)先
} else {
node._skippedPaintingOnLayer();
}
}
}
} finally {
if (!kReleaseMode) {
Timeline.finishSync();
}
}
}
這樣就實現(xiàn)了局部的重繪唉堪,將子節(jié)點與父節(jié)點的重繪分隔開。
tips:這里需要注意一點肩民,通常我們點擊按鈕的水波紋效果會導致距離它上級最近的圖層發(fā)生重繪唠亚。我們需要根據(jù)頁面的具體情況去做處理。這一點在官方的項目flutter_gallery中就有做類似處理此改。
總結(jié)
其實總結(jié)起來就是一句話趾撵,根據(jù)場景合理使用RepaintBoundary
侄柔,它可以幫你帶來性能的提升共啃。 其實優(yōu)化方向不止RepaintBoundary
,還有RelayoutBoundary
暂题。那這里就不介紹了薪者,感興趣的可以查看文末的鏈接攻人。
如果本篇對你有所啟發(fā)和幫助瞬浓,多多點贊支持猿棉!最后也希望大家支持我的Flutter開源項目flutter_deer,我會將我關于Flutter的實踐都放在其中杖爽。
本篇應該是今年的最后一篇博客了掂林,因為沒有專門寫年度總結(jié)的習慣,就順便在這來個年度總結(jié)锣杂。總的來說踱蠢,今年定的目標不僅完成了,甚至還有點超額完成企锌。明年的目標也已經(jīng)明確了陡鹃,那么就努力去完成吧F季ā(這總結(jié)就是留給自己看的,不必在意蹬叭。。坦喘。)