Skip to content

5 分钟追问

讲清楚对象池完整生命周期

unity-object-pool-full-lifecycle

标准答案

对象池完整生命周期是:创建池 → 预热对象 → 取出对象 → 使用对象 → 归还对象 → 重置状态 → 再次复用 → 最终释放池子。 它的本质是用一部分常驻内存,换掉频繁 Instantiate / Destroy 带来的 CPU 开销、GC Alloc 和帧尖峰。

完整生命周期

  1. 创建池:记录 Prefab、初始容量、最大容量、父节点、扩容策略。
  2. 预热:加载阶段提前 Instantiate 一批对象,全部 SetActive(false) 放进空闲队列。
  3. 取出:需要对象时从空闲队列取;不够时扩容,或达到上限后返回空。
  4. 激活:设置位置、旋转、父节点、数据,调用 OnSpawn
  5. 使用中:比如子弹飞行、特效播放、飘字移动、怪物 AI 运行。
  6. 归还:命中、超时、播放结束、窗口关闭、场景切换时调用 Release
  7. 重置:清理速度、目标、计时器、粒子、Trail、事件监听、协程等状态。
  8. 复用:重新进入空闲队列,等待下一次 Spawn
  9. 释放池:场景结束、模块关闭、资源卸载时,把池内对象真正 Destroy

底层原理

Instantiate 不只是 C# new,它会创建 Unity 原生对象、Transform 层级、组件、渲染数据等;Destroy 也不是完全无成本,会触发生命周期处理和后续释放。 如果子弹、特效、飘字这种对象频繁创建销毁,就会造成 CPU 抖动和 GC 压力。对象池把这些成本前移到加载阶段,让战斗中的帧时间更稳定。

简化版代码

c
using System.Collections.Generic; // 引入 Queue 和 HashSet 容器。  
using UnityEngine; // 引入 Unity 引擎 API。  

public interface IPoolable // 定义池对象生命周期接口。  
{ // 接口开始。  
    void OnSpawn(); // 对象从池中取出时调用。  
    void OnDespawn(); // 对象归还到池中时调用。  
} // 接口结束。  

public sealed class ComponentPool<T> where T : Component, IPoolable // 定义一个管理 Component 的泛型对象池。  
{ // 类开始。  
    private readonly T prefab; // 保存用于创建对象的预制体组件。  
    private readonly Transform root; // 保存池对象回收后的父节点。  
    private readonly int maxCount; // 保存池子的最大对象数量。  
    private readonly Queue<T> inactive = new Queue<T>(); // 保存空闲对象队列。  
    private readonly HashSet<T> active = new HashSet<T>(); // 保存正在使用的对象集合。  
    private readonly HashSet<T> inPool = new HashSet<T>(); // 保存已经归还的对象,用来防重复归还。  

    private int TotalCount => active.Count + inactive.Count; // 计算当前池子总对象数量。  

    public ComponentPool(T prefab, Transform root, int initialCount, int maxCount) // 构造对象池。  
    { // 构造函数开始。  
        this.prefab = prefab; // 记录预制体。  
        this.root = root; // 记录池根节点。  
        this.maxCount = maxCount; // 记录最大容量。  
        Prewarm(initialCount); // 创建池时先预热一批对象。  
    } // 构造函数结束。  

    private void Prewarm(int count) // 预热指定数量的对象。  
    { // 方法开始。  
        for (int i = 0; i < count; i++) // 循环创建初始对象。  
        { // 循环开始。  
            T item = CreateRaw(); // 创建一个未激活的对象。  
            if (item == null) return; // 如果达到上限就停止预热。  
            inactive.Enqueue(item); // 把对象放入空闲队列。  
            inPool.Add(item); // 标记对象当前已经在池中。  
        } // 循环结束。  
    } // 方法结束。  

    private T CreateRaw() // 创建一个新的池对象。  
    { // 方法开始。  
        if (TotalCount >= maxCount) return null; // 超过最大容量时拒绝创建。  
        T item = Object.Instantiate(prefab, root); // 实例化一个新对象。  
        item.gameObject.SetActive(false); // 新对象默认隐藏,等待取出。  
        return item; // 返回新创建的对象。  
    } // 方法结束。  

    public T Spawn(Vector3 position, Quaternion rotation, Transform parent = null) // 从池中取出对象。  
    { // 方法开始。  
        T item = inactive.Count > 0 ? inactive.Dequeue() : CreateRaw(); // 优先复用空闲对象,不够时尝试扩容。  
        if (item == null) return null; // 如果池子已满,就返回空。  
        inPool.Remove(item); // 取消“在池中”的标记。  
        active.Add(item); // 加入正在使用集合。  
        item.transform.SetParent(parent, false); // 设置对象新的父节点。  
        item.transform.SetPositionAndRotation(position, rotation); // 设置对象位置和旋转。  
        item.gameObject.SetActive(true); // 激活对象。  
        item.OnSpawn(); // 通知对象执行取出初始化。  
        return item; // 返回可使用对象。  
    } // 方法结束。  

    public void Release(T item) // 把对象归还到池中。  
    { // 方法开始。  
        if (item == null) return; // 空对象不处理。  
        if (inPool.Contains(item)) return; // 已经在池中说明重复归还,直接忽略。  
        if (!active.Remove(item)) return; // 不是本池正在使用的对象,直接忽略。  
        item.OnDespawn(); // 通知对象清理自身状态。  
        item.gameObject.SetActive(false); // 隐藏对象。  
        item.transform.SetParent(root, false); // 放回池根节点下。  
        inactive.Enqueue(item); // 放回空闲队列等待复用。  
        inPool.Add(item); // 标记对象已经归还。  
    } // 方法结束。  

    public void Dispose() // 销毁整个对象池。  
    { // 方法开始。  
        foreach (T item in active) Object.Destroy(item.gameObject); // 销毁所有正在使用的对象。  
        foreach (T item in inactive) Object.Destroy(item.gameObject); // 销毁所有空闲对象。  
        active.Clear(); // 清空使用中集合。  
        inactive.Clear(); // 清空空闲队列。  
        inPool.Clear(); // 清空归还标记集合。  
    } // 方法结束。  
} // 类结束。

面试加分点

对象池最容易被追问两个点:什么时候回收,以及重复回收怎么办。 我的回答是:回收由业务生命周期触发,比如子弹命中或超时、特效播放完、UI 关闭、怪物死亡;重复回收要做幂等保护,可以用 inPool 标记或状态枚举,避免同一个对象被塞进队列两次。

讲清楚 UI 虚拟列表

unity-ui-virtual-list-full

标准答案

UI 虚拟列表的核心是:数据可以有几千上万条,但真正创建的 UI Item 只保留屏幕可见区域附近的几十个。 滚动时,不是新增和销毁 Item,而是把滚出屏幕的 Item 回收到池子,再移动到新位置并重新绑定新数据。

为什么需要虚拟列表

普通 ScrollView 如果有 10000 条数据,就创建 10000 个 Item,会带来这些问题:

GameObject 数量太多,内存高。

Image / Text / TMP 太多,Canvas 重建压力大。

