Unity 的 C# 语言与 API 支持边界:从编译器到 IL2CPP 的验证方法
系列:C# 与常用数据结构源码剖析 · Unity 实战篇
阅读时间:约 55 分钟
前置知识:C# 编译执行链路、泛型、反射、托管集合
结论边界:Unity 的工具链会随 Editor 版本、包、平台与构建后端变化。本文不把某个时点的“Unity 6 = C# 9”当成永久真理,而是给出可重复的核验方法。
一、先拆掉“Unity 支持哪个 C#”这个问题
“项目支持 C# 9吗?”看似是一个是非题,实际上至少包含六个相互独立的问题:
- Editor 内置的 C# 编译器能否解析某种语法?
- Unity 为项目选择了哪个语言版本和哪些编译开关?
- 所需类型或方法是否存在于项目可见的基础类库(BCL)中?
- Editor 里的 Mono 和 Player 里的 Mono 或 IL2CPP 能否执行生成的 IL?
- 代码经过托管裁剪、AOT 泛型实例化和平台原生编译后是否仍然完整?
- 目标平台是否允许所需能力,例如动态代码生成、线程或特定原生 API?
语法和 API 尤其容易被混淆。例如,目标类库中可能已有Span<T>,但这不代表编译器一定支持某个更新的语法;反过来,编译器可能认识某种语法,但它的降级结果会引用当前 BCL 没有的类型、特性或方法。此时“语法能编译”也不等于“功能在所有 Player 上可用”。
因此,一个严谨的兼容性记录不应只写“C# 版本”,而应至少记录下表各维度:
| 维度 | 要记录的事实 | 主要证据 |
|---|---|---|
| Editor | 完整版本号与修订号 | ProjectVersion.txt、Editor About |
| 编译器 | Roslyn 组件版本、默认语言级别 | 该 Editor 版本官方手册、编译日志、语法探针 |
| API 表面 | Api Compatibility Level、可见 BCL | Player Settings、引用程序集、API 探针 |
| 程序集 | asmdef 引用、宏、平台排除 | .asmdef、导入设置、编译命令 |
| 后端 | Mono 或 IL2CPP | 每个 Build Target 的 Player Settings |
| 裁剪 | Managed Stripping Level、保留规则 | Player Settings、link.xml、裁剪报告 |
| 平台 | OS、CPU、图形 API、线程限制 | 目标设备 Player 日志与官方平台文档 |
| 构建配置 | Development/Release、脚本定义、优化等级 | CI 参数、Build Report |
Api Compatibility Level是可供编译的 API 契约,不是将 Unity 直接变成桌面版新.NET的开关。同样,项目里的csc.rsp只能向现有编译器传递它已理解的选项,不会替换 Editor 内置 Roslyn,不会自动增加 BCL,也不会为 IL2CPP 补齐新运行时功能。把-langversion:latest写进响应文件,不能推导出“C# 12 已受支持”。
二、语言功能为什么会有三种结果
C# 功能在 Unity 中大致可分成三类。第一类主要是编译期语法糖,编译器可将它降级为普通 IL;第二类除语法外还需要特定 BCL 类型或编译器支撑类型;第三类需要运行时或平台能力。
比如,某些模式匹配最终只是分支、类型测试和成员读取,但记录类型、范围、索引、初始化成员等功能可能依赖特定支撑类型。dynamic则依赖动态绑定基础设施,它不应被简化为“Mono 必定可用、IL2CPP 必定不可用”。一段具体的动态调用是否可用,取决于相关程序集、裁剪、AOT 可见的绑定路径与平台限制。对游戏主循环中的高频路径,显式接口、泛型约束或生成代码通常更容易检查与裁剪,但这是工程可控性结论,不是对dynamic的一刀切语法禁令。
实际项目中应对候选功能逐项记录:最小 Editor 版本、所需 API、各后端编译结果、各目标平台运行结果以及替代实现。不要从“IDE 没有标红”推导出 Player 支持;IDE 的语言服务、Unity 实际编译器和目标 Player 是三个不同的证据层。
三、asmdef、包与源码生成器的真实边界
Assembly Definition 文件的核心价值是建立清晰的编译边界。它能声明程序集引用、平台包含与排除、版本定义以及 unsafe 等选项。它不是每个程序集的独立 C# 运行时,也不能让同一 Editor 在不同 asmdef 中随意混用不同世代的 BCL。
包管理器中的包还会增加第二层约束:包可能只兼容特定 Unity 版本,内含预编译 DLL,或使用组件自身的条件宏。升级 Editor 时,包的锁定版本、依赖图与编译警告应与业务代码一起验证,而不是只看 Assets 目录。
Roslyn Analyzer 和源码生成器对某个 Unity 版本的可用性,必须以该版本文档和最小项目实验为准。关键变量包括 Editor 内置 Roslyn 版本、分析器程序集的目标框架、导入标签、增量生成器 API 代际,以及生成结果能否进入后续 Player 编译。桌面.NET SDK项目中可运行的生成器,不保证能被 Unity 直接加载。
对强依赖生成代码的框架,可以选择在 Unity 外部的可控构建步骤预生成.cs文件,将其作为可审查的输入再交给 Unity。代价是要管理生成物更新和确定性,收益是将编译器插件兼容问题从每位开发者的 Editor 环境移到 CI 中。
四、Mono 与 IL2CPP:不是“调试版”和“快速版”的区别
Mono 与 IL2CPP 是两条不同的脚本执行链路。简化地说,Mono 后端在支持的平台上执行托管程序集;IL2CPP 则把托管 IL 转换为 C++,再由平台工具链编译为原生代码。具体平台允许哪个后端,必须查当前 Unity 版本的 Build Target 说明。
两者应该在以下方面分别验证:
| 风险面 | Mono 下常见关注点 | IL2CPP 下常见关注点 |
|---|---|---|
| 代码生成 | 取决于平台和运行时配置 | 不能假设运行时可 JIT 出任意新代码 |
| 泛型 | JIT 路径可以覆盖部分运行时组合 | AOT 必须生成必要的泛型代码 |
| 反射 | 成员往往较容易在 Editor 中存活 | 裁剪可能移除仅由反射触及的成员 |
| 异常与调用 | 由 Mono 路径实现 | 经转换后进入原生调用链,设置会影响代价与调试 |
| 性能 | 受 JIT/AOT 方式、GC、代码形状影响 | 受 C++ 转换、原生编译器、泛型共享影响 |
| 调试 | Editor 反馈快,但不等于真机 | 构建慢,但更接近指定平台的最终行为 |
不要把 GC 简化为“Mono 用 Boehm、IL2CPP 用精确分代 GC”。Unity 的托管内存回收实现、增量模式和平台能力都与版本相关,不能从“IL2CPP 输出原生代码”推导出“没有托管 GC”或“必然是分代 GC”。工程上应用 Profiler 在对应版本和设备上记录分配率、托管堆规模与停顿,而不是依靠 GC 名称做预测。
五、反射、裁剪和 AOT 泛型要分开诊断
Type.MakeGenericType不是 IL2CPP 下无条件禁用的 API。真正的问题是:运行时组合出的闭合泛型是否已有必要的 AOT 代码,其类型、构造函数和成员是否在裁剪中存活。同一个问题可能表现为找不到类型、找不到成员、缺少 AOT 泛型代码,或者只在高裁剪级别失败。它们不能都归结为“反射不支持”。
[Preserve]和link.xml主要向托管链接器表达保留意图。它们能防止指定类型或成员被裁剪,却不能普遍地“修复所有 IL2CPP 泛型”,也不会让目标平台获得 JIT 能力。最稳定的方法通常是在静态可达代码中显式引用需要的闭合泛型和操作,并配合精确的保留规则。
// 实际列表应由项目的协议、序列化模型或注册表生成。 internal static class AotGenericReferences { // 由真实启动/注册入口调用;未被静态调用的方法本身仍可被链接器删除。 internal static void RegisterAll() { Register<int>(); Register<PlayerSnapshot>(); } private static void Register<T>() { _ = typeof(MessageHandler<T>); _ = new List<T>(); } }上例只表达“让泛型组合出现在静态代码中”的思路;RegisterAll必须由游戏真实的启动、依赖注入或协议注册入口调用,并在 IL2CPP Player 中执行所需的闭合操作。一个从未可达的Touch式辅助方法仍可被链接器整段删除,因此不能作为 AOT 证据。即便入口可达,也不保证对任意 Unity 版本和任意操作都足够。如果序列化框架会反射调用非公开构造函数或属性,还要对那些成员表达保留意图。如果框架根据服务端数据任意组合新类型,则要收紧协议、生成静态注册表,或重新设计执行路径。
裁剪配置应遵循“最小保留”:保留整个程序集虽然可能快速规避缺失,但会扩大构建体积,还可能掩盖未注册的动态依赖。每条规则应说明是谁通过什么动态路径使用该成员,并有 Player 测试覆盖。
六、异步代码:语法可用不等于帧调度正确
async/await首先是由编译器生成状态机,但它的实际行为还取决于所等待的对象、继续执行所在的上下文、对象生命周期与平台的线程能力。网络 I/O 的异步等待不等于将 CPU 密集工作自动移出主线程;继续回到 Unity 上下文也不代表对象仍然存活。
工程中需要明确四件事:谁持有异步操作,如何取消,异常在哪里观测,续体在哪个线程或玩家循环阶段执行。应避免除事件处理器等边界外的async void,因为调用者无法等待它或正常聚合它的异常。
不同 Unity 版本可能提供特定的异步类型与 PlayerLoop 集成,第三方库也可能提供不同的取消和分配特性。选型时不要只比较“是否零 GC”,还要比较调试栈、异常传播、与对象销毁的绑定、平台支持和团队可维护性。
七、托管集合的建议要精确到代码形状
“用for代替foreach”不是 Unity 的通用定律。直接遍历List<T>时,枚举器是值类型,常见编译路径不会因此产生堆分配。但将它转为非泛型IEnumerable、经过某些接口调用或构造 LINQ 查询,可能改变分派、装箱与分配。评估对象应是完整调用路径,不是一个关键字。
“用struct代替class”同样不完整。小型、不可变、具有值语义的数据适合值类型;大型结构体会增加复制成本,通过接口或object使用时可能装箱,而且将可变结构体放入List<T>时,取出后修改的往往只是副本。选择应基于语义、大小和访存模式。
字符串也不是天生不适合作为Dictionary键。它不可变,且可以用明确的字符串比较器表达大小写语义。真正的问题是否在热路径反复拼接新字符串,键是否过长,比较规则是否正确,以及是否已有稳定 ID 可用。配置表边界使用字符串、加载时转换为内部 ID,往往比在所有层强制一种键类型更清晰。
public sealed class ItemCatalog { private readonly Dictionary<string, int> _idByName = new Dictionary<string, int>(StringComparer.Ordinal); public bool TryResolve(string externalName, out int id) => _idByName.TryGetValue(externalName, out id); }预分配List<T>容量可以减少扩容次数,却不是“零 GC”保证:初始数组本身仍需要内存,元素或其他路径也可能分配;过度估算还会增加驻留内存。应在目标设备的 Player 中,用典型容量和峰值容量分别验证。
八、Burst、Jobs 和原生容器是一套另外的契约
NativeArray<T>、NativeList<T>和原生哈希容器不只是 BCL 集合的“更快版”。它们面向原生内存、Job System 安全规则和 Burst 可分析的代码子集,带来了显式生命周期、Allocator 选择、依赖调度和访问限制。忘记Dispose、在 Job 完成前释放、或隐藏写依赖,都是比托管 GC 更严重的正确性问题。
Burst 能否优化一段代码,取决于数据布局、分支、别名、向量化机会、Job 粒度与目标 CPU。小规模工作的调度和同步开销可能超过计算本身,把对象模型转换为原生数据也需要成本。因此不应声称 DOTS 容器必然比 BCL 集合快一到两个数量级。
正确的评估步骤是:先确认可并行的大块计算和数据所有权,再建立等价输入与输出的 Player 基准,分别记录转换、调度、同步和计算时间,最后在目标 CPU 上核对 Burst Inspector 和 Profiler 证据。性能结论要附包版本、Unity 版本、平台、数据规模、代码和原始结果,不能从技术名称推导倍数。
九、升级 Unity 时的可执行流程
升级不应直接在主开发分支打开项目。首先固定当前基线:提交ProjectVersion.txt、Packages/manifest.json与 lock 文件,记录各目标平台的后端、API 级别和裁剪级别,保存可重复的构建与性能样本。
然后在隔离分支中按顺序处理:
- 用目标 Editor 执行批处理导入,保存完整日志,先消除编译错误。
- 核对所有直接与间接包版本,不在同一步无限制更新业务代码和全部包。
- 编译每个 asmdef 边界,核对宏、平台排除、unsafe 和预编译 DLL。
- 运行 EditMode 和 PlayMode 测试,再构建最小 Player;Editor 通过不是收尾条件。
- 对每个受支持的后端和关键平台执行反射、序列化、异步、原生插件和泛型探针。
- 开启项目目标裁剪级别,检查警告、构建报告、崩溃栈和保留规则。
- 在目标设备回放固定场景,对比 CPU、GC、内存、启动时间和包体。
- 把新的工具链矩阵和已知差异写入项目文档,再分阶段合并。
当项目跨越多个大版本时,中间版本可能包含资产迁移或包升级步骤。应按官方升级指南选择路径,并保留可回退的版本控制节点,而不是猜测能否一步跨越。
一项能力从源码到玩家设备会经过多个可独立失败的关口。下图把失败点放回对应层次,用于决定应写语法探针、API 探针、裁剪探针还是真机运行测试。
一个关口通过只能证明该关口。Editor 编译成功不能证明裁剪后元数据存活,构建 IL2CPP Player 成功也不能证明动态路径已在设备上真正执行。
十、把兼容性探针放进 CI
大型项目不应依赖开发者记得哪些功能在哪个平台失败。可以建立一个小型Compatibility.Probesasmdef,只包含项目实际依赖的边界能力。探针不需要覆盖全部 C# 语法,而要锁定会破坏项目的部分,例如:
- 编译器探针:实际使用选定语法,保证目标 Editor 能批处理编译。
- API 探针:引用关键 BCL 类型与重载,防止 Api Compatibility Level 变更未被发现。
- 泛型探针:执行项目实际会用到的值类型、接口和反射组合。
- 裁剪探针:通过与生产相同的动态路径访问序列化成员,不直接引用绕过问题。
- 异步探针:验证取消、异常传播和销毁对象后的续体行为。
- 原生探针:加载插件、运行最小 P/Invoke、调度一个 Burst Job 并释放容器。
public static class RuntimeProbe { // 使用业务真实会反射创建的类型,不用无关的演示类型。 public static object CreateClosedHandler(Type payloadType) { Type openType = typeof(MessageHandler<>); Type closedType = openType.MakeGenericType(payloadType); return Activator.CreateInstance(closedType) ?? throw new InvalidOperationException(closedType.FullName); } }CI 至少要做两层验证:第一层是 Editor 批处理导入、编译与单元测试;第二层是真实 Player 构建和运行。如果每次提交无法覆盖所有设备,可以对主后端执行快速门禁,对高风险平台做定时构建和真机冒烟。但只生成 Player 不足以覆盖动态路径,必须启动它并执行探针。
每份结果应保存 Editor 版本、包锁定文件哈希、Build Target、后端、API 级别、裁剪级别和 Development 标志。缺少这些元数据的“成功”日志,在半年后很难成为可用证据。
10.1 无法全自动化时的最小手工验证矩阵
当真机农场、主机开发机或商店签名流程无法接入每次 CI 时,至少保留下列三格。它们的用途不同,不能用 Editor 的通过替代真实 Player 结论。
| 验证格 | 固定配置 | 最少执行内容 | 必须保存的证据 |
|---|---|---|---|
| Editor Play Mode | 目标 Unity 精确 patch,Editor Mono | 语法/API 探针、EditMode/PlayMode 测试、反射与序列化快速路径 | Editor 日志、测试结果、包锁定文件 |
| Standalone Development Player | 与发布一致的 API profile、裁剪级别与脚本后端,优先 IL2CPP | 启动 Player 执行闭合泛型、反射、序列化、取消、一个 Burst Job 和原生插件探针 | Player 日志、Build Report、裁剪警告、崩溃符号 |
| 最低档真实设备 Release Player | Non-Development,发布后端与主 CPU 架构 | 固定输入回放、快速场景切换/取消、Job 完成后释放、帧时与内存峰值 | 设备日志、Profiler capture 或平台 trace、输出 checksum、峰值内存 |
每格在执行前记录 Unity patch、Build Target、OS/CPU、API Compatibility Level、Scripting Backend、Architecture、Development、Managed Stripping Level、Incremental GC、Burst/Collections 及业务包版本、构建 commit、场景版本、输入规模和随机种子。
手工流程也要可重复:冷启动一次,预热后用同一输入连续运行三轮;每轮先核对 checksum、实体数和顺序,再记录 P50/P95/P99 帧时、GC.Alloc与峰值内存;至少一轮在异步和 Job 进行中快速切换场景,验证旧世代结果不提交、NativeContainer 不提前释放。内存疑似累积时,在“预热基线 A—重复操作后清理 B—再重复并清理 C”三个同状态点取快照,不用一次 A/B 差异直接宣告泄漏。
最小通过条件是:没有缺成员、AOT 或裁剪路径异常;没有 Safety/NativeContainer 生命周期错误和旧世代提交;功能 checksum 一致;P95/P99 与峰值内存在项目预算内;快照增长已稳定,或每个持续增长都有明确 owner、容量上限与淘汰规则。若资源只允许两格,保留 Editor 诊断和最低档 Release IL2CPP 真机,删除中间格而不是删除真机。
十一、如何讨论 Unity 中 C# 的“未来”
未来方向可以根据已发布产品、公开预览和路线图来讨论,但三者的证据强度不同。已发布 Editor 和当前手册可以支撑“现在能做什么”;预览版可以支撑“正在试验什么”;路线图、论坛发言和问卷只能表达方向,不应当作交付版本或日期的承诺。
对项目规划有价值的“未来”问题不是猜下一个 C# 版本号,而是:项目是否已将新语法隔离于核心模型,是否有办法替换序列化和反射路径,是否能在新 Editor 上自动跑完平台矩阵,包和原生插件是否有明确的版本所有者。这些准备会同时降低 C#、BCL、IL2CPP 或平台 SDK 变更的成本。
团队可以维护一张“采用状态表”:
| 状态 | 含义 | 使用规则 |
|---|---|---|
| 已采用 | 所有支持平台已有 CI 证据 | 可在业务代码中使用 |
| 有限采用 | 只适用特定 asmdef、后端或平台 | 用边界与自动化检查约束 |
| 试验 | 只在探针项目中通过 | 不进入核心产品路径 |
| 禁用 | 已知平台失败或成本不可接受 | 记录原因和替代方案 |
十二、工程审查清单
当一项新 C# 功能、新 API 或新容器准备进入 Unity 项目时,可以用以下问题做最后审查:
- 我们记录的是完整 Editor 版本,还是只写了“Unity 6”这类产品名?
- 证据来自当前版本官方文档和实际编译,还是来自另一个 Editor 的经验?
- 该功能依赖语法、BCL、运行时还是平台能力?
- asmdef、包、条件宏和预编译 DLL 是否在各平台上一致?
- Editor、Mono Player 和 IL2CPP Player 中的最小探针分别是什么?
- 如果使用反射,哪些类型和成员仅通过动态路径可达?
- 保留规则解决的是裁剪问题,还是被误用来掩盖 AOT 或 JIT 限制?
- 异步操作的所有者、取消、续体线程和异常通道是否清晰?
- 性能结论是否包含目标设备、构建配置、数据规模与原始记录?
- 如果明天升级 Editor、包或平台 SDK,CI 能否自动证明关键能力仍然存在?
结语:支持性是一条证据链
Unity 中的 C# 不是一个单独的版本开关。一段代码要到达玩家设备,需要经过 Editor 内置编译器、程序集边界、BCL/API 契约、后端、托管裁剪、AOT 泛型生成和平台工具链。其中任何一层都可以让“IDE 里可写”变成“真机上不可用”。
最值得建立的能力不是背诵某个 Unity 大版本对应哪个 C# 数字,而是会查当前 Editor 的文档和设置,会用最小探针分离语法、API、裁剪与 AOT 问题,并把这些证据放进 CI。这样无论 Unity、C# 还是目标平台如何演进,团队都能在做出产品承诺前,先知道什么是真正可用的。
下一篇:Mono vs IL2CPP:两个后端的数据结构行为差异