图片选择控件--逐步做到让自己满意

最近公司有一个项目需要支持手机本地图片的多选,就像微信那样的。
OK,不能调用系统的图片选择控件,那就自己写个吧,基本思路就是使用ContentProvider扫描手机中的图片,然后以Gridview的方式展示图片,同时为了保证能图片能快速加载,需要对图片进行缓存(内存缓存是必须的,由于本来就是本地的图片,暂时可以不用再在SD卡中缓存)。
ContentProvider扫描手机中的图片好办,关键是如何更快的加载图片。

Universal Image Loader ?

最初想到的就是使用开源图片加载框架 UIL , 作为目前使用最广泛的图片加载框架,不用真是可惜了,于是优先考虑它了。
Demo很快就写好了,大部分手机上也测试OK,但是在一台小米3手机上出现了内存溢出的问题,如下图(OOM导致了部分图片出现加载失败):
图片

究其原因,米三手机的分辨率(1920*1080)太大,拍照拍出来的图片也大(一般都在2M左右),当一次加载的图片太多的时候就容易出现内存溢出的情况。
为了不占用太多的内存,我按照官方的说法(见UIL项目介绍):

  1. 将内存缓存的图片尺寸改小(之前没有指定,UIL框架默认会按照手机的分辨率来确定缓存图片的尺寸):
ImageLoaderConfiguration.Builder builder = new ImageLoaderConfiguration.Builder(context)
        ...
        .memoryCacheExtraOptions(480, 800) // default = device screen dimensions
        .discCacheExtraOptions(480, 800, CompressFormat.JPEG, 75, null)
        ...
  1. 将显示时的图片的质量降低、同时图片缩放:
DisplayImageOptions options = new DisplayImageOptions.Builder()
            ...
            .bitmapConfig(Bitmap.Config.RGB_565)
            .imageScaleType(ImageScaleType.EXACTLY)
            ...
            .build();

OK,内存溢出的现象是不见了,但是新的问题出现了:
由于UIL的缓存配置是全局的,如果设置缓存的图片尺寸较小,那么当用户需要查看大图片的时候,可能加载出来的图片是小尺寸的缓存图片。
况且像这样指定缓存图片的大小可能影响到其他使用的UIL显示大图界面,所以为了不影响UIL在其他界面的使用,还是不要限制图片在内存中缓存的尺寸。
另外UIL在缓存图片的时候虽然也有控制图片缩放的参数.imageScaleType但是却不能根据需要来缩放,所以种种原因迫使我放弃使用UIL来加载本地图片。

普通多线程 ?

在决定放弃使用UIL加载本地图片之后,我开始自己写缓存图片的方案,我的想法是只在内存中缓存就好,没必要再在本地磁盘中缓存一份,同时为了保存不OOM,需要限定缓存图片的规格。
于是我专门写了一个加载本地图片的类,使用android支持包中的LruCache作为内存缓存,根据要显示图片的ImageView控件的宽和高来缩放图片以降低内存使用量。
使用时也比较简单:

  1. 当要加载图片的时候首先查找内存LruCache中是否有缓存,如果有直接返回该缓存图片。
  2. 如果内存中没有缓存,创建一个新线程用于加载图片。加载图片之前首先根据传入的图片的宽高参数计算目标图片的缩放比,之后根据缩放比从磁盘中取出相应的图片并返回,同时将图片缓存到内存LruCache以便下次使用。

由于使用LruCache,出现内存不足的时候,系统会自动gc,所以一般情况不会出现OOM的情况。同时由于没有缓存的时候都会新建一个线程用于加载图片所以加载图片的速度也还可以接受。
但是问题是,当图片多的时候,频繁新建线程的内存开销会比较大,这样会导致UI线程卡慢的情况(ListView或GridView滚动不流畅)。
出现这种情况后我立马就想可以,用线程池来代替每次都新建线程的情况。

线程池 ?

用线程池代替Thread比较简单,Java中的Executors类以工厂模式的方式提供了一些快速创建常用的线程池的方法。例如:

  1. 创建固定大小的线程池: Executors.newFixedThreadPool(int nThreads);
  2. 创建大小为1的固定线程池: Executors.newSingleThreadExecutor();
  3. 创建线程keepAliveTime为1分钟的可伸缩线程池: Executors.newCachedThreadPool();
  4. 创建一个支持定时及周期性的任务执行的线程池: Executors.newScheduledThreadPool(int nThreads);

