不用 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
}

响应到达时由 handleOpenResponsecabinet_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 队列里也只放行 OPENHEARTBEAT
  • 期间收到的门状态/在位上报直接忽略(反正开门期间状态本来就在剧烈变化,处理也是浪费);
  • 批量结束后恢复队列,并清空状态去重缓存,让恢复后的轮询重新拉取最终状态。

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

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

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

十、总结

  1. 编解码器保持纯函数,用"返回消耗字节数"的三态语义把粘包处理移交给缓冲区循环;
  2. available() 轮询 vs 阻塞 read 没有标准答案,匹配你的超时约束才是答案;
  3. 对端性能弱时,发送侧的队列化、优先级、防饿死、断连清队列比连接本身更重要;
  4. CompletableDeferred + withTimeout 是把消息流包装成请求-响应的轻量方案,不用引框架;
  5. 重连要做多层兜底,工业现场"防御式冗余"不是浪费,是必需品。
相关推荐
Godikov1 小时前
硬件异步事件下的业务一致性:智能柜领取/归还流程的竞态设计
android
事圆则缓1 小时前
Java 面向对象如何落到 Android View 体系
android
iPad协议个微协议2 小时前
# 微信自动营销系统如何基于 WechatApi 做事件驱动设计
android·微信·企业微信·个人开发·wechatapi·微信个人号开发
AirDroid_cn2 小时前
隐私沙盒:第三方应用读取剪切板内容如何拦截?
android·gitee
2501_915918414 小时前
怎么把 Python 写的 Flet 应用打包成 iOS App 并上架 App Store?
android·ios·小程序·https·uni-app·iphone·webview
邪修king6 小时前
Re:Linux系统篇(二十六):文件系统(二):Ext 文件系统底层详解:从 inode、块组到软硬链接,结合 Windows 讲透文件管理本质
android·java·linux
又见情义6 小时前
RK3568 Android 13 版本号管理实战:基于 ROCKCHIP_BUILD_NUMBER 的定制化方案
android
终端安全笔记6 小时前
iOS 27 强制 TLS 1.2:租赁设备的注册链路会在哪一环断
android·网络·安全·ios·智能手机
mmsx7 小时前
MapLibre 实战 11|用户说"我的地块丢了":一个 sealed class 图层模型,和四个让我重构三版的坑
android·前端·开源