图片占用的不是文件大小,是 解码后的 RGBA 和 GPU 纹理。2MB 的 JPEG 可以变成 48MB 的 CPU 像素再加一份 GPU 副本。OOM 和滚动掉帧,经常被写成「ImageCache 太小」或「没加缓存」,真实原因通常是:解码尺寸远大于显示尺寸、同时存活的解码结果太多、纹理在 GPU 侧的寿命和 Widget 对不上。

读完你应该能独立回答三个问题:cacheWidth 作用在哪一条线程、ImageCache 淘汰的是什么而不是文件、为什么 dispose 了 Image Widget 显存不一定立刻掉。

1. 先画边界:三条寿命,三块内存

一张网图从点击到上屏,至少过三块堆:

  1. 压缩文件 / 网络缓冲: 几 MB。IO Runner 或 HttpClient。和最终显存几乎无关。
  2. 解码后的位图(CPU): 宽 × 高 × 4(RGBA8888)。活在 Dart 堆的外部 typed data 或原生堆,取决于编解码器。ImageCache 主要钉的是这一层的 ImageStreamCompleter / ImageInfo。
  3. GPU 纹理: 上传后活在 Raster/GPU。可能还有 mipmap、图集浪费、Impeller 的纹理图集对齐。Widget 释放不等于立刻 glDeleteTextures。

不变量:

  • 文件 KB 数不进入 OOM 公式。公式是 解码像素数 × 同时存活数 ×(CPU 是否还留着 × GPU 是否还留着)。
  • 解码发生在 IO Runner,上传常发生在 Raster。UI Isolate 若同步 decodeImageFromList,会把解码搬到 UI,这是自伤。
  • ImageCache 按 数量和字节 设上限,淘汰的是缓存项,不是「磁盘上的 jpeg」。
  • 正在屏幕上显示的图是 live,不会被 maximumSizeBytes 踢掉。上限只管「没人听了但仍留着的」。
final bytes = 4000 * 3000 * 4; // 48,000,000

列表里 10 张未缩小的 1200 万像素图,仅 CPU 位图就是 480MB 量级,GPU 再来一份。这和「ImageCache 默认 100MB」同时成立:live 图不进淘汰,缓存上限帮不上忙。

2. ImageCache 实际缓存什么

PaintingBinding.imageCache 是进程内(当前 Isolate)的 Map:

  • key:ImageProvider 的 obtainKey 结果(例如 NetworkImage 的 url + scale)。
  • value:ImageStreamCompleter。它可能还在解码,也可能已经持有一帧或多帧 ui.Image。

默认 maximumSize = 1000,maximumSizeBytes = 100 << 20(100MB)。淘汰近似 LRU。注意:

  • 1000 张缩略图 可能远不到 100MB,先撞数量上限。
  • 几张 4K 先撞字节上限。
  • Image.network(url, scale: 2) 和 scale: 1 是不同 key,会解码两次。
  • ResizeImage 的目标宽高是 key 的一部分。同一 url 不同 cacheWidth 是不同项——这是对的,因为位图不同。

precacheImage 只是提前把 provider 推进缓存。它仍按全尺寸解码,除非 provider 本身带 resize。启动时 precache 三张开屏大图,等于启动期主动 OOM。

动图(GIF / 多帧 WebP):completer 持有多帧。每一帧都是一张位图。同时播放的 GIF 数量要当「张数 × 帧数」来估。ImageCache 的字节统计对多帧是否完整计入随版本,不要假设它帮你把 GIF 算准。

3. 解码:cacheWidth 为什么必须在这一步发生

编解码器可以在解码时下采样(JPEG / WebP 尤其友好)。Image.network(..., cacheWidth: w, cacheHeight: h) 或 ResizeImage 把目标尺寸传进 instantiateImageCodecFromBuffer 的 targetWidth/Height。IO 线程直接产出接近显示大小的位图。

错误路径:全分辨率解码 → 得到 48MB ui.Image → 绘制时 GPU 再缩放。中间那份 48MB 已经把 Dart/原生堆打爆,还要上传 48MB 纹理。滚动时掉帧是上传和带宽,OOM 是堆。

正确的 w:

int decodeWidthOf(BuildContext context, double logicalWidth) {
  final dpr = MediaQuery.devicePixelRatioOf(context);
  return (logicalWidth * dpr).round();
}

逻辑 120pt、dpr 3,解码 360 像素宽足够。再大是把视网膜当 4K 用。不要用原图宽度。不要用 double.infinity。列表项高度 80,按 80 × dpr 算。

HEIF / 某些 PNG 的解码器不一定支持任意 target 下采样,可能仍全量解码再 CPU 缩放。这时更要限制同时解码数,并避免原图像素级 PNG。

ui.Image 本身是 GPU/原生资源的 Dart 包装。ImageInfo 被 cache 和所有 Image Widget 的 listener 引用。最后一个 listener 离开、且被 cache 淘汰后,ui.Image.dispose() 才该发生。自己拿着 ui.Image 不 dispose,是泄漏的经典路径(instantiateImageCodec 手动用法)。

4. GPU 纹理:Widget 没了,纹理可能还在

上传:Raster 线程把 CPU 位图送进 GPU。Impeller/Skia 可能:

  • 按图集打包,实际占用大于像素数(对齐、padding)。
  • 延迟删除:一帧或几帧后才回收,避免「这帧还在采样」。
  • RepaintBoundary 再缓存一份离屏,和原图纹理同时存在。

所以 Instruments / GPU profiler 上的纹理内存,会大于 ImageCache.currentSizeBytes。两套账:cache 是 CPU 侧 completer;纹理是 GPU 侧。只调 imageCache.maximumSizeBytes 却不管屏幕上同时出现多少张大图,GPU 照样炸。

列表滚动的正确模型:

  • 视口内:live,必须在。
  • 即将进入:有限预解码,带 cacheWidth。
  • 远离视口:completer 可被淘汰,纹理应释放。

ListView.builder 默认会 dispose 离开的 Element,这是好事。AutomaticKeepAlive、自己缓存 ImageProvider 解析结果、全局 Map<url, ui.Image> 会把好事关掉。

evict(provider) 从 cache 拿掉,不保证 GPU 立刻释放,也不清 Http 磁盘缓存。imageCache.clear() 清的是当前 Isolate 的 cache,清不掉正在显示的 live 图,也清不掉别的 Engine。

5. 线程与卡顿形态

现象线程原因
滚动时周期性卡一下,以后顺IO + Raster解码+上传;解码尺寸过大
滚动全程 GPU 条高Raster每帧采样太多大纹理 / 没减尺寸
首屏白一下再出图IO没 precache,或网络
突然 GC / 掉帧UI同步解码或巨大 Uint8List 进 Dart 堆
后台内存涨、页面都 pop 了任意cache 太大、live 泄漏、原生 Bitmap 没回收

不要在 UI Isolate 上 decodeImageFromList(await file.readAsBytes())。readAsBytes 已经是一份完整压缩文件进 Dart 堆,再同步解码是第二刀。用 instantiateImageCodecFromBuffer + ImmutableBuffer.fromFilePath,让文件映射和解码离开 UI。

并发:IO Runner 不是无限线程。同时解码 20 张 4K 会把 IO 打满,间接让其他图片、字体也排队。自己做「最多 3 个解码」的闸,比加大 cache 有效。

6. 生命周期:Provider、Stream、Widget

Image Widget 并不持有位图。它持有 ImageStream 的监听。didChangeDependencies 时可能因为 devicePixelRatio 变化重新 resolve——旋转、外接屏、折叠屏会触发重新解码。cacheWidth 若按旧 dpr 算,会糊或过大。

ImageProvider 应不可变。自己写 provider 时 == 和 hashCode 必须和 key 一致,否则 cache 失效或错命中。错命中表现为「A 图出 B 图」,是 key 碰撞,不是 GPU 错乱。

手动 ui.Image:

final buffer = await ImmutableBuffer.fromFilePath(path);
final descriptor = await ImageDescriptor.encoded(buffer);
final codec = await descriptor.instantiateCodec(
  targetWidth: decodeWidth,
);
final frame = await codec.getNextFrame();
final image = frame.image;
// 用完:
image.dispose();
codec.dispose();
descriptor.dispose();
buffer.dispose();

