Skip to content

引擎向 Demo 最低配置

窗口和输入

engine-window-input-system

窗口和输入

窗口和输入可以理解成游戏客户端最底层的交互入口:窗口负责和操作系统打交道,输入负责把键盘、鼠标、手柄、触摸转换成游戏里的动作。

标准回答

窗口层主要负责创建窗口、处理关闭、全屏切换、尺寸变化、DPI、焦点变化等。比如窗口失焦时,游戏可能要暂停输入;窗口 Resize 时,渲染分辨率和 UI 适配也要更新。

输入层负责采集键盘、鼠标、手柄、触摸等设备状态。底层通常会维护当前帧和上一帧输入快照,这样才能判断 PressedHeldReleased。再往上会有一层 Action 映射,把物理按键映射成游戏动作,比如 WASD -> Move鼠标左键 -> AttackSpace -> Jump

面试里可以这样说

如果是自研引擎,我会用 Win32、SDL 或 GLFW 接收操作系统事件,把事件放入输入队列,然后每帧开始时更新输入快照。业务层不直接关心具体按键,而是查询动作状态,比如 IsActionPressed("Attack")。这样以后换键位、支持手柄、支持触摸时,不需要改玩法逻辑。

如果是 Unity 项目,我会说 Unity 已经封装了窗口和底层输入,业务侧常用 Input System、EventSystem 和 UI Raycast。要注意 UI 优先级,比如点击按钮时 UI 消费输入,不能同时让角色攻击或移动。

常见坑

NOTE

窗口失焦后输入没清空,会导致回来后角色继续移动。 UI 没有消费输入,会出现点按钮时角色也攻击。 只读当前帧状态,不记录上一帧,就不好判断按下和抬起。 键位写死在逻辑里,后面做改键、手柄、移动端会很痛苦。

渲染三角形或简单模型

engine-render-triangle-simple-model

渲染三角形或简单模型

渲染三角形是图形程序的最小闭环:准备顶点数据,上传到 GPU,绑定 Shader 和矩阵,提交 DrawCall,最后光栅化成屏幕上的像素。

标准回答

如果我要渲染一个三角形,第一步是准备 3 个顶点,每个顶点至少有位置,也可以带颜色、UV、法线。然后把这些顶点上传到 GPU 的顶点缓冲区。接着绑定 Shader,顶点着色器把模型空间坐标通过 MVP 矩阵变换到裁剪空间,片元着色器决定最终颜色。最后提交 DrawCall,GPU 会把三角形光栅化成片元,再经过深度测试、混合等阶段写入帧缓冲。

渲染简单模型只是三角形的扩展:模型由很多三角形组成,通常会有顶点缓冲、索引缓冲、法线、UV、材质和贴图。CPU 负责组织 Mesh、材质和渲染状态,GPU 负责并行处理顶点和像素。

Unity 里可以这样说

Unity 里可以通过 Mesh 创建三角形,设置 verticestriangles,再配合 MeshFilterMeshRendererMaterial 显示出来。底层 Unity 仍然会把 Mesh 数据上传到 GPU,并在渲染阶段提交 DrawCall。

每行注释代码

c
using UnityEngine; // 引入 UnityEngine,使用 Mesh、Vector3、Material 等 Unity 类型

public class TriangleMeshDemo : MonoBehaviour // 定义一个挂在 GameObject 上的三角形演示脚本
{ // 类开始
    public Material material; // 暴露材质字段,用来给三角形指定显示用的材质

    private void Start() // Start 会在脚本启用后的第一帧调用
    { // Start 方法开始
        Mesh mesh = new Mesh(); // 创建一个新的 Mesh 对象,用来保存三角形数据
        mesh.vertices = new Vector3[] // 设置 Mesh 的顶点数组
        { // 顶点数组开始
            new Vector3(-1f, -1f, 0f), // 第 0 个顶点,位于左下方
            new Vector3(0f, 1f, 0f), // 第 1 个顶点,位于上方
            new Vector3(1f, -1f, 0f) // 第 2 个顶点,位于右下方
        }; // 顶点数组结束
        mesh.triangles = new int[] { 0, 1, 2 }; // 设置三角形索引,表示用 0、1、2 三个顶点组成一个面
        mesh.RecalculateNormals(); // 重新计算法线,方便光照 Shader 正常显示
        MeshFilter filter = gameObject.AddComponent<MeshFilter>(); // 给当前对象添加 MeshFilter,用来保存 Mesh
        MeshRenderer renderer = gameObject.AddComponent<MeshRenderer>(); // 添加 MeshRenderer,用来把 Mesh 渲染出来
        filter.mesh = mesh; // 把刚创建的三角形 Mesh 赋给 MeshFilter
        renderer.material = material; // 把外部指定的材质赋给 MeshRenderer
    } // Start 方法结束
} // 类结束

常见追问

IMPORTANT

看不到三角形,常见原因是相机没对准、模型在裁剪范围外、顶点顺序被背面剔除、材质 Shader 有问题、深度测试被挡住。面试里能把这些排查点说出来,会比只说“创建 Mesh”更像真的调过渲染问题。

相机和 Transform

unity-camera-transform

相机和 Transform

Transform 决定物体在世界里的位置、旋转、缩放;Camera 也是挂在 GameObject 上的组件,所以相机的位置和朝向同样由 Transform 决定。

标准回答

在 Unity 里,每个 GameObject 都有 Transform。普通角色的 Transform 决定角色在哪里、朝哪里、缩放多少;相机的 Transform 决定“从哪里看”和“朝哪里看”。Camera 组件本身决定 FOV、near/far 裁剪面、正交或透视、Culling Mask 等渲染参数。

底层可以理解成:相机的 Transform 会生成 View 矩阵,把世界空间转换到相机空间;Camera 的投影参数生成 Projection 矩阵,把相机空间投到裁剪空间。最终渲染管线把 3D 世界变成屏幕上的 2D 像素。

Unity 工程里怎么用

第三人称相机通常会拿角色 Transform 当目标,根据目标位置加一个后上方 offset,然后让相机平滑移动过去,并 LookAt 角色。相机跟随常放在 LateUpdate,因为角色通常在 Update 里移动,LateUpdate 能拿到角色这一帧更新后的最终位置,减少抖动。

每行注释代码

c
using UnityEngine; // 引入 UnityEngine,使用 Transform、Vector3、Quaternion 等 Unity 类型

public class SmoothFollowCamera : MonoBehaviour // 定义一个相机平滑跟随脚本
{ // 类开始
    public Transform target; // 要跟随的目标,一般是玩家角色的 Transform
    public Vector3 offset = new Vector3(0f, 4f, -6f); // 相机相对目标的偏移,表示在目标后上方
    public float followSpeed = 8f; // 相机位置跟随速度,数值越大越快
    public float rotateSpeed = 10f; // 相机旋转跟随速度,数值越大越快

    private void LateUpdate() // LateUpdate 在 Update 之后执行,适合做相机跟随
    { // LateUpdate 方法开始
        if (target == null) // 如果目标不存在
        { // if 语句开始
            return; // 直接返回,避免空引用报错
        } // if 语句结束

        Vector3 targetPosition = target.position + offset; // 计算相机希望到达的位置
        transform.position = Vector3.Lerp(transform.position, targetPosition, followSpeed * Time.deltaTime); // 平滑移动相机到目标位置
        Vector3 lookDirection = target.position - transform.position; // 计算相机指向目标的方向
        Quaternion targetRotation = Quaternion.LookRotation(lookDirection); // 根据方向计算相机应该朝向的旋转
        transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotateSpeed * Time.deltaTime); // 平滑旋转相机朝向目标
    } // LateUpdate 方法结束
} // 类结束

常见坑

TIP

相机抖动:角色在 Update 移动,相机也在 Update 跟随,顺序不稳定时容易抖,通常放 LateUpdate。 坐标混用:position 是世界坐标,localPosition 是相对父物体坐标。 FOV 和距离混淆:FOV 改的是透视感,距离改的是相机和目标的空间关系。 复杂相机:项目复杂时通常用 Cinemachine 处理跟随、碰撞、震动和过渡。

场景对象管理

engine-scene-object-management

场景对象管理

