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

Posted

技术标签:

【中文标题】自定义 CursorLoader 和支持 ListView 的 CursorAdapter 之间的数据不同步【英文标题】:Data out of sync between a custom CursorLoader and a CursorAdapter backing a ListView 【发布时间】:2012-07-17 11:01:36 【问题描述】:

背景:

我有一个自定义的CursorLoader,它直接与 SQLite 数据库一起使用,而不是使用 ContentProvider。此加载程序与由CursorAdapter 支持的ListFragment 一起使用。到目前为止一切顺利。

为了简单起见,我们假设 UI 上有一个删除按钮。当用户单击它时,我会从数据库中删除一行,并在我的加载器上调用onContentChanged()。另外,在onLoadFinished() 回调中,我在我的适配器上调用notifyDatasetChanged() 以刷新UI。

问题:

当删除命令快速连续发生时,意味着onContentChanged() 被快速连续调用,bindView() 最终会处理陈旧数据。这意味着一行已被删除,但 ListView 仍在尝试显示该行。这会导致光标异常。

我做错了什么?

代码:

这是一个自定义 CursorLoader(基于 Diane Hackborn 女士的 this advice)

/**
 * 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 类的一个sn-p,它显示了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 将列绑定到行布局中的Views。


编辑 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.向版主提出的问题:是否应该将此编辑移至单独的问题?


编辑 2

根据各种答案的建议,我对Loaders 工作原理的理解似乎存在根本性错误。原来:

    FragmentAdapter 根本不应该直接在 Loader 上运行。 Loader 应该监控数据的所有变化,并且只要数据发生变化,就应该在 onLoadFinished() 中为 Adapter 提供新的 Cursor

有了这种理解,我尝试了以下更改。 - 在Loader 上没有任何操作。 refresh 方法现在什么都不做。

另外,为了调试 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的sn-ps

@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 扩展。我仍然没有看到我的数据库更改被刷新,当我在数据库中插入/删除一行时,我的LoaderonContentChanged() 方法也没有被调用:-(

澄清几点:

    我使用了CursorLoader 的代码,只修改了一行返回Cursor。在这里,我用我的DbManager 代码替换了对ContentProvider 的调用(它又使用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,“光标”不是“实时”对象,它会在数据库中修改数据时更新它所指向的数据。

这是我的 Loader 的最新版本:

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 元素和 db 行,而不是在每次删除时加载光标.. 实际上我在这里展示的场景是我的应用程序实际执行的非常简化的形式。话虽如此,如何在不通过适配器的情况下选择性地删除ListView 中的单行? 我认为同步是更好的解决方案。试试我的答案.. 要明确一点...您在使用LoaderManager 的支持/非支持版本时保持一致,对吗?看起来您正在使用 support.v4.content.CursorLoaderandroid.content.LoaderManager... 您应该改为调用 getSupportLoaderManager() 吗?不是说这就是问题……但仍然如此。 另外,您将0 作为int flag 参数传递给您的CursorAdapter,对吗? (并且您添加到问题中的编辑完全没问题,IMO)。 【参考方案1】:

根据您提供的代码,我不能 100% 确定,但有几点很突出:

    首先要指出的是,您已将此方法包含在您的 ListFragment 中:

    public void refresh()      
        mLoader.onContentChanged();
    
    

    使用LoaderManager 时,很少需要(而且通常很危险)直接操作您的Loader。在第一次调用initLoader 之后,LoaderManager 可以完全控制Loader,并将通过在后台调用其方法来“管理”它。在这种情况下直接调用Loaders 方法时必须非常小心,因为它可能会干扰Loader 的底层管理。我不能肯定你对onContentChanged() 的调用是不正确的,因为你没有在你的帖子中提到它,但在你的情况下它不应该是必要的(也不应该持有对mLoader 的引用)。您的ListFragment 不关心如何检测更改......也不关心如何加载数据。它所知道的是,新数据会在可用时神奇地在onLoadFinished 中提供。

    您也不应该在onLoadFinished 中调用mAdapter.notifyDataSetChanged()swapCursor 会为你做这件事。

在大多数情况下,Loader 框架应该完成所有涉及加载数据和管理Cursors 的复杂事情。相比之下,您的ListFragment 代码应该很简单。


编辑#1:

据我所知,CursorLoader 依赖于 ForceLoadContentObserverLoader&lt;D&gt; 实现中提供的嵌套内部类)......所以看起来这里的问题是你实现您的自定义ContentObserver,但没有设置任何识别它。许多“自我通知”的东西是在Loader&lt;D&gt;AsyncTaskLoader&lt;D&gt; 实现中完成的,因此隐藏在执行实际工作的具体Loaders(例如CursorLoader)之外(即@987654351 @ 不知道 CustomForceLoadContentObserver,所以它为什么会收到任何通知?)。

您在更新后的帖子中提到您无法直接访问final ForceLoadContentObserver mObserver;,因为它是一个隐藏字段。您的解决方法是实现您自己的自定义ContentObserver 并在覆盖的loadInBackground 方法中调用registerObserver()(这将导致registerContentObserver 在您的Cursor 上被调用)。这就是为什么您没有收到通知的原因......因为您使用了 ContentObserver 从未被 Loader 框架识别的自定义 ContentObserver

要解决此问题,您应该直接让您的班级extend AsyncTaskLoader&lt;Cursor&gt; 而不是CursorLoader(即,只需将您从CursorLoader 继承的部分复制并粘贴到您的班级中)。这样您就不会遇到隐藏的 ForceLoadContentObserver 字段的任何问题。

编辑#2:

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

我不怀疑还有其他解决方案(即,可能通过设置全局 ContentObserver... 或者甚至使用 ContentResolver#notifyChange 方法没有 ContentProvider) ,但最干净和最简单的解决方案似乎只是实现私有ContentProvider

(p.s. 确保您在清单中的 provider 标签中设置了android:export="false",这样您的ContentProvider 就不会被其他应用看到!:p)

【讨论】:

我同意不保留对您的加载程序的引用。我所做的只是用 is 重新绑定。 我不明白。你的意思是,如果基础数据发生变化,我需要 什么都不做 来刷新ListView?甚至不需要refresh() 方法?另外,我没有在我的Loader 实现中覆盖onContentChanged()。我上面粘贴的是我的 Loader 的完整实现。 我用CursorLoaderLoaderCallbacks实验的更多细节和结果编辑了这个问题。 @curioustechizen (1) 当基础数据发生变化时,Loader 应该能够识别变化并且应该启动一个新的异步查询。 (2) 查询完成后,最新的更新数据最终会找到onLoadComplete。 (3) 最终在onLoadComplete 中调用onLoadFinishedonLoadFinished 是您知道确定可以安全使用新数据的地方,因此您通常会在此处使用新的Cursor 更新您的 UI(即通过交换新的Cursor 进入适配器)。 (4) 使用swapCursor(cursor) 将新光标交换到适配器中。此方法还将在您的适配器上调用notifyDataSetChanged()(如我最初的帖子中所述),因此您不必担心通知适配器已进行更改。【参考方案2】:

这并不能真正解决您的问题,但它可能对您仍有一些用处:

有一个方法 CursorLoader.setUpdateThrottle(long delayMS),它强制在 loadInBackground 完成和安排下一次加载之间有一个最短时间。

【讨论】:

+1 指出这一点。它仍然不能解决我的问题 - 因为正如我所发现的那样(正如我将很快在对原始问题的编辑中描述的那样),这并不能保证 loadInBackground() 不会被称为 @987654323 @ 尚未完成所有行的渲染。 Alex:这就是为什么我说这不是一个真正的解决方案。也许我应该把它作为评论发布?【参考方案3】:

替代方案:

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


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

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

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

【讨论】:

当我看到这个答案时,我还以为是这样!但是,由于某种原因,我无法理解,尽管尝试对各种代码块进行同步,但这个问题仍然存在。我尝试同步onContentChanged()notifyDataSetChanged(),并尝试同步各种对象-ListView、加载程序、ListFragmentAdapter。还是没用。当我模拟五个同时删除时,我总是遇到这样一种情况:bindView() 中的 Cursor 正在处理在 listView 开始呈现其行后被删除的行。 有什么方法可以停止当前加载? 我用更多细节编辑了这个问题。事实上,我认为在这种情况下,理想的方法是批量删除并调用notifyDatasetChanged() 一次而不是多次。但我仍然看不到实现这一目标的方法。我正在查看cancelLoad() 以及如何使用它来停止加载。 @userSeven7s CursorLoader 只关心 SQLite 数据库……它不知道如何插入/删除数据,也不关心。使用Loaders 是从 SQLite 数据库加载数据的最简单、最方便的方法。 IIRC,为了防止“数据库锁定”异常,您真正需要做的就是使用a singleton SQLiteDatabase instance to perform your database accesses,您可以轻松地在Service 中检索和使用它。 我正在使用 SQLiteOpenHelper,它同步了 getReadableDatabase()getWriteableDatabase() 方法。在每个 SQLite 操作之前,我都会调用适当的 get*Database() 方法。【参考方案4】:

我阅读了您的整个帖子,因为我遇到了同样的问题,以下语句为我解决了这个问题:

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

【讨论】:

你在哪里打电话给restartLoader()?我很确定我也试过这个。如果您能发布一些关于您如何解决问题的代码,我将不胜感激。自从这个帖子打开以来已经快一年了;正如你所看到的,有几个编辑,我迷路了!特别是我很想知道你是否能够在没有自定义 ForceLoadObservers 等的情况下让它工作.. 我在 onResume() 中调用了 restartLoader()。【参考方案5】:

A 也有同样的问题。我通过以下方式解决了它:

@Override
public void onResume() 
    super.onResume();  // Always call the superclass method first
    if (some_condition) 
        getSupportLoaderManager().getLoader(LOADER_ID).onContentChanged();
    

【讨论】:

以上是关于自定义 CursorLoader 和支持 ListView 的 CursorAdapter 之间的数据不同步的主要内容,如果未能解决你的问题,请参考以下文章

使用 EXTRA_ADDRESS_BOOK_INDEX 支持 CursorLoader

NOPI实现导入导出泛型List,支持自定义列

iOS 16 中 SwiftUI 4.0 Toolbar 自定义背景色以及 List 自动编辑操作的原生支持

iOS 16 中 SwiftUI 4.0 Toolbar 自定义背景色以及 List 自动编辑操作的原生支持

iOS 16 中 SwiftUI 4.0 Toolbar 自定义背景色以及 List 自动编辑操作的原生支持

Mvc自定义路由让支持.html的格式