Article

C# 实战课 03:事件与解耦:实现一个零 GC 的轻量事件总线

如果你做过一点 Unity 项目,很快就会遇到“互相引用地狱”:

  • UI 直接引用玩法脚本
  • 玩法脚本直接引用存档、音效、统计
  • 改一个小功能,牵一串文件

事件总线是一个很有效的解耦手段,但“照搬后端事件系统”会踩坑:

  • 频繁分配导致 GC 抖动
  • 退订不干净造成内存泄漏(对象销毁了,但委托还在)
  • event 泛滥导致难以追踪

这章目标:实现一个Unity 老版本可用、可控、零 GC 的轻量事件总线

本章交付物

  1. EventBus:按事件类型注册回调
  2. UnsubscribeToken:保证退订可控
  3. 事件命名与边界规范:避免“事件滥用”

事件设计:先把事件当成“协议”

事件不是“随便发个消息”,它是模块间协议。

建议事件结构:

  • 名字体现业务:PlayerDied 而不是 OnDead
  • 字段只放必要信息:id、数值、原因
  • 不把大对象塞进去(避免隐式引用)

示例:

public struct PlayerDied
{
    public int PlayerId;
    public int ReasonCode;
}

public struct GoldChanged
{
    public int Delta;
    public int Current;
}

struct 的原因:避免装箱,减少堆分配。

EventBus:按类型存订阅列表

Unity 老版本里我们追求简单可靠,不做反射扫描。

using System;
using System.Collections.Generic;

public sealed class EventBus
{
    private readonly Dictionary<Type, object> _handlers = new Dictionary<Type, object>(64);

    public UnsubscribeToken Subscribe<T>(Action<T> handler) where T : struct
    {
        if (handler == null) throw new ArgumentNullException("handler");

        object boxed;
        if (!_handlers.TryGetValue(typeof(T), out boxed))
        {
            boxed = new HandlerList<T>();
            _handlers[typeof(T)] = boxed;
        }

        var list = (HandlerList<T>)boxed;
        var id = list.Add(handler);
        return new UnsubscribeToken(this, typeof(T), id);
    }

    public void Publish<T>(T evt) where T : struct
    {
        object boxed;
        if (!_handlers.TryGetValue(typeof(T), out boxed)) return;
        ((HandlerList<T>)boxed).Invoke(evt);
    }

    internal void Unsubscribe(Type eventType, int id)
    {
        object boxed;
        if (!_handlers.TryGetValue(eventType, out boxed)) return;

        // 这里不用反射:通过接口下沉一层
        var list = boxed as IHandlerList;
        if (list != null) list.Remove(id);
    }

    private interface IHandlerList
    {
        void Remove(int id);
    }

    private sealed class HandlerList<T> : IHandlerList where T : struct
    {
        private struct Slot
        {
            public int Id;
            public Action<T> Handler;
        }

        private Slot[] _slots = new Slot[8];
        private int _count;
        private int _nextId = 1;

        public int Add(Action<T> handler)
        {
            if (_count == _slots.Length)
            {
                var next = new Slot[_slots.Length * 2];
                Array.Copy(_slots, next, _slots.Length);
                _slots = next;
            }

            var id = _nextId++;
            _slots[_count++] = new Slot { Id = id, Handler = handler };
            return id;
        }

        public void Invoke(T evt)
        {
            // 不用 foreach,避免迭代器分配
            for (int i = 0; i < _count; i++)
            {
                var h = _slots[i].Handler;
                if (h != null) h(evt);
            }
        }

        public void Remove(int id)
        {
            for (int i = 0; i < _count; i++)
            {
                if (_slots[i].Id != id) continue;

                // swap remove:O(1)
                _slots[i] = _slots[_count - 1];
                _slots[_count - 1] = default(Slot);
                _count--;
                return;
            }
        }

        void IHandlerList.Remove(int id) { Remove(id); }
    }
}

这套实现的重点:

  • Publish 不分配
  • 订阅列表用数组,退订用 swap remove
  • 回调列表不依赖 LINQ、不产生迭代器

退订:用 Token 强制生命周期

很多泄漏来自“订阅后忘了退订”。我们用 token 让退订变成显式行为。

using System;

public struct UnsubscribeToken : IDisposable
{
    private readonly EventBus _bus;
    private readonly Type _type;
    private readonly int _id;

    internal UnsubscribeToken(EventBus bus, Type type, int id)
    {
        _bus = bus;
        _type = type;
        _id = id;
    }

    public void Dispose()
    {
        if (_bus == null) return;
        _bus.Unsubscribe(_type, _id);
    }
}

Unity 用法(推荐在 OnEnable/OnDisable 配对):

public sealed class GoldPanel : MonoBehaviour
{
    private EventBus _bus;
    private UnsubscribeToken _token;

    public void Init(EventBus bus)
    {
        _bus = bus;
    }

    private void OnEnable()
    {
        _token = _bus.Subscribe<GoldChanged>(OnGoldChanged);
    }

    private void OnDisable()
    {
        _token.Dispose();
    }

    private void OnGoldChanged(GoldChanged evt)
    {
        // 更新 UI
    }
}

事件边界规范(必须有)

没有规范的事件系统会变成第二个耦合地狱。

建议三条硬规则:

  1. 一个模块只发布“自己域内”的事件。
  2. 事件结构不携带 MonoBehaviour 引用。
  3. UI 只订阅事件,不直接调用玩法核心。

本章验收

  1. UI 不再直接引用玩法脚本,通过事件更新。
  2. 进入/退出界面多次不会导致回调重复执行(退订有效)。
  3. Profiler 中 GC.Alloc 不会因为 Publish/Subscribe 持续增长。

下一章预告

下一章做“Timer/Scheduler”:

  • 把散落的 Update 逻辑收敛成可测试的调度器
  • 支持节流、防抖、延时任务

这会让后续技能冷却、Buff、引导流程更可控。