相册或商品列表一滚动,内存明显上涨。第一反应往往是“图片泄漏了”,接着开始清缓存,最后曲线还是看不懂。
核心结论:先同时观察原图像素、目标解码尺寸、
ImageCache、Dart Heap 和进程总内存,再判断是正常峰值、缓存没有边界,还是对象真的无法释放。只看一条内存曲线,不能证明泄漏。
文件不大,不代表解码后不大
JPEG、WebP 文件看到的是压缩体积,显示前通常需要解码成像素数据。以常见的每像素 4 Byte 粗略估算,一张 4000 × 3000 的图片需要:
4000 × 3000 × 4 bytes ≈ 45.8 MiB解码期间还可能同时存在压缩字节、中间缓冲和绘制资源,实际大小也受像素格式与平台实现影响。
所以先看原图像素、显示尺寸、设备像素比和同时解码数量,不要只看文件多少 KB。
先固定一个可以重复的场景
在目标真机的 Profile 或 Release 模式下,固定图片数据和操作步骤,记录四个时间点:
- 进入页面前的基线;
- 首批大图解码时的峰值;
- 滚动停止后的稳定值;
- 退出页面并等待一次 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 与裁剪场景仍要保证解码像素足够覆盖显示区域。服务端能提供缩略图时,也能减少网络与解码成本。
cacheWidth、cacheHeight 会提示引擎按指定尺寸解码;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 设置 cacheWidth 或 cacheHeight。
Q5:图片内存高时,可以直接清空全局 ImageCache 吗?
答: 不应作为默认方案。清缓存可能增加重复解码和网络开销,应先确定缓存是否真的无上限或持有了不再需要的图片。
Q6:怎样证明旧页面仍然持有大图?
答: 多轮操作后做 Heap Snapshot Diff,再用 Retaining Path 确认页面、集合、监听或缓存中的持有链。
Q7:Flutter Web 也能靠 cacheWidth 降低解码内存吗?
答: 不能直接依赖。Flutter 官方 API 明确说明 Web 上会忽略 cacheWidth 和 cacheHeight,应使用合适尺寸的网络资源并结合浏览器工具验证。