diff --git a/GFramework.Core.Tests/GFramework.Core.Tests.csproj b/GFramework.Core.Tests/GFramework.Core.Tests.csproj index 0973f95..9e5b4cb 100644 --- a/GFramework.Core.Tests/GFramework.Core.Tests.csproj +++ b/GFramework.Core.Tests/GFramework.Core.Tests.csproj @@ -23,6 +23,7 @@ + diff --git a/GFramework.Core.Tests/GlobalUsings.cs b/GFramework.Core.Tests/GlobalUsings.cs index e13a9bc..ae224b7 100644 --- a/GFramework.Core.Tests/GlobalUsings.cs +++ b/GFramework.Core.Tests/GlobalUsings.cs @@ -18,4 +18,8 @@ global using System.Threading; global using System.Threading.Tasks; global using NUnit.Framework; global using NUnit.Compatibility; -global using GFramework.Core.Systems; \ No newline at end of file +global using GFramework.Core.Systems; +global using GFramework.Core.Abstractions.StateManagement; +global using GFramework.Core.Extensions; +global using GFramework.Core.Property; +global using GFramework.Core.StateManagement; \ No newline at end of file diff --git a/GFramework.Core.Tests/StateManagement/StoreTests.cs b/GFramework.Core.Tests/StateManagement/StoreTests.cs index e55f631..ceb3c60 100644 --- a/GFramework.Core.Tests/StateManagement/StoreTests.cs +++ b/GFramework.Core.Tests/StateManagement/StoreTests.cs @@ -1,9 +1,3 @@ -using GFramework.Core.Abstractions.Property; -using GFramework.Core.Abstractions.StateManagement; -using GFramework.Core.Extensions; -using GFramework.Core.Property; -using GFramework.Core.StateManagement; - namespace GFramework.Core.Tests.StateManagement; /// @@ -259,6 +253,137 @@ public class StoreTests Assert.That(store.LastDispatchRecord.NextState.Count, Is.EqualTo(2)); } + /// + /// 测试 reducer 句柄注销后,后续同类型 action 不会再命中该 reducer。 + /// + [Test] + public void RegisterReducerHandle_UnRegister_Should_Stop_Future_Reductions() + { + var store = new Store(new CounterState(0, "Player")); + var reducerHandle = store.RegisterReducerHandle((state, action) => + state with { Count = state.Count + action.Amount }); + + store.Dispatch(new IncrementAction(2)); + reducerHandle.UnRegister(); + store.Dispatch(new IncrementAction(2)); + + Assert.That(store.State.Count, Is.EqualTo(2)); + } + + /// + /// 测试 middleware 句柄注销后,后续 dispatch 不会再经过该中间件。 + /// + [Test] + public void RegisterMiddleware_UnRegister_Should_Stop_Future_Pipeline_Execution() + { + var store = CreateStore(); + var logs = new List(); + var middlewareHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic")); + + store.Dispatch(new IncrementAction(1)); + middlewareHandle.UnRegister(); + store.Dispatch(new IncrementAction(1)); + + Assert.That(store.State.Count, Is.EqualTo(2)); + Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" })); + } + + /// + /// 测试移除同一 action 类型中的某个 reducer 后,其余 reducer 仍保持原有注册顺序。 + /// + [Test] + public void RegisterReducerHandle_UnRegister_Should_Preserve_Remaining_Order() + { + var executionOrder = new List(); + var store = new Store(new CounterState(0, "Player")); + + store.RegisterReducerHandle((state, action) => + { + executionOrder.Add("first"); + return state with { Count = state.Count + action.Amount }; + }); + + var middleReducer = store.RegisterReducerHandle((state, action) => + { + executionOrder.Add("middle"); + return state with { Count = state.Count + action.Amount * 10 }; + }); + + store.RegisterReducerHandle((state, action) => + { + executionOrder.Add("last"); + return state with { Count = state.Count + action.Amount * 100 }; + }); + + middleReducer.UnRegister(); + store.Dispatch(new IncrementAction(1)); + + Assert.That(executionOrder, Is.EqualTo(new[] { "first", "last" })); + Assert.That(store.State.Count, Is.EqualTo(101)); + } + + /// + /// 测试注册句柄的注销操作是幂等的,多次调用不会抛异常或影响其他注册项。 + /// + [Test] + public void RegisterHandles_UnRegister_Should_Be_Idempotent() + { + var logs = new List(); + var store = new Store(new CounterState(0, "Player")); + var reducerHandle = store.RegisterReducerHandle((state, action) => + state with { Count = state.Count + action.Amount }); + var middlewareHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic")); + + Assert.That(() => + { + reducerHandle.UnRegister(); + reducerHandle.UnRegister(); + middlewareHandle.UnRegister(); + middlewareHandle.UnRegister(); + }, Throws.Nothing); + + store.Dispatch(new IncrementAction(1)); + + Assert.That(store.State.Count, Is.EqualTo(0)); + Assert.That(logs, Is.Empty); + } + + /// + /// 测试 dispatch 进行中注销 reducer 和 middleware 时, + /// 当前 dispatch 仍使用开始时的快照,而后续 dispatch 会看到注销结果。 + /// + [Test] + public void UnRegister_During_Dispatch_Should_Affect_Next_Dispatch_But_Not_Current_One() + { + using var entered = new ManualResetEventSlim(false); + using var release = new ManualResetEventSlim(false); + + var logs = new List(); + var store = new Store(new CounterState(0, "Player")); + var reducerHandle = store.RegisterReducerHandle((state, action) => + state with { Count = state.Count + action.Amount }); + var blockingHandle = store.RegisterMiddleware(new BlockingMiddleware(entered, release)); + var recordingHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic")); + + var dispatchTask = Task.Run(() => store.Dispatch(new IncrementAction(1))); + + Assert.That(entered.Wait(TimeSpan.FromSeconds(2)), Is.True, "middleware 未按预期进入阻塞阶段"); + + reducerHandle.UnRegister(); + blockingHandle.UnRegister(); + recordingHandle.UnRegister(); + release.Set(); + + Assert.That(dispatchTask.Wait(TimeSpan.FromSeconds(2)), Is.True, "dispatch 未在释放 middleware 后完成"); + Assert.That(store.State.Count, Is.EqualTo(1), "当前 dispatch 应继续使用启动时抓取的 reducer 快照"); + Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" })); + + store.Dispatch(new IncrementAction(1)); + + Assert.That(store.State.Count, Is.EqualTo(1), "后续 dispatch 应看到 reducer 已被注销"); + Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" })); + } + /// /// 测试未命中的 action 仍会记录诊断信息,但不会改变状态。 /// diff --git a/GFramework.Core/StateManagement/Store.cs b/GFramework.Core/StateManagement/Store.cs index a828535..73cb5c6 100644 --- a/GFramework.Core/StateManagement/Store.cs +++ b/GFramework.Core/StateManagement/Store.cs @@ -33,16 +33,18 @@ public sealed class Store : IStore, IStoreDiagnostics /// /// 已注册的中间件链,按添加顺序执行。 + /// 每个条目都持有稳定身份,便于通过注销句柄精确移除而不影响其他同类中间件。 /// Dispatch 开始时会抓取快照,因此运行中的分发不会受到后续注册变化影响。 /// - private readonly List> _middlewares = []; + private readonly List _middlewares = []; /// /// 按 action 具体运行时类型组织的 reducer 注册表。 /// Store 采用精确类型匹配策略,保证 reducer 执行顺序和行为保持确定性。 + /// 每个 reducer 通过注册条目获得稳定身份,以支持运行时精确注销。 /// Dispatch 开始时会抓取对应 action 类型的 reducer 快照。 /// - private readonly Dictionary> _reducers = []; + private readonly Dictionary> _reducers = []; /// /// 已缓存的局部状态选择视图。 @@ -92,34 +94,6 @@ public sealed class Store : IStore, IStoreDiagnostics _stateComparer = comparer ?? EqualityComparer.Default; } - /// - /// 获取最近一次分发的 action 类型。 - /// - public Type? LastActionType - { - get - { - lock (_lock) - { - return _lastActionType; - } - } - } - - /// - /// 获取最近一次真正改变状态的时间戳。 - /// - public DateTimeOffset? LastStateChangedAt - { - get - { - lock (_lock) - { - return _lastStateChangedAt; - } - } - } - /// /// 获取当前状态快照。 /// @@ -163,9 +137,7 @@ public sealed class Store : IStore, IStoreDiagnostics enteredDispatchScope = true; context = new StoreDispatchContext(action!, _state); stateComparerSnapshot = _stateComparer; - middlewaresSnapshot = _middlewares.Count > 0 - ? _middlewares.ToArray() - : Array.Empty>(); + middlewaresSnapshot = CreateMiddlewareSnapshot(); reducersSnapshot = CreateReducerSnapshot(context.ActionType); } @@ -212,34 +184,6 @@ public sealed class Store : IStore, IStoreDiagnostics } } - /// - /// 获取当前订阅者数量。 - /// - public int SubscriberCount - { - get - { - lock (_lock) - { - return _listeners.Count; - } - } - } - - /// - /// 获取最近一次分发记录。 - /// - public StoreDispatchRecord? LastDispatchRecord - { - get - { - lock (_lock) - { - return _lastDispatchRecord; - } - } - } - /// /// 订阅状态变化通知。 /// @@ -341,6 +285,62 @@ public sealed class Store : IStore, IStoreDiagnostics } } + /// + /// 获取最近一次分发的 action 类型。 + /// + public Type? LastActionType + { + get + { + lock (_lock) + { + return _lastActionType; + } + } + } + + /// + /// 获取最近一次真正改变状态的时间戳。 + /// + public DateTimeOffset? LastStateChangedAt + { + get + { + lock (_lock) + { + return _lastStateChangedAt; + } + } + } + + /// + /// 获取当前订阅者数量。 + /// + public int SubscriberCount + { + get + { + lock (_lock) + { + return _listeners.Count; + } + } + } + + /// + /// 获取最近一次分发记录。 + /// + public StoreDispatchRecord? LastDispatchRecord + { + get + { + lock (_lock) + { + return _lastDispatchRecord; + } + } + } + /// /// 创建一个用于当前状态类型的 Store 构建器。 /// @@ -353,6 +353,7 @@ public sealed class Store : IStore, IStoreDiagnostics /// /// 注册一个强类型 reducer。 /// 同一 action 类型可注册多个 reducer,它们会按照注册顺序依次归约状态。 + /// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 。 /// /// reducer 处理的 action 类型。 /// 要注册的 reducer 实例。 @@ -360,25 +361,13 @@ public sealed class Store : IStore, IStoreDiagnostics /// 时抛出。 public Store RegisterReducer(IReducer reducer) { - ArgumentNullException.ThrowIfNull(reducer); - - lock (_lock) - { - var actionType = typeof(TAction); - if (!_reducers.TryGetValue(actionType, out var reducers)) - { - reducers = []; - _reducers[actionType] = reducers; - } - - reducers.Add(new ReducerAdapter(reducer)); - } - + RegisterReducerHandle(reducer); return this; } /// /// 使用委托快速注册一个 reducer。 + /// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 。 /// /// reducer 处理的 action 类型。 /// 执行归约的委托。 @@ -390,23 +379,86 @@ public sealed class Store : IStore, IStoreDiagnostics return RegisterReducer(new DelegateReducer(reducer)); } + /// + /// 注册一个强类型 reducer,并返回可用于注销该 reducer 的句柄。 + /// 该句柄只会移除当前这次注册,不会影响同一 action 类型下的其他 reducer。 + /// 若在 dispatch 进行中调用注销,当前这次 dispatch 仍会使用开始时抓取的 reducer 快照, + /// 注销仅影响之后的新 dispatch。 + /// + /// reducer 处理的 action 类型。 + /// 要注册的 reducer 实例。 + /// 用于注销当前 reducer 注册的句柄。 + /// 时抛出。 + public IUnRegister RegisterReducerHandle(IReducer reducer) + { + ArgumentNullException.ThrowIfNull(reducer); + + var actionType = typeof(TAction); + var registration = new ReducerRegistration(new ReducerAdapter(reducer)); + + lock (_lock) + { + if (!_reducers.TryGetValue(actionType, out var reducers)) + { + reducers = []; + _reducers[actionType] = reducers; + } + + reducers.Add(registration); + } + + return new DefaultUnRegister(() => UnRegisterReducer(actionType, registration)); + } + + /// + /// 使用委托快速注册一个 reducer,并返回可用于注销该 reducer 的句柄。 + /// 适合测试代码或按场景临时挂载的状态逻辑。 + /// + /// reducer 处理的 action 类型。 + /// 执行归约的委托。 + /// 用于注销当前 reducer 注册的句柄。 + /// 时抛出。 + public IUnRegister RegisterReducerHandle(Func reducer) + { + ArgumentNullException.ThrowIfNull(reducer); + return RegisterReducerHandle(new DelegateReducer(reducer)); + } + /// /// 添加一个 Store 中间件。 /// 中间件按添加顺序包裹 reducer 执行,可用于日志、审计或调试。 + /// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 . /// /// 要添加的中间件实例。 /// 当前 Store 实例,便于链式配置。 /// 时抛出。 public Store UseMiddleware(IStoreMiddleware middleware) + { + RegisterMiddleware(middleware); + return this; + } + + /// + /// 注册一个 Store 中间件,并返回可用于注销该中间件的句柄。 + /// 中间件按注册顺序包裹 reducer 执行;注销只会移除当前这次注册。 + /// 若在 dispatch 进行中调用注销,当前这次 dispatch 仍会使用开始时抓取的中间件快照, + /// 注销仅影响之后的新 dispatch。 + /// + /// 要注册的中间件实例。 + /// 用于注销当前中间件注册的句柄。 + /// 时抛出。 + public IUnRegister RegisterMiddleware(IStoreMiddleware middleware) { ArgumentNullException.ThrowIfNull(middleware); + var registration = new MiddlewareRegistration(middleware); + lock (_lock) { - _middlewares.Add(middleware); + _middlewares.Add(registration); } - return this; + return new DefaultUnRegister(() => UnRegisterMiddleware(registration)); } /// @@ -563,6 +615,27 @@ public sealed class Store : IStore, IStoreDiagnostics return activeListeners.Count > 0 ? activeListeners.ToArray() : Array.Empty>(); } + /// + /// 为当前中间件链创建快照。 + /// Dispatch 在离开状态锁前复制列表,以便后续在锁外执行稳定、不可变的中间件序列。 + /// + /// 当前中间件链的快照;若未注册则返回空数组。 + private IStoreMiddleware[] CreateMiddlewareSnapshot() + { + if (_middlewares.Count == 0) + { + return Array.Empty>(); + } + + var snapshot = new IStoreMiddleware[_middlewares.Count]; + for (var i = 0; i < _middlewares.Count; i++) + { + snapshot[i] = _middlewares[i].Middleware; + } + + return snapshot; + } + /// /// 为当前 action 类型创建 reducer 快照。 /// Dispatch 在离开状态锁前复制列表,以便后续在锁外执行稳定、不可变的 reducer 序列。 @@ -576,7 +649,13 @@ public sealed class Store : IStore, IStoreDiagnostics return Array.Empty(); } - return reducers.ToArray(); + var snapshot = new IStoreReducerAdapter[reducers.Count]; + for (var i = 0; i < reducers.Count; i++) + { + snapshot[i] = reducers[i].Adapter; + } + + return snapshot; } /// @@ -592,6 +671,42 @@ public sealed class Store : IStore, IStoreDiagnostics } } + /// + /// 注销一个中间件注册条目。 + /// 仅精确移除与当前句柄关联的条目,避免误删同一实例的其他重复注册。 + /// + /// 要移除的中间件注册条目。 + private void UnRegisterMiddleware(MiddlewareRegistration registration) + { + lock (_lock) + { + _middlewares.Remove(registration); + } + } + + /// + /// 注销一个 reducer 注册条目。 + /// 若该 action 类型下已无其他 reducer,则同时清理空注册桶,保持注册表紧凑。 + /// + /// reducer 对应的 action 类型。 + /// 要移除的 reducer 注册条目。 + private void UnRegisterReducer(Type actionType, ReducerRegistration registration) + { + lock (_lock) + { + if (!_reducers.TryGetValue(actionType, out var reducers)) + { + return; + } + + reducers.Remove(registration); + if (reducers.Count == 0) + { + _reducers.Remove(actionType); + } + } + } + /// /// 适配不同 action 类型 reducer 的内部统一接口。 /// Store 通过该接口在运行时按 action 具体类型执行 reducer,而不暴露内部装配细节。 @@ -632,6 +747,18 @@ public sealed class Store : IStore, IStoreDiagnostics } } + /// + /// 表示一条 reducer 注册记录。 + /// 该包装对象为运行时注销提供稳定身份,同时不改变 reducer 的执行顺序语义。 + /// + private sealed class ReducerRegistration(IStoreReducerAdapter adapter) + { + /// + /// 获取真正执行归约的内部适配器。 + /// + public IStoreReducerAdapter Adapter { get; } = adapter; + } + /// /// 基于委托的 reducer 适配器实现,便于快速在测试和应用代码中声明 reducer。 /// @@ -656,6 +783,18 @@ public sealed class Store : IStore, IStoreDiagnostics } } + /// + /// 表示一条中间件注册记录。 + /// 通过显式注册对象而不是直接存储中间件实例,可在重复注册同一实例时保持精确注销。 + /// + private sealed class MiddlewareRegistration(IStoreMiddleware middleware) + { + /// + /// 获取注册的中间件实例。 + /// + public IStoreMiddleware Middleware { get; } = middleware; + } + /// /// 表示一个 Store 状态监听订阅。 /// 该对象用于支持初始化回放与正式订阅之间的原子衔接,避免 SubscribeWithInitValue 漏掉状态变化。 diff --git a/docs/zh-CN/core/state-management.md b/docs/zh-CN/core/state-management.md index 3b5afae..db44943 100644 --- a/docs/zh-CN/core/state-management.md +++ b/docs/zh-CN/core/state-management.md @@ -147,6 +147,43 @@ var store = (Store)Store - 测试里快速组装不同配置的 Store - 不希望把 Store 的装配细节散落在多个调用点 +## 运行时临时注册与注销 + +如果某个 reducer 或 middleware 只需要在一段生命周期内生效,例如调试探针、临时玩法规则、 +或场景级模块扩展,可以直接使用 `Store` 提供的句柄式注册 API: + +```csharp +public sealed class LoggingMiddleware : IStoreMiddleware +{ + public void Invoke(StoreDispatchContext context, Action next) + { + Console.WriteLine($"Dispatching: {context.ActionType.Name}"); + next(); + } +} + +var store = new Store(new PlayerState(100, "Player")); + +var reducerHandle = store.RegisterReducerHandle((state, action) => + state with { Health = Math.Max(0, state.Health - action.Amount) }); + +var middlewareHandle = store.RegisterMiddleware(new LoggingMiddleware()); + +store.Dispatch(new DamageAction(10)); + +reducerHandle.UnRegister(); +middlewareHandle.UnRegister(); +``` + +这里有两个重要约束: + +- `RegisterReducerHandle()` 和 `RegisterMiddleware()` 返回的是当前这一次注册的精确注销句柄 +- 如果在某次 `Dispatch()` 已经开始后再调用 `UnRegister()`,当前这次 dispatch 仍会继续使用开始时抓取的快照,注销只影响后续新的 + dispatch + +如果你只需要初始化阶段的链式配置,继续使用 `RegisterReducer()` 和 `UseMiddleware()` 即可; +如果你需要运行时清理,就使用上面的句柄式 API。 + ## 官方示例:角色面板状态 下面给出一个更贴近 GFramework 实战的完整示例,展示如何把 `Store` 放进 Model,