ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unity SBP实战:用Scriptable Build Pipeline替代BuildAssetBundles构建AB包

Unity SBP实战:用Scriptable Build Pipeline替代BuildAssetBundles构建AB包 做客户端构建的同学应该都有这种经历项目上了规模以后还在用BuildPipeline.BuildAssetBundles打包构建过程完全是个黑盒——资源明明没变却要等上半小时打出来的包偶尔会带一堆莫名其妙的脏数据却根本定位不到是哪一步产生的。两年前我接手一个接近 10GB 资源的手游项目时一次 AB 包构建要跑 40 分钟中间任何一个环节都不能干预出问题只能全量重来。后来我把整个构建流程迁到了 Scriptable Build PipelineSBP上构建时间从 40 分钟压到了 12 分钟左右还顺手在构建流程里塞了好几个自定义检查任务。这篇文章就围绕 SBP 的机制、实战代码和迁移注意事项展开适合已经接触过 AB 包、但想真正掌控构建过程的开发者。1. 为什么项目一复杂Unity 自带构建管线就撑不住了1.1 黑盒构建的三宗罪传统 AB 包构建的核心就一行BuildPipeline.BuildAssetBundles(AssetBundleBuild[])。输入是一堆AssetBundleBuild输出是打包好的 Bundle中间发生了什么几乎没人知道。问题也正出在这个中间。第一不可拆解。整个构建是单线程大循环干到一半挂掉就只能从头再来。你没法说这次只重新生成涉及变化的 bundle因为构建逻辑没有暴露任何中间阶段的入口。项目资源一多这个时间成本会迅速放大。第二不可干预。想在构建完成后把 bundle 的 hash 表自动同步到服务器想在打包前校验一下某个目录有没有误传资源想在序列化阶段做一个自定义的资源剥离这些需求在旧管线里只能用构建前 hook 构建后扫描这种补丁方式做构建本身依然是铁板一块。第三不可追踪。构建日志只有最后一步Build completed with a result of Succeeded。具体哪些资源进了哪个 bundle、哪个 shader 变体被打进去了、哪个公共依赖被重复打了两份全部要靠自己写工具去解析产物。一旦线上包出了资源加载问题排查链路极长。我印象最深的一次打出来的包里某个 UI atlas 被重复引用了 6 次包体直接多了 80MB。用旧管线打出来没有任何警告想定位只能自己遍历依赖图。1.2 SBP 到底动了哪块蛋糕SBP 的全称是 Scriptable Build Pipeline是 Unity 在 2018.1 之后逐步推出的可编程构建管线同时也是后续 Addressables 体系的底层支撑。它跟旧管线的本质区别并不在于能提速多少而在于构建过程被拆成了几十个可执行的构建任务每个任务都有输入、输出、上下文开发者可以拿到任意中间数据也可以往任务链里塞自己的逻辑。换句话说旧管线是把一个高速运转的电机封装在铁壳里你只能看转不转SBP 是把这个电机拆成了零件图纸你可以按心情调整顺序、换零件、加机油。实际项目里SBP 带来的直接收益有三个层面构建可拆分任务级别的缓存让增量构建成为可能资源没变就直接读缓存构建可编程通过实现IBuildTask接口能在构建的任意阶段插入自定义逻辑构建可观测每个任务执行时间、产物 hash、依赖关系都能拿到排查问题不再靠猜。2. SBP 的顶层设计构建不再是一次函数调用2.1 核心对象速览SBP 的 API 看起来有点唬人但核心其实就四个东西理解了它们整个管线就通了。核心对象继承/实现作用IBuildParameters实现类通常是BundleBuildParameters描述怎么构建比如目标平台、输出目录、压缩格式IBundleBuildContent实现类有BundledAssetContent、BundleContent描述构建什么即哪些资源进哪个 bundleIBuildContext任务间共享数据的上下文容器用于在任务之间传递中间产物和数据IBuildTask实现类就是一个个构建任务管线的最小执行单元通过Run方法执行逻辑我用生活化一点的方式理解IBuildParameters是施工图纸IBundleBuildContent是建材清单IBuildTask是施工队里的工人IBuildContext是工人们共用的手推车。你要做的就是决定上哪几个工人、给他们发什么图纸和清单。2.2 任务管线如何编排从收集依赖到产出 Bundle一次标准的 SBP 构建内部会依次执行一组任务。不同 SBP 版本任务列表略有差异但主干流程大致是这样收集所有资源的依赖信息分析出每个 Asset 依赖哪些其他 Asset计算 bundle 之间的依赖关系识别哪些资源被多个 bundle 共用生成资源打包方案packing决定哪些依赖要提取为公共 bundle生成序列化命令把资源写入序列化文件将序列化文件归档并压缩成最终 bundle 文件按需把内容 hash 追加到文件名上生成 Link.xml、保存构建报告等收尾工作。这个流程里最关键的设计是第三步。SBP 会自动做依赖去重把被多个 bundle 引用的公共资源单独提取出来这一点比旧版BuildAssetBundles聪明得多。旧版构建时一个公共资源如果被两个 bundle 引用可能会被同时打进两个包里而你没有任何赔偿机制SBP 则会自动建一个类似shared_assets的公共包让两边都依赖它。2.3 缓存机制为什么增量构建可以有SBP 能明显提速靠的不是 C 层多线程而是任务级别的缓存。每个任务执行前会计算输入的 hash如果与缓存一致就直接复用上次任务输出的结果不再重新执行。这个缓存默认存在项目的Library/BuildCache目录下。这意味着只有当你改了资源、切换了构建参数或者在自定义任务里产生随机内容时相应的任务才会真正重建。其他没受影响的阶段直接读缓存。这也是为什么我那个 10GB 资源的项目能从 40 分钟降到 12 分钟——绝大多数资源根本没变只有序列化、压缩等少数几个任务需要重跑。注意SBP 的缓存粒度远大于整个构建但也不是精确到单个资源。依赖某个改了资源的 bundle 对应的任务链会整体失效这是合理的设计如果你发现改了一个小资源导致所有 bundle 全部重打那说明 bundle 划分本身有问题公共依赖太多了。3. 手写一个基于 SBP 的 AB 包构建器关键代码逐段拆解3.1 先搭建最小可运行版本我用一个简化但可实际落地的示例来演示。假设项目资源在Assets/GameRes下每个子目录打包成一个独立 bundle用 SBP 完成构建using System.Collections.Generic; using System.IO; using System.Linq; using UnityEditor; using UnityEditor.Build.Pipeline; using UnityEditor.Build.Pipeline.Interfaces; using UnityEngine; public static class BundleBuilder { [MenuItem(Tools/构建 AB/使用 SBP 构建)] public static void BuildAll() { var target EditorUserBuildSettings.activeBuildTarget; var targetGroup BuildPipeline.GetBuildTargetGroup(target); var outputPath BuildBundles/ target; Directory.CreateDirectory(outputPath); var parameters new BundleBuildParameters(target, targetGroup, outputPath) { BundleCompression BundleCompression.LZ4, OutputFormat BundleOutputFormat.AppendHash, UseTraceMessages true }; var content GetBuildContent(); var exitCode ContentPipeline.BuildAssetBundles(parameters, content, out long totalTime); if (exitCode ReturnCode.Success) { Debug.Log($SBP 构建成功总耗时 {totalTime} ms); } else { Debug.LogError($SBP 构建失败错误码{exitCode}); } } private static BundledAssetContent GetBuildContent() { const string root Assets/GameRes; var builds new ListAssetBundleBuild(); foreach (var dir in Directory.GetDirectories(root)) { var dirName Path.GetFileName(dir); var assetPaths Directory.GetFiles(dir, *, SearchOption.AllDirectories) .Where(p !p.EndsWith(.meta)) .Select(p p.Replace(\\, /)) .ToArray(); builds.Add(new AssetBundleBuild { assetBundleName dirName.ToLower() .bundle, assetNames assetPaths }); } return new BundledAssetContent(builds.ToArray()); } }这段代码有几点值得单独说BundleBuildParameters的构造函数前两个参数必须传对。BuildTarget和BuildTargetGroup如果对不上轻则构建参数异常重则目录结构错乱。用EditorUserBuildSettings.activeBuildTarget和BuildPipeline.GetBuildTargetGroup(target)是稳妥做法。OutputFormat.AppendHash强烈建议开启。开启后生成的 bundle 文件名会带上内容 hash比如ui.bundle_9f3a7c2b对后续 CDN 增量同步、客户端版本管理都非常友好。后面第五章会详细说。BundledAssetContent是 SBP 1.x 时代比较常见的实现类。如果你用的 SBP 版本较新可能会发现它被标记为 deprecated新版推荐BundleContentIBundleContentSet的组合。这个我在第六章踩坑部分再说示例代码先以兼容性好的版本为准。3.2 从本地文件系统收集构建内容很多项目并不愿意维护一个巨大的AssetBundleBuild[]资产表而是希望目录即 bundle 划分。上面GetBuildContent()做的事情就是这个。要点有两个文件路径分隔符必须统一。Directory.GetFiles返回 Windows 风格的反斜杠而 Unity 资源系统需要正斜杠。忘了做Replace(\\, /)的话收集出来的路径都找不到资产。过滤.meta文件。这是老生常谈但在遍历Directory.GetFiles时特别容易漏一漏就会导致构建报错。这类按目录打包的方式适合资源目录结构清晰的项目但不是万能的。如果项目里存在跨目录共享资源的情况单纯靠目录划分会制造大量公共 bundle不一定划算。更细的控制建议用 AssetBundleBuild 的assetNames手动指定。3.3 参数配置里的门道BundleBuildParameters里值得关注的参数很多我结合生产经验给出一个推荐配置表参数推荐值原因BundleCompressionLZ4LZMA 压缩率更高但加载时需要整体解压LZ4 是块压缩适合随机访问OutputFormatAppendHash文件名自带内容哈希方便热更做差异比对UseTraceMessages本地开CI 关详细 trace 日志对定位问题有帮助但量大CI 日志容易爆DisableWriteTypeTree保持默认 false关闭 typetree 可以减包体但会影响跨平台/跨版本兼容ContiguousBundles按需开启开启后 bundle 内资源连续存放加载性能更优但构建时间会增加一个常见误区是把BundleCompression设为LZMA来追求极致包体。如果你的游戏有较多小资源频繁加载LZMA 的整体解压开销可能抵消掉压缩省下的空间。选择压缩格式要结合加载场景一起评估不能只看包体数字。4. 自定义 IBuildTask 的完整范式输出 Hash 清单与构建水印4.1 自定义任务的基本姿势IBuildTask接口本身非常轻量public interface IBuildTask { int Version { get; } ReturnCode Run(IReportingContext context); }Version用于区分同一任务的不同实现版本版本号变了缓存会自动失效可以避免代码改了但缓存还命中旧的这种恶心问题。Run方法拿到的是IReportingContext它继承自IBuildContext可以从中读取构建参数、内容定义、构建结果等一切注册了的数据。自定义任务最常见的用法是挂在构建完成后做后处理。一个非常实用的例子把本次构建产出的所有 bundle 文件名和 hash 写到一个清单文件里供服务器或构建脚本读取。using System.Text; using UnityEditor.Build.Pipeline.Interfaces; using UnityEngine; public class WriteBundleHashListTask : IBuildTask { public int Version 1; public ReturnCode Run(IReportingContext context) { if (!context.TryGetObjectIBuildResults(out var results)) { Debug.LogWarning(未获取到 IBuildResults跳过 Bundle Hash 清单生成); return ReturnCode.Success; } var sb new StringBuilder(); foreach (var pair in results.BundleInfos) { sb.AppendLine(${pair.Key}|{pair.Value.FileName}|{pair.Value.Hash}); } var outputDir BuildReport; Directory.CreateDirectory(outputDir); File.WriteAllText(Path.Combine(outputDir, bundle-hash.txt), sb.ToString()); return ReturnCode.Success; } }实现完之后怎么把它塞进管线最直接的方式是在调用ContentPipeline.BuildAssetBundles的重载中传入任务列表var tasks new ListIBuildTask(); tasks.Add(new WriteBundleHashListTask()); var exitCode ContentPipeline.BuildAssetBundles( parameters, content, out long totalTime, tasks.ToArray() );这里有个小坑如果直接传tasksSBP 会使用你提供的完整任务列表而不再自动插入默认任务。所以上面这段代码在真机上要么构建报错要么产物缺失。更稳妥的做法是用 SBP 提供的DefaultBuildTasks.Create(...)拿到默认任务链再把自定义任务追加到链尾或者插到某个特定任务前后。不同 SBP 版本里DefaultBuildTasks.Create的签名略有差异用的时候以当前版本源码为准。4.2 场景二给构建产物注入版本水印除了输出文件清单自定义任务还能用来打水印。比如我们希望每个 bundle 的旁边额外生成一个.version.txt记录本次构建的版本号和构建时间public class WriteVersionWatermarkTask : IBuildTask { public int Version 1; public ReturnCode Run(IReportingContext context) { if (!context.TryGetObjectIBuildParameters(out var parameters)) return ReturnCode.Success; var outputFolder parameters.OutputFolder; var version $BuildTime{DateTime.Now:yyyyMMddHHmmss}; File.WriteAllText(Path.Combine(outputFolder, build-version.txt), version); return ReturnCode.Success; } }这样做的好处是构建产物被上传到 CDN 之后不管中间被谁搬运、解压、再压缩只要这个文件还在拿到的团队都能一眼看出这是哪一次构建出来的包。我们在排查线上资源问题时多次靠这个水印文件确认了 CDN 缓存是否更新到位。4.3 为什么我推荐在任务里拿结果而不是构建完再扫产物有一种更省事的做法是在ContentPipeline.BuildAssetBundles返回之后自己去扫输出目录、解析 manifest 文件来分析产物。但相比之下在自定义任务里拿IBuildResults有几个明显优势结果来自内存不需要重新解析序列化文件速度快得多IBuildResults.BundleInfos里每个BundleDetails包含的Hash、Crc、FileName都是结构化的比解析 manifest 文本可靠自定义任务在管线内部执行可以天然参与 SBP 的缓存机制。如果你构建后扫描的逻辑依赖某些中间数据它也能拿到而在构建之外是拿不到的。4.4 自定义任务里的 IReportingContext 到底能干什么IReportingContext不仅用来拿数据还能上报进度和诊断信息。在长时间构建的 CI 任务里这个能力很有用。你可以这样用context.AddDiagnostics(WriteBundleHashListTask, bundle count, results.BundleInfos.Count);这类诊断信息会写进 SBP 的构建日志里帮助后续定位是哪一步慢了。5. 从 BuildAssetBundles 迁移到 SBP行为差异清单与兼容处理5.1 Manifest 与目录结构差异旧版BuildAssetBundles的输出目录里会生成一个总的AssetBundleManifest客户端通常用AssetBundleManifest.GetAllAssetBundles()获取所有 bundle 列表。迁移到 SBP 之后这个总 manifest 依然存在但文件结构上有不少变化。SBP 默认会在输出目录下按平台子目录组织产物比如BuildBundles/StandaloneWindows64。每个独立 bundle 会有一个同名.manifest文件同时还有一个总的.manifest文件。关键差异在于SBP 的 manifest 中记录的 hash 是构建内容 hash旧版则是基于文件路径和内容的另一种 hash如果开启OutputFormat.AppendHash产物的实际文件名与 manifest 中的名字可能不一致因为 hash 会被附加到文件名里客户端依赖 manifest 定位资源时需要做一层映射旧版 bundle 可以完全自己命名SBP 在开启某种输出格式时对命名有更严格的约束。迁移时不要只把打包命令换掉就完事客户端那边的加载逻辑、资源映射表逻辑、热更 diff 逻辑都要跟着适配。5.2 资源冗余策略的变化省下了很多人肉优化旧版构建最大的痛点是公共依赖处理需要人工介入。两个 bundle 都引用同一个材质如果你忘了手动把它抽到公共包构建结果里就会出现两份拷贝——体积白白翻倍而且旧版不会给你任何警告。SBP 自动做依赖分析会把被多个 bundle 依赖的资源自动提取到额外的公共 bundle 中使其成为依赖链上的共享节点。这个能力对项目来说几乎是纯赚的——省下了大量人工排查冗余资源的工时。但也要注意一个新问题公共 bundle 的划分是 SBP 决定的不是显式配置的。如果你想控制某个资源必须打在主 bundle 里、禁止它被提取到公共包SBP 在默认情况下并不会让你这么轻易实现需要通过自定义任务干预 bundle 依赖计算阶段。项目早期设计资源划分时就要考虑这一点不要觉得反正有自动去重就不管 bundle 划分了。5.3 哈希与版本号的坑SBP 的 hash 体系和旧版不兼容这里有一个特别容易踩的坑如果客户端之前用旧版 manifest 的 hash 作为资源版本号去请求热更文件迁移后你会发现——同一份资源旧版打出的 hash 和 SBP 打出的 hash 完全不同。这不是 bug是两套构建体系的产物定义本来就不同。正确做法是迁移时一并升级客户端的热更协议推荐直接用AppendHash后的文件名作为 CDN 请求地址这样不需要额外存储 hash 映射表文件名本身就是版本号。旧的热更方案在迁移 SBP 之后必须测试覆盖到位否则会出现 CDN 上存在新文件、客户端却因为 hash 不匹配而反复下载旧文件的问题。5.4 场景与 Enter Play Mode 的关联旧构建管线在处理场景时有一些隐式逻辑迁移到 SBP 后建议检查场景是否按预期打进了 bundle。SBP 处理场景的方式与普通资源不同场景必须作为场景对象放进构建内容里而不是简单依赖收集。如果你的项目把场景打进 AB 包并采用异步场景加载模式迁移时要确认场景入口是否正确收集。这里容易遇到的问题包括场景依赖的 lightmap 数据缺失、场景需要烘焙数据但烘焙文件未纳入依赖。6. 我在生产环境里踩过的坑与压榨策略6.1 命名空间版本差异同一段代码在不同 SBP 版本上的表现SBP 迭代过程中 API 变化比较频繁这是迁移时最大的隐性成本。我最早写代码时用的是BundledAssetContent后来把项目从 2019.4 升到 2021.3随包附带的 SBP 版本更新后BundledAssetContent直接标记为废弃新的BundleContent需要配合IBundleContentSet使用。类似的情况还有DefaultBuildTasks.Create的签名在不同版本间有修改BundleBuildParameters构造函数参数在不同版本有增减ArchiveAndCompressBundles等内部任务类的命名也有所调整。遇到这些问题的正确姿势是打开包管理器查看com.unity.scriptablebuildpipeline当前版本直接去Library/PackageCache/com.unity.scriptablebuildpipeline/Editor下翻源码。SBP 是官方开源包源码就在本地比搜博客可靠得多。我的经验是每次升级 Unity 版本后构建脚本大概率需要微调不要在 CI 上盲目跑构建先在本地最小复现一次。6.2 增量缓存命中不了的常见原因SBP 缓存失效的最常见原因不是资源变了而是构建参数变了。以下操作都会导致缓存大面积失效切换BundleCompressionLZ4 切到 LZMA 会让后续所有任务重新执行开启或关闭AppendHash修改自定义任务的Version值。还有一个比较隐蔽的坑自定义任务如果每次执行都生成随机内容比如写入一个带DateTime.Now的文件SBP 会认为任务输出已经变化后续依赖这个输出的任务全部要重跑。所以在自定义任务里除非确实需要否则不要写入时间戳、随机数这类不稳定内容到会被后续任务依赖的位置。像上面写build-version.txt的例子放在管线尾部就没事如果放在中间就会污染缓存链。6.3 在 CI 上跑 SBP 的注意事项项目规模一大本地打包和 CI 打包往往是并行的。SBP 缓存默认存在Library/BuildCache下多个进程同时读写同一个库目录是有冲突风险的。我们的 CI 集群上就出现过两个构建任务互相踩缓存导致构建失败的情况。建议在 CI 环境里为每次构建设置独立的输出目录和独立的BUILD_CACHE路径或者干脆排队执行。CI 日志方面UseTraceMessages建议关掉。开启后会产生大量细粒度日志排查问题确实方便但在 CI 上意味着日志系统可能撑爆而且查找真正有用的报错信息变得更加困难。我通常的做法是本地调试开 traceCI 上关闭但保留 SBP 的一行 summary 日志和自定义任务的诊断信息。6.4 结合产物目录做增量同步SBP 配合AppendHash还有一个额外好处可以非常简单地实现产物增量同步。构建完成后扫描输出目录把文件名里包含的 hash 值提取出来与 CDN 上的记录做比较只上传新增或变化的文件。因为文件名本身就是内容 hash同样的内容永远不会产生第二个名字重复上传的概率很低。具体做法可以在自定义任务里顺手生成一个bundle-hash.txt像前面章节那样。服务器拿到清单后简单按文件名判断即可不需要维护额外的数据库表。6.5 一点心态建议SBP 并不是一个装上就能自动变快的插件。它能给你带来掌控力但代价是你要花时间理解它的任务模型写一些胶水代码并维护随着 Unity 版本升级而变化的 API。如果你的项目用BuildAssetBundles还能忍资源规模也不大没必要强行迁移但如果你正在为热更包体过大、构建时间过长或者资源重复问题头疼SBP 值得你投入这个学习成本。我的实际体会是迁移 SBP 最有效的推进方式不是推翻重写而是先在项目里加一个菜单项跑通一个最小构建用例再逐步把自定义校验任务加上去。等构建日志变得完全可控之后你再回头看那条 40 分钟的黑盒构建就会明显感觉回不去了。
返回列表