跳到主要内容

零反射、零 GC:"高性能"事件系统到底意味着什么

TinyGiants
GES Creator & Unity Games & Tools Developer

Unity Asset Store 上每一个事件系统插件的描述里都写着"高性能"。就夹在"易于使用"和"完整文档"中间。但问题是——1ms 和 0.001ms 对人来说都很快,可一个比另一个慢了一千倍。当一个插件说"高性能"时,到底在说什么?跟什么比?怎么测的?

我以前也不在意这些。大多数人都不在意。接几个事件,游戏在开发机上跑得好好的,发布就完了。但后来我做了一个移动端项目,几百个实体各自监听多个事件,突然"高性能"就不再是营销打勾项了——而是 60 FPS 和幻灯片之间的区别。

这篇文章讲的是"高性能"对于事件系统到底应该意味着什么、为什么大多数实现达不到、以及 GES 如何通过 Expression Tree 编译实现接近零开销。用真实数据说话,不打太极。

度量问题

问你一个问题:上次你 profile 事件系统是什么时候?

不是渲染管线,不是物理,是你的事件系统。就是那个把游戏逻辑连在一起的东西。大多数开发者从来不做这事,因为在小项目里事件开销是隐形的。你有 20 个监听者,每帧可能触发 5 个事件——开销约等于零,profiler 都懒得显示。

但游戏会变大。那个有 20 个监听者的可爱小原型变成了一个有 500 种事件类型、分布在多个场景中的数千个监听者的正式项目。移动端游戏、VR 体验、有大量 AI 实体的游戏——都会到达事件系统开销从"基本免费"变成吃真实帧预算的临界点。

大多数开发者不去测的原因很简单:在配置豪华的桌面端 CPU 上,开销被帧预算的余量掩盖了。只有在面向预算紧张的平台时它才会显现——60 FPS 移动端的 16.67ms、90 FPS Quest VR 的 11.1ms、120 FPS PSVR2 的 8.3ms。在这些平台上,每零点几毫秒都很重要。

没人提的隐藏开销

那到底是什么让一个事件系统慢、另一个快?有四个主要开销类别决定了一个事件系统是高效还是拖后腿。逐个拆解。

开销 #1:反射

这是大头。.NET 中的反射——使用 GetType()GetProperty()GetMethod()Invoke()——比直接方法调用慢 50-1000 倍,取决于具体操作。

更离谱的是:UnityEvent 每次调用都在用反射。不只是初始化阶段——每次触发 UnityEvent,它内部都通过反射来调用目标方法。Unity 这些年一直在优化,但根本性的开销还在。不信的话自己去 profile。打开 deep profiler,触发 UnityEvent 几千次,看 System.Reflection 调用堆积起来。

// What a typical reflection-based event plugin does behind the scenes
public bool EvaluateCondition(ConditionNode node)
{
// Step 1: Get the target component via reflection
var component = target.GetComponent(node.componentType); // Reflection

// Step 2: Get the property/field via reflection
var property = component.GetType().GetProperty(node.propertyName); // Reflection

// Step 3: Get the value via reflection
object value = property.GetValue(component); // Reflection + boxing

// Step 4: Compare via reflection
return CompareValues(value, node.comparisonValue, node.comparisonType); // Unboxing
}

每一步都涉及反射。如果你有一个可视化条件系统在运行时检查属性——"玩家血量是否低于 30?"——它大概率就在做这个,每帧多次,对每个活跃的监听者。

开销 #2:装箱与拆箱

当反射调用返回一个值类型(int、float、bool、Vector3)作为 object 时,.NET 会分配一个小的堆对象来包装它。这就是装箱。反向转换就是拆箱。分配本身很便宜,但它在喂养垃圾回收器。

把数据当 object 传递的事件系统——很多都这么做,为了保持"通用"——每次触发都会装箱所有值类型参数。每帧、每个事件、每个监听者。

开销 #3:GC 分配

