当前位置: 首页 > news >正文

Android 12 蓝牙权限适配指南:从基础到实战

1. 为什么Android 12的蓝牙权限让你头疼?

如果你最近在维护一个带蓝牙功能的App,升级到Android 12(API 31)后,是不是发现以前好好的蓝牙扫描、连接功能突然就“罢工”了?或者,用户那边疯狂反馈“App用不了蓝牙了”?别慌,这几乎是所有Android开发者在适配Android 12时都会遇到的第一个大坎儿。我去年在给公司的一个智能家居App做升级时就踩了这个坑,当时测试机一升到Android 12,整个设备发现流程直接瘫痪,把我急出一身汗。

问题的核心,就在于Android 12对蓝牙权限模型进行了一次“大手术”。在Android 11及之前,我们申请蓝牙权限的思路相对固定:要扫描设备,你得先拿到位置权限(ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION),因为系统认为蓝牙扫描结果可能被用来推断你的物理位置。这个设计初衷是为了保护用户隐私,防止恶意应用通过扫描周围的蓝牙设备(比如你家的智能音箱、办公室的打印机)来追踪你的行踪。但这也给很多纯粹做设备连接、数据传输的应用带来了困扰——我明明只是想连个蓝牙耳机放音乐,或者连个智能手环同步数据,为什么非要我获取你的位置信息呢?用户也常常不理解,一个听歌的App为什么要定位权限,导致拒绝率很高。

Android 12的这次权限重构,就是为了解决这个“误伤”问题。它把蓝牙权限拆得更细、更精准了。以前那种“一刀切”要位置权限的做法被改变,取而代之的是三个全新的、职责分明的运行时权限:BLUETOOTH_SCANBLUETOOTH_ADVERTISEBLUETOOTH_CONNECT。简单来说,就是“干什么活儿,申请什么权限”。如果你的应用只是扫描寻找蓝牙设备(比如寻找可配对的耳机),那就只申请BLUETOOTH_SCAN;如果你的应用是作为外围设备被其他设备发现(比如让手机变成一个蓝牙信标),那就申请BLUETOOTH_ADVERTISE;如果你的应用需要与已经配对的设备进行通信(比如向已连接的音箱发送音频流),那就申请BLUETOOTH_CONNECT

这个变化好处很明显:权限申请对用户更透明了,用户能更清楚地知道你这个App到底要用蓝牙来做什么,隐私保护也更到位。但对于我们开发者来说,适配工作就是实实在在的“债”了。你需要仔细梳理自己App的每一个蓝牙操作场景,然后像拼图一样,把新的权限声明和运行时请求代码正确地嵌入到现有的逻辑里。接下来,我就带你从最基础的权限声明开始,一步步走完这个适配流程,中间还会分享我踩过的几个“坑”和解决方案,保证你看完就能动手改。

2. 新旧权限对照与清单文件声明

首先,我们得搞清楚新旧权限的对应关系,这就像搬家前先整理好物品清单,知道旧东西对应新家的哪个位置。在Android 11(API 30)及更早的版本中,我们主要和下面这几个权限打交道:

  • BLUETOOTHBLUETOOTH_ADMIN:这是蓝牙功能的基础入场券。BLUETOOTH允许基本的连接操作,BLUETOOTH_ADMIN则允许更高级的管理操作,比如启动/关闭蓝牙适配器、发现设备。这两个权限在安装时由用户授予(安装时权限),不需要在运行时弹窗请求。
  • ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION:这是让很多开发者头疼的“位置门槛”。从Android 6.0(API 23)引入运行时权限开始,只要你的App涉及扫描蓝牙设备(无论是经典蓝牙还是低功耗蓝牙BLE),就必须在运行时向用户申请位置权限。系统默认你扫描到的设备列表可能泄露位置信息。
  • ACCESS_BACKGROUND_LOCATION:如果你的App需要在后台(比如通过Service或WorkManager)持续扫描蓝牙设备,那么在Android 10(API 29)及以上,你还需要申请这个后台位置权限,它的授权级别更高,用户控制也更严格。

