注册

Android 位图(图片)加载引入的内存溢出问题分析

1.一些定义


在分析具体问题之前,我们先了解一些基本概念,这样可以帮助理解后面的原理部分。当然了,大家对于这部分定义已经了然于胸的,就可以跳过了。


什么是内存泄露?


我们知道Java GC管理的主要区域是堆,Java中几乎所有的实例对象数据实际是存储在堆上的(当然JDK1.8之后,针对于不会被外界调用的数据而言,JVM是放置于栈内的)。针对于某一程序而言,堆的大小是固定的,我们在代码中新建对象时,往往需要在堆中申请内存,那么当系统不能满足需求,于是产生溢出。或者可以这样理解堆上分配的内存没有被释放,从而失去对其控制。这样会造成程序能使用的内存越来越少,导致系统运行速度减慢,严重情况会使程序宕掉。


什么是位图?


位图使用我们称为像素的一格一格的小点来描述图像,计算机屏幕其实就是一张包含大量像素点的网格,在位图中,平时看到的图像将会由每一个网格中的像素点的位置和色彩值来决定,每一点的色彩是固定的,而每个像素点色彩值的种类,产生了不同的位图Config,常见的有:



ALPHA_8, 代表8位Alpha位图,每个像素占用1byte内存
RGB_565,代表8位RGB位图,每个像素占用2byte内存
ARGB_4444 (@deprecated),代表16位ARGB位图,每个像素占用2byte内存
ARGB_8888,代表32位ARGB位图,每个像素占用4byte内存



其实很好理解,我们知道RGB是指红蓝绿,不同的config代表,计算机中每种颜色用几位二进制位来表示,例如:RGB_565代表红5为、蓝6位、绿5为。


2.原理分析


2.1 原理分析一


由第一节的基础定义,我们知道不过JVM还是Android虚拟机,对于每个应用程序可用内存大小是有约束的,而针对于单个程序中Bitmap所占的内存大小也有约束(一般机器是8M、16M,大家可以通过查看build.prop文件去查看这个定义大小),一旦超过了这个大小,就会报OOM错误。
Android编程中,我们经常会使用ImageView 控件,加载图片,例如以下代码:


package com.itbird.BitmapOOM;

import android.graphics.BitmapFactory;
import android.os.Bundle;
import android.widget.ImageView;

import androidx.appcompat.app.AppCompatActivity;

import com.itbird.R;

public class ImageViewLoadBitmapTestActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.imageviewl_load_bitmap_test);
ImageView imageView = findViewById(R.id.imageview);
imageView.setImageResource(R.drawable.bigpic);
imageView.setBackgroundResource(R.drawable.bigpic);
imageView.setImageBitmap(BitmapFactory.decodeFile("path/big.jpg"));
imageView.setImageBitmap(BitmapFactory.decodeResource(getResources(), R.drawable.bigpic));
}
}

当图片很小时,一般不会有问题,当图片很大时,就会出现OOM错误,原因是直接调用decodeResource、setImageBitmap、setBackgroundResource时,实际上,这些函数在完成照片的decode之后,都是调用了java底层的createBitmap来完成的,需要消耗更多的内存。至于为什么会消耗那么多内存,如下面的源码分析:
android8.0之前Bitmap源码


public final class Bitmap implements Parcelable {
private static final String TAG = "Bitmap";
...
private byte[] mBuffer;
...
}

android8.0之后Bitmap源码


public final class Bitmap implements Parcelable {
...
// Convenience for JNI access
private final long mNativePtr;
...
}

对上上述两者,相信大家已经看出点什么了,android8.0之前,Bitmap在Java层保存了byte数组,而且细跟源码的话,您也会发现,8.0之前虽然调用了native函数,但是实际其实就是在native层创建Java层byte[],并将这个byte[]作为像素存储结构,之后再通过在native层构建Java Bitmap对象的方式,将生成的byte[]传递给Bitmap.java对象。(这里其实有一个小知识点,android6.0之前,源码里面很多这样的实现,通过C层来创建Java层对象)。
image.png


