公司动态

第五篇:开始、暂停、回充、续割,割草任务状态机如何设计?

📅 2026/7/31 12:35:39
第五篇:开始、暂停、回充、续割,割草任务状态机如何设计?
上一篇我们拆解了割草机 App 的实时状态管理。设备的完整状态通常来自两部分HTTPS 获取完整快照 MQTTS / WebSocket 接收增量事件移动端再通过 Repository、Reducer 和 StateFlow把这些数据合并成统一的设备状态。但设备状态只是基础。当用户真正发起一次割草任务后业务会变得更加复杂。用户看到的可能只是开始割草 暂停 继续 返回充电 结束任务但设备真正执行时一次割草任务可能经历任务创建 ↓ 设备准备 ↓ 离开充电桩 ↓ 前往工作区域 ↓ 开始割草 ↓ 电量不足 ↓ 返回充电 ↓ 充电完成 ↓ 继续割草 ↓ 任务完成过程中还可能发生用户暂停用户主动结束设备被抬起刀盘堵转RTK 定位丢失设备越界下雨电池温度异常设备突然离线App 被关闭后重新打开。因此割草任务不能只用一个isWorking表示。它本质上是一个会持续很长时间、可能被暂停、打断、恢复和重建的业务状态机。这一篇我们就从移动端角度拆解割草任务状态机应该如何设计。一、为什么割草任务不是“开始”和“完成”普通接口型业务中一个操作可能很快完成。例如创建一条记录提交请求 ↓ 服务器保存 ↓ 返回成功但割草任务不同。一次任务可能持续几十分钟甚至几个小时。在这段时间里设备会不断变化准备 移动 割草 暂停 回充 充电 续割 完成任务还可能跨越App 页面切换App 进入后台App 进程被系统回收手机网络断开设备短暂离线用户重新登录多个家庭成员同时查看设备。因此割草任务不能被理解成一次接口调用。更准确地说“开始割草”只是创建任务和触发设备执行的入口真正的任务状态需要由设备和云端持续维护。二、设备状态和任务状态有什么区别设计状态机之前必须先区分两个概念设备状态 任务状态它们有关联但不是同一回事。1. 设备状态设备状态描述割草机当前正在做什么。例如空闲 准备中 离开充电桩 移动中 割草中 暂停 回充中 充电中 故障 升级中可以定义sealed interface DeviceWorkState { data object Unknown : DeviceWorkState data object Idle : DeviceWorkState data object Preparing : DeviceWorkState data object LeavingDock : DeviceWorkState data object Moving : DeviceWorkState data object Mowing : DeviceWorkState data object Paused : DeviceWorkState data object Returning : DeviceWorkState data object Charging : DeviceWorkState data object Updating : DeviceWorkState data class Fault( val warning: DeviceWarning ) : DeviceWorkState }2. 任务状态任务状态描述一次割草任务进行到了哪个业务阶段。例如待执行 启动中 执行中 暂停中 回充中 充电等待 等待续割 已完成 已取消 执行失败可以定义sealed interface MowingTaskState { data object Pending : MowingTaskState data object Starting : MowingTaskState data object Running : MowingTaskState data object Paused : MowingTaskState data object ReturningForCharge : MowingTaskState data object Charging : MowingTaskState data object WaitingToResume : MowingTaskState data object Completing : MowingTaskState data object Completed : MowingTaskState data object Cancelling : MowingTaskState data object Cancelled : MowingTaskState data class Failed( val reason: TaskFailure ) : MowingTaskState }3. 为什么不能只保留设备状态假设设备现在正在充电。DeviceWorkState Charging但它背后可能存在两种完全不同的任务含义。场景一设备平时停在充电桩设备状态 Charging 当前任务 不存在场景二执行割草任务时电量不足正在补电设备状态 Charging 任务状态 Charging 任务完成进度 63%两种场景的页面展示不同。场景一可以显示设备正在充电 可以在电量充足后开始新任务场景二应该显示任务已完成 63% 设备正在充电 充电完成后将继续割草因此设备正在充电 ≠ 当前任务已经结束设备状态用于描述机器当前动作任务状态用于描述一项长期业务的生命周期。三、一次完整的割草任务会经历哪些状态一条比较完整的自动割草任务可以拆成下面的状态。Pending ↓ Starting ↓ Running ↓ Completing ↓ Completed但真实业务通常更复杂Pending ↓ Starting ↓ Preparing ↓ LeavingDock ↓ MovingToZone ↓ Mowing ↓ ReturningForCharge ↓ Charging ↓ WaitingToResume ↓ MovingToZone ↓ Mowing ↓ Completing ↓ Completed其中任何阶段都可能进入Paused Failed Cancelled因此完整状态图可以理解为┌──────────────┐ │ Failed │ └──────────────┘ ▲ │ Pending → Starting → Running ───┼──→ Completing → Completed │ │ ├──→ Paused │ │ │ └──→ Running │ └──→ ReturningForCharge ↓ Charging ↓ WaitingToResume ↓ Running 任意可取消状态 ↓ Cancelling ↓ Cancelled四、为什么多个 Boolean 无法描述任务流程业务初期可能会这样设计data class TaskState( val isStarted: Boolean, val isRunning: Boolean, val isPaused: Boolean, val isCharging: Boolean, val isReturning: Boolean, val isCompleted: Boolean, val hasError: Boolean )字段看起来都能理解。但它们可以组合出大量冲突状态。例如isRunning true isPaused true设备到底是在运行还是已经暂停又例如isCompleted true isCharging true到底是任务执行完成后正常充电还是任务中途补电还有hasError true isRunning true isReturning true错误发生后设备是否仍在回充任务是否已经失败多个 Boolean 最大的问题是它们表达的是独立条件但任务状态本身具有互斥性和转换顺序。更合理的方式是使用一个主要状态表达任务所处阶段。data class MowingTask( val taskId: String, val deviceId: String, val mapId: String, val zoneIds: ListString, val state: MowingTaskState, val progress: TaskProgress, val createdAt: Long, val startedAt: Long?, val completedAt: Long?, val version: Long )同一时刻state只能处于一个明确状态。五、状态机不仅要定义状态还要定义转换只定义枚举并不代表已经设计好了状态机。状态机还需要明确当前状态 收到什么事件 满足什么条件 转换到什么状态可以理解为Current State Event Guard Next State例如Idle StartCommandAccepted 设备在线、地图有效、无严重故障 Starting一个简单的状态转换表当前状态事件下一个状态Pending启动指令已接受StartingStarting设备开始准备RunningRunning用户暂停成功PausedPaused用户继续成功RunningRunning电量不足ReturningForChargeReturningForCharge到达充电桩ChargingCharging达到续割电量WaitingToResumeWaitingToResume续割启动RunningRunning割草区域完成CompletingCompleting任务数据保存成功CompletedRunning用户取消任务CancellingCancelling设备停止成功Cancelled任意执行状态不可恢复故障Failed非法转换应该被拒绝例如任务已经完成Completed此时再收到PauseRequested不应该转换到暂停状态。又例如任务还没有开始Pending此时收到ResumeRequested也是非法操作。因此状态机需要明确允许的转换。fun MowingTaskState.canTransitionTo( target: MowingTaskState ): Boolean { return when (this) { MowingTaskState.Pending - { target is MowingTaskState.Starting || target is MowingTaskState.Cancelled } MowingTaskState.Starting - { target is MowingTaskState.Running || target is MowingTaskState.Failed || target is MowingTaskState.Cancelled } MowingTaskState.Running - { target is MowingTaskState.Paused || target is MowingTaskState.ReturningForCharge || target is MowingTaskState.Completing || target is MowingTaskState.Cancelling || target is MowingTaskState.Failed } MowingTaskState.Paused - { target is MowingTaskState.Running || target is MowingTaskState.Cancelling || target is MowingTaskState.Failed } MowingTaskState.ReturningForCharge - { target is MowingTaskState.Charging || target is MowingTaskState.Failed } MowingTaskState.Charging - { target is MowingTaskState.WaitingToResume || target is MowingTaskState.Cancelling || target is MowingTaskState.Failed } MowingTaskState.WaitingToResume - { target is MowingTaskState.Running || target is MowingTaskState.Cancelling || target is MowingTaskState.Failed } MowingTaskState.Completing - { target is MowingTaskState.Completed || target is MowingTaskState.Failed } MowingTaskState.Cancelling - { target is MowingTaskState.Cancelled || target is MowingTaskState.Failed } MowingTaskState.Completed, MowingTaskState.Cancelled, is MowingTaskState.Failed - false } }六、用户点击开始割草后任务状态如何变化结合前面文章中的通信架构用户点击开始割草后并不能立即进入Running。完整过程应该是用户点击开始 ↓ App 本地校验 ↓ HTTPS 提交指令 ↓ 云端接受指令 ↓ 任务进入 Starting ↓ 云端通过 MQTTS 向设备下发 ↓ 设备开始准备 ↓ 设备上报 Preparing ↓ 设备离开充电桩 ↓ 设备前往工作区 ↓ 设备真正开始割草 ↓ 任务进入 Running页面状态可以是准备开始 ↓ 正在向设备发送任务 ↓ 设备正在准备 ↓ 设备正在前往工作区域 ↓ 正在割草而不是点击开始 ↓ 立即显示割草中指令状态和任务状态仍然要分开发送开始指令时可能同时存在CommandState WaitingDevice TaskState Starting DeviceState Idle设备开始准备后CommandState Success TaskState Starting DeviceState Preparing设备真正开始割草后TaskState Running DeviceState Mowing因此页面可能需要组合三类状态data class MowingControlUiState( val taskState: MowingTaskState, val deviceState: DeviceWorkState, val pendingCommand: PendingCommand?, val availableActions: SetMowingAction )七、暂停任务应该如何设计暂停看起来是一个简单操作但需要区分用户发起暂停 设备正在暂停 设备已经暂停完整流程Running ↓ 用户点击暂停 PauseCommandSending ↓ 云端接受指令 ↓ WaitingDevice ↓ 设备降低速度并停止刀盘 ↓ 设备上报 Paused ↓ 任务进入 Paused在设备真正上报暂停前任务不应该立即变为Paused。可以增加过渡状态sealed interface MowingTaskState { data object Running : MowingTaskState data object Pausing : MowingTaskState data object Paused : MowingTaskState data object Resuming : MowingTaskState // 其他状态省略 }状态转换Running ↓ PauseRequested Pausing ↓ DevicePaused Paused如果指令失败Pausing ↓ PauseFailed Running页面可以显示Pausing 正在暂停设备 Paused 任务已暂停八、暂停和停止任务有什么区别用户很容易把暂停和停止理解成同一个操作但业务含义完全不同。暂停暂停意味着任务仍然存在 当前进度保留 稍后可以继续例如任务进度42% 任务状态Paused 已完成区域保留 剩余区域保留用户可以继续任务Paused ↓ Resuming ↓ Running停止或取消停止意味着当前任务结束 不会自动继续 剩余区域不再执行状态流程Running / Paused ↓ Cancelling ↓ Cancelled取消后即使设备返回充电桩也不能把任务恢复成运行状态。因此暂停 任务暂时中断 取消 任务生命周期结束移动端在文案、按钮和确认弹窗上必须明确区分。例如停止任务时可以提示结束后本次任务将不再继续 未完成区域需要重新创建任务。九、返回充电并不一定代表任务结束割草机执行任务时可能因为电量不足自动回充。Running ↓ BatteryLow ReturningForCharge ↓ DockReached Charging此时任务通常没有结束。任务仍然保留当前任务 ID已完成区域剩余区域当前进度续割参数上一次工作位置。因此设备状态 Charging 任务状态 Charging 任务完成 否页面可以显示任务已完成 63% 设备正在充电 充电达到续割条件后将自动继续主动回充和低电量回充也可能不同用户在割草过程中点击“返回充电”业务可能有两种定义。定义一回充后任务继续Running ↓ 用户请求回充 ReturningForCharge ↓ Charging ↓ WaitingToResume ↓ Running定义二回充并结束当前任务Running ↓ 用户请求结束并回充 Cancelling ↓ Returning ↓ Cancelled因此产品层需要明确区分按钮返回充电到底表示回去充电后继续还是结束任务并返回充电桩移动端不能只根据按钮文字自行猜测业务含义。十、充电完成后如何自动续割续割是割草任务状态机中非常关键的一段。完整流程可能是Running ↓ 电量不足 ReturningForCharge ↓ Charging ↓ 电量达到续割阈值 WaitingToResume ↓ 设备离开充电桩 ↓ 返回未完成区域 ↓ Running这里需要明确几个问题。1. 什么时候允许续割续割条件可能包括电量达到阈值当前仍存在未完成区域任务没有被用户取消当前时间仍在允许作业时段天气条件允许RTK 状态正常地图版本没有变化设备不存在严重故障。可以设计 Guarddata class ResumeGuard( val batteryEnough: Boolean, val hasRemainingArea: Boolean, val taskNotCancelled: Boolean, val withinWorkingTime: Boolean, val weatherAllowed: Boolean, val rtkAvailable: Boolean, val mapVersionValid: Boolean, val noCriticalFault: Boolean ) { fun canResume(): Boolean { return batteryEnough hasRemainingArea taskNotCancelled withinWorkingTime weatherAllowed rtkAvailable mapVersionValid noCriticalFault } }2. 充满电不一定立即续割例如设备在晚上充满但用户设置只允许白天割草。这时可以进入WaitingToResume页面显示设备已充电完成 等待下一个允许作业时间继续任务3. App 是否需要负责触发续割通常不建议让 App 成为自动续割的唯一触发者。因为 App 可能被关闭进入后台网络中断用户更换手机。自动续割更适合由设备 或 云端任务系统负责。App 主要负责展示续割状态接收续割结果允许用户取消自动续割在必要时提供人工确认。否则一旦 App 不在线任务就无法继续。十一、异常中断应该如何进入状态机割草任务可能被很多异常打断。例如设备被抬起 刀盘堵转 车轮堵转 RTK 定位丢失 设备越界 电池温度异常 设备离线 下雨 地图异常这些异常不能全部简单转换成Failed。因为有些异常可以恢复有些异常无法恢复。1. 可恢复中断例如短暂 RTK 信号弱临时避障短暂网络中断雨水传感器触发用户抬起设备后重新放回轻微轮子打滑。任务可以进入Interrupted等待条件恢复Running ↓ Interrupted ↓ 条件恢复 Running可以设计data class Interrupted( val reason: InterruptionReason, val recoverable: Boolean, val occurredAt: Long ) : MowingTaskState2. 不可恢复故障例如刀盘严重故障地图数据损坏设备关键传感器异常电池系统故障用户明确终止任务任务参数失效。这类情况可以进入Failed任务生命周期结束需要重新创建任务。3. 任务中断和任务失败的区别Interrupted 任务暂时不能继续但仍然保留恢复可能 Failed 本次任务已经无法继续页面展示也应该不同。Interrupted任务已暂停 RTK 信号较弱恢复后将继续执行Failed任务执行失败 请处理刀盘故障后重新创建任务十二、设备离线时任务应该变成什么状态设备离线是非常特殊的情况。当 App 或云端无法连接设备时我们只能知道暂时无法获得设备最新状态但不能立刻断定任务已经失败设备可能仍在本地继续执行也可能已经停止。因此不建议直接将任务改为Failed。更准确的表达是任务状态 最后已知为 Running 状态新鲜度 STALE 连接状态 DeviceOffline / Unknown可以设计data class MowingTaskSnapshot( val task: MowingTask, val freshness: StateFreshness, val lastUpdatedAt: Long )页面显示上次状态正在割草 设备当前离线任务状态可能不是最新如果设备离线超过一定时间并由云端任务系统判定任务失败才正式进入Failed。也就是说设备离线 ≠ 任务立即失败十三、状态机中的事件来自哪里任务状态不是由页面按钮直接修改而是由不同业务事件驱动。事件可能来自四个方向。1. 用户操作事件例如StartRequested PauseRequested ResumeRequested CancelRequested ReturnToDockRequested2. 云端指令事件例如CommandAccepted CommandRejected CommandTimeout3. 设备状态事件例如DevicePreparing DeviceMowing DevicePaused DeviceReturning DeviceCharging DeviceFault4. 系统条件事件例如BatteryLow BatteryEnough RtkLost RtkRecovered RainDetected WorkingTimeReached可以定义sealed interface MowingTaskEvent { data object StartRequested : MowingTaskEvent data object StartAccepted : MowingTaskEvent data class StartRejected( val reason: String ) : MowingTaskEvent data object DeviceStartedMowing : MowingTaskEvent data object PauseRequested : MowingTaskEvent data object DevicePaused : MowingTaskEvent data object ResumeRequested : MowingTaskEvent data object DeviceResumed : MowingTaskEvent data object BatteryLow : MowingTaskEvent data object DockReached : MowingTaskEvent data object BatteryEnough : MowingTaskEvent data object TaskAreaCompleted : MowingTaskEvent data object CancelRequested : MowingTaskEvent data object DeviceStopped : MowingTaskEvent data class FaultOccurred( val fault: TaskFailure ) : MowingTaskEvent }十四、使用 Reducer 统一处理任务状态变化任务事件不应该在不同页面和回调中随意修改状态。可以建立统一 Reducerobject MowingTaskReducer { fun reduce( current: MowingTask, event: MowingTaskEvent ): MowingTask { val nextState when ( val state current.state ) { MowingTaskState.Pending - { when (event) { MowingTaskEvent.StartRequested - MowingTaskState.Starting MowingTaskEvent.CancelRequested - MowingTaskState.Cancelled else - state } } MowingTaskState.Starting - { when (event) { MowingTaskEvent.DeviceStartedMowing - MowingTaskState.Running is MowingTaskEvent.StartRejected - MowingTaskState.Failed( TaskFailure.StartRejected( event.reason ) ) else - state } } MowingTaskState.Running - { when (event) { MowingTaskEvent.PauseRequested - MowingTaskState.Pausing MowingTaskEvent.BatteryLow - MowingTaskState.ReturningForCharge MowingTaskEvent.TaskAreaCompleted - MowingTaskState.Completing MowingTaskEvent.CancelRequested - MowingTaskState.Cancelling is MowingTaskEvent.FaultOccurred - MowingTaskState.Failed( event.fault ) else - state } } MowingTaskState.Pausing - { when (event) { MowingTaskEvent.DevicePaused - MowingTaskState.Paused else - state } } MowingTaskState.Paused - { when (event) { MowingTaskEvent.ResumeRequested - MowingTaskState.Resuming MowingTaskEvent.CancelRequested - MowingTaskState.Cancelling else - state } } MowingTaskState.Resuming - { when (event) { MowingTaskEvent.DeviceResumed - MowingTaskState.Running else - state } } MowingTaskState.ReturningForCharge - { when (event) { MowingTaskEvent.DockReached - MowingTaskState.Charging else - state } } MowingTaskState.Charging - { when (event) { MowingTaskEvent.BatteryEnough - MowingTaskState.WaitingToResume else - state } } MowingTaskState.WaitingToResume - { when (event) { MowingTaskEvent.DeviceResumed - MowingTaskState.Running MowingTaskEvent.CancelRequested - MowingTaskState.Cancelling else - state } } MowingTaskState.Completing - { when (event) { MowingTaskEvent.DeviceStopped - MowingTaskState.Completed else - state } } MowingTaskState.Cancelling - { when (event) { MowingTaskEvent.DeviceStopped - MowingTaskState.Cancelled else - state } } MowingTaskState.Completed, MowingTaskState.Cancelled, is MowingTaskState.Failed - state } return current.copy( state nextState ) } }这样所有状态变化都可以统一审查和测试。十五、页面按钮应该由状态机统一决定页面不应该自己到处判断if ( isOnline !isCharging !isReturning !hasError ) { showStartButton true }更合理的方式是由任务状态机统一返回当前可执行操作。enum class MowingAction { START, PAUSE, RESUME, RETURN_TO_DOCK, CANCEL, RETRY, VIEW_FAULT }fun MowingTaskState.availableActions(): SetMowingAction { return when (this) { MowingTaskState.Pending - { setOf( MowingAction.START, MowingAction.CANCEL ) } MowingTaskState.Starting - { setOf(MowingAction.CANCEL) } MowingTaskState.Running - { setOf( MowingAction.PAUSE, MowingAction.RETURN_TO_DOCK, MowingAction.CANCEL ) } MowingTaskState.Pausing, MowingTaskState.Resuming, MowingTaskState.Cancelling, MowingTaskState.Completing - { emptySet() } MowingTaskState.Paused - { setOf( MowingAction.RESUME, MowingAction.RETURN_TO_DOCK, MowingAction.CANCEL ) } MowingTaskState.ReturningForCharge, MowingTaskState.Charging, MowingTaskState.WaitingToResume - { setOf(MowingAction.CANCEL) } MowingTaskState.Completed - { emptySet() } MowingTaskState.Cancelled - { setOf(MowingAction.START) } is MowingTaskState.Failed - { setOf( MowingAction.RETRY, MowingAction.VIEW_FAULT ) } } }这样首页、地图页和任务详情页使用的是同一套按钮规则。十六、任务进度应该如何建模任务状态只说明当前阶段还需要独立的任务进度模型。例如data class TaskProgress( val completedArea: Double, val totalArea: Double, val progressPercent: Float, val remainingArea: Double, val elapsedTimeSeconds: Long, val estimatedRemainingSeconds: Long?, val completedZoneIds: SetString, val currentZoneId: String? )需要注意任务状态变化 和 任务进度变化不是同一件事。设备进入充电状态时TaskState Charging Progress 63%任务暂停时TaskState Paused Progress 42%任务完成时TaskState Completed Progress 100%进度最好由设备或云端任务系统计算。App 不应该仅根据时间自行估算任务是否完成。十七、多区域任务应该如何设计一次任务可能包含多个割草区域前院 后院 侧边草坪任务执行顺序可能是前院 ↓ 连接通道 ↓ 后院 ↓ 连接通道 ↓ 侧边草坪可以定义data class ZoneTaskProgress( val zoneId: String, val state: ZoneTaskState, val progress: Float )enum class ZoneTaskState { PENDING, MOVING_TO_ZONE, MOWING, COMPLETED, SKIPPED, FAILED }整个任务状态仍然可能是Running但当前区域状态可能是前院Completed 后院Mowing 侧边草坪Pending因此多区域任务通常需要两层状态任务级状态 区域级状态不能把每个区域直接当成完全独立的任务否则回充、暂停和整体取消会变得难以协调。十八、App 重启后如何恢复正在执行的任务割草任务可能持续很长时间。用户关闭 App 后重新打开ViewModel 和内存状态已经丢失。恢复流程应该是App 启动 ↓ 恢复当前用户和设备 ↓ 查询是否存在活动任务 ↓ HTTPS 获取任务快照 ↓ HTTPS 获取设备快照 ↓ 建立 MQTTS / WebSocket ↓ 恢复实时订阅 ↓ 合并最新事件 ↓ 重新构建任务页面服务端最好提供当前活动任务接口例如interface MowingTaskApi { GET(devices/{deviceId}/active-task) suspend fun getActiveTask( Path(deviceId) deviceId: String ): MowingTaskDto? }本地可以缓存data class CachedActiveTask( val taskId: String, val deviceId: String, val lastKnownState: String, val progress: Float, val updatedAt: Long )但本地缓存只能用于快速展示。页面可以先显示上次任务状态正在割草 正在获取设备最新状态……随后使用云端快照进行校准。十九、任务状态和设备状态冲突时相信谁真实项目中可能出现云端任务状态 Running 设备状态 Charging这不一定冲突因为设备可能处于任务中途补电。但也可能出现真正冲突任务状态 Running 设备状态 Idle 当前任务 ID 为空这可能说明任务状态同步延迟设备已经完成但云端未更新设备重启丢失任务实时消息乱序App 使用了旧快照服务端任务状态异常。移动端不应该自行“猜测”并强行修正云端任务。更合理的做法是发现状态冲突 ↓ 查询当前活动任务 ↓ 查询设备最新快照 ↓ 根据 taskId、version 和时间戳校准 ↓ 仍然冲突则展示异常状态可以增加enum class TaskConsistencyState { CONSISTENT, SYNCING, CONFLICTED, UNKNOWN }页面提示正在同步设备任务状态……而不是在不同状态之间反复跳动。二十、状态机应该运行在 App、云端还是设备严格来说割草任务状态机不会只存在于一个地方。它通常分布在三端。1. 设备状态机负责真实硬件动作启动电机 离开充电桩 开始割草 停止刀盘 返回充电 处理故障设备是执行事实的最终来源。2. 云端任务状态机负责业务任务创建任务 记录任务阶段 保存任务进度 跨端同步 处理预约任务 管理自动续割 生成历史记录云端负责长期任务生命周期。3. App 展示状态机负责展示当前阶段 限制可用按钮 管理待处理指令 处理断线恢复 显示状态是否过期App 不应该独立决定设备已经完成某个动作而是根据云端和设备事件建立展示状态。可以理解为设备 执行状态机 云端 业务任务状态机 App 交互与展示状态机三者状态名称可以相似但职责不同。二十一、状态机需要版本号和时间戳任务会通过HTTPS 快照MQTTSWebSocket本地缓存在不同数据源之间同步。因此任务状态最好携带taskId stateVersion sequence updatedAt deviceTimestamp serverTimestamp例如data class VersionedMowingTask( val task: MowingTask, val version: Long, val updatedAt: Long )收到实时事件时fun shouldApply( currentVersion: Long, eventVersion: Long ): Boolean { return eventVersion currentVersion }否则可能出现先收到 Running 后收到旧的 Starting页面错误地从割草中退回启动中。二十二、状态机应该如何测试状态机非常适合单元测试。因为它的本质是给定当前状态 输入一个事件 得到确定的新状态例如Test fun running task enters pausing when pause requested() { val task createTask( state MowingTaskState.Running ) val result MowingTaskReducer.reduce( task, MowingTaskEvent.PauseRequested ) assertEquals( MowingTaskState.Pausing, result.state ) }测试低电量回充Test fun running task returns for charge when battery low() { val task createTask( state MowingTaskState.Running ) val result MowingTaskReducer.reduce( task, MowingTaskEvent.BatteryLow ) assertEquals( MowingTaskState.ReturningForCharge, result.state ) }测试非法事件Test fun completed task ignores pause request() { val task createTask( state MowingTaskState.Completed ) val result MowingTaskReducer.reduce( task, MowingTaskEvent.PauseRequested ) assertEquals( MowingTaskState.Completed, result.state ) }需要覆盖的场景包括正常开始启动失败暂停和继续低电量回充充电后续割用户取消可恢复异常不可恢复故障重复消息乱序消息App 重启恢复已完成任务收到旧事件。二十三、设计任务状态机时常见的错误1. 点击按钮后直接修改任务状态用户点击暂停不代表设备已经暂停。2. 设备状态和任务状态使用同一个枚举设备充电不一定代表任务结束。3. 使用多个 Boolean 拼接状态容易出现运行、暂停、回充和充电同时为真的情况。4. 只定义状态不定义允许的转换任何状态都能跳到任何状态状态机失去意义。5. 没有过渡状态缺少Starting、Pausing、Cancelling页面无法表达设备正在执行操作。6. 设备离线就立即把任务标记失败离线只代表暂时无法确认设备状态。7. 充电状态直接当作任务完成任务可能只是中途补电充电后还要续割。8. 自动续割依赖 App 在线App 被关闭后任务就无法继续设计不可靠。9. App 自己计算最终任务状态任务的最终事实应该来自设备和云端。10. 没有 taskId 和 version无法区分旧任务消息、重复事件和乱序状态。二十四、推荐的任务模块架构任务模块可以拆成feature-mowing ├── presentation │ ├── MowingViewModel │ ├── MowingUiState │ └── MowingUiEvent │ ├── domain │ ├── MowingTask │ ├── MowingTaskState │ ├── MowingTaskEvent │ ├── MowingTaskReducer │ ├── StartMowingUseCase │ ├── PauseMowingUseCase │ ├── ResumeMowingUseCase │ └── CancelMowingUseCase │ └── data ├── MowingTaskRepository ├── MowingRemoteDataSource ├── MowingRealtimeDataSource └── MowingLocalDataSource完整数据流用户操作 ↓ ViewModel ↓ UseCase ↓ Repository ↓ HTTPS 提交控制指令 ↓ 设备执行 ↓ MQTTS / WebSocket 返回事件 ↓ RealtimeDataSource ↓ MowingTaskEvent ↓ MowingTaskReducer ↓ MowingTaskState ↓ StateFlow ↓ UI二十五、割草任务状态机的核心原则将前面的内容整理后可以得到几个核心原则。第一用户点击按钮 ≠ 设备已经执行完成第二设备状态 ≠ 任务状态第三设备正在充电 ≠ 任务已经完成第四设备离线 ≠ 任务立即失败第五暂停任务 ≠ 取消任务第六状态机不仅要定义状态 还要定义事件、条件和允许的转换最终移动端应该根据设备和云端返回的事实驱动任务状态变化而不是根据用户点击直接推测任务结果。总结割草任务不是一次普通接口调用而是一个可能持续很长时间、跨越多个设备状态并支持中断恢复的业务流程。一次完整任务可能经历待执行 ↓ 启动中 ↓ 割草中 ↓ 暂停 ↓ 继续 ↓ 电量不足 ↓ 返回充电 ↓ 充电等待 ↓ 自动续割 ↓ 任务完成过程中还可能被故障 定位异常 设备离线 用户取消 天气变化打断。因此移动端需要将设备状态 任务状态 控制指令状态 连接状态 状态新鲜度分别建模。任务状态机应该由事件驱动通过 Reducer 统一完成状态转换并明确每个状态允许的操作。页面按钮不应该自己拼接大量判断而应该由状态机统一返回当前可执行动作。App 重启后也不能依赖内存或旧页面状态而应该通过HTTPS 获取任务快照 实时消息恢复增量状态重新构建任务现场。割草任务状态机真正解决的不只是代码中的状态判断而是当设备长期运行、网络可能中断、用户可能离开 App 时系统依然能够清楚地知道这项任务进行到了哪里、还能做什么以及接下来应该如何恢复。这才是割草机任务架构的核心。下一篇预告《割草机 App 的地图系统边界、禁区、通道和充电桩如何建模》下一篇将继续拆解割草机地图为什么不是普通地图工作区域、禁区、通道和充电桩分别是什么为什么业务层不能直接依赖地图 SDK 的数据类型地图数据应该如何建模多区域和通道之间是什么关系设备位置、实时轨迹和历史轨迹如何分层地图版本如何与云端和设备同步。