到了Android 12,游戏规则变了。新的三个权限(BLUETOOTH_SCAN,BLUETOOTH_ADVERTISE,BLUETOOTH_CONNECT)都是运行时权限。这意味着即使用户在安装时同意了,你在执行相关操作前,仍然需要在App运行中动态地向用户申请。这给了用户更及时的控制权。

那么,在AndroidManifest.xml文件里,我们应该怎么写呢?目标是要做到新旧版本兼容,确保你的App在Android 12及以上的新设备上用新权限,在Android 11及以下的老设备上继续用老权限。这里有个关键属性:android:maxSdkVersion。你可以把它理解为给旧权限设置一个“退休年龄”。

下面是一个完整的、兼容性良好的清单文件权限声明示例,我建议你直接复制过去,然后根据注释调整:

<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.yourcompany.yourapp"> <!-- 兼容Android 11及以下设备:声明旧版蓝牙权限,并限制其只在API 30及以下生效 --> <uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <!-- Android 12+ 新权限:蓝牙扫描 --> <!-- 注意:如果你的App绝对不通过蓝牙扫描结果推导物理位置,可以添加`neverForLocation`标志 --> <uses-permission android:name="android.permission.BLUETOOTH_SCAN" /> <!-- Android 12+ 新权限:蓝牙广播(使设备可被发现) --> <uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" /> <!-- Android 12+ 新权限:蓝牙连接(与已配对设备通信) --> <uses-permission android:name="android.permission.BLUETOOTH_CONNECT" /> <!-- 位置权限:根据你的App实际需求决定是否保留 --> <!-- 情况1:你的App确实需要位置服务(例如地图、导航),或者无法保证蓝牙扫描结果不用于定位 --> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <!-- 情况2:你的App完全不需要位置信息,且能承诺蓝牙扫描不用于定位(见下文详解) --> <!-- 那么你可以选择不声明位置权限,或在BLUETOOTH_SCAN权限上添加`neverForLocation`标志 --> <!-- 声明硬件特性(可选,但推荐) --> <!-- 告诉Google Play商店,你的App需要蓝牙或低功耗蓝牙硬件支持 --> <uses-feature android:name="android.hardware.bluetooth" android:required="false"/> <uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/> <application ...> ... </application> </manifest>

我来解释一下几个关键点:

  1. 旧权限的maxSdkVersion="30":这行代码是兼容性的灵魂。它告诉系统:“在Android 11(API 30)及以下的设备上,请给我BLUETOOTHBLUETOOTH_ADMIN权限;但在Android 12(API 31)及以上的设备上,就别管这两个旧权限了,我主要用下面那三个新的。” 这样就完美避免了权限冲突或重复申请。
  2. 新权限的声明BLUETOOTH_SCANBLUETOOTH_ADVERTISEBLUETOOTH_CONNECT这三个权限没有sdkVersion限制,它们会在所有版本的Android上被声明,但只有在Android 12及以上的设备上,系统才会真正把它们当作运行时权限来处理。在旧设备上,它们会被系统忽略。
  3. 位置权限何去何从?这是最让人困惑的地方。在Android 12上,如果你能向系统“强烈保证”你的App永远不会利用蓝牙扫描结果来推断物理位置(比如你的App只是一个蓝牙音乐播放器,扫描只是为了找音箱,完全不关心地理位置),那么你就有机会摆脱位置权限的束缚。具体做法是在声明BLUETOOTH_SCAN权限时,添加一个特殊的标志位android:usesPermissionFlags="neverForLocation"。我们会在下一节详细讨论这个“免死金牌”该怎么用。

2.1 关键抉择:如何声明“不推导位置”?

“不推导物理位置”这个特性是Android 12蓝牙权限更新的最大亮点,也是适配工作的核心难点。它允许你绕过烦人的位置权限请求,极大地提升用户体验和授权通过率。但谷歌对此审核很严,你需要能“强烈断言”(strongly assert)你的应用符合条件。

什么情况下可以声明neverForLocation想象一下这些场景:你的App是一个无线耳机管理工具,扫描蓝牙只是为了连接用户的耳机;或者是一个智能体重秤的数据同步App,扫描只是为了找到家里的体重秤;又或者是一个通过蓝牙传输文件的工具。在这些场景里,扫描到的蓝牙设备MAC地址或名称,仅仅用于建立通信链路,App本身没有任何功能需要知道用户在哪里,也不会将这些设备信息上传到服务器去做地理位置分析。如果你的App符合这些描述,那你很可能就可以使用neverForLocation标志。

