手把手构建一个安全、健壮的 ContentProvider

前言

之前,我们学习了如何使用现有的 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
    }
}

待会,我们将会逐步实现这六个核心方法。

核心方法简介

我们来介绍一下这六个方法的作用:

  1. onCreate() :在 Provider 创建后会被系统调用,用于完成初始化工作(如创建数据库帮助类实例)。返回 true 表示初始化成功。

  2. query() :用于查询数据,会将查询结果存放在 Cursor 对象中并返回。

    uri:指定要查询的数据集(通常对应数据库中的一张表)。

    projection:指定查询结果需要返回哪些列。传入 null 表示返回所有列。

    selection:指定查询结果的筛选条件。

    selectionArgs:为 selection 中的 ? 占位符提供具体的值,这可以防止 SQL 注入

    sortOrder:指定查询结果的排序方式。

  3. insert() :用于添加数据。添加完成后,会返回一个代表新数据的 Uri 对象。

  4. update():用于更新数据,并返回受影响的行数。

  5. delete():用于删除数据,并返回被删除的行数。

  6. 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 能够响应不同的数据访问请求,我们需要用到通配符。

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

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

因此,我们可以定义两种核心的访问模式:

  1. 访问整个数据集 content://<authority>/<path>

  2. 访问单条数据 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 就被配置好了,我们可以通过调用 UriMatchermatch() 方法来解析每一个 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
    }
}

现在,我们就完成了 DatabaseProviderContentProvider),它可处理增删改查的请求,并且可以通知数据变化。

配置 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> 标签中添加 readPermissionwritePermission 属性来应用权限。

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 中的数据,并且隐私数据还不会泄露。

相关推荐
用户2018792831672 小时前
通俗易懂的讲解:Android系统启动全流程与Launcher诞生记
android
二流小码农2 小时前
鸿蒙开发:资讯项目实战之项目框架设计
android·ios·harmonyos
用户2018792831674 小时前
WMS 的核心成员和窗口添加过程
android
用户2018792831674 小时前
PMS 创建之“软件包管理超级工厂”的建设
android
用户2018792831674 小时前
通俗易懂的讲解:Android APK 解析的故事
android
渣渣_Maxz4 小时前
使用 antlr 打造 Android 动态逻辑判断能力
android·设计模式
Android研究员4 小时前
HarmonyOS实战:List拖拽位置交换的多种实现方式
android·ios·harmonyos
guiyanakaung5 小时前
一篇文章让你学会 Compose Multiplatform 推荐的桌面应用打包工具 Conveyor
android·windows·macos
恋猫de小郭5 小时前
Flutter 应该如何实现 iOS 26 的 Liquid Glass ,它为什么很难?
android·前端·flutter
葱段5 小时前
【Compose】Android Compose 监听TextField粘贴事件
android·kotlin·jetbrains