Android开发学习笔记(八) —— 探究ContentProvider

一、什么是ContentProvider

1.1 概念

内容提供器(Content Provider)是Android四大组件之一,主要用于在不同的应用程序之间实现数据共享的功能,它提供了一套完整的机制,允许一个程序访问另一个程序中的数据,同时还能保证被访数据的安全性。使用时需要在AndroidManifest..xml文件中进行声明。

不同于文件存储和SharedPreferences存储中的两种全局可读写操作模式,内容提供器可以选择只对哪一部分数据进行共享,从而保证我们程序中的隐私数据不会有泄漏的风险。

不过在正式开始学习内容提供器之前,我们需要先掌握另外一个非常重要的知识Android运行时权限,因为待会的内容提供器示例中会使用到运行时权限的功能。

二、运行时权限

2.1 Android权限机制详解

在之前的学习过程中,我们接触过一些权限。例如,BroadcastTest项目的时候第一次接触了Android权限相关的内容,当时为了要访问系统的网络状态以及监听开机广播,于是在AndroidManifest..xml文件中添加了这样两句权限声明:

1
2
3
4
5
6
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.broadcasttest">
<uses-permission android:name="android.permission.ACCESS NETWORK STATE"/>
<uses-permission android:name="android.permission.RECEIVE BOOT COMPLETED"/>
...
</manifest>

因为访问系统的网络状态以及监听开机广播涉及了用户设备的安全性,因此必须在AndroidManifest.xml中加入权限声明,否则我们的程序就会崩溃。

那么现在问题来了,加入了这两句权限声明后,对于用户来说到底有什么影响呢?为什么这样就可以保护用户设备的安全性了呢?其实用户主要在以下两个方面得到了保护,一方面,用户在安装程序的时候会在安装界面显示应用所需要的权限;另一方面,用户可以随时在应用程序管理界面查看任意一个程序的权限申请情况,以保证应用程序不会出现各种滥用权限的情况。

然而大多数应用存在滥用权限的情况,不管到底用不用得到,反正先把权限申请了再说。Android开发团队当然也意识到了这个问题,于是在6.0系统中加入了运行时权限功能。也就是说,用户不需要在安装软件的时候一次性授权所有申请的权限,而是可以在软件的使用过程中再对某一项权限申请进行授权。比如说一款相机应用在运行时申请了地理位置定位权限,就算我拒绝了这个权限,但是我应该仍然可以使用这个应用的其他功能,而不是像之前那样直接无法安装它。

当然,并不是所有权限都需要在运行时申请,对于用户来说,不停地授权也很烦琐。Android现在将所有的权限归成了两类,一类是普通权限,一类是危险权限。普通权限指的是那些不会直接威胁到用户的安全和隐私的权限,对于这部分权限申请,系统会自动帮我们进行授权,而不需要用户再去手动操作了,比如在BroadcastTest项目中申请的两个权限就是普通权限。危险权限则表示那些可能会触及用户隐私,或者对设备安全性造成影响的权限,如获取设备联系人信息、定位设备的地理位置等,对于这部分权限申请,必须要由用户手动点击授权才可以,否则程序就无法使用相应的功能。

但是Android中有一共有上百种权限,我们怎么从中区分哪些是普通权限,哪些是危险权限呢?其实并没有那么难,因为危险权限总共就那么几个,除了危险权限之外,剩余的就都是普通权限了。下表列出了Android中所有的危险权限,一共是9组24个权限。

我们并不需要记住这些权限,只要把它当成一个参照表来查阅就行了。每当要使用一个权限时,可以先到这张表中来查一下,如果是属于这张表中的权限,那么就需要进行运行时权限处理,如果不在这张表中,那么只需要在AndroidManifest.xml文件中添加一下权限声明就可以了。另外注意一下,表格中每个危险权限都属于一个权限组,我们在进行运行时权限处理时使用的是权限名,但是用户一旦同意授权了,那么该权限所对应的权限组中所有的其他权限也会同时被授权。

Android系统中完整的权限列表可以访问http:/developer..android.com/reference/android/.Manifest.permission.html

2.2 在程序运行时申请权限

我们通过一个例子来学习运行时权限的使用方法。假设应用在运行时需要申请CALL_PHONE这个权限,这里我们就用一个按钮来触发权限申请,所以修改activity_main.xml布局文件如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:orientation="vertical"
android:gravity="center">

<android.widget.Button
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="申请权限"
android:onClick="ClicktoAccess"/>

</LinearLayout>

