Unity 里的 Resources 到底能不能用?拆了三个项目之后,我不建议一刀切
Resources 文件夹该不该用,这话题我从 2016 年用 Unity 5.3 那会儿就开始听人念叨了。Unity 官方 Best Practices 手册里写得很直白,原话是「Don't use Resources folders」,理由是这个目录下的东西会被无条件打进包里,哪怕你一次都没 Load 过。这话本身没毛病,但传着传着就变成了「谁用 Resources 谁就是菜鸟」。我不太同意,至少不完全同意。
上个月中旬接了个外包,帮一个已经上线半年的休闲游戏做包体瘦身。打开工程第一眼,Assets/Resources 目录下躺着 612 个文件,硬盘占用 340MB 上下,打出来的 APK 是 187MB。甲方说 iOS 那边想上架,超过 200MB 会弹蜂窝下载的提示,转化率要掉一大截,所以必须压下来。我接单时候的第一个念头也是「Resources 的锅」,但真拆下去发现事情没那么简单。
第一步不是换方案,是先数清楚谁在用谁
我没急着搬资源,先写了个编辑器脚本,用 AssetDatabase.GetDependencies 把 Build Settings 里那三个场景的依赖全导成一份 CSV,再跟 Resources 目录做个差集。跑完发现,612 个文件里真正还被场景或预制体引用到的只有 219 个,剩下那 393 个是历史包袱——美术中间换过两轮风格,旧图旧模型没人敢删,就一直躺在那里吃包体。
这一步我觉得很多人会跳过。按我的经验,包体问题里「垃圾资源」和「加载策略」的贡献比例大概是七三开。你先把那 393 个确定没用的删干净,187MB 直接掉到 130MB 出头,比换什么加载方案都来得快。删之前记得 git commit 一下,别问我怎么知道的。
剩下那 219 个资源,我测了三组数据
删完垃圾之后,剩下这批资源才是真正值得讨论「放哪儿」的部分。我把它们按三种方式各打了一次包,测试机是 Redmi Note 12 Turbo(骁龙 7+ Gen 2,Android 13,12GB 内存),每个版本冷启动进主界面跑 10 次取平均。工程是 Unity 2021.3.35f1,IL2CPP + ARM64,纹理压缩走 ASTC 6x6,音频 Vorbis,包体压缩统一 LZ4。
方案 A,这 219 个全部留在 Resources:APK 130.4MB,冷启动到主界面可交互 2.31 秒,出包耗时 6 分 48 秒。
方案 B,全部搬进 Addressables,做成一个本地 Group,Bundle Mode 用 Pack Together:APK 133.8MB,冷启动 1.94 秒,出包耗时 4 分 12 秒。
方案 C,混合——常驻内存的 UI 图集、字体、主界面 BGM 留在 Resources,其余进 Addressables,并且把 Addressables 的初始化时机从游戏启动推迟到点击「开始战斗」之后:APK 131.2MB,冷启动 1.62 秒,出包耗时 4 分 30 秒。
先说个反直觉的结论:换加载方案对包体几乎没影响,三者差距在 3.5MB 以内。B 反而最大,因为多出了 catalog 的 json 和每个 bundle 的文件头开销。真正被影响的是出包速度和启动耗时——Resources 目录越大,每次构建重建 resources.assets 索引的时间就越长,612 个文件那版能差出两三分钟。所以如果你的诉求只是「包太大」,先删资源、再换压缩格式,别指望换个加载框架就能瘦身。
我的判断标准:按生命周期切,不按资源类型切
网上流传的说法一般是「小的放 Resources,大的放 AssetBundle」,我觉得这个切法不太对。大和小是相对的,而且真正决定成本的不是文件大小,是这份资源在内存里待多久、被多少地方同时引用。
我现在用的判断标准只有一条:这份资源从被加载那一刻起,中途会不会被卸载?如果答案是不会——它一直活到游戏退出——那放 Resources 完全没问题,代码简单、路径查找是哈希、运行时甚至比走 Addressables 快一截。典型的就是 UI 图集、字体、图标、常驻 BGM、新手引导那几个预制体。这类东西留在 Resources,反而是方案 C 里主界面最快的原因。
反过来,只要是「进了某个关卡才加载、出了关卡就该扔掉」的资源,比如战斗场景的模型、特效、关卡专属音频,就该走 Addressables。因为你需要的是它能在切场景时被真正释放掉,而不是等 Resources.UnloadUnusedAssets 那种全量扫描的暴力回收——那个函数在真机中端机上跑一次,我这边测出来是 120 毫秒到 300 毫秒不等,跑在主线程上就是一次肉眼可见的掉帧。
至于需要热更、需要分渠道包、需要按语言下发不同资源的情况,那没得选,只能 Addressables 或者自己写 AssetBundle 管理,这部分复杂度是省不掉的。
真机上容易翻车的几个点
第一个是引用计数。Addressables.LoadAssetAsync 拿到的是一个 AsyncOperationHandle
第二个是 await handle.Task 的异常行为。如果你写同步逻辑,拿到的 handle 会给你一个 Status == Failed,你判断就行;但一旦用 await handle.Task,加载失败时它是直接抛异常的,不是返回 null。我第一次写的时候没包 try-catch,弱网测试机上一进就白屏。下面那段代码我带了正确的写法。
第三个是 catalog 的启动开销。Addressables 默认懒初始化,第一次 LoadAssetAsync 的时候才会去读 catalog 和 hash 文件。本地组开销很小,但如果 catalog 放 CDN 上,弱网下会卡在启动界面好几秒,玩家看到的就是一个不动的小圈。我的做法是在登录界面就主动调一次 Addressables.InitializeAsync(),把这段等待藏在有进度条的地方。
一段可以直接抄的加载封装
using System;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public static class ResLoader
{
// Addressables 1.19 之后 Task 属性都能用,我这边是 1.21.19
public static async Task<T> LoadAsync<T>(string key) where T : UnityEngine.Object
{
AsyncOperationHandle<T> handle = default;
try
{
handle = Addressables.LoadAssetAsync<T>(key);
T result = await handle.Task; // 失败会抛异常,不是返回 null
if (result == null)
{
if (handle.IsValid()) Addressables.Release(handle);
return null;
}
return result;
}
catch (Exception e)
{
Debug.LogError($"[Addressables] 加载 {key} 失败: {e.Message}");
if (handle.IsValid()) Addressables.Release(handle);
return null;
}
}
}
这段没做缓存,也没做引用计数记录,因为这两件事每项目的情况不一样。真要上生产,我建议配一个 Dictionary<string, AsyncOperationHandle> 记账,Release 的时候从表里摘,摘不到就别调 Release,能省掉九成的 invalid handle 报错。
最后说两句可能不中听的
我现在接新项目,第一个问题不是「用不用 Addressables」,而是「这游戏要不要热更,要不要分渠道包」。如果答案都是不要——买断制单机、一次上架不迭代——那 Resources 真的够用,别给自己找事。Addressables 的学习曲线不是陡,是碎:Group、Label、Profile、Catalog、Content Update、Bundle Mode、Asset Load Mode,这些概念你得一个个踩过一遍才知道哪个参数在什么场景下会咬你。
还有个更现实的问题:资源管理里最贵的成本从来不是运行时性能,是团队里没人说得清「这个资源现在是谁在引用」。Resources 的静态引用至少还能右键 Find References In Scene 找一找,Addressables 的运行时引用计数只能靠你在代码里自己记账。如果团队代码规范一般,上 Addressables 之前先想清楚,谁来负责这本账。