Compare commits

..

17 Commits

Author SHA1 Message Date
gewuyou
b912e6aa4d
Merge pull request #133 from GeWuYou/feat/state-dynamic-registration
feat(state): 添加运行时临时注册与注销功能
2026-03-23 21:18:43 +08:00
GeWuYou
1f1aff5335 refactor(state): 优化状态管理存储的中间件和reducer快照创建
- 为CreateMiddlewareSnapshot方法添加状态锁保护
- 为CreateReducerSnapshot方法添加状态锁保护
- 更新方法注释说明锁的安全性保障机制
- 避免调用方需要了解锁顺序的隐式依赖关系
2026-03-23 20:58:03 +08:00
GeWuYou
14849d6761 refactor(GFramework.Core.Tests): 更新全局using引用
- 添加GFramework.Core.Abstractions.Property命名空间引用
- 重新排序using语句以优化代码结构
- 保持现有功能不变的同时改进代码组织方式
2026-03-23 20:41:36 +08:00
GeWuYou
2c00070bb1 feat(state): 添加运行时临时注册与注销功能
- 实现 RegisterReducerHandle 和 RegisterMiddleware 方法,支持获取注销句柄
- 添加 IUnRegister 接口和 DefaultUnRegister 实现,提供精确注销能力
- 修改内部数据结构,使用 Registration 包装对象确保注销时的身份稳定性
- 实现中间件和 reducer 的快照机制,确保运行中注销不影响当前 dispatch
- 添加相关单元测试验证运行时注册注销的正确性
- 更新文档说明运行时临时注册与注销的使用方式和约束条件
2026-03-23 20:38:46 +08:00
gewuyou
b6ef6278c0
Merge pull request #132 from GeWuYou/feat/state-management-core
Feat/state management core
2026-03-23 20:19:53 +08:00
GeWuYou
3d212716d6 refactor(tests): 更新状态管理测试的命名空间引用
- 添加了属性抽象层的命名空间引用
- 添加了状态管理抽象层的命名空间引用
- 保持了原有的核心扩展、属性和状态管理命名空间引用
2026-03-23 20:14:23 +08:00
GeWuYou
b7c54743fa refactor(state): 优化 Store 状态分发的并发控制机制
- 将 Store 类标记为 sealed 以防止继承
- 引入独立的 dispatch 门闩锁,将状态锁的保护范围缩小为仅保护临界区访问
- 实现 dispatch 过程中的快照机制,确保中间件和 reducer 在锁外执行稳定的不可变序列
- 重构 ExecuteDispatchPipeline 方法,接受快照参数并改为静态方法
- 添加 CreateReducerSnapshot 方法为每次分发创建 reducer 快照
- 更新 StoreBuilder 和 StoreSelection 类为 sealed
- 新增测试用例验证长时间运行的 middleware 不会阻塞状态读取和订阅操作
- 修复 dispatch 过程中状态锁占用时间过长的问题,提升并发性能
2026-03-23 20:11:10 +08:00
GeWuYou
79cebb95b5 feat(state): 添加 StoreBuilder 配置功能并优化状态管理
- 引入 StoreBuilder<TState> 支持模块化配置 reducer 和中间件
- 实现状态选择视图缓存机制提升性能
- 重构订阅管理使用精确订阅对象替代委托链
- 增强 SubscribeWithInitValue 方法防止状态变化遗漏
- 添加完整的状态管理文档示例和测试用例
- 更新接口定义支持新的构建器功能
2026-03-23 19:59:23 +08:00
GeWuYou
79f1240e1d docs(core): 添加状态管理文档并完善属性绑定指南
- 新增 state-management 文档,介绍集中式状态容器方案
- 在 property 文档中补充与 Store 的使用边界说明
- 更新核心功能表格,添加状态管理条目链接
- 在 README 中增加 StateManagement 功能描述
- 添加状态管理相关接口到抽象层文档
- 提供 Store 与 BindableProperty 的选择指导原则
2026-03-23 19:35:01 +08:00
GeWuYou
2b4b87baba feat(state): 添加状态管理框架核心功能
- 实现 Store 类作为集中式状态容器,默认支持状态归约和订阅通知
- 添加 IReadonlyStore、IStore、IReducer 等状态管理相关抽象接口
- 实现 StoreExtensions 扩展方法,提供 Select 和 ToBindableProperty 选择器功能
- 添加 StoreSelection 类,支持从完整状态树中投影局部状态视图
- 实现 StoreDispatchContext 和 StoreDispatchRecord 用于分发过程诊断
- 添加 IStoreMiddleware 中间件接口,支持在分发过程中插入日志和审计逻辑
- 实现完整的状态选择器和绑定属性桥接功能,便于现有 UI 代码复用
- 添加 Store 相关单元测试,覆盖状态归约、订阅通知和选择器桥接场景
2026-03-23 19:34:28 +08:00
deepsource-autofix[bot]
c70728b64e refactor: simplify lambda
This PR refactors a lambda expression that contained unnecessary braces around a single-statement body, converting it into a more concise expression-bodied form to improve readability and maintainability.

- Consider simplifying lambda when its body has a single statement: The original lambda used a block body with braces and a single call to Execute, which is verbose for a one-line operation. We removed the braces and semicolon, converting it into an expression-bodied lambda (`static (spc, pair) => Execute(spc, pair.Left, pair.Right)`) to simplify the code and follow best practices.

> This Autofix was generated by AI. Please review the change before merging.
2026-03-23 08:28:07 +08:00
gewuyou
cf486cbeff
Merge pull request #128 from GeWuYou/feat/get-node-generator
feat(godot): 添加 GetNode 源代码生成器功能
2026-03-22 15:34:00 +08:00
gewuyou
8d656b90a7
Merge pull request #129 from GeWuYou/deepsource-autofix-b7cf8394
refactor: simplify single-statement getter
2026-03-22 15:30:07 +08:00
GeWuYou
fc386fb4bc refactor(generator): 调整项目文件夹结构
- 移除 logging 文件夹引用
- 将 diagnostics 文件夹重命名为 Diagnostics
- 更新项目文件中的文件夹路径配置
2026-03-22 15:28:39 +08:00
deepsource-autofix[bot]
bbf1dc8d0c
refactor: simplify single-statement getter
This PR refactors properties that contain only a single return statement by converting them to expression-bodied members, reducing boilerplate and improving readability.

- Getters and setters with a single statement in their bodies can be simplified: The `IsDone` property originally used a full getter block to evaluate whether all coroutine handles are inactive. We replaced it with an expression-bodied property (`public bool IsDone => _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));`) and relocated the explanatory comment above it, streamlining the code.

> This Autofix was generated by AI. Please review the change before merging.
2026-03-22 07:24:25 +00:00
GeWuYou
b95c65a30e refactor(generator): 优化GetNodeGenerator代码结构
- 使用语法树遍历替代字符串匹配来检测注入方法调用
- 添加IsGeneratedInjectionInvocation辅助方法提高代码可读性
- 将字段分组逻辑从列表查找改为字典映射提升性能
- 优化GroupByContainingType方法的时间复杂度
2026-03-22 15:23:51 +08:00
GeWuYou
9ab09cf47b feat(godot): 添加 GetNode 源代码生成器功能
- 实现了 [GetNode] 属性用于标记 Godot 节点字段
- 创建了 GetNodeGenerator 源代码生成器自动注入节点获取逻辑
- 添加了节点路径推导和多种查找模式支持
- 集成了生成器到 Godot 脚手架模板中
- 添加了完整的诊断规则和错误提示
- 创建了单元测试验证生成器功能
- 更新了解决方案配置以包含新的测试项目
- 在 README 中添加了详细的使用文档和示例代码
2026-03-22 15:16:24 +08:00
39 changed files with 4014 additions and 56 deletions

View File

@ -12,6 +12,7 @@ GFramework 框架的抽象层定义模块,包含所有核心组件的接口定
- 事件系统接口 (IEvent, IEventBus) - 事件系统接口 (IEvent, IEventBus)
- 依赖注入容器接口 (IIocContainer) - 依赖注入容器接口 (IIocContainer)
- 可绑定属性接口 (IBindableProperty) - 可绑定属性接口 (IBindableProperty)
- 状态管理接口 (IStore, IReducer, IStateSelector, IStoreBuilder)
- 日志系统接口 (ILogger) - 日志系统接口 (ILogger)
## 设计原则 ## 设计原则

View File

@ -0,0 +1,40 @@
using GFramework.Core.Abstractions.Events;
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 只读状态容器接口,用于暴露应用状态快照和订阅能力。
/// 该抽象适用于 Controller、Query、ViewModel 等只需要观察状态的调用方,
/// 使其无需依赖写入能力即可响应复杂状态树的变化。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IReadonlyStore<out TState>
{
/// <summary>
/// 获取当前状态快照。
/// Store 负责保证返回值与最近一次成功分发后的状态一致。
/// </summary>
TState State { get; }
/// <summary>
/// 订阅状态变化通知。
/// 仅当 Store 判断状态发生有效变化时,才会调用该监听器。
/// </summary>
/// <param name="listener">状态变化时的监听器,参数为新的状态快照。</param>
/// <returns>用于取消订阅的句柄。</returns>
IUnRegister Subscribe(Action<TState> listener);
/// <summary>
/// 订阅状态变化通知,并立即以当前状态调用一次监听器。
/// 该方法适合在 UI 初始化或 ViewModel 首次绑定时建立同步视图。
/// </summary>
/// <param name="listener">状态变化时的监听器,参数为新的状态快照。</param>
/// <returns>用于取消订阅的句柄。</returns>
IUnRegister SubscribeWithInitValue(Action<TState> listener);
/// <summary>
/// 取消订阅指定的状态监听器。
/// </summary>
/// <param name="listener">需要移除的监听器。</param>
void UnSubscribe(Action<TState> listener);
}

View File

@ -0,0 +1,19 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 定义状态归约器接口。
/// Reducer 应保持纯函数风格:根据当前状态和 action 计算下一状态,
/// 不直接产生副作用,也不依赖外部可变环境。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
public interface IReducer<TState, in TAction>
{
/// <summary>
/// 根据当前状态和 action 计算下一状态。
/// </summary>
/// <param name="currentState">当前状态快照。</param>
/// <param name="action">触发本次归约的 action。</param>
/// <returns>归约后的下一状态。</returns>
TState Reduce(TState currentState, TAction action);
}

View File

@ -0,0 +1,17 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 定义状态选择器接口,用于从整棵状态树中投影出局部状态视图。
/// 该抽象适用于复用复杂选择逻辑,避免在 UI 或 Controller 中重复编写投影代码。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">投影后的局部状态类型。</typeparam>
public interface IStateSelector<in TState, out TSelected>
{
/// <summary>
/// 从给定状态中选择目标片段。
/// </summary>
/// <param name="state">当前完整状态。</param>
/// <returns>投影后的局部状态。</returns>
TSelected Select(TState state);
}

View File

@ -0,0 +1,17 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 可写状态容器接口,提供统一的状态分发入口。
/// 所有状态变更都应通过分发 action 触发,以保持单向数据流和可测试性。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IStore<out TState> : IReadonlyStore<TState>
{
/// <summary>
/// 分发一个 action 以触发状态演进。
/// Store 会按注册顺序执行与该 action 类型匹配的 reducer并在状态变化后通知订阅者。
/// </summary>
/// <typeparam name="TAction">action 的具体类型。</typeparam>
/// <param name="action">要分发的 action 实例。</param>
void Dispatch<TAction>(TAction action);
}

View File

@ -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);
}

View File

@ -0,0 +1,31 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 暴露 Store 的诊断信息。
/// 该接口用于调试、监控和后续时间旅行能力的扩展,不参与状态写入流程。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IStoreDiagnostics<TState>
{
/// <summary>
/// 获取当前已注册的订阅者数量。
/// </summary>
int SubscriberCount { get; }
/// <summary>
/// 获取最近一次分发的 action 类型。
/// 即使该次分发未引起状态变化,该值也会更新。
/// </summary>
Type? LastActionType { get; }
/// <summary>
/// 获取最近一次真正改变状态的时间戳。
/// 若尚未发生状态变化,则返回 <see langword="null"/>。
/// </summary>
DateTimeOffset? LastStateChangedAt { get; }
/// <summary>
/// 获取最近一次分发记录。
/// </summary>
StoreDispatchRecord<TState>? LastDispatchRecord { get; }
}

View File

@ -0,0 +1,19 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 定义 Store 分发中间件接口。
/// 中间件用于在 action 分发前后插入日志、诊断、审计或拦截逻辑,
/// 同时保持核心 Store 实现专注于状态归约与订阅通知。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public interface IStoreMiddleware<TState>
{
/// <summary>
/// 执行一次分发管线节点。
/// 实现通常应调用 <paramref name="next"/> 继续后续处理;若选择短路,
/// 需要自行保证上下文状态对调用方仍然是可解释的。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">继续执行后续中间件或 reducer 的委托。</param>
void Invoke(StoreDispatchContext<TState> context, Action next);
}

View File

