做 Android 应用开发的人,几乎都写过一行registerContentObserver,也几乎都在某个版本上被它坑过:代码照抄、编译通过、跑起来一点反应都没有。我自己第一次用它是在一个相册类项目里,需求是"用户在系统相册里删了图,我的列表要自动刷新"。当时我的做法是抄了三行代码注册一个 ContentObserver,然后等着回调解救我的下拉刷新逻辑。结果真机上一整天没触发过一次,后来才发现问题根本不在业务代码,而在于我对这个注册动作的理解只停留在方法签名上。
这篇就围绕 Android 系统的 registerContentObserver 讲透它:从注册这个动作在 ContentResolver 和 system_server 之间到底发生了什么,到 notifyForDescendants 这个参数为什么容易理解错,再到回调线程、内存泄漏、收不到通知这些实战坑怎么定位。内容会偏底层原理加实操,所以不管你是刚接触 ContentProvider 体系的新人,还是写过几年业务、准备梳理 Framework 知识的开发者,应该都能拿到点东西。
1. 一次"数据变了"的通知,为什么值得单独写一篇
1.1 从相册里那张删不掉的图片说起
先还原一个很典型的场景。你的 App 有一个图片列表,数据来源是 MediaStore,用户可能在系统相册、在文件管理器、甚至在另一个 App 里删掉了一张图。你不做任何处理的话,你的列表还停在旧的游标数据上,用户切回来看到的是一张已经不存在于磁盘上的缩略图,点进去直接空白。这时候有三种解决思路:轮询、广播、内容观察者。
轮询最简单也最蠢,定时 query 一遍 MediaStore,代价是耗电和卡顿,图库数据量大时 query 本身就是一次数据库扫描。广播的问题在于,系统并没有为"MediaStore 里的某一行被删了"发一个通用广播,历史上ACTION_MEDIA_SCANNER_FINISHED那套机制早就不能覆盖分区存储之后的场景了。剩下的就是内容观察者机制:数据的所有者(ContentProvider)在写完之后主动喊一声,感兴趣的观察者收到通知,自己去重新查询。
这里有个关键点很多人没意识到:ContentObserver 传递的不是数据,只是一个"某个 uri 范围下的数据可能变了"的信号。通知里最多带一个 uri 和一组 flags,告诉你哪块地方动了,至于动了什么、动了多少、是哪个 App 动的,一概不知。所以拿到回调之后你还是得老老实实重新 query 一次。理解了这一点,后面很多行为就顺了,比如为什么"同一秒内改十条数据可能只收到一次回调",为什么"通知里拿到的 uri 有时候是 null"。
1.2 ContentObserver 和广播、轮询、FileObserver 的分工
把这几个机制摆在一起对比会更清楚它们各自的定位。
| 机制 | 传递内容 | 跨进程 | 典型场景 | 主要代价 |
|---|---|---|---|---|
| ContentObserver | 变更信号(uri + flags) | 支持,走 Binder | MediaStore、联系人、日历、Settings | 需要 provider 主动 notify |
| 系统广播 | 明确的动作与 extras | 支持 | 开机、网络切换、电量 | 隐式广播限制越来越严 |
| 定时轮询 | 自己查到的数据 | 不涉及 | 无推送能力的第三方接口 | 耗电、延迟、扫库压力 |
| FileObserver | 文件系统事件(inotify) | 本进程可见 | 监听自己目录下的文件变动 | 递归目录支持差、耗 fd |
从表里能看出 ContentObserver 的独特之处:它是唯一一个由数据持有方主动推送变更、并且能跨进程送达的轻量机制。它之所以能跨进程,核心就在于它背后站着一个系统服务 ContentService,这是所有注册和通知的中转站。你在 App 进程里调一次注册,实际动作是把一个 Binder 对象交给 system_server 保管;provider 那边写数据后调一次 notifyChange,system_server 再挨个把信号发回来。
顺带说一句,系统自身大量使用这套东西,Settings 的变更、联系人的同步状态、MediaStore 的扫描结果,内部都是靠 ContentObserver 串起来的。所以这篇文章讲的不只是"怎么调一个 API",而是 Android 整个数据变更通知体系的一条主干。
2. 三个参数,决定了你这份"订阅"的全部行为
2.1 uri:不是地址,是前缀树上的坐标
registerContentObserver(Uri uri, boolean notifyForDescendants, ContentObserver observer)这个三参数版本的签名,第一次看会觉得很普通,实际上每个参数都有讲究,尤其是第一个。
很多人把 uri 理解成"我要监听的那条数据",所以注册content://media/external/images/media/123就以为只监听 id 为 123 的那张图。这个理解只对了一半。在 ContentService 内部,所有的注册项被组织成一棵前缀树,uri 是按 authority 加上 path 逐段拆开挂到树上的。你注册content://media/external/images/media/123,系统会依次创建(或复用)media→external→images→media→123这条链上的节点,然后把你这个 observer 挂在最后那个节点上。
这带来一个很实际的影响:注册粒度决定了 notification 的量级。你注册到content://media,整个 media provider 的任何一次 notifyChange 都可能命中你;你注册到某个具体 id,命中范围就窄得多。所以在选注册 uri 的时候,要先想清楚自己关心的是"整张表"还是"某一行",选错了要么被无关通知淹没,要么在数据变化时完全收不到。
另外还有两个容易忽略的细节。一是 uri 里带的 user id 会被系统按当前调用用户补齐,多用户场景下这条注册是绑定用户的,不是全局的。二是注册的 uri 必须是形如content://authority/path的格式,如果你拿一个file://或者http://的 uri 去注册,要么被忽略要么直接抛异常,这一点后面讲 FileProvider 的时候还会细说。
2.2 notifyForDescendants:注册端和通知端各有一份
notifyForDescendants这个名字直译过来是"是否通知后代",字面上不难,难的是它容易被误解成"我要不要监听子孙 uri"。它真正的含义是:当变更发生在你注册节点下面的更细粒度节点上时,你要不要收到通知。
举个例子。你注册content://myapp.notes/notes并且把 notifyForDescendants 设为 true。这时候如果 provider 写完之后 notify 的是content://myapp.notes/notes/42,这个 uri 是你的注册节点的子孙,你依然能收到回调。如果设为 false,这条通知就跟你没关系了,哪怕你在notes这层注册过。
关键在于,通知端也有一份类似的语义。ContentResolver 里有一个NOTIFY_SKIP_NOTIFY_FOR_DESCENDANTS这类内部用的标志,provider 在调用 notifyChange 的时候可以通过 flags 表达"这次变更不要向下扩散"。所以最终能否收到通知,是注册端的 notifyForDescendants 和通知端的 flags 两个条件共同作用的结果。这也解释了一个很常见的困惑:明明我注册时设了 true,为什么有些变更还是收不到?因为发通知的那一方主动把向下传播关掉了。
需要提醒的是,把 notifyForDescendants 一律设成 true 并不是"更保险"的做法。它意味着任何子孙节点的变更都会打到你的回调里,如果你的回调里有重新 query 这种重操作,通知一多就会变成性能问题。合理的做法是让注册粒度和你的数据依赖范围对齐,而不是无脑开最大。
2.3 observer:构造函数里的 Handler 才是决定回调线程的那个人
第三个参数是一个 ContentObserver 实例,看起来最不起眼,实际上它身上藏着本篇最容易踩的一个坑:回调到底在哪个线程执行。
ContentObserver 只有一个带参数的构造方法ContentObserver(Handler handler),没有无参版本。这个 Handler 不是可选的装饰,它直接决定了onChange的执行线程:
- 传入一个绑定了主线程 Looper 的 Handler,回调就在主线程执行,可以放心直接更新 UI;
- 传入一个绑定了子线程 Looper 的 Handler,回调就在那个子线程执行;
- 传入 null,回调既不切主线程也不切子线程,它直接在 Binder 线程里同步执行 onChange。
最后这条是最坑的。因为注册这个动作是从 system_server 的 Binder 线程池里回调回来的,所以传 null 的时候,你的onChange跑在一个既不是主线程、也不属于你应用的 Worker 线程上。在这个线程里碰任何 View 都会抛CalledFromWrongThreadException,做稍微重一点的操作还可能把 Binder 线程占住,影响系统其他跨进程调用。
我见过不少项目里的写法是new ContentObserver(null),理由是"我看官方示例里就是 null"。官方示例里能这么写,是因为那些示例的 onChange 只做打日志这种零开销的事情。真要在回调里刷 UI,就必须显式传一个主线程 Handler,或者干脆传一个 HandlerThread 的 Handler,把重活放到后台线程。这一条后面还会在踩坑章节里展开。
3. 注册动作进到 system_server 之后:ObserverNode 树与 ObserverEntry
3.1 ContentResolver 到 ContentService 的这次 Binder 调用
理解了三个参数,接着看注册这个动作真正落到了哪里。
你在应用进程里调用的ContentResolver.registerContentObserver,本质上是一次跨进程调用。它内部会通过ContentService.getContentService()拿到系统服务的 Binder 代理,然后把三样东西递给它:你注册的 uri、notifyForDescendants、以及从 ContentObserver 身上取出来的一个IContentObserver对象。
这里有个实现细节值得记住:ContentObserver 内部持有一个 Transport 对象,它是IContentObserver.Stub的实例,也就是一个 Binder 服务端对象,并且是懒加载、只创建一次的。这意味着同一个 ContentObserver 实例,无论你注册多少次、注册多少个不同的 uri,跨进程传过去的永远是同一个 Binder 引用。
为什么要强调这一点?因为它直接决定了取消注册的行为,下一小节就会看到后果。
进入 system_server 之后,注册请求最终落到 ContentService 的registerContentObserverLocked,方法名里带 Locked 说明它工作在同步块里,因为整棵树是被多线程并发访问的。系统服务收到请求后做的第一件事是校验 uri 的合法性,然后从根节点开始逐段建立路径。整个过程没有数据库、没有磁盘 IO,纯粹是内存里的树操作,所以你完全可以在 Activity 启动时放心注册,开销很小。
3.2 树是按 authority 打头的,addObserverLocked 怎么建节点
先说清楚这棵树的形状,因为它直接决定了通知匹配的规则。
树的第一层是 uri 的authority,不是 path。系统在计算 uri 段数的时候,会在 path 段数的基础上加一,这个多出来的一段就是 authority。所以content://media/external/images/media在树里的路径是media(external(images(media))),共四层。
每一层节点里维护两样东西:一个子节点列表,和一个观察者条目列表。条目里记录着观察者的 Binder 引用、它的 uid、pid,以及一个关键字段——这个观察者在注册时填的 notifyForDescendants 值。注意这一项是挂在"条目"上而不是"节点"上的,因为同一个节点上可能挂着好几个观察者,它们对子孙通知的诉求各不相同。
注册的递归过程大致是:从根节点开始,按当前 index 取出 uri 对应的段名,在子节点列表里找同名节点,找不到就新建一个挂上去,然后 index 加一继续往下,直到走到 uri 的最后一层,把观察者条目挂在这个叶节点上。
这里还有一个去重逻辑容易被人忽略:如果同一个观察者、同一个 uid、同一个 uri 被重复注册,系统不会新建条目,而是把已有条目的 notifyForDescendants 更新成这次传入的值。所以重复注册不会导致回调被调用两次,这个设计还是比较稳的。
3.3 unregister 只认 observer,不认 uri
现在回到前面埋的那个伏笔。
unregisterContentObserver(ContentObserver observer)这个方法的参数列表里,没有 uri。原因是系统在取消注册时只做一件事:拿着传进来的那个 IContentObserver Binder 引用,去整棵树里把所有持有这个引用的条目全部摘掉。
结合前面说的"同一个 ContentObserver 实例永远对应同一个 Transport",结论就很清晰了:
- 同一个 ContentObserver 实例注册了 5 个不同的 uri,调用一次 unregister 会把这 5 个注册全部清掉;
- 反过来,如果你每次都用
new ContentObserver(...)去注册,哪怕监听的 uri 只差一个字符,也会在系统里留下独立的条目,unregister 时必须持有每个实例的引用才能逐个清理。
这个机制解释了两个常见现象。第一个是"我只想取消其中一个监听,结果全没了"——因为 unregister 的粒度是观察者实例,不是 uri。第二个是"页面销毁后 observer 还活着"——往往是因为 new 了太多个实例,某一个忘了取消。
顺带提一句,从性能角度看,用一个 ContentObserver 实例监听多个相关 uri比用多个实例更划算:系统树里的条目更少,notifyChange 时的遍历和匹配开销也更小。这不是什么大优化,但在监听密集的页面里是有意义的。
4. 一次 notifyChange 走到你的 onChange,中间经过了几道筛选
4.1 collectObserversLocked 的两类命中:精确命中与前缀命中
provider 写完数据后调用ContentResolver.notifyChange(uri, observer, flags),信号进入 ContentService 之后,会走一个收集观察者的过程。理解这个过程,你就理解了自己为什么有时候收到、有时候收不到。
收集的逻辑是沿着树的路径往下走的,在每一层节点上会做两件事。第一件事是看当前这个节点上挂着的观察者条目里,有没有 notifyForDescendants 为 true 的,如果有,把它们收集进来——这一类就是前缀命中,也就是你注册的是父级 uri,但变更发生在子级 uri 上。第二件事是继续往下走一层,如果走到的这一层正好是通知 uri 的最后一层,那么把这一层节点上挂着的所有观察者都收集进来——这一类是精确命中。
这两类合在一起,就是最终会收到回调的完整集合。除此之外的观察者,一个都不会被通知到。
所以有两种典型的"收不到",可以对着上面的逻辑自查:
- 你注册的是
content://myapp.notes/notes/42,但 provider notify 的是content://myapp.notes/notes。变更在父级,你在子级,前缀匹配的方向是"从上往下"的,子级注册监听不到父级变更。这是百度搜索里经常被问到的一类问题,答案很简单:注册层级要比通知层级更靠上,或者和它对齐。 - 你注册的 uri 的 authority 和 provider 实际 notify 的 authority 不一致。authority 是树的第一层,对不上就没有任何交集,这也包括那种同一个 provider 注册了多个 authority 的情况。
4.2 selfChange 与 deliverSelfNotifications:为什么要放过"自己发的变更"
再看一个容易被忽略但很关键的概念:selfChange。
notifyChange有一个重载是带 observer 参数的:notifyChange(uri, observer)。这个 observer 表示"这次变更是我引起的"。ContentService 在收集观察者的时候会做一次比对,如果发现某个条目的观察者正好就是发起通知时传进来的那个对象,就会去问它一句:你要不要接收自己发起的变更?这个询问对应的方法就是deliverSelfNotifications(),它的默认返回值是false。
也就是说,默认情况下,你自己改的数据,自己不会收到通知。
为什么这么设计?因为否则会出现非常典型的死循环:A 收到变更通知,去更新数据,更新完调 notifyChange,又通知到自己,又更新,无限循环。系统把"自己发起的变更"单独标出来,就是为了给这条环路留一个刹车。如果你的回调逻辑确实需要感知自己写入的结果,可以重写deliverSelfNotifications()返回 true,但重写完一定要检查回调里有没有再次写入的操作,否则很容易自己把自己绕死。
这里还有个小细节:notifyChange不带 observer 参数的重载,等于告诉系统"这次变更谁都可能感兴趣,包括我自己",这种情况下你自己注册的观察者也会收到通知。所以有些同学会疑惑为什么有的地方改完数据收到了回调,有的地方没有,差别往往就在 notifyChange 传没传那个 observer。
4.3 flags 与 API 30 之后的分发入口变化
onChange 的方法签名有几个版本,这也是一处版本差异集中的地方。
早期的分发入口是onChange(boolean selfChange, Uri uri, int flags),三参数版本是 API 16 加的。到 API 30(Android 11),系统新增了接收 uri 集合的重载:onChange(boolean selfChange, Collection<Uri> uris, int flags),同时把三参数版本标记为废弃。这个变化背后的原因是批量语义——一次 notifyChange 可能携带多个 uri,用集合表达比一个个回调更合理,也更省跨进程次数。
对开发者的实际影响是:如果你只重写了两参数的onChange(boolean, Uri),在老版本上没问题,因为三参数版本的默认实现会把它转发到两参数版本;但在新版本上,如果你恰好重写了"接收集合"的那一版,就会出现不同 Android 版本走不同分支的情况。稳妥的做法是只重写一版,并且是对应你最低支持版本就已经存在的那一版,同时在方法里调用super之前先想清楚是否会产生重复回调。
还有个流传很广的说法需要澄清一下:只重写onChange(boolean selfChange)能不能收到回调?不建议这么写。分发链路最终落在带 uri 的那几个重载上,一参数版本在分发链路上并不保证被调用,不同版本表现也不一致。老老实实重写带 uri 的版本,别省这个参数。
5. 收不到、重复收、收完崩溃:几个真实场景的排查路径
5.1 FileProvider 那串 content:// 是监听不到的
网络上搜 ContentObserver,经常能搜到一堆content://xxx.fileprovider/external_path/...、content://xxx.fileprovider/external_files/...这类 uri。这里必须说清楚一件事:这类 uri 注册 ContentObserver 是没有意义的,因为 FileProvider 不会调用 notifyChange。
FileProvider 的定位是"按需生成一个临时的、带权限校验的 content uri 给别的 App 读取文件",它不是数据库,没有行、没有表,也不存在"数据变更"这个概念。你注册上去之后,注册动作本身在系统里是成功的,树上确实多了一个节点,但永远不会有人往这个节点发通知,所以就是静默无响应。
更麻烦的是,Android 在 uri 授权校验上有额外的检查。如果你去 query 或者 open 一个没有授权的 FileProvider uri,通常会直接抛SecurityException或者返回 null,报错信息里会提到Permission Denial和缺少grantUriPermission之类。所以看到这种 uri,正确反应是:如果目的是读文件,走 ContentResolver 的 openInputStream 并确保拿到授权;如果目的是监听变动,那得换成别的方案,比如监听自己进程目录用子线程轮询加文件时间戳比对。
那什么样的 uri 才值得监听?判断标准很简单:它背后必须有一个真实的 ContentProvider 实现,并且在数据写入后主动调用了 notifyChange。典型的比如content://media/...、content://contacts/...、content://settings/...,以及你自己写的 provider。第三方 App 的 provider 也可以监听,但前提是它没做调用方隔离,而且它的 authority 对你的 App 可见。
5.2 忘了 unregister 之后,泄漏的东西比你想的多
第二个高频问题是泄漏。
ContentObserver 忘记取消注册导致的泄漏,链条比普通的 Handler 泄漏更长、更隐蔽。具体来说是这样:你的 observer 实例被 ContentService 持有,而 ContentService 是 system_server 里的常驻服务,它的生命周期等于整个系统。所以只要不取消注册,你的 observer 对象就永远不会被回收;而 observer 通常是 Activity 或 Fragment 的内部类或者匿名类,它又反过来持有 Activity。一套下来,就是一个 Activity 被 system_server 长期引用。
这种泄漏在 LeakCanary 里未必能直接抓到,因为引用链穿过 Binder 和系统服务,工具不一定能完整展开。表现出来往往是"页面反复进出几次之后内存曲线缓慢上升",很难定位。
清理时机要选对。注册是跟着"数据依赖的生命周期"走的,所以:
- 如果观察的是当前页面要展示的数据,跟在 Activity 的 onDestroy 或者 Fragment 的 onDestroyView 里取消;
- 如果用 Lifecycle 或者 ViewModel,把注册放在 onCreate、取消放在 onCleared,这个粒度更准;
- 如果观察的是全应用级别的数据(比如登录状态),放在 Application 或者一个单例管理类里,不需要频繁注册取消,但要在应用退出路径上考虑清理。
另外一个容易被忽略的点:注册和取消必须成对。我见过一种写法是在 onResume 里注册,但取消放在了某个分支里,导致快速切换页面时出现"注册了两次、取消了一次"的状态。因为取消是按实例全清的,这种写法又不会立刻出错,最后就变成偶发的重复回调。安全的做法是注册前先取消一次,也就是幂等的unregister → register顺序。
5.3 回调落在 Binder 线程:一次闪屏和一次 ANR
再讲一个我印象最深的现场。某个页面用了 ContentObserver 监听 MediaStore,回调里直接调用了 RecyclerView 的 adapter 更新和 notifyDataSetChanged。测试机上偶尔闪一下,低端机上偶尔卡死几秒然后弹 ANR。
原因就在前面提过的 Handler 上。那个 observer 是用new ContentObserver(null)构造的,回调直接跑在 Binder 线程里。在 Binder 线程里刷 UI,一方面可能触发线程检查异常,另一方面如果系统正好在密集地发通知,你的回调一次接一次地跑在 Binder 线程池的线程上,把系统服务的回调路径也一起拖慢了。而 ANR 的触发点更隐蔽:如果你的回调里做了 query,query 又是同步阻塞的,Binder 线程被占住之后,同一时间其他 App 发过来的跨进程请求就会被排队,主线程里等待某个 Binder 返回的地方卡住,最终就表现为主线程 ANR。
修法很直接:构造时传一个绑定了主线程 Looper 的 Handler,回调里只做轻量状态更新;如果回调里必须做数据库查询或者大量计算,传一个 HandlerThread 的 Handler 把活挪到后台,算完了再用主线程 Handler 切回去。
这里还有一个细节值得注意:如果你传的是主线程 Handler,回调本身就在主线程,此时再去做耗时操作会直接卡 UI;如果传的是子线程 Handler,回调在子线程,此时碰 View 就是错的。所以"该传哪个 Handler"没有统一答案,取决于你回调里干什么,这一点一定要在写代码前想清楚,而不是抄一段就跑。
5.4 用 dumpsys content 看 Observer tree 来定责
收不到通知的时候,最有效的手段不是加日志,而是直接看系统里的注册状态。
ContentService 实现了 dump 接口,通过 adb 可以直接把整棵观察者树打印出来:
adb shell dumpsys content > content_dump.txt输出里会有一段观察者树的内容,按层级缩进展示每个节点上的注册项,通常能看到 uri、观察者所属的 uid 和 pid、以及该条目的 notifyForDescendants 之类字段。不同 Android 版本打印的字段名和排版会有些差异,但结构是稳定的:从 authority 开始往下逐段展开。
用它排查问题的思路是"三方对齐":
- 看我的注册在不在树里。如果树里找不到你的包名对应的 uid,说明注册这一步就没成功,问题出在调用时机或者异常被吞掉了。
- 看我注册的节点层级对不对。如果发现自己的观察者挂在很靠上的 authority 节点上,而实际数据变更是发生在很深的子路径上,那多半是被前缀匹配规则绕进去了,反过来也一样。
- 看 notifyForDescendants 的值是不是和预期一致。同一段代码在不同版本上表现不同的时候,往往能在这里看出差别。
配合日志一起用效果更好:在 provider 侧notifyChange调用前后打日志,同时在观察者侧onChange入口打日志,两边时间对不上,就能立刻判断是"没人通知"还是"通知了但没匹配上",避免在业务代码里瞎猜。
6. 把注册这件事封装起来:从工具类到 Flow
6.1 一个自带 HandlerThread 与生命周期管理的 Java 封装
既然注册和取消必须成对、Handler 必须选对、uri 又可能有好几个,那封装一次是值得的。下面这个工具类是 Java 版本,思路是"一个实例管一组 uri,内部自带后台线程,手动释放"。
public class ContentWatcher { private final ContentResolver resolver; private final Handler worker; private final Handler main; private final List<Uri> targets = new ArrayList<>(); private final Callback callback; private Observer observer; public interface Callback { void onChanged(Uri uri); } public ContentWatcher(Context context, String threadName, Callback callback) { this.resolver = context.getApplicationContext().getContentResolver(); this.callback = callback; this.main = new Handler(Looper.getMainLooper()); HandlerThread ht = new HandlerThread(threadName); ht.start(); this.worker = new Handler(ht.getLooper()); } public ContentWatcher watch(Uri uri, boolean descendants) { if (observer == null) { observer = new Observer(worker); } targets.add(uri); resolver.registerContentObserver(uri, descendants, observer); return this; } public void release() { if (observer != null) { try { resolver.unregisterContentObserver(observer); } catch (Exception ignored) { } observer = null; } targets.clear(); } private class Observer extends ContentObserver { Observer(Handler handler) { super(handler); } @Override public void onChange(boolean selfChange, Uri uri) { final Uri finalUri = uri != null ? uri : (targets.isEmpty() ? null : targets.get(0)); main.post(() -> callback.onChanged(finalUri)); } } }几个设计点解释一下。第一,所有 uri 共用同一个 Observer 实例,因为 unregister 是按实例全清的,共享实例能让 release 一次到位,同时减少系统树里的条目。第二,回调统一在 worker 线程接收、在 main 线程派发,把线程切换这件事收进封装里,业务方拿到回调时可以直接刷 UI,也可以根据自己的需要再切走。第三,uri 为 null 时的兜底:当通知方没有携带具体 uri 时,用注册列表里的第一个作为提示,而不是把 null 直接抛给业务方,省得业务方到处判空。
这套封装也有取舍。它把释放的时机交给了调用方,所以必须配合生命周期调用 release,否则一样会泄漏。如果不想手动管,就往下看 Kotlin 那一版。
6.2 Kotlin 下用 callbackFlow 把变更变成事件流
在 Kotlin 项目里,更自然的形态是把变更做成一个 Flow,让协程的取消机制帮你处理释放。
fun ContentResolver.observeChanges( uri: Uri, descendants: Boolean = true ): Flow<Uri?> = callbackFlow { val handler = Handler(Looper.getMainLooper()) val observer = object : ContentObserver(handler) { override fun deliverSelfNotifications(): Boolean = true override fun onChange(selfChange: Boolean, changed: Uri?) { trySend(changed) } } registerContentObserver(uri, descendants, observer) awaitClose { unregisterContentObserver(observer) } }.conflate()这里有两个点值得展开。awaitClose是这套写法的核心价值:收集方取消时自动取消注册,不需要调用方记得写释放代码,从根本上解决了"忘了 unregister"这一类问题。而.conflate()是为了应对高频变更——如果 provider 在一秒内连续改了十条数据,普通 Flow 会把这十次变更全部排队交给下游,而 conflate 只保留最新一次,下游的处理天然就变成了"合并到最新状态",这正好符合"拿到信号后重新查询"的业务模型。
如果你的处理逻辑允许延迟一点点,还可以配合debounce使用,把一秒内的连续变更合并成一次查询,这对 Settings 这类变更频繁但数据量小的场景特别划算。不过要小心:debounce 意味着一部分中间状态会被丢弃,如果你的业务需要精确感知每一次变更,就不能加。
还有一个隐藏的坑要提醒:callbackFlow的trySend在缓冲满的时候会失败。如果你注册的 uri 范围很大、变更又猛,会出现丢事件的情况。这种场景下要么加大 buffer,要么用buffer(Channel.CONFLATED)显式声明合并策略,别默认靠运气。
6.3 CursorLoader 的老思路:ForceLoadContentObserver 还能不能借鉴
最后说一个"官方老代码里最好的教材",因为很多人可能见过但没留意过。
Framework 里的 CursorLoader 就是这么干的:它内部有一个ForceLoadContentObserver,继承自 ContentObserver,构造时传了一个new Handler(),并且重写了deliverSelfNotifications()返回 true。它在后台加载完游标之后把观察者注册到游标上,当数据变化时触发重新加载,加载完成后再重新注册。
这个实现里有三个值得抄的点。一是它把观察者挂在了 Cursor 上,也就是注册的 uri 是查询结果自身对应的 uri,粒度和查询范围天然对齐,不会过宽也不会过窄。二是它重写了 deliverSelfNotifications,这是为了让列表在自己触发的更新后也能重新加载,避免出现"我改了数据但列表没刷新"的尴尬。三是它的 onChange 里做的事情极其轻量,只是设置一个标志并触发一次加载,真正的查询在后台线程完成。
这套思路放到现在依然成立,只是载体从 Loader 换成了 Flow。核心原则没变:注册粒度跟着查询范围走,回调里只做轻量动作,重活放到后台。
顺便说一句,这也是很多面试会追问的点:为什么 Loader 时代不需要手动写刷新逻辑,就是因为这套观察者机制在背后兜着。理解了它,也就理解了现在很多"数据自动刷新"类需求的做法只有一种骨架,具体用 Loader、LiveData 还是 Flow,只是载体不同。
我个人在实际操作中的体会是,ContentObserver 这套机制真正的难点从来不在 API 本身,三个参数的写法看两眼就会了,难的是注册的粒度、回调的线程、释放的时机这三件事同时想对。我现在的习惯是每写一个观察者,先在纸上回答三个问题:我关心的数据边界到底在哪一层 uri,回调里最重的操作是什么,它在哪个生命周期结束时必须消失。这三个问题想清楚了,代码也就是十来行的事;想不清楚,后面就是无穷无尽的偶发 bug。另外建议在开发阶段就把adb shell dumpsys content这条命令放进常用工具清单,能省掉大量"改代码加日志再编译"的来回。