Appearance
题海扩展:讲义可用判断题
Unity 协程是多线程。
错。
解析
这题判断为错。Unity 协程的核心不是“开了一个新线程”,而是“把一个方法拆成多次执行”。协程方法通常返回 IEnumerator,执行到 yield return 时暂停,把控制权交还给 Unity,等条件满足后再从暂停位置继续执行。
依据是 Unity 官方协程手册:协程可以跨多帧展开任务,但协程不是线程;协程中的同步代码仍然运行在主线程上。也就是说,如果在协程里写一个很耗 CPU 的循环、同步文件读取、复杂寻路或大量解析,它仍然会卡住主线程。
项目里要这样理解:协程适合做流程编排、分帧等待、异步加载等待、动画过渡、延迟执行,不适合拿来做真正的并行计算。需要并行 CPU 计算时,应考虑 C# 线程、Task、Unity Job System、Burst 或把重活拆到可控的后台任务里,并注意 Unity 大多数 API 只能在主线程访问。
List<T> 底层是数组。
对。
解析
这题判断为对。List<T> 是可变长度的泛型线性表,它对外表现为可以按下标访问的列表,对内通常通过一块连续数组保存元素。
依据是 .NET 官方文档:List<T> 使用一个会按需扩容的内部数组;Capacity 表示当前内部结构不用扩容就能容纳的元素数量,Count 表示实际元素数量。当 Add 导致 Count 超过 Capacity 时,List<T> 会重新分配更大的数组,并把旧元素复制过去。
所以 List<T> 的随机下标访问通常是 O(1),尾部追加在不扩容时也很快;但中间插入、删除需要移动后续元素,扩容还会产生数组分配和拷贝成本。Unity 项目里如果在帧循环里频繁扩容,容易产生 GC 和性能波动,可以提前设置容量或复用列表。
Dictionary 一定有序。
错。
解析
这题判断为错。Dictionary<TKey, TValue> 的语义是“按 Key 快速查 Value”,不是“按插入顺序或排序顺序遍历”。它的核心目标是查找效率,而不是顺序稳定。
依据是 .NET 官方文档:Dictionary<TKey, TValue> 基于哈希表实现,按 Key 检索非常快;用于枚举时返回元素的顺序没有定义。即使某些运行时版本在特定情况下看起来接近插入顺序,也不应该把它当成跨版本、跨平台、跨实现都可靠的契约。
项目里如果需要稳定顺序,不要依赖 Dictionary 遍历结果。按插入顺序展示可以额外维护 List<TKey>;按 Key 排序可以使用 SortedDictionary 或排序后的 Key 列表;既要查找又要顺序,就要显式设计“索引结构 + 顺序结构”。
Destroy 会在当前行代码执行后立即释放 Native 对象。
错。
解析
这题判断为错。Object.Destroy(obj) 不是在这一行之后立刻把 Unity Native 对象完全释放掉,而是把对象标记为销毁,实际销毁会延迟到当前更新循环之后,通常在渲染之前完成。
依据是 Unity Object.Destroy 官方 API:实际对象销毁总是延迟到当前 Update 循环之后,但会在渲染之前执行。Unity 还提供 DestroyImmediate,但它主要用于编辑器场景,运行时代码通常应使用 Destroy。
项目里常见坑是:调用 Destroy(gameObject) 后,同一帧里其它代码可能还持有 C# 包装对象引用;Unity 对象的 == null 又有特殊重载,容易让人误以为和普通 C# 对象生命周期一样。正确理解是:Destroy 负责 Unity 对象生命周期,托管引用何时被 GC 回收是另一回事;如果需要立刻停止逻辑,通常应先禁用组件、移除引用或设置状态,再等待 Unity 完成销毁。
FixedUpdate 适合处理物理相关逻辑。
对。
解析
这题判断为对。FixedUpdate 按固定时间步长驱动,和 Unity 物理模拟节奏绑定,适合处理刚体受力、物理移动、碰撞相关的逻辑。
依据是 Unity 官方手册和 MonoBehaviour.FixedUpdate API:固定更新循环常用于执行物理相关代码,例如给刚体施加力;Unity 物理系统以固定间隔进行模拟,这有利于物理结果的稳定性和一致性。
项目里要注意,“适合物理”不等于“所有逻辑都放 FixedUpdate”。输入采样通常放 Update,因为输入跟渲染帧有关;相机跟随常放 LateUpdate;普通 UI、动画参数、非物理表现逻辑通常用 Update。常见做法是:Update 采集输入并缓存意图,FixedUpdate 根据缓存意图驱动 Rigidbody。
Time.deltaTime 会受 timeScale 影响。
对。
解析
这题判断为对。Time.deltaTime 表示上一帧到当前帧的“缩放后游戏时间间隔”,它会受到 Time.timeScale 影响。
依据是 Unity 时间系统文档:timeScale 表示时间流逝倍率,可用于慢动作、加速或暂停;需要不受 timeScale 影响的帧间隔时,应使用 Time.unscaledDeltaTime。当 timeScale = 0 时,依赖 deltaTime 推进的玩法计时、移动、冷却通常会暂停。
项目里可以按语义选择:玩法逻辑、技能 CD、怪物 AI、战斗时间通常用 deltaTime,这样暂停和慢动作能统一生效;暂停菜单动画、网络超时、真实时间倒计时、加载界面动画通常用 unscaledDeltaTime 或真实时间。
WaitForSecondsRealtime 不受 timeScale 影响。
对。
解析
这题判断为对。WaitForSecondsRealtime 等待的是未缩放真实时间,不受 Time.timeScale 影响;对应地,WaitForSeconds 等待的是缩放后的游戏时间。
依据是 Unity WaitForSecondsRealtime 官方 API:它使用 unscaled time 暂停协程指定秒数。Unity WaitForSeconds 文档则说明它使用 scaled time,等待时长会受到 timeScale 影响。
项目里常见场景是暂停面板、断线重连倒计时、加载超时、UI 提示自动关闭等,这些逻辑即使 timeScale = 0 也应该继续走,就适合使用 WaitForSecondsRealtime。但它也不是精确到毫秒的定时器,协程恢复通常发生在满足时间后的下一帧。
sharedMaterial 修改会影响共享该材质的对象。
对。
解析
这题判断为对。Renderer.sharedMaterial 取到的是共享材质资源,多个 Renderer 可能引用同一份 Material。修改它,相当于修改公共材质,所有使用这份材质的对象都可能被影响。
依据是 Unity Renderer.sharedMaterial 官方 API:修改 sharedMaterial 会改变所有使用该材质的对象外观,也会改变项目中存储的材质设置;如果只想修改某个 Renderer 的材质,应使用 material 或其它 per-renderer 方案。
项目里要区分三个概念:sharedMaterial 适合读共享材质或做全局材质调整;material 会为当前 Renderer 创建材质实例,适合单个对象独立变化,但要注意材质实例膨胀和销毁;MaterialPropertyBlock 常用于只改少量渲染参数,避免复制材质,但在 SRP Batcher 项目里要结合管线特性实测。
所有 GetComponent 都绝对不能用。
错。
解析
这题判断为错。GetComponent 有查找成本,但不能被简单理解成“绝对不能用”。它是 Unity 常用 API,正确问题应该是“在哪里用、用多频繁、是否需要缓存”。
依据是 Unity GetComponent 官方 API:它用于从当前 GameObject 上取指定类型组件;字符串版本还明确提示出于性能原因最好使用 Type 或泛型版本。也就是说,Unity 并没有禁止使用它,只是提示不同用法和调用频率会影响性能。
项目里推荐做法是:在 Awake、Start、初始化阶段、低频交互里使用并缓存引用;避免在大量对象的 Update、LateUpdate、OnGUI 或热点循环里反复调用;能直接拖 Inspector 引用、构造注入或缓存就不要每帧查找。面试时说“绝对不能用”反而显得不工程化,正确答案是按热点和可读性权衡,并用 Profiler 验证。
对象池一定会降低内存占用。
错。
解析
这题判断为错。对象池的主要价值通常是减少频繁 Instantiate、Destroy、托管分配和 GC 抖动,不是保证降低内存占用。池子会把对象留在内存里复用,所以它经常提高的是运行时平滑度,而不是降低常驻内存。
依据是对象池的基本机制:创建一批对象并缓存,使用时取出,不用时放回。对象被放回池后并没有真正释放,它的 GameObject、组件、数组、贴图引用、粒子系统、音频引用等可能仍然占用内存。
项目里如果池子过大、只增不减、跨场景不清理,反而会造成内存常驻过高。合理对象池要有容量上限、预热策略、回收清理、场景切换释放、长时间不用时收缩,并区分“对象复用”和“资源释放”。所以对象池是降低分配和销毁成本的手段,不是天然降低内存的手段。
DrawCall 越少一定越好。
错。
解析
这题判断为错。DrawCall 少通常有利于降低 CPU 提交渲染命令和状态切换成本,但不是越少越好。渲染优化要看瓶颈在 CPU、GPU、带宽、Overdraw、Shader 复杂度还是内存。
依据是 Unity Draw Call Batching 官方手册:批处理可以减少绘制调用,但静态批处理会带来内存和存储开销,动态批处理会产生 CPU 开销;透明物体由于排序要求,也可能无法像不透明物体一样高效合批。
项目里为了减少 DrawCall 手动合并过大的网格,可能破坏视锥剔除和遮挡剔除,导致本来不可见的小物体也被一起绘制;为了合批强行共用材质,可能增加 Shader 分支或纹理采样;为了减少批次使用大图集,也可能增加内存。正确做法是结合 Frame Debugger、Profiler、RenderDoc 或平台 GPU 工具确认瓶颈,再决定是减少 DrawCall、降低 Overdraw、优化 Shader、减少材质切换还是改善剔除。
透明物体通常比不透明物体更容易造成 Overdraw。
对。
解析
这题判断为对。透明物体通常需要混合背景颜色,并且经常不能像不透明物体那样充分依赖深度写入和 Early-Z 来减少像素着色,因此多个透明层叠加时,一个屏幕像素可能被反复绘制。
依据是 Unity 渲染队列和透明排序相关文档:透明队列通常在不透明队列之后绘制,并按距离排序;透明 Shader 往往需要从后往前渲染。Unity 的 Overdraw 视图也可以用来观察一个对象绘制到另一个对象上导致颜色累积的区域。
项目里粒子特效、半透明 UI、玻璃、烟雾、全屏淡入淡出都容易制造 Overdraw。移动端尤其敏感,因为像素填充率和带宽有限。优化方向包括减少大面积透明层、裁剪透明区域、降低粒子重叠、拆分 UI Canvas、使用不透明替代方案、控制特效屏幕占比和生命周期。
AssetBundle 卸载一定会销毁实例化对象。
错。
解析
这题判断为错。“一定会”这个说法太绝对。AssetBundle.Unload 的行为取决于传入的 unloadAllLoadedObjects 参数,以及对象、资源、实例、引用之间的关系。
依据是 Unity AssetBundle.Unload(bool unloadAllLoadedObjects) 官方 API:当参数为 false 时,只释放 AssetBundle 本身的压缩文件数据,已经从 Bundle 加载出来的对象实例会保持不变;当参数为 true 时,还会销毁从该 Bundle 加载的对象,如果场景中的 GameObject 引用了这些资源,引用会丢失。
项目里要把几层东西分清:Bundle 文件数据、从 Bundle 加载出的 Asset、由 Prefab 实例化出的场景对象、其它资源依赖、脚本缓存引用。常见内存问题不是一句 Unload 能解决的,需要看是否还有实例、静态缓存、Addressables handle、材质贴图引用、依赖 Bundle 引用计数等。正确答案是:卸载策略不同,结果不同,不能说一定销毁实例化对象。
IL2CPP 是解释执行。
错。
解析
这题判断为错。IL2CPP 不是解释执行器,它是 Unity 的 AOT 编译后端。它会把 C# 编译出的 IL 转成 C++,再用目标平台的原生编译器生成本机二进制。
依据是 Unity IL2CPP 官方手册:IL2CPP backend 会把 MSIL 转成 C++,再创建目标平台原生二进制;这种在构建阶段提前为目标平台编译代码的方式叫 AOT。相对地,Mono 后端更偏向运行时 JIT。
项目里这会带来几个影响:IL2CPP 启动后不是靠 JIT 动态编译托管代码;反射、泛型虚方法、动态代码生成、代码裁剪都要更小心;构建时间通常更长,但平台兼容性、安全性和发布性能常更适合移动端和主机平台。
iOS 支持 JIT 热更新。
错。
解析
这题判断为错。iOS 上不能把“JIT 热更新”当成可用方案。Unity iOS 发布通常走 IL2CPP/AOT,运行时动态生成并执行新机器码这类 JIT 思路会受到平台限制。
依据是 Unity Scripting Restrictions 官方手册:iOS IL2CPP 属于 AOT 平台;一些平台不允许运行时代码生成,因此依赖目标设备 JIT 编译的托管代码会失败,必须提前 AOT 编译。Unity iOS 排错文档里也有 “attempting to JIT compile while running with aot-only” 这类典型错误。
项目里要区分“热更新资源”和“JIT 热更新代码”。资源、配置、Lua/脚本解释执行、服务器下发数据、Addressables 资源更新等可以按平台规则设计;但不能简单说 iOS 支持 JIT 执行新 C# 代码。HybridCLR、XLua、ILRuntime 等方案也都要围绕 AOT 限制、元数据、解释执行、补充泛型实例和平台审核规则来设计。
shared_ptr 可以解决所有内存泄漏。
错。
解析
这题判断为错。std::shared_ptr 解决的是“共享所有权下自动释放对象”的问题,不是所有内存问题的万能药。
依据是 C++ RAII 和智能指针的所有权模型:shared_ptr 通过控制块维护强引用计数,最后一个强引用释放时销毁对象。但如果两个对象互相用 shared_ptr 持有,对方的引用计数都无法归零,就会形成循环引用;如果同一个裸指针被错误地交给多个独立 shared_ptr 控制块,还可能重复释放;如果全局缓存一直持有 shared_ptr,对象也不会释放。
项目里应该先设计清楚所有权。唯一拥有用 unique_ptr,共享生命周期才用 shared_ptr,观察关系或反向引用用 weak_ptr,临时访问用引用或裸指针表达非拥有关系。内存泄漏的根因可能是生命周期设计错误、缓存不清、循环引用、资源句柄没释放、Native 资源没按协议关闭,不能指望一个 shared_ptr 自动兜底。
虚析构能避免通过基类指针删除派生类时析构不完整。
对。
解析
这题判断为对。C++ 里如果一个类要作为多态基类使用,并且可能通过基类指针删除派生类对象,基类析构函数应该声明为 virtual。
依据是 C++ 的动态绑定和析构规则:通过基类指针 delete 派生类对象时,如果基类析构函数不是虚函数,行为可能是不完整析构甚至未定义行为;声明为虚析构后,删除会按对象真实类型调用派生类析构,再调用基类析构,从而完整释放派生类资源和基类资源。
项目里判断标准很简单:只要类有虚函数,或者被设计成接口/抽象基类,并且对象可能由基类指针、unique_ptr<Base>、shared_ptr<Base> 管理生命周期,就应写 virtual ~Base() = default;。如果类不是多态基类,也不会通过基类指针删除,才可以不付出虚析构带来的虚表语义成本。
UDP 完全不能做可靠传输。
错。
解析
这题判断为错。UDP 协议本身不保证可靠、有序、不重复,也不自动重传;但这不等于“完全不能做可靠传输”。应用层可以在 UDP 之上实现自己的可靠机制。
依据是 UDP 标准 RFC 768 和 UDP 使用指南 RFC 8085:UDP 提供的是较少协议机制的数据报服务,不保证交付和去重;如果应用需要可靠消息传递,必须自己实现合适的机制。也就是说,不可靠是 UDP 自身的默认能力边界,可靠可以在上层补。
项目里可靠 UDP 常见做法包括序号、ACK、超时重传、去重、乱序缓存、滑动窗口、心跳、断线重连、关键消息确认,甚至可以按消息类型选择“可靠”“不可靠”“最新状态覆盖旧状态”。实时游戏常用 UDP,不是因为可靠性不重要,而是因为它想自己控制哪些包必须可靠,哪些旧状态可以丢,避免 TCP 队头阻塞影响手感。
A* 启发函数高估可能失去最优性。
对。
解析
这题判断为对。A* 想保证找到最短路径,启发函数通常需要满足“不高估真实剩余代价”,也就是 admissible。如果启发函数高估了某些节点到终点的代价,A* 可能过早偏向看起来更便宜的路线,从而错过真实最优路径。
依据是 A* 的最优性条件:f(n) = g(n) + h(n),其中 g(n) 是起点到当前节点的已知代价,h(n) 是当前节点到终点的估计代价。若 h(n) 从不超过真实最小剩余代价,A* 扩展节点时才能保持最优性保证;如果 h(n) 高估,就会改变节点优先级,破坏这个保证。
项目里可以有意识地使用“带权 A*”或更激进的启发函数来换速度,比如让怪物寻路更快但不一定最短。这样不是算法错误,而是工程取舍。但面试题问“高估可能失去最优性”,答案是对,因为最优性保证依赖启发函数不高估。
项目面试只要讲实现功能就够了。
错。
解析
这题判断为错。项目面试里“实现了什么功能”只是最低层信息,不能完整体现工程能力。面试官更关心你为什么这样做、怎么拆模块、遇到什么问题、如何定位和验证、结果有没有数据支撑。
依据来自面试评价逻辑:游戏客户端岗位不只考 API 使用,还考工程闭环。一个好项目回答通常要包含背景目标、个人职责、模块结构、关键技术点、难点取舍、性能或稳定性问题、排查方法、最终效果和可复盘经验。
项目里可以按这个顺序讲:先用一句话说明项目和你的职责,再讲核心模块设计;然后挑 1 到 2 个最有含金量的问题展开,比如资源加载、战斗同步、UI 性能、对象池、卡顿排查、热更新、渲染优化;最后用指标收尾,比如加载耗时下降、GC Alloc 降低、DrawCall/Overdraw 改善、崩溃率下降或工具链效率提升。只讲“我实现了背包、登录、技能”会显得像功能搬运,讲清楚取舍和验证才像真正做过项目。