场景对象管理就是统一管理场景里对象的生命周期:创建、注册、更新、查询、可见性判断、销毁和资源回收。它解决的是“对象不能散落在场景里没人管”的问题。

标准回答

我理解场景对象管理的核心是生命周期可控。一个对象进入场景时,应该经过资源加载、实例化、注册索引;运行过程中可以按 ID、类型、阵营、区域快速查找;对象离开场景时要注销索引、取消事件、停止计时器、归还对象池,最后释放相关资源。

在 Unity 里,场景对象通常是 GameObject + Component + Transform。如果项目小,可以直接通过引用管理;如果对象很多,比如怪物、子弹、掉落物、特效,就需要 SceneObjectManager 或 EntityManager 统一管理,避免到处 FindObjectOfType 或全场遍历。

面试里可以这样说

我会给每个重要对象分配唯一 ID,并注册到管理器里。管理器内部维护 Dictionary<int, SceneObject> 做 ID 查询,也可以按类型、阵营、区域建立额外索引。对于大量对象,我不会让每个对象都自己 Update,而是接入 UpdateManager 分帧更新;对于频繁创建销毁的对象,比如子弹和特效,会接对象池;对于远处不可见对象,会做隐藏、降频或 LOD。

每行注释代码

c
using System.Collections.Generic; // 引入 Dictionary 和 List 所在命名空间
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 GameObject

public class SceneObjectManager : MonoBehaviour // 定义场景对象管理器
{ // 类开始
    private readonly Dictionary<int, GameObject> objects = new Dictionary<int, GameObject>(); // 用字典保存对象 ID 到 GameObject 的映射

    public void Register(int id, GameObject obj) // 注册一个场景对象
    { // Register 方法开始
        if (obj == null) // 如果传入对象为空
        { // if 开始
            return; // 直接返回,避免空引用
        } // if 结束

        objects[id] = obj; // 把对象记录到字典中,如果 ID 已存在则覆盖
    } // Register 方法结束

    public GameObject Get(int id) // 根据 ID 查询对象
    { // Get 方法开始
        objects.TryGetValue(id, out GameObject obj); // 尝试从字典中取出对象
        return obj; // 返回找到的对象,找不到则返回 null
    } // Get 方法结束

    public void Unregister(int id) // 注销一个场景对象
    { // Unregister 方法开始
        objects.Remove(id); // 从字典中移除对象引用,避免销毁后仍被持有
    } // Unregister 方法结束

    public void ClearAll() // 清理所有场景对象引用
    { // ClearAll 方法开始
        objects.Clear(); // 清空字典,通常在切场景或重开时调用
    } // ClearAll 方法结束
} // 类结束

常见坑

NOTE

对象 Destroy 了,但还在字典里被引用,导致 Missing Reference 或资源无法释放。 切场景时事件没有取消订阅,旧对象还收到回调。 对象池归还时没有重置状态,下一次取出来带着上一局数据。 大量对象都写 Update,导致 BehaviourUpdate 很高,应该集中管理或分帧。

资源加载

unity-resource-loading

资源加载

资源加载就是把磁盘、包体或远端资源变成运行时可用对象,同时管理依赖、缓存、实例化和释放。面试里重点不是“能加载出来”,而是能不能避免卡顿、重复加载和资源泄漏。

标准回答

Unity 里常见资源加载方式有 ResourcesAssetBundleAddressables,项目里通常会再封装一层资源管理器。业务模块不直接到处调用加载 API,而是统一通过 AssetManager.LoadAsync(key) 之类的入口请求资源。

完整流程一般是:业务发起加载请求,资源管理器先查缓存;如果缓存命中,就直接返回并增加引用计数;如果缓存未命中,就先加载依赖,再异步加载主资源;加载完成后返回资源句柄或实例对象。使用结束后调用释放接口,引用计数归零时再释放资源。

面试里可以这样说

我会把资源加载分成加载、使用、释放三个阶段。加载阶段要处理依赖和异步,避免同步加载卡主线程;使用阶段要做缓存和引用计数,避免同一个资源重复加载;释放阶段要区分实例对象和资源本体,Destroy 实例不等于资源已经释放,Addressables 还需要释放 handle。

每行注释代码

