如何在Android 8.0以下高效地复用图片?
我们都知道日常开发的Android App是运行在Java虚拟机的环境中,Java虚拟机会自动进行垃圾回收(garbage collection,以下简称gc),但gc发生时有可能会造成一定程度的卡顿,而Java大内存对象的创建更易引发gc,对应到Android中即Bitmap对象,所以需要尽可能的减少新Bitmap的创建。
在Android 8.0及以上版本,Bitmap的数据是存储在native内存,创建Bitmap不会影响gc。而Android 3.0-7.1版本上,Bitmap的像素数据(byte[])是存储在java堆中的,一张500*500 ARGB8888格式的图片,其内存占用约为1m,如果频繁地创建和销毁Bitmap很容易引起gc,造成页面卡顿。
Android有提供inBitmap机制,来复用不再使用的Bitmap,但是,如何方便地收集不再使用的Bitmap,如何高效的存储管理收集的Bitmap,Android并没有提供系统的解决方案。
基于这些问题,本文提供一套高效的图片复用方案,此方案只需配置较低的内存缓存,即可达到很高的图片复用率,从而有效减少图片相关的gc。
01
Android提供的图片复用机制
inBitmap图片复用的思路是:当生成的Bitmap不再使用时,将不再使用的Bitmap缓存起来,需要生成新Bitmap时,不再分配新内存,而是,直接拿符合条件缓存的Bitmap修改其数据,之后,将新生成的Bitmap给业务方使用,这样就避免了新Bitmap的创建。
如下代码展示了inBitmap相关使用api
public Bitmap decodeBitmap(byte[] data, Bitmap.Config config, int targetWidth, int targetHeight) {
final BitmapFactory.Options options = new BitmapFactory.Options();
//通过inJustDecodeBounds参数,先获取待生成Bitmap的尺寸信息
options.inJustDecodeBounds = true;
BitmapFactory.decodeByteArray(data, 0, data.length, options);
//计算采样率
options.inSampleSize = calculateInSampleSize(options, targetWidth, targetHeight);
options.inJustDecodeBounds = false;
options.inMutable = true;//生成的bitmap可修改
options.inPreferredConfig = config;
//根据待生成Bitmap的尺寸信息,获取符合条件可复用的bitmap
options.inBitmap = getReusableBitmap(options);
return BitmapFactory.decodeByteArray(data, 0, data.length, options);
}
inBitmap复用,并不是随便拿一张不用的Bitmap就可以复用其内存,Android系统是有一定要求的,并且不同版本要求不同,具体如下:
Android 3.0 - 4.3 | decode的图片必须是jpg、png格式;和复用图片的[width, height, ARGB]必须相同;inSampleSize必须是1 |
Android 4.4 - 7.1 | 只需decode图片的内存占用(byte count)小于复用图片的内存大小即可 |
从上表可以看到,4.3及之前版本,要求比较苛刻,要求复用的图片,width,height,ARGB(各个像素颜色信息)必须完全相同,这就导致复用命中率不高。而4.4到7.1之间,只要求新生成的Bitmap内存占用小于复用Bitmap的内存占用即可,这样可复用的图片的范围明显放宽了很多,更多的图片可以复用,inBitmap也会有更明显的效果。
在了解了Android提供的inBitmap机制后,远无法达到高效的复用图片的目的,首先面临的两大难题就是:如何收集不再使用的图片、如何存储收集的图片,并在复用图片时高效检索出符合要求的图片。下面的章节围绕着两大问题会详细解答。
02
收集不再使用的图片
使用inBitmap机制,首先需要收集不再使用的Bitmap,必须保证收集到的Bitmap确实不再使用,否则,如果解析新图片时,将新图片的数据填充到了还在使用中的Bitmap上,会导致业务方引用的Bitmap诡异地变成了另一张图。
另外由于经常会遇到同一张图片在多个地方同时使用的场景,就需要每一个对于图片的引用都能被记录才可以,所以,引用计数的方案是再合适不过的。
但引用计数的引入会带来更高的复杂度,如果让业务方在每次获取到图片时,需要手动进行计数加1的操作,不再使用图片时,进行计数减1的操作,显然是不太合理的,操作繁琐且容易出现计数错误的情况,下面会介绍是如何处理和规避这些问题的。
1.资源引用计数
识别Bitmap不再使用,无法通过一个简单地标记来实现,因为图片库会提供内存缓存的功能,这就导致可能出现同一个Bitmap被业务方多处引用(比如使用图片url,加载生成Bitmap A,返回给view1显示,紧接着使用相同url加载图片,这时会命中内存缓存,将Bitmap A返回给view2,从而Bitmap A被业务方两处引用),必须在业务方所有地方都不再引用资源时,才可以inBitmap复用此资源。另外,由于图片资源不会引用其他图片资源,即不会出现资源循环引用的情况,所以,使用引用计数来标识图片是否在使用是一个不错的方案。
图片资源引用计数相关规则如下:
(1)图片资源每被业务方引用一次,资源引用计数+1
(2) 被引用的图片不再被业务方引用,资源引用计数-1
(3)引用计数=0,即业务方在任何地方都没有引用此图片资源,此资源可被回收复用
Bitmap引用计数包装类,示例代码如下:
public class Resource {
//引用计数的值,有可能多线程同时修改,release引用计数-1,acquire命中内存缓存+1
private final AtomicInteger acquired=new AtomicInteger(0);
//内存缓存的唯一key
private final String memoryCacheKey;
//加载的图片
private final Bitmap bitmap;
//业务方每多一处对资源的引用,计数+1
public void acquire(){
this.acquired.incrementAndGet();
}
//业务方不再引用资源,计数-1
public void release(){
this.acquired.decrementAndGet();
}
}
2.向业务方屏蔽引用计数
为保证图片库的易用性,并且避免由于业务方错误的操作导致资源引用计数错误的情况发生,应尽量对业务方屏蔽引用计数过程,将引用计数的操作封装在图片库内部。
图片引用计数+1,主要是在生成一张新图片,或命中内存缓存时,这两个时机。
图片引用计数-1,主要是在业务方不再使用图片之后。日常加载图片的过程中有一些特定的时机,可以明确的标识出图片已不再使用,从而进行计数-1的操作。
最为常见的时机是同一个view加载多张图片场景,演示代码如下:
privaite ImageView imageView;
public void loadImage() {
//加载了url1对应的图片到imageView上,并显示
String url1 = "http://image1.jpg";
ImageRequest request = new ImageRequest(url1);
ImageProviderApi.get().load(request).into(imageView);
//模拟同一个view,成功加载一张图片之后,再加载另一张
new Handler(Looper.getMainLooper()).postDelayed(new Runnable(){
@Override
public void run() {
//等待1秒之后,再加载另一张图片到imageView,之前加载的图片不再使用,自动对资源引用计数-1
String url2 = "http://image2.jpg";
ImageRequest request = new ImageRequest(url2);
ImageProviderApi.get().load(request).into(imageView);
}
}, 1000);
}
如上,load.into链式调用的图片加载方式,如今被广大开发人员所喜爱,这种加载方式,业务方只需要关注图片相关参数的设置,而加载显示的过程都由图片库来完成,调用简洁使用方便。关键这种方式还可以将引用计数的逻辑,包装在内部。
具体内部计数相关实现逻辑为,当加载url1对应的图片成功之后,将图片包装成一个Resource对象,此对象的引用计数是1,将生成的Resource对象设置到imageView的特定tag上;当加载url2对应的图片时,会从imageView的特定tag上获取到上次加载的Resource对象,并将Resource对象内部的引用计数-1。
其次,在列表加载图片的场景,在view复用时,上次加载的图片资源必定是不再使用的,以ListView为例的演示代码如下:
@Override
public View getView(int position, View convertView, ViewGroup parent) {
ImageView imageView;
//第一次创建新view
if (convertView == null) {
imageView = new ImageView(context);
convertView = imageView;
} else {
//复用上次的imageView
imageView = (ImageView) convertView;
}
String url = getItemUrl(position);
ImageRequest request = new ImageRequest(url);
//加载图片到ImageView, view复用时,加载新图片,会先对上次加载的图片资源引用计数-1
ImageProviderApi.get().load(request).into(imageView);
return convertView;
}
03
高效存取复用图片
通过上一节的介绍,已经可以收集到不再使用的图片,只收集到了不再使用的图片仍然无法高效的进行复用,因为inBitmap机制,要求复用的图片必须符合一定的条件,并且在不同的Android版本上,复用的条件也是不同的。
为了实现高效的复用,就必须对内存缓存进行更加精细地划分,尤其供复用的图片甚至要按复用条件进行分组存储,具体方案会在下面章节详细介绍。
1.内存缓存结构
结合资源的引用计数,为很好的区分哪些资源是在使用着的,哪些资源是不再使用供inBitmap复用的。内存缓存就无法只使用一个LruCache实现了,一种简便的内存结构大体如下:
其中,ActiveResource是基于WeakReference<Resource>的map,当Resource被业务方引用时,都可以从ActiveResource中找到对应的资源。当业务方不再引用资源,而没有主动回收资源时,也不会造成内存泄漏。
InBitmapPool主要是存储可用于复用的图片资源,但是,如果InBitmapPool中已存在要生成的新图片,从InBitmapPool中查询获取可大大减少加载的时间。实际项目中,InBitmapPool作为内存缓存,在一些场景下其命中率还是很高的。
2.Resource流转
大体的流转如下图:
inBitmap复用流程说明:
(1)图片加载时,会将生成的Bitmap包装成一个Resource对象,引用计数+1,并添加到ActiveResource中,然后,将Resource返回给业务方使用。
(2)业务方不再使用此Resource,调用回收Resource的相关方法,将资源引用计数-1,如果引用计数为0,代表业务方不再使用此Resource,将Resource从ActiveResource中移除,添加到LruCache中,LruCache中存储最近不再使用的Resource,但有很大概率,会被再次使用。
(3)如果LruCache满了,再次添加新的资源,会把长时间不使用的资源移除LruCache,放入InBitmapPool,此时资源内部的Bitmap就可以用于inBitmap了。
(4)当decode一张新图片时,会按照一定的策略,从InBitmapPool中取出最合适的Bitmap,复用其内存。
内存缓存命中流程说明:
(5)加载一个最近使用过的图片,大概率会命中LruCache,会从LruCache中移除对应Resource,将引用计数+1,然后,放到ActiveResource中,并返回Resource给业务方使用。
(6)加载一个最近使用过的图片,如果没有命中LruCache,会从InBitmapPool中查找,如果找到,将Resource引用计数+1,从InBitmapPool中移除,添加到ActiveResource中,并返回Resource给业务方使用。
(7)(补充)加载一个业务方正在使用着的图片,会命中ActiveResource,从ActiveResource中查询到对应的Resource之后,将Resource内部的引用计数+1,并返回给业务方使用。举例:view1 加载 A Bitmap,加载成功之后缓存到了ActiveResource中,view2马上也加载A Bitmap,此时,会从ActiveResource中取出A Bitmap对应的资源,并计数+1,此时计数为2(view1 view2都在引用)。当view 1、view2都不再使用此图片,分别调用资源回收的方法,会分别将资源引用计数-1,当计数为0,A Bitmap对应资源会从ActiveResource中移除,添加到LruCache中。
3.InBitmapPool结构
inBitmap在3.0-4.3之间,要求【width,height,ARGB】信息完全相同,4.4-7.1之间,要求复用图片【byte count】大于新生成图片,实际项目中列表使用的图片往往是后台按照统一尺寸剪裁之后的图片,相同尺寸的图片会有多张,合适的方式是将图片按照尺寸规格分组进行管理。
InBitmapPool 抽象的数据结构应该如下:
如上图,将图片资源按照尺寸信息进行分组,分组根据不同图片尺寸的访问频率,按照LRU的策略进行管理,这样可保证,InBitmapPool中存储的都是最近常用尺寸的图片资源,淘汰掉的是不常用尺寸的图片,当从InBitmapPool中查找符合尺寸要求的图片资源时,大概率可以找到。
3.0-4.3 InBitmapPool实现
inBitmap在3.0-4.3之间,要求【width,height,ARGB】信息完全相同,根据上边InBitmapPool的抽象数据结构,可以将【width,height,ARGB】相同的Bitmap资源作为一个分组,根据LRU的策略,最近常访问的【width,height,ARGB】的分组放到LruLinkList的头部。
为能快速的根据【width,height,ARGB】信息找到对应的Bitmap资源,可以添加一个用于快速查找的map,将图片的【width,height,ARGB】作为key,图片分组作为value,存储在map中。
简要实现,如下图:
(1)上图左侧的Map:存储Bitmap的尺寸信息,用于快速检索到合适尺寸的Bitmap。其key为自定义类,重写hashCode()方法根据图片的【width,height,ARGB】生成hash值。
(2)右侧LruLinkedList:根据Lru策略实现的双向链表,最近最常使用【width,height,ARGB】的图片,存储在链表头部的group节点中,具体为group内部的集合。
(3)添加资源:模拟向InBitmapPool中添加一张【500,600,RGB565】的Bitmap对应资源,首先从map中检索,有没有已存在的【500,600,RGB565】对应的group,如果没有,构建group3,将【500,600,RGB565】对应的图片资源添加到group3的内部链表中,然后,将group3插入到LruLinkList的尾部,最后,将【500,600,RGB565】作为key,group3作为value添加进map中。当再次添加一张【500,600,RGB565】Bitmap的资源时,检索map发现存在【500,600,RGB565】对应的group3,便直接将图片资源添加到group3内部链表的尾部。
(4)获取资源:模拟从InBitmapPool中获取一张【400,400,RGB565】的Bitmap对应资源,首先从map中检索,检索到【400,400,RGB565】对应的group1已存在,从group1内部链表中取出resource1,用于inBitmap,并将group1移动到LruLinkList的头部,从而保证,最近使用的图片尺寸的分组,总在LruLinkList的头部(注意:只有从InBitmapPool中获取图片的操作,才算是对图片特定尺寸的访问,因为只有decode新图片时,才会从InBitmapPool获取特定尺寸可复用的图片)。
(5)淘汰资源:模拟InBitmapPool淘汰资源的过程,比如InBitmapPool容量是6M,当前已使用大小为5.8M,向InBitmapPool添加一张320KB的图时,由于没有足够的空间,就需要淘汰InBitmapPool中,最长时间没有使用的资源。具体逻辑是,通过tail哨兵节点,获取LruLinkList中尾部的group3,移除group3内部链表头部的图片资源;当group3内部链表已为空时,便将group3从LruLinkList中移除,并从map中也删除。
4.4-7.1 InBitmapPool实现
inBitmap复用在4.4-7.1之间,要求复用图片【byte count】大于新生成图片,根据上边InBitmapPool的抽象数据结构,可以将内存占用相同的Bitmap资源作为一个分组,根据LRU的策略,最近常访问【byte count】的分组放到LruLinkList的头部。
为能快速的根据【byte count】,找到对应的Bitmap资源,可以添加一个便于快速查找的map,将图片的【byte count】作为key,图片分组作为value,存储在map中。
简要实现,如下图:
(1)上图左边的TreeMap:存储Bitmap的内存占用信息,用于快速检索到合适内存大小的Bitmap。其key为Bitmap的【byte count】,Integer类型,value为group类型。
(2)右侧LruLinkedList:根据Lru策略实现的双向链表,最近最常使用【byte count】的图片,存储在链表的头部。相同内存占用的Bitmap资源存储在同一分组
(3)添加资源:和3.0-4.3 InBitmapPool添加资源过程相同,只是往TreeMap中添加记录时,key是Bitmap的内存占用大小。
(4)获取资源:模拟decode一张尺寸为480*500,Bitmap.Config=RGB565的图片,可计算出要生成的新Bitmap内存大小为480*500*2=480000,通过treeMap.ceilingKey(480000)可以获取到treeMap中在key大于等于480000情况下,最接近的key是500000,其value是group2,然后,从group2内部的链表头部取出resource4,resource4内部的Bitmap可直接用于inBitmap复用。最后,需要将group2移动到LruLinkList的头部,从而保证LruLinkList中的group是按照最近最常使用的Bitmap大小信息排序的。
(5)淘汰资源:和3.0-4.3 InBitmapPool淘汰资源过程相同。
(6)当做内存缓存使用:InBitmapPool也可以当做内存缓存使用,当查询内存缓存,ActiveResource和LruCache都没有命中时,根据加载参数生成memoryCacheKey,使用memoryCacheKey从InBitmapPool中查询是否存在要加载的资源,如存在直接返回给业务方使用,而不必重新decode出新Bitmap。大概的实现逻辑是,建立一个用于查找的map,比如叫cacheMap,将resource的memoryCacheKey作为key,resource作为value添加到cacheMap中,当InBitmapPool作为内存缓存使用时,根据memoryCacheKey从cacheMap中查找,如果存在对应的resource,可根据resource内部的Bitmap得到图片的内存占用信息,通过内存占用信息进而从treeMap中获取对应的group,将resource从group内部的链表中移除,并将group移动到LruLinkList的头部。如果group内部链表已为空,将group从LruLinkList和treeMap中删除(命中InBitmapPool中的resource也算是对这个大小图片资源的访问,也需要将此大小尺寸的group移动到LruLinkList的头部)。
04
总结
在实际项目中由于InBitmapPool也可作为内存缓存使用,可显著加大内存缓存的容量,Android4.4-7.1之间,图片复用命中率在列表加载不同尺寸图片场景也可稳定在80%以上,相同使用场景下,使用此复用方案,gc发生的频率可以减少60%左右。
对整篇文章做一个简单的总结:inBitmap复用不再使用的图片,可有效减少gc;可使用引用计数的方案,标记不再使用的图片;通过封装加载显示过程,向业务方屏蔽引用计数细节;结合引用计数方案,将内存缓存分为ActiveResource、LruCache、InBitmapPool;InBitmapPool将图片资源按照尺寸信息进行分组存储,分组根据LRU的策略进行管理,保证InBitmapPool中存储的都是常用尺寸的图片,提高inBitmap的命中率。