ContentProvider:Android 跨程序数据共享的最佳实践

217 阅读10分钟

前言

我们都知道,在文件存储和 SharedPreferences 存储中,用于让其他应用访问当前应用中数据的MODE_WORLD_READABLEMODE_WORLD_WRITEABLE 模式都被官方废弃了,这是为什么?

因为它们存在着严重的安全缺陷:

  • 访问者要么拥有全部权限,要么没有一点权限,无法做到将部分数据进行共享,也无法指定只允许某一个应用访问。

  • 访问者直接操作文件,需要了解数据的存储方式,并且当数据发生改变时,无法感知到变化。

为此,官方提供了一套更安全、更灵活、更强大的机制来实现跨程序数据共享,它就是 ContentProvider

ContentProvider 简介

ContentProvider(内容提供者)是 Android 的四大组件之一,主要用于实现不同应用程序之间数据共享的功能。它可让一个应用将自己的数据(数据库、文件、网络数据)提供给其他应用访问,同时,可掌控数据的访问权限,可为数据的读和写操作设置不同的权限。

并且不管底层的数据如何存储,都可以提供统一的接口给外界访问。当然,其他应用无需关心数据的存储方式,只需通过 ContentResolverURI 即可操作数据。

在学习 ContentProvider 之前,我们先来看看运行时权限,因为待会许多由系统ContentProvider 提供的数据(如通讯录、日历)都需要用户的授权。

深入理解运行时权限

权限机制

为什么要有运行时权限?

为了回答这个问题,我们先来看看 Android 之前的权限机制。在 Android 6.0 之前,一个应用所需的所有权限都要在清单文件中添加声明,否则程序会崩溃。

这种机制是为了保护用户设备的安全:

  • 一方面,用户在安装应用程序时,系统会提醒用户该应用程序申请的全部权限,让用户决定是否全部接受并继续安装。

  • 另一方面,用户可以随时在系统设置中查看任意一个应用程序的权限列表,保证应用不会滥用权限。

但这只是理想中的情况,现实中:很多应用都会申请各种非必要的权限,哪怕它用不到。对于一些不合理的权限,你只能选择接受,否则你根本安装不了该应用,更谈不上使用了。

为了解决这个问题,在 Android 6.0 系统中加入了运行时权限机制。应用不能再像以前一样,在安装时一次性获取所有权限,而是当首次需要使用某个敏感功能时,才向用户弹出对话框申请权限。用户可以对每一项权限进行授权或者拒绝,并且随时可以在设置中取消授权。

危险权限

当然,不是所有的权限都需要在运行时进行申请。Android 将权限主要分为普通权限危险权限

  • 普通权限不会涉及到用户的安全和隐私,比如访问网络、设置壁纸等,所以这部分权限,系统会在安装应用时会自动授权;

  • 而危险权限(如获取联系人信息、使用摄像头等)涉及了用户敏感数据或设备功能,则需要应用在运行时由用户手动授权,否则应用无法使用相关功能。

权限特别多,我们只要注意危险权限就可以了。截止到 Android 15(API 35)的危险权限分组列表:

权限组 (Permission Group)权限 (Permissions)描述
android.permission-group.CALENDARREAD_CALENDAR, WRITE_CALENDAR访问和修改用户的日历事件。
android.permission-group.CAMERACAMERA访问摄像头。
android.permission-group.CONTACTSREAD_CONTACTS, WRITE_CONTACTS, GET_ACCOUNTS访问和修改联系人。
android.permission-group.LOCATIONACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, ACCESS_BACKGROUND_LOCATION获取设备位置信息,后台定位需要额外申请。
android.permission-group.MICROPHONERECORD_AUDIO使用麦克风录制音频。
android.permission-group.PHONEREAD_PHONE_STATE, CALL_PHONE, READ_CALL_LOG, WRITE_CALL_LOG, ADD_VOICEMAIL, USE_SIP, PROCESS_OUTGOING_CALLS, ANSWER_PHONE_CALLS, READ_PHONE_NUMBERS用于电话核心功能,部分权限仅限系统级应用。
android.permission-group.SENSORSBODY_SENSORS, BODY_SENSORS_BACKGROUND访问心率、体温等身体传感器,后台访问需额外权限。
android.permission-group.SMSSEND_SMS, RECEIVE_SMS, READ_SMS, RECEIVE_WAP_PUSH, RECEIVE_MMS用于短信和彩信的收发与读取。
android.permission-group.NEARBY_DEVICESBLUETOOTH_SCAN, BLUETOOTH_ADVERTISE, BLUETOOTH_CONNECT, NEARBY_WIFI_DEVICES, UWB_RANGINGAPI 31+,用于扫描和连接附近的设备,不再滥用位置权限。
android.permission-group.NOTIFICATIONSPOST_NOTIFICATIONSAPI 33+,发送通知变为运行时权限。
android.permission-group.MEDIAREAD_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIOAPI 33+,取代了粗粒度的 READ_EXTERNAL_STORAGE
-READ_MEDIA_VISUAL_USER_SELECTEDAPI 34+,允许应用访问用户在照片选择器中手动选择的图片和视频,是分区存储的最佳实践。

