Qt/C++ Qt状态机框架(QStateMachine)的简明指南

6 篇文章 1 订阅

1. 简介

本篇博文主要参考 Qt State Machine C++ Guide原文,并增加了自己实践过程中的理解。下面就直奔主题。

Qt状态机框架提供了创建和执行状态图的类。概念和符号基于Harel的状态图,这也是UML状态图的基础。状态机执行的语义基于状态图XML (SCXML)。

状态图提供了一种图形化的方式来模拟系统对刺激的反应。这是通过定义系统可能处于的状态,以及系统如何从一个状态转移到另一个状态(状态之间的转换)来实现的。事件驱动系统(如Qt应用程序)的一个关键特征是,行为通常不仅依赖于上一个或当前事件,还依赖于之前的事件。有了状态图,这些信息很容易表达。

状态机框架提供了API和执行模型,可以用来在Qt应用程序中有效地嵌入状态图的元素和语义。该框架与Qt的元对象系统紧密集成;例如,状态之间的转换可以由信号触发,状态可以配置为用于设置QObject上的属性和调用其方法,状态机则由Qt的事件系统驱动。

状态机框架中的状态图是分层的。状态可以嵌套在其他状态中,状态机的当前配置由当前活动的状态集组成。状态机的有效配置中的所有状态将具有共同的祖先。

2. 状态机框架中的类

这些类由qt提供,用于创建事件驱动的状态机。

说明
QAbstractStateThe base class of states of a QStateMachine
QAbstractTransitionThe base class of transitions between QAbstractState objects
QEventTransitionQObject-specific transition for Qt events
QFinalStateFinal state
QHistoryStateMeans of returning to a previously active substate
QKeyEventTransitionTransition for key events
QMouseEventTransitionTransition for mouse events
QSignalTransitionTransition based on a Qt signal
QStateGeneral-purpose state for QStateMachine
QStateMachineHierarchical finite state machine
QStateMachine::SignalEventRepresents a Qt signal event
QStateMachine::WrappedEventInherits QEvent and holds a clone of an event associated with a QObject

3. 一个简单的状态机例子

为了演示Qt状态机API的核心功能,让我们看一个小例子:一个状态机有三个状态,s1、s2和s3。状态机由单个QPushButton控制;点击按钮时,机器转换到另一种状态。最初,状态机处于状态s1。这台机器的状态图如下:

简单的状态机例子

下列代码用于创建这样一个状态机。首先,我们创建状态机和状态:

QStateMachine machine;
auto *s1 = new QState();
auto *s2 = new QState();
auto *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();

状态机异步执行,即它将接入应用程序的事件循环。

4. 状态与状态切换信号的用法

上面的例子中展示了简单的状态切换功能,但并没有触发任何动作。这并不是我们想要的,相反,我们关心的是状态进入/退出时触发的信号,以及切换本身触发的信号。下面就来看看如何使用它们。

先看看如何使用状态的进入/退出的信号。在这个例子中,我们使用了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::assignProperty()函数间接使用状态的进入信号之外,Qt还显式地提供了状态进入和退出时发出的信号:进入状态时会发出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()函数以加入我们想要的业务逻辑。

再来看看如何使用状态切换触发的信号。在上一节中,当我们向状态添加转换时,可以得到这个转换的实例。当转换发生时,会发出triggered()信号,我们可以将其连接到我们自定义的槽函数。

auto *trans = s1->addTransition(button, SIGNAL(clicked()), s2);
QObject::connect(trans, SIGNAL(triggered()), this, SLOT(showMinimized()));

乍一看,大家的反应可能是“就这?”。在这个例子中,似乎这个transtriggered信号显得有些鸡肋,因为我们直接使用buttonclicked()信号即可。但是,在一些实际的应用场景中,我们可能需要根据当前的状态不同,对同一个信号做出不同的响应。例如,假设s1和s2都监听了buttonclicked信号,我们需要根据当前状态是s1还是s2分别执行不同的动作,并进行状态切换。这里假设存在如下两个状态转换:

  • s1在收到buttonclicked信号后,执行doSomething1()业务逻辑,然后切换到s2;
  • s2在收到buttonclicked信号后,执行doSomething2()业务逻辑,然后切换到s3。