@ -0,0 +1,55 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 表示一次 Store 分发流程中的上下文数据。
/// 中间件和 Store 实现通过该对象共享当前 action、分发时间以及归约结果。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreDispatchContext<TState>
{
/// <summary>
/// 初始化一个新的分发上下文。
/// </summary>
/// <param name="action">当前分发的 action。</param>
/// <param name="previousState">分发前的状态快照。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
public StoreDispatchContext(object action, TState previousState)
{
Action = action ?? throw new ArgumentNullException(nameof(action));
PreviousState = previousState;
NextState = previousState;
DispatchedAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// 获取当前分发的 action 实例。
/// </summary>
public object Action { get; }
/// <summary>
/// 获取当前分发的 action 运行时类型。
/// </summary>
public Type ActionType => Action.GetType();
/// <summary>
/// 获取分发前的状态快照。
/// </summary>
public TState PreviousState { get; }
/// <summary>
/// 获取或设置归约后的下一状态。
/// Store 会在 reducer 执行完成后使用该值更新内部状态。
/// </summary>
public TState NextState { get; set; }
/// <summary>
/// 获取或设置本次分发是否导致状态发生变化。
/// 中间件可读取该值进行日志和诊断,但通常应由 Store 负责最终判定。
/// </summary>
public bool HasStateChanged { get; set; }
/// <summary>
/// 获取本次分发创建时的时间戳。
/// </summary>
public DateTimeOffset DispatchedAt { get; }
}

View File

@ -0,0 +1,62 @@
namespace GFramework.Core.Abstractions.StateManagement;
/// <summary>
/// 记录最近一次 Store 分发的结果。
/// 该结构为调试和诊断提供稳定的只读视图,避免调用方直接依赖 Store 的内部状态。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreDispatchRecord<TState>
{
/// <summary>
/// 初始化一条分发记录。
/// </summary>
/// <param name="action">本次分发的 action。</param>
/// <param name="previousState">分发前状态。</param>
/// <param name="nextState">分发后状态。</param>
/// <param name="hasStateChanged">是否发生了有效状态变化。</param>
/// <param name="dispatchedAt">分发时间。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
public StoreDispatchRecord(
object action,
TState previousState,
TState nextState,
bool hasStateChanged,
DateTimeOffset dispatchedAt)
{
Action = action ?? throw new ArgumentNullException(nameof(action));
PreviousState = previousState;
NextState = nextState;
HasStateChanged = hasStateChanged;
DispatchedAt = dispatchedAt;
}
/// <summary>
/// 获取本次分发的 action 实例。
/// </summary>
public object Action { get; }
/// <summary>
/// 获取本次分发的 action 运行时类型。
/// </summary>
public Type ActionType => Action.GetType();
/// <summary>
/// 获取分发前状态。
/// </summary>
public TState PreviousState { get; }
/// <summary>
/// 获取分发后状态。
/// </summary>
public TState NextState { get; }
/// <summary>
/// 获取本次分发是否产生了有效状态变化。
/// </summary>
public bool HasStateChanged { get; }
/// <summary>
/// 获取分发时间。
/// </summary>
public DateTimeOffset DispatchedAt { get; }
}

View File

@ -23,6 +23,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Scriban" Version="6.6.0" /> <PackageReference Include="Scriban" Version="6.6.0" />
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/> <ProjectReference Include="..\GFramework.Core\GFramework.Core.csproj"/>
<ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"/> <ProjectReference Include="..\GFramework.SourceGenerators.Abstractions\GFramework.SourceGenerators.Abstractions.csproj"/>
<ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"/> <ProjectReference Include="..\GFramework.SourceGenerators.Common\GFramework.SourceGenerators.Common.csproj"/>

View File

@ -19,3 +19,8 @@ global using System.Threading.Tasks;
global using NUnit.Framework; global using NUnit.Framework;
global using NUnit.Compatibility; global using NUnit.Compatibility;
global using GFramework.Core.Systems; global using GFramework.Core.Systems;
global using GFramework.Core.Abstractions.StateManagement;
global using GFramework.Core.Extensions;
global using GFramework.Core.Property;
global using GFramework.Core.StateManagement;
global using GFramework.Core.Abstractions.Property;

View File

@ -0,0 +1,662 @@
namespace GFramework.Core.Tests.StateManagement;
/// <summary>
/// Store 状态管理能力的单元测试。
/// 这些测试覆盖集中式状态容器的核心职责:状态归约、订阅通知、选择器桥接和诊断行为。
/// </summary>
[TestFixture]
public class StoreTests
{
/// <summary>
/// 测试 Store 在创建后能够暴露初始状态。
/// </summary>
[Test]
public void State_Should_Return_Initial_State()
{
var store = CreateStore(new CounterState(1, "Player"));
Assert.That(store.State.Count, Is.EqualTo(1));
Assert.That(store.State.Name, Is.EqualTo("Player"));
}
/// <summary>
/// 测试 Dispatch 能够执行 reducer 并向订阅者广播新状态。
/// </summary>
[Test]
public void Dispatch_Should_Update_State_And_Notify_Subscribers()
{
var store = CreateStore();
var receivedStates = new List<CounterState>();
store.Subscribe(receivedStates.Add);
store.Dispatch(new IncrementAction(2));
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(receivedStates.Count, Is.EqualTo(1));
Assert.That(receivedStates[0].Count, Is.EqualTo(2));
}
/// <summary>
/// 测试当 reducer 返回逻辑相等状态时不会触发通知。
/// </summary>
[Test]
public void Dispatch_Should_Not_Notify_When_State_Does_Not_Change()
{
var store = CreateStore();
var notifyCount = 0;
store.Subscribe(_ => notifyCount++);
store.Dispatch(new RenameAction("Player"));
Assert.That(store.State.Name, Is.EqualTo("Player"));
Assert.That(notifyCount, Is.EqualTo(0));
}
/// <summary>
/// 测试同一 action 类型的多个 reducer 会按注册顺序执行。
/// </summary>
[Test]
public void Dispatch_Should_Run_Multiple_Reducers_In_Registration_Order()
{
var store = CreateStore();
store.RegisterReducer<IncrementAction>((state, action) =>
state with { Count = state.Count + action.Amount * 10 });
store.Dispatch(new IncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(11));
}
/// <summary>
/// 测试 SubscribeWithInitValue 会立即回放当前状态并继续接收后续变化。
/// </summary>
[Test]
public void SubscribeWithInitValue_Should_Replay_Current_State_And_Future_Changes()
{
var store = CreateStore(new CounterState(5, "Player"));
var receivedCounts = new List<int>();
store.SubscribeWithInitValue(state => receivedCounts.Add(state.Count));
store.Dispatch(new IncrementAction(3));
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>
[Test]
public void UnRegister_Handle_Should_Stop_Future_Notifications()
{
var store = CreateStore();
var notifyCount = 0;
var unRegister = store.Subscribe(_ => notifyCount++);
store.Dispatch(new IncrementAction(1));
unRegister.UnRegister();
store.Dispatch(new IncrementAction(1));
Assert.That(notifyCount, Is.EqualTo(1));
}
/// <summary>
/// 测试选择器仅在所选状态片段变化时触发通知。
/// </summary>
[Test]
public void Select_Should_Only_Notify_When_Selected_Slice_Changes()
{
var store = CreateStore();
var selectedCounts = new List<int>();
var selection = store.Select(state => state.Count);
selection.Register(selectedCounts.Add);
store.Dispatch(new RenameAction("Renamed"));
store.Dispatch(new IncrementAction(2));
Assert.That(selectedCounts, Is.EqualTo(new[] { 2 }));
}
/// <summary>
/// 测试选择器支持自定义比较器,从而抑制无意义的局部状态通知。
/// </summary>
[Test]
public void Select_Should_Respect_Custom_Selected_Value_Comparer()
{
var store = CreateStore();
var selectedCounts = new List<int>();
var selection = store.Select(
state => state.Count,
new TensBucketEqualityComparer());
selection.Register(selectedCounts.Add);
store.Dispatch(new IncrementAction(5));
store.Dispatch(new IncrementAction(6));
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>
[Test]
public void ToBindableProperty_Should_Work_With_Existing_BindableProperty_Pattern()
{
var store = CreateStore();
var mirror = new BindableProperty<int>(0);
IReadonlyBindableProperty<int> bindableProperty = store.ToBindableProperty(state => state.Count);
bindableProperty.Register(value => mirror.Value = value);
store.Dispatch(new IncrementAction(3));
Assert.That(mirror.Value, Is.EqualTo(3));
}
/// <summary>
/// 测试 IStateSelector 接口重载能够复用显式选择逻辑。
/// </summary>
[Test]
public void Select_With_IStateSelector_Should_Project_Selected_Value()
{
var store = CreateStore();
var selection = store.Select(new CounterNameSelector());
Assert.That(selection.Value, Is.EqualTo("Player"));
}
/// <summary>
/// 测试 Store 在中间件内部发生同一实例的嵌套分发时会抛出异常。
/// </summary>
[Test]
public void Dispatch_Should_Throw_When_Nested_Dispatch_Happens_On_Same_Store()
{
var store = CreateStore();
store.UseMiddleware(new NestedDispatchMiddleware(store));
Assert.That(
() => store.Dispatch(new IncrementAction(1)),
Throws.InvalidOperationException.With.Message.Contain("Nested dispatch"));
}
/// <summary>
/// 测试中间件链执行顺序和 Store 诊断信息更新。
/// </summary>
[Test]
public void Dispatch_Should_Run_Middlewares_In_Order_And_Update_Diagnostics()
{
var store = CreateStore();
var logs = new List<string>();
store.UseMiddleware(new RecordingMiddleware(logs, "first"));
store.UseMiddleware(new RecordingMiddleware(logs, "second"));
store.Dispatch(new IncrementAction(2));
Assert.That(logs, Is.EqualTo(new[]
{
"first:before",
"second:before",
"second:after",
"first:after"
}));
Assert.That(store.LastActionType, Is.EqualTo(typeof(IncrementAction)));
Assert.That(store.LastStateChangedAt, Is.Not.Null);
Assert.That(store.LastDispatchRecord, Is.Not.Null);
Assert.That(store.LastDispatchRecord!.HasStateChanged, Is.True);
Assert.That(store.LastDispatchRecord.NextState.Count, Is.EqualTo(2));
}
/// <summary>
/// 测试 reducer 句柄注销后,后续同类型 action 不会再命中该 reducer。
/// </summary>
[Test]
public void RegisterReducerHandle_UnRegister_Should_Stop_Future_Reductions()
{
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((state, action) =>
state with { Count = state.Count + action.Amount });
store.Dispatch(new IncrementAction(2));
reducerHandle.UnRegister();
store.Dispatch(new IncrementAction(2));
Assert.That(store.State.Count, Is.EqualTo(2));
}
/// <summary>
/// 测试 middleware 句柄注销后,后续 dispatch 不会再经过该中间件。
/// </summary>
[Test]
public void RegisterMiddleware_UnRegister_Should_Stop_Future_Pipeline_Execution()
{
var store = CreateStore();
var logs = new List<string>();
var middlewareHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic"));
store.Dispatch(new IncrementAction(1));
middlewareHandle.UnRegister();
store.Dispatch(new IncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(2));
Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" }));
}
/// <summary>
/// 测试移除同一 action 类型中的某个 reducer 后,其余 reducer 仍保持原有注册顺序。
/// </summary>
[Test]
public void RegisterReducerHandle_UnRegister_Should_Preserve_Remaining_Order()
{
var executionOrder = new List<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
store.RegisterReducerHandle<IncrementAction>((state, action) =>
{
executionOrder.Add("first");
return state with { Count = state.Count + action.Amount };
});
var middleReducer = store.RegisterReducerHandle<IncrementAction>((state, action) =>
{
executionOrder.Add("middle");
return state with { Count = state.Count + action.Amount * 10 };
});
store.RegisterReducerHandle<IncrementAction>((state, action) =>
{
executionOrder.Add("last");
return state with { Count = state.Count + action.Amount * 100 };
});
middleReducer.UnRegister();
store.Dispatch(new IncrementAction(1));
Assert.That(executionOrder, Is.EqualTo(new[] { "first", "last" }));
Assert.That(store.State.Count, Is.EqualTo(101));
}
/// <summary>
/// 测试注册句柄的注销操作是幂等的,多次调用不会抛异常或影响其他注册项。
/// </summary>
[Test]
public void RegisterHandles_UnRegister_Should_Be_Idempotent()
{
var logs = new List<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((state, action) =>
state with { Count = state.Count + action.Amount });
var middlewareHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic"));
Assert.That(() =>
{
reducerHandle.UnRegister();
reducerHandle.UnRegister();
middlewareHandle.UnRegister();
middlewareHandle.UnRegister();
}, Throws.Nothing);
store.Dispatch(new IncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(0));
Assert.That(logs, Is.Empty);
}
/// <summary>
/// 测试 dispatch 进行中注销 reducer 和 middleware 时,
/// 当前 dispatch 仍使用开始时的快照,而后续 dispatch 会看到注销结果。
/// </summary>
[Test]
public void UnRegister_During_Dispatch_Should_Affect_Next_Dispatch_But_Not_Current_One()
{
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
var logs = new List<string>();
var store = new Store<CounterState>(new CounterState(0, "Player"));
var reducerHandle = store.RegisterReducerHandle<IncrementAction>((state, action) =>
state with { Count = state.Count + action.Amount });
var blockingHandle = store.RegisterMiddleware(new BlockingMiddleware(entered, release));
var recordingHandle = store.RegisterMiddleware(new RecordingMiddleware(logs, "dynamic"));
var dispatchTask = Task.Run(() => store.Dispatch(new IncrementAction(1)));
Assert.That(entered.Wait(TimeSpan.FromSeconds(2)), Is.True, "middleware 未按预期进入阻塞阶段");
reducerHandle.UnRegister();
blockingHandle.UnRegister();
recordingHandle.UnRegister();
release.Set();
Assert.That(dispatchTask.Wait(TimeSpan.FromSeconds(2)), Is.True, "dispatch 未在释放 middleware 后完成");
Assert.That(store.State.Count, Is.EqualTo(1), "当前 dispatch 应继续使用启动时抓取的 reducer 快照");
Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" }));
store.Dispatch(new IncrementAction(1));
Assert.That(store.State.Count, Is.EqualTo(1), "后续 dispatch 应看到 reducer 已被注销");
Assert.That(logs, Is.EqualTo(new[] { "dynamic:before", "dynamic:after" }));
}
/// <summary>
/// 测试未命中的 action 仍会记录诊断信息,但不会改变状态。
/// </summary>
[Test]
public void Dispatch_Without_Matching_Reducer_Should_Update_Record_Without_Changing_State()
{
var store = CreateStore();
store.Dispatch(new NoopAction());
Assert.That(store.State.Count, Is.EqualTo(0));
Assert.That(store.LastActionType, Is.EqualTo(typeof(NoopAction)));
Assert.That(store.LastDispatchRecord, Is.Not.Null);
Assert.That(store.LastDispatchRecord!.HasStateChanged, Is.False);
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>
/// 测试长时间运行的 middleware 不会长时间占用状态锁,
/// 使读取状态和新增订阅仍能在 dispatch 进行期间完成。
/// </summary>
[Test]
public void Dispatch_Should_Not_Block_State_Read_Or_Subscribe_While_Middleware_Is_Running()
{
using var entered = new ManualResetEventSlim(false);
using var release = new ManualResetEventSlim(false);
var store = CreateStore();
store.UseMiddleware(new BlockingMiddleware(entered, release));
var dispatchTask = Task.Run(() => store.Dispatch(new IncrementAction(1)));
Assert.That(entered.Wait(TimeSpan.FromSeconds(2)), Is.True, "middleware 未按预期进入阻塞阶段");
var stateReadTask = Task.Run(() => store.State.Count);
Assert.That(stateReadTask.Wait(TimeSpan.FromMilliseconds(200)), Is.True, "State 读取被 dispatch 长时间阻塞");
Assert.That(stateReadTask.Result, Is.EqualTo(0), "middleware 执行期间应仍能读取到提交前的状态快照");
var subscribeTask = Task.Run(() =>
{
var unRegister = store.Subscribe(_ => { });
unRegister.UnRegister();
});
Assert.That(subscribeTask.Wait(TimeSpan.FromMilliseconds(200)), Is.True, "Subscribe 被 dispatch 长时间阻塞");
release.Set();
Assert.That(dispatchTask.Wait(TimeSpan.FromSeconds(2)), Is.True, "dispatch 未在释放 middleware 后完成");
Assert.That(store.State.Count, Is.EqualTo(1));
}
/// <summary>
/// 创建一个带有基础 reducer 的测试 Store。
/// </summary>
/// <param name="initialState">可选初始状态。</param>
/// <returns>已配置基础 reducer 的 Store 实例。</returns>
private static Store<CounterState> CreateStore(CounterState? initialState = null)
{
var store = new Store<CounterState>(initialState ?? new CounterState(0, "Player"));
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
store.RegisterReducer<RenameAction>((state, action) => state with { Name = action.Name });
return store;
}
/// <summary>
/// 用于测试的计数器状态。
/// 使用 record 保持逻辑不可变语义,便于 Store 基于状态快照进行比较和断言。
/// </summary>
/// <param name="Count">当前计数值。</param>
/// <param name="Name">当前名称。</param>
private sealed record CounterState(int Count, string Name);
/// <summary>
/// 表示增加计数的 action。
/// </summary>
/// <param name="Amount">要增加的数量。</param>
private sealed record IncrementAction(int Amount);
/// <summary>
/// 表示修改名称的 action。
/// </summary>
/// <param name="Name">新的名称。</param>
private sealed record RenameAction(string Name);
/// <summary>
/// 表示没有匹配 reducer 的 action用于验证无变更分发路径。
/// </summary>
private sealed record NoopAction;
/// <summary>
/// 显式选择器实现,用于验证 IStateSelector 重载。
/// </summary>
private sealed class CounterNameSelector : IStateSelector<CounterState, string>
{
/// <summary>
/// 从状态中选择名称字段。
/// </summary>
/// <param name="state">完整状态。</param>
/// <returns>名称字段。</returns>
public string Select(CounterState state)
{
return state.Name;
}
}
/// <summary>
/// 将计数值按十位分桶比较的测试比较器。
/// 该比较器用于验证选择器只在局部状态“语义变化”时才触发通知。
/// </summary>
private sealed class TensBucketEqualityComparer : IEqualityComparer<int>
{
/// <summary>
/// 判断两个值是否落在同一个十位分桶中。
/// </summary>
/// <param name="x">左侧值。</param>
/// <param name="y">右侧值。</param>
/// <returns>若位于同一分桶则返回 <see langword="true"/>,否则返回 <see langword="false"/>。</returns>
public bool Equals(int x, int y)
{
return x / 10 == y / 10;
}
/// <summary>
/// 返回基于十位分桶的哈希码。
/// </summary>
/// <param name="obj">目标值。</param>
/// <returns>分桶哈希码。</returns>
public int GetHashCode(int obj)
{
return obj / 10;
}
}
/// <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>
private sealed class RecordingMiddleware(List<string> logs, string name) : IStoreMiddleware<CounterState>
{
/// <summary>
/// 记录当前中间件在分发前后的调用顺序。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">后续处理节点。</param>
public void Invoke(StoreDispatchContext<CounterState> context, Action next)
{
logs.Add($"{name}:before");
next();
logs.Add($"{name}:after");
}
}
/// <summary>
/// 用于验证 dispatch 管线在 middleware 执行期间不会占用状态锁的测试中间件。
/// </summary>
private sealed class BlockingMiddleware(ManualResetEventSlim entered, ManualResetEventSlim release)
: IStoreMiddleware<CounterState>
{
/// <summary>
/// 通知测试线程 middleware 已进入阻塞点,并等待释放信号后继续执行。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">后续处理节点。</param>
public void Invoke(StoreDispatchContext<CounterState> context, Action next)
{
entered.Set();
release.Wait(TimeSpan.FromSeconds(2));
next();
}
}
/// <summary>
/// 在中间件阶段尝试二次分发的测试中间件,用于验证重入保护。
/// </summary>
private sealed class NestedDispatchMiddleware(Store<CounterState> store) : IStoreMiddleware<CounterState>
{
/// <summary>
/// 标记是否已经触发过一次嵌套分发,避免因测试实现本身导致无限递归。
/// </summary>
private bool _hasTriggered;
/// <summary>
/// 在第一次进入中间件时执行嵌套分发。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="next">后续处理节点。</param>
public void Invoke(StoreDispatchContext<CounterState> context, Action next)
{
if (!_hasTriggered)
{
_hasTriggered = true;
store.Dispatch(new IncrementAction(1));
}
next();
}
}
}

View File

@ -30,12 +30,6 @@ public sealed class WaitForAllCoroutines(
/// 获取一个值,指示所有协程是否已完成执行 /// 获取一个值,指示所有协程是否已完成执行
/// </summary> /// </summary>
/// <returns>当所有协程都已完成时返回true否则返回false</returns> /// <returns>当所有协程都已完成时返回true否则返回false</returns>
public bool IsDone // 检查所有协程句柄是否都不在调度器中存活
{ public bool IsDone => _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));
get
{
// 检查所有协程句柄是否都不在调度器中存活
return _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));
}
}
} }

View File

@ -0,0 +1,90 @@
using GFramework.Core.Abstractions.Property;
using GFramework.Core.Abstractions.StateManagement;
using GFramework.Core.StateManagement;
namespace GFramework.Core.Extensions;
/// <summary>
/// 为 Store 提供选择器和 BindableProperty 风格桥接扩展。
/// 这些扩展用于在集中式状态容器和现有 Property/UI 生态之间建立最小侵入的互操作层。
/// </summary>
public static class StoreExtensions
{
/// <summary>
/// 从 Store 中选择一个局部状态视图。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">局部状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择委托。</param>
/// <returns>可用于订阅局部状态变化的只读绑定视图。</returns>
public static StoreSelection<TState, TSelected> Select<TState, TSelected>(
this IReadonlyStore<TState> store,
Func<TState, TSelected> selector)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(selector);
return new StoreSelection<TState, TSelected>(store, selector);
}
/// <summary>
/// 从 Store 中选择一个局部状态视图,并指定局部状态比较器。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">局部状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择委托。</param>
/// <param name="comparer">用于比较局部状态是否变化的比较器。</param>
/// <returns>可用于订阅局部状态变化的只读绑定视图。</returns>
public static StoreSelection<TState, TSelected> Select<TState, TSelected>(
this IReadonlyStore<TState> store,
Func<TState, TSelected> selector,
IEqualityComparer<TSelected>? comparer)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(selector);
return new StoreSelection<TState, TSelected>(store, selector, comparer);
}
/// <summary>
/// 使用显式选择器对象从 Store 中选择一个局部状态视图。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">局部状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择器实例。</param>
/// <param name="comparer">用于比较局部状态是否变化的比较器。</param>
/// <returns>可用于订阅局部状态变化的只读绑定视图。</returns>
public static StoreSelection<TState, TSelected> Select<TState, TSelected>(
this IReadonlyStore<TState> store,
IStateSelector<TState, TSelected> selector,
IEqualityComparer<TSelected>? comparer = null)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(selector);
return new StoreSelection<TState, TSelected>(store, selector.Select, comparer);
}
/// <summary>
/// 将 Store 中选中的局部状态桥接为 IReadonlyBindableProperty 风格接口。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">局部状态类型。</typeparam>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择委托。</param>
/// <param name="comparer">用于比较局部状态是否变化的比较器。</param>
/// <returns>只读绑定属性视图。</returns>
public static IReadonlyBindableProperty<TSelected> ToBindableProperty<TState, TSelected>(
this IReadonlyStore<TState> store,
Func<TState, TSelected> selector,
IEqualityComparer<TSelected>? comparer = null)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(selector);
return new StoreSelection<TState, TSelected>(store, selector, comparer);
}
}

View File

@ -13,6 +13,7 @@ GFramework 框架的核心模块提供MVC架构的基础设施。
- **Events** - 事件系统,实现组件间松耦合通信 - **Events** - 事件系统,实现组件间松耦合通信
- **IoC** - 轻量级依赖注入容器 - **IoC** - 轻量级依赖注入容器
- **Property** - 可绑定属性,支持数据绑定和响应式编程 - **Property** - 可绑定属性,支持数据绑定和响应式编程
- **StateManagement** - 集中式状态容器,支持状态归约、选择器和诊断
- **Utility** - 无状态工具类 - **Utility** - 无状态工具类
- **Pool** - 对象池系统减少GC压力 - **Pool** - 对象池系统减少GC压力
- **Extensions** - 框架扩展方法 - **Extensions** - 框架扩展方法

View File

