Skip to content

初面 45 分钟套卷

自我介绍和项目介绍

unity-self-intro-project-intro

自我介绍模板

NOTE

面试官您好,我主要方向是 Unity 游戏客户端开发,技术栈以 C#、Unity、数据结构算法为主,也有一定 C++、图形学、性能优化和资源管理基础。 我做过一个完整的游戏 Demo,包含角色移动、战斗技能、Buff、背包、UI 管理、资源加载、对象池、配置表和基础性能优化。项目里我更关注模块边界和可维护性,不只是把功能做出来,也会考虑 GC、加载时间、资源生命周期和低端机表现。 我希望应聘游戏客户端方向,后续也想继续往客户端架构、性能优化和引擎基础方向深入。

项目介绍模板

TIP

我做的是一个 Unity 动作/冒险类 Demo,核心玩法是角色在关卡中移动、战斗、释放技能、击败怪物、获得奖励,并通过背包和属性系统形成一个完整闭环。 我主要负责角色控制、技能系统、Buff 系统、背包系统、UI 管理、资源加载封装和对象池。 项目里比较难的是战斗系统和资源管理:技能释放会牵涉动画、命中检测、伤害计算、Buff 添加、特效播放和 UI 刷新,如果直接互相调用会很乱。所以我把它拆成配置层、运行时数据层、逻辑服务层和表现层,用事件系统解耦。 性能方面,我针对频繁创建销毁的子弹、特效、怪物做了对象池;针对 UI 列表做了复用;资源加载统一走资源管理器,避免重复加载和忘记释放。 如果重做一次,我会更早加入自动化配置校验、资源引用检查和性能基准测试,这样能更快发现空引用、资源泄漏和 GC 问题。

3 分钟项目介绍背诵版

CAUTION

我这个项目是一个 Unity 动作类 Demo,目标是做出一个完整玩法闭环:玩家进入关卡后可以移动、攻击、释放技能,击败怪物后获得奖励,奖励进入背包,并影响角色属性。 我主要负责三个部分:第一是战斗技能,包括技能配置、CD、目标检测、伤害和 Buff;第二是基础框架,包括事件系统、对象池、资源加载和配置表;第三是 UI 和表现,包括背包界面、血条、技能按钮、特效播放。 技术难点主要是模块解耦和性能。比如一次技能释放会牵涉背包消耗、动画、碰撞盒、伤害、Buff、特效和 UI,如果直接写在一个脚本里后期很难维护。所以我把规则放配置表,逻辑放 Service,表现层只监听事件。 优化上,我用对象池减少怪物、子弹、特效的频繁创建销毁;用 Profiler 看 GC Alloc 和帧耗时;资源加载统一封装,记录引用和释放时机。 这个项目让我比较系统地理解了 Unity 客户端开发,不只是会用 API,也更清楚生命周期、资源管理、性能优化和模块边界这些实际项目里会遇到的问题。

面试关键句

不要说“我负责了很多模块”,要说清楚“我负责什么,遇到什么问题,怎么拆,怎么验证结果”。 项目介绍最稳的结构是:项目背景、我的职责、技术难点、解决方案、结果数据、复盘改进。

C# 值类型和引用类型

csharp-value-reference-types

一句话定义

C# 里值类型保存“值本身”,引用类型保存“对象引用”。所以值类型赋值通常是复制数据,引用类型赋值通常是复制引用,两个变量可能指向同一个对象。

标准答案

值类型包括:intfloatdoubleboolchardecimalenumstruct。Unity 里常见的 Vector3QuaternionColor 也是 struct,属于值类型。

引用类型包括:classobjectstring、数组、委托、接口引用、List<T>Dictionary<TKey, TValue>。Unity 里的 GameObjectComponentMonoBehaviour 都是引用类型。

底层理解

不要简单背“值类型在栈上,引用类型在堆上”。更准确地说:

值类型的核心是“复制语义”。它作为局部变量时,可能在栈上或寄存器里;作为类的字段时,会嵌在堆对象内部;如果发生装箱,会被复制到托管堆上的 object 里。

引用类型的核心是“引用语义”。变量里保存的是引用,真正的对象通常在托管堆上。赋值时复制的是引用,不是复制整个对象。

代码例子

