ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity自动化打包实战:bat与shell脚本实现命令行构建与持续集成

Unity自动化打包实战:bat与shell脚本实现命令行构建与持续集成

1. 为什么要在 Unity 之外再套一层 bat 或 shell

做过 Unity 项目发布的人大概都有过这种体验:编辑器里点一下 Build,等进度条走完,得到一个 exe 或者一个可执行文件,然后手动把配置文件、资源目录、依赖库一个个拖到输出文件夹里,再压缩、再改名、再上传。一次两次还行,版本一多、平台一多,纯手工操作迟早要出事。漏拷一个配置文件、忘记改版本号、压缩包命名不一致,这些问题在团队协作里几乎必然出现。

bat(Windows 批处理)和shell(类 Unix 系统脚本)打包 Unity 的 exe,本质上就是把「Unity 命令行构建 + 产物整理 + 归档」这一整条链路脚本化。它的价值不在于省下那几分钟点击时间,而在于可重复、可追溯、可无人值守。只要脚本写对了,任何人、任何机器、任何时间跑出来的产物结构都是一致的,这对持续集成和版本管理来说是刚需。

这篇文章面向的是已经会用 Unity 编辑器出包、但还没把打包流程自动化的开发者。不管你是独立开发者还是团队里的构建负责人,只要你想让「出包」这件事从手工活变成一条命令,下面的内容都能直接拿去改改用。我会从 Unity 命令行构建的底层机制讲起,再分别拆解 Windows 下 bat 和类 Unix 下 shell 的写法,最后把踩过的坑和排查思路完整摊开。

需要先明确一个前提:Unity 的命令行构建能力是这一切的基础。Unity 支持通过-batchmode -quit -executeMethod这类参数在无界面模式下调用你自己写的静态构建方法。也就是说,脚本负责「调度」,真正的构建逻辑仍然写在 C# 里。理解这个分工,后面所有设计才站得住脚。

2. Unity 命令行构建的底层机制与脚本分工

2.1 命令行参数到底做了什么

Unity 编辑器本身就是一个可执行程序,Windows 下是Unity.exe,macOS 下是/Applications/Unity/Hub/Editor/<版本>/Unity.app/Contents/MacOS/Unity。当你用命令行启动它并带上特定参数时,它会以「批处理模式」运行,不弹窗口、不等待人工交互,执行完指定方法后按-quit退出。

几个关键参数的含义必须吃透:

  • -batchmode:无界面模式,不渲染编辑器 UI。这是自动化构建的前提,没有它脚本会卡在等待界面响应上。
  • -quit:执行完-executeMethod指定的方法后自动退出。如果不加,Unity 进程会一直挂着,脚本永远等不到结束。
  • -executeMethod:指定要执行的静态方法,格式是命名空间.类名.方法名。这个方法必须是无参的静态方法,且所在的类要放在Editor目录下,否则打包进 Player 时会编译报错。
  • -projectPath:指定工程根目录。脚本里通常用绝对路径,避免相对路径在不同工作目录下解析出错。
  • -logFile:指定日志输出文件。这个参数极其重要,后面排查构建失败全靠它。
  • -buildTarget:指定目标平台,比如Win64、OSXUniversal、Linux64。多平台构建时靠它切换。

一个典型的调用长这样:

Unity.exe -batchmode -quit -projectPath "D:/MyProject" \ -executeMethod BuildScript.PerformBuild \ -logFile "D:/logs/build.log" \ -buildTarget Win64

这里有个容易被忽略的点:-executeMethod里的方法如果执行过程中抛异常,Unity 默认可能仍然以退出码 0 结束,脚本会误以为构建成功。所以构建方法内部必须自己捕获异常并调用EditorApplication.Exit(1)来传递失败信号。这个细节我在第 5 节会展开。

2.2 构建方法应该写在哪、怎么写

构建逻辑写在 C# 里,放在工程的Assets/Editor目录下。一个最小可用的构建方法大概是这样:

