一、跨程序共享数据——内容提供器简介

  内容提供器(Content Provider)主要用于在不同的应用程序之间实现数据共享的功能,它提供了一套完整的机制,允许一个程序访问另一个程序中的数据,同时还能保证被访数据的安全性,目前,使用内容提供器是Android实现跨程序共享数据的标准方式。

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

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

二、运行时权限

  Android开发团队在Android6.0系统中引用了运行时权限这个功能,比之前的Android权限机制能更好的保护用户的安全和隐私。

2.1、Android权限机制详解

  Android现在将所有的权限归成了两类:一类是普通权限,一类是危险权限。普通权限是指那些不会直接威胁到用户的安全和隐私的权限,对于这部分权限申请,系统会自动帮我们进行授权,而不需要用户再去手动操作了,比如查看网络连接,开机自启等权限;危险权限则表示那些可能会触及用户隐私,或者对设备安全性造成影响的权限,如获取设备联系人信息、定位设备的地理位置等,对于这部分权限申请,必须要有用户手动点击授权才可以,否则程序就无法使用相应的功能。

  虽然Android中一共有上百种权限,但是危险权限一共就只有9组24个,其他剩下的就都是普通权限。下面是危险权限的列表:

注意:表格中每个危险权限都是属于一个权限组,我们在进行运行时权限处理时使用的是权限名,但是用户一旦同意授权了,那么该权限所对应的权限组中所有的其他权限也会同时被授权。

  查看Android系统完整的权限列表:http://developer.android.com/reference/android/Manifest.permission.html

 2.2、在程序运行时申请权限

   为简单起见,我们使用CALL_PHONE这个危险权限来作为本小节中的示例,CALL_PHONE这个权限是编写拨打电话功能的时候需要声明的,因为拨打电话会涉及用户手机的资费问题,因而被列为了危险权限,在Android6.0系统出现之前,拨打电话功能的实现其实非常简单:

第一步:设置一个按钮,通过点击按钮来完成拨打电话的操作

第二步:在MainActivity中实现点击按钮的逻辑功能

第三步:在AndroidManifest中添加权限声明

第四步:分别在Android系统低于6.0和高于6.0的模拟器上运行,点击按钮,效果如下:Android系统低于6.0(左)、Android系统高于6.0(右)

   

第五步:效果分析

  从运行效果中我们可以看出,在Android系统小于6.0的手机上,点击按钮后是直接就可以打电话了,但是在Android系统高于6.0的手机上,点击按钮后,并没有打电话。这时候我们查看Logcat日志,发现抛出了以下异常:(Permission Denial,权限被禁止,说明6.0以上系统在使用危险权限时都必须进行运行时权限处理)

接下来我们尝试修复这个问题:

第六步:修改MainActivity中的代码:

代码分析:

  上面的代码将运行时权限的完整流程都覆盖了。运行时权限的核心其实就是在程序运行的过程中由用户授权我们去执行某些危险操作,程序是不可以擅自做主去执行这些危险操作的。因此第一步,要先判断用户是不是已经给我们授权了,借助的是:ContextCompat.checkSelfPermission()方法,checkSelfPermission()方法接收两个参数,第一个参数是:Context,第二个参数是具体的权限名,比如打电话的权限名就是:Manifest.permission.CALL_PHONE,然后我们使用方法的返回值和:PackageManager.PERMISSION_GRANTED做比较,相等就说明用户已经授权,不等就表示用户没有授权。

  如果已经授权的话,就直接去执行打电话的逻辑操作就行了,这里把拨打电话的逻辑封装到了:call()方法中。如果没有授权,则需要调用ActivityCompat.requestPermissions()方法来向用户申请授权,requestPermissions()方法接收3个参数,第一个参数要求是Activity的实例,第二个参数是一个String数组,把要申请的权限名放在数组中即可,第三个参数是请求码,只要是唯一值就可以了,这里传入的是1.

  调用完了:requestPermissions()方法之后,系统会弹出一个权限申请的对话框,然后用户可以选择同意或拒绝我们的权限申请,不论是哪种结果,最终都会回调到:onRequestPermissionsResult()方法中,而授权的结果则会封装在:grantResults参数当中,这里我们只需要判断一下最后的授权结果:

  1、如果用户同意的话就会调用:call()方法来拨打电话,并且后面如果再点击:Make Call 按钮,就不会再弹出权限申请的对话框了,而是直接就拨打电话了,如果想要关闭权限,进入:setting ---> Apps ---> RunTimePermissionTest ---> Permissions,关闭权限就行了。

  2、如果用户拒绝的话我们只能放弃操作,并且弹出一条失败提示。下次再点击按钮,仍然会再弹出权限申请的对话框。