这是移动端的隐形杀手。每次触发都分配内存的事件系统会产生垃圾。垃圾累积到 GC 触发回收周期,在 Unity 的 Mono 运行时上会导致可见的卡顿——一个玩家能感知到的帧率跳变。

问题会叠加:触发的事件越多,累积的垃圾越多,GC 跑得越频繁,卡顿越多。这是一个随游戏复杂度增加而恶化的死亡螺旋。在 VR 里,一次 GC 峰值就能导致一个掉帧,让玩家感到恶心。字面意义上的恶心。

开销 #4:字符串匹配

有些事件系统用字符串键来标识事件。"OnPlayerDeath"、"OnEnemySpawned"、"OnHealthChanged"。每次事件触发时,系统要做字符串比较(或者字典查找,这涉及哈希计算)来找到匹配的监听者。

基于字典哈希的字符串查找在事件数量少时够快。但它们阻止了编译器做类型检查,无法安全重命名,而且在构造查找键时会分配内存(子字符串操作、复合键的字符串拼接等)。

GC 问题值得单独说

让我具体说一下为什么 GC 对事件系统尤其重要。

假设一个 60 FPS 运行的游戏,每帧触发 50 个事件。如果每次事件触发哪怕只分配 64 字节(一个装箱的 float、一个临时 delegate、一个小字符串),那就是每帧 3200 字节。听起来很少,对吧?但那是每秒 192KB。Mono GC 的增量收集器在移动端大约每 1-4MB 分配触发一次回收,也就是说每 5-20 秒一次 GC 峰值。每次峰值 1-5ms,60 FPS 下就是一个掉帧。

玩家能感知到这个。测试人员报告"偶尔卡顿"。QA 提了 bug,但没人能稳定复现,因为时机取决于分配模式。是不是很熟悉?

零分配的事件系统直接消灭了这整类问题。不是"减少"——是消灭。零字节分配意味着事件操作的 GC 压力为零,句号。

条件评估问题

真正有意思的地方来了。一个只做回调分发的事件系统要做到快相对容易——原生 C# event/delegate 本身就够快。难的是可视化系统中的条件评估

可视化事件编辑器让策划构建条件树:"当玩家血量低于 30 且处于着地状态,或者有护盾时触发这个响应。"在编辑器里很漂亮。但运行时那些可视化节点需要真正从组件上读取属性并评估比较

Condition Tree

大多数可视化系统用反射来做这件事。每帧、每个活跃条件,系统调用 PropertyInfo.GetValue() 读取当前值,装箱,比较,返回结果。一个中等复杂度的游戏可能每帧评估 20-50 个条件。按我们讨论的反射开销——每次反射调用大约 0.05-0.08ms,每个条件 3-4 次调用——你看的是每帧 3-16ms 光条件检查。

在移动端,这可能就是你整个帧预算,而游戏逻辑还一行都没跑。

"零开销"到底该意味着什么

这是我的定义,我认为它应该成为行业标准:

一个零开销的事件系统,其成本不应超过直接方法调用加上你的监听者实际要做的工作。

也就是说:

  • 触发一个零监听者的事件基本不花时间
  • 每个监听者的分发成本等同于直接调用一个 delegate
  • 条件评估和手写的 if 语句一样快
  • 每帧事件操作的 GC 分配为零字节
  • 运行时不使用反射。句号。

如果一个事件系统达不到这些标准,它就不是"高性能"——只是"在桌面端硬件上还看不出明显慢"。

不应存在的妥协

原生 C# event 给你裸速——直接 delegate 调用,零分配,零反射。但其他什么都不给。没有可视化编辑器,没有条件树,没有 Flow Graph,没有运行时调试工具。只有代码接线代码。

UnityEvent 给你 Inspector 集成、可视化绑定、场景级事件接线。但底层用反射。更慢、有分配、原生不支持复杂条件逻辑。

传统观点是你只能二选一:裸速或可视化便利。鱼与熊掌不可兼得。但这只在你接受反射作为可视化配置到运行时行为的桥梁机制时才成立。

