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 到 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 版本上都能稳定运行。