Skip to content

引擎项目追问

为什么要自己写这个模块?

why-build-module-yourself-interview

面试高分回答

我不会一上来就所有东西都自研。一般会先评估官方方案、插件或者开源方案,如果它能稳定满足需求,我会优先接入或做一层封装。只有当项目有比较明确的定制需求,或者现成方案在性能、包体、扩展性、调试成本上不合适时,才会考虑自己写。

比如 UI 管理、对象池、资源加载、配置表、红点系统这些模块,项目里经常需要和业务规则强绑定。现成方案可能能解决一部分问题,但不一定适合我们的打开流程、缓存策略、资源释放、热更规则、埋点日志、异常兜底。所以自己写的目的不是炫技,而是让模块更贴合项目。

可以这样回答面试官

NOTE

我当时选择自己写这个模块,主要是三个原因。

第一,项目需求比较定制化。比如我们需要统一入口、统一生命周期、统一缓存和释放规则,直接用通用插件很难完全贴合。

第二,可控性更强。出了问题我能从入口到执行流程完整追踪,线上日志、异常兜底、性能优化都能自己掌控。

第三,后期维护更方便。团队成员能看懂代码,也能按项目业务继续扩展,不会被黑盒插件限制。

当然我也会控制自研边界。像寻路、物理、渲染、Addressables 这类成熟底层能力,我会优先用引擎或成熟方案;自己写的更多是业务调度层和项目规则层。

一句话总结

TIP

自研模块的价值不是“自己造一套”,而是当项目有明确业务规则、性能要求和维护要求时,用更可控、更贴合项目的方式把流程统一起来。

渲染流程怎么走?

rendering-flow-implementation

面试高分回答

渲染流程可以分成两大段:CPU 准备阶段GPU 绘制阶段

CPU 这边,Unity 会先根据相机找到可能被看到的物体,然后做剔除,比如视锥剔除、遮挡剔除。看不到的物体尽量不提交。接着根据材质、Shader、Render Queue、透明不透明等规则排序,再做 Batching、Instancing、SRP Batcher 这类优化,最后把 DrawCall 提交给 GPU。

GPU 这边,先跑顶点着色器,把模型顶点从模型空间变到裁剪空间,也就是常说的 MVP 变换。然后进行裁剪和光栅化,把三角形变成一个个片元。接着跑片元着色器,计算每个片元的颜色,比如贴图采样、光照、法线、PBR。最后经过深度测试、模板测试、透明混合,把结果写进颜色缓冲,最终显示到屏幕上。

完整流程可以这样背

场景对象和材质准备好以后,相机开始渲染。Unity 先做剔除,决定哪些 Renderer 需要画;再按渲染队列和材质排序,尽量减少状态切换;然后 CPU 提交 DrawCall 给 GPU。GPU 接到命令后,经过顶点着色器、裁剪、光栅化、片元着色器、深度测试、模板测试、混合,最后写入 Frame Buffer。如果项目开启了后处理,还会对整张画面再做 Bloom、Color Grading、抗锯齿等效果。

重点细节

TIP

不透明物体一般可以从近到远画,这样前面的物体先写入深度,后面被挡住的片元可以提前丢弃,减少计算。

透明物体一般从远到近画,因为透明混合依赖后面的颜色。如果顺序错了,透明物体就可能出现穿插、排序错误、显示不对。

DrawCall 太多通常偏 CPU 压力,因为 CPU 要频繁切材质、设状态、提交命令。片元太多、后处理太重、Overdraw 太高,通常偏 GPU 压力。

一句话总结

WARNING

渲染就是:CPU 负责决定“画什么、怎么提交”,GPU 负责把“顶点和三角形”一步步变成屏幕上的像素。

内存怎么管理?

memory-management-implementation-csharp

面试高分回答

Unity 内存要分两类看:一类是 C# 托管内存,比如 new 出来的对象、字符串、数组、List、闭包,这部分由 GC 回收;另一类是 Unity 引擎资源内存,比如贴图、Mesh、AudioClip、AnimationClip、Prefab、Material,这部分不能只指望 C# GC,需要 DestroyAddressables.ReleaseAssetBundle.UnloadResources.UnloadUnusedAssets 等方式配合管理。