对应的MainActivity代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MainActivity extends AppCompatActivity {

......
public void ClicktoAccess(View view) {
try {
Intent intent = new Intent(Intent.ACTION_CALL);
intent.setData(Uri.parse("tel:10086"));
startActivity(intent);
}catch (Exception e){
e.printStackTrace();
}

}
}

ClicktoAccess()方法为按钮的点击方法,在该方法中,我们构建了一个隐式Intent,Intent的action指定为Intent.ACTION_CALL,这是一个系统内置的打电话的动作,data部分指定了协议是tel,号码是10086。另外为了防止程序崩溃,我们将所有操作都放在了异常捕获代码块当中。

Intent.ACTION_CALLIntent.ACTION_DIAL不同,Intent.ACTION_DIAL表示打开拨号界面,这个是不需要声明权限的,而Intent.ACTION_CALL则可以直接拨打电话,因此必须声明权限。

接下来就是在AndroidManifest.xml文件中声明该权限:

1
2
3
4
5
6
7
8
9
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.CALL_PHONE"/>
<application
...
</application>

</manifest>

这样我们就将拨打电话的功能成功实现了,并且在低于Android6.0系统的手机上都是可以正常运行的,但是如果我们在6.0或者更高版本系统的手机上运行,点击Make Call按钮就没有任何效果,这时观察logcat中的打印日志,你会看到如下警告信息。

1
java.lang.SecurityException: Permission Denial: starting Intent { act=android.intent.action.CALL...

警告信息中提醒我们Permission Denial,很明显,是由于权限被禁止所导致的,因为6.0及以上系统在使用危险权限时都必须进行运行时权限处理。

那么下面我们就来尝试修复这个问题,修改MainActivity中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class MainActivity extends AppCompatActivity {

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}

public void ClicktoAccess(View view) {

if (ContextCompat.checkSelfPermission(MainActivity.this, android.Manifest.permission.CALL_PHONE)
!= PackageManager.PERMISSION_GRANTED){
ActivityCompat.requestPermissions(MainActivity.this,new String[]{android.Manifest.permission.CALL_PHONE},1);
} else {
call();
}

}

private void call(){
try {
Intent intent = new Intent(Intent.ACTION_CALL);
intent.setData(Uri.parse("tel:10086"));
startActivity(intent);
}catch (Exception e){
e.printStackTrace();
}
}

@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode) {
case 1:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
call();
} else {
Toast.makeText(this, "你拒绝了此权限申请", Toast.LENGTH_SHORT).show();
}
break;
default:
}
}
}

说白了,运行时权限的核心就是在程序运行过程中由用户授权去执行某些危险操作,程序是不可以擅自做主去执行这些危险操作的。

上面的代码将运行时权限的完整流程都覆盖了,具体如下所示:

  1. 首先通过ContextCompat.checkSelfPermission()方法判断用户是不是已经给过授权,该方法参数如下:
    • 第一个参数是Context
    • 第二个参数是具体的权限名,比如打电话的权限名就是android.Manifest.permission.CALL_PHONE
  2. 然后将ContextCompat.checkSelfPermission()方法的返回值和PackageManager.PERMISSION_GRANTED做比较,相等就说明用户已经授权,不等就表示用户没有授权。
    • 如果已经授权的话就简单了,直接去执行拨打电话的逻辑操作就可以了,这里我们把拨打电话的逻辑封装到了call()方法中。
    • 如果没有授权的话,则需要调用ActivityCompat.requestPermissions()方法来向用户申请授权,该方法方法接收3个参数:
      • 第一个参数要求是Activity的实例
      • 第二个参数是一个String数组,我们把要申请的权限名放在数组中即可
      • 第三个参数是请求码,只要是唯一值就可以了。
  3. 调用完了requestPermissions()方法之后,系统会弹出一个权限申请的对话框,然后用户可以选择同意或拒绝权限申请,不论是哪种结果,最终都会回调到onRequestPermissionsResult()方法中,而授权的结果则会封装在grantResults参数当中。这里我们只需要判断一下最后的授权结果,如果用户同意的话就调用call()方法来拨打电话,如果用户拒绝的话我们只能放弃操作,并且弹出一条失败提示。

现在重新运行一下程序,并点击按钮,会出现权限申请的弹窗,用户可以选择同意或拒绝。如果选择拒绝,则会弹出提示,下次再点击按钮仍会出现权限申请弹窗。如果选择同意,则会跳转到拨打电话界面,并且拨打了10086,并且由于用户已经完成了授权操作,之后再点击按钮就不会再弹出权限申请对话框了,而是直接拨打电话(想要需要授权可以在应用管理中进行权限关闭)。

