Android BLE 收到字节流以后,为什么业务状态还是不对

做 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)
}

这段代码看着很顺,问题也正出在"太顺"了。它默认了三件事:

  1. 这次回调里的 value 刚好是一条完整消息
  2. 这条消息刚好能直接解释成业务状态
  3. 这个状态现在就应该立刻改到 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
        }
    }
}

这样做以后,排查问题也会直接很多。

如果页面状态不对,你可以按层查:

  1. BLE 回调有没有进
  2. 切包器有没有切出完整消息
  3. 解析器有没有解出正确事件
  4. ViewModel 有没有正确收敛状态
  5. UI 有没有只展示 uiState

这比以前那种"回调里直接 parse 然后直接改页面"的写法好太多了,因为每一层都能单独定位。

还有一种问题:你收到了"中间状态",不是"最终状态"

这也是 BLE 项目里很常见的一种错觉。

比如设备切 ANC 时,可能会依次发:

  • ANC_SWITCHING
  • ANC_MODE=2
  • ANC_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 项目时,一个很值钱的判断

如果你已经到了"数据回来了但业务不对"这个阶段,后面最值得养成的习惯就是别再问:

"为什么设备发回来的值不对?"

先问这三个问题:

  1. 这是不是一条完整协议消息
  2. 这条消息是不是最终业务状态
  3. 这条状态是不是应该现在就改到 UI

这三个问题一旦问清楚,很多看起来很玄的问题都会变得正常。

因为 BLE 项目里最容易乱的,从来不是蓝牙连接本身,而是你把底层字节、协议事件、页面状态这三件事揉在一起了。

最后一句话

收到字节流,只说明设备说话了。 它不等于设备把"业务状态"直接交给你了。

真实项目里,中间通常还要过三道坎:

  • 切包
  • 解码
  • 状态收敛

这三层如果省掉,代码一开始会显得很省事,后面就会一直觉得哪儿都差一点。 这三层如果立住了,BLE 项目会一下子清楚很多。

相关推荐
莪_幻尘1 小时前
Prompt 工程化落地:从"手工咒语"到工业级软件系统
前端
荒天帝1 小时前
Android App 最强APM来袭
前端
vim怎么退出1 小时前
我给 Claude Code 写了一个自适应学习 Skill,7 天刷完浏览器原理
前端·人工智能
逍遥归来1 小时前
UICollectionViewDiffableDataSource 刷新方案总结
前端
小黑兔斯基2 小时前
前端html+ css布局
前端
Awu12272 小时前
🍎Claude Code Playground:我愿称之为「前端调参神器」
前端·人工智能·aigc
clue2 小时前
让微信小程序也能发PATCH
前端·后端
luback2 小时前
前端把页面用PDF导出
前端·pdf·reactjs·html2canvas
豹哥学前端2 小时前
10分钟彻底搞懂 window 对象、全局环境与 JS 引擎
前端·面试