状态机框架主要为创建和执行状态图提供了类。该部分记录了状态机的基本使用,比如启动流程,不同状态相同转换的封装(分组状态),避免状态组合爆炸的并行状态,自定义转换,以及转换动画的设置。对于状态机应用场景并未提及

简单的状态机

img

实现如上图状态机机制,状态机有三个状态:s1s2s3 。状态机由一个QPushButton 控制;当点击按钮时,状态机会转换到另一个状态。最初,状态机处于s1 状态

  1. 先创建状态机和状态
  2. 创建转换
  3. 状态添加并设置初始状态
  4. 启动
1
2
3
4
5
6
7
8
9
10
11
12
QStateMachine machine;
QState *s1 = new QState();
QState * s2 = new QState();
QState *s3 = new QState();
s1->addTransition(button,&PushButton::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();

但这个状态机既循环不终结,又没做其他有意义的事

  1. 可以通过状态类的方法QState::assignProperty() 函数在状态进入时设置QObject 的属性实现状态进入时操作。如s1->assignProperty(label,”text”,”In state s1”)
  2. 接受进入退出信号做事。进入某状态时会触发entered()信号,退出会触发exited()信号,QObject::connect(s1, &QState::entered, button, &QPushButton:showMaximized);绑定信号槽做事
  3. 最终状态。为了使状态机能够结束,它需要有一个顶层最终状态(QFinalState object)。当状态机进入顶层终态时,状态机将发出QStateMachine::finished() 信号并停止。要在图中引入最终状态,只需创建一个QFinalState 对象,并将其作为一个或多个转换的目标。

分组状态以共享转换

解决问题是对于上述单个按钮的三种状态,如果旁边有一个退出按钮,希望任何状态下都能点击退出按钮实现退出。传统的做法是给三种状态设定最终状态,s1->final,s2->final,s3->final的转化都关联退出按钮的按下信号,这样子就有三条线,有点没必要。

解决办法是将三种状态分组来实现相同的行为,创建一个新的顶层状态,并使原来的三个状态成为新状态的子状态。下图显示了新的状态机。

img

原来的三个状态被重新命名为s11s12s13 ,以反映它们现在是新的顶级状态s1 的子状态。子状态隐式继承了父状态的转换。这意味着现在只需从s1 添加一个过渡到最终状态s2 即可。添加到s1 的新状态也将自动继承这一过渡。

要对状态进行分组,只需在创建状态时指定适当的父级状态。此外,还需要指定哪个子状态为初始状态(即当父状态为转换目标时,状态机应进入哪个子状态)。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 QState();
s1->addTransition(quitButton,&QPushButton::clicked,s2);
machine.addState(s2);
machine.setInitialState(s1);
//这里绑定了状态机结束信号与窗口关闭事件
QObject::connect(&machine, &QStateMachine::finished,
QCoreApplication::instance(), &QCoreApplication::quit);

子状态可以覆盖其从父类继承的转换。例如,下面的代码添加了一个过渡,当状态机处于状态s12 时,该过渡能有效地使 “退出 “按钮被忽略。

s12->addTransition(quitButton, &QPushButton::clicked, s12214235);过渡的目标状态可以是任何状态,即目标状态不必与源状态在状态层次结构中处于同一层级。

历史状态以保存和恢复当前状态

解决问题希望在状态机运行过程中先中断,去执行一些与状态机无关的任务,再回到中断时的状态继续。

解决办法历史状态QHistoryState,是一种伪状态,用以记录父状态最后一次退出时的子状态,当状态机在运行时检测到存在这样一个状态时,它会在父状态退出时自动记录当前(真实)的子状态。向历史状态的转换实际上是向状态机之前保存的子状态的转换;状态机会自动 “转发 “到真实的子状态。所以必须有父状态

img

如图希望在s1父状态时点击中断按钮去做别的事,做完再回来到中断时的子状态

1
2
3
4
5
6
7
8
9
10
11
12
QHistoryState *s1_h = 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);

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

解决问题单状态里面有多个属性,任何一属性变化都会到一新状态,假设两个属性,就有四种互斥状态,有八种转换关系,若属性多了,组合成指数级增长,如下图

imgimg

解决办法使用并行状态组,进入该状态组时,所有子状态均进入,即s11,s12均进入,在其中进行子状态转换。但保证同进同出,任一子状态发生退出父状态的转换,父状态及其所有子状态都要退出。

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

1
2
3
QState *s1 = new QState(QState::ParallelStates);
QState *s11 = new QState(s1);
QState *s12 = new QState(s2);

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

父状态的finish信号时机

子状态可以是最终子状态,当达到最终子状态时,父状态会发出QState::Finished信号,可以使用这个信号来触发状态转化:s1->addTransition(s1,&QState::Finished,s2)

img

鼓励多在复合状态中使用最终状态,可以帮助隐藏复合状态的内部细节,外界唯一能做的就是进入该复合状态以及收到该复合状态的结束信号

比如上图中done指向完全可以不是最终状态节点而是s2,但那样就使得s1的细节暴露出来并受到s2的依赖

特别的,对于并行状态组,当所有子状态都运行到最终状态时,父状态才会发出结束信号。

无目标状态转换

状态过渡并不一定需要目标状态,s1->addTransition(s1,__,s2)这是有目标转换,无目标与之相同,只是不会引起任何状态变化。这样,当机器处于特定状态时,就可以对信号或事件做出反应,而无需离开该状态。

和s1->s1的转换区别就在于,如果转换目标被强制设为s1,那么每次都会退出并重新进入s1

1
2
3
4
5
6
7
8
9
10
11
12
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);

事件循环,信号、事件转换与转换防护

状态机运行他自己的事件循环。

  1. 对于信号转换QSignalTransition对象,状态机截获信号同时会向自己发布QStateMachine::SignalEvent;
  2. 对于QObject事件转换QEventTransition对象,则会发布QStateMachine::WrappedEvent;
  3. 对于自定义事件触发自定义转换。想让状态机在收到自己定义的事件时,从一个状态切换到另一个状态。可以用QStateMachine::PostEvent()向状态机发布自己的事件。

自定义事件转换说明

既要自定义事件,也要重写QAbstractTransition 抽象类的eventTest 纯虚函数。

比起直接用信号转换,自定义事件能携带大量复杂数据,支持多条件筛选,更加灵活。

  1. 定义自定义事件(继承QEvent)。

  2. 定义自定义转换(子类化QAbstractTransition)

  3. “什么样的事件能触发这个转换?”(通过重写 eventTest())“触发后要做什么?”(可选重写 onTransition())。

  • eventTest(QEvent *event):核心筛选函数,返回 true 表示 “这个事件符合条件,触发转换”;返回 false 表示 “跳过这个事件”。筛选逻辑可以是:① 事件类型是否匹配(比如是不是 MyEvent);② 事件的属性是否满足(比如 MyEvent 中的 status 是否为 Success)。
  • onTransition(QEvent *event):转换执行时的回调(可选),比如触发转换后更新 UI、打印日志等。

如下例子,定义自己的事件与防护转换

eventTest() 的重新实现中,我们首先检查事件类型是否为所需类型;如果是,我们就将事件转换为StringEvent 并执行字符串比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class 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):_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 _value == se->value;
}
void onTransition(QEvent *) override {}
private:
QString _value;
}

img

实现上图状态机的转化代码如下

1
2
3
4
5
6
StringTransition trans1 = new StringTransition("Hello");
trans1->setTargetState(s2);
s1->addTransition(trans1);

状态机启动就可向其发布事件
machine.postEvent(new StringEvent("Hello"));

未被任何相关转换处理的事件将被状态机静默消耗,对状态进行分组并提供对此类事件的默认处理方式可能很有用

img

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

解决问题将焦点聚焦到状态机分配状态中的属性上,而不是每次状态机结束后都去关心如何让这个状态机各属性还原。即收购手机仅仅关注开机后功能是否正常而不去关心需不需要恢复出厂设置以便二次销售。

machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);

设置该还原策略后,机器将自动还原所有属性。如果机器进入一个未设置给定属性的状态,它将首先搜索祖先层次结构,看看该属性是否在那里定义。如果有,则会将该属性还原为最接近的祖先所定义的值。如果没有,则会还原为初始值(即执行状态中任何属性赋值之前的属性值)。

1
2
3
4
5
6
7
QState *s1 = new QState();
s1->assignProperty(object, "fooBar", 1.0);
machine.addState(s1);
machine.setInitialState(s1);
QState *s2 = new QState();
machine.addState(s2);
QState *s3 = new QState(s1);

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

如果是嵌套状态的话,当机器处于状态s3 时,没有为该状态定义任何值,但s1 将属性定义为 1.0,因此这就是将分配给fooBar 的值。

嵌套状态机

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

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

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

