LOADING

加载过慢请开启缓存 浏览器默认开启

状态机(State Machine)

简单说,状态机就是一种在不同状态之间切换,并根据当前状态输入决定行为的模型。

可以理解成这样的一套东西:(假若我是一台CD机)

一组状态(比如:Idle、Playing、Paused)

一组事件/输入(比如:Start 按钮按下、Pause 按钮按下)

一组转移规则(比如:在 Idle 状态下按 Start -> 切到 Playing)

一些动作(状态变化时要做什么,比如初始化资源、更新界面)

当前状态 输入事件 下一个状态 动作
Idle 点击播放 Playing 开始播放音乐
Playing 点击暂停 Paused 暂停音乐
Paused 点击播放 Playing 继续播放
Playing 播放结束 Idle 回到初始状态

状态机的核心就是:“状态 + 输入 → 动作 & 新状态

表驱动状态机

显然,我们可以使用 C 中的数组来实现如此一个状态机的转移表

typedef void (*pfState)(void);

typedef enum eEvent {
    EVT_NO_EVENT,
    EVT_Start_PRESSED,
    EVT_Pause_PRESSED,
    EVT_END
} eEvent;

typedef struct stStmRow {
    pfState  pSrcState;
    eEvent        eEvt;
    pfState pDestState;
} StmRow_t;

// 定义状态函数
void stateIdle(void);
void statePlaying(void);
void statePaused(void);

static eEvent      eCurrentEvent       = EVT_NO_EVENT;
static pfState     pfCurrentState      = stateIdle;

// 状态事件表
StmRow_t  stm[]= {
{stateIdle     , EVT_Start_PRESSED , statePlaying },
{statePlaying  , EVT_Pause_PRESSED , statePaused  },
{statePaused   , EVT_Start_PRESSED , statePlaying },
{statePlaying  , EVT_END           , stateIdle    }
};

通过 StateMachineHandler 查询判断 pfCurrentStateeCurrentEventstm 中的是否匹配,如果匹配,则执行相应的动作,并切换到下一个状态。当然需要为状态添加进入和离开的动作:

static bool bIsStateFirstEntry  = false;
static bool bIsStateAboutToExit = false;

void stateIdle(void)
{
    if(bIsStateFirstEntry) {
        // 处理初次进入,进行相关初始化
    }

    // ...

    if(bIsStateAboutToExit) {
        // 处理离开状态,比如释放内存,复位寄存器等
    }
}

void StateMachineHandler()
{
    int idx = 0;
    if(EVT_NO_EVENT != eCurrentEvent) {
        for(;idx < sizeof(stm)/sizeof(StmRow_t); idx++) {
            if( (stm[idx].pSrcState == pfCurrentState) &&
                (stm[idx].eEvt      == eCurrentEvent)) {
                eCurrentEvent  = EVT_NO_EVENT;
                bIsStateAboutToExit = true;
                pfCurrentState();
                // 更新状态
                pfCurrentState = stm[idx].pDestState;
                bIsStateFirstEntry = true;
            }
        }
    }

    if(NULL != pfCurrentState) {
        pfCurrentState();
        bIsStateFirstEntry = false;
    }
}

事件驱动状态机

需要提及的是,上文表驱动状态机的实现较繁琐,一般不会应用在如此简单的状态机中。

  • 最快速的写法:
void CD_Handler()
{
    if(Start_pressed) {
        // 🎵 Playing
    }
    if(Pause_pressed) {
        // ⏸ Paused
    }
    if(Play_End) {
        // 💤 Idle
    }
}

这样的代码隐式的描述了状态机,而且存在逻辑隐患,查询效率低。

  • 普遍的的 switch-case 示例:
void CD_Switcher()
{
    switch (fsm->current_state) {
        case Idle:
            if (event == Start_pressed) {
                playing_actions();
                fsm->current_state = Playing;
            }
            break;
        case Playing:
            if (event == Pause_pressed) {
                paused_actions();
                fsm->current_state = Paused;
            } else if (event == Play_End) {
                idle_actions();
                fsm->current_state = Idle;
            }
            break;
        // 其他状态...
    }
}

这样看起来似乎清晰一点,但是直接扔进主循环中会导致CPU一直在查询状态机,严重阻塞其他任务。因此,引入事件驱动状态机,当有事件发生时,再进行状态切换:

    eEvent event = dequeue(fsm->event_queue);
    if(event != No_event) {
        CD_Switcher(fsm, event);
    }

通过事件队列 dequeue() 来推动状态机的更新,从而避免CPU的轮询。同样可以在表驱动状态机中引入事件队列。

  • 除了 if-if 这种低效实现,其他的状态机实现按照外面调度方式决定它是不是阻塞或者打空转

业务与引擎分离状态机

回顾最初的表驱动状态机实现,StateMachineHandler 只负责状态切换,而业务逻辑则由状态函数 stateIdle 实现。两者解耦,互不污染,互不强依赖。

将具体的业务主体(CD机)与状态机引擎分离,状态机引擎只负责状态切换,业务主体则负责具体的业务逻辑。这样,状态机引擎可以复用,而业务主体则可以专注于自己的业务逻辑。

表驱动的显著优点是:可读性强,状态转移规则明确地写在转移表中。
——但是,当状态机非常复杂、动态转移多时,转移表也可能变得非常庞大、难以维护。

提供完全分离的业务与引擎:

但这并不能直接解决状态转移表过于庞大、难以维护的问题,业务逻辑会随着状态的增加而膨胀。需要在此架构中创建子状态机,将状态转移表拆分到各个状态机中。同时需要将行为动作从状态函数中剥离出来。