LayoutGroup 计算变重。

打开列表时可能明显卡顿。

滚动时 UI 层级和布局频繁变化,容易掉帧。

虚拟列表的目标是:让 UI 节点数量稳定。比如数据有 10000 条,但屏幕一次只能看到 8 条,那么真实 Item 可能只创建 8 + buffer 个,比如 12 到 20 个。

底层原理

固定高度 Item 的虚拟列表最好理解:

Content 总高度 = 数据数量 * Item 高度
firstIndex = 当前滚动距离 / Item 高度
visibleCount = 视口高度 / Item 高度 + 缓冲数量

然后只显示 [firstIndex, lastIndex] 这一段数据。 比如现在滚到第 200 条附近,就让 10 多个真实 Item 分别绑定第 200、201、202 条数据。继续往下滚时,第 200 条对应的 Item 滚出屏幕后,可以复用成第 213 条。

简化版代码:固定高度竖向虚拟列表

c
using System; // 引入 Action,用来把数据绑定逻辑交给外部。  
using System.Collections.Generic; // 引入 Dictionary、Queue、List 容器。  
using UnityEngine; // 引入 Unity 引擎 API。  
using UnityEngine.UI; // 引入 ScrollRect 组件。  

public class VirtualList : MonoBehaviour // 定义一个固定高度 Item 的虚拟列表组件。  
{ // 类开始。  
    [SerializeField] private ScrollRect scrollRect; // 保存 ScrollRect 引用。  
    [SerializeField] private RectTransform content; // 保存 ScrollRect 的 Content 节点。  
    [SerializeField] private RectTransform itemPrefab; // 保存 Item 预制体。  
    [SerializeField] private float itemHeight = 80f; // 保存每个 Item 的固定高度。  
    [SerializeField] private int buffer = 2; // 保存上下额外缓冲的 Item 数量。  

    private int dataCount; // 保存数据总数量。  
    private int lastFirstIndex = -1; // 保存上一次首个可见索引,避免重复刷新。  
    private Action<int, RectTransform> bindItem; // 保存外部传入的数据绑定方法。  
    private readonly Queue<RectTransform> pool = new Queue<RectTransform>(); // 保存空闲 Item 池。  
    private readonly Dictionary<int, RectTransform> showing = new Dictionary<int, RectTransform>(); // 保存当前正在显示的索引和 Item。  
    private readonly List<int> recycleList = new List<int>(); // 复用一个列表,避免 Refresh 时 new 临时集合。  

    public void Init(int count, Action<int, RectTransform> bindFunc) // 初始化虚拟列表。  
    { // 方法开始。  
        dataCount = count; // 记录数据数量。  
        bindItem = bindFunc; // 记录绑定函数。  
        lastFirstIndex = -1; // 重置首个可见索引缓存。  
        content.sizeDelta = new Vector2(content.sizeDelta.x, dataCount * itemHeight); // 设置 Content 总高度。  
        scrollRect.onValueChanged.RemoveListener(OnScroll); // 先移除旧监听,避免重复注册。  
        scrollRect.onValueChanged.AddListener(OnScroll); // 监听滚动事件。  
        Refresh(true); // 强制刷新一次列表。  
    } // 方法结束。  

    private void OnDestroy() // 对象销毁时调用。  
    { // 方法开始。  
        scrollRect.onValueChanged.RemoveListener(OnScroll); // 移除滚动监听,避免引用泄漏。  
    } // 方法结束。  

    private void OnScroll(Vector2 value) // ScrollRect 滚动时回调。  
    { // 方法开始。  
        Refresh(false); // 根据滚动位置刷新可见 Item。  
    } // 方法结束。  

    private void Refresh(bool force) // 刷新当前可见范围。  
    { // 方法开始。  
        if (dataCount <= 0) return; // 没有数据时直接返回。  
        float scrollY = Mathf.Max(0f, content.anchoredPosition.y); // 获取向下滚动的距离。  
        int first = Mathf.FloorToInt(scrollY / itemHeight) - buffer; // 计算第一个需要显示的索引。  
        first = Mathf.Clamp(first, 0, dataCount - 1); // 把索引限制在合法范围内。  
        int visible = Mathf.CeilToInt(scrollRect.viewport.rect.height / itemHeight) + buffer * 2 + 1; // 计算可见数量加缓冲数量。  
        int last = Mathf.Min(dataCount - 1, first + visible - 1); // 计算最后一个需要显示的索引。  

        if (!force && first == lastFirstIndex) return; // 如果首索引没变,就不重复刷新。  
        lastFirstIndex = first; // 记录新的首索引。  

        recycleList.Clear(); // 清空复用列表。  
        foreach (KeyValuePair<int, RectTransform> pair in showing) // 遍历当前正在显示的 Item。  
        { // foreach 开始。  
            if (pair.Key < first || pair.Key > last) // 如果这个 Item 已经滚出可见范围。  
            { // if 开始。  
                recycleList.Add(pair.Key); // 记录需要回收的索引。  
            } // if 结束。  
        } // foreach 结束。  

        for (int i = 0; i < recycleList.Count; i++) // 遍历所有需要回收的索引。  
        { // for 开始。  
            Recycle(recycleList[i]); // 回收对应的 Item。  
        } // for 结束。  

        for (int index = first; index <= last; index++) // 遍历当前应该显示的索引范围。  
        { // for 开始。  
            if (showing.ContainsKey(index)) continue; // 如果这个索引已经有 Item,就不用重复创建。  
            RectTransform item = GetItem(); // 从池子里取一个 Item。  
            showing[index] = item; // 记录这个索引对应的 Item。  
            item.gameObject.SetActive(true); // 显示 Item。  
            item.anchoredPosition = new Vector2(0f, -index * itemHeight); // 根据索引设置 Item 在 Content 中的位置。  
            bindItem?.Invoke(index, item); // 绑定这个索引对应的数据。  
        } // for 结束。  
    } // 方法结束。  

    private RectTransform GetItem() // 获取一个可用 Item。  
    { // 方法开始。  
        if (pool.Count > 0) return pool.Dequeue(); // 池子里有空闲 Item 时直接复用。  
        return Instantiate(itemPrefab, content); // 池子不够时创建一个新的 Item。  
    } // 方法结束。  

    private void Recycle(int index) // 回收指定索引的 Item。  
    { // 方法开始。  
        RectTransform item = showing[index]; // 找到这个索引对应的 Item。  
        showing.Remove(index); // 从显示字典里移除索引。  
        item.gameObject.SetActive(false); // 隐藏 Item。  
        pool.Enqueue(item); // 放回对象池等待复用。  
    } // 方法结束。  
} // 类结束。

项目里真正要注意的坑

虚拟列表最容易出问题的不是公式,而是 Item 复用后的旧状态没清干净。比如上一个数据的红点、选中态、倒计时、按钮监听、异步头像请求还留着,就会出现“滑动后数据显示串了”。

异步图片尤其要小心:Item 绑定第 10 条数据时发起头像加载,结果它还没回来,Item 已经被复用成第 30 条了。这时旧头像回调回来,就会把第 10 条头像错误地设置到第 30 条上。所以回调里必须校验 indexdataId