项目里我一般从几个方向控制内存:第一,减少运行时频繁分配,比如 Update 里不随便 new、不频繁字符串拼接、少用 LINQ 和闭包;第二,频繁创建销毁的对象用对象池,比如子弹、特效、飘字、怪物;第三,资源加载用统一管理器做缓存和引用计数,谁使用谁加引用,不用了释放引用;第四,切场景或战斗结束时集中清理临时对象、对象池和不用的资源;第五,用 Profiler 和 Memory Profiler 看 GC Alloc、托管堆、纹理、Mesh、音频是否异常增长。

C# 示例:战斗结束时清理临时对象和不用资源

c
using System.Collections; // 引入 IEnumerator,用来写协程
using System.Collections.Generic; // 引入 List,用来记录临时对象
using UnityEngine; // 引入 Unity 基础类型
public sealed class BattleMemoryCleaner : MonoBehaviour // 定义战斗内存清理器
{ // 类开始
    private readonly List<GameObject> temporaryObjects = new List<GameObject>(); // 保存战斗中创建的临时对象
    public void RegisterTemporaryObject(GameObject obj) // 注册需要在战斗结束后清理的对象
    { // RegisterTemporaryObject 开始
        if (obj == null) return; // 如果对象为空,就直接返回
        temporaryObjects.Add(obj); // 把临时对象加入列表
    } // RegisterTemporaryObject 结束
    public void ClearAfterBattle() // 战斗结束时调用清理流程
    { // ClearAfterBattle 开始
        StartCoroutine(ClearAfterBattleCoroutine()); // 启动协程,避免同一帧做太多清理造成卡顿
    } // ClearAfterBattle 结束
    private IEnumerator ClearAfterBattleCoroutine() // 分步骤清理战斗内存
    { // 协程开始
        for (int i = temporaryObjects.Count - 1; i >= 0; i--) // 从后往前遍历临时对象列表
        { // for 开始
            GameObject obj = temporaryObjects[i]; // 取出当前临时对象
            if (obj != null) Destroy(obj); // 销毁场景实例,注意 Destroy 通常在帧末真正生效
        } // for 结束
        temporaryObjects.Clear(); // 清空列表,断开 C# 对这些对象的引用
        yield return null; // 等一帧,让 Destroy 有机会完成
        yield return Resources.UnloadUnusedAssets(); // 卸载已经没有引用的 Unity 资源
        Debug.Log("战斗临时对象和未使用资源清理完成"); // 打印日志,方便确认清理流程执行过
    } // 协程结束
} // 类结束

面试官喜欢听的补充

NOTE

Destroy(gameObject) 只是销毁场景里的实例,不一定立刻释放贴图、材质、Mesh 这些资源内存。如果还有引用,比如静态变量、事件监听、缓存字典、对象池、Addressables handle 没释放,资源就不会真正下降。

Resources.UnloadUnusedAssets() 也不是随便每帧调用的,它比较重,通常放在切场景、退出副本、Loading 过渡这种玩家能接受卡顿的位置。

一句话总结

CAUTION

内存管理的核心是:托管对象少分配、频繁对象用池子、资源用引用计数、退出流程集中释放,最后用 Profiler 验证内存有没有真的降下来。

组件更新顺序怎么处理?

component-update-order-csharp

面试高分回答

Unity 能保证大生命周期顺序,比如 AwakeOnEnableStartUpdateLateUpdate,但同一个阶段里,不同脚本谁先 Update,不要随便依赖。如果两个组件有强依赖,比如输入必须先于角色移动,角色移动必须先于相机跟随,就要显式管理顺序。

简单项目可以用 Script Execution Order[DefaultExecutionOrder]。复杂项目我更推荐写一个统一的 GameLoopManager,把一帧拆成几个阶段:输入、逻辑、移动、动画、相机、UI。这样顺序稳定,出问题也好查。

