“为什么我的应用午休后打开就是白屏?”
你在 Cursor 里做了一个漂亮的 Expo app。Claude Code 生成了 auth flow、dashboard 和 settings screen。你把它发出去了。用户也下载了。然后私信开始出现。
- “为什么我午休后再打开就是白屏?”
- “为什么我每天早上都得重新登录?”
- “我从 Instagram 切回来之后,数据怎么没了?”
你并没有做出一个坏掉的 app。你只是把 web app 的思路塞进了一个 native app 容器里。 Browser 没有 app lifecycle,mobile app 有。用户按下 home 键时,app 不会死掉,它只是睡着了。六小时后用户再点开图标,它又醒了。可六小时前还成立的那些前提,这时往往已经全变了。
resume 问题
当用户离开几个小时后再回来,你必须做判断。session 还有效吗?data 还新鲜吗?多数 Vibe-coded app 会把这些判断拆成各个 screen 自己处理,而且处理方式并不一致。一个 screen 会重新 fetch。另一个继续展示 stale data。第三个则因为默认 session 还在而直接崩掉。
解决办法是,根据用户上次活跃到现在经过的时间,推导出一个 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'
}
4 个状态,各自含义都很明确。
- fresh:首次启动,没有上一次 session。
- resumed:5 分钟内回到 app,data 大概率还没问题。
- stale:离开超过 5 分钟,该重新 fetch 关键 data 了。
- expired:离开超过 24 小时,基本应该按 fresh launch 来处理。
Lifecycle Provider
只对 AppState subscribe 一次,然后把推导出来的状态暴露给整个 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>
)
}
关键点在这里。auth、updates、data refresh 这些关心“重新回到 app”这件事的 provider,都只读这一个 context 里的 resumeStatus。不要让每个 screen 自己写一份 AppState.addEventListener。也不要让每个 feature 自己猜 app 到底在后台待了多久。
route guard 要同时等两件事
你的 route guard 应该等 auth 恢复完成 和 lifecycle ready 这两件事都完成之后,再开始渲染。
// 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 开发者之所以平时不会想到这个问题,是因为 browser 根本没有 app lifecycle。mobile 开发没有这种奢侈。Autotomy Expo Starter Pack 默认把 lifecycle 管理这层做好了,所以你的 Vibe-coded app 不会在用户重新回来时突然出问题。