Unity UI架构的道与术:从“一团乱麻”到“井然有序”(4)
这是MVP模式的第一步,也是最关键的一步。我们必须先定义好View需要具备哪些“能力”。// ICharacterView.cs (接口文件)// 这个接口定义了View必须实现的所有功能// 事件:当用户点击升级按钮时,需要通知Presenter// 方法:Presenter可以通过这些方法来命令View更新显示// 甚至可以有更具体的行为设计亮点:接口只定义了“做什么”(What),而不关心“
第四章:斩断锁链——用MVP模式解放“视图”(View)
在第三章中,我们通过将Model提升为全局数据中心,成功地解耦了业务逻辑与UI表现。但如果我们仔细审视CharacterController的代码,会发现它依然不够“纯粹”。它不仅要处理业务逻辑(响应用户输入、操作Model),还要负责UI的更新(调用view.UpdateView(model))。它的一只脚踩在逻辑的泥土里,另一只脚却伸到了表现层的领地。
这种“跨界”行为,导致Controller与View之间形成了一种强依赖关系。更重要的是,由于View(作为MonoBehaviour)难以在测试环境中被模拟,导致对Controller的逻辑进行单元测试依然困难重重。
为了解决这个问题,架构思想继续演进,催生了MVC的一个重要变体:MVP(Model-View-Presenter)。MVP的核心目标只有一个:彻底斩断逻辑层与视图层之间的直接联系,让视图(View)变得极度“被动”和“愚蠢”,从而让逻辑层(Presenter)变得100%可测试。
1. MVP的核心思想:引入“接口”作为契约
MVP引入了一个新的角色——Presenter(主持人),来替代Controller。但它做的远不止是换个名字。MVP模式的精髓,在于它在View和Presenter之间,建立了一道不可逾越的“防火墙”——接口(Interface)。
-
View的职责:实现一个ICharacterView接口。这个接口定义了View所有可以被外部控制的行为(如:UpdateLevel(int level)、UpdateExp(float ratio)、ShowLevelUpEffect())。View自身不再有任何主动更新的逻辑。
-
Presenter的职责:持有对ICharacterView接口的引用,而不是对具体CharacterView类的引用。它通过调用接口方法来命令View更新。同时,它也持有对Model的引用,处理所有业务逻辑。
-
Model的职责:保持不变,依然是纯粹的数据中心。
2. 定义契约:ICharacterView接口
这是MVP模式的第一步,也是最关键的一步。我们必须先定义好View需要具备哪些“能力”。
// ICharacterView.cs (接口文件)
// 这个接口定义了View必须实现的所有功能
public interface ICharacterView
{
// 事件:当用户点击升级按钮时,需要通知Presenter
event System.Action OnLevelUpRequest;
// 方法:Presenter可以通过这些方法来命令View更新显示
void SetName(string name);
void SetLevel(int level);
void SetExperience(float ratio, int current, int max);
void SetAttack(int attack);
void SetDefense(int defense);
// 甚至可以有更具体的行为
void ShowLevelUpSuccess();
void ShowExpNotEnough();
}
设计亮点:
-
高度抽象:接口只定义了“做什么”(What),而不关心“怎么做”(How)。SetName具体是更新哪个Text组件,Presenter完全不关心,这是View自己的事。
-
行为驱动:接口方法不再是笼统的UpdateView,而是更具体、更原子化的行为指令,如SetLevel、SetAttack等。
3. 实现View:成为一个忠实的“傀儡”
现在,我们的CharacterView.cs需要实现这个ICharacterView接口。它将变得比MVC时代更加“被动”和“愚蠢”,像一个完全听从指令的傀儡
// CharacterView.cs (实现了ICharacterView)
using UnityEngine;
using UnityEngine.UI;
public class CharacterView : MonoBehaviour, ICharacterView
{
// 视图引用
public Text nameText;
public Text levelText;
public Slider expSlider;
public Text attackText;
public Text defenseText;
public Button levelUpButton;
// ... 可能还有特效、提示文本等引用
public event System.Action OnLevelUpRequest;
void Start()
{
levelUpButton.onClick.AddListener(() => {
OnLevelUpRequest?.Invoke();
});
}
// --- 实现接口定义的所有方法 ---
public void SetName(string name) { nameText.text = name; }
public void SetLevel(int level) { levelText.text = "Lv: " + level; }
public void SetExperience(float ratio, int current, int max) { expSlider.value = ratio; }
public void SetAttack(int attack) { attackText.text = "攻击力: " + attack; }
public void SetDefense(int defense) { defenseText.text = "防御力: " + defense; }
public void ShowLevelUpSuccess() { Debug.Log("View: 播放升级成功的特效!"); }
public void ShowExpNotEnough() { Debug.Log("View: 弹出提示:经验不足!"); }
}
改造亮点:
-
完全被动:CharacterView内部没有任何逻辑。它只是机械地实现接口里的方法。
-
与Model解耦:它甚至完全不知道CharacterModel的存在。它只认识最基本的数据类型,如int和string。
4. 定义Presenter:成为纯粹的“逻辑大脑”
最后,我们创建CharacterPresenter.cs。这将是我们应用的“逻辑大脑”,它不继承MonoBehaviour,是一个纯粹的C#类。
// CharacterPresenter.cs (纯C#类)
public class CharacterPresenter
{
private readonly ICharacterView view;
private readonly CharacterModel model;
// Presenter通过构造函数,接收对View接口和Model的引用
public CharacterPresenter(ICharacterView characterView, CharacterModel characterModel)
{
this.view = characterView;
this.model = characterModel;
// 建立联系
this.view.OnLevelUpRequest += HandleLevelUpRequest;
this.model.OnDataChanged += HandleDataChanged;
// 首次同步数据到View
HandleDataChanged();
}
private void HandleLevelUpRequest()
{
if (model.TryLevelUp())
{
// 升级成功,命令View播放成功效果
view.ShowLevelUpSuccess();
}
else
{
// 升级失败,命令View显示提示
view.ShowExpNotEnough();
}
}
private void HandleDataChanged()
{
// 当Model变化时,将数据逐一同步到View
view.SetName(model.Name);
view.SetLevel(model.Level);
view.SetExperience((float)model.CurrentExp / model.ExpToNextLevel, model.CurrentExp, model.ExpToNextLevel);
view.SetAttack(model.Attack);
view.SetDefense(model.Defense);
}
public void UnregisterEvents()
{
// 提供一个方法来解除事件绑定
this.view.OnLevelUpRequest -= HandleLevelUpRequest;
this.model.OnDataChanged -= HandleDataChanged;
}
}
改造亮点:
-
与Unity解耦:CharacterPresenter是一个纯C#类,不依赖任何Unity引擎的API。
-
面向接口编程:它只依赖于ICharacterView接口,而不知道具体的CharacterView实现。这使得我们可以轻易地替换View的实现(比如换一套UI皮肤,或者创建一个用于测试的“假”View)。
-
100%可测试:现在,我们可以编写单元测试,创建一个MockCharacterView(一个实现了ICharacterView接口的假类),然后将它和CharacterModel一起传入CharacterPresenter,从而对Presenter的所有逻辑进行全面的、自动化的测试。
那么谁来创建Presenter呢? 通常是一个负责组装的MonoBehaviour脚本,有时会直接写在CharacterView.cs中:
// 在CharacterView.cs中增加组装逻辑
public class CharacterView : MonoBehaviour, ICharacterView
{
private CharacterPresenter presenter;
// ...其他代码不变...
void Awake()
{
// View被创建时,主动创建Presenter并将自己和全局Model传进去
presenter = new CharacterPresenter(this, GameRoot.PlayerModel);
}
void OnDestroy()
{
presenter?.UnregisterEvents();
}
}
5. MVP的收益与新的思考
通过MVP模式的改造,我们彻底斩断了逻辑层(Presenter)和视图层(View)之间的强依赖关系。我们获得了前所未有的可测试性和灵活性。
然而,MVP也带来了新的“痛苦”:
-
接口的爆炸:对于一个复杂的UI,ICharacterView接口可能会变得非常庞大,包含几十个方法。
-
手动同步的繁琐:在HandleDataChanged方法中,我们依然需要手动地、逐一地调用view.SetXXX方法来同步数据。当Model的属性增加时,我们必须记得在这里也增加一行同步代码。这个过程枯燥且容易出错。
深度洞察:
MVP模式,是“关注点分离”思想的一次极致实践。它以“一切为了可测试性”为核心目标,通过引入接口这道“防火墙”,成功地将业务逻辑(Presenter)塑造成了一个独立、纯粹、与平台无关的“大脑”。这在大型、长期的、需要保证软件质量的项目中,其价值是无可估量的。
但是,它手动同步数据的方式,依然不够“优雅”。开发者们开始思考:有没有一种方法,可以让我们只关心数据的变化,而让UI的同步过程完全自动化?
这个问题,直接催生了现代UI架构的终极形态——MVVM(Model-View-ViewModel)和其核心武器“数据绑定”。在下一章,我们将看到,这场为了追求“终极优雅”的革命,是如何展开的。
更多推荐



所有评论(0)