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); // 数据变化了在这里处理业务逻辑 } }如果构造时传入 nullonChange 会回调在 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()性能差距可以忽略因为短信库一般不会太大除了那种几万条短信的极端情况。三是验证码匹配的正则。如果短信内容是“您的验证码是1234565分钟内有效”\\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 ListString getMountedVolumes() { ListString 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(ListString 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 参数决定回调线程。如果你传 nullonChange 就会跑在系统 Binder 线程池中某个线程上。这在低配机或高并发场景下会引发诡异问题你在回调里假设它跑在主线程然后在里面更新 UI结果时不时闪退或者你在回调里访问了某个非线程安全的单例导致数据错乱。我自己就遇到过一次在onChange里直接调了一个单例的缓存更新方法那个单例内部用 HashMap 做缓存本来在主线程跑得好好的换到 Binder 线程后高并发下 HashMap 的 put 和 get 冲突直接导致内部数据残留脏数据。排查了大半天才发现是线程切换导致的。建议除非你有 100% 的把握回调里的逻辑是线程安全的否则统一给 Observer 传入主线程 Handler或者手动 post 到主线程。宁可多几次线程切换也别踩线程安全的地雷。4.2 忘记注销导致的内存泄漏ContentObserver 被注册到 ContentResolver 后是有跨进程引用的。如果你在 Activity 里注册了 ObserverActivity 销毁后却没有注销这个 Observer 会一直被系统持有。而你的 Observer 如果是匿名内部类它会隐式持有外部 Activity 的引用整个 Activity 就泄漏了。解决方式很简单注册和注销必须成对出现且遵循生命周期。表格里列一下我常用的注册时机注册场景注册位置注销位置Activity 可见期间监听onStart / onResumeonStop / onPauseFragment 可见期间监听onStart / onResumeonStop / onPauseService 全生命周期监听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 不匹配、notifyForDescendantsfalse注册父 URI 时改为 true回调不实时防抖机制、线程阻塞检查 Handler 和 Runnable 逻辑回调多次触发单个 Provider 多次 notifyChange在回调里做去重/状态比对内存泄漏未注销 Observer严格配对注册/注销报 SecurityException缺少读取权限或分区存储限制弹窗申请权限、适配 Scoped Storage查询结果为空URI 被权限遮蔽检查 targetSdk 和权限申请状态系统重启后失效未重新注册配合开机广播/前台服务重新注册我把这套从实践中积累的排查表放在这里遇到问题先对照一遍大多数坑都能对号入座。5. 版本适配与性能优化5.1 Android 版本差异要注意什么ContentObserver 本身从 API 1 就有了非常稳定但它所“观察”的数据源和 Provider 在不同版本上变化很大Android 7.0API 24FileProvider 引入file://URI 受限如果你通过 ContentObserver 拿到变更 URI 后尝试直接访问文件路径会 Crash。Android 8.0API 26通知渠道、后台执行限制如果你在后台 Service 里注册 ContentObserver要注意服务保活的问题。Android 10API 29分区存储强制开启外部存储的 URI 不能直接通过路径访问必须用 MediaStore API 或 SAF。Android 11API 30软件包可见性变化查询其他应用信息需要queries声明。Android 13API 33READ_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 是由目标应用自己控制的目标应用不主动 notifyChangeContentObserver 是不会收到任何通知的。换句话说这些 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 监听的是数据层ContentProviderFileObserver 监听的是文件系统层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 感知变化。话又说回来跨进程共享配置这种事情现在更推荐用 DataStoreAndroid 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 ListOnStorageDeviceListener 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我建议按下面的顺序去学习避免一上来就看源码细节被劝退写一个最简单的 Demo创建一个空项目注册一个 ContentObserver 去监听系统短信 URI在手机里手动发一条短信观察 onChange 是否触发。不一定要解析验证码先让“回调”跑起来再说。这个过程不超过半小时但能建立最直观的“通知”认知。配合 ContentProvider 查询在 onChange 回调里查询短信内容或联系人列表打印日志观察每次数据变化对应哪些查询结果。这一步能帮你建立“变化 → 查询 → 业务处理”的完整链路。读系统源码打开 Android Studio找到ContentObserver.java和ContentResolver.java看看 registerContentObserver 和 notifyChange 的注释和内部实现理解 Binder 回调的机制。对比一下ContentObserver.Transport这个内部类是如何跨进程转发通知的。模拟复杂场景用一个 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 就是这样一个值得被用透的小组件。

相关新闻

OpenClaw在Windows上的完整部署:WSL2+Node+Python环境初始化与排障

OpenClaw在Windows上的完整部署:WSL2+Node+Python环境初始化与排障

想在一台Windows机器上把OpenClaw 完整跑起来,确实不是下载一个安装包就能完事的。OpenClaw 这类面向 AI Agent 工作流的开源命令行工具,天生依赖一套完整的“运行时环境”:Node.js 负责驱动 CLI,Python 负责跑本地模型或辅助脚本…

2026/10/3 14:34:55 阅读更多 →
ESP32外挂4G模块:从PPP拨号到Cat.1联网全流程实战

ESP32外挂4G模块:从PPP拨号到Cat.1联网全流程实战

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

2026/10/3 14:34:55 阅读更多 →
智能汽车电子电气架构落地三大硬核挑战:供电、线束与OTA

智能汽车电子电气架构落地三大硬核挑战:供电、线束与OTA

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

2026/10/3 14:34:55 阅读更多 →

最新新闻

Spring @Async异步线程池实战:接口性能优化与踩坑复盘

Spring @Async异步线程池实战:接口性能优化与踩坑复盘

做后端接口性能优化的这些年,我有个很深的体会:很多接口慢,真不是因为主流程复杂,而是因为把一堆没必要同步执行的事塞进了请求线程。举个例子,用户下单后要发短信、送积分、写统计日志,这三件事每件都要几…