高级一点的追问

固定高度列表用除法就能算 firstIndex,比较简单。 如果 Item 高度不固定,就要维护每个 Item 的高度缓存,再做前缀和数组,通过二分查找找到当前 scrollY 对应的第一个 Item。这个实现复杂很多,但适合聊天列表、邮件列表、动态文本列表。

一句话面试版

UI 虚拟列表就是“数据全保留,视图只保留可见区域”,通过滚动偏移计算可见索引范围,范围外 Item 回收到对象池,范围内 Item 取出并重新绑定数据,从而减少 GameObject 数量、内存占用和 Canvas 重建压力。

讲清楚 AssetBundle 依赖加载

unity-assetbundle-dependency-loading

标准答案

AssetBundle 依赖加载的核心是:先通过 Manifest 查出目标 Bundle 的所有依赖,先加载依赖 Bundle,再加载目标 Bundle,最后加载 Asset;释放时要用引用计数,不能把还被别人使用的依赖提前卸载。

比如 hero.bundle 里有 Hero.prefab,但它引用的材质在 mat.bundle,贴图在 tex.bundle,Shader 在 shader.bundle。如果只加载 hero.bundle,Prefab 可能能出来,但材质、贴图、Shader 会丢,常见表现就是材质粉色、贴图空白、引用 Missing。

底层原理

AssetBundle 构建时会生成 AssetBundleManifest,里面记录了 Bundle 之间的依赖关系。运行时可以通过:

c
manifest.GetAllDependencies(bundleName);

拿到目标 Bundle 的所有直接和间接依赖。

所以正确流程是:

加载 Manifest -> 查询依赖 -> 加载依赖 Bundle -> 加载目标 Bundle -> LoadAsset -> Instantiate

释放流程则反过来:

业务释放资源 -> 主包引用计数 -1 -> 依赖引用计数 -1 -> 计数为 0 才 Unload

关键点

AssetBundle.Unload(false):卸载 Bundle 对象本身,已经加载出来的 Asset 通常还能继续存在。

AssetBundle.Unload(true):连已经加载出来的 Asset 也卸掉,如果场景对象还在用,可能直接出问题。

所以项目里一般不是业务随手 Unload,而是资源管理器统一做引用计数。

简化代码

c
using System; // 引入 Action 回调类型。  
using System.Collections; // 引入 IEnumerator,用于协程加载。  
using System.Collections.Generic; // 引入 Dictionary,用于缓存 Bundle。  
using System.IO; // 引入 Path,用于拼接文件路径。  
using UnityEngine; // 引入 Unity 引擎 API。  

public sealed class SimpleBundleLoader : MonoBehaviour // 定义一个简化版 AssetBundle 加载器。  
{ // 类开始。  
    private sealed class BundleRecord // 定义 Bundle 记录结构。  
    { // 类开始。  
        public AssetBundle Bundle; // 保存已经加载的 AssetBundle。  
        public int RefCount; // 保存这个 Bundle 当前引用计数。  
    } // 类结束。  

    [SerializeField] private string rootPath; // 保存 AssetBundle 所在目录。  
    private AssetBundleManifest manifest; // 保存 Manifest,用来查询依赖关系。  
    private readonly Dictionary<string, BundleRecord> loaded = new Dictionary<string, BundleRecord>(); // 保存已加载 Bundle 表。  

    public IEnumerator Init(string manifestBundleName) // 初始化 Manifest。  
    { // 方法开始。  
        string path = Path.Combine(rootPath, manifestBundleName); // 拼接 Manifest Bundle 路径。  
        AssetBundle manifestBundle = AssetBundle.LoadFromFile(path); // 加载存放 Manifest 的 Bundle。  
        manifest = manifestBundle.LoadAsset<AssetBundleManifest>("AssetBundleManifest"); // 从 Bundle 中取出 Manifest 对象。  
        manifestBundle.Unload(false); // 卸载 Manifest Bundle 外壳,保留 Manifest 对象。  
        yield break; // 协程结束。  
    } // 方法结束。  

    public IEnumerator LoadAssetWithDependencies<T>(string bundleName, string assetName, Action<T> onLoaded) where T : UnityEngine.Object // 加载目标资源和依赖。  
    { // 方法开始。  
        string[] dependencies = manifest.GetAllDependencies(bundleName); // 查询目标 Bundle 的所有依赖。  
        foreach (string dependency in dependencies) // 遍历每一个依赖 Bundle。  
        { // foreach 开始。  
            yield return RetainBundle(dependency); // 先加载并增加依赖 Bundle 的引用计数。  
        } // foreach 结束。  
        yield return RetainBundle(bundleName); // 再加载并增加目标 Bundle 的引用计数。  
        AssetBundle bundle = loaded[bundleName].Bundle; // 取出目标 Bundle。  
        AssetBundleRequest request = bundle.LoadAssetAsync<T>(assetName); // 异步加载目标 Asset。  
        yield return request; // 等待 Asset 加载完成。  
        onLoaded?.Invoke(request.asset as T); // 把加载到的资源通过回调返回。  
    } // 方法结束。  

    private IEnumerator RetainBundle(string bundleName) // 加载 Bundle 或增加引用计数。  
    { // 方法开始。  
        if (loaded.TryGetValue(bundleName, out BundleRecord record)) // 如果 Bundle 已经加载过。  
        { // if 开始。  
            record.RefCount++; // 引用计数加一。  
            yield break; // 直接结束,不重复加载。  
        } // if 结束。  
        string path = Path.Combine(rootPath, bundleName); // 拼接 Bundle 文件路径。  
        AssetBundleCreateRequest request = AssetBundle.LoadFromFileAsync(path); // 异步加载 Bundle 文件。  
        yield return request; // 等待 Bundle 加载完成。  
        loaded[bundleName] = new BundleRecord { Bundle = request.assetBundle, RefCount = 1 }; // 记录 Bundle 和初始引用计数。  
    } // 方法结束。  

    public void ReleaseAssetWithDependencies(string bundleName) // 释放目标 Bundle 和它的依赖引用。  
    { // 方法开始。  
        ReleaseBundle(bundleName); // 先释放目标 Bundle 的一次引用。  
        string[] dependencies = manifest.GetAllDependencies(bundleName); // 再查出它的依赖列表。  
        foreach (string dependency in dependencies) // 遍历依赖 Bundle。  
        { // foreach 开始。  
            ReleaseBundle(dependency); // 释放依赖 Bundle 的一次引用。  
        } // foreach 结束。  
    } // 方法结束。  

    private void ReleaseBundle(string bundleName) // 释放单个 Bundle 的一次引用。  
    { // 方法开始。  
        if (!loaded.TryGetValue(bundleName, out BundleRecord record)) return; // 如果没有加载过就直接返回。  
        record.RefCount--; // 引用计数减一。  
        if (record.RefCount > 0) return; // 还有其他资源在使用时不能卸载。  
        record.Bundle.Unload(false); // 引用归零后卸载 Bundle 外壳。  
        loaded.Remove(bundleName); // 从已加载表里移除记录。  
    } // 方法结束。  
} // 类结束。

