Android 大图加载与 OOM 优化

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(&regionBitmap, 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_ptrstd::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 的局限

  1. 被动淘汰:LRU 只在「新图片要进缓存且缓存已满」时淘汰,不会在系统内存紧张时主动清空或缩小缓存。
  2. 不处理进行中的加载:正在解码或下载的图片会继续占用内存,LRU 无法取消这些任务。
  3. 可能淘汰即将显示的图片:按「最近最少使用」淘汰,可能把用户马上要看到的图片清掉,导致再次加载。
  4. 无法做加载优先级:不区分当前可见、预加载、后台加载,无法在内存紧张时优先保证可见内容。

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 等。
相关推荐
南城书生1 小时前
Android Handler 机制源码分析
前端
南城书生2 小时前
RecyclerView 源码分析
前端
南城书生2 小时前
LeakCanary 原理分析
前端
没想好d2 小时前
通用管理后台组件库-13-页签组件
前端
xChive2 小时前
ECharts-大屏开发复习记录与踩坑总结
前端·javascript·echarts
南城书生2 小时前
Java HashMap 源码分析
前端
南城书生2 小时前
Java 线程池(ThreadPoolExecutor)源码分析
前端
前端Hardy2 小时前
别再靠 Code Review 纠格式了!一套自动化前端工程化方案,让 Vue 项目提交即合规
前端·程序员·代码规范
前端Hardy2 小时前
用 uni-app x 重构我们的 App:一套代码跑通 iOS、Android、鸿蒙!人力成本直降 60%
前端·ios·uni-app