前言
最近在开发 Flutter 应用时遇到一个诡异的图片加载 bug:源站列表的封面图要么全部加载不出来、要么一下子全部出现;进入播放页后详情封面一直卡在骨架屏,但全局背景图却能正常加载。排查过程涉及 Riverpod Provider 时序、图片代理机制和缓存策略,值得记录。
问题现象
1. 源站浏览页:进入页面后,所有海报封面同时显示骨架屏。等待数秒后,要么全部加载成功、要么全部失败。不存在”部分加载”的情况。
2. 播放页:详情卡片中的封面(96×144)始终显示骨架屏动画,但全局背景的模糊大图(通过 LayoutBackdrop 渲染)却能正常加载。
两个现象看似独立,实则指向同一个根因。
技术背景
应用使用了以下图片加载链路:
原始图片 URL
↓
resolveProxiedImageUrl()
├─ 若域名在 imageProxyDomains 中 → /api/proxy/image?url=...(通过服务端代理绕过防盗链)
└─ 否则 → 使用原始 URL
↓
CachedNetworkImage(YamImageCacheManager,14天磁盘缓存)
关键信息:apiBaseProvider 和 publicRuntimeProvider 是 Riverpod 的 FutureProvider,异步获取 API 地址和运行时配置(含 imageProxyDomains)。
根因分析
问题一:Provider 时序与 URL 切换
ProxiedNetworkImage 的 build 方法中:
final api = ref.watch(apiBaseProvider); // FutureProvider<String?>
final rt = ref.watch(publicRuntimeProvider); // FutureProvider<RuntimeDto?>
final resolved = effectiveLoadUrl(url, api, rt);
CachedNetworkImage(imageUrl: resolved, ...)
当 FutureProvider 正在加载时,api.asData?.value 为 null,effectiveLoadUrl 返回原始 URL。Provider 就绪后,resolved 切换为代理 URL。
这个切换导致所有 CachedNetworkImage 实例同时:
1. 取消原始 URL 的请求(可能正在加载或已失败)
2. 用代理 URL 重新开始下载
3. 再次显示骨架屏
原始 URL 通常被源站防盗链拦截(403),所以用户看到的是”全都不加载”;代理 URL 就绪后所有图片”突然”同时开始加载——这就是”全有或全无”现象。
问题二:为什么全局背景不卡骨架屏
LayoutBackdrop 使用了完全不同的加载策略:
Image.network(
resolved,
gaplessPlayback: true, // ← 关键
// ...
)
Image.network 不受 CacheManager 管理,且 gaplessPlayback: true 保证 URL 切换时旧帧不会消失。配合 AnimatedSwitcher 的 420ms 交叉淡入,URL 切换是平滑的。
而 CachedNetworkImage 没有 gaplessPlayback 的语义——imageUrl 变化就丢弃旧状态,重新显示 progressIndicatorBuilder(即骨架屏)。
问题三:预加载用错 URL
源站页面的 _preloadPosterImages() 使用 ref.read() 读取 FutureProvider:
void _preloadPosterImages() {
final api = ref.read(apiBaseProvider); // 可能还是 AsyncLoading!
final rt = ref.read(publicRuntimeProvider); // 同上!
final url = effectiveLoadUrl(raw, api, rt); // URL 错了
cache.getFileStream(url).drain(); // 预加载了个寂寞
}
Provider 未就绪 → 预加载用原始 URL → 请求失败或缓存浪费。等 provider 就绪后 widget 重建用代理 URL,预加载的缓存对不上。
控制台的 ClientException
预加载的 .drain() 使用 unawaited,如果图片服务器断开连接(ClientException: Connection closed before full header was received),Future 错误无人捕获,输出到控制台。
解决方案
修复一:等待 Provider 就绪再渲染
@override
Widget build(BuildContext context, WidgetRef ref) {
final api = ref.watch(apiBaseProvider);
final rt = ref.watch(publicRuntimeProvider);
// Provider 未就绪时只显示骨架屏
if (api is AsyncLoading || rt is AsyncLoading) {
return _buildSkeleton(context);
}
// Provider 就绪后,resolved URL 不会中途变化
final resolved = effectiveLoadUrl(url, api, rt);
CachedNetworkImage(
imageUrl: resolved, // ← 终身不变
// ...
);
}
核心思路:不要用一个”临时值”去渲染,等真正的值就绪再说。Provider 一旦就绪就会永久缓存(无 autoDispose),这个等待只在首次访问时发生一次。
修复二:预加载正确 await Provider
Future<void> _preloadPosterImages() async {
// await 确保 provider 就绪,拿到正确的值
final api = await ref.read(apiBaseProvider.future).catchError((_) => null);
final rt = await ref.read(publicRuntimeProvider.future).catchError((_) => null);
for (final item in _items) {
final url = effectiveLoadUrl(raw, AsyncData(api), AsyncData(rt));
// 吞掉网络错误,不污染控制台
unawaited(cache.getFileStream(url).drain<void>().catchError((_) {}));
}
}
修复三:两处预加载同时修复
同样的 unawaited(drain()) 模式也存在于 source_site_short_screen.dart,一并修复。
缓存有效性分析
修复后缓存反而更有效了:
- 修复前:先用原始 URL 请求(失败或缓存浪费)→ 切换代理 URL 时
CachedNetworkImage丢弃旧缓存 → 缓存命中率为零 - 修复后:
resolved终身不变 → 第一次加载缓存 miss,写入磁盘 → 第二次加载缓存 hit(14 天有效期,500 对象上限)→ 预加载也用正确 URL 填充缓存
YamImageCacheManager 的 stalePeriod: 14 days、maxNrOfCacheObjects: 500 完全不变,但缓存命中率显著提升。
总结
核心教训:使用 FutureProvider 时,ref.read() 和 ref.watch() 的区别至关重要。ref.watch() 会随状态变化重建 widget,但如果重建导致 CachedNetworkImage 的 imageUrl 变化,代价是丢弃整个加载状态。确保 imageUrl 在组件的生命周期内保持不变,比”尽早显示占位图”更重要。
讨论
还没有留言,来留下第一条评论吧!