Skip to content

ART GC 机制、Bitmap 内存演进与大图优化 ​

回到总览:性能、测试、排障
相关模块:JVM / ART 垃圾回收算法与 GC 演进深度剖析 · Android / Java 内存泄漏定位、LeakCanary 原理与 Profiler 实战 · dp/dpi 与放图策略 · 图片加载原理

一句话定义 ​

Android ART 内存优化是指针对 Android 特有的堆内存限制与 Bitmap 内存分配机制,通过对 Bitmap 像素区域复用、降低解码分辨率、监控大图分配以及管控 ART 大对象空间 (LOS),从根源上消除 OOM(OutOfMemoryError)与内存抖动引发卡顿的专项工程治理。

代码索引 ​

主题Lab 说明源码
Bitmap 内存:Config 对比 / inSampleSize / inBitmap / 大图检测 / RegionDecoderbitmap-memory-demoBitmapMemoryDemoActivity.kt

为什么需要 ​

  • 为什么在 Android 中加载一张 4K 像素的图片,图片文件体积只有 500KB,但占用的内存却高达 30MB 以上?
    • 一句话答:图片文件(PNG/JPG)是经过压缩的文件格式,而加载到内存中的 Bitmap 是未压缩的原始像素矩阵;其内存计算公式为 宽 x 高 x 每像素字节数(如 3840 x 2160 x 4 = 33.1MB),与磁盘上的文件压缩大小无关。
  • 为什么 10 年 Android 工程师必须搞清 Android 8.0 前后 Bitmap 内存分配位置的变化?
    • 一句话答:Android 3.0~7.1 时代 Bitmap 像素存在 Java 堆中,极易触发 Java 堆 OOM;Android 8.0+ 重新将像素分配在 Native 堆(通过 android.graphics.Bitmap 指针),虽然解除了 Java 堆限制,但若不显式 recycle() 或及时 GC,极易造成 Native 内存暴涨崩溃。
  • 为什么 RGB_565 内存正好减半,却不能随便用?
    • 一句话答:RGB_565 每像素 2 字节(R5+G6+B5,无 Alpha),正好是 ARGB_8888(4 字节)的一半;但没有透明通道、且色深低(65536 色),渐变/暗部会出现色带,半透明 UI 场景不能用(详见 §2)。

底层机制 ​

1. Android Bitmap 内存分配演进历史 ​

Android 版本像素内存 (Pixel Storage) 分配位置GC 回收机制OOM 崩溃类型
Android 2.3 之前Native 堆手动调用 Bitmap.recycle() 释放Native OOM
Android 3.0 ~ 7.1Java 堆 (byte[])随 GC 自动回收Java 堆 OOM (最易崩溃)
Android 8.0 +Native 堆 (Native Allocation)NativeAllocationRegistry 绑定 Java 对象的 GC 引用Native 堆 OOM / 虚拟内存用尽

补充:HARDWARE Config(API 26+)把像素放 GPU 显存,不占 Java 堆也不占 App native 堆;但只读、不可 getPixels、不可 inBitmap 复用,只能靠显示生命周期回收。

2. Bitmap 内存精确计算公式与渲染形式(Config) ​

Bitmap 内存占用 (字节) = 实际解码宽度 x 实际解码高度 x 单像素字节数

缩放因子 (TargetDensity / Density) ​

当图片放在 res/drawable-xhdpi (density=320) 中,运行在 xxhdpi (density=480) 设备上时: 实际解码宽度 = 原始宽度 x (480 / 320)。放哪个桶直接决定解码尺寸与内存(详见 12/01 dp/dpi 与放图策略)。

单像素字节数(ColorConfig 全表) ​

Config字节/像素bit 布局说明
ALPHA_81BA8只有透明度(蒙版 / 水印 / 遮罩)
RGB_5652BR5+G6+B5(16 bit)无 Alpha,内存减半;渐变/暗部有色带
ARGB_88884BA8+R8+G8+B8(32 bit)默认,1670 万色 + 256 级透明
RGBA_F168B半精度浮点HDR / 宽色域,照片编辑才用
HARDWAREGPU 显存—8.0+ 硬件位图,只读不可改