using UnityEditor; using UnityEngine; using System; public class BuildScript { public static void PerformBuild() { try { string[] scenes = { "Assets/Scenes/Main.unity" }; BuildPlayerOptions options = new BuildPlayerOptions { scenes = scenes, locationPathName = "Build/Windows/MyGame.exe", target = BuildTarget.StandaloneWindows64, options = BuildOptions.None }; BuildReport report = BuildPipeline.BuildPlayer(options); if (report.summary.result != BuildResult.Succeeded) { Debug.LogError("构建失败: " + report.summary.result); EditorApplication.Exit(1); } EditorApplication.Exit(0); } catch (Exception e) { Debug.LogError("构建异常: " + e.ToString()); EditorApplication.Exit(1); } } }

这段代码里有几个设计决策值得说明。第一,locationPathName用的是相对路径,相对于工程根目录,这样脚本传-projectPath后产物位置可预测。第二,显式判断BuildResult,因为BuildPipeline.BuildPlayer在部分失败场景下不会抛异常,只会在返回值里体现。第三,无论成功失败都调用EditorApplication.Exit,把退出码交给外层脚本判断。

注意:EditorApplication.Exit在-quit参数存在时可能产生竞争,实践中更稳妥的做法是只依赖退出码,并在脚本里同时检查日志文件里是否出现Build succeeded之类的关键字作为二次校验。

2.3 脚本层与 C# 层的职责边界

很多人一开始会把所有逻辑都塞进 bat 或 shell,比如在脚本里判断文件是否存在、复制资源、改配置。这样做短期能跑,长期会失控。合理的边界是:

层级负责的事不该做的事
C# 构建方法场景列表、构建目标、版本号注入、资源处理文件归档、压缩、上传
bat / shell调用 Unity、检查退出码、整理产物、压缩、清理决定构建哪些场景、改 Player 设置

这样划分的理由是:构建相关的逻辑依赖 Unity API,只有 C# 能访问;而文件系统操作、进程调度是脚本的强项。把两者混在一起,改一个构建参数要动脚本,改一次归档规则要动 C#,维护成本翻倍。

3. Windows 下用 bat 串起整条打包链路

3.1 一个能直接用的 bat 骨架

先给一个完整可跑的版本,再逐段解释为什么这么写:

@echo off setlocal enabledelayedexpansion set UNITY_PATH="C:\Program Files\Unity\Hub\Editor\2022.3.10f1\Editor\Unity.exe" set PROJECT_PATH=%~dp0..\MyProject set BUILD_DIR=%~dp0..\Build\Windows set LOG_FILE=%~dp0..\Logs\build_win.log set VERSION=1.0.0 if not exist "%BUILD_DIR%" mkdir "%BUILD_DIR%" if not exist "%~dp0..\Logs" mkdir "%~dp0..\Logs" echo [%date% %time%] 开始构建 Windows 版本 %VERSION% %UNITY_PATH% -batchmode -quit ^ -projectPath "%PROJECT_PATH%" ^ -executeMethod BuildScript.PerformBuild ^ -logFile "%LOG_FILE%" ^ -buildTarget Win64 set EXIT_CODE=%ERRORLEVEL% echo [%date% %time%] Unity 退出码: %EXIT_CODE% if not "%EXIT_CODE%"=="0" ( echo 构建失败,请查看日志: %LOG_FILE% exit /b %EXIT_CODE% ) echo 构建成功,开始整理产物 xcopy /E /I /Y "%PROJECT_PATH%\Build\Windows" "%BUILD_DIR%\%VERSION%\" >nul echo 打包完成: %BUILD_DIR%\%VERSION% endlocal exit /b 0

3.2 路径处理与延迟变量展开的坑

%~dp0表示当前 bat 文件所在目录,带盘符和结尾反斜杠。用它做基准路径,脚本放到哪都能跑,这是比写死绝对路径更稳的做法。但要注意%~dp0在setlocal之后、被call调用的子脚本里行为会变,跨脚本传路径时最好先转成绝对路径再传。

enabledelayedexpansion和!变量!语法是另一个高频坑。在for循环或if块里,如果用%变量%,它会在解析整行时就被替换成旧值,导致逻辑判断失效。必须用!变量!延迟展开。我见过太多脚本因为这个问题在循环里反复用同一个旧值,排查半天才发现是展开时机的问题。

路径里带空格是 Windows 下的经典雷区。Program Files就是典型。所有路径变量在使用时都要加双引号,%UNITY_PATH%这种本身就带引号的写法,拼接时要注意别变成双重引号。稳妥做法是变量里不存引号,用的时候统一加。

