早上好大家。
想与大家分享一个我最近的重要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)。
设置
核心设计简单:
GameEvent(ScriptableObject)作为事件通道。GameEventListener(MonoBehaviour)位于预置件上,侦听 SO,并触发 UnityEvents。- 事件发送者仅需调用
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);
}
为什么这会导致这个工作量大幅放大:
- 零硬依赖:战斗模块不知道 UI 存在。你可以删除面板,任何内容都不会被破坏。
- 设计者友好:你可以拖放一个
OnDeath事件进入 UnityEvent 句柄来触发音效和粒子效果,而不需要触摸一行 C# 代码。 - 预置件隔离:由于局部过滤,因此幽灵预置件独立存在。能够在场景中放下 50 个幽灵,而它们只会针对自身事件做出反应,尽管它们使用了同样的全局 SO 通道。
缺点(坦率说):
这并非个人的解决方案。如果需要追踪事件,你无法使用 F12(转到定义)去查看哪些东西在监听这个事件。最终需要手动构建一个客户编辑器窗口来追踪活跃的监听器,如果项目过大的话。
我已清理核心脚本(Event、Listener、ComponentEvent)并将其放在GitHub下MIT许可证下。如果您在紧耦构建代码或单例地狱上挣扎,在项目中无损尝试这些代码。
GitHub 仓库和设置可视化指南:
https://github.com/MorfiusMatie/Unity-SO-Event-System
我很想听听其他小开发者如何在全球和局部事件中平衡而不去全ECS。
评论 (0)