如果你能把可视化配置编译成原生代码呢?

Expression Tree 编译:那座桥

这是 GES 的核心技术创新。GES 不在运行时通过反射解释可视化条件,而是在初始化阶段使用 .NET Expression Tree 将其编译为原生 delegate。

Expression Tree 是一个 .NET 特性(System.Linq.Expressions 命名空间),它允许你把代码表示为数据——一棵表达式节点树——然后通过 JIT 编译器将这棵树编译成可执行的 IL 代码。结果是一个与手写 C# 运行速度相同的 delegate。

概念上的管线是这样的:

可视化条件树 → Expression Tree → IL 代码 → 编译后的 Lambda

简化后的代码是这样的:

// Instead of this (reflection every frame):
object value = propertyInfo.GetValue(target); // Slow. Allocates. Every frame.

// GES builds an Expression Tree at initialization:
var targetParam = Expression.Parameter(typeof(MyComponent), "target");
var propertyAccess = Expression.Property(targetParam, "Health");
var lambda = Expression.Lambda<Func<MyComponent, float>>(propertyAccess, targetParam);

// Compiles it once to a native delegate:
Func<MyComponent, float> getHealth = lambda.Compile();

// Then calls it every frame — zero reflection:
float health = getHealth(myComponent); // Same speed as: myComponent.Health

编译只在初始化时发生一次。之后 getHealth 就是一个 JIT 优化的原生 delegate。它在功能上和直接在源码中写 myComponent.Health 完全一样。没有树遍历,没有解释执行,没有反射。只是一个编译成 IL 的直接属性访问。

完整的编译管线

让我带你走一遍 GES 端到端的实际处理过程。

阶段 1:可视化配置(设计时)

在 GES 编辑器中,策划可视化地构建条件树。每个节点是一个条件——一个属性、一个比较运算符和一个值。节点之间用 AND/OR/NOT 逻辑运算符连接。在这个阶段,一切都是序列化数据,没有代码运行。

阶段 2:Expression Tree 构建(初始化)

当游戏启动或监听者激活时,GES 读取序列化的条件数据并构建 Expression Tree:

// Simplified version of GES internals
private Func<bool> CompileConditionTree(ConditionNodeData rootNode)
{
Expression body = BuildExpression(rootNode);
var lambda = Expression.Lambda<Func<bool>>(body);
return lambda.Compile();
}

private Expression BuildExpression(ConditionNodeData node)
{
if (node.isLogicalOperator)
{
var left = BuildExpression(node.children[0]);
var right = BuildExpression(node.children[1]);

return node.operatorType switch
{
LogicalOp.And => Expression.AndAlso(left, right), // Short-circuit AND
LogicalOp.Or => Expression.OrElse(left, right), // Short-circuit OR
LogicalOp.Not => Expression.Not(left),
_ => throw new InvalidOperationException()
};
}
else
{
var target = Expression.Constant(node.targetComponent);
var property = Expression.Property(target, node.propertyName);
var compareValue = Expression.Constant(node.compareValue);

return node.comparisonType switch
{
Comparison.Equals => Expression.Equal(property, compareValue),
Comparison.GreaterThan => Expression.GreaterThan(property, compareValue),
Comparison.LessThan => Expression.LessThan(property, compareValue),
// ... etc
};
}
}

注意 Expression.AndAlsoExpression.OrElse 的使用——它们编译成短路求值,和 C# 编译器为 &&|| 生成的完全一样。如果 AND 的左侧为 false,右侧根本不会被评估。这在大规模场景下很重要。

阶段 3:IL 编译(一次性开销)

lambda.Compile() 调用会启动 .NET Expression Tree 编译器,发射 IL 字节码并 JIT 编译。这是昂贵的一步——根据复杂度大约 0.1-2ms 每棵树。但它只发生一次。

对于这样一个复杂的条件树:

AND
├── Health < 30
└── OR
├── IsGrounded == true
└── HasShield == true

