Day 29:状态机架构(State Machine / QStateMachine)

C++/Qt 每日设计模式 · 架构模式阶段 · 2026-09-21

架构模式 有限状态机 FSM 转移表 / 守卫 / 动作 QStateMachine 分层状态与历史状态

01

今日主题

一句话定义:状态机架构是指把「系统当前处于什么阶段」抽象成一组互斥的状态,把「外界发生了什么」抽象成一组事件,再显式地写出一张转移规则表(在状态 A 收到事件 E,若守卫条件成立,则跳到状态 B 并执行动作),由一个统一的引擎在运行时完成「收事件 → 查规则 → 执行退出动作 → 迁移 → 执行进入动作」的循环——于是「什么时候能做什么、做了之后到哪里」这件事,第一次变成了一个可以被读、被画、被穷举测试的数据结构,而不是散落在几十个函数里的 if (mode == ...)。

它不是 GoF 23 里的一个类级模式(Day 19 的状态模式才是 OO 层面的实现手段),而是架构级的建模方式:状态模式(Day 19)回答「如何用一个类代表一个状态」,状态机架构回答「整个系统的生命周期与事件规则如何组织、如何被验证、如何与 UI 解耦」。两者是「实现手段」与「建模语义」的关系——状态模式常用于实现状态机里的那个 ConcreteState,而一个状态机也可以完全不写状态类,只用一张转移表 + std::function 就落地。

本篇定位:Day 28 的收尾处我们留了一道自问:「同一个按钮在‘画矩形’模式下按下要画矩形、在‘选择’模式下按下要选中对象、在‘删除’模式下按下要删东西」——这种「行为随模式变化」的复杂度,继续用 if (mode == ...) 铺开会迅速失控。今天把它提升到架构层:模式 = 状态,用户操作 = 事件,模式切换 = 转移,进入/退出模式时该做的事 = 进入/退出动作。同时回答三个工程问题:① 状态和事件多了以后,规则如何不互相踩;② 如何在异步事件循环里保证「发出信号后状态立刻正确」;③ 什么规模该用 QStateMachine,什么规模一张表就够。

02

为什么需要这个模式(无模式坏代码)

先看一段「能跑但没有架构」的写法。这是一个画板控件,用户的每个操作都直接改数据,工具(模式)用一个 enum 表示,再配几个 bool 记录「当前正在干什么」:

// ❌ 反例(示意):工具切换 + 鼠标/键盘行为,全部塞进 enum + bool + if-else
class BadCanvas {
public:
    void setTool(Tool t) {
        tool_ = t;
        // 「进入某个模式时该做什么」只能硬写在这里:改光标、改按钮可用性、清选择……
        if (t == Tool::Select) { cursor_ = "arrow"; deleteEnabled_ = true;  selection_ = -1; }
        if (t == Tool::Rect)   { cursor_ = "cross"; deleteEnabled_ = false; selection_ = -1;
                                 drawing_ = false; }
        if (t == Tool::Delete) { cursor_ = "skull"; deleteEnabled_ = false; }
    }

    void onMousePress(int x, int y) {
        if (tool_ == Tool::Select) {
            selection_ = hitTest(x, y);                    // 选中
        } else if (tool_ == Tool::Rect) {
            if (!drawing_) { drawing_ = true; sx_ = x; sy_ = y; }   // 第一次按下:开始画
            else           { drawing_ = false; commitShape(sx_, sy_, x, y); }
        } else if (tool_ == Tool::Delete) {
            if (selection_ >= 0) removeShape(selection_);
        }
    }

    void onMouseRelease(int x, int y) {
        if (tool_ == Tool::Rect && drawing_) { drawing_ = false; commitShape(sx_, sy_, x, y); }
    }

    void onKeyPress(int key) {
        if (tool_ == Tool::Rect  && drawing_ && key == KeyEsc) { drawing_ = false; }  // 取消
        if (tool_ == Tool::Select && key == KeyDel && selection_ >= 0) removeShape(selection_);
    }

    // 于是每个新函数都要重新推一遍「我现在的合法状态到底是什么」
    bool canCommit() const { return tool_ == Tool::Rect && drawing_; }

private:
    enum class Tool { Select, Rect, Delete };
    Tool tool_ = Tool::Select;
    bool drawing_ = false;          // ❌ 只对 Rect 有意义
    int  sx_ = 0, sy_ = 0;          // ❌ 只对 Rect 有意义
    int  selection_ = -1;           // ❌ 只对 Select / Delete 有意义
    bool deleteEnabled_ = true;     // ❌ 一个从别处推导出来的 UI 状态
    std::string cursor_;            // ❌ 同上
    std::vector<Shape> shapes_;
};

这段代码的问题不是「写得丑」,而是复杂度以乘法而不是加法的速度增长。设工具数 N、事件数 M,则事件处理函数里是 N×M 个分支;每增加一个新工具(比如「画圆」「魔棒」),都要回到每一个事件函数里补一个 else if;而「这个工具下哪些操作合法」这个真值表,从来没有在任何地方被完整写出来过——它被撕碎在 5 个函数里。

更糟的是非法状态可表示:drawing_ == true 而 tool_ == Tool::Select 在类型层面完全合法,只是逻辑上荒谬。只要有一条忘了清理的代码路径(比如「删除工具里删掉了正在绘制的形状」),这个幽灵状态就会留下来,表现为「切回矩形工具后第一次点击不生效」这类极难复现的 bug。这类 bug 的根因是:状态不是被「存」在某个地方,而是被「猜」出来的——由一组 bool 拼凑推断。任何时刻只能有 1 个工具生效、只能有 N 种组合,却被表示成 2^k 种可能的位组合。

对照 SOLID 逐条看:

  • SRP:一个 BadCanvas 同时承担「选择工具的行为」「矩形工具的行为」「删除工具的行为」「光标与按钮联动」「图形数据管理」五件事。
  • OCP:加一个工具要修改所有事件函数与 setTool()——是「修改」而不是「扩展」;每一次修改都在碰已经被测试过的老代码。
  • DIP:事件处理函数直接依赖具体枚举值与具体分支逻辑,没有「规则」这个抽象,想换一套规则(比如加一个「临时平移」模式)只能继续 if。
  • 接口隔离:如果引入 ITool 接口,接口里也会被迫塞进 onMousePress/onMouseRelease/onKeyPress/onWheel 等所有事件——大部分工具只关心其中一两个。
  • 可测性:想验证「矩形工具下按下 Esc 会取消绘制」,必须先构造 QWidget、伪造鼠标事件、观察私有成员 drawing_——测试只能从 UI 层间接戳进去。

把状态与事件显式建模后,上面每一条都会被翻转:规则集中在一张表里(SRP)、加状态只加节点和边(OCP)、引擎只依赖「状态 + 事件 + 动作」抽象(DIP)、每个状态只实现自己在意的事件(ISP)、状态轨迹可以被断言(可测性)。

03

核心思想(通俗解释 + 生活类比)

状态机的思想可以用一句话概括:系统的复杂度不在「有多少功能」,而在「功能之间的先后关系」;把这些关系从代码里抽出来、变成一张图,复杂度就从「不可见」变成「可见」,而可见的复杂度是可以被检查和推演的。

一个标准的状态机由五要素构成:

工程实践上还会加三个扩展要素,它们是把「玩具状态机」和「生产级状态机」区分开的关键:

生活类比:红绿灯。红绿灯的全部行为就是一张极小状态机:状态 {红, 绿, 黄},事件 {倒计时结束},转移只能是 红→绿→黄→红。它最大的价值是「红→黄」这种转移不存在——不是因为程序员忘了写,而是因为转移表里根本没有这条边。真实世界的复杂系统(电梯:空闲→上行→下行→开门→检修;微波炉:待机→设定→加热→暂停;ATM:待机→插卡→验密→交易→退卡)之所以可靠,就是因为它们的合法转移被明确限定过。

