轻量级模块仓库与事件系统设计

轻量级模块仓库与事件系统设计

设计目标

在 Unity 项目中提供一套轻量级的全局模块仓库和事件系统。模块的生命周期与游戏进程一致:启动后按需创建或显式注册,在游戏运行期间持续存活,并在游戏关闭时统一释放。

业务代码通过以下接口使用系统:

1
2
3
4
5
6
ModuleRepository.GetOrCreate<T>();
ModuleRepository.Register(myModule);
EventModule.Subscribe<T>(callback);
EventModule.Unsubscribe<T>(callback);
eventData.Fire();
eventData.FireDeferred(EventDispatchPhase.Update);

系统负责具体模块类型的注册、查询、帧更新和释放。

总体结构

系统由三个部分组成:

  • ModuleRepository 保存模块实例,组织 UpdateLateUpdate 和关闭流程。
  • EventModule 持有事件频道、订阅关系和延迟派发队列。
  • ModuleRepositoryDriver 连接 Unity 生命周期与模块仓库,并跨场景持续存在。

EventModule 本身也是一个模块,由 ModuleRepository 创建和释放。事件系统不单独维护全局生命周期。

模块契约

所有模块都实现 IModuleIModule 继承 IDisposable,用于统一释放模块持有的资源。

需要参与帧更新的模块额外实现以下接口:

  • IUpdatableModule.Update():在 Unity 的 Update 阶段执行。
  • ILateUpdatableModule.LateUpdate():在 Unity 的 LateUpdate 阶段执行。

模块可以实现其中一个接口,也可以同时实现两个接口。模块仓库只根据接口决定模块参与哪些更新阶段,不要求业务模块继承共同基类。

ModuleRepository

ModuleRepository 是静态的全局模块仓库。每种具体模块类型都对应一个 ModuleSlot<T>:泛型静态字段保存该类型当前的 Slot 和创建状态,实例字段则保存模块本身以及预先转换好的更新接口。

ModuleSlot<T> 同时实现非泛型的 IModuleSlot。因此,按类型查询时可以直接访问泛型静态字段,无须查询字典;统一更新和关闭时,又可以把不同 T 的 Slot 放进同一组有序列表中遍历。静态查询、更新接口缓存、释放和槽位复位都由同一个 Slot 完成。

注册与查询

  • GetOrCreate<T>() 返回已注册的模块;模块不存在时,通过 new T() 创建、注册并返回。
  • TryGet<T>(out T module) 只查询模块,不会隐式创建。
  • Register<T>(module) 注册已有实例。
  • 注册 null 时抛出 ArgumentNullException
  • 同一种具体模块类型只能注册一次,重复注册时抛出 InvalidOperationException
  • 模块创建期间再次请求相同类型,视为循环创建并抛出 InvalidOperationException

激活与更新

新注册且实现更新接口的模块先进入待激活集合,并在下一次 ModuleRepository.Update() 开始时加入更新队列。

该规则保证:

  • 模块第一次参与生命周期时一定先执行 Update
  • UpdateLateUpdate 中创建的模块从下一帧开始更新。
  • 新模块不会在首次 Update 之前收到 LateUpdate
  • UpdateLateUpdate 的执行顺序与模块注册顺序一致。

仓库不允许递归调用自身的 UpdateLateUpdate。检测到递归更新时抛出 InvalidOperationException

更新异常

每个模块的更新调用都使用独立的 try/catch

  • 异常通过 Debug.LogException 记录。
  • 发生异常的模块从 UpdateLateUpdate 和待激活集合中移除,不再参与后续帧更新。
  • 模块实例仍保持注册,并在仓库关闭时执行 Dispose()
  • 其他模块继续按照原有顺序更新。

该策略避免持续故障的模块每帧产生重复异常日志,同时保留统一释放资源的能力。

关闭

Shutdown() 是游戏生命周期中的终止操作:

  • 模块按注册顺序的逆序执行 Dispose()
  • 单个模块释放失败时记录异常,其余模块继续释放。
  • 无论释放过程是否发生异常,模块集合、更新集合和泛型槽位都会清空。
  • 重复调用 Shutdown() 不产生额外效果。
  • 关闭开始后,GetOrCreateRegisterUpdateLateUpdate 拒绝访问。
  • TryGet 在槽位清理后返回 false

如果模块在自身的 UpdateLateUpdate 中调用 Shutdown(),仓库先记录关闭请求,允许该模块的方法正常返回,然后停止执行后续模块,并从更新流程的 finally 路径完成关闭。

播放会话重置

RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration) 在新的 Unity 运行会话开始时重置仓库静态状态,保证关闭 Domain Reload 的编辑器配置也能获得干净状态。