项目里常见坑

公共材质、贴图、Shader 要拆成公共依赖包,否则多个 Bundle 可能各自带一份,导致包体变大和内存重复。

依赖 Bundle 必须在目标资源使用期间保持有效,尤其是 Shader、材质、贴图这类公共资源。不要一个界面关闭就直接把公共依赖卸掉,因为另一个界面或角色可能还在用。

真正项目里还会把 Manifest 和版本号、Hash、CDN 路径结合起来:客户端先对比本地和远端 Manifest,下载缺失或 Hash 变化的 Bundle,再按依赖关系加载。

一句话面试版

AssetBundle 依赖加载就是:用 AssetBundleManifest 查依赖,按“依赖包先、目标包后”的顺序加载,资源使用期间用引用计数管理主包和依赖包,释放时等所有引用归零再卸载,避免丢材质、丢贴图、Shader 粉色和重复打包。

讲清楚一次技能释放流程

unity-skill-release-full-flow

标准答案

一次技能释放流程可以拆成:输入请求 → 合法性校验 → 锁定释放上下文 → 进入技能状态 → 播放前摇 → 命中帧判定 → 伤害/Buff 结算 → 表现反馈 → 进入 CD → 后摇结束 → 恢复控制

面试里不要只说“播放动画、放特效”,要讲清楚:技能系统本质是一个配置驱动的时间轴系统,动画、特效、音效、命中判定、伤害结算都挂在不同时间点上执行。

底层流程

玩家按技能键后,客户端先生成 CastRequest。 然后做校验:角色是否死亡、是否被控制、技能是否在 CD、蓝量是否够、目标是否存在、距离和朝向是否合法。 校验通过后,锁定本次释放上下文,比如技能 ID、释放者、目标、释放方向、释放位置、随机种子等。

然后角色进入技能状态,播放动画。技能时间轴一般分三段:

前摇:技能已经开始,但还没造成伤害。

命中帧:真正做范围检测、目标筛选、伤害结算。

后摇:伤害已发生,但动作还没结束,决定能不能取消或接下一个技能。

简化代码

c
using System.Collections; // 引入 IEnumerator,用来写技能时间轴协程。  
using UnityEngine; // 引入 Unity 引擎 API。  

[System.Serializable] // 允许这个配置类显示在 Inspector。  
public class SkillConfig // 定义一个简化版技能配置。  
{ // 类开始。  
    public int skillId; // 技能 ID,用来区分不同技能。  
    public float cooldown = 3f; // 技能冷却时间。  
    public float windupTime = 0.3f; // 前摇时间。  
    public float hitTime = 0.5f; // 命中帧时间点。  
    public float totalTime = 1.0f; // 技能总持续时间。  
    public float range = 3f; // 技能检测范围。  
    public int damage = 100; // 技能基础伤害。  
} // 类结束。  

public class SkillCaster : MonoBehaviour // 定义一个简化版技能释放器。  
{ // 类开始。  
    [SerializeField] private Animator animator; // 保存 Animator 引用,用来播放技能动画。  
    [SerializeField] private LayerMask targetLayer; // 保存目标层,用于范围检测过滤。  
    [SerializeField] private SkillConfig skill; // 保存当前要释放的技能配置。  
    private readonly Collider[] hitBuffer = new Collider[32]; // 预分配命中数组,避免每次释放技能产生 GC。  
    private float cooldownEndTime; // 记录技能冷却结束时间。  
    private bool isCasting; // 记录当前是否正在释放技能。  

    public void TryCast() // 尝试释放技能。  
    { // 方法开始。  
        if (isCasting) return; // 正在释放技能时不能重复释放。  
        if (Time.time < cooldownEndTime) return; // 技能还在 CD 中时不能释放。  
        if (skill == null) return; // 技能配置为空时不能释放。  
        StartCoroutine(CastRoutine()); // 启动技能时间轴协程。  
    } // 方法结束。  

    private IEnumerator CastRoutine() // 技能释放完整流程。  
    { // 协程开始。  
        isCasting = true; // 标记角色进入技能释放状态。  
        animator.Play("Skill"); // 播放技能动画。  
        yield return new WaitForSeconds(skill.hitTime); // 等到命中帧时间点。  
        DoHitCheck(); // 在命中帧执行范围检测和伤害结算。  
        float remainTime = skill.totalTime - skill.hitTime; // 计算命中帧之后还剩多少后摇时间。  
        if (remainTime > 0f) // 如果还有后摇时间。  
        { // if 开始。  
            yield return new WaitForSeconds(remainTime); // 等待后摇结束。  
        } // if 结束。  
        cooldownEndTime = Time.time + skill.cooldown; // 技能结束后进入 CD。  
        isCasting = false; // 恢复角色可释放技能状态。  
    } // 协程结束。  

    private void DoHitCheck() // 执行技能命中检测。  
    { // 方法开始。  
        int count = Physics.OverlapSphereNonAlloc(transform.position, skill.range, hitBuffer, targetLayer); // 用 NonAlloc 范围检测找目标。  
        for (int i = 0; i < count; i++) // 遍历所有命中的目标。  
        { // for 开始。  
            Collider target = hitBuffer[i]; // 取出当前命中的 Collider。  
            if (target == null) continue; // 如果目标为空就跳过。  
            Health health = target.GetComponent<Health>(); // 尝试获取目标身上的血量组件。  
            if (health == null) continue; // 如果目标没有血量组件就跳过。  
            health.TakeDamage(skill.damage); // 对目标造成伤害。  
        } // for 结束。  
    } // 方法结束。  
} // 类结束。  

public class Health : MonoBehaviour // 定义一个简化版血量组件。  
{ // 类开始。  
    public int hp = 1000; // 保存当前血量。  
    public void TakeDamage(int damage) // 处理受到伤害。  
    { // 方法开始。  
        hp -= damage; // 扣除血量。  
        if (hp <= 0) gameObject.SetActive(false); // 血量小于等于 0 时隐藏对象。  
    } // 方法结束。  
} // 类结束。

项目里真正要讲的点

如果是单机或弱联网,可以客户端直接做命中检测和伤害结算。 如果是强联网或 MMO,客户端主要负责输入响应和表现预测,服务端负责权威校验、命中确认、伤害结算、CD 和状态同步,防止客户端篡改伤害、CD、范围。

技能打断也要按时间点处理:前摇被打断,通常不结算伤害;命中帧之后被打断,伤害已经发生,可能只取消后摇或表现。这样设计才不会出现“动画被打断但伤害已经没了”或者“明明被控了还打出伤害”的混乱问题。

一句话面试版

一次技能释放不是简单播动画,而是一次配置驱动的时间轴流程:先校验释放条件,锁定释放上下文,进入技能状态,在前摇、命中帧、后摇不同时间点触发动画、特效、命中检测、伤害结算和 CD,同步项目里还要区分客户端表现和服务端权威结算。

讲清楚一次卡顿排查

unity-stutter-investigation-flow

标准答案

