Appearance
面试前 1 天速记
Unity 生命周期顺序
标准回答
Unity 常见生命周期顺序是: Awake -> OnEnable -> Start -> FixedUpdate / Update / LateUpdate -> OnDisable -> OnDestroy
关键点
Awake:脚本实例加载时调用一次,适合做自身初始化,比如缓存组件、初始化字段。 OnEnable:对象或组件启用时调用,可能调用多次;它在 Start 之前。 Start:第一次进入更新前调用一次,适合做依赖其他对象初始化后的逻辑。 FixedUpdate:固定时间步调用,适合物理相关逻辑。 Update:每帧调用,适合输入、普通逻辑、计时。 LateUpdate:所有 Update 之后调用,常用于相机跟随。 OnDisable:对象或组件被禁用时调用,适合取消事件订阅、停止协程。 OnDestroy:对象销毁或场景卸载时调用,适合最终清理。
面试容易问的坑
TIP
OnEnable 是在 Start 之前调用的。 Start 不是每次启用都调用,只会在第一次启用并进入更新前调用一次。 不同脚本之间的执行顺序默认不稳定,如果有强依赖,要用 Script Execution Order 或自己做初始化流程管理。
协程不是线程
标准回答
对,Unity 协程不是线程。协程看起来像“异步”,但它通常还是在 Unity 主线程里执行。它的本质是一个 IEnumerator 状态机,Unity 在主循环里按条件继续调用它的 MoveNext()。所以协程不会自动并行,也不会把耗时任务丢到后台。
底层理解
StartCoroutine 启动后,Unity 会保存这个协程的 IEnumerator。 当执行到 yield return null,协程暂停,把执行权还给 Unity;下一帧 Unity 再继续调用 MoveNext()。 当执行到 WaitForSeconds,Unity 会等时间条件满足后再继续执行。
所以协程的核心是:暂停和恢复,不是并行执行。
面试加分说法
NOTE
如果协程里写大量计算,比如一个很重的寻路循环、压缩逻辑、复杂 AI 计算,即使它在协程里,也还是会卡主线程。除非你在循环里分段 yield,把任务拆到多帧,否则它和普通函数一样会阻塞这一帧。
真正的线程是由操作系统调度的执行流,可以和主线程并行。但 Unity 大多数 API 只能在主线程调用,比如 Transform、GameObject、Instantiate。所以子线程可以做纯计算、IO、网络解析,结果要切回主线程再操作 Unity 对象。
Dictionary 是哈希表
标准回答
对,Dictionary<TKey, TValue> 底层通常就是哈希表。它通过 key 计算哈希值,把数据放到对应的 bucket 里,所以平均查找、插入、删除都接近 O(1)。
底层原理
Dictionary 里通常有两个核心数组:
buckets:保存某个哈希桶对应的 entry 索引。 entries:真正保存 hashCode、key、value、next。
查找时流程大概是:
- 对
key调用GetHashCode()。 - 根据 hash 定位到某个 bucket。
- bucket 找到 entries 里的入口。
- 如果有哈希冲突,就沿着
next找下一个 entry。 - 最后用
Equals()判断 key 是否真的相等。 - 找到后返回对应 value。
面试要补充的点
IMPORTANT
哈希表不是完全不会冲突,不同 key 可能算到同一个 bucket。冲突少时查找接近 O(1),冲突特别严重时最坏可能退化到 O(n)。
如果容量不够,Dictionary 会扩容,创建更大的内部数组并重新分布元素,所以大量插入时可以提前指定容量,减少扩容成本。
常见坑
自定义类型当 key 时,要保证 GetHashCode() 和 Equals() 逻辑一致。 如果 key 插入后参与哈希的字段被修改,可能导致以后查不到这个 key。 遍历 Dictionary 时修改集合,也会触发版本检查并抛异常。
List 是动态数组
标准回答
对,List<T> 底层可以理解成动态数组。它内部维护一段连续的 T[] 数组,用 Count 表示当前实际元素数量,用 Capacity 表示内部数组容量。当 Add 时容量不够,就会申请一个更大的数组,把旧元素复制过去,再放入新元素。
底层原理
List<T> 不是链表,它的核心优势来自数组:
list[i] 可以通过下标直接访问,所以随机访问是 O(1)。 尾部 Add 如果不触发扩容,也是 O(1)。 如果容量满了,扩容会分配新数组并复制旧元素,这一次是 O(n),但平均摊还下来尾部添加仍接近 O(1)。
面试要补充的点
WARNING
Count 是真实元素个数,Capacity 是内部数组容量。 Clear() 通常只是把 Count 清零,不一定释放内部数组容量。 Insert 或 RemoveAt 如果发生在中间位置,需要移动后面的元素,所以通常是 O(n)。 Contains、IndexOf 这种查找要从头扫,也是 O(n)。
Unity 里怎么用
如果大概知道数量,比如怪物列表、子弹列表、UI Item 缓存,可以提前设置 Capacity,减少运行中扩容带来的分配和复制。频繁中间插入删除的场景,不一定适合用 List<T>;如果只是按下标访问和尾部增删,List<T> 就很合适。
对象池减少创建销毁和 GC
标准回答
对,对象池可以减少频繁创建、销毁带来的 CPU 开销和 GC 压力。它的核心思路是:对象不再每次都 Instantiate 和 Destroy,而是提前创建一批,使用时取出来,用完后重置状态并放回池子,下次继续复用。
底层理解
在 Unity 里,频繁 Instantiate / Destroy 不只是 C# 对象问题,还涉及 Unity 原生对象创建、组件初始化、引用关系处理、销毁延迟等成本。大量短生命周期对象,比如子弹、特效、飘字、伤害数字,如果每次都创建销毁,就容易造成帧时间尖峰和 GC Alloc。
对象池把这个过程改成:Get -> Activate -> Use -> Reset -> Release。 运行时主要做取出和归还,开销更稳定。
面试加分说法
CAUTION
对象池不是完全没有代价。它本质上是用内存换 CPU 和 GC 稳定性:池里的对象会常驻内存,所以要控制初始容量、最大容量和释放策略。归还时还要重置状态,比如位置、速度、事件订阅、协程、粒子状态、引用对象,否则会出现脏数据。还要防止重复归还,切场景时也要清理池子。
UGUI 卡在 Canvas Rebuild 和 Overdraw
标准回答
UGUI 卡在 Canvas Rebuild 和 Overdraw,我会先分清它是 CPU 侧问题 还是 GPU 侧问题。 Canvas Rebuild 主要是 CPU 侧 UI 重建、布局计算、批次重建;Overdraw 主要是 GPU 侧透明 UI 像素被反复绘制。
Canvas Rebuild 怎么看
如果 Profiler 里 Canvas.BuildBatch、Canvas.SendWillRenderCanvases、LayoutRebuilder、Graphic.Rebuild 比较高,通常说明 UI 频繁重建。常见原因是频繁改 Text、频繁 SetActive、大量 LayoutGroup、ContentSizeFitter、ScrollView Item 太多、动静 UI 放在同一个 Canvas 里。
优化方向是:拆 Canvas,动静分离;减少运行时 Layout 组件;ScrollView 用虚拟列表和对象池;文本变化合并刷新;不必要的 Raycast Target 关闭。
Overdraw 怎么看
如果 GPU 压力高,或者 Camera.Render 高,再看 Scene 的 Overdraw、Frame Debugger 或 RenderDoc。UGUI 大多数是透明绘制,多个 Panel、半透明背景、Mask、粒子特效、大面积透明图片叠在一起时,同一个像素会被画很多次。
优化方向是:减少全屏半透明层,裁掉图片透明空白,降低粒子和特效覆盖面积,减少 Mask 和嵌套 UI,能合并的背景尽量合并,低端机降低 UI 特效质量。
面试加分说法
IMPORTANT
我不会只说“UGUI 卡,所以减少 DrawCall”。UGUI 卡顿要先定位: 如果是 Canvas Rebuild 高,说明 CPU 在重建 UI; 如果是 Overdraw 高,说明 GPU 在重复画透明像素。 这两个问题表现都像卡顿,但优化方向完全不同。
Profiler 先定位瓶颈再优化
标准回答
我做性能优化时会先用 Profiler 定位瓶颈,再决定优化方案。不会一上来就凭感觉说是 DrawCall、多线程、对象池或者资源问题。因为卡顿可能来自 CPU、GPU、GC、UI Rebuild、物理、加载 IO、内存峰值,原因不同,优化方向也完全不同。
面试加分说法
CAUTION
我的流程一般是:先复现卡顿场景,记录机型、版本和操作路径;然后用 Profiler 看 Timeline 和 Hierarchy,找耗时最高的 Marker。 如果是 BehaviourUpdate 高,我会查脚本、AI、Update 数量;如果是 GC.Alloc 高,我会查字符串、LINQ、临时集合、频繁 new;如果是 Canvas.BuildBatch 高,我会查 UGUI Rebuild;如果是 Camera.Render 或 GPU 高,我会进一步用 Frame Debugger 或 RenderDoc 看 Overdraw、阴影、后处理、SetPass。
关键点
Profiler 的价值不是证明“我优化过”,而是告诉我真正瓶颈在哪里。 定位后再选方案:对象池、分帧、异步加载、合批、降级、缓存、资源压缩。最后还要同条件复测,用优化前后数据证明效果。
DrawCall 是 CPU 提交渲染命令
标准回答
对,DrawCall 可以理解成 CPU 向图形 API 提交一次绘制命令。CPU 负责准备渲染状态,比如 Mesh、Material、Shader Pass、纹理、矩阵等,然后提交绘制命令;GPU 再真正执行顶点处理、光栅化、片元着色,把东西画到屏幕上。
面试加分说法
CAUTION
DrawCall 多,通常首先会增加 CPU 提交命令和状态切换成本,不一定直接说明 GPU 忙。真正 GPU 忙通常还要看像素复杂度、Overdraw、阴影、后处理、分辨率、Shader 复杂度等。
DrawCall、Batch、SetPass 区别
DrawCall:一次绘制提交。 Batch:Unity 尝试把多个对象合并成更少的提交。 SetPass Call:切换 Shader Pass 或渲染状态,通常比单纯 Draw 更贵。
优化方向
常见优化有:合批、图集、复用材质、减少材质实例、Static Batching、Dynamic Batching、GPU Instancing、SRP Batcher。 但也不能盲目合批,因为合批可能增加内存、影响剔除粒度,甚至让本来不可见的对象也被一起提交。
AssetBundle 要处理依赖和卸载
标准回答
对,AssetBundle 不能只处理“加载主包”,还必须处理依赖加载和资源卸载。因为一个资源可能依赖其他 Bundle,比如角色 Prefab 依赖材质包,材质又依赖贴图包。如果只加载主包,不加载依赖,就可能出现材质丢失、贴图丢失、Shader 异常等问题。
底层思路
加载时一般先通过 AssetBundleManifest 查询依赖,比如 GetAllDependencies(bundleName),然后先加载依赖 Bundle,再加载目标 Bundle,最后 LoadAsset。 卸载时不能只卸载主 Bundle,还要考虑依赖包有没有被其他资源继续使用,所以通常要做引用计数:每加载一次引用加一,每释放一次引用减一,只有引用归零后才允许卸载。
Unload 的区别
AssetBundle.Unload(false):卸载 Bundle 容器本身,但已经加载出来的资源对象可能还存在。 AssetBundle.Unload(true):会卸载从 Bundle 加载出来的资源对象,如果这些资源还在场景中被使用,可能导致引用丢失或显示异常。
所以更稳的流程是:先销毁场景实例或归还对象池,再释放资源引用;等主包和依赖包引用计数都归零后,再按策略卸载。
面试加分说法
CAUTION
我会把 AssetBundle 管理成统一的资源句柄:谁 Load,谁 Release;主包和依赖包都走引用计数。这样可以避免重复加载,也能避免依赖包被提前卸载。对于 UnloadUnusedAssets,我不会频繁调用,因为它代价比较高,一般放在切场景、进入加载界面、内存压力较大时统一处理。
C++ 虚函数靠虚表
一句话定义
C++ 虚函数的多态调用,主流实现就是靠对象里的 vptr 找到类的 vtable,再从虚表里取出真正要调用的函数地址。
底层原理
当你写:
c
#include <iostream> // 引入标准输出库,方便演示调用结果
class Base { // 定义基类 Base
public: // 下面成员允许外部访问
virtual ~Base() = default; // 虚析构,保证用基类指针删除派生对象时析构完整
virtual void Foo() { std::cout << "Base\n"; } // virtual 表示 Foo 支持运行时多态
}; // Base 类定义结束
class Derived : public Base { // Derived 继承 Base
public: // 下面成员允许外部访问
void Foo() override { std::cout << "Derived\n"; } // override 表示重写 Base 的虚函数
}; // Derived 类定义结束
int main() { // 程序入口
Base* p = new Derived(); // 基类指针 p 指向真实的 Derived 对象
p->Foo(); // 通过对象里的 vptr 找到 Derived 的 vtable,最终调用 Derived::Foo
delete p; // 通过虚析构正确释放 Derived 对象
return 0; // 程序正常结束
} // main 函数结束p 的静态类型是 Base*,但它指向的真实对象是 Derived。调用 p->Foo() 时,编译器不能只按 Base 写死函数地址,而是生成一段“间接调用”:先从对象内存里取 vptr,再通过 vptr 找到 Derived 的虚表,最后调用虚表中 Foo 对应的函数地址。
面试加分点
NOTE
虚表是主流编译器实现方式,但 C++ 标准不强制规定必须这样实现。虚函数的代价主要是对象通常多一个 vptr,调用时多一次间接寻址,并且可能影响内联优化。
真正面试时可以这样说:虚函数解决的是“基类指针调用派生类实现”的问题,本质是运行时动态绑定;普通函数多数是编译期绑定,虚函数则通过 vptr -> vtable -> function address 完成多态调用。
shared_ptr 循环引用用 weak_ptr
标准回答
对,shared_ptr 循环引用通常用 weak_ptr 打断。核心原因是:shared_ptr 会增加强引用计数,强引用计数不为 0,对象就不会析构;而 weak_ptr 只观察对象,不拥有对象,不会增加强引用计数。
底层原理
shared_ptr 背后通常有一个控制块,里面记录:
strong count:强引用计数,决定对象什么时候析构。weak count:弱引用计数,只负责观察控制块,不决定对象生命周期。
如果 A 里有 shared_ptr<B>,B 里又有 shared_ptr<A>,即使外部变量释放了,A 和 B 还是互相把对方的强引用计数撑住,所以析构函数不会执行,这就是循环引用。
解决办法是让其中一边改成 weak_ptr,比如父对象持有子对象用 shared_ptr,子对象反向指向父对象用 weak_ptr。
c
#include <memory> // 引入智能指针 shared_ptr 和 weak_ptr
#include <iostream> // 引入输出库,方便演示
struct Player; // 前置声明 Player,避免 Skill 里直接依赖完整定义
struct Skill { // 定义技能对象
std::weak_ptr<Player> owner; // 技能只观察拥有者,不增加 Player 的强引用计数
void Use() { // 定义使用技能的方法
if (auto p = owner.lock()) { // lock 成功说明 Player 还活着,并临时得到 shared_ptr
std::cout << "Use skill\n"; // 对象有效时才执行技能逻辑
} // 临时 shared_ptr 离开作用域,强引用计数自动减少
} // Use 函数结束
}; // Skill 结构体结束
struct Player { // 定义玩家对象
std::shared_ptr<Skill> skill; // Player 拥有 Skill,所以这里用 shared_ptr
}; // Player 结构体结束面试加分点
TIP
weak_ptr 不能直接访问对象,因为它不保证对象还活着。正确做法是先 lock(),如果返回的 shared_ptr 不为空,再使用对象。
项目里常见场景是:父子对象、观察者模式、事件监听者、缓存系统、资源反查关系。面试时可以说一句很加分的话:我不会无脑把所有指针都换成 weak_ptr,我会先明确谁拥有生命周期,谁只是观察关系;拥有者用 shared_ptr,反向引用或观察者用 weak_ptr。
TCP 可靠,UDP 更轻更适合实时游戏
标准回答
这句话是对的,但面试里要补完整:TCP 的优势是可靠、有序、自动重传,适合登录、聊天、支付、背包操作这类“不能丢、顺序要对”的业务;UDP 的优势是轻量、低延迟、不会因为旧包丢失卡住后面的新包,所以更适合实时游戏里的移动同步、状态快照、帧同步输入、动作表现。
底层原理
TCP 帮你做了很多事:连接管理、确认 ACK、丢包重传、顺序保证、拥塞控制。它的好处是省心,坏处是可能有“队头阻塞”:比如包 1 丢了,包 2、包 3 即使已经到了,也可能要等包 1 重传成功后才能按顺序交给上层。
实时游戏怕的不是“少一个旧状态”,而是“旧状态卡住新状态”。比如玩家移动同步,位置包 100 丢了,但位置包 101、102 已经到了,这时候通常应该直接用最新位置,而不是等旧包重传回来。
UDP 不保证可靠、不保证顺序、不保证不重复,但正因为它机制少,延迟更可控。游戏可以在 UDP 上自己做一层协议:重要消息加序号、ACK、重传;不重要的状态同步只保留最新包,旧包迟到直接丢。
游戏场景怎么选
移动、朝向、怪物位置、状态快照:通常走 UDP,不追求每个包都到,追求最新状态。
技能释放、伤害结算、道具变化、任务奖励:必须可靠,可以用 TCP,也可以用 UDP 自己实现可靠消息。
登录、支付、公告、聊天:一般 TCP 更合适,因为可靠性和顺序比低延迟更重要。
面试加分说法
NOTE
我不会简单说“游戏一定用 UDP”。更准确的说法是:实时核心同步更适合 UDP,但业务上会区分消息类型。高频状态用不可靠 UDP,关键操作用可靠 UDP 或 TCP。真正项目里还要处理包序号、ACK、重传、乱序、去重、心跳、超时、断线重连和协议版本兼容。
A* 是 g+h
标准回答
对,A* 的核心评分就是:
c
f(n) = g(n) + h(n)g 是从起点走到当前点的真实代价,h 是从当前点到终点的预估代价,f 就是这个点的综合评分。A* 每次都会从 Open Set 里取 f 最小的点继续搜索。
底层原理
如果只看 g,就像 Dijkstra,会比较稳但可能搜索很多无关区域;如果只看 h,就像贪心,会一直朝终点冲,但容易被障碍物骗。A* 把两者加起来:既考虑“已经走了多少路”,也考虑“离目标还有多远”。
c
int newG = current.G + moveCost; // 计算从起点经过 current 再走到 next 的真实成本
int newH = Math.Abs(next.X - goal.X) + Math.Abs(next.Y - goal.Y); // 用曼哈顿距离估算 next 到终点的距离
int newF = newG + newH; // A* 的核心公式:f = g + h
if (newG < next.G) // 如果这条新路径比 next 原来的路径更短,就更新 next
{ // 进入节点更新逻辑
next.G = newG; // 保存新的真实成本 g
next.H = newH; // 保存新的预估成本 h
next.F = newF; // 保存新的综合评分 f
next.Parent = current; // 记录父节点,最后用它回溯出完整路径
} // 节点更新结束Unity/游戏场景
在格子地图、战棋、怪物追击、塔防寻路里,A* 很常见。比如怪物要从出生点走到玩家,g 表示已经走过的路,h 表示离玩家还差多远,f 最小的格子就是当前最值得继续探索的方向。
面试加分点
CAUTION
h 很关键。如果 h 估得太大,A* 可能变快,但不一定保证最短路;如果 h 保守,比如四方向格子用曼哈顿距离,通常能保证最优路径。项目里 Open Set 一般用优先队列,不然每次找最小 f 都遍历列表,节点多了会很慢。
MVP 是模型、视图、投影变换
标准回答
对,MVP 就是 Model、View、Projection 三个矩阵变换。它的作用是把一个模型顶点从“模型自己的局部坐标”,一步步变到 GPU 可以裁剪和光栅化的“裁剪空间”。
底层原理
Model 矩阵负责把模型空间变到世界空间。比如一个角色模型的顶点原本只是相对角色原点的位置,乘上 M 后,才知道它在整个场景里的位置。
View 矩阵负责把世界空间变到相机空间。可以理解为:不是物体真的动了,而是把世界换算到“以相机为原点”的坐标系里。
Projection 矩阵负责把相机空间变到裁剪空间。透视投影会产生近大远小,正交投影则不会产生透视缩放。
公式常写成:
c
clipPos = P * V * M * localPos注意读的时候是从右往左:先 M,再 V,最后 P。
Shader 代码理解
c
float4 localPos = float4(vertex.xyz, 1.0); // 把模型空间顶点扩展成齐次坐标
float4 worldPos = mul(unity_ObjectToWorld, localPos); // 乘 Model 矩阵,从模型空间变到世界空间
float4 viewPos = mul(UNITY_MATRIX_V, worldPos); // 乘 View 矩阵,从世界空间变到相机空间
float4 clipPos = mul(UNITY_MATRIX_P, viewPos); // 乘 Projection 矩阵,从相机空间变到裁剪空间
return clipPos; // 返回裁剪空间坐标,后续交给 GPU 做裁剪和光栅化面试加分点
IMPORTANT
在 Unity 里,顶点着色器常用 UnityObjectToClipPos(vertex),它本质上就是帮你做模型空间到裁剪空间的变换。
常见坑是矩阵乘法顺序不能乱,不同图形 API 还可能涉及行主序、列主序、左右手坐标系、裁剪空间 Z 范围差异。面试里说到这些,基本就不是只会背 API 了。
项目回答一定要讲难点、方案、结果
标准回答
对,项目回答一定要讲“难点、方案、结果”。因为面试官真正想判断的不是你有没有做过功能,而是你有没有解决复杂问题的能力,有没有工程取舍,有没有用数据验证效果。
面试回答框架
可以按这个顺序讲:
- 项目背景:做了什么游戏或系统,平台是什么,目标是什么。
- 我的职责:我负责哪些模块,边界是什么。
- 技术难点:当时遇到什么问题,为什么难。
- 解决方案:怎么拆模块,怎么实现,怎么优化。
- 结果数据:优化前后 FPS、GC、内存、加载时间、包体大小等变化。
- 复盘反思:如果重做一次,会怎么改。
可以直接背的表达
我做的是一个 XX 项目,我主要负责 XX 模块。这个模块最大的难点是 XX,因为它涉及 XX 问题。我的方案是先把数据层、逻辑层、表现层拆开,然后用 XX 机制解决 XX。最后结果是 XX 指标从 A 优化到 B,同时这个方案的代价是 XX。如果重做一次,我会补充 XX 工具或规范,让系统更容易维护。
面试加分点
IMPORTANT
不要只说“我做了背包、技能、对象池”,要继续往下讲:为什么要做、原来有什么问题、怎么定位、方案有什么代价、结果怎么证明。
一句话记住:项目题不是“我做了什么”,而是“我遇到什么难题,用什么方案,拿到了什么结果”。