一种无限异步状态机的工程实践

前言

做游戏开发时,我们经常遇到这样的场景:游戏从主菜单进入游戏,然后暂停,再返回游戏,最后回到主菜单。这背后就是状态机在工作。

传统写法用 if-else 或者 switch 来管理,代码量一多就乱成一团。本文要讲的状态机系统,解决了三个核心问题:

  1. 状态切换异步化 - 支持保存数据、加载资源等耗时操作
  2. 状态可回退 - 像浏览器后退按钮一样返回上一个状态
  3. 零配置使用 - 新增状态不需要注册,直接就能用

核心设计:泛型 + 多态

状态基类的设计

1
2
3
4
5
6
7
8
9
10
11
public abstract class StateBase<T> where T : class
{
public string TypeName { get; set; }
public bool AllowReentry { get; protected set; }
public bool HaveExitTask { get; protected set; }
public Action ExitFinishCall { get; set; }

public virtual void EnterState(int enterCount = 1, object parameters = null) { }
public virtual void UpdateState() { }
public virtual void ExitState() { }
}

这个设计有几个关键点:

1. 泛型参数 T 的作用

T 看起来多余,其实是为了类型隔离。你可以同时存在多个独立的状态机系统:

1
2
3
StateBase<GameStateBase>    // 游戏状态机
StateBase<CharacterState> // 角色状态机
StateBase<UIState> // UI状态机

三个系统互不干扰,各自管理自己的状态。

2. HaveExitTask 机制 - 核心创新

这是这个系统最巧妙的设计。先看问题:传统状态切换是这样的:

1
2
ExitState()  // 退出当前状态
EnterState() // 进入新状态

但实际开发中,ExitState 可能需要保存数据、上传服务器,这些操作是异步的。如果直接写成同步代码,要么阻塞主线程,要么状态切换不完整。

解决方案:

1
2
public bool HaveExitTask { get; protected set; }
public Action ExitFinishCall { get; set; }

状态管理器这样处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if (currentState.HaveExitTask)
{
// 设置回调函数
currentState.ExitFinishCall = () => {
currentState = nextState;
nextState.EnterState();
};

// 启动异步任务
currentState.ExitState();
}
else
{
// 同步切换
currentState.ExitState();
currentState = nextState;
nextState.EnterState();
}

使用时这样写:

1
2
3
4
5
6
7
8
9
10
11
12
public class GamePauseState : GameStateBase
{
protected override bool DefaultHaveExitTask => true;

public override void ExitState()
{
// 启动异步保存
SaveGameAsync(() => {
ExitFinishCall?.Invoke(); // 保存完通知切换
});
}
}

3. enterCount 参数 - 区分首次进入和重入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public override void EnterState(int enterCount, object parameters)
{
if (enterCount == 1)
{
// 首次进入,需要初始化
LoadGameWorld();
}
else
{
// 重入(重新开始),先清理再初始化
ClearGameWorld();
LoadGameWorld();
}
}

这个参数特别适合”重新开始游戏”的场景。


状态管理器:双泛型的妙用

CRTP 设计模式

1
2
3
public class StateManagerBase<T, K> : MonoBehaviour 
where T : StateBase<T>
where K : StateManagerBase<T, K>

为什么要两个泛型参数?

T 是状态类型,确保注册的状态类型正确。