2026/10/3 15:01:19 阅读更多 →
网易云音乐推荐算法深度拆解:从召回、排序到冷启动的完整实践

网易云音乐推荐算法深度拆解:从召回、排序到冷启动的完整实践

1. 从一次点击开始:音乐推荐系统到底在解决什么问题每天有大量用户打开网易云音乐,点进“每日推荐”或者“私人FM”,然后随意按下一首歌的播放键。这个动作看起来稀松平常,背后却是一整套推荐算法在极短时间内完成的排序决策。作为…

2026/10/3 15:01:19 阅读更多 →
跨芯片算子优化实战:用Triton GEMM反超厂商原生算力的完整方法论

跨芯片算子优化实战:用Triton GEMM反超厂商原生算力的完整方法论

各位做算子开发、跑AI芯片适配的朋友,应该都经历过这种场景:一份写好的Triton GEMM内核,在NVIDIA的GPU上跑得好好的,切到国产芯片或者别的加速卡上,性能直接腰斩,甚至不如人家原生的算子库。最近我在做跨芯…

2026/10/3 15:01:19 阅读更多 →
FSR 1.0核心解析:EASU边缘自适应上采样原理与源码实现

FSR 1.0核心解析:EASU边缘自适应上采样原理与源码实现

几个月前为了给自己的渲染器加一套低分辨率渲染方案,我把AMD开源的FSR 1.0完整读了一遍。坦白说,第一眼看到ffx_fsr1.h里那堆AH4、AF3、AMul宏的时候,我是想直接关掉页面的。后来耐着性子把EASU这条推理链理顺,才意识到它没有想象…

2026/10/3 15:01:19 阅读更多 →
SpringBoot+Vue就业管理系统毕设全解析:从数据库到权限控制

SpringBoot+Vue就业管理系统毕设全解析:从数据库到权限控制

这个项目我在带学生做毕设的时候见过太多次了——每年就业季前后,都会有人拿着"SpringBootVue就业管理系统"这种标题的源码来问我要不要选它、能不能跑通、答辩该怎么讲。说实话,这类项目之所以烂大街,恰恰是因为它选题讨巧、技术栈…

2026/10/3 15:01:19 阅读更多 →
微信小程序考试信息报名系统毕设全流程实战:从需求到答辩

微信小程序考试信息报名系统毕设全流程实战:从需求到答辩

每年毕业季我都会收到一批私信,问的最多的就是“毕设选题选什么”。如果你正盯着“基于微信小程序的考试信息报名系统”这个题目犹豫,我直接说结论:这题能做,而且非常适合作为毕业设计。它既有完整的业务闭环——考生查考试、在线…

2026/10/3 15:00:18 阅读更多 →

日新闻

把回忆蒸馏成 AI 的浪漫实验:为什么你需要前任.skill 完整指南

把回忆蒸馏成 AI 的浪漫实验:为什么你需要前任.skill 完整指南

把回忆蒸馏成 AI 的浪漫实验:为什么你需要前任.skill 完整指南 【免费下载链接】ex-skill 前任 skill 项目地址: https://gitcode.com/gh_mirrors/exsk/ex-skill 前任.skill 是一个运行在 Claude Code 上的开源 Skill:导入微信、iMessage、短信、…

2026/10/3 0:00:27 阅读更多 →
45个经典Linux面试题:从命令到网络排障的完整考点解析

45个经典Linux面试题:从命令到网络排障的完整考点解析

刚开始带应届生的时候,我最头疼的就是他们拿着一摞Linux面试题背得滚瓜烂熟,一上机全露馅。后来自己从被面的人变成面别人的人,才慢慢摸清楚:Linux面试题考的根本不是答案本身,而是你面对一个不确定的系统问题时&#…

2026/10/3 0:01:28 阅读更多 →
SAP生产预留实战指南:MB21/MB23/MB25协同与MRP集成

SAP生产预留实战指南:MB21/MB23/MB25协同与MRP集成

简介:本资源是一份面向SAP ABAP开发人员、生产计划专员及ERP实施顾问的实操型操作指南,聚焦SAP生产预留核心业务场景,系统解决物料预留创建、查询、校验与批量处理等高频问题。文档以结构化方式覆盖预留背景原理、OMC2编码规则、工厂级参数配…

2026/10/3 0:01:28 阅读更多 →

周新闻

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解

如何划分训练/验证集:Spirula Studio五种eval_mode策略详解 【免费下载链接】spirula-studio Cross-vendor 3D Gaussian Splatting trainer - video to splat to mesh, Vulkan or CUDA. 项目地址: https://gitcode.com/GitHub_Trending/sp/spirula-studio Sp…

2026/10/3 9:14:33 阅读更多 →
SEO怎么推广速查手册新手避坑实战指南

SEO怎么推广速查手册新手避坑实战指南

SEO怎么推广速查手册新手避坑实战指南 模板网站太丑不够用?别急着加滤镜,那是治标不治本。很多老板盯着后台流量掉得眼红,却还在纠结首页Banner的圆角是不是3像素。这就像穿着西装去挖土,姿势不对,努力白费。我整理这份 速查手册…

2026/10/3 9:47:50 阅读更多 →
FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏

FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏

FireRed-OpenStoryline少样本仿写深度解析:AI Agent如何复刻你的独特文案风格与节奏 【免费下载链接】FireRed-OpenStoryline FireRed-OpenStoryline is an AI video editing agent that transforms manual editing into intention-driven directing through natural language …

2026/10/3 9:42:31 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

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

2026/10/2 10:36:31 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

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

2026/10/3 9:42:35 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

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

2026/10/3 9:42:36 阅读更多 →