Skip to content

资源加载

Resources 有什么问题?

一句话回答:Resources 的问题不是“完全不能用”,而是它太方便了,容易导致包体变大、路径脆弱、依赖不清楚、内存不好控、热更新不好做。小项目可以用,大项目一般不建议把它当主资源系统。

resources-folder-problems

零基础理解

你可以把 Resources 想成 Unity 给你的一个“万能仓库”。

只要资源放在:

c
Assets/Resources/

下面,就可以用代码直接加载:

c
Resources.Load<GameObject>("UI/BagPanel");

看起来很方便,但问题是:方便的代价是资源管理变粗糙

问题 1:Resources 里的资源容易进包,导致包体变大

比如你放了这些东西:

c
Assets/Resources/UI/BagPanel.prefab
Assets/Resources/UI/ShopPanel.prefab
Assets/Resources/Icon/Sword.png
Assets/Resources/Icon/BigRareIcon.png
Assets/Resources/Enemy/Boss.prefab

即使某些资源当前版本根本没用到,它们也很容易被打进包里。

结果就是:

首包变大
下载变慢
审核包体压力变大
后期资源拆分困难

面试可以说: Resources 会让资源边界不清晰,不利于首包控制和资源分包。

问题 2:Resources.Load 靠字符串路径,容易出错

c
using UnityEngine;

public class LoadUIExample : MonoBehaviour
{
    private void Start()
    {
        // 这里的路径不写 Assets/Resources,也不写后缀 .prefab
        // 实际资源路径是:Assets/Resources/UI/BagPanel.prefab
        GameObject prefab = Resources.Load<GameObject>("UI/BagPanel");

        if (prefab == null)
        {
            Debug.LogError("加载失败:路径可能写错,或者资源不在 Resources 文件夹下");
            return;
        }

        Instantiate(prefab);
    }
}

这个写法的问题是:

c
UI/BagPanel 是字符串
编译器不会检查它对不对
文件改名后,代码不会自动报错
运行到这里才发现 prefab == null

比如美术或你自己把文件改成:

c
BagWindow.prefab

但是代码还写:

c
Resources.Load<GameObject>("UI/BagPanel");

编译不会报错,运行时才出问题。

问题 3:依赖关系不直观

如果一个 Prefab 引用了:

图片
材质
字体
音效
动画
子 Prefab

你用 Resources.Load 加载这个 Prefab 时,它相关依赖也可能被带进来。

问题是你不容易一眼看清楚:

这个 UI 到底依赖了多少资源?
这些资源什么时候被加载?
什么时候能释放?
为什么内存突然涨了?

大型项目里,这会让资源分析和性能排查很难受。

问题 4:内存释放不好控制

很多新手以为:

Destroy(gameObject);

就等于资源完全释放了。

其实不是。

c
using UnityEngine;

public class ResourceMemoryExample : MonoBehaviour
{
    private GameObject currentPanel;

    public void OpenPanel()
    {
        // 加载 Prefab 资源本体
        GameObject prefab = Resources.Load<GameObject>("UI/BagPanel");

        // 实例化出一个界面对象
        currentPanel = Instantiate(prefab);
    }

    public void ClosePanel()
    {
        // Destroy 只销毁场景里的实例
        // Resources 加载出来的资源本体,不一定马上从内存里释放
        Destroy(currentPanel);

        // 这个方法会尝试卸载没有被使用的资源
        // 但它比较重,可能造成卡顿,不适合频繁调用
        Resources.UnloadUnusedAssets();
    }
}

重点记住:

Destroy 销毁的是实例
资源本体是否释放,要看是否还有引用
UnloadUnusedAssets 可以释放未使用资源,但开销较大

所以频繁打开关闭 UI 时,如果资源管理不好,就可能出现:

内存涨上去不下来
切界面卡顿
GC 或资源卸载造成掉帧

问题 5:不适合热更新和远程资源管理

商业游戏常常需要:

资源分包
远程下载
版本对比
按需更新
活动资源动态下发
不同渠道不同资源

Resources 对这些支持不够友好,因为它更像是“打包进 App 内部的资源”。

所以大项目常用:

Addressables
AssetBundle
自研资源管理系统

Addressables 更适合做:

异步加载
引用计数
资源分组
远程资源
依赖管理
资源卸载

那 Resources 什么时候可以用?

可以用在:

小 Demo
学习项目
编辑器工具
少量固定配置
少量永远内置的小资源
快速原型开发

不太建议用在:

大量 UI Prefab
大量图标
角色模型
关卡资源
活动资源
需要热更新的资源
频繁加载卸载的大资源

一个比较好的封装方式

即使用 Resources,也不要在业务代码里到处写字符串路径。可以封装一层。

c
using UnityEngine;

public static class ResourceLoader
{
    // 统一加载 Prefab
    public static T Load<T>(string path) where T : Object
    {
        T asset = Resources.Load<T>(path);

        if (asset == null)
        {
            Debug.LogError("Resources 加载失败,路径:" + path);
        }

        return asset;
    }

    // 统一实例化 Prefab
    public static T InstantiatePrefab<T>(string path, Transform parent = null) where T : Component
    {
        T prefab = Load<T>(path);

        if (prefab == null)
            return null;

        return Object.Instantiate(prefab, parent);
    }
}

使用:

c
public class OpenBagPanel : MonoBehaviour
{
    public void Open()
    {
        // 至少把加载入口统一起来,后面替换 Addressables 时更容易
        BagPanel panel = ResourceLoader.InstantiatePrefab<BagPanel>("UI/BagPanel");
    }
}

这样以后如果要从 Resources 换成 Addressables,不至于全项目到处改。

面试高分回答

NOTE

Resources 的优点是简单,资源放到 Resources 文件夹后可以通过字符串路径直接加载,适合 Demo、小项目或少量内置资源。但它的问题也很明显:第一,Resources 里的资源容易被打进包里,不利于控制包体;第二,加载路径是字符串,改名或移动资源后编译期发现不了;第三,资源依赖关系不透明,加载和卸载时机不好管理;第四,不适合分包、热更新和远程资源管理。实际项目里我会尽量把它封装起来,避免业务层直接调用,大型项目优先考虑 Addressables 或 AssetBundle。”

最短记忆版

Resources 优点:
简单,直接 Resources.Load。

Resources 问题:
包体容易变大;
字符串路径容易错;
依赖关系不透明;
内存卸载不好控;
不适合热更新和分包。

项目建议:
小项目可以用;
大项目封装后少用;
主资源系统优先 Addressables / AssetBundle。

AssetBundle 是什么?

一句话回答:AssetBundle 是 Unity 的一种资源打包格式和运行时加载机制。它可以把 Prefab、贴图、音效、动画、材质等资源打成独立包,让游戏在运行时从本地或服务器加载,实现分包、按需加载、热更新、降低首包大小

assetbundle-explained

零基础理解

你可以这样理解:

Resources:
资源提前放进游戏包里,运行时直接从包内加载。

AssetBundle:
先把资源打成一个个独立压缩包,运行时需要哪个就加载哪个。

比如一个游戏有:

主城资源
战斗资源
角色皮肤
活动资源
语音包
节日 UI

如果全部放进主包,安装包会很大。 用 AssetBundle 后,可以这样做:

首包只放基础资源
进入主城时加载主城 Bundle
进入战斗时加载战斗 Bundle
活动开启时下载活动 Bundle
活动结束后可以删除或不再加载

AssetBundle 解决什么问题

它主要解决四类问题:

1. 首包过大
2. 资源需要按需加载
3. 资源需要热更新
4. 不同模块资源要拆分管理

比如手游里常见的“进入游戏后下载资源”“活动更新不用重新下载完整安装包”,底层就可能用 AssetBundle 或基于它封装的资源系统。

最简单的打包代码

这个代码一般放在 Editor 文件夹下,因为打包只在编辑器里做。

c
using UnityEditor;
using UnityEngine;

public class AssetBundleBuilder
{
    [MenuItem("Tools/Build AssetBundles")]
    public static void Build()
    {
        // AssetBundle 输出目录
        string outputPath = "AssetBundles";

        // 如果目录不存在,就创建目录
        if (!System.IO.Directory.Exists(outputPath))
        {
            System.IO.Directory.CreateDirectory(outputPath);
        }

        // 构建 AssetBundle
        // BuildTarget 要和目标平台一致,比如 Android、iOS、StandaloneWindows
        BuildPipeline.BuildAssetBundles(
            outputPath,
            BuildAssetBundleOptions.None,
            BuildTarget.StandaloneWindows
        );

        Debug.Log("AssetBundle 打包完成");
    }
}

然后你需要在 Unity Inspector 里给资源设置 AssetBundle Name。 比如:

c
BagPanel.prefab -> ui/bagpanel
Sword.png       -> icon/sword

打包后会生成对应的 bundle 文件。

运行时加载 AssetBundle

c
using UnityEngine;

public class AssetBundleLoadExample : MonoBehaviour
{
    private AssetBundle bundle;
    private GameObject panelInstance;

    private void Start()
    {
        // AssetBundle 文件路径
        string path = Application.streamingAssetsPath + "/ui/bagpanel";

        // 从本地文件加载 AssetBundle
        bundle = AssetBundle.LoadFromFile(path);

        if (bundle == null)
        {
            Debug.LogError("AssetBundle 加载失败:" + path);
            return;
        }

        // 从 Bundle 里加载具体资源
        GameObject prefab = bundle.LoadAsset<GameObject>("BagPanel");

        if (prefab == null)
        {
            Debug.LogError("Bundle 中找不到 BagPanel");
            return;
        }

        // 实例化 UI
        panelInstance = Instantiate(prefab);
    }

    private void OnDestroy()
    {
        // 销毁实例
        if (panelInstance != null)
        {
            Destroy(panelInstance);
        }

        // 卸载 AssetBundle
        // false 表示只卸载 Bundle 文件本身,不强制卸载已经加载出来的资源对象
        if (bundle != null)
        {
            bundle.Unload(false);
        }
    }
}

Unload(false)Unload(true) 区别

这个很容易考。

