十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity资源管理认知:Resources陷阱、依赖冗余与内存优化

Unity资源管理认知:Resources陷阱、依赖冗余与内存优化 资源管理这件事我在职业生涯里被它坑得最惨的一次不是内存爆掉也不是包体超限而是上线前一天美术换了一张 4K 的 UI 底图第二天 Android 包从 180MB 涨到 240MB中低端机启动黑屏三秒。查了整整一晚最后发现问题不在那张图上——它躺在Resources目录里被一个早就没人用的登录界面 Prefab 引用着而那个 Prefab 从来没被删掉。这张图连同它的 Mipmap、CPU 侧副本从游戏启动的第一帧就常驻内存一直到玩家退出。这就是 Unity 资源管理最典型的样子它不出错它只是默默地变贵。你不会收到编译报错不会看到红色日志甚至在编辑器里跑得飞快——因为编辑器是按需加载的而真机是照单全收。这篇文章属于「认知篇」我不打算上来就甩 Addressables 的 API 或者写一堆打包脚本而是想把「Unity 的资源管理为什么会痛」这件事拆开揉碎讲清楚。你只有先理解痛点从哪来后面选方案时才不会被各种框架的宣传语牵着走。适合谁看做过一两个完整项目、对Resources.Load和Instantiate已经熟门熟路、但每次优化内存和包体都靠猜的同学。如果你正在被「包体莫名其妙涨了」「真机内存比编辑器高一倍」「热更做不动」折磨那下面的内容大概率能对上号。1. 痛点的源头Unity 把依赖关系藏进了一棵看不见的树1.1 拖进 Inspector 的那一刻依赖就被写死了很多同学对「资源依赖」的理解停留在「我用代码加载它」。但在 Unity 里绝大多数的资源引用根本不是代码写的而是你在 Inspector 里拖出来的。你把一张贴图拖进材质的_MainTex把一个材质拖进 Prefab 的 Renderer把另一个 Prefab 拖进某个脚本的 public 字段——每一次拖拽Unity 都在序列化文件里写进了一条{fileID, guid}记录。这个 guid 指向的是.meta文件里那串 32 位十六进制数。它的好处是资产移动位置不影响引用坏处是——这条引用对代码完全不可见。你在 C# 里翻遍整个工程找不到任何一处提到这张贴图的地方但它在包体和内存里的位置已经确定了。更麻烦的是这棵树是可以无限延伸的。Prefab 引用材质材质引用 Shader 和贴图贴图可能是图集里的一小块图集又引用它自己的 Sprite 定义Sprite 又被一个SpriteAtlas管着SpriteAtlas在打包时又会拉进一堆本来看起来没关系的散图。你以为你只加载了一个 UI 面板实际上拽出来的是一整条链。1.2 序列化引用和运行时加载是两套语言理解 Unity 资源管理的第一个坎是搞清楚「序列化依赖」和「运行时加载」不是一回事。序列化依赖决定的是当我加载 A 的时候Unity 必须把 A 依赖的所有东西也一并加载进来否则 A 里的引用就会变成null或者那个经典的粉红色 Missing 材质。这个过程是引擎自动完成的你没法只加载 A 的一半。运行时加载决定的则是我什么时候去读这个文件、读完之后什么时候从内存里扔掉。Resources.Load、AssetBundle.LoadAsset、Addressables.LoadAssetAsync、直接Instantiate一个 Inspector 引用的 Prefab这几种方式的加载时机和卸载策略完全不同。痛点就出在这两套语言的错位上。你用Resources.Load加载了一个 Prefab你只是拿到了入口对象的句柄但它背后那棵依赖树是被引擎一次性拖进来的。等你想卸载的时候你会发现Resources.UnloadUnusedAssets()有时候卸得掉有时候卸不掉——因为它判断的依据是「还有没有托管引用」而不是「我是不是用完了」。我见过太多项目在这上面栽跟头代码里明明写了Destroy内存曲线就是不降。原因往往藏在某个静态字典、某个事件回调的闭包、某个static列表里那个引用一直活着引擎就认为这个资源还有人在用。1.3 「看不见」才是真正的成本如果资源依赖会像编译错误一样跳出来这事反而简单了。真正让资源管理变成「工程难题」而不是「技术问题」的是它的隐形性体现在三个层面层面表现后果代码层面引用写在序列化数据里搜不到不敢删资源只能越堆越多打包层面冗余复制在 Build 阶段才发生包体超限了才知道运行层面加载和驻留在真机才显现编辑器测不出来上线才发现我经常用一个比喻Unity 的资源系统像一间没有标签的仓库。你往里放东西的时候很方便随手一丢就行但哪天要清仓你完全不知道该从哪个架子开始搬。所有能做好的资源管理项目本质上都是先给仓库贴上标签再谈怎么搬。2. Resources 目录的甜蜜陷阱方便背后的三笔账Resources文件夹是 Unity 给新手的第一个礼物也是第一个坑。它的用法简单到不用教建一个叫Resources的文件夹把东西扔进去Resources.Load(路径)就能拿到。不需要打包不需要配置不需要记 AssetBundle 名。但它的代价是要在打包、启动、迭代三个阶段分别还的。2.1 打包阶段的全量注入Resources目录下的所有资产不管你用不用到都会被无条件打进构建。它们会被合并进resources.assets以及按平台切分后的变体文件并且 Unity 会在游戏启动时建立一张完整的索引表。注意这句话里的两个关键词无条件和索引。无条件意味着你三年前做 demo 时丢进去的一个测试模型只要还在那个文件夹里它就还在包里。我审计过一个项目Resources目录 400 多个文件实际被代码引用到的不到 90 个剩下的全是历史遗留。砍掉之后包体直接少了 60MB。索引则意味着启动开销。这张表要在游戏启动的早期建立资源越多启动越慢。最典型的现象是——冷启动时首帧黑屏时间长低端机上尤其明显。很多团队把锅甩给首场景的复杂度和 Shader 编译其实一部分账要算在Resources的索引上。还有一点容易被忽略Resources里的资产无法被有效压缩和剥离。因为引擎不知道你什么时候会Load它所以它必须保持可用状态。这跟 AssetBundle 按需加载、加载完可以整包卸载的模型完全不同。2.2 启动阶段的同步阻塞Resources.Load是同步接口。意味着调用它的那一帧主线程必须停下来把文件读进来、反序列化、建立对象。文件大一点这一帧就是几十甚至上百毫秒掉帧肉眼可见。有人会说那用Resources.LoadAsync不就行了。这里有个流传很广的误解Resources.LoadAsync并不是真正的后台线程加载。它的实现方式是把加载任务切分到多帧执行主线程仍然要在每一帧里干活。对于单帧耗时很长的大资产你依然会看到明显卡顿只是卡顿被摊开了。实测下来LoadAsync更适合「加载很多小资源」的场景比如预加载一批图标。对于单个大 Prefab 或者大场景它救不了你。2.3 迭代阶段的热更死角这是最要命的一笔账。Resources目录里的内容一旦打进包就没法单独替换。想改只能重新出一个完整包走应用商店审核。对于需要频繁调数值、换活动图、修 UI 的项目这几乎是不可接受的。我见过有团队为了绕开这个问题把活动相关内容全部放到 AssetBundle 里只有基础资源留在Resources。结果就是两套加载逻辑并存代码里到处是if (isInResources) {...} else {...}维护成本反而更高。2.4 一次真实的排查一个小小的图标为什么吃掉了 30MB说说我前面提到的那个登录界面的案例把排查链路完整走一遍因为这套思路后面所有问题都用得上。现象Android 包体比预期大 60MB且冷启动明显变慢。第一步我用 Unity 自带的 Build ReportEditorApplication里可以拿到最近一次构建的报告导出资产清单按体积排序。排在最前面的是几张贴图单个都不到 1MB加起来也不足以解释 60MB。这时候如果只看构建报告很容易得出「包体没问题」的错误结论。第二步切到Resources目录看里面有什么。发现一个叫TestAssets的子目录里面是一整套 UI 资源包括那张 4K 底图、几个图集、一些字体。这个名字一看就是当年做原型时留下的。第三步全工程搜索这些资源的名称。代码里一个都搜不到。这时候就进入了「序列化引用」的领域——搜不到很正常。第四步用AssetDatabase.GetDependencies(path, true)递归查反向依赖。Unity 没有直接提供「谁引用了我」的 API但有变通办法可以遍历工程里所有的 Prefab 和 Scene用一个编辑器脚本检查它们的依赖列表里是否包含目标资源。这个脚本跑得慢但对关键资源用一次是值得的。跑完之后拿到了结果一个叫UILogin_Old.prefab的 Prefab 引用了这套 UI 资源而这个 Prefab 本身也被放在了Resources目录下。也就是说它同样被无条件打进包里并且建立了索引。第五步确认无人使用后连同资源一起删除。包体降了 58MB冷启动快了大概 0.8 秒。这个案例给我的教训不是「要删掉不用的资源」而是Resources目录必须有人管。一旦它变成「先丢进去再说」的地方它就一定会变成垃圾桶。我们后来立的规矩是——Resources目录只允许放启动流程必需的、体积极小的资源比如一个加载界面用的背景图和几个配置文本其余一律禁止入库由 CI 在打包前扫描并报警。3. 依赖冗余同一张贴图为什么会进三次构建从Resources走出来之后大多数团队会走向 AssetBundle 或者 Addressables。然后就会撞上第二个大坑冗余。3.1 隐式依赖导致的复制AssetBundle 的规则很简单一个资产只会被打进它所属的那个 Bundle。一个 Bundle 依赖的、不属于自己的资产会被复制进来或者通过依赖关系引用。关键在于「通过依赖关系引用」这件事不是自动发生的。Unity 在打包时会为每个 Bundle 生成一张依赖表。如果你没有正确设置共享依赖同一张被 A 和 B 两个 Bundle 都引用的贴图就会被物理复制两份甚至多份。举个我实际遇到的例子一套角色特效用了 5 张共享的贴图。我们把特效拆成 20 个 Bundle每个角色对应一个。因为没人处理共享贴图这 5 张图被复制进了 20 个 Bundle 里。贴图本身每张 512KB看起来不多但因为打包时会做对齐和额外的头部信息实际膨胀远超预期。20 个 Bundle 累计多出 60MB 左右的冗余。判断冗余最直接的办法是看构建后的 Bundle 清单里同一个资产的fileID guid是否出现在多个 Bundle 中。BuildPipeline提供的依赖信息可以导出成 JSON用脚本扫一遍就能列出所有重复项。下面这段脚本是我常用的一个简化版检查思路直接丢进Editor文件夹就能跑using System.Collections.Generic; using System.Linq; using UnityEditor; using UnityEngine; public class BundleRedundancyChecker { [MenuItem(Tools/资源管理/检查 Bundle 冗余)] public static void Check() { // 1. 拿到当前构建配置下所有 AssetBundle 的名字 var bundleNames AssetDatabase.GetAllAssetBundleNames(); // 资产路径 - 出现过的 Bundle 列表 var assetToBundles new Dictionarystring, Liststring(); foreach (var bundle in bundleNames) { var assets AssetDatabase.GetAssetPathsFromAssetBundle(bundle); foreach (var asset in assets) { // 递归取依赖这样隐式依赖也能被统计到 var deps AssetDatabase.GetDependencies(asset, true); foreach (var dep in deps) { if (dep.EndsWith(.cs) || dep.EndsWith(.shader)) continue; if (!assetToBundles.TryGetValue(dep, out var list)) { list new Liststring(); assetToBundles[dep] list; } if (!list.Contains(bundle)) list.Add(bundle); } } } var redundant assetToBundles .Where(kv kv.Value.Count 1) .OrderByDescending(kv kv.Value.Count) .ToList(); foreach (var kv in redundant) { Debug.LogWarning($[冗余] {kv.Key} 出现在 {kv.Value.Count} 个 Bundle{string.Join(, , kv.Value)}); } Debug.Log($共发现 {redundant.Count} 个冗余资产); } }注意这段脚本用的是编辑器 API只能在 Editor 下跑而且大工程全量递归会很慢建议只对关心的目录执行。真实项目里更稳的做法是基于构建产物Build Report 的 JSON做对比速度会快一个数量级。3.2 冗余检测要做成常态而不是一次性动作我见过太多团队做了一次冗余检查、修了一波然后半年后冗余又回来了。因为拆分方式会随着内容迭代不断变化今天合理的粒度三个月后可能就不合理了。比较务实的做法是把检查接到 CI 上每次出包后自动跑一遍把「新增冗余资产」的数量和体积作为一条构建指标记录下来。超过阈值就发通知。这样做的成本不高但能挡住 80% 的劣化。3.3 拆包的粒度取舍拆包这事没有标准答案但有几个我踩出来的经验策略适用场景代价按功能模块拆内容模块化清晰、玩家路径可预测公共依赖需要单独处理按场景拆场景之间互不往来、各带一套专属资源场景间共享资源容易冗余按资源类型拆贴图、音频、模型各自成包加载一个对象要跨多个包IO 次数变多公共包 业务包中大型项目的主流做法公共包容易越滚越大我更倾向于「公共包 业务包」的结构但要给公共包设一个硬指标它承载的必须是真正被多处引用的资产。判断标准就是上面那段脚本的输出。如果某个资产只在一个业务模块里用它就不该进公共包——进公共包会让所有玩家都在下载它哪怕他们根本不进那个模块。另外一个细节是图集。UI 图集天然适合做公共包因为 UI 资源跨模块复用非常频繁。但要注意图集的粒度一张 2048×2048 的图集只要用到里面一个图标整个图集就要加载进来。所以图集不能只按「哪些图长得像」来合还要按「哪些图会被同时用到」来合。前者是美术视角后者是工程视角两者经常冲突需要提前对齐。4. 生命周期错位卸载为什么总是不听话解决了冗余下一个拦路虎是卸载。我敢打赌每个做过资源管理的人都写过Resources.UnloadUnusedAssets()也都被它坑过。4.1UnloadUnusedAssets判断的是什么它的判断逻辑是遍历所有已加载的对象如果某个对象没有任何托管引用指向它就卸载。这里的关键点是「托管引用」——包括局部变量、字段、静态字段、委托、闭包捕获的变量、甚至调试器里的临时引用。这意味着两件事第一它不能被打断执行时会遍历整个对象图开销不小。通常建议在切场景的加载界面里调用不要随便在游戏过程中调。第二它的判断会「误伤」也可能「误放」。误伤的情况比较少误放的情况非常常见——你以为某个资源已经不用了但某个静态缓存里还留着一个GameObject的引用引擎就认为它还有用。我遇到过最隐蔽的一次一个static Action事件在场景切换时忘了-。事件持有的是委托委托持有的是目标对象的方法方法所在的类又持有它自己引用的 Prefab。一整条链就这么被钉死在内存里。这种问题靠看代码几乎发现不了只能靠内存快照里追引用链。4.2 一次Instantiate背后发生了什么很多人对Instantiate的认知是「复制一个对象」。实际发生的事情要多得多引擎读取源 Prefab 的序列化数据按对象图分配新的对象逐个反序列化组件和字段递归处理所有引用包括外部资产引用这一步可能触发资源加载依次调用组件的Awake、OnEnable处理 Transform 层级的挂载。其中第 4 步值得单独说。如果 Prefab 引用了一个还没加载的资产Instantiate会同步把它加载进来。这就是为什么有时候Instantiate会突然卡一下——你以为只是复制对象其实背后还夹带着 IO。高频生成的对象比如子弹、飘字、粒子特效如果每次都走完整流程开销会非常可观。这就是对象池存在的原因。4.3 跨场景引用与DontDestroyOnLoadDontDestroyOnLoad是个好东西但它也是内存泄漏的重灾区。挂在它下面的对象生命周期跟整个应用一样长。如果这个对象又引用了若干资源那些资源就永久驻留。更隐蔽的是「跨场景引用」。场景 A 里的某个对象持有场景 B 里对象的引用即使 B 被卸载了只要 A 还活着B 的一部分对象就没法释放。反向的依赖同样成立。我的经验是**跨场景引用要当成架构问题处理而不是用DontDestroyOnLoad打补丁。**更稳的做法是让跨场景的数据走「纯数据层」——只持有配置、数值、ID不持有 Unity 的 Object 引用。需要显示的时候再按 ID 去加载。多一层间接但换来了清晰的边界。4.4 对象池解决了什么又引入了什么对象池的本质是拿空间换时间把Instantiate和Destroy的开销前置到初始化阶段运行时只做启用和禁用。它解决的问题很实在GC 压力、内存碎片、实例化卡顿。但它也引入了一堆新问题状态残留复用的对象如果没干净重置会出现「这个特效带了上个怪物的颜色」这类诡异现象。事件未解绑禁用时不-启用时又跑几次就变成一只对象被通知了三遍。内存峰值等于峰值数量池子只会涨不会缩某个 boss 战刷了 300 个弹幕对象之后这 300 个对象的内存就一直占着。池的管理本身要成本谁来回收、什么时候回收、按什么维度分池这些都要设计。我一般的建议是池子只给「高频、结构简单、状态容易重置」的对象用比如子弹、伤害飘字、常用特效。复杂对象比如完整的 UI 面板用「加载一次、隐藏复用」的缓存方式反而更简单直接。顺便说一个很多人问过的细节UI 的显示隐藏到底该用SetActive、改localScale还是移出相机范围SetActive(false)会触发OnDisable停止渲染和大部分逻辑是语义最正确的做法。但频繁切换会有开销而且如果对象上有大量子节点SetActive的递归成本不低。改localScale为 0 或者移到屏幕外能避免OnDisable的开销对象依然在内存里、依然在渲染管线里被处理只是看不到。适合超高频切换的简单元素。移出相机范围本质上跟改 scale 类似只是通过位置控制。我的选择通常是这样需要省 CPU 且切换不频繁的用SetActive需要极致流畅的滑动列表项用 scale 或位置欺骗。没有绝对的对错但一定要在项目里统一别一个人一个写法。5. 导入设置内存爆炸的第一现场前面几节讨论的都是「怎么组织资源」。但还有一类问题更基础——单个资源本身的导入设置就是错的。这类问题占比之高常常超出预期。5.1 纹理三个默认勾选但代价昂贵的开关纹理内存是移动端内存占用的最大头通常能占到一半以上。三个最容易被忽略的设置Read/Write Enabled。勾上之后Unity 会在 CPU 侧也保留一份纹理数据除了 GPU 侧的那份。这意味着内存直接翻倍。很多项目的默认导入设置里这个是勾着的只是因为某个老功能需要读取像素。但那个功能可能早就不用了。移动端上除非确实要用GetPixels或者运行时改纹理否则一律关掉。Mipmap。开启后纹理会多出约 33% 的内存。它对远处物体的画质和性能有好处但对 UI 图标、全屏背景这类不会被缩得很小的纹理来说基本上是白给。UI 图集一般是关掉 Mipmap 的。分辨率。这是最容易被忽略的。一张 4096×4096 的 RGBA32 纹理未压缩状态下是 4096×4096×4 64MB。加上 Mipmap 是约 85MB。这个数字单独拿出来就足以让一个中端手机游戏崩掉。我们拿 512×512 举例看看不同格式下的内存差异格式每像素位数512×512 内存说明RGBA32未压缩32 bpp1.0 MB基准RGBA32 Mipmap32 bpp约 1.33 MB多出约 33%ETC2 RGBA88 bpp约 0.25 MB支持透明通道ASTC 4×48 bpp约 0.25 MB质量更好压缩速度慢ASTC 6×63.56 bpp约 0.11 MB质量换体积适合中远景PVRTC 4bpp4 bpp约 0.125 MB苹果平台传统方案从上表可以看出从 RGBA32 换成 ASTC 6×6内存能降到原来的 1/9。这不是渐进式优化这是数量级的变化。所以我在任何项目里的第一步永远是先把纹理格式捋一遍。提示ASTC 在支持的设备上画质和体积都很优秀但打包时间会明显变长。中大型项目建议在 CI 上做完整的 ASTC 构建本地开发用 ETC2 快速迭代。5.2 模型与音频那些随手勾上的选项模型这边最常出问题的是这几个Read/Write Enabled跟纹理同理勾上之后模型数据在 CPU 侧也留一份。只有需要运行时修改顶点或者做碰撞体细节处理时才需要。Mesh Compression开启能减小包体但会引入精度损失。用于远景模型很好用于主角就会看出破绽。Normals / Tangents 的导入不需要法线贴图的模型切掉 Tangents 能省不少。Animation 的压缩动画曲线默认是不压缩的帧率高、骨骼多的角色动画能吃几十 MB。开启关键帧优化和压缩通常能砍掉一半以上。音频这边Decompress On Load是最常见的选择也是最耗内存的——它会把音频完整解压到内存里。对于短音效比如按钮点击这个选择没问题对于背景音乐这种几分钟的音频必须改成Streaming或者Compressed In Memory否则一首 BGM 就能吃掉几十 MB。Force To Mono也是同理立体声的双声道对绝大多数音效没有任何意义纯属浪费一倍内存。5.3 平台差异带来的额外复杂度同一份资源在不同平台上最优的设置是不一样的。Android 上 ASTC 是好选择但一些老设备只支持 ETC2iOS 上 ASTC 支持度很好PVRTC 反而在新设备上不占优势。分辨率方面高端机可以吃下 2048 的图集低端机可能连 1024 都要缩。Unity 允许通过 Quality Settings 做纹理质量分级也允许在导入时针对平台覆盖设置。我的建议是不要一开始就做多档先按目标机型的中位数定一套等项目稳定了再考虑分级。过早做分级会让工程复杂度失控而且往往因为没人维护而形同虚设。更有效的做法是用AssetPostprocessor把导入规则代码化。比如一个新贴图进来脚本自动判断它是不是在 UI 目录下、尺寸超过多少就报警、自动关掉 Read/Write。这样至少能拦住「美术随手导入」造成的低级错误。using UnityEditor; using UnityEngine; public class TextureImportRule : AssetPostprocessor { void OnPreprocessTexture() { var importer (TextureImporter)assetImporter; bool isUI assetPath.Contains(/UI/); bool isBig importer.maxTextureSize 2048; // UI 贴图默认关掉 Mipmap 和 Read/Write if (isUI) { importer.mipmapEnabled false; importer.isReadable false; importer.textureType TextureImporterType.Sprite; } // 超大贴图直接报警避免误导入 if (isBig importer.textureCompression TextureImporterCompression.Uncompressed) { Debug.LogWarning($[纹理规范] {assetPath} 尺寸偏大且未压缩请确认是否必要); } } }这段代码不复杂但它把一个「靠人记」的规范变成了一个「靠工具拦」的机制。经验告诉我只要规范是写在文档里的它就一定会被忘只有变成工具的一部分它才能真正生效。6. 排查工具链把「感觉内存高」变成「定位到具体资产」前面讲了很多原理但真到了线上出问题的时候靠的还是排查手段。这一节讲讲我实际用的工具和流程。6.1 Profiler 和 Memory Profiler 的分工Unity 自带的 Profiler 有两块用得上Memory 模块看整体趋势。Total Reserved、Total Used、Texture Memory、Mesh Memory、Audio、Managed Heap这些分类数字能帮你快速判断是哪一类资源在涨。CPU 模块看Instantiate、Destroy、加载调用的耗时定位卡顿。Profiler 的局限在于它给的是「分类」而不是「具体哪个资产」。要定位到具体资产需要 Memory Profiler 这个包Package Manager 里可以装。Memory Profiler 的核心能力是快照Snapshot和引用链Reference Chain。拍一张快照你会看到当前所有存活对象可以按类型、按大小排序点进任意一个对象能看到「谁在引用它」。这正好解决了前面说的「静态引用导致卸载不掉」的问题——顺着引用链一路点下去就能找到那个罪魁祸首。6.2 一次完整的快照对比排查流程我的标准流程是这样的确定基准场景。找一个可复现的操作路径比如「进入主城 → 打开背包 → 进入战斗 → 退出战斗 → 回到主城」。在起点拍一张快照记为 A。完整走一遍路径回到起点。再拍一张快照记为 B。在 Memory Profiler 里对比 A 和 B 的差异重点看「B 中有而 A 中没有」的对象。对每个新增对象追引用链判断它是不是应该被释放。修复后重复整个流程确认差异归零。这套流程的关键是「可复现」和「对比」。单张快照只能告诉你「现在有什么」对比才能告诉你「什么东西没被放掉」。我见过很多同学拍了一张快照就盯着看看了半天也看不出问题——因为他不知道自己应该期待看到什么。另一个实用技巧是对比时候不要只看体积大的对象也要看数量多的对象。一万个小的GameObject加起来可能比一个大贴图还占地方而且它们的引用链往往更乱。6.3 几个常见的误判排查过程中有几个坑我自己都踩过把 RenderTexture 当成泄漏。后处理、UI 模糊、阴影贴图这些都会产生 RenderTexture它们的生命周期由渲染管线控制经常是「用完即回收」。你在快照里看到一堆RenderTexture不一定是问题要看它们是不是在下一帧就消失了。把 Shader 变体当成资源。Shader 的编译和变体收集是另一个独立话题它的内存占用在 Profiler 里有时候会被算到别的分类下容易误判。忽略了托管堆和脚本层。如果你的项目用了 Lua 或者其他脚本方案那部分内存不会出现在 Unity 的资源分类里。堆的增长同样是内存泄漏的重要来源需要单独看。Profiler 本身的开销。连接 Profiler 之后游戏本身的性能数据会失真内存快照也会因为调试符号的加载而偏大。所以永远不要拿「连着 Profiler 跑出来的内存」去跟线上崩溃日志里的数字对比。7. 认知先行在选框架之前要先建立的心智模型写到这里痛点基本拆完了。最后我想聊聊「认知」这件事因为它决定了你后面选什么方案、怎么用。7.1 所有资源管理问题最终都归结为三个问题不管用什么框架你都要能回答三个问题谁在用这个资源也就是引用关系的可见化。如果一个资源在内存里但你说不出谁在引用它那它早晚会出问题。什么时候加载它是启动时全量加载还是按需加载是按场景加载还是按模块加载加载的时机决定了内存峰值。什么时候释放它显式释放还是等 GC引用计数还是引用链分析释放的边界在哪里Unity 原生方案在第一个问题上几乎是空白依赖藏在序列化数据里第二个问题靠Resources勉强能答第三个问题靠UnloadUnusedAssets兜底。这就是为什么大型项目最后都会奔着 Addressables 或者自研方案去——不是因为框架有多神而是因为这三点它必须帮你理清。但反过来说如果你连这三个问题都没有明确答案那换成 Addressables 也救不了你。我见过用着 Addressables 但依然满内存泄漏的项目问题不在框架在于没想清楚这三个问题。7.2 规范比框架更重要这话可能有点反直觉但我的经验是一个规范执行到位的原生方案比一个规范混乱的 Addressables 项目要健康得多。规范包括哪些目录规范哪些目录允许被无条件加载哪些目录必须走热更哪些目录只放编辑器资源。命名规范Bundle 名、资源路径、图集名前缀ui_、char_、fx_让人一眼能看出归属。导入规范用AssetPostprocessor强制执行的纹理、模型、音频设置。评审规范代码里新增资源引用时必须说明加载和释放时机新增static缓存的必须写清楚何时清理。构建规范出包前自动跑冗余检查和资源体积检查超阈值就拦下来。这些东西看起来枯燥但它们是把「资源管理」从个人技能变成团队能力的关键。否则每次换人知识就归零坑要重新踩一遍。7.3 我踩过的几个坑以及现在的做法分享几个具体的、能直接抄的教训第一个坑用Resources装配置表。早期项目把 JSON 配置全放在Resources里觉得读取方便。后来配置表越来越大几百 KB 的文本文件被无条件打进包而且每次改数值都要重新出包。现在我的做法是把配置表也走资源系统甚至可以放到服务器本地只缓存最近用到的几张。第二个坑图集无限膨胀。UI 图集从 1024 一路涨到 4096因为「反正还能塞」。但 4096 的图集一旦加载即使只用一个图标整张图都在内存里。现在我的规矩是图集不超过 2048超了就拆而且按功能拆而不是按美术喜好拆。第三个坑忘了事件解绑。前面提过的那次静态事件泄漏让我养成了一个习惯——任何的地方我会立刻在旁边写-哪怕当时用不上。写代码的时候多打一行排查的时候少熬一夜。第四个坑只在编辑器测内存。编辑器里的资源是懒加载的而且有大量的编辑器缓存。真机的数字往往高出一大截。所以任何资源相关的优化必须上真机验证而且要选一台配置偏低的设备当基准机。第五个坑认为打包一次就好了。资源结构会随着项目迭代不断变化今天最优的拆包方案三个月后可能就变成了负担。所以我把资源审查变成了一个固定节奏的动作——每个版本出包后看一眼冗余报告和体积趋势而不是等到出问题才查。如果让我给正在做资源管理优化的同学一句话建议那大概是**先花两天时间把工程里所有资源的引用关系摸清楚再动手改任何东西。**我见过太多人一上来就换框架、改加载方式结果因为不了解现有的依赖结构把问题从「包体大」变成了「包体大 运行时报空」。先把地图画出来再决定走哪条路这是我在这件事上最深的体会。
返回列表