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

image

做蓝牙连接的人都知道,现实世界里的连接过程从来不是一条直线。蓝牙没开、权限没给、眼镜被其他客户端抢占了、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.postDelayedremoveCallbacks 在某些 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

下一篇聊聊重连机制——怎么让用户第一次配对了以后,以后戴上眼镜就能自动连上,以及在各种失效场景下怎么优雅降级。

posted @ 2026-06-02 13:51  byte_2  阅读(1)  评论(0)    收藏  举报