很多人刚开始写 Android 蓝牙的时候,都会觉得这东西虽然坑多,但还不算特别复杂。
无非就是扫描、连接、发现服务、开通知、发命令、收数据。经典蓝牙那边也差不多,配对、建 socket、起线程、读写流。流程看起来都挺直,API 也都能找到。前期就算遇到点问题,查一查,补一补,通常也能继续往前写。
所以很多蓝牙项目一开始都不难看。真正让人难受的,往往不是第一版,而是第二版、第三版以后。设备功能多了,页面多了,重连、初始化、同步、OTA 这些东西都接进来以后,代码就会慢慢变成另一种味道。
最明显的感觉通常不是“写不动了”,而是“越来越不好改了”。
你会发现,连接明明成功了,页面却还不能点;有些状态断开以后会清,有些不会;日志单看每一步都像正常,但拼起来就是乱的;同一个设备,现在到底算连上了、准备好了,还是只是看起来连上了,不同地方给出来的答案还不一样。
很多人到了这个阶段,会下意识把问题归到 Android 蓝牙 API 头上。这个判断不能说错,蓝牙栈确实有很多不稳定和历史包袱。但真正让项目越写越乱的,往往不是某一个 API 有多坑,而是你从一开始就没有把“状态”这件事想清楚。
蓝牙项目最容易让人掉进去的地方,就是它表面上看起来像一条线,实际上却从来不是一条线。
你以为自己写的是一个流程:扫到设备,连上设备,初始化完成,开始操作。可真实项目里,这几件事根本不是同一层的东西。连接成功,不等于链路已经可用。链路可用,不等于协议已经 ready。协议 ready,也不等于页面现在就该让用户随便点。这里面只要有两层被你混在一起,前面还不一定看得出来,后面功能一多,问题就会开始往外冒。
最常见的一种混乱,就是太早把“connected”当成“everything is ready”。
这件事几乎每个蓝牙项目前期都会犯。onConnectionStateChange() 一进 STATE_CONNECTED,心里就会很自然地觉得已经好了,UI 也跟着切到可操作态。可你真做过一阵子以后就知道,连上只是开始。BLE 这边你可能还没发现服务,还没拿到对的 characteristic,还没把通知真正打开,还没做初始化命令交换。SPP 那边你可能也只是 socket 建起来了,后面的握手、读线程、协议同步都还没完成。
最早期的代码,很多都会长这样:
override fun onConnectionStateChange(
gatt: BluetoothGatt,
status: Int,
newState: Int
) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
isConnected = true
view.showConnected()
view.enableOperate()
}
}
这段代码的问题不在于它不能跑,而在于它默认把“连接建立”和“系统可操作”当成了一件事。
也就是说,连接建立这件事,本身只是“通道通了”,它离“系统真的 ready”还差很远。
这个距离如果你在代码里没有明确表达出来,后面系统就会开始说一些模棱两可的话。页面会告诉用户设备已经好了,但设备其实还没进入可工作状态;按钮会提前亮起来,但命令发下去又没有真正意义;有的地方认为现在应该等初始化回包,有的地方已经开始拿它当正常在线设备去操作了。最后你看到的表象通常会被说成一句很笼统的话:蓝牙状态不同步。
但“不同步”很多时候并不是某个状态值错了,而是你一开始就没有把它们分开。
所以后面更稳一点的写法,通常不会再只保留一个 isConnected,而是至少把“连上了”和“能用了”拆开:
data class DeviceSessionState(
val connected: Boolean = false,
val transportReady: Boolean = false,
val protocolReady: Boolean = false
) {
val operable: Boolean
get() = connected && transportReady && protocolReady
}
这段代码本身并不高级,但它至少表达清楚了一件事:operable 不是蓝牙一连上就天然成立的。
我后来越来越觉得,蓝牙项目难写,不是因为事件多,而是因为事件太容易把人骗了。每一个回调单独看都很合理,connected 合理,servicesDiscovered 合理,descriptorWrite 合理,第一条设备消息回来了也合理。问题是,如果每来一个事件,你都顺手改一批全局状态,最后整个系统到底处于什么阶段,就会越来越难说清楚。
这也是很多项目为什么前期“能跑”,后期却越来越难维护。因为它的运行不是由一个清楚的状态模型在驱动,而是由一堆先后抵达的回调在临时拼接。设备少、页面少的时候,这种写法还撑得住。等你开始做自动重连,开始跨页面共享设备状态,开始加 OTA,开始让初始化流程依赖设备回包往前推,这种临时拼接就会越来越吃力。
比如很多项目后面都会慢慢长成这种样子:
var isConnected = false
var isInit = false
var isReady = false
var isSyncing = false
var isOta = false
单看每个变量都不奇怪,但它们混在一起以后,真正麻烦的不是值对不对,而是没人能再说清楚这些值之间到底是什么关系。
最后最折磨人的,不是某一个 bug 本身,而是你根本说不清这个 bug 到底该算哪一层的问题。
有时候你明明是在查一个“按钮为什么不能点”的 UI 问题,顺着顺着发现其实是协议初始化没完成。有时候你以为是连接断了,后来看日志才发现连接没断,只是业务侧还没 ready。有时候你觉得是设备状态没同步,结果真正乱掉的是页面自己维护了一套跟底层不一致的可操作状态。
这种混乱一旦形成,项目就会进入一种很别扭的阶段。不是不能继续做,而是每加一个功能,你都得先想半天:这个状态到底该谁改,这个断开到底该不该重置,这个页面恢复的时候要不要复用旧状态,这个“已连接”到底是不是用户理解里的“已经能用”。
很多人这时候会继续加判断,像是这里补一个 if,那里补一个标记位,先把眼前的问题压过去。短期当然有效,但补得越多,说明你的状态边界其实越模糊。因为一个真正清楚的系统,不应该总靠“这个时候例外一下”“那个回调到了先别动”来维持秩序。
我自己现在再看蓝牙项目,已经不太会先想“这个 API 怎么调”,而会先想另一件事:这个设备从不可用到可用,中间到底经过了哪几个真正不同的阶段。
哪怕只是很粗地先收成一个状态流,也会比到处散着布尔值稳一点:
sealed class DeviceSession {
data object Disconnected : DeviceSession()
data object Connecting : DeviceSession()
data object Connected : DeviceSession()
data object Preparing : DeviceSession()
data object Ready : DeviceSession()
data object Syncing : DeviceSession()
data object Updating : DeviceSession()
data class Error(val message: String) : DeviceSession()
}
不一定非得写成 sealed class,枚举也行,分模块也行。重点不是形式,而是你要让项目里的人一眼看出来:现在到底进行到哪一步了。
这个问题听起来有点虚,但其实特别具体。因为你只要把它想清楚,很多东西都会顺下来。你会知道连接成功以后还不能马上开放操作,会知道页面展示的“已连接”和业务意义上的“可操作”不该是一回事,也会知道断开以后哪些状态该清,哪些状态不能跟着一起乱清。很多以前只能靠经验硬压住的问题,最后都会变成更普通的工程问题。
说到底,蓝牙项目真正难的地方,从来不只是通信本身,而是它把链路、协议、设备、业务、UI 这几件事硬绑在了一起。你如果没有主动把它们拆开,项目就会默认用最偷懒的方式长下去。前期看不出太大问题,后面却会越来越像一团缠住的线。
所以很多时候,救一个蓝牙项目,真不一定是去多背几个 BLE 回调,也不一定是把某段收发代码再重写一遍。更重要的,反而是回过头问一句:我们现在到底用什么方式,描述这个设备从“连上了”到“真的能用了”的全过程?
这个问题要是一直含糊着,代码大概率就会越来越乱。这个问题一旦被想明白,很多原来看起来很玄的蓝牙问题,反而会突然变得没那么玄了。