1. 先弄懂游戏循环Unity的帧到底是怎么转起来的1.1 游戏循环的由来为什么Unity不按顺序把代码跑完很多人在学Unity之前写过控制台程序或者Web程序脑子里形成的固有印象是代码从入口函数开始一行一行往下执行执行完就结束。但游戏完全不是这样。游戏程序从启动那一刻起就处于一个永不退出的循环里——每秒钟这个循环要跑几十上百遍每一遍就是一帧。这个循环就是游戏循环Game Loop)。Unity的游戏循环是引擎内置的不需要你写也不需要你管但是你必须理解它。因为你的所有脚本逻辑都是被这个循环切碎之后按帧调用的。你不是在写一个顺序流程而是在写一堆挂在不同触发点上的回调函数。用一个生活化的比喻来理解传统程序像是做一道菜备菜、下锅、装盘做完就结束游戏程序更像是开餐厅从开门到打烊一直在重复接客、点菜、上菜、收桌这套流程每一桌客人就是一帧。Unity引擎不会因为你没写循环代码就停下来它自己会一直转你的脚本只是被它当成某个环节的临时工在调用。理解了这个前提再回头看那些所谓的运行原理问题很多疑惑都会解开。比如为什么两个脚本之间传递数据有时候会拿到空值为什么同一个物体改了位置画面却不跟着变为什么物理和渲染经常对不上——本质上都是因为你还没在脑中建立每一帧做了什么、以什么顺序做的模型。1.2 Unity一帧里的完整流程从输入到渲染Unity官方文档会把一帧拆成几个主要阶段在Profiler里能看得非常清楚。很多新手打开Profiler被满屏的模块名吓住其实只要抓住主干就行。一帧大致按这个顺序走EarlyUpdate处理输入事件Input、计时器、本地通知等把上一帧遗留的底层数据先整理好。FixedUpdate阶段物理引擎的固定步长模拟所有挂了FixedUpdate回调的脚本在这里被调用。Update阶段调用所有MonoBehaviour的Update回调大部分游戏逻辑都在这。LateUpdate阶段所有Update之后再执行的逻辑适合做摄像机跟随、状态修正。渲染阶段引擎把场景里的物体做剔除、排序然后向GPU提交绘制指令。帧结束处理一些清理、回调、异步任务的结果回收。这里有一个非常关键的认知点Update并不像很多新手以为的那样每帧就执行一次这么简单它在整帧流程里有明确的位置。比如你在Update里写读取鼠标输入的逻辑这份输入数据其实是上一帧末就收集好的你在Update里改了一个物体的Transform这个改动到渲染阶段才真正生效。所以我一直跟新人强调不要只在Update里写代码要试着把自己代入Unity的调度节奏里。还要注意一个容易忽略的细节场景里所有启用状态的MonoBehaviour脚本它们在Update阶段的调用顺序在默认情况下是不确定性的。虽然Unity提供了Script Execution Order面板可以手动调整但你不设置的话两个脚本谁先执行完全取决于Unity内部的加载顺序。这就是很多偶发的Bug的源头。1.3 帧率陷阱Update、FixedUpdate与Time.deltaTimeUpdate每帧执行但每帧的间隔时间不固定。60帧的时候一帧16.67ms跑到120帧的时候一帧8.33ms卡一下可能变成100ms。所以如果你在Update里写每帧移动一个固定距离那么帧率高的设备上物体会动得快帧率低的设备上动得慢。这就是为什么所有移动逻辑都必须乘以Time.deltaTime。Time.deltaTime拿到的就是上一帧到当前帧所经过的时间以秒为单位。你用移动速度 × Time.deltaTime算出来的位移实际效果是每秒移动固定距离而不是每帧移动固定距离。这两者的差别在帧率不稳的手机上尤其明显。同一段代码在iPhone高刷屏和低端安卓机上跑出来的移动速度只有乘了deltaTime才能保持一致。而FixedUpdate使用固定步长默认0.02秒即每秒50次调用它不受帧率波动影响。物理模拟必须放在FixedUpdate里原因就在于物理引擎需要稳定的时间步长来做积分计算。如果你把刚体的施力、速度修改放在Update里帧率一变物理模拟的时间间隔全乱套物体运动就会变得忽快忽慢。注意FixedUpdate虽然每秒固定调用50次但如果游戏帧率低于50帧Unity会在同一帧内多次调用FixedUpdate来追补物理步长。所以不要在FixedUpdate里依赖帧计数或者一次性逻辑比如只执行一次的操作否则会出现莫名其妙的重复执行。我实操中就踩过一个典型的坑在FixedUpdate里写了一个按下空格就跳的判断结果低帧率下玩家按一次空格角色跳了两次。原因就是物理步长被追补时FixedUpdate在那一帧里被调用了两次而输入状态还没来得及刷新所以两次都判定为按下。这种问题非常隐蔽排查的时候只能看到偶发双跳很难直接定位。2. MonoBehaviour生命周期脚本的执行顺序藏着设计意图2.1 完整的生命周期调用顺序每个挂在GameObject上的MonoBehaviour脚本从存活到销毁会依次经历下面这些关键节点。我建议所有打算深入Unity的人都把这个顺序背下来它比任何API文档都重要Awake脚本实例被创建时立刻调用且只调用一次。OnEnable每次脚本被启用时调用可能多次。Start在第一次Update之前调用且只调用一次。FixedUpdate / Update / LateUpdate循环执行。OnDisable脚本被禁用时调用。OnDestroy脚本实例被销毁时调用。这个顺序不是随手排的每一环都有考量Awake在对象创建后立刻触发可以保证所有组件都已经被创建好而且它在场景加载过程中执行适合做内部引用绑定OnEnable会在对象每次激活时触发适合注册事件和订阅消息Start在第一次帧更新前调用保证场景里所有对象都已经Awake完毕适合做跨对象初始化。2.2 Awake、Start、OnEnable到底有什么区别这三个函数的区别是我带新人时被问得最多的。很多人只知道Awake先执行Start后执行但实际用起来还是一头雾水。这里我给出一个更准确的判断标准Awake相当于出生Start相当于上台前。Awake在这个脚本所属的GameObject被实例化时执行即使物体在场景中被禁用SetActive(false)只要脚本本身是启用的Awake依然会执行。Start会在场景中所有对象的Awake都执行完、即将进行第一次Update前才执行。OnEnable的时机比很多人想象的更微妙如果脚本一开始就是启用的OnEnable会在Awake之后立即被调用如果脚本一开始是禁用的那么OnEnable会等到你SetActive(true)的那一刻才被调用。所以初始化逻辑放哪里是一门学问。举个实际场景你要做一个怪物管理器它要监听场景中所有怪物的死亡事件。如果这个管理器对象在场景初始时是隐藏的SetActive(false)你在Awake里订阅了事件但OnEnable不会立刻执行等到你把它激活时OnEnable才执行。如果你把订阅逻辑写在Start里可能已经错过了早期生成的事件。这种为什么事件没收到的问题根源往往就是生命周期函数选错了。2.3 基于生命周期的初始化设计模式我自己的项目中初始化逻辑有一套固定的分配规则你可以直接参考用Awake做组件引用获取GetComponent因为此时对象所有组件都已就位而且Awake调用时机最早缓存下来供后面使用。用Start做涉及其他对象的数据准备比如读取配置、计算初始属性、向管理器注册自己。用OnEnable订阅事件、OnDisable取消订阅这样对象反复启用/禁用都不会出现回调堆积。还有一个非常重要的提醒不要在Awake里访问场景中其他GameObject的Start初始化结果。因为Awake阶段场景里所有对象的Awake都还没执行完但Start阶段所有对象的Awake已经全部完成。很多间歇性Bug比如有时候能拿到引用、有时候为空往往就是因为把跨对象的初始化放到了Awake里。我记得有一次排查一个任务系统的Bug任务UI在自己Awake里读取玩家背包里的道具数量结果每次进新场景UI上显示的数量总是上一次的值。后来发现原因就是背包管理器的Awake还没跑任务UI的Awake就先跑了读数的时候背包数据根本没初始化。把读取逻辑挪到Start之后问题立刻消失。这种问题在编辑器里很难稳定复现因为你手动点Play的时候加载顺序可能跟实际场景加载时的顺序不同。3. GameObject与组件场景运行时的心脏3.1 Transform层级在运行时如何驱动场景里每一个可见或不可见的物体都是GameObjectGameObject本身是个容器它的行为都来自挂在上面的组件Component。Transform是几乎所有物体都有的组件它负责位置、旋转、缩放并且维护父子关系。在运行时Unity会按照先父后子的顺序更新Transform。父物体动了子物体的世界坐标也要跟着变。所以当你修改父物体的position子物体的position局部坐标虽然不变但它的世界坐标已经跟着变了。这就是为什么在代码里把子物体的position直接改成世界坐标有时候会出现飞出去的效果。实操中一个很常见的坑你在Update里设置子物体的localPosition以为是相对父物体的偏移但如果父物体在之前的帧里被旋转过、缩放过来localPosition的计算结果会完全超出你的预期。每次修改Transform之前先想清楚你要改的是局部坐标还是世界坐标尽量统一操作维度。我见过一个UI血条跟随角色的Demo作者把血条挂到角色子节点然后在代码里设置position结果角色一转身血条就飘到屏幕外。其实就是因为他用的是世界坐标position而UI节点挂在角色下每次赋值都被父节点Transform再次变换。3.2 GetComponent的查找机制为什么必须缓存GetComponent是Unity里最常见的调用之一但很多人不知道它的代价。在几万个物体的大场景里频繁调用GetComponent会引起GC分配和查找开销。实际经验是在Awake阶段把需要引用的组件缓存到私有字段里之后直接使用缓存。还要注意GetComponent只查找当前GameObject它不查找子物体也不查找父物体。查找子物体要用GetComponentInChildren查找父物体要用GetComponentInParent这两个方法默认会递归遍历整个层级所以开销更大更要注意缓存。有些人的代码里Update里写GetComponent每帧查一次。单独看没什么感觉但场景里如果有100个怪物每个怪物每帧都查一次一帧就是100次查找调用一分钟就是6000次一小时就是36万次。这些开销完全可以用初始化时的一次缓存省下来。对移动端的性能来说这种无谓的调用累计起来是致命的。提示如果你确实需要在运行时频繁查找组件优先考虑把引用从外部注入Inspector拖引用其次是Awake缓存最后才是运行时GetComponent。3.3 Instantiate与Destroy创建销毁背后的原理用过Instantiate的都知道它能复制一个对象出来但背后的运行原理值得理清Instantiate会执行一次对象的完整序列化拷贝生成一个新的GameObject实例然后把上面所有组件的Awake、OnEnable在合适的时机触发。新对象的Awake会在当前帧内、Instantiate返回之前执行但Start要等到下一帧的Update之前才执行。这就导致一个有趣的现象Instantiate返回后你可以立刻修改这个新对象的Transform、读写它的组件数据它都是安全的但你在这个时刻访问它的Start要设定的变量比如从配置表读取的初始属性拿到的是默认值。所以如果你在Start里根据某些外部数据初始化对象状态而不是在Awake里赋值Instantiate后立刻使用这个对象的业务数据很可能是错的。Destroy比较特殊它不会立刻销毁对象Unity会在当前帧更新结束后才真正执行销毁。所以你在Destroy之后立刻访问对象仍然能访问到但如果在下一次物理更新或者LateUpdate里访问可能就会报Object has been destroyed错误。这就是为什么很多教程里会用DestroyImmediate但在运行时用DestroyImmediate是不推荐的它不会延迟销毁容易造成引用悬挂。实际开发里我见过一个很典型的Bug玩家发射子弹子弹命中了敌人敌人被Destroy但子弹脚本OnTriggerEnter里在Destroy之后继续访问enemy.transform.position结果下一帧物理回调又触发了一次此时敌人其实已经在销毁队列里访问其Transform时抛出了MissingReferenceException。这类问题排查起来非常浪费时间最好的防御方式就是在Destroy之后不要持有任何对该对象的引用凡是可能被异步回调触发的逻辑访问对象前先判断是否为空。4. 一帧内隐藏的工作渲染与物理的调度逻辑4.1 渲染管线怎么跟游戏循环接上渲染是每一帧的重头戏。在Unity里当你写了一个带MeshRenderer组件的物体Unity的渲染管线会在每帧的渲染阶段根据相机的位置、朝向、视锥体判断这个物体是否在可视范围内如果可见就把它往GPU提交绘制指令。这个过程叫剔除Culling。Unity还会尝试把多个材质相同、Mesh相同的物体合并成一次绘制调用这个过程叫批处理Batching。这里要理解一个重点CPU端的脚本逻辑和GPU端的渲染是不同步的。你在一帧的Update里修改Transform当前帧渲染时Unity会重新读取Transform数据然后把绘制指令提交给GPUGPU的渲染是异步的渲染结果要到下一帧才显示。所以当你在编辑器里拖动物体你会发现Game视图的显示比场景视图略慢半拍这就是同步差异造成的。顺带提一句批处理为什么重要GPU每一次绘制调用Draw Call都有固定的开销绘制100个Cube需要100次Draw Call但如果它们材质相同、网格相同可以合并成1次Draw Call。很多项目一卡顿第一件事就是查批处理有没有被破坏。什么会破坏批处理比如物体被缩放成不同大小动态批处理有要求比如物体使用了不同的材质实例比如UV偏移不一致。理解了渲染管线在帧内的调度位置你再回头做优化时会更有方向感。4.2 物理引擎的固定步长为什么物理逻辑必须放FixedUpdate物理系统是另一个独立运行的模块。Unity内置的PhysX物理引擎按照固定时间步长进行模拟每一小步都要处理碰撞检测、刚体受力、关节约束等。默认值是0.02秒也就是每秒50次模拟。如果你的游戏帧率稳定在60帧那么物理模拟并不是每帧都执行一次而是每三帧才执行一次3×0.0167≈0.05对应约2.5个步长具体峰值分布不均匀。这带来一个经典问题当你的游戏卡顿、帧率降到20帧FixedUpdate的调用次数反而会变多因为物理引擎要按固定时间间隔追补模拟。在低帧率状态下一帧里可能连续调用多次FixedUpdate来追平物理时间。这也是为什么物理相关逻辑不能依赖帧率的原因也是之前提到按下跳跃却跳了两次这种Bug的根源。我在做物理相关功能时的习惯是所有涉及刚体加力、速度设置、碰撞检测后处理的逻辑一律放FixedUpdate跟物理无关的纯表现逻辑、输入收集放Update需要读取物理结果来驱动表现比如根据角色速度调整动画速度的逻辑放到LateUpdate里在物理之后读取。4.3 LateUpdate存在的理由为什么摄像机跟随要放这里LateUpdate是在所有Update之后调用的它存在的意义就是给跟随类逻辑一个稳定的时间点。比如摄像机跟随角色如果摄像机在Update里读取角色的位置而角色的移动也在Update里两者的执行顺序是没有保证的可能角色先动、摄像机后读那么一帧画面里摄像机的位置总是滞后一帧。放到LateUpdate里此时所有角色的Update都执行完了摄像机读到的就是这一帧的最终位置。这个设计非常重要尤其是做FPS游戏或者竞技游戏的时候跟手感的差异往往就在这一帧的延迟上。你用LateUpdate做摄像机跟随跟手程度会比Update好很多因为摄像机拿到的是当前帧角色已经更新完的最终位置而不是上一帧残留的旧值。我把这个逻辑总结成一句话LateUpdate是观察者视角适合读取其他对象Update之后的结果Update是行为者视角适合改变自身状态。所有跟手摄像、位置修正、UI对齐跟随都应该放LateUpdate。5. 主线程限制与协程Unity并发模型的核心原理5.1 为什么必须在主线程操作Unity APIUnity引擎的核心模块包括场景管理、组件系统、渲染指令提交都不是线程安全的。所有Unity对象的访问和修改都必须发生在主线程。原因很简单引擎内部的数据结构没有加锁如果多个线程同时改Transform、同时访问GameObject内存就会出现不可预知的状态。所以你在后台线程里哪怕只是读取transform.position也会在运行时抛出异常或者返回错误数据。正确做法是后台线程只做纯计算比如寻路计算、序列化、网络包解析把结果存到队列里主线程在Update或协程里取出队列结果再去操作Unity对象。我见过很多新手在项目里用C#的Task写网络请求然后在回调里直接修改UI文本结果就是Unity报get_transform can only be called from the main thread。这个问题其实不难解决但需要用对模式后台线程计算结果主线程取结果并更新。5.2 协程它不是线程是迭代器协程Coroutine是Unity里常用的异步方案但它的底层实现和多线程没有任何关系。协程的本质是一个C#迭代器IEnumeratorUnity在每次迭代到yield语句时会把当前方法挂起然后在合适的时机恢复执行。举一个实际例子StartCoroutine(MyCoroutine()); IEnumerator MyCoroutine() { Debug.Log(开始); yield return new WaitForSeconds(2f); Debug.Log(两秒后); }这段代码里yield return new WaitForSeconds(2f)并不是真的让线程等待两秒而是协程告诉Unity我要暂停请在两秒后的帧来恢复我。所以协程里的代码始终跑在主线程上它的挂起不影响其他代码执行也不会占用额外线程。实操心得协程非常适合做延时、动画队列、UI自动滚轮这类逻辑但要注意协程对对象销毁特别敏感。如果挂在某个GameObject上的协程还没跑完物体被销毁了协程会直接中止不会再恢复。还有一种常见错误是在协程里等待一个永远等不到的条件比如等待一个被禁用的异步任务协程会永远挂在那个yield上白白浪费内存。5.3 异步加载与真实异步Unity到底异步了什么Unity后来的异步接口Addressables、SceneManager.LoadSceneAsync、UnityWebRequest很多是基于真实的多线程异步实现的它们允许后台线程做资源读取、解压、加载但最终对Unity对象做修改、实例化到场景里时仍然是在主线程上完成的。所以这类接口返回后你在回调里直接访问场景是安全的但你在非主线程里手动调用这些接口就是错误的。用一句话记忆Unity的世界里主线程是唯一能碰场景数据的人后台线程是它的助理只能帮忙准备素材不能上手操作。理解了这一点你在写网络同步、资源加载、寻路这类多线程需求时就不会试图绕过主线程而是会主动用队列、事件、协程这些安全的方式把结果交还给主线程。实际项目里我比较推荐的异步资源加载写法是用UnityWebRequest在后台下载资源字节流这一步是后台线程下载完成后回到主线程把字节流交给AssetBundle.LoadFromMemory然后再用Instantiate去创建对象。每一步都清楚当前代码跑在哪个线程就不会出幺蛾子。写到这里Unity运行原理的地基已经铺完了。我在带新人入行的时候最喜欢说的一句话是先别急着学怎么做背包、怎么做角色控制先把游戏循环生命周期主线程这三根柱子立起来后面所有的框架和优化都是在这些地基上搭房子。这篇作为开篇没有给你一段写死的代码但如果你认真理解了游戏循环和生命周期再看引擎里的每一个回调函数都不会再觉得它们是魔法。下一篇开始我会从Component和脚本的交互入手结合具体的玩法Demo讲清楚Unity里的代码到底是怎么挂到场景里跑起来的。如果你在阅读过程中有任何疑问尤其是对帧率、生命周期、协程这几块想深入聊的欢迎在评论区提出我挑典型的来展开。