今天我們來聊聊如何調試 Flutter App。
軟件開發通常是一個不斷迭代、螺旋式上升的過程。在迭代的過程中,我們不可避免地會經常與 Bug 打交道,特別是在多人協作的項目中,我們不僅要修復自己的 Bug,有時還需要幫別人解決 Bug。
而修復 Bug 的過程,不僅能幫我們排除代碼中的隱患,也能幫助我們更快地上手項目。因此,掌握好調試這門技能,就顯得尤為重要了。
在 Flutter 中,調試代碼主要分為輸出日志、斷點調試和布局調試 3 類。所以,在今天這篇文章中,我將會圍繞這 3 個主題為你詳細介紹 Flutter 應用的代碼調試。
我們先來看看,如何通過輸出日志調試應用代碼吧。
## 輸出日志
為了便于跟蹤和記錄應用的運行情況,我們在開發時通常會在一些關鍵步驟輸出日志(Log),即使用 print 函數在控制臺打印出相關的上下文信息。通過這些信息,我們可以定位代碼中可能出現的問題。
在前面的很多篇文章里,我們都大量使用了 print 函數來輸出應用執行過程中的信息。不過,由于涉及 I/O 操作,使用 print 來打印信息會消耗較多的系統資源。同時,這些輸出數據很可能會暴露 App 的執行細節,所以我們需要在發布正式版時屏蔽掉這些輸出。
說到操作方法,你想到的可能是在發布版本前先注釋掉所有的 print 語句,等以后需要調試時,再取消這些注釋。但,這種方法無疑是非常無聊且耗時的。那么,Flutter 給我們提供了什么更好的方式嗎?
為了根據不同的運行環境來開啟日志調試功能,我們可以使用 Flutter 提供的 debugPrint 來代替 print。**debugPrint 函數同樣會將消息打印至控制臺,但與 print 不同的是,它提供了定制打印的能力。**也就是說,我們可以向 debugPrint 函數,賦值一個函數聲明來自定義打印行為。
比如在下面的代碼中,我們將 debugPrint 函數定義為一個空函數體,這樣就可以實現一鍵取消打印的功能了:
~~~
debugPrint = (String message, {int wrapWidth}) {};// 空實現
~~~
在 Flutter 中,我們可以使用不同的 main 文件來表示不同環境下的入口。比如,在第 34 篇文章“[如何理解 Flutter 的編譯模式?](https://time.geekbang.org/column/article/135865)”中,我們就分別用 main.dart 與 main-dev.dart 實現了生產環境與開發環境的分離。同樣,我們可以通過 main.dart 與 main-dev.dart,去分別定義生產環境與開發環境不同的打印日志行為。
在下面的例子中,我們將生產環境的 debugPrint 定義為空實現,將開發環境的 debugPrint 定義為同步打印數據:
~~~
//main.dart
void main() {
// 將 debugPrint 指定為空的執行體, 所以它什么也不做
debugPrint = (String message, {int wrapWidth}) {};
runApp(MyApp());
}
//main-dev.dart
void main() async {
// 將 debugPrint 指定為同步打印數據
debugPrint = (String message, {int wrapWidth}) => debugPrintSynchronously(message, wrapWidth: wrapWidth);
runApp(MyApp());
}
~~~
可以看到,在代碼實現上,我們只要將應用內所有的 print 都替換成 debugPrint,就可以滿足開發環境下打日志的需求,也可以保證生產環境下應用的執行信息不會被意外打印。
## 斷點調試
輸出日志固然方便,但如果要想獲取更為詳細,或是粒度更細的上下文信息,靜態調試的方式非常不方便。這時,我們需要更為靈活的動態調試方法,即斷點調試。斷點調試可以讓代碼在目標語句上暫停,讓程序逐條執行后續的代碼語句,來幫助我們實時關注代碼執行上下文中所有變量值的詳細變化過程。
Android Studio 提供了斷點調試的功能,調試 Flutter 應用與調試原生 Android 代碼的方法完全一樣,具體可以分為三步,即**標記斷點、調試應用、查看信息**。
接下來,我們以 Flutter 默認的計數器應用模板為例,觀察代碼中 \_counter 值的變化,體會斷點調試的全過程。
**首先是標記斷點。**既然我們要觀察 \_counter 值的變化,因此在界面上展示最新的 \_counter 值時添加斷點,去觀察其數值變化是最理想的。因此,我們在行號右側點擊鼠標,可以把斷點加載到初始化 Text 控件所示的位置。
在下圖的例子中,我們為了觀察 \_counter 在等于 20 的時候是否正常,還特意設置了一個條件斷點 \_counter==20,這樣調試器就只會在第 20 次點擊計數器按鈕時暫停下來:
:-: 
圖 1 標記斷點
添加斷點后,對應的行號將會出現圓形的斷點標記,并高亮顯示整行代碼。到此,斷點就添加好了。當然,我們還可以同時添加多個斷點,以便更好地觀察代碼的執行過程。
**接下來則是調試應用了。**和之前通過點擊 run 按鈕的運行方式不同,這一次我們需要點擊工具欄上的蟲子圖標,以調試模式啟動 App,如下圖所示:
:-: 
圖 2 調試 App
等調試器初始化好后,我們的程序就啟動了。由于我們的斷點設置在了 \_counter 為 20 時,因此在第 20 次點擊了“+”按鈕后,代碼運行到了斷點位置,自動進入了 Debug 視圖模式。
:-: 
圖 3 Debug 視圖模式
如圖所示,我把 Debug 視圖模式劃分為 4 個區域,即 A 區控制調試工具、B 區步進調試工具、C 區幀調試窗口、D 區變量查看窗口。
**A 區的按鈕**,主要用來控制調試的執行情況:
:-: 
圖 4 A 區按鈕
* 比如,我們可以點擊繼續執行按鈕來讓程序繼續運行、點擊終止執行按鈕來讓程序終止運行、點擊重新執行按鈕來讓程序重新啟動,或是在程序正常執行時,點擊暫停執行按鈕按鈕來讓程序暫停運行。
* 又比如,我們可以點擊編輯斷點按鈕來編輯斷點信息,或是點擊禁用斷點按鈕來取消斷點。
**B 區的按鈕**,主要用來控制斷點的步進情況:
:-: 
圖 5 B 區按鈕
* 比如,我們可以點擊單步跳過按鈕來讓程序單步執行(但不會進入方法體內部)、點擊單步進入或強制單步進入按鈕讓程序逐條語句執行,甚至還可以點擊運行到光標處按鈕讓程序執行到在光標處(相當于新建臨時斷點)。
* 比如,當我們認為斷點所在的方法體已經無需執行時,則可以點擊單步跳出按鈕讓程序立刻執行完當前進入的方法,從而返回方法調用處的下一行。
* 又比如,我們可以點擊表達式計算按鈕來通過賦值或表達式方式修改任意變量的值。如下圖所示,我們通過輸入表達式 \_counter+=100,將計數器更新為 120:
:-: 
圖 6 Evaluate 計算表達式
**C 區**用來指示當前斷點所包含的函數執行堆棧,**D 區**則是其堆棧中的函數幀所對應的變量。
在這個例子中,我們的斷點是在 \_MyHomePageState 類中的 build 方法設置的,因此 D 區顯示的也是 build 方法上下文所包含的變量信息(比如 \_counter、\_widget、this、\_element 等)。如果我們想切換到 \_MyHomePageState 的 build 方法執行堆棧中的其他函數(比如 StatefulElement.build),查看相關上下文的變量信息時,只需要在 C 區中點擊對應的方法名即可。
:-: 
圖 7 切換函數執行堆棧
可以看到,Android Studio 提供的 Flutter 調試能力很豐富,我們可以通過這些基本步驟的組合,更為靈活地調整追蹤步長,觀察程序的執行情況,揪出代碼中的 Bug。
## 布局調試
通過斷點調試,我們在 Android Studio 的調試面板中,可以隨時查看執行上下文有關的變量的值,根據邏輯來做進一步的判斷,確定跟蹤執行的步驟。不過在更多時候,我們使用 Flutter 的目的是實現視覺功能,而視覺功能的調試是無法簡單地通過 Debug 視圖模式面板來搞定的。
在上一篇文章中,我們通過 Flutter 提供的熱重載機制,已經極大地縮短了從代碼編寫到界面運行所耗費的時間,可以更快地發現代碼與目標界面的明顯問題,但**如果想要更快地發現界面中更為細小的問題,比如對齊、邊距等,則需要使用 Debug Painting 這個界面調試工具**。
Debug Painting 能夠以輔助線的方式,清晰展示每個控件元素的布局邊界,因此我們可以根據輔助線快速找出布局出問題的地方。而 Debug Painting 的開啟也比較簡單,只需要將 debugPaintSizeEnabled 變量置為 true 即可。如下所示,我們在 main 函數中,開啟了 Debug Painting 調試開關:
~~~
import 'package:flutter/rendering.dart';
void main() {
debugPaintSizeEnabled = true; // 打開 Debug Painting 調試開關
runApp(new MyApp());
}
~~~
運行代碼后,App 在 iPhone X 中的執行效果如下:
:-: 
圖 8 Debug Painting 運行效果
可以看到,計數器示例中的每個控件元素都已經被標尺輔助線包圍了。
輔助線提供了基本的 Widget 可視化能力。通過輔助線,我們能夠感知界面中是否存在對齊或邊距的問題,但卻沒有辦法獲取到布局信息,比如 Widget 距離父視圖的邊距信息、Widget 寬高尺寸信息等。
**如果我們想要獲取到 Widget 的可視化信息(比如布局信息、渲染信息等)去解決渲染問題,就需要使用更強大的 Flutter Inspector 了。**Flutter Inspector 對控件布局詳細數據提供了一種強大的可視化手段,來幫助我們診斷布局問題。
為了使用 Flutter Inspector,我們需要回到 Android Studio,通過工具欄上的“Open DevTools”按鈕啟動 Flutter Inspector:
:-: 
圖 9 Flutter Inspector 啟動按鈕
隨后,Android Studio 會打開瀏覽器,將計數器示例中的 Widget 樹結構展示在面板中。可以看到,Flutter Inspector 所展示的 Widget 樹結構,與代碼中實現的 Widget 層次是一一對應的。
:-: 
圖 10 Flutter Inspector 示意圖
我們的 App 運行在 iPhone X 之上,其分辨率為 375\*812。接下來,我們以 Column 組件的布局信息為例,通過確認其水平方向為居中布局、垂直方向為充滿父 Widget 剩余空間的過程,來說明**Flutter Inspector 的具體用法**。
為了確認 Column 在垂直方向是充滿其父 Widget 剩余空間的,我們首先需要確定其父 Widget 在垂直方向上的另一個子 Widget,即 AppBar 的信息。我們點擊 Flutter Inspector 面板左側中的 AppBar 控件,右側對應顯示了它的具體視覺信息。
可以看到 AppBar 控件距離左邊距為 0,上邊距也為 0;寬為 375,高為 100:
:-: 
圖 11 Flutter Inspector 之 AppBar
然后,我們將 Flutter Inspector 面板左側選擇的控件更新為 Column,右側也更新了它的具體視覺信息,比如排版方向、對齊模式、渲染信息,以及它的兩個子 Widget-Text。
可以看到,Column 控件的距離左邊距為 38.5,上邊距為 0;寬為 298,高為 712:
:-: 
圖 12 Flutter Inspector 之 Columnn
通過上面的數據我們可以得出:
* Column 的右邊距 = 父 Widget 寬度(即 iPhone X 寬度 375)-Column 左邊距(即 38.5)- Column 寬(即 298)=38.5,即左右邊距相等,因此 Column 是水平方向居中的;
* Column 的高度 = 父 Widget 的高度(即 iPhone X 高度 812)- AppBar 上邊距(即 0)- AppBar 高度(即 100) - Column 上邊距(即 0)= 712.0,即 Column 在垂直方向上完全填滿了父 Widget 除去 AppBar 之后的剩余空間。
因此,Column 的布局行為是完全符合預期的。
## 總結
好了,今天的分享就到這里,我們總結一下今天的主要內容吧。
首先,我帶你學習了如何實現定制日志的輸出能力。Flutter 提供了 debugPrint 函數,這是一個可以被覆蓋的打印函數。我們可以分別定義生產環境與開發環境的日志輸出行為,來滿足開發期打日志需求的同時,保證發布期日志執行信息不會被意外打印。
然后,我與你介紹了 Android Studio 提供的 Flutter 調試功能,并通過觀察計數器工程的計數器變量為例,與你講述了具體的調試方法。
最后,我們一起學習了 Flutter 的布局調試能力,即通過 Debug Paiting 來定義輔助線,以及通過 Flutter Inspector 這種可視化手段來更為準確地診斷布局問題。
寫代碼不可避免會出現 Bug,出現時就需要 Debug(調試)。調試代碼本質上就是一個不斷收斂問題發生范圍的過程,因此排查問題的一個最基本思路,就是二分法。
所謂二分調試法,是指通過某種穩定復現的特征(比如 Crash、某個變量的值、是否出現某個現象等任何明顯的跡象),加上一個能把問題出現的范圍劃分為兩半的手段(比如斷點、assert、日志等),兩者結合反復迭代不斷將問題可能出現的范圍一分為二(比如能判斷出引發問題的代碼出現在斷點之前等)。通過二分法,我們可以快速縮小問題范圍,這樣一來調試的效率也就上去了。
## 思考題
最后,我給你留下一道思考題吧。
請將 debugPrint 在生產環境下的打印日志行為更改為寫日志文件。其中,日志文件一共 5 個(0-4),每個日志文件不能超過 2MB,但可以循環寫。如果日志文件已滿,則循環至下一個日志文件,清空后重新寫入。
- 前言
- 開篇詞
- 預習篇
- 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混合開發框架(二)?
- 結束語
- 結束語丨勿畏難,勿輕略