14、内容提供者:ContentProvider原理与使用、读取系统联系人、跨进程数据共享、自定义ContentProvider

各位同学,今天我们来聊聊Android四大组件中最低调、但也最容易被忽视的一个——ContentProvider。

说实话,我刚开始做Android开发那会儿,对ContentProvider的理解就是“用来读联系人的东西”。直到后来接手了一个需要给多个App提供数据的基础模块,才真正体会到它的价值。你想想看,如果没有ContentProvider,两个App之间想共享数据,要么写文件、要么用Socket,那得多麻烦?

14.1 ContentProvider是什么?

ContentProvider,说白了就是Android系统提供的一套跨进程数据共享机制。它让一个App可以把内部数据(比如数据库、文件)暴露给其他App访问,同时还能保证安全性。

它的核心工作模式是这样的:

  • 提供方:实现ContentProvider,暴露数据接口
  • 使用方:通过ContentResolver调用接口,获取数据
  • 中间人:Android系统负责进程间通信(IPC)

核心要点:ContentProvider底层基于Binder机制实现跨进程通信。你不需要关心Binder怎么用,只需要按照ContentProvider的规范写代码就行。

我个人习惯把ContentProvider理解成一个“数据路由器”。它接收URI(统一资源标识符),然后根据URI找到对应的数据表,执行增删改查操作。

14.2 URI:ContentProvider的“门牌号”

每个ContentProvider都有一个唯一的Authority(授权标识),就像你家门牌号一样。完整的URI格式是这样的:

content://authority/path/id

举个例子:

  • content://com.example.myprovider/students → 访问所有学生数据
  • content://com.example.myprovider/students/1 → 访问id为1的学生数据

嗯,这里要注意:URI中的content://是固定前缀,不能改。后面的路径你可以自己定义,但最好保持语义清晰。

14.3 读取系统联系人:实战入门

我们先来一个最经典的实战——读取手机联系人。这个需求几乎每个App都会遇到,我当年做通讯录App时就被这个坑过。

// 获取ContentResolver
ContentResolver resolver = getContentResolver();

// 联系人URI
Uri uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI;

// 查询字段
String[] projection = new String[]{
    ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
    ContactsContract.CommonDataKinds.Phone.NUMBER
};

// 执行查询
Cursor cursor = resolver.query(uri, projection, null, null, null);

