Appearance
Flutter 图片加载
回到总览:图片加载 / Image Loading引擎原理对照:02-图片加载方式总览与原理(Android Glide/Coil/Fresco 内部机制) 横向对比:03-图片加载对比(六类概念/考点/优势/性能)
1. 实现原理
Flutter 不用 Android 的 Bitmap/ImageView,而是走自绘引擎 dart:ui:图片经 Codec 在 Dart 侧解码,结果为 ui.Image,再交给 Image Widget 绘制。核心是 ImageProvider + ImageCache 两层。
1.1 加载链路:ImageProvider.resolve → ImageCache 命中短路
text
Image(...) / Image.network(url)
└─ ImageProvider.resolve(configuration, size)
├─ 1) ImageCache 查 key(live completer 命中?)→ 直接复用 ImageStreamCompleter(跳过解码)
├─ 2) 未命中 → 调 loadImage()/loadBuffer():
│ NetworkImage: http 取字节 → ui.instantiateImageCodec(buffer) → Codec.getNextFrame()
│ AssetImage : 从 asset bundle 读字节 → 解码(变体目录选择逻辑、固有逻辑尺寸计算与 GPU 放大渲染致模糊,详见 12/01 §三.1)
│ 产出 ImageStreamCompleter(MultiFrameImageStreamCompleter 处理动图)
└─ 3) completer 向 ImageCache 登记;Image widget 订阅 ImageStream,拿到 ImageInfo 后绘制图下备注:ImageCache 命中(同 key)→ 复用 completer、跳过网络/解码;未命中 → 取字节 + instantiateImageCodec 解码 → 登记缓存 → 绘制。双限:1000 张 / ~100MB。
1.2 ImageCache:Dart 侧的内存 LRU
- Flutter 全局单例(
PaintingBinding.instance.imageCache),绑定 root Isolate;多 Isolate 不共享。 - 双限:按「数量」
maximumSize(默认 1000)和「字节」maximumSizeBytes(随版本调整,约 100MB)淘汰,二者先到先逐出。 - 存两类对象:
ImageStreamCompleter(正在解码/已就绪的流,避免重复发起)和已解码的ImageInfo。 evict(key)手动清;列表滚走后用不上可主动evict控内存。
1.3 CachedNetworkImage:在 ImageCache 之外加了「磁盘缓存」
ImageCache 只管内存。CachedNetworkImage(基于 flutter_cache_manager)额外把网络字节存到设备磁盘,App 重启后仍可命中,且带占位/进度/失败回调:
text
CachedNetworkImage(url)
└─ CachedNetworkImageProvider
├─ CacheManager: 磁盘查 key(文件存在?)→ 命中直接读本地字节
├─ 未命中 → http 下载 → 写磁盘 → 交给 ImageProvider 解码进 ImageCache1.4 降采样:cacheWidth / cacheHeight(对应 Android inBitmap/inSampleSize)
Image 的 cacheWidth/cacheHeight 会经 ResizeImage 在解码阶段把图缩到目标像素,内存与解码开销随尺寸平方下降——和 Android 按 ImageView 尺寸降采样同一思路。
1.5 CachedNetworkImage 的已知限制(社区共识 + 公司实测)
先看图片引擎的"内存治理"全貌(与 Android 三引擎同一套思想,Flutter 侧对应实现见六层方案 §7):
图下备注:取字节 → 解码 → 缓存 → 绘制四阶段;回收靠复用池(BitmapPool)、两级缓存(强 LRU + 弱引用)、压力感知(onTrimMemory / 生命周期取消)三件套。Fresco 额外用编码态缓存 + CloseableReference 引用计数。
CachedNetworkImage(基于 flutter_cache_manager)解决了「磁盘缓存」这一个痛点,但有三层"管不到":
| 限制 | 表现 | 对策 |
|---|---|---|
| 取消不彻底 | 底层 dart:io HttpClient 无 CancelToken;widget 销毁 / evictFromCache 只丢弃结果,底层 HTTP 连接继续下载完(弱网大图会默默跑完) | 自管下载(带取消);或换 extended_image(有 CancellationToken) |
| 缓存移除 ≠ 释放 | evictFromCache() / emptyCache() 只删磁盘文件;内存 ImageCache 是另一套(LRU + live 强引用),不调 imageCache.evict(key) / clearLiveImages() 就还占内存 | 清缓存必须磁盘 + 内存两套都清 |
| 大图必须自己降采样 | 不设 cacheWidth/memCacheWidth 就整图解码进 Dart 堆,峰值高(Flutter 无 native 池) | 列表大图一律 memCacheWidth |
公司实测(12.02-leshangquan-delivery / 12.04-xiaojinka):使用层优化已做——useOldImageOnUrlChange: true(URL 变化不闪旧图)、cacheKey 显式指定、fadeInDuration/fadeOutDuration: 10ms(防滚动闪烁)、filterQuality: FilterQuality.medium、progressIndicatorBuilder / errorWidget 占位兜底。缺失层:列表大图未设 memCacheWidth(171dp 卡片整图解码)、无自定义 CacheManager(取消/清理不可控)、无 imageCache 回收——"用了库 ≠ 内存治理好了",见下文六层方案。
2. 特点与优势
- Flutter 自绘 vs Android 引擎(Glide/Coil/Fresco) :Flutter 走
dart:uiCodec,bitmap 在 Dart VM 堆;Android 走 nativeBitmap(GlideBitmapPool/inBitmap复用、Fresco nativeCloseableReference)。两者内存归属、复用机制、缓存 API 都不互通——所以 Flutter 没有 Glide 那种 native 内存池,超大图只能靠cacheWidth降采样控内存。 Image.networkvsCachedNetworkImage:前者只进ImageCache(内存),重启/清内存后必重新下载;后者多一层磁盘缓存 + 占位/进度/失败 UI,网络图场景几乎都该用它。Image体系 vs 手动解码:除非来源特殊(自定义协议、需逐帧处理),否则用ImageProvider体系,缓存与生命周期由框架托管。- 跨端一致性:同一套
dart:ui解码,Android/iOS/Web 行为一致;这是相对「每端一套图库」的最大优势。
3. 用法
1. 基础网络图(Image.network)
dart
Image.network(
'https://example.com/a.jpg',
width: 120,
height: 120,
fit: BoxFit.cover,
)2. 带占位/进度/失败的 CachedNetworkImage
dart
CachedNetworkImage(
imageUrl: 'https://example.com/a.jpg',
placeholder: (ctx, url) => const CircularProgressIndicator(),
errorWidget: (ctx, url, err) => const Icon(Icons.error),
fadeInDuration: const Duration(milliseconds: 300),
memCacheWidth: 240, // 解码降采样,等价于 cacheWidth
)3. 预加载(避免列表首次卡顿)
dart
// 在 build 前预热
precacheImage(const NetworkImage('https://example.com/a.jpg'), context);4. 解码降采样(cacheWidth)
dart
Image.network(
'https://example.com/big.jpg',
cacheWidth: 400, // 解码到 400px 宽,内存随面积下降
cacheHeight: 300,
)5. 自定义 ImageProvider(特殊来源)
dart
class MyProvider extends ImageProvider<MyProvider> {
final String token;
const MyProvider(this.token);
@override
Future<MyProvider> obtainKey(ImageConfiguration c) async => this;
@override
ImageStreamCompleter loadImage(MyProvider key, ImageDecoderCallback decode) {
// 自行取字节 → decode(...) → 返回 completer
// 与 02 中「自定义委托」同理:把「取字节+解码」逻辑封装后交给框架调度
}
}4. 考点
ImageCache双限淘汰:数量maximumSize与字节maximumSizeBytes哪个先到先逐出;列表大图不evict会撑满内存。ImageCache绑定 root Isolate:新 Isolate 不共享缓存,跨 Isolate 加载不命中彼此。CachedNetworkImage与ImageCache是两层:前者磁盘、后者内存;清内存缓存不代表清磁盘,反之亦然。cacheWidth/cacheHeight在解码阶段生效:控制的是解码目标尺寸(内存),不是显示尺寸(width/height);要省内存必须设cacheWidth。precacheImage的 context 泄漏:传入的BuildContext在预热完成前被销毁会抛异常,列表里注意生命周期。- Flutter vs Android 内存世界观不同:面试常问「Flutter 有没有 BitmapPool」——没有,靠
cacheWidth降采样 +ImageCache双限。
5. 性能对比(与 Android 三引擎横向)
| 维度 | Glide | Coil | Fresco | Compose 原生 | Flutter |
|---|---|---|---|---|---|
| 内存友好(超大图) | 5 | 4 | 5(native) | 4 | 3(靠 cacheWidth) |
| 包体积 | 2 | 5 | 1 | 5 | 4(需引 cached_network_image) |
| 解码速度 | 4 | 4 | 4 | 4 | 4 |
| 跨端一致 | 1 | 3(KMP) | 1 | 3 | 5 |
| 生命周期绑定 | 5 | 5 | 5 | 5(组合) | 4(Widget 树) |
| 易用/生态 | 4 | 5 | 2 | 5 | 4 |
分数为 1–5 相对排序(5 最好),用于直觉,非基准测试数字。结论:Flutter 胜在跨端一致、API 简洁;短板是无 native 内存池,超大图必须显式
cacheWidth降采样。
6. 对比分析举例
例:同一个网络图「首次加载 vs 二次进入」的路径差异
- 首次:
CachedNetworkImage→ 磁盘 miss → http 下载 → 写磁盘 →ImageProvider解码进ImageCache(内存)→ 绘制。 - 二次(同 session):
ImageCache内存命中 → 跳过下载与解码。 - 二次(杀进程重开):
ImageCache清空,但磁盘命中 → 读本地字节 → 解码进内存,省一次网络。
例:列表快速滚动为什么要用 CachedNetworkImage + 适时 evict
- 滚得快时每个 item 都发起下载会瞬时打满带宽与
ImageCache;CachedNetworkImage的磁盘层让滚回的 item 直接读本地,且ImageCache双限自动淘汰离屏图,避免 OOM。
7. 大列表 + 大图:六层方案(实战)
对应 Android 引擎四段(取字节→解码→缓存→生命周期),Flutter 无 native 内存池,降采样是硬要求。每层解决一个资源约束、可单独验证。
- L1 降采样(硬要求) :
cacheWidth/memCacheWidth按显示尺寸解码,整图进 Dart 堆必炸。 - L2 视口驱动:
ListView.builder懒构建 +visibility_detector可见才发起请求,不可见不下载不解码。 - L3 预加载:对
index±3即将可见项precacheImage,避免滚动到才请求导致白屏闪烁。 - L4 双层缓存:
ImageCache(默认 1000 张 / 约 100MB 双限)+CachedNetworkImage磁盘 LRU——回滚不重下、离线可用。 - L5 回收:
imageCache.clear()/clearLiveImages()+WidgetsBindingObserver监听内存警告(对应 AndroidonTrimMemory)。 - L6 超大图:全景 / 地图用
interactive_image类 tile 分块(等价BitmapRegionDecoder);普通大图服务端下发"缩略图 + 原图"两档。
图下备注:L1~L6 每层都是"单点可验证"——L1 看
getByteCount等价的内存下降,L2 看请求是否按视口触发,L4 看二次进入是否命中,L5 看内存警告后imageCache.currentSizeBytes回落。不要试图一次全上,先补 L1 + L4(收益最大),再补 L2/L3 优化体验。
- 渲染层补充(L7,可选) :列表海量缩略图可把
filterQuality降到FilterQuality.low/none,牺牲极微弱的边缘平滑换滚动流畅(公司实测用FilterQuality.medium平衡);图文混排里给含大图的子树包RepaintBoundary,避免其他小组件动画连带重绘大图(机制详见07/11 Layer Tree)。
复习检查题
Flutter 的
ImageCache和 Android 的BitmapPool是一回事吗?答:不是。
ImageCache是 Dart 侧的 LRU(按数量+字节双限),存ui.Image;BitmapPool是 Glide 在 native 堆的 Bitmap 复用池。Flutter 没有 native 复用池,超大图只能靠cacheWidth降采样控内存。CachedNetworkImage清了ImageCache还会重新下载吗?答:不一定。它有两层:清
ImageCache只清内存,磁盘缓存还在就不重新下载;只有磁盘也失效才会再 http 下载。反过来,evictFromCache()只删磁盘文件,内存里的 ImageCache 条目还要imageCache.evict(key)/clearLiveImages()才释放。大列表里
CachedNetworkImage不设memCacheWidth会怎样?答:整图解码进 Dart 堆(Flutter 无 native 池),列表大图会撑爆内存;必须按显示尺寸设
memCacheWidth/cacheWidth降采样(六层方案 L1)。
对应实验
labs/flutter/host/lib/topics/image_loading/cached_network_image_demo_page.dart—— 可跑的CachedNetworkImage演示(网络图 + 占位/进度/失败 +cacheWidth降采样),见 lab README。