Appearance
面试官深挖套路
你说用了对象池,那对象什么时候回收?重复回收怎么办?
你说用了对象池,那对象什么时候回收?重复回收怎么办?
面试里可以这样回答:
对象池里的对象不是自动回收,而是由 业务生命周期结束时主动回收。
比如:
子弹:命中目标、飞出范围、生命周期结束
特效:播放完成、粒子系统停止
怪物:死亡动画结束、尸体消失
UI Item:滚出可视区域、关闭界面重复回收有什么问题?
如果同一个对象被回收两次,池里可能变成:
c
pool = [A, A]下一次连续取两次对象:
c
obj1 = A
obj2 = A表面上像拿到了两个对象,实际是同一个引用。 这会导致两个子弹、两个特效、两个 UI 逻辑同时操作同一个对象,非常危险。
怎么防?
我一般会做三层保护:
c
1. 所有回收都走统一 Release 接口
2. 对象身上有 InPool 标记
3. Release 里判断:如果已经在池里,就直接 return不要只靠 activeSelf 判断,因为对象被禁用不一定代表它已经进池。
C# 示例
c
using System.Collections.Generic; // 引入 Stack 集合类型
using UnityEngine; // 引入 Unity 常用类型
public class PooledObject : MonoBehaviour // 定义池对象组件
{ // 组件开始
public bool InPool { get; private set; } // 记录当前对象是否已经在对象池里
public void OnSpawn() // 定义出池时调用的方法
{ // 方法开始
InPool = false; // 出池后标记为不在池中
} // 方法结束
public void OnDespawn() // 定义回池前调用的方法
{ // 方法开始
StopAllCoroutines(); // 停止旧协程,避免复用后旧逻辑继续执行
InPool = true; // 标记当前对象已经回到池中
} // 方法结束
} // 组件结束
public class GameObjectPool // 定义对象池类
{ // 类开始
private Stack<PooledObject> cache = new Stack<PooledObject>(); // 用栈保存可复用对象
private PooledObject prefab; // 保存对象预制体
private Transform root; // 保存对象池父节点
public GameObjectPool(PooledObject prefab, Transform root) // 定义构造函数
{ // 构造函数开始
this.prefab = prefab; // 记录预制体
this.root = root; // 记录父节点
} // 构造函数结束
public PooledObject Get(Vector3 position, Quaternion rotation) // 定义取对象方法
{ // 方法开始
PooledObject item = cache.Count > 0 ? cache.Pop() : Object.Instantiate(prefab, root); // 有缓存就取,没有就实例化
item.transform.SetPositionAndRotation(position, rotation); // 设置对象位置和旋转
item.OnSpawn(); // 调用出池初始化逻辑
item.gameObject.SetActive(true); // 激活对象
return item; // 返回可用对象
} // 方法结束
public void Release(PooledObject item) // 定义回收对象方法
{ // 方法开始
if (item == null) // 如果传进来的对象为空
{ // 判断开始
return; // 空对象直接忽略
} // 判断结束
if (item.InPool) // 如果对象已经在池中
{ // 判断开始
return; // 防止重复回收
} // 判断结束
item.OnDespawn(); // 调用回池清理逻辑
item.gameObject.SetActive(false); // 隐藏对象
item.transform.SetParent(root); // 挂回对象池父节点下
cache.Push(item); // 放回池中等待复用
} // 方法结束
} // 类结束面试加分说法
NOTE
我不会在业务里到处 SetActive(false),而是统一调用 Release。 Release 要设计成幂等的:调用一次和调用多次,结果都应该安全。 另外,回收前要清理速度、目标引用、计时器、协程、事件订阅,否则对象下次复用时会带着上一次的脏状态。
你说用了事件系统,那事件顺序如何保证?异常如何处理?
你说用了事件系统,那事件顺序如何保证?异常如何处理?
面试可以这样回答:
事件顺序我不会依赖 Dictionary 或“谁先注册谁先执行”的隐式行为,而是显式记录:
priority:优先级,高的先执行
sequence:订阅序号,同优先级时先订阅的先执行
snapshot:派发前复制监听列表,避免派发中增删监听导致顺序混乱异常处理上,我会让每个监听者单独 try/catch。 一个 UI 飘字异常,不应该影响战斗结算、音效播放、任务进度这些后续监听者。
核心回答
普通事件:捕获异常,记录日志,继续派发
关键事件:可以配置为异常后中断比如战斗伤害事件:
1. 战斗系统 priority = 100
2. UI 飘字 priority = 50
3. 音效系统 priority = 50如果 UI 飘字异常,我会记录错误,但继续执行音效系统。
C# 示例
c
using System; // 引入 Type、Action、Exception 等基础类型
using System.Collections.Generic; // 引入 Dictionary、List 等集合类型
public sealed class EventBus // 定义事件总线类
{ // 类开始
private sealed class HandlerEntry // 定义监听者记录类
{ // 监听者记录类开始
public int Priority; // 监听者优先级,数值越大越先执行
public long Sequence; // 监听者订阅序号,同优先级时序号小的先执行
public Action<object> Handler; // 统一保存监听回调
public string Name; // 保存监听者名字,方便异常日志定位
} // 监听者记录类结束
private readonly Dictionary<Type, List<HandlerEntry>> handlers = new Dictionary<Type, List<HandlerEntry>>(); // 保存事件类型到监听列表的映射
private long nextSequence = 0; // 记录下一个订阅序号
public void Subscribe<T>(Action<T> handler, int priority = 0, string name = null) // 订阅某种事件
{ // 方法开始
Type eventType = typeof(T); // 获取事件类型
if (!handlers.TryGetValue(eventType, out List<HandlerEntry> list)) // 如果当前事件类型还没有监听列表
{ // 判断开始
list = new List<HandlerEntry>(); // 创建新的监听列表
handlers[eventType] = list; // 把监听列表保存到字典中
} // 判断结束
HandlerEntry entry = new HandlerEntry(); // 创建监听者记录
entry.Priority = priority; // 保存优先级
entry.Sequence = nextSequence++; // 保存订阅序号并递增
entry.Name = name ?? handler.Method.Name; // 保存监听者名字
entry.Handler = e => handler((T)e); // 把强类型回调包装成 object 回调
list.Add(entry); // 加入监听列表
list.Sort((a, b) => // 对监听列表排序
{ // 排序函数开始
int priorityCompare = b.Priority.CompareTo(a.Priority); // 优先级高的排前面
if (priorityCompare != 0) // 如果优先级不同
{ // 判断开始
return priorityCompare; // 直接按优先级排序
} // 判断结束
return a.Sequence.CompareTo(b.Sequence); // 优先级相同时,先订阅的排前面
}); // 排序函数结束
} // 方法结束
public void Publish<T>(T eventData, bool stopOnException = false) // 发布事件
{ // 方法开始
Type eventType = typeof(T); // 获取事件类型
if (!handlers.TryGetValue(eventType, out List<HandlerEntry> list)) // 如果没有任何监听者
{ // 判断开始
return; // 直接结束
} // 判断结束
HandlerEntry[] snapshot = list.ToArray(); // 派发前做快照,避免遍历中增删监听导致问题
for (int i = 0; i < snapshot.Length; i++) // 按排序后的顺序遍历监听者
{ // 循环开始
HandlerEntry entry = snapshot[i]; // 取出当前监听者
try // 尝试执行监听者
{ // try 开始
entry.Handler(eventData); // 调用监听回调
} // try 结束
catch (Exception ex) // 捕获当前监听者异常
{ // catch 开始
Console.WriteLine($"Event {eventType.Name}, Handler {entry.Name}, Error {ex.Message}"); // 记录事件类型、监听者和异常信息
if (stopOnException) // 如果当前事件要求异常后中断
{ // 判断开始
throw; // 把异常继续抛出,让上层决定如何处理
} // 判断结束
} // catch 结束
} // 循环结束
} // 方法结束
} // 类结束面试加分说法
我会把事件分成两类:
非关键事件:UI、音效、提示、红点,异常后记录日志并继续
关键事件:支付、存档、交易、战斗结算,可以配置为异常后中断一句话总结就是:
c
顺序靠 priority + sequence 保证;
派发靠 snapshot 稳定;
异常靠 try/catch 隔离;
关键事件允许 fail-fast。你说用了状态机,那状态爆炸怎么办?
你说用了状态机,那状态爆炸怎么办?
面试可以这样答:
状态爆炸通常不是状态机本身的问题,而是把太多维度硬塞进了一个状态里。 比如这种就很危险:
c
Run_Attack_Burning_Sword
Jump_Attack_Burning_Sword
Idle_Attack_Poison_Bow移动、攻击、Buff、武器、动画都混在一起,状态数量会成倍增长。
正确处理方式
c
1. 拆正交维度:移动 FSM、战斗 FSM、Buff 系统分开
2. 分层状态机:Grounded 下面再分 Idle、Run
3. 用参数表达属性:Burning、Poison、Weapon 不要全变成状态
4. 复杂 AI 决策交给行为树或 GOAP,不要硬塞进 FSMC# 示例
c
public enum MoveState // 定义移动状态枚举
{ // 枚举开始
Idle, // 站立状态
Run, // 跑动状态
Jump, // 跳跃状态
Stunned // 眩晕状态
} // 枚举结束
public enum CombatState // 定义战斗状态枚举
{ // 枚举开始
Ready, // 可以攻击
Attacking, // 正在攻击
Cooldown, // 攻击冷却中
Locked // 被控制,不能攻击
} // 枚举结束
public class CharacterStateController // 定义角色状态控制器
{ // 类开始
private MoveState moveState = MoveState.Idle; // 移动状态单独维护
private CombatState combatState = CombatState.Ready; // 战斗状态单独维护
public bool IsStunned; // 是否眩晕,这是上下文条件
public bool IsGrounded; // 是否在地面,这是移动判断条件
public float MoveInput; // 移动输入值
public bool AttackPressed; // 是否按下攻击键
public bool HasBurnBuff; // 是否有燃烧 Buff,它不是移动状态
public string WeaponType = "Sword"; // 武器类型是数据,不要拼进状态名
public void Tick() // 每帧更新状态
{ // 方法开始
UpdateMoveState(); // 更新移动状态机
UpdateCombatState(); // 更新战斗状态机
ApplyAnimationParams(); // 把状态和参数同步给动画层
} // 方法结束
private void UpdateMoveState() // 更新移动状态
{ // 方法开始
if (IsStunned) // 如果角色被眩晕
{ // 判断开始
moveState = MoveState.Stunned; // 移动状态切到眩晕
return; // 眩晕优先级最高,直接结束
} // 判断结束
if (!IsGrounded) // 如果角色不在地面
{ // 判断开始
moveState = MoveState.Jump; // 移动状态切到跳跃
return; // 空中状态处理完成
} // 判断结束
if (MoveInput > 0.01f) // 如果有移动输入
{ // 判断开始
moveState = MoveState.Run; // 移动状态切到跑动
return; // 跑动状态处理完成
} // 判断结束
moveState = MoveState.Idle; // 否则就是站立状态
} // 方法结束
private void UpdateCombatState() // 更新战斗状态
{ // 方法开始
if (IsStunned) // 如果角色被眩晕
{ // 判断开始
combatState = CombatState.Locked; // 战斗状态切到锁定
return; // 被控制时不能攻击
} // 判断结束
if (AttackPressed && combatState == CombatState.Ready) // 如果按下攻击并且当前可攻击
{ // 判断开始
combatState = CombatState.Attacking; // 战斗状态切到攻击中
return; // 攻击状态处理完成
} // 判断结束
if (combatState == CombatState.Attacking) // 如果当前正在攻击
{ // 判断开始
combatState = CombatState.Cooldown; // 攻击结束后进入冷却
return; // 冷却状态处理完成
} // 判断结束
if (combatState == CombatState.Cooldown) // 如果当前处于冷却
{ // 判断开始
combatState = CombatState.Ready; // 冷却结束后恢复可攻击
} // 判断结束
} // 方法结束
private void ApplyAnimationParams() // 同步动画参数
{ // 方法开始
bool isMoving = moveState == MoveState.Run; // 根据移动状态得到是否移动
bool isAttacking = combatState == CombatState.Attacking; // 根据战斗状态得到是否攻击
bool isBurning = HasBurnBuff; // Buff 作为独立表现参数
} // 方法结束
} // 类结束面试加分说法
我不会把 Run + Attack + Burning + Sword 拼成一个状态。 我会把它拆成:
c
MoveState = Run
CombatState = Attacking
Buff = Burning
Weapon = Sword这样新增一个 Buff 或武器,不会导致状态数量爆炸。
一句话总结:
互斥流程才做状态;
可叠加条件做参数或组件;
复杂决策交给行为树;
动画表现用参数和 Layer 消化。你说优化了 GC,那具体减少了多少?怎么测?
NOTE
面试回答: 不能只说“我优化了 GC”,要说清楚“优化前多少、优化后多少、怎么测出来的”。比较漂亮的回答是:
我会先固定测试环境:同一台真机、同一个 Development Build、同一段战斗流程、同样时长,比如 10 分钟压测。然后用 Unity Profiler 看 GC Alloc、GC.Collect 尖峰、Managed Heap 峰值、帧耗时 P95/P99。比如优化前平均 3.2KB/frame,约 15 秒触发一次 GC,单次卡顿 8-12ms;优化后常规帧降到 0-128B/frame,10 分钟内没有明显 GC.Collect 尖峰,Managed Heap 峰值从 148MB 降到 121MB。这样面试官会觉得你是真的测过,而不是只会背“减少 GC”。
简单采样脚本:
c
using UnityEngine; // 引入 Unity 基础 API。
using Unity.Profiling; // 引入 ProfilerRecorder,用来读取 Profiler 指标。
using System.Text; // 引入 StringBuilder,避免频繁字符串拼接。
public class GcMeasureRecorder : MonoBehaviour // 定义一个挂在场景里的 GC 采样组件。
{ // 类开始。
private ProfilerRecorder gcAllocRecorder; // 保存每帧 GC Alloc 的采样器。
private StringBuilder logBuilder = new StringBuilder(256); // 复用日志构建器,减少额外 GC。
private int frameCount; // 记录采样了多少帧。
private long totalGcAlloc; // 记录总 GC 分配字节数。
private long maxGcAlloc; // 记录单帧最大 GC 分配。
private void OnEnable() // 组件启用时开始采样。
{ // 方法开始。
gcAllocRecorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame"); // 开启每帧 GC 分配采样。
frameCount = 0; // 重置帧数。
totalGcAlloc = 0; // 重置总分配。
maxGcAlloc = 0; // 重置峰值分配。
} // 方法结束。
private void Update() // 每帧统计一次。
{ // 方法开始。
long gcAlloc = gcAllocRecorder.LastValue; // 读取当前帧 GC Alloc,单位是字节。
frameCount++; // 采样帧数加一。
totalGcAlloc += gcAlloc; // 把当前帧分配加入总量。
if (gcAlloc > maxGcAlloc) // 如果当前帧分配比历史峰值更大。
{ // 判断开始。
maxGcAlloc = gcAlloc; // 更新最大单帧分配。
} // 判断结束。
} // 方法结束。
private void OnDisable() // 组件关闭时输出统计结果。
{ // 方法开始。
long avgGcAlloc = frameCount > 0 ? totalGcAlloc / frameCount : 0; // 计算平均每帧 GC Alloc。
logBuilder.Clear(); // 清空复用的 StringBuilder。
logBuilder.Append("Frames: ").Append(frameCount).Append('\n'); // 输出采样帧数。
logBuilder.Append("Avg GC Alloc: ").Append(avgGcAlloc).Append(" B/frame\n"); // 输出平均每帧分配。
logBuilder.Append("Max GC Alloc: ").Append(maxGcAlloc).Append(" B/frame"); // 输出最大单帧分配。
Debug.Log(logBuilder.ToString()); // 打印结果,最终面试可配合 Profiler 截图证明。
gcAllocRecorder.Dispose(); // 释放 ProfilerRecorder。
} // 方法结束。
} // 类结束。TIP
补一句很加分: Editor 里的数据只能辅助定位,最终数据最好来自真机 Development Build + Autoconnect Profiler;Deep Profile 可以帮助找调用栈,但它本身会放大开销,所以不能直接拿它当最终性能数据。
你说用了异步加载,那依赖资源没加载完怎么办?
CAUTION
面试回答: 依赖资源没加载完时,不能直接实例化主资源。我的做法是:资源管理器内部维护一个“加载状态表”,同一个资源如果正在加载,就复用同一个 Task/Handle 等它完成;只有依赖全部成功后,主资源才进入 Ready 状态。如果依赖失败,就返回失败结果,走重试、占位资源、取消加载或降级逻辑。
Addressables 里,按 key 加载资源时会自动处理依赖,但业务层仍然要等 handle.Task 完成;AssetBundle 则通常要先通过 Manifest 找依赖包,把依赖包加载完,再加载目标包。
C# 示例:同一个资源加载中时,复用同一个异步任务
c
using System.Collections.Generic; // 引入 Dictionary,用来保存加载状态表。
using System.Threading.Tasks; // 引入 Task,用来表示异步加载任务。
using UnityEngine; // 引入 Unity 的 GameObject 类型。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载 API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 和加载状态枚举。
public sealed class AsyncPrefabLoader // 定义一个异步 Prefab 加载器。
{ // 类开始。
private readonly Dictionary<string, Task<GameObject>> loadingTasks = new Dictionary<string, Task<GameObject>>(); // 保存正在加载的任务,避免重复加载。
private readonly Dictionary<string, AsyncOperationHandle<GameObject>> loadedHandles = new Dictionary<string, AsyncOperationHandle<GameObject>>(); // 保存已经加载成功的资源句柄。
public async Task<GameObject> LoadPrefabAsync(string key) // 对外提供异步加载 Prefab 的方法。
{ // 方法开始。
if (loadedHandles.TryGetValue(key, out AsyncOperationHandle<GameObject> loadedHandle)) // 如果资源已经加载成功。
{ // 判断开始。
return loadedHandle.Result; // 直接返回已经加载好的 Prefab。
} // 判断结束。
if (loadingTasks.TryGetValue(key, out Task<GameObject> runningTask)) // 如果同一个资源正在加载中。
{ // 判断开始。
return await runningTask; // 等待同一个任务完成,而不是重新发起加载。
} // 判断结束。
Task<GameObject> newTask = LoadPrefabInternalAsync(key); // 创建真正的加载任务。
loadingTasks[key] = newTask; // 把任务放入加载中表。
try // 开始等待加载结果。
{ // try 开始。
return await newTask; // 等依赖和主资源都加载成功后再返回。
} // try 结束。
finally // 无论成功还是失败,都要清理加载中状态。
{ // finally 开始。
loadingTasks.Remove(key); // 从加载中表移除,避免状态残留。
} // finally 结束。
} // 方法结束。
private async Task<GameObject> LoadPrefabInternalAsync(string key) // 真正执行 Addressables 加载的方法。
{ // 方法开始。
AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key); // 发起异步加载,Addressables 会处理依赖。
await handle.Task; // 等待依赖和目标资源加载完成。
if (handle.Status != AsyncOperationStatus.Succeeded) // 如果加载失败。
{ // 判断开始。
Addressables.Release(handle); // 释放失败句柄,避免资源状态泄漏。
throw new System.Exception("Load prefab failed: " + key); // 抛出异常,让业务层决定重试或降级。
} // 判断结束。
loadedHandles[key] = handle; // 保存成功句柄,后续可以直接复用。
return handle.Result; // 返回加载完成的 Prefab。
} // 方法结束。
public void ReleasePrefab(string key) // 对外提供释放资源的方法。
{ // 方法开始。
if (!loadedHandles.TryGetValue(key, out AsyncOperationHandle<GameObject> handle)) // 如果资源没有加载成功。
{ // 判断开始。
return; // 直接返回,避免错误释放。
} // 判断结束。
Addressables.Release(handle); // 释放 Addressables 资源句柄。
loadedHandles.Remove(key); // 从已加载表中移除。
} // 方法结束。
} // 类结束。TIP
一句话背法: 异步加载时,依赖没好就不使用;加载中请求要合并;依赖成功后再 Ready;失败要重试或降级;释放时要防止依赖被提前卸载。
你说用了 Addressables,那资源卸载谁负责?
IMPORTANT
面试回答: Addressables 的资源卸载不是“系统自动替你管完”,它只负责底层引用计数;真正决定什么时候不用资源的是业务层。我的项目里一般会让 ResourceManager 统一持有 AsyncOperationHandle,UI、场景、战斗模块只做“申请资源”和“归还资源”。
低层原则是:谁 Load,谁 Release;谁 InstantiateAsync,谁 ReleaseInstance。 如果漏掉 Release,引用计数降不下来,Bundle、贴图、材质可能一直占内存;如果过早 Release,资源可能被提前卸载,出现贴图丢失、材质异常、Missing Reference 等问题。
C# 示例:用“租约”管理 Addressables 引用计数
c
using System; // 引入 IDisposable 和 Exception。
using System.Collections.Generic; // 引入 Dictionary。
using System.Threading.Tasks; // 引入 Task。
using UnityEngine; // 引入 GameObject。
using UnityEngine.AddressableAssets; // 引入 Addressables API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle。
public sealed class AddressableAssetLease : IDisposable // 定义业务层拿到的资源租约。
{ // 资源租约类开始。
private readonly AddressablePrefabManager owner; // 保存资源管理器引用。
private readonly string key; // 保存资源 key。
private bool disposed; // 记录是否已经归还过。
public GameObject Asset { get; } // 对外暴露加载好的资源。
internal AddressableAssetLease(AddressablePrefabManager owner, string key, GameObject asset) // 创建租约。
{ // 构造函数开始。
this.owner = owner; // 保存资源管理器。
this.key = key; // 保存资源 key。
Asset = asset; // 保存资源对象。
} // 构造函数结束。
public void Dispose() // 业务用完资源时归还。
{ // 方法开始。
if (disposed) return; // 如果已经归还过,就避免重复 Release。
disposed = true; // 标记已经归还。
owner.Release(key); // 通知资源管理器减少引用计数。
} // 方法结束。
} // 资源租约类结束。
public sealed class AddressablePrefabManager // 定义 Prefab 资源管理器。
{ // 管理器类开始。
private sealed class Entry // 定义单个资源的缓存记录。
{ // 记录类开始。
public AsyncOperationHandle<GameObject> Handle; // 保存 Addressables 加载句柄。
public Task<GameObject> LoadingTask; // 保存正在加载的任务。
public int RefCount; // 保存业务引用计数。
} // 记录类结束。
private readonly Dictionary<string, Entry> entries = new Dictionary<string, Entry>(); // 保存所有资源记录。
public async Task<AddressableAssetLease> AcquireAsync(string key) // 业务申请资源。
{ // 方法开始。
Entry entry = GetOrCreateEntry(key); // 获取或创建资源记录。
entry.RefCount++; // 引用计数加一。
GameObject asset = await entry.LoadingTask; // 等待资源和依赖加载完成。
return new AddressableAssetLease(this, key, asset); // 返回租约,让业务用完后归还。
} // 方法结束。
private Entry GetOrCreateEntry(string key) // 获取或创建加载记录。
{ // 方法开始。
if (entries.TryGetValue(key, out Entry entry)) return entry; // 如果已经加载或正在加载,就复用同一份记录。
entry = new Entry(); // 创建新的资源记录。
entries[key] = entry; // 先放进表里,避免重复加载。
entry.LoadingTask = LoadInternalAsync(key, entry); // 发起真正的异步加载。
return entry; // 返回资源记录。
} // 方法结束。
private async Task<GameObject> LoadInternalAsync(string key, Entry entry) // 真正调用 Addressables 加载。
{ // 方法开始。
AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key); // 发起加载,Addressables 会处理依赖。
await handle.Task; // 等待加载完成。
if (handle.Status != AsyncOperationStatus.Succeeded) // 判断加载是否失败。
{ // 判断开始。
entries.Remove(key); // 从缓存表移除失败记录。
Addressables.Release(handle); // 释放失败句柄。
throw new Exception("Addressables load failed: " + key); // 抛出异常交给业务处理。
} // 判断结束。
entry.Handle = handle; // 保存成功句柄。
return handle.Result; // 返回加载好的 Prefab 资源。
} // 方法结束。
internal void Release(string key) // 归还资源。
{ // 方法开始。
if (!entries.TryGetValue(key, out Entry entry)) return; // 如果没有记录,直接返回。
entry.RefCount--; // 引用计数减一。
if (entry.RefCount > 0) return; // 还有业务在用,就不能释放。
Addressables.Release(entry.Handle); // 引用计数归零,释放 Addressables 句柄。
entries.Remove(key); // 从缓存表移除资源记录。
} // 方法结束。
} // 管理器类结束。TIP
注意点: 如果用的是 Addressables.InstantiateAsync 创建对象,释放时应该用 Addressables.ReleaseInstance(obj);如果是 LoadAssetAsync 加载 Prefab 模板再自己 Instantiate,那实例对象要自己 Destroy,资源句柄再由资源管理器 Release。
你说用了热更新,那线上出错怎么回滚?
IMPORTANT
面试回答: 我会说:热更新上线前必须设计回滚机制,不能等线上炸了再想办法。回滚的核心不是“重新发一个包”,而是服务端把当前版本 Manifest 指针切回上一稳定版本 last_good,客户端下次启动或重进游戏时发现远端版本回退,就删除坏版本缓存,重新使用稳定资源或稳定脚本包。
资源热更一般比较好回滚,因为 Bundle、Hash、Manifest 都可以保留旧版本;代码热更要更小心,Lua、HybridCLR、ILRuntime 这类方案回滚时要保证接口、配置、存档数据兼容。如果新版本已经写入了新结构存档,还要准备修档或兼容读取逻辑。
C# 简化示例:本地保留上一稳定 Manifest,出错时回滚
c
using System; // 引入 Exception 和 Serializable。
using System.IO; // 引入文件读写 API。
using UnityEngine; // 引入 Unity 的 JsonUtility 和 PlayerPrefs。
[Serializable] // 允许 Unity 把这个类序列化成 JSON。
public sealed class HotfixManifest // 定义热更版本清单。
{ // 类开始。
public string version; // 记录热更版本号。
public string hash; // 记录资源包或代码包的校验 Hash。
public int schema; // 记录数据结构版本,用来判断兼容性。
} // 类结束。
public sealed class HotfixRollbackManager // 定义热更回滚管理器。
{ // 类开始。
private readonly string currentPath; // 保存当前 Manifest 路径。
private readonly string lastGoodPath; // 保存上一稳定 Manifest 路径。
private readonly string badPath; // 保存坏版本 Manifest 路径,方便排查问题。
public HotfixRollbackManager(string rootPath) // 构造函数传入热更根目录。
{ // 构造函数开始。
currentPath = Path.Combine(rootPath, "current_manifest.json"); // 拼出当前 Manifest 文件路径。
lastGoodPath = Path.Combine(rootPath, "last_good_manifest.json"); // 拼出上一稳定 Manifest 文件路径。
badPath = Path.Combine(rootPath, "bad_manifest.json"); // 拼出坏版本 Manifest 文件路径。
} // 构造函数结束。
public void MarkCurrentAsGood() // 当前版本运行稳定后,把它标记为稳定版本。
{ // 方法开始。
if (!File.Exists(currentPath)) return; // 如果当前 Manifest 不存在,就直接返回。
File.Copy(currentPath, lastGoodPath, true); // 把当前 Manifest 复制成 last_good。
} // 方法结束。
public void RollbackToLastGood() // 回滚到上一稳定版本。
{ // 方法开始。
if (!File.Exists(lastGoodPath)) throw new Exception("No last good manifest."); // 没有稳定版本就不能回滚。
if (File.Exists(currentPath)) File.Copy(currentPath, badPath, true); // 先备份坏版本,方便后续分析。
File.Copy(lastGoodPath, currentPath, true); // 用上一稳定 Manifest 覆盖当前 Manifest。
PlayerPrefs.SetString("HotfixState", "RollbackToLastGood"); // 记录客户端发生过回滚。
PlayerPrefs.Save(); // 立刻保存回滚状态。
} // 方法结束。
public HotfixManifest LoadCurrentManifest() // 读取当前正在使用的 Manifest。
{ // 方法开始。
string json = File.ReadAllText(currentPath); // 读取当前 Manifest JSON 文本。
return JsonUtility.FromJson<HotfixManifest>(json); // 把 JSON 反序列化成 Manifest 对象。
} // 方法结束。
} // 类结束。NOTE
面试加分句: 我不会只做“客户端本地回滚”,还会做服务端开关:发现崩溃率、登录失败率、关键异常超过阈值后,先熔断灰度,停止继续放量,再把远端 Manifest 从 v101 切回 v100 last_good,最后看监控指标是否回落。
你说用了 Shader,那移动端兼容怎么处理?
WARNING
面试回答: 我会说:移动端 Shader 兼容不是只看“能不能跑”,而是要看低端机能不能稳、不同图形 API 表现是否一致、性能是否可控、效果能不能降级。一般会做低中高画质档位:低端机关掉复杂光照、阴影、后处理、溶解边缘、Rim 等效果;中高端机再逐步开启。
重点处理这几类问题:少纹理采样、少透明 Overdraw、少动态分支、控制 Shader Variant、用 half 降低精度成本、避免移动端不友好的特性,比如 Geometry Shader、Tessellation、GrabPass、大量循环、复杂后处理。最后一定用 Android 和 iOS 真机测,不能只看 Editor。
ShaderLab 示例:移动端低成本 Unlit
c
Shader "Interview/MobileCompatibleUnlit" // 定义一个移动端友好的 Unlit Shader。
{ // Shader 开始。
Properties // 定义材质面板参数。
{ // Properties 开始。
_MainTex ("Main Texture", 2D) = "white" {} // 主贴图,默认白图。
_Color ("Tint Color", Color) = (1, 1, 1, 1) // 颜色叠加参数。
} // Properties 结束。
SubShader // 定义一个渲染子着色器。
{ // SubShader 开始。
Tags { "RenderType"="Opaque" "Queue"="Geometry" } // 设置为不透明队列,移动端更友好。
LOD 100 // 设置较低 LOD,表示这是低成本版本。
Pass // 定义一个渲染 Pass。
{ // Pass 开始。
CGPROGRAM // 开始 CG/HLSL 代码。
#pragma vertex vert // 指定顶点着色器函数。
#pragma fragment frag // 指定片元着色器函数。
#pragma target 2.0 // 降低 Shader Model 要求,提高移动端兼容性。
#pragma shader_feature_local _QUALITY_HIGH // 使用本地关键字,减少全局变体污染。
#include "UnityCG.cginc" // 引入 Unity 内置辅助函数。
sampler2D _MainTex; // 声明主贴图采样器。
half4 _Color; // 使用 half 精度保存颜色,移动端通常更省。
float4 _MainTex_ST; // Unity 自动生成的贴图缩放和偏移。
struct appdata // 定义顶点输入结构。
{ // appdata 开始。
float4 vertex : POSITION; // 顶点坐标。
half2 uv : TEXCOORD0; // UV 坐标用 half2,减少精度成本。
}; // appdata 结束。
struct v2f // 定义顶点到片元的数据结构。
{ // v2f 开始。
float4 pos : SV_POSITION; // 裁剪空间坐标。
half2 uv : TEXCOORD0; // 传给片元着色器的 UV。
}; // v2f 结束。
v2f vert(appdata v) // 顶点着色器函数。
{ // vert 开始。
v2f o; // 创建输出结构。
o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间。
o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 应用贴图缩放和偏移。
return o; // 返回顶点输出。
} // vert 结束。
half4 frag(v2f i) : SV_Target // 片元着色器函数。
{ // frag 开始。
half4 col = tex2D(_MainTex, i.uv) * _Color; // 采样一次贴图并乘颜色。
#if defined(_QUALITY_HIGH) // 高画质分支,打包时可裁剪。
col.rgb = sqrt(col.rgb); // 示例高画质处理,真实项目可替换成更复杂效果。
#endif // 高画质分支结束。
return col; // 输出最终颜色。
} // frag 结束。
ENDCG // 结束 CG/HLSL 代码。
} // Pass 结束。
} // SubShader 结束。
Fallback "Unlit/Texture" // 如果当前 Shader 不支持,回退到更简单的内置 Shader。
} // Shader 结束。CAUTION
加分说法: 我不会只写一个最高效果 Shader,而是做“低成本基础版 + 高画质 Keyword + 构建时变体裁剪 + 真机性能验证”。如果某些机型 GPU 时间、发热或画面异常,就通过画质档位关闭对应 Keyword,而不是让所有设备硬跑同一套效果。
你说用了网络同步,那弱网下怎么处理?
IMPORTANT
面试回答: 弱网下我不会只说“断线重连”,而是分层处理:网络层处理丢包、乱序、重传;同步层处理快照、插值、预测、校正;表现层保证画面尽量平滑;服务端始终保持权威。
比如移动同步里,玩家自己的角色用客户端预测,输入先本地执行,避免按键后卡住;服务端回权威状态后,如果误差小就平滑校正,误差大才拉回。其他玩家或怪物用快照插值,客户端故意延迟播放一点点,比如 100ms,这样即使网络包有抖动,也能在两个快照之间平滑过渡。
C# 示例:远端对象快照插值,缓解弱网抖动
c
using System.Collections.Generic; // 引入 List,用来保存服务器快照。
using UnityEngine; // 引入 MonoBehaviour、Vector3、Time 等 Unity 类型。
public sealed class SnapshotInterpolator : MonoBehaviour // 定义一个远端对象快照插值组件。
{ // 类开始。
private struct Snapshot // 定义服务器同步过来的状态快照。
{ // 结构体开始。
public float Time; // 服务器时间或同步后的逻辑时间。
public Vector3 Position; // 服务器给出的对象位置。
} // 结构体结束。
private readonly List<Snapshot> snapshots = new List<Snapshot>(); // 保存收到但还没播放完的快照。
private const float InterpolationDelay = 0.1f; // 延迟 100ms 播放,用来吸收网络抖动。
public void AddSnapshot(float serverTime, Vector3 position) // 网络层收到服务器快照时调用。
{ // 方法开始。
snapshots.Add(new Snapshot { Time = serverTime, Position = position }); // 把新快照加入缓冲区。
snapshots.Sort((a, b) => a.Time.CompareTo(b.Time)); // 按时间排序,处理乱序到达的网络包。
} // 方法结束。
private void Update() // 每帧更新远端对象表现。
{ // 方法开始。
if (snapshots.Count < 2) return; // 快照不足两个时无法插值。
float renderTime = Time.time - InterpolationDelay; // 计算当前要播放的历史时间点。
while (snapshots.Count >= 2 && snapshots[1].Time <= renderTime) // 如果更旧的快照已经用不上。
{ // 循环开始。
snapshots.RemoveAt(0); // 删除旧快照,避免缓冲区无限增长。
} // 循环结束。
Snapshot from = snapshots[0]; // 取插值起点快照。
Snapshot to = snapshots[1]; // 取插值终点快照。
float length = to.Time - from.Time; // 计算两个快照的时间间隔。
float t = length > 0f ? (renderTime - from.Time) / length : 0f; // 计算插值比例。
t = Mathf.Clamp01(t); // 把比例限制在 0 到 1 之间。
transform.position = Vector3.Lerp(from.Position, to.Position, t); // 在两个服务器位置之间平滑移动。
} // 方法结束。
} // 类结束。NOTE
加分说法: 关键操作,比如登录、购买、释放技能、结算奖励,我会走可靠消息,带 seq + ack + retry;普通位置同步可以允许丢旧包,因为下一帧快照会覆盖。弱网严重时还可以降低同步频率、减少非关键消息、显示弱网提示、短线保留会话并在重连后拉一份服务端完整快照恢复。
你说做过引擎 Demo,那模块边界怎么划分?
TIP
面试回答: 我会按“职责边界 + 依赖方向 + 生命周期”来划分。最上层是 Game/Demo,只写玩法和测试场景;中间是 Engine API,给业务提供稳定接口;下面是运行时模块,比如 Resource、Scene、Render、Physics、Audio、Input;最底层是 Core 和 Platform,负责日志、时间、数学、文件、线程、图形 API 抽象。
关键原则是:上层可以依赖下层,下层不能反向依赖上层。 比如渲染模块不应该直接知道“玩家血量、怪物 AI”,它只接收 RenderItem、MaterialHandle、MeshHandle 这种渲染数据;资源模块也不应该知道“背包、技能、任务”,它只负责加载、缓存、引用计数和释放。
C# 示例:用模块接口划清生命周期边界
c
using System.Collections.Generic; // 引入 List,用来保存模块列表。
public sealed class EngineContext // 定义引擎上下文,用来传递公共服务。
{ // EngineContext 类开始。
public float DeltaTime { get; set; } // 保存当前帧的时间间隔。
} // EngineContext 类结束。
public interface IEngineModule // 定义所有引擎模块必须遵守的接口。
{ // 接口开始。
string Name { get; } // 模块名称,用于日志和调试。
void Initialize(EngineContext context); // 模块初始化,例如创建缓存和注册服务。
void Tick(float deltaTime); // 模块每帧更新,例如输入、场景、物理等。
void Shutdown(); // 模块关闭,例如释放资源和注销事件。
} // 接口结束。
public sealed class ModuleManager // 定义模块管理器,统一控制模块生命周期。
{ // ModuleManager 类开始。
private readonly List<IEngineModule> modules = new List<IEngineModule>(); // 保存已经注册的模块。
private readonly EngineContext context = new EngineContext(); // 保存共享的引擎上下文。
public void Register(IEngineModule module) // 注册一个引擎模块。
{ // Register 方法开始。
modules.Add(module); // 按注册顺序保存模块。
} // Register 方法结束。
public void InitializeAll() // 初始化所有模块。
{ // InitializeAll 方法开始。
foreach (IEngineModule module in modules) // 按顺序遍历模块。
{ // foreach 开始。
module.Initialize(context); // 调用模块初始化。
} // foreach 结束。
} // InitializeAll 方法结束。
public void TickAll(float deltaTime) // 更新所有模块。
{ // TickAll 方法开始。
context.DeltaTime = deltaTime; // 更新全局帧间隔。
foreach (IEngineModule module in modules) // 按顺序遍历模块。
{ // foreach 开始。
module.Tick(deltaTime); // 调用模块更新。
} // foreach 结束。
} // TickAll 方法结束。
public void ShutdownAll() // 关闭所有模块。
{ // ShutdownAll 方法开始。
for (int i = modules.Count - 1; i >= 0; i--) // 按初始化的反方向关闭模块。
{ // for 开始。
modules[i].Shutdown(); // 调用模块关闭。
} // for 结束。
} // ShutdownAll 方法结束。
} // ModuleManager 类结束。NOTE
加分说法: 我会把模块边界设计成“业务调引擎,引擎调模块,模块之间通过接口和数据通信”。这样后面替换渲染后端、替换资源系统、换输入系统时,不会牵一发动全身。