漏任何一档都会钉原生内存。Image.memory 把这件事藏起来,但 memory 的 Uint8List 仍在 Dart 堆上整份活着——大文件不要走 Image.memory。

7. 设计取舍

  • 默认缓存 100MB / 1000 张: 中等 App 够用。大图 Feed 必须改策略:减小单张尺寸,而不是只加大上限。加大上限是把 OOM 推迟成更大的 OOM。
  • live 不淘汰: 正在显示的图不能被踢,否则闪。代价是视口里的错误尺寸无法靠 cache 政策拯救。
  • 解码时缩放而不是绘制时缩放: 省 CPU 堆和上传。代价是同一张图多种显示尺寸会解码多份。列表用缩略图、详情再解大图,是正确的双 key。
  • GPU 延迟释放: 避免采样已删纹理。代价是 pop 页面后显存曲线下降慢一拍,不要立刻在下一页再塞同等大图。

CDN 出多尺寸是比 cacheWidth 更省的:解码器读的压缩数据都更少,IO 和 CPU 都轻。cacheWidth 是客户端最后一道闸,不是图片架构本身。

8. 排障

  1. OOM 堆栈在解码: 打每张图的 targetWidth/Height 和原图像素。缺 cacheWidth 是第一嫌疑。
  2. currentSizeBytes 很小但仍 OOM: 病在 GPU 或原生 Bitmap,或 live 图。用 Android Profiler Graphics / Xcode GPU 看纹理。
  3. 滚动掉帧与图片一一对应: 解码尺寸、同时解码数、是否同步解码。Timeline 的 IO / Raster 轨道。
  4. 页面离开内存不掉: 全局 cache 引用、keepAlive、自己的 Map<url, ImageProvider> 单例、Completer 没人 evict。
  5. GIF 把机子烫化: 限同时播放,静止时用首帧,离屏暂停。
  6. 同一 url 反复解码: key 不稳定(url 带变化 token、自定义 provider 没写 ==)、或 cache 上限太小被立刻踢。
  7. clear() 了还在: live 图、其他 Engine、磁盘缓存、GPU 延迟删。
assert(() {
  imageCache.maximumSizeBytes = 50 << 20;
  return true;
}());

Debug 里把上限故意调低,能提前暴露「我依赖无限 cache」的页面。线上再按机型内存设,但永远先缩小解码尺寸。

9. 和 Texture / 相机帧的关系

相机、播放器走 Texture id,不进 ImageCache。它们的显存由插件和 Engine 的 texture registry 管。每帧更新的纹理不要再 toImage 转成 ui.Image 塞进 cache——那会每帧 8MB 分配。需要截图就截一次并立刻 dispose。

10. Provider、Completer、key:一张图为什么会被解两次

ImageProvider.resolve 的流程:

  1. obtainKey(configuration):异步得到 key(NetworkImage 的 key 含 url、scale、headers 吗?默认 headers 不进 key——带 cookie 的不同用户可能错命中。自己加 header 鉴权时要进 key 或不要用全局 cache)。
  2. imageCache.putIfAbsent(key, () => load()):没有才 load。
  3. loadImage / loadBuffer 返回 ImageStreamCompleter。MultiFrameImageStreamCompleter 管动图,OneFrameImageStreamCompleter 管静图。
  4. Widget 的 ImageStreamListener 挂上。第一帧就绪就 setState。

解两次的常见原因:

  • 两个 Image 的 configuration 不同(dpr、locale 一般不影响 network key,但 ImageConfiguration.size 影响 ResizeImage)。
  • 一个用 cacheWidth: 200,一个不用。
  • url 带随时间变的 query(签名 URL)。key 每分钟变,cache 废了,还泄漏旧项直到 LRU 踢掉。
  • 自定义 provider 没实现 == / hashCode,每次 putIfAbsent 都当新 key。

FadeInImage / cached_network_image 在 Framework 的 cache 之外可能再做磁盘 cache。磁盘命中仍要解码。磁盘 cache 解决的是网络,不是 48MB RGBA。插件若把解码后的文件写成 PNG 再读回来,会更慢更大。磁盘只存压缩原图或已经缩好的缩略图。