C# 示例:统一 Tick 调度

c
using System.Collections.Generic; // 引入 List 集合
using UnityEngine; // 引入 Unity 基础类型
public interface IGameTick // 定义统一更新接口
{ // 接口开始
    int Order { get; } // 数字越小越早执行
    void Tick(float deltaTime); // 每帧由管理器调用的更新方法
} // 接口结束
public sealed class GameLoopManager : MonoBehaviour // 定义游戏循环管理器
{ // 类开始
    private readonly List<IGameTick> ticks = new List<IGameTick>(); // 保存所有需要按顺序更新的对象
    public void Register(IGameTick tick) // 注册一个更新对象
    { // Register 开始
        if (tick == null) return; // 如果传进来为空,就直接返回
        if (ticks.Contains(tick)) return; // 如果已经注册过,就避免重复注册
        ticks.Add(tick); // 加入更新列表
        ticks.Sort((a, b) => a.Order.CompareTo(b.Order)); // 按 Order 从小到大排序
    } // Register 结束
    public void Unregister(IGameTick tick) // 注销一个更新对象
    { // Unregister 开始
        if (tick == null) return; // 如果传进来为空,就直接返回
        ticks.Remove(tick); // 从更新列表移除
    } // Unregister 结束
    private void Update() // Unity 每帧调用
    { // Update 开始
        float deltaTime = Time.deltaTime; // 记录这一帧的时间间隔
        for (int i = 0; i < ticks.Count; i++) // 按顺序遍历所有更新对象
        { // for 开始
            ticks[i].Tick(deltaTime); // 调用当前对象的 Tick 方法
        } // for 结束
    } // Update 结束
} // 类结束
public sealed class PlayerMoveSystem : MonoBehaviour, IGameTick // 玩家移动系统接入统一 Tick
{ // 类开始
    public int Order => 200; // 移动系统在输入系统之后执行
    public void Tick(float deltaTime) // 每帧移动逻辑
    { // Tick 开始
        Debug.Log("根据已经缓存好的输入计算角色移动"); // 这里处理角色移动
    } // Tick 结束
} // 类结束

项目里怎么说

NOTE

初始化时,我一般让 Awake 只做本对象组件缓存,OnEnable 注册事件,Start 做依赖其他对象的初始化。每帧更新时,输入先读,逻辑状态再判断,移动根据逻辑结果执行,动画同步参数,相机放到 LateUpdate 或统一的 Late 阶段,UI 最后刷新显示。

一句话总结

CAUTION

不要把组件更新顺序交给“运气”。有强依赖就显式排序:少量核心脚本用执行顺序设置,系统多的时候用统一 Tick 管理器。

资源生命周期怎么处理?

resource-lifecycle-csharp

面试高分回答

资源生命周期我一般按一条闭环处理:加载、持有、使用、释放引用、真正卸载、验证内存

项目里不会让业务到处直接 Resources.LoadAddressables.LoadAssetAsync,而是统一走 ResourceManager。加载资源时记录 key、资源对象、加载句柄、引用计数。业务使用资源时引用计数加一,不用了引用计数减一。只有引用计数归零,才真正释放资源句柄。

要注意区分两个东西:实例生命周期资源生命周期。比如 Instantiate 出来的怪物、特效、UI,是场景实例;它不用了可以 Destroy 或回对象池。但 Prefab、贴图、材质、音频这些资源本体,还需要 Addressables.Release 或 AssetBundle 卸载策略来释放。

C# 示例:引用计数资源生命周期

