☰
Android ContentProvider精讲:跨应用数据共享与URI映射实操
2026/9/26 5:33:37 网站建设 项目流程

1. 为什么需要内容提供者:应用之间的数据孤岛怎么打破

做过安卓开发的都知道,每个应用默认运行在自己的进程里,有自己的沙盒目录、私有数据库和 SharedPreferences。这种隔离机制保证了安全,但也带来一个麻烦:应用A想把一段数据交给应用B,直接读写对方的文件目录?门都没有,权限模型直接把它挡死。那怎么办?系统提供了 ContentProvider 这套标准接口,让一个应用可以把内部数据以"暴露服务"的方式开放出去,另一个应用通过 ContentResolver 像访问数据库一样读写这些数据。这就是今天要聊的核心:内容提供者(ContentProvider)在应用之间共享数据。

标题里写着"(15)—内容提供者(1)",说明这是系列里的一个章节,第一篇主要解决的是概念和基础用法。你可以把 ContentProvider 理解成"数据的中转站",它不关心数据存在哪里——SQLite、内存、文件、甚至网络请求都行——它只负责把你想要暴露的数据统一封装成类似数据库表的形态,对外提供增删改查四个操作。而调用方不需要知道数据源头是什么,只需要知道一个 URI 就能拿到数据。这个设计很像餐厅的点餐台:厨房里怎么做菜你不需要管,你只需要对服务员下订单,服务员把菜端给你。

这套机制解决的实际问题很多,最典型的是读取系统通讯录、读取日历事件、读取相册图片。这些系统内置的数据全都通过 ContentProvider 暴露给第三方应用。你自己开发的 App 之间如果需要共享登录状态、共享用户配置、共享业务数据,同样可以自己写一个 ContentProvider。比如你在做一套多模块应用,主模块和子模块需要共用一份用户资料,与其用文件、全局变量这些脆弱的方案,不如直接用 ContentProvider 来得规范。

这篇博文面向的读者是已经掌握了 Activity、Intent、SQLite 基础操作的安卓学习者。如果你连四大组件都还没接触全,建议先把 Activity 和 Service 过一遍再来。读完之后你不仅能理解 ContentProvider 的最核心原理,还可以照着后面的代码写一个能跑的简单实例,把"应用间共享数据"这个概念真正落地。

2. 核心组件拆解:URI、ContentResolver 与 ContentProvider 的分工

2.1 URI 就是数据表的门牌号

ContentProvider 暴露出来的每一个数据集都有一个唯一的 URI 标识。格式通常长这样:

content://com.example.dataprovider/user

拆开来看,content://是固定的协议头,就像http://一样;com.example.dataprovider是 authority,也就是提供者的唯一标识,一般定义为应用包名加后缀,确保全局不冲突;/user是路径,表示你要访问的是哪个数据集,可以继续加层级,比如/user/1表示 id 为 1 的那一条记录。

新手最常犯的错是把 URI 写死成字符串到处拼。其实系统提供了Uri.parse()方法,把字符串转成 Uri 对象,之后所有操作都要基于这个 Uri 对象进行。在 ContentProvider 内部,你会收到一个 Uri 实例,需要自己解析路径片段来判断调用方想操作哪张表。这里有个约定俗成的常量定义方式,在 Provider 内部声明authority常量和path常量,然后用UriMatcher做匹配。UriMatcher是一个工具类,专门用来匹配 Uri 的路径模式,你提前把content://authority/user注册成 CODE_USER,把content://authority/user/#注册成 CODE_USER_ID,provider 收到请求后通过matcher.match(uri)返回对应的 code,再用 switch 分发逻辑。这套模式几乎是所有 ContentProvider 的标准写法,建议直接背下来。

2.2 ContentResolver:调用方手里的万能遥控器

普通应用没有资格直接拿到 ContentProvider 实例,只能通过getContentResolver()拿到一个 ContentResolver 对象。这个对象提供了主要的四个方法:query、insert、update、delete。看方法签名你会发现,它们接受的都是 Uri 和 ContentValues 这类抽象参数,完全屏蔽了底层实现细节。

