简介:这份PPT围绕UML状态图的核心概念与建模方法展开,面向正在学习系统分析与概要设计、需要掌握动态行为建模的软件工程学习者与项目实践者。内容从事件、状态和转换三要素切入,分别说明信号事件、调用事件、变化事件与时间事件的表示方式,并解析状态名称、进入/退出动作、内部转换、子状态和延迟事件等构成要素。资源以图书馆管理系统的借书、还书用例为主线,示范如何绘制状态图,并将状态图与类抽象、类间关系及交互图描述衔接起来,服务于HNS软件学院开发部的图书馆管理系统概要设计场景。压缩包仅含1个PPT文件,约285KB,结构紧凑,适合课堂讲解或自学查阅。已有374人学习,读者可据此梳理从需求模型到动态建模的思路,掌握事件触发转换、状态划分和用例状态图绘制的具体做法,为后续软件架构设计打下基础。
1. 状态图的难点在语义:同一份借书用例能画出三张互相矛盾的图
UML 状态图看起来门槛很低:几个圆角矩形、几条带箭头的线、箭头旁写一行「事件[守卫]/动作」,就能交差。可同一份图书借阅系统的借书用例交给三个人画,能出三张互相打架的图——有人把「借出中」画成状态,有人把它当成动作;有人在箭头上写「借书」,有人写「校验通过」;还有人给每个状态都补了 entry/exit,结果执行顺序完全对不上。问题不在画图的手快慢,而在于没想清楚事件是什么、状态在等什么、转换在什么条件下才算被触发。这张图背后其实是一套很小的执行模型:事件进队列、转换被选中、动作按固定顺序执行。把这三件事拆开,画图就从玄学变成了推导。
2. 事件:为什么信号是异步的,调用事件却会把发送方按住
资料把事件定义成「对一个在时间和空间上占据一定位置的有意义的事情的规格说明」,直白一点说,就是「发生了某件事,值得状态机做出反应」。事件分外部与内部:外部事件在系统与参与者之间传送,内部事件在系统内部对象之间传送。更关键的是事件的类型划分,它直接决定了转换被触发时,双方的控制权在谁手里。
2.1 信号:被建模成「构造型化的类」的通信媒介
信号是两个对象之间通信媒介的命名实体,接收信号本身就是接收对象的一个事件。它和简单的类有很多共同点:可以有实例,可以有属性和操作,还能参与泛化关系。资料里举的例子很典型——计算机设备的中断信号是一般信号,键盘中断信号是特殊信号,后者继承前者的属性,同时又能触发包含父信号类型的转换。这句话的技术含量比看上去高:接收方如果声明触发器为父信号,子信号也能把它点燃,这就是信号泛化带来的「触发器兼容」。
为什么 UML 要把信号建成构造型为<<Signal>>的类,而不是新造一个元类?常见的实现思路是复用:类已经具备属性、泛化、关联这些能力,信号需要携带数据(比如碰撞力force : Float),最省事的做法就是把参数声明为属性、把信号声明为构造型化的类,工具和元模型都不用改。发送关系则用构造型为<<send>>的依赖表示——某操作发送了一个特定信号。这里有意识地留下了不一致:发送是异步的,发送方投递完就走,不关心接收方什么时候处理。异步带来的是解耦,代价是发送方永远不知道对方是不是真的接住了。
2.2 调用事件:同步的真正含义是「控制权借出去了」
调用事件是一个对象对操作调用的接收。资料明确点出接收方有两条路:把操作实现成一个普通方法,或者实现成状态机里的一个调用事件触发器。这两条路对外看起来是同一个接口,行为却完全不同——前者返回一个结果,后者改变接收者自己的状态。
同步的机制在资料里写得很清楚:对象调用另一对象的操作时,控制从发送者传送到接收者,事件触发转换,完成操作后接收者转换到新状态,控制再返还给发送者。示例是「未验证图书」在收到searchBook(name)后转到「已验证图书」。把它和信号放在一起看,差异就出来了:信号是异步的,调用事件一般来说是同步的。同步意味着发送方在整段转换期间是被按住的,包括接收方的 exit 动作、转换动作、entry 动作全部跑完。如果接收方在这个过程里又回调发送方,就会出现重入——这是后面「运行到完成」要解决的问题。
一个容易忽略的取舍:同一个业务动作,建模成信号还是调用事件,决定了它能不能被「拒绝」。信号发出去就收不回,调用事件则可能因为守卫条件不满足而根本不触发转换,控制照样返还,只是接收方状态没变。需要「对方必须给我一个确定答复」的场合,调用事件更合适;需要「通知一声,谁爱处理谁处理」的场合,信号更合适。
2.3 变化事件与时间事件:when和after的表达式语义
变化事件是指依赖于指定属性值的布尔表达式得到满足,这是一种「一直等到条件成立」的声明方式。UML 里用关键字when加布尔表达式建模,既可以写绝对时间when time = 10:00,也可以写需要不间断测试的表达式when altitude < 1000。资料给的例子是打印机在「暂停」状态收到when(缺纸?) / printTest()后回到「打印」。
这里最容易被误读的是触发时机。变化事件不是「条件为真就一直触发」,而是条件从假变真的那一刻产生一次事件,属于边沿触发而非电平触发。想清楚这一点,很多「为什么我的转换只跑了一次」的疑问就消失了——条件持续为真并不会反复触发转换,要再触发,得先让它变假再变真。
时间事件表示一段时间推移,用关键字after加计算一段时间的表达式建模,计时基准默认为进入当前状态的时间。示例是「打印就绪」在after(2 seconds)/connectionPrint()后进入「打印」。基准这一句是排查定时器问题的第一现场:after从最近一次进入该状态算起,不是从系统启动算起。所以只要状态被重新进入一次,计时器就归零重来。至于这次「重新进入」是怎么发生的,下一章讲内部转换时会看到,一条画错的线足以让超时永远不触发。
| 事件类型 | 建模关键字 | 同步性 | 触发时机 |
|---|---|---|---|
| 信号 | <<Signal>>类、<<send>>依赖 | 异步 | 信号到达接收对象即产生事件 |
| 调用事件 | 操作调用 | 一般同步 | 接收方完成转换后控制才返还 |
| 变化事件 | when(布尔表达式) | 异步 | 表达式由假变真时产生一次 |
| 时间事件 | after(时间表达式) | 异步 | 从进入当前状态起计时到期 |
3. 状态:entry/exit、内部转换与延迟事件各自解决什么问题
状态被定义成对象生命周期中满足某些条件、执行某些活动或等待某些事件时的一个条件或状况。资料里打印机的六个状态——就绪、打印、缺纸、忙、暂停、停止——就是一台具体打印机在 UML 里被抽象成对象之后,工作时可能出现的状况集合。这个定义的重点落在「等待某些事件」上:状态是一个稳定点,它存在的主要目的是等。
3.1 状态不是阶段名,是五个槽位的组合
资料把状态拆成五个组成部分,这份拆分本身就是设计取舍的结果。名称是区分状态的字符串,状态也可以是匿名的;entry/exit 动作分别指进入和退出这个状态时执行的动作;内部转换是不会导致状态改变的转换;子状态是嵌套结构里的成员,分不相交(顺序活动)与并发(并发活动)两种;延迟事件是在该状态下暂不处理、但会推迟到该对象另一个状态下排队处理的事件列表。
这五项里,entry/exit 的执行时机最值得抠。它们绑定在「进入」和「退出」这个动作上,而不是绑定在状态上:只要状态被离开,exit 就一定跑;只要状态被进入,entry 就一定跑。这就解释了为什么「借出」状态的 entry 里做库存扣减、exit 里做日志落盘是常规写法——不管你是从哪个状态过来的、要往哪个状态去,这两段逻辑都会被完整执行一次。反过来,如果某段逻辑只想在特定转换上执行,它应该写在转换动作里,而不是 entry。
提示:entry/exit 与转换动作的分工,本质是「所有进入路径都要做的事」和「只有这条路径才做的事」的分工。分不清就往 entry 里塞,最后会出现重复扣减这类问题。
3.2 内部转换与外部自转换:一条线画错就多跑一遍 entry/exit
资料对内部转换的定义只有一句:不会导致状态改变的转换。这句话的信息量在于「不导致状态改变」——它不产生 exit 和 entry。与之相对,如果把一条转换的源状态和目标状态画成同一个状态,那是外部自转换,语义是先退出再进入,exit 和 entry 都会重新执行一遍。
这两者在图上的区别小到几乎看不出来,在运行时的差别却很大。回到打印机:在「打印」状态里每收到一页数据就累加计数,如果把它建模成内部转换,计数累加、状态不变、after计时器不动;如果画成外部自转换,每次累加都会触发一次 exit 和 enter,而after的计时基准正是「进入当前状态的时间」——于是计时器被无限次重置,超时永远不会到期。这就是前面说的「血泪经验」:一个看似无关的连线选择,废掉了一个时间事件。
内部转换的另一个用途是处理那些「在本状态内消化掉」的事件,比如「打印」状态下收到「查询进度」,直接返回当前页数,不需要离开状态。判断标准很朴素:处理这件事的过程中,对象需不需要暂时不是当前状态?不需要,就用内部转换。
3.3 子状态:不相交与并发,决定了同一时刻有几个状态是活的
子状态分两类,资料给的定义是「不相交(顺序活动)」和「并发(并发活动)」。不相交子状态是「或」的关系:外层状态激活时,同一时刻只有一个子状态是活动的,进入时按初始伪状态选定一个。并发子状态是「与」的关系:外层状态激活时,多个区域同时活动,进入时所有区域一起进入。
这个区分不是学术洁癖,它直接决定了 entry 动作执行几次、退出条件怎么判。设想一台打印机的「忙」状态被拆成两个并发区域:一个管打印作业推进,一个管耗材监控。缺纸发生在耗材区,作业区此刻还在推进,两个区域互不干扰,只有两边都结束后外层状态才退出。如果错画成不相交,「打印中」和「缺纸」就变成互斥的,缺纸时作业必须停下来,业务上未必是这么规定的。
我一般会这么理解:顺序子状态回答「这个阶段分几步走」,并发子状态回答「这个阶段有几件事同时在发生」。前者关心阶段推进的先后,后者关心区域的组合。画错方向,后面所有守卫条件都会跟着错。
3.4 延迟事件:不是忽略,是留一次「回头再处理」的机会
延迟事件的定义很短:在该状态下暂不处理,但将推迟到该对象的另一个状态下排队处理的事件列表。它和「忽略一个事件」是两回事:忽略是直接丢弃,事件就此消失;延迟是记录下来,等对象迁移到允许处理它的状态时再投递。
在实际系统里这个机制的价值很实在。设备正忙的时候收到「停止」请求,如果直接丢弃,用户会觉得按钮没反应;如果用延迟事件记下来,回到就绪状态再处理,用户看到的是「点了之后过一会儿才停」,行为可预期。需要注意的是,延迟事件列表属于状态的一部分,它跟着状态走——离开这个状态时,队列里攒下的事件需要有明确的去处,否则会变成一堆没人消费的遗留消息。
4. 转换:触发事件、守卫条件、动作的三段式与「运行到完成」
转换被定义为两个状态间的一种关系,表示对象将在当前状态中执行动作,并在某个特定事件发生或某个特定的条件满足时进入后继状态。转换激活之前,称对象处于源状态;激活之后,就称对象处于目标状态。资料给的例子是「获取时间片」这个事件发生时,程序从「就绪」转换到「运行」。
4.1 转换的三段式:事件选中、守卫把关、动作执行
把定义摊开,一条转换其实由五部分组成:源状态、触发事件、守卫条件、动作、目标状态。事件负责「选中候选转换」,守卫负责「这次到底走不走」,动作负责「走的过程中做点什么」。多个转换可以由同一个事件触发,此时守卫条件必须互斥,否则执行哪条就变成了实现相关的运气问题。
守卫的求值时机很关键:它只在事件到达的那一刻求值一次,而不是持续监控。这意味着守卫里不适合放有副作用的表达式,也不适合依赖「过一会儿可能会变」的值。这一点和变化事件形成对照:需要持续监控条件变化,就该用when,而不是在守卫里写一个永远为真的判断然后指望它自己变。
把转换写成数据而不是图,是工程上常见的落地方式。一份简化的状态表大致长这样:
# 状态表:{源状态: {事件名: (守卫函数, 转换动作, 目标状态)}} state_table = { "在架": { "请求借出": (lambda a: a["额度"] > 0 and a["副本可用"], "锁定副本", "借出中"), "下架": (None, "登记下架", "已下架"), }, "借出中": { "校验通过": (None, "生成借阅记录", "借出"), "校验失败": (None, "释放副本", "在架"), }, }这段结构体里,键是源状态和事件名,值是三元组。守卫是一个接收事件参数、返回布尔值的函数,None表示无条件通过;转换动作是普通可调用对象;目标状态是字符串。把它写成数据的好处是可以用矩阵方式穷举检查——每个状态的出边事件是否重复、守卫是否可能同时为真、有没有哪个事件在所有状态下都没有对应转换。图能表达的东西有限,表格能暴露的问题更多。
4.2 运行到完成:为什么状态机处理事件时不需要加锁
状态机的执行语义里有一条容易被忽略但极其重要的约定:一次只处理一个事件,从事件被选中到转换动作、exit、entry 全部执行完毕,这期间不接受新事件的抢占,新产生的事件排队等待。常见的实现思路就是下面这段循环:
from collections import deque class StateMachine: def __init__(self, table, initial): self.table = table # 状态表,见 4.1 self.state = initial # 当前状态 self.queue = deque() # 待处理事件队列,运行到完成的边界 self.deferred = [] # 当前状态登记的延迟事件 def post(self, event): self.queue.append(event) # 只入队,不打断正在进行的转换 def run(self): while self.queue: ev = self.queue.popleft() cell = self.table.get(self.state, {}).get(ev.name) if cell is None: if ev.name in self.table[self.state].get("deferred", ()): self.deferred.append(ev) # 记账,不丢弃 continue # 其余情况视为忽略 guard, action, target = cell if guard and not guard(ev.args): continue # 守卫不通过,事件消费掉,状态不变 self._exit(self.state) # 先退出源状态 action(ev.args) # 再执行转换动作 self._enter(target) # 最后进入目标状态 self.state = target参数和顺序都值得说明。queue是运行到完成的物理体现:post只入队,绝不递归调用run,这样转换动作里产生的新事件不会插到当前转换中间。deferred挂在状态机上而不是状态表里,退出状态时需要一次清理策略,否则延迟事件会跨状态堆积。执行顺序固定为 exit → 转换动作 → entry,这个顺序不能换:exit 里通常释放源状态的资源,entry 里申请目标状态的资源,中间的动作才有一个干净的过渡区。
运行到完成带来的好处是内部一致性:转换动作里读到的状态在整个动作期间不会变,不需要为状态机内部加锁。代价是吞吐——一个慢动作会堵住整个队列;也是重入的来源——如果在动作里同步调用另一个状态机,而对方又调回来,就得靠「事件入队」而不是「直接调用」来打破循环。
4.3 借书用例:复合状态把组合爆炸的连线压回去
资料留的建模任务是两个:图书业务模块中借书用例的状态图,以及还书用例的状态图。这两个任务放在一起看才有意思——它们描述的是同一个图书副本生命周期的两段,拆开画最容易出的问题是状态命名不一致:还书图里出现了借书图里没有的「已归还」,借书图里有个「借出成功」在还书图里叫「已借出」。两张图单独看都自洽,拼起来对不上,这就是黑匣子式的建模债务。
更稳的做法是先定一份状态命名表,再决定哪些状态属于复合状态。以副本为中心,外层可以是「可外借」和「不可外借」两个复合状态:前者内部顺序包含「在架」和「预约锁定」,后者包含「借出」「逾期」「归还处理中」。借出流程的入口事件是「请求借出」,守卫检查读者额度和副本可用性,进入「借出中」这个瞬时状态做校验,校验结果通过则进「借出」,失败则退「在架」。还书流程复用同一张状态机,只是入口事件换成「请求归还」,从「借出」或「逾期」进入「归还处理中」,处理完回到「在架」。
用复合状态的意义在于收敛连线。如果按「每种组合一个平铺状态」来画,在架、在架且被预约、借出、借出且逾期、逾期且被催还……状态数会按维度相乘的速度膨胀,而大部分转换在业务上根本不成立。复合状态把这些维度拆成嵌套层次,外层管大类,内层管细节,转换只需要在真正变化的层次上画。这也是资料把状态图的子状态单列一项的原因——它不是语法糖,是控制复杂度的主要手段。
5. 建模避坑:状态图最容易出现的五类问题
状态图出问题,往往不是语法错误,而是语义错位。图能画出来、工具不报错、评审也能过,跑起来才发现行为不对。下面五条是我见过频率最高的,每条按现象、原因、解决三段来看。
5.1 把动作画成状态:图里全是「校验中」「保存中」
现象:图上出现了「校验中」「保存中」「计算中」这类状态,仔细看它们的出边没有任何触发事件,或者出边写的是「完成」。评审时没人觉得有问题,实现时不知道该等什么。
原因:把执行步骤混进了状态。状态的定义里有一条隐含要求——它是一个稳定点,存在的意义是等待事件。一个纯计算过程没有等待,它只是一段动作。
解决:对每个候选状态问一句「它在这里等什么事件」。答不上来的,就不是状态,把这段逻辑搬到转换动作或者某个状态的 entry 里。真正需要保留的瞬时状态,通常是那种「等外部结果」的,比如等支付回调、等库存服务响应,它们有明确的入边事件和出边事件。
5.2 把事件画成守卫:箭头上写着「借书成功」
现象:转换的触发事件写成了「借书成功」「校验通过」这类结果描述,导致往上追溯时找不到这个事件的产生者,图成了无源之水。
原因:混淆了「发生了什么」和「条件满足了吗」。事件是外界或内部对象发来的事情,守卫是对象自己基于属性做的判断。
解决:事件用「请求借书(name)」「归还请求(副本号)」这种带参数的动词短语,守卫用[读者额度 > 0]、[副本状态 = 在架]这种布尔表达式。判断标准是:这个东西需不需要别人告诉你?需要,是事件;自己查一下就知道,是守卫。
5.3 同一事件多条出边却没有互斥守卫
现象:一个事件引出两三条转换,守卫要么没写,要么写成了可能同时成立的条件。图上看着挺完整,实现时不知道该走哪条,不同人有不同理解。
原因:把「业务上一般不会同时发生」当成了「逻辑上互斥」。
解决:同一事件的出边守卫必须互斥且尽量完备,做不到完备就补一条无条件出边兜底。用 4.1 的状态表做一次穷举检查,把每个状态的出边事件列出来,逐条比对守卫的布尔表达式,能发现大部分遗漏。
5.4 延迟事件当成忽略事件处理
现象:设备忙的时候点「停止」没反应,等忙完了也不会停;用户以为按钮坏了,实际上是事件被丢了。
原因:把「当前状态不处理」直接实现成丢弃,没有走延迟事件列表。
解决:确认这个事件是不是「晚点处理也可以」。可以,就登记到延迟列表,并在退出状态时明确它的去向;不可以,就老老实实给一条转换,哪怕目标是「拒绝」状态并给出反馈。
5.5 计时器不响或提前响:after的基准被自转换重置
现象:after(2 seconds)的超时转换永远不触发,或者刚一进入状态就触发了。日志里看不出异常,因为没有任何动作执行失败。
原因:计时基准是「进入当前状态的时间」。如果状态被外部自转换反复重入,计时器每次都归零;如果误用了内部转换但期望它重置计时器,则会出现相反的现象。
解决:先确认那条看似「原地不动」的转换到底是内部转换还是外部自转换——它决定了 entry/exit 跑不跑、计时器重不重置。这一条和 3.2 是同一个问题的两面,值得在评审时单独拎出来看。
6. 设计取舍:状态图该画到多细,以及怎么验证它没画错
状态图的粒度没有标准答案,但有一个可操作的判断依据:这张图要回答什么问题。如果它要回答「这个对象一生会经历哪些阶段」,那状态应该粗,粗到能一眼看出业务主线;如果它要回答「某个异常路径下对象怎么走」,那状态必须细,细到每条守卫都能对应一行代码。试图用一张图同时满足两种诉求,结果通常是中间层堆了十几个状态,谁也说不清哪个是主干。
另一个常被忽略的取舍是状态图与交互图、活动图的边界。资料把状态图放在架构建模一章,与类、类的关系、交互图并列,这个编排本身说明了一件事:状态图描述的是单个对象在其生命周期内的行为,交互图描述的是多个对象之间按时间排列的消息往来。同一个借书用例,用状态图看的是「这本书的副本经历了什么」,用交互图看的是「读者、借阅服务、库存之间怎么对话」。两者不是替代关系,画重了会互相矛盾,画漏了会留下没人负责的行为。我的经验是:先确定哪个对象的状态最复杂,给它画状态图;对象之间的协作,交给交互图。
验证方法上,图本身几乎无法自证。可行的做法有两条。一条是把状态表写成数据,做矩阵穷举——每个状态的出边事件是否唯一、守卫是否互斥、有没有事件在所有状态下都无人处理、有没有状态没有任何入边。这些都是纯静态检查,写几十行脚本就能跑。另一条是把状态机真的跑起来,用事件序列做场景回放,重点观察 exit/entry 的执行次数和顺序,因为绝大多数语义错误都藏在这两处。资料里那两个建模任务——借书用例和还书用例——正好可以当成回放的输入:把「借出 → 逾期 → 归还」这条主线和「借出 → 校验失败 → 在架」这条支线各跑一遍,如果两张图的状态命名对不上,回放会立刻暴露。
还有一个取舍值得说:要不要为异常路径单独建状态。把「校验失败」建成状态,图会变复杂,但守卫条件就从转换上移到了状态里,反而更容易做统一的失败处理;不建,图更干净,但失败逻辑散落在各条转换的动作里。我倾向于建,前提是这个失败有后续动作要等——比如等人工审核、等重试窗口。没有后续的失败,一个转换动作里的日志就够了。
从那以后我每次画状态图,都强制先写一份状态命名表,再写一张状态-事件矩阵,最后才动手画线。图是给人看的,表是给机器和对齐用的,两者对不上,问题一定在画之前就埋下了。这份资料把事件、状态、转换拆成三节,又在末尾留了两个必须自己动手的建模任务,顺序正好对上——先看清楚语义,再落到自己的用例上。希望帮到你。
本文还有配套的精品资源,点击获取