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

📅 2026/8/8 3:32:50
Flutter 带 TTL 的多级缓存设计:内存+磁盘+网络三层实战
Flutter 带 TTL 的多级缓存设计内存磁盘网络三层实战作者FungLeo 适用Flutter / Dart思路可迁移到任意端目标让几乎不变的字典数据和列表首屏少发一半以上的请求同时保证数据还能更新。前言先说个我自己都觉得有点丢人的观察。前段时间我拿抓包工具看了一下自己项目的请求发现一个很尴尬的事实用户每切一次页面我就要重新拉一遍下拉选项。分类、状态、归属人这些字典数据一天到晚也不见得变一次我却老老实实每次进页面都去问服务端一遍这些选项是啥。页面进得越勤请求发得越欢。用户看到的就是每次进列表页都要先转个圈体验很一般。一开始我想的很简单那我加个变量存起来不就完了结果很快被现实打脸——内存缓存重开 App 就没了冷启动第一次进页面照样转圈后来又加了本地持久化结果走向另一个极端数据永远不刷新服务端改了一个选项名客户端半个月还显示老的。来回折腾了几轮最后沉淀出一套还算好用的模式内存 磁盘 TTL 三层结构。这篇就把它完整写出来各位看官可以直接抄到自己项目里。本文要点一个能长期跑生产的缓存必须同时答上四问命中怎么办、没命中怎么办、过期怎么办、强制更新怎么办三层结构内存扛高频访问、磁盘扛冷启动、TTL 扛数据陈旧、force 参数把最终决定权还给用户磁盘持久化一定要连时间戳一起存否则 TTL 形同虚设我在这翻过最狠的车列表服务用 peekCache 实现 stale-while-revalidate先拿旧数据填满界面后台再悄悄更新缓存 key 必须带用户/租户维度否则切账号会串数据——那是事故不是体验问题先想清楚缓存到底要解决几个问题在贴代码之前我想先把需求拆清楚。说实话缓存这东西写起来不难难的是想清楚边界。一个能用的缓存方案至少要同时回答四个问题命中怎么办—— 有数据就直接用一个包都别发没命中怎么办—— 降级到下一层最后才走网络过期怎么办—— 得有个时间概念不能永远吃老本要强制更新怎么办—— 用户下拉刷新的时候缓存得给我让路。只解决第 1 个问题的那叫变量存了一下算不上缓存方案。四个问题都答上了才是能长期跑在生产里的东西。好啦思路理清楚了开干。三层各自是什么角色很多人一上来就写代码但先把三层的定位摆清楚后面才不会写乱层级介质速度存活期适用场景何时失效内存进程内变量最快ns 级活不过进程重启高频读、几乎不变的字典App 被杀 / 重启磁盘本地持久化sp 或 db慢一点ms 级扛冷启动字典、首屏首拉超过 TTL网络服务端最慢数百 ms永远最新以上全 miss 时—一句话记牢内存最快但活不过进程磁盘慢一点但能扛冷启动网络最慢但数据最新。按这个顺序降级才能做到绝大多数情况零请求冷启动一次请求过期一次请求。模式一Options 缓存内存 磁盘 TTL这个模式适合那些变化频率极低、但到处都要用的字典数据。classOptionsCacheService{// 第一层内存缓存进程内最快ListOptionItem?_categories;ListOptionItem?_users;DateTime?_lastFetchTime;// TTL这里给 10 小时36000 秒按数据变化频率自己定staticconstint ttlSeconds36000;boolget_isValid_lastFetchTime!nullDateTime.now().difference(_lastFetchTime!).inSecondsttlSeconds;/// 对外暴露按 id 取名称/// 命中内存或磁盘就直接返回过期了才会真的发请求FutureStringgetCategoryName(Stringid)async{await_ensureLoaded();// 注意 orElse查不到就回退显示 id别让它抛异常finalhit_categories?.firstWhere((e)e.idid,orElse:()OptionItem(id:id,name:id),);returnhit?.name??id;}/// 三层降级的核心逻辑Futurevoid_ensureLoaded()async{if(_isValid_categories!null)return;// 1. 内存命中直接走人if(await_loadFromLocal())return;// 2. 磁盘命中含时间戳校验await_refreshFromApi();// 3. 都没有才走网络await_saveToLocal();// 4. 回写磁盘下次冷启动能用_lastFetchTimeDateTime.now();}/// 手动强制刷新绕过所有缓存Futurevoidrefresh()async{await_refreshFromApi();await_saveToLocal();_lastFetchTimeDateTime.now();}}这里面有几个点特别容易写错第一磁盘持久化一定要连时间戳一起存。这是我当初翻车最狠的地方。我最早只把数据本身写进了本地存储时间戳留在内存里。结果 App 一重启内存里的_lastFetchTime归零磁盘里的数据却还在——于是_loadFromLocal()每次都命中TTL 形同虚设数据永远不刷新。所以磁盘里存的应该是这么个结构{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 的首屏缓存。classSomeService{ListItemModel?_cache;String?_cacheKey;DateTime?_cacheTime;staticconstint ttl300;// 列表数据变化快5 分钟足够了bool_isCacheValid(Stringkey)_cacheKeykey_cacheTime!nullDateTime.now().difference(_cacheTime!).inSecondsttl;/// 引用安全的 peek命中就返回数据不命中返回 null/// 关键是它绝不触发请求纯查询ListItemModel?peekCache(Stringkey)_isCacheValid(key)?_cache:null;FuturePageResultfetch({required int page,requiredStringkey,// 缓存维度见下面的说明bool forcefalse,// 下拉刷新时传 true绕过缓存})async{// 只缓存第一页force 时直接跳过缓存判定if(page1!force_isCacheValid(key)){returnPageResult(items:_cache!,total:_cache!.length);}finalrawait_api(page);if(page1){_cacher.items;_cacheKeykey;// ← 存的是本次请求的 key别写成 _cacheKey _cacheKey_cacheTimeDateTime.now();}returnr;}}几个设计上的讲究缓存判定要放在请求守卫前面。很多人的列表服务里都有个_isLoading之类的守卫变量防止重复请求。如果你把缓存判定写在守卫后面就会出现明明有缓存却因为守卫拦截而什么都没返回的尴尬情况。正确的顺序是先查缓存 → 命中就返回 → 没命中再进守卫逻辑 → 最后才发请求。peekCache为什么要单独存在因为它把要不要发请求的决定权交还给了调用方。页面可以这么用// 进页面先 peek 一把有缓存就先把界面画出来用户不用看骨架屏finalcachedservice.peekCache(currentKey);if(cached!null){setState(()itemscached);}// 然后再决定要不要静默拉一次新数据这就是所谓的 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 优化校阅转发请注明首发地址谢谢大家相关阅读Flutter Riverpod 在 build 期改 provider 导致整页崩溃踩坑实录Flutter Debug 红屏、Release 灰屏你的 release-only bug只是异常被藏起来了Flutter 可复用公共组件库设计与落地AppDialog/BottomSheet 等实战Flutter 接入 Alice 调试浮窗一个顶层 final 抢跑把 release 网络整没了Flutter Material 3 从 0 搭品牌主题系统四件套实战全记录