
很多剛開始接觸 Flutter 的朋友在看完一堆“Hello World”和基礎組件之后大概率都會撞上同一堵墻StatefulWidget 里那堆 initState、build、dispose 方法到底什么時候被調(diào)用為什么順序是那樣在里面到底能干什么、不能干什么說實話這些知識點單個拎出來都不難但組合在一起就成了很多人對狀態(tài)管理理解深淺的分水嶺。我最初寫 Flutter 時就因為搞不清 build 和 setState 的真正關系把一個小小的計數(shù)器頁面寫出了沒必要的性能損耗后來翻文檔、看調(diào)試日志才把這套機制徹底理順。這篇文章就圍繞 Widget 的一生把 initState、build、dispose 這幾個關鍵節(jié)點的調(diào)用時機、底層原因、實戰(zhàn)姿勢和容易踩的坑一次性講透。適合那些已經(jīng)知道怎么用 StatefulWidget但還想把“為什么”搞清楚的同學。1. Widget 是“圖紙”不是“房子”為什么 Flutter 要給 StatefulWidget 設計生命周期1.1 Flutter 里三棵樹的“三角關系”要聊生命周期得先回到一個最容易被忽略的基礎概念Flutter 的界面并不是“畫”出來的而是“配置”出來的。每當你寫一個 Widget它并不是屏幕上的真實像素而是一份描述界面的數(shù)據(jù)這東西更像建筑圖紙。真正負責蓋房子、貼瓷磚、刷墻的是 Element 和 RenderObject。Widget輕量、不可變、可以被快速創(chuàng)建和銷毀它只負責描述“我想要什么樣的界面”。ElementWidget 在樹中的實例化節(jié)點維護著運行時的狀態(tài)負責把 Widget 配置同步到 RenderObject。RenderObject真正執(zhí)行布局和繪制的那一層。這就是 Flutter 里著名的三棵樹。StatelessWidget 因為本身沒有需要跨幀保存的狀態(tài)所以它的 Element 相對簡單生命周期也就幾句話講完。但對 StatefulWidget 來說State 對象需要存活在 Element 上并且跟隨界面狀態(tài)的變化不斷更新那就必須有一套明確規(guī)定好的“出生、成長、死亡”流程這就是你在文檔里看到的那幾個方法存在的根本原因。1.2 State 的“戶口”落在 Element 上很多初學者會以為 setState 是“刷新 Widget”其實嚴格來說setState 是通知框架“State 里存儲的數(shù)據(jù)發(fā)生了變化需要重新執(zhí)行 build 來生成一份新的配置”。這份配置依然會被交給 Element由 Element 去比對、復用舊節(jié)點最小化更新底層渲染。理解了這一點你就能明白為什么 initState 和 dispose 在整個生命周期里只調(diào)用一次而 build 可能被調(diào)用無數(shù)次。initState 是給 State 對象上戶口這意味著每個 State 實例只有一次初始化機會dispose 是注銷戶口意味著 State 實例即將被銷毀。而 build 是每次“重新生成配置”的執(zhí)行入口只要配置可能發(fā)生了變化它就會被再次調(diào)用。1.3 生命周期方法不是“回調(diào)地獄”而是狀態(tài)管理的基石Flutter 之所以把這幾個方法暴露給開發(fā)者本質(zhì)上是在告訴你這是你管理狀態(tài)的安全邊界。在 initState 里框架還沒完成樹的構(gòu)建所以你不能依賴某些上下文信息在 dispose 里框架即將拆除 Element所以你必須把手上的資源交回去。只要按照這套邊界的約束去使用State 的生命周期就是可靠的如果無視邊界亂操作各種內(nèi)存泄漏、異常崩潰就會找上門來。2. initState初始化邏輯的起點也是最容易踩坑的入口2.1 調(diào)用時機State 對象被創(chuàng)建之后、掛載到樹上之前很多人背過 initState 的定義當 State 對象被插入到渲染樹時調(diào)用。但這句話還可以拆得更細一點它是在 State 對象創(chuàng)建完成之后、Element 第一次執(zhí)行 build 之前調(diào)用的。也就是說initState 的執(zhí)行早于第一次 build而且在整個 State 的生命周期里只執(zhí)行一次。有個非常直觀的驗證方式在 initState 里打一個 debugPrint然后在 build 里也打一個 debugPrint你會在控制臺看到 initState 永遠先于 build 出現(xiàn)。之后再怎么觸發(fā)刷新initState 都不會再次打印build 倒是會頻繁出現(xiàn)。2.2 initState 里應該做什么我一般按這三件事來檢查經(jīng)歷過幾個項目之后我給初始化邏輯總結(jié)了一張清單每次寫完 initState 都會對著過一遍初始化 State 自己持有的字段。凡是需要在生命周期內(nèi)長期保存的數(shù)據(jù)比如計數(shù)器、開關狀態(tài)、列表數(shù)據(jù)容器都可以在這里賦初值。準備好需要被釋放的資源對象。比如創(chuàng)建 AnimationController、TextEditingController、FocusNode、ScrollController。這些對象的特點是創(chuàng)建時需要持有狀態(tài)銷毀時也需要顯式釋放所以非常適合在 initState 里創(chuàng)建、在 dispose 里釋放。發(fā)起一些只需要做一次的數(shù)據(jù)準備操作。例如從本地數(shù)據(jù)庫讀取初始配置、訂閱一個流式數(shù)據(jù)源。但要注意這里只能“發(fā)起”不能“阻塞”更不要直接去等結(jié)果。為什么要把“創(chuàng)建可釋放資源”這一項單獨拿出來因為這是最容易出問題的場景。比如動畫控制器如果不在 initState 里創(chuàng)建而在 build 里每次重建那你需要管理的生命周期就亂套了如果創(chuàng)建了但忘了釋放頁面關閉后動畫 ticker 還在跑就是一個實打?qū)嵉膬?nèi)存泄漏。2.3 initState 里的三個“不能”initState 的坑主要集中在這三個方面我把它們當成鐵律記著不能在這里調(diào)用 setState。因為第一次 build 還沒執(zhí)行State 還沒有被“渲染”到樹上所謂刷新根本無從談起。如果你有初始值要設置直接改字段就行。不能在這里直接讀取 InheritedWidget 提供的數(shù)據(jù)。原因是 didChangeDependencies 還沒有被調(diào)用上下文依賴還沒有建立。想拿 InheritedWidget 的值去 didChangeDependencies 里拿或者放到 build 里取。不能在這里同步執(zhí)行耗時操作。比如從磁盤同步讀取一個大文件、做復雜的加密計算這些都會卡住 UI 首幀渲染。耗時的事放到異步里做結(jié)果通過 setState 回來就好。其中關于 InheritedWidget 的這條限制尤其容易踩。很多人遇到“在 initState 里拿不到主題、拿不到本地化字符串”的報錯然后一臉茫然。其實只要記住本來就不是在這里取的moveOn。2.4 一次真實的初始化示例下面是一個我當時做項目時寫過的代碼結(jié)構(gòu)功能是進入頁面后啟動一個每秒計數(shù)的定時器同時創(chuàng)建兩個控制器一個是文本輸入框的一個是動畫用的class _TimerPageState extends StateTimerPage with SingleTickerProviderStateMixin { int _seconds 0; late Timer _timer; late TextEditingController _textController; late AnimationController _animationController; override void initState() { super.initState(); _textController TextEditingController(); _animationController AnimationController( vsync: this, duration: const Duration(milliseconds: 300), ); _timer Timer.periodic(const Duration(seconds: 1), (timer) { setState(() { _seconds; }); }); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(生命周期演示)), body: Column( children: [ Text(計時$_seconds 秒), TextField(controller: _textController), ], ), ); } override void dispose() { _timer.cancel(); _textController.dispose(); _animationController.dispose(); super.dispose(); } }這段代碼有幾個細節(jié)值得注意。第一late關鍵字是為了延遲初始化因為這三個字段在 initState 里才真正被賦值。第二withSingleTickerProviderStateMixin是在告訴框架這個 State 可以作為一個 vsync 信號源用于 AnimationController 的 ticker 管理。如果你用了多個動畫控制器就得換成TickerProviderStateMixin。第三dispose 里三個資源的釋放順序其實也有講究先取消定時器再釋放可能會回調(diào)的控制器最后釋放動畫控制器。雖然 Flutter 對釋放順序沒有強制規(guī)定但我習慣把“更可能觸發(fā)異步回調(diào)”的資源放在前面釋放把純本地的資源放在最后釋放。3. buildFlutter 里被誤解最深的“畫圖”方法3.1 build 不是畫圖而是生成新配置很多剛從 Android 或者 Web 轉(zhuǎn)過來的同學會下意識地把 build 和“繪制”“渲染”畫等號覺得只要刷新界面系統(tǒng)就要重新畫一幀。這個理解偏差會直接導致性能上的一堆壞習慣。實際情況是build 方法返回的是一個 Widget 樹這套 Widget 樹是“新配置”。Flutter 拿到新配置之后會交給 Element 去做 diff看舊的節(jié)點和新的節(jié)點能不能復用。如果能復用只需要更新差異部分底層渲染對象并不會重畫如果節(jié)點類型變了才會銷毀舊的 Element 并創(chuàng)建新的。所以build 的調(diào)用頻率遠沒有“每幀重畫”那么恐怖。Flutter 只在某些特定時機才執(zhí)行 buildState 首次掛載到樹上。setState 被調(diào)用之后。依賴的 InheritedWidget 數(shù)據(jù)發(fā)生了變化。父 Widget 重建并且子 Widget 的配置發(fā)生了變化。其中最后一條想多提一句。父 Widget build 的時候會連帶調(diào)用子 Widget 的 build這是 Flutter 的默認行為也是性能問題的高發(fā)區(qū)。如果父 Widget 只是布局信息變了子 Widget 完全不必重新生成配置那你還得配合const構(gòu)造、或者用shouldRebuild之類的優(yōu)化手段去阻止無意義的 rebuild。3.2 build 方法里的大忌清單為了避免把 build 寫成性能殺手我給自己定了一組禁令不要在 build 里發(fā)起網(wǎng)絡請求。網(wǎng)絡請求的響應是異步的結(jié)果回來之后還得 setState而 setState 又可能再次觸發(fā) build。如果在 build 里直接發(fā)請求就等于每次構(gòu)建都發(fā)一次頁面還沒人等數(shù)據(jù)刷新就先把服務器轟炸了。不要在 build 里做耗時計算。包括大量列表項的排序過濾、大字符串拼接、圖片解碼。這些計算應該提前算好把結(jié)果存到 State 的字段里build 只負責讀取展示。不要在 build 里修改 State 字段。這一點很容易被忽視。比如在 build 里做_count然后 lay out 時依賴_count就可能出現(xiàn)渲染結(jié)果不確定的問題嚴重的還會陷入無限重建循環(huán)。不要無腦在 build 里創(chuàng)建昂貴的對象。像重復創(chuàng)建同一個TextStyle其實開銷不大但如果你在 build 里創(chuàng)建AnimationController這類帶生命周期對象那問題就大了。我把這幾條禁令和之前提到的 initState 結(jié)合著用了所有“需要保持長期存活”的對象一律在 initState 里創(chuàng)建所有“臨時計算結(jié)果”盡量提前算好build 里只做 Widget 配置的組裝。3.3 一個 build 性能問題的調(diào)試思路曾經(jīng)有個頁面列表滾動的時候明顯卡頓。我用性能分析工具看了眼發(fā)現(xiàn) build 被調(diào)用的頻率沒有異常但 build 方法內(nèi)部每次都把一個幾百長度的 JSON 字符串jsonDecode了一遍用來生成 ListView 的數(shù)據(jù)。問題就在這里明明數(shù)據(jù)在 initState 里拿過一次卻因為圖省事把它塞到了 build 里。后來我把 JSON 解析挪到 initState 里解析結(jié)果存進 State 字段build 里只做_listData.map(...)組裝 Widget 列表卡頓立刻消失。這不是 Flutter 的坑而是對 build 機制理解不到位造成的自傷。每次想復用時我都拿這個例子提醒自己。3.4 build 與 const 優(yōu)化的關系你會在很多 Flutter 代碼里看到 const Widget比如const Text(你好)。const 在這里的意思是這個 Widget 配置在編譯期就是常量永遠不會變化。這樣一來Element 比對的時候可以直接復用節(jié)點連新配置都不需要生成。養(yǎng)成隨手加 const 的習慣可以在很大程度降低 build 方法的消耗這在大型頁面里的收益非常明顯。4. dispose收尾工序里漏掉的每一處最后都會變成泄漏4.1 什么時候觸發(fā) disposeState 要從樹上被移走了dispose 是 State 生命周期里的最后一站代表這個 State 對象即將被永久銷毀之后不會再有機會執(zhí)行任何代碼。最常見的觸發(fā)場景是頁面被 pop 出棧、列表里的某個子項在條件渲染中被移除、或者整個頁面被系統(tǒng)回收。這里有個容易搞錯的點deactivate 和 dispose 不一樣。deactivate 是在 State 被暫時從樹上移除、但還有可能被重新插入時調(diào)用比如列表項在 key 調(diào)整時移動位置。dispose 則意味著徹底結(jié)束。所以如果你在處理的是“臨時離開”的場景應該考慮的是 deactivate 的邏輯而不是把資源在 deactivate 里就全釋放掉。4.2 dispose 必須釋放的東西我列了個表就我自己的項目經(jīng)驗來看下面這些資源如果在 dispose 里沒處理干凈后續(xù)大概率出問題資源類型典型對象不釋放的后果定時器Timer、Timer.periodic頁面銷毀后仍觸發(fā)回調(diào)setState 報錯或做無用功動畫控制器AnimationControllerTicker 持續(xù)運行內(nèi)存泄漏甚至導致系統(tǒng)丟幀文本控制器TextEditingController輸入框銷毀后監(jiān)聽器仍然存活狀態(tài)殘留滾動控制器ScrollController列表移除后監(jiān)聽還在內(nèi)存泄漏焦點節(jié)點FocusNode焦點狀態(tài)無法回收數(shù)據(jù)流訂閱StreamSubscription后臺持續(xù)收到數(shù)據(jù)無法取消監(jiān)聽自定義監(jiān)聽ChangeNotifier 的 addListener頁面銷毀后仍然收到通知這張表我建議直接保存下來。每寫一個帶資源的 State把表拿出來對照一遍你就能避免 90% 以上的生命周期泄漏問題。4.3 mounted 到底是什么為什么要用它dispose 做完之后State 對象并沒有馬上被垃圾回收它可能還會在異步代碼里被引用。如果你在異步回調(diào)里寫了setState而此時 State 已經(jīng)被 dispose 了Flutter 會直接拋出一個異常setState() called after dispose()。這時候就需要mounted這個屬性派上用場了。mounted 表示當前 State 是否還附著在 Element 樹上。正確的姿勢是在異步回調(diào)里先判斷 mounted再決定要不要 setState_timer Timer.periodic(const Duration(seconds: 1), (timer) { if (mounted) { setState(() { _seconds; }); } });看起來就是多了一個判斷但在頁面銷毀后它可以幫你避免大量無意義的異常。同樣在 build 里拿 context 做異步操作時回調(diào)里也要加 mounted 保護道理是一樣的。4.4 dispose 里訪問 context 的隱患dispose 階段State 的 context 已經(jīng)從樹上拆下來了此時如果你還嘗試用這個 context 去做 Navigator 跳轉(zhuǎn)、顯示 SnackBar都可能觸發(fā)異常。有個常見場景用戶點返回鍵退出登錄頁登錄頁的 dispose 里判斷“如果登錄成功了就跳轉(zhuǎn)主頁面”這明顯是設計錯誤。正確做法是在頁面切走之前用能用的 context 完成必要操作dispose 只負責資源的收尾。5. 串起全過程一個帶定時器頁面的完整生命周期時間線5.1 從路線入棧開始一步步跟蹤 State 的運行軌跡理論說了這么多不如走一遍完整的執(zhí)行流程。假設你有一個頁面打開時有計時器中間有文本框用戶能點擊按鈕加計數(shù)然后關閉頁面。我們從頁面被 push 進導航棧的那一刻開始跟蹤。第一步Navigator 構(gòu)造這個頁面的 Widget 樹。Widget 本身被創(chuàng)建State 對象也被創(chuàng)建但此時還沒有上樹。緊接著 Element 掛載State 被插入到 Element 上此時 initState 被調(diào)用。在這個例子里我們創(chuàng)建了 Timer、TextEditingController、AnimationController并初始化_seconds 0。第二步initState 返回后框架調(diào)用 didChangeDependencies。如果有對 InheritedWidget 的依賴這是第一次能安全獲取依賴數(shù)據(jù)的地方。之后框架馬上調(diào)用 build生成第一幀的 Widget 配置。這一次 build 里我們組裝了包含計時文本、文本框、按鈕的界面。第三步用戶點了一下按鈕調(diào)用 setState??蚣馨l(fā)現(xiàn)這個 State 需要重建于是再次調(diào)用 build生成新的配置。舊的 Widget 配置被丟棄Element 更新差異部分界面上計數(shù)數(shù)字變化。第四步計時器每秒觸發(fā)一次每次都執(zhí)行同樣的流程setState - build - 局部更新。這一幀一幀的構(gòu)建只要頁面還在就會持續(xù)進行。第五步用戶按了返回鍵導航棧把頁面彈出。此時 State 從樹上被移除先觸發(fā) deactivate。如果這個 State 之后沒有通過某種機制被重新插入緊跟著就是 dispose。我們在 dispose 里取消定時器、銷毀所有控制器State 生命周期正式結(jié)束。5.2 為什么我推薦用日志驗證生命周期順序光靠看文章還是不夠我強烈建議你自己動手驗證一次。在 initState、didChangeDependencies、build、didUpdateWidget、deactivate、dispose 里各打一個 debugPrint然后做各種操作觀察日志順序。這個方法花不了幾分鐘卻能讓這套機制像肌肉記憶一樣長在腦子里。有一次我就是靠日志發(fā)現(xiàn)了一個詭異問題頁面明明還在屏幕上dispose 卻已經(jīng)被調(diào)用了。一查原來是父組件在某種布局條件下把子頁面從樹里刪掉了但從導航角度看頁面還在棧頂所以看起來“頁面沒關State 沒了”。這類問題在生命周期混亂的史前項目里特別常見先別急著噴框架日志會告訴你真相。5.3 didUpdateWidget 和 didChangeDependencies 的補充位置雖然這篇文章重點在 initState、build、dispose但完整時間線里另外兩個方法也應該知道它們的位置。didUpdateWidget 在父 Widget 重建導致子 Widget 配置變化時調(diào)用如果你需要對比新舊 widget 配置差異來執(zhí)行額外邏輯就在這里寫。didChangeDependencies 則是在 initState 之后、以及依賴的 InheritedWidget 變化時被調(diào)用。記住一句話initState 和 dispose 是出生和死亡build 是常駐循環(huán)而 didUpdateWidget 和 didChangeDependencies 是圍繞“依賴變化”的前后呼應。6. 生命周期相關的常見崩潰、卡頓與內(nèi)存泄漏排查清單6.1 高頻問題對照表按癥狀定位根因我根據(jù)自己的踩坑經(jīng)歷和帶新人時的經(jīng)驗整理了一張對照表。遇到生命周期相關的問題按表排查比重新翻所有代碼快得多。癥狀描述可能原因處理方法setState() called after dispose()異步回調(diào)未判斷 mounted在回調(diào)里先檢查 mounted再 setState頁面關閉后定時器還在跑Timer 沒在 dispose 里 cancel在 dispose 里逐一定時器 cancel動畫結(jié)束后報 Ticker 異常AnimationController 未 dispose釋放所有 controller確認 mixin 類型輸入框內(nèi)容丟失但頁面內(nèi)存暴漲TextEditingController 未釋放在 dispose 里銷毀 controller列表滾動后頁面卡頓build 里有耗時計算或?qū)ο髣?chuàng)建把計算提到 initStatebuild 只組裝initState 里拿不到主題或本地化試圖在 initState 讀取 InheritedWidget移到 didChangeDependencies 或 build頁面關閉后控制臺不斷有數(shù)據(jù)流日志StreamSubscription 未取消在 dispose 里 cancel 所有訂閱Widget 和 State 對不上界面錯亂列表項缺少 keyState 被錯誤復用給列表項加上唯一 key這張表治標但更重要的是治本也就是養(yǎng)成“創(chuàng)建資源和釋放資源成對出現(xiàn)”的潛意識。每次在 initState 里 new 出來一個對象順手在 dispose 里補上對應的 dispose 調(diào)用寫完代碼自查一遍比事后排查效率高得多。6.2 Flutter DevTools 的檢查方法如果項目已經(jīng)出現(xiàn)了疑似泄漏別靠肉眼看代碼硬猜。建議打開 Flutter DevTools 的 Memory 頁面利用里面的 Heap Snapshot 功能查看頁面銷毀后是否還有對應對象的實例持有。凡是生命周期沒收干凈的資源基本上都會在這里現(xiàn)出原形。更簡單粗暴的辦法是你在關鍵生命周期方法里加日志然后反復打開、關閉同一個頁面觀察日志是否每次都成對出現(xiàn)。正常情況應該是一對一的initState 一次對應 dispose 一次中間 build 的次數(shù)隨意。如果 initState 出現(xiàn)兩次而 dispose 只有一次那多半就是 State 被重新創(chuàng)建而沒有銷毀重點去找那些沒有 key 的列表項或者被錯誤緩存的頁面。6.3 我踩過的最隱蔽的一個坑Timer 回調(diào)和 BuildContext最后分享一個我自己翻車過的問題。某個頁面的定時器在每次回調(diào)時都要用 State 里的 context 去更新一個進度條。頁面還在的時候一切正常但用戶一旦快速退出頁面Timer 還沒來得及 cancel回調(diào)先一步觸發(fā)了 setState然后就崩了。我當時第一反應是加 mounted 判斷加了之后確實不崩了但定時器還沒取消等于每秒鐘都在做一個毫無意義的空回調(diào)白白耗電。那個坑給我的教訓是mounted 判斷只能保證“不報錯”不能替代“資源釋放”。真正該做的是在 dispose 里及時 cancel而不是依賴 mounted 去兜底。后來我再寫定時器相關的邏輯都會保持一個習慣dispose 里先取消一切異步源再釋放所有 controller最后調(diào)用 super.dispose。最后再分享一個小技巧如果你跟我一樣經(jīng)常在幾個頁面之間來回復制初始化代碼可以考慮給自己定一個“生命周期三件套”檢查習慣寫完 initState接著寫 dispose再看 build 里有沒有不該出現(xiàn)的東西。這不是什么高深的工程規(guī)范只是防呆措施。我在實際項目里就是靠這個習慣把排查生命周期問題的時間縮短了至少一半。等你把這個節(jié)奏融入到肌肉記憶里再看那些“偶發(fā)崩潰”“頁面卡頓”“內(nèi)存暴漲”你會發(fā)現(xiàn)自己已經(jīng)能第一時間猜到是哪類原因了。