Android 12蓝牙扫描权限适配:解决扫描不到设备问题

📅 2026/8/26 4:35:11
Android 12蓝牙扫描权限适配:解决扫描不到设备问题
1. 项目背景与问题现象最近在适配Android 12API 31及以上版本的蓝牙功能时一个非常典型且高频的“坑”出现了你的应用明明在Android 11上运行得好好的蓝牙扫描能发现一堆设备但一到Android 12的设备上扫描结果就空空如也日志里除了你自己的应用日志系统关于蓝牙发现的日志也少得可怜。这绝不是你的代码逻辑突然失效了而是Google在Android 12中引入了一套更严格的运行时权限模型特别是针对蓝牙扫描这一涉及位置信息的行为。这个问题困扰了相当多的开发者尤其是那些需要与蓝牙低功耗BLE设备或经典蓝牙设备交互的应用比如智能家居控制、健康设备数据同步、文件传输工具等。从用户和测试的角度看应用“失灵”了但从开发角度看这是一个必须跨越的权限门槛。我花了些时间把Android 12及更高版本上蓝牙扫描所需的权限梳理了一遍并记录了完整的排查和解决方案。如果你也遇到了“扫描不到设备”的灵异事件这篇笔记应该能帮你快速定位问题。2. Android 12蓝牙权限模型的核心变化要解决问题首先得理解Google为什么这么做以及具体改了哪里。在Android 12之前进行蓝牙扫描特别是BLE扫描主要需要两个权限ACCESS_FINE_LOCATION精确定位和BLUETOOTH_SCAN在Android 11引入。当时的逻辑是因为蓝牙扫描结果特别是BLE设备的信号强度RSSI可以被用来进行粗略的室内定位所以它被归类为一种“位置信息”访问需要位置权限。到了Android 12权限管理变得更加精细和严格。核心变化在于将“蓝牙扫描”这个行为本身从“位置信息”的范畴中部分剥离出来并强调了其“邻近设备”访问的特性。这带来了几个关键点2.1 新增的BLUETOOTH_SCAN权限成为绝对主角在Android 12API 31及以上版本BLUETOOTH_SCAN权限从之前的“普通”权限升级为“运行时”权限。这意味着必须声明在AndroidManifest.xml中必须声明此权限。必须动态申请在代码中必须在运行时向用户弹窗请求授权就像请求相机、位置权限一样。独立控制用户可以在系统设置中单独控制某个应用是否拥有“蓝牙扫描”的权限而不再完全与位置权限绑定。2.2 与位置权限的“脱钩”与“有条件关联”这是最容易让人困惑的地方。Android 12允许你的应用在不访问设备位置信息的前提下进行蓝牙扫描。这是通过BLUETOOTH_SCAN权限的android:usesPermissionFlags属性实现的。你可以在声明权限时添加neverForLocation标志。这样系统就会明白“这个应用扫描蓝牙只是为了连接设备不是为了获取位置。” 用户授予BLUETOOTH_SCAN权限时系统可能就不会再强制要求位置权限。但是“脱钩”是有条件的如果你的应用需要从扫描结果中获取位置信息例如你需要使用ScanResult中的Rssi信号强度或TimestampNanos等信息来进行三角定位或位置推断那么你仍然需要ACCESS_FINE_LOCATION权限。如果你的应用仅为了发现和连接设备例如连接一个蓝牙音箱、智能灯泡你只需要知道附近有这些设备并获取其地址MAC地址用于连接那么你可以声明neverForLocation标志从而可能避免申请位置权限。2.3 其他必要权限的巩固除了扫描连接和管理蓝牙设备还需要其他权限BLUETOOTH_CONNECT用于连接蓝牙设备、与已配对设备通信、访问设备信息等。这在Android 12也成为了运行时权限。BLUETOOTH_ADVERTISE如果你的应用自身要作为蓝牙外围设备广播例如让手机模拟一个Beacon则需要此权限。ACCESS_BACKGROUND_LOCATION如果你的应用需要在后台应用不可见时持续进行蓝牙扫描那么即使你声明了neverForLocation在Android 10及以上版本后台扫描行为本身就可能触发系统对后台位置访问的限制可能需要此权限。但情况较为复杂通常前台扫描足够。下表总结了不同场景下的权限需求操作场景Android 11及以前Android 12及以后 (声明neverForLocation)Android 12及以后 (需要位置信息)前台扫描发现BLE设备BLUETOOTH_SCAN(普通) ACCESS_FINE_LOCATION(运行时)BLUETOOTH_SCAN(运行时)BLUETOOTH_SCAN(运行时) ACCESS_FINE_LOCATION(运行时)连接已发现/已配对设备BLUETOOTH(普通) BLUETOOTH_ADMIN(普通)BLUETOOTH_CONNECT(运行时)BLUETOOTH_CONNECT(运行时)应用作为外围设备广播BLUETOOTH_ADVERTISE(普通)BLUETOOTH_ADVERTISE(运行时)BLUETOOTH_ADVERTISE(运行时)后台持续扫描上述权限 ACCESS_BACKGROUND_LOCATION(运行时)可能仍需ACCESS_BACKGROUND_LOCATION取决于扫描结果用途ACCESS_BACKGROUND_LOCATION(运行时)3. 完整解决方案与代码实现理解了原理我们来看具体怎么做。这里以一个典型的“扫描并连接BLE设备”的应用为例假设我们不需要位置信息仅为了设备连接。3.1 AndroidManifest.xml 权限声明这是第一步也是最容易出错的一步。声明必须准确无误。manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.mybluetoothapp !-- 对于 Android 12 (API 31) 蓝牙扫描声明不用于定位 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / !-- 对于 Android 12 (API 31) 蓝牙连接 -- uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / !-- 对于 Android 6.0 到 Android 11 (API 23-30) 蓝牙扫描需要的位置权限 -- !-- 注意即使你的 targetSdkVersion 31为了兼容旧设备最好也加上 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION / !-- 如果需要后台扫描则还需要这个谨慎使用 -- !-- uses-permission android:nameandroid.permission.ACCESS_BACKGROUND_LOCATION / -- !-- 传统蓝牙权限用于 Android 11 及以下 -- uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / !-- 如果只针对 BLE 设备可以声明此 feature -- uses-feature android:nameandroid.hardware.bluetooth_le android:requiredtrue/ application ... ... /application /manifest关键点解析android:usesPermissionFlagsneverForLocation这是Android 12权限声明的精髓。它明确告知系统你申请BLUETOOTH_SCAN权限的目的不是为了获取位置。这能带来两个好处1) 在Android 12设备上系统可能不会强制要求你同时拥有位置权限2) 在申请弹窗中向用户说明的理由会更清晰例如“允许应用发现附近的设备”而不是“允许应用访问位置”。兼容性声明即使targetSdkVersion设为31或更高你仍然需要声明ACCESS_FINE_LOCATION。因为当你的应用安装在Android 11API 30的设备上时系统会忽略BLUETOOTH_SCAN的声明因为该系统版本不存在此权限转而检查ACCESS_FINE_LOCATION。这是一种标准的向后兼容做法。BLUETOOTH和BLUETOOTH_ADMIN在旧版本系统上这些是必需的。即使在新系统上声明它们也无害系统会忽略已废弃的权限。3.2 运行时权限动态申请声明了权限接下来就是在合适的时机例如进入扫描界面时向用户申请。我们需要处理Android 12的新权限和旧版本的位置权限。import android.Manifest import android.bluetooth.BluetoothAdapter import android.content.pm.PackageManager import android.os.Build import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat class BluetoothScanActivity : AppCompatActivity() { private lateinit var bluetoothAdapter: BluetoothAdapter // 使用 Activity Result API 简化权限请求回调处理 private val requestPermissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions - val allGranted permissions.entries.all { it.value } if (allGranted) { // 所有必要权限都已授予可以开始扫描 startBluetoothScan() } else { // 有权限被拒绝向用户解释为什么需要这些权限 showPermissionDeniedDialog() } } override fun onStart() { super.onStart() checkAndRequestPermissions() } private fun checkAndRequestPermissions() { val permissionsToRequest mutableListOfString() // 检查蓝牙硬件支持 bluetoothAdapter BluetoothAdapter.getDefaultAdapter() ?: run { showToast(该设备不支持蓝牙) return } // 1. 检查并添加 Android 12 (API 31) 的权限 if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { // BLUETOOTH_SCAN 权限用于发现设备 if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) ! PackageManager.PERMISSION_GRANTED ) { permissionsToRequest.add(Manifest.permission.BLUETOOTH_SCAN) } // BLUETOOTH_CONNECT 权限用于连接设备如果你在扫描后需要连接 if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) ! PackageManager.PERMISSION_GRANTED ) { permissionsToRequest.add(Manifest.permission.BLUETOOTH_CONNECT) } } else { // 2. 检查并添加 Android 6.0 到 Android 11 (API 23-30) 的位置权限 // 注意在 Android 11 上即使声明了 BLUETOOTH_SCAN普通权限扫描 BLE 仍需位置权限 if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED ) { permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION) } } // 3. 对于所有版本检查蓝牙开关这不是权限但必须开启 if (!bluetoothAdapter.isEnabled) { // 可以引导用户打开蓝牙这里使用一个简单的提示 showEnableBluetoothDialog() return // 先不请求权限等蓝牙打开再说 } // 4. 如果有需要申请的权限则发起请求 if (permissionsToRequest.isNotEmpty()) { requestPermissionLauncher.launch(permissionsToRequest.toTypedArray()) } else { // 所有权限都已具备蓝牙已开启直接开始扫描 startBluetoothScan() } } private fun startBluetoothScan() { // 这里实现你的蓝牙扫描逻辑例如使用 BluetoothLeScanner // val scanner bluetoothAdapter.bluetoothLeScanner // val settings ScanSettings.Builder().build() // val filters listOfScanFilter() // 可以添加过滤条件 // scanner.startScan(filters, settings, scanCallback) showToast(权限检查通过开始扫描...) // ... 实际扫描代码 } // 辅助方法显示对话框等UI交互 private fun showEnableBluetoothDialog() { /* ... */ } private fun showPermissionDeniedDialog() { /* ... */ } private fun showToast(message: String) { /* ... */ } }代码逻辑拆解版本判断是核心我们首先根据Build.VERSION.SDK_INT判断设备系统版本。对于Android 12S/API 31及以上我们动态申请BLUETOOTH_SCAN和BLUETOOTH_CONNECT。对于Android 11及以下我们申请ACCESS_FINE_LOCATION。权限检查前置在申请前先用ContextCompat.checkSelfPermission检查权限是否已授予。只申请未被授予的权限避免不必要的弹窗打扰用户。蓝牙开关检查无论权限如何蓝牙硬件必须开启。这是一个独立于权限的系统状态检查。通常我们会先确保蓝牙开启再处理权限问题。使用现代APIregisterForActivityResult配合ActivityResultContracts.RequestMultiplePermissions是处理运行时权限的最佳实践它避免了重写onRequestPermissionsResult方法的繁琐使代码更清晰。优雅降级如果用户拒绝了权限应该有一个友好的界面showPermissionDeniedDialog向用户解释这些权限对于应用核心功能如连接智能设备的必要性并引导用户去应用设置页面手动开启。3.3 扫描逻辑的实现与适配权限搞定后扫描逻辑本身也需要针对Android 12进行微调。主要是ScanSettings的配置。import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanFilter import android.bluetooth.le.ScanResult import android.bluetooth.le.ScanSettings import android.os.Build import android.os.Handler import android.os.Looper private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { // 处理扫描到的设备 val device result.device val rssi result.rssi // 注意在声明了 neverForLocation 后此值可能为0 val scanRecord result.scanRecord // ... 更新UI将设备添加到列表 } override fun onScanFailed(errorCode: Int) { // 扫描失败处理常见错误码如 SCAN_FAILED_APPLICATION_REGISTRATION_FAILED应用注册失败通常权限问题 // 或 SCAN_FAILED_FEATURE_UNSUPPORTED不支持BLE等 showToast(扫描失败错误码: $errorCode) } } private fun buildScanSettings(): ScanSettings { val builder ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) // 扫描模式低延迟发现设备最快 // Android 12 的重要适配如果你声明了 neverForLocation且不需要RSSI可以设置此标志 if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { // 这个标志告诉硬件可以为了省电而降低扫描报告频率因为我们不关心精确的RSSI // 注意设置此标志后onScanResult回调的频率可能会降低且RSSI可能不准确或为0 builder.setLegacy(false) // 对于只关心新BLE设备的情况可以设为false // 如果你确实需要RSSI做信号筛选就不要加下面这行 // builder.setPhy(ScanSettings.PHY_LE_ALL_SUPPORTED) } return builder.build() } fun startScan() { val scanner bluetoothAdapter.bluetoothLeScanner val filters listOfScanFilter() // 可以按设备名、服务UUID等过滤提高效率 val settings buildScanSettings() // 开始扫描 scanner.startScan(filters, settings, scanCallback) showToast(扫描已启动) // 可选扫描10秒后自动停止节省电量 Handler(Looper.getMainLooper()).postDelayed({ stopScan() }, 10000) } fun stopScan() { bluetoothAdapter.bluetoothLeScanner?.stopScan(scanCallback) showToast(扫描已停止) }关键点与避坑指南setLegacy(false)在Android 12上这个设置与neverForLocation标志协同工作。它告诉底层蓝牙栈你不需要为了向后兼容旧版扫描报告格式而做额外处理。对于只针对Android 5.0以上新BLE规范的应用设置为false可能更高效。但如果你需要扫描一些旧设备或遇到扫描问题可以尝试设为true或移除这行。RSSI值可能为0这是声明neverForLocation后一个非常重要的副作用。系统或硬件为了保护位置隐私可能会在扫描结果中屏蔽或归零RSSI值。如果你的逻辑依赖RSSI来筛选信号强的设备比如只连接-70dBm以上的设备那么你就不能声明neverForLocation必须同时申请位置权限。扫描过滤使用ScanFilter能极大提升扫描效率减少不必要的回调和处理。例如如果你只寻找特定服务UUID的设备务必加上过滤器。4. 问题排查与深度调试即使按照上面的步骤做了有时候扫描依然不工作。这时候就需要系统化的排查。以下是我总结的排查清单按照从外到内、从易到难的顺序进行。4.1 基础环境检查设备蓝牙是否真的开启通过系统设置确认而不仅仅是代码检查。有时代码检查返回true但硬件或驱动层可能有问题。目标蓝牙设备是否处于可发现模式BLE设备通常需要处于广播状态经典蓝牙设备需要处于“可被检测”状态。物理距离和干扰将设备靠近1米内排除信号弱的问题。远离Wi-Fi路由器、微波炉等2.4GHz干扰源。其他应用能否扫描到使用“nRF Connect”、“LightBlue”等通用的蓝牙调试APP测试。如果它们也扫不到很可能是目标设备或手机蓝牙硬件的问题。如果它们能扫到而你的应用不能问题就在你的应用上。4.2 应用权限与配置检查检查AndroidManifest.xml确保权限声明没有拼写错误uses-permission标签在正确的层级。特别注意android:usesPermissionFlagsneverForLocation是否只附加在BLUETOOTH_SCAN上。检查targetSdkVersion在app/build.gradle中确保targetSdkVersion至少为31如果你要适配Android 12的新权限模型。如果targetSdkVersion低于31系统会使用旧版的权限规则但你又声明了新权限可能导致行为不一致。动态权限状态复查在申请权限的回调中打印或记录所有请求权限的授予状态。使用ActivityResultContracts.RequestMultiplePermissions返回的MapString, Boolean来确认。private val requestPermissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions - permissions.entries.forEach { entry - Log.d(PermissionDebug, ${entry.key} : ${entry.value}) } // ... 后续处理 }检查是否在后台从Android 10开始对后台应用获取位置信息包括通过蓝牙扫描间接获取有严格限制。确保你的扫描代码是在应用处于前台Activity可见时执行的。如果你需要在后台扫描那将是一个更复杂的课题涉及前台服务、后台位置权限和更严格的审核。4.3 日志与系统信息分析当基础检查都通过后就需要深入系统内部看日志。查看应用日志过滤你的应用Tag检查是否有SecurityException或类似“Permission Denial”的异常抛出。查看系统蓝牙日志这是最有效的手段。使用adb logcat命令过滤蓝牙相关的系统日志。adb logcat | grep -E (Bluetooth|BT|BLE|ScanManager|AdapterService)或者更精确地在Android Studio的Logcat中选择设备后添加过滤器tag:Bluetooth或tag:BluetoothAdapter。重点观察以下日志当你调用startScan()时是否有类似App your.package.name is not allowed to use Bluetooth或Scan failed, app uid XXXX not allowed to scan的错误。这直接指向权限问题。是否有Scanning too frequent之类的警告Android对扫描频率有限制过于频繁的启停扫描会被系统限制。检查系统权限设置页面手动进入手机的设置 应用 你的应用 权限查看“附近设备”对应BLUETOOTH_SCAN和“位置信息”权限是否确认为“允许”。注意在Android 13位置权限可能细分为“仅在使用中允许”和“始终允许”确保符合你的场景。4.4 代码逻辑与API使用复查扫描回调是否注册成功确保startScan被调用并且传入的scanCallback对象是有效的、未被垃圾回收的例如作为Activity的成员变量持有。扫描设置是否过于苛刻检查ScanSettings比如setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)在低功耗模式下发现设备会很慢。对于测试建议使用SCAN_MODE_LOW_LATENCY。过滤器是否过滤掉了所有设备如果你使用了ScanFilter检查过滤条件如设备名称、MAC地址、服务UUID是否写错导致所有设备都被过滤掉。尝试不设过滤器或设置一个宽泛的过滤器进行测试。是否在扫描过程中停止了蓝牙适配器确保在扫描生命周期内从startScan到stopScanBluetoothAdapter对象是有效的并且蓝牙没有在中间被系统或用户关闭。4.5 针对特定机型或系统的特殊处理有些厂商如小米、华为、OPPO、Vivo的定制系统MIUI、EMUI等有更激进的权限管理或后台限制。自启动管理在部分系统上即使授予了所有权限应用如果被“禁止自启动”或“关联启动”后台扫描可能被杀死。需要引导用户手动在手机管家中设置。省电策略检查是否将你的应用加入了“电池优化”的白名单。在设置中搜索“电池优化”找到你的应用选择“不优化”。悬浮窗权限等虽然与蓝牙扫描无直接关系但某些系统会将这些非常规权限与后台活动关联导致应用行为异常。如果排查所有可能性后仍无效可以查阅该机型开发者的官方论坛或社区看是否有已知的特定限制。5. 进阶考量与最佳实践解决了基本扫描问题后为了构建一个健壮的蓝牙应用还需要考虑以下几点。5.1 后台扫描的复杂性如前所述在后台进行蓝牙扫描是一个“深水区”。从Android 10开始后台应用访问位置信息受到严格限制。即使你声明了neverForLocation持续的后台扫描行为本身也可能被系统视为潜在的位置访问行为从而触发限制。建议尽量避免持续后台扫描这是最省心省电的做法。考虑使用以下替代方案前台服务当应用需要长时间扫描时启动一个带有持续通知的前台服务。这明确告知用户应用正在活动并通常能获得更稳定的系统资源。定时扫描使用WorkManager或AlarmManager安排定期、短暂的扫描任务而不是7x24小时不间断扫描。使用系统广播有限对于已配对设备的连接状态变化可以监听BluetoothDevice.ACTION_ACL_CONNECTED和ACTION_ACL_DISCONNECTED广播但这无法发现新设备。如果必须后台扫描确保申请了ACCESS_BACKGROUND_LOCATION权限这是一个敏感权限上架Google Play需要额外声明和审核。在应用商店的描述中清晰说明为什么需要此权限。准备处理用户拒绝该权限的情况并提供优雅降级方案例如仅支持前台扫描功能。5.2 权限申请的时机与用户体验不要一进入应用就弹出一堆权限请求这会让用户反感。适时请求在用户即将使用需要该权限的功能时再请求。例如在用户点击“扫描设备”按钮后再执行权限检查和申请流程。解释必要性在请求权限前尤其是BLUETOOTH_SCAN和位置权限用一个简单的对话框或界面文字向用户解释“我们需要扫描附近蓝牙设备的权限以便为您连接智能音箱/手环。” 这能显著提高授权率。处理“不再询问”如果用户拒绝了权限并勾选了“不再询问”下次shouldShowRequestPermissionRationale()会返回false。此时你应该引导用户前往系统设置页面手动开启权限。提供一个清晰的指引按钮和说明文字。5.3 兼容旧版本Android的优雅处理你的应用可能需要支持Android 11甚至更早的版本。权限代码需要做良好的分支处理。private fun checkPermissions(): ListString { val neededPermissions mutableListOfString() if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { // Android 12 if (!hasPermission(Manifest.permission.BLUETOOTH_SCAN)) { neededPermissions.add(Manifest.permission.BLUETOOTH_SCAN) } if (!hasPermission(Manifest.permission.BLUETOOTH_CONNECT)) { neededPermissions.add(Manifest.permission.BLUETOOTH_CONNECT) } // 注意在声明了 neverForLocation 后可以不主动请求位置权限 // 但如果功能需要RSSI则仍需检查并请求 // if (needLocationForScan !hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { // neededPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION) // } } else { // Android 6.0 - 11 if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) { neededPermissions.add(Manifest.permission.ACCESS_FINE_LOCATION) } // Android 12以下BLUETOOTH 和 BLUETOOTH_ADMIN 是普通权限无需运行时申请 // 但需要在 Manifest 中声明 } return neededPermissions } private fun hasPermission(permission: String): Boolean { return ContextCompat.checkSelfPermission(this, permission) PackageManager.PERMISSION_GRANTED }这种结构清晰的版本判断使得权限逻辑一目了然便于维护。5.4 测试策略蓝牙开发测试至关重要。准备多版本测试机至少准备一台Android 12和一台Android 11的设备进行测试。模拟权限拒绝场景在开发者选项中可以手动撤销应用的具体权限测试应用在权限缺失时的表现是否崩溃以及引导用户开启权限的流程是否顺畅。使用模拟器谨慎Android模拟器对蓝牙的支持有限尤其是低功耗蓝牙。大部分模拟器无法模拟真实的蓝牙扫描。真机测试是唯一可靠的方式。日志是朋友养成在关键节点如权限检查前后、扫描开始/停止、回调触发时打Log的习惯并熟练使用adb logcat查看系统蓝牙日志。这是定位疑难杂症的最强武器。我在实际项目中正是通过系统日志发现了一条关键的Permission denial for startLeScan日志才最终锁定是Android 12上新权限模型导致的问题。从那时起我就把权限检查和适配作为蓝牙功能开发的第一步再也没在这个坑里摔倒过。蓝牙开发本身就有很多坑从协议到硬件兼容性权限问题只是第一道关卡但跨过它你的应用才算是拿到了入场券。希望这篇详细的笔记能帮你节省大量排查时间。