位图和内存

位图对象通常是应用内存占用空间的最大单一贡献者。无论是应用图标、通知图片还是媒体内容,低效的位图处理都会迅速导致内存不足 (OOM) 错误和系统范围的内存压力。

位图配置和像素数据

位图消耗的内存量主要取决于其尺寸(宽度 × 高度)和配置 (Bitmap.Config)。

该配置定义了用于表示每个像素的字节数:

配置 每像素字节数 说明
ALPHA_8 1 仅限 Alpha(透明度)通道。适用于遮罩。
RGB_565 2 红色(5 位)、绿色(6 位)、蓝色(5 位)。无 Alpha。适用于不透明图像,对色彩保真度要求不高。
ARGB_8888 4 Alpha、红色、绿色、蓝色(各 8 位)。默认且最常见。
RGBA_F16 8 半精度浮点数。用于广色域和 HDR 内容。
HARDWARE 不适用 存储在显存 (gralloc/DMABuf) 中。请参阅硬件位图。

记忆公式: Memory (Bytes) = Width × Height × Bytes Per Pixel

例如,在 1080p 设备 (1920x1080) 上,ARGB_8888 中的全屏图片占用:1920 × 1080 × 4 字节 ≈ 8.3 MB。

堆位图与共享位图

堆位图(原生堆)

在现代 Android(8.0 及更高版本)中,位图像素数据存储在原生堆中,而只有一小部分封装容器对象驻留在 Java 堆中。

当应用需要呈现图片时,通常会将其从压缩图片文件解码为位图并存储在堆中。

共享位图 (ashmem/memfd)

当位图在进程之间传输时(例如,通过 Binder 传输到 SystemUI 以用于通知),Android 会使用共享内存(ashmem 或 memfd)来避免复制像素数据。

可以通过调用 Bitmap.asShared() 将 Bitmap 实例显式复制到共享内存,也可以在将 Bitmap 放入 Parcel 中(通常是通过将 Bitmap 添加到 Parcelable(例如 Bundle))并通过 Binder IPC 发送时隐式复制。

通过 Binder IPC 发送共享位图时,像素数据本身不会被复制,而是会向接收方进程复制引用共享内存区域的文件描述符。底层内存区域可能在多个进程之间共享,并且只有在引用它的所有文件描述符都已关闭后才会释放。

可变位图与不可变位图

  • 可变位图:创建后可以修改(例如,通过 Canvas)。它们始终需要自己的私有内存分配。如果复制的是可变位图,则必须进行深度复制(所有像素数据的第二个副本)。
  • 不可变的位图:无法更改。这样一来,便可进行优化,例如在不同的 Bitmap 实例之间共享相同的底层内存缓冲区。从 APK 资源 (BitmapFactory) 加载的位图通常是不可变的。

高效的位图处理

位图池化和重用

频繁分配和解除分配位图会导致分配抖动,从而迫使 GC 不断运行。常见的图片加载库使用 Bitmap Pool。

Google 建议使用 Glide 作为基于 Java 的应用的解决方案,并使用 Coil 作为基于 Kotlin 的应用的解决方案(尤其是在使用 Jetpack Compose 时)。

当不再需要位图时,应用会调用 bitmap.recycle() 或将其返回到池中,而不是让垃圾收集器对其进行处理。下次需要具有相同尺寸和配置的位图时,该池会提供现有缓冲区,从而避免进行新的分配。

硬件位图

Bitmap.Config.HARDWARE 可让您将像素数据直接存储在图形内存 (DMABuf) 中。

  • 优点:
    • 节省内存:不使用应用或原生堆,而是使用 GPU 内存。无论如何,应用界面中显示的位图通常都需要复制到 GPU 内存,因此这可以节省复制操作和额外的内存开销。
    • 性能:由于数据已在 GPU 上,因此绘制速度非常快。
  • 缺点:
    • 不可变:硬件位图无法修改。
    • 回读速度慢:从 CPU(例如 getPixel())访问像素的开销非常大。
    • 归因:在 AHAT 等标准工具中更难跟踪(请参阅下文)。

常见的位图内存误区

即使您使用现代位图配置,解码和调度位图的方式中的几个重复模式也可能会导致内存大幅飙升。

解码过缩放的位图

一张 4000×3000 像素的全分辨率照片在 ARGB_8888 中占用 48 MB 空间。仅为了在 200×150 像素的缩略图中呈现完整图片而解码完整图片,会浪费超过 99% 的分配像素缓冲区。

当您使用 ImageDecoder 或 BitmapFactory 直接解码图片时,请在解码过程中使用 ImageDecoder.setTargetSize() 或 BitmapFactory.Options.inSampleSize 进行降采样,以匹配目标视图尺寸。当您提供有界的目标视图大小时,Glide 和 Coil 等图片加载库会自动执行此降采样。

