在上一篇文章中,我與你分享了如何捕獲 Flutter 應用的未處理異常。所謂異常,指的是 Dart 代碼在運行時意外發生的錯誤事件。對于單一異常來說,我們可以使用 try-catch,或是 catchError 去處理;而如果我們想對異常進行集中的攔截治理,則需要使用 Zone,并結合 FlutterError 進行統一管理。異常一旦被抓取,我們就可以利用第三方數據上報服務(比如 Bugly),上報其上下文信息了。
這些線上異常的監控數據,對于開發者盡早發現線上隱患,確定問題根因至關重要。如果我們想進一步評估應用整體的穩定性的話,就需要把異常信息與頁面的渲染關聯起來。比如,頁面渲染過程是否出現了異常,而導致功能不可用?
而對于以“絲般順滑”著稱的 Flutter 應用而言,頁面渲染的性能同樣需要我們重點關注。比如,界面渲染是否出現會掉幀卡頓現象,或者頁面加載是否會出現性能問題導致耗時過長?這些問題,雖不至于讓應用完全不能使用,但也很容易引起用戶對應用質量的質疑,甚至是反感。
通過上面的分析,可以看到,衡量線上 Flutter 應用整體質量的指標,可以分為以下 3 類:
* 頁面異常率;
* 頁面幀率;
* 頁面加載時長。
其中,頁面異常率反應了頁面的健康程度,頁面幀率反應了視覺效果的順滑程度,而頁面加載時長則反應了整個渲染過程中點對點的延時情況。
這三項數據指標,是度量 Flutter 應用是否優秀的重要質量指標。通過梳理這些指標的統計口徑,建立起 Flutter 應用的質量監控能力,這樣一來我們不僅可以及早發現線上隱患,還可以確定質量基線,從而持續提升用戶體驗。
所以在今天的分享中,我會與你詳細講述這 3 項指標是如何采集的。
## 頁面異常率
頁面異常率指的是,頁面渲染過程中出現異常的概率。它度量的是頁面維度下功能不可用的情況,其統計公式為:**頁面異常率 = 異常發生次數 / 整體頁面 PV 數**。
在了解了頁面異常率的統計口徑之后,接下來我們分別來看一下這個公式中的分子與分母應該如何統計吧。
我們先來看看**異常發生次數的統計方法**。通過上一篇文章,我們已經知道了在 Flutter 中,未處理異常需要通過 Zone 與 FlutterError 去捕獲。所以,如果我們想統計異常發生次數的話,依舊是利用這兩個方法,只不過要在異常攔截的方法中,通過一個計數器進行累加,統一記錄。
下面的例子演示了異常發生次數的具體統計方法。我們使用全局變量 exceptionCount,在異常捕獲的回調方法 \_reportError 中持續地累加捕獲到的異常次數:
~~~
int exceptionCount = 0; Future<Null> _reportError(dynamic error, dynamic stackTrace) async { exceptionCount++; // 累加異常次數 FlutterCrashPlugin.postException(error, stackTrace);} Future<Null> main() async { FlutterError.onError = (FlutterErrorDetails details) async { // 將異常轉發至 Zone Zone.current.handleUncaughtError(details.exception, details.stack); }; runZoned<Future<Null>>(() async { runApp(MyApp()); }, onError: (error, stackTrace) async { // 攔截異常 await _reportError(error, stackTrace); });}
~~~
接下來,我們再看看**整體頁面 PV 數如何統計**吧。整體頁面 PV 數,其實就是頁面的打開次數。通過第 21 篇文章“[路由與導航,Flutter 是這樣實現頁面切換的](https://time.geekbang.org/column/article/118421)”,我們已經知道了 Flutter 頁面的切換需要經過 Navigator 來實現,所以頁面切換狀態也需要通過 Navigator 才能感知到。
與注冊頁面路由類似的,在 MaterialApp 中,我們可以通過 NavigatorObservers 屬性,去監聽頁面的打開與關閉。下面的例子演示了**NavigatorObserver 的具體用法**。在下面的代碼中,我們定義了一個繼承自 NavigatorObserver 的觀察者,并在其 didPush 方法中,去統計頁面的打開行為:
~~~
int totalPV = 0;// 導航監聽器class MyObserver extends NavigatorObserver{ @override void didPush(Route route, Route previousRoute) { super.didPush(route, previousRoute); totalPV++;// 累加 PV }} class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( // 設置路由監聽 navigatorObservers: [ MyObserver(), ], home: HomePage(), ); } }
~~~
現在,我們已經收集到了異常發生次數和整體頁面 PV 數這兩個參數,接下來我們就可以計算出頁面異常率了:
~~~
double pageException() { if(totalPV == 0) return 0; return exceptionCount/totalPV;}
~~~
可以看到,頁面異常率的計算還是相對比較簡單的。
## 頁面幀率
頁面幀率,即 FPS,是圖像領域中的定義,指的是畫面每秒傳輸幀數。由于人眼的視覺暫留特質,當所見到的畫面傳輸幀數高于一定數量的時候,就會認為是連貫性的視覺效果。因此,對于動態頁面而言,每秒鐘展示的幀數越多,畫面就越流暢。
由此我們可以得出,**FPS 的計算口徑為單位時間內渲染的幀總數**。在移動設備中,FPS 的推薦數值通常是 60Hz,即每秒刷新頁面 60 次。
為什么是 60Hz,而不是更高或更低的值呢?這是因為顯示過程,是由 VSync 信號周期性驅動的,而 VSync 信號的周期就是每秒 60 次,這也是 FPS 的上限。
CPU 與 GPU 在接收到 VSync 信號后,就會計算圖形圖像,準備渲染內容,并將其提交到幀緩沖區,等待下一次 VSync 信號到來時顯示到屏幕上。如果在一個 VSync 時間內,CPU 或者 GPU 沒有完成內容提交,這一幀就會被丟棄,等待下一次機會再顯示,而這時頁面會保留之前的內容不變,造成界面卡頓。因此,FPS 低于 60Hz 時就會出現掉幀現象,而如果低于 45Hz 則會有比較嚴重的卡頓現象。
為方便開發者統計 FPS,Flutter 在全局 window 對象上提供了幀回調機制。我們可以**在 window 對象上注冊 onReportTimings 方法**,將最近繪制幀耗費的時間(即 FrameTiming),以回調的形式告訴我們。有了每一幀的繪制時間后,我們就可以計算 FPS 了。
需要注意的是,onReportTimings 方法只有在有幀被繪制時才有數據回調,如果用戶沒有和 App 發生交互,界面狀態沒有變化時,是不會產生新的幀的。考慮到單個幀的繪制時間差異較大,逐幀計算可能會產生數據跳躍,所以為了讓 FPS 的計算更加平滑,我們需要保留最近 25 個 FrameTiming 用于求和計算。
而另一方面,對于 FPS 的計算,我們并不能孤立地只考慮幀繪制時間,而應該結合 VSync 信號的周期,即 1/60 秒(即 16.67 毫秒)來綜合評估。
由于幀的渲染是依靠 VSync 信號驅動的,如果幀繪制的時間沒有超過 16.17 毫秒,我們也需要把它當成 16.67 毫秒來算,因為繪制完成的幀必須要等到下一次 VSync 信號來了之后才能渲染。而如果幀繪制時間超過了 16.67 毫秒,則會占用后續的 VSync 信號周期,從而打亂后續的繪制次序,產生卡頓現象。這里有兩種情況:
* 如果幀繪制時間正好是 16.67 的整數倍,比如 50,則代表它花費了 3 個 VSync 信號周期,即本來可以繪制 3 幀,但實際上只繪制了 1 幀;
* 如果幀繪制時間不是 16.67 的整數倍,比如 51,那么它花費的 VSync 信號周期應該向上取整,即 4 個,這意味著本來可以繪制 4 幀,實際上只繪制了 1 幀。
所以我們的 FPS 計算公式最終確定為:**FPS=60\* 實際渲染的幀數 / 本來應該在這個時間內渲染完成的幀數**。
下面的示例演示了如何通過 onReportTimings 回調函數實現 FPS 的計算。在下面的代碼中,我們定義了一個容量為 25 的列表,用于存儲最近的幀繪制耗時 FrameTiming。在 FPS 的計算函數中,我們將列表中每幀繪制時間與 VSync 周期 frameInterval 進行比較,得出本來應該繪制的幀數,最后兩者相除就得到了 FPS 指標。
需要注意的是,Android Studio 提供的 Flutter 插件里展示的 FPS 信息,其實也來自于 onReportTimings 回調,所以我們在注冊回調時需要保留原始回調引用,否則插件就讀不到 FPS 信息了。
~~~
import 'dart:ui';
var orginalCallback;
void main() {
runApp(MyApp());
// 設置幀回調函數并保存原始幀回調函數
orginalCallback = window.onReportTimings;
window.onReportTimings = onReportTimings;
}
// 僅緩存最近 25 幀繪制耗時
const maxframes = 25;
final lastFrames = List<FrameTiming>();
// 基準 VSync 信號周期
const frameInterval = const Duration(microseconds: Duration.microsecondsPerSecond ~/ 60);
void onReportTimings(List<FrameTiming> timings) {
lastFrames.addAll(timings);
// 僅保留 25 幀
if(lastFrames.length > maxframes) {
lastFrames.removeRange(0, lastFrames.length - maxframes);
}
// 如果有原始幀回調函數,則執行
if (orginalCallback != null) {
orginalCallback(timings);
}
}
double get fps {
int sum = 0;
for (FrameTiming timing in lastFrames) {
// 計算渲染耗時
int duration = timing.timestampInMicroseconds(FramePhase.rasterFinish) - timing.timestampInMicroseconds(FramePhase.buildStart);
// 判斷耗時是否在 Vsync 信號周期內
if(duration < frameInterval.inMicroseconds) {
sum += 1;
} else {
// 有丟幀,向上取整
int count = (duration/frameInterval.inMicroseconds).ceil();
sum += count;
}
}
return lastFrames.length/sum * 60;
}
~~~
運行這段代碼,可以看到,我們統計的 FPS 指標和 Flutter 插件展示的 FPS 走勢是一致的。
:-: 
圖 1 FPS 指標走勢
## 頁面加載時長
頁面加載時長,指的是頁面從創建到可見的時間。它反應的是代碼中創建頁面視圖是否存在過度繪制,或者繪制不合理導致創建視圖時間過長的情況。
從定義可以看出,**頁面加載時長的統計口徑為頁面可見的時間 - 頁面創建的時間**。獲取頁面創建的時間比較容易,我們只需要在頁面的初始化函數里記錄時間即可。那么,**頁面可見的時間應該如何統計**呢?
在第 11 篇文章“[提到生命周期,我們是在說什么?](https://time.geekbang.org/column/article/109490)”中,我在介紹 Widget 的生命周期時,曾向你介紹過 Flutter 的幀回調機制。WidgetsBinding 提供了單次 Frame 回調 addPostFrameCallback 方法,它會在當前 Frame 繪制完成之后進行回調,并且只會回調一次。一旦監聽到 Frame 繪制完成回調后,我們就可以確認頁面已經被渲染出來了,因此我們可以借助這個方法去獲取頁面可見的時間。
下面的例子演示了如何通過幀回調機制獲取頁面加載時長。在下面的代碼中,我們在頁面 MyPage 的初始化方法中記錄了頁面的創建時間 startTime,然后在頁面狀態的初始化方法中,通過 addPostFrameCallback 注冊了單次幀繪制回調,并在回調函數中記錄了頁面的渲染完成時間 endTime。將這兩個時間做減法,我們就得到了 MyPage 的頁面加載時長:
~~~
class MyHomePage extends StatefulWidget {
int startTime;
int endTime;
MyHomePage({Key key}) : super(key: key) {
// 頁面初始化時記錄啟動時間
startTime = DateTime.now().millisecondsSinceEpoch;
}
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
@override
void initState() {
super.initState();
// 通過幀繪制回調獲取渲染完成時間
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.endTime = DateTime.now().millisecondsSinceEpoch;
int timeSpend = widget.endTime - widget.startTime;
print("Page render time:${timeSpend} ms");
});
}
...
}
~~~
試著運行一下代碼,觀察命令行輸出:
~~~
flutter: Page render time:548 ms
~~~
可以看到,通過單次幀繪制回調統計得出的頁面加載時間為 548 毫秒。
至此,我們就已經得到了頁面異常率、頁面幀率和頁面加載時長這 3 個指標了。
## 總結
好了,今天的分享就到這里,我們來總結下主要內容吧。
今天我們一起學習了衡量 Flutter 應用線上質量的 3 個指標,即頁面異常率、頁面幀率和頁面加載時長,以及分別對應的數據采集方式。
其中,頁面異常率表示頁面渲染過程中的穩定性,可以通過集中捕獲未處理異常,結合 NavigatorObservers 觀察頁面 PV,計算得出頁面維度下功能不可用的概率。
頁面幀率則表示了頁面的流暢情況,可以利用 Flutter 提供的幀繪制耗時回調 onReportTimings,以加權的形式計算出本應該繪制的幀數,得到更為準確的 FPS。
而頁面加載時長,反應的是渲染過程的延時情況。我們可以借助于單次幀回調機制,來獲取頁面渲染完成時間,從而得到整體頁面的加載時長。
通過這 3 個數據指標統計方法,我們再去評估 Flutter 應用的性能時,就有一個具體的數字化標準了。而有了數據之后,我們不僅可以及早發現問題隱患,準確定位及修復問題,還可以根據它們去評估應用的健康程度和頁面的渲染性能,從而確定后續的優化方向。
我把今天分享涉及的知識點打包到了[GitHub](https://github.com/cyndibaby905/40_peformance_demo)中,你可以下載下來,反復運行幾次,加深理解與記憶。
## 思考題
最后,我給你留一道思考題吧。
如果頁面的渲染需要依賴單個或多個網絡接口數據,這時的頁面加載時長應該如何統計呢?
- 前言
- 開篇詞
- 預習篇
- 01丨預習篇 · 從0開始搭建Flutter工程環境
- 02丨預習篇 · Dart語言概覽
- Flutter開發起步
- 03丨深入理解跨平臺方案的歷史發展邏輯
- 04丨Flutter區別于其他方案的關鍵技術是什么?
- 05丨從標準模板入手,體會Flutter代碼是如何運行在原生系統上的
- Dart語言基礎
- 06丨基礎語法與類型變量:Dart是如何表示信息的?
- 07丨函數、類與運算符:Dart是如何處理信息的?
- 08丨綜合案例:掌握Dart核心特性
- Flutter基礎
- 09丨Widget,構建Flutter界面的基石
- 10丨Widget中的State到底是什么?
- 11丨提到生命周期,我們是在說什么?
- 12丨經典控件(一):文本、圖片和按鈕在Flutter中怎么用?
- 13丨ListView在Flutter中是什么?
- 14 丨 經典布局:如何定義子控件在父容器中排版位置?
- 15 丨 組合與自繪,我該選用何種方式自定義Widget?
- 16 丨 從夜間模式說起,如何定制不同風格的App主題?
- 17丨依賴管理(一):圖片、配置和字體在Flutter中怎么用?
- 18丨依賴管理(二):第三方組件庫在Flutter中要如何管理?
- 19丨用戶交互事件該如何響應?
- 20丨關于跨組件傳遞數據,你只需要記住這三招
- 21丨路由與導航,Flutter是這樣實現頁面切換的
- Flutter進階
- 22丨如何構造炫酷的動畫效果?
- 23丨單線程模型怎么保證UI運行流暢?
- 24丨HTTP網絡編程與JSON解析
- 25丨本地存儲與數據庫的使用和優化
- 26丨如何在Dart層兼容Android-iOS平臺特定實現?(一)
- 27丨如何在Dart層兼容Android-iOS平臺特定實現?(二)
- 28丨如何在原生應用中混編Flutter工程?
- 29丨混合開發,該用何種方案管理導航棧?
- 30丨為什么需要做狀態管理,怎么做?
- 31丨如何實現原生推送能力?
- 32丨適配國際化,除了多語言我們還需要注意什么
- 33丨如何適配不同分辨率的手機屏幕?
- 34丨如何理解Flutter的編譯模式?
- 35丨HotReload是怎么做到的?
- 36丨如何通過工具鏈優化開發調試效率?
- 37丨如何檢測并優化FlutterApp的整體性能表現?
- 38丨如何通過自動化測試提高交付質量?
- Flutter綜合應用
- 39丨線上出現問題,該如何做好異常捕獲與信息采集?
- 40丨衡量FlutterApp線上質量,我們需要關注這三個指標
- 41丨組件化和平臺化,該如何組織合理穩定的Flutter工程結構?
- 42丨如何構建高效的FlutterApp打包發布環境?
- 43丨如何構建自己的Flutter混合開發框架(一)?
- 44丨如何構建自己的Flutter混合開發框架(二)?
- 結束語
- 結束語丨勿畏難,勿輕略