3. 蓝牙权限申请与运行时处理

3.1 原理:Android 6~14 蓝牙权限演进

Android 的蓝牙权限模型经历了三次重大变更。Android 6.0 引入运行时权限,蓝牙相关权限从安装时授权变为运行时动态申请;Android 10 增加后台位置限制;Android 12 将传统蓝牙权限拆分为细粒度权限,彻底剥离位置依赖。下表汇总各版本核心差异:

Android 版本API 级别蓝牙权限清单关键变化
6.0 – 9.023 – 28ACCESS_FINE_LOCATION(或粗略位置)蓝牙扫描需位置权限;BLUETOOTH + BLUETOOTH_ADMIN 静态声明
10 – 1129 – 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 → 检测权限 → 动态请求 → 处理结果”。下图展示完整决策路径:

蓝牙权限申请流程图 应用启动 / 蓝牙操作 SDK ≥ 31 ? 检查 BLUETOOTH_SCAN & BLUETOOTH_CONNECT (可选 ADVERTISE) 检查 ACCESS_FINE_ LOCATION 权限已授予? 执行蓝牙操作 动态请求权限 用户选择 授予 → 继续 拒绝 → 降级/提示 不再询问 → 引导设置

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_LOCATIONAndroid 10+ 需额外请求后台位置权限(需先获得前台位置权限)
应用被系统标记为“后台限制”频繁请求权限或后台使用蓝牙合并权限请求次数;使用 WorkManager 或 ForegroundService 降低后台行为频率

3.6 性能与稳定性优化建议

  • 缓存权限状态: 在应用启动时一次性检查所有蓝牙权限,将结果存入内存变量,避免每次操作都触发系统调用。
  • 减少弹窗次数: 将多个权限合并到一次 requestPermissions 请求中,避免用户多次拒绝后进入“不再询问”状态。
  • 优雅处理“不再询问”: 检测到用户永久拒绝后,弹出对话框提供“前往设置”按钮(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)),并引导用户手动开启。
  • 版本隔离: 使用 @RequiresApi 注解或 if (Build.VERSION.SDK_INT >= 31) 包裹新版权限调用,确保在低版本设备上不出现编译或运行时错误。
  • 权限回收检测: 当 Activity/Fragment 进入 onResume 时重新检查权限是否被用户后台撤销,若被撤销则暂停蓝牙操作并提示。
最佳实践: 权限申请不应阻塞用户界面。建议在用户首次点击“扫描”或“连接”按钮时触发请求,而非在 onCreate 中直接弹出。若用户拒绝,可使用 SnackBar 提供重试引导,避免强制弹窗造成负面体验。