mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-03-24 04:06:48 +08:00
feat(state): 添加 StoreBuilder 配置功能并优化状态管理
- 引入 StoreBuilder<TState> 支持模块化配置 reducer 和中间件 - 实现状态选择视图缓存机制提升性能 - 重构订阅管理使用精确订阅对象替代委托链 - 增强 SubscribeWithInitValue 方法防止状态变化遗漏 - 添加完整的状态管理文档示例和测试用例 - 更新接口定义支持新的构建器功能
This commit is contained in:
parent
79f1240e1d
commit
79cebb95b5
@ -12,7 +12,7 @@ GFramework 框架的抽象层定义模块,包含所有核心组件的接口定
|
||||
- 事件系统接口 (IEvent, IEventBus)
|
||||
- 依赖注入容器接口 (IIocContainer)
|
||||
- 可绑定属性接口 (IBindableProperty)
|
||||
- 状态管理接口 (IStore, IReducer, IStateSelector)
|
||||
- 状态管理接口 (IStore, IReducer, IStateSelector, IStoreBuilder)
|
||||
- 日志系统接口 (ILogger)
|
||||
|
||||
## 设计原则
|
||||
|
||||
@ -0,0 +1,46 @@
|
||||
namespace GFramework.Core.Abstractions.StateManagement;
|
||||
|
||||
/// <summary>
|
||||
/// 定义 Store 构建器接口,用于在创建 Store 之前完成 reducer、中间件和比较器配置。
|
||||
/// 该抽象适用于模块化注册、依赖注入装配和测试工厂,避免调用方必须依赖具体 Store 类型进行配置。
|
||||
/// </summary>
|
||||
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||
public interface IStoreBuilder<TState>
|
||||
{
|
||||
/// <summary>
|
||||
/// 配置用于判断状态是否真正变化的比较器。
|
||||
/// </summary>
|
||||
/// <param name="comparer">状态比较器。</param>
|
||||
/// <returns>当前构建器实例。</returns>
|
||||
IStoreBuilder<TState> WithComparer(IEqualityComparer<TState> comparer);
|
||||
|
||||
/// <summary>
|
||||
/// 添加一个强类型 reducer。
|
||||
/// </summary>
|
||||
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
|
||||
/// <param name="reducer">要添加的 reducer。</param>
|
||||
/// <returns>当前构建器实例。</returns>
|
||||
IStoreBuilder<TState> AddReducer<TAction>(IReducer<TState, TAction> reducer);
|
||||
|
||||
/// <summary>
|
||||
/// 使用委托快速添加一个 reducer。
|
||||
/// </summary>
|
||||
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
|
||||
/// <param name="reducer">执行归约的委托。</param>
|
||||
/// <returns>当前构建器实例。</returns>
|
||||
IStoreBuilder<TState> AddReducer<TAction>(Func<TState, TAction, TState> reducer);
|
||||
|
||||
/// <summary>
|
||||
/// 添加一个 Store 中间件。
|
||||
/// </summary>
|
||||
/// <param name="middleware">要添加的中间件。</param>
|
||||
/// <returns>当前构建器实例。</returns>
|
||||
IStoreBuilder<TState> UseMiddleware(IStoreMiddleware<TState> middleware);
|
||||
|
||||
/// <summary>
|
||||
/// 基于给定初始状态创建一个新的 Store。
|
||||
/// </summary>
|
||||
/// <param name="initialState">Store 的初始状态。</param>
|
||||
/// <returns>已应用当前构建器配置的 Store 实例。</returns>
|
||||
IStore<TState> Build(TState initialState);
|
||||
}
|
||||
@ -90,6 +90,27 @@ public class StoreTests
|
||||
Assert.That(receivedCounts, Is.EqualTo(new[] { 5, 8 }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 Store 的 SubscribeWithInitValue 在初始化回放期间不会漏掉后续状态变化。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SubscribeWithInitValue_Should_Not_Miss_Changes_During_Init_Callback()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var receivedCounts = new List<int>();
|
||||
|
||||
store.SubscribeWithInitValue(state =>
|
||||
{
|
||||
receivedCounts.Add(state.Count);
|
||||
if (receivedCounts.Count == 1)
|
||||
{
|
||||
store.Dispatch(new IncrementAction(1));
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(receivedCounts, Is.EqualTo(new[] { 0, 1 }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试注销订阅后不会再收到后续通知。
|
||||
/// </summary>
|
||||
@ -145,6 +166,28 @@ public class StoreTests
|
||||
Assert.That(selectedCounts, Is.EqualTo(new[] { 11 }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 StoreSelection 的 RegisterWithInitValue 在初始化回放期间不会漏掉后续局部状态变化。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void Selection_RegisterWithInitValue_Should_Not_Miss_Changes_During_Init_Callback()
|
||||
{
|
||||
var store = CreateStore();
|
||||
var selection = store.Select(state => state.Count);
|
||||
var receivedCounts = new List<int>();
|
||||
|
||||
selection.RegisterWithInitValue(value =>
|
||||
{
|
||||
receivedCounts.Add(value);
|
||||
if (receivedCounts.Count == 1)
|
||||
{
|
||||
store.Dispatch(new IncrementAction(1));
|
||||
}
|
||||
});
|
||||
|
||||
Assert.That(receivedCounts, Is.EqualTo(new[] { 0, 1 }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 ToBindableProperty 可桥接到现有 BindableProperty 风格用法,并与旧属性系统共存。
|
||||
/// </summary>
|
||||
@ -233,6 +276,46 @@ public class StoreTests
|
||||
Assert.That(store.LastStateChangedAt, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 Store 能够复用同一个缓存选择视图实例。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void GetOrCreateSelection_Should_Return_Cached_Instance_For_Same_Key()
|
||||
{
|
||||
var store = CreateStore();
|
||||
|
||||
var first = store.GetOrCreateSelection("count", state => state.Count);
|
||||
var second = store.GetOrCreateSelection("count", state => state.Count);
|
||||
|
||||
Assert.That(second, Is.SameAs(first));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 测试 StoreBuilder 能够应用 reducer、中间件和状态比较器配置。
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void StoreBuilder_Should_Apply_Configured_Reducers_Middlewares_And_Comparer()
|
||||
{
|
||||
var logs = new List<string>();
|
||||
var store = (Store<CounterState>)Store<CounterState>
|
||||
.CreateBuilder()
|
||||
.WithComparer(new CounterStateNameInsensitiveComparer())
|
||||
.AddReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount })
|
||||
.AddReducer<RenameAction>((state, action) => state with { Name = action.Name })
|
||||
.UseMiddleware(new RecordingMiddleware(logs, "builder"))
|
||||
.Build(new CounterState(0, "Player"));
|
||||
|
||||
var notifyCount = 0;
|
||||
store.Subscribe(_ => notifyCount++);
|
||||
|
||||
store.Dispatch(new RenameAction("player"));
|
||||
store.Dispatch(new IncrementAction(2));
|
||||
|
||||
Assert.That(notifyCount, Is.EqualTo(1));
|
||||
Assert.That(store.State.Count, Is.EqualTo(2));
|
||||
Assert.That(logs, Is.EqualTo(new[] { "builder:before", "builder:after", "builder:before", "builder:after" }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个带有基础 reducer 的测试 Store。
|
||||
/// </summary>
|
||||
@ -315,6 +398,45 @@ public class StoreTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用于测试 StoreBuilder 自定义状态比较器的比较器实现。
|
||||
/// 该比较器忽略名称字段的大小写差异,并保持计数字段严格比较。
|
||||
/// </summary>
|
||||
private sealed class CounterStateNameInsensitiveComparer : IEqualityComparer<CounterState>
|
||||
{
|
||||
/// <summary>
|
||||
/// 判断两个状态是否在业务语义上相等。
|
||||
/// </summary>
|
||||
/// <param name="x">左侧状态。</param>
|
||||
/// <param name="y">右侧状态。</param>
|
||||
/// <returns>若两个状态在计数相同且名称仅大小写不同,则返回 <see langword="true"/>。</returns>
|
||||
public bool Equals(CounterState? x, CounterState? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return x.Count == y.Count &&
|
||||
string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 返回与业务语义一致的哈希码。
|
||||
/// </summary>
|
||||
/// <param name="obj">目标状态。</param>
|
||||
/// <returns>忽略名称大小写后的哈希码。</returns>
|
||||
public int GetHashCode(CounterState obj)
|
||||
{
|
||||
return HashCode.Combine(obj.Count, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 记录中间件调用顺序的测试中间件。
|
||||
/// </summary>
|
||||
|
||||
@ -14,9 +14,9 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
{
|
||||
/// <summary>
|
||||
/// 当前状态变化订阅者列表。
|
||||
/// 使用列表而不是委托链,便于精确维护订阅数量并生成稳定的快照调用序列。
|
||||
/// 使用显式订阅对象而不是委托链,便于处理原子初始化订阅、挂起补发和精确解绑。
|
||||
/// </summary>
|
||||
private readonly List<Action<TState>> _listeners = [];
|
||||
private readonly List<ListenerSubscription> _listeners = [];
|
||||
|
||||
/// <summary>
|
||||
/// Store 内部所有可变状态的同步锁。
|
||||
@ -35,6 +35,12 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
/// </summary>
|
||||
private readonly Dictionary<Type, List<IStoreReducerAdapter>> _reducers = [];
|
||||
|
||||
/// <summary>
|
||||
/// 已缓存的局部状态选择视图。
|
||||
/// 该缓存用于避免高频访问的 Model 属性在每次 getter 调用时都创建新的选择对象。
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, object> _selectionCache = [];
|
||||
|
||||
/// <summary>
|
||||
/// 用于判断状态是否发生有效变化的比较器。
|
||||
/// </summary>
|
||||
@ -101,12 +107,14 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(listener);
|
||||
|
||||
var subscription = new ListenerSubscription(listener);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_listeners.Add(listener);
|
||||
_listeners.Add(subscription);
|
||||
}
|
||||
|
||||
return new DefaultUnRegister(() => UnSubscribe(listener));
|
||||
return new DefaultUnRegister(() => UnSubscribe(subscription));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -119,9 +127,53 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(listener);
|
||||
|
||||
var currentState = State;
|
||||
listener(currentState);
|
||||
return Subscribe(listener);
|
||||
var subscription = new ListenerSubscription(listener)
|
||||
{
|
||||
IsActive = false
|
||||
};
|
||||
TState currentState;
|
||||
TState? pendingState = default;
|
||||
var hasPendingState = false;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
currentState = _state;
|
||||
_listeners.Add(subscription);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
listener(currentState);
|
||||
}
|
||||
catch
|
||||
{
|
||||
UnSubscribe(subscription);
|
||||
throw;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!subscription.IsSubscribed)
|
||||
{
|
||||
return new DefaultUnRegister(() => { });
|
||||
}
|
||||
|
||||
subscription.IsActive = true;
|
||||
if (subscription.HasPendingState)
|
||||
{
|
||||
pendingState = subscription.PendingState;
|
||||
hasPendingState = true;
|
||||
subscription.HasPendingState = false;
|
||||
subscription.PendingState = default!;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPendingState)
|
||||
{
|
||||
listener(pendingState!);
|
||||
}
|
||||
|
||||
return new DefaultUnRegister(() => UnSubscribe(subscription));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -135,7 +187,14 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_listeners.Remove(listener);
|
||||
var index = _listeners.FindIndex(subscription => subscription.Listener == listener);
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_listeners[index].IsSubscribed = false;
|
||||
_listeners.RemoveAt(index);
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,7 +240,7 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
|
||||
_state = context.NextState;
|
||||
_lastStateChangedAt = context.DispatchedAt;
|
||||
listenersSnapshot = _listeners.Count > 0 ? _listeners.ToArray() : Array.Empty<Action<TState>>();
|
||||
listenersSnapshot = SnapshotListenersForNotification(context.NextState);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -252,6 +311,15 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建一个用于当前状态类型的 Store 构建器。
|
||||
/// </summary>
|
||||
/// <returns>新的 Store 构建器实例。</returns>
|
||||
public static StoreBuilder<TState> CreateBuilder()
|
||||
{
|
||||
return new StoreBuilder<TState>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个强类型 reducer。
|
||||
/// 同一 action 类型可注册多个 reducer,它们会按照注册顺序依次归约状态。
|
||||
@ -311,6 +379,58 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取或创建一个带缓存的局部状态选择视图。
|
||||
/// 对于会被频繁读取的 Model 只读属性,推荐使用该方法复用同一个选择实例。
|
||||
/// </summary>
|
||||
/// <typeparam name="TSelected">局部状态类型。</typeparam>
|
||||
/// <param name="key">缓存键,调用方应保证同一个键始终表示同一局部状态语义。</param>
|
||||
/// <param name="selector">状态选择委托。</param>
|
||||
/// <param name="comparer">用于比较局部状态是否变化的比较器。</param>
|
||||
/// <returns>稳定复用的选择视图实例。</returns>
|
||||
public StoreSelection<TState, TSelected> GetOrCreateSelection<TSelected>(
|
||||
string key,
|
||||
Func<TState, TSelected> selector,
|
||||
IEqualityComparer<TSelected>? comparer = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
ArgumentNullException.ThrowIfNull(selector);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_selectionCache.TryGetValue(key, out var existing))
|
||||
{
|
||||
if (existing is StoreSelection<TState, TSelected> cachedSelection)
|
||||
{
|
||||
return cachedSelection;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"A cached selection with key '{key}' already exists with a different selected type.");
|
||||
}
|
||||
|
||||
var selection = new StoreSelection<TState, TSelected>(this, selector, comparer);
|
||||
_selectionCache[key] = selection;
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取或创建一个带缓存的只读 BindableProperty 风格视图。
|
||||
/// </summary>
|
||||
/// <typeparam name="TSelected">局部状态类型。</typeparam>
|
||||
/// <param name="key">缓存键,调用方应保证同一个键始终表示同一局部状态语义。</param>
|
||||
/// <param name="selector">状态选择委托。</param>
|
||||
/// <param name="comparer">用于比较局部状态是否变化的比较器。</param>
|
||||
/// <returns>稳定复用的只读绑定视图。</returns>
|
||||
public StoreSelection<TState, TSelected> GetOrCreateBindableProperty<TSelected>(
|
||||
string key,
|
||||
Func<TState, TSelected> selector,
|
||||
IEqualityComparer<TSelected>? comparer = null)
|
||||
{
|
||||
return GetOrCreateSelection(key, selector, comparer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行一次完整分发管线。
|
||||
/// </summary>
|
||||
@ -368,6 +488,52 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从当前订阅集合中提取需要立即通知的监听器快照,并为尚未激活的初始化订阅保存待补发状态。
|
||||
/// </summary>
|
||||
/// <param name="nextState">本次分发后的最新状态。</param>
|
||||
/// <returns>需要在锁外立即调用的监听器快照。</returns>
|
||||
private Action<TState>[] SnapshotListenersForNotification(TState nextState)
|
||||
{
|
||||
if (_listeners.Count == 0)
|
||||
{
|
||||
return Array.Empty<Action<TState>>();
|
||||
}
|
||||
|
||||
var activeListeners = new List<Action<TState>>(_listeners.Count);
|
||||
foreach (var subscription in _listeners)
|
||||
{
|
||||
if (!subscription.IsSubscribed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subscription.IsActive)
|
||||
{
|
||||
activeListeners.Add(subscription.Listener);
|
||||
continue;
|
||||
}
|
||||
|
||||
subscription.PendingState = nextState;
|
||||
subscription.HasPendingState = true;
|
||||
}
|
||||
|
||||
return activeListeners.Count > 0 ? activeListeners.ToArray() : Array.Empty<Action<TState>>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 解绑一个精确的订阅对象。
|
||||
/// </summary>
|
||||
/// <param name="subscription">要解绑的订阅对象。</param>
|
||||
private void UnSubscribe(ListenerSubscription subscription)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
subscription.IsSubscribed = false;
|
||||
_listeners.Remove(subscription);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 适配不同 action 类型 reducer 的内部统一接口。
|
||||
/// Store 通过该接口在运行时按 action 具体类型执行 reducer,而不暴露内部装配细节。
|
||||
@ -431,4 +597,37 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
||||
return _reducer(currentState, action);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个 Store 状态监听订阅。
|
||||
/// 该对象用于支持初始化回放与正式订阅之间的原子衔接,避免 SubscribeWithInitValue 漏掉状态变化。
|
||||
/// </summary>
|
||||
private sealed class ListenerSubscription(Action<TState> listener)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取订阅回调。
|
||||
/// </summary>
|
||||
public Action<TState> Listener { get; } = listener;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置订阅是否已激活。
|
||||
/// 非激活状态表示正在执行初始化回放,此时新的状态变化会被暂存为待补发值。
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置订阅是否仍然有效。
|
||||
/// </summary>
|
||||
public bool IsSubscribed { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否存在待补发的最新状态。
|
||||
/// </summary>
|
||||
public bool HasPendingState { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置初始化阶段积累的最新状态。
|
||||
/// </summary>
|
||||
public TState PendingState { get; set; } = default!;
|
||||
}
|
||||
}
|
||||
87
GFramework.Core/StateManagement/StoreBuilder.cs
Normal file
87
GFramework.Core/StateManagement/StoreBuilder.cs
Normal file
@ -0,0 +1,87 @@
|
||||
using GFramework.Core.Abstractions.StateManagement;
|
||||
|
||||
namespace GFramework.Core.StateManagement;
|
||||
|
||||
/// <summary>
|
||||
/// Store 构建器的默认实现。
|
||||
/// 该类型用于在 Store 创建之前集中配置比较器、reducer 和中间件,适合模块安装和测试工厂场景。
|
||||
/// </summary>
|
||||
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||
public class StoreBuilder<TState> : IStoreBuilder<TState>
|
||||
{
|
||||
/// <summary>
|
||||
/// 延迟应用到 Store 的配置操作列表。
|
||||
/// 采用延迟配置而不是直接缓存 reducer 适配器,可复用 Store 自身的注册和验证逻辑。
|
||||
/// </summary>
|
||||
private readonly List<Action<Store<TState>>> _configurators = [];
|
||||
|
||||
/// <summary>
|
||||
/// 状态比较器。
|
||||
/// </summary>
|
||||
private IEqualityComparer<TState>? _comparer;
|
||||
|
||||
/// <summary>
|
||||
/// 配置状态比较器。
|
||||
/// </summary>
|
||||
/// <param name="comparer">状态比较器。</param>
|
||||
/// <returns>当前构建器实例。</returns>
|
||||
public IStoreBuilder<TState> WithComparer(IEqualityComparer<TState> comparer)
|
||||
{
|
||||
_comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 使用委托快速添加一个 reducer。
|
||||
/// </summary>
|
||||
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
|
||||
/// <param name="reducer">执行归约的委托。</param>
|
||||
/// <returns>当前构建器实例。</returns>
|
||||
public IStoreBuilder<TState> AddReducer<TAction>(Func<TState, TAction, TState> reducer)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(reducer);
|
||||
_configurators.Add(store => store.RegisterReducer(reducer));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 添加一个 Store 中间件。
|
||||
/// </summary>
|
||||
/// <param name="middleware">要添加的中间件。</param>
|
||||
/// <returns>当前构建器实例。</returns>
|
||||
public IStoreBuilder<TState> UseMiddleware(IStoreMiddleware<TState> middleware)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(middleware);
|
||||
_configurators.Add(store => store.UseMiddleware(middleware));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 基于给定初始状态创建一个新的 Store。
|
||||
/// </summary>
|
||||
/// <param name="initialState">Store 的初始状态。</param>
|
||||
/// <returns>已应用当前构建器配置的 Store 实例。</returns>
|
||||
public IStore<TState> Build(TState initialState)
|
||||
{
|
||||
var store = new Store<TState>(initialState, _comparer);
|
||||
foreach (var configurator in _configurators)
|
||||
{
|
||||
configurator(store);
|
||||
}
|
||||
|
||||
return store;
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,7 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
/// <summary>
|
||||
/// 当前监听器列表。
|
||||
/// </summary>
|
||||
private readonly List<Action<TSelected>> _listeners = [];
|
||||
private readonly List<SelectionListenerSubscription> _listeners = [];
|
||||
|
||||
/// <summary>
|
||||
/// 保护监听器集合和底层 Store 订阅句柄的同步锁。
|
||||
@ -96,6 +96,7 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(onValueChanged);
|
||||
|
||||
var subscription = new SelectionListenerSubscription(onValueChanged);
|
||||
var shouldAttach = false;
|
||||
|
||||
lock (_lock)
|
||||
@ -106,7 +107,7 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
shouldAttach = true;
|
||||
}
|
||||
|
||||
_listeners.Add(onValueChanged);
|
||||
_listeners.Add(subscription);
|
||||
}
|
||||
|
||||
if (shouldAttach)
|
||||
@ -114,7 +115,7 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
AttachToStore();
|
||||
}
|
||||
|
||||
return new DefaultUnRegister(() => UnRegister(onValueChanged));
|
||||
return new DefaultUnRegister(() => UnRegister(subscription));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -127,9 +128,58 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
|
||||
var subscription = new SelectionListenerSubscription(action)
|
||||
{
|
||||
IsActive = false
|
||||
};
|
||||
var currentValue = Value;
|
||||
action(currentValue);
|
||||
return Register(action);
|
||||
TSelected? pendingValue = default;
|
||||
var hasPendingValue = false;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_listeners.Count == 0)
|
||||
{
|
||||
_currentValue = currentValue;
|
||||
}
|
||||
|
||||
_listeners.Add(subscription);
|
||||
}
|
||||
|
||||
EnsureAttached();
|
||||
try
|
||||
{
|
||||
action(currentValue);
|
||||
}
|
||||
catch
|
||||
{
|
||||
UnRegister(subscription);
|
||||
throw;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (!subscription.IsSubscribed)
|
||||
{
|
||||
return new DefaultUnRegister(() => { });
|
||||
}
|
||||
|
||||
subscription.IsActive = true;
|
||||
if (subscription.HasPendingValue)
|
||||
{
|
||||
pendingValue = subscription.PendingValue;
|
||||
hasPendingValue = true;
|
||||
subscription.PendingValue = default!;
|
||||
subscription.HasPendingValue = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasPendingValue)
|
||||
{
|
||||
action(pendingValue!);
|
||||
}
|
||||
|
||||
return new DefaultUnRegister(() => UnRegister(subscription));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -141,11 +191,55 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(onValueChanged);
|
||||
|
||||
SelectionListenerSubscription? subscriptionToRemove = null;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var index = _listeners.FindIndex(subscription => subscription.Listener == onValueChanged);
|
||||
if (index < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
subscriptionToRemove = _listeners[index];
|
||||
}
|
||||
|
||||
if (subscriptionToRemove != null)
|
||||
{
|
||||
UnRegister(subscriptionToRemove);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保当前选择视图已连接到底层 Store。
|
||||
/// </summary>
|
||||
private void EnsureAttached()
|
||||
{
|
||||
var shouldAttach = false;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
shouldAttach = _listeners.Count > 0 && _storeSubscription == null;
|
||||
}
|
||||
|
||||
if (shouldAttach)
|
||||
{
|
||||
AttachToStore();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取消注册一个精确的选择结果监听器。
|
||||
/// </summary>
|
||||
/// <param name="subscriptionToRemove">需要移除的订阅对象。</param>
|
||||
private void UnRegister(SelectionListenerSubscription subscriptionToRemove)
|
||||
{
|
||||
IUnRegister? storeSubscription = null;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_listeners.Remove(onValueChanged);
|
||||
subscriptionToRemove.IsSubscribed = false;
|
||||
_listeners.Remove(subscriptionToRemove);
|
||||
if (_listeners.Count == 0 && _storeSubscription != null)
|
||||
{
|
||||
storeSubscription = _storeSubscription;
|
||||
@ -186,7 +280,26 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
if (!_comparer.Equals(_currentValue, latestValue))
|
||||
{
|
||||
_currentValue = latestValue;
|
||||
listenersSnapshot = _listeners.ToArray();
|
||||
foreach (var listener in _listeners)
|
||||
{
|
||||
if (!listener.IsSubscribed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listener.IsActive)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
listener.PendingValue = latestValue;
|
||||
listener.HasPendingValue = true;
|
||||
}
|
||||
|
||||
listenersSnapshot = _listeners
|
||||
.Where(listener => listener.IsSubscribed && listener.IsActive)
|
||||
.Select(listener => listener.Listener)
|
||||
.ToArray();
|
||||
shouldNotify = listenersSnapshot.Length > 0;
|
||||
}
|
||||
}
|
||||
@ -219,7 +332,26 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
}
|
||||
|
||||
_currentValue = selectedValue;
|
||||
listenersSnapshot = _listeners.ToArray();
|
||||
foreach (var listener in _listeners)
|
||||
{
|
||||
if (!listener.IsSubscribed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (listener.IsActive)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
listener.PendingValue = selectedValue;
|
||||
listener.HasPendingValue = true;
|
||||
}
|
||||
|
||||
listenersSnapshot = _listeners
|
||||
.Where(listener => listener.IsSubscribed && listener.IsActive)
|
||||
.Select(listener => listener.Listener)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
foreach (var listener in listenersSnapshot)
|
||||
@ -227,4 +359,36 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
||||
listener(selectedValue);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表示一个选择结果监听订阅。
|
||||
/// 该对象用于保证 RegisterWithInitValue 在初始化回放与后续状态变化之间不会漏掉最近一次更新。
|
||||
/// </summary>
|
||||
private sealed class SelectionListenerSubscription(Action<TSelected> listener)
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取订阅回调。
|
||||
/// </summary>
|
||||
public Action<TSelected> Listener { get; } = listener;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置订阅是否已激活。
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置订阅是否仍然有效。
|
||||
/// </summary>
|
||||
public bool IsSubscribed { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置是否存在待补发的局部状态值。
|
||||
/// </summary>
|
||||
public bool HasPendingValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 获取或设置初始化阶段积累的最新局部状态值。
|
||||
/// </summary>
|
||||
public TSelected PendingValue { get; set; } = default!;
|
||||
}
|
||||
}
|
||||
@ -109,7 +109,7 @@ public class UISystem : AbstractSystem
|
||||
}
|
||||
```
|
||||
|
||||
## 命令的生命周期
|
||||
## 命令的生命周期
|
||||
|
||||
1. **创建命令**:实例化命令对象,传入必要的参数
|
||||
2. **执行命令**:调用 `Execute()` 方法,内部委托给 `OnExecute()`
|
||||
@ -118,9 +118,33 @@ public class UISystem : AbstractSystem
|
||||
|
||||
**注意事项:**
|
||||
|
||||
- 命令应该是无状态的,执行完即可丢弃
|
||||
- 避免在命令中保存长期引用
|
||||
- 命令执行应该是原子操作
|
||||
- 命令应该是无状态的,执行完即可丢弃
|
||||
- 避免在命令中保存长期引用
|
||||
- 命令执行应该是原子操作
|
||||
|
||||
### 与 Store 配合使用
|
||||
|
||||
当某个 Model 内部使用 `Store<TState>` 管理复杂聚合状态时,Command 依然是推荐的写入口。
|
||||
|
||||
```csharp
|
||||
public sealed class DamagePlayerCommand(int amount) : AbstractCommand
|
||||
{
|
||||
protected override void OnExecute()
|
||||
{
|
||||
var model = this.GetModel<PlayerPanelModel>();
|
||||
model.Store.Dispatch(new DamagePlayerAction(amount));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这样可以保持现有职责边界不变:
|
||||
|
||||
- Controller 发送命令
|
||||
- Command 执行操作
|
||||
- Model 承载状态
|
||||
- Store 负责统一归约状态变化
|
||||
|
||||
完整示例见 [`state-management`](./state-management)。
|
||||
|
||||
## CommandBus - 命令总线
|
||||
|
||||
@ -448,4 +472,4 @@ public class LoadLevelCommand : AbstractCommand
|
||||
|
||||
---
|
||||
|
||||
**许可证**:Apache 2.0
|
||||
**许可证**:Apache 2.0
|
||||
|
||||
@ -129,6 +129,206 @@ public class PlayerStateModel : AbstractModel
|
||||
|
||||
这样可以保留 Model 的生命周期和领域边界,同时获得统一状态入口。
|
||||
|
||||
## 使用 StoreBuilder 组织配置
|
||||
|
||||
当一个 Store 需要在模块安装、测试工厂或 DI 装配阶段统一配置时,可以使用 `StoreBuilder<TState>`:
|
||||
|
||||
```csharp
|
||||
var store = (Store<PlayerState>)Store<PlayerState>
|
||||
.CreateBuilder()
|
||||
.AddReducer<DamageAction>((state, action) =>
|
||||
state with { Health = Math.Max(0, state.Health - action.Amount) })
|
||||
.Build(new PlayerState(100, "Player"));
|
||||
```
|
||||
|
||||
适合以下场景:
|
||||
|
||||
- 模块启动时集中注册 reducer 和 middleware
|
||||
- 测试里快速组装不同配置的 Store
|
||||
- 不希望把 Store 的装配细节散落在多个调用点
|
||||
|
||||
## 官方示例:角色面板状态
|
||||
|
||||
下面给出一个更贴近 GFramework 实战的完整示例,展示如何把 `Store<TState>` 放进 Model,
|
||||
再通过 Command 修改状态,并在 Controller 中使用 selector 做 UI 绑定。
|
||||
|
||||
### 1. 定义状态和 action
|
||||
|
||||
```csharp
|
||||
public sealed record PlayerPanelState(
|
||||
string Name,
|
||||
int Health,
|
||||
int MaxHealth,
|
||||
int Level);
|
||||
|
||||
public sealed record DamagePlayerAction(int Amount);
|
||||
public sealed record HealPlayerAction(int Amount);
|
||||
public sealed record RenamePlayerAction(string Name);
|
||||
```
|
||||
|
||||
### 2. 在 Model 中承载 Store
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Property;
|
||||
using GFramework.Core.Model;
|
||||
using GFramework.Core.Extensions;
|
||||
using GFramework.Core.StateManagement;
|
||||
|
||||
public class PlayerPanelModel : AbstractModel
|
||||
{
|
||||
public Store<PlayerPanelState> Store { get; } =
|
||||
new(new PlayerPanelState("Player", 100, 100, 1));
|
||||
|
||||
// 使用带缓存的选择视图,避免属性 getter 每次访问都创建新的 StoreSelection 实例。
|
||||
public IReadonlyBindableProperty<int> Health =>
|
||||
Store.GetOrCreateBindableProperty("health", state => state.Health);
|
||||
|
||||
public IReadonlyBindableProperty<string> Name =>
|
||||
Store.GetOrCreateBindableProperty("name", state => state.Name);
|
||||
|
||||
public IReadonlyBindableProperty<float> HealthPercent =>
|
||||
Store.GetOrCreateBindableProperty("health_percent",
|
||||
state => (float)state.Health / state.MaxHealth);
|
||||
|
||||
protected override void OnInit()
|
||||
{
|
||||
Store
|
||||
.RegisterReducer<DamagePlayerAction>((state, action) =>
|
||||
state with
|
||||
{
|
||||
Health = Math.Max(0, state.Health - action.Amount)
|
||||
})
|
||||
.RegisterReducer<HealPlayerAction>((state, action) =>
|
||||
state with
|
||||
{
|
||||
Health = Math.Min(state.MaxHealth, state.Health + action.Amount)
|
||||
})
|
||||
.RegisterReducer<RenamePlayerAction>((state, action) =>
|
||||
state with
|
||||
{
|
||||
Name = action.Name
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个写法的关键点是:
|
||||
|
||||
- 状态结构集中定义在 `PlayerPanelState`
|
||||
- 所有状态修改都经过 reducer
|
||||
- 高频访问的局部状态通过缓存选择视图复用实例
|
||||
- Controller 只消费局部只读视图,不直接修改 Store
|
||||
|
||||
### 3. 通过 Command 修改状态
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Command;
|
||||
|
||||
public sealed class DamagePlayerCommand(int amount) : AbstractCommand
|
||||
{
|
||||
protected override void OnExecute()
|
||||
{
|
||||
var model = this.GetModel<PlayerPanelModel>();
|
||||
model.Store.Dispatch(new DamagePlayerAction(amount));
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RenamePlayerCommand(string name) : AbstractCommand
|
||||
{
|
||||
protected override void OnExecute()
|
||||
{
|
||||
var model = this.GetModel<PlayerPanelModel>();
|
||||
model.Store.Dispatch(new RenamePlayerAction(name));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里仍然遵循 GFramework 现有分层:
|
||||
|
||||
- Controller 负责转发用户意图
|
||||
- Command 负责执行业务操作
|
||||
- Model 持有状态
|
||||
- Store 负责统一归约状态变化
|
||||
|
||||
### 4. 在 Controller 中绑定局部状态
|
||||
|
||||
```csharp
|
||||
using GFramework.Core.Abstractions.Controller;
|
||||
using GFramework.Core.Abstractions.Events;
|
||||
using GFramework.Core.Events;
|
||||
using GFramework.Core.Extensions;
|
||||
using GFramework.SourceGenerators.Abstractions.Rule;
|
||||
|
||||
[ContextAware]
|
||||
public partial class PlayerPanelController : IController
|
||||
{
|
||||
private readonly IUnRegisterList _unRegisterList = new UnRegisterList();
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
var model = this.GetModel<PlayerPanelModel>();
|
||||
|
||||
model.Name
|
||||
.RegisterWithInitValue(name =>
|
||||
{
|
||||
Console.WriteLine($"Player Name: {name}");
|
||||
})
|
||||
.AddToUnregisterList(_unRegisterList);
|
||||
|
||||
model.Health
|
||||
.RegisterWithInitValue(health =>
|
||||
{
|
||||
Console.WriteLine($"Health: {health}");
|
||||
})
|
||||
.AddToUnregisterList(_unRegisterList);
|
||||
|
||||
model.HealthPercent
|
||||
.RegisterWithInitValue(percent =>
|
||||
{
|
||||
Console.WriteLine($"Health Percent: {percent:P0}");
|
||||
})
|
||||
.AddToUnregisterList(_unRegisterList);
|
||||
}
|
||||
|
||||
public void OnDamageButtonClicked()
|
||||
{
|
||||
this.SendCommand(new DamagePlayerCommand(15));
|
||||
}
|
||||
|
||||
public void OnRenameButtonClicked(string newName)
|
||||
{
|
||||
this.SendCommand(new RenamePlayerCommand(newName));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 什么时候这个示例比 BindableProperty 更合适
|
||||
|
||||
如果你只需要:
|
||||
|
||||
- `Health`
|
||||
- `Name`
|
||||
- `Level`
|
||||
|
||||
分别独立通知,那么多个 `BindableProperty<T>` 就足够了。
|
||||
|
||||
如果你很快会遇到以下问题,这个 Store 方案会更稳:
|
||||
|
||||
- 一次操作要同时修改多个字段
|
||||
- 同一个业务操作要在多个界面复用
|
||||
- 希望把“状态结构”和“状态变化规则”集中在一起
|
||||
- 未来要加入 middleware、调试记录或撤销/重做能力
|
||||
|
||||
### 6. 推荐的落地方式
|
||||
|
||||
在实际项目里,建议按这个顺序引入:
|
||||
|
||||
1. 先把复杂聚合状态封装到某个 Model 内部
|
||||
2. 再把修改入口逐步迁移到 Command
|
||||
3. 最后在 Controller 层使用 selector 或 `ToBindableProperty()` 做局部绑定
|
||||
|
||||
这样不会破坏现有 `BindableProperty<T>` 的轻量工作流,也能让复杂状态逐步收敛到统一入口。
|
||||
|
||||
## 什么时候不用 Store
|
||||
|
||||
以下情况继续优先使用 `BindableProperty<T>`:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user