Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战

Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战

作者:FungLeo | 适用:Flutter / Dart(思路可迁移到任意端)

目标:让"几乎不变的字典数据"和"列表首屏"少发一半以上的请求,同时保证数据还能更新。

前言

先说个我自己都觉得有点丢人的观察。

前段时间我拿抓包工具看了一下自己项目的请求,发现一个很尴尬的事实:用户每切一次页面,我就要重新拉一遍下拉选项。分类、状态、归属人这些字典数据,一天到晚也不见得变一次,我却老老实实每次进页面都去问服务端一遍"这些选项是啥"。

页面进得越勤,请求发得越欢。用户看到的就是每次进列表页都要先转个圈,体验很一般。

一开始我想的很简单:那我加个变量存起来不就完了?结果很快被现实打脸------内存缓存重开 App 就没了,冷启动第一次进页面照样转圈;后来又加了本地持久化,结果走向另一个极端:数据永远不刷新,服务端改了一个选项名,客户端半个月还显示老的。

来回折腾了几轮,最后沉淀出一套还算好用的模式:内存 + 磁盘 + TTL 三层结构。这篇就把它完整写出来,各位看官可以直接抄到自己项目里。

本文要点

  • 一个能长期跑生产的缓存,必须同时答上四问:命中怎么办、没命中怎么办、过期怎么办、强制更新怎么办
  • 三层结构:内存扛高频访问、磁盘扛冷启动、TTL 扛数据陈旧、force 参数把最终决定权还给用户
  • 磁盘持久化一定要连时间戳一起存,否则 TTL 形同虚设(我在这翻过最狠的车)
  • 列表服务用 peekCache 实现 stale-while-revalidate:先拿旧数据填满界面,后台再悄悄更新
  • 缓存 key 必须带用户/租户维度,否则切账号会串数据------那是事故不是体验问题

先想清楚:缓存到底要解决几个问题

在贴代码之前,我想先把需求拆清楚。说实话,缓存这东西写起来不难,难的是想清楚边界。一个能用的缓存方案,至少要同时回答四个问题:

  1. 命中怎么办 ------ 有数据就直接用,一个包都别发;
  2. 没命中怎么办 ------ 降级到下一层,最后才走网络;
  3. 过期怎么办 ------ 得有个时间概念,不能永远吃老本;
  4. 要强制更新怎么办 ------ 用户下拉刷新的时候,缓存得给我让路。

只解决第 1 个问题的,那叫"变量存了一下",算不上缓存方案。四个问题都答上了,才是能长期跑在生产里的东西。

好啦,思路理清楚了,开干。

三层各自是什么角色

很多人一上来就写代码,但先把三层的定位摆清楚,后面才不会写乱:

层级 介质 速度 存活期 适用场景 何时失效
内存 进程内变量 最快(ns 级) 活不过进程重启 高频读、几乎不变的字典 App 被杀 / 重启
磁盘 本地持久化(sp 或 db) 慢一点(ms 级) 扛冷启动 字典、首屏首拉 超过 TTL
网络 服务端 最慢(数百 ms+) 永远最新 以上全 miss 时 ---

一句话记牢:内存最快但活不过进程;磁盘慢一点但能扛冷启动;网络最慢但数据最新。按这个顺序降级,才能做到"绝大多数情况零请求,冷启动一次请求,过期一次请求"。

模式一:Options 缓存(内存 + 磁盘 + TTL)

这个模式适合那些变化频率极低、但到处都要用的字典数据。

dart 复制代码
class OptionsCacheService {
  // 第一层:内存缓存,进程内最快
  List<OptionItem>? _categories;
  List<OptionItem>? _users;
  DateTime? _lastFetchTime;

  // TTL:这里给 10 小时(36000 秒),按数据变化频率自己定
  static const int ttlSeconds = 36000;

  bool get _isValid =>
      _lastFetchTime != null &&
      DateTime.now().difference(_lastFetchTime!).inSeconds < ttlSeconds;

  /// 对外暴露:按 id 取名称
  /// 命中内存或磁盘就直接返回,过期了才会真的发请求
  Future<String> getCategoryName(String id) async {
    await _ensureLoaded();
    // 注意 orElse:查不到就回退显示 id,别让它抛异常
    final hit = _categories?.firstWhere(
      (e) => e.id == id,
      orElse: () => OptionItem(id: id, name: id),
    );
    return hit?.name ?? id;
  }

