不用 Netty:Android 手写 TCP 客户端实战——自定义协议帧、粘包处理与可靠重连

不用 Netty:Android 手写 TCP 客户端实战------自定义协议帧、粘包处理与可靠重连

场景:Android 智能柜终端需要通过 TCP 长连接与多个柜门控制板通信(端口 1234,JSON 指令)。对端是性能有限的嵌入式板卡,扛不住指令风暴,还会断线、丢响应。没有引入 Netty 等框架,纯手写了一套客户端。本文分享其中最有普适价值的几个设计:帧编解码、粘包/半包处理、发送侧优先级队列、异步消息流的同步化、多层重连兜底。

一、整体结构:四层各司其职

复制代码
CabinetProtocol      纯常量:帧头、指令类型、响应码(35 行)
ProtocolFrameCodec   帧编解码:纯函数,无副作用(61 行)
CabinetTcpClient     连接/收发/重连/发送队列/线程模型(534 行)
CabinetManager       业务指令封装:开门、灯控、状态查询(227 行)

职责切分得干净,每一层都可以单独理解和测试。协议定义(CabinetProtocol.kt):

kotlin 复制代码
object CabinetProtocol {
    const val HEADER_FLAG = 0x20.toByte()
    const val MAX_FRAME_SIZE = 4096
    const val DEFAULT_PORT = 1234

    object Type {
        const val HEARTBEAT = "heartbeat"
        const val OPEN = "open"
        const val TURN_ON_LIGHT = "turn_on"
        const val ON = "on"           // 门已开(硬件上报)
        const val CLOSE = "close"     // 门已关(硬件上报)
        const val GET_STATUS = "get_status"
        // ...
    }

    object Code {
        const val SUCCESS = 200
        const val FAILED = 500
    }
}

帧格式:[0x20][4字节长度(大端)][JSON数据]。

二、帧编解码:三态返回语义是关键

编解码器是整个协议层最精巧的部分。解码函数返回 Pair<JSON, 消耗字节数>?,三种返回语义对应三种情况 (ProtocolFrameCodec.kt:31-59):

kotlin 复制代码
fun decode(dataStream: ByteArray, startIndex: Int = 0): Pair<String, Int>? {
    if (dataStream.size - startIndex < 5) return null        // 半包:头都不完整

    if (dataStream[startIndex] != CabinetProtocol.HEADER_FLAG) {
        throw ProtocolException("Invalid frame header")      // 协议错误:抛异常
    }

    val length = ((dataStream[startIndex + 1].toInt() and 0xFF) shl 24) or ...
    if (length < 0 || length > CabinetProtocol.MAX_FRAME_SIZE - 5) {
        throw ProtocolException("Invalid data length: $length")
    }

    if (dataStream.size - startIndex < 5 + length) {
        return null                                          // 半包:体不完整
    }

    val jsonData = String(dataStream, startIndex + 5, length, Charsets.UTF_8)
    return Pair(jsonData, 5 + length)                        // 完整帧:返回数据+消耗字节数
}

注意设计取向:编解码器自己不管缓冲区,只负责"从 startIndex 开始尝试解一帧,告诉你消耗了多少字节"。粘包/半包的处理责任通过"消耗字节数"这个返回值移交给了调用方的缓冲区循环------这让编解码器保持纯函数,逻辑全部可测。

三、粘包/半包处理:滑动缓冲区 + 逐字节容错重同步

接收侧(CabinetTcpClient.kt:147-221)维护一个固定 4KB 的缓冲区和 bufferPosition 指针:

kotlin 复制代码
private fun processBuffer() {
    var processed = 0
    while (processed < bufferPosition) {
        try {
            val result = codec.decode(buffer, processed)
            if (result != null) {
                val (jsonData, consumed) = result
                callback?.onMessageReceived(host, jsonData)
                processed += consumed                 // 粘包:继续解下一帧
            } else {
                break                                 // 半包:等更多数据
            }
        } catch (e: ProtocolException) {
            callback?.onError(e)
            processed++                               // 协议错误:跳过 1 字节重同步
        }
    }
    // 把未消费的剩余数据搬到缓冲区头部
    if (processed > 0) {
        val remaining = bufferPosition - processed
        if (remaining > 0) {
            System.arraycopy(buffer, processed, buffer, 0, remaining)
        }
        bufferPosition = remaining
    }
}

三种情况各一句处理:完整帧就消费并继续(解粘包),半包就 break 等数据,协议错误就逐字节跳过找下一个帧头(容错重同步)。61 行编解码 + 这段循环,就是自定义 TCP 协议粘包处理的全部要点。

四、一个非常规选择:available() 轮询替代阻塞 read

教科书写法是 inputStream.read(buffer) 阻塞读。这个项目偏偏用了 available() 轮询 + 10ms 睡眠,代码注释里自曝了动机:

kotlin 复制代码
val available = stream.available()
if (available > 0) {
    // FIX: 限制读取长度不超过 available() 报告的数量,
    // 避免 read() 阻塞等待数据导致 soTimeout 触发
    val toRead = minOf(available, buffer.size - bufferPosition)
    val readCount = stream.read(buffer, bufferPosition, toRead)
    if (readCount > 0) {
        bufferPosition += readCount
        processBuffer()
    }
} else {
    Thread.sleep(10)   // 避免 CPU 空转
}

背景是 socket 设置了 soTimeout = 5000,阻塞式 read 一旦 5 秒没数据就抛 SocketTimeoutException,长连接空闲期会疯狂误触发超时。改用 available() 轮询后,只在"确定有数据"时才 read,超时问题从根上消失。代价是 10ms 级的接收延迟和轻微空转------对门控场景完全可接受。

这是"教科书方案 vs 现场方案"的典型案例:没有绝对正确的写法,只有匹配约束的写法。

五、发送侧:四级优先级队列 + 防饿死调度

对端是弱性能嵌入式板卡,业务上既有"用户开门"这种必须立刻响应的指令,又有几百路状态的周期轮询。如果调 send 就直接写 socket,轮询流量会把用户指令饿死。方案是队列化 + 优先级调度(CabinetTcpClient.kt:42-66):

kotlin 复制代码
enum class CommandPriority(val value: Int) {
    URGENT(0),   // 用户操作:开门、关灯等
    P0(1),       // 开启状态的箱门轮询
    P1(2),       // 在位检测轮询
    P2(3)        // 关闭状态的箱门轮询
}

private val urgentQueue = LinkedBlockingQueue<Command>(50)
private val p0Queue = LinkedBlockingQueue<Command>(50)
// ...

调度协程(CabinetTcpClient.kt:228-286)的防饿死策略:P0 连续最多发 3 个就强制让位给 P1/P2,并且每条指令之间保留间隔,避免打满板卡:

kotlin 复制代码
val cmd = when {
    urgentQueue.isNotEmpty() -> { p0Streak = 0; urgentQueue.poll() }
    p0Queue.isNotEmpty() && p0Streak < 3 -> { p0Streak++; p0Queue.poll() }
    p1Queue.isNotEmpty() -> { p0Streak = 0; p1Queue.poll() }
    p2Queue.isNotEmpty() -> { p0Streak = 0; p2Queue.poll() }
    else -> { p0Streak = 0; null }
}
if (cmd != null) {
    val success = sendInternal(cmd.jsonData)
    delay(when {
        urgentQueue.isNotEmpty() -> 100L   // urgent 队列还有积压:100ms 间隔,防板卡过载
        else -> intervalMs
    })
}

还有两个防御细节:

  • 队列满时丢最旧命令 (offerWithDropOldest)------轮询类状态查询丢了无碍,下一周期会补;
  • 断连时清空所有轮询队列------防止离线期间无效堆积,重连后瞬间把积压的几百条过期查询打向板卡。

六、异步消息流包装成同步请求-响应

TCP 是消息流,但业务想要"开门并等结果,失败重试"。用 CompletableDeferred + withTimeout 把回调包装成挂起函数(CabinetManager.kt:58-120):

kotlin 复制代码
private val pendingOpenResponses = ConcurrentHashMap<String, CompletableDeferred<Boolean>>()

suspend fun openDoor(cabinetAddress: String, doorAddress: String): Boolean {
    val key = "$cabinetAddress-$doorAddress"
    repeat(3) { attempt ->
        // 防并发:同门已有等待中的请求,取消旧的
        pendingOpenResponses[key]?.cancel()
        val deferred = CompletableDeferred<Boolean>()
        pendingOpenResponses[key] = deferred

        tcpClient.sendCommand(CabinetProtocol.Type.OPEN, data)
        try {
            val success = withTimeout(1000L) { deferred.await() }   // 1 秒等响应
            if (success) return true
            if (attempt < 2) delay(500L)
        } catch (e: TimeoutCancellationException) {
            // 超时,进入下一次重试
        } finally {
            pendingOpenResponses.remove(key)
        }
    }
    return false
}

响应到达时由 handleOpenResponse 按 cabinet_address-door_address 找到对应 deferred 并 complete()。同时在消息总入口做"抢消费"------开门响应先尝试匹配 pending 请求,被消费就不再进入业务状态机,避免一条响应被处理两次。

七、可靠重连:三层兜底,冗余是故意的

单层重连在现场环境下不够用,这个项目叠了三层:

  1. Client 内部 3 秒重连 (CabinetTcpClient.kt:122-144):断线后独立线程延迟 3 秒重连,synchronized + @Volatile 双重检查防并发重入,shouldReconnect 开关保证主动断开时不误重连;
  2. 70 秒心跳超时强制重连 (MainViewModel.kt:537-576):每 30 秒向所有控制板发心跳,每个 Host 一个独立的超时 Runnable,收到心跳就 remove + repost 续命,70 秒没收到就主动断开重连;
  3. onDisconnected 兜底:注释写得很直白------"如果 CabinetTcpClient 内部重连失败,70 秒后由 MainViewModel 主动恢复"。
kotlin 复制代码
private fun postHeartbeatTimeout(host: String, delayMillis: Long = 70000L) {
    val runnable = getOrCreateHeartbeatRunnable(host)
    mHeartbeatHandler.removeCallbacks(runnable)          // 先移除,防任务堆叠
    mHeartbeatHandler.postDelayed(runnable, delayMillis)
}

工业现场的指导原则是:连接恢复路径不止一条,每条路径都假设其他路径可能失效。

八、独占开门模式:突刺流量下保护脆弱对端

管理员"一键开门"可能同时开几十扇门,开门指令是 URGENT 级,但与此同时几百路轮询和状态上报还在跑,板卡直接被打爆。方案是进入"独占模式":

  • 普通轮询命令整体转移到挂起队列;
  • urgent 队列里也只放行 OPEN 和 HEARTBEAT;
  • 期间收到的门状态/在位上报直接忽略(反正开门期间状态本来就在剧烈变化,处理也是浪费);
  • 批量结束后恢复队列,并清空状态去重缓存,让恢复后的轮询重新拉取最终状态。

九、线程模型:混合但分工明确

执行体 职责
裸 Thread(每连接一个) socket 读循环、重连
协程 Dispatchers.IO + SupervisorJob 发送调度器、消息消费
主线程 Handler 心跳、周期统计等轻量定时任务
独立 HandlerThread × 2 门状态轮询调度、灯控防抖(注释:避免阻塞主线程)
Semaphore(1) 所有门事件的业务处理全局串行
EventBus 连接状态、批量开门进度等跨层通知

原则:裸线程只管 socket IO,协程管业务,Handler 管定时,信号量消灭业务层并发------业务代码里可以不设防地读写数据库,因为串行化已经在入口处保证了。

十、总结

  1. 编解码器保持纯函数,用"返回消耗字节数"的三态语义把粘包处理移交给缓冲区循环;
  2. available() 轮询 vs 阻塞 read 没有标准答案,匹配你的超时约束才是答案;
  3. 对端性能弱时,发送侧的队列化、优先级、防饿死、断连清队列比连接本身更重要;
  4. CompletableDeferred + withTimeout 是把消息流包装成请求-响应的轻量方案,不用引框架;
  5. 重连要做多层兜底,工业现场"防御式冗余"不是浪费,是必需品。
相关推荐
传奇开心果编程21 分钟前
【Compose Multiplatform 跨端开发学与练】第5课 网络与数据层
android·网络·学习·ui·ios·kotlin·composer
ao-weilai1 小时前
MySQL数据库:内置函数
android·数据库·mysql
SWAGGY..2 小时前
【C++进阶】:(7)红黑树的原理与 C++ 实现:结构设计、插入调整及性质验证
android·java·开发语言·c++·算法
Android打工仔2 小时前
CoroutineScheduler 设计解析(上)—— 为什么 Dispatchers.IO 会创建更多线程?
android·kotlin·源码阅读
martindelophy3 小时前
用自然语言剪视频:我们在 Timeline Studio 中实现了 ChatCut 对话剪辑
android·音视频
ao-weilai4 小时前
MySQL数据库:复合查询
android·数据库·mysql
三少爷的鞋16 小时前
AI 时代,我们都将成为通才型开发者:只懂 Android,已经不够了
android
Dovis(誓平步青云)20 小时前
家里设备越来越多,如何用一张空间地图控制灯光和温度![
android·java·前端·javascript·人工智能·电脑
晚风叙码1 天前
MySQL 数据类型详解:从数值到字符串,一篇讲透
android·mysql·adb
传奇开心果编程1 天前
【Compose Multiplatform 跨端开发学与练】第3课 布局与组件
android·windows·学习·ui·ios·kotlin·composer