213 lines
9.5 KiB
Markdown
213 lines
9.5 KiB
Markdown
# 【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:`<img src>` 的 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);
|
||
- 富文本里的 `<img>` 渲染见 [rich_text_demo.md](rich_text_demo.md);
|
||
- 图片墙布局用 [grid_demo.md](grid_demo.md) 的 GridView;网络图片预览(双指缩放)用 `photo_view`。
|