Appearance
引擎向 Demo 最低配置
窗口和输入
窗口和输入
窗口和输入可以理解成游戏客户端最底层的交互入口:窗口负责和操作系统打交道,输入负责把键盘、鼠标、手柄、触摸转换成游戏里的动作。
标准回答
窗口层主要负责创建窗口、处理关闭、全屏切换、尺寸变化、DPI、焦点变化等。比如窗口失焦时,游戏可能要暂停输入;窗口 Resize 时,渲染分辨率和 UI 适配也要更新。
输入层负责采集键盘、鼠标、手柄、触摸等设备状态。底层通常会维护当前帧和上一帧输入快照,这样才能判断 Pressed、Held、Released。再往上会有一层 Action 映射,把物理按键映射成游戏动作,比如 WASD -> Move,鼠标左键 -> Attack,Space -> Jump。
面试里可以这样说
如果是自研引擎,我会用 Win32、SDL 或 GLFW 接收操作系统事件,把事件放入输入队列,然后每帧开始时更新输入快照。业务层不直接关心具体按键,而是查询动作状态,比如 IsActionPressed("Attack")。这样以后换键位、支持手柄、支持触摸时,不需要改玩法逻辑。
如果是 Unity 项目,我会说 Unity 已经封装了窗口和底层输入,业务侧常用 Input System、EventSystem 和 UI Raycast。要注意 UI 优先级,比如点击按钮时 UI 消费输入,不能同时让角色攻击或移动。
常见坑
NOTE
窗口失焦后输入没清空,会导致回来后角色继续移动。 UI 没有消费输入,会出现点按钮时角色也攻击。 只读当前帧状态,不记录上一帧,就不好判断按下和抬起。 键位写死在逻辑里,后面做改键、手柄、移动端会很痛苦。
渲染三角形或简单模型
渲染三角形或简单模型
渲染三角形是图形程序的最小闭环:准备顶点数据,上传到 GPU,绑定 Shader 和矩阵,提交 DrawCall,最后光栅化成屏幕上的像素。
标准回答
如果我要渲染一个三角形,第一步是准备 3 个顶点,每个顶点至少有位置,也可以带颜色、UV、法线。然后把这些顶点上传到 GPU 的顶点缓冲区。接着绑定 Shader,顶点着色器把模型空间坐标通过 MVP 矩阵变换到裁剪空间,片元着色器决定最终颜色。最后提交 DrawCall,GPU 会把三角形光栅化成片元,再经过深度测试、混合等阶段写入帧缓冲。
渲染简单模型只是三角形的扩展:模型由很多三角形组成,通常会有顶点缓冲、索引缓冲、法线、UV、材质和贴图。CPU 负责组织 Mesh、材质和渲染状态,GPU 负责并行处理顶点和像素。
Unity 里可以这样说
Unity 里可以通过 Mesh 创建三角形,设置 vertices 和 triangles,再配合 MeshFilter、MeshRenderer 和 Material 显示出来。底层 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
相机和 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 处理跟随、碰撞、震动和过渡。
场景对象管理
场景对象管理
场景对象管理就是统一管理场景里对象的生命周期:创建、注册、更新、查询、可见性判断、销毁和资源回收。它解决的是“对象不能散落在场景里没人管”的问题。
标准回答
我理解场景对象管理的核心是生命周期可控。一个对象进入场景时,应该经过资源加载、实例化、注册索引;运行过程中可以按 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 里常见资源加载方式有 Resources、AssetBundle、Addressables,项目里通常会再封装一层资源管理器。业务模块不直接到处调用加载 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
简单材质或 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 调试面板
ImGui 调试面板
ImGui 调试面板一般指 Dear ImGui 做的开发期运行时工具。它不是正式游戏 UI,而是给程序调试用的面板,用来实时看数据、改参数、开关功能、触发命令和定位问题。
标准回答
ImGui 的核心特点是立即模式 UI。传统 UI 往往要创建按钮、滑条、窗口对象并维护它们的生命周期;ImGui 是每一帧用代码重新描述 UI,比如这一帧调用 Button、SliderFloat、Checkbox,它就会生成这一帧的界面和交互结果。
在自研引擎里,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 项目里我一般不会让业务到处直接写 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 开销,移动端尤其明显。线上日志也不能全量上传,否则会浪费流量、磁盘和服务器成本。
面试里可以补一句:真正成熟的日志系统会有采样、限频、压缩、循环文件、崩溃前缓存、隐私脱敏、后台聚合查询和告警。这样就不是“我会打印日志”,而是“我能用日志支撑线上质量”。
基础内存管理
基础内存管理
基础内存管理的核心是:内存从哪里申请,谁拥有它,什么时候释放,释放后还能不能访问。面试里不要只说“栈快、堆慢”,更要说清楚所有权、生命周期、泄漏、悬空引用和碎片问题。
标准回答
栈内存一般用于函数调用帧和局部变量,生命周期由作用域自动管理,速度快,但空间有限。堆内存用于动态申请的对象或资源,生命周期更灵活,但需要明确释放,或者交给 GC、智能指针、资源管理器处理。
在 C++ 里,最重要的是 RAII:资源在构造函数里获取,在析构函数里释放。这样对象生命周期结束时,资源自动释放,可以减少忘记 delete、异常提前返回导致泄漏的问题。
在 C# 和 Unity 里,普通托管对象由 GC 回收,但贴图、Mesh、AudioClip、GameObject、AssetBundle、Addressables 句柄等资源不只是托管内存,还涉及 Unity Native Memory,所以经常需要 Destroy、Unload、Addressables.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、对象池只进不出,都会导致对象或资源无法释放。面试里能说到这些,就比只背“栈和堆区别”更像真正做过项目。
文档说明架构
文档说明架构,就是用一份清晰的架构文档把项目的目标、分层、模块边界、数据流、依赖方向、扩展点和取舍讲明白。面试里它的价值很大,因为它能证明你不是只会写功能,而是知道系统为什么这样拆、后续怎么维护。
标准回答
如果我要说明一个项目架构,我不会只贴代码目录,而是会按这条线讲:
项目要解决什么问题;系统分成哪几层;每个模块负责什么;模块之间怎么通信;核心数据怎么流动;新增功能应该改哪里;哪些地方有性能或维护风险。
比如 Unity 项目里,我会把架构文档拆成:README 总览、Architecture.md 架构总图、模块说明文档、关键流程图、性能优化记录、已知问题和后续改进。这样面试官问战斗、资源、UI、存档、事件系统,我都能回到同一套结构里解释。
架构文档推荐模板
# 项目架构说明
## 1. 项目背景
说明游戏类型、目标平台、核心玩法、主要技术目标。
## 2. 总体架构
画出 UI、业务逻辑、资源管理、数据配置、网络、存档等模块关系。
## 3. 模块职责
说明每个模块负责什么,不负责什么,以及对外提供哪些接口。
## 4. 核心流程
说明启动流程、场景加载流程、战斗流程、资源加载流程、存档流程。
## 5. 数据结构
说明配置表、运行时数据、存档数据、网络数据分别怎么流转。
## 6. 扩展方式
说明新增角色、技能、Buff、任务、UI 面板时应该改哪些地方。
## 7. 性能和风险
说明对象池、异步加载、资源释放、事件解绑、GC 控制等策略。
## 8. 复盘和改进
说明当前架构的短板,以及如果重做会怎么优化。面试里怎么讲更打动人
可以这样说: “我做项目时会把架构文档当成系统边界说明书。比如战斗模块只负责战斗状态和伤害结算,不直接操作 UI;UI 只订阅事件并刷新表现;资源模块统一负责异步加载和释放。这样模块之间依赖方向清楚,后面加技能、加 Buff、换 UI,都不会牵一发动全身。”
常见坑
NOTE
最差的架构文档是只写文件夹目录,比如 Scripts/UI、Scripts/Manager、Scripts/Data,但不解释职责和依赖。真正有用的文档要能回答三个问题:这个模块为什么存在,它依赖谁,别人怎么安全地扩展它。