如果不使用Qt的状态机框架,我们会很自然的想到使用一个状态变量(m_curState)来记录当前的状态,并用枚举类型来表示各种状态:

  • s1-STATE_1
  • s2-STATE_2
  • s3-STATE_3

然后在buttonclicked()的槽函数中根据状态来决定要执行的动作。例如下列代码片段:

MyWdiget::MyWidget(QWidget *parent) {
    ...
    QObject::connect(button, SIGNAL(clicked()), this, SLOT(onButtonClicked()));
}

void MyWdiget::onButtonClicked()
{
    if (m_curState == STATE_1) {
        doSomething1();
        m_curState = STATE_2;
    }
    else {
        doSomething2();
        m_curState = STATE_3;
    }
}

如果我们有更多的状态需要监听同一个事件,那这里的if语句或者switch/case语句就会显得很不优雅,并且,随着状态的增加,对状态变量的维护就会变得越来越困难,非常容易出错,而且难以理解。

此时,这个切换动作就会显现出奇妙的效果。同样的需求,我们用Qt状态机框架来实现,其思路就是实现定义好几个状态,然后利用状态切换的事件来触发我们想要的业务逻辑。针对这个例子,我们便可以按照下面的方式来编码:

auto *s1Tos2Trans = s1->addTransition(button, SIGNAL(clicked()), s2);
QObject::connect(s1Tos2Trans , SIGNAL(triggered()), this, SLOT(doSomething1()));

auto *s2Tos3Trans = s2->addTransition(button, SIGNAL(clicked()), s3);
QObject::connect(s2Tos3Trans , SIGNAL(triggered()), this, SLOT(doSomething2()));

如此一来,当button被点击时,Qt的状态机框架就会替我们完成状态的切换,并且在合适的状态调用合适的槽函数:

  • 当此时处于s1时,就会替我们调用doSomething1()
  • 当此时处于s2时,就会替我们调用doSomething2()

这样的代码更容易理解和维护。

5. 结束状态机

前面的例子中定义的状态机永远不会结束。为了让状态机能够结束,它需要有一个顶级的最终状态(QFinalState对象)。当状态机进入顶级最终状态时,它将发出QStateMachine::finished()信号并停止。

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

6. 通过对状态分组来共享转换

假设我们希望用户能够在任何时候通过点击退出按钮来退出应用程序。为了实现这一点,我们需要创建一个最终状态,并使其成为与Quit按钮的clicked()信号相关联的转换的目标。我们可以从s1、s2和s3中的每一个添加一个过渡;然而,这似乎是多余的,我们还必须记住以后每添加一个新状态,都需要添加这样的转换。

为此,Qt状态机框架提供了状态分组的机制,我们可以通过对状态s1、s2和s3进行分组来实现相同的行为(即,单击退出按钮退出状态机,而不管状态机处于哪个状态)。这是通过创建一个新的顶级状态并使三个原始状态成为新状态的子状态来实现的。下图显示了新的状态机。

状态分组例子

这三个原始状态已被重命名为s11、s12和s13,以反映它们现在是顶级状态s1的子状态。子状态隐式继承其父状态的转换。这意味着现在添加从s1到最终状态s2的单个转变就足够了。后续新添加到s1的子状态也将自动继承这种转换。

对状态进行分组所需要的就是在创建状态时指定正确的父状态。还需要为父状态指定哪个子状态是初始状态(即,当父状态是转换的目标时,状态机应该进入哪个子状态)。

auto *s1 = new QState();
auto *s11 = new QState(s1);
auto *s12 = new QState(s1);
auto *s13 = new QState(s1);
s1->setInitialState(s11);
machine.addState(s1);
auto *s2 = new QFinalState();
s1->addTransition(quitButton, SIGNAL(clicked()), s2);
machine.addState(s2);
machine.setInitialState(s1);

QObject::connect(&machine, SIGNAL(finished()),
                 QCoreApplication::instance(), SLOT(quit()));

