早上好大家。

想与大家分享一个我最近的重要architecture决定。像不少单一开发者的项目,通常从干净利落开始后逐渐变成复杂而糟糕的紧密依赖关系。典型的例子如下:Player.cs受到伤害,需要更新UI,因此会调用UIManager.Instance.UpdateHealth()

突然间,玩家预置件(prefab)与UI深度耦合。在测试场景中加载一个空白场景以调整移动,但游戏会抛出空引用异常,因为UI Manager丢失了。

为了解决这个问题,我看了下纯粹的Entity-Component-System(ECS),但诚实说,代价(bootloader和学习曲)太大而不值得。我转向使用 ScriptableObject 驱动的事件通道(event channels)。

这并非新概念(2017年 Ryan Hipple 在 Unite 演讲中介绍了其基础原理),但我想与大家分享我如何解决 ScriptableObject 事件最主要的缺陷:全局噪音(global noise)。

设置

核心设计简单:

  1. GameEvent(ScriptableObject)作为事件通道。
  2. GameEventListener(MonoBehaviour)位于预置件上,侦听 SO,并触发 UnityEvents。
  3. 事件发送者仅需调用 myEvent.Raise(this),其无需知道谁在监听。

问题:全球事件混乱

SO 事件的最直接问题是,事件太全球化。如果场景中有10只幽灵,幽灵A受到伤害,则会激发 OnTakeDamage SO 事件。但是,幽灵B的UI也在监听这个相同的SO。突然间,整个屏幕上的所有幽灵都会闪烁。

大多数开发者则通过在运行时为每个敌人创建独特的SO实例来解决这个问题。这是一个管理内存的噩梦。

解决方案:局部分数过滤

不再实例化新的SO,而是保留了全局通道的同时添加了空间过滤到监听器。

当事件触发时,广播器会将其自身作为 sender:public void Raise(Component sender)

GameEventListener 添加了一个简单的开关:onlyFromThisObject。如果此开关为真,监听器将检查 sender 是否是其本地预置件的子树:

C#

if (binding.onlyFromThisObject) {
    if (filterRoot == null || sender == null || (sender.transform != filterRoot && !sender.transform.IsChildOf(filterRoot))) {
        continue; //ignore global noise,这个事件不是我们的
    }
}
binding.response?.Invoke(sender);
}

为什么这会导致这个工作量大幅放大:

  1. 零硬依赖:战斗模块不知道 UI 存在。你可以删除面板,任何内容都不会被破坏。
  2. 设计者友好:你可以拖放一个 OnDeath 事件进入 UnityEvent 句柄来触发音效和粒子效果,而不需要触摸一行 C# 代码。
  3. 预置件隔离:由于局部过滤,因此幽灵预置件独立存在。能够在场景中放下 50 个幽灵,而它们只会针对自身事件做出反应,尽管它们使用了同样的全局 SO 通道。

缺点(坦率说):

这并非个人的解决方案。如果需要追踪事件,你无法使用 F12(转到定义)去查看哪些东西在监听这个事件。最终需要手动构建一个客户编辑器窗口来追踪活跃的监听器,如果项目过大的话。

我已清理核心脚本(Event、Listener、ComponentEvent)并将其放在GitHub下MIT许可证下。如果您在紧耦构建代码或单例地狱上挣扎,在项目中无损尝试这些代码。

GitHub 仓库和设置可视化指南:

https://github.com/MorfiusMatie/Unity-SO-Event-System

我很想听听其他小开发者如何在全球和局部事件中平衡而不去全ECS。