mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-03-25 04:59:01 +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)
|
- 事件系统接口 (IEvent, IEventBus)
|
||||||
- 依赖注入容器接口 (IIocContainer)
|
- 依赖注入容器接口 (IIocContainer)
|
||||||
- 可绑定属性接口 (IBindableProperty)
|
- 可绑定属性接口 (IBindableProperty)
|
||||||
- 状态管理接口 (IStore, IReducer, IStateSelector)
|
- 状态管理接口 (IStore, IReducer, IStateSelector, IStoreBuilder)
|
||||||
- 日志系统接口 (ILogger)
|
- 日志系统接口 (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 }));
|
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>
|
||||||
/// 测试注销订阅后不会再收到后续通知。
|
/// 测试注销订阅后不会再收到后续通知。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -145,6 +166,28 @@ public class StoreTests
|
|||||||
Assert.That(selectedCounts, Is.EqualTo(new[] { 11 }));
|
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>
|
/// <summary>
|
||||||
/// 测试 ToBindableProperty 可桥接到现有 BindableProperty 风格用法,并与旧属性系统共存。
|
/// 测试 ToBindableProperty 可桥接到现有 BindableProperty 风格用法,并与旧属性系统共存。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -233,6 +276,46 @@ public class StoreTests
|
|||||||
Assert.That(store.LastStateChangedAt, Is.Null);
|
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>
|
/// <summary>
|
||||||
/// 创建一个带有基础 reducer 的测试 Store。
|
/// 创建一个带有基础 reducer 的测试 Store。
|
||||||
/// </summary>
|
/// </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>
|
||||||
/// 记录中间件调用顺序的测试中间件。
|
/// 记录中间件调用顺序的测试中间件。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -14,9 +14,9 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 当前状态变化订阅者列表。
|
/// 当前状态变化订阅者列表。
|
||||||
/// 使用列表而不是委托链,便于精确维护订阅数量并生成稳定的快照调用序列。
|
/// 使用显式订阅对象而不是委托链,便于处理原子初始化订阅、挂起补发和精确解绑。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly List<Action<TState>> _listeners = [];
|
private readonly List<ListenerSubscription> _listeners = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Store 内部所有可变状态的同步锁。
|
/// Store 内部所有可变状态的同步锁。
|
||||||
@ -35,6 +35,12 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly Dictionary<Type, List<IStoreReducerAdapter>> _reducers = [];
|
private readonly Dictionary<Type, List<IStoreReducerAdapter>> _reducers = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 已缓存的局部状态选择视图。
|
||||||
|
/// 该缓存用于避免高频访问的 Model 属性在每次 getter 调用时都创建新的选择对象。
|
||||||
|
/// </summary>
|
||||||
|
private readonly Dictionary<string, object> _selectionCache = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用于判断状态是否发生有效变化的比较器。
|
/// 用于判断状态是否发生有效变化的比较器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -101,12 +107,14 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(listener);
|
ArgumentNullException.ThrowIfNull(listener);
|
||||||
|
|
||||||
|
var subscription = new ListenerSubscription(listener);
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_listeners.Add(listener);
|
_listeners.Add(subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DefaultUnRegister(() => UnSubscribe(listener));
|
return new DefaultUnRegister(() => UnSubscribe(subscription));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -119,9 +127,53 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(listener);
|
ArgumentNullException.ThrowIfNull(listener);
|
||||||
|
|
||||||
var currentState = State;
|
var subscription = new ListenerSubscription(listener)
|
||||||
listener(currentState);
|
{
|
||||||
return Subscribe(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>
|
/// <summary>
|
||||||
@ -135,7 +187,14 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
|||||||
|
|
||||||
lock (_lock)
|
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;
|
_state = context.NextState;
|
||||||
_lastStateChangedAt = context.DispatchedAt;
|
_lastStateChangedAt = context.DispatchedAt;
|
||||||
listenersSnapshot = _listeners.Count > 0 ? _listeners.ToArray() : Array.Empty<Action<TState>>();
|
listenersSnapshot = SnapshotListenersForNotification(context.NextState);
|
||||||
}
|
}
|
||||||
finally
|
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>
|
/// <summary>
|
||||||
/// 注册一个强类型 reducer。
|
/// 注册一个强类型 reducer。
|
||||||
/// 同一 action 类型可注册多个 reducer,它们会按照注册顺序依次归约状态。
|
/// 同一 action 类型可注册多个 reducer,它们会按照注册顺序依次归约状态。
|
||||||
@ -311,6 +379,58 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
|||||||
return this;
|
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>
|
||||||
/// 执行一次完整分发管线。
|
/// 执行一次完整分发管线。
|
||||||
/// </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>
|
/// <summary>
|
||||||
/// 适配不同 action 类型 reducer 的内部统一接口。
|
/// 适配不同 action 类型 reducer 的内部统一接口。
|
||||||
/// Store 通过该接口在运行时按 action 具体类型执行 reducer,而不暴露内部装配细节。
|
/// Store 通过该接口在运行时按 action 具体类型执行 reducer,而不暴露内部装配细节。
|
||||||
@ -431,4 +597,37 @@ public class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
|
|||||||
return _reducer(currentState, action);
|
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>
|
||||||
/// 当前监听器列表。
|
/// 当前监听器列表。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private readonly List<Action<TSelected>> _listeners = [];
|
private readonly List<SelectionListenerSubscription> _listeners = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保护监听器集合和底层 Store 订阅句柄的同步锁。
|
/// 保护监听器集合和底层 Store 订阅句柄的同步锁。
|
||||||
@ -96,6 +96,7 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(onValueChanged);
|
ArgumentNullException.ThrowIfNull(onValueChanged);
|
||||||
|
|
||||||
|
var subscription = new SelectionListenerSubscription(onValueChanged);
|
||||||
var shouldAttach = false;
|
var shouldAttach = false;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
@ -106,7 +107,7 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
|||||||
shouldAttach = true;
|
shouldAttach = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_listeners.Add(onValueChanged);
|
_listeners.Add(subscription);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldAttach)
|
if (shouldAttach)
|
||||||
@ -114,7 +115,7 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
|||||||
AttachToStore();
|
AttachToStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new DefaultUnRegister(() => UnRegister(onValueChanged));
|
return new DefaultUnRegister(() => UnRegister(subscription));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -127,9 +128,58 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(action);
|
ArgumentNullException.ThrowIfNull(action);
|
||||||
|
|
||||||
|
var subscription = new SelectionListenerSubscription(action)
|
||||||
|
{
|
||||||
|
IsActive = false
|
||||||
|
};
|
||||||
var currentValue = Value;
|
var currentValue = Value;
|
||||||
action(currentValue);
|
TSelected? pendingValue = default;
|
||||||
return Register(action);
|
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>
|
/// <summary>
|
||||||
@ -141,11 +191,55 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(onValueChanged);
|
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;
|
IUnRegister? storeSubscription = null;
|
||||||
|
|
||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_listeners.Remove(onValueChanged);
|
subscriptionToRemove.IsSubscribed = false;
|
||||||
|
_listeners.Remove(subscriptionToRemove);
|
||||||
if (_listeners.Count == 0 && _storeSubscription != null)
|
if (_listeners.Count == 0 && _storeSubscription != null)
|
||||||
{
|
{
|
||||||
storeSubscription = _storeSubscription;
|
storeSubscription = _storeSubscription;
|
||||||
@ -186,7 +280,26 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
|||||||
if (!_comparer.Equals(_currentValue, latestValue))
|
if (!_comparer.Equals(_currentValue, latestValue))
|
||||||
{
|
{
|
||||||
_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;
|
shouldNotify = listenersSnapshot.Length > 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -219,7 +332,26 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
|||||||
}
|
}
|
||||||
|
|
||||||
_currentValue = selectedValue;
|
_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)
|
foreach (var listener in listenersSnapshot)
|
||||||
@ -227,4 +359,36 @@ public class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSele
|
|||||||
listener(selectedValue);
|
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. **创建命令**:实例化命令对象,传入必要的参数
|
1. **创建命令**:实例化命令对象,传入必要的参数
|
||||||
2. **执行命令**:调用 `Execute()` 方法,内部委托给 `OnExecute()`
|
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 - 命令总线
|
## CommandBus - 命令总线
|
||||||
|
|
||||||
@ -448,4 +472,4 @@ public class LoadLevelCommand : AbstractCommand
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**许可证**:Apache 2.0
|
**许可证**:Apache 2.0
|
||||||
|
|||||||
@ -129,6 +129,206 @@ public class PlayerStateModel : AbstractModel
|
|||||||
|
|
||||||
这样可以保留 Model 的生命周期和领域边界,同时获得统一状态入口。
|
这样可以保留 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
|
## 什么时候不用 Store
|
||||||
|
|
||||||
以下情况继续优先使用 `BindableProperty<T>`:
|
以下情况继续优先使用 `BindableProperty<T>`:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user