深入 Android ContentProvider 跨进程数据共享:从 URI 路由到 ContentObserver 变更通知的全链路架构解析
做系统相册 App 时,我需要在多个进程间同步图片索引数据。最先想到的是共享数据库文件,但多进程写 SQLite 的锁冲突让人头大——WAL 模式也救不了。后来切换到 ContentProvider,一次调通后回头来看,这套机制的设计比直觉中要精细。
这篇文章从 URI 路由、Cursor 跨进程传输、到 ContentObserver 变更通知,串起 ContentProvider 完整的数据链路。
URI 路由:入口匹配的本质
ContentProvider 对外暴露数据的入口是 URI,格式大家都熟:
content://com.example.app.provider/table/123
内部的匹配逻辑容易被忽略。每个 Provider 通过 UriMatcher 将 URI 映射为整数 code,在 query / insert / update / delete 中用一个 switch 分发:
public class MyProvider extends ContentProvider {
private static final UriMatcher matcher = new UriMatcher(UriMatcher.NO_MATCH);
static {
matcher.addURI("com.example.app.provider", "user", 1);
matcher.addURI("com.example.app.provider", "user/#", 2);
}
@Override
public Cursor query(Uri uri, String[] proj, String sel,
String[] selArgs, String sort) {
int code = matcher.match(uri);
switch (code) {
case 1: return db.query("user", proj, sel, selArgs, null, null, sort);
case 2: return db.query("user", proj,
"_id=?", new String[]{uri.getLastPathSegment()}, null, null, sort);
default: throw new IllegalArgumentException("Unknown URI: " + uri);
}
}
}
UriMatcher 的匹配是纯字符串比较,没有正则或通配符语义。# 只匹配数字,* 匹配任意文本。通配符段的顺序很重要——匹配按注册顺序执行,命中的第一个就是结果。实际项目中,# 用得远比 * 多,因为 Android 数据模型几乎都靠自增 ID 做主键。
有一个容易踩的坑:UriMatcher 的 addURI 方法要求 authority 和 path 独立传入。如果你把完整 URI 字符串(含 content:// 前缀)一起塞进去,匹配会永远返回 NO_MATCH。
跨进程 Cursor:谁在传数据,谁在收
调用方通过 ContentResolver.query() 拿到 Cursor 时,这个 Cursor 并不是 Provider 进程里的那个。Android 在这里做了一层关键的透明代理。
以 API 29 以下为例,ContentResolver 通过 Binder 调用 Provider 的 query(),返回的是一个 CursorToBulkCursorAdaptor 对象。这个 Adaptor 把 Cursor 包装成 IBulkCursor,在调用方进程再被 BulkCursorToCursorAdaptor 反向解包,还原出一个可用的 Cursor 实例。
整个过程用伪代码描述:
// Provider 进程
Cursor cursor = db.query(...);
IBulkCursor bulkCursor = new CursorToBulkCursorAdaptor(cursor, ...);
return new CursorWindow(...); // 首个数据窗口随 Binder 返回
// 调用方进程
IBulkCursor bulkCursor = stub.query(...);
Cursor result = new BulkCursorToCursorAdaptor(bulkCursor);
跨进程数据传输的单位是 CursorWindow,默认大小为 2MB。Provider 一侧填充窗口,调用方读取。Cursor.moveToNext() 不会触发逐行 Binder 调用——它先检查当前窗口是否还有数据,窗口耗尽时才通过 Binder 请求下一批。这个设计大幅减少了 IPC 次数。
ContentResolver.query() 返回的实际上是 CursorWrapper 的子类。API 29+ 上底层实现切换为 ContentProviderNative 的新传输通道,但上层 API 行为保持一致,调用方无需感知差异。
ContentObserver:变更通知的链路
数据变更后通知其他进程,靠的是 ContentObserver 配合 ContentResolver.notifyChange()。
// Provider 进程
getContext().getContentResolver().notifyChange(uri, null);
// 调用方进程
getContentResolver().registerContentObserver(uri, true, new ContentObserver(handler) {
@Override
public void onChange(boolean selfChange) {
// 重新查询数据
}
});
notifyChange 的第二个参数是 ContentObserver 类型,传 null 则通知该 URI 的所有订阅者。通知走的是 Binder 回调,从 Provider 所在进程推送到所有注册了 Observer 的客户端进程。
延迟通知场景下,onChange 在 UI 线程触发——里面直接做 requery 会报 NetworkOnMainThreadException 或阻塞主线程。建议始终传入一个后台 Handler:
new ContentObserver(new Handler(Looper.getMainLooper())) {
@Override
public void onChange(boolean selfChange) {
// 主线程回调
}
};
registerContentObserver 的第二个布尔参数 notifyForDescendants 要留意。设为 true 时,对 content://authority/table 的任何子 URI(如 table/1)的变更都会触发通知。监听整表变化时这个参数很实用,不用为每个 ID 各注册一个 Observer。
一个容易被忽略的细节:selfChange
Observer 注册进程内如果自己调了 notifyChange,onChange(boolean selfChange) 的参数就是 true。利用这个标志可以避免”自己改数据 → 收到通知 → 再次查询 → 又改”的死循环。但跨进程场景下 selfChange 始终为 false——因为变更发生在另一个进程。
实践中的几条经验
URI 设计要分层,不要把所有表塞到一个 authority 下做 path 区分。每个 authority 对应一个 Provider 实例,独立管理数据库连接和生命周期,出问题时隔离性更好。
Cursor 要及时关闭。跨进程 Cursor 的底层关联了 Binder 对象和 CursorWindow 内存。不关 Cursor 会同时泄露两种资源,finalize() 触发的清理在 GC 时机上完全不可控。用 try-with-resources 或在 finally 中调用 close()。
变更通知不要滥用。Android 的 notifyChange 最终调 ActivityManagerService(高版本为 ContentService)做广播通知,高频变更(如实时定位)走 ContentObserver 不适合。这种场景用 Broadcast 或 Messenger 更合理,让 ContentProvider 回归”结构化数据共享”的定位。
ContentProvider 是四大组件里最”安静”的一个——没有界面,不启动 Activity,但它是跨进程数据共享最标准的路径。理解 URI 路由、Cursor 代理和 Observer 通知三条链路,日常开发中碰到的 ContentProvider 问题基本都能定位。