c
bundle.Unload(false);

意思是:

c
卸载 AssetBundle 包本身
但已经 LoadAsset 出来的资源还可以继续用

比如 Prefab 已经加载出来并实例化了,场景里的对象不会马上消失。

c
bundle.Unload(true);

意思是:

卸载 AssetBundle 包本身
同时卸载从这个 Bundle 加载出来的资源

如果资源还在被场景对象使用,可能导致材质、贴图、Prefab 引用丢失。 所以实际项目里不能乱用 Unload(true)

依赖问题是 AssetBundle 的重点

比如:

c
Knight.prefab
依赖 Knight.mat
Knight.mat 依赖 Knight.png

如果你把它们打进不同 Bundle:

c
role_knight.ab
role_material.ab
role_texture.ab

加载角色时,不能只加载 role_knight.ab。 还要先加载它依赖的材质包和贴图包。

否则可能出现:

角色模型出来了
材质丢了
贴图变粉
动画引用丢失

面试可以说: AssetBundle 真正麻烦的地方不只是加载,而是依赖、版本、缓存、卸载和引用计数管理。

AssetBundle 和 Resources 的区别

Resources:
简单,直接 Resources.Load。
缺点是资源容易进包,路径字符串脆弱,不适合热更新。

AssetBundle:
资源可以独立打包、下载、更新、卸载。
缺点是管理复杂,需要处理依赖和版本。

简单说:

Resources 适合小项目和 Demo。
AssetBundle 适合正式项目的资源管理和热更新。

AssetBundle 和 Addressables 的关系

Addressables 可以理解成 Unity 官方在 AssetBundle 上层封装的一套资源管理系统。

它帮你处理很多麻烦事:

地址管理
异步加载
依赖加载
引用计数
远程资源
资源释放
分组打包

所以现在项目里常见说法是:

底层可能还是 AssetBundle
上层用 Addressables 或自研资源系统管理

面试高分回答

TIP

“AssetBundle 是 Unity 用来把资源独立打包并在运行时加载的机制。它可以把 Prefab、贴图、音效、动画等资源从主包中拆出来,放在本地或服务器上,客户端需要时再加载,所以常用于降低首包大小、资源分包、按需加载和热更新。相比 Resources,它更灵活,但复杂度也更高,需要处理 Bundle 之间的依赖、版本清单、下载缓存、引用计数和卸载时机。实际项目里一般不会直接到处写 AssetBundle.Load,而是封装成资源管理器,或者使用 Addressables。”

最短记忆版

c
AssetBundle 是资源包。

作用:
分包、按需加载、热更新、降低首包。

流程:
资源 -> 打 Bundle -> 放本地或服务器 -> 运行时加载 -> 用完卸载。

难点:
依赖、版本、缓存、引用计数、卸载时机。

和 Resources 区别:
Resources 简单但粗糙;
AssetBundle 灵活但要管理。

Addressables 是什么?

一句话回答:Addressables 是 Unity 官方的资源管理系统,可以让你用“地址”加载资源,并帮你管理异步加载、依赖、AssetBundle、远程资源、缓存、引用释放。它可以理解成:AssetBundle 的上层封装

addressables-explained

零基础理解

以前你用 Resources

c
Resources.Load<GameObject>("UI/BagPanel");

问题是资源必须放在 Resources 文件夹里,包体和内存都不好管理。

AssetBundle

你自己打包
你自己加载依赖
你自己管理版本
你自己卸载

比较灵活,但很麻烦。

Addressables 就是 Unity 帮你把这些麻烦事封装起来:

你只管说:我要加载 "UI/BagPanel"
它负责找资源在哪里、依赖哪些包、怎么异步加载、什么时候释放。

底层原理怎么说

Addressables 不是凭空变魔法,它大概由几块组成:

Address:
资源的逻辑地址,比如 "UI/BagPanel"。

Catalog:
地址表,记录 address 对应哪个资源、在哪个位置、依赖哪些东西。

Provider:
真正执行加载的模块,比如从本地加载、从远程下载、从 AssetBundle 加载。

AsyncOperationHandle:
异步加载句柄,里面有状态、结果、进度,也参与引用计数。

AssetBundle:
很多 Addressables 项目底层仍然是把资源打成 AssetBundle。

所以面试里可以说: Addressables 是基于地址和 Catalog 的资源定位系统,上层统一 API,底层可以使用 AssetBundle 实现资源分包和远程加载。

最常见加载代码

c
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesLoadExample : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> handle;
    private GameObject prefabInstance;

    private void Start()
    {
        // 异步加载 Address 为 "UI/BagPanel" 的资源
        handle = Addressables.LoadAssetAsync<GameObject>("UI/BagPanel");

        // 加载完成后回调
        handle.Completed += OnLoadCompleted;
    }

    private void OnLoadCompleted(AsyncOperationHandle<GameObject> operation)
    {
        // 判断加载是否成功
        if (operation.Status != AsyncOperationStatus.Succeeded)
        {
            Debug.LogError("Addressables 加载失败");
            return;
        }

        // operation.Result 就是加载出来的 Prefab
        GameObject prefab = operation.Result;

        // 实例化界面
        prefabInstance = Instantiate(prefab);
    }

    private void OnDestroy()
    {
        // 销毁实例对象
        if (prefabInstance != null)
        {
            Destroy(prefabInstance);
        }

        // 释放 Addressables 句柄
        // 这一步很重要,不释放会导致引用计数不下降
        if (handle.IsValid())
        {
            Addressables.Release(handle);
        }
    }
}

更推荐的实例化写法

如果你是加载并实例化 Prefab,可以直接用 InstantiateAsync

c
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesInstantiateExample : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> instanceHandle;

    private void Start()
    {
        // 直接异步实例化 Addressables 资源
        instanceHandle = Addressables.InstantiateAsync("Enemy/Goblin", transform);

        // 注册加载完成回调
        instanceHandle.Completed += OnInstanceCreated;
    }

    private void OnInstanceCreated(AsyncOperationHandle<GameObject> operation)
    {
        if (operation.Status == AsyncOperationStatus.Succeeded)
        {
            Debug.Log("敌人创建成功:" + operation.Result.name);
        }
    }

    private void OnDestroy()
    {
        // 用 InstantiateAsync 创建的对象,建议用 ReleaseInstance 释放
        if (instanceHandle.IsValid())
        {
            Addressables.ReleaseInstance(instanceHandle);
        }
    }
}

Addressables 解决了什么问题

c
1. 比 Resources 更适合大型项目
资源不一定要放 Resources,能更清楚地分组和管理。

2. 比手写 AssetBundle 省事
依赖、异步、远程、本地缓存、引用释放都有统一 API。

3. 支持按需加载
进入战斗再加载战斗资源,打开商城再加载商城资源。

4. 支持远程资源
资源可以放 CDN,需要时下载。

5. 支持 Label
可以按标签批量加载,比如 preload、battle、ui_common。

预下载资源怎么写

比如进入战斗前,先下载 battle 标签下的资源。

c
using System.Collections;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesDownloadExample : MonoBehaviour
{
    private IEnumerator Start()
    {
        string label = "battle";

        // 先查询这个标签下的资源需要下载多少字节
        AsyncOperationHandle<long> sizeHandle =
            Addressables.GetDownloadSizeAsync(label);

        yield return sizeHandle;

        long downloadSize = sizeHandle.Result;

        // 查询完成后释放查询句柄
        Addressables.Release(sizeHandle);

        if (downloadSize > 0)
        {
            Debug.Log("需要下载资源大小:" + downloadSize);

            // 下载这个标签下的依赖资源
            AsyncOperationHandle downloadHandle =
                Addressables.DownloadDependenciesAsync(label);

            while (!downloadHandle.IsDone)
            {
                // PercentComplete 可以用来更新进度条
                Debug.Log("下载进度:" + downloadHandle.PercentComplete);
                yield return null;
            }

            if (downloadHandle.Status == AsyncOperationStatus.Succeeded)
            {
                Debug.Log("战斗资源下载完成");
            }

            // 下载完成后释放句柄
            Addressables.Release(downloadHandle);
        }
    }
}

最容易踩的坑

第一个坑:加载了不释放。

c
LoadAssetAsync 后要 Addressables.Release(handle)
InstantiateAsync 后要 Addressables.ReleaseInstance(handle)

如果不释放,引用计数不降,资源可能一直留在内存里。

第二个坑:以为 Addressables 能更新代码。

不能。 Addressables 主要管资源更新,比如:

Prefab
图片
音效
动画
材质
配置文件

C# 代码热更新不是它负责的。

第三个坑:分组不合理。

分太细:Bundle 太多,请求变多,管理复杂。
分太粗:下载一个小资源,带下一整个大包。

实际项目要按模块分,比如:

c
ui_common
ui_bag
battle_common
role_knight
activity_summer
audio_chinese

面试高分回答

IMPORTANT

“Addressables 是 Unity 官方的可寻址资源系统,它通过 Address 和 Catalog 把资源的逻辑地址和真实位置解耦。业务层只需要通过地址或标签加载资源,不需要关心资源在本地、远程还是哪个 AssetBundle 里。它底层通常基于 AssetBundle,并封装了异步加载、依赖加载、远程下载、缓存和引用释放。相比 Resources,它更适合大型项目和热更新;相比直接使用 AssetBundle,它减少了手动管理依赖、版本和卸载的复杂度。但使用时要注意 Release,否则引用计数不下降,资源可能无法释放。”

最短记忆版

c
Addressables = 更好用的资源管理系统。

核心:
用地址加载资源。

底层:
Catalog 找位置,AssetBundle 存资源,Handle 管异步和引用。

优点:
异步、依赖、远程、分包、缓存、释放统一管理。

坑:
加载后要 Release;
不能更新 C# 代码;
分组要设计好。

参考:Unity 官方文档 Addressables packageLoading Addressable assetsRemote content distribution

同步加载和异步加载区别是什么?

一句话回答: 同步加载是“我现在就要资源,没加载完代码不往下走”;异步加载是“我先发起加载请求,游戏继续运行,加载完成后再通知我使用结果”。

