Chlx's Live

Back

一、整体架构#

系统中存在三种不同性质的数据:

objectKey
→ 图片的稳定资源身份

signed URL
→ 有时效的 CDN 访问凭证

图片文件
→ App 本地缓存的实际内容
plaintext

二、上传和后端存储#

上传流程:

App 向后端申请上传凭证
→ App 直传 OSS
→ 获得 objectKey
→ App 更新业务数据时提交 objectKey
→ 数据库保存 objectKey
plaintext

数据库保存类似:

club/123/background/xxx.webp
avatars/123/240x240/xxx.webp
posts/123/xxx.webp
plaintext

数据库不保存带 auth_key 的完整 CDN URL。

业务接口返回数据时,后端才通过 CdnUrlSerializer 将 objectKey 转换为临时 signed URL:

objectKey
→ https://cdn.northward.zone/posts/123/xxx.webp?auth_key=...
plaintext

因此:

数据库数据:长期稳定
signed URL:临时有效
plaintext

三、CDN 鉴权与缓存#

Northward 使用阿里云 CDN Type A 鉴权。

客户端请求:

/posts/123/a.webp?auth_key=timestamp-rand-uid-md5
plaintext

CDN 首先验证 auth_key

签名有效
→ 移除鉴权参数
→ 按 /posts/123/a.webp 查询边缘缓存

签名过期或错误
→ 返回 HTTP 403
plaintext

所以 CDN 边缘节点的缓存身份也是资源路径,而不是完整 signed URL:

旧 URL:/posts/123/a.webp?auth_key=old
新 URL:/posts/123/a.webp?auth_key=new

CDN Cache Key:
/posts/123/a.webp
plaintext

目前生产 CDN 鉴权有效期为 7 天。


四、SharedPreferences 业务缓存#

SharedPreferences 是 Flutter 的持久化键值存储,可以理解为 App 本地的小型:

Map<String, String>
plaintext

Northward 使用它保存首页帖子、热榜、社团等业务 JSON。

例如:

home_new
home_following
home_essence
home_hot_lists_week
club_hall_cache
plaintext

由于接口 JSON 中包含 signed URL,SharedPreferences 也会保存当时接口返回的签名。

因此,下次启动读取的可能是:

最新业务内容 + 仍有效的 URL
或者
旧业务内容 + 已过期的 URL
plaintext

五、图片内存缓存#

所有 Widget 形式的网络图片统一经过:

CachedNetworkImage

第一层是 Flutter ImageCache

  • 保存已经解码的图片;
  • 访问速度最快;
  • App 重启后消失;
  • 内存紧张时可能被系统淘汰;
  • 使用归一化后的稳定媒体缓存键。

内存命中后,不再访问磁盘或 CDN。


六、图片磁盘缓存#

第二层是 cached_network_image_ce 的磁盘缓存:

AppImageCacheManager

当前配置:

  • 全局单例;
  • 最多缓存 1000 个对象;
  • stalePeriod 约 100 年,实际不按时间主动过期;
  • 超过容量后按 LRU 淘汰;
  • 用户可以在设置页手动清理;
  • 升级到 CE 缓存实现时,一次性清理旧图片缓存目录并记录完成标记;
  • 元数据使用 Hive CE,图片内容保存在文件系统。

磁盘缓存 Key 由下面的工具生成:

MediaCacheKey

例如:

URL₁:
https://cdn.northward.zone/posts/123/a.webp?auth_key=old

URL₂:
https://cdn.northward.zone/posts/123/a.webp?auth_key=new

归一化后的磁盘缓存 Key:
northward-media:northward:posts/123/a.webp
plaintext

因此新旧 signed URL 共用同一个图片文件缓存。


七、原问题#

旧业务缓存中的 signed URL 可能已经过期。

原问题包含两部分:

  1. 磁盘未命中时,只能使用业务 JSON 中的 signed URL;
  2. 稳定 cacheKey 可能让 Flutter 认为新旧 URL 是同一个图片 Provider,从而抑制新签名的重新加载。

稳定 cacheKey 本身没有错,它保证同一 objectKey 不会产生多份磁盘缓存。缺失的是:

signed URL 变化后,需要重新创建底层图片 Widget。


八、为什么不等待最新业务 JSON#

生产实测表明,最新业务 JSON 通常需要数百毫秒,弱网可能需要数秒。

而旧 URL 失效需要同时满足:

业务缓存中的 URL 已超过 7 天
并且
对应图片未命中内存和磁盘缓存
plaintext

因此不能为了少量过期情况,让所有未缓存图片等待最新接口。

最终策略是:

旧 signed URL 乐观加载
+
最新业务 JSON 后台刷新
plaintext

九、新版加载流程#

核心代码关系:

底层 Widget 实例身份
= ValueKey(imageUrl)

内存/磁盘缓存身份
= MediaCacheKey.fromUrl(imageUrl)
= objectKey
plaintext

也就是说:

signed URL 变化
→ 图片 Widget 可以重建

objectKey 不变
→ 内存和磁盘缓存仍然复用
plaintext

十、不同场景下的行为#

图片已经缓存#

读取旧业务 JSON
→ 按 objectKey 命中图片缓存
→ 立即显示
→ 不访问 CDN
plaintext

图片未缓存,但旧 URL 仍有效#

读取旧业务 JSON
→ 立即使用旧 URL
→ CDN 返回 200
→ 缓存并显示
plaintext

不需要等待最新业务接口。

图片未缓存,旧 URL 已过期#

旧 URL 请求 CDN
→ 返回 403
→ 暂时显示兜底图

最新业务 JSON 返回
→ imageUrl 变化
→ ValueKey 变化
→ CE 图片 Widget 重建
→ 使用新 URL 加载
plaintext

旧 URL 已经加载成功,随后新 URL 到达#

新 URL 触发 Widget 重建
→ 仍使用相同 objectKey cacheKey
→ 从内存或磁盘缓存命中
→ 不重新下载图片文件
plaintext

十一、首页数据竞态修复#

首页关注、精华和热榜会并发执行:

读取 SharedPreferences
+
请求最新业务接口
plaintext

原来存在这种可能:

最新接口先返回并显示
→ 本地缓存稍后读取完成
→ 旧数据覆盖最新数据
plaintext

现在通过:

StaleWhileRevalidateGuard

保证:

缓存先返回
→ 立即展示缓存
→ 随后由最新网络数据覆盖

网络先返回
→ 标记最新数据已应用
→ 较晚到达的本地缓存被丢弃
plaintext

首页最新和社团大厅本身按“先读取缓存,再请求网络”的顺序执行,因此不需要这个 Guard。


十二、最终缓存模型#

最终原则:

objectKey 决定“这是哪张图片”,signed URL 决定“这次如何下载”。

当前方案保留了最快的旧 URL 乐观加载,同时让最新 signed URL 有机会修复旧请求失败,不需要新增补签接口、MediaRef、expiresAt 或图片状态机。

图片上传、签名与缓存机制
https://lixuan.live/blog/tu-pian-shang-chuan-qian-ming-yu-huan-cun-ji-zhi
Author Chlx
Published at 2026年4月18日
Comment seems to stuck. Try to refresh?✨