前言
这篇文章算是对单例模式在实际运用时的进一步探索和讲解,本文的目的是阐明具有单例模式的类的使用(一般是Manager)和一类与Manager看起来很像的类Controller,并对Controller和Manager的区别详细讲解,并提供多种解决单例模式耦合弊端的三种方法。
前置篇:Unity游戏开发-基础设施单例基类 | 闲云野鸥
Controller与Manager是什么?
manager与Controller为习惯命名,翻译为管理器和控制器。
Manager(管理器)
核心作用是提供给全局的服务,充当模块之间的粘合剂,要同时满足这两点才有必要设置,判断的因素是管理器不会随着某一个对象的失活而消失,它会一直存在在游戏进程中。
管理器的核心职能(管理器可实现的功能):
1.对全局的数据的集中存储与管理,比如DataManager,为各种模块提供唯一的数据来源,避免数据不一致。
2.跨模块通讯的枢纽,比如玩家死亡需要同时调用UI和Audio的Manager,这时候EventManager只需要发布和触发事件就可以。
3.负责全局资源的调度和生命周期管理,比如AudioManager统一管理各种音频,避免音频到处散落提升管理难度。
4.核心业务流程的控制台:控制游戏宏观层面的状态和规则判定,比如GameManager负责游戏处于什么状态(暂停,运行)并执行相应的代码。
Controller(控制器)
控制器与管理器最大的区别是局部的控制和管理,它负责游戏内的一些对象(永远和具体对象关联),不是单例模式,可以同时存在多个,一旦对象小时,控制器也会消失。
控制器的核心职能:
1.驱动表现(反馈),直接操作对象上的组件,比如控制Transform,移动控制,Animator播放动画
2.接受输入和触发,负责感知外界的变化,比如PlayerController监听玩家的键盘/手柄输入。
3.状态流转和局部逻辑,它负责处理该对象的内部逻辑。比如PlayerController负责计算
移动,处理跳跃等切换动画状态机
PS问题来了接受输入和触发看起来应该是全局做的事情啊?为什么不用管理器?确实管理器来管理全局输入确实是一个必要的功能,但是你想一下如果没有玩家了,那玩家输入控制(WASD)还有必要吗?显然没啥必要了,所以这一类局部的输入就不要放到管理器了。
管理器与控制器的协作和不同
记住非常重要的一点:其实管理器和控制器所能实现的功能可以大同小异,但是其核心本质局部管理和全局管理很重要!
不同
其实在上面对控制器和管理器的概念讲解中其核心区别你应该可以隐约察觉到了。
这里的重点在于如何理解全局和局部,可以问自己四个问题:
1.问生命周期:“当场景切换时,它是被销毁重建的,还是一直在后台活着的?”
2.问实例的数量:“它是独一无二的吗?还是可以存在无数个?”
3.问物理归属:“它必须挂载到具体模型上吗?”
4.问职责边界:“它是处理整个游戏流程,还是控制某个物体的具体动作?”
这里就之间给一张表格理解一下
| 维度 | Manager (管理器) | Controller (控制器) |
|---|---|---|
| 角色比喻 | 导演组 / 公司高管 | 演员 / 一线员工 |
| 存在形式 | 全局唯一(通常挂在常驻空物体上,单例) | 随需生成(挂在具体角色/物体上,多实例) |
| 生命周期 | 贯穿整个游戏,或跨场景常驻 | 随游戏对象的创建而生,销毁而死 |
| 核心视角 | 上帝视角(宏观):看整个舞台、看全局数据 | 第一人称视角(微观):只看自己、处理自己的事 |
| 主要工作 | 资源调度、状态统筹、跨部门通信、流程把控 | 接收输入、处理具体逻辑、驱动自身动画/物理表现 |
协作
这里的协作,不只是控制器和管理器之间的,管理器和管理器,控制器和控制器之间也需要协作,这里将分别介绍这几种协作方式。
管理器与管理器
管理器与管理器之间的协作方式,主要有3个原则,存在3个方案。
3个协作原则
1.单向依赖原则:如果必须发生直接调用,只能由“宏观流程管理器”向下调度“底层服务管理器”,底层服务绝不能反向依赖宏观管理器。
2.事件驱动原则:管理器之间应尽量“背靠背”通信,即通过全局事件总线(Event Bus)进行交互,而不是“面对面”互相调用。
3.禁止循环依赖:严禁出现 A 管理器调用 B 管理器,B 管理器又调用 A 管理器的死循环。
这里先不详细展开后续的3个方案种会进一步加深这些原则的理解
3个方案(按推荐顺序)
1.事件总线
最纯粹的解耦方式,管理器之间完全不需要认识彼此。
进一步了解和查看相关代码可以查看文章:
Unity游戏开发-事件总线 | 闲云野鸥
2.单向依赖与层级调度
当两个管理器存在明确的上下游层级关系,且需要严格的执行顺序时,允许单向直接调用。
工作机制: 宏观的“协调者”可以向下指挥底层的“服务者”,但服务者不能反向指挥协调者。
实战场景:游戏暂停流程。
GameManager(协调者)调用 Pause() 时,内部可以依次调用 AudioManager.Instance.PauseMusic() 和 UIManager.Instance.ShowPauseMenu()。
但是,AudioManager 和 UIManager 绝对不能反过来去调用 GameManager。
优势:能够清晰地控制复杂的业务时序和状态流转。
3.依赖注入
如果管理器之间确实需要互相配合完成复杂任务,不要通过全局 Instance 去抓取依赖,而是把依赖“推”进去。(解决管理器互相调用的问题)
PS注入的方式可以用构造函数,方法,属性。
工作机制: 在管理器初始化时,由外部(如启动脚本或框架)将所需的接口注入进来。
实战场景:CombatManager(战斗管理器)在结算伤害时,需要通知 UIManager 弹出伤害数字。
CombatManager 内部只持有一个 IUIManager 接口,并在 Init(IUIManager ui) 时接收注入(传入场景内的UIManager)。结算伤害时,直接调用 uiManager.ShowDamageNumber(damage)。
优势:CombatManager 只知道“我有一个 UI 接口”,不知道背后是不是单例。这极大地提高了代码的可测试性和可替换性。
控制器与控制器
控制器与控制器之间的协作,也是开发中比较容易出现代码混乱和性能问题的地方,这里也是提供3种解决方案。
核心原则: 面向抽象,不要强绑定。(不要在一个控制器里直接获取另一个控制器)。
3个方案(按推荐顺序)
1.接口契约:
工作机制:定义一个通用的接口(如 IDamageable 或 IInteractable)。目标对象实现该接口,发起方只认接口不认具体类。
实战场景(玩家砍中怪物):
1 | // 1. 定义接口 |
这就实现了面向抽象,玩家不知道这是怪物类只知道这个类可以执行受伤方法。
2.触发局部事件
局部事件通常用于同一个游戏对象内部,或者有明确从属关系的两个对象之间。
工作机制:在交互发生时,触发一个局部的回调。
实战场景:玩家打开了一个宝箱。
PlayerController 调用 ChestController.Open()。
ChestController 内部执行开启动画,并触发一个局部的 OnChestOpened 事件。(事件在Chest控制器中声明好,并让内部组件订阅)
挂在宝箱身上的 ChestLootController 监听到这个局部事件,负责掉落物品(触发一系列方法)
PS这里叙述一下局部事件,这里的局部是游戏中一个对象上的所有组件组成的局部,具体实现方法主要依托于C#的事件(或UnityEvent),在需要订阅事件的组件里直接获取Controller,添加方法。
3.通过全局管理器/事件总线
如果两个控制器的交互,会引发全局系统的变化,此时不应由控制器直接处理,而应上报给 Manager。(跨模块的影响)
工作机制:控制器 A 触发全局事件,Manager 监听后,再去调度控制器 B。
实战场景:玩家击杀了怪物。
PlayerCombatController 触发全局事件 OnMonsterKilled()。
GameManager 监听到事件,调用 ScoreManager 加分,调用 UIManager 弹飘字。
PlayerCombatController 绝对不需要自己去获取 UIManager 的引用去弹飘字。
控制器与管理器
这里比较简单了,就是通过事件中心互相协作,控制器负责触发,管理器负责监听。
有以下3不原则
- Controller 绝不直接调用 Manager 去修改全局数据:要加分、要存档,请发事件。
- Manager 绝不越权操控 Controller 的底层组件:要暂停、要复活,请调用 Controller 暴露的公共业务方法。
- Controller 之间绝不直接对话:如果有交互(比如玩家踩到机关),请通过接口(
IDamageable)或事件总线(EventCenter)进行,不要让两个 Controller 互相持有引用
说些什么吧!