Android 进阶11:进程通信之 ContentProvider 内容提供者

时间:2022-07-30 17:02:46

学习启舰大神,每篇文章写一句励志的话,与大家共勉。

  • When you are content to be simply yourself and don’t compare or compete, everyone will respect you.
  • 当你满足于做自己而不去比较或竞争时,每个人都会尊重你。

读完本文你将了解:

ContentProvider 简介

作为安卓 F4,ContentProvider 其实是比较低调的一个,日常开发中使用的频率也没那三位多。

它的诞生就是为了给不同应用提供内容访问,自然在我们研究的“多进程通信方式”之中。

ContentProvider 封装了数据的跨进程传输,我们可以直接使用 getContentResolver() 拿到 ContentResolver 进行增删改查即可。

ContentProvider 以一个或多个表(与在关系型数据库中的表类似)的形式将数据呈现给外部应用。 行表示提供程序收集的某种数据类型的实例,行中的每个列表示为实例收集的每条数据。

实现一个 ContentProvider 时需要实现以下几个方法:

  • onCreate():初始化 provider
  • query():查询数据
  • insert():插入数据到 provider
  • update():更新 provider 的数据
  • delete():删除 provider 中的数据
  • getType():返回 provider 中的数据的 MIME 类型

注意:

1. onCreate() 默认执行在主线程,别做耗时操作,query() 也最好异步执行

2. 上面的 4 个增删改查操作都可能会被多个线程并发访问,因此需要注意线程安全

ContentProvider 与 URI

ContentProvider 使用 URI 标识要操作的数据,这里的内容 URI 主要包括两部分:

  1. authority:整个提供程序的符号名称
  2. path:指向表的名称/路径

内容 URI 统一的形式就是:

content://authority/path

例如:

content://user_dictionary/words

当你调用 ContentResolver 方法来访问 ContentProvider 中的表时,需要传递要操作表的 URI。

在通过 ContentResolver 进行数据请求时(比如 contentResolver.insert(uri, contentValues);), 系统会检查指定 URI 的 authority 信息,然后将请求传递给注册监听这个 authority 的 ContentProvider 。这个 ContentProvider 可以监听 URI 想要操作的内容,Android 中为我们提供了 UriMatcher 来解析 URI。

权限

由于内容提供者要被不同应用访问,因此权限必不可少。我们可以给内容提供者设置 “读/写”权限。

设置自定义权限分三步:

  1. 向系统声明一个权限
  2. 给要设置权限的组件设置需要这个权限
  3. 在想要使用上述组件的应用中注册这个权限

先定义权限

<!--在系统中注册读内容提供者的权限-->
<permission
    android:name="top.shixinzhang.permission.READ_CONTENT"    //指定权限的名称
    android:label="Permission for read content provider"
    android:protectionLevel="normal"
    />

其中 android:protectionLevel可选的值主要如下:

  • normal:低风险,任何应用都可以申请,在安装应用时,不会直接提示给用户
  • dangerous:高风险,系统可能要求用户输入相关信息才授予权限,任何应用都可以申请,在安装应用时,会直接提示给用户
  • signature:只有和定义了这个权限的 apk 用相同的私钥签名的应用才可以申请该权限
  • signatureOrSystem:有两种应用可以申请该权限
    • 和定义了这个权限的 apk 用相同的私钥签名的应用
    • 在 /system/app 目录下的应用

这里我们设置的值为 normal

给 provider 中设置读权限

这里设置的 readPermission 为上面声明的值:

<provider
    android:name=".provider.IPCPersonProvider"
    android:authorities="net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider"
    android:exported="true"
    android:grantUriPermissions="true"
    android:process=":provider"
    android:readPermission="top.shixinzhang.permission.READ_CONTENT">

这个权限无法在运行时请求,必须在清单文件中使用 <uses-permission> 元素和内容提供者定义的准确权限名称指明你的权限。

在应用中注册这个权限

<uses-permission android:name="top.shixinzhang.permission.READ_CONTENT"/>

在您的清单文件中指定此元素后,您将有效地为应用“请求”此权限。 用户安装您的应用时,会隐式授予允许此请求。

官方建议:

对于同一开发者提供的不同应用之间的 IPC 通信,最好将 android:protectionLevel 属性设置为 “signature” 保护级别。签名权限不需要用户确认,因此,这种方式不仅能提升用户体验,而且在相关应用使用相同的密钥进行签名来访问数据时,还能更好地控制对内容提供程序数据的访问。

支持的数据类型

Android 本身包括的内容提供程序可管理音频、视频、图像和个人联系信息等数据。

