07. Flutter 鸿蒙实战 07:故事集列表与缓存设计
用 Repository 缓存故事、照片、视频路径,减少重复请求和页面等待。
故事模块的特点
故事模块不是普通列表。每个故事可能关联照片、人物、地点、故事正文、语音、活化视频。用户从列表进入详情时,如果所有数据都等详情页再请求,就会先看到空白或 loading。这个项目用StoriesRepository做了一层缓存和预加载。
缓存字段集中在lib/features/stories/stories_repository.dart:
staticList<Story>?_storyCache;staticfinalMap<String,Story>_storyDetailCache={};staticfinalMap<String,Uint8List>_photoCache={};staticfinalMap<String,Future<Uint8List?>>_photoRequests={};staticfinalMap<String,StoryPhotoInfo>_photoInfoCache={};staticfinalMap<String,Future<StoryPhotoInfo?>>_photoInfoRequests={};staticfinalMap<String,String>_videoFileCache={};staticfinalMap<String,Future<String?>>_videoFileRequests={};这里不仅缓存最终数据,还缓存正在进行的 Future。这样同一张照片同时被多个组件请求时,不会重复发多次网络请求。
列表缓存
故事列表请求逻辑很直接:
Future<List<Story>>list({bool forceRefresh=false})async{finalcached=_storyCache;if(!forceRefresh&&cached!=null){returnList.unmodifiable(cached);}finalrows=await_api.getJsonList('/api/stories');finalstories=rows.whereType<Map<String,dynamic>>().map(Story.fromJson).toList();_storyCache=stories;for(finalstoryinstories){_storyDetailCache[story.id]=story;}returnList.unmodifiable(stories);}列表请求成功后顺手写入详情缓存。这样从列表点击进入详情时,详情页能先展示已有的故事数据,再后台刷新。
图片缓存
照片字节通过photo(photoId)获取:
Future<Uint8List?>photo(String?photoId)async{if(photoId==null||photoId.isEmpty)returnnull;finalcached=_photoCache[photoId];if(cached!=null)returncached;finalpending=_photoRequests[photoId];if(pending!=null)returnpending;finalrequest=_api.getBytes('/api/photos/$photoId/image').then((bytes){_photoCache[photoId]=bytes;returnbytes;}).whenComplete((){_photoRequests.remove(photoId);});_photoRequests[photoId]=request;returnrequest;}这个写法解决了两个问题:已经加载过的照片直接返回;正在加载中的照片复用同一个 Future。列表里有多处显示同一张图时,网络压力会小很多。
进入详情前准备数据
prepareStoryDetail是这个模块里很关键的设计:
Future<StoryDetailBundle>prepareStoryDetail(Stringid,{bool forceRefresh=false,})async{finalstory=awaitget(id,forceRefresh:forceRefresh);finalinfo=awaitphotoInfo(story.photoId,forceRefresh:forceRefresh);varvideoFilePath=_cachedVideoForPhoto(info);if(videoFilePath==null&&info?.shouldLoadAnimatedVideo==true){try{videoFilePath=awaitvideoFileForPhoto(info);}catch(_){}}unawaited(photo(story.photoId));returnStoryDetailBundle(story:story,photoInfo:info,videoFilePath:videoFilePath,);}这段代码把故事、照片信息、视频缓存路径准备好,再交给详情页。即使视频预取失败,也不会阻塞打开详情页。
缓存失效
登录、退出或切换用户时必须清缓存:
staticvoidclearCache(){_storyCache=null;_storyDetailCache.clear();_photoCache.clear();_photoRequests.clear();_photoInfoCache.clear();_photoInfoRequests.clear();_videoFileCache.clear();_videoFileRequests.clear();}故事属于用户私有数据,也可能包含家人共享内容。缓存跨账号残留会产生严重的数据错乱。
缓存一致性补充
缓存更新比缓存读取更重要
很多项目只写“读取时优先缓存”,但忘记写“更新后维护缓存”。这个项目在创建、更新、删除故事时都会同步维护_storyCache和_storyDetailCache。
if(cached!=null){_storyCache=[for(finalitemincached)item.id==story.id?story:item,];}这样用户从详情页保存后回到列表,列表能直接看到新内容,不必等下一次全量刷新。
正在请求的 Future 也要缓存
_photoRequests保存的是进行中的图片请求。列表滚动和组件重建可能让同一张图片被请求多次,如果只缓存最终 bytes,重复请求仍然会发生。缓存 Future 可以从源头减少并发重复请求。
列表缓存和详情预取
缓存要解决两个问题
故事列表的缓存不是为了炫技,而是解决两个实际问题:列表返回快、详情打开快。列表页拿到故事后,会顺手把每个故事写入详情缓存;进入详情时就能先展示已有数据,再后台刷新。
Future<List<Story>>list({bool forceRefresh=false})async{finalcached=_storyCache;if(!forceRefresh&&cached!=null){returnList.unmodifiable(cached);}finalrows=await_api.getJsonList('/api/stories');finalstories=rows.whereType<Map<String,dynamic>>().map(Story.fromJson).toList();_storyCache=stories;for(finalstoryinstories){_storyDetailCache[story.id]=story;}returnList.unmodifiable(stories);}这里返回List.unmodifiable,避免页面拿到列表后直接改缓存对象。缓存层保持数据所有权,页面只负责展示和触发刷新。
并发请求也要缓存
照片缩略图请求很容易重复。列表里多个组件可能同时请求同一张照片,如果只缓存结果,不缓存正在进行的 Future,请求还没完成时仍然会发出多次网络请求。
finalpending=_photoRequests[photoId];if(pending!=null)returnpending;finalrequest=_api.getBytes('/api/photos/$photoId/image').then((bytes){_photoCache[photoId]=bytes;returnbytes;}).whenComplete((){_photoRequests.remove(photoId);});_photoRequests[photoId]=request;returnrequest;这个写法能把同一时间的重复请求合并成一次。图片越多、列表越长,收益越明显。
更新缓存比读取缓存更关键
缓存最容易出问题的地方不是读取,而是数据变化后没有同步。这个项目在创建、更新、重新生成、删除故事时都会维护缓存。
Future<Story>update(Stringid,StoryEditDraftdraft)async{finalstory=Story.fromJson(await_api.patchJson('/api/stories/$id',draft.toJson()),);_storyDetailCache[story.id]=story;finalcached=_storyCache;if(cached!=null){_storyCache=[for(finalitemincached)item.id==story.id?story:item,];}returnstory;}如果只改详情缓存,不改列表缓存,用户返回列表会看到旧标题;如果只改列表缓存,不改详情缓存,再次进入详情又会回到旧内容。两个缓存都要同步。
缓存失效时机
故事缓存至少在这些场景下需要失效或刷新:
登录用户变化:清空全部故事、照片、视频缓存。 上传新照片:重新拉取故事列表或照片列表。 编辑故事:更新列表缓存和详情缓存。 删除故事:从列表缓存移除,并删除详情缓存。 重新生成故事:用服务端返回的新内容覆盖旧缓存。这个项目在AuthController里处理登录态变化,在StoriesRepository里处理故事数据变化,职责边界比较清楚。页面不需要知道缓存内部有几个 Map,只需要调用 Repository 提供的方法。
列表验证路径
故事列表模块可以按下面路径验证:
1. 首次进入故事列表,触发网络请求。 2. 进入详情后返回,列表不闪烁、不重复请求同一批数据。 3. 编辑故事标题或正文后返回列表,列表展示新内容。 4. 删除故事后返回列表,被删除项消失。 5. 退出登录再登录另一个账号,列表不显示旧账号缓存。这组场景能覆盖缓存命中、缓存更新和缓存清理。缓存做得好,页面会更快;缓存边界没处理好,数据错乱比慢更麻烦。
缓存问题排查
缓存对象要有清晰所有权
StoriesRepository用静态字段保存故事、照片、视频缓存。这意味着缓存是跨页面共享的,不属于某一个页面实例。
staticList<Story>?_storyCache;staticfinalMap<String,Story>_storyDetailCache={};staticfinalMap<String,Uint8List>_photoCache={};staticfinalMap<String,Future<Uint8List?>>_photoRequests={};页面销毁不代表缓存清空。这样从列表到详情、再返回列表时,体验更流畅。但它也要求登录态变化时必须主动清理缓存,否则不同用户之间会互相影响。
forceRefresh 的使用边界
缓存读取需要一个强制刷新入口。项目里的list和get都支持forceRefresh,普通进入页面可以用缓存,下拉刷新或保存后校正则走强制刷新。
Future<Story>get(Stringid,{bool forceRefresh=false})async{finalcached=_storyDetailCache[id];if(!forceRefresh&&cached!=null)returncached;finalstory=Story.fromJson(await_api.getJson('/api/stories/$id'));_storyDetailCache[id]=story;returnstory;}没有forceRefresh,页面要么永远使用旧缓存,要么完全不敢用缓存。有了这个参数,缓存和真实数据之间就有了可控同步点。
缓存命中也要避免可变引用泄露
列表缓存返回时使用不可修改列表,能避免页面直接改 Repository 内部数据。
returnList.unmodifiable(stories);如果页面直接拿到内部 List 并执行remove,Repository 可能完全不知道缓存已经变了。不可变返回值能把修改入口收回到 Repository 方法里。
图片缓存失败策略
图片加载失败时,当前请求会从_photoRequests移除,不会缓存失败结果。下次进入页面仍然有机会重试。
}).whenComplete((){_photoRequests.remove(photoId);});这比把失败结果永久缓存更合理。移动网络失败可能只是临时问题,下一次刷新应该允许重新请求。
缓存问题定位表
| 现象 | 优先检查 | 对应文件 | 处理方向 |
|---|---|---|---|
| 返回列表后还是旧内容 | 列表缓存是否更新 | stories_repository.dart | update/regenerate 后同步_storyCache |
| 详情打开慢 | 是否预取详情和图片 | prepareStoryDetail | 先用列表缓存,再后台刷新 |
| 图片重复请求很多 | 是否缓存 pending Future | _photoRequests | 同一 photoId 复用同一个请求 |
| 退出账号后仍显示旧故事 | 缓存是否清理 | auth_controller.dart | 登录态变化时调用clearCache() |
缓存的核心不是“存起来”,而是“什么时候用、什么时候更新、什么时候清”。三件事缺一件,缓存都会变成 bug 来源。
缓存容量控制
当前缓存适合中小规模故事列表。如果照片数量继续增长,图片和视频缓存需要容量控制,否则内存和磁盘都会变大。
故事列表缓存:按账号清空 图片内存缓存:限制最大数量或最大字节数 视频文件缓存:按最近使用时间清理 pending 请求缓存:请求结束后立即移除故事文本可以保留久一点,图片和视频更适合做可回收缓存。缓存策略按资源成本区分,既能保证体验,也能避免 App 长时间使用后占用过大。
用clearCache()|
缓存的核心不是“存起来”,而是“什么时候用、什么时候更新、什么时候清”。三件事缺一件,缓存都会变成 bug 来源。