一、整体架构#
```mermaid
flowchart LR
APP["App 上传图片"] --> OSS["直传 OSS"]
OSS --> KEY["业务接口提交 objectKey"]
KEY --> DB["数据库保存 objectKey"]
DB --> API["业务接口序列化"]
API --> SIGN["生成带 auth_key 的 CDN URL"]
SIGN --> JSON["返回业务 JSON"]
JSON --> SP["SharedPreferences 业务缓存"]
JSON --> IMG["图片组件"]
IMG --> MEM{"Flutter ImageCache"}
MEM -- 命中 --> SHOW["显示图片"]
MEM -- 未命中 --> DISK{"CE 磁盘缓存"}
DISK -- 命中 --> SHOW
DISK -- 未命中 --> CDN["使用 signed URL 请求 CDN"]
CDN -- 200 --> SAVE["写入磁盘和内存缓存"]
SAVE --> SHOW
CDN -- 403 --> FALLBACK["显示兜底图"]
SP -->|"下次启动恢复旧业务 JSON"| IMG
```plaintext系统中存在三种不同性质的数据:
objectKey
→ 图片的稳定资源身份
signed URL
→ 有时效的 CDN 访问凭证
图片文件
→ App 本地缓存的实际内容plaintext二、上传和后端存储#
上传流程:
App 向后端申请上传凭证
→ App 直传 OSS
→ 获得 objectKey
→ App 更新业务数据时提交 objectKey
→ 数据库保存 objectKeyplaintext数据库保存类似:
club/123/background/xxx.webp
avatars/123/240x240/xxx.webp
posts/123/xxx.webpplaintext数据库不保存带 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-md5plaintextCDN 首先验证 auth_key:
签名有效
→ 移除鉴权参数
→ 按 /posts/123/a.webp 查询边缘缓存
签名过期或错误
→ 返回 HTTP 403plaintext所以 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.webpplaintext目前生产 CDN 鉴权有效期为 7 天。
四、SharedPreferences 业务缓存#
SharedPreferences 是 Flutter 的持久化键值存储,可以理解为 App 本地的小型:
Map<String, String>plaintextNorthward 使用它保存首页帖子、热榜、社团等业务 JSON。
例如:
home_new
home_following
home_essence
home_hot_lists_week
club_hall_cacheplaintext由于接口 JSON 中包含 signed URL,SharedPreferences 也会保存当时接口返回的签名。
因此,下次启动读取的可能是:
最新业务内容 + 仍有效的 URL
或者
旧业务内容 + 已过期的 URLplaintext五、图片内存缓存#
所有 Widget 形式的网络图片统一经过:
第一层是 Flutter ImageCache:
- 保存已经解码的图片;
- 访问速度最快;
- App 重启后消失;
- 内存紧张时可能被系统淘汰;
- 使用归一化后的稳定媒体缓存键。
内存命中后,不再访问磁盘或 CDN。
六、图片磁盘缓存#
第二层是 cached_network_image_ce 的磁盘缓存:
当前配置:
- 全局单例;
- 最多缓存 1000 个对象;
stalePeriod约 100 年,实际不按时间主动过期;- 超过容量后按 LRU 淘汰;
- 用户可以在设置页手动清理;
- 升级到 CE 缓存实现时,一次性清理旧图片缓存目录并记录完成标记;
- 元数据使用 Hive CE,图片内容保存在文件系统。
磁盘缓存 Key 由下面的工具生成:
例如:
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.webpplaintext因此新旧 signed URL 共用同一个图片文件缓存。
七、原问题#
旧业务缓存中的 signed URL 可能已经过期。
flowchart TD
A["进入页面"] --> B["读取旧业务 JSON"]
B --> C["立即构建图片组件"]
C --> D{"内存/磁盘图片命中?"}
D -- 是 --> SHOW["立即显示"]
D -- 否 --> OLD["使用旧 signed URL 请求 CDN"]
OLD --> CHECK{"签名有效?"}
CHECK -- 是 --> SAVE["缓存并显示"]
CHECK -- 否 --> ERR["CDN 返回 403"]
ERR --> FALLBACK["显示兜底图"]
A --> API["后台请求最新业务接口"]
API --> NEW["返回新 signed URL"]
NEW --> REBUILD["页面数据更新"]
REBUILD --> SAME["新旧 URL 对应相同 cacheKey"]
SAME --> PROBLEM["Flutter 可能复用旧失败的图片流"]mermaid原问题包含两部分:
- 磁盘未命中时,只能使用业务 JSON 中的 signed URL;
- 稳定 cacheKey 可能让 Flutter 认为新旧 URL 是同一个图片 Provider,从而抑制新签名的重新加载。
稳定 cacheKey 本身没有错,它保证同一 objectKey 不会产生多份磁盘缓存。缺失的是:
signed URL 变化后,需要重新创建底层图片 Widget。
八、为什么不等待最新业务 JSON#
生产实测表明,最新业务 JSON 通常需要数百毫秒,弱网可能需要数秒。
而旧 URL 失效需要同时满足:
业务缓存中的 URL 已超过 7 天
并且
对应图片未命中内存和磁盘缓存plaintext因此不能为了少量过期情况,让所有未缓存图片等待最新接口。
最终策略是:
旧 signed URL 乐观加载
+
最新业务 JSON 后台刷新plaintext九、新版加载流程#
flowchart TD
A["进入页面"] --> B["立即恢复旧业务 JSON"]
B --> C["立即构建图片 Widget"]
C --> D{"内存/磁盘缓存命中?"}
D -- 是 --> SHOW["立即显示图片"]
D -- 否 --> OLD["立即请求旧 signed URL"]
A --> API["后台请求最新业务接口"]
OLD --> OK{"旧 URL 是否成功?"}
OK -- 是 --> SAVE["按 objectKey 缓存并显示"]
OK -- 否 --> FALLBACK["暂时显示兜底图"]
API --> NEW["返回新 signed URL"]
NEW --> SP["覆盖 SharedPreferences"]
NEW --> UPDATE["setState 更新业务数据"]
UPDATE --> KEY["底层 Widget Key 随 imageUrl 变化"]
KEY --> RECREATE["重新创建 CE 图片 Widget"]
RECREATE --> CACHE{"稳定 objectKey 缓存命中?"}
CACHE -- 是 --> SHOW
CACHE -- 否 --> RETRY["使用新 signed URL 请求 CDN"]
RETRY --> SAVEmermaid核心代码关系:
底层 Widget 实例身份
= ValueKey(imageUrl)
内存/磁盘缓存身份
= MediaCacheKey.fromUrl(imageUrl)
= objectKeyplaintext也就是说:
signed URL 变化
→ 图片 Widget 可以重建
objectKey 不变
→ 内存和磁盘缓存仍然复用plaintext十、不同场景下的行为#
图片已经缓存#
读取旧业务 JSON
→ 按 objectKey 命中图片缓存
→ 立即显示
→ 不访问 CDNplaintext图片未缓存,但旧 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现在通过:
保证:
缓存先返回
→ 立即展示缓存
→ 随后由最新网络数据覆盖
网络先返回
→ 标记最新数据已应用
→ 较晚到达的本地缓存被丢弃plaintext首页最新和社团大厅本身按“先读取缓存,再请求网络”的顺序执行,因此不需要这个 Guard。
十二、最终缓存模型#
数据库媒体身份
= objectKey
CDN 边缘缓存 Key
= URL path / objectKey
App 磁盘图片缓存 Key
= objectKey
Flutter 内存图片缓存 Key
= 归一化后的 objectKey
当前图片 Widget 实例 Key
= signed URL
SharedPreferences
= 业务 JSON,包括接口当时返回的 signed URLplaintext最终原则:
objectKey 决定“这是哪张图片”,signed URL 决定“这次如何下载”。
当前方案保留了最快的旧 URL 乐观加载,同时让最新 signed URL 有机会修复旧请求失败,不需要新增补签接口、MediaRef、expiresAt 或图片状态机。