自动化构建
前言
在第 50 篇(工程化总览)中,我们建立了 HybridCLR 工程化的整体框架,明确了构建系统是整个工程化体系的基石。构建系统承担着将源代码转化为可分发产物的核心职责——对于 HybridCLR 项目而言,这个职责被一分为二:完整构建产出提交应用商店的 AOT 安装包,热更新构建产出随时可推送的热更新 DLL 包。
这一分工看似简单,但在实践中却充满了工程挑战。每一次构建都是一次复杂的多阶段流水线操作:从 Unity 编辑器初始化、IL 编译、Assembly-CSharp 后处理、HybridCLR Generate 元数据生成,到 IL2CPP 原生编译、资源打包(AssetBundle)、最终的应用包(APK/IPA/AAB)打包。任何一个环节出错,整个构建就需要重来。而在热更新构建场景下,团队可能一天之内触发数十次构建,每一次构建必须更快、更稳、更可追溯。
本文将从构建系统的架构设计出发,深入探讨 Unity 命令行构建、HybridCLR 集成、多平台并行策略、增量构建、缓存机制、CI/CD 集成、Docker 容器化以及构建监控等核心主题。文中会结合第 42 篇(环境搭建)和第 33 篇(DHE 实战指南)的相关内容,并在结尾预告第 52 篇(测试体系)的方向。
一、构建系统的设计
1.1 完整构建流程(Full Build)
完整构建是为应用商店提审而进行的端到端构建,其流程包含以下六个关键阶段:
[源码] → [AOT 编译 (IL2CPP)] → [HybridCLR Generate] → [热更新 DLL 编译] → [资源打包] → [应用包构建]
每个阶段的具体职责如下:
1. AOT 主工程编译(IL2CPP)。这是 Unity 的标准 IL2CPP 编译流程。Unity 编辑器调用 IL2CPP 编译器将 C# IL 代码转换为 C++ 代码,再经由平台原生编译器(MSVC/Xcode/GCC)编译为机器码。对于 HybridCLR 项目,这个阶段产出的是 AOT 主程序集(Assembly-CSharp.dll 等原生编译后的二进制),它们是运行时的"宿主"代码,不可通过热更新修改。
2. HybridCLR Generate。这是 HybridCLR 特有的构建步骤,包含几个核心子操作:AOT 泛型实例化预生成(GenerateAOTGenericTypes)、桥接函数生成(GenerateBridgeFunctions)、Link.xml 生成等。GenerateAll 命令会分析项目中所有程序集的引用关系,找出需要在 AOT 侧预留的泛型实例和桥接函数。这一步骤的正确性直接决定了热更新 DLL 在运行时能否成功加载和正确执行。关于桥接函数的细节可参考第 42 篇搭建章节。
3. 热更新 DLL 编译。通过 CompileDllCommand.CompileDllActiveBuildTarget() 将热更新程序集编译为 .NET Standard 2.0 格式的 DLL。这些 DLL 将被嵌入到 StreamingAssets 目录中随包发布。这里需要注意:编译目标必须与 AOT 主工程的 API Compatibility Level 一致(建议使用 .NET Standard 2.0 或 .NET Framework 4.x)。
4. 资源打包(AssetBundle)。将所有需要动态加载的资源(场景、UI 预制体、模型、贴图等)打包为 AssetBundle 文件。资源打包的策略直接影响包体大小和运行时加载性能。推荐采用"逻辑分块"策略:按功能模块划分 AB 包,每个模块一个主包 + 若干个共享依赖包。
5. IL2CPP 原生编译。将 AOT 主工程的 IL 代码通过 IL2CPP 编译器转换为 C++ 代码,再由平台特定编译器编译为机器码。这是完整构建中最耗时的阶段,通常占构建总耗时的 60%-80%。
6. 应用包构建(BuildPlayer)。调用 BuildPipeline.BuildPlayer 将编译后的二进制文件、资源包、HybridCLR 生成的元数据文件等打包为最终的应用安装包。
1.2 热更新构建流程(Hotfix Build)
热更新构建仅需重新构建热更新 DLL,不涉及 AOT 主工程编译和应用包打包。其流程精简如下:
[源码修改] → [增量编译热更新 DLL] → [生成版本清单] → [打包增量包] → [上传 CDN]
完整构建与热更新构建的差异可以通过下表直观呈现:
| 维度 | 完整构建 (Full Build) | 热更新构建 (Hotfix Build) |
|---|---|---|
| 构建耗时 | 30-60 分钟 | 1-5 分钟 |
| 触发频率 | 每周 1-2 次 | 每天 5-20 次 |
| 涉及编译器 | IL2CPP + 平台原生编译器 | Roslyn C# 编译器 |
| 构建产物 | APK/IPA/AAB(百 MB 级) | DLL 增量包(KB ~ MB 级) |
| 分发渠道 | 应用商店 | 自建 CDN |
| 用户影响 | 需要用户手动更新 | 应用内静默更新 |
| 回滚成本 | 高(重新发版审核) | 低(版本降级指令) |
1.3 多平台构建的并行策略
HybridCLR 项目通常需要同时支持 Android 和 iOS 两个平台,有可能还需要支持 Windows/Mac 编辑器扩展包。多平台构建的核心挑战在于如何处理构建的并行性和平台特异性。
并行策略一:独立构建机并行。为每个平台配备专用的构建机(或构建容器),各平台构建互不干扰。Android 构建使用 Windows/Linux 构建机,iOS 构建使用 macOS 构建机。CI 流水线在检测到代码推送后,并行触发各平台的构建任务。这种策略的优点是构建速度最快,缺点是硬件成本较高。
并行策略二:串行构建 + 交叉编译。在同一台构建机上按顺序执行各平台的构建。先做 Android 构建(IL2CPP),再做 iOS 构建(需要 macOS 环境则通过远程编译)。优点是硬件成本低,缺点是构建总时间长,不适合快速迭代场景。
并行策略三:混合策略。Android 和 Windows 构建在 Linux 构建集群上并行执行,iOS 构建在专门的 macOS 构建机上执行。对于热更新构建(仅 DLL),所有平台共用同一套编译结果,因为热更新 DLL 的编译产物是跨平台通用的(.NET Standard 2.0)。这是 TeamCity 和 Jenkins 集群中常见的部署拓扑。
对于大多数团队,推荐的配置是:在 CI 服务器上为每个目标平台创建一个独立的构建 Job,各 Job 并行执行完整构建。而对于热更新构建,只需要一条 Job 一次性编译所有平台共用的热更新 DLL。
1.4 增量构建的核心思想
增量构建是提升构建效率的关键技术。其核心思想是:只重新构建发生了变更的部分,未变更的部分直接从缓存中复用。
代码级别的增量。Unity 编辑器的增量编译(Incremental Compilation)在 C# 编译器层面已经实现——Roslyn 编译器仅重新编译发生变更的源文件及其直接依赖的文件。但 HybridCLR 的热更新 DLL 编译使用的是独立于 Unity 编辑器的编译管道,需要手动启用增量编译支持。
资源级别的增量。AssetBundle 构建工具(如 BuildPipeline.BuildAssetBundles)内置了增量构建支持。在多次构建之间,如果某个资源文件的内容没有变化,其产生的 AB 包文件不会重新生成。但需要注意:AB 包的增量构建依赖 .manifest 文件的正确性,如果 manifest 文件被删除或损坏,下次构建将完全重做。
HybridCLR Generate 的增量。HybridCLR 的 GenerateAll 操作目前尚不支持完整的增量模式——每次执行都会重新分析所有程序集并重新生成桥接函数和 AOT 泛型实例。但在实际项目中,这些生成的代码在 AOT 主工程代码未变更时是稳定的。因此可以通过缓存 Generated 目录来实现"伪增量"——在 CI 构建的完整构建步骤中不清理 Generated 目录的话,将复用上一次的生成结果。但这一做法有一定的风险,需要确保 AOT 源码的确未发生变更。
以下是三种增量层级的对比:
| 增量层级 | 实现方式 | 节省时间比例 | 可靠性 |
|---|---|---|---|
| 代码级别 | Roslyn 增量编译 | 10%-30% | 高 |
| 资源级别 | AB 构建增量 | 30%-60% | 中(依赖 manifest) |
| Generate 级别 | Generated 目录缓存 | 20%-40% | 低(需手动确认安全) |
二、构建脚本
2.1 Unity 命令行构建
Unity 编辑器支持通过命令行参数以 -batchmode 模式执行构建。在 CI 环境中,这是执行自动化构建的标准方式。典型命令行如下:
# Android 完整构建
Unity.exe -quit -batchmode -logFile Build/android_build.log \
-projectPath "C:/Projects/MyGame" \
-executeMethod HybridCLRBuildPipeline.PerformFullBuild \
-buildTarget Android
# iOS 完整构建
/Applications/Unity/Unity.app/Contents/MacOS/Unity -quit -batchmode \
-logFile Build/ios_build.log \
-projectPath "/Users/ci/Projects/MyGame" \
-executeMethod HybridCLRBuildPipeline.PerformFullBuild \
-buildTarget iOS
# 热更新构建
Unity.exe -quit -batchmode -logFile Build/hotfix_build.log \
-projectPath "C:/Projects/MyGame" \
-executeMethod HybridCLRBuildPipeline.PerformHotfixBuild \
-buildTarget Android -hotfixBuild
几个关键命令行参数说明:
-batchmode:无头模式运行,不弹出任何对话框。Unity 在有对话框弹出时会阻塞等待用户交互,在 CI 环境下会导致构建任务挂死。-quit:构建完成后自动退出 Unity 编辑器进程。如果不加此参数,Unity 进程会保持运行,消耗 CI 节点资源。-logFile:指定日志输出路径。建议在 CI 中将日志文件作为构建产物归档,方便排查构建失败原因。-executeMethod:执行指定的 C# 静态方法作为构建入口。该方法必须声明为public static void,且不带参数。-buildTarget:指定构建目标平台。-hotfixBuild:自定义参数,用于在构建脚本中区分构建模式。
需要注意的是,CI 环境在执行 -batchmode 的 Unity 构建时,需要确保 Unity 许可证已激活。对于持续运行在同一台机器上的 CI 代理,许可证在首次激活后会持续有效。但对于 Docker 容器或临时构建机,需要额外的许可证管理策略。
2.2 自定义 C# BuildPipeline 脚本
在 Unity 中编写自定义构建脚本是实现自动化构建的核心工作。一个完整的 HybridCLR 构建脚本需要整合以下几个操作:
CompileDllCommand.CompileDllActiveBuildTarget。这是 HybridCLR 提供的 DLL 编译接口,负责将热更新源码编译为目标平台的热更新 DLL。它的内部逻辑是:调用 BuildPipeline.BuildAssetBundles 伪装成一个资源构建,从而利用 Unity 的编译管道,将热更新程序集编译为正确的目标格式。
HybridCLRGenerateCommands.GenerateAll。这是 HybridCLR 的所有生成命令的集合入口。执行顺序为:GenerateAOTGenericTypes → GenerateBridgeFunctions → GenerateLinkXml → GenerateReversePInvokeCallback。每种生成操作都会在 Assets/HybridCLRGenerate/ 目录下产生对应的源码文件。
BuildPipeline.BuildPlayer。Unity 的标准应用包构建接口。该接口接受一个 BuildPlayerOptions 参数,包含场景列表、输出路径、构建目标、构建选项等配置。
以下是一个完整的构建脚本实现:
using System;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditor.Build.Reporting;
using HybridCLR.Editor;
using HybridCLR.Editor.Commands;
using UnityEngine;
public static class HybridCLRBuildPipeline
{
private const string HotUpdateDllRoot = "Build/HotUpdate";
private const string PackageRoot = "Build/Packages";
private const string VersionConfigPath = "Assets/Resources/VersionConfig.asset";
[MenuItem("Build/Full Build - Android")]
public static void PerformFullBuildAndroid()
{
PerformFullBuild(BuildTarget.Android);
}
[MenuItem("Build/Full Build - iOS")]
public static void PerformFullBuildiOS()
{
PerformFullBuild(BuildTarget.iOS);
}
[MenuItem("Build/Hotfix Build")]
public static void PerformHotfixBuild()
{
try
{
EditorApplication.delayCall += () =>
{
Debug.Log("[BuildPipeline] === Hotfix Build Started ===");
PreBuildValidation(BuildMode.HotfixBuild);
// 步骤 1:编译热更新 DLL
Debug.Log("[BuildPipeline] Step 1/3: Compiling hot update DLLs...");
CompileDllCommand.CompileDllActiveBuildTarget();
Debug.Log("[BuildPipeline] Hot update DLLs compiled successfully.");
// 步骤 2:打包增量包
Debug.Log("[BuildPipeline] Step 2/3: Packaging hotfix delta...");
string version = ReadCurrentVersion();
string outputDir = $"{HotUpdateDllRoot}/v{version}";
PackageHotUpdateDelta(outputDir);
// 步骤 3:生成版本清单
Debug.Log("[BuildPipeline] Step 3/3: Generating version manifest...");
GenerateVersionManifest(outputDir, version, BuildMode.HotfixBuild);
Debug.Log($"[BuildPipeline] === Hotfix Build Succeeded: {outputDir} ===");
};
}
catch (Exception ex)
{
Debug.LogError($"[BuildPipeline] Hotfix build failed: {ex.Message}");
EditorApplication.Exit(1);
}
}
public static void PerformFullBuild(BuildTarget target)
{
try
{
Debug.Log($"[BuildPipeline] === Full Build Started (Target: {target}) ===");
PreBuildValidation(BuildMode.FullBuild);
// 步骤 1:编译热更新 DLL
Debug.Log("[BuildPipeline] Step 1/6: Compiling hot update DLLs...");
CompileDllCommand.CompileDllActiveBuildTarget();
// 步骤 2:执行 HybridCLR Generate
Debug.Log("[BuildPipeline] Step 2/6: HybridCLR Generate All...");
HybridCLRGenerateCommands.GenerateAll();
// 步骤 3:IL2CPP 编译 + 应用包构建
Debug.Log("[BuildPipeline] Step 3/6: Building player...");
string outputPath = $"{PackageRoot}/{target}/Game_v{ReadCurrentVersion()}_{DateTime.Now:yyyyMMdd_HHmmss}";
string extension = target == BuildTarget.Android ? ".apk" : ".ipa";
if (target == BuildTarget.Android)
outputPath += extension;
BuildPlayerOptions options = new BuildPlayerOptions
{
scenes = GetEnabledScenes(),
locationPathName = outputPath,
target = target,
options = BuildOptions.None
};
BuildReport report = BuildPipeline.BuildPlayer(options);
if (report.summary.result != BuildResult.Succeeded)
{
throw new Exception(
$"Build failed with {report.summary.totalErrors} errors. " +
$"Check log: {report.summary.outputPath}"
);
}
// 步骤 4:生成版本清单
Debug.Log("[BuildPipeline] Step 4/6: Generating version manifest...");
GenerateVersionManifest(
Path.GetDirectoryName(outputPath),
ReadCurrentVersion(),
BuildMode.FullBuild
);
Debug.Log($"[BuildPipeline] === Full Build Succeeded: {report.summary.outputPath} ===");
}
catch (Exception ex)
{
Debug.LogError($"[BuildPipeline] Full build failed: {ex.Message}");
EditorApplication.Exit(1);
}
}
private static void PreBuildValidation(BuildMode mode)
{
if (EditorApplication.isCompiling)
{
throw new Exception("Cannot start build while Unity is compiling scripts.");
}
if (mode == BuildMode.FullBuild)
{
// 验证场景列表
if (GetEnabledScenes().Length == 0)
{
throw new Exception("No enabled scenes found in Build Settings.");
}
// 验证 VersionConfig 资源存在
if (!AssetDatabase.LoadAssetAtPath<ScriptableObject>(VersionConfigPath))
{
Debug.LogWarning("[BuildPipeline] VersionConfig not found, using PlayerSettings version.");
}
}
}
private static string ReadCurrentVersion()
{
// 优先从 VersionConfig ScriptableObject 读取
// 回退到 PlayerSettings.bundleVersion
return PlayerSettings.bundleVersion.Replace(".", "_");
}
private static string[] GetEnabledScenes()
{
return EditorBuildSettings.scenes
.Where(s => s.enabled)
.Select(s => s.path)
.ToArray();
}
private static void PackageHotUpdateDelta(string outputDir)
{
if (Directory.Exists(outputDir))
Directory.Delete(outputDir, true);
Directory.CreateDirectory(outputDir);
string sourceDir = SettingsUtil.GetHotUpdateDllsOutputDir(
EditorUserBuildSettings.activeBuildTarget
);
foreach (string dll in Directory.GetFiles(sourceDir, "*.dll"))
{
string fileName = Path.GetFileName(dll);
// 跳过 AOT 主程序集(不需要热更)
if (IsAOTAssembly(fileName))
continue;
string destFile = $"{outputDir}/{fileName}";
File.Copy(dll, destFile, true);
// 附加 .bytes 后缀以兼容 Unity 的资源加载
string bytesFile = $"{outputDir}/{fileName}.bytes";
File.Copy(dll, bytesFile, true);
}
Debug.Log($"[BuildPipeline] Hotfix delta packaged: {outputDir}");
}
private static bool IsAOTAssembly(string assemblyName)
{
string name = Path.GetFileNameWithoutExtension(assemblyName);
return name.StartsWith("Unity.") ||
name.StartsWith("UnityEngine") ||
name.StartsWith("UnityEditor") ||
name == "mscorlib" ||
name == "System" ||
name.StartsWith("System.") ||
name.StartsWith("Mono.") ||
name == "Assembly-CSharp-firstpass" ||
name == "Assembly-CSharp-Editor";
}
private static void GenerateVersionManifest(
string outputDir, string version, BuildMode mode
)
{
string manifestPath = $"{outputDir}/version.json";
var dllInfos = Directory.GetFiles(outputDir, "*.dll")
.Select(f => new
{
name = Path.GetFileName(f),
size = new FileInfo(f).Length,
md5 = ComputeMD5(f)
})
.ToList();
var manifest = new
{
version = version,
buildMode = mode.ToString(),
buildTime = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
commitHash = GetGitCommitHash(),
platform = EditorUserBuildSettings.activeBuildTarget.ToString(),
files = dllInfos
};
string json = JsonUtility.ToJson(manifest, true);
File.WriteAllText(manifestPath, json);
Debug.Log($"[BuildPipeline] Version manifest written: {manifestPath}");
}
private static string GetGitCommitHash()
{
// 在 CI 环境中通过环境变量获取
string ciCommit = Environment.GetEnvironmentVariable("CI_COMMIT_SHA")
?? Environment.GetEnvironmentVariable("GIT_COMMIT");
if (!string.IsNullOrEmpty(ciCommit))
return ciCommit;
// 本地环境通过 git 命令获取
try
{
using (var proc = new System.Diagnostics.Process())
{
proc.StartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "git",
Arguments = "rev-parse --short HEAD",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
proc.Start();
string hash = proc.StandardOutput.ReadToEnd().Trim();
proc.WaitForExit();
return hash;
}
}
catch
{
return "unknown";
}
}
private static string ComputeMD5(string filePath)
{
using (var md5 = System.Security.Cryptography.MD5.Create())
using (var stream = File.OpenRead(filePath))
{
byte[] hash = md5.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLower();
}
}
public enum BuildMode
{
FullBuild,
HotfixBuild
}
}
该脚本在 第 50 篇 的示例基础上做了进一步的增强:增加了 PreBuildValidation 方法在构建前检查编译状态和场景配置;补充了 PackageHotUpdateDelta 方法精确过滤 AOT 主程序集;改进了 GenerateVersionManifest 方法使其在完整构建和热更新构建两种模式下均能生成正确的清单文件;添加了 GetGitCommitHash 方法从 CI 环境变量或本地 Git 命令获取提交哈希。
2.3 构建参数通过命令行传递
在多平台、多环境的构建场景下,硬编码构建参数是不可取的。构建脚本应该能够通过命令行参数接收外部配置。Unity 的 -executeMethod 本身不支持参数传递,常用的做法是利用 Environment.GetCommandLineArgs() 解析自定义参数:
public static class BuildArgs
{
public static string Get(string key, string defaultValue = null)
{
string[] args = Environment.GetCommandLineArgs();
for (int i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals($"-{key}", StringComparison.OrdinalIgnoreCase))
return args[i + 1];
}
// 检查 -buildArgs 统一参数
for (int i = 0; i < args.Length; i++)
{
if (args[i].Equals("-buildArgs", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
string[] pairs = args[i + 1].Split(';');
foreach (string pair in pairs)
{
string[] kv = pair.Split('=');
if (kv.Length == 2 && kv[0].Equals(key, StringComparison.OrdinalIgnoreCase))
return kv[1];
}
}
}
return defaultValue;
}
public static bool Has(string key)
{
string[] args = Environment.GetCommandLineArgs();
foreach (string arg in args)
{
if (arg.Equals($"-{key}", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
// 判断是否是热更新构建
public static bool IsHotfixBuild => Has("hotfixBuild");
// 获取 CDN 上传地址
public static string CdnUploadUrl => Get("cdnUrl", "https://cdn.example.com/hotfix");
// 获取构建版本号(手动指定,覆盖 PlayerSettings)
public static string OverrideVersion => Get("buildVersion");
}
命令行调用方式如下:
Unity.exe -quit -batchmode \
-projectPath "C:/Projects/MyGame" \
-executeMethod HybridCLRBuildPipeline.PerformHotfixBuild \
-hotfixBuild \
-buildVersion 1.2.3 \
-cdnUrl "https://cdn.mycompany.com/game/hotfix"
这种方式将构建参数从脚本中解耦出来,CI 配置只需要关心参数值的设置,而不需要修改构建脚本本身。
2.4 CI 集成脚本
在 CI 平台(如 Jenkins、GitLab CI、GitHub Actions)中集成 HybridCLR 构建,通常需要编写 CI 配置文件。以下是一个 GitLab CI 的完整配置文件示例:
stages:
- test
- build_hotfix
- build_full
- upload
- notify
variables:
UNITY_VERSION: "2022.3.10f1"
UNITY_PATH_LINUX: "/opt/Unity/Editor/Unity"
UNITY_PATH_WIN: "C:/Program Files/Unity/Hub/Editor/2022.3.10f1/Editor/Unity.exe"
# 热更新构建 Job
hotfix_build:
stage: build_hotfix
only:
- develop
- /^hotfix-.*$/
script:
- |
& "$env:UNITY_PATH_WIN" -quit -batchmode -logFile Build/hotfix_build.log `
-projectPath "$env:CI_PROJECT_DIR" `
-executeMethod HybridCLRBuildPipeline.PerformHotfixBuild `
-hotfixBuild `
-buildVersion "$env:CI_COMMIT_TAG"
- Compress-Archive -Path Build/HotUpdate/* -DestinationPath Build/hotfix_package.zip
artifacts:
paths:
- Build/hotfix_package.zip
- Build/hotfix_build.log
expire_in: 7 days
tags:
- unity-windows
# Android 完整构建 Job
android_full_build:
stage: build_full
only:
- /^release-.*$/
script:
- |
& "$env:UNITY_PATH_WIN" -quit -batchmode -logFile Build/android_build.log `
-projectPath "$env:CI_PROJECT_DIR" `
-executeMethod HybridCLRBuildPipeline.PerformFullBuild `
-buildTarget Android `
-buildVersion "$env:CI_COMMIT_TAG"
artifacts:
paths:
- Build/Packages/Android/
- Build/android_build.log
expire_in: 30 days
tags:
- unity-windows
# iOS 完整构建 Job(需要 macOS Runner)
ios_full_build:
stage: build_full
only:
- /^release-.*$/
script:
- |
"$UNITY_PATH_LINUX" -quit -batchmode -logFile Build/ios_build.log \
-projectPath "$CI_PROJECT_DIR" \
-executeMethod HybridCLRBuildPipeline.PerformFullBuild \
-buildTarget iOS \
-buildVersion "$CI_COMMIT_TAG"
# iOS 构建产出是 Xcode 工程,需要进一步签名打包
- cd Build/Packages/iOS
- xcodebuild -project "Unity-iPhone.xcodeproj" -scheme "Unity-iPhone" archive
- xcodebuild -exportArchive -archivePath "build.xcarchive" -exportPath "." -exportOptionsPlist "export.plist"
artifacts:
paths:
- Build/Packages/iOS/*.ipa
- Build/ios_build.log
expire_in: 30 days
tags:
- unity-macos
# 上传热更新包到 CDN
upload_hotfix:
stage: upload
needs: ["hotfix_build"]
script:
- |
$version = (Get-Content Build/HotUpdate/*/version.json | ConvertFrom-Json).version
$url = "https://cdn.mycompany.com/game/hotfix/$version/"
& aws s3 sync Build/HotUpdate/ "s3://mygame-hotfix/$version/" --acl public-read
- |
# 触发 CDN 预热
& curl -X POST "https://api.cdn-service.com/prefetch" `
-H "Authorization: Bearer $env:CDN_API_KEY" `
-d "{\"paths\":[\"/hotfix/$version/*\"]}"
environment:
name: production
tags:
- deploy
# 构建结果通知
notify:
stage: notify
needs: ["upload_hotfix", "android_full_build", "ios_full_build"]
script:
- |
$status = if ($env:CI_JOB_STATUS -eq "success") { "SUCCESS" } else { "FAILED" }
$message = "Build $status - $env:CI_PROJECT_NAME - $env:CI_COMMIT_REF_NAME"
& curl -X POST "https://hooks.slack.com/services/$env:SLACK_WEBHOOK" `
-H "Content-Type: application/json" `
-d "{\"text\":\"$message\",\"attachments\":[{\"text\":\"Job: $env:CI_JOB_URL\"}]}"
when: always
tags:
- deploy
该 CI 配置文件定义了五条流水线阶段:构建前测试、热更新构建、完整构建(Android+iOS 并行)、CDN 上传和通知。热更新构建仅在 develop 分支和 hotfix-* 分支上触发,完整构建仅 release-* 分支触发。各 Job 之间通过 needs 关键字控制执行顺序,通过 artifacts 传递构建产物。
三、构建环境的配置
3.1 Docker 容器化 Unity 构建环境
Unity 官方推荐在 CI 环境中使用 Docker 容器化来提供一致的构建环境。容器化可以解决"本地构建正常、CI 构建失败"这一经典痛点——因为开发环境和 CI 环境使用完全相同的 Unity 版本、相同的基础镜像和相同的系统依赖。
以下是基于 Ubuntu 的 Unity 构建 Dockerfile:
FROM ubuntu:20.04
LABEL description="Unity Build Environment for HybridCLR Project"
# 安装基础依赖
RUN apt-get update && apt-get install -y \
wget \
unzip \
xvfb \
libgtk-3-0 \
libnotify4 \
libnss3 \
libxss1 \
libasound2 \
libxtst6 \
libpulse0 \
libgl1-mesa-glx \
libglu1-mesa \
libxrandr2 \
libgconf-2-4 \
libxcursor1 \
libxcomposite1 \
libxi6 \
libxrender1 \
libxfixes3 \
libcups2 \
libsm6 \
libgdk-pixbuf2.0-0 \
libkrb5-3 \
libgssapi-krb5-2 \
&& rm -rf /var/lib/apt/lists/*
# 安装 Unity 编辑器(从 Unity Hub 获取)
ARG UNITY_VERSION=2022.3.10f1
ARG UNITY_INSTALLER=UnitySetup64
COPY ${UNITY_INSTALLER} /tmp/unity_setup.sh
RUN chmod +x /tmp/unity_setup.sh && \
/tmp/unity_setup.sh -u -l /opt/Unity \
--install-android-support \
--install-ios-support \
--install-windows-mac-support \
-s && \
rm /tmp/unity_setup.sh
# 设置环境变量
ENV UNITY_PATH=/opt/Unity/Editor/Unity
ENV DISPLAY=:99
# 使用 xvfb 提供虚拟显示
ENTRYPOINT ["/bin/bash", "-c", "Xvfb :99 -screen 0 1024x768x24 &> /dev/null & exec \"$@\""]
在容器中执行构建:
docker run --rm \
-v $(pwd):/workspace \
-w /workspace \
unity-build-env:2022.3.10f1 \
/opt/Unity/Editor/Unity -quit -batchmode \
-projectPath /workspace \
-executeMethod HybridCLRBuildPipeline.PerformHotfixBuild \
-logFile /workspace/Build/hotfix_build.log
需要注意的是,Docker 容器中的 Unity 需要许可证。推荐的做法是使用 Unity 的浮动许可服务器(Unity Licensing Server),将许可证在容器启动时动态激活。具体流程:在构建机级别部署 Unity 浮动许可服务 → 容器启动时调用 unity-licensing-client 激活 → 构建完成后回收许可证。
3.2 构建服务器的配置建议
构建服务器的硬件配置直接决定了构建速度。以下是针对 HybridCLR 项目的建议配置:
| 组件 | 推荐配置 | 说明 |
|---|---|---|
| CPU | 16 核以上(AMD Ryzen 9 / Intel i9 / Apple M2 Pro+) | IL2CPP 编译是多线程的,更多核心 = 更快编译 |
| 内存 | 32GB 以上 | IL2CPP 编译过程需要大量内存,16GB 可能导致 OOM |
| 磁盘 | NVMe SSD 1TB 以上 | Unity Library 目录和构建产物非常大,HDD 会成为瓶颈 |
| GPU | 不需要独立显卡 | CI 环境中使用 -batchmode 或 xvfb,不需要 GPU |
| 网络 | 千兆以太网 | CDN 上传和资源下载需要稳定的网络带宽 |
对于 iOS 构建,必须使用 macOS 系统。可以考虑以下方案:
- Mac mini 集群:适合中小团队,成本可控,能效高。
- MacStadium / MacinCloud:云 macOS 服务,适合没有实体 Mac 硬件的团队。
- GitHub Actions macOS Runner:如果项目托管在 GitHub,可直接使用其 macOS CI Runner,无需自行管理构建机。
3.3 缓存策略
缓存是提升构建效率最有效的手段。在 HybridCLR 构建中,可以从以下几个维度建立缓存体系。
Unity Library 缓存。Unity 的 Library 目录包含了导入资源的缓存数据(meta 文件、导入后的贴图、模型等)。在一个干净的 CI 环境中,每次构建都需要重新导入所有资源,这是非常耗时的。通过缓存 Library 目录,可以大幅缩短构建准备时间。但需要注意:Unity 编辑器的版本升级会导致 Library 缓存失效,需要重建。
HybridCLR Generate 缓存。HybridCLR 的 GenerateAll 步骤在 AOT 主工程代码不变时,生成的文件是稳定的。我们可以实现一个基于源码哈希的缓存管理器,在热更新构建(不涉及 AOT 主工程变更)时跳过 GenerateAll:
using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Cryptography;
using UnityEditor;
using UnityEngine;
using HybridCLR.Editor;
using HybridCLR.Editor.Commands;
public static class HybridCLRCacheManager
{
private static string CacheRoot => Path.Combine(
Application.dataPath, "../Library/HybridCLRBuildCache"
);
private static string GenerateCacheDir => Path.Combine(
Application.dataPath, "../Library/HybridCLRGenerateCache"
);
[Serializable]
private class GenerateCacheEntry
{
public string aotHash; // AOT 源码哈希
public string generateVersion; // HybridCLR 包版本
public string targetPlatform; // 目标平台
public DateTime cachedAt;
public List<string> generatedFiles;
}
[Serializable]
private class DllCacheEntry
{
public string sourceHash; // 热更新源码哈希
public string dllPath;
public string platform;
public DateTime cachedAt;
}
public static bool TryRestoreGenerateCache(BuildTarget target)
{
if (!CommandLine.HasArgument("useGenerateCache"))
return false;
string cacheFile = Path.Combine(
CacheRoot, $"generate_{target}.json"
);
if (!File.Exists(cacheFile))
return false;
string json = File.ReadAllText(cacheFile);
var entry = JsonUtility.FromJson<GenerateCacheEntry>(json);
// 验证 HybridCLR 版本是否一致
if (entry.generateVersion != GetHybridCLRVersion())
return false;
// 验证 AOT 源码哈希
string currentAotHash = ComputeAOTSourceHash();
if (entry.aotHash != currentAotHash)
return false;
// 恢复生成的文件
string generateDir = HybridCLRGenerateCommands.GetGenerateOutputDir();
if (Directory.Exists(generateDir))
Directory.Delete(generateDir, true);
Directory.CreateDirectory(generateDir);
foreach (string file in entry.generatedFiles)
{
string cacheSrc = Path.Combine(GenerateCacheDir, file);
string dest = Path.Combine(generateDir, file);
if (File.Exists(cacheSrc))
{
Directory.CreateDirectory(Path.GetDirectoryName(dest));
File.Copy(cacheSrc, dest, true);
}
}
AssetDatabase.Refresh();
Debug.Log($"[Cache] Restored Generate cache for {target} from {cacheFile}");
return true;
}
public static void SaveGenerateCache(BuildTarget target)
{
if (!CommandLine.HasArgument("useGenerateCache"))
return;
if (!Directory.Exists(CacheRoot))
Directory.CreateDirectory(CacheRoot);
if (!Directory.Exists(GenerateCacheDir))
Directory.CreateDirectory(GenerateCacheDir);
string generateDir = HybridCLRGenerateCommands.GetGenerateOutputDir();
if (!Directory.Exists(generateDir))
return;
// 拷贝生成文件到缓存目录
string currentAotHash = ComputeAOTSourceHash();
var generatedFiles = new List<string>();
foreach (string file in Directory.GetFiles(generateDir, "*", SearchOption.AllDirectories))
{
string relativePath = Path.GetRelativePath(generateDir, file);
string cacheDest = Path.Combine(GenerateCacheDir, relativePath);
Directory.CreateDirectory(Path.GetDirectoryName(cacheDest));
File.Copy(file, cacheDest, true);
generatedFiles.Add(relativePath);
}
var entry = new GenerateCacheEntry
{
aotHash = currentAotHash,
generateVersion = GetHybridCLRVersion(),
targetPlatform = target.ToString(),
cachedAt = DateTime.UtcNow,
generatedFiles = generatedFiles
};
string cacheFile = Path.Combine(CacheRoot, $"generate_{target}.json");
File.WriteAllText(cacheFile, JsonUtility.ToJson(entry, true));
Debug.Log($"[Cache] Saved Generate cache for {target}");
}
public static bool TryRestoreDllCache(string cacheKey, out string cachedDllDir)
{
cachedDllDir = null;
// 只在热更新构建中启用 DLL 缓存
if (!CommandLine.HasArgument("hotfixBuild"))
return false;
string cacheFile = Path.Combine(CacheRoot, $"dll_{cacheKey}.json");
if (!File.Exists(cacheFile))
return false;
string json = File.ReadAllText(cacheFile);
var entry = JsonUtility.FromJson<DllCacheEntry>(json);
// 验证源码哈希
string currentHash = ComputeHotfixSourceHash();
if (entry.sourceHash != currentHash)
{
File.Delete(cacheFile);
return false;
}
// 缓存有效期为 24 小时
if (DateTime.UtcNow - entry.cachedAt > TimeSpan.FromHours(24))
{
File.Delete(cacheFile);
return false;
}
cachedDllDir = entry.dllPath;
return Directory.Exists(cachedDllDir);
}
public static void SaveDllCache(string cacheKey, string dllDir)
{
if (!CommandLine.HasArgument("hotfixBuild"))
return;
if (!Directory.Exists(CacheRoot))
Directory.CreateDirectory(CacheRoot);
var entry = new DllCacheEntry
{
sourceHash = ComputeHotfixSourceHash(),
dllPath = dllDir,
platform = EditorUserBuildSettings.activeBuildTarget.ToString(),
cachedAt = DateTime.UtcNow
};
string cacheFile = Path.Combine(CacheRoot, $"dll_{cacheKey}.json");
File.WriteAllText(cacheFile, JsonUtility.ToJson(entry, true));
}
private static string ComputeAOTSourceHash()
{
// 计算所有 AOT 主工程源码文件的 MD5 哈希
// 包括 Assets/Scripts 下非 HotUpdate 目录的 .cs 文件
var hashInput = new List<byte>();
string[] aotDirs = new[]
{
Path.Combine(Application.dataPath, "Scripts"),
Path.Combine(Application.dataPath, "Plugins")
};
foreach (string dir in aotDirs)
{
if (!Directory.Exists(dir))
continue;
foreach (string file in Directory.GetFiles(dir, "*.cs", SearchOption.AllDirectories))
{
// 跳过热更新目录
if (file.Contains("HotUpdate") || file.Contains("Hotfix"))
continue;
byte[] content = File.ReadAllBytes(file);
hashInput.AddRange(content);
}
}
using (var md5 = MD5.Create())
{
byte[] hash = md5.ComputeHash(hashInput.ToArray());
return BitConverter.ToString(hash).Replace("-", "").ToLower();
}
}
private static string ComputeHotfixSourceHash()
{
// 计算热更新源码目录中所有 .cs 文件的哈希
string hotfixDir = Path.Combine(Application.dataPath, "HotUpdate/Scripts");
if (!Directory.Exists(hotfixDir))
return string.Empty;
using (var md5 = MD5.Create())
{
foreach (string file in Directory.GetFiles(hotfixDir, "*.cs", SearchOption.AllDirectories))
{
byte[] content = File.ReadAllBytes(file);
byte[] fileHash = md5.ComputeHash(content);
// 将所有文件哈希累加
for (int i = 0; i < fileHash.Length; i++)
fileHash[i] ^= content[i % content.Length];
}
return BitConverter.ToString(md5.Hash).Replace("-", "").ToLower();
}
}
private static string GetHybridCLRVersion()
{
// 从 HybridCLR 程序集获取版本
var asm = typeof(HybridCLRGenerateCommands).Assembly;
var version = asm.GetName().Version;
return version?.ToString() ?? "unknown";
}
}
该缓存管理器实现了两个关键的缓存策略:
-
Generate 缓存:在 AOT 源码和 HybridCLR 包版本未发生变更时,跳过耗时的
GenerateAll过程,直接从缓存恢复生成文件。这要求构建脚本在构建流程中显式调用TryRestoreGenerateCache和SaveGenerateCache。 -
DLL 编译缓存:在热更新源码未变更且缓存未过期时,跳过 DLL 编译步骤,直接使用缓存的 DLL。这对于热更新构建频繁但修改范围较小的场景非常有效。
SLCache(Shared Local Cache)。对于在 Kubernetes 集群中运行 CI 的场景,推荐使用 SLCache 这样的分布式缓存方案。SLCache 通过共享存储(NFS/S3)让集群中的每个构建节点都能访问相同的缓存数据。当一个节点构建完成后,缓存数据立即对其他节点可见。
缓存的失效策略需要谨慎设计。过度激进的缓存策略可能导致构建产物不一致(例如使用了旧的 Generate 文件导致漏掉了新的 AOT 泛型实例)。建议遵循以下原则:
- 热更新构建可以安全使用全部缓存(AOT 源码未变更的前提由 CI 流程保证)
- 完整构建应谨慎使用 Generate 缓存(仅在明确知道 AOT 源码未变更时启用)
- 每次 Unity 编辑器版本升级后,所有缓存应强制失效
四、构建监控
构建监控是保障构建系统稳定性的最后一道防线。没有监控的构建系统就像一个没有仪表盘的飞机——你永远不知道它什么时候会出问题,直到引擎熄火。
4.1 构建成功率统计
构建成功率是最基础的构建健康指标,反映的是构建系统的整体稳定性。计算方法很简单:单位时间内成功构建次数 / 总构建次数 × 100%。
但仅仅统计一个总体成功率是不够的,需要从多个维度进行分解:
| 维度 | 说明 | 目标值 |
|---|---|---|
| 总体成功率 | 所有构建的总成功/失败比例 | > 95% |
| 完整构建成功率 | AOT 主包构建成功率 | > 90% |
| 热更新构建成功率 | DLL 增量包构建成功率 | > 98% |
| Android 构建成功率 | Android 平台独享 | > 95% |
| iOS 构建成功率 | iOS 平台独享 | > 85%(受签名影响) |
| 各分支成功率 | develop/release/hotfix 分别统计 | develop > 95% |
构建失败的原因也需要分类统计,常见的失败原因包括:
- 编译错误(占比约 40%):源码级别错误,通常是开发者的代码问题
- HybridCLR Generate 失败(占比约 15%):AOT 泛型注册遗漏、桥接函数冲突等
- IL2CPP 编译失败(占比约 20%):平台原生编译器错误、内存不足、Link.xml 配置问题
- 资源打包失败(占比约 10%):AB 包名冲突、资源依赖循环、资源格式不支持
- 基础设施问题(占比约 15%):CI Runner 掉线、磁盘空间不足、Unity 许可证失效
在 CI 平台中,可以通过收集构建日志中的 ERROR 和 EXCEPTION 关键字来自动识别失败原因。更进一步的方案是使用 ELK(Elasticsearch + Logstash + Kibana)栈对构建日志进行全文检索和聚合分析。
4.2 构建时间趋势
构建时间是衡量构建效率的核心指标。对于 HybridCLR 项目,需要关注以下时间指标:
- 热更新构建平均耗时:目标 < 3 分钟。超过 5 分钟意味着需要对构建流程进行优化。
- 完整构建平均耗时:目标 < 40 分钟。超过 60 分钟说明构建机性能不达标或需要引入缓存策略。
- Generate 步骤耗时:目标 < 2 分钟。如果 Generate 超过 5 分钟,说明产生了过多的桥接函数或 AOT 泛型实例,需要检查代码结构。
构建时间的追踪应该在每次构建完成后,将各阶段的耗时数据上报到监控系统(如 Prometheus + Grafana)。以下是一个上报示例:
// 在构建脚本中记录各阶段耗时
public static class BuildTelemetry
{
private static readonly Dictionary<string, long> s_phaseTimings = new();
public static void BeginPhase(string phaseName)
{
s_phaseTimings[phaseName] = DateTime.Now.Ticks;
}
public static void EndPhase(string phaseName)
{
if (!s_phaseTimings.ContainsKey(phaseName))
return;
long elapsed = (DateTime.Now.Ticks - s_phaseTimings[phaseName]) / TimeSpan.TicksPerSecond;
string reportLine = $"[BUILD_TELEMETRY] phase={phaseName} duration={elapsed}s";
// 输出到日志,CI 工具可以抓取
Debug.Log(reportLine);
// 可选:写入 JSON 文件供后续分析
string telemetryFile = Path.Combine(
Application.dataPath, "../Build/telemetry.json"
);
var record = new
{
phase = phaseName,
durationSeconds = elapsed,
timestamp = DateTime.Now.ToString("o"),
buildTarget = EditorUserBuildSettings.activeBuildTarget.ToString()
};
// 追加写入
File.AppendAllText(
telemetryFile,
JsonUtility.ToJson(record) + "\n"
);
}
}
在 Grafana 中,可以绘制出构建时间的趋势图、各阶段的耗时占比饼图、以及按平台分组的耗时柱状图。当某个阶段的耗时出现明显异常增长时,应自动触发告警。
4.3 构建产物版本管理
构建产物是构建系统最核心的产出,需要系统化的管理方案。
产物命名规范。推荐使用统一的命名格式:
{项目名}_{版本号}_{构建时间}_{平台}_{构建模式}_{构建ID}.{扩展名}
例如:
MyGame_1_2_3_20260529_143022_Android_Full_b1234.apk
MyGame_1_2_3_20260529_143022_Hotfix_b1234.zip
产物存储结构。建议在文件服务器或对象存储中采用以下目录结构:
artifacts/
├── apk/
│ ├── v1.2.3/
│ │ ├── MyGame_v1.2.3_20260529.apk
│ │ └── build_report.json
│ ├── v1.2.4/
│ └── ...
├── ipa/
│ └── ...
└── hotfix/
├── v1.2.1/
│ ├── hotfix_delta.zip
│ ├── version.json
│ └── build_report.json
├── v1.2.1-hotfix.1/
└── ...
产物生命周期。不同类型的构建产物应有不同的保留策略:
- 完整构建产物(APK/IPA):保留最近 3 个版本,超过 90 天的旧版本自动清理
- 热更新构建产物(DLL 包):保留最近 10 次构建,超过 30 天自动清理
- 构建日志:保留最近 30 天,超过 30 天自动归档到冷存储
- CDN 上的热更新文件:保留最近 3 个版本,确保降级回滚可用
版本追溯能力。每个构建产物都应该能够追溯到其对应的源码版本。实现方式是在构建产物的 build_report.json 中包含以下信息:
{
"project": "MyGame",
"version": "1.2.3",
"buildNumber": 1234,
"buildMode": "FullBuild",
"platform": "Android",
"commitHash": "a1b2c3d4",
"branch": "release/v1.2",
"committer": "zhangsan",
"buildTime": "2026-05-29T14:30:22+08:00",
"unityVersion": "2022.3.10f1",
"hybridclrVersion": "4.0.11",
"dllList": [
{"name": "HotFix.dll", "md5": "abcd1234", "size": 245760},
{"name": "HotFix2.dll", "md5": "efgh5678", "size": 131072}
]
}
有了完整的追溯信息,当线上出现问题时,运维人员可以迅速定位到是哪个构建、哪个 commit、哪个开发者引入的问题,大幅缩短故障排查时间。
五、总结与展望
本文系统地探讨了 HybridCLR 项目自动化构建的完整体系,从构建系统的架构设计、构建脚本的实现、构建环境的配置到构建监控的建设,覆盖了自动化构建的每一个关键环节。
回顾全文,我们达成了以下几个核心认识:
构建系统设计是工程化的基石。我们将构建分为完整构建和热更新构建两个场景。完整构建遵循"编译→Generate→打包→构建"的六阶段流水线,服务于应用商店提审;热更新构建精简为"编译→打包增量→上传CDN"的三阶段流水线,服务于日常热更新发布。二者的核心区别在于是否涉及 IL2CPP 原生编译环节。多平台构建的并行策略和增量构建的层级体系是优化构建效率的关键。
构建脚本是自动化构建的执行主体。我们通过 Unity 命令行 + C# BuildPipeline 脚本的组合方式,实现了全自动化的构建流程。脚本中的 PreBuildValidation、PackageHotUpdateDelta、GenerateVersionManifest 等方法确保了构建的可靠性和可追溯性。通过命令行参数传递构建配置,使得构建脚本可以与 CI 平台解耦,增强了灵活性。
构建环境配置决定了构建的稳定性和效率。Docker 容器化为构建环境提供了一致性保障,避免了"环境不一致"导致的构建失败。合理的硬件配置(16 核 CPU、32GB 内存、NVMe SSD)和三级缓存体系(Library 缓存、Generate 缓存、DLL 编译缓存)可以大幅缩短构建时间。
构建监控是构建系统长久稳定运行的保障。成功率和构建时间的多维度统计、构建失败原因的自动分类、构建产物的完整生命周期管理,构成了构建监控的三大支柱。
在下一篇文章(第 52 篇)中,我们将聚焦测试体系的建设。自动化构建解决了"能否持续交付"的问题,而测试体系解决的是"交付的质量如何保障"的问题。第 52 篇将深入讨论 HybridCLR 项目的单元测试方法论、集成测试框架、兼容性矩阵测试以及自动化测试在 CI 中的集成方案。同时,第 33 篇的 DHE 实战指南中涉及的性能测试方法也将在测试体系中得到体系化的延伸。
本文参考:第 33 篇(DHE 实战指南)、第 42 篇(环境搭建)、第 50 篇(工程化总览)、第 52 篇(测试体系)


被折叠的 条评论
为什么被折叠?



