写点什么

如何在 Android 8.0 以下高效地复用图片?

发布于: 2 小时前
如何在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。

一、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 系统是有一定要求的,并且不同版本要求不同,具体如下:

inBitmap 参数官方说明:https://developer.android.com/reference/android/graphics/BitmapFactory.Options#inBitmap


从上表可以看到,4.3 及之前版本,要求比较苛刻,要求复用的图片,width,height,ARGB(各个像素颜色信息)必须完全相同,这就导致复用命中率不高。而 4.4 到 7.1 之间,只要求新生成的 Bitmap 内存占用小于复用 Bitmap 的内存占用即可,这样可复用的图片的范围明显放宽了很多,更多的图片可以复用,inBitmap 也会有更明显的效果。


在了解了 Android 提供的 inBitmap 机制后,远无法达到高效的复用图片的目的,首先面临的两大难题就是:如何收集不再使用的图片、如何存储收集的图片,并在复用图片时高效检索出符合要求的图片。下面的章节围绕着两大问题会详细解答。

二、收集不再使用的图片


使用 inBitmap 机制,首先需要收集不再使用的 Bitmap,必须保证收集到的 Bitmap 确实不再使用,否则,如果解析新图片时,将新图片的数据填充到了还在使用中的 Bitmap 上,会导致业务方引用的 Bitmap 诡异地变成了另一张图。


另外由于经常会遇到同一张图片在多个地方同时使用的场景,就需要每一个对于图片的引用都能被记录才可以,所以,引用计数的方案是再合适不过的


但引用计数的引入会带来更高的复杂度,如果让业务方在每次获取到图片时,需要手动进行计数加 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(); }}
复制代码

(二)向业务方屏蔽引用计数


为保证图片库的易用性,并且避免由于业务方错误的操作导致资源引用计数错误的情况发生,应尽量对业务方屏蔽引用计数过程,将引用计数的操作封装在图片库内部。


图片引用计数+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;    }
复制代码

三、高效存取复用图片


通过上一节的介绍,已经可以收集到不再使用的图片,只收集到了不再使用的图片仍然无法高效的进行复用,因为 inBitmap 机制,要求复用的图片必须符合一定的条件,并且在不同的 Android 版本上,复用的条件也是不同的。


为了实现高效的复用,就必须对内存缓存进行更加精细地划分,尤其供复用的图片甚至要按复用条件进行分组存储,具体方案会在下面章节详细介绍。

(一)内存缓存结构


结合资源的引用计数,为很好的区分哪些资源是在使用着的,哪些资源是不再使用供 inBitmap 复用的。内存缓存就无法只使用一个 LruCache 实现了,一种简便的内存结构大体如下:



其中,ActiveResource 是基于 WeakReference<Resource>的 map,当 Resource 被业务方引用时,都可以从 ActiveResource 中找到对应的资源。当业务方不再引用资源,而没有主动回收资源时,也不会造成内存泄漏。


InBitmapPool 主要是存储可用于复用的图片资源,但是,如果 InBitmapPool 中已存在要生成的新图片,从 InBitmapPool 中查询获取可大大减少加载的时间。实际项目中,InBitmapPool 作为内存缓存,在一些场景下其命中率还是很高的。

(二)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 中。

(三)InBitmapPool 结构


inBitmap 在 3.0-4.3 之间,要求【width,height,ARGB】信息完全相同,4.4-7.1 之间,要求复用图片【byte count】大于新生成图片,实际项目中列表使用的图片往往是后台按照统一尺寸剪裁之后的图片,相同尺寸的图片会有多张,合适的方式是将图片按照尺寸规格分组进行管理。


InBitmapPool 抽象的数据结构应该如下:



如上图,将图片资源按照尺寸信息进行分组,分组根据不同图片尺寸的访问频率,按照 LRU 的策略进行管理,这样可保证,InBitmapPool 中存储的都是最近常用尺寸的图片资源,淘汰掉的是不常用尺寸的图片,当从 InBitmapPool 中查找符合尺寸要求的图片资源时,大概率可以找到。

1. 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 中也删除。

2. 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 的头部)。

四、总结


在实际项目中由于 InBitmapPool 也可作为内存缓存使用,可显著加大内存缓存的容量,Android4.4-7.1 之间,图片复用命中率在列表加载不同尺寸图片场景也可稳定在 80%以上,相同使用场景下,使用此复用方案,gc 发生的频率可以减少 60%左右。


对整篇文章做一个简单的总结:inBitmap 复用不再使用的图片,可有效减少 gc;可使用引用计数的方案,标记不再使用的图片;通过封装加载显示过程,向业务方屏蔽引用计数细节;结合引用计数方案,将内存缓存分为 ActiveResource、LruCache、InBitmapPool;InBitmapPool 将图片资源按照尺寸信息进行分组存储,分组根据 LRU 的策略进行管理,保证 InBitmapPool 中存储的都是常用尺寸的图片,提高 inBitmap 的命中率。

用户头像

科技赋能娱乐,“码”出快乐生活 2020.02.13 加入

爱奇艺技术产品团队秉持高效、开放、创新的理念,分享前沿技术,传达爱奇艺生态理念及技术进展。

评论

发布
暂无评论
如何在Android 8.0以下高效地复用图片?