Данные не синхронизируются между пользовательским CursorLoader и CursorAdapter, поддерживающим ListView
История:
У меня есть пользовательский CursorLoader
, который работает непосредственно с SQLite Database вместо использования ContentProvider
. Этот загрузчик работает с ListFragment
с поддержкой CursorAdapter
. Пока все хорошо.
Чтобы упростить ситуацию, предположим, что в пользовательском интерфейсе есть кнопка "Удалить". Когда пользователь нажимает на это, я удаляю строку из БД, а также вызываю onContentChanged()
на моем загрузчике. Кроме того, на onLoadFinished()
обратном вызове я вызываю notifyDatasetChanged()
на моем адаптере, чтобы обновить интерфейс.
Проблема:
Когда команды удаления выполняются быстро, что означает, что onContentChanged()
вызывается в быстрой последовательности, bindView()
заканчивается, чтобы работать со устаревшими данными. Это означает, что строка удалена, но ListView все еще пытается отобразить эту строку. Это приводит к исключениям курсора.
Что я делаю неправильно?
код:
Это пользовательский CursorLoader (на основе этот совет от Дайан Хакборн)
/**
* 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);
}
}
Отрывок из моего класса ListFragment
, который показывает обратные вызовы LoaderManager
; а также метод refresh()
, который я вызываю всякий раз, когда пользователь добавляет/удаляет запись.
@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
является просто регулярным с newView()
, который перегружен, чтобы возвращать вновь раздутый макет строки XML и bindView()
с помощью Cursor
для привязки столбцов к View
в макете строк.
РЕДАКТИРОВАТЬ 1
Покопавшись в этом немного, я думаю, что основная проблема здесь заключается в том, как CursorAdapter
обрабатывает базовый Cursor
. Я пытаюсь понять, как это работает.
Для лучшего понимания возьмите следующий сценарий.
- Предположим, что
CursorLoader
закончил загрузку и возвращает Cursor
, который теперь имеет 5 строк.
-
Adapter
начинает отображать эти строки. Он перемещает Cursor
в следующую позицию и вызывает getView()
- В этот момент, даже когда представление списка находится в процессе визуализации, строка (скажем, с _id = 2) удаляется из базы данных.
- Здесь проблема.
CursorAdapter
переместил Cursor
в позицию, которая соответствует удаленной строке. Метод bindView()
по-прежнему пытается получить доступ к столбцам для этой строки, используя этот Cursor
, который является недопустимым, и мы получаем исключения.
Вопрос:
- Правильно ли это понимание? Меня особенно интересует пункт 4 выше, где я исхожу из предположения, что когда строка удаляется,
Cursor
не обновляется, если я не прошу об этом.
- Предполагая, что это правильно, как я могу попросить
CursorAdapter
отклонить/прервать его рендеринг ListView
, даже когда он выполняется, и попросить его использовать свежий Cursor
(возвращается через Loader#onContentChanged()
и Adapter#notifyDatasetChanged()
) вместо?
P.S. Вопрос модераторам: должно ли это редактирование быть перенесено на отдельный вопрос?
EDIT 2
Основываясь на предположении из разных ответов, похоже, что в моем понимании того, как работает Loader
, была фундаментальная ошибка. Оказывается, что:
-
Fragment
или Adapter
не должны работать непосредственно на Loader
вообще.
-
Loader
должен отслеживать все изменения в данных и должен просто указывать Adapter
новый Cursor
в onLoadFinished()
всякий раз, когда данные изменяются.
Вооружившись этим пониманием, я предпринял следующие изменения.
- Нет операции на Loader
вообще. Метод обновления ничего не делает.
Кроме того, чтобы отладить, что происходит внутри Loader
и ContentObserver
, я придумал следующее:
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();
}
}
И вот фрагменты моих Fragment
и 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();
}
Теперь, когда есть изменения в БД (добавлена /удалена строка), метод onChange()
ContentObserver
должен быть вызван - правильно? Я этого не вижу. Мой ListView
никогда не показывает никаких изменений. Единственный раз, когда я вижу какое-либо изменение, я должен явно называть onContentChanged()
на Loader
.
Что здесь не так?
РЕДАКТИРОВАТЬ 3
Хорошо, поэтому я переписал мой Loader
, чтобы перейти непосредственно из AsyncTaskLoader
. Я до сих пор не вижу изменений в обновлении базы данных или метода onContentChanged()
моего Loader
, вызываемого при вставке/удалении строки в БД: - (
Просто чтобы прояснить несколько вещей:
-
Я использовал код для CursorLoader
и только что изменил одну строку, которая возвращает Cursor
. Здесь я заменил вызов на ContentProvider
моим кодом DbManager
(который, в свою очередь, использует DatabaseHelper
для выполнения запроса и возвращает Cursor
).
Cursor cursor = AppGlobals.INSTANCE.getDbManager().getAllCameras();
-
Мои вставки/обновления/удаления в базе данных происходят из других источников, а не через Loader
. В большинстве случаев операции БД происходят в фоновом режиме Service
, а в нескольких случаях - от Activity
. Я непосредственно использую класс DbManager
для выполнения этих операций.
То, что я до сих пор не получаю, - , который сообщает моей Loader
, что строка была добавлена /удалена/изменена? Другими словами, где называется ForceLoadContentObserver#onChange()
? В моем загрузчике я регистрирую своего наблюдателя на Cursor
:
void registerContentObserver(Cursor cursor, ContentObserver observer) {
cursor.registerContentObserver(mObserver);
}
Это означало бы, что бремя на Cursor
должно оповещать mObserver
, когда оно изменилось. Но, тогда AFAIK, "Курсор" не является "живым" объектом, который обновляет данные, на которые он указывает, когда и когда данные изменяются в БД.
Здесь последняя итерация моего загрузчика:
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();
}
}
Ответы
Ответ 1
Я не уверен на 100%, основываясь на коде, который вы предоставили, но пара штук торчит:
-
Первое, что торчит, это то, что вы включили этот метод в свой ListFragment
:
public void refresh() {
mLoader.onContentChanged();
}
При использовании LoaderManager
редко бывает необходимо (и часто опасно) напрямую манипулировать вашим Loader
. После первого вызова initLoader
, LoaderManager
имеет полный контроль над Loader
и будет "управлять", вызывая его методы в фоновом режиме. Вы должны быть очень осторожны при вызове методов Loader
непосредственно в этом случае, так как это может помешать базовому управлению Loader
. Я не могу точно сказать, что ваши звонки на onContentChanged()
неверны, поскольку вы не упоминаете об этом в своем сообщении, но это не должно быть необходимо в вашей ситуации (и ни одна из них не должна содержать ссылку на mLoader
). Ваш ListFragment
не заботится о том, как изменяются изменения... и не заботится о том, как загружаются данные. Все, что он знает, это то, что новые данные будут волшебным образом предоставлены в onLoadFinished
, когда они будут доступны.
-
Вы также не должны вызывать mAdapter.notifyDataSetChanged()
в onLoadFinished
. swapCursor
сделает это за вас.
По большей части структура Loader
должна выполнять все сложные вещи, связанные с загрузкой данных и управлением Cursor
s. Ваш код ListFragment
должен быть простым в сравнении.
Изменить # 1:
Из того, что я могу сказать, CursorLoader
полагается на ForceLoadContentObserver
(вложенный внутренний класс, представленный в реализации Loader<D>
)... так что кажется, что проблема здесь в том, что вы реализуя ваш пользовательский ContentObserver
, но ничего не создано для его распознавания. Многие элементы "самообновления" выполняются в реализации Loader<D>
и AsyncTaskLoader<D>
и поэтому скрыты от конкретного Loader
(например, CursorLoader
), которые выполняют фактическую работу (т.е. Loader<D>
не имеет понятия о CustomForceLoadContentObserver
, так почему он должен получать какие-либо уведомления?).
Вы упомянули в своем обновленном сообщении, что вы не можете напрямую обращаться к final ForceLoadContentObserver mObserver;
, так как это скрытое поле. Исправлено создание собственного ContentObserver
и вызов registerObserver()
в методе overriden loadInBackground
(что вызовет вызов registerContentObserver
на ваш Cursor
). Вот почему вы не получаете уведомления... потому что вы использовали пользовательский ContentObserver
, который никогда не распознается инфраструктурой Loader
.
Чтобы устранить проблему, вы должны иметь свой класс непосредственно extend AsyncTaskLoader<Cursor>
вместо CursorLoader
(т.е. просто скопируйте и вставьте части, которые вы наследуете из CursorLoader
в свой класс). Таким образом, вы не столкнетесь с какими-либо проблемами в скрытом поле ForceLoadContentObserver
.
Изменить # 2:
Согласно Commonsware, нет простого способа настроить глобальные уведомления, исходящие из SQLiteDatabase
, поэтому SQLiteCursorLoader
в своей библиотеке Loaderex
полагается на Loader
вызов onContentChanged()
на себя каждый раз при совершении транзакции. Самый простой способ передачи уведомлений прямо из источника данных - реализовать ContentProvider
и использовать CursorLoader
. Таким образом, вы можете доверять, что уведомления будут транслироваться на ваш CursorLoader
каждый раз, когда ваш Service
обновляет базовый источник данных.
Я не сомневаюсь, что существуют другие решения (т.е., возможно, путем создания глобального ContentObserver
... или, возможно, даже с помощью метода ContentResolver#notifyChange
без ContentProvider
)), но самое простое и простое решение похоже, просто реализовать частный ContentProvider
.
(убедитесь, что вы установили android:export="false"
в теге поставщика в манифесте, чтобы ваш ContentProvider
не мог быть замечен другими приложениями!: p)
Ответ 2
На самом деле это не решение вашей проблемы, но для вас это может быть полезно:
Существует метод CursorLoader.setUpdateThrottle(long delayMS)
, который обеспечивает минимальное время между загрузкой loadInBackground и следующей запланированной загрузкой.
Ответ 3
Альтернатива:
Я считаю, что использование CursoLoader слишком тяжело для этой задачи. Что нужно синхронизировать, это добавление/удаление базы данных, и это можно сделать с помощью синхронизированного метода. Как я уже сказал в более раннем комментарии, когда служба mDNS останавливается, удалите из нее db (синхронизированным образом), отправьте удаление трансляции в приемнике: удалите из списка держателей данных и уведомите. Этого должно быть достаточно. Чтобы избежать использования дополнительного arraylist (для поддержки адаптера), использование CursorLoader является дополнительной работой.
Вы должны выполнить некоторую синхронизацию в объекте ListFragment
.
Вызов notifyDatasetChanged()
должен быть синхронизирован.
synchronized(this) { // this is ListFragment or ListView.
notifyDatasetChanged();
}
Ответ 4
Я прочитал весь ваш поток, поскольку у меня была одна и та же проблема. Следующее утверждение - это то, что разрешило эту проблему для меня:
getLoaderManager().restartLoader(0, null, this);
Ответ 5
A имела ту же проблему. Я решил это:
@Override
public void onResume() {
super.onResume(); // Always call the superclass method first
if (some_condition) {
getSupportLoaderManager().getLoader(LOADER_ID).onContentChanged();
}
}