Flutter 通用网络图片封装实战:带占位 / 错误 / 缓存的 CachedImageWidget

导语

原生 Image.network 存在加载无占位、错误无兜底、无缓存、样式单一等痛点,不仅影响用户体验,还会造成不必要的流量浪费。本文基于 cached_network_image 封装通用网络图片组件 CachedImageWidget,集成占位加载、错误重试、本地缓存、圆角 / 圆形裁剪等核心功能,通过精细化优化适配所有图片展示场景,实现 "一次封装,全域复用"!

一、核心需求升级拆解

在基础需求上强化实用性与体验感,覆盖更多业务场景:✅ 加载中支持骨架屏 / 自定义占位图,视觉过渡更自然✅ 加载失败显示兜底图,支持点击重试,提升容错性✅ 本地缓存自动管理,可配置缓存时长与最大缓存数量✅ 支持圆角、圆形、边框样式,无需额外嵌套组件✅ 加载完成渐变动画,优化视觉体验✅ 适配暗黑模式,样式统一协调✅ 支持图片预加载,提前缓存高频访问图片

二、依赖准备(稳定版)

yaml

复制代码
dependencies:
  flutter:
    sdk: flutter
  cached_network_image: ^3.3.1 # 图片缓存核心依赖(支持缓存、占位、错误处理)
  flutter_cache_manager: ^3.3.1 # 缓存管理(自定义缓存策略)

执行命令安装依赖:

bash

运行

复制代码
flutter pub get

三、完整代码实现(带优化注释)

dart

复制代码
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'skeleton_widget.dart'; // 引入之前封装的骨架屏组件(无则可替换为默认占位)

/// 通用网络图片组件(支持缓存、占位、错误重试、样式自定义)
class CachedImageWidget extends StatefulWidget {
  // 必选参数
  final String imageUrl; // 图片网络地址(支持http/https)
  final double width; // 图片宽度(必填,避免布局抖动)
  final double height; // 图片高度(必填,避免布局抖动)

  // 可选参数(带合理默认值,降低使用成本)
  final BoxFit fit; // 图片缩放模式(默认cover,保持比例填充)
  final double borderRadius; // 圆角半径(默认0,不圆角)
  final bool isCircle; // 是否圆形裁剪(优先级高于圆角,默认false)
  final Widget? placeholder; // 自定义占位图(优先级高于骨架屏)
  final Widget? errorWidget; // 自定义错误图(优先级高于默认错误图)
  final Duration fadeDuration; // 加载完成渐变动画时长(默认300ms)
  final bool enableRetry; // 是否允许点击重试(默认true)
  final int cacheDurationDays; // 缓存有效期(默认7天)
  final int maxCacheCount; // 最大缓存数量(默认100张,避免内存占用过多)
  final Color? borderColor; // 图片边框颜色(默认无边框)
  final double borderWidth; // 边框宽度(默认0,无边框)
  final bool preload; // 是否预加载图片(默认false,需主动触发时设为true)

  const CachedImageWidget({
    super.key,
    required this.imageUrl,
    required this.width,
    required this.height,
    this.fit = BoxFit.cover,
    this.borderRadius = 0.0,
    this.isCircle = false,
    this.placeholder,
    this.errorWidget,
    this.fadeDuration = const Duration(milliseconds: 300),
    this.enableRetry = true,
    this.cacheDurationDays = 7,
    this.maxCacheCount = 100,
    this.borderColor,
    this.borderWidth = 0.0,
    this.preload = false,
  });

  @override
  State<CachedImageWidget> createState() => _CachedImageWidgetState();
}

class _CachedImageWidgetState extends State<CachedImageWidget> {
  bool _isLoading = true; // 加载中状态
  bool _isError = false; // 加载错误状态
  late CacheManager _cacheManager; // 自定义缓存管理器

  @override
  void initState() {
    super.initState();
    // 初始化缓存管理器
    _cacheManager = CacheManager(
      Config(
        'flutter_cached_images', // 缓存目录名称
        stalePeriod: Duration(days: widget.cacheDurationDays), // 缓存有效期
        maxNrOfCacheObjects: widget.maxCacheCount, // 最大缓存数量
        repo: JsonCacheInfoRepository(databaseName: 'cached_images.db'),
        fileService: HttpFileService(),
      ),
    );

    // 预加载图片(仅当preload为true时触发)
    if (widget.preload) {
      _preloadImage();
    }
  }

  @override
  void dispose() {
    // 释放缓存管理器资源
    _cacheManager.dispose();
    super.dispose();
  }

  /// 预加载图片到缓存
  Future<void> _preloadImage() async {
    try {
      await _cacheManager.downloadFile(widget.imageUrl);
      if (mounted) {
        setState(() => _isLoading = false);
      }
    } catch (e) {
      if (mounted) {
        setState(() => _isError = true);
      }
    }
  }

  /// 重新加载图片
  void _reloadImage() {
    if (_isError && mounted) {
      setState(() {
        _isError = false;
        _isLoading = true;
      });
      // 清除该图片的缓存后重新加载
      _cacheManager.removeFile(widget.imageUrl).then((_) {
        if (mounted) {
          setState(() {}); // 触发重建,重新加载图片
        }
      });
    }
  }

  /// 构建基础裁剪与边框样式
  Widget _buildImageContainer(Widget child) {
    // 边框配置
    final BoxBorder? border = widget.borderWidth > 0 && widget.borderColor != null
        ? Border.all(
            color: widget.borderColor!,
            width: widget.borderWidth,
          )
        : null;

    // 圆形/圆角裁剪 + 边框
    return ClipRRect(
      borderRadius: widget.isCircle
          ? BorderRadius.circular(widget.height / 2) // 圆形:半径=高度/2
          : BorderRadius.circular(widget.borderRadius),
      child: Container(
        width: widget.width,
        height: widget.height,
        decoration: BoxDecoration(
          border: border,
          borderRadius: widget.isCircle
              ? BorderRadius.circular(widget.height / 2)
              : BorderRadius.circular(widget.borderRadius),
        ),
        child: child,
      ),
    );
  }

  /// 构建默认占位图(骨架屏)
  Widget _buildDefaultPlaceholder() {
    return SkeletonWidget(
      width: widget.width,
      height: widget.height,
      isCircle: widget.isCircle,
      borderRadius: widget.borderRadius,
    );
  }

  /// 构建默认错误图
  Widget _buildDefaultErrorWidget() {
    return Container(
      width: widget.width,
      height: widget.height,
      color: Colors.grey.shade100,
      alignment: Alignment.center,
      child: const Icon(
        Icons.broken_image_outlined,
        color: Colors.grey,
        size: 32,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    // 基础图片组件(缓存+加载状态管理)
    final Widget imageContent = CachedNetworkImage(
      imageUrl: widget.imageUrl,
      width: widget.width,
      height: widget.height,
      fit: widget.fit,
      fadeInDuration: widget.fadeDuration,
      fadeOutDuration: const Duration(milliseconds: 200),
      cacheManager: _cacheManager,
      // 加载中占位图
      placeholder: (context, url) => widget.placeholder ?? _buildDefaultPlaceholder(),
      // 加载错误处理
      errorWidget: (context, url, error) {
        if (mounted) {
          setState(() {
            _isLoading = false;
            _isError = true;
          });
        }
        // 错误图 + 点击重试
        final errorContent = widget.errorWidget ?? _buildDefaultErrorWidget();
        return widget.enableRetry
            ? GestureDetector(
                onTap: _reloadImage,
                behavior: HitTestBehavior.opaque,
                child: errorContent,
              )
            : errorContent;
      },
      // 加载完成回调
      imageBuilder: (context, imageProvider) {
        if (mounted) {
          setState(() {
            _isLoading = false;
            _isError = false;
          });
        }
        return Container(
          decoration: BoxDecoration(
            image: DecorationImage(
              image: imageProvider,
              fit: widget.fit,
            ),
          ),
        );
      },
    );

    // 组合裁剪、边框、图片内容
    return _buildImageContainer(imageContent);
  }
}

四、丰富使用示例(覆盖全场景)

dart

复制代码
class CachedImageDemo extends StatelessWidget {
  const CachedImageDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('通用网络图片示例')),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('1. 圆形头像(带骨架占位+边框)'),
            const SizedBox(height: 10),
            // 圆形头像(带边框+骨架占位)
            CachedImageWidget(
              imageUrl: 'https://example.com/avatar.png',
              width: 60,
              height: 60,
              isCircle: true,
              borderColor: Colors.blue,
              borderWidth: 2,
              cacheDurationDays: 30, // 头像缓存30天
            ),
            const SizedBox(height: 20),

            const Text('2. 矩形图片(自定义错误图+圆角)'),
            const SizedBox(height: 10),
            // 矩形图片(自定义错误图+圆角)
            CachedImageWidget(
              imageUrl: 'https://example.com/banner.png',
              width: double.infinity,
              height: 200,
              borderRadius: 12,
              fit: BoxFit.cover,
              errorWidget: Container(
                color: Colors.grey.shade100,
                alignment: Alignment.center,
                child: const Text(
                  '图片加载失败\n点击重试',
                  textAlign: TextAlign.center,
                  style: TextStyle(color: Colors.grey),
                ),
              ),
              enableRetry: true,
            ),
            const SizedBox(height: 20),

            const Text('3. 列表项图片(预加载+小圆角)'),
            const SizedBox(height: 10),
            // 列表项图片(预加载+小圆角)
            CachedImageWidget(
              imageUrl: 'https://example.com/item.jpg',
              width: 80,
              height: 80,
              borderRadius: 8,
              fit: BoxFit.cover,
              preload: true, // 预加载,提升列表滑动体验
              maxCacheCount: 50, // 列表图片缓存上限50张
            ),
            const SizedBox(height: 20),

            const Text('4. 暗黑模式适配图片(边框+渐变动画)'),
            const SizedBox(height: 10),
            // 暗黑模式适配(边框+渐变动画)
            CachedImageWidget(
              imageUrl: 'https://example.com/dark-mode.jpg',
              width: 150,
              height: 150,
              borderRadius: 8,
              borderColor: Theme.of(context).primaryColor,
              borderWidth: 1,
              fadeDuration: const Duration(milliseconds: 500), // 延长渐变动画
            ),
          ],
        ),
      ),
    );
  }
}

