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

上一篇我们拆解了割草机 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: List<String>, 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启动指令已接受Starting
Starting设备开始准备Running
Running用户暂停成功Paused
Paused用户继续成功Running
Running电量不足ReturningForCharge
ReturningForCharge到达充电桩Charging
Charging达到续割电量WaitingToResume
WaitingToResume续割启动Running
Running割草区域完成Completing
Completing任务数据保存成功Completed
Running用户取消任务Cancelling
Cancelling设备停止成功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: Set<MowingAction> )

七、暂停任务应该如何设计

暂停看起来是一个简单操作,但需要区分:

用户发起暂停 设备正在暂停 设备已经暂停

完整流程:

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 状态正常;

  • 地图版本没有变化;

  • 设备不存在严重故障。

可以设计 Guard:

data 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 ) : MowingTaskState

2. 不可恢复故障

例如:

  • 刀盘严重故障;

  • 地图数据损坏;

  • 设备关键传感器异常;

  • 电池系统故障;

  • 用户明确终止任务;

  • 任务参数失效。

这类情况可以进入:

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 ReturnToDockRequested

2. 云端指令事件

例如:

CommandAccepted CommandRejected CommandTimeout

3. 设备状态事件

例如:

DevicePreparing DeviceMowing DevicePaused DeviceReturning DeviceCharging DeviceFault

4. 系统条件事件

例如:

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 统一处理任务状态变化

任务事件不应该在不同页面和回调中随意修改状态。

可以建立统一 Reducer:

object 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(): Set<MowingAction> { 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: Set<String>, 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 快照;

  • MQTTS;

  • WebSocket;

  • 本地缓存;

在不同数据源之间同步。

因此,任务状态最好携带:

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. 没有过渡状态

缺少StartingPausingCancelling,页面无法表达设备正在执行操作。


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 的数据类型;

  • 地图数据应该如何建模;

  • 多区域和通道之间是什么关系;

  • 设备位置、实时轨迹和历史轨迹如何分层;

  • 地图版本如何与云端和设备同步。