UE5.8 技术博客系列 · CI/CD 与自动构建

📑 目录

导语:周五傍晚六点,美术总监老周把最后一张场景贴图推进仓库,在群里发了一句“今晚能出包吗”。我点开编辑器,开始手动 Cook、打包、上传。七点,打包到 87% 报了一个 Shader 编译错误;八点重跑,发现忘关 Use Pak File;九点出的包启动后黑屏,日志里躺着一句 LogOutputDevice: Error: === Handled ensure: ===。等我把修复版上传到内部下载站,已经晚上十一点半。老周回了一句“辛苦了”,我只觉得下周不能再这样。

这不是某个项目的特例。任何使用 Unreal Engine 的团队,只要成员超过三个人、版本更新超过两周,都会撞上同一个问题:打包不应该是一个人守在机器前按按钮。UE5.8 的命令行工具链和 BuildGraph 已经相当成熟,配合 Jenkins 或 GitHub Actions,可以把 Cook、打包、测试、分发串成一条自动管线。本文把这条管线的每个关节拆开,讲清楚我们是怎么从“周五噩梦”走出来的。

1. 为什么自动构建不是奢侈品

手动打包的问题不只是累。它会引入大量不可复现的中间状态:本地缓存有没有清、插件版本对不对、编辑器是不是开着另一个项目、命令行参数有没有写错。同一份代码,在我的机器上能出包,在你的机器上可能就因为 DDC 过期而失败。更隐蔽的是,手动流程把“构建知识”锁在个人电脑里,老成员请假或离职,整个团队的发布能力会瞬间打折。

自动构建的核心价值是把发布变成一条可重复、可审计、可回滚的流水线。任何人提交代码后,系统都能在固定环境里跑一遍完整流程,产物被统一命名、统一签名、统一分发。出现问题时,我们可以回到某一次成功的构建编号,而不是翻聊天记录找“昨晚那个包”。

从成本角度看,搭建 CI/CD 需要一两天,但省下来的时间很快会回本。假设团队每周出两次测试包,每次手动打包平均消耗两小时,一年就是两百多小时。把这两百小时花在修 Bug 或做特性上,回报远高于维护一台构建机。

整个流程可以抽象成四个阶段:触发、构建、验证、分发。下面这张图展示了我们在 UE5.8 项目里实际使用的整体架构。

flowchart TD
    subgraph source ["源代码仓库"]
        git_server["Git Server"]
    end
    subgraph ci ["CI 服务"]
        jenkins["Jenkins / GitHub Actions"]
        build_agent["构建代理 Windows/Linux/Mac"]
    end
    subgraph artifact ["产物存储"]
        s3["对象存储 / NAS"]
        cdn["CDN / 内网下载站"]
    end
    subgraph users ["用户侧"]
        tester["测试人员"]
        developer["开发人员"]
    end
    git_server --> jenkins
    jenkins --> build_agent
    build_agent --> s3
    s3 --> cdn
    cdn --> tester
    cdn --> developer

2. 命令行 Cook 与打包

UE5.8 的编辑器界面再华丽,CI 环境里也只有命令行。理解命令行参数是搭管线的第一步。

2.1 核心工具链

引擎目录下的 Engine/Build/BatchFiles 里有三个最常用的脚本:

  • RunUAT.bat:通用构建入口,能跑 BuildCookRun、BuildGraph 等任务。
  • RunUBT.bat:Unreal Build Tool,负责编译 C++ 模块。
  • UE5Editor-Cmd.exe:无界面编辑器,用于执行蓝图/C++ 自动化测试、Cook 等。

一次完整的打包通常调用 RunUAT BuildCookRun。下面这条命令把 Game.uproject cooked 并打包成 Windows 64 位 Shipping 版本:

RunUAT.bat BuildCookRun ^
  -project="D:\Work\MyGame\MyGame.uproject" ^
  -noP4 ^
  -platform=Win64 ^
  -clientconfig=Shipping ^
  -serverconfig=Shipping ^
  -cook ^
  -allmaps ^
  -stage ^
  -pak ^
  -archive ^
  -archivedirectory="D:\Builds\MyGame"

参数含义:

  • -noP4:不连接 Perforce,纯 Git 项目必须加。
  • -clientconfig=Shipping:客户端用 Shipping 配置。
  • -cook:执行 Cook,把资源转成目标平台格式。
  • -allmaps:Cook 项目里的所有地图。
  • -stage:把 Cook 结果复制到 Staging 目录。
  • -pak:把文件打进 Pak 文件。
  • -archive:把最终结果复制到 -archivedirectory

