Qt状态机框架(Qt5.9.3)
文章目录
状态机框架提供了用于创建和执行状态图的类。这些概念和符号基于Harel的 Statecharts: A visual formalism for complex systems,也是UML状态图的基础。状态机执行的语义基于 State Chart XML (SCXML)。
Statecharts提供了一个建模系统来反应图形化方法。通过定义系统可能的状态以及系统如何从一个状态移动到另一个状态(状态之间的转换)来完成的。事件驱动系统(如Qt应用程序)的一个关键特性是行为通常不仅取决于最后一个或当前的事件,而且还取决于之前发生的事件。有了状态图,这个信息很容易表达。
状态机框架提供了一个API和执行模型,可以用来有效地将状态图的元素和语义嵌入到Qt应用程序中。该框架与Qt的元对象系统紧密集成。例如,状态之间的转换可以由信号触发,并且状态可以被配置为在{QObject
}上设置属性并调用方法。 Qt的事件系统被用来驱动状态机。
状态机框架中的状态图是分层的。状态可以嵌套在其他状态中,状态机的当前配置由当前处于活动状态的一组状态组成。状态机的有效配置中的所有状态将具有共同的父对象。
状态机框架中的类
这些类由qt提供,用于创建事件驱动的状态机。
类 | 描述 |
---|---|
QAbstractState | QStateMachine的基类 |
QAbstractTransition | QAbstractState对象之间的转换基类 |
QEventTransition | 针对Qt事件的QObject特定转换 |
QFinalState | 最终状态 |
QHistoryState | 返回到以前活动的子状态 |
QSignalTransition | 基于Qt信号的转换 |
QState | QStateMachine的通用状态 |
QStateMachine | 分层有限状态机 |
QStateMachine::SignalEvent | 代表一个Qt信号事件 |
QStateMachine::WrappedEvent | 继承QEvent并拥有与QObject关联的事件的克隆 |
QKeyEventTransition | 重要事件的转换 |
QMouseEventTransition | 鼠标事件的转换 |
一个简单的状态机
为了演示State Machine 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, SIGNAL(clicked()), s2);
s2->addTransition(button, SIGNAL(clicked()), s3);
s3->addTransition(button, SIGNAL(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");
当输入任何状态时,label的文字将相应地改变。
当进入状态时,QState::entered()
信号被发射,当状态退出时,QState::exited()
信号被发射。在下面的代码片段中,当进入状态s3时,按钮的showMaximized()
槽将被调用,当s3退出时,按钮的showMinimized()
槽将被调用:
QObject::connect(s3, SIGNAL(entered()), button, SLOT(showMaximized()));
QObject::connect(s3, SIGNAL(exited()), button, SLOT(showMinimized()));
自定义状态可以重新实现QAbstractState::onEntry()
和QAbstractState::onExit()
。
状态机结束
上一节中定义的状态机永远不会结束。为了使状态机能够退出,它需要有一个顶层的最终状态(QFinalState
对象)。当状态机进入最终状态时,状态机将发出QStateMachine::finished()
信号并暂停。
在图中引入最终状态所需要做的只是创建一个QFinalState对象,并将其用作一个或多个转换的目标。
通过分组来分享转换
假设我们希望用户可以随时通过单击退出按钮来退出应用程序。为了实现这一点,我们需要创建一个最终状态,并使其成为与Quit按钮的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, SIGNAL(clicked()), s2);
machine.addState(s2);
machine.setInitialState(s1);
QObject::connect(&machine, SIGNAL(finished()), QApplication::instance(), SLOT(quit()));
在这种情况下,我们希望应用程序在状态机完成时退出,因此机器的finished()
信号连接到应用程序的quit()
槽。
子状态可以覆盖继承的转换。例如,下面的代码添加一个转换,当状态机处于状态s12时,使Quit按钮被忽略。
s12->addTransition(quitButton, SIGNAL(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, SIGNAL(entered()), mbox, SLOT(exec()));
s3->addTransition(s1h);
machine.addState(s3);
s1->addTransition(interruptButton, SIGNAL(clicked()), s3);
利用并行状态避免状态的暴涨
假设你想在一个状态机上模拟汽车的一组互斥属性。让我们说我们感兴趣的属性是干净与肮脏,移动与不移动。这将需要四个互斥状态和八个转换才能在所有可能的组合之间表现和自由移动。
如果我们添加了第三个属性(比如说,红色vs蓝色),那么状态的总数就会增加一倍,达到八个。如果我们增加了第四个属性(比如说,封闭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, SIGNAL(finished()), s2);
在想要隐藏组合状态的内部细节时,在组合状态中使用最终状态是非常有用的。即外部世界唯一能够做的就是进入该状态,并在状态完成工作时得到通知。构建复杂(深度嵌套)状态机时,这是一个非常强大的抽象和封装机制。 (在上面的例子中,你当然可以直接从s1的完成状态创建一个转换,而不是依靠s1的finished()
信号,但是结果是s1的实现细节被暴露)。
对于并行状态组,当所有子状态都进入最终状态时,QState::finished()
信号被发出。
无目标转换
一个转换不需要有一个目标状态。没有目标的转换可以像任何其他转换一样被触发。不同的是当一个无目标转换被触发时,它不会引起任何状态改变。这样,您可以在状态机处于特定状态时对信号或事件作出反应,而不必离开该状态。例:
QStateMachine machine;
QState *s1 = new QState(&machine);
QPushButton button;
QSignalTransition *trans = new QSignalTransition(&button, SIGNAL(clicked()));
s1->addTransition(trans);
QMessageBox msgBox;
msgBox.setText("The button was clicked; carry on.");
QObject::connect(trans, SIGNAL(triggered()), &msgBox, SLOT(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:
virtual bool eventTest(QEvent *e)
{
if (e->type() != QEvent::Type(QEvent::User+1)) // StringEvent
return false;
StringEvent *se = static_cast<StringEvent*>(e);
return (m_value == se->value);
}
virtual void onTransition(QEvent *) {}
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的值。
动画属性分配
State Machine API与Qt中的Animation 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, SIGNAL(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, SIGNAL(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, SIGNAL(entered()), messageBox, SLOT(exec()));
s1->addTransition(button, SIGNAL(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, SIGNAL(entered()), messageBox, SLOT(exec()));
s1->addTransition(button, SIGNAL(clicked()), s2);
s2->addTransition(s2, SIGNAL(propertiesAssigned()), s3);
在这个例子中,当点击按钮时,机器将进入s2。它将保持在状态s2中,直到几何属性被设置为QRect(0,0,50,50)。然后它会转换到S3。当输入s3时,弹出消息框。如果转换到s2具有geometry属性的动画,则机器将保持在s2中,直到动画播放结束。如果没有这样的动画,它将简单地设置属性,并立即进入状态S3。
无论哪种方式,当状态机于状态s3时,可以保证属性geometry已经被赋予了定义的值。
如果全局恢复策略设置为QStateMachine :: 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()
,这样当进入子状态机时,它将自动开始运行。
父状态机将子状态机作为状态机算法中的原子状态。子状态机是独立的,它维护自己的事件队列和配置。尤其要注意的是,子状态机的配置()不是父机配置的一部分(只有子机本身)。
子状态机的状态不能被指定为父状态机转换,只有子状态机本身可以。相反,父状态机的状态不能被指定为子状态机转换。子状态机的finished()
信号可以用来触发父机器的转换。