4. 离线状态机设计:三种状态(Online、Offline、Recovering)转换、事件驱动、Flow 状态暴露

离线状态机是离线优先应用的核心中枢。它需要清晰定义三种稳定状态——Online、Offline、Recovering,以及驱动状态切换的离散事件。在 Kotlin 中,我们使用 sealed class 表示状态,使用 sealed interface 表示事件,并通过 StateFlow 将当前状态暴露给 UI 层和业务层。

设计原则:状态机必须满足幂等性——同一事件在同一状态下触发多次,行为和结果必须一致。避免在状态机外直接修改状态变量。

4.1 状态定义:sealed class + 伴生数据

三种状态各自携带必要的运行时数据,便于下游做差异处理:

sealed class ConnectionState {
    data object Online : ConnectionState()
    data class Offline(
        val lastOnlineTimestamp: Long,
        val reason: String = ""
    ) : ConnectionState()
    data class Recovering(
        val retryCount: Int,
        val currentAttempt: Int,
        val nextRetryDelay: Long
    ) : ConnectionState()
}
状态数据字段典型用途
Online无UI 显示正常图标,允许所有网络请求
OfflinelastOnlineTimestamp, reason显示离线提示、记录降级时长、展示错误原因
RecoveringretryCount, currentAttempt, nextRetryDelay显示进度条、倒计时、重试次数

4.2 事件驱动:sealed interface 与发送通道

所有改变状态的行为都通过事件表达,由状态机内部统一处理。事件包括网络恢复、重试成功、重试失败、主动下线等:

sealed interface ConnectionEvent {
    data object NetworkAvailable : ConnectionEvent
    data object RetrySucceeded : ConnectionEvent
    data class RetryFailed(val attempt: Int, val delay: Long) : ConnectionEvent
    data object ManualGoOffline : ConnectionEvent
    data object AppForegrounded : ConnectionEvent
}

事件发送通道使用 Channel 以保证背压安全,避免事件丢失:

private val eventChannel = Channel<ConnectionEvent>(Channel.BUFFERED)
fun sendEvent(event: ConnectionEvent) {
    eventChannel.trySend(event)
}

4.3 状态转换逻辑:reduce 函数

状态机核心是一个纯函数 reduce,输入当前状态和事件,输出新状态。不允许副作用(如网络请求)直接写在这里:

fun reduceState(current: ConnectionState, event: ConnectionEvent): ConnectionState {
    return when (current) {
        is ConnectionState.Online -> when (event) {
            is ConnectionEvent.ManualGoOffline -> ConnectionState.Offline(
                lastOnlineTimestamp = System.currentTimeMillis(),
                reason = "user_manual"
            )
            else -> current
        }
        is ConnectionState.Offline -> when (event) {
            is ConnectionEvent.NetworkAvailable -> ConnectionState.Recovering(
                retryCount = 3,
                currentAttempt = 1,
                nextRetryDelay = 1000L
            )
            is ConnectionEvent.RetrySucceeded -> ConnectionState.Online
            is ConnectionEvent.RetryFailed -> {
                val nextDelay = current.nextRetryDelay * 2
                if (current.currentAttempt >= current.retryCount) {
                    ConnectionState.Offline(
                        lastOnlineTimestamp = current.lastOnlineTimestamp,
                        reason = "max_retries_exceeded"
                    )
                } else {
                    ConnectionState.Recovering(
                        retryCount = current.retryCount,
                        currentAttempt = current.currentAttempt + 1,
                        nextRetryDelay = nextDelay
                    )
                }
            }
            else -> current
        }
        is ConnectionState.Recovering -> when (event) {
            is ConnectionEvent.RetrySucceeded -> ConnectionState.Online
            is ConnectionEvent.RetryFailed -> {
                val nextDelay = current.nextRetryDelay * 2
                if (current.currentAttempt >= current.retryCount) {
                    ConnectionState.Offline(
                        lastOnlineTimestamp = System.currentTimeMillis(),
                        reason = "max_retries_reached"
                    )
                } else {
                    ConnectionState.Recovering(
                        retryCount = current.retryCount,
                        currentAttempt = current.currentAttempt + 1,
                        nextRetryDelay = nextDelay
                    )
                }
            }
            is ConnectionEvent.ManualGoOffline -> ConnectionState.Offline(
                lastOnlineTimestamp = System.currentTimeMillis(),
                reason = "user_cancelled_retry"
            )
            else -> current
        }
    }
}
常见错误:直接在状态类中编写转换逻辑或使用 var 可变状态。这样会导致状态变更不可追踪,且单元测试困难。务必使用纯函数 reduce。