c
using System.Collections.Generic; // 引入 Dictionary,用来缓存资源记录
using System.Threading.Tasks; // 引入 Task,用来支持异步加载
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.AddressableAssets; // 引入 Addressables 加载接口
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 异步句柄类型
public sealed class ResourceLifetimeManager : MonoBehaviour // 定义资源生命周期管理器
{ // 类开始
    private sealed class ResourceRecord // 定义单个资源的生命周期记录
    { // ResourceRecord 类开始
        public GameObject Asset; // 保存加载出来的资源本体
        public AsyncOperationHandle<GameObject> Handle; // 保存 Addressables 句柄,释放时必须用它
        public int RefCount; // 保存当前资源被多少业务持有
    } // ResourceRecord 类结束
    private readonly Dictionary<string, ResourceRecord> records = new Dictionary<string, ResourceRecord>(); // 用 key 管理所有已加载资源
    public async Task<GameObject> LoadAsync(string key) // 异步加载资源并增加引用
    { // LoadAsync 开始
        if (records.TryGetValue(key, out ResourceRecord record)) // 如果资源已经加载过
        { // 缓存命中开始
            record.RefCount++; // 引用计数加一
            return record.Asset; // 返回已经缓存的资源
        } // 缓存命中结束
        AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key); // 通过 Addressables 异步加载资源
        await handle.Task; // 等待异步加载完成
        if (handle.Status != AsyncOperationStatus.Succeeded) // 判断加载是否失败
        { // 加载失败开始
            Addressables.Release(handle); // 释放失败句柄,避免句柄泄漏
            Debug.LogError("资源加载失败: " + key); // 打印错误日志
            return null; // 返回空表示加载失败
        } // 加载失败结束
        ResourceRecord newRecord = new ResourceRecord(); // 创建新的资源记录
        newRecord.Asset = handle.Result; // 保存加载出来的资源
        newRecord.Handle = handle; // 保存加载句柄
        newRecord.RefCount = 1; // 第一次加载时引用计数为一
        records.Add(key, newRecord); // 把资源记录加入缓存表
        return newRecord.Asset; // 返回资源本体
    } // LoadAsync 结束
    public GameObject InstantiateAsset(string key, Transform parent) // 根据已加载资源创建实例
    { // InstantiateAsset 开始
        if (!records.TryGetValue(key, out ResourceRecord record)) return null; // 如果资源没加载,就无法实例化
        GameObject instance = Instantiate(record.Asset, parent); // 创建场景实例
        return instance; // 返回实例对象
    } // InstantiateAsset 结束
    public void DestroyInstance(GameObject instance) // 销毁资源实例
    { // DestroyInstance 开始
        if (instance == null) return; // 如果实例为空,就直接返回
        Destroy(instance); // 销毁场景里的实例对象
    } // DestroyInstance 结束
    public void Release(string key) // 释放一次资源引用
    { // Release 开始
        if (!records.TryGetValue(key, out ResourceRecord record)) return; // 如果没有这条资源记录,就直接返回
        record.RefCount--; // 引用计数减一
        if (record.RefCount > 0) return; // 如果还有业务在用,就不能真正卸载
        Addressables.Release(record.Handle); // 引用归零后释放 Addressables 句柄
        records.Remove(key); // 从缓存表移除资源记录
    } // Release 结束
} // 类结束

面试官喜欢听的补充

CAUTION

我会把资源生命周期和业务生命周期绑定。比如打开背包界面时加载背包 UI 和图标资源,关闭背包时释放对应引用;进入战斗时加载怪物、技能特效、音效,战斗结束后回收对象池并释放临时资源。

最容易出问题的是:场景实例销毁了,但资源引用还在;事件没注销,静态变量还引用着对象;缓存字典只加不删;对象池无限增长。这些都会导致资源无法真正卸载。

一句话总结

IMPORTANT

资源生命周期的关键不是“能加载出来”,而是要让每个资源都有明确的持有者、释放时机和验证手段。加载走统一入口,使用加引用,不用减引用,归零才释放,最后用 Profiler 确认内存真的下降。

编辑器和运行时如何分离?

editor-runtime-separation-csharp

面试高分回答

Unity 项目里要把 Runtime 运行时代码Editor 编辑器代码 分开。运行时代码会进入最终包体,玩家设备上会执行;编辑器代码只在 Unity Editor 里执行,比如导表工具、自定义 Inspector、批量资源处理、打包工具。