例如,当使用 ImageDecoder 解码位图时,传递一个 OnHeaderDecodedListener,该会将输出维度缩放到目标视图大小:

val source = ImageDecoder.createSource(resources, R.drawable.high_res_photo)
val bitmap = ImageDecoder.decodeBitmap(source) { decoder, info, _ ->
    if (info.size.width > targetWidth || info.size.height > targetHeight) {
        decoder.setTargetSize(targetWidth, targetHeight)
    }
}

并行解码带来的高并发保留率

即使各个位图的大小合适且生命周期较短,并行解码许多图片也可能会导致严重的内存峰值。例如,如果某个整理工具或图库界面在无界线程池上调度 30 个任务来同时解码图标或缩略图,则所有 30 个未压缩的像素缓冲区和解码器暂存缓冲区会同时占用 RAM。

这种高并发保留会增加峰值原生堆内存占用空间,并可能在批处理完成之前触发 lmkd 终止。使用有限的线程池、信号量或协程调度程序(例如 Dispatchers.IO.limitedParallelism(2))来限制解码并发性,以便一次只解码少量位图。

帧处理循环中未回收的临时位图

在 Android 8.0 及更高版本中,Java Bitmap 封装容器对象在 Java 堆上仅占用约 56 字节,而其像素缓冲区位于原生堆中,可能会占用数兆字节。您可以在 Android Studio Memory Profiler 或 AHAT 中验证此拆分,其中每个 Bitmap 实例都会显示大约 56 字节的浅层 Java 大小以及数兆字节的原生大小,并且在 dumpsys meminfo 中的 Native Allocations (Bitmap (malloced)) 下也会显示。

相机帧分析、OCR 或 ML 推断循环等高频流水线通常通过调用 ImageProxy.toBitmap() 和 Bitmap.createBitmap() 来为旋转或裁剪操作在每个帧上分配新的位图。如果丢弃对被取代帧的引用而不回收这些帧,可能会导致原生内存膨胀。微小的 Java 封装容器几乎不会增加 Java 堆占用空间,因此它们不会快速触发垃圾回收,无法防止在 NativeAllocationRegistry 回收之前积累数百兆字节的原生像素缓冲区。

在紧凑的循环中处理帧时,请尽可能重复使用预分配的缓冲区,或者在每个帧完成处理后立即对临时中间位图显式调用 bitmap.recycle()。

实操练习:探索位图

我们将使用 BitmapLab 示例应用来探索这些概念。

1. 使用 dumpsys meminfo 进行测量

启动 BitmapLab,然后点按 ALLOCATE 10MB ARGB_8888。然后运行以下命令:

adb shell dumpsys meminfo -s com.android.bitmaplab

在较新的 Android 版本中,找到 Native Allocations 部分。与通用的应用摘要相比,这些属性可为位图提供更好的归因:

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240  # <--- 10MB Bitmap data!
Bitmap (nonmalloced):        0                              0
  • 位图 (malloced):在进程的原生堆中分配的位图。在 Android 8.0 及更高版本中,大多数标准位图都存储在此处。
  • 位图(非 malloced):使用专用内存(例如硬件位图或共享位图 [通过 ashmem 或 memfd])的位图。

如果您在 BitmapLab 中分配了共享位图,您会看到它反映在 Bitmap (nonmalloced) 中:

 Native Allocations
                         Count                       Total(kB)
                        ------                         ------
   Bitmap (malloced):        1                          10240
Bitmap (nonmalloced):        1                          10240  # <--- Shared Bitmap!

共享位图跟踪

在某些 Android 版本和内核配置中,dumpsys meminfo 还可为通过文件描述符映射到进程地址空间的位图提供高分辨率跟踪。

默认情况下,共享位图使用通用名称(“位图”)。如需启用详细的归因和唯一的位图跟踪(识别不同进程之间的共享位图),您必须启用以下系统属性:

adb shell setprop debug.hwui.bitmap_ashmem_long_name true

启用后,/proc/<pid>/smaps 中的 ashmem 区域将具有更具描述性的名称。meminfo 将利用这一点,结果将如下所示:

 Shared Bitmaps
                         Count                       Size(KB)
                        ------                         ------
              Mapped:        1                          10240
              Unique:        1                          10240
  • 已映射:所有与位图相关的内存映射的总大小。
  • 唯一:仅考虑唯一性的位图大小(即,同一底层共享位图像素数据的两次或更多次映射仅计数一次)。

2. AHAT 中的位图

