☰
Unity UI架构演进:从MVC到MVP的工程实践与设计模式解析
2026/10/15 3:13:03 网站建设 项目流程

1. 从一次重构说起:为什么你的Unity项目需要MVP

去年我接手了一个已经迭代了快两年的Unity手游项目,代码量不算特别大,大概六万多行,但每次加一个新功能,我都要花大量时间去理清UI按钮的回调到底散落在多少个脚本里。最夸张的一次,一个“领取奖励”的按钮,点击之后触发了七个不同脚本里的方法,其中三个还互相持有引用,改一处崩三处。那段时间我几乎每天都在做同一件事:在几百个MonoBehaviour里翻找某个Text组件到底是在哪里被赋值的。

这个场景我相信很多Unity开发者都经历过。Unity本身是一个非常自由的引擎,它不强制你用什么架构,GameObject和Component的组合方式让快速原型变得极其方便,但也正因为这种自由,项目一旦超过某个规模,代码就会迅速腐化成一团乱麻。UI逻辑、数据逻辑、网络请求、动画控制全部塞在一个MonoBehaviour里,测试无从下手,协作效率断崖式下跌。

MVP(Model-View-Presenter)就是解决这个问题的一套经典方案。它不是Unity独有的,但在Unity的UI开发场景里特别适用。核心思路很简单:把“数据长什么样”“界面怎么显示”“用户操作之后怎么处理”这三件事拆成三个角色,各管各的,互不越界。Model管数据和业务规则,View管显示和交互反馈,Presenter管协调——从Model拿数据给View,从View接操作调Model。

这篇文章不会只给你讲概念。我会从实际项目出发,把MVP在Unity里的落地方式、代码组织、常见坑点、以及它和MVC、MVVM的区别都讲清楚。如果你正在被UI逻辑混乱困扰,或者想给团队引入一套可持续的架构规范,那接下来的内容应该能帮你少走不少弯路。

2. 拆开看:Model、View、Presenter各自到底管什么

2.1 Model不是“数据类”,而是业务规则的守门人

很多人第一次接触MVP的时候,会把Model简单理解成一个struct或者class,里面放几个字段就完事了。比如做一个背包系统,Model就是List<Item>。这其实只对了一半。

Model的核心职责是承载业务逻辑和数据状态,它不应该知道UI的存在,也不应该关心数据是怎么显示的。举个例子,背包里有一件装备,它的属性是攻击力加50,但角色当前已经装备了一件攻击力加30的武器,那么最终攻击力是多少?这个计算逻辑应该放在Model里,而不是在UI脚本里写一个if-else。

我在实际项目中会把Model进一步拆成两层:数据实体和业务服务。数据实体就是纯粹的数据结构,比如PlayerData、ItemData;业务服务则是操作这些数据的逻辑,比如InventoryService、CombatService。这样做的好处是,业务逻辑可以脱离Unity引擎独立运行,单元测试写起来非常轻松——你不需要启动Unity,直接用NUnit跑测试就行。

// 数据实体:纯粹的数据结构 public class ItemData { public int Id; public string Name; public int AttackBonus; public int DefenseBonus; } // 业务服务:操作数据的逻辑 public class InventoryService { private List<ItemData> _items = new List<ItemData>(); private ItemData _equippedWeapon; public int GetTotalAttack() { int baseAttack = 10; int weaponBonus = _equippedWeapon?.AttackBonus ?? 0; return baseAttack + weaponBonus; } public void EquipWeapon(ItemData item) { if (item == null) return; _equippedWeapon = item; } }

注意看GetTotalAttack这个方法,它完全不依赖任何Unity的API,纯C#逻辑。这意味着你可以在任何环境下测试它,也意味着当策划说“攻击力计算公式要改”的时候,你只需要改这一个地方,不用去翻UI脚本。

2.2 View的边界感:只做显示,不做决策

