App 离线缓存
知与行 / iOS App 开发复盘 ·
网络差时时间格/计划/知主页/聊天空白或没数据。根因:TTL 与「缓存作废」语义混淆,MMKV 过期即删无兜底。
格式参考:docs/开发记录博客/agents.md(问题背景 → 问题列表 → 根因链 → 方案 → 采纳 → 用例 → 小结)。
问题背景
真机日志出现 [XingTimeGrid] 静默刷新失败: timeout,用户反馈网络不好时 时间格 / 计划 / 知主页 / 聊天 等页面 空白或像「没数据」,体验差。
初步误判是「完全没有本地存储」;排查后发现:部分页面有内存缓存,但持久化不完整、TTL 语义错误、登出未清用户数据,冷启动 / 杀进程 / MMKV 过期后网络一失败就什么都看不到。
问题列表
问题一:TTL 与「缓存作废」混为一谈,过期即删
根因分析:
- why1:MMKV
get()在超过expiresIn时 直接 delete,离线读不到任何兜底 - why2:
FRESH_TTL(10min)被同时用作「需刷新」和「不可读」两种语义 - why3:时间格 MMKV key
user_{id}_time_home_today与内存 Map 双写,但 10min 后 L2 也被清,杀进程后只剩网络一条路
方案:
- 短期方案:把 MMKV
expiresIn拉长到 24h,业务层用dateKey跨天作废 - 优点:改动小
- 缺点:仍无法区分 fresh / stale,其它模块要对齐各自规则
- 长远方案:Stale-While-Revalidate——
FRESH_TTL只触发后台静默刷新;新增peekStale()过期仍可读;业务边界(今日dateKey、感悟yearMonth、userId)单独作废 - 优点:语义清晰,全 App 可复用
- 缺点:需统一各页加载顺序(先 L1 → L2 → 网络)
采纳方案:长远方案。扩展 [mmkvCache.ts](../zhiyuxing-app/src/lib/mmkvCache.ts) 的 peekStale / setPersistent;新建 [app-cache/policy.ts](../zhiyuxing-app/src/lib/app-cache/policy.ts) 的 readWithFallback / FRESH_TTL_MS。
补充用例:
- 有网打开行·时间格 → 杀进程 → 断网 → 冷启动仍显示今日计划
- 静默刷新超时:界面 不 blank,顶部 hint「网络异常,显示离线数据」
- 跨天:
dateKey不匹配的旧缓存不展示
问题二:各 Tab 缓存碎片化,计划页 / 知主页 / 聊天无 L2
根因分析:
- why1:行·计划 [
xing-plans-cache.ts](../zhiyuxing-app/src/lib/xing-plans-cache.ts) 仅进程内 Map,杀进程即失 - why2:知·主页 [
zhi-insights-cache.ts](../zhiyuxing-app/src/lib/zhi-insights-cache.ts) 同理;loadMore分页 不写回 cache - why3:Agent Chat 无本地消息;
chatDbHydrated=false时全屏「同步对话…」,离线无法读历史
方案:
- 短期方案:各页各自补 AsyncStorage
- 优点:局部快
- 缺点:key 不统一、登出难清、与 MMKV 双轨
- 长远方案:统一 per-user key([
app-cache/keys.ts](../zhiyuxing-app/src/lib/app-cache/keys.ts))+ 域模块(xing-time-home-cache/xing-plans-cache/zhi-insights-cache/agent-chat-local-cache等)L1 + L2 双写 - 优点:一套 SWR 模式覆盖行 / 知 / 聊天
- 缺点:改动面大,需分批回归
采纳方案:长远方案。重点落地:
| 模块 | L2 | 备注 | |------|-----|------| | 行·时间格 | MMKV | 迁入 xing-time-home-cache.ts | | 行·计划 | MMKV | 扩展 xing-plans-cache.ts | | 行·命运 | MMKV | 统一 destinyDataKey,替代散落 AsyncStorage | | 知·主页 | MMKV | pinned + 按月 normal;loadMore 写回 | | 知·自我/报告 | MMKV | zhi-reports-cache 列表 + 详情 | | 知·愿景 | MMKV | zhi-wishes-cache(含 wishIds);zhi-vision-cache 详情 | | Agent Chat | MMKV | 会话列表 + 按 session 消息;有本地数据时不阻塞 UI |
删除未使用的 [usePlans.ts](../zhiyuxing-app/src/hooks/usePlans.ts)、[cache.ts](../zhiyuxing-app/src/lib/cache.ts),避免第三套缓存 API。
补充用例:
- 断网切 Tab / 子页不空白
- 知主页 loadMore 后杀进程,分页数据仍在
- Chat 离线可浏览已缓存会话消息;发送仍依赖网络并有明确失败提示
问题三:网络失败 UX 不一致——有缓存仍像「没数据」
根因分析:
- why1:时间格 无缓存 + 首次加载失败 时
plans保持[],无 error、无 hint - why2:各页 catch 只
console.error,用户无感知;部分页(自我报告)网络失败显示 假空态 - why3:终端
[XingTimeGrid] 静默刷新失败被误解为「没缓存」——实际是 后台刷新失败,前台应仍展示 stale
方案:
- 短期方案:每页单独加 Toast
- 优点:快
- 缺点:文案与时机不统一
- 长远方案:统一状态机 + [
TransientHintContext](../zhiyuxing-app/src/contexts/TransientHintContext.tsx) + [offline-hint.ts](../zhiyuxing-app/src/lib/app-cache/offline-hint.ts) - 有 stale:保留数据 + 轻提示
- 无 stale:loading / error + 重试
- 优点:体验一致
- 缺点:需在多个页面接入
采纳方案:长远方案。行 / 知 / 命运 / 愿景 / Chat 等页在 静默刷新失败且已有 stale 数据 时调用 showOfflineDataHint。
补充用例:
- 有缓存时刷新失败:数据不消失 + 顶部 hint
- 无缓存时加载失败:错误文案或重试(计划页等)
- 用户不再把「静默刷新失败」日志等同于「界面应该空白」
问题四:换账号可能看到上一用户缓存
根因分析:
- why1:
signOut/deleteAccount清 Widget、凭据,但 不清 MMKV / 内存 Map - why2:知·愿景 growth 曾用全局 key
wishes(非 userId) - why3:各模块 invalidate 分散,无统一入口
方案:
- 短期方案:登出时手动清已知 key 列表
- 优点:快
- 缺点:新增 cache 易漏
- 长远方案:[
clearUserAppCaches(userId)](../zhiyuxing-app/src/lib/app-cache/clear-user-caches.ts)——按user_{userId}_前缀清 MMKV + 各模块内存 Map + 遗留wishes;[AuthContext](../zhiyuxing-app/src/contexts/AuthContext.tsx) 登出/删号接入 - 优点:一处维护
- 缺点:新增域模块须注册 invalidate
采纳方案:长远方案。
补充用例:
- 用户 A 登出 → 用户 B 登录 → 不见 A 的计划 / 感悟 / 愿望标题
- 删号后本地无残留会话消息
问题五:关联页未触发缓存失效 / 刷新
根因分析:
- why1:[
record-insight.tsx](../zhiyuxing-app/app/record-insight.tsx) 保存后只 sync Widget,未markZhiInsightsRefreshAfterEdit - why2:[
new-plan.tsx](../zhiyuxing-app/app/new-plan.tsx) 编辑成功 invalidate 计划 cache,但 未 invalidate 时间格 cache - why3:growth 与 new-plan 愿望数据 key 不一致,可能各存一份
方案:
- 短期方案:依赖 focus 时 TTL 过期再拉
- 优点:无改动的假象
- 缺点:新建感悟后列表可能短期不一致
- 长远方案:写操作后 显式 mark / invalidate;愿望统一
user_{userId}_zhi_wishes - 优点:数据一致
- 缺点:需在编辑入口补钩子
采纳方案:长远方案。record-insight 补 refresh mark;new-plan 补 invalidateTimeHomeCache。
补充用例:
- 新建感悟 → 回知主页 → 列表含新条目(或 focus 触发静默拉取)
- 编辑计划 → 时间格与计划页均更新
小结
本次问题的本质不是「零缓存」,而是 缓存层不统一、TTL 误当作废、持久化缺口、失败不可见、登出不清理。落地 App 级 Stale-While-Revalidate:L1 内存 + L2 MMKV(Web localStorage 兜底)、FRESH_TTL 只驱动刷新、peekStale 保离线可读、统一 key 与 clearUserAppCaches、失败时用 TransientHint 告知「离线数据」。
架构入口:
- 策略:[
app-cache/policy.ts](../zhiyuxing-app/src/lib/app-cache/policy.ts) - Key:[
app-cache/keys.ts](../zhiyuxing-app/src/lib/app-cache/keys.ts) - 清理:[
app-cache/clear-user-caches.ts](../zhiyuxing-app/src/lib/app-cache/clear-user-caches.ts) - 单测:[
app-cache-policy.test.ts](../zhiyuxing-app/src/lib/__tests__/app-cache-policy.test.ts)
未纳入本期:离线写队列(Chat 发送、计划完成等)、全面迁移 React Query、Android Widget 与 Native MMKV 性能完全对齐。核心教训:「刷新失败」和「没有数据」是两种状态,UX 和数据层都要分开处理。