if (cursor != null) {
    while (cursor.moveToNext()) {
        String name = cursor.getString(cursor.getColumnIndex(
            ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
        String phone = cursor.getString(cursor.getColumnIndex(
            ContactsContract.CommonDataKinds.Phone.NUMBER));
        Log.d("Contact", "姓名:" + name + ",电话:" + phone);
    }
    cursor.close();
}

避坑指南:我曾经在读取联系人时忘记加READ_CONTACTS权限,结果在Android 6.0以上的设备上直接崩溃。记住:运行时权限一定要在查询之前申请,否则会抛出SecurityException。

另外,Cursor用完后一定要关闭。我见过不少新手写代码不关Cursor,结果内存泄漏到App卡死。你可以用try-with-resources或者直接在finally里关闭。

14.4 自定义ContentProvider:从零开始

现在我们来自己写一个ContentProvider。假设我们有一个学生数据库,需要暴露给其他App使用。

第一步:创建数据库帮助类

public class StudentDbHelper extends SQLiteOpenHelper {
    private static final String DB_NAME = "school.db";
    private static final int DB_VERSION = 1;

    public static final String TABLE_STUDENTS = "students";
    public static final String COL_ID = "_id";
    public static final String COL_NAME = "name";
    public static final String COL_SCORE = "score";

    public StudentDbHelper(Context context) {
        super(context, DB_NAME, null, DB_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
        String sql = "CREATE TABLE " + TABLE_STUDENTS + " (" +
                COL_ID + " INTEGER PRIMARY KEY AUTOINCREMENT, " +
                COL_NAME + " TEXT, " +
                COL_SCORE + " INTEGER)";
        db.execSQL(sql);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        db.execSQL("DROP TABLE IF EXISTS " + TABLE_STUDENTS);
        onCreate(db);
    }
}

第二步:实现ContentProvider

public class StudentProvider extends ContentProvider {

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

    private static final int CODE_STUDENTS = 1;
    private static final int CODE_STUDENT_ID = 2;

    static {
        URI_MATCHER.addURI(AUTHORITY, "students", CODE_STUDENTS);
        URI_MATCHER.addURI(AUTHORITY, "students/#", CODE_STUDENT_ID);
    }

    private StudentDbHelper dbHelper;

    @Override
    public boolean onCreate() {
        dbHelper = new StudentDbHelper(getContext());
        return true;
    }

    @Override
    public Cursor query(Uri uri, String[] projection, String selection,
                        String[] selectionArgs, String sortOrder) {
        SQLiteDatabase db = dbHelper.getReadableDatabase();
        Cursor cursor;

        switch (URI_MATCHER.match(uri)) {
            case CODE_STUDENTS:
                cursor = db.query(StudentDbHelper.TABLE_STUDENTS,
                        projection, selection, selectionArgs, null, null, sortOrder);
                break;
            case CODE_STUDENT_ID:
                String id = uri.getLastPathSegment();
                cursor = db.query(StudentDbHelper.TABLE_STUDENTS,
                        projection, "_id=?", new String[]{id}, null, null, sortOrder);
                break;
            default:
                throw new IllegalArgumentException("Unknown URI: " + 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(StudentDbHelper.TABLE_STUDENTS, null, values);

        // 通知数据变化
        getContext().getContentResolver().notifyChange(uri, null);
        return ContentUris.withAppendedId(uri, id);
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        int count = db.delete(StudentDbHelper.TABLE_STUDENTS, selection, selectionArgs);
        if (count > 0) {
            getContext().getContentResolver().notifyChange(uri, null);
        }
        return count;
    }

    @Override
    public int update(Uri uri, ContentValues values, String selection,
                      String[] selectionArgs) {
        SQLiteDatabase db = dbHelper.getWritableDatabase();
        int count = db.update(StudentDbHelper.TABLE_STUDENTS, values, selection, selectionArgs);
        if (count > 0) {
            getContext().getContentResolver().notifyChange(uri, null);
        }
        return count;
    }

    @Override
    public String getType(Uri uri) {
        switch (URI_MATCHER.match(uri)) {
            case CODE_STUDENTS:
                return "vnd.android.cursor.dir/vnd.com.example.student";
            case CODE_STUDENT_ID:
                return "vnd.android.cursor.item/vnd.com.example.student";
            default:
                return null;
        }
    }
}

第三步:在AndroidManifest.xml中注册

<provider
    android:name=".StudentProvider"
    android:authorities="com.example.school.studentprovider"
    android:exported="true" />

个人经验android:exported="true"这个属性一定要显式设置。在Android 12以上,如果targetSdkVersion>=31,不设置exported会直接报错。我有个项目就是因为这个被Google Play拒绝了。

14.5 跨进程数据共享:其他App怎么用?

现在我们的StudentProvider已经暴露出来了,其他App想读取数据,只需要通过ContentResolver调用即可:

// 在其他App中读取数据
ContentResolver resolver = getContentResolver();
Uri uri = Uri.parse("content://com.example.school.studentprovider/students");

Cursor cursor = resolver.query(uri, null, null, null, null);
if (cursor != null) {
    while (cursor.moveToNext()) {
        String name = cursor.getString(cursor.getColumnIndex("name"));
        int score = cursor.getInt(cursor.getColumnIndex("score"));
        Log.d("Remote", "学生:" + name + ",分数:" + score);
    }
    cursor.close();
}

你看,使用方完全不需要关心数据是存在SQLite还是文件里,甚至不需要知道数据在哪个进程。这就是ContentProvider的魅力——数据访问与数据存储完全解耦

14.6 ContentProvider原理简析

为什么ContentProvider能跨进程?底层靠的是Binder。但Binder用起来很复杂,ContentProvider帮我们封装好了。

大致流程是这样的:

  1. 使用方调用ContentResolver.query()
  2. 系统通过Binder把请求发送到提供方进程
  3. 提供方的ContentProvider.query()被调用
  4. 结果通过Binder返回给使用方

这里有个关键点:Cursor对象本身不能跨进程。系统内部会把Cursor的数据序列化成一个BulkCursorDescriptor,然后通过Binder传输。所以如果你自定义的Cursor太复杂,可能会遇到性能问题。

性能建议:跨进程查询时,尽量只查询需要的字段,不要用SELECT *。我在项目中遇到过因为查询字段太多导致Binder传输超时的问题,后来只查必要字段就解决了。

14.7 知识体系总览

下面这张图总结了ContentProvider的核心知识结构:

ContentProvider 知识体系 ContentProvider 底层原理:Binder IPC 核心组件:URI + UriMatcher + ContentResolver 四大操作:query / insert / update / delete 实战场景 读取系统联系人 | 自定义Provider | 跨进程共享 原理篇 组件篇 操作篇 实战篇

14.8 避坑总结

最后,我把这些年踩过的坑总结一下:

问题 原因 解决方案
查询返回null URI写错或Authority不匹配 检查AndroidManifest中的authorities
跨进程数据不更新 没有调用notifyChange 增删改后必须通知变化
权限异常 缺少运行时权限或exported未设置 检查权限声明和exported属性
Cursor泄漏 查询后未关闭Cursor 使用try-finally或try-with-resources

好了,关于ContentProvider的内容就讲到这里。记住一句话:ContentProvider是Android跨进程数据共享的标准方案。虽然平时用得不多,但一旦需要,它就是最优雅的选择。

最后一个小建议:如果你只是想在同一个App的不同组件间共享数据,用SharedPreferences或者Room就够了,没必要上ContentProvider。杀鸡焉用牛刀嘛。


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