状态机框架
状态机框架提供了用于创建和执行状态图的类。其概念和符号基于哈瑞尔的《Statecharts: A visual formalism for complex systems》,该文档也是UML状态图的基础。状态机执行的语义基于State Chart XML(SCXML)。
状态图提供了一种图形化建模系统对刺激的反应方式。这是通过定义系统可能处于的 状态,以及系统如何从一个状态转移到另一个状态(状态之间的转换)来实现的。事件驱动系统(如Qt应用程序)的一个关键特征是行为往往不仅取决于最后或当前事件,还取决于先前的事件。使用状态图,可以轻松表达这些信息。
状态机框架提供了一个API和执行模型,可以有效地在Qt应用程序中嵌入状态图的元素和语义。该框架与Qt的元对象系统紧密集成;例如,状态之间的转换可以由信号触发,并且可以配置状态以在QObject上设置属性和调用方法。Qt的事件系统用于驱动状态机。
状态机框架中的状态图是分层的。状态可以嵌套在其他状态内部,并且状态机的当前配置包括当前活动的状态集。状态机的有效配置中的所有状态将具有共同的祖先。
状态机框架中的类
这些类由Qt提供,用于创建基于事件驱动的状态机。
QAbstractState | QStateMachine 的状态的基类 |
---|---|
QAbstractTransition | QAbstractState 对象之间转换的基类 |
QEventTransition | 专门用于 Qt 事件的转换 |
QFinalState | 最终状态 |
QHistoryState | 返回到之前活动的子状态的方式 |
QKeyEventTransition | 键盘事件的转换 |
QMouseEventTransition | 鼠标事件的转换 |
QSignalTransition | 基于 Qt 信号的转换 |
QState | QStateMachine 的通用状态 |
QStateMachine | 分层有限状态机 |
QStateMachine::SignalEvent | 表示 Qt 信号事件 |
QStateMachine::WrappedEvent | 继承自 QEvent 并持有与 QObject 关联的事件的克隆 |
一个简单的状态机
为了演示状态机 API 的核心功能,让我们看一个小例子:一个拥有三个状态 s1
、s2
和 s3
的状态机。这个状态机由一个单独的 QPushButton 控制;当按钮被点击时,状态机会转移到另一个状态。最初,状态机处于状态 s1
。这个状态机的状态图如下:
以下代码片段显示了创建这样一个状态机所需的代码。首先,我们创建状态机和状态:
QStateMachine machine;
QState *s1 = new QState();
QState *s2 = new QState();
QState *s3 = new QState();
然后,我们使用 QState::addTransition() 函数创建转换:
s1->addTransition(button, &QPushButton::clicked, s2);
s2->addTransition(button, &QPushButton::clicked, s3);
s3->addTransition(button, &QPushButton::clicked, s1);
接下来,我们将状态添加到状态机中,并设置状态机的初始状态:
machine.addState(s1);
machine.addState(s2);
machine.addState(s3);
machine.setInitialState(s1);
最后,我们启动状态机:
machine.start();
状态机是异步执行的,即它成为应用程序事件循环的一部分。
在状态进入和退出时执行有用的工作
上面的状态机仅仅是从一个状态转移到另一个状态,它并没有执行任何操作。可以使用 QState::assignProperty()
函数在状态进入时设置 QObject 的属性。在下面的代码片段中,为每个状态指定了应该分配给 QLabel 的 text 属性的值:
s1->assignProperty(label, "text", "In state s1");
s2->assignProperty(label, "text", "In state s2");
s3->assignProperty(label, "text", "In state s3");
当任何状态被进入时,标签的文本将相应更改。
当状态被进入时,会发射 QState::entered()
信号,当状态被退出时,会发射 QState::exited()
信号。在下面的代码片段中,当状态 s3
被进入时,将调用按钮的 showMaximized()
槽,并且当 s3
被退出时,将调用按钮的 showMinimized()
槽:
QObject::connect(s3, &QState::entered, button, &QPushButton::showMaximized);
QObject::connect(s3, &QState::exited, button, &QPushButton::showMinimized);
自定义状态可以重新实现 QAbstractState::onEntry()
和 QAbstractState::onExit()
。
完成的状态机
在上一节中定义的状态机永远不会完成。为了使状态机能够完成,它需要有一个顶级的 最终 状态(QFinalState 对象)。当状态机进入顶级最终状态时,状态机将发射 finished() 信号并停止。
要在图中引入最终状态,你只需创建一个 QFinalState 对象,并将其用作一个或多个转换的目标即可。
通过分组状态共享转换
假设我们希望用户能够随时通过单击“退出”按钮退出应用程序。为了实现这一点,我们需要创建一个最终状态,并将其作为与“退出”按钮的 clicked() 信号关联的转换的目标。我们可以从 s1
、s2
和 s3
中的每个状态添加一个转换;然而,这似乎是冗余的,并且还需要记住在未来添加的每个新状态中添加这样的转换。
我们可以通过将状态 s1
、s2
和 s3
分组来实现相同的行为(即,单击“退出”按钮退出状态机,无论状态机当前处于哪个状态)。这是通过创建一个新的顶级状态,并使三个原始状态成为新状态的子状态来完成的。下面的图表显示了新的状态机。
原来的三个状态已经被重命名为 s11
、s12
和 s13
,以反映它们现在是新的顶级状态 s1
的子状态。子状态隐式地继承其父状态的转换。这意味着现在只需要从 s1
到最终状态 s2
添加一个转换就足够了。添加到 s1
的新状态也将自动继承这个转换。
要将状态分组,只需在创建状态时指定正确的父状态即可。您还需要指定哪个子状态是初始状态(即,当父状态是转换的目标时,状态机应该进入哪个子状态)。
QState *s1 = new QState();
QState *s11 = new QState(s1);
QState *s12 = new QState(s1);
QState *s13 = new QState(s1);
s1->setInitialState(s11);
machine.addState(s1);
QFinalState *s2 = new QFinalState();
s1->addTransition(quitButton, &QPushButton::clicked, s2);
machine.addState(s2);
machine.setInitialState(s1);
QObject::connect(&machine, &QStateMachine::finished,
QCoreApplication::instance(), &QCoreApplication::quit);
在这种情况下,当状态机完成时我们希望应用程序退出,因此将状态机的 finished() 信号连接到应用程序的 quit() 槽。
子状态可以覆盖继承的转换。例如,以下代码添加了一个转换,当状态机处于状态 s12
时,实际上会导致忽略“退出”按钮。
s12->addTransition(quitButton, &QPushButton::clicked, s12);
转换可以将任何状态作为其目标,即目标状态不必与源状态在状态层次结构中处于相同的级别。
使用历史状态保存和恢复当前状态
假设我们想在前面部分讨论的示例中添加一个“中断”机制;用户应该能够单击一个按钮,使状态机执行一些非相关的任务,然后状态机应该恢复之前正在做的事情(即返回到旧状态,在这种情况下是 s11
、s12
和 s13
中的一个)。
使用 历史状态(QHistoryState 对象)可以轻松地建模这种行为。历史状态是一个伪状态,表示父状态在上次退出时所处的子状态。
历史状态被创建为父状态的子状态,我们希望记录当前子状态;当状态机在运行时检测到这样一个状态时,它会在父状态退出时自动记录当前(真实的)子状态。对历史状态的转换实际上是对状态机之前保存的子状态的转换;状态机会自动“转发”转换到真实子状态。
下图显示了在添加了中断机制之后的状态机。
以下代码显示了如何实现它;在这个例子中,当 s3
进入时,我们简单地显示一个消息框,然后通过历史状态立即返回到 s1
的先前子状态。
QHistoryState *s1h = new QHistoryState(s1);
QState *s3 = new QState();
s3->assignProperty(label, "text", "In s3");
QMessageBox *mbox = new QMessageBox(mainWindow);
mbox->addButton(QMessageBox::Ok);
mbox->setText("Interrupted!");
mbox->setIcon(QMessageBox::Information);
QObject::connect(s3, &QState::entered, mbox, &QMessageBox::exec);
s3->addTransition(s1h);
machine.addState(s3);
s1->addTransition(interruptButton, &QPushButton::clicked, s3);
使用并行状态来避免状态的组合爆炸
假设你想在一个单独的状态机中对车辆的一组互斥属性进行建模。假设我们感兴趣的属性是“清洁”与“脏”,以及“移动”与“静止”。要能够表示并自由地在所有可能的组合之间移动,需要四个互斥的状态和八个转换。
如果我们添加了第三个属性(比如,红色 vs 蓝色),状态的总数将翻倍,变为八个;如果我们再添加了第四个属性(比如,封闭式 vs 敞篷),状态的总数将再次翻倍,变为16个。
使用并行状态,随着我们添加更多的属性,状态和转换的总数会线性增长,而不是呈指数增长。此外,并行状态可以添加到或从并行状态中删除,而不会影响任何它们的兄弟状态。
要创建一个并行状态组,将 QState::ParallelStates 传递给 QState 构造函数。
QState *s1 = new QState(QState::ParallelStates);
// s11 和 s12 将同时进入
QState *s11 = new QState(s1);
QState *s12 = new QState(s1);
当进入并行状态组时,所有子状态将同时进入。单独子状态内部的转换会正常操作。然而,任何子状态都可能采取一个退出父状态的转换。当这种情况发生时,将退出父状态及其所有子状态。
状态机框架中的并行性遵循交错语义。所有并行操作将在事件处理的单个原子步骤中执行,因此没有事件可以中断并行操作。但是,事件仍然会按顺序处理,因为状态机本身是单线程的。举个例子:考虑这样一种情况,即有两个退出相同并行状态组的转换,并且它们的条件同时为真。在这种情况下,最后处理的两个事件中的第一个事件将不会产生任何效果,因为第一个事件已经导致状态机退出并行状态。
检测复合状态的完成
子状态可以是最终状态(一个 QFinalState 对象);当进入最终子状态时,父状态会发射 finished() 信号。下图显示了一个复合状态 s1,它在进入最终状态之前执行一些处理:
当 s1 的最终状态被进入时,s1 会自动发射 finished() 信号。我们使用信号转换来使此事件触发状态变化:
s1->addTransition(s1, &QState::finished, s2);
在复合状态中使用最终状态在于当你想要隐藏复合状态的内部细节时非常有用;也就是说,外部世界唯一能做的事情就是进入状态,并在状态完成其工作时收到通知。这是在构建复杂(深度嵌套)状态机时非常强大的抽象和封装机制。(在上面的例子中,你当然可以直接从 s1 的 done 状态创建转换,而不是依赖于 s1 的 finished() 信号,但这样做的后果是暴露并依赖于 s1 的实现细节)。
对于并行状态组,当所有子状态都进入最终状态时,会发射 finished() 信号。
无目标转换
转换不必有目标状态。没有目标的转换可以像任何其他转换一样触发;不同之处在于,当触发一个无目标的转换时,它不会导致任何状态变化。这使您能够在状态机处于某种状态时对信号或事件做出反应,而无需离开该状态。例如:
QStateMachine machine;
QState *s1 = new QState(&machine);
QPushButton button;
QSignalTransition *trans = new QSignalTransition(&button, &QPushButton::clicked);
s1->addTransition(trans);
QMessageBox msgBox;
msgBox.setText("The button was clicked; carry on.");
QObject::connect(trans, &QSignalTransition::triggered, &msgBox, &QMessageBox::exec);
machine.setInitialState(s1);
每次单击按钮时都会显示消息框,但状态机将保持在当前状态(s1)。然而,如果目标状态明确设置为 s1,那么每次都会退出并重新进入 s1(例如,将会发射 QAbstractState::entered() 和 QAbstractState::exited() 信号)。
事件、转换和守卫
一个 QStateMachine 运行自己的事件循环。对于信号转换(QSignalTransition 对象),当它拦截到相应的信号时,QStateMachine 会自动向自身发送一个 QStateMachine::SignalEvent;类似地,对于 QObject 事件转换(QEventTransition 对象),会发送一个 QStateMachine::WrappedEvent。
您可以使用 QStateMachine::postEvent() 将自己的事件发送到状态机。
当向状态机发送自定义事件时,通常还会有一个或多个可以从该类型事件触发的自定义转换。要创建这样的转换,您需要子类化 QAbstractTransition 并重新实现 QAbstractTransition::eventTest(),在这里检查事件是否与您的事件类型匹配(以及可选的其他条件,例如事件对象的属性)。
下面我们定义了自己的自定义事件类型 StringEvent,用于向状态机发送字符串:
struct StringEvent : public QEvent
{
StringEvent(const QString &val)
: QEvent(QEvent::Type(QEvent::User+1)),
value(val) {}
QString value;
};
接下来,我们定义了一个转换,只有当事件的字符串与特定字符串匹配时才触发(一种 守卫 转换):
class StringTransition : public QAbstractTransition
{
Q_OBJECT
public:
StringTransition(const QString &value)
: m_value(value) {}
protected:
bool eventTest(QEvent *e) override
{
if (e->type() != QEvent::Type(QEvent::User+1)) // StringEvent
return false;
StringEvent *se = static_cast<StringEvent*>(e);
return (m_value == se->value);
}
void onTransition(QEvent *) override {}
private:
QString m_value;
};
在 eventTest() 的重新实现中,我们首先检查事件类型是否是所需的类型;如果是,则将事件转换为 StringEvent 并执行字符串比较。
以下是使用自定义事件和转换的状态图:
以下是状态图的实现:
QStateMachine machine;
QState *s1 = new QState();
QState *s2 = new QState();
QFinalState *done = new QFinalState();
StringTransition *t1 = new StringTransition("Hello");
t1->setTargetState(s2);
s1->addTransition(t1);
StringTransition *t2 = new StringTransition("world");
t2->setTargetState(done);
s2->addTransition(t2);
machine.addState(s1);
machine.addState(s2);
machine.addState(done);
machine.setInitialState(s1);
一旦状态机启动,我们就可以向其发送事件。
machine.postEvent(new StringEvent("Hello"));
machine.postEvent(new StringEvent("world"));
一个没有被任何相关转换处理的事件将会被状态机默默消耗。将状态进行分组并提供这些事件的默认处理可能是有用的;例如,如下状态图所示:
对于深度嵌套的状态图,您可以在最合适的粒度级别添加这样的“回退”转换。
使用恢复策略自动恢复属性
在某些状态机中,将注意力集中在为状态分配属性上,而不是在状态不再处于活动状态时恢复属性可能是有用的。如果您知道当状态机进入一个状态时,该属性应该始终恢复到其初始值,而该状态没有显式地给该属性赋值,您可以将全局恢复策略设置为 QStateMachine::RestoreProperties。
QStateMachine machine;
machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);
当设置了这个恢复策略时,状态机将自动恢复所有属性。如果它进入一个没有设置给定属性的状态,它将首先搜索祖先层次结构,看看属性是否在那里定义。如果是,则属性将恢复为最近祖先定义的值。如果没有,则将属性恢复为其初始值(即在状态中执行任何属性赋值之前的属性值)。
看下面的代码:
QStateMachine machine;
machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);
QState *s1 = new QState();
s1->assignProperty(object, "fooBar", 1.0);
machine.addState(s1);
machine.setInitialState(s1);
QState *s2 = new QState();
machine.addState(s2);
假设当机器启动时,属性 fooBar
的值为 0.0。当机器处于状态 s1
时,该属性将为 1.0,因为该状态明确将此值分配给它。当机器处于状态 s2
时,该属性没有明确定义的值,因此将隐式地恢复为 0.0。
如果我们使用嵌套状态,父状态定义了属性的值,并被所有没有明确为属性分配值的后代继承。
QStateMachine machine;
machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);
QState *s1 = new QState();
s1->assignProperty(object, "fooBar", 1.0);
machine.addState(s1);
machine.setInitialState(s1);
QState *s2 = new QState(s1);
s2->assignProperty(object, "fooBar", 2.0);
s1->setInitialState(s2);
QState *s3 = new QState(s1);
在这里,s1 有两个子状态:s2 和 s3。当进入 s2 时,属性 fooBar 将具有值 2.0,因为这在状态中明确定义了。当机器处于状态 s3 时,状态没有定义值,但是 s1 定义了属性为 1.0,因此这将是分配给 fooBar 的值。
动画属性分配
状态机 API 与 Qt 中的动画 API 连接在一起,以允许在状态分配属性时自动执行动画。
假设我们有以下代码:
QState *s1 = new QState();
QState *s2 = new QState();
s1->assignProperty(button, "geometry", QRectF(0, 0, 50, 50));
s2->assignProperty(button, "geometry", QRectF(0, 0, 100, 100));
s1->addTransition(button, &QPushButton::clicked, s2);
在这里,我们定义了用户界面的两个状态。在 s1
中,button
是小的,在 s2
中,它更大。如果我们点击按钮从 s1
过渡到 s2
,那么按钮的几何形状将在进入给定状态后立即设置。然而,如果我们希望过渡是平滑的,我们只需要创建一个 QPropertyAnimation 并将其添加到转换对象中。
QState *s1 = new QState();
QState *s2 = new QState();
s1->assignProperty(button, "geometry", QRectF(0, 0, 50, 50));
s2->assignProperty(button, "geometry", QRectF(0, 0, 100, 100));
QSignalTransition *transition = s1->addTransition(button, &QPushButton::clicked, s2);
transition->addAnimation(new QPropertyAnimation(button, "geometry"));
为该属性添加动画意味着当状态被进入时,属性分配将不会立即生效。相反,动画将在状态被进入时开始播放,并平滑地执行属性分配。由于我们没有设置动画的起始值或结束值,这些值将被隐式设置。动画的起始值将是动画开始时属性的当前值,而结束值将根据状态的属性分配定义而设置。
如果状态机的全局恢复策略被设置为 QStateMachine::RestoreProperties,那么也可以为属性恢复添加动画。
检测属性在状态中是否已设置
当使用动画分配属性时,状态不再定义机器在给定状态时属性将具有的确切值。在动画运行时,属性可以根据动画的情况潜在地具有任何值。
在某些情况下,能够检测属性实际上是否已被分配为状态定义的值可能很有用。
假设我们有以下代码:
QMessageBox *messageBox = new QMessageBox(mainWindow);
messageBox->addButton(QMessageBox::Ok);
messageBox->setText("Button geometry has been set!");
messageBox->setIcon(QMessageBox::Information);
QState *s1 = new QState();
QState *s2 = new QState();
s2->assignProperty(button, "geometry", QRectF(0, 0, 50, 50));
connect(s2, &QState::entered, messageBox, SLOT(exec()));
s1->addTransition(button, &QPushButton::clicked, s2);
当点击按钮时,状态机将过渡到状态 s2
,该状态将设置按钮的几何形状,然后弹出一个消息框来提醒用户几何形状已经被更改。
在正常情况下,不使用动画,这将按预期操作。然而,如果在 s1
和 s2
之间的过渡中为 button
的 geometry
设置了动画,那么当进入 s2
时动画将开始运行,但在动画完成运行之前,geometry
属性实际上不会达到其定义的值。在这种情况下,消息框将在按钮的几何形状实际上被设置之前弹出。
为了确保消息框直到几何实际上达到其最终值才弹出,我们可以使用状态的 propertiesAssigned() 信号。当属性被分配其最终值时,propertiesAssigned() 信号将被发射,无论是立即还是在动画播放完成后。
QMessageBox *messageBox = new QMessageBox(mainWindow);
messageBox->addButton(QMessageBox::Ok);
messageBox->setText("Button geometry has been set!");
messageBox->setIcon(QMessageBox::Information);
QState *s1 = new QState();
QState *s2 = new QState();
s2->assignProperty(button, "geometry", QRectF(0, 0, 50, 50));
QState *s3 = new QState();
connect(s3, &QState::entered, messageBox, SLOT(exec()));
s1->addTransition(button, &QPushButton::clicked, s2);
s2->addTransition(s2, &QState::propertiesAssigned, s3);
在这个例子中,当点击按钮时,状态机将进入 s2
。它将保持在状态 s2
中,直到 geometry
属性被设置为 QRect(0, 0, 50, 50)
为止。然后它将过渡到 s3
。当进入 s3
时,消息框将弹出。如果进入 s2
的过渡有一个 geometry
属性的动画,那么状态机将留在 s2
中,直到动画完成播放。如果没有这样的动画,它将简单地设置属性,并立即进入状态 s3
。
无论哪种方式,当状态机在状态 s3
中时,您可以确保属性 geometry
已被分配为定义的值。
如果全局恢复策略设置为 RestoreProperties,则状态将不会发出 propertiesAssigned() 信号,直到这些属性也已被执行。
状态退出时动画尚未完成的情况下会发生什么
如果一个状态具有属性分配,并且进入该状态的过渡具有属性的动画,那么在属性被分配给状态定义的值之前,该状态有可能被退出。特别是当有从状态出发的过渡不依赖于 propertiesAssigned() 信号时,正如前一节所述。
状态机 API 保证状态机分配的属性要么:
- 为属性显式分配了一个值。
- 当前正在动画转换为状态显式分配的值。
当状态在动画完成之前退出时,状态机的行为取决于过渡的目标状态。如果目标状态显式为属性分配一个值,则不会采取任何其他操作。属性将被分配目标状态定义的值。
如果目标状态未向属性分配任何值,则有两种选择:默认情况下,属性将被分配为离开的状态定义的值(如果动画被允许完成播放,它将被分配的值)。然而,如果设置了全局恢复策略,这将优先,并且属性将像往常一样被恢复。
默认动画
如前所述,您可以将动画添加到过渡中,以确保目标状态中的属性分配被动画化。如果希望针对特定属性使用特定动画,而不管采取哪种过渡,可以将其添加为状态机的默认动画。当在构造状态机时不知道特定状态分配(或恢复)的属性时,这种情况特别有用。
QState *s1 = new QState();
QState *s2 = new QState();
s2->assignProperty(object, "fooBar", 2.0);
s1->addTransition(s2);
QStateMachine machine;
machine.setInitialState(s1);
machine.addDefaultAnimation(new QPropertyAnimation(object, "fooBar"));
当状态机处于状态 s2
时,将播放属性 fooBar
的默认动画,因为此属性由 s2
分配。
请注意,显式设置在过渡中的动画将优先于给定属性的任何默认动画。
嵌套状态机
QStateMachine 是 QState 的子类。这使得状态机可以成为另一个机器的子状态。QStateMachine 重新实现了 QState::onEntry() 并调用 QStateMachine::start(),这样当进入子状态机时,它将自动开始运行。
父状态机将子机器视为 原子 状态在状态机算法中。子状态机是自包含的;它维护自己的事件队列和配置。特别地,注意子机器的 configuration() 不是父机器配置的一部分(只有子机器本身是)。
子状态机的状态不能被指定为父状态机中的转换目标;只有子状态机本身可以。反之亦然,父状态机中的状态不能被指定为子状态机中的转换目标。子状态机的 finished() 信号可用于触发父机器中的转换。
另请参阅 声明式状态机框架。