1. 项目骨架规则:目录划分、命名约定与资源管理的硬约束
1.1 目录怎么分,才不会在三个月后想重构
Unity 项目有个特点:任何人拿到一个新工程,第一眼看的不是代码,是 Assets 目录。目录结构如果乱,后面所有事儿都会跟着乱。我见过太多项目,Scripts 下面塞着几十个脚本,Materials 和 Textures 混在一起,Plugins 里躺着三四个平台的 SDK,还有叫 NewFolder 的、叫 test2 的、叫 final_v3 的目录,这种项目别说接手,自己过两周回来都找不到东西。
我这边长期使用的规范是这样的,按资源类型和业务模块双向划分:
Assets/ ├── _Project/ │ ├── Art/ # 美术资源(Models、Textures、Materials、Animations) │ ├── Audio/ # 音频(BGM、SFX、Voice) │ ├── Code/ # C# 脚本(Framework、Gameplay、UI、Editor) │ ├── Data/ # ScriptableObject、Json、配置表 │ ├── Effects/ # 粒子、Shader、特效相关 │ ├── Prefabs/ # 预制体(Player、UI、Props、FX) │ ├── Scenes/ # 场景文件(按关卡或功能分子目录) │ ├── Settings/ # 渲染管线资产、输入配置、物理配置 │ └── ThirdParty/ # 第三方插件、SDK为什么加_Project这个前缀?因为 Unity 默认的 Assets 根目录下面经常会有 Unity 自动生成的目录,比如Editor、Resources、Plugins,如果我们自己的业务资源全部收拢到一个带下划线前缀的目录里,一眼就能和引擎级目录区分开。下划线前缀还能让这个目录在 Project 窗口里默认排最前面,打开工程第一眼看到的就是自己的项目结构。
另外有一个原则:Resources目录尽量只有一个,且放在_Project/Resources底下。很多人习惯在多个目录里各建一个 Resources,Unity 虽然支持这种写法,但一旦资源路径冲突或者你想做热更新、AssetBundle 迁移,会非常痛苦。统一收口,所有动态加载的资源走同一个目录,是后续优化资源管理的基础。
1.2 命名规范:从资源名到脚本类名的统一约定
命名这件事,看起来是小事儿,但对协作的影响极大。我参与过十几个 Unity 项目,踩过最深的坑就是命名字段不统一,比如同一个角色模型,有人叫Hero_Swordman_001,有人叫swordmanFBX,还有人叫角色-剑士-01。这种资源在美术、策划、程序之间流转时,每次都要人肉确认"这个是不是那个",效率极低。
我们团队最终定下来一套规则,简单粗暴但有效:
- 文件名使用 PascalCase,即每个单词首字母大写,如
PlayerController.cs、Swordman_Attack.fbx。 - 资源名前缀追踪资源类型:
- 模型:
M_前缀(M 代表 Mesh/Model),如M_Swordman.fbx - 材质:
MAT_前缀,如MAT_IronArmor.mat - 纹理:
T_前缀加用途说明,如T_Swordman_Albedo、T_Swordman_Normal、T_Swordman_Roughness - 预制体:
PF_前缀,如PF_Enemy_Swordman.prefab - 动画片段:
Anim_前缀,如Anim_Swordman_Attack01 - UI 界面:
UI_前缀,如UI_MainMenu.prefab - 脚本:不强制加前缀,但类名必须和文件名完全一致,否则 Unity Inspector 上 MonoBehaviour 会变成 Missing Script
- 模型:
- 场景命名:
Scene_前缀加模块名和序号,比如Scene_Game_MainLevel_01。 - 导出动画片段时特别注意:如果从美术工具导出 FBX 时带多个动画,在 Unity 里通过 Model Importer 的 Animation 页签拆分出
Take 001、Take 002,导出前就约定好用Anim_重命名每一个 Clip,而不是让Take 0xx留在工程里,不然早晚会引用错动画。
这套规则并不复杂,难的是坚持。所以我建议把它写进项目的 README,并且在 Review 时把命名规范作为第一道检查项。别嫌烦,一个三个月后还能快速找资源的项目,和一堆乱命名资源的项目,协作效率能差出一倍。
1.3 资源导入设置:Meta 文件与平台适配在源头就要管好
Unity 里的每一个资源都有对应的.meta文件,这个文件记录着资源的 GUID、导入设置等关键信息。规范里必须有一条硬性规定:meta 文件必须强制提交到版本控制,任何时候都不能删。一旦 meta 丢失,Unity 会重新生成新的 GUID,所有引用到这个资源的场景、预制体、脚本序列化数据全部会断链,表现出来的就是 Prefab 上缺字段、场景里脚本丢失。这种事情在团队协作里发生过太多次,每次都是大事故。
资源导入设置也要在源头规范化。不同平台对纹理、音频、网格的默认导入设置完全不同,如果不处理就进工程,包体、内存、加载时间都会失控。我的建议是:
- 纹理资源:根据用途提前指定类型。UI 图用
Sprite(2D and UI),模型贴图用Default,法线贴图必须标记为Normal Map。像素压缩格式统一,Android 用 ASTC,iOS 用 ASTC 或 PVRTC,不使用平台默认的 TrueColor 保留格式,否则内存直接拉满。 - 音频资源:背景音乐用
Streaming(流式加载),音效用Compressed In Memory,短效提示音用Decompress On Load。Vorbis 压缩质量设置在 50~80 之间,别默认拉到 100,包体大不说,听感差异极小。 - 模型资源:如果模型只用于场景摆放不参与骨骼动画,
Rig类型设为None,这样可以跳过骨骼解析;如果模型没有动画,Animation Type设为None能省不少内存;如果有动画,才设Humanoid或Generic。Read/Write Enabled默认保持关闭,只有当脚本需要运行时读取网格数据时才打开,这个开关开着会缓存一份 CPU 可访问的网格副本,直接导致内存翻倍。
这些设置看起来琐碎,但就是这些琐碎的东西,最后决定了你的包体能压到多少、内存能不能稳住、场景切换掉不掉帧。
2. 脚本与代码规范:生命周期、状态管理与事件驱动的正确姿势
2.1 MonoBehaviour 的职责边界:别让一个脚本变成垃圾桶
Unity 项目里最常见的坏味道,就是一个MonoBehaviour类里什么都有:能接受输入、能播放动画、能调 UI、能发网络请求、还能算伤害数值。这种脚本一开始写的时候很爽,后面越改越痛苦,因为每加一个功能都要在同一个类里找地方塞,依赖关系越来越乱。
我把脚本按职责拆成了四种类型:
- 组件型脚本:挂载在 GameObject 上,处理该物体自身的表现逻辑。比如
PlayerMotor负责移动,PlayerAnimatorController负责动画状态切换,HealthComponent负责血量变化。 - 管理器型脚本:全局唯一,负责某一类系统的调度。比如
UIManager、AudioManager、EventManager,通常是单例或静态类,不挂在场景物体上(或挂在专属的管理节点上)。 - 数据型脚本:纯 C# 类,不继承 MonoBehaviour,用于描述数据结构和配置。比如
PlayerData、ItemConfig。能写成纯 C# 的绝不写成 MonoBehaviour,这样方便单测,也避免场景引用导致的内存泄漏。 - 工具型脚本:静态方法集,比如
MathUtils、FileUtils、StringUtils,没有状态,只做纯计算和 IO 操作。
为什么强调这个拆分?因为 MonoBehaviour 一旦挂到 GameObject 上,它的生命周期就被引擎接管了,什么时候 Awake、什么时候 OnDestroy 不完全由你控制。如果我们把纯逻辑的代码也塞进 MonoBehaviour,在单元测试时就要创建 GameObject、挂组件、跑场景,折腾一圈下来成本极高。而纯 C# 类可以在 EditMode 下直接跑测试,快速验证逻辑。
2.2 Update 方法的使用纪律:能不用轮询就不用
Unity 开发里性能杀手第一名,就是滥用Update。很多人习惯把判断写在 Update 里每帧执行,比如:
void Update() { if (Input.GetKeyDown(KeyCode.Space)) { DoJump(); } }这个例子还好,性能损耗可忽略,但如果你在 Update 里做路径查找、资源加载、字符串拼接、反射调用,那问题就大了。规范里我定了这么几条:
- 事件驱动优先:输入检测、UI 显隐、业务逻辑触发,优先用事件或回调。比如按钮点击用
onClick.AddListener,粒子播放完用ParticleSystem.Stop回调,而不是在 Update 里每帧检查状态。 - 需要每帧处理的才放进 Update:比如角色移动、摄像机跟随、动画参数更新。这些东西本身就需要逐帧插值或同步,放 Update 理所当然。
- 低频逻辑用协程或计时器:比如每 5 秒刷新一次排行榜,写一个协程
while(true){ yield return new WaitForSeconds(5f); Refresh(); },比在 Update 里Timer += Time.deltaTime然后判断要干净得多。 - 固定物理用 FixedUpdate:刚体相关的力、速度修改要放在 FixedUpdate 里,放 Update 会导致物理计算不稳定,现象是低帧率时角色飘、跳跃高度不一致。
- LateUpdate 只用于摄像机跟随和动画:LateUpdate 在 Update 之后执行,此时所有角色移动已经完成,相机跟随不会出现抖动。
协程这个方案有个注意点:协程的执行时机是受 MonoBehaviour 生命周期影响的,如果物体被 SetActive(false),协程会继续跑,如果物体被销毁,协程停。所以在使用协程时,要确保对协程的启停有清晰的生命周期控制,最好用专门的TaskRunner或 UniTask 管理,而不是到处裸奔StartCoroutine。
2.3 单例、事件中心和静态状态:全局变量的边界在哪里
Unity 项目里单例模式几乎成了标配。GameManager.Instance、AudioManager.Instance、DataManager.Instance,到处都是。单例用得好能简化跨模块通信,用得不好就是隐形的地雷,场景切换不销毁、静态引用不释放、模块间耦合过重。
我的规范是:
- 单例对象必须明确区分全局单例和场景单例。全局单例跨场景存活,放在专门的
_App节点下,场景加载时不销毁;场景单例随场景销毁,比如LevelManager,在场景内各系统间共享状态。 - 单例的构造和销毁要处理干净。建议用自动创建模式,即在
Instance属性里判断为空时自动new GameObject挂载,而不是在场景里手拖。这样可以避免忘挂、重复挂的问题。 - 跨模块通信优先走事件中心而不是直接引用单例。比如"背包物品被拖拽到装备栏"这个事件,
BagManager只管发出事件,EquipmentManager监听事件并处理,双方不持有彼此引用。这样新增模块、替换模块时不动其他代码。 - 静态类的使用要克制。静态状态(静态字段、静态属性)不归任何对象管理,一旦改了就全局生效且很难追踪。能放进 ScriptableObject 配置的,别用静态字段;能通过依赖注入传参的,别直接访问静态类。
还有一点很多人忽略:Unity 的OnApplicationQuit和OnDestroy执行顺序在不同平台不一样,如果单例在退出时互相引用,可能报空引用错误。我建议在单例的OnDestroy里只做"释放本对象持有的资源"这一个动作,不要尝试调用其他单例的方法,各管各的,出问题概率会小很多。
3. 渲染与场景管理规范:从包围盒、阴影到 UI 显隐都有讲究
3.1 包围盒与合批:看不见的渲染开销决定性能上限
很多项目在优化渲染时,第一反应是"把阴影关掉""把分辨率降一点",这些当然有效,但属于先砍视觉再砍性能。真正该做的第一件事,是检查渲染器的包围盒(Bounds)是否合理。
Unity 渲染器上的Renderer.bounds是 Unity 做视锥剔除、遮挡剔除、光照计算的基础数据。如果包围盒不对,会引发两类明显问题:
- 包围盒过大:物体会在不可见时仍然参与渲染,浪费 DrawCall 和渲染时间。常见原因是从美术工具导入的模型没有正确计算包围盒,或者预制体上有残存的不可见子物体把 Bounds 撑大了。
- 包围盒过小:物体会在镜头已经能看到它时被剔除,屏幕上直接出现"凭空消失的模型"。常见原因是运行时修改了 Mesh 的顶点数据,比如地形切割、动态变形,但没有调用
Renderer.UpdateBounds。
实操上的做法是:所有新导入的模型都在Model Importer里勾选Auto Generate Colliders(如果需要)并检查Mesh.bounds是否合理;运行时动态修改网格后,必须手动调用rebuildBounds。另外要把动态合批和静态合批的使用边界划清楚。动态合批适用于移动物体,但有顶点数限制(每批 300 顶点以内,取决于平台),静态合批适用于不动的物体,能显著减少 DrawCall,代价是合批后无法单独变换和减裁,内存会略微增加。规范里写死一条:所有不动的物件(场景装饰、墙体、桥梁)必须标记为 Static 并启用 Batching,所有移动物件尽可能用低面数模型,让引擎动态合批能命中。
还有人习惯用一个巨大的空物体包着整关卡,只有一个渲染器,这在个别项目里是为了好做整体显隐,但代价是完全破坏了视锥剔除的粒度——整个关卡永远在渲染范围内,等于关闭了剔除。这种场景我建议拆成多个区域块,每块一个渲染器,用遮挡剔除(Occlusion Culling)来处理,性能和内存都会好很多。
3.2 阴影、Shader 与渲染管线的规格约束
阴影和 Shader 是渲染规范里最容易失控的部分。很多美术同学对性能没有概念,随手放一盏平行光加一盏点光,再开实时阴影,场景里什么都齐了,一跑起来掉帧掉到没法看。
我的建议是:
- 先约定渲染管线和光照方案。项目启动时就要确定是使用内置渲染管线(Built-in)、URP 还是 HDRP。URP 是目前中小型项目的主流选择,性能好、扩展性强、移动端友好。渲染管线一旦确定,尽量避免在中途大规模更换,这个过程会痛苦到你想删库。
- 实时阴影只在必要时开启。移动端项目,角色和主要场景物件可以开实时阴影,其余大量装饰物用烘焙光照贴图(Baked Lightmap)。
Shadow Distance按场景大小调试,一般设置在 30~80 米之间,超过这个距离的阴影取消渲染,这比调阴影分辨率更省性能。 - Shader 的使用要统一收口。项目里所有材质应该基于同一套 Shader 变体构建,避免每个人从网上下一个 Shader 就往工程里扔。变体数量会直接影响打包时间、包体和运行时加载耗时。在 Shader 里慎用
#pragma multi_compile,能用#pragma shader_feature的绝不用前者,因为前者会把所有关键字组合都打进变体,内容直接指数膨胀。 - 关于渲染管线的逆向分析:调试麻烦的渲染问题时,可以在 Frame Debugger 里逐 DrawCall 查看渲染状态,这能定位大多数"为什么这个物体被渲染了两次""为什么这个乱画了"的问题。团队里如果有人负责图形底层,建议常规性地在 Frame Debugger 和 RenderDoc 中检查 pass 数量和材质参数,避免人均 Shader 滥用导致的问题变成长期技术债。
3.3 UI 显隐方案:SetActive、LocalScale 与移出屏幕怎么选
UI 显隐这个问题在网上讨论很热烈,核心争论是SetActive(false/true)、LocalScale、移出屏幕三种方案哪个好。我的经验是,没有绝对好坏,只有适合的场景。
我整理一个对照表:
| 方案 | 原理 | 性能开销 | 适用场景 |
|---|---|---|---|
| SetActive(false) | 禁用 GameObject 及所有组件 | 重新激活时重建组件状态,开销较大,但释放渲染和更新开销 | 不频繁切换的界面、弹窗、加载界面 |
| SetActive(false) 替代方案:Canvas.enabled | 禁用 Canvas 渲染,但保持逻辑活动 | 不重建组件,但 Update 仍在跑 | UI 需要在隐藏时继续做逻辑(比如倒计时) |
| LocalScale = Vector3.zero | 把物体缩到零,视觉上隐藏 | 开销最小,但 UI 仍然参与布局计算,极端情况会产生视觉残影 | 不推荐,除非在特殊动画过渡中 |
| 移出屏幕(RectTransform 移到视口外) | 通过修改 anchoredPosition 把 UI 移走 | 无重建开销,但位置计算麻烦,容易出 Bug | 不推荐日常使用,多用于特定过场动画 |
我的规范是:界面频率低、结构复杂的(排行榜、商城、设置面板)用SetActive(false),进入和退出时配合简单的 CanvasGroup 淡入淡出动画,避免突兀。需要频繁切换的(比如 Tab 页签)用CanvasGroup控制 alpha 和 raycastTarget,保留所有子物体激活,牺牲一点内存换流畅切换。绝对不要用 LocalScale(0) 做永久隐藏,因为在部分 UI 布局下会出现子物体布局残留、点击区域不符等问题。
另外,GraphicRaycaster是 UI 点击检测的入口,面板隐藏后一定要关闭raycastTarget,否则面板看不见但依然挡着下层 UI 点击。这也是新手最常见的问题之一——按钮没反应,找半天发现上面挡了个透明 Panel。
4. 性能与内存优化规范:用数据说话,别拍脑袋优化
4.1 Profiler 使用规范:性能优化必须从数据开始
我见过太多项目组在优化性能时靠"感觉":感觉这个界面打开有点慢,感觉战斗时有点卡,感觉内存有点高。问哪里慢,说不上来;看具体数据,没记录过。这种优化方式,十次有八次方向是错的。
Unity 的性能分析工具链已经很成熟,规范里必须要求每个开发者学会使用:
- Unity Profiler:定位 CPU、GPU、内存、渲染、物理、UI 各部分耗时,按时间线检查卡顿点。
- Frame Debugger:定位渲染管线中每个 DrawCall 的耗时和提交顺序,检查是否有多余的 pass。
- Memory Profiler:查看托管堆分配、原生内存、资源占用,定位内存泄漏。
- Profiler 的 Deep Profile 模式:如果定位不到具体方法耗时,用 Deep Profile 会大幅降低帧率但提供所有函数调用时间,适合攻坚阶段。
性能优化的核心步骤永远是:先有数据,再定目标,然后动手。比如发现某场景帧耗 18ms,用 Profiler 一查发现 Physics 占了 6ms,UI 占了 4ms,渲染占了 5ms,你就可以针对性地去查物理碰撞体和 UI 刷新逻辑,而不是一上来就关阴影降分辨率。
4.2 资源加载、缓存与释放:内存管理的生命周期约定
内存管理的核心不是"内存不够了想怎么释放",而是"从设计上就控制内存的增长"。我们团队的内存规范包括这几条:
- 场景资源按需加载:场景里不用的资源不要一股脑加载进来。UI 资源用 Addressables 或 AssetBundle 按功能模块打包和加载,主城场景只加载主城需要的资源,战斗场景只加载战斗资源,绝不把全量资源放进一个包。
- 资源缓存统一管理:同一份资源(比如一个角色预制体)不能被多个系统重复加载。用
AssetReference或资源管理系统统一查重,已加载的资源直接引用计数加一,释放时逐一减一,引用计数归零才真正卸载。这样做能避免"同一个模型被加载了 20 份"的内存灾难。 - 定时清理和场景切换清理:场景切换时统一走资源管理器释放当前场景资源,避免单独在
OnDestroy里东一个西一个地 Unload。 - 对象池管理高频物:射出去的子弹、飘字、粒子特效,这些高频创建销毁的对象必须走对象池。对象池的核心是预分配和复用:实例化出的对象把组件状态重置干净再入池,取出时执行
OnSpawn/OnDespawn回调,而不是靠SetActive硬切。
还有一个容易忽略的点:Resources.Load加载的资源默认不会卸载,即使调用Resources.UnloadUnusedAssets,也要等到场景切换的合适时机才能完全释放。如果项目不是做极小的 Demo,建议长期方案是切换到 Addressables,它对资源依赖分析、内存释放、远程加载的支持要完整得多。
4.3 物理、动画与 Timeline 的取舍规范
物理是性能大头。规范里我要求:
- 碰撞体尽量用基本形状:Box、Sphere、Capsule,不要用 Mesh Collider(特别是凹多边形网格碰撞),复杂的碰撞体能省则省。
- 减少每帧物理查询:
OverlapBox、SphereCast、Raycast这类查询如果在 Update/频繁调用中滥用,会极大地拖慢性能。能用事件驱动(如触发器 OnTriggerEnter)就绝不用轮询射线检测。 - 控制刚体数量:场景里如果有几万个刚体,性能是不可能好的。静态碰撞体用不带刚体的 Collider,只有真正需要物理响应的物体才加刚体。
动画方面,Animator的 Parameters 数量和状态机复杂度会影响 CPU 开销。能用Timeline做过场动画和序列化事件的地方,不要用一大坨脚本状态机硬写。Timeline 的PlayableDirector可以轻松控制多轨动画、音效、事件回调,但要注意 Timeline 使用的 Clip 资源如果是通过 FBX 导入的多个动画片段,需要在导入阶段就把Animation Type和Loop Time设置正确,否则运行时会出现动画跳帧或循环异常。
关于动画 Event:用 Animation Event 进行"动画播到某一帧调某个方法"的逻辑时,要特别注意参数类型只能使用简单类型(int、float、string),不要试图传复杂对象,也不要试图在动画事件里做重逻辑结构(比如异步加载大资源),这些用法都会引入难以排查的时序问题。
5. 数据通信规范:串口、网络与数字孪生场景的统一封装
5.1 串口通信的封装与异常处理
搜索热词里有 Unity 串口通信,这个场景在数字孪生、工业设备对接、传感器数据采集中非常常见。Unity 本身不直接支持串口,需要通过System.IO.Ports.SerialPort来实现,但直接用这个类写业务会有很多坑。
我的规范是封装一个SerialPortManager:
- 串口参数配置放在 ScriptableObject 或 JSON 配置文件里:端口号、波特率、数据位、停止位、校验位都抽出来,不要在代码里写死。换设备时改配置就行。
- 读写分离:读数据放后台线程,写数据走主线程队列。
SerialPort.DataReceived事件在后台线程触发,不能在事件里直接操作 Unity API。做法是先把数据丢进 ConcurrentQueue,在主线程的 Update 里取出并解析。 - 必须做异常和断线重连:串口经常会出现拔插、占用、数据乱码等问题。封装类里要有重连机制,检测到
SerialPinChanged或者超时无数据时自动重连,而不是直接抛出异常崩溃。 - 超时控制:串口读取要设置
ReadTimeout和WriteTimeout,避免设备不响应时阻塞主线程,导致游戏卡死。
5.2 网络协议与数据同步规则
网络通信和串口最大的不同在于数据和时序的复杂度。规范上我建议统一走消息分发框架:
- 客户端和服务器之间统一使用消息 ID + 数据体的格式,消息 ID 用 int 或 short 表示,数据体用 Protocol Buffers 或 MessagePack 序列化。不要混用 Json、
BinaryFormatter、Xml,否则后续维护成本爆炸。 - 客户端 UI 和网络层之间不能直接耦合。网络回调里更新 UI 必须通过事件中心派发,且回调只会发生在主线程。Unity 的跨线程问题在协程网络库中很容易踩坑,用 UnityWebRequest 的
SendWebRequest回调时,默认已经回到主线程,可以放心更新 UI;但使用原生 TCP Socket 或第三方网络库时,必须手动处理回调线程。 - 数据同步要区分全量和增量。每次切换场景、登录进服时拉全量数据;运行中只发增量数据(比如玩家位置、背包物品变化)。全量数据包如果频繁发送,网络带宽和客户端解析开销都会失控。
5.3 地图场景与数字孪生的接入规范
数字孪生、智慧城市、地图可视化的需求越来越多,这类项目里经常用 Cesium for Unity 加载真实地理信息数据。接入这类 SDK 时,规范上要注意:
- 首次对接先确认许可协议和 SDK 版本,Cesium for Unity 的版本和 Unity 主版本有较强绑定关系,选错版本会导致整个工程跑不起来。
- 离线地图数据是另一个大坑:如果项目对网络环境有要求(内网部署、展厅无外网),需要把 Cesium 的影像数据、地形数据下载到本地,在工程里配置离线地形和影像源。这个环节要在项目设计阶段就确定,不能在开发中后期再转型,否则会把整个数据加载架构推倒重来。
- 地图场景的性能优化:Cesium 的 3D Tiles 加载是动态调度的,场景里同时可见的瓦片数量越多,DrawCall 和显存占用越高。在场景规范里要限制
MaximumScreenSpaceError的值,它控制瓦片精度的选择,值越大加载的瓦片越粗糙、数量越少,在主要关注宏观呈现的项目里可以适当调大。 - 地图场景和业务场景分离:地图功能做成独立模块,不要让业务逻辑直接操作 Cesium 的内部对象。这样地图 SDK 升级时,业务层不需要改动。
关于 Perlin Noise 这类过程化地图生成(搜索热词里也有mathf.perlinnoise),如果项目中涉及地形生成,建议把它封装成独立的地形生成工具,输出高度图到 TerrainData,而不是在运行时逐格生成顶点。运行时逐格修改地形顶点,性能开销大且容易触发很多隐性 Bug(法线、碰撞、LOD 重建等)。
6. 构建发布与平台适配规范:从 Windows 到微信小游戏一次跑通
6.1 宏定义与平台分支的使用纪律
Unity 的宏定义(#if UNITY_ANDROID、#if UNITY_IOS、#if UNITY_EDITOR)是平台适配的基础工具,用好了事半功倍,用差了代码可读性极差。
我的规范是:
- 平台差异代码必须收口,不能散落在各个业务脚本里。统一用
PlatformAdapter类封装,业务层永远只调用PlatformAdapter.SomeMethod(),内部通过宏做平台分支。例如支付、分享、推送、登录等第三方 SDK,每个 SDK 都封装成一个 Provider,通过平台适配器来决定实例化哪个 Provider。 - 宏定义里的代码必须保持最小,只放平台 API 调用和数据类型转换,不放业务逻辑。业务逻辑应该抽到公共方法里,让不同平台调用同一个方法。
- 在
Player Settings里的Scripting Define Symbols,要记录每一项的含义和开启人,避免项目切换到另一个分支时出现"为什么我这边多一个宏?"的困惑。团队的ProjectSettings文件里相关字段要定期 Review。
特别提一下UNITY_WEBGL和微信小游戏平台。微信小游戏本质上运行在 WebGL 环境,但又有自己的一层 JavaScript 桥接。像Application.platform在微信小游戏里返回的是 WebGL,但如果用了 WeChat 小游戏 SDK,它是通过WX对象注入的,所以必须区分"通用 WebGL"和"WeChat 小游戏"两层平台维度。这个适配建议专门做一个WechatPlatformAdapter,在微信小游戏增加广告、支付、视频播放能力的接入和回退。
6.2 构建配置与部署:WebGL、IIS 与微信小游戏的差异化处理
从热词里能看到unity 发布web部署iis、unity 微信小游戏打包这些具体需求,这里展开讲一下部署层面的规范。
WebGL 构建:
- 压缩格式:Brotli 或 Gzip,选择一种,并在服务器端配置对应的
Content-Encoding头。WebGL 的gz文件结尾不要改扩展名,否则部署到服务器后容易 404。 Player Settings -> Publishing Settings里,Enable Exceptions一般选Explicitly Thrown Exceptions Only,部署正式环境时关闭Development Build,避免过大的调试开销。- WebGL 部署到 IIS 时,需要单独配置 MIME 类型:
.unityweb映射为application/octet-stream,.wasm映射为application/wasm,否则浏览器下载时类型不对可能导致无法加载。每次构建后有条件的可以写一个自动部署脚本,把构建产物拷到 Web 目录并刷新 IIS 应用池,减少手动操作。
微信小游戏:
- 微信小游戏打包和其他平台最大的不同是需要把 Unity 的 IL2CPP 产物和微信环境桥接,常用的方案是官方提供的
Unity WebGL 小游戏适配插件。构建时要把Compression Format设置为Disabled或具体适配插件支持的格式,否则小游戏运行时解压会出问题。 - 小游戏的包体限制很敏感,主包超过 4MB 就需要走分包加载。这类项目要尽早把游戏资源从
StreamingAssets调整为 CDN 远程资源,避免后期全部返工。 - 微信小游戏的视频播放方案:通常是使用
<video>原生组件插在 Canvas 上,通过WX.createVideo创建视频对象,播放 Unity 外部 CDN 上的视频文件。这块的坑在于视频层级与 Unity Canvas 层级冲突,需要做原生层和 WebGL 层的同步,建议封装成一个 YouTube 式的视频播放组件,对外暴露播放、暂停、关闭、回调统一接口。
Windows 构建:
- 提到一个常见的报错:
Unity is running with administrator privileges, which is not supported.,这个在 Windows 上以管理员身份运行 Unity Editor 时常出现,主要是 Unity 觉得管理员权限会影响某些功能。规范上的做法是不要拿管理员权限跑 Unity Editor,如果不是必须,用普通用户权限启动。如果项目里确实有脚本需要管理员权限才能操作文件/注册表,也应该把权限提升放到运行时单独做,而不是让整个编辑器全程管理员运行。
6.3 代码混淆、加密与安全的基础规则
Unity 客户端的代码安全一直是个伪命题,因为它原生跑在用户设备上,逆向只是时间问题。但我们依然要做基本的防护,目的不是绝对安全,而是提高逆向门槛、保护付费逻辑和客户端的协议层。
- 混淆工具:用 Beebyte、Obfuscator 或 open-source 的混淆方案对最终发布的 C# 程序集做混淆。注意混淆后要完整跑一遍回归测试,混淆经常会踩到反射、序列化、字符串加密相关的坑。
- 敏感信息剥离:支付密钥、服务器 IP、AES 密钥禁止直接写死在代码和配置里。正确做法是放在服务器下发的配置里,或者用公钥加密后运行时解密。如果必须内置密钥,也要拆散存储并混淆变量名。
- 协议加密:网络通信至少做一层加密,全走明文很容易被工具抓包后模拟请求。轻量场景用 AES + 随机 IV,重要场景用 HTTPS 或 TLS。数字签名要加防重放机制,客户端和服务器都加上时间戳校验。
- 防破解的思路要现实:不要在客户端做 "是否正版"的强校验,因为这会被 patch 调分支跳过。更实际的是把核心玩法逻辑放在服务器端,客户端只做表现层。
7. 版本管理与协作规范:Git、Review 与自动化质检
7.1 Unity 项目的 Git 配置要点
Unity 项目在 Git 协作上有一个和其他项目截然不同的地方:场景文件、预制体、资源导入设置都是文本化的 YAML 格式,虽然可以 diff,但冲突极其频繁。所以版本管理的规范必须前置。
.gitignore必须提前配好,把Library/、Temp/、Obj/、Build/、Logs/、UserSettings/都忽略掉。Packages/目录要提交,ProjectSettings/要提交。Assets/下所有.meta文件必须提交,不能进.gitignore,这条之前讲过,这里是操作层面的强制要求。- 建议开启
git lfs管理大文件资源(模型、纹理、音频),否则仓库体积很快膨胀到几个人拉不动代码。在.gitattributes里声明*.fbx filter=lfs,*.png filter=lfs之类的规则。 - 场景文件的冲突处理思路:同一时间尽量只让一个人改同一个场景。如果确实要并行开发,先让一个人把场景结构(空物体、节点层级)定好提交,其他人只往里面填内容,这样冲突概率小很多。
7.2 代码 Review 检查清单与常用检查点
Review 的目的不是挑刺,而是把常见坑拦在合入前。我整理了一份 Unity 项目专用的 Review 检查清单,每次提交代码前自己先过一遍:
- 是否引用了外部资源(如
Resources.Load、AssetBundle 加载)而没有对应的释放逻辑? - 是否在 Update 里做了不必要的计算(字符串操作、反射、一次性初始化)?
- 是否用到了
GameObject.Find、FindObjectOfType等搜索型 API?如果在 Update 里出现,直接打回。 - 是否在
OnDestroy中安全地解绑了全局事件?(否则跨场景会留下悬挂事件,调用时直接空引用) - 是否在静态类或单例里持有场景对象引用?(这会使场景对象在切换时无法被 Destroy)
- 是否处理了
OnDisable/OnEnable的重复注册问题?(AddListener 时先 RemoveListener,避免重复调用) - 修改了渲染相关的代码后,是否在目标平台真机测试过?(Editor 里渲染行为和真机差别很大)
- 是否在
OnValidate、OnDrawGizmos里写了会影响合批的代码?
如果项目组成员对代码规范不熟悉,可以在项目里加一个自动化检查工具用脚本扫描:比如检查Update里是否调用了GetComponent或Resources.Load,这些都能用Editor脚本静态扫描出来,在 CI 或打包流水线上跑。
7.3 CI/CD 与自动化构建的接入经验
Unity 项目的 CI/CD 没有 Web 项目那么轻量,因为构建环境需要安装 Unity Editor 和模块,且 Unity 的许可证激活、-batchmode命令行参数都要处理。但这块一旦跑通,收益非常明显。
- 标准做法:本地安装 Unity 命令行工具,用
unity-editor -batchmode -quit -projectPath ... -executeMethod BuildScript.BuildXXX -logFile -在服务器或本地流水线上执行构建。BuildScript里用BuildPipeline.BuildPlayer的 API 指定目标平台、输出路径、场景列表、宏定义、AssetBundle 打包。 - 自动化要分成三层:Testing 层(跑 EditMode / PlayMode 测试)、Validation 层(资源检查、命名检查、Shader 变体检查)、Build 层(产出 Android/iOS/Windows/WebGL 安装包)。三层都过了,才允许合入主分支。
- 一个很关键的细节:Unity 的
-batchmode下跑测试必须加上-runTests -testPlatform EditMode -testResults参数,并且建议在 CI 环境里锁定 Unity 的小版本(比如 2022.3.20f1),不同 patch 版本对某些资源导入和 Shader 编译的差异会带来构建结果不一致的问题。
8. 结尾前再多讲两句:Unity 开发规范的本质是降低沟通成本
写到最后,我想说一句:开发规范这东西,它不是束缚,不是教条,它是让团队里每个人在同一个语境下工作的一套默认约定。规范的价值不在于每一条都对,而在于"团队有共识"这件事本身。哪怕你是一个人做项目,写规范也能帮你三个月后快速找回当时的思路;带团队的话,规范的杠杆作用就更明显了。
我之前在项目里一度觉得规范很麻烦,每个脚本要按框架写、命名要带前缀、资源要按目录放,总觉得拖慢节奏。等到项目进入中后期,当我要同时改十个脚本、加一个新功能、发一个测试包的时候,发现因为前期规范立住了,所有东西都能被"预料到",那一刻才真正体会到规范省下来的时间有多可观。
如果这篇内容对你的项目有帮助,可以从最痛的 2~3 条开始落地,比如先把meta文件的提交规则和Update的使用纪律立起来。不用一口气全上,渐进地来,你的 Unity 项目会慢慢变得干净、可维护,最终受益的一定是每一个参与开发的人。