Appearance
二面 60 分钟套卷
项目架构设计
标准回答
项目架构设计的核心是:先明确需求边界,再拆分模块,让“数据、逻辑、表现、基础设施”各自负责。 我一般会把 Unity 客户端分成:入口层、基础设施层、业务逻辑层、表现层、工具层。
推荐分层
入口层:GameRoot、启动流程、模块注册、全局生命周期。 基础设施层:资源管理、配置表、网络、日志、对象池、计时器、事件系统。 业务逻辑层:战斗、技能、Buff、背包、任务、商城、红点、成就。 表现层:UI、动画、特效、音效、相机、输入。 工具层:配置检查、资源检查、打包工具、日志分析、性能巡检。
底层思路
架构最重要的是依赖方向。业务可以依赖基础服务,但基础服务不能反向依赖具体业务。 比如资源管理器只负责加载资源,不应该知道“背包界面”是什么;战斗系统只产生伤害结果,不应该直接控制 UI 飘字;UI 应该监听战斗事件或读取状态后刷新表现。
Unity 工程实践
我会用一个 GameRoot 管理模块生命周期,让所有模块都有统一的 Init、Tick、Dispose。这样启动顺序清楚,切场景时也知道谁该保留、谁该释放。
c
using System.Collections.Generic; // 引入 List 容器。
using UnityEngine; // 引入 Unity 基础 API。
public interface IGameModule // 定义游戏模块统一接口。
{ // 接口开始。
void Init(); // 初始化模块资源和状态。
void Tick(float deltaTime); // 每帧更新模块逻辑。
void Dispose(); // 释放模块持有的资源和事件。
} // 接口结束。
public sealed class GameRoot : MonoBehaviour // 定义游戏全局入口对象。
{ // 类开始。
private readonly List<IGameModule> modules = new List<IGameModule>(); // 保存所有模块实例。
private void Awake() // Unity 初始化时调用。
{ // 方法开始。
DontDestroyOnLoad(gameObject); // 保证入口对象切场景不销毁。
RegisterModules(); // 注册项目需要的模块。
for (int i = 0; i < modules.Count; i++) modules[i].Init(); // 按注册顺序初始化模块。
} // 方法结束。
private void Update() // Unity 每帧调用。
{ // 方法开始。
float deltaTime = Time.deltaTime; // 获取当前帧间隔时间。
for (int i = 0; i < modules.Count; i++) modules[i].Tick(deltaTime); // 驱动所有模块更新。
} // 方法结束。
private void OnDestroy() // 对象销毁时调用。
{ // 方法开始。
for (int i = modules.Count - 1; i >= 0; i--) modules[i].Dispose(); // 按反向顺序释放模块。
modules.Clear(); // 清空模块列表。
} // 方法结束。
private void RegisterModules() // 注册模块的方法。
{ // 方法开始。
modules.Add(new LogModule()); // 注册日志模块。
modules.Add(new ConfigModule()); // 注册配置模块。
modules.Add(new ResourceModule()); // 注册资源模块。
modules.Add(new EventModule()); // 注册事件模块。
modules.Add(new UIModule()); // 注册 UI 模块。
modules.Add(new BattleModule()); // 注册战斗模块。
} // 方法结束。
} // 类结束。面试关键句
TIP
我的项目架构不是简单堆 Manager,而是先拆清楚模块边界。入口层负责生命周期,基础设施提供通用能力,业务层保存规则和状态,表现层只负责展示。这样做的好处是模块可替换、问题好定位、切场景资源好清理,后续加新玩法也不会到处改代码。
资源加载和卸载
标准回答
资源加载和卸载的核心不是“什么时候 Load,什么时候 Unload”,而是管理资源生命周期:谁加载、谁持有、谁释放、依赖是否还被使用、实例对象是否已经销毁。
在 Unity 里要区分三层东西:资源文件,比如 Texture、Prefab、AudioClip;实例对象,比如 Instantiate 出来的 GameObject;资源包或句柄,比如 AssetBundle、Addressables Handle。销毁实例不等于资源立刻释放,释放句柄也不一定立刻回收内存。
加载流程
业务层只传资源 Key,不直接到处调用加载 API。 资源管理器检查缓存,如果已经加载过,就引用计数加一。 如果没加载,先加载依赖,再加载主资源。 返回资源或实例,同时记录句柄、引用计数和拥有者。 异步加载要处理失败、取消、重复请求和依赖未完成。
卸载流程
先停止使用资源,比如关闭 UI、结束战斗、回收特效。 如果是实例对象,先 Destroy 或放回对象池。 然后调用资源管理器 Release,让引用计数减一。 引用计数归零后释放句柄和依赖。 必要时在切场景或低内存时调用 Resources.UnloadUnusedAssets(),但它可能卡顿,不能频繁调用。
关键区别
Resources.Load 简单,但资源容易进包,依赖和卸载不可控。 AssetBundle 灵活,但依赖、引用计数、重复打包都要自己管。 Addressables 封装了依赖和句柄,但 Load 和 Release 必须配对,否则也会泄漏。
代码示例
c
using System.Collections.Generic; // 引入 Dictionary 容器。
using UnityEngine; // 引入 Unity 基础 API。
public sealed class SimpleResourceManager // 定义简单资源管理器。
{ // 类开始。
private sealed class Record // 定义资源缓存记录。
{ // 记录类开始。
public Object Asset; // 保存加载到的 Unity 资源对象。
public int RefCount; // 保存当前资源引用计数。
} // 记录类结束。
private readonly Dictionary<string, Record> records = new Dictionary<string, Record>(); // 保存资源 Key 到记录的映射。
public T Load<T>(string key) where T : Object // 按 Key 加载资源。
{ // 方法开始。
if (records.TryGetValue(key, out Record record)) // 如果缓存中已经有资源。
{ // 判断开始。
record.RefCount++; // 引用计数加一。
return record.Asset as T; // 返回缓存资源。
} // 判断结束。
T asset = Resources.Load<T>(key); // 示例中使用 Resources 加载资源。
if (asset == null) // 判断资源是否加载失败。
{ // 判断开始。
Debug.LogError($"Load resource failed: {key}"); // 打印加载失败日志。
return null; // 返回空引用。
} // 判断结束。
records[key] = new Record { Asset = asset, RefCount = 1 }; // 创建缓存记录并设置引用计数。
return asset; // 返回加载成功的资源。
} // 方法结束。
public void Release(string key) // 释放一个资源引用。
{ // 方法开始。
if (!records.TryGetValue(key, out Record record)) // 如果没有找到资源记录。
{ // 判断开始。
Debug.LogWarning($"Release missing resource: {key}"); // 输出重复释放或错误释放日志。
return; // 直接返回。
} // 判断结束。
record.RefCount--; // 引用计数减一。
if (record.RefCount > 0) return; // 仍然有人使用时不能卸载。
records.Remove(key); // 从缓存表中移除资源记录。
Debug.Log($"Resource ref count is zero: {key}"); // 输出资源可释放日志。
} // 方法结束。
} // 类结束。面试关键句
NOTE
资源加载和卸载要成对管理,不能只看 API。我的做法是统一走资源管理器,记录资源 Key、句柄、引用计数和依赖关系;关闭 UI 或切场景时先销毁实例,再释放引用,最后在合适时机清理无用资源,避免泄漏和峰值内存过高。
热更新方案
标准回答
热更新方案可以分三类:资源热更、配置热更、代码热更。 资源热更更新 Prefab、贴图、音频、特效,常用 AssetBundle 或 Addressables;配置热更更新数值、活动、掉落、关卡表;代码热更一般用 Lua、ILRuntime、HybridCLR 这类方案。
核心流程是:首包只放基础启动器和必要资源,启动时拉取远端 Manifest,和本地版本清单对比,算出需要下载的差异包;下载完成后做大小、Hash、签名校验;校验通过后原子切换版本;失败就保留旧版本并回滚。
底层原理
热更新的本质不是“下载文件覆盖本地”,而是管理一套版本化资源系统。 客户端本地有一份 local manifest,服务器有一份 remote manifest。每个资源记录 version、hash、size、dependencies、url。客户端通过 Hash 判断文件是否变化,只下载变化部分。
推荐流程
- 启动游戏,读取本地版本。
- 请求服务器热更配置和远端清单。
- 判断是否需要整包更新、强制更新或热更新。
- 对比本地和远端 Manifest。
- 下载差异资源和配置。
- 校验 Hash、大小、签名。
- 写入临时目录。
- 全部成功后切换到新版本目录。
- 失败则删除临时目录,继续使用旧版本。
- 支持灰度、回滚、断点续传和失败重试。
代码示例
c
using System.Collections.Generic; // 引入 Dictionary 容器。
using UnityEngine; // 引入 Unity 基础 API。
public sealed class PatchFileInfo // 定义补丁文件信息。
{ // 类开始。
public string Key; // 资源唯一 Key。
public string Hash; // 资源 Hash 值。
public long Size; // 资源文件大小。
public string Url; // 资源下载地址。
} // 类结束。
public static class HotUpdateDiff // 定义热更新差异计算工具。
{ // 工具类开始。
public static List<PatchFileInfo> GetNeedDownloadFiles( // 定义计算下载列表的方法。
Dictionary<string, PatchFileInfo> localManifest, // 传入本地清单。
Dictionary<string, PatchFileInfo> remoteManifest) // 传入远端清单。
{ // 方法开始。
List<PatchFileInfo> result = new List<PatchFileInfo>(); // 创建需要下载的文件列表。
foreach (KeyValuePair<string, PatchFileInfo> pair in remoteManifest) // 遍历远端清单每一项。
{ // 循环开始。
string key = pair.Key; // 取出资源 Key。
PatchFileInfo remoteFile = pair.Value; // 取出远端文件信息。
if (!localManifest.TryGetValue(key, out PatchFileInfo localFile)) // 判断本地是否没有该文件。
{ // 判断开始。
result.Add(remoteFile); // 本地缺失则加入下载列表。
continue; // 继续检查下一个文件。
} // 判断结束。
if (localFile.Hash != remoteFile.Hash) // 判断 Hash 是否不一致。
{ // 判断开始。
result.Add(remoteFile); // 文件变化则加入下载列表。
} // 判断结束。
} // 循环结束。
Debug.Log($"Need download count = {result.Count}"); // 输出需要下载的文件数量。
return result; // 返回差异文件列表。
} // 方法结束。
} // 工具类结束。工程重点
资源热更要处理依赖和重复打包。 配置热更要做字段校验、表间引用校验、版本兼容。 代码热更要注意平台限制,尤其 iOS 不能依赖 JIT,IL2CPP 下还要考虑 AOT、泛型、裁剪和补充元数据。 上线一定要做灰度发布、回滚开关、CDN 缓存刷新、下载失败重试和完整日志上报。
面试关键句
CAUTION
我热更新的重点不是下载,而是版本管理、校验、原子切换和回滚。我的方案是首包内置稳定更新器,启动后拉取 Manifest,对比 Hash 下载差异包,校验通过后切换版本;如果失败,就保留旧版本继续运行,线上通过灰度和回滚控制风险。
大量怪物和特效优化
标准回答
大量怪物和特效优化,我会先用工具定位瓶颈,再分别处理。怪物多通常压 CPU:AI、寻路、动画、物理、Update 调度会变重;特效多通常压 GPU:透明 Overdraw、粒子数量、材质切换、贴图带宽会变重。不能只说“用对象池”,对象池只能减少创建销毁和 GC,解决不了每帧更新和渲染成本。
大量怪物优化
怪物 AI 要分帧执行,不要所有怪物每帧都完整思考。近处怪物高频更新,远处怪物低频更新,屏外怪物只保留核心状态。 寻路不要每只怪物每帧算,可以分批、缓存路径、降低远处寻路频率。 动画要做 LOD,屏外或远处可以停 Animator,或者降低更新频率。 物理要减少 Rigidbody 和碰撞检测数量,合理设置 Layer Collision Matrix。 渲染上用 LOD、合批、GPU Instancing,远处怪物可以用低模或替代显示。
大量特效优化
特效首先控制同屏数量和生命周期,播放完必须回收到对象池。 透明特效要重点看 Overdraw,粒子贴图面积越大、叠得越多,移动端越贵。 减少材质种类和 Shader 变体,尽量共用材质,减少 SetPass。 粒子系统要限制最大粒子数、发射频率、碰撞模块、光照模块。 低端机要做特效分档,比如关闭高配 Bloom、阴影、复杂扭曲和大面积半透明。
代码示例:怪物分帧更新
c
using System.Collections.Generic; // 引入 List 容器。
using UnityEngine; // 引入 Unity 基础 API。
public interface IMonsterLogic // 定义怪物逻辑接口。
{ // 接口开始。
void LogicTick(float deltaTime); // 定义怪物逻辑更新方法。
} // 接口结束。
public sealed class MonsterUpdateScheduler : MonoBehaviour // 定义怪物分帧更新调度器。
{ // 类开始。
private readonly List<IMonsterLogic> monsters = new List<IMonsterLogic>(); // 保存所有怪物逻辑对象。
[SerializeField] private int updateCountPerFrame = 20; // 每帧最多更新的怪物数量。
private int cursor; // 保存当前更新游标。
public void AddMonster(IMonsterLogic monster) // 添加怪物到调度器。
{ // 方法开始。
if (monster == null) return; // 空对象不加入。
if (monsters.Contains(monster)) return; // 避免重复加入。
monsters.Add(monster); // 加入怪物列表。
} // 方法结束。
public void RemoveMonster(IMonsterLogic monster) // 从调度器移除怪物。
{ // 方法开始。
if (monster == null) return; // 空对象不处理。
monsters.Remove(monster); // 从列表中移除怪物。
if (cursor >= monsters.Count) cursor = 0; // 防止游标越界。
} // 方法结束。
private void Update() // Unity 每帧调用。
{ // 方法开始。
if (monsters.Count == 0) return; // 没有怪物时直接返回。
int count = Mathf.Min(updateCountPerFrame, monsters.Count); // 计算本帧实际更新数量。
for (int i = 0; i < count; i++) // 循环更新一部分怪物。
{ // 循环开始。
monsters[cursor].LogicTick(Time.deltaTime); // 更新当前游标指向的怪物。
cursor = (cursor + 1) % monsters.Count; // 游标移动到下一个怪物。
} // 循环结束。
} // 方法结束。
} // 类结束。面试关键句
WARNING
我会说:大量怪物和特效优化要先区分 CPU、GPU、GC 哪个是瓶颈。怪物侧我会做对象池、AI 分帧、动画 LOD、寻路降频、物理裁剪;特效侧我会做对象池、粒子预算、材质合并、Overdraw 控制和低端机降级。最后用 Profiler、Frame Debugger 和真机数据对比优化前后,而不是凭感觉判断。
C++ 虚函数和智能指针
标准回答
C++ 虚函数解决的是“运行时多态”:用基类指针或引用调用函数时,真正执行哪个函数由对象的真实类型决定。智能指针解决的是“资源生命周期”:把堆对象的释放绑定到对象作用域,减少忘记 delete、异常路径泄漏等问题。
虚函数底层
有虚函数的类,对象里通常会多一个 vptr,也就是虚表指针。vptr 指向该类型的虚函数表 vtable。 当你用基类指针调用虚函数时,程序不是直接跳到固定函数,而是通过对象里的 vptr 找到虚表,再找到真实函数地址。
c
#include <iostream> // 引入标准输出库。
#include <memory> // 引入智能指针库。
class Actor // 定义基类 Actor。
{ // 基类开始。
public: // 公开区域开始。
virtual ~Actor() = default; // 定义虚析构,保证基类指针删除派生对象安全。
virtual void Update() // 定义虚函数,允许派生类重写。
{ // 函数开始。
std::cout << "Actor Update\n"; // 输出基类更新逻辑。
} // 函数结束。
}; // 基类结束。
class Monster : public Actor // 定义派生类 Monster。
{ // 派生类开始。
public: // 公开区域开始。
void Update() override // 重写基类虚函数。
{ // 函数开始。
std::cout << "Monster Update\n"; // 输出怪物更新逻辑。
} // 函数结束。
}; // 派生类结束。
int main() // 程序入口。
{ // 主函数开始。
std::unique_ptr<Actor> actor = std::make_unique<Monster>(); // 用基类智能指针持有派生对象。
actor->Update(); // 通过虚函数调用 Monster 的实现。
return 0; // 返回 0 表示程序正常结束。
} // 主函数结束。为什么析构函数常写 virtual
如果基类析构不是虚的,用 Base* 删除 Derived 对象时,可能只调用基类析构,派生类资源没有释放。 所以只要一个类准备被当作多态基类使用,析构函数通常应该写成 virtual。
智能指针区别
unique_ptr:独占所有权,不能拷贝,只能移动。适合“这个对象只有一个明确拥有者”的场景。 shared_ptr:共享所有权,通过控制块保存引用计数。最后一个 shared_ptr 销毁时释放对象。 weak_ptr:不拥有对象,不增加强引用计数。主要用来观察 shared_ptr 管理的对象,并解决循环引用。
循环引用问题
两个对象互相用 shared_ptr 指向对方,引用计数永远不归零,就会泄漏。解决方式是把其中一边改成 weak_ptr。
c
#include <memory> // 引入智能指针库。
struct Parent; // 前向声明 Parent。
struct Child // 定义 Child 结构体。
{ // Child 开始。
std::weak_ptr<Parent> parent; // 用 weak_ptr 指向父对象,避免循环引用。
}; // Child 结束。
struct Parent // 定义 Parent 结构体。
{ // Parent 开始。
std::shared_ptr<Child> child; // 用 shared_ptr 持有子对象。
}; // Parent 结束。面试关键句
IMPORTANT
我会说:虚函数管的是行为多态,底层通常靠 vptr 和 vtable 做动态绑定;智能指针管的是资源生命周期,底层靠 RAII 和所有权模型。实际工程里,能用 unique_ptr 就不要滥用 shared_ptr,需要共享所有权才用 shared_ptr,只观察对象或打破循环引用时用 weak_ptr。
TCP/UDP 和网络同步
标准回答
TCP/UDP 是传输层协议,网络同步是游戏业务层方案。 TCP 解决“可靠、有序传输”,UDP 解决“低延迟传输”,而帧同步、状态同步解决的是“多个客户端如何看到接近一致的游戏世界”。
TCP 和 UDP 区别
TCP:可靠、有序、面向连接。丢包会重传,数据按顺序到达,但会有队头阻塞。适合登录、聊天、支付、背包、邮件、商城这类不能丢、但不要求极低延迟的消息。
UDP:无连接、不保证可靠、不保证顺序,但延迟低。适合移动、朝向、位置快照、技能表现、实时战斗状态。旧的位置包即使丢了也没关系,因为后面的新状态更重要。
为什么游戏常用 UDP
实时游戏更怕“卡住”等旧包,不一定怕“丢一个旧包”。 TCP 如果一个包丢了,后面的包即使已经到了,也要等前面的包重传完成才能交给应用层,这叫队头阻塞。动作游戏、射击游戏、实时同步里,这会直接影响手感。 所以常见做法是 UDP 承载实时数据,再自己实现关键消息的可靠机制,比如 seq、ack、重传、去重、超时、心跳。
网络同步方案
帧同步:同步玩家输入,所有客户端按同一帧执行同一份逻辑。优点是流量小、回放方便;缺点是要求确定性强,浮点误差、随机数、不同平台结果都可能导致不同步。适合 RTS、棋牌、部分格斗类游戏。
状态同步:服务端计算权威状态,定期给客户端发快照。客户端用预测、插值、外推让画面平滑。优点是服务端权威、反作弊强、适合 MMO 和动作游戏;缺点是流量更大,客户端要处理延迟和校正。
简单消息头示例
c
using System; // 引入基础类型命名空间。
public struct NetPacketHeader // 定义网络消息头。
{ // 结构体开始。
public ushort MessageId; // 消息协议号,用来分发到业务模块。
public uint Sequence; // 当前消息序号,用来处理乱序和去重。
public uint Ack; // 最近收到的对端序号,用来确认可靠消息。
public uint Tick; // 逻辑帧或服务器帧,用来做时间线对齐。
public byte Flags; // 消息标记,比如可靠、压缩、加密。
} // 结构体结束。
public static class SnapshotFilter // 定义状态快照过滤工具。
{ // 工具类开始。
private static uint lastSnapshotTick; // 保存已经应用过的最新快照帧。
public static bool CanApplySnapshot(uint serverTick) // 判断快照是否可以应用。
{ // 方法开始。
if (serverTick <= lastSnapshotTick) return false; // 丢弃旧快照或重复快照。
lastSnapshotTick = serverTick; // 记录最新应用的服务器帧。
return true; // 允许客户端应用该快照。
} // 方法结束。
} // 工具类结束。Unity/游戏项目里怎么说
我会说:登录、支付、聊天这类消息我倾向用 TCP 或可靠通道;移动、朝向、战斗快照这类实时消息更适合 UDP。UDP 上层要自己处理包序号、ACK、重传、去重、心跳和超时。同步方案上,如果是 RTS 可以考虑帧同步;如果是 MMO、动作游戏,我更倾向服务端权威的状态同步,再配合客户端预测、插值和服务端校正。
面试关键句
TIP
TCP/UDP 解决的是“怎么传”,帧同步/状态同步解决的是“怎么一致”。实时游戏通常不是简单选 TCP 或 UDP,而是按消息类型分通道:关键消息可靠,实时状态低延迟,旧包可丢,新状态优先。
图形学 MVP 和 DrawCall
标准回答
MVP 和 DrawCall 是两个层面的概念。 MVP 讲的是“顶点坐标怎么变换”:模型空间经过 Model、View、Projection 矩阵,最终进入裁剪空间,再经过透视除法和视口映射到屏幕。 DrawCall 讲的是“CPU 怎么让 GPU 画一次”:CPU 设置 Shader、材质、纹理、矩阵、网格和渲染状态,然后提交一次绘制命令给 GPU。
MVP 是什么
M 是 Model 矩阵,把模型局部坐标变到世界坐标。 V 是 View 矩阵,把世界坐标变到相机空间。 P 是 Projection 矩阵,把相机空间变到裁剪空间。
公式可以记成:
c
clipPos = Projection * View * Model * localPos在 Unity Shader 里,常见写法是 UnityObjectToClipPos(v.vertex),本质就是把模型顶点变到裁剪空间。
DrawCall 是什么
DrawCall 是 CPU 向 GPU 提交的一次绘制命令。 它通常包含:画哪个 Mesh、用哪个 Material、用哪个 Shader Pass、绑定哪些贴图和常量、当前渲染状态是什么。 DrawCall 多,通常先增加 CPU 提交成本;但是否 GPU 慢,还要看 Shader 复杂度、像素数量、Overdraw、带宽和后处理。
两者关系
每次 DrawCall 里,CPU 会把当前对象的矩阵、材质参数等传给 GPU。 GPU 在顶点着色器里用 MVP 矩阵处理顶点。 所以可以理解为:DrawCall 是“提交一次绘制”,MVP 是“这次绘制里顶点怎么从模型空间走到屏幕”。
Shader 示例
c
Shader "Interview/MVPUnlit" // 定义一个用于面试演示的 Unlit Shader。
{ // Shader 开始。
SubShader // 定义子着色器。
{ // SubShader 开始。
Pass // 定义一次渲染 Pass。
{ // Pass 开始。
CGPROGRAM // 开始 CG/HLSL 代码块。
#pragma vertex vert // 指定顶点着色器函数。
#pragma fragment frag // 指定片元着色器函数。
#include "UnityCG.cginc" // 引入 Unity 常用矩阵和工具函数。
struct appdata // 定义输入顶点数据结构。
{ // appdata 开始。
float4 vertex : POSITION; // 接收模型空间顶点坐标。
}; // appdata 结束。
struct v2f // 定义顶点到片元的数据结构。
{ // v2f 开始。
float4 pos : SV_POSITION; // 保存裁剪空间坐标。
}; // v2f 结束。
v2f vert(appdata v) // 定义顶点着色器。
{ // 顶点函数开始。
v2f o; // 创建输出结构。
o.pos = UnityObjectToClipPos(v.vertex); // 使用 MVP 把模型空间顶点变到裁剪空间。
return o; // 返回顶点处理结果。
} // 顶点函数结束。
fixed4 frag(v2f i) : SV_Target // 定义片元着色器。
{ // 片元函数开始。
return fixed4(1, 1, 1, 1); // 输出纯白颜色。
} // 片元函数结束。
ENDCG // 结束 CG/HLSL 代码块。
} // Pass 结束。
} // SubShader 结束。
} // Shader 结束。Unity 工程实践
看 MVP 问题时,我会重点检查坐标空间是否用错,比如把世界坐标当局部坐标、法线没正确变换、相机空间和裁剪空间混淆。 看 DrawCall 问题时,我会用 Frame Debugger 看每次绘制为什么被拆开,用 Profiler 看 Render Thread 和 Main Thread,再考虑合批、GPU Instancing、SRP Batcher、图集、减少材质切换。
面试关键句
NOTE
MVP 管的是一个顶点如何从模型空间变到裁剪空间,DrawCall 管的是 CPU 如何提交一次绘制命令。DrawCall 多不等于一定 GPU 慢,它更常先体现为 CPU 提交压力;真正优化要结合 Frame Debugger、Profiler、Overdraw 和 Shader 成本一起判断。
一道树/图算法题
题目:岛屿数量
给你一个二维网格,'1' 表示陆地,'0' 表示水。上下左右相连的陆地算同一个岛屿,求一共有多少个岛屿。
标准思路
这题本质是图遍历。每个格子是一个节点,上下左右相邻就是边。 从左到右、从上到下扫描整个网格,遇到一个没有访问过的陆地,就说明发现了一座新岛,答案加一。然后用 BFS 或 DFS 把这座岛所有相连陆地都标记掉,避免后面重复计数。
C# 代码:BFS 写法
c
using System.Collections.Generic; // 引入 Queue 队列容器。
public class Solution // 定义 LeetCode 风格解题类。
{ // 类开始。
public int NumIslands(char[][] grid) // 定义计算岛屿数量的方法。
{ // 方法开始。
if (grid == null || grid.Length == 0) return 0; // 空网格直接返回 0。
int rows = grid.Length; // 获取网格行数。
int cols = grid[0].Length; // 获取网格列数。
bool[,] visited = new bool[rows, cols]; // 创建访问标记数组,防止重复访问。
int count = 0; // 记录岛屿数量。
int[] dr = new int[] { -1, 1, 0, 0 }; // 定义上下左右的行方向偏移。
int[] dc = new int[] { 0, 0, -1, 1 }; // 定义上下左右的列方向偏移。
for (int r = 0; r < rows; r++) // 遍历每一行。
{ // 外层循环开始。
for (int c = 0; c < cols; c++) // 遍历每一列。
{ // 内层循环开始。
if (grid[r][c] != '1' || visited[r, c]) continue; // 跳过水域和已经访问过的陆地。
count++; // 遇到新的未访问陆地,岛屿数量加一。
Queue<(int row, int col)> queue = new Queue<(int row, int col)>(); // 创建 BFS 队列。
queue.Enqueue((r, c)); // 把当前陆地加入队列。
visited[r, c] = true; // 标记当前陆地已经访问。
while (queue.Count > 0) // 队列不为空时继续扩散。
{ // while 循环开始。
(int row, int col) current = queue.Dequeue(); // 取出当前格子。
for (int i = 0; i < 4; i++) // 枚举上下左右四个方向。
{ // 方向循环开始。
int nextRow = current.row + dr[i]; // 计算相邻格子的行号。
int nextCol = current.col + dc[i]; // 计算相邻格子的列号。
if (nextRow < 0 || nextRow >= rows) continue; // 行号越界就跳过。
if (nextCol < 0 || nextCol >= cols) continue; // 列号越界就跳过。
if (visited[nextRow, nextCol]) continue; // 已经访问过就跳过。
if (grid[nextRow][nextCol] != '1') continue; // 不是陆地就跳过。
visited[nextRow, nextCol] = true; // 标记相邻陆地已经访问。
queue.Enqueue((nextRow, nextCol)); // 把相邻陆地加入队列继续扩散。
} // 方向循环结束。
} // while 循环结束。
} // 内层循环结束。
} // 外层循环结束。
return count; // 返回岛屿总数量。
} // 方法结束。
} // 类结束。复杂度
时间复杂度:O(m * n),每个格子最多访问一次。 空间复杂度:O(m * n),visited 和 BFS 队列最坏可能存很多格子。
面试关键句
NOTE
这题我会先把二维网格抽象成图:格子是节点,相邻陆地是边。扫描到新的未访问陆地时,说明发现新连通块;然后用 BFS/DFS 把这个连通块全部标记。树其实是特殊的图,而普通图一定要注意 visited,否则会重复访问甚至死循环。
一个系统设计题:技能/Buff/背包
标准回答
这类系统设计题,我会用一个完整链路讲:背包提供资源,技能消耗资源并产生效果,Buff 改变角色状态,表现层监听事件刷新 UI、动画、特效。核心不是把三个系统写在一起,而是把它们拆成清晰边界:配置层、运行时数据层、逻辑服务层、事件表现层。
模块拆分
技能系统:负责释放条件、CD、消耗、目标筛选、命中判定、效果触发。 Buff 系统:负责 Buff 添加、叠层、持续时间、周期 Tick、移除、属性修改。 背包系统:负责物品堆叠、容量、获得、消耗、装备、排序、存档。 事件系统:负责通知 UI、红点、任务、音效、特效,不让表现层直接改业务数据。
核心流程
玩家点击技能。 技能系统检查 CD、蓝量、距离、目标是否合法。 如果技能需要道具,调用背包系统尝试扣除。 扣除成功后,技能进入 CD,并生成伤害、治疗或 Buff 结果。 Buff 系统把 Buff 加到目标身上,按规则处理叠层和持续时间。 事件系统广播 SkillCast、ItemChanged、BuffAdded,UI 和表现层刷新。
代码示例
c
using UnityEngine; // 引入 Unity 日志 API。
public sealed class SkillConfig // 定义技能配置。
{ // 技能配置类开始。
public int Id; // 技能 ID。
public int CostItemId; // 技能消耗的道具 ID。
public int CostCount; // 技能消耗的道具数量。
public int AddBuffId; // 技能命中后添加的 Buff ID。
} // 技能配置类结束。
public sealed class BagService // 定义背包服务。
{ // 背包服务类开始。
public bool ConsumeItem(int itemId, int count) // 尝试消耗道具。
{ // 方法开始。
Debug.Log($"Consume item {itemId}, count {count}"); // 输出消耗日志。
return true; // 示例中默认消耗成功。
} // 方法结束。
} // 背包服务类结束。
public sealed class BuffService // 定义 Buff 服务。
{ // Buff 服务类开始。
public void AddBuff(int targetId, int buffId) // 给目标添加 Buff。
{ // 方法开始。
Debug.Log($"Add buff {buffId} to target {targetId}"); // 输出添加 Buff 日志。
} // 方法结束。
} // Buff 服务类结束。
public sealed class SkillService // 定义技能服务。
{ // 技能服务类开始。
private readonly BagService bagService; // 保存背包服务引用。
private readonly BuffService buffService; // 保存 Buff 服务引用。
public SkillService(BagService bag, BuffService buff) // 构造技能服务。
{ // 构造函数开始。
bagService = bag; // 注入背包服务。
buffService = buff; // 注入 Buff 服务。
} // 构造函数结束。
public bool CastSkill(SkillConfig config, int casterId, int targetId) // 释放技能。
{ // 方法开始。
if (config == null) return false; // 技能配置为空则释放失败。
bool paid = bagService.ConsumeItem(config.CostItemId, config.CostCount); // 尝试扣除技能消耗。
if (!paid) return false; // 扣除失败则不能释放技能。
buffService.AddBuff(targetId, config.AddBuffId); // 给目标添加技能产生的 Buff。
Debug.Log($"Caster {casterId} cast skill {config.Id}"); // 输出技能释放日志。
return true; // 返回释放成功。
} // 方法结束。
} // 技能服务类结束。面试关键句
IMPORTANT
技能、Buff、背包不能互相写死调用。我的设计是配置驱动规则,运行时保存实例状态,服务层处理业务结果,表现层只监听事件。一次技能释放可以触发背包扣道具、Buff 添加、任务进度、UI 刷新,但这些都通过事件和接口连接,保证系统可扩展、可测试,也方便服务端校验和回放。
如果上线出问题怎么排查
标准回答
CAUTION
上线出问题,我会先止血,再定位。第一步不是马上改代码,而是确认影响面:是全量用户、某个版本、某个渠道、某个机型、某个区服,还是某个资源版本。确认影响面后,先用回滚、关闭功能开关、停止灰度、切回旧配置或旧 Manifest 控制影响。
排查流程
- 确认现象:崩溃、卡顿、登录失败、资源下载失败、支付不到账、战斗状态错乱。
- 确认范围:版本、渠道、机型、系统、区服、资源版本、配置版本。
- 先止血:关开关、停灰度、回滚热更、切旧配置、下架问题资源。
- 收集证据:客户端日志、崩溃堆栈、服务端日志、traceId、玩家操作路径。
- 分类定位:CPU、GPU、内存、IO、网络、资源、配置、协议、服务端。
- 修复验证:测试环境复现,小流量灰度,指标恢复后再扩大范围。
- 复盘防复发:补监控、补自动化测试、补发布门禁、补回滚工具。
Unity 客户端重点
崩溃看堆栈、机型、系统版本、Unity 版本、资源版本。 卡顿看 Profiler、帧耗时、GC、加载耗时、资源解压、Shader 预热。 资源问题看 Manifest、Hash、CDN、Bundle 依赖、Addressables Catalog。 配置问题看空字段、表间引用、版本兼容、是否有回滚清单。 网络问题看 RTT、丢包、超时、重连、协议序号和服务端错误码。
代码示例:线上问题上下文上报
c
using UnityEngine; // 引入 Unity 基础 API。
public static class OnlineIssueReporter // 定义线上问题上报工具。
{ // 工具类开始。
public static void ReportError(string module, string message, string traceId) // 上报错误日志。
{ // 方法开始。
string appVersion = Application.version; // 获取客户端版本号。
string device = SystemInfo.deviceModel; // 获取设备型号。
string system = SystemInfo.operatingSystem; // 获取操作系统信息。
string scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name; // 获取当前场景名。
int frame = Time.frameCount; // 获取当前帧号。
float time = Time.realtimeSinceStartup; // 获取游戏启动后的真实时间。
string log = $"module={module}, message={message}, traceId={traceId}, version={appVersion}, device={device}, system={system}, scene={scene}, frame={frame}, time={time}"; // 拼接结构化日志。
Debug.LogError(log); // 输出错误日志。
} // 方法结束。
} // 工具类结束。面试关键句
IMPORTANT
线上问题处理要先控制影响面,再用日志和监控定位根因。我的排查顺序是版本、渠道、机型、资源和配置版本先收敛范围,再结合客户端日志、服务端 trace、崩溃堆栈和性能指标定位。修复后必须灰度验证,并把这次问题沉淀成监控、自动化检查或发布门禁。