如果你做过一点 Unity 项目,很快就会遇到“互相引用地狱”:
- UI 直接引用玩法脚本
- 玩法脚本直接引用存档、音效、统计
- 改一个小功能,牵一串文件
事件总线是一个很有效的解耦手段,但“照搬后端事件系统”会踩坑:
- 频繁分配导致 GC 抖动
- 退订不干净造成内存泄漏(对象销毁了,但委托还在)
- event 泛滥导致难以追踪
这章目标:实现一个Unity 老版本可用、可控、零 GC 的轻量事件总线。
本章交付物
EventBus:按事件类型注册回调UnsubscribeToken:保证退订可控事件命名与边界规范:避免“事件滥用”
事件设计:先把事件当成“协议”
事件不是“随便发个消息”,它是模块间协议。
建议事件结构:
- 名字体现业务:
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
}
}
事件边界规范(必须有)
没有规范的事件系统会变成第二个耦合地狱。
建议三条硬规则:
- 一个模块只发布“自己域内”的事件。
- 事件结构不携带 MonoBehaviour 引用。
- UI 只订阅事件,不直接调用玩法核心。
本章验收
- UI 不再直接引用玩法脚本,通过事件更新。
- 进入/退出界面多次不会导致回调重复执行(退订有效)。
- Profiler 中
GC.Alloc不会因为 Publish/Subscribe 持续增长。
下一章预告
下一章做“Timer/Scheduler”:
- 把散落的
Update逻辑收敛成可测试的调度器 - 支持节流、防抖、延时任务
这会让后续技能冷却、Buff、引导流程更可控。