Qt 状态机 C++ 指南

状态机框架提供了用于创建和执行状态图的类。本页面展示了框架在 C++ 中的关键特性。

状态机框架中的 C++ 类

要查看状态机框架中 C++ 类的完整列表,请参阅 Qt 状态机 C++ 类

一个简单的状态机

为了演示状态机 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() 信号。以下代码片段中,当状态 s3 进入时,将调用按钮的 showMaximized() 槽,而当 s3 退出时,将调用按钮的 showMinimized() 槽

    QObject::connect(s3, &QState::entered, button, &QPushButton:showMaximized);
    QObject::connect(s3, &QState::exited, button, &QPushButton::showMinimized);

自定义状态可以重新实现 QAbstractState::onEntry() 和 QAbstractState::onExit()。

终结的状态机

前面章节中定义的状态机永远不会完成。为了让状态机能够完成,它需要一个顶层 final 状态 (QFinalState 对象)。当状态机进入顶层最终状态时,机器将发出 QStateMachine::finished() 信号并停止。

在图上引入最终状态的操作很简单,只需创建一个QFinalState对象,并将其用作一个或多个转换的目标。

通过分组状态实现状态共享

假设我们希望用户能够通过点击“退出”按钮在任何时候退出应用程序。为了实现这一点,我们需要创建一个最终状态,并将其作为与“退出”按钮的clicked()信号关联的转换的目标。我们可以从每个s1s2s3添加转换;然而,这似乎是多余的,还必须记得未来添加的每个新状态也要添加这样的转换。

我们可以通过将状态s1s2s3分组来实现相同的行为(即无论状态机处于哪个状态,点击退出按钮都会退出状态机)。这是通过创建一个新的顶级状态并将三个原始状态作为该新状态的子状态来实现的。以下图表显示了新的状态机。

三个原始状态已被重命名为s11s12s13,以反映它们现在是新的顶级状态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);

转换可以具有任何状态的作为目标,即目标状态不必与源状态处于状态层次结构的同一级别。

使用历史状态来保存和恢复当前状态

想象一下,我们想要向上一节讨论的示例添加一种“中断”机制;用户应该能够点击一个按钮,使状态机执行一些与当前任务无关的任务,之后状态机应恢复它之前正在进行的事情(即返回到旧状态,在这种情况下是s11s12s13之一)。

这种行为可以简单地使用历史状态来建模。历史状态(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);

使用并行状态以避免状态组合爆炸

假设你想在一个单一的状态机中模拟汽车的一些互斥属性。比如说,我们感兴趣的属性是干净与脏,以及移动与不移动。需要四个互斥状态和八个转换才能表示和自由地在所有可能的组合之间移动。

如果我们添加第三个属性(例如,红色与蓝色),状态的总量将翻倍,达到八个;如果我们添加第四个属性(例如,封闭与敞篷),状态的总量将再次翻倍,达到十六。

使用并行状态,当我们添加更多属性时,状态和转换的总数线性增长,而不是指数增长。此外,可以在不影响它们任何兄弟状态的情况下向或从并行状态添加或删除状态。

要创建一个并行状态组,将 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);

当进入并行状态组时,所有其子状态将同时进入。单个子状态内的转换操作正常。然而,任何一个子状态都可以执行一个转换,退出父状态。当这种情况发生时,父状态及其所有子状态都将退出。

状态机框架中的并行主义遵循交错语义。所有并行操作都将在一个单独的事件处理原子的步骤中执行,因此没有事件可以中断并行操作。然而,由于机器本身是单线程的,事件仍将按顺序处理。例如:考虑两个退出同一并行状态组的转换,并且它们的条件同时为真。在这种情况下,最后处理的两个事件中的最后一个将没有任何效果,因为第一个事件已经导致机器退出并行状态。

检测复合状态完成

一个子状态可以是最终的(一个 QFinalState 对象);当一个最终子状态被进入时,父状态发出 QState::finished() 信号。以下图表显示了一个复合状态 s1,它在进入一个最终状态之前执行了一些处理

