Datos fuera de sincronización entre un CursorLoader personalizado y un CursorAdapter respaldando un ListView

Fondo:

Tengo un CursorLoader encargo que trabaja directamente con la base de datos de SQLite en vez de utilizar un ContentProvider . Este cargador funciona con un ListFragment respaldado por un CursorAdapter . Hasta aquí todo bien.

Para simplificar las cosas, vamos a suponer que hay un botón Eliminar en la interfaz de usuario. Cuando el usuario hace clic en esto, onContentChanged() una fila del DB, y también llamo onContentChanged() en mi cargador. Además, en la devolución de llamada notifyDatasetChanged() , llamo notifyDatasetChanged() en mi adaptador para actualizar la interfaz de usuario.

Problema:

Cuando los comandos de eliminación ocurren en sucesión rápida, lo que significa que onContentChanged() se llama en sucesión rápida, bindView() termina trabajando con datos obsoletos . Lo que esto significa es que se ha eliminado una fila, pero ListView sigue intentando mostrar esa fila. Esto conduce a las excepciones Cursor.

¿Qué estoy haciendo mal?

Código:

Este es un CursorLoader personalizado (basado en este consejo de la Sra. 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); } } 

Un fragmento de mi clase ListFragment que muestra las LoaderManager llamada LoaderManager ; Así como un método refresh() que llamo cuando el usuario añade / elimina un registro.

 @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(); } 

Mi CursorAdapter es solo uno regular con newView() siendo over-ridden para devolver el diseño de fila recién inflado XML y bindView() usando el Cursor para enlazar columnas a View s en el diseño de fila.


EDITAR 1

Después de cavar en esto un poco, creo que la cuestión fundamental aquí es la forma en que CursorAdapter maneja el Cursor subyacente. Estoy tratando de entender cómo funciona.

Tome el siguiente escenario para una mejor comprensión.

  1. Supongamos que CursorLoader ha terminado de cargar y devuelve un Cursor que ahora tiene 5 filas.
  2. El Adapter comienza a mostrar estas filas. Mueve el Cursor a la siguiente posición y llama a getView()
  3. En este punto, incluso mientras la vista de lista está en proceso de ser procesada, una fila (por ejemplo, con _id = 2) se elimina de la base de datos.
  4. Aquí es donde el problema es – El CursorAdapter ha movido el Cursor a una posición que corresponde a una fila eliminada. El método bindView() todavía intenta acceder a las columnas de esta fila utilizando este Cursor , que no es válido y obtenemos excepciones.

Pregunta:

  • ¿Es correcto este entendimiento? Estoy particularmente interesado en el punto 4 anterior en el que estoy haciendo la suposición de que cuando se elimina una fila, el Cursor no se actualiza a menos que yo pida que sea.
  • Suponiendo que esto es correcto, ¿cómo puedo pedir a mi CursorAdapter para descartar / abortar su representación de la ListView incluso como está en progreso y pedirle que utilice el nuevo Cursor (devuelto a través de Loader#onContentChanged() y Adapter#notifyDatasetChanged() ?

PS Pregunta a los moderadores: ¿Debería cambiar esta edición a otra pregunta?


EDIT 2

Basado en la sugerencia de varias respuestas, parece que hubo un error fundamental en mi comprensión de cómo Loader s trabajo. Resulta que:

  1. El Fragment o el Adapter no deben estar funcionando directamente en el Loader .
  2. El Loader debe supervisar todos los cambios en los datos y sólo debe dar al Adapter el nuevo Cursor en onLoadFinished() siempre que cambie los datos.

Armado con esta comprensión, intenté los cambios siguientes. – Ninguna operación en el Loader . El método de actualización no hace nada ahora.

Además, para depurar lo que está pasando dentro de Loader y el ContentObserver , me ocurrió lo siguiente:

 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(); } } 

Y aquí hay fragmentos de mi Fragment y 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(); } 

