做Unity开发久了,你会发现真正让你卡壳的往往不是复杂技术,而是那些“常用知识”里的隐藏细节。比如同样的Update,脚本加载顺序不同表现就不同;同一个插值运算,坐标系没理清模型就跑偏。这篇东西不讲高大上的架构,就把Unity3d开发里最高频的几块知识——帧循环、坐标系、生命周期、协程、资源加载、性能优化——按我踩过的坑重新讲一遍,适合刚入门的同学建立整体框架,也适合有几年经验但总在某些地方反复栽跟头的老手对照自查。
1. 帧循环与时间管理:Unity运行时的最底层认知
很多初学者把Unity当成一个“不断调用Update的定时器”,框架本身确实是这样,但理解深一点,你会发现这个“定时器”分了好几个节奏,每个节奏适用场景完全不一样。大多数诡异Bug,比如角色瞬移、物理穿透、UI忽快忽慢,根源都在于没搞懂Update、FixedUpdate和LateUpdate的关系。
1.1 Update、FixedUpdate与LateUpdate的关系
先看一张内存里的执行顺序:场景加载后,先是Awake,然后OnEnable,接着FixedUpdate(如果启动物理模拟),然后Update,再然后LateUpdate,最后渲染。每一帧都走一遍这个循环,但FixedUpdate和Update的调用频率是独立的。
Update每一帧调用一次,调用频率直接取决于屏幕刷新率。在60Hz的手机上,一秒60次;在90Hz的高刷屏幕上,一秒90次。所以Update里做位移时,必须乘以Time.deltaTime,否则角色速度会随设备帧率变化,这就是“不同手机上角色跑得不一样快”的经典原因。
FixedUpdate则是固定步长调用,默认是每秒50次(每次间隔0.02秒)。它不随帧率变化,即便你的游戏卡到只有10帧,FixedUpdate依然会按物理时间追赶调用,保证物理模拟的稳定性。但注意,这不意味着FixedUpdate里不能出现卡顿——如果一帧里需要补很多次FixedUpdate,这一帧的时间开销会急剧上升。
LateUpdate在Update之后执行,适合做相机跟随。想象一下,如果相机跟随代码放在Update里,而角色位移也在Update里,执行顺序取决于脚本的执行顺序,可能先移动相机再移动角色,画面就会产生一帧的抖动。放在LateUpdate里,可以确保在所有物体的Update执行完之后,相机再根据最新的位置进行跟随,画面就平滑很多。
我自己的习惯是:凡是跟Rigidbody物理交互有关的内容,尽量放在FixedUpdate里;逻辑状态、输入处理放在Update;相机跟随、UI同步放在LateUpdate。不过要注意,Rigidbody组件应该由FixedUpdate来做速度赋值,而不是每帧直接改Transform,否则会干扰物理引擎的求导准确性。
1.2 用deltaTime还是unscaledDeltaTime
Time.deltaTime是当前帧和上一帧的时间间隔,会受Time.timeScale影响。如果你想做游戏暂停效果,把timeScale设成0,所有基于deltaTime的动画、位移都会停止,但UI、暂停菜单、过场动画可能仍然需要正常运转。这时候不能用deltaTime,要用Time.unscaledDeltaTime。
我第一次做暂停菜单时,整个界面都“死”了,后来才发现,暂停菜单的按钮动画用了dotween的默认时间缩放,导致所有补间也停了。正确做法是把菜单动画的UpdateType改为UnscaledTime,或者自己写一个基于unscaledDeltaTime的滚动逻辑。
还有一个容易漏掉的点:Time.realtimeSinceStartup。这是一个从应用启动到现在的时间,不会因为timeScale改变,也不受暂停影响,最适合做计时、超时检测、外部数据请求的超时控制。但要注意,realtimeSinceStartup在后台切回时有时会有跳变,需要容忍一定误差。
补充一个数值细节:很多人喜欢在FixedUpdate里写transform.position += speed * Time.deltaTime,这其实是不严谨的。在FixedUpdate里应该用Time.fixedDeltaTime,因为固定步长下deltaTime可能等于当前实际帧间隔,但也可能因为帧率变化而不同,统一用fixedDeltaTime才代表物理步长。这个坑在我做物理跑步游戏时踩过,角色速度忽快忽慢,排查半天才发现是DeltaTime用混了。
2. 坐标系与Transform插值:空间思维的入门与进阶
Unity的坐标系是左手坐标系,X向右、Y向上、Z向前。这句话背起来容易,用起来很多人还是会被世界坐标、局部坐标、屏幕坐标绕晕,尤其是做UI、做手势交互、做相机射线检测时,转换一多就开始糊涂。
2.1 世界坐标、局部坐标与屏幕坐标的三层转换
世界坐标是场景空间中的绝对位置,所有物体在同一个“大空间”里。局部坐标是相对于父物体Pivot点的位置,一个Cube放在空物体下,它的局部坐标就是相对空物体的偏移。屏幕坐标则是以像素为单位的坐标,从屏幕左下角(0,0)到右上角(Screen.width, Screen.height)。
常见需求是:点击屏幕位置,要在3D世界里放一个标记。那么大致流程是:先从Input.GetMousePosition拿到屏幕像素坐标,然后用Camera.ScreenPointToRay生成射线,再用Physics.Raycast检测交点,如果打到地面,就能拿到世界坐标。反过来,如果要把3D物体的头顶位置显示在UI上,需要先用Camera.WorldToScreenPoint把世界坐标转成屏幕坐标,再把它转换成UI的Canvas局部坐标。
最容易出错的是Canvas的渲染模式。如果Canvas是Screen Space - Overlay,UI坐标直接和屏幕坐标等同,只需要除以Canvas.scaleFactor(如果勾选缩放)就能得到UI世界位置。如果是Screen Space - Camera,需要把Canvas作为UI相机下的一个平面,转换时先WorldToScreenPoint,再ScreenPointToWorldPointInRectangle。我踩过一次坑:项目里用Screen Space - Camera,我直接用WorldToScreenPoint转出来的坐标去设置UI位置,结果UI跑到了屏幕外面,后来才意识到还需要考虑Canvas的RectTransform尺寸和缩放因子。
另一个高频坑是TransformDirection和TransformPoint的区别。TransformDirection只做方向旋转,不包含位置变换;TransformPoint则会带上物体的位置和旋转,用于把局部坐标点转成世界坐标点。比如子弹从枪口发射,枪口在世界坐标系的位置应该用transform.TransformPoint(localOffset),如果你用了TransformDirection,得到的只是一个方向向量,子弹发射点就错了。
2.2 Quaternion和Euler角:为什么旋转总是转错
Unity里Transform的Rotation显示的是欧拉角(X、Y、Z),但内部存储的是四元数Quaternion。之所以用四元数,是因为欧拉角有万向节锁问题,当某轴旋转到90度时,另外两轴的旋转会变得耦合,导致旋转丢失维度。新手最常犯的错误是直接修改transform.eulerAngles的某个分量,比如eulerAngles.y += 1,在大多数情况下看起来没问题,但一旦物体旋转接近90度,你会发现物体的朝向莫名其妙“翻转”了。
正确做法是用四元数的乘法来叠加旋转。比如让物体绕自身Y轴每秒转30度,应写transform.rotation *= Quaternion.Euler(0, 30 * Time.deltaTime, 0),或者用transform.Rotate(0, 30 * Time.deltaTime, 0)。绕世界轴则用Quaternion.Euler和当前Rotation左乘,比如transform.rotation = Quaternion.Euler(0, 30 * Time.deltaTime, 0) * transform.rotation。
还有做朝向时,很多人喜欢直接用LookAt。LookAt默认会让物体的Z轴指向目标,如果你的模型正面不是Z轴方向(很多角色模型是从3DMax导出,正面其实是-Y方向),就会出现“脸朝向侧面”的情况。解决方法是把模型放进一个空物体里,调整模型相对空物体的旋转,让正面对齐Z轴,然后用空物体LookAt目标。
插值旋转时优先用Quaternion.Slerp,不要自己写欧拉角插值。Slerp是球面线性插值,能保证旋转过程均匀且无抖动。Quaternion.RotateTowards也很实用,它可以按最大角度步长旋转,适合做角色转向,不会像Slerp那样有点“先快后慢”的缓动感,让转向更干脆。
3. 生命周期回调与初始化顺序:程序崩溃的第一来源
很多运行时的空引用错误,都和生命周期回调的执行顺序有关。脚本之间互相引用,或者在Awake里访问其他尚未Awake的对象,都可能出现“预期内但不可控”的崩溃。理解Unity的初始化顺序,是稳定性的第一道防线。
3.1 Awake、OnEnable、Start的执行细节
场景加载时,所有激活的GameObject都会按顺序执行Awake。注意,Unity的脚本执行顺序有默认优先级,但并不是严格按Hierarchy顺序,而是按脚本的Execution Order设置,如果没设置,就按加载顺序,这个顺序往往不可靠。所以不要在Awake里依赖其他脚本已执行完Awake的前提。
Awake的作用是“无论脚本是否启用,只要物体激活就执行一次”。OnEnable则是在脚本从禁用变为启用时执行,也包括首次激活。Start在第一次Update之前执行,如果脚本被禁用,Start不会执行,直到启用后才执行。
一个比较经典的问题:如果一个对象从SetActive(false)开始,那么它上面的Awake也会延迟到被激活时才执行。我之前做过一个养成游戏,一开始把所有UI面板设为false,结果某个面板在Awake里初始化了数据,等到打开面板时数据还没填,界面就空了一块。后来我把数据初始化移到Start里,或者改用手动Initialize方法,才稳定解决。
另一个经常忽略的点:OnDestroy和OnDisable的执行时机。在场景切换时,旧场景中所有激活的物体都会先OnDisable,再OnDestroy,顺序和创建时相反。如果你在OnDisable里释放资源,而其他对象仍在同一帧引用这些资源,就会产生悬垂引用。更安全的做法是,逻辑解耦依赖事件或直接调用统一清理方法,而不是完全依赖回调顺序。
3.2 对象池与SetActive对生命周期的影响
做对象池时,我们通常把不用的对象SetActive(false),用到时再SetActive(true)。这里有个隐藏的坑:SetActive(true)会触发OnEnable,而SetActive(false)会触发OnDisable。如果对象池里的对象在OnEnable里做初始化、在OnDisable里做清理,没有问题。但如果你在OnEnable里请求资源、注册事件,OnDisable里忘记反注册,就会造成事件泄漏和重复注册。
我推荐的对象池写法是:在类里维护一个IsFromPool标记,或直接用显式的Spawn和Despawn方法,而不是依赖OnEnable和OnDisable来做业务初始化。把“从池里拿出来的预热”和“放回去的复位”分开,写在Spawn/Despawn里,这样就算对象在同帧内被多次SetActive,也不会重复执行逻辑。
还有一个Start和OnEnable的执行顺序坑:如果对象在场景中激活,脚本先用OnEnable,再用Start。但如果你在OnEnable里执行了某个操作,然后Start里又执行了另一个操作,顺序是确定的。可当你用Instantiate动态创建对象时,如果实例的初始状态是激活的,OnEnable会立即执行,而Start要等到下一帧才执行(如果创建时在帧中)。如果把初始化代码放在Start里,而另一段代码在Instantiate之后立刻访问了该对象的某个字段,就会拿到默认值。所以凡是实例化后需要立即生效的数据,放在Awake或OnEnable里,而不是Start里。
4. 协程和线程:异步逻辑的正确打开方式
Unity的C#本身支持多线程,但游戏主线程(渲染线程和逻辑线程)并不安全,大多数人操作Transform、UI、物理都必须在主线程。协程是Unity官方推荐的“伪异步”方案,它不创建新线程,只是在主线程中分段执行。理解协程的本质,才能避免写出又慢又卡的异步逻辑。
4.1 协程的本质与IEnumerator的运作机制
协程本质是一个IEnumerator迭代器。你写的yield return语句,会在当前帧暂停方法,并在下一帧(或指定时机)从暂停处继续。它不是独立线程,所有协程都运行在主线程,所以不会产生线程安全问题。但它的执行频率受帧率限制,如果你在协程里做耗时较多的循环,依然会卡住主线程。
比如yield return null表示等待下一帧继续,yield return new WaitForSeconds(1f)表示等待1秒(根据缩放时间),yield return new WaitForFixedUpdate()表示等待下一个物理步,yield return new WaitUntil(() => condition)表示等待条件为真。这些都是常用模式,但值得注意WaitForSeconds的精度问题:它依赖Time.time,受timeScale影响,而且如果协程被暂停(timeScale设为0),计时也会暂停。
另外,协程的启动方式有两种:StartCoroutine(string methodName)和StartCoroutine(IEnumerator routine)。使用字符串版本会通过反射查找方法,性能更差且无法传参,而且如果你修改了方法名就会运行时报错。建议始终使用IEnumerator版本。
4.2 yield return的常用模式与内存注意点
协程里的内存分配经常被忽略。yield return new WaitForSeconds(1f)每次都会在堆上分配一个WaitForSeconds对象。如果你在游戏过程中频繁启动短协程(比如每次攻击都启动一个0.5秒的等待),会产生大量短命对象,增加GC压力。较新的Unity版本(2018.3及以上)提供了WaitForSecondsCache或WaitForSecondsRealtime,或者你可以自己缓存WaitForSeconds实例。
比如在一个射击游戏里,每发子弹都协程去等待0.3秒后检查命中,如果同时有20发子弹在飞,就会创建20个WaitForSeconds对象。优化做法是在类外边声明一个静态的WaitForSeconds对象,所有协程共用:private static readonly WaitForSeconds FIRE_WAIT = new WaitForSeconds(0.3f);,然后yield return FIRE_WAIT;。这样整个生命周期只分配一次。
协程还有一个容易被忽略的停止问题:StopAllCoroutines()会停止该脚本上所有协程,但不会立即销毁协程内已经创建的IEnumerator对象,如果这个对象引用了外部大数组,可能会延迟释放。更稳妥的做法是,把协程持有的IEnumerator引用保存在字段中,在停止时置空。
我还遇到过协程和场景切换的坑:协程不会因为物体被禁用而自动停止,它会继续执行到下一个yield,甚至可能在物体销毁后依然运行部分代码(如果引用已销毁对象,可能抛异常)。所以在场景切换前,最好手动停止所有协程,或者把协程放在一个常驻的Manager对象上,而不放在被销毁的角色对象上。
对于复杂的异步逻辑,比如加载远程资源、网络请求,我建议结合C#的async/await和UniTask这类第三方库。Unity官方也有UnityWebRequest.SendWebRequest()的异步方案,但要注意一定要在主线程里等待结果,因为UnityWebRequest的result回调会在主线程执行,使用async/await时,同步上下文如果没处理好,可能不会回到主线程,导致后续代码线程错误。我在做一个在线排行榜时,用过回调嵌套,后来换成UniTask,舒服很多。
5. 资源加载与依赖管理:AssetBundle与Addressables的实战选择
资源管理是项目从Demo走向正式产品的分水岭。在编辑器下直接用Resources.Load很省事,但发布之后,包体、加载速度、内存占用都靠资源系统扛。AssetBundle和Addressables是两条主流路线,Addressables其实是构建在AssetBundle之上的更高层管理框架,它们的选择直接决定项目的可扩展性。
5.1 AssetBundle的分包策略与依赖加载
AssetBundle最基本的分包逻辑是把共享资源单独打一个包,把场景独有资源单独打一个包,把UI资源单独打一个包。如果两个AB包都引用了一个公共材质,而这个材质没有被单独打进公共包,加载其中一个包时会自动拉取依赖,但如果依赖包没有加载,就会产生材质显示粉红色的情况。
我自己踩过的典型坑:场景A和场景B都用了同一个纹理,我图省事把纹理一起打进了A包和B包,结果内存里出现了两份纹理,白白多占了几十MB。后来我改用依赖分包:把公共纹理打进一个名为common的AB包,A和B包只引用它而不包含它,加载场景A前先加载common,再加载A包,这样内存里只有一份纹理。
AB包加载还有一个重灾区:同一个AB包被多次LoadFromFile。Unity会缓存AssetBundle引用,但不会自动引用计数,如果你手动重复加载,每次都会消耗内存。正确做法是维护一个字典,记录BundleName到AssetBundle引用的映射,加载时先查字典,已存在就直接用;卸载时也要注意,AB包内部依赖的计数要把每个包的引用数都递减到0,才能真正卸载。
5.2 Addressables的远程加载与本地缓存
Addressables把资源地址、打包、加载、引用计数、远程加载和本地缓存都封装好了,上手门槛比AssetBundle低很多,而且支持自动整理依赖关系,不用手动管理Bundle依赖。它的核心概念是Addressable Asset:在Inspector里把资源标记为Addressable,然后代码用Addressables.LoadAssetAsync<T>(address)来加载。
Addressables的远程加载时,会自动把远程Bundle下载到本地缓存目录,默认缓存机制比较智能:只要没有更新版本,就不会重复下载。但我遇到的一个问题:开发阶段频繁改资源,地址没变但版本变了,真机总是加载到旧缓存。后来我意识到,要主动调用Addressables.CheckForCatalogUpdates()和UpdateCatalogs(),或者把远程profile里的Loading Offset设大一些。更重要的是,要理解Bundle的版本控制依赖Catalog更新,而不是依赖资源文件本身的hash。
使用Addressables要注意引用计数。LoadAssetAsync会递增引用计数,如果我们加载了一个资源但在结束时没有调用Addressables.Release(handle),资源永远不会被卸载,内存会慢慢涨。我见过某项目把Addressables加载的角色面板资源一直保留,切换场景时也不释放,跑久了内存翻倍。所以一个好的实践是,在UI面板关闭的地方统一释放所有Addressable handle。
Addressables也适合做工程化探索:通过Label批量加载一组资源,通过AddressableAssetSettings调整并发下载数量、压缩格式。比如我可以给UI图标打一个Label,然后一次性加载所有UI图标,比逐个加载快很多。下载进度可以用AsyncOperationHandle.GetDownloadStatus()获取,方便做动态加载条。
聊到底,我个人的建议是:如果你从零开始一个中大型项目,直接用Addressables,别自己折腾AssetBundle的依赖图。AssetBundle适合那些团队已有完整插件和经验的场景,或者目标平台对Addressables支持不全的极特殊情况。Addressables的坑确实也有,但官方维护力度大,生态成熟,省下的心智和时间远超手动封装Manager。
6. 移动端性能与GC压榨:从Profiler到真机调优
Unity开发最终都要上真机,真机性能和编辑器性能完全是两回事。刚开始做性能优化时,我总盯着帧率看,后来才发现帧率是结果不是原因,真正需要看的是CPU耗时、GPU耗时、内存占用、GC分配。频繁GC会导致卡顿,尤其在安卓设备上,一次Full GC常常让游戏抖个100ms。
6.1 从Profiler窗口看懂瓶颈
Unity自带的Profiler是查看瓶颈的第一入口。打开Window > Analysis > Profiler,选择CPU Usage模式,可以看到整帧中每个函数的耗时堆栈。如果看到某个函数占了很大比例,通常是两种原因:一种是算法本身复杂度高,另一种是它作为“漏水点”被频繁调用,比如每帧都调用Resources.Load或FindObjectOfType。
FindObjectOfType是一个性能杀手,它会在整个场景的所有物体上遍历,查找匹配类型。如果在Update里每帧调用,会严重拖慢帧率。常见优化是用静态引用缓存对象,或者在初始化时赋值,而不是运行时查找。我的一个项目里,角色每次播放动画时都调用GetComponent<Animator>(),导致每帧几百次GetComponent,后来改成缓存引用,耗时下降了60%。
GPU瓶颈在Profiler里看GPU Usage和Rendering,常见原因是绘制调用过多、Shader过于复杂、分辨率过高。移动端的Overdraw尤其可怕,一个像素被绘制多次,就会白白消耗带宽。可以用Frame Debugger查看每个Draw Call的渲染对象,或者用Alt+Vision的Overdraw视图查看半透明层叠情况。
6.2 降低GC Alloc的常见手法
GC Alloc是移动端卡顿的重要源头。每次产生托管堆分配,GC都需要回收,如果分配频繁且总量大,就会触发Full GC。常见优化手段包括:避免在每帧创建字符串、列表、数组、临时对象;使用对象池;使用C#的StringBuilder;使用ArrayPool<T>。
运行时的字符串拼接是重灾区,比如"Score: " + score,每次拼接都会产生新字符串。场景中有大量文本UI时,哪怕只是每个血条数字变化,也会产生大量GC。优化时用StringBuilder,或者用ToString("0")缓存结果,或者直接把数字转成Sprite显示。
还有一点容易被忽略:LINQ操作会分配堆内存。list.Where(x => x.a > 0)这行代码会产生迭代器和委托对象,如果放在Update里,每帧都会分配。尽量改用普通for循环,或者使用Unity 2020+提供的HashSet、Dictionary等不分配迭代器的原生循环。如果你必须用泛型集合,注意用for而不是foreach,因为foreach在某些Unity版本中会产生垃圾(虽然新的IL2CPP已经优化很多,但在Mono后端仍可能产生)。
协程的WaitForSeconds分配、事件调用中的闭包分配、字符串格式化分配,都是常见GC来源。只要在Profiler里看到GC Alloc一栏有持续增长,就说明有分配没被完全回收。
我一般会在真机上定期查看Profiler的Memory分类,特别关注Managed Heap大小。如果Managed Heap持续上升不回落,很可能有对象泄漏(比如事件持有对象引用)。解决方式是使用弱引用WeakReference或事件统一反注册。
还有一个容易被忽略的点:静态集合会持有对象引用,类似静态Dictionary里的英雄数据列表,如果不主动清空,就算场景销毁对象也不被回收。我有一次排查内存泄漏,发现某个全局配置表持有了所有实例化怪物的引用,怪物死亡后资源没释放,内存直线上升。
补充一个真机调试技巧:安卓下直接用Unity的Profiler连接真机,在Build Settings勾选Development Build和Autoconnect Profiler,然后投屏查看实时数据。如果不方便投屏,可以开启SDK的GPU判断。
最后说几句
这些都是我在实际项目里反复验证过的东西。有几条经验强烈建议你当成默认习惯:生命周期里别依赖执行顺序,能缓存引用就别FindObject;旋转操作多用Quaternion,别硬改欧拉角;资源加载统一走Addressables,并且一定要写Release;每帧都调用的函数要优先考虑GC分配;真机Profiler才是最终标准,别只盯着编辑器。
你说Unity3d的常用知识复杂吗?其实每个点单独拎出来都不难,难的是在一套代码里同时处理好它们之间的相互作用。多留几个心眼,把这些基础问题在源头扼杀掉,开发效率会高很多。