s1 的最终状态被进入时,s1 将自动发出 finished。我们使用信号转换来引发此事件并触发状态变化

  s1->addTransition(s1, &QState::finished, s2);

在组合状态中使用最终状态非常有用,当你想要隐藏组合状态的内部细节时;也就是说,外部世界应该只能进入状态,并在状态完成其工作后获得通知。这是构建复杂(深度嵌套)状态机时一个非常强大的高级封装机制。(在上面的示例中,当然可以直接从 s1 的 's done 状态创建一个转换,而不是依赖于 s1finished() 信号,但后果是暴露和依赖于 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)。然而,如果显式地将目标状态设置为 s1,则每次都会退出 s1 并重新进入(例如,会发出 QAbstractState::entered() 和 QAbstractState::exited() 信号)。

事件、转换和守护

QStateMachine 运行自己的事件循环。对于信号转换(QSignalTransition 对象),当 QStateMachine 接收到相应信号时,会自动转发一个 QStateMachine::SignalEvent 给自己;同样,对于 QObject 事件转换(QEventTransition 对象)会转发一个 QStateMachine::WrappedEvent

您可以使用 QStateMachine::postEvent() 将您自己的事件发布到状态机。

当将自定义事件发布到状态机时,通常还有一或多个可以从中触发该类型事件的转换。要创建这样的转换,请继承 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有两个子状态:s2s3。当进入转换状态s2时,属性fooBar的值将为2.0,因为在此状态中已显式定义该值。当机器处于状态s3时,未为状态定义值,但s1定义了属性为1.0,因此这是将分配给fooBar的值。

动画和状态机

状态机API连接到The Animation Framework,以允许在属性在状态中分配时自动对其动画化。

状态机提供了一个特殊状态,可以播放动画。当给出一个QPropertyAnimation时,一个QState还可以在进入或退出状态时设置属性,这个特殊动画状态将在这些值之间插值。

我们可以使用一个或多个动画将动画与状态之间的转换相关联,使用QSignalTransitionQEventTransition类。这两个类都源自QAbstractTransition,它定义了便利函数addAnimation(),该函数可以在转换发生时触发附加一个或多个动画。

我们还有将属性与状态相关联而不是设置开始值和结束值的可能性。

假设我们有以下代码

    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);

当点击 button 时,机器将过渡到状态 s2,这将设置按钮的几何形状,然后弹出消息框,提醒用户几何形状已更改。

在正常情况下,如果没有使用动画,这将按预期运行。但是,如果在 s1s2 之间的转换上为 buttongeometry 设置了动画,当进入 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);

在此示例中,当 clic button 时,机器将进入 s2。它将保持处于 s2 状态,直到将 geometry 属性设置为 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分配。

请注意,显式设置在转换上的动画将优于给定的任何默认动画。

嵌套状态机

QStateMachineQState 的子类。这允许状态机成为其他机器的子状态。 QStateMachine 重写了 QState::onEntry() 并调用了 QStateMachine::start(),因此,当进入子状态机时,它将自动开始运行。

父状态机将子机器视为状态机算法中的原子状态。子状态机是自包含的;它维护自己的事件队列和配置。特别是请注意,子机器的configuration() 不是父机器配置的一部分(只有子机器本身)。

子状态机中的状态不能作为父状态机中转换的目标进行指定;只有子状态机本身可以。相反,父状态机中的状态不能作为子状态机中转换的目标指定。可以使用子状态机的finished() 信号来触发父机器的转换。

另请参阅Qt 状态机概述Qt 状态机 QML 指南

© 2024 Qt公司有限公司。本文档中包含的文档贡献是各自所有者的版权。提供的文档根据由自由软件基金会发布的GNU自由文档许可协议第1.3版条款进行许可。GNU自由文档许可协议第1.3版。Qt及其相关标志是Qt公司有限公司在芬兰及/或其他国家的商标。所有其他商标均为其各自所有者的财产。