perf(editor): 关闭 Domain/Scene Reload 加速进入 Play,并补齐域重载安全

关闭 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) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:40:37 +08:00
parent 253a8811fa
commit 4189a6210b
9 changed files with 202 additions and 22 deletions

View File

@@ -26,9 +26,24 @@ namespace BaseGames.Core.Events
#endif #endif
private void OnEnable() private void OnEnable()
{
ResetRuntimeState();
// 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。
PlayModeResetHook.Register(ResetRuntimeState);
}
/// <summary>
/// 重置运行时态:清空粘性缓存值与订阅委托,回到"零订阅、无缓存"的干净起点
/// (复刻 Domain Reload 每次进入 Play 提供的初始状态)。
/// </summary>
private void ResetRuntimeState()
{ {
_hasLastValue = false; _hasLastValue = false;
_lastValue = default; _lastValue = default;
_onEventRaisedBacking = null;
#if UNITY_EDITOR
_subscriberCount = 0;
#endif
} }
public event Action<T> OnEventRaised public event Action<T> OnEventRaised
@@ -90,6 +105,22 @@ namespace BaseGames.Core.Events
private int _subscriberCount; private int _subscriberCount;
#endif #endif
private void OnEnable()
{
ResetRuntimeState();
// 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。
PlayModeResetHook.Register(ResetRuntimeState);
}
/// <summary>重置运行时态:清空订阅委托,回到"零订阅"的干净起点。</summary>
private void ResetRuntimeState()
{
_onEventRaisedBacking = null;
#if UNITY_EDITOR
_subscriberCount = 0;
#endif
}
public event Action OnEventRaised public event Action OnEventRaised
{ {
add add

View File

@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace BaseGames.Core.Events
{
/// <summary>
/// 关闭 Domain ReloadProject Settings → Editor → Enter Play Mode Settings
/// ScriptableObject 资产不再于进入/退出 Play 时被卸载重载,其 OnEnable/OnDisable 也就不再触发,
/// 导致 SO 的运行时态(粘性缓存值、订阅委托、运行时字典等)在多次 Play 会话间残留。
///
/// 本工具提供一条可靠的"进入 Play 前重置"通道,复刻 Domain Reload 曾提供的"干净起点"
/// 持有运行时态的 SO 在首次 OnEnable编辑器加载资产时仍会触发一次调用 <see cref="Register"/>
/// 登记自身的重置方法;此后每次"即将进入 Play"由编辑器事件统一回调全部重置方法。
///
/// 运行时构建(非编辑器)下为空实现、零开销;构建中没有 Domain Reload 概念,
/// SO 的 OnEnable 仍照常负责重置,无需本工具介入。
/// </summary>
public static class PlayModeResetHook
{
#if UNITY_EDITOR
// 以委托相等性去重:同一 SO 实例的同一重置方法多次 Register 只保留一份
// Delegate 的 Equals/GetHashCode 比较 Target 实例与方法)。
private static readonly HashSet<Action> _resets = new();
private static bool _subscribed;
/// <summary>登记一个"进入 Play 前"执行的重置回调(仅编辑器有效)。</summary>
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
/// <summary>运行时构建空实现。SO 的 OnEnable 在构建中照常重置运行时态。</summary>
public static void Register(Action reset) { }
#endif
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5cebf2bf42383c74a86dd7138932143c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -12,6 +12,13 @@ namespace BaseGames.Core
{ {
private static readonly Dictionary<Type, object> _services = new(); private static readonly Dictionary<Type, object> _services = new();
/// <summary>
/// 关闭 Domain Reload 后静态字段不再于进入 Play 时重置,
/// 残留的服务注册会指向上一次运行已销毁的对象。此钩子在任何 Awake 之前清空注册表。
/// </summary>
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetOnEnterPlayMode() => _services.Clear();
/// <summary>以接口类型 TInterface 注册实现 impl。</summary> /// <summary>以接口类型 TInterface 注册实现 impl。</summary>
public static void Register<TInterface>(TInterface impl) public static void Register<TInterface>(TInterface impl)
=> _services[typeof(TInterface)] = impl; => _services[typeof(TInterface)] = impl;

View File

@@ -27,6 +27,13 @@ namespace BaseGames.Core
/// <summary>设置变更后触发(用于 UIScaleApplier、色盲滤镜、Camera Shake 等订阅)。</summary> /// <summary>设置变更后触发(用于 UIScaleApplier、色盲滤镜、Camera Shake 等订阅)。</summary>
public static event Action<GlobalSettingsData> SettingsChanged; public static event Action<GlobalSettingsData> SettingsChanged;
/// <summary>
/// 关闭 Domain Reload 后静态事件不再于进入 Play 时清空,残留订阅者会指向上一次运行已销毁的对象。
/// 此钩子在任何 Awake 之前将其重置为 null。
/// </summary>
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetStaticState() => SettingsChanged = null;
private void Awake() private void Awake()
{ {
ServiceLocator.Register<ISettingsService>(this); ServiceLocator.Register<ISettingsService>(this);

View File

@@ -79,6 +79,13 @@ namespace BaseGames.EventChain
/// EventChainEditorWindow 在 OnEnable 中订阅此事件。 /// EventChainEditorWindow 在 OnEnable 中订阅此事件。
/// </summary> /// </summary>
public static event Action<string, string> OnChainExecutedInEditor; public static event Action<string, string> OnChainExecutedInEditor;
/// <summary>
/// 关闭 Domain Reload 后静态事件不再于进入 Play 时清空,此钩子在任何 Awake 之前将其重置为 null
/// 避免上一次运行残留的编辑器窗口订阅者累积。
/// </summary>
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
private static void ResetEditorStaticState() => OnChainExecutedInEditor = null;
#endif #endif
private readonly HashSet<string> _completedChains = new(); private readonly HashSet<string> _completedChains = new();

View File

@@ -71,6 +71,9 @@ namespace BaseGames.Input
private InputActionMap _ui; private InputActionMap _ui;
private bool _isBound; private bool _isBound;
private InputAction _jumpAction; private InputAction _jumpAction;
// 记录每次绑定对应的解绑动作。关闭 Domain Reload 后 InputActionAsset 跨会话存活,
// 必须在重新绑定前先解绑旧回调,否则同一输入会被绑定多次、触发多次。
private readonly List<Action> _unbinders = new();
private void EnsureInitialized() private void EnsureInitialized()
{ {
@@ -105,9 +108,20 @@ namespace BaseGames.Input
{ {
Debug.Assert(_onPauseRequested != null, Debug.Assert(_onPauseRequested != null,
"[InputReaderSO] _onPauseRequested 未赋值,请在 Inspector 中指定 EVT_PauseRequested。", this); "[InputReaderSO] _onPauseRequested 未赋值,请在 Inspector 中指定 EVT_PauseRequested。", this);
// Reset private state on every OnEnable so stale ScriptableObject ResetRuntimeState();
// references from a previous Play session don't cause // 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。
// 'Map must be contained in state' errors. PlayModeResetHook.Register(ResetRuntimeState);
}
/// <summary>
/// 重置运行时态并重建输入绑定,回到本次 Play 会话的干净起点:
/// 先解绑上一次会话残留在 InputActionAsset 上的回调(避免跨会话重复绑定导致多次触发),
/// 再清空对外事件订阅者,最后重新初始化并绑定。
/// </summary>
private void ResetRuntimeState()
{
UnbindAll();
ClearGameplayEvents();
_gameplay = null; _gameplay = null;
_ui = null; _ui = null;
_isBound = false; _isBound = false;
@@ -115,6 +129,31 @@ namespace BaseGames.Input
EnsureInitialized(); EnsureInitialized();
} }
/// <summary>解绑本读取器先前注册到 InputActionAsset 上的全部回调。</summary>
private void UnbindAll()
{
foreach (var unbind in _unbinders) unbind();
_unbinders.Clear();
}
/// <summary>清空所有对外事件的订阅者,回到零订阅起点(复刻 Domain Reload 的初始状态)。</summary>
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() private void OnDisable()
{ {
_gameplay?.Disable(); _gameplay?.Disable();
@@ -245,38 +284,46 @@ namespace BaseGames.Input
_onPauseRequested?.Raise(); _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); var action = map.FindAction(name, throwIfNotFound: false);
if (action != null) if (action == null)
{
action.started += _ => callback();
}
else
{ {
Debug.LogWarning($"[BindStarted] Action '{name}' not found in map"); Debug.LogWarning($"[BindStarted] Action '{name}' not found in map");
return;
} }
Action<InputAction.CallbackContext> 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<InputAction.CallbackContext> callback) Action<InputAction.CallbackContext> callback)
{ {
var action = map.FindAction(name, throwIfNotFound: false); 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<InputAction.CallbackContext> callback) Action<InputAction.CallbackContext> callback)
{ {
var action = map.FindAction(name, throwIfNotFound: false); 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); var action = map.FindAction(name, throwIfNotFound: false);
if (action != null) action.canceled += _ => callback(); if (action == null) return;
Action<InputAction.CallbackContext> handler = _ => callback();
action.canceled += handler;
_unbinders.Add(() => action.canceled -= handler);
} }
// ── Rebinding API (P4-3) ────────────────────────────────────────────── // ── Rebinding API (P4-3) ──────────────────────────────────────────────

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using BaseGames.Core; using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save; using BaseGames.Core.Save;
using UnityEngine; using UnityEngine;
@@ -41,10 +42,23 @@ namespace BaseGames.World
public event Action<WorldObjectCategory, string[]> OnBatchStateChanged; public event Action<WorldObjectCategory, string[]> OnBatchStateChanged;
/// <summary> /// <summary>
/// Editor 重新进入 Play Mode 时 ScriptableObject 保留上一次运行状态, /// ScriptableObject 会在多次 Play 会话间保留运行状态,进入 Play 前必须清干净。
/// OnEnable 在域重载Domain Reload和每次 Play 开始时都会调用,确保状态干净。 /// 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,故同时登记到 PlayModeResetHook
/// 由编辑器钩子在进入 Play 前兜住重置(构建中仍由 OnEnable 负责)。
/// </summary> /// </summary>
private void OnEnable() => _states.Clear(); private void OnEnable()
{
ResetRuntimeState();
PlayModeResetHook.Register(ResetRuntimeState);
}
/// <summary>清空运行时状态字典与变更事件订阅者,回到干净起点。</summary>
private void ResetRuntimeState()
{
_states.Clear();
OnStateChanged = null;
OnBatchStateChanged = null;
}
// ── 泛化 API ───────────────────────────────────────────────────────── // ── 泛化 API ─────────────────────────────────────────────────────────
/// <summary>检查指定类别中 id 是否已标记。</summary> /// <summary>检查指定类别中 id 是否已标记。</summary>

View File

@@ -24,10 +24,10 @@ EditorSettings:
m_EnableEditorAsyncCPUTextureLoading: 0 m_EnableEditorAsyncCPUTextureLoading: 0
m_AsyncShaderCompilation: 1 m_AsyncShaderCompilation: 1
m_PrefabModeAllowAutoSave: 1 m_PrefabModeAllowAutoSave: 1
m_EnterPlayModeOptionsEnabled: 0 m_EnterPlayModeOptionsEnabled: 1
m_EnterPlayModeOptions: 1 m_EnterPlayModeOptions: 3
m_GameObjectNamingDigits: 1 m_GameObjectNamingDigits: 1
m_GameObjectNamingScheme: 0 m_GameObjectNamingScheme: 2
m_AssetNamingUsesSpace: 1 m_AssetNamingUsesSpace: 1
m_InspectorUseIMGUIDefaultInspector: 0 m_InspectorUseIMGUIDefaultInspector: 0
m_UseLegacyProbeSampleCount: 0 m_UseLegacyProbeSampleCount: 0