从今天起,我们开始学习状态模式。在实际的软件开发中,状态模式并不是很常用,但是在能够用到的场景里,它可以发挥很大的作用。从这一点上来看,它有点像我们之前讲到的组合模式。
状态模式一般用来实现状态机,而状态机常用在游戏、工作流引擎等系统开发中。不过,状态机的实现方式有多种,除了状态模式,比较常用的还有分支逻辑法和查表法。今天,我们就详细讲讲这几种实现方式,并且对比一下它们的优劣和应用场景。
话不多说,让我们正式开始今天的学习吧!

什么是有限状态机?

有限状态机,英文翻译是 Finite State Machine,缩写为 FSM,简称为状态机。状态机有 3 个组成部分:状态(State)、事件(Event)、动作(Action)。其中,事件也称为转移条件(Transition Condition)。事件触发状态的转移及动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。
对于刚刚给出的状态机的定义,我结合一个具体的例子,来进一步解释一下。
“超级马里奥”游戏不知道你玩过没有?在游戏中,马里奥可以变身为多种形态,比如小马里奥(Small Mario)、超级马里奥(Super Mario)、火焰马里奥(Fire Mario)、斗篷马里奥(Cape Mario)等等。在不同的游戏情节下,各个形态会互相转化,并相应的增减积分。比如,初始形态是小马里奥,吃了蘑菇之后就会变成超级马里奥,并且增加 100 积分。
实际上,马里奥形态的转变就是一个状态机。其中,马里奥的不同形态就是状态机中的“状态”,游戏情节(比如吃了蘑菇)就是状态机中的“事件”,加减积分就是状态机中的“动作”。比如,吃蘑菇这个事件,会触发状态的转移:从小马里奥转移到超级马里奥,以及触发动作的执行(增加 100 积分)。
为了方便接下来的讲解,我对游戏背景做了简化,只保留了部分状态和事件。简化之后的状态转移如下图所示:
image.png
我们如何编程来实现上面的状态机呢?换句话说,如何将上面的状态转移图翻译成代码呢?
我写了一个骨架代码,如下所示。其中,obtainMushRoom()、obtainCape()、obtainFireFlower()、meetMonster() 这几个函数,能够根据当前的状态和事件,更新状态和增减积分。不过,具体的代码实现我暂时并没有给出。你可以把它当做面试题,试着补全一下,然后再来看我下面的讲解,这样你的收获会更大。

  1. public enum State {
  2. SMALL(0), SUPER(1), FIRE(2), CAPE(3);
  3. private int value;
  4. private State(int value) {
  5. this.value = value;
  6. }
  7. public int getValue() {
  8. return this.value;
  9. }
  10. }
  11. public class MarioStateMachine {
  12. private int score;
  13. private State currentState;
  14. public MarioStateMachine() {
  15. this.score = 0;
  16. this.currentState = State.SMALL;
  17. }
  18. public void obtainMushRoom() {
  19. //TODO
  20. }
  21. public void obtainCape() {
  22. //TODO
  23. }
  24. public void obtainFireFlower() {
  25. //TODO
  26. }
  27. public void meetMonster() {
  28. //TODO
  29. }
  30. public int getScore() {
  31. return this.score;
  32. }
  33. public State getCurrentState() {
  34. return this.currentState;
  35. }
  36. }
  37. public class ApplicationDemo {
  38. public static void main(String[] args) {
  39. MarioStateMachine mario = new MarioStateMachine();
  40. mario.obtainMushRoom();
  41. int score = mario.getScore();
  42. State state = mario.getCurrentState();
  43. System.out.println("mario score: " + score + "; state: " + state);
  44. }
  45. }

状态机实现方式一:分支逻辑法

对于如何实现状态机,我总结了三种方式。其中,最简单直接的实现方式是,参照状态转移图,将每一个状态转移,原模原样地直译成代码。这样编写的代码会包含大量的 if-else 或 switch-case 分支判断逻辑,甚至是嵌套的分支判断逻辑,所以,我把这种方法暂且命名为分支逻辑法。
按照这个实现思路,我将上面的骨架代码补全一下。补全之后的代码如下所示:

  1. public class MarioStateMachine {
  2. private int score;
  3. private State currentState;
  4. public MarioStateMachine() {
  5. this.score = 0;
  6. this.currentState = State.SMALL;
  7. }
  8. public void obtainMushRoom() {
  9. if (currentState.equals(State.SMALL)) {
  10. this.currentState = State.SUPER;
  11. this.score += 100;
  12. }
  13. }
  14. public void obtainCape() {
  15. if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER)) {
  16. this.currentState = State.CAPE;
  17. this.score += 200;
  18. }
  19. }
  20. public void obtainFireFlower() {
  21. if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER)) {
  22. this.currentState = State.FIRE;
  23. this.score += 300;
  24. }
  25. }
  26. public void meetMonster() {
  27. if (currentState.equals(State.SUPER)) {
  28. this.currentState = State.SMALL;
  29. this.score -= 100;
  30. return;
  31. }
  32. if (currentState.equals(State.CAPE)) {
  33. this.currentState = State.SMALL;
  34. this.score -= 200;
  35. return;
  36. }
  37. if (currentState.equals(State.FIRE)) {
  38. this.currentState = State.SMALL;
  39. this.score -= 300;
  40. return;
  41. }
  42. }
  43. public int getScore() {
  44. return this.score;
  45. }
  46. public State getCurrentState() {
  47. return this.currentState;
  48. }
  49. }

对于简单的状态机来说,分支逻辑这种实现方式是可以接受的。但是,对于复杂的状态机来说,这种实现方式极易漏写或者错写某个状态转移。除此之外,代码中充斥着大量的 if-else 或者 switch-case 分支判断逻辑,可读性和可维护性都很差。如果哪天修改了状态机中的某个状态转移,我们要在冗长的分支逻辑中找到对应的代码进行修改,很容易改错,引入 bug。

状态机实现方式二:查表法

实际上,上面这种实现方法有点类似 hard code,对于复杂的状态机来说不适用,而状态机的第二种实现方式查表法,就更加合适了。接下来,我们就一块儿来看下,如何利用查表法来补全骨架代码。
实际上,除了用状态转移图来表示之外,状态机还可以用二维表来表示,如下所示。在这个二维表中,第一维表示当前状态,第二维表示事件,值表示当前状态经过事件之后,转移到的新状态及其执行的动作。
image.png
相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改 transitionTable 和 actionTable 两个二维数组即可。实际上,如果我们把这两个二维数组存储在配置文件中,当需要修改状态机时,我们甚至可以不修改任何代码,只需要修改配置文件就可以了。具体的代码如下所示:

  1. public enum Event {
  2. GOT_MUSHROOM(0), GOT_CAPE(1), GOT_FIRE(2), MET_MONSTER(3);
  3. private int value;
  4. private Event(int value) {
  5. this.value = value;
  6. }
  7. public int getValue() {
  8. return this.value;
  9. }
  10. }
  11. public class MarioStateMachine {
  12. private int score;
  13. private State currentState;
  14. private static final State[][] transitionTable = {{SUPER, CAPE, FIRE, SMALL}, {SUPER, CAPE, FIRE, SMALL}, {CAPE, CAPE, CAPE, SMALL}, {FIRE, FIRE, FIRE, SMALL}};
  15. private static final int[][] actionTable = {{+100, +200, +300, +0}, {+0, +200, +300, -100}, {+0, +0, +0, -200}, {+0, +0, +0, -300}};
  16. public MarioStateMachine() {
  17. this.score = 0;
  18. this.currentState = State.SMALL;
  19. }
  20. public void obtainMushRoom() {
  21. executeEvent(Event.GOT_MUSHROOM);
  22. }
  23. public void obtainCape() {
  24. executeEvent(Event.GOT_CAPE);
  25. }
  26. public void obtainFireFlower() {
  27. executeEvent(Event.GOT_FIRE);
  28. }
  29. public void meetMonster() {
  30. executeEvent(Event.MET_MONSTER);
  31. }
  32. private void executeEvent(Event event) {
  33. int stateValue = currentState.getValue();
  34. int eventValue = event.getValue();
  35. this.currentState = transitionTable[stateValue][eventValue];
  36. this.score = actionTable[stateValue][eventValue];
  37. }
  38. public int getScore() {
  39. return this.score;
  40. }
  41. public State getCurrentState() {
  42. return this.currentState;
  43. }
  44. }

状态机实现方式三:状态模式

