架构解析

事件系统 · 架构解析

覆盖:Assets\Revolution\Runtime\RevEventSystem\(Core\ + Facade\ + Support\)

本篇只讲"为什么":为什么这么设计、为什么这么写代码、每个决策换来了什么、代价是什么、边界画在哪。
想学怎么用 → 看同目录的《使用说明》,那里是手把手,本篇不重复。
面向小白:用大白话讲"不这么做会怎样",不复述代码。

源设计起源 · 为什么要做这个模块

一句话 游戏里到处是"一个地方发生、很多地方要响应"(获得皮肤 → 商城刷新 + 英雄界面刷新 + 弹窗 + 埋点)。事件系统就是让"发的只管发,谁关心谁自己听"。

本篇只讲为什么;怎么用见同目录《使用说明》。

0三句话速览(完整用法见《使用说明》)

① 事件名      业务侧集中定义静态常量:public const string HeroSkinAdd = "Hero.Skin.Add";
② 参数        定义一个结构体:public readonly struct HeroSkinAddArgs { ... }
③ 注册/派发   RevEvent.AddEventListener<HeroSkinAddArgs>(GameEventId.HeroSkinAdd, OnSkinAdd, owner: this);
              RevEvent.DispatchEvent(GameEventId.HeroSkinAdd, new HeroSkinAddArgs(1001, 2001));

就这三步。剩下的都是这三步的细节。

1事件系统解决什么问题

游戏里到处都是"一个地方发生、多个地方响应":

玩家获得皮肤
    → 商城要刷新
    → 英雄界面要刷新
    → 弹窗要提示
    → 埋点要上报

直接调用的话,发事件的人得认识所有接收者 —— 加一个响应方就要改发事件的那段代码。事件系统把两边解开: 发的只管发"我获得皮肤了",谁关心谁自己监听。

唯一代价 发出去没人收,是没有编译期保护的。所以这套系统的设计重心之一就是让静默失败变得不可能 (见 2.3 与 4.7)。

2当前实现 vs 早期版本

2.1 总览

维度早期版本 EventCenter.cs新版 Runtime\RevEventSystem\
代码规模1906 行 / 单文件,89 KB1164 行 / 5 文件(其中约三成是注释)
public 方法/重载119 个26 个(业务真正要认的只有 3 族共 15 个)
参数个数上限EventInfo 类定义到 16 个参数;EventTrigger 同步重载 25 个(写到 12 个参数)+ EventTriggerAsync 22 个0 ~ 4(超过就定义参数结构体)
事件键枚举 E_EventType 和 string 两套字典并存只有 string 一套,业务自定义静态常量
类型不匹配as 转换失败 → ?.Invoke → 静默什么都不发生明确报出「监听者实际是 Action<Int32>,本次派发需要 Action<SkinArgs>」并跳过
派发中增删监听者无保护(直接 delegate += / RemoveAll)标记删除 + 派发结束统一清理,嵌套派发也正确
派发中新增监听者取决于当时集合实现,不可预期明确约定:本轮不执行,下一轮生效
异步派发async void EventTriggerAsync(异常被吞、顺序无保证)不做(需要就先 await,再派发)
异常隔离一个监听者抛异常 → 整条派发链断掉每个监听者独立 try-catch + 全局异常出口
优先级每次 AddAction 都全量 Sort 一遍只有真用了优先级才排一次,且同优先级严格保持注册顺序
内存 / GC每次注册都 new EventInfo + delegate +=(产生新委托链);监听者列表用默认容量 4监听者节点走对象池;列表容量预留 1;派发过程零复制
泄漏防护只能逐个反注册,漏一个就泄漏RemoveAllByOwner(this) 一把摘干净
出错可观测性以静默失败为主日志出口 + 异常钩子 + "无人监听"告警开关 + 诊断 API

下面把几条关键差别展开讲。

2.2 参数个数:从 16 收到 4 —— 少即是多

早期版本为"同一件事、不同参数个数"写了 18 个 EventInfo 类:

