18.Unity进阶C#知识补充总结

  1. 18.总结
    1. 18.1 知识点
      1. 总结主要内容
      2. 注意
    2. 18.2 核心要点速览
      1. Unity跨平台基本原理
        1. Mono跨平台
        2. IL2CPP跨平台
      2. IL2CPP打包问题及解决方案
        1. 类型裁剪问题
        2. 泛型问题
      3. .Net API兼容级别
      4. 各Unity版本与C#版本对应关系
      5. 命名参数、可选参数和动态类型
        1. 命名参数(Named Parameters)
        2. 可选参数(Optional Parameters)
        3. 动态类型(dynamic)
      6. 线程和线程池
        1. 线程基础(Thread)
        2. 线程池(ThreadPool)
      7. Task任务类
        1. Task 概述
        2. Task 创建方法
        3. 获取返回值
        4. 同步执行 Task
        5. 任务阻塞方法
        6. 任务延续方法
        7. 取消 Task 执行方法
      8. 异步方法
        1. 同步与异步基础概念
        2. 异步编程适用场景
        3. async 和 await 关键字
        4. 示例代码分析
          1. 普通异步方法
          2. 复杂逻辑计算
      9. 新增语法
        1. C# 6 新增功能和语法
        2. C# 7 新增功能和语法
        3. C# 8 的新增功能和语法
        4. C# 9 新增功能和语法
        5. C# 自带异常类
      10. DateTime日期时间和TimeSpan时间跨度
        1. DateTime
        2. TimeSpan
      11. 正则表达式
    3. 18.3 面试题精选
      1. 基础题
        1. 1. Mono和IL2CPP两种脚本后端的区别是什么?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        2. 2. async和await关键字的作用是什么?在Unity中使用有什么注意事项?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        3. 3. DateTime.Now和DateTime.Today有什么区别?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
      2. 进阶题
        1. 1. IL2CPP打包时遇到类型裁剪问题如何解决?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        2. 2. Task和Thread有什么区别?为什么推荐使用Task?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        3. 3. 在Unity子线程中访问Unity对象会怎样?如何正确处理?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
      3. 深度题
        1. 1. Unity项目中如何选择异步方案?async/await和协程各有什么优劣?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章
        2. 2. IL2CPP泛型问题的根本原因是什么?如何解决?
          1. 题目
          2. 深入解析
          3. 答题示例
          4. 参考文章

18.总结


18.1 知识点

总结主要内容

注意


18.2 核心要点速览

Unity跨平台基本原理

Mono跨平台

  • 原理:C#代码经Mono C#编译器(mcs)编译为IL中间代码,Mono VM转译为操作系统原生代码运行。
  • 优缺点:跨语言和跨平台,但有一定性能和包大小问题。

IL2CPP跨平台

  • 原理:C#代码经Mono C#编译器编译为IL中间代码,Unity用IL2CPP.exe转译为C++代码,各平台C++编译器编译为原生汇编代码,IL2CPP VM运行管理。
  • 优缺点:效率高,包小,但存在类型裁剪和泛型问题。

IL2CPP打包问题及解决方案

类型裁剪问题

  • 问题:可能意外剪裁类型,运行时抛出找不到类型异常。
  • 解决方案:设置PlayerSetting中Managed Stripping Level为Low;使用link.xml告诉引擎哪些类型不能剪裁。

泛型问题

  • 问题:未在打包前显式使用泛型类型,运行时使用会报错。
  • 解决方案:声明类并定义public泛型类变量;编写静态方法并调用泛型方法。

.Net API兼容级别

兼容级别 特点 使用建议
.Net 4.x 具备完整.Net API,含部分无法跨平台API 针对Windows平台且使用.Net Standard 2.0没有的功能时使用
.Net Standard 2.0 .Net标准API集合,内容少,减小可执行文件大小,跨平台支持好 建议使用

各Unity版本与C#版本对应关系

Unity版本 支持的C#版本
Unity 2021.2 C# 9
Unity 2020.3 C# 8
Unity 2019.4 C# 7.3
Unity 2017 C# 6
Unity 5.5 C# 4

命名参数、可选参数和动态类型

命名参数(Named Parameters)

用法 语法 说明 Unity 场景
调用时指定参数名 方法(参数名: 值) 跳过参数顺序,按名赋值 Debug.DrawLine(start: transform.position, end: target.position)
混合位置参数 方法(位置参数, 命名参数: 值) 位置参数需在前,命名参数在后 Raycast(origin, direction: Vector3.forward, layerMask: 1<<8)

优势

  1. 防顺序错误
  2. 自注释代码(复杂参数列表如射线检测的 distance 和 layerMask)

可选参数(Optional Parameters)