该重置入口不作为业务 API 暴露,也不代表运行时支持重新启动仓库。

事件类型与公开接口

事件数据实现空接口 IGameEvent。事件回调使用强类型委托:

1
public delegate void EventCallback<in T>(T eventData) where T : IGameEvent;

EventModule 提供统一的静态业务接口:

  • Subscribe<T>(callback):订阅事件。
  • Unsubscribe<T>(callback):取消订阅。
  • Fire<T>(eventData):立即派发事件。
  • FireDeferred<T>(eventData, phase):将事件加入指定阶段的延迟派发队列。

EventExtensions 为事件数据提供 Fire()FireDeferred() 扩展方法。扩展方法只负责转发,不持有状态。

EventModule 的实例、UpdateLateUpdateDispose 不属于业务接口。生命周期方法通过显式接口实现交给 ModuleRepository 调用。

EventBridge 与 EventChannel

每种事件类型对应一个 EventChannel<T>,负责保存该类型的订阅关系。频道外部再包裹一层 EventBridge<T>,用于连接泛型静态快速访问与 EventModule 的实例生命周期。

EventBridge<T>.Current 是每种事件类型独立的静态入口。即时 Fire<T>Unsubscribe<T> 可以直接取得桥接和频道,不需要通过 typeof(T) 查询字典,也不需要把非泛型频道再强制转换回 EventChannel<T>

另一方面,所有已创建的桥接都会加入 EventModule._eventBridgesEventModule.Dispose() 遍历该列表,清空频道中的订阅关系,并将对应的静态 Current 复位。因此,静态入口只负责快速访问,频道的实际生命周期仍然归属于 EventModule

桥接创建遵循“先登记、后发布”的顺序:先把新桥接加入 EventModule 的实例列表,成功后再赋值给静态 Current。这样即使创建过程失败,也不会留下无法被生命周期系统清理的静态频道。

相比字典方案,静态桥接省去了高频派发路径中的哈希查询和类型转换,但每种事件类型会额外创建一个 EventBridge<T> 对象,并由实例列表保存。这是用少量常驻对象换取更直接的热路径访问。

订阅规则如下:

  • 回调为 null 时抛出 ArgumentNullException
  • 重复订阅同一个回调时抛出 InvalidOperationException
  • 取消一个未订阅的回调不产生效果。
  • 回调按照订阅顺序执行。
  • 每个回调在独立的 try/catch 中执行;一个回调抛出异常不会阻止后续回调。
  • 没有订阅者时,Fire 不产生效果,也不会创建 EventModuleEventChannel<T>

仓库关闭后,FireFireDeferredUnsubscribe 不产生效果,避免 Unity 清理阶段重新创建事件系统;Subscribe 则拒绝访问。

派发期间的订阅变更

EventChannel<T> 持有以下状态:

  • 可复用的有效订阅者列表;
  • 可复用的待处理变更列表;
  • 记录同步嵌套派发层级的深度计数器。

没有事件正在派发时,订阅变更直接作用于有效列表。派发期间产生的订阅、取消订阅和清空操作按调用顺序写入待处理列表,并在最外层 Firefinally 中统一应用。

因此,在一次同步派发调用链中被取消的回调仍会收到当前事件和该调用链内嵌套触发的事件,但不会收到之后的顶层事件。重复订阅检查同时考虑有效列表和待处理变更,所以错误会在调用 Subscribe 时立即抛出。

派发过程按索引遍历稳定的有效列表,不为每次派发或订阅变更创建数组快照。列表会保留容量,只有扩容时才产生新的内部数组分配。

为什么不用C#的event

例如下面,直接用event来做,还能保持语义与C#事件一致

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
internal sealed class EventChannel<T> where T : IGameEvent
{
private event EventCallback<T> _callbacks;

public void Subscribe(EventCallback<T> callback)
{
_callbacks += callback;
}

public void Unsubscribe(EventCallback<T> callback)
{
_callbacks -= callback;
}

public void Fire(T eventData)
{
_callbacks?.Invoke(eventData);
}
}

之所以使用 List 是因为我们的事件系统包含一些额外的特性:

  1. 每次订阅变化,内部可能会产生新的订阅列表,潜在的GC问题;而我们的只有扩容时分配,预热后无分配
  2. 当前模式提供 拒绝重复订阅 的功能
  3. 当前模式下,单个回调异常不会阻断后续的回调
  4. 嵌套(递归)派发语义更加简单:任何在回调里的针对该事件的订阅变化都不会立即生效,需要最外层回调结束才会作用

延迟事件派发

EventDispatchPhase 定义两个派发阶段:

1
2
3
4
5
public enum EventDispatchPhase
{
Update,
LateUpdate,
}

