Unity / C# 面试题复盘归档 5
1. List 初始化容量与扩容效率
题目
以下代码,谁的效率更高?为什么?
// 代码1
List<int> list = new List<int>();
for (int i = 0; i < 50; i++)
{
list.Add(i);
}
// 代码2
List<int> list2 = new List<int>(50);
for (int i = 0; i < 50; i++)
{
list2.Add(i);
}
标准参考答案
代码 2 效率更高。
因为 List<T> 底层是数组,new List<int>() 默认没有提前指定容量,在不断 Add 的过程中,如果容量不够,就会触发扩容。扩容时需要重新申请一个更大的数组,并把旧数组中的元素复制过去,这会带来额外的内存分配和拷贝开销,也会产生旧数组对象,增加 GC 压力。
而 new List<int>(50) 是预先指定容量 Capacity,不是指定元素数量 Count。它提前分配了可以容纳 50 个元素的内部数组,所以后续添加 50 个元素时不会触发扩容。
不过这个例子只有 50 个 int,实际性能差距不会很大。但在 Unity 中,如果数据量比较大,或者这段逻辑频繁执行,比如在战斗逻辑、资源加载、对象池初始化中使用,提前设置容量是更好的性能习惯。
2. 数组和链表的区别
题目
数组和链表的区别是什么?
标准参考答案
数组和链表的主要区别在于存储结构。
数组是一段连续的内存空间,可以通过下标直接访问元素,所以按下标访问的时间复杂度是 O(1)。但是如果要查找某个具体值或对象,仍然需要从头遍历,复杂度是 O(n)。
链表是由一个个节点组成的,节点在内存中可以不连续,每个节点通常保存数据和指向下一个节点的引用。链表不支持高效的随机访问,如果要访问第几个元素,需要从头遍历,所以访问复杂度是 O(n)。
在插入和删除方面,如果已经拿到了目标节点,链表可以做到 O(1) 插入或删除;而数组在中间插入或删除时,需要移动后面的元素,复杂度通常是 O(n)。
在实际 Unity 开发中,数组和 List<T> 更常用,因为它们连续存储,遍历效率高,缓存友好。链表适合频繁插入删除、但不太需要随机访问的场景,不过在游戏业务中使用频率相对较低。
3. Action、Func、UnityAction 和 UnityEvent 的区别
题目
C# 中的 Action 和 Func 是什么?
Unity 中的 UnityAction 是什么?
它们有什么区别?
标准参考答案
Action 和 Func 都是 C# 内置的泛型委托类型。
Action 表示没有返回值的方法,可以没有参数,也可以有多个参数。例如:
Action action = () =>
{
Debug.Log("Hello");
};
Action<int> onScoreChanged = score =>
{
Debug.Log(score);
};
Func 表示有返回值的方法,它的最后一个泛型参数代表返回值类型。例如:
Func<int, string> func;
表示传入一个 int,返回一个 string。
UnityAction 是 Unity 定义的委托类型,定义在 UnityEngine.Events 命名空间下,本质上和 Action 类似,常用于 Unity 的事件系统中,比如:
button.onClick.AddListener(OnClick);
这里的 AddListener 接收的通常就是 UnityAction。
需要注意的是,UnityAction 本身不能直接在 Inspector 中拖拽绑定。真正可以在 Inspector 中配置监听函数的是 UnityEvent,因为它是 Unity 可序列化的事件类型。
所以总结来说:Action 和 Func 是 C# 委托;UnityAction 是 Unity 的委托;UnityEvent 才是可以暴露到 Inspector 中配置的 Unity 事件。
4. struct 值传递与引用类型字段
题目
请问最终的打印结果是什么?
public struct Record
{
public int id;
public string name;
public int[] children;
}
public void DoSomething(Record record)
{
record.id = 6;
record.name = "Bob";
record.children[0] = 7;
}
var record = new Record();
record.name = "Alice";
record.children = new int[] { 1, 2, 3 };
DoSomething(record);
Debug.Log(string.Format("{0}-{1}-{2}", record.id, record.name, record.children[0]));
标准参考答案
最终输出是:
0-Alice-7
因为 Record 是结构体,属于值类型。调用:
DoSomething(record);
时,默认是值传递,会把外部的 record 复制一份传入方法。
所以方法内部执行:
record.id = 6;
record.name = "Bob";
修改的是副本里的字段,不会影响外部原始的 record。因此外部的 id 仍然是默认值 0,name 仍然是 "Alice"。
但是 children 是数组,数组是引用类型。结构体复制时,复制的是数组引用,原结构体和副本结构体中的 children 指向同一个数组对象。
所以方法内部执行:
record.children[0] = 7;
修改的是同一个数组对象里的元素,外部也能看到变化。
因此最终结果是:
0-Alice-7
如果方法参数改成 ref Record record,那么 id 和 name 的修改也会影响外部,输出会变成:
6-Bob-7
5. 网络游戏中数据传输的基本流程
题目
网络游戏开发中,网络传输数据的基本流程是什么?
标准参考答案
网络游戏中,一次网络数据传输通常从业务层产生一条消息开始。比如玩家移动、释放技能、购买道具,客户端会先把这些业务数据封装成协议对象。
然后将协议对象序列化成字节流,比如使用 JSON、Protobuf、MessagePack 或自定义二进制格式。序列化之后,一般还会进行协议封包,比如加上消息长度、协议号、序列号等协议头。必要时还会做压缩和加密。
接着通过具体的网络协议发送出去。实时性要求不高的数据可以走 HTTP 或 TCP,例如登录、聊天、背包、商城、排行榜。实时性要求较高的数据可能会走 UDP,例如位置同步、动作同步、帧同步等。
服务端收到数据后,会进行拆包、解密、解压、反序列化,然后根据协议号分发到对应的业务逻辑处理。处理完成后,再把结果按同样的流程序列化、封包并返回给客户端。
客户端收到服务端消息后,也要先解包和反序列化,再根据协议号分发到对应系统,比如角色系统、战斗系统、UI 系统等。
在 Unity 中还要注意,如果网络接收在子线程中执行,不能直接操作 GameObject、UI 或动画组件,通常需要把消息派发回主线程处理。
简洁口述版:
网络传输的基本流程是:业务层生成协议数据,序列化成字节流,加协议头封包,必要时压缩和加密,然后通过 TCP、UDP 或 HTTP 发送到服务器。服务器收到后解包、反序列化、处理业务,再把响应按相同流程返回。客户端收到后再解包、反序列化,根据协议号分发给对应模块处理。Unity 中如果网络线程收到消息,需要切回主线程更新对象和 UI。
6. 四元数相乘与四元数旋转向量
题目
两个四元数相乘有什么作用?
四元数乘以向量有什么作用?
标准参考答案
两个四元数相乘表示旋转的组合,也就是把两个旋转合成为一个最终旋转。需要注意的是,四元数乘法不满足交换律,q1 * q2 和 q2 * q1 的结果通常不同。
在 Unity 中,Quaternion result = qA * qB 可以理解为在 qA 的基础上叠加 qB 这个旋转。如果再作用到向量上,result * v 等价于 qA * (qB * v),所以乘法顺序需要特别注意。
四元数乘以向量的作用,是用这个四元数表示的旋转去旋转这个向量。
例如:
Vector3 dir = Quaternion.Euler(0, 90, 0) * Vector3.forward;
表示把 Vector3.forward 绕 Y 轴旋转 90 度,结果大致会变成 Vector3.right。
Unity 中常见用途包括:
Vector3 forward = transform.rotation * Vector3.forward;
这可以得到物体当前朝向的世界前方向,含义接近:
Vector3 forward = transform.forward;
也可以用于本地偏移转世界偏移,例如:
Vector3 localOffset = new Vector3(0, 0, 2);
Vector3 worldOffset = transform.rotation * localOffset;
Vector3 targetPos = transform.position + worldOffset;
这个在角色移动、子弹发射、技能方向、摄像机偏移、本地坐标转世界坐标时都很常用。
简洁口述版:
两个四元数相乘表示组合旋转,可以把多个旋转合成一个最终旋转,但顺序很重要,因为四元数乘法不满足交换律。四元数乘以向量表示用这个旋转去旋转该向量。比如 transform.rotation * Vector3.forward 可以得到物体当前朝向的世界方向,常用于子弹发射方向、角色朝向、技能释放方向,以及本地偏移转世界偏移。
7. 视锥体剔除与 DrawCall
题目
图中的小球是否被渲染了?是否会产生 DrawCall?
标准参考答案
如果小球在当前相机的视锥体外,那么它不会被当前相机渲染,也不会因为当前相机产生 DrawCall。
Unity 在渲染前会做视锥体剔除,也就是 Frustum Culling。只有 Renderer 的包围盒进入相机视锥范围,才有可能进入后续渲染流程并提交 DrawCall。视锥体外的物体会被剔除,不会提交给 GPU 绘制。
所以对于当前 Game Camera 来说:
相机看不到
=> 被视锥体剔除
=> 不提交渲染
=> 不产生当前相机的 DrawCall
但要注意,如果场景里还有其他相机能看到它,或者它参与阴影、反射探针、Scene View 编辑器显示等其他渲染过程,那么它可能会在其他 Pass 中产生 DrawCall。单看当前 Game Camera,它不会产生 DrawCall。
简洁口述版:
当前相机看不到这个小球,所以它不会被当前相机渲染,也不会产生当前相机的 DrawCall。因为 Unity 默认会做视锥体剔除,视锥体外的 Renderer 不会进入实际绘制提交阶段。但如果有其他相机、阴影 Pass 或反射探针需要绘制它,那可能会在其他渲染过程中产生 DrawCall。
8. 遮挡剔除、深度测试与 DrawCall
题目
在没有使用遮挡剔除的情况下,图中 A 和 B 都是默认 Standard 材质。图中的小球最终是否会被渲染,是否会产生 DrawCall?
标准参考答案
如果小球在相机视锥体内,但被前面的方块挡住,并且没有开启遮挡剔除,那么小球仍然会进入渲染流程,并且会产生 DrawCall。
没有开启 Occlusion Culling 的情况下,Unity 不会因为“小球被方块挡住”就在 CPU 侧提前跳过它。只要它在相机视锥体内,并且 Renderer 满足渲染条件,它通常就会被提交给 GPU 绘制。
但是它最终不一定会显示在屏幕上。因为 A 和 B 都是默认 Standard 不透明材质,会进行深度测试。如果小球完全在方块后面,那么它的像素可能会因为深度测试失败而无法写入屏幕颜色缓冲,所以最终画面中看不到它。
所以更准确地说:
小球会被提交渲染
会产生 DrawCall
但如果完全被遮挡,最终画面中可能看不到它
遮挡剔除优化的是物体是否在 CPU 侧提前被剔除,减少不必要的渲染提交;深度测试解决的是 GPU 像素层面谁挡住谁的问题。
简洁口述版:
没有开启遮挡剔除时,小球虽然被方块挡住,但只要它还在相机视锥体内,就仍然会被提交渲染,也会产生 DrawCall。不过由于默认 Standard 材质是不透明材质,最终会通过深度测试决定像素是否写入屏幕。如果小球完全在方块后面,最终画面中看不到它,但这不代表它没有产生 DrawCall。
9. Windows / Android 平台下不使用第三方方案实现热更新
题目
如果不考虑 iOS 平台,只在 Windows 和 Android 平台上发布游戏,如何在不使用第三方热更新方案的前提下实现热更新功能?
标准参考答案
不使用第三方热更新方案时,可以把热更新分成资源热更新和代码热更新两部分。
资源热更新可以使用 AssetBundle 或 Unity 官方 Addressables。启动游戏时先从服务器拉取版本清单,对比本地资源版本号、Hash 或 CRC,找出需要更新的 AssetBundle、配置表、图片、音频等文件,然后下载到 persistentDataPath。下载完成后进行完整性校验,校验通过后更新本地版本清单。后续加载资源时优先从热更目录加载,如果热更目录没有,再回退到包内默认资源。
代码热更新方面,如果只考虑 Windows 和 Android,并且使用的是支持动态加载托管程序集的 Mono 后端,可以把热更业务逻辑单独拆成一个 C# 工程,编译成 DLL。客户端启动时检查 DLL 版本,如果有更新就下载新的 DLL,然后用 Assembly.Load(byte[]) 动态加载,通过接口、反射或约定入口类调用热更逻辑。
例如主工程保留稳定接口:
public interface IHotfixEntry
{
void Start();
void Update();
void Dispose();
}
热更 DLL 中实现入口:
public class HotfixEntry : IHotfixEntry
{
public void Start()
{
UnityEngine.Debug.Log("Hotfix Start");
}
public void Update()
{
}
public void Dispose()
{
}
}
主工程加载 DLL 后,通过反射创建入口对象:
Assembly assembly = Assembly.Load(dllBytes);
Type type = assembly.GetType("HotfixEntry");
IHotfixEntry entry = Activator.CreateInstance(type) as IHotfixEntry;
entry.Start();
但如果 Android 使用 IL2CPP,由于是 AOT 编译模式,不能简单依赖运行时加载新的 C# DLL 来执行新逻辑。在不使用 HybridCLR、ILRuntime、Lua 等第三方方案的情况下,代码热更新能力会比较有限,通常只能做资源热更、配置热更,或者通过服务器下发配置来驱动包内已有逻辑。
简洁口述版:
可以分两层做。资源热更用 AssetBundle 或 Addressables,启动时拉版本清单,对比 Hash,下载变更资源到 persistentDataPath,后续加载时优先加载本地热更资源。代码热更的话,在不考虑 iOS,并且 Windows / Android 使用 Mono 后端的情况下,可以把热更逻辑单独编译成 DLL,客户端下载后用 Assembly.Load 动态加载,通过接口或反射调用入口类。但如果 Android 用 IL2CPP,因为是 AOT 模式,不能简单加载新 DLL 执行代码;这时不使用第三方方案就基本只能做资源和配置层面的热更新。
10. Unity 中排查 Android 真机问题
题目
Unity 中如何调试排查 Android 上运行的项目问题?
标准参考答案
Unity 排查 Android 真机问题时,我一般会先打 Development Build,并根据需要开启 Script Debugging、Autoconnect Profiler 或 Wait For Managed Debugger。
如果是逻辑报错、资源加载失败或启动异常,先用 Android Logcat 或 adb logcat 看真机日志,结合 Unity 的 Debug.Log、异常堆栈、资源加载日志来定位问题。
例如可以通过:
adb logcat
查看设备日志,也可以使用 Unity 的 Android Logcat 工具窗口。
如果是 C# 逻辑问题,可以开启 Script Debugging,然后用 Visual Studio 或 Rider Attach 到 Android Player 进行断点调试,查看变量值、调用栈和流程状态。
如果是性能问题,比如卡顿、掉帧、发热、内存上涨,就用 Unity Profiler 连接真机,重点看 CPU、Rendering、Memory、GC Alloc、Timeline 等模块,判断是脚本、渲染、资源、UI、物理还是内存问题。
如果是闪退、黑屏或 ANR,还要看 adb logcat 中的 Java Exception、Native Crash、ANR 信息,并结合设备型号、Android 版本、CPU 架构、权限配置、AndroidManifest、Gradle 配置、SDK 接入、IL2CPP 符号文件等一起分析。
如果接入了登录、支付、广告等 Android SDK,还要重点检查 SDK 初始化时机、Activity 生命周期、权限、包名、签名、混淆规则和回调是否正常。
简洁口述版:
不是只打 APK 实机跑一下。一般会打 Development Build,开启 Script Debugging 和 Profiler 支持。普通报错用 Android Logcat 或 adb logcat 看日志;逻辑问题用 IDE Attach 到 Android Player 断点调试;性能问题用 Unity Profiler 连真机分析 CPU、渲染、内存和 GC;如果是闪退、黑屏、ANR,还要看 Android 崩溃堆栈、权限、SDK 接入和机型兼容问题。
Comments
评论区
欢迎在这里留言交流。