@ -0,0 +1,837 @@
using GFramework.Core.Abstractions.Events;
using GFramework.Core.Abstractions.StateManagement;
using GFramework.Core.Events;
namespace GFramework.Core.StateManagement;
/// <summary>
/// 集中式状态容器的默认实现,用于统一管理复杂状态树的读取、归约和订阅通知。
/// 该类型定位于现有 BindableProperty 之上的可选能力,适合跨模块共享、需要统一变更入口
/// 或需要中间件/诊断能力的状态场景,而不是替代所有简单字段级响应式属性。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class Store<TState> : IStore<TState>, IStoreDiagnostics<TState>
{
/// <summary>
/// Dispatch 串行化门闩。
/// 该锁保证任意时刻只有一个 action 管线在运行,从而保持状态演进顺序确定,
/// 同时避免让耗时 middleware / reducer 长时间占用状态锁。
/// </summary>
private readonly object _dispatchGate = new();
/// <summary>
/// 当前状态变化订阅者列表。
/// 使用显式订阅对象而不是委托链,便于处理原子初始化订阅、挂起补发和精确解绑。
/// </summary>
private readonly List<ListenerSubscription> _listeners = [];
/// <summary>
/// Store 内部所有可变状态的同步锁。
/// 该锁仅保护状态快照、订阅集合、缓存选择视图和注册表本身的短临界区访问。
/// </summary>
private readonly object _lock = new();
/// <summary>
/// 已注册的中间件链,按添加顺序执行。
/// 每个条目都持有稳定身份,便于通过注销句柄精确移除而不影响其他同类中间件。
/// Dispatch 开始时会抓取快照,因此运行中的分发不会受到后续注册变化影响。
/// </summary>
private readonly List<MiddlewareRegistration> _middlewares = [];
/// <summary>
/// 按 action 具体运行时类型组织的 reducer 注册表。
/// Store 采用精确类型匹配策略,保证 reducer 执行顺序和行为保持确定性。
/// 每个 reducer 通过注册条目获得稳定身份,以支持运行时精确注销。
/// Dispatch 开始时会抓取对应 action 类型的 reducer 快照。
/// </summary>
private readonly Dictionary<Type, List<ReducerRegistration>> _reducers = [];
/// <summary>
/// 已缓存的局部状态选择视图。
/// 该缓存用于避免高频访问的 Model 属性在每次 getter 调用时都创建新的选择对象。
/// </summary>
private readonly Dictionary<string, object> _selectionCache = [];
/// <summary>
/// 用于判断状态是否发生有效变化的比较器。
/// </summary>
private readonly IEqualityComparer<TState> _stateComparer;
/// <summary>
/// 标记当前 Store 是否正在执行分发。
/// 该标记用于阻止同一 Store 的重入分发,避免产生难以推导的执行顺序和状态回滚问题。
/// </summary>
private bool _isDispatching;
/// <summary>
/// 最近一次分发的 action 类型。
/// </summary>
private Type? _lastActionType;
/// <summary>
/// 最近一次分发记录。
/// </summary>
private StoreDispatchRecord<TState>? _lastDispatchRecord;
/// <summary>
/// 最近一次真正改变状态的时间戳。
/// </summary>
private DateTimeOffset? _lastStateChangedAt;
/// <summary>
/// 当前 Store 持有的状态快照。
/// </summary>
private TState _state;
/// <summary>
/// 初始化一个新的 Store。
/// </summary>
/// <param name="initialState">Store 的初始状态。</param>
/// <param name="comparer">状态比较器;未提供时使用 <see cref="EqualityComparer{T}.Default"/>。</param>
public Store(TState initialState, IEqualityComparer<TState>? comparer = null)
{
_state = initialState;
_stateComparer = comparer ?? EqualityComparer<TState>.Default;
}
/// <summary>
/// 获取当前状态快照。
/// </summary>
public TState State
{
get
{
lock (_lock)
{
return _state;
}
}
}
/// <summary>
/// 分发一个 action 并按顺序执行匹配的 reducer。
/// </summary>
/// <typeparam name="TAction">action 的具体类型。</typeparam>
/// <param name="action">要分发的 action。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
/// <exception cref="InvalidOperationException">当同一 Store 发生重入分发时抛出。</exception>
public void Dispatch<TAction>(TAction action)
{
ArgumentNullException.ThrowIfNull(action);
Action<TState>[] listenersSnapshot = Array.Empty<Action<TState>>();
IStoreMiddleware<TState>[] middlewaresSnapshot = Array.Empty<IStoreMiddleware<TState>>();
IStoreReducerAdapter[] reducersSnapshot = Array.Empty<IStoreReducerAdapter>();
IEqualityComparer<TState> stateComparerSnapshot = _stateComparer;
StoreDispatchContext<TState>? context = null;
var enteredDispatchScope = false;
lock (_dispatchGate)
{
try
{
lock (_lock)
{
EnsureNotDispatching();
_isDispatching = true;
enteredDispatchScope = true;
context = new StoreDispatchContext<TState>(action!, _state);
stateComparerSnapshot = _stateComparer;
middlewaresSnapshot = CreateMiddlewareSnapshot();
reducersSnapshot = CreateReducerSnapshot(context.ActionType);
}
// middleware 和 reducer 可能包含较重的同步逻辑,因此仅持有 dispatch 串行门,
// 不占用状态锁,让读取、订阅和注册操作只在需要访问共享状态时短暂阻塞。
ExecuteDispatchPipeline(context, middlewaresSnapshot, reducersSnapshot, stateComparerSnapshot);
lock (_lock)
{
_lastActionType = context.ActionType;
_lastDispatchRecord = new StoreDispatchRecord<TState>(
context.Action,
context.PreviousState,
context.NextState,
context.HasStateChanged,
context.DispatchedAt);
if (!context.HasStateChanged)
{
return;
}
_state = context.NextState;
_lastStateChangedAt = context.DispatchedAt;
listenersSnapshot = SnapshotListenersForNotification(context.NextState);
}
}
finally
{
if (enteredDispatchScope)
{
lock (_lock)
{
_isDispatching = false;
}
}
}
}
// 始终在锁外通知订阅者,避免监听器内部读取 Store 或执行额外逻辑时产生死锁。
foreach (var listener in listenersSnapshot)
{
listener(context!.NextState);
}
}
/// <summary>
/// 订阅状态变化通知。
/// </summary>
/// <param name="listener">状态变化时的监听器。</param>
/// <returns>用于取消订阅的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="listener"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister Subscribe(Action<TState> listener)
{
ArgumentNullException.ThrowIfNull(listener);
var subscription = new ListenerSubscription(listener);
lock (_lock)
{
_listeners.Add(subscription);
}
return new DefaultUnRegister(() => UnSubscribe(subscription));
}
/// <summary>
/// 订阅状态变化通知,并立即回放当前状态。
/// </summary>
/// <param name="listener">状态变化时的监听器。</param>
/// <returns>用于取消订阅的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="listener"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister SubscribeWithInitValue(Action<TState> listener)
{
ArgumentNullException.ThrowIfNull(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>
/// 取消订阅指定监听器。
/// </summary>
/// <param name="listener">需要移除的监听器。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="listener"/> 为 <see langword="null"/> 时抛出。</exception>
public void UnSubscribe(Action<TState> listener)
{
ArgumentNullException.ThrowIfNull(listener);
lock (_lock)
{
var index = _listeners.FindIndex(subscription => subscription.Listener == listener);
if (index < 0)
{
return;
}
_listeners[index].IsSubscribed = false;
_listeners.RemoveAt(index);
}
}
/// <summary>
/// 获取最近一次分发的 action 类型。
/// </summary>
public Type? LastActionType
{
get
{
lock (_lock)
{
return _lastActionType;
}
}
}
/// <summary>
/// 获取最近一次真正改变状态的时间戳。
/// </summary>
public DateTimeOffset? LastStateChangedAt
{
get
{
lock (_lock)
{
return _lastStateChangedAt;
}
}
}
/// <summary>
/// 获取当前订阅者数量。
/// </summary>
public int SubscriberCount
{
get
{
lock (_lock)
{
return _listeners.Count;
}
}
}
/// <summary>
/// 获取最近一次分发记录。
/// </summary>
public StoreDispatchRecord<TState>? LastDispatchRecord
{
get
{
lock (_lock)
{
return _lastDispatchRecord;
}
}
}
/// <summary>
/// 创建一个用于当前状态类型的 Store 构建器。
/// </summary>
/// <returns>新的 Store 构建器实例。</returns>
public static StoreBuilder<TState> CreateBuilder()
{
return new StoreBuilder<TState>();
}
/// <summary>
/// 注册一个强类型 reducer。
/// 同一 action 类型可注册多个 reducer它们会按照注册顺序依次归约状态。
/// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 <see cref="RegisterReducerHandle{TAction}(IReducer{TState, TAction})"/>。
/// </summary>
/// <typeparam name="TAction">reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">要注册的 reducer 实例。</param>
/// <returns>当前 Store 实例,便于链式配置。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="reducer"/> 为 <see langword="null"/> 时抛出。</exception>
public Store<TState> RegisterReducer<TAction>(IReducer<TState, TAction> reducer)
{
RegisterReducerHandle(reducer);
return this;
}
/// <summary>
/// 使用委托快速注册一个 reducer。
/// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 <see cref="RegisterReducerHandle{TAction}(Func{TState, TAction, TState})"/>。
/// </summary>
/// <typeparam name="TAction">reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">执行归约的委托。</param>
/// <returns>当前 Store 实例,便于链式配置。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="reducer"/> 为 <see langword="null"/> 时抛出。</exception>
public Store<TState> RegisterReducer<TAction>(Func<TState, TAction, TState> reducer)
{
ArgumentNullException.ThrowIfNull(reducer);
return RegisterReducer(new DelegateReducer<TAction>(reducer));
}
/// <summary>
/// 注册一个强类型 reducer并返回可用于注销该 reducer 的句柄。
/// 该句柄只会移除当前这次注册,不会影响同一 action 类型下的其他 reducer。
/// 若在 dispatch 进行中调用注销,当前这次 dispatch 仍会使用开始时抓取的 reducer 快照,
/// 注销仅影响之后的新 dispatch。
/// </summary>
/// <typeparam name="TAction">reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">要注册的 reducer 实例。</param>
/// <returns>用于注销当前 reducer 注册的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="reducer"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister RegisterReducerHandle<TAction>(IReducer<TState, TAction> reducer)
{
ArgumentNullException.ThrowIfNull(reducer);
var actionType = typeof(TAction);
var registration = new ReducerRegistration(new ReducerAdapter<TAction>(reducer));
lock (_lock)
{
if (!_reducers.TryGetValue(actionType, out var reducers))
{
reducers = [];
_reducers[actionType] = reducers;
}
reducers.Add(registration);
}
return new DefaultUnRegister(() => UnRegisterReducer(actionType, registration));
}
/// <summary>
/// 使用委托快速注册一个 reducer并返回可用于注销该 reducer 的句柄。
/// 适合测试代码或按场景临时挂载的状态逻辑。
/// </summary>
/// <typeparam name="TAction">reducer 处理的 action 类型。</typeparam>
/// <param name="reducer">执行归约的委托。</param>
/// <returns>用于注销当前 reducer 注册的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="reducer"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister RegisterReducerHandle<TAction>(Func<TState, TAction, TState> reducer)
{
ArgumentNullException.ThrowIfNull(reducer);
return RegisterReducerHandle(new DelegateReducer<TAction>(reducer));
}
/// <summary>
/// 添加一个 Store 中间件。
/// 中间件按添加顺序包裹 reducer 执行,可用于日志、审计或调试。
/// 该重载保留现有链式配置体验;若需要在运行时注销,请改用 <see cref="RegisterMiddleware"/>.
/// </summary>
/// <param name="middleware">要添加的中间件实例。</param>
/// <returns>当前 Store 实例,便于链式配置。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="middleware"/> 为 <see langword="null"/> 时抛出。</exception>
public Store<TState> UseMiddleware(IStoreMiddleware<TState> middleware)
{
RegisterMiddleware(middleware);
return this;
}
/// <summary>
/// 注册一个 Store 中间件,并返回可用于注销该中间件的句柄。
/// 中间件按注册顺序包裹 reducer 执行;注销只会移除当前这次注册。
/// 若在 dispatch 进行中调用注销,当前这次 dispatch 仍会使用开始时抓取的中间件快照,
/// 注销仅影响之后的新 dispatch。
/// </summary>
/// <param name="middleware">要注册的中间件实例。</param>
/// <returns>用于注销当前中间件注册的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="middleware"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister RegisterMiddleware(IStoreMiddleware<TState> middleware)
{
ArgumentNullException.ThrowIfNull(middleware);
var registration = new MiddlewareRegistration(middleware);
lock (_lock)
{
_middlewares.Add(registration);
}
return new DefaultUnRegister(() => UnRegisterMiddleware(registration));
}
/// <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>
/// <param name="context">当前分发上下文。</param>
/// <param name="middlewares">本次分发使用的中间件快照。</param>
/// <param name="reducers">本次分发使用的 reducer 快照。</param>
/// <param name="stateComparer">本次分发使用的状态比较器快照。</param>
private static void ExecuteDispatchPipeline(
StoreDispatchContext<TState> context,
IReadOnlyList<IStoreMiddleware<TState>> middlewares,
IReadOnlyList<IStoreReducerAdapter> reducers,
IEqualityComparer<TState> stateComparer)
{
Action pipeline = () => ApplyReducers(context, reducers, stateComparer);
for (var i = middlewares.Count - 1; i >= 0; i--)
{
var middleware = middlewares[i];
var next = pipeline;
pipeline = () => middleware.Invoke(context, next);
}
pipeline();
}
/// <summary>
/// 对当前 action 应用所有匹配的 reducer。
/// reducer 使用 action 的精确运行时类型进行查找,以保证匹配结果和执行顺序稳定。
/// </summary>
/// <param name="context">当前分发上下文。</param>
/// <param name="reducers">本次分发使用的 reducer 快照。</param>
/// <param name="stateComparer">本次分发使用的状态比较器快照。</param>
private static void ApplyReducers(
StoreDispatchContext<TState> context,
IReadOnlyList<IStoreReducerAdapter> reducers,
IEqualityComparer<TState> stateComparer)
{
if (reducers.Count == 0)
{
context.NextState = context.PreviousState;
context.HasStateChanged = false;
return;
}
var nextState = context.PreviousState;
// 多个 reducer 共享同一 action 类型时,后一个 reducer 以前一个 reducer 的输出作为输入,
// 从而支持按模块拆分归约逻辑,同时保持总体状态演进顺序明确。
foreach (var reducer in reducers)
{
nextState = reducer.Reduce(nextState, context.Action);
}
context.NextState = nextState;
context.HasStateChanged = !stateComparer.Equals(context.PreviousState, nextState);
}
/// <summary>
/// 确保当前 Store 没有发生重入分发。
/// </summary>
/// <exception cref="InvalidOperationException">当检测到重入分发时抛出。</exception>
private void EnsureNotDispatching()
{
if (_isDispatching)
{
throw new InvalidOperationException("Nested dispatch on the same store is not allowed.");
}
}
/// <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>
/// <returns>当前中间件链的快照;若未注册则返回空数组。</returns>
private IStoreMiddleware<TState>[] CreateMiddlewareSnapshot()
{
lock (_lock)
{
if (_middlewares.Count == 0)
{
return Array.Empty<IStoreMiddleware<TState>>();
}
var snapshot = new IStoreMiddleware<TState>[_middlewares.Count];
for (var i = 0; i < _middlewares.Count; i++)
{
snapshot[i] = _middlewares[i].Middleware;
}
return snapshot;
}
}
/// <summary>
/// 为当前 action 类型创建 reducer 快照。
/// 该方法自行获取状态锁,避免让快照安全性依赖调用方的锁顺序知识。
/// </summary>
/// <param name="actionType">当前分发的 action 类型。</param>
/// <returns>对应 action 类型的 reducer 快照;若未注册则返回空数组。</returns>
private IStoreReducerAdapter[] CreateReducerSnapshot(Type actionType)
{
lock (_lock)
{
if (!_reducers.TryGetValue(actionType, out var reducers) || reducers.Count == 0)
{
return Array.Empty<IStoreReducerAdapter>();
}
var snapshot = new IStoreReducerAdapter[reducers.Count];
for (var i = 0; i < reducers.Count; i++)
{
snapshot[i] = reducers[i].Adapter;
}
return snapshot;
}
}
/// <summary>
/// 解绑一个精确的订阅对象。
/// </summary>
/// <param name="subscription">要解绑的订阅对象。</param>
private void UnSubscribe(ListenerSubscription subscription)
{
lock (_lock)
{
subscription.IsSubscribed = false;
_listeners.Remove(subscription);
}
}
/// <summary>
/// 注销一个中间件注册条目。
/// 仅精确移除与当前句柄关联的条目,避免误删同一实例的其他重复注册。
/// </summary>
/// <param name="registration">要移除的中间件注册条目。</param>
private void UnRegisterMiddleware(MiddlewareRegistration registration)
{
lock (_lock)
{
_middlewares.Remove(registration);
}
}
/// <summary>
/// 注销一个 reducer 注册条目。
/// 若该 action 类型下已无其他 reducer则同时清理空注册桶保持注册表紧凑。
/// </summary>
/// <param name="actionType">reducer 对应的 action 类型。</param>
/// <param name="registration">要移除的 reducer 注册条目。</param>
private void UnRegisterReducer(Type actionType, ReducerRegistration registration)
{
lock (_lock)
{
if (!_reducers.TryGetValue(actionType, out var reducers))
{
return;
}
reducers.Remove(registration);
if (reducers.Count == 0)
{
_reducers.Remove(actionType);
}
}
}
/// <summary>
/// 适配不同 action 类型 reducer 的内部统一接口。
/// Store 通过该接口在运行时按 action 具体类型执行 reducer而不暴露内部装配细节。
/// </summary>
private interface IStoreReducerAdapter
{
/// <summary>
/// 使用当前 action 对状态进行一次归约。
/// </summary>
/// <param name="currentState">当前状态。</param>
/// <param name="action">分发中的 action。</param>
/// <returns>归约后的下一状态。</returns>
TState Reduce(TState currentState, object action);
}
/// <summary>
/// 基于强类型 reducer 的适配器实现。
/// 该适配器仅负责安全地完成 object 到 action 类型的转换,然后委托给真实 reducer。
/// </summary>
/// <typeparam name="TAction">当前适配器负责处理的 action 类型。</typeparam>
private sealed class ReducerAdapter<TAction>(IReducer<TState, TAction> reducer) : IStoreReducerAdapter
{
/// <summary>
/// 包装后的强类型 reducer 实例。
/// </summary>
private readonly IReducer<TState, TAction> _reducer =
reducer ?? throw new ArgumentNullException(nameof(reducer));
/// <summary>
/// 将运行时 action 转换为强类型 action 后执行归约。
/// </summary>
/// <param name="currentState">当前状态。</param>
/// <param name="action">运行时 action。</param>
/// <returns>归约后的下一状态。</returns>
public TState Reduce(TState currentState, object action)
{
return _reducer.Reduce(currentState, (TAction)action);
}
}
/// <summary>
/// 表示一条 reducer 注册记录。
/// 该包装对象为运行时注销提供稳定身份,同时不改变 reducer 的执行顺序语义。
/// </summary>
private sealed class ReducerRegistration(IStoreReducerAdapter adapter)
{
/// <summary>
/// 获取真正执行归约的内部适配器。
/// </summary>
public IStoreReducerAdapter Adapter { get; } = adapter;
}
/// <summary>
/// 基于委托的 reducer 适配器实现,便于快速在测试和应用代码中声明 reducer。
/// </summary>
/// <typeparam name="TAction">当前适配器负责处理的 action 类型。</typeparam>
private sealed class DelegateReducer<TAction>(Func<TState, TAction, TState> reducer) : IReducer<TState, TAction>
{
/// <summary>
/// 真正执行归约的委托。
/// </summary>
private readonly Func<TState, TAction, TState> _reducer =
reducer ?? throw new ArgumentNullException(nameof(reducer));
/// <summary>
/// 执行一次委托归约。
/// </summary>
/// <param name="currentState">当前状态。</param>
/// <param name="action">当前 action。</param>
/// <returns>归约后的下一状态。</returns>
public TState Reduce(TState currentState, TAction action)
{
return _reducer(currentState, action);
}
}
/// <summary>
/// 表示一条中间件注册记录。
/// 通过显式注册对象而不是直接存储中间件实例,可在重复注册同一实例时保持精确注销。
/// </summary>
private sealed class MiddlewareRegistration(IStoreMiddleware<TState> middleware)
{
/// <summary>
/// 获取注册的中间件实例。
/// </summary>
public IStoreMiddleware<TState> Middleware { get; } = middleware;
}
/// <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!;
}
}

View File

@ -0,0 +1,88 @@
using GFramework.Core.Abstractions.StateManagement;
namespace GFramework.Core.StateManagement;
/// <summary>
/// Store 构建器的默认实现。
/// 该类型用于在 Store 创建之前集中配置比较器、reducer 和中间件,适合模块安装和测试工厂场景。
/// </summary>
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
public sealed class StoreBuilder<TState> : IStoreBuilder<TState>
{
/// <summary>
/// 延迟应用到 Store 的配置操作列表。
/// 采用延迟配置而不是直接缓存 reducer 适配器,可复用 Store 自身的注册和验证逻辑。
/// </summary>
private readonly List<Action<Store<TState>>> _configurators = [];
/// <summary>
/// 状态比较器。
/// </summary>
private IEqualityComparer<TState>? _comparer;
/// <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;
}
/// <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>
/// 配置状态比较器。
/// </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">执行归约的委托。</param>
/// <returns>当前构建器实例。</returns>
public IStoreBuilder<TState> AddReducer<TAction>(Func<TState, TAction, TState> reducer)
{
ArgumentNullException.ThrowIfNull(reducer);
_configurators.Add(store => store.RegisterReducer(reducer));
return this;
}
}