Ahora, siempre que hay un cambio en el DB (fila agregada / suprimida), el método onChange() del ContentObserver debe ser llamado – correcto? No veo que esto suceda. Mi ListView nunca muestra ningún cambio. La única vez que veo algún cambio es si llamo explícitamente onContentChanged() al Loader .

¿Qué pasa aquí?


EDIT 3

Ok, así que volví a escribir mi Loader para extender directamente desde AsyncTaskLoader . Aún no veo mis cambios de base de datos actualizados, ni el método onContentChanged() de mi Loader se llama cuando inserto / elimino una fila en el DB 🙁

Sólo para aclarar algunas cosas:

  1. He utilizado el código para CursorLoader y acaba de modificar una sola línea que devuelve el Cursor . Aquí, he reemplazado la llamada a ContentProvider con mi código DbManager (que a su vez utiliza DatabaseHelper para realizar una consulta y devolver el Cursor ).

    Cursor cursor = AppGlobals.INSTANCE.getDbManager().getAllCameras();

  2. Mis inserciones / actualizaciones / eliminaciones en la base de datos suceden desde cualquier lugar y no a través de Loader . En la mayoría de los casos las operaciones de DB están ocurriendo en un Service fondo, y en un par de casos, de una Activity . Utilizo directamente mi clase de DbManager para realizar estas operaciones.

Lo que todavía no obtengo es – ¿ quién le dice a mi Loader que una fila ha sido añadida / eliminada / modificada? En otras palabras, ¿dónde se ForceLoadContentObserver#onChange() ? En mi Cargador, inscribo a mi observador en el Cursor :

 void registerContentObserver(Cursor cursor, ContentObserver observer) { cursor.registerContentObserver(mObserver); } 

Esto implicaría que la responsabilidad está en el Cursor para notificar mObserver cuando ha cambiado. Pero, a continuación, AFAIK, un 'Cursor' no es un objeto "en vivo" que actualiza los datos que está apuntando a como y cuando los datos se modifican en el DB.

Esta es la última iteración de mi cargador:

 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(); } } 

No estoy 100% seguro basado en el código que has proporcionado, pero un par de cosas sobresalen:

  1. Lo primero que sobresale es que has incluido este método en tu ListFragment :

     public void refresh() { mLoader.onContentChanged(); } 

    Cuando se utiliza LoaderManager , rara vez es necesario (ya menudo es peligroso) manipular directamente a Loader . Después de la primera llamada a initLoader , LoaderManager tiene control total sobre el Loader y lo "administrará" llamando a sus métodos en segundo plano. Debe tener mucho cuidado al llamar a los métodos de Loader directamente en este caso, ya que podría interferir con la gestión subyacente del Loader . No puedo asegurar que tus llamadas a onContentChanged() son incorrectas, ya que no las mencionas en tu mensaje, pero no debería ser necesario en tu situación (y tampoco debería tener una referencia a mLoader ). Su ListFragment no le importa cómo se detectan los cambios … ni le importa cómo se cargan los datos. Todo lo que sabe es que los nuevos datos mágicamente se proporcionarán en onLoadFinished cuando esté disponible.

  2. Tampoco debería llamar a mAdapter.notifyDataSetChanged() en onLoadFinished . swapCursor hará esto por usted.

En su mayor parte, el marco de Loader debe hacer todas las cosas complicadas que implican cargar datos y administrar el Cursor . Su código ListFragment debe ser simple en comparación.


Editar # 1:

Por lo que puedo decir, el CursorLoader basa en el ForceLoadContentObserver (una clase interna anidada proporcionada en la implementación de Loader<D> ), por lo que parece que el problema aquí es que está implementando su personalizado ContentObserver , pero no hay nada Establecido para reconocerlo. Muchas de las cosas de "auto-notificación" se realizan en la implementación de Loader<D> y AsyncTaskLoader<D> y, por lo tanto, están ocultas del Loader concreto (como CursorLoader ) No tiene idea acerca de CustomForceLoadContentObserver , así que ¿por qué debería recibir alguna notificación?).

