设计模式学习笔记 - 设计模式与范式 -行为型:8.状态模式:游戏、工作流引擎中常用的状态机是如何实现的?

概述

本章学习状态模式。在实际的开发中,状态模式并不是很常用,但是在能够用到的场景里,它可以发挥很大的作用。从这一点上看,它有点像我们之前讲到的组合模式

状态模式一般用来实现状态机,而状态机常用在游戏、工作流引擎等系统开发中。不过,状态机的实现方式有很多种,除了状态模式,比较常用的还有分支逻辑法和查表法。本章就详细讲讲这几种实现方式,并且对比一下它们的优劣和应用场景。


什么是有限状态机

有限状态机,英文翻译是 Finite State Machine,缩写为 FSM,简称状态机。状态机有三个组成部分:状态(State)、事件(Event)、动作(Action)。

  • 事件也称为转移条件(Transaction Condition)。
  • 事件触发状态的转移及动作的执行。
  • 不过,动作不是必须得,也可能只转移状态,不执行任何动作。

对于刚刚给出的状态机定义,结合一个具体的例子进行解释。

"超级马里奥" 游戏不知道你玩过没有?在游戏中,马里奥可以变身为多种形态,比如小马里奥(Small Mario)、超级马里奥(Super Mario)、火焰马里奥(Fire Mario)、斗篷马里奥(Cape Mario)等等。在不同的游戏情节下,各个形态会互相转化,并相应地增减积分。比如,初始状态是小马里奥,吃了蘑菇后就变成超级马里奥,并且增加 100 积分。

实际上,马里奥形态的转变就是一个状态机。其中,马里奥的不同形态就是状态机种的 "状态",游戏情节(比如吃了蘑菇)就是状态机种的 "事件",加减积分就是状态机种的 "动作"。比如,吃蘑菇这个事件,会触发状态的转移(从小马里奥转移到超级马里奥),以及触发动作的执行(增加 100 积分)。

为方便讲解,我对游戏背景做了简化,只保留了部分状态和事件,简化之后的状态转移如下所示:

如何编程来实现上面的状态机呢?

我写了个骨架代码,如下所示。其中,obtainMushRoom()obtainCape()obtainFileFlower()meetMonster() 这个几个函数,能够根据当前的状态和事件,更新状态和增减积分。不过,具体的代码实现暂时没给出。你可以先试着自己补全一下。

java 复制代码
public enum State {
    SMALL(0),
    SUPER(1),
    FIRE(2),
    CAPE(3),
    ;

    private int value;

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

    public int getValue() {
        return 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 score;
    }

    public State getState() {
        return currentState;
    }
}

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

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

对于如何实现状态机,有三种方式。其中,最简单直接的实现方式是,参照状态转移图,将每一个状态转移,原模原样地直译成代码。这样编写的代码会包含大量的 if-esle 或 switch-else 分支判断逻辑,甚至是嵌套的分支判断逻辑,所以,我把这种方法暂且命名为分支逻辑法

按照这个思路,将上面的骨架代码补全一下。补全之后的代码如下所示:

java 复制代码
public class MarioStateMachine {
    private int score;
    private State currentState;

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

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

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

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

    public void meetMonster() {
        if (currentState == State.SUPER) {
            this.currentState = State.SMALL;
            this.score -= 100;
        }
        if (currentState == State.CAPE) {
            this.currentState = State.SMALL;
            this.score -= 200;
        }
        if (currentState == State.FIRE) {
            this.currentState = State.SMALL;
            this.score -= 300;
        }
    }

    public int getScore() {
        return score;
    }

    public State getState() {
        return currentState;
    }
}

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

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

实际上,上面的实现方式有点类似 hard code,对于复杂的状态机来说不适用,而状态机的第二种实现方式查表法,就更加合适了。接下来,看下如何利用查表法来补全骨架代码。

实际上,除了用状态转移图来表示之外,状态机还可以用二维表来表示,如下所示。这个二维表中,第一维表示当前的状态,第二维表示当前状态经过事件之后,转移到的新状态及其执行的动作。

