Skip to content

题海扩展:资源、热更、构建

资源命名规范如何设计?

unity-resource-naming-convention-design

标准答案

资源命名规范的核心不是“看起来整齐”,而是让资源能被搜索、打包、热更、自动检查、多人协作稳定管理。我的设计思路是:先定目录结构,再定文件名模板,再定 Address 和 Bundle 规则,最后用工具自动检查。

推荐规则

文件名可以用:

类型_模块_名称_用途_变体

例如:

PF_UI_ShopPanelTX_Char_HeroA_DiffuseMAT_Char_HeroA_BodySND_UI_Button_ClickVFX_Battle_Hit_FireCFG_Item_BaseATLAS_UI_Shop

常用前缀:

PF:Prefab TX:Texture MAT:Material ANIM:AnimationClip CTRL:AnimatorController SND:AudioClip VFX:特效 CFG:配置 SCN:Scene SHD:Shader ATLAS:图集 FONT:字体

底层原理

Unity 资源引用主要靠 .meta 里的 GUID 维持,文件改名不一定会丢引用;但如果项目里用字符串路径、Addressables Address、AssetBundle 名、配置表路径来加载资源,命名和路径不稳定就会直接影响运行时加载、热更对比和包体分析。

所以命名要分层:

目录名:给人和模块看。 文件名:给美术、策划、程序搜索。 Address:给运行时加载。 Bundle 名:给打包和热更。 标签/分组:给工具分析和自动检查。

简单校验代码

c
using System.Text.RegularExpressions; // 引入正则表达式工具
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Debug 日志

public static class AssetNameChecker // 定义资源命名检查工具
{ // 类开始
    private static readonly Regex Rule = new Regex(@"^[A-Z]+_[A-Za-z0-9]+_[A-Za-z0-9_]+$"); // 定义命名规则

    [MenuItem("Tools/Check Asset Names")] // 添加编辑器菜单
    private static void Check() // 执行检查
    { // 方法开始
        string[] guids = AssetDatabase.FindAssets(""); // 查找项目内所有资源
        foreach (string guid in guids) // 遍历每个资源 GUID
        { // 循环开始
            string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转成资源路径
            string name = System.IO.Path.GetFileNameWithoutExtension(path); // 获取不带扩展名的文件名
            if (!Rule.IsMatch(name)) // 判断文件名是否不符合规则
            { // if 开始
                Debug.LogWarning("资源命名不规范: " + path); // 打印有问题的资源路径
            } // if 结束
        } // 循环结束
    } // 方法结束
} // 类结束

Unity 项目里怎么落地

我会把规范写进导入工具和 CI,而不是只写文档。资源导入时检查命名;打包前检查 Address、Bundle、重复资源、中文路径、空格、临时文件;提交前阻止 New Material副本最终版 这类名字进仓库。

官方参考:Unity AssetDatabase.FindAssets 可用于编辑器资源搜索;Unity Asset Metadata 说明 .meta 与 GUID 对资源引用很重要;Addressables 文档说明运行时资源管理依赖 Address 和分组。

参考:AssetDatabase.FindAssetsAsset metadataAddressables

资源目录规范如何设计?

unity-resource-directory-convention-design

标准答案

资源目录规范的核心是:目录要体现业务边界、资源类型、加载生命周期和打包边界。不是谁想放哪就放哪,而是让程序、美术、策划、打包工具、热更系统都能稳定找到资源。

我一般会这样分:

c
Assets/
  Game/
    Runtime/
      UI/
      Character/
      Battle/
      Scene/
      Common/
    Config/
    Addressables/
  Editor/
  ThirdParty/
  Tests/
  StreamingAssets/

设计原则

Game/Runtime:运行时真正会进包的项目资源。 Game/Common:字体、公共 Shader、通用图集、公共材质。 Game/UI/Shop/Prefabs:按业务模块放 UI 预制体。 Game/UI/Shop/Textures:模块自己的图片资源。 Game/Config:配置表、ScriptableObject 配置。 Editor:只放编辑器工具,避免运行时代码混进去。 ThirdParty:第三方插件单独隔离,方便升级和排查。 StreamingAssets:只放必须原样保留的运行时文件。 Resources:尽量少用,避免资源被隐式打进包、难追踪卸载。

底层原理

Unity 资源引用靠 .meta 文件里的 GUID 维护,所以移动资源时最好在 Unity Project 窗口里移动,保证 .meta 跟着走。Unity 官方也说明,.meta 丢失会导致引用断掉。

同时 Unity 有一些特殊目录名,比如 EditorResourcesPluginsStreamingAssets,它们有特殊编译、加载或打包含义,不能当普通文件夹乱用。Addressables 分组也会影响 AssetBundle 打包方式,所以目录最好和加载、卸载、热更边界对齐。

一个更完整的例子

c
Assets/Game/Runtime/UI/Shop/Prefabs/PF_UI_ShopPanel.prefab
Assets/Game/Runtime/UI/Shop/Textures/TX_UI_Shop_Bg.png
Assets/Game/Runtime/Character/HeroA/Prefabs/PF_Char_HeroA.prefab
Assets/Game/Runtime/Battle/VFX/VFX_Battle_Hit_Fire.prefab
Assets/Game/Runtime/Common/Fonts/FONT_Main.asset
Assets/Game/Runtime/Common/Shaders/SHD_UI_Gray.shader
Assets/Game/Config/Item/CFG_Item_Base.asset
Assets/Editor/AssetCheck/AssetNameChecker.cs
Assets/ThirdParty/DOTween/

常见坑点

不要把编辑器脚本放进运行时目录。 不要把临时资源、测试资源混进正式资源目录。 不要到处建 Resources 文件夹。 不要让同一个模块资源散落在多个目录里。 不要让共享资源放在业务模块下,否则容易重复打包。 不要直接按美术习惯分目录,而要兼顾运行时加载和热更分包。

官方参考:Unity Reserved folder namesAsset metadataAddressables Pack groups into AssetBundles

美术资源导入后如何自动设置压缩格式?

unity-art-asset-auto-texture-compression

标准答案

美术资源导入后自动设置压缩格式,通常用 AssetPostprocessor + TextureImporter 做。 流程是:美术把图片拖进工程后,Unity 导入前触发 OnPreprocessTexture(),工具脚本根据路径、命名、资源类型、平台规则,自动设置 maxSizeformatmipmapisReadableSprite/NormalMap 等参数。

核心思路

UI 贴图:一般关 mipmap,按平台用 ASTC,重要图标可更高质量。 角色/场景贴图:一般开 mipmap,限制 maxSize,按质量档压缩。 法线贴图:必须设置为 NormalMap,不要当普通颜色图。 Read/Write:默认关闭,避免内存和显存双份占用。

示例代码

c
using UnityEditor; // 引入 Unity 编辑器 API
using UnityEngine; // 引入 Unity 基础类型

public sealed class TextureImportProcessor : AssetPostprocessor // 定义贴图导入处理器
{ // 类开始
    private void OnPreprocessTexture() // Unity 在贴图导入前调用
    { // 方法开始
        TextureImporter importer = (TextureImporter)assetImporter; // 获取当前贴图的导入器
        string path = assetPath.Replace("\\", "/"); // 统一路径分隔符
        importer.isReadable = false; // 默认关闭 Read/Write,避免额外内存占用
        importer.alphaIsTransparency = true; // 让透明通道按透明贴图处理
        if (path.Contains("/UI/")) // 如果是 UI 目录下的贴图
        { // if 开始
            importer.textureType = TextureImporterType.Sprite; // 设置为 Sprite 类型
            importer.mipmapEnabled = false; // UI 贴图通常不需要 mipmap
            SetPlatform(importer, "Android", TextureImporterFormat.ASTC_6x6, 1024); // 设置 Android 压缩格式
            SetPlatform(importer, "iPhone", TextureImporterFormat.ASTC_6x6, 1024); // 设置 iOS 压缩格式
            return; // UI 规则处理完成后返回
        } // if 结束
        if (path.Contains("_N") || path.Contains("_Normal")) // 如果命名表示法线贴图
        { // if 开始
            importer.textureType = TextureImporterType.NormalMap; // 设置为法线贴图类型
            importer.mipmapEnabled = true; // 3D 贴图通常需要 mipmap
            SetPlatform(importer, "Android", TextureImporterFormat.ASTC_6x6, 1024); // 设置 Android 法线压缩
            SetPlatform(importer, "iPhone", TextureImporterFormat.ASTC_6x6, 1024); // 设置 iOS 法线压缩
            return; // 法线规则处理完成后返回
        } // if 结束
        importer.textureType = TextureImporterType.Default; // 其他贴图按普通贴图处理
        importer.mipmapEnabled = true; // 普通 3D 贴图开启 mipmap
        SetPlatform(importer, "Android", TextureImporterFormat.ASTC_8x8, 2048); // Android 普通图使用更省空间的 ASTC
        SetPlatform(importer, "iPhone", TextureImporterFormat.ASTC_8x8, 2048); // iOS 普通图使用更省空间的 ASTC
    } // 方法结束

    private static void SetPlatform(TextureImporter importer, string platform, TextureImporterFormat format, int maxSize) // 设置指定平台贴图参数
    { // 方法开始
        TextureImporterPlatformSettings settings = new TextureImporterPlatformSettings(); // 创建平台贴图设置
        settings.name = platform; // 设置平台名,例如 Android 或 iPhone
        settings.overridden = true; // 开启平台覆盖设置
        settings.format = format; // 设置平台压缩格式
        settings.maxTextureSize = maxSize; // 设置最大贴图尺寸
        settings.compressionQuality = 50; // 设置压缩质量
        importer.SetPlatformTextureSettings(settings); // 应用平台贴图设置
    } // 方法结束
} // 类结束

项目里怎么落地

我不会只靠导入脚本,还会加一层“打包前检查”:扫描所有贴图,发现 UI 开了 mipmap、Read/Write 没关、Android 没设压缩、法线图没设 NormalMap,就直接报错阻止进包。

Unity 官方依据:AssetPostprocessor.OnPreprocessTexture 会在 Texture Importer 运行前回调;TextureImporter 控制贴图导入设置;SetPlatformTextureSettingsTextureImporterPlatformSettings 用于设置平台覆盖参数。

参考:OnPreprocessTextureTextureImporterSetPlatformTextureSettings

如何自动检查贴图尺寸是否超标?

unity-texture-size-overlimit-auto-check

标准答案

自动检查贴图尺寸,核心做法是:把“资源规范”写成程序规则,在导入、提交、打包前自动扫描贴图,发现超标就输出报告,严重时直接阻断打包。

一般会做三层:

  1. 导入时提醒:美术把贴图拖进 Unity 后立刻警告。
  2. 菜单全量检查:开发者手动点一次,扫全项目。
  3. CI / 打包前检查:如果有超标资源,直接让构建失败。

底层原理

Unity 贴图有两个尺寸概念:

源图尺寸:比如原始 PNG 是 4096x4096

导入后尺寸:Unity 可以通过 TextureImporter.maxTextureSize 把它压到 20481024

面试里要说清楚:不能只看 Inspector 里的 Max Size,因为源图过大仍然会影响仓库体积、导入时间、热更包体、峰值处理成本。Unity 官方提供了 TextureImporter.GetSourceTextureWidthAndHeight 读取源图宽高;也可以用 AssetDatabase.FindAssets("t:Texture2D") 全量找贴图,用 AssetPostprocessor.OnPostprocessTexture 做导入后通知。

参考 Unity 官方文档:GetSourceTextureWidthAndHeightFindAssetsOnPostprocessTexture

代码示例

c
using UnityEditor; // 引入 Unity 编辑器资源数据库 API。
using UnityEngine; // 引入 Debug 和 Mathf 等 Unity 基础 API。

public static class TextureSizeChecker // 定义贴图尺寸检查工具类。
{ // 类开始。
    [MenuItem("Tools/Art/Check Texture Size")] // 在 Unity 顶部菜单添加检查入口。
    public static void CheckFromMenu() // 菜单点击后执行的方法。
    { // 方法开始。
        int count = CheckAllTextures(); // 扫描所有贴图并返回超标数量。
        Debug.Log(count == 0 ? "[TextureSize] 全部通过。" : $"[TextureSize] 超标数量: {count}"); // 输出检查结果。
    } // 方法结束。

    public static int CheckAllTextures() // 给菜单、CI、打包流程复用的扫描方法。
    { // 方法开始。
        string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { "Assets/Game" }); // 在业务资源目录查找所有 Texture2D。
        int errorCount = 0; // 记录超标贴图数量。
        foreach (string guid in guids) // 遍历每一个贴图 GUID。
        { // 循环开始。
            string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转成资源路径。
            TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; // 获取贴图导入器。
            if (importer == null) // 判断导入器是否为空。
            { // 分支开始。
                continue; // 不是有效贴图就跳过。
            } // 分支结束。
            importer.GetSourceTextureWidthAndHeight(out int width, out int height); // 读取源图真实宽高。
            int limit = GetLimitByPath(path); // 根据路径规则获取尺寸上限。
            int maxSide = Mathf.Max(width, height); // 取宽高中较大的一边。
            if (maxSide <= limit) // 判断是否没有超标。
            { // 分支开始。
                continue; // 没超标就检查下一张。
            } // 分支结束。
            errorCount++; // 超标数量加一。
            Debug.LogError($"[TextureSize] {path} 源图 {width}x{height} 超过上限 {limit}"); // 输出具体错误路径和尺寸。
        } // 循环结束。
        return errorCount; // 返回超标数量。
    } // 方法结束。

    private static int GetLimitByPath(string path) // 根据资源路径返回尺寸规则。
    { // 方法开始。
        string p = path.Replace("\\", "/"); // 统一路径分隔符。
        if (p.Contains("/UI/Icon/")) return 512; // UI 图标限制为 512。
        if (p.Contains("/UI/Background/")) return 2048; // UI 背景限制为 2048。
        if (p.Contains("/Character/")) return 4096; // 角色贴图限制为 4096。
        if (p.Contains("/Scene/")) return 2048; // 场景普通贴图限制为 2048。
        return 2048; // 默认限制为 2048。
    } // 方法结束。
} // 类结束。

Unity 工程实践

我会把这个工具接到资源规范里:图标、背景、角色、场景贴图分别设置不同上限。开发阶段用菜单检查,导入阶段给警告,打包前或 CI 里强制扫描;如果发现超标,报告里必须包含路径、源图尺寸、规则上限和修改建议。

如何检查 Prefab 引用丢失?

unity-prefab-missing-reference-check

标准答案

检查 Prefab 引用丢失,一般不是靠人工点开 Inspector,而是写 Editor 工具自动扫描:

  1. AssetDatabase.FindAssets("t:Prefab") 找到所有 Prefab。
  2. PrefabUtility.LoadPrefabContents(path) 把 Prefab 加载到隔离场景里检查。
  3. 遍历所有子节点和组件。
  4. 组件为 null,说明是 Missing Script
  5. 遍历组件的 SerializedProperty,如果字段是对象引用,objectReferenceValue == null,但内部还保留旧引用 ID,就说明是 Missing Reference
  6. 输出 Prefab 路径、节点路径、组件名、字段名,方便直接定位。

Unity 官方也建议 LoadPrefabContents 用于批处理 Prefab,处理完要 UnloadPrefabContents 释放;SerializedProperty 用来通用访问 Unity 序列化字段;FindAssets 用来按类型搜索资源。

参考:LoadPrefabContentsUnloadPrefabContentsSerializedPropertyFindAssets

底层原理

Prefab 本质上保存的是序列化数据。比如脚本字段里拖了一个材质、贴图、AudioClip,Unity 会记录对象引用信息。后来资源被删除、.meta 丢失、GUID 变了,或者脚本字段改名,就可能出现引用断裂。

这里要区分两个概念:

普通 null:字段本来就没拖东西,这是合法的。

Missing Reference:字段以前引用过对象,但对象找不到了,这是错误。

所以工程检查时不能看到 null 就报错,而是要判断它是不是“丢失的旧引用”。

代码示例

c
using UnityEditor; // 引入 Unity 编辑器 API。 
using UnityEngine; // 引入 GameObject、Transform、Component、Debug 等 Unity 类型。 

public static class PrefabMissingReferenceChecker // 定义 Prefab 引用丢失检查工具类。 
{ // 类开始。 
    [MenuItem("Tools/Check/Check Prefab Missing References")] // 在 Unity 菜单栏添加检查入口。 
    private static void CheckAllPrefabs() // 定义扫描所有 Prefab 的入口方法。 
    { // 方法开始。 
        string[] guids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Game" }); // 在业务目录下查找所有 Prefab。 
        int errorCount = 0; // 记录发现的问题数量。 
        foreach (string guid in guids) // 遍历每一个 Prefab 的 GUID。 
        { // 循环开始。 
            string prefabPath = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转换成资源路径。 
            GameObject root = null; // 保存加载出来的 Prefab 根节点。 
            try // 使用 try 保证后面一定能释放 Prefab 内容。 
            { // try 开始。 
                root = PrefabUtility.LoadPrefabContents(prefabPath); // 把 Prefab 加载到隔离场景中。 
                errorCount += CheckPrefabRoot(prefabPath, root); // 检查当前 Prefab 并累计错误数。 
            } // try 结束。 
            finally // 无论检查是否异常,都进入释放逻辑。 
            { // finally 开始。 
                if (root != null) // 判断 Prefab 是否成功加载。 
                { // 判断开始。 
                    PrefabUtility.UnloadPrefabContents(root); // 释放隔离场景中的 Prefab 内容。 
                } // 判断结束。 
            } // finally 结束。 
        } // 循环结束。 
        Debug.Log(errorCount == 0 ? "[PrefabCheck] 全部通过。" : $"[PrefabCheck] 发现问题数量: {errorCount}"); // 输出最终检查结果。 
    } // 方法结束。 

    private static int CheckPrefabRoot(string prefabPath, GameObject root) // 检查单个 Prefab 根节点。 
    { // 方法开始。 
        int errorCount = 0; // 记录当前 Prefab 的错误数量。 
        Transform[] transforms = root.GetComponentsInChildren<Transform>(true); // 获取所有子节点,包括未激活节点。 
        foreach (Transform transform in transforms) // 遍历每一个 Transform。 
        { // 循环开始。 
            string nodePath = GetHierarchyPath(transform); // 获取当前节点在 Prefab 内的层级路径。 
            Component[] components = transform.GetComponents<Component>(); // 获取当前节点上的所有组件。 
            for (int i = 0; i < components.Length; i++) // 遍历组件数组。 
            { // 循环开始。 
                Component component = components[i]; // 取出当前组件。 
                if (component == null) // 判断组件是否丢失脚本。 
                { // 判断开始。 
                    Debug.LogError($"[MissingScript] Prefab={prefabPath}, Node={nodePath}, ComponentIndex={i}"); // 输出 Missing Script 位置。 
                    errorCount++; // 错误数量加一。 
                    continue; // 跳过空组件。 
                } // 判断结束。 
                SerializedObject serializedObject = new SerializedObject(component); // 创建组件的序列化对象。 
                SerializedProperty property = serializedObject.GetIterator(); // 获取序列化属性迭代器。 
                bool enterChildren = true; // 第一次遍历需要进入子属性。 
                while (property.NextVisible(enterChildren)) // 遍历所有可见序列化字段。 
                { // 循环开始。 
                    enterChildren = false; // 后续遍历由迭代器自己控制层级。 
                    if (property.propertyType != SerializedPropertyType.ObjectReference) // 判断字段是否不是对象引用。 
                    { // 判断开始。 
                        continue; // 非对象引用字段直接跳过。 
                    } // 判断结束。 
                    if (property.objectReferenceValue != null) // 判断对象引用是否仍然有效。 
                    { // 判断开始。 
                        continue; // 有效引用不算丢失。 
                    } // 判断结束。 
                    if (property.objectReferenceInstanceIDValue == 0) // 判断是否只是普通空引用。 
                    { // 判断开始。 
                        continue; // 普通 null 是合法空引用,不报错。 
                    } // 判断结束。 
                    Debug.LogError($"[MissingReference] Prefab={prefabPath}, Node={nodePath}, Component={component.GetType().Name}, Field={property.propertyPath}"); // 输出丢失引用位置。 
                    errorCount++; // 错误数量加一。 
                } // 循环结束。 
            } // 循环结束。 
        } // 循环结束。 
        return errorCount; // 返回当前 Prefab 的错误数量。 
    } // 方法结束。 

