Android GIS 系列几百万行数据怎么不卡 UI:异步 IO 流与解析管道的分层设计

2 阅读16分钟

IO 的瓶颈不在读写速度,而在上下文切换次数与缓冲区边界。

先讲一个真实到有点扎心的场景:你在 Android 上跟一台外接 GNSS 模组通信,设备每秒钟往外吐几十条 NMEA 语句,峰值能到上百条。你以为「卡不卡」取决于硬件收得快不快,结果一接上,主线程开始掉帧,UI 一会儿冻一下,定位还时不时跳变。你去看 CPU,并没有跑满;去看网速,也没瓶颈。问题出在哪?出在数据是以连续字节流的形式来的,没有边界——一句话可能被拆成两帧到达,也可能两句话挤在一帧里,而你还在主线程里一个字节一个字节地拼、一条语句一条语句地解析。

本文要讲的,是一套在工业级 Android 项目里跑了很多年的架构:异步 IO 流 + 数据解析管道。它的核心判断就一句话——IO 真正的瓶颈从来不是读写速度,而是「线程切换了几次」和「缓冲区在哪儿断了」。把这两件事设计对,几百万行数据流过,UI 照样丝滑。


一、先说清楚:跟硬件"对话"到底难在哪

跟硬件设备通信,表面上是「连上、收发」四个字,落地时会发现它本质上要同时解决三件事:

  1. 异步读:硬件数据随时可能到来,你不能阻塞调用线程干等,必须有个常驻的读线程在后台守着。
  2. 异步写:写指令也不能卡住调用线程,点了发送就得立刻返回,真正的发送在别处发生。
  3. 帧解析:从一段连续的字节流里,把「完整的一帧数据」抠出来——这是最容易被低估的一步。

这三件事单拎出来都不难,难的是它们组合到一起之后冒出来的坑:

  • 读线程和写线程大概率会同时碰同一个 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) {}
}

几个设计要点,每一条都是踩出来的:

  1. 读写分离:读线程和写线程独立跑,互不阻塞。调用线程写数据时只往队列里 put,立刻返回。
  2. 写队列LinkedBlockingQueue 实现了生产者-消费者模式,调用线程是生产者,写线程是消费者,天然解耦。
  3. 线程安全:写操作通过队列串行化,读操作只有单线程,从根上消除了竞争条件——这一点比加一堆锁干净得多。
  4. 生命周期可控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 帧缓冲区怎么写

FrameBufferByteArrayOutputStream 当蓄水池,每次 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[&#34;硬件设备&#34;]
    participant RT[&#34;读线程 IO-Read&#34;]
    participant PIPE[&#34;解析管道&#34;]
    participant PARSER[&#34;NmeaParser&#34;]
    participant DATA[&#34;NmeaData&#34;]
    participant UI[&#34;UI 线程&#34;]
    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[&#34;业务层 UI 日志 跨进程&#34;] --> B[&#34;解析管道层 Filter链 FrameParser&#34;]
    B --> C[&#34;设备抽象层 状态机 重连&#34;]
    C --> D[&#34;异步IO流层 读线程 写线程&#34;]
    D --> E[&#34;通道实现层 蓝牙 串口 Socket UDP USB&#34;]
    E -->|&#34;字节流上行&#34;| D
    D -->|&#34;process 入管道&#34;| C
    C -->|&#34;完整帧&#34;| B
    B -->|&#34;数据对象&#34;| A

核心设计原则,四条:

  1. 单一职责:IO 流只管收发字节,解析器只管理解数据,设备只管状态。谁越界谁就难改。
  2. 开闭原则:新增一个协议,只要新增一个 Parser 实现,registerParser 挂上就行,已有代码一行不用动。
  3. 责任链:多个解析器 / 过滤器自由组合、互不干扰,透传和解析天然并行。
  4. 生产者-消费者:写队列把调用线程和写线程解耦,调用方永不阻塞。

这套架构在工业级 Android 应用里长期验证过,支撑了 NMEA 0183、RTCM、厂商私有协议等多种协议的解析,运行稳定可靠。回看开头那个「卡 UI」的问题,答案已经清楚了:不是设备慢,是你把「切线程」和「切缓冲」这两件事的位置摆错了。


小结

  • 硬件通信先拆三件事:异步读、异步写、帧解析,别混在一个线程里。
  • IO 瓶颈在上下文切换次数与缓冲区边界,不在读写速度;解析全程放 IO 读线程,只把结果推回 UI 线程。
  • 用两条线程 + LinkedBlockingQueue 做读写解耦,调用线程永不阻塞。
  • 半帧粘帧靠跨 read 累积的 FrameBuffer 解决,残数据必须留在缓冲里等下次。
  • 解析用责任链管道,加协议只加 Parser,透传和解析可并行互不干扰。

系列导航:GIS 系列第 6 篇(共 10 篇)。上一篇《矢量数据集双层并发设计》,下一篇《栅格类型包装与格网地面归算》。

你在现在项目里,读线程拿到字节后是在 IO 线程直接切帧解析,还是先丢回主线程再 parse?有没有被半帧粘帧坑过、最后定位时不时跳一下?