这里有个很多人想不通的点:为什么不能直接 new 一个 ContentProvider 来用?因为 ContentProvider 的生命周期是受系统管理的。它在进程启动时被实例化,并且单例存在,你直接 new 出来不仅拿不到正确的 Context,还会绕过权限检查。ContentResolver 实际上会通过 Binder 机制跨进程调用 Provider 所在的进程,由系统统一调度,你在这边调用resolver.query(),底层可能已经走了好几次进程间通信。这就是为什么共享数据一定要走这套框架,而不是拿静态类去搞。

我举个生活例子,ContentResolver 好比电影院的售票窗口,ContentProvider 是影院后台的系统。你作为观众不需要认识放电影的人,只需要在窗口买票(query/insert),窗口会协调后台完成整个流程。如果直接翻进后台找放映员,轻则被保安拦下,重则整个影院系统崩掉。

2.3 ContentProvider 的 onCreate 和数据源绑定

ContentProvider 本身只是一个调度壳,它不存数据,数据由你管理。你在继承 ContentProvider 后,必须实现onCreate()方法。这个回调发生在应用启动早期,甚至可能比 Application 的onCreate更早,所以这里只应该做轻量初始化,比如打开数据库连接、创建 helper 实例。绝对不要在这里去启动线程做耗时操作,因为你的 Provider 一旦被别的进程调用,系统就会在当前进程的主线程里执行这个回调,卡住了会影响整个应用的启动速度。

常见做法是在onCreate里实例化一个SQLiteOpenHelper或者直接拿到 Room 数据库实例。做个简单 Demo 的时候,我甚至会直接用内存里的Map数据结构来模拟表,省去建库的繁琐。这不影响学习原理,反而能把注意力集中在 ContentProvider 的职责上。但在实际项目中,数据源几乎都是 SQLite 或 Room,因为 ContentProvider 的查询返回类型是 Cursor,和数据库天然匹配。

还有一个重要细节:onCreate返回值是 boolean,表示 Provider 是否初始化成功。如果返回 false,系统会认为 Provider 不可用,后续调用可能直接失败。所以正常情况下永远返回 true,除非你的初始化逻辑明确判断出环境有问题。

2.4 四大方法实现时的 Uri 校验和返回值约定

实现query、insert、update、delete时,最重要的一件事就是先用UriMatcher检查 uri 是否匹配,不匹配就抛IllegalArgumentException。很多新手忽略这一步,结果所有 uri 都返回空数据,排查半天才发现是自己把 authority 写错了。

返回值的约定也要记住:query返回 Cursor,查不到数据也不要返回 null,而是返回一个空 Cursor(如果使用 SQLiteDatabase,天然满足这一点);insert返回新插入行的 uri,也就是带上 id 的完整 uri;update和delete返回受影响的行数。这个约束在跨进程调用时尤其重要,因为 Binder 传输对 null 的容忍度和同进程不一样,返回 null 可能导致调用方抛出异常。

3. 手写一个跨应用共享数据的完整示例:从数据库到调用方

3.1 项目结构和权限声明

为了直观演示,我建了两个应用模块,一个叫ProviderDemo,负责提供数据;另一个叫ClientDemo,负责消费数据。两个应用独立运行在不同进程,正好模拟真实跨进程共享场景。先看 Provider 这一侧的整体结构:

ProviderDemo/ ├── MainActivity.java // 用于手动往数据库里塞几条测试数据 ├── UserProvider.java // ContentProvider 实现类 └── UserDbHelper.java // SQLiteOpenHelper 实现类

在 Provider 的 AndroidManifest.xml 中,ContentProvider 需要这样注册:

<provider android:name=".UserProvider" android:authorities="com.example.providerdemo.userprovider" android:exported="true" android:grantUriPermissions="true" />

这里重点说android:exported。在早期安卓版本里,不声明这个属性的 Provider 默认可以被外部应用调用;从 Android 12(API 31)开始,如果 Provider 声明了 intent-filter 但没显式设置 exported,系统会直接报错。对本例这种没有 intent-filter 的 Provider,如果 targetSdkVersion 是 31 以上,并且没有显式给 exported 赋值,系统同样会拒绝安装。所以我直接写上exported="true",表示允许其他应用访问。你如果只想让同一签名的应用访问,可以设置android:exported="false"然后配合android:grantUriPermissions做临时授权,或者使用android:permission指定权限。这是后话,但务必知道有这么个开关。

