16、ContentProvider:跨进程数据共享、自定义ContentProvider、访问系统通讯录

说到 ContentProvider,我个人的理解是——它是 Android 四大组件里最「低调」的一个。Activity、Service、BroadcastReceiver 大家天天打交道,但 ContentProvider 呢?很多人只在面试题里见过它。其实,它是跨进程数据共享的基石。

说白了,ContentProvider 就是给别的 App 提供数据的接口。你想想看,你的 App 里有一堆联系人、照片、或者用户配置,别的 App 想读怎么办?直接读数据库?不安全。暴露文件路径?太粗暴。ContentProvider 就是那个「中间人」,它帮你把数据封装好,只暴露你允许的操作。

为什么需要 ContentProvider?

我记得刚入行那会儿,有个需求是要让两个 App 共享用户登录状态。我当时想:简单,写个 SharedPreferences,让另一个 App 直接读不就行了?结果被 Leader 骂了一顿——跨进程读 SharedPreferences 根本不可靠,而且权限控制一塌糊涂。

ContentProvider 解决了三个核心问题:

  • 统一的数据访问接口:不管底层是 SQLite、文件、还是网络,对外都暴露 CRUD 操作。
  • 细粒度的权限控制:可以精确到读、写、甚至某张表的某几列。
  • 跨进程安全:通过 Binder 机制,数据不会因为进程崩溃而丢失。

核心要点:ContentProvider 不是「数据库」,它是「数据访问层」。底层可以是任何存储方式。

自定义 ContentProvider 实战

我们来写一个最简单的自定义 ContentProvider。假设我们有一个 App,里面存了用户的笔记数据,想让其他 App 也能读取。

第一步,继承 ContentProvider 类:

public class NoteProvider extends ContentProvider {

    private static final String AUTHORITY = "com.example.notes.provider";
    private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);

    private static final int NOTES = 1;
    private static final int NOTE_ID = 2;

    static {
        URI_MATCHER.addURI(AUTHORITY, "notes", NOTES);
        URI_MATCHER.addURI(AUTHORITY, "notes/#", NOTE_ID);
    }

    private SQLiteDatabase db;

    @Override
    public boolean onCreate() {
        // 初始化数据库,这里用简化写法
        NoteDbHelper helper = new NoteDbHelper(getContext());
        db = helper.getWritableDatabase();
        return true;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {
        switch (URI_MATCHER.match(uri)) {
            case NOTES:
                return db.query("notes", projection, selection, selectionArgs, null, null, sortOrder);
            case NOTE_ID:
                String id = uri.getLastPathSegment();
                return db.query("notes", projection, "_id=?", new String[]{id}, null, null, null);
            default:
                throw new IllegalArgumentException("Unknown URI: " + uri);
        }
    }

    // 省略 insert、update、delete 方法,结构类似
}

第二步,在 AndroidManifest.xml 中注册:

<provider
    android:name=".NoteProvider"
    android:authorities="com.example.notes.provider"
    android:exported="true"
    android:readPermission="com.example.notes.READ"
    android:writePermission="com.example.notes.WRITE" />

小技巧:我习惯把 authority 定义成常量,放在一个单独的 Contract 类里。这样 Provider 和客户端都引用同一个常量,不容易写错。

访问系统通讯录

系统通讯录是 ContentProvider 最经典的应用场景。我记得有一次做社交 App,需要读取用户的联系人列表来推荐好友。当时踩了不少坑,这里分享给你。

读取通讯录的核心代码:

// 查询联系人
Cursor cursor = getContentResolver().query(
    ContactsContract.Contacts.CONTENT_URI,
    null, null, null, null
);

if (cursor != null) {
    while (cursor.moveToNext()) {
        String name = cursor.getString(
            cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)
        );
        String contactId = cursor.getString(
            cursor.getColumnIndex(ContactsContract.Contacts._ID)
        );

        // 查询该联系人的电话号码
        Cursor phoneCursor = getContentResolver().query(
            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
            null,
            ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?",
            new String[]{contactId},
            null
        );

        if (phoneCursor != null) {
            while (phoneCursor.moveToNext()) {
                String phone = phoneCursor.getString(
                    phoneCursor.getColumnIndex(
                        ContactsContract.CommonDataKinds.Phone.NUMBER
                    )
                );
                // 处理数据...
            }
            phoneCursor.close();
        }
    }
    cursor.close();
}

注意:Android 6.0 以上需要动态申请 READ_CONTACTS 权限。我曾经在线上遇到过用户反馈「读取不到联系人」,排查了半天发现是权限弹窗被用户拒绝了,但代码没有处理拒绝后的逻辑。

ContentProvider 的核心流程

为了让你更直观地理解 ContentProvider 的工作机制,我画了一张图:

ContentProvider 跨进程数据共享流程 App A(客户端) getContentResolver() query / insert / update / delete URI Binder 跨进程 通信 App B(服务端) ContentProvider onCreate → CRUD 数据源 SQLite / 文件 / 网络 App A 通过 ContentResolver 发起请求 → Binder 跨进程传输 → App B 的 ContentProvider 处理 → 返回数据 整个过程对 App A 是透明的,它不知道数据来自哪个进程

ContentProvider 的权限模型

权限控制是 ContentProvider 里最容易出问题的地方。我见过不少 App 因为权限配置不当,导致数据泄露或者功能异常。

权限类型 配置方式 说明
读权限 android:readPermission 客户端需要声明该权限才能执行 query 操作
写权限 android:writePermission 客户端需要声明该权限才能执行 insert/update/delete
临时权限 android:grantUriPermissions 通过 Intent 临时授权,适合一次性访问
路径权限 <path-permission> 对不同的 URI 路径设置不同的权限

经验之谈:如果你只是想让自己的 App 内部使用 ContentProvider,可以把 exported 设为 false。这样其他 App 就无法访问了,省去权限管理的麻烦。

ContentObserver:监听数据变化

ContentProvider 还有一个好搭档——ContentObserver。它可以监听数据的变化,一旦数据变了,立刻通知你。

举个例子,你的 App 显示通讯录列表,如果用户在系统设置里新增了一个联系人,你希望列表自动刷新。这时候 ContentObserver 就派上用场了:

getContentResolver().registerContentObserver(
    ContactsContract.Contacts.CONTENT_URI,
    true,  // 是否监听子路径
    new ContentObserver(new Handler()) {
        @Override
        public void onChange(boolean selfChange) {
            // 数据变了,刷新列表
            loadContacts();
        }
    }
);

嗯,这里要注意一点:onChange 回调是在 Binder 线程池里执行的,不能直接更新 UI。记得 post 到主线程。

避坑指南

最后,分享几个我踩过的坑:

  • Cursor 没有关闭:我曾经有一个功能,每次打开页面都查询通讯录,但忘了关闭 Cursor。结果用户反馈 App 越来越卡,最后 OOM 了。记住:Cursor 用完后一定要 close,或者用 try-with-resources。
  • URI 匹配错误:UriMatcher 的匹配规则是从上到下匹配的。如果你把 NOTES 和 NOTE_ID 的顺序写反了,NOTE_ID 永远匹配不到。我因为这个 bug 排查了整整一个下午。
  • 权限声明遗漏:在 AndroidManifest 里声明了权限,但忘了在代码里动态申请。结果 6.0 以上的设备直接崩溃。现在我已经养成了习惯:所有危险权限都走动态申请流程。

ContentProvider 虽然看起来有点「重」,但它是 Android 跨进程数据共享的标准方案。掌握了它,你就能在多个 App 之间安全、高效地传递数据了。


公众号:蓝海资料掘金营,微信deep3321