3、Android蓝牙权限与适配:蓝牙相关权限声明、运行时权限请求、Android版本兼容性处理(API 23+、API 31+、API 33+)
Android蓝牙开发中,权限声明与运行时请求是构建稳定应用的第一道门槛。随着Android系统从6.0(API 23)演进到13(API 33),蓝牙权限模型经历了从“安装时授权”到“运行时动态请求”,再到“细粒度蓝牙权限拆分”的重大变革。如果开发者未能正确适配不同API级别的权限规则,轻则导致扫描不到设备、连接失败,重则直接崩溃或上架被拒。本章将系统梳理蓝牙权限的声明规范、运行时请求的完整流程,以及针对API 23+、API 31+、API 33+的兼容性处理策略,并结合实际项目中的典型场景进行案例拆解。
上图展示了蓝牙权限适配的核心决策路径。从开发者的目标SDK版本出发,可以快速定位到对应的权限声明与请求策略。接下来,我们将从权限模型演进、声明规范、运行时请求流程、版本兼容处理四个维度展开详细讲解。
3.1 蓝牙权限模型演进与核心原理
Android蓝牙权限体系经历了三个重要阶段,理解其演进逻辑是正确适配的基础。
3.4.1 传统权限模型(API 22及以下)
在Android 6.0之前,蓝牙权限(BLUETOOTH、BLUETOOTH_ADMIN)属于安装时权限。用户安装应用时一次性授予,应用无法在运行时动态请求或撤销。这种模型简单粗暴,但用户对权限的控制力极弱,恶意应用可能滥用蓝牙权限进行数据窃取。
3.4.2 运行时权限模型(API 23 - API 30)
Android 6.0(API 23)引入了运行时权限机制,将权限分为“正常权限”和“危险权限”。蓝牙相关的BLUETOOTH和BLUETOOTH_ADMIN仍属于正常权限,但进行蓝牙扫描和连接时,系统要求应用必须同时拥有定位权限(ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION)。这是因为蓝牙扫描可以间接推断用户位置,属于隐私保护范畴。此阶段的关键适配点:
- Manifest声明:必须声明BLUETOOTH、BLUETOOTH_ADMIN以及至少一个定位权限。
- 运行时请求:定位权限属于危险权限,必须在运行时动态请求。
- 逻辑判断:即使定位权限被拒绝,应用仍可进行已配对设备的通信,但无法扫描新设备。
3.4.3 细粒度蓝牙权限模型(API 31+)
Android 12(API 31)对蓝牙权限进行了重大重构,将原来的BLUETOOTH和BLUETOOTH_ADMIN拆分为三个细粒度权限:
| 权限名称 | 对应功能 | 权限级别 |
|---|---|---|
| BLUETOOTH_SCAN | 扫描蓝牙设备 | 危险权限(需运行时请求) |
| BLUETOOTH_CONNECT | 连接已配对设备、发起连接 | 危险权限(需运行时请求) |
| BLUETOOTH_ADVERTISE | 广播蓝牙信号(外围设备模式) | 危险权限(需运行时请求) |
同时,Android 12+不再强制要求定位权限用于蓝牙扫描(但部分厂商ROM可能仍有此要求)。Android 13(API 33)进一步优化,纯BLE应用(不涉及位置推断)完全无需定位权限。这一变化显著降低了隐私敏感度,但也要求开发者必须精确声明所需权限。
核心原理总结:蓝牙权限适配的本质是“声明-请求-校验”三步骤。声明是静态的,请求是动态的,校验是持续性的。开发者需要根据targetSdkVersion和实际功能选择正确的权限组合,并在运行时处理用户授权结果。
3.2 蓝牙权限声明规范与Manifest配置
无论targetSdkVersion如何,Manifest中的权限声明都是第一步。错误的声明会导致应用安装失败或运行时SecurityException。
3.2.1 基础蓝牙权限声明
<!-- Android 12(API 31)之前的传统声明 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<!-- Android 12+ 新权限声明(必须添加) -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- 定位权限(API 30及以下必需,API 31+可选) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 声明蓝牙硬件特性(可选但推荐) -->
<uses-feature android:name="android.hardware.bluetooth" android:required="true" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
注意android:usesPermissionFlags="neverForLocation"属性,它向系统声明应用不会使用蓝牙扫描结果推断用户位置。添加此标志后,在Android 12+设备上可以避免强制请求定位权限。但必须确保应用确实不将蓝牙信号用于位置推断,否则可能被下架。
3.2.2 权限声明的最佳实践
- 冗余声明无害:同时声明新旧权限不会冲突,系统会根据API级别自动选择适用的权限。
- uses-feature限制:如果应用核心功能依赖BLE,设置
required="true"可过滤无BLE硬件的设备。 - 权限组概念:BLUETOOTH_SCAN和BLUETOOTH_CONNECT属于同一权限组,用户授权其中一个,另一个可能自动授权(但不可依赖此行为)。
风险提示:如果targetSdkVersion ≥ 31,但Manifest中未声明BLUETOOTH_SCAN和BLUETOOTH_CONNECT,应用在调用蓝牙API时会直接抛出SecurityException,且无法通过运行时请求补充声明。务必在发布前检查Manifest合并结果。
3.3 运行时权限请求完整流程
运行时权限请求是蓝牙适配中最容易出错的环节。一个健壮的请求流程需要处理用户授权、拒绝、永久拒绝、权限回调等多个场景。
3.3.1 请求前的状态检查
在发起请求前,必须检查权限是否已授予。Android 6.0+提供了ContextCompat.checkSelfPermission()方法:
private boolean hasBluetoothPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED
&& checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED;
} else {
return checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED;
}
}
3.3.2 请求权限的两种方式
方式一:使用ActivityCompat(推荐)
private static final int REQUEST_BLUETOOTH_PERMISSIONS = 1001;
private void requestBluetoothPermissions() {
String[] permissions;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions = new String[]{
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
};
} else {
permissions = new String[]{
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
};
}
ActivityCompat.requestPermissions(this, permissions, REQUEST_BLUETOOTH_PERMISSIONS);
}
方式二:使用系统API(API 23+)
requestPermissions(permissions, REQUEST_BLUETOOTH_PERMISSIONS);
两种方式效果相同,但ActivityCompat提供了更好的兼容性封装。
3.3.3 回调处理与用户引导
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_BLUETOOTH_PERMISSIONS) {
boolean allGranted = true;
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
allGranted = false;
break;
}
}
if (allGranted) {
startBluetoothScan();
} else {
// 判断是否点击了"不再询问"
boolean shouldShowRationale = false;
for (String permission : permissions) {
if (ActivityCompat.shouldShowRequestPermissionRationale(this, permission)) {
shouldShowRationale = true;
break;
}
}
if (shouldShowRationale) {
// 用户拒绝但未勾选"不再询问",可再次请求
showPermissionRationaleDialog();
} else {
// 用户勾选"不再询问",需引导去设置页
showSettingsDialog();
}
}
}
}
经验提醒:不要在主线程中直接调用requestPermissions(),应确保Activity处于前台且已完全启动。建议在onResume()中检查权限状态,避免在onCreate()中请求导致回调丢失。
3.4 Android版本兼容性处理策略
不同API级别的蓝牙权限差异巨大,一个兼容性良好的应用需要针对不同版本采取差异化策略。
3.4.1 版本差异对照表
| 功能场景 | API 23-30 | API 31-32 | API 33+ |
|---|---|---|---|
| 扫描BLE设备 | 需要定位权限 | 需要BLUETOOTH_SCAN(可加neverForLocation) | 需要BLUETOOTH_SCAN(无需定位) |
| 连接已配对设备 | 需要BLUETOOTH权限 | 需要BLUETOOTH_CONNECT | 需要BLUETOOTH_CONNECT |
| 作为外围设备广播 | 需要BLUETOOTH_ADMIN | 需要BLUETOOTH_ADVERTISE | 需要BLUETOOTH_ADVERTISE |
| 获取已配对设备列表 | 需要BLUETOOTH权限 | 需要BLUETOOTH_CONNECT | 需要BLUETOOTH_CONNECT |
3.4.2 统一权限管理封装
为了简化多版本适配,建议封装一个权限管理工具类:
public class BluetoothPermissionManager {
public static boolean hasPermissions(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return hasPermission(context, Manifest.permission.BLUETOOTH_SCAN)
&& hasPermission(context, Manifest.permission.BLUETOOTH_CONNECT);
} else {
return hasPermission(context, Manifest.permission.ACCESS_FINE_LOCATION);
}
}
public static String[] getRequiredPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return new String[]{
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT
};
} else {
return new String[]{
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
};
}
}
private static boolean hasPermission(Context context, String permission) {
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
}
}
3.4.3 特殊情况处理
- Android 12+的neverForLocation标志:如果应用声明了此标志,系统会隐藏定位权限请求,但必须确保应用不调用任何可能推断位置的方法(如getScanResults()中的RSSI值)。
- 部分厂商ROM的兼容问题:某些国产ROM(如MIUI、EMUI)在Android 12基础上仍要求定位权限,建议在运行时检测并提示用户。
- 权限被撤销的场景:用户可能在系统设置中手动撤销蓝牙权限,应用应在onResume()中重新检查。
风险提示:在Android 12+设备上,如果应用同时声明了BLUETOOTH_SCAN和ACCESS_FINE_LOCATION,系统会弹出两个独立的权限对话框。用户可能只授权其中一个,导致功能异常。建议在UI层面明确提示用户需要同时授权。
3.5 实战案例:多版本兼容的蓝牙扫描功能
下面通过一个完整的案例,展示如何在实际项目中处理蓝牙权限适配问题。
3.5.1 项目背景与约束
某物联网应用需要实现BLE设备扫描功能,目标设备为Android 8.0(API 26)至Android 14(API 34)的机型。应用需要扫描并连接血压计设备,同时作为外围设备广播数据。项目约束:
- targetSdkVersion = 34(强制要求适配最新权限模型)
- minSdkVersion = 26(覆盖绝大多数存量设备)
- 应用不依赖位置信息,纯BLE通信
3.5.2 初始现象与目标
开发初期,测试人员在Android 13设备上发现:点击“扫描”按钮后,应用直接崩溃,Logcat显示SecurityException: Need BLUETOOTH_SCAN permission。而在Android 10设备上,扫描功能正常但无法获取结果。
3.5.3 分析判断过程
通过日志和代码审查,定位到三个核心问题:
- Manifest声明缺失:未添加BLUETOOTH_SCAN和BLUETOOTH_CONNECT权限。
- 运行时请求逻辑错误:代码中仅请求了定位权限,未根据API级别区分权限集合。
- 扫描回调未处理权限拒绝:当用户拒绝权限时,扫描静默失败,无任何提示。
3.5.4 实际操作步骤
步骤1:修正Manifest声明
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
使用maxSdkVersion="30"限制定位权限仅在API 30及以下生效,避免Android 12+设备弹出不必要的定位请求。
步骤2:重构权限请求逻辑
private void ensurePermissions() {
if (BluetoothPermissionManager.hasPermissions(this)) {
startScan();
} else {
requestPermissions(BluetoothPermissionManager.getRequiredPermissions(), REQUEST_CODE);
}
}
步骤3:增强用户引导
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if (requestCode == REQUEST_CODE) {
if (BluetoothPermissionManager.hasPermissions(this)) {
startScan();
} else {
new AlertDialog.Builder(this)
.setTitle("需要蓝牙权限")
.setMessage("扫描蓝牙设备需要蓝牙权限,请在设置中开启")
.setPositiveButton("去设置", (d, w) -> openAppSettings())
.setNegativeButton("取消", null)
.show();
}
}
}
3.5.5 结果对比与复盘
| 测试设备 | 适配前 | 适配后 |
|---|---|---|
| Android 10(API 29) | 扫描无结果(定位权限未请求) | 正常扫描并连接 |
| Android 13(API 33) | 崩溃(SecurityException) | 正常扫描,无定位权限请求 |
| Android 14(API 34) | 崩溃 | 正常扫描,权限流程清晰 |
复盘经验:蓝牙权限适配的核心在于“版本感知”和“用户引导”。版本感知要求开发者明确每个API级别的权限差异,用户引导则要求应用在权限被拒时提供清晰的解决方案。此外,建议在CI流程中加入多版本真机测试,避免权限问题在发布后才暴露。
3.6 常见误区与总结复盘
在蓝牙权限适配过程中,开发者常陷入以下误区:
- 误区一:只声明新权限不声明旧权限。如果targetSdkVersion ≥ 31,但minSdkVersion较低,旧设备可能无法识别新权限,导致安装失败。正确做法是同时声明新旧权限。
- 误区二:忽略neverForLocation标志。不添加此标志,Android 12+设备会强制要求定位权限,增加用户授权负担。
- 误区三:在Fragment中直接调用requestPermissions。Fragment应使用
requestPermissions()方法,但回调需在Fragment中处理,避免与Activity回调混淆。 - 误区四:认为权限授权后永久有效。用户可以在系统设置中随时撤销权限,应用必须在onResume()中重新检查。
- 误区五:忽略Android 13+的广告权限。如果应用需要作为外围设备广播,必须单独请求BLUETOOTH_ADVERTISE权限,且该权限与扫描权限相互独立。
总结:Android蓝牙权限适配是一个系统性工程,涉及Manifest静态声明、运行时动态请求、多版本差异化处理三个层面。开发者应建立“版本感知”的编程思维,通过封装工具类统一管理权限逻辑,并在真机上进行多版本验证。记住一个核心原则:权限请求不是一次性操作,而是持续性的状态管理。只有将权限处理融入应用的生命周期管理,才能构建出稳定、合规的蓝牙应用。