Flutter 图片预加载

1,755 阅读6分钟

🚀 Flutter 图片预加载工具详解:支持并发加载、状态监听、SVG兼容

在构建 Flutter 应用时,提前加载图片资源是一种常见优化手段,尤其适用于展示型页面、动画首帧、轮播图等场景。本文将手把手讲解一个支持 批量并发预加载、状态追踪、SVG 加载支持 的工具类 ImagePreloader,帮助你打造更加丝滑的用户体验。


🎯 主要功能

功能支持
图片预加载(本地/网络)
SVG 图片预加载
状态缓存与查询
批量并发控制
加载状态流用于 UI 监听
手动清除与资源释放

1️⃣ 状态枚举:让图片“有迹可循” 📍

// 图片预加载状态枚举
enum ImagePreloadStatus {
  notStarted, // 未开始
  loading,    // 加载中
  success,    // 加载成功
  failed,     // 加载失败
}
  • 设计动机:用 4 个离散值就能完整描述下载生命周期。
  • 实战价值:UI 只要 switch(status) 就能渲染骨架屏 / 进度条 / 错误页。

2️⃣ 结果模型:所有信息都装进一只“快递盒” 📦

class ImagePreloadResult {
  final String imageUrl;                // 图片 URL
  final ImagePreloadStatus status;      // 当前状态
  final String? errorMessage;           // 失败原因
  final double progress;                // 进度 0.0~1.0

  ImagePreloadResult copyWith({ ... })  // 不可变 -> 复制更新
}
  • 不可变 + copyWith:每次状态更新都生成新实例,Stream 才能检测到变化💡。
  • progress:SVG & 位图最终都会写 1.0,后续可扩展成真正的分帧进度。

3️⃣ 单例管理器:全局只开一张“管控面板” 🛠️

class ImagePreloader {
  static final ImagePreloader _instance = ImagePreloader._internal();
  factory ImagePreloader() => _instance;           // 🔑 对外使用
  ImagePreloader._internal();

  final Map<String, ImagePreloadResult> _imageStatusMap = {};
  final _imageStatusController = StreamController<ImagePreloadResult>.broadcast();

  Stream<ImagePreloadResult> get imageStatusStream => _imageStatusController.stream;
}
  • 为什么用单例?

    1. 同一张图 只下载一次,避免多页面重复 I/O。
    2. broadcast() 让多个 StreamBuilder 同时监听,而不用各自拉流。

4️⃣ 识别 & 缓存 SVG:走“专用绿色通道” 🛣️

bool _isSvgImage(String url) => url.toLowerCase().contains('.svg');

Future<void> _precacheSvg(String imageUrl) async {
  final loader = imageUrl.startsWith('http')
      ? SvgNetworkLoader(imageUrl)
      : SvgAssetLoader(imageUrl);

  await svg.cache.putIfAbsent(               // flutter_svg 的内部缓存表
    loader.cacheKey(null),
    () => loader.loadBytes(null),
  );
}
  • 区别:SVG 不走 precacheImage,直接把字节流塞进 flutter_svg 自带缓存⚡。
  • 好处:跳过位图解码步骤,节省 CPU & 内存。

5️⃣ 核心流程 preloadImage(): 🏗️

