1. 状态模式到底在解决什么问题
先说个我早期写代码的真实经历。那时候接了一个订单系统的需求,订单有“待支付”“已支付”“已发货”“已完成”“已取消”这么几种状态,每个状态下用户能做的操作完全不一样:待支付能取消,已支付不能取消只能等发货,已发货能确认收货,已完成就只能申请售后了。我图省事,直接在一个Service类里写了几个方法,每个方法开头就是一长串if判断当前状态,像这样:
java复制public String cancel(Long orderId) {
Order order = orderDao.findById(orderId);
if ("待支付".equals(order.getStatus())) {
// 执行取消逻辑
} else if ("已支付".equals(order.getStatus())) {
// 提示取消失败
} else {
// 其他各种状态的处理
}
return ...;
}
刚开始状态少还好,后来需求方说还要加“退款中”“退款完成”“已关闭”……那个类直接膨胀到两千多行。每次改一个状态,都要把涉及这个状态的所有方法翻一遍,生怕漏掉某个分支。更要命的是,如果有人不小心在某个else里把状态判断写反了,线上就出bug了。那段日子让我深刻体会到一句话:用一堆if-else来管理状态流转,本质上是在手动维护一张状态机,而这张状态机散落在每个方法的犄角旮旯里。
状态模式解决的就是这个问题。它把“每个状态”封装成一个独立的类,状态自己的行为、状态允许跳转到哪些其他状态,都由这个类自己说了算。对象内部状态一旦改变,它的行为也跟着变,从外部看起来就像换了一个类一样。这就是状态模式的核心思想——允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎修改了它的类。
这篇文章适合正在学设计模式准备期末考试的同学、工作中被状态判断折磨的Java/C++工程师,以及想在Android源码或者其他框架里找到状态模式实际应用的人。我会把状态模式从原理讲到手写实现,再讲到我实际项目里的重构过程,最后聊聊它和策略模式那些经常被搞混的细节。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 状态模式的三个角色和一个核心机制
2.1 状态模式的结构拆解
状态模式的结构不复杂,核心就三个角色。
Context(上下文):持有当前状态对象的引用,对外提供业务方法。Context不关心具体状态类内部怎么实现,它只负责把请求转发给当前状态对象,同时提供切换状态的入口。
State(抽象状态):定义了一个接口或者抽象类,声明所有状态都要实现的行为方法。比如订单的State接口可以定义doPay()、doCancel()、doShip()、doConfirm()这些方法。
ConcreteState(具体状态):实现State接口的具体类,每个类代表一种具体状态,并且实现这个状态下各个行为方法的具体逻辑。比如“待支付”状态下调用doPay()就是执行支付,“已支付”状态下调用doPay()可能就是抛异常或者提示“不能重复支付”。
这三者之间的协作机制很巧妙:Context里存着一个State引用,每次业务方法被调用,Context就把这个调用转发给当前State对象。当状态要发生变化时,Context调用一个setState方法把引用换成另一个State对象。整个过程对调用方完全透明,调用方只跟Context打交道,完全感受不到状态切换这件事。
2.2 一个最小可运行的Java示例
理论说多了容易晕,直接上代码。我用订单系统来演示,先定义State接口:
java复制public interface OrderState {
// 支付
void pay(OrderContext context);
// 取消
void cancel(OrderContext context);
// 发货
void ship(OrderContext context);
// 确认收货
void confirm(OrderContext context);
// 当前状态名称,方便打印
String getStateName();
}
然后写四个具体状态类,先看“待支付”状态:
java复制public class PendingPayState implements OrderState {
@Override
public void pay(OrderContext context) {
System.out.println("订单支付成功,状态从[待支付]变为[已支付]");
context.setState(new PaidState());
}
@Override
public void cancel(OrderContext context) {
System.out.println("订单已取消,状态从[待支付]变为[已取消]");
context.setState(new CanceledState());
}
@Override
public void ship(OrderContext context) {
System.out.println("订单还未支付,不能发货");
}
@Override
public void confirm(OrderContext context) {
System.out.println("订单还未支付,不能确认收货");
}
@Override
public String getStateName() {
return "待支付";
}
}
“已支付”状态:
java复制public class PaidState implements OrderState {
@Override
public void pay(OrderContext context) {
System.out.println("订单已支付,请勿重复支付");
}
@Override
public void cancel(OrderContext context) {
System.out.println("订单已支付,不能取消");
}
@Override
public void ship(OrderContext context) {
System.out.println("订单已发货,状态从[已支付]变为[已发货]");
context.setState(new ShippedState());
}
@Override
public void confirm(OrderContext context) {
System.out.println("订单还未发货,不能确认收货");
}
@Override
public String getStateName() {
return "已支付";
}
}
Context类,也就是我们常说的OrderContext:
java复制public class OrderContext {
private OrderState currentState;
public OrderContext() {
// 初始状态是待支付
this.currentState = new PendingPayState();
}
public void setState(OrderState state) {
this.currentState = state;
}
public void pay() {
currentState.pay(this);
}
public void cancel() {
currentState.cancel(this);
}
public void ship() {
currentState.ship(this);
}
public void confirm() {
currentState.confirm(this);
}
public String getCurrentStateName() {
return currentState.getStateName();
}
}
写个测试类:
java复制public class StatePatternDemo {
public static void main(String[] args) {
OrderContext order = new OrderContext();
System.out.println("当前状态:" + order.getCurrentStateName());
order.pay();
System.out.println("当前状态:" + order.getCurrentStateName());
order.ship();
System.out.println("当前状态:" + order.getCurrentStateName());
order.confirm();
System.out.println("当前状态:" + order.getCurrentStateName());
order.pay(); // 已完成的订单再支付,会怎样?
}
}
运行结果:
code复制当前状态:待支付
订单支付成功,状态从[待支付]变为[已支付]
当前状态:已支付
订单已发货,状态从[已支付]变为[已发货]
当前状态:已发货
订单确认收货成功
当前状态:已完成
订单已完成,不能支付
看到这里你应该能感受到状态模式最直观的好处了:每个状态下能做什么、不能做什么,写在了对应的状态类里,而不是像以前那样散落在Service方法的一堆if里面。以后要加一个新状态,比如“退款中”,只需要新建一个RefundState类,然后在相关的状态类里把跳转逻辑补上就行,大部分旧代码不用动。
3. 从if-else重构到状态模式的完整实操
3.1 先看看重构前的坏味道代码长什么样
我给一个真实的项目案例。以前写过一个文本编辑器,文档有三种状态:草稿、已发布、已归档。不同状态下工具栏按钮的可用性不一样,接口校验逻辑也不一样,比如:“草稿”可以编辑、可以删除,“已发布”只能编辑不能删除,“已归档”什么都干不了,只能查看。
最初的代码长这样,这是典型的“switch-case堆积”:
java复制public void handleAction(String action, Doc doc) {
switch (doc.getStatus()) {
case DRAFT:
if ("edit".equals(action)) {
// 编辑逻辑
} else if ("delete".equals(action)) {
// 删除逻辑
} else if ("publish".equals(action)) {
// 发布逻辑
} else {
throw new UnsupportedOperationException("草稿状态不支持操作:" + action);
}
break;
case PUBLISHED:
if ("edit".equals(action)) {
// 编辑逻辑
} else if ("delete".equals(action)) {
throw new UnsupportedOperationException("已发布不能删除");
} else {
throw new UnsupportedOperationException("已发布不支持操作:" + action);
}
break;
case ARCHIVED:
throw new UnsupportedOperationException("已归档不支持任何操作");
default:
throw new IllegalStateException("非法状态:" + doc.getStatus());
}
}
这种代码的问题特别明显。第一,每个方法里都有这么一坨switch,什么delete接口、update接口、publish接口,每个接口都得重复写一遍状态判断。第二,新增一个状态就是在所有方法里再插一堆case,改动量是指数级的。第三,很容易出现“这个接口漏掉了新状态”的情况,一旦漏了,线上就会出现那种“草稿状态下居然能执行归档操作”的离奇bug。
3.2 重构的具体步骤
我没一次性推翻重写,而是分两步走。
第一步,先定义State抽象类,并把所有和状态有关的公共行为列出来:
java复制public abstract class DocState {
// 编辑文档
public abstract void edit(DocContext context, String content);
// 发布文档
public abstract void publish(DocContext context);
// 删除文档
public abstract void delete(DocContext context);
// 归档文档
public abstract void archive(DocContext context);
// 状态名称
public abstract String getStateName();
}
第二步,把原来的switch-case里的每块逻辑搬到对应的具体状态类里。比如DraftState是这样的:
java复制public class DraftState extends DocState {
@Override
public void edit(DocContext context, String content) {
// 草稿状态下允许编辑
context.setContent(content);
System.out.println("草稿内容已更新");
}
@Override
public void publish(DocContext context) {
System.out.println("草稿发布成功,状态变为[已发布]");
context.setState(new PublishedState());
}
@Override
public void delete(DocContext context) {
System.out.println("草稿已删除");
context.deleteDoc();
}
@Override
public void archive(DocContext context) {
System.out.println("草稿不能被归档,请先发布");
}
@Override
public String getStateName() {
return "草稿";
}
}
PublishedState类似,只是行为不同:
java复制public class PublishedState extends DocState {
@Override
public void edit(DocContext context, String content) {
context.setContent(content);
System.out.println("已发布文档内容已更新");
}
@Override
public void publish(DocContext context) {
System.out.println("文档已经是发布状态,不能重复发布");
}
@Override
public void delete(DocContext context) {
System.out.println("已发布文档不能删除");
}
@Override
public void archive(DocContext context) {
System.out.println("文档归档成功,状态变为[已归档]");
context.setState(new ArchivedState());
}
@Override
public String getStateName() {
return "已发布";
}
}
同时把Context里的switch-case方法换掉:
java复制public class DocContext {
private DocState state;
private String content;
public DocContext() {
this.state = new DraftState(); // 初始草稿
}
public void setState(DocState state) {
this.state = state;
}
public void edit(String content) {
state.edit(this, content);
}
public void publish() {
state.publish(this);
}
public void delete() {
state.delete(this);
}
public void archive() {
state.archive(this);
}
public void setContent(String content) {
this.content = content;
}
public void deleteDoc() {
this.content = null;
}
public String getStateName() {
return state.getStateName();
}
}
重构之后,原来的handleAction方法整个删掉了,每个具体状态类自己维护自己那部分行为边界。新增一个状态时只影响和它有跳转关系的状态类,不会把所有方法都搅在一起。这一步做完,代码行数虽然没少太多,但维护体验是完全不一样的——改“草稿”逻辑的时候只需要打开DraftState,不用在两千行文件里搜status关键字的出现位置。
3.3 关于"状态从哪来、往哪去"的设计选择
用状态模式还有个绕不开的设计点:状态跳转的代码放在哪?
最常见的两种做法。第一种是状态类自己决定下一个状态,比如我在订单例子里写的,PendingPayState的pay方法里直接new了PaidState并通过context.setState切过去。这种写法的好处是状态流转逻辑内聚,一个状态明确知道自己能跳到哪,不能跳到哪。坏处是状态类之间产生了间接依赖,A状态类里出现了B状态类的实例。
第二种是Context统一管理状态流转,也就是Context里维护一张映射表或者写一个方法专门判断流转是否合法,状态类只负责行为不负责跳转。这种写法状态类更干净,但Context代码会慢慢变胖。
我个人的经验是:如果状态数量少、流转路径固定,用第一种,代码直观好读。如果状态多、流转关系复杂,比如有十几二十个状态,建议单独抽一个状态流转管理器,把合法性校验集中在同一处,不然排查状态跳来跳去的问题会让人头大。这也是为什么后来我在项目里明显更倾向于把“状态流转规则”和“状态行为”分开管理的原因。
4. 状态模式和策略模式,别再把它们混为一谈
状态模式的消息里总有人把它和策略模式放一起比较。两者结构上确实长得很像,都有Context、都有抽象接口、都有具体实现类,调用方式也几乎一样。但它们的设计意图完全不同。
策略模式的核心是“可替换的算法”:客户端主动选择一个策略,然后在运行过程中策略一般不变。比如优惠价格计算,今天用满减策略,明天用折扣策略,那是客户端事先选定的,不会出现“算着算着自己换个策略”的情况。
状态模式的核心是“随状态变化的行为迁移”:状态的切换是对象内部自己驱动或者由外部动作触发的,而且状态一旦切换,对象的行为风格就整体变了。最经典的说法就是“状态模式是用来自动切换策略的策略模式”。
再往深一层看,两者的调用方向也存在差异。策略模式的策略选择权在客户端手里,客户端知道自己在用哪种策略。而状态模式的状态切换对客户端基本是隐藏的,客户端只调pay()、cancel()这些方法,根本不需要关心当前处于什么状态。
这里我提供一个小技巧帮你快速判断该用哪个模式。假如你写代码的时候,思考的是“在同一个状态下,根据不同条件走不同算法”,那是策略模式。假如你思考的是“不同状态下,同一操作的行为完全不同,而且状态会自己变化”,那是状态模式。一句话总结:策略模式切换的是算法,状态模式切换的是身份。
5. 真实场景和源码里的状态模式
5.1 哪些业务场景天生适合状态模式
从我接触过的项目来看,以下场景特别适合用状态模式。
订单系统:不同状态下的可操作行为完全不同,而且状态流转路径清晰。这是状态模式最典型的应用,也是面试里最常举的例子。
工作流审批:待审批、审批中、已通过、已驳回、已撤回。每一步能执行的操作都不一样,而且流转方向有严格限制,比如已经驳回的单子不能直接变成已通过。
播放器:播放、暂停、停止、快进、缓冲。这个场景有意思的地方在于,外部按键是同一个“播放键”,但不同状态下按它的效果完全不同——播放状态下按是暂停,暂停状态下按是继续播放,停止状态下按是重新开始。
电梯控制系统:静止、运行中、开门中、关门中、检修状态。电梯门不能在运行中打开,检修状态下不能响应呼叫,这些都是典型的状态限制。
TCP连接:别看它和业务系统离得远,TCP的状态机里LISTEN、SYN_SENT、ESTABLISHED、CLOSE_WAIT这些状态之间的跳转,本质就是状态模式的思想。
5.2 源码里的状态模式——以Android为例
这里我聊一个比较经典的源码例子:Android的WindowManager。如果你看过Android源码,会发现WindowManagerImpl内部对窗口操作的处理,实际上通过一个策略选择和状态管理并存的机制来运作。虽然严格说它更偏“代理模式”,但它里面大量使用了“当前窗口类型决定后续操作能否执行”的写法,这种思路跟状态模式是一脉相承的。
另一个例子是Android的ListView/RecyclerView的滑动状态,比如OnScrollListener里的SCROLL_STATE_IDLE、SCROLL_STATE_TOUCH_SCROLL、SCROLL_STATE_FLING,不同滚动状态下触摸事件的响应策略不一样。虽然它没有直接用状态模式的类结构来实现,但它是理解状态模式“行为随状态变化”这句话很好的现实映射。面试的时候如果你能拿RecyclerView滑动状态举例,会比单纯背订单例子更能让面试官眼前一亮。
5.3 C++等语言中实现状态模式要注意的事
热词里有人搜“C++状态模式”,我顺带说一嘴。C++实现状态模式和Java最大的区别在于内存管理。Java里new一个状态类然后直接setState,GC会处理旧对象。C++里如果用裸指针,需要在切换状态时小心delete,避免内存泄漏;用shared_ptr的话又要注意循环引用的问题——Context持有当前State,State的方法里又持有Context的this指针;如果用unique_ptr则要注意所有权转移。
我在C++版实践里比较推荐的状态对象管理方式是:如果状态类是无状态的(只是行为不同、不含数据字段),就做成单例,每次都返回同一个对象。这样既省内存又避免了切状态时的析构顺序问题。不过这要求你状态类里不能放跟具体业务实例相关的字段,只能放只读的配置。如果状态类必须带数据,那我建议由Context来持有所有状态的实例,切换时只换引用,这样生命周期统一由Context管,不会乱。
6. 状态模式里的坑、细节和面试考点
6.1 状态对象该用单例还是每次都new
这是状态模式落地时最常见的疑问。如果状态本身是无状态的,也就是说状态类里不保存业务数据,只有行为逻辑,那么完全可以做成单例,每个具体状态类只有一个实例,Context切换状态时持有同一个引用就行。这么做的好处是省内存,尤其在高并发场景下减少对象的创建和GC压力。
但如果状态类里要保存跟实例相关的数据,比如每个订单的“当前重试次数”“超时时间”这种字段,那必须每次new一个独立实例,否则多个订单共用一个状态实例就会互相污染数据。我踩过这个坑:起初图省事把带重试次数的状态做成了单例,结果A订单的重试次数莫名其妙出现在B订单上,排查了好久才发现是状态实例被共享了。
6.2 状态流转的合法性校验别只靠状态类自觉
状态模式很优雅的一点是状态类自己知道哪些流转是合法的,非法操作直接忽略或者抛异常。但实际项目中,状态流转的合法性往往不只是“当前状态允不允许”这么简单,还可能涉及权限、资源、时间窗口等条件。比如“待支付”状态的订单确实可以取消,但如果是超过30天且已经自动确认收货的订单,即使它内部状态还是“待支付”,也不能取消了。这种规则如果全塞在状态类里,状态类会越来越臃肿。
我的建议是:锁存规则和业务校验可以放在Context或者Service层,状态类只负责基础的状态流转。这样才能让状态类保持单一职责,避免变成一个大杂烩。
6.3 状态模式违反开闭原则这件事
说实话,状态模式并不是完美的。虽然它能把一个状态的行为聚拢在一起,但新增一个状态时,你通常还是要改动已有的状态类来增加“从旧状态跳到新状态”的路径。比如订单状态从“待支付”加了“待支付(且支付超时)”这个新状态,你可能得在待支付、已支付等多个旧状态类里都加跳转分支。所以说,状态模式只是把“改坏味道switch”的代价分散到了少数几个类里,并没有从根本上做到开闭原则。面试的时候如果你能主动指出这个局限,会给面试官留下思考深入的印象。
另外还有一点,状态模式会让类数量明显增加。一个状态的每一个操作都是一个类,如果状态有10个、操作有5种,那就是50个行为方法分布在10个类里。小系统强行用状态模式反而会显得过度设计。我的建议是:if-else分支超过三层、状态超过五个的时候再考虑状态模式,小状态机直接if-else或者查表法反而更清晰。
6.4 状态模式与有限状态机的关系
最后再聊一个容易被问到的问题:状态模式和有限状态机(FSM)是一回事吗?
严格说,状态模式是一种代码组织方式,而有限状态机是一种更抽象的理论模型。有限状态机强调的是“状态”和“事件”的二元组定义,通常可以用状态转移表来完整描述。状态模式是实现状态机的一种具体手段,但它不能覆盖状态机的全部诉求。比如状态机里可以支持“进入状态时执行的动作”“离开状态时的动作”“某个事件触发但没有状态变化”等行为,这些用状态模式天然表达起来没那么直接,通常要配合观察者模式或者模板方法一起用。
我处理复杂状态机时比较常用的做法是:先画状态转移表,把所有状态、事件、动作、下一状态列清楚,确认无误后再决定用状态模式还是直接用一张转移表的配置驱动。如果状态和事件组合特别多,有时候一张表比二十个状态类更管用。技术选型从来不是“哪个模式高级用哪个”,而是“哪个方案维护成本低用哪个”。
写到这里,我想起当时重构订单Service那个下午的心路历程。从满屏的else if到清爽的状态类,最直观的感受不是代码变短了,而是找问题变快了。以前线上出了bug,要先打开巨大的Service文件,一层层翻if分支,翻得眼睛都快瞎了。现在只需要打开对应的状态类,几十行代码,一眼就能扫完某个状态的完整逻辑。这种“心智负担的断崖式下降”才是设计模式带给你的真正价值,不是大括号的数量,不是类名多华丽,而是你改代码时再也不用提心吊胆了。状态模式不是什么高深的东西,它就是帮你把状态这摊子事理清楚的一个工具,该用的时候果断用,不该用的时候也别硬上。