一次卡顿排查,我会按闭环来讲:先确认现象,再用工具抓数据,定位是哪一帧卡,展开调用栈归类瓶颈,做最小修复,最后用优化前后数据验证。 重点不是上来就说“我用了对象池”,而是先证明卡顿来自哪里。

完整排查流程

  1. 确认现象:是战斗中卡、打开 UI 卡、切场景卡、首次放技能卡,还是每隔几秒固定卡一下。
  2. 稳定复现:记录机型、场景、操作路径、帧率、内存、资源包版本。
  3. Profiler 抓帧:真机 Development Build 连接 Profiler,看 CPU Timeline
  4. 找尖峰帧:比如正常 16ms,某一帧突然 80ms。
  5. 展开调用栈:看尖峰在 BehaviourUpdateGC.CollectCanvas.BuildBatchCamera.RenderPhysics.Simulate 还是资源加载。
  6. 瓶颈归类:CPU、GPU、GC、UI、物理、IO、资源加载分别处理。
  7. 最小修复:一次只改一个主要问题,避免改太多看不出效果。
  8. 复测对比:同设备、同场景、同操作,对比平均帧耗、P95 帧耗、GC Alloc、内存峰值。
  9. 防止复发:加性能预算、资源检查、自动化跑图、线上日志和告警。

常见卡顿类型

GC 卡顿:Profiler 里看到 GC.Collect 或某些脚本每帧 GC Alloc 很高。 解决:对象池、缓存 List、NonAlloc API、减少字符串拼接、避免热路径 LINQ。

UI 卡顿:看到 Canvas.BuildBatchCanvas.SendWillRenderCanvases 高。 解决:静态动态 Canvas 分离、减少 LayoutGroup、虚拟列表、文本变化时才刷新。

CPU 逻辑卡顿:看到 BehaviourUpdate、AI、寻路、排序、技能筛选很高。 解决:分帧、降频、缓存、空间划分、Job System。

GPU 卡顿:看到 Camera.Render 高,或者 GPU 时间比 CPU 时间高。 解决:降低 Overdraw、阴影、后处理、透明特效、分辨率和 Shader 复杂度。

资源加载卡顿:首次打开界面、首次释放技能、首次出现怪物卡。 解决:Loading 阶段预加载,异步加载,分帧初始化,Shader Variant 预热。

打点代码示例

c
using Unity.Profiling; // 引入 ProfilerMarker,用于在 Profiler Timeline 中标记自定义代码段。  
using UnityEngine; // 引入 Unity 引擎 API。  

public class BattleProfilerSample : MonoBehaviour // 定义一个战斗逻辑打点示例脚本。  
{ // 类开始。  
    private static readonly ProfilerMarker aiMarker = new ProfilerMarker("Battle.AI.Update"); // 创建 AI 更新的 Profiler 标记。  
    private static readonly ProfilerMarker skillMarker = new ProfilerMarker("Battle.Skill.Check"); // 创建技能检测的 Profiler 标记。  

    private void Update() // Update 每帧执行。  
    { // 方法开始。  
        using (aiMarker.Auto()) // 自动记录 AI 逻辑耗时,using 结束时自动停止采样。  
        { // using 开始。  
            UpdateAI(); // 执行 AI 更新逻辑。  
        } // using 结束。  

        using (skillMarker.Auto()) // 自动记录技能检测耗时,方便在 Timeline 中定位尖峰。  
        { // using 开始。  
            UpdateSkillCheck(); // 执行技能范围检测或目标筛选逻辑。  
        } // using 结束。  
    } // 方法结束。  

    private void UpdateAI() // 模拟 AI 更新逻辑。  
    { // 方法开始。  
        // 这里放巡逻、追击、攻击决策等逻辑。  
    } // 方法结束。  

    private void UpdateSkillCheck() // 模拟技能检测逻辑。  
    { // 方法开始。  
        // 这里放目标筛选、距离检测、扇形检测等逻辑。  
    } // 方法结束。  
} // 类结束。

面试加分说法

如果面试官问“你怎么证明优化有效”,我会说:我不会只说感觉不卡了,而是用同一台机器、同一场景、同一操作路径,对比优化前后的 平均帧耗P95/P99 帧耗GC AllocGC 次数内存峰值。如果是线上问题,还会记录机型、场景 ID、资源版本、网络状态和关键流程 traceId。

一句话面试版

一次卡顿排查就是:先复现,再用 Profiler 找尖峰帧,展开调用栈判断是 CPU、GPU、GC、UI、物理还是资源加载问题,然后做针对性优化,并用优化前后数据证明效果,最后通过规范和监控防止复发。

讲清楚 C++ 虚函数

cpp-virtual-function-clear-explain

标准答案

C++ 虚函数就是用 virtual 修饰的成员函数,它让我们可以通过基类指针或引用,在运行时调用到派生类真正重写的函数。这就是 C++ 运行时多态。

比如:

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

class Character // 定义角色基类。  
{ // 类开始。  
public: // 公开接口开始。  
    virtual ~Character() = default; // 虚析构,保证用基类指针删除派生对象时析构完整。  
    virtual void Attack() // 定义虚函数,允许派生类重写攻击行为。  
    { // 函数开始。  
        std::cout << "Character attack\n"; // 输出基类攻击逻辑。  
    } // 函数结束。  
}; // 类结束。  

class Boss : public Character // Boss 继承 Character。  
{ // 类开始。  
public: // 公开接口开始。  
    void Attack() override // 重写基类虚函数,override 能防止函数签名写错。  
    { // 函数开始。  
        std::cout << "Boss heavy attack\n"; // 输出 Boss 自己的攻击逻辑。  
    } // 函数结束。  
}; // 类结束。  

int main() // 程序入口。  
{ // 函数开始。  
    Character* character = new Boss(); // 用基类指针指向派生类对象。  
    character->Attack(); // 运行时调用 Boss::Attack,而不是 Character::Attack。  
    delete character; // 通过虚析构正确释放 Boss 对象。  
    return 0; // 返回 0 表示程序正常结束。  
} // 函数结束。

底层原理

虚函数通常靠 vptrvtable 实现。

有虚函数的类,对象里通常会多一个隐藏指针,叫 vptrvptr 指向这个类对应的虚函数表,也就是 vtablevtable 里存的是虚函数地址。

当你写:

c
Character* p = new Boss();
p->Attack();

编译器发现 Attack 是虚函数,就不会直接固定调用 Character::Attack,而是大概走:

c
对象地址 -> vptr -> vtable -> Attack 函数槽位 -> Boss::Attack

所以它能根据真实对象类型调用正确函数。

几个关键点

虚函数必须通过指针或引用体现多态:

c
Character* p = new Boss(); // 可以发生运行时多态。  
Character& r = boss; // 也可以发生运行时多态。  
Character obj = Boss(); // 会发生对象切片,不适合多态。

基类析构函数通常要写成虚析构:

c
virtual ~Character() = default;

否则 delete basePtr; 时可能只调用基类析构,不完整释放派生类资源。

构造函数和析构函数里调用虚函数要小心。对象构造基类部分时,派生类部分还没构造好;析构时派生类部分已经开始销毁,所以通常不会按你想象的方式分派到派生类。