c
using System; // 引入 Console 输出功能。
public struct Position // 定义一个值类型结构体。
{ // 结构体开始。
    public int X; // 保存 X 坐标。
} // 结构体结束。
public class Player // 定义一个引用类型类。
{ // 类开始。
    public int Hp; // 保存玩家血量。
} // 类结束。
public static class Demo // 定义一个演示类。
{ // 演示类开始。
    public static void Run() // 定义演示入口方法。
    { // 方法开始。
        Position a = new Position { X = 1 }; // 创建值类型变量 a。
        Position b = a; // 把 a 的值复制一份给 b。
        b.X = 99; // 修改 b 不会影响 a。
        Console.WriteLine(a.X); // 输出 1,因为 a 和 b 是两份数据。
        Player p1 = new Player { Hp = 100 }; // 创建一个堆上的 Player 对象。
        Player p2 = p1; // 把 p1 的引用复制给 p2。
        p2.Hp = 50; // 通过 p2 修改同一个 Player 对象。
        Console.WriteLine(p1.Hp); // 输出 50,因为 p1 和 p2 指向同一个对象。
        object box = a; // 值类型赋给 object,会发生装箱。
        Position c = (Position)box; // 从 object 拆箱得到新的 Position 副本。
        Console.WriteLine(c.X); // 输出 1,因为装箱时复制的是当时的值。
    } // 方法结束。
} // 演示类结束。

Unity 里的坑

Vector3 是值类型,所以你拿到某个 Vector3 副本后改它,不一定会影响原对象,很多时候要重新赋回去。

string 是引用类型,但它不可变。你写 str += "abc" 时,不是在原字符串上修改,而是创建新字符串,所以它表现得有点像值类型。

热路径里要小心装箱,比如值类型传给 object、非泛型集合、某些接口调用、日志拼接,都可能产生 GC Alloc。

面试关键句

WARNING

值类型和引用类型的本质区别是“复制值”还是“复制引用”,而不是死背栈和堆。实际项目里还要关注装箱、大结构体复制、字符串不可变和 Unity 热路径 GC。

Dictionary 底层

csharp-dictionary-internals

一句话定义

Dictionary<TKey, TValue> 底层是哈希表,用 key 的哈希值快速定位位置,再通过相等比较找到真正的键值对。

底层结构

它通常由两块核心数组组成:

buckets:桶数组,负责根据哈希值快速定位入口。

entries:元素数组,真正保存 hashCodenextkeyvalue

可以理解成:

c
key -> GetHashCode() -> bucket 下标 -> entry 链 -> Equals 比较 -> value

如果两个不同的 key 算出来落到同一个 bucket,就发生哈希冲突。Dictionary 不会直接覆盖,而是用 next 把多个 entry 串成一条链,查找时沿着链继续比较。

查找流程

比如执行:

c
dict["hero"]

大概流程是:

  1. "hero" 调用 GetHashCode()
  2. 根据哈希值算出 bucket 下标。
  3. buckets[index] 找到 entries 入口。
  4. 比较 hashCode 是否一致。
  5. 再用 Equals 判断 key 是否真的相等。
  6. 找到后返回 value,找不到就报错或返回 false。

所以平均情况下是 O(1),但如果哈希冲突特别严重,很多 key 都挤到同一个桶里,就可能退化成接近 O(n)

扩容机制

Dictionary 容量不够时会扩容。扩容不是简单加一个格子,而是创建更大的 bucketsentries,然后把已有元素重新计算桶位置,也就是重哈希。

所以在 Unity 热路径里,如果你知道大概数量,最好提前给容量:

c
using System.Collections.Generic; // 引入泛型集合命名空间。
using UnityEngine; // 引入 Unity 常用 API。
public sealed class SkillTable : MonoBehaviour // 定义一个技能表组件。
{ // 类开始。
    private readonly Dictionary<int, string> _skills = new Dictionary<int, string>(128); // 预设容量,减少运行时扩容。
    private void Awake() // Unity 初始化入口。
    { // 方法开始。
        _skills[1001] = "FireBall"; // 添加技能 ID 和技能名的映射。
        _skills[1002] = "IceArrow"; // 添加另一个技能 ID 和技能名的映射。
    } // 方法结束。
    public bool TryGetSkillName(int skillId, out string skillName) // 定义安全查询技能名的方法。
    { // 方法开始。
        return _skills.TryGetValue(skillId, out skillName); // 一次哈希查找,同时返回是否找到。
    } // 方法结束。
} // 类结束。

Unity 工程注意

热更新配置表、技能表、Buff 表、资源表,经常会用 Dictionary<int, Config> 来做 ID 查询。

项目里要注意几个点:

不要在频繁执行的 Update 里反复新增大量 key,可能触发扩容和 GC。

优先用 TryGetValue,不要先 ContainsKeydict[key],那样可能查两次。

自定义 key 如果是 struct,要正确实现 GetHashCodeEquals,否则可能查不到或者冲突很多。

不要依赖 Dictionary 的遍历顺序。不同 .NET、Mono、IL2CPP、Unity 版本下实现细节可能不同,业务逻辑不应该靠它的顺序。