View File

@ -0,0 +1,394 @@
using GFramework.Core.Abstractions.Events;
using GFramework.Core.Abstractions.Property;
using GFramework.Core.Abstractions.StateManagement;
using GFramework.Core.Events;
namespace GFramework.Core.StateManagement;
/// <summary>
/// Store 选择结果的只读绑定视图。
/// 该类型将整棵状态树上的订阅转换为局部状态片段的订阅,
/// 使现有依赖 IReadonlyBindableProperty 的 UI 代码能够平滑复用到 Store 场景中。
/// </summary>
/// <typeparam name="TState">源状态类型。</typeparam>
/// <typeparam name="TSelected">投影后的局部状态类型。</typeparam>
public sealed class StoreSelection<TState, TSelected> : IReadonlyBindableProperty<TSelected>
{
/// <summary>
/// 用于判断选择结果是否真正变化的比较器。
/// </summary>
private readonly IEqualityComparer<TSelected> _comparer;
/// <summary>
/// 当前监听器列表。
/// </summary>
private readonly List<SelectionListenerSubscription> _listeners = [];
/// <summary>
/// 保护监听器集合和底层 Store 订阅句柄的同步锁。
/// </summary>
private readonly object _lock = new();
/// <summary>
/// 负责从完整状态中投影出局部状态的选择器。
/// </summary>
private readonly Func<TState, TSelected> _selector;
/// <summary>
/// 源 Store。
/// </summary>
private readonly IReadonlyStore<TState> _store;
/// <summary>
/// 当前已缓存的选择结果。
/// 该缓存仅在存在监听器时用于变化比较和事件通知,直接读取 Value 时始终以 Store 当前状态为准。
/// </summary>
private TSelected _currentValue = default!;
/// <summary>
/// 连接到底层 Store 的订阅句柄。
/// 仅当当前存在至少一个监听器时才会建立该订阅,以减少长期闲置对象造成的引用链。
/// </summary>
private IUnRegister? _storeSubscription;
/// <summary>
/// 初始化一个新的 Store 选择视图。
/// </summary>
/// <param name="store">源 Store。</param>
/// <param name="selector">状态选择器。</param>
/// <param name="comparer">选择结果比较器;未提供时使用 <see cref="EqualityComparer{T}.Default"/>。</param>
/// <exception cref="ArgumentNullException">
/// 当 <paramref name="store"/> 或 <paramref name="selector"/> 为 <see langword="null"/> 时抛出。
/// </exception>
public StoreSelection(
IReadonlyStore<TState> store,
Func<TState, TSelected> selector,
IEqualityComparer<TSelected>? comparer = null)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_selector = selector ?? throw new ArgumentNullException(nameof(selector));
_comparer = comparer ?? EqualityComparer<TSelected>.Default;
}
/// <summary>
/// 获取当前选择结果。
/// </summary>
public TSelected Value => _selector(_store.State);
/// <summary>
/// 将无参事件监听适配为带选择结果参数的监听。
/// </summary>
/// <param name="onEvent">无参事件监听器。</param>
/// <returns>用于取消订阅的句柄。</returns>
IUnRegister IEvent.Register(Action onEvent)
{
ArgumentNullException.ThrowIfNull(onEvent);
return Register(_ => onEvent());
}
/// <summary>
/// 注册选择结果变化监听器。
/// </summary>
/// <param name="onValueChanged">选择结果变化时的回调。</param>
/// <returns>用于取消订阅的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="onValueChanged"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister Register(Action<TSelected> onValueChanged)
{
ArgumentNullException.ThrowIfNull(onValueChanged);
var subscription = new SelectionListenerSubscription(onValueChanged);
var shouldAttach = false;
lock (_lock)
{
if (_listeners.Count == 0)
{
_currentValue = Value;
shouldAttach = true;
}
_listeners.Add(subscription);
}
if (shouldAttach)
{
AttachToStore();
}
return new DefaultUnRegister(() => UnRegister(subscription));
}
/// <summary>
/// 注册选择结果变化监听器,并立即回放当前值。
/// </summary>
/// <param name="action">选择结果变化时的回调。</param>
/// <returns>用于取消订阅的句柄。</returns>
/// <exception cref="ArgumentNullException">当 <paramref name="action"/> 为 <see langword="null"/> 时抛出。</exception>
public IUnRegister RegisterWithInitValue(Action<TSelected> action)
{
ArgumentNullException.ThrowIfNull(action);
var subscription = new SelectionListenerSubscription(action)
{
IsActive = false
};
var currentValue = Value;
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>
/// 取消注册选择结果变化监听器。
/// </summary>
/// <param name="onValueChanged">需要移除的监听器。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="onValueChanged"/> 为 <see langword="null"/> 时抛出。</exception>
public void UnRegister(Action<TSelected> 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;
lock (_lock)
{
subscriptionToRemove.IsSubscribed = false;
_listeners.Remove(subscriptionToRemove);
if (_listeners.Count == 0 && _storeSubscription != null)
{
storeSubscription = _storeSubscription;
_storeSubscription = null;
}
}
storeSubscription?.UnRegister();
}
/// <summary>
/// 将当前选择视图连接到底层 Store。
/// </summary>
private void AttachToStore()
{
var subscription = _store.Subscribe(OnStoreChanged);
Action<TSelected>[] listenersSnapshot = Array.Empty<Action<TSelected>>();
var latestValue = Value;
var shouldNotify = false;
lock (_lock)
{
// 如果在建立底层订阅期间所有监听器都已被移除,则立即释放刚刚建立的订阅,
// 避免选择视图在无人监听时继续被 Store 保持引用。
if (_listeners.Count == 0)
{
subscription.UnRegister();
return;
}
if (_storeSubscription != null)
{
subscription.UnRegister();
return;
}
_storeSubscription = subscription;
if (!_comparer.Equals(_currentValue, latestValue))
{
_currentValue = latestValue;
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;
}
}
if (!shouldNotify)
{
return;
}
foreach (var listener in listenersSnapshot)
{
listener(latestValue);
}
}
/// <summary>
/// 响应底层 Store 的状态变化,并在选中片段真正变化时通知监听器。
/// </summary>
/// <param name="state">新的完整状态。</param>
private void OnStoreChanged(TState state)
{
var selectedValue = _selector(state);
Action<TSelected>[] listenersSnapshot = Array.Empty<Action<TSelected>>();
lock (_lock)
{
if (_listeners.Count == 0 || _comparer.Equals(_currentValue, selectedValue))
{
return;
}
_currentValue = selectedValue;
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)
{
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!;
}
}

View File

@ -0,0 +1,40 @@
#nullable enable
namespace GFramework.Godot.SourceGenerators.Abstractions;
/// <summary>
/// 标记 Godot 节点字段Source Generator 会为其生成节点获取逻辑。
/// </summary>
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public sealed class GetNodeAttribute : Attribute
{
/// <summary>
/// 初始化 <see cref="GetNodeAttribute" /> 的新实例。
/// </summary>
public GetNodeAttribute()
{
}
/// <summary>
/// 初始化 <see cref="GetNodeAttribute" /> 的新实例,并指定节点路径。
/// </summary>
/// <param name="path">节点路径。</param>
public GetNodeAttribute(string path)
{
Path = path;
}
/// <summary>
/// 获取或设置节点路径。未设置时将根据字段名推导。
/// </summary>
public string? Path { get; set; }
/// <summary>
/// 获取或设置节点是否必填。默认为 true。
/// </summary>
public bool Required { get; set; } = true;
/// <summary>
/// 获取或设置节点查找模式。默认为 <see cref="NodeLookupMode.Auto" />。
/// </summary>
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
}

View File

@ -0,0 +1,28 @@
#nullable enable
namespace GFramework.Godot.SourceGenerators.Abstractions;
/// <summary>
/// 节点路径的查找模式。
/// </summary>
public enum NodeLookupMode
{
/// <summary>
/// 自动推断。未显式设置路径时默认按唯一名查找。
/// </summary>
Auto = 0,
/// <summary>
/// 按唯一名查找,对应 Godot 的 %Name 语法。
/// </summary>
UniqueName = 1,
/// <summary>
/// 按相对路径查找。
/// </summary>
RelativePath = 2,
/// <summary>
/// 按绝对路径查找。
/// </summary>
AbsolutePath = 3
}

View File

@ -0,0 +1,37 @@
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
namespace GFramework.Godot.SourceGenerators.Tests.Core;
/// <summary>
/// 提供源代码生成器测试的通用功能。
/// </summary>
/// <typeparam name="TGenerator">要测试的源代码生成器类型,必须具有无参构造函数。</typeparam>
public static class GeneratorTest<TGenerator>
where TGenerator : new()
{
/// <summary>
/// 运行源代码生成器测试。
/// </summary>
/// <param name="source">输入源代码。</param>
/// <param name="generatedSources">期望生成的源文件集合。</param>
public static async Task RunAsync(
string source,
params (string filename, string content)[] generatedSources)
{
var test = new CSharpSourceGeneratorTest<TGenerator, DefaultVerifier>
{
TestState =
{
Sources = { source }
},
DisabledDiagnostics = { "GF_Common_Trace_001" }
};
foreach (var (filename, content) in generatedSources)
test.TestState.GeneratedSources.Add(
(typeof(TGenerator), filename, content));
await test.RunAsync();
}
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TestTargetFrameworks Condition="'$(TestTargetFrameworks)' == ''">net10.0</TestTargetFrameworks>
<TargetFrameworks>$(TestTargetFrameworks)</TargetFrameworks>
<ImplicitUsings>disable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis" Version="4.14.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.Common" Version="4.14.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.14.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.3"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0"/>
<PackageReference Include="NUnit" Version="4.5.1"/>
<PackageReference Include="NUnit3TestAdapter" Version="6.1.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\GFramework.Godot.SourceGenerators\GFramework.Godot.SourceGenerators.csproj"/>
</ItemGroup>
</Project>

View File

@ -0,0 +1,243 @@
using GFramework.Godot.SourceGenerators.Tests.Core;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using NUnit.Framework;
namespace GFramework.Godot.SourceGenerators.Tests.GetNode;
[TestFixture]
public class GetNodeGeneratorTests
{
[Test]
public async Task Generates_InferredUniqueNameBindings_And_ReadyHook_WhenReadyIsMissing()
{
const string source = """
using System;
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public sealed class GetNodeAttribute : Attribute
{
public GetNodeAttribute() {}
public GetNodeAttribute(string path) { Path = path; }
public string? Path { get; set; }
public bool Required { get; set; } = true;
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
}
public enum NodeLookupMode
{
Auto = 0,
UniqueName = 1,
RelativePath = 2,
AbsolutePath = 3
}
}
namespace Godot
{
public class Node
{
public virtual void _Ready() {}
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
public T? GetNodeOrNull<T>(string path) where T : Node => default;
}
public class HBoxContainer : Node
{
}
}
namespace TestApp
{
public partial class TopBar : HBoxContainer
{
[GetNode]
private HBoxContainer _leftContainer = null!;
[GetNode]
private HBoxContainer m_rightContainer = null!;
}
}
""";
const string expected = """
// <auto-generated />
#nullable enable
namespace TestApp;
partial class TopBar
{
private void __InjectGetNodes_Generated()
{
_leftContainer = GetNode<global::Godot.HBoxContainer>("%LeftContainer");
m_rightContainer = GetNode<global::Godot.HBoxContainer>("%RightContainer");
}
partial void OnGetNodeReadyGenerated();
public override void _Ready()
{
__InjectGetNodes_Generated();
OnGetNodeReadyGenerated();
}
}
""";
await GeneratorTest<GetNodeGenerator>.RunAsync(
source,
("TestApp_TopBar.GetNode.g.cs", expected));
}
[Test]
public async Task Generates_ManualInjectionOnly_WhenReadyAlreadyExists()
{
const string source = """
using System;
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public sealed class GetNodeAttribute : Attribute
{
public GetNodeAttribute() {}
public GetNodeAttribute(string path) { Path = path; }
public string? Path { get; set; }
public bool Required { get; set; } = true;
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
}
public enum NodeLookupMode
{
Auto = 0,
UniqueName = 1,
RelativePath = 2,
AbsolutePath = 3
}
}
namespace Godot
{
public class Node
{
public virtual void _Ready() {}
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
public T? GetNodeOrNull<T>(string path) where T : Node => default;
}
public class HBoxContainer : Node
{
}
}
namespace TestApp
{
public partial class TopBar : HBoxContainer
{
[GetNode("%LeftContainer")]
private HBoxContainer _leftContainer = null!;
[GetNode(Required = false, Lookup = NodeLookupMode.RelativePath)]
private HBoxContainer? _rightContainer;
public override void _Ready()
{
__InjectGetNodes_Generated();
}
}
}
""";
const string expected = """
// <auto-generated />
#nullable enable
namespace TestApp;
partial class TopBar
{
private void __InjectGetNodes_Generated()
{
_leftContainer = GetNode<global::Godot.HBoxContainer>("%LeftContainer");
_rightContainer = GetNodeOrNull<global::Godot.HBoxContainer>("RightContainer");
}
}
""";
await GeneratorTest<GetNodeGenerator>.RunAsync(
source,
("TestApp_TopBar.GetNode.g.cs", expected));
}
[Test]
public async Task Reports_Diagnostic_When_FieldType_IsNotGodotNode()
{
const string source = """
using System;
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
namespace GFramework.Godot.SourceGenerators.Abstractions
{
[AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)]
public sealed class GetNodeAttribute : Attribute
{
public string? Path { get; set; }
public bool Required { get; set; } = true;
public NodeLookupMode Lookup { get; set; } = NodeLookupMode.Auto;
}
public enum NodeLookupMode
{
Auto = 0,
UniqueName = 1,
RelativePath = 2,
AbsolutePath = 3
}
}
namespace Godot
{
public class Node
{
public virtual void _Ready() {}
public T GetNode<T>(string path) where T : Node => throw new InvalidOperationException(path);
public T? GetNodeOrNull<T>(string path) where T : Node => default;
}
}
namespace TestApp
{
public partial class TopBar : Node
{
[GetNode]
private string _leftContainer = string.Empty;
}
}
""";
var test = new CSharpSourceGeneratorTest<GetNodeGenerator, DefaultVerifier>
{
TestState =
{
Sources = { source }
},
DisabledDiagnostics = { "GF_Common_Trace_001" }
};
test.ExpectedDiagnostics.Add(new DiagnosticResult("GF_Godot_GetNode_004", DiagnosticSeverity.Error)
.WithSpan(39, 24, 39, 38)
.WithArguments("_leftContainer"));
await test.RunAsync();
}
}

View File

@ -0,0 +1,18 @@
// Copyright (c) 2025 GeWuYou
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
global using System;
global using System.Collections.Generic;
global using System.Linq;
global using System.Threading;
global using System.Threading.Tasks;

View File

@ -0,0 +1,3 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

View File

@ -0,0 +1,13 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
### New Rules
Rule ID | Category | Severity | Notes
----------------------|------------------|----------|--------------------
GF_Godot_GetNode_001 | GFramework.Godot | Error | GetNodeDiagnostics
GF_Godot_GetNode_002 | GFramework.Godot | Error | GetNodeDiagnostics
GF_Godot_GetNode_003 | GFramework.Godot | Error | GetNodeDiagnostics
GF_Godot_GetNode_004 | GFramework.Godot | Error | GetNodeDiagnostics
GF_Godot_GetNode_005 | GFramework.Godot | Error | GetNodeDiagnostics
GF_Godot_GetNode_006 | GFramework.Godot | Warning | GetNodeDiagnostics

View File

@ -0,0 +1,82 @@
using GFramework.SourceGenerators.Common.Constants;
using Microsoft.CodeAnalysis;
namespace GFramework.Godot.SourceGenerators.Diagnostics;
/// <summary>
/// GetNode 生成器相关诊断。
/// </summary>
public static class GetNodeDiagnostics
{
/// <summary>
/// 嵌套类型不受支持。
/// </summary>
public static readonly DiagnosticDescriptor NestedClassNotSupported =
new(
"GF_Godot_GetNode_001",
"Nested classes are not supported",
"Class '{0}' cannot use [GetNode] inside a nested type",
PathContests.GodotNamespace,
DiagnosticSeverity.Error,
true);
/// <summary>
/// static 字段不受支持。
/// </summary>
public static readonly DiagnosticDescriptor StaticFieldNotSupported =
new(
"GF_Godot_GetNode_002",
"Static fields are not supported",
"Field '{0}' cannot be static when using [GetNode]",
PathContests.GodotNamespace,
DiagnosticSeverity.Error,
true);
/// <summary>
/// readonly 字段不受支持。
/// </summary>
public static readonly DiagnosticDescriptor ReadOnlyFieldNotSupported =
new(
"GF_Godot_GetNode_003",
"Readonly fields are not supported",
"Field '{0}' cannot be readonly when using [GetNode]",
PathContests.GodotNamespace,
DiagnosticSeverity.Error,
true);
/// <summary>
/// 字段类型必须继承自 Godot.Node。
/// </summary>
public static readonly DiagnosticDescriptor FieldTypeMustDeriveFromNode =
new(
"GF_Godot_GetNode_004",
"Field type must derive from Godot.Node",
"Field '{0}' must be a Godot.Node type to use [GetNode]",
PathContests.GodotNamespace,
DiagnosticSeverity.Error,
true);
/// <summary>
/// 无法从字段名推导路径。
/// </summary>
public static readonly DiagnosticDescriptor CannotInferNodePath =
new(
"GF_Godot_GetNode_005",
"Cannot infer node path",
"Field '{0}' does not provide a path and its name cannot be converted to a node path",
PathContests.GodotNamespace,
DiagnosticSeverity.Error,
true);
/// <summary>
/// 现有 _Ready 中未调用生成注入逻辑。
/// </summary>
public static readonly DiagnosticDescriptor ManualReadyHookRequired =
new(
"GF_Godot_GetNode_006",
"Call generated injection from _Ready",
"Class '{0}' defines _Ready(); call __InjectGetNodes_Generated() there or remove _Ready() to use the generated hook",
PathContests.GodotNamespace,
DiagnosticSeverity.Warning,
true);
}

View File

@ -60,11 +60,6 @@
<None Include="GeWuYou.$(AssemblyName).targets" Pack="true" PackagePath="build" Visible="false"/> <None Include="GeWuYou.$(AssemblyName).targets" Pack="true" PackagePath="build" Visible="false"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Folder Include="diagnostics\"/> <Folder Include="Diagnostics\"/>
<Folder Include="logging\"/>
</ItemGroup>
<ItemGroup>
<AdditionalFiles Remove="AnalyzerReleases.Shipped.md"/>
<AdditionalFiles Remove="AnalyzerReleases.Unshipped.md"/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,568 @@
using System.Collections.Immutable;
using System.Text;
using GFramework.Godot.SourceGenerators.Diagnostics;
using GFramework.SourceGenerators.Common.Constants;
using GFramework.SourceGenerators.Common.Diagnostics;
using GFramework.SourceGenerators.Common.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace GFramework.Godot.SourceGenerators;
/// <summary>
/// 为带有 <c>[GetNode]</c> 的字段生成 Godot 节点获取逻辑。
/// </summary>
[Generator]
public sealed class GetNodeGenerator : IIncrementalGenerator
{
private const string GodotAbsolutePathPrefix = "/";
private const string GodotUniqueNamePrefix = "%";
private const string GetNodeAttributeMetadataName =
$"{PathContests.GodotSourceGeneratorsAbstractionsPath}.GetNodeAttribute";
private const string GetNodeLookupModeMetadataName =
$"{PathContests.GodotSourceGeneratorsAbstractionsPath}.NodeLookupMode";
private const string InjectionMethodName = "__InjectGetNodes_Generated";
private const string ReadyHookMethodName = "OnGetNodeReadyGenerated";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var candidates = context.SyntaxProvider.CreateSyntaxProvider(
static (node, _) => IsCandidate(node),
static (ctx, _) => Transform(ctx))
.Where(static candidate => candidate is not null);
var compilationAndCandidates = context.CompilationProvider.Combine(candidates.Collect());
context.RegisterSourceOutput(compilationAndCandidates,
static (spc, pair) => Execute(spc, pair.Left, pair.Right));
}
private static bool IsCandidate(SyntaxNode node)
{
if (node is not VariableDeclaratorSyntax
{
Parent: VariableDeclarationSyntax
{
Parent: FieldDeclarationSyntax fieldDeclaration
}
})
return false;
return fieldDeclaration.AttributeLists
.SelectMany(static list => list.Attributes)
.Any(static attribute => attribute.Name.ToString().Contains("GetNode", StringComparison.Ordinal));
}
private static FieldCandidate? Transform(GeneratorSyntaxContext context)
{
if (context.Node is not VariableDeclaratorSyntax variable)
return null;
if (ModelExtensions.GetDeclaredSymbol(context.SemanticModel, variable) is not IFieldSymbol fieldSymbol)
return null;
return new FieldCandidate(variable, fieldSymbol);
}
private static void Execute(
SourceProductionContext context,
Compilation compilation,
ImmutableArray<FieldCandidate?> candidates)
{
if (candidates.IsDefaultOrEmpty)
return;
var getNodeAttribute = compilation.GetTypeByMetadataName(GetNodeAttributeMetadataName);
var godotNodeSymbol = compilation.GetTypeByMetadataName("Godot.Node");
if (getNodeAttribute is null || godotNodeSymbol is null)
return;
var fieldCandidates = candidates
.Where(static candidate => candidate is not null)
.Select(static candidate => candidate!)
.Where(candidate => ResolveAttribute(candidate.FieldSymbol, getNodeAttribute) is not null)
.ToList();
foreach (var group in GroupByContainingType(fieldCandidates))
{
var typeSymbol = group.TypeSymbol;
if (!CanGenerateForType(context, group, typeSymbol))
continue;
var bindings = new List<NodeBindingInfo>();
foreach (var candidate in group.Fields)
{
var attribute = ResolveAttribute(candidate.FieldSymbol, getNodeAttribute);
if (attribute is null)
continue;
if (!TryCreateBinding(context, candidate, attribute, godotNodeSymbol, out var binding))
continue;
bindings.Add(binding);
}
if (bindings.Count == 0)
continue;
ReportMissingReadyHookCall(context, group, typeSymbol);
var source = GenerateSource(typeSymbol, bindings, FindReadyMethod(typeSymbol) is null);
context.AddSource(GetHintName(typeSymbol), source);
}
}
private static bool CanGenerateForType(
SourceProductionContext context,
TypeGroup group,
INamedTypeSymbol typeSymbol)
{
if (typeSymbol.ContainingType is not null)
{
context.ReportDiagnostic(Diagnostic.Create(
GetNodeDiagnostics.NestedClassNotSupported,
group.Fields[0].Variable.Identifier.GetLocation(),
typeSymbol.Name));
return false;
}
if (IsPartial(typeSymbol))
return true;
context.ReportDiagnostic(Diagnostic.Create(
CommonDiagnostics.ClassMustBePartial,
group.Fields[0].Variable.Identifier.GetLocation(),
typeSymbol.Name));
return false;
}
private static bool TryCreateBinding(
SourceProductionContext context,
FieldCandidate candidate,
AttributeData attribute,
INamedTypeSymbol godotNodeSymbol,
out NodeBindingInfo binding)
{
binding = default!;
if (candidate.FieldSymbol.IsStatic)
{
ReportFieldDiagnostic(context,
GetNodeDiagnostics.StaticFieldNotSupported,
candidate);
return false;
}
if (candidate.FieldSymbol.IsReadOnly)
{
ReportFieldDiagnostic(context,
GetNodeDiagnostics.ReadOnlyFieldNotSupported,
candidate);
return false;
}
if (!IsGodotNodeType(candidate.FieldSymbol.Type, godotNodeSymbol))
{
ReportFieldDiagnostic(context,
GetNodeDiagnostics.FieldTypeMustDeriveFromNode,
candidate);
return false;
}
if (!TryResolvePath(candidate.FieldSymbol, attribute, out var path))
{
ReportFieldDiagnostic(context,
GetNodeDiagnostics.CannotInferNodePath,
candidate);
return false;
}
binding = new NodeBindingInfo(
candidate.FieldSymbol,
path,
ResolveRequired(attribute));
return true;
}
private static void ReportFieldDiagnostic(
SourceProductionContext context,
DiagnosticDescriptor descriptor,
FieldCandidate candidate)
{
context.ReportDiagnostic(Diagnostic.Create(
descriptor,
candidate.Variable.Identifier.GetLocation(),
candidate.FieldSymbol.Name));
}
private static void ReportMissingReadyHookCall(
SourceProductionContext context,
TypeGroup group,
INamedTypeSymbol typeSymbol)
{
var readyMethod = FindReadyMethod(typeSymbol);
if (readyMethod is null || CallsGeneratedInjection(readyMethod))
return;
context.ReportDiagnostic(Diagnostic.Create(
GetNodeDiagnostics.ManualReadyHookRequired,
readyMethod.Locations.FirstOrDefault() ?? group.Fields[0].Variable.Identifier.GetLocation(),
typeSymbol.Name));
}
private static AttributeData? ResolveAttribute(
IFieldSymbol fieldSymbol,
INamedTypeSymbol getNodeAttribute)
{
return fieldSymbol.GetAttributes()
.FirstOrDefault(attribute =>
SymbolEqualityComparer.Default.Equals(attribute.AttributeClass, getNodeAttribute));
}
private static bool IsPartial(INamedTypeSymbol typeSymbol)
{
return typeSymbol.DeclaringSyntaxReferences
.Select(static reference => reference.GetSyntax())
.OfType<ClassDeclarationSyntax>()
.All(static declaration =>
declaration.Modifiers.Any(static modifier => modifier.IsKind(SyntaxKind.PartialKeyword)));
}
private static bool IsGodotNodeType(ITypeSymbol typeSymbol, INamedTypeSymbol godotNodeSymbol)
{
var current = typeSymbol as INamedTypeSymbol;
while (current is not null)
{
if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, godotNodeSymbol) ||
SymbolEqualityComparer.Default.Equals(current, godotNodeSymbol))
return true;
current = current.BaseType;
}
return false;
}
private static IMethodSymbol? FindReadyMethod(INamedTypeSymbol typeSymbol)
{
return typeSymbol.GetMembers()
.OfType<IMethodSymbol>()
.FirstOrDefault(static method =>
method.Name == "_Ready" &&
!method.IsStatic &&
method.Parameters.Length == 0 &&
method.MethodKind == MethodKind.Ordinary);
}
private static bool CallsGeneratedInjection(IMethodSymbol readyMethod)
{
foreach (var syntaxReference in readyMethod.DeclaringSyntaxReferences)
{
if (syntaxReference.GetSyntax() is not MethodDeclarationSyntax methodSyntax)
continue;
if (methodSyntax.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Any(IsGeneratedInjectionInvocation))
return true;
}
return false;
}
private static bool IsGeneratedInjectionInvocation(InvocationExpressionSyntax invocation)
{
switch (invocation.Expression)
{
case IdentifierNameSyntax identifierName:
return string.Equals(
identifierName.Identifier.ValueText,
InjectionMethodName,
StringComparison.Ordinal);
case MemberAccessExpressionSyntax memberAccess:
return string.Equals(
memberAccess.Name.Identifier.ValueText,
InjectionMethodName,
StringComparison.Ordinal);
default:
return false;
}
}
private static bool ResolveRequired(AttributeData attribute)
{
return attribute.GetNamedArgument("Required", true);
}
private static bool TryResolvePath(
IFieldSymbol fieldSymbol,
AttributeData attribute,
out string path)
{
var explicitPath = ResolveExplicitPath(attribute);
if (!string.IsNullOrWhiteSpace(explicitPath))
return ReturnResolvedPath(explicitPath!, out path);
var inferredName = InferNodeName(fieldSymbol.Name);
if (string.IsNullOrWhiteSpace(inferredName))
{
path = string.Empty;
return false;
}
var resolvedName = inferredName!;
return TryResolveInferredPath(attribute, resolvedName, out path);
}
private static bool ReturnResolvedPath(string resolvedPath, out string path)
{
path = resolvedPath;
return true;
}
private static bool TryResolveInferredPath(
AttributeData attribute,
string inferredName,
out string path)
{
path = BuildPathPrefix(ResolveLookup(attribute)) + inferredName;
return true;
}
private static string BuildPathPrefix(NodeLookupModeValue lookupMode)
{
switch (lookupMode)
{
case NodeLookupModeValue.RelativePath:
return string.Empty;
case NodeLookupModeValue.AbsolutePath:
return GodotAbsolutePathPrefix;
default:
return GodotUniqueNamePrefix;
}
}
private static string? ResolveExplicitPath(AttributeData attribute)
{
var namedPath = attribute.GetNamedArgument<string>("Path");
if (!string.IsNullOrWhiteSpace(namedPath))
return namedPath;
if (attribute.ConstructorArguments.Length == 0)
return null;
return attribute.ConstructorArguments[0].Value as string;
}
private static NodeLookupModeValue ResolveLookup(AttributeData attribute)
{
foreach (var namedArgument in attribute.NamedArguments)
{
if (!string.Equals(namedArgument.Key, "Lookup", StringComparison.Ordinal))
continue;
if (namedArgument.Value.Type?.ToDisplayString() != GetNodeLookupModeMetadataName)
continue;
if (namedArgument.Value.Value is int value)
return (NodeLookupModeValue)value;
}
return NodeLookupModeValue.Auto;
}
private static string? InferNodeName(string fieldName)
{
var workingName = fieldName.TrimStart('_');
if (workingName.StartsWith("m_", StringComparison.OrdinalIgnoreCase))
workingName = workingName.Substring(2);
workingName = workingName.TrimStart('_');
if (string.IsNullOrWhiteSpace(workingName))
return null;
if (workingName.IndexOfAny(['_', '-', ' ']) >= 0)
{
var parts = workingName
.Split(['_', '-', ' '], StringSplitOptions.RemoveEmptyEntries);
return parts.Length == 0
? null
: string.Concat(parts.Select(ToPascalToken));
}
return ToPascalToken(workingName);
}
private static string ToPascalToken(string token)
{
if (string.IsNullOrEmpty(token))
return token;
if (token.Length == 1)
return token.ToUpperInvariant();
return char.ToUpperInvariant(token[0]) + token.Substring(1);
}
private static string GenerateSource(
INamedTypeSymbol typeSymbol,
IReadOnlyList<NodeBindingInfo> bindings,
bool generateReadyOverride)
{
var namespaceName = typeSymbol.GetNamespace();
var generics = typeSymbol.ResolveGenerics();
var sb = new StringBuilder()
.AppendLine("// <auto-generated />")
.AppendLine("#nullable enable");
if (namespaceName is not null)
{
sb.AppendLine()
.AppendLine($"namespace {namespaceName};");
}
sb.AppendLine()
.AppendLine($"partial class {typeSymbol.Name}{generics.Parameters}");
foreach (var constraint in generics.Constraints)
sb.AppendLine($" {constraint}");
sb.AppendLine("{")
.AppendLine($" private void {InjectionMethodName}()")
.AppendLine(" {");
foreach (var binding in bindings)
{
var typeName = binding.FieldSymbol.Type
.WithNullableAnnotation(NullableAnnotation.None)
.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
var accessor = binding.Required ? "GetNode" : "GetNodeOrNull";
var pathLiteral = EscapeStringLiteral(binding.Path);
sb.AppendLine(
$" {binding.FieldSymbol.Name} = {accessor}<{typeName}>(\"{pathLiteral}\");");
}
sb.AppendLine(" }");
if (generateReadyOverride)
{
sb.AppendLine()
.AppendLine($" partial void {ReadyHookMethodName}();")
.AppendLine()
.AppendLine(" public override void _Ready()")
.AppendLine(" {")
.AppendLine($" {InjectionMethodName}();")
.AppendLine($" {ReadyHookMethodName}();")
.AppendLine(" }");
}
sb.AppendLine("}");
return sb.ToString();
}
private static string GetHintName(INamedTypeSymbol typeSymbol)
{
return typeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat)
.Replace("global::", string.Empty)
.Replace("<", "_")
.Replace(">", "_")
.Replace(",", "_")
.Replace(" ", string.Empty)
.Replace(".", "_") + ".GetNode.g.cs";
}
private static string EscapeStringLiteral(string value)
{
return value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"");
}
private static IReadOnlyList<TypeGroup> GroupByContainingType(IEnumerable<FieldCandidate> candidates)
{
var groupMap = new Dictionary<INamedTypeSymbol, TypeGroup>(SymbolEqualityComparer.Default);
var orderedGroups = new List<TypeGroup>();
foreach (var candidate in candidates)
{
var typeSymbol = candidate.FieldSymbol.ContainingType;
if (!groupMap.TryGetValue(typeSymbol, out var group))
{
group = new TypeGroup(typeSymbol);
groupMap.Add(typeSymbol, group);
orderedGroups.Add(group);
}
group.Fields.Add(candidate);
}
return orderedGroups;
}
private sealed class FieldCandidate
{
public FieldCandidate(
VariableDeclaratorSyntax variable,
IFieldSymbol fieldSymbol)
{
Variable = variable;
FieldSymbol = fieldSymbol;
}
public VariableDeclaratorSyntax Variable { get; }
public IFieldSymbol FieldSymbol { get; }
}
private sealed class NodeBindingInfo
{
public NodeBindingInfo(
IFieldSymbol fieldSymbol,
string path,
bool required)
{
FieldSymbol = fieldSymbol;
Path = path;
Required = required;
}
public IFieldSymbol FieldSymbol { get; }
public string Path { get; }
public bool Required { get; }
}
private enum NodeLookupModeValue
{
Auto = 0,
UniqueName = 1,
RelativePath = 2,
AbsolutePath = 3
}
private sealed class TypeGroup
{
public TypeGroup(INamedTypeSymbol typeSymbol)
{
TypeSymbol = typeSymbol;
}
public INamedTypeSymbol TypeSymbol { get; }
public List<FieldCandidate> Fields { get; } = new();
}
}