如果只想 Cook 不打包,可以去掉 -stage-pak-archive,单独跑 -cook。这在快速验证资源改动时很有用。

2.2 命令行打包的常见坑

第一个坑是 DDC(Derived Data Cache)。CI 机器上的 DDC 默认写在用户目录,每次构建机重建都会清空,导致 Cook 时间爆炸。解决方案是在构建命令里显式指定共享 DDC 路径:

-ddc=DerivedDataBackendGraph

并在 DerivedDataBackendGraph.xml 里把 Shared 节点指向 NAS 或 S3 兼容存储。

第二个坑是插件版本。UE5.8 的项目常常依赖 Marketplace 插件或内部插件,如果这些插件以子模块方式管理,构建前必须执行 git submodule update --init --recursive

第三个坑是长路径。Windows 默认 MAX_PATH 是 260 字符,引擎中间文件很容易突破这个限制。构建机要开启长路径支持,并在命令行里避免把项目放在过深的目录。

3. BuildGraph 简介

BuildGraph 是 UE 内置的一套声明式构建脚本系统,用 XML 描述任务依赖图。它比直接在命令行里拼 BuildCookRun 更灵活,适合多平台、多配置、多阶段的复杂管线。

3.1 基本结构

一个 BuildGraph 脚本由 BuildGraph 根节点、Agent 代理节点、Node 任务节点和 Job 作业节点组成。节点之间通过 Requires 声明依赖。下面是一个最小示例,只做客户端 Cook:

<?xml version="1.0" encoding="utf-8"?>
<BuildGraph xmlns="http://www.epicgames.com/BuildGraph"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:schemaLocation="http://www.epicgames.com/BuildGraph ../Schema/BuildGraph.xsd"
            Version="1.0">
  <Property Name="Project" Value="D:\Work\MyGame\MyGame.uproject"/>
  <Property Name="ArchiveDir" Value="D:\Builds\MyGame"/>

  <Agent Name="WindowsBuilder" Type="Windows">
    <Node Name="Cook Client">
      <Job Name="Cook Win64" Command="RunUAT.bat BuildCookRun -project=$(Project) -platform=Win64 -clientconfig=Development -cook -allmaps -stage -pak -archive -archivedirectory=$(ArchiveDir)"/>
    </Node>
  </Agent>
</BuildGraph>

3.2 为什么用 BuildGraph

当项目需要同时出 Windows、Linux 和 Android 三个包时,手动写三条命令也能跑,但依赖关系和错误处理会变得非常脆弱。BuildGraph 可以把公共步骤拆成节点,例如 Build EditorCook ContentPackage Win64Package LinuxPackage Android,然后让平台打包节点都依赖 Cook Content。一旦 Cook 失败,所有平台打包都不会启动,避免浪费算力。

BuildGraph 还支持条件节点和参数化,方便做版本构建。比如通过命令行传入 -set:BuildConfig=Shipping,脚本内部根据变量决定用 Development 还是 Shipping。

下面这张图展示了一个典型的多平台 BuildGraph 依赖关系:

flowchart TD
    A["Setup: 拉取代码 + 更新子模块"] --> B["Build Editor"]
    B --> C["Cook Content"]
    C --> D["Package Win64"]
    C --> E["Package Linux Server"]
    C --> F["Package Android"]
    D --> G["Run Smoke Tests"]
    E --> G
    F --> G
    G --> H["Archive to Storage"]
    H --> I["Notify Team"]

4. Jenkins 与 GitHub Actions 配置

CI 服务负责监听仓库事件、调度构建代理、收集日志和产物。Jenkins 和 GitHub Actions 是 UE 团队最常见的两种选择,核心思路相通,只是配置语法不同。

4.1 Jenkins 配置要点

