1. 程式人生 > 其它 >狀態模式:遊戲、工作流引擎中常用的狀態機是如何實現的?

狀態模式:遊戲、工作流引擎中常用的狀態機是如何實現的?

從今天起,我們開始學習狀態模式。在實際的軟體開發中,狀態模式並不是很常用,但是在能夠用到的場景裡,它可以發揮很大的作用。從這一點上來看,它有點像我們之前講到的組合模式。

狀態模式一般用來實現狀態機,而狀態機常用在遊戲工作流引擎等系統開發中。不過,狀態機的實現方式有多種,除了狀態模式,比較常用的還有分支邏輯法和查表法。今天,我們就詳細講講這幾種實現方式,並且對比一下它們的優劣和應用場景。

什麼是有限狀態機?

有限狀態機,英文翻譯是 Finite State Machine,縮寫為 FSM,簡稱為狀態機。狀態機有 3 個組成部分:狀態(State)、事件(Event)、動作(Action)。其中,事件也稱為轉移條件(Transition Condition)。事件觸發狀態的轉移及動作的執行。不過,動作不是必須的,也可能只轉移狀態,不執行任何動作。

對於剛剛給出的狀態機的定義,我結合一個具體的例子,來進一步解釋一下。

“超級馬里奧”遊戲不知道你玩過沒有?在遊戲中,馬里奧可以變身為多種形態,比如小馬里奧(Small Mario)、超級馬里奧(Super Mario)、火焰馬里奧(Fire Mario)、斗篷馬里奧(Cape Mario)等等。在不同的遊戲情節下,各個形態會互相轉化,並相應的增減積分。比如,初始形態是小馬里奧,吃了蘑菇之後就會變成超級馬里奧,並且增加 100 積分。

實際上,馬里奧形態的轉變就是一個狀態機。其中,馬里奧的不同形態就是狀態機中的“狀態”,遊戲情節(比如吃了蘑菇)就是狀態機中的“事件”,加減積分就是狀態機中的“動作”。比如,吃蘑菇這個事件,會觸發狀態的轉移:從小馬里奧轉移到超級馬里奧,以及觸發動作的執行(增加 100 積分)。

為了方便接下來的講解,我對遊戲背景做了簡化,只保留了部分狀態和事件。簡化之後的狀態轉移如下圖所示:

我們如何程式設計來實現上面的狀態機呢?換句話說,如何將上面的狀態轉移圖翻譯成程式碼呢?

我寫了一個骨架程式碼,如下所示。其中,obtainMushRoom()、obtainCape()、obtainFireFlower()、meetMonster() 這幾個函式,能夠根據當前的狀態和事件,更新狀態和增減積分。不過,具體的程式碼實現我暫時並沒有給出。你可以把它當做面試題,試著補全一下,然後再來看我下面的講解,這樣你的收穫會更大。


public enum State {
  SMALL(0),
  SUPER(1),
  FIRE(2),
  CAPE(3);

  private int value;

  private State(int value) {
    this.value = value;
  }

  public int getValue() {
    return this.value;
  }
}

public class MarioStateMachine {
  private int score;
  private State currentState;

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = State.SMALL;
  }

  public void obtainMushRoom() {
    //TODO
  }

  public void obtainCape() {
    //TODO
  }

  public void obtainFireFlower() {
    //TODO
  }

  public void meetMonster() {
    //TODO
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState;
  }
}

public class ApplicationDemo {
  public static void main(String[] args) {
    MarioStateMachine mario = new MarioStateMachine();
    mario.obtainMushRoom();
    int score = mario.getScore();
    State state = mario.getCurrentState();
    System.out.println("mario score: " + score + "; state: " + state);
  }
}

狀態機實現方式一:分支邏輯法

對於如何實現狀態機,我總結了三種方式。其中,最簡單直接的實現方式是,參照狀態轉移圖,將每一個狀態轉移,原模原樣地直譯成程式碼。這樣編寫的程式碼會包含大量的 if-else 或 switch-case 分支判斷邏輯,甚至是巢狀的分支判斷邏輯,所以,我把這種方法暫且命名為分支邏輯法。

按照這個實現思路,我將上面的骨架程式碼補全一下。補全之後的程式碼如下所示:


public class MarioStateMachine {
  private int score;
  private State currentState;

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = State.SMALL;
  }

  public void obtainMushRoom() {
    if (currentState.equals(State.SMALL)) {
      this.currentState = State.SUPER;
      this.score += 100;
    }
  }

  public void obtainCape() {
    if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER) ) {
      this.currentState = State.CAPE;
      this.score += 200;
    }
  }

  public void obtainFireFlower() {
    if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER) ) {
      this.currentState = State.FIRE;
      this.score += 300;
    }
  }

  public void meetMonster() {
    if (currentState.equals(State.SUPER)) {
      this.currentState = State.SMALL;
      this.score -= 100;
      return;
    }

    if (currentState.equals(State.CAPE)) {
      this.currentState = State.SMALL;
      this.score -= 200;
      return;
    }

    if (currentState.equals(State.FIRE)) {
      this.currentState = State.SMALL;
      this.score -= 300;
      return;
    }
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState;
  }
}

對於簡單的狀態機來說,分支邏輯這種實現方式是可以接受的。但是,對於複雜的狀態機來說,這種實現方式極易漏寫或者錯寫某個狀態轉移。除此之外,程式碼中充斥著大量的 if-else 或者 switch-case 分支判斷邏輯,可讀性和可維護性都很差。如果哪天修改了狀態機中的某個狀態轉移,我們要在冗長的分支邏輯中找到對應的程式碼進行修改,很容易改錯,引入 bug。

狀態機實現方式二:查表法

實際上,上面這種實現方法有點類似 hard code,對於複雜的狀態機來說不適用,而狀態機的第二種實現方式查表法,就更加合適了。接下來,我們就一塊兒來看下,如何利用查表法來補全骨架程式碼。

實際上,除了用狀態轉移圖來表示之外,狀態機還可以用二維表來表示,如下所示。在這個二維表中,第一維表示當前狀態,第二維表示事件,值表示當前狀態經過事件之後,轉移到的新狀態及其執行的動作。

相對於分支邏輯的實現方式,查表法的程式碼實現更加清晰,可讀性和可維護性更好。當修改狀態機時,我們只需要修改 transitionTable 和 actionTable 兩個二維陣列即可。實際上,如果我們把這兩個二維陣列儲存在配置檔案中,當需要修改狀態機時,我們甚至可以不修改任何程式碼,只需要修改配置檔案就可以了。具體的程式碼如下所示:

狀態機實現方式三:狀態模式

在查表法的程式碼實現中,事件觸發的動作只是簡單的積分加減,所以,我們用一個 int 型別的二維陣列 actionTable 就能表示,二維陣列中的值表示積分的加減值。但是,如果要執行的動作並非這麼簡單,而是一系列複雜的邏輯操作(比如加減積分、寫資料庫,還有可能傳送訊息通知等等),我們就沒法用如此簡單的二維陣列來表示了。這也就是說,查表法的實現方式有一定侷限性。

雖然分支邏輯的實現方式不存在這個問題,但它又存在前面講到的其他問題,比如分支判斷邏輯較多,導致程式碼可讀性和可維護性不好等。實際上,針對分支邏輯法存在的問題,我們可以使用狀態模式來解決。

狀態模式通過將事件觸發的狀態轉移和動作執行,拆分到不同的狀態類中,來避免分支判斷邏輯。我們還是結合程式碼來理解這句話。

利用狀態模式,我們來補全 MarioStateMachine 類,補全後的程式碼如下所示。

其中,IMario 是狀態的介面,定義了所有的事件。SmallMario、SuperMario、CapeMario、FireMario 是 IMario 介面的實現類,分別對應狀態機中的 4 個狀態。原來所有的狀態轉移和動作執行的程式碼邏輯,都集中在 MarioStateMachine 類中,現在,這些程式碼邏輯被分散到了這 4 個狀態類中


public interface IMario { //所有狀態類的介面
  State getName();
  //以下是定義的事件
  void obtainMushRoom();
  void obtainCape();
  void obtainFireFlower();
  void meetMonster();
}

public class SmallMario implements IMario {
  private MarioStateMachine stateMachine;

  public SmallMario(MarioStateMachine stateMachine) {
    this.stateMachine = stateMachine;
  }

  @Override
  public State getName() {
    return State.SMALL;
  }

  @Override
  public void obtainMushRoom() {
    stateMachine.setCurrentState(new SuperMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 100);
  }

  @Override
  public void obtainCape() {
    stateMachine.setCurrentState(new CapeMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 200);
  }

  @Override
  public void obtainFireFlower() {
    stateMachine.setCurrentState(new FireMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 300);
  }

  @Override
  public void meetMonster() {
    // do nothing...
  }
}

public class SuperMario implements IMario {
  private MarioStateMachine stateMachine;

  public SuperMario(MarioStateMachine stateMachine) {
    this.stateMachine = stateMachine;
  }

  @Override
  public State getName() {
    return State.SUPER;
  }

  @Override
  public void obtainMushRoom() {
    // do nothing...
  }

