1. 单例模式:Unity面试绕不开的第一道坎
1.1 面试官最爱的三连问
单例模式在Unity客户端面试里的出现频率,夸张点说,十场面试九场有。你简历上写着“负责游戏主逻辑模块”,面试官大概率上来就问一句:你项目里的GameManager、AudioManager这些全局对象是怎么管理生命周期和访问入口的?这问题听着温和,实际上就是在钓你答单例。
别以为初级面试就只问概念。我见过太多候选人张嘴就是“单例就是一个类只能有一个实例”,然后就没有然后了。面试官接着往下问,立刻露馅:你的单例在Awake里初始化还是OnEnable里初始化?场景切换之后单例会不会被重复创建?两个场景各有一个AudioManager同时存在是什么情况?DontDestroyOnLoad你挂在哪?这些问题一个比一个具体,全是Unity引擎层面的坑,光背定义根本扛不住。
另外还有一个高频变体问法:你写一个单例,怎么保证线程安全?这个问题在纯Unity开发里其实有点“超纲”,因为Unity主线程模型下很少并发new单例,但面试官考的是你有没有在写框架、写工具类的时候考虑过并发边界。答“Unity里一般不需要考虑线程安全”可以,但紧接着最好能补一句“如果用lock或者双重校验锁,可以这样写”,把技术深度垫起来。
1.2 一份能过面试的MonoBehaviour单例模板
先说结论:Unity里的单例和普通C#单例不一样,最大区别在于Unity对象有生命周期,有自己的消息回调。你光写一个“private static Instance + public static get”是不够的,因为场景里如果挂了两个脚本,你get的时候很可能返回一个旧的、已经要销毁的对象。
我建议初级候选人至少能手写下面这个模板,然后对着面试官讲清楚每一行代码的作用:
public class GameManager : MonoBehaviour { private static GameManager _instance; public static GameManager Instance { get { return _instance; } } private void Awake() { if (_instance != null && _instance != this) { Destroy(gameObject); return; } _instance = this; DontDestroyOnLoad(gameObject); // 初始化逻辑可以放这里 } private void OnDestroy() { if (_instance == this) { _instance = null; } } }这个版本的信息量其实很大。第一,静态属性不直接new,而是从字段返回,这就避免了外部随意创建实例。第二,Awake里做了重复实例的拦截,如果场景里已经有一个GameManager,新创建的这个直接销毁自己。第三,DontDestroyOnLoad让单例跨场景存活。第四,OnDestroy里把_instance清空,防止场景销毁后再次访问拿到悬空引用。
很多人在OnDestroy清理这一步栽跟头。如果不加这句,Unity退出时对象销毁顺序不可控,如果你在一个已经销毁的场景里通过Instance访问GameManager,返回的是一个野指针似的旧引用,虽然C#有null判断能兜住,但会引发“Instance明明不为null但对象已经被销毁”的诡异问题。这在热重载和小场景切来切去的项目里很常见。
进阶一点的版本,面试官如果问线程安全,你可以在get里加一个锁:
private static readonly object _lock = new object(); private static GameManager _instance; public static GameManager Instance { get { lock (_lock) { if (_instance == null) { // 在非主线程创建Unity对象是违禁操作 // 所以这个写法只是为了展示线程安全思路,实际项目中慎用 var go = new GameObject("GameManager"); _instance = go.AddComponent<GameManager>(); } return _instance; } } }但是记住一点,这段代码只是演示,不要在真实项目里对着Unity对象加锁new。真要写纯C#工具类单例,比如事件中心、配置管理器,才用得上这种写法。你能把这里面的区别讲明白,面试官对你的评价会立刻上几个档次。
1.3 单例的正确边界:怎么答“什么时候不该用单例”
面试官问“单例的缺点是什么”的时候,很多候选人都能背出几句:全局状态污染、可测试性差、隐藏依赖。但光背没意义,你得结合Unity项目里的真实体验说。
我自己的体会是,单例最大的问题在于生命周期不可见。你用一个AudioManager.Instance.PlayBGM(),开发者根本不知道这个AudioManager什么时候被创建、什么时候被销毁,依赖关系全被隐式藏起来了。项目小的时候没问题,等做到几十个管理器互相调用,你查一个Bug可能要从十几个单例里来回跳,调试体验极其痛苦。
所以在面试里你可以这样答:我现在的项目里,全局唯一的服务类会用单例,但有三个替代方案我会优先考虑。第一,低频访问的数据用静态类或ScriptableObject,不需要生命周期管理,比如游戏配置表。第二,跨系统通讯用事件系统,避免直接依赖具体管理器。第三,对象生命周期跟场景绑定的逻辑,绝不做成DontDestroyOnLoad单例,而是挂在场景物体上,场景销毁它跟着销毁。
这套回答的潜台词是:我不是无脑用单例,我懂什么时候该用、什么时候该绕开。初级面试能答到这一层,基本就超出平均水平了。
2. 观察者模式:UI跟玩法解耦的标准答案
2.1 事件机制与Unity的三种实现
观察者模式在Unity面试里出现的频率,我个人体感比单例还高。原因也简单,游戏项目里到处都是“某个东西变了,其他一堆东西要跟着响应”的场景:玩家血量降了,血条UI要刷新,屏幕要闪红,Boss的技能AI要切换形态;任务进度变了,任务面板要更新,可能音效也要触发。
如果所有模块之间直接互相引用,代码就会变成一团蜘蛛网。观察者模式的核心就是引入一个“事件总线”,让发布者和订阅者互不认识,大家只跟事件总线打交道。这就像公司里部门之间不直接对话,有事找前台协调,虽然多了一层,但整个组织结构清爽很多。
Unity里实现观察者模式有三种常见姿势。第一种是C#原生事件,用event关键字声明委托,语法简洁,性能也高。但缺点是手动管理订阅关系,忘记取消订阅容易造成内存泄漏。第二种是UnityEvent,可以在Inspector面板可视化绑定,适合做预制体内部的事件关联,比如按钮点击。第三种是自己写一个事件中心,统一管理所有事件的订阅和广播,这也是大项目里最常用的方案。
面试的时候你不光要说“我用了观察者模式”,还要能解释为什么选其中一种。比如UnityEvent确实方便,但它的序列化机制和性能开销在帧率敏感的战斗系统里不合适,这时候用C#事件或手写事件中心更靠谱。这种选型层面的思考,才是面试官真正想听到的东西。
2.2 手写一个轻量事件中心(EventBus)
我建议所有Unity客户端方向的候选人都能手写一个事件中心,这比背诵任何设计模式定义都管用。一个初级别够用的事件中心,核心功能就三个:注册监听、移除监听、广播事件。下面是简化版本:
public class EventCenter { private static Dictionary<System.Type, System.Delegate> _events = new Dictionary<System.Type, System.Delegate>(); public static void AddListener<T>(System.Action<T> listener) where T : struct { if (_events.ContainsKey(typeof(T))) { _events[typeof(T)] = System.Delegate.Combine( _events[typeof(T)], listener); } else { _events[typeof(T)] = listener; } } public static void RemoveListener<T>(System.Action<T> listener) where T : struct { if (_events.ContainsKey(typeof(T))) { _events[typeof(T)] = System.Delegate.Remove( _events[typeof(T)], listener); } } public static void Broadcast<T>(T eventData) where T : struct { if (_events.TryGetValue(typeof(T), out var del)) { var action = del as System.Action<T>; action?.Invoke(eventData); } } }这段代码用泛型方法把事件类型直接作为字典的Key,每次广播一个事件,就相当于发了一封带“类型标签”的邮件,只有订阅了这个类型的监听者才会收到。用法也很简单,先定义事件数据结构:
public struct PlayerHpChanged { public float Hp; public float MaxHp; public float Delta; }然后在一处广播,在另一处监听:
// 广播方 EventCenter.Broadcast(new PlayerHpChanged { Hp = currentHp, MaxHp = maxHp, Delta = -10f }); // 监听方(UI血条) EventCenter.AddListener<PlayerHpChanged>(OnPlayerHpChanged); private void OnPlayerHpChanged(PlayerHpChanged data) { hpBar.fillAmount = data.Hp / data.MaxHp; }这个方案的优点很直接:血条UI不引用Player类的任何东西,Player那边也不知道UI的存在,两边通过事件握手。以后想加一个“血量变化飘字效果”,只需要在事件中心注册一个新监听者,不需要改动Player一行代码。面试时能把这个例子讲清楚,比背十遍“观察者模式定义了对象间一对多的依赖关系”要有说服力得多。
2.3 用“滑动条调音量”讲清楚观察者模式
这里顺便提一个特别生活化、面试官也爱听的例子:做一个音量设置滑动条。你可能的做法是拖一个Slider进场景,然后在OnValueChanged事件里直接把AudioMixer的volume改了。功能能跑,但UI和音频模块耦合在一起。
如果用观察者思维重构一下:Slider只需要负责把数值变化广播成“音量变化请求”事件,具体这个请求被谁处理、怎么处理,Slider自己完全不知道。音频管理器订阅这个事件,收到后去调混音器;甚至还可以有另一个订阅者把音量值同步到存档系统。这样你以后要换音频插件、加音效淡入淡出、做“设置页改动立即生效”,都不用去动UI代码。
有个容易忽略的细节是内存泄漏。用C#事件或者自己手写的事件中心,监听方的生命周期一定要管理好。比如UI面板被关闭后,如果还挂着一个订阅着PlayerHpChanged的监听者,这个面板虽然隐藏了但对象一直存活,事件每次广播它都会收到。轻则多出无意义的逻辑调用,重则对象无法被GC回收,内存越顶越高。解决办法就是在UI的OnDestroy或OnDisable里调用RemoveListener。
面试时如果能主动讲出“观察者模式要注意取消订阅”,然后再补一句“我一般把订阅和取消订阅成对写在OnEnable和OnDisable里”,面试官基本就知道你踩过坑、有真经验了。
3. 对象池模式:高频创建销毁的性能救星
3.1 为什么Unity里不能随便Instantiate
对象池模式本身不难理解,就是“对象用完不销毁,回收复用”。但在Unity面试里,它背后考的是你对Instantiate和Destroy底层开销的认知。很多新手写射击游戏,每开一枪就Instantiate一个子弹,子弹碰到敌人就Destroy。单发子弹没问题,但特效、敌人尸体、掉落物、弹痕贴花加起来,帧率能给你干到个位数。
Instantiate的开销主要在三个方面:第一是内存分配,C#对象创建会触发托管堆分配,频繁分配导致GC频繁,而GC的峰值停顿是游戏卡顿的头号元凶;第二是Unity引擎的Awake、OnEnable会从头执行一遍,如果预制体层级深,这条链路上的组件初始化都要跑;第三是场景层级结构频繁增删节点,会触发Transform重建和渲染数据更新。
我见过一个实际的优化案例,某卡牌项目的战斗特效原本是每回合Instantiate一个全屏技能特效,用完再Destroy,在低端安卓机上会肉眼可见掉帧。改成对象池后,同一时间只保活几个特效对象,用Get和Release来回切换激活状态,流畅度立刻上来了。这个案例直接套在面试里,比空谈理论强太多。
3.2 对象池的核心实现要点
一个初级候选人如果能现场写一个通用对象池,我会给很高分。核心就是两个方法Get和Release,内部用一个Stack或者Queue存空闲对象:
public class SimpleObjectPool { private Stack<GameObject> _pool = new Stack<GameObject>(); private GameObject _prefab; private Transform _parent; public SimpleObjectPool(GameObject prefab, int preloadCount, Transform parent = null) { _prefab = prefab; _parent = parent; for (int i = 0; i < preloadCount; i++) { var obj = CreateNewObject(); obj.SetActive(false); _pool.Push(obj); } } public GameObject Get() { GameObject obj = _pool.Count > 0 ? _pool.Pop() : CreateNewObject(); obj.SetActive(true); return obj; } public void Release(GameObject obj) { obj.SetActive(false); _pool.Push(obj); } private GameObject CreateNewObject() { var obj = _prefab != null ? Object.Instantiate(_prefab) : new GameObject(); if (_parent != null) { obj.transform.SetParent(_parent); } return obj; } }这里有几个关键细节值得展开。第一,为什么用Stack不用List?因为对象的获取和释放是典型的后进先出场景,Stack在Push和Pop上都是O(1),而且缓存友好度更高。第二,为什么Get的时候SetActive(true),Release的时候SetActive(false)?因为Unity中SetActive(false)的物体不参与更新和渲染,比移动或者缩放更彻底地“休眠”。第三,不要忘了挂在池子父节点下面,否则你的Hierarchy会乱成一锅粥,找对象和运行检查都特别痛苦。
更进阶的版本还要处理几个边界条件。比如池子无上限、滥用Get会让对象数量持续膨胀,所以要有maxSize上限,超过上限就不再入池而是直接Destroy。又比如对象释放时可能需要重置状态,子弹的private变量、特效的粒子发射器,都可能在复用后残留上一次的状态。我一般会在对象身上放一个自定义接口,比如IPoolable,涉及OnSpawn和OnDespawn两个回调方法,池子在Get和Release的时候主动调用,让对象自己做状态重置。
对象池还有一个非常实际的应用点:在微信小游戏或者网页端Gl平台这些内存受限环境,频繁Instantiate和Destroy带来的性能和内存压力会被放大。很多第三方引擎或开发框架,比如微信小游戏适配层里做了资源管理模块,本质就是对象池思想。在面试里聊这个,显得你不是只会写PC端Demo,而是对平台差异有认知。
3.3 面试加分:平台差异与内存优化
如果你面试的是小游戏方向或者做轻量级游戏的团队,对象池几乎是必问的。原因很简单,小游戏包体和运行内存都紧张,GC一卡顿玩家立刻流失。你可以在对象池答案里主动带上平台视角:在PC上,一秒Instantiate 100个对象可能不痛不痒;但在内存有限的WebGL或小游戏环境,每一次GC都可能造成明显的掉帧卡顿,对象池的作用就从“优化”变成了“必须”。
还可以提到一个比较细的点:对于需要频繁判断是否在屏幕内的子弹或敌人,可以用Renderer的isVisible属性或者自定义的包围盒剔除逻辑,让它出屏后提前回收到池子。这和对象池配合使用,能进一步减少场景中激活对象的数量,避免无意义的逻辑和渲染开销。面试时点到这一层,你的答案是能“冒烟”的。
4. 状态机模式:角色控制与AI的骨架
4.1 状态机的核心要素
角色控制是Unity客户端岗位最常见的面试场景题之一。面试官可能会说:“一个角色有Idle、Run、Attack、Hurt、Death这些状态,你怎么设计?”你要是回答“用if判断当前状态,然后switch分发逻辑”,也不能说全错,但代码写多了会非常痛苦。状态越多,分支越乱,新加一个状态要改好几个地方,Bug就在这种地方大量滋生。
状态机模式的本质,是把“每个状态的行为”和“状态之间的转换关系”显式建模。核心三要素:状态、事件、迁移。状态就是当前角色所处的行为模式,比如“待机”“奔跑”;事件是触发迁移的条件,比如“按了空格攻击键”;迁移是状态A到状态B的转换规则,比如“待机状态收到攻击事件后迁移到攻击状态”。
面试里如果能画清楚“状态迁移图”,比如拿纸笔或者口头描述:Idle收到移动输入→Run,Run失去移动输入→Idle,任意状态收到受伤事件→Hurt,Hurt的动画播完→Idle,这种描述能力本身就是优秀的架构思维展示。面试官能从你的表达里看出,你有没有把“一个乱七八糟的角色行为”拆解成“一套清晰可扩展的状态网络”的能力。
4.2 用代码实现一套可用的角色状态机
面试手写状态机,我推荐用“状态类 + 上下文”的结构,而不是一层枚举加一堆switch。这样写的好处是每个状态是一个独立的类,状态逻辑内聚,后续加状态不需要改旧代码。骨架代码如下:
public interface IState { void Enter(); void Update(); void Exit(); } public class StateMachine { private IState _currentState; public void ChangeState(IState newState) { _currentState?.Exit(); _currentState = newState; _currentState.Enter(); } public void Update() { _currentState?.Update(); } }然后定义具体的状态类,比如待机、攻击。每个状态内部管理自己的行为逻辑:
public class IdleState : IState { private CharacterController _controller; public IdleState(CharacterController controller) { _controller = controller; } public void Enter() { _controller.Animator.Play("Idle"); } public void Update() { if (_controller.InputDirection.magnitude > 0.1f) { _controller.StateMachine.ChangeState(new RunState(_controller)); } if (_controller.IsAttackPressed) { _controller.StateMachine.ChangeState(new AttackState(_controller)); } } public void Exit() { // 可以做一些退出时的清理 } }小细节:状态对象每次可使用单例或者直接new,但注意不要再把状态类也做成全局单例。状态机持有上下文引用,让状态能访问角色的公共数据。实际项目中,状态类可能访问一个共享的PlayerContext,里面放了Animator、移动速度、当前HP等;而不是在状态类里写死对具体组件的引用,那样状态就不好复用了。
我在面试里会问一个追踪问题:Attack状态还没播完动画,玩家又按了一次攻击键,怎么处理?好的回答通常分两种:一种是“忽略重复输入,等动画播完再回Idle”,另一种是“设计攻击连招状态链,轻攻击结束前按攻击键迁移到重攻击”。能说出第二种方案,说明你理解状态的粒度是可以继续往下拆的,具备真实战斗系统的设计意识。
4.3 状态机和Animator Controller的关系
还有一类回答特别加分:主动把代码状态机和Unity的Animator Controller联系起来。Animator本身就是一个可视化状态机,有参数、有状态、有Transition,专门负责动画层面的状态切换。业务逻辑状态机负责决策,Animator负责表现,二者通过Animator参数桥接。
比如你先通过代码状态机判断角色处于“攻击”状态,然后在Enter方法里设置Animator.SetTrigger("AttackTrigger")。Animator那边根据触发器播放攻击动画,动画播放结束再触发一个动画事件,回调给代码,说“攻击动画播完了”,代码状态机再决定是否回Idle。这个模式下,业务逻辑和动画表现彻底分离,美术调动画不会影响代码,程序改逻辑不会误伤动画。
这个回答还有一个隐藏得分点:说明你理解了Unity引擎的职责边界。面试官问状态机,不只是想听你背一套代码框架,而是想确认你在实际开发中能不能选对工具。代码状态机、Animator Controller、甚至Playable API,各有适用场景,你能说清楚谁的活儿谁干,就已经是中级偏上的理解了。
5. 工厂模式:批量生成对象的职责划分
5.1 三种工厂模式的区别与选型
提到工厂模式,很多人第一反应是“我不就用new创建对象吗,为什么要绕一层”。这个问题在面试里很容易被追问。工厂模式解决的核心痛点是:当创建对象的逻辑变得复杂——比如要根据类型创建不同配置的对象、创建前后需要做一堆额外操作、创建过程希望被统一管理——new直接写在业务代码里就会扩散得到处都是,而且你根本没法在不改动业务代码的情况下扩展新类型。
简单工厂、工厂方法、抽象工厂,这三个概念初级面试至少要知道区别。简单工厂是“一个类里面写一个静态方法,根据参数switch创建不同对象”;工厂方法把创建动作下沉到子类,父类定义接口,子类决定实例化谁;抽象工厂更进一步,创建的是“产品族”,比如一套风格统一的UI控件、一个主题下的敌人和武器。在Unity游戏开发里,简单工厂和工厂方法用得最多,抽象工厂更多出现在框架设计层面。
面试回答时,不用所有细节都铺开,但最好能表达出一个判断:简单工厂适合类型不太多的场景,缺点是每次新增类型都要改工厂内部逻辑;工厂方法适合类型增长频繁、希望符合开闭原则的场景——加新类型就加一个新工厂子类,旧代码不用动。这种“什么时候用谁”的权衡意识,是面试官真正想考察的。
5.2 在Unity中落地一个怪物工厂
举个例子,你的游戏里有几种怪物:近战小怪、远程射手、Boss。普通代码写起来可能是这样:
public GameObject CreateMonster(MonsterType type, Transform spawnPoint) { switch (type) { case MonsterType.Melee: return Object.Instantiate(meleePrefab, spawnPoint.position, spawnPoint.rotation); case MonsterType.Ranged: return Object.Instantiate(rangedPrefab, spawnPoint.position, spawnPoint.rotation); case MonsterType.Boss: return Object.Instantiate(bossPrefab, spawnPoint.position, spawnPoint.rotation); default: return null; } }这个代码在功能上没毛病,但如果以后要加“怪物出生必须有出场动画”“不同怪物需要不同的初始Buff”“从表驱动读取怪物等级、外形、掉落表”,这段逻辑会越来越臃肿。用简单工厂封装一下,把创建细节隔离在一个类里,业务层的刷怪点只管调工厂,这是初级项目里足够好用的设计。
再进一步,如果怪物的创建逻辑依赖不同的“关卡主题”,比如森林关卡和地下城关卡的怪物外观完全不同,就需要工厂方法模式。定义一个MonsterFactory基类,森林工厂、地下城工厂分别继承,各自决定创建什么怪物、穿什么皮肤。这样你的刷怪点逻辑完全不用关心“我在哪个主题”,它只需要持有抽象的MonsterFactory引用,在场景加载时由配置系统注入具体工厂。
面试时你可以把这个例子平铺直叙讲出来,但重点是结尾的结论:工厂模式不是用来炫技的,它解决的是“创建逻辑变化频率高于使用逻辑”时的维护成本问题。如果项目里对象创建就一行Instantiate,你非套个工厂,那就是过度设计,面试官反而会皱眉。
5.3 面试回答范例
有一种回答方式能兼顾条理和深度:先给结论,再举项目例子,最后说权衡。比如面试官问“你项目里的敌人是怎么创建的”,你可以这样答:
“我之前的项目有近战、远程、Boss三类怪物,创建入口是关卡刷怪点。一开始刷怪逻辑直接写了switch去Instantiate,后来新增怪物类型越来越多,刷怪点的代码就开始变乱,而且每种怪物出生时的初始化逻辑也不一样,我干脆把创建动作抽到一个MonsterFactory里。调用方只传类型和出生点,拿到成品实例。后续加新怪物,只在工厂内部扩展,调用方不用动。代价是工厂类本身会膨胀,所以后来我又根据怪物家族拆成了子工厂。总结下来,工厂模式帮我隔离了创建逻辑的变化,代价是多了一层封装,但对于怪物数量增长快的项目,这层封装非常值得。”
这段话的结构是“遇到了什么问题→用了什么方案→效果如何→代价是什么→我的判断”,完全符合面试官对“架构思维”的期待。
6. 命令模式:一个容易被忽略的高频加分项
6.1 命令模式的基础骨架
命令模式在初级面经里经常被忽略,但它在游戏开发里的应用场景非常真实:输入系统、回放系统、撤销重做系统、网络同步里的操作预测,全都能用命令模式来实现。它的核心思想就是一句话:把“一个操作”本身封装成一个对象。
普通代码里,“移动角色”就是直接调用角色的Move方法;命令模式里,“移动角色”被封装成一个MoveCommand对象,这个对象暴露Execute和Undo两个方法,由Invoker统一管理。这样你就获得了一些额外能力:把命令按顺序存进列表,就能做回放;调用Undo,就能做撤销;把命令序列化成数据,就能同步给全网玩家。
一个最简骨架:
public interface ICommand { void Execute(); void Undo(); } public class MoveCommand : ICommand { private PlayerUnit _unit; private Vector3 _before; private Vector3 _after; public MoveCommand(PlayerUnit unit, Vector3 targetPosition) { _unit = unit; _before = unit.transform.position; _after = targetPosition; } public void Execute() { _unit.transform.position = _after; } public void Undo() { _unit.transform.position = _before; } } public class CommandInvoker { private Stack<ICommand> _history = new Stack<ICommand>(); public void ExecuteCommand(ICommand command) { command.Execute(); _history.Push(command); } public void UndoLastCommand() { if (_history.Count > 0) { var command = _history.Pop(); command.Undo(); } } }这里有几个细节需要解释。为什么用Stack保存历史?因为Undo要倒着撤销最近的操作,Stack是后进先出的天然数据结构。为什么MoveCommand要记录_before和_after两个位置?因为真正健壮的撤销不能依赖“反推”,必须保存完整的“操作前”和“操作后”状态。比如单位中途被推走,_before就不准了,记录快照更可靠。
6.2 命令模式在游戏里的实际应用场景
初级面试可能不会让你写完整的命令模式,但你完全可以主动举一个应用例子。最常见的是“回合制棋盘游戏”的撤销:玩家每走一步棋,就生成一步MoveCommand压入历史栈,点击悔棋时从栈顶弹出并调用Undo。比“存档回档”轻量得多,而且可以精确控制哪些操作允许撤销、哪些不允许。
另一个例子是回放系统。你想做“录像回放”,如果每帧记录全部状态,内存占用会爆炸。用命令模式,把玩家的所有操作录成命令列表,回放时重放命令,游戏逻辑重新跑一遍,就能实现完全一致的战斗回放。战场游戏、塔防、自走棋的回放基本都是这个思路。
还有输入系统。之前文章里说过用Unity自带的Input系统直接判断按键,复杂项目通常会做“输入命令映射”:按A键生成左移命令,按B键生成攻击命令。如果要改键、支持手柄或者做输入缓冲,都能在命令层做文章。游戏引擎的输入系统如Input System底层设计里,也有类似的思路。
这里要提醒一个坑:Undo的时候,如果命令里持有的是已经被销毁的对象引用,Undo调用会报NullReferenceException。处理办法是命令里不要直接持有对象引用,而是持有对象的唯一ID,Undo时通过ID去全局系统里查找对象;如果对象不存在,说明这个命令已经失效,直接丢弃。这个层级细节如果你能主动说出来,面试官会觉得你的工程经验远超初级。
6.3 值得扩展的方向:回放、网络同步、输入缓冲
命令模式的扩展方向特别适合在面试中抛出来,引导面试官往你熟悉的方向追问。比如你可以说:搭好了命令系统之后,我把命令序列化成了JSON或者二进制数据,这样本地操作记录就能变成网络同步协议,对手的操作就是一条条命令;还在客户端做了预测,本地玩家输入先执行命令,服务端回包校验,有问题再回滚。虽然初级面试不要求你真做过网络预测,但能讲清这个思路,说明你对命令模式的上限是有认知的。
另一个扩展方向是“输入缓冲”和“连续技”。在动作游戏中,玩家在上一招还没打完时就按了下一招,系统可以把输入命令暂时缓冲起来,当前状态结束立即取出下一条命令执行。这本质上是把输入变成了可排队、可延迟处理的对象,命令模式天然适合这个场景。
聊到这里你可能会发现,命令模式跟前面的状态机、观察者都能配合起来用。命令负责“操作”,状态机负责“操作后的行为变化”,事件负责“变化之后的通知”——三个模式各司其职,这是一个很完整的系统设计思路。面试讲到这种组合拳,比单个模式说半天要有力得多。
7. 初级面试的答题技巧与准备建议
7.1 设计模式问题的高分回答结构
看了前面几个模式的拆解,你应该已经感觉到:面试官考设计模式,重点从来不是“你背了多少定义”,而是“你能否在真实项目里识别出模式的应用场景,并讲清楚取舍”。所以回答设计模式题,我强烈推荐一个固定结构:定义一句话→项目案例→权衡取舍→总结。
定义一句话,是告诉面试官你懂这个模式的基本概念,别啰嗦,两句话以内。项目案例是主体,要具体到“我遇到了什么问题,当时项目规模多大,在哪里用了什么模式,效果怎么样”。权衡取舍是关键,主动说这个模式的缺点、替代方案、为什么没选替代方案。最后总结,一两句话收尾,点明核心思想即可。
举个例子,被问到观察者模式,你可以这样组织回答:“观察者模式是让消息的发布方和订阅方解耦的设计(定义)。我在项目里做战斗飘字和UI血条时,如果用直接调用,UI会引用一大票战斗模块,后来我用事件中心广播血量变化事件,UI只订阅事件(案例)。这个方案的缺点是事件多了以后不好查是谁在监听谁,所以我只给跨模块通信用事件,模块内部还是普通方法调用(权衡)。核心价值是UI彻底不依赖战斗逻辑了,加新UI表现非常快(总结)。”
这个结构回答下来,面试官很难挑毛病,因为既有深度又有实例。
7.2 常见面试题速查表
我整理了一份初级Unity客户端面试里常见的设计模式考法和回答要点,你可以按这个表自查:
| 模式 | 常见问法 | 回答要点 | 易踩的坑 |
|---|---|---|---|
| 单例模式 | 全局管理器怎么做、线程安全如何保证 | Unity生命周期、重复实例清理、DontDestroyOnLoad | 忘记了OnDestroy清空引用 |
| 观察者模式 | 两个模块怎么解耦通讯、事件监听的内存问题 | 事件中心结构、成对订阅退订 | 忘记RemoveListener导致泄漏 |
| 对象池模式 | 子弹特效怎么优化、GC压力怎么缓解 | Stack存储、Get/Release、SetActive切换、预创建 | 没有状态重置就复用对象 |
| 状态机模式 | 角色状态怎么管理、敌人AI怎么做 | IState接口、迁移条件、Animator联动 | 迁移条件写太散,状态复用差 |
| 工厂模式 | 怪物类型很多,创建逻辑怎么组织 | 创建与使用分离、按类型扩展 | 滥用工厂导致过度设计 |
| 命令模式 | 撤销回放怎么做、输入缓冲怎么设计 | ICommand、撤销栈、记录快照 | Undo时对象已被销毁 |
这张表不是让你背的,而是帮你查缺补漏。比如“对象池模式”下面如果你连“Stack存储”这个点都没想到,建议把前面对应章节再读一遍。初级面试的覆盖面其实不算广,把高频模式练到“现场能写代码、随口能讲案例”的程度,比贪多嚼不烂刷十几个模式更有价值。
7.3 结合游戏形态谈设计模式
面试的最后一轮,面试官经常会把话题从具体模式拉远,问你“你怎么看待设计模式和游戏研发的关系”。这时候如果你能把模式和游戏形态结合起来谈,印象分会很高。
比如谈到微信小游戏或者小程序游戏,你可以说:这种项目的包体小、内存紧张、启动即玩,性能敏感度极高,所以对象池和资源管理一定是重点,观察者模式可以帮我把逻辑层跟UI和音频彻底解耦,方便小步高频迭代。谈到WebGL端游戏,你可以提一句:浏览器环境下IO和持久化有自己的限制,比如文件读写方案和传统端游不太一样,代码分层的合理性会影响后续排查问题的效率,架构更要有边界。谈到数字孪生项目,你可以说:这种项目数据来源多、接口杂,观察者模式用来做前端数据分发很合适,改动数据结构时只有订阅者需要感知,业务接入成本低。谈到游戏开发用C++还是C#,你可以补一句:语言差异会影响部分模式实现方式,但模式的思想是跨语言的,我自己C#用得熟,理解C++里的虚函数和模板实现时也很快。
这些回答的共同点是:不空谈理论,而是把设计模式放回具体业务场景里看价值。面试官要的从来不是“你学会了一堆模式”,而是“你能在合适的场景用合适的工具把事做好”。
到了这个层面,设计模式初级面试的问题基本就聊透了。我自己带新人的方法是:面完当场让他手写一个状态机,写完再追问“如果加入一个格挡状态,要改哪些文件”。大部分背题选手到这里就卡住了。所以如果你能看到这里,建议真的打开工程,把上文的代码自己敲一遍,敲完再想想新需求要怎么扩展。面试的临场发挥,从来都来自平时的积累,不来自临阵磨枪。