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_IDprovider 收到请求后通过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 的完整 uriupdate和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:authoritiescom.example.providerdemo.userprovider android:exportedtrue android:grantUriPermissionstrue /这里重点说android:exported。在早期安卓版本里不声明这个属性的 Provider 默认可以被外部应用调用从 Android 12API 31开始如果 Provider 声明了 intent-filter 但没显式设置 exported系统会直接报错。对本例这种没有 intent-filter 的 Provider如果 targetSdkVersion 是 31 以上并且没有显式给 exported 赋值系统同样会拒绝安装。所以我直接写上exportedtrue表示允许其他应用访问。你如果只想让同一签名的应用访问可以设置android:exportedfalse然后配合android:grantUriPermissions做临时授权或者使用android:permission指定权限。这是后话但务必知道有这么个开关。3.2 UserDbHelper基础的 SQLiteOpenHelperProvider 本身不碰数据库细节所以我单独写一个 helperpublic 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 UserProviderUriMatcher 注册和 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:namecom.example.providerdemo.permission.READ_USER android:protectionLevelnormal /然后在 Provider 的注册里引用provider android:name.UserProvider android:authoritiescom.example.providerdemo.userprovider android:readPermissioncom.example.providerdemo.permission.READ_USER android:writePermissioncom.example.providerdemo.permission.WRITE_USER android:exportedtrue /ClientDemo 这边使用权限uses-permission android:namecom.example.providerdemo.permission.READ_USER /这样一来任何没有声明READ_USER权限的应用在执行 query 时都会收到SecurityException。注意权限的protectionLevel如果是dangerous级别还需要运行时动态申请normal级别则安装时自动授权。根据你的数据类型选择合适级别涉及用户隐私信息的通讯录、位置这类数据通常是 dangerous。这里有个非常容易踩的坑Provider 在exportedtrue的情况下如果没有设置任何 readPermission/writePermission那么所有应用都能访问。安卓没有默认给 ContentProvider 开启仅同签名应用可访问的能力虽然你可以通过 signature 权限实现所以每次写 Provider 都要先问自己一句这个数据被别人乱读是否有风险如果答案是肯定的权限必须配好。4.2 主线程和 ANRContentProvider 方法执行在哪条线程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)一次性提交。ArrayListContentProviderOperation 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:exportedfalse。如果 exported 是 false即使你声明了权限外部应用只要直接指定组件方式访问系统甚至不会把请求送达 Provider而是直接在 Binder 层拒绝这种情况的表现反而像是权限没生效。还有一个低级错误permission标签写在了application内部而不是manifest根节点下导致权限定义失效编译时也不报错。5.5 表格速查ContentProvider 常见错误一览现象可能原因排查思路调用方抛 SecurityExceptionProvider 权限未授予或权限级别定义不对检查 Manifest 的 uses-permission、检查权限 protectionLevelUri 匹配异常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 抓一遍看看它怎么处理多表关联、事务、权限那会对你的安卓架构观产生很大的正面影响。
企业数字化 ERP 产品动态
相关推荐
Agent工具调用实战:从Function Calling原理到代码实现 做 Agent 做到第八篇,前面几篇我们聊了框架、规划、状态流转,甚至给 Agent 加了点简单的记忆。但如果你真的动手把项目跑起来,大概率会遇到同一个问题:不管它在对话里怎么能说会道,一旦要它“干点实事”——查一下实时… · 2026/9/26 5:33:32
基于Paimon+StarRocks的轨迹数据统一底座架构实践 如果你在高德这类体量的业务里碰过轨迹数据,大概会有同样的感受:GPS 点本身不复杂,复杂的是它的下游。同一个经纬度坐标,既要支持实时位置查询,又要做历史轨迹回放,还要喂给热力图、ETA、OD 分析、偏航纠偏… · 2026/9/26 5:33:32
Kafka vs RabbitMQ:核心概念模型全面拆解与选型指南 最近有个朋友问我:"你们系统用的Kafka还是RabbitMQ?听说Kafka比RabbitMQ好用,是不是该换?"我听完愣了一下,因为这两个东西虽然都叫消息队列,但底层模型完全是两码事,压根不是"谁… · 2026/9/26 5:33:32
子流程设计:从Dify到ComfyUI,工作流拆分的核心思路与实践 做工作流最怕什么?我最怕打开一个别人搭好的主流程图,密密麻麻几十个节点在画布上纠缠成一大坨,看一眼就头皮发麻。这类流程往往有一个共同特征:所有逻辑都平铺在主流程里,没有分层,没有边界,改… · 2026/9/26 7:18:14
BP神经网络做齿轮箱故障诊断:从振动信号特征提取到Python实战 简介:基于反向传播(BP)神经网络的齿轮箱故障诊断MATLAB源码,面向设备故障诊断与机器学习交叉应用场景,适合机械工程、自动化等专业学生及从事状态监测的工程技术人员。资源包内仅含1个.m脚本,压缩后仅约1KB… · 2026/9/26 7:18:14
纯Python手写逻辑回归:从数学原理到信贷风控可解释建模 简介:本资源是一份面向Python初学者与机器学习入门者的逻辑回归实践项目,聚焦信用卡逾期风险预测这一典型二分类问题,帮助用户掌握从数据探索到模型可视化的完整建模流程。压缩包共3个文件,包含1个结构清晰的Python源码文件&#… · 2026/9/26 7:18:14
工作流子流程创建全攻略:从拆分原则到参数设计与踩坑实录 从事工作流开发这些年,我被问得最多的一个问题是:"主流程越来越长,节点堆了二三十个,每次改一个地方都要小心翼翼,这种情况怎么破?"答案其实很朴素:拆子流程。标题里写的"工作流… · 2026/9/26 7:18:14
Claude Code模板体系实战:从零搭建可复用的AI编程工作流 claude-code-templates这个标题,乍一看只是一个项目名,但拆开来看,它背后是当前AI编程工具链里一个非常关键却又容易被轻视的环节:模板化。我最早接触Claude Code时,和大多数人一样,把它当成一个“加强版终… · 2026/9/26 7:18:14
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46