适用于 Unity 的 MVP 架构
Source: Dev.to
Note: 这 不是 一本严格的 MVP 手册,也不是经典 MVP 在 Unity 中的一对一完美映射。这里展示的是一种适用于我们游戏的改编方案,它更像是一个拥有大量菜单和众多小游戏的应用程序。它可能完全适配你的项目,也可能不完全适配,但希望能为你提供有价值的思路。
产品
没有“一种架构统治一切”。
我们的应用 Magrid 是面向 3 – 10 岁儿童的学习解决方案(如果你有孩子,或者恰好是个 5 岁的小朋友,快去看看吧)。它旨在实现两个主要目标:
- 语言无关 – 世界各地的儿童都可以使用。
- 包容性 – 包含对有残障的儿童的支持。
技术视角
- 许多菜单(登录/注册、学生管理、绩效评审、课程管理等)
- 许多小游戏(模式识别、视觉感知、数字比较等)
主要的技术关注点是 关注点分离,既包括菜单逻辑,也包括小游戏本身。
架构概览
该架构包含两类实体:
- MVP 模块
- 系统
MVP 模块
一个 MVP 模块由 三个组件 构成:
| 组件 | 职责 |
|---|---|
View(继承自 MonoBehaviour) | 处理用户输入,显示数据,直接与 Presenter 交互 |
| Presenter(普通 C# 类) | 包含所有业务逻辑,从 Model 获取数据,仅通过接口更新 View |
| Model(普通 C# 类) | 存储模块所需的数据,为了清晰而独立分离 |
流程
- View 是 MVP 模块的入口点。
- View 创建其 Presenter(们)。
- 为了复用,一个 View 可以拥有多个 Presenter。
- View 将自身的接口传递给 Presenter。
- Presenter 在需要时创建其 Model。
- 模块准备就绪。
所有业务逻辑都位于 Presenter 中。设想将 View 替换为命令行实现——应用仍能正常工作,因为 Presenter 不使用 Unity API,仅通过接口进行通信。
简单代码示例
// View ---------------------------------------------------------
public class FooView : BaseScreen, IFooView
{
[SerializeField] private TMP_Text exposedInExpectorExample;
private FooPresenter _presenter;
private void Start()
{
_presenter = new FooPresenter(this);
}
public void SetName(string barName)
{
exposedInExpectorExample.text = barName;
}
}
// View interface ------------------------------------------------
public interface IFooView : BaseMvpView
{
void SetName(string barName);
}
// Presenter ----------------------------------------------------
public class FooPresenter : BaseMvpPresenter
{
private readonly IFooView _view;
private readonly FooModel _model;
public FooPresenter(IFooView view)
{
_view = view;
_model = new FooModel();
}
}
// Model ---------------------------------------------------------
public class FooModel : BaseMvpModel
{
public string BarName { get; set; }
}
说明
BaseScreen(View 的基类)是一个负责画布管理(打开、关闭等)的MonoBehaviour。BaseMvpView是一个通用接口,视图接口继承自它:
public interface BaseMvpView where T : BaseMvpPresenter { }
BaseMvpModel是一个简单接口:
public interface BaseMvpModel
{
int Version { get; }
}
Version 字段用于在更新后加载持久化数据时进行数据迁移。每个模块可以自行管理迁移逻辑。
系统
系统本质上是 没有 View 的 MVP 模块。它们的目的是为所有 MVP 模块提供服务。
- 所有系统由单个
MonoBehaviour(称为 AppManager)实例化,MVP 可以访问它(Unity 版单例)。 - 由于 IL2CPP 与 Zenject 的兼容性问题,我们实现了一个简易的依赖管理器。
特性
- 每个系统 公开一个接口。
- 所有系统都可以在测试中 被 mock。
- 系统在应用加载期间 完成初始化。
示例系统
| 系统 | 职责 |
|---|---|
| Profile System | 持久化数据存储 |
| API System | 与服务器通信 |
| Purchase System | 应用内购买 |
| Analytics System | 分析提供商及事件(GameAnalytics、Firebase 等) |
| UI System | 屏幕、弹窗、导航、返回操作 |
PlayInfo – 中介者
PlayInfo 处理 MVP 模块需要通信的少数特殊情况。它包含:
- 一组模块可以订阅的 events。
- 一些 shared variables。
最初它主要用于帮助从遗留代码库过渡,在那个代码库中所有东西都通过静态方法相互访问。PlayInfo 充当了重构后的 MVP 模块与遗留代码之间的中介。最终我们保留了一些事件,因为它们被证明非常有用。
测试策略
我们使用两种类型的测试:
- 单元测试 – 覆盖 Presenter 和系统。它们的依赖最少,便于进行 Mock。
- 集成 / UI 测试 – 模拟用户交互并测试多个模块的协同工作。可以使用
UnityTest和反射来模拟用户交互并检查 UI(这值得单独写篇文章)。
隔离
- 每个模块都通过 接口(以及少数情况下的 事件)实现完全隔离。
- 这使得重构/更改更加安全,并减少意外的副作用。
典型模块组成
- View
- View Interface
- Presenter
- Model
Unity 边缘情况
归根结底,这仍然是一个游戏。有时你需要使用 Unity 的 API,例如 协程(Coroutines) 或 物理(Physics)。
- 要启动协程,请使用
AppManager(MonoBehaviour 单例)。
// 示例:从 Presenter 启动协程
public class FooPresenter : BaseMvpPresenter
{
private readonly IFooView _view;
private readonly FooModel _model;
private readonly AppManager _appManager;
public FooPresenter(IFooView view, AppManager appManager)
{
_view = view;
_model = new FooModel();
_appManager = appManager;
}
public void DoSomethingAsync()
{
_appManager.StartCoroutine(DoWork());
}
private IEnumerator DoWork()
{
// 这里写 Unity‑specific 逻辑
yield return new WaitForSeconds(1f);
_view.SetName("Done");
}
}
其他 Unity‑specific 的需求(物理、动画事件等)也采用类似方式处理:Presenter 将调用委托给系统或 AppManager,从而保持 Presenter 本身不直接依赖 Unity。
TL;DR
- MVP 为我们提供可测试、解耦的 UI 逻辑。
- 系统(Systems) 提供共享服务,避免污染 View 层。
- PlayInfo 是用于旧式通信的轻量级中介。
- AppManager 是唯一需要
MonoBehaviour的 Unity 入口点。
欢迎根据自己的项目自行调整这些思路——目标是保持关注点分离、便于测试,同时在需要时仍能利用 Unity 强大的运行时特性。
在 MVP 架构中避免重复
这些情况对我们来说很少见。虽然我宁愿每天选择 可维护性 而不是 可复用性,但重复代码仍然会带来伤害。
我们解决此问题的方式
-
构建通用 UI 组件
示例:在多个视图中复用的 学生资料 UI 元素。- 无需重复相同的 UI 代码。
- 只需更改一次组件(或其预制件),即可更新所有使用它的屏幕。
-
将无状态逻辑放入工具类
示例:可以从任何模块调用的电子邮件验证正则表达式。 -
对每个视图使用多个 presenter 并注入适当的接口(接口分离)
示例:“联系支持”逻辑已经在 设置 中实现。 在 商店 界面中复用该 presenter,而不是重复代码。 -
当情况不符合上述两种情况时,将其视为系统
提供一个 MVP 模块可以使用的服务。
为什么 MVP(或 MVVM)适用于 Unity 项目
即使这些模式最初并非为游戏设计,它们在 菜单繁多、逻辑驱动的 Unity 项目 中表现出色。采用 MVP 为我们带来了:
- 可测试性 – presenter 可以在隔离环境下进行单元测试。
- 关注点分离 – UI、业务逻辑和数据被清晰划分。
- 可维护性 – 一层的更改很少会波及其他层。
有反馈吗?
如果你有问题或更好的改进想法,请告诉我。 :D