Android开发之高效加载Bitmap

概述

在Android开发中,我们经常与Bitmap打交道,而对Bitmap的不恰当的操作经常会导致OOM(Out of Memory)。这篇文章我们会介绍如何高效地在Android开发中使用Bitmap,在保证图片显示质量的前提下尽可能占用更小的内存。

什么是Bitmap

Android中的Bitmap对象是对位图的抽象,它可以从文件系统、资源文件夹、网络等各种不同的来源获取。位图可以看做是像素点的集合,本质上就是通过一系列二进制位来描述一张图片,具有不同色彩格式的位图使用不同数量的二进制位来描述一个像素点,因而图片质量和图片大小也就不同。

Bitmap占用的内存计算

屏幕密度

首先,我们来介绍下两个名词:density和densityDpi,它们的含义分别如下:

  • density:可以理解为相对屏幕密度,我们知道,1个DIP在160dpi的屏幕上大约为1像素大小。我们以160dpi为基准线,density的值即为相对于160dpi屏幕的相对屏幕密度。比如,160dpi屏幕的density值为1, 320dpi屏幕的density值为2
  • densityDpi:可以理解为绝对屏幕密度,也就是实际的屏幕密度值(dots per inch),比如160dpi屏幕的densityDpi值就是160

计算Bitmap占用的内存

Bitmap占用的内存不仅与它的像素点数和色彩格式有关,还和具体设备的屏幕密度、所在的drawable文件夹有关。下面我们来通过一个实例介绍这些因素是如何影响Bitmap所占用的内存的大小的。这里我们使用的虚拟机的屏幕密度(densityDpi)为240dpi,图片文件(670 * 376)存放在drawable-xhdpi文件夹下。我们可以通过以下代码获取Bitmap对象并计算它所占用的内存大小:

Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.size);
int size = bitmap.getByteCount();

我们可以得到size值为567384。以上代码中我们通过getByteCount方法来获取Bitmap对象以字节为单位的大小,我们来看一下这个方法的源码:

public final int getByteCount() { 
  // int result permits bitmaps up to 46,340 x 46,340 
  return getRowBytes() * getHeight();
}

其中getHeight方法会返回Bitmap对象的mHeight实例域,也就是图片的高度(单位为px),而getRowBytes方法返回的是图片的像素宽度与色彩深度的乘积。这样综合起来,我们知道了getByteCount方法的返回值是这样计算的:像素宽 * 像素高 * 色彩深度。其中色彩深度与Bitmap的色彩格式有关,默认为ARGB_8888,也就是一个像素大小为32位(4字节)。根据这个公式我们来算一下:670 * 376 * 4 = 1007680。跟我们得到的567384差了不少,这是为什么呢?因为我们没有考虑到的图片所在资源文件夹以及设备的屏幕密度。
图片所在资源文件夹和设备的屏幕密度这两个参数分别对应着BitmapFactory中的inDensity和inTargetDensity。比如我们的图片在drawable-xhdpi文件夹下,那么inDensity值就为320;设备的屏幕密度为240dpi,因而inTargetDensity的值就为240。把图片显示到一个设备上要根据各自的屏幕密度进行缩放,这个缩放系数即为inTargetDensity除以inDensity。具体解释以下:我们知道dpi代表着每inch的像素点数,那么设图片像素宽高分别为pixWidth、pixHeight,我们把图片放到了drawable-xhdpi文件夹下(inDensity为240dpi),pixWidth、pixHeight分别除以inDensity可以得到图片的物理宽高(单位inch),然后我们把这个物理宽高分别乘以设备的屏幕密度再相乘,也就可以得到目标设备上图片的像素数了。按照这个过程我们可以得到目标设备上图片的像素数的计算公式:(pixWidth / inDensity * inTargetDensity) * (pixHeight / inDensity * inTargetDensity) 。将这个像素数乘以4就可以得到在内存中的大小了,我们来验证下:(670 / 320 * 240) * (376 / 320 *240) * 4 = 566830。和通过getByteCount得到的值近似相等。关于为什么不相等,感兴趣的小伙伴可以参考这篇文章:Android 开发绕不过的坑:你的 Bitmap 究竟占多大内存? 而在实际开发中,这种影响我们通常可以忽略。

高效加载Bitmap

