1. 为什么你的 ContentProvider 总是“查不到数据”
如果你写过 Android 四大组件里的 ContentProvider,大概率遇到过这种场景:AndroidManifest.xml里 provider 也注册了,ContentResolver.query()也调了,结果要么抛IllegalArgumentException: Unknown URI,要么返回一个空 Cursor,要么跨应用访问直接SecurityException。更让人抓狂的是,数据明明插进去了,另一个界面却完全没反应——因为没人通知它。
ContentProvider 是 Android 四大组件里最“低调”的一个。Activity 管界面,Service 管后台,BroadcastReceiver 管广播,而 ContentProvider 管的是跨应用数据共享。它把数据封装成类似content://authority/path/id的 Uri,让别的应用通过 ContentResolver 用统一接口增删改查,再配合 ContentObserver 实现数据变化的实时监听。听起来很完整,但真正落地时,Uri 拼错一个字符、authority 和包名对不上、exported没开、权限没声明,链路就断在某一环。
这篇就围绕Uri、ContentResolver、ContentObserver三个热词,把 ContentProvider 从配置骨架到验证路径完整跑一遍。我会给出可以直接复制的AndroidManifest.xml与 provider 配置片段、ContentResolver 的 CRUD 调用示例、ContentObserver 的注册与注销代码,最后用 adb 和 Logcat 做验证。适合已经会写 Activity、想搞懂跨应用数据共享的 Android 开发者,也适合被Unknown URI折磨过、想系统梳理一遍的同学。
2. 先把 Uri 和 authority 这两件事说透
ContentProvider 的入口就是 Uri。它的结构是content://authority/path/id,拆开看四段:content://是协议,固定不变;authority 是这个 provider 的唯一标识,通常写成包名.provider;path 指向具体的数据表或资源;id 是可选的单条记录标识。比如content://com.example.myapp.provider/users/123,authority 是com.example.myapp.provider,path 是users,id 是123。
这里最容易踩的坑是:authority 必须和 Manifest 里android:authorities完全一致,一个字符都不能差。我见过有人 Manifest 写com.example.myapp.provider,代码里 Uri 写成com.example.myapp.providers,多了个 s,结果就是Unknown URI。另一个坑是 UriMatcher 的注册顺序,users/#这种带 id 的规则要放在users之后匹配,否则users会先命中,id 永远解析不出来。
在动手写代码前,建议先把这套“数据共享链路”想清楚:provider 端负责定义 Uri 规则、实现 CRUD、在数据变化时notifyChange;client 端通过 ContentResolver 发起请求、通过 ContentObserver 订阅变化。两端靠 authority 和 Uri 对齐,靠权限控制谁能读谁能写。下面这张表可以先存着,配置时逐项对照。
| 配置项 | 作用 | 常见错误 |
|---|---|---|
android:authorities | provider 唯一标识 | 与代码 Uri 不一致 |
android:exported | 是否允许跨应用访问 | 忘了设 true,跨应用直接拒绝 |
android:readPermission | 读权限 | 权限名拼写不一致 |
android:writePermission | 写权限 | protectionLevel 设错 |
grantUriPermissions | 临时授权 | 需要精细控制时未开启 |
如果你在本地调试时想快速验证模型对 Android 代码的理解,或者让 AI 帮你补全 provider 的样板代码,可以先用 TaoToken 模型对话 把思路理一遍,再落到工程里。真正跑链路还是得靠本地 adb 和 Logcat。
3. 可复制的 provider 配置与 CRUD 骨架
3.1 AndroidManifest.xml 里的 provider 声明
先看 Manifest。provider 必须声明在<application>内部,authorities和代码里的AUTHORITY常量保持一致。如果只是自己应用内部用,exported设 false 更安全;要跨应用共享才设 true,并且配上读写权限。
<application> <!-- 声明 ContentProvider --> <provider android:name=".provider.MyContentProvider" android:authorities="com.example.myapp.provider" android:enabled="true" android:exported="true" android:readPermission="com.example.myapp.permission.READ_USERS" android:writePermission="com.example.myapp.permission.WRITE_USERS" android:grantUriPermissions="true"> <!-- 针对路径的细粒度权限 --> <path-permission android:pathPrefix="/users" android:readPermission="com.example.myapp.permission.READ_USERS" /> <grant-uri-permission android:path="/users/public/*" /> </provider> <!-- 声明自定义权限 --> <permission android:name="com.example.myapp.permission.READ_USERS" android:protectionLevel="normal" /> <permission android:name="com.example.myapp.permission.WRITE_USERS" android:protectionLevel="dangerous" /> </application>注意:
protectionLevel用normal时对方安装即授予,用dangerous需要运行时申请。调试阶段如果一直被SecurityException拦住,可以先临时把权限去掉、exported设 true,确认链路通了再逐步加回权限。
3.2 ContentProvider 核心方法实现
provider 端的关键是 UriMatcher 和四个 CRUD 方法。下面这段可以直接作为骨架,重点看query里的setNotificationUri和insert/update/delete里的notifyChange——它们是 ContentObserver 能收到通知的前提。
public class MyContentProvider extends ContentProvider { public static final String AUTHORITY = "com.example.myapp.provider"; public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/users"); private static final int USERS = 1; private static final int USER_ID = 2; private static final UriMatcher sUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); static { sUriMatcher.addURI(AUTHORITY, "users", USERS); sUriMatcher.addURI(AUTHORITY, "users/#", USER_ID); } private SQLiteDatabase mDatabase; @Override public boolean onCreate() { DatabaseHelper helper = new DatabaseHelper(getContext()); mDatabase = helper.getWritableDatabase(); return mDatabase != null; } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { SQLiteQueryBuilder builder = new SQLiteQueryBuilder(); builder.setTables("users"); int match = sUriMatcher.match(uri); switch (match) { case USERS: break; case USER_ID: builder.appendWhere("_id=" + uri.getLastPathSegment()); break; default: throw new IllegalArgumentException("Unknown URI: " + uri); } Cursor cursor = builder.query(mDatabase, projection, selection, selectionArgs, null, null, sortOrder); // 关键:绑定通知 Uri,数据变化时观察者才能收到 cursor.setNotificationUri(getContext().getContentResolver(), uri); return cursor; } @Override public Uri insert(Uri uri, ContentValues values) { if (sUriMatcher.match(uri) != USERS) { throw new IllegalArgumentException("Unknown URI: " + uri); } long rowId = mDatabase.insert("users", null, values); if (rowId > 0) { Uri newUri = ContentUris.withAppendedId(CONTENT_URI, rowId); getContext().getContentResolver().notifyChange(newUri, null); return newUri; } throw new SQLException("Failed to insert row into " + uri); } @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { int count; int match = sUriMatcher.match(uri); if (match == USER_ID) { String where = "_id=" + uri.getLastPathSegment(); if (selection != null) where += " AND (" + selection + ")"; count = mDatabase.update("users", values, where, selectionArgs); } else if (match == USERS) { count = mDatabase.update("users", values, selection, selectionArgs); } else { throw new IllegalArgumentException("Unknown URI: " + uri); } if (count > 0) { getContext().getContentResolver().notifyChange(uri, null); } return count; } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { int count; int match = sUriMatcher.match(uri); if (match == USER_ID) { String where = "_id=" + uri.getLastPathSegment(); if (selection != null) where += " AND (" + selection + ")"; count = mDatabase.delete("users", where, selectionArgs); } else if (match == USERS) { count = mDatabase.delete("users", selection, selectionArgs); } else { throw new IllegalArgumentException("Unknown URI: " + uri); } if (count > 0) { getContext().getContentResolver().notifyChange(uri, null); } return count; } @Override public String getType(Uri uri) { switch (sUriMatcher.match(uri)) { case USERS: return "vnd.android.cursor.dir/vnd.com.example.users"; case USER_ID: return "vnd.android.cursor.item/vnd.com.example.user"; default: throw new IllegalArgumentException("Unknown URI: " + uri); } } }3.3 ContentResolver 的增删改查调用
client 端通过 ContentResolver 操作,代码里不用关心底层是 SQLite 还是网络。查询时记得在 finally 里cursor.close(),否则容易泄漏。
public class UserRepository { private static final Uri CONTENT_URI = Uri.parse("content://com.example.myapp.provider/users"); // 查询 public List<User> queryAll(Context context) { List<User> list = new ArrayList<>(); Cursor cursor = context.getContentResolver().query( CONTENT_URI, null, null, null, "created_at DESC"); if (cursor != null) { try { while (cursor.moveToNext()) { User u = new User(); u.id = cursor.getLong(cursor.getColumnIndexOrThrow("_id")); u.name = cursor.getString(cursor.getColumnIndexOrThrow("name")); u.email = cursor.getString(cursor.getColumnIndexOrThrow("email")); list.add(u); } } finally { cursor.close(); } } return list; } // 插入 public Uri insert(Context context, String name, String email) { ContentValues values = new ContentValues(); values.put("name", name); values.put("email", email); return context.getContentResolver().insert(CONTENT_URI, values); } // 更新 public int update(Context context, long id, String newName) { ContentValues values = new ContentValues(); values.put("name", newName); Uri uri = ContentUris.withAppendedId(CONTENT_URI, id); return context.getContentResolver().update(uri, values, null, null); } // 删除 public int delete(Context context, long id) { Uri uri = ContentUris.withAppendedId(CONTENT_URI, id); return context.getContentResolver().delete(uri, null, null); } }3.4 ContentObserver 监听数据变化
ContentObserver 是“数据变了通知我”的机制。注册时传入 Uri,provider 端notifyChange后,onChange就会被回调。注意注册和注销要成对出现,通常在onResume注册、onPause注销,避免界面不可见时还在收通知。
public class UserListActivity extends AppCompatActivity { private static final Uri CONTENT_URI = Uri.parse("content://com.example.myapp.provider/users"); private ContentObserver mObserver; @Override protected void onResume() { super.onResume(); mObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { @Override public void onChange(boolean selfChange, Uri uri) { super.onChange(selfChange, uri); Log.d("Observer", "数据变化: " + uri); // 重新查询刷新 UI reloadData(); } }; getContentResolver().registerContentObserver( CONTENT_URI, true, mObserver); } @Override protected void onPause() { super.onPause(); if (mObserver != null) { getContentResolver().unregisterContentObserver(mObserver); mObserver = null; } } private void reloadData() { // 调用上面的 queryAll 刷新列表 } }注意:
registerContentObserver第二个参数notifyForDescendants设为 true 时,子路径的变化也会触发。如果你只想监听精确 Uri,设 false。
4. 用 adb 和 Logcat 验证整条链路
配置写完了,怎么确认真的通了?分三步验证。
第一步,确认 provider 被系统识别。安装应用后执行:
adb shell dumpsys activity providers | grep com.example.myapp.provider如果能看到 provider 信息,说明 Manifest 注册成功。如果什么都没有,检查android:name路径和authorities是否写对。
第二步,直接用 adb 操作 provider,绕过 UI 验证 CRUD:
# 查询 adb shell content query --uri content://com.example.myapp.provider/users # 插入 adb shell content insert --uri content://com.example.myapp.provider/users \ --bind name:s:张三 --bind email:s:zhangsan@test.com # 更新 adb shell content update --uri content://com.example.myapp.provider/users/1 \ --bind name:s:李四 # 删除 adb shell content delete --uri content://com.example.myapp.provider/users/1content query能返回行数据,说明 provider 的 query 和 UriMatcher 都正常。如果报Unknown URI,回到第 2 节检查 authority 和 path。
第三步,验证 ContentObserver。在 Activity 里注册观察者后,用 adb 插入一条数据,观察 Logcat:
adb logcat -s Observer如果看到数据变化: content://com.example.myapp.provider/users/2,说明notifyChange和onChange链路打通。如果没反应,检查 provider 的 insert 里有没有调用notifyChange,以及 query 里有没有setNotificationUri。
实测下来,最容易断的是setNotificationUri这一步——很多人只写了notifyChange,但 query 返回的 Cursor 没绑定通知 Uri,观察者就收不到。这两个要成对出现。
5. 本篇常见错排查
报错一:IllegalArgumentException: Unknown URI九成是 authority 或 path 不匹配。先对比 Manifest 的android:authorities和代码里的AUTHORITY常量,再检查 UriMatcher 注册的 path 和实际请求的 path。带 id 的规则users/#要放在users后面。
报错二:SecurityException: Permission Denial跨应用访问时权限没配对。检查 provider 的readPermission/writePermission和调用方 Manifest 里的<uses-permission>是否一致。调试阶段可以先去掉权限、exported设 true 确认链路,再逐步加回。
报错三:Cursor 返回空但数据库有数据常见于query里selection拼接错误,或者projection列名写错。用adb shell content query直接查一次,排除是 provider 问题还是 client 问题。另外确认onCreate里数据库真的创建成功了。
报错四:ContentObserver 不回调三个检查点:provider 的 insert/update/delete 有没有notifyChange;query 返回的 Cursor 有没有setNotificationUri;观察者注册的 Uri 和 notify 的 Uri 是否匹配。三者缺一不可。
报错五:跨应用访问时exported相关报错Android 12 以后,声明了 intent-filter 的组件必须显式设置android:exported。provider 如果没设,安装时可能直接失败。跨应用共享必须设 true,仅内部使用设 false。
如果你在排查过程中想让 AI 帮你分析 Logcat 或补全某段 provider 代码,可以用 TaoToken API Keys 配好密钥,把报错贴进去让它给排查思路。接入细节可以对照 TaoToken 接入文档 走一遍。
6. 把这条链路沉淀成可复用的模板
ContentProvider 这套东西,配一次通一次,之后就是复制粘贴改 authority 和表名。建议你把第 3 节的 Manifest 片段、provider 骨架、ContentResolver 封装、ContentObserver 注册注销这四块整理成一个模板工程,下次新项目直接改包名和字段就能用。
如果你长期在写 Android 或者做 Agent 相关的编码工作,需要频繁让模型补全这类样板代码,可以看看 TaoToken Coding Plan,把 provider、Resolver、Observer 的代码生成和排障交给它,自己专注在业务逻辑上。真正跑通链路的关键还是那三步:Manifest 对齐 authority、provider 里 notifyChange 和 setNotificationUri 成对、adb 验证 CRUD 和观察者回调。这三步过了,ContentProvider 就不再是四大组件里最神秘的那个了。