“为什么我的应用午休后打开就是白屏?”

你在 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 不会在用户重新回来时突然出问题。