View File

@ -6,8 +6,40 @@
- 与 Godot 场景相关的编译期生成能力 - 与 Godot 场景相关的编译期生成能力
- 基于 Roslyn 的增量生成器实现 - 基于 Roslyn 的增量生成器实现
- `[GetNode]` 字段注入,减少 `_Ready()` 里的 `GetNode<T>()` 样板代码
## 使用建议 ## 使用建议
- 仅在 Godot + C# 项目中启用 - 仅在 Godot + C# 项目中启用
- 非 Godot 项目可只使用 GFramework.SourceGenerators - 非 Godot 项目可只使用 GFramework.SourceGenerators
## GetNode 用法
```csharp
using GFramework.Godot.SourceGenerators.Abstractions;
using Godot;
public partial class TopBar : HBoxContainer
{
[GetNode]
private HBoxContainer _leftContainer = null!;
[GetNode]
private HBoxContainer _rightContainer = null!;
public override void _Ready()
{
__InjectGetNodes_Generated();
OnReadyAfterGetNode();
}
private void OnReadyAfterGetNode()
{
}
}
```
当未显式填写路径时,生成器会默认将字段名推导为唯一名路径:
- `_leftContainer` -> `%LeftContainer`
- `m_rightContainer` -> `%RightContainer`

View File

@ -46,6 +46,7 @@
<None Remove="GFramework.SourceGenerators\**"/> <None Remove="GFramework.SourceGenerators\**"/>
<None Remove="GFramework.SourceGenerators.Common\**"/> <None Remove="GFramework.SourceGenerators.Common\**"/>
<None Remove="GFramework.SourceGenerators.Tests\**"/> <None Remove="GFramework.SourceGenerators.Tests\**"/>
<None Remove="GFramework.Godot.SourceGenerators.Tests\**"/>
<None Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/> <None Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/>
<None Remove="GFramework.SourceGenerators.Abstractions\**"/> <None Remove="GFramework.SourceGenerators.Abstractions\**"/>
<None Remove="GFramework.Core.Abstractions\**"/> <None Remove="GFramework.Core.Abstractions\**"/>
@ -85,6 +86,7 @@
<Compile Remove="GFramework.SourceGenerators\**"/> <Compile Remove="GFramework.SourceGenerators\**"/>
<Compile Remove="GFramework.SourceGenerators.Common\**"/> <Compile Remove="GFramework.SourceGenerators.Common\**"/>
<Compile Remove="GFramework.SourceGenerators.Tests\**"/> <Compile Remove="GFramework.SourceGenerators.Tests\**"/>
<Compile Remove="GFramework.Godot.SourceGenerators.Tests\**"/>
<Compile Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/> <Compile Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/>
<Compile Remove="GFramework.SourceGenerators.Abstractions\**"/> <Compile Remove="GFramework.SourceGenerators.Abstractions\**"/>
<Compile Remove="GFramework.Core.Abstractions\**"/> <Compile Remove="GFramework.Core.Abstractions\**"/>
@ -110,6 +112,7 @@
<EmbeddedResource Remove="GFramework.SourceGenerators\**"/> <EmbeddedResource Remove="GFramework.SourceGenerators\**"/>
<EmbeddedResource Remove="GFramework.SourceGenerators.Common\**"/> <EmbeddedResource Remove="GFramework.SourceGenerators.Common\**"/>
<EmbeddedResource Remove="GFramework.SourceGenerators.Tests\**"/> <EmbeddedResource Remove="GFramework.SourceGenerators.Tests\**"/>
<EmbeddedResource Remove="GFramework.Godot.SourceGenerators.Tests\**"/>
<EmbeddedResource Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/> <EmbeddedResource Remove="GFramework.Godot.SourceGenerators.Abstractions\**"/>
<EmbeddedResource Remove="GFramework.SourceGenerators.Abstractions\**"/> <EmbeddedResource Remove="GFramework.SourceGenerators.Abstractions\**"/>
<EmbeddedResource Remove="GFramework.Core.Abstractions\**"/> <EmbeddedResource Remove="GFramework.Core.Abstractions\**"/>