    private static string GetHierarchyPath(Transform target) // 生成节点层级路径。 
    { // 方法开始。 
        string path = target.name; // 初始路径是当前节点名。 
        Transform parent = target.parent; // 获取父节点。 
        while (parent != null) // 只要还有父节点就继续向上拼路径。 
        { // 循环开始。 
            path = parent.name + "/" + path; // 把父节点名拼到路径前面。 
            parent = parent.parent; // 继续向上一层。 
        } // 循环结束。 
        return path; // 返回完整层级路径。 
    } // 方法结束。 
} // 类结束。

Unity 工程实践

项目里我会把这个检查接到三个地方:菜单手动检查、打包前检查、CI 自动检查。报告不要只写“有 Missing”,而要写清楚:

Prefab 路径 + 节点路径 + 组件类型 + 字段 propertyPath

这样美术或程序点开 Prefab 就能修,不会浪费时间猜。

如何检查资源重复引用?

unity-duplicate-resource-reference-check

标准答案

检查资源重复引用,我会先建立“资源依赖图”:扫描入口资源,比如 Prefab、Scene、ScriptableObject、Material,然后用 AssetDatabase.GetDependencies 递归拿到依赖,再反向统计:

某个依赖资源 -> 被哪些入口资源引用

但要注意:多个 Prefab 引用同一张贴图不一定错。真正危险的是:这些 Prefab 被打进不同 AssetBundle / Addressables Group,而公共依赖没有抽成共享资源,导致同一张贴图、材质、动画被重复打包。

Unity 里可以用 AssetDatabase.FindAssets 找资源,用 AssetDatabase.GetDependencies(path, true) 取依赖;如果项目用 Addressables,还可以用官方 Analyze 工具里的 Check Duplicate Bundle Dependencies

参考:GetDependenciesFindAssetsAddressables Analyze

底层原理

资源重复引用分两种:

一种是“共享引用”:比如多个怪物 Prefab 都引用同一个 MonsterCommon.mat,这是正常复用。

另一种是“重复打包”:比如 Hero.prefabplayer_bundleMonster.prefabmonster_bundle,它们都依赖 Shared.png,但 Shared.png 没有单独成为共享包,那么打包时可能两边各带一份,造成包体变大、下载变多、内存加载也更乱。

代码示例

c
using System.Collections.Generic; // 引入 Dictionary 和 HashSet。 
using System.IO; // 引入 Path 处理资源扩展名。 
using UnityEditor; // 引入 Unity 编辑器资源数据库 API。 
using UnityEngine; // 引入 Debug 输出 API。 

public static class DuplicateResourceReferenceChecker // 定义重复资源引用检查工具类。 
{ // 类开始。 
    [MenuItem("Tools/Check/Check Duplicate Resource References")] // 在 Unity 菜单栏添加检查入口。 
    private static void CheckDuplicateReferences() // 定义检查入口方法。 
    { // 方法开始。 
        string[] guids = AssetDatabase.FindAssets("t:Object", new[] { "Assets/Game" }); // 扫描业务资源目录下的所有资源。 
        Dictionary<string, HashSet<string>> dependencyToOwners = new Dictionary<string, HashSet<string>>(); // 建立依赖资源到引用者列表的反向索引。 
        foreach (string guid in guids) // 遍历每一个资源 GUID。 
        { // 循环开始。 
            string ownerPath = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转换成资源路径。 
            if (!IsEntryAsset(ownerPath)) // 判断该资源是否不是入口资源。 
            { // 判断开始。 
                continue; // 非入口资源跳过。 
            } // 判断结束。 
            string[] dependencies = AssetDatabase.GetDependencies(ownerPath, true); // 递归获取入口资源依赖的所有资源。 
            foreach (string dependencyPath in dependencies) // 遍历每一个依赖资源。 
            { // 循环开始。 
                if (dependencyPath == ownerPath) // 判断依赖是否就是资源自己。 
                { // 判断开始。 
                    continue; // 自己依赖自己不统计。 
                } // 判断结束。 
                if (!IsRuntimeResource(dependencyPath)) // 判断是否不是运行时资源。 
                { // 判断开始。 
                    continue; // 脚本和编辑器资源不统计。 
                } // 判断结束。 
                if (!dependencyToOwners.TryGetValue(dependencyPath, out HashSet<string> owners)) // 判断反向索引里是否还没有该依赖。 
                { // 判断开始。 
                    owners = new HashSet<string>(); // 创建引用者集合。 
                    dependencyToOwners.Add(dependencyPath, owners); // 把依赖资源加入反向索引。 
                } // 判断结束。 
                owners.Add(ownerPath); // 记录当前入口资源引用了该依赖。 
            } // 循环结束。 
        } // 循环结束。 
        foreach (KeyValuePair<string, HashSet<string>> pair in dependencyToOwners) // 遍历所有依赖资源的引用情况。 
        { // 循环开始。 
            if (pair.Value.Count <= 1) // 判断该依赖是否只被一个入口引用。 
            { // 判断开始。 
                continue; // 只被一个入口引用就不是重复引用。 
            } // 判断结束。 
            HashSet<string> bundleNames = GetOwnerBundleNames(pair.Value); // 获取所有引用者所属的 AssetBundle 名。 
            if (bundleNames.Count <= 1) // 判断引用者是否都在同一个包里。 
            { // 判断开始。 
                continue; // 同包内共享通常不是重复打包风险。 
            } // 判断结束。 
            Debug.LogWarning($"[DuplicateReference] 依赖资源: {pair.Key}, 引用者数量: {pair.Value.Count}, 所属包数量: {bundleNames.Count}"); // 输出重复引用风险。 
            foreach (string owner in pair.Value) // 遍历该依赖的所有引用者。 
            { // 循环开始。 
                Debug.LogWarning($"    Owner={owner}, Bundle={GetBundleName(owner)}"); // 输出引用者路径和所属包名。 
            } // 循环结束。 
        } // 循环结束。 
    } // 方法结束。 

    private static bool IsEntryAsset(string path) // 判断资源是否适合作为入口资源。 
    { // 方法开始。 
        string ext = Path.GetExtension(path).ToLowerInvariant(); // 获取小写扩展名。 
        return ext == ".prefab" || ext == ".unity" || ext == ".asset" || ext == ".mat"; // Prefab、Scene、配置和材质可作为入口。 
    } // 方法结束。 

    private static bool IsRuntimeResource(string path) // 判断依赖是否是运行时资源。 
    { // 方法开始。 
        string ext = Path.GetExtension(path).ToLowerInvariant(); // 获取小写扩展名。 
        if (ext == ".cs" || ext == ".asmdef" || ext == ".shadergraph") return false; // 过滤脚本和工程配置类资源。 
        if (path.Contains("/Editor/")) return false; // 过滤 Editor 目录资源。 
        return true; // 其他资源认为是运行时资源。 
    } // 方法结束。 

    private static HashSet<string> GetOwnerBundleNames(HashSet<string> owners) // 获取引用者所属的包名集合。 
    { // 方法开始。 
        HashSet<string> result = new HashSet<string>(); // 创建包名集合。 
        foreach (string owner in owners) // 遍历每个引用者。 
        { // 循环开始。 
            result.Add(GetBundleName(owner)); // 加入引用者所属的包名。 
        } // 循环结束。 
        return result; // 返回包名集合。 
    } // 方法结束。 

    private static string GetBundleName(string assetPath) // 获取资源所属 AssetBundle 名。 
    { // 方法开始。 
        AssetImporter importer = AssetImporter.GetAtPath(assetPath); // 获取资源导入器。 
        if (importer == null) return "<none>"; // 没有导入器就返回空包名。 
        if (string.IsNullOrEmpty(importer.assetBundleName)) return "<none>"; // 没设置 AssetBundle 名就返回空包名。 
        return importer.assetBundleName; // 返回资源设置的 AssetBundle 名。 
    } // 方法结束。 
} // 类结束。

Unity 工程实践

我会把检查结果分级:小图标重复引用可能只是警告,大贴图、音频、动画、FBX 被多个包重复依赖就要重点处理。修复方式一般是抽 shared bundle,或者调整 Addressables Group,把公共依赖单独标记为 Addressable。

常见坑是:不要看到“被多个资源引用”就直接删,它可能是正确复用;要结合资源大小、所属包、加载场景、热更频率一起判断。

如何检查 AssetBundle 冗余?

unity-assetbundle-redundancy-check

标准答案

检查 AssetBundle 冗余,核心是检查“公共依赖有没有被多个 Bundle 重复打进去”。

最常见的冗余是:A.ab 里有 Hero.prefabB.ab 里有 Monster.prefab,它们都依赖同一张 Shared.png,但 Shared.png 没有单独设置成共享 Bundle。这样打包时,Shared.png 可能会被复制进两个 Bundle,包体就变大了。

检查流程:

  1. 枚举所有 AssetBundle。
  2. 找到每个 Bundle 的入口资源。
  3. AssetDatabase.GetDependencies(path, true) 递归拿依赖。
  4. 建立反向表:依赖资源 -> 被哪些 Bundle 引用
  5. 如果某个资源没显式进 Bundle,却被多个 Bundle 依赖,就是冗余风险。
  6. 大贴图、音频、动画、模型要重点报警。

Unity 官方也提到要避免 AssetBundle 中资源重复;相关 API 可看:AssetDatabase.GetDependenciesGetAllAssetBundleNamesGetAssetPathsFromAssetBundle

代码示例

c
using System.Collections.Generic; // 引入 Dictionary 和 HashSet。 
using System.IO; // 引入 FileInfo 用来读取文件大小。 
using UnityEditor; // 引入 Unity 编辑器资源 API。 
using UnityEngine; // 引入 Debug 输出 API。 

public static class AssetBundleRedundancyChecker // 定义 AssetBundle 冗余检查工具类。 
{ // 类开始。 
    [MenuItem("Tools/Check/Check AssetBundle Redundancy")] // 在 Unity 菜单栏添加检查入口。 
    private static void CheckRedundancy() // 定义检查入口方法。 
    { // 方法开始。 
        Dictionary<string, HashSet<string>> dependencyToBundles = new Dictionary<string, HashSet<string>>(); // 记录依赖资源被哪些 Bundle 引用。 
        string[] bundleNames = AssetDatabase.GetAllAssetBundleNames(); // 获取工程里所有 AssetBundle 名。 
        foreach (string bundleName in bundleNames) // 遍历每一个 Bundle。 
        { // 循环开始。 
            string[] entryAssets = AssetDatabase.GetAssetPathsFromAssetBundle(bundleName); // 获取当前 Bundle 的入口资源。 
            foreach (string entryAsset in entryAssets) // 遍历当前 Bundle 的入口资源。 
            { // 循环开始。 
                string[] dependencies = AssetDatabase.GetDependencies(entryAsset, true); // 递归获取入口资源的所有依赖。 
                foreach (string dependency in dependencies) // 遍历每一个依赖资源。 
                { // 循环开始。 
                    if (dependency == entryAsset) // 判断依赖是否就是入口资源自身。 
                    { // 判断开始。 
                        continue; // 自身不算冗余依赖。 
                    } // 判断结束。 
                    if (!IsRuntimeAsset(dependency)) // 判断是否不是运行时资源。 
                    { // 判断开始。 
                        continue; // 脚本和编辑器资源不参与统计。 
                    } // 判断结束。 
                    AssetImporter importer = AssetImporter.GetAtPath(dependency); // 获取依赖资源的导入器。 
                    if (importer != null && !string.IsNullOrEmpty(importer.assetBundleName)) // 判断依赖是否已经显式进了某个 Bundle。 
                    { // 判断开始。 
                        continue; // 显式进包的资源由依赖关系管理,不按隐式冗余处理。 
                    } // 判断结束。 
                    if (!dependencyToBundles.TryGetValue(dependency, out HashSet<string> bundles)) // 判断反向表中是否还没有该依赖。 
                    { // 判断开始。 
                        bundles = new HashSet<string>(); // 创建引用该依赖的 Bundle 集合。 
                        dependencyToBundles.Add(dependency, bundles); // 把依赖加入反向表。 
                    } // 判断结束。 
                    bundles.Add(bundleName); // 记录当前 Bundle 依赖了这个资源。 
                } // 循环结束。 
            } // 循环结束。 
        } // 循环结束。 
        foreach (KeyValuePair<string, HashSet<string>> pair in dependencyToBundles) // 遍历所有依赖统计结果。 
        { // 循环开始。 
            if (pair.Value.Count <= 1) // 判断依赖是否只被一个 Bundle 使用。 
            { // 判断开始。 
                continue; // 只被一个 Bundle 使用就不是跨包冗余。 
            } // 判断结束。 
            long size = GetAssetFileSize(pair.Key); // 获取资源文件大小。 
            Debug.LogWarning($"[AB冗余风险] 资源={pair.Key}, 大小={size} bytes, 被 {pair.Value.Count} 个 Bundle 隐式依赖"); // 输出冗余资源信息。 
            foreach (string bundle in pair.Value) // 遍历引用该资源的 Bundle。 
            { // 循环开始。 
                Debug.LogWarning($"    Bundle={bundle}"); // 输出具体 Bundle 名。 
            } // 循环结束。 
        } // 循环结束。 
    } // 方法结束。 

    private static bool IsRuntimeAsset(string path) // 判断资源是否属于运行时资源。 
    { // 方法开始。 
        string lower = path.ToLowerInvariant(); // 转成小写路径方便判断。 
        if (lower.EndsWith(".cs")) return false; // 过滤 C# 脚本。 
        if (lower.EndsWith(".asmdef")) return false; // 过滤程序集定义文件。 
        if (lower.Contains("/editor/")) return false; // 过滤 Editor 目录资源。 
        return true; // 其他资源默认认为可能进入运行时。 
    } // 方法结束。 

    private static long GetAssetFileSize(string path) // 获取资源文件大小。 
    { // 方法开始。 
        FileInfo fileInfo = new FileInfo(path); // 创建文件信息对象。 
        if (!fileInfo.Exists) return 0; // 文件不存在时返回 0。 
        return fileInfo.Length; // 返回文件字节大小。 
    } // 方法结束。 
} // 类结束。

Unity 工程实践

修复方式通常有三个:

把公共依赖单独打成 shared.ab

调整分包粒度,让相关资源进入同一个 Bundle。

Addressables 项目里用 Analyze 的重复依赖检查,再把公共依赖移动到公共 Group。

面试里别只说“用工具查一下”,要补一句:我会把检查接到打包前和 CI,报告里输出资源路径、大小、类型、被哪些 Bundle 引用;大贴图、音频、模型重复时直接阻断出包。

AssetBundle 名称如何规划?

unity-assetbundle-name-planning

标准答案

AssetBundle 名称要按“加载边界”规划,而不是按文件夹随便起名。我的命名原则是:平台、质量档、业务模块、子模块、包名都能从名字看出来,并且命名规则能被工具自动校验。

推荐格式:

c
platform/quality/module/feature/bundle_name.ab

例子:

c
android/hd/ui/main/ui_main.ab
ios/ld/scene/forest/scene_forest.ab
android/hd/shared/shader/shared_shader.ab

官方上,Unity 资源的 AssetBundle 名会记录在 AssetImporter.assetBundleName,也可以通过 AssetDatabase.GetAllAssetBundleNamesGetAssetPathsFromAssetBundle 做工具扫描。

参考:assetBundleNameGetAllAssetBundleNames

规划原则

不要把 Hash、随机数、时间戳放进逻辑名里,Hash 应该交给 Manifest 或 Addressables 构建系统处理。AssetBundle 的逻辑名要稳定,否则热更新、缓存、回滚、日志排查都会变麻烦。

按资源类型拆:uirolesceneaudiofxshared

按加载场景拆:首包、常驻、场景、活动、剧情、新手引导。

公共资源单独拆:公共 Shader、公共字体、公共材质、公共 UI 图集,避免隐式依赖重复打包。

代码示例

c
using System.Text.RegularExpressions; // 引入正则表达式。 
using UnityEditor; // 引入 Unity 编辑器资源 API。 
using UnityEngine; // 引入 Debug 输出 API。 

public static class AssetBundleNameRuleChecker // 定义 AssetBundle 命名检查工具。 
{ // 类开始。 
    private static readonly Regex Rule = new Regex(@"^[a-z0-9]+/(hd|md|ld)/(ui|role|scene|audio|fx|shared)/[a-z0-9_]+/[a-z0-9_]+\.ab$"); // 定义命名规则。 

    [MenuItem("Tools/Check/Check AssetBundle Names")] // 添加菜单入口。 
    private static void CheckNames() // 定义检查方法。 
    { // 方法开始。 
        string[] names = AssetDatabase.GetAllAssetBundleNames(); // 获取所有 AssetBundle 名。 
        foreach (string name in names) // 遍历每个 AssetBundle 名。 
        { // 循环开始。 
            if (Rule.IsMatch(name)) // 判断命名是否符合规则。 
            { // 判断开始。 
                continue; // 符合规则就跳过。 
            } // 判断结束。 
            Debug.LogError($"[AB命名错误] {name},推荐格式: android/hd/ui/main/ui_main.ab"); // 输出错误提示。 
        } // 循环结束。 
        Debug.Log("[AB命名检查] 检查完成。"); // 输出检查完成日志。 
    } // 方法结束。 
} // 类结束。

面试加分点

我不会只靠人工约定,而是会把命名校验接到打包前和 CI。规则不通过就阻断出包。这样能避免大小写混乱、中文路径、临时命名、公共包失控、热更新路径不稳定这些问题。

公共依赖包怎么拆?

unity-shared-dependency-bundle-split

标准答案

公共依赖包不是“所有公共资源都塞进一个包”,而是把多个业务包共同依赖、体积较大、变化较少、加载时机接近的资源抽出来,单独做成 shared_xxx.ab

比如:

shared_core.ab:Shader、字体、公共材质。

shared_ui.ab:公共 UI 图集、通用 UI 音效。

shared_role.ab:角色通用材质、通用骨骼动画。

shared_scene.ab:场景通用贴图、通用环境材质。

Unity 官方也强调要避免 AssetBundle 中重复资源;工程里通常用 AssetDatabase.GetDependencies 分析依赖,用 Manifest 或 Addressables 管理依赖关系。

参考:避免 AssetBundle 资源重复GetDependencies

拆包原则

适合抽公共包:被多个 Bundle 引用、体积比较大、变化频率低、多个场景都会用、生命周期比业务包更长。

不适合抽公共包:只被一个模块使用、资源很小、更新非常频繁、只在某个活动短期使用、和业务强绑定。

最怕的错误是拆一个巨大的 shared_all.ab。这样虽然解决了重复打包,但会导致首包变大、加载链变长、热更新牵连范围变大。

