# 【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`。