编译后的 delegate 在功能上等同于:

(health < 30f) && (isGrounded || hasShield)

同样的 IL,同样的性能,同样的短路行为。只是从可视化数据生成的,而不是手写的。

阶段 4:运行时执行(每帧)

运行时,条件评估就是一个 delegate 调用:

if (compiledCondition())  // One call. No reflection. No traversal. No allocation.
{
ExecuteResponse();
}

就这样。整棵可视化条件树——可能有 10 个节点,嵌套的 AND/OR 逻辑和多个属性比较——就是一个 delegate 调用,和等效的手写 if 语句一样快。

实际意味着什么

编译后的条件处理了那些让朴素实现翻车的边缘情况:

空值安全 —— 已销毁组件引用通过编译后的空值守卫检查,不是 try/catch 块。空值检查作为分支指令烘焙进了 delegate。

零装箱 —— 值类型属性(int、float、bool、Vector3)通过编译后的 delegate 直接访问。没有 object 包装,没有堆分配,没有 GC 压力。

深层属性访问 —— 你可以检查 player.Inventory.ActiveWeapon.Damage > 50,整条链在 IL 中编译为顺序的属性加载,和在 C# 中手写完全一样。

硬核基准数据

理论够了。这是来自受控基准测试的实际 GES 性能数据。

事件触发性能

场景耗时GC 分配
事件触发,0 监听者~0.001ms0 bytes
事件触发,1 监听者~0.003ms0 bytes
事件触发,10 监听者~0.02ms0 bytes
事件触发,100 监听者~0.15ms0 bytes
事件触发,1000 监听者~1.2ms0 bytes

全线零 GC 分配。不管监听者数量多少,没有装箱、没有临时对象、没有垃圾压力。

条件评估性能

场景耗时GC 分配
简单条件(1 节点)~0.001ms0 bytes
复杂条件(5 节点,AND/OR)~0.003ms0 bytes
深层条件树(10+ 节点)~0.005ms0 bytes

对比反射方案:5 节点的条件树用 Expression Tree 是 ~0.003ms,用反射是 ~0.75ms。250 倍的提升

Flow Node 执行

场景耗时GC 分配
单个 flow node~0.01ms0 bytes
Flow 链(5 节点)~0.05ms0 bytes
Flow 链(10 节点)~0.09ms0 bytes

Monitor Window(仅编辑器)

场景耗时
Monitor 面板刷新~0.3ms

Monitor Window 是编辑器工具——这个开销只在开发阶段存在,不影响构建。

Monitor Performance

对比:GES vs 其他方案

特性GES原生 C# EventsUnityEvent字符串系统
触发开销(10 监听者)~0.02ms~0.01ms~0.15ms~0.08ms
每次触发 GC0 bytes0 bytes32-128 bytes64-256 bytes
可视化条件编辑器有限看实现
条件评估速度~0.003msN/A(手写代码)N/A~0.5ms(反射)
运行时反射
类型安全完全完全部分
运行时调试工具有限看实现

规律很明显:GES 在匹配原生 C# event 速度的同时,提供了 C# event 无法给你的可视化工具。而在速度和分配两方面都碾压反射方案。

生产环境验证

这些不是在真空里跑的合成基准测试。GES 已经在生产场景中验证过:

  • 500+ 种同时活跃的事件类型
  • 10,000+ 监听者分布在多个场景中
  • 零帧率下降可归因于事件系统
  • 零 GC 峰值来自游戏过程中的事件操作

Stress Test

初始化成本——Expression Tree 编译——通常在场景加载时总计 50-200ms,分散在所有条件树上。这发生在加载画面期间,玩家感知不到。延迟编译意味着条件树在监听者首次激活时才编译,而不是在场景开始时全部编译,所以成本自然分散。

真正有效的扩展策略

知道裸数据是有用的,但知道如何在大规模场景下保持低开销更有用。以下是 GES 为大型项目支持的具体策略。

数据库分区