precacheImage(provider, context) 需要 BuildContext 是因为 ImageConfiguration(dpr、locale、size)。在 main() 里没有 MediaQuery 时 precache,dpr 可能是 1.0,真机 3.0 上会再解一次,预热白做。放到第一帧有 MediaQuery 之后,按真 dpr 算 cacheWidth。

错误处理:ImageStreamListener.onError。cache 会不会留下失败项,随实现。失败应 evict,否则短暂网络错误会把「失败」缓存到 LRU 结束。errorBuilder 只是 UI,不负责 evict。

11. 列表、动图、内存上限怎么定

Feed 场景给一组可执行的不变量,而不是「适当设置 cache」:

  • 解码宽度 ≤ itemWidth * dpr * 1.2(1.2 是给轻微放大/过冲)。超过就是浪费。
  • 同时解码 ≤ 3。多余的排队。滚动速度高于解码速度时,优先取消已离开视口的请求(ImageProvider 取消能力有限,至少不要 keepAlive 它们)。
  • 同时解码后的 live 图 ≈ 视口张数 + 1 屏缓冲。十屏之外必须可淘汰。
  • 动图:视口内最多 1~2 个在播,其余显示首帧静态 ui.Image。
  • maximumSizeBytes 设成「大约 2 屏缩略图 + 1 张大图预览」,不要设成 500MB。机器 RAM 2GB 和 12GB 用同一上限是懒。
  • 大图预览页 push 时解大图,pop 时 evict 大图 key,留下缩略图 key。
class SizedNetworkImage extends StatelessWidget {
  const SizedNetworkImage({required this.url, required this.logicalWidth});
  final String url;
  final double logicalWidth;

  @override
  Widget build(BuildContext context) {
    final w = (logicalWidth * MediaQuery.devicePixelRatioOf(context)).round();
    return Image.network(
      url,
      cacheWidth: w,
      filterQuality: FilterQuality.low,
      gaplessPlayback: true,
    );
  }
}

gaplessPlayback 避免切换 key 时闪白;它会把旧 ui.Image 多留一会儿。和 evict 一起用时要理解:旧图可能仍 live。这是闪白和内存之间的取舍。

filterQuality 影响采样成本,不影响解码尺寸。低质量采样在滚动中更便宜。定住再切高质量是产品策略。

PaintingBinding.instance.imageCache.clearLiveImages() 是最后手段,会让当前屏闪。用于内存警告回调(didHaveMemoryPressure)时,先 clear() 淘汰非 live,仍不够再清 live。不要每进一次后台就清 live——回来全白再解一遍,又是 CPU 尖刺。

ui.Image 的 dispose 契约

ui.Image 实现了显式 dispose。ImageCache 和 Image Widget 正常路径会处理。你一旦 frame.image 拿到手,契约就转到你:

  • 交给 RawImage 的 image: 参数,Widget 并不自动拥有销毁权,除非文档写明。RawImage 不 dispose 传入的 image。
  • Canvas.drawImage 用完,图像若不再展示,立即 dispose。
  • toImage 得到的每一帧都是新 ui.Image,截图循环不 dispose 必炸 GPU。
  • dispose 后还 drawImage 是原生崩溃,不是 Dart 异常。

clone() 增加一份句柄,必须成对 dispose。把 ui.Image 放进 ChangeNotifier 当全局封面图,要在替换时 dispose 旧的。这和 ImageCache 淘汰是独立的——全局 notifier 让 image 永远 live。

HTTP、重定向、内存中的压缩数据

NetworkImage 下载的 bytes 在解码前是压缩数据,解码后应丢弃。自己写下载时,常把 Uint8List bytes 留在 repository 里「方便重解」。这等于压缩和 RGBA 同时活。重解用文件或磁盘 cache,内存只留一份 RGBA 或什么都不留交给 ImageCache。

重定向 URL 作为 key:最终 URL 和请求 URL 不同,可能解两份。统一用 canonical url。

HTTP 缓存头和 ImageCache 无关。304 省网络,不省解码。磁盘上的 jpeg 每次进内存仍要解码(除非你缓存的是已解码文件,那更占磁盘且版本难管)。

内存警告与多 Engine