用法 语法 说明 Unity 场景
声明默认值 void Method(int a = 0) 参数可省略,调用时不传则用默认值 void Save(string path = "save.dat")
跳过中间默认值 方法(值, 命名参数: 值) 配合命名参数,跳过中间默认值 Test2(i: 1, s: "自定义")(跳过 bool b)

注意

  • 默认值需为编译时常量
  • 公有方法修改默认值会破坏二进制兼容性(扩展功能时用命名参数替代默认值修改)

动态类型(dynamic)

核心 说明 Unity 限制
作用 绕过编译时类型检查,运行时解析成员(类似 object,但延迟绑定) 反射简化(dynamic obj = Activator.CreateInstance(type)
语法 dynamic dyn = GetDynamicObject();dyn.Method(); 与 COM/Python 交互(Unity 极少使用)
优势 1. 减少反射代码量
2. 动态数据处理(如 JSON 反序列化)
临时脚本交互(非 Unity 主流场景)

Unity 注意

  • IL2CPP 不支持(必须用 Mono 脚本后端)
  • 无智能提示,易拼写错误(打包时需切换为 Mono,且避免在核心逻辑中使用)

慎用场景

  1. COM 对象交互
  2. 动态语言桥接(如 IronPython)
  3. 反射简化(优先用 interface 或 object 替代,仅在无法避免时使用)

线程和线程池

线程基础(Thread)

特性 说明 Unity 约束
多线程支持 Unity 允许创建多线程,但子线程禁止访问主线程对象(如 Transform、GameObject) 子线程操作 Unity 对象会报错,需通过 UnityMainThreadDispatcher 切换
线程生命周期 手动创建的线程需在 OnDestroy 中调用 Abort() 终止,否则编辑器关闭才结束 避免内存泄漏和性能浪费

线程池(ThreadPool)

方法 功能 参数 返回值 是否受设置影响 Unity 注意事项
GetAvailableThreads 获取当前可用的工作线程数和 I/O 线程数(不受人为设置影响) out int 工作线程, out int I/O 线程 无(通过 out 参数返回) ❌ 系统动态计算 反映当前系统负载,可用于监控但不可依赖(如动态调整任务量)
SetMaxThreads 设置线程池最大活动的工作线程和 I/O 线程数(超过则任务排队) int 工作线程最大值, int I/O 线程最大值 bool(设置是否成功) ✅ 影响后续 GetMax 结果 Unity 中建议保守设置(如 20, 20),避免过度消耗资源导致主线程卡顿
GetMaxThreads 获取当前线程池最大活动的工作线程和 I/O 线程数(受 SetMax 影响) out int 工作线程, out int I/O 线程 无(通过 out 参数返回) ✅ 反映最后一次 SetMax 用于验证设置是否生效(如打包后环境变化可能导致设置失败)
SetMinThreads 设置线程池最小保留的工作线程和 I/O 线程数(空闲时也保留) int 工作线程最小值, int I/O 线程最小值 bool(设置是否成功) ✅ 影响线程池初始化 Unity 中慎用(保留过多线程可能增加内存开销)
GetMinThreads 获取线程池最小保留的工作线程和 I/O 线程数(受 SetMin 影响) out int 工作线程, out int I/O 线程 无(通过 out 参数返回) ✅ 反映最后一次 SetMin 用于调试线程池初始化策略(如确保最低并发能力)
QueueUserWorkItem 将任务排入线程池,空闲线程自动执行(顺序不可控) Action 任务委托, object 状态参数(可选) bool(入队是否成功) ❌ 仅受线程池负载影响 禁止在任务中访问 Unity 对象(如 GameObject),需通过 UnityMainThreadDispatcher 切换

Task任务类

Task 概述

项目 详情
命名空间 System.Threading.Tasks
本质 基于线程池优点对线程 Thread 的封装,一个 Task 对象可看作一个线程
优势 具备线程池节约性能优点,可带返回值、执行后续操作、更方便取消任务

Task 创建方法

创建方式 无返回值 Task 有返回值 Task 说明
new Task 对象 Task task = new Task(Action action); task.Start(); Task<T> task = new Task<T>(Func<T> function); task.Start(); 手动创建并启动,创建后需调用 Start 方法
Task.Run 静态方法 Task task = Task.Run(Action action); Task<T> task = Task.Run<T>(Func<T> function); 自动启动,更简洁
Task.Factory.StartNew 静态方法 Task task = Task.Factory.StartNew(Action action); Task<T> task = Task.Factory.StartNew<T>(Func<T> function); 自动启动,功能与 Task.Run 类似

获取返回值

方法 语法 注意事项
Task.Result 属性 T result = task.Result; 获取结果时会阻塞线程,若任务未完成,会等待任务完成

同步执行 Task

方法 语法 注意事项
Task.RunSynchronously 方法 Task task = new Task(Action action); task.RunSynchronously(); 需使用 new Task 对象方式,Run 和 StartNew 创建时就启动,无法用于同步执行

任务阻塞方法

方法 语法 说明
Task.Wait 方法 task.Wait(); 等待任务执行完毕,再执行后续代码
Task.WaitAny 静态方法 Task.WaitAny(Task[] tasks); 传入任务中任意一个任务结束就继续执行后续代码
Task.WaitAll 静态方法 Task.WaitAll(Task[] tasks); 任务列表中所有任务执行结束才继续执行后续代码

任务延续方法

方法 语法 说明
Task.WhenAll + Task.ContinueWith Task.WhenAll(Task[] tasks).ContinueWith(Task continuationAction); 传入任务全部完成后执行后续任务
Task.Factory.ContinueWhenAll 方法 Task.Factory.ContinueWhenAll(Task[] tasks, Action<Task[]> continuationAction); 传入任务全部完成后执行后续任务
Task.WhenAny + Task.ContinueWith Task.WhenAny(Task[] tasks).ContinueWith(Task continuationAction); 传入任务中任意一个完成后执行后续任务
Task.Factory.ContinueWhenAny 方法 Task.Factory.ContinueWhenAny(Task[] tasks, Action<Task> continuationAction); 传入任务中任意一个完成后执行后续任务

取消 Task 执行方法

方法 说明
布尔标识控制 如使用 bool isRuning 控制线程内死循环的结束
CancellationTokenSource 类 可实现延迟取消、取消回调等功能,通过 Cancel 方法取消任务,Token.Register 注册取消回调

异步方法

同步与异步基础概念

类型 定义 特点
同步方法 当一个方法被调用时,调用者需要等待该方法执行完毕后返回才能继续执行。 顺序执行,调用者需等待结果,可能造成线程阻塞。
异步方法 当一个方法被调用时立即返回,并获取一个线程执行该方法内部的逻辑,调用者不用等待该方法执行完毕。 调用后立即返回,不阻塞调用者线程,可提高程序运行效率。

异步编程适用场景

  1. 复杂逻辑计算:如复杂的寻路算法,避免阻塞主线程。
  2. 网络下载、网络通讯:网络操作耗时不确定,异步执行让主线程继续处理其他任务。
  3. 资源加载:如加载大型图片、音频、视频等,提高程序响应速度。

async 和 await 关键字

关键字 作用 使用说明
async 用于修饰函数、lambda 表达式、匿名函数。 - 异步方法中建议使用 await 关键字,否则异步方法会以同步方式执行。
- 异步方法名称建议以 Async 结尾。
- 异步方法的返回值只能是 void、Task、Task<>。
- 异步方法中不能声明使用 ref 或 out 关键字修饰的变量。
await 用于在函数中和 async 配对使用,等待某个逻辑结束。 - 遇到 await 关键字时,异步方法将被挂起,将控制权返回给调用者。
- 当 await 修饰内容异步执行结束后,继续通过调用者线程执行后面内容。

示例代码分析

普通异步方法
public async void TestAsync()
{
    print("进入异步方法");
    await Task.Run(() =>
    {
        print("异步方法Task内");
        Thread.Sleep(5000); // 在异步任务中,线程暂停5秒钟(模拟耗时操作)
    });
    print("异步方法后面的逻辑");//这句会在五秒后才打印
}

TestAsync();
print("主线程逻辑执行");

执行流程:调用 TestAsync 方法后,打印“进入异步方法”,遇到 await 时,将 Task.Run 中的任务放入线程池执行,控制权返回给调用者,主线程继续执行“主线程逻辑执行”。5 秒后,Task.Run 中的任务执行完毕,继续执行 TestAsync 方法中 await 后面的代码,打印“异步方法后面的逻辑”。

复杂逻辑计算
public async void CalcPathAsync(GameObject obj, Vector3 endPos)
{
    print("开始处理寻路逻辑");
    int value = 10;
    await Task.Run(() =>
    {
        print("处理复杂逻辑计算");
        Thread.Sleep(1000); // 处理复杂逻辑计算,模拟耗时操作
        value = 50;
        // 不能在多线程里访问 Unity 主线程场景中的对象
        // print(obj.transform.position);
    });
    print("寻路计算完毕 处理逻辑" + value);//这句会在一秒后才打印
    obj.transform.position = Vector3.zero;
}

CalcPathAsync(this.gameObject, Vector3.zero);

执行流程:调用 CalcPathAsync 方法,打印“开始处理寻路逻辑”,遇到 await 时,将 Task.Run 中的任务放入线程池执行,控制权返回给调用者。1 秒后,Task.Run 中的任务执行完毕,继续执行 CalcPathAsync 方法中 await 后面的代码,打印“寻路计算完毕 处理逻辑50”,并将对象的位置设置为 Vector3.zero。

新增语法

C# 6 新增功能和语法

功能和语法 描述 示例
=> 运算符(表达式体成员) 用于简化方法和属性的定义,让代码更简洁 public int Add(int a, int b) => a + b;
public string FullName => $“{FirstName} {LastName}”;
Null 传播器(?.) 在访问对象成员之前检查对象是否为 null,避免 NullReferenceException 异常 string name = person?.Name;
字符串内插($) 在字符串中直接嵌入表达式,无需使用 string.Format 方法 string name = “John”; int age = 30;
string message = $“My name is {name} and I’m {age} years old.”;
静态导入 使用 using static 可以无需指定类型名称即可访问其静态成员和嵌套类型 using static System.Math;
double result = Sqrt(16);
异常筛选器 在 catch 语句后使用 when 关键字来筛选异常,根据不同的异常条件进行不同的处理 try { /* 可能抛出异常的代码 */ } catch (Exception ex) when (ex.Message.Contains(“特定错误信息”)) { /* 处理特定异常 */ } catch (Exception ex) { /* 处理其他异常 */ }
nameof 运算符 可以将变量、类型、成员等的名称转换为字符串 int number = 10;
string variableName = nameof(number);

C# 7 新增功能和语法

功能和语法 描述 示例
字面值改进 可以在数值之间插入 _ 作为分隔符,方便查看数值 int largeNumber = 1_000_000;
out 参数相关和弃元知识点 out 变量可直接在函数参数中声明;使用 _ 作为占位符忽略不需要的 out 参数 if (int.TryParse(“123”, out int result)) { /* 使用 result */ }
int.TryParse(“123”, out _);
ref 返回值 函数可以返回引用类型,调用者可通过返回的引用修改原数据 public ref int Find(int[] array, int value) { /* 查找逻辑 */ return ref array[index]; }
本地函数 在函数内部声明临时函数,只能在声明该函数的函数内部使用,可使用父函数变量 public int TestLocal(int i) { bool b = false; i += 10; Calc(); print(b); return i; void Calc() { i += 10; b = true; } }
抛出表达式 可以在表达式中抛出异常,而不仅仅是在语句中 string name = null; string result = name?? throw new ArgumentNullException(nameof(name));
元组 用于将多个值组合成一个单一的对象,无需定义新的类型 (string name, int age) person = (“John”, 30);
Console.WriteLine($“{person.name} is {person.age} years old.”);
模式匹配 允许根据数据的类型和结构进行条件判断和处理 if (o is 1){ print(“o是1”); }
object obj = 123; if (obj is int number) { Console.WriteLine($“The number is {number}”); }
if (o is var v){print(o); }

C# 8 的新增功能和语法

功能和语法 描述 示例
静态本地函数 在本地函数前加 static,使其不能访问上层方法变量,避免逻辑混乱 public int CalcInfo(int i) { static void Calc(ref int i) { ... } ... }
Using 声明 using() 语法简写,函数执行完自动调用对象 Dispose 方法,对象需继承 System.IDisposable 接口 using StreamWriter sw = new StreamWriter("path");
Null 合并赋值 ??= 左侧为空时将右侧值赋给左侧变量 string s = null; s ??= "val";
解构函数 Deconstruct 可在自定义类中声明,用元组写法获取类对象变量,类中可有多个但参数数量不同 class Person { public void Deconstruct(out string n, out bool s) { ... } }

模式匹配增强功能

模式类型 描述 示例
switch 表达式 对有返回值的 switch 语句的缩写,用 => 代替 case:,用 _ 代替 default public Vector2 GetPos(PosType type) => type switch { PosType.Top_Left => new Vector2(0, 0), ... _ => new Vector2(0, 0) };
属性模式 在常量模式基础上判断对象属性,语法为 变量 is {属性:值, 属性:值} DiscountInfo info = new DiscountInfo("5折", true);
if (info is { discount: "6折", isDiscount: true }) print("信息相同");
元组模式 更简单地完成多个变量的判断,直接用元组进行判断 int ii = 10; bool bb = true;
if((ii, bb) is (11, true)) { print("元组的值相同"); }
位置模式 自定义类实现解构函数 Deconstruct 后,可用对应类对象与元组进行 is 判断 if (info is ("5折", true)) { print("位置模式 满足条件"); }

C# 9 新增功能和语法

功能和语法 描述 示例
Records (记录类型) 不可变引用类型,编译器自动生成常见方法,可通过 with 表达式创建新实例 public record Person(string FirstName, string LastName);
var p1 = new Person("John", "Doe");
var p2 = p1 with { LastName = "Smith" };
改进的模式匹配 支持记录类型的模式匹配,新增逻辑模式和常量模式 if (shape is Circle { Radius: > 0 } circle) { /*... */ }
switch (num) { case 0: /*... */ break; case int n when n > 0: /*... */ break; }
改进的模式组合 引入 and、or 和 not 运算符,简化复杂模式匹配 if (shape is Circle { Radius: > 0 } circle and not null) { /*... */ }
允许部分方法实现 部分方法声明和实现可分离,接口中定义部分方法,实现类可选择实现 public interface ILogger { partial void LogHeader(); void Log(string message); }
public class FileLogger : ILogger { public partial void LogHeader() { /*... */ } public void Log(string message) { /*... */ } }

C# 自带异常类

  • IndexOutOfRangeException:当一个数组的下标超出范围时运行时引发。
  • NullReferenceException:当一个空对象被引用时运行时引发。
  • ArgumentException:方法的参数是非法的。
  • ArgumentNullException:一个空参数传递给方法,该方法不能接受该参数。
  • ArgumentOutOfRangeException:参数值超出范围。
  • SystemException:其他用户可处理的异常的基本类。
  • OutOfMemoryException:内存空间不够。
  • StackOverflowException:堆栈溢出。
  • ArithmeticException:出现算术上溢或者下溢。
  • ArrayTypeMismatchException:试图在数组中存储错误类型的对象。
  • BadImageFormatException:图形的格式错误。
  • DivideByZeroException:除零异常。
  • DllNotFoundException:找不到引用的DLL。
  • FormatException:参数格式错误。
  • InvalidCastException:使用无效的类。
  • InvalidOperationException:方法的调用时间错误。
  • MethodAccessException:试图访问受保护的方法。
  • MissingMemberException:访问一个无效版本的DLL。
  • NotFiniteNumberException:对象不是一个有效的数字。
  • NotSupportedException:调用的方法在类中没有实现。

DateTime日期时间和TimeSpan时间跨度

DateTime

方面 描述 示例
概述 位于 System 命名空间,处理日期和时间的结构体,默认值和最小值是 0001 年 1 月 1 日 00:00:00,最大值是 9999 年 12 月 31 日 23:59:59 -
初始化 主要参数有年、月、日、时、分、秒、毫秒、ticks;次要参数有 DateTimeKind、Calendar DateTime dateTime = new DateTime(2022, 12, 1, 13, 30, 45, 500);
获取时间 DateTime.Now 返回当前本地日期和时间;DateTime.Today 返回今日日期;DateTime.UtcNow 返回当前 UTC 日期和时间 DateTime now = DateTime.Now;
DateTime today = DateTime.Today;
DateTime utcNow = DateTime.UtcNow;
计算时间 可通过 AddDays 等方法进行时间计算 DateTime newTime = now.AddDays(-1);
字符串输出 多种输出格式,也支持自定义 now.ToString("yyyy - MM - dd - ddd / HH - mm - ss");
字符串转 DateTime 字符串格式需为 “年/月/日 时:分:秒”,用 TryParse 转换 DateTime.TryParse("1988/5/4 18:00:08", out DateTime date);
存储时间 建议存储时间戳,通过 DateTimeOffset 获取 long timestamp = ((DateTimeOffset)DateTime.Now).ToUnixTimeSeconds();

TimeSpan

方面 描述 示例
概述 位于 System 命名空间,时间跨度结构体,可由两个 DateTime 对象相减得到 TimeSpan ts = DateTime.Now - new DateTime(1970, 1, 1);
初始化 可指定天、时、分、秒等初始化 TimeSpan ts2 = new TimeSpan(1, 0, 0, 0);
相互计算 可进行加减运算 TimeSpan ts4 = ts2 + new TimeSpan(0, 1, 1, 1);
常量 有 TicksPerSecond 等常量用于计算 ts4.Ticks / TimeSpan.TicksPerSecond;

正则表达式

方法 描述 代码示例
public bool IsMatch( string input ) 指示 Regex 构造函数中指定的正则表达式是否在指定的输入字符串中找到匹配项 var regex = new Regex(@"\d"); bool isMatch = regex.IsMatch("abc123");
public bool IsMatch( string input, int startat ) 指示 Regex 构造函数中指定的正则表达式是否在指定的输入字符串中找到匹配项,从字符串中指定的开始位置开始 var regex = new Regex(@"\d"); bool isMatch = regex.IsMatch("abc123", 3);
public static bool IsMatch( string input, string pattern ) 指示指定的正则表达式是否在指定的输入字符串中找到匹配项 bool isMatch = Regex.IsMatch("abc123", @"\d");
public MatchCollection Matches( string input ) 在指定的输入字符串中搜索正则表达式的所有匹配项 var regex = new Regex(@"\d"); var matches = regex.Matches("abc123");
public string Replace( string input, string replacement ) 在指定的输入字符串中,把所有匹配正则表达式模式的所有匹配的字符串替换为指定的替换字符串 var regex = new Regex(@"\d"); string result = regex.Replace("abc123", "X");
public string[] Split( string input ) 把输入字符串分割为子字符串数组,根据在 Regex 构造函数中指定的正则表达式模式定义的位置进行分割 var regex = new Regex(@"\d"); string[] parts = regex.Split("abc123");

18.3 面试题精选

基础题

1. Mono和IL2CPP两种脚本后端的区别是什么?

题目

Unity支持Mono和IL2CPP两种脚本后端,请简述两者的工作原理及主要区别。

深入解析

Mono脚本后端

  • C#代码经Mono C#编译器(mcs)编译为IL中间代码
  • 运行时由Mono虚拟机(VM)将IL转译为操作系统原生代码执行
  • 跨平台依靠Mono VM在不同平台的实现

IL2CPP脚本后端

  • C#代码先编译为IL中间代码
  • Unity使用IL2CPP.exe将IL转译为C++代码
  • 各平台C++编译器编译为原生汇编代码
  • 由IL2CPP VM进行运行时管理(垃圾回收、线程管理等)

主要区别对比

对比项 Mono IL2CPP
性能 较低,JIT编译有运行时开销 较高,AOT编译为原生代码
包体大小 较大,需打包Mono VM 较小,无VM开销
跨平台 依赖Mono VM移植 C++编译器原生支持
热更新 支持JIT,便于热更 AOT编译,热更受限
调试 支持更多调试特性 部分调试受限
答题示例

Mono是JIT模式,运行时由Mono VM将IL转译为原生代码;IL2CPP是AOT模式,提前将IL转成C++再编译为原生代码。

IL2CPP性能更高、包体更小,是Unity推荐的生产环境方案;Mono调试更方便,适合开发阶段。

参考文章
  • 3.Unity跨平台的基本原理-Mono.md
  • 4.Unity跨平台的基本原理-IL2CPP.md

2. async和await关键字的作用是什么?在Unity中使用有什么注意事项?

题目

请解释async和await关键字的作用,并说明在Unity中使用异步方法的注意事项。

深入解析

async关键字

  • 用于修饰方法、lambda表达式或匿名函数
  • 标识该方法为异步方法
  • 异步方法返回值只能是void、Task或Task

await关键字

  • 只能在async方法内使用
  • 遇到await时,方法挂起,控制权返回调用者
  • 等待的任务完成后,继续执行后续代码

Unity中的注意事项

  1. 子线程禁止访问Unity主线程对象(如Transform、GameObject)
  2. 需要操作Unity对象时,必须切回主线程执行
  3. 异步方法中不能使用ref或out参数
  4. 建议方法名以Async结尾
public async void LoadDataAsync()
{
    var data = await Task.Run(() => 
    {
        // 子线程执行耗时操作
        return HeavyCalculation();
    });
    // 自动回到主线程,可以操作Unity对象
    transform.position = data.position;
}
答题示例

async标识异步方法,await挂起方法等待任务完成,期间控制权返回调用者,不阻塞线程。

Unity中子线程不能访问Unity对象,await后的代码会回到原同步上下文(通常是主线程),可以安全操作Unity对象。

参考文章
  • 10.CSharp各版本新功能和语法-CSharp5功能和语法-异步方法async和await.md

3. DateTime.Now和DateTime.Today有什么区别?

题目

DateTime.Now和DateTime.Today都用于获取当前时间,它们有什么区别?各自适用于什么场景?

深入解析

DateTime.Now

  • 返回当前本地日期和时间的完整信息
  • 包含年、月、日、时、分、秒、毫秒
  • 受系统时区影响

DateTime.Today

  • 返回今日日期,时间部分全部为零(午夜00:00:00)
  • 只包含年、月、日
  • 不受系统时区影响

使用场景

  • DateTime.Now:需要精确时间时使用,如记录事件发生时刻、计算时间差
  • DateTime.Today:只关心日期时使用,如每日签到、判断是否同一天
DateTime now = DateTime.Now;    // 2024-03-18 15:30:45.123
DateTime today = DateTime.Today; // 2024-03-18 00:00:00.000

// 判断是否同一天
if (lastLoginDate.Date == DateTime.Today)
{
    // 今天已登录
}
答题示例

DateTime.Now返回完整的日期时间,包含时分秒毫秒;DateTime.Today只返回日期部分,时间归零。

需要精确时间用Now,只判断日期用Today。判断是否同一天时,可以用.Date属性统一比较。

参考文章
  • 17.CSharp其它知识补充-日期和时间.md

进阶题

1. IL2CPP打包时遇到类型裁剪问题如何解决?

题目

使用IL2CPP打包后,运行时出现”找不到类型”异常,这是什么原因?如何解决?

深入解析

问题原因
IL2CPP在打包时会进行代码裁剪(Stripping),移除未使用的类型和成员。但某些通过反射动态调用的类型,编译器无法静态分析出其使用情况,导致被误裁剪。

解决方案

方案一:调整裁剪级别

  • 在Player Settings中将Managed Stripping Level设置为Low或Minimal
  • 缺点:包体会变大

方案二:使用link.xml保留类型

<linker>
  <assembly fullname="Assembly-CSharp" preserve="all"/>
  <assembly fullname="UnityEngine">
    <type fullname="UnityEngine.GameObject" preserve="all"/>
  </assembly>
</linker>
  • 将link.xml文件放在Assets根目录
  • 可以精确控制保留哪些类型

方案三:代码中显式引用

// 在代码中显式使用,防止被裁剪
public class Linker
{
    public static void Preserve()
    {
        var obj = new MyDynamicType();
    }
}
答题示例

IL2CPP代码裁剪会移除编译器认为未使用的类型,反射动态调用的类型容易被误裁。

解决方案:一是降低裁剪级别;二是用link.xml明确保留类型;三是在代码中显式引用。推荐用link.xml精确控制。

参考文章
  • 5.Unity跨平台的基本原理-IL2CPP模式可能存在的问题处理.md

2. Task和Thread有什么区别?为什么推荐使用Task?

题目

C#中Task和Thread都可以用于多线程编程,它们有什么区别?为什么现代C#推荐使用Task?

深入解析

Thread特点

  • 底层线程封装,一个Thread对象对应一个系统线程
  • 手动管理线程生命周期
  • 线程创建和销毁开销大
  • 无返回值,无任务延续机制

Task特点

  • 基于线程池,复用线程减少开销
  • 支持返回值(Task
  • 支持任务延续(ContinueWith、async/await)
  • 支持取消(CancellationToken)
  • 支持任务组合(WhenAll、WhenAny)

性能对比

// Thread方式:每次创建新线程
for (int i = 0; i < 100; i++)
{
    new Thread(() => DoWork()).Start(); // 创建100个线程
}

// Task方式:复用线程池
for (int i = 0; i < 100; i++)
{
    Task.Run(() => DoWork()); // 复用线程池中的线程
}

推荐Task的原因

  1. 性能更好:线程池复用,避免频繁创建销毁
  2. 功能更强:返回值、延续、取消、异常聚合
  3. 代码更简洁:配合async/await,异步代码像同步一样写
  4. 可组合性强:WhenAll、WhenAny等组合操作
答题示例

Thread是底层线程封装,每次创建对应一个系统线程;Task基于线程池,复用线程资源。

推荐Task的原因:一是性能好,线程池复用减少开销;二是功能强,支持返回值、延续、取消;三是配合async/await代码更清晰。

参考文章
  • 8.CSharp各版本新功能和语法-CSharp5功能和语法-线程和线程池.md
  • 9.CSharp各版本新功能和语法-CSharp5功能和语法-Task任务类.md

3. 在Unity子线程中访问Unity对象会怎样?如何正确处理?

题目

在Unity中创建子线程执行任务时,如果直接在子线程中访问Transform等Unity对象会发生什么?正确的处理方式是什么?

深入解析

问题现象
Unity的API大部分不是线程安全的,在子线程中访问会抛出异常:

GetTransform can only be called from the main thread.

原因
Unity引擎的主循环在主线程执行,内部状态(如场景图、Transform层级)没有线程同步保护,子线程访问会导致竞态条件。

正确处理方式

方式一:async/await自动切回主线程

public async void ProcessAsync()
{
    await Task.Run(() => 
    {
        // 子线程执行耗时计算
        result = HeavyCalculation();
    });
    // await后自动回到主线程,可安全操作Unity对象
    transform.position = result;
}

方式二:使用UnityMainThreadDispatcher

public void ProcessInThread()
{
    Task.Run(() => 
    {
        var result = HeavyCalculation();
        // 通过Dispatcher将操作派发到主线程
        UnityMainThreadDispatcher.Instance.Enqueue(() => 
        {
            transform.position = result;
        });
    });
}

方式三:协程配合Task

IEnumerator ProcessCoroutine()
{
    var task = Task.Run(() => HeavyCalculation());
    while (!task.IsCompleted)
    {
        yield return null;
    }
    transform.position = task.Result;
}
答题示例

子线程访问Unity对象会报错,因为Unity API不是线程安全的。

正确做法:一是用async/await,await后自动回到主线程;二是用MainThreadDispatcher派发到主线程;三是用协程等待Task完成。

参考文章
  • 8.CSharp各版本新功能和语法-CSharp5功能和语法-线程和线程池.md
  • 10.CSharp各版本新功能和语法-CSharp5功能和语法-异步方法async和await.md

深度题

1. Unity项目中如何选择异步方案?async/await和协程各有什么优劣?

题目

Unity提供了协程(Coroutine)和C#的async/await两种异步方案,在实际项目中如何选择?请从性能、易用性、功能等角度分析。

深入解析

协程特点

  • Unity特有机制,基于迭代器实现
  • 必须在MonoBehaviour中使用
  • 通过yield控制执行时机
  • 与Unity生命周期紧密绑定

async/await特点

  • C#语言特性,通用性强
  • 不依赖MonoBehaviour
  • 基于Task和线程池
  • 支持返回值和异常捕获

对比分析

对比项 协程 async/await
返回值 支持Task
异常处理 较弱,需手动捕获 强,try-catch自然捕获
取消 StopCoroutine,较粗糙 CancellationToken,精细控制
线程 始终在主线程 可切换线程
Unity版本兼容 全版本 需要Unity 2017+
调试 断点调试友好 调试体验稍差

选择建议

优先使用协程的场景

  • 简单的时序控制(等待几秒、等待一帧)
  • 需要与Unity生命周期同步
  • 项目Unity版本较老

优先使用async/await的场景

  • 需要返回值的异步操作
  • 复杂的异步流程控制
  • 需要取消和超时控制
  • 网络请求、文件IO等真正的异步操作
  • 需要在子线程执行耗时计算

混合使用示例

// 简单延迟用协程
IEnumerator SimpleDelay()
{
    yield return new WaitForSeconds(1f);
    DoSomething();
}

// 复杂异步用Task
public async Task<AssetBundle> LoadAssetBundleAsync(string path)
{
    var bundle = await AssetBundle.LoadFromFileAsync(path);
    return bundle;
}
答题示例

协程适合简单时序控制,与Unity生命周期绑定;async/await功能更强,支持返回值、取消、异常捕获。

简单延迟、帧同步用协程;网络请求、耗时计算、需要返回值用async/await。实际项目可以混合使用。

参考文章
  • 9.CSharp各版本新功能和语法-CSharp5功能和语法-Task任务类.md
  • 10.CSharp各版本新功能和语法-CSharp5功能和语法-异步方法async和await.md

2. IL2CPP泛型问题的根本原因是什么?如何解决?

题目

使用IL2CPP打包后,某些泛型类型在运行时报错,这是什么原因?请从IL2CPP的编译机制角度分析,并给出解决方案。

深入解析

问题根源

IL2CPP采用AOT(Ahead-of-Time)编译,需要在编译时确定所有泛型类型的具体实现。但以下情况会导致泛型实例化失败:

  1. 反射创建泛型实例:编译时无法知道运行时会用到哪些泛型参数
  2. 泛型方法从未被静态调用:编译器认为未使用,不生成代码
  3. 值类型泛型参数:每个值类型参数都需要单独的代码生成

IL2CPP泛型代码生成机制

C#代码 → IL → IL2CPP转C++ → 平台编译器 → 原生代码

在IL2CPP转C++阶段,需要确定所有泛型实例化。如果某个List<MyType>从未在代码中显式使用,就不会生成对应的C++代码。

解决方案

方案一:预声明泛型类型

public class IL2CPP_GenericPreserve
{
    // 显式声明可能动态使用的泛型类型
    public List<MyDynamicType> list;
    public Dictionary<string, MyDynamicType> dict;
    
    // 显式调用泛型方法
    public static void Preserve()
    {
        new IL2CPP_GenericPreserve().GenericMethod<int>();
        new IL2CPP_GenericPreserve().GenericMethod<float>();
    }
    
    public void GenericMethod<T>() { }
}

方案二:使用泛型约束

// 约束为class,使用引用类型共享实现
public class Container<T> where T : class
{
    // 引用类型泛型可以共享实现
}

方案三:link.xml保留

<linker>
  <assembly fullname="Assembly-CSharp">
    <type fullname="MyNamespace.MyGenericClass`1" preserve="all"/>
  </assembly>
</linker>

最佳实践

  • 避免运行时动态创建泛型实例
  • 在代码中显式使用所有需要的泛型参数组合
  • 打包前进行充分测试
答题示例

IL2CPP是AOT编译,编译时需要确定所有泛型实例化。反射或动态调用的泛型,编译器无法预知,不会生成代码。

解决方案:一是在代码中显式声明和调用所有泛型参数组合;二是用link.xml保留;三是尽量避免运行时动态泛型。

参考文章
  • 5.Unity跨平台的基本原理-IL2CPP模式可能存在的问题处理.md

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