Skip to content

终极自测

给自己计时 60 分钟,能答出来说明基础已经能打。

5 分钟讲清楚你的项目

标准回答

5 分钟讲项目,不要按功能流水账讲。按这个顺序:项目背景、我的职责、核心系统、技术难点、解决方案、结果数据、复盘反思。重点一定放在“难点、方案、结果”。

five-minute-project-pitch

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 串起来解决哈希冲突。

csharp-dictionary-internals-3min

3 分钟讲法

TIP

Dictionary 可以理解成“数组 + 哈希函数 + 冲突链”。它内部通常有两个关键结构:bucketsentriesbuckets 不是直接存 key-value,而是存某个桶对应的 entry 下标;entries 里才真正保存 hashCodenextkeyvalue

查找时,先对 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()

unity-coroutine-3min

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;需要高频使用时可以缓存等待对象,但要注意等待时间固定才适合缓存。对象销毁、StopCoroutineStopAllCoroutines 都会导致协程停止。脚本禁用和 GameObject 禁用对协程停止行为也要分清,项目里最好明确生命周期管理。

5 分钟手写对象池

标准回答

5 分钟手写对象池,要讲清楚 5 点:为什么用、怎么取、怎么还、怎么重置、怎么防重复归还。对象池不是单纯用 Queue 存对象,关键是生命周期管理。

unity-object-pool-5min

5 分钟讲法

对象池的核心是:对象不用时不销毁,而是隐藏并放回池子,下次需要时直接复用。这样可以减少 InstantiateDestroy、托管分配和 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 AllocInstantiateDestroy 和帧耗时是否下降。

5 分钟手写反转链表

标准回答

反转链表用三指针:prevcurrnextprev 表示已经反转好的部分,curr 表示当前处理的节点,next 用来临时保存后继节点,防止断链后找不到后面的节点。

reverse-linked-list-5min

5 分钟讲法

原链表是:

c
1 -> 2 -> 3 -> 4 -> null

目标是改成:

c
4 -> 3 -> 2 -> 1 -> null

每一轮做四步:

  1. 先保存 next = curr.next,防止后半段丢失。
  2. 反转当前指针:curr.next = prev
  3. prev 后移到 curr
  4. 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),因为只用了 prevcurrnext 三个额外指针。

常见坑

CAUTION

最容易错的是直接写 curr.next = prev,但没有先保存 next。这样会导致后面的链表丢失。另一个常见错误是最后返回 head,正确应该返回 prev

5 分钟讲清楚 C++ 虚函数

标准回答

C++ 虚函数用于实现运行时多态:用基类指针或引用调用函数时,真正执行哪个函数,不只看指针类型,而是看对象的真实类型。主流编译器通常通过 vptrvtable 实现虚函数派发。

cpp-virtual-function-5min

5 分钟讲法

虚函数解决的是“基类接口调用派生类实现”的问题。比如游戏里有 Actor 基类,派生出 PlayerMonsterNpc,它们都可以有自己的 Update()Attack()。外部只拿 Actor*,但调用时希望执行真实对象自己的逻辑,这就是多态。

底层上,一个类只要有虚函数,对象里通常会多一个隐藏指针,叫 vptrvptr 指向这个类对应的虚表 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++ 可以通过统一的基类接口调用不同派生类行为,本质是运行时动态绑定,常见底层实现是对象持有 vptrvptr 指向虚表,虚表里保存真实函数地址。

5 分钟讲清楚 shared_ptr 和 weak_ptr

标准回答

shared_ptr 表示共享所有权,多个 shared_ptr 可以共同管理同一个对象;weak_ptr 表示弱引用,只观察对象,不拥有对象,不会延长对象生命周期。两者通常共享同一个控制块,控制块里维护强引用计数和弱引用计数。

cpp-shared-weak-ptr-5min

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、纹理、渲染队列变化。

unity-drawcall-optimization-5min

5 分钟讲法

NOTE

DrawCall 可以理解成 CPU 对 GPU 说:“用这个 Mesh、这个材质、这个 Shader 状态画一次。”如果场景里有很多物体,而且它们材质、贴图、Shader 状态都不一样,CPU 就要频繁准备渲染状态并提交命令。移动端 CPU 性能有限时,DrawCall 和 SetPass 过高会让 Render Thread 或 Main Thread 压力变大。

优化前我会先用工具定位,而不是直接改资源。Profiler 里看 RenderingBatchesSetPass CallsRender 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 还是资源加载导致的。

unity-stutter-debug-flow-5min

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,而是把技能拆成“配置数据、运行时上下文、释放流程、效果节点、表现层事件”。

unity-skill-system-design-5min

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 节点或配置项,不要改主流程。表现层通过事件监听技能阶段,例如 OnSkillStartOnHitFrameOnSkillEnd,这样动画、特效、音效、镜头震动可以独立调整。

性能上,目标筛选要避免每帧全场扫描,可以用 Layer、范围检测、空间划分或缓存列表;特效、子弹、伤害数字要接对象池;技能配置用表驱动,运行时只读配置。联机项目还要区分客户端表现和服务端结算,伤害、CD、命中合法性最好由服务端或权威逻辑校验。

5 分钟讲清楚 A* 寻路

标准回答

A* 是一种带启发函数的寻路算法。它每次从候选节点里选 f 最小的节点继续搜索,公式是:

c
f = g + h

g 是从起点走到当前节点的真实代价,h 是从当前节点到终点的预估代价,f 是综合评分。

astar-pathfinding-5min

5 分钟讲法

A* 可以理解成“有方向感的 BFS”。BFS 会一圈一圈扩散,A* 会用 h 引导搜索朝终点靠近,所以通常搜索范围更小。

它有两个核心集合:OpenClosedOpen 保存待搜索节点,每轮从里面取 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 分钟回答“如果重做项目,你会怎么改?”

标准回答

如果重做项目,我不会简单说“全部推倒重来”。我会先保留已经验证有效的核心玩法和模块,再针对当时暴露出的短板做结构化改进:架构分层、资源规范、性能监控、工具链和测试流程。

project-redo-improvement-5min

5 分钟回答模板

NOTE

如果重做一次,我会保留项目的核心玩法闭环,因为它已经能验证玩法可行性。但我会更早把工程结构和工具链搭起来。之前为了快速完成 Demo,很多模块是先以功能实现为主,比如战斗、UI、资源加载都能跑通,但后期扩展时会发现模块边界不够清晰,性能问题和资源问题也更多依赖人工排查。

第一点,我会把模块边界拆得更明确。比如把数据层、逻辑层、表现层分开:配置表只负责静态数据,战斗逻辑只负责结算,动画、特效、音效通过事件或表现层监听。这样后面新增技能、Buff、怪物 AI 时,不需要反复改主流程。

第二点,我会更早建立资源管理规范。比如资源命名、图集规则、Addressables 分组、依赖检查、引用计数和卸载时机。之前如果资源管理靠经验,很容易出现重复打包、切场景峰值内存高、资源没有释放的问题。重做时我会把资源检查工具和打包报告纳入流程。

第三点,我会提前做性能监控。不是等卡顿出现后再查,而是在 Demo 阶段就记录关键指标,比如 FPS、GC Alloc、峰值内存、加载耗时、DrawCall、SetPass。这样每次改功能都能对比数据,避免性能退化。

第四点,我会补编辑器工具和自动化检查。比如配置表校验、资源引用检查、对象池泄漏检查、UI Raycast Target 扫描、Shader Variant 裁剪报告。这些工具短期会增加开发成本,但能减少后期人工排查和低级错误。

这个改法的代价是前期搭框架和工具会慢一点,不适合非常短的原型验证。但如果项目周期更长、团队规模更大,这些投入能换来更低的维护成本、更稳定的性能和更清晰的协作流程。

一句话收尾

如果重做,我不会否定原项目,而是把“快速做出功能”的方案升级成“能长期维护和扩展”的工程方案。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0