面试关键句

IMPORTANT

Dictionary 的核心是哈希表,buckets 负责快速定位,entries 保存真实键值对,冲突通过 next 链处理。它平均查找是 O(1),但性能依赖哈希质量、容量设置和冲突情况;在 Unity 中还要注意扩容、装箱和 GC。

Unity 生命周期

unity-lifecycle-order

一句话定义

Unity 生命周期就是 Unity 引擎在对象创建、启用、每帧更新、禁用、销毁时,自动调用 MonoBehaviour 上的一系列回调函数。

核心顺序

常见顺序可以这样记:

c
Awake → OnEnable → Start → FixedUpdate / Update / LateUpdate → OnDisable → OnDestroy

更细一点:

Awake:对象加载后最早调用,通常只调用一次。适合初始化自身字段、缓存组件。

OnEnable:对象或组件启用时调用。它可能被反复调用,比如 SetActive(true)enabled = true

Start:第一次 Update 之前调用,只调用一次。适合依赖其他对象已经 Awake 完成后的初始化。

FixedUpdate:固定时间步调用,适合物理逻辑,比如 Rigidbody 移动、AddForce。

Update:每帧调用,适合输入、普通逻辑、计时器。

LateUpdate:每帧在 Update 后调用,适合相机跟随、动画后处理、表现层修正。

OnDisable:对象或组件被禁用时调用。常用于取消事件订阅、停止协程。

OnDestroy:对象被销毁、场景卸载、退出游戏时调用。常用于最终清理。

底层原理

Unity 底层有一个主循环,叫 PlayerLoop。每一帧 Unity 会按阶段执行:输入、脚本更新、物理、动画、渲染等。MonoBehaviour 的生命周期函数,本质上就是 Unity 在对应阶段反射或内部注册后调用的回调。

所以它不是你主动调用的,而是引擎在固定时机调用你写好的方法。

代码观察顺序

c
using UnityEngine; // 引入 UnityEngine 命名空间。
public sealed class LifecycleDemo : MonoBehaviour // 定义一个生命周期演示组件。
{ // 类开始。
    private void Awake() // 对象加载后最早调用一次。
    { // Awake 方法开始。
        Debug.Log("Awake"); // 输出 Awake,观察初始化顺序。
    } // Awake 方法结束。
    private void OnEnable() // 对象或组件启用时调用。
    { // OnEnable 方法开始。
        Debug.Log("OnEnable"); // 输出 OnEnable,观察启用时机。
    } // OnEnable 方法结束。
    private void Start() // 第一次 Update 之前调用一次。
    { // Start 方法开始。
        Debug.Log("Start"); // 输出 Start,观察首次更新前的时机。
    } // Start 方法结束。
    private void FixedUpdate() // 按固定时间步调用。
    { // FixedUpdate 方法开始。
        Debug.Log("FixedUpdate"); // 输出 FixedUpdate,观察物理帧。
    } // FixedUpdate 方法结束。
    private void Update() // 每个渲染帧调用。
    { // Update 方法开始。
        Debug.Log("Update"); // 输出 Update,观察普通逻辑帧。
    } // Update 方法结束。
    private void LateUpdate() // 每帧在 Update 之后调用。
    { // LateUpdate 方法开始。
        Debug.Log("LateUpdate"); // 输出 LateUpdate,观察后处理时机。
    } // LateUpdate 方法结束。
    private void OnDisable() // 对象或组件禁用时调用。
    { // OnDisable 方法开始。
        Debug.Log("OnDisable"); // 输出 OnDisable,观察禁用时机。
    } // OnDisable 方法结束。
    private void OnDestroy() // 对象销毁时调用。
    { // OnDestroy 方法开始。
        Debug.Log("OnDestroy"); // 输出 OnDestroy,观察销毁时机。
    } // OnDestroy 方法结束。
} // 类结束。

Unity 项目里怎么用

Awake 里缓存组件,比如 GetComponent<Rigidbody>()

OnEnable 里注册事件,OnDisable 里取消订阅,避免内存泄漏或重复响应。

Start 里做依赖其他对象的初始化,比如等待管理器、配置、其他组件先完成 Awake

Update 里读输入,FixedUpdate 里做物理,LateUpdate 里做相机跟随。

常见坑

TIP

不要认为所有脚本的 Awake 顺序都固定。不同对象之间的执行顺序不应该靠运气,要用 Script Execution Order 或自己写 Bootstrap 控制。

OnEnable 一定在 Start 之前吗?对一个启用状态下首次参与生命周期的组件来说,通常是 Awake → OnEnable → Start。但 OnEnable 可以反复执行,而 Start 只执行一次。

