Skip to content

题海扩展:最终加题清单

Unity 的脚本生命周期完整顺序是什么?

csharp-unity-script-lifecycle-full-order

标准答案

Unity 脚本生命周期不能死背成一条绝对固定链,最好分阶段记:

初始化阶段

c
Reset / OnValidate 编辑器阶段
-> Awake
-> OnEnable
-> Start

Awake 通常最早,用来初始化自己;OnEnable 在对象激活且脚本启用时调用;Start 在第一次 Update 前调用,通常用于依赖其他对象初始化后的逻辑。

每帧阶段

c
FixedUpdate
-> 物理模拟 / Trigger / Collision
-> Update
-> Coroutine 按 yield 类型继续
-> LateUpdate
-> 渲染相关回调

FixedUpdate 适合物理,Update 适合输入和普通逻辑,LateUpdate 适合相机跟随、根据角色本帧移动结果做收尾。

退出阶段

c
OnDisable
-> OnDestroy

如果是应用退出,还会触发 OnApplicationQuit。但移动端更常关注 OnApplicationPauseOnApplicationFocus,因为 App 可能只是切后台,不一定正常退出。

容易被追问的细节

Awake 一般只执行一次。 OnEnable 可以执行很多次。 Start 对一个脚本实例通常只执行一次。 Update 每帧执行。 OnDisable 每次禁用都会执行。 OnDestroy 在对象销毁或场景卸载时执行。

代码例子

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Debug。

public class LifecycleLogger : MonoBehaviour // 定义一个生命周期日志脚本。
{ // 类开始。
    private void Awake() // 对象加载或实例化时调用,通常最早。
    { // 方法开始。
        Debug.Log("Awake"); // 打印 Awake。
    } // 方法结束。

    private void OnEnable() // 对象激活且组件启用时调用。
    { // 方法开始。
        Debug.Log("OnEnable"); // 打印 OnEnable。
    } // 方法结束。

    private void Start() // 第一次 Update 前调用一次。
    { // 方法开始。
        Debug.Log("Start"); // 打印 Start。
    } // 方法结束。

    private void FixedUpdate() // 按固定物理步长调用。
    { // 方法开始。
        Debug.Log("FixedUpdate"); // 打印 FixedUpdate。
    } // 方法结束。

    private void Update() // 每帧调用一次。
    { // 方法开始。
        Debug.Log("Update"); // 打印 Update。
    } // 方法结束。

    private void LateUpdate() // 所有 Update 之后调用。
    { // 方法开始。
        Debug.Log("LateUpdate"); // 打印 LateUpdate。
    } // 方法结束。

    private void OnDisable() // 对象或组件被禁用时调用。
    { // 方法开始。
        Debug.Log("OnDisable"); // 打印 OnDisable。
    } // 方法结束。

    private void OnDestroy() // 对象销毁或场景卸载时调用。
    { // 方法开始。
        Debug.Log("OnDestroy"); // 打印 OnDestroy。
    } // 方法结束。
} // 类结束。

面试加分点

我会这样总结:Awake 建自己,OnEnable 订阅事件,Start 连别人,Update 跑逻辑,LateUpdate 做收尾,OnDisable 退订,OnDestroy 释放资源。

脚本之间同类回调的默认顺序不稳定。如果必须控制,比如输入系统先于角色系统、角色移动先于相机跟随,可以用 Script Execution Order,但不要滥用。

Unity 中 Awake 里能否安全访问其他对象的 Start 初始化结果?

csharp-unity-awake-access-other-start

标准答案

不能安全访问。Awake 发生在 Start 之前,所以你在 A 的 Awake 里访问 B 的 Start 初始化结果,B 的 Start 通常还没执行。

更稳的说法是:

Awake 适合初始化自己,比如缓存组件、读取 Inspector 引用、准备自身字段。 Start 更适合做对象之间的协作,比如读取别的对象已经在 Awake 里准备好的数据。

底层顺序

常见运行顺序是:

所有对象 Awake
-> 启用对象 OnEnable
-> 第一帧前 Start
-> Update

所以:

A.Awake 读 B.Start 的结果:不安全
A.Start 读 B.Awake 的结果:通常更合理

但还要注意:不同脚本之间的 Awake 顺序默认也不应该强依赖。如果必须保证顺序,要用 Script Execution OrderBootstrapper 或显式初始化方法。

错误例子

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Debug。

public class ServiceB : MonoBehaviour // 定义一个服务对象 B。
{ // 类开始。
    public bool IsReady { get; private set; } // 记录 B 是否初始化完成。

    private void Start() // Start 会在 Awake 之后执行。
    { // 方法开始。
        IsReady = true; // 在 Start 里设置初始化完成。
    } // 方法结束。
} // 类结束。

public class ConsumerA : MonoBehaviour // 定义一个依赖 B 的对象 A。
{ // 类开始。
    [SerializeField] private ServiceB serviceB; // 在 Inspector 中绑定 B。

    private void Awake() // A 的 Awake 可能早于 B 的 Start。
    { // 方法开始。
        Debug.Log(serviceB.IsReady); // 这里很可能读到 false,因为 B.Start 还没执行。
    } // 方法结束。
} // 类结束。

推荐写法

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Debug。

public class ConfigService : MonoBehaviour // 定义一个配置服务。
{ // 类开始。
    public bool IsReady { get; private set; } // 记录配置是否准备好。

    private void Awake() // Awake 用来准备自己的基础数据。
    { // 方法开始。
        IsReady = true; // 在 Awake 中完成自身初始化。
    } // 方法结束。
} // 类结束。

public class BattleSystem : MonoBehaviour // 定义一个战斗系统。
{ // 类开始。
    [SerializeField] private ConfigService configService; // 在 Inspector 中绑定配置服务。

    private void Start() // Start 用来做跨对象协作。
    { // 方法开始。
        if (configService.IsReady) // 此时读取对方 Awake 中准备的数据更合理。
        { // 判断开始。
            Debug.Log("可以开始初始化战斗系统"); // 使用配置服务结果。
        } // 判断结束。
    } // 方法结束。
} // 类结束。

面试加分点

如果项目复杂,我不会让各个脚本互相赌 Awake / Start 顺序,而是写一个 Bootstrapper

c
LoadConfig()
-> InitManagers()
-> InitSystems()
-> EnterGame()

这样初始化顺序由我显式控制,而不是交给 Unity 回调顺序碰运气。

一句话总结:Awake 里可以找对象和建自己,但不要依赖别人 Start;跨对象依赖放 Start 或显式初始化流程。

Unity 中 OnEnable 可能在 Awake 前调用吗?

csharp-unity-onenable-before-awake

结论:同一个 MonoBehaviour 实例上,OnEnable 不会在 Awake 前调用。

第一次启用时顺序是:

c
Awake -> OnEnable -> Start

但要注意一个面试坑:跨对象、跨脚本不能乱依赖顺序。也就是说,A 对象的 OnEnable 不要假设 B 对象的 Awake 一定已经把数据初始化好了。严格依赖顺序时,用 Script Execution Order、统一 Bootstrap 初始化,或者用事件通知“我准备好了”。

代码记忆版:

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Debug。

public class EnableOrderDemo : MonoBehaviour // 定义一个测试 Awake 和 OnEnable 顺序的脚本。
{ // 类开始。
    private bool _awakeFinished; // 记录 Awake 是否已经执行过。

    private void Awake() // 同一个实例上,Awake 会先于 OnEnable 执行。
    { // 方法开始。
        _awakeFinished = true; // 标记 Awake 已完成。
        Debug.Log("Awake:初始化自身字段和组件引用"); // 打印 Awake 日志。
    } // 方法结束。

    private void OnEnable() // 对象或组件每次启用时都会调用。
    { // 方法开始。
        Debug.Log($"OnEnable:Awake 是否已完成 = {_awakeFinished}"); // 同实例这里会看到 true。
        SubscribeEvents(); // 在 OnEnable 里订阅事件。
    } // 方法结束。

    private void OnDisable() // 对象或组件每次禁用时都会调用。
    { // 方法开始。
        UnsubscribeEvents(); // 在 OnDisable 里退订事件,避免重复订阅。
    } // 方法结束。

    private void SubscribeEvents() // 定义订阅事件的方法。
    { // 方法开始。
    } // 方法结束。

    private void UnsubscribeEvents() // 定义退订事件的方法。
    { // 方法开始。
    } // 方法结束。
} // 类结束。

面试一句话:Awake 适合做一次性的自身初始化,OnEnable 适合做每次启用时的订阅、刷新、恢复逻辑;同实例一定是 Awake 先,但跨对象初始化不要靠猜生命周期顺序。

Unity 中 FixedUpdate 一帧可能执行多次吗?

csharp-unity-fixedupdate-multiple-per-frame

标准答案

会。FixedUpdate 不是“每个渲染帧调用一次”,而是按固定物理时间步调用。Unity 默认 Time.fixedDeltaTime = 0.02f,也就是大约每秒 50 次物理步。

所以一个渲染帧里,FixedUpdate 可能是:

0 次:这一帧很快,还没累计够 0.02 秒。 1 次:刚好够一个固定物理步。 多次:这一帧卡了,比如 60ms,Unity 可能补跑多个物理步。

底层原理

Unity 内部可以理解成有一个“固定时间累积器”:

accumulator += 当前渲染帧耗时
while accumulator >= fixedDeltaTime:
    调用 FixedUpdate
    执行一次物理模拟
    accumulator -= fixedDeltaTime

所以低帧率时,Unity 为了让物理时间追上真实时间,可能在同一个渲染帧里连续执行多次 FixedUpdate。不过它也不会无限补跑,会受到 Maximum Allowed Timestep / maximumDeltaTime 之类的限制,否则卡顿时可能越补越卡。

代码验证

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Time 和 Debug。

public class FixedUpdateCounter : MonoBehaviour // 定义一个统计每帧 FixedUpdate 次数的脚本。
{ // 类开始。
    private int _fixedCountThisFrame; // 记录当前渲染帧之前执行了几次 FixedUpdate。

    private void FixedUpdate() // 每次固定物理步到来时调用。
    { // 方法开始。
        _fixedCountThisFrame++; // 当前渲染帧内的 FixedUpdate 次数加一。
    } // 方法结束。

    private void Update() // 每个渲染帧调用一次。
    { // 方法开始。
        Debug.Log($"第 {Time.frameCount} 帧,FixedUpdate 执行次数 = {_fixedCountThisFrame}"); // 输出这一帧对应的 FixedUpdate 次数。
        _fixedCountThisFrame = 0; // 输出后清零,准备统计下一帧。
    } // 方法结束。
} // 类结束。

Unity 工程实践

Update 更适合读输入、UI、普通帧逻辑。 FixedUpdate 更适合 Rigidbody 的力、速度、物理移动。 不要在 FixedUpdate 里放大量 AI、寻路、加载、复杂遍历,因为低帧率时它可能一帧执行多次,开销会被放大。

面试一句话

FixedUpdate 跟固定物理时间步走,不跟渲染帧走;所以一帧可能不执行,也可能执行多次,卡顿时 Unity 会补跑物理步。

Unity 中 LateUpdate 适合做哪些逻辑?

csharp-unity-lateupdate-logic

标准答案

LateUpdate 适合做“本帧收尾逻辑”:等所有 Update 把角色位置、输入、AI、动画参数等更新完之后,再做依赖最终状态的事情。

最典型的是:

相机跟随:角色在 Update 里移动完,相机在 LateUpdate 里跟随,减少抖动。 血条/名字板跟随:角色世界坐标确定后,再转屏幕坐标。 表现层修正:比如角色朝向修正、镜头摇晃、IK 辅助、特效挂点刷新。 不适合:不要把 Rigidbody 物理力、复杂 AI、寻路、资源加载放这里。

底层理解

一帧里大概是:

FixedUpdate 可能 0/多次 -> Update -> LateUpdate -> 渲染

Update 更像“本帧主要逻辑更新”,LateUpdate 更像“渲染前最后整理”。所以相机如果放 Update,可能出现相机先跟随,角色后移动,画面看起来抖;放 LateUpdate,相机看到的是角色本帧更新后的最终位置。

相机跟随代码

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Transform、Vector3。

public class CameraLateFollow : MonoBehaviour // 定义一个相机 LateUpdate 跟随脚本。
{ // 类开始。
    [SerializeField] private Transform target; // 要跟随的目标,一般是玩家角色。
    [SerializeField] private Vector3 offset = new Vector3(0f, 5f, -8f); // 相机相对目标的偏移。
    [SerializeField] private float smoothTime = 0.15f; // 相机平滑跟随时间。
    private Vector3 velocity; // SmoothDamp 内部使用的速度缓存。

    private void LateUpdate() // LateUpdate 在 Update 之后执行,适合做相机跟随。
    { // 方法开始。
        if (target == null) // 如果没有目标,就不继续计算。
        { // if 开始。
            return; // 直接返回,避免空引用。
        } // if 结束。

        Vector3 targetPosition = target.position + offset; // 计算相机应该移动到的位置。
        transform.position = Vector3.SmoothDamp(transform.position, targetPosition, ref velocity, smoothTime); // 平滑移动相机。
        transform.LookAt(target); // 让相机朝向目标。
    } // 方法结束。
} // 类结束。

面试一句话

LateUpdate 适合做依赖本帧最终状态的收尾逻辑,尤其是相机跟随和世界坐标 UI 跟随;输入放 Update,物理放 FixedUpdate,表现同步和镜头收尾放 LateUpdate

Unity 中 OnDestroy 一定会调用吗?

csharp-unity-ondestroy-guaranteed

标准答案

OnDestroy 不一定会调用。它只是在 Unity 正常销毁对象时触发的生命周期回调,不是绝对可靠的“最后保险”。

常见会调用的情况:

Destroy(gameObject)Destroy(component)。 场景卸载、切场景时对象被销毁。 退出 Play Mode 或应用正常退出。 父物体被销毁,子物体和组件一起销毁。

但这些情况不能依赖它:

对象从来没有 active 过。 移动端 App 进入后台后被系统直接杀掉。 程序崩溃、进程被强杀。 对象池对象只是 SetActive(false) 回池,并没有真正 Destroy

底层理解

OnDestroy 是 Unity 对象销毁流程的一部分。正常情况下,销毁前通常会先走 OnDisable,再走 OnDestroy。但如果 Unity 进程已经没机会继续执行,比如移动端后台被系统回收,OnDestroy 就可能根本跑不到。

另外,Destroy(obj) 不是立刻销毁,而是把对象标记为销毁,真正销毁通常会延迟到当前 Update 循环之后、渲染之前。

工程实践

事件退订更建议放 OnDisable,因为对象只是禁用时也应该停止接收事件。 释放最终资源可以放 OnDestroy,但最好也有显式 Release()。 重要存档不要只放 OnDestroy,应该在关键节点、OnApplicationPauseOnApplicationFocus 时保存。

代码示例

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Debug。

public class DestroyLifeDemo : MonoBehaviour // 定义一个演示销毁生命周期的脚本。
{ // 类开始。
    private bool _released; // 记录资源是否已经释放,避免重复释放。

    private void OnDisable() // 对象禁用或销毁前通常会调用。
    { // 方法开始。
        Debug.Log("OnDisable:退订事件,停止计时器"); // 输出 OnDisable 的用途。
        UnsubscribeEvents(); // 退订事件,避免禁用后还收到回调。
    } // 方法结束。

    private void OnDestroy() // 对象真正销毁时调用,但不是绝对保证。
    { // 方法开始。
        Debug.Log("OnDestroy:最后清理资源"); // 输出 OnDestroy 的用途。
        Release(); // 做最后的资源释放兜底。
    } // 方法结束。

    private void OnApplicationPause(bool pause) // 移动端进入后台时可能调用。
    { // 方法开始。
        if (pause) // 如果应用进入暂停状态。
        { // if 开始。
            SaveImportantData(); // 及时保存关键数据,不等 OnDestroy。
        } // if 结束。
    } // 方法结束。

    private void Release() // 定义释放资源的方法。
    { // 方法开始。
        if (_released) // 如果已经释放过。
        { // if 开始。
            return; // 直接返回,避免重复释放。
        } // if 结束。

        _released = true; // 标记资源已经释放。
    } // 方法结束。

    private void UnsubscribeEvents() // 定义退订事件的方法。
    { // 方法开始。
    } // 方法结束。

    private void SaveImportantData() // 定义保存关键数据的方法。
    { // 方法开始。
    } // 方法结束。
} // 类结束。

面试一句话

OnDestroy 是正常销毁回调,不是绝对保证;事件退订放 OnDisable,重要数据提前保存,OnDestroy 只当最后清理兜底。

参考:Unity 官方 MonoBehaviour.OnDestroyObject.Destroy 文档。

Unity 中对象池如何和 Addressables 结合?

csharp-unity-addressables-object-pool

标准答案

对象池和 Addressables 结合时,要把生命周期分成两层:

Addressables 负责:加载 Prefab、加载依赖、维护引用计数、释放资源。 对象池 负责:实例复用、取出、归还、重置状态、统一销毁。

高频对象,比如子弹、特效、伤害数字,推荐:

Addressables.LoadAssetAsync<GameObject>() 加载 Prefab -> 手动 Instantiate 预热对象池 -> Return 时 SetActive(false) -> 清池时 Destroy 实例 -> Addressables.Release(handle)

核心坑点

回池不是释放资源。回池只是 SetActive(false),不要每次回池都 Addressables.Release。 池子还活着,Prefab 的 AsyncOperationHandle 就要活着。 清池时先销毁池里所有实例,再 Addressables.Release(handle)。 如果对象是 Addressables.InstantiateAsync 创建的,真正销毁时用 Addressables.ReleaseInstance。 如果对象是 LoadAssetAsync 后自己 Instantiate 的,实例用 Destroy,Prefab handle 用 Addressables.Release

代码示例

c
using System; // 引入异常类型,用于未初始化时抛出错误。
using System.Collections.Generic; // 引入 Queue 和 HashSet,用于管理空闲对象和使用中对象。
using System.Threading.Tasks; // 引入 Task,用于等待 Addressables 异步加载。
using UnityEngine; // 引入 UnityEngine,使用 GameObject、Transform、MonoBehaviour。
using UnityEngine.AddressableAssets; // 引入 Addressables API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 和加载状态。

public sealed class AddressablePool : MonoBehaviour // 定义一个基于 Addressables 的 GameObject 对象池。
{ // 类开始。
    private readonly Queue<GameObject> _free = new Queue<GameObject>(); // 保存空闲对象。
    private readonly HashSet<GameObject> _busy = new HashSet<GameObject>(); // 保存已经借出的对象,防止重复归还。
    private AsyncOperationHandle<GameObject> _handle; // 保存 Prefab 的 Addressables 加载句柄。
    private GameObject _prefab; // 保存加载出来的 Prefab 资源。

    public async Task PreloadAsync(string address, int count) // 异步加载 Addressables 资源并预热对象池。
    { // 方法开始。
        _handle = Addressables.LoadAssetAsync<GameObject>(address); // 根据地址异步加载 Prefab。
        _prefab = await _handle.Task; // 等待加载完成并取得 Prefab。
        if (_handle.Status != AsyncOperationStatus.Succeeded || _prefab == null) // 判断加载是否失败。
        { // if 开始。
            Debug.LogError($"Addressables 加载失败: {address}"); // 输出加载失败日志。
            return; // 加载失败时直接返回。
        } // if 结束。

        for (int i = 0; i < count; i++) // 按预热数量创建对象。
        { // for 开始。
            GameObject obj = CreateOne(); // 创建一个池对象。
            _free.Enqueue(obj); // 把创建好的对象放入空闲队列。
        } // for 结束。
    } // 方法结束。

    public GameObject Get(Vector3 position, Quaternion rotation) // 从对象池取出一个对象。
    { // 方法开始。
        GameObject obj = _free.Count > 0 ? _free.Dequeue() : CreateOne(); // 优先复用空闲对象,不够时扩容创建。
        _busy.Add(obj); // 标记对象正在使用。
        obj.transform.SetPositionAndRotation(position, rotation); // 设置对象位置和旋转。
        obj.SetActive(true); // 激活对象,让它进入使用状态。
        return obj; // 返回对象给业务逻辑。
    } // 方法结束。

    public void Return(GameObject obj) // 把对象归还到池里。
    { // 方法开始。
        if (obj == null) // 判断传入对象是否为空。
        { // if 开始。
            return; // 空对象不处理。
        } // if 结束。

        if (!_busy.Remove(obj)) // 如果对象不在使用集合里,说明可能重复归还或不是本池对象。
        { // if 开始。
            Debug.LogWarning("对象重复归还,或者不是这个池创建的对象。"); // 输出警告,方便排查对象池错误。
            return; // 直接返回,避免同一个对象进入队列两次。
        } // if 结束。

        obj.SetActive(false); // 禁用对象,停止表现和 Update。
        obj.transform.SetParent(transform, false); // 挂回池节点,方便层级管理。
        _free.Enqueue(obj); // 放回空闲队列,等待下次复用。
    } // 方法结束。

    public void Clear() // 清空对象池并释放 Addressables 资源。
    { // 方法开始。
        foreach (GameObject obj in _free) // 遍历所有空闲对象。
        { // foreach 开始。
            Destroy(obj); // 销毁空闲对象实例。
        } // foreach 结束。

        foreach (GameObject obj in _busy) // 遍历所有还在使用的对象。
        { // foreach 开始。
            Destroy(obj); // 销毁使用中的对象实例。
        } // foreach 结束。

        _free.Clear(); // 清空空闲集合。
        _busy.Clear(); // 清空使用集合。
        _prefab = null; // 清掉 Prefab 引用。
        if (_handle.IsValid()) // 判断 Addressables 句柄是否有效。
        { // if 开始。
            Addressables.Release(_handle); // 释放 Prefab 句柄,让 Addressables 引用计数减少。
        } // if 结束。
    } // 方法结束。

    private GameObject CreateOne() // 创建一个新的池对象实例。
    { // 方法开始。
        if (_prefab == null) // 如果 Prefab 还没有加载出来。
        { // if 开始。
            throw new InvalidOperationException("对象池还没有完成 Addressables 预加载。"); // 抛出错误,避免空引用。
        } // if 结束。

        GameObject obj = Instantiate(_prefab, transform); // 从 Prefab 实例化一个对象。
        obj.SetActive(false); // 默认放入池中时保持禁用。
        return obj; // 返回创建好的对象。
    } // 方法结束。
} // 类结束。

面试一句话

Addressables 管资源和引用计数,对象池管实例复用;池子存在期间要持有加载 handle,归还对象只重置和禁用,清池时才销毁实例并释放 Addressables handle。

参考:Unity Addressables 官方文档的 Memory managementInstantiateAsync

Unity 中资源异步加载过程中切场景怎么办?

csharp-unity-async-loading-scene-switch

标准答案

资源异步加载过程中切场景,不能简单等它回来,也不能回调里直接 Instantiate。正确做法是:

切场景时让旧请求失效 -> 回调回来先检查场景版本/owner 是否还有效 -> 无效就 Release handle 并丢弃结果 -> 有效才实例化或绑定 UI

核心记住一句话:不怕异步加载晚回来,怕它晚回来还操作旧场景。

底层原理

异步加载通常包含:查地址、下载 Bundle、加载依赖、反序列化资源、回到主线程实例化。切场景时,旧场景里的 MonoBehaviour、UI、父节点可能已经销毁,但异步操作可能还会完成并触发回调。

所以要做三件事:

请求归属:记录这是哪个场景、哪个界面、哪个系统发起的加载。 版本校验:切场景时版本号加一,旧版本回调直接丢弃。 句柄释放:Addressables 的 AsyncOperationHandle 要成对 Release,否则引用计数不降。

代码示例

c
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、Transform、GameObject。
using UnityEngine.AddressableAssets; // 引入 Addressables,用于异步加载资源。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 和加载状态。
using System.Collections.Generic; // 引入 List,用于保存当前场景持有的资源句柄。

public sealed class SceneScopedAddressableLoader : MonoBehaviour // 定义一个按场景管理 Addressables 加载的组件。
{ // 类开始。
    private readonly List<AsyncOperationHandle> _liveHandles = new List<AsyncOperationHandle>(); // 保存当前场景仍然有效的资源句柄。
    private int _sceneVersion; // 记录当前场景版本,切场景时递增。

    public void LoadPrefabForCurrentScene(string address, Transform parent) // 发起一个属于当前场景的 Prefab 加载。
    { // 方法开始。
        int requestVersion = _sceneVersion; // 记录本次请求发起时的场景版本。
        AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(address); // 异步加载 Addressables Prefab。
        handle.Completed += completedHandle => OnPrefabLoaded(completedHandle, requestVersion, parent); // 加载完成后进入统一回调。
    } // 方法结束。

    private void OnPrefabLoaded(AsyncOperationHandle<GameObject> handle, int requestVersion, Transform parent) // 处理 Prefab 加载完成回调。
    { // 方法开始。
        if (requestVersion != _sceneVersion || parent == null) // 如果版本过期或父节点已经销毁。
        { // if 开始。
            Addressables.Release(handle); // 释放旧请求的资源句柄,避免引用计数泄漏。
            return; // 丢弃结果,不再实例化到旧场景。
        } // if 结束。

        if (handle.Status != AsyncOperationStatus.Succeeded || handle.Result == null) // 如果加载失败或结果为空。
        { // if 开始。
            Addressables.Release(handle); // 释放失败请求的句柄。
            return; // 直接返回,避免空引用。
        } // if 结束。

        _liveHandles.Add(handle); // 保存成功加载的句柄,让资源在当前场景期间保持有效。
        Instantiate(handle.Result, parent); // 只有场景仍有效时,才实例化到当前父节点下。
    } // 方法结束。

    public void BeginSceneSwitch() // 切场景前调用,让旧请求全部失效。
    { // 方法开始。
        _sceneVersion++; // 场景版本加一,使旧异步回调全部变成过期请求。
        for (int i = 0; i < _liveHandles.Count; i++) // 遍历当前场景持有的所有资源句柄。
        { // for 开始。
            if (_liveHandles[i].IsValid()) // 如果句柄仍然有效。
            { // if 开始。
                Addressables.Release(_liveHandles[i]); // 释放句柄,降低 Addressables 引用计数。
            } // if 结束。
        } // for 结束。

        _liveHandles.Clear(); // 清空句柄列表,避免重复释放。
    } // 方法结束。

    private void OnDestroy() // 对象销毁时做兜底清理。
    { // 方法开始。
        BeginSceneSwitch(); // 释放当前场景持有的资源句柄。
    } // 方法结束。
} // 类结束。

工程实践

普通资源用 LoadAssetAsync 加载后,必须保存 handle,场景结束时手动 Addressables.Release。 Addressable 场景用 Addressables.LoadSceneAsync 时,也要保存场景 handle,卸载时用 Addressables.UnloadSceneAsyncLoadSceneMode.Single 会卸载旧场景,但不会自动释放你单独 LoadAssetAsync 出来的 Addressables handle。 如果用了 activateOnLoad = false,不要同时堆很多异步加载,因为它可能阻塞 Addressables 的异步队列。 切场景流程最好统一交给 SceneManager / ResourceManager,不要让 UI、战斗、特效脚本各自偷偷加载又不登记。

面试一句话

资源异步加载跨场景时,我会给每个请求绑定场景版本和 owner;切场景时让旧版本失效,完成回调先校验,过期就释放 handle 并丢弃结果,有效才实例化,避免空引用、资源泄漏和旧场景回调污染新场景。

参考:Unity Addressables 官方文档 Loading Addressable assetsLoad a scene

Unity 中多个异步请求加载同一资源如何合并?

csharp-unity-merge-async-load-requests

标准答案

多个异步请求加载同一资源,项目里一般用 ResourceManager 做“请求合并”:

先查已加载缓存 -> 再查正在加载表 inFlight -> 如果正在加载就复用同一个 Task/handle -> 如果没有才真正 LoadAssetAsync -> 完成后通知所有等待者 -> Release 时按业务引用计数减少

底层理解

Addressables 对同一个资源多次 LoadAssetAsync 时,底层通常不会真的重复加载多份资源,而是通过引用计数管理;但每次调用都会增加引用计数,并返回一个 handle,后面也要对应 Release。

所以工程上我不会让业务层到处直接调 Addressables.LoadAssetAsync,而是统一走资源管理器。这样能控制重复请求、失败回调、切场景释放、引用计数和日志统计。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary,用来保存加载记录。
using System.Threading.Tasks; // 引入 Task,用来让多个调用方等待同一个异步结果。
using UnityEngine; // 引入 UnityEngine,使用 GameObject 和 MonoBehaviour。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载 API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 和加载状态。

public sealed class CoalescedAddressLoader : MonoBehaviour // 定义一个会合并相同请求的资源加载器。
{ // 类开始。
    private sealed class LoadRecord // 定义单个资源的加载记录。
    { // 内部类开始。
        public AsyncOperationHandle<GameObject> Handle; // 保存 Addressables 返回的加载句柄。
        public Task<GameObject> Task; // 保存共享的异步任务,多个请求会等待它。
        public int RefCount; // 保存业务层引用计数。
    } // 内部类结束。

    private readonly Dictionary<string, LoadRecord> _records = new Dictionary<string, LoadRecord>(); // 用 address 作为 key 保存缓存和正在加载记录。

    public Task<GameObject> LoadPrefabAsync(string address) // 对外提供加载 Prefab 的接口。
    { // 方法开始。
        if (_records.TryGetValue(address, out LoadRecord record)) // 如果这个资源已经在加载或已经加载完成。
        { // if 开始。
            record.RefCount++; // 业务引用计数加一。
            return record.Task; // 返回同一个 Task,不重复发起 Addressables 加载。
        } // if 结束。

        record = new LoadRecord(); // 创建新的加载记录。
        record.RefCount = 1; // 第一个请求者让引用计数从一开始。
        record.Handle = Addressables.LoadAssetAsync<GameObject>(address); // 真正发起一次 Addressables 加载。
        record.Task = WaitLoadAsync(address, record); // 创建一个共享等待任务。
        _records.Add(address, record); // 把记录加入字典,后续同 key 请求会复用它。
        return record.Task; // 返回共享任务给调用方。
    } // 方法结束。

    private async Task<GameObject> WaitLoadAsync(string address, LoadRecord record) // 等待 Addressables 加载完成。
    { // 方法开始。
        await record.Handle.Task; // 等待底层异步加载结束。
        if (record.Handle.Status != AsyncOperationStatus.Succeeded || record.Handle.Result == null) // 判断加载是否失败。
        { // if 开始。
            _records.Remove(address); // 失败后移除记录,避免后续永远等待失败记录。
            if (record.Handle.IsValid()) // 如果句柄仍然有效。
            { // if 开始。
                Addressables.Release(record.Handle); // 释放失败请求的句柄。
            } // if 结束。
            return null; // 返回空,告诉调用方加载失败。
        } // if 结束。

        return record.Handle.Result; // 返回加载成功的 Prefab 资源。
    } // 方法结束。

    public void Release(string address) // 对外提供释放资源的接口。
    { // 方法开始。
        if (!_records.TryGetValue(address, out LoadRecord record)) // 如果没有找到记录。
        { // if 开始。
            return; // 直接返回,避免重复释放报错。
        } // if 结束。

        record.RefCount--; // 业务引用计数减一。
        if (record.RefCount > 0) // 如果还有其他模块正在使用。
        { // if 开始。
            return; // 不释放底层资源。
        } // if 结束。

        _records.Remove(address); // 引用计数归零后,从记录表移除。
        if (record.Handle.IsValid()) // 如果 Addressables 句柄有效。
        { // if 开始。
            Addressables.Release(record.Handle); // 真正释放 Addressables 句柄。
        } // if 结束。
    } // 方法结束。
} // 类结束。

面试加分点

Prefab 资源可以合并加载,但 GameObject 实例不能直接共用,多个调用方如果都要一个对象,应该共享 Prefab,然后各自 Instantiate,或者走对象池。

失败时一定要移除 inFlight 记录,否则后续请求会一直等一个失败任务。切场景时要统一释放该场景持有的引用,不能只靠业务脚本自己记得 Release。

参考:Unity Addressables 官方文档的 Memory managementLoading Addressable assets

Unity 中如何设计资源引用计数?

csharp-unity-resource-reference-counting

标准答案

资源引用计数的核心是:谁申请资源,谁负责释放资源。项目里一般做一个统一 ResourceManager,每个资源 key 对应一条 ResourceRecord,里面记录:

refCount:业务引用次数。 handle:Addressables 返回的加载句柄。 state:Loading / Loaded / Releasing。 owners:谁持有它,比如 UI、场景、对象池、战斗系统。 deps:依赖资源或 Bundle 信息。

核心流程

Acquire 时: 先查缓存,有就 refCount++;正在加载就复用同一个任务;没有就启动异步加载。

Release 时: refCount--;如果还有人用,就不卸载;如果归零,可以进入延迟卸载队列;真正卸载时再 Addressables.Release(handle)

注意:refCount == 0 不代表内存立刻下降。因为资源可能还在同一个 AssetBundle 里,Bundle 不能只卸载其中一部分,依赖也可能还被其他资源引用。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary,用来保存资源记录。
using System.Threading.Tasks; // 引入 Task,用来复用异步加载任务。
using UnityEngine; // 引入 UnityEngine,使用 GameObject 和 Debug。
using UnityEngine.AddressableAssets; // 引入 Addressables API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 和加载状态。

public sealed class RefCountResourceManager : MonoBehaviour // 定义一个简单的引用计数资源管理器。
{ // 类开始。
    private sealed class Record // 定义一条资源记录。
    { // 内部类开始。
        public AsyncOperationHandle<GameObject> Handle; // 保存 Addressables 加载句柄。
        public Task<GameObject> Task; // 保存共享异步任务。
        public int RefCount; // 保存业务引用计数。
    } // 内部类结束。

    private readonly Dictionary<string, Record> _records = new Dictionary<string, Record>(); // 用资源地址保存资源记录。

    public Task<GameObject> AcquireAsync(string address) // 申请一个资源。
    { // 方法开始。
        if (_records.TryGetValue(address, out Record record)) // 如果资源已经加载或正在加载。
        { // if 开始。
            record.RefCount++; // 引用计数加一。
            return record.Task; // 返回同一个异步任务,避免重复加载。
        } // if 结束。

        record = new Record(); // 创建新的资源记录。
        record.RefCount = 1; // 第一个申请者让引用计数为一。
        record.Handle = Addressables.LoadAssetAsync<GameObject>(address); // 发起 Addressables 异步加载。
        record.Task = WaitLoadAsync(address, record); // 创建共享等待任务。
        _records.Add(address, record); // 把资源记录放入字典。
        return record.Task; // 返回加载任务。
    } // 方法结束。

    private async Task<GameObject> WaitLoadAsync(string address, Record record) // 等待资源加载完成。
    { // 方法开始。
        await record.Handle.Task; // 等待 Addressables 加载结束。
        if (record.Handle.Status != AsyncOperationStatus.Succeeded) // 如果加载失败。
        { // if 开始。
            _records.Remove(address); // 移除失败记录。
            Addressables.Release(record.Handle); // 释放失败句柄。
            return null; // 返回空资源。
        } // if 结束。

        if (record.RefCount <= 0) // 如果加载完成前已经没人需要它。
        { // if 开始。
            _records.Remove(address); // 从记录表移除。
            Addressables.Release(record.Handle); // 立即释放加载结果。
            return null; // 返回空资源。
        } // if 结束。

        return record.Handle.Result; // 返回加载成功的资源。
    } // 方法结束。

    public void Release(string address) // 释放一个资源引用。
    { // 方法开始。
        if (!_records.TryGetValue(address, out Record record)) // 如果找不到资源记录。
        { // if 开始。
            Debug.LogWarning($"重复释放或未申请资源: {address}"); // 输出警告,方便排查重复释放。
            return; // 直接返回。
        } // if 结束。

        record.RefCount--; // 引用计数减一。
        if (record.RefCount > 0) // 如果还有其他模块持有资源。
        { // if 开始。
            return; // 不释放底层资源。
        } // if 结束。

        if (!record.Handle.IsDone) // 如果资源还在加载中。
        { // if 开始。
            return; // 等加载完成回调里再释放结果。
        } // if 结束。

        _records.Remove(address); // 从记录表移除资源。
        Addressables.Release(record.Handle); // 释放 Addressables 句柄。
    } // 方法结束。
} // 类结束。

面试加分点

引用计数最好记录 owner 和调用栈,方便查“谁没释放”。 对象池持有的 Prefab handle 不能提前释放,否则实例可能丢材质、贴图、Mesh。 切场景时按 sceneScope 批量 Release。 归零后可以延迟几秒或放入 LRU,避免刚卸载又马上加载。 调试时看 key / refCount / owner / handle state,再配合 Memory Profiler 和 Addressables Event Viewer。

参考:Unity Addressables 官方 Memory managementAssetBundle dependencies

Unity 中如何避免 UI 多次刷新?

csharp-unity-avoid-ui-multiple-refresh

标准答案

避免 UI 多次刷新,核心做法是:数据变化只标脏,不立刻刷新;一帧末尾统一 Flush;刷新时只做差量更新。

也就是不要这样:

金币变化 -> Refresh背包变化 -> Refresh任务变化 -> Refresh

而是这样:

金币/背包/任务变化 -> MarkDirty -> 本帧末尾统一 Refresh 一次

工程做法

OnEnable 订阅事件,OnDisable 退订,避免重复监听。 事件回调里只 MarkDirty,不要直接全量 Refresh。 用 HashSet 收集脏 UI,同一个 UI 一帧内标脏多次也只刷新一次。 刷新时比较旧值和新值,没变化就不改 Text/Image/Layout。 列表类 UI 用虚拟列表,避免每次刷新全部 Item。 避免 Refresh 里再次改数据、再次发事件,形成刷新递归。

代码示例

c
using System.Collections.Generic; // 引入集合,用 HashSet 去重待刷新的 UI。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour。
using TMPro; // 引入 TMP_Text,用于显示文本。

public interface IUiDirtyView // 定义可被调度刷新的 UI 接口。
{ // 接口开始。
    void RefreshDirty(); // 只刷新脏数据。
} // 接口结束。

public sealed class UiRefreshScheduler : MonoBehaviour // 定义 UI 刷新调度器。
{ // 类开始。
    private readonly HashSet<IUiDirtyView> _dirtyViews = new HashSet<IUiDirtyView>(); // 保存本帧需要刷新的 UI,并自动去重。

    public void MarkDirty(IUiDirtyView view) // 标记某个 UI 需要刷新。
    { // 方法开始。
        if (view == null) // 如果传入 UI 为空。
        { // if 开始。
            return; // 直接返回,避免空引用。
        } // if 结束。

        _dirtyViews.Add(view); // 加入待刷新集合,同一帧重复加入也只保留一次。
    } // 方法结束。

    private void LateUpdate() // 在一帧末尾统一刷新 UI。
    { // 方法开始。
        if (_dirtyViews.Count == 0) // 如果没有需要刷新的 UI。
        { // if 开始。
            return; // 直接返回,避免无意义遍历。
        } // if 结束。

        foreach (IUiDirtyView view in _dirtyViews) // 遍历所有待刷新的 UI。
        { // foreach 开始。
            view.RefreshDirty(); // 执行一次差量刷新。
        } // foreach 结束。

        _dirtyViews.Clear(); // 清空集合,准备下一帧继续收集。
    } // 方法结束。
} // 类结束。

public sealed class GoldPanel : MonoBehaviour, IUiDirtyView // 定义一个金币 UI 面板。
{ // 类开始。
    [SerializeField] private TMP_Text goldText; // 绑定金币文本组件。
    [SerializeField] private UiRefreshScheduler scheduler; // 绑定 UI 刷新调度器。
    private int _gold; // 保存当前金币数据。
    private int _shownGold = -1; // 保存已经显示到 UI 上的金币值。

    public void SetGold(int gold) // 外部数据变化时调用。
    { // 方法开始。
        _gold = gold; // 更新数据。
        scheduler.MarkDirty(this); // 只标记脏,不立刻刷新 UI。
    } // 方法结束。

    public void RefreshDirty() // 调度器在帧末调用这个方法。
    { // 方法开始。
        if (_shownGold == _gold) // 如果显示值和数据值一致。
        { // if 开始。
            return; // 不修改文本,避免触发无意义重建。
        } // if 结束。

        _shownGold = _gold; // 更新已显示的缓存值。
        goldText.text = _gold.ToString(); // 只有真正变化时才改 UI 文本。
    } // 方法结束。
} // 类结束。

面试一句话

我会把 UI 刷新拆成“数据通知、脏标记、帧末合批、差量更新”四步;这样能减少重复 Refresh、降低 Canvas Rebuild/Layout Rebuild,也更容易统计每个窗口的刷新次数。

Unity 中如何实现数据驱动 UI?

csharp-unity-data-driven-ui

标准答案

数据驱动 UI 的核心是:UI 不保存真实业务状态,真实状态放在 Model;UI 只根据数据变化刷新显示;用户操作通过 Command 改数据,再由数据通知 UI 更新。

简单说就是:

Model 存数据 -> ViewModel 转成 UI 字段 -> View 绑定显示 -> 用户点击发 Command -> Command 修改 Model -> Model 通知 UI

工程做法

复杂 UI 可以用 MVP/MVVM 思路。 Model 放背包、金币、任务状态。 ViewModel 把数据转换成 UI 要显示的文本、进度条、按钮状态。 View 只负责绑定组件、显示数据、接收点击。 刷新用事件通知、脏标记、帧末合批、差量更新,避免 UI 多次刷新。

代码示例

c
using System; // 引入 Action,用于数据变化通知。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour。
using TMPro; // 引入 TMP_Text,用于显示文本。
using UnityEngine.UI; // 引入 Button,用于按钮点击。

public sealed class PlayerModel // 定义玩家数据模型。
{ // 类开始。
    public event Action OnChanged; // 定义数据变化事件。
    public int Gold { get; private set; } // 保存金币数量。
    public int Level { get; private set; } = 1; // 保存玩家等级。

    public void AddGold(int value) // 增加金币。
    { // 方法开始。
        Gold += value; // 修改真实数据。
        OnChanged?.Invoke(); // 通知外部数据已经变化。
    } // 方法结束。

    public void LevelUp() // 玩家升级。
    { // 方法开始。
        Level++; // 修改等级数据。
        OnChanged?.Invoke(); // 通知 UI 或其他系统刷新。
    } // 方法结束。
} // 类结束。

public sealed class PlayerViewModel // 定义玩家 UI 数据转换层。
{ // 类开始。
    private readonly PlayerModel _model; // 保存玩家数据模型引用。
    public string GoldText => $"金币:{_model.Gold}"; // 把金币数据转换成 UI 文本。
    public string LevelText => $"等级:{_model.Level}"; // 把等级数据转换成 UI 文本。

    public PlayerViewModel(PlayerModel model) // 构造 ViewModel。
    { // 构造开始。
        _model = model; // 保存 Model 引用。
    } // 构造结束。

    public void AddGoldCommand() // 定义按钮命令。
    { // 方法开始。
        _model.AddGold(100); // 用户操作最终修改 Model,而不是直接改 UI。
    } // 方法结束。
} // 类结束。

public sealed class PlayerPanel : MonoBehaviour // 定义玩家信息 UI 面板。
{ // 类开始。
    [SerializeField] private TMP_Text goldText; // 绑定金币文本。
    [SerializeField] private TMP_Text levelText; // 绑定等级文本。
    [SerializeField] private Button addGoldButton; // 绑定加金币按钮。
    private PlayerModel _model; // 保存 Model。
    private PlayerViewModel _viewModel; // 保存 ViewModel。

    private void Awake() // 初始化 UI 数据结构。
    { // 方法开始。
        _model = new PlayerModel(); // 创建玩家数据模型。
        _viewModel = new PlayerViewModel(_model); // 创建 UI 数据转换层。
    } // 方法结束。

    private void OnEnable() // UI 启用时绑定事件。
    { // 方法开始。
        _model.OnChanged += Refresh; // 订阅数据变化事件。
        addGoldButton.onClick.AddListener(_viewModel.AddGoldCommand); // 按钮点击执行 Command。
        Refresh(); // 打开 UI 时刷新一次初始显示。
    } // 方法结束。

    private void OnDisable() // UI 禁用时解绑事件。
    { // 方法开始。
        _model.OnChanged -= Refresh; // 退订数据变化事件,避免重复刷新。
        addGoldButton.onClick.RemoveListener(_viewModel.AddGoldCommand); // 移除按钮监听,避免重复注册。
    } // 方法结束。

    private void Refresh() // 根据 ViewModel 刷新 UI。
    { // 方法开始。
        goldText.text = _viewModel.GoldText; // 显示金币文本。
        levelText.text = _viewModel.LevelText; // 显示等级文本。
    } // 方法结束。
} // 类结束。

面试一句话

数据驱动 UI 就是让 Model 存真实状态,ViewModel 转换 UI 状态,View 只负责显示和输入;UI 不直接写业务逻辑,数据变化通过事件或绑定通知 UI,并用脏标记和差量刷新控制性能。

Unity 中如何设计 UI 路由?

csharp-unity-ui-router-design

标准答案

UI 路由就是给 UI 系统做一个统一入口:业务只说“我要打开背包页”,不直接 Instantiate 某个 Prefab。路由层负责查配置、加载资源、挂到正确层级、传参、入栈、返回和释放。

核心设计

RouteTable:保存 route -> prefabAddress / layer / openModeUiRouter:统一提供 Open(route, args)Close(route)Back()WindowStack:维护返回栈,安卓返回键或关闭按钮走栈顶。 LayerRoot:分普通层、弹窗层、提示层、引导层,保证显示和点击顺序。 ResourceManager:负责 UI Prefab 的异步加载、缓存和释放。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary 和 Stack,用来保存路由表和返回栈。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、GameObject、Transform。

public enum UiLayer // 定义 UI 层级类型。
{ // 枚举开始。
    Normal, // 普通页面层。
    Popup, // 弹窗层。
    Toast, // 飘字提示层。
} // 枚举结束。

public sealed class UiRouteConfig // 定义一条 UI 路由配置。
{ // 类开始。
    public string Route; // 路由名,例如 bag、shop、setting。
    public GameObject Prefab; // 对应的 UI Prefab,项目里也可以换成 Addressables 地址。
    public UiLayer Layer; // 窗口应该挂到哪个 UI 层级。
    public bool PushStack; // 打开后是否进入返回栈。
} // 类结束。

public abstract class UiWindow : MonoBehaviour // 定义所有 UI 窗口的基类。
{ // 类开始。
    public virtual void OnOpen(object args) { } // 窗口打开时接收参数。
    public virtual bool CanClose() { return true; } // 返回窗口是否允许关闭。
    public virtual void OnClose() { } // 窗口关闭时做退订和清理。
} // 类结束。

public sealed class UiRouter : MonoBehaviour // 定义 UI 路由管理器。
{ // 类开始。
    [SerializeField] private Transform normalRoot; // 普通窗口挂载节点。
    [SerializeField] private Transform popupRoot; // 弹窗窗口挂载节点。
    [SerializeField] private Transform toastRoot; // 提示窗口挂载节点。
    private readonly Dictionary<string, UiRouteConfig> _routes = new Dictionary<string, UiRouteConfig>(); // 保存所有路由配置。
    private readonly Stack<UiWindow> _stack = new Stack<UiWindow>(); // 保存返回栈。

    public void Register(UiRouteConfig config) // 注册一条 UI 路由。
    { // 方法开始。
        _routes[config.Route] = config; // 用 route 作为 key 保存配置。
    } // 方法结束。

    public UiWindow Open(string route, object args = null) // 打开一个 UI 窗口。
    { // 方法开始。
        UiRouteConfig config = _routes[route]; // 根据 route 找到配置。
        Transform root = GetRoot(config.Layer); // 根据层级找到父节点。
        GameObject obj = Instantiate(config.Prefab, root); // 实例化 UI Prefab。
        UiWindow window = obj.GetComponent<UiWindow>(); // 拿到窗口脚本。
        window.OnOpen(args); // 把参数传给窗口。
        if (config.PushStack) // 如果这个窗口需要进入返回栈。
        { // if 开始。
            _stack.Push(window); // 把窗口压入返回栈。
        } // if 结束。
        return window; // 返回打开的窗口。
    } // 方法结束。

    public void Back() // 执行返回逻辑。
    { // 方法开始。
        if (_stack.Count == 0) // 如果返回栈为空。
        { // if 开始。
            return; // 直接返回。
        } // if 结束。
        UiWindow window = _stack.Peek(); // 取出栈顶窗口但先不弹出。
        if (!window.CanClose()) // 如果窗口当前不允许关闭。
        { // if 开始。
            return; // 直接返回。
        } // if 结束。
        _stack.Pop(); // 从返回栈弹出窗口。
        window.OnClose(); // 执行窗口关闭逻辑。
        Destroy(window.gameObject); // 销毁窗口对象,项目里也可以改成回收到 UI 池。
    } // 方法结束。

    private Transform GetRoot(UiLayer layer) // 根据 UI 层级返回挂载节点。
    { // 方法开始。
        if (layer == UiLayer.Popup) // 如果是弹窗层。
        { // if 开始。
            return popupRoot; // 返回弹窗层节点。
        } // if 结束。
        if (layer == UiLayer.Toast) // 如果是提示层。
        { // if 开始。
            return toastRoot; // 返回提示层节点。
        } // if 结束。
        return normalRoot; // 默认返回普通窗口层。
    } // 方法结束。
} // 类结束。

面试加分点

重复打开要有策略:SingleTop 复用当前窗口,Replace 关闭旧窗口再打开,Multiple 允许多个实例。 返回栈要和弹窗层分清:不是所有弹窗都要进返回栈。 异步打开要防重复点击:同一路由加载中时合并请求。 窗口关闭要退订事件、取消异步、释放资源句柄。 路由日志要打印 route、args、stack、layer、耗时,方便查返回错乱和重复打开。

一句话

UI 路由 = RouteTable 配窗口,Router 管打开关闭,Stack 管返回,LayerRoot 管层级,ResourceManager 管加载释放。

Unity 中如何实现页面返回栈?

csharp-unity-ui-page-back-stack

标准答案

页面返回栈就是用一个 Stack 管理“可返回页面”:打开普通页面时 Push,点击返回时只处理栈顶页面,关闭当前页后恢复上一页。根页面一般不出栈,弹窗最好单独用 PopupStack 管。

核心规则

Open Page:创建页面,保存 PageRecord,压入页面栈。 Back:取栈顶,检查能不能关闭,能关闭才 PopRoot Page:比如主界面,不被返回键关闭。 Popup:弹窗、确认框、奖励弹窗建议单独用弹窗栈。 重复打开:同一个页面要有 SingleTop / Replace / Multiple 策略。

代码示例

c
using System.Collections.Generic; // 引入 Stack,用来保存页面返回栈。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 GameObject。

public abstract class UiPage : MonoBehaviour // 定义 UI 页面基类。
{ // 类开始。
    public virtual void OnOpen(object args) { } // 页面打开时接收参数。
    public virtual void OnShowAgain() { } // 返回到这个页面时调用。
    public virtual bool CanBack() { return true; } // 页面是否允许被返回键关闭。
    public virtual void OnClose() { } // 页面关闭时做清理。
} // 类结束。

public sealed class PageRecord // 定义页面栈中的一条记录。
{ // 类开始。
    public string Route; // 保存页面路由名。
    public object Args; // 保存打开页面时的参数。
    public UiPage Page; // 保存页面实例。
    public bool IsRoot; // 标记是否是根页面。
} // 类结束。

public sealed class UiBackStack : MonoBehaviour // 定义页面返回栈管理器。
{ // 类开始。
    private readonly Stack<PageRecord> _pageStack = new Stack<PageRecord>(); // 保存页面返回栈。

    public void Push(string route, UiPage page, object args, bool isRoot) // 打开页面并入栈。
    { // 方法开始。
        if (_pageStack.Count > 0) // 如果当前已经有页面。
        { // if 开始。
            _pageStack.Peek().Page.gameObject.SetActive(false); // 隐藏旧页面,避免点击穿透和显示冲突。
        } // if 结束。

        PageRecord record = new PageRecord(); // 创建页面记录。
        record.Route = route; // 保存路由名。
        record.Args = args; // 保存页面参数。
        record.Page = page; // 保存页面实例。
        record.IsRoot = isRoot; // 保存是否根页面。
        _pageStack.Push(record); // 把页面压入返回栈。
        page.OnOpen(args); // 通知页面打开。
    } // 方法结束。

    public void Back() // 执行返回逻辑。
    { // 方法开始。
        if (_pageStack.Count == 0) // 如果栈为空。
        { // if 开始。
            return; // 直接返回。
        } // if 结束。

        PageRecord top = _pageStack.Peek(); // 先拿到栈顶页面。
        if (top.IsRoot) // 如果栈顶是根页面。
        { // if 开始。
            Debug.Log("已经在根页面,可以弹出退出确认。"); // 输出日志,项目里可弹退出确认框。
            return; // 根页面不关闭。
        } // if 结束。

        if (!top.Page.CanBack()) // 如果当前页面不允许返回。
        { // if 开始。
            return; // 直接返回,例如保存确认或动画未结束。
        } // if 结束。

        _pageStack.Pop(); // 从页面栈弹出当前页面。
        top.Page.OnClose(); // 通知当前页面关闭。
        Destroy(top.Page.gameObject); // 销毁当前页面,项目里也可以改成缓存或回池。
        if (_pageStack.Count > 0) // 如果还有上一个页面。
        { // if 开始。
            UiPage previous = _pageStack.Peek().Page; // 取出上一个页面。
            previous.gameObject.SetActive(true); // 恢复显示上一个页面。
            previous.OnShowAgain(); // 通知上一个页面重新显示。
        } // if 结束。
    } // 方法结束。
} // 类结束。

面试加分点

返回栈只管理“页面”,不要把所有弹窗都塞进去。 异步打开页面时要防重复点击,加载中同一路由要合并。 关闭页面后必须同步移除栈记录,否则返回栈会指向已销毁对象。 页面可以实现 CanBack(),用于保存确认、动画未结束、战斗中禁止关闭等场景。 开发阶段要打印当前 stack 路径,比如 Main -> Bag -> Detail,排查返回错乱很快。

一句话

页面返回栈 = 打开 Push,返回 Pop,根页面不出栈,弹窗另开 PopupStack,所有 Back 都走统一入口。

Unity 中如何实现 UI 弹窗队列?

csharp-unity-ui-popup-queue

标准答案

UI 弹窗队列就是:多个弹窗请求不要同时弹出来,而是先进队列;当前没有弹窗时显示队首;当前弹窗关闭后,再显示下一个。

常用于:奖励弹窗、公告弹窗、确认框、断线提示、错误提示、活动弹窗。

核心规则

同一时间只显示一个模态弹窗。 弹窗请求保存 route / args / priority / source / canMerge。 普通弹窗按 FIFO,重要弹窗按优先级。 同类奖励可以合并,避免连续弹十几个奖励框。 关闭回调一定要触发,否则队列会卡死。 切场景时要清空旧队列,避免旧场景弹窗跑到新场景。

代码示例

c
using System; // 引入 Action,用于弹窗关闭回调。
using System.Collections.Generic; // 引入 List,用于保存待显示弹窗队列。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour、GameObject、Transform。

public sealed class PopupRequest // 定义一个弹窗请求。
{ // 类开始。
    public string Key; // 弹窗唯一 key,用于去重和合并。
    public GameObject Prefab; // 弹窗 Prefab,项目中也可以换成 Addressables 地址。
    public object Args; // 打开弹窗时传入的参数。
    public int Priority; // 弹窗优先级,数字越大越先显示。
    public bool CanMerge; // 是否允许同类弹窗合并或去重。
} // 类结束。

public abstract class PopupWindow : MonoBehaviour // 定义弹窗基类。
{ // 类开始。
    public event Action<PopupWindow> Closed; // 定义弹窗关闭事件。

    public virtual void OnOpen(object args) { } // 弹窗打开时接收参数。

    public void Close() // 关闭弹窗。
    { // 方法开始。
        Closed?.Invoke(this); // 通知队列管理器当前弹窗已经关闭。
    } // 方法结束。
} // 类结束。

public sealed class PopupQueueManager : MonoBehaviour // 定义弹窗队列管理器。
{ // 类开始。
    [SerializeField] private Transform popupRoot; // 弹窗挂载节点。
    private readonly List<PopupRequest> _pending = new List<PopupRequest>(); // 保存待显示弹窗请求。
    private PopupWindow _current; // 保存当前正在显示的弹窗。
    private string _currentKey; // 保存当前弹窗 key。

    public void Enqueue(PopupRequest request) // 添加一个弹窗请求。
    { // 方法开始。
        if (request == null || request.Prefab == null) // 如果请求为空或 Prefab 为空。
        { // if 开始。
            return; // 直接返回,避免空引用。
        } // if 结束。

        if (request.CanMerge && ContainsSameKey(request.Key)) // 如果允许合并且队列里已有同类弹窗。
        { // if 开始。
            return; // 直接丢弃重复请求,项目里也可以改成合并奖励数据。
        } // if 结束。

        _pending.Add(request); // 把请求加入待显示队列。
        _pending.Sort((a, b) => b.Priority.CompareTo(a.Priority)); // 按优先级从高到低排序。
        TryShowNext(); // 尝试显示下一个弹窗。
    } // 方法结束。

    private void TryShowNext() // 尝试显示队列中的下一个弹窗。
    { // 方法开始。
        if (_current != null) // 如果当前已经有弹窗显示。
        { // if 开始。
            return; // 不显示新的弹窗,等待当前弹窗关闭。
        } // if 结束。

        if (_pending.Count == 0) // 如果队列为空。
        { // if 开始。
            return; // 没有弹窗需要显示。
        } // if 结束。

        PopupRequest request = _pending[0]; // 取出队首请求。
        _pending.RemoveAt(0); // 从队列移除队首请求。
        GameObject obj = Instantiate(request.Prefab, popupRoot); // 实例化弹窗。
        _current = obj.GetComponent<PopupWindow>(); // 获取弹窗脚本。
        _currentKey = request.Key; // 记录当前弹窗 key。
        _current.Closed += OnPopupClosed; // 订阅关闭事件。
        _current.OnOpen(request.Args); // 把参数传给弹窗。
    } // 方法结束。

    private void OnPopupClosed(PopupWindow popup) // 当前弹窗关闭时调用。
    { // 方法开始。
        popup.Closed -= OnPopupClosed; // 退订关闭事件,避免重复回调。
        Destroy(popup.gameObject); // 销毁弹窗对象,项目里也可以回收到 UI 对象池。
        _current = null; // 清空当前弹窗引用。
        _currentKey = null; // 清空当前弹窗 key。
        TryShowNext(); // 继续显示队列中的下一个弹窗。
    } // 方法结束。

    private bool ContainsSameKey(string key) // 判断当前弹窗或队列中是否已有同 key 弹窗。
    { // 方法开始。
        if (_current != null && _currentKey == key) // 如果当前弹窗就是同 key。
        { // if 开始。
            return true; // 返回已经存在。
        } // if 结束。

        for (int i = 0; i < _pending.Count; i++) // 遍历待显示队列。
        { // for 开始。
            if (_pending[i].Key == key) // 如果找到同 key 请求。
            { // if 开始。
                return true; // 返回已经存在。
            } // if 结束。
        } // for 结束。

        return false; // 没有找到同 key 弹窗。
    } // 方法结束。

    public void ClearQueue() // 清空弹窗队列。
    { // 方法开始。
        _pending.Clear(); // 清空待显示请求。
        if (_current != null) // 如果当前有弹窗。
        { // if 开始。
            Destroy(_current.gameObject); // 销毁当前弹窗。
            _current = null; // 清空当前弹窗引用。
            _currentKey = null; // 清空当前弹窗 key。
        } // if 结束。
    } // 方法结束。
} // 类结束。

面试一句话

弹窗队列 = 请求入队,当前为空才显示队首;弹窗关闭后清空 current,再显示下一个。项目里还要处理优先级、去重合并、异步加载失败、切场景清理和关闭回调丢失。

Unity 中如何做界面打开性能监控?

csharp-unity-ui-open-performance-monitor

标准答案

Unity 里做界面打开性能监控,我会把“一次 UI 打开”拆成多个阶段埋点,而不是只看总耗时。重点记录:

打开请求、资源加载、Prefab 实例化、组件绑定、数据刷新、Layout/Canvas 重建、首帧可见、GC Alloc、Item 数量、是否命中缓存。

底层思路

界面打开慢,通常不是一个点慢,而是多个阶段叠加:资源没加载、实例化太重、列表 Item 太多、LayoutGroup 触发重建、Canvas.BuildBatch 高、打开时产生 GC。所以监控要能回答三个问题:慢在哪、慢多少、哪些机型慢。

代码示例

c
using System.Collections.Generic; // 引入字典,用来保存每个阶段的耗时。
using System.Diagnostics; // 引入 Stopwatch,用来统计真实经过时间。
using UnityEngine; // 引入 UnityEngine,用来输出日志。
using UnityEngine.Profiling; // 引入 Unity Profiler,用来读取内存信息。

public sealed class UiOpenPerfTrace // 定义一次 UI 打开性能追踪对象。
{ // 类开始。
    private readonly Stopwatch _watch = new Stopwatch(); // 创建计时器,统计从点击到打开完成的总耗时。
    private readonly Dictionary<string, long> _marks = new Dictionary<string, long>(8); // 保存每个阶段完成时的毫秒数。
    private readonly string _windowName; // 保存当前打开的窗口名。
    private readonly long _beginMonoBytes; // 保存打开前的托管内存大小。

    public UiOpenPerfTrace(string windowName) // 构造函数,传入窗口名。
    { // 构造函数开始。
        _windowName = windowName; // 记录窗口名,方便线上按窗口聚合。
        _beginMonoBytes = Profiler.GetMonoUsedSizeLong(); // 记录打开前托管堆大小。
        _watch.Start(); // 开始计时。
        Mark("Begin"); // 记录开始阶段。
    } // 构造函数结束。

    public void Mark(string stage) // 记录某个阶段完成。
    { // 方法开始。
        _marks[stage] = _watch.ElapsedMilliseconds; // 保存当前阶段距离开始的耗时。
    } // 方法结束。

    public void End(int itemCount, bool cacheHit) // 结束本次 UI 打开监控。
    { // 方法开始。
        _watch.Stop(); // 停止计时器。
        long totalMs = _watch.ElapsedMilliseconds; // 读取总耗时。
        long monoDelta = Profiler.GetMonoUsedSizeLong() - _beginMonoBytes; // 计算托管内存变化。
        if (totalMs > 200 || monoDelta > 20 * 1024) // 超过阈值才输出慢打开日志。
        { // 条件开始。
            Debug.Log($"UIOpen window={_windowName}, totalMs={totalMs}, monoDelta={monoDelta}, itemCount={itemCount}, cacheHit={cacheHit}"); // 输出结构化日志。
        } // 条件结束。
    } // 方法结束。
} // 类结束。

Unity 工程实践

实际项目里我会配合 Profiler.BeginSample/EndSampleProfilerMarker 包住关键阶段,比如 LoadPrefabInstantiateBindDataRefreshListCanvasRebuild。线上日志只上报慢样本,避免监控系统自己造成 GC 或 IO 卡顿。

面试一句话

我不会只说“用 Profiler 看一下”,而是会把 UI 打开拆成阶段埋点,结合真机结构化日志,定位是资源加载慢、实例化慢、列表刷新慢,还是 Canvas 重建导致卡顿。

Unity 中如何做全局 Loading 管理?

csharp-unity-global-loading-manager

标准答案

全局 Loading 管理的核心,不是写一个 ShowLoading()HideLoading(),而是做成一个全局异步状态管理器。场景加载、资源加载、网络请求都向它申请一个 LoadingHandle,任务结束时归还句柄;只要还有未完成任务,Loading 就不能被隐藏。

底层原理

最常见的问题是:A 模块打开 Loading,B 模块也打开 Loading,A 先结束后调用 Hide,结果 B 还没加载完,Loading 却消失了。所以不能用简单 bool,而要用“句柄 / 引用计数 / 活跃任务集合”。

还要统一处理:进度聚合、输入遮罩、延迟显示、最小展示时长、超时重试、切场景不销毁、取消后的回调保护。

代码示例

c
using System; // 引入 IDisposable,用于句柄自动归还。
using System.Collections.Generic; // 引入 Dictionary,用于保存活跃 Loading 请求。
using UnityEngine; // 引入 UnityEngine,用于 MonoBehaviour 和序列化字段。
using UnityEngine.UI; // 引入 UI,用于进度条 Slider。

public sealed class LoadingManager : MonoBehaviour // 定义全局 Loading 管理器。
{ // 类开始。
    [SerializeField] private CanvasGroup _view; // 引用 Loading 根节点,用来控制显示和输入遮罩。
    [SerializeField] private Slider _progressBar; // 引用进度条,用来显示聚合进度。
    private readonly Dictionary<int, float> _progressMap = new Dictionary<int, float>(); // 保存每个请求的进度。
    private int _nextId = 1; // 生成唯一请求 ID。

    public LoadingHandle Show() // 外部申请显示 Loading。
    { // 方法开始。
        int id = _nextId++; // 分配一个新的请求 ID。
        _progressMap[id] = 0f; // 新请求默认进度为 0。
        RefreshView(); // 刷新 Loading 显示状态。
        return new LoadingHandle(this, id); // 返回句柄,调用方完成后必须 Dispose。
    } // 方法结束。

    public void Report(int id, float progress) // 外部上报某个请求的进度。
    { // 方法开始。
        if (_progressMap.ContainsKey(id) == false) return; // 如果请求已经结束,就忽略过期回调。
        _progressMap[id] = Mathf.Clamp01(progress); // 把进度限制在 0 到 1。
        RefreshView(); // 刷新进度条显示。
    } // 方法结束。

    internal void Release(int id) // 归还某个 Loading 请求。
    { // 方法开始。
        _progressMap.Remove(id); // Remove 本身是幂等的,重复归还也不会报错。
        RefreshView(); // 刷新显示状态。
    } // 方法结束。

    private void RefreshView() // 根据当前活跃请求刷新 UI。
    { // 方法开始。
        bool visible = _progressMap.Count > 0; // 只要还有请求没完成,就保持显示。
        _view.alpha = visible ? 1f : 0f; // 控制 Loading 透明度。
        _view.blocksRaycasts = visible; // 显示时阻挡点击,防止重复操作。
        _progressBar.value = CalculateProgress(); // 设置聚合后的进度值。
    } // 方法结束。

    private float CalculateProgress() // 计算所有请求的平均进度。
    { // 方法开始。
        if (_progressMap.Count == 0) return 1f; // 没有请求时认为进度完成。
        float sum = 0f; // 准备累计进度。
        foreach (float value in _progressMap.Values) sum += value; // 累加每个请求的进度。
        return sum / _progressMap.Count; // 返回平均进度。
    } // 方法结束。
} // 类结束。

public readonly struct LoadingHandle : IDisposable // 定义 Loading 句柄。
{ // 结构体开始。
    private readonly LoadingManager _manager; // 保存所属 LoadingManager。
    private readonly int _id; // 保存请求 ID。
    public int Id => _id; // 暴露 ID,方便外部上报进度。
    public LoadingHandle(LoadingManager manager, int id) { _manager = manager; _id = id; } // 创建句柄。
    public void Dispose() => _manager?.Release(_id); // 释放句柄时归还请求。
} // 结构体结束。

面试一句话

全局 Loading 不是一个单纯 UI 面板,而是异步流程的状态管理器:统一申请、句柄释放、进度聚合、输入遮罩、失败兜底。

Unity 中如何做网络断线重连 UI?

csharp-unity-network-reconnect-ui

标准答案

网络断线重连 UI 的核心是:UI 不负责真正重连,UI 只表现网络状态。网络层检测到心跳超时、Socket 断开、切后台恢复、Token 过期后,发出状态事件;UI 层根据状态显示“弱网提示、断线弹窗、重连中、同步数据、失败重试”。

重点不是弹窗本身,而是状态流程要完整:断线 -> 阻塞输入 -> 自动重连 -> 恢复登录态 -> 拉取服务端快照 -> 同步完成 -> 关闭 UI

底层原理

断线后不能只要 Socket 连上就立刻关闭 UI,因为游戏状态可能已经不同步。比如战斗中断线 5 秒,本地角色位置、怪物血量、技能 CD 都可能和服务端不一致,所以要等服务端快照同步完,再恢复玩家操作。

UI 层还要注意:网络回调可能不在 Unity 主线程,不能直接改 UI,需要派发回主线程。

代码示例

c
using System; // 引入 Action,用来接收重试按钮回调。
using UnityEngine; // 引入 UnityEngine,用来编写 MonoBehaviour。
using UnityEngine.UI; // 引入 Unity UI,用来控制按钮和文本。

public enum ReconnectUiState // 定义断线重连 UI 的状态。
{ // 枚举开始。
    Hidden, // 正常联网状态,不显示重连界面。
    WeakNetwork, // 弱网状态,只做轻提示,不阻塞操作。
    Reconnecting, // 正在自动重连,需要显示遮罩和次数。
    Resyncing, // 已连接成功,但正在同步服务端状态。
    Failed // 重连失败,需要显示手动重试或返回登录。
} // 枚举结束。

public sealed class ReconnectPanel : MonoBehaviour // 定义断线重连 UI 面板。
{ // 类开始。
    [SerializeField] private CanvasGroup _root; // 控制面板显示和点击遮罩。
    [SerializeField] private Text _messageText; // 显示当前重连提示文本。
    [SerializeField] private Button _retryButton; // 手动重试按钮。
    private Action _onRetry; // 保存外部传入的重试逻辑。
    private bool _retryLocked; // 防止玩家连续点击重试创建多个连接。

    public void SetRetryCallback(Action onRetry) // 设置重试按钮回调。
    { // 方法开始。
        _onRetry = onRetry; // 保存重试逻辑。
        _retryButton.onClick.RemoveAllListeners(); // 清理旧监听,避免重复绑定。
        _retryButton.onClick.AddListener(OnRetryClicked); // 绑定新的点击事件。
    } // 方法结束。

    public void SetState(ReconnectUiState state, int attempt, int maxAttempt) // 根据网络状态刷新 UI。
    { // 方法开始。
        bool visible = state != ReconnectUiState.Hidden; // 只要不是隐藏状态,就显示面板。
        _root.alpha = visible ? 1f : 0f; // 控制面板透明度。
        _root.blocksRaycasts = visible; // 显示时阻挡点击,防止重复操作。
        _retryButton.gameObject.SetActive(state == ReconnectUiState.Failed); // 只有失败时显示重试按钮。
        _retryLocked = state == ReconnectUiState.Reconnecting; // 自动重连中锁住手动重试。
        if (state == ReconnectUiState.WeakNetwork) _messageText.text = "当前网络不稳定"; // 弱网时只提示。
        if (state == ReconnectUiState.Reconnecting) _messageText.text = $"正在重连 {attempt}/{maxAttempt}"; // 重连中显示次数。
        if (state == ReconnectUiState.Resyncing) _messageText.text = "连接成功,正在同步数据"; // 同步中提示不要操作。
        if (state == ReconnectUiState.Failed) _messageText.text = "重连失败,请检查网络后重试"; // 失败时给明确提示。
    } // 方法结束。

    private void OnRetryClicked() // 玩家点击手动重试。
    { // 方法开始。
        if (_retryLocked) return; // 如果正在重连,就忽略重复点击。
        _retryLocked = true; // 立刻加锁,保证重试操作幂等。
        _onRetry?.Invoke(); // 调用网络层的重试逻辑。
    } // 方法结束。
} // 类结束。

面试一句话

断线重连 UI 不是简单弹窗,而是网络状态机的表现层:断线后阻塞输入,自动重连,恢复鉴权,拉服务端快照,同步完成后才关闭 UI。

Unity 中如何做配置表自动生成代码?

csharp-unity-config-codegen

标准答案

Unity 配置表自动生成代码,本质是把 Excel/CSV 的“表结构”变成 C# 的“强类型代码”。流程一般是:

策划表格 -> 读取 Schema -> 校验数据 -> 类型映射 -> 生成 Config 类和 Table 容器 -> 导出运行时数据 -> 构建前自动检查。

这样可以减少魔法字符串、手写解析和运行时反射,字段改名或类型错误能尽早暴露。

底层原理

配置表通常有字段名、字段类型、注释、默认值、客户端/服务端标记。导表工具先解析这些元信息,再生成:

HeroConfig:一行配置对应一个对象。

HeroConfigTable:用 Dictionary<int, HeroConfig> 按 ID 查询。

运行时数据:可以是 JSON、二进制、ScriptableObject 或自定义格式。

重点是:生成代码前必须先校验数据,比如重复 ID、引用不存在、枚举非法、字段为空、数值越界。

代码示例

c
using System.IO; // 引入文件 API,用来写出生成后的 C# 文件。
using System.Text; // 引入 StringBuilder,用来拼接代码文本。
using UnityEditor; // 引入 UnityEditor,用来添加菜单和刷新资源。
using UnityEngine; // 引入 UnityEngine,用来输出日志。

public static class ConfigCodeGenerator // 定义配置表代码生成工具类。
{ // 类开始。
    [MenuItem("Tools/Config/Generate Code")] // 在 Unity 菜单栏添加生成入口。
    public static void Generate() // 定义生成配置代码的方法。
    { // 方法开始。
        string className = "HeroConfig"; // 示例中要生成的配置类名。
        string outputPath = "Assets/Scripts/Generated/HeroConfig.cs"; // 生成代码的输出路径。
        StringBuilder sb = new StringBuilder(); // 创建字符串构建器,用来拼接 C# 源码。
        sb.AppendLine("public sealed partial class " + className); // 生成类声明,partial 方便手写扩展。
        sb.AppendLine("{"); // 生成类开始大括号。
        sb.AppendLine("    public int Id { get; private set; }"); // 生成 Id 字段属性。
        sb.AppendLine("    public string Name { get; private set; }"); // 生成 Name 字段属性。
        sb.AppendLine("    public int Hp { get; private set; }"); // 生成 Hp 字段属性。
        sb.AppendLine("    public float Speed { get; private set; }"); // 生成 Speed 字段属性。
        sb.AppendLine("}"); // 生成类结束大括号。
        Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); // 确保生成目录存在。
        File.WriteAllText(outputPath, sb.ToString(), Encoding.UTF8); // 把拼好的代码写入文件。
        AssetDatabase.Refresh(); // 刷新 Unity 资源数据库,让新代码进入编译。
        Debug.Log("Config code generated: " + outputPath); // 输出生成成功日志。
    } // 方法结束。
} // 类结束。

面试一句话

配置表自动生成代码不是简单复制数据,而是把配置规范变成可编译的代码契约:先校验,再生成,最后让运行时代码用强类型访问配置。

Unity 中如何做配置表字段校验?

csharp-unity-config-field-validation

标准答案

Unity 配置表字段校验,本质是把“策划表能不能用”提前挡在导表阶段,而不是等运行时空引用、解析失败、资源找不到才爆。校验一般分五层:

Schema 校验、单元格校验、行级规则、跨表引用、版本兼容。

比如 skillId 必须唯一,buffId 必须能在 Buff 表中查到,prefabPath 必须能找到资源,damage 必须是数字且在合法范围内。

底层原理

配置表字段不是孤立字符串,它最终会变成 C# 强类型字段、运行时字典、资源引用和业务逻辑条件。所以字段校验要在生成代码和导出数据之前执行。错误信息也不能只写“导表失败”,要精确到:

表名、行号、列名、原始值、错误原因、修复建议。

代码示例

c
using System.Collections.Generic; // 引入集合类型,用来保存行数据、引用表和错误信息。
using UnityEngine; // 引入 UnityEngine,用来输出错误日志。

public enum ConfigFieldType // 定义配置字段支持的类型。
{ // 枚举开始。
    Int, // 表示整数字段。
    String, // 表示字符串字段。
    RefId // 表示引用其他表 ID 的字段。
} // 枚举结束。

public sealed class FieldRule // 定义一个字段的校验规则。
{ // 类开始。
    public string Name; // 字段名,例如 skillId。
    public ConfigFieldType Type; // 字段类型,例如 Int 或 RefId。
    public bool Required; // 是否必填。
    public int Min; // 整数最小值。
    public int Max; // 整数最大值。
} // 类结束。

public static class ConfigFieldValidator // 定义配置表字段校验工具。
{ // 类开始。
    public static bool ValidateInt(Dictionary<string, string> row, FieldRule rule, int rowIndex, List<string> errors) // 校验整数字段。
    { // 方法开始。
        string value = row.TryGetValue(rule.Name, out string raw) ? raw : ""; // 读取字段原始文本。
        if (rule.Required && string.IsNullOrWhiteSpace(value)) // 判断必填字段是否为空。
        { // 判断开始。
            errors.Add($"第{rowIndex}行字段{rule.Name}不能为空"); // 记录空字段错误。
            return false; // 返回校验失败。
        } // 判断结束。
        if (int.TryParse(value, out int number) == false) // 判断字段能否解析成整数。
        { // 判断开始。
            errors.Add($"第{rowIndex}行字段{rule.Name}不是整数,值={value}"); // 记录类型错误。
            return false; // 返回校验失败。
        } // 判断结束。
        if (number < rule.Min || number > rule.Max) // 判断整数是否在合法范围内。
        { // 判断开始。
            errors.Add($"第{rowIndex}行字段{rule.Name}超出范围,值={number}"); // 记录范围错误。
            return false; // 返回校验失败。
        } // 判断结束。
        return true; // 返回校验成功。
    } // 方法结束。

    public static bool ValidateRefId(Dictionary<string, string> row, FieldRule rule, int rowIndex, HashSet<int> validIds, List<string> errors) // 校验跨表引用字段。
    { // 方法开始。
        if (ValidateInt(row, rule, rowIndex, errors) == false) return false; // 先复用整数校验。
        int id = int.Parse(row[rule.Name]); // 把字段值解析成引用 ID。
        if (validIds.Contains(id) == false) // 判断引用 ID 是否存在于目标表。
        { // 判断开始。
            errors.Add($"第{rowIndex}行字段{rule.Name}引用不存在,id={id}"); // 记录引用缺失错误。
            return false; // 返回校验失败。
        } // 判断结束。
        return true; // 返回校验成功。
    } // 方法结束。
} // 类结束。

Unity 工程实践

我会把字段校验放在导表工具、构建前流程和 CI 里。只要出现重复 ID、字段类型错误、引用不存在、资源路径缺失,就直接阻断导出和打包。这样能把配置错误提前暴露给策划和客户端,而不是进入游戏后才变成空引用或逻辑 bug。

面试一句话

配置表字段校验不是简单判空,而是用 Schema 和规则系统,把类型、范围、唯一性、跨表引用、资源路径和版本兼容都挡在导表阶段。

Unity 中如何实现技能配置驱动?

csharp-unity-skill-config-driven

标准答案

Unity 技能配置驱动,就是把技能的“目标规则、冷却、消耗、时间轴、伤害、Buff、特效、音效”都放到配置里,代码只写一套通用执行框架。

核心拆成三层:

SkillConfig:静态配置,描述这个技能是什么。 SkillInstance:运行时实例,记录释放者、目标、当前时间、是否打断。 SkillActionExecutor:动作执行器,根据配置节点执行动画、伤害、Buff、子弹、特效。

底层原理

配置只描述“做什么”,执行器负责“怎么做”。比如一个技能配置里写:

0ms 播放动画300ms 做伤害判定450ms 添加 Buff600ms 允许取消后摇

运行时技能系统按时间轴 Tick,时间到了就触发对应节点。这样新增普通技能时主要改配置,不用每个技能都写一个新脚本。

代码示例

c
using System.Collections.Generic; // 引入 List,用来保存技能动作节点。
using UnityEngine; // 引入 UnityEngine,用来使用 GameObject 和 Debug。

public enum SkillActionType // 定义技能动作类型。
{ // 枚举开始。
    PlayAnimation, // 播放动画动作。
    ApplyDamage, // 造成伤害动作。
    AddBuff // 添加 Buff 动作。
} // 枚举结束。

public sealed class SkillActionConfig // 定义一个时间轴动作配置。
{ // 类开始。
    public int TimeMs; // 动作触发时间,单位是毫秒。
    public SkillActionType Type; // 动作类型,例如伤害或 Buff。
    public int Value; // 动作参数,例如伤害值或 BuffId。
} // 类结束。

public sealed class SkillConfig // 定义技能静态配置。
{ // 类开始。
    public int SkillId; // 技能 ID。
    public int CooldownMs; // 技能冷却时间。
    public int CostMp; // 技能消耗蓝量。
    public List<SkillActionConfig> Actions = new List<SkillActionConfig>(); // 技能时间轴动作列表。
} // 类结束。

public sealed class SkillInstance // 定义技能运行时实例。
{ // 类开始。
    public GameObject Caster; // 记录技能释放者。
    public GameObject Target; // 记录技能目标。
    public int CurrentTimeMs; // 记录技能已经运行的时间。
    public int NextActionIndex; // 记录下一个要执行的动作下标。
} // 类结束。

public sealed class SkillRunner // 定义通用技能执行器。
{ // 类开始。
    public SkillInstance Cast(SkillConfig config, GameObject caster, GameObject target) // 创建并释放一个技能。
    { // 方法开始。
        SkillInstance instance = new SkillInstance(); // 创建技能运行时实例。
        instance.Caster = caster; // 设置释放者。
        instance.Target = target; // 设置目标。
        instance.CurrentTimeMs = 0; // 初始化技能时间。
        instance.NextActionIndex = 0; // 初始化动作下标。
        Debug.Log($"释放技能:{config.SkillId}"); // 输出释放技能日志。
        return instance; // 返回技能实例。
    } // 方法结束。

    public void Tick(SkillConfig config, SkillInstance instance, int deltaMs) // 每帧推进技能时间轴。
    { // 方法开始。
        instance.CurrentTimeMs += deltaMs; // 累加技能运行时间。
        while (instance.NextActionIndex < config.Actions.Count) // 循环检查是否还有动作未执行。
        { // 循环开始。
            SkillActionConfig action = config.Actions[instance.NextActionIndex]; // 取出当前动作配置。
            if (instance.CurrentTimeMs < action.TimeMs) break; // 如果时间还没到,就停止执行。
            ExecuteAction(instance, action); // 执行动作节点。
            instance.NextActionIndex++; // 移动到下一个动作节点。
        } // 循环结束。
    } // 方法结束。

    private void ExecuteAction(SkillInstance instance, SkillActionConfig action) // 执行一个技能动作。
    { // 方法开始。
        if (action.Type == SkillActionType.PlayAnimation) Debug.Log("播放攻击动画"); // 播放动画节点。
        if (action.Type == SkillActionType.ApplyDamage) Debug.Log($"造成伤害:{action.Value}"); // 伤害节点。
        if (action.Type == SkillActionType.AddBuff) Debug.Log($"添加Buff:{action.Value}"); // Buff 节点。
    } // 方法结束。
} // 类结束。

面试一句话

技能配置驱动的核心是:SkillConfig 描述技能,SkillInstance 保存运行状态,Timeline 控制触发时机,ActionExecutor 解释配置并执行具体行为。

Unity 中如何实现 Buff 优先级?

csharp-unity-buff-priority

标准答案

Buff 优先级不是单纯给 Buff 一个 priority 排序,而是解决四类冲突:

同组 Buff 怎么覆盖或共存。 属性加成按什么顺序计算。 多个 Buff 监听同一事件时谁先执行。 UI 图标、控制状态、特效谁显示在前面。

比如“30% 减速”和“50% 减速”同属于 SlowGroup,通常只保留高优先级的 50% 减速;但“攻击力 +100”和“攻击力 +20%”属于不同计算阶段,可以同时生效。

底层原理

我一般会把 Buff 配置里拆出 GroupKeyPriorityStackPolicy。添加 Buff 时先查同组 Buff:

新 Buff 优先级更高:移除旧 Buff,添加新 Buff。 新 Buff 优先级更低:通常忽略,或者进入等待队列。 优先级相同:按叠层策略决定刷新时间、叠层、拒绝或替换。

事件执行时还要稳定排序:priority 相同再按 buffId 或创建序号排序,不能依赖 Dictionary 遍历顺序。

代码示例

c
using System.Collections.Generic; // 引入集合类型,用来保存 Buff 列表。
using UnityEngine; // 引入 UnityEngine,用来输出调试日志。

public enum BuffStackPolicy // 定义同优先级 Buff 的叠加策略。
{ // 枚举开始。
    Replace, // 直接替换旧 Buff。
    RefreshTime, // 刷新持续时间。
    Stack // 增加层数。
} // 枚举结束。

public sealed class BuffConfig // 定义 Buff 静态配置。
{ // 类开始。
    public int BuffId; // Buff 唯一 ID。
    public string GroupKey; // Buff 互斥组,例如 SlowGroup。
    public int Priority; // Buff 优先级,数值越大越强。
    public int MaxStack; // Buff 最大叠层数。
    public BuffStackPolicy StackPolicy; // 同优先级时的处理策略。
} // 类结束。

public sealed class BuffInstance // 定义 Buff 运行时实例。
{ // 类开始。
    public BuffConfig Config; // 保存 Buff 配置。
    public int Stack; // 当前 Buff 层数。
    public int Sequence; // 创建序号,用来保证排序稳定。
} // 类结束。

public sealed class BuffComponent // 定义角色身上的 Buff 管理组件。
{ // 类开始。
    private readonly List<BuffInstance> _buffs = new List<BuffInstance>(); // 保存当前角色身上的 Buff。
    private int _nextSequence = 1; // 生成 Buff 创建序号。

    public void AddBuff(BuffConfig config) // 添加一个 Buff。
    { // 方法开始。
        BuffInstance oldBuff = FindSameGroup(config.GroupKey); // 查找同组旧 Buff。
        if (oldBuff != null && oldBuff.Config.Priority > config.Priority) return; // 旧 Buff 更强时忽略新 Buff。
        if (oldBuff != null && oldBuff.Config.Priority < config.Priority) _buffs.Remove(oldBuff); // 新 Buff 更强时移除旧 Buff。
        if (oldBuff != null && oldBuff.Config.Priority == config.Priority) // 优先级相同时进入叠加策略。
        { // 判断开始。
            ApplyStackPolicy(oldBuff, config); // 根据配置处理刷新或叠层。
            SortBuffs(); // 重新排序,保证事件执行顺序稳定。
            return; // 同优先级处理完后直接返回。
        } // 判断结束。
        BuffInstance newBuff = new BuffInstance(); // 创建新的 Buff 实例。
        newBuff.Config = config; // 绑定 Buff 配置。
        newBuff.Stack = 1; // 新 Buff 默认一层。
        newBuff.Sequence = _nextSequence++; // 记录创建顺序。
        _buffs.Add(newBuff); // 添加到 Buff 列表。
        SortBuffs(); // 添加后重新排序。
        Debug.Log($"添加Buff:{config.BuffId}"); // 输出添加日志。
    } // 方法结束。

    private BuffInstance FindSameGroup(string groupKey) // 查找同组 Buff。
    { // 方法开始。
        return _buffs.Find(buff => buff.Config.GroupKey == groupKey); // 根据 GroupKey 查找第一个同组 Buff。
    } // 方法结束。

    private void ApplyStackPolicy(BuffInstance buff, BuffConfig config) // 处理同优先级 Buff。
    { // 方法开始。
        if (config.StackPolicy == BuffStackPolicy.Replace) buff.Config = config; // 替换策略会换成新配置。
        if (config.StackPolicy == BuffStackPolicy.RefreshTime) Debug.Log("刷新持续时间"); // 刷新时间策略会重置剩余时间。
        if (config.StackPolicy == BuffStackPolicy.Stack) buff.Stack = Mathf.Min(buff.Stack + 1, config.MaxStack); // 叠层策略会增加层数并限制上限。
    } // 方法结束。

    private void SortBuffs() // 对 Buff 进行稳定排序。
    { // 方法开始。
        _buffs.Sort((a, b) => b.Config.Priority != a.Config.Priority ? b.Config.Priority.CompareTo(a.Config.Priority) : a.Sequence.CompareTo(b.Sequence)); // 先按优先级降序,再按创建顺序升序。
    } // 方法结束。
} // 类结束。

面试一句话

Buff 优先级要分开看:覆盖优先级解决谁替换谁,计算优先级解决数值顺序,事件优先级解决谁先执行,显示优先级解决 UI 和特效谁在前面。

Unity 中如何实现 Buff 叠层?

csharp-unity-buff-stacking

标准答案

Buff 叠层不是简单 stack++,而是要把规则配置清楚:

叠几层:MaxStack 控制最大层数。 怎么叠:重复添加时是刷新时间、增加层数、替换,还是拒绝。 时间怎么算:新增一层是否重置持续时间,是否每层独立倒计时。 效果怎么算:属性、DOT、护盾、控制类 Buff 的叠层公式不一样。 表现怎么刷:UI 图标层数、剩余时间、特效强度要同步更新。

底层原理

运行时一般把同一个 Buff 合并成一个 BuffInstance,里面保存 StackRemainTimeTickTimer。再次添加相同 Buff 时,先查已有实例,再按 StackPolicy 更新层数和时间。

常见策略:

RefreshOnly:不加层数,只刷新时间。 StackAndRefresh:层数 +1,并刷新持续时间。 StackNoRefresh:层数 +1,但不刷新时间。 Independent:每层独立计时,逻辑更精确,但更复杂。

代码示例

c
using UnityEngine; // 引入 UnityEngine,用来使用 Mathf 和 Debug。

public enum BuffStackPolicy // 定义 Buff 叠层策略。
{ // 枚举开始。
    RefreshOnly, // 只刷新持续时间,不增加层数。
    StackAndRefresh, // 增加层数,并刷新持续时间。
    StackNoRefresh // 增加层数,但不刷新持续时间。
} // 枚举结束。

public sealed class BuffConfig // 定义 Buff 静态配置。
{ // 类开始。
    public int BuffId; // Buff 唯一 ID。
    public int MaxStack; // Buff 最大叠层数。
    public int DurationMs; // Buff 持续时间,单位毫秒。
    public int TickIntervalMs; // Buff Tick 间隔,单位毫秒。
    public int ValuePerStack; // 每层 Buff 的数值效果。
    public BuffStackPolicy StackPolicy; // Buff 叠层策略。
} // 类结束。

public sealed class BuffInstance // 定义 Buff 运行时实例。
{ // 类开始。
    public BuffConfig Config; // 当前 Buff 对应的配置。
    public int Stack; // 当前 Buff 层数。
    public int RemainMs; // 当前 Buff 剩余时间。
    public int TickTimerMs; // 当前 Buff Tick 计时器。
} // 类结束。

public sealed class BuffStackSystem // 定义 Buff 叠层处理系统。
{ // 类开始。
    public void AddOrStack(BuffInstance instance, BuffConfig config) // 添加或叠加 Buff。
    { // 方法开始。
        if (instance.Config == null) // 如果当前没有这个 Buff 实例。
        { // 判断开始。
            instance.Config = config; // 绑定 Buff 配置。
            instance.Stack = 1; // 初始层数为 1。
            instance.RemainMs = config.DurationMs; // 初始化剩余时间。
            instance.TickTimerMs = 0; // 初始化 Tick 计时器。
            return; // 新 Buff 初始化完成后返回。
        } // 判断结束。
        if (config.StackPolicy == BuffStackPolicy.RefreshOnly) // 如果策略是只刷新时间。
        { // 判断开始。
            instance.RemainMs = config.DurationMs; // 重置剩余时间。
            return; // 刷新完成后返回。
        } // 判断结束。
        if (config.StackPolicy == BuffStackPolicy.StackAndRefresh) // 如果策略是叠层并刷新时间。
        { // 判断开始。
            instance.Stack = Mathf.Min(instance.Stack + 1, config.MaxStack); // 增加层数但不超过上限。
            instance.RemainMs = config.DurationMs; // 重置剩余时间。
            return; // 叠层完成后返回。
        } // 判断结束。
        if (config.StackPolicy == BuffStackPolicy.StackNoRefresh) // 如果策略是叠层但不刷新时间。
        { // 判断开始。
            instance.Stack = Mathf.Min(instance.Stack + 1, config.MaxStack); // 增加层数但不超过上限。
            return; // 叠层完成后返回。
        } // 判断结束。
    } // 方法结束。

    public void Tick(BuffInstance instance, int deltaMs) // 推进 Buff 时间。
    { // 方法开始。
        instance.RemainMs -= deltaMs; // 扣除 Buff 剩余时间。
        instance.TickTimerMs += deltaMs; // 累加 Tick 计时器。
        if (instance.TickTimerMs >= instance.Config.TickIntervalMs) // 判断是否到达 Tick 间隔。
        { // 判断开始。
            instance.TickTimerMs = 0; // 重置 Tick 计时器。
            int damage = instance.Config.ValuePerStack * instance.Stack; // 根据层数计算本次伤害。
            Debug.Log($"Buff Tick 伤害:{damage},当前层数:{instance.Stack}"); // 输出 Tick 结果。
        } // 判断结束。
    } // 方法结束。
} // 类结束。

面试一句话

Buff 叠层要同时管理层数、上限、持续时间、Tick 结算、属性重算和 UI 表现;真正难点不是加层,而是规则一致、可配置、可同步。

Unity 中如何实现 Buff 互斥?

csharp-unity-buff-mutual-exclusion

标准答案

Buff 互斥就是:两个 Buff 不能同时存在时,由统一规则决定“新 Buff 被拒绝、替换旧 Buff、清除旧 Buff,还是触发特殊效果”。

常见做法是配置里加:

GroupKey:互斥组,比如减速组、护盾组。 Tags:Buff 标签,比如控制、减速、燃烧。 Priority:冲突时谁更强。 MutexPolicy:冲突后的处理策略。

底层原理

添加 Buff 时不要直接 Add,而是先走互斥判断:

先看角色身上有没有免疫标签。 再看有没有同组 Buff。 再看标签之间是否冲突。 最后比较优先级并执行策略。

互斥影响战斗结果,所以正式项目里通常服务端做最终判定,客户端只做表现预测。

代码示例

c
using System.Collections.Generic; // 引入 List,用来保存角色身上的 Buff。
using UnityEngine; // 引入 UnityEngine,用来输出调试日志。

public enum BuffMutexPolicy // 定义 Buff 冲突后的处理策略。
{ // 枚举开始。
    RejectNew, // 拒绝新 Buff。
    ReplaceOld // 移除旧 Buff,添加新 Buff。
} // 枚举结束。

public sealed class BuffConfig // 定义 Buff 静态配置。
{ // 类开始。
    public int BuffId; // Buff 唯一 ID。
    public string GroupKey; // Buff 互斥组,例如 SlowGroup。
    public int Priority; // Buff 优先级,数值越大越强。
    public string[] Tags; // Buff 自身标签,例如 Control。
    public string[] ImmuneTags; // 该 Buff 提供的免疫标签。
    public BuffMutexPolicy Policy; // 发生冲突时使用的策略。
} // 类结束。

public sealed class BuffInstance // 定义 Buff 运行时实例。
{ // 类开始。
    public BuffConfig Config; // 保存当前 Buff 的配置。
} // 类结束。

public sealed class BuffComponent // 定义角色身上的 Buff 管理组件。
{ // 类开始。
    private readonly List<BuffInstance> _buffs = new List<BuffInstance>(); // 保存当前角色身上的 Buff。

    public bool TryAddBuff(BuffConfig config) // 尝试添加一个 Buff。
    { // 方法开始。
        if (HasImmune(config.Tags)) return false; // 如果已有 Buff 免疫新 Buff 标签,则拒绝添加。
        BuffInstance conflict = FindSameGroup(config.GroupKey); // 查找同互斥组 Buff。
        if (conflict == null) return AddNew(config); // 没有冲突时直接添加。
        if (conflict.Config.Priority > config.Priority) return false; // 旧 Buff 更强时拒绝新 Buff。
        if (config.Policy == BuffMutexPolicy.ReplaceOld) _buffs.Remove(conflict); // 替换策略下移除旧 Buff。
        return AddNew(config); // 添加新 Buff。
    } // 方法结束。

    private bool HasImmune(string[] tags) // 判断是否被免疫。
    { // 方法开始。
        foreach (BuffInstance buff in _buffs) // 遍历当前已有 Buff。
        { // 循环开始。
            foreach (string immuneTag in buff.Config.ImmuneTags) // 遍历已有 Buff 提供的免疫标签。
            { // 循环开始。
                foreach (string tag in tags) // 遍历新 Buff 的标签。
                { // 循环开始。
                    if (immuneTag == tag) return true; // 标签命中免疫时返回 true。
                } // 循环结束。
            } // 循环结束。
        } // 循环结束。
        return false; // 没有命中免疫则返回 false。
    } // 方法结束。

    private BuffInstance FindSameGroup(string groupKey) // 查找同组 Buff。
    { // 方法开始。
        return _buffs.Find(buff => buff.Config.GroupKey == groupKey); // 根据互斥组查找冲突 Buff。
    } // 方法结束。

    private bool AddNew(BuffConfig config) // 添加新的 Buff 实例。
    { // 方法开始。
        _buffs.Add(new BuffInstance { Config = config }); // 创建实例并加入列表。
        Debug.Log($"添加 Buff:{config.BuffId}"); // 输出添加成功日志。
        return true; // 返回添加成功。
    } // 方法结束。
} // 类结束。

面试一句话

Buff 互斥不要写散在技能脚本里,而要用互斥组、标签、免疫、优先级和策略做成统一规则,最终结果保持服务端一致。

Unity 中如何实现技能打断?

csharp-unity-skill-interrupt

标准答案

Unity 技能打断的核心是:不要只停动画,而要先判断规则,再清理技能副作用,最后切换角色状态

一般要配置这些规则:

当前技能阶段是否允许打断。 打断来源是什么,比如受击、闪避、死亡、眩晕、击飞。 打断优先级是否足够。 角色是否有霸体、无敌帧、不可打断窗口。 打断后是否返还 CD、是否关闭碰撞盒、是否停止特效和子弹。

底层原理

技能通常分为前摇、命中帧、后摇。前摇一般可以被受击或闪避打断;命中帧如果已经结算伤害,不能随便回滚;后摇通常允许取消接闪避或连招。

真正的打断流程应该是:

收到打断请求 -> 判断当前阶段 -> 判断霸体/免疫 -> 比较优先级 -> 停止时间轴 -> 关闭碰撞盒 -> 停止特效/Root Motion -> 切到受击/闪避/待机状态。

代码示例

c
using UnityEngine; // 引入 UnityEngine,用来输出日志。

public enum SkillPhase // 定义技能阶段。
{ // 枚举开始。
    Startup, // 前摇阶段。
    Hit, // 命中阶段。
    Recovery // 后摇阶段。
} // 枚举结束。

public enum InterruptType // 定义打断来源。
{ // 枚举开始。
    Dodge, // 闪避打断。
    HitStun, // 受击硬直打断。
    Stun, // 眩晕控制打断。
    Death // 死亡强制打断。
} // 枚举结束。

public sealed class SkillConfig // 定义技能配置。
{ // 类开始。
    public bool CanInterruptStartup; // 前摇阶段是否允许被打断。
    public bool CanInterruptHit; // 命中阶段是否允许被打断。
    public bool CanInterruptRecovery; // 后摇阶段是否允许被打断。
    public int InterruptPriority; // 当前技能抗打断优先级。
    public bool RefundCooldownWhenInterrupted; // 被打断时是否返还冷却。
} // 类结束。

public sealed class SkillInstance // 定义技能运行时实例。
{ // 类开始。
    public SkillConfig Config; // 当前技能配置。
    public SkillPhase Phase; // 当前技能阶段。
    public bool HasSuperArmor; // 当前是否有霸体。
    public bool IsRunning; // 当前技能是否正在运行。
} // 类结束。

public sealed class SkillInterruptSystem // 定义技能打断系统。
{ // 类开始。
    public bool TryInterrupt(SkillInstance skill, InterruptType type, int interruptPower) // 尝试打断当前技能。
    { // 方法开始。
        if (skill == null || skill.IsRunning == false) return false; // 没有技能运行时不能打断。
        if (skill.HasSuperArmor && type != InterruptType.Death) return false; // 有霸体时只有死亡能强制打断。
        if (CanInterruptByPhase(skill) == false) return false; // 当前阶段不允许打断时直接失败。
        if (interruptPower < skill.Config.InterruptPriority) return false; // 打断强度不够时直接失败。
        CleanupSkill(skill); // 清理技能产生的副作用。
        skill.IsRunning = false; // 标记技能已经停止。
        Debug.Log($"技能被{type}打断"); // 输出打断日志。
        return true; // 返回打断成功。
    } // 方法结束。

    private bool CanInterruptByPhase(SkillInstance skill) // 判断当前阶段是否允许打断。
    { // 方法开始。
        if (skill.Phase == SkillPhase.Startup) return skill.Config.CanInterruptStartup; // 前摇阶段读取前摇规则。
        if (skill.Phase == SkillPhase.Hit) return skill.Config.CanInterruptHit; // 命中阶段读取命中规则。
        if (skill.Phase == SkillPhase.Recovery) return skill.Config.CanInterruptRecovery; // 后摇阶段读取后摇规则。
        return false; // 未知阶段默认不允许打断。
    } // 方法结束。

    private void CleanupSkill(SkillInstance skill) // 清理技能副作用。
    { // 方法开始。
        Debug.Log("停止动画时间轴"); // 停止技能时间轴。
        Debug.Log("关闭攻击碰撞盒"); // 关闭已经打开的攻击碰撞盒。
        Debug.Log("停止特效和 Root Motion"); // 停止表现和位移。
        Debug.Log(skill.Config.RefundCooldownWhenInterrupted ? "返还部分CD" : "不返还CD"); // 根据配置处理冷却返还。
    } // 方法结束。
} // 类结束。

面试一句话

技能打断要先判断阶段窗口和打断优先级,再处理霸体免疫,打断成功后必须统一取消时间轴并清理碰撞盒、特效、Root Motion、子弹和状态。

Unity 中如何实现输入缓冲?

csharp-unity-input-buffer

标准答案

Unity 输入缓冲,就是玩家提前按下某个操作时,先把这个输入短暂保存起来;等角色进入“可以执行”的窗口时,再自动消费这个输入。

它常用于动作游戏里的连招、翻滚、跳跃、技能释放。比如普攻后摇还没结束,玩家提前按了下一次攻击,如果没有输入缓冲,这次输入会丢;有输入缓冲,就能在连招窗口打开时自动接下一段。

底层原理

输入缓冲一般由三部分组成:

输入队列:保存输入类型和时间戳。 过期清理:超过 100 到 300 毫秒的输入自动删除。 消费窗口:状态机允许时才取出并执行输入。

核心不是“按下就执行”,而是“按下先记录,状态允许时消费,消费后立刻清理”。

代码示例

c
using System.Collections.Generic; // 引入 Queue,用来保存输入缓冲队列。
using UnityEngine; // 引入 UnityEngine,用来读取时间和按键。

public enum PlayerInputType // 定义玩家输入类型。
{ // 枚举开始。
    Attack, // 攻击输入。
    Dodge, // 闪避输入。
    Skill // 技能输入。
} // 枚举结束。

public struct BufferedInput // 定义一个被缓冲的输入。
{ // 结构体开始。
    public PlayerInputType Type; // 记录输入类型。
    public float Time; // 记录输入发生的时间。
} // 结构体结束。

public sealed class InputBuffer // 定义输入缓冲器。
{ // 类开始。
    private readonly Queue<BufferedInput> _inputs = new Queue<BufferedInput>(); // 创建输入队列。
    private readonly float _bufferTime; // 输入最多保留的时间。

    public InputBuffer(float bufferTime) // 构造输入缓冲器。
    { // 构造开始。
        _bufferTime = bufferTime; // 保存缓冲时间。
    } // 构造结束。

    public void Push(PlayerInputType type) // 写入一个新的输入。
    { // 方法开始。
        BufferedInput input = new BufferedInput(); // 创建输入记录。
        input.Type = type; // 设置输入类型。
        input.Time = Time.time; // 设置输入发生时间。
        _inputs.Enqueue(input); // 把输入放入队列。
    } // 方法结束。

    public bool TryConsume(PlayerInputType type) // 尝试消费指定类型的输入。
    { // 方法开始。
        ClearExpired(); // 先清理过期输入。
        if (_inputs.Count == 0) return false; // 没有输入时返回 false。
        BufferedInput input = _inputs.Peek(); // 查看队首输入。
        if (input.Type != type) return false; // 类型不匹配时不消费。
        _inputs.Dequeue(); // 类型匹配时移除输入。
        return true; // 返回消费成功。
    } // 方法结束。

    public void Clear() // 清空所有输入。
    { // 方法开始。
        _inputs.Clear(); // 清空队列,常用于死亡、切场景、硬控开始。
    } // 方法结束。

    private void ClearExpired() // 清理过期输入。
    { // 方法开始。
        while (_inputs.Count > 0 && Time.time - _inputs.Peek().Time > _bufferTime) // 循环检查队首是否过期。
        { // 循环开始。
            _inputs.Dequeue(); // 移除过期输入。
        } // 循环结束。
    } // 方法结束。
} // 类结束。

public sealed class PlayerInputExample : MonoBehaviour // 定义一个输入缓冲使用示例。
{ // 类开始。
    private readonly InputBuffer _buffer = new InputBuffer(0.2f); // 创建 0.2 秒输入缓冲。
    private bool _canCombo; // 表示当前是否处于连招消费窗口。

    private void Update() // Unity 每帧更新。
    { // 方法开始。
        if (Input.GetKeyDown(KeyCode.J)) _buffer.Push(PlayerInputType.Attack); // 按下 J 时记录攻击输入。
        if (_canCombo && _buffer.TryConsume(PlayerInputType.Attack)) DoNextAttack(); // 连招窗口打开时消费攻击输入。
    } // 方法结束。

    private void DoNextAttack() // 执行下一段攻击。
    { // 方法开始。
        Debug.Log("触发下一段连招"); // 输出连招触发日志。
    } // 方法结束。
} // 类结束。

面试一句话

输入缓冲的核心是:输入先暂存,状态机窗口打开后再消费,消费成功后立刻清理;它解决的是动作游戏里提前按键丢输入的问题。

Unity 中如何实现战斗慢动作?

csharp-unity-battle-slow-motion

标准答案

Unity 战斗慢动作一般有三种:

Hit Stop:命中瞬间停住几帧,强化打击感。 Bullet Time:大招、闪避、击杀时全局慢放。 Local Slow:只让某个角色、Boss、动画局部变慢。

全局慢动作通常用 Time.timeScale,但恢复计时必须用 Time.unscaledDeltaTimeWaitForSecondsRealtime,否则 timeScale = 0 后恢复逻辑也会被暂停。

底层原理

Time.timeScale 会影响 deltaTime、Animator、普通协程等待、物理节奏等。 所以改 timeScale 时通常要同步调整 Time.fixedDeltaTime,恢复时再还原。 UI、Loading、恢复协程、真实倒计时要用不受缩放影响的时间。

代码示例

c
using System.Collections; // 引入 IEnumerator,用来写慢动作协程。
using UnityEngine; // 引入 UnityEngine,用来访问 Time 和 MonoBehaviour。

public sealed class BattleSlowMotion : MonoBehaviour // 定义战斗慢动作管理器。
{ // 类开始。
    [SerializeField] private float _restoreSpeed = 8f; // 配置慢动作恢复到正常速度的平滑速度。
    private float _baseFixedDeltaTime; // 保存原始物理固定步长。
    private int _version; // 保存请求版本号,避免旧协程覆盖新慢动作。

    private void Awake() // Unity 初始化时调用。
    { // 方法开始。
        _baseFixedDeltaTime = Time.fixedDeltaTime; // 记录默认 fixedDeltaTime,方便恢复。
    } // 方法结束。

    public void PlaySlowMotion(float scale, float duration) // 播放一次慢动作。
    { // 方法开始。
        _version++; // 每次请求递增版本号,让旧请求失效。
        StopAllCoroutines(); // 停止旧慢动作恢复协程。
        StartCoroutine(RunSlowMotion(_version, scale, duration)); // 启动新的慢动作协程。
    } // 方法结束。

    private IEnumerator RunSlowMotion(int version, float scale, float duration) // 慢动作执行协程。
    { // 方法开始。
        Time.timeScale = Mathf.Clamp(scale, 0f, 1f); // 设置游戏时间缩放。
        Time.fixedDeltaTime = _baseFixedDeltaTime * Time.timeScale; // 同步调整物理固定步长。
        float timer = 0f; // 使用真实时间记录慢动作持续时间。
        while (timer < duration) // 慢动作持续期间循环。
        { // 循环开始。
            if (version != _version) yield break; // 如果有新请求,就停止旧协程。
            timer += Time.unscaledDeltaTime; // 使用不受 timeScale 影响的时间计时。
            yield return null; // 等待下一帧继续。
        } // 循环结束。
        while (Time.timeScale < 0.999f) // 平滑恢复到正常速度。
        { // 循环开始。
            if (version != _version) yield break; // 如果有新请求,就停止旧恢复流程。
            Time.timeScale = Mathf.MoveTowards(Time.timeScale, 1f, _restoreSpeed * Time.unscaledDeltaTime); // 按真实时间恢复速度。
            Time.fixedDeltaTime = _baseFixedDeltaTime * Time.timeScale; // 恢复过程中同步物理步长。
            yield return null; // 等待下一帧继续恢复。
        } // 循环结束。
        Time.timeScale = 1f; // 确保最终恢复正常速度。
        Time.fixedDeltaTime = _baseFixedDeltaTime; // 确保物理固定步长恢复默认值。
    } // 方法结束。
} // 类结束。

面试一句话

战斗慢动作最好由统一 TimeManager 管理:用 timeScale 控制游戏时间,用 unscaledDeltaTime 控制恢复,用 fixedDeltaTime 同步物理,用镜头、音效、后处理增强表现。

Unity 中如何实现受击震屏?

csharp-unity-hit-camera-shake

标准答案

受击震屏本质是一个相机表现反馈:战斗系统在命中、暴击、破盾、击杀时发出震屏请求,相机系统根据强度、时长、方向和优先级临时偏移相机,结束后恢复原位。

实际项目里最好不要让每个技能直接改 Camera,而是统一走 CameraShakeManager。如果项目用了 Cinemachine,优先用 Cinemachine Impulse;如果不用 Cinemachine,就在相机跟随之后的 LateUpdate 里叠加一个临时偏移。

代码示例

c
using UnityEngine; // 引入 UnityEngine,用来访问 Transform、Random、Time 等 Unity API。

public sealed class HitCameraShake : MonoBehaviour // 定义受击震屏组件。
{ // 类开始。
    [SerializeField] private Transform _cameraRoot; // 引用要震动的相机节点。
    [SerializeField] private float _maxOffset = 0.25f; // 限制最大震动偏移,避免画面过晃。
    [SerializeField] private float _frequency = 45f; // 控制震动频率,数值越大抖动越快。
    private Vector3 _baseLocalPosition; // 记录相机原始局部位置,防止震屏后漂移。
    private float _timeLeft; // 当前震屏剩余时间。
    private float _duration; // 当前震屏总时长。
    private float _amplitude; // 当前震屏强度。
    private float _seed; // 随机噪声种子,让震动不完全重复。

    private void Awake() // Unity 初始化时调用。
    { // 方法开始。
        if (_cameraRoot == null) _cameraRoot = transform; // 没有手动绑定时默认震动自身。
        _baseLocalPosition = _cameraRoot.localPosition; // 记录初始局部位置。
        _seed = Random.value * 1000f; // 生成随机噪声种子。
    } // 方法结束。

    public void Shake(float amplitude, float duration) // 外部调用震屏。
    { // 方法开始。
        _amplitude = Mathf.Min(Mathf.Max(_amplitude, amplitude), _maxOffset); // 取更强的震动,并限制最大值。
        _duration = Mathf.Max(duration, 0.01f); // 保证持续时间不会为 0。
        _timeLeft = Mathf.Max(_timeLeft, _duration); // 如果已有震屏,就保留更长的剩余时间。
    } // 方法结束。

    private void LateUpdate() // 在相机跟随之后叠加震屏。
    { // 方法开始。
        if (_timeLeft <= 0f) // 判断震屏是否结束。
        { // 判断开始。
            _cameraRoot.localPosition = _baseLocalPosition; // 恢复原始局部位置。
            _amplitude = 0f; // 清空震屏强度。
            return; // 结束本帧逻辑。
        } // 判断结束。
        _timeLeft -= Time.unscaledDeltaTime; // 用真实时间衰减,慢动作或暂停时也能恢复。
        float fade = Mathf.Clamp01(_timeLeft / _duration); // 根据剩余时间计算衰减比例。
        float x = Mathf.PerlinNoise(_seed, Time.unscaledTime * _frequency) - 0.5f; // 生成横向噪声。
        float y = Mathf.PerlinNoise(_seed + 10f, Time.unscaledTime * _frequency) - 0.5f; // 生成纵向噪声。
        Vector3 offset = new Vector3(x, y, 0f) * (_amplitude * fade); // 根据强度和衰减计算偏移。
        _cameraRoot.localPosition = _baseLocalPosition + offset; // 把临时偏移叠加到基础位置上。
    } // 方法结束。
} // 类结束。

面试一句话

受击震屏是表现层反馈,不参与战斗判定;正确做法是命中事件发请求,CameraShakeManager 统一管理,在 LateUpdate 叠加临时偏移,结束后恢复原位。

Unity 中如何实现技能镜头特写?

csharp-unity-skill-camera-closeup

标准答案

技能镜头特写本质是战斗表现层:技能系统只发镜头请求,镜头系统负责切换、混合、持续和恢复

常见实现有三种:

Cinemachine Virtual Camera:提高特写虚拟相机 Priority,让 Cinemachine 自动混合。 Timeline Camera Track:适合大招、处决、剧情技能演出。 自定义相机插值:保存原位置和 FOV,用曲线插到特写点,再恢复。

底层原理

技能时间轴在某个节点触发 CameraCloseupRequest,里面包含目标、偏移、FOV、持续时间、混合时间、优先级。CameraDirector 收到请求后判断优先级,切换到特写镜头;技能结束、目标死亡、技能打断时,必须恢复到战斗相机。

代码示例

c
using System.Collections; // 引入 IEnumerator,用来写镜头恢复协程。
using UnityEngine; // 引入 UnityEngine,用来访问 Transform 和 MonoBehaviour。
using Cinemachine; // 引入 Cinemachine,用来控制虚拟相机。

public sealed class SkillCameraCloseup : MonoBehaviour // 定义技能镜头特写管理器。
{ // 类开始。
    [SerializeField] private CinemachineVirtualCamera _battleCamera; // 引用普通战斗虚拟相机。
    [SerializeField] private CinemachineVirtualCamera _closeupCamera; // 引用技能特写虚拟相机。
    [SerializeField] private int _normalPriority = 10; // 普通相机优先级。
    [SerializeField] private int _closeupPriority = 50; // 特写相机优先级。
    private int _version; // 请求版本号,用来防止旧协程恢复新镜头。

    public void PlayCloseup(Transform follow, Transform lookAt, float duration) // 播放技能镜头特写。
    { // 方法开始。
        _version++; // 每次新请求递增版本号。
        StopAllCoroutines(); // 停止旧的恢复协程。
        _closeupCamera.Follow = follow; // 设置特写相机跟随目标。
        _closeupCamera.LookAt = lookAt; // 设置特写相机看向目标。
        _battleCamera.Priority = _normalPriority; // 保持战斗相机为普通优先级。
        _closeupCamera.Priority = _closeupPriority; // 提高特写相机优先级,触发 Cinemachine 切换。
        StartCoroutine(RestoreAfter(_version, duration)); // 延迟恢复战斗相机。
    } // 方法结束。

    public void CancelCloseup() // 外部取消特写镜头。
    { // 方法开始。
        _version++; // 让旧协程失效。
        RestoreCamera(); // 立即恢复战斗相机。
    } // 方法结束。

    private IEnumerator RestoreAfter(int version, float duration) // 持续指定时间后恢复。
    { // 方法开始。
        float timer = 0f; // 创建真实时间计时器。
        while (timer < duration) // 在持续时间内循环等待。
        { // 循环开始。
            if (version != _version) yield break; // 如果有新请求,就停止旧协程。
            timer += Time.unscaledDeltaTime; // 使用不受慢动作影响的时间。
            yield return null; // 等待下一帧。
        } // 循环结束。
        RestoreCamera(); // 时间到后恢复战斗相机。
    } // 方法结束。

    private void RestoreCamera() // 恢复战斗相机。
    { // 方法开始。
        _closeupCamera.Priority = _normalPriority - 1; // 降低特写相机优先级。
        _battleCamera.Priority = _normalPriority; // 恢复战斗相机优先级。
    } // 方法结束。
} // 类结束。

面试一句话

技能镜头特写不要让技能脚本直接改主相机,而是由技能时间轴发请求,CameraDirector 统一切虚拟相机、处理优先级和混合,并在技能结束或打断时恢复战斗相机。

Unity 中如何实现敌人死亡溶解效果?

csharp-unity-enemy-death-dissolve

标准答案

敌人死亡溶解效果,本质是:死亡判定由战斗系统完成,溶解只是表现层效果

流程一般是:

血量归零 -> 触发死亡事件 -> 关闭 AI、碰撞、寻路、血条 -> 播死亡动画 -> 脚本驱动 Shader 溶解参数 -> 溶解结束 -> 回对象池或销毁。

Shader 里通常用一张噪声图做裁剪:噪声值小于溶解阈值的像素被 clip 掉,阈值附近加一圈边缘发光。

代码示例

c
using System.Collections; // 引入 IEnumerator,用来写溶解协程。
using UnityEngine; // 引入 UnityEngine,用来访问 Renderer、Time、MaterialPropertyBlock。

public sealed class EnemyDeathDissolve : MonoBehaviour // 定义敌人死亡溶解组件。
{ // 类开始。
    [SerializeField] private Renderer[] _renderers; // 保存敌人身上的所有渲染器。
    [SerializeField] private float _duration = 1.2f; // 配置溶解持续时间。
    private static readonly int DissolveAmountId = Shader.PropertyToID("_DissolveAmount"); // 缓存 Shader 溶解参数 ID。
    private readonly MaterialPropertyBlock _block = new MaterialPropertyBlock(); // 创建材质属性块,避免实例化材质。

    public void Play() // 播放死亡溶解效果。
    { // 方法开始。
        StopAllCoroutines(); // 停止旧协程,避免重复播放。
        StartCoroutine(RunDissolve()); // 启动溶解协程。
    } // 方法结束。

    private IEnumerator RunDissolve() // 执行溶解过程。
    { // 方法开始。
        float timer = 0f; // 创建计时器。
        while (timer < _duration) // 在持续时间内循环。
        { // 循环开始。
            timer += Time.deltaTime; // 累加时间。
            float amount = Mathf.Clamp01(timer / _duration); // 计算 0 到 1 的溶解进度。
            SetDissolveAmount(amount); // 把进度写入 Shader 参数。
            yield return null; // 等待下一帧。
        } // 循环结束。
        SetDissolveAmount(1f); // 确保最终完全溶解。
        gameObject.SetActive(false); // 溶解结束后隐藏对象,项目里通常回对象池。
    } // 方法结束。

    private void SetDissolveAmount(float amount) // 设置所有 Renderer 的溶解值。
    { // 方法开始。
        foreach (Renderer renderer in _renderers) // 遍历所有渲染器。
        { // 循环开始。
            renderer.GetPropertyBlock(_block); // 读取当前属性块。
            _block.SetFloat(DissolveAmountId, amount); // 设置溶解进度参数。
            renderer.SetPropertyBlock(_block); // 写回属性块。
        } // 循环结束。
    } // 方法结束。

    private void OnEnable() // 对象池复用时调用。
    { // 方法开始。
        SetDissolveAmount(0f); // 重置溶解值,避免复用时敌人一出生就是残缺的。
    } // 方法结束。
} // 类结束。

面试一句话

敌人死亡溶解是 Shader 表现:死亡事件关闭逻辑,脚本驱动 _DissolveAmount,Shader 用噪声阈值 clip 像素并加边缘光,结束后回收并重置参数。

Unity 中如何设计日志等级?

csharp-unity-log-level-design

一句话定义 Unity 日志等级就是给日志按严重程度分层,再按开发包、测试包、正式包设置过滤规则:该看的能看到,不该刷的别拖慢游戏。

常见等级设计

Debug / Trace:开发期细节日志,比如状态切换、资源路径、AI 决策,正式包一般关闭。 Info:关键流程日志,比如登录成功、进入战斗、切场景完成。 Warn:可恢复异常,比如配置缺默认值、资源降级加载。 Error:功能出错但游戏还能继续,比如资源加载失败、接口返回异常。 Fatal:严重错误,比如关键模块初始化失败、无法继续游戏,通常要立刻上报。

底层设计思路 日志等级本质是一个“阈值过滤器”。比如正式包设置最低等级为 Warn,那 DebugInfo 就不输出,减少字符串拼接、控制台输出、文件 IO 和 GC。真正线上定位时,日志还要带上下文:玩家 ID、设备、版本号、场景、模块名、堆栈、最近操作链路。

代码示例

c
using UnityEngine; // 引入 Unity 的日志输出 API。

public enum LogLevel // 定义日志等级枚举。
{ // 枚举开始。
    Debug = 0, // 开发调试日志。
    Info = 1, // 关键流程日志。
    Warn = 2, // 可恢复异常日志。
    Error = 3, // 功能错误日志。
    Fatal = 4 // 严重不可恢复日志。
} // 枚举结束。

public static class GameLog // 定义项目统一日志入口。
{ // 类开始。
    public static LogLevel MinLevel = LogLevel.Info; // 当前环境允许输出的最低日志等级。

    public static void Log(LogLevel level, string tag, string message) // 输出一条日志。
    { // 方法开始。
        if (level < MinLevel) return; // 低于阈值的日志直接忽略。

        string text = $"[{level}][{tag}] {message}"; // 组装结构化日志文本。

        if (level >= LogLevel.Error) Debug.LogError(text); // Error 和 Fatal 使用错误日志输出。
        else if (level == LogLevel.Warn) Debug.LogWarning(text); // Warn 使用警告日志输出。
        else Debug.Log(text); // Debug 和 Info 使用普通日志输出。

        if (level >= LogLevel.Error) Upload(text); // Error 以上可以采样上报到线上平台。
    } // 方法结束。

    private static void Upload(string text) // 模拟上传线上日志。
    { // 方法开始。
        Debug.Log("UploadLog: " + text); // 示例只打印,真实项目会写入上传队列。
    } // 方法结束。
} // 类结束。

面试加分说法 日志系统不能只封装 Debug.Log,还要考虑等级语义、环境阈值、结构化字段、采样限流、文件滚动、线上上报和性能开销。

Unity 中如何做本地日志滚动文件?

csharp-unity-local-log-rolling-files

标准答案 Unity 本地日志滚动文件,一般做成:统一日志入口收集日志,写入内存队列,定时批量落盘;当文件超过大小、跨天或重新启动时切新文件;启动时清理旧日志,只保留最近 N 个或 N 天。

底层原理 滚动日志解决两个问题:第一,避免一个日志文件无限变大;第二,避免频繁文件 IO 卡主线程。实际项目里我会把日志写到 Application.persistentDataPath/Logs,正式包只保留 Warn/Error/Fatal,崩溃后下次启动上传最近几份日志。

代码示例

c
using System; // 引入 DateTime 和 Exception 等基础类型。
using System.Collections.Generic; // 引入 Queue 和 List 容器。
using System.IO; // 引入文件和目录操作 API。
using System.Linq; // 引入日志文件排序和裁剪用的 LINQ。
using UnityEngine; // 引入 Unity 的日志回调和路径 API。
public static class RollingFileLogger // 定义本地滚动日志工具类。
{ // 类开始。
    private static readonly object LockObj = new object(); // 用锁保护多线程日志队列。
    private static readonly Queue<string> Pending = new Queue<string>(); // 等待写入文件的日志队列。
    private static readonly List<string> Batch = new List<string>(256); // 复用批量写入缓存,减少 GC。
    private const long MaxBytes = 5L * 1024L * 1024L; // 单个日志文件最大 5MB。
    private const int MaxFiles = 20; // 最多保留 20 个日志文件。
    private static string LogDir = ""; // 日志目录路径。
    private static string CurrentFile = ""; // 当前正在写入的日志文件。
    private static string CurrentDay = ""; // 当前日志文件所属日期。
    private static bool Inited = false; // 标记日志系统是否已经初始化。
    public static void Init() // 初始化日志系统。
    { // 方法开始。
        if (Inited) return; // 防止重复初始化和重复注册回调。
        LogDir = Path.Combine(Application.persistentDataPath, "Logs"); // 选择可读写的持久化目录。
        Directory.CreateDirectory(LogDir); // 如果日志目录不存在就创建。
        OpenNewFile(); // 启动时创建一个新的日志文件。
        CleanOldFiles(); // 启动时清理超过保留数量的旧日志。
        Application.logMessageReceivedThreaded += OnUnityLog; // 捕获 Unity 普通日志、警告和错误。
        Inited = true; // 标记初始化完成。
    } // 方法结束。
    private static void OnUnityLog(string condition, string stackTrace, LogType type) // Unity 日志回调。
    { // 方法开始。
        string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{type}] {condition}"; // 拼出一行结构化日志。
        if (type == LogType.Error || type == LogType.Exception || type == LogType.Assert) line += Environment.NewLine + stackTrace; // 错误日志附带堆栈。
        lock (LockObj) Pending.Enqueue(line); // 回调里只入队,不直接写磁盘。
    } // 方法结束。
    public static void Flush() // 把队列里的日志批量写入文件。
    { // 方法开始。
        if (!Inited) return; // 未初始化时不执行写入。
        Batch.Clear(); // 清空复用缓存。
        lock (LockObj) while (Pending.Count > 0) Batch.Add(Pending.Dequeue()); // 把队列数据搬到本地批次。
        if (Batch.Count == 0) return; // 没有日志就直接返回。
        try // 捕获文件 IO 异常,避免日志系统反过来拖垮游戏。
        { // try 开始。
            RotateIfNeeded(); // 写入前检查是否需要切新文件。
            File.AppendAllLines(CurrentFile, Batch); // 批量追加写入,减少频繁 IO。
        } // try 结束。
        catch (Exception) { } // 示例中吞掉异常,真实项目可写入备用通道。
        finally { Batch.Clear(); } // 无论成功失败都清理批次缓存。
    } // 方法结束。
    private static void RotateIfNeeded() // 检查是否需要滚动到新日志文件。
    { // 方法开始。
        string today = DateTime.Now.ToString("yyyyMMdd"); // 获取今天日期。
        bool dayChanged = CurrentDay != today; // 判断是否跨天。
        bool tooLarge = File.Exists(CurrentFile) && new FileInfo(CurrentFile).Length >= MaxBytes; // 判断是否超过大小。
        if (dayChanged || tooLarge) OpenNewFile(); // 跨天或超大时创建新文件。
    } // 方法结束。
    private static void OpenNewFile() // 创建新的日志文件路径。
    { // 方法开始。
        CurrentDay = DateTime.Now.ToString("yyyyMMdd"); // 记录当前日期。
        string name = $"log_{DateTime.Now:yyyyMMdd_HHmmss_fff}.txt"; // 用时间戳生成文件名。
        CurrentFile = Path.Combine(LogDir, name); // 拼接完整日志文件路径。
    } // 方法结束。
    private static void CleanOldFiles() // 清理超过保留数量的旧日志。
    { // 方法开始。
        string[] oldFiles = Directory.GetFiles(LogDir, "log_*.txt").OrderByDescending(File.GetLastWriteTimeUtc).Skip(MaxFiles).ToArray(); // 找出需要删除的旧文件。
        foreach (string file in oldFiles) File.Delete(file); // 删除超出数量限制的日志文件。
    } // 方法结束。
    public static void Shutdown() // 关闭日志系统。
    { // 方法开始。
        if (!Inited) return; // 未初始化时不用关闭。
        Application.logMessageReceivedThreaded -= OnUnityLog; // 取消 Unity 日志回调。
        Flush(); // 退出前尽量把剩余日志写完。
        Inited = false; // 标记日志系统已关闭。
    } // 方法结束。
} // 类结束。

Unity 工程实践Init() 放在游戏启动入口,Flush() 可以每帧调用,也可以每隔几秒调用一次;Shutdown() 放在退出或切换账号时。注意不要在日志回调里做重 IO,也不要把密码、Token、支付信息写进日志。

Unity 中如何上传崩溃日志?

csharp-unity-crash-log-upload

标准答案 Unity 上传崩溃日志一般分两层:一层接入崩溃 SDK,比如 Unity Cloud Diagnostics、Bugly、Firebase Crashlytics、Sentry,用来捕获 Native Crash;另一层自己做本地日志补传:平时写滚动日志和面包屑,启动时创建“运行中标记”,正常退出删除,如果下次启动发现标记还在,就认为上次异常退出,把最近日志、设备信息、版本号、场景、用户 ID 上传。

底层原理 真正崩溃时,尤其是 IL2CPP、渲染线程、驱动、系统层崩溃,C# 代码可能已经来不及执行,所以不能指望“崩溃瞬间发网络请求”。更可靠的做法是:崩溃前持续落盘,崩溃后下次启动补传。

代码示例

c
using System; // 引入 DateTime。
using System.Collections; // 引入 IEnumerator。
using System.IO; // 引入文件读写 API。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Networking; // 引入 UnityWebRequest。

public class CrashLogUploader : MonoBehaviour // 定义崩溃日志上传组件。
{ // 类开始。
    private const string UploadUrl = "https://example.com/upload_crash"; // 示例上传地址,真实项目换成后端接口。
    private string logDir; // 保存日志目录。
    private string markerFile; // 保存异常退出标记文件路径。

    private void Awake() // 游戏启动时初始化。
    { // 方法开始。
        logDir = Path.Combine(Application.persistentDataPath, "Logs"); // 日志放到可写目录。
        markerFile = Path.Combine(logDir, "last_session_running.flag"); // 用文件标记上次是否正常退出。
        Directory.CreateDirectory(logDir); // 确保日志目录存在。
        if (File.Exists(markerFile)) StartCoroutine(UploadLastCrashLog()); // 如果标记还在,说明上次可能异常退出。
        File.WriteAllText(markerFile, DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); // 当前会话启动后写入运行标记。
    } // 方法结束。

    private void OnApplicationQuit() // 应用正常退出时调用。
    { // 方法开始。
        if (File.Exists(markerFile)) File.Delete(markerFile); // 正常退出删除标记,避免误判为崩溃。
    } // 方法结束。

    private IEnumerator UploadLastCrashLog() // 上传上次异常退出的日志。
    { // 协程开始。
        yield return new WaitForSeconds(2f); // 等待网络和 SDK 初始化完成。
        string latestLog = FindLatestLog(); // 找到最近一份本地日志。
        if (string.IsNullOrEmpty(latestLog)) yield break; // 没有日志就结束。
        WWWForm form = new WWWForm(); // 创建表单数据。
        form.AddField("version", Application.version); // 上传游戏版本。
        form.AddField("device", SystemInfo.deviceModel); // 上传设备型号。
        form.AddField("system", SystemInfo.operatingSystem); // 上传系统版本。
        form.AddBinaryData("log", File.ReadAllBytes(latestLog), Path.GetFileName(latestLog), "text/plain"); // 上传日志文件。
        using (UnityWebRequest request = UnityWebRequest.Post(UploadUrl, form)) // 创建 POST 请求。
        { // using 开始。
            request.timeout = 10; // 设置超时时间,避免卡住启动流程。
            yield return request.SendWebRequest(); // 异步发送请求。
            if (request.result == UnityWebRequest.Result.Success) Debug.Log("Crash log uploaded"); // 上传成功时记录结果。
            else Debug.LogWarning("Crash log upload failed: " + request.error); // 上传失败时保留本地日志等待下次重试。
        } // using 结束。
    } // 协程结束。

    private string FindLatestLog() // 查找最近的日志文件。
    { // 方法开始。
        string[] files = Directory.GetFiles(logDir, "log_*.txt"); // 获取所有日志文件。
        if (files.Length == 0) return ""; // 没有日志时返回空字符串。
        Array.Sort(files, (a, b) => File.GetLastWriteTimeUtc(b).CompareTo(File.GetLastWriteTimeUtc(a))); // 按最后写入时间倒序排序。
        return files[0]; // 返回最新日志。
    } // 方法结束。
} // 类结束。

面试加分点 崩溃上传要带“业务上下文”,不能只传堆栈。比如最近进入哪个场景、点了哪个按钮、释放了哪个技能、加载了哪个资源。服务端还要按堆栈签名、版本、机型聚合,计算崩溃率,必要时告警或热更新回滚。

Unity 中如何做埋点缓存?

csharp-unity-analytics-cache

标准答案 Unity 埋点缓存一般做成“本地可靠队列”:业务调用统一 Track 接口,事件先进内存队列;达到数量、时间间隔、切后台、退出游戏时批量上传;如果网络失败,就写入本地文件,下次启动再补传。

底层原理 埋点不能每条都发请求,因为会造成网络请求过多、耗电、弱网失败、主线程卡顿。正确做法是批量、异步、可重试、有上限:内存队列负责短期缓存,磁盘文件负责失败兜底,服务端用 eventIdbatchId 做去重幂等。

代码示例

c
using System; // 引入时间和序列化相关类型。
using System.Collections; // 引入 IEnumerator 协程接口。
using System.Collections.Generic; // 引入 Queue 和 List 容器。
using System.IO; // 引入文件读写 API。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Networking; // 引入 UnityWebRequest 网络请求 API。

[Serializable] // 允许 JsonUtility 序列化埋点事件。
public class AnalyticsEvent // 定义单条埋点事件。
{ // 类开始。
    public string eventId; // 事件唯一 ID,用于服务端去重。
    public string name; // 事件名,例如 click_button。
    public string time; // 事件发生时间。
    public string userId; // 玩家 ID。
    public string version; // 客户端版本。
    public string paramJson; // 业务参数 JSON。
} // 类结束。

[Serializable] // 允许 JsonUtility 序列化批量事件。
public class AnalyticsBatch // 定义一批埋点事件。
{ // 类开始。
    public List<AnalyticsEvent> events = new List<AnalyticsEvent>(); // 保存批量事件列表。
} // 类结束。

public class AnalyticsCache : MonoBehaviour // 定义埋点缓存组件。
{ // 类开始。
    private const int MaxBatchCount = 20; // 达到 20 条就触发上传。
    private const float FlushInterval = 10f; // 每 10 秒尝试上传一次。
    private readonly Queue<AnalyticsEvent> queue = new Queue<AnalyticsEvent>(); // 内存埋点队列。
    private bool uploading = false; // 标记当前是否正在上传。
    private float nextFlushTime = 0f; // 下一次定时上传时间。
    private string cacheFile; // 本地缓存文件路径。

    private void Awake() // 初始化埋点缓存。
    { // 方法开始。
        cacheFile = Path.Combine(Application.persistentDataPath, "analytics_cache.jsonl"); // 缓存写到持久化目录。
        LoadDiskCache(); // 启动时加载上次失败的埋点。
        nextFlushTime = Time.realtimeSinceStartup + FlushInterval; // 设置第一次上传时间。
    } // 方法结束。

    public void Track(string eventName, string paramJson) // 业务调用的统一埋点入口。
    { // 方法开始。
        AnalyticsEvent e = new AnalyticsEvent(); // 创建一条埋点事件。
        e.eventId = Guid.NewGuid().ToString("N"); // 生成唯一事件 ID。
        e.name = eventName; // 保存事件名。
        e.time = DateTime.UtcNow.ToString("o"); // 保存 UTC 时间。
        e.userId = "player_001"; // 示例玩家 ID,真实项目从账号系统取。
        e.version = Application.version; // 保存客户端版本。
        e.paramJson = paramJson; // 保存业务参数。
        queue.Enqueue(e); // 放入内存队列。
    } // 方法结束。

    private void Update() // 每帧检查是否需要上传。
    { // 方法开始。
        bool enoughCount = queue.Count >= MaxBatchCount; // 判断数量是否达到批量阈值。
        bool enoughTime = Time.realtimeSinceStartup >= nextFlushTime; // 判断是否达到时间阈值。
        if (!uploading && (enoughCount || enoughTime)) StartCoroutine(Flush()); // 满足条件就启动上传协程。
    } // 方法结束。

    private IEnumerator Flush() // 批量上传埋点。
    { // 协程开始。
        uploading = true; // 标记正在上传。
        nextFlushTime = Time.realtimeSinceStartup + FlushInterval; // 重置下一次上传时间。
        AnalyticsBatch batch = new AnalyticsBatch(); // 创建本次上传批次。
        while (queue.Count > 0 && batch.events.Count < MaxBatchCount) batch.events.Add(queue.Dequeue()); // 从队列取出一批事件。
        if (batch.events.Count == 0) { uploading = false; yield break; } // 没有事件就结束。
        if (Application.internetReachability == NetworkReachability.NotReachable) { SaveDiskCache(batch.events); uploading = false; yield break; } // 没网时落盘保存。
        string json = JsonUtility.ToJson(batch); // 把批量事件序列化成 JSON。
        using (UnityWebRequest req = UnityWebRequest.Post("https://example.com/analytics", json, "application/json")) // 创建异步 POST 请求。
        { // using 开始。
            req.timeout = 10; // 设置超时时间,避免长期卡住。
            yield return req.SendWebRequest(); // 异步发送请求。
            if (req.result != UnityWebRequest.Result.Success) SaveDiskCache(batch.events); // 上传失败就写入本地缓存。
        } // using 结束。
        uploading = false; // 标记上传结束。
    } // 协程结束。

    private void SaveDiskCache(List<AnalyticsEvent> events) // 保存失败埋点到磁盘。
    { // 方法开始。
        foreach (AnalyticsEvent e in events) File.AppendAllText(cacheFile, JsonUtility.ToJson(e) + "\n"); // 每行保存一个事件 JSON。
    } // 方法结束。

    private void LoadDiskCache() // 加载磁盘中的历史埋点。
    { // 方法开始。
        if (!File.Exists(cacheFile)) return; // 没有缓存文件就直接返回。
        foreach (string line in File.ReadAllLines(cacheFile)) queue.Enqueue(JsonUtility.FromJson<AnalyticsEvent>(line)); // 读出旧事件放回队列。
        File.Delete(cacheFile); // 加载后删除旧文件,避免重复上传。
    } // 方法结束。

    private void OnApplicationPause(bool pause) // 应用切后台时调用。
    { // 方法开始。
        if (pause) StartCoroutine(Flush()); // 切后台前尽量上传或落盘。
    } // 方法结束。
} // 类结束。

面试加分点 我会补充:埋点缓存一定要有容量上限和重试退避,避免弱网下无限堆积;上传要带 eventId,服务端做幂等;敏感字段要脱敏;主线程只入队,不做阻塞网络和大量文件 IO。

Unity 中如何防止埋点阻塞主线程?

csharp-unity-analytics-nonblocking-main-thread

标准答案 防止埋点阻塞主线程,核心是:Track() 只做轻量入队,不做同步网络、不做同步文件 IO、不做大 JSON 序列化、不做阻塞等待。埋点数据先进入内存队列,再按数量或时间批量上传;失败后再落盘缓存,下次补传。

底层原理 Unity 主线程负责输入、动画、逻辑、渲染提交,如果埋点里做 File.WriteAllText、同步 HTTP、压缩、加密、Task.Wait()Result,就可能把一帧卡住。协程上传虽然仍在主线程调度,但 UnityWebRequest.SendWebRequest() 不会阻塞等待网络完成;如果是大批量序列化、压缩、加密,可以放后台线程,但后台线程不能调用 Unity API。

代码示例

c
using System.Collections; // 引入协程 IEnumerator。
using System.Collections.Generic; // 引入 Queue 和 List。
using System.Text; // 引入 UTF8 编码。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Networking; // 引入 UnityWebRequest。

public class NonBlockingAnalytics : MonoBehaviour // 定义非阻塞埋点组件。
{ // 类开始。
    private const string UploadUrl = "https://example.com/analytics"; // 示例上传地址。
    private const int MaxQueueCount = 1000; // 队列最大缓存数量。
    private const int BatchCount = 20; // 每次最多上传 20 条。
    private const float FlushInterval = 10f; // 每 10 秒尝试上传一次。
    private readonly Queue<string> queue = new Queue<string>(); // 内存埋点队列。
    private readonly List<string> batch = new List<string>(BatchCount); // 复用批量缓存,减少 GC。
    private bool uploading = false; // 标记是否正在上传。
    private float nextFlushTime = 0f; // 下一次上传时间。

    public void Track(string eventJson) // 业务调用的埋点入口。
    { // 方法开始。
        if (queue.Count >= MaxQueueCount) return; // 队列满时丢弃低优先级埋点,避免撑爆内存。
        queue.Enqueue(eventJson); // 主线程只入队,不做网络和磁盘操作。
    } // 方法结束。

    private void Update() // 每帧检查是否需要上传。
    { // 方法开始。
        bool enoughCount = queue.Count >= BatchCount; // 判断是否达到数量阈值。
        bool enoughTime = Time.realtimeSinceStartup >= nextFlushTime; // 判断是否达到时间阈值。
        if (!uploading && (enoughCount || enoughTime)) StartCoroutine(FlushAsync()); // 满足条件后异步上传。
    } // 方法结束。

    private IEnumerator FlushAsync() // 批量异步上传埋点。
    { // 协程开始。
        uploading = true; // 标记上传中,避免并发上传。
        nextFlushTime = Time.realtimeSinceStartup + FlushInterval; // 重置下次上传时间。
        batch.Clear(); // 清空复用批次。
        while (queue.Count > 0 && batch.Count < BatchCount) batch.Add(queue.Dequeue()); // 按批次从队列取出。
        if (batch.Count == 0) { uploading = false; yield break; } // 没有数据就结束。
        string json = "{\"events\":[" + string.Join(",", batch) + "]}"; // 示例组装批量 JSON。
        byte[] body = Encoding.UTF8.GetBytes(json); // 把 JSON 转成请求体字节。
        using (UnityWebRequest req = new UnityWebRequest(UploadUrl, "POST")) // 创建异步 POST 请求。
        { // using 开始。
            req.uploadHandler = new UploadHandlerRaw(body); // 设置上传数据。
            req.downloadHandler = new DownloadHandlerBuffer(); // 设置下载缓冲。
            req.SetRequestHeader("Content-Type", "application/json"); // 设置请求类型。
            req.timeout = 10; // 设置超时,避免弱网下长期占用。
            yield return req.SendWebRequest(); // 异步等待网络完成,不阻塞主线程。
            if (req.result != UnityWebRequest.Result.Success) SaveForRetry(batch); // 失败时保存等待重试。
        } // using 结束。
        uploading = false; // 标记上传结束。
    } // 协程结束。

    private void SaveForRetry(List<string> failedBatch) // 保存失败批次。
    { // 方法开始。
        foreach (string e in failedBatch) queue.Enqueue(e); // 示例放回队列,真实项目可批量落盘。
    } // 方法结束。
} // 类结束。

面试加分点 我会强调:埋点是低优先级系统,不能影响战斗、输入和渲染。高频事件要采样或防抖;队列必须有上限;失败重试要指数退避;切后台时可以快速 Flush;最后用 Profiler 看 Main Thread 耗时、GC Alloc、请求数量和磁盘 IO,证明它没有卡主线程。

Unity 中如何做网络请求重试?

csharp-unity-network-request-retry

标准答案 Unity 网络请求重试一般做成统一请求管理器:每个请求设置超时时间、最大重试次数、指数退避、随机抖动;只对超时、断网、5xx429 这类临时错误重试。像支付、下单、领奖这种请求,不能盲目重试,必须带幂等 ID,让服务端识别“这是同一次业务请求”。

底层原理 重试不是失败后马上再发,而是“有限次数 + 逐渐延迟 + 可取消”。否则弱网时大量客户端同时重试,会把服务器打得更糟,也可能导致重复发奖、重复扣费。Unity 里通常用协程或 async 做异步等待,不能用同步等待卡住主线程。

代码示例

c
using System; // 引入 Action 回调类型。
using System.Collections; // 引入 IEnumerator 协程接口。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Networking; // 引入 UnityWebRequest 网络请求 API。

public class RetryHttpClient : MonoBehaviour // 定义一个网络重试组件。
{ // 类开始。
    public IEnumerator GetWithRetry(string url, Action<string> onSuccess, Action<string> onFail) // 定义带重试的 GET 请求。
    { // 方法开始。
        int maxRetry = 3; // 最多重试 3 次。
        float baseDelay = 1f; // 第一次重试前等待 1 秒。
        for (int attempt = 0; attempt <= maxRetry; attempt++) // 从第 0 次请求开始循环。
        { // for 开始。
            using (UnityWebRequest req = UnityWebRequest.Get(url)) // 每次重试都创建新的请求对象。
            { // using 开始。
                req.timeout = 8; // 设置单次请求超时时间。
                yield return req.SendWebRequest(); // 异步发送请求,不阻塞主线程。
                if (req.result == UnityWebRequest.Result.Success) // 判断请求是否成功。
                { // if 开始。
                    onSuccess?.Invoke(req.downloadHandler.text); // 成功后回调响应文本。
                    yield break; // 成功后结束协程。
                } // if 结束。
                bool retryable = IsRetryable(req); // 判断当前错误是否适合重试。
                bool lastAttempt = attempt == maxRetry; // 判断是否已经到最后一次。
                if (!retryable || lastAttempt) // 不可重试或次数耗尽时失败。
                { // if 开始。
                    onFail?.Invoke($"Request failed: {req.responseCode}, {req.error}"); // 回调失败原因。
                    yield break; // 结束协程。
                } // if 结束。
            } // using 结束。
            float jitter = UnityEngine.Random.Range(0f, 0.3f); // 加随机抖动,避免同时重试。
            float delay = Mathf.Min(baseDelay * Mathf.Pow(2f, attempt) + jitter, 8f); // 指数退避并限制最大等待。
            yield return new WaitForSecondsRealtime(delay); // 使用真实时间等待,不受 timeScale 影响。
        } // for 结束。
    } // 方法结束。

    private bool IsRetryable(UnityWebRequest req) // 判断错误是否可重试。
    { // 方法开始。
        if (req.result == UnityWebRequest.Result.ConnectionError) return true; // 连接失败通常可以重试。
        long code = req.responseCode; // 获取 HTTP 状态码。
        if (code == 408 || code == 429) return true; // 超时或限流可以稍后重试。
        if (code >= 500 && code <= 599) return true; // 服务端临时错误可以重试。
        return false; // 其他错误默认不重试。
    } // 方法结束。
} // 类结束。

面试加分点 我会强调:400 参数错误、401 鉴权错误通常不该一直重试;429 最好按服务端 Retry-After 等待;切场景或 UI 关闭后要取消旧请求;重要业务请求要带 X-Request-Id 或业务流水号,服务端保证幂等。

Unity 中如何做错误码管理?

csharp-unity-error-code-management

标准答案 Unity 错误码管理的核心是:错误码由客户端和服务端统一约定,按模块分号段,用配置表维护“错误码、提示文案、处理动作、是否上报”,业务层不要到处 switch 或写魔法数字,而是统一交给 ErrorCodeManager 处理。

底层原理 错误码本质是客户端和服务端之间的“错误契约”。比如 10xxxx 表示网络错误,20xxxx 表示登录错误,30xxxx 表示背包错误。客户端收到错误码后,不应该只弹一句“请求失败”,而要判断它是可重试、需要重新登录、需要提示玩家、还是需要上报日志。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary 用来保存错误码配置。
using UnityEngine; // 引入 Debug 和 Unity 基础 API。

public enum ErrorAction // 定义错误码对应的处理动作。
{ // 枚举开始。
    Toast = 0, // 只显示普通提示。
    Retry = 1, // 允许玩家或系统重试。
    Relogin = 2, // 需要重新登录。
    Report = 3 // 需要上报日志。
} // 枚举结束。

public class ErrorInfo // 定义错误码配置数据。
{ // 类开始。
    public string Message; // 展示给玩家看的文案。
    public ErrorAction Action; // 错误码对应的处理动作。
    public bool NeedReport; // 是否需要上报日志。
    public ErrorInfo(string message, ErrorAction action, bool needReport) // 构造错误信息。
    { // 构造函数开始。
        Message = message; // 保存提示文案。
        Action = action; // 保存处理动作。
        NeedReport = needReport; // 保存是否上报。
    } // 构造函数结束。
} // 类结束。

public static class ErrorCodeManager // 定义统一错误码管理器。
{ // 类开始。
    private static readonly Dictionary<int, ErrorInfo> Table = new Dictionary<int, ErrorInfo>() // 模拟错误码配置表。
    { // 字典开始。
        { 100001, new ErrorInfo("网络不稳定,请稍后重试", ErrorAction.Retry, false) }, // 网络超时错误。
        { 200001, new ErrorInfo("登录已过期,请重新登录", ErrorAction.Relogin, true) }, // Token 过期错误。
        { 300001, new ErrorInfo("背包已满,请先清理背包", ErrorAction.Toast, false) }, // 背包满错误。
        { 900001, new ErrorInfo("系统异常,请稍后再试", ErrorAction.Report, true) } // 系统异常错误。
    }; // 字典结束。

    public static void Handle(int code, string context) // 统一处理错误码。
    { // 方法开始。
        if (!Table.TryGetValue(code, out ErrorInfo info)) // 查不到配置时走兜底逻辑。
        { // if 开始。
            info = new ErrorInfo("未知错误,请稍后再试", ErrorAction.Report, true); // 创建未知错误默认配置。
        } // if 结束。

        ShowMessage(info.Message); // 统一展示安全文案给玩家。

        if (info.Action == ErrorAction.Retry) ShowRetryButton(code); // 可重试错误显示重试入口。
        if (info.Action == ErrorAction.Relogin) GoToLogin(); // 登录错误跳转登录流程。
        if (info.NeedReport) Report(code, context); // 需要上报时记录错误上下文。
    } // 方法结束。

    private static void ShowMessage(string message) // 显示错误提示。
    { // 方法开始。
        Debug.Log("Toast: " + message); // 示例用 Debug 模拟 Toast。
    } // 方法结束。

    private static void ShowRetryButton(int code) // 显示重试按钮。
    { // 方法开始。
        Debug.Log("Show retry button for code: " + code); // 示例输出重试按钮信息。
    } // 方法结束。

    private static void GoToLogin() // 跳转登录流程。
    { // 方法开始。
        Debug.Log("Go to login"); // 示例输出重新登录行为。
    } // 方法结束。

    private static void Report(int code, string context) // 上报错误日志。
    { // 方法开始。
        Debug.LogError("Report error code: " + code + ", context: " + context); // 示例上报错误码和上下文。
    } // 方法结束。
} // 类结束。

Unity 工程实践 真实项目里错误码表一般由配置表生成代码,字段包括:codemodulemessageKeyactionneedReportpriority。文案不要直接写中文,要走多语言表;未知错误必须兜底;严重错误要带账号、场景、接口名、请求参数摘要、版本号一起上报。

面试加分点 错误码管理不是只做提示,而是统一“提示、重试、跳登录、上报、兼容”。特别是服务端新增错误码时,旧客户端可能不认识,所以一定要有 UnknownError 兜底。

Unity 中如何设计弱网模拟工具?

csharp-unity-weak-network-simulator-tool

标准答案 Unity 弱网模拟工具的核心是:在统一网络层里插一个“弱网规则层”,让所有 HTTP、Socket、资源下载都先经过它,再模拟延迟、抖动、丢包、乱序、限速、断线。这样能复现真实移动网络问题,比如地铁、电梯、后台切回、弱 4G、WiFi 抖动。

底层原理 弱网不是只测“断网”。真实弱网更常见的是:请求很慢、偶尔超时、响应乱序、重试变多、下载速度忽高忽低。所以工具要支持配置预设和随机种子:同一个 profile + seed 可以复现同一个 bug,方便测试和程序定位。

代码示例

using System.Collections; // 引入 IEnumerator,用来写协程。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Networking; // 引入 UnityWebRequest 网络请求 API。

[System.Serializable] // 允许在 Inspector 中编辑弱网配置。
public class WeakNetworkProfile // 定义弱网配置类。
{ // 类开始。
    public bool enabled = true; // 是否启用弱网模拟。
    public bool offline = false; // 是否模拟完全断网。
    public int latencyMs = 200; // 基础延迟,单位毫秒。
    public int jitterMs = 80; // 抖动范围,单位毫秒。
    public float lossRate = 0.1f; // 丢包概率,例如 0.1 表示 10%。
} // 类结束。

public class WeakNetworkHttp : MonoBehaviour // 定义 HTTP 弱网模拟封装。
{ // 类开始。
    public WeakNetworkProfile profile = new WeakNetworkProfile(); // 当前使用的弱网配置。

    public IEnumerator Get(string url, System.Action<string> onSuccess, System.Action<string> onFail) // 发送带弱网模拟的 GET 请求。
    { // 方法开始。
        if (profile.enabled && profile.offline) // 如果启用了弱网并且模拟断网。
        { // if 开始。
            onFail?.Invoke("Simulated offline"); // 直接回调断网失败。
            yield break; // 结束协程。
        } // if 结束。

        if (profile.enabled) // 如果启用了弱网模拟。
        { // if 开始。
            float jitter = Random.Range(-profile.jitterMs, profile.jitterMs) / 1000f; // 计算随机抖动秒数。
            float delay = Mathf.Max(0f, profile.latencyMs / 1000f + jitter); // 计算最终延迟时间。
            yield return new WaitForSecondsRealtime(delay); // 等待延迟时间,不受 timeScale 影响。
            if (Random.value < profile.lossRate) // 按概率模拟丢包。
            { // if 开始。
                onFail?.Invoke("Simulated packet loss"); // 回调模拟丢包失败。
                yield break; // 结束协程。
            } // if 结束。
        } // if 结束。

        using (UnityWebRequest req = UnityWebRequest.Get(url)) // 创建真实 Unity 网络请求。
        { // using 开始。
            req.timeout = 10; // 设置超时时间,避免长期卡住。
            yield return req.SendWebRequest(); // 异步发送请求,不阻塞主线程。
            if (req.result == UnityWebRequest.Result.Success) onSuccess?.Invoke(req.downloadHandler.text); // 成功时返回文本。
            else onFail?.Invoke(req.error); // 失败时返回错误信息。
        } // using 结束。
    } // 方法结束。
} // 类结束。

工程实践 我会把它做成 EditorWindow 或游戏内调试面板,只在开发包、测试包开启。预设可以有 Good WiFiWeak 4GSubwayElevator Offline。每次测试记录延迟、丢包率、随机种子、请求 URL、重试次数和超时次数,方便复现。

常见坑 只模拟断网是不够的;更容易出问题的是高延迟、偶发丢包、乱序、重复包、切后台后恢复。还有一点,后台线程不能调用 Unity API,弱网工具最好插在网络封装层,不要散落在业务代码里。

Unity 中如何做 GM 面板?

csharp-unity-gm-panel-design

标准答案 Unity GM 面板本质是一个受控调试工具,不是简单做几个作弊按钮。正常设计会分成:入口控制、权限校验、命令注册、参数解析、业务分发、日志审计。开发、测试、运营可以用它快速加道具、跳关卡、刷怪、切场景、模拟异常,但正式服必须严格限制。

底层思路 我会让各业务模块自己注册 GM 命令,GM 面板只负责解析和分发,不直接依赖背包、战斗、任务等所有系统。这样后面新增命令时,不用改一堆 UI 代码。

代码示例

c
using System; // 引入 Action 和 Exception。
using System.Collections.Generic; // 引入 Dictionary。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.UI; // 引入 UGUI 控件。

public class GMPanel : MonoBehaviour // 定义 GM 面板组件。
{ // 类开始。
    public GameObject root; // GM 面板根节点。
    public InputField input; // 命令输入框。
    public Text output; // 输出日志文本。
    private readonly Dictionary<string, Action<string[]>> commands = new Dictionary<string, Action<string[]>>(); // 保存命令名到处理函数的映射。

    private void Awake() // 初始化 GM 面板。
    { // 方法开始。
        root.SetActive(false); // 默认隐藏 GM 面板。
        Register("add_item", AddItem); // 注册添加物品命令。
        Register("set_level", SetLevel); // 注册设置等级命令。
    } // 方法结束。

    private void Update() // 每帧检测快捷键。
    { // 方法开始。
        if (Input.GetKeyDown(KeyCode.F12)) Toggle(); // 按 F12 打开或关闭 GM 面板。
    } // 方法结束。

    public void OnClickExecute() // 点击执行按钮时调用。
    { // 方法开始。
        Execute(input.text); // 执行输入框里的命令。
    } // 方法结束。

    private void Register(string name, Action<string[]> handler) // 注册一条 GM 命令。
    { // 方法开始。
        commands[name] = handler; // 把命令名和处理函数保存到字典。
    } // 方法结束。

    private void Execute(string raw) // 执行原始命令字符串。
    { // 方法开始。
        if (!CanUseGM()) { Print("没有 GM 权限"); return; } // 没有权限时直接拒绝。
        string[] parts = raw.Split(' '); // 按空格拆分命令和参数。
        if (parts.Length == 0 || string.IsNullOrWhiteSpace(parts[0])) { Print("命令为空"); return; } // 空命令直接返回。
        string cmd = parts[0]; // 第一个单词作为命令名。
        string[] args = new string[parts.Length - 1]; // 创建参数数组。
        Array.Copy(parts, 1, args, 0, args.Length); // 拷贝命令参数。
        if (!commands.TryGetValue(cmd, out Action<string[]> handler)) { Print("未知命令: " + cmd); return; } // 找不到命令时提示错误。
        try { handler(args); Audit(raw, true); } // 执行命令并记录成功日志。
        catch (Exception e) { Print(e.Message); Audit(raw, false); } // 捕获异常并记录失败日志。
    } // 方法结束。

    private bool CanUseGM() // 判断当前是否允许使用 GM。
    { // 方法开始。
        return Debug.isDebugBuild || Application.isEditor; // 只允许开发包或编辑器使用。
    } // 方法结束。

    private void Toggle() // 切换 GM 面板显示状态。
    { // 方法开始。
        root.SetActive(!root.activeSelf); // 反转显示状态。
    } // 方法结束。

    private void AddItem(string[] args) // 添加物品命令。
    { // 方法开始。
        if (args.Length < 2) { Print("用法: add_item itemId count"); return; } // 参数不足时提示用法。
        int itemId = int.Parse(args[0]); // 解析物品 ID。
        int count = int.Parse(args[1]); // 解析物品数量。
        Print($"添加物品 itemId={itemId}, count={count}"); // 示例输出执行结果。
    } // 方法结束。

    private void SetLevel(string[] args) // 设置等级命令。
    { // 方法开始。
        if (args.Length < 1) { Print("用法: set_level level"); return; } // 参数不足时提示用法。
        int level = int.Parse(args[0]); // 解析目标等级。
        Print($"设置等级 level={level}"); // 示例输出执行结果。
    } // 方法结束。

    private void Print(string msg) // 输出 GM 面板日志。
    { // 方法开始。
        output.text += msg + "\n"; // 追加一行日志到 UI。
    } // 方法结束。

    private void Audit(string raw, bool success) // 记录 GM 操作审计日志。
    { // 方法开始。
        Debug.Log($"GM Audit: command={raw}, success={success}"); // 记录命令、结果和操作者信息。
    } // 方法结束。
} // 类结束。

项目里要注意 正式服不要只靠客户端开关保护 GM,因为客户端不可信。重要 GM 操作最好走服务端鉴权和审计;危险命令要二次确认;每次执行都记录操作者、命令、参数、结果和时间。

Unity 中如何做运行时调试菜单?

csharp-unity-runtime-debug-menu

标准答案 Unity 运行时调试菜单是一个真机诊断面板,主要用来查看运行时状态,比如 FPS、内存、GC、日志、网络请求、资源加载、当前场景和玩家状态。它和 GM 面板不同:GM 更偏“修改状态”,Debug Menu 更偏“观察状态、导出证据、辅助定位问题”。

核心设计 我会把它拆成:入口控制、菜单管理器、调试页面、数据采集器、导出模块。入口只在开发包、测试包或白名单账号开启;页面做成模块化,比如性能页、日志页、网络页、资源页;采样不要每帧都做,通常 0.5 到 1 秒刷新一次,避免调试工具自己造成卡顿。

代码示例

c
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.UI; // 引入 UGUI 控件。

public class RuntimeDebugMenu : MonoBehaviour // 定义运行时调试菜单。
{ // 类开始。
    public GameObject root; // 调试菜单根节点。
    public Text infoText; // 用来显示运行时信息的文本。
    private float timer = 0f; // 采样计时器。
    private int frameCount = 0; // 统计采样周期内的帧数。
    private float fps = 0f; // 保存当前 FPS。

    private void Awake() // 初始化调试菜单。
    { // 方法开始。
        root.SetActive(false); // 默认隐藏调试菜单。
    } // 方法结束。

    private void Update() // 每帧更新入口和采样。
    { // 方法开始。
        if (Input.GetKeyDown(KeyCode.F12)) Toggle(); // PC 上按 F12 打开或关闭。
        frameCount++; // 累加帧数。
        timer += Time.unscaledDeltaTime; // 使用不受暂停影响的时间。
        if (timer >= 1f) RefreshInfo(); // 每 1 秒刷新一次调试信息。
    } // 方法结束。

    private void Toggle() // 切换调试菜单显示状态。
    { // 方法开始。
        if (!CanOpen()) return; // 没有权限时不允许打开。
        root.SetActive(!root.activeSelf); // 切换显示和隐藏。
    } // 方法结束。

    private bool CanOpen() // 判断是否允许打开调试菜单。
    { // 方法开始。
        return Debug.isDebugBuild || Application.isEditor; // 只允许开发包或编辑器开启。
    } // 方法结束。

    private void RefreshInfo() // 刷新调试信息。
    { // 方法开始。
        fps = frameCount / timer; // 计算最近一秒 FPS。
        frameCount = 0; // 重置帧数。
        timer = 0f; // 重置计时器。
        long monoMemory = System.GC.GetTotalMemory(false); // 获取托管堆内存。
        infoText.text = $"FPS: {fps:F1}\nManaged Memory: {monoMemory / 1024 / 1024} MB\nScene: {UnityEngine.SceneManagement.SceneManager.GetActiveScene().name}"; // 显示性能和场景信息。
    } // 方法结束。
} // 类结束。

面试加分点 我会说:运行时调试菜单要服务真机排查,所以最好支持一键导出日志、设备信息、版本号、截图和当前场景;日志页要能按等级筛选;网络页要能看到失败请求、重试次数、RTT;资源页要能看到 Addressables 或 Bundle 的加载状态和引用计数。

常见坑 调试菜单本身也会消耗性能,所以不要每帧拼接大量字符串、不要每帧刷新复杂 UI、不要正式包默认暴露入口。否则你想查卡顿,结果卡顿是调试菜单自己造成的。

C# 中如何避免事件内存泄漏?

csharp-event-memory-leak-avoidance

标准答案 C# 事件内存泄漏的根因是:事件发布者会保存委托,委托里又保存订阅者对象引用。如果发布者生命周期更长,比如单例、全局事件总线、静态事件,而订阅者是 UI、场景对象、临时对象,那么不取消订阅就会导致订阅者无法被 GC。

怎么避免 最常用规则是:+=-= 成对出现。Unity 里通常 OnEnable 订阅,OnDisable 退订;普通 C# 对象可以在 Dispose 里退订;一次性事件触发后自动退订;事件总线可以返回 IDisposable 订阅令牌;静态事件在切账号、切场景、重启逻辑时要清理。

代码示例

c
using System; // 引入 Action 事件类型。
using UnityEngine; // 引入 MonoBehaviour 和 Debug。

public class GameEventCenter // 定义一个全局事件中心。
{ // 类开始。
    public static event Action<int> OnGoldChanged; // 定义金币变化事件,静态事件生命周期很长。
    public static void RaiseGoldChanged(int gold) // 对外提供触发事件的方法。
    { // 方法开始。
        OnGoldChanged?.Invoke(gold); // 安全触发事件。
    } // 方法结束。
    public static void Clear() // 提供清理静态事件的方法。
    { // 方法开始。
        OnGoldChanged = null; // 清空所有订阅,避免切场景或切账号后残留旧对象。
    } // 方法结束。
} // 类结束。

public class GoldView : MonoBehaviour // 定义一个 UI 订阅者。
{ // 类开始。
    private void OnEnable() // 对象启用时调用。
    { // 方法开始。
        GameEventCenter.OnGoldChanged += RefreshGold; // 订阅事件。
    } // 方法结束。

    private void OnDisable() // 对象禁用时调用。
    { // 方法开始。
        GameEventCenter.OnGoldChanged -= RefreshGold; // 退订事件,避免事件中心继续引用当前 UI。
    } // 方法结束。

    private void RefreshGold(int gold) // 处理金币变化。
    { // 方法开始。
        Debug.Log("Gold = " + gold); // 示例刷新 UI。
    } // 方法结束。
} // 类结束。

常见坑 匿名 lambda 很容易退订失败,因为两次写出来的 lambda 不是同一个委托实例。要么用命名函数,要么把 lambda 保存到字段里再退订。

面试加分点 我会补一句:C# 事件泄漏不是 GC 不工作,而是对象仍然可达;GC 看到“发布者 -> 委托 -> 订阅者”这条强引用链,就不会回收订阅者。

C# 中闭包捕获循环变量有什么坑?

csharp-closure-loop-variable-pitfall

标准答案 闭包捕获循环变量的坑是:闭包捕获的是“变量本身”,不是“当时的值”。所以 for 循环里如果直接捕获 i,多个回调可能共享同一个 i,等回调真正执行时,i 已经变成循环结束后的值。

经典场景 Unity 里最常见是动态生成按钮:

c
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.UI; // 引入 UGUI Button。

public class ClosureLoopDemo : MonoBehaviour // 定义闭包循环变量示例。
{ // 类开始。
    [SerializeField] private Button[] buttons; // 保存按钮数组。

    public void BindWrong() // 错误绑定方式。
    { // 方法开始。
        for (int i = 0; i < buttons.Length; i++) // 使用 for 循环遍历按钮。
        { // for 开始。
            buttons[i].onClick.AddListener(() => Debug.Log("Wrong index = " + i)); // 错误:所有回调共享同一个 i。
        } // for 结束。
    } // 方法结束。

    public void BindRight() // 正确绑定方式。
    { // 方法开始。
        for (int i = 0; i < buttons.Length; i++) // 使用 for 循环遍历按钮。
        { // for 开始。
            int index = i; // 每一轮创建局部副本。
            buttons[index].onClick.AddListener(() => Debug.Log("Right index = " + index)); // 正确:每个回调捕获自己的 index。
        } // for 结束。
    } // 方法结束。
} // 类结束。

底层原理 编译器会把被捕获的局部变量提升到一个“闭包对象”里。错误写法中,多个 lambda 引用的是同一个闭包字段 i;正确写法中,每轮循环都会创建新的 index,每个 lambda 捕获的是自己的副本。

补充坑点 现代 C# 的 foreach 循环变量已经做过修正,通常每轮是独立变量;但 for 循环捕获 i 仍然是高频坑。Unity 里按钮回调、异步加载回调、计时器回调、事件监听都容易踩这个问题。另外闭包可能产生额外分配,高频逻辑里也要注意 GC。

C# 中装箱拆箱如何在 Unity 中被发现?

csharp-unity-boxing-detection

标准答案 在 Unity 里发现装箱拆箱,最直接看 Profiler -> CPU Usage -> GC Alloc。因为装箱会把值类型复制到托管堆上,产生 Managed Allocation,所以它通常表现为某一帧出现 GC Alloc。然后再通过 Deep ProfileAllocation Callstacks、代码搜索或 IL 反编译定位具体来源。

常见排查路径 先用 Profiler 看哪一帧有 GC Alloc,再在 HierarchyTimeline 里按分配排序,找到具体方法。接着检查代码里有没有这些高危点:值类型传给 object、非泛型集合、params object[]、结构体转接口、枚举格式化、日志拼接等。最后可以反编译 IL,看有没有 boxunbox.any 指令。

代码示例

using System.Collections; // 引入 ArrayList 非泛型集合。
using System.Collections.Generic; // 引入 List 泛型集合。
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.Profiling; // 引入 Profiler 标记 API。

public class BoxingDetectDemo : MonoBehaviour // 定义装箱检测示例。
{ // 类开始。
    private readonly ArrayList badList = new ArrayList(); // 非泛型集合会把 int 当 object 存,容易装箱。
    private readonly List<int> goodList = new List<int>(); // 泛型集合直接存 int,不需要装箱。

    private void Update() // 每帧执行示例逻辑。
    { // 方法开始。
        Profiler.BeginSample("Boxing Bad Example"); // 在 Profiler 里标记坏例子。
        object boxed = 123; // int 赋给 object,会发生装箱并产生托管分配。
        int value = (int)boxed; // object 转回 int,是拆箱。
        badList.Add(value); // int 放进 ArrayList,会再次装箱。
        Profiler.EndSample(); // 结束坏例子采样。

        Profiler.BeginSample("No Boxing Good Example"); // 在 Profiler 里标记好例子。
        int normalValue = 123; // int 仍然保持值类型。
        goodList.Add(normalValue); // int 放进 List<int>,不会因为集合本身而装箱。
        Profiler.EndSample(); // 结束好例子采样。
    } // 方法结束。
} // 类结束。

Unity 面试加分点 我会补一句:看到 GC Alloc 不等于一定是装箱,也可能是字符串、闭包、LINQ、数组、协程对象等分配。所以正确姿势是“Profiler 找分配点 + 调用栈定位 + 代码确认 + 优化后复测”。如果要更底层,可以用 ILSpy/dotPeek 看 IL 里是否出现 box 指令。

C# 中如何降低字符串拼接分配?

csharp-unity-string-concat-allocation-reduction

标准答案 降低字符串拼接分配,核心是少在热路径里拼字符串。因为 string 是不可变对象,拼接通常会创建新字符串;如果在 Update、UI 刷新、日志输出、循环里频繁拼接,就会产生 GC Alloc。常用优化是:按需刷新、缓存固定文本、复用 StringBuilder、日志先判断等级、TMP 使用 SetText 格式化重载。

底层原理 少量一次性拼接问题不大,编译器可能会优化成一次 String.Concat。真正危险的是循环里 s += item,它每轮都可能创建新字符串,还要复制前面已经拼好的内容,分配和复制成本都会上升。

代码示例

c
using System.Text; // 引入 StringBuilder。
using TMPro; // 引入 TextMeshPro 文本组件。
using UnityEngine; // 引入 Unity 基础 API。

public class ScoreTextOptimized : MonoBehaviour // 定义分数文本优化示例。
{ // 类开始。
    [SerializeField] private TMP_Text scoreText; // 保存 UI 文本组件引用。
    private readonly StringBuilder builder = new StringBuilder(32); // 复用 StringBuilder,避免反复创建。
    private int lastScore = int.MinValue; // 记录上一次显示的分数。

    public void SetScore(int score) // 更新分数显示。
    { // 方法开始。
        if (score == lastScore) return; // 分数没变时不刷新 UI。
        lastScore = score; // 更新缓存的分数。
        builder.Clear(); // 清空内容但保留内部容量。
        builder.Append("Score: "); // 追加固定前缀。
        builder.Append(score); // 追加数字分数。
        scoreText.text = builder.ToString(); // 只生成最终字符串,避免中间多次拼接。
    } // 方法结束。

    public void SetScoreWithTmp(int score) // 使用 TMP 格式化重载更新。
    { // 方法开始。
        if (score == lastScore) return; // 分数没变时跳过。
        lastScore = score; // 保存最新分数。
        scoreText.SetText("Score: {0}", score); // TMP 的 SetText 格式化重载通常比 string.Format 更适合 UI 热路径。
    } // 方法结束。
} // 类结束。

Unity 里常见优化点 UI 数字不要每帧刷新,只有数值变化时刷新。日志不要先拼好再判断是否输出,应该先判断日志等级。排行榜、背包、战斗日志这种多段文本,用 StringBuilder 并预估容量。最后一定用 Profiler 看 GC Alloc,优化后复测是否真的下降。

C# 中如何设计无 GC 的消息派发?

csharp-unity-zero-gc-message-dispatch

标准答案 无 GC 消息派发的核心是:把分配放到初始化阶段,运行时派发热路径做到不 new、不装箱、不闭包、不 LINQ、不扩容。消息最好强类型化,监听列表提前注册并预分配容量,派发时只用 for 循环调用监听者。

底层原理 最容易产生 GC 的写法是 object payload,如果消息是 struct,传给 object 就会装箱;运行时临时 lambda 会产生闭包;Where/ToList 会创建迭代器和列表;监听列表扩容也会分配。无 GC 设计就是把这些都从派发路径拿掉。

代码示例

c
using System.Collections.Generic; // 引入 List 容器。
using UnityEngine; // 引入 Debug。

public interface IMessageHandler<T> where T : struct // 定义强类型消息处理接口。
{ // 接口开始。
    void OnMessage(in T message); // 使用 in 传递消息,减少大结构体复制。
} // 接口结束。

public readonly struct DamageMessage // 定义伤害消息为值类型。
{ // 结构体开始。
    public readonly int TargetId; // 受击目标 ID。
    public readonly int Damage; // 伤害数值。
    public DamageMessage(int targetId, int damage) // 构造伤害消息。
    { // 构造函数开始。
        TargetId = targetId; // 保存目标 ID。
        Damage = damage; // 保存伤害值。
    } // 构造函数结束。
} // 结构体结束。

public sealed class NoGcMessageBus<T> where T : struct // 定义强类型无 GC 消息通道。
{ // 类开始。
    private readonly List<IMessageHandler<T>> handlers; // 保存监听者列表。
    private int dispatching; // 标记是否正在派发。

    public NoGcMessageBus(int capacity) // 构造消息通道。
    { // 构造函数开始。
        handlers = new List<IMessageHandler<T>>(capacity); // 初始化时预分配容量。
    } // 构造函数结束。

    public void Subscribe(IMessageHandler<T> handler) // 注册监听者。
    { // 方法开始。
        if (dispatching > 0) return; // 示例中派发期间不允许修改列表。
        if (!handlers.Contains(handler)) handlers.Add(handler); // 避免重复注册。
    } // 方法结束。

    public void Unsubscribe(IMessageHandler<T> handler) // 取消监听者。
    { // 方法开始。
        if (dispatching > 0) return; // 示例中派发期间不允许修改列表。
        handlers.Remove(handler); // 从监听列表中移除。
    } // 方法结束。

    public void Dispatch(in T message) // 派发消息。
    { // 方法开始。
        dispatching++; // 标记进入派发状态。
        for (int i = 0; i < handlers.Count; i++) // 使用 for 循环避免 LINQ 和临时列表。
        { // for 开始。
            handlers[i].OnMessage(in message); // 强类型调用,不把消息转成 object。
        } // for 结束。
        dispatching--; // 标记退出派发状态。
    } // 方法结束。
} // 类结束。

public sealed class DamageView : IMessageHandler<DamageMessage> // 定义一个伤害消息监听者。
{ // 类开始。
    public void OnMessage(in DamageMessage message) // 处理伤害消息。
    { // 方法开始。
        Debug.Log("Damage = " + message.Damage); // 示例输出,真实热路径里日志也要关闭或过滤。
    } // 方法结束。
} // 类结束。

Unity 实战注意 这种系统适合战斗事件、输入事件、网络同步事件等高频路径。订阅和退订尽量在初始化、OnEnable/OnDisable 做,不要每帧做。派发中如果允许增删监听,需要做延迟队列或快照,但快照不能每次新建列表。最后必须用 Profiler 看 Dispatch 热路径的 GC Alloc 是否为 0B

C# 中如何设计类型安全事件系统?

csharp-unity-type-safe-event-system

标准答案

C# 类型安全事件系统,就是用“事件类型”代替字符串事件名,用 Subscribe<T> / Publish<T> 代替 object 参数,让编译器检查事件参数是否匹配。这样可以减少字符串写错、参数强转失败、装箱拆箱、事件难追踪这些问题。

底层原理

常见事件总线如果写成 Publish("Damage", object data),问题是错误只能运行时发现。类型安全写法会把事件定义成 DamageEventPlayerDeadEvent 这种明确类型,内部通常用 Dictionary<Type, List<Delegate>> 按事件类型存监听者。对外暴露泛型 API,业务层只能订阅和派发同一种 T

Unity 里怎么用

一般在 OnEnable 订阅,在 OnDisable 退订,避免对象销毁后还被事件总线引用导致内存泄漏。高频事件要注意不要把 struct 事件塞进 object 队列,否则会装箱;战斗、UI、任务、红点、资源加载回调都适合用这种设计。

c
using System; // 引入 Action 和 Type。
using System.Collections.Generic; // 引入 Dictionary 和 List。
using UnityEngine; // 引入 MonoBehaviour 和 Debug。

public interface IGameEvent { } // 定义事件标记接口,限制只有事件类型能进入系统。

public readonly struct DamageEvent : IGameEvent // 定义一个强类型伤害事件。
{ // DamageEvent 开始。
    public readonly int TargetId; // 保存受击目标 ID。
    public readonly int Damage; // 保存伤害数值。
    public DamageEvent(int targetId, int damage) // 定义事件构造函数。
    { // 构造函数开始。
        TargetId = targetId; // 初始化目标 ID。
        Damage = damage; // 初始化伤害数值。
    } // 构造函数结束。
} // DamageEvent 结束。

public sealed class EventBus // 定义类型安全事件总线。
{ // EventBus 开始。
    private readonly Dictionary<Type, List<Delegate>> listeners = new Dictionary<Type, List<Delegate>>(); // 按事件类型保存监听列表。
    public void Subscribe<T>(Action<T> listener) where T : IGameEvent // 订阅指定事件类型。
    { // Subscribe 开始。
        Type type = typeof(T); // 取出泛型事件类型。
        if (!listeners.TryGetValue(type, out List<Delegate> list)) // 如果该类型还没有监听列表。
        { // if 开始。
            list = new List<Delegate>(8); // 创建监听列表并预留容量。
            listeners.Add(type, list); // 把监听列表放进字典。
        } // if 结束。
        if (!list.Contains(listener)) list.Add(listener); // 避免重复订阅同一个监听函数。
    } // Subscribe 结束。
    public void Unsubscribe<T>(Action<T> listener) where T : IGameEvent // 取消订阅指定事件类型。
    { // Unsubscribe 开始。
        Type type = typeof(T); // 取出泛型事件类型。
        if (listeners.TryGetValue(type, out List<Delegate> list)) list.Remove(listener); // 找到列表后移除监听函数。
    } // Unsubscribe 结束。
    public void Publish<T>(T evt) where T : IGameEvent // 发布指定事件类型。
    { // Publish 开始。
        Type type = typeof(T); // 取出当前事件类型。
        if (!listeners.TryGetValue(type, out List<Delegate> list)) return; // 没有监听者就直接返回。
        for (int i = 0; i < list.Count; i++) // 按订阅顺序遍历监听者。
        { // for 开始。
            try { ((Action<T>)list[i]).Invoke(evt); } // 调用类型匹配的监听函数。
            catch (Exception e) { Debug.LogException(e); } // 单个监听者异常不影响后续监听者。
        } // for 结束。
    } // Publish 结束。
} // EventBus 结束。

public static class GameEvents // 定义全局事件入口。
{ // GameEvents 开始。
    public static readonly EventBus Bus = new EventBus(); // 保存项目共享事件总线。
} // GameEvents 结束。

public sealed class DamageView : MonoBehaviour // 定义一个伤害显示组件。
{ // DamageView 开始。
    private void OnEnable() { GameEvents.Bus.Subscribe<DamageEvent>(OnDamage); } // 启用时订阅伤害事件。
    private void OnDisable() { GameEvents.Bus.Unsubscribe<DamageEvent>(OnDamage); } // 禁用时取消订阅,避免泄漏。
    private void OnDamage(DamageEvent evt) { Debug.Log("Damage = " + evt.Damage); } // 收到事件后刷新表现。
} // DamageView 结束。

面试加分点

类型安全事件系统不是“全局乱发消息”,要控制模块边界;事件顺序通常按订阅顺序;派发中增删监听者要额外处理;高频事件要关注 GC;静态事件总线一定要成对退订。

C# 中如何实现对象池泛型版本?

csharp-generic-object-pool

标准答案

C# 泛型对象池一般写成 ObjectPool<T>:内部用 Stack<T> 保存空闲对象,用 Func<T> 创建对象,用 Action<T> 做取出和归还时的初始化/重置。核心 API 通常是 PrewarmGetReleaseClear

对象池的重点不是“存一堆对象”,而是完整生命周期:提前创建、取出使用、归还重置、防重复归还、限制最大容量、切场景清理。

c
using System; // 引入 Func 和 Action 委托。
using System.Collections.Generic; // 引入 Stack 和 HashSet 容器。

public sealed class ObjectPool<T> where T : class // 定义一个只管理引用类型的泛型对象池。
{ // 类开始。
    private readonly Stack<T> idleObjects; // 保存空闲对象,后进先出,取还都很快。
    private readonly HashSet<T> inPool; // 记录哪些对象已经在池中,用来防止重复归还。
    private readonly Func<T> createFunc; // 保存对象创建函数,池子不直接依赖具体类型。
    private readonly Action<T> onGet; // 保存取出对象时的初始化回调。
    private readonly Action<T> onRelease; // 保存归还对象时的重置回调。
    private readonly Action<T> onDestroy; // 保存池子满时销毁对象的回调。
    private readonly int maxSize; // 保存池子最大容量,避免无限常驻内存。
    public int CountAll { get; private set; } // 记录总共创建过多少个对象。
    public int CountInactive => idleObjects.Count; // 返回当前空闲对象数量。

    public ObjectPool(Func<T> createFunc, Action<T> onGet = null, Action<T> onRelease = null, Action<T> onDestroy = null, int defaultCapacity = 16, int maxSize = 128) // 构造对象池。
    { // 构造函数开始。
        this.createFunc = createFunc ?? throw new ArgumentNullException(nameof(createFunc)); // 创建函数不能为空。
        this.onGet = onGet; // 保存取出回调。
        this.onRelease = onRelease; // 保存归还回调。
        this.onDestroy = onDestroy; // 保存销毁回调。
        this.maxSize = Math.Max(1, maxSize); // 保证最大容量至少为 1。
        idleObjects = new Stack<T>(defaultCapacity); // 初始化空闲栈。
        inPool = new HashSet<T>(); // 初始化重复归还检测集合。
    } // 构造函数结束。

    public void Prewarm(int count) // 预热指定数量的对象。
    { // Prewarm 开始。
        for (int i = 0; i < count; i++) // 循环创建对象。
        { // for 开始。
            T obj = createFunc(); // 创建一个新对象。
            CountAll++; // 总创建数量加一。
            onRelease?.Invoke(obj); // 预热对象默认放入空闲状态。
            idleObjects.Push(obj); // 把对象压入空闲栈。
            inPool.Add(obj); // 标记对象已经在池中。
        } // for 结束。
    } // Prewarm 结束。

    public T Get() // 从池中取出对象。
    { // Get 开始。
        T obj = idleObjects.Count > 0 ? idleObjects.Pop() : createFunc(); // 有空闲对象就复用,否则创建。
        inPool.Remove(obj); // 取出后不再属于空闲池。
        if (CountAll < maxSize && !inPool.Contains(obj)) CountAll++; // 示例统计创建数量。
        onGet?.Invoke(obj); // 调用取出初始化逻辑。
        return obj; // 返回可使用对象。
    } // Get 结束。

    public void Release(T obj) // 把对象归还给池。
    { // Release 开始。
        if (obj == null) return; // 空对象不处理。
        if (inPool.Contains(obj)) return; // 已经在池中说明重复归还,直接忽略。
        onRelease?.Invoke(obj); // 归还前重置对象状态。
        if (idleObjects.Count >= maxSize) // 如果空闲对象已经达到上限。
        { // if 开始。
            onDestroy?.Invoke(obj); // 执行销毁逻辑。
            CountAll--; // 总数量减少。
            return; // 不再放回池中。
        } // if 结束。
        idleObjects.Push(obj); // 放回空闲栈。
        inPool.Add(obj); // 标记对象已经在池中。
    } // Release 结束。

    public void Clear() // 清空对象池。
    { // Clear 开始。
        while (idleObjects.Count > 0) // 遍历所有空闲对象。
        { // while 开始。
            T obj = idleObjects.Pop(); // 取出一个空闲对象。
            onDestroy?.Invoke(obj); // 执行销毁逻辑。
        } // while 结束。
        inPool.Clear(); // 清空重复归还检测集合。
        CountAll = 0; // 重置总数量。
    } // Clear 结束。
} // 类结束。

Unity 里怎么用

比如子弹池:createFuncInstantiate(prefab)onGetSetActive(true)onRelease 里清速度、停协程、解绑事件、SetActive(false)onDestroyDestroy(gameObject)

面试加分点

对象池是用“内存常驻”换“减少频繁创建销毁和 GC”。要说清楚:什么时候回收、归还时如何重置、如何防重复归还、池子过大怎么释放、切场景时如何清理。Unity 大多数对象池操作应放主线程做。

C# 中如何实现组件缓存?

csharp-unity-component-cache

标准答案

C# / Unity 里的组件缓存,就是把常用的 Component 引用提前保存到字段里,避免在 Update、循环、批量对象里反复 GetComponentFindCamera.main 这类查找。

最常见做法是:能在 Inspector 拖引用就拖引用;不能拖就 Awake 缓存;动态对象可以用懒加载;组件种类很多时可以封装一个泛型缓存。

底层原理

GetComponent<T>() 不是直接读字段,它需要在当前 GameObject 的组件列表里查找目标类型。偶尔调用一次问题不大,但如果每帧、每个子弹、每个怪物、每个 UI Item 都查,就会把查找成本放大。组件缓存的本质是:把查找成本从热路径提前到初始化阶段。

代码示例:泛型组件缓存

c
using System; // 引入 Type 类型。
using System.Collections.Generic; // 引入 Dictionary 容器。
using UnityEngine; // 引入 Unity 组件相关类型。

public sealed class ComponentCache : MonoBehaviour // 定义一个组件缓存器。
{ // 类开始。
    private readonly Dictionary<Type, Component> cache = new Dictionary<Type, Component>(8); // 用 Type 作为 key 缓存组件引用。

    public T GetCached<T>() where T : Component // 获取指定类型的组件缓存。
    { // 方法开始。
        Type type = typeof(T); // 获取组件类型。
        if (cache.TryGetValue(type, out Component cached) && cached != null) // 如果缓存里已经有有效组件。
        { // if 开始。
            return (T)cached; // 直接返回缓存组件。
        } // if 结束。
        if (TryGetComponent(out T component)) // 如果当前对象上能找到这个组件。
        { // if 开始。
            cache[type] = component; // 把组件保存进缓存。
            return component; // 返回刚找到的组件。
        } // if 结束。
        return null; // 没找到就返回 null。
    } // 方法结束。

    public void ClearCache() // 清空组件缓存。
    { // 方法开始。
        cache.Clear(); // 清除所有缓存引用。
    } // 方法结束。

    private void OnDestroy() // 对象销毁时调用。
    { // 方法开始。
        ClearCache(); // 清理缓存,避免残留引用。
    } // 方法结束。
} // 类结束。

public sealed class PlayerMove : MonoBehaviour // 定义一个使用组件缓存的移动脚本。
{ // 类开始。
    private ComponentCache componentCache; // 保存组件缓存器引用。
    private Rigidbody rb; // 保存 Rigidbody 缓存引用。

    private void Awake() // 初始化阶段调用。
    { // 方法开始。
        componentCache = GetComponent<ComponentCache>(); // 获取组件缓存器。
        rb = componentCache.GetCached<Rigidbody>(); // 从缓存器获取 Rigidbody。
    } // 方法结束。

    private void FixedUpdate() // 物理帧更新。
    { // 方法开始。
        if (rb == null) return; // 如果没有 Rigidbody 就直接返回。
        rb.AddForce(Vector3.forward * 10f); // 使用缓存好的 Rigidbody 执行移动逻辑。
    } // 方法结束。
} // 类结束。

Unity 工程实践

角色、怪物、子弹、UI 窗口、血条、特效对象都适合做组件缓存。比如 AnimatorRigidbodyColliderCanvasGroupTextMeshProUGUIImage 这些高频访问组件,最好在 Awake 或初始化函数里拿一次。

常见坑

缓存不是越多越好,偶尔调用一次 GetComponent 不一定是瓶颈。真正要避免的是热路径反复查找。对象池复用时也要注意,如果对象的父节点、绑定目标、UI 数据源发生变化,缓存引用可能需要重新绑定。

C# 中如何设计可取消异步任务?

csharp-cancellable-async-task

标准答案

C# 可取消异步任务的核心是 CancellationTokenSource + CancellationTokenCancellationTokenSource 负责发出取消信号,CancellationToken 作为参数传给每一层异步方法,任务在 await、循环、IO 等安全点检查取消并主动退出。

重点:取消不是强杀线程,而是“协作式取消”。

底层原理

CancellationTokenSource.Cancel() 只是把 token 标记成“已取消”。真正停止发生在两种地方:一种是异步 API 自己支持 token,比如 Task.Delay(1000, token);另一种是业务代码主动调用 token.ThrowIfCancellationRequested()

如果取消发生,通常会抛出 OperationCanceledException。这个异常要和真正的网络失败、资源加载失败区分开,因为“用户关闭页面导致取消”不是错误。

Unity 工程实践

比如 UI 页面关闭、切场景、对象销毁、资源加载超时,都应该取消异步任务,避免异步回调回来以后继续操作已经销毁的 UI 或 GameObject。资源加载如果底层 API 不支持真正取消,也要做到“取消后忽略结果,并释放资源句柄”。

c
using System; // 引入 Exception 和 ReferenceEquals。
using System.Threading; // 引入 CancellationTokenSource 和 CancellationToken。
using System.Threading.Tasks; // 引入 Task 异步任务。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug。

public sealed class CancellableTaskExample : MonoBehaviour // 定义一个可取消异步任务示例组件。
{ // 类开始。
    private CancellationTokenSource lifeCts; // 保存生命周期取消源,用来表示对象是否销毁。
    private CancellationTokenSource currentCts; // 保存当前正在执行的任务取消源。

    private void Awake() // 组件初始化时调用。
    { // Awake 开始。
        lifeCts = new CancellationTokenSource(); // 创建生命周期取消源。
    } // Awake 结束。

    public void StartLoad() // 开始一次异步加载。
    { // StartLoad 开始。
        CancelCurrent(); // 先取消上一次未完成的任务。
        CancellationTokenSource runCts = CancellationTokenSource.CreateLinkedTokenSource(lifeCts.Token); // 把当前任务和生命周期取消绑定。
        runCts.CancelAfter(5000); // 设置 5 秒超时取消。
        currentCts = runCts; // 记录当前任务取消源。
        _ = RunLoadAsync(runCts); // 启动异步任务,内部自己处理异常。
    } // StartLoad 结束。

    public void CancelCurrent() // 取消当前任务。
    { // CancelCurrent 开始。
        if (currentCts == null) return; // 没有任务就直接返回。
        if (!currentCts.IsCancellationRequested) currentCts.Cancel(); // 没取消过才发送取消信号。
    } // CancelCurrent 结束。

    private async Task RunLoadAsync(CancellationTokenSource runCts) // 执行真正的异步逻辑。
    { // RunLoadAsync 开始。
        CancellationToken token = runCts.Token; // 从取消源中取出 token。
        try // 捕获异步过程中的异常。
        { // try 开始。
            token.ThrowIfCancellationRequested(); // 开始前检查是否已经取消。
            string data = await LoadDataAsync(token); // 把 token 继续传给下一层异步方法。
            token.ThrowIfCancellationRequested(); // await 返回后再次检查取消。
            Debug.Log("Load success: " + data); // 没取消才继续刷新表现。
        } // try 结束。
        catch (OperationCanceledException) when (token.IsCancellationRequested) // 单独捕获取消异常。
        { // catch 取消开始。
            Debug.Log("Load canceled"); // 取消通常不是错误,只记录即可。
        } // catch 取消结束。
        catch (Exception e) // 捕获真正的失败。
        { // catch 失败开始。
            Debug.LogException(e); // 真正失败才打印错误。
        } // catch 失败结束。
        finally // 无论成功、失败、取消都要清理。
        { // finally 开始。
            if (ReferenceEquals(currentCts, runCts)) currentCts = null; // 如果还是当前任务,就清空引用。
            runCts.Dispose(); // 释放 CancellationTokenSource 内部资源。
        } // finally 结束。
    } // RunLoadAsync 结束。

    private async Task<string> LoadDataAsync(CancellationToken token) // 模拟一层异步加载方法。
    { // LoadDataAsync 开始。
        token.ThrowIfCancellationRequested(); // 进入方法时检查取消。
        await Task.Delay(1000, token); // 模拟异步等待,并让等待本身支持取消。
        token.ThrowIfCancellationRequested(); // 等待结束后再次检查取消。
        return "player data"; // 返回加载结果。
    } // LoadDataAsync 结束。

    private void OnDestroy() // 对象销毁时调用。
    { // OnDestroy 开始。
        lifeCts.Cancel(); // 通知所有绑定生命周期的任务取消。
        CancelCurrent(); // 取消当前任务。
        lifeCts.Dispose(); // 释放生命周期取消源。
        lifeCts = null; // 清空生命周期取消源引用。
    } // OnDestroy 结束。
} // 类结束。

面试加分点

可取消异步任务要做到:token 层层传递、取消和失败分开处理、finally 清理资源、超时取消、生命周期取消、重复任务取消旧请求。千万不要说用 Thread.Abort,那是暴力中断,容易破坏状态和资源释放。

C# 中如何处理 async 异常?

csharp-async-exception-handling

标准答案

async Task 里的异常不会马上丢失,它会被保存到返回的 Task 里;调用方 await 这个 Task 时,异常会被重新抛出,所以最推荐的写法是:异步方法返回 Task,调用方用 try / catch 包住 await

async void 比较危险,因为调用方拿不到 Task,也就没法 await 和捕获异常。它只适合事件回调,而且内部必须自己 try / catch

底层原理

编译器会把 async 方法编译成状态机。方法内部抛异常时,状态机会捕获异常并把 Task 标记为 Faulted。当调用方 await task 时,await 会检查任务状态,如果是失败状态,就把异常重新抛出来。

取消异常要单独处理:OperationCanceledException 通常代表用户取消、切场景、对象销毁,不应该和真正的网络失败、解析失败、空引用混在一起。

c
using System; // 引入 Exception 和 InvalidOperationException。
using System.Threading; // 引入 CancellationToken 和 CancellationTokenSource。
using System.Threading.Tasks; // 引入 Task 异步任务。
using UnityEngine; // 引入 MonoBehaviour 和 Debug。

public sealed class AsyncExceptionExample : MonoBehaviour // 定义一个 Unity 异步异常处理示例。
{ // 类开始。
    private CancellationTokenSource cts; // 保存当前对象生命周期的取消源。

    private void OnEnable() // 对象启用时调用。
    { // OnEnable 开始。
        cts = new CancellationTokenSource(); // 创建取消源。
        _ = RunSafeAsync(OpenPanelAsync(cts.Token)); // 启动异步任务,并用安全包装器接住异常。
    } // OnEnable 结束。

    private void OnDisable() // 对象禁用时调用。
    { // OnDisable 开始。
        cts?.Cancel(); // 通知异步任务取消。
        cts?.Dispose(); // 释放取消源内部资源。
        cts = null; // 清空取消源引用。
    } // OnDisable 结束。

    private async Task OpenPanelAsync(CancellationToken token) // 定义一个可等待的异步 UI 打开流程。
    { // OpenPanelAsync 开始。
        try // 捕获异步过程中的异常。
        { // try 开始。
            string data = await LoadDataAsync(token); // await 会在失败时重新抛出异常。
            token.ThrowIfCancellationRequested(); // await 返回后再次检查是否取消。
            Debug.Log("UI data = " + data); // 没取消且没失败时刷新 UI。
        } // try 结束。
        catch (OperationCanceledException) when (token.IsCancellationRequested) // 单独处理取消异常。
        { // 取消 catch 开始。
            Debug.Log("Open panel canceled"); // 取消通常不是错误。
        } // 取消 catch 结束。
        catch (Exception e) // 捕获真正的业务失败。
        { // 异常 catch 开始。
            Debug.LogException(e); // 记录异常,方便定位问题。
        } // 异常 catch 结束。
        finally // 无论成功、失败、取消都会执行。
        { // finally 开始。
            Debug.Log("Hide loading"); // 收尾逻辑,比如关闭 loading。
        } // finally 结束。
    } // OpenPanelAsync 结束。

    private async Task<string> LoadDataAsync(CancellationToken token) // 定义模拟加载数据的异步方法。
    { // LoadDataAsync 开始。
        await Task.Delay(1000, token); // 模拟异步等待,并支持取消。
        throw new InvalidOperationException("Mock load failed"); // 模拟加载失败。
    } // LoadDataAsync 结束。

    private static async Task RunSafeAsync(Task task) // 定义 Fire-and-forget 的安全包装器。
    { // RunSafeAsync 开始。
        try // 捕获未 await 任务中的异常。
        { // try 开始。
            await task; // 观察 Task,异常会在这里重新抛出。
        } // try 结束。
        catch (Exception e) // 捕获异步任务异常。
        { // catch 开始。
            Debug.LogException(e); // 记录异常,避免静默丢失。
        } // catch 结束。
    } // RunSafeAsync 结束。
} // 类结束。

Unity 面试说法

在 Unity 里,异步加载 UI、资源、网络数据时,我会让方法返回 Task,在调用处 awaittry / catch。如果确实是 Fire-and-forget,比如按钮点击后启动一个后台任务,也会包一层 RunSafeAsync,保证异常被记录。对象销毁或切场景时用 CancellationToken 取消,避免异步回来后操作已经销毁的 UI。

C++ 中为什么需要移动语义?

cpp-move-semantics

标准答案

C++ 需要移动语义,是因为很多对象内部持有资源,比如堆内存、文件句柄、Socket、纹理、Mesh 数据、unique_ptr。如果这些对象只是临时对象,或者马上要交给另一个对象管理,深拷贝就很浪费。移动语义可以直接“转移资源所有权”,把旧对象置为空状态,从而避免昂贵拷贝。

一句话:移动语义就是“能偷资源就别复制资源”。

底层原理

移动语义依赖右值引用 T&&。临时对象、std::move(obj) 之后的对象,可以绑定到右值引用,从而调用移动构造或移动赋值。

std::move 本身不移动,它只是做类型转换,把对象转换成右值;真正移动发生在移动构造函数或移动赋值函数里。

c
#include <algorithm> // 引入 std::copy。
#include <cstddef> // 引入 std::size_t。
#include <utility> // 引入 std::swap。

class Buffer // 定义一个持有堆内存的类。
{ // 类开始。
private: // 私有成员开始。
    std::size_t size_; // 保存数组长度。
    int* data_; // 保存堆内存指针。

public: // 公有成员开始。
    explicit Buffer(std::size_t size) // 普通构造函数。
        : size_(size), data_(size > 0 ? new int[size] : nullptr) // 申请堆内存。
    { // 构造函数开始。
    } // 构造函数结束。

    ~Buffer() // 析构函数。
    { // 析构函数开始。
        delete[] data_; // 释放当前对象拥有的堆内存。
    } // 析构函数结束。

    Buffer(const Buffer& other) // 拷贝构造函数。
        : size_(other.size_), data_(other.size_ > 0 ? new int[other.size_] : nullptr) // 重新申请一块新内存。
    { // 拷贝构造开始。
        std::copy(other.data_, other.data_ + size_, data_); // 把旧对象的数据复制到新内存。
    } // 拷贝构造结束。

    Buffer& operator=(const Buffer& other) // 拷贝赋值函数。
    { // 拷贝赋值开始。
        if (this == &other) return *this; // 处理自赋值。
        Buffer temp(other); // 先拷贝出一个临时对象。
        Swap(temp); // 和临时对象交换资源。
        return *this; // 返回当前对象。
    } // 拷贝赋值结束。

    Buffer(Buffer&& other) noexcept // 移动构造函数。
        : size_(other.size_), data_(other.data_) // 直接接管旧对象的资源。
    { // 移动构造开始。
        other.size_ = 0; // 把旧对象长度置空。
        other.data_ = nullptr; // 把旧对象指针置空,避免重复释放。
    } // 移动构造结束。

    Buffer& operator=(Buffer&& other) noexcept // 移动赋值函数。
    { // 移动赋值开始。
        if (this == &other) return *this; // 处理自移动。
        delete[] data_; // 先释放当前对象原来的资源。
        size_ = other.size_; // 接管旧对象的长度。
        data_ = other.data_; // 接管旧对象的指针。
        other.size_ = 0; // 把旧对象长度置空。
        other.data_ = nullptr; // 把旧对象指针置空。
        return *this; // 返回当前对象。
    } // 移动赋值结束。

    void Swap(Buffer& other) noexcept // 定义资源交换函数。
    { // Swap 开始。
        std::swap(size_, other.size_); // 交换长度。
        std::swap(data_, other.data_); // 交换指针。
    } // Swap 结束。
}; // 类结束。

面试加分点

移动后对象不是“不能用”,而是处于“有效但状态未指定”的状态,至少要能析构和重新赋值。移动构造最好加 noexcept,因为 std::vector 扩容时,如果移动可能抛异常,容器可能退回去用拷贝来保证异常安全。游戏引擎里,大块资源、渲染命令、临时缓冲、资源句柄都很适合用移动语义减少拷贝成本。

C++ 中完美转发解决什么问题?

cpp-perfect-forwarding

标准答案

完美转发解决的是:泛型包装函数接收参数后,再转交给内部函数时,如何保留参数原来的左值/右值属性

如果不用完美转发,右值传进模板函数后,因为参数有了名字,它在函数内部会变成左值,可能导致本来能移动的对象变成拷贝。完美转发用 Args&& 接收参数,再用 std::forward<Args>(args) 原样转发。

底层原理

Args&& 在模板参数推导场景下叫“转发引用”。它配合引用折叠规则,可以同时接收左值和右值。std::forward<T> 会根据模板推导出来的 T 决定到底转成左值还是右值。

std::move 是无条件转右值,std::forward 是有条件转发:原来是左值就继续左值,原来是右值就继续右值。

c
#include <iostream> // 引入输出流。
#include <memory> // 引入 std::unique_ptr 和 std::make_unique。
#include <string> // 引入 std::string。
#include <utility> // 引入 std::move 和 std::forward。

class Enemy // 定义一个敌人类。
{ // Enemy 开始。
public: // 公有成员开始。
    Enemy(const std::string& name, int hp) // 接收左值字符串时调用这个构造函数。
        : name_(name), hp_(hp) // 拷贝 name,并初始化 hp。
    { // 构造函数开始。
        std::cout << "copy name\n"; // 打印拷贝构造路径。
    } // 构造函数结束。

    Enemy(std::string&& name, int hp) // 接收右值字符串时调用这个构造函数。
        : name_(std::move(name)), hp_(hp) // 移动 name,并初始化 hp。
    { // 构造函数开始。
        std::cout << "move name\n"; // 打印移动构造路径。
    } // 构造函数结束。

private: // 私有成员开始。
    std::string name_; // 保存敌人名字。
    int hp_; // 保存敌人血量。
}; // Enemy 结束。

template <typename T, typename... Args> // 定义一个泛型工厂函数模板。
std::unique_ptr<T> MakeObject(Args&&... args) // Args&& 是转发引用,可以接收左值和右值。
{ // MakeObject 开始。
    return std::make_unique<T>(std::forward<Args>(args)...); // 用 std::forward 原样转发参数。
} // MakeObject 结束。

int main() // 程序入口。
{ // main 开始。
    std::string bossName = "Boss"; // 创建一个左值字符串。
    auto boss = MakeObject<Enemy>(bossName, 100); // 左值仍按左值转发,会走拷贝版本。
    auto goblin = MakeObject<Enemy>(std::string("Goblin"), 30); // 右值仍按右值转发,会走移动版本。
    return 0; // 返回 0 表示程序正常结束。
} // main 结束。

游戏开发场景

完美转发常用于 emplace_backmake_unique、对象工厂、对象池原地构造、事件系统参数转发。比如引擎里创建组件、命令对象、资源句柄时,希望参数直接传给目标构造函数,中间不要多拷贝。

常见坑

不要把 std::forward 当成 std::move 用。std::move 是强制移动,可能把左值也移动掉;std::forward 是保持原来的值类别。还有一点:同一个参数不要 forward 多次,因为如果它是右值,第一次转发后资源可能已经被移动走了。

C++ 中虚函数表什么时候创建?

cpp-vtable-creation-time

标准答案

虚函数表不是在 new 对象时才临时创建的。更准确地说:

vtable 的布局通常在编译期由编译器决定,并作为静态数据放进目标文件,经过链接后进入可执行文件或动态库;程序加载后,虚表已经在内存里了。真正运行时发生的是:对象构造时,构造函数里会写入隐藏的 vptr,让对象指向对应类的虚函数表。

底层原理

一个类只要有 virtual 函数,主流编译器通常会为这个多态类生成虚函数表。虚表里保存虚函数入口地址。对象本身不会保存整张表,而是保存一个隐藏指针 vptr,指向对应类的虚表。

所以面试可以这样说:

vtable 通常是类级别的,编译期生成;vptr 是对象级别的,构造对象时设置。

c
#include <iostream> // 引入输出流。

class Base // 定义一个带虚函数的基类。
{ // Base 开始。
public: // 公有成员开始。
    Base() { std::cout << "Base ctor\n"; } // 构造 Base 子对象时,vptr 通常先指向 Base 的虚表。
    virtual void Foo() { std::cout << "Base Foo\n"; } // virtual 函数通常会在虚表中占一个槽位。
    virtual ~Base() { std::cout << "Base dtor\n"; } // 虚析构保证通过基类指针删除时能完整析构。
}; // Base 结束。

class Derived : public Base // 定义派生类。
{ // Derived 开始。
public: // 公有成员开始。
    Derived() { std::cout << "Derived ctor\n"; } // 构造 Derived 阶段,vptr 通常会改为指向 Derived 的虚表。
    void Foo() override { std::cout << "Derived Foo\n"; } // override 后,Derived 虚表对应槽位指向这个函数。
    ~Derived() override { std::cout << "Derived dtor\n"; } // 析构 Derived 后,会继续进入 Base 析构阶段。
}; // Derived 结束。

int main() // 程序入口。
{ // main 开始。
    Derived obj; // 创建对象时会执行构造函数,并设置对象内部隐藏的 vptr。
    Base* ptr = &obj; // 用基类指针指向派生类对象。
    ptr->Foo(); // 通过 vptr 找到虚表,再调用 Derived::Foo。
    return 0; // 程序正常结束。
} // main 结束。

面试加分点

构造和析构期间,vptr 可能会随着当前构造/析构的子对象变化,所以在构造函数或析构函数里调用虚函数,不会表现成完整派生类的多态调用。

还有一句要记住:C++ 标准没有规定一定必须有“虚表”这个结构,虚表是主流编译器实现动态多态的方式。面试里讲虚表可以,但要知道它属于实现细节。

C++ 中对象切片是什么?

cpp-object-slicing

标准答案

C++ 对象切片就是:把派生类对象按值赋给基类对象时,只复制了基类那一部分,派生类自己的成员被丢掉了。切片之后,新对象真实类型已经是 Base,所以虚函数也不会再动态派发到 Derived

下载 SVG 图

底层原理

Derived 对象内部可以理解为包含一个 Base 子对象,再加上 Derived 自己的字段。 当你写 Base b = derived; 时,目标对象类型就是 Base,所以只会构造 Base 那部分,Derived 的字段和动态类型都没了。

c
#include <iostream> // 引入标准输出流。

class Base // 定义基类。
{ // Base 开始。
public: // 公有成员开始。
    virtual void Print() const // 定义虚函数。
    { // Print 开始。
        std::cout << "Base\n"; // 输出 Base。
    } // Print 结束。
    virtual ~Base() = default; // 定义虚析构,保证多态删除安全。
}; // Base 结束。

class Derived : public Base // 定义派生类。
{ // Derived 开始。
private: // 私有成员开始。
    int extra_; // 派生类自己的额外数据。
public: // 公有成员开始。
    explicit Derived(int extra) // 定义构造函数。
        : extra_(extra) // 初始化派生类数据。
    { // 构造函数开始。
    } // 构造函数结束。
    void Print() const override // 重写基类虚函数。
    { // Print 开始。
        std::cout << "Derived extra = " << extra_ << "\n"; // 输出派生类数据。
    } // Print 结束。
}; // Derived 结束。

void PrintByValue(Base obj) // 按值传参,会发生对象切片。
{ // PrintByValue 开始。
    obj.Print(); // obj 已经是 Base 对象,所以调用 Base::Print。
} // PrintByValue 结束。

void PrintByRef(const Base& obj) // 按引用传参,不会复制对象。
{ // PrintByRef 开始。
    obj.Print(); // 保留真实类型,所以会动态派发到 Derived::Print。
} // PrintByRef 结束。

int main() // 程序入口。
{ // main 开始。
    Derived d(100); // 创建一个 Derived 对象。
    Base b = d; // 发生对象切片,只保留 Base 部分。
    b.Print(); // 输出 Base。
    PrintByValue(d); // 发生对象切片,输出 Base。
    PrintByRef(d); // 不发生切片,输出 Derived extra = 100。
    return 0; // 程序正常结束。
} // main 结束。

怎么避免

多态对象不要按值传递,应该用 Base&Base*std::unique_ptr<Base>std::shared_ptr<Base>。容器里也不要写 std::vector<Base> 存派生类对象,应该写 std::vector<std::unique_ptr<Base>>

面试加分点

virtual 不能阻止对象切片,因为切片发生在“按值构造 Base 对象”的时候。切完以后对象已经真的是 Base 了,虚函数表也对应 Base。如果确实需要“复制一个多态对象”,常见做法是提供虚函数 clone()

C++ 中析构函数抛异常有什么问题?

cpp-destructor-throw-exception

标准答案

C++ 里析构函数不应该抛异常。因为析构函数通常用于 RAII 清理资源,而且经常在异常传播、栈展开过程中被自动调用。如果析构函数再抛出异常,可能触发 std::terminate,程序直接终止。

C++11 以后,析构函数通常默认是 noexcept 的;从 noexcept 析构函数里抛异常,也会直接终止程序。

底层原理

假设业务代码已经抛了一个异常,程序开始栈展开,局部对象会被析构。如果某个析构函数又抛出第二个异常,运行时同时面对两个未处理异常,无法安全继续,就会调用 std::terminate

所以析构函数应该尽量 noexcept,内部自己兜底处理错误。真正需要把失败告诉调用方时,应该提供显式的 Close()Commit()Flush() 这类函数,让调用方在对象析构前主动处理。

c
#include <exception> // 引入 std::exception。
#include <iostream> // 引入 std::cerr。
#include <stdexcept> // 引入 std::runtime_error。

class FileResource // 定义一个文件资源类。
{ // 类开始。
public: // 公有成员开始。
    ~FileResource() noexcept // 析构函数声明为 noexcept,保证析构不向外抛异常。
    { // 析构函数开始。
        try // 尝试执行资源清理。
        { // try 开始。
            CloseNoThrow(); // 调用内部清理函数。
        } // try 结束。
        catch (const std::exception& e) // 捕获清理过程中可能出现的标准异常。
        { // catch 开始。
            std::cerr << "destructor cleanup failed: " << e.what() << "\n"; // 记录错误,不继续抛出。
        } // catch 结束。
        catch (...) // 捕获所有非标准异常。
        { // catch 开始。
            std::cerr << "destructor cleanup failed: unknown error\n"; // 记录未知错误。
        } // catch 结束。
    } // 析构函数结束。

    bool Close() // 提供显式关闭接口,让调用方可以主动处理失败。
    { // Close 开始。
        if (closed_) return true; // 如果已经关闭,就直接返回成功。
        closed_ = true; // 标记资源已经关闭。
        return true; // 返回关闭结果。
    } // Close 结束。

private: // 私有成员开始。
    bool closed_ = false; // 记录资源是否已经关闭。

    void CloseNoThrow() // 定义析构时使用的内部清理函数。
    { // CloseNoThrow 开始。
        if (closed_) return; // 如果已经关闭,就不重复处理。
        closed_ = true; // 标记资源已经关闭。
    } // CloseNoThrow 结束。
}; // 类结束。

void UseResource() // 定义一个使用资源的函数。
{ // 函数开始。
    FileResource file; // 创建局部资源对象。
    throw std::runtime_error("business error"); // 模拟业务异常触发栈展开。
} // 函数结束。

面试加分点

析构函数的职责是“收拾残局”,不是制造新的异常。游戏引擎里纹理句柄、文件句柄、锁、临时缓冲、GPU 资源包装对象,都应该保证析构兜底安全。资源释放失败可以记录日志、打错误码、延迟回收,但不要从析构函数继续抛。

C++ 中如何避免头文件污染?

cpp-avoid-header-pollution

标准答案

C++ 头文件污染,指的是头文件把不该暴露的东西扩散给所有 #include 它的文件,比如 using namespace、宏、重型依赖、全局变量定义、平台头文件、实现细节等。

避免思路就是:头文件只放最小必要接口,具体实现和重依赖尽量放到 .cpp

底层原理

#include 本质接近文本展开。一个头文件如果写了 using namespace std;,那么所有包含它的 .cpp 都会被迫引入这些名字。一个头文件如果包含了大量平台、渲染、物理库头文件,也会让所有依赖它的文件编译变慢。

常用做法

  1. 头文件不要写 using namespace
  2. 能前置声明就前置声明。
  3. 重型 include 放到 .cpp
  4. 宏尽量不用,必须用就加项目前缀,用完及时 #undef
  5. inline 函数定义不要放头文件。
  6. 全局变量头文件里用 extern 声明,.cpp 里定义。
  7. 私有实现用 PImpl 隔离。
  8. #pragma once 或 include guard 防止重复包含。
c
#pragma once // 防止头文件被重复包含。

#include <memory> // 这里只包含公开接口确实需要的智能指针头文件。

namespace engine // 把公开 API 放进项目命名空间,避免全局命名污染。
{ // 命名空间开始。

class Texture; // 前置声明 Texture,避免在头文件 include Texture.h。
class PlatformRenderer; // 前置声明平台渲染器,隐藏平台相关依赖。

class Renderer // 定义公开渲染器接口。
{ // Renderer 开始。
public: // 公有接口开始。
    Renderer(); // 构造函数只声明,具体实现放到 cpp。
    ~Renderer(); // 析构函数只声明,避免在头文件暴露 Impl 完整定义。
    void Draw(const Texture& texture); // 参数用引用,前置声明就够用。
private: // 私有成员开始。
    class Impl; // 前置声明私有实现类。
    std::unique_ptr<Impl> impl_; // 用 PImpl 把实现细节藏到 cpp。
}; // Renderer 结束。

} // namespace engine 结束。
#include "Renderer.h" // cpp 先包含自己的头文件,检查头文件是否自洽。
#include "Texture.h" // cpp 里需要完整 Texture 定义,所以放这里 include。
#include "PlatformRenderer.h" // 平台相关重依赖只污染当前 cpp。

namespace engine // 进入项目命名空间。
{ // 命名空间开始。

class Renderer::Impl // 在 cpp 里定义私有实现类。
{ // Impl 开始。
public: // 公有成员开始。
    void Draw(const Texture& texture) // 实现真正绘制逻辑。
    { // Draw 开始。
        (void)texture; // 示例代码里暂时不使用 texture,避免未使用警告。
    } // Draw 结束。
}; // Impl 结束。

Renderer::Renderer() // 定义 Renderer 构造函数。
    : impl_(std::make_unique<Impl>()) // 在 cpp 里创建私有实现对象。
{ // 构造函数开始。
} // 构造函数结束。

Renderer::~Renderer() = default; // 析构函数放 cpp,让 Impl 在这里是完整类型。

void Renderer::Draw(const Texture& texture) // 定义公开绘制接口。
{ // Draw 开始。
    impl_->Draw(texture); // 转发给私有实现。
} // Draw 结束。

} // namespace engine 结束。

面试加分点

#pragma once 或 include guard 只能防止重复包含,不能解决命名空间污染、宏污染和依赖扩散。真正的头文件治理是控制接口边界:头文件像“合同”,.cpp 像“实现”。游戏引擎里渲染、物理、平台层、第三方 SDK 的头文件尤其要隔离,否则编译时间和跨平台冲突会很痛。

C++ 中如何减少编译依赖?

cpp-reduce-compile-dependencies

标准答案

C++ 减少编译依赖的核心是:让头文件尽量轻,只暴露稳定接口,把重依赖和实现细节放到 .cpp。因为 #include 本质接近文本展开,一个公共头文件改动,所有包含它的 .cpp 都可能重新编译。

常用手段:前置声明、PImpl、拆分 public/private 头文件、少写模板和 inline 重逻辑、重型库只在 .cpp include,必要时配合 PCH 或 C++20 Modules。

代码示例:头文件只暴露接口

c
#pragma once // 防止头文件重复包含。

#include <memory> // 头文件中使用 std::unique_ptr,所以需要包含 memory。

namespace engine // 使用项目命名空间,减少全局名字冲突。
{ // 命名空间开始。

class Texture; // 前置声明 Texture,避免头文件包含 Texture.h。
class PlatformRenderer; // 前置声明平台渲染器,避免平台头文件扩散。

class Renderer // 定义渲染器公开接口。
{ // Renderer 类开始。
public: // 公有接口开始。
    Renderer(); // 构造函数只声明,实现放到 cpp。
    ~Renderer(); // 析构函数只声明,让 Impl 完整定义留在 cpp。
    void Draw(const Texture& texture); // 参数用引用,前置声明就够用。

private: // 私有成员开始。
    class Impl; // 前置声明私有实现类。
    std::unique_ptr<Impl> impl_; // 用 PImpl 隐藏私有实现和重依赖。
}; // Renderer 类结束。

} // engine 命名空间结束。
#include "Renderer.h" // cpp 先包含自己的头文件,检查头文件是否自洽。
#include "Texture.h" // cpp 需要 Texture 完整定义,所以在这里包含。
#include "PlatformRenderer.h" // 平台重依赖只污染当前 cpp。

namespace engine // 进入项目命名空间。
{ // 命名空间开始。

class Renderer::Impl // 在 cpp 中定义私有实现。
{ // Impl 类开始。
public: // 公有成员开始。
    void Draw(const Texture& texture) // 实现真正绘制逻辑。
    { // Draw 函数开始。
        (void)texture; // 示例中暂时不使用参数,避免未使用警告。
    } // Draw 函数结束。
}; // Impl 类结束。

Renderer::Renderer() // 定义 Renderer 构造函数。
    : impl_(std::make_unique<Impl>()) // 创建私有实现对象。
{ // 构造函数开始。
} // 构造函数结束。

Renderer::~Renderer() = default; // 在 cpp 定义析构,此时 Impl 是完整类型。

void Renderer::Draw(const Texture& texture) // 定义公开 Draw 接口。
{ // Draw 函数开始。
    impl_->Draw(texture); // 转发给私有实现。
} // Draw 函数结束。

} // engine 命名空间结束。

面试加分点

前置声明不是万能的:指针、引用可以用前置声明;值成员、继承、sizeof、访问成员函数通常需要完整定义。PImpl 能显著减少编译依赖,但会多一次间接访问和一次堆分配。PCH 能加速稳定大头文件,但不能替代依赖治理。

C++ 中 Pimpl 模式是什么?

cpp-pimpl-pattern

标准答案

PImpl 是 Pointer to Implementation,也叫“编译防火墙”。做法是:公开类的头文件里只放一个指向实现类的指针,比如 std::unique_ptr<Impl>,真正的私有成员、平台依赖、第三方库依赖都放到 .cpp 里的 Impl 类中。

它主要解决三个问题:减少头文件依赖、隐藏实现细节、提高库 ABI 稳定性。

底层原理

普通写法里,如果类的私有成员包含 TexturePlatformRenderer、第三方 SDK 类型,那么头文件必须 include 它们。这样调用方即使只想用 Renderer 接口,也会被迫依赖一堆重头文件。

PImpl 把这些私有成员挪到 .cpp,头文件只保留 Impl 的前置声明和指针。因为指针大小固定,所以头文件不需要知道 Impl 的完整布局。

c
#pragma once // 防止头文件重复包含。

#include <memory> // 使用 std::unique_ptr,所以需要包含 memory。

namespace engine // 使用项目命名空间,避免全局命名污染。
{ // 命名空间开始。

class Texture; // 前置声明 Texture,避免头文件依赖 Texture.h。

class Renderer // 定义公开渲染器类。
{ // Renderer 开始。
public: // 公有接口开始。
    Renderer(); // 声明构造函数,具体实现放到 cpp。
    ~Renderer(); // 声明析构函数,必须放到 cpp 让 Impl 成为完整类型。
    Renderer(Renderer&&) noexcept; // 声明移动构造,允许 Renderer 转移实现对象。
    Renderer& operator=(Renderer&&) noexcept; // 声明移动赋值,允许 Renderer 转移实现对象。
    Renderer(const Renderer&) = delete; // 禁止拷贝,避免多个对象共享同一个 Impl。
    Renderer& operator=(const Renderer&) = delete; // 禁止拷贝赋值,保证资源所有权清晰。
    void Draw(const Texture& texture); // 对外暴露绘制接口。

private: // 私有成员开始。
    class Impl; // 前置声明私有实现类。
    std::unique_ptr<Impl> impl_; // 只保存实现类指针,隐藏真正成员。
}; // Renderer 结束。

} // engine 命名空间结束。
#include "Renderer.h" // cpp 先包含自己的头文件,检查头文件是否自洽。
#include "Texture.h" // cpp 需要 Texture 完整定义,所以在这里包含。
#include "PlatformRenderer.h" // 平台渲染器重依赖只放在 cpp。
#include <memory> // 使用 std::make_unique,所以包含 memory。

namespace engine // 进入项目命名空间。
{ // 命名空间开始。

class Renderer::Impl // 在 cpp 中定义私有实现类。
{ // Impl 开始。
public: // 公有成员开始。
    PlatformRenderer platformRenderer; // 真正的平台渲染对象隐藏在 cpp 中。

    void Draw(const Texture& texture) // 实现真正绘制逻辑。
    { // Draw 开始。
        platformRenderer.Draw(texture); // 调用平台渲染器绘制纹理。
    } // Draw 结束。
}; // Impl 结束。

Renderer::Renderer() // 定义 Renderer 构造函数。
    : impl_(std::make_unique<Impl>()) // 创建私有实现对象。
{ // 构造函数开始。
} // 构造函数结束。

Renderer::~Renderer() = default; // 在 cpp 中定义析构,此时 Impl 已经是完整类型。

Renderer::Renderer(Renderer&&) noexcept = default; // 默认移动构造,转移 unique_ptr。

Renderer& Renderer::operator=(Renderer&&) noexcept = default; // 默认移动赋值,转移 unique_ptr。

void Renderer::Draw(const Texture& texture) // 定义公开 Draw 接口。
{ // Draw 开始。
    impl_->Draw(texture); // 转发给私有实现。
} // Draw 结束。

} // engine 命名空间结束。

面试加分点

PImpl 的收益是减少编译依赖、隐藏实现细节、让动态库接口更稳定。代价是多一次指针间接访问,通常还多一次堆分配,所以热路径上的小对象不一定适合用。游戏引擎里它常用于平台层、渲染后端、音频后端、第三方 SDK 封装这类“依赖重、变化多、需要隔离”的模块。

C++ 中如何设计跨 DLL 接口?

cpp-cross-dll-interface

标准答案

跨 DLL 接口的核心原则是:DLL 边界就是 ABI 边界,只传稳定协议,不传 C++ 实现细节

最稳的做法是导出 extern "C" 函数,用 opaque handle 或纯虚接口做业务调用;不要直接跨 DLL 暴露 STL、异常、模板、C++ 类布局,也不要在一个 DLL 里 new、另一个 DLL 里 delete

为什么不能随便导出 C++ 类

C++ ABI 不稳定:不同编译器、不同 STL 版本、不同运行库、不同编译选项,都可能导致类布局、vtable、异常处理、内存分配规则不一致。 所以跨 DLL 最怕这些东西:std::stringstd::vector、异常、模板类型、直接导出类、跨模块释放内存。

推荐接口头文件示例

c
#pragma once // 防止头文件重复包含。

#include <cstdint> // 使用固定宽度整数类型。

#if defined(_WIN32) // 判断是否是 Windows 平台。
#define PLUGIN_CALL __cdecl // 明确调用约定,避免调用方和 DLL 不一致。
#define PLUGIN_API extern "C" __declspec(dllexport) // 使用 C ABI 导出函数,避免 C++ 名字改编。
#else // 非 Windows 平台走这里。
#define PLUGIN_CALL // 非 Windows 示例中不额外指定调用约定。
#define PLUGIN_API extern "C" __attribute__((visibility("default"))) // 导出默认可见符号。
#endif // 平台宏结束。

struct PluginHandle; // 声明不透明句柄,调用方不知道内部实现布局。

static constexpr std::uint32_t PLUGIN_API_VERSION = 1; // 定义接口版本号。

struct PluginCreateDesc // 定义创建参数结构体。
{ // 结构体开始。
    std::uint32_t size; // 结构体大小,用于版本兼容。
    std::uint32_t version; // 调用方使用的接口版本。
    const char* configPath; // 配置路径,用 C 字符串避免 std::string 跨边界。
}; // 结构体结束。

enum PluginResult : std::int32_t // 定义跨 DLL 返回码。
{ // 枚举开始。
    PluginResult_Ok = 0, // 表示成功。
    PluginResult_InvalidArgument = 1, // 表示参数错误。
    PluginResult_VersionMismatch = 2, // 表示接口版本不匹配。
    PluginResult_InternalError = 3 // 表示 DLL 内部错误。
}; // 枚举结束。

PLUGIN_API PluginResult PLUGIN_CALL Plugin_Create(const PluginCreateDesc* desc, PluginHandle** outHandle); // 在 DLL 内创建对象,并把句柄返回给调用方。

PLUGIN_API PluginResult PLUGIN_CALL Plugin_Update(PluginHandle* handle, float deltaTime); // 通过句柄调用业务逻辑。

PLUGIN_API void PLUGIN_CALL Plugin_Destroy(PluginHandle* handle); // 在同一个 DLL 内销毁对象,避免跨模块 delete。

工程注意点

  1. 内存:谁分配谁释放,提供 Create / Destroy 成对接口。
  2. 异常:DLL 内部 catch (...),对外返回错误码,不让异常跨边界。
  3. 数据:跨边界传 POD、数组、char*、长度,不传 STL 容器。
  4. 版本:结构体带 size/version,新增字段时可以兼容旧调用方。
  5. 二进制兼容:不要随便改虚函数顺序;新增功能用新接口版本。
  6. 调用约定:明确 __cdecl / __stdcall,跨平台用宏封装。

游戏引擎场景

插件系统、渲染后端、音频后端、平台 SDK、热插拔模块都常用这种设计。面试里一句话总结:跨 DLL 接口不是普通 C++ API,要设计成稳定 ABI 协议。

C++ 中如何处理 ABI 兼容?

cpp-abi-compatibility

一句话定义 C++ ABI 兼容就是:旧版本程序已经编译好了,不重新编译,也能继续加载和调用新版 DLL / so / 插件,并且不会因为函数名、参数布局、类布局、虚表、异常、内存分配规则变化而崩。

底层原理 ABI 管的是“二进制层面的约定”,不是源码语法。比如函数导出名、调用约定、参数怎么进寄存器或栈、结构体字段偏移、对齐方式、虚函数表顺序、异常传播方式、运行库、内存由谁分配谁释放,这些一变,旧二进制就可能按旧规则读新内存,直接崩或者读错数据。

C++ 本身 ABI 很脆弱,因为不同编译器、不同 STL、不同编译选项都可能改变类布局和符号名。所以跨 DLL / 插件 / SDK 边界时,通常不要直接暴露 C++ 类、模板、std::stringstd::vector、异常对象。

常见做法

  1. 对外导出尽量用 extern "C",避免 C++ 名字改编。
  2. 用不透明句柄隐藏实现,比如 EnginePlugin* 只声明不暴露结构。
  3. 参数结构体使用 POD,并带 sizeversion
  4. 新字段只追加到末尾,不删除、不改顺序、不改含义。
  5. 不跨模块传 STL 容器、C++ 异常、模板对象。
  6. 谁分配谁释放,对外提供 Destroy / Free 接口。
  7. 异常在模块内部 catch,对外返回错误码。
  8. 虚函数接口如果已经发布,不要随便插入、删除、重排虚函数。更稳的是 C API + 函数表。
  9. 用 ABI diff、旧版本客户端回归测试、语义化版本控制防止误破坏。

代码示例:稳定 C ABI 边界

c
#pragma once // 防止头文件被重复包含。

#include <cstdint> // 使用固定宽度整数类型,避免不同平台 int 大小差异。

#if defined(_WIN32) // 判断当前是否是 Windows 平台。
#define ABI_CALL __cdecl // 固定调用约定,避免调用方和被调用方约定不一致。
#define ABI_EXPORT extern "C" __declspec(dllexport) // 使用 C ABI 导出,避免 C++ 名字改编。
#else // 处理非 Windows 平台。
#define ABI_CALL // 非 Windows 示例中不额外指定调用约定。
#define ABI_EXPORT extern "C" __attribute__((visibility("default"))) // 在 ELF 平台导出默认可见符号。
#endif // 平台判断结束。

struct EnginePlugin; // 声明不透明句柄,不暴露真实类布局。

static constexpr std::uint32_t ENGINE_ABI_VERSION = 1; // 定义当前 ABI 主版本号。

enum EngineResult : std::int32_t // 使用固定底层类型定义错误码。
{ // 枚举开始。
    EngineResult_Ok = 0, // 表示调用成功。
    EngineResult_BadVersion = 1, // 表示 ABI 版本不兼容。
    EngineResult_BadArgument = 2, // 表示调用参数不合法。
    EngineResult_InternalError = 3 // 表示模块内部发生错误。
}; // 枚举结束。

struct EngineCreateDesc // 定义创建插件对象的参数结构。
{ // 结构体开始。
    std::uint32_t size; // 调用方传入结构体大小,用来兼容新旧字段。
    std::uint32_t version; // 调用方声明自己使用的 ABI 版本。
    const char* configPath; // 使用 C 字符串,避免 std::string 跨 ABI 边界。
}; // 结构体结束。

ABI_EXPORT EngineResult ABI_CALL Engine_Create(const EngineCreateDesc* desc, EnginePlugin** outPlugin); // 创建插件对象,由模块内部分配。
ABI_EXPORT EngineResult ABI_CALL Engine_Update(EnginePlugin* plugin, float deltaTime); // 更新插件对象,对外只传稳定类型。
ABI_EXPORT void ABI_CALL Engine_Destroy(EnginePlugin* plugin); // 销毁插件对象,保证谁分配谁释放。

面试加分说法 我会把 ABI 边界当成协议来设计:外部只看到稳定的 C ABI、版本化 POD 数据和不透明句柄;内部实现可以随便重构。只要导出符号、调用约定、结构体布局和内存所有权规则稳定,旧客户端就能继续跑。破坏 ABI 的改动必须升主版本,并用旧版本二进制做回归测试。

图形中为什么需要齐次裁剪?

cpp-graphics-homogeneous-clipping

标准答案 图形中需要齐次裁剪,是因为顶点经过 MVP 变换后还在裁剪空间 clip space,坐标是 (x, y, z, w)。真正进入屏幕前要做透视除法:

x_ndc = x / wy_ndc = y / wz_ndc = z / w

如果不先裁剪,遇到 w 接近 0、顶点在相机后方、三角形跨过近平面,就可能把不可见点除成巨大坐标,导致三角形飞到屏幕外、形状错误、插值错误。所以 GPU 要先在齐次空间里裁剪,再做透视除法。

底层原理 普通 3D 空间里视锥体不是一个简单的 -1 到 1 盒子。经过投影矩阵后,GPU 用 w 来描述可见范围。

以 D3D / Unity 常见裁剪规则为例:

-w <= x <= w-w <= y <= w0 <= z <= w

满足这些条件的点,透视除法后才会落到合法 NDC 范围里。 如果三角形只有一部分在范围内,GPU 会和裁剪平面求交点,生成新的顶点,把不可见部分裁掉。

为什么不能先透视除法再裁剪? 因为透视除法要除以 w。如果点在近平面附近,w 很小,坐标会被放大到非常离谱;如果点在相机后面,w 可能符号异常,图元会被翻转或变形。齐次裁剪就是为了避免“非法点先参与除法”。

Unity / 游戏面试说法 在 Unity Shader 里,顶点通常经过 UnityObjectToClipPosMVP 变到裁剪空间,后面的齐次裁剪、透视除法、视口变换大多由 GPU 固定管线完成。面试里可以强调:齐次裁剪不是优化细节,而是透视投影正确性的必要步骤。它保证了跨近平面的三角形不会炸屏,也保证后续光栅化和插值输入是合法的。

图形中为什么需要透视除法?

cpp-graphics-perspective-divide

标准答案 图形中需要透视除法,是因为顶点经过投影矩阵后得到的是齐次裁剪坐标 (x, y, z, w),它还不是屏幕能直接使用的坐标。GPU 必须执行:

x_ndc = x / wy_ndc = y / wz_ndc = z / w

这样才能把裁剪空间转换到 NDC,也就是归一化设备坐标。更重要的是,w 通常和深度有关,远处点的 w 更大,除完以后更靠近屏幕中心,所以才会出现“近大远小”的透视效果。

底层原理 透视投影不是简单把 3D 坐标压扁到 2D,而是先通过投影矩阵把深度信息放进 w。在透视除法前,一个点是:

c
clip = (x, y, z, w)

除以 w 后变成:

c
ndc = (x / w, y / w, z / w)

比如两个点横向偏移都一样:

near: x = 1, w = 1 => x_ndc = 1far: x = 1, w = 5 => x_ndc = 0.2

远处点除以更大的 w,结果更靠近中心,所以屏幕上看起来更小。

为什么不能省略? 如果没有透视除法,投影后的坐标不会正确进入 NDC,远近大小关系也不对,看起来就会像错误的正交投影。光栅化、屏幕映射、深度处理、透视正确插值都会出问题。

Unity / Shader 里怎么理解 在 Unity 顶点着色器里,我们通常输出 SV_POSITION,也就是裁剪空间坐标。一般不要自己提前乱除以 w,而是交给 GPU 固定管线完成:

c
Object Space -> World Space -> View Space -> Clip Space -> 透视除法 -> NDC -> Screen

面试里可以这样总结:透视除法的作用是“用 w 把齐次裁剪坐标投回 NDC,并让深度产生近大远小效果”。它是透视投影真正成立的关键步骤。

图形中为什么会有 Z-Fighting?

cpp-graphics-z-fighting

标准答案 Z-Fighting 是因为两个面在屏幕同一个像素上算出来的深度值太接近,深度缓冲的精度不够区分谁在前。结果就是这一帧可能 A 面通过深度测试,下一帧可能 B 面通过,画面就会出现闪烁、条纹、抖动。

底层原理 GPU 渲染时会把每个像素的深度写入 Depth Buffer。Depth Buffer 不是无限精度的,常见是 16-bit、24-bit、32-bit。两个面如果共面,或者距离非常近,经过投影和深度量化后可能得到几乎一样甚至同一档的深度值。

透视投影下深度精度通常不是均匀分布的,近处精度高,远处精度低。所以相机 near 太小、far 太大时,远处更容易出现 Z-Fighting。

常见场景 地面和道路贴图完全重合。 墙面上又贴了一层几乎共面的贴花。 两个模型面片重叠。 超大场景里相机裁剪范围设置得太夸张,比如 near = 0.01far = 100000

怎么解决 优先从几何上避免共面,让两个面真正错开一点。 调整相机裁剪面,把 nearClipPlane 适当调远,把 farClipPlane 适当调近。 贴花类效果可以用 Decal 系统,或者 Shader 里的深度偏移。 使用更高精度深度缓冲、反向 Z、合理的渲染管线设置。 不要只靠改渲染顺序硬压,渲染顺序可能暂时遮住问题,但深度精度问题还在。

面试一句话 Z-Fighting 本质是“两个片元深度太接近 + 深度缓冲精度有限”,根治优先改几何和相机 near/far,贴花类需求再考虑 Offset、Decal 或更高精度深度方案。

图形中如何处理透明排序?

cpp-graphics-transparent-sorting

标准答案 透明排序的核心是:不透明物体先画并写入深度,透明物体后画,并通常按“从远到近”排序再做 Alpha Blend。因为透明混合依赖颜色缓冲里已经存在的颜色,绘制顺序不同,最终颜色就可能不同。

底层原理 不透明物体可以开 ZWrite On,谁离相机近谁写入深度,后面的片元会被深度测试挡掉,所以顺序影响较小。

透明物体不一样。常见透明混合公式是:

c
result = src * alpha + dst * (1 - alpha)

这里的 dst 是颜色缓冲里已经画好的颜色。所以如果先画红色透明片,再画蓝色透明片,和先画蓝色再画红色,结果不一样。也因此透明物体通常:

ZTest On:仍然被不透明物体遮挡。 ZWrite Off:避免透明物体把后面的透明物错误挡掉。 Back To Front:从远到近画,让远处颜色先进入颜色缓冲。

Unity 中怎么处理 Unity 里通常靠这些机制控制透明顺序:

Render Queue:不透明一般在 Geometry,透明一般在 TransparentSorting Layer / Order in Layer:常用于 2D、Sprite、UI。 Renderer.sortingOrdersortingLayerID:控制 Renderer 排序。 Material.renderQueue:手动调整材质队列。 粒子系统可以设置 Sorting Mode,比如按距离、按年龄等。 UI 用 Canvas 的 Sorting Order、层级和相机模式控制。

最常见的坑 透明排序按“物体中心点”排序,只能解决大部分情况。两个透明物体互相穿插,或者一个透明模型自身前后交叠时,对象级排序就不可能完全正确。因为同一个物体里,有些像素 A 在前,有些像素 B 在前。

这时可以考虑:

拆分 Mesh,让排序粒度更细。 树叶、栅栏这种硬边透明优先用 Alpha Test / Alpha Clip。 贴花用 Decal,不要直接贴一层共面透明面。 复杂透明可以用 OIT、Depth Peeling、Weighted Blended OIT,但成本更高。 移动端尽量减少大面积半透明和多层粒子,因为 Overdraw 很贵。

面试一句话 透明排序不是简单开深度测试就能解决,因为 Alpha Blend 依赖绘制顺序。常规方案是不透明先写深度,透明后面远到近混合;相交透明和自身透明无法靠对象排序完美解决,需要拆分、Alpha Test、Decal 或 OIT。

图形中为什么法线要归一化?

cpp-graphics-normal-normalization

标准答案 法线要归一化,是因为光照计算需要的是“方向”,不是“长度”。像漫反射常用 dot(N, L),它的数学含义其实是:

N · L = |N| × |L| × cosθ

只有当 NL 都是单位向量时,点乘结果才只表示夹角,也就是表面朝向光源的程度。如果法线长度不是 1,亮度就会被错误放大或缩小,导致太亮、太暗、高光漂移、阴影边界异常。

底层原理 漫反射里常见公式是:

c
diffuse = max(0, dot(normalize(N), normalize(L)))

如果 N 长度是 2,而夹角没变,那么:

c
dot(2N, L) = 2cosθ

这就不是“角度”了,而是把法线长度也混进了亮度。高光更敏感,因为高光通常还会用到 NVH 或反射方向,只要法线不准,高光位置和强度就会明显异常。

为什么法线会变得不是单位长度 顶点法线即使一开始是单位向量,经过光栅化插值后,中间片元的法线也可能不是单位长度。 模型有非均匀缩放时,法线变换后长度和方向都可能变化,所以要用逆转置矩阵变换,再归一化。 法线贴图采样后,从纹理值解码到 [-1, 1],再经过压缩、混合、TBN 变换,也可能不是严格单位长度。 多个法线混合、蒙皮、程序化扰动后,也需要重新 normalize。

Unity / Shader 实践 在 Unity 自写光照 Shader 时,通常会在片元阶段做:

c
N = normalize(normalWS)

如果用了法线贴图,一般流程是:采样法线贴图,解码到切线空间,经过 TBN 转到世界空间,然后再归一化。URP/HDRP 的很多内置函数会帮你处理一部分,但自定义 Shader 里不能默认认为法线一定是单位长度。

面试一句话 法线归一化的目的,是让法线只表达方向,不把长度带进光照。只要法线经过插值、变换、法线贴图、混合,就要确认它还是单位向量,否则 N·L 和高光都会算错。

图形中切线空间如何计算?

cpp-graphics-tangent-space-calculation

标准答案 切线空间就是模型表面上的一个局部坐标系,由三个轴组成:

Tangent T:沿 UV 的 U 方向。 Bitangent B:沿 UV 的 V 方向。 Normal N:垂直于表面。

它的作用是把法线贴图里的方向,从“切线空间”转换到世界空间或视图空间,才能参与光照计算。

计算步骤 对一个三角形,已知三个顶点位置和 UV:

P0, P1, P2UV0, UV1, UV2

先算空间边:

E1 = P1 - P0E2 = P2 - P0

再算 UV 边:

D1 = UV1 - UV0 = (du1, dv1)D2 = UV2 - UV0 = (du2, dv2)

因为空间边可以看成 U 方向和 V 方向的组合:

E1 = T * du1 + B * dv1E2 = T * du2 + B * dv2

解这个 2×2 方程,就能得到:

det = du1 * dv2 - dv1 * du2r = 1 / detT = (E1 * dv2 - E2 * dv1) * rB = (E2 * du1 - E1 * du2) * r

然后要正交化和归一化:

c
T = normalize(T - N * dot(N, T))

B 通常不直接存,而是用:

c
B = cross(N, T) * tangent.w

这里的 tangent.w 表示手性,用来处理镜像 UV 导致的方向翻转。

为什么要 TBN 法线贴图里采样出来的法线,比如偏蓝的 (0.5, 0.5, 1),表示的是“相对于纹理表面”的方向,不是世界空间方向。 所以 Shader 里通常要做:

c
normalWS = T * normalTS.x + B * normalTS.y + N * normalTS.z

这就是用 TBN 把切线空间法线变成世界空间法线。

Unity 里的实践 Unity 的 Mesh 通常会存 normaltangent,其中 tangent.xyz 是切线方向,tangent.w 是手性。 如果使用法线贴图,Shader 里会构造 TBN,把切线空间法线转到世界空间或视图空间。 实际项目里还要注意:建模软件、法线贴图烘焙工具、引擎导入设置最好使用一致的 tangent basis,比如常见的 MikkTSpace,否则容易出现接缝、法线翻转、高光断裂。

常见坑 UV 退化时 det 接近 0,切线计算不可靠。 镜像 UV 会导致 B 方向翻转,需要 tangent.w。 T、B、N 要归一化并保持空间一致。 法线贴图解码后也要 normalize。 模型有非均匀缩放时,法线相关方向要正确变换,否则光照会错。

图形中如何实现描边?

cpp-graphics-outline-rendering

标准答案 图形里的描边,本质是先找出“哪里是边缘”,再把一圈描边颜色合成到画面上。常见做法有三种:模型外扩描边、屏幕空间描边、Stencil 选择描边。

底层原理 模型外扩描边:把模型沿法线方向放大一圈,先渲染这个“外壳”,只画背面,再正常渲染原模型,中心被盖住后就只剩外圈。适合卡通角色、低成本风格化描边。

屏幕空间描边:在后处理里读取深度图、法线图,比较当前像素和周围像素。如果深度或法线差异很大,就认为这里是边缘。适合全场景轮廓、敌人透视轮廓,但有全屏 Pass 成本。

Stencil 描边:先把目标物体写入模板缓冲,再扩一圈或后处理时只在模板边缘绘制颜色。适合选中高亮、交互目标、敌人锁定框。

Unity 常用代码:模型外扩描边 Pass

c
Shader "Interview/OutlineInvertedHull" // 定义一个模型外扩描边 Shader。
{ // Shader 开始。
    Properties // 材质属性开始。
    { // 属性块开始。
        _OutlineColor ("Outline Color", Color) = (0, 0, 0, 1) // 定义描边颜色。
        _OutlineWidth ("Outline Width", Float) = 0.03 // 定义描边宽度。
    } // 属性块结束。
    SubShader // 子着色器开始。
    { // SubShader 开始。
        Tags { "RenderType"="Opaque" } // 声明这是不透明渲染类型。
        Pass // 描边 Pass 开始。
        { // Pass 开始。
            Cull Front // 剔除正面,只显示外扩后的背面壳。
            ZWrite On // 写入深度,让描边能参与遮挡。
            CGPROGRAM // CG 代码开始。
            #pragma vertex vert // 指定顶点着色器函数。
            #pragma fragment frag // 指定片元着色器函数。
            #include "UnityCG.cginc" // 引入 Unity 常用变换函数。
            fixed4 _OutlineColor; // 接收描边颜色属性。
            float _OutlineWidth; // 接收描边宽度属性。
            struct appdata // 定义顶点输入结构。
            { // 输入结构开始。
                float4 vertex : POSITION; // 顶点模型空间位置。
                float3 normal : NORMAL; // 顶点模型空间法线。
            }; // 输入结构结束。
            struct v2f // 定义顶点到片元的数据结构。
            { // 输出结构开始。
                float4 pos : SV_POSITION; // 裁剪空间位置。
            }; // 输出结构结束。
            v2f vert(appdata v) // 顶点着色器开始。
            { // 函数开始。
                v2f o; // 创建输出变量。
                v.vertex.xyz += normalize(v.normal) * _OutlineWidth; // 沿法线方向把模型外扩。
                o.pos = UnityObjectToClipPos(v.vertex); // 把外扩后的顶点变到裁剪空间。
                return o; // 返回顶点输出。
            } // 函数结束。
            fixed4 frag(v2f i) : SV_Target // 片元着色器开始。
            { // 函数开始。
                return _OutlineColor; // 所有外壳片元都输出描边颜色。
            } // 函数结束。
            ENDCG // CG 代码结束。
        } // Pass 结束。
    } // SubShader 结束。
} // Shader 结束。

常见坑点 模型外扩遇到硬边、非均匀缩放、复杂法线时,描边厚度可能不均匀。屏幕空间描边宽度稳定,但需要深度/法线纹理和全屏采样,移动端要注意成本。透明物体描边还会牵扯透明排序和深度写入,不能只靠一个 Pass 硬套。

面试一句话 角色卡通描边我优先考虑模型外扩;选中高亮我倾向 Stencil;如果要给整个场景做统一轮廓,我会用屏幕空间深度/法线边缘检测。关键是先确定边缘来源,再看性能和美术效果取舍。

图形中如何实现高亮选中?

cpp-graphics-selection-highlight

标准答案 高亮选中通常不是单一做法,而是“业务选中状态 + 渲染表现”。简单目标可以改材质颜色、发光、边缘光;更醒目的目标可以加描边;如果要统一选中效果,可以用 Stencil/Mask 再做后处理描边或发光。

底层原理 材质高亮:给选中物体设置额外颜色、Emission、Rim Light。优点是简单便宜,缺点是不够醒目。 描边高亮:模型外扩或屏幕空间描边。优点是识别度高,缺点是多 Pass 或后处理有成本。 Stencil/Mask 高亮:先把选中物体写到模板或 Mask,再统一做描边、发光、透视轮廓。适合选中目标、敌人锁定、交互物提示。

Unity 工程实现示例:用 MaterialPropertyBlock 做高亮

c
using UnityEngine; // 引入 Unity 引擎命名空间。

public sealed class SelectionHighlighter : MonoBehaviour // 定义一个选中高亮组件。
{ // 类开始。
    [SerializeField] private Renderer targetRenderer; // 指定要高亮的 Renderer。
    [SerializeField] private Color normalColor = Color.white; // 配置未选中时的颜色。
    [SerializeField] private Color highlightColor = Color.yellow; // 配置选中时的高亮颜色。
    private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor"); // 缓存颜色属性 ID,避免频繁字符串查找。
    private MaterialPropertyBlock propertyBlock; // 缓存 MPB,避免每次 new 产生 GC。
    private bool selected; // 记录当前是否处于选中状态。

    private void Awake() // Unity 初始化回调。
    { // 函数开始。
        propertyBlock = new MaterialPropertyBlock(); // 创建材质属性块。
        if (targetRenderer == null) // 判断是否没有手动绑定 Renderer。
        { // if 开始。
            targetRenderer = GetComponentInChildren<Renderer>(); // 自动从子物体查找 Renderer。
        } // if 结束。
        ApplyHighlight(false); // 初始化为未选中状态。
    } // 函数结束。

    public void SetSelected(bool value) // 对外提供设置选中状态的方法。
    { // 函数开始。
        if (selected == value) // 如果状态没有变化。
        { // if 开始。
            return; // 直接返回,避免重复设置。
        } // if 结束。
        selected = value; // 保存新的选中状态。
        ApplyHighlight(selected); // 根据状态刷新渲染表现。
    } // 函数结束。

    private void ApplyHighlight(bool value) // 应用高亮表现。
    { // 函数开始。
        if (targetRenderer == null) // 如果没有 Renderer。
        { // if 开始。
            return; // 无法设置高亮,直接返回。
        } // if 结束。
        targetRenderer.GetPropertyBlock(propertyBlock); // 读取当前 Renderer 的属性块。
        propertyBlock.SetColor(BaseColorId, value ? highlightColor : normalColor); // 设置选中或普通颜色。
        targetRenderer.SetPropertyBlock(propertyBlock); // 把属性块写回 Renderer。
    } // 函数结束。

    private void OnDisable() // 物体禁用时回调。
    { // 函数开始。
        selected = false; // 清掉选中状态。
        ApplyHighlight(false); // 还原普通颜色。
    } // 函数结束。
} // 类结束。

面试加分点 我会优先避免直接改 sharedMaterial,否则可能把所有使用同一材质的对象都改亮。单个对象高亮可以用 MaterialPropertyBlock,选中描边可以用额外 Pass 或 Stencil,复杂项目里最好让“选中状态”和“渲染表现”解耦,取消选中、对象销毁、切场景时都要恢复状态。

图形中如何实现角色受击闪白?

cpp-graphics-hit-flash-white

标准答案 角色受击闪白,常见做法是在角色 Shader 里加一个 _HitFlash 参数,颜色输出时把原颜色和白色做插值。受击瞬间把 _HitFlash 设为 1,然后在 0.05 ~ 0.15 秒内快速衰减到 0

底层原理 Shader 里核心就是:

c
float flash = saturate(_HitFlash); // 把闪白强度限制在 0 到 1。
half3 finalRgb = lerp(baseRgb, half3(1, 1, 1), flash); // 按强度把原颜色插值到白色。

flash = 0 时显示原色。 flash = 1 时完全变白。 flash1 快速回到 0,就形成受击一闪的效果。

Unity 控制代码

c
using System.Collections; // 引入协程相关命名空间。
using UnityEngine; // 引入 Unity 引擎命名空间。

public sealed class HitFlashWhite : MonoBehaviour // 定义受击闪白组件。
{ // 类开始。
    [SerializeField] private Renderer[] renderers; // 保存角色身上的所有 Renderer。
    [SerializeField] private float duration = 0.08f; // 设置闪白持续时间。
    private static readonly int HitFlashId = Shader.PropertyToID("_HitFlash"); // 缓存 Shader 参数 ID。
    private MaterialPropertyBlock block; // 缓存材质属性块,避免频繁 new。
    private Coroutine flashRoutine; // 记录当前闪白协程。

    private void Awake() // Unity 初始化回调。
    { // 函数开始。
        block = new MaterialPropertyBlock(); // 创建材质属性块。
    } // 函数结束。

    public void PlayHitFlash() // 对外提供受击闪白接口。
    { // 函数开始。
        if (flashRoutine != null) // 如果上一次闪白还没结束。
        { // if 开始。
            StopCoroutine(flashRoutine); // 停止旧协程,避免多个协程抢同一个参数。
        } // if 结束。
        flashRoutine = StartCoroutine(FlashRoutine()); // 启动新的闪白协程。
    } // 函数结束。

    private IEnumerator FlashRoutine() // 闪白协程。
    { // 函数开始。
        float time = 0f; // 记录已经经过的时间。
        while (time < duration) // 持续到闪白时间结束。
        { // while 开始。
            time += Time.deltaTime; // 累加时间。
            float flash = 1f - time / duration; // 从 1 线性衰减到 0。
            SetFlash(Mathf.Clamp01(flash)); // 写入当前闪白强度。
            yield return null; // 等待下一帧继续衰减。
        } // while 结束。
        SetFlash(0f); // 确保最后完全恢复原色。
        flashRoutine = null; // 清空协程引用。
    } // 函数结束。

    private void SetFlash(float value) // 设置所有 Renderer 的闪白参数。
    { // 函数开始。
        foreach (Renderer r in renderers) // 遍历角色的所有 Renderer。
        { // foreach 开始。
            r.GetPropertyBlock(block); // 读取当前属性块。
            block.SetFloat(HitFlashId, value); // 设置闪白强度。
            r.SetPropertyBlock(block); // 写回属性块。
        } // foreach 结束。
    } // 函数结束。

    private void OnDisable() // 物体禁用时回调。
    { // 函数开始。
        SetFlash(0f); // 禁用时恢复原色,避免对象池复用时仍然发白。
    } // 函数结束。
} // 类结束。

面试加分点 我会用 MaterialPropertyBlock 控制单个角色,不直接改 sharedMaterial,否则所有共用材质的角色都会一起闪白。角色如果有多个 SkinnedMeshRenderer,要统一设置。对象池复用时要在回收或禁用时把 _HitFlash 重置为 0。如果游戏有暂停或慢动作,要明确用 deltaTime 还是 unscaledDeltaTime

图形中如何实现屏幕震动?

cpp-graphics-screen-shake

标准答案 屏幕震动通常是给相机叠加一个“短时间衰减的随机偏移”。核心不是永久修改相机跟随位置,而是:

最终相机位置 = 基础相机位置 + 震动偏移

震动结束后,偏移必须回到 0,这样相机不会越抖越偏。

底层原理 屏幕震动本质是相机空间的小范围扰动。爆炸、受击、落地这些事件触发一个震动请求,里面通常包含:

amplitude:震动强度。 duration:持续时间。 frequency:抖动频率。 decay curve:衰减曲线。 axis:震动方向,比如 2D 游戏常只抖 X/Y。

每帧根据剩余时间计算当前强度,再乘一个随机方向或噪声,得到偏移量。

Unity 简单实现

c
using UnityEngine; // 引入 Unity 引擎命名空间。

public sealed class CameraShake : MonoBehaviour // 定义相机震动组件。
{ // 类开始。
    [SerializeField] private float maxOffset = 0.35f; // 限制最大震动偏移,防止震动过猛。
    private float amplitude; // 当前震动强度。
    private float duration; // 当前震动总时长。
    private float timeLeft; // 当前剩余震动时间。
    private Vector3 lastOffset; // 记录上一帧震动偏移,方便恢复基础位置。

    public void Shake(float newAmplitude, float newDuration) // 对外提供震动请求接口。
    { // 函数开始。
        amplitude = Mathf.Max(amplitude, newAmplitude); // 多次震动时取更强的震动。
        duration = Mathf.Max(0.001f, newDuration); // 防止持续时间为 0 导致除零。
        timeLeft = Mathf.Max(timeLeft, newDuration); // 多次震动时保留更长的剩余时间。
    } // 函数结束。

    private void LateUpdate() // 在 LateUpdate 中执行,保证跟随逻辑之后再叠加震动。
    { // 函数开始。
        transform.localPosition -= lastOffset; // 先移除上一帧震动,恢复基础位置。
        lastOffset = Vector3.zero; // 清空上一帧偏移记录。
        if (timeLeft > 0f) // 判断当前是否还在震动中。
        { // if 开始。
            timeLeft -= Time.unscaledDeltaTime; // 使用不受暂停影响的时间,也可以按项目改成 deltaTime。
            float t = Mathf.Clamp01(timeLeft / duration); // 计算剩余比例。
            float strength = amplitude * t; // 根据剩余比例衰减强度。
            Vector2 random = Random.insideUnitCircle * strength; // 生成 X/Y 平面的随机偏移。
            random = Vector2.ClampMagnitude(random, maxOffset); // 限制最大偏移。
            lastOffset = new Vector3(random.x, random.y, 0f); // 把 2D 偏移转成 3D 偏移。
            transform.localPosition += lastOffset; // 把震动偏移叠加到相机位置。
        } // if 结束。
    } // 函数结束。

    private void OnDisable() // 组件禁用时回调。
    { // 函数开始。
        transform.localPosition -= lastOffset; // 禁用时移除残留震动偏移。
        lastOffset = Vector3.zero; // 清空偏移。
        timeLeft = 0f; // 清空剩余时间。
    } // 函数结束。
} // 类结束。

项目里怎么做更稳 相机跟随、相机碰撞、构图系统先算 basePosition,震动系统最后在 LateUpdate 叠加 shakeOffset。如果项目用了 Cinemachine,推荐用 Cinemachine Impulse,它已经帮你处理震动信号、衰减、混合和监听器。

常见坑 不要直接把相机位置永久加随机值,否则震动结束后相机会偏掉。不要震动过大,移动端和动作游戏里容易晕。多次震动要做合成和上限,比如 Clamp 强度。切场景、暂停、禁用相机时要清理偏移。

网络中如何处理断线重连后的状态恢复?

unity-network-reconnect-state-restore

标准答案 断线重连后的状态恢复,核心不是“客户端把断线前的状态继续用”,而是:重新连接、重新鉴权、恢复会话、对齐消息序号,然后向服务端请求权威快照或增量状态,客户端再用服务端结果校正本地表现。

底层流程 先通过心跳超时、Socket 关闭、网络异常发现断线。客户端进入 Reconnecting 状态,暂停危险操作,比如支付、领取奖励、战斗提交、背包操作。然后用 token/sessionId/lastSeq 重新连接服务器。服务端校验身份和会话,如果消息缺口不大,可以补发增量;如果缺口太大,就直接下发全量快照。

恢复时要以服务端为准,比如位置、血量、Buff、技能 CD、背包、任务、战斗状态都应该由服务端快照校正。客户端断线期间的本地预测只能作为临时表现,不能当最终结果。

关键点sessionId 用来恢复会话。 lastSeq / ack 用来告诉服务端客户端收到哪条消息。 opId / requestId 用来做请求去重,防止断线重发导致重复扣费、重复发奖。 serverTime 用来校正技能 CD、Buff 剩余时间、活动倒计时。 重连失败要兜底,比如回登录、重新进入场景、提示玩家网络异常。

简化代码思路

c
using System.Collections; // 引入协程命名空间。
using UnityEngine; // 引入 Unity 引擎命名空间。

public sealed class ReconnectController : MonoBehaviour // 定义断线重连控制器。
{ // 类开始。
    private string sessionId; // 保存当前会话 ID。
    private long lastServerSeq; // 保存客户端最后处理过的服务端消息序号。
    private bool reconnecting; // 标记是否正在重连。

    public void OnDisconnected() // 网络层发现断线时调用。
    { // 函数开始。
        if (reconnecting) return; // 如果已经在重连中,就避免重复启动。
        reconnecting = true; // 标记进入重连状态。
        ShowReconnectUI(true); // 显示重连中的 UI。
        StartCoroutine(ReconnectRoutine()); // 启动重连流程。
    } // 函数结束。

    private IEnumerator ReconnectRoutine() // 断线重连协程。
    { // 协程开始。
        yield return ConnectToServer(); // 重新建立 Socket 连接。
        yield return SendResumeRequest(sessionId, lastServerSeq); // 发送会话恢复请求。
        ServerSnapshot snapshot = ReceiveSnapshot(); // 接收服务端权威快照。
        ApplyServerSnapshot(snapshot); // 用服务端状态覆盖或校正本地状态。
        ShowReconnectUI(false); // 关闭重连 UI。
        reconnecting = false; // 标记重连结束。
    } // 协程结束。

    private void ApplyServerSnapshot(ServerSnapshot snapshot) // 应用服务端快照。
    { // 函数开始。
        lastServerSeq = snapshot.seq; // 更新最新服务端序号。
        PlayerState.Apply(snapshot.player); // 恢复玩家位置、血量、Buff、CD。
        BagModel.Apply(snapshot.bag); // 恢复背包数据。
        QuestModel.Apply(snapshot.quest); // 恢复任务数据。
        BattleModel.Apply(snapshot.battle); // 恢复战斗状态。
    } // 函数结束。
} // 类结束。

面试加分回答 我会强调“服务端权威、客户端校正、请求幂等”。断线重连不是简单连上就完事,而是要解决身份恢复、消息缺口、状态快照、重复请求、UI 平滑恢复和失败兜底。尤其是支付、领奖、背包变更这种操作,一定要用 opId 做幂等,避免弱网下重复结算。

网络中如何处理消息乱序?

unity-network-message-reordering

标准答案 处理消息乱序,核心是给每条消息加 seq 序号,然后按业务类型决定策略:必须有序的消息就缓存等待缺口,只需要最新状态的消息就丢弃旧包,互不相关的业务拆成多个通道处理。

底层原理 如果是 TCP,同一连接内字节流天然有序,但业务层仍可能因为多线程队列、多个通道、异步分发造成逻辑乱序。实时游戏常用 UDP/KCP/RUDP 时,乱序更常见,所以包头一般会带 seqchannelmsgId

严格有序消息,比如背包变更、领奖、战斗指令,收到 104103 没到,就先缓存 104,等 103 到了再连续处理。状态快照消息,比如位置、朝向、动画参数,旧包迟到就直接丢,因为等旧包会增加延迟。

简化代码

c
using System.Collections.Generic; // 引入有序字典集合。

public sealed class OrderedMessageBuffer // 定义严格有序消息缓冲器。
{ // 类开始。
    private readonly SortedDictionary<int, NetMessage> buffer = new(); // 保存提前到达的乱序消息。
    private int expectedSeq; // 记录当前期待处理的消息序号。

    public OrderedMessageBuffer(int firstSeq) // 构造函数传入第一个期待序号。
    { // 构造函数开始。
        expectedSeq = firstSeq; // 初始化期待序号。
    } // 构造函数结束。

    public void Receive(NetMessage msg) // 接收一条网络消息。
    { // 函数开始。
        if (msg.Seq < expectedSeq) // 如果消息序号小于期待序号。
        { // if 开始。
            return; // 说明是旧包或重复包,直接丢弃。
        } // if 结束。

        if (msg.Seq > expectedSeq) // 如果消息序号大于期待序号。
        { // if 开始。
            buffer[msg.Seq] = msg; // 说明中间有缺口,先缓存该消息。
            return; // 暂时不能处理,等待缺失消息到达。
        } // if 结束。

        ProcessAndAdvance(msg); // 当前消息正好是期待序号,立即处理。
        while (buffer.TryGetValue(expectedSeq, out NetMessage next)) // 尝试连续处理缓存里的后续消息。
        { // while 开始。
            buffer.Remove(expectedSeq); // 从缓冲区移除即将处理的消息。
            ProcessAndAdvance(next); // 处理后续连续消息。
        } // while 结束。
    } // 函数结束。

    private void ProcessAndAdvance(NetMessage msg) // 处理消息并推进序号。
    { // 函数开始。
        Dispatch(msg); // 分发给业务模块处理。
        expectedSeq++; // 期待下一个序号。
    } // 函数结束。

    private void Dispatch(NetMessage msg) // 分发消息到业务层。
    { // 函数开始。
        // 这里根据 msg.MsgId 分发到背包、战斗、房间等模块。 
    } // 函数结束。
} // 类结束。

面试加分点 不要所有消息都强制有序,否则一个丢包会导致队头阻塞。移动同步、位置快照这类消息应该只保留最新;背包、支付、领奖、战斗结算这种必须有序且幂等。复杂项目里会拆 channel,比如移动一个序号通道,战斗一个序号通道,聊天一个序号通道,互不阻塞。

网络中如何处理重复消息?

unity-network-duplicate-message-handling

标准答案 重复消息要分两层处理:网络层用 seq 去重包,业务层用 opId/requestId 做幂等。普通重复包直接丢弃;关键业务重复请求不能再次执行,而是返回第一次处理结果,避免重复扣费、重复发奖、重复结算。

底层原理 重复消息很常见,比如 ACK 丢了,发送方以为对方没收到,就会重传。接收方再次收到同一个 seq,不能再分发给业务,只能丢弃,并可以再回一次 ACK。

但只做 seq 还不够。像支付、领奖、扣道具、任务提交这种关键业务,可能因为超时被客户端重新发起请求。这时必须用 opId 做幂等:同一个 opId 只执行一次,后续重复请求直接返回第一次的结果。

简化代码

c
using System.Collections.Generic; // 引入集合命名空间。

public sealed class DuplicateMessageFilter // 定义重复消息过滤器。
{ // 类开始。
    private readonly HashSet<int> handledSeqSet = new(); // 保存已经处理过的网络消息序号。
    private readonly Dictionary<string, Response> opResultCache = new(); // 保存已经执行过的业务请求结果。

    public bool IsDuplicatePacket(int seq) // 判断网络包是否重复。
    { // 函数开始。
        if (handledSeqSet.Contains(seq)) // 如果序号已经处理过。
        { // if 开始。
            return true; // 返回重复。
        } // if 结束。
        handledSeqSet.Add(seq); // 记录这个序号已经处理。
        return false; // 返回不是重复包。
    } // 函数结束。

    public Response HandleBusinessRequest(Request request) // 处理业务请求。
    { // 函数开始。
        if (opResultCache.TryGetValue(request.OpId, out Response cached)) // 如果这个 opId 已经执行过。
        { // if 开始。
            return cached; // 直接返回旧结果,避免重复执行业务。
        } // if 结束。
        Response result = ExecuteBusiness(request); // 正常执行业务逻辑。
        opResultCache[request.OpId] = result; // 缓存执行结果。
        return result; // 返回本次执行结果。
    } // 函数结束。

    private Response ExecuteBusiness(Request request) // 执行真正的业务逻辑。
    { // 函数开始。
        return new Response(); // 示例:真实项目里这里会发奖、扣道具或更新状态。
    } // 函数结束。
} // 类结束。

项目里要注意 去重表不能无限增长,要用 TTL 或 LRU 清理。客户端去重只能减少重复表现,关键资产操作必须服务端保证幂等。重复包丢弃后仍然可以回 ACK,否则对方可能一直重传。状态快照类消息通常直接用最新的,旧的重复包丢掉即可。

面试一句话 重复消息处理不是简单“收到重复就丢”,而是 seq 解决网络包重复,opId 解决业务重复执行,ACK 可以重复发,去重缓存要有过期策略,涉及资产和结算的操作必须由服务端做幂等保证。

网络中如何做心跳超时?

unity-network-heartbeat-timeout一句话定义 网络心跳超时就是:客户端和服务器定时互发很小的心跳包,谁太久没收到对方响应,就认为连接已经不可靠,进入重连、踢线或状态恢复流程。

核心流程

客户端每隔一段时间发送 Ping,服务器收到后返回 Pong。客户端记录最近一次收到 Pong 的时间,比如 lastPongTime。如果当前时间减去 lastPongTime 超过阈值,就认为连接超时。

一般不要一次没收到就断线,真实网络会有抖动。更稳的做法是:

Ping 间隔 = 3 秒超时阈值 = 10 秒连续丢失 2 到 3 次心跳后才判定异常

服务端也要维护 lastHeartbeatTime,如果某个客户端长期没有心跳,就清理连接、释放会话资源。

c
using UnityEngine; // 引入 Unity 引擎命名空间。

public sealed class HeartbeatClient : MonoBehaviour // 定义客户端心跳组件。
{ // 类开始。
    [SerializeField] private float pingInterval = 3f; // 心跳发送间隔。
    [SerializeField] private float timeoutSeconds = 10f; // 心跳超时阈值。
    private float nextPingTime; // 下一次发送 Ping 的时间。
    private float lastPongTime; // 最近一次收到 Pong 的时间。
    private bool reconnecting; // 是否已经进入重连状态。

    private void OnEnable() // 组件启用时初始化心跳状态。
    { // 函数开始。
        float now = Time.unscaledTime; // 使用不受暂停影响的真实时间。
        nextPingTime = now + pingInterval; // 设置下一次 Ping 的发送时间。
        lastPongTime = now; // 初始化最近一次响应时间。
        reconnecting = false; // 初始化为未重连状态。
    } // 函数结束。

    private void Update() // 每帧检查是否需要发送心跳或判定超时。
    { // 函数开始。
        if (reconnecting) return; // 已经在重连时不重复触发超时逻辑。
        float now = Time.unscaledTime; // 获取当前真实时间。
        if (now >= nextPingTime) // 判断是否到达发送 Ping 的时间。
        { // if 开始。
            SendPing(now); // 发送 Ping,并携带客户端时间戳。
            nextPingTime = now + pingInterval; // 更新下一次 Ping 的时间。
        } // if 结束。
        if (now - lastPongTime > timeoutSeconds) // 判断是否超过心跳超时阈值。
        { // if 开始。
            EnterReconnect(); // 进入断线重连流程。
        } // if 结束。
    } // 函数结束。

    public void OnPong(float serverTime) // 收到服务器 Pong 时调用。
    { // 函数开始。
        lastPongTime = Time.unscaledTime; // 刷新最近一次响应时间。
    } // 函数结束。

    private void SendPing(float clientTime) // 发送 Ping 消息。
    { // 函数开始。
        // 这里把 clientTime 放进 Ping 包里,收到 Pong 后可以计算 RTT。
    } // 函数结束。

    private void EnterReconnect() // 进入重连流程。
    { // 函数开始。
        reconnecting = true; // 标记已经进入重连状态。
        // 这里冻结危险操作、显示重连 UI,并通知网络模块重新连接。
    } // 函数结束。
} // 类结束。

面试加分说法

心跳不是业务同步,它主要解决“连接是否还活着”和“延迟大概是多少”。实际项目里通常会把心跳、断线重连、弱网提示、请求重发、状态恢复放在同一套网络状态机里。

坑点是阈值不能太短,否则弱网会误判;也不能太长,否则玩家已经断线很久客户端还不知道。移动端切后台、锁屏、网络切换时也要特殊处理,不能简单按普通超时逻辑直接踢掉。

网络中如何处理客户端时间和服务器时间差?

unity-network-client-server-time-offset

标准答案

客户端和服务器时间有差,不能让客户端直接用本机时间做结算。常见做法是:客户端用 Ping / Pong 向服务器取时间戳,估算一个 offset,之后客户端显示用:

c
serverNow = clientMonotonicNow + offset

但是最终结算,比如活动是否过期、技能 CD、奖励发放、排行榜刷新,仍然必须以服务器时间为准。

底层原理

客户端发送请求时记录本地单调时间 t0,服务器返回自己的当前时间 S,客户端收到响应时记录 t1

c
RTT = t1 - t0
客户端中点时间 = (t0 + t1) / 2
offset = S - 客户端中点时间

这样就能估算“服务器时间比客户端时间快多少或慢多少”。项目里不会只同步一次,而是定时采样,多取几次,丢掉 RTT 特别大的样本,再平滑修正 offset,避免网络抖动导致倒计时突然跳变。

c
using UnityEngine; // 引入 Unity 引擎命名空间。

public sealed class NetworkTimeSync // 定义网络时间同步器。
{ // 类开始。
    private double offsetSeconds; // 保存服务器时间和客户端单调时间的偏移。
    private const double MaxAcceptedRtt = 0.5d; // 超过 500ms 的样本认为抖动太大。
    private const double SmoothFactor = 0.1d; // 平滑修正比例,避免时间突然跳变。

    public double ServerNow => Now() + offsetSeconds; // 对外提供估算后的服务器当前时间。

    public double CreateSyncRequest() // 创建同步请求时调用。
    { // 函数开始。
        return Now(); // 返回客户端发送请求的本地单调时间。
    } // 函数结束。

    public void OnSyncResponse(double clientSendTime, double serverTimeAtReply) // 收到服务器时间响应时调用。
    { // 函数开始。
        double clientReceiveTime = Now(); // 记录客户端收到响应的本地单调时间。
        double rtt = clientReceiveTime - clientSendTime; // 计算一次往返延迟。
        if (rtt <= 0d || rtt > MaxAcceptedRtt) return; // 丢弃异常 RTT 样本。
        double clientMidTime = (clientSendTime + clientReceiveTime) * 0.5d; // 估算服务器回包时对应的客户端中点时间。
        double sampleOffset = serverTimeAtReply - clientMidTime; // 计算本次采样得到的时间偏移。
        offsetSeconds += (sampleOffset - offsetSeconds) * SmoothFactor; // 用平滑方式修正 offset。
    } // 函数结束。

    private static double Now() // 获取客户端单调时间。
    { // 函数开始。
        return Time.realtimeSinceStartupAsDouble; // 使用不受系统时间修改影响的 Unity 真实运行时间。
    } // 函数结束。
} // 类结束。

Unity 项目里怎么用

倒计时、活动开启时间、商店刷新时间,可以用估算出来的 ServerNow 做显示。 但是关键业务不要信客户端,比如“奖励能不能领”“技能 CD 到没到”“订单是否过期”,都要服务器最终判断。

常见坑

不要用 DateTime.Now 直接当游戏时间,玩家可以改系统时间。 不要一次同步后永久相信 offset,网络抖动和设备计时器漂移都会造成误差。 高延迟样本不能直接覆盖 offset,否则 UI 倒计时会突然前后跳。 重连、切后台回来、网络切换后,最好重新做一次时间校准。

网络中如何做战斗回放校验?

unity-network-battle-replay-validation

标准答案

战斗回放校验不是“录屏回放”,而是记录一场战斗的关键数据:初始快照 + 输入日志 + 随机种子 + 配置版本,然后用同一套战斗逻辑离线重跑一遍。重跑过程中定期计算 checksum,和服务器当时记录的 checksum 对比;如果不一致,就说明出现了不同步、作弊、配置不一致或非确定性逻辑。

底层原理

核心公式可以理解成:

同一个初始状态 + 同一串输入 + 同一个随机种子 = 同一个战斗结果

所以需要记录的不是角色每一帧的位置录像,而是:

BattleSnapshot:开局状态,比如角色属性、血量、位置、Buff、技能 CD。 InputLog:每个 tick 的玩家操作,比如移动、释放技能、目标 ID。 RandomSeed:随机种子,保证暴击、掉落、随机目标选择一致。 ConfigVersion:配置表版本,防止回放时技能数值和线上不一样。 Checksum:关键帧状态哈希,比如每 10 帧或每 30 帧算一次。

简单代码思路

c
using System.Collections.Generic; // 引入集合接口。

public sealed class ReplayValidator // 定义战斗回放校验器。
{ // 类开始。
    private readonly BattleSimulator simulator; // 保存战斗模拟器引用。

    public ReplayValidator(BattleSimulator simulator) // 构造函数接收模拟器。
    { // 构造函数开始。
        this.simulator = simulator; // 保存外部传入的模拟器。
    } // 构造函数结束。

    public bool Verify(ReplayData data, IReadOnlyDictionary<int, int> expectedHashes) // 校验一场回放。
    { // 函数开始。
        simulator.Load(data.InitialSnapshot, data.RandomSeed); // 用初始快照和随机种子恢复战斗起点。
        int commandIndex = 0; // 记录当前处理到哪条输入命令。
        for (int tick = data.StartTick; tick <= data.EndTick; tick++) // 按固定 tick 推进战斗。
        { // tick 循环开始。
            while (commandIndex < data.Commands.Count && data.Commands[commandIndex].Tick == tick) // 取出当前 tick 的所有输入。
            { // 输入循环开始。
                simulator.ApplyCommand(data.Commands[commandIndex]); // 把玩家输入喂给战斗逻辑。
                commandIndex++; // 移动到下一条输入命令。
            } // 输入循环结束。
            simulator.StepFixedTick(); // 推进一帧固定战斗逻辑。
            if (expectedHashes.TryGetValue(tick, out int expectedHash)) // 判断当前 tick 是否需要校验 hash。
            { // hash 判断开始。
                int actualHash = simulator.CalculateChecksum(); // 计算当前战斗状态的 checksum。
                if (actualHash != expectedHash) // 对比实际 hash 和期望 hash。
                { // 不一致判断开始。
                    return false; // 校验失败,说明从这个 tick 附近开始跑偏。
                } // 不一致判断结束。
            } // hash 判断结束。
        } // tick 循环结束。
        return true; // 全部关键帧一致,说明回放校验通过。
    } // 函数结束。
} // 类结束。

项目里怎么落地

如果是帧同步游戏,回放校验最自然:服务端或裁判服保存每帧输入,离线重放检查结果。 如果是状态同步游戏,也可以记录服务器权威快照和关键操作,用来复盘线上战斗 bug。

checksum 通常不要包含表现层数据,比如 Animator 状态、特效、UI、摄像机震动;应该包含真正影响战斗结果的数据,比如位置、血量、Buff、技能 CD、子弹状态、随机数状态。

常见坑

最容易出问题的是非确定性:浮点误差、随机数没有统一种子、遍历 Dictionary 顺序不稳定、逻辑用了真实时间 deltaTime、动画事件驱动伤害、配置表版本不一致。

面试可以补一句:如果 checksum 不一致,我不会只说“不同步了”,我会用二分法定位第一个不一致 tick,再把该 tick 的输入、关键实体状态、随机数状态和配置版本打出来,这样才能真正复现和修。

网络中如何防止客户端伪造伤害?

unity-network-anti-fake-damage

标准答案

防止客户端伪造伤害的核心不是“把伤害包加密”,而是:客户端不能决定伤害,客户端只发操作意图,服务端权威校验并计算伤害

客户端可以说:“我要释放 skillId = 1001 的技能,目标是 targetId = 8。” 但不能说:“我打了 999999 点伤害。”这个字段就算传上来,也不能用于结算。

底层原理

服务端要校验这些东西:

技能是否合法:有没有这个技能、是否解锁。 释放条件是否合法:CD、蓝量、怒气、死亡、眩晕、沉默。 目标是否合法:目标是否存在、是否死亡、是否敌方、是否无敌。 空间是否合法:距离、角度、碰撞、视线、服务器权威位置。 伤害是否合法:攻击、防御、Buff、护盾、暴击随机数都由服务端算。 协议是否合法:seqnonce、时间戳防止旧包重放。

服务端思路代码

c
public sealed class DamageServer // 定义服务端伤害处理器。
{ // 类开始。
    public DamageResult HandleCastSkill(Player caster, SkillRequest request, BattleState state) // 处理客户端释放技能请求。
    { // 函数开始。
        if (!state.Sequence.Accept(caster.Id, request.Sequence)) return DamageResult.Reject("重复或乱序请求"); // 校验序号,防止重放攻击。
        SkillConfig skill = state.SkillConfigs.Get(request.SkillId); // 根据技能 ID 读取服务器配置。
        if (skill == null) return DamageResult.Reject("技能不存在"); // 技能不存在时拒绝请求。
        if (caster.IsDead) return DamageResult.Reject("释放者已死亡"); // 死亡状态不能释放技能。
        if (caster.HasState(StateType.Stun)) return DamageResult.Reject("释放者被眩晕"); // 被控制状态不能释放技能。
        if (!caster.Cooldowns.IsReady(skill.Id, state.Now)) return DamageResult.Reject("技能 CD 未结束"); // 校验技能冷却。
        if (caster.Mana < skill.ManaCost) return DamageResult.Reject("蓝量不足"); // 校验资源消耗。
        Unit target = state.Units.Find(request.TargetId); // 在服务器状态中查找目标。
        if (target == null) return DamageResult.Reject("目标不存在"); // 目标不存在时拒绝。
        if (target.IsDead) return DamageResult.Reject("目标已死亡"); // 目标死亡时拒绝。
        if (!state.Camp.IsEnemy(caster, target)) return DamageResult.Reject("目标不是敌人"); // 校验阵营关系。
        if (target.HasState(StateType.Invincible)) return DamageResult.Reject("目标无敌"); // 校验无敌状态。
        if (!state.Space.InRange(caster.Position, target.Position, skill.Range)) return DamageResult.Reject("超出技能范围"); // 用服务器位置校验距离。
        if (!state.Space.InAngle(caster.Forward, caster.Position, target.Position, skill.Angle)) return DamageResult.Reject("目标不在扇形内"); // 校验技能角度。
        int damage = state.Formula.CalculateDamage(caster, target, skill); // 服务端根据属性和配置计算伤害。
        target.Health -= damage; // 服务端扣除目标血量。
        caster.Cooldowns.Start(skill.Id, state.Now, skill.Cooldown); // 服务端启动技能 CD。
        return DamageResult.Success(target.Id, damage, target.Health); // 返回权威伤害结果。
    } // 函数结束。
} // 类结束。

Unity 项目里怎么说更像实战

客户端可以做“表现预测”,比如先播攻击动画、飘字、命中特效,让手感更好。但真正扣血一定等服务器 DamageResult。如果预测和服务器结果不一致,客户端要用服务器结果修正。

加密、签名、协议混淆是有用的,但它们只是提高篡改成本,不能替代服务端校验。面试里可以这样总结:安全不是相信包没被改,而是假设包一定可能被改,然后服务端仍然能判定它是否合法。

网络中如何同步随机数?

unity-network-random-sync

标准答案

网络中同步随机数,核心不是让每个客户端自己 Random.Range(),而是保证随机来源可控:

  1. 关键结算随机:由服务端直接生成结果并广播,比如抽卡、掉落、暴击、命中。
  2. 帧同步/回放随机:服务端下发 seed,所有端使用同一套确定性随机算法,并保证调用顺序完全一致。
  3. 表现层随机:比如粒子抖动、镜头轻微偏移,可以本地随机,但不能影响战斗结果。

底层原理

随机数其实不是“真正随机”,常见游戏逻辑里用的是伪随机数 PRNG。只要:

同一个 seed + 同一个随机算法 + 同一个调用顺序 = 同一串随机结果

所以帧同步或战斗回放里,服务端可以在开局下发一个 battleSeed,然后所有客户端都用同一个确定性随机数生成器。问题是:如果 A 客户端多调用了一次随机,后面整串结果就会错位,所以要严格控制调用顺序。

更稳的做法是把随机流拆开:

CombatRng:战斗命中、暴击、随机目标。 DropRng:掉落、奖励。 VfxRng:特效表现,本地使用,不参与同步。

C# 简单确定性随机数

c
public struct DeterministicRng // 定义一个确定性随机数生成器。
{ // 结构体开始。
    private uint state; // 保存当前随机状态。

    public DeterministicRng(uint seed) // 使用种子初始化随机数生成器。
    { // 构造函数开始。
        state = seed == 0u ? 1u : seed; // 避免 seed 为 0 导致部分算法退化。
    } // 构造函数结束。

    public uint NextUInt() // 生成下一个无符号整数随机数。
    { // 函数开始。
        uint x = state; // 取出当前状态。
        x ^= x << 13; // 执行 XorShift 第一步扰动。
        x ^= x >> 17; // 执行 XorShift 第二步扰动。
        x ^= x << 5; // 执行 XorShift 第三步扰动。
        state = x; // 保存新的随机状态。
        return x; // 返回本次随机结果。
    } // 函数结束。

    public int Range(int minInclusive, int maxExclusive) // 生成指定范围内的整数。
    { // 函数开始。
        uint value = NextUInt(); // 获取一个随机无符号整数。
        int range = maxExclusive - minInclusive; // 计算随机范围长度。
        return minInclusive + (int)(value % (uint)range); // 通过取模映射到目标范围。
    } // 函数结束。

    public uint State => state; // 暴露当前状态,方便回放和 checksum 校验。
} // 结构体结束。

项目里怎么选方案

如果是抽卡、掉落、PVP 伤害这种强结算,最好服务端直接随机并广播结果,客户端只播放表现。 如果是帧同步战斗,为了省带宽,可以同步 seed + 输入,所有端自己确定性生成随机结果。 如果担心调用顺序错乱,可以用这种方式生成随机:

c
random = Hash(matchSeed, tick, entityId, eventType, counter)

这样随机结果和事件绑定,不太依赖“之前调用过几次随机”。

常见坑

不要用客户端自己生成的 seed 做关键结算。 不要把 Unity 全局 Random 混进战斗逻辑。 不要让动画、特效、UI 消耗战斗随机流。 回放日志里要记录 seed、随机流名字、counter 和关键帧 rngState。 如果是跨平台同步,尽量使用整数 PRNG,少依赖浮点随机计算。

网络中如何处理输入延迟?

unity-network-input-latency

标准答案

网络输入延迟不能完全消除,只能“隐藏”和“修正”。常见做法是:

本地玩家:客户端预测,按下按键立刻移动或播放动作,同时把输入发给服务器。 服务器:权威模拟,校验输入是否合法,返回权威状态。 客户端:回滚校正,如果本地预测和服务器结果不一致,就回到服务器状态,再重放未确认输入。 远端玩家:插值平滑,不要预测别人,而是在服务器快照之间插值。 命中类玩法:延迟补偿,服务器按开火时间回看历史位置做判定。

底层原理

如果客户端每次输入都等服务器确认,流程是:

按键 -> 发给服务器 -> 服务器处理 -> 回包 -> 客户端表现

假设 RTT 是 100ms,玩家就会感觉角色慢半拍。所以客户端会先预测执行:

按键 -> 本地立即执行 -> 同时发给服务器 -> 收到权威结果后校正

关键是每个输入都要带 seqtick,客户端缓存“已经预测但还没被服务器确认”的输入。

c
using System.Collections.Generic; // 引入 List 集合。

public sealed class ClientPrediction // 定义客户端预测模块。
{ // 类开始。
    private int nextSeq = 1; // 下一个输入序号。
    private PlayerState localState; // 客户端当前预测状态。
    private readonly List<PlayerInput> pendingInputs = new List<PlayerInput>(); // 保存未被服务器确认的输入。

    public void OnLocalInput(PlayerInput input) // 本地玩家产生输入时调用。
    { // 函数开始。
        input.Sequence = nextSeq++; // 给输入分配递增序号。
        ApplyInput(ref localState, input); // 先在本地立即执行预测。
        pendingInputs.Add(input); // 把该输入加入未确认队列。
        SendToServer(input); // 同时把输入发送给服务器。
    } // 函数结束。

    public void OnServerSnapshot(PlayerState serverState, int ackSequence) // 收到服务器权威快照时调用。
    { // 函数开始。
        localState = serverState; // 先回到服务器认可的权威状态。
        pendingInputs.RemoveAll(input => input.Sequence <= ackSequence); // 删除服务器已经处理过的输入。
        foreach (PlayerInput input in pendingInputs) // 遍历还没被确认的输入。
        { // 循环开始。
            ApplyInput(ref localState, input); // 重新播放未确认输入,恢复预测状态。
        } // 循环结束。
    } // 函数结束。

    private void ApplyInput(ref PlayerState state, PlayerInput input) // 根据输入推进角色状态。
    { // 函数开始。
        state.Position += input.MoveDirection * input.MoveSpeed * input.FixedDeltaTime; // 用固定步长计算预测位移。
    } // 函数结束。

    private void SendToServer(PlayerInput input) // 发送输入到服务器。
    { // 函数开始。
        // 这里把 input 的 seq、方向、按键、tick 发给服务器。
    } // 函数结束。
} // 类结束。

Unity 项目里怎么取舍

移动、转向、普通攻击前摇可以预测,这样手感跟手。 扣血、击杀、掉落、资源消耗必须等服务器确认,防止客户端伪造结果。 其他玩家不要强行预测,通常用快照插值,否则很容易抖动和穿模。 射击、技能命中可以做延迟补偿,但要限制最大回看时间,避免高延迟玩家获得过大优势。

面试加分句

我会把输入延迟拆成三层处理:自己用预测保证手感,别人用插值保证平滑,关键命中用服务端延迟补偿保证公平;所有最终状态仍然以服务器权威快照为准。

网络中如何做插值平滑?

unity-network-interpolation-smoothing

标准答案

网络插值平滑就是:客户端不要直接把远端对象设置到“最新服务器位置”,而是先把服务器快照缓存起来,让远端对象渲染在稍微过去的时间点,比如延后 100ms,然后在前后两个服务器快照之间做 Lerp / Slerp

底层原理

服务器不是每帧都发位置,而且网络包到达时间会抖动。 如果客户端收到一个包就立刻把角色放到新位置,就会“一跳一跳”。

正确做法是:

c
renderTime = serverNow - interpolationDelay

然后在快照缓冲里找:

c
A.time <= renderTime <= B.time

再计算:

c
alpha = (renderTime - A.time) / (B.time - A.time)
position = Lerp(A.position, B.position, alpha)
rotation = Slerp(A.rotation, B.rotation, alpha)

Unity 示例代码

c
using System.Collections.Generic; // 引入 List 集合。
using UnityEngine; // 引入 Unity 引擎类型。

public sealed class RemoteInterpolator : MonoBehaviour // 定义远端对象插值组件。
{ // 类开始。
    private struct Snapshot // 定义服务器快照结构。
    { // 结构体开始。
        public double Time; // 服务器时间戳。
        public Vector3 Position; // 服务器位置。
        public Quaternion Rotation; // 服务器旋转。
    } // 结构体结束。

    [SerializeField] private double interpolationDelay = 0.1d; // 插值延迟,常见是 100ms 左右。
    private readonly List<Snapshot> snapshots = new List<Snapshot>(); // 保存服务器快照缓冲。
    public double ServerNow { get; set; } // 外部网络时钟同步后的服务器当前时间。

    public void AddSnapshot(double serverTime, Vector3 position, Quaternion rotation) // 收到服务器快照时调用。
    { // 函数开始。
        snapshots.Add(new Snapshot { Time = serverTime, Position = position, Rotation = rotation }); // 把快照加入缓冲。
        snapshots.Sort((a, b) => a.Time.CompareTo(b.Time)); // 按服务器时间排序,防止乱序包影响插值。
    } // 函数结束。

    private void Update() // 每帧渲染远端对象。
    { // 函数开始。
        if (snapshots.Count < 2) return; // 快照不足两个时无法插值。
        double renderTime = ServerNow - interpolationDelay; // 计算要渲染的过去时间点。
        while (snapshots.Count >= 3 && snapshots[1].Time <= renderTime) // 删除已经用不到的旧快照。
        { // 循环开始。
            snapshots.RemoveAt(0); // 移除最老的快照。
        } // 循环结束。
        Snapshot from = snapshots[0]; // 取前一个快照。
        Snapshot to = snapshots[1]; // 取后一个快照。
        double length = to.Time - from.Time; // 计算两个快照的时间间隔。
        float alpha = length <= 0d ? 0f : (float)((renderTime - from.Time) / length); // 计算插值比例。
        alpha = Mathf.Clamp01(alpha); // 限制比例在 0 到 1 之间。
        transform.position = Vector3.Lerp(from.Position, to.Position, alpha); // 对位置做线性插值。
        transform.rotation = Quaternion.Slerp(from.Rotation, to.Rotation, alpha); // 对旋转做球面插值。
    } // 函数结束。
} // 类结束。

常见坑

不要盲目写:

c
transform.position = Vector3.Lerp(transform.position, targetPosition, Time.deltaTime * 10f); // 这只是追目标点,不是按服务器时间插值。

这种写法会变成“越靠近越慢”的缓动,网络包抖动时仍然不稳定。

真正的网络插值要依赖:服务器时间戳、快照缓冲、固定插值延迟。 interpolationDelay 越大越稳,但远端显示越滞后;越小越跟手,但更容易遇到“后一个快照还没到”的情况。

算法中如何判断一个点是否在扇形内?

algorithm-point-in-sector

标准答案

判断一个点是否在扇形内,分两步:

  1. 先判断点到圆心的距离是否小于半径。
  2. 再判断点的方向是否落在扇形角度范围内。

公式就是:

距离条件:|P - C| <= R
角度条件:dot(normalize(P - C), forward) >= cos(扇形半角)

为什么用点乘

两个单位向量点乘等于夹角的 cos 值:

c
dot = cos(theta)

夹角越小,cos 越大。 所以不需要真的算出 theta,只要比较:

c
dot >= cos(halfAngle)

这样比每次调用 Vector3.Angle 更适合批量技能筛选。

C# / Unity 写法

c
using UnityEngine; // 引入 Unity 的 Vector3 和 Mathf。

public static class SectorUtility // 定义扇形判断工具类。
{ // 类开始。
    public static bool IsPointInSector(Vector3 center, Vector3 forward, Vector3 point, float radius, float angleDegree) // 判断 point 是否在扇形内。
    { // 函数开始。
        Vector3 toPoint = point - center; // 计算圆心指向目标点的向量。
        toPoint.y = 0f; // 地面技能通常只判断 XZ 平面,忽略高度。
        forward.y = 0f; // 朝向也投影到 XZ 平面。
        float distSqr = toPoint.sqrMagnitude; // 计算平方距离,避免开根号。
        if (distSqr > radius * radius) return false; // 距离超过半径,直接不在扇形内。
        if (distSqr <= 0.000001f) return true; // 点几乎就在圆心,可以认为在扇形内。
        if (forward.sqrMagnitude <= 0.000001f) return false; // 朝向接近零向量时无法判断方向。
        Vector3 dirToPoint = toPoint.normalized; // 归一化目标方向。
        Vector3 dirForward = forward.normalized; // 归一化扇形朝向。
        float halfAngle = angleDegree * 0.5f; // 扇形判断要用半角。
        float cosHalf = Mathf.Cos(halfAngle * Mathf.Deg2Rad); // 把半角转弧度后求 cos。
        float dot = Vector3.Dot(dirForward, dirToPoint); // 点乘得到两个方向夹角的 cos 值。
        return dot >= cosHalf; // dot 大于等于 cosHalf,说明夹角小于等于半角。
    } // 函数结束。
} // 类结束。

面试加分说法

在 Unity 技能系统里,一般不会直接遍历全场所有敌人做扇形判断。更常见做法是:先用 Physics.OverlapSphere 找半径内候选目标,再用点乘判断角度,最后按距离、血量或优先级排序。

时间复杂度:单个点判断是 O(1),判断 N 个目标是 O(N)。 常见坑:把总角度当半角用、忘记归一化 forward、3D 地面技能没有投影到 XZ 平面。

算法中如何判断线段和圆是否相交?

algorithm-segment-circle-intersection

标准答案

判断线段和圆是否相交,最稳的思路是:找圆心到线段的最近点 P,如果圆心到 P 的距离小于等于半径 R,就相交。相切也算相交。

核心公式

c
AB = B - A
AC = C - A
t = dot(AC, AB) / |AB|²
t = Clamp01(t)
P = A + AB * t
相交条件:|P - C|² <=

Clamp01(t) 很关键,因为我们判断的是“线段”,不是无限长直线。

C# / Unity 写法

c
using UnityEngine; // 引入 Unity 的 Vector2 和 Mathf。

public static class Geometry2D // 定义 2D 几何工具类。
{ // 类开始。
    public static bool SegmentIntersectsCircle(Vector2 a, Vector2 b, Vector2 center, float radius) // 判断线段 AB 是否和圆相交。
    { // 函数开始。
        Vector2 ab = b - a; // 计算线段方向向量 AB。
        Vector2 ac = center - a; // 计算 A 指向圆心 C 的向量 AC。
        float abLenSqr = ab.sqrMagnitude; // 计算 AB 的平方长度,避免开根号。
        float radiusSqr = radius * radius; // 计算半径平方,用于平方距离比较。
        if (abLenSqr <= 0.000001f) // 如果线段长度几乎为 0,就退化成点和圆判断。
        { // if 开始。
            return (center - a).sqrMagnitude <= radiusSqr; // 判断端点 A 是否在圆内或圆上。
        } // if 结束。
        float t = Vector2.Dot(ac, ab) / abLenSqr; // 计算圆心投影到直线 AB 上的比例。
        t = Mathf.Clamp01(t); // 把投影比例限制在线段范围 0 到 1 内。
        Vector2 closest = a + ab * t; // 计算线段上离圆心最近的点。
        float distSqr = (center - closest).sqrMagnitude; // 计算圆心到最近点的平方距离。
        return distSqr <= radiusSqr; // 最近距离小于等于半径,说明线段和圆相交。
    } // 函数结束。
} // 类结束。

面试加分点

这个算法单次判断是 O(1)。 性能上用平方距离比较,避免 sqrt。 游戏里常用于:子弹轨迹检测圆形碰撞体、技能射线检测圆形目标、移动路径是否穿过危险区域。

常见坑是忘记 Clamp01,这样会把“无限长直线”和圆相交误判成“线段”和圆相交。

算法中如何做空间分区?

algorithm-spatial-partitioning

标准答案

空间分区就是:把地图空间按位置拆成小区域,物体放进对应区域;查询附近对象时,只查当前区域和邻近区域,而不是遍历全场。

它常用于碰撞检测、怪物感知、AOI、子弹命中、大量单位查询。核心目的是把暴力 O(N²) 的两两检查,变成“局部候选检查”。

常见方案

Spatial Hash / 均匀网格:适合大量动态对象,比如怪物、子弹、玩家 AOI。 Quadtree / Octree:适合对象分布不均的大地图,2D 用四叉树,3D 用八叉树。 BVH:适合射线检测、复杂 Mesh、静态场景加速。

面试里最常讲的是空间哈希网格:把坐标换算成格子坐标,然后用字典存。

c
using System.Collections.Generic; // 引入 Dictionary 和 List。
using UnityEngine; // 引入 Vector3、Vector2Int、Mathf。

public sealed class SpatialHashGrid<T> // 定义一个泛型空间哈希网格。
{ // 类开始。
    private readonly float cellSize; // 每个格子的边长。
    private readonly Dictionary<Vector2Int, List<T>> buckets = new Dictionary<Vector2Int, List<T>>(); // 格子到对象列表的映射。
    private readonly System.Func<T, Vector3> getPosition; // 外部传入的取位置函数。

    public SpatialHashGrid(float cellSize, System.Func<T, Vector3> getPosition) // 构造函数。
    { // 构造函数开始。
        this.cellSize = cellSize; // 保存格子大小。
        this.getPosition = getPosition; // 保存取位置函数。
    } // 构造函数结束。

    public void Clear() // 清空所有空间分区数据。
    { // 函数开始。
        buckets.Clear(); // 清空所有格子桶。
    } // 函数结束。

    public void Add(T obj) // 添加一个对象到空间分区。
    { // 函数开始。
        Vector2Int cell = GetCell(getPosition(obj)); // 根据对象位置计算格子坐标。
        if (!buckets.TryGetValue(cell, out List<T> list)) // 如果该格子还没有列表。
        { // if 开始。
            list = new List<T>(); // 创建新的对象列表。
            buckets[cell] = list; // 把列表放入字典。
        } // if 结束。
        list.Add(obj); // 把对象加入当前格子。
    } // 函数结束。

    public void Query(Vector3 center, float radius, List<T> results) // 查询圆形范围内的对象。
    { // 函数开始。
        results.Clear(); // 清空外部传入的结果列表,避免重复数据。
        int minX = Mathf.FloorToInt((center.x - radius) / cellSize); // 计算查询范围覆盖的最小 X 格子。
        int maxX = Mathf.FloorToInt((center.x + radius) / cellSize); // 计算查询范围覆盖的最大 X 格子。
        int minZ = Mathf.FloorToInt((center.z - radius) / cellSize); // 计算查询范围覆盖的最小 Z 格子。
        int maxZ = Mathf.FloorToInt((center.z + radius) / cellSize); // 计算查询范围覆盖的最大 Z 格子。
        float radiusSqr = radius * radius; // 计算半径平方,避免开根号。
        for (int x = minX; x <= maxX; x++) // 遍历覆盖到的 X 格子。
        { // 外层循环开始。
            for (int z = minZ; z <= maxZ; z++) // 遍历覆盖到的 Z 格子。
            { // 内层循环开始。
                Vector2Int cell = new Vector2Int(x, z); // 构造当前格子坐标。
                if (!buckets.TryGetValue(cell, out List<T> list)) continue; // 如果格子为空就跳过。
                foreach (T obj in list) // 遍历当前格子里的候选对象。
                { // foreach 开始。
                    Vector3 pos = getPosition(obj); // 获取候选对象的位置。
                    Vector3 delta = pos - center; // 计算对象到查询中心的向量。
                    delta.y = 0f; // 地面范围查询通常忽略高度。
                    if (delta.sqrMagnitude <= radiusSqr) // 判断对象是否真的在圆形范围内。
                    { // if 开始。
                        results.Add(obj); // 加入最终结果。
                    } // if 结束。
                } // foreach 结束。
            } // 内层循环结束。
        } // 外层循环结束。
    } // 函数结束。

    private Vector2Int GetCell(Vector3 position) // 根据世界坐标计算格子坐标。
    { // 函数开始。
        int x = Mathf.FloorToInt(position.x / cellSize); // 把世界 X 坐标映射到格子 X。
        int z = Mathf.FloorToInt(position.z / cellSize); // 把世界 Z 坐标映射到格子 Z。
        return new Vector2Int(x, z); // 返回二维格子坐标。
    } // 函数结束。
} // 类结束。

面试加分点

空间分区只是 Broad Phase,也就是粗筛。它只能减少候选数量,不能代替精确碰撞检测。比如先用网格找附近敌人,再用距离、扇形、射线或碰撞体做 Narrow Phase。

格子大小很关键:格子太大,候选对象还是很多;格子太小,对象跨格、查询邻格成本会变高。通常让格子大小接近查询半径或对象直径。

算法中如何做最近目标搜索?

algorithm-nearest-target-search

标准答案

最近目标搜索的核心就是:先筛掉不合法目标,再比较平方距离,记录距离最小的那个目标

如果目标很少,直接遍历 O(N) 就够了。 如果目标很多,比如上百上千个怪物、子弹、玩家,就先用空间分区、OverlapSphereNonAlloc 或 AOI 系统做粗筛,再在候选里找最近。

核心流程

1. 收集候选目标
2. 过滤死亡、友方、不可选中、超出范围的目标
3. 用平方距离比较,不开根号
4. 维护 bestTarget 和 bestDistSqr
5. 遍历结束后返回 bestTarget

只找最近一个目标时,不要先排序。排序是 O(N log N),维护最小值只要 O(N)

c
using System.Collections.Generic; // 引入 IReadOnlyList 集合接口。
using UnityEngine; // 引入 Unity 的 Vector3 和 MonoBehaviour。

public sealed class TargetInfo : MonoBehaviour // 定义目标信息组件。
{ // 类开始。
    public int Camp; // 目标所属阵营。
    public bool IsDead; // 目标是否已经死亡。
    public bool CanBeSelected = true; // 目标是否可以被选中。
} // 类结束。

public static class TargetSearchUtility // 定义目标搜索工具类。
{ // 类开始。
    public static TargetInfo FindNearestTarget(Vector3 center, IReadOnlyList<TargetInfo> targets, int selfCamp, float radius) // 查找最近合法目标。
    { // 函数开始。
        TargetInfo bestTarget = null; // 当前找到的最近目标。
        Vector3 flatCenter = center; // 复制搜索中心点。
        flatCenter.y = 0f; // 地面搜索通常忽略高度。
        float bestDistSqr = radius * radius; // 用半径平方作为初始最大距离。
        for (int i = 0; i < targets.Count; i++) // 遍历所有候选目标。
        { // 循环开始。
            TargetInfo target = targets[i]; // 取出当前目标。
            if (target == null) continue; // 空目标直接跳过。
            if (target.IsDead) continue; // 死亡目标直接跳过。
            if (!target.CanBeSelected) continue; // 不可选中目标直接跳过。
            if (target.Camp == selfCamp) continue; // 同阵营目标直接跳过。
            Vector3 targetPos = target.transform.position; // 获取目标世界坐标。
            targetPos.y = 0f; // 忽略高度,只比较 XZ 平面距离。
            Vector3 delta = targetPos - flatCenter; // 计算目标相对搜索中心的偏移。
            float distSqr = delta.sqrMagnitude; // 计算平方距离,避免开根号。
            if (distSqr > bestDistSqr) continue; // 超过当前最近距离就跳过。
            bestDistSqr = distSqr; // 更新当前最小平方距离。
            bestTarget = target; // 更新当前最近目标。
        } // 循环结束。
        return bestTarget; // 返回最近合法目标,没有则返回 null。
    } // 函数结束。
} // 类结束。

面试加分点

如果只是几十个目标,线性扫描最简单稳定。 如果是大量单位,先用空间分区减少候选数量,比如网格、四叉树、AOI、Physics.OverlapSphereNonAlloc。 如果“最近”不是唯一标准,可以改成评分函数,比如距离、血量、威胁值、职业优先级一起算。

常见坑:每帧 FindObjectOfType、每帧 LINQ OrderBy、忘记过滤死亡目标、用 Vector3.Distance 导致大量开根号。

算法中如何做怪物寻路避让?

algorithm-monster-pathfinding-avoidance

标准答案

保底抽卡本质是:带状态的概率随机。普通权重随机只看本次概率,保底抽卡还要看玩家之前抽了多少次。

常见规则是:

pityCount:距离上次稀有已经抽了多少次。 softPityStart:软保底开始抽数,之后概率逐步提高。 hardPity:硬保底抽数,到这里必出稀有。 guaranteeUp:如果上次稀有歪了,下次稀有强制 UP。

核心流程

1. 读取玩家保底状态
2. 根据 pityCount 计算本抽稀有概率
3. 如果达到 hardPity,强制出稀有
4. 否则按概率随机
5. 如果出了稀有,pityCount 清零
6. 如果没出稀有,pityCount + 1
7. 如果有 UP 规则,再判断是否歪和是否进入大保底
8. 扣资源、发奖励、更新状态、写日志要在同一个事务里

C# 简化实现

c
using System; // 引入 Random 类型。

public sealed class GachaState // 定义玩家抽卡状态。
{ // 类开始。
    public int PityCount; // 距离上次稀有已经抽了多少次。
    public bool GuaranteeUp; // 是否处于下次稀有必定 UP 的状态。
} // 类结束。

public sealed class GachaConfig // 定义抽卡配置。
{ // 类开始。
    public double BaseRareRate = 0.006d; // 基础稀有概率,例如 0.6%。
    public int SoftPityStart = 74; // 软保底开始抽数。
    public int HardPity = 90; // 硬保底抽数。
    public double SoftIncrease = 0.06d; // 软保底后每抽增加的概率。
    public double UpRate = 0.5d; // 非大保底时抽中 UP 的概率。
} // 类结束。

public sealed class GachaResult // 定义抽卡结果。
{ // 类开始。
    public bool IsRare; // 本次是否出了稀有。
    public bool IsUp; // 本次稀有是否为 UP。
    public int NewPityCount; // 抽完后的保底计数。
    public bool NewGuaranteeUp; // 抽完后的 UP 保底状态。
} // 类结束。

public static class GachaSystem // 定义抽卡系统。
{ // 类开始。
    public static GachaResult DrawOnce(GachaState state, GachaConfig config, Random random) // 执行一次抽卡。
    { // 函数开始。
        double rareRate = GetRareRate(state.PityCount, config); // 根据当前保底计数计算稀有概率。
        bool forceRare = state.PityCount + 1 >= config.HardPity; // 判断本抽是否达到硬保底。
        bool isRare = forceRare || random.NextDouble() < rareRate; // 硬保底强制出稀有,否则按概率随机。
        bool isUp = false; // 默认本次不是 UP。
        if (isRare) // 如果本次抽中了稀有。
        { // if 开始。
            isUp = state.GuaranteeUp || random.NextDouble() < config.UpRate; // 大保底必 UP,否则按 UP 概率随机。
            state.PityCount = 0; // 出稀有后清空稀有保底计数。
            state.GuaranteeUp = !isUp; // 如果这次歪了,下次稀有进入大保底。
        } // if 结束。
        else // 如果本次没有抽中稀有。
        { // else 开始。
            state.PityCount++; // 没出稀有时保底计数加一。
        } // else 结束。
        return new GachaResult // 返回抽卡结果对象。
        { // 对象初始化开始。
            IsRare = isRare, // 写入是否稀有。
            IsUp = isUp, // 写入是否 UP。
            NewPityCount = state.PityCount, // 写入新的保底计数。
            NewGuaranteeUp = state.GuaranteeUp // 写入新的 UP 保底状态。
        }; // 对象初始化结束。
    } // 函数结束。

    private static double GetRareRate(int pityCount, GachaConfig config) // 根据保底计数计算本抽概率。
    { // 函数开始。
        if (pityCount + 1 >= config.HardPity) return 1.0d; // 到硬保底时概率为 100%。
        if (pityCount + 1 < config.SoftPityStart) return config.BaseRareRate; // 未到软保底时使用基础概率。
        int overflow = pityCount + 1 - config.SoftPityStart; // 计算超过软保底起点的抽数。
        double rate = config.BaseRareRate + overflow * config.SoftIncrease; // 根据超过抽数逐步提高概率。
        return Math.Min(rate, 1.0d); // 概率最多不能超过 100%。
    } // 函数结束。
} // 类结束。

面试加分点

抽卡必须服务端权威结算,客户端不能决定抽中什么。 扣资源、发奖励、更新保底计数、写日志要在同一个事务里,避免扣了没发、发了没扣。 日志要记录玩家、池子、抽数、概率、随机种子或随机结果,方便查投诉和做概率审计。 十连抽本质上是连续执行十次单抽逻辑,但要注意每一抽都更新保底状态。

算法中如何做概率权重随机?

algorithm-weighted-random

标准答案

概率权重随机就是:权重越大,被抽中的概率越高。做法是先求总权重,再在 [0, total) 里随机一个数,然后从左到右累加权重,随机数落在哪个区间,就选哪个结果。

比如:

金币 weight = 10
宝石 weight = 30
装备 weight = 60
total = 100

那概率就是:

金币 10%
宝石 30%
装备 60%

基础算法

c
using System; // 引入 Random 类型。
using System.Collections.Generic; // 引入 IReadOnlyList 集合接口。

public sealed class WeightedItem<T> // 定义一个带权重的选项。
{ // 类开始。
    public T Value; // 保存真正要返回的值。
    public int Weight; // 保存该选项的权重。
} // 类结束。

public static class WeightedRandomUtility // 定义权重随机工具类。
{ // 类开始。
    public static T PickOne<T>(IReadOnlyList<WeightedItem<T>> items, Random random) // 从权重列表里随机选择一个结果。
    { // 函数开始。
        int totalWeight = 0; // 初始化总权重。
        for (int i = 0; i < items.Count; i++) // 遍历所有选项。
        { // 循环开始。
            if (items[i].Weight < 0) throw new ArgumentException("权重不能为负数"); // 负权重没有概率意义,直接报错。
            totalWeight += items[i].Weight; // 累加正权重和零权重。
        } // 循环结束。
        if (totalWeight <= 0) throw new ArgumentException("总权重必须大于 0"); // 没有任何可抽中的选项时直接报错。
        int roll = random.Next(0, totalWeight); // 在 [0, totalWeight) 范围内随机一个整数。
        int current = 0; // 初始化当前累加权重。
        for (int i = 0; i < items.Count; i++) // 再次遍历选项,寻找 roll 落在哪个区间。
        { // 循环开始。
            current += items[i].Weight; // 把当前选项权重加入累加值。
            if (roll < current) // 如果随机点落在当前区间内。
            { // if 开始。
                return items[i].Value; // 返回当前选项。
            } // if 结束。
        } // 循环结束。
        return items[items.Count - 1].Value; // 理论上不会走到这里,只作为兜底返回最后一项。
    } // 函数结束。
} // 类结束。

复杂度和优化

少量配置,比如几十个掉落项,用线性扫描就够了,时间复杂度 O(N)。 大量配置或频繁抽取,可以提前构建前缀和数组,抽取时二分,复杂度 O(log N)。 如果权重表固定且抽取极其频繁,可以用 Alias Method,构建 O(N),抽取 O(1)

游戏项目里的坑

权重不是百分比,不要求加起来等于 100,只看相对比例。 权重不能为负,总权重为 0 要有兜底。 强结算随机,比如抽卡、掉落、暴击,应该由服务端随机并记录日志。 不要用 OrderBy(x => Random) 做权重随机,它不但慢,还不是正确的权重抽取方式。

算法中如何做保底抽卡?

algorithm-gacha-pity

标准答案

保底抽卡本质是:带状态的概率随机。普通权重随机只看本次概率,保底抽卡还要看玩家之前抽了多少次。

常见规则是:

pityCount:距离上次稀有已经抽了多少次。 softPityStart:软保底开始抽数,之后概率逐步提高。 hardPity:硬保底抽数,到这里必出稀有。 guaranteeUp:如果上次稀有歪了,下次稀有强制 UP。

核心流程

1. 读取玩家保底状态
2. 根据 pityCount 计算本抽稀有概率
3. 如果达到 hardPity,强制出稀有
4. 否则按概率随机
5. 如果出了稀有,pityCount 清零
6. 如果没出稀有,pityCount + 1
7. 如果有 UP 规则,再判断是否歪和是否进入大保底
8. 扣资源、发奖励、更新状态、写日志要在同一个事务里

C# 简化实现

c
using System; // 引入 Random 类型。

public sealed class GachaState // 定义玩家抽卡状态。
{ // 类开始。
    public int PityCount; // 距离上次稀有已经抽了多少次。
    public bool GuaranteeUp; // 是否处于下次稀有必定 UP 的状态。
} // 类结束。

public sealed class GachaConfig // 定义抽卡配置。
{ // 类开始。
    public double BaseRareRate = 0.006d; // 基础稀有概率,例如 0.6%。
    public int SoftPityStart = 74; // 软保底开始抽数。
    public int HardPity = 90; // 硬保底抽数。
    public double SoftIncrease = 0.06d; // 软保底后每抽增加的概率。
    public double UpRate = 0.5d; // 非大保底时抽中 UP 的概率。
} // 类结束。

public sealed class GachaResult // 定义抽卡结果。
{ // 类开始。
    public bool IsRare; // 本次是否出了稀有。
    public bool IsUp; // 本次稀有是否为 UP。
    public int NewPityCount; // 抽完后的保底计数。
    public bool NewGuaranteeUp; // 抽完后的 UP 保底状态。
} // 类结束。

public static class GachaSystem // 定义抽卡系统。
{ // 类开始。
    public static GachaResult DrawOnce(GachaState state, GachaConfig config, Random random) // 执行一次抽卡。
    { // 函数开始。
        double rareRate = GetRareRate(state.PityCount, config); // 根据当前保底计数计算稀有概率。
        bool forceRare = state.PityCount + 1 >= config.HardPity; // 判断本抽是否达到硬保底。
        bool isRare = forceRare || random.NextDouble() < rareRate; // 硬保底强制出稀有,否则按概率随机。
        bool isUp = false; // 默认本次不是 UP。
        if (isRare) // 如果本次抽中了稀有。
        { // if 开始。
            isUp = state.GuaranteeUp || random.NextDouble() < config.UpRate; // 大保底必 UP,否则按 UP 概率随机。
            state.PityCount = 0; // 出稀有后清空稀有保底计数。
            state.GuaranteeUp = !isUp; // 如果这次歪了,下次稀有进入大保底。
        } // if 结束。
        else // 如果本次没有抽中稀有。
        { // else 开始。
            state.PityCount++; // 没出稀有时保底计数加一。
        } // else 结束。
        return new GachaResult // 返回抽卡结果对象。
        { // 对象初始化开始。
            IsRare = isRare, // 写入是否稀有。
            IsUp = isUp, // 写入是否 UP。
            NewPityCount = state.PityCount, // 写入新的保底计数。
            NewGuaranteeUp = state.GuaranteeUp // 写入新的 UP 保底状态。
        }; // 对象初始化结束。
    } // 函数结束。

    private static double GetRareRate(int pityCount, GachaConfig config) // 根据保底计数计算本抽概率。
    { // 函数开始。
        if (pityCount + 1 >= config.HardPity) return 1.0d; // 到硬保底时概率为 100%。
        if (pityCount + 1 < config.SoftPityStart) return config.BaseRareRate; // 未到软保底时使用基础概率。
        int overflow = pityCount + 1 - config.SoftPityStart; // 计算超过软保底起点的抽数。
        double rate = config.BaseRareRate + overflow * config.SoftIncrease; // 根据超过抽数逐步提高概率。
        return Math.Min(rate, 1.0d); // 概率最多不能超过 100%。
    } // 函数结束。
} // 类结束。

面试加分点

抽卡必须服务端权威结算,客户端不能决定抽中什么。 扣资源、发奖励、更新保底计数、写日志要在同一个事务里,避免扣了没发、发了没扣。 日志要记录玩家、池子、抽数、概率、随机种子或随机结果,方便查投诉和做概率审计。 十连抽本质上是连续执行十次单抽逻辑,但要注意每一抽都更新保底状态。

算法中如何做冷却队列?

algorithm-cooldown-queue

一句话定义 冷却队列就是把“冷却结束时间 endTime”放进一个最小堆里,让最早结束的冷却永远排在队头;每帧只检查队头,而不是扫描所有技能。

底层原理 释放技能时计算:

c
endTime = now + cooldown

然后把 (skillId, endTime, version) 放进最小堆。最小堆的特点是:堆顶永远是最早到期的冷却。每帧只看堆顶:

如果 heap.Peek().endTime > now,说明后面的都没到期
如果 heap.Peek().endTime <= now,就弹出并通知技能冷却完成

为了查询某个技能还剩多久,通常再配一个字典:

c
Dictionary<skillId, CooldownItem>

面试里可以说得漂亮一点:堆负责处理“谁先到期”,字典负责处理“某个技能还剩多久”。

C# 实现

c
using System; // 引入 Action 回调类型。
using System.Collections.Generic; // 引入 List 和 Dictionary 容器。

public sealed class CooldownQueue // 定义一个冷却队列类。
{ // 类开始。
    private struct CooldownItem // 定义堆里存放的冷却事件。
    { // 结构体开始。
        public int SkillId; // 技能 ID。
        public float EndTime; // 冷却结束时间。
        public int Version; // 版本号,用来丢弃旧冷却事件。
    } // 结构体结束。

    private readonly List<CooldownItem> heap = new List<CooldownItem>(); // 用数组实现最小堆。
    private readonly Dictionary<int, CooldownItem> latest = new Dictionary<int, CooldownItem>(); // 保存每个技能最新冷却状态。
    public Action<int> OnReady; // 冷却结束时通知外部。

    public void StartCooldown(int skillId, float now, float duration) // 开始或刷新某个技能冷却。
    { // 函数开始。
        int version = latest.TryGetValue(skillId, out CooldownItem oldItem) ? oldItem.Version + 1 : 1; // 刷新冷却时版本号加一。
        CooldownItem item = new CooldownItem { SkillId = skillId, EndTime = now + duration, Version = version }; // 创建新的冷却事件。
        latest[skillId] = item; // 记录这个技能当前最新的冷却信息。
        Push(item); // 把冷却事件放进最小堆。
    } // 函数结束。

    public float GetRemain(int skillId, float now) // 查询某个技能还剩多少冷却时间。
    { // 函数开始。
        if (!latest.TryGetValue(skillId, out CooldownItem item)) return 0f; // 没有冷却记录就表示已经可用。
        return Math.Max(0f, item.EndTime - now); // 返回剩余时间,并保证不小于 0。
    } // 函数结束。

    public void Update(float now) // 每帧调用,处理已经到期的冷却。
    { // 函数开始。
        while (heap.Count > 0 && heap[0].EndTime <= now) // 只要堆顶已经到期,就继续弹出。
        { // while 开始。
            CooldownItem item = Pop(); // 弹出最早到期的冷却事件。
            if (!latest.TryGetValue(item.SkillId, out CooldownItem latestItem)) continue; // 如果技能已经被清理,就跳过。
            if (latestItem.Version != item.Version) continue; // 如果版本不一致,说明这是旧事件,直接丢弃。
            latest.Remove(item.SkillId); // 移除冷却记录,表示技能已经冷却完成。
            OnReady?.Invoke(item.SkillId); // 通知外部这个技能已经可用。
        } // while 结束。
    } // 函数结束。

    private void Push(CooldownItem item) // 向最小堆插入一个事件。
    { // 函数开始。
        heap.Add(item); // 新元素先放到数组末尾。
        int index = heap.Count - 1; // 获取新元素下标。
        while (index > 0) // 从下往上调整堆。
        { // while 开始。
            int parent = (index - 1) / 2; // 计算父节点下标。
            if (heap[parent].EndTime <= heap[index].EndTime) break; // 父节点更早到期,说明堆已经合法。
            Swap(parent, index); // 父子节点交换。
            index = parent; // 继续向上检查。
        } // while 结束。
    } // 函数结束。

    private CooldownItem Pop() // 弹出最小堆堆顶。
    { // 函数开始。
        CooldownItem root = heap[0]; // 保存堆顶元素。
        heap[0] = heap[heap.Count - 1]; // 用最后一个元素覆盖堆顶。
        heap.RemoveAt(heap.Count - 1); // 删除最后一个元素。
        Heapify(0); // 从堆顶开始向下调整。
        return root; // 返回原来的堆顶元素。
    } // 函数结束。

    private void Heapify(int index) // 向下调整最小堆。
    { // 函数开始。
        while (true) // 一直调整到堆合法为止。
        { // while 开始。
            int left = index * 2 + 1; // 计算左子节点下标。
            int right = index * 2 + 2; // 计算右子节点下标。
            int smallest = index; // 假设当前节点最小。
            if (left < heap.Count && heap[left].EndTime < heap[smallest].EndTime) smallest = left; // 左子节点更小就更新。
            if (right < heap.Count && heap[right].EndTime < heap[smallest].EndTime) smallest = right; // 右子节点更小就更新。
            if (smallest == index) break; // 当前节点已经最小,停止调整。
            Swap(index, smallest); // 和更小的子节点交换。
            index = smallest; // 继续向下检查。
        } // while 结束。
    } // 函数结束。

    private void Swap(int a, int b) // 交换堆里的两个元素。
    { // 函数开始。
        CooldownItem temp = heap[a]; // 暂存第一个元素。
        heap[a] = heap[b]; // 第二个元素放到第一个位置。
        heap[b] = temp; // 暂存元素放到第二个位置。
    } // 函数结束。
} // 类结束。

复杂度 开始冷却:O(logN)。 冷却到期弹出:O(logN)。 查看最早到期元素:O(1)。 查询某个技能剩余时间:字典 O(1)

Unity 场景里怎么用 技能 CD、Buff 到期、任务倒计时、活动刷新都可以这么做。少量技能直接用字典也行;如果有大量 Buff、技能、子弹延迟、定时器,再用最小堆或时间轮更合适。

常见坑 刷新冷却时旧事件还在堆里,所以要用 versiontoken 防止旧事件误触发。暂停系统里要区分 Time.timeTime.unscaledTime 和服务器时间,别把本地时间和服务端战斗时间混在一起。

算法中如何做优先级技能选择?

algorithm-priority-skill-selection

标准答案 优先级技能选择一般不是“按数组第一个能放就放”,而是:候选技能池 → 硬过滤 → 权重打分 → 稳定选最高分。硬过滤解决“能不能放”,打分解决“哪个更值得放”。

底层原理 先过滤掉冷却没好、蓝量不够、距离不够、目标死亡、当前状态不能释放的技能。剩下的技能再算分:

score = 基础优先级 + 伤害收益 + 命中人数收益 + 距离收益 + 血量策略收益

最后选 score 最高的技能。如果分数一样,要用稳定规则,比如先比配置优先级,再比 skillId。这样 AI、回放、联机同步结果更稳定。

C# 示例

c
using System; // 引入 Math 工具类。
using System.Collections.Generic; // 引入 List 集合。

public sealed class SkillSelector // 定义技能选择器。
{ // 类开始。
    public sealed class SkillConfig // 定义技能配置数据。
    { // 配置类开始。
        public int SkillId; // 技能 ID。
        public int Priority; // 策划配置的基础优先级。
        public float MpCost; // 技能消耗的蓝量。
        public float Range; // 技能释放距离。
        public float DamageScore; // 技能伤害收益评分。
        public float ControlScore; // 技能控制收益评分。
    } // 配置类结束。

    public sealed class SkillContext // 定义当前战斗上下文。
    { // 上下文类开始。
        public float CurrentMp; // 当前角色蓝量。
        public float TargetDistance; // 当前目标距离。
        public bool HasTarget; // 是否有目标。
        public bool TargetAlive; // 目标是否存活。
        public bool CanCastByState; // 当前状态是否允许释放技能。
    } // 上下文类结束。

    public int ChooseBestSkill(List<SkillConfig> skills, SkillContext context) // 从技能列表里选择最优技能。
    { // 函数开始。
        int bestSkillId = -1; // 记录当前最优技能 ID。
        float bestScore = float.MinValue; // 记录当前最高分。
        int bestPriority = int.MinValue; // 记录当前最高优先级。
        for (int i = 0; i < skills.Count; i++) // 遍历所有候选技能。
        { // 循环开始。
            SkillConfig skill = skills[i]; // 取出当前技能配置。
            if (!CanCast(skill, context)) continue; // 不能释放就直接跳过。
            float score = GetScore(skill, context); // 计算当前技能得分。
            bool betterScore = score > bestScore; // 判断当前技能是否分数更高。
            bool sameScoreBetterPriority = score == bestScore && skill.Priority > bestPriority; // 分数相同就比较配置优先级。
            bool samePriorityBetterId = score == bestScore && skill.Priority == bestPriority && skill.SkillId < bestSkillId; // 再相同就用更小 ID 保证稳定。
            if (betterScore || sameScoreBetterPriority || samePriorityBetterId) // 如果当前技能更优就替换结果。
            { // if 开始。
                bestSkillId = skill.SkillId; // 更新最优技能 ID。
                bestScore = score; // 更新最高分。
                bestPriority = skill.Priority; // 更新最高优先级。
            } // if 结束。
        } // 循环结束。
        return bestSkillId; // 返回最终选择的技能 ID。
    } // 函数结束。

    private bool CanCast(SkillConfig skill, SkillContext context) // 判断技能是否满足硬条件。
    { // 函数开始。
        if (!context.HasTarget) return false; // 没有目标不能释放。
        if (!context.TargetAlive) return false; // 目标死亡不能释放。
        if (!context.CanCastByState) return false; // 当前状态不允许释放就失败。
        if (context.CurrentMp < skill.MpCost) return false; // 蓝量不足就失败。
        if (context.TargetDistance > skill.Range) return false; // 目标超出范围就失败。
        return true; // 所有硬条件通过,可以进入评分。
    } // 函数结束。

    private float GetScore(SkillConfig skill, SkillContext context) // 计算技能评分。
    { // 函数开始。
        float score = 0f; // 初始化分数。
        score += skill.Priority * 1000f; // 基础优先级权重最大。
        score += skill.DamageScore; // 加上伤害收益。
        score += skill.ControlScore; // 加上控制收益。
        score -= Math.Abs(context.TargetDistance - skill.Range * 0.7f) * 10f; // 距离越接近理想释放距离越好。
        return score; // 返回最终分数。
    } // 函数结束。
} // 类结束。

Unity 面试说法 我会把技能选择拆成配置层和运行时层:配置表里放基础优先级、距离、消耗、目标类型、评分权重;运行时只负责读取当前上下文,比如 CD、蓝量、目标距离、目标血量、角色状态。这样后面加新技能时主要改配置,不需要在代码里堆大量 if else

常见坑 只按伤害选技能会很蠢:近战技能可能够不到,治疗技能可能应该在低血量时优先,控制技能可能在 Boss 读条时价值更高。还有一点,联机或回放里不要随便随机选同分技能,要做稳定 Tie-Break。

算法中如何做行为树节点执行?

algorithm-behavior-tree-node-execution

标准答案 行为树节点执行的核心是:从根节点开始 Tick,每个节点返回 SuccessFailureRunning 三种状态,父节点根据子节点返回值决定继续、失败、成功或下次接着跑。

底层原理 行为树不是一次性把整棵树跑完,而是每帧或每隔几帧调用一次 Root.Tick()。叶子节点负责真正行为,比如判断敌人、移动、攻击;组合节点负责控制流程。

Sequence 像“并且”:所有子节点都成功才成功,遇到失败立刻失败。 Selector 像“或者”:有一个子节点成功就成功,全部失败才失败。 Running 表示当前行为还没完成,比如“移动到目标点”需要多帧执行,所以父节点要记住当前运行到哪个子节点。

C# 简化实现

c
using System.Collections.Generic; // 引入 List 集合。

public enum BTStatus // 定义行为树节点返回状态。
{ // 枚举开始。
    Success, // 表示节点执行成功。
    Failure, // 表示节点执行失败。
    Running // 表示节点还在执行中。
} // 枚举结束。

public sealed class BTContext // 定义行为树上下文,也叫黑板数据。
{ // 类开始。
    public object Target; // 保存当前目标。
    public float DistanceToTarget; // 保存和目标的距离。
    public bool CanSeeTarget; // 保存是否看到目标。
} // 类结束。

public abstract class BTNode // 定义行为树节点基类。
{ // 类开始。
    public abstract BTStatus Tick(BTContext context); // 每次执行节点时调用 Tick。
    public virtual void Reset() { } // 节点结束或中断时重置状态。
} // 类结束。

public sealed class SequenceNode : BTNode // 定义顺序节点。
{ // 类开始。
    private readonly List<BTNode> children; // 保存子节点列表。
    private int runningIndex; // 记录当前运行到第几个子节点。

    public SequenceNode(List<BTNode> children) // 构造顺序节点。
    { // 构造函数开始。
        this.children = children; // 保存外部传入的子节点。
    } // 构造函数结束。

    public override BTStatus Tick(BTContext context) // 执行顺序节点。
    { // 函数开始。
        while (runningIndex < children.Count) // 从当前运行子节点开始往后执行。
        { // 循环开始。
            BTStatus status = children[runningIndex].Tick(context); // 执行当前子节点。
            if (status == BTStatus.Running) return BTStatus.Running; // 子节点还在跑,父节点也返回 Running。
            if (status == BTStatus.Failure) // 如果子节点失败。
            { // if 开始。
                Reset(); // 重置顺序节点状态。
                return BTStatus.Failure; // 顺序节点直接失败。
            } // if 结束。
            runningIndex++; // 当前子节点成功,继续下一个子节点。
        } // 循环结束。
        Reset(); // 所有子节点成功后重置状态。
        return BTStatus.Success; // 顺序节点执行成功。
    } // 函数结束。

    public override void Reset() // 重置顺序节点。
    { // 函数开始。
        runningIndex = 0; // 下次从第一个子节点重新开始。
        for (int i = 0; i < children.Count; i++) children[i].Reset(); // 重置所有子节点。
    } // 函数结束。
} // 类结束。

public sealed class SelectorNode : BTNode // 定义选择节点。
{ // 类开始。
    private readonly List<BTNode> children; // 保存子节点列表。
    private int runningIndex; // 记录当前运行到第几个子节点。

    public SelectorNode(List<BTNode> children) // 构造选择节点。
    { // 构造函数开始。
        this.children = children; // 保存外部传入的子节点。
    } // 构造函数结束。

    public override BTStatus Tick(BTContext context) // 执行选择节点。
    { // 函数开始。
        while (runningIndex < children.Count) // 从当前运行子节点开始往后执行。
        { // 循环开始。
            BTStatus status = children[runningIndex].Tick(context); // 执行当前子节点。
            if (status == BTStatus.Running) return BTStatus.Running; // 子节点还在跑,选择节点也返回 Running。
            if (status == BTStatus.Success) // 如果某个子节点成功。
            { // if 开始。
                Reset(); // 重置选择节点状态。
                return BTStatus.Success; // 选择节点直接成功。
            } // if 结束。
            runningIndex++; // 当前子节点失败,尝试下一个子节点。
        } // 循环结束。
        Reset(); // 全部子节点失败后重置状态。
        return BTStatus.Failure; // 选择节点执行失败。
    } // 函数结束。
} // 类结束。

Unity 项目里怎么说 在怪物 AI 里,根节点可能是 Selector:先尝试攻击玩家,如果看不到玩家或距离不够,就切到追击;如果没有目标,就巡逻。移动、攻击这种行为通常会返回 Running,直到移动到位或动画播放到命中帧才返回 Success

常见坑 叶子节点不要写阻塞逻辑,比如 while 等到移动结束,这会卡主线程。大量怪物不要每帧全量 Tick,可以分帧更新。Running 节点要能被打断,否则怪物可能一直卡在“追击”或“攻击前摇”里。

项目中如何证明你写的模块可扩展?

project-module-extensibility-proof

标准答案 项目里证明模块可扩展,不能只说“我用了接口、用了继承”。更有说服力的说法是:当新需求来了,我的核心流程不用改,只需要新增配置、新增策略类,并且有测试和工具能验证不会影响旧功能。

面试可以这样说 我一般从四点证明模块可扩展:第一,模块边界清楚,数据层、逻辑层、表现层分开;第二,扩展点明确,比如技能效果、Buff 效果、任务条件都抽象成策略;第三,规则配置化,策划改参数不需要改代码;第四,有实际新增需求案例,比如后来新增“护盾 Buff”或“吸血技能”,我只加了配置和一个效果类,核心释放流程、结算流程、UI 刷新流程都没有改。

代码层证明

c
public interface ISkillEffect // 定义技能效果扩展点。
{ // 接口开始。
    void Apply(SkillContext context, EffectConfig config); // 所有技能效果都实现统一执行入口。
} // 接口结束。

public sealed class DamageEffect : ISkillEffect // 伤害效果只是其中一种实现。
{ // 类开始。
    public void Apply(SkillContext context, EffectConfig config) // 执行伤害效果。
    { // 函数开始。
        context.Target.TakeDamage(config.Value); // 根据配置数值扣除目标血量。
    } // 函数结束。
} // 类结束。

public sealed class ShieldEffect : ISkillEffect // 新增护盾效果时,只新增一个类。
{ // 类开始。
    public void Apply(SkillContext context, EffectConfig config) // 执行护盾效果。
    { // 函数开始。
        context.Caster.AddShield(config.Value); // 根据配置数值给释放者加护盾。
    } // 函数结束。
} // 类结束。

public sealed class SkillEffectFactory // 定义技能效果工厂。
{ // 类开始。
    private readonly Dictionary<string, ISkillEffect> effects = new Dictionary<string, ISkillEffect>(); // 保存效果类型到实现类的映射。
    public void Register(string type, ISkillEffect effect) => effects[type] = effect; // 注册一种新的效果类型。
    public ISkillEffect Get(string type) => effects[type]; // 根据配置里的类型取出对应效果。
} // 类结束。

更打动面试官的表达 我会说:“我不是为了抽象而抽象,而是根据变化点来设计扩展点。比如技能系统里稳定的是释放流程,容易变化的是效果类型、目标筛选、数值参数。所以我把释放流程固定,把效果和规则做成配置加策略。后面新增一个技能,主要是加配置和效果类,而不是到核心流程里加一堆 if else。”

可以量化的证明 新增一个普通技能从原来半天改代码,降到十几分钟配表加测试;核心模块文件改动很少;配置检查工具能提前发现字段缺失、类型错误;旧技能有回归测试,能证明新增功能没有破坏旧逻辑。

项目中如何证明你做过性能优化?

project-performance-optimization-proof

标准答案 项目中证明做过性能优化,核心不是说“我用了对象池、我优化了 UI”,而是要拿出:现象、工具、瓶颈、方案、前后数据对比、后续防复发机制

面试回答模板 我会先说具体场景:比如战斗中怪物和特效变多后,低端机出现掉帧。然后我用 Unity Profiler 在真机上采样,发现 Main Thread 里 AI 更新和 GC Alloc 比较高。之后我做了对象池、缓存组件引用、AI 分帧、减少 LINQ 和字符串拼接。最后用同一台设备、同一场景、同一怪物数量对比数据。

比如可以这样说:

优化前:平均 42 FPS,Main Thread 约 24ms,GC Alloc 约 1.2MB/frame
优化后:平均 58 FPS,Main Thread 约 15ms,GC Alloc 接近 0KB/frame

代码层怎么证明你真的做过

c
using Unity.Profiling; // 引入 Unity ProfilerMarker,用来给自定义模块打性能采样点。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 生命周期。

public sealed class MonsterAISystem : MonoBehaviour // 定义怪物 AI 系统脚本。
{ // 类开始。
    private static readonly ProfilerMarker TickMarker = new ProfilerMarker("MonsterAI.Tick"); // 创建 AI Tick 的性能标记。
    private void Update() // Unity 每帧调用 Update。
    { // 函数开始。
        using (TickMarker.Auto()) // 进入 Profiler 采样范围。
        { // using 开始。
            UpdateMonsterAI(); // 执行怪物 AI 更新逻辑。
        } // using 结束后自动结束采样。
    } // 函数结束。
    private void UpdateMonsterAI() // 定义实际 AI 更新函数。
    { // 函数开始。
        // 在这里写巡逻、追击、攻击、分帧更新等逻辑。 // 说明这里是被采样的业务代码。
    } // 函数结束。
} // 类结束。

回答时要强调的点 同样的数据必须在同一设备、同一场景、同一画质、同一怪物数量下测,否则对比没有说服力。优化后还要讲代价,比如对象池是用内存换 CPU 和 GC,AI 分帧是用响应延迟换主线程稳定。

常见扣分说法 只说“我用了对象池”,但说不出回收时机、重复回收、峰值内存。只说“我看了 Profiler”,但说不出看的是 Main ThreadGC AllocCanvas.BuildBatchCamera.Render 还是 Physics.Simulate。面试官要的是证据,不是口号。

项目中如何证明你能解决复杂 Bug?

project-complex-bug-solving-proof

标准答案 项目里证明自己能解决复杂 Bug,重点不是说“我修好了”,而是要讲清楚:现象是什么、怎么复现、怎么收集证据、怎么缩小范围、根因是什么、怎么验证修复、怎么防止复发

面试回答模板 我会先描述一个真实场景:比如战斗中偶现角色卡死,玩家不能移动,但动画还在播放。这个问题一开始复现率很低,所以我没有直接猜原因,而是先补日志,记录角色状态机状态、输入、坐标、技能 ID、当前帧号和场景信息。后来通过日志发现,角色被技能打断后,状态退出时没有清理 canMove = false,导致移动一直被锁住。

关键是讲出定位过程 我先确认问题发生条件,再加关键日志,把“偶现”变成有上下文的问题。然后通过关闭部分技能、简化场景、二分最近改动,把范围缩小到状态机和技能打断逻辑。最后定位到根因:某个攻击状态被中断时没有执行统一 OnExit 清理。

代码层可以这样证明你有排查意识

c
using UnityEngine; // 引入 Unity 基础类型。

public sealed class RoleDebugLogger // 定义角色调试日志工具。
{ // 类开始。
    public static void LogState(string roleId, string state, bool canMove, Vector3 position, int frame) // 记录角色关键状态。
    { // 函数开始。
        Debug.Log($"role={roleId}, state={state}, canMove={canMove}, pos={position}, frame={frame}"); // 输出能定位问题的上下文。
    } // 函数结束。
} // 类结束。

修复后怎么证明有效 修复不是改一行就结束。我会补一个最小复现场景,修复前能稳定触发,修复后连续跑 1000 次不再出现。同时在线上日志里加保护,如果发现角色长时间 canMove=false 但没有控制状态,就主动上报异常。

面试加分表达 “我解决复杂 Bug 的习惯是先收集证据,而不是凭感觉改代码。复杂 Bug 通常不是一个点的问题,而是状态、时序、资源或异步流程组合出来的,所以我会先让问题可观测,再缩小范围,最后用回归用例证明修复没有引入新问题。”

项目中如何证明你能多人协作?

project-team-collaboration-proof

标准答案 项目中证明能多人协作,不能只说“我沟通能力好”。更有说服力的是:我参与过需求拆分、接口对齐、文档同步、代码评审、联调验收,并且有 PR、文档、提交记录、联调单这些证据。

面试可以这样说 我在项目里不是只负责自己写完功能,而是会先和策划确认规则边界,和美术确认资源规范,和后端或其他客户端同学确认数据结构、事件时机和错误处理。比如做技能系统时,我负责客户端释放流程,但 UI 需要显示 CD,动画需要同步命中帧,特效需要挂点,服务端需要协议字段,所以我会先把接口和事件定义清楚,再拆任务开发。

怎么证明你真的协作过 我会拿具体证据说:有需求拆分记录、接口文档、PR 评审记录、联调问题单、提交记录。比如某个功能我不是一次提交几千行,而是按“数据结构、逻辑流程、表现接入、联调修复”分成小提交,方便别人 review,也方便出问题时回滚。

更打动面试官的表达 “我理解多人协作的重点不是多开会,而是降低别人接入我的成本。比如我会把接口定义、调用时机、异常返回、资源命名、配置字段写清楚;如果有冲突,我会先对齐目标,再讨论方案代价,而不是只坚持自己的实现。”

常见扣分说法 只说“我会沟通”“我和同学一起做过项目”,但说不出自己怎么拆任务、怎么处理接口变化、怎么解决冲突、怎么保证别人能接入。面试官想听的是协作机制和真实证据。

项目中如何证明你理解业务和工程取舍?

project-business-engineering-tradeoff-proof

标准答案 项目中证明你理解业务和工程取舍,不能只说“我代码写得好”。更有说服力的是:我能先理解业务目标,再识别工程约束,然后对比多个方案的成本、风险和收益,最后用数据验证这个选择是合适的。

面试可以这样说 我不会只从技术完美角度看问题。比如活动版本两周后必须上线,如果我选择大重构,技术上可能更漂亮,但风险很高,也可能影响进度。所以我会先保证核心玩法闭环和上线稳定,把关键路径做扎实;对后续可能变化的地方留扩展点,把非核心优化放到后续迭代。

具体例子 比如做资源加载时,最完美的方案可能是完整重构资源系统,但版本时间不允许。我会选择先把活动资源拆到热更包,做依赖校验、下载失败重试、加载兜底和内存峰值控制。这样业务上能按时上线,工程上也守住了包体、加载时间和稳定性底线。

回答时要讲清楚取舍 我会明确说:这个方案的好处是上线快、风险低、能满足当前业务;代价是架构没有一步到位,后续如果活动类型继续变多,需要再补更完整的资源管理和配置工具。这样面试官会觉得你不是只会写功能,而是知道什么时候该做长期设计,什么时候该先交付稳定版本。

能打动面试官的关键词 业务目标、工程约束、方案对比、成本、风险、指标、复盘。最重要的是别只说“我觉得这样更好”,而是说“为什么这个阶段这样做最值”。

项目中如何讲清楚一个技术难点?

project-technical-difficulty-explanation

标准答案 讲技术难点时,不要只说“这个模块很复杂”。要讲成一条清晰链路:项目背景是什么,难点具体难在哪里,我怎么拆分,最后怎么实现,结果如何验证,如果重做会怎么改。

面试可以这样说 我会先用一句话交代背景:比如我负责的是技能系统,它要支持大量技能配置,同时还要处理动画、命中帧、特效、伤害结算和后续扩展。这个难点不是“代码多”,而是状态组合多、时序复杂、表现和逻辑要同步、后续还要方便加新技能

然后讲拆法 我把它拆成配置层、运行层、表现层。配置层描述技能 ID、目标类型、伤害段数、命中时间;运行层负责技能状态、CD、打断、伤害结算;表现层负责动画、特效、音效和镜头。中间用事件或时间轴把它们串起来,避免动画代码和战斗逻辑互相写死。

最后讲验证和取舍 验证上,我会说新增一个技能需要改多少代码、是否能通过配置完成、战斗帧率有没有下降、命中帧是否稳定。取舍上,我会补一句:当时没有做特别复杂的可视化编辑器,因为项目时间不够,我先做了配置表加校验工具,保证能上线,后续再补编辑器。

好用的回答顺序 背景一句话,难点具体化,方案拆模块,关键实现,数据验证,复盘取舍。 这样面试官会觉得你不是在背技术名词,而是真的做过、踩过坑、也知道为什么这么设计。

项目中如何讲清楚一次失败经历?

project-failure-experience-explanation

标准答案 讲失败经历时,重点不是证明你没犯过错,而是证明你能主动承担、及时补救、复盘原因、形成机制。不要甩锅,也不要讲特别严重、不可挽回的事故。

面试可以这样说 我有一次失败经历是在做背包 UI 时,前期只按少量道具设计,没有提前考虑大量道具场景。结果后期数据量上来后,打开背包会卡顿,列表刷新也比较慢,最后不得不返工做虚拟列表和对象池。

原因怎么讲 这个问题主要责任在我,当时我更关注功能能不能跑通,没有提前做性能预估,也没有和策划确认后期道具数量上限。后来我意识到,UI 系统不能只按 Demo 数据设计,应该提前考虑真实数据规模。

补救怎么讲 我当时先做了分页和对象池止损,减少一次性实例化大量 Item。后面又把 ScrollView 改成虚拟列表,只创建可见区域的格子,并补了一个测试数据入口,可以快速生成几百上千个道具来压测。

复盘怎么讲才加分 这次之后,我形成了一个习惯:做 UI、背包、排行榜、配置表这类系统时,会先问数据上限,再做性能预算;同时给模块补测试入口和检查表。这样不是只修了一个 Bug,而是把个人教训变成了后续开发流程。

一句话收尾 这次失败让我意识到,项目开发不是只把功能做出来,还要提前评估真实规模、性能边界和后续维护成本。

项目中如何讲清楚重构方向?

project-refactor-direction-explanation

标准答案 讲重构方向时,核心不是说“代码太乱,我想重写”,而是要说清楚:旧系统痛点是什么、重构目标是什么、哪些边界不动、怎么分阶段迁移、如何验证收益、出问题怎么回滚。

面试可以这样说 我会先讲为什么要重构,比如资源加载逻辑散落在 UI、战斗和场景模块里,导致重复加载、卸载不清、切场景峰值内存高。这个时候重构方向不是全部推倒重写,而是先统一资源入口,把加载、引用计数、释放和异常兜底收口到资源管理层。

怎么讲重构路线 第一阶段先包一层 ResourceFacade,让外部调用方式稳定下来。第二阶段新功能全部走新接口,不再继续增加旧逻辑。第三阶段逐步迁移旧模块,比如 UI、角色、场景加载。第四阶段等测试和线上数据稳定后,再删除旧入口。

怎么讲验证 重构后我会用指标证明收益,比如重复加载次数下降、切场景峰值内存下降、资源泄漏减少、加载流程日志更清晰。还要补测试和日志,确保出问题能回滚到旧入口。

最加分的一句话 “我理解重构不是追求架构好看,而是用可控风险解决真实痛点。我的原则是先稳定外部行为,再逐步替换内部实现,每一步都能验证和回滚。”

项目中如何讲清楚上线风险?

project-launch-risk-explanation

标准答案 讲上线风险时,不能只说“上线前多测几遍”。更成熟的回答是:我会先列风险清单,再按影响范围和发生概率分级,然后准备灰度、开关、降级、监控和回滚预案。

面试可以这样说 我会把上线风险分成几类:代码风险、资源风险、配置风险、协议风险、SDK 风险和性能风险。比如热更新资源可能下载失败,配置表可能字段不兼容,支付 SDK 可能在部分机型回调异常,战斗逻辑改动可能导致线上不同步。

上线前怎么控风险 上线前我会做 Checklist:关键链路回归、真机测试、配置表校验、资源 Hash 校验、包体和内存检查。高风险功能要有负责人和回滚方案,不能只靠“大家测一下”。

上线中怎么控风险 我会先灰度小比例用户,看崩溃率、登录成功率、资源下载失败率、支付成功率、错误码数量。如果指标异常,就暂停扩大灰度,用功能开关或配置降级先止血。

上线后怎么控风险 上线后重点看监控和日志,比如崩溃、卡顿、下载失败、热更失败、支付异常。如果是资源或配置问题,优先走热更回滚;如果是代码问题,要评估是否功能关闭、强更修复或回退版本。

一句话收尾 上线风险的核心不是“保证不出问题”,而是出问题时能快速发现、快速止损、快速定位、快速恢复。

项目中如何讲清楚自己比其他候选人的优势?

project-candidate-advantage-explanation

标准答案 讲自己比其他候选人的优势时,不要贬低别人,也不要空喊“我更努力、更热爱游戏”。更好的说法是:岗位需要什么能力,我就用项目证据证明我在这些能力上更匹配。

面试可以这样说 我的优势主要有三点。第一,我不是只会用 Unity API,我会追到底层原理,比如 GC、协程、资源生命周期、渲染和性能瓶颈。第二,我的项目不是零散功能,而是有完整玩法闭环,有战斗、UI、资源、对象池、状态机和性能优化这些模块。第三,我能用数据和复盘证明结果,比如优化前后帧率、GC Alloc、加载时间的变化。

更自然的表达 “我不太想说自己一定比所有候选人强,但我觉得我和这个岗位比较匹配。因为游戏客户端需要的不只是写功能,还要能定位问题、做性能优化、理解资源和生命周期、能和策划美术协作。我在项目里都有对应实践,也能现场讲清楚取舍和踩过的坑。”

最加分的一句 “我的优势不是某一个点特别夸张,而是基础、项目、工程意识和复盘能力比较完整。遇到问题时,我会先用工具定位,用数据验证,而不是凭感觉改。”

不要这样说 不要说“我比别人更努力”“我学习能力很强”这种空话。可以说,但后面必须接证据:做过什么模块、解决过什么问题、优化了多少、失败后怎么改。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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