如何声明?在你的AndroidManifest.xml中,像下面这样修改BLUETOOTH_SCAN的声明:

<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />

添加了android:usesPermissionFlags="neverForLocation"这个属性后,神奇的事情发生了:

  • 在Android 12+的设备上,当你的App申请BLUETOOTH_SCAN权限时,系统不会自动连带要求位置权限。
  • 如果你的App也没有其他功能需要位置权限(比如地图),那么你甚至可以完全不声明ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION权限。用户再也看不到那个让人生疑的“允许应用访问位置信息吗?”的弹窗了。

但是,请务必诚实!这是一个对谷歌和用户的承诺。如果你声明了neverForLocation,但被谷歌审核或用户发现你的App实际上通过蓝牙扫描信息推断或记录了位置,你的应用可能会被下架。在代码层面,即使你声明了这个标志,你仍然可以通过BluetoothLeScanner获取到扫描结果(ScanResult),其中包含设备的信号强度(RSSI)等信息。理论上,这些信息可以用于粗略定位。你的责任是确保在应用逻辑中,绝不使用这些信息进行任何与地理位置相关的计算、存储或上传。

我个人的经验是,对于绝大多数面向消费者、功能纯粹的蓝牙设备连接类App,只要设计之初就没考虑位置功能,大胆使用neverForLocation是没问题的。它能显著降低用户的权限戒备心理,提高功能可用性。

3. 运行时权限请求代码实战

权限在清单文件里声明好了,接下来就是重头戏:在代码里动态请求这些权限。Android 12的新蓝牙权限都是运行时权限,所以我们必须使用ActivityResultContracts.RequestPermissionRequestMultiplePermissions这套现代API来请求。我强烈建议使用ActivityResult API,它比老旧的onRequestPermissionsResult回调方式更清晰、更易于管理。

我们先来定义一个权限请求的工具类或者在你的Activity/Fragment中编写请求逻辑。这里假设你的应用需要扫描和连接蓝牙设备,那么你需要请求BLUETOOTH_SCANBLUETOOTH_CONNECT权限。

第一步:检查并请求权限在尝试执行任何蓝牙操作(比如点击一个“搜索设备”的按钮)之前,你需要检查权限是否已授予。

// 在Activity或Fragment中 import android.Manifest import android.os.Build import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat class BluetoothActivity : AppCompatActivity() { // 定义需要申请的权限数组,注意区分版本 private val requiredPermissions: Array<String> by lazy { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12+ 使用新权限 arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT ) } else { // Android 11及以下,需要蓝牙和位置权限 arrayOf( Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.ACCESS_FINE_LOCATION ) } } // 注册一个用于请求多个权限的合约 private val requestPermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissionsGrantedMap -> // 回调结果是一个Map,键是权限名,值是布尔值(是否授予) val allGranted = permissionsGrantedMap.values.all { it } if (allGranted) { // 所有权限都已授予,可以开始蓝牙操作了 startBluetoothOperation() } else { // 有些权限被拒绝,需要向用户解释为什么需要这些权限 showPermissionRationale() } } // 一个触发权限检查的方法,例如在按钮点击事件中调用 fun onScanButtonClicked() { if (checkAllPermissionsGranted()) { // 权限已全部拥有,直接开始操作 startBluetoothOperation() } else { // 请求缺失的权限 requestPermissionLauncher.launch(requiredPermissions) } } // 检查所有所需权限是否都已授予 private fun checkAllPermissionsGranted(): Boolean { return requiredPermissions.all { permission -> ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED } } private fun startBluetoothOperation() { // 这里实现你的蓝牙扫描或连接逻辑 Toast.makeText(this, "权限已就绪,开始蓝牙操作", Toast.LENGTH_SHORT).show() // 例如:initBluetoothAdapter(), startScan() 等 } private fun showPermissionRationale() { // 向用户展示一个对话框,解释为什么需要这些权限 AlertDialog.Builder(this) .setTitle("需要权限") .setMessage("扫描和连接蓝牙设备需要相关权限。扫描权限用于发现附近的设备,连接权限用于与设备通信。") .setPositiveButton("去设置") { _, _ -> // 引导用户去应用设置页面手动开启权限 val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = Uri.fromParts("package", packageName, null) startActivity(intent) } .setNegativeButton("取消", null) .show() } }