三、访问其他程序中的数据

内容提供器的用法一般有两种,一种是使用现有的内容提供器来读取和操作相应程序中的数据,另一种是创建自己的内容提供器给我们程序的数据提供外部访问接口。

如果一个应用程序通过内容提供器对其数据提供了外部访问接口,那么任何其他的应用程序就都可以对这部分数据进行访问。Android系统中自带的电话簿、短信、媒体库等程序都提供了类似的访问接口,这使得第三方应用程序可以充分地利用这部分数据来实现更好的功能。

3.1 contentResolver的基本用法

对于每一个应用程序来说,如果想要访问内容提供器中共享的数据,就一定要借助ContentResolver类,可以通过Context中的getContentResolver()方法获取到该类的实例。ContentResolver中提供了一系列的方法用于对数据进行增删改查(CRUD)操作,其中insert()方法用于添加数据,update()方法用于更新数据,delete()方法用于删除数据,query()方法用于查询数据。

与SQLiteDatabase不同,ContentResolver中的增删改查方法都是不接收表名参数的,而是使用一个Uri参数代替,这个参数被称为内容URI。内容URI给内容提供器中的数据建立了唯一标识符,它主要由两部分组成:**authoritypath**。authority是用于对不同的应用程序做区分的,一般为了避免冲突,都会采用程序包名的方式来进行命名,比如某个程序的包名是com.example.app,那么该程序对应的authority就可以命名为com.example.app.providerpath则是用于对同一应用程序中不同的表做区分的,通常都会添加到authority的后面。比如某个程序的数据库里存在两张表:table1和table2,这时就可以将path分别命名为/table1/table2,然后把authoritypath进行组合,内容URI就变成了com.example.app.provider/table1com.example.app.provider/table2。不过,目前还很难辨认出这两个字符串就是两个内容URI,我们还需要在字符串的头部加上协议声明。因此,内容URI 最标准的格式写法如下:

1
2
content://com.example.app.provider/tablel
content://com.example.app.provider/table2

内容URI可以非常清楚地表达出我们想要访问哪个程序中哪张表里的数据。也正是因此,ContentResolver中的增删改查方法才都接收Uri对象作为参数,因为如果使用表名的话,系统将无法得知我们期望访问的是哪个应用程序里的表。

在得到了内容URI字符串之后,我们还需要将它解析成 Uri 对象才可以作为参数传人。解析的方法也相当简单,代码如下所示:

1
Uri uri = Uri.parse("content://com.example.app.provider/table1")

只需要调用Uri.parse()方法,就可以将内容URI字符串解析成Uri对象了。

接下来就是增删改查操作了,这部分与SQLiteDatabase的操作大同小异,只不过是多了一个uri。

3.1.1 查询

示例代码如下所示:

1
2
3
4
5
6
Cursor cursor = getContentResolver().query(
uri,
projection,
selection,
selectionArgs,
sortorder);

这些参数可以与SQLiteDatabase中query()方法的参数进行类比,以便我们更容易理解。

查询完成后返回的仍然是一个Cursor对象,这时我们就可以将数据从Cursor对象中逐个读取出来了。读取的思路仍然是通过移动游标的位置来遍历Cursor的所有行,然后再取出每一行中相应列的数据,代码如下所示:

1
2
3
4
5
6
7
if(cursor != null){
while (cursor.moveToNext()){
String column1 = cursor.getstring(cursor.getColumnIndex("column1"));
int column2 = cursor.getInt(cursor.getColumnIndex("column2"));
}
cursor.close();
}

3.1.2 添加

示例代码如下所示:

1
2
3
4
ContentValues values = new ContentValues();
values.put("columnl","text");
values.put("column2",1);
getContentResolver().insert(uri,values);

与SQLiteDatabase的操作相似,仍然是将待添加的数据组装到ContentValues中,然后调用ContentResolver的insert()方法,将Uri和ContentValues作为参数传入即可。

3.1.3 更新

示例代码如下所示:

1
2
3
ContentValues values = new ContentValues();
values.put("column1","")
getContentResolver().update(uri,values,"column1 = ? and column2 = ?",new String[]{"text","1"});

上述代码通过selection和selectionArgs参数来指定符合条件的数据进行更新。

3.1.4 删除

调用ContentResolver的delete()方法将这条数据删除掉,代码如下所示:

1
getContentResolver().delete(uri,"column2 = ?",new String[]{"1"});

到这里为止,我们就把ContentResolver中的增删改查方法全部学完了。因为这些知识在学习SQLiteDatabase的时候就已经学习过了,所需特别注意的就只有uri这个参数而已。所以接下来直接进行实操演练一下。