RGB_565 vs ARGB_8888:肉眼差别与计算 ​

  • 内存差正好 2 倍:w × h × 2 vs w × h × 4。例:1080×1920 → ARGB_8888 ≈ 7.9MB、RGB_565 ≈ 3.96MB;4K(3840×2160)→ 33.2MB vs 16.6MB。
  • 肉眼差别:对不透明图片,纯色、天空 / 肤色 / 夜景等平滑渐变区域能看到色带(banding) ;对比度高、色彩跳跃的照片几乎看不出。硬限制:无 Alpha——半透明圆角、水印、渐变遮罩不能用 565。
  • 适用:不带透明度的背景大图、缩略图。

像素通道与内存计算:ARGB_8888 vs RGB_565

图下备注:1 字节 = 8 bit;ARGB_8888 = 4 通道 × 8 bit = 4B/像素,RGB_565 = R5+G6+B5 = 2B/像素;内存 = 宽 × 高 × 每像素字节数,1080×1920 差正好 2 倍。

3. 两层压缩:文件压缩 ≠ 内存压缩(如何识别) ​

层状态大小例子
文件层(编码态)PNG / JPEG / WebP 已压缩几百 KB4K 图 500KB
内存层(解码态)未压缩像素矩阵宽×高×bpp4K 图 ≈ 33MB

识别 / 度量手段:

  • Options.inJustDecodeBounds = true:只读图片头部宽高,不载入像素,先探明原图多大;
  • bitmap.getByteCount() / getAllocationByteCount():度量实际内存;
  • 大图检测:监控解码出的 Bitmap,若 bitmap.getWidth() > imageView.getWidth() * 2 即告警(lab 有实现)。

一张图从文件到内存的流水线:文件压缩 ≠ 内存压缩

图下备注:500KB 是编码态(文件层已压缩),33MB 是解码态(内存层未压缩像素矩阵);识别压缩 = 区分这两层,用 inJustDecodeBounds / getByteCount 度量。

4. inBitmap 内存复用机制 (BitmapPool) ​

Android 4.4+ 支持通过 BitmapFactory.Options.inBitmap 复用已分配的 Bitmap 内存,避免重复申请/释放内存引发内存抖动:

java
BitmapFactory.Options options = new BitmapFactory.Options();
// 1. 设置可复用的旧 Bitmap (旧 Bitmap 大小必须 >= 新解码 Bitmap 大小)
options.inBitmap = reusableBitmap; 
options.inMutable = true;

// 2. 解码新图片直接覆盖在旧内存区域
Bitmap newBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.new_image, options);

注意:inBitmap 只减少"重复分配"(复用旧内存),不减少单张内存;它配合降采样解决的是"列表快速滚动时的内存抖动与峰值"。

5. 解码三方式对比:inSampleSize / setTargetSize / createScaledBitmap ​

解码三方式对比:路径、结果尺寸与峰值内存

图下备注:inSampleSize 解码期隔 N 取 1(2 的幂近似、峰值=小图);setTargetSize 解码期精确缩放(峰值=小图);createScaledBitmap 先全图再缩(峰值=原图+新图)。前两者等比需自算。

