Android | ContentProvider 筑基篇

点赞关注,不再迷路,你的支持对我意义重大!

🔥 Hi,我是丑丑。本文 「Android 路线」| 导读 —— 从零到无穷大 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一起成长。(联系方式在 GitHub)


前言

  • ContentProvider 是 Android 四大组件之一,属于内容共享型组件;
  • 在这篇文章里,我将分析 ContentProvider 的基本使用方法,在下篇文章里我会介绍 ContentProvider 的原理 & 源码分析。如果能帮上忙,请务必点赞加关注,这真的对我非常重要。

目录


前置知识

这篇文章的内容会涉及以下前置 / 相关知识,贴心的我都帮你准备好了,请享用~

  • Binder 机制: 【点赞催更】

1. 概述

1.1 作用

ContentProvider 是进程间内容共享的统一接口。注意:ContentProvider 的作用不是实现进程间通信,它只是为进程间通信提供了一套统一接口,真正实现进程间通信的是底层的 Binder 机制。

1.2 优点:透明地提供内容

使用 ContentProvider 允许应用透明地将数据开放给其它应用,无论底层数据采用何种实现方式(网络、内存、文件或数据库),外界对于数据的访问方式都是统一的 & 固定的。外界只关心采用 CURD 来访问 ContentProvider 的数据,至于其内部数据的实现是采用文件存储还是数据库存储,外界是不感知的。

1.3 ContentProvider 是单例吗?

通常来说,ContentProvider 是单例的,特殊情况可以设置android:multiprocess属性来决定是不是单例:当属性值为 true 时,每个调用者进程都会存在一个 ContentProvider 实例,官方的解释是可以避免进程间通讯的开销,但是这种方式在实际开发中很少运用。因此我们说一般情况下 ContentProvider 是单例的,只在服务提供进程创建实例。


2. 相关概念

2.1 统一资源标识符(URI)

统一资源标识符(Uniform Resource Indentifier)的作用是 唯一标识 ContentProvider 的数据。在通过 ContentResolver 解析数据时,URI 是必要的参数,其遵循的格式体现在ContentUris.java

Content URIs have the syntax:content://authority/path/id

可以看到,URI 遵循固定的格式,一共分为四个部分:schema://authority/path/id

例如: content://com.xurui/user/1

元素 描述
schema(方案) 固定为 content://
authority(权威) 标识 ContentProvider 的唯一字符串,对应于注册时指定的 android:authority 属性
path(路径) 标识 authority 数据的某些子集
id(记录 id) 标识 path 子集中的某个记录(不指定是标识全部记录)

系统预置了一些 ContentProvider,例如通讯录、媒体资源等,这里举出一些常用的系统 ContentProvider 的 Authority,它们的接口约定定义在目录/android.provider

Authority 描述
com.android.contacts 通讯录
media 媒体
com.android.calendar 日历
user_dictionary 用户词典

2.2 MIME 数据类型

MIME类型(Multipurpose Internal Mail Extensions,多用途互联网邮件扩展类型)是一种互联网标准,用于指定某种扩展名的文件与应用程序的对应关系。一个 MIME 类型分为「主类型」+「子类型」,例如 .html 文件对应的 MIME 类型为 text/html,其中 text 为主类型,html 为子类型。

在 ContentProvider 中,通过 getType(Uri) 方法来确定 URI 对应的 MIME 类型,返回值可以返回 标准 MIME 类型或者自定义 MIME 类型,这是一个抽象方法,需要由子类实现:

ContentProvider.java

public abstract String getType(Uri uri);

2.2.1 标准 MIME 类型

标准 MIME 类型中常见的主类型有:

  • 声音:audio
  • 视频:video
  • 图像:image
  • 文本:text

对应的 MIMIE 类型举例:

扩展名 MIME
.html text/html
.txt text/plain
.png image/png
.jpeg image/jpeg

2.2.2 自定义 MIME 类型

