feat(state): 添加运行时临时注册与注销功能

- 实现 RegisterReducerHandle 和 RegisterMiddleware 方法,支持获取注销句柄
- 添加 IUnRegister 接口和 DefaultUnRegister 实现,提供精确注销能力
- 修改内部数据结构,使用 Registration 包装对象确保注销时的身份稳定性
- 实现中间件和 reducer 的快照机制,确保运行中注销不影响当前 dispatch
- 添加相关单元测试验证运行时注册注销的正确性
- 更新文档说明运行时临时注册与注销的使用方式和约束条件
This commit is contained in:
GeWuYou 2026-03-23 20:38:46 +08:00
parent b6ef6278c0
commit 2c00070bb1
5 changed files with 391 additions and 85 deletions

View File

@ -23,6 +23,7 @@
<ItemGroup>
<PackageReference Include="Scriban" Version="6.6.0" />
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/>
<ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"/>

View File

@ -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;
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;

View File

@ -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;
/// <summary>
@ -259,6 +253,137 @@ public class StoreTests
Assert.That(store.LastDispatchRecord.NextState.Count, Is.EqualTo(2));
}
/// <summary>
/// 测试 reducer 句柄注销后,后续同类型 action 不会再命中该 reducer。
/// </summary>
[Test]
public void RegisterReducerHandle_UnRegister_Should_Stop_Future_Reductions()
{
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((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));
}
/// <summary>
/// 测试 middleware 句柄注销后,后续 dispatch 不会再经过该中间件。
/// </summary>
[Test]
public void RegisterMiddleware_UnRegister_Should_Stop_Future_Pipeline_Execution()
{
var store = CreateStore();
var logs = new List<string>();
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" }));
}
/// <summary>
/// 测试移除同一 action 类型中的某个 reducer 后,其余 reducer 仍保持原有注册顺序。
/// </summary>
[Test]
public void RegisterReducerHandle_UnRegister_Should_Preserve_Remaining_Order()
{
var executionOrder = new List<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
store.RegisterReducerHandle<IncrementAction>((state, action) =>
{
executionOrder.Add("first");
return state with { Count = state.Count + action.Amount };
});
var middleReducer = store.RegisterReducerHandle<IncrementAction>((state, action) =>
{
executionOrder.Add("middle");
return state with { Count = state.Count + action.Amount * 10 };
});
store.RegisterReducerHandle<IncrementAction>((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));
}
/// <summary>
/// 测试注册句柄的注销操作是幂等的,多次调用不会抛异常或影响其他注册项。
/// </summary>
[Test]
public void RegisterHandles_UnRegister_Should_Be_Idempotent()
{
var logs = new List<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((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);
}
/// <summary>
/// 测试 dispatch 进行中注销 reducer 和 middleware 时,
/// 当前 dispatch 仍使用开始时的快照,而后续 dispatch 会看到注销结果。
/// </summary>
[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<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((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" }));
}
/// <summary>
/// 测试未命中的 action 仍会记录诊断信息,但不会改变状态。
/// </summary>

View File

@ -33,16 +33,18 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
/// <summary>
/// 已注册的中间件链,按添加顺序执行。
/// 每个条目都持有稳定身份,便于通过注销句柄精确移除而不影响其他同类中间件。
/// Dispatch 开始时会抓取快照,因此运行中的分发不会受到后续注册变化影响。
/// </summary>
private readonly List<IStoreMiddleware<TState>> _middlewares = [];
private readonly List<MiddlewareRegistration> _middlewares = [];
/// <summary>
/// 按 action 具体运行时类型组织的 reducer 注册表。
/// Store 采用精确类型匹配策略,保证 reducer 执行顺序和行为保持确定性。
/// 每个 reducer 通过注册条目获得稳定身份,以支持运行时精确注销。
/// Dispatch 开始时会抓取对应 action 类型的 reducer 快照。
/// </summary>
private readonly Dictionary<Type, List<IStoreReducerAdapter>> _reducers = [];
private readonly Dictionary<Type, List<ReducerRegistration>> _reducers = [];
/// <summary>
/// 已缓存的局部状态选择视图。
@ -92,34 +94,6 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
_stateComparer = comparer ?? EqualityComparer<TState>.Default;
}
/// <summary>
/// 获取最近一次分发的 action 类型。
/// </summary>
public Type? LastActionType
{
get
{
lock (_lock)
{
return _lastActionType;
}
}
}
/// <summary>
/// 获取最近一次真正改变状态的时间戳。
/// </summary>
public DateTimeOffset? LastStateChangedAt
{
get
{
lock (_lock)
{
return _lastStateChangedAt;
}
}
}
/// <summary>
/// 获取当前状态快照。
/// </summary>
@ -163,9 +137,7 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
enteredDispatchScope = true;
context = new StoreDispatchContext<TState>(action!, _state);
stateComparerSnapshot = _stateComparer;
middlewaresSnapshot = _middlewares.Count > 0
? _middlewares.ToArray()
: Array.Empty<IStoreMiddleware<TState>>();
middlewaresSnapshot = CreateMiddlewareSnapshot();
reducersSnapshot = CreateReducerSnapshot(context.ActionType);
}
@ -212,34 +184,6 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
}
}
/// <summary>
/// 获取当前订阅者数量。
/// </summary>
public int SubscriberCount
{
get
{
lock (_lock)
{
return _listeners.Count;
}
}
}
/// <summary>
/// 获取最近一次分发记录。
/// </summary>
public StoreDispatchRecord<TState>? LastDispatchRecord
{
get
{
lock (_lock)
{
return _lastDispatchRecord;
}
}
}
/// <summary>
/// 订阅状态变化通知。
/// </summary>
@ -341,6 +285,62 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
}
}
/// <summary>
/// 获取最近一次分发的 action 类型。
/// </summary>
public Type? LastActionType
{
get
{
lock (_lock)
{
return _lastActionType;
}
}
}
/// <summary>
/// 获取最近一次真正改变状态的时间戳。
/// </summary>
public DateTimeOffset? LastStateChangedAt
{
get
{
lock (_lock)
{
return _lastStateChangedAt;
}
}
}
/// <summary>
/// 获取当前订阅者数量。
/// </summary>
public int SubscriberCount
{
get
{
lock (_lock)
{
return _listeners.Count;
}
}
}
/// <summary>
/// 获取最近一次分发记录。
/// </summary>
public StoreDispatchRecord<TState>? LastDispatchRecord
{
get
{
lock (_lock)
{
return _lastDispatchRecord;
}
}
}
/// <summary>
/// 创建一个用于当前状态类型的 Store 构建器。
/// </summary>
@ -353,6 +353,7 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
/// <summary>
/// 注册一个强类型 reducer。
/// 同一 action 类型可注册多个 reducer它们会按照注册顺序依次归约状态。
/// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 <see cref="RegisterReducerHandle{TAction}(IReducer{TState, TAction})"/>。
/// </summary>
/// <typeparam name="TAction">reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">要注册的 reducer 实例。</param>
@ -360,25 +361,13 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
/// <exception cref="ArgumentNullException">当 <paramref name="reducer"/> 为 <see langword="null"/> 时抛出。</exception>
public Store<TState> RegisterReducer<TAction>(IReducer<TState, TAction> 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<TAction>(reducer));
}
RegisterReducerHandle(reducer);
return this;
}
/// <summary>
/// 使用委托快速注册一个 reducer。
/// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 <see cref="RegisterReducerHandle{TAction}(Func{TState, TAction, TState})"/>。
/// </summary>
/// <typeparam name="TAction">reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">执行归约的委托。</param>
@ -390,23 +379,86 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
return RegisterReducer(new DelegateReducer<TAction>(reducer));
}
/// <summary>
/// 注册一个强类型 reducer并返回可用于注销该 reducer 的句柄。
/// 该句柄只会移除当前这次注册,不会影响同一 action 类型下的其他 reducer。
/// 若在 dispatch 进行中调用注销,当前这次 dispatch 仍会使用开始时抓取的 reducer 快照,
/// 注销仅影响之后的新 dispatch。
/// </summary>
/// <typeparam name="TAction">reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">要注册的 reducer 实例。</param>
/// <returns>用于注销当前 reducer 注册的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="reducer"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister RegisterReducerHandle<TAction>(IReducer<TState, TAction> reducer)
{
ArgumentNullException.ThrowIfNull(reducer);
var actionType = typeof(TAction);
var registration = new ReducerRegistration(new ReducerAdapter<TAction>(reducer));
lock (_lock)
{
if (!_reducers.TryGetValue(actionType, out var reducers))
{
reducers = [];
_reducers[actionType] = reducers;
}
reducers.Add(registration);
}
return new DefaultUnRegister(() => UnRegisterReducer(actionType, registration));
}
/// <summary>
/// 使用委托快速注册一个 reducer并返回可用于注销该 reducer 的句柄。
/// 适合测试代码或按场景临时挂载的状态逻辑。
/// </summary>
/// <typeparam name="TAction">reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">执行归约的委托。</param>
/// <returns>用于注销当前 reducer 注册的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="reducer"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister RegisterReducerHandle<TAction>(Func<TState, TAction, TState> reducer)
{
ArgumentNullException.ThrowIfNull(reducer);
return RegisterReducerHandle(new DelegateReducer<TAction>(reducer));
}
/// <summary>
/// 添加一个 Store 中间件。
/// 中间件按添加顺序包裹 reducer 执行,可用于日志、审计或调试。
/// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 <see cref="RegisterMiddleware"/>.
/// </summary>
/// <param name="middleware">要添加的中间件实例。</param>
/// <returns>当前 Store 实例,便于链式配置。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="middleware"/> 为 <see langword="null"/> 时抛出。</exception>
public Store<TState> UseMiddleware(IStoreMiddleware<TState> middleware)
{
RegisterMiddleware(middleware);
return this;
}
/// <summary>
/// 注册一个 Store 中间件,并返回可用于注销该中间件的句柄。
/// 中间件按注册顺序包裹 reducer 执行;注销只会移除当前这次注册。
/// 若在 dispatch 进行中调用注销,当前这次 dispatch 仍会使用开始时抓取的中间件快照,
/// 注销仅影响之后的新 dispatch。
/// </summary>
/// <param name="middleware">要注册的中间件实例。</param>
/// <returns>用于注销当前中间件注册的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="middleware"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister RegisterMiddleware(IStoreMiddleware<TState> middleware)
{
ArgumentNullException.ThrowIfNull(middleware);
var registration = new MiddlewareRegistration(middleware);
lock (_lock)
{
_middlewares.Add(middleware);
_middlewares.Add(registration);
}
return this;
return new DefaultUnRegister(() => UnRegisterMiddleware(registration));
}
/// <summary>
@ -563,6 +615,27 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
return activeListeners.Count > 0 ? activeListeners.ToArray() : Array.Empty<Action<TState>>();
}
/// <summary>
/// 为当前中间件链创建快照。
/// Dispatch 在离开状态锁前复制列表,以便后续在锁外执行稳定、不可变的中间件序列。
/// </summary>
/// <returns>当前中间件链的快照;若未注册则返回空数组。</returns>
private IStoreMiddleware<TState>[] CreateMiddlewareSnapshot()
{
if (_middlewares.Count == 0)
{
return Array.Empty<IStoreMiddleware<TState>>();
}
var snapshot = new IStoreMiddleware<TState>[_middlewares.Count];
for (var i = 0; i < _middlewares.Count; i++)
{
snapshot[i] = _middlewares[i].Middleware;
}
return snapshot;
}
/// <summary>
/// 为当前 action 类型创建 reducer 快照。
/// Dispatch 在离开状态锁前复制列表,以便后续在锁外执行稳定、不可变的 reducer 序列。
@ -576,7 +649,13 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
return Array.Empty<IStoreReducerAdapter>();
}
return reducers.ToArray();
var snapshot = new IStoreReducerAdapter[reducers.Count];
for (var i = 0; i < reducers.Count; i++)
{
snapshot[i] = reducers[i].Adapter;
}
return snapshot;
}
/// <summary>
@ -592,6 +671,42 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
}
}
/// <summary>
/// 注销一个中间件注册条目。
/// 仅精确移除与当前句柄关联的条目,避免误删同一实例的其他重复注册。
/// </summary>
/// <param name="registration">要移除的中间件注册条目。</param>
private void UnRegisterMiddleware(MiddlewareRegistration registration)
{
lock (_lock)
{
_middlewares.Remove(registration);
}
}
/// <summary>
/// 注销一个 reducer 注册条目。
/// 若该 action 类型下已无其他 reducer则同时清理空注册桶保持注册表紧凑。
/// </summary>
/// <param name="actionType">reducer 对应的 action 类型。</param>
/// <param name="registration">要移除的 reducer 注册条目。</param>
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);
}
}
}
/// <summary>
/// 适配不同 action 类型 reducer 的内部统一接口。
/// Store 通过该接口在运行时按 action 具体类型执行 reducer而不暴露内部装配细节。
@ -632,6 +747,18 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
}
}
/// <summary>
/// 表示一条 reducer 注册记录。
/// 该包装对象为运行时注销提供稳定身份,同时不改变 reducer 的执行顺序语义。
/// </summary>
private sealed class ReducerRegistration(IStoreReducerAdapter adapter)
{
/// <summary>
/// 获取真正执行归约的内部适配器。
/// </summary>
public IStoreReducerAdapter Adapter { get; } = adapter;
}
/// <summary>
/// 基于委托的 reducer 适配器实现,便于快速在测试和应用代码中声明 reducer。
/// </summary>
@ -656,6 +783,18 @@ public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
}
}
/// <summary>
/// 表示一条中间件注册记录。
/// 通过显式注册对象而不是直接存储中间件实例,可在重复注册同一实例时保持精确注销。
/// </summary>
private sealed class MiddlewareRegistration(IStoreMiddleware<TState> middleware)
{
/// <summary>
/// 获取注册的中间件实例。
/// </summary>
public IStoreMiddleware<TState> Middleware { get; } = middleware;
}
/// <summary>
/// 表示一个 Store 状态监听订阅。
/// 该对象用于支持初始化回放与正式订阅之间的原子衔接,避免 SubscribeWithInitValue 漏掉状态变化。

