PureMVC在Unity中的模块解耦实践与避坑指南

发布时间:2026/10/14 4:31:19
PureMVC在Unity中的模块解耦实践与避坑指南 1. 为什么一个十几年前的老框架还在被反复提起如果你在Unity项目里写过超过三个UI面板大概率经历过这样的场景点击背包里的“使用”按钮角色属性面板要刷新任务追踪面板要更新进度成就系统要检查是否达成条件音效模块要播放反馈音服务端还要同步一次数据。最直接的写法是在按钮回调里挨个调用这些模块的方法代码能跑但三个月后你回头看这段逻辑会发现它像一张蜘蛛网——改一个需求牵动五个文件。PureMVC就是冲着这个问题来的。它是一个轻量级的MVC框架最早在ActionScript/Flex时代流行后来被移植到包括C#在内的多种语言。在Unity游戏开发中它的核心价值不是“帮你写游戏”而是给混乱的模块通信定一套规矩谁负责发消息、谁负责处理逻辑、谁负责更新显示各司其职互不越界。这篇文章适合两类人看。一类是正在维护一个中型Unity项目、感觉代码越来越难改的开发者另一类是想了解“框架到底解决什么问题”而不是只会调API的进阶学习者。我会从PureMVC的核心角色讲起拆解它在Unity里的落地方式重点讲清楚消息通信的完整链路、代理与中介者的职责边界以及我在实际项目中踩过的几个印象深刻的坑。全文基于常见的PureMVC C#移植版本展开不绑定任何特定商业项目。需要先说明一点PureMVC不是Unity专属框架它不依赖MonoBehaviour也不管你的渲染管线。它只做一件事——解耦。理解这一点后面的所有设计选择都会变得顺理成章。2. PureMVC的五个核心角色到底谁管什么2.1 门面是整个框架的唯一入口Facade门面是PureMVC对外的统一接口。一个应用通常只有一个Facade实例它继承自单例模式负责初始化并持有Controller、Model、View三个核心部件。你可以把它理解成公司的前台外部要注册命令、获取代理、发送通知都找它不需要知道内部是怎么组织的。在Unity里我习惯在游戏启动的第一个场景里创建一个继承自Facade的类比如叫GameFacade在它的InitializeFacade或者自定义的Startup方法里完成所有Command、Proxy、Mediator的注册。这里有个细节注册顺序会影响初始化逻辑。如果某个Mediator在构造时需要读取Proxy的数据那Proxy必须先注册。我一般遵循“Model → View → Controller”的顺序虽然PureMVC本身不强制但这样能避免很多空引用。public class GameFacade : Facade { public static GameFacade Instance { get { return _instance ?? (_instance new GameFacade()); } } private static GameFacade _instance; protected override void InitializeController() { base.InitializeController(); RegisterCommand(NotificationConst.START_GAME, typeof(StartGameCommand)); RegisterCommand(NotificationConst.USE_ITEM, typeof(UseItemCommand)); } protected override void InitializeModel() { base.InitializeModel(); RegisterProxy(new PlayerDataProxy()); RegisterProxy(new InventoryProxy()); } protected override void InitializeView() { base.InitializeView(); RegisterMediator(new MainUIMediator()); } }这段代码看起来简单但它是整个项目通信的骨架。所有跨模块的交互最终都会汇聚到Facade注册的这些Command上。2.2 Proxy只碰数据不碰显示Proxy代理的职责非常纯粹持有数据、提供数据操作接口、在数据变化时发出通知。它不应该知道任何UI的存在也不应该直接调用Mediator的方法。举个实际例子。背包系统里有一个InventoryProxy内部维护一个物品列表。当玩家使用一个消耗品时逻辑是这样的Command收到“使用物品”的通知调用InventoryProxy的RemoveItem方法Proxy在移除成功后发送一个ITEM_REMOVED通知携带物品ID和剩余数量。至于UI怎么刷新、要不要弹提示Proxy完全不关心。这种设计的好处是数据层可以独立测试。你可以写单元测试直接new一个InventoryProxy调用它的方法断言数据变化不需要启动Unity也不需要任何UI。我在上一个项目里就是这么做的Proxy层的测试覆盖率能到80%以上改数据结构时心里有底。注意Proxy里不要写协程也不要引用UnityEngine命名空间下的任何东西。一旦Proxy依赖了Unity的API它就失去了可测试性也违背了框架分层的初衷。2.3 Mediator是UI和逻辑之间的翻译官Mediator中介者负责管理一个具体的视图组件通常是UI面板或场景对象。它做三件事监听自己关心的通知、更新视图显示、把用户的输入转换成通知发出去。关键点在于Mediator不写业务逻辑。比如点击“购买”按钮Mediator只负责发送一个PURCHASE_ITEM通知具体扣不扣钱、库存够不够、要不要弹确认框全部由对应的Command去协调。Mediator里最多做一些纯显示的判断比如按钮置灰、文本格式化。public class ShopItemMediator : Mediator { public new const string NAME ShopItemMediator; private ShopItemView _view; public ShopItemMediator(ShopItemView view) : base(NAME, view) { _view view; _view.OnBuyClick OnBuyClick; } public override void ListNotificationInterests() { return new Liststring { NotificationConst.PURCHASE_SUCCESS, NotificationConst.PURCHASE_FAILED }; } public override void HandleNotification(INotification notification) { switch (notification.Name) { case NotificationConst.PURCHASE_SUCCESS: _view.ShowSuccessTip(); break; case NotificationConst.PURCHASE_FAILED: _view.ShowFailedTip(notification.Body as string); break; } } private void OnBuyClick() { SendNotification(NotificationConst.PURCHASE_ITEM, _view.ItemId); } }这段代码里Mediator没有一行业务判断它只是“转发”和“响应”。这种克制是PureMVC能保持清晰的关键也是最难坚持的地方——开发者很容易图省事直接在Mediator里写if-else处理逻辑一旦开了这个口子分层就名存实亡了。2.4 Command是唯一允许“做决定”的地方Command命令是业务逻辑的落脚点。它接收一个通知可以访问Proxy读写数据可以发送新的通知触发其他Command或更新Mediator也可以调用服务端接口。所有“如果……就……”的判断都应该出现在Command里。PureMVC里的Command分两种简单Command实现ICommand接口执行完就结束和宏Command继承MacroCommand可以按顺序执行多个子Command。宏Command在复杂流程里很有用比如“购买物品”可以拆成“校验余额 → 扣除货币 → 添加物品 → 发送成功通知”四个子Command每个子Command只做一件事组合起来完成完整流程。public class PurchaseItemCommand : MacroCommand { protected override void InitializeMacroCommand() { AddSubCommand(typeof(CheckBalanceCommand)); AddSubCommand(typeof(DeductCurrencyCommand)); AddSubCommand(typeof(AddItemCommand)); AddSubCommand(typeof(PurchaseResultCommand)); } }这种拆法看起来啰嗦但好处是每个子Command都可以单独复用和测试。CheckBalanceCommand在别的购买场景里也能用DeductCurrencyCommand在充值、兑换等场景里也能用。组合优于继承小步骤优于大函数这是我在重构老项目时体会最深的一点。2.5 Notification是唯一的通信货币Notification通知是PureMVC里所有模块之间通信的唯一载体。它有三个字段Name通知名、Body任意对象、Type可选字符串。发送通知用SendNotification接收通知靠ListNotificationInterests和HandleNotification。这里有一个容易被忽视的设计原则通知名应该定义成常量集中管理。我见过太多项目里通知名是散落的字符串字面量拼错一个字母就导致逻辑静默失效排查半天。用一个静态类把所有通知名收拢起来配合IDE的自动补全能省下大量调试时间。public static class NotificationConst { public const string START_GAME StartGame; public const string USE_ITEM UseItem; public const string ITEM_REMOVED ItemRemoved; public const string PURCHASE_ITEM PurchaseItem; public const string PURCHASE_SUCCESS PurchaseSuccess; public const string PURCHASE_FAILED PurchaseFailed; }另外Body的类型要约定清楚。同一个通知名有时传int有时传string接收方就得写一堆类型判断。我的习惯是一个通知名对应一种Body类型如果确实需要传多个值就定义一个简单的数据类而不是用object数组硬塞。3. 一次完整的物品使用流程是怎么跑通的3.1 从按钮点击到数据变更的完整链路光讲角色分工还是抽象我们用一个具体场景把整条链路串起来。假设玩家在背包界面点击了一个“生命药水”的使用按钮期望的效果是药水数量减一角色血量恢复UI刷新播放音效。第一步背包面板上的按钮被点击对应的Mediator捕获到这个事件发送USE_ITEM通知Body是物品的唯一ID。第二步Facade里注册的UseItemCommand被触发。它首先通过Facade获取InventoryProxy调用GetItemById拿到物品数据判断物品类型是否为消耗品、数量是否大于零。第三步校验通过后UseItemCommand调用InventoryProxy的ConsumeItem方法Proxy内部把数量减一然后发送ITEM_COUNT_CHANGED通知Body包含物品ID和新数量。第四步UseItemCommand继续调用PlayerDataProxy的RestoreHp方法Proxy更新血量数据发送HP_CHANGED通知。第五步背包面板的Mediator监听了ITEM_COUNT_CHANGED收到通知后更新对应格子的数字显示。角色状态面板的Mediator监听了HP_CHANGED更新血条。音效模块的Mediator监听了USE_ITEM_SUCCESS播放音效。整条链路里没有任何一个模块直接引用另一个模块。Mediator不知道Command的存在Command不知道具体哪个Mediator在监听Proxy更不知道谁在消费它的数据。所有连接都是通过通知名这个“契约”完成的。3.2 为什么不让Mediator直接调用Proxy这是新手最容易犯的错误。看到Mediator里能通过Facade拿到Proxy就顺手写了一句facade.RetrieveProxy(InventoryProxy.NAME).ConsumeItem(id)代码确实能跑但问题埋下了。首先业务逻辑散落了。今天在背包面板里扣物品明天在商店面板里也扣物品后天在快捷栏里也扣物品每个地方都写一遍校验和扣减改需求时就要改三处。其次Mediator变得难以复用。如果这个Mediator要在一个没有InventoryProxy的环境里使用比如某个独立的小游戏场景它就直接崩了。正确的做法是Mediator只发通知Command统一处理。多写一个Command类看起来麻烦但它把“做什么”和“怎么显示”彻底分开了。我在重构一个老项目时把十几个Mediator里直接调Proxy的代码全部改成发通知虽然花了三天但之后加新功能的速度明显变快因为不用再担心漏改某个入口。3.3 通知的发送时机与顺序陷阱PureMVC的通知是同步派发的。这意味着当你在Command里调用SendNotification时所有监听者的HandleNotification会立即执行执行完之后才回到SendNotification的下一行。这个特性带来一个隐蔽的坑如果在通知派发过程中又发送了另一个通知执行顺序会变成嵌套的。举个例子。UseItemCommand发送了ITEM_COUNT_CHANGED某个Mediator在HandleNotification里发现数量变成零了顺手发送了一个ITEM_EMPTY通知。这个ITEM_EMPTY会在ITEM_COUNT_CHANGED的所有监听者执行完之前就派发出去导致某些监听者还没处理完旧通知就收到了新通知状态可能不一致。我的处理原则是HandleNotification里只做显示更新不发送新通知。如果确实需要触发后续逻辑让Command在发送完数据变更通知之后再显式发送下一个通知。这样执行顺序是线性的容易推理。提示如果项目里通知数量很多建议在开发期加一个简单的日志记录每次通知的发送和接收排查顺序问题时非常有用。上线前关掉即可。4. Unity场景切换时Mediator的注册与销毁4.1 场景加载与Mediator生命周期的错位PureMVC的Mediator注册在Facade里它的生命周期和Unity的GameObject并不同步。场景A里的UI面板被销毁了但对应的Mediator还注册在View里下次发送通知时HandleNotification会访问一个已经被销毁的View直接报MissingReferenceException。这个问题的根源在于PureMVC不知道Unity对象什么时候被销毁。它只认RegisterMediator和RemoveMediator这两个调用。所以你必须在场景卸载或面板关闭时手动调用Facade的RemoveMediator。我的做法是在每个Mediator的View组件上挂一个小的MonoBehaviour在OnDestroy里通知Facade移除对应的Mediator。注意不能在Mediator自己的OnRemove里访问View因为那时候View可能已经销毁了。public class MediatorCleanup : MonoBehaviour { public string MediatorName; private void OnDestroy() { if (GameFacade.Instance.HasMediator(MediatorName)) { GameFacade.Instance.RemoveMediator(MediatorName); } } }4.2 常驻Mediator与临时Mediator的区分不是所有Mediator都应该随场景销毁。像全局音效管理、网络状态提示、顶层弹窗队列这类模块通常需要跨场景存在。我的分类方式是常驻Mediator在游戏启动时注册直到游戏退出才移除。对应的View用DontDestroyOnLoad保持。场景Mediator在场景加载完成后注册场景卸载前移除。对应的View随场景销毁。区分清楚之后注册和移除的时机就明确了。常驻的在GameFacade初始化时注册场景的在场景控制器比如一个SceneLoader里注册。千万不要在Mediator的构造函数里做场景相关的初始化因为构造时机和场景加载完成时机不一定对齐。4.3 重复注册导致的静默覆盖PureMVC的RegisterMediator如果遇到同名的Mediator会先移除旧的再注册新的不会报错。这个行为在某些情况下是便利在另一些情况下是灾难。我遇到过一个问题某个面板打开时注册Mediator关闭时忘记移除再次打开时又注册一次。表面上没问题但旧的Mediator虽然被移除了它监听的View事件没有解绑导致一次按钮点击触发了两次逻辑。排查了很久才发现是重复注册导致的。所以我的建议是在RegisterMediator之前先判断HasMediator如果已存在就先RemoveMediator。多写两行代码省下的是几个小时的调试时间。if (facade.HasMediator(ShopPanelMediator.NAME)) { facade.RemoveMediator(ShopPanelMediator.NAME); } facade.RegisterMediator(new ShopPanelMediator(view));5. 宏命令拆分粒度与通知风暴的平衡5.1 宏命令不是越细越好前面提到宏命令可以把复杂流程拆成多个子Command但拆得太细会带来另一个问题通知数量爆炸。每个子Command执行完可能都要发通知一个购买流程发出去七八个通知每个通知又有若干Mediator监听调试时日志刷屏性能上也有开销。我的经验是按“事务边界”来拆。一个事务边界是指“要么全部成功要么全部回滚”的最小单元。比如“扣钱”和“加物品”应该在一个事务里中间任何一步失败都要回滚。而“播放音效”和“刷新UI”不属于事务它们只是副作用可以合并到一个结果通知里。具体做法是宏命令的子Command只负责数据变更不发通知最后一个ResultCommand统一发送一个结果通知Body里包含所有需要的信息。这样Mediator只需要监听一个通知处理起来也简单。5.2 用Type字段区分同一通知的不同阶段Notification的Type字段经常被忽略但它其实很有用。比如一个LOAD_DATA通知可以用Type区分“开始加载”“加载成功”“加载失败”三个阶段Mediator根据Type做不同的显示而不需要定义三个通知名。SendNotification(NotificationConst.LOAD_DATA, null, Start); // 加载完成后 SendNotification(NotificationConst.LOAD_DATA, data, Success); // 失败时 SendNotification(NotificationConst.LOAD_DATA, errorMsg, Failed);这样做的好处是通知名集中Mediator的ListNotificationInterests里只写一个名字HandleNotification里用switch判断Type。缺点是Type是字符串容易拼错所以同样要定义常量。5.3 避免在循环里发送通知这是一个性能相关的坑。我曾经写过一个批量更新物品数量的逻辑在for循环里逐个发送ITEM_COUNT_CHANGED通知。物品少的时候没问题物品上百个的时候UI卡顿明显。原因是每个通知都会遍历所有Mediator的监听列表上百次遍历叠加起来就很可观。优化方式很简单批量操作时先改完所有数据最后发一个BATCH_UPDATE通知。Mediator收到后一次性刷新所有相关格子。如果UI刷新本身也耗时还可以进一步做分帧处理但那是另一个话题了。6. 实际项目中那些文档不会写的坑6.1 通知名冲突导致的逻辑串线PureMVC的通知是全局广播的没有命名空间隔离。如果两个不相干的模块用了同一个通知名就会互相干扰。我见过一个项目里“UPDATE”这个通知名被三个模块使用结果更新金币时角色属性面板也跟着刷新虽然没报错但白白浪费了性能还让排查逻辑变得困难。解决办法是给通知名加模块前缀比如Inventory_ItemChanged、Player_HpChanged、Shop_PurchaseSuccess。看起来啰嗦但能彻底避免冲突。如果项目已经很大了可以写一个简单的脚本扫描所有通知名找出重复的逐个重命名。6.2 Proxy数据初始化时机与Mediator注册顺序前面提过注册顺序的问题这里展开说一个具体场景。假设MainUIMediator在构造时需要读取PlayerDataProxy里的玩家名字来显示。如果PlayerDataProxy还没注册RetrieveProxy会返回nullMediator构造失败。我的做法是在Mediator的OnRegister方法里做数据读取而不是构造函数里。OnRegister是在Mediator被注册到View之后调用的这时候Facade已经完成了所有Proxy的注册只要你在InitializeModel里注册了。这样顺序就安全了。public override void OnRegister() { base.OnRegister(); var playerProxy Facade.RetrieveProxy(PlayerDataProxy.NAME) as PlayerDataProxy; _view.SetPlayerName(playerProxy.PlayerName); }6.3 调试时如何快速定位通知的发送源头PureMVC本身不提供通知追踪功能当项目里有几十个通知在飞的时候想知道某个通知是谁发的、被谁收了只能靠日志。我的做法是写一个简单的调试工具在开发模式下包装SendNotification记录调用堆栈。public static void SendNotificationWithTrace(string name, object body null) { #if UNITY_EDITOR Debug.Log($[Notify] {name} from {new System.Diagnostics.StackTrace().GetFrame(1).GetMethod().DeclaringType.Name}); #endif GameFacade.Instance.SendNotification(name, body); }然后在所有Command和Mediator里用这个包装方法代替直接调用SendNotification。上线前用条件编译去掉日志不影响性能。这个工具帮我省下了大量“这个通知到底谁发的”的排查时间。6.4 不要为了用框架而用框架最后说一个观念上的坑。PureMVC适合的是模块较多、交互复杂、需要长期维护的项目。如果你做的是一个超休闲小游戏只有三五个界面逻辑简单引入PureMVC反而增加了大量样板代码得不偿失。我个人的判断标准是当项目里出现“改一个功能要动三个以上文件且这些文件之间没有明显的依赖关系”时就是引入框架的时机。框架解决的是复杂度管理问题如果复杂度本身不高框架就是负担。另外PureMVC也不是唯一选择。Unity生态里还有基于ScriptableObject的事件系统、UniRx的响应式方案、以及各种轻量级IOC容器。选择哪个取决于团队习惯和项目特点。PureMVC的优势在于概念少、规则清晰、跨引擎通用如果你之前在其他语言里用过迁移到Unity几乎没有学习成本。我在实际使用中最大的体会是PureMVC的价值不在于它提供了多少功能而在于它强制你思考每个模块的职责边界。当你习惯了“Mediator只发通知、Command做决定、Proxy管数据”这套规则之后即使换到别的架构这种分层思维也会一直受用。至于框架本身用熟了之后你会发现真正需要写的代码其实很少大部分时间花在思考“这个逻辑应该放在哪一层”上——而这恰恰是最值得花时间的地方。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询