c
using System.Collections.Generic; // 引入 Dictionary,用来保存资源缓存
using System.Threading.Tasks; // 引入 Task,用来演示异步加载接口
using UnityEngine; // 引入 UnityEngine,使用 Object 和 Resources

public class SimpleAssetManager // 定义一个简单资源管理器
{ // 类开始
    private readonly Dictionary<string, Object> cache = new Dictionary<string, Object>(); // 用字典缓存已经加载过的资源
    private readonly Dictionary<string, int> refCount = new Dictionary<string, int>(); // 用字典记录每个资源的引用计数

    public async Task<T> LoadAsync<T>(string path) where T : Object // 定义异步加载方法,T 必须是 UnityEngine.Object
    { // LoadAsync 方法开始
        if (cache.TryGetValue(path, out Object cached)) // 如果缓存里已经有这个资源
        { // if 开始
            refCount[path]++; // 引用计数加一,表示又有一个地方在使用它
            return cached as T; // 把缓存资源转换成目标类型并返回
        } // if 结束

        ResourceRequest request = Resources.LoadAsync<T>(path); // 使用 Resources 异步加载资源
        while (!request.isDone) // 当资源还没有加载完成时
        { // while 开始
            await Task.Yield(); // 等待下一帧,避免阻塞主线程
        } // while 结束

        T asset = request.asset as T; // 从加载请求里取出资源并转换成目标类型
        cache[path] = asset; // 把加载到的资源放入缓存
        refCount[path] = 1; // 初始化引用计数为 1
        return asset; // 返回加载完成的资源
    } // LoadAsync 方法结束

    public void Release(string path) // 定义释放资源引用的方法
    { // Release 方法开始
        if (!refCount.ContainsKey(path)) // 如果引用计数字典里没有这个资源
        { // if 开始
            return; // 直接返回,避免释放不存在的资源
        } // if 结束

        refCount[path]--; // 引用计数减一
        if (refCount[path] > 0) // 如果还有其他地方在使用这个资源
        { // if 开始
            return; // 不真正释放资源
        } // if 结束

        refCount.Remove(path); // 移除引用计数记录
        cache.Remove(path); // 移除缓存引用,让资源有机会被卸载
    } // Release 方法结束
} // 类结束

常见坑

CAUTION

Destroy 实例对象,不释放资源句柄,资源仍然可能常驻内存。 异步加载完成时,发起加载的 UI 或角色已经销毁,回调里要判空或支持取消。 重复加载同一个资源,没有缓存和引用计数,会造成内存浪费。 切场景时没有统一释放,导致旧场景资源残留。

简单材质或 Shader

unity-simple-material-shader

简单材质或 Shader

材质 Material 是 Shader 的一个实例,它保存 Shader 引用和参数;Shader 是 GPU 真正执行的程序,决定顶点怎么变换、像素怎么着色。

标准回答

在 Unity 里,Shader 更像一套渲染规则,Material 是这套规则的具体参数实例。比如多个材质可以共用同一个 Shader,但每个材质可以设置不同颜色、贴图、透明度等参数。

一个简单 Unlit Shader 不计算光照,只把顶点变换到裁剪空间,然后在片元阶段输出颜色或采样贴图。它适合 UI、特效、图标、调试模型、纯色物体等场景。

每行注释代码

c
Shader "Custom/SimpleUnlit" // 定义 Shader 名字,在 Unity 材质面板中会显示为 Custom/SimpleUnlit
{ // Shader 代码块开始
    Properties // 定义材质面板中可调的参数
    { // Properties 代码块开始
        _Color ("Tint Color", Color) = (1, 1, 1, 1) // 定义颜色参数,默认是白色
        _MainTex ("Main Texture", 2D) = "white" {} // 定义主贴图参数,默认使用白色贴图
    } // Properties 代码块结束

    SubShader // 定义一个子着色器,Unity 会选择当前平台可用的 SubShader
    { // SubShader 代码块开始
        Tags { "RenderType" = "Opaque" } // 设置渲染类型为不透明
        Pass // 定义一次渲染 Pass
        { // Pass 代码块开始
            CGPROGRAM // 开始 CG/HLSL 程序块
            #pragma vertex vert // 指定顶点着色器函数名为 vert
            #pragma fragment frag // 指定片元着色器函数名为 frag
            #include "UnityCG.cginc" // 引入 Unity 常用 Shader 工具函数

            sampler2D _MainTex; // 声明主贴图采样器
            float4 _Color; // 声明颜色参数

            struct appdata // 定义从 Mesh 输入到顶点着色器的数据结构
            { // appdata 结构体开始
                float4 vertex : POSITION; // 顶点位置,由 Mesh 提供
                float2 uv : TEXCOORD0; // 顶点 UV,由 Mesh 提供
            }; // appdata 结构体结束

            struct v2f // 定义从顶点着色器传给片元着色器的数据结构
            { // v2f 结构体开始
                float4 pos : SV_POSITION; // 裁剪空间位置,用于光栅化
                float2 uv : TEXCOORD0; // 传递给片元阶段的 UV
            }; // v2f 结构体结束

            v2f vert(appdata v) // 顶点着色器函数,输入 Mesh 顶点数据
            { // vert 函数开始
                v2f o; // 创建输出结构体
                o.pos = UnityObjectToClipPos(v.vertex); // 把模型空间顶点转换到裁剪空间
                o.uv = v.uv; // 把 UV 原样传给片元着色器
                return o; // 返回顶点阶段输出
            } // vert 函数结束

            fixed4 frag(v2f i) : SV_Target // 片元着色器函数,返回最终像素颜色
            { // frag 函数开始
                fixed4 texColor = tex2D(_MainTex, i.uv); // 根据 UV 采样主贴图颜色
                return texColor * _Color; // 返回贴图颜色乘以材质颜色
            } // frag 函数结束
            ENDCG // 结束 CG/HLSL 程序块
        } // Pass 代码块结束
    } // SubShader 代码块结束
} // Shader 代码块结束

常见坑

NOTE

Material 改参数会影响这个材质实例;renderer.material 可能复制材质,频繁用会产生额外材质实例。大量对象只想改颜色时,可以用 MaterialPropertyBlock,避免复制材质。

移动端 Shader 要注意采样次数、透明 Overdraw、Shader Variant 数量和精度类型。简单效果能用 Unlit 就不要上复杂光照。

ImGui 调试面板

engine-imgui-debug-panel

ImGui 调试面板

ImGui 调试面板一般指 Dear ImGui 做的开发期运行时工具。它不是正式游戏 UI,而是给程序调试用的面板,用来实时看数据、改参数、开关功能、触发命令和定位问题。

标准回答

ImGui 的核心特点是立即模式 UI。传统 UI 往往要创建按钮、滑条、窗口对象并维护它们的生命周期;ImGui 是每一帧用代码重新描述 UI,比如这一帧调用 ButtonSliderFloatCheckbox,它就会生成这一帧的界面和交互结果。

在自研引擎里,ImGui 通常接在主循环里:先把窗口输入传给 ImGui,再调用 NewFrame,然后绘制调试面板,最后调用 Render,把 ImGui 生成的 DrawData 交给渲染后端画到屏幕上。

每行注释代码

c
bool showDebugPanel = true; // 控制调试面板是否显示
float timeScale = 1.0f; // 保存游戏时间缩放参数
bool enableAI = true; // 保存 AI 系统是否启用
int enemyCount = 0; // 保存当前敌人数量

void DrawDebugPanel() // 定义绘制 ImGui 调试面板的函数
{ // 函数开始
    if (!showDebugPanel) // 如果面板不需要显示
    { // if 开始
        return; // 直接返回,不绘制任何调试 UI
    } // if 结束

    ImGui::Begin("Debug Panel"); // 开始绘制一个名为 Debug Panel 的窗口
    ImGui::Text("Runtime Stats"); // 绘制一行标题文本
    ImGui::Text("Enemy Count: %d", enemyCount); // 显示当前敌人数量
    ImGui::SliderFloat("Time Scale", &timeScale, 0.0f, 3.0f); // 绘制滑条,用来实时调节时间缩放
    ImGui::Checkbox("Enable AI", &enableAI); // 绘制复选框,用来开关 AI 系统

    if (ImGui::Button("Spawn Enemy")) // 如果点击了生成敌人的按钮
    { // if 开始
        SpawnEnemy(); // 调用生成敌人的调试命令
    } // if 结束

    if (ImGui::Button("Clear Scene")) // 如果点击了清理场景的按钮
    { // if 开始
        ClearSceneObjects(); // 调用清理场景对象的调试命令
    } // if 结束

    ImGui::End(); // 结束当前 ImGui 窗口
} // 函数结束