sync-vs-async-loading

零基础理解

同步加载像你去窗口买票:

你站在那里等
票没拿到,你不能走
后面事情也不能做

异步加载像你手机点外卖:

下单以后你可以继续玩手机
外卖到了再通知你

放到 Unity 里:

同步加载:
资源没加载完,当前代码卡住,主线程可能卡顿。

异步加载:
先开始加载,游戏还能继续刷新帧,加载完成后再使用资源。

同步加载代码

比如用 Resources.Load

c
using UnityEngine;

public class SyncLoadExample : MonoBehaviour
{
    private void Start()
    {
        // 同步加载:这行代码会一直等到资源加载完成
        // 如果资源很大,或者设备比较慢,这里可能造成卡顿
        GameObject prefab = Resources.Load<GameObject>("UI/BagPanel");

        if (prefab == null)
        {
            Debug.LogError("加载失败");
            return;
        }

        // 资源加载完以后,代码才会继续执行到这里
        Instantiate(prefab);
    }
}

这段代码的特点是:

写起来简单
逻辑很直观
但是加载大资源时容易卡住画面

比如你打开背包时同步加载一个很大的 UI,玩家可能会感觉:

点了按钮
游戏卡一下
背包才弹出来

异步加载代码

比如用 Resources.LoadAsync

c
using System.Collections;
using UnityEngine;

public class AsyncLoadExample : MonoBehaviour
{
    private IEnumerator Start()
    {
        // 异步加载:发起加载请求
        ResourceRequest request = Resources.LoadAsync<GameObject>("UI/BagPanel");

        // 加载没完成之前,每帧回来继续等
        while (!request.isDone)
        {
            // 这里可以更新 Loading 进度条
            Debug.Log("加载进度:" + request.progress);

            // yield return null 表示等下一帧再继续检查
            yield return null;
        }

        // 加载完成后,asset 才有结果
        GameObject prefab = request.asset as GameObject;

        if (prefab == null)
        {
            Debug.LogError("异步加载失败");
            yield break;
        }

        // 资源加载完成后再实例化
        Instantiate(prefab);
    }
}

异步加载的特点是:

不会长时间卡在同一行代码
可以显示 Loading 动画和进度条
适合加载场景、大 UI、大模型、大贴图、远程资源

用 Addressables 的异步加载

正式项目更常见的是 Addressables

c
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesAsyncLoadExample : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> handle;
    private GameObject instance;

    private void Start()
    {
        // 异步加载 Address 为 "UI/BagPanel" 的资源
        handle = Addressables.LoadAssetAsync<GameObject>("UI/BagPanel");

        // 加载完成后执行回调
        handle.Completed += OnLoaded;
    }

    private void OnLoaded(AsyncOperationHandle<GameObject> operation)
    {
        if (operation.Status != AsyncOperationStatus.Succeeded)
        {
            Debug.LogError("Addressables 加载失败");
            return;
        }

        // operation.Result 是加载完成的资源
        GameObject prefab = operation.Result;

        // 实例化仍然是在主线程使用 Unity 对象
        instance = Instantiate(prefab);
    }

    private void OnDestroy()
    {
        if (instance != null)
        {
            Destroy(instance);
        }

        // Addressables 加载后要释放句柄
        // 否则引用计数不下降,资源可能无法卸载
        if (handle.IsValid())
        {
            Addressables.Release(handle);
        }
    }
}

重要区别 1:是否阻塞当前流程

同步加载:

加载开始
等待
等待
等待
加载完成
继续执行

异步加载:

加载开始
这一帧继续结束
下一帧继续检查
游戏还能刷新
加载完成后回调或继续协程

所以面试里可以说: 同步加载会阻塞调用线程,Unity 中通常就是主线程;异步加载不会让当前帧一直等资源完成,可以把加载过程分摊到后续帧或后台流程。

重要区别 2:代码复杂度不同

同步加载简单:

c
GameObject prefab = Resources.Load<GameObject>("UI/BagPanel");
Instantiate(prefab);

异步加载要考虑:

加载中状态
加载完成回调
加载失败处理
进度条
取消或关闭界面后的处理
资源释放

比如玩家打开界面后马上关闭,如果异步加载还没完成,你要防止加载完成后又把 UI 创建出来。

c
using System.Collections;
using UnityEngine;

public class SafeAsyncPanelLoader : MonoBehaviour
{
    private bool isClosed;

    public void Close()
    {
        // 标记界面已经关闭
        isClosed = true;
    }

    private IEnumerator LoadPanel()
    {
        ResourceRequest request = Resources.LoadAsync<GameObject>("UI/BagPanel");

        yield return request;

        // 如果加载完成前界面已经关闭,就不要再创建 UI
        if (isClosed)
        {
            yield break;
        }

        GameObject prefab = request.asset as GameObject;

        if (prefab != null)
        {
            Instantiate(prefab);
        }
    }
}

重要区别 3:异步不等于一定开了子线程

这是面试加分点。

很多新手会说:

异步加载就是开子线程加载。

这个说法太绝对。

更准确的说法是:

异步表示调用方不用一直阻塞等待结果。
Unity 内部可能分帧处理,也可能有后台 IO 或后台解压流程。
但 Unity 的 GameObject、Transform、UI 等对象,通常仍然要在主线程访问。

所以不能这样理解:

异步 = 可以在子线程随便 Instantiate
异步 = 可以在子线程改 transform
异步 = 可以在子线程刷新 UI

这些都不对。

什么时候用同步加载

适合:

非常小的资源
启动时必须马上拿到的配置
编辑器工具
Demo
不会造成明显卡顿的资源

比如:

c
// 小图标、小配置、简单 Demo 可以同步加载
TextAsset config = Resources.Load<TextAsset>("Config/LocalConfig");

但正式游戏里,大资源尽量不要在战斗中同步加载。

什么时候用异步加载

适合:

加载场景
打开大型 UI
加载角色模型
加载高清贴图
加载音频
下载远程资源
进战斗前预加载

比如切场景:

c
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneLoadExample : MonoBehaviour
{
    public IEnumerator LoadBattleScene()
    {
        // 异步加载战斗场景
        AsyncOperation operation = SceneManager.LoadSceneAsync("BattleScene");

        while (!operation.isDone)
        {
            // 这里可以更新进度条
            Debug.Log("场景加载进度:" + operation.progress);

            yield return null;
        }

        Debug.Log("战斗场景加载完成");
    }
}

常见坑

第一个坑:异步结果没完成就使用。

c
ResourceRequest request = Resources.LoadAsync<GameObject>("UI/BagPanel");

// 错误:这里资源可能还没加载完
GameObject prefab = request.asset as GameObject;

正确做法是:

c
yield return request;
GameObject prefab = request.asset as GameObject;

第二个坑:异步加载完成时,界面已经关闭。 所以要有状态判断,避免“玩家关了界面,加载完又自动弹出来”。

第三个坑:加载完成后忘记释放。 尤其是 Addressables:

c
LoadAssetAsync -> Addressables.Release
InstantiateAsync -> Addressables.ReleaseInstance

第四个坑:异步加载也可能卡。 异步能减少卡顿,但如果你加载完成后一口气 Instantiate 1000 个对象,仍然可能卡。 所以还要配合:

对象池
分帧实例化
预加载
进度条
资源分组

面试高分回答

IMPORTANT

“同步加载是调用后立即等待结果,资源没加载完代码不会继续执行,在 Unity 里如果发生在主线程,就可能导致画面卡顿。异步加载是发起加载请求后立即返回,后续通过协程、回调或 AsyncOperationHandle 获取完成结果,加载期间可以继续刷新 UI、播放动画和显示进度条。异步并不等于可以随便在子线程操作 Unity 对象,很多 Unity API 仍然要求在主线程使用。实际项目中,小资源或初始化必要资源可以同步加载,大资源、场景、远程资源和 Addressables 资源通常用异步加载,并且要处理加载失败、界面关闭、进度显示和资源释放。”

最短记忆版

c
同步加载:
等资源加载完才继续。
简单,但可能卡主线程。

异步加载:
先发起请求,游戏继续跑。
完成后回调、协程或 await 使用结果。

异步不等于随便开子线程操作 Unity。
GameObject、Transform、UI 仍然主要在主线程处理。

项目建议:
小资源可以同步;
大资源、场景、远程资源用异步。

资源依赖怎么管理?

一句话回答: 资源依赖管理就是:知道谁依赖谁,打包时别重复,加载时先加载依赖,释放时别提前卸载还在用的资源

resource-dependency-management

零基础理解

一个 Prefab 看起来只是一个文件,但它背后可能依赖很多资源:

c
Knight.prefab
-> Knight.mat
-> Knight_Diffuse.png
-> Lit.shader
-> Run.anim
-> Sword.prefab
-> UINameFont.asset

所以你加载“角色 Prefab”时,不是只加载一个 Prefab,而是要保证它依赖的材质、贴图、动画、Shader 都能找到。

如果依赖没处理好,会出现:

模型变粉
贴图丢失
字体丢失
动画不播放
Bundle 重复变大
内存卸不下来

底层原理

Unity 资源之间的引用,本质上不是靠文件名,而是靠序列化引用。编辑器里资源通常通过:

c
GUID
fileID

来记录引用关系。

比如一个 Prefab 里引用了一个材质,材质又引用了贴图。Unity 在构建资源包时会根据这些引用形成依赖图。

所以资源依赖分两种:

c
直接依赖:
Prefab 直接引用 Material。

间接依赖:
Prefab -> Material -> Texture。

面试里说出“直接依赖和间接依赖”,会比只说“先加载依赖”更像真的懂。

编辑器里怎么查依赖

c
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;

public class DependencyChecker
{
    [MenuItem("Tools/Check Selected Asset Dependencies")]
    public static void CheckDependencies()
    {
        // 获取当前在 Project 面板选中的资源路径
        string path = AssetDatabase.GetAssetPath(Selection.activeObject);

        if (string.IsNullOrEmpty(path))
        {
            Debug.LogWarning("请先选择一个资源");
            return;
        }

        // true 表示递归查找依赖,也就是把间接依赖也查出来
        string[] dependencies = AssetDatabase.GetDependencies(path, true);

        Debug.Log("资源:" + path + " 的依赖如下:");

        foreach (string dependency in dependencies)
        {
            Debug.Log(dependency);
        }
    }
}
#endif