详见官方文档中保护等级为 dangerous 的权限。

注意:在 Android 11+ 系统中,用户即使授予了某个危险权限,该权限同组的其他权限也不会被系统自动授权。

权限申请实战

我们通过一个示例来演示如何在程序运行时申请权限。我们将使用官方推荐的 Activity Result API 来替代传统的 onRequestPermissionsResult 回调方式,因为它更简洁,还不容易出错。

我们来实现向用户申请读取联系人的权限。

首先创建名为 RuntimePermissionTestEmpty Views Activity 项目,往布局中添加一个按钮,用于触发权限申请。activity_main.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/requestPermissionBtn"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Request Contacts Permission" />

</LinearLayout>

然后在 MainActivity 中实现对应逻辑,代码如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    // 权限请求启动器
    // 我们通过 registerForActivityResult 注册一个回调,专门处理权限请求的结果
    private val requestPermissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
            // 该回调会在用户做出选择后执行
            if (isGranted) {
                // 用户同意了权限
                Toast.makeText(this, "Permission Granted!", Toast.LENGTH_SHORT).show()
            } else {
                // 用户拒绝了权限
                Toast.makeText(this, "Permission Denied!", Toast.LENGTH_SHORT).show()
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.requestPermissionBtn.setOnClickListener {
            when{
                // 检查权限是否已经被授予
                ContextCompat.checkSelfPermission(
                    this,
                    android.Manifest.permission.READ_CONTACTS
                ) == PackageManager.PERMISSION_GRANTED -> {
                    // 已经被授予
                    Toast.makeText(this, "Permission already granted.", Toast.LENGTH_SHORT).show()
                }

                // 如果权限未被授予,且用户之前拒绝过一次(方法会返回 true)
                shouldShowRequestPermissionRationale(android.Manifest.permission.READ_CONTACTS) -> {
                    // 我们可以在这里,向用户解释清楚权限作用,然后再发起权限请求
                    Toast.makeText(this, "We need contacts permission to show names.", Toast.LENGTH_LONG).show()
                    requestPermissionLauncher.launch(android.Manifest.permission.READ_CONTACTS)
                }


                else -> {
                    // 直接发起权限请求
                    requestPermissionLauncher.launch(android.Manifest.permission.READ_CONTACTS)
                }

            }


        }

    }
    
}

我们来解释一下这段代码,其核心在于按钮的点击事件中,我们使用 when 语句来处理了所有情况:

  • 第一种情况,我们使用 ContextCompat.checkSelfPermission() 方法来判断权限是否已被用户授权过(为 PackageManager.PERMISSION_GRANTED 状态)。如果是,直接执行后续逻辑,不需要请求权限。

  • 第二种情况,我们使用 shouldShowRequestPermissionRationale() 判断用户之前是否拒绝过,并且没有点击不再询问。这时,我们可以先向用户解释一下权限的作用,再调用 requestPermissionLauncher.launch() 弹出系统对话框来向用户申请权限。

  • 其他情况下,为应用第一次请求权限,或者用户之前点击了不再询问。我们直接 requestPermissionLauncher.launch() 来弹出系统授权对话框请求权限。

最后是最重要的一步,在 AndroidManifest.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="android.permission.READ_CONTACTS" />
    ...

</manifest>

现在重新运行程序,并点击按钮,结果如图所示:

image.png

点击拒绝,结果如图:

image.png

再次点击按钮,结果如图:

image.png

点击同意,结果如图:

image.png

接下来,我们来看看如何利用获取到的权限去访问 ContentProvider

访问其他程序中的数据

ContentProvider 的用法有两种:

  • 一种是使用现有的 ContentProvider

  • 另一种是创建自定义的 ContentProvider

本文只探讨第一种:如何访问其他程序提供的数据。

ContentResolver 的基本用法

要想访问 ContentProvider 中共享的数据,就要用到 ContentResolver 对象。

它提供了标准的 insertupdatedeletequery 方法来对数据进行增删改查操作,这些方法都会接收一个 Uri 参数,这个参数被称为内容 URI。

内容 URI 会给 ContentProvider 中的数据建立唯一标识,它的格式如下所示:

content://<authority>/<path>

  • content://:协议声明,表示该字符串是内容 URI。

  • authority:授权方,用于唯一标识一个应用中的 ContentProvider,一般使用应用的包名。

  • path:路径,用于标识要访问的具体数据集(如数据库中的某张表)。

比如:要查询 com.example.provider 这个授权方下的 table 数据表,那么其内容 URi 对象是:

val uri = Uri.parse("content://com.example.provider/table")

有了 URi 对象,我们就可以开始使用 ContentProvider 查询了。我们来重点看看 query 方法的参数与 SQL 查询语句的对应关系:

query()方法参数对应SQL部分描述
urifrom table_name指定要查询的 ContentProvider 和其中的数据集
projectionselect column1, column2指定查询结果返回的列名,null表示所有列
selectionwhere column = ?指定查询结果的约束条件
selectionArgs? 占位符提供值selection 中的 ? 占位符提供具体的值。
sortOrderorder by column desc指定查询结果的排序方式

查询返回一个 Cursor 对象,我们可以遍历它来获取数据。

实战:读取系统联系人

下面,我们来结合运行时权限,使用 ContentResolver 读取系统通讯录中的联系人信息。

不过,你先要在模拟器中手动添加几个联系人,如图所示:

创建名为 ContactsTest 的 Empty Views Activity 项目。在布局中,添加 ListView 来简单展示我们读取出来的联系人信息。activity_main.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">

    <ListView
        android:id="@+id/contactsView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

</LinearLayout>

然后在 MainActivity 实现功能逻辑,代码如下:

class MainActivity : AppCompatActivity() {
    // 视图绑定
    private lateinit var binding: ActivityMainBinding

    // 联系人信息列表
    private val contactsList = ArrayList<String>()

    // 列表适配器
    private lateinit var adapter: ArrayAdapter<String>

    // 注册权限请求回调
    private val requestPermissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean ->
            if (isGranted) {
                // 用户授予权限,执行读取联系人信息操作
                readContacts()
            } else {
                // 用户拒绝权限
                Toast.makeText(this, "You denied the contacts permission", Toast.LENGTH_SHORT)
                    .show()
            }
        }


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 创建并设置列表的适配器
        adapter = ArrayAdapter(this, android.R.layout.simple_list_item_1, contactsList)
        binding.contactsView.adapter = adapter


        // 检查权限并决定是直接读取还是发起请求
        checkPermissionAndReadContacts()

    }

    private fun checkPermissionAndReadContacts() {
        if (
            ContextCompat.checkSelfPermission(
                this,
                android.Manifest.permission.READ_CONTACTS
            ) == PackageManager.PERMISSION_GRANTED
        ) {
            // 已有权限,直接读取
            readContacts()
        } else {
            // 没有权限,发起请求
            requestPermissionLauncher.launch(android.Manifest.permission.READ_CONTACTS)
        }

    }


    private fun readContacts() {
        // 使用 contentResolver 查询联系人数据
        contentResolver.query(
            ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
            null, null, null, null
        )?.use { cursor -> // 使用 use 函数确保 Cursor 能自动关闭
            // 获取列索引
            val displayNameIndex = cursor.getColumnIndex(
                ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME
            )
            val numberIndex = cursor.getColumnIndex(
                ContactsContract.CommonDataKinds.Phone.NUMBER
            )

            contactsList.clear() // 清空列表中的旧数据,防止在某些情况下,数据被重复添加

            while (cursor.moveToNext()) {
                // 索引都有效
                if (displayNameIndex != -1 && numberIndex != -1) {
                    // 获取联系人姓名
                    val displayName = cursor.getString(displayNameIndex)
                    // 获取联系人手机号
                    val number = cursor.getString(numberIndex)

                    contactsList.add("$displayName\n$number")
                }

            }
            // 刷新列表
            adapter.notifyDataSetChanged()
        }
    }

}

上述代码中,我们只在 onCreate() 方法中调用了一个 checkPermissionAndReadContacts() 方法,让它来决定当前是先申请权限还是直接读取数据。

并且构建 URi 对象时,我们使用了系统预设好的 ContactsContract.CommonDataKinds.Phone.CONTENT_URI 常量,而没有去手写,防止因拼写错误导致应用崩溃。

最后在清单文件中声明 READ_CONTACTS 权限。如下所示:

<?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="android.permission.READ_CONTACTS" />

    ...

</manifest>

现在运行程序,会弹出对话框让我们授予访问联系人的权限,如图所示:

请求权限对话框

点击允许后,即可在界面看到联系人的信息,如图所示:

成功读取联系人