第三课:状态图绘制——基本符号、规范与工具实战

好,咱们进入第三课。上一课我们聊了状态机的基本概念,说白了就是“状态+事件+转移”这三板斧。但光有概念不行,你得能把它画出来。状态图,就是状态机的“设计蓝图”。

我见过不少工程师,脑子里想得挺清楚,一画图就乱套。箭头乱飞、状态重叠、事件标注不清……最后代码实现的时候,自己都看不懂自己画的图。嗯,这其实是个大坑。今天我们就来填平它。

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 命名要规范

状态名用大写字母,单词间用下划线。比如:

  • IDLEDATA_RECEIVINGERROR_HANDLING
  • idleDataReceivingerror handling

事件名用小写字母,动词开头。比如:

  • button_presstimeout_expireddata_received
  • PressButtonTimeoutReceivedData

为什么要这么严格?因为状态图最终要翻译成代码。命名规范了,代码生成工具才能正确解析。我自己写代码时,状态名和事件名直接就是枚举类型的定义,完全一一对应。

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。这样既保证了沟通效率,又保证了规范一致性。

小提示:如果你用PlantUML,建议把状态图定义放在一个单独的.puml文件中,然后在代码注释里引用这个文件。比如:// 状态图定义见:docs/state_machine/led_control.puml。这样代码和设计图就关联起来了。
避坑指南:我曾经在一个项目里,用Draw.io画了20多个状态图,结果后来需求变更,每个图都要手动修改。那叫一个痛苦。后来改用PlantUML,改一行文本,所有图自动更新。所以,如果状态机逻辑频繁变动,优先用PlantUML

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语言写一个完整的例子,包括状态表、事件队列、转移函数。敬请期待。