第三课:状态图绘制——基本符号、规范与工具实战
好,咱们进入第三课。上一课我们聊了状态机的基本概念,说白了就是“状态+事件+转移”这三板斧。但光有概念不行,你得能把它画出来。状态图,就是状态机的“设计蓝图”。
我见过不少工程师,脑子里想得挺清楚,一画图就乱套。箭头乱飞、状态重叠、事件标注不清……最后代码实现的时候,自己都看不懂自己画的图。嗯,这其实是个大坑。今天我们就来填平它。
3.1 状态图的基本符号
状态图其实没几个符号,但每个符号都有严格的语义。我个人习惯把它们分成三类:状态、转移、特殊标记。
3.1.1 状态的表示
状态用圆角矩形表示。这是最基础的符号。一个状态框里,通常包含三部分信息:
- 状态名称:必须唯一,比如“IDLE”、“RUNNING”。
- 内部活动:进入状态时执行的动作(entry)、在状态中持续执行的动作(do)、退出状态时执行的动作(exit)。
- 内部变量:可选,用于记录状态内部的数据。
举个例子,一个简单的“等待”状态:
┌─────────────────┐
│ WAITING │
│ entry: 启动定时器 │
│ do: 等待超时 │
│ exit: 关闭定时器 │
└─────────────────┘
我在项目中遇到过,有人把“entry”和“do”搞混了。记住:entry只执行一次,do是持续执行。比如电机启动,entry是给使能信号,do是持续输出PWM波。这俩不能写反。
3.1.2 转移的表示
转移用带箭头的实线表示。箭头上要标注触发事件和条件。格式一般是:
事件 [条件] / 动作
比如:
┌─────────┐ button_press [count < 5] / count++ ┌─────────┐
│ IDLE │ ────────────────────────────────────────→ │ ACTIVE │
└─────────┘ └─────────┘
这里要注意几点:
- 事件是必须的,没有事件就不会触发转移。
- 条件是可选的,用方括号括起来。
- 动作是可选的,表示转移过程中执行的操作。
你想想看,如果事件和条件都为空,那这个转移就是“无条件自动转移”。这在某些场景下有用,比如超时后自动跳转。但大多数情况下,我建议你明确标注事件,哪怕是个“timeout”事件。这样读图的人一眼就能看懂触发逻辑。
3.1.3 特殊标记
除了基本的状态和转移,还有几个特殊符号:
- 初始状态:实心圆点,表示状态机的起点。每个状态图有且只有一个初始状态。
- 终止状态:实心圆点外加一个圆圈,表示状态机结束。可以有多个,也可以没有。
- 选择节点:菱形,用于分支判断。相当于代码里的if-else。
- 历史状态:圆圈里加个H,表示记住上次离开时的子状态。这个在嵌套状态机里很常用。
我曾经在一个复杂的通信协议栈里,用历史状态省了一大堆重复的转移逻辑。嗯,这个后面讲嵌套状态机时会细说。
3.2 绘制规范——别让图变成天书
符号学完了,但怎么画才规范?我总结了三条铁律:
3.2.1 布局要清晰
状态图不是电路图,别画得密密麻麻。我建议:
- 状态之间保持适当间距,至少留出两个字符的空白。
- 箭头尽量走直线或直角,避免交叉。如果必须交叉,用“桥接”符号(一个小弧线)表示。
- 把主要流程放在图的中央,异常处理放在边缘。
说白了,就是让读图的人一眼能看出“正常流程”是什么。我见过有人把十几个状态画成一团乱麻,最后自己都找不到入口。这种图,还不如不画。
3.2.2 命名要规范
状态名用大写字母,单词间用下划线。比如:
- ✅
IDLE、DATA_RECEIVING、ERROR_HANDLING - ❌
idle、DataReceiving、error handling
事件名用小写字母,动词开头。比如:
- ✅
button_press、timeout_expired、data_received - ❌
PressButton、Timeout、ReceivedData
为什么要这么严格?因为状态图最终要翻译成代码。命名规范了,代码生成工具才能正确解析。我自己写代码时,状态名和事件名直接就是枚举类型的定义,完全一一对应。
3.2.3 避免“幽灵转移”
什么叫幽灵转移?就是图上没画,但代码里有的转移。比如你画了从A到B的转移,但没画从B到A的。结果代码里B状态收到某个事件后,确实会回到A。这就叫“图上没有,代码有”。
我曾经在一个项目里,因为漏画了一个转移,导致新同事接手时完全看不懂代码逻辑。最后花了三天时间才把图和代码对齐。所以我的建议是:要么不画图,画了就要和代码完全一致。
3.3 使用工具——PlantUML与Draw.io实战
手画状态图?别闹了。现在都用工具。我个人最常用的是PlantUML和Draw.io。一个适合代码生成,一个适合快速原型。
3.3.1 PlantUML——文本即图
PlantUML最大的好处是:用文本描述状态图,然后自动生成图片。这样你可以把状态图定义放在代码仓库里,和代码一起版本控制。
一个简单的例子:
@startuml
[*] --> IDLE
IDLE --> ACTIVE : button_press [count < 5]
ACTIVE --> IDLE : timeout_expired
ACTIVE --> ERROR : error_detected
ERROR --> IDLE : reset_pressed
@enduml
这段文本会生成一个标准的状态图。你想想看,如果团队里每个人都用PlantUML,那状态图就和代码一样,可以review、可以diff、可以回滚。这比在Draw.io里手动拖拽要规范得多。
不过PlantUML也有缺点:复杂的状态图写起来很啰嗦。比如嵌套状态、历史状态,语法会变得很复杂。这时候我建议用Draw.io做原型,再用PlantUML做最终版本。
3.3.2 Draw.io——所见即所得
Draw.io(现在叫diagrams.net)是免费的在线绘图工具。它支持状态图模板,拖拽就能画。适合快速原型和团队讨论。
使用Draw.io画状态图时,我建议:
- 用“圆角矩形”表示状态,不要用“矩形”。
- 用“箭头”表示转移,不要用“直线”。
- 用“菱形”表示选择节点。
- 用“圆形”表示初始和终止状态。
嗯,这里有个小技巧:在Draw.io里,按住Ctrl拖动可以复制状态。这样画多个相似状态时,不用重新拖拽,直接复制修改名称就行。
3.3.3 工具对比
| 特性 | PlantUML | Draw.io |
|---|---|---|
| 输入方式 | 文本描述 | 图形拖拽 |
| 版本控制 | ✅ 天然支持 | ❌ 需导出XML |
| 复杂状态图 | 语法复杂 | 直观易画 |
| 团队协作 | 适合代码review | 适合实时讨论 |
| 学习成本 | 中等 | 低 |
我的建议是:团队讨论用Draw.io,最终定稿用PlantUML。这样既保证了沟通效率,又保证了规范一致性。
// 状态图定义见:docs/state_machine/led_control.puml。这样代码和设计图就关联起来了。
3.4 实战:画一个LED控制状态图
光说不练假把式。我们来画一个实际的例子:一个LED灯的状态机。它有四个状态:关闭、常亮、闪烁、呼吸。事件有:按键按下、定时器超时。
用PlantUML描述:
@startuml
[*] --> OFF
OFF --> ON : button_press [mode == 0]
OFF --> BLINK : button_press [mode == 1]
OFF --> BREATHE : button_press [mode == 2]
ON --> OFF : button_press
ON --> BLINK : timer_timeout [auto_mode]
BLINK --> OFF : button_press
BLINK --> BREATHE : timer_timeout [auto_mode]
BREATHE --> OFF : button_press
BREATHE --> ON : timer_timeout [auto_mode]
@enduml
这个图里,每个状态都有明确的转移条件。你想想看,如果我要加一个“长按3秒进入设置模式”的功能,只需要加一个事件和几个转移就行。这就是状态图的好处:逻辑清晰,扩展方便。
好了,第三课就到这里。下一课我们讲状态机的实现——怎么把图变成代码。到时候我会用C语言写一个完整的例子,包括状态表、事件队列、转移函数。敬请期待。