Future<ImagePreloadResult> preloadImage(
  String imageUrl,
  BuildContext context, {
  ImageErrorListener? onError,
}) async {
  // 1. 命中缓存?
  if (_imageStatusMap[imageUrl]?.status == ImagePreloadStatus.success) {
    return _imageStatusMap[imageUrl]!;
  }

  // 2. 先广播 loading
  final result = ImagePreloadResult(imageUrl: imageUrl, status: ImagePreloadStatus.loading);
  _imageStatusMap[imageUrl] = result;
  _imageStatusController.add(result);

  try {
    if (_isSvgImage(imageUrl)) {                 // 3A. 走 SVG 流程
      await _precacheSvg(imageUrl);
      final ok = result.copyWith(status: ImagePreloadStatus.success, progress: 1.0);
      _imageStatusMap[imageUrl] = ok;
      _imageStatusController.add(ok);
      return ok;
    } else {                                     // 3B. 走位图流程
      final ImageProvider provider =
          imageUrl.startsWith('http') ? NetworkImage(imageUrl) : AssetImage(imageUrl);
      final completer = Completer<ImagePreloadResult>();

      await precacheImage(provider, context, onError: (e, s) {
        final fail = result.copyWith(status: ImagePreloadStatus.failed, errorMessage: e.toString());
        _imageStatusMap[imageUrl] = fail;
        _imageStatusController.add(fail);
        onError?.call(e, s);
        if (!completer.isCompleted) completer.complete(fail);
      });

      if (!completer.isCompleted) {              // 成功分支
        final ok = result.copyWith(status: ImagePreloadStatus.success, progress: 1.0);
        _imageStatusMap[imageUrl] = ok;
        _imageStatusController.add(ok);
        completer.complete(ok);
      }
      return completer.future;
    }
  } catch (e) {                                  // 4. 异常兜底
    final fail = result.copyWith(status: ImagePreloadStatus.failed, errorMessage: e.toString());
    _imageStatusMap[imageUrl] = fail;
    _imageStatusController.add(fail);
    return fail;
  }
}

🔍 关注点

  1. 先查缓存 ➜ O(1) 返回,零耗时。
  2. loading 事件 出去后,UI 可立刻展示 Skeleton。
  3. SVG / Bitmap 分流,二者公用同一套“成功 / 失败”广播逻辑。
  4. Completer 保证 同步代码onError 回调 都能写入最终结果,不会出现“悬空 Future”。

6️⃣ 批量下载 preloadImages():并发池把洪水关进闸门 🌊🚪

Future<List<ImagePreloadResult>> preloadImages(
  List<String> urls,
  BuildContext ctx, {
  int concurrency = 5,                      // 默认 5 条并发
}) async {
  final pool = Pool(concurrency);
  final results = <ImagePreloadResult>[];

  await Future.wait(
    urls.map((u) => pool.withResource(() async {
      final r = await preloadImage(u, ctx);
      results.add(r);
    })),
  );
  return results;
}
  • pool.withResource() :同时只放 concurrency 个任务在跑,其余排队。
  • 优势:避免一次下 50 张大图,导致带宽/内存峰值🚩。

7️⃣ 配套查询 & 清理:像缓存管理后台一样周全 🧹

bool isImagePreloaded(String url) => _imageStatusMap[url]?.status == ImagePreloadStatus.success;

void clearImage(String url)   => _imageStatusMap.remove(url);   // 删单张
void clearAllImages()         => _imageStatusMap.clear();       // 全清
void dispose()                => _imageStatusController.close(); // 组件卸载时调用

⚠️ 最佳实践

  • ImagePreloader 绑定到独立模块,可在模块 dispose() 时关闭 Stream。

🎯 一图胜千言:调用示例

1.gif

import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';

flu
import 'image_preloader.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Image Preload Demo',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.teal),
      home: const PreloadDemoPage(),
    );
  }
}

class PreloadDemoPage extends StatefulWidget {
  const PreloadDemoPage({super.key});

  @override
  State<PreloadDemoPage> createState() => _PreloadDemoPageState();
}

class _PreloadDemoPageState extends State<PreloadDemoPage> {
  bool _preloaded = false;
  int _currentIndex = 0;

  final _preloader = ImagePreloader();