AHAT 可为位图提供出色的可视化效果。

  1. 在 BitmapLab 中,分配一些位图。
  2. 使用 -b 标志(用于包含原生位图数据)捕获堆转储:

    adb shell am dumpheap -b png com.android.bitmaplab /data/local/tmp/bitmaps.hprof
    adb pull /data/local/tmp/bitmaps.hprof .
    ahat bitmaps.hprof
    
  3. 打开 localhost:7100,然后在边栏中查找 Bitmaps 链接,或搜索 Bitmap 类。

  4. AHAT 实际上会在浏览器中呈现位图,从而轻松识别哪些图片占用了大量内存。

AHAT 显示渲染的位图

3. Perfetto 中的位图轨道

Perfetto 可以跟踪位图分配和数量随时间的变化。当为特定应用启用 gfx atrace 类别时,Android 框架会发出这些计数器。

  1. 启动轨迹。您必须添加 gfx 类别,并使用 -a 标志指定目标应用包:

    external/perfetto/tools/record_android_trace -o bitmaps.perfetto-trace \
        -t 15s -b 64mb view gfx dalvik am res memory -a com.android.bitmaplab
    
  2. 在 BitmapLab 中,反复点按 Allocate 和 Clear 按钮。

  3. 同时点按 Parcel/Unparcel Bitmap。

  4. 在 ui.perfetto.dev 中分析轨迹。

在 com.android.bitmaplab 的进程部分,您会看到: * 位图数量:显示有效位图数量的计数器。 * 位图内存:显示位图使用的总字节数的计数器。

高级切片 (Perfetto SDK)

BitmapLab 还使用 Perfetto SDK 为位图操作发出高级切片。在轨迹中搜索 BitmapLab_,以查找以下内容: * BitmapLab_parcelUnparcel:涵盖分块和取消分块逻辑的切片。 * BitmapLab_postNotification:涵盖通知发布流程的 slice。

跟踪通知流程

当您点按 Post Notification 时,应用会创建一个包含当前位图的通知,并将其发送到系统。负责此操作的框架代码会发出 Perfetto slice,其中包含连接序列化(将位图写入 Parcel 以通过 Binder IPC 发送)和反序列化(从接收端的 Parcel 读取位图)的流事件。

在下面的屏幕截图中,您可以看到应用将大型位图打包到 Binder 交易中,以发布通知,以及 system_server 进程中相应的解包操作。

Perfetto 显示了从 BitmapLab 到 system_server 的流程(通过通知)

借助 Perfetto,您甚至可以跟踪同一通知位图在线程和进程之间的进一步传播,例如从 system_server(实现 INotificationManager Binder 服务器)中的 binder 线程传播到 system_server 工作线程,然后工作线程可能会将同一位图转发到 com.android.systemui 以在通知阴影中显示。

系统应用面临的挑战

SystemUI(通知)和 Launcher 等系统应用面临着独特的挑战:

  1. 无界限内容:通知和微件的数量可能非常多。如果每个视图都包含一个大型位图,系统很快就会出现内存不足的情况。
  2. 重复:同一应用图标可能同时存在于启动器的缓存、SystemUI 的通知区域和“设置”应用中。
  3. 通过硬件缓冲区共享:为了缓解此问题,系统组件正在转向集中式“图像分流”服务,该服务可在进程之间共享 HardwareBuffer 实例。
  4. DMABuf 归因:硬件位图可节省堆空间,但会使用 DMABuf 内存,而标准内存工具很难将 DMABuf 内存归因于特定进程。

    使用 adb shell dmabuf_dump 查看系统范围内的 DMABuf 分配。此工具可提供每个进程的缓冲区细分信息:

     droid.bitmaplab:19562
                      Name              Rss              Pss         nr_procs            Inode               Exporter
                 <unknown>          3840 kB          1280 kB                3             3397              virtio_gpu
                    system            12 kB             4 kB                3             3398                  system
                 <unknown>          3840 kB          1920 kB                2             3399              virtio_gpu
                    system            12 kB             6 kB                2             3400                  system
             PROCESS TOTAL         11556 kB          5136 kB
    
    • RSS:如果缓冲区映射到进程中,则为缓冲区的总大小。
    • Pss:按比例分摊的大小(RSS 除以共享缓冲区的进程数)。这是会计方面的最佳指标。
    • nr_procs:当前持有对相应缓冲区的引用的进程数。
    • 导出器:分配缓冲区的驱动程序(例如,Cuttlefish 上的 virtio_gpu 或硬件上的特定于供应商的 Ion/DMA-BUF 堆)。

    您还可以使用 adb shell dmabuf_dump -b 获取所有缓冲区的摘要和整个系统的 DMA-BUF 总用量。


← Java | ↑ 向上 | 原生 →