这个工具能帮助你看清:

一个 UI 依赖了哪些图片
一个角色依赖了哪些材质和动画
一个场景依赖了多少资源
哪些资源可能被重复打包

AssetBundle 里怎么管理依赖

如果你直接用 AssetBundle,依赖通常靠 AssetBundleManifest 管理。

核心流程是:

1. 读取 Manifest
2. 加载目标 Bundle 前,先加载它的所有依赖 Bundle
3. 用引用计数记录每个 Bundle 被使用几次
4. 关闭资源时先减引用计数
5. 引用计数为 0 时再卸载

示例代码:

c
using System.Collections.Generic;
using System.IO;
using UnityEngine;

public class SimpleBundleManager
{
    private readonly string bundleRootPath;
    private readonly AssetBundleManifest manifest;

    // 已加载的 Bundle
    private readonly Dictionary<string, AssetBundle> loadedBundles = new();

    // 每个 Bundle 当前被引用的次数
    private readonly Dictionary<string, int> refCounts = new();

    public SimpleBundleManager(string rootPath, AssetBundleManifest assetBundleManifest)
    {
        bundleRootPath = rootPath;
        manifest = assetBundleManifest;
    }

    public AssetBundle LoadBundleWithDependencies(string bundleName)
    {
        // 先加载依赖包
        string[] dependencies = manifest.GetAllDependencies(bundleName);

        foreach (string dependency in dependencies)
        {
            LoadSingleBundle(dependency);
        }

        // 再加载目标包
        return LoadSingleBundle(bundleName);
    }

    private AssetBundle LoadSingleBundle(string bundleName)
    {
        // 如果已经加载过,只增加引用计数
        if (loadedBundles.TryGetValue(bundleName, out AssetBundle loadedBundle))
        {
            refCounts[bundleName]++;
            return loadedBundle;
        }

        string fullPath = Path.Combine(bundleRootPath, bundleName);

        // 从本地文件加载 Bundle
        AssetBundle bundle = AssetBundle.LoadFromFile(fullPath);

        if (bundle == null)
        {
            Debug.LogError("Bundle 加载失败:" + fullPath);
            return null;
        }

        loadedBundles[bundleName] = bundle;
        refCounts[bundleName] = 1;

        return bundle;
    }

    public void UnloadBundleWithDependencies(string bundleName)
    {
        // 先卸载目标包
        UnloadSingleBundle(bundleName);

        // 再卸载依赖包
        string[] dependencies = manifest.GetAllDependencies(bundleName);

        foreach (string dependency in dependencies)
        {
            UnloadSingleBundle(dependency);
        }
    }

    private void UnloadSingleBundle(string bundleName)
    {
        if (!refCounts.ContainsKey(bundleName))
            return;

        refCounts[bundleName]--;

        // 还有其他资源在用这个 Bundle,不能卸载
        if (refCounts[bundleName] > 0)
            return;

        if (loadedBundles.TryGetValue(bundleName, out AssetBundle bundle))
        {
            // false 表示卸载 Bundle 文件本身
            // 不强制卸载已经 LoadAsset 出来的资源对象
            bundle.Unload(false);
        }

        loadedBundles.Remove(bundleName);
        refCounts.Remove(bundleName);
    }
}

重点是引用计数。

比如:

BagPanel.ab 依赖 ui_common.ab
ShopPanel.ab 也依赖 ui_common.ab

你打开背包和商城时:

ui_common.ab 引用计数 = 2

关闭背包时:

ui_common.ab 引用计数 = 1
不能卸载

关闭商城后:

c
ui_common.ab 引用计数 = 0
这时才能卸载

Addressables 里怎么管理依赖

如果用 Addressables,依赖加载通常由系统帮你处理。你加载一个 Address,Addressables 会根据 Catalog 找到资源和依赖。

c
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesDependencyExample : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> handle;
    private GameObject instance;

    private void Start()
    {
        // 加载角色 Prefab 时,它依赖的材质、贴图、动画等资源
        // Addressables 会根据 Catalog 自动处理依赖
        handle = Addressables.LoadAssetAsync<GameObject>("Role/Knight");

        handle.Completed += OnLoaded;
    }

    private void OnLoaded(AsyncOperationHandle<GameObject> operation)
    {
        if (operation.Status != AsyncOperationStatus.Succeeded)
        {
            Debug.LogError("角色资源加载失败");
            return;
        }

        // 资源和依赖加载完成后,才能安全实例化
        instance = Instantiate(operation.Result);
    }

    private void OnDestroy()
    {
        if (instance != null)
        {
            Destroy(instance);
        }

        // Addressables 必须 Release
        // 不释放的话,内部引用计数不会下降,资源可能一直留在内存中
        if (handle.IsValid())
        {
            Addressables.Release(handle);
        }
    }
}

Addressables 帮你做了很多事,但你仍然要记住:

c
Load 后要 Release
InstantiateAsync 后要 ReleaseInstance
Label 和 Group 要设计合理

分包策略怎么设计

常见做法:

c
公共资源:
common_ui、common_shader、common_audio。

模块资源:
bag_ui、shop_ui、battle、main_city。

角色资源:
role_knight、role_archer、role_boss_01。

活动资源:
activity_summer、activity_new_year。

重点是:多个模块都会用的资源,尽量抽到公共包

否则可能出现:

c
BagPanel.ab 里有 icon_common.png
ShopPanel.ab 里也有 icon_common.png

这就会导致同一张图被重复打包,包体和内存都浪费。

常见坑

c
1. 只加载目标 Bundle,不加载依赖 Bundle。
结果:资源丢失、材质异常、贴图变粉。

2. 公共资源没有单独分包。
结果:多个 Bundle 重复包含同一资源。

3. 关闭一个界面就把公共依赖卸了。
结果:另一个界面还在用,显示异常。

4. 只管 Load,不管 Release。
结果:内存越来越高。

5. 分包太细。
结果:Bundle 数量太多,请求太多。

6. 分包太粗。
结果:只想加载一个小 UI,却下载一整个大包。

面试高分回答

CAUTION

“资源依赖管理首先要搞清楚资源之间的引用关系,比如 Prefab 依赖材质,材质依赖贴图,这里面有直接依赖和间接依赖。使用 AssetBundle 时,我会通过 AssetBundleManifest 查询目标 Bundle 的依赖,加载时先加载依赖包,再加载目标包;卸载时使用引用计数,确保公共依赖没有被其他资源使用后再释放。打包时会把公共资源抽到 common 包,避免多个 Bundle 重复包含同一贴图或材质。如果使用 Addressables,依赖加载和引用计数系统会帮我们处理一部分,但业务层仍然要正确 Release,并且要合理设计 Group 和 Label。”

最短记忆版

c
资源依赖管理五件事:

查依赖:
知道 Prefab、材质、贴图、动画之间的关系。

分公共包:
多个模块共用的资源放 common。

按顺序加载:
先依赖,后目标。

引用计数释放:
还有人用就不能卸。

避免重复打包:
同一资源不要被多个 Bundle 各打一份。

如何避免重复打包?

一句话回答: 避免重复打包的核心是:共享资源不要跟着多个业务资源各打一份,而要抽到公共包里,让业务包依赖公共包。

avoid-duplicate-packing

零基础理解

比如你有两个 UI:

c
背包界面 BagPanel
商城界面 ShopPanel

它们都用了同一张图:

c
common_icon.png

如果分包没设计好,可能变成:

c
bag_ui.ab  里面有 common_icon.png
shop_ui.ab 里面也有 common_icon.png

这就叫重复打包。

结果是:

包体变大
下载资源变多
内存可能浪费
更新时也更麻烦

正确做法是:

c
bag_ui.ab       只放背包自己的资源
shop_ui.ab      只放商城自己的资源
common_ui.ab    放公共图标、公共字体、公共材质

然后:

c
bag_ui.ab 依赖 common_ui.ab
shop_ui.ab 依赖 common_ui.ab

为什么会重复打包

通常是因为:

多个 Bundle 引用了同一个资源
但这个共享资源没有被单独打成公共包

比如:

c
BagPanel.prefab -> common_icon.png
ShopPanel.prefab -> common_icon.png

如果 common_icon.png 没有自己的 Bundle,构建时它可能被分别塞进两个业务包里。

所以你要记住一句话:

被多个包共享的依赖,最好单独成为一个公共包。

AssetBundle 怎么避免重复打包

做法是给公共资源单独设置 AssetBundle Name

例如:

c
Assets/UI/Bag/BagPanel.prefab           -> bag_ui
Assets/UI/Shop/ShopPanel.prefab         -> shop_ui
Assets/UI/Common/common_icon.png        -> common_ui
Assets/UI/Common/CommonFont.asset       -> common_ui
Assets/Shaders/CommonLit.shader         -> common_shader

这样打包后:

c
bag_ui 依赖 common_ui
shop_ui 依赖 common_ui

加载时先加载依赖,再加载目标包。

c
using UnityEngine;

public class BundleLoadOrderExample
{
    private AssetBundle commonBundle;
    private AssetBundle bagBundle;

    public void LoadBag()
    {
        // 先加载公共依赖包
        // 比如公共图标、公共字体、公共材质
        commonBundle = AssetBundle.LoadFromFile("AssetBundles/common_ui");

        if (commonBundle == null)
        {
            Debug.LogError("公共包加载失败");
            return;
        }

        // 再加载业务包
        // BagPanel 里面引用的公共资源可以从 common_ui 里找到
        bagBundle = AssetBundle.LoadFromFile("AssetBundles/bag_ui");

        if (bagBundle == null)
        {
            Debug.LogError("背包包加载失败");
            return;
        }

        // 从业务包里加载背包 Prefab
        GameObject prefab = bagBundle.LoadAsset<GameObject>("BagPanel");

        Object.Instantiate(prefab);
    }
}

