轻量级模块仓库与事件系统设计
轻量级模块仓库与事件系统设计
设计目标
在 Unity 项目中提供一套轻量级的全局模块仓库和事件系统。模块的生命周期与游戏进程一致:启动后按需创建或显式注册,在游戏运行期间持续存活,并在游戏关闭时统一释放。
业务代码通过以下接口使用系统:
1 | ModuleRepository.Get<T>(); |
系统负责具体模块类型的注册、查询、帧更新和释放。
总体结构
系统由三个部分组成:
ModuleRepository保存模块实例,组织Update、LateUpdate和关闭流程。EventModule持有事件频道、订阅关系和延迟派发队列。ModuleRepositoryDriver连接 Unity 生命周期与模块仓库,并跨场景持续存在。
EventModule 本身也是一个模块,由 ModuleRepository 创建和释放。事件系统不单独维护全局生命周期。
模块契约
所有模块都实现 IModule。IModule 继承 IDisposable,用于统一释放模块持有的资源。
需要参与帧更新的模块额外实现以下接口:
IUpdatableModule.Update():在 Unity 的Update阶段执行。ILateUpdatableModule.LateUpdate():在 Unity 的LateUpdate阶段执行。
模块可以实现其中一个接口,也可以同时实现两个接口。模块仓库只根据接口决定模块参与哪些更新阶段,不要求业务模块继承共同基类。
ModuleRepository
ModuleRepository 是静态的全局模块仓库。每种具体模块类型在 ModuleSlot<T> 中拥有独立的实例槽位和创建状态,因此常规查询无须遍历集合。仓库同时保存有序的 ModuleRegistration<T> 记录,用于安排更新顺序和关闭顺序。
注册与查询
Get<T>()返回已注册的模块;模块不存在时,通过new T()创建、注册并返回。TryGet<T>(out T module)只查询模块,不会隐式创建。Register<T>(module)注册已有实例。- 注册
null时抛出ArgumentNullException。 - 同一种具体模块类型只能注册一次,重复注册时抛出
InvalidOperationException。 - 模块创建期间再次请求相同类型,视为循环创建并抛出
InvalidOperationException。
激活与更新
新注册且实现更新接口的模块先进入待激活集合,并在下一次 ModuleRepository.Update() 开始时加入更新队列。
该规则保证:
- 模块第一次参与生命周期时一定先执行
Update。 - 在
Update或LateUpdate中创建的模块从下一帧开始更新。 - 新模块不会在首次
Update之前收到LateUpdate。 Update和LateUpdate的执行顺序与模块注册顺序一致。
仓库不允许递归调用自身的 Update 或 LateUpdate。检测到递归更新时抛出 InvalidOperationException。
更新异常
每个模块的更新调用都使用独立的 try/catch:
- 异常通过
Debug.LogException记录。 - 发生异常的模块从
Update、LateUpdate和待激活集合中移除,不再参与后续帧更新。 - 模块实例仍保持注册,并在仓库关闭时执行
Dispose()。 - 其他模块继续按照原有顺序更新。
该策略避免持续故障的模块每帧产生重复异常日志,同时保留统一释放资源的能力。
关闭
Shutdown() 是游戏生命周期中的终止操作:
- 模块按注册顺序的逆序执行
Dispose()。 - 单个模块释放失败时记录异常,其余模块继续释放。
- 无论释放过程是否发生异常,模块集合、更新集合和泛型槽位都会清空。
- 重复调用
Shutdown()不产生额外效果。 - 关闭开始后,
Get、Register、Update和LateUpdate拒绝访问。 TryGet在槽位清理后返回false。
如果模块在自身的 Update 或 LateUpdate 中调用 Shutdown(),仓库先记录关闭请求,允许该模块的方法正常返回,然后停止执行后续模块,并从更新流程的 finally 路径完成关闭。
播放会话重置
RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration) 在新的 Unity 运行会话开始时重置仓库静态状态,保证关闭 Domain Reload 的编辑器配置也能获得干净状态。
该重置入口不作为业务 API 暴露。编辑模式测试通过 InternalsVisibleTo("BaseFramework.Tests") 调用内部的 ResetForTests(),使每个测试拥有相互隔离的仓库状态。
事件类型与公开接口
事件数据实现空接口 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 的实例、Update、LateUpdate 和 Dispose 不属于业务接口。生命周期方法通过显式接口实现交给 ModuleRepository 调用。
EventChannel
每种事件类型对应一个 EventChannel<T>。EventModule 使用事件类型作为键保存所有频道,因此订阅关系跟随 EventModule 实例,在模块释放时统一清空。
订阅规则如下:
- 回调为
null时抛出ArgumentNullException。 - 重复订阅同一个回调时抛出
InvalidOperationException。 - 取消一个未订阅的回调不产生效果。
- 回调按照订阅顺序执行。
- 每个回调在独立的
try/catch中执行;一个回调抛出异常不会阻止后续回调。 - 没有订阅者时,
Fire不产生效果,也不会创建EventModule或EventChannel<T>。
仓库关闭后,Fire、FireDeferred 和 Unsubscribe 不产生效果,避免 Unity 清理阶段重新创建事件系统;Subscribe 则拒绝访问。
派发期间的订阅变更
EventChannel<T> 持有以下状态:
- 可复用的有效订阅者列表;
- 可复用的待处理变更列表;
- 记录同步嵌套派发层级的深度计数器。
没有事件正在派发时,订阅变更直接作用于有效列表。派发期间产生的订阅、取消订阅和清空操作按调用顺序写入待处理列表,并在最外层 Fire 的 finally 中统一应用。
因此,在一次同步派发调用链中被取消的回调仍会收到当前事件和该调用链内嵌套触发的事件,但不会收到之后的顶层事件。重复订阅检查同时考虑有效列表和待处理变更,所以错误会在调用 Subscribe 时立即抛出。
派发过程按索引遍历稳定的有效列表,不为每次派发或订阅变更创建数组快照。列表会保留容量,只有扩容时才产生新的内部数组分配。
为什么不用C#的event
例如下面,直接用event来做,还能保持语义与C#事件一致
1 | internal sealed class EventChannel<T> where T : IGameEvent |
之所以使用 List 是因为我们的事件系统包含一些额外的特性:
- 每次订阅变化,内部可能会产生新的订阅列表,潜在的GC问题;而我们的只有扩容时分配,预热后无分配
- 当前模式提供
拒绝重复订阅的功能 - 当前模式下,单个回调异常不会阻断后续的回调
- 嵌套(递归)派发语义更加简单:任何在回调里的针对该事件的订阅变化都不会立即生效,需要最外层回调结束才会作用
延迟事件派发
EventDispatchPhase 定义两个派发阶段:
1 | public enum EventDispatchPhase |
EventModule 分别持有 Update 和 LateUpdate 两个先进先出(First In, First Out,FIFO)队列。队列元素为 IDeferredEventDispatch,具体实现 DeferredEventDispatch<T> 同时保存 EventChannel<T> 和事件数据,并通过 Dispatch() 保留强类型派发能力。
每个阶段开始派发时先记录队列长度,只处理当时已经存在的元素:
- 不同事件类型之间保持全局入队顺序。
- 两个阶段的队列互不消费对方的数据。
- 向正在派发的同一阶段再次入队时,新事件等待下一帧的对应阶段。
- 在
Update中向LateUpdate入队时,新事件可以在同一帧的LateUpdate执行。 - 在
LateUpdate中向Update入队时,新事件等待下一帧的Update。
每次 FireDeferred 会创建一个小型的延迟派发对象。只有性能分析确认这部分分配形成实际压力时,才引入对象池。
ModuleRepositoryDriver
ModuleRepositoryDriver 是连接 Unity 生命周期与模块仓库的唯一驱动器。
- 第一个实例在
Awake中成为主实例,并调用DontDestroyOnLoad跨场景保留。 - 后续重复实例只销毁自身的
GameObject,不影响主实例和模块仓库。 - 主实例在 Unity 的
Update和LateUpdate中驱动仓库的对应方法。 OnApplicationQuit和主实例的OnDestroy都会请求关闭仓库。- 驱动器自身使用状态字段保证只发起一次关闭;
ModuleRepository.Shutdown()同时提供幂等保护。
ResetPrimaryInstance() 使用 RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration) 注册,在 Unity Player 启动和编辑器播放会话开始时清空静态主实例引用。它不会在切换场景、应用暂停或从后台恢复时执行。
场景负责放置初始的 ModuleRepositoryDriver。系统不自动创建驱动器。
项目结构
1 | Assets/Scripts/BaseFramework/ |
BaseFramework.asmdef 隔离框架运行时代码。BaseFramework.Tests.asmdef 仅在编辑模式测试环境引用框架程序集和 Unity Test Framework,不进入 Player 构建。
实现顺序
从零实现时按照以下顺序组织工作:
- 创建运行时程序集定义以及
IModule、IUpdatableModule、ILateUpdatableModule。 - 实现
ModuleRepository的泛型槽位、注册记录、更新调度、异常隔离和终止性关闭。 - 实现
ModuleRepositoryDriver,连接 Unity 帧循环、跨场景生命周期和应用退出流程。 - 创建
IGameEvent、EventCallback<T>和实例持有的EventChannel<T>。 - 实现
DeferredEventDispatch<T>、双阶段 FIFO 队列、EventModule静态门面和事件扩展方法。 - 创建独立的编辑模式测试程序集,并覆盖模块生命周期与事件派发语义。
验证要求
自动化测试覆盖以下行为:
- 自动创建、显式注册、
null注册和重复注册; - 待激活机制以及稳定的
Update、LateUpdate执行顺序; - 更新异常隔离、故障模块停止更新以及关闭时释放;
- 更新期间请求关闭、逆序释放、释放异常隔离、关闭幂等性和终止后的访问拒绝;
- 没有订阅者时安全派发,以及拒绝重复订阅;
- 同步嵌套派发期间的稳定订阅视图和有序订阅变更;
- 回调异常不阻断后续订阅者;
- 不同事件类型之间的全局 FIFO 顺序;
Update与LateUpdate队列隔离;- 同一阶段再次入队时推迟到下一帧;
- 从
Update向LateUpdate入队时在同一帧执行; - 关闭阶段的事件接口不会重新创建模块;
EventModule释放时清空全部队列和订阅关系。
Unity 必须能够成功编译 BaseFramework、Assembly-CSharp 和 BaseFramework.Tests 三个程序集。编辑模式测试全部通过后,系统才满足交付条件。