最基本的做法是:游戏逻辑放到 Assets/Scripts/Runtime,编辑器工具放到 Assets/Scripts/Editor。只要脚本在 Editor 文件夹下,Unity 打包时就不会把它编进玩家包。

更规范的项目会用 asmdef 拆程序集,比如 Game.Runtime.asmdefGame.Editor.asmdefEditor 程序集可以引用 Runtime 程序集,因为编辑器工具经常要读运行时数据结构;但 Runtime 绝对不能引用 Editor,也不能在运行时代码里 using UnityEditor,否则打包会失败。

C# 示例:Runtime 配置类

c
using UnityEngine; // 运行时代码只能引用 UnityEngine,不引用 UnityEditor
[CreateAssetMenu(menuName = "Config/Skill Config")] // 允许在 Project 窗口创建技能配置资源
public sealed class SkillConfig : ScriptableObject // 定义运行时也能使用的技能配置
{ // SkillConfig 类开始
    public int id; // 技能 id,用于配置表或技能系统查询
    public string skillName; // 技能名称,用于 UI 显示
    public int damage; // 技能伤害数值,用于战斗计算
    public float cooldown; // 技能冷却时间,用于技能 CD 管理
} // SkillConfig 类结束

C# 示例:Editor 自定义检查器

c
#if UNITY_EDITOR // 只有在 Unity 编辑器环境下才编译下面的代码
using UnityEditor; // 引入 UnityEditor,只能用于编辑器代码
using UnityEngine; // 引入 UnityEngine,用来访问目标对象和基础类型
[CustomEditor(typeof(SkillConfig))] // 指定这个 Inspector 用来编辑 SkillConfig
public sealed class SkillConfigEditor : Editor // 定义 SkillConfig 的自定义编辑器
{ // SkillConfigEditor 类开始
    public override void OnInspectorGUI() // 重写 Inspector 绘制方法
    { // OnInspectorGUI 开始
        DrawDefaultInspector(); // 先绘制 Unity 默认 Inspector 内容
        SkillConfig config = (SkillConfig)target; // 把当前编辑对象转换成 SkillConfig
        if (GUILayout.Button("打印技能信息")) // 绘制一个编辑器按钮
        { // 按钮点击分支开始
            Debug.Log("技能: " + config.skillName + " 伤害: " + config.damage); // 点击按钮时打印配置内容
        } // 按钮点击分支结束
    } // OnInspectorGUI 结束
} // SkillConfigEditor 类结束
#endif // 结束编辑器条件编译

项目里怎么说

我一般会把运行时模块做成纯逻辑、纯数据、可打包的代码;编辑器模块只负责提高开发效率,比如导表、资源检查、批量命名、自动生成代码、自定义 Inspector。

如果只是少量调试代码,可以用 #if UNITY_EDITOR 包起来;如果是完整工具脚本,更推荐放进 Editor 文件夹或单独的 Editor asmdef。这样依赖关系更清楚,也能避免打包时把编辑器 API 带进运行时。

一句话总结

NOTE

Runtime 是玩家真正运行的游戏逻辑,Editor 是开发阶段的工具。Editor 可以依赖 Runtime,但 Runtime 不能依赖 Editor。分离好以后,打包更安全,编译更清晰,工具和业务也更容易维护。

如何调试引擎 bug?

engine-bug-debugging-csharp

面试高分回答

我不会一开始就说“这是 Unity 的 bug”。我会先稳定复现,然后缩小范围,排除业务代码、资源配置、API 使用错误,最后用最小复现工程证明问题确实和引擎版本、平台或渲染管线有关。

一般流程是:先记录 Unity 版本、平台、机型、渲染管线、复现步骤;然后看 Console、Player.log、真机日志;接着关掉无关系统,删资源、换材质、换场景,把问题压缩到最小工程。如果最小工程还能稳定复现,再换 Unity 版本、换设备、换平台做交叉验证。

