GenEvent

May 8, 2026 · View on GitHub

.NET

GenEvent 是一个高性能事件库,通过源码生成器在编译期生成全部派发代码,无运行时反射,兼容 .NET 与 Unity(netstandard2.0)。

目录

主要特性

  • 无运行时反射:所有派发、注册代码在编译期由源码生成器生成
  • 零分配热路径:默认发布路径和常见内建 fluent 过滤链在稳态下不分配;自定义 WithFilter(Predicate<object>)、多次自定义 predicate 叠加,或超过当前内联规则容量的内建过滤链,仍可能产生分配
  • IL2CPP 友好:不依赖运行时反射,可安全运行在 IL2CPP/AOT 环境
  • 优先级:通过 [OnEvent(SubscriberPriority.XXX)] 在编译期确定调用顺序,运行时零排序开销
  • 取消传播:处理器返回 false 配合 Cancelable() 可中止事件派发
  • 灵活的订阅生命周期StartListening 返回轻量值类型句柄 SubscriptionHandleIDisposable),支持 using 自动取消,也可手动调用 StopListening
  • 流式发布 API:链式配置 CancelableWithFilterOnlyType 等,可按需组合
  • 异步支持:处理器可返回 Task / Task<bool>,通过 PublishAsync 按序 await
  • 嵌套发布:支持在处理器内部再次发布事件,各层配置相互独立
  • 静态处理器[OnEvent] 可以标注 static 方法(普通类或 static 类均可)。静态处理器在 GenEventBootstrap.Init() 时自动注册,生命周期与程序一致,无需也无法调用 StartListening / StopListening

快速开始

安装

通过 .csproj 引用主库和生成器。生成器以 Analyzer 形式接入,仅在编译期生成代码,不参与运行时:

<ItemGroup>
  <ProjectReference Include="path\to\GenEvent\GenEvent.csproj" />
  <ProjectReference Include="path\to\GenEvent.SourceGenerator\GenEvent.SourceGenerator.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

Unity 项目

在 Unity 中打开 Window > Package Manager,点击左上角 Add,选择 Add package from git URL...,输入:

https://github.com/Puring103/GenEvent.git?path=src/GenEvent.Unity/Assets/Plugins/GenEvent

自动初始化:当生成器检测到项目引用了 UnityEngine / UnityEditor 时,会自动为 GenEventBootstrap.Init 添加 [RuntimeInitializeOnLoadMethod],程序启动时自动完成所有注册,无需手动调用。如需在特定时机初始化,仍可手动调用 GenEventBootstrap.Init()

最小示例

using GenEvent;
using GenEvent.Interface;

// 1. 定义事件:struct + IGenEvent<T>
public struct PlayerDeathEvent : IGenEvent<PlayerDeathEvent>
{
    public int PlayerId;
}

// 2. 定义订阅者:class + [OnEvent] 标记处理方法
public class GameManager
{
    [OnEvent]
    public void OnPlayerDeath(PlayerDeathEvent e)
    {
        Console.WriteLine($"Player {e.PlayerId} died.");
    }
}

// 3. 初始化(非 Unity 项目需在首次订阅或发布前调用一次)
GenEventBootstrap.Init();

// 4. 订阅,StartListening 返回 SubscriptionHandle(IDisposable)
var manager = new GameManager();
using var handle = manager.StartListening(); // using 结束时自动取消订阅

// 5. 发布事件
new PlayerDeathEvent { PlayerId = 1 }.Publish();

运行时契约

初始化规则

  • 非 Unity 项目中,必须在首次订阅或发布前调用 GenEventBootstrap.Init()
  • Init()幂等的,可以安全地重复调用。
  • 可通过 GenEventBootstrap.IsInitialized 检查当前程序集的 GenEvent 是否已初始化。
if (!GenEventBootstrap.IsInitialized)
{
    GenEventBootstrap.Init();
}

如果在初始化前调用 Publish()PublishAsync()StartListening()StopListening(),GenEvent 会抛出清晰的 InvalidOperationException,并提示先调用 GenEventBootstrap.Init()

线程模型

  • 当前版本不保证并发线程安全
  • 支持在处理器内部进行嵌套发布。
  • 不支持多线程并发发布、并发订阅、并发反订阅,以及多线程同时修改链式发布配置。
  • 如果宿主环境可能从多个线程访问 GenEvent,需要由宿主自行保证串行化。

诊断辅助

可使用以下 API 做快速排查:

bool hasPublisher = PublisherHelper.HasPublisher<DamageEvent>();
bool hasSubscriberRegistry = hud.HasSubscriberRegistry();
int count = SubscriberHelper.GetSubscriberCount<DamageEvent, HUDDisplay>();
  • HasPublisher<TEvent>():确认目标事件发布器是否已注册。
  • HasSubscriberRegistry():确认当前运行时订阅者类型是否存在生成的注册表。
  • GetSubscriberCount<TEvent, TSubscriber>():确认某个事件/订阅者组合当前注册了多少实例。

核心 API

定义事件

事件必须是 struct 并实现 IGenEvent<T>。值类型可避免装箱,并让默认发布路径在稳态下保持零分配。

public struct DamageEvent : IGenEvent<DamageEvent>
{
    public int Amount;
    public string Source;
}

定义订阅者与处理器

订阅者是普通 class,用 [OnEvent] 特性标记处理方法。根据是否需要参与传播控制,选择对应的返回类型:

签名说明
void Method(TEvent e)只接收事件,不参与传播控制
bool Method(TEvent e)返回 false 可中止后续订阅者接收(需配合发布时的 Cancelable()
async Task Method(TEvent e)异步处理,不参与传播控制
async Task<bool> Method(TEvent e)异步处理,返回 false 可中止传播(需配合发布时的 Cancelable()
public class HUDDisplay
{
    // void:相当于永远返回true
    [OnEvent]
    public void OnDamage(DamageEvent e)
    {
        UpdateHealthBar(e.Amount);
    }
}

public class ShieldSystem
{
    // bool:可以拦截事件并阻止后续订阅者收到
    [OnEvent]
    public bool OnDamage(DamageEvent e)
    {
        if (HasShield)
        {
            AbsorbDamage(e.Amount);
            return false; // 中止传播,后续订阅者(如 HUDDisplay)不会收到
        }
        return true;
    }
}

同一 class 对同一事件最多定义一个同步一个异步处理器,分别由 PublishPublishAsync 触发。

静态处理器

[OnEvent] 也可以标注 static 方法。静态处理器与实例处理器有三点不同:

  1. 自动生命周期:在 GenEventBootstrap.Init() 时自动注册一次,并在整个程序生命周期内保持激活。无需也无法调用 StartListening / StopListening
  2. 无需实例:适用于普通类和 static 类。
  3. 调用顺序:在相同优先级内,静态处理器先于实例处理器被调用。
// 静态类 —— 整个类作为全局处理器
public static class GameSystems
{
    [OnEvent]
    public static bool OnPlayerDeath(PlayerDeathEvent e)
    {
        // 全局常驻,无需 StartListening
        return true;
    }
}

// 普通类 —— 静态与实例处理器混用
public class PlayerController
{
    // 静态处理器:全局常驻
    [OnEvent]
    public static void OnReset(ResetEvent e)
    {
        ResetAllPlayers();
    }

    // 实例处理器:仅在该实例 StartListening 期间生效
    [OnEvent]
    public void OnDamage(DamageEvent e)
    {
        TakeDamage(e.Amount);
    }
}

对某个 PlayerController 实例调用 StopListening() 时,OnDamage 被注销,但 OnReset 仍保持激活——它与任何实例无关。

初始化

首次订阅或发布前,调用 GenEventBootstrap.Init() 完成所有 Publisher 与 Subscriber 的注册。若项目有多个程序集,每个程序集需各自调用其生成的 Init()

// 在程序入口调用一次即可;重复调用安全
GenEventBootstrap.Init();

Unity 项目由生成器自动插入 [RuntimeInitializeOnLoadMethod],无需手动调用。

bool ready = GenEventBootstrap.IsInitialized;

订阅生命周期

StartListening() 将订阅者注册到事件系统,并返回一个轻量值类型句柄 SubscriptionHandleIDisposable)。持有该句柄并在合适时机 Dispose,即可取消订阅,等价于调用 StopListening()

推荐:持有句柄,在销毁时 Dispose

public class Enemy : MonoBehaviour
{
    private SubscriptionHandle _handle;

    void OnEnable()  => _handle = this.StartListening();
    void OnDisable() => _handle.Dispose(); // 等价于 this.StopListening()
}

或使用 using 限定作用域

using (subscriber.StartListening())
{
    new DamageEvent { Amount = 10 }.Publish(); // 正常接收
} // 离开 using 块,自动取消订阅

new DamageEvent { Amount = 5 }.Publish(); // 不再接收

也可以直接调用 StopListening()(忽略句柄)

subscriber.StartListening(); // 忽略返回值,与旧写法完全相同

new DamageEvent { Amount = 10 }.Publish();

subscriber.StopListening(); // 手动取消

SubscriptionHandle.Dispose() 是幂等的,多次调用安全。

共享生命周期代码中的可选订阅

StartListening()StopListening() 是严格 API:当运行时类型没有生成订阅者注册表时会抛出异常。如果你在公共基类、UI 生命周期等位置统一处理订阅,而部分实例并没有 [OnEvent] 处理器,请使用 Try API:

private SubscriptionHandle _handle;

void OnEnable()
{
    if (this.TryStartListening(out var handle))
        _handle = handle;
}

void OnDisable()
{
    _handle.Dispose();

    // 或者在不保存句柄时:
    this.TryStopListening();
}

当运行时类型没有生成订阅者注册表时,TryStartListening(out handle)TryStopListening() 返回 false。它们不会隐藏实际事件发布路径的初始化错误;用途是表达“该对象可能不是订阅者”的可选生命周期。

仅订阅某一种事件(当订阅者处理多种事件类型时):

// 只注册 DamageEvent,其他事件类型不受影响
using var handle = subscriber.StartListening<MySubscriber, DamageEvent>();

未取消订阅的订阅者会阻止 GC 回收,请在对象销毁时务必取消。

发布事件

同步发布Publish() 返回 bool,表示事件是否完整派发到所有订阅者(未触发取消传播时始终为 true)。

bool completed = new DamageEvent { Amount = 10 }.Publish();

异步发布PublishAsync() 按优先级顺序依次 await 每个处理器,返回 Task<bool>(未触发取消传播时始终为 true)。

bool completed = await new DamageEvent { Amount = 10 }.PublishAsync();

同步 Publish() 只调用 sync 处理器;PublishAsync() 会调用 sync 与 async 处理器。

事件优先级

通过 [OnEvent(SubscriberPriority.XXX)] 指定优先级,调用顺序在编译期由生成器确定,运行时零排序开销。

优先级从高到低:Primary > High > Medium(默认)> Low > End

public class ShieldSystem
{
    [OnEvent(SubscriberPriority.High)] // 先于默认 Medium 执行
    public bool OnDamage(DamageEvent e)
    {
        if (HasShield) { AbsorbDamage(e.Amount); return false; }
        return true;
    }
}

public class HUDDisplay
{
    [OnEvent] // 默认 Medium,在 ShieldSystem 之后执行
    public void OnDamage(DamageEvent e) => UpdateHealthBar(e.Amount);
}

取消传播

在发布时链式调用 .Cancelable(),此后若某个处理器返回 false,传播立即中止,后续订阅者不再收到本次事件,Publish 返回 false

不调用 Cancelable() 时,所有处理器的返回值被忽略,事件始终完整派发给所有订阅者。

// 带 Cancelable:ShieldSystem(High)返回 false 时,HUDDisplay(Medium)不会收到
bool handled = new DamageEvent { Amount = 10 }
    .Cancelable()
    .Publish();
// handled == false 说明传播被中止

// 不带 Cancelable:所有订阅者都会收到,返回值无效
new DamageEvent { Amount = 10 }.Publish();

发布过滤

以下 API 都是作用在“已配置发布值”上的链式配置,不修改订阅注册状态,可自由组合:

API说明
evt.Cancelable()允许处理器通过返回 false 中止传播
evt.WithFilter(Predicate<object> filter)filter(subscriber) 返回 true 时跳过该订阅者
evt.OnlyType<TEvent, TSubscriber>()TSubscriber 类型的订阅者收到
evt.ExcludeType<TEvent, TSubscriber>()排除 TSubscriber 类型的订阅者
evt.OnlySubscriber(subscriber)仅指定实例收到
evt.ExcludeSubscriber(subscriber)排除指定实例
evt.OnlySubscribers(HashSet<object>)仅集合中的实例收到
evt.ExcludeSubscribers(HashSet<object>)排除集合中的实例

链式配置现在会返回一个携带本次发布配置的 ConfiguredEvent<TEvent>。大多数链式写法无需修改;如果要把 fluent 结果存入变量,应使用 varConfiguredEvent<TEvent>,而不是原始事件类型。

复用同一个 ConfiguredEvent<TEvent> 变量时,会复用其中保存的配置。也就是说,configured.Publish(); configured.Publish(); 会使用同一份过滤 / Cancelable 配置发布两次。

内建 fluent 过滤器,如 OnlySubscriberExcludeSubscriberOnlyTypeExcludeType,在常见的内联规则路径上会保持零分配。WithFilter(Predicate<object>) 继续作为自定义逻辑入口保留;它是否分配取决于调用方传入的 predicate。若传入捕获外部状态的 lambda,例如 obj => obj == target,通常仍会分配。多个自定义 predicate 叠加,或超过当前内联规则容量的内建过滤链,也会退回到 predicate 组合路径并可能产生分配。

// 仅通知 UI 层,不触发游戏逻辑
new DamageEvent { Amount = 5 }
    .OnlyType<DamageEvent, HUDDisplay>()
    .Publish();

// 排除自身,避免收到自己发出的事件
new DamageEvent { Amount = 5 }
    .ExcludeSubscriber(this)
    .Publish();

// 链式组合:可取消 + 仅指定类型
new DamageEvent { Amount = 5 }
    .Cancelable()
    .OnlyType<DamageEvent, ShieldSystem>()
    .Publish();

// 排除多个实例
var exclude = new HashSet<object> { enemyA, enemyB };
new DamageEvent { Amount = 5 }.ExcludeSubscribers(exclude).Publish();

// 如果要保存链式配置后的结果,应保存已配置事件,而不是原始事件 struct
var configured = new DamageEvent { Amount = 5 }.ExcludeSubscriber(this);
configured.Publish();
configured.Publish(); // 会再次使用同一份已保存配置发布

兼容性说明

  • GenEvent 目前仍处于 1.0 之前版本,底层辅助 / 运行时类型仍可能继续演进。
  • 更推荐通过 fluent 发布 API 使用配置,而不是直接构造或长期持有 PublishConfig<TEvent>
  • SubscriptionHandle 现在是轻量值类型。
  • GenEventFilters 会继续保留为兼容性的 predicate 辅助 API,但零分配保证针对的是内建 fluent 发布方法,而不是直接调用这些 predicate helper。

异步支持

将处理方法签名改为返回 TaskTask<bool> 即可定义异步处理器,无需额外配置。

public class NetworkSync
{
    [OnEvent]
    public async Task OnDamage(DamageEvent e)
    {
        await SendToServerAsync(e);
    }
}

// 异步发布:按优先级顺序依次 await 每个处理器
bool completed = await new DamageEvent { Amount = 10 }.PublishAsync();

同一订阅者可以同时定义 sync 和 async 两个处理器处理同一事件,分别由 PublishPublishAsync 触发:

public class CombatLogger
{
    [OnEvent]
    public void OnDamage(DamageEvent e)              // 由 Publish() 触发
    {
        LogToFile(e);
    }

    [OnEvent]
    public async Task OnDamageAsync(DamageEvent e)   // 由 PublishAsync() 触发(注意,同步版本也会被调用)
    {
        await LogToRemoteAsync(e);
    }
}

.NET 基准

仓库内提供了独立的 BenchmarkDotNet 项目 Benchmarks/GenEvent.Benchmarks/,用于做可重复的 .NET 侧性能测量。基准会区分默认 / 内建 fluent 零分配路径与自定义 predicate fallback 路径。

运行完整 benchmark:

dotnet run -c Release --project Benchmarks/GenEvent.Benchmarks/GenEvent.Benchmarks.csproj

只运行单条 benchmark 示例:

dotnet run -c Release --project Benchmarks/GenEvent.Benchmarks/GenEvent.Benchmarks.csproj -- --filter *PublishBenchmarks.Publish_NoSubscribers*

benchmark 结果更适合用于同一台机器、同一套配置下的版本趋势对比;不同机器或不同电源配置下的绝对耗时不宜直接横向比较。

源码生成器约束与诊断

生成器对事件与 [OnEvent] 方法有明确约束,违反时会在编译期报告诊断,不会静默失败。

事件约束

  • 必须是 struct,并实现 IGenEvent<T>

处理器方法约束

  • 必须是 public 方法(实例或 static 均可)
  • 必须有且仅有一个参数,且类型为实现了 IGenEvent<> 的事件类型
  • 返回类型只能是 voidboolTaskTask<bool>
  • 同一 class 对同一事件类型,最多一个 sync handler 和一个 async handler

诊断码

代码严重性含义
GE001Warning未找到 IGenEvent 接口,请检查 GenEvent 引用
GE002Warning未找到 OnEventAttribute,请检查 GenEvent 引用
GE010Error[OnEvent] 方法必须为 public
GE011Error[OnEvent] 方法必须恰好有一个参数
GE012Error[OnEvent] 方法参数必须是 IGenEvent 类型
GE013Error同一 class 对同一事件类型不能有两个同步或两个异步 handler
GE014Error[OnEvent] 方法返回类型必须为 voidboolTaskTask<bool>
GE999Error源码生成器内部异常,请查看编译器输出获取详情

出现 GE001/GE002 时请检查主库与生成器的引用是否均已正确添加;GE010–GE014 按上表修正方法签名;GE999 请查看编译器完整输出。

License

本项目采用 MIT License。Copyright (c) 2026 Puring。详见 LICENSE 文件。