前言
我们都知道,在文件存储和 SharedPreferences 存储中,用于让其他应用访问当前应用中数据的MODE_WORLD_READABLE 和 MODE_WORLD_WRITEABLE 模式都被官方废弃了,这是为什么?
因为它们存在着严重的安全缺陷:
-
访问者要么拥有全部权限,要么没有一点权限,无法做到将部分数据进行共享,也无法指定只允许某一个应用访问。
-
访问者直接操作文件,需要了解数据的存储方式,并且当数据发生改变时,无法感知到变化。
为此,官方提供了一套更安全、更灵活、更强大的机制来实现跨程序数据共享,它就是 ContentProvider。
ContentProvider 简介
ContentProvider(内容提供者)是 Android 的四大组件之一,主要用于实现不同应用程序之间数据共享的功能。它可让一个应用将自己的数据(数据库、文件、网络数据)提供给其他应用访问,同时,可掌控数据的访问权限,可为数据的读和写操作设置不同的权限。
并且不管底层的数据如何存储,都可以提供统一的接口给外界访问。当然,其他应用无需关心数据的存储方式,只需通过 ContentResolver 和 URI 即可操作数据。
在学习 ContentProvider 之前,我们先来看看运行时权限,因为待会许多由系统ContentProvider 提供的数据(如通讯录、日历)都需要用户的授权。
深入理解运行时权限
权限机制
为什么要有运行时权限?
为了回答这个问题,我们先来看看 Android 之前的权限机制。在 Android 6.0 之前,一个应用所需的所有权限都要在清单文件中添加声明,否则程序会崩溃。
这种机制是为了保护用户设备的安全:
-
一方面,用户在安装应用程序时,系统会提醒用户该应用程序申请的全部权限,让用户决定是否全部接受并继续安装。
-
另一方面,用户可以随时在系统设置中查看任意一个应用程序的权限列表,保证应用不会滥用权限。
但这只是理想中的情况,现实中:很多应用都会申请各种非必要的权限,哪怕它用不到。对于一些不合理的权限,你只能选择接受,否则你根本安装不了该应用,更谈不上使用了。
为了解决这个问题,在 Android 6.0 系统中加入了运行时权限机制。应用不能再像以前一样,在安装时一次性获取所有权限,而是当首次需要使用某个敏感功能时,才向用户弹出对话框申请权限。用户可以对每一项权限进行授权或者拒绝,并且随时可以在设置中取消授权。
危险权限
当然,不是所有的权限都需要在运行时进行申请。Android 将权限主要分为普通权限和危险权限。
-
普通权限不会涉及到用户的安全和隐私,比如访问网络、设置壁纸等,所以这部分权限,系统会在安装应用时会自动授权;
-
而危险权限(如获取联系人信息、使用摄像头等)涉及了用户敏感数据或设备功能,则需要应用在运行时由用户手动授权,否则应用无法使用相关功能。
权限特别多,我们只要注意危险权限就可以了。截止到 Android 15(API 35)的危险权限分组列表:
| 权限组 (Permission Group) | 权限 (Permissions) | 描述 |
|---|---|---|
android.permission-group.CALENDAR | READ_CALENDAR, WRITE_CALENDAR | 访问和修改用户的日历事件。 |
android.permission-group.CAMERA | CAMERA | 访问摄像头。 |
android.permission-group.CONTACTS | READ_CONTACTS, WRITE_CONTACTS, GET_ACCOUNTS | 访问和修改联系人。 |
android.permission-group.LOCATION | ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, ACCESS_BACKGROUND_LOCATION | 获取设备位置信息,后台定位需要额外申请。 |
android.permission-group.MICROPHONE | RECORD_AUDIO | 使用麦克风录制音频。 |
android.permission-group.PHONE | READ_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.SENSORS | BODY_SENSORS, BODY_SENSORS_BACKGROUND | 访问心率、体温等身体传感器,后台访问需额外权限。 |
android.permission-group.SMS | SEND_SMS, RECEIVE_SMS, READ_SMS, RECEIVE_WAP_PUSH, RECEIVE_MMS | 用于短信和彩信的收发与读取。 |
android.permission-group.NEARBY_DEVICES | BLUETOOTH_SCAN, BLUETOOTH_ADVERTISE, BLUETOOTH_CONNECT, NEARBY_WIFI_DEVICES, UWB_RANGING | API 31+,用于扫描和连接附近的设备,不再滥用位置权限。 |
android.permission-group.NOTIFICATIONS | POST_NOTIFICATIONS | API 33+,发送通知变为运行时权限。 |
android.permission-group.MEDIA | READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_AUDIO | API 33+,取代了粗粒度的 READ_EXTERNAL_STORAGE。 |
| - | READ_MEDIA_VISUAL_USER_SELECTED | API 34+,允许应用访问用户在照片选择器中手动选择的图片和视频,是分区存储的最佳实践。 |
详见官方文档中保护等级为 dangerous 的权限。
注意:在 Android 11+ 系统中,用户即使授予了某个危险权限,该权限同组的其他权限也不会被系统自动授权。
权限申请实战
我们通过一个示例来演示如何在程序运行时申请权限。我们将使用官方推荐的 Activity Result API 来替代传统的 onRequestPermissionsResult 回调方式,因为它更简洁,还不容易出错。
我们来实现向用户申请读取联系人的权限。
首先创建名为 RuntimePermissionTest 的 Empty 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>
现在重新运行程序,并点击按钮,结果如图所示:
点击拒绝,结果如图:
再次点击按钮,结果如图:
点击同意,结果如图:
接下来,我们来看看如何利用获取到的权限去访问 ContentProvider。
访问其他程序中的数据
ContentProvider 的用法有两种:
-
一种是使用现有的
ContentProvider。 -
另一种是创建自定义的
ContentProvider。
本文只探讨第一种:如何访问其他程序提供的数据。
ContentResolver 的基本用法
要想访问 ContentProvider 中共享的数据,就要用到 ContentResolver 对象。
它提供了标准的 insert、update、delete、query 方法来对数据进行增删改查操作,这些方法都会接收一个 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部分 | 描述 |
|---|---|---|
uri | from table_name | 指定要查询的 ContentProvider 和其中的数据集 |
projection | select column1, column2 | 指定查询结果返回的列名,null表示所有列 |
selection | where column = ? | 指定查询结果的约束条件 |
selectionArgs | 为 ? 占位符提供值 | 为 selection 中的 ? 占位符提供具体的值。 |
sortOrder | order 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>
现在运行程序,会弹出对话框让我们授予访问联系人的权限,如图所示:
点击允许后,即可在界面看到联系人的信息,如图所示: