Lv.的博客

qt 状态机框架初探

状态机框架       

       Qt中的状态机框架为我们提供了很多的API和类,使我们能更容易的在自己的应用程序中集成状态动画。这个框架是和Qt的元对象系统机密结合在一起的。比如,各个状态之间的转换是通过信号触发的,状态可被配置为用来设置QObject对象的属性以及调用其方法。可以说Qt中的状态机就是通过Qt自身的事件系统来驱动的。同时,状态机中的状态图是分层次的。一些状态可以被嵌套到另一些状态里,当前的状态机配置是由当前活动的所有状态组成的。在一个状态机的有效配置中的所有状态具有共同的祖先。

       一个简单的状态机

       为了阐述Qt状态机API的核心功能,我们先从一个小的例子说起:这个状态机只有三个状态,s1,s2,s3。我们通过一个按钮的点击来控制这个状态机中状态的转换;当按钮被点击时,就会发生一次状态转换,从一个状态到另一个状态。初始情况下,状态机处于s1状态。这个状态机所对应的状态图如下:

下面,我们先来看下怎么通过Qt代码来实现这个简单的状态机。

第一步,我们创建一个状态机和需要的状态:

 QStateMachine machine;

QState *s1 = new QState();

QState *s2 = new QState();

QState *s3 = new QState();

 

第二步,我们使用QState::addTransition() 函数为这些状态之间添加过渡:

 

  1.  s1->addTransition(button, SIGNAL(clicked()), s2);
  2.  s2->addTransition(button, SIGNAL(clicked()), s3);
  3.  s3->addTransition(button, SIGNAL(clicked()), s1);

 

第三步,将上面创建的三个状态添加到状态机进行管理,并为我们的状态机设置一个初始状态:

  1.  machine.addState(s1);
  2.  machine.addState(s2);
  3.  machine.addState(s3);
  4.  machine.setInitialState(s1);
最后,我们启动状态机即可:
machine.start();
这样,我们的状态机就开始异步的运行了,也就是说,它成为了我们应用程序事件循环的一部分了。这也对应了我们上面说的,Qt的状态机是通过Qt自身的事件机制来驱动的。

 

        在状态转换时操作QObject对象

        上面所创建的状态机,作为入门,我们仅仅进行了状态机中各个状态之间的见转换,而未进行其他的工作。其实,我们可以使用QState::assignProperty() 函数当进入某个状态时让其去修改某个QObject对象的属性。例如下面的代码,当进入各个状态时,改变QLabel的text属性,即改变QLabel上显示的文本内容:

 

  1.  s1->assignProperty(label, "text", "In state s1");
  2.  s2->assignProperty(label, "text", "In state s2");
  3.  s3->assignProperty(label, "text", "In state s3");