优缺点

优点:接口统一,扩展性好,适合角色、组件、渲染资源、物理对象这种“不同类型有相同行为”的场景。

缺点:对象通常多一个 vptr,调用多一次间接跳转,不利于内联和缓存友好性。游戏引擎热路径里,如果对象数量特别大,比如大量粒子、组件批量更新,可能会考虑模板、数据导向设计或 ECS 来减少虚函数分发成本。

一句话面试版

C++ 虚函数用于实现运行时多态,底层通常是对象里有 vptr 指向类的 vtable,通过基类指针或引用调用虚函数时,会在运行时查表跳转到派生类实现;它提升扩展性,但有一次间接调用和对象额外指针的成本,基类析构函数通常必须声明为虚函数。

讲清楚智能指针

cpp-smart-pointers-clear-explain

标准答案

C++ 智能指针是用 RAII 管理资源生命周期的工具。 它把“什么时候释放资源”绑定到对象生命周期上,避免我们手动 new/delete 时忘记释放、重复释放、异常提前返回导致泄漏。

常见三个智能指针:

std::unique_ptr:独占所有权,不能拷贝,只能移动。

std::shared_ptr:共享所有权,通过引用计数管理资源。

std::weak_ptr:弱引用,不增加引用计数,用来观察 shared_ptr 管理的对象,常用于解决循环引用。

底层原理

unique_ptr 本质上很轻,通常可以理解成一个包装裸指针的 RAII 对象。它离开作用域时自动 delete,因为它独占资源,所以不能拷贝,只能 std::move 转移所有权。

shared_ptr 底层会有一个控制块,控制块里通常记录:

strong count:强引用计数,有多少个 shared_ptr 正在拥有对象。

weak count:弱引用计数,有多少个 weak_ptr 正在观察对象。

deleter:对象引用归零时怎么释放资源。

strong count 变成 0,对象会被销毁。 当 strong countweak count 都变成 0,控制块也会被销毁。

代码示例

c
#include <iostream> // 引入标准输出库。  
#include <memory> // 引入 unique_ptr、shared_ptr、weak_ptr。  
#include <string> // 引入 string 类型。  

class Texture // 定义一个模拟纹理资源的类。  
{ // 类开始。  
public: // 公开成员开始。  
    explicit Texture(const std::string& name) : name_(name) // 构造函数接收纹理名字并初始化成员。  
    { // 构造函数开始。  
        std::cout << "Load texture: " << name_ << "\n"; // 输出加载纹理日志。  
    } // 构造函数结束。  

    ~Texture() // 析构函数在对象释放时调用。  
    { // 析构函数开始。  
        std::cout << "Release texture: " << name_ << "\n"; // 输出释放纹理日志。  
    } // 析构函数结束。  

    void Use() const // 定义使用纹理的方法。  
    { // 方法开始。  
        std::cout << "Use texture: " << name_ << "\n"; // 输出使用纹理日志。  
    } // 方法结束。  

private: // 私有成员开始。  
    std::string name_; // 保存纹理名字。  
}; // 类结束。  

void UniquePtrDemo() // 演示 unique_ptr 的独占所有权。  
{ // 函数开始。  
    std::unique_ptr<Texture> texture = std::make_unique<Texture>("hero.png"); // 创建一个独占纹理资源的 unique_ptr。  
    texture->Use(); // 通过 unique_ptr 使用纹理对象。  
    std::unique_ptr<Texture> moved = std::move(texture); // 把所有权移动给 moved,texture 变为空。  
    if (texture == nullptr) // 判断原来的 unique_ptr 是否已经失去所有权。  
    { // if 开始。  
        std::cout << "texture moved\n"; // 输出所有权已转移。  
    } // if 结束。  
} // 函数结束,moved 离开作用域后自动释放 Texture。  

void SharedPtrDemo() // 演示 shared_ptr 的共享所有权。  
{ // 函数开始。  
    std::shared_ptr<Texture> a = std::make_shared<Texture>("boss.png"); // 创建一个 shared_ptr,强引用计数为 1。  
    std::shared_ptr<Texture> b = a; // 拷贝 shared_ptr,强引用计数变为 2。  
    std::cout << a.use_count() << "\n"; // 输出当前强引用计数。  
    b->Use(); // 通过 b 使用同一个纹理对象。  
} // 函数结束,a 和 b 都释放后 Texture 才会析构。  

void WeakPtrDemo() // 演示 weak_ptr 的观察关系。  
{ // 函数开始。  
    std::shared_ptr<Texture> owner = std::make_shared<Texture>("icon.png"); // 创建资源拥有者。  
    std::weak_ptr<Texture> observer = owner; // 创建弱引用,观察 owner 管理的对象。  
    if (std::shared_ptr<Texture> locked = observer.lock()) // 使用前尝试提升为 shared_ptr。  
    { // if 开始。  
        locked->Use(); // 如果对象还活着,就安全使用。  
    } // if 结束。  
    owner.reset(); // 主动释放 owner 的强引用。  
    if (observer.expired()) // 判断观察的对象是否已经销毁。  
    { // if 开始。  
        std::cout << "texture expired\n"; // 输出对象已经过期。  
    } // if 结束。  
} // 函数结束。

怎么选择

优先用 unique_ptr。只要资源有明确唯一拥有者,就不要上来用 shared_ptr

确实有多个模块共享生命周期时,才用 shared_ptr。比如资源系统里多个对象共同持有一个资源句柄,但也要设计清楚谁拥有、谁释放。

需要“观察但不拥有”时用 weak_ptr。比如缓存、观察者、父子结构里的反向引用。如果父对象 shared_ptr 指向子对象,子对象再 shared_ptr 指回父对象,就会循环引用,双方引用计数永远不归零,这时反向引用应该用 weak_ptr

面试加分点

智能指针不能完全避免内存泄漏。 shared_ptr 循环引用仍然会泄漏;对象本身持有文件句柄、GPU 资源、网络连接时,也要设计好释放策略;引用计数增减通常是线程安全的,但被管理对象本身的读写不是自动线程安全。

一句话面试版

智能指针是 C++ 用 RAII 管理资源生命周期的工具,unique_ptr 表示独占所有权,shared_ptr 通过控制块引用计数共享所有权,weak_ptr 不增加引用计数,用来观察对象并解决循环引用;它们能减少手动 delete 错误,但不能替代清晰的所有权设计。

讲清楚 A*

astar-clear-explain

标准答案

A* 是一种启发式寻路算法,可以理解成:Dijkstra + 方向感。 它每次从待探索集合 Open 里选择 f = g + h 最小的节点继续搜索。

g:从起点走到当前点的真实代价。 h:从当前点到终点的估计代价。 f:总代价估计,f = g + h

如果 h 不高估真实距离,A* 可以保证找到最短路径。四方向格子地图里,h 常用曼哈顿距离。