代码示例

c
using System.Collections.Generic; // 使用 Dictionary 和 HashSet 存依赖关系。 
using System.IO; // 使用 FileInfo 读取资源文件大小。 
using UnityEditor; // 使用 Unity 编辑器资源 API。 
using UnityEngine; // 使用 Debug 输出检查结果。 

public static class SharedBundleCandidateChecker // 定义公共依赖包候选检查工具。 
{ // 类开始。 
    [MenuItem("Tools/Check/Suggest Shared Bundles")] // 添加 Unity 菜单入口。 
    private static void SuggestSharedBundles() // 定义公共依赖候选分析方法。 
    { // 方法开始。 
        Dictionary<string, HashSet<string>> depToBundles = new Dictionary<string, HashSet<string>>(); // 记录依赖资源被哪些 Bundle 使用。 
        foreach (string bundle in AssetDatabase.GetAllAssetBundleNames()) // 遍历所有 AssetBundle 名。 
        { // 循环开始。 
            foreach (string entry in AssetDatabase.GetAssetPathsFromAssetBundle(bundle)) // 遍历当前 Bundle 的入口资源。 
            { // 循环开始。 
                foreach (string dep in AssetDatabase.GetDependencies(entry, true)) // 遍历入口资源的递归依赖。 
                { // 循环开始。 
                    if (dep == entry) continue; // 跳过入口资源自身。 
                    if (!ShouldCheck(dep)) continue; // 跳过脚本和编辑器资源。 
                    AssetImporter importer = AssetImporter.GetAtPath(dep); // 获取依赖资源的导入器。 
                    if (importer != null && !string.IsNullOrEmpty(importer.assetBundleName)) continue; // 已经显式进包的不作为隐式公共依赖候选。 
                    if (!depToBundles.TryGetValue(dep, out HashSet<string> bundles)) // 如果当前依赖还没有记录。 
                    { // 判断开始。 
                        bundles = new HashSet<string>(); // 创建引用它的 Bundle 集合。 
                        depToBundles.Add(dep, bundles); // 加入反向依赖表。 
                    } // 判断结束。 
                    bundles.Add(bundle); // 记录当前 Bundle 使用了这个依赖。 
                } // 循环结束。 
            } // 循环结束。 
        } // 循环结束。 
        foreach (KeyValuePair<string, HashSet<string>> pair in depToBundles) // 遍历所有依赖统计结果。 
        { // 循环开始。 
            long size = GetFileSize(pair.Key); // 读取依赖资源大小。 
            if (pair.Value.Count < 2 || size < 64 * 1024) continue; // 小资源或只被一个包用就不建议抽 shared。 
            Debug.LogWarning($"[Shared候选] {pair.Key}, size={size}, bundles={pair.Value.Count}"); // 输出公共依赖候选。 
        } // 循环结束。 
    } // 方法结束。 

    private static bool ShouldCheck(string path) // 判断是否需要检查该资源。 
    { // 方法开始。 
        string lower = path.ToLowerInvariant(); // 转成小写路径方便判断。 
        if (lower.EndsWith(".cs")) return false; // 脚本不作为运行时资源检查。 
        if (lower.Contains("/editor/")) return false; // Editor 目录资源不进入运行时包。 
        return true; // 其他资源参与检查。 
    } // 方法结束。 

    private static long GetFileSize(string path) // 获取资源文件大小。 
    { // 方法开始。 
        FileInfo file = new FileInfo(path); // 创建文件信息对象。 
        return file.Exists ? file.Length : 0; // 文件存在就返回大小,否则返回 0。 
    } // 方法结束。 
} // 类结束。

Unity 工程实践

运行时加载顺序是:先加载公共依赖包,再加载业务包;卸载时反过来,业务包先减引用,公共包引用计数归零后再卸载。

热更新时也要小心:shared_ui.ab 一改,所有依赖它的 UI 可能都受影响,所以高频变化资源不要放进稳定公共包。

场景 Bundle 和角色 Bundle 怎么拆?

unity-scene-role-bundle-split

标准答案

场景 Bundle 和角色 Bundle 的拆法不一样:场景主要看“空间和生命周期”,角色主要看“复用和换装”。

场景建议拆成:shared_env.abscene_city_shell.abscene_city_chunk_01.abscene_city_fx.ab。大世界或大场景按 Chunk 分块加载,小副本可以一个场景一个包。

角色建议拆成:role_shared_anim.abrole_hero_001_prefab.abrole_hero_001_model.abrole_hero_001_skin_01.abrole_hero_001_fx.ab。多人共用的骨骼动画、Shader、公共材质要抽 shared,皮肤和特效按需加载。

官方相关依据可以看 Unity 对 AssetBundle 资源重复的说明、AssetDatabase.GetDependencies 和场景异步加载 API:避免 AssetBundle 资源重复GetDependenciesLoadSceneAsync

底层原理

拆包本质是在控制三个东西:下载粒度、加载峰值、依赖复用。

场景包如果太大,切场景时内存峰值高、黑屏久;拆成 Chunk 后,可以玩家走到哪加载到哪。角色包如果太粗,一个角色换个皮肤就要重新下载整套资源;拆成模型、皮肤、动画、特效后,热更新和按需加载更灵活。

但也不能拆太细,否则请求数多、依赖链复杂、加载管理成本上升。所以面试里可以说:我会按“是否一起加载、是否一起卸载、是否一起更新”来拆,而不是机械按文件类型拆。

代码示例

c
using UnityEditor; // 引入 Unity 编辑器资源 API。 
using UnityEngine; // 引入 Object、Selection、Debug 等 Unity 类型。 

public static class BundleSplitNameTool // 定义 Bundle 分组命名建议工具。 
{ // 类开始。 
    [MenuItem("Tools/Bundle/Suggest Selected Bundle Name")] // 在 Unity 菜单栏添加工具入口。 
    private static void SuggestSelectedBundleName() // 定义给选中资源建议 Bundle 名的方法。 
    { // 方法开始。 
        foreach (Object asset in Selection.objects) // 遍历当前 Project 面板选中的资源。 
        { // 循环开始。 
            string path = AssetDatabase.GetAssetPath(asset); // 获取资源路径。 
            string bundleName = SuggestBundleName(path); // 根据路径建议 Bundle 名。 
            Debug.Log($"[BundleSuggest] {path} -> {bundleName}"); // 输出建议结果。 
        } // 循环结束。 
    } // 方法结束。 

    private static string SuggestBundleName(string path) // 根据资源路径生成建议 Bundle 名。 
    { // 方法开始。 
        string p = path.Replace("\\", "/").ToLowerInvariant(); // 统一路径格式并转小写。 
        if (p.Contains("/scenes/") && p.Contains("/chunks/")) return "scene/city/scene_city_chunk.ab"; // 场景分块资源建议进入 Chunk 包。 
        if (p.Contains("/scenes/") && p.EndsWith(".unity")) return "scene/city/scene_city_shell.ab"; // 场景文件建议进入场景壳包。 
        if (p.Contains("/characters/shared/animations/")) return "role/shared/role_shared_anim.ab"; // 通用动画建议进入角色公共动画包。 
        if (p.Contains("/characters/hero_001/model/")) return "role/hero_001/role_hero_001_model.ab"; // 角色模型建议进入模型包。 
        if (p.Contains("/characters/hero_001/skin_01/")) return "role/hero_001/role_hero_001_skin_01.ab"; // 角色皮肤建议进入皮肤包。 
        if (p.Contains("/characters/hero_001/fx/")) return "role/hero_001/role_hero_001_fx.ab"; // 角色特效建议进入表现包。 
        if (p.Contains("/shared/")) return "shared/core/shared_core.ab"; // 公共资源建议进入核心共享包。 
        return "misc/need_manual_rule.ab"; // 没命中规则的资源要求人工补规则。 
    } // 方法结束。 
} // 类结束。

Unity 工程实践

场景加载一般是:先加载 shared_env,再加载 scene_shell,然后按玩家位置异步加载 chunk。角色加载一般是:先加载 role_shared,再加载角色 prefab、model、skin、anim、fx,实例化后用引用计数管理卸载。

Shader Variant 如何收集?

unity-shader-variant-collection

一句话定义: Shader Variant 收集,就是把项目真实会用到的 Shader + Pass + Keyword 组合记录下来,生成 ShaderVariantCollection,用于预热、构建保留和回归验证。

底层原理: Unity 的一个 Shader 可能因为关键字、光照模式、阴影、雾、Instancing、Pass 不同,生成很多编译版本。运行时第一次遇到某个没准备好的变体,可能出现首帧卡顿、特效第一次播放卡一下,甚至裁剪后显示粉色材质。

项目里怎么收集:

  1. 跑真实场景:主线、副本、UI、角色、技能、特效都要覆盖。
  2. 跑画质组合:低中高画质、阴影、雾、后处理、GPU Instancing 都要切。
  3. 跑动态材质:换装、染色、描边、溶解、粒子材质容易漏。
  4. 保存成 ShaderVariantCollection
  5. 构建时加入预加载,或在 Loading 阶段手动 WarmUp()
  6. 配合 IPreprocessShaders 裁剪无用变体,但白名单里的关键变体不能裁掉。

面试重点: 收集不是“全量保留”。全量保留会导致包体变大、构建变慢、预热时间变长。正确做法是“真实路径收集 + 白名单补漏 + 构建裁剪 + 真机验证”。

C# 预热示例:

c
using UnityEngine; // 引入 Unity 引擎命名空间。  

public sealed class ShaderVariantWarmUpper : MonoBehaviour // 定义 Shader 变体预热组件。  
{ // 类开始。  
    [SerializeField] private ShaderVariantCollection collection; // 在 Inspector 中配置收集好的变体集合。  

    private void Awake() // 在对象初始化时执行预热。  
    { // 方法开始。  
        if (collection == null) return; // 没有配置集合就直接返回。  
        if (collection.isWarmedUp) return; // 已经预热过就不重复预热。  
        collection.WarmUp(); // 预热集合中的 Shader Variant。  
    } // 方法结束。  
} // 类结束。

常见坑: 只跑主场景会漏技能和 UI 特效;只收 Editor 不测真机会漏平台差异;动态开关的 keyword 要手动补白名单;WarmUp 能减少首次使用卡顿,但在 Vulkan、Metal、DX12 这类现代图形 API 下,还要注意 PSO 状态不完全等价的问题。

Shader Variant 如何裁剪?

unity-shader-variant-stripping

标准答案

Shader Variant 裁剪,就是在构建 Shader 时,把项目运行时不会用到的关键词组合删掉。目标是减少构建时间、包体、内存占用和首次加载卡顿。

我一般按四层做:

  1. 源头减少:能用 shader_feature / shader_feature_local 就不要滥用 multi_compile
  2. 引擎裁剪:关闭没用的阴影、雾、XR、Lightmap、额外光源等管线特性。
  3. 自定义裁剪:实现 IPreprocessShaders.OnProcessShader,按平台、画质档、Pass、Keyword 删除变体。
  4. 真机验证:裁剪前后记录数量,测试所有画质档、特效、角色、场景,防止粉色或黑屏。

Unity 官方参考:Shader Variant StrippingIPreprocessShaders.OnProcessShader

底层原理

Shader Variant 来自关键词组合。比如一个 Shader 有:

SHADOW_ON / OFF
FOG_ON / OFF
DEBUG_ON / OFF

理论上就是 2 * 2 * 2 = 8 个变体。关键词越多,组合会指数增长。

shader_feature 更适合“材质可能用,也可能不用”的功能,Unity 可以根据实际材质引用裁掉没用的组合。

multi_compile 更像“运行时一定可能切换”的组合,Unity 会更保守,容易产生大量变体。

代码示例

c
using System.Collections.Generic; // 引入 IList,用来遍历和删除 Shader 变体。 
using UnityEditor; // 引入 EditorUserBuildSettings 和 BuildTarget。 
using UnityEditor.Build; // 引入 IPreprocessShaders 接口。 
using UnityEditor.Rendering; // 引入 ShaderCompilerData 和 ShaderSnippetData。 
using UnityEngine; // 引入 Shader 和 Debug。 
using UnityEngine.Rendering; // 引入 ShaderKeyword 和 PassType。 

public sealed class ProjectShaderVariantStripper : IPreprocessShaders // 定义项目自定义 Shader Variant 裁剪器。 
{ // 类开始。 
    public int callbackOrder => 0; // 设置回调顺序,数字越小越早执行。 

    public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> data) // Unity 构建 Shader 时会回调这个方法。 
    { // 方法开始。 
        int beforeCount = data.Count; // 记录裁剪前的变体数量。 
        ShaderKeyword debugKeyword = new ShaderKeyword(shader, "_DEBUG_VIEW"); // 定义调试显示关键字。 
        ShaderKeyword highKeyword = new ShaderKeyword(shader, "_HIGH_QUALITY"); // 定义高画质关键字。 
        for (int i = data.Count - 1; i >= 0; i--) // 倒序遍历,方便安全删除。 
        { // 循环开始。 
            ShaderCompilerData variant = data[i]; // 取出当前 Shader 变体。 
            if (ShouldStrip(snippet, variant, debugKeyword, highKeyword)) // 判断当前变体是否应该裁掉。 
            { // 判断开始。 
                data.RemoveAt(i); // 从编译列表中删除该变体。 
            } // 判断结束。 
        } // 循环结束。 
        if (beforeCount != data.Count) // 判断本次是否真的裁剪了变体。 
        { // 判断开始。 
            Debug.Log($"[ShaderStrip] {shader.name}: {beforeCount} -> {data.Count}"); // 输出裁剪前后数量。 
        } // 判断结束。 
    } // 方法结束。 

    private static bool ShouldStrip(ShaderSnippetData snippet, ShaderCompilerData variant, ShaderKeyword debugKeyword, ShaderKeyword highKeyword) // 判断单个变体是否裁剪。 
    { // 方法开始。 
        if (!EditorUserBuildSettings.development && variant.shaderKeywordSet.IsEnabled(debugKeyword)) return true; // 非开发包裁掉 Debug 变体。 
        if (EditorUserBuildSettings.activeBuildTarget == BuildTarget.Android && variant.shaderKeywordSet.IsEnabled(highKeyword)) return true; // Android 低配策略下裁掉高画质变体。 
        if (!ProjectShaderRules.EnableRealtimeShadow && snippet.passType == PassType.ShadowCaster) return true; // 项目不用实时阴影时裁掉 ShadowCaster Pass。 
        return false; // 其他变体保留。 
    } // 方法结束。 
} // 类结束。 

public static class ProjectShaderRules // 定义项目 Shader 裁剪规则配置。 
{ // 类开始。 
    public const bool EnableRealtimeShadow = false; // 示例:项目是否启用实时阴影。 
} // 类结束。

Unity 工程实践

面试里可以这样补一句:我不会直接盲裁,而是先用材质和 ShaderVariantCollection 收集真实使用组合,再做白名单保护;调试关键字、未使用平台特性、低端机不支持的高画质关键字才裁掉。裁剪后重点看包体、构建时间、Shader 加载耗时,以及真机是否出现粉色材质。

首包资源和热更资源怎么划分?

unity-first-package-hot-update-split

标准答案

首包资源放“玩家第一次安装后必须立刻可用”的内容;热更资源放“可以延迟下载、经常变化、体积较大、非首进必须”的内容。

Unity Addressables 支持远程内容分发来减少初始下载体积,也支持 Content Update Build 只分发变更内容;Group 还能按不同模式打成 Bundle。

参考:Remote ContentContent Update BuildsPack Groups into AssetBundles

首包一般放什么

启动场景、更新界面、登录界面、基础 UI、默认字体、基础 Shader、错误提示、下载器、基础配置表、新手引导前几分钟必须用到的角色、怪物、场景和音效。

一句话:没有网络时,也至少能启动、提示、登录或进入基础流程。

热更一般放什么

活动资源、后续关卡、皮肤、语音包、剧情资源、节日资源、新角色、新怪物、大型场景、可选高清资源、频繁调整的配置表。

一句话:不影响第一次启动,或者可以在玩家进入对应玩法前再下载的,都更适合热更。

代码示例

c
public enum ResourceInstallMode // 定义资源安装模式枚举。 
{ // 枚举开始。 
    BuiltIn, // 随首包安装。 
    RemoteRequired, // 进玩法前必须下载的远程资源。 
    RemoteOptional // 可选下载的远程资源。 
} // 枚举结束。 

public static class ResourceSplitRule // 定义资源划分规则工具类。 
{ // 类开始。 
    public static ResourceInstallMode Decide(string assetPath) // 根据资源路径判断安装模式。 
    { // 方法开始。 
        string path = assetPath.Replace("\\", "/").ToLowerInvariant(); // 统一路径格式并转小写。 
        if (path.Contains("/boot/")) return ResourceInstallMode.BuiltIn; // 启动资源放首包。 
        if (path.Contains("/login/")) return ResourceInstallMode.BuiltIn; // 登录资源放首包。 
        if (path.Contains("/updateui/")) return ResourceInstallMode.BuiltIn; // 更新界面必须放首包。 
        if (path.Contains("/tutorial/")) return ResourceInstallMode.BuiltIn; // 新手闭环资源优先放首包。 
        if (path.Contains("/shared/core/")) return ResourceInstallMode.BuiltIn; // 基础公共依赖放首包。 
        if (path.Contains("/event/")) return ResourceInstallMode.RemoteRequired; // 活动资源走热更。 
        if (path.Contains("/chapter/")) return ResourceInstallMode.RemoteRequired; // 后续章节走热更。 
        if (path.Contains("/skin/")) return ResourceInstallMode.RemoteOptional; // 皮肤可选下载。 
        if (path.Contains("/voice/")) return ResourceInstallMode.RemoteOptional; // 语音包可选下载。 
        return ResourceInstallMode.RemoteRequired; // 默认放远程必需资源。 
    } // 方法结束。 
} // 类结束。

工程取舍

首包太大:下载转化率差,商店包体压力大。

首包太小:玩家刚进游戏就要下载,弱网体验差。

热更太碎:请求多、依赖复杂、失败率高。

热更太粗:一次活动更新就下载很多无关资源。

所以我会用 Manifest 管理资源版本、Hash、大小、依赖、最低客户端版本;启动时只强更必要资源,非必要内容按玩法入口预下载或懒加载。热更失败要有重试、断点续传、降级提示和回滚方案。

资源版本号怎么设计?

unity-resource-version-design

标准答案

资源版本号建议设计成“四层”:

客户端版本 appVersion:控制代码和资源是否兼容,比如资源格式变了,低版本客户端必须强更。

资源版本 resVersion:表示一次资源发布批次,比如 1.3.12.240801,方便日志、灰度、回滚。

Manifest 版本 manifestVersion:描述这次资源清单,里面记录所有 Bundle 的名字、大小、Hash、CRC、依赖。

Bundle Hash:真正做增量更新时,不是只比资源大版本,而是逐个 Bundle 比 hash / size / crc

Unity Addressables 官方也支持 Content Update 和 Remote Content 分发,用来做远程资源更新和增量发布。

参考:Content Update BuildsRemote ContentCaching

设计示例

c
using System; // 引入 Serializable 特性。 
using System.Collections.Generic; // 引入 List 和 Dictionary。 

[Serializable] // 允许这个类被 JSON 序列化和反序列化。 
public class ResourceManifest // 定义资源清单。 
{ // 类开始。 
    public string appVersion; // 当前资源对应的客户端版本。 
    public string minAppVersion; // 能使用这批资源的最低客户端版本。 
    public string resVersion; // 资源发布版本号。 
    public string manifestVersion; // 清单版本号。 
    public List<BundleInfo> bundles; // 当前版本包含的所有 Bundle 信息。 
} // 类结束。 