当进入任一状态时,label的文本都会发生改变。

 

       除了操作QObject对象的属性外,我们还能通过状态的转换来调用QObject对象的函数。这是通过使用状态转换时发出的信号完成的。其中,当进入某个状态时会发出QState::enterd() 信号,当退出某个状态时会发出QState::exited() 信号。例如下面的代码,其实现的功能即为当进入s3状态时,会调用按钮的showMaximized() 函数,当退出s3状态时会调用showMinimized() 函数:

 

  1.  QObject::connect(s3, SIGNAL(entered()), button, SLOT(showMaximized()));
  2.  QObject::connect(s3, SIGNAL(exited()), button, SLOT(showMinimized()));
            状态机的结束

 

       我们在上面创建的状态机是永远不会结束的。为了使一个状态机在某种条件下结束,我们需要创建一个顶层的final 状态(QFinalState object) 。当状态机进入一个顶层的final 状态时,会发出finished() 信号,然后结束。所以,我们只需要为上面的状态图引入一个final 状态,并把它设置为某个过渡的目标状态即可。这样,当状态机在某种条件下转换到该状态时,整个状态机结束。

       通过状态分组来共享过渡

       假设我们想让用户随时通过点击退出按钮来退出整个应用程序。为了实现这个需求,我们需要创建一个final状态并使他成为和按钮的clicked()信号相关联的那个过渡的目标状态。一种办法是我们为状态s1,s2,s3分别添加一个到final状态的过渡,但这看上去有点多余,并且不利于将来的扩张。第二种方法就是将状态s1,s2,s3分成一组。我们通过创建一个新的顶层状态并使s1,s2,s3成为其孩子来完成。下面是这种方法所对应的状态转换图:

       上面的三个状态被重命名为s11,s12,s13以此来表明它们是s1的孩子。子状态会隐式的继承父状态的过渡。这意味着我们目前可以只添加一个s1到final状态s2的过渡即可,s11,s12,s13会继承这个过渡,从而无论在什么状态均可退出应用程序。并且,将来新添加到s1的新的子状态也会自动继承这个过渡。

        而所谓的分组,就是只需在创建状态时为其指定一个合适的父状态即可。当然,还需要为这组状态指定一个初始状态,即当s1是某个过渡的目标状态时,状态机应该进入哪个子状态。简单的实现代码如下:

  1.  QState *s1 = new QState();
  2.  QState *s11 = new QState(s1);
  3.  QState *s12 = new QState(s1);
  4.  QState *s13 = new QState(s1);
  5.  s1->setInitialState(s11);
  6.  machine.addState(s1);
  7.  QFinalState *s2 = new QFinalState();
  8.  s1->addTransition(quitButton, SIGNAL(clicked()), s2);
  9.  machine.addState(s2);
  10.  machine.setInitialState(s1);
  11.   
  12.  QObject::connect(&machine, SIGNAL(finished()), QApplication::instance(), SLOT(quit()));
在这个例子中,我们想让应用程序在状态机结束时退出,所以我们将状态机的finished() 信号连接到了应用程序的quit()槽函数上。

 

        注意,子状态可以覆盖从父状态那里继承的过渡。例如,下面的代码通过为s12添加一个新的过渡,导致当状态机处于s12状态是,退出按钮的点击被忽略。还有,一个过渡可以选择任何状态作为其目标状态,也就是说,一个过渡的目标状态不需要和他的源状态在状态图上处于同一个层次。

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

        如果我们想给上面的例子添加一个中断机制,即用户能通过点击一个按钮让状态机停下来去做一些其他的工作,之后再返回到它之前停下的地方。这种行为我们就可以通过 历史状态 实现。历史状态  是一个假想的状态,它表示了父状态上次退出时的子状态。

        历史状态通常创建为想要保存的那个状态的子状态。这样,程序运行时,当状态机检测到这种状态的存在时,就会在父状态退出时自动记录当前的子状态。连接到历史状态的过渡实际上就是连接到状态机上次保存的子状态,状态机会自动的将过渡前移到正在的子状态。下面的状态图显示了添加打断机制后的执行流程:

 

下面的代码展示了具体怎么实现这种功能。在这个例子里,当进入s3时我们只是简单的显示一个消息框,然后就立刻通过历史状态再返回到s1。

 

  1.  QHistoryState *s1h = new QHistoryState(s1);
  2.   
  3.  QState *s3 = new QState();
  4.  s3->assignProperty(label, "text", "In s3");
  5.  QMessageBox *mbox = new QMessageBox(mainWindow);
  6.  mbox->addButton(QMessageBox::Ok);
  7.  mbox->setText("Interrupted!");
  8.  mbox->setIcon(QMessageBox::Information);
  9.  QObject::connect(s3, SIGNAL(entered()), mbox, SLOT(exec()));
  10.  s3->addTransition(s1h);
  11.  machine.addState(s3);
  12.   
  13.  s1->addTransition(interruptButton, SIGNAL(clicked()), s3);
       使用并行状态来避免过多的状态组合

 

       一般情况下,对象的一个属性对应着两种状态,比如汽车的干净和不干净,移动和停止。这是4中独立的状态,会构成8中不同的状态转换。如下:


