觸發場景:Flutter 書籍管理 App 的掃描器 ViewModel 用建構子接收三個 service、provider 工廠手動 new 具體類別傳進去;資源清理寫在自訂的 dispose() 方法裡 疑問來源:這個 dispose() 誰呼叫?盤點後答案是沒有人——UI 透過 provider 取用 Notifier、從頭到尾沒有拿到過需要它負責釋放的物件 整理目的:記下 Notifier「生與死都歸容器管」的機制、建構端與銷毀端各自的正確掛法 本文邊界:素材是該專案 v0.31.1 的 DI 一致性重構記錄;屬架構調整、不是崩潰事故——風險是結構性的(清理掛在無人呼叫的方法上)


機制:Notifier 的建構與銷毀都不歸使用者管

Riverpod 的 Notifier 生命週期兩頭都由容器控制。建構端:provider 工廠(NotifierProvider(ViewModel.new))在容器第一次需要這個狀態時實例化 Notifier、接著呼叫 build()銷毀端:容器判定節點該回收時(scope 銷毀、autoDispose 無人監聽)觸發 dispose 流程。使用者的程式碼在這兩頭都沒有控制點——UI 拿到的是 ref.watch(provider.notifier) 的引用、它不建構也不銷毀。

在這個前提下,兩種常見寫法都是跟容器搶生命週期控制權:

  • 建構子注入依賴:工廠得手動 new 依賴傳進建構子——依賴的建構脫離 provider 圖,BookService() 直接實例化、不經過 bookServiceProvider,測試 override 摸不到它、依賴的依賴也斷鏈。
  • 手寫 dispose() 方法:方法宣告在那裡、等一個呼叫者——但 Notifier 的持有者是容器、容器只認自己的 dispose 流程,UI 沒有任何一處會呼叫這個方法。掛在裡面的 Timer 取消、StreamSubscription 釋放,等於沒掛。

遷移:兩頭都收進 build()

 1// 之前:建構子注入 + 手寫 dispose
 2class IsbnScannerViewModel extends Notifier<IsbnScannerState> {
 3  IsbnScannerViewModel(this._bookService, this._cameraService, this._validator);
 4  final BookService _bookService;
 5  // ...
 6  void dispose() { _cancelScanning(); }   // 沒有呼叫者
 7}
 8
 9final isbnScannerViewModelProvider = NotifierProvider<IsbnScannerViewModel, IsbnScannerState>(
10  () => IsbnScannerViewModel(BookService(), CameraService(), IsbnValidationService()),
11);
 1// 之後:依賴在 build() 內 ref.watch、清理在 ref.onDispose 註冊
 2class IsbnScannerViewModel extends Notifier<IsbnScannerState> {
 3  late final BookService _bookService;
 4  late final CameraService _cameraService;
 5  late final IsbnValidationService _isbnValidationService;
 6
 7  @override
 8  IsbnScannerState build() {
 9    _bookService = ref.watch(bookServiceProvider);
10    _cameraService = ref.watch(cameraServiceProvider);
11    _isbnValidationService = ref.watch(isbnValidationServiceProvider);
12    ref.onDispose(_cancelScanning);   // 容器銷毀節點時執行
13    return IsbnScannerState.initial();
14  }
15}
16
17final isbnScannerViewModelProvider = NotifierProvider<IsbnScannerViewModel, IsbnScannerState>(
18  IsbnScannerViewModel.new,          // 工廠只負責實例化、不碰依賴
19);

改完之後的責任分佈:工廠回到 ViewModel.new 一行、依賴全部經 provider 圖取得(可 override、可追蹤)、清理掛在容器一定會走的 hook 上。ref.onDispose 跟手寫方法的差別就是「誰保證執行」——前者由容器的 dispose 流程保證、後者由一個不存在的呼叫者保證。

兩個附帶的工程紀律

這次重構的執行記錄留了兩件跟主題無關、但可轉移的事:

Pre-existing 失敗要用 baseline 重跑確認。改完後跑測試、一個多語系版型測試在小螢幕溢位失敗。判定它跟本次改動無關的方式是機械的:git stash 還原變更、在 baseline 重跑同一個測試、同樣失敗——確認是既有問題、記錄待追蹤、不混進本次範圍。「看起來無關」是猜測、baseline 重跑是證據。

Worktree 的 dart analyze 要先 pub get。在 git worktree 裡跑 dart analyze、它會向上解析到主 repo 的舊版 package_config 造成假錯誤;先在 worktree 內 flutter pub get 產生本地設定檔才能得到真結果。

判讀徵兆

  • Notifier / ViewModel 類別裡有自訂的 dispose()close() 方法——先找呼叫者,找不到就是本文的形態:清理掛在無人呼叫的方法上、改掛 ref.onDispose
  • provider 工廠裡出現 new 具體類別(SomeService())傳進建構子——依賴脫離 provider 圖、測試 override 失效,改成 build()ref.watch
  • 工廠寫法不是 ViewModel.new 一行——通常代表建構子還揹著依賴
  • 改動後測試失敗、懷疑是既有問題——stash 還原跑 baseline、用同樣失敗證明、不用直覺判定

相關閱讀