第七步:再Android系统大于6.0的模拟器上运行程序,点击按钮,查看运行状态:

 

      (授权申请对话框)                                                      (不授权)

 

       (授权)                  (关闭授权)

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

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

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

 3.1、ContentResolver的基本用法

   对于一个应用程序来说,如果想要访问内容提供器中共享的数据,就一定要借助ContentResolver类,可以通过Context中的:getContentResolver()方法获取到该类的实例。ContentResolver中提供了一系列的方法用于对数据进行CRUD操作,其中:insert()方法用于添加数据,update()方法用于更新数据,delete()方法用于删除数据,query()方法用于查询数据。这和SQLiteDatabase相似,但不同于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最标准的格式写法如下:

  content://com.example.app.provider/table1

  content://com.example.app.provider/table2

 内容URI可以很清楚的表达我们想要访问哪个程序中的哪张表里的数据。在得到了内容URI字符串之后,我们还需要将它解析成Uri对象才可以作为参数传入,解析的方法也很简单,代码如下:只需要调用:Uri.parse()方法,就可以将内容URI字符串解析成Uri对象了。

现在我们就能使用这个Uri对象来查询table1表中的数据了,代码如下:

这些参数和SQLiteDatabase中query()方法里的参数很像,但是总体来说要简单一些,下表是对这些参数的详细解释:

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

掌握了最难的查询操作,剩下的增加、修改、删除操作就轻松多了,先看向table1表中添加一条数据,代码如下:

可以看到,仍然是将待添加的数据组装到:ContentValues中,然后调用ContentResolver的:insert()方法,将Uri和ContentValues作为参数传入即可。如果现在我们想要更新这条新添加的数据,把column1的值清空,可以借助ContentResolver的:update()方法实现,代码如下:

注意上述代码使用了selection和selectionArgs参数来对想要更新的数据进行约束,以防止所有的行都会受到影响。

最后可以调用ContentResolver的:delete()方法将这条数据删除掉,代码如下:

到此为止,我们就把ContentResolver中的增删改查方法全部学完了,接下来我们就看看如何读取系统电话簿中的联系人。

3.2、读取系统联系人

   由于一直使用都是模拟器,电话簿里面并没有联系人存在,所以现在需要自己手动添加几个,以便稍后进行读取。添加如下两个联系人:

第一步:新建一个ContactsTest项目,在布局文件中只放置一个ListView,我们希望读取出来的联系人信息能够在ListView中显示

第二步:在MainActivity中实现逻辑功能

代码分析:

  在onCreate()方法中,我们首先获取到了ListView控件的实例,并给它设置好了适配器,然后开始调用运行时权限的处理逻辑,因为READ_CONTACTS权限是属于危险权限,关于运行时权限的处理流程这里就不多说了,这里用户授权之后调用:readContacts()方法来读取系统联系人信息。

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

第三步:在AndroidManifest.xml中进行读取系统联系人的权限声明:

第四步:运行程序(左),点击ALLOW授权后显示出系统联系人和手机号(右)

 

四、创建自己的内容提供器

4.1、创建内容提供器的步骤

   在上一节中,我们学习了如何在自己的程序中访问其他应用程序的数据,总体来说,思路比较简单,只需要获取到该应用程序的内容URI,然后借助ContentResolver进行CRUD操作。那么那些提供外部访问接口的应用程序都是如何实现这种功能的呢?

  如果想要实现跨程序共享数据的功能,官方推荐的方式就是使用内容提供器,可以通过新建一个类去继承ContentProvider的方式来创建一个自己的内容提供器。ContentProvider类中有6个抽象方法,我们在使用子类继承它的时候,需要将这6个方法全部重写。代码如下:

  从上面我们可以看到,几乎每一个方法都会带有uri这个参数,这个参数也正是调用ContentResolver的增删改查方法是传递过来的,而现在我们需要对传入的Uri参数进行解析,从中分析出调用方期望访问的表和数据。

  回顾一下,一个标准的内容URI写法是这样的

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

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

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

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

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

 

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

  可以看到,MyProvider中新增了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个部分做了如下格式规定:

  1、必须以vnd开头

  2、如果内容URI以路径结尾,则后接:android.cursor.dir/,如果内容URI以id结尾,则后接:android.cursor.item/

  3、最后接上:vnd.<authority>.<path>

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

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

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

  到这里,一个完整的内容提供器就创建完成了,现在任何一个应用程序都可以使用ContentResolver来访问我们程序中的数据。那么如何保证隐私数据不会泄露出去呢?其实多亏了内容提供器的良好机制,这个问题在不知不觉中已经解决了,因为所有的CRUD操作都一定要匹配到相应的内容URI格式才能进行的,而我们当然不可能向UriMatcher中添加隐私数据的URI,所以这部分数据根本无法被外部程序访问到,安全问题也就不存在了。

  接下来通过实际案例来体验跨程序的数据共享功能:

 4.2实现跨程序的数据共享