五、优化亮点与核心技巧

1. 功能增强(解决原生痛点)

  • 缓存精细化管理:支持配置缓存时长、最大缓存数量,避免无限制占用存储空间;自定义缓存目录与数据库名称,便于维护。
  • 预加载功能 :新增preload参数,列表、轮播图等场景可提前缓存图片,提升滑动流畅度。
  • 边框样式支持 :无需额外嵌套Container,直接通过borderColor和borderWidth配置边框,简化代码。
  • 错误重试优化:重试时先清除该图片的旧缓存,避免因缓存损坏导致的重复失败。

2. 体验与适配优化

  • 布局稳定性 :强制要求传入width和height,避免图片加载过程中布局抖动(原生Image.network常见问题)。
  • 视觉过渡自然:加载完成渐变动画、骨架屏占位,替代生硬的瞬间显示,提升用户体验。
  • 暗黑模式适配 :边框颜色支持通过Theme.of(context)获取,自动适配亮色 / 暗黑模式,样式统一。
  • 错误提示友好:默认错误图使用轮廓图标,视觉更轻盈;支持自定义错误文本,信息传达更清晰。

3. 稳定性与性能保障

  • 资源释放 :在dispose中释放CacheManager资源,避免内存泄漏。
  • 状态安全 :所有状态更新前判断mounted,防止组件销毁后触发重建导致崩溃。
  • 缓存容错:预加载失败时自动切换为错误状态,不影响页面正常展示。

六、避坑指南(开发者必看)

常见问题 表现形式 解决方案
图片加载无占位,布局抖动 图片未加载完成时显示空白,加载后布局偏移 确保传入固定width和height;使用骨架屏占位,避免依赖图片自身尺寸
缓存不生效,重复请求 每次打开页面都重新加载图片 检查cacheDurationDays是否设置合理;确认网络图片 URL 是否固定(动态 URL 会重新缓存)
圆形裁剪变形 圆形图片显示为椭圆 确保width和height相等;isCircle设为true时,圆角配置会失效,无需重复设置
点击重试无响应 错误状态下点击图片不触发重试 检查enableRetry是否为true;自定义errorWidget需确保GestureDetector的behavior设为HitTestBehavior.opaque
缓存占用过多 设备存储空间逐渐增大 合理设置maxCacheCount(建议列表类图片设为 50-100 张);定期清理过期缓存

总结

优化后的 CachedImageWidget 不仅解决了原生 Image.network 的核心痛点,还通过缓存精细化、样式自定义、体验优化等增强,适配了头像、列表项、轮播图、横幅等所有图片展示场景。其核心优势在于 "通用性强、配置灵活、体验优秀、稳定性高",是 Flutter 项目中图片展示的必备组件。

https://openharmonycrossplatform.csdn.net/content

相关推荐
噢,我明白了21 分钟前
JAVA中的一些基础应用知识点
java·开发语言
miss26 分钟前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan1 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真1 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment1 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan1 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
时间的拾荒人1 小时前
Redis 入门详解:核心特性、重要文件与全局命令实战
开发语言·redis·面试
总要学点什么1 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
敲上瘾2 小时前
Qt Widgets入门:项目模板解析与Hello World
开发语言·数据库·c++·qt·ui·qt6.3
上海懒图科技2 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue