拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

ContentObserver从原理到实战:Android数据变更监听的优雅方案

ContentObserver从原理到实战:Android数据变更监听的优雅方案

在 Android 开发里,应用之间互相“传话”是个绕不开的话题。A 应用修改了数据,B 应用怎么知道?总不能每隔几秒就去数据库扫一遍,那性能和电量都遭不住。系统给的方案是 ContentProvider 加 ContentObserver,前者管数据存取,后者管变更通知。我当年第一次接触 ContentObserver 是做一个短信转发工具,需求是收到新短信后立刻同步到云端,当时第一反应是去轮询数据库,结果被老同事拦住了,他丢过来一句话:“用 ContentObserver 监听,短信一来立刻回调,比轮询优雅一百倍。”后来我把它用在联系人同步、相册监控、外部存储变化监听等场景,越用越觉得这是个被低估的组件。这篇就把 ContentObserver 从原理到实战拆开讲清楚,顺便把我在坑里摔出来的经验一并整理出来。

1. ContentObserver 到底解决了什么问题

1.1 从观察者模式说起

ContentObserver 的本质是观察者模式在 Android 系统数据层的具体实现。如果你写过 GUI 程序,对“事件监听”应该不陌生——按钮被点击了,OnClickListener 被触发;列表被滑动了,OnScrollListener 被回调。ContentObserver 做的事情完全一样,只不过它观察的不是 UI 控件,而是 ContentProvider 暴露的数据。

举个例子:系统的短信数据库由 Telephony 这个 ContentProvider 管理,联系人由 ContactsContract 管理,媒体库由 MediaStore 管理。你手机上几十个应用的数据,背后都是一个个 ContentProvider 在撑着。当你的应用或者其他应用往这些 Provider 里插入、修改、删除数据时,Provider 会发出变更信号,而所有注册了相应 URI 的 ContentObserver 都会收到通知。

这个机制的价值在于:你不需要主动去查数据有没有变,系统会在数据变化时主动告诉你。拿短信场景来说,你只需要注册一个观察者去盯短信数据库,新短信插进来的瞬间,onChange 方法就会被调用,你在这个回调里再去查最新的未读短信就行。整个过程零轮询、零重复查询、零无效 IO。

1.2 没有它之前大家是怎么干的

在 ContentObserver 出现之前的“远古时代”(其实也就是 Android 早期),开发者想实现数据监听,通常只有两条路:

一是定时轮询。开一个 Handler 线程,每 5 秒查一次数据库,把上一次的结果和这次比对,发现有差异就认为是数据变化了。这种方式逻辑简单,但缺陷是致命的:如果你把轮询间隔调短(比如 1 秒),性能和电量会肉眼可见地崩坏;如果你调长(比如 60 秒),又失去了实时性。而且轮询无法区分“数据没变”和“数据变了但内容一样”这两种情况,经常做无用功。

二是重写 ContentProvider。如果数据源是你自己写的 Provider,可以在 insert、update、delete 方法里手动发广播通知外部。这确实可行,但如果监听的是系统 Provider(比如短信、联系人),你不可能去改系统的源码,这条路直接堵死。

ContentObserver 的好处在于它是系统级的通知机制,注册和取消非常轻量,不需要额外维护连接,而且可以和任意 ContentProvider 配合使用。只要你能拿到一个 ContentResolver 实例和一个合法的 URI,就能监听对应的数据源变化。

1.3 应用场景大盘点

结合我自己的项目经验,ContentObserver 常见的使用场景有这些:

  • 短信验证码自动填充:注册观察者监听 Telephony.Sms.Inbox 的 URI,新短信到达后自动解析验证码,填入输入框。这是 ContentObserver 最经典的应用之一。
  • 联系人变化同步:企业通讯录应用里,监听 ContactsContract.Contacts.CONTENT_URI,本地联系人一变,立刻触发云端同步。
  • 相册/媒体库监控:类似“最近照片”的功能,监听 MediaStore.Images.Media.EXTERNAL_CONTENT_URI,新截图、新图片出现时第一时间刷新列表。
  • 外部存储挂载状态监听:手机插上 USB 连接电脑、挂载 SD 卡、弹出 U 盘时,存储路径会发生变化,通过监听 StorageManager 相关的 URI(或者 Environment 广播)来做相应处理。
  • 系统设置项监听:监听 Settings.System、Settings.Global 下的 URI,比如飞行模式切换、屏幕亮度变化、字体大小调整等,应用可以根据设置变化动态调整 UI。
  • 应用自身数据库监听:如果应用内部用 ContentProvider 封装了数据层,其他模块可以通过 ContentObserver 监听变化,实现数据驱动的 UI 刷新。

我做过一个车载项目,要求实时监听 U 盘和 TF 卡的插拔,同时监听外部存储目录里某个配置文件的变更。当时就是用 ContentObserver 监听外部存储的 URI,配合 FileObserver 做文件级监听,效果非常理想。后面会具体说这个案例。

2. 核心 API 与基础用法拆解

2.1 ContentObserver 的构造与生命周期