[Serializable] // 允许 Bundle 信息被序列化。 
public class BundleInfo // 定义单个 Bundle 的版本信息。 
{ // 类开始。 
    public string name; // Bundle 名称。 
    public string url; // Bundle 下载地址。 
    public long size; // Bundle 文件大小。 
    public string hash; // Bundle 内容 Hash。 
    public uint crc; // Bundle 校验 CRC。 
    public string[] deps; // 当前 Bundle 依赖的其他 Bundle。 
} // 类结束。 

public static class ResourceVersionComparer // 定义资源版本对比工具。 
{ // 类开始。 
    public static List<BundleInfo> GetNeedDownload(ResourceManifest local, ResourceManifest remote) // 计算需要下载的 Bundle。 
    { // 方法开始。 
        Dictionary<string, BundleInfo> localMap = BuildMap(local); // 把本地清单转成字典。 
        List<BundleInfo> result = new List<BundleInfo>(); // 创建下载列表。 
        foreach (BundleInfo remoteBundle in remote.bundles) // 遍历远程清单里的每个 Bundle。 
        { // 循环开始。 
            if (!localMap.TryGetValue(remoteBundle.name, out BundleInfo localBundle)) // 判断本地是否没有这个 Bundle。 
            { // 判断开始。 
                result.Add(remoteBundle); // 新增 Bundle 需要下载。 
                continue; // 继续检查下一个 Bundle。 
            } // 判断结束。 
            if (localBundle.hash != remoteBundle.hash || localBundle.size != remoteBundle.size || localBundle.crc != remoteBundle.crc) // 判断内容是否变化。 
            { // 判断开始。 
                result.Add(remoteBundle); // 内容变化的 Bundle 需要重新下载。 
            } // 判断结束。 
        } // 循环结束。 
        return result; // 返回最终需要下载的资源列表。 
    } // 方法结束。 

    private static Dictionary<string, BundleInfo> BuildMap(ResourceManifest manifest) // 把清单转成字典。 
    { // 方法开始。 
        Dictionary<string, BundleInfo> map = new Dictionary<string, BundleInfo>(); // 创建 Bundle 字典。 
        foreach (BundleInfo bundle in manifest.bundles) // 遍历清单里的 Bundle。 
        { // 循环开始。 
            map[bundle.name] = bundle; // 用 Bundle 名作为 key 保存信息。 
        } // 循环结束。 
        return map; // 返回字典。 
    } // 方法结束。 
} // 类结束。

工程实践

我会让版本服务返回:最新资源版本最低客户端版本Manifest URL灰度策略回滚版本。客户端先判断 minAppVersion,不兼容就强更;兼容才下载 Manifest,再按 Bundle Hash 做差异下载。

面试重点一句话:资源版本号负责“发布批次”,Manifest 负责“资源清单”,Hash 负责“增量判断”,最低客户端版本负责“兼容边界”。

文件 hash 和版本号怎么配合?

unity-file-hash-version-cooperate

标准答案

文件 Hash 和版本号要分工合作:

版本号负责“这一批资源是哪次发布”,用于灰度、回滚、兼容判断、日志定位。

文件 Hash 负责“这个文件内容有没有变”,用于增量下载、缓存命中、避免重复下载。

一句话:版本号决定拿哪张 Manifest,Hash 决定哪些文件真的要下载。

Unity 里也有类似思路:Hash128 可以表示数据内容指纹;Addressables 的 Content Update 会围绕内容变化生成更新;AssetBundle 下载 API 也支持 hash / crc 参与缓存和校验。

参考:Hash128Addressables Content UpdateUnityWebRequestAssetBundle.GetAssetBundle

底层逻辑

比如本地是:

c
resVersion = 1.4.7

远程是:

c
resVersion = 1.4.8

这只能说明“有一批新资源发布了”,不代表所有文件都要下载。

客户端还要继续对比 Manifest 里的每个文件:

c
bundleName
hash
size
crc

如果 hash 一样,说明内容没变,可以直接复用缓存。

如果 hash 不同,说明内容变了,需要下载新文件。

如果文件 Hash 变了,但资源版本号没变,这是很危险的:客户端可能以为自己已经是最新版本,不会重新拉 Manifest,导致资源更新失败。所以规则应该是:Manifest 有变化,资源版本必须升级;文件内容有变化,对应 Hash 必须变化。

代码示例

c
using System; // 引入 Serializable 特性。 
using System.Collections.Generic; // 引入 List 和 Dictionary。 

[Serializable] // 允许 Manifest 被 JSON 序列化。 
public class ResourceManifest // 定义资源清单。 
{ // 类开始。 
    public string resVersion; // 资源发布版本号。 
    public string manifestVersion; // Manifest 清单版本号。 
    public string minAppVersion; // 最低兼容客户端版本。 
    public List<ResourceFileInfo> files = new List<ResourceFileInfo>(); // 当前版本包含的文件列表。 
} // 类结束。 

[Serializable] // 允许单个文件信息被 JSON 序列化。 
public class ResourceFileInfo // 定义资源文件信息。 
{ // 类开始。 
    public string name; // 文件名或 Bundle 名。 
    public string url; // 文件下载地址。 
    public string hash; // 文件内容 Hash。 
    public long size; // 文件大小。 
    public uint crc; // 文件 CRC 校验值。 
} // 类结束。 

public static class ResourceDiff // 定义资源差异对比工具。 
{ // 类开始。 
    public static List<ResourceFileInfo> GetNeedDownload(ResourceManifest local, ResourceManifest remote) // 计算需要下载的文件。 
    { // 方法开始。 
        Dictionary<string, ResourceFileInfo> localMap = BuildMap(local); // 把本地文件清单转成字典。 
        List<ResourceFileInfo> result = new List<ResourceFileInfo>(); // 创建需要下载的文件列表。 
        foreach (ResourceFileInfo remoteFile in remote.files) // 遍历远程清单里的每个文件。 
        { // 循环开始。 
            if (!localMap.TryGetValue(remoteFile.name, out ResourceFileInfo localFile)) // 本地没有这个文件。 
            { // 判断开始。 
                result.Add(remoteFile); // 新文件需要下载。 
                continue; // 继续检查下一个文件。 
            } // 判断结束。 
            if (localFile.hash != remoteFile.hash) // Hash 不同代表内容变化。 
            { // 判断开始。 
                result.Add(remoteFile); // 内容变化的文件需要下载。 
                continue; // 继续检查下一个文件。 
            } // 判断结束。 
            if (localFile.size != remoteFile.size) // 大小不同也认为文件不一致。 
            { // 判断开始。 
                result.Add(remoteFile); // 大小异常的文件需要重新下载。 
            } // 判断结束。 
        } // 循环结束。 
        return result; // 返回差异文件列表。 
    } // 方法结束。 

    private static Dictionary<string, ResourceFileInfo> BuildMap(ResourceManifest manifest) // 把清单转成字典。 
    { // 方法开始。 
        Dictionary<string, ResourceFileInfo> map = new Dictionary<string, ResourceFileInfo>(); // 创建字典。 
        foreach (ResourceFileInfo file in manifest.files) // 遍历清单文件。 
        { // 循环开始。 
            map[file.name] = file; // 用文件名作为 key 保存文件信息。 
        } // 循环结束。 
        return map; // 返回字典。 
    } // 方法结束。 
} // 类结束。

工程实践

我会这样设计更新流程:

先请求版本服务,拿到可用的 resVersionManifest URL

下载远程 Manifest。

逐个文件对比本地 Hash 和远程 Hash。

只下载缺失或 Hash 不同的文件。

下载完成后校验 size / crc / hash

全部成功后再切换本地 Manifest。

保留上一版 Manifest,用于失败回滚。

Manifest 文件记录什么?

unity-manifest-file-records

标准答案

Manifest 文件就是资源系统的“总索引表”。它通常记录:资源版本、最低客户端版本、平台、渠道、Bundle 列表、下载地址、文件大小、Hash、CRC、依赖关系、是否必需、分组信息、灰度策略和回滚版本。

Unity 自带的 AssetBundleManifest 也能查询 Bundle 列表、依赖和 Hash;Addressables 的 Content Update / Catalog 也是类似思路。

参考:AssetBundleManifestGetAllAssetBundlesGetDirectDependenciesGetAssetBundleHashAddressables Content Update

Manifest 常见字段

全局字段:appVersionminAppVersionresVersionmanifestVersionplatformchannelbuildTime

文件字段:nameurlsizehashcrccompression

依赖字段:deps,比如 battle_ui.ab 依赖 shared_ui.ab

策略字段:requiredpreloadgrouppriorityrollbackVersiongrayPercent

代码示例

c
using System; // 引入 Serializable 特性。 
using System.Collections.Generic; // 引入 List 类型。 

[Serializable] // 允许 Manifest 被 JSON 序列化。 
public class ResourceManifest // 定义资源 Manifest。 
{ // 类开始。 
    public string appVersion; // 当前资源对应的客户端版本。 
    public string minAppVersion; // 最低兼容客户端版本。 
    public string resVersion; // 资源版本号。 
    public string manifestVersion; // Manifest 清单版本号。 
    public string platform; // 平台,比如 Android 或 iOS。 
    public string channel; // 渠道,比如 official 或 tap。 
    public string rollbackVersion; // 出问题时可回滚的资源版本。 
    public List<BundleRecord> bundles = new List<BundleRecord>(); // Bundle 文件列表。 
} // 类结束。 

[Serializable] // 允许 Bundle 记录被 JSON 序列化。 
public class BundleRecord // 定义单个 Bundle 的记录。 
{ // 类开始。 
    public string name; // Bundle 名称。 
    public string url; // Bundle 下载地址。 
    public long size; // Bundle 文件大小。 
    public string hash; // Bundle 内容 Hash。 
    public uint crc; // Bundle CRC 校验值。 
    public string[] deps; // 依赖的其他 Bundle。 
    public bool required; // 是否是必需资源。 
    public bool preload; // 是否需要预加载。 
    public string group; // 所属分组,比如 ui、scene、role。 
    public int priority; // 下载或加载优先级。 
} // 类结束。

工程实践

客户端启动后,一般先请求版本服务,拿到远程 Manifest 地址;再下载远程 Manifest,和本地 Manifest 对比。对比时不是只看版本号,而是逐个 Bundle 比 hash / size / crc。下载完成后先校验,再替换本地 Manifest;如果失败,就继续使用旧 Manifest 或回滚版本。

面试里一句话总结:Manifest 记录资源在哪里、是什么版本、有没有变化、依赖谁、怎么校验、失败怎么回滚。

CDN 下载失败如何重试?

unity-cdn-download-retry

标准答案

CDN 下载失败不能简单无限重试,应该做成一套“可控重试策略”:

先判断失败类型:超时、断网、4295xx 可以重试;404403、版本不兼容、Manifest 签名错误通常不该重试。

然后按次数重试:比如每个 CDN 重试 3 次,等待时间用指数退避:1s -> 2s -> 4s

如果主 CDN 多次失败,切备用 CDN。

下载时先写 .tmp 临时文件,校验 hash / size / crc 成功后,再替换正式文件,避免半包覆盖旧资源。

Unity 相关 API 可看:UnityWebRequestUnityWebRequest.resulttimeoutDownloadHandlerFile

代码示例

c
using System; // 引入 Action 回调类型。
using System.Collections; // 引入 IEnumerator 协程类型。
using System.Collections.Generic; // 引入 List 类型。
using System.IO; // 引入文件和目录操作 API。
using UnityEngine; // 引入 WaitForSecondsRealtime。
using UnityEngine.Networking; // 引入 UnityWebRequest。

public static class CdnRetryDownloader // 定义 CDN 重试下载工具类。
{ // 类开始。
    public static IEnumerator DownloadWithRetry(List<string> urls, string finalPath, long expectedSize, Action<bool> onFinish) // 定义带重试的下载协程。
    { // 方法开始。
        int maxRetryPerCdn = 3; // 每个 CDN 最多重试 3 次。
        int timeoutSeconds = 15; // 每次请求超时时间设置为 15 秒。
        string tempPath = finalPath + ".tmp"; // 下载先写入临时文件。
        string directory = Path.GetDirectoryName(finalPath); // 获取目标文件所在目录。
        if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); // 目录不存在时先创建目录。
        for (int cdnIndex = 0; cdnIndex < urls.Count; cdnIndex++) // 遍历主 CDN 和备用 CDN。
        { // CDN 循环开始。
            string url = urls[cdnIndex]; // 取出当前 CDN 下载地址。
            for (int attempt = 1; attempt <= maxRetryPerCdn; attempt++) // 对当前 CDN 做有限次数重试。
            { // 重试循环开始。
                DeleteTempFile(tempPath); // 每次下载前清理上次失败的临时文件。
                using (UnityWebRequest request = new UnityWebRequest(url, "GET")) // 创建 GET 请求。
                { // using 开始。
                    DownloadHandlerFile handler = new DownloadHandlerFile(tempPath); // 创建文件下载处理器,避免大文件进入内存。
                    handler.removeFileOnAbort = true; // 请求中断时自动移除临时文件。
                    request.downloadHandler = handler; // 把文件下载处理器挂到请求上。
                    request.timeout = timeoutSeconds; // 设置请求超时时间。
                    yield return request.SendWebRequest(); // 发起请求并等待完成。
                    bool fileOk = request.result == UnityWebRequest.Result.Success && VerifyFile(tempPath, expectedSize); // 判断请求和文件校验是否成功。
                    if (fileOk) // 如果下载成功并且校验通过。
                    { // 判断开始。
                        ReplaceFile(tempPath, finalPath); // 用临时文件替换正式文件。
                        onFinish?.Invoke(true); // 通知外部下载成功。
                        yield break; // 结束协程。
                    } // 判断结束。
                    if (!ShouldRetry(request.result, request.responseCode)) // 判断当前错误是否不适合重试。
                    { // 判断开始。
                        break; // 直接切换 CDN 或结束下载。
                    } // 判断结束。
                } // using 结束。
                float waitSeconds = GetBackoffSeconds(attempt); // 根据重试次数计算退避等待时间。
                yield return new WaitForSecondsRealtime(waitSeconds); // 使用真实时间等待,不受暂停影响。
            } // 重试循环结束。
        } // CDN 循环结束。
        DeleteTempFile(tempPath); // 所有 CDN 都失败后删除临时文件。
        onFinish?.Invoke(false); // 通知外部下载失败。
    } // 方法结束。

    private static bool ShouldRetry(UnityWebRequest.Result result, long responseCode) // 判断错误是否应该重试。
    { // 方法开始。
        if (result == UnityWebRequest.Result.ConnectionError) return true; // 网络连接错误可以重试。
        if (responseCode == 408 || responseCode == 429) return true; // 超时或限流可以重试。
        if (responseCode >= 500 && responseCode <= 599) return true; // 服务端错误可以重试。
        return false; // 其他错误默认不重试。
    } // 方法结束。

    private static float GetBackoffSeconds(int attempt) // 计算指数退避等待时间。
    { // 方法开始。
        return Mathf.Min(8f, Mathf.Pow(2f, attempt - 1)); // 返回 1、2、4、8 秒这样的等待时间。
    } // 方法结束。

    private static bool VerifyFile(string path, long expectedSize) // 校验下载文件是否可信。
    { // 方法开始。
        if (!File.Exists(path)) return false; // 文件不存在说明下载失败。
        if (expectedSize > 0 && new FileInfo(path).Length != expectedSize) return false; // 文件大小不一致说明文件不完整。
        return true; // 示例里只校验大小,项目里还要校验 hash 或 crc。
    } // 方法结束。

    private static void ReplaceFile(string tempPath, string finalPath) // 用临时文件替换正式文件。
    { // 方法开始。
        if (File.Exists(finalPath)) File.Delete(finalPath); // 如果旧文件存在,先删除旧文件。
        File.Move(tempPath, finalPath); // 把临时文件移动成正式文件。
    } // 方法结束。

    private static void DeleteTempFile(string tempPath) // 删除临时文件。
    { // 方法开始。
        if (File.Exists(tempPath)) File.Delete(tempPath); // 如果临时文件存在就删除。
    } // 方法结束。
} // 类结束。

工程实践

项目里我会把失败信息上报:urlcdn域名responseCode重试次数资源名网络类型地区。这样线上能判断是某个 CDN 节点坏了、某个资源 404、还是弱网失败率高。

如果资源包很大,还可以做断点续传和分片下载,但前提是 CDN 支持 HTTP Range,并且每片也要校验。

下载中断如何续传?

unity-download-resume-range

标准答案

下载中断续传的核心是:本地保留 .tmp 临时文件,下一次下载时读取临时文件大小,然后用 HTTP Range 请求从断点继续下载。

比如本地已经下载了 40MB

c
Range: bytes=41943040-

如果服务器返回 206 Partial Content,说明支持断点续传,可以把新数据追加到 .tmp 后面。

如果服务器返回 200 OK,说明服务器忽略了 Range,是从头返回完整文件,这时不能追加,必须删除 .tmp 重新完整下载。

如果返回 416 Range Not Satisfiable,说明本地断点不合法,也应该删掉临时文件重下。

Unity 里可以用 UnityWebRequest.SetRequestHeader 设置 Range,用 responseCode 判断响应码。

官方参考:SetRequestHeaderresponseCodeUnityWebRequest.resultDownloadHandlerFile

底层原理

断点续传依赖 HTTP Range 机制:

客户端:我已经有前 N 个字节了,请从第 N 个字节继续给我。

服务器:如果支持,就返回 206 Partial Content 和剩余内容。

客户端:把剩余内容追加到 .tmp 文件。

最后:校验完整文件的 size / hash / crc,通过后再把 .tmp 改名成正式文件。

代码示例

c
using System.Collections; // 引入协程 IEnumerator。
using System.IO; // 引入文件读写 API。
using UnityEngine; // 引入 MonoBehaviour 和 Debug。
using UnityEngine.Networking; // 引入 UnityWebRequest。

