状态模式
一、简介概述
状态模式并不是很常用,但是在能够用到的场景里,它可以发挥很大的作用。
状态设计模式是一种行为型设计模式,它允许对象在其内部状态发生变化时改变其行为 。这种模式可以消除大量的条件语句,并将每个状态的行为封装到单独的类中。
状态模式的主要组成部分如下:
- 上下文(Context) :上下文通常包含一个具体状态的引用,用于维护当前状态。上下文委托给当前状态对象处理状态相关行为。
- 抽象状态(State):定义一个接口,用于封装与上下文的特定状态相关的行为。
- 具体状态(Concrete State):实现抽象状态接口,为具体状态定义行为。每个具体状态类对应一个状态。
现在来看一个简单的 Java 示例。假设要模拟一个简易的电视遥控器,具有开启、关闭和调整音量的功能。
如果不使用设计模式,编写出来的代码可能是这个样子的,我们需要针对电视机当前的状态为每一次操作编写判断逻辑:
java
public class TV {
private boolean isOn;
private int volume;
public TV() {
isOn = false;
volume = 0;
}
public void turnOn() {
// 如果是开启状态
if (isOn) {
System.out.println("TV is already on.");
// 否则打开电视
} else {
isOn = true;
System.out.println("Turning on the TV.");
}
}
public void turnOff() {
if (isOn) {
isOn = false;
System.out.println("Turning off the TV.");
} else {
System.out.println("TV is already off.");
}
}
public void adjustVolume(int volume) {
if (isOn) {
this.volume = volume;
System.out.println("Adjusting volume to: " + volume);
} else {
System.out.println("Cannot adjust volume, TV is off.");
}
}
}
public class Main {
public static void main(String[] args) {
TV tv = new TV();
tv.turnOn();
tv.adjustVolume(10);
tv.turnOff();
}
}
当然在该例子中状态比较少,所以代码看起来也不是很复杂,但是状态如果变多了呢?比如加入换台,快捷键、静音等功能后呢?你会发现条件分支会急速膨胀,所以此时状态设计模式就要登场了
首先,定义抽象状态接口 TVState
,将每一个修改状态的动作抽象成一个接口:
java
public interface TVState {
void turnOn();
void turnOff();
void adjustVolume(int volume);
}
接下来,为每个具体状态创建类,实现 TVState
接口。例如,创建 TVOnState
和 TVOffState
类:
java
// 在on状态下,去执行以下各种操作
public class TVOnState implements TVState {
@Override
public void turnOn() {
System.out.println("TV is already on.");
}
@Override
public void turnOff() {
System.out.println("Turning off the TV.");
}
@Override
public void adjustVolume(int volume) {
System.out.println("Adjusting volume to: " + volume);
}
}
// 在关机的状态下执行以下的操作
public class TVOffState implements TVState {
@Override
public void turnOn() {
System.out.println("Turning on the TV.");
}
@Override
public void turnOff() {
System.out.println("TV is already off.");
}
@Override
public void adjustVolume(int volume) {
System.out.println("Cannot adjust volume, TV is off.");
}
}
接下来,定义上下文类 TV
:
java
public class TV {
// 当前状态
private TVState state;
public TV() {
state = new TVOffState();
}
public void setState(TVState state) {
this.state = state;
}
public void turnOn() {
// 打开
state.turnOn();
// 设置为开机状态
setState(new TVOnState());
}
public void turnOff() {
// 关闭
state.turnOff();
// 设置为关机状态
setState(new TVOffState());
}
public void adjustVolume(int volume) {
state.adjustVolume(volume);
}
}
最后,我们可以通过以下方式使用这些类:
java
public class Main {
public static void main(String[] args) {
TV tv = new TV();
tv.turnOn();
tv.adjustVolume(10);
tv.turnOff();
}
}
这个例子展示了状态模式的基本结构和用法。通过使用状态模式,我们可以更好地组织和管理与特定状态相关的代码。当状态较多时,这种模式的优势就会凸显出来,同时我们在代码时,因为我们会对每个状态进行独立封装,所以也会简化代码编写。
二、有限状态机
状态模式一般用来实现状态机 ,而状态机常用在游戏、工作流引擎等系统开发中。不过,状态机的实现方式有多种,除了状态模式,比较常用的还有分支逻辑法和查表法。今天,我们就详细讲讲这几种实现方式,并且对比一下它们的优劣和应用场景。
有限状态机,英文翻译是 Finite State Machine,缩写为 FSM,简称为状态机,比较官方的说法是:有限状态机是描述对象在它的生命周期内所经历的状态序列,以及如何响应来自外界的各种事件。。状态机有 3 个组成部分:状态(State)、事件(Event)、动作(Action)。其中,事件也称为转移条件(Transition Condition)。事件触发状态的转移及动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。
"超级马里奥":在游戏中,马里奥可以变身为三种形态,小马里奥(Small Mario)、大马里奥(Big Mario)和火焰马里奥(Fire Mario)。马里奥可以通过吃蘑菇(Mushroom)、火花(Fire Flower)或被敌人攻击(Enemy Attack)来改变形态。我们将用状态图表示这个马里奥的有限状态机。
可以根据这个有限状态机来实现一个马里奥游戏的简化版本。在实际游戏开发中,通常会使用游戏引擎或编程框架来处理状态转换,而不是手动编写状态机代码。不过,这个简化示例可以帮助理解有限状态机在马里奥游戏中的应用。
1、分支法
对于如何实现状态机,我总结了三种方式 。其中,最简单直接的实现方式是,参照状态转移图,将每一个状态转移,原模原样地直译成代码。这样编写的代码会包含大量的 if-else 或 switch-case 分支判断逻辑,甚至是嵌套的分支判断逻辑,所以,我们把这种方法暂且命名为分支法。
按照这个实现思路,我将上面的骨架代码补全一下。补全之后的代码如下所示:
下面是一个使用if-else 语句实现的马里奥形态变化的代码示例:
java
public class Mario {
private MarioState marioState;
public Mario() {
this.marioState = MarioState.SMALL;
}
public void handleEvent(MarioEvent event) {
if (marioState == MarioState.DEAD) {
return;
}
// 不同状态下吃蘑菇会有什么反应
if (event == MarioEvent.MUSHROOM) {
// 只有小马里奥吃蘑菇才会变大
if (marioState == MarioState.SMALL) {
marioState = MarioState.BIG;
}
} else if (event == MarioEvent.FIRE_FLOWER) {
// 只有大马里奥吃花火会变火
if (marioState == MarioState.BIG) {
marioState = MarioState.FIRE;
}
} else if (event == MarioEvent.ENEMY_ATTACK) {
if (marioState == MarioState.SMALL) {
marioState = MarioState.DEAD;
} else if (marioState == MarioState.BIG || marioState == MarioState.FIRE) {
marioState = MarioState.SMALL;
}
} else if (event == MarioEvent.FALL_INTO_PIT) {
marioState = MarioState.DEAD;
}
}
public static void main(String[] args) {
Mario mario = new Mario();
mario.handleEvent(MarioEvent.MUSHROOM);
mario.handleEvent(MarioEvent.FIRE_FLOWER);
mario.handleEvent(MarioEvent.ENEMY_ATTACK);
mario.handleEvent(MarioEvent.FALL_INTO_PIT);
System.out.println(mario.marioState);
}
}
在这个示例中,我们使用 if-else 语句来处理状态转换。在 handleEvent
方法中,我们根据事件和当前状态的组合来确定新状态 ,并更新马里奥的状态。这种实现方法相较于查表法和面向对象实现更为简单,但可能在状态和事件更多的情况下变得难以维护。选择合适的实现方法取决于实际需求和场景。
2、查表法
实际上,上面这种实现方法有点类似 hard code,对于复杂的状态机来说不适用,而状态机的第二种实现方式查表法,就更加合适了。接下来,我们就一块儿来看下,如何利用查表法来补全骨架代码。
我们可以将马里奥的状态转移方式表示为以下表格:
当前状态/事件 | MUSHROOM | FIRE_FLOWER | ENEMY_ATTACK | FALL_INTO_PIT |
---|---|---|---|---|
SMALL | BIG | SMALL | DEAD | DEAD |
BIG | BIG | FIRE | SMALL | DEAD |
FIRE | FIRE | FIRE | BIG | DEAD |
DEAD | DEAD | DEAD | DEAD | DEAD |
这个表格显示了马里奥在不同状态下遇到不同事件时将转换为的新状态。从左到右分别表示当前状态(SMALL, BIG, FIRE, DEAD),从上到下表示事件(MUSHROOM, FIRE_FLOWER, ENEMY_ATTACK, FALL_INTO_PIT)。表格中的每个单元格表示对应状态和事件的状态转换结果。
查表法是一种使用查找表来处理状态转换的方法,可以简化状态机的实现。以下是使用查表法实现马里奥形态变化的代码示例:
java
enum MarioState {
SMALL, BIG, FIRE, DEAD
}
enum Event {
MUSHROOM, FIRE_FLOWER, ENEMY_ATTACK, FALL_INTO_PIT
}
public class Mario {
private MarioState state;
// 使用二维数组定义状态转换表
private static final MarioState[][] TRANSITION_TABLE = {
// SMALL, BIG, FIRE, DEAD
{MarioState.BIG, MarioState.BIG, MarioState.FIRE, MarioState.DEAD}, // MUSHROOM
{MarioState.SMALL, MarioState.FIRE, MarioState.FIRE, MarioState.DEAD}, // FIRE_FLOWER
{MarioState.DEAD, MarioState.SMALL, MarioState.BIG, MarioState.DEAD}, // ENEMY_ATTACK
{MarioState.DEAD, MarioState.DEAD, MarioState.DEAD, MarioState.DEAD} // FALL_INTO_PIT
};
public Mario() {
state = MarioState.SMALL;
}
public void handleEvent(Event event) {
// 使用查表法获取状态转换后的新状态
MarioState newState = TRANSITION_TABLE[event.ordinal()][state.ordinal()];
// 打印状态转换信息
System.out.printf("从 %s 变为 %s%n", state, newState);
// 更新状态
state = newState;
}
}
public class MarioDemo {
public static void main(String[] args) {
Mario mario = new Mario();
mario.handleEvent(Event.MUSHROOM); // 变为大马里奥
mario.handleEvent(Event.FIRE_FLOWER); // 变为火焰马里奥
mario.handleEvent(Event.FALL_INTO_PIT); // 变为死亡马里奥
}
}
在这个示例中,使用了一个二维数组 TRANSITION_TABLE
来表示状态转换表。数组的行表示事件,列表示马里奥的当前状态,数组的元素表示新状态。通过查找表,我们可以直接获取状态转换后的新状态,从而简化状态机的实现。
handleEvent
方法中,我们根据事件和当前状态的序数来查找新状态,并更新马里奥的状态。这个查表法实现的有限状态机相比之前的面向对象实现更为简洁,但可能不适用于需要处理复杂事件或动作的场景。根据实际需求选择合适的实现方法是很重要的。
相对于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性更好。当修改状态机时,只需要修改 transitionTable 和 actionTable 两个二维数组即可。实际上,我们把这两个二维数组存储在配置文件中,当需要修改状态机时,我们甚至可以不修改任何代码,只需要修改配置文件就可以了
3、状态模式
在查表法的代码实现中,事件触发的动作只是简单的状态或者数值,所以,用一个 MarioState类型的二维数组 TRANSITION_TABLE 就能表示,二维数组中的值表示出发事件后的新状态。但是,如果要执行的动作并非这么简单,而是一系列复杂的逻辑操作(比如加减分数、处理位置信息等等),就没法用如此简单的二维数组来表示了。这也就是说,查表法的实现方式有一定局限性。
虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码可读性和可维护性不好等。实际上,针对分支逻辑法存在的问题,我们可以使用状态模式来解决。
**状态模式通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,来避免分支判断逻辑。**我们还是结合代码来理解这句话。
利用状态模式,来补全 MarioStateMachine 类,补全后的代码如下所示。
以下是一个使用 Java 实现的简化版马里奥形态变化的案例代码:
java
/**
* 抽象状态
*/
public interface MarioStatus {
void eatMushroom();
void eatFireFlower();
void enemyAttack();
void fallFit();
}
public class MairoSmallState implements MarioStatus {
private Mario mario;
public MairoSmallState(Mario mario) {
this.mario = mario;
}
@Override
public void eatMushroom() {
System.out.println("小马里奥吃了蘑菇变大了");
// 修改状态
mario.setMarioState(new MarioBigState(mario));
}
@Override
public void eatFireFlower() {
System.out.println("小马里奥吃了火焰没变化");
}
@Override
public void enemyAttack() {
System.out.println("小马里奥遇到敌人,死亡");
mario.setMarioState(new MarioDeadState(mario));
}
@Override
public void fallFit() {
System.out.println("小马里奥掉坑里,死亡");
mario.setMarioState(new MarioDeadState(mario));
}
}
public class MarioBigState implements MarioStatus{
private Mario mario;
public MarioBigState(Mario mario) {
this.mario = mario;
}
@Override
public void eatMushroom() {
System.out.println("大马里奥吃了蘑菇,没变化");
}
@Override
public void eatFireFlower() {
System.out.println("小马里奥吃了火焰,变成火焰马里奥");
mario.setMarioState(new MarioFireState(mario));
}
@Override
public void enemyAttack() {
System.out.println("大马里奥遇到敌人,变成小马里奥");
mario.setMarioState(new MairoSmallState(mario));
}
@Override
public void fallFit() {
System.out.println("大马里奥掉坑里,死亡");
mario.setMarioState(new MarioDeadState(mario));
}
}
public class MarioDeadState implements MarioStatus{
private Mario mario;
public MarioDeadState(Mario mario) {
this.mario = mario;
}
@Override
public void eatMushroom() {
System.out.println("马里奥已死亡");
}
@Override
public void eatFireFlower() {
System.out.println("马里奥已死亡");
}
@Override
public void enemyAttack() {
System.out.println("马里奥已死亡");
}
@Override
public void fallFit() {
System.out.println("马里奥已死亡");
}
}
public class MarioFireState implements MarioStatus{
private Mario mario;
public MarioFireState(Mario mario) {
this.mario = mario;
}
@Override
public void eatMushroom() {
System.out.println("火焰马里奥吃了蘑菇没变化");
}
@Override
public void eatFireFlower() {
System.out.println("火焰马里奥吃了火焰没变化");
}
@Override
public void enemyAttack() {
System.out.println("火焰马里奥遇到敌人,变成小马里奥");
mario.setMarioState(new MairoSmallState(mario));
}
@Override
public void fallFit() {
System.out.println("火焰马里奥掉坑里,死亡");
mario.setMarioState(new MarioDeadState(mario));
}
}
@Data
public class Mario {
private MarioStatus marioState;
public Mario() {
this.marioState = new MairoSmallState(this);
}
public void handEvent(MarioEvent marioEvent) {
if (marioEvent == MarioEvent.MUSHROOM) {
marioState.eatMushroom();
} else if (marioEvent == MarioEvent.FIRE_FLOWER) {
marioState.eatFireFlower();
} else if (marioEvent == MarioEvent.ENEMY_ATTACK) {
marioState.enemyAttack();
} else if (marioEvent == MarioEvent.FALL_INTO_PIT) {
marioState.fallFit();
}
}
public static void main(String[] args) {
Mario mario = new Mario();
mario.handEvent(MarioEvent.MUSHROOM);
mario.handEvent(MarioEvent.FIRE_FLOWER);
mario.handEvent(MarioEvent.ENEMY_ATTACK);
mario.handEvent(MarioEvent.FALL_INTO_PIT);
}
}
在这个简化示例中,我们定义了 MarioState
接口以及实现了DeadMario
、 SmallMario
、BigMario
和 FireMario
类,分别表示马里奥的四种形态。每个形态类实现了 handleEvent
方法,用于处理不同的游戏事件并根据有限状态机规则进行状态转换。
Mario
类作为状态的上下文,用于管理和切换马里奥的状态。它有一个 setState
方法,用于更新当前状态。handleEvent
方法将事件传递给当前状态,以便根据事件执行相应的状态转换。
在 MarioDemo
测试类中,创建了一个 Mario
实例,并通过调用 handleEvent
方法模拟游戏中的事件。通过运行这个测试类,你可以观察到马里奥根据有限状态机的规则在不同形态之间切换。
这个简化示例展示了如何使用有限状态机来实现马里奥角色的形态变化。在实际游戏开发中,可能需要考虑更多的事件和状态,以及与游戏引擎或框架集成的方式。不过,这个示例可以帮助你理解有限状态机在游戏中的应用
三、重点回顾
虽然网上有各种状态模式的定义,但是你只要记住状态模式是状态机的一种实现方式即可。状态机又叫有限状态机,它有 3 个部分组成:状态、事件、动作。其中,事件也称为转移条件。事件触发状态的转移及动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。
针对状态机,今天我们总结了三种实现方式。
第一种实现方式叫分支逻辑法。利用 if-else 或者 switch-case 分支逻辑,参照状态转移图,将每一个状态转移原模原样地直译成代码。对于简单的状态机来说,这种实现方式最简单、最直接,是首选。
第二种实现方式叫查表法。对于状态很多、状态转移比较复杂的状态机来说,查表法比较合适。通过二维数组来表示状态转移图,能极大地提高代码的可读性和可维护性。
第三种实现方式叫状态模式。对于状态并不多、状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说,我们首选这种实现方式。
状态模式的代码实现还存在一些问题,比如,状态接口中定义了所有的事件函数,这就导致,即便某个状态类并不需要支持其中的某个或者某些事件,但也要实现所有的事件函数。不仅如此,添加一个事件到状态接口,所有的状态类都要做相应的修改。针对这些问题,你有什么解决方法吗?