Appearance
终极自测
给自己计时 60 分钟,能答出来说明基础已经能打。
5 分钟讲清楚你的项目
标准回答
5 分钟讲项目,不要按功能流水账讲。按这个顺序:项目背景、我的职责、核心系统、技术难点、解决方案、结果数据、复盘反思。重点一定放在“难点、方案、结果”。
5 分钟版本
IMPORTANT
我做的是一个 Unity 俯视角动作 Roguelike Demo,目标是做完整玩法闭环:选角色、进入关卡、战斗成长、击败 Boss、结算并重开。项目主要面向移动端,所以除了玩法能跑通,还要考虑低端机帧率、GC、资源加载和 UI 性能。
我主要负责战斗和客户端基础框架,包括角色移动、技能释放、怪物 AI、对象池、事件系统、战斗 UI、资源异步加载和性能优化。模块边界上,我把战斗数据、战斗逻辑和表现层拆开,配置表负责技能和怪物参数,运行时系统只消费配置,不把数值硬编码在逻辑里。
项目里最难的问题是大量怪物、弹幕和特效同时出现时,低端机容易出现卡顿和 GC Alloc。我的处理方式是先用 Profiler 定位瓶颈,确认主要问题来自频繁 Instantiate、Destroy、大量 Update 和 UI 刷新。然后我做了对象池复用子弹、特效和伤害数字;用 Update Manager 合并分散的 Update;怪物 AI 按帧分批刷新;资源加载改成异步预加载,并做引用计数管理。
优化后,运行时 GC Alloc 明显下降,战斗中帧率更稳定,切场景峰值内存也更可控。这里的数据你面试时要替换成自己真实测过的数据,比如 FPS 从多少到多少,GC 从多少降到多少,加载时间从多少秒降到多少秒。
如果重做一次,我会更早补工具链,比如配置表校验、资源引用检查、对象池泄漏检测和性能采样面板。这样后期不是靠人肉检查问题,而是靠工具和规范提前暴露风险。
记忆模板
我做了什么项目;我负责什么模块;最难的问题是什么;我怎么定位;我怎么拆方案;结果提升了多少;如果重做会怎么改。
3 分钟讲清楚 Dictionary 底层
标准回答
Dictionary<TKey, TValue> 底层本质是哈希表。它通过 key 计算 hashCode,再把 hash 映射到某个桶 bucket,桶里记录的是 entries 数组中的入口位置。真正的键值对存在 entries 里,如果多个 key 落到同一个桶,就用 next 把这些 entry 串起来解决哈希冲突。
3 分钟讲法
TIP
Dictionary 可以理解成“数组 + 哈希函数 + 冲突链”。它内部通常有两个关键结构:buckets 和 entries。buckets 不是直接存 key-value,而是存某个桶对应的 entry 下标;entries 里才真正保存 hashCode、next、key、value。
查找时,先对 key 求 hash,再通过 hash 映射到 bucket 下标。找到 bucket 后,根据 bucket 记录的 entry 位置去 entries 数组里查。如果这个位置的 key 正好相等,就返回 value;如果不相等,说明发生了哈希冲突,就沿着 entry 的 next 继续找,直到找到 key 或链结束。
所以它平均查找是 O(1),不是因为不用比较,而是因为哈希函数把数据分散到了不同桶里,大多数情况下只需要比较很少几个 entry。最坏情况下,如果大量 key 都落到同一个桶,查找会退化成 O(n)。
插入时也类似:先算 hash,找 bucket,再写入一个新的 entry。如果 bucket 已经有元素,就把新 entry 的 next 指向原来的入口,再让 bucket 指向新 entry。容量不够时,Dictionary 会扩容,创建更大的数组,并重新建立 bucket 到 entry 的映射。
Unity/项目里怎么说
在 Unity 项目里,Dictionary 常用于配置表索引、对象 ID 查询、资源缓存、事件表、Buff 表、技能表。它适合“通过 ID 快速查对象”的场景。
但要注意:频繁新增可能触发扩容,扩容会分配新数组,可能带来 GC;如果 key 是自定义类型,要正确实现 GetHashCode() 和 Equals();如果 key 的 hash 分布很差,性能会明显下降。
面试加分点
可以补一句:Dictionary 快的前提是哈希分布好、容量合适、冲突少。项目里如果能预估数量,我会提前设置容量,避免运行时频繁扩容。
3 分钟讲清楚 Unity 协程
标准回答
Unity 协程不是线程。它本质是一个 IEnumerator 状态机,由 Unity 在主线程上调度执行。协程执行到 yield return 时会暂停,把控制权还给 Unity;等到下一帧、指定时间、异步加载完成等条件满足后,Unity 再继续调用它的 MoveNext()。
3 分钟讲法
协程可以理解成“把一段逻辑拆成多帧执行”。它不是开了一个新线程,所以不能用来并行执行大量 CPU 计算,也不能绕过 Unity API 的主线程限制。
底层上,C# 的 yield 会把函数编译成一个状态机对象,这个对象实现了 IEnumerator。Unity 调用 StartCoroutine 后,会保存这个迭代器对象,然后在合适的时机不断调用 MoveNext()。每次执行到 yield return xxx,协程就暂停,并把 xxx 返回给 Unity。Unity 根据这个返回值判断什么时候恢复。
常见的等待含义是:
yield return null:下一帧继续。
yield return new WaitForSeconds(1f):等待 1 秒,受 Time.timeScale 影响。
yield return new WaitForSecondsRealtime(1f):等待真实时间 1 秒,不受暂停影响。
yield return asyncOperation:等待异步加载完成。
代码示例
c
using System.Collections; // 引入 IEnumerator 和协程相关接口
using UnityEngine; // 引入 UnityEngine,才能使用 MonoBehaviour 和 WaitForSeconds
public class CoroutineDemo : MonoBehaviour // 定义一个挂在 GameObject 上的脚本
{ // 类开始
private void Start() // Unity 在脚本启用后的第一帧调用 Start
{ // Start 方法开始
StartCoroutine(AttackFlow()); // 启动协程,让攻击流程分段执行
} // Start 方法结束
private IEnumerator AttackFlow() // 定义一个协程,本质返回 IEnumerator
{ // 协程方法开始
Debug.Log("开始前摇"); // 打印前摇开始,表示第一段逻辑立即执行
yield return new WaitForSeconds(0.3f); // 暂停 0.3 秒,等待前摇时间结束
Debug.Log("产生伤害"); // 等待结束后继续执行,进入伤害判定阶段
yield return null; // 暂停到下一帧,避免所有逻辑挤在同一帧
Debug.Log("进入后摇"); // 下一帧继续执行,进入后摇阶段
} // 协程方法结束
} // 类结束Unity 项目里怎么用
协程适合做延迟执行、技能流程、动画等待、UI 渐变、异步加载等待、分帧处理大量任务。比如技能释放可以按“前摇 -> 伤害判定 -> 后摇”拆成几段,逻辑会更直观。
常见坑
NOTE
不要说协程是线程,这是面试高频扣分点。协程仍然跑在主线程,如果你在协程里写一个很重的循环,照样会卡主线程。
另外,频繁 new WaitForSeconds 可能产生 GC;需要高频使用时可以缓存等待对象,但要注意等待时间固定才适合缓存。对象销毁、StopCoroutine、StopAllCoroutines 都会导致协程停止。脚本禁用和 GameObject 禁用对协程停止行为也要分清,项目里最好明确生命周期管理。
5 分钟手写对象池
标准回答
5 分钟手写对象池,要讲清楚 5 点:为什么用、怎么取、怎么还、怎么重置、怎么防重复归还。对象池不是单纯用 Queue 存对象,关键是生命周期管理。
5 分钟讲法
对象池的核心是:对象不用时不销毁,而是隐藏并放回池子,下次需要时直接复用。这样可以减少 Instantiate、Destroy、托管分配和 GC 抖动。Unity 里常用于子弹、特效、伤害数字、怪物、ScrollView Item。
手写时我会用 Queue<GameObject> 保存空闲对象,用 HashSet<GameObject> 记录哪些对象属于池子,再用一个 freeSet 防止重复归还。Get 时从队列取,没有就扩容;Release 时重置状态、隐藏对象、放回队列。
c
using System.Collections.Generic; // 引入 Queue 和 HashSet 集合
using UnityEngine; // 引入 Unity 的 GameObject、Transform、Object 等类型
public class GameObjectPool // 定义一个通用的 GameObject 对象池
{ // 类开始
private readonly GameObject prefab; // 保存要复用的预制体
private readonly Transform root; // 保存池中对象的父节点,方便层级管理
private readonly Queue<GameObject> freeQueue = new Queue<GameObject>(); // 保存当前空闲对象
private readonly HashSet<GameObject> allObjects = new HashSet<GameObject>(); // 记录所有由池创建的对象
private readonly HashSet<GameObject> freeSet = new HashSet<GameObject>(); // 记录已经归还的对象,防止重复归还
public GameObjectPool(GameObject prefab, int initialCount, Transform root = null) // 构造函数,传入预制体、初始数量和父节点
{ // 构造函数开始
this.prefab = prefab; // 保存预制体引用
this.root = root; // 保存父节点引用
Preload(initialCount); // 初始化时预创建一批对象
} // 构造函数结束
public void Preload(int count) // 预创建指定数量的对象
{ // Preload 方法开始
for (int i = 0; i < count; i++) // 循环创建 count 个对象
{ // for 循环开始
AddNewObjectToPool(); // 创建新对象并放入空闲池
} // for 循环结束
} // Preload 方法结束
private GameObject AddNewObjectToPool() // 创建一个新对象并加入池中
{ // AddNewObjectToPool 方法开始
GameObject obj = Object.Instantiate(prefab, root); // 实例化预制体并挂到 root 下
obj.SetActive(false); // 新对象先隐藏,等待取出使用
allObjects.Add(obj); // 记录这个对象属于当前对象池
freeQueue.Enqueue(obj); // 把对象加入空闲队列
freeSet.Add(obj); // 标记对象当前处于空闲状态
return obj; // 返回新创建的对象
} // AddNewObjectToPool 方法结束
public GameObject Get(Vector3 position, Quaternion rotation) // 从池中取出一个对象
{ // Get 方法开始
if (freeQueue.Count == 0) // 如果没有空闲对象
{ // if 开始
AddNewObjectToPool(); // 按需扩容创建一个新对象
} // if 结束
GameObject obj = freeQueue.Dequeue(); // 从空闲队列取出一个对象
freeSet.Remove(obj); // 标记对象不再处于空闲状态
obj.transform.SetPositionAndRotation(position, rotation); // 设置对象的位置和旋转
obj.SetActive(true); // 激活对象,让它进入使用状态
return obj; // 返回可使用的对象
} // Get 方法结束
public void Release(GameObject obj) // 把对象归还到池中
{ // Release 方法开始
if (obj == null) // 如果传入对象为空
{ // if 开始
return; // 直接返回,避免空引用
} // if 结束
if (!allObjects.Contains(obj)) // 如果对象不是这个池创建的
{ // if 开始
Object.Destroy(obj); // 销毁外来对象,避免污染池子
return; // 结束归还流程
} // if 结束
if (freeSet.Contains(obj)) // 如果对象已经在池中
{ // if 开始
return; // 直接返回,防止重复归还
} // if 结束
obj.transform.SetParent(root, false); // 归还时重新挂回池节点
obj.transform.localPosition = Vector3.zero; // 重置本地坐标,避免脏状态
obj.transform.localRotation = Quaternion.identity; // 重置本地旋转,避免脏状态
obj.SetActive(false); // 隐藏对象,停止显示和大部分逻辑
freeQueue.Enqueue(obj); // 放回空闲队列
freeSet.Add(obj); // 标记对象已经归还
} // Release 方法结束
public void Clear() // 清空整个对象池
{ // Clear 方法开始
foreach (GameObject obj in allObjects) // 遍历池创建过的所有对象
{ // foreach 开始
if (obj != null) // 如果对象还存在
{ // if 开始
Object.Destroy(obj); // 销毁对象,释放场景实例
} // if 结束
} // foreach 结束
allObjects.Clear(); // 清空总对象记录
freeQueue.Clear(); // 清空空闲队列
freeSet.Clear(); // 清空空闲状态记录
} // Clear 方法结束
} // 类结束面试加分点
WARNING
对象池有效的前提是对象能被正确重置。子弹归还时要清速度、生命周期、命中特效、Trail、粒子、事件订阅;UI Item 要清文本、图片、按钮事件和选中状态。重复归还是高频 bug,所以要做幂等保护。
池子容量一般按峰值预估,预创建一部分,不够时可以扩容。代价是内存常驻变高,所以战斗结束、切场景或资源卸载时要 Clear。优化效果要用 Profiler 验证,看 GC Alloc、Instantiate、Destroy 和帧耗时是否下降。
5 分钟手写反转链表
标准回答
反转链表用三指针:prev、curr、next。prev 表示已经反转好的部分,curr 表示当前处理的节点,next 用来临时保存后继节点,防止断链后找不到后面的节点。
5 分钟讲法
原链表是:
c
1 -> 2 -> 3 -> 4 -> null目标是改成:
c
4 -> 3 -> 2 -> 1 -> null每一轮做四步:
- 先保存
next = curr.next,防止后半段丢失。 - 反转当前指针:
curr.next = prev。 - 让
prev后移到curr。 - 让
curr后移到next。
循环结束时,curr == null,说明原链表处理完了,此时 prev 就是新头节点。
C# 手写代码
c
public class ListNode // 定义链表节点类
{ // 类开始
public int val; // 保存当前节点的值
public ListNode next; // 保存下一个节点的引用
public ListNode(int val = 0, ListNode next = null) // 定义构造函数,允许传入值和下一个节点
{ // 构造函数开始
this.val = val; // 把传入的 val 保存到当前节点
this.next = next; // 把传入的 next 保存为下一个节点
} // 构造函数结束
} // 类结束
public class Solution // 定义解题类
{ // 类开始
public ListNode ReverseList(ListNode head) // 定义反转链表函数,传入原链表头节点
{ // 函数开始
ListNode prev = null; // prev 指向已经反转好的链表头,初始为空
ListNode curr = head; // curr 指向当前正在处理的节点,初始为 head
while (curr != null) // 只要当前节点不为空,就继续反转
{ // while 循环开始
ListNode next = curr.next; // 先保存 curr 的下一个节点,防止断链
curr.next = prev; // 把当前节点的 next 指向 prev,完成当前节点反转
prev = curr; // prev 后移到当前节点,扩大已反转部分
curr = next; // curr 后移到原来的下一个节点,继续处理未反转部分
} // while 循环结束
return prev; // curr 为空时,prev 就是反转后的新头节点
} // 函数结束
} // 类结束复杂度
时间复杂度是 O(n),因为每个节点只处理一次。
空间复杂度是 O(1),因为只用了 prev、curr、next 三个额外指针。
常见坑
CAUTION
最容易错的是直接写 curr.next = prev,但没有先保存 next。这样会导致后面的链表丢失。另一个常见错误是最后返回 head,正确应该返回 prev。
5 分钟讲清楚 C++ 虚函数
标准回答
C++ 虚函数用于实现运行时多态:用基类指针或引用调用函数时,真正执行哪个函数,不只看指针类型,而是看对象的真实类型。主流编译器通常通过 vptr 和 vtable 实现虚函数派发。
5 分钟讲法
虚函数解决的是“基类接口调用派生类实现”的问题。比如游戏里有 Actor 基类,派生出 Player、Monster、Npc,它们都可以有自己的 Update() 或 Attack()。外部只拿 Actor*,但调用时希望执行真实对象自己的逻辑,这就是多态。
底层上,一个类只要有虚函数,对象里通常会多一个隐藏指针,叫 vptr。vptr 指向这个类对应的虚表 vtable。虚表里存的是虚函数地址。通过 Base* p = new Derived() 调用 p->Foo() 时,会先从对象中找到 vptr,再找到 Derived 的虚表,最后调用表里对应的 Derived::Foo()。
c
#include <iostream> // 引入标准输出库,用来演示调用结果
class Actor // 定义一个基类 Actor
{ // Actor 类开始
public: // public 表示下面成员可以被外部访问
virtual ~Actor() = default; // 虚析构,保证通过基类指针删除派生对象时析构完整
virtual void Attack() // 定义虚函数 Attack,允许派生类重写
{ // Actor::Attack 函数开始
std::cout << "Actor Attack\n"; // 输出基类攻击逻辑
} // Actor::Attack 函数结束
}; // Actor 类结束
class Player : public Actor // 定义 Player 类,并继承 Actor
{ // Player 类开始
public: // public 表示下面成员可以被外部访问
void Attack() override // override 表示重写基类的虚函数 Attack
{ // Player::Attack 函数开始
std::cout << "Player Attack\n"; // 输出玩家自己的攻击逻辑
} // Player::Attack 函数结束
}; // Player 类结束
int main() // 程序入口函数
{ // main 函数开始
Actor* actor = new Player(); // 基类指针指向派生类对象
actor->Attack(); // 运行时通过 vptr 和 vtable 调用 Player::Attack
delete actor; // 通过虚析构正确释放 Player 对象
return 0; // 返回 0 表示程序正常结束
} // main 函数结束代价和坑点
虚函数不是免费的。对象里通常会多一个 vptr,调用时多一次间接寻址,还可能影响编译器内联优化。多数情况下这个代价很小,但在高频、极致性能场景要知道它存在。
基类析构函数通常要声明为 virtual。否则如果你用 Base* 指向 Derived,然后 delete Base*,可能只调用基类析构,导致派生类资源没有释放完整。
构造函数和析构函数里调用虚函数也要小心。在构造和析构期间,对象还没有完全成为最终派生类型,或者已经在销毁过程中,所以虚函数不会按你想象的完整派生类多态方式派发。
面试加分点
IMPORTANT
可以补一句:vtable 是主流实现,但 C++ 标准不强制规定必须这样实现。面试里我会重点说概念是“动态绑定”,实现上常见是 vptr + vtable。
一句话收尾:虚函数让 C++ 可以通过统一的基类接口调用不同派生类行为,本质是运行时动态绑定,常见底层实现是对象持有 vptr,vptr 指向虚表,虚表里保存真实函数地址。
5 分钟讲清楚 shared_ptr 和 weak_ptr
标准回答
shared_ptr 表示共享所有权,多个 shared_ptr 可以共同管理同一个对象;weak_ptr 表示弱引用,只观察对象,不拥有对象,不会延长对象生命周期。两者通常共享同一个控制块,控制块里维护强引用计数和弱引用计数。
5 分钟讲法
shared_ptr 的核心是引用计数。每拷贝一个 shared_ptr,强引用计数加一;每销毁一个 shared_ptr,强引用计数减一。当强引用计数变成 0 时,管理的对象会被释放。
weak_ptr 不增加强引用计数,所以它不能直接访问对象。访问前必须调用 lock(),如果对象还活着,lock() 会返回一个临时 shared_ptr;如果对象已经释放,返回空。
c
#include <iostream> // 引入标准输出库
#include <memory> // 引入 shared_ptr、weak_ptr、make_shared
struct Player // 定义 Player 结构体
{ // Player 开始
std::string name; // 保存玩家名字
Player(const std::string& name) : name(name) {} // 构造函数初始化名字
~Player() { std::cout << name << " destroyed\n"; } // 析构时输出日志
}; // Player 结束
int main() // 程序入口
{ // main 开始
std::shared_ptr<Player> p1 = std::make_shared<Player>("hero"); // 创建对象和 shared_ptr
std::shared_ptr<Player> p2 = p1; // 拷贝 shared_ptr,强引用计数增加
std::weak_ptr<Player> weak = p1; // 创建 weak_ptr,只观察对象,不增加强引用计数
if (std::shared_ptr<Player> locked = weak.lock()) // 尝试把 weak_ptr 提升成 shared_ptr
{ // if 开始
std::cout << locked->name << "\n"; // 对象还活着时,安全访问对象
} // if 结束
p1.reset(); // 释放 p1 持有的强引用
p2.reset(); // 释放 p2 持有的强引用,对象在这里被析构
if (weak.expired()) // 判断 weak_ptr 观察的对象是否已经失效
{ // if 开始
std::cout << "object expired\n"; // 对象已经释放时输出提示
} // if 结束
return 0; // 程序正常结束
} // main 结束循环引用
shared_ptr 最大的坑是循环引用。比如 A 里有 shared_ptr<B>,B 里又有 shared_ptr<A>,外部引用都释放后,A 和 B 还互相持有强引用,强引用计数永远不归零,对象不会析构。
解决方式是:明确谁拥有谁。拥有关系用 shared_ptr,反向引用或观察关系用 weak_ptr。比如父节点持有子节点用 shared_ptr,子节点反向指向父节点用 weak_ptr。
面试加分点
TIP
make_shared 通常更推荐,因为对象和控制块常常可以一次分配,性能和局部性更好。但如果需要自定义 deleter,或者对象生命周期和控制块分配要分开控制,也可以直接构造 shared_ptr。
引用计数的增减通常是线程安全的,但这不代表被管理对象本身线程安全。多个线程同时改对象内容,仍然需要锁或其他同步手段。
一句话收尾:shared_ptr 管所有权,weak_ptr 管观察关系;shared_ptr 决定对象什么时候释放,weak_ptr 用来安全观察对象并打断循环引用。
5 分钟讲清楚 DrawCall 优化
标准回答
DrawCall 是 CPU 向 GPU 提交一次绘制命令。优化 DrawCall 的核心不是单纯追求数字越低越好,而是减少 CPU 渲染提交成本和渲染状态切换,比如材质、Shader、纹理、渲染队列变化。
5 分钟讲法
NOTE
DrawCall 可以理解成 CPU 对 GPU 说:“用这个 Mesh、这个材质、这个 Shader 状态画一次。”如果场景里有很多物体,而且它们材质、贴图、Shader 状态都不一样,CPU 就要频繁准备渲染状态并提交命令。移动端 CPU 性能有限时,DrawCall 和 SetPass 过高会让 Render Thread 或 Main Thread 压力变大。
优化前我会先用工具定位,而不是直接改资源。Profiler 里看 Rendering、Batches、SetPass Calls、Render Thread;Frame Debugger 里看每一次 DrawCall 为什么不能合批,是材质不同、Shader 变体不同、纹理不同,还是渲染队列和关键字不同。如果 GPU 时间高,还要看 Overdraw、阴影、后处理、带宽,不能只盯 DrawCall。
Unity 常见优化手段有几类:
Static Batching:适合不动的静态物体,把多个静态网格合成批次,减少 DrawCall。代价是内存会增加,因为合批后可能复制顶点数据。
Dynamic Batching:适合很小的 Mesh,Unity 在运行时尝试合批。限制比较多,比如顶点数量、材质状态等,所以现在项目里不能完全依赖它。
GPU Instancing:适合同 Mesh、同材质的大量对象,比如草、石头、子弹、相同怪物。CPU 提交一次或少量几次,GPU 根据每个实例的数据画多个对象。
SRP Batcher:URP/HDRP 常用,它不一定减少 DrawCall 数量,但可以降低切换材质和设置常量缓冲的 CPU 成本。面试时要说清楚,它优化的是 CPU 提交效率,不是传统意义上把很多对象合成一个 DrawCall。
UI 方面要用图集、减少材质数量、避免频繁打断 Canvas 批次。大量 UI Item 要做虚拟列表,不要让屏幕外 Item 也参与重建和渲染。
常见误区
DrawCall 多不一定是 GPU 问题。DrawCall 主要体现 CPU 提交压力;GPU 慢可能是 Overdraw、复杂 Shader、阴影、后处理、分辨率、带宽问题。正确说法是:先定位 CPU/GPU,再选择优化方案。
也不要为了合批把所有材质强行合成一个,导致美术流程不可维护。优化要看收益和代价,比如 Static Batching 是内存换 CPU,GPU Instancing 要求 Mesh 和材质一致,SRP Batcher 要求 Shader 写法满足规范。
面试收尾
我会这样总结:DrawCall 优化的思路是先用 Profiler 和 Frame Debugger 定位提交瓶颈,再通过合批、实例化、减少材质切换、图集、LOD 和剔除降低渲染提交成本,最后用优化前后数据证明帧耗确实下降。
5 分钟讲清楚一次卡顿排查流程
标准回答
一次卡顿排查流程,我会按“先复现、再采样、再归类、再优化、最后验证”的顺序做。重点是不要凭感觉改代码,要用工具把卡顿帧拆开,看它到底是 CPU、GPU、GC、IO 还是资源加载导致的。
5 分钟讲法
我会先确认现象:卡顿是稳定复现还是偶现,发生在哪个机型、哪个场景、哪个操作路径,比如进战斗、释放技能、打开背包、切场景、怪物刷新。没有复现路径时,先加日志和性能埋点,把问题变成可观察的数据。
然后用 Unity Profiler 录制卡顿帧。60 FPS 的单帧预算大约是 16.6ms,如果某一帧跳到 50ms,我会先看这一帧的 Main Thread、Render Thread、GC Alloc、Physics、UI、Animation、Scripts、Loading 等模块。不要只看总耗时,要展开调用栈,找到具体函数或系统。
接着分类瓶颈:
CPU 高:看是不是大量 Update、AI、寻路、物理、动画、UI Rebuild、排序、循环遍历导致。对应方案是分帧、对象池、Update Manager、减少无效计算、缓存组件引用、降低刷新频率。
GC 高:看 GC Alloc 来源,比如字符串拼接、LINQ、闭包、装箱、频繁 new 集合、Instantiate/Destroy。对应方案是对象池、缓存容器、避免热路径分配、用 StringBuilder 或复用列表。
GPU 高:用 Frame Debugger 或 RenderDoc 看 DrawCall、SetPass、Overdraw、透明物体、阴影、后处理、分辨率和带宽。对应方案是合批、图集、LOD、Occlusion Culling、降低阴影和后处理质量。
IO 或加载高:看同步加载、资源解压、AssetBundle/Addressables 依赖加载、切场景峰值内存。对应方案是异步加载、预加载、分块加载、引用计数、卸载无用资源。
最后必须验证结果。用同一台设备、同一场景、同一路径,对比优化前后的平均帧耗、最大帧耗、GC Alloc、GC 次数、内存峰值、加载耗时。只说“感觉不卡了”不够,面试里要说具体指标。
可以直接背的项目表达
IMPORTANT
我遇到过一次战斗场景卡顿,现象是怪物刷新和技能特效爆发时,低端机单帧会从 16ms 跳到 50ms 以上。我先固定复现路径,然后用 Profiler 录制卡顿帧,发现主要耗时来自大量对象创建销毁、AI 同帧刷新和 UI 伤害数字频繁生成。
我的方案是把子弹、特效、伤害数字接入对象池;怪物 AI 改成分帧刷新;部分 UI 更新改成事件驱动;同时减少战斗中字符串拼接和临时 List 分配。优化后同场景下 GC Alloc 明显下降,峰值帧耗降低,战斗过程更稳定。最后我补了性能检查规则,避免后续功能重新引入高频分配。
面试关键点
不要只说“我用 Profiler 看了下”。要说清楚看哪个模块、哪个线程、哪个指标、怎么归类、怎么验证。完整答案一定包含:现象、工具、瓶颈、方案、数据、防复发。
5 分钟讲清楚技能系统设计
标准回答
技能系统设计的核心是:配置驱动、流程可控、逻辑和表现解耦。不要把每个技能都写成一堆 if else,而是把技能拆成“配置数据、运行时上下文、释放流程、效果节点、表现层事件”。
5 分钟讲法
我会先明确边界:技能系统负责一次技能从请求到结算的完整流程,包括 CD、消耗、状态校验、目标筛选、前摇、命中、伤害、Buff、表现触发和冷却。它不应该直接负责角色属性存储、动画资源加载、特效池管理,而是通过属性系统、Buff 系统、资源系统、表现层协作。
核心数据分两类:SkillConfig 是静态配置,保存技能 ID、CD、消耗、范围、伤害倍率、Buff ID、特效路径、动画名;SkillContext 是运行时上下文,保存释放者、目标、方向、释放位置、命中列表。配置不能被运行时修改,否则多个角色共享配置时会出问题。
一次释放流程通常是:输入请求 -> 前置校验 -> 创建上下文 -> 目标筛选 -> 播放前摇 -> 到命中帧结算 -> 应用伤害和 Buff -> 触发表现事件 -> 进入 CD。攻击判定可以由动画事件、Timeline 时间点或逻辑计时器触发,但结算逻辑最好不要直接写死在动画事件里,否则后面换动画、改前摇会很难维护。
核心代码结构
c
using System.Collections.Generic; // 引入 List 等集合类型
public class SkillConfig // 定义技能静态配置
{ // SkillConfig 开始
public int Id; // 技能唯一 ID
public float Cooldown; // 技能冷却时间
public int Cost; // 技能消耗,例如蓝量或能量
public float Range; // 技能释放或检测范围
public int Damage; // 技能基础伤害
} // SkillConfig 结束
public class SkillContext // 定义一次技能释放的运行时上下文
{ // SkillContext 开始
public Unit Caster; // 保存释放技能的角色
public Unit Target; // 保存当前技能目标
public SkillConfig Config; // 保存本次释放使用的技能配置
public List<Unit> HitTargets = new List<Unit>(); // 保存本次命中的目标列表
} // SkillContext 结束
public class SkillSystem // 定义技能系统
{ // SkillSystem 开始
private readonly CooldownSystem cooldownSystem; // 保存冷却系统引用
private readonly TargetSystem targetSystem; // 保存目标筛选系统引用
private readonly DamageSystem damageSystem; // 保存伤害结算系统引用
public SkillSystem(CooldownSystem cooldownSystem, TargetSystem targetSystem, DamageSystem damageSystem) // 构造函数注入依赖
{ // 构造函数开始
this.cooldownSystem = cooldownSystem; // 保存冷却系统
this.targetSystem = targetSystem; // 保存目标系统
this.damageSystem = damageSystem; // 保存伤害系统
} // 构造函数结束
public bool TryCast(Unit caster, SkillConfig config, Unit target) // 尝试释放技能
{ // TryCast 开始
if (!CanCast(caster, config, target)) // 如果前置条件不满足
{ // if 开始
return false; // 返回释放失败
} // if 结束
SkillContext context = new SkillContext(); // 创建一次释放上下文
context.Caster = caster; // 记录释放者
context.Target = target; // 记录目标
context.Config = config; // 记录技能配置
context.HitTargets = targetSystem.FindTargets(caster, target, config.Range); // 根据范围筛选目标
foreach (Unit hitTarget in context.HitTargets) // 遍历所有命中目标
{ // foreach 开始
damageSystem.ApplyDamage(caster, hitTarget, config.Damage); // 对命中目标结算伤害
} // foreach 结束
cooldownSystem.StartCooldown(caster, config.Id, config.Cooldown); // 技能释放成功后进入冷却
return true; // 返回释放成功
} // TryCast 结束
private bool CanCast(Unit caster, SkillConfig config, Unit target) // 检查技能是否允许释放
{ // CanCast 开始
if (caster == null || config == null) return false; // 释放者或配置为空则失败
if (target == null) return false; // 目标为空则失败
if (cooldownSystem.IsCooling(caster, config.Id)) return false; // 技能还在冷却则失败
if (caster.Mp < config.Cost) return false; // 蓝量不足则失败
return true; // 所有条件通过则允许释放
} // CanCast 结束
} // SkillSystem 结束面试加分点
TIP
技能系统要重点讲“解耦”和“扩展”。新增一个中毒、击退、吸血、护盾技能时,最好新增 Effect 节点或配置项,不要改主流程。表现层通过事件监听技能阶段,例如 OnSkillStart、OnHitFrame、OnSkillEnd,这样动画、特效、音效、镜头震动可以独立调整。
性能上,目标筛选要避免每帧全场扫描,可以用 Layer、范围检测、空间划分或缓存列表;特效、子弹、伤害数字要接对象池;技能配置用表驱动,运行时只读配置。联机项目还要区分客户端表现和服务端结算,伤害、CD、命中合法性最好由服务端或权威逻辑校验。
5 分钟讲清楚 A* 寻路
标准回答
A* 是一种带启发函数的寻路算法。它每次从候选节点里选 f 最小的节点继续搜索,公式是:
c
f = g + hg 是从起点走到当前节点的真实代价,h 是从当前节点到终点的预估代价,f 是综合评分。
5 分钟讲法
A* 可以理解成“有方向感的 BFS”。BFS 会一圈一圈扩散,A* 会用 h 引导搜索朝终点靠近,所以通常搜索范围更小。
它有两个核心集合:Open 和 Closed。Open 保存待搜索节点,每轮从里面取 f 最小的节点;Closed 保存已经处理过的节点,避免重复搜索。每次更新邻居节点时,要记录 Parent,等找到终点后,从终点沿 Parent 一路回溯,就能得到完整路径。
C# 手写核心版
c
using System.Collections.Generic; // 引入 List 和 HashSet 集合
public class Node // 定义寻路节点
{ // Node 类开始
public int X; // 节点在网格中的 X 坐标
public int Y; // 节点在网格中的 Y 坐标
public bool Walkable = true; // 节点是否可以行走
public int G; // 起点到当前节点的真实代价
public int H; // 当前节点到终点的预估代价
public int F => G + H; // 当前节点综合评分,F 等于 G 加 H
public Node Parent; // 记录父节点,用于最后回溯路径
public List<Node> Neighbors = new List<Node>(); // 保存相邻节点列表
} // Node 类结束
public class AStar // 定义 A* 寻路类
{ // AStar 类开始
public List<Node> FindPath(Node start, Node goal) // 定义寻路函数,传入起点和终点
{ // FindPath 方法开始
List<Node> open = new List<Node>(); // 创建 Open 列表,保存待搜索节点
HashSet<Node> closed = new HashSet<Node>(); // 创建 Closed 集合,保存已处理节点
open.Add(start); // 把起点加入 Open
start.G = 0; // 起点到自己的真实代价是 0
start.H = GetH(start, goal); // 计算起点到终点的预估代价
while (open.Count > 0) // 只要还有待搜索节点就继续
{ // while 循环开始
Node current = GetMinF(open); // 从 Open 中取 F 最小的节点
open.Remove(current); // 从 Open 中移除当前节点
closed.Add(current); // 把当前节点加入 Closed
if (current == goal) // 如果当前节点就是终点
{ // if 开始
return BuildPath(goal); // 从终点回溯并返回路径
} // if 结束
foreach (Node next in current.Neighbors) // 遍历当前节点的所有邻居
{ // foreach 开始
if (!next.Walkable || closed.Contains(next)) // 如果邻居不可走或已经处理过
{ // if 开始
continue; // 跳过这个邻居
} // if 结束
int newG = current.G + 1; // 计算从当前节点走到邻居的新 G 值
if (!open.Contains(next) || newG < next.G) // 如果邻居未加入 Open 或找到更短路径
{ // if 开始
next.G = newG; // 更新邻居的真实代价
next.H = GetH(next, goal); // 更新邻居到终点的预估代价
next.Parent = current; // 记录邻居的父节点
if (!open.Contains(next)) // 如果邻居还不在 Open 中
{ // if 开始
open.Add(next); // 把邻居加入 Open
} // if 结束
} // if 结束
} // foreach 结束
} // while 循环结束
return null; // Open 搜空还没到终点,说明没有路径
} // FindPath 方法结束
private int GetH(Node a, Node b) // 定义启发函数
{ // GetH 方法开始
return System.Math.Abs(a.X - b.X) + System.Math.Abs(a.Y - b.Y); // 四方向网格使用曼哈顿距离
} // GetH 方法结束
private Node GetMinF(List<Node> open) // 从 Open 中找 F 最小的节点
{ // GetMinF 方法开始
Node best = open[0]; // 默认第一个节点是最优节点
foreach (Node node in open) // 遍历 Open 中所有节点
{ // foreach 开始
if (node.F < best.F) // 如果当前节点 F 更小
{ // if 开始
best = node; // 更新最优节点
} // if 结束
} // foreach 结束
return best; // 返回 F 最小的节点
} // GetMinF 方法结束
private List<Node> BuildPath(Node goal) // 根据 Parent 回溯路径
{ // BuildPath 方法开始
List<Node> path = new List<Node>(); // 创建路径列表
Node current = goal; // 从终点开始回溯
while (current != null) // 只要当前节点不为空就继续
{ // while 开始
path.Add(current); // 把当前节点加入路径
current = current.Parent; // 移动到父节点
} // while 结束
path.Reverse(); // 反转路径,让路径从起点到终点
return path; // 返回最终路径
} // BuildPath 方法结束
} // AStar 类结束复杂度和工程点
上面手写版为了好理解,用 List 找最小 F,每次会遍历 Open。项目里一般会把 Open 换成优先队列或二叉堆,这样效率更高。
复杂度常说成 O(E log V),其中 V 是节点数,E 是边数。网格地图里可以粗略理解成节点越多、邻居越多,搜索越贵。
Unity 里 A* 常用于格子地图、战棋、怪物追击、塔防路线。大地图要考虑分区、缓存、异步寻路或分帧寻路,否则同一帧大量怪物一起寻路会造成卡顿。
常见坑
TIP
h 不能乱写。四方向移动一般用曼哈顿距离,八方向移动可以用对角距离或欧几里得距离。如果启发函数高估太多,A* 可能更快,但不一定保证最短路。
还有一个常见坑是只找到终点但没有记录 Parent,最后无法还原路径。面试里记住一句:A* 搜索靠 Open/Closed,选点靠 f=g+h,出路径靠 Parent 回溯。
5 分钟回答“如果重做项目,你会怎么改?”
标准回答
如果重做项目,我不会简单说“全部推倒重来”。我会先保留已经验证有效的核心玩法和模块,再针对当时暴露出的短板做结构化改进:架构分层、资源规范、性能监控、工具链和测试流程。
5 分钟回答模板
NOTE
如果重做一次,我会保留项目的核心玩法闭环,因为它已经能验证玩法可行性。但我会更早把工程结构和工具链搭起来。之前为了快速完成 Demo,很多模块是先以功能实现为主,比如战斗、UI、资源加载都能跑通,但后期扩展时会发现模块边界不够清晰,性能问题和资源问题也更多依赖人工排查。
第一点,我会把模块边界拆得更明确。比如把数据层、逻辑层、表现层分开:配置表只负责静态数据,战斗逻辑只负责结算,动画、特效、音效通过事件或表现层监听。这样后面新增技能、Buff、怪物 AI 时,不需要反复改主流程。
第二点,我会更早建立资源管理规范。比如资源命名、图集规则、Addressables 分组、依赖检查、引用计数和卸载时机。之前如果资源管理靠经验,很容易出现重复打包、切场景峰值内存高、资源没有释放的问题。重做时我会把资源检查工具和打包报告纳入流程。
第三点,我会提前做性能监控。不是等卡顿出现后再查,而是在 Demo 阶段就记录关键指标,比如 FPS、GC Alloc、峰值内存、加载耗时、DrawCall、SetPass。这样每次改功能都能对比数据,避免性能退化。
第四点,我会补编辑器工具和自动化检查。比如配置表校验、资源引用检查、对象池泄漏检查、UI Raycast Target 扫描、Shader Variant 裁剪报告。这些工具短期会增加开发成本,但能减少后期人工排查和低级错误。
这个改法的代价是前期搭框架和工具会慢一点,不适合非常短的原型验证。但如果项目周期更长、团队规模更大,这些投入能换来更低的维护成本、更稳定的性能和更清晰的协作流程。
一句话收尾
如果重做,我不会否定原项目,而是把“快速做出功能”的方案升级成“能长期维护和扩展”的工程方案。