Skip to content

面试官深挖套路

你说用了对象池,那对象什么时候回收?重复回收怎么办?

你说用了对象池,那对象什么时候回收?重复回收怎么办?

面试里可以这样回答:

对象池里的对象不是自动回收,而是由 业务生命周期结束时主动回收

比如:

子弹:命中目标、飞出范围、生命周期结束
特效:播放完成、粒子系统停止
怪物:死亡动画结束、尸体消失
UI Item:滚出可视区域、关闭界面

object-pool-recycle-duplicate-csharp

重复回收有什么问题?

如果同一个对象被回收两次,池里可能变成:

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),而是统一调用 ReleaseRelease 要设计成幂等的:调用一次和调用多次,结果都应该安全。 另外,回收前要清理速度、目标引用、计时器、协程、事件订阅,否则对象下次复用时会带着上一次的脏状态。

你说用了事件系统,那事件顺序如何保证?异常如何处理?

你说用了事件系统,那事件顺序如何保证?异常如何处理?

面试可以这样回答:

事件顺序我不会依赖 Dictionary 或“谁先注册谁先执行”的隐式行为,而是显式记录:

priority:优先级,高的先执行
sequence:订阅序号,同优先级时先订阅的先执行
snapshot:派发前复制监听列表,避免派发中增删监听导致顺序混乱

异常处理上,我会让每个监听者单独 try/catch。 一个 UI 飘字异常,不应该影响战斗结算、音效播放、任务进度这些后续监听者。

event-system-order-exception-csharp

核心回答

普通事件:捕获异常,记录日志,继续派发
关键事件:可以配置为异常后中断

比如战斗伤害事件:

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、武器、动画都混在一起,状态数量会成倍增长。

state-machine-state-explosion-csharp

正确处理方式

c
1. 拆正交维度:移动 FSM、战斗 FSM、Buff 系统分开
2. 分层状态机:Grounded 下面再分 Idle、Run
3. 用参数表达属性:Burning、Poison、Weapon 不要全变成状态
4. 复杂 AI 决策交给行为树或 GOAP,不要硬塞进 FSM

C# 示例

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 AllocGC.Collect 尖峰、Managed Heap 峰值、帧耗时 P95/P99。比如优化前平均 3.2KB/frame,约 15 秒触发一次 GC,单次卡顿 8-12ms;优化后常规帧降到 0-128B/frame,10 分钟内没有明显 GC.Collect 尖峰,Managed Heap 峰值从 148MB 降到 121MB。这样面试官会觉得你是真的测过,而不是只会背“减少 GC”。

gc-optimization-measurement-csharp

简单采样脚本:

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 ProfilerDeep Profile 可以帮助找调用栈,但它本身会放大开销,所以不能直接拿它当最终性能数据。

你说用了异步加载,那依赖资源没加载完怎么办?

CAUTION

面试回答: 依赖资源没加载完时,不能直接实例化主资源。我的做法是:资源管理器内部维护一个“加载状态表”,同一个资源如果正在加载,就复用同一个 Task/Handle 等它完成;只有依赖全部成功后,主资源才进入 Ready 状态。如果依赖失败,就返回失败结果,走重试、占位资源、取消加载或降级逻辑。

Addressables 里,按 key 加载资源时会自动处理依赖,但业务层仍然要等 handle.Task 完成;AssetBundle 则通常要先通过 Manifest 找依赖包,把依赖包加载完,再加载目标包。

csharp-unity-async-dependency-not-ready

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 等问题。

csharp-unity-addressables-unload-owner

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 这类方案回滚时要保证接口、配置、存档数据兼容。如果新版本已经写入了新结构存档,还要准备修档或兼容读取逻辑。

csharp-unity-hot-update-rollback

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。

csharp-unity-shader-mobile-compatibility

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,这样即使网络包有抖动,也能在两个快照之间平滑过渡。

csharp-unity-network-sync-weak-network

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,给业务提供稳定接口;下面是运行时模块,比如 ResourceSceneRenderPhysicsAudioInput;最底层是 CorePlatform,负责日志、时间、数学、文件、线程、图形 API 抽象。

关键原则是:上层可以依赖下层,下层不能反向依赖上层。 比如渲染模块不应该直接知道“玩家血量、怪物 AI”,它只接收 RenderItemMaterialHandleMeshHandle 这种渲染数据;资源模块也不应该知道“背包、技能、任务”,它只负责加载、缓存、引用计数和释放。

csharp-engine-demo-module-boundaries

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

加分说法: 我会把模块边界设计成“业务调引擎,引擎调模块,模块之间通过接口和数据通信”。这样后面替换渲染后端、替换资源系统、换输入系统时,不会牵一发动全身。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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