牙權(quán)限適配指南:解決BLUETOOTH_SCAN失效與后臺(tái)掃描限制)
1. 項(xiàng)目概述當(dāng)藍(lán)牙掃描在Android 12上“失靈”如果你最近將你的Android應(yīng)用升級(jí)到以Android 12API級(jí)別31為目標(biāo)版本或者你的用戶設(shè)備升級(jí)到了Android 12突然發(fā)現(xiàn)一個(gè)老功能——藍(lán)牙設(shè)備掃描——不工作了那么你絕對(duì)不是一個(gè)人。這個(gè)看似簡(jiǎn)單的功能背后是Android權(quán)限模型又一次重大的演進(jìn)。過(guò)去在Android 6.0之后我們習(xí)慣了在運(yùn)行時(shí)請(qǐng)求危險(xiǎn)權(quán)限比如ACCESS_FINE_LOCATION來(lái)讓藍(lán)牙掃描正常工作。但到了Android 12谷歌引入了更細(xì)粒度的藍(lán)牙權(quán)限控制新增了BLUETOOTH_SCAN、BLUETOOTH_ADVERTISE和BLUETOOTH_CONNECT三個(gè)權(quán)限徹底改變了游戲規(guī)則。很多開(kāi)發(fā)者包括經(jīng)驗(yàn)豐富的我都在這里踩了坑明明代碼沒(méi)變權(quán)限也申請(qǐng)了但BluetoothAdapter.startDiscovery()或者BluetoothLeScanner.startScan()就是返回空列表或者直接失敗。這篇筆記就是記錄我如何從“一臉懵”到徹底搞懂并解決這個(gè)問(wèn)題的全過(guò)程涵蓋了從權(quán)限聲明、運(yùn)行時(shí)請(qǐng)求到后臺(tái)掃描限制的所有核心細(xì)節(jié)。2. Android 12藍(lán)牙權(quán)限體系深度解析Android 12的藍(lán)牙權(quán)限改革核心思想是“最小權(quán)限原則”和“用戶隱私保護(hù)”的進(jìn)一步落地。它將過(guò)去相對(duì)粗放的藍(lán)牙操作權(quán)限拆解成了意圖更明確、控制更精細(xì)的三個(gè)獨(dú)立權(quán)限。理解它們各自的職責(zé)是解決問(wèn)題的第一步。2.1 新舊權(quán)限體系對(duì)比與映射在Android 11API 30及之前進(jìn)行藍(lán)牙掃描特別是BLE掃描通常需要兩個(gè)權(quán)限BLUETOOTH和BLUETOOTH_ADMIN這兩個(gè)是普通權(quán)限在AndroidManifest.xml中聲明即可用于基礎(chǔ)的藍(lán)牙操作和管理。ACCESS_FINE_LOCATION這是一個(gè)危險(xiǎn)權(quán)限因?yàn)橥ㄟ^(guò)掃描藍(lán)牙信號(hào)特別是BLE信標(biāo)可以推斷設(shè)備的地理位置。所以從Android 6.0開(kāi)始掃描藍(lán)牙設(shè)備需要你在運(yùn)行時(shí)向用戶申請(qǐng)這個(gè)位置權(quán)限。到了Android 12情況變得復(fù)雜也清晰了BLUETOOTH_SCAN這是本次問(wèn)題的核心。它取代了ACCESS_FINE_LOCATION在掃描場(chǎng)景下的作用。任何希望發(fā)現(xiàn)周邊藍(lán)牙設(shè)備包括經(jīng)典藍(lán)牙和BLE的應(yīng)用都必須申請(qǐng)此權(quán)限。BLUETOOTH_CONNECT用于連接已經(jīng)配對(duì)或發(fā)現(xiàn)的藍(lán)牙設(shè)備例如連接耳機(jī)、向設(shè)備寫(xiě)入數(shù)據(jù)等。BLUETOOTH_ADVERTISE用于讓本設(shè)備作為外圍設(shè)備廣播自身被其他設(shè)備發(fā)現(xiàn)。對(duì)于需要向后兼容的應(yīng)用即targetSdkVersion 31但在Android 12設(shè)備上運(yùn)行系統(tǒng)會(huì)嘗試進(jìn)行權(quán)限映射。但最可靠的方式還是主動(dòng)適配新的權(quán)限體系。注意ACCESS_FINE_LOCATION權(quán)限在Android 12上對(duì)于藍(lán)牙掃描依然可能被需要但前提是你的應(yīng)用在聲明BLUETOOTH_SCAN權(quán)限時(shí)沒(méi)有添加android:usesPermissionFlagsneverForLocation屬性。如果你掃描的目的是為了獲取位置如室內(nèi)導(dǎo)航你就需要它如果只是為了連接設(shè)備如智能硬件你可以聲明不需要位置信息從而避免申請(qǐng)位置權(quán)限提升用戶通過(guò)率。2.2BLUETOOTH_SCAN權(quán)限的特殊屬性與FlagsBLUETOOTH_SCAN不是一個(gè)簡(jiǎn)單的危險(xiǎn)權(quán)限。它有幾個(gè)關(guān)鍵屬性必須在AndroidManifest.xml中聲明清楚android:permissionFlags這個(gè)屬性至關(guān)重要。neverForLocation如果你能向系統(tǒng)和用戶證明你的應(yīng)用掃描藍(lán)牙設(shè)備不是為了獲取地理位置例如你只是為了連接一個(gè)已知的智能燈泡或體重秤那么你應(yīng)該加上這個(gè)標(biāo)志。加上后你的應(yīng)用將不再需要ACCESS_FINE_LOCATION權(quán)限。這是符合谷歌隱私政策的最佳實(shí)踐。聲明示例uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation /maxSdkVersion為了優(yōu)雅地處理向后兼容性你通常需要為舊權(quán)限設(shè)置最高SDK版本。對(duì)于ACCESS_FINE_LOCATION如果你使用了neverForLocation標(biāo)志理論上在Android 12上就不需要它了。但為了兼容Android 11及以下的設(shè)備你仍然需要聲明它并限制其最高版本。聲明示例uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 /這意味著在Android 12API 31及以上的設(shè)備上系統(tǒng)會(huì)忽略這個(gè)權(quán)限聲明。2.3 后臺(tái)掃描限制另一個(gè)潛在的“坑”即使你正確聲明和請(qǐng)求了BLUETOOTH_SCAN權(quán)限在Android 12上藍(lán)牙掃描行為還受到應(yīng)用可見(jiàn)性App Visibility和后臺(tái)模式的嚴(yán)格限制。前臺(tái)服務(wù)要求如果你的應(yīng)用在后臺(tái)例如沒(méi)有可見(jiàn)的Activity執(zhí)行藍(lán)牙掃描必須要有一個(gè)帶有foregroundServiceTypeconnectedDevice的前臺(tái)服務(wù)正在運(yùn)行。這是Android 10引入、在后續(xù)版本中強(qiáng)化的限制。沒(méi)有前臺(tái)服務(wù)時(shí)的行為如果沒(méi)有正確的前臺(tái)服務(wù)即使擁有BLUETOOTH_SCAN權(quán)限后臺(tái)掃描也可能掃描結(jié)果回調(diào)延遲極大。掃描不到任何設(shè)備。在onScanFailed回調(diào)中收到錯(cuò)誤碼SCAN_FAILED_APPLICATION_REGISTRATION_FAILED。因此一個(gè)健壯的藍(lán)牙掃描功能需要同時(shí)處理好權(quán)限和運(yùn)行時(shí)狀態(tài)前臺(tái)/后臺(tái)兩套邏輯。3. 完整解決方案與代碼實(shí)操理論講清楚了我們來(lái)看具體怎么改。假設(shè)我們有一個(gè)簡(jiǎn)單的BLE掃描應(yīng)用需要適配Android 12。3.1 第一步修正 AndroidManifest.xml這是所有工作的基礎(chǔ)必須準(zhǔn)確無(wú)誤。?xml version1.0 encodingutf-8? manifest xmlns:androidhttp://schemas.android.com/apk/res/android packagecom.example.bleapp !-- 對(duì)于 Android 12 (API 31) 的藍(lán)牙掃描權(quán)限 -- !-- 添加 neverForLocation 標(biāo)志表明我們掃描不是為了獲取地理位置 -- uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / !-- 對(duì)于 Android 12 的藍(lán)牙連接權(quán)限如果需要連接設(shè)備 -- uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / !-- 兼容 Android 6.0 到 11 的位置權(quán)限并限制最高版本 -- !-- maxSdkVersion30 意味著此權(quán)限在 Android 12 設(shè)備上不會(huì)被請(qǐng)求 -- uses-permission android:nameandroid.permission.ACCESS_FINE_LOCATION android:maxSdkVersion30 / !-- 舊版藍(lán)牙管理權(quán)限普通權(quán)限聲明即可 -- uses-permission android:nameandroid.permission.BLUETOOTH / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN / !-- 如果需要在后臺(tái)掃描必須聲明前臺(tái)服務(wù)類型 -- uses-permission android:nameandroid.permission.FOREGROUND_SERVICE / application ... !-- 后臺(tái)藍(lán)牙掃描所需的前臺(tái)服務(wù)聲明 -- service android:name.BluetoothScanForegroundService android:foregroundServiceTypeconnectedDevice android:exportedfalse / ... /application /manifest3.2 第二步實(shí)現(xiàn)動(dòng)態(tài)權(quán)限請(qǐng)求邏輯在你的Activity或Fragment中你需要編寫(xiě)邏輯來(lái)判斷設(shè)備版本并請(qǐng)求正確的權(quán)限。import android.Manifest import android.bluetooth.BluetoothAdapter import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanResult 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 MainActivity : AppCompatActivity() { private lateinit var bluetoothAdapter: BluetoothAdapter private lateinit var bluetoothLeScanner: BluetoothLeScanner private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { // 處理掃描到的設(shè)備 val device result.device val rssi result.rssi // ... 更新UI } override fun onScanFailed(errorCode: Int) { // 掃描失敗處理 when (errorCode) { ScanCallback.SCAN_FAILED_APPLICATION_REGISTRATION_FAILED - { // 常見(jiàn)于后臺(tái)掃描沒(méi)有前臺(tái)服務(wù)或權(quán)限問(wèn)題 } ScanCallback.SCAN_FAILED_FEATURE_UNSUPPORTED - {} ScanCallback.SCAN_FAILED_INTERNAL_ERROR - {} ScanCallback.SCAN_FAILED_ALREADY_STARTED - {} } } } // 使用 Activity Result API 請(qǐng)求權(quán)限 private val requestPermissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions - val allGranted permissions.all { it.value } if (allGranted) { // 所有必要權(quán)限都已授予開(kāi)始掃描 startBluetoothScan() } else { // 向用戶解釋為什么需要這些權(quán)限 showPermissionDeniedDialog() } } private fun checkAndRequestPermissions() { val permissionsToRequest mutableListOfString() // 檢查并添加 BLUETOOTH_SCAN 權(quán)限 (Android 12) if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_SCAN) ! PackageManager.PERMISSION_GRANTED) { permissionsToRequest.add(Manifest.permission.BLUETOOTH_SCAN) } // 如果需要連接設(shè)備還需要 BLUETOOTH_CONNECT if (ContextCompat.checkSelfPermission(this, Manifest.permission.BLUETOOTH_CONNECT) ! PackageManager.PERMISSION_GRANTED) { permissionsToRequest.add(Manifest.permission.BLUETOOTH_CONNECT) } } else { // Android 11 及以下需要位置權(quán)限 if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ! PackageManager.PERMISSION_GRANTED) { permissionsToRequest.add(Manifest.permission.ACCESS_FINE_LOCATION) } } // 檢查藍(lán)牙開(kāi)關(guān)狀態(tài)這是一個(gè)運(yùn)行時(shí)狀態(tài)不是權(quán)限 bluetoothAdapter BluetoothAdapter.getDefaultAdapter() if (bluetoothAdapter null || !bluetoothAdapter.isEnabled) { // 提示用戶打開(kāi)藍(lán)牙 val enableBtIntent Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE) startActivityForResult(enableBtIntent, REQUEST_ENABLE_BT) return } if (permissionsToRequest.isNotEmpty()) { // 請(qǐng)求缺失的權(quán)限 requestPermissionLauncher.launch(permissionsToRequest.toTypedArray()) } else { // 已有所有權(quán)限直接開(kāi)始掃描 startBluetoothScan() } } private fun startBluetoothScan() { bluetoothLeScanner bluetoothAdapter.bluetoothLeScanner val scanSettings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) .build() // 可以添加 ScanFilter 來(lái)過(guò)濾特定設(shè)備 val filters listOfScanFilter() // 為空則掃描所有設(shè)備 // 檢查是否在后臺(tái)。如果是需要確保前臺(tái)服務(wù)已啟動(dòng)。 if (isAppInBackground()) { // 啟動(dòng)一個(gè)帶有 connectedDevice 類型的前臺(tái)服務(wù) val serviceIntent Intent(this, BluetoothScanForegroundService::class.java) if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { startForegroundService(serviceIntent) } else { startService(serviceIntent) } // 通常前臺(tái)服務(wù)內(nèi)部會(huì)調(diào)用 startScan } else { // 在前臺(tái)直接掃描 bluetoothLeScanner.startScan(filters, scanSettings, scanCallback) } } // ... 其他代碼如停止掃描、處理生命周期等 }3.3 第三步處理后臺(tái)掃描前臺(tái)服務(wù)實(shí)現(xiàn)BluetoothScanForegroundService.kt示例import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.Service import android.bluetooth.le.BluetoothLeScanner import android.bluetooth.le.ScanCallback import android.bluetooth.le.ScanResult import android.content.Intent import android.os.Build import android.os.IBinder class BluetoothScanForegroundService : Service() { private lateinit var bluetoothLeScanner: BluetoothLeScanner private val scanCallback object : ScanCallback() { override fun onScanResult(callbackType: Int, result: ScanResult) { // 后臺(tái)處理掃描結(jié)果例如存入數(shù)據(jù)庫(kù)或通過(guò) WorkManager 上傳 } } override fun onCreate() { super.onCreate() val bluetoothAdapter BluetoothAdapter.getDefaultAdapter() bluetoothLeScanner bluetoothAdapter.bluetoothLeScanner startForegroundScan() } private fun startForegroundScan() { // 1. 創(chuàng)建通知渠道 (Android O及以上) if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { val channelId bluetooth_scan_channel val channelName 藍(lán)牙掃描服務(wù) val importance NotificationManager.IMPORTANCE_LOW val channel NotificationChannel(channelId, channelName, importance).apply { description 用于保持后臺(tái)藍(lán)牙設(shè)備掃描 } val notificationManager getSystemService(NotificationManager::class.java) notificationManager.createNotificationChannel(channel) } // 2. 構(gòu)建通知 val notification if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { Notification.Builder(this, bluetooth_scan_channel) } else { Notification.Builder(this) }.apply { setContentTitle(藍(lán)牙設(shè)備掃描中) setContentText(正在搜索附近的藍(lán)牙設(shè)備...) setSmallIcon(android.R.drawable.ic_dialog_info) // 使用你自己的圖標(biāo) setOngoing(true) }.build() // 3. 啟動(dòng)前臺(tái)服務(wù) startForeground(1, notification) // 4. 開(kāi)始藍(lán)牙掃描 val filters listOfScanFilter() val scanSettings ScanSettings.Builder() .setScanMode(ScanSettings.SCAN_MODE_LOW_POWER) // 后臺(tái)建議低功耗模式 .build() bluetoothLeScanner.startScan(filters, scanSettings, scanCallback) } override fun onDestroy() { bluetoothLeScanner.stopScan(scanCallback) super.onDestroy() } override fun onBind(intent: Intent?): IBinder? null }4. 常見(jiàn)問(wèn)題排查與實(shí)戰(zhàn)心得即使代碼看起來(lái)都對(duì)了在實(shí)際調(diào)試中依然會(huì)遇到各種“妖魔鬼怪”。下面是我在真機(jī)調(diào)試和用戶反饋中收集的典型問(wèn)題及解決方法。4.1 問(wèn)題速查表現(xiàn)象可能原因排查步驟與解決方案掃描回調(diào)onScanResult從未被調(diào)用1.BLUETOOTH_SCAN權(quán)限未在Manifest中聲明或聲明錯(cuò)誤。2. 權(quán)限未在運(yùn)行時(shí)授予。3. 應(yīng)用在后臺(tái)且未啟動(dòng)正確的前臺(tái)服務(wù)。4. 設(shè)備藍(lán)牙未開(kāi)啟。1. 檢查AndroidManifest.xml確保uses-permission android:nameandroid.permission.BLUETOOTH_SCAN ... /存在且正確。2. 在設(shè)置中查看應(yīng)用權(quán)限列表確認(rèn)BLUETOOTH_SCAN和BLUETOOTH_CONNECT若需要已開(kāi)啟。3. 使用adb shell dumpsys package your.package.name命令查看應(yīng)用權(quán)限狀態(tài)。4. 確保藍(lán)牙已打開(kāi)并嘗試掃描系統(tǒng)藍(lán)牙設(shè)置中能看到的設(shè)備。onScanFailed回調(diào)返回SCAN_FAILED_APPLICATION_REGISTRATION_FAILED這是Android 12后臺(tái)掃描的典型錯(cuò)誤。應(yīng)用在后臺(tái)狀態(tài)嘗試掃描但沒(méi)有一個(gè)活躍的、類型為connectedDevice的前臺(tái)服務(wù)。1. 確保你的掃描代碼在應(yīng)用退到后臺(tái)時(shí)已經(jīng)啟動(dòng)了一個(gè)前臺(tái)服務(wù)。2. 檢查服務(wù)聲明中的android:foregroundServiceTypeconnectedDevice。3. 檢查通知渠道和通知是否成功創(chuàng)建并顯示。在Android 11設(shè)備上工作正常升級(jí)到Android 12后掃描不到設(shè)備1. 應(yīng)用targetSdkVersion已升級(jí)到31但未適配新權(quán)限。2. 用戶升級(jí)系統(tǒng)后應(yīng)用權(quán)限被重置或需要重新授權(quán)。1. 確認(rèn)應(yīng)用已按照上文正確聲明和請(qǐng)求BLUETOOTH_SCAN權(quán)限。2. 引導(dǎo)用戶到系統(tǒng)設(shè)置中手動(dòng)為你的應(yīng)用開(kāi)啟“附近設(shè)備”權(quán)限在某些廠商定制UI中BLUETOOTH_SCAN可能被歸類于此。3. 在代碼中增加權(quán)限檢查如果未授權(quán)則再次引導(dǎo)用戶。能掃描到部分設(shè)備但掃描不到目標(biāo)設(shè)備1. 目標(biāo)設(shè)備未處于可發(fā)現(xiàn)模式如BLE未廣播。2. 掃描設(shè)置ScanSettings問(wèn)題例如掃描模式過(guò)于保守。3. 手機(jī)系統(tǒng)或目標(biāo)設(shè)備存在省電策略限制。1. 確認(rèn)目標(biāo)設(shè)備正常工作并處于廣播狀態(tài)。可以用其他藍(lán)牙掃描App如nRF Connect交叉驗(yàn)證。2. 嘗試使用ScanSettings.SCAN_MODE_LOW_LATENCY高功耗模式進(jìn)行測(cè)試。3. 關(guān)閉手機(jī)的省電模式并將你的應(yīng)用加入電池優(yōu)化白名單。權(quán)限彈窗顯示混亂或請(qǐng)求了不需要的權(quán)限1.AndroidManifest.xml中權(quán)限聲明冗余或沖突。2. 動(dòng)態(tài)請(qǐng)求權(quán)限的列表邏輯有誤。1. 清理Manifest移除不必要的權(quán)限聲明。確保ACCESS_FINE_LOCATION有android:maxSdkVersion30限制。2. 仔細(xì)檢查checkAndRequestPermissions()函數(shù)中的版本判斷邏輯確保只在對(duì)應(yīng)版本請(qǐng)求對(duì)應(yīng)權(quán)限。4.2 實(shí)操心得與避坑指南真機(jī)調(diào)試是唯一真理Android模擬器對(duì)藍(lán)牙的支持一直不完美尤其是權(quán)限和硬件交互部分。務(wù)必使用Android 12或更高版本的實(shí)體手機(jī)進(jìn)行開(kāi)發(fā)和測(cè)試。在開(kāi)發(fā)者選項(xiàng)中可以模擬權(quán)限被拒絕的場(chǎng)景。使用ADB命令快速驗(yàn)證權(quán)限狀態(tài)這是最高效的調(diào)試手段之一。連接手機(jī)后在終端執(zhí)行adb shell dumpsys package com.your.package.name | grep -A 50 Permissions:查看輸出中android.permission.BLUETOOTH_SCAN和android.permission.BLUETOOTH_CONNECT的狀態(tài)是grantedtrue還是false。“附近設(shè)備”權(quán)限的坑部分國(guó)產(chǎn)定制系統(tǒng)如MIUI、ColorOS將BLUETOOTH_SCAN權(quán)限包裝在了一個(gè)叫“附近設(shè)備”的權(quán)限組里。用戶可能在系統(tǒng)設(shè)置里找不到獨(dú)立的“藍(lán)牙掃描”開(kāi)關(guān)。你需要引導(dǎo)用戶到“應(yīng)用管理 - 你的應(yīng)用 - 權(quán)限管理”里找到“附近設(shè)備”并允許。在代碼中彈窗解釋時(shí)最好提及這一點(diǎn)。后臺(tái)服務(wù)通知不能馬虎Android系統(tǒng)對(duì)前臺(tái)服務(wù)的通知檢查非常嚴(yán)格。通知渠道必須創(chuàng)建通知必須顯示且不能輕易被用戶清除setOngoing(true)。如果通知沒(méi)展示出來(lái)前臺(tái)服務(wù)可能被系統(tǒng)殺死導(dǎo)致后臺(tái)掃描立即停止。neverForLocation標(biāo)志的權(quán)衡加上這個(gè)標(biāo)志可以避免申請(qǐng)敏感的位置權(quán)限對(duì)用戶更友好審核也更容易通過(guò)。但前提是你的應(yīng)用真的不需要位置信息。如果你的應(yīng)用功能是基于地理圍欄或室內(nèi)定位去掉這個(gè)標(biāo)志并申請(qǐng)位置權(quán)限是更誠(chéng)實(shí)的做法。升級(jí)targetSdkVersion要謹(jǐn)慎當(dāng)你將應(yīng)用的targetSdkVersion升級(jí)到31或更高時(shí)意味著你明確告訴系統(tǒng)你的應(yīng)用已完全適配此版本。系統(tǒng)將嚴(yán)格執(zhí)行新規(guī)如后臺(tái)掃描限制。建議在升級(jí)前在分支上進(jìn)行充分的兼容性測(cè)試處理好所有BLUETOOTH_SCAN、BLUETOOTH_CONNECT以及后臺(tái)限制相關(guān)的問(wèn)題。