15、蓝牙权限适配(Android 12+):BLUETOOTH_SCAN、BLUETOOTH_CONNECT、BLUETOOTH_ADVERTISE运行时权限申请、位置权限解耦、兼容旧版本。

Android 12 对蓝牙权限的改动,说实话,是我这几年适配工作中印象最深的一次。以前我们做蓝牙开发,权限逻辑相对简单——声明个 BLUETOOTHBLUETOOTH_ADMIN,再配上位置权限就完事了。但 Android 12 直接把蓝牙权限体系拆了个七零八落,引入了三个全新的运行时权限。嗯,咱们今天就来把这套新规则彻底捋清楚。

核心变化一句话总结:Android 12 将蓝牙相关功能从位置权限中解耦出来,不再需要位置权限就能扫描蓝牙设备。但代价是——你必须申请三个新的运行时权限。

15.1 新权限体系概览

Android 12 新增了三个权限,分别对应蓝牙的三种核心操作:

权限名 对应操作 是否需要用户弹窗
BLUETOOTH_SCAN 扫描附近的蓝牙设备 是(运行时)
BLUETOOTH_CONNECT 连接已配对的蓝牙设备 是(运行时)
BLUETOOTH_ADVERTISE 广播本设备(作为外围设备) 是(运行时)

我个人习惯把这三个权限称为「蓝牙三件套」。你想想看,以前一个 BLUETOOTH 权限包打天下,现在拆成三个,每个都有独立的用途。这样做的好处是——用户授权时可以更精确地控制你的 App 能做什么。

小提示:如果你的 App 只需要扫描设备,那就只申请 BLUETOOTH_SCAN,别把另外两个也带上。我在项目中见过不少开发者图省事,三个权限一起申请,结果用户一看「你要连接我的设备?还要广播?」直接拒绝了。

15.2 位置权限解耦——终于不用背锅了

以前做蓝牙扫描,必须同时申请位置权限。为什么?因为系统认为蓝牙扫描可以推断用户位置。这其实是个历史遗留问题,很多用户不理解:「我就连个蓝牙耳机,凭什么要给你位置权限?」

Android 12 终于把这个锅卸掉了。从 Android 12 开始,蓝牙扫描不再需要位置权限。但注意——这是有条件的。

重要提醒:如果你的 App 通过蓝牙扫描结果来推断用户位置(比如室内定位、信标测距),那你仍然需要位置权限。系统不会因为你用了 BLUETOOTH_SCAN 就豁免你。

说白了,Google 的逻辑是:蓝牙扫描本身不要求位置权限,但如果你用扫描结果做位置相关的事,那位置权限还是跑不掉。嗯,这个设计其实挺合理的——把「工具」和「用途」分开了。

15.3 运行时权限申请实战

下面我写一个完整的权限申请流程。注意,这里要同时兼容 Android 12 以下和以上的设备。

// 判断是否需要位置权限
private fun needsLocationPermission(): Boolean {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        // Android 12+:蓝牙扫描不需要位置权限
        // 但如果你用扫描结果做位置推断,还是需要
        false
    } else {
        // Android 11 及以下:蓝牙扫描必须配合位置权限
        true
    }
}

// 获取需要申请的权限列表
private fun getRequiredPermissions(): Array<String> {
    val permissions = mutableListOf<String>()
    
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
        // Android 12+:申请蓝牙三件套
        permissions.add(Manifest.permission.BLUETOOTH_SCAN)
        permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
        permissions.add(Manifest.permission.BLUETOOTH_ADVERTISE)
        
        // 如果 App 需要位置推断,再加位置权限
        if (needsLocationPermission()) {
            permissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
        }
    } else {
        // 旧版本:申请传统蓝牙权限 + 位置权限
        permissions.add(Manifest.permission.BLUETOOTH)
        permissions.add(Manifest.permission.BLUETOOTH_ADMIN)
        permissions.add(Manifest.permission.ACCESS_FINE_LOCATION)
    }
    
    return permissions.toTypedArray()
}

这段代码的核心思路是:根据系统版本动态决定申请哪些权限。我曾经在项目里看到有人直接写死权限列表,结果在 Android 12 设备上闪退——因为 BLUETOOTH_SCAN 在旧版本上根本不存在。

15.4 兼容旧版本——别让老用户寒心

你的 App 肯定要跑在 Android 11、10、甚至 9 的设备上。这时候怎么办?