FixedUpdate 不是每帧一定执行一次。帧率高时可能某帧不执行,卡顿时可能一帧执行多次。

协程原理

unity-coroutine-principle

一句话定义

Unity 协程不是线程,而是 Unity 在主线程上分帧执行的 IEnumerator 状态机。它每次执行到 yield return 就暂停,等条件满足后再从暂停的位置继续执行。

底层原理

C# 里的 yield return 会被编译器转换成一个状态机对象。这个对象实现了 IEnumerator,里面会保存当前执行到哪一行、局部变量、Current 等状态。

Unity 调用 StartCoroutine 后,会把这个 IEnumerator 注册到自己的协程调度系统里。之后 Unity 在主循环中不断检查它:

MoveNext() 返回 true:说明协程还没结束。

Currentnull:下一帧继续。

CurrentWaitForSeconds:等游戏时间过去指定秒数。

CurrentWaitForSecondsRealtime:等真实时间过去指定秒数。

MoveNext() 返回 false:协程执行结束。

所以协程不是“后台跑一个线程”,而是“每帧让 Unity 帮你继续执行一小段”。

代码例子

c
using System.Collections; // 引入 IEnumerator 和协程相关类型。
using UnityEngine; // 引入 Unity 常用 API。
public sealed class CoroutineDemo : MonoBehaviour // 定义一个协程演示组件。
{ // 类开始。
    private void Start() // Unity 在第一次 Update 前调用。
    { // Start 方法开始。
        StartCoroutine(PlayFlow()); // 启动一个协程,把 IEnumerator 交给 Unity 调度。
    } // Start 方法结束。
    private IEnumerator PlayFlow() // 定义一个返回 IEnumerator 的协程方法。
    { // 协程方法开始。
        Debug.Log("第一步:开始播放动画"); // 立刻执行第一段逻辑。
        yield return null; // 暂停到下一帧,再从这里后面继续执行。
        Debug.Log("第二步:等待 1 秒"); // 下一帧恢复后执行这行。
        yield return new WaitForSeconds(1f); // 暂停 1 秒,受 Time.timeScale 影响。
        Debug.Log("第三步:继续执行表现逻辑"); // 等待结束后继续执行。
        yield break; // 主动结束当前协程。
    } // 协程方法结束。
} // 类结束。

协程和线程的区别

协程仍然在主线程执行,所以它可以安全访问大多数 Unity API,比如 transform.positiongameObject.SetActive

线程是真正并行执行的,适合做文件 IO、网络、复杂计算,但子线程通常不能直接操作 Unity 对象。

协程不会让 CPU 密集型逻辑变快。如果你在协程里写一个巨大的 for 循环,中间没有 yield,它仍然会卡主线程。

Unity 项目里怎么用

协程适合做延时流程、分步动画、技能前摇后摇、淡入淡出、异步加载等待、剧情流程控制。

比如释放技能:

播放前摇动画 → yield 等待 0.3 秒 → 生成伤害判定 → yield 等待后摇 → 进入冷却

这种流程用协程写会比在 Update 里堆一堆计时变量更直观。

常见坑

WaitForSecondsTime.timeScale 影响。游戏暂停时如果 timeScale = 0,它可能不会继续走。暂停期间还要跑 UI 动画,可以用 WaitForSecondsRealtime

协程不是对象池。频繁 new WaitForSeconds()、频繁启动大量短协程,仍然可能产生 GC。

StopCoroutine 要用同一种方式停止。用字符串启动、用 IEnumerator 停止,或者反过来,容易停不掉。

GameObject.SetActive(false) 会让该对象上的协程停止;组件 enabled = false 对协程影响要区分具体情况,面试里可以说项目中通常在 OnDisable 主动停止或清理,避免生命周期不清晰。

面试关键句

NOTE

Unity 协程本质是 IEnumerator 状态机,不是线程。Unity 在主线程的 PlayerLoop 中调用 MoveNext() 推进它,yield return 返回的对象决定它什么时候恢复。它适合写分帧流程,但不适合重计算,也不能突破 Unity API 的主线程限制。

对象池手写或口述

unity-object-pool-handwrite

一句话定义

对象池就是提前创建一批对象,运行时反复取出和归还,避免频繁 Instantiate / Destroy 造成 CPU 峰值和 GC。

面试口述版本

我会这样说:对象池适合子弹、特效、伤害数字、UI Item、怪物等频繁创建销毁的对象。核心结构一般是一个空闲队列,再加一个使用中集合。取对象时从空闲池拿,拿不到就按策略扩容;归还时要重置状态、隐藏对象、放回空闲池。为了防止重复归还,我会用 HashSet 记录使用中的对象,归还时先判断它是不是还在使用中。

