自定义CursorLoader和支持ListView的CursorAdapter之间的数据不同步

29

背景:

我有一个自定义的CursorLoader,它直接与SQLite数据库配合工作,而不是使用ContentProvider。这个加载器与由CursorAdapter支持的ListFragment一起工作。到目前为止都很好。

为了简化事情,假设UI上有一个“删除”按钮。当用户点击它时,我会从DB中删除一行,并在我的加载器上调用onContentChanged()。此外,在onLoadFinished()回调中,我调用适配器的notifyDatasetChanged()以刷新UI。

问题:

当删除命令快速连续发生时,也就是说onContentChanged()被快速连续调用时,bindView()最终将使用陈旧的数据。这意味着一行已被删除,但ListView仍然试图显示该行。这会导致游标异常。

我做错了什么?

代码:

这是一个自定义的CursorLoader(基于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);
    }    
}

这是我的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();
}

我的 CursorAdapter 是一个普通的适配器,其中 newView() 被重写以返回新填充的行布局 XML,而 bindView() 利用 Cursor 将列绑定到行布局中的 View


编辑1

经过一番探究,我认为根本问题在于 CursorAdapter 处理底层 Cursor 的方式。我正在尝试理解它是如何工作的。

为了更好地理解,请看以下场景:

  1. 假设 CursorLoader 完成加载并返回具有 5 行的 Cursor
  2. 适配器开始显示这些行。它移动 Cursor 到下一个位置并调用 getView()
  3. 此时,即使列表视图正在被渲染,也会从数据库中删除一行(例如 _id = 2)。
  4. 问题就出在这里——CursorAdapter 已将 Cursor 移动到对应已删除行的位置。而 bindView() 方法仍然尝试使用该 Cursor 访问此行的列,导致无效并抛出异常。

