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

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 的使用边界

缓存读取需要一个强制刷新入口。项目里的 listget 都支持 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 来源。

相关推荐
程序员黑豆1 小时前
从零开始:创建第一个鸿蒙应用程序
前端·harmonyos
fanchenxinok1 小时前
学习笔记:LabVIEW中如何将解析S19/HEX的VI封装为子VI并供主VI调用
笔记·学习·labview·子vi
2301_809051141 小时前
基本电子电路元件 学习笔记
笔记·学习
今天AI了吗2 小时前
Hermes Agent 搭建全流程:从本机试跑到可持续运行的个人 AI Agent
java·人工智能·python·学习·embedding
一只小菜鸡..3 小时前
Stanford CS144 学习笔记 (二):传输层与数据通信机制
网络·笔记·学习
数行拙笔3 小时前
Redis---list类型
数据库·redis·缓存
千逐683 小时前
HarmonyOS 实战 | 弹窗/提示框怎么做——Alert、Dialog、Toast 详解
华为·harmonyos·鸿蒙
天选之子1233 小时前
tabulate学习
学习
子非鱼94274 小时前
06-Flutter 鸿蒙实战 06:上传后处理流程,照片识别、故事生成和状态刷新
学习·flutter·华为·harmonyos