View File

@ -34,6 +34,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Ecs.Arch.Tests",
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Game.Tests", "GFramework.Game.Tests\GFramework.Game.Tests.csproj", "{738DC58A-0387-4D75-AA96-1C1D8C29D350}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Game.Tests", "GFramework.Game.Tests\GFramework.Game.Tests.csproj", "{738DC58A-0387-4D75-AA96-1C1D8C29D350}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GFramework.Godot.SourceGenerators.Tests", "GFramework.Godot.SourceGenerators.Tests\GFramework.Godot.SourceGenerators.Tests.csproj", "{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -248,6 +250,18 @@ Global
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x64.Build.0 = Release|Any CPU {738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x64.Build.0 = Release|Any CPU
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.ActiveCfg = Release|Any CPU {738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.ActiveCfg = Release|Any CPU
{738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.Build.0 = Release|Any CPU {738DC58A-0387-4D75-AA96-1C1D8C29D350}.Release|x86.Build.0 = Release|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x64.ActiveCfg = Debug|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x64.Build.0 = Debug|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x86.ActiveCfg = Debug|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Debug|x86.Build.0 = Debug|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|Any CPU.Build.0 = Release|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.ActiveCfg = Release|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x64.Build.0 = Release|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.ActiveCfg = Release|Any CPU
{E315489C-248A-4ABB-BD92-96F9F3AFE2C1}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@ -2,6 +2,7 @@
// meta-description: 负责管理场景的生命周期和架构关联 // meta-description: 负责管理场景的生命周期和架构关联
using Godot; using Godot;
using GFramework.Core.Abstractions.Controller; using GFramework.Core.Abstractions.Controller;
using GFramework.Godot.SourceGenerators.Abstractions;
using GFramework.SourceGenerators.Abstractions.Logging; using GFramework.SourceGenerators.Abstractions.Logging;
using GFramework.SourceGenerators.Abstractions.Rule; using GFramework.SourceGenerators.Abstractions.Rule;
@ -16,7 +17,15 @@ public partial class _CLASS_ :_BASE_,IController
/// </summary> /// </summary>
public override void _Ready() public override void _Ready()
{ {
__InjectGetNodes_Generated();
OnReadyAfterGetNode();
}
/// <summary>
/// 节点注入完成后的初始化钩子。
/// </summary>
private void OnReadyAfterGetNode()
{
} }
} }

View File

@ -5,6 +5,7 @@ using GFramework.Core.Abstractions.Controller;
using GFramework.Core.Extensions; using GFramework.Core.Extensions;
using GFramework.Game.Abstractions.UI; using GFramework.Game.Abstractions.UI;
using GFramework.Godot.UI; using GFramework.Godot.UI;
using GFramework.Godot.SourceGenerators.Abstractions;
using GFramework.SourceGenerators.Abstractions.Logging; using GFramework.SourceGenerators.Abstractions.Logging;
using GFramework.SourceGenerators.Abstractions.Rule; using GFramework.SourceGenerators.Abstractions.Rule;
@ -19,7 +20,15 @@ public partial class _CLASS_ :_BASE_,IController,IUiPageBehaviorProvider,IUiPage
/// </summary> /// </summary>
public override void _Ready() public override void _Ready()
{ {
__InjectGetNodes_Generated();
OnReadyAfterGetNode();
}
/// <summary>
/// 节点注入完成后的初始化钩子。
/// </summary>
private void OnReadyAfterGetNode()
{
} }
/// <summary> /// <summary>
/// 页面行为实例的私有字段 /// 页面行为实例的私有字段

View File

@ -122,6 +122,30 @@ 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 - 命令总线
### 功能说明 ### 功能说明

View File

@ -404,24 +404,25 @@ public class PlayerController : IController
## 包说明 ## 包说明
| 包名 | 职责 | 文档 | | 包名 | 职责 | 文档 |
|------------------|-----------------|----------------------| |----------------------|-----------------|--------------------------|
| **architecture** | 架构核心,管理所有组件生命周期 | [查看](./architecture) | | **architecture** | 架构核心,管理所有组件生命周期 | [查看](./architecture) |
| **constants** | 框架常量定义 | 本文档 | | **constants** | 框架常量定义 | 本文档 |
| **model** | 数据模型层,存储状态 | [查看](./model) | | **model** | 数据模型层,存储状态 | [查看](./model) |
| **system** | 业务逻辑层,处理业务规则 | [查看](./system) | | **system** | 业务逻辑层,处理业务规则 | [查看](./system) |
| **controller** | 控制器层,连接视图和逻辑 | (在 Abstractions 中) | | **controller** | 控制器层,连接视图和逻辑 | (在 Abstractions 中) |
| **utility** | 工具类层,提供无状态工具 | [查看](./utility) | | **utility** | 工具类层,提供无状态工具 | [查看](./utility) |
| **command** | 命令模式,封装写操作 | [查看](./command) | | **command** | 命令模式,封装写操作 | [查看](./command) |
| **query** | 查询模式,封装读操作 | [查看](./query) | | **query** | 查询模式,封装读操作 | [查看](./query) |
| **events** | 事件系统,组件间通信 | [查看](./events) | | **events** | 事件系统,组件间通信 | [查看](./events) |
| **property** | 可绑定属性,响应式编程 | [查看](./property) | | **property** | 可绑定属性,响应式编程 | [查看](./property) |
| **ioc** | IoC 容器,依赖注入 | [查看](./ioc) | | **state-management** | 集中式状态容器与选择器 | [查看](./state-management) |
| **rule** | 规则接口,定义组件约束 | [查看](./rule) | | **ioc** | IoC 容器,依赖注入 | [查看](./ioc) |
| **extensions** | 扩展方法,简化 API 调用 | [查看](./extensions) | | **rule** | 规则接口,定义组件约束 | [查看](./rule) |
| **logging** | 日志系统,记录运行日志 | [查看](./logging) | | **extensions** | 扩展方法,简化 API 调用 | [查看](./extensions) |
| **environment** | 环境接口,提供运行环境信息 | [查看](./environment) | | **logging** | 日志系统,记录运行日志 | [查看](./logging) |
| **localization** | 本地化系统,多语言支持 | [查看](./localization) | | **environment** | 环境接口,提供运行环境信息 | [查看](./environment) |
| **localization** | 本地化系统,多语言支持 | [查看](./localization) |
## 组件联动 ## 组件联动

View File

@ -6,6 +6,10 @@ Property 包提供了可绑定属性BindableProperty的实现支持属
BindableProperty 是 GFramework 中 Model 层数据管理的基础,通过事件机制实现属性变化的通知。 BindableProperty 是 GFramework 中 Model 层数据管理的基础,通过事件机制实现属性变化的通知。
> 对于简单字段和局部 UI 绑定,`BindableProperty<T>` 仍然是首选方案。
> 如果你需要统一管理复杂状态树、通过 action / reducer 演进状态,或复用局部状态选择器,
> 请同时参考 [`state-management`](./state-management)。
## 核心接口 ## 核心接口
### IReadonlyBindableProperty`<T>` ### IReadonlyBindableProperty`<T>`
@ -138,6 +142,43 @@ BindableProperty 基于事件系统实现属性变化通知:
## 在 Model 中使用 ## 在 Model 中使用
### 什么时候继续使用 BindableProperty
以下场景仍然优先推荐 `BindableProperty<T>`
- 单个字段变化就能驱动视图更新
- 状态范围局限在单个 Model 内
- 不需要统一的 action / reducer 写入入口
- 不需要从聚合状态树中复用局部选择逻辑
如果你的状态已经演化为“多个字段必须一起更新”或“多个模块共享同一聚合状态”,
可以在 Model 内部组合 `Store<TState>`,而不是把所有字段都继续拆成独立属性。
### 与 Store / StateMachine 的边界
- `BindableProperty<T>`:字段级响应式值
- `Store<TState>`:聚合状态容器,负责统一归约状态变化
- `StateMachine`:流程状态切换,不负责数据状态归约
一个复杂 Model 可以同时持有 Store 和 BindableProperty
```csharp
public class PlayerStateModel : AbstractModel
{
public Store<PlayerState> Store { get; } = new(new PlayerState(100, "Player"));
public BindableProperty<bool> IsDirty { get; } = new(false);
protected override void OnInit()
{
Store.RegisterReducer<DamageAction>((state, action) =>
state with { Health = Math.Max(0, state.Health - action.Amount) });
}
}
public sealed record PlayerState(int Health, string Name);
public sealed record DamageAction(int Amount);
```
### 定义可绑定属性 ### 定义可绑定属性
```csharp ```csharp
@ -422,11 +463,13 @@ _mOnValueChanged?.Invoke(value);
4. **使用 RegisterWithInitValue** - UI 绑定时立即获取初始值 4. **使用 RegisterWithInitValue** - UI 绑定时立即获取初始值
5. **避免循环依赖** - 属性监听器中修改其他属性要小心 5. **避免循环依赖** - 属性监听器中修改其他属性要小心
6. **使用自定义比较器** - 对于浮点数等需要精度控制的属性 6. **使用自定义比较器** - 对于浮点数等需要精度控制的属性
7. **复杂聚合状态使用 Store** - 当多个字段必须统一演进时,使用 Store 管理聚合状态更清晰
## 相关包 ## 相关包
- [`model`](./model.md) - Model 中大量使用 BindableProperty - [`model`](./model.md) - Model 中大量使用 BindableProperty
- [`events`](./events.md) - BindableProperty 基于事件系统实现 - [`events`](./events.md) - BindableProperty 基于事件系统实现
- [`state-management`](./state-management) - 复杂状态树的集中式管理方案
- [`extensions`](./extensions.md) - 提供便捷的注销扩展方法 - [`extensions`](./extensions.md) - 提供便捷的注销扩展方法
--- ---

View File

@ -0,0 +1,390 @@
# State Management 包使用说明
## 概述
State Management 提供一个可选的集中式状态容器方案,用于补足 `BindableProperty<T>` 在复杂状态树场景下的能力。
当你的状态具有以下特征时,推荐使用 `Store<TState>`
- 多个字段需要在一次业务操作中协同更新
- 多个模块或 UI 片段共享同一聚合状态
- 希望所有状态写入都经过统一的 action / reducer 入口
- 需要对整棵状态树做局部选择和按片段订阅
这套能力不会替代现有 Property 机制,而是与其并存:
- `BindableProperty<T>`:字段级响应式值
- `Store<TState>`:聚合状态容器
- `StateMachine`:流程状态切换
## 核心接口
### IReadonlyStore`<TState>`
只读状态容器接口,提供:
- `State`:读取当前状态快照
- `Subscribe()`:订阅状态变化
- `SubscribeWithInitValue()`:订阅并立即回放当前状态
- `UnSubscribe()`:取消订阅
### IStore`<TState>`
在只读能力上增加:
- `Dispatch<TAction>()`:统一分发 action
### IReducer`<TState, TAction>`
定义状态归约逻辑:
```csharp
public interface IReducer<TState, in TAction>
{
TState Reduce(TState currentState, TAction action);
}
```
### IStateSelector`<TState, TSelected>`
从整棵状态树中投影局部视图,便于 UI 和 Controller 复用选择逻辑。
## Store`<TState>`
`Store<TState>` 是默认实现,支持:
- 初始状态快照
- reducer 注册
- middleware 分发管线
- 只在状态真正变化时通知订阅者
- 基础诊断信息(最近一次 action、最近一次分发记录、最近一次状态变化时间
## 基本示例
```csharp
using GFramework.Core.StateManagement;
public sealed record PlayerState(int Health, string Name);
public sealed record DamageAction(int Amount);
public sealed record RenameAction(string Name);
var store = new Store<PlayerState>(new PlayerState(100, "Player"))
.RegisterReducer<DamageAction>((state, action) =>
state with { Health = Math.Max(0, state.Health - action.Amount) })
.RegisterReducer<RenameAction>((state, action) =>
state with { Name = action.Name });
store.SubscribeWithInitValue(state =>
{
Console.WriteLine($"{state.Name}: {state.Health}");
});
store.Dispatch(new DamageAction(25));
store.Dispatch(new RenameAction("Knight"));
```
## 选择器和 Bindable 风格桥接
Store 可以通过扩展方法把聚合状态投影成局部只读绑定视图:
```csharp
using GFramework.Core.Extensions;
var healthSelection = store.Select(state => state.Health);
healthSelection.RegisterWithInitValue(health =>
{
Console.WriteLine($"Current HP: {health}");
});
```
如果现有 UI 代码已经依赖 `IReadonlyBindableProperty<T>`,可以直接桥接:
```csharp
IReadonlyBindableProperty<int> healthProperty =
store.ToBindableProperty(state => state.Health);
```
## 在 Model 中使用
推荐把 Store 作为 Model 的内部状态容器,由 Model 暴露领域友好的业务方法:
```csharp
public class PlayerStateModel : AbstractModel
{
public Store<PlayerState> Store { get; } = new(new PlayerState(100, "Player"));
protected override void OnInit()
{
Store.RegisterReducer<DamageAction>((state, action) =>
state with { Health = Math.Max(0, state.Health - action.Amount) });
}
public void TakeDamage(int amount)
{
Store.Dispatch(new DamageAction(amount));
}
}
```
这样可以保留 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 的装配细节散落在多个调用点
## 运行时临时注册与注销
如果某个 reducer 或 middleware 只需要在一段生命周期内生效,例如调试探针、临时玩法规则、
或场景级模块扩展,可以直接使用 `Store<TState>` 提供的句柄式注册 API
```csharp
public sealed class LoggingMiddleware<TState> : IStoreMiddleware<TState>
{
public void Invoke(StoreDispatchContext<TState> context, Action next)
{
Console.WriteLine($"Dispatching: {context.ActionType.Name}");
next();
}
}
var store = new Store<PlayerState>(new PlayerState(100, "Player"));
var reducerHandle = store.RegisterReducerHandle<DamageAction>((state, action) =>
state with { Health = Math.Max(0, state.Health - action.Amount) });
var middlewareHandle = store.RegisterMiddleware(new LoggingMiddleware<PlayerState>());
store.Dispatch(new DamageAction(10));
reducerHandle.UnRegister();
middlewareHandle.UnRegister();
```
这里有两个重要约束:
- `RegisterReducerHandle()``RegisterMiddleware()` 返回的是当前这一次注册的精确注销句柄
- 如果在某次 `Dispatch()` 已经开始后再调用 `UnRegister()`,当前这次 dispatch 仍会继续使用开始时抓取的快照,注销只影响后续新的
dispatch
如果你只需要初始化阶段的链式配置,继续使用 `RegisterReducer()``UseMiddleware()` 即可;
如果你需要运行时清理,就使用上面的句柄式 API。
## 官方示例:角色面板状态
下面给出一个更贴近 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>`
- 单一字段直接绑定 UI
- 状态规模很小,不需要聚合归约
- 没有跨模块共享状态树的需求
- 你只需要“值变化通知”,不需要“统一状态演进入口”
## 最佳实践
1. 优先把 `TState` 设计为不可变状态(如 `record`
2. 让 reducer 保持纯函数风格,不在 reducer 内执行副作用
3. 使用 selector 暴露局部状态,而不是让 UI 自己解析整棵状态树
4. 需要日志或诊断时,优先通过 middleware 扩展,而不是把横切逻辑塞进 reducer
## 相关文档
- [`property`](./property) - 字段级响应式属性
- [`model`](./model) - Store 常见承载位置
- [`events`](./events) - 组件间事件通信
- [`state-machine-tutorial`](../tutorials/state-machine-tutorial) - 流程状态切换能力