# 【Demo】Image 图片组件(四种来源 + BoxFit + 缓存 + 错误兜底) > 源文件:`lib/pages/demo/image_demo.dart` | 分类:组件封装 > 一句话:网络图(带缓存)/ 本地资源图 / 圆角图 / 圆形头像 -- 图片加载的四个必会姿势。 --- ## 一、这个 Demo 解决什么问题 图片是 App 最高频的组件,但新手常踩:加载失败白屏、图片变形、圆角/圆形不会切、网络图每次重下载不缓存。 本页用四个区块讲清: | 区块 | 用法 | 知识点 | |---|---|---| | ① 网络图片(带缓存) | `CachedNetworkImage` | `BoxFit.cover` + `progressIndicatorBuilder` 下载进度 + `errorWidget` 兜底 | | ② 本地资源图 | `Image.asset` | 必须在 pubspec.yaml 注册 assets | | ③ 圆角网络图 | `ClipRRect` 包 `CachedNetworkImage` | 图片本身没变,只是画出来带圆角 | | ④ 圆形头像 | `CircleAvatar` + `CachedNetworkImageProvider` | `backgroundImage` 走缓存 + 失败时显示首字母 | > 对照 Vue:`` 的 Flutter 版本,但多了"裁切模式"、"磁盘缓存"和"错误兜底"这三个必须处理的点。 --- ## 二、怎么运行 / 演示内容 ```dart runApp(const MaterialApp(home: ImageDemoPage())); ``` - ① 首次加载显示下载进度条;再次进入本页(或同 URL 出现在列表页)秒出 -- 走本地缓存,不重新下载; - ② 本地 logo 图等比完整显示; - ③ 圆角大图(radius 60 接近胶囊),加载中转圈; - ④ 圆形头像:加载失败时显示"头"字兜底。 - 断网/改错地址 -> 所有网络图都显示"图片加载失败"占位图(不白屏)。 --- ## 三、核心知识点(新手向) ### 1. 图片来源的几种方式 ```dart CachedNetworkImage(imageUrl: url) // 网络图片 + 本地缓存(生产推荐) CachedNetworkImageProvider(url) // 同上,但返回 ImageProvider(头像场景) Image.network(url) // 网络图片(无缓存,每次重下载,仅作对比) Image.asset('assets/...') // 本地资源(先注册 pubspec.yaml) Image.file(File(...)) // 本地磁盘文件(需 path_provider,本页不演示) Image.memory(bytes) // 内存字节流(base64 等,见 share_demo 截图预览) ``` ### 2. 为什么用 CachedNetworkImage 而不是 Image.network `Image.network` 每次都重新发 HTTP 请求下载图片,没缓存、列表页性能差、流量浪费。 `cached_network_image`(baseflow 维护,6.9k likes)底层用 `flutter_cache_manager` 把图片写入 本地缓存目录,二次加载直接读本地文件,对标 Web 端浏览器 HTTP 缓存。它还提供三段式回调, 比 `Image.network` 的 `loadingBuilder`(依赖 `ImageChunkEvent`,常常拿不到进度)更可靠: ```dart CachedNetworkImage( imageUrl: _netUrl, fit: BoxFit.cover, // 加载中占位 / 下载进度(两者二选一,progressIndicatorBuilder 更直观) progressIndicatorBuilder: (context, url, downloadProgress) => CircularProgressIndicator(value: downloadProgress.progress), // 失败兜底:断网/404/格式错 -> 占位图,不白屏 errorWidget: (context, url, error) => _buildErrorBox(), ) ``` > 注意:`placeholder` 和 `progressIndicatorBuilder` **二选一**,后者会覆盖前者。 > `downloadProgress.progress` 是 `double?`(总大小未知时为 null),需判空再赋给 `value`。 ### 2.1. 新增依赖后必须 Hot Restart(原生端常见“假死 loading”根因) 现象:你改了 `pubspec.yaml`、新增插件后,只点 Hot Reload,Android/iOS 模拟器里可能出现 `CachedNetworkImage` 一直处于 loading(不进 errorWidget、不出图);但完整重启后恢复正常。 原因:原生插件注册发生在启动阶段(GeneratedPluginRegistrant),Hot Reload 不会重新执行注册流程。 解决:停止运行 → 重新 `flutter run` / Hot Restart 一次。 ### 3. 头像场景用 CachedNetworkImageProvider ```dart CircleAvatar( backgroundImage: CachedNetworkImageProvider(_netUrl), // 同样走缓存 onBackgroundImageError: (error, stackTrace) {}, // 失败不崩 child: Text('头'), // 背景加载失败时显示首字母 ) ``` `CachedNetworkImage` 是 Widget,适合直接渲染;`CachedNetworkImageProvider` 是 ImageProvider, 适合塞给 `CircleAvatar.backgroundImage`、`BoxDecoration.image` 等需要 ImageProvider 的场景。 ### 4. BoxFit 裁切模式速记 | 值 | 行为 | 用途 | |---|---|---| | `cover` | 等比缩放填满并**裁掉多余** | 头像/封面(最常用) | | `contain` | 等比缩放**完整显示**(有留白) | logo、二维码 | | `fill` | **拉伸填满**(会变形) | 少用 | | `fitWidth` / `fitHeight` | 按宽/高撑满 | 特殊场景 | ### 5. 错误兜底:不白屏 ```dart CachedNetworkImage( imageUrl: _netUrl, width: 300.w, height: 180.h, fit: BoxFit.cover, errorWidget: (context, url, error) => _buildErrorBox(width: 300.w, height: 180.h), // 断网/404/格式错 -> 占位图 ) ``` Flutter 3.x 网络图**务必给 `errorWidget`**,否则加载失败抛异常白屏。 占位图 `_buildErrorBox` 接收和原图一样的宽高 -> 布局不跳变(加载中->失败,UI 不抖动)。 ### 6. 圆角 ≠ 图片属性,是"裁剪"组件 ```dart ClipRRect( borderRadius: BorderRadius.circular(60.r), // 裁剪器 child: CachedNetworkImage(...), // 图片本身没变 ) ``` --- ## 四、关键代码剖析 ```dart class ImageDemoPage extends StatelessWidget { const ImageDemoPage({super.key}); static const String _netUrl = 'https://hx.23544.com/StaticResource/2026-07-29/ranking3Position1785292048369.png'; /// 加载中占位:progress 为 null 转圈,非 null 显示百分比进度条 Widget _buildLoadingBox({ required double width, required double height, required double? progress, }) { return Container( width: width, height: height, color: const Color(0xFFF0F2F5), child: Center( child: progress == null ? SizedBox( width: 24.w, height: 24.w, child: const CircularProgressIndicator(strokeWidth: 2), ) : Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 24.w, height: 24.w, child: CircularProgressIndicator( value: progress, strokeWidth: 2), ), SizedBox(height: 8.h), Text('${(progress * 100).toInt()}%', style: TextStyle(fontSize: 12.sp, color: Color(0xFF888888))), ], ), ), ); } /// 统一的加载失败占位图(接收和原图一样的宽高 -> 布局不跳变) Widget _buildErrorBox({required double width, required double height}) { return Container( width: width, height: height, color: const Color(0xFFF0F2F5), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.broken_image_outlined, size: 40.w, color: Color(0xFFB0B4BC)), SizedBox(height: 8.h), Text('图片加载失败', style: TextStyle(fontSize: 13.sp, color: Color(0xFF888888))), ], ), ), ); } } ``` - `progressIndicatorBuilder` 用 `downloadProgress.progress` 驱动进度条,二次加载命中缓存时直接跳过该回调(秒出); - 占位图接收和原图一样的宽高 -> 加载中 -> 成功/失败,UI 不抖动。 --- ## 五、踩坑记录 1. **网络图必须 errorWidget/errorBuilder**:加载失败(断网/404/格式错)不给兜底就白屏; `CachedNetworkImage` 用 `errorWidget`,`Image.network` 用 `errorBuilder`; 2. **新增插件后必须 Hot Restart**:只 Hot Reload 可能导致原生插件未重新注册,表现为一直 loading; 3. **Image.asset 必须先注册**:`pubspec.yaml` 的 `assets:` 列表里没有的路径直接运行期报错; 4. Android **release 包要 INTERNET 权限**(`AndroidManifest.xml` 已加)--debug 默认有,release 没有会黑图; 5. 图片"变形"先查 `fit`:忘了写默认是 `fill` 拉伸,封面图要用 `cover`; 6. 圆角用 `ClipRRect` 包,**不是**给图片加 borderRadius(Image 没有这个属性,Container 才有); 7. `placeholder` 与 `progressIndicatorBuilder` **二选一**:同时写后者生效,前者被忽略; `downloadProgress.progress` 是 `double?`,总大小未知时为 null,直接塞给 `CircularProgressIndicator.value` 前要判空; 8. **缓存命中后不再回调进度**:二次加载直接读本地缓存,`progressIndicatorBuilder`/`placeholder` 都不会触发, 表现为"秒出" -- 这正是缓存生效的标志,不是没生效; 9. `Image.file` 在 Web 编译不过(`dart:io`),跨端项目用 `Image.memory` 替代(见 [note_picker_demo.md](note_picker_demo.md)); 10. `cached_network_image` 在 **Web 端缓存能力有限**(仅内存缓存,刷新即失效),原生端(Android/iOS)走磁盘缓存。 --- ## 六、扩展思考 - 多图选择页的缩略图渲染(原生/Web 双端)见 [note_picker_demo.md](note_picker_demo.md); - 截图字节流用 `Image.memory` 预览见 [share_demo.md](share_demo.md); - 富文本里的 `` 渲染见 [rich_text_demo.md](rich_text_demo.md); - 图片墙布局用 [grid_demo.md](grid_demo.md) 的 GridView;网络图片预览(双指缩放)用 `photo_view`。