Rokid AI 眼镜远程协作应用"一线互联"开发实践:连接状态机与失败模型设计

做蓝牙连接的人都知道,现实世界里的连接过程从来不是一条直线。蓝牙没开、权限没给、眼镜被其他客户端抢占了、SDK 回调没来、缓存凭据过期了——每一种意外都可能出现。
问题是,你怎么把这些意外变成一个用户能理解、能操作的东西,而不是一个"连接失败,请稍后重试"的黑洞。
先把过程拆清楚
我们定义了一个有限的连接阶段枚举,每个阶段对应连接过程中的一个明确步骤:
enum class RokidGlassesBtStage {
SCAN, // BLE 扫描阶段
FRESH_INIT, // 首次连接初始化
RESOLVE_ENDPOINT, // CXR endpoint 解析
CONNECT, // Socket 连接
RECONNECT, // 重连
}
连接过程本质上就是一个状态机:每个操作推进到下一个阶段,每个阶段都可能成功(进入下一阶段)或者失败(进入失败态)。正常链路是这样的:
Idle → Scanning → ResolvingEndpoint → Connecting → Connected
重连链路短一些,因为跳过了扫描和 endpoint 解析:
Idle or Disconnected → Connecting → Connected
失败也是状态,不是异常
这里面关键的设计抉择是:失败不是异常,是状态机的合法状态。所以 Failed 和 Idle、Connected 一样,都是 RokidGlassesBtState 的一个子类型:
sealed interface RokidGlassesBtState {
data object Idle : RokidGlassesBtState
data object Scanning : RokidGlassesBtState
data object ResolvingEndpoint : RokidGlassesBtState
data object Connecting : RokidGlassesBtState
data object Connected : RokidGlassesBtState
data object Disconnected : RokidGlassesBtState
data class Failed(
val stage: RokidGlassesBtStage,
val code: RokidGlassesBtErrorCode,
val detail: String? = null
) : RokidGlassesBtState
}
为什么失败要单独带 stage 和 code?因为"扫描失败"和"连接失败",用户要做的补救动作完全不同。前者可能需要开蓝牙,后者可能需要把眼镜靠近手机重新扫。
错误码也做了结构化定义:
enum class RokidGlassesBtErrorCode {
MISSING_PERMISSION, // 未授予蓝牙权限
BLUETOOTH_UNAVAILABLE, // 蓝牙未开
BLE_SCANNER_UNAVAILABLE, // BLE 扫描器不可用
SCAN_FAILED, // 系统 BLE 扫描失败
INVALID_BLUETOOTH_DEVICE, // 无效设备
MISSING_ENDPOINT, // 无可用重连凭据
INVALID_ENDPOINT, // 凭据格式异常
AUTH_NOT_READY, // 凭证缺失
SOCKET_CONNECT_FAILED, // Socket 失效
INACTIVE_CONNECTED, // 被抢占
SDK_EXCEPTION, // SDK 异常
TIMEOUT // 超时
}
每个错误码对应一个明确的用户操作指引,不会出现一个通用的"连接失败"弹窗然后用户除了重试不知道还能干嘛。
连接超时怎么办
工业场景比实验室多了一个常见问题:连接超时。眼镜可能离得有点远,或者 SDK 内部在等某种握手信号迟迟不来。我们给连接阶段加了一个超时计时:
private fun scheduleConnectTimeout(stage: RokidGlassesBtStage): Int {
connectGeneration += 1
val generation = connectGeneration
mainHandler.postDelayed({
if (connectGeneration == generation) {
val current = _state.value
if (current == RokidGlassesBtState.ResolvingEndpoint ||
current == RokidGlassesBtState.Connecting) {
_state.value = RokidGlassesBtStateReducer.failed(
stage = stage,
code = RokidGlassesBtErrorCode.TIMEOUT
)
}
}
}, DEFAULT_CONNECT_TIMEOUT_MS)
return generation
}
这里有个细节:connectGeneration 是一个递增计数器,每次发起新连接时 +1。超时回调触发后会检查当前的 generation 是否和发起时一致——如果不一致,说明已经有新的连接请求发起或者连接已经完成,这个过期的超时就直接丢弃。用递增计数器而不是 cancel 操作来管理超时,是因为 Handler.postDelayed 的 removeCallbacks 在某些 Android 版本上有坑,计数器更可靠。
这些在 UI 上长什么样
上层的 Kotlin 协程收集状态变化,根据 Failed 的 code 决定给用户看什么:
manager.state.collect { state ->
when (state) {
RokidGlassesBtState.Connected -> showConnected()
is RokidGlassesBtState.Failed -> {
val msg = when (state.code) {
RokidGlassesBtErrorCode.MISSING_PERMISSION -> "请授予蓝牙权限"
RokidGlassesBtErrorCode.BLUETOOTH_UNAVAILABLE -> "请打开蓝牙"
RokidGlassesBtErrorCode.SOCKET_CONNECT_FAILED -> "连接凭据失效,请重新扫描"
RokidGlassesBtErrorCode.TIMEOUT -> "连接超时,请确认眼镜在附近"
RokidGlassesBtErrorCode.INACTIVE_CONNECTED -> "眼镜已被其他设备连接"
else -> "连接失败:${state.code}"
}
showError(msg)
// 如果是凭据失效,引导重新扫描
if (state.code == RokidGlassesBtErrorCode.SOCKET_CONNECT_FAILED) {
promptRescan()
}
}
}
}
一个状态机 + 一个结构化的错误模型,底层库干净,上层 UI 也干净。用户看到的永远是"该怎么办",而不是"出了什么问题"——这个区别在工业现场很关键。
相关仓库:github.com/jumuxyz/jlink-ai-rokid-glasses-bt · Apache 2.0
下一篇聊聊重连机制——怎么让用户第一次配对了以后,以后戴上眼镜就能自动连上,以及在各种失效场景下怎么优雅降级。
浙公网安备 33010602011771号