维度inSampleSizeImageDecoder.setTargetSizeBitmap.createScaledBitmap
等比✅ 天然等比(同因子)❌ 按给定宽高(会变形)❌ 按给定宽高(会变形)
精确尺寸❌ 只能 2 的幂近似✅ 精确✅ 精确
峰值内存小(解码期)小(解码期)大(先全图再缩,两图并存)
质量隔 N 取 1 / 频域缩放采样 + 插值双线性(filter=true)
  • inSampleSize 为什么必须 2 的幂:解码器按"块 / 行"粒度处理(JPEG 8×8 DCT 块、PNG 行),2 的幂可在解码过程中整齐跳过整行整列或用频域 1/2、1/4 缩放(libjpeg scale),实现高效、无对齐问题;传非 2 的幂会被向下取整。
  • inSampleSize 怎么算:inJustDecodeBounds 读原图宽高 → 从 1 起翻倍,取最大的 2 的幂,使 宽/sample ≥ 目标宽 且 高/sample ≥ 目标高(保证不小于显示尺寸、不糊)。例:4000×3000 → 目标 600×450 → sample=4 → 解码 1000×750 ≈ 3MB(省 16 倍)。
  • 标准套路:inSampleSize 粗降(保内存)+ createScaledBitmap / setTargetSize 精调(控尺寸)。

inSampleSize 隔 N 取 1 的降采样机制

inSampleSize 计算流程与收益

图下备注:采样发生在解码阶段(JPEG 用频域小 DCT 直接输出小图),内存峰值 = 小图;像素 = 原像素 / sample²,sample=4 → 省 16 倍。inSampleSize 只给 2 的幂近似,精确尺寸要配合 setTargetSize / createScaledBitmap。

6. 超大图:BitmapRegionDecoder 分块加载 ​

原理:newInstance() 只解析图片头部(宽高 / 格式),不加载任何像素;之后每次 decodeRegion(rect, options) 只解码视口覆盖的矩形区域;手势平移 / 缩放时用矩阵逆变换重算可见 rect,内存恒定 ≈ 视口大小(1080×1920 ≈ 8MB),与图片总尺寸无关。

java
// 1. 只读头部,不载像素(对比 BitmapFactory 解整张)
BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(inputStream, false);
int width = decoder.getWidth(), height = decoder.getHeight();

// 2. 手势事件里:屏幕可见矩形 -> 逆变换 -> 图片坐标 rect
Rect visibleRect = computeVisibleRect(matrix, width, height);

// 3. 只解码该区域(可再配合 inSampleSize 控制分块精度)
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inSampleSize = 2;
Bitmap tile = decoder.decodeRegion(visibleRect, opts);
canvas.drawBitmap(tile, dstRect, null); // 画到对应位置;旧块 recycle / 复用

BitmapRegionDecoder 按需解码视口区域

图下备注:newInstance 只读头部不载像素;decodeRegion 按需随机读文件对应区域;视口外的像素从不进内存,内存恒定 = 视口大小。

适用:万级像素全景图、地图、长图浏览(第三方如 subsampling-scale-image-view 即此实现;Glide 对超大图内部也走分块解码)。视口外的像素留在文件里,用到才随机读取(JPEG 按 DCT 块对齐)。

7. 输出侧压缩:Bitmap.compress()(存盘 / 上传) ​

compress() 是重新编码成文件字节(编码态),与"内存"是两条线;输出大小 = f(格式, quality, 内容复杂度),与源文件大小无关:

场景格式 / quality说明
照片 / 背景JPEG 或 WebP,q=80~90人眼几乎无差,体积小
截图 / UI / 透明PNG 或 WebP 无损保留透明、可再编辑
二次保存—有损累积:质量 ≤ 原文件,大小通常 ≥ 原文件

WebP 收益:同画质下 WebP 比 PNG/JPEG 体积小 30%~50% 且支持透明,包体 / 网络 I/O / 解码负担同步下降,Android / Flutter 原生支持;iOS 14+ 系统级支持,旧 iOS 需第三方解码(如 SDWebImage WebP 扩展)。

编码态 vs 解码态 vs 保存策略

图下备注:下载 500KB 是编码态、内存 7.9MB 是解码态、保存是重新编码;PNG 文件 ≠ 内存(无损编码约内存的 1/2~1/5)。有保存需求直接落盘原文件字节。

