事件总线

游戏系统之间类型安全的同步发布/订阅。

游戏系统之间类型安全的发布/订阅 · 同步、仅限主线程 · Subscribe 返回 IDisposable 令牌 · 无操作替代:NullEventBus

功能概述

事件总线让各系统在互不知晓的情况下对话。发布者调用 bus.Publish(new PlayerDamagedEvent { ... });每个通过 bus.Subscribe<PlayerDamagedEvent>(handler) 注册的处理器都会同步收到它,顺序即各处理器订阅的顺序。事件类型在编译期匹配——没有反射、没有动态查找——因此该总线在 IL2CPP(AOT 编译)构建中也能安全工作。它是输入服务、场景转换、UI 面板栈与你自己的游戏代码之间的管道。

快速示例

定义一个事件类,在 OnEnable 中订阅,在 OnDisable 中退订:

using System;
using CommonGameSystem.Core;
using UnityEngine;

// Any plain class works as an event.
public class PlayerDamagedEvent
{
    public int Amount;
}

[DefaultExecutionOrder(100)]
public class DamageUI : MonoBehaviour
{
    private IEventBus _bus;
    private IDisposable _subscription;

    private void Awake()
    {
        _bus = ServiceLocator.Resolve<IEventBus>();  // Cache it once
    }

    private void OnEnable()
    {
        _subscription = _bus.Subscribe<PlayerDamagedEvent>(OnPlayerDamaged);
    }

    private void OnDisable()
    {
        _subscription?.Dispose();
    }

    private void OnPlayerDamaged(PlayerDamagedEvent ev)
    {
        Debug.Log($"Damage: {ev.Amount}");
        // Update UI, play a sound, etc.
    }
}

在主线程上的任何位置发布只需一行:

_bus.Publish(new PlayerDamagedEvent { Amount = 12 });

对于只应存活于一个作用域内的处理器,using 会自动释放令牌:

private void RunScopedListener()
{
    using var token = _bus.Subscribe<PlayerDamagedEvent>(ev => Debug.Log(ev.Amount));
    // The handler is active here...
}   // ...and unsubscribed automatically when the scope ends.

完整 API 一览

IEventBus 接口

  • IDisposable Subscribe<TEvent>(Action<TEvent> handler) where TEvent : classTEvent 类型的事件注册一个处理器。返回一个令牌,其 Dispose() 用于退订——重复释放是安全的,第二次不做任何事。事件类型必须是引用类型(classrecord)。处理器为 null 时抛出 ArgumentNullException

  • void Publish<TEvent>(TEvent ev) where TEvent : class 同步调用为精确类型 TEvent 注册的每个处理器,按订阅顺序(先订阅者先被调用)。没有处理器时调用会静默返回。事件为 null 时抛出 ArgumentNullException。处理器抛出的异常会被捕获并记录;其余处理器仍会执行。

EventBusOptions 调优(可选)

默认总线由启动引导以默认选项注册。如果你自行构造总线(用于测试,或一个隔离的子总线),可以对它调优:

var bus = new DefaultEventBus(
    EventBusOptions.Default.With(
        initialListCapacity: 8,
        warnSubscribersPerType: 100));
  • WarnSubscribersPerType(默认 50):仅限 Editor 的阈值;当某个事件类型的订阅者数量超过该值时,为该类型记录一次警告。通常是订阅泄漏的征兆。
  • WarnPublishDepth(默认 8):仅限 Editor 的阈值;当发布调用的嵌套深度超过该值时警告一次(一个处理器又发布另一个事件,依此类推)。发布调用栈完全退栈后,该警告会重新待命。
  • InitialListCapacity(默认 4):内存调优——每个类型的订阅者列表的初始大小。

EventBusOptions 是不可变结构体:从 EventBusOptions.Default 出发,用 .With(...) 覆盖个别值。

EventBusDiagnostics(仅限 Editor)

仅在 Editor 构建中可用,用于检视总线状态:

#if UNITY_EDITOR
int count = EventBusDiagnostics.SubscribeCount<MyEvent>(_bus);
int depth = EventBusDiagnostics.PeekPublishDepth(_bus);
long warnings = EventBusDiagnostics.WarningsEmitted;
#endif

关闭此服务

ServiceLocator.Replace<IEventBus>(new NullEventBus());

NullEventBus 静默丢弃每次发布,并从 Subscribe 返回无害的无操作令牌。用它在测试或特殊构建中禁用事件路由,而无需改动任何调用方代码。其他服务(输入、UI、场景流程)照常工作——它们只是不再收到事件。没有日志,没有副作用。

行为与边界情况

  • 仅限主线程。 所有 SubscribePublish 调用都必须来自 Unity 主线程。Debug 构建会对此做断言。Release 构建出于速度考虑跳过检查,因此在那里从后台线程调用可能破坏状态——绝对不要这样做。要从工作线程发布,先把工作排回主线程(调度器服务正好有一个在主线程执行的调度功能)。

  • 派发基于快照运行。 每次 Publish 都会遍历发布开始时获取的订阅者列表快照。这使得处理器在派发期间订阅或退订——甚至退订它自己——都是安全的:变更只对下一次发布生效,而不影响正在进行的这一次。快照是每次发布的一小笔分配;在极高发布频率且订阅者众多的情况下,这会表现为垃圾回收压力,因此对非常高频的更新尽量做批处理。

  • 缓存总线,不要每次都 Resolve。 Resolve<IEventBus>() 是一次字典查找。在 Update 中调用它每帧都要付出这份成本。在 AwakeStart 中获取一次并保留引用。

  • 事件类型必须是 classrecord,不能是 struct。这让派发保持简单,并避免装箱分配。

  • 没有基于继承的派发。 订阅基类事件类型不会收到派生事件类型。Publish<DerivedEvent>不会调用 Subscribe<BaseEvent> 的处理器。请订阅与你发布时完全相同的类型。

  • 释放订阅令牌。 忘记 Dispose() 会让处理器永远保持注册——这既是内存泄漏,还会让订阅对象一直存活。在 OnDisable/OnDestroy 中退订,或对作用域内的处理器使用 using

  • 处理器异常不会中断派发。 如果某个处理器抛出异常,异常会被记录,下一个处理器仍会执行。你的发布调用总是正常完成。

  • 自定义事件类在 IL2CPP 下需要 link.xml 如果你使用 IL2CPP 构建,请把事件类加入项目的 link.xml,以免构建的代码裁剪把它们移除:

    <assembly fullname="YourNamespace" preserve="all">
        <type fullname="YourNamespace.PlayerDamagedEvent" preserve="all"/>
    </assembly>
    

相关页面

  • 延迟事件队列 —— 现在入队事件,稍后统一派发(本总线的异步姊妹服务)
  • 配置 —— 通过本总线发布 ConfigurationChanged<TGroup> 事件
  • 调度器 —— 供从工作线程发布使用的主线程调度
  • 启动引导 —— 启动顺序
  • 服务定位器 —— 解析并缓存总线