Flutter 缓存策略:本地缓存、陈旧数据与刷新规则——该缓存什么、何时失效、如何在导航间保持一致。

在移动应用里,缓存就是把数据的副本放在近处(内存或设备上),这样下一个屏幕能够立即渲染,而不必等待网络。那些数据可能是一组条目、用户资料或搜索结果。
麻烦在于,缓存的数据常常有些不准确。用户会很快察觉:价格没更新、徽章数量感觉卡住了,或者在刚修改后详情页还显示旧信息。调试之所以困难,是因为时机。相同的接口在手动下拉刷新后看起来没问题,但在返回导航、应用恢复或切换账号后可能就出错了。
这里存在真实的权衡。如果你总是获取最新数据,界面会显得缓慢、抖动,并且浪费电量和流量。如果你过度缓存,应用看起来很快,但用户会不再信任所见的内容。
一个简单的目标会很有帮助:让新鲜度可预测。为每个屏幕决定它允许显示的内容(新鲜、可接受的陈旧或离线),数据可以保留多长时间再刷新,以及哪些事件必须使其失效。
想象一个常见流程:用户打开一个订单,然后返回订单列表。如果列表来自缓存,它可能仍显示旧的状态。如果你每次都刷新,列表可能会闪烁并感觉慢。清晰的规则比如“立即显示缓存,后台刷新,响应到达时更新两个屏幕”会让导航间的体验保持一致。
缓存不仅仅是“存下数据”。它是数据副本加上一条规则:何时这份副本仍然有效。如果只存载荷而不存规则,你会得到两种现实:一个屏幕显示新信息,另一个显示昨天的数据。
一个实用模型是把每个缓存项放到三种状态之一:
这样的框架让 UI 可预测,因为每次看到某个状态时都能以相同方式响应。
新鲜度规则应基于你能向队友解释的信号。常见选择有基于时间的过期(比如 5 分钟)、版本变更(schema 或应用版本)、用户操作(下拉刷新、提交、删除)或服务器提示(ETag、last-updated 时间戳或显式的“清除缓存”响应)。
举例:个人资料页立即加载缓存的用户数据。如果是可用但已陈旧,它会显示缓存的名字和头像,然后静默刷新。如果用户刚编辑过个人资料,那就是必须刷新的时刻。应用应立即更新缓存,以便所有屏幕保持一致。
决定谁来管理这些规则:在大多数应用中,最佳默认是:数据层负责新鲜度和失效,UI 只是做出反应(显示缓存、显示加载、显示错误),后端在可能时给出提示。这能防止每个屏幕各自发明规则。
良好缓存从一个问题开始:如果这些数据有点旧,会伤害用户吗?如果答案是“应该没问题”,通常就是本地缓存的好候选。
被频繁读取且变化缓慢的数据通常值得缓存:用户经常滚动的 feed 和列表、目录类内容(产品、文章、模板)以及参考数据如分类或国家。设置与偏好也适合这里,基础的个人信息如名字和头像 URL 也一样。
有风险的是与金钱或时间相关的任何东西。余额、支付状态、库存可用性、预约时段、配送 ETA 和“最后在线”会在陈旧时造成实际问题。可以为速度缓存这些数据,但把缓存当作临时占位符,并在决策点强制刷新(例如,在确认订单前)。
派生的 UI 状态是另一类。保存所选标签、过滤器、搜索查询、排序或滚动位置可以让导航更流畅,但也会在旧选择意外恢复时让用户困惑。一个简单规则:在用户停留在当前流程时把 UI 状态保存在内存中,但当用户有意“重新开始”(比如回到首页)时重置它。
避免缓存会带来安全或隐私风险的数据:秘密(密码、API 密钥)、一次性令牌(OTP、重置密码令牌)以及敏感个人数据,除非你确实需要离线访问。永远不要缓存完整的卡信息或任何增加欺诈风险的数据。
在购物应用中,缓存产品列表能带来很大收益。但结算页应该在购买前总是刷新总价和库存可用性。
大多数 Flutter 应用最终需要本地缓存,这样屏幕加载更快,不会在网络唤醒时一闪而空。关键是决定缓存存放在哪里,因为每一层在速度、容量限制和清理行为上不同。
内存缓存最快。适合刚获取并将在应用打开期间重复使用的数据,比如当前用户资料、最近搜索结果或用户刚查看的产品。代价也很明显:当应用被杀死时它会消失,因此无法帮助冷启动或离线使用。
磁盘键值存储适合你希望跨重启保留的小项。考虑偏好和简单的二进制块:功能开关、“上次选择的标签”和很少变动的小 JSON 响应。把它保持有意为小项。如果你把大列表塞进键值存储,更新会变得混乱,膨胀也很容易发生。
当数据较大、有结构或需要离线能力时,本地数据库是最佳选择。它也有助于需要查询的场景(“所有未读消息”、“购物车中的商品”、“上个月的订单”),而不是加载一个巨大的 blob 然后在内存中过滤。
为了让缓存可预测,为每种数据选择一个主存储,避免把同一数据集放在三处。
一个快速经验法:
还要为大小做计划。决定什么叫“太大”,数据保留多久,以及如何清理。例如:限制缓存搜索结果为最近 20 条查询,定期删除 30 天前的记录,防止缓存悄然无限增长。
刷新规则应足够简单,以便能用一句话说明每个屏幕的行为。这正是合理缓存的好处:用户得到快速的屏幕,应用仍然值得信赖。
最简单的规则是 TTL(生存时间)。把数据与时间戳一起存储,并把它视作在例如 5 分钟内新鲜。之后它变为陈旧。TTL 适合“锦上添花”的数据,如 feed、分类或推荐。
一个有用的改进是把 TTL 分为软 TTL 和硬 TTL。
在软 TTL 下,你立即显示缓存数据,然后后台刷新并在变更时更新 UI。在硬 TTL 下,一旦过期就不再显示旧数据。你要么用加载器阻塞,要么显示“离线/请重试”状态。硬 TTL 适合错误比缓慢更糟糕的场景,比如余额、订单状态或权限。
如果后端支持,优先使用“只有在变更时才刷新”的方式,利用 ETag、updatedAt 或版本字段。应用可以询问“有没有变化?”,如果没有则跳过下载完整载荷。
对许多屏幕来说,用户友好的默认是 stale-while-revalidate:先显示,静默刷新,仅在结果不同的时候重绘。它带来了速度而减少随机闪烁。
每个屏幕的新鲜度通常会像这样:
基于出错代价而不是仅仅请求代价来选择规则。
缓存失效始于一个问题:哪个事件会让缓存变得不如重新请求的代价值得信任?如果你选择一小套触发器并坚持使用,行为就会可预测,UI 也会显得稳定。
现实应用中最重要的触发器:
举例:用户编辑了头像然后返回。如果你只依赖基于时间的刷新,之前的屏幕可能会继续显示旧图片直到下一次抓取。相反,应把编辑视为触发器:立即更新缓存的 profile 对象并用新时间戳标记为新鲜。
保持失效规则简洁明确。如果你无法指出确切事件去使缓存失效,你会么么刷新太频繁(界面慢、闪烁),要么刷新不够(页面陈旧)。
先列出关键屏幕和每个屏幕需要的数据。不要以端点为思路,要以用户可见对象为中心:个人资料、购物车、订单列表、目录条目、未读计数。
接着,为每种数据选择一个事实来源。在 Flutter 中,这通常是一个 repository,隐藏数据来自何处(内存、磁盘或网络)。屏幕不应决定何时访问网络。它们应向 repository 请求数据并响应返回的状态。
一个实用流程:
savedAt 时间戳、schema/app 版本和 ownerUserId。元数据使规则可执行。如果 ownerUserId 变化(登出/登录),你可以立即丢弃或忽略旧的缓存行,而不是短暂显示前一个用户的数据。
对于 UI 行为,事先决定“陈旧”意味着什么。一个常见规则:立即展示陈旧数据以避免空白屏,触发后台刷新,到达后更新界面。如果刷新失败,则保留陈旧数据并显示一个小而清晰的错误。
然后用一些无聊但重要的测试锁定规则:
ownerUserId 关联的缓存被清除或隔离。这就是“我们有缓存”与“我们的应用每次表现一致”之间的差别。
没有什么比在列表页看到一个值、点进详情修改它、再返回却看到旧值更能破坏信任。导航一致性来自于让每个屏幕都从同一来源读取。
一个稳健规则是:只取一次,存一次,多处渲染。屏幕不应独立调用同一端点然后各自保留私有副本。把缓存数据放在共享存储(你的状态管理层),让列表页和详情页都监听同一数据。
保留一个拥有当前值与新鲜度的单一位置。屏幕可以请求刷新,但不应分别管理它们的定时器、重试和解析。
避免“两个现实”的实用习惯:
即便规则很好,用户有时仍会看到陈旧数据(离线、网速慢、后台恢复)。用小而冷静的提示让它变得明显:一个“刚更新”时间戳、细微的“正在刷新…”指示或“离线”徽章。
对于编辑,乐观更新通常体验最好。举例:用户在详情页改了商品价格。立即更新共享 store,这样返回列表时就能看到新价格。如果保存失败,回滚到以前的值并显示短消息。
大多数缓存失败都很平凡:缓存工作了,但没人能解释何时该用、何时过期、谁来负责。
第一个陷阱是没有元数据的缓存。如果你只保存载荷,就无法判断它是否过旧、哪个应用版本产生它或它属于哪个用户。至少保存 savedAt、一个简单的版本号和 userId(或租户键)。这个习惯能避免很多“为什么这个屏幕错了?”的 bug。
另一个常见问题是为同一数据维护多个缓存且无人所有。列表页保留内存列表,仓库写入磁盘,详情页再次抓取并保存到别处。选择一个事实来源(通常是 repository 层),让每个屏幕都通过它读取。
账户切换也是常见坑。如果有人登出或切换账户,清除用户范围的表和键。否则你可能短暂显示前一个用户的头像或订单,这会像隐私泄露。
覆盖上述问题的实用修复:
savedAt、版本和 userId 一起存储,而不仅仅是 JSON。举例:产品列表即时从缓存加载,然后静默刷新。如果刷新失败,继续显示缓存并明确提示数据可能过时,同时提供重试。不要在缓存可用时阻塞 UI。
在发布前,把缓存从“看起来行得通”变为可测试的规则。即便用户来回导航、离线或用不同账户登录,他们也应该看到合理的数据。
为每个屏幕决定数据可被认为新鲜的时长。快速变化的数据(消息、余额)可能是几分钟,而缓慢变更的数据(设置、产品分类)可能是几小时。然后确认当数据不新鲜时会发生什么:后台刷新、打开时刷新或手动下拉刷新。
为每种数据决定哪些事件必须清除或绕过缓存。常见触发器包括登出、编辑项、切换账户以及改变数据形状的应用更新。
确保在载荷旁存储一小组元数据:
savedAtuserId把所有权弄清楚:对每种数据使用一个 repository(例如 ProductsRepository),而不是每个 widget 一个。Widget 应该请求数据,而不是决定缓存规则。
还要决定并测试离线行为。确认从缓存显示哪些屏幕、哪些操作被禁用以及显示什么文案(“显示已保存的数据”,并带明显的刷新控件)。每个基于缓存的屏幕都应有手动刷新,且要容易找到。
想象一个简单的商店应用,包含三屏:产品目录(列表)、产品详情和收藏页。用户滚动目录,打开产品,并点击爱心图标收藏。目标是在慢网络下也感觉迅速,同时不显示令人困惑的不一致。
本地缓存能帮你立即渲染的信息:目录页的条目(ID、标题、价格、缩略图 URL、收藏标记)、产品详情(描述、规格、可用性、lastUpdated)、图片元数据(URL、尺寸、cache keys)和用户的收藏集合(产品 ID 集合,可选带时间戳)。
当用户打开目录时,立即显示缓存结果,然后后台重新验证。如果有新鲜数据到达,仅更新变化的部分并保持滚动位置。
对于收藏切换,把它视为“必须一致”的操作。乐观更新本地收藏集合,立刻更新缓存的产品行和该 ID 的产品详情缓存。如果网络调用失败,则回滚并显示简短提示。
为保持导航一致性,让列表徽章和详情心形图标都来自相同的事实来源(本地缓存或 store),而不是来自不同的屏幕状态。返回时列表心形会立即更新,详情页会反映从列表发起的更改,收藏页的计数在各处一致而无需等待重抓。
添加简单的刷新规则:目录缓存过期快(分钟级),产品详情稍长,收藏永不过期但在登录/登出后要进行对账。
当团队能指向一页规则并就应该发生的事情达成一致时,缓存就不再神秘。目标不是完美,而是可预测的行为在各个版本中保持一致。
为每个屏幕写一张小表并保持简短,便于在变更时审查:屏幕名称与主要数据、缓存位置与键、新鲜度规则(TTL、基于事件或手动)、失效触发器以及刷新期间用户看到的内容。
在调整时添加轻量级日志。记录缓存命中、未命中以及为什么发生刷新(TTL 到期、用户下拉刷新、应用恢复、变更完成)。当有人反馈“这个列表看起来不对”时,这些日志能让问题可定位。
从简单的 TTL 开始,然后根据用户注意到的问题进行细化。新闻信息流可能允许 5 到 10 分钟的陈旧,而订单状态屏幕可能需要在恢复时刷新并在任何结账操作后刷新。
如果你在快速构建 Flutter 应用,先在规划阶段概述数据层和缓存规则会很有帮助。对于使用 Koder.ai 的团队,Planning Mode 是把每个屏幕规则写好然后按规则实现的实用场所。
在调整刷新行为时,保护稳定的屏幕以便实验。快照与回滚能在新规则意外引入闪烁、空状态或导航间计数不一致时节省时间。
从为每个屏幕设定一个明确规则开始:它可以立即展示什么(缓存),什么时候必须刷新,以及刷新期间用户看到什么。如果你无法用一句话解释清楚该规则,应用最终会显得不一致。
把缓存数据看作有新鲜度状态。如果它是 新鲜,就直接展示。如果是 可用但已陈旧,现在展示并在后台静默刷新。如果是 必须刷新,在展示之前先请求新数据(或展示加载/离线状态)。这样 UI 行为就不是“有时更新,有时不更新”。
缓存那些被频繁读取且即使有点陈旧也不会伤害用户的数据,如信息流、目录、参考数据和基本个人信息。谨慎对待与金钱或时效相关的数据(余额、库存、ETA、订单状态);可以为速度缓存它们,但在关键决策或确认前强制刷新。
会话内重用时用内存(当前配置文件、最近查看项);需要跨重启保存的简单小项用磁盘键值存储(偏好设置、小 JSON);当数据大、结构化或需离线与查询时,用本地数据库(消息、订单、库存)。
单纯的 TTL 是一个不错的默认:把数据设为一段时间内新鲜,然后刷新。不过更好的体验是“先显示缓存,后台刷新,若有变化再更新”,因为这样能避免空白屏和频繁闪烁。
在明确降低缓存可信度的事件发生时失效:用户编辑(创建/更新/删除)、登录/登出或切换账户、从后台恢复且数据超过 TTL、以及用户显式刷新。保持触发器简洁明确,避免过度或不足的刷新。
让两个屏幕都读同一个事实来源,而不是各自维护私有副本。当用户在详情页编辑后,立即更新共享缓存对象,这样返回列表时会渲染新值,然后再与服务器同步,失败时回滚。
务必在有效载荷旁保存元数据,尤其是时间戳和用户标识。登出或切换账户时立即清除或隔离用户范围的缓存项,并取消与旧用户相关的进行中的请求,这样就不会短暂地呈现前一个用户的数据。
默认保留陈旧数据可见,并展示一个小且清晰的错误提示并提供重试,而不是把屏幕置空。如果屏幕不能安全显示旧数据,就改用必须刷新的规则并显示加载或离线信息,而不是冒然展示不可靠的数值。
把缓存规则放在数据层(例如仓库)中,让每个屏幕遵循相同行为。如果你在 Koder.ai 中快速构建,先在 Planning Mode 把每个屏幕的新鲜度与失效规则写好,再实现,这样 UI 只需对状态做出反应,而不是自己发明刷新逻辑。