坑:源文件是 500KB 的 JPEG(已高度压缩),解码后 33MB,若用 PNG(无损)重存 → 可能 5MB+(比源文件大 10 倍),清晰度却无提升(信息在 JPEG 时已丢)。有保存需求应直接落盘下载的原文件字节(零重编码、零损失、零 CPU),compress() 只在改尺寸 / 转格式(缩略图、转 WebP)时用——Glide 的 DataCache 就是"先存原始字节再解码"。

Android / Flutter / Web / Backend 对照 ​

维度Android 原生Flutter (Dart)Web / Frontend
像素分配位置Native 堆 (8.0+) / Java 堆 (7.1-) / GPU 显存 (HARDWARE)Skia / Impeller GPU 显存与 Native浏览器 Canvas / GPU 纹理内存
图片复用池Glide BitmapPool (inBitmap)ImageCache (最大 1000 张 / 100MB)浏览器 Image Bitmap Cache
大图告警监控 Bitmap 宽高 > View 尺寸debugInvertOversizedImagesWeb Vitals / 页面大图监控
放图策略密度桶 / nodpiasset 变体 1x/2x/3x(缺失不缩放)srcset / DPR
适配dp + 断点(见 12 模块)逻辑像素 + 断点CSS px + media queries

常见场景 ​

1. 动态采样率压缩 (inSampleSize) ​

在将大图加载到 ImageView 之前,根据控件实际测量尺寸进行二次采样降级:

java
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
    final BitmapFactory.Options options = new BitmapFactory.Options();
    // 只读取图片头部的宽高元数据,不载入像素到内存
    options.inJustDecodeBounds = true;
    BitmapFactory.decodeResource(res, resId, options);

    // 计算最适宜的采样率 inSampleSize (必须是 2 的幂次)
    options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

    // 真正载入图像像素
    options.inJustDecodeBounds = false;
    return BitmapFactory.decodeResource(res, resId, options);
}

private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
    int height = options.outHeight, width = options.outWidth, inSampleSize = 1;
    while (width / 2 >= reqWidth && height / 2 >= reqHeight) {
        width /= 2; height /= 2; inSampleSize *= 2;   // 最大 2 的幂,保证不小于目标
    }
    return inSampleSize;
}

2. 大图检测(开发期告警) ​

java
// 监控所有解码出的 Bitmap:宽高 > 控件尺寸 * 2 即告警(lab 有完整实现)
if (bitmap.getWidth() > imageView.getWidth() * 2 || bitmap.getHeight() > imageView.getHeight() * 2) {
    Log.w("BigImageDetect", "大图未降采样: " + bitmap.getWidth() + "x" + bitmap.getHeight());
}

常见误配、事故后果与排障 ​

1. 事故:列表中控件尺寸 100dp x 100dp,误载入 4K 原始大图 ​

  • 误配原因:直接 BitmapFactory.decodeStream() 或 ImageView.setImageBitmap() 加载原始分辨率大图,未按目标控件尺寸做采样降级。
  • 后果:每张图片浪费 30MB+ 内存,连续滚动 RecyclerView 瞬间触发频繁 GC,导致卡顿甚至爆内存 OOM。
  • 排障与修法:
    1. 使用 Glide 或 Coil 加载图片,它们默认会根据 ImageView 的具体尺寸进行降采样。Compose 侧可改用 Landscapist,其约束驱动采样与组合生命周期绑定同样服务于降内存、减 jank。
    2. 开发测试阶段接入"大图检测工具"(lab 有实现)。

2. 事故:超大整数域 / 超大图整图解码 ​

  • 误配原因:用 BitmapFactory 直接解 1 万×1 万的图(≈400MB);或在超大图上做手势缩放却整图持有。
  • 后果:OOM / Native 内存暴涨。
  • 修法:整图只做缩略图 → inSampleSize;需要缩放浏览 → BitmapRegionDecoder 分块(§6);显示用 → HARDWARE 放显存。

