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

资讯详情

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

Android蓝牙开发实战:Kotlin实现状态机、BLE与经典蓝牙连接

Android蓝牙开发实战:Kotlin实现状态机、BLE与经典蓝牙连接 简介面向 Android 开发者的 Kotlin 蓝牙基础操作示例工程演示了蓝牙开关、设备搜索、配对连接以及已配对列表展示等核心流程适合初学蓝牙功能集成或需要快速搭建原型的开发者参考。压缩包共 44 个文件体积仅 113KB主要包含 3 个 Kotlin 源码文件、12 个 XML 界面布局、8 个 PNG 与 10 个 WebP 图片资源以及 Gradle 构建脚本和 ProGuard 配置结构完整可直接在 Android Studio 中导入运行。已有 1340 人学习使用。代码按功能拆分了设备列表展示、配对状态监听等模块清单文件中已完成相关权限声明可以帮助使用者理解 Android 蓝牙 API 的调用时序与常见状态处理方式省去从零排查的麻烦。资源体量小巧适合作为教学示例或内部工具集的基础框架。1. 为什么说蓝牙基本操作的核心是一个状态机把 KotlinAndroid 蓝牙基本操作 拆开看真正让开发者头疼的往往不是扫描、配对、连接这三个动词而是连接状态随时可能被系统、被用户、被物理距离打断。一个早上还能稳定收发数据的蓝牙模块下午可能就出现GATT_INTERNAL_ERROR而你手里唯一的线索只有一行没人愿意读的日志。这就是 Android 蓝牙开发的常态API 本身不难真正的复杂度全在状态流转和异常处理。因此与其把蓝牙基本操作理解成一连串按钮触发的事件不如把它当作一台状态机来设计。经典蓝牙RFCOMM和低功耗蓝牙BLE/GATT虽然协议栈不同但最终都落到同一个基础模型Idle → Scanning → Connecting → Connected → Disconnected。本篇文章会先讲环境与权限Android 12 之后的权限变动足以让旧项目直接崩溃再用 Kotlin 分别实现经典蓝牙和 BLE 两条主线的核心代码最后把状态机、生命周期与调试技巧收拢成一个可复用的封装。适合刚接手蓝牙需求、或者被onConnectionStateChange回调搞到怀疑人生的 Android 工程师读完可以直接落地到一个实际项目里。2. Kotlin Android 蓝牙开发的权限与前置环境检查2.1 Android 权限演进从 6.0 到 13.0 的适配策略蓝牙权限是初学者踩坑最多的地方因为 Android 在 6.0、10.0、12.0、13.0 分别对权限模型做过调整。如果只按旧教程写Manifest.permission.BLUETOOTH在 Android 12 设备上会直接闪退。以 API 34Android 14为基准正确的最小权限集合如下uses-permission android:nameandroid.permission.BLUETOOTH android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_ADMIN android:maxSdkVersion30 / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN android:usesPermissionFlagsneverForLocation / uses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_ADVERTISE /这里有一个关键细节BLUETOOTH_SCAN在 Android 12 上默认被系统视为可能推断位置信息因此需要显式声明neverForLocation否则部分手机尤其是国产 ROM会在运行时强制要求定位权限。BLUETOOTH_ADVERTISE只有在做 BLE 外设Peripheral时才必须如果只是中心设备Central扫描连接可以不声明但建议保留避免后期加需求时又要改清单文件。旧权限里的BLUETOOTH和BLUETOOTH_ADMIN用maxSdkVersion30限制是为了兼容 Android 11 及以下设备。运行时请求权限用 Activity Result API 即可通常在MainActivity的onCreate阶段触发。注意BLUETOOTH_SCAN和BLUETOOTH_CONNECT是两组独立的权限必须分别请求不能合并成一个 dialog。提示真机调试时如果已经从最近任务滑掉了应用系统回调的权限结果会丢失需要在onResume里做一次兜底检查。2.2 蓝牙适配器检查与 Kotlin 扩展函数封装拿到权限之后第一件事是检查设备是否支持蓝牙、蓝牙是否打开。常见的BluetoothAdapter获取方式在不同 SDK 版本上有差异建议封装成一个扩展函数fun Context.defaultBluetoothAdapter(): BluetoothAdapter? if (Build.VERSION.SDK_INT Build.VERSION_CODES.S) { context.getSystemService(BluetoothManager::class.java)?.adapter } else { Suppress(DEPRECATION) BluetoothAdapter.getDefaultAdapter() }Android 12 之后BluetoothAdapter.getDefaultAdapter()被标记为废弃但实际返回逻辑并没有变只是官方更推荐通过BluetoothManager获取。封装成扩展函数之后所有业务代码统一调用defaultBluetoothAdapter()后续如果要接厂商定制 ROM 的特殊适配逻辑只需要改动这一个文件。蓝牙未开启时可以引导用户跳转系统设置页if (adapter?.isEnabled false) { startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)) }ACTION_REQUEST_ENABLE会弹出系统级蓝牙开启对话框不需要额外权限。这里有一个反直觉的点即使蓝牙关闭BluetoothAdapter对象仍然非空很多初学者在这里写if (adapter null) return后发现代码完全不执行实际上是isEnabled才是真正的开关状态。2.3 扫描回调注册registerReceiver 还是回调方式经典蓝牙的扫描结果通过BroadcastReceiver返回这是与 BLE 最大的区别。注册方式有两种registerReceiver动态注册和ContextCompat.registerReceiverAndroid 13 推荐。推荐在onStart注册、onStop注销避免在后台收到无关广播导致内存泄漏private val receiver object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { BluetoothDevice.ACTION_FOUND - { val device: BluetoothDevice? intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE) device?.let { onDeviceFound(it) } } BluetoothAdapter.ACTION_DISCOVERY_FINISHED - { onDiscoveryFinished() } } } } override fun onStart() { super.onStart() val filter IntentFilter().apply { addAction(BluetoothDevice.ACTION_FOUND) addAction(BluetoothAdapter.ACTION_DISCOVERY_FINISHED) } ContextCompat.registerReceiver( requireContext(), receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED ) }RECEIVER_NOT_EXPORTED是 Android 12 之后的安全要求非系统广播不允许跨应用发送如果漏掉这个参数低版本设备不会报错但 Android 14 上会直接抛出SecurityException。扫描期间建议显示一个进度条因为经典蓝牙扫描是耗时的同步过程ACTION_DISCOVERY_FINISHED不一定在预期时间内触发。3. Classic 蓝牙的配对连接与 RFCOMM 数据收发3.1 经典蓝牙协议里的配对与连接分层经典蓝牙Classic和 BLE 最本质的区别在于传输模型。经典蓝牙基于 RFCOMM 串口仿真协议面向流式数据适合传输文件、打印机指令、车载免提等场景BLE 基于 GATT 属性协议面向报文适合低功耗传感器。做基本操作时如果不区分这两类设备代码很容易跑偏——比如把 BLE 的connectGatt用到经典蓝牙设备上会一直回调133错误。经典蓝牙连接流程是startDiscovery()扫描 → 用户选择设备 →createBond()配对 →socket.connect()建立 RFCOMM 连接。这里有一个关键细节配对Bond和连接Connect是两步独立的操作但createBond()是异步的系统会弹授权框。如果用反射直接调用setPin()自动输入 PIN 码在 Android 10 之后会被系统限制所以不要尝试绕过系统配对 UI老老实实让用户确认。3.2 Kotlin 封装 RFCOMM 连接的最小实现class ClassicBluetoothClient(private val device: BluetoothDevice) { private var socket: BluetoothSocket? null private val uuid UUID.fromString(00001101-0000-1000-8000-00805F9B34FB) fun connect(timeoutMs: Long 10000L): Boolean { if (device.bondState ! BluetoothDevice.BOND_BONDED) { return false } return try { socket device.createRfcommSocketToServiceRecord(uuid) socket?.let { it.connect() // 阻塞调用必须放到 IO 线程 return it.isConnected } ?: false } catch (e: IOException) { // 部分设备需要走反射方式建立 socket socket device.javaClass .getMethod(createRfcommSocket, Int::class.javaPrimitiveType) .invoke(device, 1) as BluetoothSocket socket?.connect() socket?.isConnected true } } fun write(data: ByteArray) { val output socket?.outputStream ?: return output.write(data) output.flush() } fun read(): ByteArray? { val input socket?.inputStream ?: return null val buffer ByteArray(1024) val size input.read(buffer) return if (size 0) buffer.copyOf(size) else null } fun close() { socket?.close() } }上述代码里UUID是 Serial Port ProfileSPP的固定值绝大多数蓝牙串口模块如 HC05都使用它。connect()是阻塞方法放到Dispatchers.IO线程里执行否则会触发NetworkOnMainThreadException。反射createRfcommSocket是经典的兼容方案适用于部分国产模块不识别标准 UUID 的情况但要注意该方法是内部 APIOEM 定制的 ROM 可能移除了这个重载。提示read()是同步阻塞读如果设备端不主动发数据线程会一直在input.read()处等待。通常的做法是启动一个协程循环读取配合withTimeoutOrNull做超时控制。3.3 A2DP 与 SCO 模式的切换场景经典蓝牙开发中除了 RFCOMM 数据通道音频类设备还涉及 A2DP音乐播放和 SCO通话音频两种profile的切换。很多车机连接成功后出现能通话但没声音或者能放歌但麦克风没反应就是没有正确切换 profile。Android 提供了BluetoothA2dp和BluetoothHeadset两个系统服务连接状态变化时系统会自动切换但部分非原厂 ROM 存在切延迟代码里需要监听ACTION_A2DP_PLAYING_STATE_CHANGED广播来通知界面更新。这块如果需求里只做数据透传可以暂时不处理但做蓝牙对讲或语音助手类应用时它是必踩的坑。4. BLE 低功耗蓝牙的扫描、GATT 连接与特征值读写4.1 BLE 与经典蓝牙的选型边界BLE 在 Android 4.3 开始支持API 18 以上不需要额外兼容库官方BluetoothLeScanner可用。BLE 的适合场景是低功耗、小数据量单包不超过 20 字节、低频交互例如蓝牙测距基于 RSSI 的接近检测、水控器、传感器数据上报。如果要做文件传输或大数据流BLE 不适合应该切回经典蓝牙。这个选型要在需求评审阶段就确认清楚因为两者的连接管理方式和线程模型完全不一样后期切换的成本几乎等于重写。BLE 的核心概念是 GATT 层级Service → Characteristic → Descriptor。一个 BLE 设备可能有多个 Service每个 Service 下有若干个 CharacteristicCharacteristic 支持读、写、通知三种属性。Android 端拿到的BluetoothGattCharacteristic对象是操作数据的入口写数据需要调用writeCharacteristic接收数据则需要注册onCharacteristicChanged回调。4.2 用 Kotlin 协程封装 GATT 连接与超时处理suspend fun connectGatt( context: Context, device: BluetoothDevice, autoConnect: Boolean false ): BluetoothGatt? suspendCancellableCoroutine { continuation - val callback object : BluetoothGattCallback() { override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { if (newState BluetoothProfile.STATE_CONNECTED) { gatt.discoverServices() } else if (newState BluetoothProfile.STATE_DISCONNECTED) { continuation.resume(null) } } override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { if (status BluetoothGatt.GATT_SUCCESS) { continuation.resume(gatt) } else { continuation.resume(null) } } } val gatt device.connectGatt(context, autoConnect, callback) continuation.invokeOnCancellation { gatt.disconnect() gatt.close() } }connectGatt的第一个参数传Context官方文档提示传Activity或Service均可但推荐传ApplicationContext避免 Activity 销毁时导致连接被自动释放。autoConnect参数含义与直觉相反true表示连接不成功时进入后台重连模式false表示只尝试一次。大多数业务场景用false即可——重连逻辑交给状态机管理而不是依赖系统自带的重试机制。discoverServices()是连接成功后的下一个步骤必须在onConnectionStateChange里主动调用系统不会自动执行。onServicesDiscovered回调成功后才能调用getService()、getCharacteristic()等查询方法。整个流程用suspendCancellableCoroutine包装后可以配合withTimeoutOrNull实现 10 秒超时避免设备不可达时协程永远挂起。4.3 特征值读写与通知订阅的三个必调参数fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic): Boolean { val result gatt.setCharacteristicNotification(characteristic, true) if (!result) return false val descriptor characteristic.getDescriptor( UUID.fromString(00002902-0000-1000-8000-00805F9B34FB) ) ?: return false descriptor.value BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE return gatt.writeDescriptor(descriptor) }写数据时有一个常见坑某些国产模块要求先写 Characteristic 的WRITE_TYPE_DEFAULT另一些需要WRITE_TYPE_NO_RESPONSE。判断依据是特征值的properties属性是否包含PROPERTY_WRITE_NO_RESPONSE。如果写数据一直成功但设备不执行多半是 writeType 选错了。另外在 Android 13 上setCharacteristicNotification返回true不代表通知真的开启必须成功写入 CCCDClient Characteristic Configuration Descriptor后设备才会开始推送数据。上面代码里00002902是系统固定 UUID不能用设备自带的 UUID 替代。4.4 蓝牙测距与 RSSI 的实用读取方法BLE 扫描结果里的scanRecord包含 RSSI信号强度值可以用来做粗略的距离估算。标准公式是distance 10^((measuredPower - rssi) / (10 * n))其中measuredPower是设备在 1 米处的 RSSI 标准值一般在广播包里n是环境衰减因子室内取 2-3。实际项目里RSSI 抖动非常严重建议做滑动窗口滤波取最近 10 个 RSSI 的平均值再计算距离同时规定低于 -90dBm 直接判定为超出范围。需要注意的边界蓝牙测距只能做接近检测比如靠近自动亮屏不能做精确室内定位因为金属遮挡、人体吸收对信号影响远大于距离因素。5. 连接状态机设计从回调地狱到可控的单例管理模式5.1 把 onConnectionStateChange 映射为状态流BluetoothGattCallback的回调是分散的连接状态一个回调、服务发现一个回调、数据到达一个回调、MTU 协商又是一个回调。业务层如果直接拼接这些回调很快会变成一个私有的回调地狱。常见做法是把这些事件统一收敛到一个状态机里对外只暴露StateFlowConnectionStatesealed class ConnectionState { object Idle : ConnectionState() object Scanning : ConnectionState() data class Connecting(val device: BluetoothDevice) : ConnectionState() data class Connected(val gatt: BluetoothGatt) : ConnectionState() data class Disconnected(val reason: DisconnectReason) : ConnectionState() }状态机内部接收三个输入userIntent用户主动发起连接/断开、systemEvent回调事件、timeoutTick超时定时器。例如在Connecting状态下收到用户的Disconnect意图直接跳转Disconnected(UserCancelled)收到系统回调onConnectionStateChange(status0, newState2)则跳转Connected并触发discoverServices。使用StateFlow的好处是 UI 层只需要collect一个数据源不需要分别在扫描回调、配对回调、连接回调里各写一套刷新逻辑。Kotlin Flow 在这里的应用还需要注意StateFlow会丢弃中间态如果 UI 想展示 连接中 3 秒 这种过程动画需要用SharedFlow或者叠加一个额外的connectStartTime字段。collect时如果有多个 Observer建议使用stateIn(scope, SharingStarted.WhileSubscribed(), ConnectionState.Idle)这样最后一个订阅者取消时会自动清理底层资源。5.2 生命周期绑定避免 Activity 销毁后 GATT 回调闪崩GATT 回调持有了当前线程的Looper如果 Activity 已经onDestroy回调仍然可能在主线程触发。常规做法是在onDestroy里调用gatt.close()但这会造成正在进行的 MTU 协商被打断。更稳妥的方案是使用AndroidViewModel持有蓝牙客户端实例class BleViewModel(application: Application) : AndroidViewModel(application) { private val bluetoothController BluetoothController(application) val connectionState: StateFlowConnectionState bluetoothController.state override fun onCleared() { bluetoothController.release() super.onCleared() } }ViewModel.onCleared()在 Activity 真正销毁时才会回调可以安全地disconnect()和close()。如果使用ApplicationContext创建 GATT则不需要担心Activity的引用泄漏问题——但代价是无法直接用回调里的Context启动 Dialog。状态机的Connected状态里保存gatt对象后续 UI 操作都从状态里取引用不要自己另存一份。提示close()和disconnect()的区别是初学者经常搞混的。disconnect()只断开当前链路close()会销毁整个BluetoothGatt客户端。重连场景只调用disconnect()彻底放弃设备才调用close()。5.3 断线重连的幂等设计与防抖参数断线重连是最容易写崩的逻辑。不加控制的while (true)重连会让手机蓝牙服务异常发热甚至导致系统蓝牙进程崩溃。常见做法是采用指数退避重试第一次等 500ms第二次 1s第三次 2s最多重试 5 次。重连请求必须检查当前状态是否为Idle或Disconnected避免多个重连协程同时执行fun retryConnect(device: BluetoothDevice) { if (state.value !is ConnectionState.Idle state.value !is ConnectionState.Disconnected) { return } viewModelScope.launch { for (attempt in 1..MAX_RETRY) { val result connect(device) if (result ! null) break delay(RETRY_DELAY_MS * attempt) } } }代码里的幂等判断是核心retryConnect可能在某个回调中被触发多次如果状态已经变为Connecting则直接丢弃新的重连请求。重连失败后不要直接尝试建立新连接先调用一次close()清理残留的 GATT 客户端否则系统会报 client already registered 错误。重连逻辑里还应该监听系统广播ACTION_ACL_DISCONNECTED——很多情况下断开是物理距离导致等信号恢复后设备会自动重新连接不需要应用层主动干预。5.4 多设备连接管理用 ArrayMap 按地址索引如果产品需求是同时连接多个蓝牙设备比如同时连手表和体脂秤单个状态机就力不从心了。常见的做法是用一个以设备地址为 Key 的ArrayMapString, BluetoothGatt管理所有连接再为每个连接创建独立的状态机class MultiDeviceController { private val gattMap ArrayMapString, BluetoothGatt() fun connect(device: BluetoothDevice) { if (gattMap.containsKey(device.address)) return val gatt connectGattInternal(device) gattMap[device.address] gatt } fun disconnect(address: String) { gattMap.remove(address)?.let { it.disconnect() it.close() } } }ArrayMap在条目数量少小于 100时比HashMap更省内存适合蓝牙这种最多十几个设备的场景。操作多设备时要注意 Android 系统的一个限制同一时间同一应用只能建 6 个 GATT 连接超过部分会回调status133GATT_ERROR。这个限制在不同手机上不一样华为、小米的部分机型只有 4 个设计需求时需要预留余量。6. 调试蓝牙应用的三板斧HCI Log、MTU 协商与单例封装蓝牙开发最缺的不是功能实现而是定位问题的手段。当数据收不到、连接秒断、配对失败时最直接的手段是开启蓝牙 HCI 日志在开发者选项中打开 Bluetooth HCI snoop log然后复现问题取下/sdcard/btsnoop_hci.log文件用 Wireshark 打开。HCI log 能看到协议栈层面的事如果应用层一切正常但 log 里没有收到 ACK说明是模块固件问题反之如果 log 显示LL_CONNECTION_UPDATE_REQ一直不响应说明 MTU 协商有问题。这比反复加 log 猜测快得多。MTU 是 BLE 数据吞吐量的瓶颈默认 23 字节其中 3 字节留给头实际有效载荷 20 字节。需要传输大文件时可以用requestMtu(512)协商更大的 MTU但必须在连接成功后、读写数据前调用gatt.requestMtu(247)onMtuChanged回调里会返回实际协商成功后的 MTU 值通常支持 Android 8.0 以上的设备都能协商到 512。但注意MTU 协商是双向的对端设备也需要支持大 MTU否则协商结果会取最小值。如果只是做传感器数据上报几十字节默认 MTU 完全够用不必刻意调大。最后一个技巧是把所有蓝牙入口收敛到一个单例。常见做法是object BluetoothController { fun init(context: Context) ... }配合applicationContext初始化。单例的好处是全局只有一个 BluetoothAdapter 引用、一个状态机、一套线程池不会出现两个页面同时扫描导致的资源竞争。但单例的坏处是生命周期变长需要在应用退出时主动调用release()。实测下来单例配合 ViewModel 是体验最好的范式单例提供全局能力ViewModel 按界面裁剪数据两者互补而不是互斥。用 btsnoop 日志对比两次成功的连接流程你会发现连接失败的设备往往卡在LE Create Connection之后没有任何响应这时优先怀疑设备端固件而不是继续在 Android 侧找问题。这套调参-抓包-对比的循环比任何一次重写代码都更有效率。本文还有配套的精品资源点击获取
返回列表