正式项目里不会手写这么简单,通常会通过 AssetBundleManifest 查依赖:

c
string[] deps = manifest.GetAllDependencies("bag_ui");

然后先加载这些依赖包。

Addressables 怎么避免重复打包

Addressables 里也一样。

如果两个 Addressable 资源都依赖同一个非 Addressable 贴图,这张贴图可能被打进多个 Bundle。

常见处理方式:

1. 把共享依赖也标记为 Addressable
2. 放到公共 Group,比如 common_ui
3. 用 Analyze 工具检查重复 Bundle 依赖
4. 根据分析结果移动重复资源到共享 Group

例如:

c
Group: UI_Bag
- BagPanel

Group: UI_Shop
- ShopPanel

Group: UI_Common
- common_icon
- common_font
- common_material

这样 BagPanelShopPanel 共同依赖 UI_Common,公共资源不会跟着两个 UI 各打一份。

用编辑器脚本检查重复依赖

这段代码可以选中多个资源,比如多个 UI Prefab,然后检查哪些依赖被多个资源共同使用。

c
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

public class DuplicateDependencyChecker
{
    [MenuItem("Tools/Check Duplicate Dependencies")]
    public static void CheckDuplicateDependencies()
    {
        // 记录:某个依赖资源 被 哪些资源引用
        Dictionary<string, List<string>> dependencyOwners = new();

        foreach (Object selectedObject in Selection.objects)
        {
            // 获取选中资源的路径
            string ownerPath = AssetDatabase.GetAssetPath(selectedObject);

            if (string.IsNullOrEmpty(ownerPath))
                continue;

            // true 表示递归查依赖,也就是间接依赖也查出来
            string[] dependencies = AssetDatabase.GetDependencies(ownerPath, true);

            foreach (string dependencyPath in dependencies)
            {
                // 跳过自己
                if (dependencyPath == ownerPath)
                    continue;

                // 跳过 C# 脚本,脚本一般不作为资源包重复依赖来处理
                if (dependencyPath.EndsWith(".cs"))
                    continue;

                if (!dependencyOwners.TryGetValue(dependencyPath, out List<string> owners))
                {
                    owners = new List<string>();
                    dependencyOwners.Add(dependencyPath, owners);
                }

                // 记录这个依赖被哪个资源引用了
                owners.Add(ownerPath);
            }
        }

        foreach (var pair in dependencyOwners)
        {
            string dependencyPath = pair.Key;
            List<string> owners = pair.Value;

            // 被两个及以上资源引用,就可能是公共依赖
            if (owners.Count >= 2)
            {
                Debug.LogWarning(
                    "发现共享依赖,建议考虑抽到公共包:\n" +
                    dependencyPath + "\n被这些资源引用:\n" +
                    string.Join("\n", owners)
                );
            }
        }
    }
}
#endif

这个工具的意义是帮你发现:

哪些图标被多个 UI 用了
哪些字体被多个界面用了
哪些材质被多个角色用了
哪些贴图可能应该进 common 包

哪些资源最容易重复打包

最常见的是:

公共图标
公共字体
公共材质
公共 Shader
公共音效
公共动画
公共配置表
角色共用武器
UI 共用背景

尤其 UI 项目里,这些东西很容易重复:

按钮底图
通用弹窗背景
金币图标
钻石图标
品质框
字体资源
图集外散图

所以 UI 资源通常会有:

c
common_ui
common_icon
common_font
common_atlas

Sprite 图集也很重要

如果很多 UI 都引用散图:

c
coin.png
diamond.png
close_button.png
quality_frame.png

这些散图可能在不同 UI 包里重复出现。

更好的方式是把它们放进图集:

c
common_ui.spriteatlas

然后图集进公共包。

这样好处是:

减少重复打包
减少 DrawCall
资源组织更清楚
加载公共 UI 时一次拿到常用图标

分包粒度不要极端

不要所有资源都打一个大包:

缺点:只想打开背包,却下载整个 UI 大包。

也不要每个小图标一个包:

缺点:Bundle 太多,请求太多,管理复杂。

更合理的是按:

模块
生命周期
使用频率
是否公共
是否热更新

来分。

例如:

c
common_ui
common_shader
common_audio
ui_bag
ui_shop
battle_common
role_knight
activity_summer

面试高分回答

IMPORTANT

“重复打包通常发生在多个 AssetBundle 或 Addressables Group 引用了同一个依赖资源,但这个依赖没有被单独抽出来。比如背包和商城都引用同一张公共图标,如果公共图标没有进入 common 包,就可能被分别打进两个业务包。解决方式是先通过依赖分析工具找出多个资源共享的依赖,比如贴图、字体、材质、Shader,再把它们放到公共 Bundle 或共享 Addressables Group 中。运行时加载业务包前先加载依赖包,卸载时通过引用计数保证公共包不被提前卸载。Addressables 项目里还可以用 Analyze 检查 Duplicate Bundle Dependencies,根据结果调整 Group。”

最短记忆版

c
避免重复打包:

1. 查依赖
看哪些资源被多个 Prefab / UI / 角色共用。

2. 抽 common
公共贴图、字体、材质、Shader 单独打包。

3. 合理分组
按模块、生命周期、公共程度分包。

4. 分析构建结果
AssetBundle 看 Manifest;
Addressables 用 Analyze。

5. 引用计数释放
公共包还有人用,就不能卸。

面试时抓住这句话就很稳:重复打包的本质,是共享依赖没有独立出来;解决方法是依赖分析 + 公共包 + 引用计数。

如何卸载不用的资源?

一句话回答: 卸载不用资源不是只写 Destroy,而是要分清楚:场景实例要 Destroy,资源引用要清掉,Addressables 要 Release,AssetBundle 要 Unload,Resources 资源通常靠 UnloadUnusedAssets 清理。

unload-unused-resources

零基础理解

你可以把资源分成两层:

实例:
场景里真正出现的 GameObject,比如一个怪物、一个 UI 面板。

资源本体:
Prefab、贴图、材质、音效、动画、AssetBundle、Addressables 资源。

Destroy(gameObject) 主要销毁的是实例

但它不代表:

贴图一定释放了
材质一定释放了
Prefab 一定释放了
AssetBundle 一定释放了
Addressables 引用计数一定下降了

所以完整卸载通常是:

1. Destroy 场景实例
2. 清空代码里的引用
3. 按资源系统释放资源
4. 在合适时机做 UnloadUnusedAssets
5. 必要时让 GC 回收托管内存

Resources 怎么卸载

Resources.Load 加载出来的资源,如果没有引用了,可以用 Resources.UnloadUnusedAssets() 让 Unity 尝试卸载不用的资源。

c
using System.Collections;
using UnityEngine;

public class ResourcesUnloadExample : MonoBehaviour
{
    private GameObject panelInstance;
    private GameObject panelPrefab;

    public void OpenPanel()
    {
        // 加载 Prefab 资源本体
        panelPrefab = Resources.Load<GameObject>("UI/BagPanel");

        // 实例化场景对象
        panelInstance = Instantiate(panelPrefab);
    }

    public IEnumerator ClosePanel()
    {
        if (panelInstance != null)
        {
            // Destroy 只销毁实例
            Destroy(panelInstance);
            panelInstance = null;
        }

        // 清掉 Prefab 引用
        // 如果你还用字段、列表、字典保存着它,它就不算“未使用”
        panelPrefab = null;

        // 卸载没有被引用的资源
        // 这个操作比较重,可能卡顿,适合 Loading 或切场景时做
        yield return Resources.UnloadUnusedAssets();
    }
}

重点:

c
Destroy 实例
清掉引用
再 UnloadUnusedAssets

如果你有一个 List<GameObject> 或静态变量还保存着资源,Unity 会认为它还在用,就不会卸载。

AssetBundle 怎么卸载

AssetBundle 有两个常见卸载方式:

c
bundle.Unload(false);
bundle.Unload(true);

区别非常重要。

c
Unload(false):
卸载 AssetBundle 包本身,但不强制卸载已经 LoadAsset 出来的资源。

Unload(true):
卸载 AssetBundle 包本身,同时卸载从这个 Bundle 加载出来的资源。

示例:

c
using UnityEngine;

public class AssetBundleUnloadExample : MonoBehaviour
{
    private AssetBundle bundle;
    private GameObject instance;

    public void Load()
    {
        // 从本地加载 AssetBundle
        bundle = AssetBundle.LoadFromFile("AssetBundles/ui_bag");

        if (bundle == null)
        {
            Debug.LogError("Bundle 加载失败");
            return;
        }

        // 从 Bundle 中加载 Prefab
        GameObject prefab = bundle.LoadAsset<GameObject>("BagPanel");

        // 实例化 UI
        instance = Instantiate(prefab);
    }

    public void Close()
    {
        if (instance != null)
        {
            // 先销毁实例
            Destroy(instance);
            instance = null;
        }

        if (bundle != null)
        {
            // false:只卸载 Bundle 文件,不强制卸载已经加载出的资源对象
            // 如果你还想让已加载资源继续使用,用 false 更安全
            bundle.Unload(false);
            bundle = null;
        }
    }
}

什么时候用 Unload(true)

确定这个 Bundle 加载出来的资源没人用了
确定实例已经销毁
确定没有其他对象引用这些资源

否则可能出现:

贴图丢失
材质异常
模型变粉
UI 图片丢失

所以正式项目通常会用引用计数

A 界面用 common_ui
B 界面也用 common_ui
关闭 A 时不能卸 common_ui
等 B 也关闭后,引用计数为 0,再卸载

Addressables 怎么卸载

Addressables 最重要的是:Load 对应 Release,Instantiate 对应 ReleaseInstance。

