
1. 为什么要在 Android GIS 里做统一通道抽象做过 Android GIS 采集端的人都有一个共同体会设备侧的数据入口从来不是单一的。外业测绘用的 RTK 接收机走串口或蓝牙手持采集棒可能走 USB OTG而指挥中心下发的任务包又走网络。如果每接一种硬件就写一套读取逻辑代码会在两三个月内变成一团乱麻。我在一个管线巡检项目里就吃过这个亏——最初只支持蓝牙 GPS后来客户要求加串口 RTK再后来又要接 USB 条码枪三套代码各自维护字段解析、断线重连、坐标系转换全部重复实现改一个 bug 要改三个地方。这套统一抽象要解决的核心问题就一句话让上层 GIS 业务只面对一个数据流接口底层是串口、蓝牙、USB 还是网络对业务层完全透明。听起来像老生常谈的接口隔离但真正落到 Android 上四种通道的差异远比想象中大串口要处理波特率和 JNI 读写蓝牙分经典和 BLE 两套完全不同的 APIUSB 涉及权限申请和端点配置网络又要考虑长连接和心跳。把这些差异全部收敛到一个抽象层里才是这个项目真正的价值。适合谁来参考如果你正在做 Android 端的 GIS 数据采集、外业调查、设备对接或者任何需要同时兼容多种物理通道的 App这套思路都能直接抄。哪怕你只做单一通道理解这个抽象分层也能帮你把代码写得更干净。下面我按设计思路—核心细节—实操落地—问题排查的顺序把踩过的坑和验证过的方案完整讲一遍。2. 统一抽象的整体设计与选型考量2.1 抽象层的三层结构划分我把整个通道体系拆成三层这个分层是后面所有代码的基础值得先讲清楚。最底层是物理通道层每种通道一个实现类只负责把字节读进来、把字节写出去不关心字节是什么含义。串口通道管文件描述符和波特率蓝牙通道管 Socket 或 GATT 连接USB 通道管端点读写网络通道管 Socket 流。这一层的接口极其简单就是open、read、write、close四个动作。中间是协议解析层负责把原始字节流切成一条条完整报文。GIS 设备常见的协议有 NMEA 0183GPS 定位、自定义二进制帧RTK 差分数据、以及各种条码枪的 ASCII 文本。这一层要处理粘包、半包、校验和是 bug 最集中的地方。最上层是业务数据层把解析后的报文转成 GIS 能用的结构——经纬度、高程、定位质量、时间戳再交给地图渲染或入库。注意三层之间必须严格单向依赖业务层绝对不能 import 任何串口或蓝牙的类。我见过太多项目在业务代码里直接if (isBluetooth)分支那就是抽象失败的标志。2.2 为什么用接口而不是抽象基类选型时我纠结过用abstract class还是interface。最后选接口理由是 Java 单继承的限制——如果某个通道实现类还想继承别的基类比如 USB 通道要继承 Android 的BroadcastReceiver处理权限回调抽象基类就会挡路。接口可以多实现灵活性高得多。核心接口大概长这样public interface DataChannel { boolean open(ChannelConfig config); int read(byte[] buffer, int timeoutMs); int write(byte[] data); void close(); boolean isConnected(); ChannelType getType(); }ChannelConfig是个配置容器串口填波特率、数据位、停止位蓝牙填 MAC 地址和 UUIDUSB 填 vendorId 和 productId网络填 host 和 port。用同一个配置对象承载不同参数虽然有点万能对象的味道但换来的是上层构造通道时不用关心具体类型。2.3 通道选型的实际权衡四种通道不是随便都要支持的实际项目里怎么选有讲究。我整理了一张对照表这是多个项目验证下来的经验值通道典型场景延迟稳定性供电开发难度串口RTK 接收机、工业传感器极低极高需外部供电中需 JNI蓝牙手持 GPS、便携设备中中自带电池高协议复杂USB条码枪、高精度设备低高总线供电中权限端点网络差分数据、任务下发高依赖信号无需低选型逻辑很直接对实时性要求极高的差分数据走串口便携场景走蓝牙固定工位走 USB远程协同走网络。但现实是客户什么都想要所以才需要统一抽象来兜底。3. 四种通道的核心实现细节3.1 串口通道JNI 与文件描述符的那些事Android 没有原生串口 API必须走 JNI 调用 Linux 的open、read、write。核心是拿到/dev/ttyS*或/dev/ttyUSB*的文件描述符。这里第一个坑就是权限——普通 App 没有权限直接打开这些设备节点通常需要设备已 root或者厂商在系统层放开权限。打开串口的 C 代码关键片段int fd open(path, O_RDWR | O_NOCTTY | O_NDELAY); if (fd 0) return -1; struct termios options; tcgetattr(fd, options); cfsetispeed(options, B115200); cfsetospeed(options, B115200); 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);波特率的选择要看设备手册RTK 常见 115200 或 460800工业传感器常见 9600。波特率不匹配的典型症状是收到一堆乱码我第一次调 RTK 时把 115200 写成 9600排查了半天才反应过来。读数据用阻塞读加超时避免线程卡死// JNI 层设置 VMIN 和 VTIME 控制读超时 options.c_cc[VMIN] 0; options.c_cc[VTIME] 10; // 1 秒超时实操心得串口读取一定要放在独立线程且用O_NONBLOCK或超时机制。我见过在主线程读串口导致 ANR 的案例用户直接给一星差评。3.2 蓝牙通道经典与 BLE 的分叉处理蓝牙是四种通道里最麻烦的因为经典蓝牙SPP和 BLE 是两套完全不同的 API。经典蓝牙用BluetoothSocket走 RFCOMM 协议适合持续大数据流BLE 用BluetoothGatt基于 GATT 服务和特征值适合低功耗小数据。经典蓝牙连接的核心步骤BluetoothDevice device adapter.getRemoteDevice(macAddress); BluetoothSocket socket device.createRfcommSocketToServiceRecord( UUID.fromString(00001101-0000-1000-8000-00805F9B34FB)); socket.connect(); InputStream in socket.getInputStream();那个 UUID 是 SPP 的标准 UUID几乎所有串口透传模块都用它。BLE 则要先connectGatt再discoverServices找到目标特征值后setCharacteristicNotification开启通知数据通过onCharacteristicChanged回调返回。两者统一到DataChannel接口时我在实现类内部做了分支构造时根据设备类型决定走哪条路径对外暴露的read/write完全一致。BLE 的write要注意单次写入不能超过 MTU默认 20 字节大数据要分包这是新手最容易忽略的点。3.3 USB 通道权限申请与端点配置USB 在 Android 上走UsbManager第一步是申请权限。设备插入后会收到ACTION_USB_DEVICE_ATTACHED广播拿到UsbDevice后要检查是否有权限没有就requestPermission。UsbManager manager (UsbManager) getSystemService(Context.USB_SERVICE); UsbDevice device intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); if (!manager.hasPermission(device)) { PendingIntent pi PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE); manager.requestPermission(device, pi); }拿到权限后要找到正确的接口和端点。一个 USB 设备可能有多个 interface每个 interface 有多个 endpoint。数据读取用bulkTransferUsbInterface intf device.getInterface(0); UsbEndpoint inEndpoint null; for (int i 0; i intf.getEndpointCount(); i) { UsbEndpoint ep intf.getEndpoint(i); if (ep.getType() UsbConstants.USB_ENDPOINT_XFER_BULK ep.getDirection() UsbConstants.USB_DIR_IN) { inEndpoint ep; } } connection manager.openDevice(device); connection.claimInterface(intf, true); int len connection.bulkTransfer(inEndpoint, buffer, buffer.length, 1000);注意claimInterface的第二个参数force设为 true 会强制从内核驱动手里抢接口某些设备如 USB 转串口芯片需要这样但可能导致系统驱动失效要谨慎。3.4 网络通道长连接与心跳保活网络通道相对简单用Socket或OkHttp的 WebSocket 都行。GIS 场景里网络通道主要传差分数据和任务包对实时性要求不如串口高但断线重连和心跳必须做好。我用的是Socket加独立读写线程心跳每 30 秒发一次连续三次没收到响应就判定断线并触发重连。重连用指数退避第一次 1 秒第二次 2 秒最多退到 30 秒避免网络恢复瞬间大量重连把服务器打挂。private void startHeartbeat() { scheduler.scheduleAtFixedRate(() - { if (System.currentTimeMillis() - lastReceiveTime 90000) { reconnect(); } else { write(HEARTBEAT_PACKET); } }, 30, 30, TimeUnit.SECONDS); }4. 统一抽象层的落地实现4.1 通道工厂与配置管理上层业务不应该new SerialChannel()或new BluetoothChannel()而是通过工厂拿public class ChannelFactory { public static DataChannel create(ChannelConfig config) { switch (config.getType()) { case SERIAL: return new SerialChannel(); case BLUETOOTH: return new BluetoothChannel(); case USB: return new UsbChannel(); case NETWORK: return new NetworkChannel(); default: throw new IllegalArgumentException(Unknown channel); } } }配置从 SharedPreferences 或数据库读用户在外业现场切换设备时改配置重启通道即可业务代码一行不用动。这是抽象带来的最大收益——新增一种通道只需加一个实现类和一个枚举值。4.2 数据解析层的粘包处理原始字节流最大的问题是粘包和半包。串口一次read可能返回半条报文也可能返回两条半。我的处理方式是维护一个ByteArrayOutputStream缓冲区每次读到数据就追加然后循环尝试从缓冲区头部解析完整帧。以 NMEA 为例帧以$开头以\r\n结尾buffer.write(newData); while (true) { int start indexOf(buffer, $); int end indexOf(buffer, \n); if (start 0 || end 0 || end start) break; byte[] frame extract(buffer, start, end); parseNmea(frame); buffer.remove(0, end 1); }自定义二进制协议则用帧头 长度 数据 校验的结构先读帧头定位再读长度字段确定整帧大小不够就等下次数据。校验和一定要验外业环境电磁干扰大坏帧率不低。4.3 断线重连与状态回调四种通道都会断线串口被拔、蓝牙超出范围、USB 松动、网络切换。统一抽象里我定义了一个ChannelListenerpublic interface ChannelListener { void onDataReceived(byte[] data); void onDisconnected(int reason); void onError(Exception e); }每个通道实现类内部检测到断线就回调onDisconnected上层统一处理重连逻辑。重连策略放在抽象层而不是各通道里这样四种通道共享同一套退避算法代码复用率极高。5. 实操过程与关键环节记录5.1 从零搭建一个串口读取 Demo先讲最基础的串口因为它是其他通道的参照。第一步在build.gradle配置 NDK第二步写 JNI 的 C 文件第三步在 Java 层封装。CMakeLists 关键配置add_library(serial-lib SHARED serial_port.c) find_library(log-lib log) target_link_libraries(serial-lib ${log-lib})Java 层声明 native 方法public class SerialPort { static { System.loadLibrary(serial-lib); } public native int open(String path, int baudRate); public native int read(byte[] buffer, int timeout); public native int write(byte[] data); public native void close(); }实测下来115200 波特率下读 1KB 数据耗时约 90ms完全满足 RTK 每秒 5 帧的输出频率。注意 read 的 buffer 不要开太小我一开始开 64 字节高频数据下频繁触发系统调用CPU 占用明显偏高改成 1024 后降下来了。5.2 蓝牙 SPP 连接的完整流程蓝牙要处理配对、连接、读取三个阶段。配对用createBond连接用createRfcommSocketToServiceRecord读取用InputStream。adapter.cancelDiscovery(); // 连接前必须停止扫描 socket device.createRfcommSocketToServiceRecord(SPP_UUID); socket.connect(); // 阻塞调用放子线程 InputStream in socket.getInputStream(); byte[] buf new byte[1024]; while (running) { int len in.read(buf); if (len 0) listener.onDataReceived(Arrays.copyOf(buf, len)); }实操心得connect()是阻塞的一定要放子线程且要设超时。我遇到过设备不在范围时connect卡死十几秒的情况用户体验极差。可以用Future加超时控制。5.3 USB 权限回调的坑USB 权限申请是异步的通过广播返回结果。这里有个经典坑PendingIntent的 flag 在 Android 12 以上必须显式指定可变性否则直接崩溃。int flags Build.VERSION.SDK_INT Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0; PendingIntent pi PendingIntent.getBroadcast(this, 0, intent, flags);收到权限广播后才能真正openDevice。我建议把 USB 通道的整个生命周期和 Activity 绑定在onResume注册广播onPause注销避免内存泄漏。5.4 网络通道的差分数据转发网络通道在 GIS 里最常见的用途是转发 RTK 差分数据。流程是从服务器收到差分数据通过串口或蓝牙转发给 RTK 接收机。这正好体现了统一抽象的价值——网络通道作为数据源串口通道作为数据宿两者通过同一个接口对接。networkChannel.setListener(data - { serialChannel.write(data); // 差分数据直接转发 });这段代码只有三行但背后是四种通道的统一抽象在支撑。如果没有这层抽象这里会变成一堆类型判断和转换。6. 常见问题与排查技巧实录6.1 通道问题速查表现象可能原因排查方向串口收到乱码波特率不匹配核对设备手册逐个波特率试蓝牙连不上未配对或超出范围检查配对状态靠近设备USB 无权限未申请或 flag 错误检查 PendingIntent flag网络频繁断心跳超时或信号弱调大心跳间隔检查信号数据粘包解析逻辑未处理半包加缓冲区按帧头切分定位跳点坐标系未转换检查 WGS84 与 CGCS2000 转换6.2 串口权限的替代方案不是所有设备都能 root串口权限是个老大难。几个可行方向一是用厂商提供的 SDK他们通常在系统层放开了权限二是用 USB 转串口芯片如 FT231X、CH340走 USB 通道申请权限绕开设备节点权限问题三是设备出厂预装时把 App 放进系统分区。我在项目里最常用的是第二种USB 转串口既解决了权限又统一到了 USB 通道一举两得。6.3 蓝牙 BLE 的 MTU 协商BLE 默认 MTU 是 23 字节实际可用 20 字节。传大数据必须协商更大 MTUgatt.requestMtu(512); // 在 onConnectionStateChange 成功后调用 // 回调 onMtuChanged 后才能真正用新 MTU协商成功后单次可写 512 字节左右但不同设备支持的最大值不同要以onMtuChanged返回的实际值为准。没协商就写大数据数据会被截断且不报错这是 BLE 最隐蔽的坑。6.4 多通道并发时的线程安全一个 GIS App 可能同时开着蓝牙 GPS 和网络差分两个通道的读取线程都会往业务层推数据。业务层的解析器和缓冲区必须加锁或者每个通道独立一份解析器实例。我选后者每个通道持有自己的解析器业务层只接收解析好的结构化数据从根上避免并发问题。7. 一些实际项目里的经验补充这套抽象我在三个项目里迭代过最大的体会是抽象层要早做但不能过度设计。第一版我只抽了open/read/write/close四个方法够用后来加了isConnected和状态回调是因为断线重连确实需要再后来想加通道优先级、带宽统计这些发现业务根本用不上果断砍掉。另一个体会是配置的持久化很重要。外业人员经常换设备今天用蓝牙明天用串口配置要能存下来下次直接用。我用 Room 存了一份通道配置表字段包括类型、参数 JSON、最后使用时间切换设备时从列表里选不用重新输参数。最后分享一个小技巧调试阶段给每个通道加一个回环测试模式写出去的数据立刻读回来能快速验证通道是否正常不用依赖真实设备。这个功能帮我省了大量现场调试时间。后续如果要做通道热切换——运行中从蓝牙切到串口不断数据——可以在抽象层加一个ChannelSwitcher内部维护双缓冲新通道就绪后再切换数据源。这个我还没在正式项目里落地但思路是通的有需要的可以试试。