如果是渲染问题,我会用 Frame Debugger、RenderDoc、替换材质、关闭后处理来定位。 如果是性能问题,我会用 Profiler、Timeline、Memory Profiler 看 CPU、GPU、GC、资源内存。 如果是崩溃问题,我会看 Player.log、设备日志、崩溃堆栈,有条件就做符号化。

C# 示例:记录复现环境信息

c
using UnityEngine; // 引入 Unity 基础类型
public sealed class EngineBugReproLogger : MonoBehaviour // 定义引擎问题复现日志脚本
{ // 类开始
    [SerializeField] private string bugName = "Unknown Bug"; // 记录当前要复现的问题名称
    private void Start() // 场景开始时记录环境信息
    { // Start 开始
        LogEnvironment(); // 打印当前运行环境
    } // Start 结束
    private void LogEnvironment() // 记录复现环境信息
    { // LogEnvironment 开始
        Debug.Log("Bug 名称: " + bugName); // 打印问题名称
        Debug.Log("Unity 版本: " + Application.unityVersion); // 打印 Unity 版本
        Debug.Log("平台: " + Application.platform); // 打印当前运行平台
        Debug.Log("设备型号: " + SystemInfo.deviceModel); // 打印设备型号
        Debug.Log("显卡: " + SystemInfo.graphicsDeviceName); // 打印显卡名称
        Debug.Log("显存: " + SystemInfo.graphicsMemorySize + " MB"); // 打印显存大小
        Debug.Log("系统内存: " + SystemInfo.systemMemorySize + " MB"); // 打印系统内存大小
        Debug.Log("屏幕分辨率: " + Screen.width + "x" + Screen.height); // 打印屏幕分辨率
        Debug.Log("当前场景: " + UnityEngine.SceneManagement.SceneManager.GetActiveScene().name); // 打印当前场景名
    } // LogEnvironment 结束
    public void MarkStep(string stepName) // 手动记录复现步骤
    { // MarkStep 开始
        Debug.Log("复现步骤: " + stepName); // 打印当前执行到哪一步
    } // MarkStep 结束
} // 类结束

面试官喜欢听的补充

TIP

如果最后确认是引擎 bug,我会先给项目做规避方案,比如换 API、换资源格式、关闭某个渲染特性、锁定稳定 Unity 版本,保证项目先能上线。然后再整理最小复现工程、日志、版本信息、平台信息,提交给官方或记录到团队问题库。

一句话总结

调试引擎 bug 的关键不是喊“引擎有问题”,而是拿出证据:稳定复现、最小工程、版本对比、日志堆栈、可行规避。

如何做性能分析?

performance-analysis-unity-csharp

面试高分回答

我做性能分析不会先凭感觉改代码,而是先复现问题、建立基线、采样定位,再针对瓶颈优化,最后用数据验证。

第一步是稳定复现,比如固定机型、场景、操作步骤,确认是战斗卡、UI 卡、加载卡,还是长时间运行后越来越卡。第二步记录基线,比如 FPS、单帧耗时、CPU 占用、GPU 占用、GC Alloc、内存峰值。第三步用 Unity Profiler 真机采样,先判断瓶颈类型:CPU、GPU、GC、内存还是加载。第四步下钻到具体模块,比如脚本、物理、动画、UI Rebuild、粒子、DrawCall、Shader、资源加载。最后优化完再录一次 Profiler,对比优化前后的数据。

常见判断方式

CPU 瓶颈:Profiler 里主线程耗时高,比如 ScriptUpdatePhysicsAnimatorCanvas.BuildBatch 很高。

GPU 瓶颈:CPU 看起来不忙,但画面仍掉帧,可能是 Overdraw、后处理、阴影、透明物体、粒子太重。

GC 问题:Profiler 里每帧有 GC Alloc,一段时间后出现 GC.Collect 尖峰,表现为周期性卡顿。

内存问题:Memory Profiler 里纹理、Mesh、Audio、托管对象持续增长,切场景后没有下降。

加载问题:Loading 或切场景时卡顿,可能是同步加载、AssetBundle 解压、Shader 变体预热、资源依赖过多。