不要一个巨大的事件注册表,按领域分区:战斗事件、UI 事件、音频事件、AI 事件。每个分区有自己的监听者列表,所以触发战斗事件不会遍历 UI 监听者。这让每次触发的成本保持恒定,不受总监听者数量影响。

条件监听者

不是让每个监听者都检查条件然后大部分返回 false,GES 在调用监听者之前评估条件。条件不通过的监听者直接跳过——没有 delegate 调用,没有函数调用开销。在 100 个监听者存在但只有 5 个条件为 true 的场景下,你只需为 5 次调用付出代价,不是 100 次。

OR 短路求值

编译后的 Expression Tree 使用 OrElse,这会短路求值:如果 OR 组中的第一个条件为 true,剩下的就跳过了。在 OR 分支中把最可能为 true 的条件放在前面可以减少评估工作量。

SetInspectorListenersActive 批量操作

当你需要临时禁用大量监听者时——过场动画、加载过渡、菜单覆盖——使用 SetInspectorListenersActive(false) 而不是逐个切换监听者。这是一个调用就能阻止对组件上所有 Inspector 配置的监听者进行评估,避免了逐个遍历的开销。

Monitor Dashboard 性能分析

在开发阶段使用 GES Monitor Window 来识别热点事件通道——哪些事件触发最频繁、哪些有最多监听者、哪些条件评估最昂贵。然后优先优化这些。

Monitor Dashboard

为什么这对移动端和 VR 尤其重要

让我把具体的帧预算数字摆出来。

移动端(iOS/Android)

移动端 CPU 比桌面端慢 5-10 倍。桌面端 0.5ms 的事件开销到移动端变成 2.5-5ms。60 FPS 目标下(16.67ms 预算),光事件开销就占了 15-30% 的预算。用 GES,同样的工作量在移动端只要 0.02-0.05ms。这个差距就是能不能发布的区别。

VR(Quest, PSVR2)

VR 是帧预算要求最苛刻的平台。Quest 要求 90 FPS(每帧 11.1ms)。PSVR2 目标 120 FPS(每帧 8.3ms)。而 VR 游戏天生就是事件密集型的——手部追踪产生事件、注视追踪产生事件、物理交互产生事件、空间音频触发产生事件。在 VR 里用反射事件系统就是给自己埋了一颗确定会炸的性能地雷。零反射系统让事件层在 profiler 里隐形,而这恰恰是它该待的位置。

移动端的 GC 问题

这个需要特别强调。Unity 在移动端的垃圾回收器(Mono 运行时)是非分代的、全局暂停式的。它跑起来时,一切都冻结。触发回收的阈值不固定,但任何每帧分配都在加速这个周期。在 VR 里,头部追踪期间的一次 GC 暂停会导致可见卡顿,这能诱发晕动症。每帧零垃圾产生的事件系统不是优化——而是 VR 发布的硬性要求。

底线

"高性能"不是一个特性——它是一个可测量的属性。当有人声称他们的事件系统很快时,正确的问题是:

  • N 个监听者时每次触发多少微秒?
  • 每帧分配多少字节?
  • 运行时用反射吗?用在哪?
  • 条件怎么评估?通过反射还是编译代码?

GES 的回答是:亚微秒级触发、零分配、零运行时反射、Expression Tree 编译的条件评估和手写 C# 一样快。

零反射方案不只是性能优化。它是让可视化事件编辑在生产级游戏中可行的基础——不只是在你的开发机上跑得还行但在 Quest 3 上面对 200 个活跃实体就崩溃的原型。当你的事件系统快到你永远不用担心它的性能时,你会更自由地使用它。你会添加更多事件、更多条件、更多监听者而不用担心帧预算。而这种无所畏惧地进行架构设计的自由才是真正让你的游戏变好的东西。

性能不是奢侈品。它是所有其他东西的地基。


🚀 全球开发者服务矩阵

🇨🇳 国区开发者社区

🌐 全球开发者社区

📧 支持与合作