4、蓝牙权限与适配:Android 6~14权限动态申请、蓝牙开关状态监听、设备扫描限制适配

Android 系统自 6.0(API 23)引入运行时权限机制以来,蓝牙相关的权限模型经历了多次重大调整。尤其是 Android 12(API 31)开始强制要求 BLUETOOTH_SCAN、BLUETOOTH_ADVERTISE、BLUETOOTH_CONNECT 三个新权限,彻底改变了传统蓝牙开发的权限申请方式。与此同时,不同 Android 版本对蓝牙扫描的频次、结果回调时机、后台扫描限制均有不同策略。本章将系统梳理 Android 6 至 Android 14 的蓝牙权限演进路径,讲解动态申请的正确姿势、蓝牙开关状态监听的实现细节,以及设备扫描限制的适配方案,帮助开发者构建一套兼容性良好的蓝牙服务层。

Android 6~14 蓝牙权限与扫描适配决策图 Android 6 ~ 11 (API 23-30) • 传统蓝牙权限组 BLUETOOTH / BLUETOOTH_ADMIN • 定位权限(扫描必要条件) ACCESS_FINE_LOCATION ACCESS_COARSE_LOCATION • 运行时动态申请 • 无后台扫描限制 Android 12+ (API 31+) • 新增蓝牙专属权限 BLUETOOTH_SCAN BLUETOOTH_CONNECT BLUETOOTH_ADVERTISE • 需配合 usesPermissionFlags • 扫描结果回调限制 • 后台扫描节流 权限申请核心流程 ① 检查SDK版本 ② 按版本分组权限 ③ 动态请求授权 ④ 处理拒绝/永久拒绝 ⑤ 监听开关状态 ⑥ 启动扫描/适配限制 扫描限制适配策略 扫描间隔限制 • 30秒内最多扫描5次 • 每次扫描不超过30秒 • 需控制扫描周期 结果回调限制 • 批量回调扫描结果 • 避免频繁UI刷新 • 使用缓存策略 后台扫描限制 • 后台不可连续扫描 • 需前台服务配合 • 使用WorkManager调度

上图展示了 Android 6~14 蓝牙权限模型的核心差异与扫描适配的关键维度。从 Android 6 到 Android 11,蓝牙权限与定位权限绑定,开发者需要同时申请蓝牙权限和定位权限才能执行扫描;而从 Android 12 开始,系统将蓝牙权限拆分为三个独立权限,定位权限不再作为扫描的必要条件(但若需获取 Beacon 的物理位置信息,仍需定位权限)。扫描限制方面,Android 8(API 26)引入了后台执行限制,Android 12 进一步强化了扫描节流策略。下面我们将从权限模型演进、动态申请实现、开关监听、扫描适配四个维度深入展开。

4.1 蓝牙权限模型演进与核心原理

理解蓝牙权限模型是适配工作的基础。Android 的权限体系分为安装时权限(Install-time Permission)和运行时权限(Runtime Permission)。蓝牙权限在 Android 12 之前属于安装时权限,在 Android 12 之后变为运行时权限。这种变化意味着开发者必须在应用运行时主动请求用户授权,而不能仅依赖 AndroidManifest.xml 中的声明。

4.1.1 传统权限模型(Android 6~11)

在 Android 6 至 Android 11 期间,蓝牙操作主要依赖以下权限:

  • BLUETOOTH:允许应用执行蓝牙通信,如连接、数据传输。
  • BLUETOOTH_ADMIN:允许应用管理蓝牙设备,如开启/关闭蓝牙、扫描设备。
  • ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION:由于蓝牙扫描可能暴露用户位置,系统强制要求定位权限。

值得注意的是,从 Android 10(API 29)开始,ACCESS_BACKGROUND_LOCATION 权限被引入,如果应用需要在后台扫描蓝牙设备,必须额外申请该权限。这导致权限申请流程变得复杂,用户需要分别授权前台定位和后台定位。

4.1.2 新权限模型(Android 12+)

Android 12 将蓝牙权限从安装时权限改为运行时权限,并拆分为三个独立权限:

权限名 用途 对应旧权限
BLUETOOTH_SCAN 扫描蓝牙设备 BLUETOOTH_ADMIN + 定位权限
BLUETOOTH_CONNECT 连接已配对的蓝牙设备 BLUETOOTH
BLUETOOTH_ADVERTISE 广播蓝牙数据(外围设备模式) BLUETOOTH_ADMIN