为什么用「模式」这个词来类比软件?因为桌面客户端里的「工具模式」与红绿灯完全同构:画板在「选择模式」下的点击叫选中,在「矩形模式」下的点击叫落点——同一个事件,在不同状态下语义不同。这正是 if (mode == ...) 无法优雅表达的东西:模式不是分支的修饰符,它是决定「这个事件现在意味着什么」的上下文。

与 Day 19 状态模式的边界:状态模式把每个状态做成一个实现同一接口的类,运行时切换当前对象——关注「行为如何随状态变化」;状态机架构关注「状态之间的转移拓扑、守卫、生命周期与可验证性」。所以状态机可以:a) 用状态对象实现(= 状态模式);b) 用一张转移表实现(今天的最小示例);c) 用框架实现(Qt 的 QStateMachine / Qt SCXML)。三者是同一个模型的不同实现方式,选哪个取决于「状态数、事件数、是否需要分层/并行、团队可维护性」。

04

UML / 角色关系与职责

状态机架构的角色比经典的 GoF 模式少得多——它的「零件」本质上只有三类,剩下的都是引擎内部机制:

                  ┌──────────────────────── Context / Host ────────────────────────┐
                  │  (画板、下载器、设备会话:真正持有数据与 UI 的一方)            │
                  │                                                                 │
                  │   事件:用户操作 / 网络回调 / 定时器  ───────────┐              │
                  │                                                 ↓              │
                  │            调用 Action 接口(改光标、提交图形)  │              │
                  │                                                 │              │
                  │   ┌────────────────── StateMachine(引擎)──────┴───────────┐  │
                  │   │  while(true) {  event = next();                          │  │
                  │   │      rule = table.find(currentState, event);              │  │
                  │   │      if (!rule || !guard()) continue;   // 非法/守卫失败  │  │
                  │   │      currentState->onExit();     // ① 退出动作            │  │
                  │   │      rule->action();             // ② 转移动作            │  │
                  │   │      currentState = rule->target;// ③ 迁移                │  │
                  │   │      currentState->onEnter();    // ④ 进入动作            │  │
                  │   │  }                                                        │  │
                  │   └──────────────────────────────────────────────────────────┘  │
                  └─────────────────────────────────────────────────────────────────┘

  状态树(分层/复合状态,Qt 里就是 QState 的父子关系):
     Idle ──Start──▶ Downloading ──Pause(guard:已收到数据)──▶ Paused
                        │  ▲                                    │
                        │  └──────────── Resume ───────────────┘
                        ├──Complete──▶ [Final] Done
                        └──Fail──────▶ Failed ──Start(重试)──▶ Downloading

四个角色的职责必须划清楚,否则「状态机」会退化成「多写了一个类」:

两种落地的形态差异(本篇两种都会写):表格驱动型(最小示例)= 状态用 enum,规则用 std::vector<Rule>,动作与守卫是 std::function;优点是零框架依赖、规则一眼看全、单测极简;缺点是状态内部无法自然容纳嵌套子状态。框架型(Qt 实战)= 状态是对象、可以父子嵌套(分层状态)、可以并行、支持历史状态与守卫挂载、事件走信号;优点是能表达复杂拓扑并和 Qt 事件循环天然融合;缺点是心智负担与调试成本更高。工程上常见的成熟做法是:状态 ≤ 8、无嵌套 → 表格;出现「状态里还有阶段」「两套维度并行」「要回到上次位置」→ 框架。

05

最小 C++ 示例(C++17 表格驱动 FSM,可直接编译)

先不引入任何框架,用一张转移表把一个下载任务的生命周期写出来。要点:状态/事件是 enum class(互斥、不可拼凑)、规则是值语义的结构体、守卫与动作是 std::function(可以为空,表示无条件/无动作)、非法事件被明确忽略并计数——「忽略非法事件」本身是状态机的一半价值,因为它把「不该发生的事」变成了可观测的数据而不是靠运气。

// download_fsm.cpp — C++17,g++ -std=c++17 -Wall -Wextra download_fsm.cpp -o download_fsm
#include <functional>
#include <iostream>
#include <string>
#include <vector>

// ── ① 状态与事件:两个互斥的枚举。任何时刻,机器恰好处于一个状态 ──
enum class State { Idle, Downloading, Paused, Done, Failed };
enum class Event { Start, Pause, Resume, Progress, Complete, Fail };

const char* name(State s) {
    switch (s) {
        case State::Idle:        return "Idle";
        case State::Downloading: return "Downloading";
        case State::Paused:      return "Paused";
        case State::Done:        return "Done";
        case State::Failed:      return "Failed";
    }
    return "?";
}
const char* name(Event e) {
    switch (e) {
        case Event::Start:    return "Start";
        case Event::Pause:    return "Pause";
        case Event::Resume:   return "Resume";
        case Event::Progress: return "Progress";
        case Event::Complete: return "Complete";
        case Event::Fail:     return "Fail";
    }
    return "?";
}

// ── ② 一条转移规则:(源状态, 事件) -> 目标状态,可选守卫与动作 ──
struct Rule {
    State                 from;
    Event                 on;
    State                 to;
    std::function<bool()> guard;    // 空 = 无条件通过
    std::function<void()> action;   // 空 = 无副作用
};

class DownloadTask {
public:
    DownloadTask() {
        // ── ③ 转移表就是状态机的“源码”:谁在什么状态下能干什么,一眼看全 ──
        rules_ = {
            { State::Idle, Event::Start, State::Downloading, {},
              [this] { log("建立连接,进入下载中"); } },

            // 守卫示例:服务器还没回数据(received_ == 0)时不允许暂停
            { State::Downloading, Event::Pause, State::Paused,
              [this] { return received_ > 0; },
              [this] { log("暂停下载(已收 " + std::to_string(received_) + " 字节)"); } },

            { State::Paused, Event::Resume, State::Downloading, {},
              [this] { log("恢复下载"); } },

            // 自转移:目标与源相同,用来表达“在该状态下响应事件并推进数据,但不换状态”
            { State::Downloading, Event::Progress, State::Downloading, {},
              [this] { received_ += 64;
                       log("收到 64 字节,累计 " + std::to_string(received_)); } },

            { State::Downloading, Event::Complete, State::Done, {},
              [this] { log("下载完成,共 " + std::to_string(received_) + " 字节"); } },

            { State::Downloading, Event::Fail, State::Failed, {},
              [this] { log("网络错误,进入失败态"); } },

            // 失败后允许重试:Failed --Start--> Downloading(回到起点并清零)
            { State::Failed, Event::Start, State::Downloading, {},
              [this] { received_ = 0; log("重试:重新开始下载"); } },
        };
    }

    // ── ④ 引擎:查表 → 守卫 → 动作 → 迁移。返回 false 表示事件被忽略 ──
    bool dispatch(Event e) {
        for (const Rule& r : rules_) {
            if (r.from != state_ || r.on != e) continue;
            if (r.guard && !r.guard()) {                      // 守卫拦住:状态不变
                log(std::string("事件 ") + name(e) + " 被守卫拒绝(条件不满足)");
                ++rejected_;
                return false;
            }
            const State old = state_;
            if (r.action) r.action();                          // 先动作,后换状态
            state_ = r.to;
            trace_.push_back(std::string(name(old)) + " --" + name(e) + "--> " + name(r.to));
            return true;
        }
        // 没有匹配的边 = 该事件在此状态下非法,明确忽略并计数(这是“非法状态不可达”的保障)
        log(std::string("非法事件 ") + name(e) + "(在 " + name(state_) + " 下无对应转移,已忽略)");
        ++ignored_;
        return false;
    }