3.3 退出码判断与失败快速中断

%ERRORLEVEL%是上一个命令的退出码。Unity 正常结束返回 0,构建失败返回非 0。但前面提过,如果 C# 方法没显式Exit(1),失败也可能返回 0。所以脚本层要做双重保险:既看退出码,也扫日志。

findstr /C:"Build succeeded" "%LOG_FILE%" >nul if errorlevel 1 ( echo 日志中未找到成功标记,判定为失败 exit /b 1 )

findstr找到返回 0,找不到返回 1,所以if errorlevel 1表示没找到。这个判断放在退出码检查之后,作为兜底。虽然多花几毫秒,但能挡住「假成功」这种最难查的问题。

3.4 产物整理与版本目录约定

产物整理的核心是「可预测」。我习惯用Build/平台/版本号/这样的三级结构,好处是历史版本不会互相覆盖,回滚时直接切目录。xcopy /E /I /Y里,/E复制子目录含空目录,/I目标不存在时按目录处理,/Y覆盖不询问。这三个参数组合基本能覆盖大多数复制场景。

如果产物里有需要排除的文件(比如调试用的 pdb),可以在 xcopy 之后加删除步骤,或者用robocopy的/XF参数排除。robocopy比xcopy更强大,支持多线程和更细的过滤,但退出码语义不同(0 到 7 都算成功),判断时要小心。

提示:bat 里做压缩可以用 7-Zip 的命令行版,7z a -tzip archive.zip folder\*。但要注意 7z 的退出码和 bat 的 errorlevel 配合,建议单独判断,别和 Unity 的退出码混在一起。

4. 类 Unix 环境下 shell 脚本的写法差异

4.1 shell 版本的核心结构

macOS 和 Linux 下 Unity 的路径和调用方式不同,但整体思路一致:

#!/bin/bash set -euo pipefail UNITY_PATH="/Applications/Unity/Hub/Editor/2022.3.10f1/Unity.app/Contents/MacOS/Unity" PROJECT_PATH="$(cd "$(dirname "$0")/.." && pwd)/MyProject" BUILD_DIR="$(cd "$(dirname "$0")/.." && pwd)/Build/macOS" LOG_FILE="$(cd "$(dirname "$0")/.." && pwd)/Logs/build_mac.log" VERSION="1.0.0" mkdir -p "$BUILD_DIR" "$(dirname "$LOG_FILE")" echo "[$(date '+%Y-%m-%d %H:%M:%S')] 开始构建 macOS 版本 $VERSION" set +e "$UNITY_PATH" -batchmode -quit \ -projectPath "$PROJECT_PATH" \ -executeMethod BuildScript.PerformBuild \ -logFile "$LOG_FILE" \ -buildTarget OSXUniversal EXIT_CODE=$? set -e echo "[$(date '+%Y-%m-%d %H:%M:%S')] Unity 退出码: $EXIT_CODE" if [ "$EXIT_CODE" -ne 0 ]; then echo "构建失败,日志: $LOG_FILE" exit "$EXIT_CODE" fi if ! grep -q "Build succeeded" "$LOG_FILE"; then echo "日志中未找到成功标记,判定失败" exit 1 fi cp -R "$PROJECT_PATH/Build/macOS/" "$BUILD_DIR/$VERSION/" echo "打包完成: $BUILD_DIR/$VERSION"

4.2 set -euo pipefail 的取舍

set -e让脚本遇到非零退出码立即终止,set -u让引用未定义变量报错,set -o pipefail让管道中任一环节失败都算失败。这三个组合是 shell 脚本健壮性的基石,但和 Unity 调用有冲突:Unity 失败返回非零,set -e会直接终止脚本,后面的日志检查就执行不到了。

所以我在 Unity 调用前后临时set +e和set -e,把退出码抓下来自己判断。这个模式在需要精细控制错误处理的场景里很常见,比全局关掉set -e更安全。

4.3 路径解析与跨平台差异

$(cd "$(dirname "$0")" && pwd)是获取脚本所在目录绝对路径的标准写法,比%~dp0啰嗦但更可靠,因为它不依赖调用时的当前目录。macOS 下 Unity 的可执行文件藏在.app包内部,路径很长,建议用变量存起来,别到处硬编码。