内容提供者可以提供多种不同的数据类型:

  • int
  • long
  • double
  • float
  • BLOB:作为 64KB 字节的数组的二进制大型对象

使用二进制大型对象 (BLOB) 数据类型存储大小或结构会发生变化的数据。

例如,您可以使用 BLOB 列来存储协议缓冲区或 JSON 结构。

之前反编译微信时,保存朋友圈的数据就是 BLOB 类型。

ContentProvider 还会维护其定义的每个内容 URI 的 MIME 数据类型信息。

你可以使用 MIME 类型信息确定应用是否可以处理 ContentProvider 提供的数据,或根据 MIME 类型选择处理类型。

在使用包含复杂数据结构或文件的提供程序时,通常需要 MIME 类型。

ContentProvider 的使用

ContentProvider 的使用分为以下 4 步:

  1. 设计数据存储
    • 选择文件还是数据库
    • 如果您想提供 Bitmap 或其他庞大的文件导向型数据,请将数据存储在一个文件中,然后间接提供这些数据,而不是直接将其存储在表中
    • 使用二进制大型对象 (BLOB) 数据类型存储大小或结构会发生变化的数据。 例如使用 BLOB 列来存储 JSON
  2. 创建 ContentProvider 子类,实现关键方法
    • ContentProvider 实例通过处理来自其他应用的请求来管理对结构化数据集的访问
    • 所有形式的访问最终都会调用 ContentResolver,后者接着调用 ContentProvider 的具体方法来获取访问权限
    • 注意文章开头提到的避免耗时操作和线程安全
    • 尽管必须实现这些方法,它们的返回值并不重要,只要返回符合要求的数据类型即可,即使不执行任何其他操作
  3. 定义提供程序的授权字符串(authority)、内容 URI 以及列名称
    • 对应前面设计的数据库表名和字段名
    • 如果想让内容提供者应用处理 Intent,则还要定义 Intent 操作、Extra 数据以及标志
    • 还要定义想要访问该数据的应用必须具备的权限
  4. 通过 ContentResolver 和 URI 进行增删改查

下面以一个例子实验一下。

设计数据存储

这里我们使用 SQLite 存储数据,创建一个数据库帮助类:

public class DbOpenHelper extends SQLiteOpenHelper {

    private final static String DB_NAME = "person_list.db";
    public final static String TABLE_NAME = "person";
    private final static int DB_VERSION = 1;
    private final String SQL_CREATE_TABLE = "create table if not exists " + TABLE_NAME + "(_id integer primary key, name TEXT, description TEXT)";

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

    @Override
    public void onCreate(final SQLiteDatabase db) {
        db.execSQL(SQL_CREATE_TABLE);
    }

    @Override
    public void onUpgrade(final SQLiteDatabase db, final int oldVersion, final int newVersion) {

    }
}

上面的代码创建了数据库 person_listperson 表。

创建 ContentProvider 子类

public class IPCPersonProvider extends ContentProvider {
    private final String TAG = this.getClass().getSimpleName();
    private static final UriMatcher mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
    public static final String AUTHORITY = "net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider";  //授权
    public static final Uri PERSON_CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/person");

    private SQLiteDatabase mDatabase;
    private Context mContext;
    private String mTable;

    private static final int TABLE_CODE_PERSON = 2;

    static {
        //关联不同的 URI 和 code,便于后续 getType
        mUriMatcher.addURI(AUTHORITY, "person", TABLE_CODE_PERSON);
    }

    @Override
    public boolean onCreate() {
        initProvider();
        return false;
    }

    /**
     * 初始化时清楚旧数据,插入一条数据
     */
    private void initProvider() {
        mTable = DbOpenHelper.TABLE_NAME;
        mContext = getContext();
        mDatabase = new DbOpenHelper(mContext).getWritableDatabase();

        new Thread(new Runnable() {
            @Override
            public void run() {
                mDatabase.execSQL("delete from " + mTable);
                mDatabase.execSQL("insert into " + mTable + " values(1,'shixinzhang','handsome boy')");
            }
        }).start();
    }

    @Nullable
    @Override
    public Cursor query(final Uri uri, final String[] projection, final String selection, final String[] selectionArgs, final String sortOrder) {
        String tableName = getTableName(uri);
        showLog(tableName + " 查询数据" );
        return mDatabase.query(tableName, projection, selection, selectionArgs, null, sortOrder, null);
    }

    @Nullable
    @Override
    public Uri insert(final Uri uri, final ContentValues values) {
        String tableName = getTableName(uri);
        showLog(tableName + " 插入数据");
        mDatabase.insert(tableName, null, values);
        mContext.getContentResolver().notifyChange(uri, null);
        return null;
    }

    @Override
    public int delete(final Uri uri, final String selection, final String[] selectionArgs) {
        String tableName = getTableName(uri);
        showLog(tableName + " 删除数据");
        int deleteCount = mDatabase.delete(tableName, selection, selectionArgs);
        if (deleteCount > 0) {
            mContext.getContentResolver().notifyChange(uri, null);
        }
        return deleteCount;
    }

    @Override
    public int update(final Uri uri, final ContentValues values, final String selection, final String[] selectionArgs) {
        String tableName = getTableName(uri);
        showLog(tableName + " 更新数据");
        int updateCount = mDatabase.update(tableName, values, selection, selectionArgs);
        if (updateCount > 0) {
            mContext.getContentResolver().notifyChange(uri, null);
        }
        return updateCount;
    }

    /**
     * CRUD 的参数是 Uri,根据 Uri 获取对应的表名
     *
     * @param uri
     * @return
     */
    private String getTableName(final Uri uri) {
        String tableName = "";
        int match = mUriMatcher.match(uri);
        switch (match){
            case TABLE_CODE_PERSON:
                tableName = DbOpenHelper.TABLE_NAME;
        }
        showLog("UriMatcher " + uri.toString() + ", result: " + match);
        return tableName;
    }

    @Nullable
    @Override
    public String getType(final Uri uri) {
        return null;
    }

    private void showLog(final String s) {
        LogUtils.d(TAG, s + "***** @ " + Thread.currentThread().getName());
    }
}

定义 ContentProvider 的授权字符串(authority)、内容 URI、权限

①ContentProvider 可以关联多个授权字符串(authority),如上述代码所示,我们使用这个类的完整路径名为一个authority:


public static final String AUTHORITY = "net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider";  //授权

②内容 URI 用于在 ContentProvider 中标识数据的 URI,可以使用 content:// + authority 作为 ContentProvider 的 URI,这里就是:

content://net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider

如果该数据库中有多个表,可以继续增加 path:

content://net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider/table1
content://net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider/table2

这里我们的 URI 为:

public static final Uri PERSON_CONTENT_URI = Uri.parse("content://" + AUTHORITY + "/person");

在 ContentProvider 中可以通过 UriMatcher 来为不同的 URI 关联不同的 code,便于后续根据 URI 找到对应的表。

③AndroidManifest 中声明权限

<uses-permission android:name="top.shixinzhang.permission.READ_CONTENT"/>

<!--读内容提供者的权限-->
<permission
    android:name="top.shixinzhang.permission.READ_CONTENT"
    android:label="Permission for read content provider"
    android:protectionLevel="normal"
    />
<provider
    android:name=".provider.IPCPersonProvider"
    android:authorities="net.sxkeji.shixinandroiddemo2.provider.IPCPersonProvider"
    android:exported="true"
    android:grantUriPermissions="true"
    android:process=":provider"
    android:readPermission="top.shixinzhang.permission.READ_CONTENT">

因为我们要测试跨进程通信,因此这里将 provider 声明为另外一个进程 android:process=":provider"

通过 ContentResolver 和 URI 进行增删改查

在 Activity 中调用 ContentResolver 进行增加和查询操作:


private void getContentFromContentProvider() {
    Uri uri = IPCPersonProvider.PERSON_CONTENT_URI;    //ContentProvider 中注册的 URI
    ContentValues contentValues = new ContentValues();
    contentValues.put("_id", id++);
    contentValues.put("name", "rourou" + DateUtils.getCurrentTime());
    contentValues.put("description", "beautiful girl");
    ContentResolver contentResolver = getContentResolver();    //获取内容处理器
    contentResolver.insert(uri, contentValues);    //插入一条数据

    //再查询一次
    Cursor cursor = contentResolver.query(uri, new String[]{"name", "description"}, null, null, null, null);
    if (cursor == null) {
        return;
    }
    StringBuilder cursorResult = new StringBuilder("DB 查询结果:");
    while (cursor.moveToNext()) {
        String result = cursor.getString(0) + ", " + cursor.getString(1);
        LogUtils.d(TAG, "DB 查询结果:" + result);
        cursorResult.append("\n").append(result);
    }
    mTvCpResult.setText(cursorResult.toString());
    cursor.close();
}

@OnClick(R.id.btn_add_person_to_db)
public void addPersonToDB() {
    getContentFromContentProvider();
}

运行结果

调用 ContentProvider 的 Activity:

Android 进阶11:进程通信之 ContentProvider 内容提供者

我们在另外一个进程的 provider 中打了些 Log,可以看到被调用了:

Android 进阶11:进程通信之 ContentProvider 内容提供者

源码浅析

