一个让人抓狂的日常
你改了一行 C# 代码,点下 Build,然后……去泡了杯咖啡。回来发现还在转。
或者更糟:你只是想出个测试包给策划看看,结果构建跑了二十分钟,中间还卡在一个叫 “IL2CPP” 的阶段一动不动。
Unity 慢,几乎是每个用过它的人的共识。但"慢"是个太笼统的词。它到底慢在哪一步?是编译代码慢,还是处理美术资源慢,还是最后打包慢?
搞不清这个,你就只能干等着,或者盲目地去网上抄一堆"优化设置"碰运气。这篇我们把 Unity 的构建流程拆开,一段一段看清楚——每一段在干什么、为什么慢、慢的是首次还是每次。看完你至少能判断:我这次卡住的进度条,到底卡在哪个环节。
先建立一张地图:Unity 构建的完整流水线
跟 Gradle 一样,Unity 的"出包"也不是一个动作,而是一整条流水线。先把全貌摆出来,后面逐段讲:
① 资源导入(Asset Import) 把 png/fbx/wav 等源文件 → 转成 Unity 内部格式 ↓ ② 脚本编译(Script Compilation) 把你的 C# 代码 → 编译成 .NET 程序集(DLL) ↓ ③ 资源处理与序列化 收集场景、预制体、依赖,序列化成运行时格式 ↓ ④ IL2CPP 转换(仅当使用 IL2CPP 后端) 把 .NET 的 IL 中间码 → 转成 C++ 源码 ↓ ⑤ 原生编译与链接(Native Compile & Link) 把生成的 C++ → 用平台编译器编译成机器码 ↓ ⑥ 着色器编译(Shader Compilation) 编译所有用到的 shader 变体 ↓ ⑦ 打包(Packaging) 资源 + 代码 + 引擎 → 打成 APK/IPA/exe 等慢,就分布在这七段里。而且每一段"慢的性质"不一样——有的只慢第一次,有的每次都慢,有的只在出正式包时才拖你后腿。这个区分,是你诊断问题的关键。
第一大慢点:资源导入——只慢第一次,但第一次能要命
先说 ① 资源导入。
Unity 里你放进项目的 png、fbx、wav、psd 这些源文件,引擎其实"不认"。它需要把每个源文件转换成自己的内部格式(压缩纹理、网格数据等),这个过程叫Import(导入)。转换的结果,全部缓存在项目的Library/文件夹里。
这里的关键规律是:
资源导入是"首次很慢,之后基本免费"。
第一次打开项目 / 删了 Library / 换了台机器: → 所有资源从零导入,几千张贴图、几百个模型一个个转 → 可能要等十几分钟甚至几小时(大项目) ↓ 之后: → 导入结果都在 Library 缓存里 → 只有你改动过的资源才重新导入 → 日常几乎感觉不到所以那些"新人 clone 完项目,打开 Unity 卡了半小时"的经典场景,慢的就是这一步。它不是构建本身慢,是首次资源导入慢。
几个和它相关的实战点:
- 千万别把
Library/提交到 Git。它是缓存,能重新生成,而且巨大。但—— - 可以用 Unity Accelerator 或 Cache Server 共享导入缓存。团队里一个人导入过,其他人直接下载结果,不用各自重导。这是大团队治首次导入慢的标准方案。
- 贴图压缩格式选错会加剧这步的慢。尤其是移动端的 ASTC 压缩,本身就耗时,几千张图叠起来很可观。
第二大慢点:脚本编译与"域重载"——日常最烦人的那个卡顿
② 脚本编译,是你日常开发中感受最频繁的慢。
你改一行 C#,Unity 就得重新编译。但真正让你难受的往往不是编译本身,而是编译后紧跟着的一个动作——域重载(Domain Reload)。
简单说,Unity 编译完代码后,需要把整个脚本运行环境卸载再重新加载一遍,好让新代码生效。这个"卸了重装"的过程,项目越大越慢,因为要重新初始化的东西越多。
你改一行代码,按 Ctrl+S ↓ Unity 重新编译改动涉及的程序集 ↓ 域重载:卸载旧的脚本域 → 重新加载 → 重新初始化所有静态状态 ↓ ← 你就卡在这里,编辑器转圈,几秒到几十秒 终于可以继续操作 / 进入 Play 模式这一步的性质是"每次改代码都慢",是最消磨心力的那种慢。
治它的方向:
- 把代码拆成多个程序集(Assembly Definition,asmdef)。Unity 只重编改动所在的那个程序集,而不是把你所有代码全编一遍。这是缩短编译时间最有效的手段。别把几千个脚本堆在一个默认程序集里。
- 开启 “Enter Play Mode Options”,关掉 Domain Reload。新版 Unity 允许进入 Play 模式时跳过域重载,能极大加快"改代码→测试"的循环。代价是你得自己处理静态变量的重置,但收益巨大。
第三大慢点:IL2CPP + 原生编译——出包慢的头号元凶
现在到了重头戏。你出正式包(尤其移动端)时那漫长的等待,绝大部分是它俩造成的——④ IL2CPP 转换 和 ⑤ 原生编译。
先解释 IL2CPP 是什么。Unity 的 C# 代码,平时是编译成IL(中间语言)由虚拟机运行的。但为了性能和平台限制(比如 iOS 根本不允许 JIT),Unity 提供了一个后端叫IL2CPP,它会:
你的 C# 代码 → 先编成 IL 中间码 ↓ IL2CPP 登场 IL 中间码 → 翻译成等价的 C++ 源代码 (④,生成海量 .cpp 文件) ↓ C++ 源代码 → 用平台原生编译器编译成机器码 (⑤) ↓ 链接成最终的原生库问题就在这:它相当于把你的整个游戏、加上 Unity 引擎的一大堆代码,全部翻译成 C++,然后从头编译一遍 C++。C++ 编译本来就慢,而这里的 C++ 是机器生成的、量极其庞大。这就是为什么 Release 包比 Debug/Editor 慢一个数量级——Editor 下用的是快速的 Mono 后端,不走 IL2CPP 这条重路。
Mono 后端(Editor / 开发包): C# → IL → 直接跑 ← 快,改完马上能测 IL2CPP 后端(正式 Release 包): C# → IL → C++ → 编译 → 链接 ← 慢,每次出包都要走一遍这一步的性质是"出正式包时必慢,且往往是全流程最慢的一段"。
能做的优化有限,但有方向:
- 开发阶段尽量用 Mono 后端 + 快速测试,别动不动出 IL2CPP 包。只在真正需要真机验证性能或发布时才走 IL2CPP。
- 开启增量 IL2CPP 构建。新版 Unity 支持只重新处理改动的部分,而不是每次全量翻译+编译。这能显著缩短第二次之后的 IL2CPP 构建时间。
- 代码裁剪(Managed Stripping)是把双刃剑。它删掉没用到的代码,减少 C++ 生成量、加快编译;但设太激进会误删反射用到的代码导致运行时崩溃。需要配合 link.xml 权衡。
- 减少代码体量、少用泛型爆炸。大量泛型实例化会让 IL2CPP 生成成倍的 C++ 代码,直接拖慢编译。
第四大慢点:着色器编译——一个容易被忽视的"隐形杀手"
⑥ 着色器(Shader)编译,是很多人没意识到、却真实存在的一大慢点。
问题的根源是着色器变体(Shader Variants)。一个 shader 不是只编译一次——它会根据不同的关键字组合(有没有雾、几盏灯、什么光照模式……)编译出大量的变体。变体数量是各个开关的乘法组合,很容易爆炸到成千上万个。
一个 shader 有 5 个开关,每个开关 2 种状态: 2 × 2 × 2 × 2 × 2 = 32 个变体,每个都要单独编译 开关更多、再叠加多平台: 变体数轻松破万 → 编译时间以分钟计这一步的性质是"出包时慢,且随项目 shader 复杂度增长而急剧恶化"。而且它经常藏在 IL2CPP 之后,让你以为是别的环节慢。
治它的方向:
- 裁剪无用的着色器变体。用 Shader Variant Collection 只保留实际用到的组合,别让引擎把所有理论组合都编译出来。
- 利用着色器缓存。编译过的变体会缓存,同样受益于 Cache Server / Accelerator 共享。
把七段的"慢的性质"汇总成一张表
这张表是这篇的精华——它帮你在等进度条时快速定位:
环节 慢在何时 主要诱因 首要优化手段 ───────────────────────────────────────────────────────────────────── ① 资源导入 只慢首次 资源多/压缩重 Cache Server 共享 ② 脚本编译+域重载 每次改代码 代码堆一个程序集 拆 asmdef、关域重载 ③ 资源序列化 出包时 场景/依赖庞大 减少冗余引用 ④ IL2CPP 转换 出正式包 代码量大/泛型爆炸 增量构建、代码裁剪 ⑤ 原生编译链接 出正式包 C++ 量巨大 增量构建、开发用Mono ⑥ 着色器编译 出包时 变体组合爆炸 裁剪变体、缓存 ⑦ 打包 出包时 资源体积大 资源压缩、分包读这张表的方法:先问自己"我这次是日常改代码卡,还是出包卡?"
- 日常改代码卡→ 几乎一定是 ②(脚本编译+域重载),去拆程序集、关域重载;
- 出正式包卡了很久→ 大概率是 ④⑤(IL2CPP + 原生编译)或 ⑥(着色器),开增量构建、裁变体;
- 新机器/新 clone 第一次巨慢→ 是 ①(资源导入),上 Cache Server。
一个统一的认知:Unity 慢,本质是"全量 vs 增量"的问题
拆到这里,你会发现一条贯穿所有环节的主线,和上一篇讲 Gradle 增量构建时其实是同一个道理:
Unity 每一个慢环节,慢的根源几乎都是"该增量的时候做了全量"。
- 资源导入:首次是全量(没缓存),之后是增量(只导改动的);
- 脚本编译:不拆程序集就是全量重编,拆了就是增量;
- IL2CPP:不开增量就每次全量翻译编译,开了就只处理改动;
- 着色器:不裁变体就全量编译所有组合,裁了就只编用到的。
所以优化 Unity 构建速度,方法论其实高度统一:想尽办法让每一步只处理"变化的部分",别每次都从零来过。缓存(Library、Cache Server、着色器缓存)解决"跨次复用",增量构建(asmdef、增量 IL2CPP)解决"单次少做",两者配合,就是治慢的总纲。
结语
回到最初的问题——Unity 构建到底慢在哪?答案不是某一个点,而是分布在七段流水线里,且各有各的脾气:
- 首次打开慢,慢在资源导入,缓存能救;
- 日常改代码卡,卡在脚本编译和域重载,拆程序集、关域重载能救;
- 出正式包漫长等待,主要是IL2CPP + 原生 C++ 编译这条重路,外加着色器变体爆炸,增量构建和变体裁剪能救。
下次再对着转圈的进度条,别再笼统地骂"Unity 真慢"了。先看一眼它卡在哪个阶段的提示文字,对照上面那张表——你就能知道,这次拖住你的到底是哪一环,以及该往哪个方向下手。
看懂流水线的每一段,"慢"就从一团模糊的怨气,变成了一个可以定位、可以拆解、可以逐个击破的具体问题。这才是从"等构建"到"治构建"的分水岭。