在查表法的代码实现中,事件触发的动作只是简单的积分加减,所以,我们用一个 int 类型的二维数组 actionTable 就能表示,二维数组中的值表示积分的加减值。但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作(比如加减积分、写数据库,还有可能发送消息通知等等),我们就没法用如此简单的二维数组来表示了。这也就是说,查表法的实现方式有一定局限性。
虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码可读性和可维护性不好等。实际上,针对分支逻辑法存在的问题,我们可以使用状态模式来解决。
状态模式通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,来避免分支判断逻辑。我们还是结合代码来理解这句话。
利用状态模式,我们来补全 MarioStateMachine 类,补全后的代码如下所示。
其中,IMario 是状态的接口,定义了所有的事件。SmallMario、SuperMario、CapeMario、FireMario 是 IMario 接口的实现类,分别对应状态机中的 4 个状态。原来所有的状态转移和动作执行的代码逻辑,都集中在 MarioStateMachine 类中,现在,这些代码逻辑被分散到了这 4 个状态类中。

  1. public interface IMario { //所有状态类的接口
  2. State getName();
  3. //以下是定义的事件
  4. void obtainMushRoom();
  5. void obtainCape();
  6. void obtainFireFlower();
  7. void meetMonster();
  8. }
  9. public class SmallMario implements IMario {
  10. private MarioStateMachine stateMachine;
  11. public SmallMario(MarioStateMachine stateMachine) {
  12. this.stateMachine = stateMachine;
  13. }
  14. @Override
  15. public State getName() {
  16. return State.SMALL;
  17. }
  18. @Override
  19. public void obtainMushRoom() {
  20. stateMachine.setCurrentState(new SuperMario(stateMachine));
  21. stateMachine.setScore(stateMachine.getScore() + 100);
  22. }
  23. @Override
  24. public void obtainCape() {
  25. stateMachine.setCurrentState(new CapeMario(stateMachine));
  26. stateMachine.setScore(stateMachine.getScore() + 200);
  27. }
  28. @Override
  29. public void obtainFireFlower() {
  30. stateMachine.setCurrentState(new FireMario(stateMachine));
  31. stateMachine.setScore(stateMachine.getScore() + 300);
  32. }
  33. @Override
  34. public void meetMonster() {
  35. // do nothing...
  36. }
  37. }
  38. public class SuperMario implements IMario {
  39. private MarioStateMachine stateMachine;
  40. public SuperMario(MarioStateMachine stateMachine) {
  41. this.stateMachine = stateMachine;
  42. }
  43. @Override
  44. public State getName() {
  45. return State.SUPER;
  46. }
  47. @Override
  48. public void obtainMushRoom() {
  49. // do nothing...
  50. }
  51. @Override
  52. public void obtainCape() {
  53. stateMachine.setCurrentState(new CapeMario(stateMachine));
  54. stateMachine.setScore(stateMachine.getScore() + 200);
  55. }
  56. @Override
  57. public void obtainFireFlower() {
  58. stateMachine.setCurrentState(new FireMario(stateMachine));
  59. stateMachine.setScore(stateMachine.getScore() + 300);
  60. }
  61. @Override
  62. public void meetMonster() {
  63. stateMachine.setCurrentState(new SmallMario(stateMachine));
  64. stateMachine.setScore(stateMachine.getScore() - 100);
  65. }
  66. }
  67. // 省略CapeMario、FireMario类...
  68. public class MarioStateMachine {
  69. private int score;
  70. private IMario currentState; // 不再使用枚举来表示状态
  71. public MarioStateMachine() {
  72. this.score = 0;
  73. this.currentState = new SmallMario(this);
  74. }
  75. public void obtainMushRoom() {
  76. this.currentState.obtainMushRoom();
  77. }
  78. public void obtainCape() {
  79. this.currentState.obtainCape();
  80. }
  81. public void obtainFireFlower() {
  82. this.currentState.obtainFireFlower();
  83. }
  84. public void meetMonster() {
  85. this.currentState.meetMonster();
  86. }
  87. public int getScore() {
  88. return this.score;
  89. }
  90. public State getCurrentState() {
  91. return this.currentState.getName();
  92. }
  93. public void setScore(int score) {
  94. this.score = score;
  95. }
  96. public void setCurrentState(IMario currentState) {
  97. this.currentState = currentState;
  98. }
  99. }

上面的代码实现不难看懂,我只强调其中的一点,即 MarioStateMachine 和各个状态类之间是双向依赖关系。MarioStateMachine 依赖各个状态类是理所当然的,但是,反过来,各个状态类为什么要依赖 MarioStateMachine 呢?这是因为,各个状态类需要更新 MarioStateMachine 中的两个变量,score 和 currentState。
实际上,上面的代码还可以继续优化,我们可以将状态类设计成单例,毕竟状态类中不包含任何成员变量。但是,当将状态类设计成单例之后,我们就无法通过构造函数来传递 MarioStateMachine 了,而状态类又要依赖 MarioStateMachine,那该如何解决这个问题呢?
实际上,在第 42 讲单例模式的讲解中,我们提到过几种解决方法,你可以回过头去再查看一下。在这里,我们可以通过函数参数将 MarioStateMachine 传递进状态类。根据这个设计思路,我们对上面的代码进行重构。重构之后的代码如下所示:

  1. public interface IMario {
  2. State getName();
  3. void obtainMushRoom(MarioStateMachine stateMachine);
  4. void obtainCape(MarioStateMachine stateMachine);
  5. void obtainFireFlower(MarioStateMachine stateMachine);
  6. void meetMonster(MarioStateMachine stateMachine);
  7. }
  8. public class SmallMario implements IMario {
  9. private static final SmallMario instance = new SmallMario();
  10. private SmallMario() {
  11. }
  12. public static SmallMario getInstance() {
  13. return instance;
  14. }
  15. @Override
  16. public State getName() {
  17. return State.SMALL;
  18. }
  19. @Override
  20. public void obtainMushRoom(MarioStateMachine stateMachine) {
  21. stateMachine.setCurrentState(SuperMario.getInstance());
  22. stateMachine.setScore(stateMachine.getScore() + 100);
  23. }
  24. @Override
  25. public void obtainCape(MarioStateMachine stateMachine) {
  26. stateMachine.setCurrentState(CapeMario.getInstance());
  27. stateMachine.setScore(stateMachine.getScore() + 200);
  28. }
  29. @Override
  30. public void obtainFireFlower(MarioStateMachine stateMachine) {
  31. stateMachine.setCurrentState(FireMario.getInstance());
  32. stateMachine.setScore(stateMachine.getScore() + 300);
  33. }
  34. @Override
  35. public void meetMonster(MarioStateMachine stateMachine) {
  36. // do nothing...
  37. }
  38. }
  39. // 省略SuperMario、CapeMario、FireMario类...
  40. public class MarioStateMachine {
  41. private int score;
  42. private IMario currentState;
  43. public MarioStateMachine() {
  44. this.score = 0;
  45. this.currentState = SmallMario.getInstance();
  46. }
  47. public void obtainMushRoom() {
  48. this.currentState.obtainMushRoom(this);
  49. }
  50. public void obtainCape() {
  51. this.currentState.obtainCape(this);
  52. }
  53. public void obtainFireFlower() {
  54. this.currentState.obtainFireFlower(this);
  55. }
  56. public void meetMonster() {
  57. this.currentState.meetMonster(this);
  58. }
  59. public int getScore() {
  60. return this.score;
  61. }
  62. public State getCurrentState() {
  63. return this.currentState.getName();
  64. }
  65. public void setScore(int score) {
  66. this.score = score;
  67. }
  68. public void setCurrentState(IMario currentState) {
  69. this.currentState = currentState;
  70. }
  71. }

实际上,像游戏这种比较复杂的状态机,包含的状态比较多,我优先推荐使用查表法,而状态模式会引入非常多的状态类,会导致代码比较难维护。相反,像电商下单、外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,所以,更加推荐使用状态模式来实现。

重点回顾

好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
今天我们讲解了状态模式。虽然网上有各种状态模式的定义,但是你只要记住状态模式是状态机的一种实现方式即可。状态机又叫有限状态机,它有 3 个部分组成:状态、事件、动作。其中,事件也称为转移条件。事件触发状态的转移及动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。
针对状态机,今天我们总结了三种实现方式。
第一种实现方式叫分支逻辑法。利用 if-else 或者 switch-case 分支逻辑,参照状态转移图,将每一个状态转移原模原样地直译成代码。对于简单的状态机来说,这种实现方式最简单、最直接,是首选。
第二种实现方式叫查表法。对于状态很多、状态转移比较复杂的状态机来说,查表法比较合适。通过二维数组来表示状态转移图,能极大地提高代码的可读性和可维护性。
第三种实现方式叫状态模式。对于状态并不多、状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说,我们首选这种实现方式。

课堂讨论

状态模式的代码实现还存在一些问题,比如,状态接口中定义了所有的事件函数,这就导致,即便某个状态类并不需要支持其中的某个或者某些事件,但也要实现所有的事件函数。不仅如此,添加一个事件到状态接口,所有的状态类都要做相应的修改。针对这些问题,你有什么解决方法吗?