核心流程

  1. 把起点放进 Open
  2. 每轮取出 f 最小的节点。
  3. 如果它是终点,就沿 parent 回溯路径。
  4. 否则把它放进 Closed
  5. 遍历它的邻居。
  6. 如果走到邻居的 g 更小,就更新邻居的 gparent,并放进 Open
  7. Open 空了还没到终点,说明不可达。

C# 示例:格子 A*

c
using System.Collections.Generic; // 引入 List、HashSet、Dictionary 容器。  
using UnityEngine; // 引入 Vector2Int 和 Mathf。  
public static class AStarGrid // 定义一个格子地图 A* 工具类。  
{ // 类开始。  
    private static readonly Vector2Int[] dirs = // 定义四方向移动数组。  
    { // 数组开始。  
        Vector2Int.up, // 向上移动一格。  
        Vector2Int.down, // 向下移动一格。  
        Vector2Int.left, // 向左移动一格。  
        Vector2Int.right // 向右移动一格。  
    }; // 数组结束。  
    public static List<Vector2Int> FindPath(bool[,] walkable, Vector2Int start, Vector2Int goal) // 查找从 start 到 goal 的路径。  
    { // 方法开始。  
        List<Vector2Int> open = new List<Vector2Int>(); // 保存待探索节点。  
        HashSet<Vector2Int> closed = new HashSet<Vector2Int>(); // 保存已处理节点。  
        Dictionary<Vector2Int, Vector2Int> parent = new Dictionary<Vector2Int, Vector2Int>(); // 保存每个节点从哪里来。  
        Dictionary<Vector2Int, int> gScore = new Dictionary<Vector2Int, int>(); // 保存起点到每个节点的真实代价。  
        open.Add(start); // 把起点加入 Open。  
        gScore[start] = 0; // 起点到起点的代价是 0。  
        while (open.Count > 0) // 只要还有待探索节点就继续。  
        { // while 开始。  
            Vector2Int current = GetLowestF(open, gScore, goal); // 找到 f 最小的节点。  
            if (current == goal) return BuildPath(parent, current); // 到达终点后回溯路径。  
            open.Remove(current); // 从 Open 中移除当前节点。  
            closed.Add(current); // 把当前节点放入 Closed。  
            foreach (Vector2Int dir in dirs) // 遍历四个方向。  
            { // foreach 开始。  
                Vector2Int next = current + dir; // 计算邻居坐标。  
                if (!InBounds(walkable, next)) continue; // 越界节点跳过。  
                if (!walkable[next.x, next.y]) continue; // 障碍节点跳过。  
                if (closed.Contains(next)) continue; // 已处理节点跳过。  
                int newG = gScore[current] + 1; // 计算从当前节点走到邻居的新 g 值。  
                if (!gScore.TryGetValue(next, out int oldG) || newG < oldG) // 如果邻居没访问过,或者新路径更短。  
                { // if 开始。  
                    parent[next] = current; // 记录邻居的父节点。  
                    gScore[next] = newG; // 更新邻居的 g 值。  
                    if (!open.Contains(next)) open.Add(next); // 如果邻居不在 Open,就加入 Open。  
                } // if 结束。  
            } // foreach 结束。  
        } // while 结束。  
        return new List<Vector2Int>(); // Open 空了还没到终点,返回空路径。  
    } // 方法结束。  
    private static Vector2Int GetLowestF(List<Vector2Int> open, Dictionary<Vector2Int, int> gScore, Vector2Int goal) // 从 Open 中找 f 最小的节点。  
    { // 方法开始。  
        Vector2Int best = open[0]; // 默认第一个节点是最优节点。  
        int bestF = gScore[best] + Heuristic(best, goal); // 计算第一个节点的 f 值。  
        for (int i = 1; i < open.Count; i++) // 从第二个节点开始遍历。  
        { // for 开始。  
            Vector2Int node = open[i]; // 取出当前候选节点。  
            int f = gScore[node] + Heuristic(node, goal); // 计算候选节点的 f 值。  
            if (f < bestF) // 如果候选节点 f 更小。  
            { // if 开始。  
                best = node; // 更新最优节点。  
                bestF = f; // 更新最优 f 值。  
            } // if 结束。  
        } // for 结束。  
        return best; // 返回 f 最小的节点。  
    } // 方法结束。  
    private static int Heuristic(Vector2Int a, Vector2Int b) // 计算启发式距离。  
    { // 方法开始。  
        return Mathf.Abs(a.x - b.x) + Mathf.Abs(a.y - b.y); // 四方向网格使用曼哈顿距离。  
    } // 方法结束。  
    private static bool InBounds(bool[,] grid, Vector2Int p) // 判断坐标是否在地图范围内。  
    { // 方法开始。  
        return p.x >= 0 && p.y >= 0 && p.x < grid.GetLength(0) && p.y < grid.GetLength(1); // 返回是否没有越界。  
    } // 方法结束。  
    private static List<Vector2Int> BuildPath(Dictionary<Vector2Int, Vector2Int> parent, Vector2Int current) // 根据 parent 回溯路径。  
    { // 方法开始。  
        List<Vector2Int> path = new List<Vector2Int>(); // 创建路径列表。  
        path.Add(current); // 先加入终点。  
        while (parent.ContainsKey(current)) // 只要当前节点有父节点就继续回溯。  
        { // while 开始。  
            current = parent[current]; // 移动到父节点。  
            path.Add(current); // 把父节点加入路径。  
        } // while 结束。  
        path.Reverse(); // 反转路径,让它从起点到终点。  
        return path; // 返回最终路径。  
    } // 方法结束。  
} // 类结束。

复杂度

上面这版为了面试手写清晰,用 List 扫描最小 f,最坏可能接近 O(V²)。 工程里一般用优先队列/二叉堆优化 Open,常见复杂度可以理解成 O(E log V)

Unity 项目里怎么用

格子战棋、塔防、怪物寻路、点击移动都可以用 A*。 如果单位很多,不要所有怪物同一帧同时寻路,可以做路径缓存、分帧寻路、后台线程计算,再回主线程应用结果。路径算出来后通常还要做平滑,避免角色走锯齿线。

一句话面试版

A* 每次从 Open 集合中取 f = g + h 最小的节点扩展,g 是真实已走代价,h 是到终点的估计代价,走到终点后通过 parent 回溯路径;只要启发函数不高估真实距离,就能保证最短路,比 BFS 更有方向感。

讲清楚 DrawCall 优化

unity-drawcall-optimization-clear

标准答案

DrawCall 优化的核心不是单纯追求“数量越低越好”,而是先判断瓶颈在哪里。 DrawCall 本质是 CPU 向 GPU 提交一次绘制命令,数量太多时,CPU 需要频繁切材质、Shader、纹理、常量缓冲和渲染状态,容易让 Main Thread / Render Thread 变成瓶颈。

排查顺序

我会先用 ProfilerCPURender ThreadCamera.Render,再用 Frame Debugger 看每个批次为什么断开,比如材质不同、贴图不同、Shader Variant 不同、关键字不同、透明排序不同。 如果是 GPU 时间高,那不一定是 DrawCall 问题,可能是 Overdraw、阴影、后处理、Shader 太复杂、带宽压力大。

常见优化方案

