作者:閒魚技術-三蒞
背景
高性能高流暢度一直是Flutter團隊宣傳的一大亮點,也是當初閒魚選擇Flutter的重要因素之一,但是隨著複雜業務的應用落地,通過Flutter頁面和原生頁面滑動流暢度對比,我們開始產生懷疑,因為部分Flutter頁面流暢度明顯低於Native,是Flutter的宣傳言過其實還是我們開發人員使用姿勢有問題,今天我們就來具體分析下。
Flutter渲染原理簡介
優化之前我們先來介紹下Flutter的渲染原理,通過這部分基礎瞭解渲染流程以及主要耗時花費
flutter視圖樹包含了三顆樹:Widget、Element、RenderObject
- Widget: 存放渲染內容、它只是一個配置數據結構,創建是非常輕量的,在頁面刷新的過程中隨時會重建
- Element: 同時持有Widget和RenderObject,存放上下文信息,通過它來遍歷視圖樹,支撐UI結構
- RenderObject: 根據Widget的佈局屬性進行layout,paint ,負責真正的渲染
從創建到渲染的大體流程是:根據Widget生成Element,然後創建相應的RenderObject並關聯到Element.renderObject屬性上,最後再通過RenderObject來完成佈局排列和繪製。
例如下面這段佈局代碼
Container(
color: Colors.blue,
child: Row(
children: <Widget>[
Image.asset('image'),
Text('text'),
],
),
);
對應三棵樹的結構如下圖
瞭解了這三棵樹,我們再來看下頁面刷新的時候具體做了哪些操作
當需要更新UI的時候,Framework通知Engine,Engine會等到下個Vsync信號到達的時候,會通知Framework進行animate, build,layout,paint,最後生成layer提交給Engine。Engine會把layer進行組合,生成紋理,最後通過Open Gl接口提交數據給GPU, GPU經過處理後在顯示器上面顯示,如下圖:
結合前面的例子,如果text文本或者image內容發生變化會觸發哪些操作呢?
Widget是不可改變,需要重新創建一顆新樹,build開始,然後對上一幀的element樹做遍歷,調用他的updateChild,看子節點類型跟之前是不是一樣,不一樣的話就把子節點扔掉,創造一個新的,一樣的話就做內容更新,對renderObject做updateRenderObject操作,updateRenderObject內部實現會判斷現在的節點跟上一幀是不是有改動,有改動才會別標記dirty,重新layout、paint,再生成新的layer交給GPU,流程如下圖:
到這裡大家對Flutter在渲染方面有基本的理解,作為後面優化部分內容理解的基礎
性能分析工具及方法
下面來看下性能分析工具,注意,統計性能數據一定要在真機+profile模式下運行,拿到最接近真實的體驗數據。
performance overlay
平時常用的性能分析工具有performance overlay,通過他可以直觀看到當前幀的耗時,但是他是UI線程和GPU線程分開展示的,UI Task Runner是Flutter Engine用於執行Dart root isolate代碼,GPU Task Runner被用於執行設備GPU的相關調用。綠色的線表示當前幀,出現紅色則表示耗時超過16.6ms,也就是發生丟幀現象
Dart DevTool
另一個工具是Dart DevTool ,就是早期的Observatory,官方提供的性能檢測工具。它的 timeline 界面可以讓逐幀分析應用的 UI 性能。但是目前還是預覽版,存在一些問題。
profile模式下運行起來,點擊android studio底部的菜單按鈕,會彈出一個網頁
點擊頂部的Timeline菜單
這個時候滑動頁面,每一幀的耗時會以柱形bar的形式顯示在頁面上,每條bar代表一個frame,同時用不同顏色區分UI/GPU線程耗時,這個時候我們要分析卡頓的場景就需要選中一條紅色的bar(總耗時超過16.6ms),中間區域的Frame events chart顯示了當前選中的frame的事件跟蹤,UI和GPU事件是獨立的事件流,但它們共享一個公共的時間軸。
選中Frame events chart中的某個事件,以上圖為例Layout耗時最長,我們選中它,會在底部Flame chart區域顯示一個自頂向下的堆棧跟蹤,每個堆棧幀的寬度表示它消耗CPU的時長,消耗大量CPU時長的堆棧是我們首要分析的重點,後面就是具體分析堆棧,定位卡頓問題。
debug調試工具
另外還有一些debug調試工具可以輔助查看更多信息,注意,只能在debug模式下使用分析,拿到的數據不能作為性能標準
debugProfileBuildsEnabled:向 Timeline 事件中添加每個widget的build 信息
debugProfilePaintsEnabled: 向 timeline 事件中添加每個renderObject的paint 信息
debugPaintLayerBordersEnabled:每個layer會出現一個邊框,幫助區分layer層級
debugPrintRebuildDirtyWidgets:打印標記為dirty的widgets
debugPrintLayouts:打印標記為dirty的renderObjects
debugPrintBeginFrameBanner/debugPrintEndFrameBanner:打印每幀開始和結束
實例分析
瞭解這些工具下面我們來看個簡單的demo具體分析下,一個由Column、Container、ListView嵌套的佈局,其中有個定時器控制Text中顯示的文本實時更新
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
class TestDemo extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _TestDemoState();
}
}
class _TestDemoState extends State<TestDemo> {
int _count = 0;
Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(Duration(milliseconds: 1000), (t) {
setState(() {
_count++;
});
});
}
@override
void dispose() {
if (_timer != null) {
if (_timer.isActive) {
_timer.cancel();
}
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text("Test Demo"),
),
body: content()
);
}
Widget content(){
Widget result = Column(
children: <Widget>[
Container(
margin: EdgeInsets.fromLTRB(10,10,10,5),
height: 100,
color: Color(0xff1fbfbf),
),
Container(
margin: EdgeInsets.fromLTRB(10,5,10,10),
height: 100,
color: Color(0xff1b8bdf),
),
Container(
height: 100,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 5,
itemBuilder: (context, index) {
return Container(
width: 70,
height: 70,
child: Image.asset(
'assets/images/common_empty.png',
width: 50,
height: 50,
),
);
}),
),
Container(
margin: EdgeInsets.fromLTRB(10,20,10,10),
height: 100,
width: 350,
color: Colors.yellow,
child: Center(
child:
Text(
_count.toString(),
style: TextStyle(fontSize: 18, fontWeight:FontWeight.bold),
),
)
),
],
);
return result;
}
}
大部分widget都是靜態的,只有黃色Container中包含一個內容一直刷新的Text,這個時候我們打開debugProfileBuildsEnabled,用Timeline分析下它的渲染耗時,可以通過Frame events chart中顯示的build層級非常深
結合第一部分渲染原理我們瞭解到,每次定時器刷新text數字的時候,整個頁面widget樹都會重新build,但其實只有最底層Container中的Text內容在改變,沒有必要刷新整顆樹,所以這裡我們的優化方案是提高build效率,降低Widget tree遍歷的出發點,將setState刷新數據儘量下發到底層節點,所以將Text單獨抽取成獨立的Widget,setState下發到抽取出的Widget內部
class _TestDemoState extends State<TestDemo> {
...
Widget content(){
Widget result = Column(
children: <Widget>[
...
Container(
margin: EdgeInsets.fromLTRB(10,20,10,10),
height: 100,
width: 350,
color: Colors.yellow,
child: Center(
child:
CountText()
)
),
],
);
return result;
}
}
class CountText extends StatefulWidget {
@override
State<StatefulWidget> createState() {
return _CountTextState();
}
}
class _CountTextState extends State<CountText> {
int _count = 0;
Timer _timer;
@override
void initState() {
super.initState();
_timer = Timer.periodic(Duration(milliseconds: 1000), (t) {
setState(() {
_count++;
});
});
}
@override
void dispose() {
if (_timer != null) {
if (_timer.isActive) {
_timer.cancel();
}
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
_count.toString(),
style: TextStyle(fontSize: 18, fontWeight:FontWeight.bold),
);
}
}
修改後的Timeline顯示如下圖:
build層級明顯減少,總耗時也明顯降低
接下來分析下Paint過程有沒有可以優化的部分,我們打開debugProfilePaintsEnabled變量分析可以看到Timeline顯示的paint層級
通過debugPaintLayerBordersEnabled = true;
顯示layer邊框可以看到不斷變化的Text和其他Widget都是在同一個layer中的,這裡我們想到的優化點是利用RepaintBoundary提高paint效率,它為經常發生顯示變化的內容提供一個新的隔離layer,新的layer paint不會影響到其他layer
RepaintBoundary(
child: Container(
margin: EdgeInsets.fromLTRB(10,20,10,10),
height: 100,
width: 350,
color: Colors.yellow,
child: Center(
child: CountText()
)
),
),
看下優化後的效果
可以看到我們為黃色的Container建立了單獨的layer,並且paint的層級減少很多
總結常見問題
- 提高build效率,setState刷新數據儘量下發到底層節點
- 提高paint效率,RepaintBoundry創建單獨layer減少重繪區域
這兩個我們之前的例子已經具體分析過
- 減少build中邏輯處理,因為widget在頁面刷新的過程中隨時會通過build重建,build調用頻繁,我們應該只處理跟UI相關的邏輯
- 減少saveLayer(ShaderMask、ColorFilter、Text Overflow)、clipPath的使用,saveLayer會在GPU中分配一塊新的繪圖緩衝區,切換繪圖目標,這個操作是在GPU中非常耗時的,clipPath會影響每個繪圖指令,做相交操作,之外的部分剔除掉,所以這也是個耗時操作
- 減少Opacity Widget 使用,尤其是在動畫中,因為他會導致widget每一幀都會被重建,可以用 AnimatedOpacity 或 FadeInImage 進行代替
以上內容介紹了些Flutter常見的性能問題以及我們怎麼用工具檢測這個問題,在平時開發過程中要留意規避這類問題
Flutter-DX案例分析
近期我們做了個Flutter端的動態化模板渲染方案Flutter-DX,它使用集團DinamicX的DSL,通過下發DSL模板,在Flutter側實現動態解析渲染。具體介紹可以參考之前的文章:
做一個高一致性、高性能的Flutter動態渲染,真的很難麼?
這裡不再詳細介紹。
儘管進行了一次渲染架構升級,很大程度上提升性能表現,但是通過高可用線上統計,發現在長列表場景下fps值沒有達到預期值,所以需要進一步分析哪些操作導致的耗時問題。
以搜索頁頁面結構為例,外部是GridView的容器,裡面都是一個個DX模板組成的寶貝card,滑動過程中發現流暢度要明顯偏低
所以我們做了以下的優化措施
1.針對Sliver滑動的優化,sliver在滑動過程中,有一個超出屏幕上下250像素的一個緩存區
在列表滾動過程中,DX card不斷的被重建和銷燬,沒有任何緩存機制,我們在其中加了個緩存池,流程如下,避免element不斷的被銷燬和創建,一定程度提高流暢度
2. 通過Timeline分析發現TextPaint的layout耗時顯著,進一步對比分析發現,同樣的UI顯示,帶換行符的長文本長度layout耗時明顯偏高,
後來確認帶換行符的文本會影響佈局效率,具體分析可以查看 issue
這裡我們做的優化措施是在判斷只有一行文本顯示的情況下,截取換行符前的內容作為text文本,從而提升TextPaint layout效率。
除此之外,還有一些減少佈局層級和簡化build流程,預加載緩存等措施,實現將FPS提升3個點,達到一定程度的優化效果。
總結
以上內容分析了flutter的渲染原理以及遇到卡頓問題可以用哪些工具從哪些方向入手分析,Flutter雖然一直宣稱流暢度是一大亮點,但也存在一定的優化空間,以及需要開發者掌握一定的開發技巧才能達到更絲滑的體驗。