public sealed class ResumeDownloadDemo : MonoBehaviour // 定义断点续传示例组件。
{ // 类开始。
    public IEnumerator Download(string url, string finalPath, long expectedSize) // 定义下载协程。
    { // 方法开始。
        string tempPath = finalPath + ".tmp"; // 正式文件旁边保存临时文件。
        long offset = File.Exists(tempPath) ? new FileInfo(tempPath).Length : 0L; // 读取已经下载的字节数。
        bool restartFromZero = false; // 记录是否需要删除临时文件重下。
        bool downloadOk = false; // 记录本次下载是否成功。
        using (UnityWebRequest request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbGET)) // 创建 GET 请求。
        { // using 开始。
            if (offset > 0) request.SetRequestHeader("Range", $"bytes={offset}-"); // 有临时文件时请求剩余字节。
            request.downloadHandler = new AppendFileDownloadHandler(tempPath, offset > 0); // 把响应数据写入临时文件。
            yield return request.SendWebRequest(); // 发送请求并等待完成。
            if (request.result != UnityWebRequest.Result.Success) // 判断网络层是否失败。
            { // 判断开始。
                Debug.LogError($"下载失败: {request.error}"); // 输出失败原因。
                yield break; // 下载失败直接退出。
            } // 判断结束。
            if (offset > 0 && request.responseCode == 200) restartFromZero = true; // 服务器忽略 Range 时不能追加。
            else if (offset > 0 && request.responseCode != 206) restartFromZero = true; // 断点请求没返回 206 时重下。
            else downloadOk = VerifySize(tempPath, expectedSize); // 校验最终文件大小是否正确。
        } // using 结束并释放文件句柄。
        if (restartFromZero) // 判断是否需要从头下载。
        { // 判断开始。
            if (File.Exists(tempPath)) File.Delete(tempPath); // 删除错误的临时文件。
            yield return Download(url, finalPath, expectedSize); // 重新完整下载。
            yield break; // 结束当前协程。
        } // 判断结束。
        if (!downloadOk) // 判断校验是否失败。
        { // 判断开始。
            if (File.Exists(tempPath)) File.Delete(tempPath); // 删除校验失败的临时文件。
            yield break; // 退出下载流程。
        } // 判断结束。
        if (File.Exists(finalPath)) File.Delete(finalPath); // 删除旧正式文件。
        File.Move(tempPath, finalPath); // 原子切换成新正式文件。
        Debug.Log("下载完成并校验通过"); // 输出成功日志。
    } // 方法结束。

    private static bool VerifySize(string path, long expectedSize) // 定义文件大小校验方法。
    { // 方法开始。
        if (!File.Exists(path)) return false; // 文件不存在说明失败。
        if (expectedSize <= 0) return true; // 没有期望大小时跳过大小校验。
        return new FileInfo(path).Length == expectedSize; // 比较真实大小和期望大小。
    } // 方法结束。
} // 类结束。

public sealed class AppendFileDownloadHandler : DownloadHandlerScript // 定义可追加写文件的下载处理器。
{ // 类开始。
    private readonly FileStream stream; // 保存文件流。
    public AppendFileDownloadHandler(string path, bool append) // 定义构造函数。
    { // 构造开始。
        stream = new FileStream(path, append ? FileMode.Append : FileMode.Create, FileAccess.Write); // 追加或重建临时文件。
    } // 构造结束。
    protected override bool ReceiveData(byte[] data, int dataLength) // 接收网络数据回调。
    { // 方法开始。
        if (data == null || dataLength <= 0) return false; // 数据无效时中断下载。
        stream.Write(data, 0, dataLength); // 把收到的数据写入文件。
        return true; // 返回 true 表示继续接收。
    } // 方法结束。
    protected override void CompleteContent() // 下载内容接收完成回调。
    { // 方法开始。
        stream.Flush(); // 把缓冲区写入磁盘。
    } // 方法结束。
    protected override void Dispose(bool disposing) // 释放下载处理器。
    { // 方法开始。
        stream.Dispose(); // 关闭文件流。
        base.Dispose(disposing); // 调用父类释放逻辑。
    } // 方法结束。
} // 类结束。

工程实践

正式项目里不能只校验大小,还要校验 hash / crc。流程是:下载到 .tmp,成功后校验,校验通过再替换正式文件;校验失败删除 .tmp,下次重新下载。

如果资源很大,可以进一步做“分片续传”:每片单独记录范围和 Hash,失败只重下坏片。但一般 AssetBundle 热更里,单文件 Range 续传已经能解决大部分弱网中断问题。

热更新如何校验完整性?

unity-hot-update-integrity-check

标准答案

热更新完整性校验不能只看“下载成功”,要做多层校验:

  1. Manifest 校验:版本、平台、最低客户端版本、签名是否正确。
  2. 文件校验:下载后的 size / hash / crc 是否和 Manifest 一致。
  3. 依赖校验:业务包依赖的 shared 包是否都存在、版本是否匹配。
  4. 加载校验:AssetBundle 是否能正常加载。
  5. 提交校验:全部成功后再切换本地 Manifest,失败就保留旧版本或回滚。

Unity 里 Hash128 可表示内容 Hash;UnityWebRequestAssetBundle.GetAssetBundle 有 hash / crc 相关重载;AssetBundleManifest 能记录 Bundle Hash 和依赖;Addressables Content Update 也是围绕内容变化做更新。

参考:Hash128UnityWebRequestAssetBundle.GetAssetBundleAssetBundleManifestAddressables Content Update

底层原理

下载成功只代表网络层拿到了数据,不代表文件没坏、不代表文件没被篡改、不代表依赖齐全。

所以 Manifest 里通常要记录:

c
bundleName
url
size
hash
crc
deps
required
minAppVersion

客户端下载到 .tmp 文件后,先校验大小,再算 Hash,再校验 CRC。全部通过后,才能把 .tmp 改成正式文件,并更新本地 Manifest。

代码示例

c
using System.IO; // 引入文件读写 API。
using System.Security.Cryptography; // 引入 SHA256 哈希计算 API。
using System.Text; // 引入 StringBuilder 拼接十六进制字符串。

public static class HotUpdateFileVerifier // 定义热更新文件校验工具类。
{ // 类开始。
    public static bool VerifyFile(string filePath, long expectedSize, string expectedSha256) // 定义文件完整性校验方法。
    { // 方法开始。
        if (!File.Exists(filePath)) return false; // 文件不存在,校验失败。
        FileInfo fileInfo = new FileInfo(filePath); // 创建文件信息对象。
        if (fileInfo.Length != expectedSize) return false; // 文件大小不一致,说明下载不完整。
        string actualSha256 = CalcSha256(filePath); // 计算本地文件的 SHA256。
        if (actualSha256 != expectedSha256) return false; // Hash 不一致,说明文件内容不可信。
        return true; // 大小和 Hash 都正确,校验通过。
    } // 方法结束。

    private static string CalcSha256(string filePath) // 定义 SHA256 计算方法。
    { // 方法开始。
        using FileStream stream = File.OpenRead(filePath); // 打开文件读取流。
        using SHA256 sha256 = SHA256.Create(); // 创建 SHA256 计算器。
        byte[] hashBytes = sha256.ComputeHash(stream); // 计算文件 Hash 字节数组。
        StringBuilder builder = new StringBuilder(hashBytes.Length * 2); // 创建十六进制字符串构造器。
        foreach (byte b in hashBytes) // 遍历每一个 Hash 字节。
        { // 循环开始。
            builder.Append(b.ToString("x2")); // 把字节转成两位小写十六进制。
        } // 循环结束。
        return builder.ToString(); // 返回最终 Hash 字符串。
    } // 方法结束。
} // 类结束。

工程实践

我会让热更新流程变成“事务式”的:下载所有差异包到临时目录,逐个校验,依赖检查全部通过,再更新本地 Manifest。只要中间有一个包失败,就删除临时文件,不切版本。

面试里可以补一句:如果只校验文件大小,可能挡不住内容损坏;如果只校验 Hash,但没有 Manifest 签名,Manifest 本身被篡改也危险。

热更新如何处理磁盘空间不足?

unity-hot-update-disk-space

标准答案

热更新遇到磁盘空间不足,要在下载前就预判,而不是等写文件失败。核心流程是:

先根据 Manifest 计算本次需要下载的总大小,再加上临时文件、解压峰值和安全冗余;如果空间不足,先清理 .tmp、失败残留、旧版本补丁、可重新下载的缓存;仍然不足,就暂停下载并提示用户释放空间,不能切换 Manifest。

Unity 热更资源一般放在 Application.persistentDataPath;大文件下载可以用 DownloadHandlerFile 直接写文件,避免把整个包读进内存;缓存可结合 Caching.ClearCache 做清理。

参考:persistentDataPathDownloadHandlerFileCaching.ClearCacheDriveInfo.AvailableFreeSpace

底层原理

磁盘不足最危险的不是“下不下来”,而是出现半包、坏包、Manifest 已切换但资源不完整。正确做法是事务式更新:新资源先下载到 staging 或 .tmp 目录,全部校验通过后,再替换正式资源和本地 Manifest。失败时继续使用旧版本。

代码示例

c
using System.IO; // 引入文件和磁盘信息 API。
using UnityEngine; // 引入 Unity 路径和日志 API。

public static class HotUpdateDiskGuard // 定义热更新磁盘空间保护工具。
{ // 类开始。
    public static bool PrepareDiskSpace(long downloadBytes, long extraBytes) // 下载前准备磁盘空间。
    { // 方法开始。
        string root = Application.persistentDataPath; // 获取热更新资源常用持久化目录。
        long needBytes = downloadBytes + extraBytes; // 计算下载大小加解压和安全冗余。
        if (HasEnoughSpace(root, needBytes)) return true; // 空间足够就直接允许下载。
        CleanTempFiles(root); // 空间不足时先清理临时文件。
        CleanOldPatchVersions(root); // 再清理旧补丁版本。
        if (HasEnoughSpace(root, needBytes)) return true; // 清理后再次检查空间。
        Debug.LogError($"磁盘空间不足,需要至少 {needBytes} bytes"); // 输出空间不足日志。
        return false; // 仍然不足就拒绝本次热更新。
    } // 方法结束。

    private static bool HasEnoughSpace(string path, long needBytes) // 检查磁盘剩余空间是否足够。
    { // 方法开始。
        string root = Path.GetPathRoot(path); // 获取路径所在磁盘根目录。
        DriveInfo drive = new DriveInfo(root); // 创建磁盘信息对象。
        return drive.AvailableFreeSpace > needBytes; // 比较可用空间和需要空间。
    } // 方法结束。

    private static void CleanTempFiles(string root) // 清理临时文件。
    { // 方法开始。
        foreach (string file in Directory.GetFiles(root, "*.tmp", SearchOption.AllDirectories)) // 查找所有临时文件。
        { // 循环开始。
            File.Delete(file); // 删除失败或中断留下的临时文件。
        } // 循环结束。
    } // 方法结束。

    private static void CleanOldPatchVersions(string root) // 清理旧补丁版本。
    { // 方法开始。
        string oldPatchDir = Path.Combine(root, "OldPatches"); // 约定旧补丁目录。
        if (!Directory.Exists(oldPatchDir)) return; // 目录不存在就不用清理。
        Directory.Delete(oldPatchDir, true); // 删除旧补丁目录。
    } // 方法结束。
} // 类结束。

工程实践

我会额外做三点:第一,提示用户“还需要释放多少 MB”;第二,当前正在使用的资源版本绝对不能删;第三,只有所有文件下载和校验成功后,才替换本地 Manifest。这样即使空间不足,玩家下次启动也还能用旧版本进入游戏。

热更新过程中 App 退出怎么办?

unity-hot-update-app-exit

标准答案

热更新过程中 App 退出,不能把它当异常处理,而要把热更新设计成“可恢复事务”。

核心做法是:资源先下载到临时目录 staging/.tmp,旧版本资源和正式 Manifest 不动;每下载完一个文件或一个分片,就把进度、目标版本、文件 Hash、当前阶段写到本地。只有所有资源下载完成并校验通过后,才切换正式 Manifest。 如果 App 退出或被系统杀掉,下次启动先读本地热更状态:能校验通过就断点续传,不能恢复就清理临时目录并继续使用旧版本。

底层原理

热更新最怕的是“半更新状态”:资源只下载了一半,但版本号或 Manifest 已经切到新版本,结果启动时找不到资源、Hash 不匹配、Bundle 依赖断裂。

所以真正安全的流程一般是:

c
Download -> Verify -> Commit

Commit 之前,新版本只是临时数据;Commit 之后,才算真正生效。 移动端还要注意:不要依赖 OnApplicationQuit,Unity 官方也说明移动端可能无法调用它;更稳的是在 OnApplicationPause / OnApplicationFocus 时保存状态,并且平时每一步都落盘。Unity 的 persistentDataPath 适合保存跨启动保留的数据,DownloadHandlerFile 可以直接把下载内容写入文件,避免大文件进内存。

参考:Unity OnApplicationQuitpersistentDataPathDownloadHandlerFile 文档。(docs.unity3d.com) (docs.unity3d.com) (docs.unity3d.com)

代码示例

c
using System; // 引入 Serializable 特性需要的命名空间。
using System.IO; // 引入 File 和 Path 文件操作 API。
using UnityEngine; // 引入 Application.persistentDataPath 和 JsonUtility。
[Serializable] // 标记该类可以被 Unity JsonUtility 序列化。
public class HotUpdateState // 定义热更新中断后可恢复的状态数据。
{ // 状态类开始。
    public string targetVersion; // 记录本次热更新的目标版本。
    public string phase; // 记录当前阶段,例如 Downloading、Verified、Commit。
    public string currentFile; // 记录当前正在处理的资源文件。
    public long downloadedBytes; // 记录当前文件已下载的字节数。
} // 状态类结束。
public static class HotUpdateStateStore // 定义热更新状态的保存和读取工具类。
{ // 工具类开始。
    private static string StatePath => Path.Combine(Application.persistentDataPath, "update_state.json"); // 拼出状态文件路径。
    public static void Save(HotUpdateState state) // 保存当前热更新状态。
    { // 保存方法开始。
        string json = JsonUtility.ToJson(state); // 把状态对象转成 JSON 字符串。
        string tempPath = StatePath + ".tmp"; // 先写临时文件,避免写一半损坏正式状态。
        File.WriteAllText(tempPath, json); // 把 JSON 写入临时状态文件。
        if (File.Exists(StatePath)) // 如果旧状态文件已经存在。
        { // 删除旧文件分支开始。
            File.Delete(StatePath); // 删除旧状态文件。
        } // 删除旧文件分支结束。
        File.Move(tempPath, StatePath); // 临时文件写完后再替换成正式状态文件。
    } // 保存方法结束。
    public static HotUpdateState Load() // 读取上一次未完成的热更新状态。
    { // 读取方法开始。
        if (!File.Exists(StatePath)) // 如果状态文件不存在。
        { // 无状态分支开始。
            return null; // 返回 null,表示没有未完成热更新。
        } // 无状态分支结束。
        string json = File.ReadAllText(StatePath); // 读取状态 JSON。
        return JsonUtility.FromJson<HotUpdateState>(json); // 反序列化成状态对象并返回。
    } // 读取方法结束。
    public static void Clear() // 清理热更新状态。
    { // 清理方法开始。
        if (File.Exists(StatePath)) // 如果状态文件存在。
        { // 删除状态分支开始。
            File.Delete(StatePath); // 删除状态文件。
        } // 删除状态分支结束。
    } // 清理方法结束。
} // 工具类结束。

Unity 工程实践

面试里可以这样说:我会把热更新做成事务式状态机。下载时只写 staging 目录,退出时保存状态、取消请求、关闭文件句柄,但不切正式 Manifest。下次启动先加载旧 Manifest 保证游戏可进,再检查 update_state.json,校验临时文件 Hash,能续传就续传,不能续传就回滚清理。

一句话记忆:热更新过程中退出,最多丢下载进度,不能丢旧版本,也不能留下半更新状态。

Patch 包如何回滚?

unity-patch-package-rollback

标准答案

Patch 包回滚的核心不是“把文件一个个改回去”,而是把当前生效版本入口切回上一版稳定版本

我会这样设计:旧版本资源目录不删除,新 Patch 先下载到 staging 或独立版本目录,比如 res/v103。只有下载、Hash、签名、依赖校验都通过后,才把 current.json 里的 activeVersionv102 切到 v103。如果 Patch 失败、崩溃率升高、加载资源报错,回滚时只需要把 activeVersion 切回 lastStableVersion,再清理坏版本缓存,并把坏版本加入黑名单,防止下次又进入同一个错误 Patch。

底层原理

本质是事务提交:

下载临时资源 -> 校验 -> 提交版本入口 -> 运行健康检查 -> 失败则回滚入口

不要直接覆盖旧资源,否则一旦中途失败,就会出现“旧 Manifest 找不到旧 Bundle,新 Manifest 又不完整”的半更新状态。 正确做法是让资源版本并存:res/v102 继续可运行,res/v103 只是候选版本。真正决定加载哪个版本的是 current.json 或本地 Manifest 指针。

代码示例

c
using System.IO; // 引入文件和目录操作 API。
using UnityEngine; // 引入 Unity 的持久化路径和 PlayerPrefs。
public static class PatchRollbackStore // 定义 Patch 回滚工具类。
{ // 工具类开始。
    private static string Root => Application.persistentDataPath; // 获取热更资源本地根目录。
    public static void Rollback(string stableVersion, string failedVersion) // 回滚到稳定版本,并记录失败版本。
    { // 回滚方法开始。
        string currentPath = Path.Combine(Root, "current.json"); // 拼出当前版本入口文件路径。
        string tempPath = currentPath + ".tmp"; // 拼出临时入口文件路径。
        string json = "{\"activeVersion\":\"" + stableVersion + "\"}"; // 生成新的版本入口内容。
        File.WriteAllText(tempPath, json); // 先写临时文件,避免 current.json 写一半损坏。
        if (File.Exists(currentPath)) File.Delete(currentPath); // 如果旧入口文件存在,就先删除。
        File.Move(tempPath, currentPath); // 把临时文件移动成正式入口文件。
        string failedDir = Path.Combine(Root, "res/" + failedVersion); // 拼出失败版本资源目录。
        if (Directory.Exists(failedDir)) Directory.Delete(failedDir, true); // 删除失败 Patch 的资源缓存。
        PlayerPrefs.SetString("BlockedPatchVersion", failedVersion); // 记录失败版本,避免再次进入坏 Patch。
        PlayerPrefs.Save(); // 立刻保存黑名单信息。
    } // 回滚方法结束。
} // 工具类结束。

Unity 工程实践

IMPORTANT

项目里我会把回滚分成两种情况: Patch 还没提交时失败,就清理 staging,继续用旧版本。Patch 已经提交后发现问题,就切回上一版 Manifest,释放或重启当前资源环境,并上报失败原因。

如果用 Addressables 或 AssetBundle,还要注意:回滚前要停止新的加载请求;已经实例化出来的对象不能假装没事,必要时切 Loading 场景或重启游戏流程;旧版本 Bundle 和 Manifest 至少保留一到两个稳定版本,不能更新成功后立刻删光。

强更和非强更区别是什么?

unity-force-vs-optional-update

标准答案

强更和非强更的区别,核心看一句话:旧客户端还能不能安全进入游戏

强更:客户端版本低于服务端最低支持版本,必须更新,否则不能登录或进游戏。 非强更:客户端仍然兼容服务端和资源,可以继续玩,只是提示玩家更新,或者后台下载。

常见判断规则是:

clientBuild < minBuild:强更 minBuild <= clientBuild < latestBuild:非强更 clientBuild >= latestBuild:直接进入

底层原理

强更本质是“版本门禁”。服务端维护 minBuildlatestBuild、下载地址、渠道号、灰度开关等策略。客户端启动时先请求版本策略,再决定能不能继续。

强更通常用于:协议不兼容、服务端接口变更、严重崩溃修复、安全漏洞、SDK 更新、IL2CPP/原生代码变化。 非强更通常用于:美术资源、活动资源、UI 优化、小功能优化、可兼容配置更新。

代码示例

c
public enum UpdateDecision // 定义版本检查后的处理结果。
{ // 枚举开始。
    EnterGame, // 版本已满足要求,可以直接进入游戏。
    OptionalUpdate, // 版本不是最新,但仍然兼容,可以提示非强更。
    ForceUpdate // 版本低于最低支持版本,必须强制更新。
} // 枚举结束。
public class VersionPolicy // 定义服务端下发的版本策略。
{ // 策略类开始。
    public int minBuild; // 服务端允许进入游戏的最低版本号。
    public int latestBuild; // 服务端推荐玩家更新到的最新版本号。
    public string updateUrl; // 应用商店、渠道包或热更包下载地址。
} // 策略类结束。
public static class VersionChecker // 定义版本检查工具类。
{ // 工具类开始。
    public static UpdateDecision Decide(int clientBuild, VersionPolicy policy) // 根据客户端版本和服务端策略做判断。
    { // 判断方法开始。
        if (clientBuild < policy.minBuild) // 如果客户端版本低于最低支持版本。
        { // 强更分支开始。
            return UpdateDecision.ForceUpdate; // 返回强更,禁止继续进入游戏。
        } // 强更分支结束。
        if (clientBuild < policy.latestBuild) // 如果客户端版本低于最新推荐版本。
        { // 非强更分支开始。
            return UpdateDecision.OptionalUpdate; // 返回非强更,可以提示但允许跳过。
        } // 非强更分支结束。
        return UpdateDecision.EnterGame; // 版本已经是最新或满足要求,直接进入游戏。
    } // 判断方法结束。
} // 工具类结束。

