“為什麼我的 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 不會在使用者回來時才開始出問題。