源设计起源 · 为什么要做这个模块
- 没它会怎样:发事件的那段代码得认识所有接收者:每加一个响应方,就要回去改它一次。系统越大,这种"顺手改一下"越贵。
- 早期做法一:出错是静默的:派发时做类型转换,类型对不上就得到空值 —— 不报错、不打日志、不崩,事件就这么消失了。表现是"某个功能偶尔不刷新",排查方向还被带偏。
- 早期做法二:改着改着就崩:派发过程中有回调反注册了自己或别人,正在遍历的监听者列表被改短,于是漏调用、重复调用甚至越界崩溃。
- 早期做法三:漏一处就是泄漏:界面注册了 5 个事件、销毁时漏掉一个,事件系统就一直攥着已销毁的界面对象:回收不掉,下次派发还会执行到它。
- 换来了什么:签名对不上会明确报出两侧类型;派发中增删监听者变得可预期;常规注册/反注册路径零 GC 分配 —— 原来靠运气的地方,现在都有明确结果。
本篇只讲为什么;怎么用见同目录《使用说明》。
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当前实现 vs 早期版本
2.1 总览
| 维度 | 早期版本 EventCenter.cs | 新版 Runtime\RevEventSystem\ |
|---|---|---|
| 代码规模 | 1906 行 / 单文件,89 KB | 1164 行 / 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
// 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:
- 业务侧集中定义静态常量(
GameEventId.HeroSkinAdd = "Hero.Skin.Add"),拿到"拼错编译不过 + IDE 补全"的全部好处; - 框架不需要知道业务有哪些事件,不用像参考实现那样搞一份自动生成的
EventID.cs; - 排查时日志里直接是可读的事件名,而不是
3017。
字典用 StringComparer.Ordinal,不受系统区域设置影响(否则土耳其语环境下 I/i 会互相匹配,属于经典冷坑)。
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 有两个硬伤:
- 异常没法被捕获 —— 监听者里抛的异常不会回到调用方,直接进全局未处理异常;
- 顺序无保证 —— 你不知道谁先谁后,而事件系统的大量用法("先刷数据再刷界面")恰恰依赖顺序。
新版不提供异步派发。正确姿势是:需要异步就先把异步做完,再派发。
// 该这样: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_isHandlerListChangedInDispatching | RevEventListener.IsAbandoned + RevEventHandlerGroup 的 _dispatchDepth / _changedInDispatch;并额外覆盖嵌套派发 |
| ② 内存优化:容量预留 1 | new ListView<CEventHandler>(1),"默认 4 会浪费约 75%" | new List<RevEventListener>(1),注释里写明来由 |
| ③ handler 对象池 | Queue<CEventHandler> m_eventHandlerUnuse | RevEventListener 静态池(上限 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 = true(调试开关):立刻向外抛,调试器能停在真正出错的那一行,而不是停在这段 catch 里;- 上报信息里会点名是哪个监听者(
类型名.方法名(owner=…, priority=…)),省掉"到底谁干的"的排查。
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"; }
const而不是static readonly:const能被编译器直接内联,零访问成本;- 命名建议
模块.对象.动作(Hero.Skin.Add):日志里一眼看出谁发的,也天然避免了跨模块重名; - 不要手写字符串字面量。
DispatchEvent("Hero.Skin.Ad", …)拼错一个字母不会报错,只会没人响应。
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); // 再埋点上报
三个可选参数(都是命名参数用法最清楚):
| 参数 | 默认 | 作用 |
|---|---|---|
priority | 0 | 越大越先执行;同优先级保持注册顺序 |
owner | null | 归属对象,用于 RemoveAllByOwner;不传则取 handler.Target |
4.4 派发事件
int invoked = RevEvent.DispatchEvent(GameEventId.HeroSkinAdd, new HeroSkinAddArgs(heroId, skinId, from));
返回值是实际被调用的监听者数量,两个用途:
- 写断言 / 单测时判断"到底有没有人收";
- 排查时打印一句就知道链路通没通:
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; // 调试:派发无人监听就告警
| 成员 | 默认 | 说明 |
|---|---|---|
Log | Unity 下自动接 Debug.LogWarning | 空事件名、签名不匹配等消息的出口 |
OnException | Unity 下自动接 Debug.LogError + Debug.LogException | 监听者抛异常的出口;自定义上报时记得自己也把异常打出来,否则 Console 里会没有 |
RethrowOnException | false | 打开后异常不再被隔离,调试器能停在真正出错的那一行 |
LogNoListener | false | 打开后"发了没人收"会告警,同一个事件名只提示一次,不刷屏 |
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 主线程 + 域重载
- 只在主线程用:
AddEventListener/DispatchEvent/RemoveEventListener都是。 子线程里要发事件,先把数据切回主线程再派发。系统内部不加锁,这是有意的(Unity 的绝大多数 API 也是这个约定)。 - 静态数据会被自动重置:关闭 Domain Reload 的"快速进入 Play 模式"下,静态字段不会自动清空。
Support\RevEventUnityHooks.cs在SubsystemRegistration阶段调了一次RevEvent.ResetAll(), 避免上一次运行的监听者"活到"这一次。
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 警告 |
其中几条最值得关注的断言(都是"写的时候看不错、跑起来才知道"的类型):
- 回调里反注册自己:本次仍被调用 1 次,下一轮不再调用;
- 摘掉"还没轮到"的监听者:本轮就不执行它;
- 嵌套派发同一事件:外层 1 次 + 内层 1 次,不死循环,且内层不会提前清理列表;
RethrowOnException抛出后:派发深度正常退回,事件不会"卡在派发中";- 签名不匹配:报错信息里同时含
Action<Int32>和Action<HeroSkinAddArgs>。
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.cs | Unity 集成:日志出口 + 进 Play / 域重载重置 |
设计对照与取舍记录属于内部资料,未随仓库提供 —— 想搞清取舍原因,看本节各文件的职责划分,以及《使用说明》里对应的"坑清单"。
9本期没做的(可后续扩展)
| 项 | 说明 |
|---|---|
| 局部事件路由(树形冒泡) | 参考实现 CLocalEventRouter:子节点派发 → 自己处理不了就向上冒泡。适合 UI 层级事件,属独立子系统 |
AddOnce(只监听一次) | 目前可在回调第一行 RemoveEventListener 达到同样效果;真需要再补一个 once 参数 |
| 事件名生成器 | 像 RevResPath 那样扫代码生成 GameEventId 静态常量类,把"事件名来自哪里"也纳入编译期保护 |
| 异步派发 | 有意不做,见 2.6 |