Unity 工程实践

在 Unity 项目里,强更一般放在登录前或进大厅前做,因为进游戏后再强更会破坏体验。包体强更一般跳应用商店或渠道包;资源更新可以用 AssetBundle、Addressables 或自研热更流程。

面试里可以补一句很加分的话:强更不是客户端自己拍脑袋决定,而是服务端版本策略控制;客户端要考虑渠道差异、灰度发布、回滚和弱网失败处理。

资源灰度发布怎么做?

unity-resource-gray-release

标准答案

资源灰度发布,就是让一小部分确定用户先使用新资源 Manifest,观察下载失败率、崩溃率、加载耗时、资源缺失等指标,确认稳定后再逐步扩大比例。

一般流程是:客户端启动时把 userId、渠道、包版本、设备信息发给版本服务;服务端根据灰度策略决定返回稳定版 Manifest v102,还是灰度版 Manifest v103。客户端只按服务端返回的 Manifest 下载资源,不自己随机选择版本。

底层原理

灰度的核心是“确定性分桶”。比如用 userId 做稳定 Hash,然后 % 100,如果灰度比例是 5%,那桶号 0 ~ 4 的玩家进入灰度组。这样同一个玩家每次启动都会落到同一组,不会今天用新资源、明天又变旧资源。

如果灰度资源出现问题,服务端直接把灰度比例调成 0,或者把 v103 加入黑名单,让客户端重新拿稳定版 Manifest,这就是快速回滚。

代码示例

c
using System; // 引入基础系统命名空间。
public class ResourceGrayPolicy // 定义资源灰度策略数据。
{ // 策略类开始。
    public int grayPercent; // 灰度比例,例如 5 表示 5% 用户。
    public string stableManifest; // 稳定资源清单地址或版本号。
    public string grayManifest; // 灰度资源清单地址或版本号。
} // 策略类结束。
public static class ResourceGrayDecider // 定义资源灰度分桶工具类。
{ // 工具类开始。
    public static string DecideManifest(string userId, ResourceGrayPolicy policy) // 根据用户和策略决定返回哪个 Manifest。
    { // 决策方法开始。
        if (string.IsNullOrEmpty(userId)) // 如果没有用户 ID。
        { // 空用户分支开始。
            return policy.stableManifest; // 没有稳定分桶依据时默认走稳定版本。
        } // 空用户分支结束。
        int bucket = GetStableBucket(userId); // 根据用户 ID 计算稳定桶号。
        bool inGray = bucket < policy.grayPercent; // 判断桶号是否落在灰度比例内。
        return inGray ? policy.grayManifest : policy.stableManifest; // 灰度用户返回新 Manifest,否则返回稳定 Manifest。
    } // 决策方法结束。
    private static int GetStableBucket(string userId) // 把用户 ID 转成 0 到 99 的稳定桶号。
    { // 分桶方法开始。
        unchecked // 允许整数溢出,用于稳定 Hash 计算。
        { // unchecked 代码块开始。
            uint hash = 2166136261u; // 使用 FNV-1a 的初始 Hash 值。
            for (int i = 0; i < userId.Length; i++) // 遍历用户 ID 的每个字符。
            { // 循环体开始。
                hash ^= userId[i]; // 把当前字符混入 Hash。
                hash *= 16777619u; // 乘以 FNV 质数继续打散。
            } // 循环体结束。
            return (int)(hash % 100u); // 映射成 0 到 99 的桶号。
        } // unchecked 代码块结束。
    } // 分桶方法结束。
} // 工具类结束。

Unity 工程实践

项目里我会把资源灰度放在资源版本服务上做,而不是写死在客户端。灰度策略通常包含:渠道、地区、包版本、用户白名单、黑名单、灰度比例、目标 Manifest、CDN 地址。

关键点有三个:第一,同一用户要稳定落组;第二,新旧 Manifest 的资源依赖要隔离清楚,公共依赖避免重复打包;第三,必须有监控和回滚能力。面试时可以补一句:灰度不是只发新资源,更重要的是能发现问题、停止放量、快速切回稳定版本。

多渠道包资源差异怎么处理?

unity-multi-channel-resource-differences

标准答案

多渠道包资源差异,核心做法是:公共资源共用,渠道差异用配置和覆盖包处理

不要为每个渠道复制一整套 Unity 工程或完整资源目录。一般会把资源分成三类:

公共资源:角色、场景、通用 UI、Shader、音效等,所有渠道共用。 渠道配置:channelId、SDK 参数、支付参数、登录方式、开关配置。 渠道差异资源:渠道 Logo、启动图、公告图、活动入口图、特殊 SDK 资源等。

最终构建时根据 channelId 生成对应渠道包和渠道 Manifest。

底层原理

资源加载时可以做一层覆盖规则:

先查:channel/{channelId}/xxx 找不到再查:common/xxx

这样渠道 A 可以覆盖自己的 Logo,渠道 B 可以覆盖自己的活动图,但角色、场景、Shader 这类大资源仍然走公共包,避免重复打包和包体膨胀。

代码示例

c
public static class ChannelResourceResolver // 定义渠道资源路径解析工具类。
{ // 工具类开始。
    public static string Resolve(string channelId, string resourceKey) // 根据渠道号和资源名解析最终资源路径。
    { // 解析方法开始。
        string channelPath = $"channel/{channelId}/{resourceKey}"; // 拼出渠道专属资源路径。
        if (ResourceExists(channelPath)) // 如果渠道专属资源存在。
        { // 渠道资源存在分支开始。
            return channelPath; // 优先返回渠道覆盖资源路径。
        } // 渠道资源存在分支结束。
        string commonPath = $"common/{resourceKey}"; // 拼出公共资源路径。
        return commonPath; // 返回公共资源路径作为兜底。
    } // 解析方法结束。
    private static bool ResourceExists(string path) // 判断资源路径是否存在。
    { // 判断方法开始。
        return false; // 示例代码中省略真实查询逻辑,项目里可查 Manifest 或 Addressables catalog。
    } // 判断方法结束。
} // 工具类结束。

Unity 工程实践

项目里我会这样拆:

公共 Bundle:所有渠道共享,比如 common_uicommon_scenecommon_character。 渠道 Bundle:只放差异资源,比如 channel_taptap_logochannel_huawei_sdk_res。 渠道配置表:保存 SDK AppId、支付开关、登录方式、审核服地址、资源 CDN 地址。 构建流水线:CI 根据渠道参数注入 channelId,自动生成不同 Manifest。 自动校验:检查资源缺失、重复打包、依赖冗余、Hash 不一致、渠道配置漏填。

面试里可以补一句:多渠道差异的难点不是能不能打多个包,而是怎么让公共资源不重复、渠道资源可覆盖、热更和回滚还能按渠道生效。

常见坑

直接复制整套资源,会导致包体暴涨。 渠道资源依赖了公共资源,但打包规则没抽公共依赖,会重复进包。 客户端写死渠道逻辑,后续加渠道很痛苦。 热更只按大版本下发,不区分渠道,容易把 A 渠道资源发给 B 渠道。

自动构建流水线包含哪些步骤?

unity-automated-build-pipeline-steps

标准答案

自动构建流水线,就是把“从代码提交到生成可安装包/热更包”的过程自动化。完整流程一般包括:

触发构建:手动、定时、提交代码、打 Tag。 拉取代码:指定分支、commit、子模块、LFS 资源。 环境准备:Unity 版本、SDK、JDK、NDK、证书、渠道参数。 配置注入:版本号、渠道号、服务器地址、宏定义、资源 CDN。 资源检查:Prefab 引用丢失、贴图尺寸、配置表空值、Bundle 冗余。 资源打包:AssetBundle、Addressables Catalog、Manifest、Hash。 构建包体:Android APK/AAB,iOS Xcode 工程或 IPA。 签名测试:证书签名、启动冒烟测试、资源完整性校验。 上传归档:上传 CDN、测试平台、包管理后台。 通知结果:构建成功/失败、包地址、版本号、commit、日志。

底层原理

流水线最重要的是“可重复”和“可追溯”。同一个 commit + 配置 + Unity 版本 + 资源版本,应该能构建出同样的产物。 所以不要手动改配置、手动拖资源、手动改版本号,而是用脚本和 CI 参数统一注入。

代码示例

c
using System; // 引入 Exception 等基础类型。
using UnityEditor; // 引入 Unity 编辑器构建 API。
using UnityEditor.Build.Reporting; // 引入构建报告相关类型。
public static class CiBuild // 定义 CI 自动构建入口类。
{ // 类开始。
    public static void BuildAndroid() // 定义给 CI 调用的 Android 构建方法。
    { // 方法开始。
        BuildPlayerOptions options = new BuildPlayerOptions(); // 创建 Unity 构建参数对象。
        options.scenes = new[] { "Assets/Scenes/Login.unity", "Assets/Scenes/Game.unity" }; // 设置要打进包体的场景列表。
        options.locationPathName = "Builds/Game.apk"; // 设置 APK 输出路径。
        options.target = BuildTarget.Android; // 设置构建目标平台为 Android。
        options.options = BuildOptions.None; // 设置普通构建选项。
        BuildReport report = BuildPipeline.BuildPlayer(options); // 调用 Unity 构建接口开始构建 Player。
        if (report.summary.result != BuildResult.Succeeded) // 判断构建是否失败。
        { // 失败分支开始。
            throw new Exception("Android build failed."); // 抛出异常,让 CI 任务显示失败。
        } // 失败分支结束。
    } // 方法结束。
} // 类结束。

Unity 工程实践

面试里可以这样答:我会把构建流水线分成“输入、检查、构建、验证、发布、归档”几层。输入包括分支、commit、渠道、平台、版本号;检查包括资源规范、配置表、Bundle 依赖;构建产物包括安装包、资源包、Manifest、符号表和日志;最后上传到 CDN 或测试平台,并把构建结果发到群里。

加分点是:失败要能定位,成功要能追溯。比如每个包都记录 commitId、构建机、Unity 版本、渠道配置、资源 Manifest Hash,这样线上出问题才能回到当时的构建环境。

构建失败如何定位?

unity-build-failure-diagnosis

标准答案

构建失败定位,我会先问一句:失败发生在哪个阶段? 不要一上来清 Library、重启机器、重新打包。正确顺序是:

先看 CI 流水线失败在哪个 Job。 再看第一个 Error,不要只看最后一行。 然后判断属于环境、编译、资源、平台构建、签名上传哪一类。 最后用同一个 commit + 构建参数 + Unity 版本 + SDK 版本 本地复现,修复后再补自动检查,避免下次再炸。

底层原理

构建流水线其实是一条阶段链:

环境准备 -> 代码编译 -> 资源导入 -> 资源打包 -> Player 构建 -> 签名 -> 上传

后面的失败经常是前面错误引起的。比如最后显示 Gradle 失败,但真正原因可能是 AndroidManifest 冲突、SDK 版本不匹配、插件重复、IL2CPP 编译失败。所以要找“第一个有效错误”,不是盯着最后一行。

代码示例

c
using System; // 引入异常类型。
using UnityEditor; // 引入 Unity 编辑器构建 API。
using UnityEditor.Build.Reporting; // 引入构建报告类型。
using UnityEngine; // 引入 Debug 日志 API。
public static class BuildRunner // 定义自动构建入口类。
{ // 类开始。
    public static void BuildAndroid() // 定义 Android 构建方法。
    { // 方法开始。
        BuildPlayerOptions options = new BuildPlayerOptions(); // 创建构建参数对象。
        options.scenes = new[] { "Assets/Scenes/Login.unity" }; // 设置要参与构建的场景。
        options.locationPathName = "Builds/Game.apk"; // 设置 APK 输出路径。
        options.target = BuildTarget.Android; // 设置构建平台为 Android。
        BuildReport report = BuildPipeline.BuildPlayer(options); // 执行 Unity 构建并拿到报告。
        Debug.Log("Build result: " + report.summary.result); // 输出构建结果,方便 CI 日志检索。
        Debug.Log("Total errors: " + report.summary.totalErrors); // 输出错误数量,方便定位失败程度。
        if (report.summary.result != BuildResult.Succeeded) // 如果构建结果不是成功。
        { // 失败分支开始。
            throw new Exception("Build failed, check Editor.log and BuildReport."); // 抛出异常,让 CI 标记失败。
        } // 失败分支结束。
    } // 方法结束。
} // 类结束。

Unity 工程实践

我会把构建失败分成几类看:

环境问题:Unity 版本不一致、SDK/JDK/NDK 缺失、证书过期。 编译问题:脚本错误、宏定义错误、程序集引用错误、平台 API 不兼容。 资源问题:Prefab 丢引用、配置表空字段、贴图导入失败、Bundle 依赖重复。 平台问题:Android Gradle、iOS Xcode、IL2CPP、插件冲突。 发布问题:签名失败、上传 CDN 失败、权限或网络问题。

面试里最好补一句:我会把常见问题前置成自动检查,比如资源缺失检查、配置表检查、Bundle 冗余检查、版本号检查。这样构建失败能更早暴露,而不是等打包 30 分钟后才失败。

构建机环境如何保证一致?

unity-build-machine-env-consistency

标准答案

构建机环境一致性,核心不是“大家装一样的软件”,而是把构建环境也版本化、自动化、可校验

一般要保证这些东西固定:

Unity 版本:看 ProjectSettings/ProjectVersion.txt。 Package 版本:锁 Packages/manifest.jsonpackages-lock.json。 平台工具链:Android 锁 SDK、JDK、NDK、Gradle;iOS 锁 Xcode、证书、描述文件。 构建镜像:用 Docker、虚拟机模板、固定构建机镜像。 CI 参数:渠道号、版本号、宏定义、服务器地址统一由流水线注入。 缓存策略:Library 缓存要按 Unity 版本、平台、分支、包锁文件 Hash 生成 Key。 构建前预检:版本不一致直接失败,不要带病构建。 构建后归档:记录 commit、构建机、Unity 版本、SDK 版本、环境变量和配置。

底层原理

构建结果受“代码 + 资源 + 工具链 + 配置 + 缓存”共同影响。 如果 Unity 版本不同、SDK 不同、缓存没失效,就可能出现“我本地能打,构建机打不了”或者“同一个 commit 构建出不同包”的问题。

所以工程上要做成:

版本锁定 -> 固定镜像 -> 构建前预检 -> 构建 -> 环境报告归档

代码示例

c
using System; // 引入异常和环境变量相关 API。
using UnityEditor; // 引入 Unity 编辑器菜单和构建相关 API。
using UnityEngine; // 引入 Application 和 Debug API。
public static class BuildEnvironmentPreflight // 定义构建环境预检工具类。
{ // 类开始。
    private const string ExpectedUnityVersion = "2022.3.20f1"; // 定义项目要求的 Unity 版本。
    [MenuItem("CI/Preflight Check")] // 把预检方法注册到 Unity 编辑器菜单,CI 也可以调用。
    public static void Check() // 定义构建前预检方法。
    { // 方法开始。
        string currentUnityVersion = Application.unityVersion; // 读取当前构建机上的 Unity 版本。
        if (currentUnityVersion != ExpectedUnityVersion) // 判断 Unity 版本是否和项目要求一致。
        { // Unity 版本不一致分支开始。
            throw new Exception("Unity version mismatch: " + currentUnityVersion); // 抛出异常,让 CI 直接失败。
        } // Unity 版本不一致分支结束。
        string androidSdkRoot = Environment.GetEnvironmentVariable("ANDROID_SDK_ROOT"); // 读取 Android SDK 环境变量。
        if (string.IsNullOrEmpty(androidSdkRoot)) // 判断 Android SDK 路径是否为空。
        { // Android SDK 缺失分支开始。
            throw new Exception("ANDROID_SDK_ROOT is missing."); // 抛出异常,提示 SDK 环境不完整。
        } // Android SDK 缺失分支结束。
        Debug.Log("Preflight check passed."); // 输出预检通过日志。
    } // 方法结束。
} // 类结束。

Unity 工程实践

我会在 CI 第一步做环境预检,失败就立即停止,不浪费后面几十分钟打包时间。 同时每个包都归档一份 env_report.json,里面记录 Unity 版本、构建机 ID、镜像版本、SDK 路径、commitId、渠道参数和资源 Manifest Hash。

面试加分句:**构建机一致性不是靠口头约定,而是靠版本锁定、镜像固化、预检脚本、缓存 Key 和环境归档。

资源导入设置为什么要版本管理?

unity-asset-import-settings-version-control

标准答案

资源导入设置要版本管理,因为它是构建输入的一部分。同一张图片、同一个模型,如果导入设置不同,最终生成的纹理格式、尺寸、压缩方式、内存占用、Bundle Hash 都可能不同。

Unity 里最关键的是 .meta 文件:它保存资源的 GUID 和 Importer 设置。Prefab、Scene、Material 引用资源时,很多时候不是靠文件名,而是靠 GUID。如果 .meta 丢了,Unity 会重新生成 GUID,引用就可能丢失。

底层原理

导入链路大概是:

源资源 + .meta + Importer 设置 -> Unity 导入 -> Library 缓存 -> 构建产物

Library 是本机缓存,不需要提交。 但 .metaProjectSettingsPackages 锁文件、导入规则必须提交,因为它们决定缓存和最终包体怎么生成。

代码示例

c
using UnityEditor; // 引入 Unity 编辑器资源导入 API。
public class TextureImportRule : AssetPostprocessor // 定义贴图导入后处理规则。
{ // 类开始。
    void OnPreprocessTexture() // 在贴图导入前执行。
    { // 方法开始。
        TextureImporter importer = (TextureImporter)assetImporter; // 获取当前贴图的导入器。
        importer.mipmapEnabled = false; // 关闭 Mipmap,常用于 UI 贴图减少内存。
        importer.isReadable = false; // 关闭 Read/Write,避免运行时保留 CPU 侧纹理数据。
        importer.maxTextureSize = 1024; // 限制最大贴图尺寸,避免美术误提交超大图。
        importer.textureCompression = TextureImporterCompression.Compressed; // 设置贴图压缩,减少包体和显存。
    } // 方法结束。
} // 类结束。

Unity 工程实践

项目里我会版本管理这些内容:资源文件、.metaProjectSettingsPackages/manifest.jsonpackages-lock.json、导入规则脚本、Preset。 不会提交这些内容:LibraryTempLogs、本地缓存。

面试加分句:资源导入设置不进版本管理,就无法保证团队机器和构建机产物一致,也无法稳定复现线上包体问题。

Unity Library 目录为什么不提交?

unity-library-not-commit

标准答案

Library 目录不提交,因为它是 Unity 根据项目源数据生成的本机缓存,不是项目真正的源文件。

真正要提交的是:Assets.metaProjectSettingsPackagesLibrary 可以删除,Unity 下次打开项目时会根据这些输入重新导入生成。

底层原理