如果我们继续添加属性,比如颜色 红色和蓝色,那么就会变成8中状态。这是一个指数式的增长,很难想上面一样把这些状态放在一起考虑。这时,由于这些属性都是独立的,所以我们就可以将这个属性所构成的状态转换看成独立的,分开实现。可以使用并行状态来解决这个问题。如下图所示:

创建并行状态也非常的简单,只需在创建状态时将QState::ParallelStates 传给QState的构造函数即可。如下:

 

  1.  QState *s1 = new QState(QState::ParallelStates);
  2.  // s11 and s12 will be entered in parallel
  3.  QState *s11 = new QState(s1);
  4.  QState *s12 = new QState(s1);
当状态机进入一个并行状态组时,所有的子状态都会同时开始运行,每一个子状态的过渡都会正常执行。但是,每一个子状态都有可能退出父状态,如果这样,父状态和它所有的子状态都会结束。
        在Qt状态机框架的并行机制里有一个交错语义。所有的并行操作都是在一个事件处理中独立的、原子的被执行,所以没有事件能打断并行操作。但是,事件仍然是被顺序的处理的,因为状态机本身是单线程的。举个栗子,如果有两个过渡退出同一个并行状态组,并且它们的触发条件同时被满足。在这种情况下,第二个被处理的退出事件将没有任何实际的反应,因为第一个事件已经导致了状态机从并行状态中结束。

 

       检测组合状态的结束

       其实子状态可以是一个final状态;当进入一个final子状态时,父状态会发出finished() 信号。下图显示了一个组合状态s1在做了一系列的处理后进入了一个final状态:


当s1进入一个final子状态时,s1会自动发出finished() 信号。我们使用一个 信号过渡 来触发一个状态转换:

 

s1->addTransition(s1, SIGNAL(finished()), s2);
在组合状态中使用final状态对应想隐藏组合状态的内部细节来说是非常有用的。也就是说,对应外部世界来说,只需要进入这个状态,然后等待这个状态的完成信号即可。这对于构建复杂的状态机来说是一种强有力的的封装和抽象机制。 但是,对应并行状态组来说,finishe()信号只有在所以的子状态都进入final状态时才会发出。

 

       无目标状态的过渡

       一个Transition并不是一定要有一个目标状态,并且,没有目标状态的过渡也可以像其他过渡一样被触发。但区别是当一个没有目标状态的过渡被触发时,不会导致任何状态的改变。这运行你在状态机进入某个状态时响应一个信号或事件而不必离开那个状态。例如:

 

  1.  QStateMachine machine;
  2.  QState *s1 = new QState(&machine);
  3.   
  4.  QPushButton button;
  5.  QSignalTransition *trans = new QSignalTransition(&button, SIGNAL(clicked()));
  6.  s1->addTransition(trans);
  7.  
  8.  QMessageBox msgBox;
  9.  msgBox.setText("The button was clicked; carry on.");
  10.  QObject::connect(trans, SIGNAL(triggered()), &msgBox, SLOT(exec()));
  11.  
  12.  machine.setInitialState(s1);
在上面的例子中,消息框在每次按钮点击时都会显示出来,但是状态机会始终停留在s1状态。但是如果显示的把状态机的状态设置为s1,s1状态会结束,然后重新进入该状态。
            事件和过渡

 

        状态机运行在自己的事件循环中。对于信号转换(QSignalTransition 对象)来说,状态机会自动给它自己投递一个QStateMachine::SignalEvent 当它拦截到相应的信号后;同样,对于QObject事件转换(QEventTransition 对象)来说,QStateMachine::WrappedEvent会被投递。当然,你可以使用QStateMachine::postEvent()投递自己定义的事件给状态机。

       当向状态机投递一个自定义的事件时,你通常还会定义一或多个能被自定义的事件类型触发的过渡。为了创建这种过渡,可以继承QAbstractTransition 并且实现eventTest() 方法,在这个方法中判断当前事件是否匹配你的事件类型。下面是一个自定义的事件类型,StringEvent,用于向状态机投递字符串:

  1.  struct StringEvent : public QEvent
  2.  {
  3.  StringEvent(const QString &val)
  4.  : QEvent(QEvent::Type(QEvent::User+1)),
  5.  value(val) {}
  6.   
  7.  QString value;
  8.  };
