☰
Unity命令行工具实战:从批处理到CI自动化构建
2026/10/17 5:11:45 网站建设 项目流程

最近整理本地开发环境的时候,把我一直用着的 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
WindowsC:/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 包。那个瞬间让我觉得,为这些重复劳动写的代码,比很多花哨的功能都值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询