一种无限异步状态机的工程实践
前言
做游戏开发时,我们经常遇到这样的场景:游戏从主菜单进入游戏,然后暂停,再返回游戏,最后回到主菜单。这背后就是状态机在工作。
传统写法用 if-else 或者 switch 来管理,代码量一多就乱成一团。本文要讲的状态机系统,解决了三个核心问题:
- 状态切换异步化 - 支持保存数据、加载资源等耗时操作
- 状态可回退 - 像浏览器后退按钮一样返回上一个状态
- 零配置使用 - 新增状态不需要注册,直接就能用
核心设计:泛型 + 多态
状态基类的设计
1 | public abstract class StateBase<T> where T : class |
这个设计有几个关键点:
1. 泛型参数 T 的作用
T 看起来多余,其实是为了类型隔离。你可以同时存在多个独立的状态机系统:
1 | StateBase<GameStateBase> // 游戏状态机 |
三个系统互不干扰,各自管理自己的状态。
2. HaveExitTask 机制 - 核心创新
这是这个系统最巧妙的设计。先看问题:传统状态切换是这样的:
1 | ExitState() // 退出当前状态 |
但实际开发中,ExitState 可能需要保存数据、上传服务器,这些操作是异步的。如果直接写成同步代码,要么阻塞主线程,要么状态切换不完整。
解决方案:
1 | public bool HaveExitTask { get; protected set; } |
状态管理器这样处理:
1 | if (currentState.HaveExitTask) |
使用时这样写:
1 | public class GamePauseState : GameStateBase |
3. enterCount 参数 - 区分首次进入和重入
1 | public override void EnterState(int enterCount, object parameters) |
这个参数特别适合”重新开始游戏”的场景。
状态管理器:双泛型的妙用
CRTP 设计模式
1 | public class StateManagerBase<T, K> : MonoBehaviour |
为什么要两个泛型参数?
T 是状态类型,确保注册的状态类型正确。
K 是管理器类型,用了 CRTP(奇异递归模板模式):
1 | public static K Instance |
好处是:Instance 返回的是具体类型,不需要强制转换。
1 | // 使用时 |
状态切换的核心流程
1 | public void SetState<E>(object parameters = null) where E : StateBase<T> |
整个流程就像接力棒:当前状态完成退出任务后,通过回调通知管理器切换到新状态。
状态栈:像浏览器一样后退
1 | private Stack<StateBase<T>> _stateStack = new Stack<StateBase<T>>(); |
每个 SetState 都会把当前状态压栈:
1 | 初始状态:Menu |
这个机制特别适合多级菜单:主菜单 → 设置 → 音频 → 返回音频 → 返回设置 → 返回主菜单。
自动注册:反射的威力
传统方式需要手动注册每个状态:
1 | // 繁琐的写法 |
这个系统用反射实现自动注册:
1 | private StateBase<T> CreateAndRegisterState<E>() where E : StateBase<T> |
状态只在第一次使用时创建,之后永久缓存在字典里:
1 | private Dictionary<string, StateBase<T>> _statesDict; |
性能对比:
- 反射创建:约 1ms(只在第一次)
- 字典查找:约 0.00001ms(O(1))
- 游戏运行时性能几乎无影响
编辑器工具:反射 + 缓存
自动查找所有状态类
编辑器工具需要知道项目中有哪些状态类。用反射扫描程序集:
1 | public static Dictionary<Type, List<Type>> GetStateHierarchy() |
扫描出来的结构是这样的:
1 | StateBase<T> |
缓存优化
反射扫描所有程序集很慢(约 50-200ms),所以用了 30 秒缓存:
1 | private static Dictionary<Type, List<Type>> _cachedHierarchy; |
性能提升:
- 无缓存:50-200ms
- 有缓存:0.1ms
- 性能提升 显著
线程安全:双重检查锁定
单例模式用了双重检查锁定:
1 | private static readonly object _lock = new object(); |
为什么要双重检查?
单次检查的话,两个线程可能同时通过第一次检查,导致创建两个实例。双重检查确保只创建一个实例,同时又保持高性能。
完整示例
看一个完整的使用场景:
1 | // 使用状态管理器 |
设计背后的思考
为什么用泛型而不是接口?
接口需要运行时类型检查,泛型可以在编译时检查类型。而且泛型基类可以提供默认实现,代码复用性更高。
为什么用单例而不是依赖注入?
Unity 生态中单例是主流,全局访问更方便。依赖注入需要额外的容器,学习曲线陡峭。
为什么用反射自动注册?
手动注册容易遗漏,每次新增状态都要修改代码。反射自动注册是”一次编写,永久使用”。
总结
这套状态机系统的核心价值:
- 异步切换 - 用回调机制优雅地处理异步状态转换
- 状态回退 - 用栈实现浏览器式的后退功能
- 零配置 - 用反射自动创建和注册状态
- 类型安全 - 用泛型在编译时检查类型
- 高效缓存 - 用 30 秒缓存将反射性能提升N倍
技术上,它结合了多个经典设计模式:状态模式、模板方法模式、工厂模式,通过泛型、反射、闭包等现代语言特性,将状态机这个经典模式提升到了新的高度。
实际项目中,这套系统大幅降低了状态管理的心智负担。新增一个状态只需要写一个类,不需要修改任何框架代码,符合开闭原则。调试时可以通过编辑器工具一目了然地看到所有状态结构,大大提升了开发效率。
状态机虽是老生常谈,但做对了细节,依然是游戏开发中不可或缺的基础设施。