跳转到主要内容

crayonxiaoxin

Flutter 图片加载之谜:Provider 时序导致的“全有或全无”现象

前言

最近在开发 Flutter 应用时遇到一个诡异的图片加载 bug:源站列表的封面图要么全部加载不出来、要么一下子全部出现;进入播放页后详情封面一直卡在骨架屏,但全局背景图却能正常加载。排查过程涉及 Riverpod Provider 时序、图片代理机制和缓存策略,值得记录。

问题现象

1. 源站浏览页:进入页面后,所有海报封面同时显示骨架屏。等待数秒后,要么全部加载成功、要么全部失败。不存在”部分加载”的情况。

2. 播放页:详情卡片中的封面(96×144)始终显示骨架屏动画,但全局背景的模糊大图(通过 LayoutBackdrop 渲染)却能正常加载。

两个现象看似独立,实则指向同一个根因。

技术背景

应用使用了以下图片加载链路:

原始图片 URL
    ↓
resolveProxiedImageUrl()
    ├─ 若域名在 imageProxyDomains 中 → /api/proxy/image?url=...(通过服务端代理绕过防盗链)
    └─ 否则 → 使用原始 URL
    ↓
CachedNetworkImage(YamImageCacheManager,14天磁盘缓存)

关键信息:apiBaseProviderpublicRuntimeProvider 是 Riverpod 的 FutureProvider,异步获取 API 地址和运行时配置(含 imageProxyDomains)。

根因分析

问题一:Provider 时序与 URL 切换

ProxiedNetworkImagebuild 方法中:

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?.valuenulleffectiveLoadUrl 返回原始 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 填充缓存

YamImageCacheManagerstalePeriod: 14 daysmaxNrOfCacheObjects: 500 完全不变,但缓存命中率显著提升。

总结

核心教训:使用 FutureProvider 时,ref.read()ref.watch() 的区别至关重要。ref.watch() 会随状态变化重建 widget,但如果重建导致 CachedNetworkImageimageUrl 变化,代价是丢弃整个加载状态。确保 imageUrl 在组件的生命周期内保持不变,比”尽早显示占位图”更重要。

讨论

还没有留言,来留下第一条评论吧!

留下足迹