最近整理本地开发环境的时候,把我一直用着的 Unity 命令行工具开源了。它主要针对 Unity 2021 和 2022 这两代工程,解决我日常碰到的几个高频痛点:批量创建项目结构、生成代码骨架、一键打不同平台的包、检查资源引用问题、清理构建缓存。如果你也在维护一个或多个 Unity 工程,尤其是要跑 CI、带小团队、或者经常在两三个编辑器版本之间切换,下面这些设计思路和踩坑记录应该会有参考价值。
这个工具本身不复杂,也没有特别炫技的地方,核心思路就一句话:把编辑器里需要反复点击的操作,收敛成几个固定动词,变成参数化、可组合、能放进流水线的命令。它的名字我起了个朴素的叫法:unitool。下面按从设计到实操的顺序完整拆一遍。
1. 从一个很常见的痛点说起
1.1 编辑器里搬砖的困境
先描述一个挺真实的日常。我手上同时维护着三个 Unity 工程,两个跑在 2021.3 LTS,一个跑在 2022.3 LTS。每天重复最多的其实不是写玩法逻辑,而是这些事:新同事来了要给他把开发环境拉起来、新模块启动时要一口气建十几个 MonoBehaviour 脚本、要发测试包的时候得在 Android 和 Windows 两条构建链之间来回切换、每次打完包还要翻日志确认有没有缺引用或者漏告警。
这些操作在编辑器里都能做,但有一个绕不开的问题:不可复用。今天手点五分钟,明天还是五分钟,偶尔漏一步,后面得花半个小时排查,而且别人完全不知道你做过什么。我尝试过写一份操作文档,结果文档很快过时。最终促使我动手的核心原因是:操作不可记录,就没办法自动化;不能自动化,就没法在服务器上跑。
1.2 命令行能解决的四个问题
这个 CLI 要解决的,本质上是四个问题:
- 可记录:命令本身自带参数和历史,执行过什么一目了然。
- 可复制:换台机器、换个项目,同一条命令可以重放。
- 可组合:构建、测试、通知可以串成一个工作流。
- 可判断:通过退出码和标准输出,脚本能直接拿到成功还是失败。
有些人可能觉得,Unity 编辑器里做个菜单也能做这些事。菜单当然可以做,但有两个限制:一是 CI 服务器和远程机器上根本没有图形界面,二是菜单的触发方式依赖人工,做不到定时触发和事件驱动。CLI 天然适合这种无头环境,这也是它存在的最大理由。
1.3 现有命令行的不足
Unity 官方自带的命令行参数能力其实不弱,-batchmode、-quit、-executeMethod 这些参数用好了能完成很复杂的自动化任务。但实际用起来有两个比较别扭的地方:
- 参数太散:每个人都要手动拼一长串路径和版本号,你不知道哪个版本装了哪个目录,经常在不同的机器上写出不同的命令。
- 和项目约定没有绑定:两个项目即便结构类似,打包路径、场景列表、符号定义也可能完全不同,导致同样的功能在项目 A 能跑,在项目 B 就跑不通。
所以我的目标很明确:做一个薄薄的封装层,把所有项目的差异收拢到一个配置文件里,对外只暴露几个稳定动词,内部去处理版本发现、路径拼接、日志采样这些脏活。
2. 工具整体设计与模块划分
2.1 功能总览
unitool 第一版规划了六个核心命令,模块划分纯粹按我自己的使用频率来:
| 命令 | 作用 | 对应痛点 |
|---|---|---|
| init | 初始化项目目录结构 | 新项目手动建目录太麻烦 |
| gen | 生成代码模板 | 大量重复的脚本骨架 |
| build | 构建各平台 | 打测试包/发布包流程统一 |
| check | 资源健康检查 | 缺引用、命名不规范难发现 |
| clean | 清理缓存目录 | Library 体积膨胀 |
| doctor | 版本探测与自检 | 多版本环境混乱 |
这里没有做“全都要”的功能大而全。每个命令只做一件明确的事,输出也尽量是结构化的,这样才能被上层脚本消费。我给自己的一个原则:如果一个功能在编辑器里点三下就能完成,就不值得加进 CLI;只有当它要反复做、且必须被脚本感知结果时才值得。
2.2 技术选型:Shell 壳层 + C# 扩展
这个工具最核心的选型决定,是分成两层来写。
第一层是壳层,用 Bash 实现,负责参数解析、Unity 版本探测、调用 Unity 进程、抓退出码和日志摘要。第二层是 Unity 项目侧的一批 C# Editor 扩展,放在每个项目的Assets/Editor目录里,负责真正干活——生成脚本、构建、检查资源。
为什么不用纯 Python?我最初确实用 Python 写过一版原型,后来放弃了。原因比较实际:目标机器都是装了 Unity 的开发机或 CI 机器,Unity 本身自带 Mono 运行时,但多一道 Python 依赖就要多维护一个部署环节。在所有类 Unix 环境里 Bash 天然存在,Windows 下可以用 Git Bash 或 PowerShell。而真正损耗性能的部分全在 Unity 进程内部,用什么壳语言无关紧要。
C# 侧的扩展则必须遵守 Unity 的一个硬性规则:凡是给 CLI 调用的方法,必须写在 Editor 程序集里,且是 public static。否则打包时会连同项目代码一起被剥离,CLI 根本找不到入口。
2.3 配置集中管理
如果每个命令都要手写清清楚楚的全部参数,那和直接用官方命令行没区别。unitool 的设计是在项目根目录放一个.unitool/config.json,把约定集中起来:
{ "targets": { "android": "Android", "win": "StandaloneWindows64", "ios": "iOS", "webgl": "WebGL" }, "buildPath": "Builds", "namespace": "Game", "scenes": [ "Assets/Scenes/Main.unity" ], "symbols": { "dev": "DEVELOPMENT_BUILD;ENABLE_PROFILER", "release": "" } }命令读取这个文件后,就能把build win --dev展开成完整参数。这么做的好处是,项目约定随仓库走,不依赖某个人记忆里的配置。新同事 clone 下来之后跑同一条命令,结果完全一致,这就把“环境差异”给消掉了。
3. 核心实现细节
3.1 与 Unity 批处理模式打通
Unity 官方给命令行提供了一组固定参数,我先把关键几个列出来:
-batchmode:以无窗口模式运行,不加载编辑器 UI。-quit:执行完指定操作后自动退出进程。-executeMethod ClassName.MethodName:调用某个静态方法。-projectPath:指定工程路径。-logFile:把日志输出到指定文件。
一个典型调用长这样:
"/Applications/Unity/Hub/Editor/2021.3.20f1/Unity.app/Contents/MacOS/Unity" \ -projectPath /path/to/project \ -batchmode \ -quit \ -executeMethod DevTools.CliCommands.BuildWindows \ -logFile /tmp/build.log这里有一个很容易被忽略的细节:-executeMethod指向的方法必须能被 Unity 在启动时加载到,所以它所在的程序集必须被Assets/Editor下的 asmdef 显式依赖,或者放在默认的Assets/Editor目录里。如果不满足,Unity 会直接报方法找不到,而且日志堆里很可能连明显报错都没有,只有一条返回码 1。
退出码的处理同样关键。Unity 批处理模式下如果脚本里抛出未捕获异常,进程会以非零码退出;但因为在某些场景下日志输出排山倒海,异常可能被淹没。我的做法是在 C# 侧统一包一层:
public static void BuildWindows() { try { var options = new BuildPlayerOptions { scenes = Config.Scenes, locationPathName = "Builds/win/main.exe", target = BuildTarget.StandaloneWindows64, options = BuildOptions.Development }; var report = BuildPipeline.BuildPlayer(options); if (report.summary.result != BuildResult.Succeeded) { UnityEngine.Debug.LogError($"[unitool] build failed: {report.summary.result}"); EditorApplication.Exit(1); return; } UnityEngine.Debug.Log("[unitool] build success"); EditorApplication.Exit(0); } catch (System.Exception e) { UnityEngine.Debug.LogException(e); EditorApplication.Exit(1); } }你可能会问,为什么要手动调用EditorApplication.Exit(0)?因为默认情况下-quit会在方法执行完之后尽快退出,但若构建过程里有未处理的后台任务,进程可能会挂住。自己调 Exit 能更精确地控制退出时机。
3.2 版本发现与自动切换
多版本共存是 Unity 项目绕不开的话题,尤其团队里有人用 2021.3 有人用 2022.3。我采用的方案是:由 CLI 从ProjectSettings/ProjectVersion.txt读取m_EditorVersion字段,再去本机 Unity Hub 的安装目录里找匹配路径。
不同系统的默认安装路径差别很大:
| 系统 | 默认路径模式 |
|---|---|
| macOS | /Applications/Unity/Hub/Editor/<version>/Unity.app/Contents/MacOS/Unity |
| Windows | C:/Program Files/Unity/Hub/Editor/<version>/Editor/Unity.exe |
| Linux | ~/Unity/Hub/Editor/<version>/Editor/Unity |
我在 Bash 里做的版本探测函数,核心逻辑就是解析文本和拼路径:
detect_unity() { local project="$1" local version version=$(grep -oE '[0-9]{4}\.[0-9]+\.[0-9]+[abf][0-9]+' \ "$project/ProjectSettings/ProjectVersion.txt" | head -1) if [ -z "$version" ]; then echo "[unitool] cannot detect editor version" >&2 return 1 fi local candidates=( "/Applications/Unity/Hub/Editor/$version/Unity.app/Contents/MacOS/Unity" "/c/Program Files/Unity/Hub/Editor/$version/Editor/Unity.exe" "$HOME/Unity/Hub/Editor/$version/Editor/Unity" ) for p in "${candidates[@]}"; do if [ -x "$p" ]; then echo "$p" return 0 fi done echo "[unitool] editor not found for version $version" >&2 return 1 }注意[abf][0-9]+这个匹配,Unity 的版本号里 a 代表 alpha,b 代表 beta,f 代表正式版。如果匹配太宽松,很容易把 2022.3.0f1 匹配成 2022.3.0b1,导致工具拿错版本。我在这上面吃过亏。
3.3 代码模板生成的细节
gen命令是我使用频率最高的命令。原理不复杂:模板文件是纯文本,里面用占位符标记替换位置。像这样:
// ====== template_script.txt ====== using UnityEngine; namespace __NAMESPACE__ { public class __CLASSNAME__ : MonoBehaviour { private void Awake() { } private void Start() { } } }替换过程用普通的字符串替换就够,不需要上正则引擎。我这样调用:
unitool gen script PlayerController --namespace Game工具拿到名字和命名空间后,生成文件放到Assets/Scripts/Game/目录。这里有个很少有人提前意识到的问题:Unity 资源系统依赖.meta文件里的 GUID,新文件没有.meta时,Unity 会在下次启动编辑器时自动生成。但如果你希望新目录首次导入时就能立刻被 AssetDatabase 检索到,需要在生成脚本后触发一次AssetDatabase.Refresh()。
我建议的流程是:本地生成后不要立刻把目录提交到仓库,先启动一次编辑器让 Unity 把.meta补齐,再提交。不然同事拉取代码后首次导入虽然也能自动生成.meta,但会出现大量文件被视为“新增”的情况,Diff 非常难读。
3.4 资源体检的底层逻辑
check resources是给项目做“体检”的命令,做了三件事:
- 找出场景和预制体里的 Missing Script 引用。
- 找出命名和路径不一致的脚本文件(比如
PlayerController.cs放在了不该放的位置)。 - 扫描重复引用的美术资源,输出清单。
实现思路依赖AssetDatabase.GetAllAssetPaths()拿到全部资源路径,然后对场景和预制体逐项遍历组件。实现在 C# 侧并不复杂:
static void CheckMissingScripts() { var assetPaths = AssetDatabase.GetAllAssetPaths(); foreach (var path in assetPaths) { if (!path.EndsWith(".prefab") && !path.EndsWith(".unity")) continue; var go = AssetDatabase.LoadAssetAtPath<GameObject>(path); if (go == null) continue; foreach (var component in go.GetComponentsInChildren<Component>(true)) { if (component == null) { Debug.LogWarning($"[unitool] missing script in {path}"); } } } }这个功能在 GUI 里逐项查看挺烦,放到 CLI 里变成文本输出却非常顺手。输出格式我固定成[unitool][warning] ...前缀,这样外面脚本只需要按前缀过滤就行,不用去解析 Unity 原生一坨坨日志。
4. 实操过程与关键步骤
4.1 安装与初始化
unitool 本身不需要安装到 Unity 包管理器里,它是一个独立仓库。我推荐放在一个固定的本地目录,比如~/tools/unitool,然后为 bin 目录建一个别名:
git clone https://example.com/your/unitool.git ~/tools/unitool echo 'alias unitool=~/tools/unitool/bin/unitool.sh' >> ~/.bashrc首次运行建议先执行unitool doctor,它会扫描本机上所有已安装的 Unity 编辑器,生成一份可用版本清单。这个自检步骤能避免后续因为路径问题卡壳。如果你所在团队用的编辑器版本比较固定,也可以把 doctor 的输出缓存下来,写进.unitool/config.json。
4.2 用命令初始化新项目
初始化一个新工程,以前我的流程是下载模板工程,或者打开编辑器手动画一堆界面。现在一行命令:
unitool init MyNewGame它会创建Assets、Packages、ProjectSettings、.unitool这几个目录,写入最小可用的manifest.json和ProjectSettings.asset。这样创建出来的项目第一次用对应版本打开时,不会有一堆默认的包下载,也不需要手动去配置基础设置。它不是一个完整的 URP 模板,但足够作为纯逻辑工程或工具工程的起点。
如果你是做设计向项目,init 之后可以再手动从官方模板创建,然后加一个--copy-from /path/to/template选项把模板工程里的 Assets 拷贝过来。我平时两种方式混用。
4.3 生成脚本和构建的实操演示
生成脚本的命令我前面提到过,再补充两个实际用法:
# 生成一个带命名空间的普通脚本 unitool gen script PlayerController --namespace Game # 生成之后立刻打开对应目录 unitool gen script GameManager --open构建命令则是这样:
# Windows 开发包 unitool build win --dev # Android 发布包,指定签名文件 unitool build android --release --keystore path/to/keystore --keyalias myalias每次构建结束时,CLI 会打印统一格式的结果摘要:
[unitool] start 2023-05-20T10:00:00Z [unitool] build target=StandaloneWindows64 result=Success duration=214s [unitool] artifact ./Builds/win/main.exe这种格式不是随手定的,是为了让后续流水线里可以用grep '^\[unitool\]'直接把结果行抽出来,再用 awk 或 jq 解析字段。这是我在被日志解析坑了好几次之后定下的规矩:自定义工具的输出一定要能被脚本轻松消费。
4.4 在 CI 流水线里组合使用
unitool 在 CI 上的价值在于可以组合。下面是一个不绑定特定平台的流水线示例:
stages: - build - verify unity_build: stage: build script: - unitool build android --release - unitool clean cache artifacts: paths: - Builds/android/player.apk unity_verify: stage: verify script: - unitool check resources - unitool doctor这里我把“构建”和“验证”拆成两个阶段,构建产物单独归档,验证阶段跑资源检查,如果发现问题流水线就失败,不会给测试同学发一个带有 Missing Script 的安装包。整个过程没有人参与,通过与否完全由退出码决定。
4.5 清理缓存的重要性
Unity 的Library目录会随项目规模膨胀,动不动就几个 GB。clean cache其实没有做复杂操作,就是删掉Library下的缓存子目录,或者在确认后把整个 Library 删除。需要注意的是,删除 Library 后下次打开工程需要重新导入资源、重新生成索引,耗时可能不短。所以我不建议频繁删,只在 CI 机器或者换机器后使用。
我一般配合这样一个策略:CI 机器上的 workspace 每次构建完就整个清理,开发机上的本地工程反而不乱动,保留增量缓存能明显提升编辑器打开速度。
5. 常见问题与排查技巧
5.1 Unity 进程挂住不退出
这是 CLI 最怕的问题。明明加了-quit,进程却一直不结束。常见原因有三个:后台有异步操作未完成、Mono 编译进程等待锁、或者构建管线里的排队任务没被释放。解决思路是在 C# 入口处显式EditorApplication.Exit(0),同时外层 Bash 脚本加一个超时保护:
timeout 600 "$unity_path" -projectPath "$proj" -batchmode -quit \ -executeMethod "$method" -logFile "$logfile" || { echo "[unitool] timeout after 600s" >&2 exit 2 }超时之后退出码设为 2,和正常失败(1)、成功(0)区分开,流水线里才能做出不同的后续处理。
5.2 executeMethod 找不到方法
如果你确定方法存在,但仍然报 Method Not Found,先检查三件事:
- 方法是否在 Editor 程序集里。只要引用了 UnityEditor 的程序集默认就是 Editor 程序集,但如果你自定义了 asmdef,要确认它只在 Editor 编译。
- 方法是否是 public static。
- 方法名是否包含完整命名空间,比如
DevTools.CliCommands.BuildAndroid,不要只写BuildAndroid。
除此之外,还有一类隐蔽情况:第一次执行命令时工程里恰好有脚本编译错误,Unity 会先尝试编译,编译失败后直接中断,此时日志里找不到 executeMethod 相关报错,只看到一堆 CS 编译错误。解决办法是本地先把编译错误修完,或者 CI 里先跑一次不带 executeMethod 的编译检查。
5.3 不同 Unity 版本的路径和行为差异
2021 和 2022 两代版本在 API 上总体兼容,但确实有几个烦人的差异:
- Android 构建用的 Gradle 版本不同,2021 默认 Gradle 6.x,2022 默认 Gradle 7.x。两套构建缓存在本机分别落地,路径不同,清理缓存时要分开处理。
BuildPipeline.BuildPlayer的返回从 BuildReport 变成了一种可空类型,在少数情况下需要判空。- 部分 UnityEditor API 在 2022 中标记 Deprecated,虽然还能用,但会有告警,模块日志比 2021 长出一截。
我的经验是:不要仅凭 API 文档判断,以实测为准。多版本项目里,所有命令都先做一次最小构建验证,确认该版本在这台机器上能跑通再进入正式流程。
5.4 日志解析踩坑
Unity 原生日志有多吵,跑过一次 CLI 的人都知道。更麻烦的是,日志里经常出现彩色控制字符和中文乱码,直接按字符串匹配经常误判。我最开始的脚本就是直接 grep 关键错误,结果误报率很高。
后来改成两条原则:一是只解析我们自己打印的[unitool]前缀行;二是把原始日志完整落盘,交给人工排查,而不是让脚本去猜。这样脚本逻辑极其简单,误报率几乎为零。
5.5 路径空格和引号问题
Windows 系统下 Unity 的默认安装路径通常不带空格,但公司办公环境里经常会有自定义安装目录带空格的场景。Bash 里如果不把路径变量加引号,经常报command not found,而且报错信息非常有迷惑性。
我的统一做法是,所有变量引用都套上双引号:
"$unity_path" -projectPath "$proj" "$@"脚本写完后再做一次“空格路径冒烟测试”,这是我在多个 CI 环境里吃过后才养成的习惯。
5.6 问题速查表
| 问题 | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 进程不退出 | 命令超时 | 后台任务挂起 | Exit 显式退出 + timeout 保护 |
| 方法找不到 | executeMethod 报错 | 程序集不是 Editor | 检查 asmdef 和 static |
| 编译失败被吞 | 只有 CS 错误 | 首次编译失败 | 先跑编译检查再执行业务 |
| 版本匹配错 | 用错引擎 | 正则匹配过宽 | 限制 [abf] 后缀 |
| 日志解析误报 | grep 到错误行 | 原生日志太杂 | 只解析 [unitool] 前缀 |
| Windows 路径空格 | 命令找不到 | 引号缺失 | 变量统一加引号 |
6. 最后分享几个实际体会
如果现在让我重新做一遍,我依然会选择“Decoupled 两层结构”:外壳只做胶水,业务逻辑放 C#,这是最稳的边界。它让开发新命令时能直接在 Visual Studio 里断点调试,不用来回切语言,也让 Bash 侧保持极简,几乎不需要测试。
另外,我强烈建议不要一开始就追求功能多。我最初这个工具只有build一个命令,后来才逐步加进去gen、check、clean。每加一个命令之前,我都会问自己:这功能一个月里被用到几次?如果少于三次,就不值得进 CLI。工具是为了省时间而存在的,不该成为另一个需要维护的负担。
我在实际使用中最满意的场景是:一个完全没有配过环境的新同事,clone 下仓库后执行一行unitool doctor && unitool init . && unitool build win,半小时内跑出了一个可以玩的 Windows 包。那个瞬间让我觉得,为这些重复劳动写的代码,比很多花哨的功能都值。