Linux 下 Unity 的安装路径通常是/opt/Unity/Editor/Unity或通过 Hub 安装的~/Unity/Hub/Editor/<版本>/Editor/Unity。不同安装方式路径不同,脚本里最好加一段检测逻辑,找不到就报错退出,而不是让 Unity 调用静默失败。

if [ ! -x "$UNITY_PATH" ]; then echo "找不到 Unity 可执行文件: $UNITY_PATH" exit 1 fi

-x判断文件存在且可执行,比单纯-f更严格,能挡住路径写错但文件恰好存在的情况。

4.4 权限与换行符这两个隐形杀手

shell 脚本从 Windows 传到 Linux 后跑不起来,十有八九是换行符问题。Windows 用\r\n,Linux 用\n,多出来的\r会让 shebang 行变成#!/bin/bash\r,系统找不到这个解释器。解决办法是dos2unix script.sh或者sed -i 's/\r$//' script.sh。

权限问题同样常见。脚本必须有执行权限,chmod +x build.sh。如果脚本是从版本控制拉下来的,注意版本控制是否保留了执行位。有些系统默认不保留,需要在检出后手动加,或者在文档里写清楚。

注意:在 macOS 上首次运行从网络下载的脚本,系统可能因为安全策略拦截。这不是脚本本身的问题,但会让人误以为脚本写错了。遇到「无法打开」的提示时,先确认是不是这个原因。

5. 构建失败时怎么一步步定位问题

5.1 先看退出码,再看日志尾部

构建失败时,第一反应不应该是改脚本,而是看日志。Unity 的日志文件会记录从启动到退出的全过程,失败原因通常在最后几十行。我习惯用tail -n 100 build.log先扫一遍,重点找error、exception、failed这些关键字。

退出码能快速区分失败类型:非零退出码说明 Unity 进程本身异常结束,可能是参数错误、工程路径不对、许可证问题;退出码为 0 但日志里有错误,说明构建方法内部逻辑有问题,比如场景没找到、目标平台模块没装。

5.2 常见失败原因对照表

现象可能原因排查方向
Unity 启动即退出,日志为空路径错误或许可证未激活检查-projectPath是否存在,确认许可证状态
提示找不到 executeMethod方法不在 Editor 目录或签名不对确认类是 public static,方法无参
构建到一半卡住场景加载阻塞或弹窗等待检查是否有EditorUtility.DisplayDialog之类阻塞调用
产物缺失资源资源未加入构建或路径大小写问题检查 AssetBundle 和 Resources 目录
退出码 0 但产物不完整构建方法未正确传递失败信号在 C# 里显式判断 BuildResult 并 Exit(1)

这张表是我自己踩坑后整理的,基本覆盖了八成以上的失败场景。遇到新问题先往这几类里套,能省不少时间。

5.3 日志级别与自定义日志

Unity 命令行构建默认日志级别可能不够详细。可以在参数里加-logFile指定路径,日志内容本身由 Unity 控制。如果想让自己的构建方法输出更多信息,用Debug.Log在关键节点打点,比如「开始构建」「场景列表加载完成」「构建结束,结果: xxx」。这些打点会进日志,排查时能快速定位卡在哪一步。

一个实用技巧:在构建方法开头打印所有传入的环境变量和参数,确认脚本传进来的值符合预期。很多时候问题出在脚本传参上,而不是 Unity 本身。

5.4 隔离变量法缩小范围

当问题复杂时,用隔离法。先手动在命令行敲一遍完整的 Unity 调用,确认 Unity 本身能跑通。跑通了再套进脚本,如果脚本里失败,问题就在脚本的路径处理或变量展开上。如果手动也失败,问题在 Unity 参数或工程配置上。这个二分法能快速把问题范围砍一半。

我遇到过一次诡异的情况:脚本单独跑成功,放进持续集成环境就失败。最后发现是集成环境的当前工作目录不同,导致相对路径解析到了别的地方。改成全部用绝对路径后解决。这类环境差异问题,隔离法是最有效的排查手段。

6. 把打包脚本用顺手的几个实战经验

6.1 参数化而不是复制粘贴