这里最关键的不是“缓存对象”,而是“生命周期管理”。比如子弹归还时要清速度、伤害来源、Trail 残影、计时器、事件监听,否则下一次复用会带着旧状态。

手写代码

c
using System.Collections.Generic; // 引入 Queue 和 HashSet 集合类型。
using UnityEngine; // 引入 Unity 的 GameObject、Transform、Object 等类型。
public sealed class GameObjectPool // 定义一个 GameObject 对象池类。
{ // 类开始。
    private readonly GameObject _prefab; // 保存要复用的预制体。
    private readonly Transform _root; // 保存池对象的父节点,方便层级管理。
    private readonly int _maxCount; // 保存池子的最大对象数量。
    private readonly Queue<GameObject> _idle = new Queue<GameObject>(); // 保存空闲对象队列。
    private readonly HashSet<GameObject> _active = new HashSet<GameObject>(); // 保存使用中的对象集合。
    public GameObjectPool(GameObject prefab, int preloadCount, int maxCount, Transform root) // 定义对象池构造函数。
    { // 构造函数开始。
        _prefab = prefab; // 记录传入的预制体。
        _root = root; // 记录对象池根节点。
        _maxCount = Mathf.Max(preloadCount, maxCount); // 保证最大容量不小于预加载数量。
        for (int i = 0; i < preloadCount; i++) // 循环创建预热对象。
        { // for 循环开始。
            GameObject obj = CreateNewObject(false); // 创建一个默认隐藏的对象。
            _idle.Enqueue(obj); // 把新对象放入空闲队列。
        } // for 循环结束。
    } // 构造函数结束。
    public GameObject Get(Vector3 position, Quaternion rotation) // 定义从池中取对象的方法。
    { // Get 方法开始。
        GameObject obj = null; // 准备保存取出的对象。
        if (_idle.Count > 0) // 如果空闲池里还有对象。
        { // if 分支开始。
            obj = _idle.Dequeue(); // 从空闲队列取出一个对象。
        } // if 分支结束。
        else if (TotalCount < _maxCount) // 如果没有空闲对象但还没达到最大容量。
        { // else if 分支开始。
            obj = CreateNewObject(false); // 新创建一个对象作为扩容。
        } // else if 分支结束。
        else // 如果池子已经满了。
        { // else 分支开始。
            return null; // 返回 null,表示暂时没有可用对象。
        } // else 分支结束。
        obj.transform.SetPositionAndRotation(position, rotation); // 设置对象的位置和旋转。
        obj.SetActive(true); // 激活对象,让它开始参与游戏逻辑。
        _active.Add(obj); // 记录该对象正在使用中。
        return obj; // 返回可用对象。
    } // Get 方法结束。
    public void Release(GameObject obj) // 定义把对象归还到池子的方法。
    { // Release 方法开始。
        if (obj == null) // 如果传入对象为空。
        { // if 分支开始。
            return; // 直接返回,避免空引用。
        } // if 分支结束。
        if (!_active.Remove(obj)) // 如果对象不在使用中集合里。
        { // if 分支开始。
            return; // 说明重复归还或不是本池对象,直接忽略。
        } // if 分支结束。
        ResetObject(obj); // 重置对象状态,避免旧状态污染下次使用。
        obj.SetActive(false); // 隐藏对象,停止大部分表现和逻辑。
        obj.transform.SetParent(_root); // 把对象挂回池节点下面。
        _idle.Enqueue(obj); // 把对象放回空闲队列。
    } // Release 方法结束。
    public void Clear() // 定义清空对象池的方法。
    { // Clear 方法开始。
        while (_idle.Count > 0) // 循环清理所有空闲对象。
        { // while 循环开始。
            Object.Destroy(_idle.Dequeue()); // 销毁一个空闲对象。
        } // while 循环结束。
        foreach (GameObject obj in _active) // 遍历所有使用中的对象。
        { // foreach 循环开始。
            Object.Destroy(obj); // 销毁仍在使用中的对象。
        } // foreach 循环结束。
        _active.Clear(); // 清空使用中集合。
    } // Clear 方法结束。
    private int TotalCount // 定义当前池子总对象数量属性。
    { // 属性开始。
        get { return _idle.Count + _active.Count; } // 返回空闲数量加使用中数量。
    } // 属性结束。
    private GameObject CreateNewObject(bool active) // 定义创建新对象的内部方法。
    { // CreateNewObject 方法开始。
        GameObject obj = Object.Instantiate(_prefab, _root); // 实例化一个新的预制体对象。
        obj.SetActive(active); // 按参数决定新对象是否激活。
        return obj; // 返回创建出来的对象。
    } // CreateNewObject 方法结束。
    private void ResetObject(GameObject obj) // 定义重置对象状态的方法。
    { // ResetObject 方法开始。
        obj.transform.localPosition = Vector3.zero; // 重置本地坐标。
        obj.transform.localRotation = Quaternion.identity; // 重置本地旋转。
        obj.transform.localScale = Vector3.one; // 重置本地缩放。
    } // ResetObject 方法结束。
} // 类结束。