我们在构建机上装了一个 Jenkins Agent,Label 设为 ue5-builder-win。Jenkins Master 收到 Push 事件后,把任务派给这个 Agent。关键配置如下:

  • 源码管理:Git,仓库地址填项目仓库,分支规范用 */develop*/release/*
  • 触发器:Webhook 触发,Git 服务器 Push 时自动拉起构建。
  • 构建环境:清理工作区里的 Saved/LogsIntermediateDerivedDataCache 中过期的临时目录,但保留 .git 以加速拉取。
  • 构建步骤:执行 PowerShell 或 Batch 脚本,先更新子模块,再调用 RunUAT 或 BuildGraph。
  • 产物归档:把 D:\Builds\MyGame\*.zip*.exe 归档到 Jenkins,并同步到 S3。

Jenkins 的 Blue Ocean 视图对多阶段管线很友好,能把 Cook、打包、测试、分发拆开显示。哪一步红了,一眼就能看到。

4.2 GitHub Actions 配置要点

如果项目托管在 GitHub,GitHub Actions 省去了维护 Jenkins Master 的成本。Runner 可以用 GitHub 托管的 Windows Runner,也可以自托管。UE5.8 项目建议自托管,因为默认 GitHub Runner 的磁盘空间和内存对完整打包来说比较紧张。

一个典型的工作流文件结构:

name: UE5.8 Build
on:
  push:
    branches: [develop, release/*]
  pull_request:
    branches: [develop]
jobs:
  build:
    runs-on: [self-hosted, windows, ue5]
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
          lfs: true
      - name: Build Game
        shell: powershell
        run: |
          & "C:\UE_5.8\Engine\Build\BatchFiles\RunUAT.bat" `
            BuildCookRun `
            -project="$env:GITHUB_WORKSPACE\MyGame.uproject" `
            -platform=Win64 `
            -clientconfig=Development `
            -cook -allmaps -stage -pak -archive `
            -archivedirectory="D:\Builds\MyGame"
      - name: Upload Artifact
        uses: actions/upload-artifact@v4
        with:
          name: MyGame-Win64-Development
          path: D:\Builds\MyGame\**

注意 submodules: recursivelfs: true。很多 UE 项目把美术资源放 Git LFS,如果漏开,构建时会出现大量资源缺失。

4.3 CI 里的权限与凭证

构建脚本需要访问代码仓库、S3、证书、版本服务器等。不要把密码或密钥直接写在仓库里。Jenkins 用 Credentials Binding,GitHub Actions 用 Repository Secrets。上传代码到 Steam、Epic Online Services 或私有 CDN 时,尤其要注意密钥的最小权限原则。

下面这张图展示了代码提交之后直到构建完成的完整时序:

sequenceDiagram
    participant D as "开发者"
    participant R as "Git 仓库"
    participant C as "CI 服务"
    participant A as "构建代理"
    participant S as "对象存储"

    D->>R: git push origin develop
    R->>C: 触发 Webhook
    C->>A: 分配 ue5-builder-win
    A->>R: git fetch + submodule update
    A->>A: RunUAT BuildCookRun
    A->>A: 执行自动化测试
    A->>S: 上传 Pak / 安装包
    C->>D: 发送构建结果通知

5. 版本构建与分支策略

CI/CD 不只是自动跑命令,还要和版本管理配合。没有清晰的分支策略,自动构建很容易变成“每次 Push 都出包,但没人知道哪个包能用”。

5.1 分支模型

我们的项目采用以下分支模型:

  • main:永远可发布,只接受从 release/* 合并进来的代码。
  • release/1.x:版本分支,出候选包和正式包。
  • develop:日常集成,自动出开发包给策划和测试试玩。
  • feature/*:特性分支,只做编译检查,不出完整包。
  • hotfix/*:紧急修复,可以直接合入 main 并触发补丁构建。

CI 对不同分支做不同的事:

  • feature/*:编译检查 + Cook 检查,验证资源引用是否破坏。
  • develop:完整 Development 包 + 自动化 Smoke 测试。
  • release/*:Shipping 包 + 完整测试 + 上传到内测分发站。
  • main:与最后一次 release 构建结果一致,只补标签。

5.2 构建编号与版本号

每个正式包都要有唯一版本号。我们采用 主版本.次版本.补丁.构建号 的格式,例如 1.3.0.4821。构建号由 CI 自动递增,写入 DefaultGame.ini 或代码里的版本宏。

版本号要回写到两个地方:

  1. 游戏内显示:通过 FApp::GetBuildVersion() 读取,方便测试人员报 Bug 时确认版本。
  2. 产物文件名:例如 MyGame-Win64-Shipping-1.3.0.4821.exe,避免下载站里一堆 MyGame.zip 分不清。

5.3 多配置构建

Development 配置编译快、调试信息完整,适合日常验证;Shipping 配置做了大量优化和剥离,适合对外发布。Test 配置介于两者之间,保留部分调试能力又接近发布性能。CI 管线应该同时覆盖这三种配置,而不是只出 Development。

从时间成本看,三种配置的构建时间并不一样。设 Development 构建时间为 TdT_d,Test 为 TtT_t,Shipping 为 TsT_s。通常有 Td<Tt<TsT_d < T_t < T_s,经验关系接近 Tt1.3TdT_t \approx 1.3 T_dTs1.8TdT_s \approx 1.8 T_d。如果某次提交只想验证编译,只跑 TdT_d 就够了;如果要做发布候选,再跑完整矩阵。

6. 自动化测试

打包成功不等于游戏能玩。CI 里必须嵌入自动化测试,否则只是把有 Bug 的包自动发出去。

6.1 测试金字塔

UE 的自动化测试分为三层:

  • 单元测试:针对纯 C++ 函数,使用 IMPLEMENT_SIMPLE_AUTOMATION_TEST 宏。
  • 功能测试:针对蓝图和 Gameplay 逻辑,使用 Gauntlet 或编辑器自动化测试框架。
  • 端到端测试:启动打包后的游戏,跑一段固定流程,检查是否崩溃、帧率是否达标。

底层测试跑得快、定位准,应该尽量多写;顶层测试搭建成本高,但覆盖真实玩家路径,必须保留关键用例。

6.2 命令行执行测试

UE5.8 可以通过命令行跑自动化测试:

UE5Editor-Cmd.exe "D:\Work\MyGame\MyGame.uproject" -ExecCmds="Automation RunTests MyGame" -testexit="Automation Test Queue Empty" -log -unattended

这条命令会启动无界面编辑器,跑 MyGame 测试组,等测试队列清空后退出。CI 通过检查退出码判断测试是否通过。

对于打包后的端到端测试,Gauntlet 是更专业的选择。它可以在 CI 里启动打包客户端,连接到专用测试服务器,跑完指定地图后收集日志和截图。Gauntlet 的配置相对复杂,但它是主机平台认证的常用工具。

6.3 性能回归测试

除了功能正确性,还要防止性能回退。我们在 CI 里固定一台机器跑同一段关卡录制,记录平均帧时间 t¯\bar{t} 和 P99 帧时间 tp99t_{p99}。如果某次构建让 tp99t_{p99} 比基线高出超过 15%15\%,就标记为性能回归失败。

性能测试对环境一致性要求很高。同一台机器必须固定驱动版本、关闭 Windows 更新、禁止休眠、插电源、使用有线网络。否则数据的噪声会掩盖真实的性能变化。

7. 产物分发

构建出来的 Pak 文件、安装包、符号文件需要被正确分发到不同角色手里。

7.1 产物类型

一次完整构建通常产出:

  • 客户端安装包或绿色版压缩包。
  • 专用服务器二进制(如果是多人游戏)。
  • .pak 文件,用于增量更新或补丁。
  • 调试符号文件 .pdb / .sym,用于崩溃分析。
  • Cooked 日志和 BuildGraph 报告。

符号文件往往很大,但绝不能丢。没有符号,线上崩溃的 minidump 就解不出堆栈。可以把符号单独压缩上传到符号服务器或对象存储,客户端包本身不带符号。

7.2 分发渠道

内部团队常用的分发方式:

  • 对象存储 + 预签名 URL:适合中小团队,按构建号组织目录。
  • 自研下载站:前端展示版本说明、下载按钮、构建状态。
  • Steam / Epic:对外发布时直接走官方渠道,但需要额外的上传步骤。
  • CDN:大规模分发时把产物推到 CDN,减轻源站压力。

我们目前的做法是:构建成功后把产物上传到 MinIO 桶,按 MyGame/1.3.0.4821/Win64/ 这样的路径组织。下载站通过 API 读取桶内列表,测试人员按版本号下载。

7.3 增量更新

完整包动辄几十 GB,每次测试都重新下载很浪费。UE 的 Pak 文件适合做增量更新。对比两个版本的 Pak,只把变化的 Pak 或块推送到更新服务器。CI 里可以用 PakTools 或自研差分工具生成增量补丁。

增量大小的上限可以用信息熵角度理解。设版本 A 和版本 B 的资源集合差异为 Δ\Delta,补丁大小 PP 满足:

PH(Δ)=ipilog2piP \geq H(\Delta) = -\sum_{i} p_i \log_2 p_i

其中 pip_i 是差异块的出现概率。再好的差分算法也无法突破信息论下界。如果某次更新把大量贴图从 2K 换成 4K,补丁就不可能小。这个认知有助于我们和策划、美术沟通包体预期。

8. 失败排查

自动构建每天都会失败,关键是怎么快速定位。

8.1 日志分层

CI 失败时,先看三层日志:

  1. CI 控制台输出:告诉你哪个步骤红了,退出码是多少。
  2. UAT 日志 Saved/Logs/UBT-*.txt:编译和链接阶段的详细错误。
  3. 引擎日志 Saved/Logs/MyGame.log:Cook、打包、运行阶段的错误。

如果是 Cook 阶段失败,常见原因包括:资源引用丢失、蓝图编译错误、材质节点版本不兼容。如果是打包阶段失败,常见原因包括:插件未正确引用、目标平台 SDK 缺失、代码签名失败。

8.2 复现失败

CI 环境失败但本地正常,是最头痛的情况。排查思路:

  • 对比环境差异:CI 是否用了和你一样的引擎版本、SDK、Windows SDK 版本。
  • 检查缓存:CI 是否清空了 DDC 或 Intermediate,本地是否依赖了旧缓存。
  • 路径差异:CI 的项目路径是否包含中文或空格,某些旧插件对此敏感。
  • 并发问题:CI 是否并行执行了多个构建,导致文件锁冲突。

8.3 重试与熔断

不是所有失败都需要人工处理。网络波动导致的子模块拉取失败、临时性的着色器编译超时,可以设置自动重试。但重试必须有上限,否则一个真正的编译错误会被无限重试,浪费资源。

我们对不同类型失败设置不同策略:

  • 环境类失败(子模块超时、DDC 网络抖动):重试 2 次。
  • 编译类失败:立即失败,不自动重试。
  • 测试类失败:保留现场日志和截图,供人工分析。

下面这张图总结了失败排查的决策树:

graph TD
    A["构建失败"] --> B{失败发生在哪一阶段}
    B -->|Setup| C["检查子模块 / LFS / 凭证"]
    B -->|Compile| D["检查 UBT 日志与代码变更"]
    B -->|Cook| E["检查资源引用与蓝图错误"]
    B -->|Package| F["检查插件引用与 SDK"]
    B -->|Test| G["检查测试日志与崩溃转储"]
    C --> H{本地能否复现}
    D --> H
    E --> H
    F --> H
    G --> H
    H -->|"能"| I["修复后重新提交"]
    H -->|"不能"| J["对比 CI 与本地环境差异"]
    J --> K["更新 Docker/Agent 镜像或文档"]

9. 最佳实践

最后整理一些踩过坑后沉淀下来的原则。

9.1 把构建环境当代码管

构建机的环境不应该靠口口相传维护。用 Infrastructure as Code 的方式记录:Windows 版本、Visual Studio 工作负载、Windows SDK 版本、引擎安装路径、环境变量。我们维护了一份 PowerShell 脚本 setup-build-agent.ps1,新机器半小时内就能加入构建池。

9.2 每日构建必须绿

develop 分支的每日构建是团队的血压计。如果连续两天红着,测试和策划就拿不到新包,特性验证会全面滞后。我们的规则是: develop 红的时候,全团队优先修构建,再写新功能。

9.3 产物保留策略

构建产物会无限增长。我们保留最近 30 天的 Development 包、最近 10 个 Release 候选包、所有正式版本的包。符号文件保留时间与对应版本一致。过期产物自动清理,避免存储账单失控。

9.4 让通知有信息量

构建成功或失败的通知不要只发一句“构建失败”。通知里应该包含:构建号、分支、提交人、失败阶段、日志链接、产物链接。这样收到通知的人能立刻判断是否需要处理。

9.5 文档化失败案例

把每次 CI 故障和修复方法写成内部 Wiki。半年后你会得到一本很有价值的《构建故障手册》。新成员遇到同样的问题时,不用再问老员工。

10. 写在最后

回到周五晚上的那个故事。现在我们团队的构建流程是这样的:开发者 Push 代码到 develop,Jenkins Agent 自动拉取、Cook、打包、跑 Smoke 测试,把包上传到下载站,然后在企业微信发一条消息。老周再也不用问我“今晚能出包吗”,他自己会点开链接看构建状态。

自动构建不是把人的工作交给机器,而是把重复的、容易出错的、容易依赖个人的工作交给机器。省下来的时间和精力,用来打磨玩法、修真正的 Bug、陪家人吃顿晚饭。这套管线的搭建没有银弹,但 UE5.8 的命令行工具、BuildGraph 和现代 CI 服务已经提供了所有必要的积木。剩下的,就是一块一块把它们搭起来。