把通道差异封进传输层,业务层只认命令与响应。
你手里的 Android 手簿要连一台 RTK 定位设备。这设备不走寻常路:它今天用蓝牙经典(SPP)连,明天换了台机器走 BLE,后天用户插了根 USB 串口线,大后天跑到基站旁边又改成 TCP Socket 走 WiFi。再狠一点,同一台设备同时开两路------串口读定位数据,UDP 把差分数据甩给另一个模块。
如果你给每种通道都写一套独立的 connect / disconnect / read / write,会发生什么?连接状态机每个通道抄一遍,自动重连每个通道抄一遍,事件通知每个通道抄一遍。通道数一旦上到七八个,代码量直接翻好几倍,改一个重连逻辑要同时改七八个地方,漏一个就是线上 bug。这种「每种通道一套逻辑」的写法,在垂直行业(测绘、工业物联网、医疗设备)里几乎必然走向维护失控。更糟的是,业务层被迫记住「现在是蓝牙还是串口」,任何一个新需求(比如「断线后自动切备用通道」)都要穿透所有通道实现去改。
这篇要解决的麻烦就一句话:能不能让上层业务完全不关心底层走的是蓝牙还是串口,只看到「命令」和「响应」? 我在一套工业级 Android 采集软件里验证过多年的做法是------把设备的差异关进传输层,抽象点落在「通道」而不是「设备」。下文是一套用同一套核心抽象撑起 20+ 种设备类型的统一通信架构,连同我踩过的坑一起摊开讲。
先说结论,免得你读到一半忘了主线:设备的物理差异(速度、距离、配对、可靠性)全都属于传输层,业务层不该为这些差异付任何认知成本;你要抽象的不是「一台 RTK 设备」,而是「一条能收发字节的通道」。只要抽象点选在通道上,上层业务写一次,七种通道随便换。
一、通道一多,先崩的是连接管理
垂直行业 Android 应用有个共同痛点:同一套业务逻辑,必须适配多种物理通信通道。以测绘为例,一台 RTK 设备跟手簿之间至少存在下面这几种链路:
- 蓝牙经典(SPP)
- BLE(低功耗蓝牙)
- USB 串口
- TCP Socket(走 WiFi 或有线网)
- UDP
- 手簿内置串口(手簿自己集成了接收机)
- NTRIP(基于网络的 RTCM 差分数据流)
更要命的是,同一台设备经常同时占两条通道。典型如「串口读定位 + WiFi 写差分」。如果每种通道各写一套连接管理、数据收发、状态通知,代码会急剧膨胀,而且逻辑高度重复------你每加一种通道,就要把状态机、重连、事件分发重新实现一遍。
让我把「不做抽象」的代价算给你看。假设有三个通道:蓝牙、串口、TCP。每个通道你都得自己管四件事:
- 连接状态:未连 / 连接中 / 已连 / 已销毁,这四个状态的迁移你写三遍。
- 自动重连:断线以后多久重试、重试几次、失败怎么办,你写三遍。
- 事件通知:连上了、断了、连失败,要回调给业务层,你写三遍。
- 数据解析入口:收到字节怎么交给解析器,你写三遍。
三遍听起来不多,但真实项目里通道是七种起步,而且每种通道的「断线语义」还不一样:蓝牙是配对掉了就断,TCP 是网络抖动静默断,UDP 是根本不保证到达。你这三遍里每一遍的坑都不同,却要对外暴露成同一套语义。一旦哪天要加「断线后切备用通道」这种跨通道能力,你得同时改七处------这就是维护失控的起点。
所以问题的本质不是「怎么连上某一个设备」,而是「怎么让所有通道共享同一套连接语义,让业务层对差异无感」。先上一张通道能力对比表,把差异摆清楚,后面才能谈怎么把它们关进同一层。
| 通道类型 | 典型使用场景 | 速度量级 | 有效距离 | 是否需要配对/授权 | 适合传差分数据 | 一句话坑点 |
|---|---|---|---|---|---|---|
| 蓝牙经典 SPP | 手簿连外置 RTK | 中(~2.1Mbps 理论) | 十米级 | 需要配对 | 可用但占连接 | 配对状态变了连接直接断,得监听广播 |
| BLE | 低功耗传感器 | 低(~1Mbps 理论) | 十米级 | 需要绑定 | 不适合大数据流 | 吞吐小,定位数据流容易丢包 |
| USB 串口 | 插线直连 / OTG | 高且稳 | 线缆长度 | 无需配对 | 非常合适 | 拔插要监听 USB 事件,权限弹窗 |
| TCP Socket | WiFi / 有线网连基站 | 高 | 局域网/互联网 | 无需配对 | 非常合适 | 网络抖动会静默断,必须靠心跳 |
| UDP | 局域网广播差分 | 高、无连接 | 局域网 | 无需配对 | 合适但不可靠 | 不保证到达,差分包丢了不重传 |
| 内置串口 | 手簿内置接收机 | 高且稳 | 板内 | 无需配对 | 合适 | 系统是单例,多业务抢同一口 |
| NTRIP | 网络拉 RTCM 差分 | 取决于网络 | 广域 | 需账号 | 就是干这个的 | 断流要重连并重新请求挂载点 |
看清这张表就明白:这些通道在「速度、距离、是否配对、可靠性」上差异巨大,但对业务层暴露的能力高度一致------开、关、写字节、读字节、告诉我状态变了。差异是物理层的,不该泄漏到业务层。抽象要做的事,就是把这些不一致的「怎么连」收进传输层,只把一致的「能收发」暴露出去。
二、三层抽象:把差异锁在传输层
核心思路是分层抽象,每一层只依赖下一层的抽象接口,不依赖具体实现。底层再乱,上层纹丝不动。这里我实际落地是四层(在规范里常被统称为三层抽象,因为 IO 流层 + 通道实现层同属传输侧),下面按职责拆开讲。
整体的分层长这样(先给个文字版,后面有图):
text
┌──────────────────────────────────────────────────┐
│ 业务层 (API) │
│ 定位数据 · 设备状态 · 配置读写 · 差分数据发送 │
├──────────────────────────────────────────────────┤
│ 设备抽象层 (Device) │
│ 连接状态机 · 自动重连 · 事件分发 · 命令管理 │
├──────────────────────────────────────────────────┤
│ IO流抽象层 (IOStream) │
│ 异步读写线程 · 数据解析管道 · 帧过滤器 │
├──────────────────────────────────────────────────┤
│ 通道实现层 (Implementations) │
│ 蓝牙 · BLE · 串口 · Socket · UDP · USB · NTRIP │
└──────────────────────────────────────────────────┘
四层职责拆开看:
- 业务层:只关心定位数据、设备状态、配置读写、差分数据发送,不认识任何具体通道。
- 设备抽象层(Device):管连接状态机、自动重连、事件分发、命令管理。这是全架构的枢纽。
- IO 流抽象层(IOStream):把任何物理通道归一化成「异步读写字节流」,内含读线程、数据解析管道、帧过滤器。
- 通道实现层:蓝牙、BLE、串口、Socket、UDP、USB、NTRIP 各自落地。
这里要强调一个原则,也是这套架构能不能活下来的关键:依赖倒置 。上层永远指向抽象,绝不指向具体实现。设备抽象层不知道底下是蓝牙还是串口,它只认 AsyncIOStream 这个字节流抽象;业务层不知道底下是 Device 还是 IOAdapterDevice,它只认 Device 暴露的命令与事件。一旦你让业务层 import 了 BluetoothDevice,这层抽象就破了------下次换串口,业务层又得改。
为什么是四层而不是两层、三层?因为「连接管理」和「字节收发」是两件事,必须分开。如果只抽一层 Device 把所有通道塞进去,那每加一个通道你都得在 Device 里写一堆 if (type == BLUETOOTH) 的分支,等于把差异又泄露回了上层。把「字节收发」单独抽成 IOStream,通道实现层就只负责「把物理连接翻译成字节流」,干净得多。
用一张 mermaid 把依赖方向画清楚------注意箭头从上往下,上层的依赖终点都是抽象节点:
关键原则再强调一遍:每一层只依赖下一层的抽象接口,不依赖具体实现。差异被封死在传输侧(IO 流层 + 通道实现层),业务层对底下跑的是七种通道里的哪一种完全无感。这就是「抽象点落在通道」的落点。
三、最底层:一个 AsyncIOStream 收掉所有字节流
所有物理通道剥到最后,本质都是「字节流的读写」。所以最底层只抽象一个异步 IO 流,接口极简------这是整个架构里最薄、也最不该变的一层:
kotlin
abstract class AsyncIOStream {
// 生命周期
abstract fun open()
abstract fun close()
// 写入
abstract fun write(data: ByteArray)
// 读取线程不断回调
protected var onReadCallback: ((ByteArray, Int) -> Unit)? = null
fun setOnReadCallback(callback: (ByteArray, Int) -> Unit) {
onReadCallback = callback
}
}
这里有几个契约要讲清楚,因为子类全靠这几个方法活:
open():建立物理连接。蓝牙是建 RFCOMM socket,串口是开/dev/tty*文件描述符,UDP 是建DatagramSocket。它只负责「连上」,不负责「连成功」------成功与否由读线程或连接结果回调给上层。close():释放资源、关掉连接。必须幂等,重复调用不能崩。write(data):把字节写出去。同步还是异步由实现自己决定,但约定「返回即已提交到底层缓冲区」。onReadCallback:读线程每收到一段字节就回调(ByteArray, Int),第二个参数是有效长度。注意是protected var,只有子类能设,外部通过setOnReadCallback注册。
为什么读要用回调而不是 read() 返回?因为物理通道的读是持续且不可预测的 ------设备随时可能吐数据,你不能让上层阻塞在一个 read() 上等。所以「读」天然是异步的:每个通道实现在内部起一个读线程,循环读字节,读到就抛给 onReadCallback,由 IO 层往上传。上层只管「注册一个回调,数据来了叫我」。
为什么要这么薄?因为越薄越容易换实现、越不容易出 bug。这一层没有任何业务概念,只有「字节」。蓝牙的 open() 和串口的 open() 对上层来说都是「一个能读写字节的东西」。差异被彻底封死在这一层,连「这是蓝牙」这个事实都不往上漏。后面你会看到,换通道对业务层来说就是换一个构建器的事。
四、设备层:状态机、重连、解析器链
设备抽象层(Device)是整个架构的心脏。它把连接状态机、自动重连、事件分发、数据解析器注册这些横切关注点全部收拢,业务层和通道实现都不用再操心。先看完整定义:
kotlin
abstract class Device {
// ── 状态机 ──
enum class ConnectionState {
UNCONNECTED, // 未连接
CONNECTING, // 连接中
CONNECTED, // 已连接
DESTROYED // 已销毁
}
private var state = ConnectionState.UNCONNECTED
private val stateLock = Any()
// ── 自动重连 ──
private var autoReconnectEnabled = false
private var autoReconnectInterval = 3000L
private var reconnectJob: Job? = null
// ── 事件监听 ──
private val listeners = mutableListOf<DeviceEventListener>()
// ── 数据解析器链 ──
private val parsers = mutableListOf<Parser>()
// ── 公开方法 ──
fun connect() {
synchronized(stateLock) {
if (state != ConnectionState.UNCONNECTED) return
state = ConnectionState.CONNECTING
}
dispatchEvent(DeviceEvent.CONNECTING)
doConnect() // 模板方法,子类实现
}
fun disconnect() {
synchronized(stateLock) {
if (state == ConnectionState.DESTROYED) return
}
doDisconnect() // 模板方法,子类实现
synchronized(stateLock) {
state = ConnectionState.UNCONNECTED
}
dispatchEvent(DeviceEvent.DISCONNECTED)
cancelReconnect()
}
// ── 模板方法 ──
protected abstract fun doConnect()
protected abstract fun doDisconnect()
// ── 状态变更通知 ──
protected fun onConnected() {
synchronized(stateLock) {
state = ConnectionState.CONNECTED
}
dispatchEvent(DeviceEvent.CONNECTED)
}
protected fun onConnectFailed() {
synchronized(stateLock) {
state = ConnectionState.UNCONNECTED
}
dispatchEvent(DeviceEvent.CONNECT_FAILED)
tryAutoReconnect()
}
protected fun onDisconnected() {
synchronized(stateLock) {
state = ConnectionState.UNCONNECTED
}
dispatchEvent(DeviceEvent.DISCONNECTED)
tryAutoReconnect()
}
// ── 自动重连 ──
private fun tryAutoReconnect() {
if (!autoReconnectEnabled) return
cancelReconnect()
reconnectJob = CoroutineScope(Dispatchers.IO).launch {
delay(autoReconnectInterval)
connect()
}
}
fun setAutoReconnect(enabled: Boolean, intervalMs: Long = 3000L) {
autoReconnectEnabled = enabled
autoReconnectInterval = intervalMs
if (!enabled) cancelReconnect()
}
// ── 数据分发 ──
protected fun onDataReceived(data: ByteArray, length: Int) {
for (parser in parsers) {
try {
parser.parse(data, length)
} catch (e: Exception) {
// 解析异常不影响其他解析器
}
}
}
fun registerParser(parser: Parser) {
parsers.add(parser)
}
fun unRegisterParser(parser: Parser) {
parsers.remove(parser)
}
}
我把五个设计要点逐一拆开讲,因为每一处都是踩过坑才定下来的:
-
状态机线程安全 。状态变更全用
synchronized(stateLock)包住。否则业务层在多线程里乱调connect(),会建出双连接、状态卡死。connect()一进来先锁状态,不是UNCONNECTED直接 return------这一个判断挡掉了绝大多数并发连接事故。注意DESTROYED状态:disconnect()里如果设备已销毁就直接 return,避免对已释放资源操作。销毁设备时你应该把它置为DESTROYED,之后任何connect都不该再生效。 -
模板方法模式 。
connect()/disconnect()定了骨架:先锁状态、再发事件、最后调doConnect()/doDisconnect()钩子。子类只实现两个钩子,状态机和事件通知永远是对的,子类想写错都难。这条很关键------它把「连接流程的正确性」收口在父类,不 trusting 子类的自觉。 -
自动重连 。
onConnectFailed()和onDisconnected()都会触发tryAutoReconnect(),用协程delay(autoReconnectInterval)在Dispatchers.IO上延迟重连,不阻塞主线程。默认间隔3000L毫秒。setAutoReconnect(enabled, intervalMs)能动态开关;关掉时cancelReconnect()取消在途的重连任务,避免设备都销毁了还在重连。reconnectJob用Job?持有,每次重连前先cancelReconnect()再起新的,防止重连任务叠罗汉。 -
解析器链 。
parsers是个mutableListOf<Parser>,多个解析器能注册到同一设备,做到「一次收数、多方解析」。比如定位解析器和日志解析器同时挂着,各取所需。onDataReceived里对每个parser.parse都包了try-catch------一个解析器抛异常绝不连累其他解析器,这是线上稳定性的关键。没有这层保护,一个脏数据就能让整个解析链路停摆。 -
事件分发 。
listeners持有所有DeviceEventListener,dispatchEvent把状态变化广播出去。业务层只靠监听事件就知道连没连上,不轮询、不猜。配合状态机,连接语义对外是统一的「事件」,不管底下是蓝牙静默断还是 TCP 超时断。
这五个点合起来,就是设备层对外的全部承诺:给我一个能收发的字节流,我还你一套线程安全、可重连、可分发、可多方解析的连接管理。通道实现层完全不用管这些。
顺带说清 Parser 这个接口的两个方法分工,因为它常被误解。parse(data, length) 负责「处理一段原始字节」------比如从 RTCM 流里抠出一条完整帧、或者把 NMEA 句子切成字段;getData() 则返回「这条解析器最近解析出的结构化结果」,定位解析器返回定位对象,日志解析器可能返回 null(它只落盘不产出业务对象)。正是这个 getData() 的存在,让多个解析器能同时挂在一条字节流上、各取所需,而业务层按类型从 parsers 里拿自己要的那个。写新通道时,你通常不用新写解析器,复用已有的定位/日志解析器即可。
这里还有个线程细节值得记:读回调来自通道实现层起的读线程,所以 onDataReceived 以及所有 Parser.parse 都跑在 IO 线程上,不是主线程。业务层在 parse 里拿到数据后,如果要更新 UI,必须自己 post 回主线程------设备层不替你做这层切换,保持自身无 Android 主线程依赖,反而更好测。
五、通道怎么接进来:以蓝牙为例
通道实现层只要做两件事:怎么建立连接、怎么收发字节。业务逻辑一律不归它管。用蓝牙设备当例子最直观,因为所有通道实现都长这个形状:
kotlin
class BluetoothDevice(private val address: String) : Device() {
private var ioStream: BluetoothIOStream? = null
override fun doConnect() {
CoroutineScope(Dispatchers.IO).launch {
try {
val socket = BluetoothAdapter.getDefaultAdapter()
.getRemoteDevice(address)
.createRfcommSocketToServiceRecord(SPP_UUID)
socket.connect()
ioStream = BluetoothIOStream(socket)
ioStream?.setOnReadCallback { data, len ->
onDataReceived(data, len)
}
ioStream?.open()
onConnected()
} catch (e: Exception) {
onConnectFailed()
}
}
}
override fun doDisconnect() {
ioStream?.close()
ioStream = null
}
fun sendData(data: ByteArray): Boolean {
ioStream?.write(data) ?: return false
return true
}
}
注意 doConnect() 里只做一件事链:建 RFCOMM socket → connect() → 包成 BluetoothIOStream → 注册读回调(读到的字节转交给设备层的 onDataReceived)→ open() → onConnected()。连接成不成功,靠 onConnected() 和 onConnectFailed() 这两个钩子把结果交回设备层,由设备层统一管状态和事件。子类完全不用管状态机------甚至连 synchronized 都没碰过。
sendData 也简单:ioStream?.write(data) 写出去,流为空说明没连上,返回 false。上层拿到 false 就知道这次发送没生效,可以决定重试还是报错。注意这里返回 Boolean 而不是抛异常,是为了让发送失败成为「业务可预期的正常情况」,而不是崩溃。
把类关系画成一张图,能看清抽象和实现的边界------Device / AsyncIOStream 是抽象基类,具体通道去继承;IOAdapterDevice 和构建器是围绕 Device 的组合/工厂。所有七种通道(蓝牙、BLE、串口、Socket、UDP、USB、NTRIP)都是同一个形状:doConnect 里把物理连接包成 XxxIOStream,doDisconnect 里关掉:
下面再看一条「业务层发命令」的时序,把上面这几层怎么串起来跑通一次完整链路画出来------这一张图基本就是整篇文章的主线,建议对照着看:
业务层从头到尾只跟 Device 和构建器打交道,没碰过一行蓝牙 API。这就是「抽象点落在通道」的红利:换串口,只要换构建器和 doConnect() 里的几行,上面这段时序一字不改。序列里每个 onXxx 钩子都是设备层收回状态控制权的点------通道实现层永远只是「汇报结果」,不「决定状态」。
六、读写分离:一个设备两条通道
真实项目里有个绕不开的需求:读和写走不同的通道。比如某款手簿,内置接收机从串口吐定位数据,但写差分数据得通过 UDP 甩给另一个模块。一个设备要同时管两个通道------一个读、一个写。这种情况如果硬塞进单通道模型,要么读写互相干扰,要么差分数据把定位数据冲掉。
用适配器模式把多个通道组合进一个 Device,对外仍然是一个设备:
kotlin
class IOAdapterDevice(
private val readDevice: Device?, // 读通道
private val writeDevice: Device?, // 写通道
private val udpDevice: Device? // UDP 写通道(可选)
) : Device() {
// 连接:依次连接所有通道
override fun doConnect() {
powerOn()
readDevice?.connect()
writeDevice?.connect()
udpDevice?.connect()
}
// 断开:依次断开所有通道
override fun doDisconnect() {
readDevice?.disconnect()
writeDevice?.disconnect()
udpDevice?.disconnect()
powerOff()
}
// 发送命令:走写通道
fun sendCommand(cmd: Command) {
writeDevice?.sendCommand(cmd)
}
// 发送差分数据:走写通道或 UDP
fun sendDifferentialData(data: ByteArray) {
writeDevice?.sendDifferentialData(data)
udpDevice?.sendData(data)
}
// 读取数据:走读通道
fun getReceivedData(): Data? {
return readDevice?.getData()
}
}
IOAdapterDevice 自己也是 Device,所以对外接口和单通道设备一模一样------业务层根本不知道底下其实并排跑着三个通道。doConnect / doDisconnect 只是依次连/断各子通道,并顺手 powerOn() / powerOff() 控制硬件供电。sendCommand 走写通道,sendDifferentialData 同时走写通道和 UDP 多播一份,getReceivedData 走读通道。读写分离这件事对业务层完全透明,它拿到的还是「一个设备、一套命令、一个回调」。
这里有个差分数据特有的坑要提醒:走 UDP 发差分是「发了就不管」,网络丢包不会重传,而 RTK 差分丢一包就可能让定位精度掉一截。所以 sendDifferentialData 里多播到 UDP 的同时,往往还要保留一条走写通道(TCP/串口)的可靠链路兜底------这就是代码里 writeDevice?.sendDifferentialData(data) 和 udpDevice?.sendData(data) 两路都发的原因。UDP 那路追求低延迟广覆盖,写通道那路保证关键差分不丢。业务层不用关心这两路怎么分工,适配器替它决定了。
有些设备连上后还要发一串初始化命令,而且命令之间有严格时序:必须等上一条返回 OK,才能发下一条。比如先设波特率、再开数据输出、最后切工作模式,顺序错了设备直接不理你。这用「命令队列 + 响应驱动」解决,而不是在业务层写一堆回调嵌套:
kotlin
class CommandQueueManager(private val device: Device) {
private val commandQueue = ArrayDeque<Command>()
private var firstCommandSent = false
fun enqueue(commands: List<Command>) {
commandQueue.addAll(commands)
// 注册一个临时解析器,监听设备响应
device.registerParser(object : Parser {
override fun parse(data: ByteArray, length: Int) {
val response = String(data, 0, length)
if (response.isEmpty()) return
if (!firstCommandSent) {
sendNext()
firstCommandSent = true
} else if (response.contains("OK") || response.contains("ERROR")) {
sendNext()
}
if (commandQueue.isEmpty()) {
device.unRegisterParser(this) // 队列清空,注销解析器
}
}
override fun getData(): Data? = null
})
}
private fun sendNext() {
commandQueue.poll()?.let { cmd ->
device.sendCommand(cmd)
}
}
}
这段有几个细节值得说。第一,它复用了设备层的解析器链 ------不是另起一套监听,而是 registerParser 挂一个临时解析器去听响应,这样和正常的定位解析完全共存。第二,firstCommandSent 处理「第一条命令怎么发出去」:队列一注册,先发第一条;之后每收到一个 OK 或 ERROR 才 sendNext 推进下一条,严格串行。第三,空响应直接 return 不推进,避免设备心跳之类的中间数据误触发。第四,也是亮点:队列一清空就自动 unRegisterParser 把自己摘掉,不污染正常的定位数据解析流程。这个临时解析器本质就是个「观察者」,任务结束自我注销,干干净净。
七、构建器+工厂:统一创建入口
20+ 种设备类型,如果每种都直接 new,创建逻辑会散落在业务代码各处,换通道就得改业务。用构建器模式把创建收口,让「怎么造一个设备」也变成可替换的:
kotlin
abstract class DeviceBuilder {
var content: DeviceContent? = null
fun content(content: DeviceContent): DeviceBuilder {
this.content = content
return this
}
abstract fun build(): Device
}
// 蓝牙设备构建器
class BluetoothDeviceBuilder : DeviceBuilder() {
override fun build(): Device {
return BluetoothDevice(content)
}
}
// Socket 设备构建器
class SocketDeviceBuilder : DeviceBuilder() {
override fun build(): Device {
return SocketDevice(content)
}
}
// 使用
val device = BluetoothDeviceBuilder()
.content(deviceContent)
.build()
device.connect()
构建器用链式调用 .content(...).build(),把「配置从哪来」和「造什么设备」解耦。注意 content 是 DeviceContent?,也就是前面第八节那套存储抽象------设备造出来就自带自己的配置,业务层不用再单独塞。
更进一步,按设备类型枚举用工厂方法自动选构建器,业务层连构建器类名都不用出现:
kotlin
fun createBuilder(type: DeviceType): DeviceBuilder {
return when (type) {
DeviceType.BLUETOOTH -> BluetoothDeviceBuilder()
DeviceType.BLE -> BleDeviceBuilder()
DeviceType.SOCKET -> SocketDeviceBuilder()
DeviceType.SERIAL_PORT -> SerialPortDeviceBuilder()
DeviceType.UDP -> UdpDeviceBuilder()
// ... 其他类型
else -> throw IllegalArgumentException("Unknown device type: $type")
}
}
为什么既要构建器又要工厂?因为职责不同:构建器负责「单个设备的复杂装配」(链式设参、注入 content),工厂负责「按类型选哪个构建器」。业务层只需要一个 DeviceType 枚举就能拿到设备实例,「新增通道类型零改动上层代码」才真正成立------加一种新通道,只新增一个 XxxDevice + XxxDeviceBuilder + 工厂里加一行 when 分支,业务层一行都不用动。
这套创建抽象还有个隐形收益:可测试性 。单元测试里你可以让工厂返回一个 MockDevice(实现 Device 接口、不碰任何硬件),业务层照常 connect / sendCommand,验证命令时序和状态迁移,全程不需要真机。没有这层抽象,测试只能抱着真设备跑,CI 都建不起来。
八、状态持久化:Content 机制
设备的状态------配置参数、工作模式、连接信息------要持久化存下来,还要能在进程间共享(比如采集服务和应用 UI 是两个进程,都要读同一份设备配置)。这里用一个类似 SharedPreferences 的 DeviceContent 接口把存储实现抽象掉:
kotlin
interface DeviceContent {
fun <T> get(key: String, defaultValue: T): T
fun <T> set(key: String, value: T)
fun contains(key: String): Boolean
fun remove(key: String)
}
class BaseDeviceContent(context: Context) : DeviceContent {
private val prefs = context.getSharedPreferences("device_content", Context.MODE_PRIVATE)
override fun <T> get(key: String, defaultValue: T): T {
@Suppress("UNCHECKED_CAST")
return when (defaultValue) {
is String -> prefs.getString(key, defaultValue) as T
is Int -> prefs.getInt(key, defaultValue) as T
is Long -> prefs.getLong(key, defaultValue) as T
is Float -> prefs.getFloat(key, defaultValue) as T
is Boolean -> prefs.getBoolean(key, defaultValue) as T
else -> throw IllegalArgumentException("Unsupported type")
}
}
override fun <T> set(key: String, value: T) {
prefs.edit().apply {
when (value) {
is String -> putString(key, value)
is Int -> putInt(key, value)
is Long -> putLong(key, value)
is Float -> putFloat(key, value)
is Boolean -> putBoolean(key, value)
else -> throw IllegalArgumentException("Unsupported type")
}
}.apply()
}
// ... 其他方法
}
DeviceContent 是个泛型 K/V 接口,get 靠 defaultValue 的类型做运行时分派------传 String 就走 getString,传 Int 就走 getInt,以此类推。支持的类型是 String / Int / Long / Float / Boolean 五种,SharedPreferences 能存的就这五种,不支持的类型直接抛 IllegalArgumentException,边界清晰。
关键点在于:业务层通过 DeviceContent 读写配置,完全不知道底下是 SharedPreferences 还是别的存储。哪天换成数据库、换成文件、甚至换成跨进程 ContentProvider,只要新写一个 XxxDeviceContent 实现同一个接口,业务层和通道实现一行都不用动------这就是抽象接口隔离存储实现的收益。context.getSharedPreferences("device_content", Context.MODE_PRIVATE) 里的 "device_content" 是存储文件名,MODE_PRIVATE 保证只有本应用能读,进程间共享靠的是同一个文件,UI 进程改了配置,采集进程下次 get 就能拿到。
九、这套抽象到底赚了什么,以及坑在哪
先把架构收益摆成表,再补一张踩坑对照表。收益表来自这套架构在 20+ 设备类型上的长期运行验证,每一行都是「没有抽象时做不到、有了抽象后白捡」的能力:
| 维度 | 收益 |
|---|---|
| 扩展性 | 新增通道类型只需实现 IOStream + 继承 Device,零改动上层代码 |
| 可维护性 | 每种通道的连接逻辑独立,互不干扰 |
| 可测试性 | 可以 Mock Device 做业务层测试,无需真实硬件 |
| 复用性 | IO 适配器让同一设备灵活组合不同通道 |
| 稳定性 | 状态机 + 自动重连保证连接可靠性 |
举个具体的「前后对比」帮你感受收益:没有抽象时,加一种 NTRIP 通道,你要改业务层的连接管理、改状态通知、改重连、改解析入口,至少四五个文件;有了抽象,你只写一个 NtripIOStream + NtripDevice,工厂加一行,createBuilder 返回它,业务层零改动。七种通道每种都省一遍这种改动,一年下来省的是以周计的维护时间。
收益说得再好,落地时坑也不少。下面这张对照表是我踩过的真实症状,按「症状 / 根因 / 解法」列出来,建议直接截图收藏------它把前面每一层的设计动机都对应到了一个会出事的现场:
| 症状 | 根因 | 解法 |
|---|---|---|
| 断线后业务层收不到任何通知,状态卡死 | 状态机没有事件分发,或 onDisconnected 没被调用 |
设备层统一 dispatchEvent,子类只调 onConnected / onDisconnected 钩子 |
并发调用 connect 建出双连接 |
connect 没加状态锁,多个线程同时进连接流程 |
synchronized(stateLock),非 UNCONNECTED 直接 return |
| 某个解析器抛异常,后面全部不解析 | 解析器链没隔离异常 | onDataReceived 里对每个 parser.parse 包 try-catch |
| 读和写混在同一通道,差分数据冲掉定位数据 | 没做读写分离 | IOAdapterDevice 组合读通道 + 写通道 + 可选 UDP |
| 新通道接入要改一堆业务代码 | 创建入口散落各处,直接 new |
构建器 + 工厂方法统一创建,业务只传 DeviceType |
| 初始化命令乱序,设备不响应 | 命令没等响应就下发 | CommandQueueManager 响应驱动,见 OK/ERROR 才发下一条 |
配置写在 SharedPreferences,换存储就改业务 |
业务直接依赖存储实现 | DeviceContent 接口抽象存储 |
| 重连时 UI 卡死 | 重连用同步 sleep 阻塞主线程 |
协程 delay 跑在 Dispatchers.IO |
| 网络型通道静默断线发现不了 | 只靠连接对象状态,没有心跳 | 业务层或 IO 层加心跳,超时触发 onDisconnected 走重连 |
| 设备销毁后还在重连 | 重连任务没随销毁取消 | setAutoReconnect(false) 时 cancelReconnect(),并把状态置 DESTROYED |
业务层 import 了具体通道类 |
抽象点没落在通道,直接依赖实现 | 业务层只持有 Device,通道类型通过 DeviceType 枚举注入 |
最后一行最重要,它是整篇的扣题:抽象点一旦从「通道」滑向「具体设备类」,前面所有收益瞬间归零 。业务层只要 import 了 BluetoothDevice,换串口那天它就又得改。守住「业务层只认 Device」这条线,七种通道才真正对你透明。
最后给一条「加第 N 种通道」的标准动作清单,照着走业务层一行都不用改:
- 写一个
XxxIOStream : AsyncIOStream,在open()里把物理连接翻译成字节流,write()负责写出,读线程把字节喂给onReadCallback。 - 写一个
XxxDevice : Device,只实现doConnect()(建连接 → 包成XxxIOStream→ 注册读回调 →open()→onConnected())和doDisconnect()(关流)。 - 写一个
XxxDeviceBuilder : DeviceBuilder,build()返回XxxDevice。 - 在
createBuilder的when里加一行DeviceType.XXX -> XxxDeviceBuilder()。 - 复用已有的
Parser(定位/日志),不需要新写。
五步里没有一步碰业务层。这就是「抽象点落在通道」能兑现的承诺------扩展成本被压到只有通道侧自己。
回到开头那句判断收尾:设备的差异属于传输层,业务层只该看到命令与响应。当你下次要支持新通道时,先问自己------业务层真的需要知道底下是蓝牙还是串口吗?如果答案是否定的,那把抽象点钉在「通道」上,这套架构就值得直接拿来用。
小结
- 抽象点必须落在「通道」而不是「设备」,业务层只该看到命令与响应,不认识任何具体物理链路。
- 四层抽象(业务层 / 设备层 / IO 流层 / 通道实现层)层层只依赖下一层的接口,差异被封进传输层。
AsyncIOStream只暴露open / close / write加读回调,任何物理通道都能归一化成字节流。Device用模板方法 + 状态机 + 协程重连 + 解析器链,把所有横切关注点收口,子类只写doConnect/doDisconnect。IOAdapterDevice让一个设备读写走不同通道,对业务层完全透明;CommandQueueManager用响应驱动保证命令时序。- 构建器 + 工厂统一创建入口,
DeviceContent隔离存储实现,新增通道类型零改动上层,还能 Mock 测业务。
系列导航:GIS 系列第 9 篇(共 10 篇)。上一篇《火星坐标反解与投影映射》,下一篇《跨进程设备共享服务》。
你现在的项目里,连接管理逻辑是每种通道各写一遍,还是已经抽了一层统一接口?如果要维护三种以上通道,最让你头疼的是状态机、重连,还是读写分离?