The Problem
When I was writing Magnatune, one thing I needed to solve was lazy-loading cover art into the ListView. ListViews are a tricky beast on Android, due to the recycled views, and so the naive approach just doesn't work. I googled around a bit, asked on the android-developers Google Group, and asked on IRC. All to no avail. Romain Guy did, however, point me (yet again) to his very useful Shelves project. The solution he uses there is more complicated than I needed so I didn't use it directly, but it served as a great stepping-stone. I'll compare/contrast our solutions in a bit.
What I wound up doing can be seen in the following files: HTTPQueue.java, HTTPThread.java, RemoteImageView.java, and LazyAdapter.java. I will describe what each one of these does in turn.
My Solution
At the heart of the system is HttpQueue. This is actually a misnomer as it behaves, in my case, more like a stack than a queue, but no matter.
public synchronized void enqueue(HTTPThread task, int priority) {
Boolean exists = mThreads.get(task.getId());
if (exists == null) {
if (mQueue.size() == 0 || priority == PRIORITY_LOW) {
mQueue.add(task);
} else {
mQueue.add(1, task);
}
mThreads.put(task.getId(), true);
}
runFirst();
}
public synchronized void dequeue(final HTTPThread task) {
mThreads.remove(task.getId());
mQueue.remove(task);
}
public synchronized void finished(int result) {
if (mQueuedHandler != null) {
mQueuedHandler.sendEmptyMessage(result);
}
runFirst();
}
private synchronized void runFirst() {
if (mQueue.size() > 0) {
HTTPThread task = mQueue.get(0);
if (task.getStatus() == HTTPThread.STATUS_PENDING) {
mQueuedHandler = task.getHandler();
task.setHandler(mHandler);
task.start();
} else if (task.getStatus() == HTTPThread.STATUS_FINISHED) {
HTTPThread thread = mQueue.remove(0);
mThreads.remove(thread.getId());
runFirst();
}
}
}
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message message) {
finished(message.what);
}
};
What this snippet does is the following:
- Given a HTTPThread and a priority, insert it into the queue appropriately (higher priority means it goes in the front of the queue)
- Pull an HTTPThread off the queue (front of the line) and run it
- When that thread is done, let the Handler know so that the HTTPThread can be notified that it's done
- Go back to step one.
This class is very simple--any first-year CS student should be able to understand what it does and why it works. If you don't understand it, however, please let me know and I can clarify.
Up next, we have HTTPThread:
public void start() {
if (getStatus() == STATUS_PENDING) {
synchronized (this) {
mStatus = STATUS_RUNNING;
}
super.start();
}
}
public void run() {
try {
URL request = new URL(mUrl);
InputStream is = (InputStream) request.getContent();
FileOutputStream fos = new FileOutputStream(mLocal);
try {
byte[] buffer = new byte[4096];
int l;
while ((l = is.read(buffer)) != -1) {
fos.write(buffer, 0, l);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
is.close();
fos.flush();
fos.close();
}
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
synchronized (this) {
mStatus = STATUS_FINISHED;
}
Handler handler = getHandler();
if (handler != null) {
handler.sendEmptyMessage(STATUS_FINISHED);
}
}
public int getStatus() {
synchronized (this) {
return mStatus;
}
}
HTTPThread is also very simple--it's a subclass of a standard Java thread which, given a target URL and a local file path, will download the remote file to the local file and notify the Handler when it's done. That's it.
Finally, we're back to the UI (almost) with the introduction of RemoteImageView.
public void loadImage() {
if (mRemote != null) {
if (mLocal == null) {
mLocal = Environment.getExternalStorageDirectory() + "/.remote-image-view-cache/" + mRemote.hashCode() + ".jpg";
}
// check for the local file here instead of in the thread because
// otherwise previously-cached files wouldn't be loaded until after
// the remote ones have been downloaded.
File local = new File(mLocal);
if (local.exists()) {
setFromLocal();
} else {
// we already have the local reference, so just make the parent
// directories here instead of in the thread.
local.getParentFile().mkdirs();
queue();
}
}
}
public void finalize() {
if (mThread != null) {
HTTPQueue queue = HTTPQueue.getInstance();
queue.dequeue(mThread);
}
}
private void queue() {
if (mThread == null) {
mThread = new HTTPThread(mRemote, mLocal, mHandler);
HTTPQueue queue = HTTPQueue.getInstance();
queue.enqueue(mThread, HTTPQueue.PRIORITY_HIGH);
}
setImageResource(R.drawable.icon);
}
private void setFromLocal() {
mThread = null;
Drawable d = Drawable.createFromPath(mLocal);
if (d != null) {
setImageDrawable(d);
}
}
private Handler mHandler = new Handler() {
@Override
public void handleMessage(Message msg) {
setFromLocal();
}
};
RemoteImageView is a subclass of the standard ImageView widget, but with extra functionality. To summarize:
- Display a given local image as a placeholder
- See if the image has already been cached so that we can skip the network. If so, replace the image and finish.
- If not, put in a request to load a given URL.
- Wait patiently.
- Once the image has been downloaded, the Handler will notify the RemoteImageView to load the now-cached image.
To manage all of these interactions, we have the final piece of the puzzle: LazyAdapter. The relevant portion is very small:
public View getView(int position, View convertView, ViewGroup parent) {
// SNIP [irrelevant code]
View ret = super.getView(position, convertView, parent);
if (ret != null) {
RemoteImageView riv = (RemoteImageView) ret.findViewById(android.R.id.icon);
if (riv != null && !mFlinging) {
riv.loadImage();
}
}
return ret;
}
@Override
public void setViewImage(final ImageView image, final String value) {
if (value != null && value.length() > 0 && image instanceof RemoteImageView) {
RemoteImageView riv = (RemoteImageView) image;
riv.setLocalURI(MagnatuneAPI.getCacheFileName(value));
riv.setRemoteURI(value);
super.setViewImage(image, R.drawable.icon);
} else {
image.setVisibility(View.GONE);
}
}
Alright, so what we have in the first part is simply code to determine whether or not we're flinging and see if we should load the image or not (when performing a "fling", we don't want to load since those images won't matter in about half a second :-).
setViewImage(), however, is important because it is called when the Adapter tries to load the desired image. This behavior is overridden to tell the RemoteImageView to get in line and start getting loaded. We also replace the image with a placeholder since views are recycled and we don't want to display a recycled image while the new one downloads.
Comparing Solutions
If you compare Romain Guy's solution against mine, you'll see significant differences. I feel that his is overly complicated and I didn't need all of that in my solution. Basically, what his does is handle a lot of corner cases. Consider:
- Load images when idle.
- Load images when scrolling via finger.
- Do not load images when flinging.
- Do load images when flinging and the screen is touched to stop.
- Do load images when flinging and the screen is touched to manually scroll.
- Do not load images when flinging and the screen is touched to fling some more.
Phew! I didn't need all of that stuff, so what I did was say that every image request gets to budge in line. This means that the most recent ones to display will be loaded first and the ones that were firt requested are last, which is fine, since it's probably already off-screen. If not, well, the user won't care when they come in anyway.
That said, my solution does exhibit one oddity that his does not: my images load from the bottom of the screen to the top. Honestly, that doesn't matter to me, though I could see how it could strike a user as strange. Oh well, I'll take the added simplicity for the slightly strange behavior (partly because my app isn't shipping on millions and millions of phones, like some of his are :D).
Anyway, I hope that this helps someone else looking for an answer with this.