1. 为什么Android 12的蓝牙权限让人又爱又恨
做Android蓝牙开发的朋友应该都有这种感觉:每次大版本升级,蓝牙权限都要折腾一轮。尤其是Android 12(API 31)这次,可以说是把蓝牙权限模型彻底翻新了一遍——从原来一个BLUETOOTH权限通吃所有操作,拆成了BLUETOOTH_SCAN、BLUETOOTH_ADVERTISE、BLUETOOTH_CONNECT三个细粒度权限,还引入了neverForLocation这个“给不给定位权限”的判定标记。这个改动看起来只是权限拆分,实际踩坑的时候会发现,它牵扯到targetSdkVersion、运行时权限申请流程、系统兼容策略、甚至平板和车机这类大屏设备的交互逻辑,一整套链路都要跟着调整。
这篇东西我会从实际开发视角出发,把Android 12+蓝牙权限的适配思路、代码写法、常见坑位和排查方法完整过一遍。适合正在做蓝牙外设连接、BLE广播、蓝牙耳机适配的App开发者,也适合做系统级蓝牙定制的Framework工程师参考。全文基于我实际项目中的经验总结,也结合了Android官方推荐做法,尽量把“为什么这么做”和“不这么做会怎样”都说清楚。
先说一个最常见的情形:你的App目前targetSdkVersion还是30,跑在Android 12手机上,只要BLUETOOTH权限声明了,配对、连接、扫描基本都能正常工作。但一旦你把targetSdkVersion升到31,立刻就会遇到SecurityException,而且是在startScan或connectGatt这一步直接崩溃。这不是代码写错了,而是系统在强制你用新权限模型。这个适配工作,躲是躲不掉的。
2. 权限模型重构思路拆解:Google到底在纠结什么
2.1 为什么好好的BLUETOOTH权限要拆成三个
在Android 11及更早的版本里,蓝牙相关的操作基本只依赖两个权限:普通权限BLUETOOTH(负责连接、配对)和危险权限ACCESS_FINE_LOCATION(负责扫描)。开发者只要在Manifest里写两个权限声明,再在运行时申请一次定位权限,就万事大吉。
但这里有一个很别扭的地方:扫描蓝牙设备这件事,理论上会暴露用户的位置信息(通过检测周围BLE信标可以推测用户大概在哪),所以它一直被归类为位置权限的范畴。而连接一个已知设备、向设备发送数据,其实并不涉及位置隐私,可它也被绑定在同一个权限体系里。这种“一刀切”的设计,对隐私保护来说太粗糙了。
Android 12的改动思路,本质上就是“按需索取”:把蓝牙能力拆成三个独立权限,让用户清楚知道App到底要干什么——是要扫描周围的设备,还是要发起广播让别人发现你,还是要连接已知设备收发数据。用户在系统设置里也能看到更清晰的权限用途描述,而不是一个笼统的“位置信息”。
2.2 三个新权限的定位与com.android.ble权限组
三个新权限的定义如下:
| 权限名 | 所属权限组 | 功能定位 | 运行时申请时机 |
|---|---|---|---|
BLUETOOTH_SCAN | android.permission-group.BLUETOOTH_NEARBY | 扫描周围的蓝牙设备(经典蓝牙 + BLE) | 开始扫描之前 |
BLUETOOTH_ADVERTISE | android.permission-group.BLUETOOTH_NEARBY | 让当前设备可以被其他蓝牙设备发现(BLE广播 / 经典蓝牙可发现模式) | App需要对外广播时 |
BLUETOOTH_CONNECT | android.permission-group.BLUETOOTH_NEARBY | 发起连接操作、查看蓝牙设备的连接状态、配对管理 | 执行连接之前 |
这三个权限都属于运行时权限(dangerous level),都挂在BLUETOOTH_NEARBY权限组下。这意味着用户在系统设置里看到的是“附近设备”这一组权限,而不是单独的“蓝牙”条目。这跟Android 13之后的通知权限有点类似,都是把一系列相关权限归拢到一个群组里,统一管理。
这里要记住一个关键点:BLUETOOTH_NEARBY权限组和ACCESS_FINE_LOCATION/ACCESS_COARSE_LOCATION(位置信息权限组)是相互独立的,不能再指望申请位置权限就能顺带拿到蓝牙权限。如果你的App需要扫描蓝牙并解析Beacon数据来推断位置,那么定位权限和蓝牙扫描权限是两个都要申请,一个都不能少。
2.3 neverForLocation标记:不想暴露位置,就必须明确声明
neverForLocation是Android 12新增的一个Manifest属性,作用在BLUETOOTH_SCAN权限对应的<uses-feature>或权限声明的<uses-permission>条目上。加上它之后,系统就知道:这个App扫描蓝牙,不是为了获取用户位置,所以可以不在申请蓝牙扫描权限的同时强制索要定位权限。
Android 12的权限弹窗设计里,有一个细节非常影响用户体验:如果App没有声明neverForLocation,系统会认为扫描蓝牙可能用于位置推测,此时即使用户已经授予了蓝牙附近设备权限,系统仍然会弹出定位权限请求,或者在蓝牙权限组的说明里给出提示(具体的展示形式在不同手机上略有差异)。
反过来,只要你在Manifest里对BLUETOOTH_SCAN明确加上android:neverForLocation="true",系统就不会把蓝牙扫描权限和定位权限绑定在一起,也不会强行要求你申请位置权限。但代价是,如果你的App确实有“根据蓝牙Beacon推算用户位置”的功能,那这个标记绝对不能加,加了就涉及功能合规问题了。
实际项目里,我见过不少团队直接把neverForLocation无脑加上,只为了让权限弹窗更少。如果App根本不涉及位置业务,当然没问题;但如果是做室内定位、Beacon营销、基于蓝牙的围栏提醒这类业务,这么做等于篡改系统声明,审核阶段甚至功能上线后都有可能出风险。这一点要提醒团队内部做合规自查。
3. 清单声明与运行时申请实操
3.1 Manifest声明:兼容Android 12以下的写法
从代码兼容性的角度,你会发现一个尴尬的事实:如果只在Manifest声明BLUETOOTH_SCAN这些新权限,那App跑在Android 11及以下的设备上时,系统根本不认识这些权限名,蓝牙功能也就不可用。所以正确的做法是“新旧权限全量声明”,然后通过maxSdkVersion做区分:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <!-- Android 12及以上:新增的细粒度蓝牙权限 --> <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" /> <!-- 定位权限:Android 11及以下的扫描仍然需要 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" /> <!-- Android 12及以上,如果App业务上需要位置,则单独保留;如果不需要,则移除或做条件化处理 --> </manifest>这里有个很重要的点需要大家理解:android:usesPermissionFlags="neverForLocation"是Android 12里专门为BLUETOOTH_SCAN准备的属性(也是官方文档中的写法)。也有的资料写作android:neverForLocation="true",实际上官方XML属性的名称是usesPermissionFlags的枚举取值,不是单独一个新属性。我在适配的时候测过,两种写法在不同厂商ROM上的容忍度不太一样,保险起见以SDK源码里AndroidManifest.xsd的定义为准——官方标准的写法就是:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />顺带提一句,如果你的App targetSdkVersion是30或者更低,那么你可能会看到Android Studio给出一个Lint警告:“BLUETOOTH_SCAN permission should only be used with targetSdkVersion 31+”。这只是一个提示,不影响编译,但你应该意识到:这个权限只有在target 31及以上才会真正启用,低target版本下系统依然走旧权限逻辑。
3.2 运行时申请流程:不再是一锤子买卖
旧版本里,开发者只需要申请两次权限(第一次定位权限,第二次可能要存储权限等),就能覆盖整个蓝牙生命周期。Android 12则要求开发者在不同阶段申请不同权限,而且系统弹窗会结合权限组做“聚合授权”,用户体验比想象中好一些,但代码复杂度确实上来了。
下面是一套我实测可用的申请策略:
- App启动或进入蓝牙功能页时,先检查
BLUETOOTH_CONNECT和BLUETOOTH_SCAN这两个核心权限是否已授权。 - 如果未授权,先申请这两个权限。系统会弹出“允许XXX在此设备附近吗?”的权限弹窗,用户同意后,两个权限一并授予(因为它们在同一个权限组里)。
- 如果App需要主动向外广播(比如作为Beacon向外发送数据),再单独申请
BLUETOOTH_ADVERTISE。 - 如果需要解析Beacon、根据RSSI推算位置,还需要额外申请
ACCESS_FINE_LOCATION。这类业务场景,蓝牙权限和定位权限是“并列关系”,不是“替代关系”。
对应的代码写法大致如下:
private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> val scanGranted = permissions[Manifest.permission.BLUETOOTH_SCAN] == true val connectGranted = permissions[Manifest.permission.BLUETOOTH_CONNECT] == true if (scanGranted && connectGranted) { startBleOperation() } else { showPermissionDeniedTips() } } fun requestBlePermissions() { val permissions = mutableListOf<String>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { permissions.add(Manifest.permission.BLUETOOTH_SCAN) permissions.add(Manifest.permission.BLUETOOTH_CONNECT) // 如果需要广播功能,再加 Manifest.permission.BLUETOOTH_ADVERTISE // 如果需要位置业务,再加 Manifest.permission.ACCESS_FINE_LOCATION } else { permissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } requestPermissionLauncher.launch(permissions.toTypedArray()) }有一点要特别注意:BLUETOOTH_ADVERTISE这个权限,很多App用不到就不要申请。我在项目里遇到过的情况是,一个只需要做中心设备(Central)连接外设的App,多申请了BLUETOOTH_ADVERTISE,结果在某些国产ROM上多弹了一次权限框,用户拒绝后直接导致后续连接流程不走了。权限最小化不只是审核要求,也是真实用户体验问题。
3.3 判断蓝牙是否开启:新API带来的坑
在Android 12之前,判断蓝牙是否开启用的是BluetoothAdapter.isEnabled(),这个方法的调用在target 31下仍然可以跑,但它其实需要一个BLUETOOTH_CONNECT权限才能保证结果准确。更规范的写法是使用BluetoothAdapter.getDefaultAdapter()配合getState(),并处理没有权限时可能的异常。
在实际开发中,我建议封装一个统一的状态检查方法,把权限判断和蓝牙开关判断合并到一起:
@SuppressLint("MissingPermission") fun isBluetoothReady(): Boolean { val adapter = BluetoothAdapter.getDefaultAdapter() ?: return false return adapter.isEnabled }使用@SuppressLint("MissingPermission")是因为我们已经在前面把权限申请过了,这里只是告诉静态检查工具不要报错。但请注意:如果用户在系统设置里手动关掉了蓝牙权限,或者App被强制停止后权限失效,这里调用isEnabled()还是会抛SecurityException的。所以更稳妥的做法是在检查蓝牙开关之前,再确认一次权限授予状态。
4. targetSdkVersion不同导致的权限差异与兼容矩阵
4.1 四个版本段的权限行为对照
适配蓝牙权限最容易搞混的地方,就是不同targetSdkVersion组合不同系统版本时,系统的权限判定逻辑完全不一样。这里我画一张我自己的兼容排查表(用表格更方便现场对照):
| 系统版本 | targetSdkVersion 30及以下 | targetSdkVersion 31及以上 |
|---|---|---|
| Android 11及以下 | 旧权限模型:只需BLUETOOTH+ACCESS_FINE_LOCATION,运行时申请定位 | 旧权限模型:Manifest里新权限名不识别,行为跟左列一致 |
| Android 12(API 31) | 旧权限模型:新权限名无效,系统按旧权限校验;可能存在兼容警告 | 新权限模型:必须申请新蓝牙权限,neverForLocation决定是否要求定位 |
| Android 13(API 33) | 旧权限模型:但系统会提示“此应用是为旧版Android设计”,部分严格ROM会限制后台扫描 | 新权限模型:逻辑同Android 12,多了通知权限等新变化 |
| Android 14(API 34) | 旧权限模型:Android 14开始对target 30及以下的应用安装做了更严格限制 | 新权限模型:新增了部分针对蓝牙扫描的后台限制,权限模型与12类似 |
这张表的重点是:你到底是按“新权限”还是“旧权限”走,完全取决于App的targetSdkVersion,而不是用户手机的Android版本。这一点非常反直觉,很多开发者在Android 12手机上用target 30的包测试,发现蓝牙一切正常,就直接判定“不用适配”,等到应用市场强制要求target 31之后,线上直接就崩了。
4.2 后台扫描与前台服务的交互限制
Android 12还引入了针对蓝牙扫描的前台服务类型限制。如果你的App需要在后台持续扫描BLE设备,必须声明一个bluetooth类型的前台服务(foregroundServiceType)。这一点虽然不完全是权限模型的改动,但它直接决定了申请了权限之后,App能不能在后台长期使用扫描能力。
Manifest声明如下:
<service android:name=".BleScanService" android:foregroundServiceType="bluetooth" android:exported="false" />启动前台服务时也要显式指定类型:
// 仅针对Android 10及以上 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForegroundService(Intent(this, BleScanService::class.java)) } else { startService(Intent(this, BleScanService::class.java)) }在服务内部启动前台通知时,还需要同步传入类型:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { ServiceCompat.startForeground( this, NOTIFICATION_ID, notification, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ServiceInfo.FOREGROUND_SERVICE_TYPE_BLUETOOTH } else { 0 } ) }这儿有个最常见的灾难现场:开发者在Android 12设备上跑后台扫描,权限都给了,前台服务也启动了,但忘了指定foregroundServiceType="bluetooth",结果一退到后台,扫描就停。排查半天还以为是厂商省电策略的问题,最后发现是系统在log里默默打了“foregroundServiceType”异常。
4.3 各ROM的权限弹窗与授权差异
国内厂商ROM对Android 12权限模型做了不少定制。我在测试中发现,小米、华为、OPPO、vivo这几家对BLUETOOTH_NEARBY权限组的弹窗文案和处理逻辑不完全一致。有些ROM会在后台弹出权限时默认拒绝,有些ROM要求用户额外打开“后台定位”或“附近设备”的开关,还有部分旧版本ROM会把新权限名显示成“其他权限”。
建议在适配阶段做一轮真机矩阵测试,关注几个点:
- 权限弹窗是否正常弹出,文案是否可理解。
- 拒绝一次后,第二次是否还能触发弹窗(某些ROM只弹一次)。
- 系统设置中的权限页是否能找到“附近设备”分组。
- 权限恢复默认后(用户清数据或重装App),是否影响蓝牙扫描。
5. 常见的异常、权限拒绝与排查技巧实录
5.1 SecurityException:权限模型切换后的第一杀手
这个异常的特征很明显:代码里调用startDiscovery或BluetoothLeScanner.startScan时,直接抛出java.lang.SecurityException: Need BLUETOOTH_SCAN permission。
出现这个异常,优先按顺序排查:
| 排查项 | 检查方法 | 建议 |
|---|---|---|
| Manifest是否声明新权限 | 打开AndroidManifest.xml,确认BLUETOOTH_SCAN和BLUETOOTH_CONNECT存在 | 不要漏掉任何一个 |
| targetSdkVersion是否为31+ | build.gradle里的targetSdkVersion字段 | 必须>= 31,否则新权限不生效 |
| 运行时权限是否已授予 | 在调用点前打Log或断点,检查checkSelfPermission返回值 | 不要假设用户一定会点“允许” |
| 是否使用了旧权限替代新权限 | 只声明了BLUETOOTH而没有新增权限 | 必须新旧全量声明 |
| 权限被系统重置 | 用户在设置里关闭了权限,或App被系统回收 | 在异常捕获处引导用户到设置页 |
另外,我还是建议所有蓝牙操作统一封装在一个BluetoothManagerCompat类里,操作入口处统一做权限检查,捕获SecurityException后弹Toast或跳转设置页。这样做成一个兜底,能避免很多线上偶现崩溃。
5.2 扫描不到设备,先别急着怀疑你的代码
Android 12适配后,“扫描不到设备”是排查成本非常高的一个问题,因为原因实在太多。我整理过一份核查清单,你在定位问题的时候可以直接对着拍查:
- 检查是否真的授予了
BLUETOOTH_SCAN权限:有些人只给了定位权限,没给附近设备权限,扫描自然没结果。 - 检查是否打开了系统级定位开关(Android 12的某些逻辑仍然耦合了定位开关):即使
neverForLocation=true,部分ROM仍需“位置信息”总开关打开才能扫描。 - 检查是否开启了蓝牙扫描过滤(
ScanFilter):过滤条件太严格会过滤掉所有设备。 - 检查扫描回调是否在正确线程注册:
startScan传的callback在Binder线程回调,不要在回调里直接操作UI。 - 检查是否是多应用同时扫描:Android系统对BLE扫描有内部频率限制,多个App同时扫描会导致其中一个被系统强制停止回调。
我踩过的一个个很典型的坑是:适配Android 12后,用BLUETOOTH_SCAN权限扫描正常,但一旦同时请求ACCESS_FINE_LOCATION,再把两者都拒绝一次,之后即使重新授予附近设备权限,扫描回调也不触发。后来查了半天,发现是华为某机型把“附近设备”权限和“位置信息”开关做了联动,必须把定位总开关打开。这类问题跟代码关系不大,但会浪费大量排查时间,建议大家遇到扫描不到设备时先检查系统设置。
5.3 onScanResult回调频率异常与扫描策略建议
Android 12之后,系统对BLE扫描的节流更强了。如果你在startScan时传了ScanCallback,但没有指定ScanSettings的setReportDelay,某些设备上会发现onScanResult回调频率忽高忽低,尤其在亮屏、灭屏切换和后台运行时。
我的实践建议是:
- 前台扫描时,
ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)。 - 后台扫描时,把扫描模式降到
SCAN_MODE_LOW_POWER,并用setReportDelay(2000)做批处理,减少系统压力。 - 一定记得在页面暂停或服务销毁时调用
stopScan,否则会持续占用蓝牙资源,导致其他App也扫不到。
还有一个经验是:如果你的扫描逻辑和连接逻辑放在同一进程,建议把扫描回调与连接回调分开线程处理,避免onScanResult里的耗时操作阻塞Binder线程,进而影响onConnectionStateChange回调。
5.4 旧的蓝牙广播与动态注册变化
Android 12对蓝牙广播的注册方式也做了收紧。以前监听BluetoothDevice.ACTION_FOUND和BluetoothAdapter.ACTION_DISCOVERY_FINISHED,直接在Manifest里静态注册Receiver就能收到。target 31之后,这种隐式广播在很多场景下已经收不到了,必须改成本地动态注册。
val filter = IntentFilter().apply { addAction(BluetoothDevice.ACTION_FOUND) addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) } val receiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { when (intent?.action) { BluetoothDevice.ACTION_FOUND -> { val device = intent.getParcelableExtra<BluetoothDevice>(BluetoothDevice.EXTRA_DEVICE) // 处理扫描到的设备 } BluetoothAdapter.ACTION_DISCOVERY_FINISHED -> { // 扫描结束 } } } } ContextCompat.registerReceiver( this, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED )动态注册时,ContextCompat.RECEIVER_NOT_EXPORTED这个flag很重要。target 31之后,如果Receiver没有指定exported属性,系统默认视为false,跨应用的隐式广播可能会收不到。蓝牙相关的系统广播一般用RECEIVER_NOT_EXPORTED就够了,如果业务需要接收其他应用发来的广播,再考虑RECEIVER_EXPORTED,但这类需求在蓝牙业务里非常少见。
5.5 系统App与特权权限场景参考
如果你做的是系统预装App或者SystemUIDemo这类特权应用,有另外一种授权思路:在Android 12的系统中,可以通过privapp-permissions白名单机制直接授予BLUETOOTH_SCAN、BLUETOOTH_CONNECT等权限,绕过运行时申请弹窗。具体做法是在/etc/permissions/下放一个XML,声明对应包名和权限列表。这种场景下,Manifest里仍然要声明权限,但运行时不需要动态申请。
这部分帮助理解自定义ROM时蓝牙权限的来源是哪儿来的。实际开发现场,绝大多数应用还是走正常的运行时申请逻辑,没必要为了省一次弹窗去做特权白名单,除非你的App是系统内置应用,否则生命周期管理会很麻烦。
6. 从权限适配延展到系统定制场景的几点思考
6.1 从“默认授予所有应用权限”聊到预授权机制
开发自定义固件、企业管控类App的时候,很多人会想“能不能直接默认授权所有应用、省去用户点弹窗的麻烦”。在Android 12的蓝牙权限体系下,这个需求可以分层实现:
- 系统应用层面,可以对特定包名在
DefaultPermissionGrantPolicy里配置默认授予策略。 - 普通应用层面,可以用
AppOpsManager或DevicePolicyManager做一些受限场景下的权限管控,但前提是App必须有设备管理员(Device Admin)权限。
不过我不建议在非定制系统上走“默认授权所有应用”的路线,因为这会直接削弱Android 12的隐私保护设计,审核上可能也会有问题。如果只是内部测试机或者演示机,可以在开发阶段用adb命令快速授予权限:
adb shell pm grant com.example.bleapp android.permission.BLUETOOTH_SCAN adb shell pm grant com.example.bleapp android.permission.BLUETOOTH_CONNECT adb shell pm grant com.example.bleapp android.permission.BLUETOOTH_ADVERTISE6.2 蓝牙扫描与系统动效/旋转屏场景的相互影响
有一类不太起眼但实际存在的问题:Android 12平板或车机上,系统旋转屏180度方向调整时,会导致Activity重建,进而打断BLE扫描流程。如果你在做车载蓝牙或平板蓝牙配对工具,这个场景几乎必现。
处理方式有两种:在AndroidManifest.xml中给对应Activity设置screenOrientation为nosensor或landscape,避免旋转触发重建;或者更优雅的做法是,把蓝牙扫描实例提升到Application级别持有,让Activity旋转时扫描不中断。每次Activity重建后重新startScan的做法,在低速轮询类业务里简单可行,但在需要连续扫描回调的场景下,会出现重复回调或者漏回调,体验很不好。
6.3 init分区挂载与WiFi DHCP的“不相关”联想
热搜词里出现了android12 init分区挂载和android12 wifi dhcp,乍看和蓝牙权限八竿子打不着,但实际做系统定制时你会发现,这三者经常被放在一起排查问题——因为定制固件时蓝牙、WiFi、NFC这些连接类权限都是预置在系统镜像里的,如果init脚本或partition挂载阶段出了问题,可能导致权限文件没有正确加载,进而出现“系统设置里看不到附近设备权限”、“权限申请弹窗无法弹出”这类问题。
遇到这种情况,第一步不是改代码,而是检查adb shell dumpsys package里目标App的权限状态是否正常,再检查/system/etc/permissions/下是否有对应权限文件被成功挂载。我见过一个比较极端的案例:某定制ROM把privapp-permissions文件放错了分区路径,导致系统应用的所有特权权限全部丢失,蓝牙扫描权限自然也在其中,排查了大半天才定位到是文件挂载问题,而不是代码逻辑问题。所以做系统定制时,权限相关问题的排查一定要把镜像文件路径和挂载情况纳入考虑范围。
7. 权限策略设计经验与收尾小技巧
最后聊几个我自己实践后觉得非常有用的经验:
第一,尽量做一个统一权限策略层。不管是扫描、广播还是连接,不要把权限判断散落在各个业务模块里。建议把权限请求、权限状态监听、权限被拒后的引导跳转全部收敛到一个PermissionManager里。当你面对线上用户“权限被拒绝后功能不可用”的投诉时,你会感谢当初自己的设计。
第二,权限文案提前配合好。BLUETOOTH_SCAN的弹窗标题是系统固定的,但权限弹窗下方的“App说明”文案(即requestPermissionRationale相关的逻辑)是可以自定义的。在用户第一次拒绝后,可以在界面上展示一个包含业务场景说明的Dialog,告诉用户“为什么需要蓝牙权限”,然后再引导用户去设置页开启。别小看这一步,实测下来能提升不少授权转化率。
第三,做好权限回调的幂等处理。用户可能随时去系统设置关掉权限,或者App在后台被系统杀掉。每次进入蓝牙功能页时都重新校验权限状态,不要缓存授权结果。
第四,日志要打好。蓝牙权限问题有个特点:直接看源码很难快速定位,因为它与系统版本、ROM定制、用户操作路径都有关。建议在onRequestPermissionsResult回调、startScan、connectGatt、onConnectionStateChange这四处都打上Log,配合adb bugreport能省下不少口水。
Android 12的蓝牙权限改动,本质上是一次隐私模型的进化。它让用户对“蓝牙能力”有了更清晰的知情权,也让开发者在申请权限时不得不去思考每个真正需要蓝牙的业务场景。适配确实有点繁琐,但做成一次,后面Android 13、Android 14的蓝牙相关改动就会轻松很多。根据我的实际体验,只要能静下心来把手动扫描、自动重连、后台扫描这几条链路的权限申请逻辑理顺,Android 12+蓝牙权限这套体系用起来还是很顺手的。希望这篇内容能帮大家少踩几个坑,把适配时间从几天压缩到半天。