在上面打印 ContentProvider 增删改查所在线程时,看到显示的是 “Binder”,难不成也是使用 Binder 实现的么,我们去看看源码。

先看 Activity 直接调用的 ContentResolver.insert() 方法:

public final @Nullable Uri insert(@RequiresPermission.Write @NonNull Uri url,
            @Nullable ContentValues values) {
    Preconditions.checkNotNull(url, "url");
    IContentProvider provider = acquireProvider(url);
    if (provider == null) {
        throw new IllegalArgumentException("Unknown URL " + url);
    }
    try {
        long startTime = SystemClock.uptimeMillis();
        Uri createdRow = provider.insert(mPackageName, url, values);
        long durationMillis = SystemClock.uptimeMillis() - startTime;
        maybeLogUpdateToEventLog(durationMillis, url, "insert", null /* where */);
        return createdRow;
    } catch (RemoteException e) {...}
}

可以看到它调用了 IContentProvider.insert() 方法,直觉告诉我,这个类应该不简单!

点开源码一看,果然!


/**
 * The ipc interface to talk to a content provider.
 * @hide
 */
public interface IContentProvider extends IInterface {...}

IContentProvider 也是个 IInterface,跟我们前面看的 AIDL、Binder 一模一样嘛!

在下水平时间有限,就不深入研究了,这里借用 gityuan 的 理解ContentProvider原理 的一张图大概了解一下:

Android 进阶11:进程通信之 ContentProvider 内容提供者

注意事项

防止 SQL 注入

如果 ContentProvider 管理的数据位于 SQL 数据库中,在保存数据时,有可能会遇到恶意语句导致 SQL 注入。

这部分翻译理解自官方文档,有不合适的地方求指出 0.0

比如 ContentProvider.query():

public Cursor query(final Uri uri, final String[] projection, final String selection, final String[] selectionArgs, final String sortOrder) {
    String tableName = getTableName(uri);
    return mDatabase.query(tableName, projection, selection, selectionArgs, null, sortOrder, null);
}

这时如果输入的 selection 为恶意 SQL,就可能被执行,造成意外的损失。

例如,传入的 selectionname = nothing; DROP TABLE *;,这会生成查询子句 name = nothing; DROP TABLE *;

由于这个查询子句被作为 SQL 语句处理,因此这可能会导致 ContentProvider 擦除数据库中的所有表。

要避免此问题,可使用一个用于将 ? 作为可替换参数的查询子句以及一个单独的选择参数数组。

也就是将查询的 “字段名 = ?” 和具体值分别传入到在上述代码的 selectionselectionArgs

这样执行查询操作时,用户的输入直接受查询约束,而不会被作为 SQL 语句的一部分,因此无法注入恶意 SQL。

将 ? 用作可替换参数的条件语句和一个选择参数数组是指定查询语句的首选方式,即使 ContentProvider 管理的数据类型不是 SQL 数据库。

Cursor 搭配 ListView,使用 SimpleCursorAdapter 更配

ContentProvider.query() 会返回 Cursor,如果要结合 ListView 展示,可以使用 SimpleCursorAdapter

// Cursor 中要获取的数据列名称
String[] mWordListColumns = {
    UserDictionary.Words.WORD,
    UserDictionary.Words.LOCALE
};

// ListView 的 item 布局中要展示上面两个数据对于的 id
int[] mWordListItems = { R.id.dictWord, R.id.locale};

mCursorAdapter = new SimpleCursorAdapter(
    getApplicationContext(),               // The application's Context object
    R.layout.wordlistrow,                  // A layout in XML for one row in the ListView
    mCursor,                               // The result from the query
    mWordListColumns,                      // A string array of column names in the cursor
    mWordListItems,                        // An integer array of view IDs in the row layout
    0);                                    // Flags (usually none are needed)

mWordList.setAdapter(mCursorAdapter);

注意:要通过 Cursor 显示 ListView,游标必需包含名为 _ID 的列。

ContentProvider 的使用场景

只有在多个应用间分享数据时才需要使用 ContentProvider ,比如:

  • 您想为其他应用提供复杂的数据或文件
  • 您想允许用户将复杂的数据从您的应用复制到其他应用中
  • 您想使用搜索框架提供自定义搜索建议

否则直接使用应用内常用的数据存储方式(sp, db, file)即可。

代码地址

Thanks

《Android 开发艺术探索》

https://developer.android.com/guide/topics/providers/content-providers.html

https://developer.android.com/guide/topics/providers/content-provider-basics.html

https://developer.android.com/guide/topics/providers/content-provider-creating.html

http://blog.csdn.net/harvic880925/article/details/44651967

http://blog.csdn.net/harvic880925/article/details/38683625