上面我们介绍了内存中Bitmap的大小的计算方法,我们当然希望Bitmap在图像品质可以接受的前提下占用尽可能小的内存。下面我们来介绍一下如何更加高效的加载Bitmap对象。

BitmapFactory

BitmapFactory类提供了以下几个静态方法用来以不同的“原料”生产一个Bitmap对象:

//把一个byte数组从offset开始的length个字节解析为一个Bitmap对象
decodeByteArray(byte[] data, int offset, int length);
//把pathName指定的文件解析成一个Bitmap对象
decodeFile(String pathName);
//把描述符fd指定的文件解析为一个Bitmap对象
decodeFileDescriptor(FileDescriptor fd);
//根据id从给定的资源中解析出一个Bitmap对象,加载这个对象到内存中时应用options指定的选项
decodeResource(Resources res, int id, Bitmap.Options options); 
//从给定的流中解析出一个Bitmap对象
decodeStream(InputStream is);

我们下面的讲解主要围绕decodeResource方法来进行,通过对它的options进行合理的配置,我们就能够将Bitmap对象调整到令我们满意的大小。

Options类介绍

要实现高效加载Bitmap,首先我们要了解Options类的几个参数,因为正是通过合理的配置这几个参数,我们才能够实现高效的加载Bitmap对象。Options类是BitmapFactory的一个静态内部类,我们来看一下它的源码:

public static class Options { 
  public Options() { 
    inDither = false; 
    inScaled = true; 
    inPremultiplied = true; 
  } 

  public Bitmap inBitmap; //用于实现Bitmap的复用,下面会具体介绍 
  public int inSampleSize; //采样率 
  public boolean inPremultiplied; 
  public boolean inDither; //是否开启抖动
  public int inDensity; //即上文我们提到的inDensity
  public int inTargetDensity; //目标屏幕密度,同上文提到的含义相同 
  public boolean inScaled; //是否支持缩放
  public int outWidth; //图片的原始宽度
  public int outHeight; //图片的原始高度
  ... 
}

下面我们来具体介绍如何通过配置Options来实现Bitmap的高效加载。

缩放系数

在上面的源码中,我们看到Options类中存在一个inScaled参数,这个参数表示是否支持缩放,我们从Options的默然构造方法中可以看到这个参数被初始化为了true,也就是说默认是支持缩放的。那么将如何进行缩放呢?答案是根据缩放系数进行缩放。关于缩放系数的计算方法,其实我们在讲解如何计算内存中Bitmap的大小时已经介绍过了。缩放系数就是inTargetDensity除以inDensity。inDensity表示我们的图片所处的资源文件夹对应的dpi,inTargetDensity表示目标设备的屏幕密度。
通过以上的实践我们了解到了,就算不给decodeResource方法传入Options对象,它也会根据缩放系数对Bitmap进行缩放。我们当然也可以手动设置缩放系数,下面我们还是拿上面那个图片举例子,请看以下代码:

BitmapFactory.Options options = new BitmapFactory.Options();
options.inDensity = 160;
options.inTargetDensity = 320;
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.size, options);
int size = bitmap.getByteCount();

我们先来计算下size应该为多大:(670 / 160 * 320) * (376 / 160 * 320) *4 = 4030720。我们运行程序,可得到size的实际大小为:4030720。由此可见我们的设置生效了。

采样率(inSampleSize)

下面我们来介绍inSampleSize这个参数,当这个参数为1时,采样后的图片大小和原来一样;当这个参数为2时,采样后的图片宽高均为原来的1/2,大小也就成了原来的1/4。也就是说,采样后的大小等于原始大小除以采样率的平方。官方文档规定,inSampleSize的值应为2的非负整数次幂(1,2,4,... ),否则会被系统向下取整并找到一个最接近的值。通过设置inSampleSize我们就能够将图片缩放到一个合理的大小,那么该如何设置inSampleSize的值呢?在讲解这个之前,我们先来考虑以下情况:我们的ImageView的大小为100 * 100,要显示的图片大小为300 * 400,此时我们应该将inSampleSize设为多少呢。谁先我们通过计算可以得到图片宽是ImageView的3倍,而图片高是ImageView的4倍。那么我们应该将图片宽高缩小为原来的4倍吗?假如我们把图片宽高都变为原来的1/4,那么现在图片大小为75 * 100,ImageView大小为100 * 100,图片要显示在ImageView中需要进行拉伸,而拉伸的话可能会导致图片失真。所以我们应该把图片宽高变为原来的1/3,以保证它不小于ImageView的大小,这样尽管多占用一些内存,但不会造成图片质量的下降,这还是很有必要的。通过以上分析,我们知道了在设置inSampleSize时应该注意使得缩放后的图片大小不小于相应的ImageView大小。

