Unity / C# 面试题复盘
1. 如果我们想为 Unity 中的 Transform 类添加一个自定义的方法,应该如何处理?
标准答案:
如果想给 Unity 的 Transform 添加自定义方法,一般会使用 C# 的扩展方法。因为 Transform 是 Unity 内置类,我们不能也不应该直接修改它的源码。
扩展方法需要写在静态类中,方法本身也必须是静态方法,并且第一个参数使用 this Transform 表示要扩展的类型。这样就可以像调用普通成员方法一样调用它。
示例:
public static class TransformExtensions
{
public static void ResetLocal(this Transform transform)
{
transform.localPosition = Vector3.zero;
transform.localRotation = Quaternion.identity;
transform.localScale = Vector3.one;
}
}
调用时可以写成:
transform.ResetLocal();
需要注意的是,扩展方法并没有真正修改 Transform 类本身,它本质上仍然是一个静态工具方法,只是调用形式更像成员方法。
2. 请说出 using 关键字的两个作用。
标准答案:
using 在 C# 中常见有两个作用。
第一个作用是引入命名空间,比如 using UnityEngine;。这样代码中就可以直接使用 GameObject、Transform、Debug 等类型,而不需要每次都写完整命名空间。
第二个作用是用于资源释放,也就是 using 语句块。它要求对象实现 IDisposable 接口,当代码执行离开 using 作用域时,会自动调用对象的 Dispose() 方法,常用于文件流、数据库连接、网络连接等资源的释放。
需要注意的是,using 不是直接销毁对象,而是帮助我们自动释放资源。对象内存本身仍然由 GC 管理。
3. C# 中 Dictionary 不支持相同键存储,如果想要一个键对应多个值如何处理?
标准答案:
Dictionary 的 key 必须是唯一的。如果想让一个 key 对应多个 value,通常会把 value 设计成一个集合,比如 Dictionary<TKey, List<TValue>>。
添加数据时,先判断这个 key 是否存在。如果不存在,就先创建一个 List<TValue>,然后再把值添加进去。
示例:
Dictionary<int, List<string>> playerItems = new Dictionary<int, List<string>>();
if (!playerItems.ContainsKey(playerId))
{
playerItems[playerId] = new List<string>();
}
playerItems[playerId].Add(itemName);
如果希望同一个 key 下的 value 不重复,可以使用 Dictionary<TKey, HashSet<TValue>>。
另外,如果问题是多个字段共同组成一个 key,那可以使用元组、结构体或类作为组合 key。但这和“一个 key 对多个 value”不是同一个问题。如果使用自定义类或结构体作为 key,需要正确实现 Equals() 和 GetHashCode()。
4. 请问下面代码的最终打印结果是什么?为什么?
static void Main(string[] args)
{
Action action = null;
for (int i = 0; i < 10; i++)
{
action += () =>
{
Console.WriteLine(i);
};
}
action();
}
标准答案:
这段代码最终会打印 10 个 10。
原因是 Lambda 表达式形成了闭包,捕获的是 for 循环中的变量 i 本身,而不是每次循环时 i 的临时值。
循环过程中,action 累加了 10 个委托,但这些委托捕获的是同一个变量 i。等 for 循环结束后,i 的值已经变成了 10。此时再调用 action(),会依次执行之前添加的 10 个委托,所以每次打印的都是 10。
最终输出:
10 10 10 10 10 10 10 10 10 10
5. 上题中的代码,如果我们希望打印出 0~9,应该如何修改代码?
标准答案:
如果希望打印 0 到 9,需要在循环体内部声明一个临时变量,比如 int temp = i;,然后在 Lambda 中打印 temp。
示例:
static void Main(string[] args)
{
Action action = null;
for (int i = 0; i < 10; i++)
{
int temp = i;
action += () =>
{
Console.WriteLine(temp);
};
}
action();
}
这样做的原因是,原代码中所有 Lambda 捕获的是同一个循环变量 i,循环结束后它的值已经是 10。
而把 i 赋值给循环体内部的局部变量后,每次循环都会创建一个新的局部变量 temp。每个 Lambda 捕获的是自己那一轮对应的 temp,所以最终会按顺序打印 0 到 9。
6. Unity 中如何将本地坐标转为世界坐标?
标准答案:
Unity 中可以使用 transform.TransformPoint() 将本地坐标转换成世界坐标。
示例:
Vector3 worldPos = transform.TransformPoint(localPos);
这里的 localPos 是相对于当前物体 Transform 的本地坐标,转换后得到的是世界坐标。
如果要反过来,把世界坐标转换成本地坐标,可以使用:
Vector3 localPos = transform.InverseTransformPoint(worldPos);
另外需要注意,TransformPoint 用于转换位置点,会受到物体的位置、旋转和缩放影响。
如果只是转换方向,一般使用 TransformDirection。
如果是转换向量,可以使用 TransformVector。TransformVector 不受位置影响,但会受到旋转和缩放影响。
7. Unity 中如何计算出两个向量之间的夹角?请说出两种方式。
标准答案:
Unity 中计算两个向量夹角,第一种方式可以直接使用 Vector3.Angle(a, b)。它会返回两个向量之间的夹角,单位是度,范围一般是 0 到 180 度。
示例:
float angle = Vector3.Angle(a, b);
第二种方式是使用点乘公式。因为:
a · b = |a| * |b| * cosθ
所以可以先把两个向量归一化,然后用 Vector3.Dot 得到 cosθ,再通过 Mathf.Acos 求出弧度,最后乘以 Mathf.Rad2Deg 转成角度。
示例:
float dot = Vector3.Dot(a.normalized, b.normalized); dot = Mathf.Clamp(dot, -1f, 1f); float angle = Mathf.Acos(dot) * Mathf.Rad2Deg;
需要注意,这里用的是 Acos,不是 Atan。加 Clamp 是为了避免浮点误差导致 Acos 参数超出 -1 到 1 的范围。
如果需要带方向的角度,可以使用 Vector3.SignedAngle(a, b, axis)。
8. 请写出 UGUI 中两种处理异形按钮的具体方法。
标准答案:
UGUI 中处理异形按钮常见有两种方式。
第一种是使用图片的 Alpha 点击检测。可以设置 Image.alphaHitTestMinimumThreshold,让透明区域不响应点击。
示例:
Image image = GetComponent<Image>();
image.alphaHitTestMinimumThreshold = 0.1f;
这种方式适合图片本身带透明区域的不规则按钮,比如圆形按钮、不规则图标按钮等。需要注意,使用这种方式时,图片纹理通常需要开启 Read/Write Enabled,否则 Unity 无法读取像素透明度。
第二种是自定义射线检测逻辑。可以实现 ICanvasRaycastFilter,在 IsRaycastLocationValid 方法中判断点击点是否在有效区域内。
示例:
public class CircleRaycastFilter : MonoBehaviour, ICanvasRaycastFilter
{
public float radius = 100f;
public bool IsRaycastLocationValid(Vector2 screenPoint, Camera eventCamera)
{
RectTransform rectTransform = transform as RectTransform;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
rectTransform,
screenPoint,
eventCamera,
out Vector2 localPoint
);
return localPoint.magnitude <= radius;
}
}
这种方式适合更复杂的异形按钮,比如圆形、扇形、多边形或者地图区域点击。
RectMask2D 主要用于矩形裁剪显示区域,不是处理异形按钮点击区域的标准答案。
9. 请说出 Unity 中如何进行数据持久化,至少说出 5 种方式。
标准答案:
Unity 中数据持久化常见方式有很多。
第一种是 PlayerPrefs,适合保存音量、画质、简单开关、新手引导状态等少量简单数据。
第二种是 JSON 文件。可以把玩家存档对象序列化成 JSON 字符串,再通过 File.WriteAllText 写入本地文件。JSON 适合保存结构化存档,可读性强,调试方便。
第三种是 XML 文件。XML 也可以保存结构化数据,不过相对 JSON 更冗长,现在项目中使用得少一些。
第四种是二进制文件。可以通过 FileStream、BinaryWriter 或序列化框架保存数据。二进制文件体积较小,读取效率较高,也不容易被普通玩家直接修改,但可读性差,版本兼容需要额外处理。
第五种是 SQLite 或其他本地数据库。适合数据量较大、需要增删改查的数据,比如背包、任务、关卡进度、大量配置缓存等。
另外,联网游戏还可以把数据保存到服务器或云端,用于账号数据、排行榜、充值数据、多端同步存档等场景。
ScriptableObject 也可以作为配置类数据资产进行持久化,比如技能配置、道具配置、角色属性配置等。但它更适合保存配置,不太适合作为运行时玩家存档的主要方案。
10. 在 Unity 中如何控制渲染优先级?谁先渲染谁后渲染,分情况回答。
标准答案:
Unity 中控制渲染优先级要分情况看。
对于普通 3D 物体,默认主要由材质的 Render Queue、深度测试和相机决定。Unity 会先渲染不透明物体,再渲染透明物体。不透明物体一般依靠深度缓冲决定遮挡关系,而透明物体通常需要从远到近渲染。
如果想手动控制材质渲染顺序,可以修改 Shader 中的 Queue 标签,或者修改 Material.renderQueue。常见队列有 Background、Geometry、AlphaTest、Transparent 和 Overlay。队列值越小越早渲染,越大越晚渲染。
对于 2D Sprite,可以使用 Sorting Layer 和 Order in Layer 控制渲染顺序。Order in Layer 越大,通常越后渲染,也越显示在前面。多个 Sprite 组成一个整体时,可以使用 SortingGroup 统一管理排序。
对于 UGUI,可以通过 Canvas 的 Sorting Layer、Order in Layer、Sort Order 控制不同 Canvas 的显示顺序。同一个 Canvas 内,一般 Hierarchy 中越靠后的 UI 元素越后绘制,通常越显示在上面。
如果是多个 Camera,可以通过 Camera 的 Depth 控制渲染顺序。Depth 值小的 Camera 先渲染,Depth 值大的 Camera 后渲染。在 URP 中,还可以使用 Camera Stack,让 Base Camera 和 Overlay Camera 组合渲染。
需要注意,“后渲染”不一定绝对等于“显示在前面”,因为最终显示结果还会受到深度测试、ZWrite、ZTest、透明排序等因素影响。
Comments
评论区
欢迎在这里留言交流。