App 离线缓存

知与行 / iOS App 开发复盘 ·

网络差时时间格/计划/知主页/聊天空白或没数据。根因:TTL 与「缓存作废」语义混淆,MMKV 过期即删无兜底。

by 于尘 ·

格式参考: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-planinvalidateTimeHomeCache

补充用例

  • 新建感悟 → 回知主页 → 列表含新条目(或 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 和数据层都要分开处理。

← 所有文章