Android 大图加载与 OOM 优化
一、问题背景
一张 100M 的图片文件,若直接解码加载到 ImageView,极易发生 OOM(Out of Memory)。原因在于:文件大小 ≠ 内存占用。
1.1 内存计算公式
makefile
Bitmap 内存 ≈ 宽 × 高 × 每像素字节数
ARGB_8888: 4 字节/像素
RGB_565: 2 字节/像素
例如 10000×10000 的 ARGB_8888 图片,解码后约 381MB,远超常见设备堆内存限制。
1.2 核心思路
ImageView 实际显示尺寸有限(如 1080×1920),无需加载整张原图,只需加载接近显示尺寸的 Bitmap。
二、Android / Java 层优化方案
2.1 方案一:使用 Glide(推荐)
Glide 会自动采样、按目标尺寸解码,避免加载过大 Bitmap。
kotlin
Glide.with(context)
.load(urlOrFile)
.override(1080, 1920) // 按目标尺寸采样
.into(imageView)
更省内存的配置:
kotlin
Glide.with(context)
.load(urlOrFile)
.override(1080, 1920)
.format(DecodeFormat.PREFER_RGB_565) // 比 ARGB_8888 省一半内存
.into(imageView)
2.2 方案二:BitmapFactory.Options 手动采样
kotlin
// 1. 先只读尺寸,不分配像素内存
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(path, options)
val width = options.outWidth
val height = options.outHeight
// 2. 按 ImageView 尺寸计算 inSampleSize(取 2 的幂)
val targetW = imageView.width
val targetH = imageView.height
var sampleSize = 1
while (width / sampleSize > targetW || height / sampleSize > targetH) {
sampleSize *= 2
}
// 3. 真正解码
options.inJustDecodeBounds = false
options.inSampleSize = sampleSize
options.inPreferredConfig = Bitmap.Config.RGB_565
val bitmap = BitmapFactory.decodeFile(path, options)
imageView.setImageBitmap(bitmap)
2.3 方案三:BitmapRegionDecoder 区域解码
超大图且需要缩放、平移时,只解码可见区域:
kotlin
val decoder = BitmapRegionDecoder.newInstance(FileInputStream(file), false)
val rect = Rect(0, 0, 1000, 1000)
val options = BitmapFactory.Options().apply {
inSampleSize = 4
inPreferredConfig = Bitmap.Config.RGB_565
}
val bitmap = decoder.decodeRegion(rect, options)
decoder.recycle()
可配合 SubsamplingScaleImageView 等库实现缩放、平移。
2.4 优化手段汇总
| 手段 | 说明 |
|---|---|
| inSampleSize | 按 2 的幂缩小宽高,显著减少内存 |
| override / 目标尺寸 | 按 ImageView 实际尺寸采样 |
| RGB_565 | 比 ARGB_8888 省一半内存,适合无透明通道的图 |
| inJustDecodeBounds | 先只读尺寸,再决定采样比例 |
| BitmapRegionDecoder | 超大图只解码可见区域 |
| recycle() | 不再使用的 Bitmap 及时释放 Native 内存 |
三、C++ / Native 层视角
3.1 底层内存模型
Bitmap 像素数据由 Skia (C++ 库)在 Native 堆 分配,Java 层只持有句柄。OOM 可能来自:
- Java 堆:Bitmap 对象、引用、缓存
- Native 堆:像素缓冲区
- 虚拟地址空间:32 位进程约 2--3GB
3.2 C++ 常见优化手段
(1)内存映射(mmap)
cpp
int fd = open("/path/to/image.jpg", O_RDONLY);
void* addr = mmap(nullptr, fileSize, PROT_READ, MAP_PRIVATE, fd, 0);
// 按需访问:只有被访问的页才会真正加载(demand paging)
munmap(addr, fileSize);
close(fd);
(2)流式 / 分块解码
cpp
// 每次只分配几行缓冲区,逐行解码
int row_stride = cinfo.output_width * cinfo.output_components;
unsigned char* buffer = (unsigned char*)malloc(row_stride);
while (cinfo.output_scanline < cinfo.output_height) {
jpeg_read_scanlines(&cinfo, &buffer, 1);
// 处理这一行
}
(3)区域解码(ROI)
cpp
SkIRect subset = SkIRect::MakeXYWH(offsetX, offsetY, width, height);
decoder->decodeSubset(®ionBitmap, subset, config);
(4)采样解码
cpp
// 在解码阶段就缩小尺寸
cinfo.scale_num = 1;
cinfo.scale_denom = 4; // 解码为 1/4 尺寸
(5)内存池复用
cpp
// 预分配 buffer 池,避免频繁 malloc/free,减少碎片
3.3 与 Android 的对应关系
| C++ 思路 | Android 对应 |
|---|---|
| mmap | 部分解码器在 native 层用 mmap 读文件 |
| 流式解码 | libjpeg、libpng 的 progressive decode |
| 区域解码 | BitmapRegionDecoder |
| 采样解码 | BitmapFactory.Options.inSampleSize |
| 内存池 | Glide LruBitmapPool、Skia 内部 bitmap 池 |
3.4 C++ 实现注意点
- RAII :用
std::unique_ptr、std::shared_ptr管理 buffer,避免泄漏 - 对齐:解码/渲染常要求 4 或 16 字节对齐
- 线程安全:解码放子线程,避免阻塞 UI
- 零拷贝:解码直接输出到目标 buffer,减少中间拷贝
四、整体流程示意
arduino
文件 (100MB)
│
├─ mmap ──→ 虚拟地址映射,按需加载
│
├─ 流式解码 ──→ 每次只分配若干行 buffer
│
├─ 区域解码 ──→ 只分配 ROI 的 buffer
│
└─ 采样解码 ──→ 解码时缩小尺寸,减少像素数
│
└─→ 最终像素 buffer ≈ 目标显示尺寸 × bytesPerPixel
五、项目实践建议
项目已使用 Glide,优先用 Glide 加载大图并显式指定目标尺寸:
kotlin
Glide.with(context).load(url)
.override(1080, 1920)
.fitCenter()
.into(imageView)
若仍有 OOM,可:
- 在 GlideConfig 中调整内存缓存和 Bitmap 池
- 对特定大图使用
format(DecodeFormat.PREFER_RGB_565) - 超大图考虑
BitmapRegionDecoder+SubsamplingScaleImageView
六、内存紧张时的加载策略
6.1 问题场景
系统内存吃紧(onTrimMemory / onLowMemory)时,若仍在大量加载图片,易导致 OOM、卡顿。需在保证可用性的前提下维持流畅体验。
6.2 设计原则
| 原则 | 说明 |
|---|---|
| 可见优先 | 当前屏幕上的图片 > 预加载 > 后台加载 |
| 降级不崩溃 | 宁可少加载、用占位图,也不要 OOM |
| 渐进反馈 | 先占位图,再低质量,最后高质量 |
| 可恢复 | 内存恢复后,能继续加载之前被取消或降级的图片 |
6.3 核心策略
(1)监听内存压力
kotlin
override fun onTrimMemory(level: Int) {
when (level) {
ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE,
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ->
imageLoader.onMemoryPressure(PressureLevel.MODERATE)
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ->
imageLoader.onMemoryPressure(PressureLevel.CRITICAL)
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN ->
imageLoader.onMemoryPressure(PressureLevel.BACKGROUND)
}
}
override fun onLowMemory() {
imageLoader.onMemoryPressure(PressureLevel.CRITICAL)
}
(2)分级策略
| 压力等级 | 行为 | 用户体验 |
|---|---|---|
| 正常 | 正常加载、预加载、缓存 | 流畅 |
| MODERATE | 缩小内存缓存、减少预加载、降低采样质量 | 略慢但可接受 |
| CRITICAL | 只加载可见项、取消非可见加载、清空内存缓存 | 优先保证不崩、不卡 |
| BACKGROUND | 清空内存缓存,保留磁盘缓存 | 回到前台可快速恢复 |
(3)加载优先级队列
高优先级:当前可见 ImageView 的请求
中优先级:即将进入视口的预加载
低优先级:列表下方、不可见区域的预加载
内存紧张时:高优先级继续加载(必要时降采样),中优先级可延迟,低优先级直接取消。
(4)渐进式加载
arduino
内存正常:原图 / 高质量
↓
内存紧张:缩小 override、使用 RGB_565
↓
内存极紧张:占位图 + 取消加载,或仅加载缩略图
(5)占位图与失败反馈
- 所有请求先设
placeholder,内存紧张时也能立刻显示 - 被取消的请求:不弹错误提示,用户滑动回来时自动重试
- 真正失败(网络/解码错误):再考虑
error占位或轻量提示
6.4 与 Glide 的配合
kotlin
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Glide.get(this).onTrimMemory(level)
}
override fun onLowMemory() {
super.onLowMemory()
Glide.get(this).onLowMemory()
}
Glide 会按系统回调自动裁剪内存缓存和 Bitmap 池;业务层再根据 level 控制非可见区域的预加载是否取消、是否使用更小的 override。
6.5 体验小结
| 手段 | 作用 |
|---|---|
| 占位图 | 内存紧张时仍能快速显示内容 |
| 可见优先 | 保证当前屏幕图片优先加载 |
| 取消非可见 | 降低内存占用,避免 OOM |
| 降采样 / 格式 | 在内存紧张时减少单张图占用 |
| 内存恢复后重试 | 用户滑动回来时自动补全,体验连贯 |
| 少打扰 | 被取消的加载不显示错误,用户无感知 |
七、Glide LruCache 能否解决内存紧张问题?
7.1 结论:不能单独解决,需配合系统回调
LruCache 只负责缓存淘汰策略 ,不能感知系统内存压力,也不能控制正在进行的加载。Glide 实际是通过 LruCache + onTrimMemory/onLowMemory 一起工作的。
7.2 LruCache 能做什么、不能做什么
| 能力 | LruCache | 说明 |
|---|---|---|
| 缓存淘汰 | ✅ | 缓存满时按 LRU 淘汰最久未用项 |
| 感知系统内存压力 | ❌ | 不监听 onTrimMemory / onLowMemory |
| 主动释放内存 | ❌ | 只在「放入新项且已满」时被动淘汰 |
| 取消进行中的加载 | ❌ | 只管已缓存对象,不管网络/解码任务 |
| 区分可见/不可见 | ❌ | 不区分优先级,只看访问时间 |
7.3 LruCache 的局限
- 被动淘汰:LRU 只在「新图片要进缓存且缓存已满」时淘汰,不会在系统内存紧张时主动清空或缩小缓存。
- 不处理进行中的加载:正在解码或下载的图片会继续占用内存,LRU 无法取消这些任务。
- 可能淘汰即将显示的图片:按「最近最少使用」淘汰,可能把用户马上要看到的图片清掉,导致再次加载。
- 无法做加载优先级:不区分当前可见、预加载、后台加载,无法在内存紧张时优先保证可见内容。
7.4 Glide 的实际做法
Glide 同时使用 LruCache 和 系统内存回调:
LruResourceCache:内存缓存,LRU 淘汰LruBitmapPool:Bitmap 复用池- 收到
onTrimMemory/onLowMemory时:主动清空或缩小缓存
即:LRU 负责日常淘汰,系统回调负责在内存紧张时主动释放。
7.5 对比
| 场景 | 仅 LruCache | LruCache + onTrimMemory |
|---|---|---|
| 缓存满,新图进来 | 淘汰最久未用 | 同左 |
| 系统内存紧张 | 无动作,可能 OOM | 主动清空/缩小缓存 |
| 进行中的加载 | 无法取消 | 仍无法取消(需业务层配合) |
7.6 总结
- LruCache 本身:解决的是「缓存满了该删谁」的问题,不能解决「内存紧张时如何保护体验」的问题。
- Glide 的做法:LruCache + onTrimMemory/onLowMemory,能在系统压力下主动释放缓存,降低 OOM 风险。
- 仍需要业务层配合的:取消非可见区域的预加载、在内存紧张时降低采样、使用 RGB_565、合理设置 placeholder 等。