Static Batching:适合静态场景物体,同材质更容易合批。代价是可能增加内存。

Dynamic Batching:适合小 Mesh,但限制比较多,现代项目里不要过度依赖。

GPU Instancing:适合大量相同 Mesh、相同材质的对象,比如草、石头、子弹、小怪。每个对象可以通过实例数据传不同矩阵或颜色。

SRP Batcher:URP/HDRP 下常用,重点是减少 SRP 渲染状态提交成本。它不要求同一个材质,但要求 Shader 常量布局兼容。

图集和材质合并:UI、Sprite、小物件尽量使用图集和共享材质,减少 SetPass。

剔除优先:Frustum Culling、Occlusion Culling、LOD。能不画就不提交,比合批更直接。

GPU Instancing 示例

c
using UnityEngine; // 引入 Unity 引擎 API。  

public class InstancedDrawDemo : MonoBehaviour // 定义一个 GPU Instancing 绘制示例。  
{ // 类开始。  
    [SerializeField] private Mesh mesh; // 保存要重复绘制的 Mesh。  
    [SerializeField] private Material material; // 保存支持 Instancing 的材质。  
    [SerializeField] private int count = 500; // 保存要绘制的实例数量。  
    private readonly Matrix4x4[] matrices = new Matrix4x4[1023]; // 保存每个实例的矩阵,单次 DrawMeshInstanced 最多常用 1023 个。  

    private void Awake() // Awake 在对象初始化时执行。  
    { // 方法开始。  
        material.enableInstancing = true; // 开启材质的 GPU Instancing。  
        count = Mathf.Min(count, matrices.Length); // 限制实例数量不超过数组容量。  
        for (int i = 0; i < count; i++) // 初始化每个实例的位置。  
        { // for 开始。  
            Vector3 position = new Vector3(i % 50, 0f, i / 50); // 计算实例摆放位置。  
            Quaternion rotation = Quaternion.identity; // 使用默认旋转。  
            Vector3 scale = Vector3.one; // 使用默认缩放。  
            matrices[i] = Matrix4x4.TRS(position, rotation, scale); // 生成实例的变换矩阵。  
        } // for 结束。  
    } // 方法结束。  

    private void Update() // 每帧执行绘制。  
    { // 方法开始。  
        Graphics.DrawMeshInstanced(mesh, 0, material, matrices, count); // 一次提交绘制多个相同 Mesh 和材质的实例。  
    } // 方法结束。  
} // 类结束。

项目里怎么讲得有经验

我不会直接说“我把 DrawCall 降下来了”,而是会说: 我先用 Frame Debugger 找出批次断开的原因,比如 UI 图集不统一、角色材质实例过多、粒子材质太散、场景静态物体没有合批。然后按类型处理:UI 做图集,静态场景做 Static Batching,重复小物件走 GPU Instancing,URP 项目检查 SRP Batcher 兼容性,远处对象用 LOD 和遮挡剔除。

最后用数据证明:比如 Batches 从 900 降到 350,SetPass 从 300 降到 90,Render Thread 从 7ms 降到 3ms。但如果 GPU 时间没降,我会继续看 Overdraw、Shader、阴影和后处理。

一句话面试版

DrawCall 优化就是减少 CPU 向 GPU 提交绘制命令和切换渲染状态的成本,常用手段包括共享材质、图集、Static Batching、GPU Instancing、SRP Batcher、LOD 和 Occlusion Culling,但要先用 Profiler 和 Frame Debugger 判断瓶颈,不能只盯 DrawCall 数量。

讲清楚热更新版本流程

unity-hot-update-version-process

一句话定义

热更新版本流程就是:客户端启动后先拿本地版本和远端版本做对比,只下载变化的资源或代码补丁,校验通过后再切换到新版本;如果失败,就继续用旧版本或回滚。

标准流程

  1. 客户端启动,读取本地 Manifest
  2. 请求版本服务器,拿到远端版本信息。
  3. 判断是否需要强更安装包,比如客户端大版本太旧。
  4. 如果不用强更,就下载远端 Manifest
  5. 对比本地和远端文件列表。
  6. 找出 hash、size、version 不一致的资源。
  7. 下载差异文件到临时目录。
  8. 校验文件完整性,比如 hashCRC、签名、大小。
  9. 全部成功后,原子替换到正式目录。
  10. 更新本地 Manifest
  11. 进入游戏并从新资源目录加载资源。
  12. 如果失败,删除临时文件,保留旧版本继续运行或回滚。

底层理解

热更新不要理解成“直接覆盖文件”。

真正稳的做法是:

旧版本目录还在,新版本先下载到 temp 目录。 只有当所有文件都下载成功、校验成功,才把版本指针切到新目录。

这样即使下载中断、App 闪退、磁盘满了,也不会把旧资源破坏掉。

代码示例:Manifest 差异对比

c
using System.Collections.Generic; // 引入 Dictionary 和 List 等集合类型

public class PatchFile // 定义一个补丁文件信息类
{ // PatchFile 类开始
    public string name; // 文件名,例如 "ui/login.bundle"
    public string hash; // 文件 hash,用来判断文件内容是否变化
    public long size; // 文件大小,用来辅助校验下载是否完整
} // PatchFile 类结束

public static class HotUpdateDiff // 定义热更新差异计算工具类
{ // HotUpdateDiff 类开始
    public static List<PatchFile> GetNeedDownload(Dictionary<string, PatchFile> local, Dictionary<string, PatchFile> remote) // 根据本地和远端 Manifest 计算需要下载的文件
    { // GetNeedDownload 方法开始
        List<PatchFile> result = new List<PatchFile>(); // 创建结果列表,用来保存需要下载的文件

        foreach (KeyValuePair<string, PatchFile> pair in remote) // 遍历远端 Manifest 中的每一个文件
        { // foreach 循环开始
            string fileName = pair.Key; // 取出当前文件名
            PatchFile remoteFile = pair.Value; // 取出远端文件信息

            if (!local.TryGetValue(fileName, out PatchFile localFile)) // 如果本地 Manifest 没有这个文件,说明是新增文件
            { // 新增文件判断开始
                result.Add(remoteFile); // 把新增文件加入下载列表
                continue; // 跳过后面的 hash 对比
            } // 新增文件判断结束

            if (localFile.hash != remoteFile.hash || localFile.size != remoteFile.size) // 如果 hash 或 size 不同,说明文件发生变化
            { // 文件变化判断开始
                result.Add(remoteFile); // 把变化文件加入下载列表
            } // 文件变化判断结束
        } // foreach 循环结束

        return result; // 返回最终需要下载的补丁文件列表
    } // GetNeedDownload 方法结束
} // HotUpdateDiff 类结束

面试加分点

NOTE

版本流程一定要讲“校验、临时目录、原子替换、回滚”。 只说“下载新资源覆盖旧资源”会显得不够工程化。

资源热更新通常更新的是 AssetBundle / Addressables / 配置表 / Lua 脚本。 代码热更新要额外考虑平台限制,比如 iOS 不能 JIT,HybridCLR、Lua、ILRuntime 都要处理兼容、裁剪、回滚和审核风险。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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