View在Unity里通常就是一个MonoBehaviour,挂在Canvas或者某个UI预制体上。它的职责非常明确:把Presenter给它的数据展示出来,把用户的交互事件转发给Presenter。

我见过最常见的错误是View“越权”了。比如一个按钮点击之后,View自己判断了一下“如果金币足够就扣钱”,然后直接改了Model的数据。这就破坏了MVP的分层——View不应该做任何业务决策,它只负责“按钮被点了,告诉Presenter”,至于点了之后是扣钱还是弹提示,那是Presenter和Model的事。

一个规范的View应该长这样:

public class ShopView : MonoBehaviour { [SerializeField] private Text _goldText; [SerializeField] private Button _buyButton; [SerializeField] private Text _feedbackText; private ShopPresenter _presenter; public void SetPresenter(ShopPresenter presenter) { _presenter = presenter; _buyButton.onClick.AddListener(OnBuyClicked); } public void UpdateGoldDisplay(int gold) { _goldText.text = $"金币: {gold}"; } public void ShowFeedback(string message) { _feedbackText.text = message; } private void OnBuyClicked() { _presenter.OnBuyButtonClicked(); } }

这里有几个细节值得注意。第一,View持有Presenter的引用,但Presenter不直接持有View的具体类型,而是通过接口通信(后面会讲)。第二,UpdateGoldDisplay和ShowFeedback都是被动的,由Presenter调用,View自己不主动去查数据。第三,按钮的点击事件只是简单转发,没有任何条件判断。

注意:View里绝对不要出现FindObjectOfType、GetComponent去主动找Model的情况。所有数据流动都应该经过Presenter。

2.3 Presenter是胶水层,但别让它变成“上帝类”

Presenter是MVP里最灵活也最容易写歪的部分。它的核心工作是:响应View的事件,调用Model的业务逻辑,然后把结果转换成View能显示的格式推给View。

但这里有一个陷阱。很多开发者写着写着,就把所有逻辑都堆到Presenter里了——网络请求、数据转换、UI动画触发、音效播放,全塞进去。结果Presenter变成了一个新的“上帝类”,只是把原来MonoBehaviour里的混乱转移到了另一个地方。

我的经验是给Presenter划一条线:它只做协调和格式转换,不做具体实现。网络请求应该封装在独立的Service里,Presenter调用Service的接口;UI动画应该由View自己处理,Presenter只告诉View“该播放什么动画”;音效同理。

public class ShopPresenter { private IShopView _view; private InventoryService _inventoryService; private CurrencyService _currencyService; public ShopPresenter(IShopView view, InventoryService inventory, CurrencyService currency) { _view = view; _inventoryService = inventory; _currencyService = currency; } public void Initialize() { RefreshView(); } public void OnBuyButtonClicked() { int price = 100; if (!_currencyService.HasEnoughGold(price)) { _view.ShowFeedback("金币不足"); return; } _currencyService.SpendGold(price); _inventoryService.AddItem(new ItemData { Id = 1, Name = "长剑", AttackBonus = 50 }); _view.ShowFeedback("购买成功"); RefreshView(); } private void RefreshView() { _view.UpdateGoldDisplay(_currencyService.GetGold()); } }

注意IShopView这个接口。Presenter不直接依赖具体的ShopView类,而是依赖一个接口。这样做的好处是,你可以在测试的时候用一个假的View实现来验证Presenter的逻辑,不需要真的创建UI。

3. 在Unity里把MVP跑起来:从接口定义到场景装配

3.1 先定接口,再写实现

我强烈建议在动手写任何具体类之前,先把View的接口定义好。接口就是Presenter和View之间的契约,它规定了“View能做什么”。定义接口的过程,其实就是在梳理这个界面到底需要展示哪些信息、响应哪些操作。

以登录界面为例,先想清楚:需要输入账号密码、需要点击登录按钮、需要显示错误提示、需要显示加载状态。那么接口就出来了:

public interface ILoginView { string GetUsername(); string GetPassword(); void ShowError(string message); void SetLoading(bool isLoading); void OnLoginSuccess(); }

这个接口定义好之后,Presenter的编写就变得非常清晰——它只需要调用这些方法,不需要关心UI是用UGUI还是UI Toolkit做的,也不需要关心输入框是InputField还是TMP_InputField。

3.2 Presenter的生命周期管理

Unity的MonoBehaviour有Awake、Start、OnDestroy这些生命周期回调,但Presenter是纯C#类,没有这些。所以你需要自己管理Presenter的创建和销毁。

我通常会在View的Awake或Start里创建Presenter,在OnDestroy里做清理:

public class LoginView : MonoBehaviour, ILoginView { [SerializeField] private InputField _usernameInput; [SerializeField] private InputField _passwordInput; [SerializeField] private Button _loginButton; [SerializeField] private Text _errorText; [SerializeField] private GameObject _loadingIndicator; private LoginPresenter _presenter; private void Awake() { _presenter = new LoginPresenter(this, new AuthService()); _loginButton.onClick.AddListener(() => _presenter.OnLoginClicked()); } private void OnDestroy() { _loginButton.onClick.RemoveAllListeners(); _presenter?.Dispose(); } public string GetUsername() => _usernameInput.text; public string GetPassword() => _passwordInput.text; public void ShowError(string message) => _errorText.text = message; public void SetLoading(bool isLoading) => _loadingIndicator.SetActive(isLoading); public void OnLoginSuccess() => SceneManager.LoadScene("MainMenu"); }

这里有一个容易忽略的点:OnDestroy里一定要取消事件监听。Unity的Button.onClick是持久的,如果不手动移除,在场景切换或者对象销毁时可能会引发空引用异常。虽然Unity在对象销毁时会自动清理一部分,但显式移除是更稳妥的做法。

3.3 依赖注入:别在View里直接new Service

上面的代码里我写了new AuthService(),这在小型项目里没问题,但在中大型项目里会带来麻烦——Service可能依赖其他Service,或者需要配置参数,直接在View里new会导致依赖关系散落各处。

更好的做法是用一个简单的依赖注入容器,或者至少用一个工厂来统一管理。Unity社区有几个轻量级的DI方案,比如VContainer、Zenject,但对于大多数项目来说,自己写一个简单的ServiceLocator就够了:

public static class ServiceLocator { private static readonly Dictionary<Type, object> _services = new Dictionary<Type, object>(); public static void Register<T>(T service) { _services[typeof(T)] = service; } public static T Get<T>() { return (T)_services[typeof(T)]; } }

在游戏启动的时候注册所有Service,View创建Presenter的时候从ServiceLocator里取:

var authService = ServiceLocator.Get<AuthService>(); _presenter = new LoginPresenter(this, authService);

这样做的好处是,Service的生命周期和游戏进程一致,不会因为场景切换而丢失状态。而且测试的时候可以轻松替换成Mock实现。

4. 那些年我踩过的MVP坑:从内存泄漏到过度设计

4.1 事件订阅忘记取消,导致的内存泄漏

这是MVP在Unity里最常见的问题,没有之一。Presenter通常会订阅Model的事件(比如数据变化通知),或者View会订阅Presenter的事件。如果忘记在销毁时取消订阅,被订阅的对象就会一直持有引用,GC无法回收。

我遇到过一个典型案例:一个战斗结算界面,每次打开都会创建一个新的Presenter,Presenter订阅了CombatService.OnDamageCalculated事件。玩家反复打开关闭这个界面几十次之后,内存里堆积了几十个Presenter实例,每个都在响应伤害计算事件,导致结算数字翻倍显示。

解决方案很简单,让Presenter实现IDisposable接口,在Dispose里统一取消所有订阅:

public class CombatResultPresenter : IDisposable { private CombatService _combatService; public CombatResultPresenter(CombatService service) { _combatService = service; _combatService.OnDamageCalculated += HandleDamageCalculated; } public void Dispose() { _combatService.OnDamageCalculated -= HandleDamageCalculated; } private void HandleDamageCalculated(int damage) { // 处理逻辑 } }

然后在View的OnDestroy里调用_presenter.Dispose()。这个模式我建议在所有Presenter里都强制执行,哪怕当前没有订阅任何事件,留着以后加也方便。

4.2 Presenter里直接引用UnityEngine API

前面提过,Presenter应该是纯C#类,不依赖Unity引擎。但实际写代码的时候,很容易顺手就用了Debug.Log、Time.deltaTime、PlayerPrefs这些API。一旦用了,Presenter就没法脱离Unity环境做单元测试了。

我的做法是,把所有需要Unity API的操作都抽象成接口。比如需要打日志,就定义一个ILogger接口;需要计时,就定义一个ITimer接口。虽然前期麻烦一点,但后期测试和移植的时候会轻松很多。

public interface ILogger { void Log(string message); } public interface ITimer { float DeltaTime { get; } }

然后在Unity层实现这两个接口,通过构造函数注入给Presenter。这样Presenter就完全和Unity解耦了。

4.3 过度设计:小项目别硬套MVP

MVP不是银弹。如果你的项目只有几个界面,逻辑也很简单,硬套MVP反而会增加大量样板代码。我见过一个只有三个界面的小工具,开发者写了十几个接口和二十多个类,最后维护成本比不用框架还高。

我的判断标准是:当UI逻辑复杂到“改一个按钮行为需要动三个以上文件”的时候,就该考虑MVP了。或者当团队超过两个人同时改UI代码,经常发生冲突的时候,MVP的价值就体现出来了。如果只是一个人做的小项目,怎么快怎么来,没必要为了架构而架构。

4.4 View和Presenter的通信方向搞反

MVP的标准通信方向是:View知道Presenter,Presenter知道View的接口,但View不知道Presenter的具体实现。有些开发者会反过来,让Presenter直接持有View的具体引用,然后在Presenter里写_view.GetComponent<Button>()这种代码。这就完全破坏了分层的意义。

还有一种常见错误是View直接调用Model。比如View里写_inventoryService.AddItem(...),跳过了Presenter。这会导致业务逻辑分散,后期很难追踪数据流向。记住一个原则:View只和Presenter说话,Model只和Presenter说话,View和Model之间永远不直接通信。

5. MVP、MVC、MVVM:在Unity里到底选哪个

5.1 三者的核心差异

这三个模式经常被混为一谈,但它们的通信方式和适用场景有明显区别。

模式通信方式View的职责适用场景
MVCController接收输入,操作Model和View被动显示,可观察ModelWeb应用、简单UI
MVPView转发事件给Presenter,Presenter操作Model和View被动显示,通过接口与Presenter通信复杂UI、需要单元测试
MVVMView通过数据绑定自动响应ViewModel主动绑定,双向同步数据驱动UI、WPF/前端

在Unity里,MVC的问题在于Controller和View的边界容易模糊,很多人写着写着就把逻辑塞回View了。MVVM需要一套数据绑定机制,Unity原生不支持,得靠第三方库(比如UniRx或者自己实现),学习成本较高。

MVP的优势在于边界清晰、易于测试。View和Presenter通过接口通信,Presenter可以完全脱离Unity做单元测试。对于大多数Unity项目来说,MVP是性价比最高的选择。

5.2 什么时候该考虑MVVM

如果你的项目UI状态特别多,而且状态之间有关联(比如一个输入框的内容变化会影响三个不同区域的显示),那MVVM的响应式绑定会更有优势。UniRx提供了ReactiveProperty,可以实现类似的效果:

public class PlayerViewModel { public ReactiveProperty<int> Gold = new ReactiveProperty<int>(0); public ReactiveProperty<int> Level = new ReactiveProperty<int>(1); }

View订阅这些ReactiveProperty,值一变UI自动更新。但这种模式需要团队对响应式编程有理解,否则容易写出难以调试的订阅链。

我的建议是:大多数Unity项目用MVP就够了。如果团队已经熟悉响应式编程,或者项目UI状态极其复杂,再考虑MVVM。不要为了追新技术而引入不必要的复杂度。

5.3 混合方案:MVP为主,局部MVVM

实际项目中,我经常采用混合方案。核心业务界面用MVP保证可测试性,一些纯展示性的、状态联动多的界面用MVVM简化代码。比如主菜单的金币显示、等级显示,用ReactiveProperty绑定就很方便;而背包、商店这种涉及交易逻辑的界面,用MVP把业务规则隔离在Presenter和Model里。

关键是团队要有一致的规范,不能一个人用MVP一个人用MVVM,否则代码风格混乱,维护成本反而更高。

6. 一个完整案例:用MVP重构背包系统

6.1 重构前的混乱现场

假设我们有一个背包界面,重构前的代码大概长这样:

public class BackpackPanel : MonoBehaviour { public Text goldText; public Transform itemGrid; public GameObject itemPrefab; public Button closeButton; private List<ItemData> items = new List<ItemData>(); void Start() { goldText.text = PlayerData.Gold.ToString(); RefreshItems(); closeButton.onClick.AddListener(() => gameObject.SetActive(false)); } void RefreshItems() { foreach (Transform child in itemGrid) Destroy(child.gameObject); foreach (var item in items) { var go = Instantiate(itemPrefab, itemGrid); go.GetComponent<Text>().text = item.Name; go.GetComponent<Button>().onClick.AddListener(() => { PlayerData.Gold -= 10; goldText.text = PlayerData.Gold.ToString(); // 更多逻辑... }); } } }

这段代码的问题很明显:数据(PlayerData.Gold)和显示(goldText)耦合在一起,业务逻辑(扣钱)散落在UI回调里,RefreshItems既负责销毁又负责创建还负责绑定事件。想加一个“背包满了不能购买”的规则,得在这个脚本里到处找地方插入判断。

6.2 重构步骤拆解

第一步,定义Model。把背包数据和业务规则抽出来:

public class BackpackModel { private List<ItemData> _items = new List<ItemData>(); private int _gold; public event Action OnDataChanged; public IReadOnlyList<ItemData> Items => _items; public int Gold => _gold; public bool TrySpendGold(int amount) { if (_gold < amount) return false; _gold -= amount; OnDataChanged?.Invoke(); return true; } public void AddItem(ItemData item) { _items.Add(item); OnDataChanged?.Invoke(); } }

第二步,定义View接口:

public interface IBackpackView { void RefreshItemList(IReadOnlyList<ItemData> items); void UpdateGoldDisplay(int gold); void ShowMessage(string message); }

第三步,实现Presenter:

public class BackpackPresenter : IDisposable { private IBackpackView _view; private BackpackModel _model; public BackpackPresenter(IBackpackView view, BackpackModel model) { _view = view; _model = model; _model.OnDataChanged += RefreshView; } public void Initialize() { RefreshView(); } public void OnItemClicked(ItemData item) { if (_model.TrySpendGold(10)) { _view.ShowMessage($"使用了{item.Name}"); } else { _view.ShowMessage("金币不足"); } } private void RefreshView() { _view.RefreshItemList(_model.Items); _view.UpdateGoldDisplay(_model.Gold); } public void Dispose() { _model.OnDataChanged -= RefreshView; } }

第四步,View实现接口,只负责显示和转发:

public class BackpackView : MonoBehaviour, IBackpackView { [SerializeField] private Text _goldText; [SerializeField] private Transform _itemGrid; [SerializeField] private GameObject _itemPrefab; [SerializeField] private Text _messageText; private BackpackPresenter _presenter; private void Awake() { var model = new BackpackModel(); _presenter = new BackpackPresenter(this, model); } private void Start() { _presenter.Initialize(); } public void RefreshItemList(IReadOnlyList<ItemData> items) { foreach (Transform child in _itemGrid) Destroy(child.gameObject); foreach (var item in items) { var go = Instantiate(_itemPrefab, _itemGrid); go.GetComponentInChildren<Text>().text = item.Name; var captured = item; go.GetComponent<Button>().onClick.AddListener(() => _presenter.OnItemClicked(captured)); } } public void UpdateGoldDisplay(int gold) { _goldText.text = $"金币: {gold}"; } public void ShowMessage(string message) { _messageText.text = message; } private void OnDestroy() { _presenter?.Dispose(); } }

6.3 重构后的收益

重构完成之后,几个明显的变化:业务规则集中在BackpackModel里,想改“使用物品扣多少钱”只需要改一个地方;Presenter可以单独写单元测试,不需要启动Unity;View变得很薄,只负责显示和转发,几乎不会出bug。

更重要的是,当策划提出“背包满了之后不能再用物品”这个需求时,我只需要在BackpackModel.TrySpendGold里加一个容量判断,View和Presenter完全不用动。这就是分层架构带来的可维护性提升。

7. 让MVP真正落地的几个工程习惯

7.1 统一Presenter的创建方式

项目里Presenter的创建方式如果不统一,后期会很难管理。我建议用一个简单的工厂模式,把所有Presenter的创建集中在一个地方:

public static class PresenterFactory { public static T Create<T>(object view) where T : class { // 根据类型反射创建,或者用switch-case手动映射 // 这里可以统一注入Service依赖 } }

这样当Service的依赖关系变化时,只需要改工厂里的代码,不用去每个View里改。

7.2 给Presenter写单元测试

MVP最大的价值之一就是可测试性。用NUnit写Presenter的测试非常直接:

[Test] public void OnBuyButtonClicked_WhenGoldNotEnough_ShowsError() { var mockView = new MockShopView(); var currencyService = new CurrencyService(); currencyService.SetGold(50); var presenter = new ShopPresenter(mockView, null, currencyService); presenter.OnBuyButtonClicked(); Assert.AreEqual("金币不足", mockView.LastFeedback); }

这种测试跑起来只需要几毫秒,比启动Unity跑PlayMode测试快几个数量级。我通常会把核心业务逻辑的测试覆盖率做到80%以上,UI层的测试则依赖手动验证。

7.3 代码审查时重点看什么

引入MVP之后,代码审查有了明确的检查点:View里有没有业务判断?Presenter里有没有Unity API?Model有没有依赖UI?事件订阅有没有对应的取消订阅?把这些检查点固化到团队的Code Review清单里,能有效防止架构腐化。

我自己的习惯是,每次Review看到View里出现if语句判断业务条件,就会要求移到Presenter或Model里。看到Presenter里出现Debug.Log,就会要求抽象成ILogger。刚开始团队会觉得麻烦,但坚持一两个月之后,代码质量会有明显提升。

7.4 渐进式重构,别想着一步到位

最后说一个实操层面的建议:如果你手上是一个已经运行了很久的老项目,不要试图一次性把所有UI都改成MVP。那样风险太大,而且很容易改出bug。

我的做法是新功能一律用MVP,老功能在需要修改的时候顺手重构。比如某个界面因为需求变更需要大改,那就借这个机会把它拆成MVP。这样渐进式推进,团队有适应期,风险也可控。通常半年左右,核心界面就能全部迁移完成,剩下的边缘界面慢慢来就行。

这套方法我在三个不同规模的项目里都用过,最小的项目只有五个界面,最大的有八十多个界面。MVP不是万能的,但在Unity UI开发这个场景下,它确实是平衡了开发效率、可维护性和学习成本之后的一个务实选择。关键是要理解每个角色该管什么、不该管什么,然后在实践中不断调整边界,找到适合自己团队的那条线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询