diff --git a/GFramework.Core.Abstractions/StateManagement/IStore.cs b/GFramework.Core.Abstractions/StateManagement/IStore.cs index 7389f46..e00d1c8 100644 --- a/GFramework.Core.Abstractions/StateManagement/IStore.cs +++ b/GFramework.Core.Abstractions/StateManagement/IStore.cs @@ -7,6 +7,18 @@ namespace GFramework.Core.Abstractions.StateManagement; /// 状态树的根状态类型。 public interface IStore : IReadonlyStore { + /// + /// 获取当前是否可以撤销到更早的历史状态。 + /// 当未启用历史缓冲区,或当前已经位于最早历史点时,返回 。 + /// + bool CanUndo { get; } + + /// + /// 获取当前是否可以重做到更晚的历史状态。 + /// 当未启用历史缓冲区,或当前已经位于最新历史点时,返回 。 + /// + bool CanRedo { get; } + /// /// 分发一个 action 以触发状态演进。 /// Store 会按注册顺序执行与该 action 类型匹配的 reducer,并在状态变化后通知订阅者。 @@ -14,4 +26,38 @@ public interface IStore : IReadonlyStore /// action 的具体类型。 /// 要分发的 action 实例。 void Dispatch(TAction action); + + /// + /// 将多个状态操作合并到一个批处理中执行。 + /// 批处理内部的每次分发仍会立即更新 Store 状态和历史,但订阅通知会延迟到最外层批处理结束后再统一触发一次。 + /// + /// 批处理主体;调用方应在其中执行若干次 。 + void RunInBatch(Action batchAction); + + /// + /// 将当前状态回退到上一个历史点。 + /// + /// 当历史缓冲区未启用,或当前已经没有可撤销的历史点时抛出。 + void Undo(); + + /// + /// 将当前状态前进到下一个历史点。 + /// + /// 当历史缓冲区未启用,或当前已经没有可重做的历史点时抛出。 + void Redo(); + + /// + /// 跳转到指定索引的历史点。 + /// 该能力适合调试面板或开发工具实现时间旅行查看。 + /// + /// 目标历史索引,从 0 开始。 + /// 当历史缓冲区未启用时抛出。 + /// 超出当前历史范围时抛出。 + void TimeTravelTo(int historyIndex); + + /// + /// 清空当前撤销/重做历史,并以当前状态作为新的历史锚点。 + /// 该操作不会修改当前状态,也不会触发额外通知。 + /// + void ClearHistory(); } \ No newline at end of file diff --git a/GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs b/GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs index 35ed3cc..9c59c21 100644 --- a/GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs +++ b/GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs @@ -14,6 +14,22 @@ public interface IStoreBuilder /// 当前构建器实例。 IStoreBuilder WithComparer(IEqualityComparer comparer); + /// + /// 配置历史缓冲区容量。 + /// 传入 0 表示禁用历史记录;大于 0 时会保留最近若干个状态快照,用于撤销、重做和时间旅行调试。 + /// + /// 历史缓冲区容量。 + /// 当前构建器实例。 + IStoreBuilder WithHistoryCapacity(int historyCapacity); + + /// + /// 配置 reducer 的 action 匹配策略。 + /// 默认使用 ,仅在需要复用基类或接口 action 层次时再启用多态匹配。 + /// + /// 要使用的匹配策略。 + /// 当前构建器实例。 + IStoreBuilder WithActionMatching(StoreActionMatchingMode actionMatchingMode); + /// /// 添加一个强类型 reducer。 /// diff --git a/GFramework.Core.Abstractions/StateManagement/IStoreDiagnostics.cs b/GFramework.Core.Abstractions/StateManagement/IStoreDiagnostics.cs index 163304a..73b5aab 100644 --- a/GFramework.Core.Abstractions/StateManagement/IStoreDiagnostics.cs +++ b/GFramework.Core.Abstractions/StateManagement/IStoreDiagnostics.cs @@ -28,4 +28,40 @@ public interface IStoreDiagnostics /// 获取最近一次分发记录。 /// StoreDispatchRecord? LastDispatchRecord { get; } + + /// + /// 获取当前 Store 使用的 action 匹配策略。 + /// + StoreActionMatchingMode ActionMatchingMode { get; } + + /// + /// 获取历史缓冲区容量。 + /// 返回 0 表示当前 Store 未启用历史记录能力。 + /// + int HistoryCapacity { get; } + + /// + /// 获取当前可见历史记录数量。 + /// 当历史记录启用时,该值至少为 1,因为当前状态会作为历史锚点存在。 + /// + int HistoryCount { get; } + + /// + /// 获取当前状态在历史缓冲区中的索引。 + /// 当未启用历史记录时返回 -1。 + /// + int HistoryIndex { get; } + + /// + /// 获取当前是否处于批处理阶段。 + /// 该值为 时,状态变更通知会延迟到最外层批处理结束后再统一发送。 + /// + bool IsBatching { get; } + + /// + /// 获取当前历史快照列表的只读快照。 + /// 该方法会返回一份独立快照,供调试工具渲染时间旅行面板,而不暴露 Store 的内部可变集合。 + /// + /// 当前历史快照列表;若未启用历史记录或当前没有历史,则返回空数组。 + IReadOnlyList> GetHistoryEntriesSnapshot(); } \ No newline at end of file diff --git a/GFramework.Core.Abstractions/StateManagement/StoreActionMatchingMode.cs b/GFramework.Core.Abstractions/StateManagement/StoreActionMatchingMode.cs new file mode 100644 index 0000000..123829c --- /dev/null +++ b/GFramework.Core.Abstractions/StateManagement/StoreActionMatchingMode.cs @@ -0,0 +1,20 @@ +namespace GFramework.Core.Abstractions.StateManagement; + +/// +/// 定义 Store 在分发 action 时的 reducer 匹配策略。 +/// 默认使用精确类型匹配,以保持执行结果和顺序的确定性;仅在确有需要时再启用多态匹配。 +/// +public enum StoreActionMatchingMode +{ + /// + /// 仅匹配与 action 运行时类型完全相同的 reducer。 + /// 该模式不会命中基类或接口注册,适合作为默认的稳定行为。 + /// + ExactTypeOnly = 0, + + /// + /// 在精确类型匹配之外,额外匹配可赋值的基类和接口 reducer。 + /// Store 会保持确定性的执行顺序:精确类型优先,其次是最近的基类,最后是接口注册。 + /// + IncludeAssignableTypes = 1 +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/StateManagement/StoreHistoryEntry.cs b/GFramework.Core.Abstractions/StateManagement/StoreHistoryEntry.cs new file mode 100644 index 0000000..ba07ead --- /dev/null +++ b/GFramework.Core.Abstractions/StateManagement/StoreHistoryEntry.cs @@ -0,0 +1,44 @@ +namespace GFramework.Core.Abstractions.StateManagement; + +/// +/// 表示一条 Store 历史快照记录。 +/// 该记录用于撤销/重做和调试面板查看历史状态,不会暴露 Store 的内部可变结构。 +/// +/// 状态树的根状态类型。 +public sealed class StoreHistoryEntry +{ + /// + /// 初始化一条历史记录。 + /// + /// 该历史点对应的状态快照。 + /// 该历史点被记录的时间。 + /// 触发该状态的 action;若为初始状态或已清空历史后的锚点,则为 。 + public StoreHistoryEntry(TState state, DateTimeOffset recordedAt, object? action = null) + { + State = state; + RecordedAt = recordedAt; + Action = action; + } + + /// + /// 获取该历史点对应的状态快照。 + /// + public TState State { get; } + + /// + /// 获取该历史点被记录的时间。 + /// + public DateTimeOffset RecordedAt { get; } + + /// + /// 获取触发该历史点的 action 实例。 + /// 对于初始状态或调用 ClearHistory() 后的新锚点,该值为 。 + /// + public object? Action { get; } + + /// + /// 获取触发该历史点的 action 运行时类型。 + /// 若该历史点没有关联 action,则返回 。 + /// + public Type? ActionType => Action?.GetType(); +} \ No newline at end of file diff --git a/GFramework.Core.Tests/StateManagement/StoreEventBusExtensionsTests.cs b/GFramework.Core.Tests/StateManagement/StoreEventBusExtensionsTests.cs new file mode 100644 index 0000000..70b3eda --- /dev/null +++ b/GFramework.Core.Tests/StateManagement/StoreEventBusExtensionsTests.cs @@ -0,0 +1,92 @@ +using GFramework.Core.Events; + +namespace GFramework.Core.Tests.StateManagement; + +/// +/// Store 到 EventBus 桥接扩展的单元测试。 +/// 这些测试验证旧模块兼容桥接能够正确转发 dispatch 和状态变化事件,并支持运行时拆除。 +/// +[TestFixture] +public class StoreEventBusExtensionsTests +{ + /// + /// 测试桥接会发布每次 dispatch 事件,并对批处理后的状态变化只发送一次最终状态事件。 + /// + [Test] + public void BridgeToEventBus_Should_Publish_Dispatches_And_Collapsed_State_Changes() + { + var eventBus = new EventBus(); + var store = CreateStore(); + var dispatchedEvents = new List>(); + var stateChangedEvents = new List>(); + + eventBus.Register>(dispatchedEvents.Add); + eventBus.Register>(stateChangedEvents.Add); + + store.BridgeToEventBus(eventBus); + + store.Dispatch(new IncrementAction(1)); + store.RunInBatch(() => + { + store.Dispatch(new IncrementAction(1)); + store.Dispatch(new IncrementAction(1)); + }); + + Assert.That(dispatchedEvents.Count, Is.EqualTo(3)); + Assert.That(dispatchedEvents[0].DispatchRecord.NextState.Count, Is.EqualTo(1)); + Assert.That(dispatchedEvents[2].DispatchRecord.NextState.Count, Is.EqualTo(3)); + + Assert.That(stateChangedEvents.Count, Is.EqualTo(2)); + Assert.That(stateChangedEvents[0].State.Count, Is.EqualTo(1)); + Assert.That(stateChangedEvents[1].State.Count, Is.EqualTo(3)); + } + + /// + /// 测试桥接句柄注销后不会再继续向 EventBus 发送事件。 + /// + [Test] + public void BridgeToEventBus_UnRegister_Should_Stop_Future_Publications() + { + var eventBus = new EventBus(); + var store = CreateStore(); + var dispatchedEvents = new List>(); + var stateChangedEvents = new List>(); + + eventBus.Register>(dispatchedEvents.Add); + eventBus.Register>(stateChangedEvents.Add); + + var bridge = store.BridgeToEventBus(eventBus); + + store.Dispatch(new IncrementAction(1)); + bridge.UnRegister(); + store.Dispatch(new IncrementAction(1)); + + Assert.That(dispatchedEvents.Count, Is.EqualTo(1)); + Assert.That(stateChangedEvents.Count, Is.EqualTo(1)); + Assert.That(stateChangedEvents[0].State.Count, Is.EqualTo(1)); + } + + /// + /// 创建一个带基础 reducer 的测试 Store。 + /// + /// 测试用 Store 实例。 + private static Store CreateStore() + { + var store = new Store(new CounterState(0, "Player")); + store.RegisterReducer((state, action) => state with { Count = state.Count + action.Amount }); + return store; + } + + /// + /// 用于桥接测试的状态类型。 + /// + /// 当前计数值。 + /// 当前名称。 + private sealed record CounterState(int Count, string Name); + + /// + /// 用于桥接测试的计数 action。 + /// + /// 要增加的数量。 + private sealed record IncrementAction(int Amount); +} \ No newline at end of file diff --git a/GFramework.Core.Tests/StateManagement/StoreTests.cs b/GFramework.Core.Tests/StateManagement/StoreTests.cs index ceb3c60..8ecbbdf 100644 --- a/GFramework.Core.Tests/StateManagement/StoreTests.cs +++ b/GFramework.Core.Tests/StateManagement/StoreTests.cs @@ -441,6 +441,199 @@ public class StoreTests Assert.That(logs, Is.EqualTo(new[] { "builder:before", "builder:after", "builder:before", "builder:after" })); } + /// + /// 测试批处理会折叠多次状态变化通知,只在最外层结束时发布最终状态。 + /// + [Test] + public void RunInBatch_Should_Collapse_Notifications_To_Final_State() + { + var store = CreateStore(); + var receivedCounts = new List(); + + store.Subscribe(state => receivedCounts.Add(state.Count)); + + store.RunInBatch(() => + { + Assert.That(store.IsBatching, Is.True); + + store.Dispatch(new IncrementAction(1)); + store.Dispatch(new IncrementAction(2)); + }); + + Assert.That(store.IsBatching, Is.False); + Assert.That(store.State.Count, Is.EqualTo(3)); + Assert.That(receivedCounts, Is.EqualTo(new[] { 3 })); + } + + /// + /// 测试嵌套批处理只会在最外层结束时发出一次通知。 + /// + [Test] + public void RunInBatch_Should_Support_Nested_Batches() + { + var store = CreateStore(); + var receivedCounts = new List(); + + store.Subscribe(state => receivedCounts.Add(state.Count)); + + store.RunInBatch(() => + { + store.Dispatch(new IncrementAction(1)); + + store.RunInBatch(() => + { + Assert.That(store.IsBatching, Is.True); + store.Dispatch(new IncrementAction(1)); + }); + + store.Dispatch(new IncrementAction(1)); + }); + + Assert.That(store.State.Count, Is.EqualTo(3)); + Assert.That(receivedCounts, Is.EqualTo(new[] { 3 })); + } + + /// + /// 测试启用历史记录后支持撤销、重做、时间旅行和 redo 分支裁剪。 + /// + [Test] + public void History_Should_Support_Undo_Redo_Time_Travel_And_Branch_Reset() + { + var store = new Store(new CounterState(0, "Player"), historyCapacity: 8); + store.RegisterReducer((state, action) => state with { Count = state.Count + action.Amount }); + + store.Dispatch(new IncrementAction(1)); + store.Dispatch(new IncrementAction(1)); + store.Dispatch(new IncrementAction(1)); + + Assert.That(store.HistoryCount, Is.EqualTo(4)); + Assert.That(store.HistoryIndex, Is.EqualTo(3)); + Assert.That(store.CanUndo, Is.True); + Assert.That(store.CanRedo, Is.False); + + store.Undo(); + Assert.That(store.State.Count, Is.EqualTo(2)); + Assert.That(store.HistoryIndex, Is.EqualTo(2)); + Assert.That(store.CanRedo, Is.True); + + store.Undo(); + Assert.That(store.State.Count, Is.EqualTo(1)); + Assert.That(store.HistoryIndex, Is.EqualTo(1)); + + store.Redo(); + Assert.That(store.State.Count, Is.EqualTo(2)); + Assert.That(store.HistoryIndex, Is.EqualTo(2)); + + store.TimeTravelTo(0); + Assert.That(store.State.Count, Is.EqualTo(0)); + Assert.That(store.HistoryIndex, Is.EqualTo(0)); + + store.TimeTravelTo(2); + Assert.That(store.State.Count, Is.EqualTo(2)); + Assert.That(store.HistoryIndex, Is.EqualTo(2)); + + store.Dispatch(new IncrementAction(10)); + + Assert.That(store.State.Count, Is.EqualTo(12)); + Assert.That(store.CanRedo, Is.False, "新 dispatch 应清除 redo 分支"); + Assert.That(store.GetHistoryEntriesSnapshot().Select(entry => entry.State.Count), + Is.EqualTo(new[] { 0, 1, 2, 12 })); + } + + /// + /// 测试 ClearHistory 会以当前状态重置历史锚点,而不会修改当前状态。 + /// + [Test] + public void ClearHistory_Should_Reset_To_Current_State_Anchor() + { + var store = new Store(new CounterState(0, "Player"), historyCapacity: 4); + store.RegisterReducer((state, action) => state with { Count = state.Count + action.Amount }); + + store.Dispatch(new IncrementAction(1)); + store.Dispatch(new IncrementAction(1)); + store.ClearHistory(); + + Assert.That(store.State.Count, Is.EqualTo(2)); + Assert.That(store.HistoryCount, Is.EqualTo(1)); + Assert.That(store.HistoryIndex, Is.EqualTo(0)); + Assert.That(store.CanUndo, Is.False); + Assert.That(store.GetHistoryEntriesSnapshot()[0].State.Count, Is.EqualTo(2)); + } + + /// + /// 测试默认 action 匹配策略仍然只命中精确类型 reducer。 + /// + [Test] + public void Dispatch_Should_Remain_Exact_Type_Only_By_Default() + { + var store = new Store(new CounterState(0, "Player")); + store.RegisterReducer((state, action) => + state with { Count = state.Count + action.Amount * 10 }); + store.RegisterReducer((state, action) => + state with { Count = state.Count + action.Amount * 100 }); + + store.Dispatch(new DerivedIncrementAction(1)); + + Assert.That(store.State.Count, Is.EqualTo(0)); + Assert.That(store.ActionMatchingMode, Is.EqualTo(StoreActionMatchingMode.ExactTypeOnly)); + } + + /// + /// 测试启用多态匹配后,Store 会按“精确类型 -> 基类 -> 接口”的稳定顺序执行 reducer。 + /// + [Test] + public void Dispatch_Should_Use_Polymorphic_Action_Matching_In_Deterministic_Order() + { + var executionOrder = new List(); + var store = new Store( + new CounterState(0, "Player"), + actionMatchingMode: StoreActionMatchingMode.IncludeAssignableTypes); + + store.RegisterReducer((state, action) => + { + executionOrder.Add("base"); + return state with { Count = state.Count + action.Amount * 10 }; + }); + + store.RegisterReducer((state, action) => + { + executionOrder.Add("interface"); + return state with { Count = state.Count + action.Amount * 100 }; + }); + + store.RegisterReducer((state, action) => + { + executionOrder.Add("exact"); + return state with { Count = state.Count + action.Amount }; + }); + + store.Dispatch(new DerivedIncrementAction(1)); + + Assert.That(executionOrder, Is.EqualTo(new[] { "exact", "base", "interface" })); + Assert.That(store.State.Count, Is.EqualTo(111)); + } + + /// + /// 测试 StoreBuilder 能够应用历史容量和 action 匹配策略配置。 + /// + [Test] + public void StoreBuilder_Should_Apply_History_And_Action_Matching_Configuration() + { + var store = (Store)Store + .CreateBuilder() + .WithHistoryCapacity(6) + .WithActionMatching(StoreActionMatchingMode.IncludeAssignableTypes) + .AddReducer((state, action) => state with { Count = state.Count + action.Amount }) + .Build(new CounterState(0, "Player")); + + store.Dispatch(new DerivedIncrementAction(2)); + + Assert.That(store.ActionMatchingMode, Is.EqualTo(StoreActionMatchingMode.IncludeAssignableTypes)); + Assert.That(store.HistoryCapacity, Is.EqualTo(6)); + Assert.That(store.HistoryCount, Is.EqualTo(2)); + Assert.That(store.State.Count, Is.EqualTo(2)); + } + /// /// 测试长时间运行的 middleware 不会长时间占用状态锁, /// 使读取状态和新增订阅仍能在 dispatch 进行期间完成。 @@ -513,6 +706,30 @@ public class StoreTests /// private sealed record NoopAction; + /// + /// 表示参与多态匹配测试的 action 标记接口。 + /// + private interface IIncrementActionMarker + { + /// + /// 获取增量值。 + /// + int Amount { get; } + } + + /// + /// 表示多态匹配测试中的基类 action。 + /// + /// 要增加的数量。 + private abstract record IncrementActionBase(int Amount); + + /// + /// 表示多态匹配测试中的派生 action。 + /// + /// 要增加的数量。 + private sealed record DerivedIncrementAction(int Amount) + : IncrementActionBase(Amount), IIncrementActionMarker; + /// /// 显式选择器实现,用于验证 IStateSelector 重载。 /// diff --git a/GFramework.Core/Extensions/StoreEventBusExtensions.cs b/GFramework.Core/Extensions/StoreEventBusExtensions.cs new file mode 100644 index 0000000..df3ad0e --- /dev/null +++ b/GFramework.Core/Extensions/StoreEventBusExtensions.cs @@ -0,0 +1,118 @@ +using GFramework.Core.Abstractions.Events; +using GFramework.Core.Abstractions.StateManagement; +using GFramework.Core.Events; +using GFramework.Core.StateManagement; + +namespace GFramework.Core.Extensions; + +/// +/// 为 Store 提供到 EventBus 的兼容桥接扩展。 +/// 该扩展面向旧模块渐进迁移场景,使现有事件消费者可以继续观察 Store 的 action 分发和状态变化。 +/// +public static class StoreEventBusExtensions +{ + /// + /// 将 Store 的 dispatch 和状态变化同时桥接到 EventBus。 + /// dispatch 事件会逐次发布;状态变化事件会复用 Store 自身的通知折叠语义,因此批处理中只发布最终状态。 + /// + /// 状态树的根状态类型。 + /// 源 Store。 + /// 目标事件总线。 + /// 是否发布每次 action 分发事件。 + /// 是否发布状态变化事件。 + /// 用于拆除桥接的句柄。 + public static IUnRegister BridgeToEventBus( + this Store store, + IEventBus eventBus, + bool publishDispatches = true, + bool publishStateChanges = true) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(eventBus); + + IUnRegister? dispatchBridge = null; + IUnRegister? stateBridge = null; + + if (publishDispatches) + { + dispatchBridge = store.BridgeDispatchesToEventBus(eventBus); + } + + if (publishStateChanges) + { + stateBridge = store.BridgeStateChangesToEventBus(eventBus); + } + + return new DefaultUnRegister(() => + { + dispatchBridge?.UnRegister(); + stateBridge?.UnRegister(); + }); + } + + /// + /// 将 Store 的每次 dispatch 结果桥接到 EventBus。 + /// 该桥接通过中间件实现,因此即使某次分发未改变状态,也会发布对应的 dispatch 事件。 + /// + /// 状态树的根状态类型。 + /// 源 Store。 + /// 目标事件总线。 + /// 用于移除 dispatch 桥接中间件的句柄。 + public static IUnRegister BridgeDispatchesToEventBus(this Store store, IEventBus eventBus) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(eventBus); + + return store.RegisterMiddleware(new DispatchEventBusMiddleware(eventBus)); + } + + /// + /// 将 Store 的状态变化桥接到 EventBus。 + /// 该桥接复用 Store 的订阅通知语义,因此只会在状态真正变化时发布事件。 + /// + /// 状态树的根状态类型。 + /// 源 Store。 + /// 目标事件总线。 + /// 用于移除状态变化桥接的句柄。 + public static IUnRegister BridgeStateChangesToEventBus(this IReadonlyStore store, + IEventBus eventBus) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(eventBus); + + return store.Subscribe(state => + eventBus.Send(new StoreStateChangedEvent(state, DateTimeOffset.UtcNow))); + } + + /// + /// 用于把 dispatch 结果桥接到 EventBus 的内部中间件。 + /// 选择中间件而不是改写 Store 核心提交流程,是为了把兼容层成本保持在可选扩展中。 + /// + /// 状态树的根状态类型。 + private sealed class DispatchEventBusMiddleware(IEventBus eventBus) : IStoreMiddleware + { + /// + /// 目标事件总线。 + /// + private readonly IEventBus _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus)); + + /// + /// 执行后续 dispatch 管线,并在结束后把分发结果发送到 EventBus。 + /// + /// 当前分发上下文。 + /// 后续管线。 + public void Invoke(StoreDispatchContext context, Action next) + { + next(); + + var dispatchRecord = new StoreDispatchRecord( + context.Action, + context.PreviousState, + context.NextState, + context.HasStateChanged, + context.DispatchedAt); + + _eventBus.Send(new StoreDispatchedEvent(dispatchRecord)); + } + } +} \ No newline at end of file diff --git a/GFramework.Core/StateManagement/Store.cs b/GFramework.Core/StateManagement/Store.cs index 677c6f3..789eaea 100644 --- a/GFramework.Core/StateManagement/Store.cs +++ b/GFramework.Core/StateManagement/Store.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using GFramework.Core.Abstractions.Events; using GFramework.Core.Abstractions.StateManagement; using GFramework.Core.Events; @@ -6,19 +7,36 @@ namespace GFramework.Core.StateManagement; /// /// 集中式状态容器的默认实现,用于统一管理复杂状态树的读取、归约和订阅通知。 -/// 该类型定位于现有 BindableProperty 之上的可选能力,适合跨模块共享、需要统一变更入口 -/// 或需要中间件/诊断能力的状态场景,而不是替代所有简单字段级响应式属性。 +/// 该类型定位于现有 BindableProperty 之上的可选能力,适合跨模块共享、需要统一变更入口、 +/// 支持调试历史或需要中间件/诊断能力的状态场景,而不是替代所有简单字段级响应式属性。 /// /// 状态树的根状态类型。 public sealed class Store : IStore, IStoreDiagnostics { + /// + /// 当前 Store 使用的 action 匹配策略。 + /// + private readonly StoreActionMatchingMode _actionMatchingMode; + /// /// Dispatch 串行化门闩。 - /// 该锁保证任意时刻只有一个 action 管线在运行,从而保持状态演进顺序确定, + /// 该锁保证任意时刻只有一个 action 管线或历史跳转在提交状态,从而保持状态演进顺序确定, /// 同时避免让耗时 middleware / reducer 长时间占用状态锁。 /// private readonly object _dispatchGate = new(); + /// + /// 历史快照缓冲区。 + /// 当容量为 0 时该集合始终为空;启用后会保留当前时间线上的最近若干个状态快照。 + /// + private readonly List> _history = []; + + /// + /// 历史缓冲区容量。 + /// 该容量包含当前状态锚点,因此容量越小,可撤销的步数也越少。 + /// + private readonly int _historyCapacity; + /// /// 当前状态变化订阅者列表。 /// 使用显式订阅对象而不是委托链,便于处理原子初始化订阅、挂起补发和精确解绑。 @@ -27,7 +45,7 @@ public sealed class Store : IStore, IStoreDiagnostics /// /// Store 内部所有可变状态的同步锁。 - /// 该锁仅保护状态快照、订阅集合、缓存选择视图和注册表本身的短临界区访问。 + /// 该锁仅保护状态快照、订阅集合、缓存选择视图、历史记录和注册表本身的短临界区访问。 /// private readonly object _lock = new(); @@ -39,10 +57,8 @@ public sealed class Store : IStore, IStoreDiagnostics private readonly List _middlewares = []; /// - /// 按 action 具体运行时类型组织的 reducer 注册表。 - /// Store 采用精确类型匹配策略,保证 reducer 执行顺序和行为保持确定性。 - /// 每个 reducer 通过注册条目获得稳定身份,以支持运行时精确注销。 - /// Dispatch 开始时会抓取对应 action 类型的 reducer 快照。 + /// 按 action 注册类型组织的 reducer 注册表。 + /// 默认使用精确类型匹配;启用多态匹配时,会在分发时按确定性的优先级扫描可赋值类型。 /// private readonly Dictionary> _reducers = []; @@ -57,9 +73,26 @@ public sealed class Store : IStore, IStoreDiagnostics /// private readonly IEqualityComparer _stateComparer; + /// + /// 当前批处理嵌套深度。 + /// 大于 0 时会推迟状态通知,直到最外层批处理结束。 + /// + private int _batchDepth; + + /// + /// 当前批处理中是否有待发送的最终状态通知。 + /// + private bool _hasPendingBatchNotification; + + /// + /// 当前历史游标位置。 + /// 当未启用历史记录时,该值保持为 -1。 + /// + private int _historyIndex = -1; + /// /// 标记当前 Store 是否正在执行分发。 - /// 该标记用于阻止同一 Store 的重入分发,避免产生难以推导的执行顺序和状态回滚问题。 + /// 该标记用于阻止同一 Store 的重入分发或在 dispatch 中执行历史跳转,避免产生难以推导的执行顺序。 /// private bool _isDispatching; @@ -78,6 +111,17 @@ public sealed class Store : IStore, IStoreDiagnostics /// private DateTimeOffset? _lastStateChangedAt; + /// + /// reducer 注册序号。 + /// 该序号用于在多态匹配时为不同注册桶提供跨类型的稳定排序键。 + /// + private long _nextReducerSequence; + + /// + /// 当前批处理中最后一个应通知给订阅者的状态快照。 + /// + private TState _pendingBatchState = default!; + /// /// 当前 Store 持有的状态快照。 /// @@ -88,10 +132,30 @@ public sealed class Store : IStore, IStoreDiagnostics /// /// Store 的初始状态。 /// 状态比较器;未提供时使用 。 - public Store(TState initialState, IEqualityComparer? comparer = null) + /// 历史缓冲区容量;0 表示不启用历史记录。 + /// reducer 的 action 匹配策略。 + /// 小于 0 时抛出。 + public Store( + TState initialState, + IEqualityComparer? comparer = null, + int historyCapacity = 0, + StoreActionMatchingMode actionMatchingMode = StoreActionMatchingMode.ExactTypeOnly) { + if (historyCapacity < 0) + { + throw new ArgumentOutOfRangeException(nameof(historyCapacity), historyCapacity, + "History capacity cannot be negative."); + } + _state = initialState; _stateComparer = comparer ?? EqualityComparer.Default; + _historyCapacity = historyCapacity; + _actionMatchingMode = actionMatchingMode; + + if (_historyCapacity > 0) + { + ResetHistoryToCurrentState(DateTimeOffset.UtcNow); + } } /// @@ -108,6 +172,34 @@ public sealed class Store : IStore, IStoreDiagnostics } } + /// + /// 获取当前是否可以撤销到更早的历史状态。 + /// + public bool CanUndo + { + get + { + lock (_lock) + { + return _historyCapacity > 0 && _historyIndex > 0; + } + } + } + + /// + /// 获取当前是否可以重做到更晚的历史状态。 + /// + public bool CanRedo + { + get + { + lock (_lock) + { + return _historyCapacity > 0 && _historyIndex >= 0 && _historyIndex < _history.Count - 1; + } + } + } + /// /// 分发一个 action 并按顺序执行匹配的 reducer。 /// @@ -124,6 +216,8 @@ public sealed class Store : IStore, IStoreDiagnostics IStoreReducerAdapter[] reducersSnapshot = Array.Empty(); IEqualityComparer stateComparerSnapshot = _stateComparer; StoreDispatchContext? context = null; + TState notificationState = default!; + var hasNotification = false; var enteredDispatchScope = false; lock (_dispatchGate) @@ -137,8 +231,8 @@ public sealed class Store : IStore, IStoreDiagnostics enteredDispatchScope = true; context = new StoreDispatchContext(action!, _state); stateComparerSnapshot = _stateComparer; - middlewaresSnapshot = CreateMiddlewareSnapshot(); - reducersSnapshot = CreateReducerSnapshot(context.ActionType); + middlewaresSnapshot = CreateMiddlewareSnapshotCore(); + reducersSnapshot = CreateReducerSnapshotCore(context.ActionType); } // middleware 和 reducer 可能包含较重的同步逻辑,因此仅持有 dispatch 串行门, @@ -160,9 +254,9 @@ public sealed class Store : IStore, IStoreDiagnostics return; } - _state = context.NextState; - _lastStateChangedAt = context.DispatchedAt; - listenersSnapshot = SnapshotListenersForNotification(context.NextState); + ApplyCommittedStateChange(context.NextState, context.DispatchedAt, context.Action); + listenersSnapshot = CaptureListenersOrDeferNotification(context.NextState, out notificationState); + hasNotification = listenersSnapshot.Length > 0; } } finally @@ -177,10 +271,110 @@ public sealed class Store : IStore, IStoreDiagnostics } } - // 始终在锁外通知订阅者,避免监听器内部读取 Store 或执行额外逻辑时产生死锁。 - foreach (var listener in listenersSnapshot) + if (!hasNotification) { - listener(context!.NextState); + return; + } + + NotifyListeners(listenersSnapshot, notificationState); + } + + /// + /// 将当前状态回退到上一个历史点。 + /// + /// 当历史缓冲区未启用,或当前已经没有可撤销的历史点时抛出。 + public void Undo() + { + MoveToHistoryIndex(-1, isRelative: true, nameof(Undo), "No earlier history entry is available for undo."); + } + + /// + /// 将当前状态前进到下一个历史点。 + /// + /// 当历史缓冲区未启用,或当前已经没有可重做的历史点时抛出。 + public void Redo() + { + MoveToHistoryIndex(1, isRelative: true, nameof(Redo), "No later history entry is available for redo."); + } + + /// + /// 跳转到指定索引的历史点。 + /// + /// 目标历史索引,从 0 开始。 + /// 当历史缓冲区未启用时抛出。 + /// 超出历史范围时抛出。 + public void TimeTravelTo(int historyIndex) + { + MoveToHistoryIndex(historyIndex, isRelative: false, nameof(historyIndex), null); + } + + /// + /// 清空当前撤销/重做历史,并以当前状态作为新的历史锚点。 + /// + public void ClearHistory() + { + lock (_dispatchGate) + { + lock (_lock) + { + EnsureNotDispatching(); + if (_historyCapacity == 0) + { + return; + } + + ResetHistoryToCurrentState(DateTimeOffset.UtcNow); + } + } + } + + /// + /// 将多个状态操作合并到一个批处理中执行。 + /// 批处理内的状态变化会立即提交,但通知会在最外层批处理结束后折叠为一次最终回放。 + /// + /// 批处理主体。 + /// 时抛出。 + public void RunInBatch(Action batchAction) + { + ArgumentNullException.ThrowIfNull(batchAction); + + lock (_lock) + { + _batchDepth++; + } + + Action[] listenersSnapshot = Array.Empty>(); + TState notificationState = default!; + + try + { + batchAction(); + } + finally + { + lock (_lock) + { + if (_batchDepth == 0) + { + Debug.Fail("Batch depth is already zero during RunInBatch cleanup."); + } + else + { + _batchDepth--; + if (_batchDepth == 0 && _hasPendingBatchNotification) + { + notificationState = _pendingBatchState; + _pendingBatchState = default!; + _hasPendingBatchNotification = false; + listenersSnapshot = SnapshotListenersForNotification(notificationState); + } + } + } + } + + if (listenersSnapshot.Length > 0) + { + NotifyListeners(listenersSnapshot, notificationState); } } @@ -341,6 +535,71 @@ public sealed class Store : IStore, IStoreDiagnostics } } + /// + /// 获取当前 Store 使用的 action 匹配策略。 + /// + public StoreActionMatchingMode ActionMatchingMode => _actionMatchingMode; + + /// + /// 获取历史缓冲区容量。 + /// + public int HistoryCapacity => _historyCapacity; + + /// + /// 获取当前可见历史记录数量。 + /// + public int HistoryCount + { + get + { + lock (_lock) + { + return _history.Count; + } + } + } + + /// + /// 获取当前状态在历史缓冲区中的索引。 + /// + public int HistoryIndex + { + get + { + lock (_lock) + { + return _historyIndex; + } + } + } + + /// + /// 获取当前是否处于批处理阶段。 + /// + public bool IsBatching + { + get + { + lock (_lock) + { + return _batchDepth > 0; + } + } + } + + /// + /// 获取当前历史快照列表的只读快照。 + /// 该方法以方法语义显式表达会分配并返回集合副本,避免把快照克隆隐藏在属性访问中。 + /// + /// 当前历史快照列表;若未启用历史记录或当前没有历史,则返回空数组。 + public IReadOnlyList> GetHistoryEntriesSnapshot() + { + lock (_lock) + { + return _history.Count == 0 ? Array.Empty>() : _history.ToArray(); + } + } + /// /// 创建一个用于当前状态类型的 Store 构建器。 /// @@ -394,10 +653,12 @@ public sealed class Store : IStore, IStoreDiagnostics ArgumentNullException.ThrowIfNull(reducer); var actionType = typeof(TAction); - var registration = new ReducerRegistration(new ReducerAdapter(reducer)); + ReducerRegistration registration; lock (_lock) { + registration = new ReducerRegistration(new ReducerAdapter(reducer), _nextReducerSequence++); + if (!_reducers.TryGetValue(actionType, out var reducers)) { reducers = []; @@ -540,7 +801,7 @@ public sealed class Store : IStore, IStoreDiagnostics /// /// 对当前 action 应用所有匹配的 reducer。 - /// reducer 使用 action 的精确运行时类型进行查找,以保证匹配结果和执行顺序稳定。 + /// reducer 会按照预先计算好的稳定顺序执行,从而在多态匹配模式下仍保持确定性的状态演进。 /// /// 当前分发上下文。 /// 本次分发使用的 reducer 快照。 @@ -571,7 +832,7 @@ public sealed class Store : IStore, IStoreDiagnostics } /// - /// 确保当前 Store 没有发生重入分发。 + /// 确保当前 Store 没有发生重入分发或在 dispatch 中执行历史控制。 /// /// 当检测到重入分发时抛出。 private void EnsureNotDispatching() @@ -582,6 +843,20 @@ public sealed class Store : IStore, IStoreDiagnostics } } + /// + /// 将新的已提交状态应用到 Store。 + /// 该方法负责统一更新当前状态、最后变更时间和历史缓冲区,避免 dispatch 与 time-travel 路径产生分叉语义。 + /// + /// 要提交的新状态。 + /// 状态生效时间。 + /// 触发该状态的 action;若本次变化不是由 dispatch 触发,则为 。 + private void ApplyCommittedStateChange(TState nextState, DateTimeOffset changedAt, object? action) + { + _state = nextState; + _lastStateChangedAt = changedAt; + RecordHistoryEntry(nextState, changedAt, action); + } + /// /// 从当前订阅集合中提取需要立即通知的监听器快照,并为尚未激活的初始化订阅保存待补发状态。 /// @@ -615,53 +890,319 @@ public sealed class Store : IStore, IStoreDiagnostics return activeListeners.Count > 0 ? activeListeners.ToArray() : Array.Empty>(); } + /// + /// 根据当前批处理状态决定是立即提取监听器快照,还是把通知折叠到批处理尾部。 + /// + /// 最新状态快照。 + /// 若需要立即通知,则返回要回放给监听器的状态。 + /// 需要立即通知的监听器快照;若处于批处理阶段则返回空数组。 + private Action[] CaptureListenersOrDeferNotification(TState nextState, out TState notificationState) + { + if (_batchDepth > 0) + { + _pendingBatchState = nextState; + _hasPendingBatchNotification = true; + notificationState = default!; + return Array.Empty>(); + } + + notificationState = nextState; + return SnapshotListenersForNotification(nextState); + } + /// /// 为当前中间件链创建快照。 - /// 该方法自行获取状态锁,避免调用方必须记住“只能在已加锁条件下调用”这一隐含约束, - /// 从而降低未来重构时把它误用到锁外路径中的风险。 + /// 调用该方法时必须已经持有状态锁,从而避免额外的锁重入和快照时序歧义。 /// /// 当前中间件链的快照;若未注册则返回空数组。 - private IStoreMiddleware[] CreateMiddlewareSnapshot() + private IStoreMiddleware[] CreateMiddlewareSnapshotCore() { - lock (_lock) + Debug.Assert( + Monitor.IsEntered(_lock), + "Caller must hold _lock before invoking CreateMiddlewareSnapshotCore to avoid concurrency bugs."); + + if (_middlewares.Count == 0) { - 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; + 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 快照。 - /// 该方法自行获取状态锁,避免让快照安全性依赖调用方的锁顺序知识。 + /// 在精确匹配模式下只读取一个注册桶;在多态模式下会按稳定排序规则合并可赋值的基类和接口注册。 /// /// 当前分发的 action 类型。 /// 对应 action 类型的 reducer 快照;若未注册则返回空数组。 - private IStoreReducerAdapter[] CreateReducerSnapshot(Type actionType) + private IStoreReducerAdapter[] CreateReducerSnapshotCore(Type actionType) { - lock (_lock) + Debug.Assert( + Monitor.IsEntered(_lock), + "Caller must hold _lock before invoking CreateReducerSnapshotCore to avoid concurrency bugs."); + + if (_actionMatchingMode == StoreActionMatchingMode.ExactTypeOnly) { - if (!_reducers.TryGetValue(actionType, out var reducers) || reducers.Count == 0) + if (!_reducers.TryGetValue(actionType, out var exactReducers) || exactReducers.Count == 0) { return Array.Empty(); } - var snapshot = new IStoreReducerAdapter[reducers.Count]; - for (var i = 0; i < reducers.Count; i++) + var exactSnapshot = new IStoreReducerAdapter[exactReducers.Count]; + for (var i = 0; i < exactReducers.Count; i++) { - snapshot[i] = reducers[i].Adapter; + exactSnapshot[i] = exactReducers[i].Adapter; } - return snapshot; + return exactSnapshot; + } + + List? matches = null; + + foreach (var reducerBucket in _reducers) + { + if (!TryCreateReducerMatch(actionType, reducerBucket.Key, out var matchCategory, + out var inheritanceDistance)) + { + continue; + } + + matches ??= new List(); + foreach (var registration in reducerBucket.Value) + { + matches.Add(new ReducerMatch( + registration.Adapter, + registration.Sequence, + matchCategory, + inheritanceDistance)); + } + } + + if (matches is null || matches.Count == 0) + { + return Array.Empty(); + } + + matches.Sort(static (left, right) => + { + var categoryComparison = left.MatchCategory.CompareTo(right.MatchCategory); + if (categoryComparison != 0) + { + return categoryComparison; + } + + var distanceComparison = left.InheritanceDistance.CompareTo(right.InheritanceDistance); + if (distanceComparison != 0) + { + return distanceComparison; + } + + return left.Sequence.CompareTo(right.Sequence); + }); + + var snapshot = new IStoreReducerAdapter[matches.Count]; + for (var i = 0; i < matches.Count; i++) + { + snapshot[i] = matches[i].Adapter; + } + + return snapshot; + } + + /// + /// 判断指定 reducer 注册类型是否能匹配当前 action 类型,并给出用于稳定排序的分类信息。 + /// + /// 当前 action 的运行时类型。 + /// reducer 注册时声明的 action 类型。 + /// 匹配分类:0 为精确类型,1 为基类,2 为接口。 + /// 继承距离,值越小表示越接近当前 action 类型。 + /// 若可以匹配则返回 + private static bool TryCreateReducerMatch( + Type actionType, + Type registeredActionType, + out int matchCategory, + out int inheritanceDistance) + { + if (registeredActionType == actionType) + { + matchCategory = 0; + inheritanceDistance = 0; + return true; + } + + if (!registeredActionType.IsAssignableFrom(actionType)) + { + matchCategory = default; + inheritanceDistance = default; + return false; + } + + if (registeredActionType.IsInterface) + { + matchCategory = 2; + inheritanceDistance = 0; + return true; + } + + matchCategory = 1; + inheritanceDistance = GetInheritanceDistance(actionType, registeredActionType); + return true; + } + + /// + /// 计算当前 action 类型到目标基类的继承距离。 + /// 距离越小表示基类越接近当前 action 类型,在多态匹配排序中优先级越高。 + /// + /// 当前 action 的运行时类型。 + /// reducer 注册时声明的基类类型。 + /// 从当前 action 到目标基类的继承层级数。 + private static int GetInheritanceDistance(Type actionType, Type registeredActionType) + { + var distance = 0; + var currentType = actionType; + + while (currentType != registeredActionType && currentType.BaseType is not null) + { + currentType = currentType.BaseType; + distance++; + } + + return distance; + } + + /// + /// 记录一条新的历史快照。 + /// 当当前游标不在时间线末尾时,会先裁掉 redo 分支,再追加新的状态快照。 + /// + /// 要记录的状态快照。 + /// 历史记录时间。 + /// 触发该状态的 action;若为空则表示当前变化不应写入历史。 + private void RecordHistoryEntry(TState state, DateTimeOffset recordedAt, object? action) + { + if (_historyCapacity == 0) + { + return; + } + + if (action is null) + { + return; + } + + if (_historyIndex >= 0 && _historyIndex < _history.Count - 1) + { + _history.RemoveRange(_historyIndex + 1, _history.Count - _historyIndex - 1); + } + + _history.Add(new StoreHistoryEntry(state, recordedAt, action)); + _historyIndex = _history.Count - 1; + + if (_history.Count <= _historyCapacity) + { + return; + } + + var overflow = _history.Count - _historyCapacity; + _history.RemoveRange(0, overflow); + _historyIndex = Math.Max(0, _historyIndex - overflow); + } + + /// + /// 将当前状态重置为新的历史锚点。 + /// 该操作用于 Store 初始化和显式清空历史后重新建立时间线起点。 + /// + /// 锚点记录时间。 + private void ResetHistoryToCurrentState(DateTimeOffset recordedAt) + { + _history.Clear(); + _history.Add(new StoreHistoryEntry(_state, recordedAt)); + _historyIndex = 0; + } + + /// + /// 将当前状态移动到指定历史索引。 + /// 该方法统一承载 Undo、Redo 和显式时间旅行路径,确保通知与批处理语义保持一致。 + /// + /// 目标索引或相对偏移量。 + /// 是否按相对偏移量解释 。 + /// 参数名,用于异常信息。 + /// 当相对跳转无可用历史时的错误信息;绝对跳转场景传 。 + private void MoveToHistoryIndex( + int historyIndexOrOffset, + bool isRelative, + string argumentName, + string? emptyHistoryMessage) + { + Action[] listenersSnapshot = Array.Empty>(); + TState notificationState = default!; + + lock (_dispatchGate) + { + lock (_lock) + { + EnsureNotDispatching(); + EnsureHistoryEnabled(); + + var targetIndex = isRelative ? _historyIndex + historyIndexOrOffset : historyIndexOrOffset; + if (targetIndex < 0 || targetIndex >= _history.Count) + { + if (isRelative) + { + throw new InvalidOperationException(emptyHistoryMessage); + } + + throw new ArgumentOutOfRangeException(argumentName, historyIndexOrOffset, + "History index is out of range."); + } + + if (targetIndex == _historyIndex) + { + return; + } + + _historyIndex = targetIndex; + notificationState = _history[targetIndex].State; + _state = notificationState; + _lastStateChangedAt = DateTimeOffset.UtcNow; + listenersSnapshot = CaptureListenersOrDeferNotification(notificationState, out notificationState); + } + } + + if (listenersSnapshot.Length > 0) + { + NotifyListeners(listenersSnapshot, notificationState); + } + } + + /// + /// 确保当前 Store 已启用历史缓冲区。 + /// + /// 当历史记录未启用时抛出。 + private void EnsureHistoryEnabled() + { + if (_historyCapacity == 0) + { + throw new InvalidOperationException("History is not enabled for this store."); + } + } + + /// + /// 在锁外顺序通知监听器。 + /// 始终在锁外通知可避免监听器内部读取 Store 或执行额外逻辑时产生死锁。 + /// + /// 监听器快照。 + /// 要回放给监听器的状态。 + private static void NotifyListeners(Action[] listenersSnapshot, TState state) + { + foreach (var listener in listenersSnapshot) + { + listener(state); } } @@ -756,14 +1297,51 @@ public sealed class Store : IStore, IStoreDiagnostics /// /// 表示一条 reducer 注册记录。 - /// 该包装对象为运行时注销提供稳定身份,同时不改变 reducer 的执行顺序语义。 + /// 该包装对象为运行时注销提供稳定身份,并携带全局序号以支撑多态匹配时的稳定排序。 /// - private sealed class ReducerRegistration(IStoreReducerAdapter adapter) + private sealed class ReducerRegistration(IStoreReducerAdapter adapter, long sequence) { /// /// 获取真正执行归约的内部适配器。 /// public IStoreReducerAdapter Adapter { get; } = adapter; + + /// + /// 获取该 reducer 的全局注册序号。 + /// + public long Sequence { get; } = sequence; + } + + /// + /// 表示一次多态 reducer 匹配结果。 + /// 该结构在创建快照时缓存排序所需元数据,避免排序阶段重复计算类型关系。 + /// + private sealed class ReducerMatch( + IStoreReducerAdapter adapter, + long sequence, + int matchCategory, + int inheritanceDistance) + { + /// + /// 获取匹配到的 reducer 适配器。 + /// + public IStoreReducerAdapter Adapter { get; } = adapter; + + /// + /// 获取 reducer 的全局注册序号。 + /// + public long Sequence { get; } = sequence; + + /// + /// 获取匹配分类:0 为精确类型,1 为基类,2 为接口。 + /// + public int MatchCategory { get; } = matchCategory; + + /// + /// 获取继承距离。 + /// 该值越小表示注册类型越接近当前 action 类型。 + /// + public int InheritanceDistance { get; } = inheritanceDistance; } /// diff --git a/GFramework.Core/StateManagement/StoreBuilder.cs b/GFramework.Core/StateManagement/StoreBuilder.cs index 97d788e..48802aa 100644 --- a/GFramework.Core/StateManagement/StoreBuilder.cs +++ b/GFramework.Core/StateManagement/StoreBuilder.cs @@ -15,11 +15,23 @@ public sealed class StoreBuilder : IStoreBuilder /// private readonly List>> _configurators = []; + /// + /// action 匹配策略。 + /// 默认使用精确类型匹配,只有在明确需要复用基类/接口 action 层次时才切换为多态匹配。 + /// + private StoreActionMatchingMode _actionMatchingMode = StoreActionMatchingMode.ExactTypeOnly; + /// /// 状态比较器。 /// private IEqualityComparer? _comparer; + /// + /// 历史缓冲区容量。 + /// 默认值为 0,表示不记录撤销/重做历史,以维持最轻量的运行时开销。 + /// + private int _historyCapacity; + /// /// 添加一个 Store 中间件。 /// @@ -39,7 +51,7 @@ public sealed class StoreBuilder : IStoreBuilder /// 已应用当前构建器配置的 Store 实例。 public IStore Build(TState initialState) { - var store = new Store(initialState, _comparer); + var store = new Store(initialState, _comparer, _historyCapacity, _actionMatchingMode); foreach (var configurator in _configurators) { configurator(store); @@ -48,17 +60,32 @@ public sealed class StoreBuilder : IStoreBuilder return store; } + /// + /// 配置历史缓冲区容量。 + /// + /// 历史缓冲区容量;0 表示禁用历史记录。 + /// 当前构建器实例。 + /// 小于 0 时抛出。 + public IStoreBuilder WithHistoryCapacity(int historyCapacity) + { + if (historyCapacity < 0) + { + throw new ArgumentOutOfRangeException(nameof(historyCapacity), historyCapacity, + "History capacity cannot be negative."); + } + + _historyCapacity = historyCapacity; + return this; + } /// - /// 添加一个强类型 reducer。 + /// 配置 action 匹配策略。 /// - /// 当前 reducer 处理的 action 类型。 - /// 要添加的 reducer。 + /// 要使用的匹配策略。 /// 当前构建器实例。 - public IStoreBuilder AddReducer(IReducer reducer) + public IStoreBuilder WithActionMatching(StoreActionMatchingMode actionMatchingMode) { - ArgumentNullException.ThrowIfNull(reducer); - _configurators.Add(store => store.RegisterReducer(reducer)); + _actionMatchingMode = actionMatchingMode; return this; } @@ -85,4 +112,17 @@ public sealed class StoreBuilder : IStoreBuilder _configurators.Add(store => store.RegisterReducer(reducer)); return this; } + + /// + /// 添加一个强类型 reducer。 + /// + /// 当前 reducer 处理的 action 类型。 + /// 要添加的 reducer。 + /// 当前构建器实例。 + public IStoreBuilder AddReducer(IReducer reducer) + { + ArgumentNullException.ThrowIfNull(reducer); + _configurators.Add(store => store.RegisterReducer(reducer)); + return this; + } } \ No newline at end of file diff --git a/GFramework.Core/StateManagement/StoreDispatchedEvent.cs b/GFramework.Core/StateManagement/StoreDispatchedEvent.cs new file mode 100644 index 0000000..47fa53e --- /dev/null +++ b/GFramework.Core/StateManagement/StoreDispatchedEvent.cs @@ -0,0 +1,26 @@ +using GFramework.Core.Abstractions.StateManagement; + +namespace GFramework.Core.StateManagement; + +/// +/// 表示一条由 Store 分发桥接到 EventBus 的事件。 +/// 该事件用于让旧模块在不直接依赖 Store API 的情况下观察 action 分发结果。 +/// +/// 状态树的根状态类型。 +public sealed class StoreDispatchedEvent +{ + /// + /// 初始化一个新的 Store 分发桥接事件。 + /// + /// 本次分发记录。 + /// 时抛出。 + public StoreDispatchedEvent(StoreDispatchRecord dispatchRecord) + { + DispatchRecord = dispatchRecord ?? throw new ArgumentNullException(nameof(dispatchRecord)); + } + + /// + /// 获取本次桥接对应的 Store 分发记录。 + /// + public StoreDispatchRecord DispatchRecord { get; } +} \ No newline at end of file diff --git a/GFramework.Core/StateManagement/StoreStateChangedEvent.cs b/GFramework.Core/StateManagement/StoreStateChangedEvent.cs new file mode 100644 index 0000000..7a438be --- /dev/null +++ b/GFramework.Core/StateManagement/StoreStateChangedEvent.cs @@ -0,0 +1,30 @@ +namespace GFramework.Core.StateManagement; + +/// +/// 表示一条由 Store 状态变更桥接到 EventBus 的事件。 +/// 该事件会复用 Store 对订阅通知的折叠语义,因此在批处理中只会发布最终状态。 +/// +/// 状态树的根状态类型。 +public sealed class StoreStateChangedEvent +{ + /// + /// 初始化一个新的 Store 状态变更桥接事件。 + /// + /// 最新状态快照。 + /// 状态变更时间。 + public StoreStateChangedEvent(TState state, DateTimeOffset changedAt) + { + State = state; + ChangedAt = changedAt; + } + + /// + /// 获取最新状态快照。 + /// + public TState State { get; } + + /// + /// 获取该状态对外广播的时间。 + /// + public DateTimeOffset ChangedAt { get; } +} \ No newline at end of file diff --git a/docs/zh-CN/core/state-management.md b/docs/zh-CN/core/state-management.md index db44943..ee73edd 100644 --- a/docs/zh-CN/core/state-management.md +++ b/docs/zh-CN/core/state-management.md @@ -33,6 +33,10 @@ State Management 提供一个可选的集中式状态容器方案,用于补足 在只读能力上增加: - `Dispatch()`:统一分发 action +- `RunInBatch()`:在一个批处理中合并多次状态通知 +- `Undo()` / `Redo()`:基于历史缓冲区回退或前进状态 +- `TimeTravelTo()`:跳转到指定历史索引 +- `ClearHistory()`:以当前状态重置历史锚点 ### IReducer`` @@ -56,8 +60,11 @@ public interface IReducer - 初始状态快照 - reducer 注册 - middleware 分发管线 +- 可选历史缓冲区、撤销/重做和时间旅行 +- 可选批处理通知折叠 +- 可选多态 action 匹配(基类 / 接口) - 只在状态真正变化时通知订阅者 -- 基础诊断信息(最近一次 action、最近一次分发记录、最近一次状态变化时间) +- 基础诊断信息(最近一次 action、最近一次分发记录、最近一次状态变化时间、历史游标、批处理状态) ## 基本示例 @@ -136,6 +143,7 @@ public class PlayerStateModel : AbstractModel ```csharp var store = (Store)Store .CreateBuilder() + .WithHistoryCapacity(32) .AddReducer((state, action) => state with { Health = Math.Max(0, state.Health - action.Amount) }) .Build(new PlayerState(100, "Player")); @@ -147,6 +155,94 @@ var store = (Store)Store - 测试里快速组装不同配置的 Store - 不希望把 Store 的装配细节散落在多个调用点 +如果需要扩展新语义,`StoreBuilder` 还支持: + +- `WithHistoryCapacity(int)`:开启撤销 / 重做 / 时间旅行缓冲区 +- `WithActionMatching(StoreActionMatchingMode)`:切换 reducer 的 action 匹配策略 + +## 历史记录、撤销 / 重做与时间旅行 + +当状态需要调试回放、工具面板查看或编辑器内撤销/重做时,可以开启历史缓冲区: + +```csharp +var store = new Store( + new PlayerState(100, "Player"), + historyCapacity: 32); + +store.Dispatch(new DamageAction(10)); +store.Dispatch(new RenameAction("Knight")); + +store.Undo(); +store.Redo(); +store.TimeTravelTo(0); +store.ClearHistory(); +``` + +需要注意: + +- `historyCapacity: 0` 表示关闭历史记录 +- 历史只记录“状态真正变化”的 dispatch +- `Undo()` / `Redo()` / `TimeTravelTo()` 会更新当前状态并像普通状态变化一样通知订阅者 +- 当你从历史中回退后再执行新的 `Dispatch()`,原来的 redo 分支会被裁掉 + +## 批处理通知折叠 + +如果一次业务操作会连续触发多个 action,但外部订阅者只需要看到最终状态,可以使用批处理: + +```csharp +store.RunInBatch(() => +{ + store.Dispatch(new DamageAction(10)); + store.Dispatch(new RenameAction("Knight")); +}); +``` + +批处理语义如下: + +- 批处理内部每次 dispatch 仍会立即更新 Store 状态 +- 订阅通知会延迟到最外层批处理结束后再统一发送一次 +- 嵌套批处理是允许的,只有最外层结束时才会发通知 +- 状态变化桥接到 `EventBus` 时,也会复用这个折叠语义 + +## 多态 action 匹配 + +默认情况下,Store 只匹配与 action 运行时类型完全一致的 reducer,这样最稳定,也最容易推导。 + +如果你的 action 体系确实依赖基类或接口复用,可以显式开启多态匹配: + +```csharp +var store = new Store( + new PlayerState(100, "Player"), + actionMatchingMode: StoreActionMatchingMode.IncludeAssignableTypes); +``` + +启用后,reducer 的执行顺序保持确定性: + +1. 精确类型 reducer +2. 最近的基类 reducer +3. 接口 reducer + +只有在你明确需要这类复用关系时才建议启用;大多数业务状态仍建议继续使用默认的精确匹配模式。 + +## Store 到 EventBus 的兼容桥接 + +如果你在迁移旧模块时,现有逻辑仍然依赖 `EventBus`,可以临时把 Store 的 dispatch 和状态变化桥接过去: + +```csharp +using GFramework.Core.Events; +using GFramework.Core.Extensions; + +var eventBus = new EventBus(); +var bridge = store.BridgeToEventBus(eventBus); +``` + +桥接后会发送两类事件: + +- `StoreDispatchedEvent`:每次 dispatch 都会发送一次,即使状态没有变化 +- `StoreStateChangedEvent`:只在状态真正变化时发送;批处理中只发送最终状态 + +不再需要兼容层时,调用 `bridge.UnRegister()` 即可拆除桥接。 + ## 运行时临时注册与注销 如果某个 reducer 或 middleware 只需要在一段生命周期内生效,例如调试探针、临时玩法规则、 @@ -354,7 +450,7 @@ public partial class PlayerPanelController : IController - 一次操作要同时修改多个字段 - 同一个业务操作要在多个界面复用 - 希望把“状态结构”和“状态变化规则”集中在一起 -- 未来要加入 middleware、调试记录或撤销/重做能力 +- 需要 middleware、调试记录、撤销/重做或时间旅行能力 ### 6. 推荐的落地方式 @@ -381,6 +477,8 @@ public partial class PlayerPanelController : IController 2. 让 reducer 保持纯函数风格,不在 reducer 内执行副作用 3. 使用 selector 暴露局部状态,而不是让 UI 自己解析整棵状态树 4. 需要日志或诊断时,优先通过 middleware 扩展,而不是把横切逻辑塞进 reducer +5. 默认优先使用精确类型 reducer 匹配;只有确有继承层次复用需求时再启用多态匹配 +6. `EventBus` 桥接只建议作为迁移过渡层,新模块应优先直接依赖 Store ## 相关文档