  /// 三层降级的核心逻辑
  Future<void> _ensureLoaded() async {
    if (_isValid && _categories != null) return;  // 1. 内存命中,直接走人
    if (await _loadFromLocal()) return;           // 2. 磁盘命中(含时间戳校验)
    await _refreshFromApi();                      // 3. 都没有,才走网络
    await _saveToLocal();                         // 4. 回写磁盘,下次冷启动能用
    _lastFetchTime = DateTime.now();
  }

  /// 手动强制刷新:绕过所有缓存
  Future<void> refresh() async {
    await _refreshFromApi();
    await _saveToLocal();
    _lastFetchTime = DateTime.now();
  }
}
这里面有几个点特别容易写错

第一,磁盘持久化一定要连时间戳一起存。

这是我当初翻车最狠的地方。我最早只把数据本身写进了本地存储,时间戳留在内存里。结果 App 一重启,内存里的 _lastFetchTime 归零,磁盘里的数据却还在------于是 _loadFromLocal() 每次都命中,TTL 形同虚设,数据永远不刷新。

所以磁盘里存的应该是这么个结构:

json 复制代码
{
  "fetchedAt": 1717029000000,
  "categories": [
    { "id": "1", "name": "选项 A" },
    { "id": "2", "name": "选项 B" }
  ]
}

_loadFromLocal() 读出来之后,得先拿 fetchedAt 和 TTL 比一比,过期了就当没读到,老老实实返回 false 去走网络。

第二,三层的顺序不能乱:内存 → 磁盘 → 网络。

上文那张表就是这个顺序的来历。顺序乱了,要么冷启动照样转圈,要么过期了还走旧数据。

第三,firstWhere 记得带 orElse

Dart 的 firstWhere 查不到会直接抛 StateError。字典数据这种东西,服务端删掉一个选项、而客户端还缓存着旧数据引用的场景太常见了。带上 orElse 回退成显示 id,页面顶多丑一点,总比整个列表崩了强,对吧。

模式二:服务层首屏缓存 + peekCache

字典数据搞定了,那列表本身呢?

列表数据当然不能像字典那样缓存 10 小时,但"用户刚从详情页返回列表"这种场景,重新请求一遍确实没必要。所以我给列表服务也加了一层短 TTL 的首屏缓存。

dart 复制代码
class SomeService {
  List<ItemModel>? _cache;
  String? _cacheKey;
  DateTime? _cacheTime;

  static const int ttl = 300; // 列表数据变化快,5 分钟足够了

  bool _isCacheValid(String key) =>
      _cacheKey == key &&
      _cacheTime != null &&
      DateTime.now().difference(_cacheTime!).inSeconds < ttl;

  /// 引用安全的 peek:命中就返回数据,不命中返回 null
  /// 关键是它绝不触发请求,纯查询
  List<ItemModel>? peekCache(String key) =>
      _isCacheValid(key) ? _cache : null;

  Future<PageResult> fetch({
    required int page,
    required String key,     // 缓存维度,见下面的说明
    bool force = false,      // 下拉刷新时传 true,绕过缓存
  }) async {
    // 只缓存第一页;force 时直接跳过缓存判定
    if (page == 1 && !force && _isCacheValid(key)) {
      return PageResult(items: _cache!, total: _cache!.length);
    }

    final r = await _api(page);

    if (page == 1) {
      _cache = r.items;
      _cacheKey = key;               // ← 存的是本次请求的 key,别写成 _cacheKey = _cacheKey
      _cacheTime = DateTime.now();
    }
    return r;
  }
}
几个设计上的讲究

缓存判定要放在"请求守卫"前面。

很多人的列表服务里都有个 _isLoading 之类的守卫变量,防止重复请求。如果你把缓存判定写在守卫后面,就会出现"明明有缓存,却因为守卫拦截而什么都没返回"的尴尬情况。正确的顺序是:先查缓存 → 命中就返回 → 没命中再进守卫逻辑 → 最后才发请求。

peekCache 为什么要单独存在?

因为它把"要不要发请求"的决定权交还给了调用方。页面可以这么用:

dart 复制代码
// 进页面先 peek 一把,有缓存就先把界面画出来,用户不用看骨架屏
final cached = service.peekCache(currentKey);
if (cached != null) {
  setState(() => items = cached);
}
// 然后再决定要不要静默拉一次新数据

这就是所谓的 stale-while-revalidate:先拿旧数据把界面填满,后台再悄悄更新。用户感知到的就是"秒开"。如果 peekCache 自己会触发请求,这个模式就玩不起来了。

缓存 key 一定要带维度。

这条是血的教训。_cacheKey 里除了筛选条件,还应该带上用户标识、租户标识这类维度(团队内部的隔离规范文档里也专门强调过)。否则 A 账号退出、B 账号登录,一进列表页看到的还是 A 的数据------这已经不是体验问题了,是事故。

另外提醒一句,退出登录的时候记得把这些缓存清干净,包括内存里的和磁盘里的。缓存服务最好统一提供一个 clear() 方法,登出流程里挨个调一遍。

TTL 该设多长?我的经验值

这个没有标准答案,我一般按"数据能容忍多久不更新"来倒推:

数据类型 建议 TTL 说明
字典 / 枚举 / 分类选项 数小时 ~ 一天 基本不变,配合手动 refresh 兜底
用户信息 / 权限配置 十几分钟 ~ 半小时 变了要相对及时地反映出来
列表首屏 几分钟 主要是解决"页面来回切"的重复请求
实时性数据(余额、状态) 不缓存 缓存的收益远小于显示错数据的代价

一般而言,够用就好,没必要太折腾。TTL 设得再精妙,也不如给用户留一个下拉刷新的口子来得实在。

小结

好啦,两个模式都讲完了。

回头看,这套东西的核心其实就一句话:用内存扛住高频访问,用磁盘扛住冷启动,用 TTL 扛住数据陈旧,用 force 参数把最终决定权还给用户。 四层加起来,才是一个跑得住的缓存方案。

再把几个容易翻车的点用一张表收个口:

现象 根因 修法
磁盘只存数据不存时间戳 数据永远不刷新 时间戳留内存,重启归零 磁盘结构带 fetchedAt,读取先比 TTL
三层顺序乱 冷启动转圈 / 过期走旧 降级顺序错 严格 内存→磁盘→网络
firstWhere 没带 orElse 服务端删选项,列表崩 StateError 抛异常 带 orElse 回退显示 id
缓存判定写在守卫后 有缓存却返回空 守卫先拦截 先查缓存→命中返回→再进守卫
缓存 key 无维度 切账号串数据 key 只含筛选条件 key 带 用户/租户 标识
登出不清缓存 切账号残留 内存/磁盘未清 统一 clear() 在登出调用

那么各位看官,您在项目里是怎么做缓存的呢?有没有更省事的封装方式?欢迎在评论区交流一下。如果这篇文章对你有点用,希望看官您用发财的小手点个小赞哈,谢谢大家!


本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关阅读

相关推荐
Sterting1 小时前
第 9 节:本地存储 — 数据不丢的前端缓存
前端·javascript·缓存
牛马也想出海1 小时前
使用Playwright被检测为机器人的原因及反检测方案
开发语言·网络·人工智能·机器人·php
暗暗别做白日梦1 小时前
CompletableFuture 并发统计
网络·数据库·oracle
人间凡尔赛1 小时前
React Compiler 1.0 正式落地:告别 useMemo / useCallback,2026 前端性能优化的新范式
前端·性能优化·react
FungLeo2 小时前
Flutter 可复用公共组件库设计与落地:AppDialog/BottomSheet 等实战
flutter
倔强的小石头_2 小时前
中兴F50只能本地管理?刷入UFI-TOOLS后这样远程访问
网络
@CLoudbays_Martin112 小时前
Linux 服务器如何查看是否被植入后门?
linux·服务器·网络·安全·github·ssl
拾光Ծ2 小时前
【Linux网络】深入理解网络层:从IP协议格式,子网划分到NAT与路由机制
linux·网络·网络协议·tcp/ip·计算机网络
2301_7807896612 小时前
DDoS 攻击溯源:DNS 水印标记 + 区块链存证的双保险
运维·服务器·网络·云原生·ddos