Unity 进阶核心:架构解耦、性能瓶颈定位与系统设计落地
这是 Unity 进阶系列的第二部分,也是整个系列的完成篇。上一部分我们把基础工程搭建、热更新选型和资源管线铺开聊了,这一部分直接进入硬核区:系统架构怎么设计才不翻车,性能优化从哪里下手收益最大,以及如何把 Profiler、内存分析、渲染优化真正串到日常开发流程里。内容会偏工程实践,不是概念科普。
先给结论:Unity 项目做大了以后,卡顿、加载慢、内存爆、改一处坏三处,绝大多数不是单帧代码写得差,而是架构耦合和资源策略出了问题。架构上没解开模块依赖,优化做得再细也只是在补一个漏水的桶。所以这一部分会同时覆盖两个方向:先讲系统架构怎么拆、事件怎么解耦、数据怎么流动;再讲性能优化从 Profiler 入手怎么定位、渲染和内存怎么降、GC 怎么控。
文中会提供可直接用的代码骨架、验证步骤和排错清单。适合至少做过一个完整 Unity 项目、想往主程或技术负责人方向走的开发者。如果你还在犹豫“优化到底从哪开始”“为什么同屏单位一多就掉帧”“脚本线程和主线程到底卡在哪”,这篇可以直接收藏。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 目标引擎 | Unity 2022 LTS 及以上,部分 DOTS 功能建议 2022.3+ |
| 面向方向 | 高级游戏开发、系统架构设计、性能优化 |
| 核心主题 | 模块化架构、事件解耦、ECS/DOTS、渲染优化、内存与 GC 优化、资源管线 |
| 关键技术 | Profiler、Memory Profiler、SRP Batcher、GPU Instancing、Addressables、对象池 |
| 适用人群 | 有一定 Unity 基础、正在做中型或大型项目的开发者 |
| 验证方式 | Profiler 帧耗时对比、Memory Profiler 快照、构建包体对比 |
| 是否支持 API | 支持,Unity 构建管线与 Addressables 均可通过命令行和 C# API 批处理 |
| 是否支持批处理 | 支持,构建、资源处理、自动化测试均可批处理 |
| 适合场景 | RPG/射击/模拟经营等中大型项目;MMO 或 UGC 项目的架构预研 |
需要先说明:本文所有优化手段的收益都应以你本机项目实测为准,不同渲染管线、不同目标平台、不同脚本热更方案的差异非常大,不存在“照着做就一定提升多少帧”的结论。
2. 进阶开发的核心矛盾与使用边界
2.1 架构层面解决什么问题
Unity 项目一路迭代到中期,最痛苦的不是新功能做不出来,而是改一个系统要连带动七八个文件。UI 逻辑直接引用战斗逻辑、战斗逻辑直接调用网络回调、角色死亡时还要通知商店界面刷新——这种代码一旦铺开,需求变更就是灾难。
架构设计在这阶段的真正价值是:把系统之间的直接依赖变成稳定接口,把“调用关系”变成“事件通知”。做得好,新系统插入旧工程时几乎不需要改动已有模块。
2.2 性能优化层面的边界
性能优化不是无脑降画质。它的核心顺序是:先定位瓶颈,再选择手段。瓶颈在 CPU 脚本逻辑,就用对象池、缓存、GC 优化;瓶颈在 Draw Call,就用图集、合批、Instance;瓶颈在 GPU 填充率,就降分辨率、调 LOD、减 Overdraw。先测再改,一次只验证一个变量。
2.3 合规与使用边界
涉及版本迭代、热更新、资源分发时,要注意素材版权、插件授权和平台政策。特别是 Addressables 远程内容分发、AssetBundle 加密、第三方 SDK 集成,都要在合规范围内使用。不要为了压缩包体使用未经授权的资源,也不要在没有用户协议的情况下上传用户生成内容。
3. 环境准备与前置条件
3.1 引擎与工具链
建议基线:
- Unity 2022.3 LTS,稳定且 DOTS 支持已相对成熟。
- 代码编辑环境,建议 Rider 或 VS 2022,开启 Roslyn 分析器。
- 目标平台:先以 Standalone(Windows/macOS)验证性能,再切移动平台对比。
- 需要安装的模块:Android Build Support、IL2CPP、Profiler 相关模块默认自带。
- 推荐安装 Memory Profiler 包,用于内存快照对比。
3.2 工程目录规范
建议按模块划分而不是按类型划分。错误示范是 Sripts/UI、Scripts/Enemy、Scripts/Player;正确做法是 Modules/Battle、Modules/UI、Modules/Network,每个模块内部再分 View/Controller/Data。
Assets/ Modules/ Battle/ Controllers/ Data/ Views/ Tests/ UI/ Views/ Widgets/ Network/ Clients/ Messages/ Core/ EventBus/ ObjectPool/ ServiceLocator/ Resources/ AddressableAssets/这样划分的收益在于:模块之间通过接口或事件通信,不会跨目录乱引用。谁破坏了依赖规则,代码评审阶段一眼就能看出来。
3.3 安装 Memory Profiler 包
打开 Package Manager,选择 Unity Registry,搜索 Memory Profiler,安装最新版本。该工具可以直接对比两帧内存差异、查看托管堆分配、查看 Native 资源大图。
4. 系统架构设计:从耦合到解耦
4.1 先识别现有架构的坏味道
进入重构前,先看代码里有没有这些现象:
- 一个 MonoBehaviour 类超过 1000 行。
- Update 里每帧查找对象、GetComponent 或 Linq。
- 模块之间使用 public static 实例直接互调。
- 场景里存在大量互相引用的 Inspector 拖拽。
- 新需求需要同时改至少 5 个已有类。
如果命中三项以上,不要继续加新功能,先做架构整理。推荐顺序:先做事件解耦,再做模块化拆分,最后再考虑 DOTS。
4.2 事件总线:最低成本的解耦方案
事件总线的思路是:发布者不知道谁在监听,监听者不知道谁在发布。这样战斗系统只需要发一条“敌人死亡”事件,任务系统、成就系统、UI 系统各自订阅。
下面给一个极简但可落地的 C# 事件总线实现,不引入第三方库:
using System; using System.Collections.Generic; public sealed class EventBus { private static readonly Dictionary<Type, Delegate> Events = new Dictionary<Type, Delegate>(); public static void Subscribe<T>(Action<T> handler) { if (!Events.TryGetValue(typeof(T), out var existing)) { Events[typeof(T)] = handler; return; } Events[typeof(T)] = Delegate.Combine(existing, handler); } public static void Unsubscribe<T>(Action<T> handler) { if (Events.TryGetValue(typeof(T), out var existing)) { Events[typeof(T)] = Delegate.Remove(existing, handler); } } public static void Publish<T>(T message) { if (Events.TryGetValue(typeof(T), out var existing)) { (existing as Action<T>)?.Invoke(message); } } }用法示例:
public readonly struct EnemyDiedMessage { public readonly int EnemyId; public readonly Vector3 Position; public readonly int RewardGold; public EnemyDiedMessage(int enemyId, Vector3 position, int rewardGold) { EnemyId = enemyId; Position = position; RewardGold = rewardGold; } } EventBus.Publish(new EnemyDiedMessage(1, transform.position, 100));private void OnEnable() { EventBus.Subscribe<EnemyDiedMessage>(OnEnemyDied); } private void OnDisable() { EventBus.Unsubscribe<EnemyDiedMessage>(OnEnemyDied); } private void OnEnemyDied(EnemyDiedMessage message) { // 更新任务进度、播放特效、刷新 UI }实现要关注三点:
- 订阅必须配对,OnEnable 订阅、OnDisable 注销,防止内存泄漏。
- 事件消息建议用只读 struct,避免事件过多导致 GC 压力。
- 不要在事件回调里做耗时操作,事件总线只负责通知,不负责执行重逻辑。
4.3 服务定位器:处理全局唯一服务
有些模块天然全局唯一,比如网络服务、存档服务、音频服务。它们不适合走 MonoBehaviour 单例的泛滥式访问,可以收敛到一个服务定位器里。
public static class ServiceLocator { private static readonly Dictionary<Type, object> Services = new Dictionary<Type, object>(); public static void Register<T>(T service) where T : class { Services[typeof(T)] = service; } public static T Get<T>() where T : class { if (Services.TryGetValue(typeof(T), out var service)) { return service as T; } throw new InvalidOperationException($"Service {typeof(T)} is not registered."); } }典型注册流程放在游戏启动入口:
public sealed class GameBootstrap : MonoBehaviour { private void Awake() { ServiceLocator.Register<INetworkClient>(new NetworkClient()); ServiceLocator.Register<ISaveService>(new SaveService()); ServiceLocator.Register<IAudioService>(new AudioService()); } }注意:服务定位器不能滥用。如果一个类主要依赖两三个服务,直接用构造函数注入更清晰;服务定位器适合系统级、跨模块的全局访问。
4.4 组件化优于继承
传统 RPG 角色设计容易写成:
public class Player : Character public class Boss : Character public class NPC : Character这类继承链一旦加深,公共逻辑修改会同时影响所有子类。组件化思路是:能力即组件。移动、受伤、技能、掉落等都做成独立组件,运行时组合。
public interface IMovable { float MoveSpeed { get; } void Move(Vector3 direction); } public interface IDamageable { void TakeDamage(int damage); }角色预制体上挂多个组件,而不是写一个巨型类。这个思路对策划配置也更友好:需要掉落就给一个 DropLoot 组件,不需要就不挂。
4.5 ECS 与 DOTS:什么时候值得引入
DOTS(Data-Oriented Technology Stack)不是银弹。它适合大量同构实体的场景:大量单位战斗、子弹系统、塔防怪物、群体 AI。如果是 RPG 里几十个角色、逻辑复杂度高、交互频繁,传统面向对象加优化已经足够。
引入 DOTS 的正确姿势是先从局部开始,把纯计算密集、没有复杂交互的子系统迁到 ECS。例如:
- 大量单位的朝向移动、寻路。
- 弹幕射击的子弹移动。
- 群体受击飘字的位置更新。
- 大世界植被风吹摆动。
不要一上来就把整个战斗系统重写为 ECS,那是重构事故的高发区。
下面是一个最简单的 ECS 移动系统示例,展示数据驱动的大致形态:
using Unity.Entities; using Unity.Transforms; using Unity.Mathematics; public partial struct MoveSystem : ISystem { public void OnUpdate(ref SystemState state) { float deltaTime = SystemAPI.Time.DeltaTime; foreach (var (transform, speed) in SystemAPI.Query<RefRW<LocalTransform>, RefRO<MoveSpeedComponent>>()) { float3 forward = transform.ValueRO.Forward(); transform.ValueRW.Position += forward * speed.ValueRO.Value * deltaTime; } } }public struct MoveSpeedComponent : IComponentData { public float Value; }ECS 的收益需要 Profile 数据支撑:如果传统方式下单位数目超过 5000 且 CPU 成为瓶颈,迁移 ECS 才有意义;如果瓶颈在 GPU 渲染,DOTS 也救不了。
4.6 数据驱动:把配置从代码中剥离
高级项目几乎都会走到数据驱动这一步。角色属性、技能参数、掉落概率、关卡波次都应该放到 ScriptableObject、JSON 或表格里,而不是写死到代码中。
[CreateAssetMenu(menuName = "Game/CharacterConfig")] public sealed class CharacterConfig : ScriptableObject { public string CharacterId; public int MaxHealth; public float MoveSpeed; public float AttackRange; public int AttackDamage; }运行时加载:
public static CharacterConfig GetConfig(string characterId) { return Addressables.LoadAssetAsync<CharacterConfig>(characterId).WaitForCompletion(); }数据驱动的好处立竿见影:数值改动不需要改代码,不同版本之间可以出配置差异包,策划可以独立调参。
5. 性能优化:从定位到落地
5.1 用 Profiler 找到真正的瓶颈
优化第一原则:不要猜,要测。打开 Window > Analysis > Profiler,连接 Play Mode,录制 30 秒典型战斗场景。
先看 CPU Usage 面板的 Player 一项:
- 如果 Main Thread 中 Scripts 占比较高,说明脚本逻辑是瓶颈。
- 如果 WaitForTargetFPS 占比高,说明帧率被 VSync 或 Application.targetFrameRate 限制了,优化空间不大。
- 如果 RenderThread 占高,说明渲染是瓶颈。
- 如果 Physics.Simulate 占高,检查物理碰撞体数量和查询频率。
针对性方向:
Scripts 高 -> 对象池、缓存、减少反射、减少 Linq、降低 GC RenderThread 高 -> Draw Call、Overdraw、Shader 复杂度、分辨率 Physics 高 -> 减少碰撞体、降低物理频率、使用 Sphere/Capsule 代替 Mesh Collider5.2 对象池:消除运行时 Instantiate/Destroy
频繁生成和销毁对象的典型表现:战斗刷怪、子弹、飘字、特效。每帧 Instantiate 会触发内存分配和组件初始化,GC 压力非常大。
通用对象池实现:
using System.Collections.Generic; using UnityEngine; public sealed class GameObjectPool { private readonly GameObject _prefab; private readonly Transform _parent; private readonly Stack<GameObject> _available = new Stack<GameObject>(); private readonly List<GameObject> _active = new List<GameObject>(); public GameObjectPool(GameObject prefab, Transform parent, int preloadCount) { _prefab = prefab; _parent = parent; for (int i = 0; i < preloadCount; i++) { var instance = Object.Instantiate(prefab, parent); instance.gameObject.SetActive(false); _available.Push(instance); } } public GameObject Get() { if (_available.Count == 0) { var newInstance = Object.Instantiate(_prefab, _parent); _available.Push(newInstance); } var go = _available.Pop(); go.SetActive(true); _active.Add(go); return go; } public void Release(GameObject go) { go.SetActive(false); _active.Remove(go); _available.Push(go); } }使用原则:
- 子弹、飘字、普通特效、小怪,全部走池。
- 池的预热数量按同屏峰值估算,不要 0 预热直接硬扩。
- 释放对象时统一 SetActive(false),而不是 Destroy。
5.3 缓存:不要每帧 GetComponent 和 Find
Unity 的 GetComponent 在大多数情况下不是灾难,但在每帧执行且对象数量很大时就有影响。更关键的是 Find、FindObjectOfType、GetComponentInChildren 这种查找类的 API,必须避免在 Update 中调用。
规范做法:
- Awake 中缓存组件引用。
- 场景对象引用用 Inspector 或 ServiceLocator 获取。
- 反复查找同一个资源时,用静态字典做 Cache。
public sealed class DamageText : MonoBehaviour { private Text _text; private Animator _animator; private void Awake() { _text = GetComponent<Text>(); _animator = GetComponent<Animator>(); } public void Show(string content) { _text.text = content; _animator.Play("Show"); } }5.4 字符串与装箱的 GC 陷阱
字符串拼接产生的垃圾远比你想象的多。UI 频繁刷新的帧率、伤害数字、倒计时文本、排行榜列表,都是 GC 大户。
优化手段:
- 用 StringBuilder 代替高频字符串拼接。
- 用整数到字符串的预分配缓存,例如
string[] _damageTextCache = new string[1024]。 - 避免装箱:不要把 int、float 直接塞给
object参数、string.Format、Debug.Log中做拼接。 - 协程、事件和匿名函数里注意闭包捕获。
示例:伤害数字文本应该使用预分配字符串缓存:
private static readonly string[] DamageCache = new string[2048]; public static string GetDamageText(int damage) { if (damage >= 0 && damage < DamageCache.Length) { return DamageCache[damage] ??= damage.ToString(); } return damage.ToString(); }5.5 增量 GC:降低移动端卡顿
Unity 的增量式 GC(Incremental GC)可以把单帧 GC 尖峰拆散到多帧,适合移动端。开启方式有两种:
- Player Settings > Other Settings > Use Incremental GC,勾选。
- 代码中设置:
// 运行时开启增量 GC 示例 System.GC.TryStartNoGCRegion(1024 * 1024);增量 GC 不是免死金牌,它只是把卡顿摊平,分配总量没有减少。真正的问题还是要从代码侧减少分配。
5.6 协程与异步的合理使用
协程不是线程,它仍然跑在主线程,每帧恢复执行会产生分配。高频使用的协程考虑用自定义轻量 Task 系统或直接改成 Update 轮询。
C# 异步使用注意:Unity 中 async/await 的同步上下文需要谨慎,使用UniTask可以在性能和 API 体验上明显优于默认协程。如果项目没有引入 UniTask,至少限制协程数量,避免大量协程同时启动。
5.7 控制物理计算
物理优化常被忽略,但一个场景里几百个刚体互相接触时开销很大。常用手段:
- 用碰撞矩阵减少层间碰撞检测。
- 静态物体用 Static Collider,不要挂 Rigidbody。
- 角色碰撞优先用 Capsule Collider。
- 物理频率从固定 50Hz 降到 30Hz,需要在设置中做全局调优并评估手感影响。
- 大批子弹使用射线检测或 OverlapSphere 代替 Rigidbody 模拟。
6. 渲染优化与 URP 实践
6.1 先看 Draw Call
Draw Call 是渲染性能最直观的指标之一。打开 Window > Rendering > Render Pipeline Debugger,或使用 Profiler 的 Rendering 模块查看 SetPass Call。
目标值参考:
- 移动端中低端:200-300 Draw Call 以内。
- PC 端:500-800 Draw Call 以内,具体看 GPU 能力和目标帧率。
6.2 合批手段
- 静态合批:标记 Static 的物体,引擎在构建时合并 Mesh。
- 动态合批:适用于小 Mesh,数个小物体共享材质时。
- GPU Instancing:大量同 Mesh 同材质的物体最有效,例如草、树、石头、子弹。
- SRP Batcher:URP/HDRP 下开启,支持不同 Mesh 共享 Shader 变体时合批,是 URP 项目最重要的合批手段。
URP Asset 中勾选 SRP Batcher:
URP Asset -> Rendering -> SRP Batcher -> Enabled验证方式:Render Pipeline Debugger 中查看 SRP Batcher 的合批成功率,目标应接近 100%。如果成功率很低,检查是否使用了非 SRP 兼容 Shader,或材质的属性变体过多。
6.3 图集与纹理内存
UI 图片尽量打图集,避免每张图单独提交。
- 使用 Sprite Atlas 打包 UI。
- Texture 压缩格式按平台设置,Android 用 ASTC,iOS 用 ASTC,PC 用 BC7。
- 开启 Mipmap 仅适用于 3D 纹理,UI 不需要 Mipmap,会额外浪费约 33% 内存。
- 控制单张纹理尺寸,不要随便导入 2K/4K。
6.4 LOD 与遮挡剔除
- 为高模对象制作 LOD Group:远处切换低面数 Mesh。
- 开启 Occlusion Culling:Window > Rendering > Occlusion Culling,烘焙场景遮挡数据。
- 使用 Camera 的 cullingMask 分层剔除不必要的相机渲染。
6.5 Overdraw 检查
移动端 GPU 的填充率有限,半透明特效叠加会显著影响帧率。在 Game View 中切换 Shading Mode 为 Overdraw,看红色区域分布。大范围红色说明该区域存在大量重复绘制,需要减少半透明叠层、缩小特效粒子尺寸或减少粒子数。
6.6 光照方案
- 动态实时光源数量严格控制,移动端 1-2 个主光源即可。
- 静态场景优先烘焙 Lightmap。
- 角色使用 Light Probe 组采样间接光。
- 阴影距离按平台优化:移动端阴影距离通常 20-40 米。
- URP 项目可使用 Forward+ 或 Deferred,具体按 Target 平台评估。
7. 接口 API 与构建批处理
Unity 项目的“接口”不一定是 HTTP API,更多是构建管线、资源加载和自动化测试接口。这里给出一个通用的构建批处理入口,可以通过命令行执行,实现 CI/CD 集成。
7.1 构建批处理入口
创建 Editor 脚本:
using UnityEditor; using UnityEditor.Build.Reporting; using UnityEngine; public static class BuildPipelineRunner { public static void BuildWindows() { var options = new BuildPlayerOptions { scenes = new[] { "Assets/Scenes/Main.unity" }, locationPathName = "Build/Windows/Game.exe", target = BuildTarget.StandaloneWindows64, options = BuildOptions.None }; BuildReport report = BuildPipeline.BuildPlayer(options); if (report.summary.result == BuildResult.Succeeded) { Debug.Log($"Build succeeded: {report.summary.totalSize} bytes"); } else { throw new System.Exception("Build failed."); } } }命令行调用:
# 命令行执行 Unity 构建 Unity.exe -batchmode -nographics -quit \ -projectPath "D:/MyProject" \ -executeMethod BuildPipelineRunner.BuildWindows \ -logFile "Build/build_log.txt"7.2 Addressables 构建
从代码构建 Addressables:
using UnityEditor.AddressableAssets; using UnityEditor.AddressableAssets.Settings; public static class AddressableBuilder { public static void RebuildContent() { AddressableAssetSettings settings = AddressableAssetSettingsDefaultObject.Settings; AddressableAssetSettings.BuildPlayerContent(); } }构建产物输出目录默认为ServerData,可以配合 CDN 做远程资源分发。
7.3 异步加载接口示例
运行时资源加载用 Addressables 异步接口:
using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; public sealed class CharacterLoader : MonoBehaviour { private AsyncOperationHandle<GameObject> _handle; public void LoadCharacter(string address) { _handle = Addressables.InstantiateAsync(address); _handle.Completed += OnLoaded; } private void OnLoaded(AsyncOperationHandle<GameObject> handle) { if (handle.Status == AsyncOperationStatus.Succeeded) { GameObject character = handle.Result; character.transform.SetParent(transform, false); } } private void OnDestroy() { if (_handle.IsValid()) { Addressables.ReleaseInstance(_handle); } } }资源加载接口必须管理好引用计数,Addressables 的常见问题是加载了不释放,导致内存不断累积。
8. 资源占用与性能观察
8.1 Profiler 面板怎么看
- CPU Usage:看 Main Thread 中 Player Loop 的子模块占比。
- Rendering:SetPass Call、Draw Call、三角形数。
- Memory:Reserved Total、Used Total、Mono Heap。
- Physics:Simulate 耗时、触发器数量。
- UI:Canvas 重建次数、Layout 计算时间。
一个标准的性能观察流程:
- 进入典型游戏场景,找一块空旷但对象密集的区域。
- 开始 Profiler 录制 30 秒,包含:战斗触发、特效播放、UI 刷新、敌人死亡。
- 回放录制数据,找出帧耗时最高的区域。
- 展开 Player 里的子模块,确认是 Scripts、Rendering 还是 Physics。
- 针对瓶颈做一次修复,再次录制对比。一次只改一个变量。
8.2 Memory Profiler 快照对比
安装 Memory Profiler 后:
- 在场景 A 中捕获一张快照。
- 打开某个 UI、加载某个角色、播放一段特效。
- 回到场景 A,捕获第二张快照。
- 对比两张快照差异,查看新增的 Texture、Mesh、GameObject 是否被正确释放。
重点检查:
- Mono Heap 是否持续增长:增长代表 GC 压力,检查字符串、装箱、事件残留。
- Native 资源是否泄漏:Texture、RenderTexture、ComputeBuffer、Material。
- Addressables 引用计数:已加载但未释放的资源会停留在内存中。
8.3 常见性能数据差异
平台差异对同一段代码影响极大:
- PC 上 Draw Call 500 并不卡,移动端可能每帧 50ms。
- PC 上字符串拼接无所谓,移动端 IL2CPP 下 GC 感知更明显。
- PC 上 Mesh Collider 可以,移动端应避免。
- PC 上 4K 贴图没压力,移动端内存直接爆。
优化策略必须按低端机最低配置执行,而不是按开发机标准。
9. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 场景对象增多后掉帧 | Draw Call 或脚本 Update 过高 | Profiler 查看 Rendering 和 Scripts 占比 | 合批、对象池、LOD、减少每帧逻辑 |
| 内存持续增长 | 对象未释放、Addressables 未释放、事件未注销 | Memory Profiler 快照差异对比 | 检查泄漏对象、释放资源句柄、OnDisable 注销事件 |
| 打开 UI 卡顿 | Canvas 重建、Layout 重排、字体图集重建 | Profiler 查看 UI 模块 | UI 动静分层、预生成图集、减少 Layout |
| 移动端发热严重 | 渲染 Overdraw 高、粒子过多、后处理过重 | Overdraw 模式查看红色区域 | 减少半透明叠层、降低粒子数量、关闭全屏后处理 |
| 加载场景长时间卡顿 | 同步加载资源、AssetBundle 解压、Shader 编译 | Profiler 查看 Loading 阶段 | 异步加载、Shader 变体收集、预加载队列 |
| IL2CPP 包体过大 | 托管代码剥离不彻底、Shader 变体过多、内置资源未剔除 | Build Report 分析 | 裁剪未用代码、收集 Shader 变体、剔除 Built-in Resources |
| 调用 Addressables 后数量翻倍 | 重复加载同地址资源 | 查看 Profiler 引用计数 | 统一入口加载、释放时成对调用 |
| Profiler 在移动端连不上 | 防火墙、设备权限、Unity 版本不匹配 | 确认同一局域网、开启 Development Build | 重装 Profiler 模块或使用命令行 profile |
10. 最佳实践与使用建议
10.1 性能优化顺序
推荐严格按顺序处理:
- 先解决 GC 分配问题:字符串、装箱、事件、协程。
- 再做 CPU 逻辑优化:对象池、缓存、减少查找。
- 再做渲染优化:合批、LOD、图集、阴影。
- 最后处理内存:资源释放、纹理压缩、包体裁剪。
这个顺序是经验的总结:GC 问题往往是隐藏的全局性能杀手,渲染优化在没有排除 CPU 瓶颈时很容易白做。
10.2 架构落地建议
- 新项目从第一天就建立模块目录和依赖规范。
- 老项目重构不要一步到位,按模块逐个拆解,每拆一个模块跑一次全量测试。
- 事件总线和服务定位器只解决通信问题,业务逻辑仍然需要遵守单一职责。
- 每个模块提供一份最小可运行示例,方便新成员快速上手。
10.3 工程化建议
- 所有构建过程进批处理脚本,禁止手动出包。
- 所有资源加载统一走封装层,禁止业务侧直接调用 Resources.Load 或 AssetBundle.LoadFromFile。
- 所有网络、存档、音频等全局服务注册到 ServiceLocator,禁止新代码使用 public static 单例传递业务数据。
- 所有 UI 上的动态文本使用字符串缓存或 StringBuilder,禁止在 Update 中直接拼接。
- 所有特效、子弹、飘字必须走对象池,禁止运行时无限制 Instantiate。
10.4 涉及人物、声音和版权的提醒
如果项目包含角色模型、真实人物肖像、声音录制、用户生成内容或第三方素材,务必确认使用授权。本地测试素材可以随便放,但发布、商用、上传到开放平台前必须完成版权审查。涉及用户生成内容时,需要明确用户协议、内容审核机制和举报渠道。
11. 总结与本系列收尾
两个部分的内容放在一起,就是一个中等规模 Unity 项目从初期搭建到后期优化的完整链路。第一部分解决了工程基础、资源管线和热更新选型,这一部分补齐了架构设计、性能优化和系统整合。
最值得先动手验证的,是把事件总线和服务定位器引入到你的现有项目中,挑一个交叉调用最多的模块(比如角色死亡通知),改成事件驱动,然后观察改完之后其他模块的改动成本是否真的下降。这是架构收益最直观的实验。
最容易踩的坑是脱离 Profiler 做优化。无论你多笃信某个技巧有效,都要先记录当前性能数据,修改后再次记录,用数字说话。没有测量的优化都是玄学。
后续可以继续探索的方向包括:DOTS 在千人同屏场景的实战落地、Addressables 在大型开放世界中的资源流式加载策略、基于增量 GC 与内存分层的移动端内存治理,以及构建管线接入 CI 后如何自动执行性能回归测试。这些话题在 Unity 2022 LTS 和 Unity 6 时代都还有大量可展开空间。
建议收藏本文,等真正进入项目性能和架构优化阶段,再对照流程逐步执行。