在 Android 中,自定义 MIME 类型的主类型只有两种:

  • vnd.android.cursor.item:单行记录
  • vnd.android.cursor.dir:多行记录(集合)

例如通讯录 ContentProvider 定义了两种 MIME 类型,分别表示多条记录和单条记录:

ContactsContract.java

/**
 * The MIME type of {@link #CONTENT_URI} providing a directory of contact directories.
 */
public static final String CONTENT_TYPE = "vnd.android.cursor.dir/contact_directories";

/**
 * The MIME type of a {@link #CONTENT_URI} item.
 */
public static final String CONTENT_ITEM_TYPE = "vnd.android.cursor.item/contact_directory";

3. 主要方法

ContentProvider 使用表格的形式管理数据,对外暴露四个操作方法,分别是:添加、删除、更新、查询(insert、delete、update、query):

添加数据(Binder 线程)
public abstract Uri insert(Uri uri, ContentValues values);

删除数据(Binder 线程)
public abstract int delete(Uri uri, String selection, String[] selectionArgs);

更新数据(Binder 线程)
public abstract int update(Uri uri, ContentValues values, String selection, String[] selectionArgs);

查询数据(Binder 线程)
public abstract Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder);

除了 4 个核心方法外,ContentProvider 还有其他比较重要的方法,例如:

启动回调(主线程)
public abstract boolean onCreate();

返回 Uri 对应的 MIME 类型(调用线程)
public abstract String getType(Uri uri);

需要注意:四个核心方法执行在 ContentProvider 注册进程,并在 Binder 线程池中执行,而不是主线程。考虑到存在多线程并发访问,为了保证数据安全在实现 ContentProvider 是还需要保证线程同步。而 onCreate() 方法执行在 ContentProvider 注册进程的主线程,因此不能执行耗时操作。关于 onCreate() 方法的调用我在 第 4 节 ContentProvider 的启动过程 中会详细介绍。

主要方法 执行线程
insert() Binder 线程
delete() Binder 线程
update() Binder 线程
query() Binder 线程
onCreate() 主线程

3.1 插入数据

要插入一行新数据,需要使用 ContentProvider#insert(...)。例如,下面程序将一条日程数据插入的系统日历中:

ContentValues eventValues = new ContentValues();
eventValues.put(CalendarContract.Events.CALENDAR_ID, catId); // 日历账号 ID
eventValues.put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().getID()); // 时区
eventValues.put(CalendarContract.Events.DTSTART, beginTimeMillis); // 开始时间
eventValues.put(CalendarContract.Events.DTEND, endTimeMillis); // 结束时间
eventValues.put(CalendarContract.Events.TITLE, title); // 标题
eventValues.put(CalendarContract.Events.DESCRIPTION, description); // 描述
eventValues.put(CalendarContract.Events.EVENT_LOCATION, location); // 地点

Uri resultUri = context.getContentResolver().insert(CalendarContract.Events.CONTENT_URI, eventValues);
if(null == resultUri) {
    // 插入失败
    return;
}

插入成功后会返回该行的 Uri,格式如下:

content://com.android.calendar/events<id_value>

URI 中的 <id_value> 就是该行 _ID 列的值,而前缀 content://com.android.calendar/events 正好就是插入数据时使用的 URI。需要注意的是,你不需要指定数据的 _ID列,该列是表的主键,ContentProvider 会自动维护该列并分配一个唯一值。而要从 Uri 中提取 _ID 列的值,可以调用 ContentUris.parseId(...):

ContentUris.java

public static long parseId(Uri contentUri) {
    String last = contentUri.getLastPathSegment();
    return last == null ? -1 : Long.parseLong(last);
}

提示: 客户端程序并非直接调用 ContentProvider#insert(),而是通过 ContentResolver#insert() 间接调用,下文会提到。

3.2 查询数据

从 ContentProvider 中查询数据的流程主要分为三个步骤:

3.2.1 请求访问权限

ContentProvider 程序可以指定其他应用程序必须具备的权限,例如读取用户词典需要android.permission.READ_USER_DICTIONARY,写入用户词典需要android.permission.WRITE_USER_DICTIONARY