新模型下,定位权限不再是扫描的必要条件,但如果应用需要解析 Beacon 的物理位置(如室内定位),仍需申请定位权限。此外,Android 13(API 33)进一步优化了权限弹窗的交互,用户可以选择“仅在使用中允许”或“每次询问”。

核心原理:权限模型变化的本质是 Android 对用户隐私保护的强化。旧模型中,蓝牙权限与定位权限绑定,导致用户无法单独控制蓝牙扫描行为;新模型将蓝牙权限独立,用户可更精细地控制应用对蓝牙功能的访问。开发者需要根据目标 SDK 版本动态适配权限申请逻辑。

4.2 动态权限申请的实现方法

动态权限申请的核心是使用 ActivityCompat.requestPermissions() 或 registerForActivityResult() API。推荐使用 AndroidX 的 ActivityResultContracts.RequestMultiplePermissions() 来批量申请权限,这样可以减少代码复杂度并提高用户体验。

4.2.1 权限分组与版本判断

由于不同 Android 版本所需权限不同,我们需要在代码中判断 SDK 版本并构建对应的权限列表。以下是一个典型的权限分组逻辑:

private fun getRequiredPermissions(): Array<String> {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        arrayOf(
            Manifest.permission.BLUETOOTH_SCAN,
            Manifest.permission.BLUETOOTH_CONNECT,
            Manifest.permission.BLUETOOTH_ADVERTISE
        )
    } else {
        arrayOf(
            Manifest.permission.BLUETOOTH,
            Manifest.permission.BLUETOOTH_ADMIN,
            Manifest.permission.ACCESS_FINE_LOCATION
        )
    }
}

注意,Android 12 及以上版本还需要在 AndroidManifest.xml 中声明 android:usesPermissionFlags="neverForLocation" 属性,以告知系统应用不会使用蓝牙扫描结果推断用户位置。如果不声明该属性,系统会强制要求定位权限。

4.2.2 使用 ActivityResult API 申请权限

推荐使用 registerForActivityResult() 方式,它比传统的 onRequestPermissionsResult() 更简洁且生命周期安全:

private val permissionLauncher = registerForActivityResult(
    ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
    val allGranted = permissions.values.all { it }
    if (allGranted) {
        startBluetoothScan()
    } else {
        showPermissionDeniedDialog()
    }
}

fun checkAndRequestPermissions() {
    val required = getRequiredPermissions()
    val notGranted = required.filter {
        ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
    }
    if (notGranted.isEmpty()) {
        startBluetoothScan()
    } else {
        permissionLauncher.launch(notGranted.toTypedArray())
    }
}

4.2.3 处理永久拒绝场景

当用户选择“不再询问”时,权限申请弹窗将不再出现。此时应引导用户前往系统设置页手动开启权限。可以通过 shouldShowRequestPermissionRationale() 方法判断:

if (!ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) {
    // 用户已选择“不再询问”,跳转设置页
    Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
        data = Uri.parse("package:$packageName")
        startActivity(this)
    }
}

风险提示:在 Android 12 及以上版本,如果应用未声明 neverForLocation 标志,系统会在权限弹窗中额外显示定位权限请求,这可能导致用户困惑并拒绝授权。务必在 Manifest 中明确声明该标志,除非你的应用确实需要从蓝牙信号中推断物理位置。

4.3 蓝牙开关状态监听

蓝牙开关状态监听是蓝牙服务的基础能力。系统通过广播 BluetoothAdapter.ACTION_STATE_CHANGED 通知蓝牙状态变化。开发者需要注册动态广播接收器,并在合适的时机取消注册以避免内存泄漏。

4.3.1 广播接收器实现

private val bluetoothStateReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == BluetoothAdapter.ACTION_STATE_CHANGED) {
            val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
            when (state) {
                BluetoothAdapter.STATE_ON -> onBluetoothOn()
                BluetoothAdapter.STATE_TURNING_ON -> onBluetoothTurningOn()
                BluetoothAdapter.STATE_OFF -> onBluetoothOff()
                BluetoothAdapter.STATE_TURNING_OFF -> onBluetoothTurningOff()
            }
        }
    }
}