E1(Got MushRoom) E2(Got Cape) E3(Got Fire Flower) E4(Meet Monster)
Small Super/+100 Cape/+200 Fire/+300 /
Super / Cape/+200 Fire/+300 Small/-100
Cape / / / Small/-200
Fire / / / Small/-300

注:表中的斜杠表示不存在这种状态转移

相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。当修改状态机时,我们只需要修改 transactionTableactionTable 两个二维数组即可。实际上,如果我们把二维数组存储在配置文件中,当需要修改状态机时,甚至可以不修改代码,只需要修改配置文件就可以了。具体代码如下所示:

java 复制代码
public enum Event {
    GOT_MUSHROOM(0),
    GOT_CAPE(1),
    GOT_FIRE(2),
    MEET_MONSTER(3),
    ;

    private int value;

    Event(int value) {
        this.value = value;
    }

    public int getValue() {
        return value;
    }
}

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

    private static final State[][] transitionTable = {
            {SUPER, CAPE, FIRE, SMALL},
            {SUPER, CAPE, FIRE, SMALL},
            {CAPE, CAPE, CAPE, SMALL},
            {FIRE, FIRE, FIRE, SMALL}
    };

    private static final int[][] actionTable = {
            {100, 200, 300, 0},
            {0, 200, 300, -100},
            {0, 0, 0, -200},
            {0, 0, 0, -300},
    };

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

    public void obtainMushRoom() {
        executeEvent(Event.GOT_MUSHROOM);
    }

    public void obtainCape() {
        executeEvent(Event.GOT_CAPE);
    }

    public void obtainFireFlower() {
        executeEvent(Event.GOT_FIRE);
    }

    public void meetMonster() {
        executeEvent(Event.MEET_MONSTER);
    }

    private void executeEvent(Event event) {
        int stateValue = currentState.getValue();
        int eventValue = event.getValue();
        this.currentState = transitionTable[stateValue][eventValue];
        this.score += actionTable[stateValue][eventValue];
    }

    public int getScore() {
        return score;
    }

    public State getState() {
        return currentState;
    }
}

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

在查表法的代码实现中,事件触发的动作只能是简单的积分加减,所以,我们用一个 int 类型的二维数组 actionTable 就能表示,二维数组中的值表示积分的加减值。但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作(比如加减积分、写数据库,还有可能发送消息通知等等),我们就没法用如此简单的二维数组来表示了。也就是说,查表法的实现方式有一定的局限性。

虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码的可读性和可维护性不好等。实际上,对于分支逻辑法存在的问题,可以使用状态模式来解决。

状态模式通过将事件触发的状态转移和动作执行,拆分到不同的类中,来避免分支判断逻辑。我们还是结合代码来理解这句话。

其中,IMario 是状态的接口,定义了所有事件。SmallMarioSuperMarioCapeMarioFireMarioIMario 接口的实现类,分别对应状态机种的 4 个状态。原来所有的状态转移和动作执行的代码逻辑,都集中在 MarioStateMachine 中,现在,这些代码被拆分到了这 4 个状态类中。

java 复制代码
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);
    }
}

public class CapeMario implements IMario {
    private MarioStateMachine stateMachine;

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

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

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

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

    @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() - 200);
    }
}

public class FireMario implements IMario {
    private MarioStateMachine stateMachine;

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

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


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

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

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

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

public class MarioStateMachine {
    private int score;
    private IMario currentState; // 不在使用枚举表示状态

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

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

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

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

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

    public int getScore() {
        return score;
    }

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

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

