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 Play2022年之后新应用和更新应用必须要求targetSdkVersion不低于31你根本没有“不升级”的选择。国内应用市场也在逐步跟进所以这条路实际是堵死的。如果targetSdkVersion是31或更高只声明老权限是不够的。你需要同时声明新的权限并且如果老权限没有对应的新权限系统会直接忽略它。反过来如果只声明新权限但没动态申请运行时会直接抛SecurityException。我把这套映射关系整理成了表方便对照使用场景Android 11及以前Android 12及以后发起扫描BLUETOOTH_ADMINBLUETOOTH_SCAN危险权限建立连接BLUETOOTHBLUETOOTH_CONNECT危险权限对外广播BLE外设BLUETOOTH_ADMINBLUETOOTH_ADVERTISE危险权限获取蓝牙开关状态BLUETOOTHBLUETOOTH_CONNECT打开/关闭蓝牙BLUETOOTH_ADMINBLUETOOTH_CONNECT1.3 权限组“附近设备”的引入与系统弹窗行为三个新权限被收进nearby_devices权限组之后系统弹窗也随之变化。以前用户看到的权限弹窗是“允许应用访问位置信息”现在安卓12上你会看到一个标题为“允许XXX在此设备附近吗”的弹窗里面列出了“允许所有时间”“仅在使用该应用时允许”“不允许”三个选项。实测下来大多数用户看到这个弹窗第一反应是“这个应用为什么要访问附近设备”如果你的App没有在申请前做好解释拒绝率会非常高。这跟你以前申请定位权限看到的弹窗完全是两回事。定位权限描述的是“位置信息”用户还能理解附近的设备对普通用户来说很模糊很多人会直接拒绝。所以做Android12蓝牙功能除了技术实现产品交互上也得多花功夫后面我会专门讲申请时机和文案设计。2. 清单配置与权限申请设计2.1 AndroidManifest.xml里的完整配置细节先看最基础的清单配置。我直接给出一份适合大多数场景的完整配置注意里面的注释每一项都有讲究manifest xmlns:androidhttp://schemas.android.com/apk/res/android !-- Android 12API 31新增的蓝牙权限 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / !-- 旧版本兼容权限Android 11及以下需要 -- uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / !-- 定位权限经典蓝牙/BLE扫描可能仍然需要 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / uses-permission android:nameandroid.permission.ACCESS_COARSE_LOCATION / /manifest这里有两个细节需要特别说明。第一个是android:usesPermissionFlagsneverForLocation。这个属性是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 不同机型/系统版本的表现差异关于定位权限和蓝牙权限的兼容性我在真机上实测过几款主流机型简单说下现象小米MIUIAndroid12及以上即便有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(): ArrayString { 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蓝牙权限这个改动把项目里的权限管理一起梳理干净后续维护能省不少事。
