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