接下来,我们再定义一个过渡,仅仅当事件的字符串匹配特定的字符串时才触发该过渡:
  1.  class StringTransition : public QAbstractTransition
  2.  {
  3.  Q_OBJECT
  4.  
  5.  public:
  6.  StringTransition(const QString &value)
  7.  : m_value(value) {}
  8.   
  9.  protected:
  10.  virtual bool eventTest(QEvent *e)
  11.  {
  12.  if (e->type() != QEvent::Type(QEvent::User+1)) // StringEvent
  13.  return false;
  14.  StringEvent *se = static_cast<StringEvent*>(e);
  15.  return (m_value == se->value);
  16.  }
  17.  
     
  18.  virtual void onTransition(QEvent *) {}
  19.  
  20.  private:
  21.  QString m_value;
  22.  };
在重新实现的eventTest() 函数中,我们首先检查接收到的事件是否是我们想要的,如果是,就把它转换成StringEvent并且进行字符串的比较。

 

下面的状态图使用了自定义的事件和过渡:



下面,我们就实现这个状态图,使用我们刚才定义的事件和过渡:

 

  1.  QStateMachine machine;
  2.  QState *s1 = new QState();
  3.  QState *s2 = new QState();
  4.  QFinalState *done = new QFinalState();
  5.  
  6.  StringTransition *t1 = new StringTransition("Hello");
  7.  t1->setTargetState(s2);
  8.  s1->addTransition(t1);
  9.  StringTransition *t2 = new StringTransition("world");
  10.  t2->setTargetState(done);
  11.  s2->addTransition(t2);
  12.   
  13.  machine.addState(s1);
  14.  machine.addState(s2);
  15.  machine.addState(done);
  16.  machine.setInitialState(s1);
一旦我们启动了状态机,就可以向它投递我们自定义的事件了:

 

 

  1.  machine.postEvent(new StringEvent("Hello"));
  2.  machine.postEvent(new StringEvent("world"));
另外,没被任何过渡处理的事件会被状态机默默的处理掉。

 

             使用恢复策略自动恢复属性值

        在使用状态机时,我们往往将注意力集中在修改对象的属性值,而不是集中在当状态退出时怎么恢复它们。如果你知道当状态机进入某个状态时,如果未为某个属性显示的设置值,那么应该总是将该属性重置为它的默认值,这时,可以为状态机设置一个全局的重置策略。

 

  1.  QStateMachine machine;
  2.  machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);
当设置了这个重置策略,状态机会自动的重置所有的属性。当状态机进入一个状态时,若某个属性未被设置,它会首先查找它的父级,看是否在那里定义了该属性。如果有,就将该属性重置为其最近的父级所定义的值。如果没有,就将它重置为其初始值。例如:

 

 

  1.  QStateMachine machine;
  2.  machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);
  3.   
  4.  QState *s1 = new QState();
  5.  s1->assignProperty(object, "fooBar", 1.0);
  6.  machine.addState(s1);
  7.  machine.setInitialState(s1);
  8.  
  9.  QState *s2 = new QState();
  10.  machine.addState(s2);
我们假定当状态机启动时,fooBar属性值为0。当状态机在s1状态时,改属性会被设置为1.0,因为这个状态显式的为其设置了值。当状态机进入s2状态时,该状态没有为fooBar属性显式的设置值,所以它会被隐式的重置为0.

 

如果我们使用嵌套的状态,父状态为某个属性定义的值会被所有未给该属性显式赋值的子孙后代继承。例如:

 

  1.  QStateMachine machine;
  2.  machine.setGlobalRestorePolicy(QStateMachine::RestoreProperties);
  3.  
  4.  QState *s1 = new QState();
  5.  s1->assignProperty(object, "fooBar", 1.0);
  6.  machine.addState(s1);
  7.  machine.setInitialState(s1);
  8.  
  9.  QState *s2 = new QState(s1);
  10.  s2->assignProperty(object, "fooBar", 2.0);
  11.  s1->setInitialState(s2);
  12.   
  13.  QState *s3 = new QState(s1);
在这个例子中,s1有两个子状态:s2和s3。当进入s2状态时,fooBar属性会被设置为2.0,因为这个改状态显式定义的。当进入s3状态时,未给该属性设置值,但是s1状态为该属性定义了值1.0,所以,s3会继承该值,将fooBar设置为1.0。

 

       为状态过渡引入动画

       假设我们有下面的代码:

 

  1.  QState *s1 = new QState();
  2.  QState *s2 = new QState();
  3.  
  4.  s1->assignProperty(button, "geometry", QRectF(0, 0, 50, 50));
  5.  s2->assignProperty(button, "geometry", QRectF(0, 0, 100, 100));
  6.   
  7.  s1->addTransition(button, SIGNAL(clicked()), s2);
这里我们定义了一个用户界面的两种状态。在s1状态时button是比较小的,在s2状态时,button变的更大。如果我们点击按钮触发s1到s2的过渡,那么按钮的尺寸会立刻改变。如果我们想让这个过渡更平滑,需要做的仅仅是为过渡添加一个属性动画QPropertyAnimation。代码如下:

 

 

  1.  QState *s1 = new QState();
  2.  QState *s2 = new QState();
  3.   
  4.  s1->assignProperty(button, "geometry", QRectF(0, 0, 50, 50));
  5.  s2->assignProperty(button, "geometry", QRectF(0, 0, 100, 100));
  6.   
  7.  QSignalTransition *transition = s1->addTransition(button, SIGNAL(clicked()), s2);
  8.  transition->addAnimation(new QPropertyAnimation(button, "geometry"));
为属性引入动画以为着当进入该状态时,属性的赋值不会立刻起作用。相反,当进入该状态时会开发执行该动画并慢慢的改变属性的值。以为我们没有设置动画的开始值和结束值,动画会隐式的设置它们。开始值会被设置为动画开始时的属性值,结束值会被设置为终止状态指定的值。

 

       检测一个状态中所有的属性均被设置完成

       当使用动画为属性赋值时,一个状态不再为属性定义确切的值,当动画运行时,属性可能具有任何值。而在有些情况下,检测某个属性是否已经被某个状态设置完成对我们来说是很重要的。例如下面的代码:

 

  1.  QMessageBox *messageBox = new QMessageBox(mainWindow);
  2.  messageBox->addButton(QMessageBox::Ok);
  3.  messageBox->setText("Button geometry has been set!");
  4.  messageBox->setIcon(QMessageBox::Information);
  5.  
  6.  QState *s1 = new QState();
  7.  
  8.  QState *s2 = new QState();
  9.  s2->assignProperty(button, "geometry", QRectF(0, 0, 50, 50));
  10.  connect(s2, SIGNAL(entered()), messageBox, SLOT(exec()));
  11.   
  12.  s1->addTransition(button, SIGNAL(clicked()), s2);
当按钮被点击时,状态机会进入s2状态,该状态会改变按钮的尺寸,然后弹出一个消息框提示用户按钮的尺寸已经被改变了。

 

正常情况下,也就是没有使用动画的情况下,这个动作会如我们期望的所运行。但是,如果我们为s1到s2的转换添加了动画,那么当进入s2状态时会执行该动画,但是在动画执行结束之前,按钮的尺寸不会达到预定义的值。在这种情况下,消息框会在按钮尺寸实际设置完成之前弹出。

为了确保消息框直到按钮尺寸变化到指定值时才弹出,我们可以使用状态的propertiesAssigned() 信号。该信号会在属性达到最终值时被发出。如下面代码所示:

 

  1.  QMessageBox *messageBox = new QMessageBox(mainWindow);
  2.  messageBox->addButton(QMessageBox::Ok);
  3.  messageBox->setText("Button geometry has been set!");
  4.  messageBox->setIcon(QMessageBox::Information);
  5.  
  6.  QState *s1 = new QState();
  7.  
  8.  QState *s2 = new QState();
  9.  s2->assignProperty(button, "geometry", QRectF(0, 0, 50, 50));
  10.  
     
  11.  QState *s3 = new QState();
  12.  connect(s3, SIGNAL(entered()), messageBox, SLOT(exec()));
  13.  
  14.  s1->addTransition(button, SIGNAL(clicked()), s2);
  15.  s2->addTransition(s2, SIGNAL(propertiesAssigned()), s3);
在这个例子中,当按钮被点击,状态机会进入s2。但会保持在s2按钮的尺寸达到预设的QRect(0, 0, 50, 50)。接着会进入s3状态。当进入s3状态时,消息框会弹出。如果到s2的过渡被添加了动画,那么状态机会停留在s2直到动画播放完成。如果没有添加动画,就会简单的设置属性值然后立即进入s3状态。无论哪种方式,当状态机进入s3时,可以确保按钮的尺寸已经达到了预设值。

 

        状态在动画完成之前退出

        如果一个状态有属性赋值,并且到这个状态的过渡为这个属性应用了动画,那么该状态有可能在属性被赋予预设值之前退出。这在从不依赖于propertiesAssigned()信号的状态发出的过渡中更有可能发生。当发生这种情况时,状态机保证属性值要么是一个显式设置的值,要么是状态结束时动画运行到的值。

        当一个状态在动画结束之前退出,状态机的行为依赖与过渡的目标状态。如果目标状态显式的设置了该属性值,那么就不需要进行额外的操作。该属性会被设置为目标状态所定义的值。如果目标状态没有设置该属性的值,那么会有两种可能:默认情况下,该属性会被设置为正在离开的那个状态所定义的值。但是,如果设置了全局重置策略,则重置策略优先,该属性会像往常一样被重置。

       默认动画

       正如上文所说,你可以为一个过渡添加动画从而确保在目标状态里的属性赋值时动态的。如果你想为一个属性应用一个特定的动画,不论发生的是哪一个过渡,那么你可以把该动画添加为状态机的默认动画。这在创建状态机之前不知道某个属性会由哪个状态所赋值来说至关重要。例如以下代码:

 

  1.  QState *s1 = new QState();
  2.  QState *s2 = new QState();
  3.  
  4.  s2->assignProperty(object, "fooBar", 2.0);
  5.  s1->addTransition(s2);
  6.  
  7.  QStateMachine machine;
  8.  machine.setInitialState(s1);
  9.  machine.addDefaultAnimation(new QPropertyAnimation(object, "fooBar"));
当状态机在s2状态时,状态机会为fooBar属性播放这个默认动画,因为这个属性被s2设置了。记住,对于给定的属性 来说,在过渡上显式设置的动画优先于默认动画。

 

       状态机的嵌套

       QStateMachine 是QState的子类。这允许一个状态机是另一个状态机的孩子。QStateMachine重新实现了QState::onEntry() 并且调用了QStateMachine::start() ,以至于当进入子状态机时,它会自动开始运行。

       父状态机会在状态机算法中将子状态机看成一个原子状态。子状态机是独立的,它维护自己的事件队列和相关配置。特别要记住的一点是,子状态机的configuration() 并不是父状态机的configuration的一部分。

       子状态机中的状态不能被指定为父状态机中的过渡的目标状态;反过来也是这样。不过,子状态机的finished()信号可以在父状态机中被用来触发一个过渡。

        以上就是Qt状态机框架的基本知识。至于QML中使用的Declarative State Machine Framework,知识点与此类似,大家可以自行研习Qt 帮助文档The Declarative State Machine Framework 一节。

posted @ 2020-10-29 14:34  Avatarx  阅读(616)  评论(0编辑  收藏  举报