
打开一个新闻App,最先映入眼帘的是什么?图片。
新闻卡片上的配图、详情页的大图、用户头像......图片无处不在。但图片也是最"重"的资源:一张图片可能几百KB甚至几MB,加载慢、耗流量、占内存。
如果每次打开App都要重新下载所有图片,用户体验会很差。所以图片缓存是必须要做的事情。
今天这篇文章,咱们就来聊聊Flutter里怎么做图片缓存。不是简单地用个库就完事,而是深入理解它的原理,知道为什么这么做,遇到问题怎么解决。
图片加载的痛点
先想想,如果不做任何优化,直接用Image.network加载网络图片会怎样:
dart
Image.network('https://example.com/image.jpg')
问题来了:
每次都要下载:用户滑动列表,图片滑出屏幕再滑回来,又要重新下载。流量哗哗地流。
没有加载状态:图片加载需要时间,这段时间用户看到的是空白或者一个破碎的图标。
没有错误处理:网络不好、图片地址失效,直接显示错误图标,很丑。
内存管理差:大量图片加载到内存,可能导致OOM(内存溢出)。
这些问题,cached_network_image这个包都能解决。
引入cached_network_image
先看pubspec.yaml里的配置:
yaml
dependencies:
flutter:
sdk: flutter
# UI Components
cached_network_image: ^3.3.1
shimmer: ^3.0.0
cached_network_image是Flutter社区最流行的图片缓存方案,功能强大,使用简单。
shimmer是骨架屏效果的库,可以在图片加载时显示闪烁的占位符,后面会讲到。
添加依赖后运行flutter pub get安装。
CachedNetworkImage的基本用法
来看看项目里是怎么用的。先看NewsCard组件:
dart
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:intl/intl.dart';
import '../models/news_article.dart';
import '../screens/news_detail/news_detail_screen.dart';
导入cached_network_image包。注意是CachedNetworkImage,不是CacheNetworkImage,别拼错了。
新闻卡片的图片显示
dart
Widget _buildImage() {
return ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: article.imageUrl != null
? CachedNetworkImage(
imageUrl: article.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => _buildPlaceholder(),
)
: _buildPlaceholder(),
),
);
}
这段代码做了几件事,咱们一个一个看。
ClipRRect用来裁剪圆角。BorderRadius.vertical(top: Radius.circular(12))只给顶部加圆角,因为卡片底部是文字区域,不需要圆角。
AspectRatio固定宽高比为16:9。这样不管图片原始尺寸是多少,显示出来都是统一的比例,列表看起来整齐。
article.imageUrl != null先判断有没有图片URL。有些新闻可能没有配图,这时候显示占位符。
CachedNetworkImage的参数
dart
CachedNetworkImage(
imageUrl: article.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => _buildPlaceholder(),
)
imageUrl是图片地址,必填。article.imageUrl!后面的感叹号表示"我确定这个值不是null",因为前面已经判断过了。
fit: BoxFit.cover表示图片填满容器,可能会裁剪。其他选项还有contain(完整显示,可能留白)、fill(拉伸填满,可能变形)等。
placeholder是加载中显示的Widget。这里用灰色背景加转圈动画。参数context和url可以用来做更复杂的逻辑,比如根据URL显示不同的占位符。
errorWidget是加载失败显示的Widget。参数error是错误信息,可以用来调试或者显示给用户。
占位符的设计
dart
Widget _buildPlaceholder() {
return Container(
color: Colors.grey[200],
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.article_outlined,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 8),
Text(
'新闻图片',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
),
],
),
),
);
}
占位符不是随便放个灰色块就行。好的占位符应该:
有视觉提示:让用户知道这里是图片区域,不是bug。
风格统一:颜色、图标要和App整体风格一致。
不要太抢眼:占位符是临时的,不应该比真正的内容更吸引注意力。
这里用了一个图标加文字的组合,简洁明了。
详情页的大图显示
新闻详情页的图片更大,处理方式稍有不同:
dart
Widget _buildAppBar(BuildContext context) {
return SliverAppBar(
expandedHeight: 250,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: article.imageUrl != null
? CachedNetworkImage(
imageUrl: article.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
color: Colors.grey[300],
child: const Center(child: CircularProgressIndicator()),
),
errorWidget: (context, url, error) => _buildDetailPlaceholder(),
)
: _buildDetailPlaceholder(),
),
// ... actions
);
}
SliverAppBar是可折叠的AppBar,expandedHeight: 250设置展开时的高度。
FlexibleSpaceBar的background就是那张大图。用户向上滑动时,图片会逐渐收起,最后只剩下AppBar。
详情页的占位符
dart
Widget _buildDetailPlaceholder() {
return Container(
color: Colors.grey[200],
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.article_outlined,
size: 80,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'新闻图片',
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
),
),
],
),
),
);
}
详情页的占位符比卡片的大,图标80像素,文字16像素。因为详情页的图片区域更大,占位符也要相应放大,保持视觉平衡。
缓存的工作原理
CachedNetworkImage的缓存分两层:
内存缓存:最近使用的图片保存在内存里,访问速度最快。但内存有限,会根据LRU(最近最少使用)策略淘汰旧图片。
磁盘缓存:下载过的图片保存在本地文件系统,App重启后还在。容量比内存大,但访问速度稍慢。
加载图片时的流程是:
- 先查内存缓存,有就直接用
- 内存没有,查磁盘缓存
- 磁盘也没有,从网络下载
- 下载完成后,同时存入内存和磁盘
这就是为什么第一次加载慢,后面就快了。
自定义缓存配置
默认配置对大多数场景够用,但有时需要自定义:
dart
CachedNetworkImage(
imageUrl: imageUrl,
cacheManager: CacheManager(
Config(
'customCacheKey',
stalePeriod: const Duration(days: 7),
maxNrOfCacheObjects: 100,
),
),
)
stalePeriod是缓存过期时间,这里设置7天。过期后会重新下载。
maxNrOfCacheObjects是最大缓存数量,超过后会删除最旧的。
customCacheKey是缓存的标识,不同的key对应不同的缓存空间。比如用户头像和新闻图片可以用不同的缓存空间,分别管理。
使用Shimmer效果
转圈动画虽然能用,但有点单调。现在流行的是Shimmer效果(骨架屏),看起来更高级:
dart
import 'package:shimmer/shimmer.dart';
CachedNetworkImage(
imageUrl: article.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, url) => Shimmer.fromColors(
baseColor: Colors.grey[300]!,
highlightColor: Colors.grey[100]!,
child: Container(
color: Colors.white,
),
),
errorWidget: (context, url, error) => _buildPlaceholder(),
)
Shimmer.fromColors创建闪烁效果。baseColor是底色,highlightColor是高亮色,两种颜色交替产生闪烁。
效果是一道光从左到右扫过,给人"正在加载"的感觉,比转圈更现代。
图片加载进度
有时候想显示加载进度,特别是大图:
dart
CachedNetworkImage(
imageUrl: imageUrl,
progressIndicatorBuilder: (context, url, progress) {
return Center(
child: CircularProgressIndicator(
value: progress.progress,
),
);
},
)
progressIndicatorBuilder替代placeholder,可以拿到下载进度。progress.progress是0到1的小数,表示下载百分比。
CircularProgressIndicator的value参数可以显示确定的进度,而不是无限转圈。
图片预加载
有时候想提前加载图片,比如用户还没滑到那里,就先把图片下载好:
dart
void precacheImages(List<String> urls) {
for (final url in urls) {
precacheImage(
CachedNetworkImageProvider(url),
context,
);
}
}
precacheImage是Flutter内置的方法,配合CachedNetworkImageProvider可以预加载网络图片。
什么时候用?比如用户在看第一页新闻时,可以预加载第二页的图片。等用户滑到第二页,图片已经在缓存里了,秒开。
清除缓存
用户可能想清除缓存释放空间:
dart
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
Future<void> clearImageCache() async {
await DefaultCacheManager().emptyCache();
// 同时清除内存缓存
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
}
DefaultCacheManager().emptyCache()清除磁盘缓存。
imageCache.clear()清除内存缓存。clearLiveImages()清除正在使用的图片缓存。
在缓存管理页面调用这个方法:
dart
ElevatedButton(
onPressed: () async {
await clearImageCache();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片缓存已清除')),
);
},
child: const Text('清除图片缓存'),
)
获取缓存大小
显示缓存占用了多少空间:
dart
Future<String> getImageCacheSize() async {
final cacheDir = await DefaultCacheManager().store.fileDir;
int totalSize = 0;
if (await cacheDir.exists()) {
await for (var entity in cacheDir.list(recursive: true)) {
if (entity is File) {
totalSize += await entity.length();
}
}
}
// 格式化大小
if (totalSize < 1024) {
return '$totalSize B';
} else if (totalSize < 1024 * 1024) {
return '${(totalSize / 1024).toStringAsFixed(1)} KB';
} else {
return '${(totalSize / (1024 * 1024)).toStringAsFixed(1)} MB';
}
}
遍历缓存目录,累加所有文件大小,然后格式化成人类可读的形式。
处理图片加载失败
网络图片可能因为各种原因加载失败:
- URL错误或失效
- 网络断开
- 服务器错误
- 图片格式不支持
errorWidget处理这些情况:
dart
errorWidget: (context, url, error) {
// 可以根据error类型显示不同的提示
print('图片加载失败: $url, 错误: $error');
return _buildPlaceholder();
}
生产环境不要用print,用正式的日志系统。
图片尺寸优化
网络图片可能很大,但显示区域很小。下载原图浪费流量和内存。
如果后端支持,可以请求缩略图:
dart
String getThumbnailUrl(String originalUrl, {int width = 400}) {
// 假设后端支持这种格式
return '$originalUrl?w=$width';
}
CachedNetworkImage(
imageUrl: getThumbnailUrl(article.imageUrl!, width: 400),
// ...
)
很多图片服务(如七牛、阿里云OSS)都支持URL参数指定尺寸。
如果后端不支持,可以在客户端压缩,但这样还是要先下载原图,效果有限。
内存优化
大量图片可能导致内存问题。几个优化技巧:
限制内存缓存大小
dart
void main() {
// 限制内存缓存为100MB
PaintingBinding.instance.imageCache.maximumSize = 100;
PaintingBinding.instance.imageCache.maximumSizeBytes = 100 * 1024 * 1024;
runApp(const MyApp());
}
maximumSize是最大缓存图片数量,maximumSizeBytes是最大字节数。
使用memCacheWidth和memCacheHeight
dart
CachedNetworkImage(
imageUrl: imageUrl,
memCacheWidth: 400,
memCacheHeight: 300,
)
这两个参数限制内存中图片的尺寸。即使原图很大,内存里只保存缩小后的版本。
及时释放不需要的图片
dart
@override
void dispose() {
// 页面销毁时清除该页面的图片缓存
imageCache.clear();
super.dispose();
}
页面销毁时清除内存缓存,释放内存。
离线模式支持
缓存的另一个好处是支持离线浏览。用户之前看过的图片,断网后还能看:
dart
CachedNetworkImage(
imageUrl: imageUrl,
// 优先使用缓存,即使过期了也先显示
cacheManager: CacheManager(
Config(
'offlineCache',
stalePeriod: const Duration(days: 30),
),
),
)
设置较长的过期时间,让缓存保留更久。
图片加载的最佳实践
总结几条经验:
始终提供placeholder和errorWidget
dart
CachedNetworkImage(
imageUrl: imageUrl,
placeholder: (context, url) => _buildPlaceholder(),
errorWidget: (context, url, error) => _buildErrorWidget(),
)
不要让用户看到空白或者丑陋的错误图标。
使用合适的fit
dart
// 列表图片,填满容器
fit: BoxFit.cover
// 详情页大图,完整显示
fit: BoxFit.contain
// 头像,填满圆形容器
fit: BoxFit.cover
根据场景选择合适的fit模式。
固定图片容器尺寸
dart
AspectRatio(
aspectRatio: 16 / 9,
child: CachedNetworkImage(...),
)
// 或者
SizedBox(
width: 100,
height: 100,
child: CachedNetworkImage(...),
)
不要让图片容器的尺寸依赖图片本身,否则图片加载前后布局会跳动。
处理null URL
dart
article.imageUrl != null
? CachedNetworkImage(imageUrl: article.imageUrl!)
: _buildPlaceholder()
先判断URL是否为null,避免传入空字符串导致错误。
使用fadeInDuration
dart
CachedNetworkImage(
imageUrl: imageUrl,
fadeInDuration: const Duration(milliseconds: 300),
)
图片加载完成后淡入显示,比突然出现更平滑。
常见问题排查
问题一:图片不显示,也没有错误
检查URL是否正确,是否是HTTPS。有些平台默认不允许HTTP请求。
问题二:图片显示但很模糊
可能是图片本身分辨率低,或者用了memCacheWidth/memCacheHeight限制了尺寸。
问题三:内存占用过高
检查是否加载了太多大图,考虑限制内存缓存大小,或者使用缩略图。
问题四:缓存不生效,每次都重新下载
检查URL是否每次都不一样(比如带了时间戳参数)。相同的URL才会命中缓存。
问题五:清除缓存后图片还在
可能只清除了磁盘缓存,没清除内存缓存。两个都要清。
与其他方案的对比
除了cached_network_image,还有其他图片加载方案:
Image.network:Flutter内置,简单但功能少,没有磁盘缓存。
extended_image:功能更强大,支持手势缩放、编辑等,但包体积更大。
fast_cached_network_image:号称更快,但社区活跃度不如cached_network_image。
对于大多数项目,cached_network_image是最佳选择:功能够用、文档完善、社区活跃、bug少。
写在最后
图片缓存看起来是个小功能,但做好了能显著提升用户体验:
加载更快:缓存命中时几乎瞬间显示。
更省流量:同一张图片只下载一次。
离线可用:断网也能看之前的图片。
内存可控:合理的缓存策略避免OOM。
今日资讯App用CachedNetworkImage实现了这些功能,代码简洁,效果好。
图片是App的"门面",用户第一眼看到的就是图片。把图片加载做好,App的品质感立刻就上来了。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
在这里你可以找到更多Flutter开发资源,与其他开发者交流经验,共同进步。