Skip to content

第 4 章:引入 Model 重构

在上一章中,我们实现了基础功能但也发现了一些设计问题。本章将通过引入 Model 层事件系统,重构我们的计数器应用。

理解 Model 的作用

在 GFramework 中,Model(模型) 负责:

存储应用状态:保存数据,如计数器的值
提供数据访问接口:通过方法暴露数据操作
发送状态变化事件:当数据改变时通知监听者

Model 不应该

❌ 包含 UI 逻辑(如更新 Label)
❌ 直接调用 Controller 方法
❌ 知道谁在使用它

创建 Model

1. 定义 Model 接口

scripts/model/ 创建 ICounterModel.cs

csharp
using GFramework.Core.Abstractions.model;

namespace MyGFrameworkGame.scripts.model;

/// <summary>
/// 计数器模型接口,定义计数器的基本操作
/// </summary>
public interface ICounterModel : IModel
{
    /// <summary>
    /// 获取当前计数器的值
    /// </summary>
    int Count { get; }

    /// <summary>
    /// 将计数器的值增加 1
    /// </summary>
    void Increment();

    /// <summary>
    /// 将计数器的值减少 1
    /// </summary>
    void Decrement();
}

为什么要定义接口?

使用接口的好处:

  • 依赖倒置:依赖抽象而不是具体实现
  • 易于测试:可以创建 Mock 实现
  • 灵活替换:可以切换不同的实现

这是 SOLID 原则中的 依赖倒置原则(DIP)

2. 实现 Model 类

scripts/model/ 创建 CounterModel.cs

csharp
using GFramework.Core.extensions;
using GFramework.Core.model;

namespace MyGFrameworkGame.scripts.model;

/// <summary>
/// 计数器模型实现
/// </summary>
public class CounterModel : AbstractModel, ICounterModel
{
    /// <summary>
    /// 获取当前计数器的值
    /// </summary>
    public int Count { get; private set; }

    /// <summary>
    /// 初始化 Model(可选)
    /// </summary>
    protected override void OnInit()
    {
        // 可以在这里初始化默认值或加载数据
    }

    /// <summary>
    /// 计数变化事件
    /// </summary>
    public sealed record ChangedCountEvent
    {
        public int Count { get; init; }
    }

    /// <summary>
    /// 增加计数器的值
    /// </summary>
    public void Increment()
    {
        Count++;
        // 发送事件通知监听者
        this.SendEvent(new ChangedCountEvent { Count = Count });
    }

    /// <summary>
    /// 减少计数器的值
    /// </summary>
    public void Decrement()
    {
        Count--;
        // 发送事件通知监听者
        this.SendEvent(new ChangedCountEvent { Count = Count });
    }
}

事件驱动设计

注意 IncrementDecrement 方法的最后一行:

csharp
this.SendEvent(new ChangedCountEvent { Count = Count });

这是 事件驱动架构 的核心:

  • Model 只负责改变状态并发送事件
  • 不关心谁在监听
  • 不直接调用 UI 更新

这实现了完全解耦!

3. 注册 Model

编辑 scripts/module/ModelModule.cs

csharp
using GFramework.Core.Abstractions.architecture;
using GFramework.Game.architecture;
using MyGFrameworkGame.scripts.model;

namespace MyGFrameworkGame.scripts.module;

/// <summary>
/// 模型模块,负责注册所有的数据模型
/// </summary>
public class ModelModule : AbstractModule
{
    /// <summary>
    /// 安装模型到架构中
    /// </summary>
    public override void Install(IArchitecture architecture)
    {
        // 注册 CounterModel 实例
        architecture.RegisterModel<ICounterModel>(new CounterModel());
    }
}

注意泛型参数

csharp
architecture.RegisterModel<ICounterModel>(new CounterModel());

第一个泛型参数 ICounterModel 是接口类型,用于后续获取时指定类型。

重构 Controller

1. 启用上下文感知

编辑 App.cs,添加 IController 接口和特性:

