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 View
s 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.
- Supongamos que el
CursorLoader
ha terminado de cargarse y devuelve unCursor
que ahora tiene 5 filas. - El
Adapter
comienza a mostrar estas filas. Mueve elCursor
a la siguiente posición y llamagetView()
- 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.
-
Aquí es donde el problema es - El
CursorAdapter
ha movido elCursor
a una posición que corresponde a una fila eliminada. El métodobindView()
todavía intenta acceder a las columnas de esta fila usando esteCursor
, 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 laListView
incluso cuando está en progreso y pedirle que use el frescoCursor
(devuelto a través deLoader#onContentChanged()
yAdapter#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:
- El
Fragment
oAdapter
no debe estar operando directamente en elLoader
en absoluto. - El
Loader
debe monitorear todos los cambios en los datos y solo debe dar elAdapter
el nuevoCursor
enonLoadFinished()
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]]}
-
Usé el código para
CursorLoader
y solo modifiqué una sola línea que devuelve elCursor
. Aquí, reemplacé la llamada aContentProvider
con mi códigoDbManager
(que a su vez usaDatabaseHelper
para realizar una consulta y devolver elCursor
).Cursor cursor = AppGlobals.INSTANCE.getDbManager().getAllCameras();
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 fondoService
, y en un par de casos, desde unActivity
. Utilizo directamente mi claseDbManager
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();
}
}
5 answers
No estoy 100% seguro basado en el código que has proporcionado, pero un par de cosas sobresalen:
-
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 suLoader
directamente. Después de la primera llamada ainitLoader
, elLoaderManager
tiene control total sobre elLoader
y lo "administrará" llamando a sus métodos en segundo plano. Hay que tener mucho cuidado cuando llamar directamente a los métodosLoader
s en este caso, ya que podría interferir con la gestión subyacente delLoader
. No puedo decir con seguridad que tus llamadas aonContentChanged()
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 amLoader
). A suListFragment
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 enonLoadFinished
cuando se disponible. Tampoco deberías llamar a
mAdapter.notifyDataSetChanged()
enonLoadFinished
.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 Cursor
s. 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 Loader
s 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)
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.
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();
}
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);
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();
}
}
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