K 是管理器类型,用了 CRTP(奇异递归模板模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static K Instance
{
get
{
// 当 K = GameStateManager 时,这行代码变成:
// instance = FindObjectOfType<GameStateManager>();
instance = FindObjectOfType<K>();

if (instance == null)
{
GameObject go = new GameObject(typeof(K).Name);
instance = go.AddComponent<K>(); // 自动添加组件
}

return instance; // 返回 GameStateManager 类型,不是基类
}
}

好处是:Instance 返回的是具体类型,不需要强制转换。

1
2
// 使用时
GameStateManager.Instance.SetState<PlayingState>(); // 类型精确

状态切换的核心流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public void SetState<E>(object parameters = null) where E : StateBase<T>
{
// 1. 防止重复切换
if (_isTransitioning) return;

// 2. 查找或创建状态
if (!_statesDict.TryGetValue(name, out var nextState))
{
nextState = CreateAndRegisterState<E>(); // 自动创建
}

// 3. 检查是否需要切换
if (_currentState?.TypeName != nextState?.TypeName || nextState.AllowReentry)
{
_isTransitioning = true;

// 4. 当前状态压栈(用于回退)
if (_currentState != null)
_stateStack.Push(_currentState);

// 5. 执行切换(同步或异步)
if (_currentState?.HaveExitTask == true)
{
_currentState.ExitFinishCall = () => {
_currentState = nextState;
nextState.EnterState();
_isTransitioning = false;
};
_currentState.ExitState(); // 异步任务在这里启动
}
else
{
_currentState?.ExitState();
_currentState = nextState;
nextState.EnterState();
_isTransitioning = false;
}
}
}

整个流程就像接力棒:当前状态完成退出任务后,通过回调通知管理器切换到新状态。


状态栈:像浏览器一样后退

1
2
3
4
5
6
7
private Stack<StateBase<T>> _stateStack = new Stack<StateBase<T>>();

public void PopState(object parameters = null)
{
var prevState = _stateStack.Pop();
SetStateInternal(prevState, parameters);
}

每个 SetState 都会把当前状态压栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
初始状态:Menu

SetState<Playing>()
→ 栈:[Menu],当前:Playing

SetState<Pause>()
→ 栈:[Menu, Playing],当前:Pause

SetState<Settings>()
→ 栈:[Menu, Playing, Pause],当前:Settings

PopState()
→ 栈:[Menu, Playing],当前:Pause

PopState()
→ 栈:[Menu],当前:Playing

PopState()
→ 栈:[],当前:Menu

这个机制特别适合多级菜单:主菜单 → 设置 → 音频 → 返回音频 → 返回设置 → 返回主菜单。


自动注册:反射的威力

传统方式需要手动注册每个状态:

1
2
3
4
5
// 繁琐的写法
gameStateManager.RegisterState(new MenuState());
gameStateManager.RegisterState(new PlayingState());
gameStateManager.RegisterState(new PauseState());
// ... 每个状态都要写

这个系统用反射实现自动注册:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private StateBase<T> CreateAndRegisterState<E>() where E : StateBase<T>
{
try
{
E stateInstance = (E)Activator.CreateInstance(typeof(E));
RegisterState(stateInstance);
return stateInstance;
}
catch (Exception ex)
{
Debug.LogError($"创建状态失败: {ex.Message}");
return null;
}
}

状态只在第一次使用时创建,之后永久缓存在字典里:

1
2
3
4
5
6
7
8
9
10
11
private Dictionary<string, StateBase<T>> _statesDict;

public void SetState<E>()
{
if (!_statesDict.TryGetValue(name, out var state))
{
state = CreateAndRegisterState<E>(); // 创建一次,永久复用
_statesDict[name] = state;
}
// 后续切换都直接用缓存的实例
}

性能对比:

  • 反射创建:约 1ms(只在第一次)
  • 字典查找:约 0.00001ms(O(1))
  • 游戏运行时性能几乎无影响

编辑器工具:反射 + 缓存

自动查找所有状态类

编辑器工具需要知道项目中有哪些状态类。用反射扫描程序集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static Dictionary<Type, List<Type>> GetStateHierarchy()
{
var allTypes = GetAllUserTypes(); // 获取所有用户程序集的类型

// 查找 StateBase<T> 的子类
var stateBaseType = typeof(StateBase<>);
var intermediateTypes = allTypes.Where(type =>
type.IsClass &&
!type.IsAbstract &&
type.BaseType?.IsGenericType == true &&
type.BaseType.GetGenericTypeDefinition() == stateBaseType
).ToList();

// 为每个中间类查找其子类
var hierarchy = new Dictionary<Type, List<Type>>();
foreach (var intermediateType in intermediateTypes)
{
var derivedTypes = allTypes.Where(type =>
type.IsSubclassOf(intermediateType)
).ToList();

hierarchy[intermediateType] = derivedTypes;
}

return hierarchy;
}

扫描出来的结构是这样的:

1
2
3
4
5
StateBase<T>
└─ GameStateBase (中间类)
├─ MenuState
├─ PlayingState
└─ PauseState

缓存优化

反射扫描所有程序集很慢(约 50-200ms),所以用了 30 秒缓存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static Dictionary<Type, List<Type>> _cachedHierarchy;
private static DateTime _lastCacheTime = DateTime.MinValue;
private static readonly TimeSpan CACHE_DURATION = TimeSpan.FromSeconds(30);

public static Dictionary<Type, List<Type>> GetStateHierarchy(bool useCache = true)
{
if (useCache && _cachedHierarchy != null &&
DateTime.Now - _lastCacheTime < CACHE_DURATION)
{
return _cachedHierarchy; // 命中缓存,超快
}

var hierarchy = ComputeHierarchy();
_cachedHierarchy = hierarchy;
_lastCacheTime = DateTime.Now;
return hierarchy;
}

性能提升:

  • 无缓存:50-200ms
  • 有缓存:0.1ms
  • 性能提升 显著

线程安全:双重检查锁定

单例模式用了双重检查锁定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private static readonly object _lock = new object();
private static K instance;

public static K Instance
{
get
{
// 第一次检查(无锁,快速路径)
if (instance == null)
{
lock (_lock)
{
// 第二次检查(有锁,但只在第一次执行)
if (instance == null)
{
instance = FindObjectOfType<K>();
if (instance == null)
{
GameObject go = new GameObject(typeof(K).Name);
instance = go.AddComponent<K>();
}
DontDestroyOnLoad(instance.gameObject);
instance.Init();
}
}
}
return instance;
}
}

为什么要双重检查?

单次检查的话,两个线程可能同时通过第一次检查,导致创建两个实例。双重检查确保只创建一个实例,同时又保持高性能。


完整示例

看一个完整的使用场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// 使用状态管理器
public class GameController : MonoBehaviour
{
void Start()
{
GameStateManager.Instance.SetState<MenuState>();
}
}

// 主菜单状态
public class MenuState : GameStateBase
{
public override void EnterState(int enterCount, object parameters)
{
Debug.Log("进入主菜单");
}
}

// 游戏运行状态(可重入)
public class PlayingState : GameStateBase
{
protected override bool DefaultAllowReentry => true;

public override void EnterState(int enterCount, object parameters)
{
if (enterCount == 1)
Debug.Log("首次进入游戏");
else
Debug.Log("重入游戏(重新开始)");
}

public override void UpdateState()
{
// 游戏逻辑更新
}
}

// 暂停状态(带异步保存)
public class PauseState : GameStateBase
{
protected override bool DefaultHaveExitTask => true;

public override void ExitState()
{
Debug.Log("保存游戏数据...");
CoroutineScheduler.Instance.DelaySecondsExecute(1f, () => {
Debug.Log("保存完成");
ExitFinishCall?.Invoke(); // 通知切换完成
});
}
}

// 按钮触发
void OnButtonClick()
{
GameStateManager.Instance.SetState<PlayingState>(); // 开始游戏
GameStateManager.Instance.SetState<PauseState>(); // 暂停
GameStateManager.Instance.PopState(); // 返回游戏
}

设计背后的思考

为什么用泛型而不是接口?

接口需要运行时类型检查,泛型可以在编译时检查类型。而且泛型基类可以提供默认实现,代码复用性更高。

为什么用单例而不是依赖注入?

Unity 生态中单例是主流,全局访问更方便。依赖注入需要额外的容器,学习曲线陡峭。

为什么用反射自动注册?

手动注册容易遗漏,每次新增状态都要修改代码。反射自动注册是”一次编写,永久使用”。


总结

这套状态机系统的核心价值:

  1. 异步切换 - 用回调机制优雅地处理异步状态转换
  2. 状态回退 - 用栈实现浏览器式的后退功能
  3. 零配置 - 用反射自动创建和注册状态
  4. 类型安全 - 用泛型在编译时检查类型
  5. 高效缓存 - 用 30 秒缓存将反射性能提升N倍

技术上,它结合了多个经典设计模式:状态模式、模板方法模式、工厂模式,通过泛型、反射、闭包等现代语言特性,将状态机这个经典模式提升到了新的高度。

实际项目中,这套系统大幅降低了状态管理的心智负担。新增一个状态只需要写一个类,不需要修改任何框架代码,符合开闭原则。调试时可以通过编辑器工具一目了然地看到所有状态结构,大大提升了开发效率。

状态机虽是老生常谈,但做对了细节,依然是游戏开发中不可或缺的基础设施。