9.5 KiB
【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 版本,但多了"裁切模式"、"磁盘缓存"和"错误兜底"这三个必须处理的点。
二、怎么运行 / 演示内容
runApp(const MaterialApp(home: ImageDemoPage()));
- ① 首次加载显示下载进度条;再次进入本页(或同 URL 出现在列表页)秒出 -- 走本地缓存,不重新下载;
- ② 本地 logo 图等比完整显示;
- ③ 圆角大图(radius 60 接近胶囊),加载中转圈;
- ④ 圆形头像:加载失败时显示"头"字兜底。
- 断网/改错地址 -> 所有网络图都显示"图片加载失败"占位图(不白屏)。
三、核心知识点(新手向)
1. 图片来源的几种方式
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,常常拿不到进度)更可靠:
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
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. 错误兜底:不白屏
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. 圆角 ≠ 图片属性,是"裁剪"组件
ClipRRect(
borderRadius: BorderRadius.circular(60.r), // 裁剪器
child: CachedNetworkImage(...), // 图片本身没变
)
四、关键代码剖析
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 不抖动。
五、踩坑记录
- 网络图必须 errorWidget/errorBuilder:加载失败(断网/404/格式错)不给兜底就白屏;
CachedNetworkImage用errorWidget,Image.network用errorBuilder; - 新增插件后必须 Hot Restart:只 Hot Reload 可能导致原生插件未重新注册,表现为一直 loading;
- Image.asset 必须先注册:
pubspec.yaml的assets:列表里没有的路径直接运行期报错; - Android release 包要 INTERNET 权限(
AndroidManifest.xml已加)--debug 默认有,release 没有会黑图; - 图片"变形"先查
fit:忘了写默认是fill拉伸,封面图要用cover; - 圆角用
ClipRRect包,不是给图片加 borderRadius(Image 没有这个属性,Container 才有); placeholder与progressIndicatorBuilder二选一:同时写后者生效,前者被忽略;downloadProgress.progress是double?,总大小未知时为 null,直接塞给CircularProgressIndicator.value前要判空;- 缓存命中后不再回调进度:二次加载直接读本地缓存,
progressIndicatorBuilder/placeholder都不会触发, 表现为"秒出" -- 这正是缓存生效的标志,不是没生效; Image.file在 Web 编译不过(dart:io),跨端项目用Image.memory替代(见 note_picker_demo.md);cached_network_image在 Web 端缓存能力有限(仅内存缓存,刷新即失效),原生端(Android/iOS)走磁盘缓存。
六、扩展思考
- 多图选择页的缩略图渲染(原生/Web 双端)见 note_picker_demo.md;
- 截图字节流用
Image.memory预览见 share_demo.md; - 富文本里的
<img>渲染见 rich_text_demo.md; - 图片墙布局用 grid_demo.md 的 GridView;网络图片预览(双指缩放)用
photo_view。