Background:
I have a custom CursorLoader that works directly with SQLite Database instead of using a ContentProvider. This loader works with a ListFragment backed by a CursorAdapter. So far so good.
To simplify things, lets assume there is a Delete button on the UI. When user clicks this, I delete a row from the DB, and also call onContentChanged() on my loader. Also, on onLoadFinished() callback, I call notifyDatasetChanged() on my adapter so as to refresh the UI.
Problem:
When the delete commands happen in rapid succession, meaning the onContentChanged() is called in rapid succession, bindView() ends up to be working with stale data. What this means is a row has been deleted, but the ListView is still attempting to display that row. This leads to Cursor exceptions.
What am I doing wrong?
Code:
This is a custom CursorLoader (based on this advice by Ms. Diane Hackborn)
/**
* An implementation of CursorLoader that works directly with SQLite database
* cursors, and does not require a ContentProvider.
*
*/
public class VideoSqliteCursorLoader extends CursorLoader {
/*
* This field is private in the parent class. Hence, redefining it here.
*/
ForceLoadContentObserver mObserver;
public VideoSqliteCursorLoader(Context context) {
super(context);
mObserver = new ForceLoadContentObserver();
}
public VideoSqliteCursorLoader(Context context, Uri uri,
String[] projection, String selection, String[] selectionArgs,
String sortOrder) {
super(context, uri, projection, selection, selectionArgs, sortOrder);
mObserver = new ForceLoadContentObserver();
}
/*
* Main logic to load data in the background. Parent class uses a
* ContentProvider to do this. We use DbManager instead.
*
* (non-Javadoc)
*
* @see android.support.v4.content.CursorLoader#loadInBackground()
*/
@Override
public Cursor loadInBackground() {
Cursor cursor = AppGlobals.INSTANCE.getDbManager().getAllCameras();
if (cursor != null) {
// Ensure the cursor window is filled
int count = cursor.getCount();
registerObserver(cursor, mObserver);
}
return cursor;
}
/*
* This mirrors the registerContentObserver method from the parent class. We
* cannot use that method directly since it is not visible here.
*
* Hence we just copy over the implementation from the parent class and
* rename the method.
*/
void registerObserver(Cursor cursor, ContentObserver observer) {
cursor.registerContentObserver(mObserver);
}
}
A snippet from my ListFragment class that shows the LoaderManager callbacks; as well as a refresh() method that I call whenever user adds/deletes a record.
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mListView = getListView();
/*
* Initialize the Loader
*/
mLoader = getLoaderManager().initLoader(LOADER_ID, null, this);
}
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new VideoSqliteCursorLoader(getActivity());
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
mAdapter.swapCursor(data);
mAdapter.notifyDataSetChanged();
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
mAdapter.swapCursor(null);
}
public void refresh() {
mLoader.onContentChanged();
}
My CursorAdapter is just a regular one with newView() being over-ridden to return newly inflated row layout XML and bindView() using the Cursor to bind columns to Views in the row layout.
EDIT 1
After digging into this a bit, I think the fundamental issue here is the way the CursorAdapter handles the underlying Cursor. I'm trying to understand how that works.
Take the following scenario for better understanding.
- Suppose the
CursorLoaderhas finished loading and it returns aCursorthat now has 5 rows. - The
Adapterstarts displaying these rows. It moves theCursorto the next position and callsgetView() - At this point, even as the list view is in the process of being rendered, a row (say, with _id = 2) is deleted from the database.
- This is where the issue is - The
CursorAdapterhas moved theCursorto a position which corresponds to a deleted row. ThebindView()method still tries to access the columns for this row using thisCursor, which is invalid and we get exceptions.
Question:
- Is this understanding correct? I am particularly interested in point 4 above where I am making the assumption that when a row gets deleted, the
Cursordoesn't get refreshed unless I ask for it to be. - Assuming this is right, how do I ask my
CursorAdapterto discard/abort its rendering of theListVieweven as it is in progress and ask it to use the freshCursor(returned throughLoader#onContentChanged()andAdapter#notifyDatasetChanged()) instead?
P.S. Question to moderators: Should this edit be moved to a separate question?
EDIT 2
Based on suggestion from various answers, it looks like there was a fundamental mistake in my understanding of how Loaders work. It turns out that:
- The
FragmentorAdaptershould not be directly operating on theLoaderat all. - The
Loadershould monitor for all changes in data and should just give theAdapterthe newCursorinonLoadFinished()whenever data changes.
Armed with this understanding, I attempted the following changes.
- No operation on the Loader whatsoever. The refresh method does nothing now.
Also, to debug what's going on inside the Loader and the ContentObserver, I came up with this:
public class VideoSqliteCursorLoader extends CursorLoader {
private static final String LOG_TAG = "CursorLoader";
//protected Cursor mCursor;
public final class CustomForceLoadContentObserver extends ContentObserver {
private final String LOG_TAG = "ContentObserver";
public CustomForceLoadContentObserver() {
super(new Handler());
}
@Override
public boolean deliverSelfNotifications() {
return true;
}
@Override
public void onChange(boolean selfChange) {
Utils.logDebug(LOG_TAG, "onChange called; selfChange = "+selfChange);
onContentChanged();
}
}
/*
* This field is private in the parent class. Hence, redefining it here.
*/
CustomForceLoadContentObserver mObserver;
public VideoSqliteCursorLoader(Context context) {
super(context);
mObserver = new CustomForceLoadContentObserver();
}
/*
* Main logic to load data in the background. Parent class uses a
* ContentProvider to do this. We use DbManager instead.
*
* (non-Javadoc)
*
* @see android.support.v4.content.CursorLoader#loadInBackground()
*/
@Override
public Cursor loadInBackground() {
Utils.logDebug(LOG_TAG, "loadInBackground called");
Cursor cursor = AppGlobals.INSTANCE.getDbManager().getAllCameras();
//mCursor = AppGlobals.INSTANCE.getDbManager().getAllCameras();
if (cursor != null) {
// Ensure the cursor window is filled
int count = cursor.getCount();
Utils.logDebug(LOG_TAG, "Count = " + count);
registerObserver(cursor, mObserver);
}
return cursor;
}
/*
* This mirrors the registerContentObserver method from the parent class. We
* cannot use that method directly since it is not visible here.
*
* Hence we just copy over the implementation from the parent class and
* rename the method.
*/
void registerObserver(Cursor cursor, ContentObserver observer) {
cursor.registerContentObserver(mObserver);
}
/*
* A bunch of methods being overridden just for debugging purpose.
* We simply include a logging statement and call through to super implementation
*
*/
@Override
public void forceLoad() {
Utils.logDebug(LOG_TAG, "forceLoad called");
super.forceLoad();
}
@Override
protected void onForceLoad() {
Utils.logDebug(LOG_TAG, "onForceLoad called");
super.onForceLoad();
}
@Override
public void onContentChanged() {
Utils.logDebug(LOG_TAG, "onContentChanged called");
super.onContentChanged();
}
}
And here are snippets of my Fragment and LoaderCallback
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
mListView = getListView();
/*
* Initialize the Loader
*/
getLoaderManager().initLoader(LOADER_ID, null, this);
}
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
return new VideoSqliteCursorLoader(getActivity());
}
@Override
public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
Utils.logDebug(LOG_TAG, "onLoadFinished()");
mAdapter.swapCursor(data);
}
@Override
public void onLoaderReset(Loader<Cursor> loader) {
mAdapter.swapCursor(null);
}
public void refresh() {
Utils.logDebug(LOG_TAG, "CamerasListFragment.refresh() called");
//mLoader.onContentChanged();
}
Now, whenever there is a change in the DB (row added/deleted), the onChange() method of the ContentObserver should be called - correct? I don't see this happening. My ListView never shows any change. The only time I see any change is if I explicitly call onContentChanged() on the Loader.
What's going wrong here?
EDIT 3
Ok, so I re-wrote my Loader to extend directly from AsyncTaskLoader. I still don't see my DB changes being refreshed, nor the onContentChanged() method of my Loader being called when I insert/delete a row in the DB :-(
Just to clarify a few things:
I used the code for
CursorLoaderand just modified one single line that returns theCursor. Here, I replaced the call toContentProviderwith myDbManagercode (which in turn usesDatabaseHelperto perform a query and return theCursor).Cursor cursor = AppGlobals.INSTANCE.getDbManager().getAllCameras();My inserts/updates/deletes on the database happen from elsewhere and not through the
Loader. In most cases the DB operations are happening in a backgroundService, and in a couple of cases, from anActivity. I directly use myDbManagerclass to perform these operations.
What I still don't get is - who tells my Loader that a row has been added/deleted/modified? In other words, where is ForceLoadContentObserver#onChange() called? In my Loader, I register my observer on the Cursor:
void registerContentObserver(Cursor cursor, ContentObserver observer) {
cursor.registerContentObserver(mObserver);
}
This would imply that the onus is on the Cursor to notify mObserver when it has changed. But, then AFAIK, a 'Cursor' is not a "live" object that updates the data it is pointing to as and when data is modified in the DB.
Here's the latest iteration of my Loader:
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import android.support.v4.content.AsyncTaskLoader;
public class VideoSqliteCursorLoader extends AsyncTaskLoader<Cursor> {
private static final String LOG_TAG = "CursorLoader";
final ForceLoadContentObserver mObserver;
Cursor mCursor;
/* Runs on a worker thread */
@Override
public Cursor loadInBackground() {
Utils.logDebug(LOG_TAG , "loadInBackground()");
Cursor cursor = AppGlobals.INSTANCE.getDbManager().getAllCameras();
if (cursor != null) {
// Ensure the cursor window is filled
int count = cursor.getCount();
Utils.logDebug(LOG_TAG , "Cursor count = "+count);
registerContentObserver(cursor, mObserver);
}
return cursor;
}
void registerContentObserver(Cursor cursor, ContentObserver observer) {
cursor.registerContentObserver(mObserver);
}
/* Runs on the UI thread */
@Override
public void deliverResult(Cursor cursor) {
Utils.logDebug(LOG_TAG, "deliverResult()");
if (isReset()) {
// An async query came in while the loader is stopped
if (cursor != null) {
cursor.close();
}
return;
}
Cursor oldCursor = mCursor;
mCursor = cursor;
if (isStarted()) {
super.deliverResult(cursor);
}
if (oldCursor != null && oldCursor != cursor && !oldCursor.isClosed()) {
oldCursor.close();
}
}
/**
* Creates an empty CursorLoader.
*/
public VideoSqliteCursorLoader(Context context) {
super(context);
mObserver = new ForceLoadContentObserver();
}
@Override
protected void onStartLoading() {
Utils.logDebug(LOG_TAG, "onStartLoading()");
if (mCursor != null) {
deliverResult(mCursor);
}
if (takeContentChanged() || mCursor == null) {
forceLoad();
}
}
/**
* Must be called from the UI thread
*/
@Override
protected void onStopLoading() {
Utils.logDebug(LOG_TAG, "onStopLoading()");
// Attempt to cancel the current load task if possible.
cancelLoad();
}
@Override
public void onCanceled(Cursor cursor) {
Utils.logDebug(LOG_TAG, "onCanceled()");
if (cursor != null && !cursor.isClosed()) {
cursor.close();
}
}
@Override
protected void onReset() {
Utils.logDebug(LOG_TAG, "onReset()");
super.onReset();
// Ensure the loader is stopped
onStopLoading();
if (mCursor != null && !mCursor.isClosed()) {
mCursor.close();
}
mCursor = null;
}
@Override
public void onContentChanged() {
Utils.logDebug(LOG_TAG, "onContentChanged()");
super.onContentChanged();
}
}