状态机

设计思路

状态机要解决的问题不是“怎么画状态图”,而是“怎么让系统只处在合法状态里”

当代码只有一个布尔值时,状态通常很好理解。比如 open = true 表示弹窗打开,open = false 表示弹窗关闭

但真实代码很快会出现多个变量共同描述一个阶段。搜索弹窗里会有 openmountedclosing,算法里会有评估、选择、交叉、变异,连接对象里也会有关闭、监听、已连接等阶段

状态机的意义,就是把这些阶段、事件和副作用整理成明确规则。本文会在叙述中穿插 GPP 的状态模式、遗传算法引擎的设计思路,以及 UI 按钮和搜索弹窗的状态转换

状态、事件、转移

状态机可以先拆成四个元素:状态、事件、转移、副作用

  • 状态:系统当前处在哪个阶段
  • 事件:触发变化的输入
  • 转移:当前状态收到事件后进入哪个状态
  • 副作用:转移时额外执行的动作

用伪代码表达就是:

let mut state = State::Initial;

fn dispatch(state: State, event: Event) -> State {
    let next_state = transition(&state, &event);
    run_side_effects(&state, &event, &next_state);
    next_state
}

state = dispatch(state, event);

这里最重要的是 transition(state, event)。同一个事件在不同状态下可以有不同结果;同一个状态也不一定响应所有事件

因此,状态机不是把 if 全部消灭,而是让每个 if 有明确归属:它要么判断当前状态,要么判断当前事件

从变量组合到状态

很多状态机不是一开始就被设计出来的,而是从多个状态变量里“还原”出来的

以搜索弹窗为例,openmountedclosing 三个布尔值共有 8 种组合。但真正稳定、能解释的组合只有 3 种

<script setup>
import { computed, ref } from 'vue'

const open = ref(false)
const mounted = ref(false)
const closing = ref(false)

const dialogState = computed(() => {
  if (!open.value && !mounted.value && !closing.value) return 'idle'
  if (open.value && mounted.value && !closing.value) return 'open'
  if (!open.value && mounted.value && closing.value) return 'closing'

  return 'invalid'
})
</script>

idle 表示弹窗没有挂载。open 表示弹窗已经挂载并显示。closing 表示弹窗不再显示为打开态,但 DOM 仍然保留,用来播放关闭动画

其他组合就很难解释:

<script setup>
const invalidDialogStates = [
  { open: true, mounted: false, closing: false },
  { open: false, mounted: false, closing: true },
  { open: true, mounted: true, closing: true },
]
</script>

这一步是状态机建模的起点:先找出合法组合,再给合法组合命名。命名之后,代码不再只是几个布尔值,而是几个可以讨论的阶段

事件推动状态变化

状态不会自己变化,必须由事件推动。对 UI 来说,事件可能是点击按钮、按下快捷键、按下 Escape、动画结束或计时器触发

搜索弹窗的打开过程可以写成:

<script setup>
let closeTimer = 0

function openSearch() {
  if (closeTimer) {
    window.clearTimeout(closeTimer)
    closeTimer = 0
  }

  mounted.value = true
  closing.value = false
  open.value = true
}
</script>

这里不只是把 open 设为 true。它还先清理旧的关闭计时器,避免用户在关闭动画尚未结束时重新打开弹窗,却被旧计时器稍后重置

关闭过程更能体现状态机的价值:

<script setup>
const motionEnabled = ref(true)

function closeSearch() {
  if (!mounted.value || closing.value) return

  open.value = false
  closing.value = true

  if (!motionEnabled.value) {
    resetSearchState()
    return
  }

  closeTimer = window.setTimeout(() => {
    closeTimer = 0
    resetSearchState()
  }, MODAL_CLOSE_DURATION_MS)
}
</script>

mounted == false 表示弹窗已经不在页面里,不需要关闭。closing == true 表示关闭流程已经启动,不应该重复启动

motionEnabled 决定是否等待动画结束。用户偏好减少动画时,组件可以直接重置状态;否则要等一小段时间,让关闭动画播放完成

这就是“转移”和“副作用”的关系:关闭弹窗的状态转移是 open -> closing -> idle,副作用是启动计时器、清理搜索词、重置选中项

<script setup>
function dialogTransition(state, event) {
  if (state === 'idle' && event === 'openSearch') {
    return 'open'
  }

  if (state === 'open' && event === 'closeSearch') {
    return 'closing'
  }

  if (state === 'closing' && event === 'closeTimer') {
    return 'idle'
  }

  return state
}
</script>

pop-up

状态中的行为

有些状态只是阶段名,有些状态还携带行为,游戏编程模式的状态模式讲的就是后一种情况

设想一个连接对象。它可能处于 closedlisteningestablished 等状态。收到 opencloseacknowledge 时,行为取决于当前状态

如果把所有行为都写进连接对象,就会出现事件分支嵌套状态分支:

enum class State {
    Closed,
    Listening,
    Established,
};

enum class Event {
    Open,
    Close,
    Acknowledge,
};

void TCPConnection::handle(Event event) {
    if (event == Event::Open) {
        if (state_ == State::Closed) {
            state_ = State::Listening;
        } else if (state_ == State::Established) {
            return;
        }
    }

    if (event == Event::Close) {
        if (state_ == State::Listening) {
            state_ = State::Closed;
        } else if (state_ == State::Established) {
            sendFinishPacket();
            state_ = State::Closed;
        }
    }

    if (event == Event::Acknowledge) {
        if (state_ == State::Listening) {
            state_ = State::Established;
        }
    }
}

这种写法的问题是增长方向太多。新增一个状态,要检查所有事件;新增一个事件,也要检查所有状态

状态模式会把行为移动到状态对象中。连接对象只保存当前状态,并把事件交给当前状态处理

class TCPState {
public:
    virtual ~TCPState() = default;
    virtual TCPState* handle(TCPConnection& connection, Event event) = 0;
};

class TCPClosed : public TCPState {
public:
    TCPState* handle(TCPConnection&, Event event) override {
        if (event == Event::Open) {
            return new TCPListening();
        }

        return this;
    }
};

class TCPListening : public TCPState {
public:
    TCPState* handle(TCPConnection&, Event event) override {
        if (event == Event::Acknowledge) {
            return new TCPEstablished();
        }

        if (event == Event::Close) {
            return new TCPClosed();
        }

        return this;
    }
};

class TCPEstablished : public TCPState {
public:
    TCPState* handle(TCPConnection& connection, Event event) override {
        if (event == Event::Close) {
            connection.sendFinishPacket();
            return new TCPClosed();
        }

        return this;
    }
};

void TCPConnection::dispatch(Event event) {
    TCPState* next = state_->handle(*this, event);

    if (next != state_) {
        delete state_;
        state_ = next;
    }
}

这不是为了套设计模式,而是为了让状态相关行为靠近状态本身。状态越影响行为,越适合把状态从普通枚举提升为状态对象

如果只是切换按钮图标或 CSS class,状态对象通常太重。此时用枚举、reducer 或少量状态变量就足够

流程也是状态机

状态机不只适合 UI 和连接对象,也适合描述按阶段推进的流程

遗传算法引擎就是典型流程。参考genetic-algorithm-rust的设计,一个引擎会通过配置描述种群规模、基因空间、选择策略、交叉策略、变异策略、精英保留和停止条件

这些配置不是孤立参数。它们共同决定每一代种群如何从“候选解集合”推进到“下一代候选解集合”

GA

如果把每个阶段写成状态,流程会变得很清楚:

enum EvolutionState {
    Initialized,
    Evaluating,
    CheckingStop,
    Selecting,
    Reproducing,
    Terminated,
}

let config = EngineConfig::builder().build();
let mut state = EvolutionState::Initialized;

while !matches!(state, EvolutionState::Terminated) {
    state = match state {
        EvolutionState::Initialized => {
            population = create_initial_population(&config.genes_domain);
            EvolutionState::Evaluating
        }
        EvolutionState::Evaluating => {
            evaluations = evaluate_population(&population);
            stats = collect_generation_stats(&evaluations);
            EvolutionState::CheckingStop
        }
        EvolutionState::CheckingStop if stop_condition_reached(&stats) => {
            EvolutionState::Terminated
        }
        EvolutionState::CheckingStop => EvolutionState::Selecting,
        EvolutionState::Selecting => {
            elites = keep_best_individuals(&population, &evaluations);
            parents = select_parents(&population, &evaluations, config.selection);
            EvolutionState::Reproducing
        }
        EvolutionState::Reproducing => {
            let children = crossover(&parents, config.crossover);
            population = mutate(children, config.mutation);
            population = merge(elites, population);
            EvolutionState::Evaluating
        }
        EvolutionState::Terminated => EvolutionState::Terminated,
    };
}

这里的状态重点不是“不同行为属于不同对象”,而是“阶段顺序不能错”

不能在评估适应度之前选择父代,不能在检查停止条件之前盲目繁殖,也不能在满足终止条件后继续生成下一代。流程状态机把这些顺序约束写成了显式转移

let flow = [
    EvolutionState::Initialized,
    EvolutionState::Evaluating,
    EvolutionState::CheckingStop,
    EvolutionState::Selecting,
    EvolutionState::Reproducing,
    EvolutionState::Evaluating,
    EvolutionState::Terminated,
];

如果启用岛屿模型,还可以把“迁移”看成周期性插入的状态。每个岛独立完成评估、选择和繁殖,到达迁移间隔后,再把部分个体移动到其他岛

fn evolve_islands(world: &mut IslandWorld, generation: usize) {
    world.evolve_each_island();

    if generation % world.migration_interval == 0 {
        world.migrate_individuals(world.topology, world.migration_count);
    }

    world.continue_next_generation();
}

多目标优化也是同样思路。单目标模式通常追踪 best_fitness,多目标 NSGA-II 模式则追踪 Pareto 前沿。输出指标不同,但状态仍然围绕“评估 -> 判断 -> 生成下一代”推进

训练过程、构建流程、任务队列、数据处理 pipeline 都可以用类似方式分析。只要存在“必须先 A 再 B”的阶段顺序,就可以把它看成流程状态机

并行状态区域

一个系统经常不止一条状态线。把所有状态强行合成一个大枚举,反而会让状态数量膨胀

UI 按钮和搜索弹窗就是这种情况。弹窗是否打开、是否启用动画、搜索结果选中项、文章导航按钮模式,其实是几条相关但不同的状态线

<script setup>
const uiState = computed(() => ({
  dialog: dialogState.value,
  motion: motionEnabled.value ? 'enabled' : 'reduced',
  selectedResultIndex: selectedResultIndex.value,
  articleNav: articleNavMode.value,
}))
</script>

dialog 决定弹窗是否挂载和展示。motion 决定关闭时是否等待动画。results 管理键盘选中项。articleNav 管理按钮在章节标题和导航图标之间切换

搜索结果选择可以单独看成一个小状态机:

<script setup>
const selectedResultIndex = ref(0)

function moveSelection(event, results) {
  if (event.key === 'ArrowDown') {
    selectedResultIndex.value = Math.min(
      selectedResultIndex.value + 1,
      Math.max(results.length - 1, 0),
    )
  }

  if (event.key === 'ArrowUp') {
    selectedResultIndex.value = Math.max(selectedResultIndex.value - 1, 0)
  }
}

watch(results, () => {
  selectedResultIndex.value = 0
})
</script>

它的目标很单纯:选中项不能越界。搜索结果变化时,旧索引可能不再有效,所以要回到 0

文章导航按钮也可以单独看成 reducer 状态机:

<script setup>
const articleNavMode = ref('heading')

function dispatchArticleNav(action) {
  if (action === 'toggle') {
    articleNavMode.value = articleNavMode.value === 'heading' ? 'icons' : 'heading'
  }

  if (action === 'reset') {
    articleNavMode.value = 'heading'
  }
}
</script>

对应的状态图很小:

<template>
  <button @click="dispatchArticleNav('toggle')">
    {{ articleNavMode === 'heading' ? 'icons' : 'heading' }}
  </button>

  <button @click="dispatchArticleNav('reset')">
    heading
  </button>
</template>

并行状态区域的判断标准是:这些状态是否可以同时存在

弹窗可以处于 open,同时动画可以是 enabled,结果可以选中第 2 项,文章导航可以处于 icons。它们不是互斥状态,因此不应该被塞进同一个枚举

状态机分类

从工程实践看,状态机可以按问题类型分类,而不是按术语堆叠分类

分类关注点适合场景
基础状态机状态和事件之间的确定转移开关、弹窗、播放器
行为状态机不同状态下事件行为不同连接、协议、编辑器模式
流程状态机阶段必须按顺序推进遗传算法、训练流程、任务队列
并行状态机多条状态线同时存在复杂组件、页面交互、异步任务

这些分类不是互斥的。搜索弹窗是基础状态机,也包含并行状态区域。遗传算法是流程状态机,但每个阶段内部也可以继续拆成更细的状态

分类的目的不是贴标签,而是帮助选择表达方式:简单状态用枚举,阶段流转用流程,状态携带大量行为时再考虑状态模式

如何识别状态机

判断一段代码是否适合用状态机思维整理,可以看这些信号:

  • 多个布尔值共同描述一个阶段
  • 某个事件在不同条件下行为不同
  • 有动画、计时器、异步请求或清理动作
  • 有“不能重复触发”的保护条件
  • 有“必须先 A 再 B”的顺序要求

整理时可以先写伪代码,不急着引入状态机库

fn model_state_machine() {
    let variables = list_state_variables();
    let valid_states = name_valid_combinations(variables);
    let events = list_events();

    for event in events {
        define_allowed_transition(valid_states, event);
        attach_side_effects(event);
    }
}

如果这个过程能明显减少非法组合,状态机建模就是值得的。如果只是把一个简单布尔值包装成复杂对象,那就是过度设计

总结

状态机的本质,是把系统行为从散落的变量组合提升为明确的状态、事件、转移和副作用

UI 交互里,它能解释挂载、动画、键盘选择和按钮模式。算法流程里,它能约束阶段顺序。状态相关行为很重时,它还能把行为移动到状态对象中

写状态机之前,不一定要先引入库。先把合法状态写出来,把事件和转移写清楚,很多复杂逻辑就已经变得可枚举、可检查、可维护

参考