C# 示例:给可疑模块加 ProfilerMarker

c
using UnityEngine; // 引入 Unity 基础类型
using UnityEngine.Profiling; // 引入 Unity ProfilerMarker 性能标记工具
public sealed class PerformanceMarkerExample : MonoBehaviour // 定义一个性能标记示例脚本
{ // 类开始
    private static readonly ProfilerMarker EnemyTickMarker = new ProfilerMarker("Game.EnemyAI.TickAll"); // 创建一个自定义 Profiler 标记
    [SerializeField] private int enemyCount = 100; // 模拟当前需要更新的怪物数量
    private void Update() // Unity 每帧调用
    { // Update 开始
        using (EnemyTickMarker.Auto()) // 在 Profiler 里统计这个代码块的耗时
        { // 标记范围开始
            for (int i = 0; i < enemyCount; i++) // 模拟遍历所有怪物
            { // for 开始
                SimulateEnemyTick(i); // 执行单个怪物的 AI 更新
            } // for 结束
        } // 标记范围结束
    } // Update 结束
    private void SimulateEnemyTick(int index) // 模拟单个怪物逻辑
    { // SimulateEnemyTick 开始
        float value = Mathf.Sin(Time.time + index); // 模拟一点计算逻辑
        if (value > 2f) Debug.Log(value); // 这个条件一般不会成立,只是防止变量完全无用
    } // SimulateEnemyTick 结束
} // 类结束

面试官喜欢听的补充

我会优先看真机 Profiler,因为 Editor 里有编辑器开销,数据只能参考。优化前后要保留证据,比如同一场景同一操作下,帧耗时从多少 ms 降到多少 ms,GC Alloc 从多少降到多少,DrawCall 或内存峰值降了多少。

一句话总结

性能分析的核心不是“会用 Profiler”,而是能按流程把问题从现象定位到具体模块,再用数据证明优化有效。

如何保证模块可扩展?

如何保证模块可扩展?

module-extensibility-csharp

面试高分回答

我会先找模块里最可能变化的地方,把变化点抽出来。比如技能系统里,技能效果会变;资源系统里,加载方式会变;UI 系统里,面板类型会变。稳定的流程保留在主模块里,变化的部分用接口、配置、策略类或工厂来扩展。

核心原则是:对外接口稳定,对内实现可替换。外部系统只依赖 IModuleIService 这种抽象,不直接依赖具体类。新增功能时尽量新增类和配置,而不是到处改老代码。

同时模块之间要减少强耦合。比如背包变化不要直接调用 UI、任务、红点、音效一堆系统,而是发一个“背包变化事件”,谁关心谁监听。这样以后加新功能,不需要改背包核心逻辑。

C# 示例:统一模块接口 + 管理器

