Datos no sincronizados entre un CursorLoader personalizado y un CursorAdapter que respalda una ListView


Antecedentes:

Tengo un CursorLoader personalizado que funciona directamente con la base de datos SQLite en lugar de usar un ContentProvider. Este cargador funciona con un ListFragment respaldado por un CursorAdapter. Hasta ahora todo bien.

Para simplificar las cosas, supongamos que hay un botón Eliminar en la interfaz de usuario. Cuando el usuario hace clic en esto, elimino una fila de la base de datos y también llamo a onContentChanged() en mi cargador. Además, en onLoadFinished() callback, llamo a notifyDatasetChanged() en mi adaptador para actualizar la interfaz de usuario.

Problema:

Cuando los comandos delete suceder en sucesión rápida, lo que significa que el onContentChanged() se llama en sucesión rápida, bindView() termina trabajando con datos obsoletos . Lo que esto significa es que una fila ha sido eliminada, pero el ListView todavía está intentando mostrar esa fila. Esto conduce a excepciones de 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 el LoaderManager callbacks; así como un método refresh() al que llamo cada vez que el usuario agrega/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 normal con newView() siendo sobrepasado para devolver XML de diseño de fila recién inflado y bindView() usando el Cursor para enlazar columnas a Views en el diseño de fila.


EDITAR 1

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

Tome el siguiente escenario para una mejor comprensión.

  1. Supongamos que el CursorLoader ha terminado de cargarse 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 getView()
  3. En este punto, incluso cuando la vista de lista está en proceso de ser renderizada, 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 usando este Cursor, que no es válido y obtenemos excepciones.

Pregunta:

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

P.d. Pregunta a los moderadores: ¿Se debería mover esta edición a una pregunta separada?


EDITAR 2

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

  1. El Fragment o Adapter no debe estar operando directamente en el Loader en absoluto.
  2. El Loader debe monitorear todos los cambios en los datos y solo debe dar el Adapter el nuevo Cursor en onLoadFinished() siempre que los datos cambien.

Armado con este entendimiento, intenté los siguientes cambios. - Ninguna operación en el Loader en absoluto. El método refresh no hace nada ahora.

También, para depurar lo que está pasando dentro del Loader y el ContentObserver, vine para arriba con esto:

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, cada vez que hay un cambio en la DB (fila añadida / eliminada), el método onChange() del ContentObserver debe ser llamado - ¿correcto? No veo que esto suceda. My ListView nunca muestra ningún cambio. La única vez que veo algún cambio es si llamo explícitamente onContentChanged() en el Loader.

¿Qué está pasando aquí?


EDITAR 3

Ok, así que reescribí mi Loader para extender directamente desde AsyncTaskLoader. Todavía no veo que mis cambios en la base de datos se actualicen, ni el método onContentChanged() de mi Loader que se llama cuando inserto / borro una fila en la base de datos : - (

Solo para aclarar algunas cosas: {[81]]}

  1. Usé el código para CursorLoader y solo modifiqué una sola línea que devuelve el Cursor. Aquí, reemplacé la llamada a ContentProvider con mi código DbManager (que a su vez usa DatabaseHelper para realizar una consulta y devolver el Cursor).

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

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

Lo que todavía no entiendo es - ¿quién le dice a mi Loader que se ha añadido/eliminado/modificado una fila? En otras palabras, ¿dónde se llama ForceLoadContentObserver#onChange()? En mi Cargador, registro a mi observador en el Cursor:

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

Esto implicaría que corresponde al Cursor notificar a mObserver cuando haya cambiado. Pero, entonces AFAIK, un 'Cursor' no es un objeto "vivo" que actualiza los datos a los que apunta a medida que se modifican los datos en la base de datos.

Aquí está 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();
    }

}
Author: curioustechizen, 2012-07-18

