Implement on-disk cache
This commit is contained in:
parent
36c63ff57a
commit
d5d0b23db4
@ -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
|
||||
* @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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user