使用线程池后UI主线程不卡了,但又有问题,当图片多的时候,Gridview滑动到底部,这时候图片可能要等很久才能加载出来。这种情况很好理解,当图片很多的时候,线程池中的那几个线程根本不够用,所以这时候图片的加载还是会表现出一定的顺序性。如果直接滑动到GridView或ListView的底部,图片自然要等一段时间才能加载出来。
于是乎我想,可以通过监听ListView/GridView的滚动来控制图片是否加载,当控件滚动的时候不加载图片,只有当其静止的时候才加载。

监听ListView/GridView的滚动事件 ?

最初想到这种方式后并没有立即就去做,原因就是这种方式的可复用性比较低,要所有显示本地图片的界面都去监听滚动事件有点不切实际,况且从没见过哪个图片加载框架要监听控件的滚动,但他们都运行的好好的,所以这种方法肯定不是最好的。
更现实的问题是当某次要加载的图片特别多的时候(比如说超过1000张),如果用户恰好要选择靠后的图片,这时候控件大部分时间处于滚动的状态,如果此时不加载图片,就会给用户一个图片没有加载的信号,用户感官上会觉得图片加载很慢。同时在滑动的过程中,从用户手指离开屏幕到控件完全停止滚动是一个减速的过程,这个过程有一段时间,如果在这段时间就开始加载图片而不是等到控件完全停止才加载,那么等控件停止的时候就可以省下不少加载图片的时间了。
综合考虑以上因素,我放弃了通过监听ListView/GridView 的滚动事假来判断是否应该加载图片的这种想法。

我的最终方案 YES

重新审视这个问题,我觉得至少有两个条件必须满足:

  1. 尽可能少占用内存,不OOM是底线(线程池+内存LruCache基本可以达到这个要求)。
  2. 图片加载的速度要快。

第二个问题是目前最棘手的问题。如果优先加载ListView/GridView可见区域的图片而暂时忽略不可见部分的图片,由于可见部分的图片数量比较少,即使单个图片比较大也能在短时间内将可见区域的图片加载完。这样不管用户想要看前面的图片还是后面的图片都能在短时间内在界面上显示,这样用户的体验会比较好。
前面讲到的监听控件的滚动事件其实际也是优先加载可见区域的图片。那么可不可以用其他方法优先加载可视化区域的图片呢?
经过一段时间的探索,我的最终方案确定了: 由于在ListView/GridView滚动的时候会调用其adapter的getView方法(该方法中发送加载图的请求),而且是只有当ListView或GridView中的Item可见的时候才会调用getView方法, 这样我们就可以人为定义图片加载的优先级了,总结起来一句话:后来居上。 具体实施方案如下:

  1. 自己维护一个请求列表,根据请求的不同优先级加载图片,具体是这样的。
  2. 首先判断图片是否在内存缓存中,如果有,直接从内存中取出图片。
  3. 如果没有,构建一个图片请求对象,加入到请求列表(如果请求列表中原来有这个请求,删除原来的,将新的加入到列表末尾,这样可以保证该请求的优先级高)
  4. 线程池按照如下的规则处理图片请求列表:
    • 如果请求列表中的请求数量小于线程池中空闲的线程数,顺序的将请求分配给线程池中空闲线程(这时候请求列表中的所有请求同时得到执行)
    • 如果请求列表中的请求数量大于线程池中空闲的线程数,将空闲的线程的分配给优先级高的请求(优先级根据请求的先后顺序来定,后请求的优先级高)
  5. 当某个请求被执行完后,从请求列表中删除请求任务,同时,如果请求列表中还有未处理完的任务,继续按照上述规则处理请求。

这样最终效果还不错,也达到了我与其的效果。
图片

PS:以上是本人在做图片选择控件时的一些经历与体会,纯粹是个人的一些想法,可能并不是最好的解决方案,如果您有更好的解决方案,希望您能分享出来,让大家共同学习。

查看源代码

知识共享许可协议
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。

blog comments powered by Disqus