ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自定义ContentProvider实战:从Uri匹配到跨进程数据共享的完整配置

自定义ContentProvider实战:从Uri匹配到跨进程数据共享的完整配置 1. 自定义 ContentProvider 到底解决什么问题如果你写过 Android 里两个 App 之间传数据大概率踩过这样的坑A 应用想把一批结构化数据交给 B 应用用 Intent 传吧数据量一大就 TransactionTooLargeException用文件共享吧路径权限又难管用广播吧只能传轻量内容还容易被拦截。这时候系统给出的标准答案就是 ContentProvider——它是 Android 四大组件里专门负责「跨进程数据共享」的那一个。一句话解释ContentProvider 是一套把数据源SQLite、文件、内存 Map 都行包装成统一 Uri 接口的机制外部通过 ContentResolver 用content://开头的地址来增删改查完全不用关心底层是数据库还是别的。它适合谁适合需要把数据开放给其他 App 的开发者比如通讯录、媒体库、笔记类应用也适合做多进程架构时让主进程和子进程共享一份数据。我这次要带你跑通的链路是自定义一个 Provider用 UriMatcher 区分「整表」和「单条记录」两种路径用 SQLiteOpenHelper 建库在 AndroidManifest 里声明 authorities最后用 adb shell content 命令从外部验证增删改查。整套代码可以直接复制改个包名就能用。核心检索词就三个ContentProvider、UriMatcher、跨进程数据共享下面全部围绕它们展开。很多人卡住不是因为不会写 CRUD而是卡在「Uri 匹配写错导致 query 返回空」「authorities 冲突装不上」「跨进程调用报 SecurityException」这几个点上。这篇会把每个坑都标出来。2. 前置准备authorities 命名与 UriMatcher 匹配表设计动手写代码前先把两个最容易出错的东西定下来authorities 和 Uri 匹配规则。authorities 是 Provider 的全局唯一标识相当于这个数据源的「域名」。规范写法是包名倒序比如com.example.myprovider。它必须全局唯一如果你两个 App 用了同一个 authorities后安装的会直接安装失败报INSTALL_FAILED_CONFLICTING_PROVIDER。所以别图省事写com.test一定带上自己的包名。Uri 的结构是content://authorities/路径/参数。我们设计两条路径Uri 形式含义匹配码content://com.example.myprovider/user操作整张 user 表1content://com.example.myprovider/user/5操作 _id5 的单条记录2UriMatcher 就是干这个的addURI(authorities, path, code)注册规则match(uri)返回匹配码NO_MATCH表示没匹配上。注意user/#里的#代表任意数字*代表任意字符串这是两个通配符别写混。数据库这边用 SQLiteOpenHelper 管理建一张 user 表字段_id主键自增、name、age。_id这个列名是 ContentProvider 的约定CursorAdapter 等组件依赖它别改成 id。注意Provider 的 onCreate 运行在主线程别在里面做耗时操作真正的建表交给 SQLiteOpenHelper 的 onCreate它只在数据库首次创建时执行一次。如果你在本地调试时想快速验证接口也可以借助一些在线模型对话工具来生成测试用的 ContentValues 数据省得手敲。不过核心逻辑还是得自己写清楚。3. 可复制配置Provider 骨架 Manifest 声明先上完整 Provider 代码包名按你的项目改。package com.example.myprovider; import android.content.ContentProvider; import android.content.ContentUris; import android.content.ContentValues; import android.content.UriMatcher; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; public class UserProvider extends ContentProvider { public static final String AUTHORITY com.example.myprovider; public static final Uri CONTENT_URI Uri.parse(content:// AUTHORITY /user); private static final int USER_DIR 1; // 整表 private static final int USER_ITEM 2; // 单条 private static final UriMatcher matcher new UriMatcher(UriMatcher.NO_MATCH); static { matcher.addURI(AUTHORITY, user, USER_DIR); matcher.addURI(AUTHORITY, user/#, USER_ITEM); } private DbHelper dbHelper; Override public boolean onCreate() { dbHelper new DbHelper(getContext()); return true; } Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { SQLiteDatabase db dbHelper.getReadableDatabase(); Cursor cursor; switch (matcher.match(uri)) { case USER_DIR: cursor db.query(user, projection, selection, selectionArgs, null, null, sortOrder); break; case USER_ITEM: long id ContentUris.parseId(uri); cursor db.query(user, projection, _id ?, new String[]{String.valueOf(id)}, null, null, sortOrder); break; default: throw new IllegalArgumentException(Unknown Uri: uri); } cursor.setNotificationUri(getContext().getContentResolver(), uri); return cursor; } Override public Uri insert(Uri uri, ContentValues values) { SQLiteDatabase db dbHelper.getWritableDatabase(); long id db.insert(user, null, values); getContext().getContentResolver().notifyChange(uri, null); return ContentUris.withAppendedId(CONTENT_URI, id); } Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { SQLiteDatabase db dbHelper.getWritableDatabase(); int rows; switch (matcher.match(uri)) { case USER_DIR: rows db.update(user, values, selection, selectionArgs); break; case USER_ITEM: long id ContentUris.parseId(uri); rows db.update(user, values, _id ?, new String[]{String.valueOf(id)}); break; default: throw new IllegalArgumentException(Unknown Uri: uri); } getContext().getContentResolver().notifyChange(uri, null); return rows; } Override public int delete(Uri uri, String selection, String[] selectionArgs) { SQLiteDatabase db dbHelper.getWritableDatabase(); int rows; switch (matcher.match(uri)) { case USER_DIR: rows db.delete(user, selection, selectionArgs); break; case USER_ITEM: long id ContentUris.parseId(uri); rows db.delete(user, _id ?, new String[]{String.valueOf(id)}); break; default: throw new IllegalArgumentException(Unknown Uri: uri); } getContext().getContentResolver().notifyChange(uri, null); return rows; } Override public String getType(Uri uri) { switch (matcher.match(uri)) { case USER_DIR: return vnd.android.cursor.dir/vnd. AUTHORITY .user; case USER_ITEM: return vnd.android.cursor.item/vnd. AUTHORITY .user; default: throw new IllegalArgumentException(Unknown Uri: uri); } } }配套的 DbHelperpackage com.example.myprovider; import android.content.Context; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; public class DbHelper extends SQLiteOpenHelper { public DbHelper(Context context) { super(context, user.db, null, 1); } Override public void onCreate(SQLiteDatabase db) { db.execSQL(CREATE TABLE user ( _id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, age INTEGER)); } Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL(DROP TABLE IF EXISTS user); onCreate(db); } }Manifest 里声明android:exportedtrue是跨进程访问的关键不写的话 Android 12 以上直接报错provider android:name.UserProvider android:authoritiescom.example.myprovider android:exportedtrue /如果你用 Cline MCP 或 Codex 这类工具辅助生成代码记得把 Base URL、Key、Model ID 三件套配全否则生成结果可能不完整。这里不展开重点还是 Provider 本身。4. 验证请求adb shell content 命令跑通增删改查代码写完先别急着写 Activity用 adb 从命令行验证最直接能排除 UI 层的干扰。插入一条数据adb shell content insert --uri content://com.example.myprovider/user \ --bind name:s:tom --bind age:i:20查询整表adb shell content query --uri content://com.example.myprovider/user正常输出类似Row: 0 _id1, nametom, age20按单条 Uri 查询adb shell content query --uri content://com.example.myprovider/user/1更新adb shell content update --uri content://com.example.myprovider/user/1 \ --bind age:i:25删除adb shell content delete --uri content://com.example.myprovider/user/1如果 query 返回空但 insert 没报错八成是 UriMatcher 的 path 写错了比如注册的是user但调用时写成了usersmatch 返回 NO_MATCH 后走了 default 分支抛异常或者你 query 里没处理直接返回了空 Cursor。用adb shell content query时加--where也能测条件查询adb shell content query --uri content://com.example.myprovider/user \ --where age 18跨进程验证的话再建一个 App用它的 ContentResolver 调同一个 Uri能查到数据就说明 authorities 和 exported 都配对了。这一步跑通整条数据共享链路就成立了。5. 常见报错排查401、SecurityException 与空 Cursor实际调试时报错信息往往比代码本身更值得看。下面几个是我踩过的坑。java.lang.SecurityException: Permission Denial: opening provider ... requires ... or grantUriPermission()。原因通常是android:exportedfalse或者没配android:permission。跨进程访问必须 exportedtrue如果只想给特定应用开放就自定义 permission 并在对方 Manifest 里 uses-permission。INSTALL_FAILED_CONFLICTING_PROVIDER。两个 App 用了同一个 authorities改掉其中一个即可别用com.example这种大众名。IllegalArgumentException: Unknown Uri。UriMatcher 没匹配上检查 addURI 的 path 和实际 Uri 是否一致注意#和*的区别以及 authorities 大小写。query 返回空 Cursor。先确认 insert 真的成功了用 adb query 看再检查 query 方法里有没有正确把 selection 传下去。很多人 query 里写死db.query(user, null, null, null, ...)把外部传的条件丢了自然查不到。CursorWindowAllocationException或reading choices类错误。通常是 Cursor 没关或者跨进程返回的 Cursor 数据量太大。记得cursor.close()大数据量分页查。Failed to find provider info for com.example.myprovider。Provider 没在 Manifest 注册或者进程还没启动。确认provider标签在application内部。提示调试时打开adb logcat | grep -i providerProvider 的异常基本都会打出来比盲猜快得多。6. 从单机到协作把验证链路固化下来跑通一次不难难的是每次改代码后还能快速回归。我的做法是把上面那几条 adb 命令写成一个 shell 脚本改完 Provider 直接跑一遍插入、查询、更新、删除全过一遍几十秒就能确认没回归。另外Provider 的 getType 别偷懒返回 null。虽然很多场景不报错但一旦有组件依赖 MIME 类型比如 Intent 匹配、某些第三方库返回 null 就会出问题。按vnd.android.cursor.dir/和vnd.android.cursor.item/的规范写成本很低。如果你后续要把这套数据共享能力接到更复杂的 Agent 或自动化流程里比如让外部工具通过标准接口读写 App 数据可以考虑用 Coding Plan 把 Provider 的接口文档和测试用例一起维护起来改一处同步一处比口头约定靠谱。接口文档和 API Keys 的入口在官网都能找到需要的话直接去看接入文档把 Base URL 和 Key 配好就能对接。最后留一个实用技巧Provider 的 notifyChange 一定要调否则用 ContentObserver 监听数据变化的一方永远收不到通知。这个坑我在做多进程同步时踩过数据明明写进去了界面就是不刷新查了半天才发现是漏了 notifyChange。加上这一行整条链路才算真正闭环。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进