1. 为什么要在命令行里打包 Unity 项目
1.1 从手动点击到一键构建的转变
做过 Unity 项目的人都知道,每次要出包的时候,流程基本是固定的:打开 Unity Hub,等编辑器加载完,切到 Build Settings,勾选目标平台,点一下 Build,然后等进度条走完。一个人做小项目的时候,这套流程没什么问题,点几下就完事了。但一旦项目进入多人协作阶段,或者需要频繁出包测试,手动点击的弊端就暴露出来了:容易漏勾场景、容易忘记切平台、容易忘记改版本号,而且每次都要占用一台机器的编辑器界面,人得盯着。
命令行打包的核心价值就在于把这一整套动作变成一条可以重复执行的指令。你写好一条命令,以后每次出包都跑同一条命令,参数固定、流程固定、产物路径固定,人为失误的空间被压到最小。更进一步,这条命令可以被 CI 系统调用,可以被定时任务调用,也可以被其他脚本嵌套调用,整个构建环节就从"人工操作"变成了"可编排的自动化步骤"。
标题里提到的 bat 和 shell,分别对应 Windows 和类 Unix 两大平台的脚本环境。bat 是 Windows 的批处理脚本,shell 通常指 Linux 或 macOS 下的 bash/sh 脚本。Unity 本身支持通过命令行参数启动编辑器并执行构建,所以我们要做的事情本质上就是:用 bat 或 shell 拼装出正确的 Unity 命令行参数,调用 Unity 可执行文件,等待构建结束,然后检查产物是否生成成功。
1.2 这套方案适合谁,解决什么问题
这套方案最适合三类人。第一类是独立开发者或者小团队,项目需要频繁出 Windows 包或者 Linux 包,想省掉重复点击的时间。第二类是负责搭建构建流水线的工程人员,需要把 Unity 构建接入到自动化流程里,bat 和 shell 是最通用的胶水层。第三类是想学习 Unity 命令行构建原理的开发者,理解这套机制之后,无论是写 Python 脚本还是接入更复杂的构建系统,底层逻辑都是相通的。
它解决的问题也很明确:把"打开编辑器手动点 Build"变成"执行一条脚本命令",让构建过程可重复、可记录、可自动化。对于需要每天出包、或者需要同时维护多个平台版本的项目来说,这个转变带来的效率提升是实打实的。
需要提前说明的是,Unity 命令行构建并不是什么黑魔法,它本质上就是让 Unity 以批处理模式启动,加载指定项目,执行你指定的静态方法,然后退出。理解了这个本质,后面所有的参数配置和踩坑排查都会变得有迹可循。
2. 核心原理与关键参数拆解
2.1 Unity 命令行构建的底层机制
Unity 编辑器支持一组命令行参数,其中和构建最相关的是-batchmode、-quit、-projectPath、-executeMethod、-logFile这几个。它们各自的作用需要分清楚,否则很容易出现"命令跑了但什么都没发生"的情况。
-batchmode让 Unity 以无界面模式运行,不弹出编辑器窗口。这个参数在服务器环境或者 CI 环境里是必须的,因为那些环境根本没有图形界面。-quit告诉 Unity 在执行完指定方法后自动退出,如果不加这个参数,Unity 执行完方法后会一直挂着不退出,脚本就会卡在那里等不到结束。
-projectPath指定要打开的项目路径,注意这个路径要指向包含 Assets 和 ProjectSettings 文件夹的那个根目录,而不是 Assets 目录本身。-executeMethod是最关键的一个,它指定要执行的静态方法,格式是命名空间.类名.方法名。这个方法必须是一个静态方法,而且通常需要放在 Editor 目录下,因为它只在编辑器环境下执行。
-logFile指定日志输出路径。这个参数看起来不起眼,但在排查构建失败原因的时候极其重要。如果不指定,日志会输出到默认位置,在 CI 环境里很难找到。指定一个明确的路径,构建失败时直接去看这个文件,能省下大量时间。
一个最基础的命令结构大概是这样:
Unity -batchmode -quit -projectPath "/path/to/project" -executeMethod BuildScript.PerformBuild -logFile "/path/to/build.log"这里的Unity是 Unity 可执行文件的路径,Windows 下通常是Unity.exe的完整路径,Linux 下是Unity的完整路径,macOS 下是/Applications/Unity/Unity.app/Contents/MacOS/Unity。这个路径在不同版本、不同安装方式下都不一样,所以脚本里最好把它作为一个变量抽出来,方便修改。
2.2 构建脚本方法该怎么写
-executeMethod指定的方法需要你自己在项目里写。这个方法的核心职责是配置 BuildPlayerOptions 并调用 BuildPipeline.BuildPlayer。一个典型的实现大概长这样:
using UnityEditor; using UnityEngine; public class BuildScript { public static void PerformBuild() { string[] scenes = { "Assets/Scenes/Main.unity" }; string outputPath = "Build/Windows/Game.exe"; BuildPlayerOptions options = new BuildPlayerOptions(); options.scenes = scenes; options.locationPathName = outputPath; options.target = BuildTarget.StandaloneWindows64; options.options = BuildOptions.None; BuildReport report = BuildPipeline.BuildPlayer(options); if (report.summary.result != BuildResult.Succeeded) { Debug.LogError("Build failed: " + report.summary.result); EditorApplication.Exit(1); } } }这里有几个细节值得展开说。第一,scenes数组必须显式列出要打包的场景,不能依赖编辑器里 Build Settings 的勾选状态,因为命令行模式下那些勾选状态不一定可靠。第二,locationPathName对于 Windows 平台要指向具体的 exe 文件,对于 Linux 平台要指向一个没有扩展名的可执行文件,对于 macOS 平台要指向一个 .app 目录。第三,构建失败时调用EditorApplication.Exit(1)返回非零退出码,这样外层的 bat 或 shell 脚本才能感知到失败,从而做出正确的处理。
注意:
EditorApplication.Exit的退出码会被外层脚本捕获,这是判断构建成功与否的关键信号。如果构建失败但退出码是 0,CI 系统会误以为构建成功,后续流程就会带着错误的产物继续跑下去。
2.3 为什么用 bat 和 shell 而不是直接写一条命令
有人可能会问,既然核心就是一条 Unity 命令,为什么还要用 bat 或 shell 包一层?直接手敲那条命令不行吗?
短期看确实可以,但实际项目里,一条命令往往不够。你需要先清理旧的构建产物,需要根据参数决定打哪个平台,需要在构建前修改版本号,需要在构建后把产物拷贝到指定目录,需要在构建失败时发送通知。这些动作串起来,就需要脚本的流程控制能力。bat 和 shell 是最轻量的选择,不需要额外安装运行时,Windows 自带 cmd,Linux 和 macOS 自带 bash,拿来就能用。
另外,脚本文件本身也是一种文档。一个新同事接手项目,看到 build.bat 或者 build.sh,就知道这个项目是怎么出包的,比口头传授或者写一堆文档要直接得多。
3. Windows 下用 bat 脚本打包的完整实现
3.1 bat 脚本的基本结构设计
Windows 下的 bat 脚本,核心是把 Unity 路径、项目路径、输出路径、日志路径这几个变量定义清楚,然后拼装命令并执行。一个可用的脚本大概长这样:
@echo off setlocal set UNITY_PATH=C:\Program Files\Unity\Hub\Editor\2022.3.10f1\Editor\Unity.exe set PROJECT_PATH=%~dp0.. set OUTPUT_PATH=%PROJECT_PATH%\Build\Windows\Game.exe set LOG_PATH=%PROJECT_PATH%\Build\build.log if not exist "%PROJECT_PATH%\Build" mkdir "%PROJECT_PATH%\Build" "%UNITY_PATH%" -batchmode -quit -projectPath "%PROJECT_PATH%" -executeMethod BuildScript.PerformBuild -logFile "%LOG_PATH%" if %ERRORLEVEL% neq 0 ( echo Build failed with error code %ERRORLEVEL% exit /b %ERRORLEVEL% ) echo Build succeeded endlocal这里%~dp0表示脚本所在目录,%~dp0..就是脚本所在目录的上一级,通常也就是项目根目录。用相对路径而不是硬编码绝对路径,能让脚本在不同机器上都能跑,只要目录结构一致就行。
if not exist ... mkdir这一句是确保输出目录存在,因为 Unity 的 BuildPipeline 不会自动创建不存在的目录,如果目录不存在,构建会直接失败。这个坑我踩过不止一次,日志里报的错误信息很隐晦,排查半天才发现是目录没建。
3.2 参数化与多平台支持
实际项目里往往需要打多个平台,这时候可以把平台作为参数传进来,脚本内部做分支处理:
@echo off setlocal set PLATFORM=%1 if "%PLATFORM%"=="" set PLATFORM=windows set UNITY_PATH=C:\Program Files\Unity\Hub\Editor\2022.3.10f1\Editor\Unity.exe set PROJECT_PATH=%~dp0.. set BUILD_DIR=%PROJECT_PATH%\Build\%PLATFORM% set LOG_PATH=%BUILD_DIR%\build.log if not exist "%BUILD_DIR%" mkdir "%BUILD_DIR%" "%UNITY_PATH%" -batchmode -quit -projectPath "%PROJECT_PATH%" -executeMethod BuildScript.PerformBuild -buildTarget %PLATFORM% -logFile "%LOG_PATH%" if %ERRORLEVEL% neq 0 ( echo Build failed for platform %PLATFORM% exit /b %ERRORLEVEL% ) echo Build succeeded for platform %PLATFORM% endlocal注意这里加了-buildTarget参数,它告诉 Unity 切换到哪个目标平台。这个参数的值需要和 Unity 内部的平台标识对应,比如Win64、Linux64、OSXUniversal等。同时,C# 构建脚本里的BuildTarget也要根据传入的参数做相应调整,两边要配合好。
提示:
-buildTarget参数在某些 Unity 版本上行为不太一致,有时候切换平台需要重新导入资源,耗时较长。如果项目需要频繁切换平台,建议把不同平台的构建拆成独立的脚本调用,避免在同一次执行里来回切换。
3.3 构建前的版本号注入
很多项目需要在构建时自动更新版本号,比如把当前时间戳或者 Git 提交号写进版本信息里。这个动作可以在 bat 脚本里做,也可以在 C# 构建脚本里做。在 bat 里做的思路是:先计算出版本字符串,然后通过环境变量传给 Unity,C# 脚本里用System.Environment.GetEnvironmentVariable读取。
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value') do set datetime=%%I set BUILD_VERSION=%datetime:~0,8%_%datetime:~8,6% set BUILD_VERSION=%BUILD_VERSION: =% set UNITY_PATH=C:\Program Files\Unity\Hub\Editor\2022.3.10f1\Editor\Unity.exe "%UNITY_PATH%" -batchmode -quit -projectPath "%PROJECT_PATH%" -executeMethod BuildScript.PerformBuild -logFile "%LOG_PATH%"C# 侧读取:
string version = System.Environment.GetEnvironmentVariable("BUILD_VERSION"); if (!string.IsNullOrEmpty(version)) { PlayerSettings.bundleVersion = version; }这样每次构建出来的包,版本号都是唯一的,方便追溯是哪个时间点出的包。这个做法在需要频繁出包做测试的项目里特别有用,测试同学反馈问题时,直接看版本号就能定位到对应的构建。
4. Linux 和 macOS 下用 shell 脚本打包的完整实现
4.1 shell 脚本的基本结构
shell 脚本相比 bat,语法更灵活,处理路径和字符串也更方便。一个基础的构建脚本大概是这样:
#!/bin/bash set -e UNITY_PATH="/opt/Unity/Editor/Unity" PROJECT_PATH="$(cd "$(dirname "$0")/.." && pwd)" BUILD_DIR="${PROJECT_PATH}/Build/Linux" LOG_PATH="${BUILD_DIR}/build.log" mkdir -p "${BUILD_DIR}" "${UNITY_PATH}" -batchmode -quit \ -projectPath "${PROJECT_PATH}" \ -executeMethod BuildScript.PerformBuild \ -logFile "${LOG_PATH}" echo "Build finished"set -e这一句很关键,它让脚本在任何一条命令返回非零退出码时立即终止。如果不加这一句,即使 Unity 构建失败了,脚本也会继续往下执行,最后可能打印出"Build finished"这种误导性的信息。
$(cd "$(dirname "$0")/.." && pwd)这一串是获取脚本所在目录的上一级目录的绝对路径,比 bat 里的%~dp0..稍微绕一点,但效果是一样的,都是为了让脚本不依赖硬编码路径。
4.2 处理 Unity 返回码与日志
Unity 在命令行模式下,构建成功返回 0,构建失败返回非零值。但这里有个细节:有时候 Unity 会因为一些非致命的问题返回非零值,比如某些资源导入警告。所以更稳妥的做法是,除了检查返回码,还要检查产物文件是否真的生成了。
"${UNITY_PATH}" -batchmode -quit \ -projectPath "${PROJECT_PATH}" \ -executeMethod BuildScript.PerformBuild \ -logFile "${LOG_PATH}" EXIT_CODE=$? if [ ${EXIT_CODE} -ne 0 ]; then echo "Unity exited with code ${EXIT_CODE}" tail -n 50 "${LOG_PATH}" exit ${EXIT_CODE} fi if [ ! -f "${BUILD_DIR}/Game" ]; then echo "Build artifact not found" tail -n 50 "${LOG_PATH}" exit 1 fi echo "Build succeeded"tail -n 50 "${LOG_PATH}"这一句在构建失败时把日志最后 50 行打印出来,方便直接在终端里看到错误原因,不用再去翻日志文件。这个习惯在 CI 环境里特别有用,因为 CI 的日志界面往往不方便直接查看文件。
4.3 macOS 下的特殊处理
macOS 下 Unity 的路径和 Linux 不太一样,通常在/Applications/Unity/Unity.app/Contents/MacOS/Unity。另外 macOS 下打包出来的产物是一个 .app 目录,检查产物是否存在时要用-d而不是-f。
UNITY_PATH="/Applications/Unity/Unity.app/Contents/MacOS/Unity" BUILD_DIR="${PROJECT_PATH}/Build/macOS" APP_PATH="${BUILD_DIR}/Game.app" if [ ! -d "${APP_PATH}" ]; then echo "Build artifact not found" exit 1 fi还有一个 macOS 特有的坑:如果 Unity 是通过 Hub 安装的,路径里会带版本号,比如/Applications/Unity/Hub/Editor/2022.3.10f1/Unity.app/Contents/MacOS/Unity。脚本里最好把这个路径做成可配置的,或者写一个查找逻辑,自动找到最新版本的 Unity。
5. 常见问题与排查技巧实录
5.1 构建失败但日志里没有明显错误
这是最常见也最让人头疼的情况。命令跑了,返回码非零,但日志里翻来翻去就是找不到明确的错误信息。这种情况通常有几个原因:一是构建脚本里的静态方法抛了异常,但异常信息被淹没在大量日志里;二是 Unity 在批处理模式下某些初始化步骤失败了,但没有明确报错;三是磁盘空间不足或者权限问题。
排查思路是:先在日志里搜索 "Exception"、"Error"、"Failed" 这几个关键词,通常能定位到大致范围。如果搜不到,就把日志级别调高,在命令里加-logFile -让日志直接输出到标准输出,这样能看到更完整的信息。另外,可以尝试在本地用同样的命令跑一次,看是否能复现,排除环境差异。
5.2 场景丢失或打包出来是空场景
这个问题通常是因为构建脚本里的scenes数组没有正确配置,或者场景路径写错了。Unity 在命令行模式下不会自动读取 Build Settings 里的场景列表,必须显式指定。路径要相对于项目根目录,比如Assets/Scenes/Main.unity,不能写成绝对路径或者带反斜杠的路径。
另一个可能的原因是场景文件本身有问题,比如引用了丢失的资源。这种情况下,在编辑器里打开场景可能看不出问题,但打包时会失败。排查方法是先在编辑器里手动打一次包,看是否有报错,如果有,先解决编辑器里的问题,再回到命令行构建。
5.3 构建速度慢或者卡住不动
命令行构建比编辑器里手动构建慢,这是正常的,因为批处理模式下没有界面加速,而且每次都要重新加载项目。但如果慢到离谱,或者直接卡住不动,就要排查了。
常见原因是项目太大,资源导入耗时过长。这种情况下可以考虑用 Asset Bundle 或者 Addressables 来减少每次构建的资源处理量。另一个原因是 Unity 在等待某些外部进程,比如许可证验证。如果是在 CI 环境里,许可证配置不正确会导致 Unity 一直卡在验证环节。这种情况下,日志里通常会有许可证相关的提示,检查一下许可证配置即可。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 返回码非零但日志无错误 | 异常被淹没或初始化失败 | 搜索 Exception/Error,提高日志级别 |
| 打包出来是空场景 | scenes 数组未配置或路径错误 | 检查构建脚本中的场景路径 |
| 构建卡住不动 | 资源导入慢或许可证验证 | 检查资源规模,确认许可证配置 |
| 产物文件不存在 | 输出目录未创建或路径错误 | 确认 mkdir 逻辑,检查路径拼接 |
| 版本号未更新 | 环境变量未传递或读取失败 | 检查 bat/shell 中的变量设置和 C# 读取逻辑 |
提示:每次构建失败时,第一件事是看日志的最后 50 行,第二件事是确认产物目录是否存在,第三件事是确认 Unity 路径是否正确。这三步能解决大部分常见问题。
6. 实操心得与进阶建议
6.1 把构建脚本纳入版本管理
构建脚本本身也是项目代码的一部分,应该纳入版本管理。这样做的好处是,构建流程的变更可以被追踪,出问题时可以回滚到之前的版本。另外,新同事拉下代码后,直接就能用同样的脚本出包,不需要额外配置。
脚本里的 Unity 路径最好不要硬编码,而是通过环境变量或者配置文件来指定。这样不同机器上只要设置好环境变量,同一份脚本就能跑起来。如果团队里有人用不同版本的 Unity,可以在脚本里加一个版本检查逻辑,版本不匹配时给出明确提示。
6.2 构建产物的命名与归档
每次构建出来的产物,建议带上版本号或者时间戳,避免覆盖。比如Game_20240101_120000.exe这样的命名,一眼就能看出是什么时候出的包。归档目录也建议按日期分文件夹,方便回溯。
如果构建频率很高,可以考虑只保留最近 N 个版本的产物,更早的自动清理。这个逻辑可以在 bat 或 shell 脚本里实现,也可以在 CI 系统里配置。目的是避免磁盘被历史产物占满。
6.3 与 CI 系统的衔接
bat 和 shell 脚本最大的价值之一,就是可以无缝接入 CI 系统。大多数 CI 系统都支持执行 shell 命令或者 bat 命令,你只需要把构建脚本的调用配置进去就行。需要注意的是,CI 环境通常是干净的,没有 Unity 许可证缓存,也没有项目依赖的缓存,所以构建时间会比本地长。可以在 CI 配置里加上缓存逻辑,把 Library 目录缓存起来,能显著减少构建时间。
另外,CI 环境里的 Unity 路径和本地可能不一样,所以脚本里的路径配置要支持通过环境变量覆盖。这样同一份脚本,在本地和 CI 上都能跑,只是环境变量不同而已。
6.4 一个容易被忽略的细节:退出码的传递
最后说一个容易被忽略但很重要的细节:退出码的传递。bat 脚本里,如果最后一条命令是echo,那么脚本的退出码就是echo的退出码,也就是 0,即使前面的 Unity 构建失败了。这会导致 CI 系统误判。所以 bat 脚本里要显式地用exit /b %ERRORLEVEL%把 Unity 的退出码传递出去。shell 脚本里,如果用了set -e,脚本会在 Unity 失败时立即退出,退出码就是 Unity 的退出码,这是符合预期的。但如果没用set -e,就要手动检查$?并exit相应的码。
这个细节看起来小,但在实际项目里,因为退出码传递错误导致 CI 误判的情况并不少见。构建失败了但 CI 显示绿色,产物是旧的,测试同学拿着旧包测了半天,最后发现根本没出新包,这种时间浪费完全可以避免。
我在实际项目里踩过的最大的一个坑,是构建脚本里的路径用了相对路径,本地跑没问题,到了 CI 环境里因为工作目录不同,路径全部失效。后来改成基于脚本自身位置计算绝对路径,才彻底解决。所以路径处理这件事,宁可多写几行代码算绝对路径,也不要用想当然的相对路径。