前言
之前,我们学习了如何使用现有的 ContentProvider
来访问其他应用提供的数据。那我们的应用可不可以也提供一个标准的外部接口,让其他应用来安全访问我们的数据呢?
答案是肯定的。通过创建自定义的 ContentProvider
来实现。
但同时我们该如何保证内部数据的安全呢? 别着急,我们将一一揭晓。
搭建 ContentProvider 基本框架
我们在之前的 DatabaseTest
项目上进行修改,它已经完成了对 SQLite 数据库的操作。
现在,我们创建一个 DatabaseProvider
类继承自 ContentProvider
类,并实现其中必须的抽象方法,代码如下所示:
kotlin
class DatabaseProvider : ContentProvider() {
override fun onCreate(): Boolean {
// 初始化逻辑
return false
}
override fun query(
uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?,
): Cursor? {
// 查询逻辑
return null
}
override fun insert(uri: Uri, values: ContentValues?): Uri? {
// 插入逻辑
return null
}
override fun update(
uri: Uri, values: ContentValues?, selection: String?,
selectionArgs: Array<String>?,
): Int {
// 更新逻辑
return 0
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
// 删除逻辑
return 0
}
override fun getType(uri: Uri): String? {
// 获取MIME类型逻辑
return null
}
}
待会,我们将会逐步实现这六个核心方法。
核心方法简介
我们来介绍一下这六个方法的作用:
-
onCreate()
:在Provider
创建后会被系统调用,用于完成初始化工作(如创建数据库帮助类实例)。返回true
表示初始化成功。 -
query()
:用于查询数据,会将查询结果存放在Cursor
对象中并返回。uri
:指定要查询的数据集(通常对应数据库中的一张表)。projection
:指定查询结果需要返回哪些列。传入null
表示返回所有列。selection
:指定查询结果的筛选条件。selectionArgs
:为selection
中的?
占位符提供具体的值,这可以防止 SQL 注入。sortOrder
:指定查询结果的排序方式。 -
insert()
:用于添加数据。添加完成后,会返回一个代表新数据的Uri
对象。 -
update()
:用于更新数据,并返回受影响的行数。 -
delete()
:用于删除数据,并返回被删除的行数。 -
getType()
:用于根据传入的Uri
对象返回相应的 MIME 类型。
资源定位符 URI
在上述代码中,我们看到很多方法中都带有 uri
参数,那么 Uri
是什么?
Uri
的全称是 Uniform Resource Identifier,即统一资源标识符。在 ContentProvider
中,它扮演着 "资源定位符" 的角色。
应用中的每一份数据(或数据集)需要一个精准的地址来被访问,其他应用正是通过这个地址来告诉 ContentProvider
它想要操作的对象。在 Android 中,这种 Uri
被称为 Content URI(内容 URI)。其标准格式为:
xml
content://<authority>/<path>/<id>
-
content://
:固定前缀,表示这是用于ContentProvider
的内容 URI。 -
<authority>
:授权方,用于唯一标识ContentProvider
。一般使用应用的包名加上.provider
后缀。 -
<path>
:路径,用于区分ContentProvider
中不同的数据集(一般为数据库中的一张表)。 -
<id>
:这是可选的部分,一般是整数,用于在数据集中精确定位某一条记录。
URI 通配符
为了我们的 ContentProvider
能够响应不同的数据访问请求,我们需要用到通配符。
-
*
通配符:表示匹配任意长度的任意字符。 -
#
通配符:表示匹配任意长度的数字。
因此,我们可以定义两种核心的访问模式:
-
访问整个数据集
content://<authority>/<path>
-
访问单条数据
content://<authority>/<path>/#
例如 content://com.example.app/books
表示访问所有书籍,content://com.example.app/books/123
表示访问 id 为 123 的书籍。
使用 UriMatcher 解析 URI
理解了 URI 的规则后,现在,我们来解析它。
具体是使用 UriMatcher
类,它提供了一个 addURI()
方法,可用于注册 URI 模式。
java
public void addURI(
String authority, // 授权方
String path, // 路径,可包含通配符
int code // 自定义整型代码,当一个 URI 成功匹配时,UriMatcher 的 match() 方法会返回这个代码
)
现在,我们在 DatabaseProvider
中,使用 addURI()
来定义我们的解析规则和一些常量。代码如下:
kotlin
class DatabaseProvider : ContentProvider() {
// SQLiteOpenHelper 成员变量
private lateinit var dbHelper: MyDatabaseHelper
companion object {
// 定义 Authority 授权方
private const val AUTHORITY = "com.example.databasetest.provider"
// 为表定义名称常量
private const val BOOK_TABLE_NAME = "book"
private const val CATEGORY_TABLE_NAME = "category"
// 为每一种 URI 模式定义一个唯一的自定义匹配码
private const val BOOKS_DIR = 100 // 操作 book 表所有数据
private const val BOOK_ITEM = 101 // 操作 book 表单条数据
private const val CATEGORIES_DIR = 200 // 操作 category 表所有数据
private const val CATEGORY_ITEM = 201 // 操作 category 表单条数据
// 静态初始化 UriMatcher,用于辅助解析和匹配 URI
private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
// 规则:匹配访问 book 表所有数据的 URI
addURI(AUTHORITY, BOOK_TABLE_NAME, BOOKS_DIR)
// 规则:# 是通配符,匹配访问 book 表中任意单条数据的 URI
addURI(AUTHORITY, "$BOOK_TABLE_NAME/#", BOOK_ITEM)
// 规则:匹配访问 category 表所有数据的 URI
addURI(AUTHORITY, CATEGORY_TABLE_NAME, CATEGORIES_DIR)
// 规则:匹配访问 category 表中任意单条数据的 URI
addURI(AUTHORITY, "$CATEGORY_TABLE_NAME/#", CATEGORY_ITEM)
}
}
// ... 6个待实现的方法 ...
}
这样 UriMatcher
就被配置好了,我们可以通过调用 UriMatcher
的 match()
方法来解析每一个 Uri
对象,得到对应的匹配码(自定义),从而执行对应逻辑。
实现 ContentProvider 的内部逻辑
现在我们来实现 DatabaseProvider
类中那六个核心方法。
实现 onCreate() 进行初始化
onCreate()
方法是 ContentProvider
的生命周期中第一个被系统调用的方法,我们来完成初始化的工作。
我们初始化数据库帮助类 DatabaseHelper
,并且返回 true
,表示 ContentProvider
成功加载,开始接收外部请求。
kotlin
override fun onCreate(): Boolean {
// 实例化 MyDatabaseHelper 类,以便后续使用它来操作数据库
dbHelper = MyDatabaseHelper(context!!, "BookStore.db", version = 2)
// 返回 true 表示初始化成功
return true
}
实现查询逻辑 query()
我们在其中获取一个可读 的数据库实例,并且使用 uriMatcher.match(uri)
来判断请求的数据类型,根据匹配结果执行对应的查询操作,注意如果是查询单条数据,我们需要从 uri
中解析出数据的 id。最后将代表查询结果的 Cursor
对象返回。
kotlin
override fun query(
uri: Uri, projection: Array<String>?, selection: String?,
selectionArgs: Array<String>?, sortOrder: String?
): Cursor? {
// 获取一个只可读的数据库实例
val db = dbHelper.readableDatabase
// 使用 UriMatcher 匹配传入的 URI,并根据匹配码执行相应的数据库查询
val cursor = when (uriMatcher.match(uri)) {
// 查询所有书籍
BOOKS_DIR -> db.query(BOOK_TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder)
// 查询单本书籍
BOOK_ITEM -> {
val bookId = uri.lastPathSegment // 获取 URI 末尾的 id
db.query(BOOK_TABLE_NAME, projection, "id = ?", arrayOf(bookId), null, null, sortOrder)
}
// 查询所有分类
CATEGORIES_DIR -> db.query(CATEGORY_TABLE_NAME, projection, selection, selectionArgs, null, null, sortOrder)
// 查询单个分类
CATEGORY_ITEM -> {
val categoryId = uri.lastPathSegment // 获取 URI 末尾的 id
db.query(CATEGORY_TABLE_NAME, projection, "id = ?", arrayOf(categoryId), null, null, sortOrder)
}
else -> null // 对于无法识别的 URI,直接返回 null
}
return cursor
}
实现数据修改 (insert, update, delete)
这三种方法的实现特别类似:获取一个可写 的数据库实例,然后根据 UriMatcher
的匹配结果执行对象的数据库操作,最后调用 ContentResolver.notifyChange()
方法提醒外界,数据已更新。
kotlin
override fun insert(uri: Uri, values: ContentValues?): Uri? {
val db = dbHelper.writableDatabase
val newUri = when (uriMatcher.match(uri)) {
// 向表中插入数据
BOOKS_DIR -> {
// db.insert() 方法会返回新插入行的 id
val newBookId = db.insert(BOOK_TABLE_NAME, null, values)
// 使用 Authority、表名和 id 构建新数据的对应的 Uri 对象
Uri.parse("content://$AUTHORITY/$BOOK_TABLE_NAME/$newBookId")
}
CATEGORIES_DIR -> {
val newCategoryId = db.insert(CATEGORY_TABLE_NAME, null, values)
Uri.parse("content://$AUTHORITY/$CATEGORY_TABLE_NAME/$newCategoryId")
}
// 向 item 插入数据是不合逻辑的,直接抛出异常
BOOK_ITEM, CATEGORY_ITEM -> throw IllegalArgumentException("Cannot insert into a specific item URI: $uri")
else -> null
}
// 通知系统,这个 URI 对应的数据发生了变化
context?.contentResolver?.notifyChange(uri, null)
return newUri
}
override fun update(
uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?
): Int {
val db = dbHelper.writableDatabase
// update 和 delete 操作的返回值是受影响的行数
val updatedRows = when (uriMatcher.match(uri)) {
BOOKS_DIR -> db.update(BOOK_TABLE_NAME, values, selection, selectionArgs)
BOOK_ITEM -> {
val bookId = uri.lastPathSegment
db.update(BOOK_TABLE_NAME, values, "id = ?", arrayOf(bookId))
}
CATEGORIES_DIR -> db.update(CATEGORY_TABLE_NAME, values, selection, selectionArgs)
CATEGORY_ITEM -> {
val categoryId = uri.lastPathSegment
db.update(CATEGORY_TABLE_NAME, values, "id = ?", arrayOf(categoryId))
}
else -> 0
}
// 如果有数据更新了,就发出通知
if (updatedRows > 0) {
context?.contentResolver?.notifyChange(uri, null)
}
return updatedRows
}
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int {
val db = dbHelper.writableDatabase
val deletedRows = when (uriMatcher.match(uri)) {
BOOKS_DIR -> db.delete(BOOK_TABLE_NAME, selection, selectionArgs)
BOOK_ITEM -> {
val bookId = uri.lastPathSegment
db.delete(BOOK_TABLE_NAME, "id = ?", arrayOf(bookId))
}
CATEGORIES_DIR -> db.delete(CATEGORY_TABLE_NAME, selection, selectionArgs)
CATEGORY_ITEM -> {
val categoryId = uri.lastPathSegment
db.delete(CATEGORY_TABLE_NAME, "id = ?", arrayOf(categoryId))
}
else -> 0
}
if (deletedRows > 0) {
context?.contentResolver?.notifyChange(uri, null)
}
return deletedRows
}
实现 getType() 指定MIME类型
最后,我们来看看 getType()
方法。它用于返回一个描述 URI
所指向数据类型的 MIME 字符串。可以让其他应用在不解析数据的情况下,就知道 URI 指向的是单条数据还是数据集合。
MIME 字符串的标准格式如下所示:
xml
vnd.android.cursor.dir/vnd.<authority>.<path>
vnd.android.cursor.item/vnd.<authority>.<path>
vnd
:固定前缀。
android.cursor.dir
:如果 URI
指向多条数据
android.cursor.item
:如果 URI
指向单条数据
vnd.<authority>.<path>
:自定义部分。
知道了这些,那我们来实现 getType()
方法中的逻辑,代码如下:
kotlin
override fun getType(uri: Uri): String? {
return when (uriMatcher.match(uri)) {
// 书籍集合的 MIME 类型
BOOKS_DIR -> "vnd.android.cursor.dir/vnd.$AUTHORITY.$BOOK_TABLE_NAME"
// 单本书籍的 MIME 类型
BOOK_ITEM -> "vnd.android.cursor.item/vnd.$AUTHORITY.$BOOK_TABLE_NAME"
// 分类集合的 MIME 类型
CATEGORIES_DIR -> "vnd.android.cursor.dir/vnd.$AUTHORITY.$CATEGORY_TABLE_NAME"
// 单个分类的 MIME 类型
CATEGORY_ITEM -> "vnd.android.cursor.item/vnd.$AUTHORITY.$CATEGORY_TABLE_NAME"
else -> null
}
}
现在,我们就完成了 DatabaseProvider
(ContentProvider
),它可处理增删改查的请求,并且可以通知数据变化。
配置 AndroidManifest.xml
注册 Provider
虽然 DatabaseProvider
类写好了,但我们需要在 AndroidManifest.xml
清单文件中注册它,系统才能知道它是一个可以对外提供服务的 ContentProvider
。
如下所示:
xml
<?xml version="1.0" encoding="utf-8"?>
<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" />
...
</application>
</manifest>
我们通过了 <provider>
标签来完成注册。
注意这里的 android:authorities
属性值需要和在 DatabaseProvider
中定义的 AUTHORITY
值相同,并且还需要启用和允许外部访问它。
自定义权限
那回到开头的问题:如何保证数据的安全?答案是定义并使用权限。
首先自定义读和写的权限,在 <manifest>
根标签下使用 <permission>
标签来定义。
xml
<permission
android:name="com.example.databasetest.permission.READ_DATABASE"
android:description="@string/permission_desc_read"
android:label="@string/permission_label_read"
android:protectionLevel="dangerous" />
<permission
android:name="com.example.databasetest.permission.WRITE_DATABASE"
android:description="@string/permission_desc_write"
android:label="@string/permission_label_write"
android:protectionLevel="dangerous" />
其中使用了 @string
引用字符串资源,我们在 res/values/strings.xml
中添加如下内容:
xml
<resources>
<string name="app_name">DatabaseTest</string>
<string name="permission_label_read">读取书籍数据</string>
<string name="permission_desc_read">允许应用读取阅读应用中的数据。</string>
<string name="permission_label_write">写入书籍数据</string>
<string name="permission_desc_write">允许应用向阅读应用中写入数据。</string>
</resources>
注意 protectionLevel
表示权限的安全级别:
-
normal
:低风险权限,系统在应用安装时会自动授予。 -
dangerous
:高风险权限,必须运行时向用户动态申请。 -
signature
:最严格权限,只有与你应用相同密钥签名的应用才能获取此权限。
使用权限
我们在 <provider>
标签中添加 readPermission
和 writePermission
属性来应用权限。
xml
<provider
android:name=".DatabaseProvider"
android:authorities="com.example.databasetest.provider"
android:enabled="true"
android:exported="true"
android:readPermission="com.example.databasetest.permission.READ_DATABASE"
android:writePermission="com.example.databasetest.permission.WRITE_DATABASE" />
这样,如果外部应用没有被授予相应的读/写权限,将无法访问当前应用中的数据,会直接抛出 SecurityException
异常,从而保护了我们的数据。
在其他应用中访问数据
现在 DatabaseTest
应用就拥有了跨程序共享数据的功能。我们创建一个名为 ProviderTest
的 Empty Views Activity 项目来访问 DatabaseTest
中的数据。
首先通过 <uses-permission>
标签声明我们需要的权限,分别是读和写。
xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="com.example.databasetest.permission.READ_DATABASE" />
<uses-permission android:name="com.example.databasetest.permission.WRITE_DATABASE" />
<queries>
<provider android:authorities="com.example.databasetest.provider" />
</queries>
...
</manifest>
并且从 Android 11 (API 30) 开始,为了加强隐私的保护,应用默认无法检测到其他安装的应用,所以我们要在 AndroidManifest.xml
文件中通过 <queries>
标签明确声明当前要和哪个 ContentProvider
通信。
然后在其布局中添加四个按钮,分别用于添加、查询、更新和删除数据,activity_main.xml
文件中的代码如下:
xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<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>
然后在 MainActivity
中实现按钮的逻辑:通过 DatabaseProvider
来访问 ContentProvider
中的数据。代码如下:
kotlin
class MainActivity : AppCompatActivity() {
// 使用 ViewBinding 安全地访问视图
private lateinit var binding: ActivityMainBinding
// 保存新增数据的 id
private var bookId: String? = null
/**
* 待执行的动作(action)
*/
private var pendingAction: (() -> Unit)? = null
/**
* 权限请求启动器。
* 当权限被授予时,待执行的动作将会被执行。
*/
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
if (isGranted) {
// 用户同意了权限,执行之前被挂起的动作
pendingAction?.invoke()
pendingAction = null // 执行完毕后清空
} else {
// 用户拒绝了权限,给出明确提示
Toast.makeText(
this,
"Permission Denied! The operation cannot proceed.",
Toast.LENGTH_SHORT
).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
binding.addData.setOnClickListener {
// 先检查权限
checkPermissionAndAction(WRITE_PERMISSION) {
// 权限通过后,再添加数据
lifecycleScope.launch(Dispatchers.IO) {
val values = contentValuesOf(
"name" to "A Clash of Kings",
"author" to "George Martin",
"pages" to 1040,
"price" to 22.85
)
val newUri = contentResolver.insert(BOOK_URI, values)
bookId = newUri?.lastPathSegment
Log.d(TAG, "Added new book, id: $bookId")
// 在主线程给用户反馈
withContext(Dispatchers.Main) {
Toast.makeText(
applicationContext,
"Book added, ID: $bookId",
Toast.LENGTH_SHORT
).show()
}
}
}
}
binding.queryData.setOnClickListener {
// 先在主线程检查权限,再在后台执行查询数据操作
checkPermissionAndAction(READ_PERMISSION) {
lifecycleScope.launch(Dispatchers.IO) {
val stringBuilder = StringBuilder()
contentResolver.query(BOOK_URI, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex("name")
val authorIndex = cursor.getColumnIndex("author")
while (cursor.moveToNext()) {
val name = cursor.getString(nameIndex)
val author = cursor.getString(authorIndex)
stringBuilder.append("Book: $name, Author: $author\n")
}
}
Log.d(TAG, stringBuilder.toString())
withContext(Dispatchers.Main) {
Toast.makeText(
applicationContext,
"Query finished. Check Logcat.",
Toast.LENGTH_SHORT
).show()
}
}
}
}
binding.updateData.setOnClickListener {
bookId?.let { id ->
checkPermissionAndAction(WRITE_PERMISSION) {
lifecycleScope.launch(Dispatchers.IO) {
val uri = Uri.withAppendedPath(BOOK_URI, id)
val values =
contentValuesOf("name" to "A Storm of Swords", "price" to 24.05)
val updatedRows = contentResolver.update(uri, values, null, null)
Log.d(TAG, "Updated $updatedRows row(s)")
withContext(Dispatchers.Main) {
Toast.makeText(
applicationContext,
"Updated $updatedRows row(s)",
Toast.LENGTH_SHORT
).show()
}
}
}
} ?: Toast.makeText(this, "Please add a book first", Toast.LENGTH_SHORT).show()
}
binding.deleteData.setOnClickListener {
bookId?.let { id ->
checkPermissionAndAction(WRITE_PERMISSION) {
lifecycleScope.launch(Dispatchers.IO) {
val uri = Uri.withAppendedPath(BOOK_URI, id)
val deletedRows = contentResolver.delete(uri, null, null)
Log.d(TAG, "Deleted $deletedRows row(s)")
withContext(Dispatchers.Main) {
Toast.makeText(
applicationContext,
"Deleted $deletedRows row(s)",
Toast.LENGTH_SHORT
).show()
}
}
}
} ?: Toast.makeText(this, "Please add a book first", Toast.LENGTH_SHORT).show()
}
}
/**
* 权限检查与执行函数
* @param permission 要请求的权限
* @param action 权限被授予后要执行的操作
*/
private fun checkPermissionAndAction(permission: String, action: () -> Unit) {
// 检查当前是否已有权限
when {
ContextCompat.checkSelfPermission(
this,
permission
) == PackageManager.PERMISSION_GRANTED -> {
// 已有权限,直接执行操作
action()
}
else -> {
// 将待执行的动作存起来
pendingAction = action
// 直接发起请求
requestPermissionLauncher.launch(permission)
}
}
}
companion object {
private const val TAG = "MainActivity"
// 权限名称常量
private const val WRITE_PERMISSION = "com.example.databasetest.permission.WRITE_DATABASE"
private const val READ_PERMISSION = "com.example.databasetest.permission.READ_DATABASE"
// URI 常量
private val BOOK_URI = Uri.parse("content://com.example.databasetest.provider/book")
}
}
现在运行项目,界面如图所示:
点击添加按钮,会弹出对话框请求写入权限:
点击允许后,会成功添加一条数据:
然后点击查询按钮,会弹出对话框请求读取权限:
点击允许,即可在 Logcat 日志信息中查看到新增的数据:
ini
D/MainActivity com.example.providertest Book: A Clash of Kings, Author: George Martin
接着点击更新按钮,再点击查询按钮。在 Logcat 中可以看到数据更新了:
yaml
D/MainActivity com.example.providertest Book: A Storm of Swords, Author: George Martin
最后点击删除按钮,此时点击查询按钮,将查询不到任何数据。
那么跨程序共享数据功能就实现了,任何其他应用都可以访问 DatabaseTest
中的数据,并且隐私数据还不会泄露。