一开始很容易为每个平台复制一份脚本,改改路径就用。版本一多,改一个公共逻辑要改五份,迟早漏掉。正确做法是一份脚本接受参数:

./build.sh --platform win64 --version 1.2.0

脚本内部根据--platform决定-buildTarget和输出目录,根据--version决定版本目录名。这样新增平台只是加一个分支,公共逻辑只有一份。bat 里可以用%1 %2或shift解析参数,shell 里用getopts或简单的case循环。

6.2 构建前清理与构建后校验

构建前清理旧产物能避免「新文件没生成但旧文件还在」导致的误判。但清理要小心,别把不该删的删了。我的做法是只清理本次构建对应的版本目录,而不是整个 Build 目录。

构建后校验至少做两件事:确认主可执行文件存在且大小合理,确认关键资源目录存在。这两条能挡住大部分「构建成功但产物不可用」的情况。

if [ ! -f "$BUILD_DIR/$VERSION/MyGame.exe" ]; then echo "主程序缺失,构建可能不完整" exit 1 fi

6.3 版本号注入的时机

版本号最好在构建前就确定,并同时写入 Player 设置和产物目录名。在 C# 构建方法里可以通过PlayerSettings.bundleVersion设置,但要注意这个设置会修改工程文件,如果工程在版本控制下会产生变更。更干净的做法是通过命令行参数把版本号传进构建方法,用System.Environment.GetCommandLineArgs()读取,只影响本次构建,不落盘。

string[] args = System.Environment.GetCommandLineArgs(); string version = "0.0.0"; for (int i = 0; i < args.Length; i++) { if (args[i] == "-buildVersion" && i + 1 < args.Length) version = args[i + 1]; } PlayerSettings.bundleVersion = version;

脚本里对应加-buildVersion %VERSION%。这样版本号来源单一,脚本和产物永远一致。

6.4 日志归档与构建记录

每次构建的日志建议按时间戳归档,而不是覆盖同一个文件。出问题时能回溯到具体哪次构建、什么参数、什么结果。日志文件不大,保留几十次的历史成本很低,但排查价值很高。

可以在脚本末尾追加一行构建记录到汇总文件,包含时间、版本、平台、结果、耗时。时间长了这份记录就是项目的构建历史,比任何手工台账都可靠。

6.5 别忽略许可证和模块依赖

命令行构建依赖有效的许可证。个人版在无界面模式下有时需要额外确认,团队版通常配置好许可证服务器就没问题。如果构建突然开始报许可证错误,先检查许可证状态,别急着改脚本。

目标平台模块也要提前装好。比如构建 Windows 版本需要 Windows Build Support 模块,构建 macOS 需要 Mac Build Support。模块没装时,Unity 可能不报明确错误,只是构建失败。脚本里可以在构建前检查模块是否存在,或者至少在文档里写清楚依赖。

7. 从单机脚本到持续集成的平滑过渡

单机脚本跑顺之后,下一步自然是放进持续集成。这时候有几个额外考虑。第一,构建机上的 Unity 路径和本地不同,脚本里的路径要能通过环境变量覆盖,而不是写死。第二,构建机通常没有图形界面,-batchmode是必须的,而且要注意某些 Unity 版本在无显示环境下需要额外的虚拟显示支持。第三,构建产物的存放位置要统一,方便后续分发。

脚本本身不需要大改,主要是把硬编码的路径换成环境变量读取,把日志输出到持续集成能收集的位置。我习惯在脚本开头加一段:

UNITY_PATH="${UNITY_PATH:-/default/path/to/Unity}"

这样本地不设环境变量时用默认值,构建机上设了就用构建机的值,一份脚本两处通用。

真正让持续集成跑稳的,往往不是脚本多高级,而是错误处理够不够细。构建失败时能不能快速拿到日志、能不能明确知道失败在哪一步,这些决定了排查效率。所以前面强调的退出码判断、日志关键字校验、分步打点,在持续集成场景下价值更大。

整套流程跑下来,我的体会是:bat 和 shell 打包 Unity 这件事,难点从来不在脚本语法,而在于对 Unity 命令行行为的准确理解和对自己项目产物结构的清晰约定。脚本只是把这些理解固化下来。先把手动流程走顺、把每个参数的含义搞清楚,再写脚本,会比一上来就抄一份脚本改路径要顺得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进