一個「切換帳號」引爆四個 Bug
子母帳號功能很直覺:母帳號可以切換到子帳號視角,查看子帳號的健康日記、操作資料。技術上就是換一組 JWT,重新載入資料。
聽起來簡單,實際上踩了四個 GetX 狀態管理的地雷,每個都不是表面看得出來的問題。
Bug 連鎖反應全景圖
先看整體:四個 bug 環環相扣,每修一個就暴露下一個。
第一坑:JWT 換了,身份沒換
切換帳號的核心邏輯只做了一件事——替換 JWT:
// 切換到子帳號
await tokenService.saveToken(childJwt);
tokenService.token = childJwt;
await tokenService.saveRefreshToken(childRefreshToken);
看起來沒問題?但 App 裡所有 API 呼叫不只靠 JWT,還依賴 userId 和 documentId。這兩個值存在 SharedPreferences 裡,切換帳號時完全沒有更新。
結果:子帳號的 JWT 搭配母帳號的 userId 去呼叫 API → 後端權限不符 → 500 錯誤。日記的 GraphQL 查詢用母帳號的 documentId 過濾 → 查到的是母帳號的資料,不是子帳號的。
為什麼容易忽略? 因為 JWT 本身就攜帶使用者身份,直覺上換了 JWT 就等於換了身份。但 App 端的 REST API 和 GraphQL 查詢是用 SharedPreferences 裡的 userId / documentId 組裝 URL 和 filter,這兩套身份來源沒有同步。
第二坑:ViewModel 刪了重建,UI 卻指向屍體
帳號切換後,透過 clearUserControllers() 刪除所有 ViewModel,讓 fenix: true 機制自動重建新的實例:
// 刪除舊的 ViewModel
Get.delete<CompleteDiaryViewModel>(force: true);
Get.delete<HomeScreenFrontPageViewModel>(force: true);
// 導航到首頁,觸發重建
await Get.offAllNamed(RoutePath.homePage);
理論上 fenix: true 會在下次 Get.find() 時建立全新的實例。但 DashBoardScreen 是 StatelessWidget,裡面用 IndexedStack 管理五個頁面。問題在於:StatelessWidget 的 final 欄位在建構時就透過 Get.find() 綁定了 ViewModel 參考。
如果 Get.offAllNamed 沒有完整重建整個 widget tree,舊的 widget 仍持有已被 Get.delete 刪除的 ViewModel 參考。結果:使用者輸入資料 → API 呼叫走的是新 ViewModel → 成功寫入後端。但 UI 的 Obx 監聽的是舊 ViewModel → 畫面永遠不更新。
更隱蔽的是 clearUserControllers() 裡的雙重清除:
// 手動呼叫 onClose 後又 Get.delete(會再呼叫一次 onClose)
completeDiaryVM.onClose(); // TabController 被 dispose
Get.delete<CompleteDiaryViewModel>(force: true); // 再次 dispose → 拋錯
第二次 onClose() 嘗試 dispose 已經 dispose 過的 TabController → 拋出異常 → 被 try-catch 吞掉 → Get.delete 沒有執行 → 舊 ViewModel 殘留,_isInitialized 仍為 true → onInit() 不再執行 → Tab 永遠不初始化 → 無限轉圈圈。
修正思路:不刪除 ViewModel,改為原地重新載入資料。 這樣所有 widget 持有的參考永遠有效:
// 不再 delete + recreate,直接重新載入
final frontPageVM = Get.find<HomeScreenFrontPageViewModel>();
frontPageVM.fetchUserData();
await frontPageVM.healthDiaryList();
final diaryVM = Get.find<CompleteDiaryViewModel>();
diaryVM.updateTabs(memberLevel: memberLevel, forceUpdate: true);
第三坑:全域變數凍結在第一次 import
日記頁面的對話框函式放在獨立檔案,頂層有一個全域變數:
// health_diary_dialogs.dart
final homescreenfrontpageViewmodel = Get.find<HomeScreenFrontPageViewModel>();
Dart 的 top-level final 變數只在第一次存取時求值,之後永遠不變。帳號切換後 ViewModel 被重建,但這個全域變數仍指向舊的實例。所有透過 dialog 操作的功能——體重、體脂、狀態選擇——全部靜默失敗。
修正很簡單,改成 getter:
// 每次呼叫都取得最新的 ViewModel
HomeScreenFrontPageViewModel get _viewModel =>
Get.find<HomeScreenFrontPageViewModel>();
為什麼值得提? 因為 Get.find() 放在 StatelessWidget 的 final 欄位裡是安全的(每次 widget 建構都會重新求值),但放在 top-level 就是定時炸彈。語法一樣,行為完全不同。
第四坑:記憶體狀態 vs 持久化狀態
母帳號切換到子帳號後,母帳號的 JWT、userId 等資訊存在 AccountSwitchService 的實例變數裡。使用者滑掉 App 再打開 → Service 重建 → 所有實例變數歸零 → 母帳號控制權消失,App 直接用子帳號身份登入。
這是「記憶體狀態」和「持久化狀態」的經典問題。解法是切換時將母帳號資訊寫入 SharedPreferences,App 啟動時檢測並自動切回:
// App 啟動時(bootstrap.dart)
final accountSwitchService = Get.find<AccountSwitchService>();
await accountSwitchService.restoreAndSwitchBackIfNeeded();
設計決策: 為什麼不維持子帳號狀態,而是自動切回母帳號?因為子帳號模式是「母帳號在監控」的概念,App 被殺掉代表監控中斷,重啟後應該回到母帳號的安全狀態。
四個坑的共同根因
回頭看,這四個問題都指向同一個根因:GetX 的便利性掩蓋了狀態管理的複雜度。
| 問題 | 表象 | 根因 |
|---|---|---|
| 500 錯誤 | API 呼叫失敗 | 多重身份來源沒同步 |
| UI 不更新 | 資料存了但看不到 | widget 持有已死的 ViewModel |
| Dialog 失效 | 按鈕沒反應 | 全域變數凍結在舊參考 |
| 母帳號消失 | App 重開變子帳號 | 關鍵狀態只存在記憶體 |
Get.find() 讓依賴注入變得極其簡單,但也讓開發者忽略了一個事實:參考(reference)和實例(instance)是兩回事。當你 delete 再 recreate 一個 ViewModel,所有之前拿到的參考都指向一具屍體。
最終的解法不是修補 GetX 的 lifecycle,而是改變策略:不要刪除 ViewModel,讓它活著,只重新載入資料。這樣所有參考永遠有效,Obx 永遠能收到通知。
有時候最好的修復不是修理壞掉的機制,而是繞過它。
