Android12刚普及那会儿,我接手的几个蓝牙项目几乎同时出问题。最典型的一个是:targetSdkVersion一升到31,原本跑得好好的BLE扫描直接静默失败,Logcat里连个像样的报错都没有,客户端反馈“扫描不到设备”,实际上权限模型整个换了一套。如果你是从Android 10/11时代过来的开发者,第一次接触Android12的蓝牙权限改动,大概率的反应是“清单里权限明明加了,为什么还是不行”。
这篇文章就专门聊Android12的蓝牙权限,把新权限模型、系统弹窗逻辑、定位权限的历史纠葛、代码层面的申请流程,以及我实际踩过的坑一次性讲清楚。无论你是做智能硬件、可穿戴设备、蓝牙网关,还是App里只需要扫一个蓝牙设备做配对,看完都能少走弯路。
1. Android12蓝牙权限模型到底改了啥
1.1 从“一刀切”到“最小化”的权限重构
在Android 11及更低版本上,蓝牙权限非常简单:普通App只在清单里声明BLUETOOTH和BLUETOOTH_ADMIN两个权限,前者用来发起蓝牙操作,后者用来执行扫描、配对等管理操作。只要你声明了,系统不会在安装时弹权限确认,运行时也不会向用户索取,基本是“说了就能用”的状态。
但代价是隐私控制几乎为零。任何App只要声明了BLUETOOTH_ADMIN,就可以在后台扫描周围的BLE设备,拿到设备名称、MAC地址、信号强度等信息。这些数据放到今天的隐私审查标准下,是非常敏感的,尤其涉及到用户位置推断——蓝牙信号扫描的三角定位可以反推用户位置,这比GPS定位还更难被用户感知。
所以Android12做了一个比较彻底的权限拆分:不再有“蓝牙管理”这种大而全的权限,而是拆成三个细粒度权限,分别对应扫描、广播、连接三种最典型的蓝牙使用方式:
BLUETOOTH_SCAN:扫描周围的蓝牙设备。BLUETOOTH_ADVERTISE:让当前设备对外广播,比如作为BLE外设被别的设备发现。BLUETOOTH_CONNECT:与已扫描到的设备进行连接通信。
这三个权限都属于“附近设备”(Nearby devices)权限组,并且全部是危险权限,必须运行时动态申请。也就是说,就算清单里写满了,用户不点允许,你一样什么都干不了。
1.2 新旧权限映射关系与targetSdkVersion的开关效应
很多文章直接列权限对照表,但没人讲清楚一个关键的隐藏逻辑:Android12行为是否生效,取决于你的targetSdkVersion。如果你的App把targetSdkVersion升到31或以上,系统会强制走新权限模型;如果targetSdkVersion仍然低于31,那么Android12及以上设备会默认帮你做“权限兼容转换”,也就是老权限自动映射到新权限上。
举个例子:一个targetSdkVersion 30的App跑在Android12手机上,清单里只声明了BLUETOOTH和BLUETOOTH_ADMIN,系统会隐式赋予它扫描和连接的权限,不需要动态申请。听起来是不是很爽?但这里有个巨大的坑:你要上架Google Play,2022年之后新应用和更新应用必须要求targetSdkVersion不低于31,你根本没有“不升级”的选择。国内应用市场也在逐步跟进,所以这条路实际是堵死的。
如果targetSdkVersion是31或更高,只声明老权限是不够的。你需要同时声明新的权限,并且如果老权限没有对应的新权限,系统会直接忽略它。反过来,如果只声明新权限但没动态申请,运行时会直接抛SecurityException。
我把这套映射关系整理成了表,方便对照:
| 使用场景 | Android 11及以前 | Android 12及以后 |
|---|---|---|
| 发起扫描 | BLUETOOTH_ADMIN | BLUETOOTH_SCAN(危险权限) |
| 建立连接 | BLUETOOTH | BLUETOOTH_CONNECT(危险权限) |
| 对外广播(BLE外设) | BLUETOOTH_ADMIN | BLUETOOTH_ADVERTISE(危险权限) |
| 获取蓝牙开关状态 | BLUETOOTH | BLUETOOTH_CONNECT |
| 打开/关闭蓝牙 | BLUETOOTH_ADMIN | BLUETOOTH_CONNECT |
1.3 权限组“附近设备”的引入与系统弹窗行为
三个新权限被收进nearby_devices权限组之后,系统弹窗也随之变化。以前用户看到的权限弹窗是“允许应用访问位置信息”,现在安卓12上你会看到一个标题为“允许XXX在此设备附近吗?”的弹窗,里面列出了“允许所有时间”“仅在使用该应用时允许”“不允许”三个选项。实测下来,大多数用户看到这个弹窗第一反应是“这个应用为什么要访问附近设备”,如果你的App没有在申请前做好解释,拒绝率会非常高。
这跟你以前申请定位权限看到的弹窗完全是两回事。定位权限描述的是“位置信息”,用户还能理解;附近的设备对普通用户来说很模糊,很多人会直接拒绝。所以做Android12蓝牙功能,除了技术实现,产品交互上也得多花功夫,后面我会专门讲申请时机和文案设计。
2. 清单配置与权限申请设计
2.1 AndroidManifest.xml里的完整配置细节
先看最基础的清单配置。我直接给出一份适合大多数场景的完整配置,注意里面的注释,每一项都有讲究:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <!-- Android 12(API 31)新增的蓝牙权限 --> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- 旧版本兼容权限,Android 11及以下需要 --> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <!-- 定位权限:经典蓝牙/BLE扫描可能仍然需要 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> </manifest>这里有两个细节需要特别说明。
第一个是android:usesPermissionFlags="neverForLocation"。这个属性是Android12新增的,作用是告诉系统“我扫描蓝牙不是为了推断用户位置”。加上这个标记后,系统可以不用附带定位权限就允许你扫描,但代价是你拿不到MAC地址(系统会给你返回一个随机化的地址),也无法推断位置。如果你的应用确实不需要定位,建议加上,这样用户不需要为蓝牙扫描额外授予定位权限,隐私体验好很多。但绝大多数业务场景里,扫描到的设备信息会用来做范围判断或者室内定位,那就不能加这个属性,否则功能会出问题。
第二个是定位权限没有加maxSdkVersion限制。Android 6.0到Android 11期间,BLE扫描必须要定位权限,但在Android12及以上,如果你声明了BLUETOOTH_SCAN并且没有neverForLocation标记,系统会认为你已经通过附近设备权限完成了授权,不再强依赖定位权限。所以定位权限可以保留给低版本设备,或者在Android12以上按需申请。
2.2 什么时候该申请哪几个权限:场景拆分
权限申请不是三个权限一把抓全申请就完事,而是要看你到底做什么。我按场景拆开讲:
场景一:只做BLE中心设备,扫描并连接外围设备
这是最常见的形态,比如用手机去连一个蓝牙温湿度计、心率带、智能锁。你需要的权限是BLUETOOTH_SCAN和BLUETOOTH_CONNECT。如果你要扫描到的设备能正常工作,在Android 11及以下还需要定位权限;在Android12及以上,如果不加neverForLocation,定位权限可以帮你在部分机型上提高扫描成功率,但逻辑上不是必须。
场景二:只做BLE外设,对外广播
比如你的App把手机模拟成一个蓝牙键盘或者运动传感器。这种情况下你只需要BLUETOOTH_ADVERTISE,不需要扫描权限。注意,外设广播在某些场景下也涉及设备被发现,连接时由中心设备发起连接,你的App作为外围设备接受连接,需要处理BLUETOOTH_CONNECT的授权。
场景三:两者都做
同时要扫描、广播、连接。那就老老实实申请三个权限,这也是最常见的“万能型”申请方案。
2.3 申请权限的时机与交互设计建议
Android12的附近设备权限弹窗是比较劝退的。用户看到“允许XXX在此设备附近吗”第一反应是抗拒。所以申请权限的时机非常重要,我强烈建议遵循这几个原则:
- 不要在App启动时立刻弹权限。除非你的应用打开第一屏就是蓝牙功能。先让用户进入主界面,理解App是干什么的,再因为某个具体操作触发权限弹窗,接受率高很多。
- 申请之前先展示自定义说明页或BottomSheet,用一两句话说明“蓝牙权限用于连接附近设备并传输数据”,不要让用户凭空猜。
- 把扫描、连接、广播三个权限分开申请,而不是一次弹三连。一次弹多个权限,Android12会合并展示,用户会感到被打扰,拒绝率更高。
- 如果用户拒绝了权限,不要反复弹系统弹窗,而是引导用户到设置页手动开启。Android的权限弹窗如果连续拒绝两次,系统会短暂屏蔽,甚至不再弹出,这时候强制弹窗只会让用户更反感。
3. 绕不开的定位权限:新旧版本的纠葛
3.1 为什么蓝牙扫描还会牵扯到定位权限
这是开发者最容易理解偏差的地方。很多人问:我已经申请了BLUETOOTH_SCAN,为什么还要处理定位权限?答案要看Android版本。
Android 6.0引入了动态定位权限之后,Google把BLE扫描定性为“可能暴露用户位置”的行为,因为通过扫描到的Wi-Fi、蓝牙信标可以反推设备位置。所以在Android 6.0到Android 11这段时间,做BLE扫描必须在有定位权限的前提下才能正常发现设备。这是系统层面的硬性约束,不是App层能绕过的。
到了Android12,有了BLUETOOTH_SCAN权限,情况有所变化。系统允许你申请“附近设备”权限来替代定位权限,但前提是你在清单里加上了neverForLocation标记,向系统承诺不使用蓝牙推断位置。如果你声明了定位权限,或者没有加这个标记,系统会继续要求定位权限参与扫描流程。
我在实际项目里遇到过一种让人抓狂的现象:部分国产ROM(尤其是Android12定制版)即使在清单里加了neverForLocation,只要你没有给定位权限,扫描回调还是返回空列表。这说明厂商对Android12权限策略做了自己的解释和强化。所以为了稳妥,在Android12及以上我依然会保留定位权限的申请流程,只是申请优先级低于蓝牙权限。
3.2 Android12下定位权限与附近设备权限的配合
在实际申请顺序上,我建议分两步走。第一步申请定位权限(针对Android 11及以下),第二步申请蓝牙附近设备权限(针对Android12及以上)。这个顺序有讲究,因为Android11及以下根本没有附近设备权限,蓝牙扫描必须依赖定位权限;而Android12以上虽然不一定非要定位权限,但先申请定位权限兜底,可以避免厂商ROM的兼容性问题。
代码里的实现逻辑可以这样概括:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12及以上:申请附近设备权限(+ 定位权限作为兼容兜底) requestPermissions(arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.ACCESS_FINE_LOCATION ), REQUEST_CODE) } else { // Android 11及以下:只需要定位权限 requestPermissions(arrayOf( Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ), REQUEST_CODE) }这里第4个定位权限在Android12上会同时弹两个系统权限弹窗:一个是定位,一个是附近设备。实测下来用户体验有点割裂,但逻辑上不会冲突。如果你不想在Android12以上弹定位权限,也可以不申请,但要做好国产ROM扫描不到设备时给用户提示的准备。
3.3 不同机型/系统版本的表现差异
关于定位权限和蓝牙权限的兼容性,我在真机上实测过几款主流机型,简单说下现象:
- 小米MIUI:Android12及以上,即便有
BLUETOOTH_SCAN,扫描BLE设备时如果没开定位权限,列表大概率是空的。MIUI对定位权限和“附近设备”权限做了强绑定,建议在小米设备上两个权限都申请。 - 华为HarmonyOS/EMUI:新权限模型执行较规范,
BLUETOOTH_SCAN基本能替代定位权限,但扫描结果里部分设备信息不完整,需要根据业务补充定位权限。 - 三星One UI:整体跟原生Android12行为最接近,
neverForLocation标记有效,申请蓝牙权限后不申请定位也能扫描。
这些差异没法通过一套代码完全抹平,所以我在项目里通常用“定位权限 + 蓝牙权限双重申请”的策略,同时把扫描失败的原因细分提示给用户,避免用户一脸懵。
4. 核心代码实操:用Activity Result API优雅搞定权限申请
4.1 权限常量与工具类封装
Android 12的权限常量在Manifest.permission里可以直接引用,不需要硬编码字符串。不过为了代码整洁,我习惯用一个工具类统一管理蓝牙权限相关的请求逻辑。这个类需要处理三件事:检查权限、请求权限、处理回调。
object BluetoothPermissionHelper { 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.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) } } fun hasRequiredPermissions(context: Context): Boolean { return getRequiredPermissions().all { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } } }这段逻辑处理了不同版本下的权限差异。注意Android 11及以下仍然需要定位权限,这在工具类的getRequiredPermissions()里已经体现出来了。
4.2 动态申请权限的完整流程(Kotlin示例)
使用官方推荐的Activity Result API来申请权限,而不是在onRequestPermissionsResult里写一堆回调逻辑。Activity Result API在AndroidX中已经封装得很完善,代码可读性也更好。
class MainActivity : AppCompatActivity() { private val permissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> val allGranted = permissions.values.all { it } val scanGranted = permissions[Manifest.permission.BLUETOOTH_SCAN] ?: false val connectGranted = permissions[Manifest.permission.BLUETOOTH_CONNECT] ?: false if (allGranted) { startBluetoothWork() } else if (scanGranted && connectGranted) { // 核心权限已授予,但是定位权限(或其他自定义权限)被拒绝 startBluetoothWorkWithWarn() } else { showPermissionDeniedDialog() } } private fun checkAndRequestBluetoothPermission() { if (BluetoothPermissionHelper.hasRequiredPermissions(this)) { startBluetoothWork() } else { permissionLauncher.launch(BluetoothPermissionHelper.getRequiredPermissions()) } } private fun startBluetoothWork() { // 在这里执行你的蓝牙扫描/广播/连接逻辑 } }这里有一种可能被忽略的情况:allGranted为false,但BLUETOOTH_SCAN和BLUETOOTH_CONNECT都通过了,只是定位权限没通过。对于Android12及以上设备,如果只做蓝牙功能,这个状态完全可以继续。所以我单独做了分支处理,让核心功能不要因为非核心权限被卡死。
4.3 检测蓝牙状态并引导用户打开蓝牙
权限申请通过后,还有一个很容易遗漏的点:蓝牙模块本身的开关状态。很多新手把所有问题都归结到权限上,结果权限全部通过了,扫描还是无结果,最后发现蓝牙根本没打开。
判断蓝牙是否支持、是否开启的代码建议放在权限申请之后执行:
private fun checkBluetoothAdapter() { val bluetoothManager = getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager val adapter = bluetoothManager.adapter if (adapter == null) { Toast.makeText(this, "当前设备不支持蓝牙", Toast.LENGTH_SHORT).show() return } if (!adapter.isEnabled) { // 方式一:跳转系统蓝牙设置页 startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS)) // 方式二:使用BluetoothAdapter.ACTION_REQUEST_ENABLE,会弹系统授权框 // startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), REQUEST_ENABLE_BT) } }两种打开蓝牙的方式区别在于:跳转设置页用户自己手动打开,成功率更高但步骤多;使用系统授权框(ACTION_REQUEST_ENABLE)会直接在App内弹窗询问用户是否允许打开蓝牙,但是如果用户之前选择过拒绝,会直接静默失败,没有任何提示。按我的经验,如果要做上线的产品,建议用跳转设置页的方式,至少可控性更强。
4.4 兼容Android 11及更低版本的写法
如果你的App还要兼容Android 11及以下设备,核心的处理策略就是上文提到的“版本判断”。需要注意,在Android 11及以下设备上,Manifest.permission.BLUETOOTH_SCAN这个常量在编译时存在,但在运行时不生效,系统不认这个权限,所以你不能把它混在申请列表里。判空、分类处理是必须的。
另一点需要注意的是,Android 11及以下申请定位权限时,ACCESS_BACKGROUND_LOCATION(后台定位权限)不要轻易去申请,这个权限属于特殊权限,审核非常严格,而且弹窗门槛高。普通使用场景下一个ACCESS_FINE_LOCATION就够了。
我这里再给一份兼容模式下的核心代码片段,本质上就是根据SDK版本分发不同的权限请求:
val permissions = 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.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) } permissionLauncher.launch(permissions)5. 常见问题排查与避坑实录
5.1 权限弹窗不出现
开发阶段最常遇到的怪事是:明明调用了requestPermissions,但是弹窗死活不出现。排查步骤按照下面的顺序走:
第一步,检查targetSdkVersion是否真的升到了31。很多项目在build.gradle里改了targetSdk,但构建缓存没刷新,实际跑的还是旧配置,可以在运行时打日志确认。
第二步,检查清单是否真的写了新权限,注意uses-permission标签的名字大小写,Java/Kotlin里的常量写错编译不会报错,但运行时权限就是空列表。
第三步,检查是否在Fragment里请求权限但是宿主Activity没有正确配置registerForActivityResult。Fragment和Activity的launcher生命周期不一样,稍不注意就会导致launcher没注册成功,回调永不触发。
第四步,检查有没有在权限弹窗被拒绝之后短时间频繁请求,系统会限流,表现为弹窗不弹出直接走拒绝回调。
5.2 只申请了“附近设备”权限但扫描还是失败
这个问题在高版本Android上比较少见,在国产ROM上很典型。表现是权限全绿、蓝牙已开启,但扫描回调只有空列表或零星几个设备。
优先怀疑定位权限缺失。Android12的BLUETOOTH_SCAN理论上不依赖定位,但很多手机厂商的系统扫描服务仍然会把定位开关作为扫描的隐性前置条件。我建议在Android12及以上也一并申请定位权限,并且检查系统定位开关是否打开。注意这次检查的不是App的定位权限,而是系统级的GPS开关,这个开关在部分手机上对蓝牙扫描结果有直接影响。
另外还要检查neverForLocation标记。如果你的清单里加了neverForLocation,系统会隐藏部分敏感信息,部分厂商ROM干脆把扫描回调清空了。业务不需要的话,优先去掉这个标记。
5.3 用户选择“仅这一次”后,下次启动怎么处理
Android12的附近设备权限弹窗里有一个“仅在使用该应用时允许”选项,对应的是ACTION_ONE_TIME授权模式。用户选了这个,App在前台时权限有效,退到后台或下次启动时权限会被自动收回。
这个设计经常让开发者在测试时被坑:昨天还能扫描,今天冷启动打开App后扫描失败。排查时先看权限状态,发现“附近设备”权限已经变成拒绝状态了。处理方式是在App初始化时统一走一次权限检查,如果状态不满足,直接重新申请,不要依赖上次的授权结果。
5.4 targetSdkVersion相关的坑
我见过一个很尴尬的案例:项目targetSdkVersion升到31,但是代码里还在用BluetoothAdapter.getDefaultAdapter()获取适配器。在Android12上这样做不会崩溃,但如果同时没有BLUETOOTH_CONNECT权限,getDefaultAdapter()会直接抛SecurityException。因为Android12对蓝牙适配器的访问也收窄了,任何访问蓝牙功能的行为都需要有对应权限兜底。
所以代码里所有涉及到蓝牙适配器的地方,都必须先确保权限已授予,否则要捕获异常并给出友好提示。另外建议把BluetoothAdapter.getDefaultAdapter()迁移到通过BluetoothManager.getAdapter()获取,后者在Android12上更符合新架构。
5.5 厂商ROM差异与Logcat定位技巧
排查蓝牙权限问题,Logcat里是有线索的,很多人不会看。常见的几种错误日志和对应原因:
| 日志关键字 | 含义 | 定位方向 |
|---|---|---|
| SecurityException: Need BLUETOOTH_CONNECT permission | 缺少连接权限 | 检查BLUETOOTH_CONNECT动态授权状态 |
| Need BLUETOOTH_SCAN permission | 缺少扫描权限 | 检查BLUETOOTH_SCAN是否被授予 |
| Could not find Bluetooth adapter | 设备无蓝牙或权限导致适配器获取失败 | 检查蓝牙开关和适配器获取方式 |
| Scan failed with error code 2 | 蓝牙扫描内部错误/已关闭 | 确认蓝牙开关是否打开,尝试重启蓝牙 |
在真机调试时,最好把adb shell dumpsys package 包名输出的权限状态和Logcat对照看,能快速判断是系统层没放行还是代码层没申请。
6. 我从Android12蓝牙权限这几个坑里得出的实操建议
如果让我给正在做Android12蓝牙适配的人一个最直接的忠告:不要一次性把权限申请做完就以为万事大吉,一定要在真机上用不同厂商的ROM都跑一遍。权限模型本身不复杂,复杂的是各种定制系统对权限策略的再加工。你永远猜不到哪一步会卡住,所以代码里要尽量多埋点,把权限状态、蓝牙开关、扫描结果全打日志,出问题能直接定位到环节。
权限申请顺序上也多说一句,我最终稳定下来的方案是:先确认蓝牙可用性,再申请权限,最后才打开蓝牙。如果顺序反了,用户可能先开了蓝牙但权限弹窗被拒,蓝牙开着也没法用。先申请权限再打开蓝牙,至少能保证“能扫”和“能连”同时满足。
最后给一个容易被忽视的小技巧:如果App同时要兼容Android 12以下的旧设备,建议不要让目标SDK版本低于30太久。目前各应用市场对新版本的要求越来越紧,旧targetSdk会导致新系统上权限行为不一致,调试成本比升级版本本身还要高。趁着Android12蓝牙权限这个改动,把项目里的权限管理一起梳理干净,后续维护能省不少事。