didHaveMemoryPressure 在 Android onTrimMemory 等路径进来。此时应:

  1. imageCache.clear()
  2. 大图页 evict
  3. 不要指望立刻回收 GPU,下一帧再看

多 Engine:每个 Isolate 一份 ImageCache。A 页清了,B 页的 cache 还在。内存警告要广播到所有 Engine(原生遍历 Group 里的 engine 发消息)。只清当前可见页,RSS 降不下来时去查另一份 Isolate。

ImageCache 的淘汰不是立即释放

LRU 踢出 completer 后,若还有 ImageStreamListener(某 Widget 仍显示),completer 继续 live,字节仍计入 liveImageCount 相关统计(字段名随版本)。currentSizeBytes 有的版本不含 live。看文档当前实现,调试时把 liveImageCount、currentSize、currentSizeBytes 一起打。只看一个数会误判「cache 已经很小」。

踢出且无 listener 后,ui.Image.dispose 进入队列。GPU 侧再晚几帧。内存曲线下降是台阶不是悬崖。压测时 pump 足够帧再采样 RSS。

putIfAbsent 的 loader 抛错:cache 不应留下永久失败项。若你观察到某 url 永远白图,先 evict。自己写 provider 时,失败路径 completer.setError 并确保 cache 可驱逐。

颜色空间、HDR、预乘 Alpha

解码得到的像素可能是 sRGB、Display P3、或带预乘 alpha。drawImage 时引擎会转换。转换可能发生在上传时,格式不一致会多一份缓冲。能在解码参数里指定目标格式就指定。HDR 图在 SDR 屏上的 tone mapping 发生在 GPU,算填充,不算 cache 字节。但 HDR 纹理格式更贵。列表用已经 tone map 过的 SDR 缩略图。

预乘 vs 非预乘搞错,边缘有黑晕。这不是 cache bug。decodeImageFromPixels 的 format 参数必须和 C 侧输出一致。

磁盘预解码队列

自己做「滚动预加载」时,用一个长度有限的队列:只预加载下一屏 url,带 cacheWidth,完成的结果进 ImageCache。滑过的 url 取消(HTTP 层取消;解码中的可能取消不了,就让它完成并被 LRU 踢)。无界预加载等于把 Feed 全部解进内存,cache 上限会变成「淘汰刚刚预加载的、留下正在看的」,来回抖动,磁盘和 CPU 打满。有界队列是和 LRU 配套的不变量。

解码器选择与 isolate

instantiateImageCodec 走内置编解码(libjpeg-turbo、libpng、libwebp 等),在 IO Runner。自己用 FFI 解 HEIF,要自己选线程,别放 UI。解完像素再 decodeImageFromPixels,这一步仍可能上传。量两段时间。

多帧 GIF 的 getNextFrame 按需。Image Widget 会按帧调度。离屏仍调度就是耗电。TickerMode.of(context) 为 false 时应停止。自己写播放器要听 TickerMode。

缓存键包含什么:一份检查表

url、scale、cacheWidth/Height、headers 中影响内容的部分、颜色变换若发生在解码前、locale 一般不含。ColorFiltered 在绘制时发生,不应进 ImageCache key——同一份位图多次滤镜。滤镜很重且结果要复用,才另做一层缓存,注意那是另一份 RGBA。

OOM 现场怎么取证

java/native heap dump、GPU 纹理列表、imageCache 三个计数、当前路由、可见 Image url 列表(Debug 自己打)。没有 url 列表你会以为是泄漏,其实是当前屏 20 张未缩小的图。取证包进内部 debug 手势(三击版本号)。线上采样不要带 url 若 url 含隐私 token,打 hash。

图片管线的 SLA 可以写成三条数字,放进客户端基建文档:默认解码宽 ≤ 屏幕宽 × dpr;ImageCache 字节上限 ≤ 设备 RAM 的某个小比例(例如 1/40,并设 50~150MB 夹紧);滚动中同时解码 ≤ 3。三条都能自动化检查:CI 扫 Image.network 是否带 cacheWidth(允许白名单大图页);运行时打点 currentSizeBytes 和单张解码像素。没有 SLA,每个业务页都会「这一张需要高清」。高清的代价是 48MB,不是「看起来清楚一点」。把 48MB 写进 PR 描述,产品才会一起做决定。