c
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesUnloadExample : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> loadHandle;
    private GameObject instance;

    private void Start()
    {
        // 异步加载资源
        loadHandle = Addressables.LoadAssetAsync<GameObject>("UI/BagPanel");
        loadHandle.Completed += OnLoaded;
    }

    private void OnLoaded(AsyncOperationHandle<GameObject> operation)
    {
        if (operation.Status != AsyncOperationStatus.Succeeded)
        {
            Debug.LogError("Addressables 加载失败");
            return;
        }

        // 实例化资源
        instance = Instantiate(operation.Result);
    }

    private void OnDestroy()
    {
        if (instance != null)
        {
            // 销毁场景实例
            Destroy(instance);
            instance = null;
        }

        if (loadHandle.IsValid())
        {
            // 释放 Addressables 句柄
            // 本质是减少引用计数
            Addressables.Release(loadHandle);
        }
    }
}

如果用的是 InstantiateAsync,释放方式不同:

c
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressablesInstanceUnloadExample : MonoBehaviour
{
    private AsyncOperationHandle<GameObject> instanceHandle;

    private void Start()
    {
        // 直接异步实例化
        instanceHandle = Addressables.InstantiateAsync("Enemy/Knight");
    }

    private void OnDestroy()
    {
        if (instanceHandle.IsValid())
        {
            // InstantiateAsync 创建的实例,推荐用 ReleaseInstance 释放
            Addressables.ReleaseInstance(instanceHandle);
        }
    }
}

记忆:

c
LoadAssetAsync -> Addressables.Release(handle)
InstantiateAsync -> Addressables.ReleaseInstance(handle)

托管内存怎么回收

C# 里的普通对象,比如:

c
List
Dictionary
string
byte[]
自定义 class
闭包对象
委托引用

这些属于托管内存,主要靠 GC 回收。

你可以在某些 Loading 阶段手动调用:

c
using System;
using System.Collections;
using UnityEngine;

public class MemoryCleanExample : MonoBehaviour
{
    public IEnumerator CleanMemory()
    {
        // 先卸载 Unity 未使用资源
        yield return Resources.UnloadUnusedAssets();

        // 再建议 GC 回收托管内存
        // 注意:GC.Collect 可能卡顿,不要频繁在战斗中调用
        GC.Collect();
    }
}

但是不要乱用:

不要每帧 GC.Collect
不要每关一个小窗口就 GC.Collect
不要把 GC.Collect 当成优化万能药

更好的策略是减少频繁分配。

什么时候适合集中卸载

适合:

切场景时
退出战斗时
关闭大型玩法时
从副本回主城时
Loading 界面期间
活动资源结束后

不适合:

c
战斗中每次释放一个特效就 UnloadUnusedAssets
每次关闭小 UI 都 GC.Collect
每次 Destroy 一个对象都卸载资源

因为 Resources.UnloadUnusedAssets()GC.Collect() 都可能造成明显卡顿。

常见坑

c
1. 以为 Destroy 就等于释放资源。
错,Destroy 多数时候只是销毁实例。

2. 静态变量还引用资源。
比如 static Dictionary 里缓存了 Sprite,资源不会被认为没用。

3. 事件没取消订阅。
对象被事件持有,导致对象和资源都下不去。

4. Addressables 加载后忘记 Release。
引用计数不下降,资源可能一直留着。

5. AssetBundle 公共依赖提前卸载。
其他界面还在用,会导致图片、材质异常。

6. 频繁调用 UnloadUnusedAssets。
会卡顿,应该放到 Loading 或切场景阶段。

面试高分回答

WARNING

“Unity 里卸载资源要区分实例和资源本体。Destroy 只是销毁场景中的 GameObject 实例,不代表它依赖的贴图、材质、Prefab 或 AssetBundle 已经释放。Resources 加载的资源通常要清掉引用后,在合适时机调用 Resources.UnloadUnusedAssets();AssetBundle 要根据引用计数,在没人使用时调用 Unload(false) 或谨慎使用 Unload(true);Addressables 则要保存加载句柄,LoadAssetAsync 对应 Addressables.ReleaseInstantiateAsync 对应 ReleaseInstance。另外还要清理静态缓存、事件订阅、列表字典引用,必要时在 Loading 阶段调用 GC 回收托管内存。”

最短记忆版

c
卸载不用资源:

Destroy:
销毁场景实例。

清引用:
字段、列表、字典、static、事件都要注意。

Resources:
清引用后 UnloadUnusedAssets。

AssetBundle:
引用计数为 0 后 Unload。

Addressables:
Load 后 Release;
Instantiate 后 ReleaseInstance。

GC:
回收 C# 托管对象,适合 Loading 阶段谨慎调用。

UnloadUnusedAssets 有什么代价?

一句话回答:Resources.UnloadUnusedAssets() 的代价是:它要全局扫描资源引用并卸载未使用资源,所以可能造成 CPU 峰值、主线程卡顿、Loading 变长,而且下次再用这些资源还要重新加载。

unloadunusedassets-cost

零基础理解

你可以把它理解成一次“全屋大扫除”。

普通 Destroy 像是:

把桌上的杯子拿走

UnloadUnusedAssets 像是:

检查整个房子:
哪些东西没人用了?
哪些东西还在柜子里被引用?
哪些东西能扔?
然后统一清理。

所以它不是免费的。

它会花时间去判断:

哪些 Texture 没用了
哪些 Material 没用了
哪些 Mesh 没用了
哪些 AudioClip 没用了
哪些 Prefab 没用了

资源越多,检查和卸载成本越高。

代价 1:可能造成卡顿

虽然 Resources.UnloadUnusedAssets() 返回的是 AsyncOperation,可以这样写:

c
yield return Resources.UnloadUnusedAssets();

但这不代表它完全不会卡。

它仍然可能在某些阶段造成明显的主线程开销。

c
using System.Collections;
using UnityEngine;

public class UnloadCostExample : MonoBehaviour
{
    public IEnumerator CleanAfterBattle()
    {
        Debug.Log("开始清理战斗资源");

        // 这一步可能比较重
        // 适合放在 Loading 界面,而不是战斗进行中
        yield return Resources.UnloadUnusedAssets();

        Debug.Log("清理完成");
    }
}

你如果在战斗中这样做:

c
private void Update()
{
    if (Input.GetKeyDown(KeyCode.C))
    {
        StartCoroutine(Clean());
    }
}

玩家可能感觉:

突然卡一下
技能动画停一下
镜头拖动不流畅
帧率掉一下

所以它适合放在:

Loading 页面
切场景期间
退出战斗后
回主城时
关闭大型玩法后

不适合:

每次关闭小 UI
每次销毁一个怪物
每次释放一个特效
战斗中频繁调用

代价 2:会扫描资源引用

它要判断资源是否“真的没人用了”。

资源如果还被引用,就卸不掉。

比如:

c
using UnityEngine;

public class StaticCacheExample : MonoBehaviour
{
    // 静态变量一直引用这个 Sprite
    // 即使界面关了,这个 Sprite 也可能无法被 UnloadUnusedAssets 卸掉
    public static Sprite CachedIcon;

    public void LoadIcon()
    {
        CachedIcon = Resources.Load<Sprite>("Icons/Coin");
    }

    public void ClearIcon()
    {
        // 要想让资源可能被卸载,必须先清掉引用
        CachedIcon = null;
    }
}

常见“资源卸不掉”的原因:

static 变量还引用着
Dictionary 缓存还存着
List 里还放着
事件没取消订阅
单例对象还持有引用
UI 虽然隐藏了但没有销毁
对象池里还保存着对象

所以 UnloadUnusedAssets 不是万能的。 你不清引用,它也没法帮你卸。

代价 3:卸了以后,下次用还要重新加载

比如你刚退出背包就清理:

背包图标被卸载
背包贴图被卸载
背包字体被卸载

玩家马上又打开背包,就要重新加载:

磁盘读取
解压
创建资源对象
重新绑定引用

这可能导致第二次打开也卡。

所以不要盲目追求“内存越低越好”。

项目里要平衡:

内存占用
加载速度
卡顿风险
玩家操作频率

经常打开的 UI,比如:

背包
角色
任务
商城
设置

可能更适合缓存,不一定每次关闭都清理。

代价 4:可能和 GC 一起造成更大的卡顿

UnloadUnusedAssets 主要处理 Unity 资源,比如贴图、材质、Mesh、音频等。 C# 的普通对象主要靠 GC。

有些项目会在 Loading 时这样做:

c
using System;
using System.Collections;
using UnityEngine;

public class FullCleanExample : MonoBehaviour
{
    public IEnumerator FullClean()
    {
        // 先卸载 Unity 不再使用的资源
        yield return Resources.UnloadUnusedAssets();

        // 再让 C# GC 回收托管对象
        // 注意:GC.Collect 也可能卡顿
        GC.Collect();

        Debug.Log("内存清理完成");
    }
}

这个顺序常见,但要注意:

c
UnloadUnusedAssets 可能卡
GC.Collect 也可能卡
两个放一起更应该放 Loading 阶段

不要在战斗中、拖拽 UI 时、播放关键动画时调用。

正确调用时机

比较合适:

退出战斗后
切换大场景时
活动玩法结束后
从副本回主城时
打开 Loading 界面后
一次性关闭大量资源后

不合适:

Update 里定时调用
每次关闭小弹窗调用
每次 Destroy 一个对象调用
每隔几秒自动调用一次
玩家正在操作时调用

比较安全的清理流程

c
using System;
using System.Collections;
using UnityEngine;

public class MemoryCleanupManager : MonoBehaviour
{
    public IEnumerator CleanupOnLoading()
    {
        // 第一步:先关闭或销毁大对象
        // 比如战斗场景里的怪物、特效、临时 UI
        DestroyBattleObjects();

        // 第二步:清理自己的缓存引用
        // 比如 List、Dictionary、static 字段
        ClearCaches();

        // 等一帧,让 Destroy 的对象真正进入销毁流程
        yield return null;

        // 第三步:卸载 Unity 未使用资源
        yield return Resources.UnloadUnusedAssets();

        // 第四步:如果当前处于 Loading 阶段,可以谨慎触发 GC
        GC.Collect();

        Debug.Log("Loading 阶段内存清理完成");
    }

    private void DestroyBattleObjects()
    {
        // 这里写销毁战斗对象的逻辑
        // 示例:销毁怪物、技能特效、临时 UI 等
    }

    private void ClearCaches()
    {
        // 这里写清空缓存的逻辑
        // 示例:清空临时字典、列表、静态引用等
    }
}

面试高分回答

CAUTION

Resources.UnloadUnusedAssets() 可以释放当前没有被引用的 Unity 资源,但它不是一个轻量操作。它需要遍历场景对象、组件引用、静态引用等,判断哪些资源不可达,然后卸载贴图、材质、Mesh、音频等资源,所以可能造成 CPU 峰值和主线程卡顿。它适合放在切场景、退出战斗、Loading 页面这种玩家能接受等待的时机,不适合每次关闭小 UI 或战斗中频繁调用。另外,如果资源还被静态变量、缓存、对象池、事件引用持有,它也卸不掉。Addressables 和 AssetBundle 还要先正确 Release 或 Unload,不能指望 UnloadUnusedAssets 替代资源系统的释放逻辑。”

最短记忆版

c
UnloadUnusedAssets 的代价:

1. 全局扫描资源引用,耗 CPU。
2. 可能造成主线程卡顿。
3. 卸载后下次再用要重新加载。
4. 有引用就卸不掉。
5. 不能替代 Addressables.Release 或 AssetBundle.Unload。
6. 最好放在 Loading / 切场景 / 退出大战斗时调用。

面试时抓住这句:它能换内存,但代价是扫描和卸载带来的卡顿,所以要集中在 Loading 阶段做,而不是运行时频繁做。

场景异步加载怎么做进度条?

一句话回答: 场景异步加载进度条一般用 SceneManager.LoadSceneAsync,每帧读取 AsyncOperation.progress 更新 Slider。重点是:progress 通常只到 0.9,最后是否进入新场景由 allowSceneActivation 控制。

scene-async-loading-progress

零基础理解

场景加载可以想成坐电梯:

0% 到 90%:
电梯正在上楼,Unity 在加载场景资源。

90% 到 100%:
电梯已经到了门口,但门还没开。

allowSceneActivation = true:
允许开门,正式进入新场景。

Unity 的 AsyncOperation.progress 有一个很容易考的点:

如果 allowSceneActivation = false
progress 通常最多到 0.9
不会自动变成 1.0

所以 UI 进度条一般这样算:

c
float progress = Mathf.Clamp01(operation.progress / 0.9f);

基础版代码

这个版本最适合面试讲,清楚、直接。

c
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class LoadingSceneController : MonoBehaviour
{
    [SerializeField] private Slider progressSlider;
    [SerializeField] private Text progressText;

    public void LoadScene(string sceneName)
    {
        // 启动协程异步加载场景
        StartCoroutine(LoadSceneAsync(sceneName));
    }

    private IEnumerator LoadSceneAsync(string sceneName)
    {
        // 开始异步加载目标场景
        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);

        // 先不让 Unity 自动切到新场景
        // 这样我们可以等进度条满了、动画播完了,再进入新场景
        operation.allowSceneActivation = false;

        while (!operation.isDone)
        {
            // Unity 的 progress 在 allowSceneActivation=false 时通常最多到 0.9
            // 所以除以 0.9,把 0~0.9 映射成 UI 的 0~1
            float progress = Mathf.Clamp01(operation.progress / 0.9f);

            // 更新进度条
            progressSlider.value = progress;

            // 更新百分比文本
            progressText.text = Mathf.RoundToInt(progress * 100f) + "%";

            // 当进度达到 100%,允许进入新场景
            if (progress >= 1f)
            {
                operation.allowSceneActivation = true;
            }

            // 等下一帧继续检查
            yield return null;
        }
    }
}

为什么要除以 0.9?

因为 Unity 的加载进度大概是:

0.0 -> 0.9:加载场景主体资源
0.9:等待激活场景
1.0:场景真正激活完成

如果你直接写:

c
progressSlider.value = operation.progress;

玩家看到的进度条可能只到:

c
90%

然后卡住。 所以要做映射:

c
float progress = operation.progress / 0.9f;

这样:

c
operation.progress = 0.45
UI 显示 50%

operation.progress = 0.9
UI 显示 100%

带平滑效果的版本

真实项目里,进度条如果跟真实进度直接走,可能会一跳一跳。 可以做一个“显示进度”慢慢追“真实进度”。

c
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class SmoothLoadingController : MonoBehaviour
{
    [SerializeField] private Slider progressSlider;
    [SerializeField] private Text progressText;

    [SerializeField] private float smoothSpeed = 2f;
    [SerializeField] private float minLoadingTime = 1.0f;

    public void LoadScene(string sceneName)
    {
        StartCoroutine(LoadSceneAsync(sceneName));
    }

    private IEnumerator LoadSceneAsync(string sceneName)
    {
        float startTime = Time.time;

        // 显示用的进度
        float displayProgress = 0f;

        AsyncOperation operation = SceneManager.LoadSceneAsync(sceneName);

        // 先暂停场景激活,等 UI 进度和最短时间都满足后再切
        operation.allowSceneActivation = false;

        while (!operation.isDone)
        {
            // 真实加载进度,映射到 0~1
            float targetProgress = Mathf.Clamp01(operation.progress / 0.9f);

            // 显示进度平滑追赶真实进度
            displayProgress = Mathf.MoveTowards(
                displayProgress,
                targetProgress,
                smoothSpeed * Time.deltaTime
            );

            progressSlider.value = displayProgress;
            progressText.text = Mathf.RoundToInt(displayProgress * 100f) + "%";

            bool progressReady = displayProgress >= 0.99f;
            bool timeReady = Time.time - startTime >= minLoadingTime;

            // 进度条接近满,并且最短 Loading 时间也到了,再进入新场景
            if (progressReady && timeReady)
            {
                progressSlider.value = 1f;
                progressText.text = "100%";

                operation.allowSceneActivation = true;
            }

            yield return null;
        }
    }
}

这个版本解决两个问题:

加载太快,Loading 一闪而过,很突兀。
加载进度跳动,进度条不丝滑。

如果还要预加载资源怎么办

真实项目里,切场景不只是加载 Scene,还可能要加载:

Addressables 资源
角色模型
UI 图集
战斗音效
配置表
服务器数据

所以进度条可以拆成几段:

场景加载 70%
资源预加载 20%
初始化逻辑 10%

伪代码思路:

c
float sceneWeight = 0.7f;
float resourceWeight = 0.2f;
float initWeight = 0.1f;

比如:

c
// 场景加载进度占总进度的 70%
float totalProgress = sceneProgress * 0.7f;

// Addressables 下载占 20%
totalProgress += addressablesProgress * 0.2f;

// 初始化占 10%
totalProgress += initProgress * 0.1f;

面试不用写特别复杂,但你可以说出: 项目里的 Loading 进度通常不是只看场景加载,而是由场景、资源、初始化多个阶段加权组成。

常见坑

c
1. progress 只到 0.9,以为卡死。
其实是 allowSceneActivation 没打开。

2. 直接显示 operation.progress。
导致进度条最多显示 90%

3. 忘记把 Loading 场景加入 Build Settings。
LoadSceneAsync 找不到场景。

4. 加载完成后一次性 Instantiate 太多对象。
场景加载不一定卡,但进入场景第一帧可能卡。

5. 没防止重复点击。
玩家连续点两次开始游戏,启动多个加载协程。

6. 用假进度条一直跑,真实加载失败也不处理。
正式项目要处理异常、超时、资源下载失败。

面试高分回答

NOTE

“场景异步加载一般使用 SceneManager.LoadSceneAsync,它返回 AsyncOperation。我会在 Loading 界面里每帧读取 operation.progress 更新 Slider 和百分比文本。需要注意的是,当 allowSceneActivation 设置为 false 时,Unity 的 progress 通常最多到 0.9,表示场景加载基本完成但还没有激活,所以 UI 显示时要用 progress / 0.9f 映射到 0 到 1。等进度条满了、最短 Loading 时间到了,或者资源预加载也完成了,再把 allowSceneActivation 设置为 true 进入新场景。实际项目里还会把 Addressables 下载、配置初始化等阶段一起合成总进度。”

最短记忆版

c
场景异步加载进度条:

LoadSceneAsync 得到 AsyncOperation。

progress:
00.9 是加载过程;
0.9 表示等待激活;
1.0 表示场景激活完成。

UI 显示:
progress / 0.9f

控制切换:
allowSceneActivation = false 先卡住;
进度满了再设 true

项目优化:
平滑进度;
最短 Loading 时间;
合并资源预加载进度;
防止重复点击。

大场景如何分块加载?

一句话回答: 大场景分块加载就是:把大地图切成很多 Chunk,玩家靠近哪个块就异步加载哪个块,离远了就异步卸载,避免一次性把整个地图放进内存。

large-scene-chunk-loading

零基础理解

不要把一个超大地图当成一个完整场景一次性加载。

比如地图有:

森林区
河流区
村庄区
山洞区
城堡区

如果全放在一个场景里,问题是:

加载时间长
内存占用高
切场景卡
低端机容易崩

更好的做法是切块:

World_Main       主场景,常驻
Chunk_0_0        地图块
Chunk_0_1        地图块
Chunk_1_0        地图块
Chunk_1_1        地图块

玩家在 Chunk_0_0 附近,就加载周围几块。 玩家走远了,就卸载很远的块。

常见方案 1:多个 Scene Additive 加载

这是大场景常用方案。

结构:

c
MainScene
- Player
- Camera
- GameManager
- UI
- Audio
- ChunkLoader

Chunk_0_0
- 地形
-
- 石头
- 建筑
- 怪物出生点

Chunk_0_1
- 另一块地形
- 另一批装饰物

主场景一直存在,地图块用 Additive 加进来。

c
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;

public class SceneChunkLoader : MonoBehaviour
{
    [SerializeField] private Transform player;

    // 每个块的世界尺寸,比如 100 米 x 100 米
    [SerializeField] private float chunkSize = 100f;

    // 玩家周围加载几圈 Chunk
    // 1 表示加载当前块和周围 8 块
    [SerializeField] private int loadRadius = 1;

    // 卸载半径一般要比加载半径大一点
    // 这样玩家在边界来回走时,不会频繁加载卸载
    [SerializeField] private int unloadRadius = 2;

