mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-03-24 20:34:29 +08:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9ac8a927c | ||
|
|
e5da5aa801 | ||
|
|
bd29475748 | ||
|
|
91246ff482 | ||
|
|
b912e6aa4d | ||
|
|
1f1aff5335 | ||
|
|
14849d6761 | ||
|
|
2c00070bb1 | ||
|
|
b6ef6278c0 | ||
|
|
3d212716d6 | ||
|
|
b7c54743fa | ||
|
|
79cebb95b5 | ||
|
|
79f1240e1d | ||
|
|
2b4b87baba | ||
|
|
c70728b64e | ||
|
|
cf486cbeff | ||
|
|
8d656b90a7 | ||
|
|
fc386fb4bc | ||
|
|
bbf1dc8d0c | ||
|
|
b95c65a30e | ||
|
|
9ab09cf47b | ||
|
|
63b1d71a0e | ||
|
|
cdc49c319a | ||
|
|
d94d8deb29 | ||
|
|
49609d3821 | ||
|
|
003fe42ad8 | ||
|
|
a42ec0c282 | ||
|
|
884249649d | ||
|
|
d582dffe40 | ||
|
|
63a6c2e6f0 | ||
|
|
f3d45169cd | ||
|
|
86645d34cb | ||
|
|
ab04f0ace7 | ||
|
|
51492b1dcd |
43
GFramework.Core.Abstractions/Concurrency/LockInfo.cs
Normal file
43
GFramework.Core.Abstractions/Concurrency/LockInfo.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
namespace GFramework.Core.Abstractions.Concurrency;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 锁信息(用于调试)
|
||||||
|
/// </summary>
|
||||||
|
public readonly struct LockInfo
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 锁的键。
|
||||||
|
/// </summary>
|
||||||
|
public string Key { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 当前引用计数。
|
||||||
|
/// </summary>
|
||||||
|
public int ReferenceCount { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 最后访问时间戳(Environment.TickCount64)。
|
||||||
|
/// </summary>
|
||||||
|
public long LastAccessTicks { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 等待队列长度(近似值)。
|
||||||
|
/// 注意:这是一个基于 SemaphoreSlim.CurrentCount 的近似指示器,
|
||||||
|
/// 当 CurrentCount == 0 时表示锁被持有且可能有等待者,返回 1;
|
||||||
|
/// 否则返回 0。这不是精确的等待者数量,仅用于调试参考。
|
||||||
|
/// </summary>
|
||||||
|
public int WaitingCount { get; init; }
|
||||||
|
}
|
||||||
@ -11,11 +11,14 @@
|
|||||||
// See the License for the specific language governing permissions and
|
// See the License for the specific language governing permissions and
|
||||||
// limitations under the License.
|
// limitations under the License.
|
||||||
|
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
namespace GFramework.Core.Abstractions.Concurrency;
|
namespace GFramework.Core.Abstractions.Concurrency;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 锁统计信息
|
/// 锁统计信息
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[StructLayout(LayoutKind.Auto)]
|
||||||
public readonly struct LockStatistics
|
public readonly struct LockStatistics
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -38,32 +41,3 @@ public readonly struct LockStatistics
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int TotalCleaned { get; init; }
|
public int TotalCleaned { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 锁信息(用于调试)
|
|
||||||
/// </summary>
|
|
||||||
public readonly struct LockInfo
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 锁的键
|
|
||||||
/// </summary>
|
|
||||||
public string Key { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 当前引用计数
|
|
||||||
/// </summary>
|
|
||||||
public int ReferenceCount { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 最后访问时间戳(Environment.TickCount64)
|
|
||||||
/// </summary>
|
|
||||||
public long LastAccessTicks { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 等待队列长度(近似值)
|
|
||||||
/// 注意:这是一个基于 SemaphoreSlim.CurrentCount 的近似指示器,
|
|
||||||
/// 当 CurrentCount == 0 时表示锁被持有且可能有等待者,返回 1;
|
|
||||||
/// 否则返回 0。这不是精确的等待者数量,仅用于调试参考。
|
|
||||||
/// </summary>
|
|
||||||
public int WaitingCount { get; init; }
|
|
||||||
}
|
|
||||||
@ -75,7 +75,9 @@ public interface IPauseStackManager : IContextUtility
|
|||||||
void UnregisterHandler(IPauseHandler handler);
|
void UnregisterHandler(IPauseHandler handler);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 暂停状态变化事件
|
/// 暂停状态变化事件。
|
||||||
|
/// 事件遵循标准 .NET 事件模式,事件源为触发通知的暂停管理器实例,
|
||||||
|
/// 事件数据由 <see cref="PauseStateChangedEventArgs"/> 提供。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
event Action<PauseGroup, bool>? OnPauseStateChanged;
|
event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
|
||||||
}
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
namespace GFramework.Core.Abstractions.Pause;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示暂停状态变化事件的数据。
|
||||||
|
/// 该类型用于向事件订阅者传递暂停组以及该组变化后的暂停状态。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PauseStateChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 <see cref="PauseStateChangedEventArgs"/> 的新实例。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="group">发生状态变化的暂停组。</param>
|
||||||
|
/// <param name="isPaused">暂停组变化后的新状态。</param>
|
||||||
|
public PauseStateChangedEventArgs(PauseGroup group, bool isPaused)
|
||||||
|
{
|
||||||
|
Group = group;
|
||||||
|
IsPaused = isPaused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取发生状态变化的暂停组。
|
||||||
|
/// </summary>
|
||||||
|
public PauseGroup Group { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取暂停组变化后的新状态。
|
||||||
|
/// 为 <see langword="true"/> 表示进入暂停,为 <see langword="false"/> 表示恢复运行。
|
||||||
|
/// </summary>
|
||||||
|
public bool IsPaused { get; }
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ GFramework 框架的抽象层定义模块,包含所有核心组件的接口定
|
|||||||
- 事件系统接口 (IEvent, IEventBus)
|
- 事件系统接口 (IEvent, IEventBus)
|
||||||
- 依赖注入容器接口 (IIocContainer)
|
- 依赖注入容器接口 (IIocContainer)
|
||||||
- 可绑定属性接口 (IBindableProperty)
|
- 可绑定属性接口 (IBindableProperty)
|
||||||
|
- 状态管理接口 (IStore, IReducer, IStateSelector, IStoreBuilder)
|
||||||
- 日志系统接口 (ILogger)
|
- 日志系统接口 (ILogger)
|
||||||
|
|
||||||
## 设计原则
|
## 设计原则
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
19
GFramework.Core.Abstractions/StateManagement/IReducer.cs
Normal file
19
GFramework.Core.Abstractions/StateManagement/IReducer.cs
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
63
GFramework.Core.Abstractions/StateManagement/IStore.cs
Normal file
63
GFramework.Core.Abstractions/StateManagement/IStore.cs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
namespace GFramework.Core.Abstractions.StateManagement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 可写状态容器接口,提供统一的状态分发入口。
|
||||||
|
/// 所有状态变更都应通过分发 action 触发,以保持单向数据流和可测试性。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||||
|
public interface IStore<out TState> : IReadonlyStore<TState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前是否可以撤销到更早的历史状态。
|
||||||
|
/// 当未启用历史缓冲区,或当前已经位于最早历史点时,返回 <see langword="false"/>。
|
||||||
|
/// </summary>
|
||||||
|
bool CanUndo { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前是否可以重做到更晚的历史状态。
|
||||||
|
/// 当未启用历史缓冲区,或当前已经位于最新历史点时,返回 <see langword="false"/>。
|
||||||
|
/// </summary>
|
||||||
|
bool CanRedo { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 分发一个 action 以触发状态演进。
|
||||||
|
/// Store 会按注册顺序执行与该 action 类型匹配的 reducer,并在状态变化后通知订阅者。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TAction">action 的具体类型。</typeparam>
|
||||||
|
/// <param name="action">要分发的 action 实例。</param>
|
||||||
|
void Dispatch<TAction>(TAction action);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将多个状态操作合并到一个批处理中执行。
|
||||||
|
/// 批处理内部的每次分发仍会立即更新 Store 状态和历史,但订阅通知会延迟到最外层批处理结束后再统一触发一次。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="batchAction">批处理主体;调用方应在其中执行若干次 <see cref="Dispatch{TAction}(TAction)"/>、<see cref="Undo"/> 或 <see cref="Redo"/>。</param>
|
||||||
|
void RunInBatch(Action batchAction);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将当前状态回退到上一个历史点。
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">当历史缓冲区未启用,或当前已经没有可撤销的历史点时抛出。</exception>
|
||||||
|
void Undo();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将当前状态前进到下一个历史点。
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="InvalidOperationException">当历史缓冲区未启用,或当前已经没有可重做的历史点时抛出。</exception>
|
||||||
|
void Redo();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 跳转到指定索引的历史点。
|
||||||
|
/// 该能力适合调试面板或开发工具实现时间旅行查看。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="historyIndex">目标历史索引,从 0 开始。</param>
|
||||||
|
/// <exception cref="InvalidOperationException">当历史缓冲区未启用时抛出。</exception>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">当 <paramref name="historyIndex"/> 超出当前历史范围时抛出。</exception>
|
||||||
|
void TimeTravelTo(int historyIndex);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 清空当前撤销/重做历史,并以当前状态作为新的历史锚点。
|
||||||
|
/// 该操作不会修改当前状态,也不会触发额外通知。
|
||||||
|
/// </summary>
|
||||||
|
void ClearHistory();
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
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>
|
||||||
|
/// 配置历史缓冲区容量。
|
||||||
|
/// 传入 0 表示禁用历史记录;大于 0 时会保留最近若干个状态快照,用于撤销、重做和时间旅行调试。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="historyCapacity">历史缓冲区容量。</param>
|
||||||
|
/// <returns>当前构建器实例。</returns>
|
||||||
|
IStoreBuilder<TState> WithHistoryCapacity(int historyCapacity);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配置 reducer 的 action 匹配策略。
|
||||||
|
/// 默认使用 <see cref="StoreActionMatchingMode.ExactTypeOnly"/>,仅在需要复用基类或接口 action 层次时再启用多态匹配。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionMatchingMode">要使用的匹配策略。</param>
|
||||||
|
/// <returns>当前构建器实例。</returns>
|
||||||
|
IStoreBuilder<TState> WithActionMatching(StoreActionMatchingMode actionMatchingMode);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 添加一个强类型 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);
|
||||||
|
}
|
||||||
@ -0,0 +1,67 @@
|
|||||||
|
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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前 Store 使用的 action 匹配策略。
|
||||||
|
/// </summary>
|
||||||
|
StoreActionMatchingMode ActionMatchingMode { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取历史缓冲区容量。
|
||||||
|
/// 返回 0 表示当前 Store 未启用历史记录能力。
|
||||||
|
/// </summary>
|
||||||
|
int HistoryCapacity { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前可见历史记录数量。
|
||||||
|
/// 当历史记录启用时,该值至少为 1,因为当前状态会作为历史锚点存在。
|
||||||
|
/// </summary>
|
||||||
|
int HistoryCount { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前状态在历史缓冲区中的索引。
|
||||||
|
/// 当未启用历史记录时返回 -1。
|
||||||
|
/// </summary>
|
||||||
|
int HistoryIndex { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前是否处于批处理阶段。
|
||||||
|
/// 该值为 <see langword="true"/> 时,状态变更通知会延迟到最外层批处理结束后再统一发送。
|
||||||
|
/// </summary>
|
||||||
|
bool IsBatching { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前历史快照列表的只读快照。
|
||||||
|
/// 该方法会返回一份独立快照,供调试工具渲染时间旅行面板,而不暴露 Store 的内部可变集合。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>当前历史快照列表;若未启用历史记录或当前没有历史,则返回空数组。</returns>
|
||||||
|
IReadOnlyList<StoreHistoryEntry<TState>> GetHistoryEntriesSnapshot();
|
||||||
|
}
|
||||||
@ -0,0 +1,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);
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
namespace GFramework.Core.Abstractions.StateManagement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义 Store 在分发 action 时的 reducer 匹配策略。
|
||||||
|
/// 默认使用精确类型匹配,以保持执行结果和顺序的确定性;仅在确有需要时再启用多态匹配。
|
||||||
|
/// </summary>
|
||||||
|
public enum StoreActionMatchingMode
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 仅匹配与 action 运行时类型完全相同的 reducer。
|
||||||
|
/// 该模式不会命中基类或接口注册,适合作为默认的稳定行为。
|
||||||
|
/// </summary>
|
||||||
|
ExactTypeOnly = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 在精确类型匹配之外,额外匹配可赋值的基类和接口 reducer。
|
||||||
|
/// Store 会保持确定性的执行顺序:精确类型优先,其次是最近的基类,最后是接口注册。
|
||||||
|
/// </summary>
|
||||||
|
IncludeAssignableTypes = 1
|
||||||
|
}
|
||||||
@ -0,0 +1,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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
namespace GFramework.Core.Abstractions.StateManagement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示一条 Store 历史快照记录。
|
||||||
|
/// 该记录用于撤销/重做和调试面板查看历史状态,不会暴露 Store 的内部可变结构。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||||
|
public sealed class StoreHistoryEntry<TState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一条历史记录。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">该历史点对应的状态快照。</param>
|
||||||
|
/// <param name="recordedAt">该历史点被记录的时间。</param>
|
||||||
|
/// <param name="action">触发该状态的 action;若为初始状态或已清空历史后的锚点,则为 <see langword="null"/>。</param>
|
||||||
|
public StoreHistoryEntry(TState state, DateTimeOffset recordedAt, object? action = null)
|
||||||
|
{
|
||||||
|
State = state;
|
||||||
|
RecordedAt = recordedAt;
|
||||||
|
Action = action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取该历史点对应的状态快照。
|
||||||
|
/// </summary>
|
||||||
|
public TState State { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取该历史点被记录的时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset RecordedAt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取触发该历史点的 action 实例。
|
||||||
|
/// 对于初始状态或调用 <c>ClearHistory()</c> 后的新锚点,该值为 <see langword="null"/>。
|
||||||
|
/// </summary>
|
||||||
|
public object? Action { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取触发该历史点的 action 运行时类型。
|
||||||
|
/// 若该历史点没有关联 action,则返回 <see langword="null"/>。
|
||||||
|
/// </summary>
|
||||||
|
public Type? ActionType => Action?.GetType();
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<IsTestProject>true</IsTestProject>
|
<IsTestProject>true</IsTestProject>
|
||||||
|
<WarningLevel>0</WarningLevel>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Mediator.Abstractions" Version="3.0.1"/>
|
<PackageReference Include="Mediator.Abstractions" Version="3.0.1"/>
|
||||||
@ -22,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"/>
|
||||||
|
|||||||
@ -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;
|
||||||
@ -1,6 +1,5 @@
|
|||||||
using GFramework.Core.Abstractions.Logging;
|
using GFramework.Core.Abstractions.Logging;
|
||||||
using GFramework.Core.Logging.Appenders;
|
using GFramework.Core.Logging.Appenders;
|
||||||
using NUnit.Framework;
|
|
||||||
|
|
||||||
namespace GFramework.Core.Tests.Logging;
|
namespace GFramework.Core.Tests.Logging;
|
||||||
|
|
||||||
@ -152,8 +151,12 @@ public class AsyncLogAppenderTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void Append_WhenInnerAppenderThrows_ShouldNotCrash()
|
public void Append_WhenInnerAppenderThrows_ShouldNotCrash()
|
||||||
{
|
{
|
||||||
|
var reportedExceptions = new List<Exception>();
|
||||||
var innerAppender = new ThrowingAppender();
|
var innerAppender = new ThrowingAppender();
|
||||||
using var asyncAppender = new AsyncLogAppender(innerAppender, bufferSize: 1000);
|
using var asyncAppender = new AsyncLogAppender(
|
||||||
|
innerAppender,
|
||||||
|
bufferSize: 1000,
|
||||||
|
processingErrorHandler: reportedExceptions.Add);
|
||||||
|
|
||||||
// 即使内部 Appender 抛出异常,也不应该影响调用线程
|
// 即使内部 Appender 抛出异常,也不应该影响调用线程
|
||||||
Assert.DoesNotThrow(() =>
|
Assert.DoesNotThrow(() =>
|
||||||
@ -165,7 +168,56 @@ public class AsyncLogAppenderTests
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Thread.Sleep(100); // 等待后台处理
|
asyncAppender.Flush();
|
||||||
|
|
||||||
|
Assert.That(reportedExceptions, Has.Count.EqualTo(10));
|
||||||
|
Assert.That(reportedExceptions, Has.All.TypeOf<InvalidOperationException>());
|
||||||
|
Assert.That(reportedExceptions.Select(static exception => exception.Message),
|
||||||
|
Has.All.EqualTo("Test exception"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Append_WhenProcessingErrorHandlerThrows_ShouldStillNotCrash()
|
||||||
|
{
|
||||||
|
var innerAppender = new ThrowingAppender();
|
||||||
|
using var asyncAppender = new AsyncLogAppender(
|
||||||
|
innerAppender,
|
||||||
|
bufferSize: 1000,
|
||||||
|
processingErrorHandler: static _ => throw new InvalidOperationException("Observer failure"));
|
||||||
|
|
||||||
|
Assert.DoesNotThrow(() =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
|
||||||
|
asyncAppender.Append(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(asyncAppender.Flush(), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Append_WhenInnerAppenderThrowsOperationCanceledException_ShouldNotReportError()
|
||||||
|
{
|
||||||
|
var reportedExceptions = new List<Exception>();
|
||||||
|
var innerAppender = new CancellationAppender();
|
||||||
|
using var asyncAppender = new AsyncLogAppender(
|
||||||
|
innerAppender,
|
||||||
|
bufferSize: 1000,
|
||||||
|
processingErrorHandler: reportedExceptions.Add);
|
||||||
|
|
||||||
|
Assert.DoesNotThrow(() =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
var entry = new LogEntry(DateTime.UtcNow, LogLevel.Info, "TestLogger", $"Message {i}", null, null);
|
||||||
|
asyncAppender.Append(entry);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(asyncAppender.Flush(), Is.True);
|
||||||
|
Assert.That(reportedExceptions, Is.Empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助测试类
|
// 辅助测试类
|
||||||
@ -228,4 +280,20 @@ public class AsyncLogAppenderTests
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private class CancellationAppender : ILogAppender
|
||||||
|
{
|
||||||
|
public void Append(LogEntry entry)
|
||||||
|
{
|
||||||
|
throw new OperationCanceledException("Simulated cancellation");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Flush()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
using GFramework.Core.Abstractions.Pause;
|
using GFramework.Core.Abstractions.Pause;
|
||||||
using GFramework.Core.Pause;
|
using GFramework.Core.Pause;
|
||||||
using NUnit.Framework;
|
|
||||||
|
|
||||||
namespace GFramework.Core.Tests.Pause;
|
namespace GFramework.Core.Tests.Pause;
|
||||||
|
|
||||||
@ -220,11 +219,11 @@ public class PauseStackManagerTests
|
|||||||
PauseGroup? eventGroup = null;
|
PauseGroup? eventGroup = null;
|
||||||
bool? eventIsPaused = null;
|
bool? eventIsPaused = null;
|
||||||
|
|
||||||
_manager.OnPauseStateChanged += (group, isPaused) =>
|
_manager.OnPauseStateChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
eventTriggered = true;
|
eventTriggered = true;
|
||||||
eventGroup = group;
|
eventGroup = e.Group;
|
||||||
eventIsPaused = isPaused;
|
eventIsPaused = e.IsPaused;
|
||||||
};
|
};
|
||||||
|
|
||||||
_manager.Push("Test", PauseGroup.Gameplay);
|
_manager.Push("Test", PauseGroup.Gameplay);
|
||||||
@ -243,10 +242,10 @@ public class PauseStackManagerTests
|
|||||||
var token = _manager.Push("Test");
|
var token = _manager.Push("Test");
|
||||||
|
|
||||||
bool eventTriggered = false;
|
bool eventTriggered = false;
|
||||||
_manager.OnPauseStateChanged += (group, isPaused) =>
|
_manager.OnPauseStateChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
eventTriggered = true;
|
eventTriggered = true;
|
||||||
Assert.That(isPaused, Is.False);
|
Assert.That(e.IsPaused, Is.False);
|
||||||
};
|
};
|
||||||
|
|
||||||
_manager.Pop(token);
|
_manager.Pop(token);
|
||||||
|
|||||||
@ -0,0 +1,92 @@
|
|||||||
|
using GFramework.Core.Events;
|
||||||
|
|
||||||
|
namespace GFramework.Core.Tests.StateManagement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Store 到 EventBus 桥接扩展的单元测试。
|
||||||
|
/// 这些测试验证旧模块兼容桥接能够正确转发 dispatch 和状态变化事件,并支持运行时拆除。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public class StoreEventBusExtensionsTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 测试桥接会发布每次 dispatch 事件,并对批处理后的状态变化只发送一次最终状态事件。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void BridgeToEventBus_Should_Publish_Dispatches_And_Collapsed_State_Changes()
|
||||||
|
{
|
||||||
|
var eventBus = new EventBus();
|
||||||
|
var store = CreateStore();
|
||||||
|
var dispatchedEvents = new List<StoreDispatchedEvent<CounterState>>();
|
||||||
|
var stateChangedEvents = new List<StoreStateChangedEvent<CounterState>>();
|
||||||
|
|
||||||
|
eventBus.Register<StoreDispatchedEvent<CounterState>>(dispatchedEvents.Add);
|
||||||
|
eventBus.Register<StoreStateChangedEvent<CounterState>>(stateChangedEvents.Add);
|
||||||
|
|
||||||
|
store.BridgeToEventBus(eventBus);
|
||||||
|
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
store.RunInBatch(() =>
|
||||||
|
{
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(dispatchedEvents.Count, Is.EqualTo(3));
|
||||||
|
Assert.That(dispatchedEvents[0].DispatchRecord.NextState.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(dispatchedEvents[2].DispatchRecord.NextState.Count, Is.EqualTo(3));
|
||||||
|
|
||||||
|
Assert.That(stateChangedEvents.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(stateChangedEvents[0].State.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(stateChangedEvents[1].State.Count, Is.EqualTo(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试桥接句柄注销后不会再继续向 EventBus 发送事件。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void BridgeToEventBus_UnRegister_Should_Stop_Future_Publications()
|
||||||
|
{
|
||||||
|
var eventBus = new EventBus();
|
||||||
|
var store = CreateStore();
|
||||||
|
var dispatchedEvents = new List<StoreDispatchedEvent<CounterState>>();
|
||||||
|
var stateChangedEvents = new List<StoreStateChangedEvent<CounterState>>();
|
||||||
|
|
||||||
|
eventBus.Register<StoreDispatchedEvent<CounterState>>(dispatchedEvents.Add);
|
||||||
|
eventBus.Register<StoreStateChangedEvent<CounterState>>(stateChangedEvents.Add);
|
||||||
|
|
||||||
|
var bridge = store.BridgeToEventBus(eventBus);
|
||||||
|
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
bridge.UnRegister();
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
|
||||||
|
Assert.That(dispatchedEvents.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(stateChangedEvents.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(stateChangedEvents[0].State.Count, Is.EqualTo(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 创建一个带基础 reducer 的测试 Store。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>测试用 Store 实例。</returns>
|
||||||
|
private static Store<CounterState> CreateStore()
|
||||||
|
{
|
||||||
|
var store = new Store<CounterState>(new CounterState(0, "Player"));
|
||||||
|
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于桥接测试的状态类型。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Count">当前计数值。</param>
|
||||||
|
/// <param name="Name">当前名称。</param>
|
||||||
|
private sealed record CounterState(int Count, string Name);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于桥接测试的计数 action。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Amount">要增加的数量。</param>
|
||||||
|
private sealed record IncrementAction(int Amount);
|
||||||
|
}
|
||||||
879
GFramework.Core.Tests/StateManagement/StoreTests.cs
Normal file
879
GFramework.Core.Tests/StateManagement/StoreTests.cs
Normal file
@ -0,0 +1,879 @@
|
|||||||
|
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>
|
||||||
|
/// 测试批处理会折叠多次状态变化通知,只在最外层结束时发布最终状态。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void RunInBatch_Should_Collapse_Notifications_To_Final_State()
|
||||||
|
{
|
||||||
|
var store = CreateStore();
|
||||||
|
var receivedCounts = new List<int>();
|
||||||
|
|
||||||
|
store.Subscribe(state => receivedCounts.Add(state.Count));
|
||||||
|
|
||||||
|
store.RunInBatch(() =>
|
||||||
|
{
|
||||||
|
Assert.That(store.IsBatching, Is.True);
|
||||||
|
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
store.Dispatch(new IncrementAction(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(store.IsBatching, Is.False);
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(3));
|
||||||
|
Assert.That(receivedCounts, Is.EqualTo(new[] { 3 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试嵌套批处理只会在最外层结束时发出一次通知。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void RunInBatch_Should_Support_Nested_Batches()
|
||||||
|
{
|
||||||
|
var store = CreateStore();
|
||||||
|
var receivedCounts = new List<int>();
|
||||||
|
|
||||||
|
store.Subscribe(state => receivedCounts.Add(state.Count));
|
||||||
|
|
||||||
|
store.RunInBatch(() =>
|
||||||
|
{
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
|
||||||
|
store.RunInBatch(() =>
|
||||||
|
{
|
||||||
|
Assert.That(store.IsBatching, Is.True);
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(3));
|
||||||
|
Assert.That(receivedCounts, Is.EqualTo(new[] { 3 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试启用历史记录后支持撤销、重做、时间旅行和 redo 分支裁剪。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void History_Should_Support_Undo_Redo_Time_Travel_And_Branch_Reset()
|
||||||
|
{
|
||||||
|
var store = new Store<CounterState>(new CounterState(0, "Player"), historyCapacity: 8);
|
||||||
|
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
|
||||||
|
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
|
||||||
|
Assert.That(store.HistoryCount, Is.EqualTo(4));
|
||||||
|
Assert.That(store.HistoryIndex, Is.EqualTo(3));
|
||||||
|
Assert.That(store.CanUndo, Is.True);
|
||||||
|
Assert.That(store.CanRedo, Is.False);
|
||||||
|
|
||||||
|
store.Undo();
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(store.HistoryIndex, Is.EqualTo(2));
|
||||||
|
Assert.That(store.CanRedo, Is.True);
|
||||||
|
|
||||||
|
store.Undo();
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(1));
|
||||||
|
Assert.That(store.HistoryIndex, Is.EqualTo(1));
|
||||||
|
|
||||||
|
store.Redo();
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(store.HistoryIndex, Is.EqualTo(2));
|
||||||
|
|
||||||
|
store.TimeTravelTo(0);
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(0));
|
||||||
|
Assert.That(store.HistoryIndex, Is.EqualTo(0));
|
||||||
|
|
||||||
|
store.TimeTravelTo(2);
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(store.HistoryIndex, Is.EqualTo(2));
|
||||||
|
|
||||||
|
store.Dispatch(new IncrementAction(10));
|
||||||
|
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(12));
|
||||||
|
Assert.That(store.CanRedo, Is.False, "新 dispatch 应清除 redo 分支");
|
||||||
|
Assert.That(store.GetHistoryEntriesSnapshot().Select(entry => entry.State.Count),
|
||||||
|
Is.EqualTo(new[] { 0, 1, 2, 12 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试 ClearHistory 会以当前状态重置历史锚点,而不会修改当前状态。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void ClearHistory_Should_Reset_To_Current_State_Anchor()
|
||||||
|
{
|
||||||
|
var store = new Store<CounterState>(new CounterState(0, "Player"), historyCapacity: 4);
|
||||||
|
store.RegisterReducer<IncrementAction>((state, action) => state with { Count = state.Count + action.Amount });
|
||||||
|
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
store.Dispatch(new IncrementAction(1));
|
||||||
|
store.ClearHistory();
|
||||||
|
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(2));
|
||||||
|
Assert.That(store.HistoryCount, Is.EqualTo(1));
|
||||||
|
Assert.That(store.HistoryIndex, Is.EqualTo(0));
|
||||||
|
Assert.That(store.CanUndo, Is.False);
|
||||||
|
Assert.That(store.GetHistoryEntriesSnapshot()[0].State.Count, Is.EqualTo(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试默认 action 匹配策略仍然只命中精确类型 reducer。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Dispatch_Should_Remain_Exact_Type_Only_By_Default()
|
||||||
|
{
|
||||||
|
var store = new Store<CounterState>(new CounterState(0, "Player"));
|
||||||
|
store.RegisterReducer<IncrementActionBase>((state, action) =>
|
||||||
|
state with { Count = state.Count + action.Amount * 10 });
|
||||||
|
store.RegisterReducer<IIncrementActionMarker>((state, action) =>
|
||||||
|
state with { Count = state.Count + action.Amount * 100 });
|
||||||
|
|
||||||
|
store.Dispatch(new DerivedIncrementAction(1));
|
||||||
|
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(0));
|
||||||
|
Assert.That(store.ActionMatchingMode, Is.EqualTo(StoreActionMatchingMode.ExactTypeOnly));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试启用多态匹配后,Store 会按“精确类型 -> 基类 -> 接口”的稳定顺序执行 reducer。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void Dispatch_Should_Use_Polymorphic_Action_Matching_In_Deterministic_Order()
|
||||||
|
{
|
||||||
|
var executionOrder = new List<string>();
|
||||||
|
var store = new Store<CounterState>(
|
||||||
|
new CounterState(0, "Player"),
|
||||||
|
actionMatchingMode: StoreActionMatchingMode.IncludeAssignableTypes);
|
||||||
|
|
||||||
|
store.RegisterReducer<IncrementActionBase>((state, action) =>
|
||||||
|
{
|
||||||
|
executionOrder.Add("base");
|
||||||
|
return state with { Count = state.Count + action.Amount * 10 };
|
||||||
|
});
|
||||||
|
|
||||||
|
store.RegisterReducer<IIncrementActionMarker>((state, action) =>
|
||||||
|
{
|
||||||
|
executionOrder.Add("interface");
|
||||||
|
return state with { Count = state.Count + action.Amount * 100 };
|
||||||
|
});
|
||||||
|
|
||||||
|
store.RegisterReducer<DerivedIncrementAction>((state, action) =>
|
||||||
|
{
|
||||||
|
executionOrder.Add("exact");
|
||||||
|
return state with { Count = state.Count + action.Amount };
|
||||||
|
});
|
||||||
|
|
||||||
|
store.Dispatch(new DerivedIncrementAction(1));
|
||||||
|
|
||||||
|
Assert.That(executionOrder, Is.EqualTo(new[] { "exact", "base", "interface" }));
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(111));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试 StoreBuilder 能够应用历史容量和 action 匹配策略配置。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void StoreBuilder_Should_Apply_History_And_Action_Matching_Configuration()
|
||||||
|
{
|
||||||
|
var store = (Store<CounterState>)Store<CounterState>
|
||||||
|
.CreateBuilder()
|
||||||
|
.WithHistoryCapacity(6)
|
||||||
|
.WithActionMatching(StoreActionMatchingMode.IncludeAssignableTypes)
|
||||||
|
.AddReducer<IncrementActionBase>((state, action) => state with { Count = state.Count + action.Amount })
|
||||||
|
.Build(new CounterState(0, "Player"));
|
||||||
|
|
||||||
|
store.Dispatch(new DerivedIncrementAction(2));
|
||||||
|
|
||||||
|
Assert.That(store.ActionMatchingMode, Is.EqualTo(StoreActionMatchingMode.IncludeAssignableTypes));
|
||||||
|
Assert.That(store.HistoryCapacity, Is.EqualTo(6));
|
||||||
|
Assert.That(store.HistoryCount, Is.EqualTo(2));
|
||||||
|
Assert.That(store.State.Count, Is.EqualTo(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试长时间运行的 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>
|
||||||
|
/// 表示参与多态匹配测试的 action 标记接口。
|
||||||
|
/// </summary>
|
||||||
|
private interface IIncrementActionMarker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取增量值。
|
||||||
|
/// </summary>
|
||||||
|
int Amount { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示多态匹配测试中的基类 action。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Amount">要增加的数量。</param>
|
||||||
|
private abstract record IncrementActionBase(int Amount);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示多态匹配测试中的派生 action。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Amount">要增加的数量。</param>
|
||||||
|
private sealed record DerivedIncrementAction(int Amount)
|
||||||
|
: IncrementActionBase(Amount), IIncrementActionMarker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 显式选择器实现,用于验证 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,11 +17,6 @@ internal sealed class CoroutineSlot
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public CoroutineHandle Handle;
|
public CoroutineHandle Handle;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 协程是否已经开始执行
|
|
||||||
/// </summary>
|
|
||||||
public bool HasStarted;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 协程的优先级
|
/// 协程的优先级
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -30,12 +30,6 @@ public sealed class WaitForAllCoroutines(
|
|||||||
/// 获取一个值,指示所有协程是否已完成执行
|
/// 获取一个值,指示所有协程是否已完成执行
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>当所有协程都已完成时返回true,否则返回false</returns>
|
/// <returns>当所有协程都已完成时返回true,否则返回false</returns>
|
||||||
public bool IsDone
|
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
// 检查所有协程句柄是否都不在调度器中存活
|
// 检查所有协程句柄是否都不在调度器中存活
|
||||||
return _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));
|
public bool IsDone => _handles.All(handle => !_scheduler.IsCoroutineAlive(handle));
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -208,13 +208,10 @@ public class PriorityEvent<T> : IEvent
|
|||||||
MergeAndSortHandlers(T t)
|
MergeAndSortHandlers(T t)
|
||||||
{
|
{
|
||||||
var (normalSnapshot, contextSnapshot) = CreateSnapshots();
|
var (normalSnapshot, contextSnapshot) = CreateSnapshots();
|
||||||
// 使用快照避免迭代期间修改
|
// 使用统一的投影方法显式固定元组的可空标注,避免 LINQ 在 Concat 时推断出不兼容的签名。
|
||||||
return normalSnapshot
|
return normalSnapshot
|
||||||
.Select(h => (h.Priority, Handler: (Action?)(() => h.Handler.Invoke(t)),
|
.Select(h => CreateNormalHandlerInvocation(h, t))
|
||||||
ContextHandler: (Action<EventContext<T>>?)null, IsContext: false))
|
.Concat(contextSnapshot.Select(CreateContextHandlerInvocation))
|
||||||
.Concat(contextSnapshot
|
|
||||||
.Select(h => (h.Priority, Handler: (Action?)null,
|
|
||||||
ContextHandler: (Action<EventContext<T>>?)h.Handler, IsContext: true)))
|
|
||||||
.OrderByDescending(h => h.Priority)
|
.OrderByDescending(h => h.Priority)
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
@ -289,6 +286,29 @@ public class PriorityEvent<T> : IEvent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将普通事件处理器转换为统一的调用描述。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="handler">要包装的普通处理器。</param>
|
||||||
|
/// <param name="t">当前触发的事件数据。</param>
|
||||||
|
/// <returns>可与上下文处理器合并排序的统一调用描述。</returns>
|
||||||
|
private static (int Priority, Action? Handler, Action<EventContext<T>>? ContextHandler, bool IsContext)
|
||||||
|
CreateNormalHandlerInvocation(EventHandler handler, T t)
|
||||||
|
{
|
||||||
|
return (handler.Priority, () => handler.Handler.Invoke(t), null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将上下文事件处理器转换为统一的调用描述。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="handler">要包装的上下文处理器。</param>
|
||||||
|
/// <returns>可与普通处理器合并排序的统一调用描述。</returns>
|
||||||
|
private static (int Priority, Action? Handler, Action<EventContext<T>>? ContextHandler, bool IsContext)
|
||||||
|
CreateContextHandlerInvocation(ContextEventHandler handler)
|
||||||
|
{
|
||||||
|
return (handler.Priority, null, handler.Handler, true);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 事件处理器包装类,包含处理器和优先级
|
/// 事件处理器包装类,包含处理器和优先级
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
118
GFramework.Core/Extensions/StoreEventBusExtensions.cs
Normal file
118
GFramework.Core/Extensions/StoreEventBusExtensions.cs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
using GFramework.Core.Abstractions.Events;
|
||||||
|
using GFramework.Core.Abstractions.StateManagement;
|
||||||
|
using GFramework.Core.Events;
|
||||||
|
using GFramework.Core.StateManagement;
|
||||||
|
|
||||||
|
namespace GFramework.Core.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 为 Store 提供到 EventBus 的兼容桥接扩展。
|
||||||
|
/// 该扩展面向旧模块渐进迁移场景,使现有事件消费者可以继续观察 Store 的 action 分发和状态变化。
|
||||||
|
/// </summary>
|
||||||
|
public static class StoreEventBusExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 将 Store 的 dispatch 和状态变化同时桥接到 EventBus。
|
||||||
|
/// dispatch 事件会逐次发布;状态变化事件会复用 Store 自身的通知折叠语义,因此批处理中只发布最终状态。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||||
|
/// <param name="store">源 Store。</param>
|
||||||
|
/// <param name="eventBus">目标事件总线。</param>
|
||||||
|
/// <param name="publishDispatches">是否发布每次 action 分发事件。</param>
|
||||||
|
/// <param name="publishStateChanges">是否发布状态变化事件。</param>
|
||||||
|
/// <returns>用于拆除桥接的句柄。</returns>
|
||||||
|
public static IUnRegister BridgeToEventBus<TState>(
|
||||||
|
this Store<TState> store,
|
||||||
|
IEventBus eventBus,
|
||||||
|
bool publishDispatches = true,
|
||||||
|
bool publishStateChanges = true)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(store);
|
||||||
|
ArgumentNullException.ThrowIfNull(eventBus);
|
||||||
|
|
||||||
|
IUnRegister? dispatchBridge = null;
|
||||||
|
IUnRegister? stateBridge = null;
|
||||||
|
|
||||||
|
if (publishDispatches)
|
||||||
|
{
|
||||||
|
dispatchBridge = store.BridgeDispatchesToEventBus(eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publishStateChanges)
|
||||||
|
{
|
||||||
|
stateBridge = store.BridgeStateChangesToEventBus(eventBus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DefaultUnRegister(() =>
|
||||||
|
{
|
||||||
|
dispatchBridge?.UnRegister();
|
||||||
|
stateBridge?.UnRegister();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 Store 的每次 dispatch 结果桥接到 EventBus。
|
||||||
|
/// 该桥接通过中间件实现,因此即使某次分发未改变状态,也会发布对应的 dispatch 事件。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||||
|
/// <param name="store">源 Store。</param>
|
||||||
|
/// <param name="eventBus">目标事件总线。</param>
|
||||||
|
/// <returns>用于移除 dispatch 桥接中间件的句柄。</returns>
|
||||||
|
public static IUnRegister BridgeDispatchesToEventBus<TState>(this Store<TState> store, IEventBus eventBus)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(store);
|
||||||
|
ArgumentNullException.ThrowIfNull(eventBus);
|
||||||
|
|
||||||
|
return store.RegisterMiddleware(new DispatchEventBusMiddleware<TState>(eventBus));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将 Store 的状态变化桥接到 EventBus。
|
||||||
|
/// 该桥接复用 Store 的订阅通知语义,因此只会在状态真正变化时发布事件。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||||
|
/// <param name="store">源 Store。</param>
|
||||||
|
/// <param name="eventBus">目标事件总线。</param>
|
||||||
|
/// <returns>用于移除状态变化桥接的句柄。</returns>
|
||||||
|
public static IUnRegister BridgeStateChangesToEventBus<TState>(this IReadonlyStore<TState> store,
|
||||||
|
IEventBus eventBus)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(store);
|
||||||
|
ArgumentNullException.ThrowIfNull(eventBus);
|
||||||
|
|
||||||
|
return store.Subscribe(state =>
|
||||||
|
eventBus.Send(new StoreStateChangedEvent<TState>(state, DateTimeOffset.UtcNow)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用于把 dispatch 结果桥接到 EventBus 的内部中间件。
|
||||||
|
/// 选择中间件而不是改写 Store 核心提交流程,是为了把兼容层成本保持在可选扩展中。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||||
|
private sealed class DispatchEventBusMiddleware<TState>(IEventBus eventBus) : IStoreMiddleware<TState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 目标事件总线。
|
||||||
|
/// </summary>
|
||||||
|
private readonly IEventBus _eventBus = eventBus ?? throw new ArgumentNullException(nameof(eventBus));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 执行后续 dispatch 管线,并在结束后把分发结果发送到 EventBus。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">当前分发上下文。</param>
|
||||||
|
/// <param name="next">后续管线。</param>
|
||||||
|
public void Invoke(StoreDispatchContext<TState> context, Action next)
|
||||||
|
{
|
||||||
|
next();
|
||||||
|
|
||||||
|
var dispatchRecord = new StoreDispatchRecord<TState>(
|
||||||
|
context.Action,
|
||||||
|
context.PreviousState,
|
||||||
|
context.NextState,
|
||||||
|
context.HasStateChanged,
|
||||||
|
context.DispatchedAt);
|
||||||
|
|
||||||
|
_eventBus.Send(new StoreDispatchedEvent<TState>(dispatchRecord));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
GFramework.Core/Extensions/StoreExtensions.cs
Normal file
90
GFramework.Core/Extensions/StoreExtensions.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -16,3 +16,4 @@ global using System.Collections.Generic;
|
|||||||
global using System.Linq;
|
global using System.Linq;
|
||||||
global using System.Threading;
|
global using System.Threading;
|
||||||
global using System.Threading.Tasks;
|
global using System.Threading.Tasks;
|
||||||
|
global using System.Threading.Channels;
|
||||||
@ -1,17 +1,26 @@
|
|||||||
using System.Threading.Channels;
|
|
||||||
using GFramework.Core.Abstractions.Logging;
|
using GFramework.Core.Abstractions.Logging;
|
||||||
|
|
||||||
namespace GFramework.Core.Logging.Appenders;
|
namespace GFramework.Core.Logging.Appenders;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 异步日志输出器,使用 Channel 实现非阻塞日志写入
|
/// 异步日志输出器,使用 <see cref="Channel" /> 将调用线程与慢速日志目标解耦。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// 该输出器在后台线程中顺序消费日志条目,因此调用方不会因为文件 IO 或其他慢速输出目标而阻塞。
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// 内部输出器抛出的异常不会重新抛回调用线程;如需观察后台处理失败,请在构造函数中提供
|
||||||
|
/// <c>processingErrorHandler</c> 回调。
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class AsyncLogAppender : ILogAppender
|
||||||
{
|
{
|
||||||
private readonly Channel<LogEntry> _channel;
|
private readonly Channel<LogEntry> _channel;
|
||||||
private readonly CancellationTokenSource _cts;
|
private readonly CancellationTokenSource _cts;
|
||||||
private readonly SemaphoreSlim _flushSemaphore = new(0, 1);
|
private readonly SemaphoreSlim _flushSemaphore = new(0, 1);
|
||||||
private readonly ILogAppender _innerAppender;
|
private readonly ILogAppender _innerAppender;
|
||||||
|
private readonly Action<Exception>? _processingErrorHandler;
|
||||||
private readonly Task _processingTask;
|
private readonly Task _processingTask;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
private volatile bool _flushRequested;
|
private volatile bool _flushRequested;
|
||||||
@ -21,9 +30,17 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="innerAppender">内部日志输出器</param>
|
/// <param name="innerAppender">内部日志输出器</param>
|
||||||
/// <param name="bufferSize">缓冲区大小(默认 10000)</param>
|
/// <param name="bufferSize">缓冲区大小(默认 10000)</param>
|
||||||
public AsyncLogAppender(ILogAppender innerAppender, int bufferSize = 10000)
|
/// <param name="processingErrorHandler">
|
||||||
|
/// 后台处理日志时的错误回调。
|
||||||
|
/// 默认值为 <see langword="null" />,表示吞掉内部异常以避免污染宿主标准错误输出。
|
||||||
|
/// </param>
|
||||||
|
public AsyncLogAppender(
|
||||||
|
ILogAppender innerAppender,
|
||||||
|
int bufferSize = 10000,
|
||||||
|
Action<Exception>? processingErrorHandler = null)
|
||||||
{
|
{
|
||||||
_innerAppender = innerAppender ?? throw new ArgumentNullException(nameof(innerAppender));
|
_innerAppender = innerAppender ?? throw new ArgumentNullException(nameof(innerAppender));
|
||||||
|
_processingErrorHandler = processingErrorHandler;
|
||||||
|
|
||||||
if (bufferSize <= 0)
|
if (bufferSize <= 0)
|
||||||
throw new ArgumentException("Buffer size must be greater than 0.", nameof(bufferSize));
|
throw new ArgumentException("Buffer size must be greater than 0.", nameof(bufferSize));
|
||||||
@ -138,7 +155,8 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 后台处理日志的异步方法
|
/// 后台处理日志的异步方法。
|
||||||
|
/// 该循环必须始终保持存活,因此所有内部异常都通过回调上报并被吞掉。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ProcessLogsAsync(CancellationToken cancellationToken)
|
private async Task ProcessLogsAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@ -152,8 +170,8 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// 记录内部错误到控制台(避免递归)
|
// 后台消费失败只通过显式回调暴露,避免测试宿主将 stderr 误判为测试告警。
|
||||||
await Console.Error.WriteLineAsync($"[AsyncLogAppender] Error processing log entry: {ex.Message}");
|
ReportProcessingError(ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有刷新请求且通道已空
|
// 检查是否有刷新请求且通道已空
|
||||||
@ -175,7 +193,7 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
await Console.Error.WriteLineAsync($"[AsyncLogAppender] Fatal error in processing task: {ex}");
|
ReportProcessingError(ex);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@ -184,10 +202,37 @@ public sealed class AsyncLogAppender : ILogAppender, IDisposable
|
|||||||
{
|
{
|
||||||
_innerAppender.Flush();
|
_innerAppender.Flush();
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
ReportProcessingError(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 上报后台处理异常,同时隔离观察者自身抛出的错误,避免终止处理循环。
|
||||||
|
/// 取消相关异常表示关闭流程中的预期控制流,不应被视为后台处理失败。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="exception">后台处理中捕获到的异常。</param>
|
||||||
|
private void ReportProcessingError(Exception exception)
|
||||||
|
{
|
||||||
|
if (exception is OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_processingErrorHandler is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_processingErrorHandler(exception);
|
||||||
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// 忽略刷新错误
|
// 错误观察者只用于诊断,绝不能反向影响日志处理线程的生命周期。
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -80,7 +80,7 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
|
|||||||
// 触发事件
|
// 触发事件
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
OnPauseStateChanged?.Invoke(group, false);
|
RaisePauseStateChanged(group, false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@ -97,7 +97,7 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 暂停状态变化事件,当暂停状态发生改变时触发。
|
/// 暂停状态变化事件,当暂停状态发生改变时触发。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<PauseGroup, bool>? OnPauseStateChanged;
|
public event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 推入一个新的暂停请求到指定的暂停组中。
|
/// 推入一个新的暂停请求到指定的暂停组中。
|
||||||
@ -488,7 +488,18 @@ public class PauseStackManager : AbstractContextUtility, IPauseStackManager, IAs
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 触发事件
|
// 触发事件
|
||||||
OnPauseStateChanged?.Invoke(group, isPaused);
|
RaisePauseStateChanged(group, isPaused);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 以标准事件模式发布暂停状态变化事件。
|
||||||
|
/// 所有状态变更路径都通过该方法创建统一的事件参数,避免不同调用点出现不一致的载荷。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="group">发生状态变化的暂停组。</param>
|
||||||
|
/// <param name="isPaused">暂停组变化后的新状态。</param>
|
||||||
|
private void RaisePauseStateChanged(PauseGroup group, bool isPaused)
|
||||||
|
{
|
||||||
|
OnPauseStateChanged?.Invoke(this, new PauseStateChangedEventArgs(group, isPaused));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@ -13,6 +13,7 @@ GFramework 框架的核心模块,提供MVC架构的基础设施。
|
|||||||
- **Events** - 事件系统,实现组件间松耦合通信
|
- **Events** - 事件系统,实现组件间松耦合通信
|
||||||
- **IoC** - 轻量级依赖注入容器
|
- **IoC** - 轻量级依赖注入容器
|
||||||
- **Property** - 可绑定属性,支持数据绑定和响应式编程
|
- **Property** - 可绑定属性,支持数据绑定和响应式编程
|
||||||
|
- **StateManagement** - 集中式状态容器,支持状态归约、选择器和诊断
|
||||||
- **Utility** - 无状态工具类
|
- **Utility** - 无状态工具类
|
||||||
- **Pool** - 对象池系统,减少GC压力
|
- **Pool** - 对象池系统,减少GC压力
|
||||||
- **Extensions** - 框架扩展方法
|
- **Extensions** - 框架扩展方法
|
||||||
|
|||||||
1415
GFramework.Core/StateManagement/Store.cs
Normal file
1415
GFramework.Core/StateManagement/Store.cs
Normal file
File diff suppressed because it is too large
Load Diff
128
GFramework.Core/StateManagement/StoreBuilder.cs
Normal file
128
GFramework.Core/StateManagement/StoreBuilder.cs
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
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>
|
||||||
|
/// action 匹配策略。
|
||||||
|
/// 默认使用精确类型匹配,只有在明确需要复用基类/接口 action 层次时才切换为多态匹配。
|
||||||
|
/// </summary>
|
||||||
|
private StoreActionMatchingMode _actionMatchingMode = StoreActionMatchingMode.ExactTypeOnly;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 状态比较器。
|
||||||
|
/// </summary>
|
||||||
|
private IEqualityComparer<TState>? _comparer;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 历史缓冲区容量。
|
||||||
|
/// 默认值为 0,表示不记录撤销/重做历史,以维持最轻量的运行时开销。
|
||||||
|
/// </summary>
|
||||||
|
private int _historyCapacity;
|
||||||
|
|
||||||
|
/// <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, _historyCapacity, _actionMatchingMode);
|
||||||
|
foreach (var configurator in _configurators)
|
||||||
|
{
|
||||||
|
configurator(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配置历史缓冲区容量。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="historyCapacity">历史缓冲区容量;0 表示禁用历史记录。</param>
|
||||||
|
/// <returns>当前构建器实例。</returns>
|
||||||
|
/// <exception cref="ArgumentOutOfRangeException">当 <paramref name="historyCapacity"/> 小于 0 时抛出。</exception>
|
||||||
|
public IStoreBuilder<TState> WithHistoryCapacity(int historyCapacity)
|
||||||
|
{
|
||||||
|
if (historyCapacity < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(historyCapacity), historyCapacity,
|
||||||
|
"History capacity cannot be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_historyCapacity = historyCapacity;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 配置 action 匹配策略。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionMatchingMode">要使用的匹配策略。</param>
|
||||||
|
/// <returns>当前构建器实例。</returns>
|
||||||
|
public IStoreBuilder<TState> WithActionMatching(StoreActionMatchingMode actionMatchingMode)
|
||||||
|
{
|
||||||
|
_actionMatchingMode = actionMatchingMode;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 添加一个强类型 reducer。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TAction">当前 reducer 处理的 action 类型。</typeparam>
|
||||||
|
/// <param name="reducer">要添加的 reducer。</param>
|
||||||
|
/// <returns>当前构建器实例。</returns>
|
||||||
|
public IStoreBuilder<TState> AddReducer<TAction>(IReducer<TState, TAction> reducer)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(reducer);
|
||||||
|
_configurators.Add(store => store.RegisterReducer(reducer));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
GFramework.Core/StateManagement/StoreDispatchedEvent.cs
Normal file
26
GFramework.Core/StateManagement/StoreDispatchedEvent.cs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
using GFramework.Core.Abstractions.StateManagement;
|
||||||
|
|
||||||
|
namespace GFramework.Core.StateManagement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示一条由 Store 分发桥接到 EventBus 的事件。
|
||||||
|
/// 该事件用于让旧模块在不直接依赖 Store API 的情况下观察 action 分发结果。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||||
|
public sealed class StoreDispatchedEvent<TState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个新的 Store 分发桥接事件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dispatchRecord">本次分发记录。</param>
|
||||||
|
/// <exception cref="ArgumentNullException">当 <paramref name="dispatchRecord"/> 为 <see langword="null"/> 时抛出。</exception>
|
||||||
|
public StoreDispatchedEvent(StoreDispatchRecord<TState> dispatchRecord)
|
||||||
|
{
|
||||||
|
DispatchRecord = dispatchRecord ?? throw new ArgumentNullException(nameof(dispatchRecord));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取本次桥接对应的 Store 分发记录。
|
||||||
|
/// </summary>
|
||||||
|
public StoreDispatchRecord<TState> DispatchRecord { get; }
|
||||||
|
}
|
||||||
394
GFramework.Core/StateManagement/StoreSelection.cs
Normal file
394
GFramework.Core/StateManagement/StoreSelection.cs
Normal 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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
GFramework.Core/StateManagement/StoreStateChangedEvent.cs
Normal file
30
GFramework.Core/StateManagement/StoreStateChangedEvent.cs
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
namespace GFramework.Core.StateManagement;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 表示一条由 Store 状态变更桥接到 EventBus 的事件。
|
||||||
|
/// 该事件会复用 Store 对订阅通知的折叠语义,因此在批处理中只会发布最终状态。
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TState">状态树的根状态类型。</typeparam>
|
||||||
|
public sealed class StoreStateChangedEvent<TState>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个新的 Store 状态变更桥接事件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="state">最新状态快照。</param>
|
||||||
|
/// <param name="changedAt">状态变更时间。</param>
|
||||||
|
public StoreStateChangedEvent(TState state, DateTimeOffset changedAt)
|
||||||
|
{
|
||||||
|
State = state;
|
||||||
|
ChangedAt = changedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取最新状态快照。
|
||||||
|
/// </summary>
|
||||||
|
public TState State { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取该状态对外广播的时间。
|
||||||
|
/// </summary>
|
||||||
|
public DateTimeOffset ChangedAt { get; }
|
||||||
|
}
|
||||||
@ -21,12 +21,6 @@ namespace GFramework.Game.Abstractions.Scene;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ISceneBehavior : IRoute
|
public interface ISceneBehavior : IRoute
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// 获取场景的唯一标识符。
|
|
||||||
/// 用于区分不同的场景实例。
|
|
||||||
/// </summary>
|
|
||||||
string Key { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取场景的原始对象。
|
/// 获取场景的原始对象。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -31,8 +31,9 @@ public interface ISceneRouteGuard : IRouteGuard<ISceneBehavior>
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 异步检查是否允许离开指定场景。
|
/// 异步检查是否允许离开指定场景。
|
||||||
|
/// 该成员显式细化了通用路由守卫的离开检查,使场景守卫在 API 文档中保持场景语义。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sceneKey">当前场景的唯一标识符。</param>
|
/// <param name="sceneKey">当前场景的唯一标识符。</param>
|
||||||
/// <returns>如果允许离开则返回 true,否则返回 false。</returns>
|
/// <returns>如果允许离开则返回 true,否则返回 false。</returns>
|
||||||
ValueTask<bool> CanLeaveAsync(string sceneKey);
|
new ValueTask<bool> CanLeaveAsync(string sceneKey);
|
||||||
}
|
}
|
||||||
@ -46,14 +46,6 @@ public interface IUiPageBehavior : IRoute
|
|||||||
/// <returns>页面视图实例。</returns>
|
/// <returns>页面视图实例。</returns>
|
||||||
object View { get; }
|
object View { get; }
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 获取键值
|
|
||||||
/// </summary>
|
|
||||||
/// <value>返回当前对象的键标识符</value>
|
|
||||||
string Key { get; }
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取页面是否处于活动状态
|
/// 获取页面是否处于活动状态
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@ -17,9 +17,10 @@ public interface IUiRouteGuard : IRouteGuard<IUiPageBehavior>
|
|||||||
ValueTask<bool> CanEnterAsync(string uiKey, IUiPageEnterParam? param);
|
ValueTask<bool> CanEnterAsync(string uiKey, IUiPageEnterParam? param);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 离开UI前的检查
|
/// 离开UI前的检查。
|
||||||
|
/// 该成员显式细化了通用路由守卫的离开检查,使 UI 守卫在 API 文档中保持 UI 语义。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="uiKey">当前UI标识符</param>
|
/// <param name="uiKey">当前UI标识符</param>
|
||||||
/// <returns>true表示允许离开,false表示拦截</returns>
|
/// <returns>true表示允许离开,false表示拦截</returns>
|
||||||
ValueTask<bool> CanLeaveAsync(string uiKey);
|
new ValueTask<bool> CanLeaveAsync(string uiKey);
|
||||||
}
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.1</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs
Normal file
18
GFramework.Godot.SourceGenerators.Tests/GlobalUsings.cs
Normal 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;
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
; Shipped analyzer releases
|
||||||
|
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
|
||||||
|
|
||||||
@ -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
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PackageId>GeWuYou.$(AssemblyName)</PackageId>
|
<PackageId>GeWuYou.$(AssemblyName)</PackageId>
|
||||||
<TargetFramework>netstandard2.1</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
|
||||||
<!-- 这是 Analyzer,不是运行时库 -->
|
<!-- 这是 Analyzer,不是运行时库 -->
|
||||||
<IsRoslynAnalyzer>true</IsRoslynAnalyzer>
|
<IsRoslynAnalyzer>true</IsRoslynAnalyzer>
|
||||||
@ -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>
|
||||||
|
|||||||
568
GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
Normal file
568
GFramework.Godot.SourceGenerators/GetNodeGenerator.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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`
|
||||||
|
|||||||
@ -6,6 +6,9 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<!-- Godot.SourceGenerators expects this property from Godot.NET.Sdk.
|
||||||
|
Provide a safe default so source generators can run in plain SDK-style builds as well. -->
|
||||||
|
<GodotProjectDir Condition="'$(GodotProjectDir)' == ''">$(MSBuildProjectDirectory)</GodotProjectDir>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
namespace GFramework.SourceGenerators.Abstractions.Bases;
|
namespace GFramework.SourceGenerators.Abstractions.Bases;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 标记类的优先级,自动生成 <see cref="GFramework.Core.Abstractions.Bases.IPrioritized"/> 接口实现
|
/// 标记类的优先级,自动生成 <c>GFramework.Core.Abstractions.Bases.IPrioritized</c> 接口实现。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// 使用此特性可以避免手动实现 IPrioritized 接口。
|
/// 使用此特性可以避免手动实现 IPrioritized 接口。
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.1</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@ -14,9 +14,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="GFramework.SourceGenerators.Abstractions"/>
|
<Using Include="GFramework.SourceGenerators.Abstractions"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj" PrivateAssets="all"/>
|
|
||||||
</ItemGroup>
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Update="Meziantou.Analyzer" Version="3.0.25">
|
<PackageReference Update="Meziantou.Analyzer" Version="3.0.25">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
<!-- import parent: https://docs.microsoft.com/en-us/visualstudio/msbuild/customize-your-build -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>netstandard2.1</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@ -56,7 +56,7 @@ public sealed class PriorityGenerator : MetadataAttributeClassGeneratorBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. 必须是 partial
|
// 3. 必须是 partial
|
||||||
if (syntax.Modifiers.All(m => m.Kind() != SyntaxKind.PartialKeyword))
|
if (syntax.Modifiers.All(m => !m.IsKind(SyntaxKind.PartialKeyword)))
|
||||||
{
|
{
|
||||||
context.ReportDiagnostic(Diagnostic.Create(
|
context.ReportDiagnostic(Diagnostic.Create(
|
||||||
PriorityDiagnostic.MustBePartial,
|
PriorityDiagnostic.MustBePartial,
|
||||||
|
|||||||
@ -19,7 +19,7 @@ internal static class PriorityDiagnostic
|
|||||||
category: Category,
|
category: Category,
|
||||||
defaultSeverity: DiagnosticSeverity.Error,
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
isEnabledByDefault: true,
|
isEnabledByDefault: true,
|
||||||
description: "Priority 特性设计用于类级别的优先级标记,不支持其他类型。"
|
description: "Priority 特性设计用于类级别的优先级标记,不支持其他类型."
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -32,7 +32,7 @@ internal static class PriorityDiagnostic
|
|||||||
category: Category,
|
category: Category,
|
||||||
defaultSeverity: DiagnosticSeverity.Warning,
|
defaultSeverity: DiagnosticSeverity.Warning,
|
||||||
isEnabledByDefault: true,
|
isEnabledByDefault: true,
|
||||||
description: "当类已经手动实现 IPrioritized 接口时,源生成器将跳过代码生成以避免冲突。"
|
description: "当类已经手动实现 IPrioritized 接口时,源生成器将跳过代码生成以避免冲突."
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -45,7 +45,7 @@ internal static class PriorityDiagnostic
|
|||||||
category: Category,
|
category: Category,
|
||||||
defaultSeverity: DiagnosticSeverity.Error,
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
isEnabledByDefault: true,
|
isEnabledByDefault: true,
|
||||||
description: "源生成器需要在 partial 类中生成 IPrioritized 接口实现。"
|
description: "源生成器需要在 partial 类中生成 IPrioritized 接口实现."
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -58,7 +58,7 @@ internal static class PriorityDiagnostic
|
|||||||
category: Category,
|
category: Category,
|
||||||
defaultSeverity: DiagnosticSeverity.Error,
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
isEnabledByDefault: true,
|
isEnabledByDefault: true,
|
||||||
description: "Priority 特性必须提供一个有效的整数值。"
|
description: "Priority 特性必须提供一个有效的整数值."
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -71,7 +71,7 @@ internal static class PriorityDiagnostic
|
|||||||
category: Category,
|
category: Category,
|
||||||
defaultSeverity: DiagnosticSeverity.Error,
|
defaultSeverity: DiagnosticSeverity.Error,
|
||||||
isEnabledByDefault: true,
|
isEnabledByDefault: true,
|
||||||
description: "Priority 特性仅支持顶层类,不支持嵌套类。请将嵌套类移至命名空间级别。"
|
description: "Priority 特性仅支持顶层类,不支持嵌套类.请将嵌套类移至命名空间级别."
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -84,6 +84,6 @@ internal static class PriorityDiagnostic
|
|||||||
category: "GFramework.Usage",
|
category: "GFramework.Usage",
|
||||||
defaultSeverity: DiagnosticSeverity.Info,
|
defaultSeverity: DiagnosticSeverity.Info,
|
||||||
isEnabledByDefault: true,
|
isEnabledByDefault: true,
|
||||||
description: "当获取实现了 IPrioritized 接口的服务时,应使用 GetAllByPriority 方法以确保按优先级排序。"
|
description: "当获取实现了 IPrioritized 接口的服务时,应使用 GetAllByPriority 方法以确保按优先级排序."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PackageId>GeWuYou.$(AssemblyName)</PackageId>
|
<PackageId>GeWuYou.$(AssemblyName)</PackageId>
|
||||||
<TargetFramework>netstandard2.1</TargetFramework>
|
<TargetFramework>netstandard2.0</TargetFramework>
|
||||||
|
|
||||||
<!-- 这是 Analyzer,不是运行时库 -->
|
<!-- 这是 Analyzer,不是运行时库 -->
|
||||||
<IsRoslynAnalyzer>true</IsRoslynAnalyzer>
|
<IsRoslynAnalyzer>true</IsRoslynAnalyzer>
|
||||||
@ -30,7 +30,6 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\$(AssemblyName).Abstractions\$(AssemblyName).Abstractions.csproj" PrivateAssets="all"/>
|
<ProjectReference Include="..\$(AssemblyName).Abstractions\$(AssemblyName).Abstractions.csproj" PrivateAssets="all"/>
|
||||||
<ProjectReference Include="..\$(AssemblyName).Common\$(AssemblyName).Common.csproj" PrivateAssets="all"/>
|
<ProjectReference Include="..\$(AssemblyName).Common\$(AssemblyName).Common.csproj" PrivateAssets="all"/>
|
||||||
<ProjectReference Include="..\GFramework.Core.Abstractions\GFramework.Core.Abstractions.csproj" PrivateAssets="all"/>
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<!-- ★关键:只把 Generator DLL 放进 analyzers -->
|
<!-- ★关键:只把 Generator DLL 放进 analyzers -->
|
||||||
|
|||||||
@ -16,11 +16,16 @@
|
|||||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||||
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
|
||||||
<IncludeBuildOutput>false</IncludeBuildOutput>
|
<IncludeBuildOutput>false</IncludeBuildOutput>
|
||||||
|
<!-- This package is a pure meta-package that only aggregates dependencies. -->
|
||||||
|
<NoPackageAnalysis>false</NoPackageAnalysis>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<!-- 排除不需要参与打包/编译的目录 -->
|
<!-- 排除不需要参与打包/编译的目录 -->
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<None Include="README.md" Pack="true" PackagePath=""/>
|
<None Include="README.md" Pack="true" PackagePath=""/>
|
||||||
|
<None Include="packaging/_._" Pack="true" PackagePath="lib/net8.0/_._"/>
|
||||||
|
<None Include="packaging/_._" Pack="true" PackagePath="lib/net9.0/_._"/>
|
||||||
|
<None Include="packaging/_._" Pack="true" PackagePath="lib/net10.0/_._"/>
|
||||||
<None Remove="GFramework.Core\**"/>
|
<None Remove="GFramework.Core\**"/>
|
||||||
<None Remove="GFramework.Game\**"/>
|
<None Remove="GFramework.Game\**"/>
|
||||||
<None Remove="GFramework.Godot\**"/>
|
<None Remove="GFramework.Godot\**"/>
|
||||||
@ -41,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\**"/>
|
||||||
@ -80,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\**"/>
|
||||||
@ -105,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\**"/>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
/// 页面行为实例的私有字段
|
/// 页面行为实例的私有字段
|
||||||
|
|||||||
@ -228,4 +228,3 @@ architecture.RegisterLifecycleHook(new MyLifecycleHook());
|
|||||||
**相关文档**:
|
**相关文档**:
|
||||||
|
|
||||||
- [核心框架概述](./index.md)
|
- [核心框架概述](./index.md)
|
||||||
- [ADR-001: 拆分 Architecture 核心类](/docs/adr/001-split-architecture-class.md)
|
|
||||||
|
|||||||
@ -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 - 命令总线
|
||||||
|
|
||||||
### 功能说明
|
### 功能说明
|
||||||
|
|||||||
@ -105,7 +105,7 @@ Any → FailedInitialization
|
|||||||
- 初始化/销毁 - Utility 注册
|
- 初始化/销毁 - Utility 注册
|
||||||
```
|
```
|
||||||
|
|
||||||
这种设计遵循单一职责原则,使代码更易维护和测试。详见 [ADR-001](/docs/adr/001-split-architecture-class.md)。
|
这种设计遵循单一职责原则,使代码更易维护和测试。
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
@ -398,14 +398,14 @@ public class PlayerController : IController
|
|||||||
4. **易于扩展**: 添加新功能更容易
|
4. **易于扩展**: 添加新功能更容易
|
||||||
5. **代码安全**: 消除了 `null!` 断言,所有字段在构造后立即可用
|
5. **代码安全**: 消除了 `null!` 断言,所有字段在构造后立即可用
|
||||||
|
|
||||||
详细的设计决策请参考 [ADR-001: 拆分 Architecture 核心类](/docs/adr/001-split-architecture-class.md)。
|
详细的设计决策已在架构实现重构中落地。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 包说明
|
## 包说明
|
||||||
|
|
||||||
| 包名 | 职责 | 文档 |
|
| 包名 | 职责 | 文档 |
|
||||||
|------------------|-----------------|----------------------|
|
|----------------------|-----------------|--------------------------|
|
||||||
| **architecture** | 架构核心,管理所有组件生命周期 | [查看](./architecture) |
|
| **architecture** | 架构核心,管理所有组件生命周期 | [查看](./architecture) |
|
||||||
| **constants** | 框架常量定义 | 本文档 |
|
| **constants** | 框架常量定义 | 本文档 |
|
||||||
| **model** | 数据模型层,存储状态 | [查看](./model) |
|
| **model** | 数据模型层,存储状态 | [查看](./model) |
|
||||||
@ -416,6 +416,7 @@ public class PlayerController : IController
|
|||||||
| **query** | 查询模式,封装读操作 | [查看](./query) |
|
| **query** | 查询模式,封装读操作 | [查看](./query) |
|
||||||
| **events** | 事件系统,组件间通信 | [查看](./events) |
|
| **events** | 事件系统,组件间通信 | [查看](./events) |
|
||||||
| **property** | 可绑定属性,响应式编程 | [查看](./property) |
|
| **property** | 可绑定属性,响应式编程 | [查看](./property) |
|
||||||
|
| **state-management** | 集中式状态容器与选择器 | [查看](./state-management) |
|
||||||
| **ioc** | IoC 容器,依赖注入 | [查看](./ioc) |
|
| **ioc** | IoC 容器,依赖注入 | [查看](./ioc) |
|
||||||
| **rule** | 规则接口,定义组件约束 | [查看](./rule) |
|
| **rule** | 规则接口,定义组件约束 | [查看](./rule) |
|
||||||
| **extensions** | 扩展方法,简化 API 调用 | [查看](./extensions) |
|
| **extensions** | 扩展方法,简化 API 调用 | [查看](./extensions) |
|
||||||
@ -654,4 +655,3 @@ public interface IController :
|
|||||||
|
|
||||||
**向后兼容**: 所有公共 API 保持不变,现有代码无需修改。
|
**向后兼容**: 所有公共 API 保持不变,现有代码无需修改。
|
||||||
|
|
||||||
详见 [ADR-001: 拆分 Architecture 核心类](/docs/adr/001-split-architecture-class.md)
|
|
||||||
@ -101,7 +101,7 @@ void RegisterHandler(IPauseHandler handler);
|
|||||||
void UnregisterHandler(IPauseHandler handler);
|
void UnregisterHandler(IPauseHandler handler);
|
||||||
|
|
||||||
// 状态变化事件
|
// 状态变化事件
|
||||||
event Action<PauseGroup, bool>? OnPauseStateChanged;
|
event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
|
||||||
```
|
```
|
||||||
|
|
||||||
## 基本用法
|
## 基本用法
|
||||||
@ -377,13 +377,13 @@ public partial class PauseIndicator : IController
|
|||||||
_pauseManager.OnPauseStateChanged += OnPauseStateChanged;
|
_pauseManager.OnPauseStateChanged += OnPauseStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPauseStateChanged(PauseGroup group, bool isPaused)
|
private void OnPauseStateChanged(object? sender, PauseStateChangedEventArgs e)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"暂停状态变化: 组={group}, 暂停={isPaused}");
|
Console.WriteLine($"暂停状态变化: 组={e.Group}, 暂停={e.IsPaused}");
|
||||||
|
|
||||||
if (group == PauseGroup.Global)
|
if (e.Group == PauseGroup.Global)
|
||||||
{
|
{
|
||||||
if (isPaused)
|
if (e.IsPaused)
|
||||||
{
|
{
|
||||||
ShowPauseIndicator();
|
ShowPauseIndicator();
|
||||||
}
|
}
|
||||||
@ -705,7 +705,7 @@ public partial class ProperCleanup : IController
|
|||||||
_pauseManager.OnPauseStateChanged -= OnPauseChanged;
|
_pauseManager.OnPauseStateChanged -= OnPauseChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPauseChanged(PauseGroup group, bool isPaused) { }
|
private void OnPauseChanged(object? sender, PauseStateChangedEventArgs e) { }
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -743,11 +743,11 @@ public partial class PauseMenu : Control
|
|||||||
|
|
||||||
// 方案 2: 监听暂停事件
|
// 方案 2: 监听暂停事件
|
||||||
var pauseManager = this.GetUtility<IPauseStackManager>();
|
var pauseManager = this.GetUtility<IPauseStackManager>();
|
||||||
pauseManager.OnPauseStateChanged += (group, isPaused) =>
|
pauseManager.OnPauseStateChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
if (group == PauseGroup.Global)
|
if (e.Group == PauseGroup.Global)
|
||||||
{
|
{
|
||||||
Visible = isPaused;
|
Visible = e.IsPaused;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -887,13 +887,13 @@ public class PauseEventBridge : AbstractSystem
|
|||||||
{
|
{
|
||||||
var pauseManager = this.GetUtility<IPauseStackManager>();
|
var pauseManager = this.GetUtility<IPauseStackManager>();
|
||||||
|
|
||||||
pauseManager.OnPauseStateChanged += (group, isPaused) =>
|
pauseManager.OnPauseStateChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
// 发送暂停事件
|
// 发送暂停事件
|
||||||
this.SendEvent(new GamePausedEvent
|
this.SendEvent(new GamePausedEvent
|
||||||
{
|
{
|
||||||
Group = group,
|
Group = e.Group,
|
||||||
IsPaused = isPaused
|
IsPaused = e.IsPaused
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) - 提供便捷的注销扩展方法
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
488
docs/zh-CN/core/state-management.md
Normal file
488
docs/zh-CN/core/state-management.md
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
# 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
|
||||||
|
- `RunInBatch()`:在一个批处理中合并多次状态通知
|
||||||
|
- `Undo()` / `Redo()`:基于历史缓冲区回退或前进状态
|
||||||
|
- `TimeTravelTo()`:跳转到指定历史索引
|
||||||
|
- `ClearHistory()`:以当前状态重置历史锚点
|
||||||
|
|
||||||
|
### 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 匹配(基类 / 接口)
|
||||||
|
- 只在状态真正变化时通知订阅者
|
||||||
|
- 基础诊断信息(最近一次 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()
|
||||||
|
.WithHistoryCapacity(32)
|
||||||
|
.AddReducer<DamageAction>((state, action) =>
|
||||||
|
state with { Health = Math.Max(0, state.Health - action.Amount) })
|
||||||
|
.Build(new PlayerState(100, "Player"));
|
||||||
|
```
|
||||||
|
|
||||||
|
适合以下场景:
|
||||||
|
|
||||||
|
- 模块启动时集中注册 reducer 和 middleware
|
||||||
|
- 测试里快速组装不同配置的 Store
|
||||||
|
- 不希望把 Store 的装配细节散落在多个调用点
|
||||||
|
|
||||||
|
如果需要扩展新语义,`StoreBuilder<TState>` 还支持:
|
||||||
|
|
||||||
|
- `WithHistoryCapacity(int)`:开启撤销 / 重做 / 时间旅行缓冲区
|
||||||
|
- `WithActionMatching(StoreActionMatchingMode)`:切换 reducer 的 action 匹配策略
|
||||||
|
|
||||||
|
## 历史记录、撤销 / 重做与时间旅行
|
||||||
|
|
||||||
|
当状态需要调试回放、工具面板查看或编辑器内撤销/重做时,可以开启历史缓冲区:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var store = new Store<PlayerState>(
|
||||||
|
new PlayerState(100, "Player"),
|
||||||
|
historyCapacity: 32);
|
||||||
|
|
||||||
|
store.Dispatch(new DamageAction(10));
|
||||||
|
store.Dispatch(new RenameAction("Knight"));
|
||||||
|
|
||||||
|
store.Undo();
|
||||||
|
store.Redo();
|
||||||
|
store.TimeTravelTo(0);
|
||||||
|
store.ClearHistory();
|
||||||
|
```
|
||||||
|
|
||||||
|
需要注意:
|
||||||
|
|
||||||
|
- `historyCapacity: 0` 表示关闭历史记录
|
||||||
|
- 历史只记录“状态真正变化”的 dispatch
|
||||||
|
- `Undo()` / `Redo()` / `TimeTravelTo()` 会更新当前状态并像普通状态变化一样通知订阅者
|
||||||
|
- 当你从历史中回退后再执行新的 `Dispatch()`,原来的 redo 分支会被裁掉
|
||||||
|
|
||||||
|
## 批处理通知折叠
|
||||||
|
|
||||||
|
如果一次业务操作会连续触发多个 action,但外部订阅者只需要看到最终状态,可以使用批处理:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
store.RunInBatch(() =>
|
||||||
|
{
|
||||||
|
store.Dispatch(new DamageAction(10));
|
||||||
|
store.Dispatch(new RenameAction("Knight"));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
批处理语义如下:
|
||||||
|
|
||||||
|
- 批处理内部每次 dispatch 仍会立即更新 Store 状态
|
||||||
|
- 订阅通知会延迟到最外层批处理结束后再统一发送一次
|
||||||
|
- 嵌套批处理是允许的,只有最外层结束时才会发通知
|
||||||
|
- 状态变化桥接到 `EventBus` 时,也会复用这个折叠语义
|
||||||
|
|
||||||
|
## 多态 action 匹配
|
||||||
|
|
||||||
|
默认情况下,Store 只匹配与 action 运行时类型完全一致的 reducer,这样最稳定,也最容易推导。
|
||||||
|
|
||||||
|
如果你的 action 体系确实依赖基类或接口复用,可以显式开启多态匹配:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var store = new Store<PlayerState>(
|
||||||
|
new PlayerState(100, "Player"),
|
||||||
|
actionMatchingMode: StoreActionMatchingMode.IncludeAssignableTypes);
|
||||||
|
```
|
||||||
|
|
||||||
|
启用后,reducer 的执行顺序保持确定性:
|
||||||
|
|
||||||
|
1. 精确类型 reducer
|
||||||
|
2. 最近的基类 reducer
|
||||||
|
3. 接口 reducer
|
||||||
|
|
||||||
|
只有在你明确需要这类复用关系时才建议启用;大多数业务状态仍建议继续使用默认的精确匹配模式。
|
||||||
|
|
||||||
|
## Store 到 EventBus 的兼容桥接
|
||||||
|
|
||||||
|
如果你在迁移旧模块时,现有逻辑仍然依赖 `EventBus`,可以临时把 Store 的 dispatch 和状态变化桥接过去:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using GFramework.Core.Events;
|
||||||
|
using GFramework.Core.Extensions;
|
||||||
|
|
||||||
|
var eventBus = new EventBus();
|
||||||
|
var bridge = store.BridgeToEventBus(eventBus);
|
||||||
|
```
|
||||||
|
|
||||||
|
桥接后会发送两类事件:
|
||||||
|
|
||||||
|
- `StoreDispatchedEvent<TState>`:每次 dispatch 都会发送一次,即使状态没有变化
|
||||||
|
- `StoreStateChangedEvent<TState>`:只在状态真正变化时发送;批处理中只发送最终状态
|
||||||
|
|
||||||
|
不再需要兼容层时,调用 `bridge.UnRegister()` 即可拆除桥接。
|
||||||
|
|
||||||
|
## 运行时临时注册与注销
|
||||||
|
|
||||||
|
如果某个 reducer 或 middleware 只需要在一段生命周期内生效,例如调试探针、临时玩法规则、
|
||||||
|
或场景级模块扩展,可以直接使用 `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
|
||||||
|
5. 默认优先使用精确类型 reducer 匹配;只有确有继承层次复用需求时再启用多态匹配
|
||||||
|
6. `EventBus` 桥接只建议作为迁移过渡层,新模块应优先直接依赖 Store
|
||||||
|
|
||||||
|
## 相关文档
|
||||||
|
|
||||||
|
- [`property`](./property) - 字段级响应式属性
|
||||||
|
- [`model`](./model) - Store 常见承载位置
|
||||||
|
- [`events`](./events) - 组件间事件通信
|
||||||
|
- [`state-machine-tutorial`](../tutorials/state-machine-tutorial) - 流程状态切换能力
|
||||||
@ -44,7 +44,7 @@ public interface IPauseStackManager : IContextUtility
|
|||||||
int GetPauseDepth(PauseGroup group = PauseGroup.Global);
|
int GetPauseDepth(PauseGroup group = PauseGroup.Global);
|
||||||
|
|
||||||
// 暂停状态变化事件
|
// 暂停状态变化事件
|
||||||
event Action<PauseGroup, bool>? OnPauseStateChanged;
|
event EventHandler<PauseStateChangedEventArgs>? OnPauseStateChanged;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -477,12 +477,12 @@ public partial class PauseIndicator : Label
|
|||||||
pauseManager.OnPauseStateChanged -= OnPauseStateChanged;
|
pauseManager.OnPauseStateChanged -= OnPauseStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPauseStateChanged(PauseGroup group, bool isPaused)
|
private void OnPauseStateChanged(object? sender, PauseStateChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (group == PauseGroup.Global)
|
if (e.Group == PauseGroup.Global)
|
||||||
{
|
{
|
||||||
Text = isPaused ? "游戏已暂停" : "游戏运行中";
|
Text = e.IsPaused ? "游戏已暂停" : "游戏运行中";
|
||||||
Visible = isPaused;
|
Visible = e.IsPaused;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -502,16 +502,16 @@ public partial class PauseDebugger : Node
|
|||||||
pauseManager.OnPauseStateChanged += OnPauseStateChanged;
|
pauseManager.OnPauseStateChanged += OnPauseStateChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnPauseStateChanged(PauseGroup group, bool isPaused)
|
private void OnPauseStateChanged(object? sender, PauseStateChangedEventArgs e)
|
||||||
{
|
{
|
||||||
var pauseManager = this.GetUtility<IPauseStackManager>();
|
var pauseManager = this.GetUtility<IPauseStackManager>();
|
||||||
|
|
||||||
GD.Print($"=== 暂停状态变化 ===");
|
GD.Print($"=== 暂停状态变化 ===");
|
||||||
GD.Print($"组: {group}");
|
GD.Print($"组: {e.Group}");
|
||||||
GD.Print($"状态: {(isPaused ? "暂停" : "恢复")}");
|
GD.Print($"状态: {(e.IsPaused ? "暂停" : "恢复")}");
|
||||||
GD.Print($"深度: {pauseManager.GetPauseDepth(group)}");
|
GD.Print($"深度: {pauseManager.GetPauseDepth(e.Group)}");
|
||||||
|
|
||||||
var reasons = pauseManager.GetPauseReasons(group);
|
var reasons = pauseManager.GetPauseReasons(e.Group);
|
||||||
if (reasons.Count > 0)
|
if (reasons.Count > 0)
|
||||||
{
|
{
|
||||||
GD.Print($"原因:");
|
GD.Print($"原因:");
|
||||||
@ -609,9 +609,9 @@ public partial class PauseDebugger : Node
|
|||||||
|
|
||||||
7. **使用事件监听暂停状态**:实现响应式 UI
|
7. **使用事件监听暂停状态**:实现响应式 UI
|
||||||
```csharp
|
```csharp
|
||||||
pauseManager.OnPauseStateChanged += (group, isPaused) =>
|
pauseManager.OnPauseStateChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
UpdateUI(isPaused);
|
UpdateUI(e.IsPaused);
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
1
packaging/_._
Normal file
1
packaging/_._
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user