〇它是干什么的
先花 30 秒建立直觉,再动手写。
只学 3 个东西就能干活(真的)
RevEvent.AddEventListener<int>("HpChanged", OnHpChanged, owner: this); // ① 订阅(一定带 owner) RevEvent.DispatchEvent("HpChanged", 100); // ② 广播(谁想听谁听) RevEvent.RemoveAllByOwner(this); // ③ 退订全部(对象销毁时一行搞定) void OnHpChanged(int hp) { hpText.text = hp.ToString(); } // 回调:参数类型与订阅时一致
不想要参数也行:AddEventListener("GameStart", OnStart, owner: this)(无参版本 ✓)。
需要 2/3/4 个参数、优先级、按名字清空……都在第二节的对照表里。
人话
事件系统 = "广播":广播方不知道谁在听,听的人自己订阅;名字是约定,参数是类型安全的。
它最重要的价值不是"解耦"这个说法,而是 owner:对象销毁时一行退订全部 —— 这是 90% 的事件 bug(空引用、重复触发)的解药。
| 它替你解决的问题 | 怎么做的 |
|---|---|
| 找不到委托引用没法退订 | owner:注册时带上自己,销毁时 RemoveAllByOwner(this) |
| 参数到处强转(object 满天飞) | 泛型订阅:AddEventListener<T1> … 最多 4 个参数,类型编译期就定死 |
| 对象销毁了回调还在跑 | owner 机制 + 框架侧校验(见第三节第 2 条) |
| 不知道有哪些事件、谁在听 | GetAllEventNames() / GetListenerCount(name) / HasListener(name) |
| 某个回调抛异常炸掉整条广播 | 统一异常出口 RevEvent.OnException(可接管上报),不会被静默吞掉 |
一3 分钟接上第一个事件
场景:战斗里血量变了,血条自己更新 —— 两边互不认识。
// ① 血条(订阅方):挂到血条物体上 public class HpBar : MonoBehaviour { [SerializeField] private Image fill; void OnEnable() { // 订阅:事件名 + 处理函数 + owner(this) RevEvent.AddEventListener<int, int>("HpChanged", OnHpChanged, owner: this); } void OnDisable() { // 退订:一行,按 owner 全清(不用记委托、不用记事件名) RevEvent.RemoveAllByOwner(this); } void OnHpChanged(int cur, int max) { fill.fillAmount = max > 0 ? (float)cur / max : 0f; } } // ② 战斗逻辑(广播方):谁都不用认识,直接广播 RevEvent.DispatchEvent("HpChanged", hp, hpMax); // ③ 无参事件(开服、回大厅、切语言…) RevEvent.AddEventListener("EnterLobby", () => Refresh(), owner: this); RevEvent.DispatchEvent("EnterLobby");
就这样,没有初始化步骤
不需要注册事件中心、不需要在场景里摆"EventSystem"物体。事件名就是字符串;框架不做去重、也不设订阅数量上限 ——
它保证的是"发出去没人收一定看得见"(
DispatchEvent 的返回值 / RevEvent.LogNoListener)。
OnEnable/OnDisable 还是 Start/OnDestroy?
两者都行,但语义不同:OnEnable/OnDisable = "显示时听、隐藏时不听"(推荐,能省掉无效回调);
Start/OnDestroy = "创建到销毁期间一直听"。别在 OnEnable 里订阅却在 OnDestroy 里退订 —— 会重复订阅。
二"我要做 X" 对照表(全部 API)
入口统一是 RevEvent。
| 我想… | 这么写 |
|---|---|
| 订阅(无参 / 1 参) | RevEvent.AddEventListener(name, handler, owner: this) · AddEventListener<T1>(name, h, owner: this) |
| 订阅(2 / 3 / 4 个参数) | AddEventListener<T1,T2>(…) · <T1,T2,T3> · <T1,T2,T3,T4> |
| 指定回调先后顺序 | 多传一个 priority(默认 0,需要时再用) |
| 广播(对应参数个数) | RevEvent.DispatchEvent(name) · DispatchEvent<T1>(name, a1) · 以此类推到 4 个参数 |
| 退订某一个处理函数 | RevEvent.RemoveEventListener(name, handler)(泛型版本同理) |
| 退订"我的全部订阅" | RevEvent.RemoveAllByOwner(this) ← 最常用 |
| 清掉某个事件的所有订阅 | RevEvent.Clear(name) |
| 清掉全部(换关卡时) | RevEvent.ClearAll() · RevEvent.ResetAll() |
| 查有没有人在听 / 查有哪些事件 | RevEvent.HasListener(name) · GetListenerCount(name) · GetAllEventNames() |
| 接自己的日志 / 异常处理 | RevEvent.Log = msg => … · RevEvent.OnException = (e, ctx) => … |
广播的返回值有用
DispatchEvent 返回实际被调用的订阅数(int)。排查"为什么没人响应"时直接看它是不是 0:
int n = RevEvent.DispatchEvent("EnterLobby"); if (n == 0) RevLog.Warn("没人监听 EnterLobby", "Event");
三三条必守约定(踩了就出 bug)
| 约定 | 反例 ✗ | 正例 ✓ |
|---|---|---|
| ① 事件名用常量 | DispatchEvent("HpChanged") 和 AddEventListener("HpChange", …)(打错一个字母,静默失效) |
定义 static class EventIds { public const string HpChanged = "HpChanged"; },两边都用常量 |
| ② 订阅一定带 owner | AddEventListener<int>("HpChanged", OnHp)(没 owner → 退订时找不到它) |
owner: this,销毁时 RemoveAllByOwner(this) |
| ③ 参数类型两端一致 | 订阅 Action<int>、广播却传 float(编译期就会拦,注意别用 object 绕) |
参数类型用具体类型;复合数据传一个数据类(AddEventListener<DamageInfo>) |
第 ② 条为什么最要命
Unity 对象销毁后,事件中心并不知道。如果订阅没带 owner,那条订阅会一直留在那里 ——
下次广播时调到一个已销毁对象的方法:轻则报"MissingReferenceException",重则逻辑错乱。
带 owner + 销毁时 RemoveAllByOwner,是唯一稳妥的写法。
四广播 vs 直接调用:什么时候该用事件
不该用事件(直接调用更好)
A 明确拥有 B,B 就是给 A 用的:面板 → 它自己的按钮 · 战斗系统 → 它的伤害计算器 · 加载器 → 它的回调。
直接调用:看得见数据流、能断点、能返回结果。
该用事件(一对多 / 互不认识)
一个变化,多处反应,且广播方不该知道有谁:血量变化 → 血条 / 飘字 / 音效 / 成就统计 · 回大厅 → UI、缓存、音频、埋点 各自收尾。
广播:加一个新系统不用改老代码。
一条实用判据
"如果我加了第四个监听者,广播方要不要改代码?" —— 要改就别用事件;不用改就用事件。
五调试与异常(OnException / Log 钩子)
// ① 接住回调里的异常(默认会被框架记下并继续广播,不会炸掉整条链) RevEvent.OnException = (ex, context) => { RevLog.Exception(ex, "事件回调异常:" + context, "Event"); // 需要上报就挂这里(框架不硬编码任何上报 SDK) }; // ② 想看事件流量(开发期临时开) RevEvent.Log = msg => Debug.Log("[Event] " + msg); // ③ 排查"为什么没反应" Debug.Log($"EnterLobby 订阅数:{RevEvent.GetListenerCount("EnterLobby")}"); Debug.Log(string.Join(", ", RevEvent.GetAllEventNames())); // 当前所有事件名
排查顺序(30 秒)
①
② 订阅数对,但逻辑没跑 → 看参数类型/值是不是发错了 → 开
③ 报空引用 → 十有八九是没带
GetListenerCount(名字) 是 0 吗 → 说明没人订阅(名字打错了?订阅方还没 OnEnable?)② 订阅数对,但逻辑没跑 → 看参数类型/值是不是发错了 → 开
RevEvent.Log 看有没有广播出来③ 报空引用 → 十有八九是没带
owner 的老订阅指向了已销毁对象(第三节第 2 条)
六新手最容易踩的 5 个坑
| 坑 | 正确做法 |
|---|---|
| ① 事件名硬编码 + 打错字 | 统一常量类(或用 nameof);排查时用 GetAllEventNames() 对照 |
| ② 订阅不带 owner | 永远 owner: this;对象销毁/禁用时 RemoveAllByOwner(this) |
| ③ OnEnable 订阅、OnDestroy 退订 | 成对:OnEnable ↔ OnDisable(否则每次显示都加一条,重复触发) |
| ④ 用事件当"函数调用"(一发一收还要结果) | 需要返回值/需要知道谁处理了 → 直接调用(第四节);事件是广播,不是 RPC |
| ⑤ 回调里做重活(加载大资源、开界面) | 回调里只做标记/转发,重活丢给状态机或下一帧(RevTimer.NextFrame) |
七相关文档
- 《事件系统 · 架构解析》 —— 设计论证:内部结构(事件中心 / 监听组 / 侦听器)、派发中增删订阅的语义、异常隔离、诊断接口
- 《UI 系统 · 使用说明》 —— 界面上订阅事件的推荐位置(OnOpen/OnClose)
- 《日志系统 · 使用说明》 —— 排查时配日志一起看
- 文档总入口 · 在线文档站