    public IMario getState() {
        return currentState;
    }
}

上面的代码实现不难看懂,只需要注意一点,即 MarioStateMachine 和各个状态类之间是双向依赖关系。 MarioStateMachine 依赖各个类是理所当然的,但是反过来,各个状态类为什么要依赖 MarioStateMachine 呢? 这是因为,各个状态类需要更新 MarioStateMachine 中的属性, scorecurrentState

实际上,上面的代码还可以继续优化,可以将状态类设置成单例,比较状态类中不包含任何成员变量。但是,当状态类设计成单例之后,就无法通过构造函数来传递 MarioStateMachine 了,而状态类又要依赖 MarioStateMachine ,那该如何解决呢?

实际上,在《创建型:2.单例模式(中):为什么不推荐使用单例模式?又有何替代方案?》中,提到过集中解决方法,你可以回过头去查看下。在这里,可以通过函数参数将 MarioStateMachine 传递进状态类。根据这个设计思路,对上面的代码进行重构。重构之后的代码如下所示:

java 复制代码
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...
    }
}

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

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


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

    @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) {
        stateMachine.setCurrentState(SmallMario.getInstance());
        stateMachine.setScore(stateMachine.getScore() - 100);
    }
}

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

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

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

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

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

    @Override
    public void meetMonster(MarioStateMachine stateMachine) {
        stateMachine.setCurrentState(SmallMario.getInstance());
        stateMachine.setScore(stateMachine.getScore() - 200);
    }
}

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

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


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

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

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

    @Override
    public void meetMonster(MarioStateMachine stateMachine) {
        stateMachine.setCurrentState(SmallMario.getInstance());
        stateMachine.setScore(stateMachine.getScore() - 300);
    }
}

public class MarioStateMachine {
    private int score;
    private IMario currentState; // 不在使用枚举表示状态

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

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

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

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

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

    public int getScore() {
        return score;
    }

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

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

    public IMario getState() {
        return currentState;
    }
}

实际上,像游戏这种比较复杂的状态机,包含的状态比较多,优先推荐使用查表法,而状态模式会引入非常多的状态类,会导致代码比较难维护。

相反,像电商下单、外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,所以更加推荐使用状态模式来实现。

总结

本章讲解了状态模式。虽然网上有各种各样的状态模式,但是你只要记住状态模式是状态机的一种实现方式即可

状态机又叫有限状态机,它由3部分组成:状态、事件、动作。

  • 其中事件也称为转移条件。
  • 事件触发状态的转移及动作的执行。
  • 不过动作不是必须的,也可能只转移状态,不执行任何动作。

针对状态机,本章总结了三种实现方式。

  • 第一种实现方式叫分支逻辑法。利用 if-else 或 switch-case 分支逻辑,参照状态转移图,将每个状态转移原模原样的直译成代码。对于简单的状态机来说,这种实现方式最简单、最直接,是首选。
  • 第二种实现方式叫查表法。对于状态很多、状态转移比较复杂的状态机来说,查表法比较合适。通过二维素组来表示状态转移图,能极大地提高代码的可读性和可维护性。
  • 第三张实现方式叫状态模式。对于状态不多、状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说,首选这种实现方式。
相关推荐
e***98577 小时前
SpringMVC的工作流程
状态模式
q***087411 小时前
SpringMVC的工作流程
状态模式
g***789120 小时前
SpringBoot中使用TraceId进行日志追踪
spring boot·后端·状态模式
shuxiaohua3 天前
使用HttpURLConnection调用SSE采坑记录
状态模式
崎岖Qiu3 天前
状态模式与策略模式的快速区分与应用
笔记·设计模式·状态模式·策略模式·开闭原则
Jonathan Star3 天前
前端需要做单元测试吗?哪些适合做?
前端·单元测试·状态模式
一水鉴天5 天前
整体设计 全面梳理复盘 之40 M3 统摄三层 AI 的动态运营社区(Homepage)设计
架构·transformer·状态模式·公共逻辑
前端玖耀里8 天前
Vue + Axios + Node.js(Express)如何实现无感刷新Token?
状态模式
将编程培养成爱好8 天前
C++ 设计模式《外卖骑手状态系统》
c++·ui·设计模式·状态模式
向葭奔赴♡9 天前
Spring Boot参数校验全流程解析
状态模式