DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 12
STEP 01 / CODE / LOGIC

先说结论

发布订阅让发送者只声明“发生了什么”,而不直接调用 UI、音效、成就等具体模块。它降低直接依赖,但也会让调用链变得隐形,所以事件载荷、订阅生命周期和调试记录必须明确。

玩家 HP:100 -> 70
发布:HealthChanged(old=100, current=70)
UI:血条显示 70%
音效:生命值低于 30 时才播放,本次不播放
成就:累计受伤量增加 30

分类:工程设计

先说结论

发布订阅让发送者只声明“发生了什么”,而不直接调用 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,而且查询时状态可能已经继续变化。这里直接携带 previouscurrent,订阅者可以立即计算:

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;
}