第四章:斩断锁链——用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)和其核心武器“数据绑定”。在下一章,我们将看到,这场为了追求“终极优雅”的革命,是如何展开的。

Logo

这里是“一人公司”的成长家园。我们提供从产品曝光、技术变现到法律财税的全栈内容,并连接云服务、办公空间等稀缺资源,助你专注创造,无忧运营。

更多推荐