面试里可以这样说

我在引擎 Demo 里接过 ImGui 调试面板,用来查看 FPS、对象数量、资源引用、碰撞开关、AI 状态和渲染参数。它的好处是开发效率高,调试时不用做正式 UI,也不用重新编复杂面板,直接写几行代码就能实时查看和修改运行时状态。

常见坑

IMPORTANT

ImGui 面板消费输入时,输入不能继续传给游戏逻辑,否则会出现点面板按钮时角色也移动或攻击。 正式版本要关闭或加权限,不能把调试入口、刷怪按钮、资源路径、性能数据暴露给玩家。 ImGui 适合开发期调试,不适合直接当正式游戏 UI 系统。 如果做自研引擎,面试官可能会追问 ImGui 的 DrawData 如何转成你的渲染后端命令。

日志系统

日志系统不是简单 Debug.Log,而是把运行时行为、异常、玩家操作链路、设备信息、版本信息记录下来,用来定位问题、复盘线上事故、监控质量。

unity-logging-system

面试怎么说

日志系统底层可以理解成一个“生产者消费者模型”:业务代码产生日志,日志系统先做等级过滤、模块分类、补充上下文,然后放进队列,最后由后台或分帧逻辑批量写文件、压缩上传、后台分析。

Unity 项目里我一般不会让业务到处直接写 Debug.Log,而是封装统一入口,比如 GameLog.Info("Battle", "...")。这样可以统一控制日志等级、是否输出、是否落盘、是否上传,也方便线上关闭低级别日志,避免性能和包体问题。

常见日志等级一般是:Debug 调试信息,Info 普通流程,Warn 可恢复异常,Error 业务错误,Fatal 严重崩溃或不可恢复错误。线上通常只保留 Warn/Error/Fatal,开发包才打开 Debug/Info

每行注释代码

c
using System; // 引入 DateTime,用来给日志加时间戳。
using System.Collections.Generic; // 引入 Queue,用来缓存待写入的日志。
using System.IO; // 引入 StreamWriter,用来把日志写入本地文件。
using UnityEngine; // 引入 Unity API,用来获取路径和输出控制台日志。
public enum LogLevel { Debug, Info, Warn, Error, Fatal } // 定义日志等级,数值越靠后越严重。
public sealed class GameLogger : MonoBehaviour // 定义日志组件,挂到一个常驻对象上。
{ // 类开始。
    public static GameLogger Instance { get; private set; } // 保存全局唯一实例。
    [SerializeField] private LogLevel minLevel = LogLevel.Info; // 设置最低输出等级,低于它的日志会被过滤。
    private readonly Queue<string> queue = new Queue<string>(128); // 创建日志队列,避免每条日志立刻写磁盘。
    private readonly object locker = new object(); // 创建锁,避免多线程同时访问队列出问题。
    private StreamWriter writer; // 保存文件写入器。
    private void Awake() // Unity 初始化时调用。
    { // Awake 开始。
        if (Instance != null) // 如果已经有日志系统实例。
        { // if 开始。
            Destroy(gameObject); // 销毁重复的日志对象。
            return; // 结束当前初始化。
        } // if 结束。
        Instance = this; // 记录当前实例。
        DontDestroyOnLoad(gameObject); // 切场景时不销毁日志系统。
        string path = Path.Combine(Application.persistentDataPath, "game.log"); // 拼出本地日志文件路径。
        writer = new StreamWriter(path, true); // 以追加模式打开日志文件。
        Application.logMessageReceived += OnUnityLog; // 接管 Unity 自带 Debug.Log 的输出。
    } // Awake 结束。
    public void Write(LogLevel level, string tag, string message) // 对外提供统一写日志接口。
    { // Write 开始。
        if (level < minLevel) return; // 等级太低就直接忽略,减少噪声和开销。
        string line = $"[{DateTime.Now:HH:mm:ss}][{level}][{tag}] {message}"; // 组装一行结构化日志。
        lock (locker) queue.Enqueue(line); // 加锁后放入队列,等待后续批量写入。
        if (level >= LogLevel.Error) Debug.LogError(line); // 严重错误同时输出到 Unity Console。
    } // Write 结束。
    private void Update() // 每帧调用,用来分帧写日志。
    { // Update 开始。
        int count = 0; // 记录本帧写了多少条。
        lock (locker) // 加锁访问队列。
        { // lock 开始。
            while (queue.Count > 0 && count < 20) // 每帧最多写 20 条,避免一帧 IO 过多。
            { // while 开始。
                writer.WriteLine(queue.Dequeue()); // 从队列取出一条日志并写入文件。
                count++; // 写入数量加一。
            } // while 结束。
        } // lock 结束。
        if (count > 0) writer.Flush(); // 本帧写过日志才刷新文件。
    } // Update 结束。
    private void OnUnityLog(string condition, string stackTrace, LogType type) // 接收 Unity 原生日志。
    { // OnUnityLog 开始。
        if (type == LogType.Exception) Write(LogLevel.Error, "Unity", condition); // 异常日志转成 Error。
    } // OnUnityLog 结束。
    private void OnDestroy() // 对象销毁时调用。
    { // OnDestroy 开始。
        Application.logMessageReceived -= OnUnityLog; // 取消监听,避免事件引用导致泄漏。
        writer?.Flush(); // 最后刷新一次文件,防止日志丢失。
        writer?.Close(); // 关闭文件句柄,释放系统资源。
    } // OnDestroy 结束。
} // 类结束。

