第 3 篇:机器人 TCP 长连接如何稳定运行?心跳、掉线检测与自动重连
上一篇,我们讲清楚了为什么服务机器人内部经常采用这样的通信方式:
Android 控制屏
↕
TCP 长连接
↕
Linux 机器人主控
原因并不复杂。
Android 控制屏和机器人主控通常长期在线,而且处于同一台机器人内部的局域网环境中。
双方又需要持续交换:
- 任务指令
- 机器人状态
- 导航状态
- 设备状态
- 执行结果
所以相比不断发送 HTTP 请求,建立一条长期存在的 TCP 连接更加自然。
但是这里有一个非常容易产生的误区:
TCP 是可靠协议,不代表 TCP 长连接就一定可靠。
我们写:
Socket.connect()
可能只需要几行代码。
但真正让机器人运行几天、几周甚至几个月都保持稳定通信,事情就没那么简单了。
机器人实际运行过程中可能遇到:
- Android 重启
- 主控重启
- Wi-Fi 短暂断开
- 网线松动
- 交换机异常
- 网络切换
- App 进程被杀
- 主控程序异常
- Socket 已经失效但客户端暂时不知道
- 发送消息时连接突然断开
所以真正的机器人 TCP 模块,不应该只是一个 Socket。
它更像是一套:
连接状态管理系统。
这一篇就继续把这条链路往下拆。
一、TCP Connected 不代表永远 Connected
我们先看最简单的 TCP 代码。
Android 启动以后:
创建 Socket
↓
连接主控
↓
连接成功
↓
开始发送和接收数据
看起来很简单。
但是机器人不是 Demo。
它可能连续运行:8 小时、24 小时、几天 ,甚至长期不关机。
在这么长的时间里,网络一定可能发生变化。
比如 Android 与主控原本连接正常:
Android
↕
TCP
↕
Linux 主控
突然某一刻:
Wi-Fi 模块发生异常。
或者:
主控重启。
或者:
网线断开几秒。
这时候 TCP 连接实际上已经失效。
但是 Android 未必会在网络中断的那一瞬间立即知道。
这也是长连接开发里一个非常重要的问题:
如何判断连接还活着?
于是就出现了:
心跳。
二、什么是 TCP 心跳?
所谓心跳,本质上就是:
客户端和服务端定期发送一条很小的消息,用来确认对方还在线。
例如 Android 每隔一段时间发送:
PING
主控收到以后返回:
PONG
整个过程:
Android
PING
↓
Linux 主控
PONG
↓
Android
如果这个过程一直正常,就说明: 通信链路基本正常。
所以我们可以简单理解成:
正常业务消息:
START_TASK
STOP_TASK
GO_TO_POINT
GET_STATUS
心跳消息:
PING
PONG
心跳本身通常不承载复杂业务。
它只回答一个问题:
对方还在不在?
三、为什么已经有 TCP 了,还需要自己做心跳?
很多人第一次看到心跳都会问:
TCP 本身不是已经有连接状态了吗?
为什么应用层还要自己发 PING / PONG?
原因在于:
应用真正关心的并不是 Socket 对象还在不在,而是机器人通信链路还能不能正常工作。
例如某些异常情况下:
Android 端 Socket 对象仍然存在。
但实际上:
- 主控已经断电。
- 网络已经断开。
- 路由已经失效。
- 主控程序已经卡死。
这时候如果一直没有任何数据收发,客户端可能无法及时感知。
而机器人控制系统通常不能接受:
几分钟以后才发现机器人已经掉线。
所以应用层会增加自己的心跳机制。
例如:
每隔 5 秒发送一次:
PING
如果连续几次都没有收到:
PONG
那么就认为:
Connection Lost
然后进入:
断线处理
↓
关闭旧连接
↓
重新建立连接
所以:
TCP 负责传输可靠性。
应用层心跳负责:
业务层面的在线状态判断。
四、机器人中的“在线”到底应该怎么定义?
这里其实还有一个非常重要的设计问题。
假设:
Socket.isConnected == true
是不是就能认为机器人在线?
不一定。
因为:
isConnected
通常只能说明这个 Socket 曾经建立过连接。
它并不能完全代表:
此时此刻对端一定正常工作。
机器人真正的在线状态,更适合根据:
最近一次收到有效数据的时间
或者:
最近一次收到心跳响应的时间
判断。
例如我们维护:
lastReceiveTime
每次收到主控消息:
lastReceiveTime = 当前时间
然后定期判断:
当前时间 - lastReceiveTime
如果超过某个阈值:
认为连接异常。
例如:
0~10 秒
正常
10~20 秒
疑似异常
超过 20 秒
认为掉线
具体时间并不是固定标准。
不同机器人项目可以根据:
- 网络环境
- 业务实时性
- 设备性能
- 安全要求
进行调整。
但核心思想是一样的:
不要只相信一个 Socket 状态字段,要根据真实通信判断连接是否健康。
五、TCP 长连接最好有自己的状态机
做到这里以后,我们会发现:
TCP 连接其实已经不只是:
- 连接
- 断开
两个状态。
真实项目里可能至少有:
- DISCONNECTED
- CONNECTING
- CONNECTED
- RECONNECTING
例如:
DISCONNECTED
↓
开始连接
↓
CONNECTING
↓
连接成功
↓
CONNECTED
如果连接异常:
CONNECTED
↓
RECONNECTING
↓
连接成功
↓
CONNECTED
如果一直失败:
RECONNECTING
↓
继续等待下一次重连
为什么要做状态机?
因为 App 页面可能需要知道:
机器人到底处于什么状态。
例如:
正在连接机器人……
↓
机器人已连接
或者:
机器人连接断开,正在重连……
这些 UI 状态不能靠页面自己猜。
应该由 TCP 通信模块统一维护。
例如:
ConnectionState
可能是:
- Disconnected
- Connecting
- Connected
- Reconnecting
然后业务层只需要观察:
connectionState
页面不需要知道底层 Socket 到底发生了什么。
这其实就是我在机器人架构中一直强调的:
底层通信状态和上层业务 UI 解耦。
六、掉线以后,为什么不能疯狂重连?
假设 TCP 断开了。
最简单的做法可能是:
while (true) {
connect()
}
只要失败就立即重新连接。看起来似乎非常积极,但这种设计其实并不好。
假设机器人主控正在重启,需要 30 秒才能恢复。
Android 就可能变成:
连接失败
↓
立即重连
↓
连接失败
↓
立即重连
↓
连接失败
↓
立即重连
一秒钟可能发起很多次连接。
这样不仅没有意义,还可能带来:
- CPU 消耗
- 日志爆炸
- 网络请求过多
- Socket 资源频繁创建销毁
主控恢复时瞬间收到大量连接请求。
所以一般不会:无脑立即重连。
而是:逐渐增加重连间隔。
这就是: 指数退避。
七、什么是指数退避重连?
比如第一次连接失败:
1 秒后重试
第二次失败:
2 秒
第三次:
4 秒
第四次:
8 秒
第五次:
16 秒
但是一般不会无限增长。
可以设置一个最大值,例如:
30 秒。
最终类似:
1s
↓
2s
↓
4s
↓
8s
↓
16s
↓
30s
↓
30s
↓
30s
这种策略就是:Exponential Backoff 指数退避。
它解决的问题是:
网络短暂异常时,可以快速恢复。
如果网络长时间异常,又不会疯狂消耗资源。
一旦连接成功:重试次数清零。
下一次重新断开时: 再次从较短的等待时间开始。
八、重连之前为什么要先清理旧连接?
还有一个很容易被忽略的问题。
假设连接异常以后直接创建:
new Socket()
但旧 Socket 没有清理干净。
时间久了可能出现:
Socket A
Socket B
Socket C
甚至多个接收线程同时存在。
这时候问题就麻烦了。
比如:
A 还在收数据。
B 又建立成功。
C 又开始重连。
最终你可能根本不知道:
当前真正使用的是哪一条连接。
所以重连前通常应该先:
停止旧接收任务
↓
停止旧发送任务
↓
关闭 InputStream
↓
关闭 OutputStream
↓
关闭 Socket
↓
清理连接状态
↓
重新创建连接
换句话说:
任何时刻都应该明确只有一个当前有效连接。
这一点在机器人这种长期运行系统里非常重要。
九、发送消息为什么最好不要直接 socket.write()?
刚开始写的时候,我们很容易这样做:
用户点击按钮:
↓
直接:
socket.getOutputStream().write(data)
看起来很自然。
但是随着机器人业务复杂起来,很快就会出现问题。
因为很多模块都可能发送消息。
例如:
任务模块:
START_TASK
导航模块:
GO_TO_POINT
设备模块:
OPEN_DOOR
心跳模块:
PING
状态查询:
GET_STATUS
如果所有地方都直接操作 OutputStream:
TaskModule
↓
OutputStream
NavigationModule
↓
OutputStream
HeartbeatModule
↓
OutputStream
很快通信层就会变得混乱。
更好的方式通常是:
所有模块只负责: 创建消息
然后: 放入发送队列。
例如:
TaskModule
NavigationModule
DeviceModule
==================================================================
Heartbeat
↓
SendQueue
↓
统一发送协程
↓
Socket OutputStream
这样可以保证:
- 消息统一排序
- 统一发送
- 统一异常处理
- 统一日志记录
- 统一连接状态判断
业务模块不需要直接碰 Socket。
十、发送队列到底解决什么问题?
假设某一时刻:
心跳线程准备发送:
PING
用户同时点击:
STOP_TASK
机器人状态模块又需要发送:
GET_STATUS
如果三个地方同时 write:
就会增加并发写入和消息边界处理的复杂度。
而有了发送队列:
- PING
- STOP_TASK
- GET_STATUS
全部先进入:SendQueue
例如:Channel
然后只有一个发送协程:
while (isActive) {
val message = queue.receive()
socket.write(message)
}
这样整个发送过程就变成:
业务模块
↓
消息队列
↓
一个发送器
↓
TCP
这是一个非常经典的:生产者 → 消费者 模型。
业务模块是:
Producer
发送协程是:
Consumer
这样通信模块会稳定很多。
十一、接收数据为什么通常只有一个入口?
发送如此。
接收也是一样。
TCP InputStream 最好只有一个统一的读取入口。
例如:
Receive Coroutine
↓
socket.read()
↓
收到字节数据
↓
放入接收缓冲区
↓
协议解析
↓
解析出完整 Message
↓
根据 Command 分发
例如:
收到:
TASK_STATE
交给任务模块。
收到:
ROBOT_STATUS
交给状态模块。
收到:
PONG
交给连接管理模块。
收到:
DEVICE_STATE
交给设备模块。
整个流程:
Socket
↓
Receive Loop
↓
Protocol Parser
↓
Message Dispatcher
↓
业务模块
这样业务层根本不需要关心:
read()
InputStream
byte[]
这些底层细节。
十二、Android 协程模型可以怎么理解?
如果用 Kotlin 协程来理解,一个 TCP 模块大概可以拆成几类长期任务。
连接任务:
connectJob
负责:建立连接。
接收任务:
receiveJob
负责:持续读取 TCP 数据。
发送任务:
sendJob
负责:消费发送队列。
心跳任务:
heartbeatJob
负责:定期发送心跳。
监控任务:
monitorJob
负责:检查最近一次接收时间。
整体关系类似:
TCP Manager
├── Connect Coroutine
├── Receive Coroutine
├── Send Coroutine
├── Heartbeat Coroutine
└── Connection Monitor
这样相比到处:
Thread {}
Thread {}
Timer {}
Handler.postDelayed()
结构会清晰很多。
重点并不是:
“一定要用协程。”
而是:
这些长期任务必须有清晰的生命周期和职责边界。
十三、网络切换为什么会让 TCP 失效?
机器人运行过程中,还有一种非常常见的情况:
网络发生变化。
例如:
Wi-Fi 断开
↓
重新连接
或者:
Android 网络接口变化。
这时候原来的 TCP Socket 很可能已经无法继续使用。
即使新的网络已经恢复:
旧 Socket
也不一定会自动恢复。
所以网络恢复以后,通常需要:
检测网络状态
↓
关闭旧连接
↓
重新建立 TCP
而不是期待:
原来的 Socket 自己重新活过来。
这和我们手机 App 里 HTTP 的体验不太一样。
HTTP 请求失败以后:
下一次请求重新发就行。
但是 TCP 长连接维护的是:
一个持续存在的连接对象。
所以网络发生变化以后:
连接管理模块必须主动恢复连接。
十四、Android 页面关闭以后,TCP 要不要断?
这也是机器人项目里很容易设计错的地方。
假设用户从:
机器人首页
进入:
设置页面
如果 TCP 连接绑定在某一个 Activity 或 Fragment 上:
页面一销毁:
Socket.close()
那么每次页面切换都可能导致:
TCP 断开
↓
重新连接
↓
重新初始化状态
显然不合理。
因为机器人 TCP 连接的生命周期通常并不属于:
某一个页面。
而属于:
整个机器人控制应用。
所以更合理的关系是:
Application / Service / Connection Manager
↓
维护 TCP
而页面:
Activity / Fragment / Compose Screen
只是观察:
- 机器人状态
- 连接状态
- 任务状态
也就是说:
TCP 生命周期应该高于普通 UI 页面生命周期。
十五、后台运行又意味着什么?
机器人上的 Android 控制屏和普通手机 App 还有一点区别。
很多机器人控制屏本身就是:
专用设备。
App 可能长期前台运行。
甚至:
开机自动启动。
所以 TCP 通信也可能需要:
机器人启动
↓
App 启动
↓
连接主控
↓
长期保持
即使某个页面暂时没有显示:
通信仍然继续。
例如机器人正在执行:
巡航任务。
即使 Android 页面切换到了:
设置页面
主控仍然会持续上报:
NAVIGATING
POSITION
BATTERY
TASK_PROGRESS
这些状态不能因为页面改变就停止接收。
所以:UI 生命周期 和 机器人连接生命周期 必须分开。
十六、一个比较完整的 TCP 管理流程
现在把前面的内容串起来。
机器人 Android App 启动:
App Start
↓
TCP Manager 初始化
↓
ConnectionState = CONNECTING
↓
连接 Linux 主控
↓
连接成功
↓
ConnectionState = CONNECTED
↓
启动:
- Receive Loop
- Send Loop
- Heartbeat
- Connection Monitor
然后进入正常运行:
业务模块
↓
SendQueue
↓
TCP
同时:
TCP
↓
Receive Loop
↓
Protocol Parser
↓
Message Dispatcher
↓
业务状态
如果出现:
- IOException
- 心跳超时
- 网络断开
- 主控重启
↓
进入:
Disconnected
↓
关闭旧 Socket
↓
停止旧任务
↓
ConnectionState = RECONNECTING
↓
指数退避
↓
重新 connect
↓
连接成功
↓
重新启动:
- Receive
- Send
- Heartbeat
- Monitor
↓
CONNECTED
这才是一条真正完整的:
机器人 TCP 长连接生命周期。
十七、业务层不应该知道 Socket 的存在
做到这里以后,我们其实可以得到一个很重要的架构结论。
上层业务代码最好不要出现:
- Socket
- InputStream
- OutputStream
- read()
- write()
这些东西。
比如任务模块只应该调用:
robotClient.startTask()
设备模块调用:
robotClient.openDoor()
导航模块调用:
robotClient.goToPoint()
至于底层到底是:
- TCP
- Socket
- ByteArray
- 发送队列
- 心跳
- 重连
业务层都不需要知道。
理想情况下应该是:
UI
↓
ViewModel
↓
RobotRepository
↓
RobotClient
↓
TCP Manager
↓
Protocol
↓
Socket
这样以后即使底层通信实现发生变化:
TCP
换成其他方式,业务层受到的影响也会小很多。
这其实已经不只是: “怎么写 Socket” 的问题。
而是:
如何把机器人通信能力设计成基础设施。
十八、机器人 TCP 模块真正要解决的,不是 connect()
所以现在再来看最开始的问题。
TCP 长连接难不难?
如果只是:
- Socket()
- connect()
- write()
- read()
其实并不难。
真正困难的是:
- 机器人运行一天以后还正常。
- 网络断开以后能自动恢复。
- 主控重启以后 Android 能重新连接。
- 重复重连不会产生多个 Socket。
- 业务发送消息不会互相打架。
- 页面切换不会导致连接销毁。
- 心跳可以准确判断主控是否在线。
- 各种异常都能回到一个确定的连接状态。
所以一个成熟的机器人 TCP 模块,本质上是在解决:
连接状态管理。
而不是:Socket API。
十九、总结
这一篇最重要的可以浓缩成一张图:
Android 业务模块
↓
SendQueue
↓
TCP Manager
├── Connection
├── Send Loop
├── Receive Loop
├── Heartbeat
├── Timeout Monitor
└── Reconnect
↓
TCP Socket
↓
Linux 机器人主控
当连接异常:
异常发现
↓
关闭旧连接
↓
进入 RECONNECTING
↓
指数退避
↓
重新 connect
↓
恢复通信
所以真正稳定的机器人 TCP 长连接,需要至少考虑:
- 连接状态
- 心跳保活
- 掉线检测
- 自动重连
- 指数退避
- 发送队列
- 统一接收
- 协程生命周期
- 网络切换
- App 生命周期
做到这里以后:
Android 和机器人主控之间的 TCP 通道才算真正建立起来。
但是新的问题马上又来了。
我们一直在说:
发送一条消息。
接收一条消息。
可 TCP 本身其实根本没有:“一条消息” 这个概念。
它只提供:连续的字节流。
所以你调用一次:
write(A)
再调用一次:
write(B)
接收端的 read() 并不一定会变成:
第一次读到 A
第二次读到 B。
它可能一次全部读到。
也可能一条消息被拆成两次甚至多次读取。
这就是 TCP 开发中非常经典的问题: 粘包和半包。
下一篇继续:
第 4 篇
《TCP 字节流中的粘包、半包与机器人协议解析》
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)