View File

@ -147,6 +147,43 @@ var store = (Store<PlayerState>)Store<PlayerState>
- 测试里快速组装不同配置的 Store
- 不希望把 Store 的装配细节散落在多个调用点
## 运行时临时注册与注销
如果某个 reducer 或 middleware 只需要在一段生命周期内生效,例如调试探针、临时玩法规则、
或场景级模块扩展,可以直接使用 `Store<TState>` 提供的句柄式注册 API
```csharp
public sealed class LoggingMiddleware<TState> : IStoreMiddleware<TState>
{
public void Invoke(StoreDispatchContext<TState> context, Action next)
{
Console.WriteLine($"Dispatching: {context.ActionType.Name}");
next();
}
}
var store = new Store<PlayerState>(new PlayerState(100, "Player"));
var reducerHandle = store.RegisterReducerHandle<DamageAction>((state, action) =>
state with { Health = Math.Max(0, state.Health - action.Amount) });
var middlewareHandle = store.RegisterMiddleware(new LoggingMiddleware<PlayerState>());
store.Dispatch(new DamageAction(10));
reducerHandle.UnRegister();
middlewareHandle.UnRegister();
```
这里有两个重要约束:
- `RegisterReducerHandle()``RegisterMiddleware()` 返回的是当前这一次注册的精确注销句柄
- 如果在某次 `Dispatch()` 已经开始后再调用 `UnRegister()`,当前这次 dispatch 仍会继续使用开始时抓取的快照,注销只影响后续新的
dispatch
如果你只需要初始化阶段的链式配置,继续使用 `RegisterReducer()``UseMiddleware()` 即可;
如果你需要运行时清理,就使用上面的句柄式 API。
## 官方示例:角色面板状态
下面给出一个更贴近 GFramework 实战的完整示例,展示如何把 `Store<TState>` 放进 Model