而android8.0之后,Bitmap在Java层保存的只是一个地址,,Bitmap像素内存的分配是在native层直接调用calloc,所以其像素分配的是在native heap上, 这也是为什么8.0之后的Bitmap消耗内存可以无限增长,直到耗尽系统内存,也不会提示Java OOM的原因。
image.png


2.2 原理分析二


看完上面的源码解读,大家一定想知道,那我如果在自己应用中的确有大图片的加载需求,那怎么办呢?调用哪个函数呢?
BitmapFactory.java中有一个Bitmap decodeStream(InputStream is)这个函数,我们可以查看源码,这个函数底层调用了native c函数
image.png
在底层进行了decode之后,转换为了bitmap对象,返回给Java层。


3 编程中如何避免图片加载的OOM错误


通过上面章节的知识探索,相信大家已经知道了加载图片时出现OOM错误的原因,其实真正的原因并未是网上很多文章说的,不要使用调用ImageView的某某函数、BitmapFactory的某某函数,真正的原因是,对于大图片,Java堆和Native堆无法申请到可用内存时,就会出现OOM错误,那么针对于不同的系统版本,Android存储、创建图片的方式又有所不同,带来了加载大图片时的OOM错误。
那么接下来,大家最关心的解决方案,有哪些?我们在日常编码中,应该如何编码,才能有效规避此类错误的出现,别急。


3.1 利用BitmapFactory.decodeStream加载InputStream图片字节流的方式显示图片


 /**
* 以最省内存的方式读取本地资源的图片
*/
public static Bitmap readBitMap(String path, BitmapFactory.Options opt, InputStream is) {
opt.inPreferredConfig = Bitmap.Config.RGB_565;
if (Build.VERSION.SDK_INT <=android.os.Build.VERSION_CODES.KITKAT ) {
opt.inPurgeable = true;
opt.inInputShareable = true;
}
opt.inSampleSize = 2;//二分之一缩放,可写1即100%显示
//获取资源图片
try {
is = new FileInputStream(path);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return BitmapFactory.decodeStream(is, null, opt);
}

大家可以看到上面的代码,实际上一方面针对Android 4.4之下的直接声明了opt属性,告诉系统可以回收,一方面直接进行了图片缩放。说到这里,大家会有疑问,为什么是android4.4以下加这两个属性,难道之后就不用了了。不要着急,我们看源码:
源码.png
可以看到源码上说明,此属性4.4之前有用,5.0之后即使设置了,底层也是忽略的。也许大家会问,难道5.0之后Bitmap的源码有什么大的改动吗?的确是,可以看一下以下源码。
8.0之后的Bitmap内存回收机制
NativeAllocationRegistry是Android 8.0引入的一种辅助自动回收native内存的一种机制,当Java对象因为GC被回收后,NativeAllocationRegistry可以辅助回收Java对象所申请的native内存,拿Bitmap为例,如下:


Bitmap(long nativeBitmap, int width, int height, int density,
boolean isMutable, boolean requestPremultiplied,
byte[] ninePatchChunk, NinePatch.InsetStruct ninePatchInsets) {
...
mNativePtr = nativeBitmap;
long nativeSize = NATIVE_ALLOCATION_SIZE + getAllocationByteCount();
<!--辅助回收native内存-->
NativeAllocationRegistry registry = new NativeAllocationRegistry(
Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), nativeSize);
registry.registerNativeAllocation(this, nativeBitmap);
if (ResourcesImpl.TRACE_FOR_DETAILED_PRELOAD) {
sPreloadTracingNumInstantiatedBitmaps++;
sPreloadTracingTotalBitmapsSize += nativeSize;
}
}

当然这个功能也要Java虚拟机的支持,有机会再分析。


**实际使用效果:**3M以内的图片加载没有问题,但是大家注意到一点,没我们代码中是固定缩放了一般,这时大家肯定有疑问,有没有可能,去动态根据图片的大小,决定缩放比例。


3.2 利用BitmapFactory.decodeStream通过按比例压缩方式显示图片


    /**
* 以计算的压缩比例加载大图片
*
* @param res
* @param resId
* @param reqWidth
* @param reqHeight
* @return
*/
public static Bitmap decodeCalSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) {
// 检查bitmap的大小
final BitmapFactory.Options options = new BitmapFactory.Options();
// 设置为true,BitmapFactory会解析图片的原始宽高信息,并不会加载图片
options.inJustDecodeBounds = true;
options.inPreferredConfig = Bitmap.Config.RGB_565;

BitmapFactory.decodeResource(res, resId, options);

// 计算采样率
options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight);

// 设置为false,加载bitmap
options.inJustDecodeBounds = false;

return BitmapFactory.decodeResource(res, resId, options);
}

/*********************************
* @function: 计算出合适的图片倍率
* @options: 图片bitmapFactory选项
* @reqWidth: 需要的图片宽
* @reqHeight: 需要的图片长
* @return: 成功返回倍率, 异常-1
********************************/
private static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth,
int reqHeight) {
// 设置初始压缩率为1
int inSampleSize = 1;
try {
// 获取原图片长宽
int width = options.outWidth;
int height = options.outHeight;
// reqWidth/width,reqHeight/height两者中最大值作为压缩比
int w_size = width / reqWidth;
int h_size = height / reqHeight;
inSampleSize = w_size > h_size ? w_size : h_size; // 取w_size和h_size两者中最大值作为压缩比
Log.e("inSampleSize", String.valueOf(inSampleSize));
} catch (Exception e) {
return -1;
}
return inSampleSize;
}

大家可以看到,上面代码实际上使用了一个属性inJustDecodeBounds,当inJustDecodeBounds设为true时,不会加载图片仅获取图片尺寸信息,也就是说,我们先通过不加载实际图片,获取其尺寸,然后再按照一定算法(以需要的图片长宽与实际图片的长宽比例来计算)计算出压缩的比例,然后再进行图片加载。


**实际使用效果:**测试该方法可以显示出来很大的图片,只要你设定的长宽合理。


3,3 及时的回收和释放


直接上代码


 /**
* 回收bitmap
*/
private static void recycleBitmap(ImageView iv) {
if (iv != null && iv.getDrawable() != null) {
BitmapDrawable bitmapDrawable = (BitmapDrawable) iv.getDrawable();
iv.setImageDrawable(null);
if (bitmapDrawable != null) {
Bitmap bitmap = bitmapDrawable.getBitmap();
if (bitmap != null) {
bitmap.recycle();
}
}
}
}

/**
* 在Activity或Fragment的onDestory方法中进行回收(必须确保bitmap不在使用)
*/
public static void recycleBitmap(Bitmap bitmap) {
// 先判断是否已经回收
if (bitmap != null && !bitmap.isRecycled()) {
// 回收并且置为null
bitmap.recycle();
bitmap = null;
}
}

4.总结


4.1 OOM出现原因


对于大图片,直接调用decodeResource、setImageBitmap、setBackgroundResource时,这些函数在完成照片的decode之后,都是调用了java底层的createBitmap来完成的,需要消耗更多的内存。Java堆和Native堆无法申请到可用内存时,就会出现OOM错误,那么针对于不同的系统版本,Android存储、创建图片的方式又有所不同,带来了加载大图片时的OOM错误。


4.2 解决方案


1.针对于图片小而且频繁加载的,可以直接使用系统函数setImageXXX等
2针对于大图片,在进行ImageView setRes之前,需要先对图片进行处理
1)压缩
2)android4.4之前,需要设置opt,释放bitmap,android5.0之后即使设置,系统也会忽略
3)设置optConfig为565,降低每个像素点的色彩值
4)针对于频繁使用的图片,可以使用inBitmap属性
5)由于decodeStream直接读取的图片字节码,并不会根据各种机型做自动适配,所以需要在各个资源文件夹下放置相应的资源
6)及时回收


作者:itbird01
链接:https://juejin.cn/post/7179037417704259640
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册