状态机与动画

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

可以使用QSignalTransitionQEventTransition 类将一个或多个动画与状态之间的转换关联起来。这两个类都派生自QAbstractTransition ,后者定义了方便函数addAnimation() ,可在过渡发生时添加一个或多个触发动画。

还可以将属性与状态关联起来,而不是自己设置开始和结束值。

1
2
3
4
5
6
7
8
9
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 * trans = s1->addTransition(button, &QPushButton::clicked, s2);
//trans->addAnimation(new QPropertyAnimation(button,"geometry"));给geometry自动平滑插值
进入s1状态->50,50->点击按钮进入s2状态->动画开始值(5050)->平滑插值->100,100

点击按钮,从s1 过渡到s2 ,当进入给定状态时,按钮的几何图形将立即被设置。不过,如果希望过渡平滑,只需制作一个QPropertyAnimation 并将其添加到过渡对象中即可。

为相关属性添加动画意味着在进入状态后,属性分配不再立即生效。相反,动画将在进入状态后开始播放,并平稳地为属性赋值制作动画。由于我们没有设置动画的起始值或结束值,因此将隐式设置这两个值。动画的开始值将是动画开始时属性的当前值,而结束值将根据为状态定义的属性赋值来设置。

如果状态机的全局还原策略设置为 QStateMachine::RestoreProperties,则还可以为属性还原添加动画。

检测一个状态中所有属性值均被设置好了

既然上面插入动画后,进入某状态,属性值不是立即给出而是由动画根据动画效果不同而自动插值的。即动画运行期间,该属性可能由任意值。所以在某些情况下,需要检测进入某状态后的属性值是否已经被设置为该状态应该的值了。

解决问题比如s1->s2,我期望进入s2就输出某个值,算了不直观,用QRect举例子。

1
2
3
4
5
6
7
8
9
10
11
12
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->addTransition(s2,&QState::propertiesAssigned,s3)
那么这个转换将在s2中属性被分配最终值时发生

没有动画的时候,点击按钮,发生转换,触发entered()信号,消息框输出当前按钮形状。

但是当插入了动画状态,geometry属性的值就不一定能达到预定值了,即消息框在按钮的几何图形实际设置之前弹出。

解决办法为了确保在几何图形实际达到最终值之前消息框不会弹出,我们可以使用状态propertiesAssigned() 信号。propertiesAssigned() 信号将在属性被分配最终值时发出,无论该值是立即分配还是在动画播放结束后分配。如果在过渡到s2 的过程中为geometry 属性设置了动画,那么机器将一直处于s2 状态,直到动画播放完毕。如果没有这样的动画,则会简单地设置属性并立即进入状态s3

如果全局还原策略设置为 QStateMachine::RestoreProperties,那么在执行这些策略之前,状态不会发出propertiesAssigned() 信号。

若动画结束前,该状态结束了如何

解决问题如果一个状态有属性赋值,而进入该状态的转换有属性动画,如上例s2,那么在属性值到达预定值之前就存在状态退出的可能,如果不依赖QState::propertiesAssigned,更加明显。

状态机的行为取决于转换目标的状态,如s1->s2,s2要求属性值为(100,100),但s2->s3,s3不要求有该属性值。假设上述两个转换都有动画效果。

  1. 如果该转换是明确的为目标赋值,如1->2那么属性将被赋予目标状态定义的值,即默认该转换完成。

  2. 如果该转换的目标状态没有为属性赋值,如2->3

  3. 全局还原策略优先,如果设置了该策略。属性值照常还原。即为s1(0,0);

  4. 默认情况下,目标状态无属性定义时,“离开状态的属性定义” 是属性值的 “最终基准”,动画只是过渡,不改变这个基准。即为s2(100,100)

  5. 一句话,全局还原则「退回到上一个状态(s1)的属性值」,默认则「保留当前状态(s2)的属性值」

默认动画

先前是给从一个状态到另一个状态的转换增加动画trans->addAnimation。

也可以给某个属性增加动画而不仅仅是转换,这样可以不用考虑是何种转换就可以添加动画。

1
2
3
s2->assignProperty(object,"fooBar",2.0);
s1->addTransition(s2);
machine.addDefaultAnimation(new QPropertyAnimation(object,"foobar");

当机器处于状态s2 时,机器将播放属性fooBar 的默认动画,因为该属性是由s2 分配的。

请注意,在转换时明确设置的动画>给定属性的默认动画。