为了获取 ContentProvider 程序所需的权限,你的应用需要在 Manifest 文件中使用 <uses-permission> 来请求它们。当 Android Package Manager 安装 APK 时,会提示用户应用所需要的权限,用户继续安装相当于隐式授予权限。当然了,在 Android 6.0 以后部分权限还需要动态申请。

<uses-permission android:name = “ android.permission.READ_USER_DICTIONARY” > 

3.2.2 构造查询条件

ContentProvider 查询和 SQL 查询是相似的,如下表对比:

ContentProvider 查询 SQL 查询 作用
Uri FROM table_name 查询的数据集合
projection col,col,col... 查询结果所需的列
selectionClause WHERE col = value 选择条件
selectionArgs (没有确切地等效项) 选择条件参数(如果 selection )中使用了 ? 占位符
sortOrder ORDER BY col,col,... 结果集 Cursor 的排序规则
cursor = context .getContentResolver().query(
    UserDictionary.Words.CONTENT_URI,
    projection,
    selectionClause,
    selectionArgs,
    sortOrder);

例如查询手机通讯录:

Uri uri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI;

String[] projection = {
    ContactsContract.Contacts._ID,
    ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
    ContactsContract.CommonDataKinds.Phone.NUMBER
};

String selectionClause = ContactsContract.CommonDataKinds.Phone.NUMBER + " = ?";

String[] selectionArgs = {"123456"};

Cursor cursor = getContentResolver().query(uri, projection, selectionClause, selectionArgs, "sort_key COLLATE LOCALIZED asc");

此查询类似于 SQL 查询:

SELECT _ID, displayName, data1 FROM content://com.android.contacts/data/phones WHERE data1 = "123456" ORDER BY sort_key COLLATE LOCALIZED asc

3.2.3 处理结果集

查询结果是一个 Cursor 对象,处理范例如下:

if (null == mCursor) {
    // 失败
} else if (mCursor.getCount() < 1) {
    // 查询结果为空
} else {
    // 查询结果非空
    while (cursor.moveToNext()) {
        // 联系人名称
        String contractName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
        // 联系人电话
        String phone = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
        ...
    }
    cursor.close(); // 记得关闭结果集
}

3.3 删除数据

删除数据与查询类似,需要构造查询条件,删除操作结束会返回成功删除的行数。

int rowsDeleted = context.getContentResolver().delete(...);

3.4 更新数据

更新操作类似于查询操作和插入操作的结合体,既需要构造 ContentValues 对象,也需要构造查询条件,删除操作结束后返回成功修改的行数。

int rowsUpdated = context.getContentResolver().update(
    UserDictionary.Words.CONTENT_URI, 
    updateValues,
    selectionClause,
    selectionArgs
);

4. ContentProvider 核心类

4.1 ContentResolver

外界(包括当前进程的其他组件)无法直接访问 ContentProvider 的,而是需要通过 ContentResolver 来间接访问。这种设计的优点是 统一管理应用依赖的 ContentProvider,而不需要关心真正操作的 ContentProvider 实现类。

ContentResolver 是一个抽象类,我们熟悉的 Context#getContentResolver() 获得的其实是它的子类 ApplicationContentResolver

Context.java

public abstract ContentResolver getContentResolver();

ContextImpl.java

class ContextImpl extends Context {
    private final ApplicationContentResolver mContentResolver;

    @Override
    public ContentResolver getContentResolver() {
        return mContentResolver;
    }

    private static final class ApplicationContentResolver extends ContentResolver {
        private final ActivityThread mMainThread;
        
        @Override
        protected IContentProvider acquireProvider(Context context, String auth) {
            ...
        }

        @Override
        protected IContentProvider acquireExistingProvider(Context context, String auth) {
            ...
        }
      
        @Override
        public boolean releaseProvider(IContentProvider provider) {
            ...
        }
        ...
    }
}

