概述
状态机框架提供了用于创建和执行状态图的类。这些概念和符号基于Harel的Statecharts:复杂系统的可视化形式(http://www.wisdom.weizmann.ac.il/~dharel/SCANNED.PAPERS/Statecharts.pdf),也是UML状态图的基础。状态机执行的语义基于状态图XML (SCXML)(http://www.w3.org/TR/scxml/)。
状态图提供了一种图形化的方式来模拟系统对刺激的反应。这是通过定义系统可能处于的状态,以及系统如何从一个状态移动到另一个状态(状态之间的转换)来完成的。事件驱动系统(如Qt应用程序)的一个关键特征是,行为通常不仅取决于最近的或当前的事件,还取决于在它之前的事件。使用状态图,这些信息很容易表达。
状态机框架提供了一个API和执行模型,可用于在Qt应用程序中有效地嵌入状态图的元素和语义。该框架与Qt的元对象系统紧密集成;例如,状态之间的转换可以由信号触发,状态可以配置为在{QObject}上设置属性和调用方法。Qt的事件系统用于驱动状态机。
状态机框架中的状态图是分层的。状态可以嵌套在其他状态中,状态机的当前配置由当前活动的一组状态组成。状态机的有效配置中的所有状态都有一个共同的祖先。
状态机框架中的类
这些类由qt提供,用于创建事件驱动的状态机。
QAbstractState | The base class of states of a QStateMachine |
QAbstractTransition | The base class of transitions between QAbstractState objects |
QEventTransition | QObject-specific transition for Qt events |
QFinalState | Final state |
QHistoryState | Means of returning to a previously active substate(返回到先前活跃的子状态的方法) |
QKeyEventTransition | Transition for key events |
QMouseEventTransition | Transition for mouse events |
QSignalTransition | Transition based on a Qt signal |
QState | 通用状态 for QStateMachine |
QStateMachine | Hierarchical finite state machine(层次有限状态机) |
QStateMachine::SignalEvent | Represents a Qt signal event |
QStateMachine::WrappedEvent | Inherits QEvent and holds a clone of an event associated with a 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的文本属性的值是为每个状态指定的:
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()信号。在下面的代码片段中,按钮的showMaximized()插槽将在状态s3进入时被调用,按钮的showMinimized()插槽将在s3退出时被调用:
QObject::connect(s3, &QState::entered, button, &QPushButton::showMaximized);
QObject::connect(s3, &QState::exited, button, &QPushButton::showMinimized);
自定义状态可以重新实现QAbstractState::onEntry()和QAbstractState::onExit()。
结束状态机
上一节中定义的状态机永远不会结束。为了使状态机能够完成,它需要有一个顶级的最终状态(QFinalState对象)。当状态机进入顶级最终状态时,机器将发出qstatemmachine::finished()信号并停止。
要在图中引入最终状态,所需要做的就是创建一个QFinalState对象,并将其用作一个或多个转换的目标。
通过状态分组来实现共享转换
假设我们希望用户能够通过单击quit按钮随时退出应用程序。为了实现这一点,我们需要创建一个最终状态,并将其作为与Quit按钮的clicked()信号相关联的转换的目标。我们可以在s1 s2 s3中添加一个变换;然而,这似乎是多余的,并且还必须记住在将来添加的每个新状态中添加这样的转换。
通过对状态s1、s2和s3进行分组,我们可以实现相同的行为(即单击Quit按钮退出状态机,而不管状态机处于哪种状态)。这是通过创建一个新的顶级状态并将三个原始状态设置为新状态的子状态来实现的。下图显示了新的状态机。
最初的三个状态被重新命名为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时忽略Quit按钮。
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蓝色),那么状态总数将翻倍,达到8个;如果我们再加上第四个属性(比如,封闭的vs可转换的),状态的总数将再次翻倍,达到16个。
使用并行状态,随着我们添加更多属性,状态和转换的总数呈线性增长,而不是呈指数增长。此外,可以将状态添加到并行状态或从并行状态中删除,而不会影响它们的任何兄弟状态。
要创建并行状态组,将QState::ParallelStates传递给QState构造函数。
QState *s1 = new QState(QState::ParallelStates);
// s11 and s12 will be entered in parallel
QState *s11 = new QState(s1);
QState *s12 = new QState(s1);
当进入一个并行状态组时,将同时进入它的所有子状态。各个子状态中的转换正常运行。然而,任何一个子状态都可以进行退出父状态的转换。当这种情况发生时,父状态及其所有子状态都将退出。
状态机框架中的并行性遵循交错语义。所有并行操作都将在事件处理的单个原子步骤中执行,因此没有任何事件可以中断并行操作。但是,事件仍将按顺序处理,因为机器本身是单线程的。举个例子:考虑这样一种情况:有两个转换退出同一个并行状态组,并且它们的条件同时为真。在这种情况下,两个事件中最后处理的事件将不会产生任何影响,因为第一个事件已经导致机器从并行状态退出。
检测一种复合状态是否已结束
子状态可能已经final(一个QFinalState对象);当进入最后一个子状态时,父状态发出QState::finished()信号。下图显示了一个复合状态s1,它在进入最终状态之前做了一些处理:
当进入s1的最终状态时,s1将自动发出finished()。我们使用一个信号转换来触发这个事件来触发状态改变:
s1->addTransition(s1, &QState::finished, s2);
当你想隐藏复合状态的内部细节时,在复合状态中使用最终状态是很有用的;也就是说,外部世界唯一能做的就是进入状态,并在状态完成其工作时获得通知。在构建复杂的(深度嵌套的)状态机时,这是一种非常强大的抽象和封装机制。(在上面的示例中,您当然可以直接从s1的done状态创建转换,而不是依赖s1的finished()信号,但结果是暴露并依赖s1的实现细节)。
对于并行状态组,QState::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)。但是,如果将目标状态显式地设置(setTargetState)为s1,则每次都会退出并重新进入s1(例如,会发出QAbstractState::entered()和QAbstractState::exit()信号)。
通过事件触发转换
qstatemmachine运行自己的事件循环。对于信号转换(QSignalTransition对象),当QStateMachine拦截相应的信号时,它会自动向自己发送一个QStateMachine::SignalEvent;类似地,对于QObject事件转换(QEventTransition对象),发送QStateMachine::WrappedEvent。
您可以使用qstatemmachine::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"));
未由任何相关转换处理的事件将被状态机静默地使用。对状态进行分组并提供此类事件的默认处理可能很有用;例如,如下图所示:
对于深度嵌套的状态图,您可以在最合适的粒度级别上添加这种“回退”转换。
使用恢复策略自动恢复属性
在一些状态机中,将注意力集中在分配状态属性上,而不是在状态不再活动时恢复状态,这可能会很有用。如果您知道,当机器进入没有显式地为属性赋值的状态时,属性应该始终恢复到其初始值,那么您可以将全局恢复策略设置为qstatemmachine::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中,按钮很小,而在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"));
为有问题的属性添加动画意味着当进入状态时,属性分配将不再立即生效。相反,动画将在进入状态时开始播放,并平滑地为属性分配动画。因为我们没有设置动画的开始值或结束值,这些将隐式设置。动画的开始值将是动画开始时属性的当前值,结束值将根据为状态定义的属性分配来设置。
如果将状态机的全局恢复策略设置为qstatemmachine::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之间的转换上设置了按钮几何形状的动画,则动画将在输入s2时开始,但是在动画完成运行之前,几何形状属性实际上不会达到其定义的值。在这种情况下,消息框将在按钮的几何形状实际设置之前弹出。
为了确保消息框在几何图形实际达到最终值之前不会弹出,我们可以使用状态的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,直到几何属性被设置为QRect(0,0,50,50)。然后它会变成s3。当输入s3时,会弹出消息框。如果转换到s2有一个几何属性的动画,那么机器将停留在s2,直到动画完成播放。如果没有这样的动画,它将简单地设置属性并立即进入状态s3。
无论哪种方式,当机器处于状态s3时,您都可以保证属性geometry已被分配了定义的值。
如果将全局恢复策略设置为qstatemmachine::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"));
嵌套状态机
QStateMachine是QState的子类。这允许一个状态机成为另一个机器的子状态。QStateMachine重新实现了QState::onEntry()并调用了QStateMachine::start(),这样当进入子状态机时,它将自动开始运行。
父状态机将子状态机视为状态机算法中的原子状态。子状态机是自包含的;它维护自己的事件队列和配置。特别要注意的是,子机器的配置()不是父机器配置的一部分(只有子机器本身是)。
子状态机的状态不能指定为父状态机中转换的目标;只有子状态机本身可以。相反,不能将父状态机的状态指定为子状态机中转换的目标。子状态机的finished()信号可用于触发父状态机中的转换。
The State Machine Framework | Qt Core 5.15.17
qml:The Declarative State Machine Framework | Qt QML 5.15.17