
1. 项目概述为什么我们需要自动化AB包打包在Unity项目开发中尤其是中大型项目资源管理是个绕不开的坎。你肯定遇到过这种情况项目越做越大每次打包发布动辄几十分钟美术同学更新了一个UI图集或者一个模型程序就得手动去勾选一下AssetBundle标签然后重新打包不仅效率低下还容易出错。更头疼的是不同平台比如Android和iOS的AB包不能通用手动管理依赖和版本简直是噩梦。这就是“Unity3D实现自动打包AB包”这个需求最直接的来源——它不是一个炫技的功能而是一个实实在在的生产力工具目标是让资源打包这个重复、繁琐且容易出错的流程变得无人值守、一键触发、结果可靠。简单来说AB包AssetBundle是Unity提供的一种资源打包格式它允许你将场景、模型、纹理、音频等资源从主包中分离出来实现动态加载和更新。而“自动打包”核心就是通过编写编辑器脚本将“收集资源、设置Bundle名、构建输出、处理依赖、生成版本文件”这一系列操作自动化。这不仅仅是写个BuildPipeline.BuildAssetBundles的调用那么简单它涉及到一整套资源管理规范的制定、打包策略的选择、异常处理以及如何与团队工作流如CI/CD集成。我经历过从手动到半自动再到全自动并与Jenkins集成的完整过程这里面的坑和最佳实践远比官方文档那几页内容要丰富得多。2. 核心设计思路与策略选型在动手写代码之前我们必须先想清楚几个关键问题资源按照什么规则来分组打包的频率和时机是什么输出结构怎么设计才能便于客户端加载这些问题的答案构成了自动打包系统的骨架。2.1 资源分组策略按功能、类型还是场景这是最重要的决策直接决定了运行时加载的效率和更新的粒度。常见的策略有几种按功能模块分组比如“登录模块”、“战斗模块”、“商城模块”。这是最直观的方式符合业务逻辑。一个模块的所有资源UI、配置、特效打成一个包。优点是加载逻辑清晰更新时可以以模块为单位。缺点是如果模块间有公共资源比如通用按钮贴图容易造成冗余需要额外抽离公共包。按资源类型分组比如“纹理包”、“模型包”、“音频包”。这种策略利于资源复用同类型资源压缩设置可以统一。但缺点是加载一个功能可能需要同时加载多个不同类型的包IO次数可能增多。按场景分组Unity经典方式每个场景及其依赖资源打成一个包。适合场景切换明确的游戏。混合策略这是实践中采用最多的。我的策略通常是一个公共共享包Common多个功能模块包可能有的场景包。公共包放所有模块都会用到的资源如通用字体、Shader、基础UI精灵。每个功能模块再独立成包。如何实现呢我们不会手动去Inspector里一个个点选。通常是在项目的Assets目录下建立约定俗成的文件夹结构例如Assets/Art/UI/Login然后通过编辑器脚本扫描这些目录自动为目录下的资源设置AssetBundle名称。Bundle名可以就是文件夹路径的变形比如将Assets/Art/UI/Login转为ui/login。// 示例遍历目录自动设置AssetBundle名称 using UnityEditor; using UnityEngine; using System.IO; public static class AutoSetAssetBundleNames { [MenuItem(Tools/AssetBundle/Set Names By Folder)] public static void SetNames() { // 清空所有已有的AssetBundle名称避免残留 ClearAssetBundleNames(); // 设定需要处理的根目录例如所有在 Assets/Art/ 下的资源 string artRoot Assets/Art; DirectoryInfo dirInfo new DirectoryInfo(artRoot); // 遍历所有子目录 WalkDirectory(dirInfo, artRoot); AssetDatabase.RemoveUnusedAssetBundleNames(); AssetDatabase.Refresh(); Debug.Log(AssetBundle名称设置完成。); } static void WalkDirectory(DirectoryInfo dir, string rootPath) { FileInfo[] files dir.GetFiles(*, SearchOption.TopDirectoryOnly); foreach (FileInfo file in files) { // 只处理Unity认识的资源文件忽略.meta文件 if (file.Extension ! .meta) { string assetPath file.FullName.Replace(\\, /); assetPath Assets assetPath.Substring(Application.dataPath.Length); SetSingleAssetBundleName(assetPath, rootPath); } } foreach (DirectoryInfo subDir in dir.GetDirectories()) { WalkDirectory(subDir, rootPath); } } static void SetSingleAssetBundleName(string assetPath, string rootPath) { var importer AssetImporter.GetAtPath(assetPath); if (importer ! null) { // 计算相对于根目录的路径并转为小写作为bundle名 // 例如Assets/Art/UI/Login/Button.prefab - ui/login string bundleName Path.GetDirectoryName(assetPath).Replace(\\, /); bundleName bundleName.Replace(rootPath /, ).ToLower(); // 如果文件就在根目录下bundleName可能为空需要处理 if (string.IsNullOrEmpty(bundleName)) { bundleName misc; // 归到杂项包 } importer.assetBundleName bundleName; } } static void ClearAssetBundleNames() { string[] allAssetPaths AssetDatabase.GetAllAssetPaths(); foreach (string assetPath in allAssetPaths) { var importer AssetImporter.GetAtPath(assetPath); if (importer ! null !string.IsNullOrEmpty(importer.assetBundleName)) { importer.assetBundleName null; } } } }注意自动设置AssetBundle名称是一个“危险”操作因为它会覆盖所有现有设置。务必在干净的版本库基础上操作或者先备份/清空原有设置。建议将设置AssetBundle名称的脚本与打包脚本分离并在打包前作为一个独立的菜单项或CI步骤执行。2.2 打包输出结构设计打包出来的AB包不能是一股脑扔在一个文件夹里。一个清晰的结构对于后续的加载、更新和排查问题至关重要。我常用的输出结构如下[Output_Root]/[Platform]/ ├── AssetBundles/ # 存放所有AssetBundle文件 │ ├── common │ ├── ui │ │ ├── login │ │ └── main │ └── ... ├── Version/ # 存放版本信息文件 │ └── version.json └── BuildReport/ # 存放打包报告可选用于分析 └── build_20231027_1430.json为什么这么设计按平台分离这是Unity强制要求的不同平台的AB包不兼容。所以根目录就是平台名如AndroidiOS。单独AssetBundles文件夹将所有资源包集中管理与版本配置文件分离逻辑清晰。独立的Version文件夹存放一个描述本次所有AB包信息的文件常叫version.json或ab_manifest.json。这个文件是客户端热更新的核心它记录了每个AB包的名称、MD5哈希值用于校验文件是否完整、是否需要更新、文件大小、依赖关系等。这个文件本身也应该被打成一个特殊的AssetBundle供客户端首先加载。可选的BuildReport在自动化流程中记录每次打包的详细信息包体大小、包含资源列表等对于后续优化和审计非常有用。2.3 打包参数详解BuildAssetBundleOptions调用BuildPipeline.BuildAssetBundles时BuildAssetBundleOptions参数的选择直接影响包体大小、加载速度和兼容性。这里有几个关键选项None: 默认选项。会生成依赖关系但不进行特殊处理。UncompressedAssetBundle: 不压缩。这是开发阶段的首选。因为打包速度极快且便于调试可以直接查看包内内容。缺点是包体积最大。ChunkBasedCompression(LZ4): 使用LZ4压缩。这是运行时推荐的压缩方式。它在压缩率、解压速度比LZMA快得多和内存占用之间取得了很好的平衡。构建时间比不压缩长但比LZMA短。ForceRebuildAssetBundle: 强制重新构建所有AssetBundle。在自动化脚本中建议始终开启以确保每次打包都是从干净状态开始避免因缓存导致的奇怪问题。AppendHashToAssetBundleName: 将哈希值附加到AssetBundle文件名上。强烈推荐开启。例如ui/login会变成ui/login_abc123。这为浏览器缓存和增量更新提供了极大便利可以精确判断文件是否变更。DisableWriteTypeTree: 禁止在AssetBundle中写入类型树信息。这可以减小包体但如果你的Unity版本与运行时的版本可能不同就不要开启否则会导致反序列化失败。对于移动端热更新通常保持关闭。一个典型的开发期打包配置可能是BuildAssetBundleOptions.UncompressedAssetBundle | BuildAssetBundleOptions.ForceRebuildAssetBundle。而发布时则切换为BuildAssetBundleOptions.ChunkBasedCompression | BuildAssetBundleOptions.ForceRebuildAssetBundle | BuildAssetBundleOptions.AppendHashToAssetBundleName。3. 自动化打包脚本的核心实现有了清晰的设计我们就可以着手实现核心的打包脚本了。这个脚本应该是一个Editor脚本放在Assets/Editor目录下。3.1 基础打包流程一个最基础的自动化打包函数如下using System.Collections.Generic; using UnityEditor; using UnityEngine; using System.IO; public class AssetBundleBuilder : EditorWindow { // 打包输出路径通常放在项目外避免污染项目目录 private static string OutputPath Path.Combine(Application.dataPath, ../AssetBundleOutput); [MenuItem(Tools/AssetBundle/Build All Platforms)] public static void BuildAllPlatforms() { BuildTarget[] targetPlatforms { BuildTarget.Android, BuildTarget.iOS, BuildTarget.StandaloneWindows }; foreach (var target in targetPlatforms) { BuildForPlatform(target); } } [MenuItem(Tools/AssetBundle/Build for Android)] public static void BuildForAndroid() { BuildForPlatform(BuildTarget.Android); } // 核心打包方法 public static void BuildForPlatform(BuildTarget targetPlatform) { // 1. 准备输出目录 string platformOutputPath Path.Combine(OutputPath, targetPlatform.ToString()); if (!Directory.Exists(platformOutputPath)) { Directory.CreateDirectory(platformOutputPath); } string abOutputPath Path.Combine(platformOutputPath, AssetBundles); // 2. 配置打包选项 BuildAssetBundleOptions options BuildAssetBundleOptions.None; // 根据是开发还是发布选择不同选项 if (EditorUserBuildSettings.development) { options | BuildAssetBundleOptions.UncompressedAssetBundle; } else { // 发布时使用LZ4压缩并附加哈希 options | BuildAssetBundleOptions.ChunkBasedCompression; options | BuildAssetBundleOptions.AppendHashToAssetBundleName; } // 强制重建确保干净 options | BuildAssetBundleOptions.ForceRebuildAssetBundle; // 3. 执行打包 Debug.Log($开始为平台 {targetPlatform} 构建AssetBundles输出路径{abOutputPath}); BuildPipeline.BuildAssetBundles(abOutputPath, options, targetPlatform); Debug.Log($AssetBundles构建完成); // 4. 生成版本信息文件 (下一节详细讲) GenerateVersionFile(abOutputPath, platformOutputPath); // 5. 清理操作可选删除多余的.manifest文件它们对运行时无用 CleanupManifestFiles(abOutputPath); AssetDatabase.Refresh(); Debug.Log($平台 {targetPlatform} 的所有打包流程已完成。); } }3.2 生成版本信息文件这是自动化打包的灵魂。我们需要生成一个文件让客户端知道当前服务器上有哪些AB包每个包的版本哈希值是什么。通常我们选择生成一个JSON文件。首先定义一个数据结构来存储单个AB包的信息[System.Serializable] public class AssetBundleInfo { public string name; // AssetBundle名称不含哈希如 ui/login public string hash; // 完整的带哈希的文件名或单独的哈希值 public string md5; // 文件的MD5校验码用于下载后校验 public long size; // 文件大小字节 public string[] dependencies; // 依赖的AB包名称列表 } [System.Serializable] public class AssetBundleManifest { public string buildTime; // 打包时间 public string unityVersion;// Unity版本 public ListAssetBundleInfo bundles new ListAssetBundleInfo(); }然后在打包完成后遍历输出目录收集信息并生成JSONusing System.Security.Cryptography; using System.Text; static void GenerateVersionFile(string abFolderPath, string platformOutputPath) { AssetBundleManifest manifest new AssetBundleManifest(); manifest.buildTime System.DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss); manifest.unityVersion Application.unityVersion; // 首先加载Unity构建时生成的主Manifest文件它包含了所有依赖信息 string mainManifestPath Path.Combine(abFolderPath, targetPlatform.ToString()); // 注意主manifest文件名就是平台名 if (!File.Exists(mainManifestPath)) { Debug.LogError($未找到主Manifest文件{mainManifestPath}版本文件生成失败。); return; } // 加载这个主manifest获取依赖关系 AssetBundle assetBundle AssetBundle.LoadFromFile(mainManifestPath); if (assetBundle null) { Debug.LogError($加载主Manifest AssetBundle失败{mainManifestPath}); return; } AssetBundleManifest unityManifest assetBundle.LoadAssetAssetBundleManifest(AssetBundleManifest); assetBundle.Unload(false); // 遍历文件夹收集所有.ab文件不含.manifest DirectoryInfo dir new DirectoryInfo(abFolderPath); FileInfo[] allFiles dir.GetFiles(*.*, SearchOption.AllDirectories); foreach (FileInfo file in allFiles) { if (file.Extension .manifest) continue; // 忽略manifest文件 if (file.Name targetPlatform.ToString()) continue; // 忽略主manifest文件本身它是.ab文件但我们已经处理了 AssetBundleInfo info new AssetBundleInfo(); // 获取AssetBundle名称去掉平台名和哈希后缀 string fullName file.Name; // 如果打包时启用了AppendHashToAssetBundleName文件名会是 ui/login_abc123 // 我们需要分离出 name 和 hash int hashIndex fullName.LastIndexOf(_); if (hashIndex 0) { info.name fullName.Substring(0, hashIndex); info.hash fullName.Substring(hashIndex 1); // 提取哈希部分 } else { info.name fullName; info.hash ; } // 计算MD5 using (var md5 MD5.Create()) { using (var stream File.OpenRead(file.FullName)) { byte[] hashBytes md5.ComputeHash(stream); info.md5 BitConverter.ToString(hashBytes).Replace(-, ).ToLowerInvariant(); } } info.size file.Length; // 从Unity的Manifest中获取依赖 if (unityManifest ! null) { // 注意GetAllDependencies需要传入的是AssetBundle名称不含哈希 info.dependencies unityManifest.GetAllDependencies(info.name); } else { info.dependencies new string[0]; } manifest.bundles.Add(info); } // 将主Manifest文件也作为一个特殊的Bundle信息加入列表客户端需要先加载它 FileInfo mainFile new FileInfo(mainManifestPath); AssetBundleInfo mainInfo new AssetBundleInfo(); mainInfo.name AssetBundleManifest; // 给一个固定的名字 mainInfo.hash ; using (var md5 MD5.Create()) { using (var stream File.OpenRead(mainFile.FullName)) { byte[] hashBytes md5.ComputeHash(stream); mainInfo.md5 BitConverter.ToString(hashBytes).Replace(-, ).ToLowerInvariant(); } } mainInfo.size mainFile.Length; mainInfo.dependencies new string[0]; manifest.bundles.Add(mainInfo); // 序列化为JSON string json JsonUtility.ToJson(manifest, true); string versionFilePath Path.Combine(platformOutputPath, Version, version.json); Directory.CreateDirectory(Path.GetDirectoryName(versionFilePath)); File.WriteAllText(versionFilePath, json, Encoding.UTF8); Debug.Log($版本文件已生成{versionFilePath}); }实操心得AssetBundleManifest这个类名容易混淆。Unity API中有一个AssetBundleManifest类用于在运行时获取依赖信息。而我们上面定义的是自己用于版本管理的AssetBundleManifest数据结构。在实际代码中最好给我们的类改个名字比如ABVersionManifest以避免歧义。3.3 依赖管理与冗余检测依赖管理是AB包最容易出问题的地方。如果A包和B包都引用了同一个材质球但这个材质球没有被打进任何一个包或者被打进了两个包都会导致运行时错误或资源冗余。如何确保依赖被打包Unity的打包系统会自动处理依赖。只要你正确设置了资源的AssetBundle名称在打包时Unity会分析资源之间的引用关系确保被引用的资源如材质、纹理被打包到引用它的AB包中或者如果该资源自己也设置了Bundle名则会产生依赖关系。关键在于你要确保所有被引用的资源都要么设置了Bundle名成为独立的可管理单元要么被显式地包含在某个设置了Bundle名的场景或预制体中。如何检测冗余冗余是指同一个资源被多个AB包包含导致包体膨胀。我们可以利用Unity Editor的AssetDatabase.GetDependencies和打包后生成的.manifest文本文件来分析。一个简单的冗余检测思路在打包后执行解析每个AB包对应的.manifest文件这是一个文本文件列出了包内所有资源的GUID。建立一个DictionaryGUID, ListAB包名的映射。如果发现同一个GUID出现在多个AB包的列表中那么这个资源就是冗余的。根据冗余资源类型和所属AB包给出优化建议例如将公共资源抽离到独立的公共包。这个检测可以集成到打包脚本的最后输出一个报告提醒开发者注意资源分配是否合理。4. 进阶与CI/CD流水线集成对于团队项目自动打包不应该只是编辑器里的一个菜单项而应该集成到持续集成CI服务器如Jenkins, GitLab CI, GitHub Actions中实现代码提交后自动打包、测试、甚至部署。4.1 命令行打包Unity支持以-batchmode批处理模式和-quit执行后退出的方式从命令行执行编辑器脚本。我们需要创建一个入口脚本。在Assets/Editor下创建BuildAssetBundlesInBatchMode.csusing UnityEditor; using UnityEngine; public class BuildAssetBundlesInBatchMode { public static void Build() { // 从命令行参数中获取目标平台 string platformArg GetCommandLineArg(-platform); BuildTarget target BuildTarget.NoTarget; switch (platformArg.ToLower()) { case android: target BuildTarget.Android; break; case ios: target BuildTarget.iOS; break; case win64: target BuildTarget.StandaloneWindows64; break; // ... 其他平台 default: Debug.LogError($未知的平台参数: {platformArg}); EditorApplication.Exit(1); return; } // 调用我们之前写好的打包方法 AssetBundleBuilder.BuildForPlatform(target); // 打包成功退出Unity编辑器在批处理模式下 EditorApplication.Exit(0); } private static string GetCommandLineArg(string name) { var args System.Environment.GetCommandLineArgs(); for (int i 0; i args.Length; i) { if (args[i] name i 1 args.Length) { return args[i 1]; } } return null; } }然后在CI服务器的脚本中调用Unity的命令行# 示例在Jenkins的Shell构建步骤中 UNITY_PATH/Applications/Unity/Hub/Editor/2022.3.25f1/Unity.app/Contents/MacOS/Unity PROJECT_PATH/path/to/your/unity/project $UNITY_PATH -batchmode -quit -projectPath $PROJECT_PATH -executeMethod BuildAssetBundlesInBatchMode.Build -platform Android -logFile build_android.log4.2 增量打包与版本管理全量打包在资源很多时非常耗时。我们可以实现简单的增量打包逻辑只打包那些自上次打包以来发生变化的资源。思路在每次成功打包后记录本次所有AB包的哈希值或MD5到一个“上次构建记录”文件中。下次打包前先计算当前资源如果打包每个AB包会生成的哈希值Unity提供了AssetDatabase.GetAssetBundleHash方法可以获取一个AssetBundle的哈希但这需要先设置好名称。将本次计算的哈希值与上次记录的哈希值对比。只构建那些哈希值发生变化的AB包。然而在实践中我通常不建议在团队协作的CI环境中使用复杂的增量打包。原因有二一是依赖管理复杂一个底层资源的改动可能影响多个AB包需要递归检查二是为了确保构建环境的一致性和可重现性干净的、全量的构建更可靠。全量构建虽然耗时但可以安排在夜间自动进行。开发期本地打包可以使用不压缩UncompressedAssetBundle选项来极大提升速度这比实现一套健壮的增量打包系统更简单有效。版本管理则更为重要。除了我们生成的version.json还应该将本次构建的AB包输出目录整体归档并打上一个版本标签如ab_v1.2.3_android上传到文件服务器或制品库如Nexus, AWS S3。CI脚本可以自动完成这些操作。5. 常见问题与排查技巧实录即使自动化了打包过程中和运行时依然会遇到各种问题。这里记录几个我踩过的坑和解决方法。5.1 打包失败资源依赖缺失或循环依赖现象打包过程报错提示某些资源找不到或者构建过程卡死。排查检查AssetBundle名称设置是否有资源没有被正确设置Bundle名但却被其他已设置Bundle名的资源所引用使用编辑器工具Window - Asset Management - AssetBundle Browser需安装Package可以可视化查看和调试。检查场景引用如果打包包含场景确保场景中所有用到的资源尤其是Prefab上引用的都正确设置了Bundle名或者被打包进了场景所在的AB包。循环依赖A包依赖B包B包又依赖A包。这通常是由于资源分配策略不合理导致的。需要重新规划资源分组打破循环。可以通过脚本检测所有Bundle的依赖关系图查找循环。5.2 运行时加载失败NullReferenceException或FileNotFoundException现象在手机上加载AB包时失败错误信息模糊。排查步骤确认AB包是否存在检查下载路径确认version.json和对应的.ab文件是否成功下载到设备的持久化数据路径Application.persistentDataPath。校验MD5在加载文件前先计算本地文件的MD5与version.json中记录的对比。如果不匹配说明文件下载不完整或被篡改需要重新下载。检查加载路径和API使用AssetBundle.LoadFromFile加载本地文件时路径必须是绝对路径。使用UnityWebRequestAssetBundle加载远程或本地文件时注意Uri的格式file://前缀对于本地文件是必须的。加载依赖包必须先加载所有依赖包才能加载目标包。你需要先加载主Manifest包即打包输出的那个以平台命名的AB包获取AssetBundleManifest对象然后通过manifest.GetAllDependencies(abName)获取依赖列表并依次加载它们。// 正确的加载顺序示例 AssetBundle manifestAB AssetBundle.LoadFromFile(manifestPath); AssetBundleManifest manifest manifestAB.LoadAssetAssetBundleManifest(AssetBundleManifest); string[] deps manifest.GetAllDependencies(ui/login); foreach (string depName in deps) { // 假设你已经知道带哈希的文件名这里需要拼接 string depPath Path.Combine(abRootPath, depName _ depHash); AssetBundle.LoadFromFile(depPath); } // 最后加载目标包 AssetBundle loginAB AssetBundle.LoadFromFile(loginPath);平台与压缩格式确保加载的AB包构建目标平台与运行时平台一致且压缩格式兼容。在开发期用未压缩包发布后用LZ4包如果混用会导致加载失败。5.3 内存与性能问题现象加载多个AB包后内存居高不下或者加载速度慢。优化技巧及时卸载使用AssetBundle.Unload(false)或AssetBundle.Unload(true)。false表示只卸载AB包容器但已加载的资产保留在内存中如果还有引用。true表示连容器带资产一起卸载但前提是这些资产没有被任何场景对象引用。管理好卸载时机是控制内存的关键。通常一个场景或模块用完后卸载其对应的AB包。依赖共享如果多个包依赖同一个资源比如一个通用Shader确保这个资源被打包在一个公共包如common中并且只加载一次。依赖包本身并不包含该资源的副本。避免频繁加载/卸载小包IO操作有开销。如果某些小资源频繁使用可以考虑将它们合并到更大的包中或者常驻内存。使用Addressables对于超大型项目Unity的Addressable Asset System是比原生AB更现代、功能更强大的资源管理系统。它底层也使用AB但提供了更友好的异步加载、依赖管理、内存管理和更新流程。如果你的项目资源管理非常复杂可以考虑迁移到Addressables。5.4 热更新流程设计自动打包的最终目的是为了热更新。一个基本的热更新流程如下客户端启动检查本地persistentDataPath下是否有version.json。如果没有则从StreamingAssets复制初始版本。请求服务器版本向服务器请求最新的version.json。对比差异逐条对比本地和服务器版本的AssetBundleInfo列表比较md5字段。下载更新对于md5不一致或本地不存在的包加入下载队列。可以使用UnityWebRequest进行断点续传下载。校验与替换下载完成后校验本地文件的MD5。通过后将新包移动到正式AB包目录覆盖旧文件注意备份以便回滚。更新本地版本文件用服务器的version.json替换本地的。重启或热重载对于代码以外的资源模型、纹理、配置通常重启游戏后生效。对于某些资源如文本配置可以实现不重启的热重载。这个流程可以封装成一个独立的HotUpdateManager模块与我们的自动打包系统相辅相成。打包系统提供准确的版本信息热更新模块负责安全的下载和切换。实现自动打包AB包系统就像为项目搭建了一条资源生产的自动化流水线。初期投入一些时间设计和开发是值得的它会为整个团队节省无数的手动操作时间并极大降低人为错误的风险。从设置命名规则到编写打包脚本再到集成CI和设计热更新流程每一步都需要结合项目的具体需求来权衡。记住没有银弹最适合自己项目工作流的才是最好的方案。