攜程酒店Flutter性能優化實踐
作者簡介 | Qifan,攜程高級工程師,專注移動端開發;Yinuo,攜程高級工程師,專注移動端開發;popeye,攜程軟件技術專家,關注移動端跨端技術,致力于快速,高性能地支撐業務開發。
一 、前言
攜程酒店業務使用Flutter技術開發的時間快接近兩年,這期間有列表頁、詳情頁、相冊頁等頁面使用了Flutter技術棧進行了跨平臺整合,大大提高了研發效率。在開發過程中,也遇到了一些性能相關問題和用戶反饋,比如長列表滾動卡頓、頁面打開時間較長、頁面打開后部分數據加載時間較長等問題。為解決這些問題,我們選用了多個性能指標監控業務運行狀態,借助性能檢測工具定位問題,并查閱源碼、文檔等資源解決問題,形成了這篇文章。
同時在不斷的需求迭代和代碼更新過程中,APP的性能穩定性持續受到挑戰,為此我們建立了線上性能監控系統,通過量化,治理,監控三方面手段,持續改善APP性能和用戶體驗。目前頁面的各種性能指標諸如FPS、TTI、內存等都達到了不錯的效果,本文將介紹我們在優化過程中所遇到的問題和采取的主要優化方案。
二、FPS&TTI提升性能優化
2.1 常用性能指標和卡頓定義
對于客戶端應用來說,流暢度是影響用戶使用體驗的關鍵因素。流暢度低主要有:低FPS、高TTI、卡頓。這些現象出現時,頁面會出現不連續的動畫,頁面刷新會短暫停頓,打開新頁面速度較慢,新頁面出現白屏或者較長時間的加載動畫,用戶做點擊滑動等交互時頁面不響應。
用戶操作 FPS 的定義是每秒傳輸幀數 (Frames Per Second),是圖像領域的概念。對于手機客戶端來說,主流顯示屏的刷新率為60Hz,高端手機顯示屏刷新率可以達到120Hz及以上。理想情況下,頁面繪制的FPS和屏幕刷新率一致。屏幕畫面刷新次數越多,屏幕可以展示的動態細節越多,所以數值越高越好。TTI的定義是從頁面加載開始到頁面處于完全可交互狀態 (Time To Interactive),完全可交互狀態指的是頁面有內容呈現并且用戶可以進行操作。
2.2 FPS優化的工具介紹
Flutter官方提供了三種應用編譯選項,debug模式、release模式和profile模式。當我們需要做性能分析的時候,需要打包profile模式的應用,這個模式的性能接近release模式,并且有性能相關的信息分析。我們使用的工具是官方提供開發者工具中的Performance View,并選擇了Enhance tracing模式。
圖1 幀渲染時間柱狀圖
上圖是幀渲染時間,橫坐標是幀號,縱坐標是繪制時間,藍色代表該幀滿足60fps,橙色代表不滿足60fps。從這張圖可以快速定位到繪制時間較長的幀,而下圖是選中某幀之后,UI繪制和光柵化時間,如果選擇了Enhance tracing模式,可以看到耗時較長的方法、widget build。
前文已經介紹過FPS的定義,對于flutter繪制而言,每幀繪制耗時前三的是UI繪制時間、光柵化時間、vsync ahead。UI繪制時間主要是widget build、layout、paint,簡單認為是CPU時間;光柵化時間可以簡單認為是GPU時間;vsync ahead是vsync信號與widget build之間的延時。
圖2 Widget build耗時與對應執行的方法
2.3 具體實踐方案
a) 控制setState次數,使用Provider機制減小刷新范圍
我們的業務開發是MVVM結構的,數據驅動UI更新。UI的繪制占了性能開銷的很大部分,減少不必要的UI繪制、控制UI繪制的范圍這兩種方法能顯著改善性能。
減少不必要的UI繪制是通過控制build次數實現的。widget build是通過setState方法或者builder方法觸發的,在業務中,盡量減少非必要的setState,只有真正頁面數據發生變化,頁面狀態變化時才調用setState方法。對于builder方法,可以實現shouldRebuild等接口,增加觸發builder方法的限制。
控制UI繪制的范圍是通過改變widget樹層級實現的。MVVM中數據觸發UI更新的方式有很多,我們的業務主要用到了Provider機制,這是一種觀察者模式設計。如下圖所示,對于左邊的widget樹,如果只需要更新Container容器配置和Icon圖標配置,那么可以將selector拆分到這兩個widget的雙親widget,實現了Text widget不刷新。對于widget樹較大的業務,這樣的改動能顯著提升FPS。
圖3 Widget樹結構優化以減少build次數
b) 預構建widget (AnimatedBuilder)
圖4 酒店詳情頁頭部使用預構建減少build次數
上圖是酒店詳情頁頭部沉浸式動畫的UI,頭部展開的過程中,圖片和圖片上的蒙層需要重新繪制,圖片上部SHA logo不需要重新繪制,圖片下部tab欄不需要重新繪制,對于這個需求的做法是用AnimatedBuilder。
AnimatedBuilder提供了幾個可選參數,animation是對動畫的監聽,builder是動畫過程中需要重新繪制的部分,child是動畫過程中不需要重新繪制的部分,child作為參數會傳入builder中。下面的偽代碼是一個例子,動畫過程中Text并不會多次繪制。
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
child: Container(
width: 200.0,
height: 200.0,
color: Colors.green,
child: const Center(
child: Text('Text!'),
),
),
builder: (BuildContext context, Widget child) {
return Transform.rotate(
angle: _controller.value * 2.0 * math.pi,
child: child,
);
},
);
}
對于詳情頁頭部沉浸式動畫的例子,可以把widget樹進行拆分,只有圖片和圖片蒙層放入builder方法中,其余的widget作為child傳入builder,同時用Stack widget實現兩部分UI的組合,這樣改進之后,FPS在動畫過程中有較大提升。
c) const widget
對于dart語法,需要分清楚final和const關鍵字的區別。關鍵字final的意思是一次賦值,不能改變;而關鍵字const的意思是常量,確定的值。這兩者的區別是final變量在第一次使用時被初始化,而const 變量是一個編譯時替換為常量值。同樣的,對于const widget,這個widget在編譯階段就已經確定,不會有狀態的變化和成員變量更新。const widget特別適合于標簽、特殊Icon等可以復用的UI,性能開銷較小。
d) 減少耗時計算,放到Isolate
Flutter應用中的Dart代碼執行在UI Runner中,而Dart是單線程的,我們平時使用的異步任務Future都是在這個單線程的Event Queue之中,通過Event Loop來按順序執行。需要避免將一些耗時計算放在UI線程,可以把耗時計算放到Isolate去執行。
e) 懶加載
能夠實現懶加載的有ListView.builder、PageView.builder和GridView.builder,這些widget可以用戶長列表或重復容器結構的UI,通過判斷單個item是否在屏幕內或者將要進入屏幕位置而進行繪制。與之對應的是Column、Row等一次性繪制widget,對于重復結構的數據,盡量避免使用這些組件。
如下圖中,酒店周邊景點美食購物列表和附近同類型酒店列表都實現了按需加載。酒店周邊景點美食購物列表的卡片數量超過20個,最初使用Row 組件構建時,第一次構建時間超過25ms,達不到60FPS的16ms繪制時間要求。當然,按需加載也有性能開銷,出現在列表的滑動過程中。如果一次性全部構建了列表,滑動過程中不會觸發新的構建,滑動流暢度體驗更好,但是第一次構建時的卡頓感明顯。
圖5 酒店詳情頁周邊內容運用懶加載減少構建次數
f) 分幀渲染
錯峰加載方案使用分幀渲染,分幀渲染的原理是將一棵Widget樹中的部分繪制時間較長的節點在第一幀時只占位不繪制,等到下一幀開始時,節點替換占位UI,單獨使用一幀時間繪制。
在酒店詳情頭部信息繪制中運用了分幀渲染技術,下左圖未使用分幀渲染,下右圖對圖片tab欄、酒店設施標簽、點評模塊、地址欄使用分幀渲染。從結果看,減少了3次卡頓和1次輕微卡頓,流暢幀占比超過90%。
圖6 分幀渲染在詳情頁頭部運用的效果
布局與繪制的基本單位是一棵widget樹,分幀渲染的原理是將布局與繪制時間較長的子widget先用Container占位,再等下一幀開始時單獨渲染。使用占位widget的偽代碼如下,build方法返回占位widget,并在widget構建幀結束時替換占位widget并觸發繪制。
@override
void initState() {
super.initState();
result = widget.placeHolder;
replaceWidget ();
}
@override
Widget build(BuildContext context) {
return result;
}
void replaceWidget() {
SchedulerBinding.instance.addPostFrameCallback((t) {
TaskQueue.instance.scheduleTask(() {
if (mounted)
setState(() {
result = widget.child;
});
}, Priority.animation, id: widget.index);
});
}
幀的繪制狀態可以從SchedulerBinding獲得,同時建立隊列保證一幀執行一個子widget繪制。
// 等待當前幀結束時替換占位widget并觸發繪制
await SchedulerBinding.instance.endOfFrame;
// 執行任務隊列中的繪制任務
final TaskEntry<dynamic> entry = _taskQueue.first;
entry.run();
2.4 UI GPU 問題定位與優化
GPU 問題主要集中在底層渲染耗時上。有時候 Widget 樹雖然構造起來容易,但在 GPU 線程下的渲染卻很耗時。涉及 Widget 裁剪、蒙層這類多視圖疊加渲染,或是由于缺少緩存導致靜態圖像的反復繪制,都會明顯拖慢 GPU 的渲染速度。可以使用性能圖層提供的兩項參數,負責檢查多視圖疊加的視圖渲染開關checkerboardOffscreenLayers和負責檢查緩存的圖像開關checkerboardRasterCacheImages來檢查這種模塊的存在。
a) checkerboardOffscreenLayers
多視圖疊加通常會用到 Canvas 里的savaLayer 方法,這個方法在實現一些特定的效果(比如半透明)時非常有用,但由于其底層實現會在 GPU 渲染上涉及多圖層的反復繪制,因此會帶來較大的性能問題。對于 saveLayer 方法使用情況的檢查,我們只要在 MaterialApp 的初始化方法中,將 checkerboardOffscreenLayers 開關設置為 true,分析工具就會自動幫我們檢測多視圖疊加的情況了,使用了 saveLayer 的 Widget 會自動顯示為棋盤格式,并隨著頁面刷新而閃爍。
不過,saveLayer 是一個較為底層的繪制方法,因此我們一般不會直接使用它,而是會通過一些功能性 Widget,在涉及需要剪切或半透明蒙層的場景中間接地使用。所以一旦遇到這種情況,我們需要思考一下是否一定要這么做,能不能通過其他方式來實現。如下圖所示,因為詳情頭部bar用到高斯模糊,同時使用ClipRRect裁切圓角,ClipRRect會調到savelayer接口,所以該部分產生閃爍。
圖7 詳情頁頭部圖片標題欄中裁切樣式應用
b) checkerboardRasterCacheImages
從資源的角度看,另一類非常消耗性能的操作是,渲染圖像。這是因為圖像的渲染涉及 I/O、GPU 存儲,以及不同通道的數據格式轉換,因此渲染過程的構建需要消耗大量資源。
為了緩解GPU 的壓力,Flutter 提供了多層次的緩存快照,這樣Widget 重建時就無需重新繪制靜態圖像了。與檢查多視圖疊加渲染的checkerboardOffscreenLayers 參數類似,Flutter 也提供了檢查緩存圖像的開關 checkerboardRasterCacheImages,來檢測在界面重繪時頻繁閃爍的圖像(即沒有靜態緩存)。
我們可以把需要靜態緩存的圖像加到 RepaintBoundary 中,RepaintBoundary 可以確定 Widget 樹的重繪邊界,如果圖像足夠復雜,Flutter 引擎會自動將其緩存,避免重復刷新。當然,因為緩存資源有限,如果引擎認為圖像不夠復雜,也可能會忽RepaintBoundary。
2.5 頁面預加載提升TTI
網頁應用的主要流程有三步,通過鏈接打開頁面,發送服務請求獲得頁面數據,將頁面數據展示在頁面上。對客戶端應用來說,頁面之間跳轉是相對確定的,數據在頁面之間存在共享的可能,預加載的工作是在打開頁面之間預先獲得頁面的數據,從而減少打開頁面到頁面展示的時間。
預加載數據有三種常見方法,第二個頁面的數據在第一個頁面的服務結果中獲得;第二個頁面的數據在客戶端其它頁面中預先獲得并緩存;第二個頁面的服務請求在打開頁面之前發送。
a) 預加載頁面數據
頁面數據預獲取的方案,實現方法是在上一個頁面提前獲取服務數據,在用戶跳轉到當前頁面時,直接從緩存獲取,節省了數據的網絡傳輸時間,達到快速展示當前頁面內容的效果。目前在酒店核心預訂流程,都運用了數據預加載技術,如下圖所示。
圖8 酒店業務預加載頁面數據的應用
結合酒店業務特點,數據預加載需要考慮幾個方面問題,第一,酒店預訂流程頁面PV量都很高,酒店列表和詳情頁PV都是千萬級別,所以需要考慮數據預加載的時機,避免服務的資源浪費。第二,酒店列表,詳情,填單頁都有價格信息,價格信息對用戶來說是動態信息,實時都有變價可能,所以需要考慮數據預加載的緩存策略,避免因為價格的前后不一致造成用戶誤解。
在實現全流程預加載方案之后,我們酒店預訂流程頁面的慢加載率從初始值的42.90%降低至現階段的8.05%。
b) 預加載ViewModel
與數據預獲取的方案相比,預加載ViewModel更進一步,將預獲取的數據處理成ViewModel形式,在打開頁面時直接用ViewModel進行展示。這種方案減少了業務對數據處理的時間。
圖9 酒店詳情頁預加載ViewModel技術的應用
上圖是杭州綠城尊藍錢江豪華精選酒店在酒店列表頁和酒店詳情頁頭部的UI對比。可以看出,酒店詳情頁頭部的信息主要是酒店名稱、星級、榜單、特色設施、點評、開業裝修時間等信息,這些信息和列表頁酒店卡片信息存在重合。如果用戶瀏覽的軌跡為從酒店列表頁到酒店詳情頁,那么可以直接將列表頁的數據帶入酒店詳情頁作為頭部展示。
圖10 酒店詳情頁預加載ViewModel的數據流
上圖為詳情頁頭部預加載的主要流程。我們的flutter業務代碼采用MVVM的結構,將服務請求的結果處理完的數據放入ViewModel中,ViewModel的數據更新通過Provider機制觸發頁面UI更新。
圖中可以開到,詳情頁頭部ViewModel的數據有兩個來源,分別是列表頁服務請求的結果和詳情頁服務請求的結果。這兩個服務請求結果到ViewModel的業務流程不一樣,列表頁的服務結果數據通過URL參數的方式傳入詳情頁,而詳情頁服務結果可以直接生成詳情頁頭部的ViewModel。
圖中還有一個重要模塊是列表頁服務結果和詳情頁服務結果之間的通用緩存DataCache,它的功能是實現頁面之間數據的一致性。頁面上的數據可以由服務更新,也可以由用戶交互更新。業務的ViewModel依賴這個通用緩存,數據更新會觸發頁面UI更新。
三、Flutter服務通道優化
3.1 背景
因為我們APP采用的私有服務協議,目前發服務的動作還是在Native代碼上,而酒店的核心頁面已經轉到了Flutter上。通過Flutter框架提供的通道技術Native到Flutter的數據傳輸通道需要對數據做一次額外的序列化及反序列化的傳輸,同時傳輸的過程比較耗時,會阻塞UI的渲染主線程,對頁面的加載會造成明顯的影響。我們檢測到這個環節之后和框架一起對Flutter的底層框架進行了改造,可以實現數據流直接的透傳,同時不阻塞UI主線程,性能得到了極大的提升。
優化前,通過服務返回的數據流傳遞到flutter使用,整個過程要經歷以下4步:
- PB反序列化
- Response到JsonString的編碼
- JsonString到MethodChannel(使用JsonMethodCodec編解碼)
- 傳輸JsonString到Reponse的解碼
整個過程鏈路長,數據傳輸量大,效率低,影響到頁面加載性能,如下圖所示
圖11 優化前的業務服務請求數據流
改造后,通過服務返回的數據流,直接傳輸到Flutter側,在Flutter直接進行PB的反序列化,傳輸性能得到極大提升。
- ?PB的數據流到MethodChannel(使用StandardMethodCodec編解碼)傳輸
- PB反序列化到Response
整個過程鏈路短,數據傳輸量小,效率高,如下圖所示:
圖12 優化后的業務服務請求數據流
其中MethodChannel的編解碼器由JsonMethodCodec換成了StandardMethodCodec。因為StandardMethodCodec可以避免轉換JsonString的操作,能節省傳輸時間。
3.2 Flutter中使用Protobuf
在flutter中使用Protobuf,首先需要將proto契約文件轉化成dart文件,可以借助官方編譯工具protoc進行編譯。
a) 獲取protoc工具
安裝C+
+sudo apt-get install autoconf automake libtool curl make g++ unzip
安裝Protobuf發行版
https://github.com/protocolbuffers/protobuf/releases
下載完成之后,解壓,進到目錄中執行下面命令編譯安裝
./configure
make
make check
sudo make install
sudo ldconfig # refresh shared library cache.
安裝protoc-gen-dart插件
dart pub global activate protoc_plugin
在Terminal中執行protoc命令生成dart文件
protoc --dart_out=. <文件名>.proto
圖13 生成的契約文件結構
b) 使用生成的dart契約文件
執行flutter pub add protobuf命令,修改項目的pubspec.yaml,在dependencies中加上: protobuf: ^2.0.1
編寫如下測試代碼:
圖14 使用契約的樣例代碼
執行后可以得到如下結果:
圖15 執行結果
其中,生成Person的類繼承了Protobuf包里的GeneratedMessage類,序列化和反序列化由基類實現。但是這種方式不能根據需要定制化生成契約文件。因此,為了更好的兼容Json格式的數據,可以使用FreeMarker模板引擎定制化生成契約文件。
圖16 使用FreeMarker生成契約的文件結構
3.3 使用FreeMarker定制化生成dart契約文件
FreeMarker是一款模板引擎:即一種基于模板和要改變的數據,并用來生成輸出文本(HTML網頁、電子郵件、配置文件、源代碼等)的通用工具。它不是面向最終用戶的,而是一個Java類庫,是一款程序員可以嵌入他們所開發產品的組件。
下面介紹如何使用FreeMarker和protoc命令生成任意編程語言的契約文件
1)下載FreeMarker最新版jar包
https://freemarker.apache.org/freemarkerdownload.html
2)下載Protobuf對應版本的jar包
https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java
3)在Java項目中導入對應jar包
圖17 項目中導入工具方法
4)編寫Java程序
圖18 程序流程圖
程序的流程如上圖所示。首先使用protoc命令生成對應的描述文件,其次將描述文件轉換成對應java對象,最后使用FreeMarker模板引擎生成任意語言的契約文件。
圖19 程序的實現
由上圖可知,模板引擎的輸入是一個classModel對象。如下圖實現了將描述文件轉化成classModel對象的功能。
圖20 程序的實現(續)
FTL模板文件如下圖所示:
圖21 模版文件
5)執行代碼輸出契約文件
圖22 輸出的契約文件
這樣就可以實現了根據proto文件自定義生成任意編程語言的契約文件。
3.4 Json與Protobuf的性能對比
我們對比了相同報文情況下Json和Protobuf在序列化和反序列化上所花費的時間。從下圖可知,Protobuf在序列化和反序列化相同大小報文時比Json花費的時間大大減少了,也大大提高了我們獲取數據的速度。
圖23 序列化、反序列化時間
四、內存泄漏治理
4.1 內存泄漏的常用監控手段
內存泄漏是一個比較嚴重的問題,如果出現,對App的穩定性和用戶體驗都有非常大影響。因此對這塊的監控和治理也是我們非常關注的一塊。
在監控方面Flutter現在比較通用的方法就是利用Expando中的弱引用去監控我們要檢查是否有泄漏的對象,如果出現則從VM中獲取其引用鏈接,從而分析其泄漏原因。我們的框架也利用此方法監控了我們app中的每個頁面是否在退出時還存在泄漏。
另外通過Flutter的Dev tool中的內存監控工具也能實現對泄漏對象的發現。比如對于酒店詳情頁面,反復進入和退出此頁面,如果有泄漏會發現,在內存監控工具中出現此頁面多個的對象存活,此時基本可以判斷出此頁面出現了泄漏了。下圖的第一列是類名,第二、三列是實例數量,第四、五列是對應分配的字節數。
圖24 酒店詳情的內存泄漏監控
4.2 內存泄漏的治理
下面介紹一下,我們在我們頁面的內存泄漏治理中發現的一些導致泄漏的原因和解決的辦法。
a) 調用Native的Plugin時,對Future的Then設置的閉包沒有關閉
在調用Native的Plugin接口時,有時會設置一個Then的閉包,期望在這個閉包里去處理這個Plugin的返回結果。這個閉包會注冊到引擎的全局變量里面,如果Native調用了result的listener,這個Then的閉包會走到,然后會被清除掉。如果某些case,Native沒有調用,則這個閉包會泄露,如果這個閉包所屬的Model能引用到頁面對象的話,則會造成整個頁面的泄露。
比如下面這個例子,我們進入flutter頁面時會調這個plugin,但是native對應的result則必須在某些case情況下才會回調。而大部分情況下,是不會回調的,從而造成整個頁面的泄露。解決方法是把future轉換成stream,然后我們在頁面退出時cancel掉,就能避免閉包的泄漏。
例子:調用Native的Plugin時出現泄漏的情況
Flutter側的調用:
void callNative() {
FlutterBridge.callNative("method", map).then((value) {
do some thing;});
}
Native的響應:
override fun flutterPluginAction ( result: MethodChannel.Result){
if (condition) {
result.success(ret)
} else {
do something;
}
}
可以看到Native在接受到這個plugin調用時,對于result的調用返回不是一直都會做的,它需要等到滿足條件才會做這件事情,而如果它不做這件事情,對應的flutter那邊的閉包就會一直被保存在引擎中,這個引用鏈也會一直存在,從而造成這個引用鏈上的對象都泄漏了。
解決的方法:
void callNative () {
Future future = FlutterBridge. callNative ("method");
_streamSubscription?.cancel();
_streamSubscription = future?.asStream()?.listen((value)
{
do something;
});
}
我們的解決方式,就是對這種異步但不能確定回調是否一定完成的情況,換成用StreamSubscription去監聽。然后當頁面退出時做一下cancel的動作,這樣就能避免泄漏的發生。
void onPageDestroy() {
_ streamSubscription?.cancel();
}
這種等待對異步調用的回調監聽其實都可能存在類似問題,只不過如果是單純在Dart中的異步調用一般不會存在這種不回調的情況。但是對于plugin這種跟native的交互的地方,我們在初期接觸flutter時沒有關注到這塊,有可能會造成遺漏。
b) 一些觀察者模式中的訂閱者在頁面退出時沒有取消訂閱
這種是大家比較熟悉的一種情況。常見的例子有例如像Timer,EventBusCenter.defaultBus和LifeCycleObserver等。這些訂閱者如果在頁面退出時不需要了,需要記得取消掉。否則也會造成內存泄漏,這種情況我們也應該避免。
五、小結
性能優化是一件不斷持續,不斷深入的事情。我們通過本文中所介紹的改進措施對頁面性能實現了很大的優化,達到了不錯的效果。后續也會在此基礎之上對還可提高的地方繼續加深,同時也會對已經驗證實行有效的方案去做一些抽象,封裝工作,后續提供通用的解決方案。