这段代码做了几件重要的事情:

  1. 版本判断:通过Build.VERSION.SDK_INT来区分Android 12前后所需的权限集合,这是兼容性代码的基石。
  2. 使用现代APIregisterForActivityResult配合RequestMultiplePermissions,让权限请求的逻辑与结果处理更集中,避免了在onRequestPermissionsResult中写一堆if-elserequestCode判断。
  3. 先检查再请求:在请求前先调用checkAllPermissionsGranted(),避免不必要的权限请求弹窗打扰用户。只有当权限缺失时,才启动请求流程。
  4. 处理拒绝情况:在用户拒绝权限后,通过showPermissionRationale()向用户解释权限的用途,并提供一个跳转到系统设置页面的入口,让用户可以在那里手动开启权限。这是一个良好的用户体验设计。

第二步:处理Android 12+的特殊情况——BLUETOOTH_CONNECT权限的“静默”请求这里有一个非常重要的细节,是我在实测中遇到的“坑”。对于BLUETOOTH_CONNECT权限,谷歌引入了一个特殊行为:当你的应用尝试与一个已经与系统配对过的蓝牙设备进行连接时,系统可能会自动授予BLUETOOTH_CONNECT权限,而不会弹出请求对话框。

这听起来是件好事,对吧?但问题在于,这个“自动授予”的发生时机和条件有点模糊。它通常发生在用户从系统蓝牙设置界面发起连接,或者你的App尝试连接一个用户已经明确配对过的设备时。然而,你不能依赖这个行为。为了确保代码健壮性,最佳实践仍然是显式地检查并请求BLUETOOTH_CONNECT权限,就像上面代码示例中做的那样。如果系统已经静默授予,你的检查会通过,不会重复弹窗;如果系统没有授予,你的请求会触发弹窗。

第三步:别忘了检查蓝牙硬件和开关状态即使权限都拿到了,蓝牙功能也不一定能用,因为用户的手机可能没有蓝牙硬件,或者蓝牙开关是关闭的。所以,在开始扫描或连接前,还需要做两步检查:

private fun checkBluetoothAndStart() { // 1. 检查设备是否支持蓝牙 val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() if (bluetoothAdapter == null) { // 设备不支持蓝牙 Toast.makeText(this, "此设备不支持蓝牙", Toast.LENGTH_LONG).show() return } // 2. 检查蓝牙是否开启 if (!bluetoothAdapter.isEnabled) { // 蓝牙未开启,可以引导用户去开启 val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) // 使用ActivityResult API来接收用户操作结果 val enableBluetoothLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_OK) { // 用户已开启蓝牙 startBluetoothOperation() } else { // 用户拒绝开启蓝牙 Toast.makeText(this, "蓝牙功能被禁用,无法使用相关服务", Toast.LENGTH_SHORT).show() } } enableBluetoothLauncher.launch(enableBtIntent) } else { // 蓝牙已开启,可以开始你的操作了 startBluetoothOperation() } }

把这段硬件和开关状态的检查,放在你的startBluetoothOperation()方法里或者之前调用。这样,你的蓝牙功能流程就完整了:检查权限 -> 检查硬件和开关 -> 执行业务逻辑

4. 不同场景下的适配策略与代码示例

理论说完了,我们来看几个最常见的具体场景,把代码填进去。不同的蓝牙功能组合,需要的权限和代码逻辑略有不同。

4.1 场景一:仅扫描并连接BLE设备(例如,智能手环App)

这是最典型的场景。App需要扫描周围的低功耗蓝牙设备(比如手环、心率带),发现后与其中某一个建立连接并通信。

所需权限:

  • Android 12+:BLUETOOTH_SCAN,BLUETOOTH_CONNECT(如果声明了neverForLocation,则不需要位置权限)。
  • Android 11-:BLUETOOTH,BLUETOOTH_ADMIN,ACCESS_FINE_LOCATION

核心代码片段(Kotlin):

// 假设我们已经获得了所有必要的权限,并且蓝牙已开启 private lateinit var bluetoothLeScanner: BluetoothLeScanner private val scanResults = mutableListOf<ScanResult>() private val scanCallback = object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { super.onScanResult(callbackType, result) // 发现设备,更新UI列表 runOnUiThread { if (!scanResults.any { it.device.address == result.device.address }) { scanResults.add(result) // 更新你的RecyclerView或ListView adapter.notifyDataSetChanged() } } } override fun onScanFailed(errorCode: Int) { super.onScanFailed(errorCode) Log.e("Bluetooth", "扫描失败,错误码: $errorCode") runOnUiThread { Toast.makeText(this@BluetoothActivity, "蓝牙扫描失败: $errorCode", Toast.LENGTH_SHORT).show() } } } fun startBLEScan() { val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner ?: return val scanSettings = ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 扫描模式:低延迟(快速发现) .build() val scanFilters = listOf<ScanFilter>() // 可以添加过滤器,例如只扫描特定服务UUID的设备 // 示例:ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build() try { bluetoothLeScanner.startScan(scanFilters, scanSettings, scanCallback) Toast.makeText(this, "开始扫描...", Toast.LENGTH_SHORT).show() } catch (e: SecurityException) { // 这里可能会抛出SecurityException!如果权限没有真正获取到。 Log.e("Bluetooth", "启动扫描失败,权限不足", e) Toast.makeText(this, "启动扫描失败,请检查权限", Toast.LENGTH_LONG).show() } } fun stopBLEScan() { bluetoothLeScanner?.stopScan(scanCallback) Toast.makeText(this, "停止扫描", Toast.LENGTH_SHORT).show() } // 连接设备 fun connectToDevice(device: BluetoothDevice) { // 这里通常你会使用一个BluetoothGatt来进行连接和通信 // 注意:连接操作本身也需要BLUETOOTH_CONNECT权限 val gatt = device.connectGatt(this, false, gattCallback) // ... 后续的GATT操作(发现服务、读写特征值等) }

适配要点:

  • 异常捕获:注意startScan()方法现在可能会抛出SecurityException。这是因为新权限是运行时权限,即使你在清单文件里声明了,如果用户没有在运行时授予,调用此方法就会崩溃。所以务必用try-catch包起来,并给用户友好的提示。
  • neverForLocation的影响:如果你在清单中为BLUETOOTH_SCAN添加了neverForLocation标志,那么ScanResult对象中的一些字段可能会被系统过滤或返回空值(例如,getTimestampNanos()的准确性可能受影响),以确保不泄露位置信息。对于大多数连接场景,这没有影响。

4.2 场景二:作为外围设备广播(例如,让手机模拟一个蓝牙信标)

这个场景相对小众但很重要。你的App需要让手机本身变成一个可被其他设备发现的低功耗蓝牙外围设备,广播特定的数据。

所需权限:

  • Android 12+:BLUETOOTH_ADVERTISE,BLUETOOTH_CONNECT(如果你还需要连接其他设备)。
  • Android 11-:BLUETOOTH,BLUETOOTH_ADMIN(通常也需要位置权限,因为扫描端需要,但广播端本身对位置权限要求不严格,具体看情况)。

核心代码片段(Kotlin):

private lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser private var advertisingCallback: AdvertiseCallback? = null fun startAdvertising() { val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() bluetoothLeAdvertiser = bluetoothAdapter?.bluetoothLeAdvertiser ?: return val advertiseSettings = AdvertiseSettings.Builder() .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_BALANCED) // 广播模式 .setConnectable(true) // 是否可连接 .setTimeout(0) // 广播超时时间(0表示不超时) .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) // 发射功率 .build() // 构建广播数据 val advertiseData = AdvertiseData.Builder() .setIncludeDeviceName(true) // 包含设备名 .addServiceUuid(ParcelUuid.fromString("0000FEAA-0000-1000-8000-00805F9B34FB")) // 示例UUID // .addServiceData(serviceUuid, data) // 可以添加自定义服务数据 .build() // 构建扫描响应数据(当对方主动扫描时返回) val scanResponseData = AdvertiseData.Builder() .addManufacturerData(0x004C, byteArrayOf(0x02, 0x15, ...)) // 示例:模拟iBeacon .build() advertisingCallback = object : AdvertiseCallback() { override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { super.onStartSuccess(settingsInEffect) Log.i("Bluetooth", "广播启动成功") runOnUiThread { Toast.makeText(this@BluetoothActivity, "广播已开启", Toast.LENGTH_SHORT).show() } } override fun onStartFailure(errorCode: Int) { super.onStartFailure(errorCode) Log.e("Bluetooth", "广播启动失败,错误码: $errorCode") runOnUiThread { Toast.makeText(this@BluetoothActivity, "广播启动失败: $errorCode", Toast.LENGTH_SHORT).show() } } } try { bluetoothLeAdvertiser.startAdvertising(advertiseSettings, advertiseData, scanResponseData, advertisingCallback) } catch (e: SecurityException) { Log.e("Bluetooth", "启动广播失败,权限不足", e) Toast.makeText(this, "启动广播失败,请检查BLUETOOTH_ADVERTISE权限", Toast.LENGTH_LONG).show() } } fun stopAdvertising() { advertisingCallback?.let { bluetoothLeAdvertiser?.stopAdvertising(it) advertisingCallback = null Toast.makeText(this, "广播已停止", Toast.LENGTH_SHORT).show() } }

适配要点:

  • 权限对应:这个场景主要依赖BLUETOOTH_ADVERTISE权限。同样,在Android 12+上,这是一个运行时权限,需要动态申请。
  • 资源释放:广播比较耗电,记得在不需要时(比如Activity的onPauseonDestroy)调用stopAdvertising()来释放资源。
  • 后台限制:在Android 8.0(API 26)以后,应用在后台时对蓝牙广播有严格限制。如果你的App需要在后台持续广播,可能需要使用前台服务(Foreground Service)并显示一个持续的通知。

4.3 场景三:经典蓝牙(Bluetooth Classic)操作

虽然低功耗蓝牙(BLE)现在是主流,但很多设备如音箱、车载系统、老式耳机仍使用经典蓝牙。Android 12的权限变化对经典蓝牙也有影响。

所需权限:

  • 连接已配对设备:在Android 12+上,需要BLUETOOTH_CONNECT权限。在Android 11-上,需要BLUETOOTH权限。
  • 发现和配对新设备:这个过程通常通过系统的蓝牙设置界面完成,或者你的App需要启动一个BluetoothDevicePicker的Intent。在Android 12+上,发现周边设备这个动作本身,如果你的App要主动执行,可能需要BLUETOOTH_SCAN权限(即使对于经典蓝牙)。但更常见的做法是让用户去系统设置配对,然后你的App只负责连接已配对的设备。

核心代码片段(连接已配对的经典蓝牙设备):

fun connectToPairedClassicDevice(deviceName: String) { val bluetoothAdapter: BluetoothAdapter? = BluetoothAdapter.getDefaultAdapter() val pairedDevices: Set<BluetoothDevice>? = bluetoothAdapter?.bondedDevices pairedDevices?.firstOrNull { it.name == deviceName }?.let { device -> // 假设我们要连接的是一个支持RFCOMM串行端口仿真的设备(如很多蓝牙模块) val uuid = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") // 标准的SPP UUID try { // 创建Socket连接。注意:这是一个阻塞调用,必须在后台线程执行! val socket = device.createRfcommSocketToServiceRecord(uuid) GlobalScope.launch(Dispatchers.IO) { try { socket.connect() // 连接成功,可以获取输入输出流进行通信 val inputStream = socket.inputStream val outputStream = socket.outputStream // ... 进行数据读写 withContext(Dispatchers.Main) { Toast.makeText(this@BluetoothActivity, "连接成功", Toast.LENGTH_SHORT).show() } } catch (e: IOException) { Log.e("Bluetooth", "连接失败", e) withContext(Dispatchers.Main) { Toast.makeText(this@BluetoothActivity, "连接失败: ${e.message}", Toast.LENGTH_SHORT).show() } socket.close() } catch (e: SecurityException) { // Android 12+ 上,如果没有BLUETOOTH_CONNECT权限,会在这里抛出异常 Log.e("Bluetooth", "连接失败,权限不足", e) withContext(Dispatchers.Main) { Toast.makeText(this@BluetoothActivity, "连接失败,请检查蓝牙连接权限", Toast.LENGTH_LONG).show() } } } } catch (e: IOException) { Log.e("Bluetooth", "创建Socket失败", e) } } ?: run { Toast.makeText(this, "未找到名为 $deviceName 的已配对设备", Toast.LENGTH_SHORT).show() } }

适配要点:

  • 权限检查前置:在调用device.createRfcommSocketToServiceRecordsocket.connect()之前,务必确保已经获得了BLUETOOTH_CONNECT(Android 12+)或BLUETOOTH(Android 11-)权限,否则会抛出SecurityException
  • 线程处理:蓝牙Socket的连接和通信是阻塞式IO操作,绝对不能在主线程(UI线程)中执行,否则会导致应用无响应(ANR)。务必使用后台线程、协程或AsyncTask(已废弃)来处理。
  • 配对列表:经典蓝牙的连接对象通常来自已配对设备列表(bondedDevices)。你的App逻辑应该是让用户先在系统设置中完成配对,然后你的App从列表中选取设备进行连接。

5. 测试与调试:避开那些看不见的“坑”

适配代码写完了,不代表工作就结束了。测试环节至关重要,尤其是兼容性测试。我在这部分摔的跤最多,分享几个关键测试点和调试技巧。

1. 多版本真机测试是王道不要只在一台Android 12或13的手机上测试。你需要准备至少三台设备:

  • 一台Android 12或13的设备(用于测试新权限流程)。
  • 一台Android 11的设备(用于测试旧权限和位置权限的流程)。
  • 一台Android 6.0到10之间的设备(用于测试旧版位置权限的运行时请求)。

在每台设备上,完整地走一遍流程:安装App -> 首次启动 -> 触发蓝牙操作 -> 观察权限弹窗是否正确出现(内容、顺序) -> 分别尝试“允许”和“拒绝” -> 检查功能是否正常。

2. 关注“权限自动授予”的边界情况前面提到BLUETOOTH_CONNECT可能被静默授予。测试时,要模拟这种情况:

  • 先在系统蓝牙设置里配对好一个设备。
  • 然后安装你的App,第一次启动时,尝试连接这个已配对设备。观察是否弹出了权限请求对话框。在我的测试中,部分机型会弹,部分机型不会。确保你的代码在两种情况下都能正常工作(即,无论系统是否静默授予,你显式检查权限的逻辑都能正确处理)。

3. 测试neverForLocation标志的实际效果如果你声明了这个标志,重点测试:

  • 在Android 12+设备上,请求BLUETOOTH_SCAN权限时,系统是否没有同时要求位置权限?弹窗的文案是否只提到了“允许应用扫描附近的设备”?
  • 你的App在获取扫描结果后,是否真的没有任何代码尝试获取或使用地理位置信息(包括通过其他API如FusedLocationProviderClient)?这既是功能测试,也是合规自查。

4. 使用ADB命令快速测试权限在开发过程中,频繁安装卸载来测试权限很麻烦。你可以使用ADB命令来快速授予或撤销权限,这能极大提升调试效率。

# 授予权限 adb shell pm grant <你的应用包名> android.permission.BLUETOOTH_SCAN adb shell pm grant <你的应用包名> android.permission.BLUETOOTH_CONNECT adb shell pm grant <你的应用包名> android.permission.ACCESS_FINE_LOCATION # 撤销权限 adb shell pm revoke <你的应用包名> android.permission.BLUETOOTH_SCAN adb shell pm revoke <你的应用包名> android.permission.BLUETOOTH_CONNECT # 查看应用拥有的所有权限 adb shell dumpsys package <你的应用包名> | grep permission

5. 处理后台扫描的权限如果你的App需要在后台持续扫描(例如资产追踪应用),那么在Android 10及以上,除了BLUETOOTH_SCAN,你很可能还需要ACCESS_BACKGROUND_LOCATION(后台位置权限)。这个权限的申请流程更复杂,用户授权率也更低。你需要设计更充分的引导文案,并且做好用户拒绝后的功能降级(例如,仅在前台扫描)。

6. 日志是好朋友在权限请求和蓝牙API调用的关键节点,打上详细的Log。特别是捕获SecurityException,并把堆栈信息打印出来。当线上用户反馈问题时,这些日志能帮你快速定位是否是权限问题。

try { bluetoothLeScanner.startScan(...) } catch (e: SecurityException) { Log.e(TAG, "SecurityException during startScan. Permissions granted: ", e) // 可以在这里检查具体是哪个权限缺失 val scanGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) val locGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) Log.d(TAG, "BLUETOOTH_SCAN granted: ${scanGranted == PERMISSION_GRANTED}") Log.d(TAG, "ACCESS_FINE_LOCATION granted: ${locGranted == PERMISSION_GRANTED}") }

最后,记得在发布前,仔细阅读谷歌官方的蓝牙权限指南,确保你的实现符合最新的政策和最佳实践。适配Android 12的蓝牙权限确实需要花些功夫,但一旦完成,你的应用在隐私保护和用户体验上都会上一个台阶,对于应用的长期健康发展是值得的。我在项目里完整走完这套流程后,用户关于“为什么需要位置权限”的客服投诉几乎降到了零,功能稳定性也提高了。

http://www.cnnetsun.cn/news/1243537.html

相关文章:

  • WuliArt Qwen-Image Turbo一文详解:BFloat16数值稳定性对文生图质量的影响
  • Z-Image-Turbo-rinaiqiao-huiyewunv保姆级教程:Streamlit容器边框设计与响应式布局技巧
  • 基于STM32的嵌入式拆弹游戏硬件设计与实现
  • 便携式NFC检测枪设计:RC522+ESP32-C3嵌入式实现
  • 2023电赛D题国一作品解析:基于MSP432E401Y的六种信号调制识别与高精度参数估计装置
  • RemoteCLIP:遥感领域的视觉语言基础模型及其多任务应用
  • 7. TI MSPM0L1306串口通信实战:基于SysConfig与中断的UART0收发配置详解
  • DownKyi:零基础轻松下载B站高清视频的开源工具
  • FunASR离线时间戳模型实战:从Docker镜像下载到Python客户端调通的避坑指南
  • 【深度学习】Paddle-Lite模型优化实战:从导出到NB格式转换全流程解析
  • 416. 分割等和子集
  • EU104芯片深度评测:无需晶振的UART扩展方案真的靠谱吗?(实测数据+功耗分析)
  • 告别手动排查!用ncdu可视化分析CentOS目录占用(附Docker/Jenkins专项清理指南)
  • OpenTelemetry实战指南——Kubernetes环境下的链路追踪自动化部署
  • 还在为Winget安装发愁?这款工具让Windows包管理部署效率提升90%
  • vue实战:基于快马平台快速构建整合pinia和vue router的商品管理系统
  • 罗技鼠标宏压枪脚本精准控制方案:从原理到实战的系统化配置指南
  • H3C无线网络优化实战指南:从信道调优到频谱导航
  • Python实战:5分钟搞定拉格朗日插值法(附完整代码)
  • Qwen-Image-2512-SDNQ MATLAB集成:科研数据可视化增强
  • 5个超实用的非参考图像质量评估工具:从BRISQUE到PIQE的实战指南
  • 智能标注革命:从繁琐测量到一键生成的工作流革新
  • ESP32-C61 AT命令实战:HTTP/TCP/SSL透传全栈解析
  • 互联网公开数据合规利用:为万象熔炉·丹青幻境构建领域知识库
  • Glyph-OCR效果对比:传统方法 vs Glyph,结果一目了然
  • 效率倍增:用快马AI一键生成模块化全球服务器监控面板
  • ESP32-S3中断矩阵详解:寄存器映射、NMI管理与状态查询
  • CODESYS任务类型全解析:从循环任务到外部事件的实战应用
  • Kook Zimage真实幻想Turbo开发者指南:自定义LoRA注入与权重清洗
  • Qwen3-TTS-12Hz-1.7B-CustomVoice在播客制作中的应用:自动化内容生成方案