Unity自动化打包实战:bat与shell脚本实现命令行构建与持续集成
1. 为什么要在 Unity 之外再套一层 bat 或 shell做过 Unity 项目发布的人大概都有过这种体验编辑器里点一下 Build等进度条走完得到一个 exe 或者一个可执行文件然后手动把配置文件、资源目录、依赖库一个个拖到输出文件夹里再压缩、再改名、再上传。一次两次还行版本一多、平台一多纯手工操作迟早要出事。漏拷一个配置文件、忘记改版本号、压缩包命名不一致这些问题在团队协作里几乎必然出现。batWindows 批处理和shell类 Unix 系统脚本打包 Unity 的 exe本质上就是把「Unity 命令行构建 产物整理 归档」这一整条链路脚本化。它的价值不在于省下那几分钟点击时间而在于可重复、可追溯、可无人值守。只要脚本写对了任何人、任何机器、任何时间跑出来的产物结构都是一致的这对持续集成和版本管理来说是刚需。这篇文章面向的是已经会用 Unity 编辑器出包、但还没把打包流程自动化的开发者。不管你是独立开发者还是团队里的构建负责人只要你想让「出包」这件事从手工活变成一条命令下面的内容都能直接拿去改改用。我会从 Unity 命令行构建的底层机制讲起再分别拆解 Windows 下 bat 和类 Unix 下 shell 的写法最后把踩过的坑和排查思路完整摊开。需要先明确一个前提Unity 的命令行构建能力是这一切的基础。Unity 支持通过-batchmode -quit -executeMethod这类参数在无界面模式下调用你自己写的静态构建方法。也就是说脚本负责「调度」真正的构建逻辑仍然写在 C# 里。理解这个分工后面所有设计才站得住脚。2. Unity 命令行构建的底层机制与脚本分工2.1 命令行参数到底做了什么Unity 编辑器本身就是一个可执行程序Windows 下是Unity.exemacOS 下是/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_PATHC:\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 VERSION1.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 03.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 VERSION1.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/$VERSION4.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\nLinux 用\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 fi6.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 命令行行为的准确理解和对自己项目产物结构的清晰约定。脚本只是把这些理解固化下来。先把手动流程走顺、把每个参数的含义搞清楚再写脚本会比一上来就抄一份脚本改路径要顺得多。