在文章《Android | ContentProvider 精通篇》中,我会详细介绍 ContentResolver#query(...) 方法的执行过程,在那里我们再讨论 ApplicationContentResolver 方法体中的具体行为。

4.2 ContentUris

ContentUris 是 Uri 的工具类,在 ContentUris 的文档注释中主要描述了 ContentProvider URI 所遵循的格式,此外 ContentUris 还提供了三个工具方法:

1、从 Uri 中解析主键 id
public static long parseId(Uri contentUri) {
    String last = contentUri.getLastPathSegment();
    return last == null ? -1 : Long.parseLong(last);
}

2、向 Uri 追加一个 id
public static Uri.Builder appendId(Uri.Builder builder, long id) {
    return builder.appendEncodedPath(String.valueOf(id));
}

3、向 Uri 追加一个 id
public static Uri withAppendedId(Uri contentUri, long id) {
    return appendId(contentUri.buildUpon(), id).build();
}

4.3 UriMatcher

UriMatcher 是用于自定义 ContentProvider 的工具类,主要作用是根据 Uri 匹配对应的数据表。

public class ExampleProvider extends ContentProvider {

    1、初始化 UriMatcher 对象,NO_MATCH 表示不匹配任何 Uri
    private static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);

    2、注册 Uri 已经对应的返回码
    static {
        uriMatcher.addURI("com.example.app.provider", "table3", 1);
        uriMatcher.addURI("com.example.app.provider", "table3/#", 2);
    }
...
    3、 查询
    public Cursor query(
        Uri uri,
        String[] projection,
        String selection,
        String[] selectionArgs,
        String sortOrder) {

        switch (uriMatcher.match(uri)) {
            case 1:
                3.1 匹配 table3
                if (TextUtils.isEmpty(sortOrder)) sortOrder = "_ID ASC";
                break;
            case 2:
                3.2 匹配 table3/#
                selection = selection + "_ID = " + uri.getLastPathSegment();
                break;
            default:
                3.3 默认
                ...
        }
        3.4 真正执行查询
    }
}

可以使用通配符:

  • *:匹配任意长度字符串
  • #:匹配任意长度的数字字符串

4.4 ContentObserver

ContentObserver .java

子类重写实现监听逻辑
public void onChange(boolean selfChange) {
    // Do nothing.  Subclass should override.
}

public void onChange(boolean selfChange, Uri uri) {
    onChange(selfChange);
}

ContentObserver 用于监听 ContentProvider 中指定 Uri 标识数据的变化(增 / 删 / 改),使用时需要用到 ContentResolver 的两个方法:

ContentResolver.java

注册监听
public final void registerContentObserver(Uri uri, boolean notifyForDescendants, ContentObserver observer) 

注销监听
public final void unregisterContentObserver(ContentObserver observer)

需要注意:ContentProvider 内部需要手动通知修改事件,才能有效回调给 ContentResolver,例如:

ContentProvider 实现类

public class UserContentProvider extends ContentProvider { 
    public Uri insert(Uri uri, ContentValues values) { 
        ...
        通知
        getContext().getContentResolver().notifyChange(uri, null); 
    } 
}

5. 总结

  • ContentProvider 是进程间内容共享的统一接口,底层实现进程间通信的是 Binder 机制,使用 ContentProvider 的优点是透明地提供内容,外界不用关心内容的层的数据实现方式。

  • Uri 的作用是唯一标识 ContentProvider 的数据,MIME 类型描述了扩展名与应用程度的对应关系,例如 .html 对应的 MIME 类型为 text/html;

  • ContentProvider 提供了 CURD 四个核心方法类访问数据,执行在服务提供进程的 Binder 线程池,而 onCreate() 方法执行在服务提供进程主线程


参考资料


创作不易,你的「三连」是丑丑最大的动力,我们下次见!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,636评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,890评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,680评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,766评论 1 271
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,665评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,045评论 1 276
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,515评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,182评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,334评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,274评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,319评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,002评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,599评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,675评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,917评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,309评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,885评论 2 341

推荐阅读更多精彩内容