Unity 的资源流程大概是:

Assets + .meta + ProjectSettings + Packages -> Unity 导入 -> Library 缓存 -> 构建产物

Library 里保存的是导入后的中间数据,比如纹理导入缓存、模型导入缓存、脚本编译缓存、资源数据库缓存等。它和 Unity 版本、目标平台、本机路径、导入状态都有关系。

如果把 Library 提交到 Git,会有几个问题:

体积巨大,仓库会非常臃肿。 内容频繁变化,每个人打开项目都可能改动。 里面很多是机器相关缓存,不适合多人共享。 容易产生大量二进制冲突,合并成本高。 Unity 版本或平台变化后,旧缓存还可能导致奇怪问题。

Unity 工程实践

正确做法是:.gitignore 忽略 Library/,但必须提交 .meta。 如果构建机想加速,可以做 CI 缓存,但缓存不等于提交到版本库。缓存 Key 应该包含 Unity 版本、平台、Packages 锁文件、导入规则 Hash 等。

面试加分句:Library 可以缓存来加速构建,但不应该版本管理;真正要版本管理的是能重新生成 Library 的输入。

meta 文件为什么必须提交?

unity-meta-file-must-commit

标准答案

.meta 文件必须提交,因为它保存了 Unity 资源的两个核心信息:GUID导入设置

Unity 里很多引用关系不是靠文件名找资源,而是靠 .meta 里的 GUID。比如 Scene、Prefab、Material、Animator、ScriptableObject 引用某张贴图、某个脚本、某个材质时,内部记录的通常是 GUID。 如果你只提交 Texture.png,不提交 Texture.png.meta,别人拉项目后 Unity 会重新生成一个新的 GUID,原来指向旧 GUID 的引用就可能变成 Missing Reference。

底层原理

资源引用链大概是:

资源文件 -> .meta 生成 GUID -> Prefab/Scene 保存 GUID -> Unity 根据 GUID 找资源

所以 .meta 就像资源在 Unity 项目里的“身份证”。资源文件本体是内容,.meta 是身份和导入规则。身份变了,引用就断了。

.meta 还保存导入设置,比如贴图压缩格式、最大尺寸、是否 Read/Write、模型导入参数、音频压缩参数等。不提交的话,不同机器导入结果可能不同,导致内存、包体、画质、Bundle Hash 都不一致。

Unity 工程实践

项目里要做到:资源文件和 .meta 成对提交。 Unity Version Control 设置里建议使用 Visible Meta Files,让 .meta 文件可见;同时使用 Force Text,方便版本控制和冲突合并。

CI 里也可以做检查:有没有资源缺失 .meta,有没有孤儿 .meta,有没有人手动复制资源但漏提交 .meta

一句话记忆:Library 不提交,meta 必须提交;Library 是缓存,meta 是资源身份。

GUID 冲突怎么处理?

unity-guid-conflict-fix

标准答案

GUID 冲突的处理原则是:先找原主,原主保留旧 GUID,复制出来的重复资源重新生成 GUID

不要随手改 .meta,也不要两个资源继续共用同一个 GUID。正确流程是:

先扫描所有 .meta,找出重复 GUID。 再看提交历史、路径、引用关系,判断哪个资源是原始资源。 保留原始资源的 .meta 不动。 对复制出来的资源删除 .meta,让 Unity 重新导入生成新 GUID。 最后检查 Missing Reference、材质丢失、Prefab 引用、场景引用,并把重复 GUID 检查放进 CI。

底层原理

Unity 的引用关系主要靠 GUID。 如果两个资源使用同一个 GUID,Unity 可能把 A 的引用解析到 B,或者导入数据库出现混乱,最后表现成引用错乱、材质丢失、Prefab Missing、Bundle 依赖异常。

常见原因是:在文件管理器里复制资源时,把 .meta 也复制过去了;或者分支合并时带来了重复 .meta

代码示例

c
using System.Collections.Generic; // 引入 Dictionary 用来记录 GUID 和 meta 文件路径。
using System.IO; // 引入 Directory 和 File 用来扫描 meta 文件。
using UnityEditor; // 引入 MenuItem 用来添加编辑器菜单。
using UnityEngine; // 引入 Application 和 Debug 用来拿路径和输出日志。
public static class GuidConflictChecker // 定义 GUID 冲突检查工具类。
{ // 工具类开始。
    [MenuItem("Tools/Check Duplicate GUID")] // 在 Unity 菜单栏添加检查入口。
    public static void CheckDuplicateGuids() // 定义检查重复 GUID 的方法。
    { // 方法开始。
        Dictionary<string, string> guidToMeta = new Dictionary<string, string>(); // 创建字典,key 是 GUID,value 是 meta 路径。
        string[] metaFiles = Directory.GetFiles(Application.dataPath, "*.meta", SearchOption.AllDirectories); // 扫描 Assets 下所有 meta 文件。
        foreach (string metaFile in metaFiles) // 遍历每一个 meta 文件。
        { // 循环开始。
            string guid = ReadGuid(metaFile); // 从 meta 文件里读取 guid 字段。
            if (string.IsNullOrEmpty(guid)) // 如果当前 meta 没有读到 GUID。
            { // 空 GUID 分支开始。
                continue; // 跳过这个 meta 文件。
            } // 空 GUID 分支结束。
            if (guidToMeta.TryGetValue(guid, out string oldMetaFile)) // 如果这个 GUID 之前已经出现过。
            { // 重复 GUID 分支开始。
                Debug.LogError($"Duplicate GUID: {guid}\n{oldMetaFile}\n{metaFile}"); // 输出重复 GUID 和两个 meta 路径。
            } // 重复 GUID 分支结束。
            else // 如果这个 GUID 第一次出现。
            { // 首次出现分支开始。
                guidToMeta.Add(guid, metaFile); // 把 GUID 和 meta 路径记录下来。
            } // 首次出现分支结束。
        } // 循环结束。
    } // 方法结束。
    private static string ReadGuid(string metaFile) // 定义从 meta 文件读取 GUID 的方法。
    { // 方法开始。
        foreach (string line in File.ReadLines(metaFile)) // 逐行读取 meta 文件内容。
        { // 循环开始。
            if (line.StartsWith("guid:")) // 如果当前行是 guid 字段。
            { // 命中 guid 分支开始。
                return line.Substring("guid:".Length).Trim(); // 截取并返回 GUID 字符串。
            } // 命中 guid 分支结束。
        } // 循环结束。
        return null; // 没有找到 GUID 时返回 null。
    } // 方法结束。
} // 工具类结束。

Unity 工程实践

WARNING

如果我是项目里处理这个问题,我会先建分支备份,然后找重复 GUID。被场景、Prefab、材质大量引用的资源保留旧 GUID;复制出来的新资源删除 .meta 后重新导入。修完后跑一次全项目引用检查和资源构建检查。

面试加分句:GUID 冲突不是简单改字符串,而是资源身份冲突;处理时要保护已有引用,再给新资源新身份。

Addressables Group 如何规划?

unity-addressables-group-planning

标准答案

Addressables Group 不应该按文件夹随便分,而应该按生命周期、下载时机、更新频率、依赖关系来规划。

常见分法:

首包组:只放启动必需资源,比如登录 UI、基础配置、Loading。 公共依赖组:Shader、公共材质、通用图集、公共音效。 场景组:按章节、地图、关卡拆分,进入前预加载,退出后释放。 活动组:运营活动、节日资源,高频更新,适合远端下载。 语言组:中文、英文、日文语音或文本,按语言拆包。 角色组:角色、皮肤、武器,可以按职业、稀有度、玩法模块拆。

Unity 官方也说明,Group 是 Addressables 的主要组织单元;Group Settings 会影响构建路径、Bundle 压缩等;Labels 可以作为运行时加载 key;Analyze 可以分析 Addressables 布局问题。

参考:GroupsGroup settingsLabelsAnalyze

底层原理

Addressables 最终还是会生成 Catalog 和 AssetBundle。Group 的设置会影响资源被打到哪个 Bundle、从哪里加载、本地还是远端、能不能热更、压缩方式是什么。

所以 Group 规划不好会带来几个问题:

公共依赖重复打包,包体变大。 高频更新资源混在大包里,热更成本变高。 首包塞太多资源,下载和安装变慢。 资源生命周期混乱,加载后不好释放。 活动结束后资源还常驻,浪费磁盘和内存。

代码示例

c
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载 API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入异步句柄类型。
public class AddressableLoadExample : MonoBehaviour // 定义 Addressables 加载示例组件。
{ // 类开始。
    private AsyncOperationHandle<GameObject> handle; // 保存异步加载句柄,方便后续释放。
    public void LoadPrefab(string addressKey) // 根据 Addressable 地址加载资源。
    { // 方法开始。
        handle = Addressables.LoadAssetAsync<GameObject>(addressKey); // 发起异步加载请求。
        handle.Completed += OnPrefabLoaded; // 注册加载完成回调。
    } // 方法结束。
    private void OnPrefabLoaded(AsyncOperationHandle<GameObject> op) // 处理资源加载完成。
    { // 回调开始。
        if (op.Status == AsyncOperationStatus.Succeeded) // 判断资源是否加载成功。
        { // 成功分支开始。
            Instantiate(op.Result); // 实例化加载到的 Prefab。
        } // 成功分支结束。
    } // 回调结束。
    public void ReleasePrefab() // 释放已经加载的资源。
    { // 方法开始。
        if (handle.IsValid()) // 判断句柄是否仍然有效。
        { // 有效分支开始。
            Addressables.Release(handle); // 释放 Addressables 引用计数。
        } // 有效分支结束。
    } // 方法结束。
} // 类结束。

Unity 工程实践

IMPORTANT

我会先盘点资源,再定规则:启动必需进 CoreLocal,公共依赖进 CommonShared,章节地图进 SceneChapter,运营活动进 ActivityRemote,语言音频进 LanguageAudio。然后用 Labels 做运行时加载维度,比如 preloadbattleui_shoplang_cn

面试加分句:Group 是构建和生命周期管理单位,Label 是运行时查询和加载维度,两者不要混为一谈。

Addressables Remote Catalog 是什么?

unity-addressables-remote-catalog

标准答案

Addressables Remote Catalog 是放在远端 CDN 上的资源目录表。它不是资源本体,而是告诉客户端:某个 Address 对应哪个资源、依赖哪些资源、应该从哪个 Bundle、哪个 URL 去加载。

简单说:

本地 Catalog:随 App 包体一起发布,首次启动就能用。 Remote Catalog:放在服务器/CDN 上,用于资源热更新。 .hash 文件:用来判断远端 Catalog 有没有变化。 AssetBundle:真正的资源数据,Catalog 只是它的“导航表”。

Unity 官方文档里也提到,启用 Build Remote Catalog 后,构建会生成远端 Catalog;Catalog hash 用来检测 Catalog 是否变化;运行时可检查并更新 Catalog。

参考:Remote content distributionCatalogsUpdate a previous build

底层原理

Addressables 加载资源不是直接根据文件路径找资源,而是先查 Catalog:

c
Address -> ResourceLocation -> Bundle URL -> 下载/加载 AssetBundle -> 取出 Asset

当远端 Catalog 更新后,同一个 Address 可能指向新的 Bundle,比如:

shop_ui -> bundle_v102 更新后变成: shop_ui -> bundle_v103

这样客户端不用重新发整包,只要下载新的 Catalog 和需要变化的 Bundle,就能实现资源热更新。

代码示例

c
using System.Collections.Generic; // 引入 List 类型,用来保存需要更新的 Catalog 名称。
using UnityEngine; // 引入 MonoBehaviour 和 Debug。
using UnityEngine.AddressableAssets; // 引入 Addressables API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入异步操作句柄类型。
public class RemoteCatalogUpdater : MonoBehaviour // 定义远端 Catalog 更新组件。
{ // 类开始。
    private void Start() // Unity 启动时调用。
    { // 方法开始。
        CheckCatalogUpdate(); // 开始检查远端 Catalog 是否有更新。
    } // 方法结束。
    private void CheckCatalogUpdate() // 定义检查 Catalog 更新的方法。
    { // 方法开始。
        AsyncOperationHandle<List<string>> checkHandle = Addressables.CheckForCatalogUpdates(); // 请求检查有哪些 Catalog 需要更新。
        checkHandle.Completed += OnCheckCompleted; // 注册检查完成回调。
    } // 方法结束。
    private void OnCheckCompleted(AsyncOperationHandle<List<string>> handle) // 处理 Catalog 检查完成。
    { // 回调开始。
        if (handle.Status != AsyncOperationStatus.Succeeded) // 如果检查失败。
        { // 失败分支开始。
            Debug.LogWarning("Check catalog update failed."); // 输出警告,项目里可以走本地 Catalog 兜底。
            Addressables.Release(handle); // 释放检查句柄,避免引用泄漏。
            return; // 结束回调。
        } // 失败分支结束。
        if (handle.Result.Count == 0) // 如果没有需要更新的 Catalog。
        { // 无更新分支开始。
            Debug.Log("Catalog is already latest."); // 输出当前 Catalog 已经是最新。
            Addressables.Release(handle); // 释放检查句柄。
            return; // 结束回调。
        } // 无更新分支结束。
        AsyncOperationHandle<List<IResourceLocator>> updateHandle = Addressables.UpdateCatalogs(handle.Result); // 更新需要更新的 Catalog。
        updateHandle.Completed += OnUpdateCompleted; // 注册更新完成回调。
        Addressables.Release(handle); // 释放检查句柄。
    } // 回调结束。
    private void OnUpdateCompleted(AsyncOperationHandle<List<IResourceLocator>> handle) // 处理 Catalog 更新完成。
    { // 回调开始。
        if (handle.Status == AsyncOperationStatus.Succeeded) // 如果更新成功。
        { // 成功分支开始。
            Debug.Log("Remote catalog updated."); // 输出远端 Catalog 更新成功。
        } // 成功分支结束。
        else // 如果更新失败。
        { // 失败分支开始。
            Debug.LogWarning("Remote catalog update failed."); // 输出更新失败,项目里可继续使用缓存或本地目录。
        } // 失败分支结束。
        Addressables.Release(handle); // 释放更新句柄。
    } // 回调结束。
} // 类结束。

Unity 工程实践

TIP

项目里我会这样理解:Remote Catalog 是资源热更的入口文件。发资源更新时,不是只上传 Bundle,还必须上传新的 Catalog 和 hash。客户端启动时先检查 hash,如果 Catalog 有变化,就下载新 Catalog,再按新 Catalog 下载资源。

常见坑是:只把新的 AssetBundle 上传到 CDN,忘了上传 Catalog;或者更新 Catalog 时游戏里还有资源正在加载,导致加载流程混乱。比较稳的做法是在启动阶段、Loading 阶段或资源系统空闲时更新 Catalog。

Addressables Release 和 Debug 构建区别是什么?

unity-addressables-debug-vs-release-build

标准答案

Addressables 里说 Debug 和 Release 构建,通常不是指 C# 的 Debug/Release 编译开关,而是指资源加载链路的“开发调试模式”和“真实发布模式”。

Debug 更关注开发效率:常用 Use Asset DatabaseSimulate Groups,可以不真正打 AssetBundle,进 Play Mode 快。

Release 更关注线上一致性:要真实 Build Addressables Content,生成 CataloghashAssetBundle,并用正确的 BuildPath / LoadPath / RemoteLoadPath 去验证 CDN、热更、缓存、卸载和回滚。

底层原理

Addressables 的加载链路大概是:

c
address/key -> catalog -> ResourceLocation -> AssetBundle 路径 -> 下载/缓存 -> 加载资源

Debug 模式可能直接从编辑器 AssetDatabase 加载资源,所以很快,但它可能绕过真实 Bundle、远端路径、Catalog、hash、压缩、依赖关系。

Release 模式走真实 Catalog 和 Bundle,所以能暴露:

LoadPath 配错、Bundle 没上传、Catalog 没更新、hash 不一致、依赖重复、资源释放异常这些真实线上问题。

代码示例

c
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载 API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 异步句柄类型。

public sealed class AddressablesBuildModeExample : MonoBehaviour // 定义一个 Addressables 加载示例组件。
{ // 类开始。
    [SerializeField] private string key; // 保存 Addressables 资源地址或 key。
    private AsyncOperationHandle<GameObject> handle; // 保存加载句柄,后面用于释放资源。

    private void Start() // Unity 在对象启用后的第一帧前调用。
    { // Start 方法开始。
        Debug.Log(Application.isEditor ? "Editor Debug 路径" : "Player Release 路径"); // 简单区分当前运行环境。
        handle = Addressables.LoadAssetAsync<GameObject>(key); // 根据 Addressables key 异步加载资源。
        handle.Completed += OnLoaded; // 注册加载完成回调。
    } // Start 方法结束。

    private void OnLoaded(AsyncOperationHandle<GameObject> op) // 处理加载完成事件。
    { // 回调方法开始。
        Debug.Log(op.Status == AsyncOperationStatus.Succeeded ? "加载成功" : "加载失败"); // 输出加载结果。
    } // 回调方法结束。

    private void OnDestroy() // 当前组件销毁时调用。
    { // OnDestroy 方法开始。
        if (handle.IsValid()) // 判断句柄是否有效,避免重复释放或释放无效句柄。
        { // if 分支开始。
            Addressables.Release(handle); // 释放 Addressables 引用计数。
        } // if 分支结束。
    } // OnDestroy 方法结束。
} // 类结束。

Unity 工程实践

我一般会这样区分:开发阶段用 Debug Profile,本地路径、Use Asset DatabaseSimulate Groups,提高迭代速度;提测和上线前必须用 Release Profile,真实构建 Addressables Content,上传 Catalog、hash 和 Bundle 到 CDN,再用真机验证下载、缓存、加载、释放和回滚。

面试里可以补一句:只在 Editor Debug 模式测通过不代表线上没问题,因为它可能根本没走真实 AssetBundle 链路。

Addressables 加载失败如何降级?

unity-addressables-load-fallback

标准答案

Addressables 加载失败时,不能只 try/catch,而是要做“分层降级”:先判断失败原因,再决定是重试、切备用 CDN、使用缓存、加载默认资源、隐藏功能入口,还是回滚到上一版资源。

面试里可以这样答:

强依赖资源,比如登录界面、主界面、战斗核心资源,必须有内置兜底或上一版缓存;弱依赖资源,比如活动图、皮肤、特效、公告图,加载失败可以隐藏入口、显示默认图、降低画质,保证主流程不被阻塞。

底层原理

Addressables 加载链路大概是:

c
key -> catalog -> resource location -> bundle -> dependency -> asset

任何一步都可能失败,比如:

key 不存在、Catalog 没更新、Bundle 没上传、Hash 不匹配、CDN 超时、磁盘空间不足、依赖包丢失、资源被释放太早。

所以降级逻辑要放在统一资源加载层,而不是每个业务模块自己乱写。

项目里我会这样做

  1. 加载失败先记录 key、资源版本、Catalog 版本、URL、异常信息、设备和网络状态。
  2. 网络问题先限次重试,比如 2 到 3 次,使用退避间隔。
  3. CDN 问题可以切备用域名。
  4. Catalog 或热更资源异常,可以回退上一版可用 Manifest。
  5. 资源不是核心流程,就显示默认图或者隐藏入口。
  6. 核心资源失败,要弹窗提示重新下载或进入修复流程。
  7. 失败句柄要正确 Release,避免引用计数和内存泄漏。

代码示例

c
using System.Collections; // 引入协程需要的 IEnumerator。
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载接口。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 异步句柄类型。

