
1. 为什么要在 Android GIS 里做一层通道抽象做过 Android GIS 采集端的人大概都有过这种体验项目一开始只对接一个 GPS 模块代码里直接开个串口读 NMEA 就完事了。等到客户说我们还有一款蓝牙 RTK加一套再过两个月现场有台 USB 的测深仪再加一套最后数据要实时回传到指挥中心又加一套网络通道。四套代码各写各的读线程、粘包处理、重连逻辑、坐标系转换全散落在四个地方改一个 bug 要翻四个文件测试同事每次回归都要把四种设备全插一遍。这个项目要解决的就是这件事在 Android 端为 GIS 数据采集做一层统一的通道抽象让串口、蓝牙、USB、网络这四种物理链路在业务层看起来是同一个东西。业务代码只关心我收到一条完整的 GIS 报文和我要发一条指令出去至于这条报文是从 RS232 转 USB 来的、从经典蓝牙 SPP 来的、还是从 TCP 长连接来的上层完全无感。它适合谁看如果你正在做测绘、管线巡检、林业调查、地质勘探这类带外业采集的 Android 应用或者你手上有一堆异构的传感器设备要接入同一个 App那这套思路基本可以直接抄。就算你不做 GIS只要涉及多种通信通道 统一协议解析这套抽象同样成立。我下面会把设计取舍、核心代码结构、参数计算、踩过的坑全部摊开讲尽量让你看完就能落地。先说清楚一个前提这里的统一抽象不是指把物理层也统一了——串口的波特率、蓝牙的 UUID、USB 的 VID/PID、网络的 IP 端口这些差异是客观存在的抽象层要做的是把差异收敛到配置里把共性提炼到接口上。这个边界如果一开始没划清楚后面会非常痛苦。2. 整体架构设计与选型思路拆解2.1 四层结构从物理链路到业务报文我把整个通道层拆成四层从上到下依次是业务层只认GisFrame对象负责解析 NMEA、自定义二进制协议、JSON 报文等。通道抽象层定义IDataChannel接口统一 open/close/send/receive 语义。通道实现层SerialChannel、BluetoothChannel、UsbChannel、NetworkChannel 四个实现。物理驱动层Android 的UsbManager、BluetoothSocket、FileInputStream串口、Socket。这么分的好处是业务层永远不 import 任何跟android.hardware.usb或java.net.Socket相关的东西。我见过太多项目在 ViewModel 里直接new Socket()结果单元测试根本没法跑因为 Android 的 Socket 在 JVM 上行为不一致。分层之后通道实现层可以单独 mock业务逻辑用纯 JVM 测试就能覆盖。为什么是四层而不是三层因为物理驱动层是 Android 框架强绑定的你没法完全抽象掉它硬要合并到实现层会导致实现层代码又臭又长。分开之后实现层只做协议适配 状态管理驱动层只做系统 API 调用职责清晰。2.2 接口设计为什么用回调而不是阻塞读IDataChannel的核心方法我最终定成这样interface IDataChannel { fun open(config: ChannelConfig, callback: ChannelCallback) fun close() fun send(data: ByteArray): Boolean fun isConnected(): Boolean val channelType: ChannelType } interface ChannelCallback { fun onConnected(channel: IDataChannel) fun onDisconnected(channel: IDataChannel, reason: String) fun onDataReceived(channel: IDataChannel, data: ByteArray) fun onError(channel: IDataChannel, error: ChannelError) }关键决策点在于onDataReceived回调。早期我用的是阻塞式read(): ByteArray业务层开个线程死循环读。问题在于蓝牙断连时read会抛 IOException串口拔掉时read返回 -1网络超时时read卡住不动——三种异常语义完全不同业务层要写三套判断。改成回调之后所有异常都被归一化成onDisconnected或onError业务层只需要处理断了和错了两种情况。另一个决策是send返回 Boolean 而不是抛异常。因为发送失败在弱网和蓝牙场景下太常见了用异常控制流程性能差且容易漏 catch。返回 false 让调用方自己决定是重试还是丢弃。2.3 配置对象把物理差异关进笼子四种通道的配置差异极大我用一个ChannelConfig密封类来承载sealed class ChannelConfig { data class Serial( val devicePath: String, // /dev/ttyS1 val baudRate: Int, // 9600 val dataBits: Int 8, val stopBits: Int 1, val parity: Int 0 ) : ChannelConfig() data class Bluetooth( val macAddress: String, val uuid: String 00001101-0000-1000-8000-00805F9B34FB, val secure: Boolean true ) : ChannelConfig() data class Usb( val vendorId: Int, val productId: Int, val baudRate: Int 115200, val interfaceIndex: Int 0 ) : ChannelConfig() data class Network( val host: String, val port: Int, val protocol: NetProtocol NetProtocol.TCP, val reconnectIntervalMs: Long 3000 ) : ChannelConfig() }用密封类而不是一个塞满可空字段的大对象好处是编译器帮你做穷尽性检查。when(config)的时候漏掉一个分支 IDE 直接标红这在通道类型还会继续增加比如以后加 LoRa的场景下非常关键。2.4 选型对比为什么不用现成的库市面上有usb-serial-for-android、AndroidAsync这类库我评估过最后决定自己写核心抽象只在 USB 层依赖usb-serial-for-android。原因如下表方案优点缺点是否采用全自研完全可控无依赖冲突USB 驱动层工作量大部分采用usb-serial-for-androidUSB 转串口成熟稳定只管 USB不管蓝牙网络USB 层采用AndroidAsync网络层封装好体积大与协程风格不搭不采用纯 OkHttp/原生 Socket轻量需要自己处理重连网络层采用核心逻辑是USB 转串口的芯片型号太多FT231X、CH340、CP2102、PL2303自己写驱动不现实直接用成熟库而串口、蓝牙、网络这三块的 Android API 相对稳定自己封装反而更贴合业务。3. 四种通道的核心实现细节3.1 串口通道Android 上其实没有官方 API这是很多新手的第一个坑Android SDK 根本没有提供串口 API。你在网上搜到的android.hardware.SerialPort是第三方 jar 或者需要 root 后直接操作/dev/ttyS*设备节点。主流做法有两种。第一种是直接用FileInputStream/FileOutputStream打开设备节点val file File(/dev/ttyS1) val fis FileInputStream(file) val fos FileOutputStream(file)但这样打开的设备波特率是默认值要改波特率必须走ioctl系统调用。所以第二种做法是用 JNI 调用open()tcsetattr()这也是大多数开源串口库的做法。我实际项目里用的是封装好的SerialPort类核心是 JNI 层int fd open(path, O_RDWR | O_NOCTTY | O_NDELAY); struct termios options; tcgetattr(fd, options); cfsetispeed(options, B9600); cfsetospeed(options, B9600); options.c_cflag | (CLOCAL | CREAD); options.c_cflag ~CSIZE; options.c_cflag | CS8; // 8 数据位 options.c_cflag ~PARENB; // 无校验 options.c_cflag ~CSTOPB; // 1 停止位 tcsetattr(fd, TCSANOW, options);波特率的选择有讲究。GIS 设备里普通 GPS 模块 9600 就够NMEA 语句每秒也就几百字节但 RTK 差分数据、激光雷达点云这种高吞吐场景9600 会直接丢包。我一般按这个经验值选普通 GPS / 北斗模块9600 或 115200RTK 差分数据115200 起步数据量大上 460800测深仪 / 声呐19200 到 115200看厂商工业传感器多数 9600少数 38400计算依据很简单假设每秒要传 10KB 数据加上起始位停止位实际需要10 * 1024 * 10 102400bit/s那 115200 才勉强够留余量就该上 230400。永远不要贴着理论值选波特率串口没有流控的情况下丢包是静默的你根本不知道数据少了。注意Android 上访问/dev/ttyS*通常需要 root 或者设备厂商开放权限。消费级手机基本拿不到工业手持机和平板一般可以。如果你的目标设备是普通手机串口这条路走不通得靠 USB 转串口。3.2 蓝牙通道经典蓝牙 SPP 才是 GIS 设备的主流热词里出现了杰理蓝牙HC05 蓝牙模块经典蓝牙协议这些指向的都是经典蓝牙 SPPSerial Port Profile而不是 BLE。原因很实际GIS 外业设备RTK 主机、手持测距仪大多用 SPP 做透传因为 SPP 的吞吐和稳定性比 BLE 好而且配对一次之后重连简单。SPP 的核心 UUID 是固定的00001101-0000-1000-8000-00805F9B34FB。连接流程val device bluetoothAdapter.getRemoteDevice(macAddress) val socket device.createRfcommSocketToServiceRecord(SPP_UUID) bluetoothAdapter.cancelDiscovery() // 关键发现过程会拖慢连接 socket.connect() val input socket.inputStream val output socket.outputStream这里有个大坑socket.connect()是阻塞的而且某些机型上会卡住几十秒不返回。我的处理是把它放到独立线程并加超时控制val connectThread Thread { try { socket.connect() callback.onConnected(this) } catch (e: IOException) { callback.onError(this, ChannelError.ConnectTimeout) } } connectThread.start() connectThread.join(10000) // 10 秒超时 if (connectThread.isAlive) { socket.close() // 强制中断 }热词里HC05 蓝牙模块连接不上是高频问题我总结下来 90% 是这几个原因一是没先cancelDiscovery发现过程占用射频资源二是 UUID 写错有些模块用的是自定义 UUID三是模块还在被上一个连接占用需要断电重启四是 Android 12 之后BLUETOOTH_CONNECT权限没动态申请。BLE 我也做过但用在 GIS 上要谨慎。BLE 的单包有效载荷默认只有 20 字节MTU 23传 NMEA 这种短报文还行传差分数据就得靠requestMtu协商到 512而且不同手机协商结果不一样兼容性很烦。如果设备支持 SPP优先用 SPP。3.3 USB 通道Host 模式 权限申请 芯片识别Android 做 USB 通信走的是 USB Host 模式手机当主机设备当从机。热词里usb转串口ft231x usb uart驱动stm32 如何做 usb 设备都指向这个场景。第一步是枚举设备并申请权限val usbManager getSystemService(Context.USB_SERVICE) as UsbManager val deviceList usbManager.deviceList val device deviceList.values.first { it.vendorId targetVid it.productId targetPid } if (!usbManager.hasPermission(device)) { val permissionIntent PendingIntent.getBroadcast( context, 0, Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE ) usbManager.requestPermission(device, permissionIntent) }权限申请是异步的通过 BroadcastReceiver 回调。这里必须用FLAG_IMMUTABLEAndroid 12 之后 PendingIntent 不加 flag 会直接崩。拿到权限后用usb-serial-for-android打开val drivers UsbSerialProber.getDefaultProber().findAllDrivers(usbManager) val driver drivers.first { it.device device } val connection usbManager.openDevice(driver.device) val port driver.ports[0] port.open(connection) port.setParameters(115200, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE)芯片识别是难点。FT231X、CH340、CP2102、PL2303 的 VID/PID 各不相同usb-serial-for-android内置了常见芯片的 prober但国产杂牌芯片经常识别不出来。我的做法是维护一张扩展表芯片VIDPID备注FTDI FT231X0x04030x6015官方 prober 支持CH3400x1A860x7523官方 prober 支持CP21020x10C40xEA60官方 prober 支持PL23030x067B0x2303部分山寨片 PID 不同杂牌 CDC-ACM0x04830x5740需自定义 prober遇到识别不出的就自定义一个UsbSerialProber把 VID/PID 加进去。热词里amlogic usb burning tool是烧录工具跟运行时通信不是一回事别混淆。3.4 网络通道TCP 长连接 心跳 断线重连网络通道相对成熟但 GIS 场景有特殊性外业信号差4G 时断时续所以重连逻辑必须健壮。我的实现是 TCP 长连接 应用层心跳class NetworkChannel : IDataChannel { private var socket: Socket? null private var heartbeatTimer: Timer? null private fun startHeartbeat() { heartbeatTimer Timer().apply { scheduleAtFixedRate(object : TimerTask() { override fun run() { if (!sendHeartbeat()) { reconnect() } } }, 0, 30000) // 30 秒心跳 } } private fun reconnect() { close() Thread.sleep(reconnectIntervalMs) open(config, callback) } }心跳间隔怎么定太短费电费流量太长断线发现慢。我的经验是30 秒移动网络 NAT 超时一般是 5 分钟30 秒心跳足够保活断线后最多 30 秒就能发现对 GIS 数据回传来说可以接受。如果业务要求实时性高可以缩到 10 秒但要注意功耗。粘包处理是网络和串口共同的难题。TCP 是流式协议一次read可能读到半条报文也可能读到三条半。我的处理是维护一个缓冲区按协议头尾或长度字段切分private val buffer ByteArrayOutputStream() private fun onRawData(data: ByteArray) { buffer.write(data) val bytes buffer.toByteArray() var offset 0 while (offset bytes.size) { val frame tryParseFrame(bytes, offset) ?: break callback.onDataReceived(this, frame) offset frame.size } // 保留未完整的数据 buffer.reset() buffer.write(bytes, offset, bytes.size - offset) }这段代码看着简单但边界条件极多帧头跨包、长度字段本身跨包、缓冲区无限增长导致 OOM。我加了最大缓冲限制比如 64KB超了直接清空并报错防止恶意或异常数据把内存撑爆。4. 统一抽象层的关键实现4.1 通道管理器生命周期与状态机四个通道不能各管各的需要一个ChannelManager统一调度class ChannelManager { private val channels ConcurrentHashMapString, IDataChannel() fun register(id: String, channel: IDataChannel) { channels[id] channel } fun openChannel(id: String, config: ChannelConfig, callback: ChannelCallback) { channels[id]?.open(config, callback) } fun closeAll() { channels.values.forEach { it.close() } channels.clear() } }用ConcurrentHashMap是因为通道的打开/关闭可能在不同线程触发比如蓝牙在子线程连上后回调。状态机是这里最容易出 bug 的地方一个通道可能处于 IDLE、CONNECTING、CONNECTED、DISCONNECTING、ERROR 五种状态如果不在抽象层统一管理业务层会收到重复的 onConnected 或者关闭后还收到 onDataReceived。我的做法是在每个通道实现里加一个AtomicReferenceChannelState所有状态转换走 CASprivate val state AtomicReference(ChannelState.IDLE) private fun transitionTo(newState: ChannelState): Boolean { while (true) { val current state.get() if (!isValidTransition(current, newState)) return false if (state.compareAndSet(current, newState)) return true } }这样即使多线程同时调用 open也只有一个能成功。4.2 数据分发从字节流到 GIS 报文抽象层收到的是ByteArray业务层要的是GisFrame。中间需要一个解析器链interface FrameParser { fun parse(data: ByteArray): ListGisFrame? fun encode(frame: GisFrame): ByteArray } class NmeaParser : FrameParser { /* 解析 $GPGGA 等语句 */ } class BinaryParser : FrameParser { /* 解析自定义二进制协议 */ } class JsonParser : FrameParser { /* 解析 JSON 报文 */ }为什么用解析器链而不是 if-else因为 GIS 项目经常要同时支持多种协议GPS 走 NMEA测深仪走二进制后台指令走 JSON。用责任链模式每个解析器先判断这数据是不是我的是就解析不是就传给下一个。NMEA 解析有个细节校验和验证。$GPGGA,123519,4807.038,N,...*47里的*47是前面所有字符的异或校验。很多廉价模块的校验和是错的如果你严格校验会全部丢弃。我的做法是默认校验但提供开关遇到不靠谱的模块就关掉。4.3 线程模型一个通道一个读线程四种通道的读取方式不同但都需要独立线程串口FileInputStream.read()阻塞蓝牙socket.inputStream.read()阻塞USBport.read()阻塞可设超时网络socket.getInputStream().read()阻塞统一用HandlerThread或者Executors.newSingleThreadExecutor()管理。不要用主线程也不要用 AsyncTask已废弃。我倾向用单线程池因为可以复用而且关闭时shutdownNow()能中断阻塞读。private val readExecutor Executors.newSingleThreadExecutor { r - Thread(r, channel-read-${channelType.name}) } private fun startReadLoop() { readExecutor.execute { val buffer ByteArray(4096) while (isConnected()) { try { val len inputStream.read(buffer) if (len 0) { callback.onDataReceived(this, buffer.copyOf(len)) } else if (len 0) { callback.onDisconnected(this, EOF) break } } catch (e: IOException) { callback.onDisconnected(this, e.message ?: IO error) break } } } }缓冲区大小 4096 是经验值。太小会导致频繁系统调用太大浪费内存。GIS 报文一般不超过 1KB4096 足够一次读完多条。5. 实操过程与核心环节实现5.1 从零搭建项目结构与依赖先给一个可以直接抄的工程结构app/ src/main/java/com/example/gis/ channel/ IDataChannel.kt ChannelConfig.kt ChannelCallback.kt ChannelState.kt ChannelManager.kt impl/ SerialChannel.kt BluetoothChannel.kt UsbChannel.kt NetworkChannel.kt parser/ FrameParser.kt NmeaParser.kt BinaryParser.kt jni/ serial_port.c ui/ MainActivity.kt依赖清单build.gradledependencies { implementation com.github.mik3y:usb-serial-for-android:3.7.0 implementation org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 // 串口 JNI 需要自己编译 so或者用现成的 serialport 库 }权限声明AndroidManifest.xmluses-permission android:nameandroid.permission.BLUETOOTH_CONNECT / uses-permission android:nameandroid.permission.BLUETOOTH_SCAN / uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.ACCESS_NETWORK_STATE / uses-feature android:nameandroid.hardware.usb.host /注意BLUETOOTH_CONNECT和BLUETOOTH_SCAN是 Android 12API 31引入的运行时权限必须在代码里动态申请光在 Manifest 里声明没用。老代码用BLUETOOTH和BLUETOOTH_ADMIN的在 Android 12 上会直接失败。5.2 串口通道完整实现串口通道的完整实现我按可运行的标准写class SerialChannel : IDataChannel { private var fileInputStream: FileInputStream? null private var fileOutputStream: FileOutputStream? null private var serialPort: SerialPort? null private val state AtomicReference(ChannelState.IDLE) private val readExecutor Executors.newSingleThreadExecutor() override fun open(config: ChannelConfig, callback: ChannelCallback) { require(config is ChannelConfig.Serial) if (!state.compareAndSet(ChannelState.IDLE, ChannelState.CONNECTING)) return try { serialPort SerialPort( File(config.devicePath), config.baudRate, config.dataBits, config.stopBits, config.parity ) fileInputStream serialPort.getInputStream() fileOutputStream serialPort.getOutputStream() state.set(ChannelState.CONNECTED) callback.onConnected(this) startReadLoop(callback) } catch (e: Exception) { state.set(ChannelState.ERROR) callback.onError(this, ChannelError.OpenFailed(e.message)) } } private fun startReadLoop(callback: ChannelCallback) { readExecutor.execute { val buffer ByteArray(4096) while (state.get() ChannelState.CONNECTED) { try { val len fileInputStream?.read(buffer) ?: -1 if (len 0) { callback.onDataReceived(this, buffer.copyOf(len)) } else if (len 0) { break } } catch (e: IOException) { if (state.get() ChannelState.CONNECTED) { callback.onDisconnected(this, 串口读取异常: ${e.message}) } break } } } } override fun send(data: ByteArray): Boolean { return try { fileOutputStream?.write(data) fileOutputStream?.flush() true } catch (e: IOException) { false } } override fun close() { state.set(ChannelState.DISCONNECTING) readExecutor.shutdownNow() try { fileInputStream?.close() fileOutputStream?.close() serialPort?.close() } catch (e: IOException) { /* ignore */ } state.set(ChannelState.IDLE) } override fun isConnected() state.get() ChannelState.CONNECTED override val channelType ChannelType.SERIAL }几个实操要点flush()必须调用否则数据可能卡在缓冲区shutdownNow()会中断阻塞的 read但某些设备上 read 不响应中断所以还要 close 流状态判断要放在 catch 里再查一次因为 close 时也会抛 IOException不能误报断连。5.3 蓝牙通道完整实现class BluetoothChannel : IDataChannel { private var socket: BluetoothSocket? null private var inputStream: InputStream? null private var outputStream: OutputStream? null private val state AtomicReference(ChannelState.IDLE) private val readExecutor Executors.newSingleThreadExecutor() override fun open(config: ChannelConfig, callback: ChannelCallback) { require(config is ChannelConfig.Bluetooth) if (!state.compareAndSet(ChannelState.IDLE, ChannelState.CONNECTING)) return val adapter (context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager).adapter if (!adapter.isEnabled) { callback.onError(this, ChannelError.BluetoothDisabled) state.set(ChannelState.ERROR) return } adapter.cancelDiscovery() val device adapter.getRemoteDevice(config.macAddress) val uuid UUID.fromString(config.uuid) val connectThread Thread { try { socket if (config.secure) { device.createRfcommSocketToServiceRecord(uuid) } else { device.createInsecureRfcommSocketToServiceRecord(uuid) } socket?.connect() inputStream socket?.inputStream outputStream socket?.outputStream state.set(ChannelState.CONNECTED) callback.onConnected(this) startReadLoop(callback) } catch (e: IOException) { state.set(ChannelState.ERROR) callback.onError(this, ChannelError.ConnectFailed(e.message)) close() } } connectThread.start() // 超时保护 Thread { connectThread.join(15000) if (connectThread.isAlive) { close() callback.onError(this, ChannelError.ConnectTimeout) } }.start() } // startReadLoop、send、close 与串口类似略 }蓝牙的坑比串口多。第一createRfcommSocketToServiceRecord在某些国产 ROM 上会失败需要反射调用隐藏的createRfcommSocket方法传 channel 1。第二连接成功后如果对端主动断开read返回 -1 而不是抛异常要处理。第三Android 13 之后蓝牙权限更细BLUETOOTH_CONNECT必须授权才能拿到设备名。5.4 USB 通道完整实现class UsbChannel(private val context: Context) : IDataChannel { private var port: UsbSerialPort? null private var connection: UsbDeviceConnection? null private val state AtomicReference(ChannelState.IDLE) private val readExecutor Executors.newSingleThreadExecutor() override fun open(config: ChannelConfig, callback: ChannelCallback) { require(config is ChannelConfig.Usb) if (!state.compareAndSet(ChannelState.IDLE, ChannelState.CONNECTING)) return val usbManager context.getSystemService(Context.USB_SERVICE) as UsbManager val device usbManager.deviceList.values.firstOrNull { it.vendorId config.vendorId it.productId config.productId } if (device null) { callback.onError(this, ChannelError.DeviceNotFound) state.set(ChannelState.ERROR) return } if (!usbManager.hasPermission(device)) { // 触发权限申请结果通过 BroadcastReceiver 回调 requestPermission(usbManager, device, config, callback) return } doOpen(usbManager, device, config, callback) } private fun doOpen( usbManager: UsbManager, device: UsbDevice, config: ChannelConfig.Usb, callback: ChannelCallback ) { try { val driver UsbSerialProber.getDefaultProber() .findAllDrivers(usbManager) .firstOrNull { it.device device } ?: run { callback.onError(this, ChannelError.DriverNotFound) state.set(ChannelState.ERROR) return } connection usbManager.openDevice(device) port driver.ports[config.interfaceIndex] port?.open(connection) port?.setParameters( config.baudRate, 8, UsbSerialPort.STOPBITS_1, UsbSerialPort.PARITY_NONE ) state.set(ChannelState.CONNECTED) callback.onConnected(this) startReadLoop(callback) } catch (e: Exception) { state.set(ChannelState.ERROR) callback.onError(this, ChannelError.OpenFailed(e.message)) } } private fun startReadLoop(callback: ChannelCallback) { readExecutor.execute { val buffer ByteArray(4096) while (state.get() ChannelState.CONNECTED) { try { val len port?.read(buffer, 1000) ?: -1 // 1 秒超时 if (len 0) { callback.onDataReceived(this, buffer.copyOf(len)) } } catch (e: Exception) { if (state.get() ChannelState.CONNECTED) { callback.onDisconnected(this, USB 读取异常: ${e.message}) } break } } } } // send、close 略 }USB 的 read 一定要设超时这里是 1000ms否则拔掉设备时 read 会永久阻塞线程泄漏。这是我在实际项目里踩过的坑测试同事拔了 USB 线App 看起来正常但后台线程一直挂着插拔十几次之后线程池满了整个通道层瘫痪。5.5 网络通道完整实现class NetworkChannel : IDataChannel { private var socket: Socket? null private var inputStream: InputStream? null private var outputStream: OutputStream? null private val state AtomicReference(ChannelState.IDLE) private val readExecutor Executors.newSingleThreadExecutor() private var heartbeatTimer: ScheduledExecutorService? null private var config: ChannelConfig.Network? null private var callback: ChannelCallback? null override fun open(config: ChannelConfig, callback: ChannelCallback) { require(config is ChannelConfig.Network) this.config config this.callback callback if (!state.compareAndSet(ChannelState.IDLE, ChannelState.CONNECTING)) return readExecutor.execute { try { socket Socket().apply { connect(InetSocketAddress(config.host, config.port), 10000) soTimeout 0 // 读不超时靠心跳检测 keepAlive true tcpNoDelay true // GIS 小报文禁用 Nagle } inputStream socket?.getInputStream() outputStream socket?.getOutputStream() state.set(ChannelState.CONNECTED) callback.onConnected(this) startHeartbeat() startReadLoop(callback) } catch (e: IOException) { state.set(ChannelState.ERROR) callback.onError(this, ChannelError.ConnectFailed(e.message)) scheduleReconnect() } } } private fun startHeartbeat() { heartbeatTimer Executors.newSingleThreadScheduledExecutor() heartbeatTimer?.scheduleAtFixedRate({ if (state.get() ChannelState.CONNECTED) { if (!send(HEARTBEAT_BYTES)) { handleDisconnect(心跳发送失败) } } }, 30, 30, TimeUnit.SECONDS) } private fun scheduleReconnect() { val cfg config ?: return readExecutor.execute { Thread.sleep(cfg.reconnectIntervalMs) state.set(ChannelState.IDLE) open(cfg, callback!!) } } // startReadLoop、send、close 略 }tcpNoDelay true是 GIS 场景的关键设置。Nagle 算法会把小报文攒起来一起发延迟可能到 200ms。GIS 的定位报文都是小包禁用 Nagle 能显著降低延迟。代价是网络包数量增加但在 4G 环境下这点开销可以接受。6. 常见问题与排查技巧实录6.1 通道层高频问题速查表现象可能原因排查方法解决方案串口打开失败权限不足 / 设备节点不存在ls -l /dev/ttyS*看权限root 或换设备串口收到乱码波特率不匹配逐个波特率试确认设备手册蓝牙连不上未 cancelDiscovery / UUID 错抓 logcat 看异常先取消发现核对 UUID蓝牙频繁断连信号弱 / 省电策略看 RSSI关闭电池优化USB 识别不到VID/PID 不在 prober 里adb shell dumpsys usb自定义 proberUSB 读阻塞未设超时看线程栈read 加超时参数网络频繁重连心跳太短 / NAT 超时抓包看 RST心跳 30 秒数据粘包未做帧切分打印原始字节加缓冲区切分内存持续增长缓冲区未清理Profiler 看堆限制缓冲上限关闭后仍回调状态机未同步加日志看时序CAS 状态转换6.2 三个我踩过的深坑第一个坑蓝牙连接后立刻发数据丢包。原因是socket.connect()返回时底层链路还没完全就绪。我的解决是在onConnected之后延迟 200ms 再允许发送或者先发一个探测包等回应。这个延迟在文档里根本不会写是实测出来的。第二个坑USB 热插拔后旧连接没释放。用户拔掉 USB 再插上UsbManager.deviceList里会出现新对象但旧的UsbDeviceConnection还占着资源。必须在onDetached广播里主动 close否则插拔几次后openDevice返回 null。第三个坑网络通道在弱网下疯狂重连。最初重连间隔设 1 秒结果地铁里信号差App 每秒重连一次电量哗哗掉。改成指数退避1s、2s、4s、8s、最大 30s。这个策略在弱网下能显著降低功耗。6.3 调试工具与技巧串口调试我用串口调试助手先在 PC 上验证设备协议确认波特率和报文格式后再上 Android。蓝牙用nRF Connect看服务和特征虽然它是 BLE 工具但看 SPP 的配对状态也有用。USB 抓包用usb抓包工具如 Wireshark USBPcap在 PC 上抓确认设备描述符和端点配置。网络直接用 Wireshark 抓 TCP 流。日志是排查通道问题的命根子。我在每个通道的关键节点都打了日志open 开始/成功/失败、每次 read 的字节数、send 的内容、状态转换。日志用统一 TAG方便过滤private fun log(msg: String) { Log.d(Channel-${channelType.name}, [${Thread.currentThread().name}] $msg) }带上线程名能快速看出是不是线程安全问题。6.4 性能与稳定性优化通道层的性能瓶颈通常在数据拷贝。ByteArray在 read、回调、解析之间来回拷贝大数据量时 GC 压力大。优化手段是用ByteBuffer池或者直接传 offsetlength避免copyOf。我实测在 115200 波特率下每秒约 11KB 数据拷贝开销可以忽略但如果是 USB 高速设备比如激光雷达就必须优化。稳定性方面所有阻塞操作都要有超时所有资源都要在 finally 里释放所有状态转换都要原子。这三条是通道层不崩的铁律。我见过太多项目因为一个read没超时导致整个 App ANR。7. 通道扩展与后续演进这套抽象最大的价值在于加新通道的成本极低。比如以后要加 LoRa只需要实现IDataChannel接口写一个LoraChannel在ChannelManager里注册业务层一行代码都不用改。这就是抽象层的意义。如果要做多通道并发比如同时收 GPS 和测深仪ChannelManager天然支持每个通道独立线程、独立状态、独立回调。业务层通过channelId区分数据来源即可。再往上一层可以做一个通道优先级策略网络可用时优先走网络网络断了自动切蓝牙蓝牙也断了用串口。这个切换逻辑放在ChannelManager里对业务层完全透明。我在一个应急指挥项目里做过这个效果很好外业人员根本不用管当前用的是哪条链路。最后分享一个我在实际项目里总结的小经验通道层的单元测试一定要用真实设备跑一遍。Mock 能覆盖逻辑但覆盖不了 Android 各厂商 ROM 的差异。我吃过亏代码在模拟器上跑得好好的到了某国产平板上蓝牙死活连不上最后发现是 ROM 改了createRfcommSocket的行为。所以关键通道至少要在两三个品牌的真机上验证别偷懒。