面试追问怎么答

CAUTION

如果问“对象什么时候回收”:子弹命中、超出距离、生命周期结束、切场景、战斗结束时归还。

如果问“重复回收怎么办”:用 HashSet 记录使用中对象,ReleaseRemove 失败就说明已经回收过,直接忽略。

如果问“池子不够怎么办”:可以扩容,也可以返回 null,看业务。子弹一般允许扩容但设上限,UI Item 通常按可见数量固定。

如果问“对象池有没有代价”:有。它是用内存换 CPU 和 GC 稳定性,池子太大会导致常驻内存高。

UGUI 优化

unity-ugui-optimization

一句话定义

UGUI 优化的核心是:减少 Canvas Rebuild、减少 UI 合批被打断、降低 GraphicRaycaster 遍历成本、减少透明 UI 的 Overdraw。

面试标准回答

UGUI 卡顿通常不是单个按钮贵,而是某个 UI 变化触发了整块 Canvas 重建。比如血量数字每帧变化,如果它和整个主界面在同一个大 Canvas 下,就可能导致大量 UI 顶点、布局、批次重新计算。

我会先用 Profiler 定位,看这些指标:

Canvas.BuildBatch:UI 重新合批开销高。

Canvas.SendWillRenderCanvases:Canvas 重建相关逻辑高。

Layout.Rebuild:自动布局计算高。

GraphicRaycaster.Raycast:UI 点击检测遍历高。

GPU 侧还要看 Overdraw,尤其是全屏半透明面板、叠很多 Image、Mask、粒子 UI。

常用优化手段

拆 Canvas:静态 UI、频繁变化 UI、弹窗、血条、飘字分开。一个小数字变化,不应该拖着整个大厅 UI 重建。

少用动态 Layout:HorizontalLayoutGroupVerticalLayoutGroupContentSizeFitter 很方便,但大量 Item 或频繁刷新时很贵。列表初始化后能固定尺寸就固定。

ScrollView 用虚拟列表:1000 个 Item 不要真的创建 1000 个,只创建屏幕可见数量加少量缓冲。

关闭无用 Raycast Target:不可点击的 ImageText、装饰图,都把 raycastTarget 关掉。

图集和材质统一:同一个界面的碎图尽量进同一个 Atlas,减少材质和贴图切换。

减少 Overdraw:少用大面积透明图、全屏半透明叠层、复杂 Mask。移动端上 Overdraw 很容易变成 GPU 瓶颈。

示例代码:关闭无点击需求的 Raycast Target

c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.UI; // 引入 UGUI 的 Graphic 和 Selectable。
public sealed class UIRaycastCleaner : MonoBehaviour // 定义一个 UI 射线优化工具组件。
{ // 类开始。
    [SerializeField] private Transform _root; // 指定要处理的 UI 根节点。
    private void Awake() // 在初始化阶段执行一次。
    { // Awake 方法开始。
        if (_root == null) // 如果没有手动指定根节点。
        { // if 分支开始。
            _root = transform; // 默认使用当前对象作为根节点。
        } // if 分支结束。
        Graphic[] graphics = _root.GetComponentsInChildren<Graphic>(true); // 获取所有 Image、Text 等 Graphic。
        foreach (Graphic graphic in graphics) // 遍历每一个 Graphic 组件。
        { // foreach 循环开始。
            if (graphic.GetComponent<Selectable>() != null) // 如果该对象本身是按钮或可交互控件。
            { // if 分支开始。
                continue; // 保留射线检测,避免按钮点不到。
            } // if 分支结束。
            graphic.raycastTarget = false; // 关闭不可交互元素的射线检测。
        } // foreach 循环结束。
    } // Awake 方法结束。
} // 类结束。

面试关键句

WARNING

我不会一上来就说“少放 UI”。我会先用 Profiler 确认是 RebuildRaycastOverdraw 还是资源加载问题,然后再拆 Canvas、关 Raycast Target、做虚拟列表、减少透明叠层,并用优化前后数据证明有效。

Profiler 排查卡顿流程

unity-profiler-stutter-workflow

一句话定义