public class EventInfo<T> : EventInfoBase            { public Action<T> actions; ... }
public class EventInfo<T1, T2> : EventInfoBase       { public Action<T1, T2> actions; ... }
public class EventInfo<T1, T2, T3> : EventInfoBase   { ... }
// …一直写到 T16
这 18 个类其实什么都没多提供 —— 委托的参数类型本来就是"写在委托类型上"的,容器只需要存一个"委托"。 新版只用一个节点类型:
// Runtime\RevEventSystem\Core\RevEventListener.cs
public Delegate Handler;          // 实际类型是 Action / Action<T1> / … / Action<T1,T2,T3,T4> 之一

一个 Delegate 字段替代 18 个类。派发时按当前事件需要的签名做一次类型检查:

if (!(node.Handler is Action<T1> typed)) { /* 报签名不匹配 */ continue; }

参数上限定在 4:超过 2 个参数时,裸参数列表已经开始"看不懂谁是谁",正确做法是定义参数结构体(见 4.2)。 这不是能力缩水,是把人往更好的写法上推。

2.3 最坑的一处:类型不匹配会静默丢弃

早期版本的派发核心是这两句:

// 早期版本 EventCenter.cs
if (eventDic.ContainsKey(eventName))
{
    (eventDic[eventName] as EventInfo<T>).actions?.Invoke(info);
}
静默丢弃 当"注册时的参数类型"和"派发时的参数类型"不一致时,as 会算出 null,?.Invoke 于是什么都不做 —— 不报错、不日志、不崩,事件就这么消失了。
这类 bug 的表现是"我这个功能偶尔不刷新",而排查方向会被引到错误的地方(怀疑生命周期、怀疑时序), 因为没有任何线索指向事件系统。

新版把这件事变成必然可见:

// Runtime\RevEventSystem\Core\RevEventCenter.cs(1 参数派发的循环体)
if (!(node.Handler is Action<T1> typed))
{
    RevEvent.ReportSignatureMismatch(name, node.Handler, typeof(Action<T1>));
    continue;                       // 跳过它,但不影响其余监听者
}

报错长这样(两侧类型都点明):

[RevEvent] 事件 "Hero.Skin.Add" 上有签名不匹配的监听者,已跳过:
监听者实际是 Action<Int32>,本次派发需要 Action<HeroSkinAddArgs>。
同一个事件名的所有监听者必须使用同一种签名(★ 推荐统一用事件参数结构体)。

2.4 一套键,不是两套

早期版本同时维护了两张表:

private Dictionary<E_EventType, EventInfoBase> eventDic    = new ...;   // 枚举键
private Dictionary<string, EventInfoBase> stringEventDic   = new ...;   // 字符串键

结果是每个功能都要写两遍(EventTrigger 同步版就有 25 个重载、async 版 22 个, 同一件事在枚举键和字符串键下各写一遍),两套之间还可能漏改。

新版只留 string:

字典用 StringComparer.Ordinal,不受系统区域设置影响(否则土耳其语环境下 I/i 会互相匹配,属于经典冷坑)。

性能顾虑? 字符串字典查找是 O(1) 哈希,事件名是编译期常量;派发路径上没有任何字符串拼接。 和 ulong 版本的差距远小于"可读、可自定义"带来的收益。

2.5 派发中增删监听者:早期版本没有保护

这是事件系统最经典的事故现场:

正在按索引遍历监听者列表派发
    → 某个回调里反注册了自己(或别人)
    → 列表被缩短,下标整体错位
    → 漏调用、重复调用,甚至越界崩溃

早期版本没有任何保护:RemoveAll 直接改 List,delegate +=/-= 直接换委托链。

新版用那套参考实现"标记删除 + 派发后统一清理":

// 删除时:正在派发 → 只打标记,不摘列表
if (_dispatchDepth > 0)
{
    _listeners[index].Abandon();       // Handler = null; IsAbandoned = true;
    _changedInDispatch = true;
    return;
}