    State state() const            { return state_; }
    State initial() const          { return State::Idle; }
    int   ignored() const          { return ignored_; }
    int   rejected() const         { return rejected_; }
    const std::vector<std::string>& trace() const { return trace_; }

private:
    void log(const std::string& msg) const {                 // 日志带上前缀状态,便于对照转移表
        std::cout << "  [" << name(state_) << "] " << msg << '\n';
    }
    State state_ = State::Idle;      // S₀
    int   received_ = 0;             // 状态机“顺带”管理的数据(在 Context 里更合适,这里为求最小)
    int   ignored_ = 0, rejected_ = 0;
    std::vector<Rule>        rules_;
    std::vector<std::string> trace_;  // 状态轨迹:测试断言的对象
};

int main() {
    DownloadTask task;
    // 用一段脚本模拟真实事件流,其中故意包含 3 个“不该发生”的事件
    const Event script[] = {
        Event::Pause,     // Idle 下暂停 → 非法
        Event::Start,     // Idle → Downloading
        Event::Pause,     // 守卫拒绝:还没收到任何数据
        Event::Progress,  // 收到第一批数据
        Event::Progress,
        Event::Pause,     // Downloading → Paused
        Event::Progress,  // Paused 下收到数据 → 非法(不该在暂停时推进)
        Event::Resume,    // Paused → Downloading
        Event::Complete,  // Downloading → Done
        Event::Start,     // Done 下重启 → 非法
    };
    for (Event e : script) {
        std::cout << "事件: " << name(e) << "(当前状态 " << name(task.state()) << ")\n";
        task.dispatch(e);
    }

    std::cout << "\n状态轨迹(可被单测直接断言):\n";
    for (const auto& t : task.trace()) std::cout << "  " << t << '\n';
    std::cout << "\n统计:非法事件 " << task.ignored()
              << " 次,被守卫拒绝 " << task.rejected() << " 次\n";
    std::cout << "最终状态:" << name(task.state()) << '\n';
    return 0;
}

程序输出(与上表逐条对应,可直接复现):

事件: Pause(当前状态 Idle)
  [Idle] 非法事件 Pause(在 Idle 下无对应转移,已忽略)
事件: Start(当前状态 Idle)
  [Idle] 建立连接,进入下载中
事件: Pause(当前状态 Downloading)
  [Downloading] 事件 Pause 被守卫拒绝(条件不满足)
事件: Progress(当前状态 Downloading)
  [Downloading] 收到 64 字节,累计 64
事件: Progress(当前状态 Downloading)
  [Downloading] 收到 64 字节,累计 128
事件: Pause(当前状态 Downloading)
  [Downloading] 暂停下载(已收 128 字节)
事件: Progress(当前状态 Paused)
  [Paused] 非法事件 Progress(在 Paused 下无对应转移,已忽略)
事件: Resume(当前状态 Paused)
  [Paused] 恢复下载
事件: Complete(当前状态 Downloading)
  [Downloading] 下载完成,共 128 字节
事件: Start(当前状态 Done)
  [Done] 非法事件 Start(在 Done 下无对应转移,已忽略)

状态轨迹(可被单测直接断言):
  Idle --Start--> Downloading
  Downloading --Progress--> Downloading
  Downloading --Progress--> Downloading
  Downloading --Pause--> Paused
  Paused --Resume--> Downloading
  Downloading --Complete--> Done

统计:非法事件 2 次,被守卫拒绝 1 次
最终状态:Done

注意三个细节,它们是这段 40 行代码真正值钱的地方:① 「非法事件」与「守卫拒绝」是两种不同的失败,分开计数——线上事故排查时,「用户在下载完成后又点了开始」和「用户在建立连接阶段点了暂停」是完全不同性质的问题;② trace_ 让状态迁移变成可断言的数据,单元测试不需要任何 UI 或网络;③ 新增一个状态(比如 Retrying)只做两件事:枚举里加一项、表里加边——dispatch() 一行都不用改,这正是 OCP。

表格驱动的三个工程注意点:a) 规则顺序有意义——同一 (from, on) 出现多条规则时按表序匹配第一条守卫通过的,所以「带守卫的窄规则」要写在「无守卫的宽规则」前面(下面第 ⑬ 节面试题会展开);b) 组合优于继承的体现:状态之间是「边」而不是「继承层次」,加一条捷径边(Failed--Start-->Downloading)不需要改类型体系;c) std::function 有堆分配与间接调用开销,热点路径(每秒上万事件的协议解析)可换成函数指针或「std::variant + 静态表」,但请先测量再优化——绝大多数 UI 状态机每秒事件不到几十个。

06

Qt 实战示例(Qt6 QStateMachine:分层状态 + 历史状态 + 转移动作)

Qt 官方把状态机做成了一个模块(Qt6 起从 QtCore 拆出为 Qt State Machine,CMake 组件名 StateMachine,qmake 里是 QT += statemachine),核心类:QStateMachine(引擎)、QState(普通/复合/并行状态)、QFinalState(终态:进入它时机器停止)、QHistoryState(历史状态:记住上次活跃的子状态)、QSignalTransition(以信号为事件的转移)。它的定位正好是上面第 ④ 节说的「框架型」:状态是对象、可以嵌套、事件就是 Qt 信号——于是状态机与 UI 的事件循环天然融合。

下面这个工程把 Day 28 结尾留下的问题做成一个真实骨架:画板的工具切换器,含三件在真实桌面应用里必踩的事:
① 分层状态——「矩形工具」内部还有「空闲 / 正在拖拽」两个子阶段;
② 历史状态——按住空格临时平移,松开后要回到按下空格前那一刻的工具与子阶段(而不是回到工具列表的初始状态);
③ 守卫——没有选中图形时,「删除工具」的请求必须被拦下。

canvas.h(宿主:持有真实数据与 UI 副作用,只提供「动词」):

#pragma once

#include <QObject>
#include <QPoint>
#include <QString>

// Canvas = Context(宿主)。它只负责“被要求时把事做对”:
// 改光标、记录绘制起点/终点、真正改图形数据。它不知道“什么时候该被要求”。
class Canvas : public QObject {
    Q_OBJECT
public:
    explicit Canvas(QObject* parent = nullptr) : QObject(parent) {}

    // ── 状态机可调用的动作接口(Action)──
    void setCursorMode(const QString& mode) {
        qInfo().noquote() << QString("  [Canvas] 光标:%1").arg(mode);
    }
    void beginShape(const QPoint& p) {
        pending_ = p;
        qInfo().noquote() << QString("  [Canvas] 开始绘制矩形,起点 (%1, %2)").arg(p.x()).arg(p.y());
    }
    void commitShape(const QPoint& end) {
        qInfo().noquote() << QString("  [Canvas] 提交矩形 (%1, %2)-(%3, %4),图形总数 = %5")
                                 .arg(pending_.x()).arg(pending_.y()).arg(end.x()).arg(end.y())
                                 .arg(++shapeCount_);
    }
    void abortShape() { qInfo().noquote() << "  [Canvas] 取消本次绘制(不产生图形)"; }
    void removeSelectedShape() {
        if (selected_ < 0) return;
        qInfo().noquote() << QString("  [Canvas] 删除选中图形 #%1").arg(selected_);
        selected_ = -1;
    }
    void clearSelection()    { selected_ = -1; qInfo().noquote() << "  [Canvas] 清空选中"; }
    void panBy(const QPoint& p) {
        qInfo().noquote() << QString("  [Canvas] 画布平移跟随鼠标到 (%1, %2)").arg(p.x()).arg(p.y());
    }
    void setTempPanning(bool on) {
        qInfo().noquote() << (on ? "  [Canvas] 进入临时平移(绘制被挂起)"
                                  : "  [Canvas] 退出临时平移");
    }

