news 2026/9/28 4:29:34

Cursor 与 CursorAdapter 的观察者模式:从源码到可复用配置骨架

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Cursor 与 CursorAdapter 的观察者模式:从源码到可复用配置骨架

1. 从一次「数据改了界面不动」说起

如果你写过 Android 里ListView+CursorAdapter的组合,大概率遇到过这种场景:在后台线程往SQLite里插了一条记录,ContentProvider的insert也正常返回了,可列表就是不刷新,非得手动调一次adapter.notifyDataSetChanged()才肯动。更迷惑的是,有时候它又自己刷新了,行为飘忽不定。

这背后其实是 Android 一套挺精巧的观察者模式在起作用:Cursor自己是个被观察者,CursorAdapter是观察者,ContentProvider通过Uri把变更事件广播出去,最后一路传导到notifyDataSetChanged()。整条链路涉及AbstractCursor、ContentObservable、DataSetObservable、SelfContentObserver、ChangeObserver、MyDataSetObserver好几个类,名字长得像绕口令,但拆开看逻辑并不复杂。

这篇就按「源码链路 → 可复制骨架 → 日志验证 → 常见坑」的顺序走一遍。适合已经用过CursorAdapter、但没搞清自动刷新原理的同学,也适合想把这套机制封装成可复用配置骨架的人。我会给出一个能直接抄的CursorAdapter子类,配上ContentObserver的注册与注销代码,再用Log把每一步回调打出来,让你亲眼看到数据变更怎么触发刷新。顺带说一句,如果你在写这类 Android 代码时想用 AI 辅助生成骨架或排查回调顺序,把 Key 和 API 通道统一管起来会省不少事,后面会提到怎么用 TaoToken 做这件事。

2. 观察者模式在 Cursor 链路里的三层结构

先把整体结构理清楚,不然看源码容易迷路。这套机制其实是三层观察者嵌套:

第一层,ContentProvider和Cursor之间靠Uri通信。query()返回Cursor之前会调setNotificationUri(),AbstractCursor内部会 new 一个SelfContentObserver注册到这个Uri上。当ContentProvider的insert/update/delete里调了notifyChange(),这个SelfContentObserver就会收到回调。

第二层,Cursor和CursorAdapter之间。CursorAdapter构造时会 new 出ChangeObserver和MyDataSetObserver,分别通过registerContentObserver()和registerDataSetObserver()注册到Cursor内部的两个主题对象mContentObservable和mDataSetObservable上。

第三层,Cursor内部的两个主题对象。ContentObservable和DataSetObservable都继承自Observable,是标准的观察者模式主题类,负责遍历通知所有注册的观察者。

链路串起来就是:notifyChange()→SelfContentObserver.onChange()→AbstractCursor.onChange()→mContentObservable.dispatchChange()→ChangeObserver.onChange()→onContentChanged()→mCursor.requery()→mDataSetObservable.notifyChanged()→MyDataSetObserver.onChanged()→notifyDataSetChanged()。

看到没,最后一步才是真正让界面刷新的。中间任何一环断了,列表都不会动。最常见的断点就是FLAG_REGISTER_CONTENT_OBSERVER没设,导致ChangeObserver压根没注册。

3. TaoToken 前置:把 AI 辅助配置的 Key 统一管起来

写 Android 这类回调链路代码时,我经常用 AI 帮忙生成骨架或者解释某段源码的执行顺序。但工具一多,Key 散落在各个配置文件里,改一次要翻好几个地方,挺烦的。TaoToken 在这里的作用就是提供一个统一的 Key 和 API 通道入口,把模型对话、编码辅助这些能力收敛到一处管理。

它的官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api ,注意 API 这个地址不带 UTM 参数,直接填就行。如果你只是想让 AI 解释一下dispatchChange和notifyChange的区别,用模型对话就够了;如果是长期写 Android 项目、想让 AI 持续参与编码,那 Coding Plan 更合适。

具体操作上,先去控制台建一个 Key,然后按文档把 Key 配到你的开发环境里。这一步不用写进 Android 工程代码,只是给你本地写代码时用的辅助通道。下面给个用 curl 验证 Key 是否可用的例子,确认通道通了再往下写业务代码:

curl https://taotoken.net/api/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -d '{ "model": "claude-sonnet-4-20250514", "messages": [ {"role": "user", "content": "用一句话解释 Android Cursor 的 dispatchChange 和 notifyChange 区别"} ] }'

返回里能看到正常的choices结构就说明通道没问题。这一步和后面的 Android 代码是分开的,别把 Key 硬编码进CursorAdapter里,那是两码事。

4. 可复制的 CursorAdapter 子类骨架

下面这个骨架是我实际项目里用过的简化版,去掉了业务字段,保留了观察者注册和注销的完整逻辑。关键点是构造时传FLAG_REGISTER_CONTENT_OBSERVER,以及在swapCursor和close时正确注销。

public class NoteCursorAdapter extends CursorAdapter { private static final String TAG = "NoteCursorAdapter"; private ContentObserver mExtraObserver; public NoteCursorAdapter(Context context, Cursor c) { // 关键:必须带 FLAG_REGISTER_CONTENT_OBSERVER super(context, c, FLAG_REGISTER_CONTENT_OBSERVER); } @Override public View newView(Context context, Cursor cursor, ViewGroup parent) { return LayoutInflater.from(context) .inflate(R.layout.item_note, parent, false); } @Override public void bindView(View view, Context context, Cursor cursor) { TextView title = view.findViewById(R.id.tv_title); String text = cursor.getString(cursor.getColumnIndexOrThrow("title")); title.setText(text); Log.d(TAG, "bindView: " + text); } @Override public Cursor swapCursor(Cursor newCursor) { Log.d(TAG, "swapCursor: old=" + getCursor() + ", new=" + newCursor); return super.swapCursor(newCursor); } @Override public void changeCursor(Cursor cursor) { Log.d(TAG, "changeCursor called"); super.changeCursor(cursor); } @Override public void notifyDataSetChanged() { Log.d(TAG, "notifyDataSetChanged triggered"); super.notifyDataSetChanged(); } }

这里有个容易忽略的点:FLAG_AUTO_REQUERY在 API 11 之后已经废弃了,别再用它。用FLAG_REGISTER_CONTENT_OBSERVER就够了,它会让CursorAdapter在init()里 new 出ChangeObserver和MyDataSetObserver并注册到Cursor上。

如果你还想监听别的Uri(比如另一张表的变更),可以自己加一个ContentObserver,在swapCursor里注册、在close里注销:

public void registerExtraObserver(ContentResolver resolver, Uri uri) { if (mExtraObserver == null) { mExtraObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { @Override public void onChange(boolean selfChange) { Log.d(TAG, "extra observer onChange, selfChange=" + selfChange); // 这里可以触发重新 query 或局部刷新 } }; } resolver.registerContentObserver(uri, true, mExtraObserver); Log.d(TAG, "extra observer registered for " + uri); } public void unregisterExtraObserver(ContentResolver resolver) { if (mExtraObserver != null) { resolver.unregisterContentObserver(mExtraObserver); Log.d(TAG, "extra observer unregistered"); mExtraObserver = null; } }

注销这一步千万别省。ContentObserver如果注册了不注销,Activity销毁后Cursor还持有引用,轻则内存泄漏,重则在后台收到回调操作已销毁的 View 直接崩。

5. 用日志验证数据变更触发刷新

光看代码不够直观,咱们用日志把整条链路打出来。在ContentProvider的insert方法末尾加上notifyChange,然后在CursorAdapter的几个关键回调里打日志。

// ContentProvider 里 @Override public Uri insert(Uri uri, ContentValues values) { SQLiteDatabase db = mOpenHelper.getWritableDatabase(); long id = db.insert("note", null, values); if (id > 0) { Uri itemUri = ContentUris.withAppendedId(CONTENT_URI, id); getContext().getContentResolver().notifyChange(CONTENT_URI, null); Log.d("NoteProvider", "insert done, notifyChange: " + CONTENT_URI); return itemUri; } return null; }

然后在Activity里触发一次插入,观察 Logcat 输出顺序。正常应该看到类似这样的序列:

NoteProvider: insert done, notifyChange: content://com.example.app/note NoteCursorAdapter: notifyDataSetChanged triggered NoteCursorAdapter: bindView: 新插入的标题

如果只看到第一行,后面两行没出现,说明观察者链路断了。这时候按顺序排查:CursorAdapter构造时有没有传FLAG_REGISTER_CONTENT_OBSERVER;ContentProvider的query里有没有调setNotificationUri;notifyChange传的Uri和setNotificationUri注册的是不是同一个。

我试过在AbstractCursor.onChange里加日志(需要临时改源码或用反射),能看到dispatchChange确实被调用了,但ChangeObserver.onChange没触发,最后发现是deliverSelfNotifications()返回了false。SelfContentObserver的deliverSelfNotifications返回false,而ChangeObserver返回true,这个差异决定了dispatchChange里if (!selfChange || observer.deliverSelfNotifications())这个条件能不能过。

6. 本篇常见错排查

6.1 列表不刷新,日志里没有 notifyDataSetChanged

最常见的原因是构造CursorAdapter时用了super(context, c)或super(context, c, 0),没传FLAG_REGISTER_CONTENT_OBSERVER。这样mChangeObserver和mDataSetObserver都是null,压根没注册到Cursor上,notifyChange自然传不到Adapter。

改法就是构造时明确传FLAG_REGISTER_CONTENT_OBSERVER。如果你用的是CursorLoader,它内部会处理好这些,但手动管理Cursor时就得自己注意。

6.2 报 IllegalArgumentException: column '_id' does not exist

CursorAdapter的init()里会调c.getColumnIndexOrThrow("_id"),所以你的query投影里必须包含_id列。哪怕你不需要这个字段,也得在SQLite表里建_id主键,或者query时显式AS _id。

SELECT id AS _id, title, content FROM note

这个坑在自定义Cursor或联表查询时特别容易踩。

6.3 内存泄漏:Activity 销毁后仍收到 onChange

ContentObserver注册后不注销,ContentResolver会一直持有它,而它内部可能持有Activity或View的引用。解决办法是在onDestroy或Adapter的close里调unregisterContentObserver。如果你用的是CursorAdapter自带的观察者,swapCursor(null)或changeCursor(null)会帮你注销旧的,但自己额外注册的必须手动注销。

6.4 requery 在主线程卡顿

onContentChanged()里调的是mCursor.requery(),这是同步操作,数据量大时会在主线程卡一下。如果列表数据多,建议改用CursorLoader配合LoaderManager,它在后台线程做查询,查完再回调主线程更新。或者自己起线程重新query,再swapCursor。

6.5 notifyChange 传了 null 导致所有观察者都收到

notifyChange(uri, null)里的第二个参数是ContentObserver,传null表示通知所有注册在该Uri上的观察者。如果你只想通知特定的观察者,可以传具体的 observer 实例。大多数场景传null就行,但要知道它的含义,避免误以为只通知自己。

7. 把配置骨架沉淀下来

这套观察者链路拆完,其实可以沉淀成一个可复用的配置骨架:一个带FLAG_REGISTER_CONTENT_OBSERVER的CursorAdapter基类,一个负责注册/注销额外ContentObserver的工具方法,再加上ContentProvider里统一的notifyChange封装。下次写新模块直接继承基类,改改bindView就行。

如果你在写这些骨架时想让 AI 帮忙补全回调顺序或者检查注销逻辑,可以用 TaoToken 的模型对话快速问一下,或者用 Coding Plan 让它持续参与整个 Android 模块的编码。Key 和通道在控制台统一管好,接入文档里有各语言的具体配置示例。这样 AI 辅助和你的 Android 工程代码就分开了,不会把 Key 混进业务逻辑里。

最后留个实操建议:在CursorAdapter的notifyDataSetChanged里打一行日志,跑一次插入操作,看日志顺序对不对。对上了,说明整条观察者链路是通的;对不上,就按第 6 节的排查顺序逐个检查。这比盯着源码猜要快得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 4:28:21

cc-switch

文章目录设置-通用显示项目切换Skills 存储位置codex 应用增强非接管切换时保留官方登录统一 Codex 会话历史设置-路由全局出站代理codex 桌面端和cli添加新的供应商统一供应商claude 供应商请求地址上游格式认证字段模型映射默认兜底模型自定义user-agent本地代理请求覆盖编辑…

作者头像 李华
网站建设 2026/9/28 4:27:21

【AC63】在 VS Code 里用 TaoToken 配置 Makefile 编译工程:从报错到跑通

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 4:26:59

Claude Code 配置手册:settings.json 与 npm 环境接入 TaoToken 实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华