07. Flutter 鸿蒙实战 07:故事集列表与缓存设计

用 Repository 缓存故事、照片、视频路径,减少重复请求和页面等待。
故事模块的特点
故事模块不是普通列表。每个故事可能关联照片、人物、地点、故事正文、语音、活化视频。用户从列表进入详情时,如果所有数据都等详情页再请求,就会先看到空白或 loading。这个项目用 StoriesRepository 做了一层缓存和预加载。
缓存字段集中在 lib/features/stories/stories_repository.dart:
dart
static List<Story>? _storyCache;
static final Map<String, Story> _storyDetailCache = {};
static final Map<String, Uint8List> _photoCache = {};
static final Map<String, Future<Uint8List?>> _photoRequests = {};
static final Map<String, StoryPhotoInfo> _photoInfoCache = {};
static final Map<String, Future<StoryPhotoInfo?>> _photoInfoRequests = {};
static final Map<String, String> _videoFileCache = {};
static final Map<String, Future<String?>> _videoFileRequests = {};
这里不仅缓存最终数据,还缓存正在进行的 Future。这样同一张照片同时被多个组件请求时,不会重复发多次网络请求。
列表缓存
故事列表请求逻辑很直接:
dart
Future<List<Story>> list({bool forceRefresh = false}) async {
final cached = _storyCache;
if (!forceRefresh && cached != null) {
return List.unmodifiable(cached);
}
final rows = await _api.getJsonList('/api/stories');
final stories =
rows.whereType<Map<String, dynamic>>().map(Story.fromJson).toList();
_storyCache = stories;
for (final story in stories) {
_storyDetailCache[story.id] = story;
}
return List.unmodifiable(stories);
}
列表请求成功后顺手写入详情缓存。这样从列表点击进入详情时,详情页能先展示已有的故事数据,再后台刷新。
图片缓存
照片字节通过 photo(photoId) 获取:
dart
Future<Uint8List?> photo(String? photoId) async {
if (photoId == null || photoId.isEmpty) return null;
final cached = _photoCache[photoId];
if (cached != null) return cached;
final pending = _photoRequests[photoId];
if (pending != null) return pending;
final request = _api.getBytes('/api/photos/$photoId/image').then((bytes) {
_photoCache[photoId] = bytes;
return bytes;
}).whenComplete(() {
_photoRequests.remove(photoId);
});
_photoRequests[photoId] = request;
return request;
}
这个写法解决了两个问题:已经加载过的照片直接返回;正在加载中的照片复用同一个 Future。列表里有多处显示同一张图时,网络压力会小很多。
进入详情前准备数据
prepareStoryDetail 是这个模块里很关键的设计:
dart
Future<StoryDetailBundle> prepareStoryDetail(
String id, {
bool forceRefresh = false,
}) async {
final story = await get(id, forceRefresh: forceRefresh);
final info = await photoInfo(story.photoId, forceRefresh: forceRefresh);
var videoFilePath = _cachedVideoForPhoto(info);
if (videoFilePath == null && info?.shouldLoadAnimatedVideo == true) {
try {
videoFilePath = await videoFileForPhoto(info);
} catch (_) {}
}
unawaited(photo(story.photoId));
return StoryDetailBundle(
story: story,
photoInfo: info,
videoFilePath: videoFilePath,
);
}
这段代码把故事、照片信息、视频缓存路径准备好,再交给详情页。即使视频预取失败,也不会阻塞打开详情页。
缓存失效
登录、退出或切换用户时必须清缓存:
dart
static void clearCache() {
_storyCache = null;
_storyDetailCache.clear();
_photoCache.clear();
_photoRequests.clear();
_photoInfoCache.clear();
_photoInfoRequests.clear();
_videoFileCache.clear();
_videoFileRequests.clear();
}
故事属于用户私有数据,也可能包含家人共享内容。缓存跨账号残留会产生严重的数据错乱。

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