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帮我们封装好了。
大致流程是这样的:
- 使用方调用
ContentResolver.query() - 系统通过Binder把请求发送到提供方进程
- 提供方的
ContentProvider.query()被调用 - 结果通过Binder返回给使用方
这里有个关键点:Cursor对象本身不能跨进程。系统内部会把Cursor的数据序列化成一个BulkCursorDescriptor,然后通过Binder传输。所以如果你自定义的Cursor太复杂,可能会遇到性能问题。
性能建议:跨进程查询时,尽量只查询需要的字段,不要用SELECT *。我在项目中遇到过因为查询字段太多导致Binder传输超时的问题,后来只查必要字段就解决了。
14.7 知识体系总览
下面这张图总结了ContentProvider的核心知识结构:
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