15、蓝牙权限适配(Android 12+):BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE运行时权限申请、位置权限解耦、兼容旧版本。
Android 12 对蓝牙权限的改动,说实话,是我这几年适配工作中印象最深的一次。以前我们做蓝牙开发,权限逻辑相对简单——声明个 BLUETOOTH 和 BLUETOOTH_ADMIN,再配上位置权限就完事了。但 Android 12 直接把蓝牙权限体系拆了个七零八落,引入了三个全新的运行时权限。嗯,咱们今天就来把这套新规则彻底捋清楚。
核心变化一句话总结:Android 12 将蓝牙相关功能从位置权限中解耦出来,不再需要位置权限就能扫描蓝牙设备。但代价是——你必须申请三个新的运行时权限。
15.1 新权限体系概览
Android 12 新增了三个权限,分别对应蓝牙的三种核心操作:
| 权限名 | 对应操作 | 是否需要用户弹窗 |
|---|---|---|
BLUETOOTH_SCAN |
扫描附近的蓝牙设备 | 是(运行时) |
BLUETOOTH_CONNECT |
连接已配对的蓝牙设备 | 是(运行时) |
BLUETOOTH_ADVERTISE |
广播本设备(作为外围设备) | 是(运行时) |
我个人习惯把这三个权限称为「蓝牙三件套」。你想想看,以前一个 BLUETOOTH 权限包打天下,现在拆成三个,每个都有独立的用途。这样做的好处是——用户授权时可以更精确地控制你的 App 能做什么。
小提示:如果你的 App 只需要扫描设备,那就只申请 BLUETOOTH_SCAN,别把另外两个也带上。我在项目中见过不少开发者图省事,三个权限一起申请,结果用户一看「你要连接我的设备?还要广播?」直接拒绝了。
15.2 位置权限解耦——终于不用背锅了
以前做蓝牙扫描,必须同时申请位置权限。为什么?因为系统认为蓝牙扫描可以推断用户位置。这其实是个历史遗留问题,很多用户不理解:「我就连个蓝牙耳机,凭什么要给你位置权限?」
Android 12 终于把这个锅卸掉了。从 Android 12 开始,蓝牙扫描不再需要位置权限。但注意——这是有条件的。
重要提醒:如果你的 App 通过蓝牙扫描结果来推断用户位置(比如室内定位、信标测距),那你仍然需要位置权限。系统不会因为你用了 BLUETOOTH_SCAN 就豁免你。
说白了,Google 的逻辑是:蓝牙扫描本身不要求位置权限,但如果你用扫描结果做位置相关的事,那位置权限还是跑不掉。嗯,这个设计其实挺合理的——把「工具」和「用途」分开了。
15.3 运行时权限申请实战
下面我写一个完整的权限申请流程。注意,这里要同时兼容 Android 12 以下和以上的设备。
// 判断是否需要位置权限
private fun needsLocationPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12+:蓝牙扫描不需要位置权限
// 但如果你用扫描结果做位置推断,还是需要
false
} else {
// Android 11 及以下:蓝牙扫描必须配合位置权限
true
}
}
// 获取需要申请的权限列表
private fun getRequiredPermissions(): Array<String> {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12+:申请蓝牙三件套
permissions.add(Manifest.permission.BLUETOOTH_SCAN)
permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
permissions.add(Manifest.permission.BLUETOOTH_ADVERTISE)
// 如果 App 需要位置推断,再加位置权限
if (needsLocationPermission()) {
permissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
} else {
// 旧版本:申请传统蓝牙权限 + 位置权限
permissions.add(Manifest.permission.BLUETOOTH)
permissions.add(Manifest.permission.BLUETOOTH_ADMIN)
permissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
return permissions.toTypedArray()
}
这段代码的核心思路是:根据系统版本动态决定申请哪些权限。我曾经在项目里看到有人直接写死权限列表,结果在 Android 12 设备上闪退——因为 BLUETOOTH_SCAN 在旧版本上根本不存在。
15.4 兼容旧版本——别让老用户寒心
你的 App 肯定要跑在 Android 11、10、甚至 9 的设备上。这时候怎么办?
我的做法是:在 AndroidManifest.xml 里同时声明新旧两套权限,然后用 maxSdkVersion 控制旧权限的生效范围。
<!-- Android 12+ 新权限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- 旧版本兼容权限 -->
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<!-- 位置权限(旧版本必须,新版本按需) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
注意 maxSdkVersion="30" 这个属性。它的意思是:这个权限只在 Android 11(API 30)及以下生效。到了 Android 12 以上,系统会自动忽略它。这样既兼容了旧设备,又不会在新设备上产生冗余权限声明。
一个小坑:有些开发者忘了加 maxSdkVersion,结果在 Android 12 上同时声明了 BLUETOOTH 和 BLUETOOTH_SCAN。虽然不会崩溃,但 Google Play 的审核可能会提示你「存在冗余权限声明」。我建议你养成加 maxSdkVersion 的习惯。
15.5 权限申请流程图
下面我用一张 SVG 图把整个权限适配的逻辑串起来,方便你理解。
15.6 运行时权限申请代码模板
最后,我贴一段完整的运行时权限申请代码。这段代码我用了很多项目,基本没出过问题。
class BluetoothPermissionHelper(private val activity: Activity) {
companion object {
private const val PERMISSION_REQUEST_CODE = 1001
}
fun checkAndRequestPermissions(callback: (Boolean) -> Unit) {
val permissions = getRequiredPermissions()
val deniedPermissions = permissions.filter {
ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED
}
if (deniedPermissions.isEmpty()) {
// 所有权限都已授予
callback(true)
return
}
// 请求未授予的权限
ActivityCompat.requestPermissions(
activity,
deniedPermissions.toTypedArray(),
PERMISSION_REQUEST_CODE
)
}
fun handlePermissionResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
callback: (Boolean) -> Unit
) {
if (requestCode != PERMISSION_REQUEST_CODE) return
val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
if (!allGranted) {
// 检查用户是否勾选了「不再询问」
val shouldShowRationale = permissions.any {
ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (!shouldShowRationale) {
// 用户勾选了「不再询问」,引导去设置页
showPermissionSettingsDialog()
} else {
// 用户拒绝了但没勾选「不再询问」,可以再次解释
showPermissionExplanationDialog()
}
}
callback(allGranted)
}
}
避坑指南:我曾经遇到过一个情况——在 Android 12 设备上,用户拒绝了 BLUETOOTH_SCAN 权限,但 App 仍然能调用 startDiscovery() 方法。结果呢?扫描结果为空,而且没有任何错误提示。后来我才发现,Android 12 在权限被拒绝时,系统会静默返回空结果,而不是抛异常。所以一定要在扫描前检查权限状态。
15.7 总结
Android 12 的蓝牙权限适配,说白了就是三件事:
- 新权限三件套:SCAN、CONNECT、ADVERTISE,按需申请,别贪多
- 位置权限解耦:蓝牙扫描不再强制要求位置权限,但如果你用扫描结果做位置推断,还是得申请
- 兼容旧版本:用
maxSdkVersion控制旧权限声明,运行时根据系统版本动态申请
嗯,这套规则其实不难,就是细节多。你只要把权限声明和运行时申请的逻辑写对,剩下的就交给系统去处理了。记住一点:权限是用户体验的第一道门槛,别让用户一打开 App 就看到一堆权限弹窗。