3.2 UserDbHelper:基础的 SQLiteOpenHelper

Provider 本身不碰数据库细节,所以我单独写一个 helper:

public class UserDbHelper extends SQLiteOpenHelper { private static final String DB_NAME = "user.db"; private static final int DB_VERSION = 1; public static final String TABLE_NAME = "user_info"; public static final String COLUMN_ID = "_id"; public static final String COLUMN_NAME = "name"; public static final String COLUMN_AGE = "age"; private static final String SQL_CREATE_TABLE = "CREATE TABLE " + TABLE_NAME + " (" + COLUMN_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " + COLUMN_NAME + " TEXT NOT NULL, " + COLUMN_AGE + " INTEGER)"; public UserDbHelper(Context context) { super(context, DB_NAME, null, DB_VERSION); } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(SQL_CREATE_TABLE); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { db.execSQL("DROP TABLE IF EXISTS " + TABLE_NAME); onCreate(db); } }

注意表名和列名都用常量定义,避免在后面写 SQL 时手抖拼错。主键命名为_id是特意照顾安卓的 ListView 和 CursorAdapter 适配器,这些适配器默认从 Cursor 里找_id列,如果你用别的名字,后面绑定视图还得加映射配置,很麻烦。

3.3 UserProvider:UriMatcher 注册和 CRUD 实现

现在写 Provider 主体。我给出核心代码,然后逐一解释每段的意图。

public class UserProvider extends ContentProvider { private static final String AUTHORITY = "com.example.providerdemo.userprovider"; private static final String PATH_USER = "user"; private static final String PATH_USER_ID = "user/#"; public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/" + PATH_USER); private static final int CODE_USER = 1; private static final int CODE_USER_ID = 2; private static final UriMatcher MATCHER = new UriMatcher(UriMatcher.NO_MATCH); static { MATCHER.addURI(AUTHORITY, PATH_USER, CODE_USER); MATCHER.addURI(AUTHORITY, PATH_USER_ID, CODE_USER_ID); } private UserDbHelper dbHelper; @Override public boolean onCreate() { dbHelper = new UserDbHelper(getContext()); return true; } @Nullable @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { int code = MATCHER.match(uri); SQLiteDatabase db = dbHelper.getReadableDatabase(); switch (code) { case CODE_USER: return db.query(TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder); case CODE_USER_ID: String id = uri.getLastPathSegment(); return db.query(TABLE_NAME, projection, "_id=?", new String[]{id}, null, null, sortOrder); default: throw new IllegalArgumentException("Unknown URI: " + uri); } } @Nullable @Override public Uri insert(Uri uri, ContentValues values) { int code = MATCHER.match(uri); if (code != CODE_USER) { throw new IllegalArgumentException("Insert not supported: " + uri); } SQLiteDatabase db = dbHelper.getWritableDatabase(); long rowId = db.insert(TABLE_NAME, null, values); if (rowId > 0) { Uri resultUri = ContentUris.withAppendedId(CONTENT_URI, rowId); getContext().getContentResolver().notifyChange(resultUri, null); return resultUri; } throw new IllegalStateException("Failed to insert row"); } @Override public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) { int code = MATCHER.match(uri); SQLiteDatabase db = dbHelper.getWritableDatabase(); int rows; switch (code) { case CODE_USER: rows = db.update(TABLE_NAME, values, selection, selectionArgs); break; case CODE_USER_ID: String id = uri.getLastPathSegment(); rows = db.update(TABLE_NAME, values, "_id=?", new String[]{id}); break; default: throw new IllegalArgumentException("Unknown URI: " + uri); } if (rows > 0) { getContext().getContentResolver().notifyChange(uri, null); } return rows; } @Override public int delete(Uri uri, String selection, String[] selectionArgs) { int code = MATCHER.match(uri); SQLiteDatabase db = dbHelper.getWritableDatabase(); int rows; switch (code) { case CODE_USER: rows = db.delete(TABLE_NAME, selection, selectionArgs); break; case CODE_USER_ID: String id = uri.getLastPathSegment(); rows = db.delete(TABLE_NAME, "_id=?", new String[]{id}); break; default: throw new IllegalArgumentException("Unknown URI: " + uri); } if (rows > 0) { getContext().getContentResolver().notifyChange(uri, null); } return rows; } @Nullable @Override public String getType(Uri uri) { int code = MATCHER.match(uri); switch (code) { case CODE_USER: return "vnd.android.cursor.dir/" + AUTHORITY + "." + PATH_USER; case CODE_USER_ID: return "vnd.android.cursor.item/" + AUTHORITY + "." + PATH_USER; default: throw new IllegalArgumentException("Unknown URI: " + uri); } } }

这段代码信息量很大,我拆成几个要点讲。

第一,UriMatcher的初始化放在 static 块里,因为它和具体对象无关,整个类只需要一份匹配规则。NO_MATCH是默认返回值,如果没匹配到任何注册规则,匹配结果就是 -1。我习惯在 switch 里给 default 抛异常,这样有问题能第一时间暴露,而不是静默失败。

第二,getLastPathSegment()用来取 uri 最后一段路径。比如content://authority/user/5调用后返回字符串 "5"。注意拿到的是 String,直接拼进 SQL 参数,但别直接拼 SQL 语句,要用selectionArgs占位符。这既是为了防止 SQL 注入,也是为了让查询计划器更高效地复用语句。

第三,insert方法里我调用ContentUris.withAppendedId()把新生成的行号追加到 uri 后面,这样调用方能立刻知道自己创建的记录是第几条。同时,我用notifyChange()通知观察者数据变了。如果你打算配合 CursorObserver 做 UI 自动刷新,这行必不可少;不做观察者可以先忽略。

第四,getType()方法比较容易被忘掉。它返回 MIME 类型,在 Intent 匹配和 ContentProvider 间调用时系统会用到。规则是:单条数据用vnd.android.cursor.item/前缀,数据集用vnd.android.cursor.dir/前缀。虽然是约定俗成,但建议按标准写,省得某些系统组件不认识。

3.4 调用方 ClientDemo:通过 ContentResolver 读写数据

现在写 Client 侧,代码简洁很多。

public class MainActivity extends AppCompatActivity { private static final String PROVIDER_URI = "content://com.example.providerdemo.userprovider/user"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); ContentResolver resolver = getContentResolver(); Uri uri = Uri.parse(PROVIDER_URI); // 插入一条数据 ContentValues values = new ContentValues(); values.put("name", "小明"); values.put("age", 20); Uri insertedUri = resolver.insert(uri, values); Log.d("ClientDemo", "inserted: " + insertedUri); // 查询全部数据 Cursor cursor = resolver.query(uri, null, null, null, null); if (cursor != null) { while (cursor.moveToNext()) { long id = cursor.getLong(cursor.getColumnIndexOrThrow("_id")); String name = cursor.getString(cursor.getColumnIndexOrThrow("name")); int age = cursor.getInt(cursor.getColumnIndexOrThrow("age")); Log.d("ClientDemo", "id=" + id + ", name=" + name + ", age=" + age); } cursor.close(); } // 把 id 为 1 的数据的 age 改成 21 ContentValues updateValues = new ContentValues(); updateValues.put("age", 21); int updatedRows = resolver.update(uri, updateValues, "_id=?", new String[]{"1"}); Log.d("ClientDemo", "updated rows: " + updatedRows); // 删除 id 为 2 的数据 int deletedRows = resolver.delete(uri, "_id=?", new String[]{"2"}); Log.d("ClientDemo", "deleted rows: " + deletedRows); } }

这段代码看起来就是普通的数据库操作,只不过查询方法变成了 ContentResolver。这里有个隐藏细节:cursor.getColumnIndexOrThrow("name")比getColumnIndex("name")安全得多。后者如果列不存在会返回 -1,传给 getString 会抛异常;前者虽然也抛,但抛得早,能让你马上发现问题,而且代码更短。

还有一件事必须做:在 ClientDemo 的 Manifest 里,不需要注册任何 ContentProvider,因为 Client 只是调用方。但如果 Provider 声明了权限保护,Client 得申请对应权限。我这个示例没加权限限制,所以能直接访问。实际项目里请你务必加权限,否则任意应用只要知道你的 authority 和数据格式就能读走用户数据,这跟裸奔没什么区别。

3.5 运行效果和进程验证

把 ProviderDemo 和 ClientDemo 同时装上模拟器或真机,先打开 ProviderDemo,点按钮插入两条测试数据,然后打开 ClientDemo。你会看到日志里输出了插入结果和查询结果。为了确认数据确实跨进程了,可以在 ProviderDemo 里加个按钮,点击后用同一个 DbHelper 查询数据库,能看到 ClientDemo 插入的数据也在里面。两边操作的是同一个物理数据库文件,这就说明数据共享成功了。

想更直观地感受"跨进程",可以在 ProviderDemo 的 query 方法里把 Binder 调用方的 pid 和 uid 打出来:

Log.d("UserProvider", "caller pid=" + Binder.getCallingPid() + ", uid=" + Binder.getCallingUid());

然后分别从 ProviderDemo 内部和 ClientDemo 调用 query,对比日志,你会发现 pid 不同,这就直接证明了 ContentProvider 是通过进程间通信在服务外部调用的。这个方法我强烈建议你试一下,理解 Binder 对理解安卓系统帮助巨大。

4. 权限控制、线程模型和多进程适配的进阶细节

4.1 自定义权限:为什么不能裸奔

前面说到要加权限,这里展开。先在 ProviderDemo 的 Manifest 里定义一个新权限:

<permission android:name="com.example.providerdemo.permission.READ_USER" android:protectionLevel="normal" />

然后在 Provider 的注册里引用:

<provider android:name=".UserProvider" android:authorities="com.example.providerdemo.userprovider" android:readPermission="com.example.providerdemo.permission.READ_USER" android:writePermission="com.example.providerdemo.permission.WRITE_USER" android:exported="true" />

ClientDemo 这边使用权限:

<uses-permission android:name="com.example.providerdemo.permission.READ_USER" />

这样一来,任何没有声明READ_USER权限的应用在执行 query 时都会收到SecurityException。注意权限的protectionLevel,如果是dangerous级别,还需要运行时动态申请;normal级别则安装时自动授权。根据你的数据类型选择合适级别,涉及用户隐私信息的通讯录、位置这类数据通常是 dangerous。

这里有个非常容易踩的坑:Provider 在exported="true"的情况下如果没有设置任何 readPermission/writePermission,那么所有应用都能访问。安卓没有默认给 ContentProvider 开启"仅同签名应用可访问"的能力(虽然你可以通过 signature 权限实现),所以每次写 Provider 都要先问自己一句:"这个数据被别人乱读是否有风险?"如果答案是肯定的,权限必须配好。

4.2 主线程和 ANR:ContentProvider 方法执行在哪条线程

ContentProvider 的query、insert这些方法运行在哪个线程?答案是:它运行在 Provider 所在进程的 Binder 线程池线程中,并不一定是主线程。这意味着你在这些方法里做耗时数据库操作,理论上不会直接卡住 UI 线程。但别高兴太早,Binder 线程池线程数量有限,如果大量外部请求同时涌进来,或者某个请求里做了网络请求这种极慢操作,仍然会拖垮整个进程。所以最佳实践是:Provider 内部不要发起网络请求,不要做耗时超过几百毫秒的操作;如果确实有复杂计算,自己维护一个单线程 Executor 来异步处理,然后通过返回结果通知调用方。

从调用方角度,resolver.query()是同步阻塞方法。如果你在 UI 线程里直接调用,而 Provider 那边恰好执行了一个慢查询,你的 UI 就会卡住,甚至触发 ANR。因此,正式项目里调用方必须把 query/insert/update/delete 放到子线程执行。现在主流的做法是配合 Kotlin 协程或者 RxJava,把这块逻辑包起来。我写了个简单粗暴的示范:

new Thread(() -> { Cursor cursor = resolver.query(uri, null, null, null, null); // 处理 cursor }).start();

虽然线程管理和生命周期很粗糙,但至少不会卡 UI。更规范的是用 Loader 或 Paging,不过那是另一篇文章的内容了。

4.3 notifyChange 和数据观察:让 UI 自动感知变化

ContentProvider 有一套观察者机制,数据变更时调用notifyChange(uri, observer),系统会通知所有通过resolver.registerContentObserver()注册了该 uri 的观察者。这套机制非常有用,比如列表页读到了数据,用户另一个页面修改了同一条数据,列表页不需要手动刷新,只要监听了变化就能收到回调并自动刷新。

注册和注销观察者要注意配对。在 Activity 里注册,就一定要在onDestroy或onStop里注销,否则会内存泄漏和多余回调。有个细节:ContentResolver的unregisterContentObserver()需要传入同一个 observer 实例,所以别在匿名内部类里随手 new 一个就完事,要保存引用。

另一个细节是notifyChange的 uri 匹配规则。观察者注册时可以传入一个 uri,比如注册content://authority/user,而 provider 里 notify 用的是content://authority/user/3,系统会不会通知?虽然大多数情况下会,但并不是精确匹配。registerContentObserver有一个notifyForDescendants参数,如果你传 true,那么所有以注册 uri 为前缀的变更都会通知;传 false 则需要精确匹配。很多人忽略这个参数,导致通知不达预期,建议统一用 true 来监听整个数据集。

4.4 批量操作用 ContentProviderOperation:别再循环调用 insert

如果你需要在循环里插入几百条数据,别一条条调用resolver.insert,那样每次 insert 都是一次完整的 Binder 往返,性能差得离谱。正确的姿势是用ContentProviderOperation构建一个操作列表,然后调用resolver.applyBatch(authority, operations)一次性提交。

ArrayList<ContentProviderOperation> ops = new ArrayList<>(); for (int i = 0; i < 100; i++) { ContentValues values = new ContentValues(); values.put("name", "user" + i); values.put("age", i); ops.add(ContentProviderOperation.newInsert(CONTENT_URI).withValues(values).build()); } ContentProviderResult[] results = resolver.applyBatch("com.example.providerdemo.userprovider", ops);

applyBatch会在 Provider 进程里按顺序执行这些操作,性能损耗比逐条 insert 小一个数量级。要注意的是,如果 Provider 内部没有对 applyBatch 做特殊处理,默认每条操作仍然会调用对应的 insert 方法一次,好处是节省了客户端到服务端的跨进程次数,而不是数据库层面的事务。若想保证完整的事务性,可以在 Provider 里重写applyBatch,或者用db.beginTransaction()包住整个循环,这部分需要根据你的 Provider 是否涉及多库写入来取舍。

5. 常见问题与排查技巧实录

5.1 运行时明明装了 Provider 应用,却提示找不到远程服务

这个问题通常发生在两个应用都安装了,但 Provider 的 authority 写错了。最常见的情况是 Manifest 里的 authorities 和 Provider 类中定义的 AUTHORITY 不一致。比如你图省事把 AUTHORITY 写成 "com.example.provider" 而 Manifest 里写的是 "com.example.providerdemo",于是调用方用后者,Provider 的 UriMatcher 匹配不到,直接抛 IllegalArgumentException。

排查方法很简单,在 Provider 的onCreate里打一行日志打印getContext().getPackageName(),再检查一下 Manifest 里的注册;或者使用adb shell dumpsys package com.example.providerdemo看 provider 的实际 authorities,确认无误后对比你的调用 uri。

还有种情况是 Provider 应用进程被杀,系统会在有请求时自动重新拉起 Provider 所在的进程,这是系统行为,理论上不需要你操心。但如果你的 Provider 依赖全局 Application 里初始化的一些数据,而 Application 因为某种原因初始化失败,Provider 自然无法工作。这种问题隐蔽,建议把所有初始化逻辑统一管理,并打日志确认执行顺序。

5.2 查询结果是 Cursor,为什么resolver.query()返回 null

安卓文档里明确说过,ContentResolver.query 理论上可能返回 null,但大多数系统 Provider 会在无数据时返回一个空 Cursor 而不是 null。如果你自己实现 Provider 时返回 null,客户端遍历moveToNext()之前没有判空,就会 NullPointerException。

解决方式是在 Provider 实现里永远不要返回 null。查询不到数据可以返回new CursorWrapper或者直接返回数据库查询结果,SQLiteDatabase 的 query 方法即使无数据也会返回一个非 null 的 Cursor。你自己的 Provider 里如果使用内存数据源,建议用MatrixCursor配合空 rows 返回。

5.3 数据可以 insert 但 update 和 delete 不影响记录

这个问题的根子多半是 selection 拼接错了。很多人在 update 时想更新某一特定行,却把 selection 写成"_id = 1",然后 selectionArgs 传 null 或空数组,倒也能跑,但一旦改成动态 id,就容易写成"_id = ?"却忘记传参数。另一个常见坑是uri.getLastPathSegment()拿到的是字符串 id,但表里的_id列是 INTEGER 类型,直接拼"_id=?"传字符串,SQLite 在大多数情况下会自动转换,但遇到复杂查询或索引时可能导致性能降低,甚至匹配不上索引。

排查这类问题,建议先在 Provider 内部把收到的 uri、selection、selectionArgs 全打出来,再手动用同参数执行 SQL。最好是直接对数据库文件用 adb shell 敲 sqlite3 命令验证,立即能看出是逻辑问题还是类型问题。

5.4 权限加了之后,调用方依然能访问

出现这种情况,看看你的 Provider 是不是声明了android:exported="false"。如果 exported 是 false,即使你声明了权限,外部应用只要直接指定组件方式访问,系统甚至不会把请求送达 Provider,而是直接在 Binder 层拒绝,这种情况的表现反而像是"权限没生效"。还有一个低级错误:<permission>标签写在了<application>内部而不是<manifest>根节点下,导致权限定义失效,编译时也不报错。

5.5 表格速查:ContentProvider 常见错误一览

现象可能原因排查思路
调用方抛 SecurityExceptionProvider 权限未授予或权限级别定义不对检查 Manifest 的 uses-permission、检查权限 protectionLevel
Uri 匹配异常authority 或 path 不一致打印 UriMatcher.match 返回值,对比注册规则
insert 返回的 uri 无 id忘了 withAppendedId查看 insert 方法日志
数据变化后 UI 不刷新没调用 notifyChange 或观察者 unregister 过早确认 notifyChange 在 insert/update/delete 中被触发
内容能读不能写只配了 readPermission 没配 writePermission检查 provider 标签
applyBatch 抛 OperationApplicationExceptionProvider 对某个操作不支持查看异常堆栈中的操作类型,确认对应方法实现正确
第一次调用特别慢Provider 的 onCreate 中做了耗时操作优化 onCreate 逻辑,延迟初始化

6. 我也踩过的几个坑,给你提个醒

写这套示例代码的时候,我中途踩过两个有意思的坑。第一次是 ClientDemo 的 Manifest 忘记写任何权限,而 Provider 侧配了一个 signature 级别的自定义权限,运行后 Client 直接 SecurityException。这种问题在开发期被系统拒绝反而好处理,最怕的是权限没作用在 Provider 上,开发时靠运气通了,上线后数据被别人乱读。所以每次写完 Provider 都要做一件事:用另一个全新应用,不声明任何权限去访问它,确认是不是被拦住了。如果没被拦住,说明你的权限配置有问题。

第二个坑是调用了resolver.query却忘了关 Cursor。平时小 Demo 无所谓,但一旦做真实项目,Cursor 泄漏会导致 Binder 资源越积越多,最终报CursorWindow相关的 OOM。我现在写代码已经养成了习惯:凡是从 ContentResolver 拿到的 Cursor,一律在 finally 块里 close,或者让编译器帮我检查。安卓 Studio 的 lint 对Resources和Cursor这类资源有检测,看到黄色的警告千万别忽略。

最后翻回开头那张"用户数据怎么共享"的问题,现在你手里应该已经有了明确答案。ContentProvider 不是唯一方案,但它是最规范、最受系统支持的方案。它有四个显著优点:跨进程安全、接口统一、系统强大(比如权限、notifyChange)、与 SQLite 无缝集成。缺点是给简单场景增加了样板代码,但考虑到它换来的是清晰边界和安全保证,这笔投入完全值。对于初学阶段,建议你除了我这个示例,再把系统通讯录的 ContentProvider 抓一遍,看看它怎么处理多表关联、事务、权限,那会对你的安卓架构观产生很大的正面影响。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询