Implement on-disk cache

This commit is contained in:
Adrian Ulrich 2015-11-03 13:03:46 +01:00
parent 36c63ff57a
commit d5d0b23db4
2 changed files with 286 additions and 36 deletions

View File

@ -18,17 +18,24 @@
package ch.blinkenlights.android.vanilla;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.graphics.Bitmap;
import android.graphics.Bitmap.CompressFormat;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.util.LruCache;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
public class CoverCache {
/**
* Returned size of small album covers
@ -58,6 +65,10 @@ public class CoverCache {
* Shared LRU cache class
*/
private static BitmapLruCache sBitmapLruCache;
/**
* Shared on-disk cache class
*/
private static BitmapDiskCache sBitmapDiskCache;
/**
* Bitmask on how we are going to load coverart
*/
@ -73,51 +84,92 @@ public class CoverCache {
if (sBitmapLruCache == null) {
sBitmapLruCache = new BitmapLruCache(context, 6*1024*1024);
}
if (sBitmapDiskCache == null) {
sBitmapDiskCache = new BitmapDiskCache(context, 25*1024*1024);
}
}
/**
* Returns a (possibly uncached) cover for the song - may return null if the song has no cover
* Returns a (possibly uncached) cover for the song - will return null if the song has no cover
*
* @param key The cache key to use for storing a generated cover
* @param song The song used to identify the artwork to load
* @param song The song used to identify the artwork to load
* @return a bitmap or null if no artwork was found
*/
public Bitmap getCoverFromSong(CoverKey key, Song song) {
Bitmap cover = getCachedCover(key);
if (cover == null) {
cover = sBitmapLruCache.createBitmap(song, key.coverSize*key.coverSize);
// memory miss: check disk
cover = getStoredCover(key);
if (cover == null) {
// disk miss: create
cover = sBitmapLruCache.createBitmap(song, key.coverSize*key.coverSize);
if (cover != null) {
storeCover(key, cover);
}
}
// store in memory if cover was re-created
if (cover != null) {
putCover(key, cover);
cacheCover(key, cover);
}
}
return cover;
}
/**
* Returns a cached version of the cover. Will return null if nothing was cached
* Returns a cached version of the cover.
*
* @param key The cache key to use
* @param bitmap or null on cache miss
*/
public Bitmap getCachedCover(CoverKey key) {
return sBitmapLruCache.get(key);
}
/**
* Stores a new entry in the cache
* Stores a new entry in the in-memory cache
* Use getCachedCover to read the cached contents back
*
* @param key The cache key to use
* @param cover The bitmap to store
*/
public void putCover(CoverKey key, Bitmap cover) {
public void cacheCover(CoverKey key, Bitmap cover) {
sBitmapLruCache.put(key, cover);
}
/**
* Deletes all items hold in the LRU cache
* Returns the on-disk cached version of the cover.
* Should only be used on a background thread
*
* @param key The cache key to use
* @return bitmap or null on cache miss
*/
public Bitmap getStoredCover(CoverKey key) {
return sBitmapDiskCache.get(key);
}
/**
* Stores a new entry in the on-disk cache
* Use getStoredCover to read the cached contents back
*
* @param key The cache key to use
* @param cover The bitmap to store
*/
private void storeCover(CoverKey key, Bitmap cover) {
sBitmapDiskCache.put(key, cover);
}
/**
* Deletes all items hold in the cover caches
*/
public static void evictAll() {
if (sBitmapLruCache != null) {
sBitmapLruCache.evictAll();
}
if (sBitmapDiskCache != null) {
sBitmapDiskCache.evictAll();
}
}
@ -152,6 +204,199 @@ public class CoverCache {
return this.mediaType*10 + (int)this.mediaId + this.coverSize * (int)1e5;
}
@Override
public String toString() {
return "CoverKey_i"+this.mediaId+"_t"+this.mediaType+"_s"+this.coverSize;
}
}
private static class BitmapDiskCache extends SQLiteOpenHelper {
/**
* Maximal cache size to use in bytes
*/
private final long mCacheSize;
/**
* SQLite table to use
*/
private final static String TABLE_NAME = "covercache";
/**
* Projection of all columns in the database
*/
private final static String[] FULL_PROJECTION = {"id", "size", "expires", "blob"};
/**
* Projection of metadata-only columns
*/
private final static String[] META_PROJECTION = {"id", "size", "expires"};
/**
* Entries older than so many seconds are expired
*/
private final static long DEFAULT_TTL = 86400*3; // FIXME: We should probably increase this
/**
* Creates a new BitmapDiskCache instance
*
* @param context The context to use
* @param cacheSize The maximal amount of disk space to use in bytes
*/
public BitmapDiskCache(Context context, long cacheSize) {
super(context, "covercache.db", null, 1 /* version */);
mCacheSize = cacheSize;
}
/**
* Called by SQLiteOpenHelper to create the database schema
*/
@Override
public void onCreate(SQLiteDatabase dbh) {
dbh.execSQL("CREATE TABLE "+TABLE_NAME+" (id INTEGER, expires INTEGER, size INTEGER, blob BLOB);");
dbh.execSQL("CREATE UNIQUE INDEX idx ON "+TABLE_NAME+" (id);");
}
/**
* Called by SqLiteOpenHelper if the database needs an upgrade
*/
@Override
public void onUpgrade(SQLiteDatabase dbh, int oldVersion, int newVersion) {
// first db -> nothing to upgrade
}
/**
* Trims the on disk cache to given size
*
* @param maxCacheSize Trim cache to this many bytes
*/
private void trim(long maxCacheSize) {
SQLiteDatabase dbh = getWritableDatabase();
long availableSpace = maxCacheSize - getUsedSpace();
if (maxCacheSize == 0) {
// Just drop the whole database (probably a call from evictAll)
dbh.delete(TABLE_NAME, "1", null);
} else if (availableSpace < 0) {
// Try to evict all expired entries first
int affected = dbh.delete(TABLE_NAME, "expires < ?", new String[] {""+getUnixTime()});
if (affected > 0)
availableSpace = maxCacheSize - getUsedSpace();
if (availableSpace < 0) {
// still not enough space: purge random rows
Cursor cursor = dbh.query(TABLE_NAME, META_PROJECTION, null, null, null, null, "RANDOM()");
if (cursor != null) {
while (cursor.moveToNext() && availableSpace < 0) {
int id = cursor.getInt(0);
int size = cursor.getInt(1);
dbh.delete(TABLE_NAME, "id=?", new String[] {""+id});
availableSpace += size;
}
cursor.close();
}
}
}
}
/**
* Deletes all cached elements from the on-disk cache
*/
public void evictAll() {
// purge all cached entries
trim(0);
// and release the dbh
getWritableDatabase().close();
}
/**
* Checks if given stamp is considered to be expired
*
* @param stamp The timestamp to check
* @return boolean true if stamp is expired
*/
private boolean isExpired(long stamp) {
return (getUnixTime() > stamp);
}
/**
* Returns the current unix timestamp
*
* @return long unix seconds since epoc
*/
private long getUnixTime() {
return System.currentTimeMillis() / 1000L;
}
/**
* Calculates the space used by the sqlite database
*
* @return long the space used in bytes
*/
private long getUsedSpace() {
long usedSpace = -1;
SQLiteDatabase dbh = getWritableDatabase();
Cursor cursor = dbh.query(TABLE_NAME, new String[]{"SUM(size)"}, null, null, null, null, null);
if (cursor != null) {
if (cursor.moveToNext())
usedSpace = cursor.getLong(0);
cursor.close();
}
return usedSpace;
}
/**
* Stores a bitmap in the disk cache, does not update existing objects
*
* @param key The cover key to use
* @param Bitmap The bitmap to store
*/
public void put(CoverKey key, Bitmap cover) {
SQLiteDatabase dbh = getWritableDatabase();
// Ensure that there is some space left
trim(mCacheSize);
ByteArrayOutputStream out = new ByteArrayOutputStream();
cover.compress(Bitmap.CompressFormat.PNG, 100, out);
ContentValues values = new ContentValues();
values.put("id" , key.hashCode());
values.put("expires", getUnixTime() + DEFAULT_TTL);
values.put("size" , out.size());
values.put("blob" , out.toByteArray());
dbh.insert(TABLE_NAME, null, values);
}
/**
* Returns a cached bitmap
*
* @param key The key to lookup
* @return a cached bitmap, null on cache miss
*/
public Bitmap get(CoverKey key) {
Bitmap cover = null;
SQLiteDatabase dbh = getWritableDatabase(); // may also delete
String selection = "id=?";
String[] selectionArgs = { ""+key.hashCode() };
Cursor cursor = dbh.query(TABLE_NAME, FULL_PROJECTION, selection, selectionArgs, null, null, null);
if (cursor != null) {
if (cursor.moveToFirst()) {
long expires = cursor.getLong(2);
byte[] blob = cursor.getBlob(3);
if (isExpired(expires)) {
dbh.delete(TABLE_NAME, selection, selectionArgs);
} else {
ByteArrayInputStream stream = new ByteArrayInputStream(blob);
cover = BitmapFactory.decodeStream(stream);
}
}
cursor.close();
}
return cover;
}
}
/**

View File

@ -23,12 +23,10 @@ import android.graphics.BitmapFactory;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.TransitionDrawable;
import android.graphics.drawable.BitmapDrawable;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.Looper;
import android.os.Message;
import android.util.AttributeSet;
import android.widget.ImageView;
@ -116,42 +114,55 @@ public class LazyCoverView extends ImageView
/**
* mHandler and mUiHandler callbacks
*/
private static final int MSG_CACHE_COVER = 60;
private static final int MSG_DRAW_COVER = 61;
private static final int MSG_READ_COVER = 60;
private static final int MSG_CREATE_COVER = 61;
private static final int MSG_DRAW_COVER = 62;
@Override
public boolean handleMessage(Message message) {
CoverMsg payload = (CoverMsg)message.obj;
if (payload.isRecent() == false) {
return false; // this rpc is obsolete
}
switch (message.what) {
case MSG_CACHE_COVER: {
CoverMsg payload = (CoverMsg)message.obj;
if (payload.isRecent() == false) {
// This RPC is already obsoleted: drop it
break;
case MSG_READ_COVER: {
// Cover was not in in-memory cache: Try to read from disk
Bitmap bitmap = sCoverCache.getStoredCover(payload.key);
if (bitmap != null) {
// Got it: promote to memory cache and let ui thread draw it
sCoverCache.cacheCover(payload.key, bitmap);
sUiHandler.sendMessage(sUiHandler.obtainMessage(MSG_DRAW_COVER, payload));
} else {
sHandler.sendMessageDelayed(sHandler.obtainMessage(MSG_CREATE_COVER, payload), 80);
}
break;
}
case MSG_CREATE_COVER: {
// This message was sent due to a cache miss, but the cover might got cached in the meantime
Bitmap bitmap = sCoverCache.getCachedCover(payload.key);
if (bitmap == null) {
Song song = MediaUtils.getSongByTypeId(mContext.getContentResolver(), payload.key.mediaType, payload.key.mediaId);
if (song != null) {
// we got a song, try to fetch a cover
// will also populate all caches if a cover was found
bitmap = sCoverCache.getCoverFromSong(payload.key, song);
}
if (bitmap == null) {
// song has no cover: return a failback one and store
// it (only) in memory
bitmap = sFallbackBitmap;
sCoverCache.cacheCover(payload.key, bitmap);
}
sCoverCache.putCover(payload.key, bitmap);
}
sUiHandler.sendMessage(sUiHandler.obtainMessage(MSG_DRAW_COVER, payload));
break;
}
case MSG_DRAW_COVER: {
CoverMsg payload = (CoverMsg)message.obj;
// We run in the UI-Thread like setCover()
// and do not need locking: checking if the payload
// is still recent is sufficient.
if (payload.isRecent()) {
payload.view.drawFromCache(payload.key, true);
}
// draw the cover into view. must be called from ui thread handler
payload.view.drawFromCache(payload.key, true);
break;
}
default:
return false;
@ -169,15 +180,9 @@ public class LazyCoverView extends ImageView
public void setCover(int type, long id) {
mExpectedKey = new CoverCache.CoverKey(type, id, CoverCache.SIZE_SMALL);
if (drawFromCache(mExpectedKey, false) == false) {
int delay = 1;
if (sHandler.hasMessages(MSG_CACHE_COVER)) {
// User is probably scrolling fast as there is already a queued resize job
// wait 200ms as this view will most likely be obsolete soon anyway.
// This frees us from scaling bitmaps we are never going to show
delay = 200;
}
CoverMsg payload = new CoverMsg(mExpectedKey, this);
sHandler.sendMessageDelayed(sHandler.obtainMessage(MSG_CACHE_COVER, payload), delay);
// We put the message at the queue start to out-race slow CREATE RPC's
sHandler.sendMessage(sHandler.obtainMessage(MSG_READ_COVER, payload));
}
}