4.4 Flow 状态暴露:StateFlow + 协程收集

状态机通过 MutableStateFlow 对外暴露不可变快照,UI 层只需 .collect 即可获得最新状态:

private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Online)
val state: StateFlow<ConnectionState> = _state.asStateFlow()

fun start(stateMachineScope: CoroutineScope) {
    stateMachineScope.launch {
        for (event in eventChannel.operator) {
            val newState = reduceState(_state.value, event)
            _state.value = newState
        }
    }
}

这种设计确保了:

  • 状态更新始终发生在同一个协程内,避免并发写入冲突。
  • UI 层通过 state.collect 自动感知状态变化,无需手动刷新。
  • 测试时可以注入模拟事件通道,验证 reduce 逻辑。
离线状态机 状态转换图 Online Offline Recovering 事件驱动说明 • NetworkAvailable : Offline → Recovering • RetrySucceeded : Recovering → Online / Offline → Online • RetryFailed : Recovering → Offline(重试耗尽)或下一轮重试 ManualGoOffline NetworkAvailable RetrySucceeded RetryFailed(max) / ManualGoOffline

4.5 调试与日志辅助

在 reduce 函数中注入日志打印,可以快速追踪状态机行为:

fun reduceStateWithLogging(current: ConnectionState, event: ConnectionEvent): ConnectionState {
    val new = reduceState(current, event)
    Log.d("StateMachine", "${current.simpleName()} + ${event::class.simpleName} -> ${new.simpleName()}")
    return new
}

输出示例(Logcat):

D/StateMachine: Online + ManualGoOffline -> Offline
D/StateMachine: Offline + NetworkAvailable -> Recovering
D/StateMachine: Recovering + RetrySucceeded -> Online
性能基线:reduce 函数必须保持 O(1) 时间复杂度,禁止在其中进行数据库查询或网络请求。状态变更频率建议控制在 100ms 以上,避免 UI 过度重绘。

4.6 单元测试示例

由于 reduce 是纯函数,测试非常直接:

@Test
fun `Offline + NetworkAvailable transitions to Recovering`() {
    val result = reduceState(ConnectionState.Offline(lastOnlineTimestamp = 0L, reason = ""),
                             ConnectionEvent.NetworkAvailable)
    assertTrue(result is ConnectionState.Recovering)
    assertEquals(1, (result as ConnectionState.Recovering).currentAttempt)
}

@Test
fun `Recovering + max retries transitions to Offline`() {
    val state = ConnectionState.Recovering(retryCount = 2, currentAttempt = 2, nextRetryDelay = 1000L)
    val result = reduceState(state, ConnectionEvent.RetryFailed(attempt = 2, delay = 2000L))
    assertTrue(result is ConnectionState.Offline)
}

4.7 常见设计陷阱

  • 状态膨胀:不要在状态类中保存 UI 相关字段(如是否显示弹窗),应交给 ViewModel 的独立 LiveData/StateFlow。
  • 事件丢失:使用 Channel 时忘记设置 BUFFERED 或 UNLIMITED,导致溢出发送挂起。推荐 Channel.BUFFERED。
  • 并发修改:不从多个协程同时写入 _state,应将所有事件处理集中在一个协程内。
  • 忽略回收:ViewModel 被回收时未调用 eventChannel.close(),导致协程泄漏。应在 onCleared 中关闭。