  List<String> noPreLoadImages = [
    "http://e.hiphotos.baidu.com/image/pic/item/a1ec08fa513d2697e542494057fbb2fb4316d81e.jpg",
    "http://c.hiphotos.baidu.com/image/pic/item/30adcbef76094b36de8a2fe5a1cc7cd98d109d99.jpg",
    "http://h.hiphotos.baidu.com/image/pic/item/7c1ed21b0ef41bd5f2c2a9e953da81cb39db3d1d.jpg",
    "http://g.hiphotos.baidu.com/image/pic/item/55e736d12f2eb938d5277fd5d0628535e5dd6f4a.jpg",
    "http://e.hiphotos.baidu.com/image/pic/item/4e4a20a4462309f7e41f5cfe760e0cf3d6cad6ee.jpg",
    "http://b.hiphotos.baidu.com/image/pic/item/9d82d158ccbf6c81b94575cfb93eb13533fa40a2.jpg",
    "http://e.hiphotos.baidu.com/image/pic/item/4bed2e738bd4b31c1badd5a685d6277f9e2ff81e.jpg",
    "http://g.hiphotos.baidu.com/image/pic/item/0d338744ebf81a4c87a3add4d52a6059252da61e.jpg",
    "http://a.hiphotos.baidu.com/image/pic/item/f2deb48f8c5494ee5080c8142ff5e0fe99257e19.jpg",
  ];
  List<String> preLoadImages = [
    "http://f.hiphotos.baidu.com/image/pic/item/4034970a304e251f503521f5a586c9177e3e53f9.jpg",
    "http://b.hiphotos.baidu.com/image/pic/item/279759ee3d6d55fbb3586c0168224f4a20a4dd7e.jpg",
    "http://a.hiphotos.baidu.com/image/pic/item/e824b899a9014c087eb617650e7b02087af4f464.jpg",
    "http://c.hiphotos.baidu.com/image/pic/item/9c16fdfaaf51f3de1e296fa390eef01f3b29795a.jpg",
    "http://d.hiphotos.baidu.com/image/pic/item/b58f8c5494eef01f119945cbe2fe9925bc317d2a.jpg",
    "http://h.hiphotos.baidu.com/image/pic/item/902397dda144ad340668b847d4a20cf430ad851e.jpg",
    "http://b.hiphotos.baidu.com/image/pic/item/359b033b5bb5c9ea5c0e3c23d139b6003bf3b374.jpg",
    "http://a.hiphotos.baidu.com/image/pic/item/8d5494eef01f3a292d2472199d25bc315d607c7c.jpg",
    "http://b.hiphotos.baidu.com/image/pic/item/e824b899a9014c08878b2c4c0e7b02087af4f4a3.jpg",
  ];

  @override
  void initState() {
    super.initState();
    _preloader.imageStatusStream.listen((result) {
      debugPrint('[Stream] ${result.imageUrl} -> ${result.status}');
    });
    WidgetsBinding.instance.addPostFrameCallback((a){
      _startPreload();
    });
  }

  Future<void> _startPreload() async {
    await _preloader.preloadImages(preLoadImages, context);
    setState(() {
      _preloaded = true;
    });
  }

  Widget _buildImage(String url) {
    if (url.endsWith('.svg')) {
      return SvgPicture.network(
        url,
        placeholderBuilder: (_) => const CircularProgressIndicator(),
      );
    } else {
      return Image.network(url, fit: BoxFit.cover);
    }
  }

  Widget _buildPreloadedImage(String url) {
    final status = _preloader.getImageStatus(url);
    if (status?.status == ImagePreloadStatus.success) {
      if (url.endsWith('.svg')) {
        return SvgPicture.network(url);
      } else {
        return Image.network(url);
      }
    } else if (status?.status == ImagePreloadStatus.loading) {
      return const Center(child: CircularProgressIndicator());
    } else if (status?.status == ImagePreloadStatus.failed) {
      return const Icon(Icons.error, color: Colors.red);
    } else {
      return const SizedBox.shrink();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("图片预加载对比 Demo")),
      body: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          children: [
            const Text('❌ 未预加载'),
            const SizedBox(height: 12),
            Container(
              width: double.infinity,
              height: 300,
              child: PageView.builder(
                  itemCount: noPreLoadImages.length,
                  itemBuilder: (context, index) {
                    return _buildImage(noPreLoadImages[index]);
                  }),
            ),
            const SizedBox(width: 12),
            const Text('✅ 预加载后显示'),
            const SizedBox(height: 12),
            Container(
              width: double.infinity,
              height: 300,
              child: PageView.builder(
                  itemCount: preLoadImages.length,
                  itemBuilder: (context, index) {
                    return _buildPreloadedImage(preLoadImages[index]);
                  }),
            ),
          ],
        ),
      ),
    );
  }
}
  • 一次调用 ➜ 全部图片进缓存,页面滑动时零闪烁
  • 只需一个 StreamBuilder,所有图片状态尽收眼底👀。