    // 已经加载的 Chunk
    private readonly HashSet<Vector2Int> loadedChunks = new();

    // 正在加载的 Chunk,防止重复请求
    private readonly HashSet<Vector2Int> loadingChunks = new();

    private Vector2Int lastPlayerChunk;

    private void Start()
    {
        lastPlayerChunk = GetPlayerChunk();
        RefreshChunks();
    }

    private void Update()
    {
        Vector2Int currentChunk = GetPlayerChunk();

        // 只有玩家进入新 Chunk 时,才重新检查加载卸载
        if (currentChunk != lastPlayerChunk)
        {
            lastPlayerChunk = currentChunk;
            RefreshChunks();
        }
    }

    private Vector2Int GetPlayerChunk()
    {
        Vector3 pos = player.position;

        // 根据玩家坐标计算所在 Chunk
        int x = Mathf.FloorToInt(pos.x / chunkSize);
        int z = Mathf.FloorToInt(pos.z / chunkSize);

        return new Vector2Int(x, z);
    }

    private void RefreshChunks()
    {
        Vector2Int center = GetPlayerChunk();

        // 加载玩家附近的 Chunk
        for (int x = center.x - loadRadius; x <= center.x + loadRadius; x++)
        {
            for (int z = center.y - loadRadius; z <= center.y + loadRadius; z++)
            {
                Vector2Int chunk = new Vector2Int(x, z);

                if (!loadedChunks.Contains(chunk) && !loadingChunks.Contains(chunk))
                {
                    StartCoroutine(LoadChunk(chunk));
                }
            }
        }

        // 卸载离玩家太远的 Chunk
        List<Vector2Int> chunksToUnload = new();

        foreach (Vector2Int chunk in loadedChunks)
        {
            int dx = Mathf.Abs(chunk.x - center.x);
            int dz = Mathf.Abs(chunk.y - center.y);

            if (dx > unloadRadius || dz > unloadRadius)
            {
                chunksToUnload.Add(chunk);
            }
        }

        foreach (Vector2Int chunk in chunksToUnload)
        {
            StartCoroutine(UnloadChunk(chunk));
        }
    }

    private IEnumerator LoadChunk(Vector2Int chunk)
    {
        loadingChunks.Add(chunk);

        string sceneName = GetChunkSceneName(chunk);

        // Additive 表示把这个场景叠加加载进来
        // 不会把主场景卸掉
        AsyncOperation operation = SceneManager.LoadSceneAsync(
            sceneName,
            LoadSceneMode.Additive
        );

        while (!operation.isDone)
        {
            yield return null;
        }

        loadingChunks.Remove(chunk);
        loadedChunks.Add(chunk);

        Debug.Log("Chunk 加载完成:" + sceneName);
    }

    private IEnumerator UnloadChunk(Vector2Int chunk)
    {
        string sceneName = GetChunkSceneName(chunk);

        // 异步卸载远处 Chunk
        AsyncOperation operation = SceneManager.UnloadSceneAsync(sceneName);

        while (operation != null && !operation.isDone)
        {
            yield return null;
        }

        loadedChunks.Remove(chunk);

        Debug.Log("Chunk 卸载完成:" + sceneName);
    }

    private string GetChunkSceneName(Vector2Int chunk)
    {
        // 约定场景名,比如 Chunk_0_0、Chunk_1_-2
        return $"Chunk_{chunk.x}_{chunk.y}";
    }
}

为什么卸载半径要比加载半径大

比如:

加载半径 = 1
卸载半径 = 1

玩家在两个 Chunk 边界来回走时,可能出现:

加载
卸载
加载
卸载

这样会卡。

所以通常:

加载半径 = 1
卸载半径 = 2

意思是:

近处提前加载
稍微远一点也先别急着卸
真的离很远了再卸

这叫防抖,也可以理解成“缓冲区”。

常见方案 2:Chunk 做成 Addressables Prefab

如果你的块不是完整 Scene,也可以做成 Prefab:

c
Chunk_0_0.prefab
Chunk_0_1.prefab
Chunk_1_0.prefab

然后用 Addressables 异步实例化。

c
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class AddressableChunkLoader : MonoBehaviour
{
    [SerializeField] private Transform player;
    [SerializeField] private float chunkSize = 100f;
    [SerializeField] private int loadRadius = 1;
    [SerializeField] private int unloadRadius = 2;

    // 保存每个 Chunk 对应的 Addressables 句柄
    private readonly Dictionary<Vector2Int, AsyncOperationHandle<GameObject>> loadedChunks = new();

    private Vector2Int lastChunk;

    private void Start()
    {
        lastChunk = GetPlayerChunk();
        RefreshChunks();
    }

    private void Update()
    {
        Vector2Int currentChunk = GetPlayerChunk();

        if (currentChunk != lastChunk)
        {
            lastChunk = currentChunk;
            RefreshChunks();
        }
    }

    private Vector2Int GetPlayerChunk()
    {
        Vector3 pos = player.position;

        return new Vector2Int(
            Mathf.FloorToInt(pos.x / chunkSize),
            Mathf.FloorToInt(pos.z / chunkSize)
        );
    }

    private void RefreshChunks()
    {
        Vector2Int center = GetPlayerChunk();

        for (int x = center.x - loadRadius; x <= center.x + loadRadius; x++)
        {
            for (int z = center.y - loadRadius; z <= center.y + loadRadius; z++)
            {
                Vector2Int chunk = new Vector2Int(x, z);

                if (!loadedChunks.ContainsKey(chunk))
                {
                    StartCoroutine(LoadChunk(chunk));
                }
            }
        }

        List<Vector2Int> unloadList = new();

        foreach (Vector2Int chunk in loadedChunks.Keys)
        {
            int dx = Mathf.Abs(chunk.x - center.x);
            int dz = Mathf.Abs(chunk.y - center.y);

            if (dx > unloadRadius || dz > unloadRadius)
            {
                unloadList.Add(chunk);
            }
        }

        foreach (Vector2Int chunk in unloadList)
        {
            UnloadChunk(chunk);
        }
    }

    private IEnumerator LoadChunk(Vector2Int chunk)
    {
        string key = GetChunkAddress(chunk);

        // 异步实例化 Chunk Prefab
        AsyncOperationHandle<GameObject> handle =
            Addressables.InstantiateAsync(key);

        yield return handle;

        if (handle.Status == AsyncOperationStatus.Succeeded)
        {
            loadedChunks[chunk] = handle;
            Debug.Log("Addressables Chunk 加载完成:" + key);
        }
        else
        {
            Debug.LogError("Addressables Chunk 加载失败:" + key);
        }
    }

    private void UnloadChunk(Vector2Int chunk)
    {
        if (!loadedChunks.TryGetValue(chunk, out var handle))
            return;

        // 释放由 InstantiateAsync 创建的实例
        Addressables.ReleaseInstance(handle);

        loadedChunks.Remove(chunk);
    }

    private string GetChunkAddress(Vector2Int chunk)
    {
        // 约定 Address,比如 World/Chunk_0_0
        return $"World/Chunk_{chunk.x}_{chunk.y}";
    }
}

这种方案适合:

独立建筑
地块 Prefab
动态活动区域
副本房间块
小型开放地图

加载时不要一帧加载太多

玩家跑得快,可能一下子需要加载很多块。 如果你一帧启动很多加载任务,可能还是卡。

更好的做法是用加载队列:

先加载玩家最近的块
每帧只启动 1 个或少量加载
远一点的块排后面

思路:

// 按距离排序,近的先加载
// 每帧只处理一个 Chunk,避免瞬间卡顿

面试可以说: 分块加载不是只会 Additive,还要有加载队列和限速策略。

边界体验怎么处理

如果玩家跑到地图边界,而下一个 Chunk 还没加载好,就会看到空地。 解决办法:

加载半径比可见距离大
提前预加载玩家前方 Chunk
用山、墙、门、走廊、雾遮挡加载边界
移动速度很快时提前扩大加载范围
Loading 区域或传送门阻挡玩家

比如开放世界常见:

远处先显示低精度地形
近了再加载细节

这就会配合:

c
LOD
HLOD
Occlusion Culling
Terrain 分块
对象池
Addressables 预加载

大场景还要注意什么

c
1. 静态物体合批
Chunk 内部尽量做好静态批处理或 GPU Instancing。

2. 光照数据
每个 Additive Scene 的光照、Reflection Probe、Light Probe 要管理好。

3. NavMesh
可以按块烘焙 NavMesh,加载块时一起加载导航数据。

4. 怪物和采集物
不要一加载 Chunk 就全部启用 AI,可以根据距离再激活。

5. 资源释放
卸载 Scene 后,必要时在 Loading 阶段 UnloadUnusedAssets。

6. 坐标精度
超大世界可能需要 Floating Origin,避免离原点太远导致浮点精度问题。

面试高分回答

IMPORTANT

“大场景一般不会一次性加载完整地图,而是把地图按网格或区域切成多个 Chunk。主场景常驻,里面放玩家、相机、UI 和管理器;地形、建筑、怪物出生点等内容放到 Chunk Scene 或 Addressables Prefab 中。玩家移动时根据坐标计算当前 Chunk,异步加载加载半径内的块,离开卸载半径后异步卸载远处块。加载半径和卸载半径通常不一样,卸载半径更大,用来避免玩家在边界来回走导致频繁加载卸载。实际项目还会配合加载队列、LOD/HLOD、对象池、遮挡、预加载、引用计数和资源释放,避免卡顿和空地图。”

最短记忆版

c
大场景分块加载:

1. 切 Chunk
按网格、区域、房间或地形块划分。

2. 主场景常驻
玩家、相机、UI、管理器不卸。

3. 附近异步加载
LoadSceneAsync Additive 或 Addressables InstantiateAsync。

4. 远处异步卸载
UnloadSceneAsync 或 ReleaseInstance。

5. 半径控制
加载半径小,卸载半径大,避免反复加载卸载。

6. 体验优化
加载队列、LOD、预加载、遮挡、对象池、资源释放。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

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