Android GIS多通道采集:串口、蓝牙、USB、网络统一抽象实践

发布时间:2026/10/2 15:57:20
Android GIS多通道采集:串口、蓝牙、USB、网络统一抽象实践 1. 四种物理通道为什么值得做一层统一抽象做 Android GIS 采集端的朋友大概率都遇到过这种局面外业设备五花八门有的走 RS232 串口接高精度 GNSS 接收机有的用蓝牙连手持测距仪有的插 USB 转串口读全站仪数据还有的干脆走 TCP 网络从远端拉实时差分流。每接一种硬件代码里就多一套读写逻辑、多一套权限处理、多一套断线重连最后整个工程变成通道适配代码的垃圾场。我在一个地籍测量项目里就吃过这个亏。最初只支持蓝牙 RTK代码写得挺顺后来甲方要求兼容串口全站仪我硬生生复制了一份蓝牙的读写逻辑改吧改吧就上了再后来要加 USB 和网络四个通道各写各的结果一个数据分包粘包的 bug 要在四个地方分别修修完三个漏一个现场返工两次。那次之后我才下决心把这四种通道抽象成一套统一接口。这篇内容适合两类人看一类是正在做 Android GIS 外业采集、需要对接多种测量硬件的开发者另一类是想理解如何用一套抽象屏蔽底层物理差异的工程师。我会把通道抽象的设计思路、四种通道各自的坑、以及实测中总结出来的经验完整讲一遍。核心关键词就四个Android、GIS、串口、蓝牙、USB、网络围绕它们怎么被一套抽象统一收口展开。先说结论统一抽象的价值不在于少写代码而在于把通道差异收敛到一个边界内让上层的 GIS 数据解析、坐标转换、入库逻辑完全不用关心数据是从哪根线来的。这个边界划得好不好直接决定了你后面加第五种、第六种通道时是改一行配置还是重写半个模块。2. 抽象层的接口设计把通道和协议彻底分开2.1 为什么不能只抽象一个 read/write很多人第一反应是定义一个Channel接口里面放open()、read()、write()、close()四个方法就完事了。我一开始也是这么想的结果发现根本不够用。问题出在物理通道的差异不只是怎么读写还有什么时候能读读到的数据是不是完整的一帧断了之后怎么恢复。举个具体例子。串口是全双工的字节流你read到的可能是半帧数据蓝牙 SPP 本质上也是字节流但它的连接建立是异步的open()返回时通道往往还没真正就绪USB 转串口在 Android 上要走 USB Host 模式需要申请权限、开端点、起独立读线程网络 TCP 则有连接超时、心跳保活、粘包拆包一整套问题。如果接口只有 read/write这些差异就会全部泄漏到上层。所以我的做法是把接口拆成两层传输层Transport负责字节流的收发和连接生命周期协议层Protocol负责把字节流切成完整帧。这两层分开之后串口和蓝牙在传输层几乎可以共用同一份实现因为它们都是面向字节流的、有连接状态的通道。2.2 我实际用的接口定义下面是我在项目里沉淀下来的接口用 Kotlin 写的Java 项目照着翻译即可interface Transport { // 连接状态回调四种通道统一走这里 fun open(config: ChannelConfig, listener: TransportListener) fun close() fun write(data: ByteArray): Boolean fun isConnected(): Boolean } interface TransportListener { fun onConnected() fun onDisconnected(reason: String) fun onDataReceived(raw: ByteArray) // 原始字节不做任何解析 fun onError(code: Int, msg: String) }关键点在于onDataReceived给的是原始字节不做任何帧切分。帧切分交给协议层interface FrameParser { // 喂入原始字节吐出完整帧可能一次吐多帧也可能一帧都不吐 fun feed(raw: ByteArray): ListByteArray fun reset() }这样设计之后上层 GIS 业务只需要关心FrameParser吐出来的完整帧至于这帧是从串口来的还是从网络来的它完全不知道也不需要知道。我在项目里换通道时业务层代码一行没动只换了一个Transport实现类。2.3 ChannelConfig 怎么承载四种通道的差异四种通道的配置项差异很大我用一个统一的ChannelConfig数据类来承载用不到的字段留空字段串口蓝牙USB网络portName/dev/ttyS3设备 MAC设备 VID/PID空baudRate115200空空空dataBits/stopBits/parity8/1/None空空空host/port空空空192.168.x.x:8080reconnectInterval2000ms3000ms2000ms5000msheartbeat空空空30s这个表看着简单但每一项背后都有踩坑经验。比如串口的波特率全站仪常用 9600GNSS 接收机常用 115200配错了就是一堆乱码蓝牙的重连间隔不能太短否则会和系统蓝牙栈打架网络的心跳必须加否则运营商的 NAT 会在几分钟内悄悄掐断空闲连接。提示ChannelConfig建议做成不可变对象Kotlin 的 data class 或 Java 的 builder 模式因为多线程环境下配置被中途修改是极难排查的 bug 来源。3. 串口通道Android 上最反直觉的一条路3.1 Android 串口为什么不能像 Linux 那样直接 open做过嵌入式 Linux 的人第一次在 Android 上搞串口会懵明明/dev/ttyS*就在那儿为什么open()就是打不开原因有两层。第一层是权限Android 的/dev节点默认只有 root 或特定 group 能访问普通 App 拿不到第二层是SELinux即使你 chmod 了节点SELinux 策略也会拦你。所以 Android 上搞串口主流就三条路一是设备厂商在系统层开放权限并预置好节点权限最省心但依赖硬件厂商配合二是用 root 方案直接操作节点外业设备一般不给 root三是走 USB 转串口把串口问题转化成 USB 问题。我在项目里主要用第一种和第三种第二种基本放弃。如果设备是定制的外业采集终端通常厂商会提供带权限的/dev/ttyS*节点你只需要用FileInputStream/FileOutputStream包一下就能读写。但要注意Android 的串口读写必须放在独立线程主线程碰 IO 直接 ANR。3.2 串口参数配置的坑串口配置里最容易出错的是波特率和校验位。我整理了一张常见 GIS 外设的配置对照设备类型波特率数据位停止位校验高精度 GNSS 接收机11520081None全站仪960081None测距仪960071Even气象传感器480081None注意测距仪那行7 位数据位 偶校验是很多日系测距仪的老传统配成 8N1 就是满屏乱码。我第一次接某款测距仪时折腾了一下午最后翻手册才发现是 7E1。还有一个隐蔽的坑串口的 read 是阻塞的但返回的字节数不确定。你可能一次读到 3 个字节也可能读到 200 个字节。所以串口层绝对不能假设一次 read 等于一帧必须把读到的字节原样喂给FrameParser由它来负责拼帧。3.3 串口读线程的写法我的串口读线程大致长这样class SerialReader(private val input: InputStream, private val listener: TransportListener) : Thread() { Volatile private var running true private val buffer ByteArray(1024) override fun run() { while (running) { try { val len input.read(buffer) // 阻塞读 if (len 0) { listener.onDataReceived(buffer.copyOf(len)) } } catch (e: IOException) { if (running) listener.onDisconnected(serial read error: ${e.message}) break } } } fun shutdown() { running false } }这里有个细节buffer.copyOf(len)是必须的不能直接把buffer传出去。因为下一轮 read 会覆盖 buffer 内容如果上层是异步处理数据就会读到被篡改的字节。这个 bug 极其隐蔽表现为偶发数据错乱我调了两天才定位到。注意串口断开时input.read()会抛 IOException这是正常的断开信号不要当成致命错误。但如果你在shutdown()之后还收到 IOException就要忽略它否则会误报断线。4. 蓝牙通道经典蓝牙 SPP 的异步陷阱4.1 为什么选 SPP 而不是 BLEGIS 外业设备里蓝牙通道绝大多数走的是经典蓝牙的 SPP串口协议而不是 BLE。原因很实际SPP 的吞吐量比 BLE 大得多适合持续传输 GNSS 差分数据而且大量老设备只支持 SPP。BLE 虽然省电但它的 MTU 小、连接间隔受限传测绘数据流体验很差。所以我的蓝牙 Transport 实现是基于BluetoothSocket的 SPP。这里第一个坑就是BluetoothSocket.connect()是阻塞的而且可能阻塞十几秒。如果你在主线程调用直接 ANR即使在子线程调用也要设置超时否则设备不在范围内时会一直卡着。4.2 蓝牙连接的异步化处理我的做法是把连接过程也异步化用回调通知结果class BluetoothTransport : Transport { private var socket: BluetoothSocket? null private var reader: Thread? null override fun open(config: ChannelConfig, listener: TransportListener) { Thread { try { val device BluetoothAdapter.getDefaultAdapter() .getRemoteDevice(config.portName) // portName 存 MAC socket device.createRfcommSocketToServiceRecord(SPP_UUID) socket?.connect() // 阻塞但已在子线程 listener.onConnected() startReader(listener) } catch (e: Exception) { listener.onError(ERR_CONNECT, e.message ?: bt connect failed) } }.start() } }SPP_UUID是固定的00001101-0000-1000-8000-00805F9B34FB所有 SPP 设备都用这个不用改。4.3 蓝牙断线重连的节奏控制蓝牙最烦的是断线。外业环境里设备走远了、电量低了、旁边有干扰都会断。我的重连策略是指数退避第一次断线等 1 秒重连失败等 2 秒再失败等 4 秒最多退到 16 秒然后保持 16 秒间隔一直重试。为什么不用固定间隔因为固定 1 秒重连会和系统蓝牙栈抢资源实测下来反而更容易连不上。指数退避给了蓝牙栈喘息时间成功率明显更高。这个经验是我在野外连续测试三天总结出来的文档里不会写。还有一个细节重连之前一定要先close()掉旧的 socket。我见过有人直接重新connect()结果旧 socket 的资源没释放连几次之后系统蓝牙就假死了必须重启蓝牙才能恢复。5. USB 通道把 USB 转串口当成另一种串口5.1 Android USB Host 的基本流程USB 通道在 Android 上走的是USB Host 模式流程比串口和蓝牙都复杂枚举设备、申请权限、找接口、找端点、开读线程。核心步骤是通过UsbManager.getDeviceList()拿到设备列表按 VID/PID 匹配目标设备用UsbManager.requestPermission()申请权限这是个异步过程用户会看到一个授权弹窗拿到权限后openDevice()找到对应的UsbInterface和UsbEndpoint用UsbDeviceConnection.bulkTransfer()做批量读写。这里最大的坑是权限申请是异步的而且用户可能拒绝。所以 USB Transport 的open()不能同步返回结果必须等权限回调。我在实现里用一个PendingIntentBroadcastReceiver来接收授权结果拿到结果后再继续后续流程。5.2 USB 转串口芯片的差异USB 转串口不是插上就能用的芯片型号不同驱动逻辑完全不同。常见的有 FTDIFT232、Silicon LabsCP210x、WCHCH34x、ProlificPL2303几大类。Android 系统自带部分驱动但很多芯片需要你自己实现控制传输来配置波特率。芯片系统自带驱动配置波特率方式FT232部分支持控制传输设置CP210x部分支持控制传输设置CH340多数不支持需自行实现PL2303老版本支持控制传输设置我的建议是如果项目可控优先选 FTDI 或 CP210x 芯片的设备因为开源社区有成熟的参考实现能省大量时间。CH340 虽然便宜但 Android 上适配起来很痛苦我踩过一次就不想再碰。5.3 USB 读写的线程模型USB 的bulkTransfer也是阻塞的而且它的超时参数很关键。我一般设 0 表示无限等待然后放在独立线程里循环读class UsbReader(private val conn: UsbDeviceConnection, private val endpoint: UsbEndpoint, private val listener: TransportListener) : Thread() { Volatile private var running true private val buffer ByteArray(endpoint.maxPacketSize) override fun run() { while (running) { val len conn.bulkTransfer(endpoint, buffer, buffer.size, 0) if (len 0) { listener.onDataReceived(buffer.copyOf(len)) } } } }注意buffer的大小用endpoint.maxPacketSize这是端点能一次传输的最大字节数用大了浪费用小了会丢数据。提示USB 设备拔出时bulkTransfer会返回 -1 或抛异常这时要主动触发onDisconnected并清理UsbDeviceConnection。忘记close()连接会导致下次插入时无法打开设备。6. 网络通道TCP 粘包才是真正的对手6.1 网络通道和前三者的本质区别串口、蓝牙、USB 本质上都是点对点、有连接、字节流的通道而网络 TCP 虽然也是字节流但它多了粘包和拆包这个绕不开的问题。TCP 是流式协议它不保证你send一次、对方就recv一次。你发了两帧数据对方可能一次收到两帧拼在一起粘包也可能一帧被拆成两次收到拆包。这就是为什么我在第 2 节坚持把FrameParser独立出来。网络通道的Transport只管把收到的字节原样吐出来粘包拆包全部交给FrameParser。这样串口和网络可以共用同一个解析器代码复用率极高。6.2 帧解析器的三种常见策略FrameParser的实现取决于协议格式我总结了三类定长帧每帧固定 N 字节最简单攒够 N 字节就吐一帧分隔符帧用特定字节如0x0D 0x0A分隔需要处理分隔符跨包的情况长度字段帧帧头里带长度字段先读头再读体最通用但实现最复杂。GIS 设备里NMEA 语句用的是分隔符帧以$开头、\r\n结尾很多私有协议用的是长度字段帧。我项目里两种都实现了用策略模式切换。6.3 网络通道的心跳与重连网络通道必须加心跳。原因很现实移动网络和 WiFi 的 NAT 网关会在连接空闲几分钟后悄悄回收映射你以为连接还在实际上早就断了直到下次发数据才发现。我的做法是每 30 秒发一个心跳包如果连续 3 次没收到回应就判定断线并重连。心跳包的内容要和设备协议约定好不能随便发。有些设备收到不认识的数据会直接断开连接这个坑我踩过——当时发了个自定义心跳结果设备直接把我踢了排查半天才发现是心跳格式不对。7. 四种通道的实测对比与选型建议7.1 一张表看清四种通道的脾气维度串口蓝牙USB网络连接稳定性高中高中传输速率高中高取决于网络开发复杂度低中高中断线恢复简单复杂简单复杂适用场景固定外设便携设备高带宽外设远程数据7.2 选型时的实际考量如果设备是固定安装在采集终端上的优先串口最稳。如果是手持设备、需要一定活动范围蓝牙更合适但要接受它偶尔抽风。如果外设数据量大比如高频 GNSS 原始观测值USB 或串口更靠谱。如果数据源在远端那只能网络。我的项目里最终是四种通道全支持通过配置文件切换。实际部署时同一个 App 在不同项目现场用不同通道业务代码完全不用改这就是统一抽象带来的最大价值。7.3 统一抽象带来的额外收益除了代码复用统一抽象还带来两个意外好处。一是测试变简单了我可以写一个MockTransport模拟各种通道行为包括断线、粘包、乱码业务层测试不用真机就能跑。二是问题定位变快了数据异常时我只要看是哪个 Transport 报的错就能快速缩小范围不用在四套逻辑里大海捞针。8. 那些文档里不会写的实操心得8.1 线程模型一定要统一四种通道的读写线程我全部继承同一个BaseChannelThread统一管理running标志、统一异常处理、统一资源释放。这样做的直接好处是不会出现某个通道忘记关线程导致内存泄漏。我早期就是每个通道各写各的线程结果蓝牙通道的读线程在断开后没退出跑一天下来内存涨了几十兆。8.2 数据缓冲区的复用要小心为了减少 GC很多人会复用 ByteArray 缓冲区。但前面说过如果缓冲区被复用而数据还在异步处理就会读到脏数据。我的原则是跨线程传递的数据一律 copy线程内部循环用的缓冲区才复用。这点内存换来的稳定性绝对值。8.3 断线重连要区分主动断开和被动断开主动断开用户点了断开、App 退出不应该触发重连被动断开设备掉线、信号丢失才需要重连。我早期没区分结果用户手动断开后后台还在疯狂重连既费电又让用户困惑。后来加了个manualClose标志位才解决。8.4 日志要打全但要能关外业调试时通道层的日志是救命稻草。我会把每次连接、断开、收发数据的长度都打出来但日志开关必须能动态关闭否则正式发布后日志刷屏会拖慢性能。我用一个全局的LogLevel控制调试时开到 VERBOSE发布时降到 ERROR。8.5 权限申请要提前做蓝牙和 USB 都需要运行时权限。我的经验是在 App 启动或进入采集页时就提前申请而不是等到用户点连接时才申请。因为外业现场用户往往戴着手套、操作不便临时弹权限框体验很差而且一旦拒绝整个流程就卡住了。9. 从四种通道到 N 种通道抽象的扩展性验证9.1 加第五种通道要改多少代码后来项目要加一个蓝牙 BLE通道我实际验证了一下只需要新写一个BleTransport实现Transport接口配置里加几个 BLE 特有的字段业务层和FrameParser一行没改。整个过程半天搞定这在没有抽象之前是不可想象的。9.2 抽象边界划在哪里的判断标准判断抽象边界划得好不好有个简单标准新增一种通道时需要修改的代码是否只集中在一个新文件里。如果需要动到业务层、动到解析层、动到 UI 层那说明边界划错了。我现在的标准是新通道只允许新增一个 Transport 实现类 配置项其他一律不动。9.3 什么情况下不该强行统一也不是所有通道都适合塞进这套抽象。比如某些设备走的是厂商私有 SDKSDK 内部已经封装了连接和数据回调这时候强行套 Transport 接口反而别扭。我的做法是给这类 SDK 写一个薄薄的适配层把 SDK 的回调转成TransportListener的调用适配层之外还是统一的。这套东西我在三个项目里迭代了两轮现在基本稳定了。如果你也在做 Android GIS 的多通道采集建议一开始就把 Transport 和 FrameParser 分开别等到四个通道各写各的再回头重构那个代价我替你试过了真的很大。