上一篇,我们讲清楚了为什么服务机器人内部经常采用这样的通信方式:

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:

就会增加并发写入和消息边界处理的复杂度。

而有了发送队列:

  1. PING
  2. STOP_TASK
  3. 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 字节流中的粘包、半包与机器人协议解析》

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