在这种情况下,我们希望应用程序在状态机完成时退出,因此状态机的finished()信号连接到应用程序的quit()槽。

此外,子状态可以覆盖继承的转换。例如,下面的代码添加了一个转换,当状态机处于状态s12时,该转换可以让Quit按钮被忽略。

s12->addTransition(quitButton, SIGNAL(clicked()), s12);

转换可以将任何状态作为其目标,即目标状态不必与源状态在状态层次结构中处于同一级别。若转换的目标是一个非相同父亲的子状态,那么状态机发出的进入/退出信号的顺序将是下面的样子:

  1. 当前状态的exited()
  2. 当前状态的父状态的exited()
  3. 新的状态的父状态的entered()
  4. 新的状态的entered()

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

假设我们想给上一节讨论的例子添加一个“中断”机制;用户应该能够点击一个按钮来使状态机执行一些不相关的任务,之后状态机应该恢复它之前正在做的任何事情(即返回到旧状态,在这个例子中是s11、s12和s13之一)。

使用历史状态可以很容易地实现这种行为。历史状态(QHistoryState对象)是一个伪状态,它表示父状态最后一次退出时所处的子状态。

历史状态被创建为某个状态的子状态,其记录的就是其父状态退出时的当前子状态;当状态机在运行时检测到这种状态的存在时,它会在退出父状态时自动记录当前(真实)的子状态。向历史状态的转换实际上是向状态机先前保存的子状态的转换;状态机自动将转换“转发”到真实的子状态。

下图显示了添加中断机制后的状态机。

添加中断机制后的状态机
下面的代码展示了如何实现它:在本例中,我们只是在进入s3时显示一个消息框,然后通过历史状态立即返回到s1的前一个子状态。

auto *s1h = new QHistoryState(s1);

auto *s3 = new QState();
s3->assignProperty(label, "text", "In s3");
auto *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);

8. 使用平行状态来避免状态的组合爆炸

当你以为Qt的状态机框架到这里就结束了,其实它还有更加方便的功能。假设我们想要在单个状态机中对一辆汽车的一组互斥属性进行建模。假设我们感兴趣的属性是Clean与Dirty,Moving与Not moving。那么,我们需要创建四个互斥状态和八个转换才能表示所有可能的组合并在它们之间自由转换。

两组互斥的状态

如果我们增加第三个属性(比方说,红色与蓝色),状态的总数会加倍,达到八个;如果我们增加第四个属性(比如,燃油发动机和电动机),状态的总数会再翻一倍,达到16个。长此以往,我们会崩溃的。

为此,Qt提供了平行状态的机制。借助平行状态,随着我们添加更多的属性,状态和转换的总数线性增长,而不是指数增长。此外,状态可以在不影响它们的任何兄弟状态的情况下,添加到平行状态或从平行状态中删除。平行状态是一个顶级状态,它包含需要平行的状态组,这个例子的平行状态示意图如下:

平行状态

可以看到,平行状态s1中包含了两个状态组s11和s12,并且将Clean状态和Dirty状态划归到了s11状态组,Moving和Not Moving划归到了s12状态组(关于分组则参看通过状态分组来共享转换)。

若要创建平行状态组,请将QState::ParallelStates传递给QState构造函数。

auto *s1 = new QState(QState::ParallelStates);
// s11 and s12 will be entered in parallel
auto *s11 = new QState(s1);
auto *s12 = new QState(s1);

当进入平行状态组时,将同时进入其所有子状态。各个子状态中的转换正常运行。然而,任何子状态都可以进行退出父状态的转换。发生这种情况时,父状态及其所有子状态都将退出。

状态机框架中的并行性遵循交错语义(interleaved)。所有并行操作将在事件处理的单个原子步骤中执行,因此没有事件可以中断并行操作。然而,事件仍将被顺序处理,因为状态机本身是单线程的。举个例子:考虑这样一种情况,有两个转换退出同一个平行状态组,它们的条件同时变为真。在这种情况下,两个事件中最后处理的事件不会有任何影响,因为第一个事件已经导致状态机退出平行状态。

9. 检测复合状态已经结束

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

复合状态的结束