3.2 实操 — 读取通讯录的联系人

为了方便展示联系人信息,使用ListView控件,同时为了省略无关代码,使用项目给定的item,这样我们就不用构建Adapter类。因此,修改activity_main.xml中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:orientation="vertical"
android:gravity="center">

<ListView
android:id="@+id/contacts_view"
android:layout_width="match_parent"
android:layout_height="match_parent">

</ListView>
</LinearLayout>

然后是MainActivity代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
public class MainActivity extends AppCompatActivity {
//ListView的适配器
private ArrayAdapter<String> adapter;
//存放获取到的联系人信息
List<String> contactList = new ArrayList<>();

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
initListView();
PermissionCheck();
}
//获取ListView并绑定适配器
private void initListView() {
ListView contactView = findViewById(R.id.contacts_view);
//使用自带的item
adapter = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1,contactList);
contactView.setAdapter(adapter);
}
//检查权限是否已被授权
private void PermissionCheck() {
if (ContextCompat.checkSelfPermission(MainActivity.this, android.Manifest.permission.READ_CONTACTS)
!= PackageManager.PERMISSION_GRANTED){
//未授权则弹窗申请授权
ActivityCompat.requestPermissions(MainActivity.this,new String[]{android.Manifest.permission.READ_CONTACTS},1);
} else {
//已授权则读取联系人
readContacts();
}
}
//读取通讯录的联系人信息
private void readContacts(){
Cursor cursor = null;

try {
cursor = getContentResolver().query(ContactsContract.CommonDataKinds.Phone.CONTENT_URI,null,null,null);
if (cursor != null){
while (cursor.moveToNext()){
//获取联系人姓名
String name = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME));
//获取联系人手机号
String phonenumber = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER));
contactList.add(name + "\n" + phonenumber);
}
//通知刷新ListView
adapter.notifyDataSetChanged();
}
} catch (Exception e){
e.printStackTrace();
} finally {
if (cursor != null){
cursor.close();
}
}
}
//用户做出选择后的回调函数
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode) {
case 1:
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED){
readContacts();
} else {
Toast.makeText(this, "你拒绝了此权限申请", Toast.LENGTH_SHORT).show();
}
break;
default:
}
}

}

onCreate()方法中,我们首先调用initListView()方法获取ListView实例并绑定适配器,然后调用PermissionCheck()方法来进行运行时权限的处理逻辑,这一部分刚才我们已经分析过了。

下面重点看一下readContacts()方法,可以看到,这里使用了ContentResolver的query()方法来查询系统的联系人数据,不过传入的uri 参数怎么有些奇怪啊?为什么没有调用Uri.parse()方法去解析一个内容URI字符串呢?这是因为ContactsContract.CommonData Kinds.Phone类已经帮我们做好了封装,提供了一个CONTENT_URI常量,而这个常量就是使用Uri.parse()方法解析出来的结果。接着我们对Cursor对象进行遍历,将联系人姓名和手机号这些数据逐个取出,联系人姓名这一列对应的常量是ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,联系人手机号这一列对应的常量是ContactsContract.CommonData Kinds.Phone.NUMBER。两个数据都取出之后,将它们进行拼接,并且在中间加上换行符,然后将拼接后的数据添加到ListView的数据源里,并通知刷新一下ListView。最后将Cursor对象关闭。

最后还需要声明读取通讯录联系人的权限。在AndroidManifest.xml中添加:

1
<uses-permission android:name="android.permission.READ_CONTACTS"/>

四、创建自己的ContentProvider

在上一节当中,我们学习了如何在自己的程序中访问其他应用程序的数据。总体来说思路还是非常简单的,只需要获取到该应用程序的内容URI,然后借助ContentResolver进行CRUD操作就可以了。可是,那些提供外部访问接口的应用程序都是如何实现这种功能的呢?它们又是怎样保证数据的安全性?接下来我们就具体学习一下。

4.1 创建ContentProvider的步骤

要想创建自己的ContentProvider,可以通过新建一个类去继承ContentProvider的方式来创建。

ContentProvider类中有6个抽象方法,我们在使用子类继承它的时候,需要将这6个方法全部重写。新建MyContentProvider继承自ContentProvider,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class MyContentProvider extends ContentProvider {

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

@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] strings, @Nullable String s, @Nullable String[] strings1, @Nullable String s1) {
return null;
}

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

@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues contentValues) {
return null;
}

@Override
public int delete(@NonNull Uri uri, @Nullable String s, @Nullable String[] strings) {
return 0;
}