Usted mencionó en su publicación actualizada que no puede acceder a final ForceLoadContentObserver mObserver; Directamente, ya que es un campo oculto. Su solución era implementar su propio ContentObserver personalizado y llamar a registerObserver() en su método loadInBackground (que hará que registerContentObserver sea ​​llamado en su Cursor ). Esta es la razón por la que no recibe notificaciones … porque ha utilizado un ContentObserver personalizado que nunca ha sido reconocido por el marco de ContentObserver .

Para solucionar el problema, debe tener su clase extend AsyncTaskLoader<Cursor> directamente extend AsyncTaskLoader<Cursor> lugar de CursorLoader (es decir, simplemente copie y pegue las partes que está heredando de CursorLoader en su clase). De esta manera no se ejecutará en ningún problema con el campo ocultado ForceLoadContentObserver .

Editar # 2:

De acuerdo con Commonsware , no hay una manera fácil de configurar notificaciones globales procedentes de una base de datos SQLiteDatabase , por lo que SQLiteCursorLoader en su biblioteca Loaderex basa en el Loader llama onContentChanged() sobre sí mismo cada vez que se realiza una transacción. La forma más fácil de difundir las notificaciones directamente desde la fuente de datos es implementar un ContentProvider y utilizar un CursorLoader . De esta manera, puede confiar en que las notificaciones se difundirán en su CursorLoader cada vez que su Service actualice el origen de datos subyacente.

No dudo que hay otras soluciones (es decir, quizás mediante la creación de un ContentObserver global … o tal vez incluso utilizando el método ContentResolver#notifyChange sin un ContentProvider ), pero la solución más limpia y más simple parece ser simplemente implementar un Privado ContentProvider .

(Ps, asegúrate de establecer android:export="false" en la etiqueta del proveedor en tu manifiesto para que tu ContentProvider no pueda ser visto por otras aplicaciones !: p)

Esto no es realmente una solución para su problema, pero puede ser útil para usted:

Hay un método CursorLoader.setUpdateThrottle(long delayMS) , que impone un tiempo mínimo entre completar loadInBackground y la próxima carga que se está programando.

Alternativa:

Siento que usar un CursoLoader es demasiado pesado para esta tarea. Lo que necesita estar en sincronía es la base de datos add / delete, y se puede hacer en un método sincronizado. Como dije en un comentario anterior, cuando un servicio mDNS se detiene, quite de él db (de forma sincronizada), envíe remove broadcast, en el receptor: quite de la lista de data-holder y notifique. Eso debería ser suficiente. Sólo para evitar el uso de un arraylist extra (para respaldar el adaptador), utilizando un CursorLoader es extra-trabajo.


Debe realizar alguna sincronización en el objeto ListFragment .

La llamada a notifyDatasetChanged() debe estar sincronizada.

 synchronized(this) { // this is ListFragment or ListView. notifyDatasetChanged(); } 

He leído todo su hilo como yo estaba teniendo el mismo problema, la siguiente declaración es lo que resolvió este problema para mí:

getLoaderManager().restartLoader(0, null, this);

A tenía el mismo problema. Lo resolví por:

 @Override public void onResume() { super.onResume(); // Always call the superclass method first if (some_condition) { getSupportLoaderManager().getLoader(LOADER_ID).onContentChanged(); } } 
  • Androide. Yor propio progreso-bar?
  • Actualizar la interfaz de usuario desde un AsyncTaskLoader
  • Cómo manejar los errores en AsyncTaskLoader personalizado?
  • Explique AsyncTaskLoader
  • Actualizar la barra de progreso de AsyncTaskLoader?
  • Cómo pasar params a Android AsnycTaskLoader
  • Custom AsyncTaskLoader, loadinBackground no se llama después de 5 intentos
  • FlipAndroid es un fan de Google para Android, Todo sobre Android Phones, Android Wear, Android Dev y Aplicaciones para Android Aplicaciones.