一個「切換帳號」引爆四個 Bug

子母帳號功能很直覺:母帳號可以切換到子帳號視角,查看子帳號的健康日記、操作資料。技術上就是換一組 JWT,重新載入資料。

聽起來簡單,實際上踩了四個 GetX 狀態管理的地雷,每個都不是表面看得出來的問題。


Bug 連鎖反應全景圖

先看整體:四個 bug 環環相扣,每修一個就暴露下一個。

Mermaid Diagram

第一坑:JWT 換了,身份沒換

切換帳號的核心邏輯只做了一件事——替換 JWT:

// 切換到子帳號
await tokenService.saveToken(childJwt);
tokenService.token = childJwt;
await tokenService.saveRefreshToken(childRefreshToken);

看起來沒問題?但 App 裡所有 API 呼叫不只靠 JWT,還依賴 userIddocumentId。這兩個值存在 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() 時建立全新的實例。但 DashBoardScreenStatelessWidget,裡面用 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 仍為 trueonInit() 不再執行 → 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 永遠能收到通知。

有時候最好的修復不是修理壞掉的機制,而是繞過它。