public sealed class AddressablesFallbackLoader : MonoBehaviour // 定义一个安全加载 Addressables 的组件。
{ // 类开始。
    [SerializeField] private GameObject fallbackPrefab; // 保存加载失败时使用的默认预制体。
    public IEnumerator LoadPrefabOrFallback(string key, Transform parent) // 定义加载预制体并支持降级的协程。
    { // 协程开始。
        AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key); // 发起 Addressables 异步加载。
        yield return handle; // 等待 Addressables 加载完成。
        if (handle.Status == AsyncOperationStatus.Succeeded) // 判断资源是否加载成功。
        { // 成功分支开始。
            Instantiate(handle.Result, parent); // 实例化成功加载到的资源。
            Addressables.Release(handle); // 释放加载句柄,避免引用计数泄漏。
            yield break; // 结束协程。
        } // 成功分支结束。
        Debug.LogError($"Addressables 加载失败:{key},原因:{handle.OperationException}"); // 记录失败资源和异常信息。
        Addressables.Release(handle); // 失败时也释放句柄,避免残留引用。
        if (fallbackPrefab != null) // 判断是否配置了兜底资源。
        { // 兜底分支开始。
            Instantiate(fallbackPrefab, parent); // 实例化默认资源,避免界面空白或流程卡死。
        } // 兜底分支结束。
    } // 协程结束。
} // 类结束。

常见坑

最怕的是无限重试,把玩家卡在 Loading;或者失败后没有 Release,导致引用计数异常。更高级一点的回答是:线上要支持“上一版资源回滚”和“备用 CDN”,否则一次错误 Catalog 就可能让部分玩家进不去游戏。

Addressables 释放句柄有什么坑?

unity-addressables-release-handle-pitfalls

标准答案

Addressables 释放句柄最大的坑是:Release 不是“销毁资源”,而是“减少引用计数”。一次 LoadAssetAsync 通常就要对应一次 Addressables.Release(handle);如果是 Addressables.InstantiateAsync 创建出来的实例,通常要用 Addressables.ReleaseInstance(instance 或 handle)

最容易出问题的点有 5 个:

  1. 漏释放:资源和 AssetBundle 引用计数不归零,切场景后内存不降。
  2. 重复释放:同一个 handle 释放两次,可能导致无效句柄或引用计数异常。
  3. 过早释放:对象还在显示,资源句柄先释放,材质、贴图、依赖可能出问题。
  4. 接口用错:资源用 Release,Addressables 创建的实例用 ReleaseInstance
  5. 释放后继续使用:Release(handle) 后 handle 失效,不要再访问 handle.Result

底层原理

Addressables 内部靠引用计数管理资源和依赖包:

c
LoadAssetAsync -> 资源引用 +1 -> Bundle 引用 +1
Release(handle) -> 资源引用 -1 -> Bundle 引用 -1

只有当资源和依赖 Bundle 的引用计数都归零时,它们才有机会被卸载。但这也不等于内存立刻下降,因为 Unity 里可能还有对象、材质、贴图引用着它,或者底层卸载要等引擎时机。

代码示例

c
using System.Collections; // 引入 IEnumerator,用于协程等待异步加载。
using UnityEngine; // 引入 Unity 基础类型和 MonoBehaviour。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载和释放 API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 类型。
public sealed class AddressablesReleaseGuard : MonoBehaviour // 定义一个带释放保护的 Addressables 示例组件。
{ // 类开始。
    [SerializeField] private string prefabKey; // 配置要加载的 Addressables 预制体地址。
    private AsyncOperationHandle<GameObject> prefabHandle; // 保存资源加载句柄。
    private GameObject instance; // 保存实例化出来的对象。
    private bool hasHandle; // 记录当前是否真的拿到了句柄。
    private bool released; // 记录是否已经释放过,避免重复释放。
    private IEnumerator Start() // Unity 启动时执行协程。
    { // Start 方法开始。
        prefabHandle = Addressables.LoadAssetAsync<GameObject>(prefabKey); // 加载 Addressables 预制体资源。
        hasHandle = true; // 标记句柄已经被当前对象持有。
        yield return prefabHandle; // 等待异步加载完成。
        if (prefabHandle.Status != AsyncOperationStatus.Succeeded) // 判断加载是否失败。
        { // 失败分支开始。
            SafeRelease(); // 加载失败也要释放句柄。
            yield break; // 结束协程。
        } // 失败分支结束。
        instance = Instantiate(prefabHandle.Result, transform); // 加载成功后再实例化对象。
    } // Start 方法结束。
    private void OnDestroy() // 当前组件销毁时调用。
    { // OnDestroy 方法开始。
        SafeRelease(); // 统一释放资源,避免切场景泄漏。
    } // OnDestroy 方法结束。
    private void SafeRelease() // 定义安全释放方法。
    { // SafeRelease 方法开始。
        if (released) // 判断是否已经释放过。
        { // 已释放分支开始。
            return; // 防止重复释放同一个句柄。
        } // 已释放分支结束。
        released = true; // 先标记已释放,避免递归或重复调用。
        if (instance != null) // 判断实例是否还存在。
        { // 实例存在分支开始。
            Destroy(instance); // 先销毁实例对象。
            instance = null; // 清空实例引用。
        } // 实例存在分支结束。
        if (hasHandle && prefabHandle.IsValid()) // 判断句柄是否有效。
        { // 句柄有效分支开始。
            Addressables.Release(prefabHandle); // 释放资源加载句柄。
            hasHandle = false; // 标记当前不再持有句柄。
        } // 句柄有效分支结束。
    } // SafeRelease 方法结束。
} // 类结束。

Unity 工程实践

我通常会把 Addressables 封装在资源管理器里,而不是让业务层到处手动 Release。资源管理器记录:谁加载了什么、引用次数是多少、是否是实例对象、是否跨场景常驻。这样可以避免 UI 关闭、角色销毁、切场景时漏释放。

面试里可以补一句:我会用 Addressables Event Viewer、Profiler Memory、Memory Profiler 去验证引用计数和内存是否真的下降。

异步加载 UI 时如何防止重复打开?

unity-ui-async-open-deduplicate

标准答案

异步加载 UI 防止重复打开,核心是:所有打开请求都必须走 UIManager,用 windowKey 做唯一标识,并维护窗口状态。

常见状态可以分成:

Closed:没打开,可以加载。 Loading:正在异步加载,重复点击直接忽略或返回同一个请求。 Opened:已经打开,重复打开只置顶或刷新参数。 Closing:正在关闭,新的打开请求可以拒绝、排队,或等关闭结束再打开。

底层原理

重复打开通常发生在这里:

玩家连续点击按钮 -> 多次调用 Addressables.InstantiateAsync -> 多个异步请求同时回来 -> 创建多个 UI 实例 -> 重复注册事件 -> 关闭时释放混乱。

所以不能只靠按钮防抖,真正可靠的是“管理层幂等”:同一个 windowKey 同一时间只能有一个有效生命周期。

代码示例

c
using System.Collections; // 引入协程接口。
using System.Collections.Generic; // 引入 Dictionary 容器。
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.AddressableAssets; // 引入 Addressables API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入异步句柄类型。
public sealed class UIManager : MonoBehaviour // 定义 UI 管理器。
{ // 类开始。
    private enum UIState { Closed, Loading, Opened, Closing } // 定义 UI 生命周期状态。
    private sealed class UIRecord // 定义单个 UI 的运行记录。
    { // 记录类开始。
        public UIState State = UIState.Closed; // 保存当前 UI 状态。
        public int Version = 0; // 保存版本号,用来让过期异步回调失效。
        public GameObject Instance; // 保存已经创建出来的 UI 实例。
        public AsyncOperationHandle<GameObject> Handle; // 保存 Addressables 实例化句柄。
    } // 记录类结束。
    [SerializeField] private Transform uiRoot; // 保存 UI 父节点。
    private readonly Dictionary<string, UIRecord> records = new Dictionary<string, UIRecord>(); // 保存所有窗口记录。
    public void Open(string key) // 对外提供打开 UI 的统一入口。
    { // Open 方法开始。
        if (!records.TryGetValue(key, out UIRecord record)) // 如果这个窗口还没有记录。
        { // if 开始。
            record = new UIRecord(); // 创建一条新的窗口记录。
            records.Add(key, record); // 把记录放入字典。
        } // if 结束。
        if (record.State == UIState.Loading) // 如果窗口正在加载中。
        { // if 开始。
            return; // 直接返回,避免重复发起异步加载。
        } // if 结束。
        if (record.State == UIState.Opened) // 如果窗口已经打开。
        { // if 开始。
            record.Instance.transform.SetAsLastSibling(); // 把窗口置顶。
            return; // 不再创建新窗口。
        } // if 结束。
        if (record.State == UIState.Closing) // 如果窗口正在关闭。
        { // if 开始。
            return; // 简化处理为拒绝打开,也可以改成排队打开。
        } // if 结束。
        record.Version++; // 增加版本号,标记这是一次新的打开请求。
        StartCoroutine(OpenRoutine(key, record, record.Version)); // 启动异步加载协程。
    } // Open 方法结束。
    private IEnumerator OpenRoutine(string key, UIRecord record, int version) // 定义真正执行异步打开的协程。
    { // 协程开始。
        record.State = UIState.Loading; // 标记窗口进入加载中状态。
        record.Handle = Addressables.InstantiateAsync(key, uiRoot); // 通过 Addressables 异步创建 UI。
        yield return record.Handle; // 等待异步实例化完成。
        if (version != record.Version || record.State == UIState.Closing) // 判断这次回调是否已经过期。
        { // if 开始。
            if (record.Handle.Status == AsyncOperationStatus.Succeeded) // 如果实例已经创建成功。
            { // if 开始。
                Addressables.ReleaseInstance(record.Handle); // 释放过期实例,避免关闭后又冒出来。
            } // if 结束。
            record.State = UIState.Closed; // 状态回到关闭。
            yield break; // 结束协程。
        } // if 结束。
        if (record.Handle.Status != AsyncOperationStatus.Succeeded) // 如果加载失败。
        { // if 开始。
            record.State = UIState.Closed; // 状态回到关闭。
            yield break; // 结束协程。
        } // if 结束。
        record.Instance = record.Handle.Result; // 保存创建出来的 UI 实例。
        record.State = UIState.Opened; // 标记窗口已经打开。
    } // 协程结束。
    public void Close(string key) // 对外提供关闭 UI 的统一入口。
    { // Close 方法开始。
        if (!records.TryGetValue(key, out UIRecord record)) // 如果找不到窗口记录。
        { // if 开始。
            return; // 直接返回,保证关闭幂等。
        } // if 结束。
        record.Version++; // 增加版本号,让未完成的异步回调失效。
        if (record.State == UIState.Loading) // 如果窗口还在加载中。
        { // if 开始。
            record.State = UIState.Closing; // 标记为正在关闭,等加载回来后释放。
            return; // 暂时返回。
        } // if 结束。
        if (record.State != UIState.Opened) // 如果窗口不是打开状态。
        { // if 开始。
            return; // 防止重复关闭。
        } // if 结束。
        record.State = UIState.Closing; // 标记窗口进入关闭流程。
        Addressables.ReleaseInstance(record.Handle); // 释放实例和 Addressables 引用。
        record.Instance = null; // 清空实例引用。
        record.State = UIState.Closed; // 状态回到关闭。
    } // Close 方法结束。
} // 类结束。

Unity 工程实践

我会把重复打开防线分三层:按钮层做临时置灰,UIManager 层用 Dictionary 和状态机判重,异步回调层用 VersionCancellationToken 防止“关闭后又打开”。

面试里最加分的一句是:不要只靠按钮防连点,因为网络慢、切场景、关闭窗口、加载失败都会绕过按钮层,真正要靠 UIManager 的幂等和生命周期管理。

场景加载时如何预加载关键资源?

unity-resource-system-automated-tests

标准答案

场景加载时预加载关键资源,核心是:先根据场景配置拿到关键资源清单,再在 Loading 阶段提前下载依赖、加载资源、预热对象池,最后再激活场景

一般流程是:

进入 Loading -> 读预加载表 -> 下载 Addressables 依赖 -> 加载关键资源 -> 分帧预热对象池 -> 激活场景 -> 进入玩法

关键资源通常包括:主角、怪物、首屏 UI、战斗音效、常用特效、技能 Prefab、配置表、Shader Variant。

底层原理

预加载要分清三件事:

预下载:把远端 Bundle 下载到本地缓存,解决网络等待。 预加载:把资源从 Bundle 加载到内存,解决首次 Load 卡顿。 预热:提前实例化或初始化对象池,解决首次 Instantiate、材质初始化、音频首播卡顿。

不是所有资源都应该预加载。核心资源提前准备,非核心资源进场景后渐进加载,否则 Loading 阶段内存峰值会很高。

代码示例

c
using System.Collections; // 引入协程接口。
using System.Collections.Generic; // 引入 List 容器。
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.AddressableAssets; // 引入 Addressables API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 异步句柄。
using UnityEngine.SceneManagement; // 引入场景加载 API。
public sealed class ScenePreloadLoader : MonoBehaviour // 定义场景预加载器。
{ // 类开始。
    [SerializeField] private string sceneName = "Battle"; // 配置要进入的目标场景名。
    [SerializeField] private string preloadLabel = "preload_battle"; // 配置本场景关键资源 Label。
    private readonly List<AsyncOperationHandle> keptHandles = new List<AsyncOperationHandle>(); // 保存需要跨场景持有的资源句柄。
    public IEnumerator LoadSceneWithPreload() // 定义完整的加载流程。
    { // 协程开始。
        AsyncOperationHandle downloadHandle = Addressables.DownloadDependenciesAsync(preloadLabel); // 先下载关键资源依赖 Bundle。
        yield return downloadHandle; // 等待依赖下载完成。
        if (downloadHandle.Status != AsyncOperationStatus.Succeeded) // 判断下载是否失败。
        { // 下载失败分支开始。
            Debug.LogError("关键资源依赖下载失败"); // 打印错误日志。
            Addressables.Release(downloadHandle); // 释放下载操作句柄。
            yield break; // 终止加载流程。
        } // 下载失败分支结束。
        Addressables.Release(downloadHandle); // 下载完成后释放操作句柄,缓存文件仍可复用。
        AsyncOperationHandle<IList<Object>> loadHandle = Addressables.LoadAssetsAsync<Object>(preloadLabel, null); // 加载关键资源到内存。
        yield return loadHandle; // 等待关键资源加载完成。
        if (loadHandle.Status != AsyncOperationStatus.Succeeded) // 判断资源加载是否失败。
        { // 加载失败分支开始。
            Debug.LogError("关键资源加载失败"); // 打印错误日志。
            Addressables.Release(loadHandle); // 释放失败的加载句柄。
            yield break; // 终止加载流程。
        } // 加载失败分支结束。
        keptHandles.Add(loadHandle); // 保存加载句柄,等离开场景时统一释放。
        AsyncOperation sceneOp = SceneManager.LoadSceneAsync(sceneName); // 异步加载目标场景。
        sceneOp.allowSceneActivation = false; // 暂停场景激活,等待预热完成。
        while (sceneOp.progress < 0.9f) // 等待场景加载到可激活状态。
        { // 循环开始。
            yield return null; // 每帧等待一次,避免阻塞主线程。
        } // 循环结束。
        yield return WarmupByFrame(); // 分帧预热对象池或常用实例。
        sceneOp.allowSceneActivation = true; // 允许场景正式激活。
    } // 协程结束。
    private IEnumerator WarmupByFrame() // 定义分帧预热逻辑。
    { // 方法开始。
        yield return null; // 示例中让出一帧,真实项目里这里创建对象池。
    } // 方法结束。
    private void OnDestroy() // 预加载器销毁时调用。
    { // 方法开始。
        for (int i = 0; i < keptHandles.Count; i++) // 遍历所有被持有的资源句柄。
        { // 循环开始。
            if (keptHandles[i].IsValid()) // 判断句柄是否仍然有效。
            { // if 开始。
                Addressables.Release(keptHandles[i]); // 释放预加载资源引用。
            } // if 结束。
        } // 循环结束。
        keptHandles.Clear(); // 清空句柄列表。
    } // 方法结束。
} // 类结束。

Unity 工程实践

我会用配置表或 Addressables Label 管理每个场景的预加载资源,比如 preload_loginpreload_lobbypreload_battle_01。Loading 界面展示总进度,把下载进度、资源加载进度、场景加载进度合成一个进度条。

面试里可以强调一句:关键资源保证首屏体验,非关键资源进场景后异步补齐;预加载要受内存预算控制。

参考:Unity Addressables DownloadDependenciesAsync 文档、Unity AsyncOperation.allowSceneActivation 文档。

资源系统如何做自动化测试?

unity-resource-system-automated-tests-preview

标准答案

资源系统自动化测试要覆盖一整条链路:资源进工程是否合规、打包是否正确、依赖是否重复、加载是否成功、失败能否降级、释放后内存和引用是否回落

我会分 5 层做:

  1. 静态检查:命名、目录、贴图尺寸、压缩格式、Prefab Missing、脚本丢失。
  2. 构建检查:AssetBundle / Addressables Catalog 是否能构建成功。
  3. 依赖检查:重复资源、公共依赖、Bundle 冗余、包体突然变大。
  4. 运行测试:按 key 或 label 批量加载、实例化、释放。
  5. 性能测试:加载耗时、内存峰值、卸载后引用计数是否正常。

底层原理

资源系统的问题经常不是代码编译错误,而是“数据错误”:资源路径错、Label 漏配、Prefab 引用丢失、Bundle 依赖重复、Catalog 和 Hash 不一致。这些问题适合自动化,因为它们都有明确规则,可以在 CI 里提前拦截。

代码示例

c
using System.Collections; // 引入 IEnumerator,用于 UnityTest 协程测试。
using NUnit.Framework; // 引入 NUnit 断言 API。
using UnityEngine; // 引入 Unity 基础类型。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载 API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 异步句柄类型。
using UnityEngine.TestTools; // 引入 Unity Test Framework 的 UnityTest。
public sealed class ResourceSmokeTests // 定义资源系统冒烟测试类。
{ // 类开始。
    [UnityTest] // 标记这是一个可以等待异步流程的 Unity 测试。
    public IEnumerator KeyAsset_CanLoad_AndRelease() // 测试指定资源能加载并释放。
    { // 方法开始。
        string key = "ui/login.prefab"; // 定义要测试的 Addressables key。
        AsyncOperationHandle<UnityEngine.Object> handle = Addressables.LoadAssetAsync<UnityEngine.Object>(key); // 发起异步加载。
        yield return handle; // 等待加载完成。
        Assert.AreEqual(AsyncOperationStatus.Succeeded, handle.Status, $"资源加载失败:{key}"); // 断言加载必须成功。
        Assert.IsNotNull(handle.Result, $"资源结果为空:{key}"); // 断言加载结果不能为空。
        Addressables.Release(handle); // 释放加载句柄。
        Assert.IsFalse(handle.IsValid(), "释放后句柄应该失效"); // 断言释放后不应继续使用该句柄。
    } // 方法结束。
} // 类结束。

Unity 工程实践

NOTE

我会把这些测试接到 CI:提交资源后跑静态扫描;每日构建跑 Addressables 构建和 Analyze;提测前跑 PlayMode 冒烟测试;版本发布前在真机上跑关键场景加载、卸载、弱网和热更回滚测试。

面试里可以补一句:资源系统自动化测试的价值,是把“线上玩家发现资源坏了”变成“提交阶段或构建阶段就失败”。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0