4. 位置权限与蓝牙扫描的绑定关系
4.1 绑定原理
蓝牙扫描(特别是 BLE 扫描)在 Android 系统中长期与位置权限绑定,核心原因如下:
- 信号指纹可定位:蓝牙信标的 MAC 地址、信号强度(RSSI)可被用于三角定位或指纹定位,从而推断用户位置。
- 隐私保护机制:Google 为防止应用在后台通过蓝牙扫描持续收集用户位置信息,要求扫描操作必须持有位置权限。
- 合规差异:不同 OEM 厂商对 AOSP 的权限 enforcement 强度不同,但官方 CTS 要求扫描时必须关联位置权限(Android 11 及以下)。
从 Android 10 开始,系统强制要求蓝牙扫描必须伴随位置权限;直到 Android 12 引入 BLUETOOTH_SCAN 细粒度权限,才提供了“仅扫描不定位”的豁免通道。
4.2 权限分类与 API 清单
蓝牙扫描涉及的核心权限及其与位置权限的绑定关系如下表:
| 权限名称 | 所属类型 | 与位置绑定关系 | 引入 API |
|---|---|---|---|
ACCESS_FINE_LOCATION |
精确定位 | 完全绑定(Android 10-11 扫描必选) | API 1 |
ACCESS_COARSE_LOCATION |
粗略定位 | 部分绑定(Android 12+ 可与扫描解耦) | API 1 |
BLUETOOTH_SCAN |
蓝牙扫描(新权限) | 默认不绑定,可声明 neverForLocation 彻底解耦 |
API 31 |
BLUETOOTH_ADVERTISE |
蓝牙广播 | 与位置无绑定关系 | API 31 |
BLUETOOTH_CONNECT |
蓝牙连接 | 与位置无绑定关系 | API 31 |
关键区分:
ACCESS_FINE_LOCATION 和 ACCESS_COARSE_LOCATION 属于位置权限组,而 BLUETOOTH_SCAN 属于蓝牙权限组。从 API 31 起,两组权限在语义和运行时授权上完全分离。
4.3 Android 10 / 12 / 13 演变历程
下表梳理了从 Android 9 到 Android 13 蓝牙扫描与位置权限的绑定关系变化:
| Android 版本 | API 级别 | 蓝牙扫描权限要求 | 位置权限是否必需 | 核心变化 |
|---|---|---|---|---|
| 9 及以下 | ≤ 28 | BLUETOOTH_ADMIN |
否 | 扫描与位置完全解耦 |
| 10 – 11 | 29 – 30 | BLUETOOTH_ADMIN |
是(必须 ACCESS_FINE_LOCATION) |
强制绑定位置,扫描须运行时授权 |
| 12 | 31 – 32 | BLUETOOTH_SCAN(新增) |
可选(声明 neverForLocation 可免除) |
引入细粒度蓝牙权限,提供豁免通道 |
| 13+ | 33+ | BLUETOOTH_SCAN |
可选(策略同 API 31) | 权限模型稳定,强制使用新权限 API |
下面以可视化流程图展示这一演变路径及“仅扫描例外”的定位:
4.4 仅扫描例外规则详解
“仅扫描例外”是指:应用只进行蓝牙设备发现或 BLE 扫描,不利用扫描结果计算地理位置时,可以通过特定声明免除位置权限要求。该规则从 Android 12(API 31)开始生效,并延续至 Android 13+。
启用例外的三个必要条件:
- 目标 API 等级 ≥ 31;
- 在清单中声明
BLUETOOTH_SCAN权限,并附加android:usesPermissionFlags="neverForLocation"; - 运行时不得将扫描数据用于任何位置推断逻辑(包括 MAC 地址聚类、RSSI 测距、信标定位等)。
清单声明示例:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
⚠ 重要限制:如果应用同时声明了
BLUETOOTH_SCAN 和 ACCESS_FINE_LOCATION,且 没有 使用 neverForLocation,系统会默认按“需要位置权限”处理。例外声明不能与位置权限共存于同一扫描流程中。
4.5 适配实现流程
以下步骤展示如何基于 API 33 标准适配蓝牙扫描权限,同时兼容 Android 10-12 旧设备:
- 清单声明:针对 API 31+ 声明
BLUETOOTH_SCAN+neverForLocation;针对 API 29-30 声明ACCESS_FINE_LOCATION。 - 运行时权限检查:根据 API 级别动态请求相应权限组。
- 扫描初始化:使用
BluetoothLeScanner启动扫描,扫描回调中不处理位置相关数据。 - 回退策略:当用户拒绝位置权限时,对 API 29-30 设备禁止扫描;对 API 31+ 设备仍可通过
neverForLocation路径继续扫描。
运行时权限请求示例(Kotlin):
private fun requestBluetoothScanPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Android 12+:只需 BLUETOOTH_SCAN(neverForLocation 已在清单声明)
requestPermissions(
arrayOf(Manifest.permission.BLUETOOTH_SCAN),
RC_BLUETOOTH_SCAN
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10-11:必须请求位置权限
requestPermissions(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION),
RC_LOCATION
)
} else {
// Android 9-:无需运行时权限
startBleScan()
}
}
4.6 调试与验证方法
使用以下 adb 命令验证权限声明是否生效:
# 查看应用的已授予权限列表
adb shell dumpsys package <包名> | grep -E "BLUETOOTH_SCAN|ACCESS_FINE_LOCATION"
# 检查 neverForLocation 标记是否被正确解析
adb shell dumpsys package <包名> | grep "usesPermissionFlags"
日志验证建议:在扫描启动前和扫描回调中分别输出日志,确认未因缺少位置权限而抛出 SecurityException。
// 扫描启动日志
Log.d(TAG, "startBleScan: apiLevel=${Build.VERSION.SDK_INT}, " +
"hasLocation=${checkSelfPermission(ACCESS_FINE_LOCATION)}")
// 扫描回调异常捕获
try {
bluetoothLeScanner.startScan(callback)
} catch (e: SecurityException) {
Log.e(TAG, "扫描被拒绝:缺少必要权限 → ${e.message}")
}
4.7 常见错误与解决方案
- SecurityException: Need ACCESS_COARSE_LOCATION or ACCESS_FINE_LOCATION
原因:Android 10-11 设备未授予位置权限就跑扫描。解决:在 API 29-30 分支强制请求位置权限。 - neverForLocation 声明被忽略
原因:清单中同时声明了ACCESS_FINE_LOCATION且未加maxSdkVersion限制。解决:对 API 31+ 使用maxSdkVersion="30"包裹旧位置权限。 - 扫描返回空结果或立即停止
原因:系统检测到应用声明了neverForLocation但实际代码中调用了位置 API。解决:移除所有与位置相关的库调用,确保扫描结果仅用于设备发现。 - 商店审核被拒
原因:声明neverForLocation但应用描述或功能暗示会使用位置信息。解决:在隐私政策中明确说明扫描数据不用于位置推断。
4.8 性能与稳定性优化
- 按需扫描:避免长时间连续扫描,使用
ScanSettings.Builder设置合理的扫描模式和间隔。 - 权限缓存:将权限授权状态缓存到内存或 DataStore,避免每次启动扫描都重新检查。
- 回退降级:当用户拒绝位置权限且设备 API 低于 31 时,优雅提示用户进入设置页手动授权,而非直接崩溃。
- 测试覆盖:在模拟器上分别测试 API 28、29、31、33 四个镜像,验证权限请求流程和扫描行为是否符合预期。
📐 架构建议:将权限逻辑封装为独立的
BluetoothPermissionManager 单例,内部按 API 级别分发请求流程;业务层只需调用 ensureScanPermission() → startScan() 两个方法,降低耦合。