配套三条硬约定(都有断言验证,见第七章):

场景行为
回调里反注册自己本次仍被调用 1 次(不重复),下一轮不再调用
回调里摘掉"还没轮到"的监听者它本轮不会被执行
回调里新增监听者本轮不执行(派发范围在开始时已锁定),下一轮才生效

好处不止"不崩":整个派发过程零复制、零分配 —— 不用为了安全先拷一份列表(早期版本那种做法)。

2.6 不做 async 派发

早期版本有 22 个 EventTriggerAsync 重载,签名是 public async void EventTriggerAsync<T>(...)。async void 有两个硬伤:

  1. 异常没法被捕获 —— 监听者里抛的异常不会回到调用方,直接进全局未处理异常;
  2. 顺序无保证 —— 你不知道谁先谁后,而事件系统的大量用法("先刷数据再刷界面")恰恰依赖顺序。

新版不提供异步派发。正确姿势是:需要异步就先把异步做完,再派发。

// 该这样:await 完再派发
await LoadSkinAsync(skinId);
RevEvent.DispatchEvent(GameEventId.HeroSkinAdd, new HeroSkinAddArgs(heroId, skinId));

2.7 内存与 GC

早期版本每次注册都会 new EventInfo<T>(action) 并 actions += action(产生新的委托链对象), 监听者列表用的是 List 默认容量 4,而且优先级每次 Add 都全量 Sort 一遍。

新版三处针对性优化(第 3.2、3.3 节详细说):节点对象池、列表容量预留 1、优先级脏标记。 工程外实测:结构体载荷连续派发 10000 次、AddEventListener/RemoveEventListener 10000 轮, GC 分配均为 0 字节。

2.8 泄漏防护:销毁时一把摘干净

早期版本只能一个个 RemoveXxx。UI 界面注册了 5 个事件、销毁时漏掉一个,那一个就会让事件系统 一直攥着已销毁的界面对象 —— 对象回收不了,而且下次派发还会执行到它(访问已销毁的 GameObject,直接报错)。

新版提供按归属对象批量移除:

private void OnDestroy() => RevEvent.RemoveAllByOwner(this);

而且不传 owner 也能兜底:注册时若没显式指定 owner,会自动取 Handler.Target(委托的目标对象), 所以注册实例方法的场景天然支持批量移除。

3运用了参考实现的哪些设计哲学

对照《01-事件系统.md》(参考实现 CEventDispatcher 分析)里的五个亮点,逐条说落地情况:

参考实现的设计参考实现里的实现新版落地
① 派发中修改保护CEventHandler.m_isAbandon + m_isInDispatchingCounter + m_isHandlerListChangedInDispatchingRevEventListener.IsAbandoned + RevEventHandlerGroup 的 _dispatchDepth / _changedInDispatch;并额外覆盖嵌套派发
② 内存优化:容量预留 1new ListView<CEventHandler>(1),"默认 4 会浪费约 75%"new List<RevEventListener>(1),注释里写明来由
③ handler 对象池Queue<CEventHandler> m_eventHandlerUnuseRevEventListener 静态池(上限 512),Rent() / Return()
④ 异常隔离每个监听者单独 try-catch + s_onDispatchEventException 全局回调每个监听者独立 try-catch + RevEvent.OnException / Log 出口(另有 RethrowOnException 调试开关)
⑤ 按观察者批量移除RemoveEventHandlersByObserver(object)RevEvent.RemoveAllByOwner(object)(并支持委托 Target 自动推断)

3.1 派发中修改保护(最重要的一条)

参考实现的解法是"标记删除 + 派发后统一清理"。新版实现里有两个细节值得单独说:

① 派发深度计数(支持嵌套派发)

internal void EndDispatch()
{
    if (_dispatchDepth > 0) _dispatchDepth--;

    if (_dispatchDepth > 0) return;          // 嵌套派发没结束,先别动列表
    if (!_changedInDispatch) return;          // 期间没增删 → 无事可做(绝大多数情况走这里)
    _changedInDispatch = false;
    CleanupAbandoned();
}

"A 事件的回调里又派发 A"是真实存在的用法(比如"获得皮肤"里嵌套触发"英雄属性变化")。 深度计数保证清理只在最外层结束时做一次 —— 否则内层一结束就把还在遍历的列表改了。

② 派发范围在开始时锁定

List<RevEventListener> list = group.Listeners;
int count = group.DispatchLimit;         // ★ 本次派发的范围在开始时锁定(= 最外层派发开始时的监听者数)
for (int i = 0; i < count; i++) { ... }

循环用 count 而不是每次重新读 list.Count。这一行的差别是: 派发中新增的监听者不会在本轮被"越派越多"地执行,行为完全可预期。

3.2 内存优化:容量预留 1

参考实现的注释是这么说的:业务场景下绝大部分事件只有 1 个监听者,若让 List 按默认容量 4 分配, 每个事件浪费 3 个槽位;参考实现有几千个事件,合计约 75% 的浪费。

新版照抄这个判断:

// ★ 容量预留 1:绝大多数事件只有 1 个监听者
private readonly List<RevEventListener> _listeners = new List<RevEventListener>(1);

这是"了解业务特征之后做针对性优化"的典型 —— 不是拍脑袋调参数,而是先看业务长什么样。

3.3 对象池

节点在 Add / Remove 时频繁创建销毁,UI 反复开关(OnEnable / OnDisable)时尤其密集。新版让节点走池:

internal static RevEventListener Rent()  => Pool.Count > 0 ? Pool.Dequeue() : new RevEventListener();
internal static void Return(RevEventListener node) { node.Reset(); if (Pool.Count < PoolCapacity) Pool.Enqueue(node); }
关键细节:Return 前必须 Reset() 把 Handler / Owner 清空 —— 否则池会替已销毁的对象"续命",池自己就变成了泄漏源。这一点在 Reset() 的注释里写死了。

3.4 异常隔离

一个模块的 bug 不该拖垮整个事件系统,但也不能一声不响。新版的策略是"隔离 + 必定有出口":

try { typed(arg1); invoked++; }
catch (Exception e)
{
    RevEvent.ReportHandlerException(name, e, node);     // 上报(不影响其他监听者)
    if (RevEvent.RethrowOnException) throw;             // throw; 保留原始调用栈
}
容易忽略的点:RethrowOnException 抛出异常时,整个派发是通过 finally { group.EndDispatch(); } 退出的, 所以派发深度一定会退回去,不会出现"这个事件永久卡在派发中"的二次事故(有断言专门验这一条)。

3.5 按观察者批量移除

见 2.8。这条是防泄漏的"兜底网",也是参考实现被评为 ★★★★★ 的设计。

3.6 我故意和参考实现不一样的地方

差异参考实现做法新版做法为什么
事件键ulong 事件 ID(自动生成的 EventID.cs)string + 业务自定义静态常量参考实现有几千个事件、要求极致性能,且有大团队维护生成器;小项目里"业务自己能加事件、日志可读"更重要。差距是常量级的哈希查找,不是数量级
类型不匹配as 转换,失败即静默大声报出两侧类型那套参考实现有生成器保证 ID 和签名一致;这里没有生成器兜底,就必须让错误自己冒出来
局部事件路由CLocalEventRouter(子节点向上冒泡、任一层处理即停)本期未纳入它是独立子系统(适合 UI 层级事件),不影响全局事件;需要时可以按同样风格补一个
异步派发无无(早期实现有,被砍掉)async void 的异常与顺序问题大于它的便利
优先级无有(可选,默认 0)早期实现有、业务习惯有这个能力(如"先刷数据再埋点"),保留但做成"只有真用到才付排序代价"

4新版事件系统怎么用

4.1 定义事件名:静态常量类(别手写字符串)

// 业务侧:把事件名集中在一处(建议放在你自己的 Config 或 Const 目录下)
public static class GameEventId
{
    public const string HeroSkinAdd   = "Hero.Skin.Add";
    public const string BagItemChange = "Bag.Item.Change";
    public const string SceneLoaded   = "Scene.Loaded";
}

4.2 定义事件参数:结构体

// 业务侧:事件参数结构体(只放数据,不放对象引用)
public readonly struct HeroSkinAddArgs
{
    public readonly uint HeroId;
    public readonly uint SkinId;
    public readonly uint From;        // 来源:商城 / 活动 / 赠送

    public HeroSkinAddArgs(uint heroId, uint skinId, uint from)
    {
        HeroId = heroId; SkinId = skinId; From = from;
    }
}

为什么推荐结构体:

好处说明
零装箱泛型委托 Action<T> 的 T 是结构体时不会装箱(实测 10000 次派发 0 分配)
加字段不破坏签名以后要加"来源"字段,注册方签名 Action<HeroSkinAddArgs> 一个字都不用改
语义清晰DispatchEvent(id, args) 里 args.From 比第三个裸 uint 好读得多
类型即签名监听者写 void OnSkinAdd(HeroSkinAddArgs a),越界传参会编译报错

配套约定:readonly struct + 只读字段。事件参数是"传阅的通知单",谁都不该改它 (结构体是值传递,改了别人也看不见,反而更容易写出自欺欺人的代码)。

4.3 注册监听

// 最简:参数类型由方法推断,不用写 <HeroSkinAddArgs>
RevEvent.AddEventListener(GameEventId.HeroSkinAdd, OnSkinAdd);

private void OnSkinAdd(HeroSkinAddArgs args) { /* 刷新英雄界面 */ }

带 owner(推荐,销毁时能一把摘干净):

RevEvent.AddEventListener(GameEventId.HeroSkinAdd, OnSkinAdd, owner: this);

带优先级(越大越先执行,同优先级按注册顺序):

RevEvent.AddEventListener(GameEventId.HeroSkinAdd, RefreshData, priority: 10);   // 先刷数据
RevEvent.AddEventListener(GameEventId.HeroSkinAdd, Report,      priority: -10);  // 再埋点上报

三个可选参数(都是命名参数用法最清楚):

参数默认作用
priority0越大越先执行;同优先级保持注册顺序
ownernull归属对象,用于 RemoveAllByOwner;不传则取 handler.Target

4.4 派发事件

int invoked = RevEvent.DispatchEvent(GameEventId.HeroSkinAdd, new HeroSkinAddArgs(heroId, skinId, from));

返回值是实际被调用的监听者数量,两个用途:

  1. 写断言 / 单测时判断"到底有没有人收";
  2. 排查时打印一句就知道链路通没通:if (invoked == 0) Debug.LogWarning(...)。
不想每次手写判断,就打开 RevEvent.LogNoListener = true(见 4.7),派发无人监听时框架会自己告警。

4.5 反注册的两种姿势

姿势一:按委托精确移除(返回是否真的移除了)

RevEvent.RemoveEventListener(GameEventId.HeroSkinAdd, OnSkinAdd);

方法组、缓存的委托变量都能匹配上 —— 比较的是"目标对象 + 方法",不是引用。

姿势二:按归属对象批量移除(★ 销毁时的标准动作)

private void OnDestroy() => RevEvent.RemoveAllByOwner(this);
口诀:注册的时候写了 owner: this,销毁的时候就写 RemoveAllByOwner(this)。 一个对象注册了多个事件却逐个反注册,迟早会漏一个。

4.6 参数个数:0 ~ 4

// 0 参数
RevEvent.AddEventListener(GameEventId.SceneLoaded, OnSceneLoaded);
RevEvent.DispatchEvent(GameEventId.SceneLoaded);

// 1 参数(推荐用结构体)
RevEvent.AddEventListener<HeroSkinAddArgs>(GameEventId.HeroSkinAdd, OnSkinAdd);
RevEvent.DispatchEvent(GameEventId.HeroSkinAdd, args);

// 2 / 3 / 4 参数
RevEvent.AddEventListener<int, int>(GameEventId.BagItemChange, OnBagItemChange);
RevEvent.DispatchEvent(GameEventId.BagItemChange, itemId, count);

// 5 个参数以上?定义结构体,不要堆参数
public readonly struct BattleResultArgs { /* 结果、时长、击杀数、评分… */ }

4.7 四个配置开关

RevEvent.Log                  = msg => Debug.LogWarning(msg);      // 普通消息出口
RevEvent.OnException          = (e, msg) => { /* 上报到埋点 */ };     // 监听者异常出口
RevEvent.RethrowOnException   = true;                              // 调试:异常立刻抛出(定位完记得关)
RevEvent.LogNoListener        = true;                              // 调试:派发无人监听就告警
成员默认说明
LogUnity 下自动接 Debug.LogWarning空事件名、签名不匹配等消息的出口
OnExceptionUnity 下自动接 Debug.LogError + Debug.LogException监听者抛异常的出口;自定义上报时记得自己也把异常打出来,否则 Console 里会没有
RethrowOnExceptionfalse打开后异常不再被隔离,调试器能停在真正出错的那一行
LogNoListenerfalse打开后"发了没人收"会告警,同一个事件名只提示一次,不刷屏
Unity 下的默认出口由 Support\RevEventUnityHooks.cs 在进入 Play 时自动装上;已经自己设过就不覆盖。

4.8 诊断 API

RevEvent.GetListenerCount(GameEventId.HeroSkinAdd);   // 这个事件上挂了多少监听者
RevEvent.HasListener(GameEventId.HeroSkinAdd);        // 有没有人监听
RevEvent.GetAllEventNames();                          // 还有监听者的事件名清单(已排序,便于对比快照找泄漏)
RevEvent.Clear(GameEventId.HeroSkinAdd);              // 清空单个事件
RevEvent.ClearAll();                                  // 清空全部(切账号 / 回登录 / 重开一局)

找泄漏的用法:在切界面前后各打一次 GetAllEventNames(),多出来的那些就是没反注册干净的。

这三个诊断接口报的都是"活跃监听者" 派发过程中被反注册的监听者只是打了删除标记、还留在列表里等收尾清理; GetListenerCount / HasListener / GetAllEventNames() 都不会把它们算进来 —— 否则在回调里查订阅数会看到一个早就退订干净的监听者,判断"这个事件还该不该发"也会判断错。

5必须知道的五个坑

5.1 用 lambda 注册,RemoveEventListener 匹配不上

// ✗ 摘不掉:同一个 lambda 每次执行都会生成新的委托实例,比较不相等
RevEvent.AddEventListener(GameEventId.HeroSkinAdd, a => Refresh());
RevEvent.RemoveEventListener(GameEventId.HeroSkinAdd, a => Refresh());   // 匹配不上!

// ✓ 姿势一:把委托存成字段
private Action<HeroSkinAddArgs> _onSkinAdd;
_onSkinAdd = OnSkinAdd;
RevEvent.AddEventListener(GameEventId.HeroSkinAdd, _onSkinAdd);
RevEvent.RemoveEventListener(GameEventId.HeroSkinAdd, _onSkinAdd);

// ✓ 姿势二:传 owner,销毁时批量摘(推荐)
RevEvent.AddEventListener(GameEventId.HeroSkinAdd, a => Refresh(), owner: this);
RevEvent.RemoveAllByOwner(this);

5.2 lambda 捕获了 this,RemoveAllByOwner(this) 也不会命中

不传 owner 时,框架取的是 handler.Target。 lambda 捕获 this 时,Target 是编译器生成的闭包对象,不是你的类实例。所以:

RevEvent.AddEventListener(GameEventId.HeroSkinAdd, a => Refresh(), owner: this);   // ★ 显式传,别省

5.3 同一个事件名只能有一种签名

"Hero.Skin.Add" 的所有监听者必须都是 Action<HeroSkinAddArgs>。混用不会崩, 但每次派发都会报"签名不匹配"并跳过多余的那个 —— 这是一条硬约定,不是建议。 排查时看到这条日志,说明有地方注册错了类型。

5.4 派发中增删的约定(记不住就记这三条)

你在回调里做的事结果
反注册自己本次仍会被调用 1 次;下一轮不再调用
反注册别人(还没轮到)它本轮不会被执行
新增监听者本轮不执行;下一轮生效

5.5 主线程 + 域重载

6API 速查

分类方法说明
注册AddEventListener(name, handler, priority = 0, owner = null)0~4 参数共 5 个重载
派发DispatchEvent(name, …)0~4 参数共 5 个重载,返回被调用的监听者数
反注册RemoveEventListener(name, handler)5 个重载,返回是否真的移除了
批量移除RemoveAllByOwner(owner)跨所有事件,返回移除数量
清理Clear(name) / ClearAll() / ResetAll()单个 / 全部 / 连对象池一起
诊断GetListenerCount / HasListener / GetAllEventNames最后一个会分配,仅调试用
配置Log / OnException / RethrowOnException / LogNoListener见 4.7

7验证结果

核心(RevEvent / RevEventCenter / RevEventHandlerGroup / RevEventListener)是 纯 C#、不引用 UnityEngine,所以能在工程外直接跑断言 —— 验的就是要提交的那份源码:

验证项结果
行为断言(0~4 参数、派发中增删、嵌套派发、异常隔离、签名不匹配、优先级、owner 批量移除、清理与诊断…)52 项全部通过
随机 fuzz(5 组种子 × 6000 步:增删 / 派发中增删 / 清空 / 嵌套派发 / ResetAll 交叉)结构不变量零违例(节点不重复、不双归还、派发深度归零、无残留删除标记)
结构体载荷连续派发 10000 次GC 分配 0 字节(泛型委托没有装箱)
AddEventListener / RemoveEventListener 10000 轮GC 分配 0 字节(节点确实走的对象池)
LangVersion 9.0(对齐 Unity 2022.3)编译通过
整个 Revolution.Runtime 程序集(Unity 真实引用 + UNITY_EDITOR)0 错误 0 警告

其中几条最值得关注的断言(都是"写的时候看不错、跑起来才知道"的类型):

8关键文件索引

文件职责
Runtime\RevEventSystem\Facade\RevEvent.cs业务唯一入口:AddEventListener / DispatchEvent / RemoveEventListener / 配置 / 诊断
Runtime\RevEventSystem\Core\RevEventCenter.cs派发引擎:事件名 → 监听者集合的表 + 0~4 参数派发循环
Runtime\RevEventSystem\Core\RevEventHandlerGroup.cs单个事件的监听者集合:派发中增删保护、优先级排序、清理
Runtime\RevEventSystem\Core\RevEventListener.cs单个监听者节点 + 对象池
Runtime\RevEventSystem\Support\RevEventUnityHooks.csUnity 集成:日志出口 + 进 Play / 域重载重置

设计对照与取舍记录属于内部资料,未随仓库提供 —— 想搞清取舍原因,看本节各文件的职责划分,以及《使用说明》里对应的"坑清单"。

9本期没做的(可后续扩展)

项说明
局部事件路由(树形冒泡)参考实现 CLocalEventRouter:子节点派发 → 自己处理不了就向上冒泡。适合 UI 层级事件,属独立子系统
AddOnce(只监听一次)目前可在回调第一行 RemoveEventListener 达到同样效果;真需要再补一个 once 参数
事件名生成器像 RevResPath 那样扫代码生成 GameEventId 静态常量类,把"事件名来自哪里"也纳入编译期保护
异步派发有意不做,见 2.6
一句话总结:这套系统的目标不是"功能更多",而是"出错时一定看得见" —— 参数收窄到 4 个逼你用结构体、类型不匹配大声报、派发中增删有明确约定、销毁有 owner 兜底、无人监听可告警。 这些加起来,才让"发了事件没人收"这类最难查的问题变得好查。