问题:

  • 我的理解正确吗?我特别关注上面第四点,即当删除行时,只有在请求刷新后,Cursor 才会刷新。
  • 假设我的理解是正确的,那么我如何要求我的 CursorAdapter 放弃 / 中止其正在进行的 ListView 渲染,并要求其使用新的 Cursor(通过 Loader#onContentChanged()Adapter#notifyDatasetChanged() 返回)代替?

P.S. 提问者:这个编辑是否应该移动到一个单独的问题中?


编辑2

根据各种答案的建议,我的理解中存在基本错误,即:

  1. FragmentAdapter 不应直接操作 Loader
  2. Loader 应监视所有数据更改,并在数据更改时仅在 onLoadFinished() 中将新的 Cursor 提供给 Adapter

在理解了这一点之后,我尝试了以下更改。- 对 Loader 没有任何操作。现在,刷新方法什么也不做。

为了调试 LoaderContentObserver 内部的情况,我想到了这个:

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

以下是我的FragmentLoaderCallback的片段

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

现在,每当DB发生更改(添加/删除行),ContentObserveronChange()方法应该被调用 - 对吗?我没有看到这种情况发生。我的ListView从不显示任何更改。我唯一看到的变化是如果我在Loader上明确调用onContentChanged()

有什么问题吗?


编辑3

好的,所以我重新编写了Loader,直接扩展自AsyncTaskLoader。我仍然没有看到我的DB更改被刷新,也没有看到在DB中插入/删除一行时调用我的LoaderonContentChanged()方法:-(

只是为了澄清一些事情:

  1. 我使用了CursorLoader的代码,并只修改了一个返回Cursor的单行代码。在这里,我用我的DbManager代码(它又使用DatabaseHelper执行查询并返回Cursor)替换了对ContentProvider的调用。

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

  2. 我的数据库的插入/更新/删除发生在其他地方,并不是通过Loader进行的。在大多数情况下,DB操作正在后台的Service中进行,在几种情况下,则会从一个Activity中进行。我直接使用我的DbManager类来执行这些操作。

我仍然不明白的是 - 谁告诉我的Loader已经添加/删除/修改了一行?换句话说,ForceLoadContentObserver#onChange()在哪里被调用?在我的Loader中,我在Cursor上注册了观察者:

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

这意味着责任在于 Cursor 在改变时通知 mObserver。但是,据我所知,'Cursor'不是一个“活动”对象,它不会在数据在数据库中被修改时更新指向的数据。

这是我的加载器的最新迭代:

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

}

为什么不保持简单:手动删除UI元素和数据库行,而不是在每次删除时加载游标。 - Ron
实际上,我在这里提出的场景是我的应用程序实际执行的非常简化的形式。话虽如此,我如何在不通过适配器的情况下有选择性地删除ListView中的单个行? - curioustechizen
我认为同步是更好的解决方案。尝试一下我的答案。 - Ron
只是为了明确一下...你在使用LoaderManager的支持/非支持版本时保持一致,对吗?看起来你正在使用support.v4.content.CursorLoaderandroid.content.LoaderManager...你应该调用getSupportLoaderManager()而不是吗?并不是说这就是问题所在...但还是要注意。 - Alex Lockwood
еҸҰеӨ–пјҢдҪ дј йҖ’зҡ„int flagеҸӮж•°з»ҷдҪ зҡ„CursorAdapterжҳҜ0пјҢеҜ№еҗ—пјҹпјҲиҖҢдё”дҪ еңЁй—®йўҳдёӯж·»еҠ зҡ„зј–иҫ‘е®Ңе…ЁжІЎй—®йўҳпјҢжҲ‘и§үеҫ—пјүгҖӮ - Alex Lockwood
看起来我正在使用getLoaderManager,但这可能是因为我扩展了SherlockListFragment,它似乎没有一个getSupportLoaderManager()。CursorAdapter的整数标志是0。 - curioustechizen
5个回答

13
我不确定你提供的代码是否正确,但有几个问题需要注意:
  1. The first thing that sticks out is that you've included this method in your ListFragment:

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

    When using the LoaderManager, it's rarely necessary (and is often dangerous) to manipulate your Loader directly. After the first call to initLoader, the LoaderManager has total control over the Loader and will "manage" it by calling its methods in the background. You have to be very careful when calling the Loaders methods directly in this case, as it could interfere with the underlying management of the Loader. I can't say for sure that your calls to onContentChanged() are incorrect, since you don't mention it in your post, but it should not be necessary in your situation (and neither should holding a reference to mLoader). Your ListFragment does not care how changes are detected... nor does it care how data is loaded. All it knows is that new data will magically be provided in onLoadFinished when it is available.

  2. You also shouldn't call mAdapter.notifyDataSetChanged() in onLoadFinished. swapCursor will do this for you.

大多数情况下,Loader框架应该负责所有涉及加载数据和管理Cursor的复杂任务。相比之下,您的ListFragment代码应该更加简单。

##编辑 #1:

据我所知,CursorLoader 依赖于 ForceLoadContentObserver(是在 Loader<D> 实现中提供的嵌套内部类)... 所以问题似乎在于您正在实现自定义的 ContentObserver,但没有设置任何内容来识别它。许多“自我通知”工作都是在 Loader<D>AsyncTaskLoader<D> 实现中完成的,因此这些工作对于实际工作的具体 Loader(例如 CursorLoader)被隐藏起来(即 Loader<D> 不知道 CustomForceLoadContentObserver,那么它为什么会收到任何通知呢?)。

你在更新的帖子中提到,由于final ForceLoadContentObserver mObserver;是一个隐藏字段,因此无法直接访问。你的解决方法是实现自己的自定义ContentObserver并在重写的loadInBackground方法中调用registerObserver()(这将导致在Cursor上调用registerContentObserver)。这就是为什么你没有收到通知的原因...因为你使用了一个自定义的ContentObserver,它从未被Loader框架识别。
要解决这个问题,你应该让你的类直接extend AsyncTaskLoader<Cursor>而不是CursorLoader(即只需复制和粘贴你从CursorLoader继承的部分即可)。这样你就不会遇到任何与隐藏的ForceLoadContentObserver字段相关的问题了。

根据Commonsware的说法, 没有一种简单方法可以设置来自SQLiteDatabase的全局通知,这就是为什么他的Loaderex库中的SQLiteCursorLoader依赖于Loader在每次进行交易时调用onContentChanged()。直接从数据源广播通知的最简单方法是实现一个ContentProvider并使用CursorLoader。这样,您可以相信通知将每次您的Service更新基础数据源时广播到您的CursorLoader

我不怀疑还有其他解决方案(例如,通过设置全局ContentObserver...或甚至使用ContentResolver#notifyChange方法没有ContentProvider),但最清晰和最简单的解决方案似乎就是实现一个私有的ContentProvider

(附注:请确保在您的清单文件中的提供程序标签中设置android:export="false",这样其他应用程序就无法看到您的ContentProvider! :p)


我不明白。你的意思是说,如果底层数据发生变化,我不需要做任何操作就可以刷新ListView吗?甚至不需要使用refresh()方法吗?此外,我在我的Loader实现中没有重写onContentChanged()方法。我上面粘贴的就是我的Loader的完整实现。 - curioustechizen
感谢您的详细解释,并感谢您指出了ForceLoadContentObserver问题。在我的原始问题中,您会发现我没有定义自定义ContentObserver;我只是重新定义了mObserver字段。我现在意识到这也是一个错误。我将尝试使用继承AsyncTaskLoader而不是CursorLoader的建议。 - curioustechizen
我所提到的“自定义”类是CustomForceLoadContentObserver...它是“自定义”的,因为它没有类型Loader.ForceLoadContentObserver,如果您想让您的Loader子类能够在光标上注册内容观察器并在内容更改时接收通知,则需要该类型。 - Alex Lockwood
@curioustechizen,抱歉让你白费力气修改了代码。经过进一步调查,发现Mark Murphy实际上是直接在Loader上调用onContentChanged()的。可以看他的SQLiteCursorLoader实现... ExecSQLTask继承了ContentChangingTask,每次执行SQL事务时都会调用onContentChanged(),这似乎完全消除了ContentObserver的概念。对于ContentProvider来说,这很容易实现...只需要确保在ContentResolver上调用notifyChange()即可。 - Alex Lockwood
ContentProvider、setNotificationUri() 和 notifyChanges() 只适用于有限数量的情况。很容易找到一个简单的用例,它会失败得很惨。请参见:http://stackoverflow.com/questions/32741634/how-to-make-notifychange-work-between-two-activities - f470071
显示剩余10条评论

3

虽然这并不是解决你问题的方法,但它可能仍然对你有用:

有一个名为CursorLoader.setUpdateThrottle(long delayMS)的方法,它强制在loadInBackground完成和下一次加载被调度之间保持至少一定的时间间隔。


感谢指出这一点。但这仍然没有解决我的问题——因为我已经发现(并将很快在原始问题的编辑中描述),这并不能保证loadInBackground()不会在listView尚未完成渲染所有行的情况下被调用。 - curioustechizen
Alex:这就是为什么我说这并不是真正的解决方案。也许我应该把它发布为评论? - Marcus Forsell Stahre

2

替代方案:

我认为使用CursoLoader对于这个任务来说过于复杂。需要同步的是数据库的添加/删除操作,这可以通过同步方法来完成。正如我在之前的评论中所说,当mDNS服务停止时,以同步方式从数据库中删除它,并发送删除广播,在接收器中:从数据持有者列表中删除并通知。这应该足够了。为了避免使用额外的ArrayList(用于支持适配器),使用CursorLoader会增加额外的工作量。


您应该对ListFragment对象进行一些同步处理。

调用notifyDatasetChanged()应该是同步的。

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

当我看到这个答案时,我以为这就是解决方法!但是出于某种原因,尽管在各种代码块上尝试同步,但这个问题仍然存在。我尝试同步 onContentChanged()notifyDataSetChanged(),并尝试在各种对象上同步——ListView、加载器、ListFragment、适配器。但仍然无济于事。当我模拟五个同时删除的操作时,我总会遇到一个情况,在该情况下 bindView() 中的 Cursor 正在处理一个在 listView 开始渲染行之后被删除的行。 - curioustechizen
@userSeven7s 对于遗漏mDNS部分我很抱歉- 我试图保持问题的焦点。我会记住这一点的。我该如何从“Adapter”中删除这些项目?此外,如果我不使用“CursorLoader”,那么我就不用担心方向变化了吗?最后- 我目前的设计是相反的:我有一个“Service”,它为mDNS服务注册有序广播。每当有更改(发现新服务/现有服务消失)时,“Service”会从DB中删除行。然后广播到达“Activity”,在那里我尝试刷新UI。 - curioustechizen
你的广播接收器具体是做什么的? - Ron
2
@userSeven7s CursorLoader 只关心 SQLite 数据库... 它不知道数据是如何被插入/删除的,也不关心。使用 Loader 是从 SQLite 数据库加载数据最简单、最方便的方法。如果我没记错的话,为了防止 "数据库锁定" 异常,你只需要使用 一个单例的 SQLiteDatabase 实例来执行你的数据库访问,然后你可以轻松地检索和在 Service 中使用它。 - Alex Lockwood
1
我正在使用SQLiteOpenHelper,它具有同步的getReadableDatabase()getWriteableDatabase()方法。在每个SQLite操作之前,我都会调用适当的get*Database()方法。 - curioustechizen
显示剩余12条评论

2

我阅读了您的整个线程,因为我也遇到了同样的问题,下面这段代码解决了我的问题:

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


你在哪里调用restartLoader()?我很确定我也尝试过这个。如果您能发布一些解决问题的代码,我将不胜感激。自从打开这个线程以来已经快一年了;正如您所看到的,已经进行了几次编辑,而我却迷失了!特别是我想知道您是否能够在没有自定义ForceLoadObservers等情况下使其正常工作。 - curioustechizen
我在onResume()中调用了restartLoader()。 - marty331

0
我遇到了同样的问题。我通过以下方式解决了它:
@Override
public void onResume() {
    super.onResume();  // Always call the superclass method first
    if (some_condition) {
        getSupportLoaderManager().getLoader(LOADER_ID).onContentChanged();
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接