From 79cebb95b5058ed15636a79f6373a560f6fc120a Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 23 Mar 2026 19:59:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(state):=20=E6=B7=BB=E5=8A=A0=20StoreBuilde?= =?UTF-8?q?r=20=E9=85=8D=E7=BD=AE=E5=8A=9F=E8=83=BD=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 StoreBuilder 支持模块化配置 reducer 和中间件 - 实现状态选择视图缓存机制提升性能 - 重构订阅管理使用精确订阅对象替代委托链 - 增强 SubscribeWithInitValue 方法防止状态变化遗漏 - 添加完整的状态管理文档示例和测试用例 - 更新接口定义支持新的构建器功能 --- GFramework.Core.Abstractions/README.md | 2 +- .../StateManagement/IStoreBuilder.cs | 46 ++++ .../StateManagement/StoreTests.cs | 122 ++++++++++ GFramework.Core/StateManagement/Store.cs | 217 +++++++++++++++++- .../StateManagement/StoreBuilder.cs | 87 +++++++ .../StateManagement/StoreSelection.cs | 180 ++++++++++++++- docs/zh-CN/core/command.md | 34 ++- docs/zh-CN/core/state-management.md | 200 ++++++++++++++++ 8 files changed, 865 insertions(+), 23 deletions(-) create mode 100644 GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs create mode 100644 GFramework.Core/StateManagement/StoreBuilder.cs diff --git a/GFramework.Core.Abstractions/README.md b/GFramework.Core.Abstractions/README.md index efd51df..632868f 100644 --- a/GFramework.Core.Abstractions/README.md +++ b/GFramework.Core.Abstractions/README.md @@ -12,7 +12,7 @@ GFramework 框架的抽象层定义模块,包含所有核心组件的接口定 - 事件系统接口 (IEvent, IEventBus) - 依赖注入容器接口 (IIocContainer) - 可绑定属性接口 (IBindableProperty) -- 状态管理接口 (IStore, IReducer, IStateSelector) +- 状态管理接口 (IStore, IReducer, IStateSelector, IStoreBuilder) - 日志系统接口 (ILogger) ## 设计原则 diff --git a/GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs b/GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs new file mode 100644 index 0000000..35ed3cc --- /dev/null +++ b/GFramework.Core.Abstractions/StateManagement/IStoreBuilder.cs @@ -0,0 +1,46 @@ +namespace GFramework.Core.Abstractions.StateManagement; + +/// +/// 定义 Store 构建器接口,用于在创建 Store 之前完成 reducer、中间件和比较器配置。 +/// 该抽象适用于模块化注册、依赖注入装配和测试工厂,避免调用方必须依赖具体 Store 类型进行配置。 +/// +/// 状态树的根状态类型。 +public interface IStoreBuilder +{ + /// + /// 配置用于判断状态是否真正变化的比较器。 + /// + /// 状态比较器。 + /// 当前构建器实例。 + IStoreBuilder WithComparer(IEqualityComparer comparer); + + /// + /// 添加一个强类型 reducer。 + /// + /// 当前 reducer 处理的 action 类型。 + /// 要添加的 reducer。 + /// 当前构建器实例。 + IStoreBuilder AddReducer(IReducer reducer); + + /// + /// 使用委托快速添加一个 reducer。 + /// + /// 当前 reducer 处理的 action 类型。 + /// 执行归约的委托。 + /// 当前构建器实例。 + IStoreBuilder AddReducer(Func reducer); + + /// + /// 添加一个 Store 中间件。 + /// + /// 要添加的中间件。 + /// 当前构建器实例。 + IStoreBuilder UseMiddleware(IStoreMiddleware middleware); + + /// + /// 基于给定初始状态创建一个新的 Store。 + /// + /// Store 的初始状态。 + /// 已应用当前构建器配置的 Store 实例。 + IStore Build(TState initialState); +} \ No newline at end of file diff --git a/GFramework.Core.Tests/StateManagement/StoreTests.cs b/GFramework.Core.Tests/StateManagement/StoreTests.cs index a8693d4..c9c4360 100644 --- a/GFramework.Core.Tests/StateManagement/StoreTests.cs +++ b/GFramework.Core.Tests/StateManagement/StoreTests.cs @@ -90,6 +90,27 @@ public class StoreTests Assert.That(receivedCounts, Is.EqualTo(new[] { 5, 8 })); } + /// + /// 测试 Store 的 SubscribeWithInitValue 在初始化回放期间不会漏掉后续状态变化。 + /// + [Test] + public void SubscribeWithInitValue_Should_Not_Miss_Changes_During_Init_Callback() + { + var store = CreateStore(); + var receivedCounts = new List(); + + store.SubscribeWithInitValue(state => + { + receivedCounts.Add(state.Count); + if (receivedCounts.Count == 1) + { + store.Dispatch(new IncrementAction(1)); + } + }); + + Assert.That(receivedCounts, Is.EqualTo(new[] { 0, 1 })); + } + /// /// 测试注销订阅后不会再收到后续通知。 /// @@ -145,6 +166,28 @@ public class StoreTests Assert.That(selectedCounts, Is.EqualTo(new[] { 11 })); } + /// + /// 测试 StoreSelection 的 RegisterWithInitValue 在初始化回放期间不会漏掉后续局部状态变化。 + /// + [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(); + + selection.RegisterWithInitValue(value => + { + receivedCounts.Add(value); + if (receivedCounts.Count == 1) + { + store.Dispatch(new IncrementAction(1)); + } + }); + + Assert.That(receivedCounts, Is.EqualTo(new[] { 0, 1 })); + } + /// /// 测试 ToBindableProperty 可桥接到现有 BindableProperty 风格用法,并与旧属性系统共存。 /// @@ -233,6 +276,46 @@ public class StoreTests Assert.That(store.LastStateChangedAt, Is.Null); } + /// + /// 测试 Store 能够复用同一个缓存选择视图实例。 + /// + [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)); + } + + /// + /// 测试 StoreBuilder 能够应用 reducer、中间件和状态比较器配置。 + /// + [Test] + public void StoreBuilder_Should_Apply_Configured_Reducers_Middlewares_And_Comparer() + { + var logs = new List(); + var store = (Store)Store + .CreateBuilder() + .WithComparer(new CounterStateNameInsensitiveComparer()) + .AddReducer((state, action) => state with { Count = state.Count + action.Amount }) + .AddReducer((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" })); + } + /// /// 创建一个带有基础 reducer 的测试 Store。 /// @@ -315,6 +398,45 @@ public class StoreTests } } + /// + /// 用于测试 StoreBuilder 自定义状态比较器的比较器实现。 + /// 该比较器忽略名称字段的大小写差异,并保持计数字段严格比较。 + /// + private sealed class CounterStateNameInsensitiveComparer : IEqualityComparer + { + /// + /// 判断两个状态是否在业务语义上相等。 + /// + /// 左侧状态。 + /// 右侧状态。 + /// 若两个状态在计数相同且名称仅大小写不同,则返回 + 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); + } + + /// + /// 返回与业务语义一致的哈希码。 + /// + /// 目标状态。 + /// 忽略名称大小写后的哈希码。 + public int GetHashCode(CounterState obj) + { + return HashCode.Combine(obj.Count, StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name)); + } + } + /// /// 记录中间件调用顺序的测试中间件。 /// diff --git a/GFramework.Core/StateManagement/Store.cs b/GFramework.Core/StateManagement/Store.cs index 12c9a51..5d30d5b 100644 --- a/GFramework.Core/StateManagement/Store.cs +++ b/GFramework.Core/StateManagement/Store.cs @@ -14,9 +14,9 @@ public class Store : IStore, IStoreDiagnostics { /// /// 当前状态变化订阅者列表。 - /// 使用列表而不是委托链,便于精确维护订阅数量并生成稳定的快照调用序列。 + /// 使用显式订阅对象而不是委托链,便于处理原子初始化订阅、挂起补发和精确解绑。 /// - private readonly List> _listeners = []; + private readonly List _listeners = []; /// /// Store 内部所有可变状态的同步锁。 @@ -35,6 +35,12 @@ public class Store : IStore, IStoreDiagnostics /// private readonly Dictionary> _reducers = []; + /// + /// 已缓存的局部状态选择视图。 + /// 该缓存用于避免高频访问的 Model 属性在每次 getter 调用时都创建新的选择对象。 + /// + private readonly Dictionary _selectionCache = []; + /// /// 用于判断状态是否发生有效变化的比较器。 /// @@ -101,12 +107,14 @@ public class Store : IStore, IStoreDiagnostics { 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)); } /// @@ -119,9 +127,53 @@ public class Store : IStore, IStoreDiagnostics { 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)); } /// @@ -135,7 +187,14 @@ public class Store : IStore, IStoreDiagnostics 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 : IStore, IStoreDiagnostics _state = context.NextState; _lastStateChangedAt = context.DispatchedAt; - listenersSnapshot = _listeners.Count > 0 ? _listeners.ToArray() : Array.Empty>(); + listenersSnapshot = SnapshotListenersForNotification(context.NextState); } finally { @@ -252,6 +311,15 @@ public class Store : IStore, IStoreDiagnostics } } + /// + /// 创建一个用于当前状态类型的 Store 构建器。 + /// + /// 新的 Store 构建器实例。 + public static StoreBuilder CreateBuilder() + { + return new StoreBuilder(); + } + /// /// 注册一个强类型 reducer。 /// 同一 action 类型可注册多个 reducer,它们会按照注册顺序依次归约状态。 @@ -311,6 +379,58 @@ public class Store : IStore, IStoreDiagnostics return this; } + /// + /// 获取或创建一个带缓存的局部状态选择视图。 + /// 对于会被频繁读取的 Model 只读属性,推荐使用该方法复用同一个选择实例。 + /// + /// 局部状态类型。 + /// 缓存键,调用方应保证同一个键始终表示同一局部状态语义。 + /// 状态选择委托。 + /// 用于比较局部状态是否变化的比较器。 + /// 稳定复用的选择视图实例。 + public StoreSelection GetOrCreateSelection( + string key, + Func selector, + IEqualityComparer? comparer = null) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(selector); + + lock (_lock) + { + if (_selectionCache.TryGetValue(key, out var existing)) + { + if (existing is StoreSelection cachedSelection) + { + return cachedSelection; + } + + throw new InvalidOperationException( + $"A cached selection with key '{key}' already exists with a different selected type."); + } + + var selection = new StoreSelection(this, selector, comparer); + _selectionCache[key] = selection; + return selection; + } + } + + /// + /// 获取或创建一个带缓存的只读 BindableProperty 风格视图。 + /// + /// 局部状态类型。 + /// 缓存键,调用方应保证同一个键始终表示同一局部状态语义。 + /// 状态选择委托。 + /// 用于比较局部状态是否变化的比较器。 + /// 稳定复用的只读绑定视图。 + public StoreSelection GetOrCreateBindableProperty( + string key, + Func selector, + IEqualityComparer? comparer = null) + { + return GetOrCreateSelection(key, selector, comparer); + } + /// /// 执行一次完整分发管线。 /// @@ -368,6 +488,52 @@ public class Store : IStore, IStoreDiagnostics } } + /// + /// 从当前订阅集合中提取需要立即通知的监听器快照,并为尚未激活的初始化订阅保存待补发状态。 + /// + /// 本次分发后的最新状态。 + /// 需要在锁外立即调用的监听器快照。 + private Action[] SnapshotListenersForNotification(TState nextState) + { + if (_listeners.Count == 0) + { + return Array.Empty>(); + } + + var activeListeners = new List>(_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>(); + } + + /// + /// 解绑一个精确的订阅对象。 + /// + /// 要解绑的订阅对象。 + private void UnSubscribe(ListenerSubscription subscription) + { + lock (_lock) + { + subscription.IsSubscribed = false; + _listeners.Remove(subscription); + } + } + /// /// 适配不同 action 类型 reducer 的内部统一接口。 /// Store 通过该接口在运行时按 action 具体类型执行 reducer,而不暴露内部装配细节。 @@ -431,4 +597,37 @@ public class Store : IStore, IStoreDiagnostics return _reducer(currentState, action); } } + + /// + /// 表示一个 Store 状态监听订阅。 + /// 该对象用于支持初始化回放与正式订阅之间的原子衔接,避免 SubscribeWithInitValue 漏掉状态变化。 + /// + private sealed class ListenerSubscription(Action listener) + { + /// + /// 获取订阅回调。 + /// + public Action Listener { get; } = listener; + + /// + /// 获取或设置订阅是否已激活。 + /// 非激活状态表示正在执行初始化回放,此时新的状态变化会被暂存为待补发值。 + /// + public bool IsActive { get; set; } = true; + + /// + /// 获取或设置订阅是否仍然有效。 + /// + public bool IsSubscribed { get; set; } = true; + + /// + /// 获取或设置是否存在待补发的最新状态。 + /// + public bool HasPendingState { get; set; } + + /// + /// 获取或设置初始化阶段积累的最新状态。 + /// + public TState PendingState { get; set; } = default!; + } } \ No newline at end of file diff --git a/GFramework.Core/StateManagement/StoreBuilder.cs b/GFramework.Core/StateManagement/StoreBuilder.cs new file mode 100644 index 0000000..3d75196 --- /dev/null +++ b/GFramework.Core/StateManagement/StoreBuilder.cs @@ -0,0 +1,87 @@ +using GFramework.Core.Abstractions.StateManagement; + +namespace GFramework.Core.StateManagement; + +/// +/// Store 构建器的默认实现。 +/// 该类型用于在 Store 创建之前集中配置比较器、reducer 和中间件,适合模块安装和测试工厂场景。 +/// +/// 状态树的根状态类型。 +public class StoreBuilder : IStoreBuilder +{ + /// + /// 延迟应用到 Store 的配置操作列表。 + /// 采用延迟配置而不是直接缓存 reducer 适配器,可复用 Store 自身的注册和验证逻辑。 + /// + private readonly List>> _configurators = []; + + /// + /// 状态比较器。 + /// + private IEqualityComparer? _comparer; + + /// + /// 配置状态比较器。 + /// + /// 状态比较器。 + /// 当前构建器实例。 + public IStoreBuilder WithComparer(IEqualityComparer comparer) + { + _comparer = comparer ?? throw new ArgumentNullException(nameof(comparer)); + return this; + } + + /// + /// 添加一个强类型 reducer。 + /// + /// 当前 reducer 处理的 action 类型。 + /// 要添加的 reducer。 + /// 当前构建器实例。 + public IStoreBuilder AddReducer(IReducer reducer) + { + ArgumentNullException.ThrowIfNull(reducer); + _configurators.Add(store => store.RegisterReducer(reducer)); + return this; + } + + /// + /// 使用委托快速添加一个 reducer。 + /// + /// 当前 reducer 处理的 action 类型。 + /// 执行归约的委托。 + /// 当前构建器实例。 + public IStoreBuilder AddReducer(Func reducer) + { + ArgumentNullException.ThrowIfNull(reducer); + _configurators.Add(store => store.RegisterReducer(reducer)); + return this; + } + + /// + /// 添加一个 Store 中间件。 + /// + /// 要添加的中间件。 + /// 当前构建器实例。 + public IStoreBuilder UseMiddleware(IStoreMiddleware middleware) + { + ArgumentNullException.ThrowIfNull(middleware); + _configurators.Add(store => store.UseMiddleware(middleware)); + return this; + } + + /// + /// 基于给定初始状态创建一个新的 Store。 + /// + /// Store 的初始状态。 + /// 已应用当前构建器配置的 Store 实例。 + public IStore Build(TState initialState) + { + var store = new Store(initialState, _comparer); + foreach (var configurator in _configurators) + { + configurator(store); + } + + return store; + } +} \ No newline at end of file diff --git a/GFramework.Core/StateManagement/StoreSelection.cs b/GFramework.Core/StateManagement/StoreSelection.cs index 47bcbbd..35224a5 100644 --- a/GFramework.Core/StateManagement/StoreSelection.cs +++ b/GFramework.Core/StateManagement/StoreSelection.cs @@ -22,7 +22,7 @@ public class StoreSelection : IReadonlyBindableProperty /// 当前监听器列表。 /// - private readonly List> _listeners = []; + private readonly List _listeners = []; /// /// 保护监听器集合和底层 Store 订阅句柄的同步锁。 @@ -96,6 +96,7 @@ public class StoreSelection : IReadonlyBindableProperty : IReadonlyBindableProperty : IReadonlyBindableProperty UnRegister(onValueChanged)); + return new DefaultUnRegister(() => UnRegister(subscription)); } /// @@ -127,9 +128,58 @@ public class StoreSelection : IReadonlyBindableProperty { }); + } + + 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)); } /// @@ -141,11 +191,55 @@ public class StoreSelection : IReadonlyBindableProperty subscription.Listener == onValueChanged); + if (index < 0) + { + return; + } + + subscriptionToRemove = _listeners[index]; + } + + if (subscriptionToRemove != null) + { + UnRegister(subscriptionToRemove); + } + } + + /// + /// 确保当前选择视图已连接到底层 Store。 + /// + private void EnsureAttached() + { + var shouldAttach = false; + + lock (_lock) + { + shouldAttach = _listeners.Count > 0 && _storeSubscription == null; + } + + if (shouldAttach) + { + AttachToStore(); + } + } + + /// + /// 取消注册一个精确的选择结果监听器。 + /// + /// 需要移除的订阅对象。 + 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 : IReadonlyBindableProperty listener.IsSubscribed && listener.IsActive) + .Select(listener => listener.Listener) + .ToArray(); shouldNotify = listenersSnapshot.Length > 0; } } @@ -219,7 +332,26 @@ public class StoreSelection : IReadonlyBindableProperty listener.IsSubscribed && listener.IsActive) + .Select(listener => listener.Listener) + .ToArray(); } foreach (var listener in listenersSnapshot) @@ -227,4 +359,36 @@ public class StoreSelection : IReadonlyBindableProperty + /// 表示一个选择结果监听订阅。 + /// 该对象用于保证 RegisterWithInitValue 在初始化回放与后续状态变化之间不会漏掉最近一次更新。 + /// + private sealed class SelectionListenerSubscription(Action listener) + { + /// + /// 获取订阅回调。 + /// + public Action Listener { get; } = listener; + + /// + /// 获取或设置订阅是否已激活。 + /// + public bool IsActive { get; set; } = true; + + /// + /// 获取或设置订阅是否仍然有效。 + /// + public bool IsSubscribed { get; set; } = true; + + /// + /// 获取或设置是否存在待补发的局部状态值。 + /// + public bool HasPendingValue { get; set; } + + /// + /// 获取或设置初始化阶段积累的最新局部状态值。 + /// + public TSelected PendingValue { get; set; } = default!; + } } \ No newline at end of file diff --git a/docs/zh-CN/core/command.md b/docs/zh-CN/core/command.md index a383f56..f4ec2bc 100644 --- a/docs/zh-CN/core/command.md +++ b/docs/zh-CN/core/command.md @@ -109,7 +109,7 @@ public class UISystem : AbstractSystem } ``` -## 命令的生命周期 +## 命令的生命周期 1. **创建命令**:实例化命令对象,传入必要的参数 2. **执行命令**:调用 `Execute()` 方法,内部委托给 `OnExecute()` @@ -118,9 +118,33 @@ public class UISystem : AbstractSystem **注意事项:** -- 命令应该是无状态的,执行完即可丢弃 -- 避免在命令中保存长期引用 -- 命令执行应该是原子操作 +- 命令应该是无状态的,执行完即可丢弃 +- 避免在命令中保存长期引用 +- 命令执行应该是原子操作 + +### 与 Store 配合使用 + +当某个 Model 内部使用 `Store` 管理复杂聚合状态时,Command 依然是推荐的写入口。 + +```csharp +public sealed class DamagePlayerCommand(int amount) : AbstractCommand +{ + protected override void OnExecute() + { + var model = this.GetModel(); + 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 \ No newline at end of file +**许可证**:Apache 2.0 diff --git a/docs/zh-CN/core/state-management.md b/docs/zh-CN/core/state-management.md index b659095..3b5afae 100644 --- a/docs/zh-CN/core/state-management.md +++ b/docs/zh-CN/core/state-management.md @@ -129,6 +129,206 @@ public class PlayerStateModel : AbstractModel 这样可以保留 Model 的生命周期和领域边界,同时获得统一状态入口。 +## 使用 StoreBuilder 组织配置 + +当一个 Store 需要在模块安装、测试工厂或 DI 装配阶段统一配置时,可以使用 `StoreBuilder`: + +```csharp +var store = (Store)Store + .CreateBuilder() + .AddReducer((state, action) => + state with { Health = Math.Max(0, state.Health - action.Amount) }) + .Build(new PlayerState(100, "Player")); +``` + +适合以下场景: + +- 模块启动时集中注册 reducer 和 middleware +- 测试里快速组装不同配置的 Store +- 不希望把 Store 的装配细节散落在多个调用点 + +## 官方示例:角色面板状态 + +下面给出一个更贴近 GFramework 实战的完整示例,展示如何把 `Store` 放进 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 Store { get; } = + new(new PlayerPanelState("Player", 100, 100, 1)); + + // 使用带缓存的选择视图,避免属性 getter 每次访问都创建新的 StoreSelection 实例。 + public IReadonlyBindableProperty Health => + Store.GetOrCreateBindableProperty("health", state => state.Health); + + public IReadonlyBindableProperty Name => + Store.GetOrCreateBindableProperty("name", state => state.Name); + + public IReadonlyBindableProperty HealthPercent => + Store.GetOrCreateBindableProperty("health_percent", + state => (float)state.Health / state.MaxHealth); + + protected override void OnInit() + { + Store + .RegisterReducer((state, action) => + state with + { + Health = Math.Max(0, state.Health - action.Amount) + }) + .RegisterReducer((state, action) => + state with + { + Health = Math.Min(state.MaxHealth, state.Health + action.Amount) + }) + .RegisterReducer((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(); + model.Store.Dispatch(new DamagePlayerAction(amount)); + } +} + +public sealed class RenamePlayerCommand(string name) : AbstractCommand +{ + protected override void OnExecute() + { + var model = this.GetModel(); + 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(); + + 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` 就足够了。 + +如果你很快会遇到以下问题,这个 Store 方案会更稳: + +- 一次操作要同时修改多个字段 +- 同一个业务操作要在多个界面复用 +- 希望把“状态结构”和“状态变化规则”集中在一起 +- 未来要加入 middleware、调试记录或撤销/重做能力 + +### 6. 推荐的落地方式 + +在实际项目里,建议按这个顺序引入: + +1. 先把复杂聚合状态封装到某个 Model 内部 +2. 再把修改入口逐步迁移到 Command +3. 最后在 Controller 层使用 selector 或 `ToBindableProperty()` 做局部绑定 + +这样不会破坏现有 `BindableProperty` 的轻量工作流,也能让复杂状态逐步收敛到统一入口。 + ## 什么时候不用 Store 以下情况继续优先使用 `BindableProperty`: