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 View
s 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
CursorLoader
has finished loading and it returns aCursor
that now has 5 rows. - The
Adapter
starts displaying these rows. It moves theCursor
to 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
CursorAdapter
has moved theCursor
to 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
Cursor
doesn't get refreshed unless I ask for it to be. - Assuming this is right, how do I ask my
CursorAdapter
to discard/abort its rendering of theListView
even 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 Loader
s work. It turns out that:
- The
Fragment
orAdapter
should not be directly operating on theLoader
at all. - The
Loader
should monitor for all changes in data and should just give theAdapter
the newCursor
inonLoadFinished()
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
CursorLoader
and just modified one single line that returns theCursor
. Here, I replaced the call toContentProvider
with myDbManager
code (which in turn usesDatabaseHelper
to 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 myDbManager
class 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();
}
}
ListView
without going through the Adapter? – DiploblasticLoaderManager
, correct? It looks like you are using thesupport.v4.content.CursorLoader
with theandroid.content.LoaderManager
... should you be callinggetSupportLoaderManager()
instead? Not saying that is the problem... but still. – Tedious0
as theint flag
argument to yourCursorAdapter
, correct? (And the edit you've added to the question is totally fine, IMO). – TediousgetLoaderManager
, but that's probably because I extendSherlockListFragment
which I doesn't seem to have agetSupportLoaderManager()
. The int flag to CursorAdapter is 0. – Diploblastic