高效使用Bitmaps(三) 神奇的Cache

jopen 11年前

应用的场景

假设你开发了一个聊天程序,它的好友列表中显示从网络获取的好友头像。可是如果用户发现每次进入好友列表的时候,程序都要重新下载头像才能进行显示,甚至当把列表滑动到底部再重新滑动回顶部的时候,刚才已经加载完成了的头像竟然又变成了空白图片开始重新加载,这将是一种糟糕的用户体验。为了解决这种问题,你需要使用高速缓存技术——Cache。

什么是Cache?

Cache,高速缓存,原意是指计算机中一块比内存更高速容量更小的存储器。更广义地说,Cache指对于最近使用过的信息的可高速读取的存储块。而本文要讲的Cache技术,指的就是将最近使用过的Bitmap缓存在手机的内存与磁盘中,来实现再次使用Bitmap时的瞬时加载,以节省用户的时间和手机流量。

下面将针对Android中的两种Cache类型Memory Cache和Disk Cache分别进行介绍。样例代码取自Android开发者站。

1/2:Memory Cache内存中的Cache

Memory Cache使用内存来为应用程序提供Cache。由于内存的读写速度非常快,所以我们应该优先使用它(相对于下面将介绍的Disk Cache来说)。

Android中提供了LruCache类来进行Memory Cache的管理(该类是在Android 3.1时推出的,但我们可以使用android -support-v4.jar的兼容包来对低版本的手机提供支持)。

提示:有人习惯使用SoftReference和WeakReference来做Memory Cache,但谷歌官方不建议这么做。因为自从Android2.3之后,Android中的GC变得更加积极,导致这种做法中缓存的Bitmaps非常容易被回收掉;另外,在Android3.0之前,Bitmap的数据是直接分配在native memory中,它的释放是不受dalvik控制的,因此更容易导致内存的溢出。如果你喜欢简单粗暴的总结,那就是:反正不要用这种方法来管理Memory Cache。

下面我们看一段为Bitmap设置LruCache的代码

private LruCache<String, Bitmap> mMemoryCache;    @Override  protected void onCreate(Bundle savedInstanceState) {      ...      // 获取虚拟机可用内存(内存占用超过该值的时候,将报OOM异常导致程序崩溃)。最后除以1024是为了以kb为单位      final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);        // 使用可用内存的1/8来作为Memory Cache      final int cacheSize = maxMemory / 8;        mMemoryCache = new LruCache<String, Bitmap>(cacheSize) {          @Override          protected int sizeOf(String key, Bitmap bitmap) {              // 重写sizeOf()方法,使用Bitmap占用内存的kb数作为LruCache的size              return bitmap.getByteCount() / 1024;          }      };      ...  }    public void addBitmapToMemoryCache(String key, Bitmap bitmap) {      if (getBitmapFromMemCache(key) == null) {          mMemoryCache.put(key, bitmap);      }  }    public Bitmap getBitmapFromMemCache(String key) {      return mMemoryCache.get(key);  }

提示:在以上代码中,我们使用了可用内存的1/8来提供给Memory Cache,我们简单分析一下这个值。一个普通屏幕尺寸、hdpi的手机的可用内存为32M,那么他的Memory Cache为32M/8=4M。通常hdpi的手机为480*800像素,它一个全屏Bitmap占用内存为480*800*4B=1536400B≈1.5M。那么4M的内存为大约2.5个屏幕大小的bitmap提供缓存。同理,一个普通尺寸、xhdpi大小的720*1280的手机可以为大约2.2个屏幕大小的bitmap提供缓存。

当一个ImageView需要设置一个bitmap的时候,LruCache会进行检查,如果它已经缓存了相应的bitmap,它就直接取出来并设置给这个ImageView;否则,他将启动一个后台线程加载这个Bitmap

public void loadBitmap(int resId, ImageView imageView) {      final String imageKey = String.valueOf(resId);        final Bitmap bitmap = getBitmapFromMemCache(imageKey);      if (bitmap != null) {          mImageView.setImageBitmap(bitmap);      } else {          mImageView.setImageResource(R.drawable.image_placeholder);          BitmapWorkerTask task = new BitmapWorkerTask(mImageView);          task.execute(resId);      }  }
BitmapWorkerTask在加载完成后,通过前面的addBitmapToMemoryCache()方法把这个bitmap进行缓存:

class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {      ...      // 后台加载Bitmap      @Override      protected Bitmap doInBackground(Integer... params) {          final Bitmap bitmap = decodeSampledBitmapFromResource(                  getResources(), params[0], 100, 100));          addBitmapToMemoryCache(String.valueOf(params[0]), bitmap);          return bitmap;      }      ...  }

2/2:Disk Cache(磁盘中的Cache)

前面已经提到,Memory Cache的优点是读写非常快。但它的缺点就是容量太小了,而且不能持久化,所以在用户在滑动GridView时它很快会被用完,而且切换多个界面时或者是关闭程序重新打开后,再次进入原来的界面,Memory Cache是无能为力的。这个时候,我们就要用到Disk Cache了。

Disk Cache将缓存的数据放在磁盘中,因此不论用户是频繁切换界面,还是关闭程序,Disk Cache是不会消失的。

实际上,Android SDK中并没有一个类来实现Disk Cache这样的功能。但google其实已经提供了实现代码:DiskLruCache。我们只要把它搬到自己的项目中就可以了。

下面请看一段使用DiskLruCache来配合Memory Cache进行图片缓存的代码

private DiskLruCache mDiskLruCache;  private final Object mDiskCacheLock = new Object();  private boolean mDiskCacheStarting = true;  private static final int DISK_CACHE_SIZE = 1024 * 1024 * 10; // 10MB  private static final String DISK_CACHE_SUBDIR = "thumbnails";    @Override  protected void onCreate(Bundle savedInstanceState) {      ...      // 初始化memory cache      ...      // 开启后台线程初始化disk cache      File cacheDir = getDiskCacheDir(this, DISK_CACHE_SUBDIR);      new InitDiskCacheTask().execute(cacheDir);      ...  }    class InitDiskCacheTask extends AsyncTask<File, Void, Void> {      @Override      protected Void doInBackground(File... params) {          synchronized (mDiskCacheLock) {              File cacheDir = params[0];              mDiskLruCache = DiskLruCache.open(cacheDir, DISK_CACHE_SIZE);              mDiskCacheStarting = false; // 初始化完成              mDiskCacheLock.notifyAll(); // 唤醒被hold住的线程          }          return null;      }  }    class BitmapWorkerTask extends AsyncTask<Integer, Void, Bitmap> {      ...      // 在后台加载图片      @Override      protected Bitmap doInBackground(Integer... params) {          final String imageKey = String.valueOf(params[0]);            // 通过后台线程检查disk cache          Bitmap bitmap = getBitmapFromDiskCache(imageKey);            if (bitmap == null) { // 如果没有在disk cache中发现这个bitmap              // 加载这个bitmap              final Bitmap bitmap = decodeSampledBitmapFromResource(                      getResources(), params[0], 100, 100));          }            // 把这个bitmap加入cache          addBitmapToCache(imageKey, bitmap);            return bitmap;      }      ...  }    public void addBitmapToCache(String key, Bitmap bitmap) {      // 把bitmap加入memory cache      if (getBitmapFromMemCache(key) == null) {          mMemoryCache.put(key, bitmap);      }        // 同样,也加入disk cache      synchronized (mDiskCacheLock) {          if (mDiskLruCache != null && mDiskLruCache.get(key) == null) {              mDiskLruCache.put(key, bitmap);          }      }  }    public Bitmap getBitmapFromDiskCache(String key) {      synchronized (mDiskCacheLock) {          // 等待disk cache初始化完毕          while (mDiskCacheStarting) {              try {                  mDiskCacheLock.wait();              } catch (InterruptedException e) {}          }          if (mDiskLruCache != null) {              return mDiskLruCache.get(key);          }      }      return null;  }    // 在自带的cache目录下建立一个独立的子目录。优先使用外置存储。但如果外置存储不存在,使用内置存储。  public static File getDiskCacheDir(Context context, String uniqueName) {      // 如果MEDIA目录已经挂载或者外置存储是手机自带的(Nexus设备都这么干),使用外置存储;否则使用内置存储      final String cachePath =              Environment.MEDIA_MOUNTED.equals(Environment.getExternalStorageState()) ||                      !isExternalStorageRemovable() ? getExternalCacheDir(context).getPath() :                              context.getCacheDir().getPath();        return new File(cachePath + File.separator + uniqueName);  }
提示:由于disk cache的初始化是耗时操作,所以这个过程被放在了后台进程。而由此导致的结果是,主线程有可能在它初始化完成之前就尝试读取disk cache,这会导致程序出错。因此以上代码中使用了synchronized关键字和一个lock对象来确保在初始化完成之前disk cache不会被访问。(什么是synchronized?文章最后会有介绍)

上面这段代码看起来比较多,但大致读一下就会发现,它的思路非常简单:1.读取cache的时候,优先读取memory cache,读不到的时候再读取disk cache;2.把bitmap保存到cache中的时候,memory cache和disk cache都要保存。

至此,使用Cache来缓存Bitmap的方法就介绍完了。把这套思路使用在你的项目中,用户体验会马上大大增强的。


延伸:什么是synchronized?

概念:为了防止多个后台并发线程同时对同一个对象进行写操作时发成错误,java使用synchronized关键字对一个对象“加锁”,以保证同时只有一个线程可以访问该对象。

举个例子:快过年了,咱俩去火车站买回家的火车票,我在1号窗口,你在2号窗口,并且咱俩同时排队到了窗户跟前。巧的是,咱俩买的是同一趟车,而这趟车现在只剩一张票了。然后咱俩都跟售票员说:就这张了,买!于是两个售票员同时点击了电脑上的“出票”按钮。后台系统接到两个请求,两个线程同时进行处理,执行了这么两行代码:

if (tickedCount > 0) { // 如果还有票      tickedCount -= 1; // 票数减一      printTicket(); // 出票  }

线程1和线程2几乎同时运行,并且几乎同时执行到第一行代码,线程1一看,哦还有票,行,出票吧!然后执行了第二行代码,票数减一。但它不知道,在他执行第二行代码之前,线程2也执行到了第一行,这线程2也一看,哦还有票,行,出票吧!于是在线程1出票之后,线程2在已经没票的情况下依然把票数减到了-1,并且执行printTicket()方法尝试出票。到了这里,程序到底是会报错还是会出两张一样的票已经不重要,重要的是:系统出问题了,它做了不该做的事。

那么怎么解决呢?很简单,加锁:

synchronized(this) {      if (tickedCount > 0) { // 如果还有票          tickedCount -= 1; // 票数减一          printTicket(); // 出票      }  }
上面这段代码由于加了锁,导致同一时间只有一个线程可以进入这个代码块,当一个线程进入后,其他线程必须等这个线程执行完这段代码后释放了锁,才能进入这个代码块。这样,同时出同一张票的bug就不可能出现了。当然,我只是举例,上面的代码只是一个简化模型。

由于篇幅限制,无法详细地介绍synchronized的更多性质和使用方法,如果有兴趣可以自己查找相关资料。

来自:http://my.oschina.net/rengwuxian/blog/184650