ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android registerContentObserver 原理与实战避坑

Android registerContentObserver 原理与实战避坑 做 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支持走 BinderMediaStore、联系人、日历、Settings需要 provider 主动 notify系统广播明确的动作与 extras支持开机、网络切换、电量隐式广播限制越来越严定时轮询自己查到的数据不涉及无推送能力的第三方接口耗电、延迟、扫库压力FileObserver文件系统事件inotify本进程可见监听自己目录下的文件变动递归目录支持差、耗 fd从表里能看出 ContentObserver 的独特之处它是唯一一个由数据持有方主动推送变更、并且能跨进程送达的轻量机制。它之所以能跨进程核心就在于它背后站着一个系统服务 ContentService这是所有注册和通知的中转站。你在 App 进程里调一次注册实际动作是把一个 Binder 对象交给 system_server 保管provider 那边写数据后调一次 notifyChangesystem_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 树与 ObserverEntry3.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 30Android 11系统新增了接收 uri 集合的重载onChange(boolean selfChange, CollectionUri 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 的触发点更隐蔽如果你的回调里做了 queryquery 又是同步阻塞的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. 把注册这件事封装起来从工具类到 Flow6.1 一个自带 HandlerThread 与生命周期管理的 Java 封装既然注册和取消必须成对、Handler 必须选对、uri 又可能有好几个那封装一次是值得的。下面这个工具类是 Java 版本思路是一个实例管一组 uri内部自带后台线程手动释放。public class ContentWatcher { private final ContentResolver resolver; private final Handler worker; private final Handler main; private final ListUri 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 ): FlowUri? 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这条命令放进常用工具清单能省掉大量改代码加日志再编译的来回。
返回列表