csharp
using GFramework.Core.Abstractions.controller;
using GFramework.Core.extensions;
using GFramework.SourceGenerators.Abstractions.rule;
using Godot;
using MyGFrameworkGame.scripts.model;

namespace MyGFrameworkGame.scripts.app;

/// <summary>
/// 计数器应用的主控制器
/// </summary>
[ContextAware]  // ← 启用上下文感知(由源码生成器处理)
public partial class App : Control, IController  // ← 实现 IController 接口
{
    // ... 其他代码
}

ContextAware 特性

[ContextAware] 特性由 源码生成器 处理,它会自动生成:

  • IArchitecture Architecture { get; } 属性
  • 依赖注入相关的辅助代码

这使得我们可以使用 this.GetModel() 等扩展方法。

2. 获取 Model 并监听事件

修改 _Ready 方法:

csharp
using GFramework.Core.Abstractions.controller;
using GFramework.Core.extensions;
using GFramework.SourceGenerators.Abstractions.rule;
using Godot;
using MyGFrameworkGame.scripts.model;

namespace MyGFrameworkGame.scripts.app;

[ContextAware]
public partial class App : Control, IController
{
    private Button AddButton => GetNode<Button>("%AddButton");
    private Button SubButton => GetNode<Button>("%SubButton");
    private Label Label => GetNode<Label>("%Label");

    private ICounterModel _counterModel = null!;

    public override void _Ready()
    {
        // 从架构中获取 Model
        _counterModel = this.GetModel<ICounterModel>()!;

        // 监听 Model 的事件
        this.RegisterEvent<CounterModel.ChangedCountEvent>(e =>
        {
            UpdateView(e.Count);
        });

        // 按钮点击时调用 Model 的方法
        AddButton.Pressed += () =>
        {
            _counterModel.Increment();
        };

        SubButton.Pressed += () =>
        {
            _counterModel.Decrement();
        };

        // 初始化界面
        UpdateView(_counterModel.Count);
    }

    /// <summary>
    /// 更新界面显示
    /// </summary>
    private void UpdateView(int count)
    {
        Label.Text = $"Count: {count}";
    }
}

3. 运行游戏

F5 运行游戏,测试功能:

运行效果

功能依然正常!但现在架构更清晰了。

对比重构前后

重构前

csharp
// ❌ 状态在 Controller 里
private int _count;

// ❌ UI 直接修改状态
AddButton.Pressed += () =>
{
    _count++;       // 修改状态
    UpdateView();   // 更新 UI
};

// ❌ 状态和 UI 耦合
private void UpdateView()
{
    Label.Text = $"Count: {_count}";
}

问题

  • 状态、逻辑、UI 混在一起
  • 无法复用状态
  • 无法单独测试

重构后

csharp
// ✅ 状态在 Model 里
private ICounterModel _counterModel;

// ✅ UI 只发起操作
AddButton.Pressed += () =>
{
    _counterModel.Increment();  // 调用 Model 方法
};

// ✅ 通过事件更新 UI
this.RegisterEvent<CounterModel.ChangedCountEvent>(e =>
{
    UpdateView(e.Count);  // 响应事件
});

优势

  • 关注点分离
  • Model 可复用、可测试
  • UI 只负责展示

理解事件驱动架构

数据流向

用户点击按钮

Controller: _counterModel.Increment()

Model: Count++

Model: SendEvent(ChangedCountEvent)

Controller: RegisterEvent<ChangedCountEvent>

Controller: UpdateView(e.Count)

界面更新

为什么不直接调用 UpdateView?

方式一:直接调用(❌ 不推荐)

csharp
public void Increment()
{
    Count++;
    _view.UpdateView(Count);  // ← Model 依赖 View
}

问题

  • Model 依赖 View,强耦合
  • Model 无法复用
  • 无法扩展(如果有多个监听者怎么办?)

方式二:事件驱动(✅ 推荐)

csharp
public void Increment()
{
    Count++;
    this.SendEvent(new ChangedCountEvent(Count));  // ← 发送事件
}

