From 4189a6210b9e92099db5ef3189b7af86421439bd Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 11 Jun 2026 14:40:37 +0800 Subject: [PATCH] =?UTF-8?q?perf(editor):=20=E5=85=B3=E9=97=AD=20Domain/Sce?= =?UTF-8?q?ne=20Reload=20=E5=8A=A0=E9=80=9F=E8=BF=9B=E5=85=A5=20Play?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=9F=9F=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=E5=AE=89=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关闭 Domain Reload 后 SO 的 OnEnable/OnDisable 不再于进入/退出 Play 触发、 静态字段不再重置,运行时态会跨 Play 会话残留。补齐重置路径: - 新增 PlayModeResetHook:编辑器内在"进入 Play 前"统一回调各 SO 的重置, 复刻 Domain Reload 曾提供的干净起点;运行时构建为空实现、零开销。 - 静态态用 RuntimeInitializeOnLoadMethod 重置: ServiceLocator._services / SettingsManager.SettingsChanged / EventChainManager.OnChainExecutedInEditor。 - SO 运行时态经 PlayModeResetHook 重置: BaseEventChannelSO(粘性值+订阅委托) / VoidBaseEventChannelSO / WorldStateRegistry(状态字典+变更事件) / InputReaderSO。 - InputReaderSO 增加解绑追踪:跨会话重建前先解绑旧输入回调, 避免 InputActionAsset 存活导致同一输入重复绑定、多次触发。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Scripts/Core/Events/BaseEventChannelSO.cs | 31 ++++++++ .../Scripts/Core/Events/PlayModeResetHook.cs | 56 +++++++++++++ .../Core/Events/PlayModeResetHook.cs.meta | 11 +++ .../Scripts/Core/Events/ServiceLocator.cs | 7 ++ Assets/_Game/Scripts/Core/SettingsManager.cs | 7 ++ .../Scripts/EventChain/EventChainManager.cs | 7 ++ Assets/_Game/Scripts/Input/InputReaderSO.cs | 79 +++++++++++++++---- .../_Game/Scripts/World/WorldStateRegistry.cs | 20 ++++- ProjectSettings/EditorSettings.asset | 6 +- 9 files changed, 202 insertions(+), 22 deletions(-) create mode 100644 Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs create mode 100644 Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs.meta diff --git a/Assets/_Game/Scripts/Core/Events/BaseEventChannelSO.cs b/Assets/_Game/Scripts/Core/Events/BaseEventChannelSO.cs index fbc8404..20add25 100644 --- a/Assets/_Game/Scripts/Core/Events/BaseEventChannelSO.cs +++ b/Assets/_Game/Scripts/Core/Events/BaseEventChannelSO.cs @@ -26,9 +26,24 @@ namespace BaseGames.Core.Events #endif private void OnEnable() + { + ResetRuntimeState(); + // 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。 + PlayModeResetHook.Register(ResetRuntimeState); + } + + /// + /// 重置运行时态:清空粘性缓存值与订阅委托,回到"零订阅、无缓存"的干净起点 + /// (复刻 Domain Reload 每次进入 Play 提供的初始状态)。 + /// + private void ResetRuntimeState() { _hasLastValue = false; _lastValue = default; + _onEventRaisedBacking = null; +#if UNITY_EDITOR + _subscriberCount = 0; +#endif } public event Action OnEventRaised @@ -90,6 +105,22 @@ namespace BaseGames.Core.Events private int _subscriberCount; #endif + private void OnEnable() + { + ResetRuntimeState(); + // 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。 + PlayModeResetHook.Register(ResetRuntimeState); + } + + /// 重置运行时态:清空订阅委托,回到"零订阅"的干净起点。 + private void ResetRuntimeState() + { + _onEventRaisedBacking = null; +#if UNITY_EDITOR + _subscriberCount = 0; +#endif + } + public event Action OnEventRaised { add diff --git a/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs b/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs new file mode 100644 index 0000000..e964d03 --- /dev/null +++ b/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace BaseGames.Core.Events +{ + /// + /// 关闭 Domain Reload(Project Settings → Editor → Enter Play Mode Settings)后, + /// ScriptableObject 资产不再于进入/退出 Play 时被卸载重载,其 OnEnable/OnDisable 也就不再触发, + /// 导致 SO 的运行时态(粘性缓存值、订阅委托、运行时字典等)在多次 Play 会话间残留。 + /// + /// 本工具提供一条可靠的"进入 Play 前重置"通道,复刻 Domain Reload 曾提供的"干净起点": + /// 持有运行时态的 SO 在首次 OnEnable(编辑器加载资产时仍会触发一次)调用 + /// 登记自身的重置方法;此后每次"即将进入 Play"由编辑器事件统一回调全部重置方法。 + /// + /// 运行时构建(非编辑器)下为空实现、零开销;构建中没有 Domain Reload 概念, + /// SO 的 OnEnable 仍照常负责重置,无需本工具介入。 + /// + public static class PlayModeResetHook + { +#if UNITY_EDITOR + // 以委托相等性去重:同一 SO 实例的同一重置方法多次 Register 只保留一份 + // (Delegate 的 Equals/GetHashCode 比较 Target 实例与方法)。 + private static readonly HashSet _resets = new(); + private static bool _subscribed; + + /// 登记一个"进入 Play 前"执行的重置回调(仅编辑器有效)。 + public static void Register(Action reset) + { + if (reset == null) return; + _resets.Add(reset); + if (_subscribed) return; + _subscribed = true; + EditorApplication.playModeStateChanged += OnPlayModeStateChanged; + } + + private static void OnPlayModeStateChanged(PlayModeStateChange change) + { + // 仅在"退出编辑态、即将进入 Play"的瞬间重置,使后续 Awake 执行时各 SO 处于干净起点。 + if (change != PlayModeStateChange.ExitingEditMode) return; + foreach (var reset in _resets) + { + // SO 资产被卸载后其托管对象可能已失效(Unity 重载 == null),跳过避免无谓调用。 + if (reset.Target is UnityEngine.Object uo && uo == null) continue; + try { reset(); } + catch (Exception e) { UnityEngine.Debug.LogException(e); } // 暴露错误而非静默吞掉 + } + } +#else + /// 运行时构建:空实现。SO 的 OnEnable 在构建中照常重置运行时态。 + public static void Register(Action reset) { } +#endif + } +} diff --git a/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs.meta b/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs.meta new file mode 100644 index 0000000..40e0d92 --- /dev/null +++ b/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5cebf2bf42383c74a86dd7138932143c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Scripts/Core/Events/ServiceLocator.cs b/Assets/_Game/Scripts/Core/Events/ServiceLocator.cs index 9d17d3c..ba46461 100644 --- a/Assets/_Game/Scripts/Core/Events/ServiceLocator.cs +++ b/Assets/_Game/Scripts/Core/Events/ServiceLocator.cs @@ -12,6 +12,13 @@ namespace BaseGames.Core { private static readonly Dictionary _services = new(); + /// + /// 关闭 Domain Reload 后静态字段不再于进入 Play 时重置, + /// 残留的服务注册会指向上一次运行已销毁的对象。此钩子在任何 Awake 之前清空注册表。 + /// + [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetOnEnterPlayMode() => _services.Clear(); + /// 以接口类型 TInterface 注册实现 impl。 public static void Register(TInterface impl) => _services[typeof(TInterface)] = impl; diff --git a/Assets/_Game/Scripts/Core/SettingsManager.cs b/Assets/_Game/Scripts/Core/SettingsManager.cs index 73ec248..3ee3b25 100644 --- a/Assets/_Game/Scripts/Core/SettingsManager.cs +++ b/Assets/_Game/Scripts/Core/SettingsManager.cs @@ -27,6 +27,13 @@ namespace BaseGames.Core /// 设置变更后触发(用于 UIScaleApplier、色盲滤镜、Camera Shake 等订阅)。 public static event Action SettingsChanged; + /// + /// 关闭 Domain Reload 后静态事件不再于进入 Play 时清空,残留订阅者会指向上一次运行已销毁的对象。 + /// 此钩子在任何 Awake 之前将其重置为 null。 + /// + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetStaticState() => SettingsChanged = null; + private void Awake() { ServiceLocator.Register(this); diff --git a/Assets/_Game/Scripts/EventChain/EventChainManager.cs b/Assets/_Game/Scripts/EventChain/EventChainManager.cs index cc92df6..7985a74 100644 --- a/Assets/_Game/Scripts/EventChain/EventChainManager.cs +++ b/Assets/_Game/Scripts/EventChain/EventChainManager.cs @@ -79,6 +79,13 @@ namespace BaseGames.EventChain /// EventChainEditorWindow 在 OnEnable 中订阅此事件。 /// public static event Action OnChainExecutedInEditor; + + /// + /// 关闭 Domain Reload 后静态事件不再于进入 Play 时清空,此钩子在任何 Awake 之前将其重置为 null, + /// 避免上一次运行残留的编辑器窗口订阅者累积。 + /// + [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetEditorStaticState() => OnChainExecutedInEditor = null; #endif private readonly HashSet _completedChains = new(); diff --git a/Assets/_Game/Scripts/Input/InputReaderSO.cs b/Assets/_Game/Scripts/Input/InputReaderSO.cs index efefa99..05a88e4 100644 --- a/Assets/_Game/Scripts/Input/InputReaderSO.cs +++ b/Assets/_Game/Scripts/Input/InputReaderSO.cs @@ -71,6 +71,9 @@ namespace BaseGames.Input private InputActionMap _ui; private bool _isBound; private InputAction _jumpAction; + // 记录每次绑定对应的解绑动作。关闭 Domain Reload 后 InputActionAsset 跨会话存活, + // 必须在重新绑定前先解绑旧回调,否则同一输入会被绑定多次、触发多次。 + private readonly List _unbinders = new(); private void EnsureInitialized() { @@ -105,9 +108,20 @@ namespace BaseGames.Input { Debug.Assert(_onPauseRequested != null, "[InputReaderSO] _onPauseRequested 未赋值,请在 Inspector 中指定 EVT_PauseRequested。", this); - // Reset private state on every OnEnable so stale ScriptableObject - // references from a previous Play session don't cause - // 'Map must be contained in state' errors. + ResetRuntimeState(); + // 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。 + PlayModeResetHook.Register(ResetRuntimeState); + } + + /// + /// 重置运行时态并重建输入绑定,回到本次 Play 会话的干净起点: + /// 先解绑上一次会话残留在 InputActionAsset 上的回调(避免跨会话重复绑定导致多次触发), + /// 再清空对外事件订阅者,最后重新初始化并绑定。 + /// + private void ResetRuntimeState() + { + UnbindAll(); + ClearGameplayEvents(); _gameplay = null; _ui = null; _isBound = false; @@ -115,6 +129,31 @@ namespace BaseGames.Input EnsureInitialized(); } + /// 解绑本读取器先前注册到 InputActionAsset 上的全部回调。 + private void UnbindAll() + { + foreach (var unbind in _unbinders) unbind(); + _unbinders.Clear(); + } + + /// 清空所有对外事件的订阅者,回到零订阅起点(复刻 Domain Reload 的初始状态)。 + private void ClearGameplayEvents() + { + MoveEvent = null; + JumpStartedEvent = null; JumpCancelledEvent = null; + AttackEvent = null; ParryEvent = null; DashEvent = null; + UseSpringEvent = null; + SwitchTianHunFormEvent = null; SwitchDiHunFormEvent = null; SwitchMingHunFormEvent = null; + SoulSkillEvent = null; + SpiritSkill1StartedEvent = null; SpiritSkill1CancelledEvent = null; + SpiritSkill2StartedEvent = null; SpiritSkill2CancelledEvent = null; + SpellCastEvent = null; + InteractEvent = null; InteractCancelledEvent = null; + PauseEvent = null; NavigateEvent = null; + SubmitEvent = null; CancelEvent = null; PointEvent = null; + CycleMinimapZoomEvent = null; MapCenterEvent = null; + } + private void OnDisable() { _gameplay?.Disable(); @@ -245,38 +284,46 @@ namespace BaseGames.Input _onPauseRequested?.Raise(); } - private static void BindStarted(InputActionMap map, string name, Action callback) + // 以下绑定方法在挂接回调的同时,把对应的解绑动作记入 _unbinders, + // 供 UnbindAll 在跨会话重建前清理,避免回调在 InputActionAsset 上累积。 + private void BindStarted(InputActionMap map, string name, Action callback) { var action = map.FindAction(name, throwIfNotFound: false); - if (action != null) - { - action.started += _ => callback(); - - } - else + if (action == null) { Debug.LogWarning($"[BindStarted] Action '{name}' not found in map"); + return; } + Action handler = _ => callback(); + action.started += handler; + _unbinders.Add(() => action.started -= handler); } - private static void BindPerformed(InputActionMap map, string name, + private void BindPerformed(InputActionMap map, string name, Action callback) { var action = map.FindAction(name, throwIfNotFound: false); - if (action != null) action.performed += callback; + if (action == null) return; + action.performed += callback; + _unbinders.Add(() => action.performed -= callback); } - private static void BindCanceled(InputActionMap map, string name, + private void BindCanceled(InputActionMap map, string name, Action callback) { var action = map.FindAction(name, throwIfNotFound: false); - if (action != null) action.canceled += callback; + if (action == null) return; + action.canceled += callback; + _unbinders.Add(() => action.canceled -= callback); } - private static void BindCanceled(InputActionMap map, string name, Action callback) + private void BindCanceled(InputActionMap map, string name, Action callback) { var action = map.FindAction(name, throwIfNotFound: false); - if (action != null) action.canceled += _ => callback(); + if (action == null) return; + Action handler = _ => callback(); + action.canceled += handler; + _unbinders.Add(() => action.canceled -= handler); } // ── Rebinding API (P4-3) ────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/World/WorldStateRegistry.cs b/Assets/_Game/Scripts/World/WorldStateRegistry.cs index dd5637f..70b650a 100644 --- a/Assets/_Game/Scripts/World/WorldStateRegistry.cs +++ b/Assets/_Game/Scripts/World/WorldStateRegistry.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using BaseGames.Core; +using BaseGames.Core.Events; using BaseGames.Core.Save; using UnityEngine; @@ -41,10 +42,23 @@ namespace BaseGames.World public event Action OnBatchStateChanged; /// - /// Editor 重新进入 Play Mode 时 ScriptableObject 保留上一次运行的状态, - /// OnEnable 在域重载(Domain Reload)和每次 Play 开始时都会调用,确保状态干净。 + /// ScriptableObject 会在多次 Play 会话间保留运行时状态,进入 Play 前必须清干净。 + /// 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,故同时登记到 PlayModeResetHook, + /// 由编辑器钩子在进入 Play 前兜住重置(构建中仍由 OnEnable 负责)。 /// - private void OnEnable() => _states.Clear(); + private void OnEnable() + { + ResetRuntimeState(); + PlayModeResetHook.Register(ResetRuntimeState); + } + + /// 清空运行时状态字典与变更事件订阅者,回到干净起点。 + private void ResetRuntimeState() + { + _states.Clear(); + OnStateChanged = null; + OnBatchStateChanged = null; + } // ── 泛化 API ───────────────────────────────────────────────────────── /// 检查指定类别中 id 是否已标记。 diff --git a/ProjectSettings/EditorSettings.asset b/ProjectSettings/EditorSettings.asset index 7eeb408..44ec361 100644 --- a/ProjectSettings/EditorSettings.asset +++ b/ProjectSettings/EditorSettings.asset @@ -24,10 +24,10 @@ EditorSettings: m_EnableEditorAsyncCPUTextureLoading: 0 m_AsyncShaderCompilation: 1 m_PrefabModeAllowAutoSave: 1 - m_EnterPlayModeOptionsEnabled: 0 - m_EnterPlayModeOptions: 1 + m_EnterPlayModeOptionsEnabled: 1 + m_EnterPlayModeOptions: 3 m_GameObjectNamingDigits: 1 - m_GameObjectNamingScheme: 0 + m_GameObjectNamingScheme: 2 m_AssetNamingUsesSpace: 1 m_InspectorUseIMGUIDefaultInspector: 0 m_UseLegacyProbeSampleCount: 0