不用 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 请求,被消费就不再进入业务状态机,避免一条响应被处理两次。
七、可靠重连:三层兜底,冗余是故意的
单层重连在现场环境下不够用,这个项目叠了三层:
- Client 内部 3 秒重连 (
CabinetTcpClient.kt:122-144):断线后独立线程延迟 3 秒重连,synchronized+@Volatile双重检查防并发重入,shouldReconnect开关保证主动断开时不误重连; - 70 秒心跳超时强制重连 (
MainViewModel.kt:537-576):每 30 秒向所有控制板发心跳,每个 Host 一个独立的超时 Runnable,收到心跳就 remove + repost 续命,70 秒没收到就主动断开重连; - 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 管定时,信号量消灭业务层并发------业务代码里可以不设防地读写数据库,因为串行化已经在入口处保证了。
十、总结
- 编解码器保持纯函数,用"返回消耗字节数"的三态语义把粘包处理移交给缓冲区循环;
available()轮询 vs 阻塞 read 没有标准答案,匹配你的超时约束才是答案;- 对端性能弱时,发送侧的队列化、优先级、防饿死、断连清队列比连接本身更重要;
CompletableDeferred + withTimeout是把消息流包装成请求-响应的轻量方案,不用引框架;- 重连要做多层兜底,工业现场"防御式冗余"不是浪费,是必需品。