@Override
public int update(@NonNull Uri uri, @Nullable ContentValues contentValues, @Nullable String s, @Nullable String[] strings) {
return 0;
}
}
  1. onCreate()
    初始化内容提供器的时候调用。通常会在这里完成对数据库的创建和升级等操作,返回true表示内容提供器初始化成功,返回false则表示失败。注意,只有当存在ContentResolver尝试访问我们程序中的数据时,内容提供器才会被初始化。
  2. query()
    从内容提供器中查询数据。使用uri参数来确定查询哪张表,projection参数用于确定查询哪些列,selection和selectionArgs参数用于约束查询哪些行,sortOrder参数用于对结果进行排序,查询的结果存放在Cursor对象中返回。
  3. insert()
    向内容提供器中添加一条数据。使用uri参数来确定要添加到的表,待添加的数据保存在values参数中。添加完成后,返回一个用于表示这条新记录的URI。
  4. update()
    更新内容提供器中已有的数据。使用uri参数来确定更新哪一张表中的数据,新数据保存在values参数中,selection和selectionArgs参数用于约束更新哪些行,受影响的行数将作为返回值返回。
  5. delete()
    从内容提供器中删除数据。使用uri参数来确定删除哪一张表中的数据,selection和selectionArgs参数用于约束删除哪些行,被删除的行数将作为返回值返回。
  6. getType()
    根据传入的内容URI来返回相应的MIME类型。

在上一节中,我们提及到了标准uri的格式为:

1
content://com.example.app.provider/tablel

这就表示调用方期望访问的是com.example.app这个应用的table1表中的数据。除此之外,我们还可以在这个内容URI的后面加上一个id,如下所示:

1
content://com.example.app.provider/tablel/1

这就表示调用方期望访问的是com.example.app这个应用的table1表中id为1的数据。

内容URI的格式主要就只有以上两种,以路径结尾就表示期望访问该表中所有的数据,以id结尾就表示期望访问该表中拥有相应id的数据。因此我们可以使用通配符的方式来分别匹配这两种格式的内容URI,规则如下:

  • *****:表示匹配任意长度的任意字符。
  • **#**:表示匹配任意长度的数字。

所以,一个能够匹配任意表的内容URI格式就可以写成:

1
content://com.example.app.provider/*

而一个能够匹配table1表中任意一行数据的内容URI格式就可以写成:

1
content://com.example.app.provider/tablel/#

接着,我们再借助UriMatcher这个类就可以轻松地实现匹配内容URI的功能。UriMatcher中提供了一个addURI()方法,这个方法接收3个参数,可以分别把authority、path和一个自定义代码传进去。这样,当调用UriMatchermatch()方法时,就可以将一个Uri对象传入,返回值是某个能够匹配这个U对象所对应的自定义代码,利用这个代码,我们就可以判断出调用方期望访问的是哪张表中的数据了。修改MyContentProvider中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class MyContentProvider extends ContentProvider {

public static final int TABLE1_DIR = 0;
public static final int TABLE1_ITEM = 1;
public static final int TABLE2_DIR = 2;
public static final int TABLE2_ITEM = 3;
private static UriMatcher uriMatcher;

static {
uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI("com.example.app.provider","table1",TABLE1_DIR);
uriMatcher.addURI("com.example.app.provider","table1/#",TABLE1_ITEM);
uriMatcher.addURI("com.example.app.provider","table2",TABLE2_DIR);
uriMatcher.addURI("com.example.app.provider","table/#",TABLE2_ITEM);
}
......
@Nullable
@Override
public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder, @Nullable CancellationSignal cancellationSignal) {

switch (uriMatcher.match(uri)){
case 0:
//查询table1表中的所用数据
break;
case 1:
//查询table1表中的单条数据
break;
case 2:
//查询table2表中的所用数据
break;
case 3:
//查询table1表中的单条数据
break;
default:
break;
}
return super.query(uri, projection, selection, selectionArgs, sortOrder, cancellationSignal);
}
}

可以看到,MyContentProvider中新增了4个整型常量,其中TABLE1_DIR表示访问table1表中的所有数据,TABLE1_ITEM表示访问table1表中的单条数据,TABLE2_DIR表示访问table2表中的所有数据,TABLE2_ITEM表示访问table2表中的单条数据。

接着在静态代码块里我们创建了UriMatcher的实例,并调用addURI()方法,将期望匹配的内容URI格式传递进去,注意这里传入的路径参数是可以使用通配符的。然后当query()方法被调用的时候,就会通过UriMatcher的match()方法对传入的Uri对象进行匹配,如果发现UriMatcher中某个内容URI格式成功匹配了该Uri对象,则会返回相应的自定义代码,然后我们就可以判断出调用方期望访问的到底是什么数据了。