override fun onStart() {
    super.onStart()
    val filter = IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
    registerReceiver(bluetoothStateReceiver, filter)
}

override fun onStop() {
    super.onStop()
    unregisterReceiver(bluetoothStateReceiver)
}

4.3.2 状态监听的最佳实践

  • 注册时机:建议在 onStart() 中注册,在 onStop() 中注销,避免在 Activity 不可见时接收无意义的广播。
  • 状态机管理:蓝牙状态变化是异步的,从 STATE_TURNING_ON 到 STATE_ON 需要时间,应避免在 STATE_TURNING_ON 时立即执行扫描操作。
  • 使用 LiveData/Flow:在 MVVM 架构中,建议将蓝牙状态封装为 LiveData<Int> 或 StateFlow<Int>,便于 UI 层观察。

经验提醒:在 Android 12 及以上版本,监听蓝牙状态变化需要 BLUETOOTH_CONNECT 权限。如果未授予该权限,广播接收器将无法收到状态变化通知。因此,在注册接收器前务必确认权限已授予。

4.4 设备扫描限制适配

Android 系统对蓝牙扫描有严格的限制,尤其是从 Android 8 开始的后台执行限制和 Android 12 的扫描节流策略。开发者需要根据应用场景选择合适的扫描策略。

4.4.1 扫描限制的具体规则

Android 版本 限制内容 适配建议
Android 6~7 无特殊限制 可连续扫描,但需注意功耗
Android 8~11 后台应用每 30 分钟最多扫描 4 次 使用前台服务或 WorkManager 调度
Android 12+ 30 秒内最多 5 次扫描,每次不超过 30 秒 实现扫描队列,控制扫描周期

4.4.2 扫描策略设计

为了适配扫描限制,建议采用“短扫描 + 长间隔”的策略。例如,每次扫描 10 秒,间隔 30 秒。同时,应使用 BluetoothLeScanner.startScan() 的 ScanFilter 和 ScanSettings 参数来优化扫描效率:

val settings = ScanSettings.Builder()
    .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
    .setReportDelay(0) // 实时回调
    .build()

val filters = listOf(
    ScanFilter.Builder()
        .setServiceUuid(ParcelUuid.fromString("0000FFE0-0000-1000-8000-00805F9B34FB"))
        .build()
)

bluetoothLeScanner.startScan(filters, settings, scanCallback)

4.4.3 后台扫描的替代方案

如果应用需要在后台持续扫描(如 Beacon 室内定位),必须使用前台服务并附带通知。Android 12 要求前台服务必须声明 foregroundServiceType="location" 或 "connectedDevice" 类型。此外,可以使用 WorkManager 进行周期性扫描,每次扫描后释放资源。

实战场景:假设我们开发一个蓝牙门禁应用,需要持续监听附近的蓝牙门禁设备。在 Android 12 设备上,如果直接使用 startScan() 并保持扫描,系统会在 30 秒后强制停止扫描并抛出 SecurityException。正确的做法是:使用前台服务(类型为 connectedDevice),每 20 秒扫描一次,每次扫描 8 秒,扫描间隔 12 秒。这样既满足系统限制,又能保证门禁设备的及时发现。

4.5 常见误区与总结复盘

在蓝牙权限与适配开发中,开发者常犯以下错误:

  • 误区一:在 Android 12 上仍然只申请定位权限而不申请 BLUETOOTH_SCAN,导致扫描失败。
  • 误区二:未在 Manifest 中声明 neverForLocation,导致系统强制要求定位权限,用户体验下降。
  • 误区三:在 STATE_TURNING_ON 状态时立即执行扫描,此时蓝牙尚未完全开启,扫描会失败。
  • 误区四:忽略扫描回调的批量行为,在 onScanResult() 中频繁更新 UI,导致界面卡顿。

总结复盘:蓝牙权限适配的核心是“版本感知 + 动态申请 + 状态管理 + 扫描节流”。开发者应建立一套统一的权限管理模块,将版本判断、权限申请、结果回调封装为可复用的组件。同时,建议在应用启动时预检蓝牙状态和权限状态,提前引导用户完成授权,避免在业务操作中突然弹出权限请求。对于扫描操作,务必设计合理的扫描周期和回调处理机制,确保应用在不同 Android 版本上都能稳定运行。