跨场景事件:没人聊但人人踩的持久化问题
目录根本矛盾静态事件幽灵订阅问题实例事件随场景消亡DontDestroyOnLoad 创可贴Bootstrap 场景模式多场景编辑让情况更糟生命周期问题GES 如何解决这个问题ScriptableObject 事件存在于场景之外Behavior Window自动生命周期管理Persistent Listener显式的跨场景存活场景切换时发生了什么逐步持久化的场景配置多数据库动态加载要注意的反模式更大的图景你的AudioManager播放背景音乐。它订阅了OnLevelStart在玩家进入新区域时切换曲目。你把AudioManager放在DontDestroyOnLoad对象上保持跨场景存活。开发期间一切正常因为你一直在同一个场景里测试。然后有人第一次从关卡 1 加载到关卡 2。音乐不切了。AudioManager还活着 ——DontDestroyOnLoad尽职了 —— 但事件订阅没有存活下来。或者更糟旧的订阅还在指向关卡 1 里已经被销毁的事件触发方下次有东西尝试调用它时你会在游戏中途收到一个MissingReferenceException。这就是持久化问题每个有多个场景的 Unity 项目迟早都会撞上。根本矛盾Unity 的场景系统和事件系统建立在对对象生命周期截然不同的假设上。场景是临时的。加载一个场景用它卸载它。场景里的对象与它同生共死。这很干净、可预测也符合玩家体验游戏的方式 —— 去到新区域离开旧区域。事件需要持久性。全局的数据分析系统需要听到每个场景的伤害事件。存档系统需要响应存档点事件不管玩家在哪一关。成就追踪器需要在整个游玩会话中累积数据。这两个模型是矛盾的。而 Unity 并没有给你好的工具来调和它们。静态事件幽灵订阅问题大多数开发者首先尝试的是静态事件publicstaticclassGameEvents{publicstaticeventActionOnLevelStart;publicstaticeventActionintOnPlayerDamaged;publicstaticeventActionOnPlayerDied;}静态事件跨场景加载持久化因为它们存在于类上而非对象上。问题解决了对吧没那么简单。静态事件持久化了但订阅它们的对象没有。场景卸载时场景里的每个 MonoBehaviour 都被销毁。如果其中一个 MonoBehaviour 订阅了静态事件而没在OnDisable或OnDestroy中取消订阅你就有了一个幽灵订阅 —— 一个指向已销毁对象的委托。下次事件触发时MissingReferenceException: The object of type EnemySpawner has been destroyed but you are still trying to access it.修复看似简单永远在OnDisable中取消订阅。但OnDisable在场景切换时有自己的问题后面会讲。而且即使你很自律一个脚本里漏掉一次取消订阅就会造成一个只在场景切换时才显现的 Bug —— 最难复现、最容易在测试中遗漏的那种。静态事件还造成了另一个架构问题一切都是全局的。没有这个事件属于这个场景或这个事件只在这个上下文中有意义的概念。整个项目中的每个系统都能看到并订阅每个事件。对于真正全局的事件如OnApplicationPause还行但对于场景特定的事件如OnDoorOpened或OnPuzzleSolved就一团糟了。实例事件随场景消亡相反的方案 —— MonoBehaviour 上的实例事件publicclassLevelManager:MonoBehaviour{publiceventActionOnLevelStart;publiceventActionOnLevelComplete;}干净且有作用域。只有引用了LevelManager的对象才能订阅。场景卸载时LevelManager被销毁所有订阅跟着消失。没有幽灵引用。但现在跨场景通信不可能了。你的AudioManager活在DontDestroyOnLoad的世界里需要当前场景中LevelManager的引用。怎么获得每次场景加载后FindObjectOfType静态注册表Service Locator每种方案都增加了复杂度和耦合 —— 恰恰是事件本应消除的东西。而且场景卸载后你的AudioManager还持有对已销毁LevelManager的引用。希望你做了 null 检查。DontDestroyOnLoad 创可贴“把事件系统放在DontDestroyOnLoad对象上就好了。”这是最常见的建议而且确实有点用。你创建一个持久化的EventManager持有所有事件标记DontDestroyOnLoad然后所有东西都订阅它。但关于DontDestroyOnLoad人们不会告诉你的是问题 1非 DDOL 对象在场景切换时会触发OnDisable。Unity 卸载场景时场景中的每个 MonoBehaviour 都收到OnDisable和OnDestroy。如果你的监听器在OnDisable中取消订阅理应如此它们就在场景切换期间取消订阅了。你的事件系统瞬间没有监听器了。如果这个窗口期内有东西触发事件没人听得到。问题 2切换期间的执行顺序不保证。新场景加载时OnEnable在所有新 MonoBehaviour 上触发。但什么顺序如果EnemySpawner.OnEnable在LevelManager.OnEnable之前触发而 Spawner 需要订阅 LevelManager 还没初始化的事件你就得到一个 null 引用。在你机器上能用Unity 恰好按正确顺序初始化了。在 QA 的机器上不行。问题 3重复的 DDOL 对象。如果你的持久化EventManager在一个被加载两次的场景里测试时从不同起始场景按 Play 很常见你就有两个EventManager。现在每个事件有两份。一半监听器订阅了一份另一半订阅了另一份。啥都不工作但 Inspector 里一切看起来正常。Bootstrap 场景模式有些团队用Bootstrap场景解决重复问题。游戏总是先加载一个 Bootstrap 场景创建所有持久化管理器然后以 Additive 方式加载实际的游戏场景。这能用但增加了实实在在的复杂度你不能从任何场景直接按 Play 了。必须从 Bootstrap 场景开始或者写编辑器工具在你的测试场景之前自动加载 Bootstrap。加载顺序变得关键。Bootstrap 必须在任何游戏场景访问其系统之前完成初始化。这通常意味着一个加载画面即使加载很快。场景管理变复杂了。你现在在管理 Additive 场景加载意味着要管理哪些场景已加载、哪些正在加载、哪些正在卸载 —— 同时进行。它能用。大量上线的游戏用这个模式。但这是纯粹为了绕过持久化问题而存在的基础设施。是管道工程不是游戏逻辑。多场景编辑让情况更糟Unity 的 Additive 场景加载对大世界很强大 —— 同时加载村庄场景、地形场景和 UI 场景。但它让持久化问题翻倍了。哪个场景拥有哪个事件如果OnShopOpened在村庄场景里OnInventoryChanged在玩家场景里村庄卸载时会怎样OnShopOpened消失了但仍然加载的玩家场景中的对象可能还在监听它。它们现在订阅了一个不存在的东西而且自己不知道。卸载场景本应是干净的。有了跨场景事件引用一点都不干净。生命周期问题让我们精确追踪场景切换时使用事件的完整过程SceneManager.LoadScene(Level2)被调用Unity 开始卸载当前场景当前场景所有 MonoBehaviour 触发OnDisable监听器取消订阅当前场景所有 MonoBehaviour 触发OnDestroy当前场景完全卸载新场景开始加载新场景所有 MonoBehaviour 触发Awake新场景所有 MonoBehaviour 触发OnEnable监听器重新订阅新场景所有 MonoBehaviour 触发Start问题在第 3 步和第 8 步之间的空隙。在这段时间里你的事件系统没有场景级别的监听器。任何 DDOL 对象在这个窗口期触发事件就是在对着虚空喊话。而第 8 步内部的顺序在不同机器或 Unity 版本间不确定。系统 A 可能需要订阅系统 B 初始化的事件。如果 B 的OnEnable在 A 之后执行你就得到一个表现为海森堡 Bug 的竞态条件。需要跨场景持久化的真实系统示例AudioManager—— 必须听到任何场景的OnLevelStart、OnBossFight、OnVictoryAnalyticsManager—— 必须追踪会话中每个场景的事件SaveSystem—— 必须响应OnCheckpointReached不管在哪个场景AchievementTracker—— 必须跨所有场景累积进度数据这些都是必须听到任何场景事件的系统。持久化问题不是学术讨论 —— 它阻挡了真实游戏中的真实功能。GES 如何解决这个问题GES 从架构层面解决持久化问题而不是用变通方案。ScriptableObject 事件存在于场景之外这是关键洞察。在 GES 中事件是 ScriptableObject 资产存在于项目的 Assets 文件夹里 —— 不在任何场景中。它们是项目级别的资源不是场景级别的对象。publicclassAudioManager:MonoBehaviour{[GameEventDropdown,SerializeField]privateSingleGameEventonLevelStart;[GameEventDropdown,SerializeField]privateSingleGameEventonBossFight;}关卡 1 卸载、关卡 2 加载时onLevelStart事件资产哪儿也不去。它不属于任何场景。它存在于项目级别独立于场景生命周期。你的AudioManagerDDOL保持对同一个事件资产的引用。新场景的LevelManager也获得对同一个事件资产的引用。通信就这么通了。不需要静态事件。不需要事件管理器单例。不需要 Bootstrap 场景。ScriptableObject 架构让跨场景通信成为事件存储方式的自然结果而不是你必须特意启用的特殊功能。Behavior Window自动生命周期管理GES 的 Behavior Window 可视化地处理订阅生命周期。当你通过 Behavior Window 绑定监听器时它在OnEnable中自动订阅、在OnDisable中自动取消订阅。不需要手写订阅代码。不可能忘记取消订阅。这意味着场景切换直接就能用旧场景卸载 ——OnDisable触发 —— Behavior Window 自动取消订阅旧监听器新场景加载 ——OnEnable触发 —— Behavior Window 自动订阅新监听器事件资产从未被销毁所以订阅无缝连接到同一个事件没有空隙。没有竞态条件。没有幽灵引用。Persistent Listener显式的跨场景存活对于真正需要跨场景加载持久化的系统 —— 你的AudioManager、你的AnalyticsManager—— GES 提供 Persistent Listener。在代码中使用AddPersistentListenerpublicclassAudioManager:MonoBehaviour{[GameEventDropdown,SerializeField]privateSingleGameEventonLevelStart;privatevoidOnEnable(){onLevelStart.AddPersistentListener(HandleLevelStart);}privatevoidOnDestroy(){onLevelStart.RemovePersistentListener(HandleLevelStart);}privatevoidHandleLevelStart(stringlevelName){// Change music based on level}}Persistent Listener 存储在与普通监听器分离的层中。它们能存活过场景切换因为事件是 ScriptableObject存在于场景之外监听器在 DDOL 对象上存活过切换Persistent 注册显式告诉事件系统跨加载保留这个在 Behavior Window 里有一个Persistent 复选框—— 就是AddPersistentListener的可视化等价物。勾上它该绑定就能存活过场景切换不需要任何代码。场景切换时发生了什么逐步之前同样的切换追踪但这次用 GESSceneManager.LoadScene(Level2)被调用Unity 开始卸载关卡 1关卡 1 MonoBehaviour 触发OnDisable—— Behavior Window 自动取消订阅它们的监听器关卡 1 MonoBehaviour 触发OnDestroy关卡 1 完全卸载事件资产完好无损—— 它们是 ScriptableObject不是场景对象Persistent Listener 完好无损—— 它们注册在 DDOL 对象上关卡 2 开始加载关卡 2 MonoBehaviour 触发OnEnable—— Behavior Window 自动订阅它们的监听器关卡 2 MonoBehaviour 触发Start关键区别第 5 步和第 9 步之间事件系统不是空的。Persistent Listener 仍然活跃。如果 DDOL 系统在加载期间触发事件Persistent Listener 能听到。场景特定的监听器消失了这是对的但全局系统从未丢失连接。持久化的场景配置场景配置很直观你的持久化管理器活在 DDOL 对象上使用 Persistent Listener 绑定。场景特定对象使用普通的 Behavior Window 绑定。事件资产放在任何场景都能访问的共享数据库中。多数据库动态加载对于有很多场景的大型项目GES 支持多个事件数据库。你可以按上下文组织事件核心数据库—— 启动时加载的全局事件OnApplicationPause、OnSaveRequested、OnAchievementUnlocked战斗数据库—— 战斗场景活跃时加载OnDamageDealt、OnEnemyDefeatedUI 数据库—— 随 UI 场景加载OnMenuOpened、OnSettingsChanged场景特定的数据库随场景一起加载和卸载。核心数据库始终保持加载。未加载数据库中的事件变为非活跃 —— 它们不会触发尝试 Raise 是空操作而不是报错。这给了你静态事件所缺乏的作用域“这个事件只在这个场景加载时存在”又没有实例事件的脆弱性“这个事件在这个对象死亡时消失”。要注意的反模式一个要避免的错误忘了在OnDestroy中移除 Persistent Listener。// BAD - persistent listener leaks if this object is destroyedprivatevoidOnEnable(){onLevelStart.AddPersistentListener(HandleLevelStart);}// GOOD - clean up in OnDestroy for DDOL objectsprivatevoidOnDestroy(){onLevelStart.RemovePersistentListener(HandleLevelStart);}普通监听器在OnDisable中取消订阅。Persistent Listener 应该在OnDestroy中取消订阅 —— 因为 Persistent Listener 的全部意义就是在场景切换时的OnDisable中存活下来。如果你把移除放在OnDisable就违背了初衷。GES 的 Runtime Monitor特别是 Warnings 标签页会标记注册在非DontDestroyOnLoad对象上的 Persistent Listener。这几乎总是 Bug —— 你告诉事件系统跨场景加载保留这个监听器但对象本身活不过加载。更大的图景跨场景持久化不只是一个技术问题 —— 它是一个影响整个项目结构的架构决策。错误的选择会级联成单例、Service Locator、Bootstrap 场景、加载顺序依赖、和散布在每个脚本中的防御性 null 检查。GES 的方案 —— ScriptableObject 事件加显式持久化控制 —— 意味着你不必在一切全局化和什么都不能跨场景之间二选一。事件存在于项目级别。监听器根据自身需求选择持久化方式。常见情况自动处理生命周期特殊情况显式控制。你的AudioManager用 Persistent Listener 订阅一次就能在整个会话中听到每个场景的事件。你的EnemySpawner通过 Behavior Window 订阅场景卸载时自动断开下一个场景自动重连。两种模式在同一个事件上共存。不需要特殊配置。不需要 Bootstrap 场景。没有竞态条件。 全球开发者服务矩阵 Game Event System(GES) 主页 国区开发者社区 Unity 中国资产商店 B站官方视频教程 高性能架构技术文档 国内技术交流群 (1071507578) 全球开发者社区 Unity Global Asset Store Discord 全球技术社区 YouTube 官方频道 Unity 官方论坛专贴 GitHub 官方主页欢迎感兴趣的小伙伴前来围观~
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2493086.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!