环境:Windows 11、Unity 2020.3.36f1。
打开一个 Unity 项目后,编辑器反复弹出 Enter Safe Mode?。进入安全模式,Console 里有大量错误;重启后,提示依然存在。有时错误指向 UI,有时指向测试框架,还有一些是"找不到命名空间"。
这类问题容易让人误以为项目突然坏了,或者需要重装 Unity。但在这次排查中,最早值得关注的错误并不是业务代码,而是编译流程中的 DLL 访问和复制失败。
本文记录实际遇到的现象、走过的弯路,以及最终恢复脚本编译的过程。需要提前说明:最后一轮 C# 编译已经通过,但没有完成所有后续验证,也没有通过严格对照实验确认唯一根因。
一、先看第一条有效错误,别被错误数量带偏
本次出现过几种典型报错。下面保留关键结构,移除了真实路径与业务信息。
1. CS0009:无法读取程序集元数据
text
error CS0009: Metadata file
'...\Library\ScriptAssemblies\UnityEngine.TestRunner.dll'
could not be opened -- The process cannot access the file
because it is being used by another process.
排查时,应把整个错误读完。这里不仅有 CS0009,后半句还明确指出了文件共享访问冲突。只搜索错误编号,可能会找到其他元数据损坏场景,方向未必相同。
2. 编译生成的 DLL 无法复制到输出目录
text
Copying assembly from 'Temp/Assembly-CSharp.dll'
to 'Library/ScriptAssemblies/Assembly-CSharp.dll' failed.
Detailed error: Sharing violation on path ...
另一次尝试中,复制失败的详细信息变成了目标 DLL 不存在。因此,不能把所有失败都概括成"同一个文件一直被某个进程占着",每一轮仍需看具体错误。
3. 后续出现大量 CS0234、CS0246
text
error CS0234: The type or namespace name 'TestRunner'
does not exist in the namespace 'UnityEngine' ...
error CS0246: The type or namespace name '...'
could not be found ...
如果某个依赖程序集没有成功生成、复制或被编译器读取,引用它的其他程序集就可能继续报错。于是,一次较早的失败会在 Console 中表现成几十条甚至更多错误。
这时应先问:
- 最早失败的是哪个程序集?
- 它是在编译阶段失败,还是在生成后的复制阶段失败?
- 后面的错误是否都依赖这个程序集?
错误数量可以说明影响范围,不能直接说明根因。
二、安全模式是提示,问题仍要从日志里找
Unity 的安全模式用于处理脚本编译异常。安全模式下,项目中的编辑器扩展也不会按正常模式运行;编译问题解决后,Unity 可以自动退出安全模式并继续导入资源。具体行为可参考 Unity 安全模式文档。
因此,反复点击 Ignore 或关闭安全模式提示,并不会修复编译流程。
在本次排查中,继续导入资源的实验只放在独立副本中进行。它用于观察后续行为,不能作为原项目已经恢复的证明。
三、先保存日志,再做下一轮操作
Windows 下,Unity 编辑器日志通常位于:
text
%LOCALAPPDATA%\Unity\Editor\Editor.log
同目录可能还有上一轮运行的 Editor-prev.log。反复重启前,建议先把相关日志另存,否则关键现场可能被新一轮输出覆盖。
可以用 PowerShell 先提取关键行:
powershell
$editorLog = Join-Path $env:LOCALAPPDATA 'Unity\Editor\Editor.log'
Select-String -LiteralPath $editorLog -Pattern @(
'error CS',
'Copying assembly',
'Sharing violation',
'Starting script compilation',
'Finished script compilation'
)
这条命令只做筛选,不会修改项目。阅读时还要结合命中行前后的上下文。
尤其要注意两个问题:
- 同一份日志可能包含多轮编译。 前面的错误不会因为后面修复成功就自动从文件中消失。
- "Finished script compilation"本身不能单独证明编译成功。 还要检查该轮的编译器退出结果、错误输出,以及后续程序集复制与加载情况。
发布日志时,也不要直接上传完整文件。先检查并移除访问令牌、许可证信息、用户名、真实项目路径和内部服务地址。技术文章通常只需要关键错误片段。
四、本次排查中,哪些尝试没有解决问题?
1. 重启编辑器:有诊断价值,但没有稳定解决问题
一开始尝试了重启 Unity、结束残留编译器进程。但后续仍然出现 DLL 访问异常,有时还换了报错文件。
这说明"再重启一次"已经不能提供多少新信息,应该转向查占用者、编译阶段和环境差异。
Unity 论坛中也有开发者报告过 Unity 2020.3 下随机 DLL 被占用、重启后再次出现的情况。同一讨论里,有人最终发现与同步软件有关,也有人讨论了 Burst 相关问题。这些是排查线索,并不能直接套用到另一个项目。相关讨论
2. 看到 VBCSCompiler.exe,不等于找到了占用者
VBCSCompiler.exe 与 Roslyn 共享编译服务有关,但进程存在,并不能证明它持有了出错 DLL 的冲突句柄。
本次曾把注意力过早集中到这个进程上,后来需要修正判断:日志证明了文件操作失败,却没有给出具体占用者。
更可靠的做法,是在错误发生时检查目标文件的句柄。例如,使用 Windows 资源监视器的"关联的句柄"搜索,或使用 Microsoft Sysinternals 的 Handle 工具。
Handle 的只读查询示例:
powershell
handle.exe UnityEngine.TestRunner.dll
这里是排查方法示例,并非本次已经获得的句柄查询结果。Handle 官方说明包含运行权限要求;不要为了排障直接强制关闭未知句柄。Microsoft Handle 文档
3. 发现外置盘是 exFAT,也不能立即下结论
原项目位于 exFAT 格式的外置盘,内置系统盘使用 NTFS。这是值得记录的环境差异,但不是充分证据。
如果要验证存储位置的影响,应尽量保持 Unity 版本、项目内容、包版本和其他条件一致,只改变项目位置。
本次没有完成这样的严格对照,因此不能得出"exFAT 就是根因"或"外置盘不能开发 Unity"的结论,更没有必要据此格式化磁盘。
4. 搬到 C 盘后,过长的目录又引入了干扰
第一次复制到 C 盘时,副本目录嵌套较深。复制包缓存时出现了路径相关异常,后续又出现额外的资源导入失败。
这让那次实验失去了干净对照的意义:项目位置改变了,路径长度也大幅改变,还引入了新的失败现象。
后来重新建立了短路径副本。类似实验可以使用下面这样的目录:
text
C:\UnityTests\DemoProject
这是示例路径。选择短路径是为了减少变量,不代表"放到 C 盘"本身就是解决方案。
五、备份缓存,和备份项目,是两件事
排查过程中重建过编译缓存。这里最容易混淆的是:备份 Library,并不能代替备份项目源文件。
操作前,应完整保存项目中的源资源、.meta、包清单和项目设置。若没有版本控制,可以先做一份可恢复的压缩备份。
对于本次涉及的缓存,采用的是关闭编辑器后保留旧目录,再让 Unity 重建的方式,而不是直接永久删除。示意结构如下:
text
DemoProject/
├─ Assets/
├─ Packages/
├─ ProjectSettings/
├─ Library/
└─ Library.backup/
这项操作只能让 Unity 重新生成缓存,不能保证消除外部扫描、进程占用或环境问题。如果重建后最早的共享冲突仍然出现,就应继续查原因,而不是不断重复清理。
也不要把删除资源 .meta 文件当成通用修复方式。排查编译缓存时,没有必要破坏项目资源的标识和引用关系。
六、检查实时扫描,但把验证范围控制住
排查时发现系统运行着第三方文件防护软件。结合随机 DLL 访问失败的现象,实时扫描成为一个待验证因素。
实际操作中,尝试通过设置对测试缓存进行临时排除,但没有成功添加排除项。随后,关闭了该第三方文件防护软件,再进行短路径副本的编译测试。
如果读者需要验证类似因素,优先考虑对测试副本的 Library 和 Temp 做范围明确、可恢复的临时实验,记录原设置,并在验证结束后恢复。不要直接把整个磁盘加入排除范围,也不要把长期关闭防护当作开发环境配置。
需要强调:本次最后恢复编译时,防护状态、目录长度和缓存状态都发生过变化。 因此,"关闭文件防护后编译通过"只能作为观察结果,不能单独证明因果关系。
七、依赖恢复后,真正的代码错误才暴露出来
短路径副本继续编译后,依赖程序集成功生成,之前阻塞流程的共享冲突暂未再次出现。
随后,编译器报出了项目脚本中的残留变量引用。这属于明确的代码问题,修正引用后,最新一轮 C# 脚本编译通过。
这一阶段说明:环境错误和代码错误可以同时存在。处理好前者,不代表后者一定没有问题;反过来,后续发现代码错误,也不能用它解释之前所有 DLL 文件访问失败。
排查时最好分开记录:
| 错误类别 | 典型现象 | 优先检查内容 |
|---|---|---|
| 源码编译错误 | CS0103 等,指向具体脚本和代码行 | 变量、类型、调用及修改差异 |
| 程序集读取异常 | CS0009,并附带文件占用信息 | 目标文件、占用句柄、编译环境 |
| 编译产物复制异常 | Temp 到 ScriptAssemblies 复制失败 | 源文件、目标目录、权限与共享访问 |
| 依赖缺失的连锁错误 | 大量 CS0234、CS0246 | 更早失败的依赖程序集 |
| 资源导入异常 | Asset import failed | 对应资源、包缓存、路径和具体异常 |
这些分类用于确定检查顺序,不能仅凭错误编号就跳过日志上下文。
八、最后确认到了哪一步?
截至本次记录的停止点:
- 短路径测试副本的最新一轮 C# 编译没有报错。
- 编辑器完成了进一步导入,并能够进入和退出 Play Mode。
- 后续自动验证仍有未通过项,因此没有把项目标记为"全部验证完成"。
同时,还有几项结论不能写进"已解决原因":
- 没有查明最初冲突句柄的具体持有者。
- 没有证明第三方文件防护软件是唯一原因。
- 没有证明 exFAT 是原因。
- 没有在原目录中完成全部复验。
所以,更准确的表述是:在短路径副本、缓存重建及文件防护状态变化后的组合条件下,恢复了脚本编译。唯一根因仍需进一步复验。
九、下次遇到同类问题,可以按这个顺序处理
- 先备份项目和日志。 保留源资源、
.meta、包清单、项目设置,以及失败现场。 - 找到当前轮次最早的有效错误。 分清源码编译、依赖读取、产物复制和资源导入。
- 确认有没有连锁错误。 不要看到几十条"找不到类型"就逐条修改引用。
- 在异常发生时查文件占用。 没查到句柄持有者之前,不给某个进程定责。
- 在独立、短路径副本中做对照。 尽量每次只改变一个条件,并记录结果。
- 需要重建缓存时保留回退。 关闭相关进程后再操作,避免边使用边移动缓存。
- 环境恢复后继续检查代码。 依赖问题解决后,仍可能存在独立的源码错误。
- 把编译通过和运行验证分开记录。 最新一轮没有 C# 错误,不代表所有功能都已验证。
这次排查最值得保留的经验,是让每一次操作都对应一个要验证的判断。重启、搬目录、重建缓存都可以是实验手段,但只有结合日志和对照结果,才能知道它们到底改变了什么。