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 图,是我自己画的核心关系图。你看一眼就明白了:

IState 当前状态 OnEnter / OnUpdate / OnExit IEvent 触发事件 类型 / 优先级 / 数据 ITransition 从状态A → 状态B CanTransition / OnTransition 当前状态 触发事件 目标状态 三者协作:状态 + 事件 → 迁移 → 新状态

4.5 接口设计的「潜规则」

接口定义完了,但实际用起来还有几个坑。我总结一下:

  1. 接口要小,职责要单一 —— 别把 IState 搞成「万能接口」,什么日志、统计、网络都往里塞。我见过一个 IState 有 20 个方法,那还不如不用。
  2. 事件要带时间戳 —— 调试的时候,知道事件「什么时候发生的」比知道「发生了什么」更重要。我习惯在 IEvent 里加一个 GetTimestamp()。
  3. 迁移要支持「自迁移」 —— 有时候状态没变,但需要重新执行 OnEnter。比如「重置」操作,状态不变但动作要重做。
  4. 别用字符串做事件类型 —— 字符串比较太慢了。用枚举或者整数,性能好,也容易做 switch-case。

我的习惯:每个接口都配一个「空实现」或者「默认实现」。比如 IState 的 OnUpdate 可以默认什么都不做。这样新加状态时,不用每个方法都写一遍。

好了,这三个接口的设计思路就这些。说白了,就是「状态管自己、事件管触发、迁移管规则」。你把这个三角关系理清了,状态机架构就稳了八成。

可可精品资料库