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,直接通过borderColorborderWidth配置边框,简化代码。
  • 错误重试优化:重试时先清除该图片的旧缓存,避免因缓存损坏导致的重复失败。

2. 体验与适配优化

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

3. 稳定性与性能保障

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

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

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

总结

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

https://openharmonycrossplatform.csdn.net/content

相关推荐
kong@react1 小时前
springbpoot项目,富文本,xss脚本攻击防护,jsoup
java·前端·spring boot·xss
资深web全栈开发1 小时前
从零构建即时通讯系统:Go + Vue3 实战指南
开发语言·后端·golang·im 通许
涵涵(互关)1 小时前
后端返回的id到前端接收时,id改变了
前端·状态模式
码上成长1 小时前
从零实现:react&Ts--批量导入 & Excel 模版下载功能
javascript·react.js·excel
小杍随笔1 小时前
【Zed 编辑器配置全攻略:自动保存、Prettier、终端字体与格式化设置一步到位】
开发语言·rust·编辑器
拾忆,想起1 小时前
Dubbo灰度发布完全指南:从精准引流到全链路灰度
前端·微服务·架构·dubbo·safari
liudongyang1231 小时前
EasyExcel使用模版填充的方式,导致单元格边框消失
前端·html
Predestination王瀞潞1 小时前
Python3:Fifteenth 类型注解(Type Hints)
开发语言·python
fie88891 小时前
Qt对Word网页的读写功能实现
开发语言·qt·word