fix: 连接状态判定修复 — 收到首个数据包后才标记已连接,CONNECTING 5秒超时回退

- ProtocolClient: 仅收到首个有效数据包后设置 CONNECTED,不再 socket 创建后立即标记
- ProtocolClient: timeoutCheckLoop 在 connect() 中立即启动,覆盖 CONNECTING(5s) 和 CONNECTED(3s)
- RobotConnection: 允许 CONNECTING → TIMEOUT 合法转换
- MainScreen: CONNECTING 时点击连接按钮仅断开回到 DISCONNECTED,不再自动重连

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
2026-07-18 12:15:10 +08:00
parent b2adf46957
commit fbc3f1529f
4 changed files with 33 additions and 15 deletions
+4 -1
View File
@@ -161,7 +161,7 @@ gradlew installDebug
- **状态管理**: 使用 `StateFlow` 暴露连接状态、机器人状态 - **状态管理**: 使用 `StateFlow` 暴露连接状态、机器人状态
- **生命周期**: 网络连接生命周期绑定到 Activity/Service,前台时活跃 - **生命周期**: 网络连接生命周期绑定到 Activity/Service,前台时活跃
- **MsgId**: 从 0 递增 u16,循环回绕 - **MsgId**: 从 0 递增 u16,循环回绕
- **断线判定**: 3 秒无任何 UDP 包 → 标记离线 - **断线判定**: 收到首个有效数据包前为 `CONNECTING`5 秒无数据 → `TIMEOUT`);`CONNECTED` 3 秒无任何 UDP 包 → `TIMEOUT`
--- ---
@@ -208,6 +208,9 @@ gradlew installDebug
- [x] UI 布局:底部控制区移除"更多"展开区域,仅保留左右摇杆 - [x] UI 布局:底部控制区移除"更多"展开区域,仅保留左右摇杆
- [x] UI 布局:所有功能按钮统一使用平行四边形形状(左侧按钮斜边左下→右上,右侧按钮斜边左上→右下) - [x] UI 布局:所有功能按钮统一使用平行四边形形状(左侧按钮斜边左下→右上,右侧按钮斜边左上→右下)
- [x] UI 布局:所有控制按钮在未连接时灰色不可点击 - [x] UI 布局:所有控制按钮在未连接时灰色不可点击
- [x] 连接状态准确性修复:`ProtocolClient` 收到首个有效数据包后才标记 `CONNECTED`,不再 socket 创建后立即标记;`CONNECTING` 状态 5 秒无响应自动超时回退 `TIMEOUT`
- [x] 连接按钮交互优化:`CONNECTING` 状态下点击连接按钮仅断开回到 `DISCONNECTED`,不再自动重连,由用户手动再次点击发起重试
- [x] 状态机补充:`RobotConnection` 允许 `CONNECTING → TIMEOUT` 合法转换
### 待办 ### 待办
@@ -89,9 +89,10 @@ class ProtocolClient(
/** /**
* 建立连接: * 建立连接:
* 1. 创建 UDP socket * 1. 创建 UDP socket
* 2. 启动接收协程 * 2. 启动接收/发送协程
* 3. 发送订阅请求 * 3. 发送订阅请求
* 4. 启动心跳和断线检测 * 4. 启动断线检测CONNECTING 5s / CONNECTED 3s 无数据 → TIMEOUT
* 5. 首次收到有效数据包后标记已连接,并启动心跳 1Hz(在接收循环中处理)
*/ */
fun connect() { fun connect() {
if (_state.value != State.DISCONNECTED && _state.value != State.ERROR) return if (_state.value != State.DISCONNECTED && _state.value != State.ERROR) return
@@ -131,14 +132,7 @@ class ProtocolClient(
// 发送订阅请求(触发服务端 UDP 推送) // 发送订阅请求(触发服务端 UDP 推送)
sendSubscriptionRequests() sendSubscriptionRequests()
_state.value = State.CONNECTED // 启动断线检测(立即开始,覆盖 CONNECTING 和 CONNECTED 状态)
// 启动心跳
heartbeatJob = scope.launch(Dispatchers.IO) {
heartbeatLoop(sock, address)
}
// 启动断线检测
timeoutJob = scope.launch { timeoutJob = scope.launch {
timeoutCheckLoop() timeoutCheckLoop()
} }
@@ -232,6 +226,14 @@ class ProtocolClient(
if (report != null) { if (report != null) {
_statusReport.tryEmit(report) _statusReport.tryEmit(report)
} }
// 首次收到有效数据包:标记已连接,启动心跳
if (_state.value == State.CONNECTING) {
_state.value = State.CONNECTED
heartbeatJob = scope.launch(Dispatchers.IO) {
heartbeatLoop(sock, remoteAddress!!)
}
}
} catch (_: SocketTimeoutException) { } catch (_: SocketTimeoutException) {
// 超时正常,继续循环 // 超时正常,继续循环
continue continue
@@ -258,13 +260,23 @@ class ProtocolClient(
} }
} }
/** 断线检测:3 秒无数据 → 标记 TIMEOUT */ /** 断线检测:CONNECTED 状态 3 秒无数据 → TIMEOUTCONNECTING 状态 5 秒无数据 → TIMEOUT */
private suspend fun CoroutineScope.timeoutCheckLoop() { private suspend fun CoroutineScope.timeoutCheckLoop() {
while (isActive && _state.value != State.DISCONNECTED) { while (isActive && _state.value != State.DISCONNECTED) {
val elapsed = System.currentTimeMillis() - lastPacketTimeMs val elapsed = System.currentTimeMillis() - lastPacketTimeMs
if (elapsed > 3000 && _state.value == State.CONNECTED) { when (_state.value) {
State.CONNECTED -> {
if (elapsed > 3000) {
_state.value = State.TIMEOUT _state.value = State.TIMEOUT
} }
}
State.CONNECTING -> {
if (elapsed > 5000) {
_state.value = State.TIMEOUT
}
}
else -> { /* DISCONNECTED/TIMEOUT/ERROR: 无需超时处理 */ }
}
delay(500) delay(500)
} }
} }
@@ -50,6 +50,7 @@ class RobotConnection {
ProtocolClient.State.CONNECTING -> ProtocolClient.State.CONNECTING ->
to == ProtocolClient.State.CONNECTED || to == ProtocolClient.State.CONNECTED ||
to == ProtocolClient.State.ERROR || to == ProtocolClient.State.ERROR ||
to == ProtocolClient.State.TIMEOUT ||
to == ProtocolClient.State.DISCONNECTED to == ProtocolClient.State.DISCONNECTED
ProtocolClient.State.CONNECTED -> ProtocolClient.State.CONNECTED ->
to == ProtocolClient.State.TIMEOUT || to == ProtocolClient.State.TIMEOUT ||
@@ -172,7 +172,9 @@ fun MainScreen(
onStatusClick = { statusPanelVisible = true }, onStatusClick = { statusPanelVisible = true },
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onConnectToggle = { onConnectToggle = {
if (connectionState == ProtocolClient.State.CONNECTED) { if (connectionState == ProtocolClient.State.CONNECTED ||
connectionState == ProtocolClient.State.CONNECTING
) {
viewModel.disconnect() viewModel.disconnect()
} else { } else {
viewModel.connect() viewModel.connect()