Android GIS系列 串口、蓝牙、USB、网络:一套统一抽象怎么扛住四种通道

把通道差异封进传输层,业务层只认命令与响应。

你手里的 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。每个通道你都得自己管四件事:

  1. 连接状态:未连 / 连接中 / 已连 / 已销毁,这四个状态的迁移你写三遍。
  2. 自动重连:断线以后多久重试、重试几次、失败怎么办,你写三遍。
  3. 事件通知:连上了、断了、连失败,要回调给业务层,你写三遍。
  4. 数据解析入口:收到字节怎么交给解析器,你写三遍。

三遍听起来不多,但真实项目里通道是七种起步,而且每种通道的「断线语义」还不一样:蓝牙是配对掉了就断,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 把依赖方向画清楚------注意箭头从上往下,上层的依赖终点都是抽象节点:

flowchart TD A["业务层:定位数据·设备状态·配置·差分"] --> B["设备抽象层:状态机·重连·事件·命令"] B --> C["IO流抽象层:异步读写·解析管道·帧过滤"] C --> D["通道实现层:蓝牙·BLE·串口·Socket·UDP·USB·NTRIP"] B --> E["IO适配器:读写分离·双通道组合"] B --> F["构建器:统一创建入口"] B --> G["Content:状态持久化"]

关键原则再强调一遍:每一层只依赖下一层的抽象接口,不依赖具体实现。差异被封死在传输侧(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)
    }
}

我把五个设计要点逐一拆开讲,因为每一处都是踩过坑才定下来的:

  1. 状态机线程安全 。状态变更全用 synchronized(stateLock) 包住。否则业务层在多线程里乱调 connect(),会建出双连接、状态卡死。connect() 一进来先锁状态,不是 UNCONNECTED 直接 return------这一个判断挡掉了绝大多数并发连接事故。注意 DESTROYED 状态:disconnect() 里如果设备已销毁就直接 return,避免对已释放资源操作。销毁设备时你应该把它置为 DESTROYED,之后任何 connect 都不该再生效。

  2. 模板方法模式 。connect() / disconnect() 定了骨架:先锁状态、再发事件、最后调 doConnect() / doDisconnect() 钩子。子类只实现两个钩子,状态机和事件通知永远是对的,子类想写错都难。这条很关键------它把「连接流程的正确性」收口在父类,不 trusting 子类的自觉。

  3. 自动重连 。onConnectFailed() 和 onDisconnected() 都会触发 tryAutoReconnect(),用协程 delay(autoReconnectInterval) 在 Dispatchers.IO 上延迟重连,不阻塞主线程。默认间隔 3000L 毫秒。setAutoReconnect(enabled, intervalMs) 能动态开关;关掉时 cancelReconnect() 取消在途的重连任务,避免设备都销毁了还在重连。reconnectJob 用 Job? 持有,每次重连前先 cancelReconnect() 再起新的,防止重连任务叠罗汉。

  4. 解析器链 。parsers 是个 mutableListOf<Parser>,多个解析器能注册到同一设备,做到「一次收数、多方解析」。比如定位解析器和日志解析器同时挂着,各取所需。onDataReceived 里对每个 parser.parse 都包了 try-catch------一个解析器抛异常绝不连累其他解析器,这是线上稳定性的关键。没有这层保护,一个脏数据就能让整个解析链路停摆。

  5. 事件分发 。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 里关掉:

classDiagram class AsyncIOStream { <<abstract>> +open() +close() +write(ByteArray) +setOnReadCallback(callback) } class Device { <<abstract>> +connect() +disconnect() +registerParser(Parser) +setAutoReconnect(Boolean, Long) #doConnect() #doDisconnect() #onConnected() #onDataReceived(ByteArray, Int) } class BluetoothDevice { -ioStream: BluetoothIOStream +doConnect() +doDisconnect() +sendData(ByteArray) } class SocketDevice { +doConnect() +doDisconnect() } class IOAdapterDevice { -readDevice: Device -writeDevice: Device -udpDevice: Device +sendCommand(Command) +sendDifferentialData(ByteArray) } class DeviceBuilder { <<abstract>> +content(DeviceContent) +build(): Device } class BluetoothDeviceBuilder { +build(): Device } AsyncIOStream <|-- BluetoothIOStream Device <|-- BluetoothDevice Device <|-- SocketDevice Device <|-- IOAdapterDevice DeviceBuilder <|-- BluetoothDeviceBuilder Device o-- AsyncIOStream : holds IOAdapterDevice o-- Device : 组合读和写通道

下面再看一条「业务层发命令」的时序,把上面这几层怎么串起来跑通一次完整链路画出来------这一张图基本就是整篇文章的主线,建议对照着看:

sequenceDiagram participant App as 业务层 participant Builder as 构建器 participant Dev as 设备层 Device participant IO as IO流层 participant HW as 物理通道 App->>Builder: createBuilder(BLUETOOTH).content(c).build() Builder-->>App: BluetoothDevice 实例 App->>Dev: connect() Dev->>Dev: 状态置 CONNECTING 发 CONNECTING 事件 Dev->>IO: doConnect() 内 open() IO->>HW: socket.connect() 建立物理连接 HW-->>IO: 连接成功 IO-->>Dev: onConnected() Dev->>Dev: 状态置 CONNECTED 发 CONNECTED 事件 Dev-->>App: 事件回调 App->>Dev: sendCommand(cmd) Dev->>IO: write(data) IO->>HW: 字节流写出 HW-->>IO: 设备响应字节 IO-->>Dev: onDataReceived(data, len) Dev->>Dev: 解析器链逐个 parse Dev-->>App: 解析结果回调

业务层从头到尾只跟 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 种通道」的标准动作清单,照着走业务层一行都不用改:

  1. 写一个 XxxIOStream : AsyncIOStream,在 open() 里把物理连接翻译成字节流,write() 负责写出,读线程把字节喂给 onReadCallback。
  2. 写一个 XxxDevice : Device,只实现 doConnect()(建连接 → 包成 XxxIOStream → 注册读回调 → open() → onConnected())和 doDisconnect()(关流)。
  3. 写一个 XxxDeviceBuilder : DeviceBuilder,build() 返回 XxxDevice。
  4. 在 createBuilder 的 when 里加一行 DeviceType.XXX -> XxxDeviceBuilder()。
  5. 复用已有的 Parser(定位/日志),不需要新写。

五步里没有一步碰业务层。这就是「抽象点落在通道」能兑现的承诺------扩展成本被压到只有通道侧自己。

回到开头那句判断收尾:设备的差异属于传输层,业务层只该看到命令与响应。当你下次要支持新通道时,先问自己------业务层真的需要知道底下是蓝牙还是串口吗?如果答案是否定的,那把抽象点钉在「通道」上,这套架构就值得直接拿来用。

小结

  • 抽象点必须落在「通道」而不是「设备」,业务层只该看到命令与响应,不认识任何具体物理链路。
  • 四层抽象(业务层 / 设备层 / IO 流层 / 通道实现层)层层只依赖下一层的接口,差异被封进传输层。
  • AsyncIOStream 只暴露 open / close / write 加读回调,任何物理通道都能归一化成字节流。
  • Device 用模板方法 + 状态机 + 协程重连 + 解析器链,把所有横切关注点收口,子类只写 doConnect / doDisconnect。
  • IOAdapterDevice 让一个设备读写走不同通道,对业务层完全透明;CommandQueueManager 用响应驱动保证命令时序。
  • 构建器 + 工厂统一创建入口,DeviceContent 隔离存储实现,新增通道类型零改动上层,还能 Mock 测业务。

系列导航:GIS 系列第 9 篇(共 10 篇)。上一篇《火星坐标反解与投影映射》,下一篇《跨进程设备共享服务》。

你现在的项目里,连接管理逻辑是每种通道各写一遍,还是已经抽了一层统一接口?如果要维护三种以上通道,最让你头疼的是状态机、重连,还是读写分离?

相关推荐
陈柒吖1 小时前
安卓代码加固(1):加密DEX
android·前端
二流小码农1 小时前
鸿蒙开发:ArrayList,可不会让UI更新哦
android·ios·harmonyos
hai_android1 小时前
深入理解 Java 的四种引用:强引用、软引用、弱引用、虚引用
android·kotlin·android jetpack
恋猫de小郭1 小时前
Flutter Golden Tests:给 AI Agent 的 UI 测试系统
android·前端·flutter
知昂七昂1 小时前
AOSP编译:敲下 `lunch xxx-userdebug`,你知道系统偷偷干了什么吗?
android
阿赛姆电子1 小时前
TVS二极管应用候选表怎样用脚本检查?波形条件不同不能直接排序
产品
千里马学框架5 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台5 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone5 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui