C# / Unity 面试题整理

1. 装箱和拆箱是什么?

标准回答

装箱(Boxing)是将值类型转换成引用类型的过程。CLR 会在托管堆上创建一个对象,并把值类型的数据复制进去。

拆箱(Unboxing)是从装箱后的对象中取出原始值类型的过程。

例如:

int x = 10;
object obj = x; // 装箱

int y = (int)obj; // 拆箱

需要注意:

  • 装箱会产生堆内存分配;
  • 拆箱需要类型检查;
  • 频繁装箱拆箱会带来 GC 压力和性能损耗;
  • 可以通过泛型避免大量装箱。

例如:

List<int> list = new List<int>();

相比:

ArrayList list = new ArrayList();

不会发生装箱。


2. 值类型和引用类型赋值时的区别是什么?

标准回答

值类型赋值时,复制的是数据本身。

引用类型赋值时,复制的是引用地址,多个变量会指向同一个对象。

值类型示例:

int a = 10;
int b = a;

b = 20;

修改 b 不会影响 a

引用类型示例:

Person p1 = new Person();
Person p2 = p1;

此时 p1p2 指向同一个对象。

需要注意:

  • 值类型不一定总在栈上;
  • 引用类型对象通常在堆上;
  • 引用类型变量本身保存的是引用。

3. 委托和事件在使用上的区别是什么?

标准回答

委托(delegate)是一种可以保存方法引用的类型。

事件(event)本质上是对委托的一层封装,它限制了外部访问权限,因此更安全。

委托:

public Action MyDelegate;

外部可以:

obj.MyDelegate = null;
obj.MyDelegate();

事件:

public event Action MyEvent;

外部只能:

obj.MyEvent += Func;
obj.MyEvent -= Func;

不能:

obj.MyEvent = null;
obj.MyEvent();

总结:

  • 委托使用更自由;
  • 事件只能订阅和取消订阅;
  • 事件不能被外部直接触发,因此更安全。

4. 两个接口有同名方法时如何处理?

标准回答

如果两个接口中有同名方法:

interface IA
{
    void Test();
}

interface IB
{
    void Test();
}

类可以:

方式一:一个方法同时实现

class MyClass : IA, IB
{
    public void Test()
    {
    }
}

方式二:显式接口实现

class MyClass : IA, IB
{
    void IA.Test()
    {
    }

    void IB.Test()
    {
    }
}

调用:

IA a = new MyClass();
a.Test();

IB b = new MyClass();
b.Test();

注意:

显式实现的方法不能通过类对象直接调用。


5. List 是如何扩容的?

标准回答

List<T> 底层是动态数组。

它有两个核心属性:

  • Count:当前元素数量
  • Capacity:数组容量

当:

Count == Capacity

时,再添加元素就会触发扩容。

扩容流程:

  1. 创建一个更大的数组;
  2. 通常扩容为原来的 2 倍;
  3. 将旧数组元素复制到新数组;
  4. 让 List 指向新数组;
  5. 旧数组等待 GC 回收。

例如容量变化:

4 -> 8 -> 16 -> 32

复杂度:

  • 普通 Add:O(1)
  • 扩容时:O(n)
  • 摊还复杂度:O(1)

如果提前知道数据量,可以:

new List<int>(1000);

减少扩容次数。


Unity 面试题

1. 点乘和叉乘的作用是什么?

点乘 Dot

Vector3.Dot(a, b)

作用:

  • 判断夹角;
  • 判断前后方向;
  • 视野检测。

规律:

dot > 0 同方向
dot = 0 垂直
dot < 0 反方向

常用于:

Vector3.Dot(transform.forward, dir)

判断目标是否在前方。


叉乘 Cross

Vector3.Cross(a, b)

作用:

  • 求法向量;
  • 判断左右方向;
  • 判断旋转方向。

叉乘结果垂直于两个向量组成的平面。

需要注意:

Cross(a,b) != Cross(b,a)

顺序不同方向相反。


2. Unity 中多线程哪些代码会报错?

题目

A. Application.persistentDataPath
B. File.Exists()
C. transform.Translate
D. Object.Destroy()

标准答案

ACD。

原因:

Unity 大多数 API 不是线程安全的,只能在主线程调用。

说明

A

Application.persistentDataPath

属于 Unity API。


B

File.Exists()

属于 .NET IO API,可以在子线程执行。


C

transform.Translate()

操作 Unity 场景对象,只能主线程调用。


D

Object.Destroy()

销毁 Unity 对象,只能主线程调用。


3. streamingAssetsPath 和 persistentDataPath 的区别?

streamingAssetsPath

作用:

  • 存放随安装包发布的原始文件;
  • 文件保持原样;
  • 通常只读。

适合:

  • JSON
  • CSV
  • 视频
  • Lua
  • 初始配置
  • 首包 AB

读取方式:

string path = Path.Combine(Application.streamingAssetsPath, "config.json");

persistentDataPath

作用:

  • 存放运行时产生的数据;
  • 可读写;
  • 应用更新后通常保留。

适合:

  • 存档
  • 玩家设置
  • 缓存
  • 热更新资源
  • 下载内容

例如:

string path = Path.Combine(Application.persistentDataPath, "save.json");

两者核心区别

StreamingAssets:
游戏自带、原始文件、通常只读。

persistentDataPath:
运行时数据、玩家数据、可读写。

4. Unity 协程原理

标准回答

Unity 协程基于:

IEnumerator

和:

yield return

实现。

C# 编译器会把协程方法转换成状态机。

Unity 每帧调用:

MoveNext()

推进协程执行。

遇到:

yield return

时暂停,并根据返回值决定什么时候恢复。

例如:

IEnumerator Test()
{
    yield return null;

    yield return new WaitForSeconds(1f);
}

常见 yield:

yield return null
下一帧继续

yield return new WaitForSeconds()
等待时间

yield return AsyncOperation
等待异步完成

注意:

协程不是多线程,它仍然运行在 Unity 主线程上。


5. Unity 底层如何处理 C# 代码?

标准回答

Unity 中:

C#

IL 中间语言

Mono 或 IL2CPP

机器码

Mono

流程:

C#

IL

Mono Runtime

JIT

机器码

特点:

  • 开发调试方便;
  • 运行时即时编译。

IL2CPP

流程:

C#

IL

C++

平台 C++ 编译器

机器码

特点:

  • AOT 提前编译;
  • 发布包常用;
  • 平台兼容性更好;
  • 更难反编译。

最终无论 Mono 还是 IL2CPP,都会变成目标平台的机器码执行。