上述代码只是以query()方法为例做了个示范,其实insert()、update()、delete()这几个方法的实现也是差不多的,它们都会携带Uri这个参数,然后同样利用UriMatcher的match()方法判断出调用方期望访问的是哪张表,再对该表中的数据进行相应的操作就可以了。

除此之外,还有一个方法我们会比较陌生,即getType()方法。它是所有的内容提供器都必须提供的一个方法,用于获取Uri对象所对应的MIME类型。一个内容URI所对应的MIME字符串主要由3部分组成,Android对这3个部分做了如下格式规定:

  • 必须以vnd开头。
  • 如果内容URI以路径结尾,则后接 android.cursor.dir/,如果内容URI以id结尾,则后接 android.cursor.item/
  • 最后接上vnd.<authority>.<path>

所以,对于content://com.example.app.provider/table1这个内容URI,它所对应的MIME类型就可以写成:

1
vnd.android.cursor.dir/vnd.com.example.app.provider.table1

对于content://com.example.app.provider/table1/1这个内容URI,它所对应的MME类型就可以写成:

1
vnd.android.cursor.item/vnd.com.example.app.provider.tablel

现在我们可以继续完善MyContentProvider中的内容了,这次来实现getType()方法中的逻辑,代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MyContentProvider extends ContentProvider {

......
@Nullable
@Override
public String getType(@NonNull Uri uri) {
switch (uriMatcher.match(uri)){
case TABLE1_DIR:
return "vnd.android.cursor.dir/vnd.com.example.app.provider.table1";
case TABLE1_ITEM:
return "vnd.android.cursor.item/vnd.com.example.app.provider.table1";
case TABLE2_DIR:
return "vnd.android.cursor.dir/vnd.com.example.app.provider.table2";
case TABLE2_ITEM:
return "vnd.android.cursor.item/vnd.com.example.app.provider.table2";
default:
break;
}
return null;
}
......
}

到这里,一个完整的内容提供器就创建完成了,现在任何一个应用程序都可以使用ContentResolver来访问我们程序中的数据。至于前面所提到的保证隐私数据安全的功能,因为所有的CRUD操作都一定要匹配到相应的内容URI格式才能进行的,而我们当然不可能向UriMatcher中添加隐私数据的URI,所以这部分数据根本无法被外部程序访问到,安全问题也就不存在了。好了,接下来就来实战一下,真正体验一下跨程序数据共享的功能。

4.2 实现跨程序数据共享

为了突出重点,我们直接在上一章中SQLite数据库存储的项目的基础上继续开发,通过内容提供器来给它加入外部访问接口。为此我们需要给项目创建一个内容提供器,右击包名→New→Other→Content Provider,在弹出的窗口中,我们将内容提供器命名为DatabaseProvider,authority指定为com.example.databasetest.providerExported属性表示是否允许外部程序访问我们的内容提供器,Enabled属性表示是否启用这个内容提供器。将两个属性都勾中,点击Finish完成创建。

接着我们修改DatabaseProvider中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
public class DatabaseProvider extends ContentProvider {

private static final int BOOK_DIR = 0;
private static final int BOOK_ITEM = 1;
private static final int CATEGORY_DIR = 2;
private static final int CATEGORY_ITEM = 3;
private static final String AUTHORITY = "com.example.databasetest.provider";
private static UriMatcher uriMatcher;
private MyDatabaseHelper dbHelper;

static {
uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
uriMatcher.addURI(AUTHORITY,"book",BOOK_DIR);
uriMatcher.addURI(AUTHORITY,"book/#",BOOK_ITEM);
uriMatcher.addURI(AUTHORITY,"category",CATEGORY_DIR);
uriMatcher.addURI(AUTHORITY,"category/#",CATEGORY_ITEM);
}

public DatabaseProvider() {
}

@Override
public boolean onCreate() {
dbHelper = new MyDatabaseHelper(getContext(),"BookStore.db",null,2);
return true;
}

@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
SQLiteDatabase db = dbHelper.getReadableDatabase();
Cursor cursor = null;
switch (uriMatcher.match(uri)){
case BOOK_DIR:
cursor = db.query("Book",projection,selection,selectionArgs,null,null,sortOrder);
break;
case BOOK_ITEM:
String bookId = uri.getPathSegments().get(1);
cursor = db.query("Book",projection,"id = ?",new String[]{bookId},null,null,sortOrder);
break;
case CATEGORY_DIR:
cursor = db.query("Category",projection,selection,selectionArgs,null,null,sortOrder);
break;
case CATEGORY_ITEM:
String categoryId = uri.getPathSegments().get(1);
cursor = db.query("Category",projection,"id = ?",new String[]{categoryId},null,null,sortOrder);
break;
default:
break;
}
return cursor;
}

