3. 蓝牙权限申请与运行时处理
3.1 原理:Android 6~14 蓝牙权限演进
Android 的蓝牙权限模型经历了三次重大变更。Android 6.0 引入运行时权限,蓝牙相关权限从安装时授权变为运行时动态申请;Android 10 增加后台位置限制;Android 12 将传统蓝牙权限拆分为细粒度权限,彻底剥离位置依赖。下表汇总各版本核心差异:
| Android 版本 | API 级别 | 蓝牙权限清单 | 关键变化 |
|---|---|---|---|
| 6.0 – 9.0 | 23 – 28 | ACCESS_FINE_LOCATION(或粗略位置) | 蓝牙扫描需位置权限;BLUETOOTH + BLUETOOTH_ADMIN 静态声明 |
| 10 – 11 | 29 – 30 | 同 6.0 + 后台位置 ACCESS_BACKGROUND_LOCATION | 后台扫描需额外后台位置权限,拒绝后无法后台发现设备 |
| 12+ | 31+ | BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE | 蓝牙操作不再强制绑定位置;常规扫描仅需 BLUETOOTH_SCAN,连接仅需 BLUETOOTH_CONNECT;后台扫描仍可能需要 ACCESS_FINE_LOCATION |
兼容原则: targetSdkVersion 决定应用声明的权限集,但运行时需按设备实际 API 级别动态判断。即使 targetSdkVersion=33,在 Android 11 设备上仍应使用旧权限模型。
3.2 实现流程
蓝牙权限申请的核心步骤可概括为“检查 SDK → 检测权限 → 动态请求 → 处理结果”。下图展示完整决策路径:
3.3 代码实现
3.3.1 AndroidManifest 声明(兼容写法)
<!-- 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" />
<!-- 旧版本位置权限(Android 6–11) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"
android:maxSdkVersion="30" />
<!-- 基本蓝牙权限(始终需要) -->
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="31" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="31" />
maxSdkVersion 确保低版本清单中不包含 Android 12+ 专用权限,同时高版本清单不声明已废弃的旧权限。
3.3.2 运行时权限请求(Kotlin 兼容函数)
private fun requestBluetoothPermissions() {
val permissionsToRequest = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12+
if (checkSelfPermission(BLUETOOTH_SCAN) != PERMISSION_GRANTED)
permissionsToRequest.add(BLUETOOTH_SCAN)
if (checkSelfPermission(BLUETOOTH_CONNECT) != PERMISSION_GRANTED)
permissionsToRequest.add(BLUETOOTH_CONNECT)
} else {
// Android 6–11
if (checkSelfPermission(ACCESS_FINE_LOCATION) != PERMISSION_GRANTED)
permissionsToRequest.add(ACCESS_FINE_LOCATION)
}
if (permissionsToRequest.isNotEmpty()) {
requestPermissions(
permissionsToRequest.toTypedArray(),
REQUEST_CODE_BLUETOOTH_PERMISSIONS
)
} else {
onBluetoothPermissionsGranted()
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_BLUETOOTH_PERMISSIONS) {
val allGranted = grantResults.all { it == PERMISSION_GRANTED }
if (allGranted) {
onBluetoothPermissionsGranted()
} else {
// 检查是否勾选"不再询问"
val shouldShowRationale = permissions.any {
shouldShowRequestPermissionRationale(it)
}
if (!shouldShowRationale) {
// 永久拒绝,引导用户去设置
showPermissionSettingsDialog()
} else {
showPermissionRationale()
}
}
}
}
3.4 调试与验证
测试权限场景时,推荐使用以下 adb 命令模拟不同状态:
# 授予所有蓝牙权限
adb shell pm grant 包名 android.permission.BLUETOOTH_SCAN
adb shell pm grant 包名 android.permission.BLUETOOTH_CONNECT
adb shell pm grant 包名 android.permission.ACCESS_FINE_LOCATION
# 撤销特定权限
adb shell pm revoke 包名 android.permission.BLUETOOTH_SCAN
# 重置所有运行时权限(恢复初始状态)
adb shell pm reset-permissions 包名
# 在 Android 12+ 上查看权限授予情况
adb shell dumpsys package 包名 | grep -E "BLUETOOTH|Location"
调试技巧: 若目标设备为 Android 12+,在系统设置中手动关闭权限后,应用应能立即触发
onRequestPermissionsResult 回调。同时观察 Logcat 过滤 PackageManager 或 PermissionController 日志,确认授权流程完整。
3.5 常见错误与解决方案
| 错误现象 | 根因 | 解决 |
|---|---|---|
SecurityException: Need BLUETOOTH_SCAN permission | 运行时未持有新权限,但调用了 BluetoothAdapter.startDiscovery() 或 BluetoothLeScanner.startScan() | 确保动态请求后再调用对应 API;检查 checkSelfPermission 逻辑 |
| Android 12 上无法扫描但位置权限已授予 | 未声明 BLUETOOTH_SCAN 或声明了 maxSdkVersion 但当前设备是 Android 12 | 在清单中取消 maxSdkVersion 对 BLUETOOTH_SCAN 的限制 |
| 用户拒绝权限后应用崩溃 | 未处理权限拒绝分支,直接调用蓝牙 API | 在 onRequestPermissionsResult 中优雅降级或提示 |
| Android 10 后台扫描不工作 | 未请求 ACCESS_BACKGROUND_LOCATION | Android 10+ 需额外请求后台位置权限(需先获得前台位置权限) |
| 应用被系统标记为“后台限制” | 频繁请求权限或后台使用蓝牙 | 合并权限请求次数;使用 WorkManager 或 ForegroundService 降低后台行为频率 |
3.6 性能与稳定性优化建议
- 缓存权限状态: 在应用启动时一次性检查所有蓝牙权限,将结果存入内存变量,避免每次操作都触发系统调用。
- 减少弹窗次数: 将多个权限合并到一次
requestPermissions请求中,避免用户多次拒绝后进入“不再询问”状态。 - 优雅处理“不再询问”: 检测到用户永久拒绝后,弹出对话框提供“前往设置”按钮(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)),并引导用户手动开启。 - 版本隔离: 使用
@RequiresApi注解或if (Build.VERSION.SDK_INT >= 31)包裹新版权限调用,确保在低版本设备上不出现编译或运行时错误。 - 权限回收检测: 当 Activity/Fragment 进入 onResume 时重新检查权限是否被用户后台撤销,若被撤销则暂停蓝牙操作并提示。
最佳实践: 权限申请不应阻塞用户界面。建议在用户首次点击“扫描”或“连接”按钮时触发请求,而非在 onCreate 中直接弹出。若用户拒绝,可使用 SnackBar 提供重试引导,避免强制弹窗造成负面体验。