6️⃣ 完整代码


import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:pool/pool.dart';

// 图片预加载状态枚举
enum ImagePreloadStatus {
  notStarted, // 未开始
  loading, // 加载中
  success, // 加载成功
  failed, // 加载失败
}

// 图片预加载结果数据类
class ImagePreloadResult {
  final String imageUrl; // 图片URL
  final ImagePreloadStatus status; // 当前加载状态
  final String? errorMessage; // 错误信息(失败时使用)
  final double progress; // 加载进度(0.0-1.0)

  ImagePreloadResult({
    required this.imageUrl,
    required this.status,
    this.errorMessage,
    this.progress = 0.0,
  });

  // 复制并更新属性的便捷方法
  ImagePreloadResult copyWith({
    String? imageUrl,
    ImagePreloadStatus? status,
    String? errorMessage,
    double? progress,
  }) {
    return ImagePreloadResult(
      imageUrl: imageUrl ?? this.imageUrl,
      status: status ?? this.status,
      errorMessage: errorMessage ?? this.errorMessage,
      progress: progress ?? this.progress,
    );
  }
}

// 图片预加载管理器(单例模式)
class ImagePreloader {
  // 单例实例
  static final ImagePreloader _instance = ImagePreloader._internal();

  factory ImagePreloader() => _instance;

  ImagePreloader._internal();

  // 存储图片加载状态的映射表
  final Map<String, ImagePreloadResult> _imageStatusMap = {};

  // 用于广播加载状态变化的StreamController
  final _imageStatusController =
      StreamController<ImagePreloadResult>.broadcast();

  // 暴露给外部的状态流
  Stream<ImagePreloadResult> get imageStatusStream =>
      _imageStatusController.stream;

  // 获取不可修改的已加载图片状态映射
  Map<String, ImagePreloadResult> get preloadedImages =>
      Map.unmodifiable(_imageStatusMap);

  // 判断是否为SVG图片
  bool _isSvgImage(String imageUrl) {
    return imageUrl.toLowerCase().contains('.svg');
  }

  // 预加载SVG图片
  Future<void> _precacheSvg(String imageUrl) async {
    final loader =
        imageUrl.startsWith('http://') || imageUrl.startsWith('https://')
            ? SvgNetworkLoader(imageUrl)
            : SvgAssetLoader(imageUrl);

    await svg.cache.putIfAbsent(
      loader.cacheKey(null),
      () => loader.loadBytes(null),
    );
  }

