Appearance
资源系统追问链
为什么不用 Resources?
标准回答 不是说 Resources 绝对不能用,而是 不建议把它当正式项目的主资源管理方案。
Resources 最大的问题是:简单,但不可控。 它适合小 Demo、原型验证、少量兜底资源;但正式项目里,角色、UI、特效、音频、场景资源通常不应该大量放进 Resources。
底层原理 Unity 中只要资源放在任意 Resources 文件夹下,打包时通常都会被收进 Player 包里。 这会带来几个问题:
第一,包体变大。 即使某些资源当前版本根本用不到,只要在 Resources 下,也可能进包。
第二,路径字符串不安全。 Resources.Load("UI/Icon/Sword") 这种写法,路径写错、资源改名、目录移动,很多问题运行时才暴露。
第三,依赖关系不好管理。 正式项目需要知道一个 UI 依赖哪些图集、字体、材质、Shader。Resources 缺少清晰的依赖分析、分包规则和版本管理。
第四,卸载不够精细。 Resources 没有天然引用计数。资源谁加载、谁释放、什么时候释放,很容易变乱。 Resources.UnloadUnusedAssets() 可以清理未引用资源,但它代价较高,可能造成明显卡顿。
第五,不适合热更新和分包。 手游常见需求是首包小、后续按需下载、资源版本对比、远程更新。 这些更适合用 AssetBundle 或 Addressables 来做。
Unity 工程实践 正式项目里我一般会这样分:
Resources:只放极少量兜底资源,比如默认图、启动配置、占位 Prefab。 Addressables / AssetBundle:放主要游戏资源,比如角色、怪物、UI、特效、音频、地图。 资源管理器:统一负责加载、缓存、引用计数、释放、热更版本检查。
不推荐写法
GameObject prefab = Resources.Load<GameObject>("Enemy/Goblin"); // 用字符串路径加载资源,路径错误运行时才发现。
GameObject enemy = Object.Instantiate(prefab); // 实例化敌人对象,但资源生命周期没有被统一管理。推荐封装思路:用 Addressables 做可释放的异步加载
c
using System.Threading.Tasks; // 引入 Task,用来写异步加载接口。
using UnityEngine; // 引入 Unity 的 GameObject 和 Object。
using UnityEngine.AddressableAssets; // 引入 Addressables 的加载 API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入异步加载句柄类型。
public sealed class AddressableAssetLoader // 定义一个简单的 Addressables 加载封装类。
{ // 类开始。
private AsyncOperationHandle<GameObject> handle; // 保存加载句柄,后面释放资源要用。
public async Task<GameObject> LoadPrefabAsync(string address) // 定义异步加载 Prefab 的方法。
{ // 方法开始。
handle = Addressables.LoadAssetAsync<GameObject>(address); // 根据 Address 地址异步加载 Prefab。
GameObject prefab = await handle.Task; // 等待异步加载完成并拿到 Prefab。
return prefab; // 返回加载到的 Prefab。
} // 方法结束。
public GameObject InstantiatePrefab(GameObject prefab) // 定义实例化 Prefab 的方法。
{ // 方法开始。
GameObject instance = Object.Instantiate(prefab); // 实例化一个场景对象。
return instance; // 返回实例化后的对象。
} // 方法结束。
public void Release() // 定义释放资源的方法。
{ // 方法开始。
if (handle.IsValid()) // 判断句柄是否有效。
{ // if 代码块开始。
Addressables.Release(handle); // 释放 Addressables 资源引用。
} // if 代码块结束。
} // 方法结束。
} // 类结束。什么时候可以用 Resources 少量默认资源可以用。 编辑器工具临时加载可以用。 小 Demo 或面试 Demo 可以用。 但上线项目的大规模资源、热更资源、分包资源,不建议依赖它。
IMPORTANT
面试加分说法 “我不是完全不用 Resources,而是不把它作为主资源系统。它的优点是简单,但正式项目更关注包体、依赖、热更、卸载和版本管理,所以主资源我会走 Addressables 或 AssetBundle,并在上层封装资源管理器,统一处理异步加载、引用计数和释放。”
异步加载如何处理依赖?
标准回答 异步加载处理依赖的核心是:先拿到依赖图,先加载依赖,再加载主资源;使用时统一记录句柄,释放时靠引用计数管理生命周期。
比如加载一个角色 Prefab,它可能依赖:
材质 Material。 贴图 Texture。 Shader。 动画 AnimationClip。 音频。 特效 Prefab。
如果主 Prefab 先实例化,但依赖没加载完,就可能出现材质丢失、贴图变粉、动画缺失、空引用、运行时卡顿。
底层原理 资源之间不是孤立的,而是一张依赖图。 异步加载时要保证:
主资源使用前,依赖链已经完成。 同一个资源重复请求时,不要重复下载或重复加载。 加载失败时,要释放已经加载成功的依赖。 资源不用时,不是立刻释放,而是引用计数归零后释放。 释放主资源时,也要正确减少依赖引用。
如果是 Addressables,Unity 会帮你处理底层依赖加载,但你仍然要保存 handle 并正确 Release。 如果是手写 AssetBundle,就要通过 AssetBundleManifest.GetAllDependencies 先查依赖,再手动加载依赖 Bundle。
Unity 工程实践 我一般会封装一个资源管理器:
对外只暴露 LoadAsync(address) 和 Release(address)。 内部维护 address -> handle。 内部维护 address -> refCount。 内部维护 address -> loadingTask,防止同资源并发重复加载。 加载失败要清理句柄和引用计数。 场景切换时按模块或场景批量释放。
代码示例:Addressables 异步加载依赖与引用计数封装
c
using System.Collections.Generic; // 引入 Dictionary,用来缓存句柄、引用计数和加载任务。
using System.Threading.Tasks; // 引入 Task,用来封装异步加载结果。
using UnityEngine; // 引入 Unity 的 GameObject 类型。
using UnityEngine.AddressableAssets; // 引入 Addressables 的加载和释放 API。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 句柄类型。
public sealed class AddressableLoader // 定义一个简单的 Addressables 加载管理器。
{ // 类开始。
private readonly Dictionary<string, AsyncOperationHandle<GameObject>> handles = new Dictionary<string, AsyncOperationHandle<GameObject>>(); // 保存已经加载完成的资源句柄。
private readonly Dictionary<string, int> refCounts = new Dictionary<string, int>(); // 保存每个资源的引用计数。
private readonly Dictionary<string, Task<GameObject>> loadingTasks = new Dictionary<string, Task<GameObject>>(); // 保存正在加载中的任务,避免重复加载。
public Task<GameObject> LoadPrefabAsync(string address) // 对外提供异步加载 Prefab 的接口。
{ // 方法开始。
if (handles.TryGetValue(address, out AsyncOperationHandle<GameObject> cachedHandle)) // 如果资源已经加载完成。
{ // if 开始。
refCounts[address] += 1; // 引用计数加一。
return Task.FromResult(cachedHandle.Result); // 直接返回已经加载好的 Prefab。
} // if 结束。
if (loadingTasks.TryGetValue(address, out Task<GameObject> runningTask)) // 如果同一个资源正在加载中。
{ // if 开始。
refCounts[address] += 1; // 引用计数加一,表示又有一个使用者在等待。
return runningTask; // 复用同一个加载任务,避免重复请求。
} // if 结束。
refCounts[address] = 1; // 第一次请求该资源时,引用计数设为一。
Task<GameObject> newTask = LoadInternalAsync(address); // 创建真正的内部加载任务。
loadingTasks[address] = newTask; // 把加载任务缓存起来。
return newTask; // 返回异步加载任务。
} // 方法结束。
private async Task<GameObject> LoadInternalAsync(string address) // 内部执行 Addressables 加载。
{ // 方法开始。
AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(address); // Addressables 会自动加载该资源需要的依赖。
try // 开始捕获加载异常。
{ // try 开始。
GameObject prefab = await handle.Task; // 等待主资源和依赖资源加载完成。
handles[address] = handle; // 加载成功后保存句柄,后续释放要用。
return prefab; // 返回加载完成的 Prefab。
} // try 结束。
catch // 如果加载过程中发生异常。
{ // catch 开始。
if (handle.IsValid()) // 如果句柄仍然有效。
{ // if 开始。
Addressables.Release(handle); // 释放已经部分加载的资源,避免泄漏。
} // if 结束。
refCounts.Remove(address); // 移除引用计数,避免失败资源残留。
throw; // 把异常继续抛给上层处理。
} // catch 结束。
finally // 无论成功还是失败都会执行。
{ // finally 开始。
loadingTasks.Remove(address); // 移除加载中任务记录。
} // finally 结束。
} // 方法结束。
public void Release(string address) // 对外提供释放资源的接口。
{ // 方法开始。
if (!refCounts.TryGetValue(address, out int count)) // 如果找不到引用计数。
{ // if 开始。
return; // 说明资源没有被这个管理器持有,直接返回。
} // if 结束。
count -= 1; // 引用计数减一。
if (count > 0) // 如果还有其他地方正在使用。
{ // if 开始。
refCounts[address] = count; // 更新引用计数。
return; // 不释放资源。
} // if 结束。
refCounts.Remove(address); // 引用计数归零后移除记录。
if (handles.TryGetValue(address, out AsyncOperationHandle<GameObject> handle)) // 如果存在加载完成的句柄。
{ // if 开始。
Addressables.Release(handle); // 释放 Addressables 句柄,同时减少依赖引用。
handles.Remove(address); // 从句柄缓存里移除该资源。
} // if 结束。
} // 方法结束。
} // 类结束。常见坑 第一,只等主资源,不等依赖,容易出现材质、贴图、动画缺失。 第二,同一个资源同时请求多次,如果不缓存加载中任务,会重复加载。 第三,只 Destroy(instance),不 Release(handle),资源引用还在,内存不会真正降下来。 第四,加载失败时不回滚,已经加载成功的依赖会残留。 第五,场景切换时不统一释放,旧场景资源会被新场景间接引用住。
面试加分说法 “我处理异步加载依赖时,会把资源看成依赖图。Addressables 可以自动处理底层依赖,但业务层仍然要维护句柄、引用计数和加载中任务。手写 AssetBundle 管线则要先通过 Manifest 查依赖,先加载依赖 Bundle,再加载主 Bundle。释放时不能只 Destroy 实例,而要在引用计数归零后 Release 句柄。”
已校验:SVG XML OK,TEXT BOUNDS OK。
多个模块引用同一资源如何计数?
标准回答 多个模块引用同一资源时,引用计数应该由 资源管理器统一维护,而不是每个模块自己计数。
核心规则是:
Acquire(key):资源引用数加一。 Release(lease):资源引用数减一。 refCount == 0:才真正释放资源句柄和依赖。 每次 Acquire 返回一个 租约 / 句柄,这个租约只能释放一次,用来防止重复归还。
底层原理 资源管理器内部一般维护一张表:
c
key -> ResourceEntry里面记录:
资源句柄 handle。 引用计数 refCount。 持有者 owners。 依赖列表 dependencies。 是否正在加载 loadingTask。
比如战斗模块、UI 模块、预加载模块都引用 fire.prefab:
第一次加载:refCount = 1。 第二个模块也要用:不重复加载,refCount = 2。 第三个模块也要用:继续复用,refCount = 3。 某个模块释放:refCount--。 只有减到 0,才真正 Addressables.Release(handle) 或卸载 AssetBundle。
代码示例:租约防重复释放 + 统一引用计数
c
using System; // 引入 IDisposable,用来让租约支持 Dispose 释放。
using System.Collections.Generic; // 引入 Dictionary,用来保存资源表和持有者计数。
using System.Threading.Tasks; // 引入 Task,用来表示异步加载。
using UnityEngine; // 引入 Unity 的 GameObject 和 Debug。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载和释放接口。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 的异步句柄。
public sealed class ResourceLease<T> : IDisposable where T : UnityEngine.Object // 定义资源租约,每次 Acquire 都返回一个租约。
{ // ResourceLease 类开始。
private readonly Action<ResourceLease<T>> releaseAction; // 保存释放回调,由资源管理器传入。
private bool disposed; // 标记这个租约是否已经释放过。
public string Key { get; } // 保存资源 key。
public string Owner { get; } // 保存持有该资源的模块名。
public T Asset { get; } // 保存实际资源对象。
internal ResourceLease(string key, string owner, T asset, Action<ResourceLease<T>> release) // 构造资源租约。
{ // 构造函数开始。
Key = key; // 记录资源 key。
Owner = owner; // 记录资源持有者。
Asset = asset; // 记录资源对象。
releaseAction = release; // 记录释放回调。
} // 构造函数结束。
public void Dispose() // 释放租约。
{ // Dispose 方法开始。
if (disposed) // 如果这个租约已经释放过。
{ // if 开始。
Debug.LogError($"重复释放资源: {Key}, owner = {Owner}"); // 打印重复释放错误。
return; // 直接返回,避免引用计数被多减一次。
} // if 结束。
disposed = true; // 标记租约已经释放。
releaseAction(this); // 通知资源管理器减少引用计数。
} // Dispose 方法结束。
} // ResourceLease 类结束。
public sealed class PrefabResourceManager // 定义 Prefab 资源管理器。
{ // PrefabResourceManager 类开始。
private sealed class Entry // 定义资源表中的一条记录。
{ // Entry 类开始。
public AsyncOperationHandle<GameObject> Handle; // 保存 Addressables 加载句柄。
public int RefCount; // 保存总引用计数。
public Dictionary<string, int> OwnerRefs = new Dictionary<string, int>(); // 保存每个模块的引用数量。
} // Entry 类结束。
private readonly Dictionary<string, Entry> entries = new Dictionary<string, Entry>(); // 保存所有已加载资源记录。
public async Task<ResourceLease<GameObject>> AcquireAsync(string key, string owner) // 获取资源并增加引用计数。
{ // AcquireAsync 方法开始。
if (entries.TryGetValue(key, out Entry cached)) // 如果资源已经加载过。
{ // if 开始。
cached.RefCount += 1; // 总引用计数加一。
AddOwnerRef(cached, owner); // 持有者引用计数加一。
return new ResourceLease<GameObject>(key, owner, cached.Handle.Result, Release); // 返回新的资源租约。
} // if 结束。
AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key); // 第一次请求时异步加载资源。
GameObject asset = await handle.Task; // 等待资源和依赖加载完成。
Entry entry = new Entry(); // 创建资源记录。
entry.Handle = handle; // 保存 Addressables 句柄。
entry.RefCount = 1; // 第一个使用者让引用计数变成一。
AddOwnerRef(entry, owner); // 记录第一个持有者。
entries[key] = entry; // 把资源记录放入资源表。
return new ResourceLease<GameObject>(key, owner, asset, Release); // 返回资源租约。
} // AcquireAsync 方法结束。
private void Release(ResourceLease<GameObject> lease) // 释放资源租约。
{ // Release 方法开始。
if (!entries.TryGetValue(lease.Key, out Entry entry)) // 如果资源表里找不到该资源。
{ // if 开始。
Debug.LogError($"释放未知资源: {lease.Key}"); // 打印错误,说明释放顺序有问题。
return; // 直接返回。
} // if 结束。
entry.RefCount -= 1; // 总引用计数减一。
RemoveOwnerRef(entry, lease.Owner); // 持有者引用计数减一。
if (entry.RefCount > 0) // 如果还有模块正在使用资源。
{ // if 开始。
return; // 不真正释放资源。
} // if 结束。
Addressables.Release(entry.Handle); // 引用归零后释放 Addressables 句柄。
entries.Remove(lease.Key); // 从资源表中移除该资源记录。
} // Release 方法结束。
private static void AddOwnerRef(Entry entry, string owner) // 增加某个模块的持有计数。
{ // AddOwnerRef 方法开始。
entry.OwnerRefs.TryGetValue(owner, out int count); // 查询模块当前持有数量。
entry.OwnerRefs[owner] = count + 1; // 模块持有数量加一。
} // AddOwnerRef 方法结束。
private static void RemoveOwnerRef(Entry entry, string owner) // 减少某个模块的持有计数。
{ // RemoveOwnerRef 方法开始。
if (!entry.OwnerRefs.TryGetValue(owner, out int count)) // 如果模块没有持有记录。
{ // if 开始。
Debug.LogError($"模块未持有却释放: {owner}"); // 打印错误,说明释放方不合法。
return; // 直接返回。
} // if 结束。
count -= 1; // 模块持有数量减一。
if (count <= 0) // 如果该模块已经不再持有资源。
{ // if 开始。
entry.OwnerRefs.Remove(owner); // 移除该模块记录。
return; // 结束方法。
} // if 结束。
entry.OwnerRefs[owner] = count; // 更新模块剩余持有数量。
} // RemoveOwnerRef 方法结束。
} // PrefabResourceManager 类结束。Unity 里要特别注意Destroy(instance) 只是销毁场景实例,不等于释放资源。 如果 Prefab 是通过 Addressables 加载的,还要在不用时 Release(handle)。 如果多个模块共用同一个贴图、材质或 Prefab,不能某个模块一释放就卸载资源,否则其他模块会丢引用。
NOTE
面试加分说法 “我会让资源管理器按资源 key 统一维护引用计数。业务模块只拿租约,不直接操作底层句柄。Acquire 时引用加一,Release 时引用减一,归零才释放。Debug 模式下我会记录 owner 和租约状态,防止重复释放、漏释放,也方便定位资源泄漏。”
资源卸载时机怎么定?
标准回答 资源卸载时机不能只看“我现在不用了”,而要看三件事:
引用计数是否归零。 资源是否应该进入缓存。 当前是不是安全卸载时机。
一句话:Release 表示业务不用了,真正卸载要等引用归零、缓存策略允许、并且处在安全点。
底层原理 资源生命周期一般分两层:
业务生命周期:UI 关闭、战斗结束、场景退出、特效播放完。 内存生命周期:资源句柄什么时候真正释放,Bundle 什么时候卸载,Native 内存什么时候下降。
业务层调用 Release,只是告诉资源管理器:“我不再持有这个资源”。 资源管理器还要判断 refCount 是否为 0。如果还有别的模块在用,就不能卸载。 如果引用归零,也不一定马上卸载,因为这个资源可能马上又会用到。频繁加载和卸载会造成 IO、CPU、内存抖动。
Unity 工程实践 我一般这样定时机:
UI 弹窗资源:关闭界面时 Release,但常用 UI 图集可以短期缓存。 战斗资源:战斗结算后 Release,下一场可能复用的技能特效可以走对象池或缓存。 场景资源:离开场景时批量 Release,加载页阶段再真正卸载。 活动资源:活动结束或退出活动玩法时释放。 公共资源:字体、公共 Shader、公共材质、基础图集通常常驻。 低内存时:触发更激进的 LRU 淘汰和缓存清理。
Resources.UnloadUnusedAssets()、大量 Addressables.Release()、AssetBundle.Unload() 都可能造成卡顿,所以真正的大清理通常放在:
切场景 loading 页。 战斗结算黑屏阶段。 玩家不可操作阶段。 低内存保护流程里。
代码示例:Release 后进入卸载队列,在安全点真正卸载
c
using System.Collections.Generic; // 引入 Dictionary 和 Queue,用来保存资源表和卸载队列。
using UnityEngine; // 引入 Unity 的 Debug 和 Time。
using UnityEngine.AddressableAssets; // 引入 Addressables 的 Release 接口。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 的资源句柄。
public sealed class ResourceEntry // 定义资源记录。
{ // ResourceEntry 类开始。
public string Key; // 资源唯一 key。
public int RefCount; // 当前引用计数。
public bool KeepResident; // 是否常驻内存。
public float LastUsedTime; // 最近一次被释放到零引用的时间。
public AsyncOperationHandle Handle; // Addressables 资源句柄。
} // ResourceEntry 类结束。
public sealed class ResourceUnloadManager // 定义资源卸载管理器。
{ // ResourceUnloadManager 类开始。
private readonly Dictionary<string, ResourceEntry> entries = new Dictionary<string, ResourceEntry>(); // 保存所有资源记录。
private readonly Queue<string> unloadQueue = new Queue<string>(); // 保存等待真正卸载的资源 key。
private const float CacheSeconds = 30f; // 零引用资源最多缓存 30 秒。
public void Release(string key) // 业务层释放资源。
{ // Release 方法开始。
if (!entries.TryGetValue(key, out ResourceEntry entry)) // 如果资源表中找不到该资源。
{ // if 开始。
Debug.LogError($"释放未知资源: {key}"); // 打印错误,说明释放顺序有问题。
return; // 直接返回。
} // if 结束。
entry.RefCount -= 1; // 引用计数减一。
if (entry.RefCount > 0) // 如果还有其他模块持有资源。
{ // if 开始。
return; // 不进入卸载流程。
} // if 结束。
entry.LastUsedTime = Time.realtimeSinceStartup; // 记录资源变成零引用的时间。
if (entry.KeepResident) // 如果资源是常驻资源。
{ // if 开始。
return; // 常驻资源不卸载。
} // if 结束。
unloadQueue.Enqueue(key); // 把资源放入待卸载队列。
} // Release 方法结束。
public void FlushUnloadQueue(bool force) // 在安全点执行真正卸载。
{ // FlushUnloadQueue 方法开始。
int count = unloadQueue.Count; // 记录当前队列数量。
for (int i = 0; i < count; i++) // 遍历本轮待处理资源。
{ // for 开始。
string key = unloadQueue.Dequeue(); // 取出一个待卸载资源 key。
if (!entries.TryGetValue(key, out ResourceEntry entry)) // 如果资源已经被移除。
{ // if 开始。
continue; // 跳过这个资源。
} // if 结束。
if (entry.RefCount > 0) // 如果资源在等待卸载期间又被重新引用。
{ // if 开始。
continue; // 不卸载,避免误删正在使用的资源。
} // if 结束。
bool cacheExpired = Time.realtimeSinceStartup - entry.LastUsedTime >= CacheSeconds; // 判断缓存时间是否已经过期。
if (!force && !cacheExpired) // 如果不是强制卸载,并且缓存时间还没过。
{ // if 开始。
unloadQueue.Enqueue(key); // 放回队列,等待下次安全点。
continue; // 跳过本次卸载。
} // if 结束。
Addressables.Release(entry.Handle); // 真正释放 Addressables 句柄。
entries.Remove(key); // 从资源表中删除该资源记录。
} // for 结束。
} // FlushUnloadQueue 方法结束。
} // ResourceUnloadManager 类结束。常见坑 不要 Destroy(instance) 后以为资源已经释放了,实例销毁和资源句柄释放是两回事。 不要每关闭一个小界面就立刻 UnloadUnusedAssets(),很容易卡顿。 不要把公共 Shader、字体、通用图集频繁卸载,否则下一次加载又会抖。 不要只释放主资源,依赖资源也要通过引用计数递减。 不要在战斗中或玩家操作中途做大规模资源清理。
NOTE
面试加分说法 “我会把业务 Release 和真正卸载分开。业务模块关闭时先减少引用计数,引用归零后进入待卸载队列。资源管理器再根据常驻策略、LRU、缓存时间和内存压力决定是否卸载。真正的大清理我会放在 loading 页、切场景或低内存保护阶段,避免在战斗中触发卡顿。”
切场景如何避免峰值内存?
标准回答 切场景避免峰值内存的核心是:不要让旧场景资源和新场景资源长时间同时存在。
常见错误是:旧场景还没释放,新场景已经开始加载,结果内存短时间变成:
旧场景资源 + 新场景资源 + 公共资源 + 加载缓存正确思路是:先进轻量 Loading Scene,释放旧场景引用,在安全点清理,再分批加载新场景。
底层原理 Unity 切场景时,旧场景对象销毁不等于资源马上释放。 如果旧场景里的 UI、对象池、单例、事件、Addressables 句柄还持有引用,贴图、Mesh、AudioClip、Prefab 依赖就还可能留在内存里。 这时候再异步加载新场景,新场景的 Bundle、贴图、材质、光照贴图也会进内存,于是峰值就上去了。
所以要把“业务退出旧场景”和“真正释放内存”拆开处理。
Unity 工程实践 我一般会这样做:
先淡出画面,禁止玩家输入。 进入一个很轻的 LoadingScene,它只保留进度条和必要 UI。 通知旧场景模块退出,取消异步任务,退订事件,清对象池。 释放旧场景 Addressables / AssetBundle 句柄。 在 Loading 页安全点执行 Resources.UnloadUnusedAssets()。 必要时再做一次 GC.Collect(),但不要频繁用。 新场景按阶段加载:核心场景先加载,远景、怪物、特效、音频延后加载。 进入新场景后再按距离、镜头、玩法阶段流式加载。
代码示例:低峰值切场景流程
c
using System.Collections; // 引入 IEnumerator,用来写协程流程。
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 AsyncOperation。
using UnityEngine.SceneManagement; // 引入 SceneManager,用来异步加载场景。
public sealed class LowPeakSceneSwitcher : MonoBehaviour // 定义低峰值切场景控制器。
{ // 类开始。
[SerializeField] private string loadingSceneName = "Loading"; // 配置轻量 Loading 场景名。
private bool switching; // 标记当前是否正在切场景,防止重复触发。
public void SwitchTo(string targetSceneName) // 对外暴露切换到目标场景的方法。
{ // 方法开始。
if (switching) // 如果已经在切场景。
{ // if 开始。
return; // 直接返回,避免重复加载导致峰值更高。
} // if 结束。
StartCoroutine(SwitchRoutine(targetSceneName)); // 启动切场景协程。
} // 方法结束。
private IEnumerator SwitchRoutine(string targetSceneName) // 定义完整切场景协程。
{ // 协程开始。
switching = true; // 标记正在切场景。
yield return FadeOut(); // 先淡出画面,遮住释放和加载过程。
ReleaseOldSceneLogic(); // 通知旧场景业务退出并释放引用。
yield return SceneManager.LoadSceneAsync(loadingSceneName, LoadSceneMode.Single); // 切到轻量 Loading 场景,降低旧场景对象占用。
yield return Resources.UnloadUnusedAssets(); // 在 Loading 页安全点清理未使用资源。
System.GC.Collect(); // 可选:在安全点触发托管 GC,避免进入新场景后突然 GC。
AsyncOperation loadOp = SceneManager.LoadSceneAsync(targetSceneName, LoadSceneMode.Single); // 开始异步加载目标场景。
loadOp.allowSceneActivation = false; // 先不激活目标场景,方便控制进度和预处理。
while (loadOp.progress < 0.9f) // Unity 场景加载通常到 0.9 表示等待激活。
{ // while 开始。
UpdateLoadingProgress(loadOp.progress); // 更新加载进度条。
yield return null; // 等待下一帧,避免卡住主线程。
} // while 结束。
PrewarmBeforeActivate(); // 激活前做必要的轻量预热。
loadOp.allowSceneActivation = true; // 允许目标场景激活。
while (!loadOp.isDone) // 等待目标场景真正切换完成。
{ // while 开始。
yield return null; // 等待下一帧。
} // while 结束。
LoadNonCriticalAssetsLater(); // 新场景进入后再延迟加载非关键资源。
switching = false; // 标记切场景结束。
} // 协程结束。
private IEnumerator FadeOut() // 定义淡出流程。
{ // 方法开始。
yield return null; // 示例中省略具体 UI 淡出实现。
} // 方法结束。
private void ReleaseOldSceneLogic() // 释放旧场景业务引用。
{ // 方法开始。
Debug.Log("关闭旧 UI,清理对象池,释放场景资源句柄"); // 示例中用日志代表旧场景清理流程。
} // 方法结束。
private void UpdateLoadingProgress(float progress) // 更新加载进度。
{ // 方法开始。
Debug.Log($"Loading progress: {progress}"); // 示例中用日志显示加载进度。
} // 方法结束。
private void PrewarmBeforeActivate() // 激活前预热必要资源。
{ // 方法开始。
Debug.Log("只预热首屏必要资源"); // 示例中只做轻量预热。
} // 方法结束。
private void LoadNonCriticalAssetsLater() // 延后加载非关键资源。
{ // 方法开始。
Debug.Log("远景、特效、音频、怪物资源分批加载"); // 示例中用日志代表分批加载。
} // 方法结束。
} // 类结束。关键优化点 资源分组要按生命周期拆:公共资源常驻,场景资源随场景释放,活动资源随活动释放。 对象池切场景前要 Clear 或 Shrink,否则池子会把旧资源引用住。 大贴图、光照贴图、音频不要和场景一次性全进,可以分阶段加载。 UnloadUnusedAssets 很贵,适合放在 Loading 页,不适合战斗中频繁调用。 用 Memory Profiler 对比三个点:切换前、切换峰值、切换后稳定值。
IMPORTANT
面试加分说法 “我会把切场景拆成错峰流程:先进入轻量 Loading Scene,清理旧场景模块和对象池,释放资源句柄,在安全点执行资源清理,然后再分批加载新场景。优化时我不会只看切换后内存,而是重点看切换过程中的最高峰值,确保它低于低端机预算。”
资源加载失败怎么办?
标准回答 资源加载失败不能只 try/catch,要按失败类型处理:
可恢复失败:比如网络超时、CDN 临时错误,可以重试。 不可恢复失败:比如地址错误、资源缺失、Hash 不一致,要降级或阻断。 非关键资源失败:用默认资源、占位图、低配资源替代。 关键资源失败:不能继续进玩法,要提示用户重试、重新下载或回滚版本。 任何失败:都要清理已加载的部分依赖,并上报日志。
底层原理 资源加载失败常见原因有:
地址错误:address 写错或配置表引用了不存在的资源。 依赖缺失:Prefab 依赖的材质、贴图、Shader 没打进包。 版本错误:客户端 Catalog 和远端资源版本不一致。 Hash 错误:下载文件损坏或 CDN 缓存异常。 网络失败:断网、超时、请求失败。 内存不足:低端机加载大贴图、大场景时失败。 取消加载:切场景或退出模块时,加载任务被主动取消。
所以资源管理器要做的是:分类、重试、降级、清理、上报。
Unity 工程实践 如果是 Addressables,我会检查 AsyncOperationStatus。 如果失败,要释放无效句柄,避免部分依赖残留。 如果是非关键资源,比如头像、图标、特效、音效,可以用默认资源。 如果是关键资源,比如场景、主角 Prefab、战斗配置,就不能硬进玩法,否则后面会空引用或黑屏。
线上还要记录:
资源地址。 资源版本。 Catalog 版本。 失败原因。 设备型号。 网络类型。 当前场景。 是否重试成功。
代码示例:带重试、降级、清理和上报的加载封装
c
using System; // 引入 Exception,用来记录异常信息。
using System.Threading.Tasks; // 引入 Task,用来写异步加载逻辑。
using UnityEngine; // 引入 Unity 的 Object 和 Debug。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载和释放接口。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle 和状态枚举。
public sealed class SafeAssetLoader // 定义一个安全资源加载器。
{ // 类开始。
private const int MaxRetryCount = 3; // 最多重试 3 次。
private const int RetryDelayMs = 500; // 每次重试间隔 500 毫秒。
public async Task<T> LoadAsync<T>(string address, T fallback, bool critical) where T : UnityEngine.Object // 定义安全异步加载方法。
{ // 方法开始。
Exception lastException = null; // 记录最后一次异常。
for (int i = 0; i < MaxRetryCount; i++) // 按最大次数循环重试。
{ // for 开始。
AsyncOperationHandle<T> handle = Addressables.LoadAssetAsync<T>(address); // 发起 Addressables 异步加载。
try // 捕获加载过程中的异常。
{ // try 开始。
T asset = await handle.Task; // 等待资源和依赖加载完成。
if (handle.Status == AsyncOperationStatus.Succeeded && asset != null) // 判断加载是否成功并且资源不为空。
{ // if 开始。
return asset; // 成功时返回资源。
} // if 结束。
lastException = new Exception($"加载失败: {address}, status = {handle.Status}"); // 记录状态失败原因。
} // try 结束。
catch (Exception ex) // 捕获 Addressables 抛出的异常。
{ // catch 开始。
lastException = ex; // 保存异常,后面上报用。
} // catch 结束。
finally // 无论成功还是失败都会执行。
{ // finally 开始。
if (handle.IsValid() && handle.Status != AsyncOperationStatus.Succeeded) // 如果句柄有效但加载没有成功。
{ // if 开始。
Addressables.Release(handle); // 释放失败句柄和可能部分成功的依赖。
} // if 结束。
} // finally 结束。
await Task.Delay(RetryDelayMs * (i + 1)); // 等待一段时间再重试,避免立刻重复打爆网络请求。
} // for 结束。
ReportLoadFailure(address, lastException); // 重试仍失败后上报错误。
if (critical) // 如果这是关键资源。
{ // if 开始。
throw new Exception($"关键资源加载失败: {address}", lastException); // 关键资源失败时阻断流程。
} // if 结束。
return fallback; // 非关键资源失败时返回兜底资源。
} // 方法结束。
private void ReportLoadFailure(string address, Exception exception) // 定义资源加载失败上报方法。
{ // 方法开始。
Debug.LogError($"资源加载失败 address = {address}, error = {exception?.Message}"); // 打印错误日志,真实项目里会接入日志系统。
} // 方法结束。
} // 类结束。常见处理策略 网络失败:重试,带超时和退避,不要无限重试。 Hash 错误:清本地缓存,重新下载,必要时切备用 CDN。 Catalog 错误:重新拉取 Catalog,或者回滚到旧版本。 依赖缺失:阻断流程,上报构建错误,不能继续硬跑。 非关键资源缺失:用默认图、默认音效、默认材质。 关键资源缺失:弹窗提示重新下载或返回登录页。 内存不足:释放旧资源,降低质量,禁止继续加载大资源。
IMPORTANT
面试加分说法 “我会把资源加载失败分成可恢复和不可恢复。网络类失败可以有限重试,资源缺失和 Hash 错误要清缓存或阻断,非关键资源可以降级到占位资源,关键资源失败则不能进入玩法。同时失败后必须释放已经部分加载成功的依赖,并上报 address、版本、设备和网络环境,方便线上定位。”
热更新资源版本如何比较?
标准回答 热更新资源版本比较,不能只比较一个“版本号谁大”。正式项目里通常是:本地 Manifest 和远端 Manifest 做资源级 Diff。
比较顺序一般是:
先比较 App 兼容版本。 再拉取远端 Manifest。 校验 Manifest 签名或 Hash。 按资源 key 对比本地和远端记录。 hash 不同就下载,hash 相同就保留。 下载完成后再次校验 hash / crc / size。 全部成功后再提交新 Manifest,失败则继续用旧版本。
底层原理 Manifest 可以理解成资源清单,里面通常会有:
manifestVersion:清单版本。 minAppVersion:最低客户端版本。 key:资源唯一标识。 hash:资源内容 Hash。 size:资源大小,用于校验和进度计算。 crc:完整性校验。 deps:依赖资源列表。 url:下载地址。
版本号主要用来判断“是否有新清单”和“客户端是否兼容”。 真正判断资源是否要更新,主要看 资源 Hash。因为同一个资源名,只要内容变了,Hash 就会变。
Diff 结果一般分四类 新增:远端有,本地没有。 修改:本地和远端都有,但 Hash 不同。 删除:本地有,远端没有,可以延迟清理。 保留:本地和远端都有,并且 Hash 相同。
代码示例:Manifest Diff 生成更新列表
c
using System; // 引入 Exception,用来在版本不兼容时抛出错误。
using System.Collections.Generic; // 引入 Dictionary 和 List,用来存储资源表和差异列表。
public enum PatchOperation // 定义资源差异操作类型。
{ // 枚举开始。
Add, // 表示远端新增资源。
Modify, // 表示资源存在但内容发生变化。
Delete, // 表示远端已经删除该资源。
Keep // 表示资源没有变化。
} // 枚举结束。
public sealed class ResourceInfo // 定义 Manifest 中的一条资源记录。
{ // 类开始。
public string Key; // 资源唯一 key。
public string Hash; // 资源内容 Hash。
public long Size; // 资源大小。
public string Crc; // 资源校验码。
public string[] Dependencies; // 资源依赖列表。
} // 类结束。
public sealed class ResourceManifest // 定义资源 Manifest。
{ // 类开始。
public string ManifestVersion; // Manifest 版本号。
public string MinAppVersion; // 该资源清单要求的最低 App 版本。
public Dictionary<string, ResourceInfo> Resources = new Dictionary<string, ResourceInfo>(); // 资源 key 到资源信息的映射表。
} // 类结束。
public sealed class PatchItem // 定义一个资源差异结果。
{ // 类开始。
public PatchOperation Operation; // 差异操作类型。
public ResourceInfo Local; // 本地资源信息。
public ResourceInfo Remote; // 远端资源信息。
} // 类结束。
public static class ManifestDiffer // 定义 Manifest 对比工具类。
{ // 类开始。
public static List<PatchItem> BuildPatch(ResourceManifest local, ResourceManifest remote, string appVersion) // 根据本地和远端 Manifest 生成更新列表。
{ // 方法开始。
if (!IsCompatible(appVersion, remote.MinAppVersion)) // 先判断当前 App 是否满足远端资源最低版本。
{ // if 开始。
throw new Exception("客户端版本过低,需要整包更新"); // 不兼容时不能继续热更资源。
} // if 结束。
List<PatchItem> result = new List<PatchItem>(); // 创建差异结果列表。
foreach (KeyValuePair<string, ResourceInfo> pair in remote.Resources) // 遍历远端资源表。
{ // foreach 开始。
string key = pair.Key; // 取出资源 key。
ResourceInfo remoteInfo = pair.Value; // 取出远端资源信息。
if (!local.Resources.TryGetValue(key, out ResourceInfo localInfo)) // 如果本地没有这个资源。
{ // if 开始。
result.Add(new PatchItem { Operation = PatchOperation.Add, Remote = remoteInfo }); // 记录为新增资源。
continue; // 继续处理下一个资源。
} // if 结束。
if (localInfo.Hash != remoteInfo.Hash || localInfo.Size != remoteInfo.Size) // 如果 Hash 或大小不同。
{ // if 开始。
result.Add(new PatchItem { Operation = PatchOperation.Modify, Local = localInfo, Remote = remoteInfo }); // 记录为修改资源。
continue; // 继续处理下一个资源。
} // if 结束。
result.Add(new PatchItem { Operation = PatchOperation.Keep, Local = localInfo, Remote = remoteInfo }); // Hash 相同则记录为保留资源。
} // foreach 结束。
foreach (KeyValuePair<string, ResourceInfo> pair in local.Resources) // 遍历本地资源表。
{ // foreach 开始。
if (!remote.Resources.ContainsKey(pair.Key)) // 如果远端 Manifest 已经没有这个资源。
{ // if 开始。
result.Add(new PatchItem { Operation = PatchOperation.Delete, Local = pair.Value }); // 记录为删除或过期资源。
} // if 结束。
} // foreach 结束。
return result; // 返回完整差异列表。
} // 方法结束。
private static bool IsCompatible(string appVersion, string minAppVersion) // 判断 App 版本是否满足资源最低要求。
{ // 方法开始。
Version app = new Version(appVersion); // 把当前 App 版本转换成 Version。
Version min = new Version(minAppVersion); // 把最低要求版本转换成 Version。
return app >= min; // 当前版本大于等于最低版本才兼容。
} // 方法结束。
} // 类结束。Unity 工程实践 如果用 Addressables,本质上就是比较远端 Catalog 和本地 Catalog,Unity 会帮你处理很多依赖和下载逻辑。 如果是自研 AssetBundle 热更,就要自己维护 Manifest,并且在下载后校验 Hash,再替换本地 Manifest。
关键点是:先下载到临时目录,校验通过后再提交。 不能边下载边覆盖正式资源,否则中途失败会导致本地版本处于半更新状态。
常见坑 只比较版本号,不比较 Hash,会漏掉异常资源。 下载完不二次校验,可能使用损坏文件。 Manifest 没有签名,可能被篡改。 资源更新成功但 Manifest 提交失败,会导致下次重复下载。 Manifest 提交成功但资源没下全,会导致启动缺资源。 旧资源不要立刻全删,可以延迟清理,方便回滚。
NOTE
面试加分说法 “我会用 Manifest 做资源级比较,而不是只看版本号。先判断 App 兼容性,再对比本地和远端 Manifest 中每个资源的 key、hash、size、crc 和依赖,生成新增、修改、删除、保留列表。下载时写入临时目录,校验通过后再原子提交新 Manifest,失败则继续使用旧版本,保证热更新可回滚。”
Bundle 冗余怎么发现?
标准回答 Bundle 冗余一般靠 构建报告 + 依赖反查表 发现。核心思路是:把每个 Bundle 里包含的资源和依赖都扫描出来,建立:
资源 GUID / 路径 -> 被哪些 Bundle 引用如果同一个资源被多个 Bundle 包含,尤其是贴图、音频、动画、材质、Shader,就可能存在冗余。
底层原理 比如 enemy_a.ab 和 enemy_b.ab 都依赖同一张 tex_fire.png。 如果 tex_fire.png 没有被单独打进公共 Bundle,它可能会被两个 Bundle 各自包含一份。
结果就是:
包体变大。 下载量变大。 加载后内存可能也变大。 热更新时同一资源可能重复更新。 依赖关系变乱,卸载也更难判断。
怎么发现 可以用几种方式:
Addressables 项目可以用 Analyze 工具看 Check Duplicate Bundle Dependencies。 AssetBundle 项目可以看 BuildReport、AssetBundle Browser 或自研扫描工具。 自研管线一般会在构建后导出依赖表,检查同一 GUID 是否出现在多个 Bundle。 还可以按资源大小排序,优先处理大贴图、大音频、大动画。
编辑器扫描示例:找出被多个 Bundle 依赖的资源
c
#if UNITY_EDITOR // 只在 Unity 编辑器环境下编译这段工具代码。
using System.Collections.Generic; // 引入 Dictionary 和 HashSet,用来统计资源和 Bundle 的关系。
using System.IO; // 引入 FileInfo,用来估算资源文件大小。
using UnityEditor; // 引入 UnityEditor,用来访问 AssetDatabase 和菜单。
using UnityEngine; // 引入 Debug,用来输出扫描结果。
public static class BundleDuplicateScanner // 定义一个 Bundle 冗余扫描工具类。
{ // 类开始。
[MenuItem("Tools/Bundle/Scan Duplicate Dependencies")] // 在 Unity 菜单栏添加扫描入口。
public static void Scan() // 定义扫描方法。
{ // 方法开始。
Dictionary<string, HashSet<string>> assetToBundles = new Dictionary<string, HashSet<string>>(); // 建立资源路径到 Bundle 集合的反查表。
string[] bundleNames = AssetDatabase.GetAllAssetBundleNames(); // 获取项目里所有 AssetBundle 名称。
foreach (string bundleName in bundleNames) // 遍历每一个 Bundle。
{ // foreach 开始。
string[] rootAssets = AssetDatabase.GetAssetPathsFromAssetBundle(bundleName); // 获取这个 Bundle 显式包含的资源。
foreach (string rootAsset in rootAssets) // 遍历 Bundle 中每个显式资源。
{ // foreach 开始。
string[] dependencies = AssetDatabase.GetDependencies(rootAsset, true); // 递归获取该资源的全部依赖。
foreach (string dependency in dependencies) // 遍历每一个依赖资源。
{ // foreach 开始。
if (!dependency.StartsWith("Assets/")) // 如果不是项目 Assets 下的资源。
{ // if 开始。
continue; // 跳过内置资源或包外资源。
} // if 结束。
if (dependency.EndsWith(".cs")) // 如果依赖是脚本文件。
{ // if 开始。
continue; // 跳过脚本,避免干扰资源冗余分析。
} // if 结束。
if (!assetToBundles.TryGetValue(dependency, out HashSet<string> bundles)) // 如果反查表里还没有这个资源。
{ // if 开始。
bundles = new HashSet<string>(); // 创建一个新的 Bundle 集合。
assetToBundles[dependency] = bundles; // 把资源和 Bundle 集合放入反查表。
} // if 结束。
bundles.Add(bundleName); // 记录这个资源被当前 Bundle 依赖。
} // foreach 结束。
} // foreach 结束。
} // foreach 结束。
foreach (KeyValuePair<string, HashSet<string>> pair in assetToBundles) // 遍历资源反查表。
{ // foreach 开始。
if (pair.Value.Count <= 1) // 如果资源只被一个 Bundle 依赖。
{ // if 开始。
continue; // 说明没有跨 Bundle 冗余嫌疑。
} // if 结束。
long size = File.Exists(pair.Key) ? new FileInfo(pair.Key).Length : 0; // 读取资源原文件大小,用来估算冗余优先级。
string bundleList = string.Join(", ", pair.Value); // 把所有引用该资源的 Bundle 名拼成字符串。
Debug.LogWarning($"疑似冗余资源: {pair.Key}, size = {size}, bundles = {bundleList}"); // 输出疑似冗余资源报告。
} // foreach 结束。
} // 方法结束。
} // 类结束。
#endif // 结束编辑器条件编译。修复方式 如果某个资源被多个 Bundle 依赖,并且资源比较大、复用次数多,就可以把它抽到公共 Bundle,比如:
shared_ui.abshared_shader.abshared_fx.abshared_audio.ab
然后让其他 Bundle 依赖这个公共 Bundle,而不是各自包含一份。
注意坑点 不是所有重复依赖都必须抽公共包。 如果资源很小,或者只被两个很少同时加载的 Bundle 用,抽出来可能反而增加请求数量和依赖复杂度。 公共 Bundle 也不能做得太大,否则一个小功能可能被迫加载一大包公共资源。 修复后一定要重新构建,对比总包体、单包大小、加载依赖和运行时内存。
IMPORTANT
面试加分说法 “我发现 Bundle 冗余一般会做依赖反查表,用 GUID 或资源路径统计一个资源被哪些 Bundle 包含。重点看大贴图、音频、动画、材质这些资源。如果同一资源被多个 Bundle 重复包含,就评估是否抽到 shared bundle。修复后不会只看扫描结果,还会重新构建,对比包体、下载量、加载依赖和运行时内存。”
常驻资源怎么管理?
标准回答 常驻资源不是“放进内存后永远不管”,而是:白名单化、句柄化、预算化、可审计。
也就是只有真正跨场景、高频、基础依赖的资源才常驻,比如:
字体。 公共 Shader。 默认材质。 基础 UI 图集。 Loading 界面资源。 通用占位图。 公共音效配置。
大型场景贴图、Boss、剧情音频、活动资源、大特效,一般不应该常驻。
底层原理 普通资源靠引用计数管理:谁用谁 Acquire,不用就 Release,归零后可卸载。 常驻资源可以理解成被资源管理器主动 Pin 住:它的句柄长期持有,所以不会被普通场景切换释放掉。
但是常驻资源必须有边界:
谁能常驻,要靠白名单。 常驻多少,要有内存预算。 什么时候加载,要在启动、登录、Loading 页等安全阶段。 什么时候释放,一般是退出游戏、回登录、切大版本、低内存保护时。 不能把“缓存资源”误当成“常驻资源”,否则内存会越堆越高。
Unity 工程实践 我会做一个 ResidentResourceManager:
启动时加载常驻白名单。 保存 Addressables 句柄。 业务模块只能 Get,不能随便 Release。 常驻资源单独统计内存和数量。 低端机可以裁剪部分非核心常驻资源。 回登录或 App 退出时统一释放。 热更新切版本时要重新校验常驻资源版本。
代码示例:常驻资源管理器
c
using System.Collections.Generic; // 引入集合类型,用来保存常驻资源表。
using System.Threading.Tasks; // 引入 Task,用来支持异步预加载。
using UnityEngine; // 引入 Unity 的 Object 和 Debug。
using UnityEngine.AddressableAssets; // 引入 Addressables 加载和释放接口。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 Addressables 句柄类型。
public sealed class ResidentResourceManager // 定义常驻资源管理器。
{ // 类开始。
private readonly Dictionary<string, AsyncOperationHandle<UnityEngine.Object>> handles = new Dictionary<string, AsyncOperationHandle<UnityEngine.Object>>(); // 保存常驻资源句柄。
private readonly Dictionary<string, UnityEngine.Object> assets = new Dictionary<string, UnityEngine.Object>(); // 保存常驻资源对象。
private readonly HashSet<string> residentKeys = new HashSet<string>(); // 保存允许常驻的资源白名单。
public async Task PreloadAsync(IEnumerable<string> keys) // 预加载常驻资源列表。
{ // 方法开始。
foreach (string key in keys) // 遍历所有配置的常驻资源 key。
{ // foreach 开始。
if (assets.ContainsKey(key)) // 如果资源已经加载过。
{ // if 开始。
continue; // 跳过重复加载。
} // if 结束。
residentKeys.Add(key); // 把资源加入常驻白名单。
AsyncOperationHandle<UnityEngine.Object> handle = Addressables.LoadAssetAsync<UnityEngine.Object>(key); // 异步加载资源。
UnityEngine.Object asset = await handle.Task; // 等待资源加载完成。
if (handle.Status != AsyncOperationStatus.Succeeded || asset == null) // 判断加载是否失败。
{ // if 开始。
Debug.LogError($"常驻资源加载失败: {key}"); // 输出加载失败日志。
if (handle.IsValid()) // 如果句柄有效。
{ // if 开始。
Addressables.Release(handle); // 释放失败句柄。
} // if 结束。
continue; // 跳过这个失败资源。
} // if 结束。
handles[key] = handle; // 保存常驻资源句柄。
assets[key] = asset; // 保存常驻资源对象。
} // foreach 结束。
} // 方法结束。
public T Get<T>(string key) where T : UnityEngine.Object // 获取常驻资源。
{ // 方法开始。
if (!assets.TryGetValue(key, out UnityEngine.Object asset)) // 如果资源表里没有这个资源。
{ // if 开始。
Debug.LogError($"常驻资源不存在: {key}"); // 输出错误日志。
return null; // 返回空引用。
} // if 结束。
return asset as T; // 把资源转换成调用方需要的类型。
} // 方法结束。
public bool IsResident(string key) // 判断某个资源是否属于常驻资源。
{ // 方法开始。
return residentKeys.Contains(key); // 查询白名单中是否包含该资源。
} // 方法结束。
public void ReleaseAllResidents() // 释放所有常驻资源。
{ // 方法开始。
foreach (KeyValuePair<string, AsyncOperationHandle<UnityEngine.Object>> pair in handles) // 遍历所有常驻句柄。
{ // foreach 开始。
if (pair.Value.IsValid()) // 如果句柄仍然有效。
{ // if 开始。
Addressables.Release(pair.Value); // 释放 Addressables 句柄。
} // if 结束。
} // foreach 结束。
handles.Clear(); // 清空句柄表。
assets.Clear(); // 清空资源对象表。
residentKeys.Clear(); // 清空白名单记录。
} // 方法结束。
} // 类结束。常驻资源和缓存的区别 常驻资源:明确 Pin 住,跨场景长期存在,比如字体、公共 Shader。 缓存资源:暂时保留,方便复用,但可以被 LRU、内存压力或超时策略淘汰。 普通资源:按模块引用计数,归零后进入卸载流程。
常见坑 把所有常用资源都设为常驻,会导致低端机内存爆。 常驻资源没有预算,项目越做越大,内存只涨不降。 常驻资源不走统一管理器,谁都能加载一份,反而重复占内存。 热更新后常驻资源版本没刷新,可能出现旧 Shader、旧图集、旧配置。 公共图集太大也不一定适合常驻,要按首屏和复用频率拆。
TIP
面试加分说法 “我会把常驻资源做成白名单,由资源管理器在启动或登录阶段预加载并持有句柄。业务模块只能查询使用,不能随便释放。常驻资源会有内存预算和审计表,低端机可以裁剪非核心常驻项。它和缓存不同:常驻是明确 Pin 住,缓存是可淘汰的。”
如何做资源泄漏检测?
标准回答 资源泄漏检测的核心是:证明某个资源在业务结束后本该释放,但仍然被引用着。
我一般会做一套闭环:
进入场景前记录基线。 场景运行中记录资源加载和持有者。 退出场景后释放 UI、对象池、事件、Addressables 句柄。 在安全点执行清理。 用 Memory Profiler 对比前后快照。 用资源管理器导出的 refCount / owner / callstack 找到是谁没释放。 重复进出场景多次,观察稳定内存是否持续上涨。
底层原理 Unity 里资源泄漏常见分两类:
Managed 泄漏:C# 对象、List、Dictionary、事件订阅、静态引用没有释放。 Native 泄漏:Texture、Mesh、AudioClip、Material、RenderTexture、Addressables 句柄没有释放。
比如 Destroy(gameObject) 只是销毁实例,不代表它引用的 Prefab、Texture、Material 句柄都释放了。 如果对象池、静态缓存、事件系统、协程、异步任务还持有对象引用,资源就不会被卸载。
Unity 工程实践 我会重点查这些地方:
Addressables LoadAssetAsync 后有没有对应 Release。 对象池切场景时有没有 Clear 或 Shrink。 UI 关闭后图集、Prefab、Texture 是否还被引用。 事件订阅是否取消。 静态单例、全局缓存是否持有旧场景对象。 RenderTexture、临时 Material、Texture2D 是否手动释放。 DontDestroyOnLoad 对象是否错误持有场景资源。
代码示例:资源引用记录和泄漏报告
c
using System.Collections.Generic; // 引入集合类型,用来保存资源记录。
using UnityEngine; // 引入 Unity 的 Debug 和 Object 类型。
public sealed class ResourceLeakTracker // 定义资源泄漏追踪器。
{ // 类开始。
private sealed class Record // 定义单个资源的追踪记录。
{ // Record 类开始。
public string Key; // 资源唯一 key。
public int RefCount; // 当前引用计数。
public HashSet<string> Owners = new HashSet<string>(); // 当前持有该资源的模块集合。
public string FirstStack; // 第一次加载该资源时的调用栈。
} // Record 类结束。
private readonly Dictionary<string, Record> records = new Dictionary<string, Record>(); // 保存所有资源记录。
public void Acquire(string key, string owner) // 记录某个模块获取资源。
{ // Acquire 方法开始。
if (!records.TryGetValue(key, out Record record)) // 如果资源还没有记录。
{ // if 开始。
record = new Record(); // 创建新的资源记录。
record.Key = key; // 保存资源 key。
record.FirstStack = System.Environment.StackTrace; // 保存首次获取资源的调用栈。
records[key] = record; // 把资源记录放入字典。
} // if 结束。
record.RefCount += 1; // 引用计数加一。
record.Owners.Add(owner); // 记录当前持有者模块。
} // Acquire 方法结束。
public void Release(string key, string owner) // 记录某个模块释放资源。
{ // Release 方法开始。
if (!records.TryGetValue(key, out Record record)) // 如果找不到资源记录。
{ // if 开始。
Debug.LogError($"释放未知资源: {key}, owner = {owner}"); // 输出错误,说明释放顺序异常。
return; // 直接返回。
} // if 结束。
record.RefCount -= 1; // 引用计数减一。
record.Owners.Remove(owner); // 移除当前持有者模块。
if (record.RefCount <= 0) // 如果资源已经没有任何引用。
{ // if 开始。
records.Remove(key); // 从追踪表中移除该资源。
} // if 结束。
} // Release 方法结束。
public void ReportLeaks(string scopeName) // 输出指定阶段后的泄漏报告。
{ // ReportLeaks 方法开始。
foreach (KeyValuePair<string, Record> pair in records) // 遍历所有仍然存活的资源记录。
{ // foreach 开始。
Record record = pair.Value; // 取出资源记录。
string owners = string.Join(", ", record.Owners); // 拼接当前持有者列表。
Debug.LogWarning($"[{scopeName}] 疑似泄漏: {record.Key}, ref = {record.RefCount}, owners = {owners}"); // 输出泄漏摘要。
Debug.LogWarning(record.FirstStack); // 输出首次获取资源的调用栈。
} // foreach 结束。
} // ReportLeaks 方法结束。
} // ResourceLeakTracker 类结束。检测流程 第一步,定义预期:比如退出副本后,副本怪物、特效、地图贴图引用应该归零。 第二步,跑流程:进入副本,加载资源,退出副本,释放资源。 第三步,清理安全点:执行资源管理器释放队列,必要时在测试环境跑 UnloadUnusedAssets。 第四步,对比快照:用 Memory Profiler 看退出后还残留哪些 Texture、Mesh、AudioClip。 第五步,查 owner:用资源管理器导出的引用表定位哪个模块没 Release。 第六步,回归验证:重复进出 5 到 10 次,看内存稳定值是否持续上涨。
TIP
面试加分说法 “我不会只看内存曲线说泄漏,而是会先定义资源生命周期预期。资源管理器记录每次 Acquire 和 Release 的 owner、refCount、调用栈;场景退出后导出引用表,再用 Memory Profiler 对比快照。如果某个资源 refCount 不归零,或者 Native 对象在多次进出场景后持续残留,我就能定位到具体模块和调用链。”