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 显示正常图标,允许所有网络请求 |
| Offline | lastOnlineTimestamp, reason | 显示离线提示、记录降级时长、展示错误原因 |
| Recovering | retryCount, 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 逻辑。
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中关闭。