  // 核心方法:预加载单个图片
  Future<ImagePreloadResult> preloadImage(
    String imageUrl,
    BuildContext context, {
    ImageErrorListener? onError,
  }) async {
    // 如果图片已成功加载,直接返回缓存结果
    if (_imageStatusMap[imageUrl]?.status == ImagePreloadStatus.success) {
      return _imageStatusMap[imageUrl]!;
    }

    // 初始化加载状态
    final result = ImagePreloadResult(
      imageUrl: imageUrl,
      status: ImagePreloadStatus.loading,
    );
    _imageStatusMap[imageUrl] = result;
    _imageStatusController.add(result);

    try {
      // 检查是否为SVG图片
      if (_isSvgImage(imageUrl)) {
        // 使用SVG专用方法预加载
        await _precacheSvg(imageUrl);

        // 成功处理
        final successResult = result.copyWith(
          status: ImagePreloadStatus.success,
          progress: 1.0,
        );
        _imageStatusMap[imageUrl] = successResult;
        _imageStatusController.add(successResult);
        return successResult;
      } else {
        // 非SVG图片处理
        // 根据URL类型创建对应的ImageProvider
        ImageProvider imageProvider;
        if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
          imageProvider = NetworkImage(imageUrl);
        } else {
          imageProvider = AssetImage(imageUrl);
        }

        final completer = Completer<ImagePreloadResult>();

        // 使用Flutter原生方法预加载图片
        await precacheImage(
          imageProvider,
          context,
          onError: (exception, stackTrace) {
            // 错误处理
            final errorResult = result.copyWith(
              status: ImagePreloadStatus.failed,
              errorMessage: exception.toString(),
            );
            _imageStatusMap[imageUrl] = errorResult;
            _imageStatusController.add(errorResult);

            // 调用自定义错误回调
            if (onError != null) {
              onError(exception, stackTrace);
            }

            // 完成Completer
            if (!completer.isCompleted) {
              completer.complete(errorResult);
            }
          },
        );

        // 成功处理
        if (!completer.isCompleted) {
          final successResult = result.copyWith(
            status: ImagePreloadStatus.success,
            progress: 1.0,
          );
          _imageStatusMap[imageUrl] = successResult;
          _imageStatusController.add(successResult);
          completer.complete(successResult);
        }

        return await completer.future;
      }
    } catch (e) {
      // 异常处理
      final errorResult = result.copyWith(
        status: ImagePreloadStatus.failed,
        errorMessage: e.toString(),
      );
      _imageStatusMap[imageUrl] = errorResult;
      _imageStatusController.add(errorResult);
      return errorResult;
    }
  }

  // 批量预加载方法
  Future<List<ImagePreloadResult>> preloadImages(
    List<String> imageUrls,
    BuildContext context, {
    ImageErrorListener? onError,
    int concurrency = 5,
  }) async {
    final pool = Pool(concurrency);
    final results = <ImagePreloadResult>[];

    await Future.wait(
      imageUrls.map(
        (url) => pool.withResource(() async {
          final result = await preloadImage(url, context, onError: onError);
          results.add(result);
          return result;
        }),
      ),
    );

    return results;
  }
  // Future<List<ImagePreloadResult>> preloadImages(
  //     List<String> imageUrls,
  //     BuildContext context, {
  //       ImageErrorListener? onError,
  //     }) async {
  //   final futures = imageUrls.map(
  //         (url) => preloadImage(url, context, onError: onError),
  //   );
  //   return await Future.wait(futures);
  // }
  // Future<List<ImagePreloadResult>> preloadImages(
  //   List<String> imageUrls,
  //   BuildContext context, {
  //   ImageErrorListener? onError,
  //   int concurrency = 15,
  // }) async {
  //   final results = <ImagePreloadResult>[];
  //   final queue = Queue.from(imageUrls);
  //   final activeTasks = <Future<void>>[];
  //   final completed = Completer<void>();
  //
  //   void scheduleNext() {
  //     while (activeTasks.length < concurrency && queue.isNotEmpty) {
  //       final url = queue.removeFirst();
  //       late final Future<void> task;
  //       task = preloadImage(
  //         url,
  //         context,
  //         onError: onError,
  //       ).then((result) => results.add(result)).whenComplete(() {
  //         activeTasks.remove(task);
  //         scheduleNext();
  //       });
  //       activeTasks.add(task);
  //     }
  //
  //     if (activeTasks.isEmpty && queue.isEmpty) {
  //       completed.complete();
  //     }
  //   }
  //
  //   scheduleNext();
  //   await completed.future;
  //   return results;
  // }

  // 检查图片是否已预加载
  bool isImagePreloaded(String imageUrl) {
    return _imageStatusMap[imageUrl]?.status == ImagePreloadStatus.success;
  }

  // 获取指定图片的状态
  ImagePreloadResult? getImageStatus(String imageUrl) {
    return _imageStatusMap[imageUrl];
  }

  // 清除单个图片缓存
  void clearImage(String imageUrl) {
    _imageStatusMap.remove(imageUrl);
  }

  // 清除所有图片缓存
  void clearAllImages() {
    _imageStatusMap.clear();
  }

  // 释放资源
  void dispose() {
    _imageStatusController.close();
  }
}

🏁 收官小贴士

场景建议
首屏 / Banner进入首页前 await preloadImages(),确保立刻可见。
无限列表滑到第 N 页时把第 N+1 页的图片批量预热。
内存控制大批量下载 ➜ 调低 concurrency;退出页面 ➜ clearAllImages()