常见坑

TIP

Debug.Log 不是免费的,高频调用会产生字符串分配、堆栈收集、Console 开销,移动端尤其明显。线上日志也不能全量上传,否则会浪费流量、磁盘和服务器成本。

面试里可以补一句:真正成熟的日志系统会有采样、限频、压缩、循环文件、崩溃前缓存、隐私脱敏、后台聚合查询和告警。这样就不是“我会打印日志”,而是“我能用日志支撑线上质量”。

基础内存管理

基础内存管理

基础内存管理的核心是:内存从哪里申请,谁拥有它,什么时候释放,释放后还能不能访问。面试里不要只说“栈快、堆慢”,更要说清楚所有权、生命周期、泄漏、悬空引用和碎片问题。

basic-memory-management

标准回答

栈内存一般用于函数调用帧和局部变量,生命周期由作用域自动管理,速度快,但空间有限。堆内存用于动态申请的对象或资源,生命周期更灵活,但需要明确释放,或者交给 GC、智能指针、资源管理器处理。

在 C++ 里,最重要的是 RAII:资源在构造函数里获取,在析构函数里释放。这样对象生命周期结束时,资源自动释放,可以减少忘记 delete、异常提前返回导致泄漏的问题。

在 C# 和 Unity 里,普通托管对象由 GC 回收,但贴图、Mesh、AudioClip、GameObject、AssetBundle、Addressables 句柄等资源不只是托管内存,还涉及 Unity Native Memory,所以经常需要 DestroyUnloadAddressables.Release 等配合管理。

底层原理

堆分配通常要经过分配器查找空闲块、记录元数据、维护空闲链表或页管理,所以比栈分配复杂。频繁申请释放还可能产生内存碎片。C# 的 GC 会扫描对象引用关系,找到不可达对象再回收,但 GC 触发时可能带来卡顿,所以 Unity 中要尽量减少每帧临时分配。

C++ RAII 示例,每行都有注释

