把章节变成可单步观察的过程
分类:工程设计
先说结论
发布订阅让发送者只声明“发生了什么”,而不直接调用 UI、音效、成就等具体模块。它降低直接依赖,但也会让调用链变得隐形,所以事件载荷、订阅生命周期和调试记录必须明确。
一次扣血触发三个监听者
玩家 HP:100 -> 70
发布:HealthChanged(old=100, current=70)
UI:血条显示 70%
音效:生命值低于 30 时才播放,本次不播放
成就:累计受伤量增加 30
发布者只负责提交事实。UI 被销毁后若没有取消订阅,下一次事件仍会调用旧对象;这不是事件本身错误,而是监听者生命周期没有闭合。
事件系统解决什么问题
事件系统解决的是:
一个事情发生后,很多系统都想知道;
但发生事情的人不应该认识所有响应者。
例如角色扣血后,可能有很多系统要响应:
UI 血条更新
音效播放
任务系统检查
成就系统统计
伤害飘字显示
镜头震动
如果角色血量系统直接调用所有系统,会变成:
血量系统认识 UI
认识音效
认识任务
认识成就
认识飘字
这叫耦合太高。
跟着代码跑一次扣血事件
先不用通用事件总线,只用一个明确的 C# 事件:
using System;
sealed class Health
{
public event Action<int, int>? Changed;
public int Current { get; private set; } = 100;
public void TakeDamage(int damage)
{
int previous = Current;
Current = Math.Max(0, Current - damage);
Changed?.Invoke(previous, Current);
}
}
UI 和音效分别订阅,不需要让 Health 认识它们:
Health health = new();
void UpdateBar(int previous, int current) =>
Console.WriteLine($"血条: {previous} -> {current}");
void PlayHitSound(int previous, int current) =>
Console.WriteLine("播放受击音效");
health.Changed += UpdateBar;
health.Changed += PlayHitSound;
health.TakeDamage(30);
运行顺序:
TakeDamage(30)
-> Health 从 100 改成 70
-> 发布 Changed(100, 70)
-> UpdateBar 输出血条变化
-> PlayHitSound 播放声音
第二步:对象关闭前取消订阅
void OnOpen()
{
health.Changed += UpdateBar;
}
void OnClose()
{
health.Changed -= UpdateBar;
}
手工验证:
OnOpen -> TakeDamage(10) -> UI 收到一次
OnClose -> TakeDamage(10) -> UI 不再收到
再次 OnOpen -> TakeDamage(10) -> UI 仍只收到一次
如果最后一步收到两次,说明某条打开路径重复订阅或某条关闭路径漏了取消。
第三步:事件参数必须带足事实
只发布 Action 会迫使订阅者再回头查询 Health,而且查询时状态可能已经继续变化。这里直接携带 previous 和 current,订阅者可以立即计算:
int damageTaken = previous - current;
实际项目还可以携带攻击者、伤害类型和事件 ID,但不要把可修改的内部对象随意暴露给订阅者。后文的事件风暴和发布边界,都是从这次 TakeDamage 的同步调用链扩展出来的。
发布订阅
事件系统的思路是:
发布者 Publish:广播一件事情发生了。
订阅者 Subscribe:自己监听并响应这件事。
角色扣血时只广播:
我受伤了。
至于谁关心,谁自己订阅:
血条系统听到 -> 更新 UI
音效系统听到 -> 播放音效
任务系统听到 -> 检查任务
成就系统听到 -> 统计
飘字系统听到 -> 显示伤害
发布者不需要知道这些系统存在。
C# 简单写法
定义事件参数:
public struct DamageEvent
{
public int Damage;
public int CurrentHp;
}
定义事件中心:
public static class GameEvents
{
public static Action<DamageEvent> OnDamage;
}
发布事件:
void TakeDamage(int damage)
{
hp -= damage;
GameEvents.OnDamage?.Invoke(new DamageEvent
{
Damage = damage,
CurrentHp = hp
});
}
这里:
?.Invoke(...)
意思是:
如果 OnDamage 不为空,就调用它。
如果没人监听,就什么都不做。
订阅事件
血条系统:
void OnEnable()
{
GameEvents.OnDamage += HandleDamage;
}
void OnDisable()
{
GameEvents.OnDamage -= HandleDamage;
}
void HandleDamage(DamageEvent e)
{
hpBar.SetValue(e.CurrentHp);
}
音效系统:
void OnEnable()
{
GameEvents.OnDamage += HandleDamage;
}
void OnDisable()
{
GameEvents.OnDamage -= HandleDamage;
}
void HandleDamage(DamageEvent e)
{
audio.Play("Hit");
}
同一个事件可以有多个监听者。
为什么要取消订阅
取消订阅非常重要:
GameEvents.OnDamage -= HandleDamage;
如果对象销毁了,但没有取消订阅,事件中心可能还保存着旧对象的方法引用。
结果可能是:
事件触发时调用已销毁对象
空引用报错
对象无法被 GC 回收
逻辑重复响应
Unity 里常见配对写法:
void OnEnable()
{
GameEvents.OnDamage += HandleDamage;
}
void OnDisable()
{
GameEvents.OnDamage -= HandleDamage;
}
这和资源生命周期很像:
谁订阅,谁取消。
适合事件系统的场景
适合一个事件有多个响应者:
角色受伤
角色死亡
获得道具
任务完成
场景加载完成
语言切换
网络断开
配置刷新
UI 窗口打开关闭
例如获得道具:
背包系统添加物品
UI 提示获得
任务系统检查收集进度
音效系统播放提示
成就系统统计
获得道具的人不应该挨个调用这些系统。
不适合事件系统的场景
事件系统不适合隐藏强流程依赖。
例如:
必须先扣资源
再创建单位
再播放动画
再进入战斗
这种强顺序流程如果全靠事件串起来,会导致:
不知道谁先执行
不知道哪里断了
Debug 很痛苦
主流程变隐形
强流程更适合直接调用、状态机或流程控制器。
事件适合:
我通知一件事发生了,关心的人自己响应。
不适合:
我依赖你必须完成,然后我继续下一步。
最大收益
事件系统最大的收益是解耦。
发布者只知道:
我发了一个事件。
不知道:
谁在听
听了做什么
有几个系统在听
新增响应逻辑时,不用改发布者。
例如新增受伤震屏:
GameEvents.OnDamage += ShakeCamera;
不用改 TakeDamage。
最大风险
事件太多、乱用,会让流程变隐形。
看到:
GameEvents.OnDamage?.Invoke(e);
但可能不知道:
谁收到?
执行顺序是什么?
有没有人取消订阅?
谁里面又发了别的事件?
所以事件要有边界:
适合广播事实
不适合隐藏主流程
事件名要清楚
订阅/取消要成对
重要事件要方便 Debug
最重要的收获
事件系统让“发生者”和“响应者”解耦。
最重要的三条:
发布者只广播事实。
订阅者自己响应。
谁订阅,谁取消。
常用模板:
void OnEnable()
{
GameEvents.OnDamage += HandleDamage;
}
void OnDisable()
{
GameEvents.OnDamage -= HandleDamage;
}