3. 事故:PNG 重存照片导致文件暴涨 ​

  • 误配原因:把 JPEG 照片解码后用 PNG 保存("无损")。
  • 后果:500KB → 5MB+,清晰度无提升。
  • 修法:保存直接落盘原文件字节;必须重编码时照片用 JPEG/WebP q=80~90(§7)。

与相近概念对比 ​

优化手段降低内存原理是否损失画质适用场景
inSampleSize 采样降低像素分辨率(降低宽高)轻微损失缩略图、列表小图
RGB_565 格式减少单像素字节数 (4B → 2B)无透明度 / 色带不带透明度的背景大图
inBitmap 复用重用已有内存空间,减少新分配无RecyclerView 快速滚动列表
BitmapRegionDecoder只解码视口区域,内存恒定=视口无(按需)全景图 / 地图 / 长图
HARDWARE像素放 GPU 显存,不占堆无只读显示场景

对应实验 ​

Lab说明源码
bitmap-memory-demoConfig 对比 / inSampleSize / inBitmap / 大图检测 / RegionDecoderBitmapMemoryDemoActivity.kt

复习检查题 ​

  1. Android 8.0 之后,Bitmap 的像素数据存储在 Java 堆还是 Native 堆?为什么这样改动?

    答:存储在 Native 堆。因为在 Android 3.0~7.1 时代像素数据保存在 Java 堆中,极易占满应用有限的 Java 堆空间(每个 App 有 192M~512M 的上限限制)从而触发 Java OutOfMemoryError。Android 8.0+ 通过 NativeAllocationRegistry 将像素分配在 Native 堆,解除 Java 堆上限约束,同时利用注册机制让 Java 对象被 GC 回收时自动释放对应的 Native 像素空间。

  2. 如何使用 inBitmap 属性实现 Bitmap 内存的零频繁分配?

    答:在解码新图片时,将 BitmapFactory.Options.inBitmap 赋值为一个已经存在的、可变的 (inMutable = true) 且内存容量大于等于新图片解码尺寸的旧 Bitmap 对象。解码器就会直接在旧 Bitmap 的内存地址上覆盖写入新图片的像素数据,从而避免了向堆系统申请新内存和废弃旧内存的过程,减少了内存抖动。

  3. 1080×1920 的图,ARGB_8888 和 RGB_565 各占多少内存?对不透明图片肉眼差别在哪?

    答:8888 ≈ 7.9MB(×4B),565 ≈ 3.96MB(×2B),正好减半。肉眼差别在平滑渐变区域(天空 / 肤色 / 夜景)会出现色带,对比度高的照片几乎看不出;且 565 无透明通道,半透明 UI 不能用。

  4. inSampleSize / setTargetSize / createScaledBitmap 三者峰值内存有何区别?

    答:前两者在解码期完成,峰值 = 小图;createScaledBitmap 必须先有全图,峰值 = 原图 + 新图并存。标准套路是先 inSampleSize 粗降再精调。

  5. 下载 500KB 的 JPEG,为什么用 PNG 保存后可能变成 5MB,且清晰度没提升?

    答:500KB 是编码态(JPEG 已高度压缩);PNG 是无损重编码,照片内容用 PNG 编码体积暴涨 5~10 倍,但信息在 JPEG 解码前已丢失,清晰度不会变好。有保存需求应直接落盘原文件字节。

速记 ​

  • 像素计算公式:内存 = 宽 x 高 x 单像素字节数(与磁盘 PNG 压缩大小无关)。
  • 优化三板斧:降采样 (inSampleSize)、降色彩 (RGB_565)、重复用 (inBitmap)。
  • 8.0 演进:像素移入 Native 堆,解除 Java 堆限制,但仍需防范大图暴涨。
  • 解码三方式:inSampleSize 等比不精确、setTargetSize 精确峰值小、createScaledBitmap 精确但峰值最大。
  • 超大图:RegionDecoder 分块,内存恒定 = 视口。
  • 保存:直接存原文件字节;PNG 存照片会暴涨。

站点构建时间:2026/8/24 23:43:17