Flutter for OpenHarmony:从零搭建今日资讯App(二十七)图片缓存的完整方案

打开一个新闻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。这里用灰色背景加转圈动画。参数contexturl可以用来做更复杂的逻辑,比如根据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设置展开时的高度。

FlexibleSpaceBarbackground就是那张大图。用户向上滑动时,图片会逐渐收起,最后只剩下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重启后还在。容量比内存大,但访问速度稍慢。

加载图片时的流程是:

  1. 先查内存缓存,有就直接用
  2. 内存没有,查磁盘缓存
  3. 磁盘也没有,从网络下载
  4. 下载完成后,同时存入内存和磁盘

这就是为什么第一次加载慢,后面就快了。

自定义缓存配置

默认配置对大多数场景够用,但有时需要自定义:

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的小数,表示下载百分比。

CircularProgressIndicatorvalue参数可以显示确定的进度,而不是无限转圈。

图片预加载

有时候想提前加载图片,比如用户还没滑到那里,就先把图片下载好:

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开发资源,与其他开发者交流经验,共同进步。

相关推荐
夜雨声烦丿2 小时前
Flutter 框架跨平台鸿蒙开发 - 打造实时汇率换算器,支持20+货币与离线模式
flutter·华为·harmonyos
时光慢煮2 小时前
基于 Flutter × OpenHarmony 开发的去除空行 / 多余空格工具实战
flutter·华为·开源·openharmony
2401_858286112 小时前
从Redis 8.4.0源码看快速排序(1) 宏函数min和swapcode
c语言·数据库·redis·缓存·快速排序·宏函数
时光慢煮3 小时前
基于 Flutter × OpenHarmony 开发的字数统计小工具实践
flutter·openharmony
小白阿龙3 小时前
鸿蒙+flutter 跨平台开发——Text控件
flutter·鸿蒙
世人万千丶3 小时前
鸿蒙跨端框架 Flutter 学习 Day 3:综合实践——多维数据流与实时交互实验室
学习·flutter·华为·交互·harmonyos·鸿蒙
世人万千丶3 小时前
鸿蒙跨端框架 Flutter 学习 Day 3:工程实践——数据模型化:从黑盒 Map 走向强类型 Class
学习·flutter·ui·华为·harmonyos·鸿蒙·鸿蒙系统
Codeking__3 小时前
Redis——事务
数据库·redis·缓存
IT陈图图3 小时前
基于 Flutter × OpenHarmony 的应用头部信息区域的实现与解析
flutter·华为·openharmony