尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Kotlin重写Android蓝牙开关与配对:权限适配与协程实践

Kotlin重写Android蓝牙开关与配对:权限适配与协程实践 简介这份资源是面向 Android 初学者的 Kotlin 蓝牙操作示例工程核心功能覆盖打开与关闭蓝牙、扫描周边设备、发起配对请求以及将已配对设备信息列表展示出来。开发者可借此快速掌握系统蓝牙 API 的基本用法理解设备发现与配对状态回调等关键概念特别适合正在练习 Android 系统编程或准备实现蓝牙功能模块的读者。压缩包共 44 个文件以 3 份 Kotlin 源码处理业务逻辑12 个 XML 负责界面与资源配置Gradle 脚本用于工程构建另有 webp/png 图片及属性文件作为配套素材整体仅 113KB结构清晰便于定位代码。已有 1340 人浏览学习可配合博客说明边读边练。通过该工程读者能直接获得一套可运行的 Bluetooth 操作范例并可根据自身需求调整搜索、配对与展示逻辑省去从零搭建的时间。1. 用 Kotlin 重写蓝牙开关比跳系统设置页更值得做很多做 Android 的同行一提到蓝牙开关第一反应是startActivity(BluetoothAdapter.ACTION_REQUEST_ENABLE)把系统弹窗甩给用户。这个做法在 SDK 28 之前没问题但到 Android 12 之后你会发现随手写的 BLE 扫描在部分机型上持续掉 Rssi、系统广播收不到甚至直接报SecurityException。这个源码包里用 Kotlin 完整实现了蓝牙打开、关闭、搜索、配对和已配对列表展示没有走系统设置页而是基于BluetoothAdapter原生 API 自己封装了一套状态机处理。它适合两类人一类是刚接 IoT 硬件 SDK、需要在 App 内完成连接前置操作的初级工程师另一类是准备把老的 Java 蓝牙代码迁移到 Kotlin、想参考回调转协程写法的进阶开发者。核心思路就一句蓝牙操作的本质是异步回调密集型任务Kotlin 的协程和密封类恰好能把这种状态流转整理清楚。2. 环境准备与权限Android 6 到 Android 13 的权限矩阵2.1 蓝牙权限的三个时代别只加BLUETOOTH从项目源码的AndroidManifest.xml里看权限声明顺序是BLUETOOTH、BLUETOOTH_ADMIN这是 Android 12 之前的写法。但跑在 API 31 以上时必须补充BLUETOOTH_SCAN、BLUETOOTH_CONNECT和BLUETOOTH_ADVERTISE并给usesPermissionFlagsneverForLocation这个 flag 加上。否则会出现一个非常误导人的崩溃java.lang.SecurityException: Need BLUETOOTH_CONNECT permission for AttributionSource。常见做法是写一个权限常量表按 SDK 版本分流。private val requiredPermissions if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { arrayOf( Manifest.permission.BLUETOOTH_SCAN, Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_ADVERTISE, Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION ) } else { arrayOf( Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN, Manifest.permission.ACCESS_FINE_LOCATION ) }这段代码的要点是两个分支在 API 31 处断开旧权限组里没有位置权限会直接导致扫描不到经典蓝牙设备新权限组里BLUETOOTH_SCAN如果加了neverForLocation标签在部分机型上会拿不到 BLE 广播中的「次级信息」但这套项目只做经典蓝牙影响不大。功能点Android 6-11 所需权限Android 12 所需权限开关蓝牙BLUETOOTH_ADMINBLUETOOTH_CONNECT扫描发现设备BLUETOOTH_ADMIN 定位权限BLUETOOTH_SCAN 定位权限可叠加 neverForLocation配对设备BLUETOOTH_ADMINBLUETOOTH_CONNECT展示已配对列表BLUETOOTHBLUETOOTH_CONNECT这里容易踩的坑是BLUETOOTH_CONNECT和BLUETOOTH_SCAN是危险权限必须运行时申请而BLUETOOTH_ADVERTISE只在向外部广播时才强制要求。如果只是做被动的发现和连接即使不声明它系统也不会给出明确错误但审核工具会标红。2.2 用 Activity Result API 重新封装运行时权限回调源码里没有用老式的onRequestPermissionsResult而是用了registerForActivityResult这是 androidx.activity 1.2.0 之后推荐的方式。项目里 Kotlin 代码的典型写法是把权限请求抽成一个顶层函数private val permissionLauncher registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { grantResult: MapString, Boolean - val allGranted grantResult.entries.all { it.value } if (allGranted) { initBluetoothAdapter() } else { val deniedList grantResult.filterValues { !it }.keys Log.w(TAG, 用户拒绝的权限: $deniedList) // 此时蓝牙不会直接崩溃但扫描结果会一直为空需要给出引导 showPermissionGuideDialog() } } private fun checkAndRequestBluetoothPermission() { latestPermissionResult null val notGranted requiredPermissions.filter { ContextCompat.checkSelfPermission(this, it) ! PackageManager.PERMISSION_GRANTED } if (notGranted.isEmpty()) initBluetoothAdapter() else permissionLauncher.launch(notGranted.toTypedArray()) }逻辑说明RequestMultiplePermissions会把所有请求权限的结果一次性送回回调allGranted做整体判定。注意过滤notGranted这一步如果一次性把已经授权的权限再 launch系统会直接回调 true 但不会弹窗反而拖慢流程。实际项目里我一般还会记录latestPermissionResult作为状态因为用户从系统设置返回 App 时onResume里要二次校验否则会出现权限明明开了但蓝牙状态没刷新的问题。2.3 权限拒绝时的降级策略不能只弹 Toast在这个项目里拒绝权限的默认行为是优雅降级界面上按钮置灰但已配对列表仍可读。BLUETOOTH_CONNECT没授权时getBondedDevices()会抛SecurityException但BluetoothAdapter.isEnabled()不抛。所以要做一层保护fun readBondedDevicesSafe(): ListBluetoothDevice { return try { if (checkConnectPermission()) bluetoothAdapter.bondedDevices.toList() else emptyList() } catch (e: SecurityException) { Log.e(TAG, 读取已配对设备被拒绝: ${e.message}) emptyList() } }参数说明这段是典型的 Kotlin 表达式函数写法if/else的返回值直接作为函数返回值比 Java 少三行样板代码。之所以用emptyList()兜底而不是直接抛异常是因为列表页在权限未确定时也要能渲染「暂无数据」的占位 UI空集合不会触发 RecyclerView 的闪烁。权限引导对话框里放置「去设置」按钮比再次发起请求更有效——Android 权限弹窗连续点两次拒绝后系统默认不再弹这时候只能Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)跳转。3. 开关与扫描BluetoothAdapter 是入口也是陷阱3.1 开关蓝牙为什么不用 enable() 直接调源码里打开蓝牙用的是bluetoothAdapter.enable()关闭用的是disable()。这里有个 Android 8.0 之后的行为差异enable()不会弹出系统确认框而是直接打开但蓝牙「打开中」是异步过程立即调用startDiscovery()会返回 false。项目里用一个Handler轮询isEnabled状态比较笨但可靠因为系统官方广播ACTION_STATE_CHANGED在部分国产 ROM 上会被延迟。fun setBluetoothEnabled(enable: Boolean, callback: (Boolean) - Unit) { if (enable bluetoothAdapter.isEnabled) { callback.invoke(true) return } val success if (enable) bluetoothAdapter.enable() else bluetoothAdapter.disable() if (!success) { callback.invoke(false) return } // 轮询状态最长等待 5 秒 pollBluetoothState(expectedState enable, timeoutMs 5000, callback callback) } private fun pollBluetoothState(expectedState: Boolean, timeoutMs: Long, callback: (Boolean) - Unit) { val startTime System.currentTimeMillis() val poller object : Runnable { override fun run() { val current bluetoothAdapter.isEnabled expectedState if (current || System.currentTimeMillis() - startTime timeoutMs) { callback.invoke(current) return } mainHandler.postDelayed(this, 200L) } } mainHandler.postDelayed(poller, 200L) }逻辑说明Runnable轮询间隔 200msSystem.currentTimeMillis做超时控制。为什么不监听ACTION_STATE_CHANGED纯广播因为从ACTION_STATE_CHANGED回调拿到EXTRA_STATE时bluetoothAdapter.isEnabled可能还没同步需要再 post 一个空消息到主线程队列末尾。轮询虽然土但在这个场景下它天然规避了状态同步的时序问题。有一个细节值得注意目标 SDK 31 以上时enable()和disable()都需要BLUETOOTH_CONNECT权限且必须在 Android 系统弹窗的用户操作之外调用否则SecurityException会把你从后台进程里揪出来闪退。3.2 扫描设备经典蓝牙与 BLE 是两套机制这个项目里搜索设备使用的是startDiscovery()搭配BroadcastReceiver对应经典蓝牙 BR/EDR。它与startScan()的 BLE 扫描不同startDiscovery会搜索周边所有可发现的蓝牙设备包括经典蓝牙和双模设备而startScan只找 BLE 外设。源码里接收广播的核心代码private val bluetoothReceiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { BluetoothDevice.ACTION_FOUND - { val device: BluetoothDevice? intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) val rssi: Short? intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE) device?.let { onDeviceFound(it, rssi) } } BluetoothAdapter.ACTION_DISCOVERY_STARTED - { isDiscoveryActive true } BluetoothAdapter.ACTION_DISCOVERY_FINISHED - { isDiscoveryActive false // discoverable 模式在扫描结束时会自动关闭这里需要标记状态 } BluetoothDevice.ACTION_BOND_STATE_CHANGED - { val bondState intent.getIntExtra(BluetoothDevice.EXTRA_BOND_STATE, -1) handleBondStateChange(bondState) } } } }重点在ACTION_FOUND分支getParcelableExtra注意 API 33 之后要传指定类型否则 lint 会报警并可能导致抓取不到对象。EXTRA_RSSI是设备信号强度单位是 dBm经典蓝牙的 RSSI 波动很大在可视范围内可能在 -40 到 -90 之间乱跳不能拿它当测距数据用只能用做排序。扫描结果列表里同一台设备会多次回调ACTION_FOUND所以项目里用设备 MAC 地址做去重键。扫描结束后还有一件容易被忽略的事ACTION_DISCOVERY_FINISHED广播到达后系统会自动关闭「可被检测」模式但BluetoothAdapter.scanMode的读取会有延迟真机上经常发现EXTRA_SCAN_MODE还是SCAN_MODE_CONNECTABLE_DISCOVERABLE的残留值所以判断 discoverable 是否开启不要完全信任这个字段用startActivityForResult请求ACTION_REQUEST_DISCOVERABLE的RESULT_CODE最准。API用途关键参数startDiscovery()扫描经典蓝牙设备无扫描约 12 秒自动结束cancelDiscovery()停止扫描配对前必须先调用否则部分机型配对失败isDiscovering判断当前是否在扫描中布尔值ACTION_FOUND每发现一个设备回调一次EXTRA_DEVICE、EXTRA_RSSIACTION_DISCOVERY_FINISHED扫描结束回调可用于通知列表刷新3.3 Kotlin 协程改造扫描回调避免状态堆积扫描接收器回调是事件流式的项目里直接用协程把广播转成了Flow风格。核心是callbackFlowfun observeDevicesFound(): FlowBluetoothDevice callbackFlow { val receiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (intent.action BluetoothDevice.ACTION_FOUND) { val device: BluetoothDevice? intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) if (device ! null) { trySend(device) // 不阻塞地发送事件 } } } } context.registerReceiver(receiver, IntentFilter(BluetoothDevice.ACTION_FOUND)) awaitClose { context.unregisterReceiver(receiver) } }参数说明callbackFlow里trySend是档位受限的发送方法频道缓冲满时会失败要结合buffer(Channel.BUFFERED)使用。awaitClose会在协程取消时执行反注册防止内存泄漏。这套转换的价值在于配合take(20)或debounce(1000)之类的操作符写 UI 逻辑比手动维护一个ArrayListBluetoothDevice再通知适配器更新要清爽不少。在真机上扫描广播的到达频率比较高做去重的时机应在trySend之前用stateIn维护已发现的 MAC 集合避免重复设备反复触发界面闪烁。4. 配对与已配对列表从 createBond 到 BOND_STATE_CHANGED 的闭环4.1bondedDevices的读取及 Kotlin 集合转换源码里显示已配对列表在进入页面时读取一次没有动态刷新监听这个设计在真实场景里不够。我给出的补强做法是在onResume中重新拉取bluetoothAdapter.bondedDevices并转到 UI 列表因为配对状态可能在系统蓝牙设置里被手动修改。fun loadBondedDevices(): ListPairedDeviceModel { return bluetoothAdapter.bondedDevices .mapNotNull { device - val name device.name ?: returnmapNotNull null PairedDeviceModel( name name, macAddress device.address, bondState parseBondState(device.bondState), type parseDeviceType(device.type) ) } .sortedBy { it.name.lowercase() } }映射里有个小的关键词mapNotNull用于过滤 name 为 null 的设备——实测不少老旧蓝牙耳机广播出的name为空但address有值。parseDeviceType根据BluetoothDevice.DEVICE_TYPE_CLASSIC、DEVICE_TYPE_LE、DEVICE_TYPE_DUAL等常量解析这个信息对用户排错很有用比如一辆车的车载蓝牙显示设备类型为 LE就能解释为什么手机作为音频源无法直接连接。列表排序用sortedBy而不是sortedByDescending目的是把带中文名的设备放在前面因为中文拼音 dict 排序比数字更稳定。4.2createBond()配对但配对弹窗却被系统接管经典蓝牙配对有两种触发方式device.createBond()以及BluetoothDevice配对按键的系统级流程。最常见的情况是createBond()返回 true但界面弹窗是系统InputMethodManager之外的蓝牙配对对话框App 无法自定义 UI。项目里在新设备列表中点击某一项触发配对fun pairDevice(device: BluetoothDevice, context: Context) { if (device.bondState BluetoothDevice.BOND_BONDED) { Toast.makeText(context, 该设备已配对${device.address}, Toast.LENGTH_SHORT).show() return } val isDiscovering bluetoothAdapter.isDiscovering if (isDiscovering) { bluetoothAdapter.cancelDiscovery() // 配对前停止扫描这在 API 30 尤其重要 } val success device.createBond() if (!success) { Log.w(TAG, createBond 返回 false设备可能不支持经典蓝牙配对) } else { pendingPairMac device.address // 记录待处理设备用于配对回调比对 } }这里的pendingPairMac是项目里的一个细节因为系统配对弹窗可能会弹出配对码输入框用户取消后ACTION_BOND_STATE_CHANGED广播里EXTRA_BOND_STATE会瞬间跳到BOND_NONE再跳到BOND_BONDING失败。如果不比对 MAC就可能出现用户点了设备 A实际回调处理的是上一次没完成的设备 B 的配对结果。经验是过滤条件不仅要看bondState还要用intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE)比对 MAC 地址。4.3 配对状态机的 ListAdapter 刷新策略展示配对列表时源码使用ListAdapter区别在于areItemsTheSame与areContentsTheSame的粒度控制class BondedDeviceAdapter : ListAdapterPairedDeviceModel, BondedDeviceAdapter.VH( object : DiffUtil.ItemCallbackPairedDeviceModel() { override fun areItemsTheSame(oldItem: PairedDeviceModel, newItem: PairedDeviceModel) oldItem.macAddress newItem.macAddress override fun areContentsTheSame(oldItem: PairedDeviceModel, newItem: PairedDeviceModel) oldItem.bondState newItem.bondState oldItem.name newItem.name oldItem.type newItem.type } ) { // 其余 RecyclerView 绑定代码 }areItemsTheSame拿 MAC 地址做唯一标识因为设备名称会重复。areContentsTheSame不比较 Rssi 字段因为经典蓝牙 Rssi 在列表不滚动时也会抖动如果字段加入了比较ListAdapter会频繁调用notifyItemChanged导致界面肉眼可见地闪。且这个列表是从loadBondedDevices()一次性生成的不是增量加载所以submitList时要做一次toList()拷贝否则外部如果持有同一MutableList引用修改数据源再submitList时会触发IllegalStateException: Cannot call this method while RecyclerView is computing a layout这类崩溃。4.4 自定义蓝牙 Model 数据层与hashCode项目里给 UI 层定义了一个不可变的PairedDeviceModel这正是 Kotlin 数据类典型应用场景data class PairedDeviceModel( val name: String, val macAddress: String, val bondState: String, val type: String )在 Kotlin 中data class自动生成equals/ hashCode/ toString/ copy/ componentN在这个场景的意义在于 DiffUtil 可以零样板地比较content是否变化。不要让它继承BluetoothDevice——那是一个抽象类外部无法直接实例化绑定逻辑里直接持有BluetoothDevice引用即可。真正的使用误区是把BluetoothDevice对象直接放进Bundle传过 Fregment因为它是 Parcelable 没错但在 Android 12 以上跨进程传递时对权限的校验更严苛序列化name和address两个字段才是稳妥写法。5. 真机验证与三个高频坑5.1 模拟器基本不可用必须真机调蓝牙Android 模拟器不提供蓝牙硬件支持bluetoothAdapter输出 null。这个项目的启动逻辑里要加上空保护的判断否则会在getSystemService之后 NPE 闪退。简化的启动顺序检查硬件支持 → 检查权限 → 检查是否开启 → 启动扫描。验证环境建议用 Android 9 和 Android 12 的两台机器覆盖两种权限分支和targetSdk不同的行为差异。5.2 从零开始复现标准流程配一台 Pixel 或类原生系统手机按以下步骤确认全套功能正常安装后冷启动拒绝所有权限确认 App 不闪退界面空态正常展示授予位置权限但不给蓝牙权限确认已配对列表读不到扫描按钮可用但无结果全部授予后再触发一次扫描打开另一台设备的蓝牙可发现模式配对成功后杀掉 App 重进确认已配对列表有该设备且不重复关闭蓝牙再打开连续操作三次确认状态轮询不会卡死关键的命令行验证手段是adb shell dumpsys bluetooth_manageradb shell dumpsys bluetooth_manager | grep -E mEnabled|mDiscovering|mBondedDevicesmEnabled是蓝牙开机状态布尔值mDiscovering可以确认扫描是否真的在跑不要被 App 内的进度条蒙蔽。如果命令输出异常说明系统蓝牙栈已经出了更深层的问题不是 App 代码的锅直接重启蓝牙服务或 Flyme 这类 ROM 的蓝牙修复模式。5.3 华为鸿蒙和 MIUI 上扫描结果的差异国产 ROM 最大的坑是「杀后台」策略扫描过程中锁屏或切到后台系统会直接停掉蓝牙发现广播。表现为ACTION_DISCOVERY_FINISHED永远不回调界面停留在 正在扫描。辅助策略是调用wakeLock保证 CPU 不休眠以及在前台Service里执行扫描Android 8 以上后台应用执行startDiscovery会直接抛SecurityException。AndroidManifest.xml里声明的FOREGROUND_SERVICE类型如果是 targetSdk 34必须加android:foregroundServiceTypeconnectedDevice并通过startForeground传入匹配类型。5.4 模拟器上没有的经典问题重复发现与配队弹窗不出现有用户反馈配对时createBond()返回 true但系统不弹配对码确认框。这种情况大多是设备只支持 LE不支持 BR/EDR所以createBond()虽不报错但设备侧不响应握手。判断方法看device.type如果图标是「LE only」而BOND_BONDING持续 10 秒以上没变化基本可以判定不是你 App 的问题换一个双模设备再对比测试。还有一小部分车机蓝牙必须在音频通道空闲时才能配对如果之前已经连接了一台手机要断开之后再走createBond()。整个项目最终的价值不只是实现了开关和配对而是把蓝牙操作链路上的状态机、权限矩阵和厂商差异转化成了一套可直接套用的 Kotlin 代码结构。后续扩展方向是把经典蓝牙通道切换成 BLE GATT 连接核心的callbackFlow封装思路可以平移复用但BLE扫描要用LeScanCallback配对后的数据交互要从广播模式转为 GATT 读写那将是另一套 protocol 设计了。本文还有配套的精品资源点击获取
返回列表