@Override
public Uri insert(Uri uri, ContentValues values) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
Uri uriReturn = null;
switch (uriMatcher.match(uri)){
case BOOK_DIR:
case BOOK_ITEM:
long newBookId = db.insert("Book",null,values);
uriReturn = Uri.parse("content://" + AUTHORITY + "/book/" + newBookId);
break;
case CATEGORY_DIR:
case CATEGORY_ITEM:
long newCategoryId = db.insert("Category",null,values);
uriReturn = Uri.parse("content://" + AUTHORITY + "/Category/" + newCategoryId);
break;
default:
break;
}
return uriReturn;
}

@Override
public int update(Uri uri, ContentValues values, String selection,
String[] selectionArgs) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
int updatedRows = 0;
switch (uriMatcher.match(uri)){
case BOOK_DIR:
updatedRows = db.update("Book",values,selection,selectionArgs);
break;
case BOOK_ITEM:
String bookId = uri.getPathSegments().get(1);
updatedRows = db.update("Book",values,selection,new String[]{bookId});
break;
case CATEGORY_DIR:
updatedRows = db.update("Category",values,selection,selectionArgs);
break;
case CATEGORY_ITEM:
String categoryId = uri.getPathSegments().get(1);
updatedRows = db.update("Category",values,selection,new String[]{categoryId});
break;
default:
break;
}
return updatedRows;
}

@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
int deletedRows = 0;
switch (uriMatcher.match(uri)){
case BOOK_DIR:
deletedRows = db.delete("Book",selection,selectionArgs);
break;
case BOOK_ITEM:
String bookId = uri.getPathSegments().get(1);
deletedRows = db.delete("Book","id = ?",new String[]{bookId});
break;
case CATEGORY_DIR:
deletedRows = db.delete("Category",selection,selectionArgs);
break;
case CATEGORY_ITEM:
String categoryId = uri.getPathSegments().get(1);
deletedRows = db.delete("Category","id = ?",new String[]{categoryId});
break;
default:
break;
}
return deletedRows;
}

@Override
public String getType(Uri uri) {
switch (uriMatcher.match(uri)){
case BOOK_DIR:
return "vnd.android.cursor.dir/vnd.com.example.databasetest.provider.book";
case BOOK_ITEM:
return "vnd.android.cursor.item/vnd.com.example.databasetest.provider.book";
case CATEGORY_DIR:
return "vnd.android.cursor.dir/vnd.com.example.databasetest.provider.category";
case CATEGORY_ITEM:
return "vnd.android.cursor.item/vnd.com.example.databasetest.provider.category";
default:
break;
}
return null;
}

}

首先在类的一开始,同样是定义了4个常量,分别用于表示访问Book表中的所有数据、访问Book表中的单条数据、访问Category表中的所有数据和访问Category表中的单条数据。然后在静态代码块里对UriMatcher进行了初始化操作,将期望匹配的几种URI格式添加了进去。

因为我们这里是对外部应用提供访问该应用数据的接口,而数据的来源是数据库,所以我们需要先在onCreate()方法中创建一个MyDatabaseHelper实例,返回true表示内容提供器初始化成功,这时数据库就已经完成了创建或升级操作。

接下来是CRUD操作的方法,格式都差不多,都是先根据操作获取读/写的数据库实例,然后根据Uri判断用户访问哪张表以及是全表数据还是单条数据。注意当访问单条数据的时候,需要调用了Uri对象的getPathSegments()方法,它会将内容URI权限之后的部分以 “/” 符号进行分割,并把分割后的结果放入到一个字符串列表中,那这个列表的第0个位置存放的就是路径,第1个位置存放的就是id了。得到了id之后,再通过selection和selectionArgs参数进行约束,就实现了查询单条数据的功能。四种方法的具体差异如下:

  • query()方法:因为是访问数据,所以需要将查询到的数据以Cursor对象返回。
  • insert()方法:需要返回一个能够表示这条新增数据的URI,所以需要调用Uri.parse()方法来将一个内容URI解析成Uri对象,当然这个内容URI是以新增数据的id结尾的。
  • update()方法:需要返回受影响的行数。
  • delete()方法:需要返回受影响的行数。

最后是getType()方法,这个方法中的代码完全是按照上一节中介绍的格式规则编写的,没有必要再解释了。

到此,我们就将内容提供器中的代码全部编写完了。

另外还有一点需要注意,内容提供器一定要在AndroidManifest.xml文件中注册才可以使用。不过由于我们是使用Android Studio的快捷方式创建的内容提供器,因此注册这一步已经被自动完成了。具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application
......
>
<provider
android:name=".DatabaseProvider"
android:authorities="com.example.databasetest.provider"
android:enabled="true"
android:exported="true"></provider>

......
</application>

</manifest>

可以看到,<application>标签内出现了一个新的标签<provider>,我们使用它来对DatabaseProvider这个内容提供器进行注册。android:name属性指定了DatabaseProvider的类名,android:authorities属性指定了DatabaseProvider的authority,而enabledexported属性则是根据我们刚才勾选的状态自动生成的,这里表示允许DatabaseProvider被其他应用程序进行访问。

现在这个项目就已经拥有了跨程序共享数据的功能了。为了验证一下,我们需要另外创建一个新项目ProviderTest来访问刚才编写好的项目中的数据。

新项目ProviderTest布局文件如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:orientation="vertical">

<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add To Book"
android:onClick="AddToBook"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Query From Book"
android:onClick="QueryFromBook"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Update Book"
android:onClick="UpdateBook"/>
<Button
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Delete From Book"
android:onClick="DeleteFromBook"/>

</LinearLayout>

简简单单的4个按钮,分别用于添加、查询、修改和删除数据。然后修改MainActivity中的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class MainActivity extends AppCompatActivity {

private String newId;

@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
}

public void AddToBook(View view) {
Uri uri = Uri.parse("content://com.example.databasetest.provider/book");
ContentValues values = new ContentValues();
values.put("name","A Clash of Kings");
values.put("author","George Martin");
values.put("pages",1024);
values.put("price",22.45);
Uri newUri = getContentResolver().insert(uri,values);
newId = newUri.getPathSegments().get(1);
}

public void UpdateBook(View view) {
Uri uri = Uri.parse("content://com.example.databasetest.provider/book" + newId);
ContentValues values = new ContentValues();
values.put("name","A Storm of Swords");
getContentResolver().update(uri,values,null,null);
}

public void QueryFromBook(View view) {
Uri uri = Uri.parse("content://com.example.databasetest.provider/book");
Cursor cursor = getContentResolver().query(uri, null, null, null, null);
if (cursor != null){
while (cursor.moveToNext()){
String name = cursor.getString(cursor.getColumnIndex("name"));
String author = cursor.getString(cursor.getColumnIndex("author"));
int pages = cursor.getInt(cursor.getColumnIndex("pages"));
double price = cursor.getDouble(cursor.getColumnIndex("price"));
Log.d("MainActivity", "name: " + name);
Log.d("MainActivity", "author: " + author);
Log.d("MainActivity", "pages: " + pages);
Log.d("MainActivity", "price: " + price);
}
cursor.close();
}
}

public void DeleteFromBook(View view) {
Uri uri = Uri.parse("content://com.example.databasetest.provider/book" + newId);
getContentResolver().delete(uri, null, null);
}
}

可以看到,我们分别在这4个按钮的点击事件里面处理了增删改查的逻辑,分别对接ContentProvider中的增删改查方法。

添加数据的时候,首先调用了Uri.parse()方法将一个内容URI解析成Uri对象,然后把要添加的数据都存放到ContentValues对象中,接着调用ContentResolver的insert()方法执行添加操作就可以了,该方法会返回一个Uri对象,这个对象中包含了新增数据的id,我们通过getPathSegments()方法将这个id取出。

查询数据的时候,同样是调用了Uri.parse()方法将一个内容URI解析成Uri对象,然后调用ContentResolver的query()方法去查询数据,查询的结果存放在Cursor对象中的。之后对Cursor进行遍历,从中取出查询结果,并一一打印出来。

更新数据的时候,也是先将内容URI解析成Uri对象,这里我们指定了id,那么表中其他数据就不会受影响了。然后把想要更新的数据存放到ContentValues对象中,再调用ContentResolver的update()方法执行更新操作就可以了。

删除数据的时候,也是使用同样的方法解析了一个以id结尾的内容URI,然后调用ContentResolver的delete()方法执行删除操作就可以了。

到此,我们就可以尝试运行程序了。


Android开发学习笔记(八) —— 探究ContentProvider
http://example.com/2023/08/12/Android安全/Android开发学习笔记(八) —— 探究ContentProvider/
作者
gla2xy
发布于
2023年8月12日
许可协议