Appearance
5 分钟追问
讲清楚对象池完整生命周期
标准答案
对象池完整生命周期是:创建池 → 预热对象 → 取出对象 → 使用对象 → 归还对象 → 重置状态 → 再次复用 → 最终释放池子。 它的本质是用一部分常驻内存,换掉频繁 Instantiate / Destroy 带来的 CPU 开销、GC Alloc 和帧尖峰。
完整生命周期
- 创建池:记录
Prefab、初始容量、最大容量、父节点、扩容策略。 - 预热:加载阶段提前
Instantiate一批对象,全部SetActive(false)放进空闲队列。 - 取出:需要对象时从空闲队列取;不够时扩容,或达到上限后返回空。
- 激活:设置位置、旋转、父节点、数据,调用
OnSpawn。 - 使用中:比如子弹飞行、特效播放、飘字移动、怪物 AI 运行。
- 归还:命中、超时、播放结束、窗口关闭、场景切换时调用
Release。 - 重置:清理速度、目标、计时器、粒子、Trail、事件监听、协程等状态。
- 复用:重新进入空闲队列,等待下一次
Spawn。 - 释放池:场景结束、模块关闭、资源卸载时,把池内对象真正
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 虚拟列表
标准答案
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 条上。所以回调里必须校验 index 或 dataId。
高级一点的追问
固定高度列表用除法就能算 firstIndex,比较简单。 如果 Item 高度不固定,就要维护每个 Item 的高度缓存,再做前缀和数组,通过二分查找找到当前 scrollY 对应的第一个 Item。这个实现复杂很多,但适合聊天列表、邮件列表、动态文本列表。
一句话面试版
UI 虚拟列表就是“数据全保留,视图只保留可见区域”,通过滚动偏移计算可见索引范围,范围外 Item 回收到对象池,范围内 Item 取出并重新绑定数据,从而减少 GameObject 数量、内存占用和 Canvas 重建压力。
讲清楚 AssetBundle 依赖加载
标准答案
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 粉色和重复打包。
讲清楚一次技能释放流程
标准答案
一次技能释放流程可以拆成:输入请求 → 合法性校验 → 锁定释放上下文 → 进入技能状态 → 播放前摇 → 命中帧判定 → 伤害/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,同步项目里还要区分客户端表现和服务端权威结算。
讲清楚一次卡顿排查
标准答案
一次卡顿排查,我会按闭环来讲:先确认现象,再用工具抓数据,定位是哪一帧卡,展开调用栈归类瓶颈,做最小修复,最后用优化前后数据验证。 重点不是上来就说“我用了对象池”,而是先证明卡顿来自哪里。
完整排查流程
- 确认现象:是战斗中卡、打开 UI 卡、切场景卡、首次放技能卡,还是每隔几秒固定卡一下。
- 稳定复现:记录机型、场景、操作路径、帧率、内存、资源包版本。
- Profiler 抓帧:真机 Development Build 连接 Profiler,看
CPU Timeline。 - 找尖峰帧:比如正常 16ms,某一帧突然 80ms。
- 展开调用栈:看尖峰在
BehaviourUpdate、GC.Collect、Canvas.BuildBatch、Camera.Render、Physics.Simulate还是资源加载。 - 瓶颈归类:CPU、GPU、GC、UI、物理、IO、资源加载分别处理。
- 最小修复:一次只改一个主要问题,避免改太多看不出效果。
- 复测对比:同设备、同场景、同操作,对比平均帧耗、P95 帧耗、GC Alloc、内存峰值。
- 防止复发:加性能预算、资源检查、自动化跑图、线上日志和告警。
常见卡顿类型
GC 卡顿:Profiler 里看到 GC.Collect 或某些脚本每帧 GC Alloc 很高。 解决:对象池、缓存 List、NonAlloc API、减少字符串拼接、避免热路径 LINQ。
UI 卡顿:看到 Canvas.BuildBatch、Canvas.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 Alloc、GC 次数、内存峰值。如果是线上问题,还会记录机型、场景 ID、资源版本、网络状态和关键流程 traceId。
一句话面试版
一次卡顿排查就是:先复现,再用 Profiler 找尖峰帧,展开调用栈判断是 CPU、GPU、GC、UI、物理还是资源加载问题,然后做针对性优化,并用优化前后数据证明效果,最后通过规范和监控防止复发。
讲清楚 C++ 虚函数
标准答案
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 表示程序正常结束。
} // 函数结束。底层原理
虚函数通常靠 vptr 和 vtable 实现。
有虚函数的类,对象里通常会多一个隐藏指针,叫 vptr。 vptr 指向这个类对应的虚函数表,也就是 vtable。 vtable 里存的是虚函数地址。
当你写:
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,通过基类指针或引用调用虚函数时,会在运行时查表跳转到派生类实现;它提升扩展性,但有一次间接调用和对象额外指针的成本,基类析构函数通常必须声明为虚函数。
讲清楚智能指针
标准答案
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 count 和 weak 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*
标准答案
A* 是一种启发式寻路算法,可以理解成:Dijkstra + 方向感。 它每次从待探索集合 Open 里选择 f = g + h 最小的节点继续搜索。
g:从起点走到当前点的真实代价。 h:从当前点到终点的估计代价。 f:总代价估计,f = g + h。
如果 h 不高估真实距离,A* 可以保证找到最短路径。四方向格子地图里,h 常用曼哈顿距离。
核心流程
- 把起点放进
Open。 - 每轮取出
f最小的节点。 - 如果它是终点,就沿
parent回溯路径。 - 否则把它放进
Closed。 - 遍历它的邻居。
- 如果走到邻居的
g更小,就更新邻居的g、parent,并放进Open。 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 优化
标准答案
DrawCall 优化的核心不是单纯追求“数量越低越好”,而是先判断瓶颈在哪里。 DrawCall 本质是 CPU 向 GPU 提交一次绘制命令,数量太多时,CPU 需要频繁切材质、Shader、纹理、常量缓冲和渲染状态,容易让 Main Thread / Render Thread 变成瓶颈。
排查顺序
我会先用 Profiler 看 CPU、Render Thread、Camera.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 数量。
讲清楚热更新版本流程
一句话定义
热更新版本流程就是:客户端启动后先拿本地版本和远端版本做对比,只下载变化的资源或代码补丁,校验通过后再切换到新版本;如果失败,就继续用旧版本或回滚。
标准流程
- 客户端启动,读取本地
Manifest。 - 请求版本服务器,拿到远端版本信息。
- 判断是否需要强更安装包,比如客户端大版本太旧。
- 如果不用强更,就下载远端
Manifest。 - 对比本地和远端文件列表。
- 找出 hash、size、version 不一致的资源。
- 下载差异文件到临时目录。
- 校验文件完整性,比如
hash、CRC、签名、大小。 - 全部成功后,原子替换到正式目录。
- 更新本地
Manifest。 - 进入游戏并从新资源目录加载资源。
- 如果失败,删除临时文件,保留旧版本继续运行或回滚。
底层理解
热更新不要理解成“直接覆盖文件”。
真正稳的做法是:
旧版本目录还在,新版本先下载到 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 都要处理兼容、裁剪、回滚和审核风险。