相册或商品列表一滚动,内存明显上涨。第一反应往往是“图片泄漏了”,接着开始清缓存,最后曲线还是看不懂。

核心结论:先同时观察原图像素、目标解码尺寸、ImageCache、Dart Heap 和进程总内存,再判断是正常峰值、缓存没有边界,还是对象真的无法释放。只看一条内存曲线,不能证明泄漏。

文件不大,不代表解码后不大

JPEG、WebP 文件看到的是压缩体积,显示前通常需要解码成像素数据。以常见的每像素 4 Byte 粗略估算,一张 4000 × 3000 的图片需要:

4000 × 3000 × 4 bytes ≈ 45.8 MiB

解码期间还可能同时存在压缩字节、中间缓冲和绘制资源,实际大小也受像素格式与平台实现影响。

所以先看原图像素、显示尺寸、设备像素比和同时解码数量,不要只看文件多少 KB。

先固定一个可以重复的场景

在目标真机的 Profile 或 Release 模式下,固定图片数据和操作步骤,记录四个时间点:

  1. 进入页面前的基线;
  2. 首批大图解码时的峰值;
  3. 滚动停止后的稳定值;
  4. 退出页面并等待一次 GC 后的新基线。

重复操作数轮。第一次上涨后进入平台不等于泄漏;每轮结束基线仍继续抬高,才需要查缓存和引用链。最终结论不要来自 Debug 模式或模拟器。

第一批指标:图片是否解码过头

先记录这些数据:

  • 图片原始像素尺寸,而不是网络文件大小;
  • Widget 的逻辑尺寸和设备 devicePixelRatio
  • 实际请求的解码宽高;
  • 可见、预加载和并发解码的图片数量;
  • 是否包含多帧动图,或同一图片的多个解码尺寸。

宽 120 逻辑像素的缩略图,在 DPR 为 3 的设备上,目标宽度通常接近 360 物理像素,而不是 4000 像素的原图宽度。

final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
final decodeWidth = (120 * devicePixelRatio).ceil();
 
Image.network(
  product.thumbnailUrl,
  width: 120,
  height: 90,
  fit: BoxFit.cover,
  cacheWidth: decodeWidth,
)

这里只限制宽度以保留原始比例。BoxFit.cover 与裁剪场景仍要保证解码像素足够覆盖显示区域。服务端能提供缩略图时,也能减少网络与解码成本。

cacheWidthcacheHeight 会提示引擎按指定尺寸解码;Flutter Web 会忽略这两个参数,因为解码交给浏览器处理。

第二批指标:缓存和 Dart Heap 在涨什么

排查期间可以临时记录 Flutter 图片缓存状态:

void logImageCacheState() {
  final cache = PaintingBinding.instance.imageCache;
  debugPrint(
    'images=${cache.currentSize}, '
    'live=${cache.liveImageCount}, '
    'pending=${cache.pendingImageCount}, '
    'bytes=${cache.currentSizeBytes}',
  );
}

这些值能观察缓存数量、仍在使用和等待加载的图片,但 currentSizeBytes 不能代表整个进程,也不覆盖所有 Native 或 GPU 资源。

同时在 DevTools Memory 中观察 Dart Heap 的 Used、Capacity、GC 和快照差异。若 GC 后 Heap 回到相近区间而进程内存仍高,应继续看平台侧。

第三批指标:进程总内存和帧耗时

Android 观察 RSS/PSS、Native 和 Graphics;iOS 用 Instruments 查看 Memory Footprint、Allocations。比较前后结果时保持设备、构建模式和脚本一致。

解码尖峰、频繁 GC 或纹理上传还可能造成卡顿。同步观察 UI、Raster 帧耗时:60 Hz 每帧约 16.7 ms,120 Hz 约 8.3 ms。但内存上涨和卡顿同时出现,不代表根因一定相同。

怎样区分缓存增长和泄漏

加载时上涨、停止后稳定,多轮峰值和结束基线逐渐收敛,更像正常缓存或预热。

这些现象则需要继续查:

  • 每轮退出后,Dart 对象数量和 Heap 基线都持续增长;
  • liveImageCount 长期增长,并能确认旧页面仍持有图片监听;
  • currentSizeBytes 或业务图片缓存没有合理上限;
  • Dart Heap 稳定,但进程 RSS/PSS 或 Memory Footprint 持续上涨;
  • Heap Snapshot Diff 能看到不再需要的页面、图片字节或集合,并通过 Retaining Path 找到持有者。

不要把全局 imageCache.clear() 当成默认修复。它可能带来重复下载和解码。真正要修的是解码尺寸、预加载范围、缓存上限或资源所有权。

最后记住这几点

  • 文件大小是压缩体积,先看原图和目标解码像素。
  • ImageCache 指标只能解释 Flutter 图片缓存的一部分。
  • 同时观察 Dart Heap 与进程总内存,判断增长发生在哪一层。
  • 用多轮操作后的基线和快照引用链证明泄漏。
  • 图片按显示尺寸与 DPR 解码,并控制并发、预加载和缓存边界。
  • 性能结论要在目标真机的 Profile 或 Release 模式下复测。

问答复盘

Q1:一张 2 MB 的 JPEG,解码后也只占 2 MB 吗?

答: 不是。2 MB 是压缩文件大小,解码内存主要由像素宽高和像素格式决定。

Q2:内存加载图片后没有马上下降,就是泄漏吗?

答: 不一定。缓存、GC 和内存分配器都会影响曲线,应观察多轮操作后的基线。

Q3:currentSizeBytes 没有增长,为什么进程内存还在涨?

答: 它只统计 ImageCache 的相关容量。解码中间数据、Native 或 Graphics 资源需要平台工具继续观察。

Q4:给 Image.network 设置显示宽高,就会按这个尺寸解码吗?

答: 不能据此假定。布局尺寸与解码尺寸是两回事,移动端还应结合 DPR 设置 cacheWidthcacheHeight

Q5:图片内存高时,可以直接清空全局 ImageCache 吗?

答: 不应作为默认方案。清缓存可能增加重复解码和网络开销,应先确定缓存是否真的无上限或持有了不再需要的图片。

Q6:怎样证明旧页面仍然持有大图?

答: 多轮操作后做 Heap Snapshot Diff,再用 Retaining Path 确认页面、集合、监听或缓存中的持有链。

Q7:Flutter Web 也能靠 cacheWidth 降低解码内存吗?

答: 不能直接依赖。Flutter 官方 API 明确说明 Web 上会忽略 cacheWidthcacheHeight,应使用合适尺寸的网络资源并结合浏览器工具验证。