狀態模式:遊戲、工作流引擎中常用的狀態機是如何實現的?
從今天起,我們開始學習狀態模式。在實際的軟體開發中,狀態模式並不是很常用,但是在能夠用到的場景裡,它可以發揮很大的作用。從這一點上來看,它有點像我們之前講到的組合模式。
狀態模式一般用來實現狀態機,而狀態機常用在遊戲、工作流引擎等系統開發中。不過,狀態機的實現方式有多種,除了狀態模式,比較常用的還有分支邏輯法和查表法。今天,我們就詳細講講這幾種實現方式,並且對比一下它們的優劣和應用場景。
什麼是有限狀態機?
有限狀態機,英文翻譯是 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;
}
}
實際上,像遊戲這種比較複雜的狀態機,包含的狀態比較多,我優先推薦使用查表法,而狀態模式會引入非常多的狀態類,會導致程式碼比較難維護。相反,像電商下單、外賣下單這種型別的狀態機,它們的狀態並不多,狀態轉移也比較簡單,但事件觸發執行的動作包含的業務邏輯可能會比較複雜,所以,更加推薦使用狀態模式來實現。