c
using System.Collections.Generic; // 引入 List 集合
using UnityEngine; // 引入 Unity 基础类型
public interface IGameModule // 定义游戏模块统一接口
{ // 接口开始
    int Order { get; } // 模块初始化顺序,数字越小越早初始化
    void Init(); // 模块初始化方法
    void Tick(float deltaTime); // 模块每帧更新方法
    void Shutdown(); // 模块关闭和释放方法
} // 接口结束
public sealed class ModuleManager : MonoBehaviour // 定义模块管理器
{ // 类开始
    private readonly List<IGameModule> modules = new List<IGameModule>(); // 保存所有游戏模块
    public void Register(IGameModule module) // 注册模块
    { // Register 开始
        if (module == null) return; // 防止传入空模块
        if (modules.Contains(module)) return; // 防止重复注册同一个模块
        modules.Add(module); // 把模块加入列表
        modules.Sort((a, b) => a.Order.CompareTo(b.Order)); // 按初始化顺序排序
    } // Register 结束
    public void InitAll() // 初始化所有模块
    { // InitAll 开始
        for (int i = 0; i < modules.Count; i++) // 按顺序遍历模块
        { // for 开始
            modules[i].Init(); // 调用模块初始化
        } // for 结束
    } // InitAll 结束
    private void Update() // Unity 每帧调用
    { // Update 开始
        float deltaTime = Time.deltaTime; // 获取当前帧时间间隔
        for (int i = 0; i < modules.Count; i++) // 遍历所有模块
        { // for 开始
            modules[i].Tick(deltaTime); // 调用模块更新
        } // for 结束
    } // Update 结束
    public void ShutdownAll() // 关闭所有模块
    { // ShutdownAll 开始
        for (int i = modules.Count - 1; i >= 0; i--) // 按反向顺序关闭模块
        { // for 开始
            modules[i].Shutdown(); // 调用模块释放逻辑
        } // for 结束
    } // ShutdownAll 结束
} // 类结束
public sealed class InventoryModule : IGameModule // 定义背包模块实现
{ // InventoryModule 类开始
    public int Order => 100; // 背包模块初始化顺序
    public void Init() // 初始化背包模块
    { // Init 开始
        Debug.Log("加载背包数据并注册背包事件"); // 初始化时加载数据和注册事件
    } // Init 结束
    public void Tick(float deltaTime) // 背包模块每帧更新
    { // Tick 开始
    } // Tick 结束
    public void Shutdown() // 关闭背包模块
    { // Shutdown 开始
        Debug.Log("保存背包数据并注销背包事件"); // 关闭时保存数据和注销事件
    } // Shutdown 结束
} // InventoryModule 类结束

面试官喜欢听的补充

可扩展不是把代码写得很复杂,而是把变化点控制住。确定会变化的地方才抽象,比如不同技能效果、不同奖励类型、不同 UI 面板、不同资源加载方式。暂时不会变化的地方不要过度设计,否则会增加理解成本。

一句话总结

保证模块可扩展,就是让核心流程稳定,让变化点通过接口、配置、事件和策略扩展。新增需求时尽量“加代码”,少“改旧代码”,这样回归风险小,团队维护也更舒服。

目前项目最大的短板是什么?

project-biggest-weakness-interview

面试高分回答

NOTE

我觉得目前项目最大的短板是:工程化和可观测性还不够完善

项目前期主要目标是快速验证玩法,所以很多系统优先保证能跑通,比如战斗、UI、资源、配置、关卡流程。随着系统越来越多,问题就开始变成:出 bug 能不能快速定位,资源有没有泄漏,性能问题能不能提前发现,配置错误能不能在打包前拦住。

这个短板的影响是,某些问题发现得比较晚,定位依赖人工经验。比如资源引用丢失、配置表字段错误、某些界面反复打开导致 GC 增长,如果没有工具和日志,就只能靠人手动复现和查 Profiler,效率比较低。

后来我会往几个方向改:加资源检查工具、配置表导出校验、关键模块日志、ProfilerMarker、打包前检查、内存泄漏排查流程。这样可以把很多问题提前暴露,而不是等测试或者线上才发现。

可以直接这样说

目前项目最大的短板不是某个功能做不出来,而是工程化支撑还可以继续加强。前期为了快速验证玩法,很多流程偏人工,比如资源检查、配置校验、性能采样、异常定位。后期系统复杂以后,这些人工流程会影响定位效率和稳定性。

我参与的改进主要是把一些经验沉淀成工具和流程,比如配置表导出时检查重复 id 和非法字段,资源检查时发现超大贴图和丢失引用,关键系统加日志和性能标记,方便 Profiler 定位。这样后续遇到问题时,不是完全靠猜,而是能更快定位到模块和数据。

不要这样答

不要说“项目没什么短板”,这会显得你没思考。 也不要说“领导不懂、策划乱改、同事代码差”,这像甩锅。 更不要说“架构很乱、核心系统不稳定、代码没人维护”,这会把自己也拖下去。

一句话总结

这个问题要体现的是:你能看到项目问题,但不是抱怨;你能分析原因,也能推动改进。最稳的回答是选“工程化、工具链、可观测性、自动化验证”这类真实但可改进的短板。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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