Profiler 排查卡顿的核心是:先抓到“哪一帧卡”,再定位“哪个线程、哪个模块、哪个函数或资源”导致耗时尖峰,最后用数据验证优化是否有效。

标准流程

先确认现象:是进入界面卡、释放技能卡、切场景卡,还是每隔几秒固定卡一下。不要一上来就猜原因。

然后真机录制:用 Development Build 连接 Profiler,尽量在目标低端机上复现。编辑器数据只能参考,因为编辑器本身有额外开销。

再看尖峰帧:在 CPU Timeline 里找到耗时最高的一帧,看 Main ThreadRender ThreadJob WorkerGCLoading 哪个最明显。

接着分类处理:

BehaviourUpdate 高:脚本逻辑重,可能大量 Update、AI、寻路、排序、字符串处理。

GC.AllocGC.Collect 高:有托管内存分配,比如 LINQ、字符串拼接、闭包、频繁 new、装箱。

Canvas.BuildBatch 高:UGUI 重建,可能 Canvas 太大、Text 频繁变化、LayoutGroup 太多。

Camera.Render 高:渲染压力大,可能 DrawCall、阴影、后处理、透明 Overdraw。

Physics.Simulate 高:物理压力大,可能碰撞体太多、射线太多、FixedUpdate 频率过高。

Loading.ReadObject / ReadFile 高:同步加载、资源解压、AssetBundle 依赖加载卡主线程。

排查时我会怎么说

IMPORTANT

我会先录一段 Profiler 数据,找到卡顿的 spike 帧。然后在 Timeline 里看这一帧主线程是不是超了 16.6ms 或 33.3ms,再展开具体 Marker。如果是脚本耗时,我会加自定义 ProfilerMarker 缩小到具体系统;如果是 UI,我看 Canvas.BuildBatch 和 Layout;如果是 GC,我看 GC.Alloc 来源;如果是渲染,我结合 Frame Debugger 或 RenderDoc 继续看 DrawCall、Overdraw、阴影和后处理。

自定义 Marker 示例

c
using Unity.Profiling; // 引入 Unity 的 ProfilerMarker 类型。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 类型。
public sealed class MonsterAISystem : MonoBehaviour // 定义一个怪物 AI 系统组件。
{ // 类开始。
    private static readonly ProfilerMarker TickMarker = new ProfilerMarker("MonsterAI.Tick"); // 创建静态 Marker,避免每帧创建字符串。
    private void Update() // Unity 每帧调用 Update。
    { // Update 方法开始。
        TickMarker.Begin(); // 标记 AI 更新开始,Profiler 中会显示 MonsterAI.Tick。
        UpdateAllMonsters(); // 执行所有怪物 AI 逻辑。
        TickMarker.End(); // 标记 AI 更新结束,Profiler 会统计这段耗时。
    } // Update 方法结束。
    private void UpdateAllMonsters() // 定义怪物 AI 更新方法。
    { // 方法开始。
        Debug.Log("这里替换成怪物 AI 更新逻辑"); // 示例逻辑,真实项目不要在热路径频繁打日志。
    } // 方法结束。
} // 类结束。

优化闭环

优化不是“我感觉不卡了”就结束。要记录优化前后的数据,比如:

卡顿帧从 80ms 降到 22ms

GC Alloc 从每秒 200KB 降到 0B

Canvas.BuildBatch12ms 降到 2ms

加载峰值内存从 1.2GB 降到 850MB

这样面试官会觉得你不是只会背工具,而是真的知道怎么定位和验证。

一道链表或哈希算法题

csharp-two-sum-hash-algorithm

题目:两数之和

给定一个整数数组 nums 和目标值 target,找出数组中两个数,使它们相加等于 target,返回这两个数的下标。

例如:

c
nums = [2, 7, 11, 15]
target = 9

答案是 [0, 1],因为 nums[0] + nums[1] = 2 + 7 = 9

核心思路

暴力法是两层循环,时间复杂度是 O(n²)

更好的做法是用哈希表,也就是 C# 的 Dictionary<int, int>。遍历数组时,当前数是 x,我们要找的另一个数就是:

c
target - x

所以每次遍历时:

先查 target - nums[i] 是否已经在字典里。

如果在,说明找到答案。

如果不在,就把当前数字和下标存进去。

注意一定是“先查后存”,这样可以避免同一个元素被用两次。

C# 代码