动图 SLA:同时播放 ≤ 1(Feed)或 ≤ 2(详情)。超出显示封面。系统静音/省电模式停播。这些是产品规则,实现只是 TickerMode 和控制器。把规则推给「系统自己会管」会在低端机上失败。

泄漏排查顺序固定:先列当前 live url 和像素数,再看 cache 计数,再看 GPU 纹理,再看是否 keepAlive / 全局 Map。反了顺序会去「调 cache 上限」而当前屏就已经 400MB。顺序是本文的方法论:先算正在显示的,再算缓存的,最后才是泄漏。 正在显示的只能减尺寸或减张数。缓存才能 LRU。泄漏才 evict/dispose。三种药不能串。

图片组件库应成为唯一入口:AppImage.network(url, logicalWidth: ...) 内部算 cacheWidth、errorBuilder、fade、evict 策略。禁止业务直接 Image.network。lint 或自定义 lint 扫。唯一入口才能让 SLA 落地,否则总有人复制旧代码不带宽度。

入口内部处理:空 url、data url、资源图、网络图分支。资源图仍要 cacheWidth,包里的 4K 插画一样能 OOM。Image.asset 不是免费午餐。

缓存统计打到开发者页:当前张数、字节、live 数、最近淘汰 key。内部包打开这一页能在客诉「闪一下」时立刻看到是不是 cache 太小导致反复解码。调上限要有前后对比,不是拍脑袋 500MB。

CDN 协作:输出 w 参数的缩略图模板。客户端 logicalWidth×dpr 对齐到 CDN 档位(例如 360/720/1080),避免 cache key 无限多。档位太密,cache 碎片化;太疏,浪费像素。三档通常够 Feed。详情用原图或最大档,并在 pop 时 evict 最大档。这是客户端与 CDN 的共同不变量,只改一端会失效。

解码错误要分类:网络 404、解码失败、OOM、格式不支持。UI 上都是裂图,指标上必须分。OOM 要降档重试(更小 cacheWidth)一次,再失败才裂图。降档是保护,不是隐藏 bug——同时打点「发生了降档」,方便发现某 CDN 档位过大。

相册导入的图经常是 4000 边长。导入链路强制最长边上限(例如 1920)再进编辑器。不要让编辑器承担原图。这是产品规则。规则写在导入按钮上,不写在 OOM 崩溃里。

RepaintBoundary.toImage 导出分享图时指定 pixelRatio,不要默认到 3 再乘屏幕宽得到一张 4000 边的 PNG。分享图 1080 边够。导出后立刻 dispose。分享链路是 GPU 峰值的常见来源,因为它和当前屏纹理叠在一起。

图片链路还要考虑版权水印、加密图、自定义解码。加密图不能进普通 NetworkImage,因为 cache key 和磁盘可能泄漏明文。解密后的 RGBA 更不能进全局 ImageCache 除非进程内且登出 evict。这类图用一次性 provider,禁止 LRU 跨账号。登出清 cache 是安全动作,不是性能动作。和 restoration 清桶是同一类纪律。

自定义解码(相机 RAW)走 FFI,输出目标尺寸,不要解全尺寸再缩。全尺寸 RAW 能到上百 MB。导入即缩小,编辑器只见工作分辨率。导出再考虑更高分辨率,在 worker 里做。UI 永远不见全尺寸。这是图片内存公式的产品形式:让「同时存活的全尺寸数」恒为 0 或 1,且 1 只出现在导出那几秒。

相册、相机、网络、资源四条导入链,应在进入 ImageCache 之前汇合到「已带目标宽高的 Provider」。汇合点是 AppImage。四条链各自算 dpr 会算错(相机页全屏、列表项小图)。汇合点只收 logicalWidth,内部统一乘 dpr 并对齐 CDN 档。以后改档位只改一处。这是图片模块的单一真相。没有单一真相,SLA 三条数字会在四条链上各解释一次,等于没有 SLA。

