4. 状态机核心接口设计:IState、IEvent、ITransition
好,咱们今天聊点实在的。
状态机这东西,说白了就是「状态 + 事件 + 迁移」三件套。你想想看,不管多复杂的系统,拆到最后都是这三个东西在转。我做了十几年嵌入式,见过太多人一上来就写 switch-case,写到后面自己都看不懂。嗯,今天我就把这三个核心接口的设计思路掰开揉碎讲给你听。
4.1 IState 接口:状态的「身份证」
状态是什么?就是一个对象在某个时刻的「快照」。但作为接口,它得有点「契约精神」。
核心原则:IState 只负责「我是谁」和「我该干什么」,不负责「我怎么来的」。
我个人习惯,IState 接口至少包含以下三个方法:
// 状态接口定义
public interface IState
{
// 进入状态时调用(入场动作)
void OnEnter();
// 状态持续执行(心跳/轮询)
void OnUpdate();
// 离开状态时调用(清理动作)
void OnExit();
// 获取状态名称(调试用)
string GetStateName();
}
你可能会问:「为什么要有 OnEnter 和 OnExit?」
我在项目中遇到过这样一个坑:有个电机控制模块,切换状态时没做 OnExit 清理,结果上次的 PWM 占空比还留着,电机直接飞车了。从那以后,我每个状态都强制要求实现 OnEnter 和 OnExit,哪怕里面是空的。
小技巧:GetStateName() 别小看它。调试的时候,把当前状态名打出来,比看一堆二进制位舒服多了。我习惯用枚举 + 字符串映射表来实现。
4.2 IEvent 接口:状态的「触发器」
事件是什么?就是「发生了一件事,状态机你得动一动」。
但事件不能乱发。我见过有人把按键抖动、定时器中断、串口数据全塞到一个事件队列里,结果优先级全乱套了。
避坑指南:我曾经在一个工业控制器项目里,把「传感器触发」和「用户按键」放在同一个事件优先级里。结果传感器高频触发时,用户按键永远响应不了。后来我强制给事件分了三级:紧急、普通、后台。
来看看我推荐的 IEvent 接口设计:
// 事件接口定义
public interface IEvent
{
// 事件类型(枚举值)
int GetEventType();
// 事件优先级(0最高,255最低)
int GetPriority();
// 事件携带的数据(可选)
object GetEventData();
// 事件是否可被丢弃(用于队列满时)
bool IsDiscardable();
}
这里有个细节:IsDiscardable() 是我后来加的。为什么?
你想想看,如果事件队列满了,是丢掉旧事件还是丢掉新事件?我建议:心跳、按键重复这类事件可以丢;但「急停」「故障」这类事件不能丢。这个标记就是用来做这个决策的。
| 事件类型 | 优先级 | 可丢弃 | 说明 |
|---|---|---|---|
| 急停 | 0 | 否 | 最高优先级,必须处理 |
| 传感器触发 | 50 | 是 | 可丢弃旧事件 |
| 用户按键 | 100 | 否 | 用户操作不能丢 |
| 定时器心跳 | 200 | 是 | 丢了下次还有 |
4.3 ITransition 接口:状态的「桥梁」
迁移,就是「从状态 A 到状态 B,因为事件 E 发生了」。
这个接口最容易被忽略。很多人觉得「不就是 if-else 吗?」—— 但一旦状态多了,if-else 就是灾难。
核心思想:ITransition 应该是一个「可配置的规则」,而不是硬编码的逻辑。
我建议的 ITransition 接口:
// 迁移接口定义
public interface ITransition
{
// 当前状态
IState GetCurrentState();
// 目标状态
IState GetTargetState();
// 触发事件
IEvent GetTriggerEvent();
// 迁移条件(守卫条件)
bool CanTransition(IEvent eventData);
// 迁移动作(迁移时执行)
void OnTransition();
}
这里重点说说 CanTransition()。它是个「守卫条件」—— 就算事件来了,也不一定非得迁移。比如:
- 「开门」事件来了,但门已经开了,就不需要再迁移
- 「急停」事件来了,但当前状态就是急停状态,也不需要迁移
我曾经在一个项目里忘了加守卫条件,结果状态机在「开门」和「关门」之间来回震荡,像抽风一样。嗯,从那以后我每个迁移都配一个守卫条件,哪怕条件就是「true」。
4.4 三者关系:一张图说清楚
下面这张 SVG 图,是我自己画的核心关系图。你看一眼就明白了:
4.5 接口设计的「潜规则」
接口定义完了,但实际用起来还有几个坑。我总结一下:
- 接口要小,职责要单一 —— 别把 IState 搞成「万能接口」,什么日志、统计、网络都往里塞。我见过一个 IState 有 20 个方法,那还不如不用。
- 事件要带时间戳 —— 调试的时候,知道事件「什么时候发生的」比知道「发生了什么」更重要。我习惯在 IEvent 里加一个
GetTimestamp()。 - 迁移要支持「自迁移」 —— 有时候状态没变,但需要重新执行 OnEnter。比如「重置」操作,状态不变但动作要重做。
- 别用字符串做事件类型 —— 字符串比较太慢了。用枚举或者整数,性能好,也容易做 switch-case。
我的习惯:每个接口都配一个「空实现」或者「默认实现」。比如 IState 的 OnUpdate 可以默认什么都不做。这样新加状态时,不用每个方法都写一遍。
好了,这三个接口的设计思路就这些。说白了,就是「状态管自己、事件管触发、迁移管规则」。你把这个三角关系理清了,状态机架构就稳了八成。