5 answers

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

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

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

    Cuando se usa el LoaderManager, rara vez es necesario (y a menudo es peligroso) manipular su Loader directamente. Después de la primera llamada a initLoader, el LoaderManager tiene control total sobre el Loader y lo "administrará" llamando a sus métodos en segundo plano. Hay que tener mucho cuidado cuando llamar directamente a los métodos Loaders en este caso, ya que podría interferir con la gestión subyacente del Loader. No puedo decir con seguridad que tus llamadas a onContentChanged() sean incorrectas, ya que no lo mencionas en tu post, pero no debería ser necesario en tu situación (y tampoco debería contener una referencia a mLoader). A su ListFragment no le importa cómo se detectan los cambios... tampoco 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 se disponible.

  2. Tampoco deberías llamar a mAdapter.notifyDataSetChanged() en onLoadFinished. swapCursor haré esto por ti.

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


Editar #1: {[76]]}

Por lo que puedo decir, el CursorLoader se basa en el ForceLoadContentObserver (una clase interna anidada proporcionada en la implementación Loader<D>)... así que sería parece que el problema aquí es que está implementando su on custom ContentObserver, pero nada está configurado para reconocerlo. Muchas de las cosas de "auto-notificación" se hacen en la implementación Loader<D> y AsyncTaskLoader<D> y, por lo tanto, se ocultan de las Loaders concretas (como CursorLoader) que hacen el trabajo real (es decir, Loader<D> no tiene idea de CustomForceLoadContentObserver, así que ¿por qué debería recibir alguna notificación?).

Mencionaste en tu post actualizado que no puedes acceder a final ForceLoadContentObserver mObserver; directamente, ya que es un campo. Su solución fue implementar su propio custom ContentObserver y llamar registerObserver() en su método override loadInBackground (que hará que registerContentObserver sea llamado en su Cursor). Esta es la razón por la que no recibe notificaciones... porque has usado un ContentObserver personalizado que nunca es reconocido por el framework Loader.

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

Editar #2:

Según Commonsware, no hay una manera fácil de configurar notificaciones globales procedentes de un SQLiteDatabase, por lo que el SQLiteCursorLoader en su biblioteca Loaderex se basa en el Loader llamando onContentChanged() en sí mismo cada vez que se realiza una transacción. La forma más fácil de transmitir notificaciones directamente desde la fuente de datos es implementar un ContentProvider y usar un CursorLoader. De esta manera puede confiar en que las notificaciones serán transmitido a su CursorLoader cada vez que su Service actualiza la fuente de datos subyacente.

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

(p. s. asegúrese de establecer android:export="false" en la etiqueta de proveedor en su manifiesto para que su ContentProvider no puede ser visto por otras aplicaciones! : p)

 13
Author: Alex Lockwood,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 12:33:58

Esto no es realmente una solución para su problema, pero todavía podría ser de alguna utilidad para usted:

Hay un método CursorLoader.setUpdateThrottle(long delayMS), que impone un tiempo mínimo entre la finalización de loadInBackground y la próxima carga programada.

 3
Author: Marcus Forsell Stahre,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-07-20 20:08:34

Alternativa:

Siento que usar un CursoLoader es demasiado pesado para esta tarea. Lo que necesita estar sincronizado es la base de datos agregar / eliminar, y se puede hacer en un método sincronizado. Como dije en un comentario anterior, cuando un servicio mDNS se detiene, eliminar de ella db (de manera sincronizada), enviar eliminar difusión, en receptor: eliminar de la lista de titulares de datos y notificar. Eso debería ser suficiente. Solo para evitar usar una arraylist adicional (para respaldar el adaptador), usar un CursorLoader es trabajo extra.


Debería realizar alguna sincronización en el objeto ListFragment.

La llamada a notifyDatasetChanged() debe sincronizarse.

synchronized(this) {  // this is ListFragment or ListView.
     notifyDatasetChanged();
}
 2
Author: Ronnie,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-07-25 13:01:44

Leí 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);

 1
Author: marty331,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2013-06-06 04:46:02

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();
    }
}
 0
Author: Mansurov Ruslan,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2013-06-13 13:09:36