GFramework/docs/zh-CN/core/state-management.md
GeWuYou 2c00070bb1 feat(state): 添加运行时临时注册与注销功能
- 实现 RegisterReducerHandle 和 RegisterMiddleware 方法,支持获取注销句柄
- 添加 IUnRegister 接口和 DefaultUnRegister 实现,提供精确注销能力
- 修改内部数据结构,使用 Registration 包装对象确保注销时的身份稳定性
- 实现中间件和 reducer 的快照机制,确保运行中注销不影响当前 dispatch
- 添加相关单元测试验证运行时注册注销的正确性
- 更新文档说明运行时临时注册与注销的使用方式和约束条件
2026-03-23 20:38:46 +08:00

12 KiB
Raw Blame History

State Management 包使用说明

概述

State Management 提供一个可选的集中式状态容器方案,用于补足 BindableProperty<T> 在复杂状态树场景下的能力。

当你的状态具有以下特征时,推荐使用 Store<TState>

  • 多个字段需要在一次业务操作中协同更新
  • 多个模块或 UI 片段共享同一聚合状态
  • 希望所有状态写入都经过统一的 action / reducer 入口
  • 需要对整棵状态树做局部选择和按片段订阅

这套能力不会替代现有 Property 机制,而是与其并存:

  • BindableProperty<T>:字段级响应式值
  • Store<TState>:聚合状态容器
  • StateMachine:流程状态切换

核心接口

IReadonlyStore<TState>

只读状态容器接口,提供:

  • State:读取当前状态快照
  • Subscribe():订阅状态变化
  • SubscribeWithInitValue():订阅并立即回放当前状态
  • UnSubscribe():取消订阅

IStore<TState>

在只读能力上增加:

  • Dispatch<TAction>():统一分发 action

IReducer<TState, TAction>

定义状态归约逻辑:

public interface IReducer<TState, in TAction>
{
    TState Reduce(TState currentState, TAction action);
}

IStateSelector<TState, TSelected>

从整棵状态树中投影局部视图,便于 UI 和 Controller 复用选择逻辑。

Store<TState>

Store<TState> 是默认实现,支持:

  • 初始状态快照
  • reducer 注册
  • middleware 分发管线
  • 只在状态真正变化时通知订阅者
  • 基础诊断信息(最近一次 action、最近一次分发记录、最近一次状态变化时间

基本示例

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 可以通过扩展方法把聚合状态投影成局部只读绑定视图:

using GFramework.Core.Extensions;

var healthSelection = store.Select(state => state.Health);

healthSelection.RegisterWithInitValue(health =>
{
    Console.WriteLine($"Current HP: {health}");
});

如果现有 UI 代码已经依赖 IReadonlyBindableProperty<T>,可以直接桥接:

IReadonlyBindableProperty<int> healthProperty =
    store.ToBindableProperty(state => state.Health);

在 Model 中使用

推荐把 Store 作为 Model 的内部状态容器,由 Model 暴露领域友好的业务方法:

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>

var store = (Store<PlayerState>)Store<PlayerState>
    .CreateBuilder()
    .AddReducer<DamageAction>((state, action) =>
        state with { Health = Math.Max(0, state.Health - action.Amount) })
    .Build(new PlayerState(100, "Player"));

适合以下场景:

  • 模块启动时集中注册 reducer 和 middleware
  • 测试里快速组装不同配置的 Store
  • 不希望把 Store 的装配细节散落在多个调用点

运行时临时注册与注销

如果某个 reducer 或 middleware 只需要在一段生命周期内生效,例如调试探针、临时玩法规则、 或场景级模块扩展,可以直接使用 Store<TState> 提供的句柄式注册 API

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

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

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 修改状态

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 中绑定局部状态

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

相关文档