计算inSampleSize的步骤通常如下:

  • 第一步,获取图片的原始宽高,通过将Options的inJustDecodeBounds属性设为true后调用decodeResource方法,可以实现不真正加载图片而只是获取图片的尺寸信息,请看以下代码:
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(getResources(), resId, options);
//现在原始宽高以存储在了options对象的outWidth和outHeight实例域中
  • 第二步,根据原始宽高计算出inSampleSize,代码如下:
//dstWidth和dstHeight分别为目标ImageView的宽高
public static int calSampleSize(BitmapFactory.Options options, 
    int dstWidth, int dstHeight) { 
    int rawWidth = options.outWidth; 
    int rawHeight = options.outHeight; 
    int inSampleSize = 1; 
    if (rawWidth > dstWidth || rawHeight > dstHeight) { 
      float ratioHeight = (float) rawHeight / dstHeight; 
      float ratioWidth = (float) rawWidth / dstHeight; 
      inSampleSize = (int) Math.min(ratioWidth, ratioHeight);
    }
    return inSampleSize;
}

以上代码的逻辑很直接,唯一需要注意的就是要记得使采样后的图片能够“覆盖”ImageView,以防止图片质量下降。计算inSampleSize并加载采样后图片的完整demo请见这里:计算inSampleSize并显示图片的完整示例
下面我们来简单介绍下inBitmap这个参数的作用。

inBitmap参数

这个参数用来实现Bitmap内存的复用,但复用存在一些限制,具体体现在:在Android 4.4之前只能重用相同大小的Bitmap的内存,而Android 4.4及以后版本则只要后来的Bitmap比之前的小即可。使用inBitmap参数前,每创建一个Bitmap对象都会分配一块内存供其使用,而使用了inBitmap参数后,多个Bitmap可以复用一块内存,这样可以提高性能。
关于这个复用Bitmap内存的详细方法以及注意事项Android Developer网站已给出了详细的说明(Managing Bitmap Memory)。这里简单的贴出部分示例代码了解下它的大致用法:

private static void addInBitmapOptions(BitmapFactory.Options options, ImageCache cache) { 
  // inBitmap only works with mutable bitmaps, so force the decoder to 
  // return mutable bitmaps. 
  options.inMutable = true; 
  if (cache != null) { 
    // Try to find a bitmap to use for inBitmap. 
    Bitmap inBitmap = cache.getBitmapFromReusableSet(options); 
    if (inBitmap != null) { 
      // If a suitable bitmap has been found, 
      // set it as the value of inBitmap. 
      options.inBitmap = inBitmap; 
    } 
  }
}

static boolean canUseForInBitmap( Bitmap candidate, 
    BitmapFactory.Options targetOptions) { 
  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { 
    // From Android 4.4 (KitKat) onward we can re-use 
    // if the byte size of the new bitmap is smaller than 
    // the reusable bitmap candidate 
    // allocation byte count. 
    int width = targetOptions.outWidth / targetOptions.inSampleSize; 
    int height = targetOptions.outHeight / targetOptions.inSampleSize; 
    int byteCount = width * height * getBytesPerPixel(candidate.getConfig()); 
    return byteCount <= candidate.getAllocationByteCount(); 
  } 
  // On earlier versions, 
  // the dimensions must match exactly and the inSampleSize must be 1 
  return candidate.getWidth() == targetOptions.outWidth 
      && candidate.getHeight() == targetOptions.outHeight 
      && targetOptions.inSampleSize == 1;
}

Android Developer上的 Displaying Bitmap Efficiently 系列教程对Android开发中如何高效使用Bitmap做出了细致地描述,学好这个系列,玩儿转Bitmap自然就不在话下了:)

参考资料

  1. Displaying Bitmap Efficiently
  2. Android 开发绕不过的坑:你的 Bitmap 究竟占多大内存?
  3. 《Android开发艺术探索》
  4. Android Bitmap面面观

**长按或扫描二维码关注我们,让您利用每天等地铁的时间就能学会怎样写出优质app。 **


推荐阅读更多精彩内容