c
#include <iostream> // 引入标准输出,用来演示资源创建和释放。
#include <memory> // 引入智能指针,用来自动管理堆对象。
class TextureResource // 定义一个模拟贴图资源的类。
{ // 类定义开始。
public: // 公开成员区域开始。
    explicit TextureResource(int id) : id_(id) // 构造函数接收资源 ID,并保存到成员变量。
    { // 构造函数函数体开始。
        std::cout << "Load texture " << id_ << std::endl; // 模拟加载贴图资源。
    } // 构造函数函数体结束。
    ~TextureResource() // 析构函数会在对象生命周期结束时自动调用。
    { // 析构函数函数体开始。
        std::cout << "Release texture " << id_ << std::endl; // 模拟释放贴图资源。
    } // 析构函数函数体结束。
    void Use() const // 定义使用资源的函数,并声明不会修改对象。
    { // Use 函数函数体开始。
        std::cout << "Use texture " << id_ << std::endl; // 模拟使用贴图资源。
    } // Use 函数函数体结束。
private: // 私有成员区域开始。
    int id_; // 保存资源 ID,外部不能直接修改。
}; // 类定义结束。
int main() // 程序入口函数。
{ // main 函数函数体开始。
    std::unique_ptr<TextureResource> tex = std::make_unique<TextureResource>(1001); // 创建独占所有权的资源对象。
    tex->Use(); // 使用智能指针管理的贴图资源。
    return 0; // main 结束,tex 离开作用域后会自动析构并释放资源。
} // main 函数函数体结束。

Unity 项目里怎么落地

我会把内存分成三类看:托管对象、Unity 原生资源、业务缓存。托管对象重点看 GC Alloc,避免每帧 new、字符串拼接、LINQ、装箱、临时 List。Unity 资源重点看引用链和卸载流程,比如对象销毁后资源是否还有引用,Addressables 句柄有没有 Release。业务缓存重点看对象池、资源池、UI 池有没有回收策略。

常见坑

TIP

内存泄漏不是只有 C++ 才有。Unity 里事件没取消订阅、静态字典长期持有对象、Addressables 只 Load 不 Release、对象池只进不出,都会导致对象或资源无法释放。面试里能说到这些,就比只背“栈和堆区别”更像真正做过项目。

文档说明架构

文档说明架构,就是用一份清晰的架构文档把项目的目标、分层、模块边界、数据流、依赖方向、扩展点和取舍讲明白。面试里它的价值很大,因为它能证明你不是只会写功能,而是知道系统为什么这样拆、后续怎么维护。

architecture-documentation

标准回答

如果我要说明一个项目架构,我不会只贴代码目录,而是会按这条线讲:

项目要解决什么问题;系统分成哪几层;每个模块负责什么;模块之间怎么通信;核心数据怎么流动;新增功能应该改哪里;哪些地方有性能或维护风险。

比如 Unity 项目里,我会把架构文档拆成:README 总览、Architecture.md 架构总图、模块说明文档、关键流程图、性能优化记录、已知问题和后续改进。这样面试官问战斗、资源、UI、存档、事件系统,我都能回到同一套结构里解释。

架构文档推荐模板

# 项目架构说明

## 1. 项目背景
说明游戏类型、目标平台、核心玩法、主要技术目标。

## 2. 总体架构
画出 UI、业务逻辑、资源管理、数据配置、网络、存档等模块关系。

## 3. 模块职责
说明每个模块负责什么,不负责什么,以及对外提供哪些接口。

## 4. 核心流程
说明启动流程、场景加载流程、战斗流程、资源加载流程、存档流程。

## 5. 数据结构
说明配置表、运行时数据、存档数据、网络数据分别怎么流转。

## 6. 扩展方式
说明新增角色、技能、Buff、任务、UI 面板时应该改哪些地方。

## 7. 性能和风险
说明对象池、异步加载、资源释放、事件解绑、GC 控制等策略。

## 8. 复盘和改进
说明当前架构的短板,以及如果重做会怎么优化。

面试里怎么讲更打动人

可以这样说: “我做项目时会把架构文档当成系统边界说明书。比如战斗模块只负责战斗状态和伤害结算,不直接操作 UI;UI 只订阅事件并刷新表现;资源模块统一负责异步加载和释放。这样模块之间依赖方向清楚,后面加技能、加 Buff、换 UI,都不会牵一发动全身。”

常见坑

NOTE

最差的架构文档是只写文件夹目录,比如 Scripts/UIScripts/ManagerScripts/Data,但不解释职责和依赖。真正有用的文档要能回答三个问题:这个模块为什么存在,它依赖谁,别人怎么安全地扩展它。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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