图片问题在生产里反复以三种面具出现:OOM、滚动 hitch、页面离开内存不掉。三张面具对应同一公式的三个变量:单张尺寸、同时张数、该释放没释放。面具不同,变量不同,药不同。遇到面具先还原成变量,再动手。动手顺序永远是:先 cacheWidth,再并发闸,再查泄漏。反了会加 cache 上限,把 OOM 推迟到更大的数字。更大的数字更难复现,更难说服产品减图。所以顺序不能反。公式不能忘。文件 KB 不能再出现在 OOM 讨论里。谁再提文件大小,就把 4000×3000×4 写在白板上。白板比感觉硬。

ImageCache 默认 100MB 像一个安全垫,但它垫的是「没人看的图」,垫不了「正在看的错误尺寸」。正在看的图要在解码那一步变小。这一步发生在 IO Runner,参数叫 cacheWidth。参数从 logicalWidth×dpr 来,从唯一入口 AppImage 来,从 CDN 档位来。三条来源必须一致,否则 key 爆炸或像素浪费。一致之后,公式里的单张尺寸被钉住。再钉同时解码数,再钉 live 张数(虚拟化列表),再处理泄漏(全局 Map、keepAlive、未 dispose 的 ui.Image)。钉完,OOM 从随机变成可申请的预算。预算可以和产品谈:这一屏允许多少张、每张多少像素。谈得动,是因为白板有公式。没有公式,谈的是「要高清」。高清没有单位。有了宽×高×4,高清有了单位。单位是这篇文章给工程的东西。单位在,内存才可治理。可治理,列表才能继续加图,而不靠碰运气。

AppImage 落地时加两条断言:logicalWidth 有限且大于 0;算出来的 cacheWidth 不超过屏幕宽×dpr×某系数。断言只在 Debug。线上用打点替代。打点发现超标,说明有人绕过入口或入口算错。绕过用 lint 抓。算错用单测:给定 dpr=3、logical=120,期望 360。单测比肉眼看糊更硬。糊是主观。360 是客观。客观才能把 SLA 钉死。钉死之后,业务要更大的图,是改档位或改白名单,不是在业务页写一个不带宽度的 Image.network。那一行是回归。回归用 lint 红。红了就不能进主分支。主分支上的图,张张有宽度。张张有宽度,公式才对每一张成立。对每一张成立,OOM 才从「某张图」变成「这一屏总和」。总和可谈。某张图不可谈,因为永远有一张要高清。谈总和,是图片治理的政治。政治要有数。数从公式来。

lint 红了不能用 // ignore 当习惯。忽略要写原因和过期时间。过期后 CI 再红。永久忽略等于没有入口。没有入口,SLA 是假的。假 SLA 比没有更坏,因为它让人以为张张有宽度。以为会放松警戒。放松的时候,4K 图会从「就这一张」进来。就这一张,是 48MB。48MB 乘列表,是事故。事故的入口是一次 ignore。所以 ignore 要贵。贵了,人才会去改 AppImage。改入口,是正路。正路走完,公式才对生产里每一张图成立。成立,这篇才算从专栏进了仓库。

主分支张张有宽度之后,还要定期看 live 张数是不是随列表长度涨。涨,说明虚拟化失效或 keepAlive 滥用。不涨,公式的第二项才被钉住。两项都钉,第三项泄漏才看得清。看得清,才能谈治理。谈完,列表可以继续加图。加图不再是赌。赌结束,图片内存从玄学变成算术。算术和 4000×3000×4 同一块白板。白板留下,讨论才能短。短讨论,是这篇给团队的速度。

白板留下公式,列表才能继续加图而不赌内存。加图有单位,单位是像素不是文件 KB。

像素有单价,单价乘张数就是这一屏的账单。账单可谈,文件 KB 不可谈。

谈账单时把张数和单价分开写,产品才能选「少一张」还是「每张小一点」。两选都是合法优化。

两选写在 PR 里,比写「尽量高清」能让评审结束。

结束在数字,不在形容词。

12. 小结

图片内存 = 解码尺寸 × 同时存活数,再乘 CPU/GPU 是否双份。ImageCache 只是 Isolate 内对 completer 的 LRU,不管 live 图,不管 GPU 延迟释放,不管文件 KB。从解码那一步按显示像素缩小,限制同时解码和同时播放,列表项离开就让 completer 可淘汰——这三条比任何「加大 cache」都接近根因。文件大小只决定下载时间,不决定会不会 OOM。