Unity 项目在本地能导出 iOS 工程,换到云端 Mac 后却停在资源导入、原生依赖或链接阶段。最麻烦的不是失败本身,而是所有步骤挤在一个命令里:日志只留下“构建失败”,无法判断该重跑 Unity、清理缓存,还是检查 Xcode 工程。更稳妥的做法是把流水线拆成两个独立关卡:先生成可审计的 Xcode 工程,再做不含签名动作的原生编译验收。
先定义输入与产物边界
一次可复现构建至少要固定四类输入:提交版本、Unity 编辑器版本、包依赖锁定文件和场景清单。不要让脚本临时读取某位开发者机器上的编辑器设置,也不要把上一次导出的 Xcode 工程直接覆盖后继续编译。
建议为每次任务准备独立目录:
| 路径 | 用途 | 是否可缓存 |
|---|---|---|
Source/ |
当前提交的 Unity 工程 | 否 |
Library/ |
资源导入结果 | 有条件 |
Build/iOS/ |
本次导出的 Xcode 工程 | 否 |
.build/DerivedData/ |
原生编译中间文件 | 有条件 |
Artifacts/ |
日志、摘要与验收结果 | 否 |
Library 的缓存键应包含 Unity 版本、目标平台以及 Packages/manifest.json、Packages/packages-lock.json 的摘要。任意一项变化就重新导入。DerivedData 也不要在不同 Xcode 版本或不同工程设置之间直接复用。
缓存只能加速已被固定的输入,不能替代版本锁定。命中一个边界不清的缓存,通常比完整重建更难排查。
用批处理方法导出 Xcode 工程
把导出入口放在 Assets/Editor/IosExport.cs,场景由构建设置中已启用的条目生成,避免脚本与编辑器界面维护两份列表。
using System;
using System.Linq;
using UnityEditor;
using UnityEditor.Build.Reporting;
public static class IosExport
{
public static void Run()
{
var scenes = EditorBuildSettings.scenes
.Where(scene => scene.enabled)
.Select(scene => scene.path)
.ToArray();
if (scenes.Length == 0)
throw new InvalidOperationException("No enabled scenes.");
var options = new BuildPlayerOptions
{
scenes = scenes,
locationPathName = "Build/iOS",
target = BuildTarget.iOS,
options = BuildOptions.CleanBuildCache
};
var report = BuildPipeline.BuildPlayer(options);
if (report.summary.result != BuildResult.Succeeded)
throw new InvalidOperationException("Unity export failed.");
}
}
执行时显式传入项目目录、日志路径和方法名:
"$UNITY_EDITOR" \
-batchmode \
-nographics \
-quit \
-projectPath "$PWD" \
-executeMethod IosExport.Run \
-logFile "$PWD/Artifacts/unity-export.log"
任务开始前应删除 Build/iOS,但不要无条件删除 Library。任务结束后检查退出码、日志文件和 Build/iOS/Unity-iPhone.xcodeproj 是否同时存在;只看到目录存在并不代表导出成功,失败任务也可能留下半成品。
分离原生依赖与编译验收
Unity 导出成功只说明生成器完成了工作,不能证明 Objective-C、Swift、IL2CPP 产物和原生库能够一起编译。先运行项目规定的原生依赖安装步骤,再判断应构建 workspace 还是 project。依赖工具生成了 .xcworkspace 时,继续构建 .xcodeproj 往往会出现模块找不到或链接库缺失。
set -euo pipefail
cd Build/iOS
rm -rf ../../.build/DerivedData
if [ -d "Unity-iPhone.xcworkspace" ]; then
xcodebuild \
-workspace Unity-iPhone.xcworkspace \
-scheme Unity-iPhone \
-configuration Release \
-sdk iphoneos \
-derivedDataPath ../../.build/DerivedData \
CODE_SIGNING_ALLOWED=NO \
build
else
xcodebuild \
-project Unity-iPhone.xcodeproj \
-scheme Unity-iPhone \
-configuration Release \
-sdk iphoneos \
-derivedDataPath ../../.build/DerivedData \
CODE_SIGNING_ALLOWED=NO \
build
fi
这里的目标是验证编译和链接,不是生成可分发安装包。签名应放在后续受控阶段,避免普通提交验证任务接触不必要的敏感材料。
让缓存可失效而不是靠猜
Unity 项目常见的错误做法是只用分支名作为缓存键。两个提交位于同一分支,不代表资源导入结果相同。可以先生成输入摘要:
{
printf '%s
' "$UNITY_VERSION"
printf '%s
' "$XCODE_VERSION"
shasum -a 256 Packages/manifest.json
shasum -a 256 Packages/packages-lock.json
find Assets -name '*.asmdef' -o -name '*.rsp' | sort | xargs shasum -a 256
} | shasum -a 256 | awk '{print $1}' > Artifacts/cache-key.txt
不要缓存整个工作目录
整个工程目录打包缓存会混入旧导出物、临时设置和已删除文件。应分别管理 Library 与 DerivedData,恢复后仍执行输入摘要校验。出现无法解释的编译差异时,先保留失败日志,再用空缓存重跑一次;如果空缓存成功,问题应归类到缓存边界,而不是直接修改业务代码。
归档能复盘的最小证据集
构建通过后不要只保存一个成功标记。至少归档 Unity 完整日志、Xcode 输出、提交哈希、工具版本、缓存键和导出工程摘要。工程摘要可以记录 scheme、配置与构建设置,但应排除凭据、令牌和本机私有路径。
常见失败可以按阶段快速分流:
- Unity 日志没有进入
IosExport.Run:检查方法名、脚本编译错误和编辑器版本。 - 导出阶段报告场景为空:检查 Build Settings 中启用的场景,而不是临时硬编码路径。
- workspace 存在但脚本构建 project:调整检测时点,确保依赖安装已完成。
- IL2CPP 或原生库链接失败:核对目标平台、插件架构和条件导入设置。
- 同一提交偶发成功:禁用缓存重跑,并比较两次缓存键与工具版本。
- 本地通过而远端失败:比较大小写路径、未提交文件和环境变量,不要先归因于机器性能。
最后把“导出成功”和“原生编译成功”设为两个独立状态。前者失败时无需运行 Xcode,后者失败时也不必反复导入全部资源。流水线因此更容易重试,日志更短,责任边界也更清楚。
常见问题
为什么不直接让 Unity 一步生成最终安装包?
拆分后可以明确区分 Unity 脚本、资源导入、原生依赖和 Xcode 编译故障;无签名编译还能先验证源码与链接是否通过,再进入凭据受控的签名阶段。
Unity 的 Library 目录可以跨项目或跨版本复用吗?
不建议。缓存键至少应包含 Unity 编辑器版本、依赖锁定文件和目标平台;键不一致时应重新生成 Library,避免旧导入结果污染构建。
导出的工程何时应该使用 xcworkspace?
原生依赖安装生成 workspace 后应使用 xcworkspace;没有 workspace 时才使用 xcodeproj。脚本应在依赖安装完成后检测文件,而不是永久写死其中一种。
把下一次 iOS 构建放到 MiniDebug M4 上。
固定提供 M4、16GB RAM 与 256GB SSD,可按天、周、月或季租用,并从新加坡、日本东京、韩国首尔、香港、美国东部五个节点中选择。实际可用状态以控制台实时返回为准。