一、背景
在Android开发中,任何一个APP都离不开图片的加载和显示问题。这里的图片来源分为三种:项目图片资源文件(一般为res/drawable目录下的图片文件)、手机本地图片文件、网络图片资源等。图片的显示我们一般采用ImageView作为载体,通过ImageView的相应API即可设置其显示的图片内容。
我们知道:如果是需要展示项目中的图片资源文件,我们只需要调用ImageView的setImageResource(int id)方法并传入该图片资源的id(一般为R.drawable.xxx)即可。但是如果是需要展示手机本地的某张图片或者网络上的某个图片资源,又该怎么办呢?——问题A
为了回答问题A,我们先思考一个更深的问题B:Android中是如何将某一张图片的内容加载到内存中继而由ImageView显示的呢?
我们知道:如果我们想通过TextView展示一个本地txt文件的内容,我们只需要由该文件创建并包装一个输入流对象。通过该输入流对象即可得到一个代表该文件内容的字符串对象,再将该字符串对象交由TextView展示即可。换句话说,这个txt文件的内容在内存中的表达形式就是这个字符串对象。
类推一下,虽然图片文件也是文件,但是我们显然不可能对图片文件也采用这种方式:即通过该图片建立并包装一个输入流对象再获取一个字符串对象。毕竟无论如何我们都无法将某个图片的内容表示为一个字符串对象(细想一下就知道了,你能通过一段话100%准确地描述一张图片吗?显然不现实)。那么,这就引入了问题C:既然字符串对象不行,那么我们该以哪种对象来在内存中表示某个图片的内容呢?答案就是:Bitmap对象!
二、基本概述
Bitmap,即位图。它本质上就是一张图片的内容在内存中的表达形式。那么,Bitmap是通过什么方式表示一张图片的内容呢?
Bitmap原理:从纯数学的角度,任何一个面都由无数个点组成。但是对于图片而言,我们没必要用无数个点来表示这个图片,毕竟单独一个微小的点人类肉眼是看不清的。换句话说,由于人类肉眼的能力有限,我们只需要将一张图片表示为 有限但足够多的点即可。点的数量不能无限,因为无限的点信息量太大无法存储;但是点的数量也必须足够多,否则视觉上无法形成连贯性。这里的点就是像素。比如说,某个1080*640的图片,这里的像素总数即为1080X640个。
将图片内容表示为有限但足够多的像素的集合,这个“无限→有限”的思想极其迷人。所以,我们只需要将每个像素的信息存储起来,就意味着将整个图片的内容进行了表达。
像素信息:每个像素的信息,无非就是ARGB四个通道的值。其中,A代表透明度,RGB代表红绿蓝三种颜色通道值。每个通道的值范围在0~255之间,即有256个值,刚好可以通过一个字节(8bit)进行表示。所以,每个通道值由一个字节表示,四个字节表示一个像素信息,这似乎是最好的像素信息表示方案。
但是这里忽略了两个现实的需求问题:
①在实际需求中,我们真的需要这么多数量的颜色吗?上述方案是256X256X256种。有的时候,我们并不需要这么丰富的颜色数量,所以可以适当减少表示每个颜色通道的bit位数。这么做的好处是节省空间。也就是说,每个颜色通道都采用8bit来表示是代表全部颜色值的集合;而我们可以采用少于8bit的表示方式,尽管这会缺失一部分颜色值,但是只要颜色够用即可,并且这还可以节省内存空间。
②我们真的需要透明度值吗?如果我们需要某个图片作为背景或者图标,这个图片透明度A通道值是必要的。但是如果我们只是普通的图片展示,比如拍摄的照片,透明度值毫无意义。细想一下,你希望你手机自拍的照片透明或者半透明吗?hell no! 因此,透明度这个通道值是否有必要表示也是根据需求自由变化的。
具体每个像素点存储ARGB值的方案介绍,后面会详细介绍。
总结:Bitmap对象本质是一张图片的内容在内存中的表达形式。它将图片的内容看做是由存储数据的有限个像素点组成;每个像素点存储该像素点位置的ARGB值。每个像素点的ARGB值确定下来,这张图片的内容就相应地确定下来了。
现在回答一下问题A和问题B:Android就是将所有的图片资源(无论是何种来源)的内容以Bitmap对象的形式加载到内存中,再通过ImageView的setImageBitmap(Bitmap b)方法即可展示该Bitmap对象所表示的图片内容。
三、详细介绍
1、Bitmap.Config
Config是Bitmap的一个枚举内部类,它表示的就是每个像素点对ARGB通道值的存储方案。取值有以下四种:
ARGB_8888:这种方案就是上面所说的每个通道值采8bit来表示,每个像素点需要4字节的内存空间来存储数据。该方案图片质量是最高的,但是占用的内存也是最大的
ARGB_4444:这种方案每个通道都是4位,每个像素占用2个字节,图片的失真比较严重。一般不用这种方案。
RGB_565:这种方案RGB通道值分别占5、6、5位,但是没有存储A通道值,所以不支持透明度。每个像素点占用2字节,是ARGB_8888方案的一半。
ALPHA_8:这种方案不支持颜色值,只存储透明度A通道值,使用场景特殊,比如设置遮盖效果等。
比较分析:一般我们在ARGB_8888方式和RGB_565方式中进行选取:不需要设置透明度时,比如拍摄的照片等,RGB_565是个节省内存空间的不错的选择;既要设置透明度,对图片质量要求又高,就用ARGB_8888。
2、Bitmap的压缩存储
Bitmap是图片内容在内存中的表示形式,那么如果想要将Bitmap对象进行持久化存储为一张本地图片,需要对Bitmap对象表示的内容进行压缩存储。根据不同的压缩算法可以得到不同的图片压缩格式(简称为图片格式),比如GIF、JPEG、BMP、PNG和WebP等。这些图片的(压缩)格式可以通过图片文件的后缀名看出。
换句话说:Bitmap是图片在内存中的表示,GIF、JPEG、BMP、PNG和WebP等格式图片是持久化存储后的图片。内存中的Bitmap到磁盘上的GIF、JPEG、BMP、PNG和WebP等格式图片经过了”压缩”过程,磁盘上的GIF、JPEG、BMP、PNG和WebP等格式图片到内存中的Bitmap经过了“解压缩”的过程。
那么,为什么不直接将Bitmap对象进行持久化存储而是要对Bitmap对象进行压缩存储呢?这么做依据的思想是:当图片持久化保存在磁盘上时,我们应该尽可能以最小的体积来保存同一张图片的内容,这样有利于节省磁盘空间;而当图片加载到内存中以显示的时候,应该将磁盘上压缩存储的图片内容完整地展开。前者即为压缩过程,目的是节省磁盘空间;后者即为解压缩过程,目的是在内存中展示图片的完整内容。
3、有损压缩和无损压缩
Bitmap压缩存储时的算法有很多种,但是整体可分为两类:有损压缩和无损压缩。
无可否认,利用有损压缩技术可以在位图持久化存储的过程中大大地压缩图片的存储大小,但是会影响图像质量,这一点在压缩率很高时尤其明显。所以需要选择恰当的压缩率。
②无损压缩
无损压缩的基本原理是:相同的颜色信息只需保存一次。具体过程是:首先会确定图像中哪些区域是相同的,哪些是不同的。包括了重复数据的区域就可以被压缩,只需要记录该区域的起始点即可。
从本质上看,无损压缩的方法通过删除一些重复数据,也能在位图持久化存储的过程中减少要在磁盘上保存的图片大小。但是,如果将该图片重新读取到内存中,重复数据会被还原。因此,无损压缩的方法并不能减少图片的内存占用量,如果要减少图片占用内存的容量,就必须使用有损压缩方法。
无损压缩方法的优点是能够比较好地保存图像的质量,但是相对来说这种方法的压缩率比较低。
该图片在内存中所占大小为:100 * 100 * (32 / 8) Byte
在文件中所占大小为 100 * 100 * ( 24/ 8 ) * 压缩率 Byte
compress(Bitmap.CompressFormat format, int quality, OutputStream stream)
介绍一下比较不好理解的属性:
①inJustDecodeBounds:这个属性表示是否只扫描轮廓,默认为false。如果该属性为true,decodeXXXX方法不会返回一个Bitmap对象(即不会为Bitmap分配内存)而是返回null。那如果decodeXXXX方法不再分配内存以创建一个Bitmap对象,那么还有什么用呢?答案就是:扫描轮廓。
BitmapFactory.Options对象的outWidth和outHeight属性分别代表Bitmap对象的宽和高,但是这两个属性在Bitmap对象未创建之前显然默认为0,默认只有在Bitmap对象创建后才能被赋予正确的值。而当inJustDecodeBounds属性为true,虽然不会分配内存创建Bitmap对象,但是会扫描轮廓来给outWidth和outHeight属性赋值,就相当于绕过了Bitmap对象创建的这一步提前获取到Bitmap对象的宽高值。那这个属性到底有啥用呢?具体用处体现在Bitmap的采样率计算中,后面会详细介绍。
②inSample:这个表示Bitmap的采样率,默认为1。比如说有一张图片是2048像素X1024像素,那么默认情况下该图片加载到内存中的Bitmap对象尺寸也是2048像素X1024像素。如果采用的是ARGB_8888方式,那么该Bitmap对象加载所消耗的内存为2048X1024X4/1024/1024=8M。这只是一张图片消耗的内存,如果当前活动需要加载几张甚至几十张图片,那么会导致严重的OOM错误。
OOM错误:尽管Android设备内存大小可能达到好几个G(比如4G),但是Andorid中每个应用其运行内存都有一个阈值,超过这个阈值就会引发out of memory即OOM错误(内存溢出错误)。因为现在市场上流行的手机设备其操作系统都是在Andori原生操作系统基础上的拓展,所以不同的设备环境中这个内存阈值不一样。可以通过以下方法获取到当前应用所分配的内存阈值大小,单位为字节: Runtime.getRuntime().maxMemory();
尽管我们确实可以通过设置来修改这个阈值大小以提高应用的最大分配内存(具体方式是在在Manifest中设置android.largeHeap="true"),但是需要注意的是:内存是一种很宝贵的资源,不加考虑地无脑给每个应用提高最大分配内存是一个糟糕的选择。因为手机总内存相比较每个应用默认的最大分配内存虽然高很多,但是手机中的应用数量是非常多的,每个应用都修改其运行内存阈值为几百MB甚至一个G,这很严重影响手机性能!另外,如果应用的最大分配内存很高,这意味着其垃圾回收工作也会变得更加耗时,这也会影响应用和手机的性能。所以,这个方案需要慎重考虑不能滥用。
关于这个方案的理解可以参考一位大神的解释:“在一些特殊的情景下,你可以通过在manifest的application标签下添加largeHeap=true的属性来为应用声明一个更大的heap空间。然后,你可以通过getLargeMemoryClass()来获取到这个更大的heap size阈值。然而,声明得到更大Heap阈值的本意是为了一小部分会消耗大量RAM的应用(例如一个大图片的编辑应用)。不要轻易的因为你需要使用更多的内存而去请求一个大的Heap Size。只有当你清楚的知道哪里会使用大量的内存并且知道为什么这些内存必须被保留时才去使用large heap。因此请谨慎使用large heap属性。使用额外的内存空间会影响系统整体的用户体验,并且会使得每次gc的运行时间更长。在任务切换时,系统的性能会大打折扣。另外, large heap并不一定能够获取到更大的heap。在某些有严格限制的机器上,large heap的大小和通常的heap size是一样的。因此即使你申请了large heap,你还是应该通过执行getMemoryClass()来检查实际获取到的heap大小。”
综上,我们已经知道了Bitmap的加载是一个很耗内存的操作,特别是在大位图的情况下。这很容易引发OOM错误,而我们又不能轻易地通过修改或提供应用的内存阈值来避免这个错误。那么我们该怎么做呢?答案就是:利用这里所说的采样率属性来创建一个原Bitmap的子采样版本。这也是官方推荐的对于大位图加载的OOM问题的解决方案。其具体思想为:比如还是那张尺寸为2048像素X1024像素图片,在inSample值默认为1的情况下,我们现在已经知道它加载到内存中默认是一个2048像素X1024像素大位图了。我们可以将inSample设置为2,那么该图片加载到内存中的位图宽高都会变成原宽高的1/2,即1024像素X512像素。进一步,如果inSample值设置为4,那么位图尺寸会变成512像素X256像素,这个时候该位图所消耗的内存(假设还是ARGB_8888方式)为512X256X4/1024/1024=0.5M,可以看出从8M到0.5M,这极大的节省了内存资源从而避免了OOM错误。
切记:官方对于inSample值的要求是,必须为2的幂,比如2、4、8...等整数值。
这里会有两个疑问:第一:通过设置inSample属性值来创建一个原大位图的子采样版本的方式来降低内存消耗,听不上确实很不错。但是这不会导致图片严重失真吗?毕竟你丢失了那么多像素点,这意味着你丢失了很多颜色信息。对这个疑问的解释是:尽管在采样的过程确实会丢失很多像素点,但是原位图的尺寸也在减小,其像素密度是不变的。比如说如果inSample值为2,那么子采样版本的像素点数量是原来的1/4,但是子采样版本的显示尺寸(区域面积)也会变成原来的1/4,这样的话像素密码是不变的因此图片不用担心严重失真问题。第二:inSample值如何选取才是最佳?这其实取决于ImageView的尺寸,具体采样率的计算方式后面会详细介绍。
③inPreferredConfig:该属性指定Bitmap的色深值,该属性类型为Bitmap.Config值。
例如你可以指定某图片加载为Bitmap对象的色深模式为ARGB_8888,即:options.inPreferredConfig=Bitmap.Config.ARGB_8888;
④isMutable:该属性表示通过decodeXXXX方法创建的Bitmap对象其代表的图片内容是否允许被外部修改,比如利用Canvas重新绘制其内容等。默认为false,即不允许被外部操作修改。
利用这些属性定制BitmapFactory.Options对象,从而灵活地按照自己的需求配置创建的Bitmap对象。
五、Bitmap的进阶使用
1、高效地加载大位图
上面刚说了大位图加载时的OOM问题,解决方式是通过inSample属性创建一个原位图的子采样版本以减低内存。那么这里的采样率inSample值如何选取最好呢?这里我们利用官方推荐的采样率最佳计算方式:基本步骤就是:①获取位图原尺寸 ②获取ImageView即最终图片显示的尺寸 ③依据两种尺寸计算采样率(或缩放比例)。
public static int calculateInSampleSize( BitmapFactory.Options options, int reqWidth, int reqHeight) { // 位图的原宽高通过options对象获取 final int height = options.outHeight; final int width = options.outWidth; int inSampleSize = 1; if (height > reqHeight || width > reqWidth) { final int halfHeight = height / 2; final int halfWidth = width / 2; //当要显示的目标大小和图像的实际大小比较接近时,会产生没必要的采样,先除以2再判断以防止过度采样 while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) { inSampleSize *= 2; } } return inSampleSize; }
依据上面的最佳采样率计算方法,进一步可以封装出利用最佳采样率创建子采样版本再创建位图对象的方法,这里以从项目图片资源文件加载Bitmap对象为例:
public static Bitmap decodeSampledBitmapFromResource(Resources res, int resId, int reqWidth, int reqHeight) { final BitmapFactory.Options options = new BitmapFactory.Options(); options.inJustDecodeBounds = true; //因为inJustDecodeBounds为true,所以不会创建Bitmap对象只会扫描轮廓从而给options对象的宽高属性赋值 BitmapFactory.decodeResource(res, resId, options); // 计算最佳采样率 options.inSampleSize = calculateInSampleSize(options, reqWidth, reqHeight); // 记得将inJustDecodeBounds属性设置回false值 options.inJustDecodeBounds = false; return BitmapFactory.decodeResource(res, resId, options); }
2、Bitmap加载时的异步问题
由于图片的来源有三种,如果是项目图片资源文件的加载,一般采取了子采样版本加载方案后不会导致ANR问题,毕竟每张图加载消耗的内存不会很大了。但是对于本地图片文件和网络图片资源,由于分别涉及到文件读取和网络请求,所以属于耗时操作。为了避免ANR的产生,必须将图片加载为Bitmap对象的过程放入工作线程中;获取到Bitmap对象后再回到UI线程设置ImageView的显示。举个例子,如果采用AsyncTask作为我们的异步处理方案,那么代码如下:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { private final ImageView iv; private int id = 0; public BitmapWorkerTask(ImageView imageView) { iv = imageView; } // Decode image in background. @Override protected Bitmap doInBackground(Integer... params) { id = params[0]; //假设ImageView尺寸为500X500,为了方便还是以项目资源文件的加载方式为例,因为这可以复用上面封装的方法 return decodeSampledBitmapFromResource(getResources(), id, 500, 500); } @Override protected void onPostExecute(Bitmap bitmap) { iv.setImageBitmap(bitmap); } }
该方案中,doInBackground方法执行在子线程,用来处理 ”图片文件读取操作+Bitmap对象的高效加载操作” 或 ”网络请求图片资源操作+Bimap对象的高效加载操作”等两种情形下的耗时操作。onPostExecute方法执行在UI线程,用于设置ImageView的显示内容。看上去这个方案很完美,但是有一个很隐晦的严重问题:
由当前活动启动了BitmapWorkerTask任务后:当我们退出当前活动时,由于异步任务只依赖于UI线程所以BitmapWorkerTask任务会继续执行。正常的操作是遍历当前活动实例的对象图来释放各对象的内存以销毁该活动,但是由于当前活动实例的ImageView引用被BitmapWorkerTask对象持有,而且还是强引用关系。这会导致Activity实例无法被销毁,引发内存泄露问题。内存泄露问题会进一步导致内存溢出错误。
为了解决这个问题,我们只需要让BitmapWorkerTask类持有ImageView的弱引用即可。这样当活动退出时,BitmapWorkerTask对象由于持有的是ImageView的弱引用,所以ImageView对象会被回收,继而Activity实例得到销毁,从而避免了内存泄露问题。具体修改后的代码如下:
class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> { private final WeakReference<ImageView> imageViewReference; private int data = 0; public BitmapWorkerTask(ImageView imageView) { // 用弱引用来关联这个imageview!弱引用是避免android 在各种callback回调里发生内存泄露的最佳方法! //而软引用则是做缓存的最佳方法 两者不要搞混了! imageViewReference = new WeakReference<ImageView>(imageView); } // Decode image in background. @Override protected Bitmap doInBackground(Integer... params) { data = params[0]; return decodeSampledBitmapFromResource(getResources(), data, 100, 100); } @Override protected void onPostExecute(Bitmap bitmap) { //当后台线程结束后 先看看ImageView对象是否被回收:如果被回收就什么也不做,等着系统回收他的资源 //如果ImageView对象没被回收的话,设置其显示内容即可 if (imageViewReference != null && bitmap != null) { final ImageView imageView = imageViewReference.get(); if (imageView != null) { imageView.setImageBitmap(bitmap); } } } }
拓展:①WeakReference是弱引用,其中保存的对象实例可以被GC回收掉。这个类通常用于在某处保存对象引用,而又不干扰该对象被GC回收,可以用于避免内存泄露。②SoftReference是软引用,它保存的对象实例,不会被GC轻易回收,除非JVM即将OutOfMemory,否则不会被GC回收。这个特性使得它非常适合用于设计Cache缓存。缓存可以省去重复加载的操作,而且缓存属于内存因此读取数据非常快,所以我们自然不希望缓存内容被GC轻易地回收掉;但是因为缓存本质上就是一种内存资源,所以在内存紧张时我们需要能释放一部分缓存空间来避免OOM错误。综上,软引用非常适合用于设计缓存Cache。但是,这只是早些时候的缓存设计思想,比如在Android2.3版本之前。在Android2.3版本之后,JVM的垃圾收集器开始更积极地回收软引用对象,这使得原本的缓存设计思想失效了。因为如果使用软引用来实现缓存,那么动不动缓存对象就被GC回收掉实在是无法接受。所以,Android2.3之后对于缓存的设计使用的是强引用关系(也就是普通对象引用关系)。很多人会问这样不会由于强引用的缓存对象无法被回收从而导致OOM错误吗?确实会这样,但是我们只需要给缓存设置一个合理的阈值就好了。将缓存大小控制在这个阈值范围内,就不会引发OOM错误了。
3、列表加载Bitmap时的图片显示错乱问题
我们已经知道了如何高效地加载位图以避免OOM错误,还知道了如何合理地利用异步机制来避免Bitmap加载时的ANR问题和内存泄露问题。现在考虑另一种常见的Bitmap加载问题:当我们使用列表,如ListView、GridView和RecyclerView等来加载多个Bitmap时,可能会产生图片显示错乱的问题。先看一下该问题产生的原因。以ListView为例:
①ListView为了提高列表展示内容在滚动时的流畅性,使用了一种item复用机制,即:在屏幕中显示的每个ListView的item对应的布局只有在第一次的时候被加载,然后缓存在convertView里面,之后滑动改变ListView时调用的getView就会复用缓存在converView中的布局和控件,所以可以使得ListView变得流畅(因为不用重复加载布局)。
②每个Item中的ImageView加载图片时往往都是异步操作,比如在子线程中进行图片资源的网络请求再加载为一个Bitmap对象最后回到UI线程设置该item的ImageView的显示内容。
③ 听上去①是一种非常合理有效的提高列表展示流畅性的机制,②看起来也是图片加载时很常见的一个异步操作啊。其实①和②本身都没有问题,但是①+②+用户滑动列表=图片显示错乱!具体而言:当我们在其中一个itemA加载图片A的时候,由于加载过程是异步操作需要耗费一定的时间,那么有可能图片A未被加载完该itemA就“滚出去了”,这个itemA可能被当做缓存应用到另一个列表项itemB中,这个时候刚好图片A加载完成显示在itemB中(因为ImageView对象在缓存中被复用了),原本itemB该显示图片B,现在显示图片A。这只是最简单的一种情况,当滑动频繁时这种图片显示错乱问题会愈加严重,甚至让人毫无头绪。
那么如何解决这种图片显示错乱问题呢?解决思路其实非常简单:在图片A被加载到ImageView之前做一个判断,判断该ImageView对象是否还是对应的是itemA,如果是则将图片加载到ImageView当中;如果不是则放弃加载(因为itemB已经启动了图片B的加载,所以不用担心控件出现空白的情况)。
那么新的问题出现了,如何判断ImageView对象对应的item已经改变了?我们可以采取下面的方式:
①在每次getView的复用布局控件时,对会被复用的控件设置一个标签(在这里就是对ImageView设置标签)。标签内容必须可以标识不同的item!这里使用图片的url作为标签内容,然后再异步加载图片。
②在图片下载完成后要加载到ImageView之前做判断,判断该ImageView的标签内容是否和图片的url一样:如果一样说明ImageView没有被复用,可以将图片加载到ImageView当中;如果不一样,说明ListView发生了滑动,导致其他item调用了getView从而将该ImageView的标签改变,此时放弃图片的加载(尽管图片已经被下载成功了)。
总结:解决ListView异步加载Bitmap时的图片错乱问题的方式是:为被复用的控件对象(即ImageView对象)设置标签来标识item,异步任务结束后要将图片加载到ImageView时取出标签值进行比对是否一致:如果一致意味着没有发生滑动,正常加载图片;如果不一样意味着发生了滑动,取消加载。
4、Android中的Bitmap缓存策略
如果只是加载若干张图片,上述的Bitmap使用方式已经绝对够用了;但是如果在应用中需要频繁地加载大量的图片,特别是有些图片会被重复加载时,这个时候利用缓存策略可以很好地提高图片的加载速度。比如说有几张图片被重复加载的频率很高,那么可以在缓存中保留这几张图片的Bitmap对象;后续如果需要加载这些图片,则不需要花费很多时间去重新在网络上获取并加载这些图片的Bitmap对象,只需要直接向缓存中获取之前保留下来的Bitmap对象即可。
Android中对Bitmap的缓存策略分为两种:
- 内存缓存:图像存储在设备内存中,因此访问速度非常快。事实上,比图像解码过程要快得多,所以将图像存储在这里是让app更快更稳定的一个好主意。内存缓存的唯一缺点是:它只存活于app的生命周期,这意味着一旦app被Android操作系统内存管理器关闭或杀死(全部或部分),那么储存在那里的所有图像都将丢失。由于内存缓存本质上就是一种内存资源,所以切记:内存缓存必须设置一个最大可用的内存量。否则可能会导致臭名昭著的outOfMemoryError。
- 磁盘缓存:图像存储在设备的物理存储器上(磁盘)。磁盘缓存本质上就是设备SD卡上的某个目录。只要app不被卸载,其磁盘缓存可以一直安全地存储图片,只要有足够的磁盘空间即可。缺点是,磁盘读取和写入操作可能会很慢,而且总是比访问内存缓存慢。由于这个原因,因此所有的磁盘操作必须在工作线程执行,UI线程之外。否则,app会冻结,并导致ANR警报。
在实际使用中,我们不需要强行二选一,可以二者都使用,毕竟各有优势。所以Android中完整的图片缓存策略为:先尝试在内存缓存中查找Bitmap对象,如果有直接加载使用;如果没有,再尝试在磁盘缓存中查找图片文件是否存在,如果有将其加载至内存使用;如果还是没有,则老老实实发送网络请求获取图片资源并加载使用。需要注意的是,后面两种情况下的操作都必须使用异步机制以避免ANR的发生。
Android中通过LruCache实现内存缓存,通过DiskLruCache实现磁盘缓存,它们采用的都是LRU(Least Recently Used)最近最少使用算法来移除缓存中的最近不常访问的内容(变相地保留了最近经常访问的内容)。
①内存缓存LruCache
LruCache原理:LruCache底层是使用LinkedHashMap来实现的,所以LruCache也是一个泛型类。在图片缓存中,其键类型是字符串,值类型为Bitmap。利用LinkedHashMap的accessOrder属性可以实现LRU算法。accessOrder属性决定了LinkedHashMap的链表顺序:accessOrder为true则以访问顺序维护链表,即被访问过的元素会安排到链表的尾部;accessorder为false则以插入的顺序维护链表。
而LruCache利用的正是accessOrder为true的LinkedHashMap来实现LRU算法的。具体表现为:
1° put:通过LinkedHashMap的put方法来实现元素的插入,插入的过程还是要先寻找有没有相同的key的数据,如果有则替换掉旧值,并且将该节点移到链表的尾部。这可以保证最近经常访问的内容集中保存在链表尾部,最近不常访问的内存集中保存在链表头部位置。在插入后如果缓存大小超过了设定的最大缓存大小(阈值),则将LinkedHashMap头部的节点(最近不常访问的内容)删除,直到size小于maxSize。
2° get:通过LinkedHashMap的get方法来实现元素的访问,由于accessOrder为true,因此被访问到的元素会被调整到链表的尾部,因此不常被访问的元素就会留到链表的头部,当触发清理缓存时不常被访问的元素就会被删除,这里是实现LRU最关键的地方。
3° remove:通过LinkedHashMap的remove方法来实现元素的移除。
3° size:LruCache中很重要的两个成员变量size和maxSize,因为清理缓存的是在size>maxSize时触发的,因此在初始化的时候要传入maxSize定义缓存的大小,然后重写sizeOf方法,因为LruCache是通过sizeOf方法来计算每个元素的大小。这里我们是使用LruCache来缓存图片,所以sizeOf方法需要计算Bitmap的大小并返回。
LruCache对其缓存对象采用的是强引用关系,采用maxSize来控制缓存空间大小以避免OOM错误。而且LruCache类在Android SDK中已经提供了,在实际使用中我们只需要完成以下几步即可:
- 设计LruCache的最大缓存大小:一般是通过计算当前可用的内存大小继而来获取到应该设置的缓存大小
- 创建LruCache对象:传入最大缓存大小的参数,同时重写sizeOf方法来设置存在LruCache里的每个对象的大小
- 封装对LruCache的数据访问和添加操作并对外提供接口以供调用
具体代码参考如下:
//初始化LruCache对象 public void initLruCache() { //获取当前进程的可用内存,转换成KB单位 int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); //分配缓存的大小 int maxSize = maxMemory / 8; //创建LruCache对象并重写sizeOf方法 lruCache = new LruCache<String, Bitmap>(maxSize) { @Override protected int sizeOf(String key, Bitmap value) { // TODO Auto-generated method stub return value.getWidth() * value.getHeight() / 1024; } }; } /** * 封装将图片存入缓存的方法 * @param key 图片的url转化成的key * @param bitmap对象 */ private void addBitmapToMemoryCache(String key, Bitmap bitmap) { if(getBitmapFromMemoryCache(key) == null) { mLruCache.put(key, bitmap); } } //封装从LruCache中访问数据的方法 private Bitmap getBitmapFromMemoryCache(String key) { return mLruCache.get(key); } /** * 因为外界一般获取到的是url而不是key,因此为了方便再做一层封装 * @param url http url * @return bitmap */ private Bitmap loadBitmapFromMemoryCache(String url) { final String key = hashKeyFromUrl(url); return getBitmapFromMemoryCache(key); }
②磁盘缓存DiskLruCache
由于DiskLruCache并不属于Android SDK的一部分,需要自行设计。与LruCache实现LRU算法的思路基本上是一致的,但是有很多不一样的地方:LruCache是内存缓存,其键对应的值类型直接为Bitmap;而DiskLruCache是磁盘缓存,所以其键对应的值类型应该是一个代表图片文件的类。其次,前者访问或添加元素时,查找成功可以直接使用该Bitmap对象;后者访问或添加元素时,查找到指定图片文件后还需要通过文件的读取和Bitmap的加载过程才能使用。另外,前者是在内存中的数据读写操作所以不需要异步;后者涉及到文件操作必须开启子线程实现异步处理。
具体DiskLruCache的设计方案和使用方式可以参考这篇博客:https://www.jianshu.com/p/765640fe474a
有了LruCache类和DiskLruCache类,可以实现完整的Android图片二级缓存策略:在具体的图片加载时:先尝试在LruCache中查找Bitmap对象,如果有直接拿来使用。如果没有再尝试在DiskLruCache中查找图片文件,如果有将其加载为Bitmap对象再使用,并将其添加至LruCache中;如果没有查找到指定的图片文件,则发送网络请求获取图片资源并加载为Bitmap对象再使用,并将其添加DiskLruCache中。
5、Bitmap内存管理
Android设备的内存包括本机Native内存和Dalvik(类似于JVM虚拟机)堆内存两部分。在Android 2.3.3(API级别10)及更低版本中,位图的支持像素数据存储在Native内存中。它与位图本身是分开的,Bitmap对象本身存储在Dalvik堆中。Native内存中的像素数据不会以可预测的方式释放,可能导致应用程序短暂超出其内存限制并崩溃。从Android 3.0(API级别11)到Android 7.1(API级别25),像素数据与相关Bitmap对象一起存储在Dalvik堆上,一起交由Dalvik虚拟机的垃圾收集器来进行回收,因此比较安全。
①在Android2.3.3版本之前:
在Bitmap对象不再使用并希望将其销毁时,Bitmap对象自身由于保存在Dalvik堆中,所以其自身会由GC自动回收;但是由于Bitmap的像素数据保存在native内存中,所以必须由开发者手动调用Bitmap的recycle()方法来回收这些像素数据占用的内存空间。
②在Android2.3.3版本之后:
由于Bitmap对象和其像素数据一起保存在Dalvik堆上,所以在其需要回收时只要将Bitmap引用置为null 就行了,不需要如此麻烦的手动释放内存操作。
当然,一般我们在实际开发中往往向下兼容到Android4.0版本,所以你懂得。
③在Android3.0以后的版本,还提供了一个很好用的参数,叫options.inBitmap。如果你使用了这个属性,那么在调用decodeXXXX方法时会直接复用 inBitmap 所引用的那块内存。大家都知道,很多时候ui卡顿是因为gc 操作过多而造成的。使用这个属性能避免频繁的内存的申请和释放。带来的好处就是gc操作的数量减少,这样cpu会有更多的时间执行ui线程,界面会流畅很多,同时还能节省大量内存。简单地说,就是内存空间被各个Bitmap对象复用以避免频繁的内存申请和释放操作。
需要注意的是,如果要使用这个属性,必须将BitmapFactory.Options的isMutable属性值设置为true,否则无法使用这个属性。
具体使用方式参考如下代码:
final BitmapFactory.Options options = new BitmapFactory.Options(); //size必须为1 否则是使用inBitmap属性会报异常 options.inSampleSize = 1; //这个属性一定要在用在src Bitmap decode的时候 不然你再使用哪个inBitmap属性去decode时候会在c++层面报异常 //BitmapFactory: Unable to reuse an immutable bitmap as an image decoder target. options.inMutable = true; inBitmap2 = BitmapFactory.decodeFile(path1,options); iv.setImageBitmap(inBitmap2); //将inBitmap属性代表的引用指向inBitmap2对象所在的内存空间,即可复用这块内存区域 options.inBitmap = inBitmap2; //由于启用了inBitmap属性,所以后续的Bitmap加载不会申请新的内存空间而是直接复用inBitmap属性值指向的内存空间 iv2.setImageBitmap(BitmapFactory.decodeFile(path2,options)); iv3.setImageBitmap(BitmapFactory.decodeFile(path3,options)); iv4.setImageBitmap(BitmapFactory.decodeFile(path4,options));
补充:Android4.4以前,你要使用这个属性,那么要求复用内存空间的Bitmap对象大小必须一样;但是Android4.4 以后只要求后续复用内存空间的Bitmap对象大小比inBitmap指向的内存空间要小就可以使用这个属性了。另外,如果你不同的imageview 使用的scaletype 不同,但是你这些不同的imageview的bitmap在加载是如果都是引用的同一个inBitmap的话,
这些图片会相互影响。综上,使用inBitmap这个属性的时候 一定要小心小心再小心。
六、开源框架
我们现在已经知道了,Android图片加载的知识点和注意事项实在太多了:单个的位图加载我们要考虑Bitmap加载的OOM问题、异步处理问题和内存泄露问题;列表加载位图要考虑显示错乱问题;频繁大量的位图加载时我们要考虑二级缓存策略;我们还有考虑不同版本下的Bitmap内存管理问题,在这部分最后我们介绍了Bitmap内存复用方式,我们需要小心使用这种方式。
那么,能不能有一种方式让我们省去这么多繁琐的细节,方便我们对图片进行加载呢?答案就是:利用已有的成熟的图片加载和缓存开源框架!比如square公司的Picasso框架、Google公司的Glide框架和Facebook公司的Fresco框架等。特别是Fresco框架,提供了三级缓存策略,非常的专业。根据APP对图片显示和缓存的需求从低到高排序,我们可以采用的方案依次为:Bitmapfun、Picasso、Android-Universal-Image-Loader、Glide、Fresco。
这些框架可以方便我们实现对网络图片的加载和缓存操作。具体不再赘述。