Android 数据持久化技术,包括文件存储、SharedPreferences 存储以及数据库存储,使用这些持久化技术所保存的数据只能在当前应用程序中访问。虽然文件存储和 SharedPreferences 存储中提供了 MODE_WORLD_READABLE 和 MODE_WORLD_WRITEABLE 这两种操作模式,用于供给其他应用程序访问当前应用的数据,但 这两种模式在 Android 4.2 版本中都已被废弃了。因为 Android 官方已经不再推荐使用这种方式来实现跨程序数据共享的功能,而是推荐使用更加安全可靠的 ContentProvider 技术。
为什么要将我们程序中的数据共享给其他程序呢?这个是要视情况而定的,比如账号和密码这样的隐私数据显然是不能共享给其他程序的,不过一些可以让其他程序进行二次开发的数据是可以共享的。例如系统的通讯录程序,它的数据库中保存了很多联系人信息。除了通讯录之外,还有短信、媒体库等程序都实现了跨程序数据共享的功能,而使用的技术是 ContentProvider。
1 ContentProvider 简介
ContentProvider 为存储和获取数据提供统一的接口,它可以在不同的应用程序之间共享数据,本身是适合进程间通信的。
ContentProvider 是通过 Binder 机制来实现在不同的应用程序之间进行数据共享。它提供了一套完整的机制,提供统一的接口,允许一个程序访问另一个程序中的数据,同时还能保证被访问数据的安全性。 目前,使用 ContentProvider 是 Android 实现跨程序共享数据的标准方式。
不同于文件存储和 SharedPreferences 存储中的两种全局可读写操作模式,ContentProvider 可以选择只对哪一部分数据进行共享,从而保证我们程序中的隐私数据不会有泄漏的风险。
ContentProvider 的优点:
- 采用 ContentProvider 方式,解耦了底层数据的存储方式,使得无论底层数据存储采用何种方式,外界对数的访问方式都是统一的,这使得访问变得简单而且高效。 如一开始数据存储方式采用 SQLite 数据库,后来把数据库换成 MongoDB,也不会对上层数据 ContentProvider 使用代码产生影响;
- 提供一种跨进程数据共享的方式;
- 应用程序间的数据共享还有另外的一个重要话题,就是数据更新通知机制。 因为数据是在多个应用程序中共享的,当其中一个应用程序改变了这些共享数据的时候,它有责任通知其它应用程序,让它们知道共享数据被修改了,这样它们就可以作相应的处理;
2 访问其他程序中的数据
ContentProvider 的用法一般有两种:
- 一种是使用现有的 ContentProvider 读取和操作相应程序中的数据;
- 另一种是创建自己的 ContentProvider,给程序的数据提供外部访问接口;
如果一个应用程序通过 ContentProvider 对其数据提供了外部访问接口,那么任何其他的应用程序都可以对这部分数据进行访问。Android 系统中自带的通讯录、短信、媒体库等程序都提供了类似的访问接口,这就使得第三方应用程序可以充分地利用这部分数据实现更好的功能。
2.1 ContentResolver 的基本用法
对于每一个应用程序来说,如果想要访问 ContentProvider 中共享的数据,就一定要借助 ContentResolver 类,可以通过 Context 中的 getContentResolver() 方法获取该类的实例。ContentResolver 中提供了一系列的方法用于对数据进行增删改查操作,其中 insert() 方法用于添加数据,update() 方法用于更新数据,delete() 方法用于删除数据,query() 方法用于查询数据。
- ContentProvider(内容提供者):提供数据的增删改查操作,数据源可以是数据库、文件、XML、网络等;
- ContentResolver(内容解析者):外部进程可以通过 ContentResolver 与 ContentProvider 行交互。通过 ContentProvider 的增、删、改、查方法实现对共享数据的操作;
- ContentObserver(内容观察者):监听数据是否发生了变化,并通知给外界;
provider [prəˈvaɪdər] 供应者;养家者 resolver [rɪˈzɑːlvər] 溶剂;[电子] 分解器;下决心者
observer [əbˈzɜːrvər] 观察者
ContentResolver 中的增删改查方法使用一个 Uri 参数,这个参数被称为内容 URI,内容 URI 给ContentProvider 中的数据建立了唯一标识符。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 可以非常清楚地表达我们想要访问哪个程序中哪张表里的数据。也正是因此,ContentResolver 中的增删改查方法才都接收 Uri 对象作为参数。如果使用表名的话,系统将无法得知我们期望访问的是哪个应用程序里的表。
Uri(Uniform Resource Identifier),统一资源标识符。URI 分为系统预置和自定义,分别对应系统内置的数据(如通讯录、日程表等等)和自定义数据库。每一个 ContentProvider 都拥有一个公共的 URI ,这个 URI 用于表示这个 ContentProvider 所提供的数据。
uniform [ˈjuːnɪfɔːrm] 全部相同的 resource [ˈriːsɔːrs; rɪˈsɔːrs] 资源 identifier [aɪˈdentɪfaɪər] 标识符
- 主题(schema):ContentProvider 的标准前缀 content://
- 授权信息(authority):ContentProvider 的唯一标识符,外部调用着可以根据这个标识找到它
- 表名/路径(path):通俗来讲是要操作的数据库中的某个表名
- 如果 URI 中包含表示需要获取的记录的 ID,则返回改 ID 对应的数据,如果没有 ID,就表示返回全部
schema [ˈskiːmə] 模式; authority [əˈθɔːrəti] 权利,权限;权威,授权
在得到了内容 URI 字符串之后,我们还需要将它解析成 Uri 对象才可以作为参数传入。解析的方法也相当简单,代码如下所示:
val uri = Uri.parse("content://com.example.app.provider/table1")
只需要调用 Uri.parse() 方法,就可以将内容 URI 字符串解析成 Uri 对象了。
现在我们就可以使用这个 Uri 对象查询 table1 表中的数据了,代码如下所示:
val cursor = contentResolver.query(
uri,
projection,
selection,
selectionArgs,
sortOrder)
下图对使用到的这部分参数进行了详细的解释:
查询完成后返回的仍然是一个 Cursor 对象,这时就可以将数据从 Cursor 对象中逐个读取出来了。读取的思路仍然是通过移动游标的位置遍历 Cursor 的所有行,然后取出每一行中相应列的数据,代码如下所示:
while (cursor.moveToNext()) {
val column1 = cursor.getString(cursor.getColumnIndex("column1"))
val column2 = cursor.getInt(cursor.getColumnIndex("column2"))
}
cursor.close()
看如何向 table1表中添加一条数据,代码如下所示:
val values = contentValuesOf("column1" to "text", "column2" to 1)
contentResolver.insert(uri, values)
可以看到,仍然是将待添加的数据组装到 ContentValues 中,然后调用 ContentResolver 的 insert() 方法,将 Uri 和 ContentValues 作为参数传入即可。
如果我们想要更新这条新添加的数据,把 column1 的值清空,可以借助 ContentResolver 的 update() 方法实现,代码如下所示:
val values = contentValuesOf("column1" to "")
contentResolver.update(uri, values, "column1 = ? and column2 = ?", arrayOf("text", "1"))
注意,上述代码使用了 selection 和 selectionArgs 参数来对想要更新的数据进行约束,以防止所有的行都会受影响。
最后,可以调用 ContentResolver 的 delete() 方法将这条数据删除掉,代码如下所示:
contentResolver.delete(uri, "column2 = ?", arrayOf("1"))
2.2 读取系统联系人
在模拟器的通讯录里面并没有联系人存在,所以现在需要自己手动添加几个,以便稍后进行读取。打开通讯录程序,界面如图所示:
可以看到,目前通讯录里没有任何联系人,我们可以通过点击 Create new contact 创建联系 人。这里就先创建两个联系人吧,分别填入他们的姓名和手机号,如图所示:
这样准备工作就做好了,首先还是来编写一下布局文件,这里我们希望读取出来的联系人信息能够在 ListView 中显示, 因此,修改 activity_main.xml 中的代码,如下所示:
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
tools:context=".MainActivity">
<ListView
android:id="@+id/contactsView"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
简单起见,LinearLayout 里只放置了一个 ListView。这里之所以使用 ListView 而不是 RecyclerView,是因为我们要将关注的重点放在读取系统联系人上面,如果使用 RecyclerView 的话,代码偏多,会容易让我们找不着重点。
接着修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private val contactsList = ArrayList<String>()
private lateinit var adapter: ArrayAdapter<String>
private lateinit var contactsView: ListView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
contactsView = findViewById(R.id.contactsView)
adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, contactsList)
contactsView.adapter = adapter
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.READ_CONTACTS
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.READ_CONTACTS), 1)
} else {
readContacts()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
when (requestCode) {
1 -> {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
readContacts()
} else {
Toast.makeText(this, "You denied the permission", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun readContacts() {
// 查询联系人数据
contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
null,
null,
null,
null
)?.apply {
while (moveToNext()) {
// 获取联系人姓名
val displayName =
getString(getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME))
val number =
getString(getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER))
contactsList.add("$displayName\n$number")
}
adapter.notifyDataSetChanged()
close()
}
}
}
在 onCreate() 方法中,首先按照 ListView 的标准用法对其初始化,然后开始调用运行时权限的处理逻辑,因为 READ_CONTACTS 权限属于危险权限。在获取用户授权之后,调用 readContacts() 方法读取系统联系人信息。
在 readContacts() 方法中使用了 ContentResolver. query() 方法查询系统的联系人数据。不过传入的 Uri 参数并没有调用 Uri.parse() 方法去解析一个内 URI 字符串,这是因为 ContactsContract.CommonDataKinds.Phone 类已经做好了封装,提供了一个 CONTENT_URI 常量,而这个常量就是使用 Uri.parse() 方法解析出来的结果。接着对 query() 方法返回的 Cursor 对象进行遍历,这里使用了 ?. 操作符和 apply 函数来简化遍历的代码。在 apply 函数中将联系人姓名和手机号逐个取出,联系人姓名这一列对应的常量是 ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,联系人手机号这一列对 应的常量是 ContactsContract.CommonDataKinds.Phone.NUMBER。将两个数据取出后进行拼接,并且在中间加上换行符,然后将拼接后的数据添加到 ListView 的数据源里,并通知刷新一下 ListView,最后将 Cursor 对象关闭。
最后在 AndroidManifest.xml 中声明权限,如下所示:
<uses-permission android:name="android.permission.READ_CONTACTS" />
运行程序,效果如图所示:
首先弹出了申请访问联系人权限的对话框,点击 Allow,结果如图所示:
3 创建自己的 ContentProvider
3.1 创建 ContentProvider 的步骤
如果想要实现跨程序共享数据的功能,可以通过新建一个类去继承 ContentProvider 的方式来实现。ContentProvider 类中有 6 个抽象方法,我们在使用子类继承 它的时候,需要将这 6 个方法全部重写:
class MyProvider : ContentProvider() {
override fun onCreate(): Boolean {
TODO("Not yet implemented")
}
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
TODO("Not yet implemented")
}
override fun getType(uri: Uri): String? {
TODO("Not yet implemented")
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
TODO("Not yet implemented")
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
TODO("Not yet implemented")
}
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?
): Int {
TODO("Not yet implemented")
}
}
- onCreate():初始化 ContentProvider 的时候调用。 通常会在这里完成对数据库的创建和升级等操作,返回 true 表示 ContentProvider 初始化成功,返回 false 则表示失败
- update():更新 ContentProvider 中已有的数据。 uri 参数用于确定更新哪一张表中的数 据,新数据保存在 values 参数中,selection 和 selectionArgs 参数用于约束更新哪些行,受影响的行数将作为返回值返回
- delete():从 ContentProvider 中删除数据。 uri 参数用于确定删除哪一张表中的数据,selection 和 selectionArgs 参数用于约束删除哪些行,被删除的行数将作为返回值返回
- getType() :根据传入的内容 URI 返回相应的 MIME 类型;
- insert():向 ContentProvider 中添加一条数据。 uri 参数用于确定要添加到的表,待添加的数据保存在 values 参数中。添加完成后,返回一个用于表示这条新记录的 URI;
很多方法里带有 uri 这个参数,这个参数也正是调用 ContentResolver 的增删改查方法时传递过来的。需要对传入的 uri 参数进行解析,从中分析出调用方期望访问的表和数据。
回顾一下,一个标准的内容 URI 写法是:
content://com.example.app.provider/table1
这就表示调用方期望访问的是 com.example.app 这个应用的 table1 表中的数据。
除此之外,我们还可以在这个内容 URI 的后面加上一个 id,例如:
content://com.example.app.provider/table/1
这就表示调用方期望访问的是 com.example.app 这个应用的 table1 表中 id 为 1 的数据。
内容 URI 的格式主要就只有以上两种,以路径结尾表示期望访问该表中所有的数据,以 id 结尾表示期望访问该表中拥有相应 id 的数据。我们可以使用通配符分别匹配这两种格式的内容 URI,规则如下:
- *表示匹配任意长度的任意字符
- # 表示匹配任意长度的数字
所以,一个能够匹配任意表的内容 URI 格式就可以写成:
content://com.example.app.provider/*
一个能够匹配 table1 表中任意一行数据的内容 URI 格式就可以写成:
content://com.example.app.provider/table/#
接着,再借助 UriMatcher 这个类就可以轻松地实现匹配内容 URI 的功能。UriMatcher 中提供了一个 addURI() 方法,这个方法接收 3 个参数,可以分别把 authority、path 和一个自定义代码传进去。这样,当调用 UriMatcher.match() 方法时,就可以将一个 Uri 对象传入,返回值是某个能够匹配这个 Uri 对象所对应的自定义代码, 利用这个代码,我们就可以判断出调用方期望访问的是哪张表中的数据了。修改 MyProvider 中的代码,如下所示:
class MyProvider : ContentProvider() {
private val table1Dir = 0
private val table1Item = 1
private val table2Dir = 2
private val table2Item = 3
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
init {
uriMatcher.addURI("com.example.kotlintest.provider", "table1", table1Dir)
uriMatcher.addURI("com.example.kotlintest.provider", "table1/#", table1Item)
uriMatcher.addURI("com.example.kotlintest.provider", "table2", table2Dir)
uriMatcher.addURI("com.example.kotlintest.provider", "table2/#", table2Item)
}
...
override fun query(
uri: Uri,
projection: Array<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
sortOrder: String?
): Cursor? {
when (uriMatcher.match(uri)) {
table1Dir -> {
// 查询table1表中的所有数据
}
table1Item -> {
// 查询table1表中的单条数据
}
table2Dir -> {
// 查询table2表中的所有数据
}
table2Item -> {
// 查询table2表中的单条数据
}
}
...
}
...
}
可以看到,MyProvider 中新增了 4 个整型变量,其中 table1Dir 表示访问 table1 表中的所有数 据,table1Item 表示访问 table1 表中的单条数据,table2Dir 表示访问 table2 表中的所有数据,table2Item 表示访问 table2 表中的单条数据。接着我们在 MyProvider 类实例化的时候立刻创建了 UriMatcher 的实例,并调用 addURI() 方法,将期望匹配的内容 URI 格式传递进 去,注意这里传入的路径参数是可以使用通配符的。然后当 query() 方法被调用的时候,就会通过UriMatcher.match() 方法对传入的 Uri 对象进行匹配,如果发现 UriMatcher 中某个内容 URI 格式成功匹配了该 Uri 对象,则会返回相应的自定义代码,然后我们就可以判断出调用方期望访问的到底是什么数据了。
上述代码只是以 query() 方法为例做了个示范,其实 insert()、update()、delete() 这几个方法的实现是差不多的,它们都会携带 uri 这个参数,然后同样利用 UriMatcher.match() 方法判断出调用方期望访问的是哪张表,再对该表中的数据进行相应的操作就可以 了。
除此之外,还有一个方法你可能会比较陌生,即 getType() 方法。它是所有的 ContentProvider 都必须提供的一个方法,用于获取 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 类型就可以写成:
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() 方法中的逻辑,代码如下所示:
class MyProvider : ContentProvider() {
...
override fun getType(uri: Uri): String? = when (uriMatcher.match(uri)) {
table1Dir -> "vnd.android.cursor.dir/vnd.com.example.kotlintest.provider.table1"
table1Item -> "vnd.android.cursor.item/vnd.com.example.kotlintest.provider.table1"
table2Dir -> "vnd.android.cursor.dir/vnd.com.example.kotlintest.provider.table2"
table2Item -> "vnd.android.cursor.item/vnd.com.example.kotlintest.provider.table2"
else -> null
}
...
}
到这里,一个完整的 ContentProvider 就创建完成了,现在任何一个应用程序都可以使用 ContentResolver 访问程序中的数据。因为所有的增删改查操作都一定要匹配到相应的内容 URI 格式才能进行,保证了隐私数据不会泄漏出去。而我们当然不可能向 UriMatcher 中添加隐私数据的 URI,所以这部分数据根本无法被外部程序访问,安全问题也就不存在了。
3.2 实现跨程序数据共享
简创建一个 ContentProvider,New→Other→Content Provider,会弹出如图所示的窗口:
可以看到,我们将 ContentProvider 命名为 DatabaseProvider,将 authority 指定为 com.example.kotlintest.provider,Exported 属性表示是否允许外部程序访问我们的 ContentProvider,Enabled 属性表示是否启用这个 ContentProvider。将两个属性都勾中,点击 Finish 完成创建。
接着我们修改 DatabaseProvider 中的代码,如下所示:
class DatabaseProvider : ContentProvider() {
private val bookDir = 0
private val bookItem = 1
private val categoryDir = 2
private val categoryItem = 3
private val authority = "com.example.kotlintest.provider"
private var dbHelper: MyDatabaseHelper? = null
private val uriMatcher by lazy {
val mather = UriMatcher(UriMatcher.NO_MATCH)
mather.addURI(authority, "book", bookDir)
mather.addURI(authority, "book/#", bookItem)
mather.addURI(authority, "category", categoryDir)
mather.addURI(authority, "category/#", categoryItem)
mather
}
override fun onCreate() = context?.let {
dbHelper = MyDatabaseHelper(it, "BookStore.db", 2)
true
} ?: false
override fun query(
uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?
) = dbHelper?.let {
// 查询数据
val db = it.readableDatabase
val cursor = when (uriMatcher.match(uri)) {
bookDir -> db.query("Book", projection, selection, selectionArgs, null, null, sortOrder)
bookItem -> {
val bookId = uri.pathSegments[1]
db.query("Book", projection, "id = ?", arrayOf(bookId), null, null, sortOrder)
}
categoryDir -> db.query(
"Category",
projection,
selection,
selectionArgs,
null,
null,
sortOrder
)
categoryItem -> {
val categoryId = uri.pathSegments[1]
db.query(
"Category",
projection,
"id = ?",
arrayOf(categoryId),
null,
null,
sortOrder
)
}
else -> null
}
cursor
}
override fun insert(uri: Uri, values: ContentValues?) = dbHelper?.let {
// 添加数据
val db = it.writableDatabase
val uriReturn = when (uriMatcher.match(uri)) {
bookDir, bookItem -> {
val newBookId = db.insert("Book", null, values)
Uri.parse("content://$authority/book/$newBookId")
}
categoryDir, categoryItem -> {
val newCategoryId = db.insert("Category", null, values)
Uri.parse("content://$authority/category/$newCategoryId")
}
else -> null
}
uriReturn
}
override fun update(
uri: Uri, values: ContentValues?, selection: String?,
selectionArgs: Array<String>?
) = dbHelper?.let {
// 更新数据
val db = it.writableDatabase
val updateRows = when (uriMatcher.match(uri)) {
bookDir -> db.update("Book", values, selection, selectionArgs)
bookItem -> {
val bookId = uri.pathSegments[1]
db.update("Book", values, "id = ?", arrayOf(bookId))
}
categoryDir -> db.update("Category", values, selection, selectionArgs)
categoryItem -> {
val categoryId = uri.pathSegments[1]
db.update("Category", values, "id = ?", arrayOf(categoryId))
}
else -> 0
}
updateRows
} ?: 0
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?) =
dbHelper?.let {
// 删除数据
val db = it.writableDatabase
val deleteRows = when (uriMatcher.match(uri)) {
bookDir -> db.delete("Book", selection, selectionArgs)
bookItem -> {
val bookId = uri.pathSegments[1]
db.delete("Book", "id = ?", arrayOf(bookId))
}
categoryDir -> db.delete("Category", selection, selectionArgs)
categoryItem -> {
val categoryId = uri.pathSegments[1]
db.delete("Category", "id = ?", arrayOf(categoryId))
}
else -> 0
}
deleteRows
} ?: 0
override fun getType(uri: Uri) = when (uriMatcher.match(uri)) {
bookDir -> "vnd.android.cursor.dir/vnd.com.example.kotlintest.provider.book"
bookItem -> "vnd.android.cursor.item/vnd.com.example.kotlintest.provider.book"
categoryDir -> "vnd.android.cursor.dir/vnd.com.example.kotlintest.provider.category"
categoryItem -> "vnd.android.cursor.item/vnd.com.example.kotlintest.provider.category"
else -> null
}
}
首先,在类的一开始,同样是定义了 4 个变量,分别用于表示访问 Book 表中的所有数据、访问 Book 表中的单条数据、访问 Category 表中的所有数据和访问 Category 表中的单条数据。然后在一个 by lazy 代码块里对 UriMatcher 进行了初始化操作,将期望匹配的几种 URI 格式添加了进去。by lazy 代码块是 Kotlin 提供的一种懒加载技术,代码块中的代码一开始并不会执行,只有当 uriMatcher 变量首次被调用的时候才会执行,并且会将代码块中最后一行代码的返回值赋给 uriMatcher。
接下来就是每个抽象方法的具体实现了,先来看一下 onCreate() 方法。这个方法的代码很短,但是语法可能有点特殊。这里我们综合利用了 Getter 方法语法糖、 ?. 操作符、let 函数、 ?: 操作符以及单行代码函数语法糖。首先调用了 getContext() 方法并借助 ?. 操作符和 let 函数判断它的返回值是否为空,如果为空就使用 ?: 操作符返回 false,表示 ContentProvider 初始化失败;如果不为空就执行 let 函数中的代码。在 let 函数中创建了一个 MyDatabaseHelper 的实例,然后返回 true 表示 ContentProvider 初始化成功。由于我们借助了多个操作符和标准函数,因此这段逻辑是在一行表达式内完成的,符合单行代码函数的语法糖要求,所以直接用等号连接返回值即可。
接着看一下 query() 方法,在这个方法中先获取了 SQLiteDatabase 的实例,然后根据传入的 Uri 参数判断用户想要访问哪张表,再调用 SQLiteDatabase.query() 进行查询,并将 Cursor 对象返回就好了。注意,当访问单条数据的时候,调用了 Uri 对象的 getPathSegments() 方法,它会将内容 URI 权限之后的部分以 / 符号进行分割,并把分割后的结果放入一个字符串列表中,那这个列表的第 0 个位置存放的就是路径,第 1 个位置存放的就是 id 了。得到了 id 之后,再通过 selection 和 selectionArgs 参数进行约束,就实现了查询单条数据的功能。
再往后就是 insert() 方法,它也是先获取了 SQLiteDatabase 的实例,然后根据传入的 Uri 参数判断用户想要往哪张表里添加数据,再调用 SQLiteDatabase.insert() 方法进行添加就可以了。注意,insert() 方法要求返回一个能够表示这条新增数据的 URI,所以我们还需要调用 Uri.parse() 方法,将一个内容 URI 解析成 Uri 对象,当然这个内容 URI 是以新增数据的 id 结尾的。
接下来就是 update() 方法了,也是先获取 SQLiteDatabase 的实例,然后根据传入的 uri 参数判断用户想要更新哪张表里的数据,再调用 SQLiteDatabase.update() 方法进行更新就好了,受影响的行数将作为返回值返回。
下面是 delete() 方法,仍然是先获取 SQLiteDatabase 的实例,然后根据传入的 uri 参数判断用户想要删除哪张表里的数据,再调用 SQLiteDatabase.delete() 方法进行删除就好了,被删除的行数将作为返回值返回。
最后是 getType() 方法。这样就将 ContentProvider 中的代码全部编写完了。
另外,还有一点需要注意,ContentProvider 一定要在 AndroidManifest.xml 文件中注册才可以使用。不过使用 Android Studio 的快捷方式创建的 ContentProvider,因 此注册这一步已经自动完成了。打开 AndroidManifest.xml 文件,代码如下所示:
<providers
android:name=".DatabaseProvider"
android:authorities="com.example.kotlintest.provider"
android:enabled="true"
android:exported="true"/>
可以看到,<application>
标签内出现了一个新的标签 <provider>
,使用它来对 DatabaseProvider 进行注册。android:name 属性指定了 DatabaseProvider 的类名,android:authorities 属性指定了 DatabaseProvider 的 authority,而 enabled 和 exported 属性则是根据我们刚才勾选的状态自动生成的,这里表示允许 DatabaseProvider 被其他应用程序访问。
现在这个项目就已经拥有了跨程序共享数据的功能了。首先需要将程序从模拟器中删除,以防止上一节中产生的遗留数据对我们造成干 扰。然后运行一下项目,将程序重新安装在模拟器上。接着关闭这个项目,并创建一个新项目 ProviderTest,我们将通过这个程序去访问 DatabaseTest 中的数据。
修改 activity_main.xml 中的代码,如下所示:
<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"
android:orientation="vertical"
tools:context=".MainActivity">
<Button
android:id="@+id/addData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add To Book" />
<Button
android:id="@+id/queryData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Query From Book" />
<Button
android:id="@+id/updateData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Update Book" />
<Button
android:id="@+id/deleteData"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Delete From Book" />
</LinearLayout>
布局文件很简单,里面放置了 4 个按钮,分别用于添加、查询、更新和删除数据。然后修改 MainActivity 中的代码,如下所示:
class MainActivity : AppCompatActivity() {
private lateinit var addData: Button
private lateinit var queryData: Button
private lateinit var updateData: Button
private lateinit var deleteData: Button
private var bookId: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
addData = findViewById(R.id.addData)
queryData = findViewById(R.id.queryData)
updateData = findViewById(R.id.updateData)
deleteData = findViewById(R.id.deleteData)
addData.setOnClickListener {
// 添加数据
val uri = Uri.parse("content://com.example.kotlintest.provider/book")
val values = contentValuesOf(
"name" to "A Clash of Kings",
"author" to "George Martin",
"pages" to 1040,
"price" to 22.85
)
val newUri = contentResolver.insert(uri, values)
bookId = newUri?.pathSegments?.get(1)
}
queryData.setOnClickListener {
// 查询数据
val uri = Uri.parse("content://com.example.kotlintest.provider/book")
contentResolver.query(uri, null, null, null, null)?.apply {
while (moveToNext()) {
val name = getString(getColumnIndex("name"))
val author = getString(getColumnIndex("author"))
val pages = getInt(getColumnIndex("pages"))
val price = getDouble(getColumnIndex("price"))
Log.e("MainActivity", "book name is $name")
Log.e("MainActivity", "book author is $author")
Log.e("MainActivity", "book pages is $pages")
Log.e("MainActivity", "book price is $price")
}
close()
}
}
updateData.setOnClickListener {
// 更新数据
bookId?.let {
val uri = Uri.parse("content://com.example.kotlintest.provider/book/$it")
val values = contentValuesOf(
"name" to "A Storm of Swords",
"pages" to 1216,
"price" to 24.05
)
contentResolver.update(uri, values, null, null)
}
}
deleteData.setOnClickListener {
// 删除数据
bookId?.let {
val uri = Uri.parse("content://com.example.kotlintest.provider/book/$it")
contentResolver.delete(uri, null, null)
}
}
}
}
可以看到,我们分别在这 4 个按钮的点击事件里面处理了增删改查的逻辑。添加数据的时候,首先调用了 Uri.parse() 方法将一个内容 URI 解析成 Uri 对象,然后把要添加的数据都存放到 ContentValues 对象中,接着调用 ContentResolver.insert() 方法执行添加操作就可以 了。注意,insert() 方法会返回一个 Uri 对象,这个对象中包含了新增数据的 id,我们通过 getPathSegments() 方法将这个 id 取出,稍后会用到它。
查询数据的时候,同样是调用了 Uri.parse() 方法将一个内容 URI 解析成 Uri 对象,然后调用 ContentResolver.query() 方法查询数据,查询的结果当然还是存放在 Cursor 对象中。之后对 Cursor 进行遍历,从中取出查询结果,并一一打印出来。
更新数据的时候,也是先将内容 URI 解析成 Uri 对象,然后把想要更新的数据存放到 ContentValues 对象中,再调用 ContentResolver.update() 方法执行更新操作就可以 了。注意,这里我们为了不想让 Book 表中的其他行受到影响,在调用 Uri.parse() 方法时, 给内容 URI 的尾部增加了一个 id,而这个 id 正是添加数据时所返回的。这就表示我们只希望更新刚刚添加的那条数据, Book 表中的其他行都不会受影响。
删除数据的时候,也是使用同样的方法解析了一个以 id 结尾的内容 URI,然后调用 ContentResolver.delete() 方法执行删除操作就可以了。由于我们在内容 URI 里指定了一 个 id,因此只会删掉拥有相应 id 的那行数据,Book 表中的其他数据都不会受影响。
如果 SDK 是 30 的,该版本(Android 11)的更新中,改变了当前应用于本机其他应用进行交互的方式,会出现以上的一些访问权限问题,可以添加以下代码(解决方案:https://www.it610.com/article/1352251398946893824.htm):
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapplication">
<queries>
<package android:name="com.example.kotlintest" />
</queries>
...
</manifest>
点击一下 Add To Book 按钮,此时数据就应该已经添加到 KotlinTest 程序的数据库中了,可以通过点击 Query From Book 按钮进行检查。然后点击一下 Update Book 按钮更新数据,再点击一下 Query From Book 按钮进行检查。最后点击 Delete From Book 按钮删除数据,此时再点击 Query From Book 按钮就查询不到数据了。