  @Override
  public void obtainCape() {
    stateMachine.setCurrentState(new CapeMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 200);
  }

  @Override
  public void obtainFireFlower() {
    stateMachine.setCurrentState(new FireMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() + 300);
  }

  @Override
  public void meetMonster() {
    stateMachine.setCurrentState(new SmallMario(stateMachine));
    stateMachine.setScore(stateMachine.getScore() - 100);
  }
}

// 省略CapeMario、FireMario類...

public class MarioStateMachine {
  private int score;
  private IMario currentState; // 不再使用列舉來表示狀態

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = new SmallMario(this);
  }

  public void obtainMushRoom() {
    this.currentState.obtainMushRoom();
  }

  public void obtainCape() {
    this.currentState.obtainCape();
  }

  public void obtainFireFlower() {
    this.currentState.obtainFireFlower();
  }

  public void meetMonster() {
    this.currentState.meetMonster();
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState.getName();
  }

  public void setScore(int score) {
    this.score = score;
  }

  public void setCurrentState(IMario currentState) {
    this.currentState = currentState;
  }
}

上面的程式碼實現不難看懂,我只強調其中的一點,即 MarioStateMachine 和各個狀態類之間是雙向依賴關係。MarioStateMachine 依賴各個狀態類是理所當然的,但是,反過來,各個狀態類為什麼要依賴 MarioStateMachine 呢?這是因為,各個狀態類需要更新 MarioStateMachine 中的兩個變數,score 和 currentState。

實際上,上面的程式碼還可以繼續優化,我們可以將狀態類設計成單例,畢竟狀態類中不包含任何成員變數。但是,當將狀態類設計成單例之後,我們就無法通過建構函式來傳遞 MarioStateMachine 了,而狀態類又要依賴 MarioStateMachine,那該如何解決這個問題呢?

實際上,在第 42 講單例模式的講解中,我們提到過幾種解決方法,你可以回過頭去再檢視一下。在這裡,我們可以通過函式引數將 MarioStateMachine 傳遞進狀態類。根據這個設計思路,我們對上面的程式碼進行重構。重構之後的程式碼如下所示:


public interface IMario {
  State getName();
  void obtainMushRoom(MarioStateMachine stateMachine);
  void obtainCape(MarioStateMachine stateMachine);
  void obtainFireFlower(MarioStateMachine stateMachine);
  void meetMonster(MarioStateMachine stateMachine);
}

public class SmallMario implements IMario {
  private static final SmallMario instance = new SmallMario();
  private SmallMario() {}
  public static SmallMario getInstance() {
    return instance;
  }

  @Override
  public State getName() {
    return State.SMALL;
  }

  @Override
  public void obtainMushRoom(MarioStateMachine stateMachine) {
    stateMachine.setCurrentState(SuperMario.getInstance());
    stateMachine.setScore(stateMachine.getScore() + 100);
  }

  @Override
  public void obtainCape(MarioStateMachine stateMachine) {
    stateMachine.setCurrentState(CapeMario.getInstance());
    stateMachine.setScore(stateMachine.getScore() + 200);
  }

  @Override
  public void obtainFireFlower(MarioStateMachine stateMachine) {
    stateMachine.setCurrentState(FireMario.getInstance());
    stateMachine.setScore(stateMachine.getScore() + 300);
  }

  @Override
  public void meetMonster(MarioStateMachine stateMachine) {
    // do nothing...
  }
}

// 省略SuperMario、CapeMario、FireMario類...

public class MarioStateMachine {
  private int score;
  private IMario currentState;

  public MarioStateMachine() {
    this.score = 0;
    this.currentState = SmallMario.getInstance();
  }

  public void obtainMushRoom() {
    this.currentState.obtainMushRoom(this);
  }

  public void obtainCape() {
    this.currentState.obtainCape(this);
  }

  public void obtainFireFlower() {
    this.currentState.obtainFireFlower(this);
  }

  public void meetMonster() {
    this.currentState.meetMonster(this);
  }

  public int getScore() {
    return this.score;
  }

  public State getCurrentState() {
    return this.currentState.getName();
  }

  public void setScore(int score) {
    this.score = score;
  }

  public void setCurrentState(IMario currentState) {
    this.currentState = currentState;
  }
}

實際上,像遊戲這種比較複雜的狀態機,包含的狀態比較多,我優先推薦使用查表法,而狀態模式會引入非常多的狀態類,會導致程式碼比較難維護。相反,像電商下單、外賣下單這種型別的狀態機,它們的狀態並不多,狀態轉移也比較簡單,但事件觸發執行的動作包含的業務邏輯可能會比較複雜,所以,更加推薦使用狀態模式來實現。