我的做法是:在 AndroidManifest.xml 里同时声明新旧两套权限,然后用 maxSdkVersion 控制旧权限的生效范围。

<!-- Android 12+ 新权限 -->
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />

<!-- 旧版本兼容权限 -->
<uses-permission 
    android:name="android.permission.BLUETOOTH"
    android:maxSdkVersion="30" />
<uses-permission 
    android:name="android.permission.BLUETOOTH_ADMIN"
    android:maxSdkVersion="30" />

<!-- 位置权限(旧版本必须,新版本按需) -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

注意 maxSdkVersion="30" 这个属性。它的意思是:这个权限只在 Android 11(API 30)及以下生效。到了 Android 12 以上,系统会自动忽略它。这样既兼容了旧设备,又不会在新设备上产生冗余权限声明。

一个小坑:有些开发者忘了加 maxSdkVersion,结果在 Android 12 上同时声明了 BLUETOOTHBLUETOOTH_SCAN。虽然不会崩溃,但 Google Play 的审核可能会提示你「存在冗余权限声明」。我建议你养成加 maxSdkVersion 的习惯。

15.5 权限申请流程图

下面我用一张 SVG 图把整个权限适配的逻辑串起来,方便你理解。

Android 12+ 蓝牙权限适配决策流程 开始权限适配 判断:Build.VERSION.SDK_INT >= 31? (Android 12 及以上) Android 12+:申请蓝牙三件套 BLUETOOTH_SCAN + CONNECT + ADVERTISE 判断:是否需要位置推断? (室内定位、信标测距等) 追加申请:ACCESS_FINE_LOCATION 无需位置权限 否(Android 11 及以下) 旧版本兼容 BLUETOOTH + BLUETOOTH_ADMIN 必须申请位置权限

15.6 运行时权限申请代码模板

最后,我贴一段完整的运行时权限申请代码。这段代码我用了很多项目,基本没出过问题。

class BluetoothPermissionHelper(private val activity: Activity) {
    
    companion object {
        private const val PERMISSION_REQUEST_CODE = 1001
    }
    
    fun checkAndRequestPermissions(callback: (Boolean) -> Unit) {
        val permissions = getRequiredPermissions()
        val deniedPermissions = permissions.filter {
            ContextCompat.checkSelfPermission(activity, it) != PackageManager.PERMISSION_GRANTED
        }
        
        if (deniedPermissions.isEmpty()) {
            // 所有权限都已授予
            callback(true)
            return
        }
        
        // 请求未授予的权限
        ActivityCompat.requestPermissions(
            activity,
            deniedPermissions.toTypedArray(),
            PERMISSION_REQUEST_CODE
        )
    }
    
    fun handlePermissionResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray,
        callback: (Boolean) -> Unit
    ) {
        if (requestCode != PERMISSION_REQUEST_CODE) return
        
        val allGranted = grantResults.all { it == PackageManager.PERMISSION_GRANTED }
        
        if (!allGranted) {
            // 检查用户是否勾选了「不再询问」
            val shouldShowRationale = permissions.any {
                ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
            }
            
            if (!shouldShowRationale) {
                // 用户勾选了「不再询问」,引导去设置页
                showPermissionSettingsDialog()
            } else {
                // 用户拒绝了但没勾选「不再询问」,可以再次解释
                showPermissionExplanationDialog()
            }
        }
        
        callback(allGranted)
    }
}

避坑指南:我曾经遇到过一个情况——在 Android 12 设备上,用户拒绝了 BLUETOOTH_SCAN 权限,但 App 仍然能调用 startDiscovery() 方法。结果呢?扫描结果为空,而且没有任何错误提示。后来我才发现,Android 12 在权限被拒绝时,系统会静默返回空结果,而不是抛异常。所以一定要在扫描前检查权限状态。

15.7 总结

Android 12 的蓝牙权限适配,说白了就是三件事:

  • 新权限三件套:SCAN、CONNECT、ADVERTISE,按需申请,别贪多
  • 位置权限解耦:蓝牙扫描不再强制要求位置权限,但如果你用扫描结果做位置推断,还是得申请
  • 兼容旧版本:maxSdkVersion 控制旧权限声明,运行时根据系统版本动态申请

嗯,这套规则其实不难,就是细节多。你只要把权限声明和运行时申请的逻辑写对,剩下的就交给系统去处理了。记住一点:权限是用户体验的第一道门槛,别让用户一打开 App 就看到一堆权限弹窗。

公众号:蓝海资料掘金营,微信deep3321