// View 订阅事件
this.RegisterEvent<ChangedCountEvent>(e => UpdateView(e.Count));

优势

  • Model 不知道 View 的存在
  • 可以有多个订阅者
  • 完全解耦

事件系统的威力

假设将来需要:

需求 1:多个界面显示计数器

csharp
// Scene A
this.RegisterEvent<ChangedCountEvent>(e => LabelA.Text = $"Count: {e.Count}");

// Scene B
this.RegisterEvent<ChangedCountEvent>(e => LabelB.Text = $"Count: {e.Count}");

无需修改 Model!

需求 2:计数变化时播放音效

csharp
this.RegisterEvent<ChangedCountEvent>(e =>
{
    if (e.Count > 0) PlaySound("increment.wav");
    else PlaySound("decrement.wav");
});

只需添加新的监听者!

解决的问题

回顾第 3 章提出的问题:

✅ 问题 1:View-Controller 耦合

解决方案

csharp
// 之前:UI 直接修改状态
_count++;
UpdateView();

// 现在:UI 通过 Model 操作
_counterModel.Increment();

// UI 通过事件更新
this.RegisterEvent<ChangedCountEvent>(e => UpdateView(e.Count));

现在的流程:Button → Model → Event → View,这是典型的 单向数据流

✅ 问题 2:Model 未抽象

解决方案

csharp
// 之前:状态在 Controller 里
private int _count;

// 现在:状态在独立的 Model
private ICounterModel _counterModel;
_counterModel = this.GetModel<ICounterModel>();

现在:

  • Model 可复用
  • Model 可替换(依赖接口)
  • Controller 不关心具体实现
  • 可以单独测试 Model

✅ 问题 3:缺乏上下文管理

解决方案

csharp
[ContextAware]
this.GetModel<ICounterModel>()
this.RegisterEvent<ChangedCountEvent>(...)

现在:

  • Model 由 Context 提供
  • 事件系统由框架管理
  • 生命周期由框架管理
  • 不需要手动 new Model
  • 不会出现跨 Controller 状态错乱

⚠️ 问题 4:可测试性(部分改善)

Model 层

csharp
// ✅ Model 现在完全可测试
var model = new CounterModel();
model.Increment();
Assert.AreEqual(1, model.Count);

Controller 层

csharp
// ⚠️ Controller 仍然依赖 Godot 节点
App : Control

Controller 仍然不能在纯单元测试中实例化,但这是合理的:

  • View 本来就不应该被单元测试
  • 业务逻辑已经在 Model,可以测试
  • Controller 只是桥梁

核心收获

通过这次重构,我们学到了:

概念解释
Model 的职责存储状态,提供接口,发送事件
事件驱动架构Model 不关心谁在监听,完全解耦
依赖注入通过 GetModel 获取依赖,而不是 new
单向数据流Button → Model → Event → View
关注点分离数据(Model)、逻辑(Controller)、展示(View)各司其职

下一步

虽然我们引入了 Model,但 Controller 仍然承担着 交互逻辑

csharp
AddButton.Pressed += () =>
{
    _counterModel.Increment();  // ← 这是交互逻辑
};

当功能复杂化时(如需要保存、验证、异步操作),这些逻辑会让 Controller 变得臃肿。

在下一章中,我们将引入 Command(命令)模式,进一步解耦 Controller。

👉 第 5 章:命令系统优化


本章检查清单
  • [ ] ICounterModel 接口已创建
  • [ ] CounterModel 实现已创建
  • [ ] Model 已注册到 ModelModule
  • [ ] App.cs 添加了 [ContextAware] 和 IController
  • [ ] 通过事件系统更新 UI
  • [ ] 运行游戏,功能正常
  • [ ] 理解了事件驱动架构的优势

思考题

  1. 如果要在 Model 中添加"最大值限制"功能,应该怎么做?
  2. 如果需要持久化计数器(保存到文件),应该在哪一层实现?
  3. 事件驱动和直接调用的主要区别是什么?

这些问题会在后续章节中继续探讨!

基于 Apache 2.0 许可证发布