第一步:创建内容提供器:

  简单起见,我们还是在上一章中的DatabaseTest项目的基础上继续开发,通过内容提供器来给它加入外部访问接口。打开DatabaseTest项目,首先将MyDatabaseHelper中使用Toast弹出创建数据库成功的提示去掉,因为跨程序访问时我们不直接使用Toast,然后创建一个内容提供器,右击:com.workspace.hh.databasetest包 ---> New ---> Other ---> Content Provider,如图所示:

  这里我们将内容提供器命名为:DatabaseProvider,authority指定为:com.workspace.hh.databasetest.provider,Exported属性表示是否允许外部程序访问我们的内容提供器,Enabled属性表示是否启用这个内容提供器,将两个属性都勾中,点击Finish完成创建

第二步:修改DatabaseProvider中的代码:

  

代码分析:

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

  2、接下来是每个抽象方法的具体实现,先看:onCreate()方法,这个方法的代码很短,就是创建一个MyDatabaseHelper的实例,然后返回true表示内容提供器初始化成功,这时候数据库就已经完成了创建或升级操作。

  3、query()方法:这个方法中先获取到SQLiteDatabase的实例,然后根据传入的Uri参数判断出用户想要访问哪张表,再调用SQLiteDatabase的query()进行查询,并将Cursor对象返回就好了。注意当访问单条数据的时候有一个细节,这里调用看Uri对象的:getPathSegments()方法,它会将内容URI权限之后的部分以“/”符号进行分割,并把分割后的结果放入到一个字符串列表中,那这个列表的第0个位置存放的就是路径,第一个位置存放的就是id了。得到了id之后,再通过selection和selectionArgs参数进行约束,就实现了查询单条数据的功能。

  4、insert()方法,同样它也是先获取到SQLiteDatabase的实例,然后根据传入的Uri参数判断出用户想要往哪张表里添加数据,再调用SQLiteDatabase的insert()方法进行添加就可以了,注意insert()方法要求返回一个能够表示这条新增数据的URI,所以我们还需要调用:Uri.parse()方法来将一个内容URI解析成Uri对象,当然这个内容URI是以新增数据的id结尾的。

  5、update()方法:先获取SQLiteDatabase实例,然后根据传入的Uri参数判断出用户想要更新哪张表里的数据,再调用SQLiteDatabase的update()方法进行更新就好了,受影响的行数将作为返回值返回

  6、delete()方法,先获取SQLiteDatebase的实例,然后根据传入的Uri参数判断出用户想要删除哪张表里的数据,再调用SQLiteDatebase的delete()方法进行删除就好了,被删除的行数将作为返回值返回。

  7、getType()方法:这个方法中的代码完全是按照上一节中介绍的格式规则编写的,这里就不多说了。

第三步:在AndroidManifest中注册内容提供器:

  注意:内容提供器一定要在AndroidManifest文件中注册后才能使用,这里我们使用的是Android Studio的快捷方式创建的内容提供器,因此注册这一步已经被自动完成了。

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

第四步:先将DatebaseTest程序从模拟器中删除,以防止上一章中产生的遗留数据对我们造成干扰。然后重新在模拟器上运行程序,重新安装程序,点击Create Book按钮,先创建表,然后关闭DatebaseTest这个项目,重新创建一个新的项目:ProviderTest,我们将通过这个程序去访问DatebaseTest中的数据。

第五步:在activity_main.xml文件中添加四个按钮:添加、查询、更新、删除。

第六步:在MainActivity中实现按钮的逻辑功能

 

 代码分析:

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

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

  3、更新数据:先将内容URI解析成Uri对象,然后把想要更新的数据存放到ContentValues对象中,在调用ContentResolver的:update()方法执行更新操作就可以了,注意这里我们为了不想让Book表中的其他行受到影响,在调用:Uri.parse()方法时,给内容URI的尾部增加了一个id,而这个id正是添加数据时所返回的,这就是我们只希望更新刚刚添加的那条数据,Book表中的其他行都不会受到影响。

  4、删除数据的时候,也是 使用同样的方法解析了一个以id结尾的内容URI,然后调用ContentResolver的:delete()方法执行删除操作就可以了,由于我们在内容URI里指定了一个id,因此只会删掉拥有相应id的那行数据,Book表中的其他数据不会受到影响。

第七步:运行程序

  1、点击:Add to Book按钮添加数据,然后再点击:Query from Book来检查是否添加成功

 

  2、更新数据