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 List<string> { 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会返回null,Mediator构造失败。
我的做法是在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管数据”这套规则之后,即使换到别的架构,这种分层思维也会一直受用。至于框架本身,用熟了之后你会发现,真正需要写的代码其实很少,大部分时间花在思考“这个逻辑应该放在哪一层”上——而这恰恰是最值得花时间的地方。