当进入s1的最终状态时,s1会自动发出finished()。这里我们需要使用信号转换来使该事件触发状态变化:

s1->addTransition(s1, SIGNAL(finished()), s2);

当我们想要隐藏复合状态的内部细节时,在复合状态中使用最终状态非常有用:即,外部世界能够做的唯一事情是进入状态,并在状态完成其工作时接收通知。在构建复杂(深度嵌套)的状态机时,这是一种非常强大的抽象和封装机制。(在上面的例子中,我们也可以直接从s1的done状态创建一个转换,而不是依赖于s1的finished()信号,但是结果是s1的实现细节被公开,外部则需要依赖s1的内部实现细节)。

对于平行状态组,当所有子状态都进入最终状态时,会发出QState::finished()信号。

10. 无目标的转换

转换不需要有目标状态。没有目标的转换可以像任何其他转换一样被触发;不同之处在于,当无目标转换被触发时,它不会导致任何状态变化。这允许我们在状态机处于特定状态时对信号或事件做出反应,而不必离开该状态。示例:

QStateMachine machine;
auto *s1 = new QState(&machine);

QPushButton button;
auto *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::enter()QAbstractState::exited()信号。

11. 事件、转换和守卫

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;
auto *s1 = new QState();
auto *s2 = new QState();
auto *done = new QFinalState();

auto *t1 = new StringTransition("Hello");
t1->setTargetState(s2);
s1->addTransition(t1);
auto *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"));

未被任何相关转换处理的事件将被状态机静默消耗。我们可以通过对状态进行分组并提供对此类事件的默认处理;例如,如下图所示:

带默认处理的状态机图

对于深度嵌套的状态图,我们可以在最合适的粒度级别添加这种“回退”转换。

12. 使用还原策略自动还原属性

在一些状态机中,当状态不再活动时,我们可能需要将注意力集中在设置状态中的属性,而不是恢复它们。我们如果知道当状态机进入未显式指定属性值的状态时,属性应始终还原为其初始值,则可以将全局还原策略设置为QStateMachine::RestoreProperties

QStateMachine machine;
machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);

设置此还原策略后,状态机将自动还原所有属性。如果它进入未设置给定属性的状态,它将首先搜索祖先的层次结构,以查看该属性是否在那里定义。如果是,该属性将被还原为由最近的祖先定义的值。如果不是,它将被恢复到它的初始值(即,在状态中的任何属性设置被执行之前的属性的值。)

以下面的代码为例:

QStateMachine machine;
machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);

auto *s1 = new QState();
s1->assignProperty(object, "fooBar", 1.0);
machine.addState(s1);
machine.setInitialState(s1);

auto *s2 = new QState();
machine.addState(s2);

假设当状态机启动时,fooBar属性为0.0。当状态机处于状态s1时,属性将是1.0,因为状态明确地将这个值赋给它。当状态机处于状态s2时,没有为该属性显式定义值,因此它将隐式恢复为0.0。

如果我们使用嵌套状态,则父亲为该属性定义一个值,该值由所有未显式为该属性赋值的后代继承。

QStateMachine machine;
machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);

auto *s1 = new QState();
s1->assignProperty(object, "fooBar", 1.0);
machine.addState(s1);
machine.setInitialState(s1);

auto *s2 = new QState(s1);
s2->assignProperty(object, "fooBar", 2.0);
s1->setInitialState(s2);

auto *s3 = new QState(s1);

这里s1有两个子状态:s2和s3。当输入s2时,属性fooBar将具有值2.0,因为这是为状态显式定义的。当状态机处于状态s3时,没有为状态定义值,但是s1将属性定义为1.0,所以fooBar将被赋值为1.0。

13. 嵌套状态机

QStateMachineQState的子类。这允许一个状态机成为另一个状态机的子状态。QStateMachine重新实现QState::onEntry()并调用QStateMachine::start(),这样当进入子状态机时,就会自动开始运行。

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

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

14. Reference

  1. Qt State Machine C++ Guide
  • 9
    点赞
  • 58
    收藏
    觉得还不错? 一键收藏
  • 4
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值