EventModule 分别持有 UpdateLateUpdate 两个先进先出(First In, First Out,FIFO)队列。队列元素为 IDeferredEventDispatch,具体实现 DeferredEventDispatch<T> 在入队时通过 EventBridge<T> 取得频道,同时保存 EventChannel<T> 和事件数据,并通过 Dispatch() 保留强类型派发能力。

每个阶段开始派发时先记录队列长度,只处理当时已经存在的元素:

  • 不同事件类型之间保持全局入队顺序。
  • 两个阶段的队列互不消费对方的数据。
  • 向正在派发的同一阶段再次入队时,新事件等待下一帧的对应阶段。
  • Update 中向 LateUpdate 入队时,新事件可以在同一帧的 LateUpdate 执行。
  • LateUpdate 中向 Update 入队时,新事件等待下一帧的 Update

每次 FireDeferred 会创建一个小型的延迟派发对象。只有性能分析确认这部分分配形成实际压力时,才引入对象池。

ModuleRepositoryDriver

ModuleRepositoryDriver 是连接 Unity 生命周期与模块仓库的唯一驱动器。

  • 第一个实例在 Awake 中成为主实例,并调用 DontDestroyOnLoad 跨场景保留。
  • 后续重复实例只销毁自身的 GameObject,不影响主实例和模块仓库。
  • 主实例在 Unity 的 UpdateLateUpdate 中驱动仓库的对应方法。
  • OnApplicationQuit 和主实例的 OnDestroy 都会请求关闭仓库。
  • 驱动器自身使用状态字段保证只发起一次关闭;ModuleRepository.Shutdown() 同时提供幂等保护。

ResetPrimaryInstance() 使用 RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration) 注册,在 Unity Player 启动和编辑器播放会话开始时清空静态主实例引用。它不会在切换场景、应用暂停或从后台恢复时执行。

场景负责放置初始的 ModuleRepositoryDriver。系统不自动创建驱动器。

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Assets/Scripts/BaseFramework/
├── AssemblyInfo.cs
├── BaseFramework.asmdef
├── Module/
│ ├── IModule.cs
│ └── ModuleRepository.cs
├── System/
│ ├── IUpdatableModule.cs
│ └── ILateUpdatableModule.cs
└── EventBus/
├── IGameEvent.cs
├── EventCallback.cs
├── EventChannel.cs
├── DeferredEventDispatch.cs
├── EventModule.cs
└── EventExtensions.cs

Assets/Scripts/GameLogic/Logic/ModuleDriver/
└── ModuleRepositoryDriver.cs

BaseFramework.asmdef 用于隔离框架运行时代码,游戏逻辑程序集只需要引用该程序集即可使用模块仓库和事件系统。

实现顺序

从零实现时按照以下顺序组织工作:

  1. 创建运行时程序集定义以及 IModuleIUpdatableModuleILateUpdatableModule
  2. 实现同时承担静态查询与生命周期记录的 ModuleSlot<T>,再完成更新调度、异常隔离和终止性关闭。
  3. 实现 ModuleRepositoryDriver,连接 Unity 帧循环、跨场景生命周期和应用退出流程。
  4. 创建 IGameEventEventCallback<T> 和保存订阅关系的 EventChannel<T>
  5. 实现 EventBridge<T>,用泛型静态入口加速频道访问,并用实例列表维持统一清理能力。
  6. 实现 DeferredEventDispatch<T>、双阶段 FIFO 队列、EventModule 静态门面和事件扩展方法。

验证要点

实现完成后至少需要核对以下行为:

  • 自动创建、显式注册、null 注册和重复注册;
  • 待激活机制以及稳定的 UpdateLateUpdate 执行顺序;
  • 更新异常隔离、故障模块停止更新以及关闭时释放;
  • 更新期间请求关闭、逆序释放、释放异常隔离、关闭幂等性和终止后的访问拒绝;
  • 没有订阅者时安全派发,以及拒绝重复订阅;
  • 同步嵌套派发期间的稳定订阅视图和有序订阅变更;
  • 回调异常不阻断后续订阅者;
  • 不同事件类型之间的全局 FIFO 顺序;
  • UpdateLateUpdate 队列隔离;
  • 同一阶段再次入队时推迟到下一帧;
  • UpdateLateUpdate 入队时在同一帧执行;
  • 关闭阶段的事件接口不会重新创建模块;
  • EventModule 释放时清空全部队列和订阅关系。

Unity 必须能够成功编译 BaseFrameworkAssembly-CSharp 两个程序集,并在实际播放流程中确认 Update、LateUpdate、延迟事件和关闭清理均按上述规则运行。