c
using System.Collections.Generic; // 引入 Dictionary 泛型集合。
public static class TwoSumSolver // 定义两数之和求解类。
{ // 类开始。
    public static int[] TwoSum(int[] nums, int target) // 定义两数之和方法,传入数组和目标值。
    { // 方法开始。
        Dictionary<int, int> map = new Dictionary<int, int>(); // 创建字典,key 是数字,value 是下标。
        for (int i = 0; i < nums.Length; i++) // 从左到右遍历数组。
        { // for 循环开始。
            int current = nums[i]; // 取出当前数字。
            int need = target - current; // 计算当前数字需要匹配的另一个数字。
            if (map.TryGetValue(need, out int index)) // 在字典中查找 need 是否已经出现过。
            { // if 分支开始。
                return new int[] { index, i }; // 找到答案,返回之前数字的下标和当前下标。
            } // if 分支结束。
            if (!map.ContainsKey(current)) // 如果当前数字还没有存过。
            { // if 分支开始。
                map.Add(current, i); // 把当前数字和下标存入字典。
            } // if 分支结束。
        } // for 循环结束。
        return new int[] { -1, -1 }; // 没找到答案时返回无效下标。
    } // 方法结束。
} // 类结束。

复杂度

时间复杂度:O(n),因为只遍历一次数组。

空间复杂度:O(n),因为最坏情况下字典要保存接近 n 个数字。

面试关键句

TIP

这题本质是用空间换时间。哈希表把“查找另一个数”的过程从 O(n) 降到平均 O(1),所以整体从暴力法的 O(n²) 优化到 O(n)

项目深挖一个模块

unity-project-module-deep-dive

一句话思路

项目深挖一个模块时,不要只说“我做了技能系统/背包系统/资源系统”,要按“背景、职责、边界、流程、难点、优化、结果、复盘”讲。

推荐你深挖:技能系统

可以这样说:

我在项目里负责过技能系统。这个模块的目标是把角色的普攻、主动技能、Buff、冷却、动画表现、碰撞判定、伤害结算串成一条稳定流程。我的设计思路是把它拆成配置层、运行时逻辑层、表现层和结算层。

配置层保存技能 ID、CD、消耗、范围、前摇时间、伤害段数、特效路径。运行时层保存当前 CD、释放状态、目标信息。表现层负责动画、特效、音效、镜头震动。结算层负责目标筛选、伤害公式、Buff 添加和战斗日志。

核心流程

玩家输入技能键后,先进入 SkillService。它会检查角色状态、CD、蓝量、目标是否合法。如果能释放,就切换角色状态,播放动画和特效。在关键帧触发攻击判定,筛选目标后交给伤害系统结算,最后进入后摇和冷却。

技术难点

第一个难点是动画时机和逻辑时机同步。不能把伤害帧写死在代码里,否则动画一改代码也要改。我会把命中帧、特效点、碰撞盒开启时间配置化,或者用动画事件触发逻辑。

第二个难点是技能打断。比如翻滚、受击、死亡可能打断技能,所以技能释放不能只写成一段顺序代码,要接入角色状态机。打断时要关闭碰撞盒、停止特效、清理临时状态,避免残留伤害。

第三个难点是性能。技能特效、碰撞盒、子弹、伤害数字都不能频繁 Instantiate,所以我用对象池复用。热路径里避免 LINQ、字符串拼接和临时 List 分配。

小段代码骨架

c
public sealed class SkillService // 定义技能服务,作为技能释放统一入口。
{ // 类开始。
    public bool TryCastSkill(Character caster, int skillId) // 尝试让角色释放指定技能。
    { // 方法开始。
        SkillConfig config = SkillConfigTable.Get(skillId); // 读取技能配置数据。
        if (config == null) // 如果配置不存在。
        { // if 分支开始。
            return false; // 返回释放失败。
        } // if 分支结束。
        if (!caster.State.CanCastSkill) // 如果角色当前状态不能释放技能。
        { // if 分支开始。
            return false; // 返回释放失败。
        } // if 分支结束。
        if (!caster.Cooldown.IsReady(skillId)) // 如果技能还在冷却中。
        { // if 分支开始。
            return false; // 返回释放失败。
        } // if 分支结束。
        caster.State.EnterCast(config); // 切换到技能释放状态。
        caster.Animator.Play(config.AnimationName); // 播放技能动画。
        caster.Cooldown.Start(skillId, config.Cooldown); // 启动技能冷却。
        return true; // 返回释放成功。
    } // 方法结束。
} // 类结束。

结果怎么讲

我会补数据,比如:技能释放过程中 GC Alloc 从每次几十 KB 降到 0;新增普通技能主要改配置,减少硬编码;特效和判定对象池化后,首次释放卡顿明显降低。

复盘怎么讲

NOTE

如果重做,我会补一个可视化技能时间轴工具,让策划直接编辑前摇、命中帧、特效点、后摇、可打断窗口,减少程序反复改配置和查问题的成本。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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