跨程序共享数据——探究内容提供器
什么是 ContentProvider
内容提供器(Content Provider)是 Android 四大组件之一,主要用于在不同的应用程序之间实现数据共享的功能,它提供了一套完整的机制,允许一个程序访问另一个程序中的数据,同时还能保证被访数据的安全性。使用时需要在 AndroidManifest..xml
文件中进行声明。
不同于文件存储和 SharedPreferences 存储中的两种全局可读写操作模式,内容提供器可以选择只对哪一部分数据进行共享,从而保证我们程序中的隐私数据不会有泄漏的风险。
运行时权限
Android 权限机制详解
例如之前,为了要访问系统的网络状态以及监听开机广播,于是在 AndroidManifest..xml
文件中添加了这样两句权限声明:
1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" |
因为访问系统的网络状态以及监听开机广播涉及了用户设备的安全性,因此必须在 AndroidManifest.xml
中加入权限声明,否则我们的程序就会崩溃。
那么现在问题来了,加入了这两句权限声明后,对于用户来说到底有什么影响呢?为什么这样就可以保护用户设备的安全性了呢?其实用户主要在以下两个方面得到了保护,一方面,用户在安装程序的时候会在安装界面显示应用所需要的权限;另一方面,用户可以随时在应用程序管理界面查看任意一个程序的权限申请情况,以保证应用程序不会出现各种滥用权限的情况。
然而大多数应用存在滥用权限的情况,不管到底用不用得到,反正先把权限申请了再说。Android 开发团队当然也意识到了这个问题,于是在 6.0 系统中加入了运行时权限功能。也就是说,用户不需要在安装软件的时候一次性授权所有申请的权限,而是可以在软件的使用过程中再对某一项权限申请进行授权。比如说一款相机应用在运行时申请了地理位置定位权限,就算我拒绝了这个权限,但是我应该仍然可以使用这个应用的其他功能,而不是像之前那样直接无法安装它。
当然,并不是所有权限都需要在运行时申请,对于用户来说,不停地授权也很烦琐。Android 现在将所有的权限归成了两类,一类是普通权限,一类是危险权限。普通权限指的是那些不会直接威胁到用户的安全和隐私的权限,对于这部分权限申请,系统会自动帮我们进行授权,而不需要用户再去手动操作了,比如在 BroadcastTest 项目中申请的两个权限就是普通权限。危险权限则表示那些可能会触及用户隐私,或者对设备安全性造成影响的权限,如获取设备联系人信息、定位设备的地理位置等,对于这部分权限申请,必须要由用户手动点击授权才可以,否则程序就无法使用相应的功能。
但是 Android 中有一共有上百种权限,我们怎么从中区分哪些是普通权限,哪些是危险权限呢?其实并没有那么难,因为危险权限总共就那么几个,除了危险权限之外,剩余的就都是普通权限了。下表列出了 Android 中所有的危险权限,一共是 9 组 24 个权限。
权限组名 | 权限名 |
---|---|
CALENDAR |
READ_CALENDAR WRITE_CALENDAR |
CAMERA |
CAMERA |
CONTACTS |
READ_CONTACTS WRITE_CONTACTS GET_ACCOUNTS |
LOCATION |
ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION |
MICROPHONE |
RECORD_AUDIO |
PHONE |
READ_PHONE_STATE CALL_PHONE READ_CALL_LOG WRITE_CALL_LOG ADD_VOICEMAIL USE_SIP PROCESS_OUTGOING_CALLS |
SENSORS |
BODY_SENSORS |
SMS |
SEND_SMS RECEIVE_SMS READ_SMS RECEIVE_WAP_PUSH RECEIVE_MMS |
STORAGE |
READ_EXTERNAL_STORAGE WRITE_EXTERNAL_STORAGE |
我们并不需要记住这些权限,只要把它当成一个参照表来查阅就行了。每当要使用一个权限时,可以先到这张表中来查一下,如果是属于这张表中的权限,那么就需要进行运行时权限处理,如果不在这张表中,那么只需要在 AndroidManifest.xml
文件中添加一下权限声明就可以了。另外注意一下,表格中每个危险权限都属于一个权限组,我们在进行运行时权限处理时使用的是权限名,但是用户一旦同意授权了,那么该权限所对应的权限组中所有的其他权限也会同时被授权。
Android 系统中完整的权限列表可以访问 http:/developer..android.com/reference/android/.Manifest.permission.html。
在程序运行时申请权限
我们通过一个例子来学习运行时权限的使用方法。假设应用在运行时需要申请 CALL_PHONE 这个权限,这里我们就用一个按钮来触发权限申请,所以修改 activity_main.xml
布局文件如下所示:
1 |
|
MainActivity 代码
1 | public class MainActivity extends AppCompatActivity { |
ClicktoAccess()
方法为按钮的点击方法,在该方法中,我们构建了一个隐式 Intent,Intent 的 action 指定为 Intent.ACTION_CALL
,这是一个系统内置的打电话的动作,data 部分指定了协议是 tel,号码是 10086。另外为了防止程序崩溃,我们将所有操作都放在了异常捕获代码块当中。
Intent.ACTION_CALL
与 Intent.ACTION_DIAL
不同,Intent.ACTION_DIAL
表示打开拨号界面,这个是不需要声明权限的,而 Intent.ACTION_CALL
则可以直接拨打电话,因此必须声明权限。
在 AndroidManifest.xml
文件中声明该权限:
1 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" |
这样我们就将拨打电话的功能成功实现了,并且在低于 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 | public class MainActivity extends AppCompatActivity { |
行时权限的核心就是在程序运行过程中由用户授权去执行某些危险操作,程序是不可以擅自做主去执行这些危险操作的。
上面的代码将运行时权限的完整流程都覆盖了,具体如下所示:
- 首先通过 ContextCompat.checkSelfPermission()方法判断用户是不是已经给过授权,该方法参数如下:
- 第一个参数是 Context
- 第二个参数是具体的权限名,比如打电话的权限名就是
android.Manifest.permission.CALL_PHONE
。
- 然后将 ContextCompat.checkSelfPermission()方法的返回值和 PackageManager.PERMISSION_GRANTED 做比较,相等就说明用户已经授权,不等就表示用户没有授权。
- 如果已经授权的话就简单了,直接去执行拨打电话的逻辑操作就可以了,这里我们把拨打电话的逻辑封装到了
call()
方法中。 - 如果没有授权的话,则需要调用 ActivityCompat.requestPermissions()方法来向用户申请授权,该方法方法接收 3 个参数:
- 第一个参数要求是 Activity 的实例
- 第二个参数是一个 String 数组,我们把要申请的权限名放在数组中即可
- 第三个参数是请求码,只要是唯一值就可以了。
- 如果已经授权的话就简单了,直接去执行拨打电话的逻辑操作就可以了,这里我们把拨打电话的逻辑封装到了
- 调用完了
requestPermissions()
方法之后,系统会弹出一个权限申请的对话框,然后用户可以选择同意或拒绝权限申请,不论是哪种结果,最终都会回调到onRequestPermissionsResult()
方法中,而授权的结果则会封装在grantResults
参数当中。这里我们只需要判断一下最后的授权结果,如果用户同意的话就调用call()
方法来拨打电话,如果用户拒绝的话我们只能放弃操作,并且弹出一条失败提示。
现在重新运行一下程序,并点击按钮,会出现权限申请的弹窗,用户可以选择同意或拒绝。如果选择拒绝,则会弹出提示,下次再点击按钮仍会出现权限申请弹窗。如果选择同意,则会跳转到拨打电话界面,并且拨打了 10086,并且由于用户已经完成了授权操作,之后再点击按钮就不会再弹出权限申请对话框了,而是直接拨打电话。
访问其他程序中的数据
内容提供器的用法一般有两种,一种是使用现有的内容提供器来读取和操作相应程序中的数据,另一种是创建自己的内容提供器给我们程序的数据提供外部访问接口。
如果一个应用程序通过内容提供器对其数据提供了外部访问接口,那么任何其他的应用程序就都可以对这部分数据进行访问。Android 系统中自带的电话簿、短信、媒体库等程序都提供了类似的访问接口,这使得第三方应用程序可以充分地利用这部分数据来实现更好的功能。
ContentResolver 的基本用法
对于每一个应用程序来说,如果想要访问内容提供器中共享的数据,就一定要借助 ContentResolver 类,可以通过 Context 中的 getContentResolver()
方法获取到该类的实例。ContentResolver 中提供了一系列的方法用于对数据进行 CRUD 操作,其中 insert()
方法用于添加数据,update()
方法用于更新数据,delete()
方法用于删除数据,query()
方法用于查询数据。有没有似曾相识的感觉?没错,SQLiteDatabase 中也是使用这几个方法来进行 CRUD 操作的,只不过它们在方法参数上稍微有一些区别。
不同于 SQLiteDatabase,ContentResolver 中的增删改查方法都是不接收表名参数的,而是使用一个 Uri
参数代替,这个参数被称为内容 URI。内容 URI 给内容提供器中的数据建立了唯一标识符,它主要由两部分组成:authority 和 path。authority 是用于对不同的应用程序做区分的,一般为了避免冲突,都会采用程序包名的方式来进行命名。比如某个程序的包名是 com.example.app,那么该程序对应的 authority 就可以命名为 com.example.app.provider。path 则是用于对同一应用程序中不同的表做区分的,通常都会添加到 authority 的后面。比如某个程序的数据库里存在两张表:table1 和 table2,这时就可以将 path 分别命名为/table1 和/table2,然后把 authority 和 path 进行组合,内容 URI 就变成了 com.example.app.provider/table1 和 com.example.app.provider/table2。不过,目前还很难辨认出这两个字符串就是两个内容 URI,我们还需要在字符串的头部加上协议声明。因此,内容 URI 最标准的格式写法如下:
1 | content://com.example.app.provider/tablel |
内容 URI 可以非常清楚地表达出我们想要访问哪个程序中哪张表里的数据。也正是因此,ContentResolver 中的增删改查方法才都接收 Uri 对象作为参数,因为如果使用表名的话,系统将无法得知我们期望访问的是哪个应用程序里的表。
在得到了内容 URI 字符串之后,我们还需要将它解析成 Uri 对象才可以作为参数传人。解析的方法也相当简单,代码如下所示:
1 | Uri uri = Uri.parse("content://com.example.app.provider/table1") |
只需要调用 Uri.parse()
方法,就可以将内容 URI 字符串解析成 Uri 对象了。
接下来就是增删改查操作了,这部分与 SQLiteDatabase 的操作大同小异,只不过是多了一个 uri。
查询
示例代码如下所示:
1 | Cursor cursor = getContentResolver().query( |
这些参数和 SQLiteDatabase 中 query()
方法里的参数很像,但总体来说要简单一些,毕竟这是在访问其他程序中的数据,没必要构建过于复杂的查询语句。下表对使用到的这部分参数进行了详细的解释。
query()方法参数 | 对应 SQL 部分 | 描述 |
---|---|---|
uri |
from table_name |
指定查询某个应用程序下的某一张表 |
projection |
select column1, column2 |
指定查询的列名 |
selection |
where column = value |
指定 where 的约束条件 |
selectionArgs |
- |
为 where 中的占位符提供具体的值 |
sortOrder |
order by column1, column2 |
指定查询结果的排序方式 |
查询完成后返回的仍然是一个 Cursor 对象,这时我们就可以将数据从 Cursor 对象中逐个读取出来了。读取的思路仍然是通过移动游标的位置来遍历 Cursor 的所有行,然后再取出每一行中相应列的数据,代码如下所示:
1 | if(cursor != null){ |
添加
1 | ContentValues values = new ContentValues(); |
与 SQLiteDatabase 的操作相似,仍然是将待添加的数据组装到 ContentValues 中,然后调用 ContentResolver 的 insert()
方法,将 Uri 和 ContentValues 作为参数传入即可。
更新
1 | ContentValues values = new ContentValues(); |
上述代码通过 selection 和 selectionArgs 参数来指定符合条件的数据进行更新。
删除
调用 ContentResolver 的 delete()方法将这条数据删除掉,代码如下所示:
1 | getContentResolver().delete(uri,"column2 = ?",new String[]{"1"}); |
到这里为止,我们就把 ContentResolver 中的增删改查方法全部学完了。因为这些知识在学习 SQLiteDatabase 的时候就已经学习过了,所需特别注意的就只有 uri 这个参数而已。所以接下来直接进行实操演练一下。
创建自己的 ContentProvider
在上一节当中,我们学习了如何在自己的程序中访问其他应用程序的数据。总体来说思路还是非常简单的,只需要获取到该应用程序的内容 URI,然后借助 ContentResolver 进行 CRUD 操作就可以了。可是,那些提供外部访问接口的应用程序都是如何实现这种功能的呢?它们又是怎样保证数据的安全性?接下来我们就具体学习一下。
创建内容提供器的步骤
要想创建自己的 ContentProvider,可以通过新建一个类去继承 ContentProvider 的方式来创建。
ContentProvider 类中有 6 个抽象方法,我们在使用子类继承它的时候,需要将这 6 个方法全部重写。新建 MyContentProvider 继承自 ContentProvider,代码如下所示:
1 | public class MyContentProvider extends ContentProvider { |
- onCreate()
初始化内容提供器的时候调用。通常会在这里完成对数据库的创建和升级等操作,返回 true 表示内容提供器初始化成功,返回 false 则表示失败。注意,只有当存在 ContentResolver 尝试访问我们程序中的数据时,内容提供器才会被初始化。 - query()
从内容提供器中查询数据。使用 uri 参数来确定查询哪张表,projection 参数用于确定查询哪些列,selection 和 selectionArgs 参数用于约束查询哪些行,sortOrder 参数用于对结果进行排序,查询的结果存放在 Cursor 对象中返回。 - insert()
向内容提供器中添加一条数据。使用 uri 参数来确定要添加到的表,待添加的数据保存在 values 参数中。添加完成后,返回一个用于表示这条新记录的 URI。 - update()
更新内容提供器中已有的数据。使用 uri 参数来确定更新哪一张表中的数据,新数据保存在 values 参数中,selection 和 selectionArgs 参数用于约束更新哪些行,受影响的行数将作为返回值返回。 - delete()
从内容提供器中删除数据。使用 uri 参数来确定删除哪一张表中的数据,selection 和 selectionArgs 参数用于约束删除哪些行,被删除的行数将作为返回值返回。 - getType()
根据传入的内容 URI 来返回相应的 MIME 类型。
在上一节中,我们提及到了标准 uri 的格式为:
1 | content://com.example.app.provider/tablel/1 |
这就表示调用方期望访问的是 com.example.app
这个应用的 table1 表中的数据。除此之外,我们还可以在这个内容 URI 的后面加上一个 id,这就表示调用方期望访问的是 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 和一个自定义代码传进去。这样,当调用 UriMatcher
的 match()
方法时,就可以将一个 Uri 对象传入,返回值是某个能够匹配这个 U 对象所对应的自定义代码,利用这个代码,我们就可以判断出调用方期望访问的是哪张表中的数据了。修改 MyContentProvider 中的代码,如下所示:
1 | public class MyContentProvider extends ContentProvider { |
可以看到,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 | public class MyContentProvider extends ContentProvider { |
到这里,一个完整的内容提供器就创建完成了,现在任何一个应用程序都可以使用 ContentResolver 来访问我们程序中的数据。至于前面所提到的保证隐私数据安全的功能,因为所有的 CRUD 操作都一定要匹配到相应的内容 URI 格式才能进行的,而我们当然不可能向 UriMatcher 中添加隐私数据的 URI,所以这部分数据根本无法被外部程序访问到,安全问题也就不存在了。好了,接下来就来实战一下,真正体验一下跨程序数据共享的功能。
实现跨程序数据共享
为了突出重点,我们直接在上一章中 SQLite 数据库存储的项目的基础上继续开发,通过内容提供器来给它加入外部访问接口。为此我们需要给项目创建一个内容提供器,右击包名 →New→Other→Content Provider,在弹出的窗口中,我们将内容提供器命名为 DatabaseProvider
,authority 指定为 com.example.databasetest.provider
,Exported
属性表示是否允许外部程序访问我们的内容提供器,Enabled
属性表示是否启用这个内容提供器。将两个属性都勾中,点击 Finish 完成创建。
接着我们修改 DatabaseProvider 中的代码,如下所示:
1 | public class DatabaseProvider extends ContentProvider { |
首先在类的一开始,同样是定义了 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 | <manifest xmlns:android="http://schemas.android.com/apk/res/android" |
可以看到,<application>
标签内出现了一个新的标签 <provider>
,我们使用它来对 DatabaseProvider 这个内容提供器进行注册。android:name
属性指定了 DatabaseProvider 的类名,android:authorities
属性指定了 DatabaseProvider 的 authority,而 enabled
和 exported
属性则是根据我们刚才勾选的状态自动生成的,这里表示允许 DatabaseProvider 被其他应用程序进行访问。
现在这个项目就已经拥有了跨程序共享数据的功能了。为了验证一下,我们需要另外创建一个新项目 ProviderTest 来访问刚才编写好的项目中的数据。
新项目 ProviderTest 布局文件如下所示:
1 | <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" |
简简单单的 4 个按钮,分别用于添加、查询、修改和删除数据。然后修改 MainActivity 中的代码,如下所示:
1 | public class MainActivity extends AppCompatActivity { |
可以看到,我们分别在这 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()
方法执行删除操作就可以了。