“為什麼我的 app 午休後打開就是白畫面?”

你在 Cursor 裡做出了一個漂亮的 Expo app。Claude Code 生成了 auth flow、dashboard 和 settings screen。你把它發出去了。使用者也下載了。然後 DM 開始出現:

  • “為什麼我午休後再打開 app 就是白畫面?”
  • “為什麼我每天早上都得重新登入?”
  • “為什麼我從 Instagram 切回來之後,資料就不見了?”

你並沒有做出一個壞掉的 app。你只是把 web app 的思維塞進了 native app 的容器裡。 瀏覽器沒有 app lifecycle,mobile app 有。當使用者按下 home 鍵時,你的 app 不會死掉,它只是睡著了。六小時後他們再點你的圖示,它又醒過來。而六小時前還成立的一切,現在很可能都不成立了。

resume 問題

當使用者隔了幾個小時再回來時,你必須做判斷。session 還有效嗎?資料還夠新嗎?大多數 Vibe-coded app 會用很臨時的方式、在每個 screen 各自處理這些事,結果就是前後不一致。一個 screen 重新 fetch,另一個還在顯示 stale data,第三個則因為預設 session 還在而直接 crash。

解法是:根據使用者上次活躍到現在經過的時間,推導出一個 resumeStatus,再讓整個 app 對它做出反應:

// src/context/app-lifecycle-provider.tsx

import { AppState } from 'react-native'

export type AppResumeStatus =
  'fresh' | 'resumed' | 'stale' | 'expired'

export function getResumeStatus(
  lastActiveAt: number,
  now: number,
  staleMs: number = 5 * 60 * 1000,       // 5 minutes
  expireMs: number = 24 * 60 * 60 * 1000  // 24 hours
): AppResumeStatus {
  const idleMs = now - lastActiveAt
  if (idleMs >= expireMs) return 'expired'
  if (idleMs >= staleMs) return 'stale'
  return lastActiveAt === 0 ? 'fresh' : 'resumed'
}

四個狀態,各自都有明確意義:

  • fresh:第一次啟動。沒有先前的 session。
  • resumed:在 5 分鐘內回來。資料大概還沒問題。
  • stale:離開超過 5 分鐘。該重新抓取關鍵資料了。
  • expired:離開超過 24 小時。把它當成一次全新啟動。

Lifecycle Provider

只訂閱一次 AppState,再把推導出的狀態暴露到整個 app:

export function AppLifecycleProvider({
  children,
}: PropsWithChildren) {
  const { storage } = useServices()
  const [state, setState] = useState({
    appState: AppState.currentState,
    lastActiveAt: 0,
    resumeStatus: 'fresh' as AppResumeStatus,
    ready: false,
  })

  useEffect(() => {
    const sub = AppState.addEventListener(
      'change',
      (nextAppState) => {
        if (nextAppState === 'active') {
          const resumeStatus = getResumeStatus(
            state.lastActiveAt,
            Date.now()
          )
          setState((s) => ({
            ...s,
            appState: nextAppState,
            resumeStatus,
          }))
        } else if (nextAppState === 'background') {
          storage.fastStorage.set('lastActiveAt', Date.now())
          setState((s) => ({
            ...s,
            appState: nextAppState,
            lastActiveAt: Date.now(),
          }))
        }
      }
    )

    storage.fastStorage
      .get<number>('lastActiveAt')
      .then((ts) => {
        setState((s) => ({
          ...s,
          lastActiveAt: ts ?? 0,
          resumeStatus: getResumeStatus(ts ?? 0, Date.now()),
          ready: true,
        }))
      })

    return sub.remove
  }, [storage])

  return (
    <LifecycleContext.Provider value={state}>
      {children}
    </LifecycleContext.Provider>
  )
}

關鍵 insight 是:auth、更新檢查、資料刷新,任何在乎 app 恢復狀態的 provider,都從這個單一 context 讀取 resumeStatus。沒有任何一個 screen 需要自己寫 AppState.addEventListener。也沒有任何 feature 需要自己猜 app 在背景待了多久。

Route guard 要同時等兩件事

你的 route guard 應該要同時等待 auth 恢復完成lifecycle 準備就緒,再決定是否 render:

// app/_layout.tsx

function RootNavigator() {
  const { session, loading } = useAuth()
  const { ready: lifecycleReady } = useAppLifecycle()

  if (loading || !lifecycleReady) return null

  return (
    <Stack>
      <Stack.Protected guard={!session}>
        <Stack.Screen name='login' />
      </Stack.Protected>
      <Stack.Protected guard={!!session}>
        <Stack.Screen name='(app)' />
      </Stack.Protected>
    </Stack>
  )
}

web 開發者不會本能想到這件事,因為瀏覽器沒有 app lifecycle。mobile 開發沒有這種奢侈。Autotomy Expo Starter Pack 已經把 lifecycle management 內建好,讓你的 Vibe-coded app 不會在使用者回來時才開始出問題。