ContentObserver 是一个抽象类,注意,它没有抽象方法,但你一般会继承它并重写 onChange 方法。它的构造函数接收一个 Handler 参数,这个 Handler 决定 onChange 回调运行在哪个线程。

public class MyObserver extends ContentObserver { public MyObserver(Handler handler) { super(handler); } @Override public void onChange(boolean selfChange) { super.onChange(selfChange); // 数据变化了,在这里处理业务逻辑 } }

如果构造时传入 null,onChange 会回调在 Binder 线程池中的某个线程上——这一点非常关键,后面讲坑的时候会重点展开。如果传入一个绑定在主线程 Looper 的 Handler,回调就跑在主线程。

除了单参数版本,系统还提供了一个带三个参数的重载方法onChange(boolean selfChange, Uri uri),从 API 16 开始支持。这个方法可以拿到具体变化的 Uri,在多 URI 注册的时候非常有用,可以在回调里判断到底是哪条数据变了,避免每次都要全量查询。

2.2 注册与注销的正确姿势

注册观察者通过 ContentResolver 完成,核心方法是registerContentObserver(Uri uri, boolean notifyForDescendants, ContentObserver observer)。

ContentResolver resolver = getContentResolver(); Uri uri = Telephony.Sms.Inbox.CONTENT_URI; boolean notifyForDescendants = true; MyObserver observer = new MyObserver(new Handler(Looper.getMainLooper())); resolver.registerContentObserver(uri, notifyForDescendants, observer);

第二个参数notifyForDescendants值得单独解释一下。ContentProvider 的 URI 是有层级关系的,比如content://media/external/images/media和content://media/external/images/media/123,后者是前者的 descendant(后代)。如果设为 true,那么当你监听前一个 URI 时,对后代 URI 的数据变化也能收到通知;设为 false,则只有精确匹配这个 URI 的变化才会触发回调。

实际开发中,我一般设为 true。因为系统 Provider 的 URI 里经常带 id,你很难预料每次变更具体落在哪个 id 上,设为 true 可以避免漏掉变更。代价是可能会收到一些“不在你预期范围内”的回调,但通常影响不大,在回调里用传入的 Uri 参数做一次过滤即可。

注册之后务必要在合适的时机注销,方法是对应的unregisterContentObserver(observer)。最常见的做法是在 Activity 的 onDestroy 或 Fragment 的 onDestroyView 里注销。如果你注册了但忘记注销,轻则内存泄漏,重则导致回调在组件销毁后仍然触发,引发 NullPointerException 或状态错乱。

注意:ContentObserver 注册之后必须成对注销,这一点和 BroadcastReceiver 非常像。千万别偷懒在 onStart 注册、onStop 忘掉注销,踩过一次坑你就明白什么叫“欲哭无泪”。

2.3 用 Uri 精准定位监听目标

Uri 是 ContentObserver 的“眼睛”,它决定了你关注哪一块数据。用得最多的是系统预置的 URI 常量,比如:

数据源URI 常量说明
短信数据库Telephony.Sms.CONTENT_URI所有短信,含收件箱、发件箱、草稿箱
收件箱Telephony.Sms.Inbox.CONTENT_URI仅收件箱
联系人ContactsContract.Contacts.CONTENT_URI联系人主表
媒体库图片MediaStore.Images.Media.EXTERNAL_CONTENT_URI外部存储图片
媒体库视频MediaStore.Video.Media.EXTERNAL_CONTENT_URI外部存储视频
外部存储的根目录MediaStore.Files.getContentUri("external")外部存储所有文件
系统设置Settings.System.CONTENT_URI系统设置项
全局设置Settings.Global.CONTENT_URI全局设置项(需要特定权限)
通话记录CallLog.Calls.CONTENT_URI通话记录

如果是监听自定义 Provider,直接拼接自己的 URI 就行,比如Uri.parse("content://com.example.myapp.provider/table_name")。建议把 URI 定义为常量,放在 Provider 的契约类里统一管理,既方便复用,也避免魔法字符串散落各处。

3. 上手实操:两个真实案例带你走一遍完整流程

3.1 案例一:监听新短信,自动填充验证码

这个案例是 ContentObserver 最典型的应用。需求描述:用户收到短信验证码后,应用自动读取验证码并填入输入框。整个流程分为三步:定位短信 URI、注册观察者、回调查询验证码。

先定义一个 Observer。注意我这时候会传入主线程 Handler,因为要在回调里更新 UI。

public class SmsObserver extends ContentObserver { private static final Uri SMS_URI = Telephony.Sms.Inbox.CONTENT_URI; private final Context context; private final OnSmsReceivedListener listener; public interface OnSmsReceivedListener { void onSmsReceived(String sender, String body); } public SmsObserver(Context context, OnSmsReceivedListener listener) { super(new Handler(Looper.getMainLooper())); this.context = context.getApplicationContext(); this.listener = listener; } @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); String code = readLatestSmsCode(); if (code != null && listener != null) { listener.onSmsReceived(null, code); } } private String readLatestSmsCode() { String code = null; Cursor cursor = null; try { ContentResolver resolver = context.getContentResolver(); // 按时间倒序,只取最新一条 cursor = resolver.query( SMS_URI, new String[]{"body", "address"}, null, null, "date DESC LIMIT 1" ); if (cursor != null && cursor.moveToFirst()) { String body = cursor.getString(0); code = parseVerificationCode(body); } } catch (Exception e) { Log.e("SmsObserver", "query sms failed", e); } finally { if (cursor != null) { cursor.close(); } } return code; } private String parseVerificationCode(String body) { // 用正则匹配 4-8 位数字,注意排除手机号等干扰项 Pattern pattern = Pattern.compile("(\\d{4,8})"); Matcher matcher = pattern.matcher(body); return matcher.find() ? matcher.group(1) : null; } }

然后是注册和注销。我习惯在 Activity 的 onResume 里注册、onPause 里注销,这样既保证在可见期间能收到通知,又能在退到后台时避免不必要的回调。如果你要保证应用在后台也能收到短信,那就得配合 Service 或者应用常驻进程来做了,单纯靠 Activity 生命周期是撑不住的。

private SmsObserver smsObserver; @Override protected void onResume() { super.onResume(); if (smsObserver == null) { smsObserver = new SmsObserver(this, code -> { etVerifyCode.setText(code); }); } getContentResolver().registerContentObserver( Telephony.Sms.Inbox.CONTENT_URI, true, smsObserver ); } @Override protected void onPause() { super.onPause(); if (smsObserver != null) { getContentResolver().unregisterContentObserver(smsObserver); } }

这里有三个细节值得注意:

一是查询短信需要 READ_SMS 权限。Android 6.0 以上需要在运行时申请权限,6.0 之前在安装时授予。这个权限属于敏感权限,申请时最好向用户解释清楚用途。

二是查询语句里的date DESC LIMIT 1。LIMIT在 Cursor 查询里是支持的,但有些 ROM 对 SQLite 的兼容性有坑,如果你担心LIMIT不生效,可以查全部结果然后moveToFirst(),性能差距可以忽略,因为短信库一般不会太大(除了那种几万条短信的极端情况)。

三是验证码匹配的正则。如果短信内容是“您的验证码是123456,5分钟内有效”,\\d{4,8}会匹配到 123456。但如果短信里有多个数字(比如手机号 13800138000),你这个正则就会匹配到手机号。更好的做法是匹配“验证码”这三个字后面的数字:(?:验证码|校验码|动态码)[^\d]{0,4}(\\d{4,8}),或者直接找 4-6 位数字并且前后没有其他数字包裹。这个细节我在实际项目里被坑过,特此说明。

3.2 案例二:车载场景监听外部存储空间变化

这个案例源自我的一个车载项目,需求是:U 盘插入后,应用自动扫描 U 盘根目录下的配置文件,并加载到界面;U 盘拔出后,界面自动清空并提示。同时外部存储空间变化(比如内存卡扩容、文件系统挂载状态变化)也要能监听到。

车载场景和手机场景最大的不同在于:手机的外部存储通常是固定的内部存储和一张 SD 卡,而车机可能同时挂载多个外部设备(U 盘、TF 卡、移动硬盘),并且这些设备的挂载路径是不固定的。我们需要监听的是外接存储设备的“插拔”事件。

虽然传统方案是通过注册 BroadcastReceiver 监听ACTION_MEDIA_MOUNTED和ACTION_MEDIA_UNMOUNTED,但有些车机 ROM 对广播做了裁剪,不如 ContentObserver 稳定。另一个思路是监听 MediaStore 的 URI,因为每次存储设备挂载状态变化,MediaStore 的 external volume 都会同步更新。

我当时的选择是双管齐下:监听MediaStore.Files.getContentUri("external")的 URI 来感知文件系统变化,再配合查询 StorageManager 的卷信息来判断具体是哪个存储设备变了。部分 ROM 上 MediaStore 的 URI 是content://media/external/file,只需要把它当作一个“信号源”,收到变更后主动去查询 StorageManager 的状态即可。

public class StorageObserver extends ContentObserver { private static final Uri FILES_URI = MediaStore.Files.getContentUri("external"); private final Context context; private final OnStorageChangedListener listener; public StorageObserver(Context context, OnStorageChangedListener listener) { super(null); // 传 null,让回调跑在 Binder 线程,避免阻塞主线程 this.context = context.getApplicationContext(); this.listener = listener; } @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); // 这里在 Binder 线程,不要直接操作 UI // 通过 Handler 切到主线程再执行 new Handler(Looper.getMainLooper()).post(() -> { if (listener != null) { listener.onStorageChanged(getMountedVolumes()); } }); } private List<String> getMountedVolumes() { List<String> volumePaths = new ArrayList<>(); StorageManager storageManager = (StorageManager) context.getSystemService(Context.STORAGE_SERVICE); try { Method getVolumes = StorageManager.class.getMethod("getVolumes"); List<?> volumes = (List<?>) getVolumes.invoke(storageManager); for (Object volume : volumes) { Method isMounted = volume.getClass().getMethod("isMounted"); boolean mounted = (boolean) isMounted.invoke(volume); if (mounted) { Method getPath = volume.getClass().getMethod("getPath"); String path = (String) getPath.invoke(volume); volumePaths.add(path); } } } catch (ReflectiveOperationException e) { Log.e("StorageObserver", "reflect StorageManager failed", e); } return volumePaths; } public interface OnStorageChangedListener { void onStorageChanged(List<String> mountedVolumes); } }

注意一个细节:这里把 Handler 传了 null,这意味着 onChange 回调不在主线程,而是在 Binder 线程池的某个线程上。为什么故意这么写?因为存储设备插拔时,MediaStore 可能正在做大量的文件扫描,你如果在主线程里执行查询,大概率会卡 UI。让 onChange 在工作线程里快速把状态拉出来,再 post 到主线程更新 UI,是一个更稳的方案。

在使用 StorageManager 的 getVolumes 时,由于这个 API 在不同 Android 版本上变动较大,我用了反射来兼容。在 Android 10 及以上,官方推荐用StorageManager.getStorageVolumes(),这不再需要反射,可以直接调用。后面在“版本适配”部分我会补充这几个版本差异。

3.3 案例三:监听系统设置变化(亮度和音量监听)

除了内容类数据,ContentObserver 还经常用来监听系统设置。例如:用户在设置里把屏幕亮度调到最低,你的应用如果是个阅读器,可能需要跟着调整文字对比度;用户改了音量,音乐类的应用可能需要刷新音量条。

监听亮度变化的代码如下:

Uri brightnessUri = Settings.System.getUriFor(Settings.System.SCREEN_BRIGHTNESS); getContentResolver().registerContentObserver(brightnessUri, false, brightnessObserver); private ContentObserver brightnessObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); try { int brightness = Settings.System.getInt(getContentResolver(), Settings.System.SCREEN_BRIGHTNESS); // 更新 UI } catch (Settings.SettingNotFoundException e) { e.printStackTrace(); } } };

监听音量的方式也类似,用AudioManager.getStreamVolume(AudioManager.STREAM_MUSIC)配合Settings.System.getUriFor(Settings.System.VOLUME_MUSIC)就能做到。当然,音量变化也可以用 AudioManager 的AudioPlaybackCallback来监听,但 ContentObserver 的好处是它不仅能感知音量变化,还能拿到“哪一个设置项变了”,对代码的组织更友好。

3.4 如何选择监听时机:以查询联系人变化为例

联系人监听是另一个高频场景。很多企业应用需要把系统联系人同步到自己的服务器,同步时机通常是在联系人发生变化后。你可以注册观察者监听ContactsContract.Contacts.CONTENT_URI,然后在回调里查询所有联系人并上传。

但一个容易忽略的问题:联系人库是数据库,数据库变更可能频繁发生(比如批量导入),你不能每次收到底层变化就立刻做一次全量上传。这时候可以用“防抖”策略:

private Handler syncHandler = new Handler(Looper.getMainLooper()); private Runnable syncRunnable = () -> { uploadAllContacts(); // 真正的同步逻辑 }; private ContentObserver contactsObserver = new ContentObserver(syncHandler) { @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); // 防抖:500 毫秒内多次变更只触发一次同步 syncHandler.removeCallbacks(syncRunnable); syncHandler.postDelayed(syncRunnable, 500); } };

这个模式和 SearchView 的setOnQueryTextListener防抖是一个套路。数据库可能在极短的时间内连续收到多笔写入(比如系统联系人同步服务一次插入 100 条),如果你每笔都触发上传,不仅浪费流量,还可能因为数据库还在写入中而读到不完整的数据。等 500 毫秒,写入事务基本提交完毕,再拉全量,稳妥得多。

4. 容易踩的坑与问题排查实录

4.1 回调线程的“幽灵线程”

ContentObserver 构造函数里的 Handler 参数决定回调线程。如果你传 null,onChange 就会跑在系统 Binder 线程池中某个线程上。这在低配机或高并发场景下会引发诡异问题:你在回调里假设它跑在主线程,然后在里面更新 UI,结果时不时闪退;或者你在回调里访问了某个非线程安全的单例,导致数据错乱。

我自己就遇到过一次:在onChange里直接调了一个单例的缓存更新方法,那个单例内部用 HashMap 做缓存,本来在主线程跑得好好的,换到 Binder 线程后,高并发下 HashMap 的 put 和 get 冲突,直接导致内部数据残留脏数据。排查了大半天才发现是线程切换导致的。

建议:除非你有 100% 的把握回调里的逻辑是线程安全的,否则统一给 Observer 传入主线程 Handler,或者手动 post 到主线程。宁可多几次线程切换,也别踩线程安全的地雷。

4.2 忘记注销导致的内存泄漏

ContentObserver 被注册到 ContentResolver 后,是有跨进程引用的。如果你在 Activity 里注册了 Observer,Activity 销毁后却没有注销,这个 Observer 会一直被系统持有。而你的 Observer 如果是匿名内部类,它会隐式持有外部 Activity 的引用,整个 Activity 就泄漏了。

解决方式很简单:注册和注销必须成对出现,且遵循生命周期。表格里列一下我常用的注册时机:

注册场景注册位置注销位置
Activity 可见期间监听onStart / onResumeonStop / onPause
Fragment 可见期间监听onStart / onResumeonStop / onPause
Service 全生命周期监听onCreateonDestroy
应用全局监听ContentProvider.onCreate 或 Application.onCreate不需要主动注销(进程结束自然销毁)

还有个小技巧:如果你确实要在 Application 级别注册一个全局 Observer,可以将它放在ContentProvider.onCreate()里初始化。这样既能确保在应用进程启动早期就注册好,又不需要煞费苦心找一个“全局生命周期节点”。Android Startup 库的核心思想就是利用了这个机制。

4.3 高版本权限收紧与 FileProvider 的干扰

Android 7.0 之后,应用不能直接通过file://URI 去访问其他应用的文件,必须用 FileProvider。这本身和 ContentObserver 没有直接关系,但我在外部存储监听的实战中踩过一个大坑:

在 Android 10 及以上的分区存储(Scoped Storage)机制下,应用不能随意读取外部存储的全部内容。当你监听MediaStore.Files.getContentUri("external"),系统确实会回调 onChange,但你的应用不一定有权限去 query 那个 URI 下的全部文件。这时候如果你强行查询,要么返回空集合,要么抛 SecurityException。

处理方式是:先检查当前设备的 SDK 版本和权限情况。对 Android 10 以下,声明READ_EXTERNAL_STORAGE;对 Android 10 以上,使用READ_EXTERNAL_STORAGE还是READ_MEDIA_IMAGES要看目标 SDK。如果实在拿不到内容,至少能用 onChange 作为“信号”,提示外部存储有新文件写入,然后引导用户通过系统文件选择器(ACTION_OPEN_DOCUMENT)授权访问具体文件。

4.4 为什么 onReceive 没触发?谈谈 URI 匹配规则

有些朋友反馈说:“我明明注册了 ContentObserver,为什么数据变了却没回调?”

最常见的原因是:你监听的 URI 和实际变更的 URI 不匹配。前面提到过notifyForDescendants参数,如果你设置为 false,那么content://xxx/a/123的变更不会通知content://xxx/a的观察者。很多系统 Provider 在 notifyChange 时会用完整的、带 id 的 URI,这就导致你监听的父 URI 收不到通知。

解决方式是:注册父 URI 时把notifyForDescendants设为 true,让所有后代 URI 的变更都能通知上来。个别 Provider 使用ContentResolver.notifyChange(uri, observer)来通知,不会经过registerContentObserver的 URI 匹配,这种老旧的调用方式在新代码里已经很少见,但如果你遇到“注册了却怎么都不回调”的诡异问题,可以往这个方向排查。

还有一个容易忽略的点:ContentProvider 自己可以在 notifyChange 时指定 observer 参数。如果 Provider 的 notifyChange 传入了一个 observer(而不是 null),那么只有传入的那个 observer 能收到通知,其他注册的 observer 会被过滤掉。这种设计通常见于系统内部,第三方应用很少这么干,但如果在某些 ROM 上遇到异常,可以考虑是不是 ROM 修改了 Provider 的通知逻辑。

4.5 系统重启后监听失效的问题

如果你的应用被杀掉,或者手机重启,注册的 ContentObserver 就没了。这是 Binder 机制的必然结果——进程都没了,哪来的回调?所以如果你的业务需要“永久监听”,必须搭配开机广播(BOOT_COMPLETED)或前台服务来重新注册。

不过要注意:Android 8.0 之后,在后台启动 Service 有限制,开机自启也需要权限。更稳妥的方案是把“重新注册”的逻辑放在一个有前台服务的常驻进程里,或者用 WorkManager 定期检查注册状态。说到底,ContentObserver 只解决“数据变了怎么通知”的问题,不解决“进程死了怎么复活”的问题,后者需要配合其他系统机制。

4.6 常见问题速查表

问题现象可能原因解决方法
注册后不回调URI 不匹配、notifyForDescendants=false注册父 URI 时改为 true
回调不实时防抖机制、线程阻塞检查 Handler 和 Runnable 逻辑
回调多次触发单个 Provider 多次 notifyChange在回调里做去重/状态比对
内存泄漏未注销 Observer严格配对注册/注销
报 SecurityException缺少读取权限或分区存储限制弹窗申请权限、适配 Scoped Storage
查询结果为空URI 被权限遮蔽检查 targetSdk 和权限申请状态
系统重启后失效未重新注册配合开机广播/前台服务重新注册

我把这套从实践中积累的排查表放在这里,遇到问题先对照一遍,大多数坑都能对号入座。

5. 版本适配与性能优化

5.1 Android 版本差异要注意什么

ContentObserver 本身从 API 1 就有了,非常稳定,但它所“观察”的数据源和 Provider 在不同版本上变化很大:

  • Android 7.0(API 24):FileProvider 引入,file://URI 受限,如果你通过 ContentObserver 拿到变更 URI 后尝试直接访问文件路径,会 Crash。
  • Android 8.0(API 26):通知渠道、后台执行限制,如果你在后台 Service 里注册 ContentObserver,要注意服务保活的问题。
  • Android 10(API 29):分区存储强制开启,外部存储的 URI 不能直接通过路径访问,必须用 MediaStore API 或 SAF。
  • Android 11(API 30):软件包可见性变化,查询其他应用信息需要<queries>声明。
  • Android 13(API 33):READ_EXTERNAL_STORAGE被拆分,读图片、视频、音频分别对应READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO。

对应到 ContentObserver 的实际影响就是:你能查询到什么数据,取决于你的权限和 targetSdk 版本。如果你的 targetSdk 是 30,在 Android 11 手机上就不能像以前那样直接读别的应用的私有目录了,/storage/emulated/0/Android/data/包名/下的大部分内容是访问受限的。

我看到热词里有很多人搜索content://com.tencent.wework.fileprovider/external_path/android/data/com.xxx这样的 URI,这其实是企业微信、百度贴吧等应用用 FileProvider 暴露出来的共享文件路径。大家可能是在做跨应用文件读取,想用 ContentObserver 监听这些 URI 的变化。但这种 URI 是由目标应用自己控制的,目标应用不主动 notifyChange,ContentObserver 是不会收到任何通知的。换句话说,这些 URI 转化成 ContentObserver 能监听的对象,前提是目标应用调用了ContentResolver.notifyChange并且你注册了对应的 URI。FileProvider 的默认实现不会做这个通知,所以别指望靠它来监听其他应用的文件目录。

5.2 用 NotifyChange + 自定义 Provider 实现自己的数据总线

ContentObserver 不止能监听系统数据,也能监听你自定义的 ContentProvider。如果你的应用里有几个模块需要共享数据(比如登录状态、用户配置、会话信息),就可以把它们封装成一个自定义 Provider,然后用 ContentObserver 做模块间通信。

这样做的好处是:模块间的通信不依赖静态变量,不依赖 EventBus,完全通过 Android 系统标准机制完成,生命周期可控、可测试。而且你可以在 Provider 的 insert、update、delete 方法里手动调用getContext().getContentResolver().notifyChange(uri, null),然后其他模块收到回调。

自定义 Provider 的核心代码大致如下:

public class AppDataProvider extends ContentProvider { private SQLiteDatabase db; @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { int rows = db.update(getTableName(uri), values, selection, selectionArgs); if (rows > 0) { getContext().getContentResolver().notifyChange(uri, null); } return rows; } // delete、insert 方法类似,都别忘了 notifyChange }

这种“微型数据总线”我在几个中大型项目里用过。它比 EventBus 强的地方在于:自带权威数据源,天然支持跨进程(多个进程可以同时访问同一个 Provider),并且不需要引入第三方依赖。

但你也要清楚它的代价:ContentProvider 的 query/insert 是 Binder 跨进程调用,有性能开销;如果只是简单的事件通知(比如“按钮被点击了”),用 ContentProvider 属于杀鸡用牛刀,直接用 LiveData 或 BroadcastReceiver 更合适。

5.3 性能优化:减少无效回调与查询

ContentObserver 本身非常轻量,真正消耗性能的是“回调后的查询逻辑”。很多人写 onChange,一上来就 query 全表,又是不分青红皂白的全量读取,这在数据量大的表(比如整个短信库或者媒体库)上非常吃性能。

优化可以从三个方面入手:

  • 精准 URI:注册的时候尽量精确到子表。比如你只关心收件箱,就注册Telephony.Sms.Inbox.CONTENT_URI,而不是Telephony.Sms.CONTENT_URI。只监听你关心的数据范围,减少无谓的回调。
  • 防抖:多笔写入时延迟响应,等写入完成后一次性处理。这在 4.4 里已经讲过,不重复。
  • 增量查询:在回调里维护一个“上次查询到的最大 ID”或“最近一次变更时间戳”,查询时用WHERE _id > lastId或WHERE date > lastTimestamp,只拉增量数据。
private long lastMaxId = 0L; @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); Cursor cursor = getContentResolver().query( SMS_URI, new String[]{"_id", "body", "address"}, "_id > ?", // 只看增量 new String[]{String.valueOf(lastMaxId)}, "_id ASC" ); if (cursor != null) { while (cursor.moveToNext()) { long id = cursor.getLong(0); if (id > lastMaxId) { lastMaxId = id; } // 处理这条新短信 } cursor.close(); } }

这样处理后,查询的数据量被限制在“真正新增的那些行”,长期运行也不会积累性能负担。我在一个监听媒体库变化的项目中用了这个增量方案,应用连续运行一周,内存占用和查询耗时都保持在稳定水平。

6. 几个容易忽略的边角细节

6.1 ContentObserver 能不能跨进程监听?

能,而且这正是 ContentProvider 的强项。ContentResolver 是系统级的组件,注册的 ContentObserver 会随着 Binder 连接传递给 Provider 所在的进程。所以另一个进程的 Provider 发生数据变化时,系统会通过 Binder 回调到你的进程。这也是 AIDL 之外一种轻量的跨进程通信方式。

我做过一个插件化项目,宿主进程需要感知插件进程里数据库的变化,用的就是自定义 Provider + ContentObserver,整个过程不用写 AIDL 接口,省了不少胶水代码。

6.2 和 FileObserver 的区别

很多人把 ContentObserver 和 FileObserver 搞混。简单区分:ContentObserver 监听的是数据层(ContentProvider),FileObserver 监听的是文件系统层(inotify)。

如果你的数据变化是以文件形式发生的(比如配置文件被修改、图片文件被新增),FileObserver 更合适;如果你的数据是结构化存储(数据库、SharedPreferences 背后的 XML、MediaStore 索引),ContentObserver 更合适。

有些场景两者可以配合。比如车载存储监听案例里,MediaStore 的 URI 变化意味着文件系统有变,但具体是哪个文件变了,ContentObserver 看不到,还得靠 FileObserver 监听具体目录。分工明确,效果更好。

6.3 SharedPreferences 能用 ContentObserver 监听吗?

这是个经典问题。SharedPreferences 本身不是 ContentProvider 管理的,所以不能用 ContentObserver 直接监听。但 Google 在 Android 的 Preference 框架里提供了SharedPreferences.OnSharedPreferenceChangeListener,这属于内存级别的监听,不需要 ContentObserver。

如果你想跨进程监听 SharedPreferences 的变化,就得自己封装。比如将 SharedPreferences 的写入操作封装到 ContentProvider 的 update 方法里,这样写入方通过 Provider 更新数据,读取方通过 ContentObserver 感知变化。

话又说回来,跨进程共享配置这种事情,现在更推荐用 DataStore(Android Jetpack 的新组件)或者直接用数据库。SharedPreferences 在设计上不擅长跨进程同步,勉强去做反而容易踩并发问题的坑。

7. 结合案例聊一聊:监听“安卓设备存储空间变化”的思路

热词里有网友在搜“android 车载监听存储空间的变化”“android 监听存储空间的变化”,这里集中谈谈我在这类需求上的落地思路。

7.1 存储空间变化的本质

存储空间变化分两种:一是设备挂载状态变化(U 盘插入、SD 卡弹出),二是某个目录下的文件增删导致剩余空间变化。前者和存储设备相关,后者和文件系统事件相关。

  • 挂载状态变化:可以通过监听MediaStore.Files.getContentUri("external")或者监听系统广播ACTION_MEDIA_MOUNTED/ACTION_MEDIA_REMOVED来感知。两者都要在 Manifest 里声明相应权限,并且要动态注册广播(静态注册在 Android 8.0 之后被限制了)。
  • 文件增删导致空间变化:监听文件目录用 FileObserver,监听空间变化可以定时查询StatFs计算剩余空间,或者监听一个“被修改的数据库”作为代理信号。

如果你是在车机上做“U 盘自动播放视频”的功能,我会推荐用广播 + FileObserver 的双重方案。U 盘插上时,ACTION_MEDIA_MOUNTED广播触发,拿到根路径;然后对根路径注册一个 FileObserver,监控视频文件的新增和删除。U 盘拔掉时,ACTION_MEDIA_UNMOUNTED或ACTION_MEDIA_EJECT触发,释放 FileObserver。

7.2 StatFs 计算剩余空间

如果你想知道存储设备还剩多少空间,用StatFs是最标准的做法:

public static long getAvailableStorageBytes(String path) { StatFs stat = new StatFs(path); long blockSize = stat.getBlockSizeLong(); long availableBlocks = stat.getAvailableBlocksLong(); return blockSize * availableBlocks; }

在 Android 7.0 之前用getBlockSize()和getAvailableBlocks(),7.0 之后推荐用带Long后缀的版本,避免 32 位整型溢出。剩余空间查询不能指望通过 ContentObserver 自动推送,只能靠事件触发后主动查询。

7.3 一个完整的存储监听 Manager

我在车载项目里把存储监听封装成了一个单例 Manager,对外暴露监听接口。它同时注册了 ContentObserver(监听 MediaStore)、FileObserver(监听具体目录)和 BroadcastReceiver(监听挂载广播),三个信号源汇聚到一个回调里,调用方只需面向一个接口。

class StorageMonitorManager { private final Context context; private final List<OnStorageDeviceListener> listeners = new CopyOnWriteArrayList<>(); private ContentObserver mediaObserver; private FileObserver fileObserver; private final BroadcastReceiver mediaReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) { Uri uri = intent.getData(); String path = uri != null ? uri.getPath() : null; notifyMounted(path); } else if (Intent.ACTION_MEDIA_UNMOUNTED.equals(action) || Intent.ACTION_MEDIA_EJECT.equals(action)) { notifyUnmounted(); } } }; public void startMonitor() { // 1. 动态注册挂载广播 IntentFilter filter = new IntentFilter(); filter.addAction(Intent.ACTION_MEDIA_MOUNTED); filter.addAction(Intent.ACTION_MEDIA_UNMOUNTED); filter.addAction(Intent.ACTION_MEDIA_EJECT); filter.addDataScheme("file"); context.registerReceiver(mediaReceiver, filter); // 2. 注册 MediaStore 的 ContentObserver mediaObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); refreshStorageStatus(); } }; context.getContentResolver().registerContentObserver( MediaStore.Files.getContentUri("external"), true, mediaObserver ); // 3. 对内部和外部存储根目录注册 FileObserver // 注意:FileObserver 不能递归监听所有子目录,只能监听一层,要递归需自己扩展 String internalPath = Environment.getExternalStorageDirectory().getAbsolutePath(); fileObserver = new FileObserver(internalPath, FileObserver.ALL_EVENTS) { @Override public void onEvent(int event, String path) { refreshStorageStatus(); } }; fileObserver.startWatching(); } public void stopMonitor() { context.unregisterReceiver(mediaReceiver); if (mediaObserver != null) { context.getContentResolver().unregisterContentObserver(mediaObserver); mediaObserver = null; } if (fileObserver != null) { fileObserver.stopWatching(); fileObserver = null; } } }

这个模式的好处是:不管 ROM 怎么裁剪广播,只要有 MediaStore 变更就能兜底;不管系统怎么调整文件权限,只要有实际文件事件就能兜底;三个信号源互为补充,基本能覆盖绝大多数车载场景。

7.4 为什么我建议优先尝试 ContentObserver 而不是广播

在很多需求里,ContentObserver 和 BroadcastReceiver 都能达到类似效果。我的选择原则是:

  • 如果监听对象是 ContentProvider 管理的数据(短信、联系人、媒体库、Settings),优先 ContentObserver。它不需要特定的动态注册权限,不受 Android 8.0 后台广播限制的影响。
  • 如果监听对象是文件系统的增删改,优先 FileObserver。它直接作用在 inode 上,轻量且可靠。
  • 如果监听的确实是设备挂载、屏幕亮灭、网络切换这类系统事件,才用 BroadcastReceiver。

内容观察者适合做“数据层”的信号,文件观察者适合做“文件层”的信号,广播适合做“系统事件层”的信号。按这个原则去选,基本不会错。遇到广播注册受限或者被系统裁剪的情况,拿 ContentObserver 去监听 Volume 相关的 MediaStore URI,往往是最省心的退路。

8. 学习路径建议与总结心得

8.1 一步步深入的建议路线

如果你今天第一次接触 ContentObserver,我建议按下面的顺序去学习,避免一上来就看源码细节被劝退:

  1. 写一个最简单的 Demo:创建一个空项目,注册一个 ContentObserver 去监听系统短信 URI,在手机里手动发一条短信,观察 onChange 是否触发。不一定要解析验证码,先让“回调”跑起来再说。这个过程不超过半小时,但能建立最直观的“通知”认知。
  2. 配合 ContentProvider 查询:在 onChange 回调里查询短信内容或联系人列表,打印日志,观察每次数据变化对应哪些查询结果。这一步能帮你建立“变化 → 查询 → 业务处理”的完整链路。
  3. 读系统源码:打开 Android Studio,找到ContentObserver.java和ContentResolver.java,看看 registerContentObserver 和 notifyChange 的注释和内部实现,理解 Binder 回调的机制。对比一下ContentObserver.Transport这个内部类是如何跨进程转发通知的。
  4. 模拟复杂场景:用一个 Handler 每 500 毫秒往自定义 Provider 里插一条数据,然后让 ContentObserver 统计回调次数,看看它和写入频率之间的关系。通过控制变量体会系统通知的合并策略和性能特性。

8.2 我踩过的最关键的三个坑

翻翻我的开发日志,和 ContentObserver 有关的最典型的三个教训是:

第一个是“线程”。之前为了省事传了 null Handler,结果在回调里操作了一个线程不安全的集合,导致偶发性数据错乱。排查了两天才定位到是线程切换导致的。之后我给自己定了规矩:除非是重量级查询想在后台线程执行,否则一律用主线程 Handler,并在回调里确认当前线程。

第二个是“注销”。一个线上版本忘记在 Fragment 的 onDestroyView 里注销 Observer,结果用户反复切换页面后应用内存暴涨。后来用 LeakCanary 才发现是 ContentObserver 持有 Fragment 引用,迟迟无法回收。

第三个是“权限”。在 Android 10 上试了半天的外部存储监听,总是什么都查不到,最后发现是分区存储的限制,targetSdk 升级后直接查不到其他应用写入媒体库的内容。后来老老实实按照 MediaStore API 的规范去读取,才把问题解决。

8.3 最后的小经验

ContentObserver 看起来只是一个小 API,但它串联起了“观察者模式”“跨进程通信”“数据一致性”“线程模型”这四大主题。弄懂它,不光是学会了一个组件的用法,更是对整个 Android 系统数据流的理解上了一个台阶。

如果在你的项目里遇到了“某个数据变了,但我不想轮询”的诉求,试着往 ContentObserver 上想。注册三行代码,注销一行代码,却能帮你省掉无数无效查询和回调风暴。很多时候,好的架构不需要多复杂的框架,而是把系统已经提供好的机制用到极致。ContentObserver 就是这样一个值得被用透的小组件。

返回列表