IO 的瓶颈不在读写速度,而在上下文切换次数与缓冲区边界。
先讲一个真实到有点扎心的场景:你在 Android 上跟一台外接 GNSS 模组通信,设备每秒钟往外吐几十条 NMEA 语句,峰值能到上百条。你以为「卡不卡」取决于硬件收得快不快,结果一接上,主线程开始掉帧,UI 一会儿冻一下,定位还时不时跳变。你去看 CPU,并没有跑满;去看网速,也没瓶颈。问题出在哪?出在数据是以连续字节流的形式来的,没有边界——一句话可能被拆成两帧到达,也可能两句话挤在一帧里,而你还在主线程里一个字节一个字节地拼、一条语句一条语句地解析。
本文要讲的,是一套在工业级 Android 项目里跑了很多年的架构:异步 IO 流 + 数据解析管道。它的核心判断就一句话——IO 真正的瓶颈从来不是读写速度,而是「线程切换了几次」和「缓冲区在哪儿断了」。把这两件事设计对,几百万行数据流过,UI 照样丝滑。
一、先说清楚:跟硬件"对话"到底难在哪
跟硬件设备通信,表面上是「连上、收发」四个字,落地时会发现它本质上要同时解决三件事:
- 异步读:硬件数据随时可能到来,你不能阻塞调用线程干等,必须有个常驻的读线程在后台守着。
- 异步写:写指令也不能卡住调用线程,点了发送就得立刻返回,真正的发送在别处发生。
- 帧解析:从一段连续的字节流里,把「完整的一帧数据」抠出来——这是最容易被低估的一步。
这三件事单拎出来都不难,难的是它们组合到一起之后冒出来的坑:
- 读线程和写线程大概率会同时碰同一个 Socket 或者串口,线程安全是绕不开的。
- 字节流没有边界,半帧、粘帧是常态,不是偶发。
- 不同协议有不同的帧格式,解析逻辑必须能扩展,否则每加一个协议就改一遍底层。
- 有些数据要被过滤掉(比如心跳包),有些要被转发走(比如原始数据透传),不能一刀切。
这篇文章的解决办法,就是把「收字节」和「懂数据」彻底拆开:底层只管把字节搬进来搬出去,上层只管按协议理解字节。中间用一条可插拔的管道串起来。下面一层一层拆。
二、异步 IO 流:用两个线程把收发解耦
2.1 不要一个线程又读又写
最朴素的写法是一个线程里 read() 完顺手 write(),看似省事,实际上读写互相绑架:写阻塞时读就停了,读卡住时写也发不出去。正确的模型是读写分离成两条独立线程,中间用队列做缓冲。整体结构长这样(ASCII 示意):
┌─────────────────────────────────────────┐
│ 调用线程 │
│ write() ──→ 写队列 ──→ 写线程 ──→ 硬件 │
├─────────────────────────────────────────┤
│ 硬件 │
│ 硬件 ──→ 读线程 ──→ 解析管道 ──→ 业务层 │
└─────────────────────────────────────────┘
上面这张图里最关键的一句是:调用线程只跟队列打交道,永远不直接碰硬件。具体怎么落代码,看下面的抽象基类。
2.2 抽象基类怎么写
把上面这套逻辑抽成一个 AsyncIOStream 抽象类,所有具体的通道(串口、蓝牙、Socket)都继承它,只实现最底层的 onRead / onWrite / onOpen / onClose 四个钩子。基类负责线程和队列的脏活:
abstract class AsyncIOStream {
private var readThread: Thread? = null
private var writeThread: Thread? = null
private val writeQueue = LinkedBlockingQueue<ByteArray>()
private var isRunning = false
// ── 读回调 ──
private var onReadCallback: ((ByteArray, Int) -> Unit)? = null
// ── 子类实现:底层读写 ──
protected abstract fun onRead(buffer: ByteArray): Int
protected abstract fun onWrite(data: ByteArray)
protected abstract fun onOpen()
protected abstract fun onClose()
// ── 生命周期 ──
fun open() {
if (isRunning) return
isRunning = true
onOpen()
// 启动读线程
readThread = Thread({ readLoop() }, "IO-Read").apply { start() }
// 启动写线程
writeThread = Thread({ writeLoop() }, "IO-Write").apply { start() }
}
fun close() {
isRunning = false
onClose()
readThread?.interrupt()
writeThread?.interrupt()
writeQueue.clear()
}
// ── 写入(调用线程入队,写线程消费) ──
fun write(data: ByteArray) {
writeQueue.put(data)
}
fun setOnReadCallback(callback: (ByteArray, Int) -> Unit) {
onReadCallback = callback
}
// ── 读循环 ──
private fun readLoop() {
val buffer = ByteArray(4096)
while (isRunning && !Thread.currentThread().isInterrupted) {
try {
val length = onRead(buffer)
if (length > 0) {
val data = buffer.copyOf(length)
onReadCallback?.invoke(data, length)
}
} catch (e: Exception) {
if (isRunning) {
// 读取异常,通知上层
onReadError(e)
}
break
}
}
}
// ── 写循环 ──
private fun writeLoop() {
while (isRunning && !Thread.currentThread().isInterrupted) {
try {
val data = writeQueue.take() // 阻塞等待
onWrite(data)
} catch (e: InterruptedException) {
break
} catch (e: Exception) {
if (isRunning) {
onWriteError(e)
}
}
}
}
protected open fun onReadError(e: Exception) {}
protected open fun onWriteError(e: Exception) {}
}
几个设计要点,每一条都是踩出来的:
- 读写分离:读线程和写线程独立跑,互不阻塞。调用线程写数据时只往队列里
put,立刻返回。 - 写队列:
LinkedBlockingQueue实现了生产者-消费者模式,调用线程是生产者,写线程是消费者,天然解耦。 - 线程安全:写操作通过队列串行化,读操作只有单线程,从根上消除了竞争条件——这一点比加一堆锁干净得多。
- 生命周期可控:
close()同时中断两个线程并清空队列,不会有关线程跑了但数据还在飞的情况。
注意读循环里那个 ByteArray(4096) 和 buffer.copyOf(length):读出来的有效长度才 length 字节,但底层 onRead 可能把 buffer 填满也可能只填一部分,所以必须 copyOf(length) 截断后再回调,避免把上一次残留的脏数据带出去。这正是「缓冲区边界」这几个字在代码里的具体落点。
2.3 不同通道,差别只在最底层四行
前面说 IO 的真正瓶颈在「上下文切换次数与缓冲区边界」,不是读写速度。为什么?因为无论串口、蓝牙还是 Socket,单次收发的耗时都远低于一次线程切换的代价,真正吃性能的是你切了多少次线程、在哪儿切。下面这张表把常见通道放在一起对比,你会发现它们唯一的本质区别就是「字节流有没有天然边界」和「要不要自己重连」:
| 通道 | 数据形态 | 帧边界是否明确 | 典型线程模型 | 是否需要重连 | 典型场景 |
|---|---|---|---|---|---|
| 串口 | 纯字节流 | 否,需自定分隔符 | 读线程 + 写线程 | 需,检测物理断开 | 外接 GNSS / RTK 模组 |
| 蓝牙 SPP | 纯字节流 | 否 | 读线程 + 写线程 | 需,连接易掉 | 手持终端配对设备 |
| Socket TCP | 字节流 | 否 | 读线程 + 写线程 | 需,网络会抖 | 局域网内数传 |
| UDP | 报文 | 是,按包即一帧 | 单收单发即可 | 通常不重连 | 广播式差分数据源 |
| USB | 端点字节流 | 否 | 读线程 + 写线程 | 需,检测拔插 | 高速原始采样 |
表里的「帧边界是否明确」直接决定了你后面要不要做帧分割。串口、蓝牙、Socket、USB 全是裸字节流,必须有分隔符;UDP 按包收,一包就是一帧,反而省事。但无论哪种,读写解耦的两条线程模型都通用——所以基类不用改,只换最底层的实现。
2.4 串口实现示例
以串口为例,继承 AsyncIOStream 后只填四个钩子。注意 onOpen 里是通过 JNI 去开串口的,拿到的是 FileDescriptor,再包成 FileInputStream / FileOutputStream:
class SerialPortIOStream(
private val portPath: String,
private val baudRate: Int
) : AsyncIOStream() {
private var fileDescriptor: FileDescriptor? = null
private var inputStream: FileInputStream? = null
private var outputStream: FileOutputStream? = null
override fun onOpen() {
// 打开串口(JNI 调用)
fileDescriptor = openSerialPortNative(portPath, baudRate)
inputStream = FileInputStream(fileDescriptor)
outputStream = FileOutputStream(fileDescriptor)
}
override fun onClose() {
inputStream?.close()
outputStream?.close()
closeSerialPortNative()
}
override fun onRead(buffer: ByteArray): Int {
return inputStream?.read(buffer) ?: -1
}
override fun onWrite(data: ByteArray) {
outputStream?.write(data)
outputStream?.flush()
}
private external fun openSerialPortNative(path: String, baudRate: Int): FileDescriptor?
private external fun closeSerialPortNative()
}
写的时候一定要 flush(),否则字节可能滞留在 JVM 缓冲里不真正下发到硬件。onRead 直接透传 FileInputStream.read,返回的 Int 就是本次读到的有效长度,交回基类去 copyOf 截断。
三、数据解析管道:用责任链模式吃掉协议差异
3.1 为什么必须是一条管道,而不是一个大方法
从 IO 流里捞出来的原始字节,要变成业务能用的数据对象,中间至少经过好几步:
原始字节流 → 帧分割 → 帧校验 → 协议解析 → 数据对象 → 业务层
如果把这些步骤全糊进一个 parse() 方法里,后果是:代码臃肿不可复用、加一个新协议就得改老代码、想跳过某一步(比如某些场景不要校验)都做不到。责任链模式就是专门治这个的——每一步是一个独立节点,数据像水流一样依次穿过,谁该处理谁处理。
3.2 解析器接口
每个协议解析器实现同一个 Parser 接口。关键设计是 getData():每个解析器内部持有一个数据对象,通过这个方法把结果暴露给上层,这样管道不需要知道具体是哪种协议:
interface Parser {
/**
* 解析数据
* @param data 原始字节
* @param length 有效长度
*/
fun parse(data: ByteArray, length: Int)
/**
* 获取解析结果
* 每个解析器持有一个数据对象,通过此方法暴露给上层
*/
fun getData(): Data?
}
3.3 过滤器接口
过滤器跑在解析器之前,用来「拦掉」或「转发走」特定数据。返回值语义很关键:true 表示数据被拦截,不再往后传;false 表示放行,继续走管道。用布尔返回值而不是抛异常,是为了让过滤行为足够轻:
interface Filter {
/**
* 过滤数据
* @return true 表示数据被拦截,不再传递给后续解析器
* false 表示数据放行,继续传递
*/
fun filter(data: ByteArray, length: Int): Boolean
}
3.4 管道本体
DataPipeline 就是责任链的容器。它持有过滤器链和解析器链,process() 先过过滤器、再过解析器;单个解析器抛异常时 catch 住,绝不影响其他解析器——这是管道「韧性」的来源:
class DataPipeline {
private val filters = mutableListOf<Filter>()
private val parsers = mutableListOf<Parser>()
fun addFilter(filter: Filter) {
filters.add(filter)
}
fun removeFilter(filter: Filter) {
filters.remove(filter)
}
fun addParser(parser: Parser) {
parsers.add(parser)
}
fun removeParser(parser: Parser) {
parsers.remove(parser)
}
/**
* 处理一帧数据
* 先过过滤器,再过解析器
*/
fun process(data: ByteArray, length: Int) {
// 过滤器链
for (filter in filters) {
if (filter.filter(data, length)) {
return // 被拦截
}
}
// 解析器链
for (parser in parsers) {
try {
parser.parse(data, length)
} catch (e: Exception) {
// 单个解析器异常不影响其他解析器
}
}
}
}
注意 process 里那个 return:只要任一过滤器返回 true,整条数据就被截断,后面的解析器一个都拿不到。心跳包过滤、日志拦截这类需求就是靠它实现的。
3.5 把管道挂到设备上
再抽象一层 Device,对外只暴露注册/注销方法,内部把 IO 流的读回调接进管道。这样业务代码完全不知道线程、队列、缓冲区这些细节:
abstract class Device {
private val pipeline = DataPipeline()
// 注册解析器(对外暴露)
fun registerParser(parser: Parser) {
pipeline.addParser(parser)
}
fun unRegisterParser(parser: Parser) {
pipeline.removeParser(parser)
}
// 注册过滤器(对外暴露)
fun registerFilter(filter: Filter) {
pipeline.addFilter(filter)
}
fun unRegisterFilter(filter: Filter) {
pipeline.removeFilter(filter)
}
// IO 流读回调 → 管道
protected fun onRawDataReceived(data: ByteArray, length: Int) {
pipeline.process(data, length)
}
}
到这儿,分层已经很清楚了:设备管状态、管道管流转、解析器管理解、IO 流管收发,四者各管一摊。
四、帧分割:把混沌字节流切成完整的数据帧
4.1 半帧和粘帧,是绕不开的常态
IO 流读出来的数据,三种情况都可能发生,而且频率不低:
情况1(正常): $GPGGA,123456.00,...*XX\r\n
情况2(半帧): $GPGGA,1234 → 56.00,...*XX\r\n
情况3(粘帧): $GPGGA,...*XX\r\n$GPGSA,...*YY\r\n
情况 2 是一句话被拆成两次 read 到达,你第一次拿到的根本不是完整帧;情况 3 是两句话挤在同一次 read 里,你如果只取第一段就漏数据。这两种情况靠「每次 read 拿到什么就处理什么」是处理不了的,必须有一个跨多次 read 累积的缓冲区。
4.2 帧缓冲区怎么写
FrameBuffer 用 ByteArrayOutputStream 当蓄水池,每次 append 新数据后,从头扫 \r\n 分隔符,把所有完整帧切出来返回,剩下的半截留在缓冲里等下一次:
class FrameBuffer {
private val buffer = ByteArrayOutputStream()
private val frameDelimiter = "\r\n".toByteArray()
/**
* 追加数据,返回提取出的完整帧列表
*/
fun append(data: ByteArray, length: Int): List<ByteArray> {
buffer.write(data, 0, length)
val frames = mutableListOf<ByteArray>()
val allBytes = buffer.toByteArray()
var lastPos = 0
var searchPos = 0
while (searchPos <= allBytes.size - frameDelimiter.size) {
if (allBytes.sliceArray(searchPos until searchPos + frameDelimiter.size)
.contentEquals(frameDelimiter)
) {
// 找到一帧
frames.add(allBytes.sliceArray(lastPos until searchPos + frameDelimiter.size))
lastPos = searchPos + frameDelimiter.size
searchPos = lastPos
} else {
searchPos++
}
}
// 重置缓冲区,保留未消费的数据
buffer.reset()
if (lastPos < allBytes.size) {
buffer.write(allBytes, lastPos, allBytes.size - lastPos)
}
return frames
}
fun clear() {
buffer.reset()
}
}
这段逻辑里有两个地方决定了「缓冲区边界」处理得对不对:
buffer.reset()之后只把lastPos之后的残数据写回——没凑齐一帧的尾巴必须留着,否则下次 read 过来的字节对不上,帧就废了。- 查找分隔符的循环条件是
searchPos <= allBytes.size - frameDelimiter.size,少一个等号就会漏扫最后一段,导致帧少切一个字节。
把帧解析画成状态机,能更清楚地看到「累积—查找—产出」的循环,以及超长脏数据怎么被兜住:
stateDiagram-v2
[*] --> 累积缓冲
累积缓冲 --> 查找分隔符: 每次 read 追加
查找分隔符 --> 产出整帧: 命中帧边界
查找分隔符 --> 累积缓冲: 未命中 继续读
产出整帧 --> 下游解析: 交给下游 Parser
下游解析 --> 累积缓冲: 继续收下一帧
产出整帧 --> 丢弃帧: 超过 maxFrameSize
丢弃帧 --> 累积缓冲
累积缓冲 --> [*]: close 清空
4.3 帧解析器:把缓冲区和管道接起来
FrameParser 持有 FrameBuffer 和一组下游解析器。它自己实现 Parser 接口,但它的 parse 不是「解析一句协议」,而是「先切帧,再把每一帧分发给下游」:
class FrameParser(
private val frameDelimiter: ByteArray = "\r\n".toByteArray(),
private val maxFrameSize: Int = 4096
) : Parser {
private val frameBuffer = FrameBuffer()
private val downstreamParsers = mutableListOf<Parser>()
fun addDownstreamParser(parser: Parser) {
downstreamParsers.add(parser)
}
override fun parse(data: ByteArray, length: Int) {
val frames = frameBuffer.append(data, length)
for (frame in frames) {
// 防止超长帧
if (frame.size > maxFrameSize) continue
// 将完整帧交给下游解析器
for (parser in downstreamParsers) {
try {
parser.parse(frame, frame.size)
} catch (e: Exception) {
// 下游解析异常不影响其他解析器
}
}
}
}
override fun getData(): Data? = null
}
两个细节:maxFrameSize 默认 4096,超过的直接 continue 丢掉——这是防 OOM 的保险丝,万一来了段永远不以 \r\n 结尾的脏数据,缓冲区不会无限涨;getData() 返回 null,因为帧解析器自己不产生数据对象,它只负责「分发给下游」,真正的数据在下游解析器里。
到这里你已经能看出主线判断在代码里的痕迹了:切帧发生在 IO 读线程里、在缓冲区边界处,而不是把原始字节丢回主线程再处理——这正是「瓶颈在缓冲区边界、不在读写速度」的工程落点。
4.4 一张表看清解析链路上的坑
前面每一步都对应一个真实会踩的坑,汇总成对照表,方便你上线前逐条对着查:
| 症状 | 根因 | 解法 |
|---|---|---|
| 解析偶发失败、定位跳变 | 半帧:一帧被拆成两次 read 到达 | FrameBuffer 跨 read 累积,命中分隔符才产出整帧 |
| 漏解析某些语句、丢数据 | 粘帧:一次 read 含多帧但只取首帧 | 循环切分所有分隔符,逐帧下发给下游 |
| 校验和频繁不过 | 传输中字节被翻转 | verifyChecksum 从 $ 异或累加到 * 之前再对比 |
| 内存暴涨、OOM | 缓冲区无限增长,脏数据无终止符 | maxFrameSize 设上限,超长帧直接丢弃 |
| 南北纬、东西经反了 | 方向字符 N/S/E/W 漏解析 | parseCoordinate 按方向取正负 |
| UI 卡顿、掉帧 | 解析跑在主线程 | 解析放 IO 读线程,仅结果回主线程刷新 |
这张表其实也是全文主判断的另一种说法:半帧粘帧归到「缓冲区边界」,UI 卡顿归到「线程切换」,两类坑的根源都落在那句话上。
五、实战:用 NMEA 协议把整条管道跑通
光有骨架不够,下面用 NMEA 0183 把管道真正组装起来。链路是:
原始字节流 → FrameParser → NmeaParser → NmeaData
5.1 数据对象 NmeaData
NmeaData 继承 Data,把一条定位数据该有的字段全摊开。注意 fixType 的语义:0 无效、1 单点、2 差分——这个值后面驱动 UI 要不要显示「差分定位」。notifyChanged() 触发所有监听者,是数据推给 UI 的最后一跳:
class NmeaData : Data() {
// 位置信息
var latitude: Double = 0.0
var longitude: Double = 0.0
var altitude: Double = 0.0
var speed: Double = 0.0
var bearing: Double = 0.0
// 定位状态
var fixType: Int = 0 // 0=无效, 1=单点, 2=差分
var satelliteCount: Int = 0
var hdop: Double = 0.0
var pdop: Double = 0.0
var vdop: Double = 0.0
// 时间
var utcTime: String = ""
// 差分信息
var differentialAge: Double = 0.0
var differentialStationId: String = ""
// 变更通知
interface OnDataChangedListener {
fun onDataChanged()
}
private val listeners = mutableListOf<OnDataChangedListener>()
fun registerListener(listener: OnDataChangedListener) {
listeners.add(listener)
}
fun unregisterListener(listener: OnDataChangedListener) {
listeners.remove(listener)
}
fun notifyChanged() {
for (listener in listeners) {
listener.onDataChanged()
}
}
}
5.2 NMEA 解析器
NmeaParser 实现 Parser,核心是 parse:先 trim、必须以 $ 开头、过校验和,再按语句类型分派到 parseGGA / parseGSA / parseGSV / parseRMC。GP 和 GN 前缀都认(GN 是双模多星系统的标识):
class NmeaParser(private val nmeaData: NmeaData) : Parser {
override fun parse(data: ByteArray, length: Int) {
val sentence = String(data, 0, length).trim()
if (!sentence.startsWith("$")) return
// 校验和验证
if (!verifyChecksum(sentence)) return
val fields = sentence.split(",")
when {
sentence.startsWith("$GPGGA") || sentence.startsWith("$GNGGA") ->
parseGGA(fields)
sentence.startsWith("$GPGSA") || sentence.startsWith("$GNGSA") ->
parseGSA(fields)
sentence.startsWith("$GPGSV") || sentence.startsWith("$GNGSV") ->
parseGSV(fields)
sentence.startsWith("$GPRMC") || sentence.startsWith("$GNRMC") ->
parseRMC(fields)
}
nmeaData.notifyChanged()
}
override fun getData(): Data = nmeaData
private fun parseGGA(fields: List<String>) {
if (fields.size < 15) return
nmeaData.utcTime = fields[1]
nmeaData.latitude = parseCoordinate(fields[2], fields[3])
nmeaData.longitude = parseCoordinate(fields[4], fields[5])
nmeaData.fixType = fields[6].toIntOrNull() ?: 0
nmeaData.satelliteCount = fields[7].toIntOrNull() ?: 0
nmeaData.hdop = fields[8].toDoubleOrNull() ?: 0.0
nmeaData.altitude = fields[9].toDoubleOrNull() ?: 0.0
nmeaData.differentialAge = fields[13].toDoubleOrNull() ?: 0.0
nmeaData.differentialStationId = fields[14]
}
private fun parseCoordinate(value: String, direction: String): Double {
if (value.isEmpty()) return 0.0
val degrees = value.substring(0, 2).toDouble()
val minutes = value.substring(2).toDouble()
val coord = degrees + minutes / 60.0
return if (direction == "S" || direction == "W") -coord else coord
}
private fun verifyChecksum(sentence: String): Boolean {
val asteriskIndex = sentence.lastIndexOf('*') ?: return false
val checksum = sentence.substring(asteriskIndex + 1).toIntOrNull(16) ?: return false
var calculated = 0
for (i in 1 until asteriskIndex) {
calculated = calculated xor sentence[i].code
}
return calculated == checksum
}
}
几个容易翻车的地方,对照着看:
parseGGA第一行if (fields.size < 15) return:字段不足 15 个说明这句被截断或格式不对,直接放弃,不能硬解析,否则下标越界。parseCoordinate取度分格式:substring(0, 2)是度,substring(2)是分,合成度 + 分/60;南纬 S、西经 W 取负。方向字符漏掉,经纬度符号就反了。verifyChecksum从索引 1(跳过$)异或累加到*之前,和*后面的十六进制校验对比。起始位选错一位,校验和全错。
5.3 完整管道组装
把设备、帧解析器、NMEA 解析器、数据对象串起来,最后监听 notifyChanged 去刷 UI:
// 创建设备
val device = BluetoothDevice(address)
// 创建数据对象
val nmeaData = NmeaData()
// 创建帧解析器
val frameParser = FrameParser()
frameParser.addDownstreamParser(NmeaParser(nmeaData))
// 注册到设备
device.registerParser(frameParser)
// 监听数据变化
nmeaData.registerListener(object : NmeaData.OnDataChangedListener {
override fun onDataChanged() {
// 更新 UI
updateLocation(nmeaData.latitude, nmeaData.longitude)
}
})
// 连接
device.connect()
到这里,从硬件吐字节到 UI 显示定位,整条链路的时序是这样走的——注意解析全程在 IO 读线程,UI 更新才切回 UI 线程:
sequenceDiagram
participant HW["硬件设备"]
participant RT["读线程 IO-Read"]
participant PIPE["解析管道"]
participant PARSER["NmeaParser"]
participant DATA["NmeaData"]
participant UI["UI 线程"]
HW->>RT: 推送字节流
RT->>PIPE: process 原始字节
PIPE->>PARSER: parse 完整帧
PARSER->>DATA: 写入经纬度等字段
DATA->>UI: notifyChanged 回调
UI->>UI: 更新定位显示
这条时序图就是全文主判断的注脚:数据从硬件到 UI 只切了一次线程(IO 读线程 → UI 线程),中间帧分割、校验、协议解析全在 IO 线程里完成。如果你把这些步骤丢回主线程,每帧都切一次,几百万行数据就是几百万次无谓的上下文切换,UI 不卡才怪。
六、原始数据透传:让转发与解析互不干扰
有些场景你既想解析定位,又想把原始字节转发出去(写日志、跨进程传、差分数据回传)。这时候不需要为转发单独开一条链路,直接挂一个「透传解析器」并行跑即可——它不理解数据,只负责转手:
class RawDataForwarder(
private val onRawData: (ByteArray, Int) -> Unit
) : Parser {
override fun parse(data: ByteArray, length: Int) {
onRawData(data, length)
}
override fun getData(): Data? = null
}
// 使用
device.registerParser(RawDataForwarder { data, length ->
rawLogWriter.write(data, length)
})
设计亮点是:透传解析器和协议解析器并行运行、互不影响。它们都注册在同一个管道里,同一帧数据会被两个解析器各处理一遍——一个去理解,一个去转发。透传解析器 getData() 返回 null,因为它不产生业务数据对象,纯粹是个「旁路」。这正体现了责任链的好处:加一个旁路能力,零改动已有代码。
七、从四层架构回看这套设计为什么稳
把前面所有东西叠起来,是清晰的四层(加通道实现层其实是五层),自上而下:
┌──────────────────────────────────────────────────────────┐
│ 业务层 │
│ UI 更新 · 日志记录 · 跨进程传输 · 差分数据回传 │
├──────────────────────────────────────────────────────────┤
│ 解析管道层 │
│ Filter链 → FrameParser → NmeaParser/RawForwarder/... │
├──────────────────────────────────────────────────────────┤
│ 设备抽象层 │
│ 连接状态机 · 自动重连 · 事件分发 · 管道管理 │
├──────────────────────────────────────────────────────────┤
│ 异步IO流层 │
│ 读线程 → onRead回调 → 管道.process() │
│ 写队列 → 写线程 → onWrite() │
├──────────────────────────────────────────────────────────┤
│ 通道实现层 │
│ 蓝牙 · 串口 · Socket · UDP · USB · ... │
└──────────────────────────────────────────────────────────┘
用 mermaid 重画一遍分层关系,方便你照着搭自己的:
flowchart TD
A["业务层 UI 日志 跨进程"] --> B["解析管道层 Filter链 FrameParser"]
B --> C["设备抽象层 状态机 重连"]
C --> D["异步IO流层 读线程 写线程"]
D --> E["通道实现层 蓝牙 串口 Socket UDP USB"]
E -->|"字节流上行"| D
D -->|"process 入管道"| C
C -->|"完整帧"| B
B -->|"数据对象"| A
核心设计原则,四条:
- 单一职责:IO 流只管收发字节,解析器只管理解数据,设备只管状态。谁越界谁就难改。
- 开闭原则:新增一个协议,只要新增一个
Parser实现,registerParser挂上就行,已有代码一行不用动。 - 责任链:多个解析器 / 过滤器自由组合、互不干扰,透传和解析天然并行。
- 生产者-消费者:写队列把调用线程和写线程解耦,调用方永不阻塞。
这套架构在工业级 Android 应用里长期验证过,支撑了 NMEA 0183、RTCM、厂商私有协议等多种协议的解析,运行稳定可靠。回看开头那个「卡 UI」的问题,答案已经清楚了:不是设备慢,是你把「切线程」和「切缓冲」这两件事的位置摆错了。
小结
- 硬件通信先拆三件事:异步读、异步写、帧解析,别混在一个线程里。
- IO 瓶颈在上下文切换次数与缓冲区边界,不在读写速度;解析全程放 IO 读线程,只把结果推回 UI 线程。
- 用两条线程 +
LinkedBlockingQueue做读写解耦,调用线程永不阻塞。 - 半帧粘帧靠跨 read 累积的
FrameBuffer解决,残数据必须留在缓冲里等下次。 - 解析用责任链管道,加协议只加
Parser,透传和解析可并行互不干扰。
系列导航:GIS 系列第 6 篇(共 10 篇)。上一篇《矢量数据集双层并发设计》,下一篇《栅格类型包装与格网地面归算》。
你在现在项目里,读线程拿到字节后是在 IO 线程直接切帧解析,还是先丢回主线程再 parse?有没有被半帧粘帧坑过、最后定位时不时跳一下?