架构模式 有限状态机 FSM 转移表 / 守卫 / 动作 QStateMachine 分层状态与历史状态
一句话定义:状态机架构是指把「系统当前处于什么阶段」抽象成一组互斥的状态,把「外界发生了什么」抽象成一组事件,再显式地写出一张转移规则表(在状态 A 收到事件 E,若守卫条件成立,则跳到状态 B 并执行动作),由一个统一的引擎在运行时完成「收事件 → 查规则 → 执行退出动作 → 迁移 → 执行进入动作」的循环——于是「什么时候能做什么、做了之后到哪里」这件事,第一次变成了一个可以被读、被画、被穷举测试的数据结构,而不是散落在几十个函数里的 if (mode == ...)。
它不是 GoF 23 里的一个类级模式(Day 19 的状态模式才是 OO 层面的实现手段),而是架构级的建模方式:状态模式(Day 19)回答「如何用一个类代表一个状态」,状态机架构回答「整个系统的生命周期与事件规则如何组织、如何被验证、如何与 UI 解耦」。两者是「实现手段」与「建模语义」的关系——状态模式常用于实现状态机里的那个 ConcreteState,而一个状态机也可以完全不写状态类,只用一张转移表 + std::function 就落地。
本篇定位:Day 28 的收尾处我们留了一道自问:「同一个按钮在‘画矩形’模式下按下要画矩形、在‘选择’模式下按下要选中对象、在‘删除’模式下按下要删东西」——这种「行为随模式变化」的复杂度,继续用 if (mode == ...) 铺开会迅速失控。今天把它提升到架构层:模式 = 状态,用户操作 = 事件,模式切换 = 转移,进入/退出模式时该做的事 = 进入/退出动作。同时回答三个工程问题:① 状态和事件多了以后,规则如何不互相踩;② 如何在异步事件循环里保证「发出信号后状态立刻正确」;③ 什么规模该用 QStateMachine,什么规模一张表就够。
先看一段「能跑但没有架构」的写法。这是一个画板控件,用户的每个操作都直接改数据,工具(模式)用一个 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 逐条看:
BadCanvas 同时承担「选择工具的行为」「矩形工具的行为」「删除工具的行为」「光标与按钮联动」「图形数据管理」五件事。setTool()——是「修改」而不是「扩展」;每一次修改都在碰已经被测试过的老代码。ITool 接口,接口里也会被迫塞进 onMousePress/onMouseRelease/onKeyPress/onWheel 等所有事件——大部分工具只关心其中一两个。drawing_——测试只能从 UI 层间接戳进去。把状态与事件显式建模后,上面每一条都会被翻转:规则集中在一张表里(SRP)、加状态只加节点和边(OCP)、引擎只依赖「状态 + 事件 + 动作」抽象(DIP)、每个状态只实现自己在意的事件(ISP)、状态轨迹可以被断言(可测性)。
状态机的思想可以用一句话概括:系统的复杂度不在「有多少功能」,而在「功能之间的先后关系」;把这些关系从代码里抽出来、变成一张图,复杂度就从「不可见」变成「可见」,而可见的复杂度是可以被检查和推演的。
一个标准的状态机由五要素构成:
Idle / Downloading / Paused / Done / Failed。Start / Pause / Resume / Progress / Complete / Fail。QFinalState 表达「整个机器可以停了」。工程实践上还会加三个扩展要素,它们是把「玩具状态机」和「生产级状态机」区分开的关键:
setTool() 里那一堆副作用的直接手段。生活类比:红绿灯。红绿灯的全部行为就是一张极小状态机:状态 {红, 绿, 黄},事件 {倒计时结束},转移只能是 红→绿→黄→红。它最大的价值是「红→黄」这种转移不存在——不是因为程序员忘了写,而是因为转移表里根本没有这条边。真实世界的复杂系统(电梯:空闲→上行→下行→开门→检修;微波炉:待机→设定→加热→暂停;ATM:待机→插卡→验密→交易→退卡)之所以可靠,就是因为它们的合法转移被明确限定过。
为什么用「模式」这个词来类比软件?因为桌面客户端里的「工具模式」与红绿灯完全同构:画板在「选择模式」下的点击叫选中,在「矩形模式」下的点击叫落点——同一个事件,在不同状态下语义不同。这正是 if (mode == ...) 无法优雅表达的东西:模式不是分支的修饰符,它是决定「这个事件现在意味着什么」的上下文。
与 Day 19 状态模式的边界:状态模式把每个状态做成一个实现同一接口的类,运行时切换当前对象——关注「行为如何随状态变化」;状态机架构关注「状态之间的转移拓扑、守卫、生命周期与可验证性」。所以状态机可以:a) 用状态对象实现(= 状态模式);b) 用一张转移表实现(今天的最小示例);c) 用框架实现(Qt 的 QStateMachine / Qt SCXML)。三者是同一个模型的不同实现方式,选哪个取决于「状态数、事件数、是否需要分层/并行、团队可维护性」。
状态机架构的角色比经典的 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
四个角色的职责必须划清楚,否则「状态机」会退化成「多写了一个类」:
beginShape()/commitShape()/setCursorMode()/panBy())。它不知道「什么时候该调用哪个」,只负责「被调用时把这件事做对」。它是唯一有权限触碰真实数据的人。mousePressed、timeout、frameReceived),注意是「事实」而不是「命令」——事件说「发生了什么」,状态机决定「这意味着什么」。(source, event, target, guard?) + action?。它是整个架构里信息密度最高、也最值得集中管理的东西——一张表就是一份可读的系统生命周期文档。两种落地的形态差异(本篇两种都会写):表格驱动型(最小示例)= 状态用 enum,规则用 std::vector<Rule>,动作与守卫是 std::function;优点是零框架依赖、规则一眼看全、单测极简;缺点是状态内部无法自然容纳嵌套子状态。框架型(Qt 实战)= 状态是对象、可以父子嵌套(分层状态)、可以并行、支持历史状态与守卫挂载、事件走信号;优点是能表达复杂拓扑并和 Qt 事件循环天然融合;缺点是心智负担与调试成本更高。工程上常见的成熟做法是:状态 ≤ 8、无嵌套 → 表格;出现「状态里还有阶段」「两套维度并行」「要回到上次位置」→ 框架。
先不引入任何框架,用一张转移表把一个下载任务的生命周期写出来。要点:状态/事件是 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 状态机每秒事件不到几十个。
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 是我故意留下的“半成品”,请先看这三个错误再看修正版——它们是写状态机时最容易踩的坑:
rectDrag_ 上既注册了 spacePressedEvent → panTemp_,又注册了 mousePressedEvent → rectDrag_(自转移去 beginShape)。后者意味着「在拖拽中再按一次鼠标」会重新开始画形状,把用户的手抖变成新图形——正确设计是:拖拽中重复按下应当不产生转移,或者只更新「橡皮筋预览」。教训:先列出每个状态下「允许的事件全集」,再写转移;没有对应边的 (state, event) 就是被明确禁止的语义。removeTransition() 再补一条,是典型的“改规则靠打补丁”。真正的修正版会把「Esc 取消」和「空格平移」分别挂到正确的源状态上,而不是先加错再删。真实项目里这类补丁会迅速让转移表变得无法推理。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 状态机才会知道的点:
QStateMachine::SignalEvent),在事件循环里才被处理。所以 machine_.start() 返回时状态还没进入初始状态——不要在它下一行读 selectTool_->active();同理,发出事件之后状态也不会立刻改变,要「等状态稳定」请挂 QState::entered 信号。triggered() 上;「进入任何工具都改光标」是这个状态的语义,挂在 entered 上。混用会导致「因为切工具而离开拖拽态 → 意外提交半成品图形」这类 bug。rectTool_,于是重新进入它的初始子状态 rectIdle_,正在拖拽的那一笔就丢了);深历史记录嵌套配置(回到 rectTool_::rectDrag_),这才是「松开空格继续画」的正确体验。bool panning_ 要可靠得多——因为后者又回到了「非法状态可表示」的老问题。以「矩形工具下按下鼠标」这一个事件为例,把引擎内部的顺序拆开看。这套顺序在表格驱动和 Qt 里是同构的,只是 Qt 把它藏进了事件循环:
Canvas::pressMouse(120, 80) → 记录 pressed_ 并 emit mousePressedEvent(120, 80)。注意此时状态机什么都不知道,它只是一个准备接收事实的边界。rectIdle_,查表命中 rectIdle_ --mousePressed--> rectDrag_。若这条边带守卫,先执行守卫函数——守卫返回 false 则整条边不生效,状态保持不变,事件被静默丢弃(在表格版里我们会显式记账并打印日志)。rectIdle_ 的退出动作(本例为空),再执行转移自身的动作(本例为空),然后执行 rectDrag_ 的进入动作 → beginShape(pressed_) → 画布开始橡皮筋预览。整个过程中 Context(Canvas)只被动地执行动词,不参与任何判断。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 链」。
状态机架构的收益不是「代码变少」,而是把不可见的复杂度变成可见的、可检查的数据结构。逐条拆:
mousePressed、timeout、frameReceived),「这意味着什么」由状态机决定。于是同一个 mousePressed 在四个状态里有四种含义,而事件源侧一行都不需要判断——这是 UI 层能保持薄的关键。enum + std::function,或 QState + 信号),而不是具体的 QWidget、具体协议、具体数据库。因此同一套状态机可以驱动 Qt Widgets、QML 甚至无 UI 的守护进程。toolRoot_ 上(一条边覆盖三个状态),而不是给三个状态类各写一份或抽出继承链——分层状态是「把公共转移提升到父节点」的直接机制,这比继承更灵活:子状态可以随时覆盖父节点的边。继承表达 is-a,状态嵌套表达 phase-of,语义精确得多。trace_ 是纯数据,可以直接断言「事件序列 E1..En 之后状态为 X、轨迹为 Y」;Qt 版可以脱离 UI 单独 start() 机器并用信号(QSignalSpy)观察 entered。非法状态在结构上不可达,因此不必为「不可能的组合」写防御分支,测试用例数从 2^k 组合降回 N 个状态 × M 个事件。用一句话概括设计意图:把「生命周期」这件事从「散落在各函数里的条件判断」提炼为「一张显式的状态转移图」,让规则集中、状态互斥、副作用有挂载点、非法跳转不可达。前三条带来可维护性,第四条带来正确性——状态机真正值钱的是第四条。
把第 ② 节的反例放到时间轴上,代价会以四种形式出现,且都是随规模非线性放大的:
drawing_ == true && tool_ == Select、paused_ == true && !connected_ 这类组合在类型层面完全合法。它们通常由「异步回调晚到」产生(网络回调在用户已切走工具后才到达),表现为间歇性、难以复现,日志里只留下一个莫名其妙的行为。setTool(),于是 setTool() 成了第二个上帝函数,而且它必须知道每个模式的全部副作用——加一个模式就要改它一次。更糟的是「离开模式时该清理什么」经常被忘掉(比如离开矩形模式没有停止预览定时器)。适合的场景(出现任意两条就该上状态机):
不适合的场景(硬上只会增加成本):
bool 比状态机清晰;为它引入引擎是纯粹的心智税。std::function + 线性查表并不合适;要用「编译期生成的状态表 / 二位数组跳转」甚至手写 switch 并由代码生成器保证与规范一致。请先测量:绝大多数 UI 状态机每秒事件数不到几十。过度设计提醒:状态机是第一层架构——它管「什么时候允许发生什么」。它不该用来管「这件事怎么算」。见过两类典型翻车:把整个业务逻辑(折扣计算、格式解析)塞进转移动作,于是状态机变成了带图的上帝对象;以及把只有两个状态的布尔标志也包成 QStateMachine 加 5 个状态对象,代码量翻十倍而可读性零收益。判断标准很简单:如果画不出(或画出来只有两个方框一条线的)状态图,就不需要状态机;如果能画出图但没人会去看它,说明复杂度还没到。
状态机最容易被拿来和三个老相识比较:状态模式、策略模式、命令模式。它们的区别可以用「有没有事件」「有没有记忆」「谁决定下一步」这三把尺子量出来:
| 维度 | 状态机(本篇) | 状态模式(Day 19) | 策略模式(Day 20) | 命令模式(Day 14) |
|---|---|---|---|---|
| 核心问题 | 系统在什么阶段、能响应什么、响应后去哪 | 行为如何随状态变化(状态对象化) | 同一任务如何换算法实现 | 请求如何对象化(可排队/可撤销) |
| 触发者 | 事件(外部事实)驱动迁移 | Context 自行调用 state_->handle() |
调用方直接选定策略并调用 | 调用方构造命令并交给 Invoker |
| 是否有转移规则 | 有,且是一等公民(表/图,含守卫) | 有,但隐含在各状态类的互相 setState 里 |
无。策略之间互不相识、可以互换 | 无。命令之间彼此独立 |
| 是否有生命周期 | 有(初始态/终态/历史态;进入退出动作) | 有(但由状态对象自己维护,易失控) | 无 | 无(是否可撤销由撤销栈托管) |
| 典型复杂度失控点 | 状态数爆炸、转移冲突、事件模型不完整 | 状态类互相调用,转移逻辑被撕碎在各类里 | 策略数量膨胀、缺少公共骨架 | 撤销语义缺失、命令膨胀成大对象 |
| 三者关系 | 状态模式常作为「单个状态」的实现手段被状态机采用;策略模式可以充当某个转移的动作(换算法不改流程);命令模式常被用来实现状态机的动作,或与状态机组合成「宏操作 + 撤销」架构(Day 28 的 QUndoStack 就是命令 + 栈) | |||
什么时候该用状态模式、什么时候该用状态机?状态模式(Day 19)适合「状态数少、每个状态的行为很重、几乎不关心转移拓扑」的场合——把每个状态写成一个类,行为内聚;状态机适合「转移拓扑本身是复杂度来源、需要被审查/被画出来/被穷举测试」的场合。经验判据:如果状态图有回边、有分支、有非法组合,用状态机;如果只有一个维度上的 3~4 个互不相干行为,状态模式就够。两者并不是互斥的,Qt 的 QState 本质就是状态模式的对象化 + 引擎托管的规则匹配。
Qt 在「状态机」这件事上呈现出非常清晰的双轨取舍——简单的地方用枚举,复杂的地方给框架,这本身就是最好的架构教材:
QStateMachine、QState(可嵌套成复合状态、可设为并行状态)、QFinalState、QHistoryState、QSignalTransition。Qt6 起该模块从 QtCore 独立出来,CMake 里是 find_package(Qt6 REQUIRED COMPONENTS StateMachine) + Qt6::StateMachine,qmake 里是 QT += statemachine;QML 侧对应的类型通过 import QtQml.StateMachine 引入。用它可以把动画、UI、异步流程串成一张图,而不是靠 flag 拼装。QScxmlStateMachine(fromFile() / fromData()),直接执行符合 W3C SCXML 标准的状态图文件——状态、事件、守卫、动作全部写在 .scxml 里,C++ 侧只负责投递事件与响应状态变化。这是「用数据描述流程」的官方案例,也是第 ⑩ 节里「规则频繁变化」场景的标准答案。State / Transition(Item.states、state、StateGroup)就是同一套模型在 QML 里的投影:默认状态 ""、具名状态、以及状态之间的属性差异 + 过渡动画。它把「UI 的形态随状态变化」写成了声明式规则——和本篇「同一事件在不同状态下语义不同」是同一个思想,只是粒度落在属性上。QAbstractSocket::SocketState(UnconnectedState / HostLookupState / ConnectingState / ConnectedState / BoundState / ListeningState / ClosingState,配套 stateChanged() 信号)、QProcess::ProcessState(NotRunning / Starting / Running)、QAbstractAnimation::State(Stopped / Paused / Running)、播放类接口的 playback state(StoppedState / PlayingState / PausedState)。这些类的状态集合都是公开 API,其内部实现(随版本演进)就是在状态字段 + 分支/事件回调之间推进——「状态集合是公开的、转移规则是内部的」这一设计,让调用方能可靠地观察状态,又不需要理解内部转移全貌。这给我们一个重要启示:不是所有状态机都需要框架,但所有有生命周期的类都该把状态做成一个显式、公开、可观察的概念。从 Qt 身上可以抄的三个决策:① 状态集合一旦公开就视为契约,加值要谨慎(枚举会进入 API 兼容性约束);② 状态变化一定要有信号(stateChanged / entered / exited),否则 UI 只能靠轮询,状态机也就失去了可观测性;③ 简单场景(连接生命周期、进程状态)用枚举 + 分支足够,能画出五层嵌套 + 并行区域时才引入框架——不要为两个状态上一整套 QStateMachine。
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 式的固定睡眠。
需求:为「串口设备会话」实现一个状态机,覆盖设备接入的全生命周期:
Disconnected(空闲)、Connecting(正在打开串口)、Handshaking(等待设备响应握手)、Online(可收发指令)、Busy(正在执行一条耗时指令)、Failed(失败但可重试)。Connect、Opened、HandshakeOk、SendCmd、CmdDone、Timeout、Disconnect。Online 下 SendCmd 合法(Busy 下应被忽略,因为设备不允许并发指令);Online 下 SendCmd → Busy;CmdDone / Timeout → 回到 Online;Connect 在 Handshaking 超时进入 Failed,而 Failed 下 Connect 允许重试,但最多 3 次,超过后不再接受 Connect(守卫返回 false)。提示 1:重试次数不要放进「状态」里当成一个叫 Retry1/Retry2/Retry3 的状态——那会让状态数随重试上限线性膨胀。把它放在 Context 的数据里,用守卫读它(return retries_ < 3;),状态图里 Failed 永远只有一个节点。
提示 2:写测试时用「事件序列 → 期望状态轨迹」的数据驱动方式(trace_ 逐条断言),并为每条非法的 (状态, 事件) 组合各写一条用例——这是状态机测试最省力也最能发现设计漏洞的做法:你会立刻发现「Disconnect 在 Busy 下应该怎么处理」这种当初没想过的问题。
进阶(可选):把同一个状态机用 Qt QStateMachine 再实现一遍,让 Busy 成为复合状态(内部 Waiting / Receiving),并用 QHistoryState 实现「断开重连后回到断开前的子阶段」;最后对比两种实现的代码量、可读性与可测性差距。
代码特征信号(看到这些就是在用状态机):
enum class State 与 enum class Event,且二者互斥地在某处被当成一对(table[state][event] 或 switch 里成对出现)——而不是散落十几个 bool 的组合。std::vector<Rule>、.scxml、或 a 串 addTransition()),读它就能讲出系统全生命周期。continue + 计数/日志),而不是靠上游「保证不会发错事件」。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 负责「编辑结果如何到达每个视图」。