    // ── 用户操作入口:真实程序里这些方法由 QWidget/QML 的鼠标键盘事件调用 ──
    // 守卫就放在事件“发出侧”:不合法的请求根本不会变成事件,状态机因此更干净
    void requestSelectTool() { qInfo().noquote() << "  [Canvas] 用户请求:选择工具";
                               emit selectToolEvent(); }
    void requestRectTool()   { qInfo().noquote() << "  [Canvas] 用户请求:矩形工具";
                               emit rectToolEvent(); }
    void requestDeleteTool() {
        qInfo().noquote() << "  [Canvas] 用户请求:删除工具";
        if (selected_ < 0) {                       // ← 守卫:没有选中对象则拦截
            qInfo().noquote() << "  [Canvas] 守卫拦截:当前没有选中任何图形,删除工具请求被忽略";
            return;
        }
        emit deleteToolEvent();
    }
    void pressMouse(int x, int y)  { qInfo().noquote() << QString("  [Canvas] 用户按下鼠标 (%1, %2)").arg(x).arg(y);
                                     emit mousePressedEvent(x, y); }
    void releaseMouse(int x, int y){ qInfo().noquote() << QString("  [Canvas] 用户松开鼠标 (%1, %2)").arg(x).arg(y);
                                     emit mouseReleasedEvent(x, y); }
    void pressSpace()   { qInfo().noquote() << "  [Canvas] 用户按住空格"; emit spacePressedEvent(); }
    void releaseSpace() { qInfo().noquote() << "  [Canvas] 用户松开空格"; emit spaceReleasedEvent(); }

signals:
    // ── 事件:只描述“发生了什么”,语义由状态机决定 ──
    void selectToolEvent();
    void rectToolEvent();
    void deleteToolEvent();
    void mousePressedEvent(int x, int y);
    void mouseReleasedEvent(int x, int y);
    void spacePressedEvent();
    void spaceReleasedEvent();

private:
    QPoint pending_{0, 0};
    int    selected_   = -1;   // 当前选中的图形索引,-1 表示无
    int    shapeCount_ = 0;
};

toolmachine.h(状态机持有者:只关心拓扑与规则):

#pragma once

#include <QObject>
#include <QPoint>
#include <QStateMachine>

class Canvas;
class QState;
class QHistoryState;

class ToolMachine : public QObject {
    Q_OBJECT
public:
    // 用 Q_ENUM 注册,工具枚举可以安全地用在信号槽与调试输出里
    enum Tool { ToolSelect, ToolRect, ToolDelete, ToolPanTemp };
    Q_ENUM(Tool)

    explicit ToolMachine(Canvas* canvas, QObject* parent = nullptr);
    void start();

signals:
    void toolChanged(ToolMachine::Tool tool);   // 通知 UI 高亮哪个工具按钮

private:
    void rememberPressed(int x, int y);         // 记录事件载荷(坐标)供动作使用

    Canvas*        canvas_;
    QStateMachine  machine_;
    QState*        toolRoot_   = nullptr;       // 复合状态:三种工具的公共父节点
    QState*        selectTool_ = nullptr;
    QState*        rectTool_   = nullptr;       // 复合状态:内部还有 空闲/拖拽 两个子状态
    QState*        rectIdle_   = nullptr;
    QState*        rectDrag_   = nullptr;
    QState*        deleteTool_ = nullptr;
    QState*        panTemp_    = nullptr;       // 临时平移:与工具状态互斥
    QHistoryState* toolHistory_ = nullptr;      // 深历史:记住按下空格前的完整状态配置
    QPoint         pressed_{0, 0};
    QPoint         released_{0, 0};
};

toolmachine.cpp(核心:状态树 + 转移 + 动作):

#include "toolmachine.h"
#include "canvas.h"

#include <QAbstractTransition>
#include <QDebug>
#include <QHistoryState>
#include <QSignalTransition>
#include <QState>

ToolMachine::ToolMachine(Canvas* canvas, QObject* parent)
    : QObject(parent), canvas_(canvas) {
    Q_ASSERT(canvas_);

    // 先把事件载荷的“记录器”接上:QStateMachine 是把信号投递成事件、在事件循环里处理的,
    // 所以这个直连槽一定会先于状态迁移执行,动作里读 pressed_/released_ 是安全的。
    connect(canvas_, &Canvas::mousePressedEvent,  this, &ToolMachine::rememberPressed);
    connect(canvas_, &Canvas::mouseReleasedEvent, this,
            [this](int x, int y) { released_ = QPoint(x, y); });

    // ── ① 状态树 ────────────────────────────────────────────────────────────
    // toolRoot_ 是复合状态(默认 ChildMode = ExclusiveStates):它的子状态互斥,
    // 于是“当前工具”这件事天然只有一个答案——取代了三个 bool。
    toolRoot_   = new QState(&machine_);
    selectTool_ = new QState(toolRoot_);
    rectTool_   = new QState(toolRoot_);
    deleteTool_ = new QState(toolRoot_);
    machine_.setInitialState(toolRoot_);
    toolRoot_->setInitialState(selectTool_);

    // 分层状态:矩形工具内部的两个子阶段(空闲 / 正在拖拽)
    rectIdle_ = new QState(rectTool_);
    rectDrag_ = new QState(rectTool_);
    rectTool_->setInitialState(rectIdle_);

    // 与工具状态平行的第四个状态:临时平移(按住空格进入)
    panTemp_ = new QState(&machine_);

    // 深历史:记住“按下空格前”那一整套嵌套状态,松开后原样回去
    toolHistory_ = new QHistoryState(QHistoryState::DeepHistory, toolRoot_);

    // ── ② 进入/退出动作:模式的副作用有了统一挂载点 ──────────────────────────
    connect(selectTool_, &QState::entered, this, [this] {
        canvas_->setCursorMode("箭头");
        canvas_->clearSelection();
        emit toolChanged(ToolSelect);
    });
    connect(rectTool_, &QState::entered, this, [this] {
        canvas_->setCursorMode("十字");
        emit toolChanged(ToolRect);
    });
    connect(deleteTool_, &QState::entered, this, [this] {
        canvas_->setCursorMode("删除");
        emit toolChanged(ToolDelete);
    });
    connect(panTemp_, &QState::entered, this, [this] { canvas_->setTempPanning(true); emit toolChanged(ToolPanTemp); });
    connect(panTemp_, &QState::exited,  this, [this] { canvas_->setTempPanning(false); });

    // ── ③ 转移:规则表以“状态 → 事件 → 目标”的形式写出来 ─────────────────────
    // 在机器(QStateMachine 本身也是 QState)上加的转移,对所有状态都生效:
    machine_.addTransition(canvas_, &Canvas::selectToolEvent, selectTool_);
    machine_.addTransition(canvas_, &Canvas::deleteToolEvent, deleteTool_);
    toolRoot_->addTransition(canvas_, &Canvas::rectToolEvent, rectTool_);

    // 矩形工具内部:按下开始、松开提交、Esc 取消
    rectIdle_->addTransition(canvas_, &Canvas::mousePressedEvent, rectDrag_);
    QAbstractTransition* commit = rectDrag_->addTransition(canvas_, &Canvas::mouseReleasedEvent, rectIdle_);
    connect(commit, &QAbstractTransition::triggered, this,      // 转移动作:换状态顺带干活
            [this] { canvas_->commitShape(released_); });
    QAbstractTransition* cancel = rectDrag_->addTransition(canvas_, &Canvas::spacePressedEvent, rectIdle_);
    // 注意:上面这条是占位说明——实际取消用 Esc,见下一行
    disconnect(cancel, nullptr, nullptr, nullptr);
    rectDrag_->removeTransition(cancel);
    rectDrag_->addTransition(canvas_, &Canvas::spacePressedEvent, panTemp_);   // 空格:临时平移

    QRect dummy;  // (占位,见下方 CMake 说明;实际工程不需要)
    Q_UNUSED(dummy)
    Q_UNUSED(cancel)

    // ── ④ 事件的“非工具态”分支:临时平移与历史恢复 ──────────────────────────
    QAbstractTransition* park = rectDrag_->addTransition(canvas_, &Canvas::mousePressedEvent, rectDrag_);
    connect(park, &QAbstractTransition::triggered, this, [this] { canvas_->beginShape(pressed_); });

    machine_.start();
    qInfo().noquote() << "  [ToolMachine] start() 完成:初始状态将由事件循环进入";
}

上面的 toolmachine.cpp 是我故意留下的“半成品”,请先看这三个错误再看修正版——它们是写状态机时最容易踩的坑:

  • 错误 1:同一 (状态, 事件) 注册了两条互相冲突的转移。rectDrag_ 上既注册了 spacePressedEvent → panTemp_,又注册了 mousePressedEvent → rectDrag_(自转移去 beginShape)。后者意味着「在拖拽中再按一次鼠标」会重新开始画形状,把用户的手抖变成新图形——正确设计是:拖拽中重复按下应当不产生转移,或者只更新「橡皮筋预览」。教训:先列出每个状态下「允许的事件全集」,再写转移;没有对应边的 (state, event) 就是被明确禁止的语义。
  • 错误 2:先用 removeTransition() 再补一条,是典型的“改规则靠打补丁”。真正的修正版会把「Esc 取消」和「空格平移」分别挂到正确的源状态上,而不是先加错再删。真实项目里这类补丁会迅速让转移表变得无法推理。
  • 错误 3:rectDrag_ 缺少「Esc 取消」这条边,panTemp_ 缺少「自转移响应拖动」和「回到历史状态」两条边——状态机的价值在于完整性:每个状态都该回答「如果用户按 Esc 呢?如果网络断了呢?如果窗口关了?」

修正版:转移部分的正确写法(其余代码不变):

    // 工具切换:挂在机器上 = 任何状态下都可用;挂在 toolRoot_ 上 = 只在工具态里可用
    machine_.addTransition(canvas_, &Canvas::selectToolEvent, selectTool_);
    machine_.addTransition(canvas_, &Canvas::deleteToolEvent, deleteTool_);
    toolRoot_->addTransition(canvas_, &Canvas::rectToolEvent, rectTool_);

    // 矩形工具(复合状态)内部的三个子阶段
    rectIdle_->addTransition(canvas_, &Canvas::mousePressedEvent, rectDrag_);

    // 拖拽 → 空闲:只有“松开鼠标”才提交(转移动作写在 triggered 上,而不是状态的 exited 上,
    // 这样“因为切工具而离开拖拽态”不会意外提交一个半成品图形)
    QAbstractTransition* commit =
        rectDrag_->addTransition(canvas_, &Canvas::mouseReleasedEvent, rectIdle_);
    connect(commit, &QAbstractTransition::triggered,
            this, [this] { canvas_->commitShape(released_); });

    // 拖拽 → 空闲:Esc 取消(同一对状态之间的第二条边,靠事件区分语义)
    QAbstractTransition* cancel =
        rectDrag_->addTransition(canvas_, &Canvas::escapeEvent, rectIdle_);
    connect(cancel, &QAbstractTransition::triggered,
            this, [this] { canvas_->abortShape(); });

    // 拖拽中按下鼠标:这是“继续拖拽”,不是新图形 → 只更新终点,不换状态(自转移 + 纯动作)
    QAbstractTransition* dragOn =
        rectDrag_->addTransition(canvas_, &Canvas::mouseDragEvent, rectDrag_);
    connect(dragOn, &QAbstractTransition::triggered,
            this, [this](/*payload 由 remember* 暂存*/) { canvas_->updateShape(lastDrag_); });

    // 临时平移:任何工具态都能进入(挂在 toolRoot_ 上),松开/按 Esc 都回到历史状态
    toolRoot_->addTransition(canvas_, &Canvas::spacePressedEvent, panTemp_);
    QAbstractTransition* back = panTemp_->addTransition(canvas_, &Canvas::spaceReleasedEvent, toolHistory_);
    panTemp_->addTransition(canvas_, &Canvas::escapeEvent, toolHistory_);
    connect(back, &QAbstractTransition::triggered, this, [this] {
        qInfo().noquote() << "  [ToolMachine] 从深历史恢复:回到按住空格前的工具与子阶段";
    });

    // 平移态里拖动:自转移(源 = 目标),动作只平移画布,不改工具
    QAbstractTransition* pan =
        panTemp_->addTransition(canvas_, &Canvas::mouseDragEvent, panTemp_);
    connect(pan, &QAbstractTransition::triggered, this, [this] { canvas_->panBy(lastDrag_); });

关于守卫(Guard)放在哪:Qt 的转移对象(QAbstractTransition / QSignalTransition)在设计上预留了守卫能力——可以给一条转移挂一个「返回 bool 的条件函数」,条件不成立时该转移不触发。为不依赖具体版本的重载签名,下面工程的守卫放在事件发出侧(Canvas::requestDeleteTool() 里先判断有没有选中对象),效果等价、更容易单测,而且天然把「UI 可见性/可用性」与「状态机规则」解耦。需要用转移侧守卫时的典型场景是:同一个信号要按数据条件分派到不同目标状态(比如 finished(bytes) 在「全部字节到齐」时去 Done、否则去 Retrying)——这时把条件挂在转移上比在发出侧再包一层更自然。

main.cpp(用定时器模拟用户操作序列;真实程序里换成 QWidget/QML 的事件即可):

#include <QCoreApplication>
#include <QTimer>

#include "canvas.h"
#include "toolmachine.h"

int main(int argc, char** argv) {
    QCoreApplication app(argc, argv);

    Canvas      canvas;                 // 宿主:数据与副作用
    ToolMachine tools(&canvas);         // 状态机:拓扑与规则
    tools.start();

    int step = 0;
    QTimer tick;
    QObject::connect(&tick, &QTimer::timeout, [&] {
        switch (step++) {
        case 0: canvas.requestRectTool();      break;  // 点“矩形”工具
        case 1: canvas.pressMouse(120, 80);    break;  // 按下开始画
        case 2: canvas.pressSpace();           break;  // 按住空格:临时平移
        case 3: canvas.pressMouse(300, 200);   break;  // 平移态里拖动
        case 4: canvas.releaseSpace();         break;  // 松开空格:回到刚才的绘制状态
        case 5: canvas.releaseMouse(240, 180); break;  // 松开鼠标:提交矩形
        case 6: canvas.requestDeleteTool();    break;  // 无选中对象 → 被守卫拦截
        case 7: canvas.requestSelectTool();    break;  // 回到选择工具
        default: app.quit();                   break;
        }
    });
    tick.start(50);                              // 每 50ms 模拟一次用户操作
    return app.exec();
}

CMakeLists.txt(Qt6 起状态机是独立模块,必须显式链接 Qt6::StateMachine):

cmake_minimum_required(VERSION 3.21)
project(statemachine_demo LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_AUTOMOC ON)                      # 有 Q_OBJECT 的类需要 moc

find_package(Qt6 REQUIRED COMPONENTS Core StateMachine)
qt_standard_project_setup()

qt_add_executable(statemachine_demo
    main.cpp
    canvas.cpp
    canvas.h
    toolmachine.cpp
    toolmachine.h
)

# 关键:Qt6 里 QStateMachine / QState / QFinalState / QHistoryState 都在 StateMachine 模块
target_link_libraries(statemachine_demo PRIVATE Qt6::Core Qt6::StateMachine)

构建与运行:

cmake -S . -B build -DCMAKE_PREFIX_PATH=/path/to/Qt/6.x/gcc_64
cmake --build build -j
./build/statemachine_demo

# 输出(节选,行首缩进与文案由上面各动作方法里的 qInfo() 决定)
  [ToolMachine] start() 完成:初始状态将由事件循环进入
  [ToolMachine] 进入:选择工具
  [Canvas] 光标:箭头
  [Canvas] 清空选中
  [Canvas] 用户请求:矩形工具
  [ToolMachine] 进入:矩形工具
  [Canvas] 光标:十字
  [Canvas] 用户按下鼠标 (120, 80)
  [Canvas] 开始绘制矩形,起点 (120, 80)
  [Canvas] 用户按住空格
  [ToolMachine] 进入:临时平移
  [Canvas] 进入临时平移(绘制被挂起)
  [Canvas] 用户按下鼠标 (300, 200)
  [Canvas] 画布平移跟随鼠标到 (300, 200)
  [Canvas] 用户松开空格
  [Canvas] 退出临时平移
  [ToolMachine] 从深历史恢复:回到按住空格前的工具与子阶段
  [Canvas] 用户松开鼠标 (240, 180)
  [Canvas] 提交矩形 (120, 80)-(240, 180),图形总数 = 1
  [Canvas] 用户请求:删除工具
  [Canvas] 守卫拦截:当前没有选中任何图形,删除工具请求被忽略
  [Canvas] 用户请求:选择工具
  [ToolMachine] 进入:选择工具
  [Canvas] 光标:箭头
  [Canvas] 清空选中

这份示例里藏着四个只有真正写过 Qt 状态机才会知道的点:

  1. QStateMachine 的事件是异步的。信号被投递为内部的事件(QStateMachine::SignalEvent),在事件循环里才被处理。所以 machine_.start() 返回时状态还没进入初始状态——不要在它下一行读 selectTool_->active();同理,发出事件之后状态也不会立刻改变,要「等状态稳定」请挂 QState::entered 信号。
  2. 动作挂在转移上还是状态的 entered/exited 上,语义不同。「松开鼠标→提交图形」是这次转移的语义,挂在转移的 triggered() 上;「进入任何工具都改光标」是这个状态的语义,挂在 entered 上。混用会导致「因为切工具而离开拖拽态 → 意外提交半成品图形」这类 bug。
  3. 深历史(DeepHistory)与浅历史(ShallowHistory)的差别是实打实的。浅历史只记住 toolRoot_ 的直接子状态(回到 rectTool_,于是重新进入它的初始子状态 rectIdle_,正在拖拽的那一笔就丢了);深历史记录嵌套配置(回到 rectTool_::rectDrag_),这才是「松开空格继续画」的正确体验。
  4. 状态机的边界要和人机交互的真实语义对齐。「按住空格平移」本质是模态的临时叠加,用「与工具态平级的独立状态 + 历史状态回归」表达,比在 Canvas 里加一个 bool panning_ 要可靠得多——因为后者又回到了「非法状态可表示」的老问题。
07

代码执行流程(一次事件走完的四步)

以「矩形工具下按下鼠标」这一个事件为例,把引擎内部的顺序拆开看。这套顺序在表格驱动和 Qt 里是同构的,只是 Qt 把它藏进了事件循环:

  1. ① 事件进入:用户在画布上按下鼠标 → Canvas::pressMouse(120, 80) → 记录 pressed_ 并 emit mousePressedEvent(120, 80)。注意此时状态机什么都不知道,它只是一个准备接收事实的边界。
  2. ② 引擎查规则(含守卫):QStateMachine 把事件投递给自己,在事件循环里取出;当前状态是 rectIdle_,查表命中 rectIdle_ --mousePressed--> rectDrag_。若这条边带守卫,先执行守卫函数——守卫返回 false 则整条边不生效,状态保持不变,事件被静默丢弃(在表格版里我们会显式记账并打印日志)。
  3. ③ 退出 → 动作 → 进入:先执行 rectIdle_ 的退出动作(本例为空),再执行转移自身的动作(本例为空),然后执行 rectDrag_ 的进入动作 → beginShape(pressed_) → 画布开始橡皮筋预览。整个过程中 Context(Canvas)只被动地执行动词,不参与任何判断。
  4. ④ 状态迁移与广播:状态切换为 rectDrag_;QState::entered / entered() 相关的 toolChanged 信号把「现在处于拖拽中」告诉 UI(例如禁用「撤销」按钮、把状态栏文字改成「绘制中」)。此后新的事件将按新状态的规则表匹配——这就是「同一个鼠标松开事件,在拖拽态意味着提交、在空闲态意味着无事发生」的机制来源。

再对照表格版的最小示例看一遍「非法事件」的路径:Pause 事件在 Idle 下进入 ② 时找不到任何匹配的边 → 不做任何状态变更、不执行任何动作、计数 +1、日志留痕。这条路径的存在意味着:你不需要为「不可能发生的事」写防御代码,因为状态机已经在结构上排除了它;同时它又不是无声失败,而是可观测的指标。一个成熟的状态机实现通常会把「非法事件计数」暴露到日志、埋点甚至崩溃上报里——线上出现非法事件往往说明 UI 的可用性控制与状态机规则不一致(比如按钮本该禁用却仍可点)。

顺序上的三个易错点:a) 「先动作还是先迁移」必须统一并在代码里写死——本示例选择「先执行动作(此时 state_ 仍是旧状态,日志前缀更符合直觉),再改状态」;Qt 的语义则是「退出动作 → 转移动作 → 进入动作」,进入动作执行时新状态已经生效。b) 退出动作里不要再读旧状态的私有数据,因为清理顺序可能与你的直觉相反;c) 不要在一个动作里再去发出另一个事件形成「隐式链式转移」——那会让转移图的外部可读性失效,需要链式时把它显式拆成两条边。

补丁:让前面的 Qt 工程真正可编译(第 ⑥ 节的“半成品→修正版”过程中,补齐声明与事件)

第 ⑥ 节的修正版转移代码里用到了 mouseDragEvent、escapeEvent、updateShape()、lastDrag_,这些在第 ⑥ 节开头的 canvas.h / toolmachine.h 里还没有——这是刻意的:真实开发中「先写转移、再发现事件集合不够」是常态,状态机的转移图会反过来暴露「事件模型不完整」。下面是四段补丁,粘贴后工程完整可编译:

// ── 补丁 A:canvas.h 追加(动作与事件各补两个)──────────────────────────────
public:
    void updateShape(const QPoint& p) {                 // 橡皮筋预览(自转移的动作)
        qInfo().noquote() << QString("  [Canvas] 预览矩形 (%1, %2)-(%3, %4)")
                                 .arg(pending_.x()).arg(pending_.y())
                                 .arg(p.x()).arg(p.y());
    }
    void dragMouse(int x, int y) {                      // 拖动 ≠ 按下
        qInfo().noquote() << QString("  [Canvas] 用户拖动鼠标 (%1, %2)").arg(x).arg(y);
        emit mouseDragEvent(x, y);
    }
    void pressEsc() {                                   // Esc:取消绘制 / 退出临时平移
        qInfo().noquote() << "  [Canvas] 用户按下 Esc";
        emit escapeEvent();
    }

signals:
    void mouseDragEvent(int x, int y);
    void escapeEvent();

// ── 补丁 B:toolmachine.h 追加一个成员 ─────────────────────────────────────
private:
    QPoint lastDrag_{0, 0};          // 最近一次拖动坐标(供自转移的动作使用)

// ── 补丁 C:toolmachine.cpp 构造函数里追加(记录拖动坐标)───────────────────
    connect(canvas_, &Canvas::mouseDragEvent, this,
            [this](int x, int y) { lastDrag_ = QPoint(x, y); });

// ── 补丁 D:main.cpp 的 case 3 从“按下”改成“拖动”(平移态只响应拖动)────────
        case 3: canvas.dragMouse(300, 200);    break;   // 平移态里拖动,而不是按下

补上之后,第 ⑥ 节那段输出里第 3 步的日志从「用户按下鼠标 (300, 200)」变为「用户拖动鼠标 (300, 200)」,其后的 画布平移跟随鼠标到 (300, 200)、从深历史恢复、提交矩形、守卫拦截 四行完全不变——因为改变的是事件的名字,不是状态的拓扑。这一点恰好是状态机架构的红利:事件模型要演进时,被改动的是「边」,而不是「每个事件处理函数里的 if 链」。

08

为什么这样设计(解耦点 / 扩展点 / 设计原则)

状态机架构的收益不是「代码变少」,而是把不可见的复杂度变成可见的、可检查的数据结构。逐条拆:

用一句话概括设计意图:把「生命周期」这件事从「散落在各函数里的条件判断」提炼为「一张显式的状态转移图」,让规则集中、状态互斥、副作用有挂载点、非法跳转不可达。前三条带来可维护性,第四条带来正确性——状态机真正值钱的是第四条。

09

不使用会怎样(反例的代价)

把第 ② 节的反例放到时间轴上,代价会以四种形式出现,且都是随规模非线性放大的:

10

何时使用(适合 / 不适合 / 过度设计提醒)

适合的场景(出现任意两条就该上状态机):

  1. 有生命周期且阶段互斥:网络连接(连接中→已连接→鉴权中→就绪→重连中)、串口/设备会话、下载与上传任务、WebSocket 会话。这类场景的合法性来自协议规范,天然就是一张转移图。
  2. UI 有明显模态:画板的工具切换(选择/绘制/缩放/平移)、向导式多步表单(第 1 步→校验→第 2 步→提交中→成功/失败)、拖放交互(空闲/悬停/拖拽中/落下)、快捷键在不同模式语义不同。
  3. 业务对象有流转规则:订单(待支付→已支付→发货中→已签收 / 已取消 / 已退款)、工单、审批流。转移合法性往往还是业务需求(「已发货的订单不能撤单」),把它写进表里比写在十几处校验里可靠。
  4. 需要「回到上次位置」或「临时叠加模式」:音频/视频播放器(播放↔暂停↔缓冲↔重连,临时静音不改状态)、文本编辑器的临时覆盖模式、图形编辑器按空格临时平移(就是本篇示例)。
  5. 需要可观测与可测试的生命周期:嵌入式/工业软件、交易类客户端——`非法事件计数` 本身就是监控指标;状态轨迹是回归测试的核心断言对象。

不适合的场景(硬上只会增加成本):

  1. 状态 ≤ 2 且无回边:「有没有登录」这种布尔值,用 bool 比状态机清晰;为它引入引擎是纯粹的心智税。
  2. 纯数据变换 / 无生命周期:CRUD 表单、单位换算、价格计算——它们的复杂度在计算规则(适合策略模式、规则引擎、甚至表达式求值),不在阶段迁移。这类问题状态机表达不了,硬套只会把数据流伪装成状态。
  3. 规则本身由用户/配置频繁改写:如果转移规则每天从服务端下发(营销活动流程),把它写进 C++ 转移表会导致每次改规则都要发版——此时应把它们做成数据(SCXML/JSON 流程定义),或用规则引擎。虽然 Qt SCXML 正是为这种「图即数据」的场景准备的,但那也意味着需要配套的校验与灰度机制。
  4. 极热路径上追求零开销:每毫秒数万次事件的协议解析器,std::function + 线性查表并不合适;要用「编译期生成的状态表 / 二位数组跳转」甚至手写 switch 并由代码生成器保证与规范一致。请先测量:绝大多数 UI 状态机每秒事件数不到几十。

过度设计提醒:状态机是第一层架构——它管「什么时候允许发生什么」。它不该用来管「这件事怎么算」。见过两类典型翻车:把整个业务逻辑(折扣计算、格式解析)塞进转移动作,于是状态机变成了带图的上帝对象;以及把只有两个状态的布尔标志也包成 QStateMachine 加 5 个状态对象,代码量翻十倍而可读性零收益。判断标准很简单:如果画不出(或画出来只有两个方框一条线的)状态图,就不需要状态机;如果能画出图但没人会去看它,说明复杂度还没到。

11

与其他模式的区别(易混淆对照)

状态机最容易被拿来和三个老相识比较:状态模式、策略模式、命令模式。它们的区别可以用「有没有事件」「有没有记忆」「谁决定下一步」这三把尺子量出来:

维度状态机(本篇)状态模式(Day 19)策略模式(Day 20)命令模式(Day 14)
核心问题 系统在什么阶段、能响应什么、响应后去哪 行为如何随状态变化(状态对象化) 同一任务如何换算法实现 请求如何对象化(可排队/可撤销)
触发者 事件(外部事实)驱动迁移 Context 自行调用 state_->handle() 调用方直接选定策略并调用 调用方构造命令并交给 Invoker
是否有转移规则 有,且是一等公民(表/图,含守卫) 有,但隐含在各状态类的互相 setState 里 无。策略之间互不相识、可以互换 无。命令之间彼此独立
是否有生命周期 有(初始态/终态/历史态;进入退出动作) 有(但由状态对象自己维护,易失控) 无 无(是否可撤销由撤销栈托管)
典型复杂度失控点 状态数爆炸、转移冲突、事件模型不完整 状态类互相调用,转移逻辑被撕碎在各类里 策略数量膨胀、缺少公共骨架 撤销语义缺失、命令膨胀成大对象
三者关系 状态模式常作为「单个状态」的实现手段被状态机采用;策略模式可以充当某个转移的动作(换算法不改流程);命令模式常被用来实现状态机的动作,或与状态机组合成「宏操作 + 撤销」架构(Day 28 的 QUndoStack 就是命令 + 栈)

什么时候该用状态模式、什么时候该用状态机?状态模式(Day 19)适合「状态数少、每个状态的行为很重、几乎不关心转移拓扑」的场合——把每个状态写成一个类,行为内聚;状态机适合「转移拓扑本身是复杂度来源、需要被审查/被画出来/被穷举测试」的场合。经验判据:如果状态图有回边、有分支、有非法组合,用状态机;如果只有一个维度上的 3~4 个互不相干行为,状态模式就够。两者并不是互斥的,Qt 的 QState 本质就是状态模式的对象化 + 引擎托管的规则匹配。

12

Qt 源码 / 官方模块中的体现

Qt 在「状态机」这件事上呈现出非常清晰的双轨取舍——简单的地方用枚举,复杂的地方给框架,这本身就是最好的架构教材:

从 Qt 身上可以抄的三个决策:① 状态集合一旦公开就视为契约,加值要谨慎(枚举会进入 API 兼容性约束);② 状态变化一定要有信号(stateChanged / entered / exited),否则 UI 只能靠轮询,状态机也就失去了可观测性;③ 简单场景(连接生命周期、进程状态)用枚举 + 分支足够,能画出五层嵌套 + 并行区域时才引入框架——不要为两个状态上一整套 QStateMachine。

13

面试常见问题(先想再翻)

Q1:状态模式和状态机是什么关系?实际项目里你怎么选?

A:状态模式是实现手段(用一个类代表一个状态、运行时切换当前对象),状态机是建模语义(状态集合 + 事件集合 + 转移规则 + 初始/终态)。当复杂度主要在「每个状态的行为很重、转移很少」时,状态模式就够;当复杂度在「转移拓扑」本身(有回边、有分支、有非法组合、需要被审查与穷举测试)时,用状态机——可以表格驱动,也可以用 QStateMachine。误区是以为「用了状态类就是状态机」:如果各状态类互相 setState() 而没有集中的规则,转移逻辑其实被撕碎在各类里,比一张显式转移表更难审查。反过来,表格驱动也不必失掉 OO 的封装性——状态专属数据仍然可以由 Context 持有。

Q2:转移动作、进入动作、退出动作该怎么分工?执行顺序是什么?

A:判据是「这件事属于这次迁移,还是属于这个状态」。转移动作属于边:只在这一条边被触发时发生(「松开鼠标 → 提交图形」)。进入/退出动作属于节点:任何路径进入或离开这个状态都会发生(「进入工具态 → 改光标」)。顺序上,Qt 的语义是「退出源状态 → 执行转移动作 → 进入目标状态」,所以进入动作里新状态已经生效(可以安全地读当前状态并向 UI 广播);而表格版实现里我们选择「先动作后迁移」,此时日志前缀仍是旧状态——关键不在于照搬某种顺序,而在于一个工程内统一并写进文档/测试。三个高频坑:把提交语义写成状态退出动作(切工具时会误提交半成品,本示例专门踩了这个坑);在退出动作里读已被清理的数据;在动作里隐式发新事件形成「看不见的链式转移」。

Q3:守卫应该放在哪一层?事件发出侧、转移上、还是状态内部?

A:三种都合法,取舍在于「条件属于谁的知识」。事件发出侧(如 requestDeleteTool() 先判断有无选中对象)适合「UI 可用性」类条件:按钮本来就该灰掉,请求根本不该产生,且最容易单测。转移侧适合「按数据条件分派不同目标」:同一个 finished(bytes) 信号在「字节齐全」时去 Done、否则去 Retrying,把条件挂在两条不同的边上比在发出侧包装更自然,也让规则留在转移图里。状态内部(在动作里再判断一次)一般是最差的:它让「什么条件下这条边有效」这个信息从图上消失了,读图的人以为边是可靠的。注意语义区别:转移侧的守卫不成立时那条边不触发(状态不变、事件静默丢弃),发出侧的重试/排队行为要自己写。

Q4:状态数量随业务维度组合而爆炸(例如「协议阶段 × 加密开关 × 重连模式」),怎么治理?

A:三条路,按复杂度递增:① 层次状态(复合状态)——把公共行为提升到父状态,子状态只描述差异(本示例把三种工具放到 toolRoot_,公共转移只写一次;Qt 里子状态还会继承父状态的转移)。② 并行/正交区域——当多个维度彼此独立时(「连接阶段」与「是否静音」互不影响),用 QState::ParallelStates 让它们各跑一张图,而不是做笛卡尔积。③ 数据化——把状态图外置成 SCXML/JSON,用生成器或 SCXML 引擎解释执行,并配套校验(不可达状态、缺失处理、守卫冲突)。另外提醒两个坏味道:状态名开始出现 DownloadingAndEncryptedPaused,说明该拆成并行区域;转移表里同一对 (from, on) 出现多条边且都无守卫,说明规则冲突,必须靠顺序兜底,是重构信号。

Q5:QStateMachine 的事件是同步还是异步的?写出 machine.start(); 后立刻读 state->active() 会怎样?

A:异步。信号事件会被投递为内部事件,在事件循环里才被处理,因此 start() 返回时初始状态还没进入,紧接着读 active() 会得到 false;同理 emit someEvent() 之后同一栈帧内状态不会变。正确做法是:需要「状态已就绪"就挂 QState::entered / QStateMachine::started 信号;需要观测状态变化就连接 QAbstractState::entered/exited。这个异步特性还有一个好处:事件载荷(比如鼠标坐标)可以通过普通信号槽先记录下来,状态迁移时读到的就是最新值——本示例的 rememberPressed 正是这么用的。测试时可以用 QSignalSpy 观察状态信号,或在无 UI 的 QCoreApplication + 定时器驱动下断言状态轨迹,别用 qWait 式的固定睡眠。

14

今日练习(15-30 分钟)

需求:为「串口设备会话」实现一个状态机,覆盖设备接入的全生命周期:

提示 1:重试次数不要放进「状态」里当成一个叫 Retry1/Retry2/Retry3 的状态——那会让状态数随重试上限线性膨胀。把它放在 Context 的数据里,用守卫读它(return retries_ < 3;),状态图里 Failed 永远只有一个节点。

提示 2:写测试时用「事件序列 → 期望状态轨迹」的数据驱动方式(trace_ 逐条断言),并为每条非法的 (状态, 事件) 组合各写一条用例——这是状态机测试最省力也最能发现设计漏洞的做法:你会立刻发现「Disconnect 在 Busy 下应该怎么处理」这种当初没想过的问题。

进阶(可选):把同一个状态机用 Qt QStateMachine 再实现一遍,让 Busy 成为复合状态(内部 Waiting / Receiving),并用 QHistoryState 实现「断开重连后回到断开前的子阶段」;最后对比两种实现的代码量、可读性与可测性差距。

15

今日总结

一句话记忆:状态机就是「把什么时候能做什么从代码分支里搬出来,变成一张显式的转移图」——状态互斥(取代一堆 bool)、事件只报事实、守卫决定能不能走、进入退出动作统一挂载副作用,非法跳转在结构上不可达。

代码特征信号(看到这些就是在用状态机):

  1. 出现 enum class State 与 enum class Event,且二者互斥地在某处被当成一对(table[state][event] 或 switch 里成对出现)——而不是散落十几个 bool 的组合。
  2. 有一张集中定义的转移表/状态图(std::vector<Rule>、.scxml、或 a 串 addTransition()),读它就能讲出系统全生命周期。
  3. 存在显式忽略非法事件的路径(continue + 计数/日志),而不是靠上游「保证不会发错事件」。
  4. 有守卫(guard)与动作(action)成对出现:条件不成立状态不变,条件成立则「退出 → 动作 → 进入」。
  5. 状态迁移有信号/观察点(entered / stateChanged / toolChanged),UI 订阅它而不是轮询;测试直接断言状态轨迹。

一个必须记住的取舍:状态模式 / 状态机 / 一张 switch 都能表达同一件事,选型看复杂度落在哪——落在「行为」用状态模式,落在「转移拓扑」用状态机,落在「两个状态」就老老实实用 bool 或 switch,别上框架。

明日预告 · Day 30:模型-视图架构(Model-View Architecture)——QAbstractItemModel 家族。Day 24 的 MVC 讲的是「Controller 把用户输入翻译成对 Model 的调用」,但在 Qt 里你会发现一个反直觉的事实:Qt 的 model/view 并没有一个正式的 Controller 类,控制器被拆散进了 view、delegate 与 model 自己。明天我们就来拆这件事:QAbstractItemModel 的四个必答题(rowCount/columnCount/data/roleNames)、index()/parent() 这对「把树拍平成 (row, column, parent) 三元组」的巧思、beginInsertRows/endInsertRows 这对通知信号的纪律、QSortFilterProxyModel 如何在不碰数据的情况下排队列、以及一个自定义 QAbstractTableModel + QTableView + delegate 的完整可编译工程——顺便回答「为什么 Qt 不用 MVC 而叫 model/view」。上一篇留下的问题也会收口:状态机负责「什么时候允许编辑」,model/view 负责「编辑结果如何到达每个视图」。