做 BLE 项目,最烦的阶段往往不是"连不上",而是已经连上了,数据也开始回来了,结果页面状态还是一塌糊涂。
日志里能看到设备不断往上推字节流,onCharacteristicChanged() 也一直在进,表面上看,链路已经没问题了。可一到页面上,问题就出来了。电量不对,模式不对,某个开关明明设备已经切过去了,UI 还是旧值。再往后一点,甚至会出现一种更难受的情况:你知道自己"已经收到数据了",但你又说不清到底是哪一层把状态弄错了。
这类问题有个共同点。它们通常不再是 BLE 链路问题,而是你把字节流太早当成业务状态了。
很多 BLE 代码最开始都会长这样:
kotlin
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
val mode = parseMode(value)
uiState.value = uiState.value.copy(mode = mode)
}
这段代码看着很顺,问题也正出在"太顺"了。它默认了三件事:
- 这次回调里的
value刚好是一条完整消息 - 这条消息刚好能直接解释成业务状态
- 这个状态现在就应该立刻改到 UI 上
真实项目里,这三个前提经常同时不成立。
BLE 回调拿到的首先是字节流,不是"设备状态对象"。这点如果不先承认,后面越写越容易乱。
先把回调从业务里拿出来
我现在做 BLE,只要协议稍微复杂一点,都会先把回调收住。回调里不做业务,不改 UI,最多只做一件事:把字节流交给协议层。
kotlin
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray
) {
protocolPipeline.onBytesReceived(value)
}
这一步看起来只是多包了一层,但它能把后面很多混乱切开。BLE 负责收,协议层负责切包和解码,状态层负责收敛,UI 只负责展示。边界清楚以后,排查问题会省很多时间。
第一层:先解决"这是不是一条完整消息"
很多人默认一条 onCharacteristicChanged() 就是一条完整协议消息。这个在最简单的 demo 里也许成立,但只要设备协议稍微复杂一点,很快就不成立了。
真实情况通常是:
- 一条业务消息可能被拆成两段回来
- 两条短消息可能拼在一次回调里
- 某些设备第一次回的是头,第二次回的是 payload
- 某些设备会插入心跳包、状态包、业务包混着来
这时候你如果直接拿回调里的 value 去 parse,状态一定会漂。
所以第一层通常应该是切包器。假设我们协议格式很简单:
0xAA作为包头- 第 2 个字节是命令字
- 第 3 个字节是 payload 长度
- 后面跟 payload
- 最后 1 个字节是校验和
那一个最小切包器可以先写成这样:
kotlin
data class RawPacket(
val command: Int,
val payload: ByteArray
)
class PacketFramer(
private val onPacket: (RawPacket) -> Unit
) {
private val buffer = mutableListOf<Byte>()
fun append(bytes: ByteArray) {
buffer.addAll(bytes.toList())
drain()
}
private fun drain() {
while (true) {
if (buffer.size < 4) return
val headerIndex = buffer.indexOfFirst { it == 0xAA.toByte() }
if (headerIndex == -1) {
buffer.clear()
return
}
if (headerIndex > 0) {
repeat(headerIndex) { buffer.removeAt(0) }
}
if (buffer.size < 4) return
val command = buffer[1].toInt() and 0xFF
val payloadLength = buffer[2].toInt() and 0xFF
val packetLength = 4 + payloadLength
if (buffer.size < packetLength) return
val packetBytes = buffer.take(packetLength).toByteArray()
repeat(packetLength) { buffer.removeAt(0) }
if (!isChecksumValid(packetBytes)) {
continue
}
val payload = packetBytes.copyOfRange(3, 3 + payloadLength)
onPacket(RawPacket(command, payload))
}
}
private fun isChecksumValid(packet: ByteArray): Boolean {
var sum = 0
for (i in 0 until packet.lastIndex) {
sum = (sum + (packet[i].toInt() and 0xFF)) and 0xFF
}
val checksum = packet.last().toInt() and 0xFF
return sum == checksum
}
}
这段代码不是重点,重点是这个动作本身:先把字节流整理成确定消息,再碰业务。
如果这一步不做,后面很多"为什么状态不对"其实都没资格讨论,因为你连消息边界都还没收清楚。
第二层:把原始包翻译成业务事件
切完包之后,下一步也别急着改 UI。因为完整消息也不等于业务状态。
很多设备返回的是"事件",不是"最终状态"。比如:
- 这条包表示开始切 ANC
- 那条包表示切换完成
- 另一条包告诉你当前模式编号
- 再另一条包补充侧边状态位
如果你把每条协议包都直接映射成 UI 状态,页面很容易闪烁、回滚、短暂错乱。这个问题在耳机类项目里尤其明显,因为设备初始化时会连续推很多同步包。
更稳的做法是中间再加一层,把原始包翻译成领域事件。
kotlin
sealed interface DeviceEvent {
data class BatteryUpdated(val left: Int, val right: Int) : DeviceEvent
data class AncModeReported(val mode: Int) : DeviceEvent
data class DeviceNameReported(val name: String) : DeviceEvent
data object SyncFinished : DeviceEvent
}
class PacketDecoder {
fun decode(packet: RawPacket): DeviceEvent? {
return when (packet.command) {
0x10 -> decodeBattery(packet.payload)
0x20 -> decodeAncMode(packet.payload)
0x30 -> decodeDeviceName(packet.payload)
0x7F -> DeviceEvent.SyncFinished
else -> null
}
}
private fun decodeBattery(payload: ByteArray): DeviceEvent? {
if (payload.size < 2) return null
val left = payload[0].toInt() and 0xFF
val right = payload[1].toInt() and 0xFF
return DeviceEvent.BatteryUpdated(left, right)
}
private fun decodeAncMode(payload: ByteArray): DeviceEvent? {
if (payload.isEmpty()) return null
return DeviceEvent.AncModeReported(payload[0].toInt() and 0xFF)
}
private fun decodeDeviceName(payload: ByteArray): DeviceEvent? {
val name = payload.toString(Charsets.UTF_8)
return DeviceEvent.DeviceNameReported(name)
}
}
这一层的意义是把协议细节和业务状态隔开。 到这里为止,我们还没有碰 UI,也没有碰 ViewModel,只是把:
- 字节流 变成
- 协议包 再变成
- 业务事件
这一步做完,系统的可读性会好很多。后面要查问题时,你会很清楚自己现在拿到的是"设备发来的哪种事件",而不是只能盯着一串十六进制猜。
第三层:不要来一包改一次 UI
很多 BLE 页面状态乱,不是因为解析错了,而是更新得太勤快了。
比如连接成功后,设备可能会连续推:
- 当前电量
- ANC 模式
- 设备名
- 佩戴状态
- 某个插件是否 ready
如果你每来一条就立刻改 UI,页面当然会跳。开发时你看日志会觉得"同步得很完整",用户看起来却像页面没准备好。
所以第三层通常是状态收敛层。这里适合放在 ViewModel 里,用 StateFlow 去做。
kotlin
data class DeviceUiState(
val deviceName: String = "",
val leftBattery: Int? = null,
val rightBattery: Int? = null,
val ancMode: Int? = null,
val isReady: Boolean = false
)
class DeviceViewModel : ViewModel() {
private val _uiState = MutableStateFlow(DeviceUiState())
val uiState: StateFlow<DeviceUiState> = _uiState.asStateFlow()
fun onDeviceEvent(event: DeviceEvent) {
_uiState.update { old ->
when (event) {
is DeviceEvent.BatteryUpdated -> old.copy(
leftBattery = event.left,
rightBattery = event.right
)
is DeviceEvent.AncModeReported -> old.copy(
ancMode = event.mode
)
is DeviceEvent.DeviceNameReported -> old.copy(
deviceName = event.name
)
DeviceEvent.SyncFinished -> old.copy(
isReady = true
)
}
}
}
}
这个写法有个很实际的好处:状态变化被统一收口了。 不是 BLE 回调想改什么就改什么,不是解析器想改什么就改什么,而是所有变化都先变成事件,再由状态层决定怎么合并。
到这里,链路就清楚了:
rust
BLE 回调 -> 切包器 -> 解析器 -> DeviceEvent -> ViewModel -> UI
很多 BLE 项目最后能稳,就是因为中间多了这几层。
把这几层串起来看
如果把它们放在一起,大概会是这样:
kotlin
class ProtocolPipeline(
private val onEvent: (DeviceEvent) -> Unit
) {
private val decoder = PacketDecoder()
private val framer = PacketFramer { packet ->
val event = decoder.decode(packet) ?: return@PacketFramer
onEvent(event)
}
fun onBytesReceived(bytes: ByteArray) {
framer.append(bytes)
}
}
在 BLE 管理层里接起来:
kotlin
class BleManager(
private val pipeline: ProtocolPipeline
) {
fun onCharacteristicChanged(value: ByteArray) {
pipeline.onBytesReceived(value)
}
}
在 ViewModel 里接业务事件:
kotlin
class DeviceViewModel : ViewModel() {
private val _uiState = MutableStateFlow(DeviceUiState())
val uiState = _uiState.asStateFlow()
val pipeline = ProtocolPipeline(::onDeviceEvent)
private fun onDeviceEvent(event: DeviceEvent) {
_uiState.update { old ->
when (event) {
is DeviceEvent.BatteryUpdated -> old.copy(
leftBattery = event.left,
rightBattery = event.right
)
is DeviceEvent.AncModeReported -> old.copy(
ancMode = event.mode
)
is DeviceEvent.DeviceNameReported -> old.copy(
deviceName = event.name
)
DeviceEvent.SyncFinished -> old.copy(
isReady = true
)
}
}
}
}
页面层只订阅 uiState:
ini
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState.collect { state ->
binding.tvName.text = state.deviceName
binding.tvBattery.text = "${state.leftBattery ?: "-"} / ${state.rightBattery ?: "-"}"
binding.tvAnc.text = state.ancMode?.toString() ?: "-"
binding.progressBar.isVisible = !state.isReady
}
}
}
这样做以后,排查问题也会直接很多。
如果页面状态不对,你可以按层查:
- BLE 回调有没有进
- 切包器有没有切出完整消息
- 解析器有没有解出正确事件
- ViewModel 有没有正确收敛状态
- UI 有没有只展示
uiState
这比以前那种"回调里直接 parse 然后直接改页面"的写法好太多了,因为每一层都能单独定位。
还有一种问题:你收到了"中间状态",不是"最终状态"
这也是 BLE 项目里很常见的一种错觉。
比如设备切 ANC 时,可能会依次发:
ANC_SWITCHINGANC_MODE=2ANC_READY
如果你在收到第一条时就把 UI 当成最终状态去改,很容易出现页面闪一下、又变一次、最后再变一次。 这不是解析错了,而是你没有区分"过程事件"和"稳定状态"。
更稳一点的写法是把"过程事件"和"稳定状态"分开:
kotlin
sealed interface DeviceEvent {
data object AncSwitching : DeviceEvent
data class AncModeReported(val mode: Int) : DeviceEvent
data object AncReady : DeviceEvent
}
data class DeviceUiState(
val ancMode: Int? = null,
val ancBusy: Boolean = false
)
然后收敛时别乱改:
kotlin
fun onDeviceEvent(event: DeviceEvent) {
_uiState.update { old ->
when (event) {
DeviceEvent.AncSwitching -> old.copy(ancBusy = true)
is DeviceEvent.AncModeReported -> old.copy(ancMode = event.mode)
DeviceEvent.AncReady -> old.copy(ancBusy = false)
}
}
}
这样页面展示出来的感觉会稳定得多。用户看到的是"正在切换"然后"切换完成",不是模式值在那跳来跳去。
写 BLE 项目时,一个很值钱的判断
如果你已经到了"数据回来了但业务不对"这个阶段,后面最值得养成的习惯就是别再问:
"为什么设备发回来的值不对?"
先问这三个问题:
- 这是不是一条完整协议消息
- 这条消息是不是最终业务状态
- 这条状态是不是应该现在就改到 UI
这三个问题一旦问清楚,很多看起来很玄的问题都会变得正常。
因为 BLE 项目里最容易乱的,从来不是蓝牙连接本身,而是你把底层字节、协议事件、页面状态这三件事揉在一起了。
最后一句话
收到字节流,只说明设备说话了。 它不等于设备把"业务状态"直接交给你了。
真实项目里,中间通常还要过三道坎:
- 切包
- 解码
- 状态收敛
这三层如果省掉,代码一开始会显得很省事,后面就会一直觉得哪儿都差一点。 这三层如果立住了,BLE 项目会一下子清楚很多。