很多刚入坑Unity的朋友都有过这种体验:跟着教程敲Demo,方法看着都眼熟,可一到自己写玩法逻辑就卡壳——到底是该用GetComponent还是FindObjectOfType,位置修改用transform.position还是transform.Translate,物理移动放Update还是FixedUpdate。这篇内容算是我自己在Unity学习阶段整理的常用API笔记,覆盖面包括生命周期、对象组件、输入时间、物理碰撞、向量数学、协程、资源加载这几个高频使用方向,每一个都配合实际场景说明。适合刚完成引擎基础操作、正准备独立写行为脚本的开发者,也适合想系统梳理API脉络的人。
1. 先搭框架:为什么学API不能靠背
Unity的API体量非常庞大,类加上接口有上千个,要全背下来既不现实也没必要。大部分游戏脚本反复使用的API其实集中在一个很小的范围内。我踩过的最大弯路就是买回来一本手册从A开始翻,翻到一半基本崩溃。
1.1 用“一帧的旅程”把API串起来
我的建议是换个思路,不按字母排序学,而是在一帧里看Unity到底做了什么事:
- 先读取输入设备的状态,键盘、鼠标、触摸屏;
- 再推进时间,记录这一帧的间隔
deltaTime; - 然后做物理模拟,刚体移动、碰撞检测;
- 接着执行游戏逻辑脚本里的
Update; - 最后是渲染和UI刷新。
把这些环节对应到API上,你会发现它们自然分成几个群组:生命周期控制的,输入时间的,物理的,对象管理的,资源加载的。我后面每部分都按这个思路展开。
1.2 会查比会背更重要
这里得说个很反直觉的结论:常用API的重点不是记忆,而是知道“大概存在什么、什么时候要用、该去哪里查”。比如你遇到一个需求,需要让物体每秒钟恢复一部分血量,如果心里没有“协程”或“Mathf.MoveTowards”这些概念,连搜索关键词都打不出来,那才是最麻烦的。我后来习惯用一个本地文档,把自己用过的API按场景记录,遇到新需求先翻自己的记录,找不到再去查官方手册,效率比闷头背高得多。
2. 生命周期:脚本执行的起点
生命周期是Unity里最先要弄清的东西,因为你的代码在哪个回调里执行,会直接影响结果是否正常。
2.1 生命周期方法的完整执行顺序
一个普通脚本组件从实例化到销毁,会经历这些关键回调:
Awake:对象实例化后立即调用,哪怕脚本组件还没启用,且只调用一次;OnEnable:组件启用时调用,每次被重新激活都会触发;Start:在Awake之后、第一帧Update之前调用,只执行一次;FixedUpdate:按固定的物理时间间隔调用,默认0.02秒一次;Update:每帧调用一次,频率取决于帧率;LateUpdate:在Update之后调用,适合做摄像机跟随;OnDisable:组件被禁用时调用;OnDestroy:对象被销毁时调用。
这里有个典型误区:很多人以为Awake和Start可以随意混用。实际上Awake适合“无论组件是否启用都要做的初始化”,比如缓存组件引用、给成员变量赋值;Start适合依赖其他对象状态的逻辑,因为它保证在所有Awake执行完后才触发。
2.2 高频生命周期方法的选择逻辑
我实践下来的经验是:
- 只做一次性初始化,选
Awake,因为它执行早,还能避免空引用; - 逻辑依赖场景里其他对象的初始状态,放
Start; - 每帧轮询输入、轻量逻辑更新,放
Update; - 物理相关的操作,比如对刚体施力、改变速度,放
FixedUpdate; - 摄像机跟随、需要在所有角色更新后再调整坐标的逻辑,放
LateUpdate。
注意:千万别在
Update里做复杂的资源加载或频繁的查找操作。我见过一次事故,某个同学把一段Resources.Load放在Update里,结果每帧都加载一次配置表,游戏运行几秒后内存立刻暴涨。这类操作应当在Awake或Start里做一次并缓存结果。
3. 对象与组件操作:Unity世界的两个支柱
Unity里几乎没有脱离GameObject单独存在的逻辑,每个物体都是一个GameObject,上面挂载各种Component。理解这两个类型之间的关系,是理解其他API的前提。
3.1 GameObject的创建、查找与销毁
创建对象最直接的方式是用new GameObject(),也可以传入名称和参数,例如new GameObject("Enemy")。不过实际开发中更多情况是从预制体(Prefab)动态生成,这就用到Instantiate。
查找对象有几种常用入口:
GameObject.Find("名字"):通过场景中的层级路径或名字查,性能开销不小;GameObject.FindWithTag("标签"):配合Tag查找,比按名字稍微有结构;GameObject.FindGameObjectsWithTag("标签"):查找一组带同一标签的对象,返回数组;Object.FindFirstObjectByType<T>():按组件类型查找场景里的第一个匹配对象,效果直观,但代价较高,不宜在Update里调用。
销毁对象用Destroy,它会在帧末真正移除对象,所以销毁后你依然能访问到对象的引用直到帧结束,这是很多人碰到“空引用却还是能读出来”的诡异来源。
3.2 Transform:几乎绕不开的移动和旋转
Transform是使用频率最高的组件之一,核心成员包括:
position:世界坐标,代表物体在场景中的绝对位置;localPosition:相对于父节点的局部坐标;rotation:世界旋转(四元数);localScale:局部缩放;parent:父节点引用;SetParent(Transform parent, bool worldPositionStays):重新设置父节点,并决定是否保持世界位置不变。
常用方法里,Translate按方向和距离移动物体,Rotate绕指定轴旋转,LookAt让物体朝另一个物体的方向看,RotateAround让物体绕某个点旋转。这些方法都属于“增量式”操作,适合在逻辑里逐步调整。
我用下来最需要注意的是区分世界坐标和局部坐标。曾经做一个小地图UI,我把角色的世界坐标直接赋给UI的localPosition,地图上人物位置错得离谱。原因就是坐标空间没有转换。改成正确定位逻辑后,地图才正常显示。
3.3 组件获取与添加的几个坑
脚本里访问其他组件,最常见的是:
GetComponent<T>():获取指定类型的组件,没有则返回null;TryGetComponent<T>(out T component):返回布尔值,推荐使用,可以避免多次空值检查;AddComponent<T>():运行时动态添加组件;GetComponentInChildren<T>()/GetComponentInParent<T>():在子物体或父物体链路上查找组件。
使用这些API有三个经验:
一,尽量不要在Update里循环里调用GetComponent,可以用Awake缓存组件引用。当对象数量很多时,反复查找组件会带来明显的性能损耗;二,GetComponent只能拿到挂在对象上的组件,拿不到对象下子物体的组件,需要时得配合InChildren版本;三,每调用一次AddComponent,都需要留意生命周期回调的执行,动态添加的组件会在下一帧参与逻辑,不会立刻触发Awake。
提示:能缓存就缓存,能一次拿全就不要多次查询,这是Unity性能优化的基础习惯。
4. 输入与时间:让交互产生反馈
一个没有输入的游戏很难被叫作游戏。Unity的输入管理经历了老版Input和新版Input System的过渡,但老版Input依然是大量教程和项目中常用的方式,这里把最核心的动作都过一遍。
4.1 键盘、鼠标与触摸输入
输入类API主要分三个维度:状态查询、事件触发和轴值读取。
Input.GetKey(KeyCode.A):每帧检测键是否按住;Input.GetKeyDown(KeyCode.A):检测这一帧按键是否刚被按下,适合跳跃、连击等“一次触发”场景;Input.GetKeyUp(KeyCode.A):检测这一帧是否松开了按键;Input.GetMouseButton(0):鼠标左键按住检测(0左键、1右键、2中键);Input.GetMouseButtonDown(0):鼠标左键单击触发;Input.GetAxis("Horizontal")和Input.GetAxis("Vertical"):读取虚拟轴的平滑输入,默认对应键盘AD/方向键,也支持手柄摇杆;Input.GetAxisRaw:不带平滑滤波的原始输入,直接返回-1、0或1,适合要求响应精确的场景,比如平台跳跃。
触摸屏则通过Input.touchCount检查触摸数量,Input.GetTouch(i)拿到第几个触摸点,再通过touch .phase判断触摸状态。现在手机游戏一般用新输入系统处理复杂手势,但理解触摸的基本概念仍然有用,因为底层手势最终还是要归到触摸点位置上。
4.2 时间系统:deltaTime与timeScale
时间API里,Time.deltaTime是最高频的一个。它表示渲染上一帧所用时间(单位秒),会随帧率浮动。所有基于帧的移动计算,都要乘上deltaTime,把“每帧的速度”转换为“每秒的速度”。
比如这句代码:
transform.Translate(Vector3.forward * moveSpeed * Time.deltaTime);一旦漏写Time.deltaTime,移动速度就会直接和帧率挂钩。90帧环境下跑得飞快,30帧环境下慢得可怕。这个问题我早期经常犯,后来养成一个习惯:看到移动相关的乘法,第一时间找deltaTime。
其他常用的时间API:
Time.time:游戏开始后的累计时间,受timeScale影响;Time.timeScale:时间缩放,timeScale = 0可以暂停游戏,1是正常,2是双倍速;Time.fixedDeltaTime:物理步长间隔,默认0.02秒;Time.realtimeSinceStartup:真实流逝时间,不受暂停影响。
注意:当
timeScale = 0时,使用yield return new WaitForSeconds()的协程会卡住不执行,因为WaitForSeconds依赖缩放后的时间。这时应使用WaitForSecondsRealtime。如果你做的是暂停菜单,这个细节必须记住。
5. 物理与碰撞:让物体产生真实的交互
Unity的物理系统基于引擎内置的物理模拟器,使用起来不像现实物理那么难,但仍要理解刚体和碰撞的配合方式。
5.1 刚体(Rigidbody)与力的作用
想要一个物体受重力影响、能被力推动,必须挂载Rigidbody组件。刚体最常用的成员有:
velocity:当前速度,直接赋值可以瞬间设定速度,比如跳起瞬间赋值;angularVelocity:角速度;isKinematic:开启后物体不再受物理驱动,只能通过脚本控制位置,适合移动平台、带动画的NPC;useGravity:是否受重力;mass:质量,影响受力后的加速度;drag和angularDrag:线性阻力和角阻力;interpolation:插值模式,用于减少高速移动时的抖动感。
给物体施加力一般用:
AddForce(Vector3 force):冲击力,考虑质量影响,适合“推一下”的效果;AddForce(Vector3 force, ForceMode mode):ForceMode.Acceleration忽略质量、ForceMode.VelocityChange直接改变速度;AddTorque(Vector3 torque):施加扭矩,让物体旋转;AddExplosionForce:爆炸力,实现区域受力效果。
经验上,涉及刚体的运动命令安排到FixedUpdate里最合适,因为物理引擎的更新步长独立于帧率,在FixedUpdate里施力可以得到更稳定的表现,避免低帧率下物理行为飘忽。
5.2 碰撞器与触发器的区别
碰撞器(Collider)定义了物体的体积,常见的有BoxCollider、SphereCollider、CapsuleCollider、MeshCollider。默认状态下,碰撞器会让物体和物体互相阻挡。如果勾选了isTrigger,碰撞器就不再产生物理阻挡,而变成“触发器”,只负责感应进出事件。
碰撞回调方法有三种:
OnCollisionEnter/OnCollisionExit/OnCollisionStay:物理碰撞发生时调用,要求双方都有碰撞器,且至少一方带刚体;OnTriggerEnter/OnTriggerExit/OnTriggerStay:触发器交集发生时调用,作用于isTrigger开启的碰撞器。
参数里通常能拿到Collision或Collider对象,用来判断是哪个物体触发的。常用套路是先比较collision.gameObject.CompareTag("Player"),再执行相应逻辑。
提示:很多新手在做拾取道具时把道具设为
Trigger,然后期待OnCollisionEnter被触发,结果一直不执行。关键就是这两个事件体系的适用条件完全不同。如果你只想要“走进区域触发”而不是“物理碰撞反弹”,就用OnTriggerEnter。
6. 向量、数学与协程:进阶但绕不开的工具
这三个方向平时不会天天见,但遇到复杂逻辑时全靠它们撑腰。尤其是向量运算,几乎是所有移动玩法的基础。
6.1 Vector3 与 Quaternion
Vector3是一个三维向量结构,既表示位置、方向,也表示偏移。常用成员:
Vector3.zero、Vector3.one、Vector3.forward等静态属性,提供标准向量;magnitude:向量的长度;normalized:返回长度为1的方向向量;Vector3.Distance(a, b):计算两点距离;Vector3.Dot(a, b):点积,判断方向的相似程度,也可用于反隐匿检测;Vector3.Cross(a, b):叉积,得到垂直于两个向量的新向量;Vector3.Lerp(a, b, t):线性插值,t为0到1;Vector3.SmoothDamp:带平滑速度的逼近,比Lerp更自然,适合做相机跟随。
我用过最经典的场景是制作自动追踪的敌方炮弹。炮弹飞行方向需要始终指向目标,代码大致是:
Vector3 direction = (target.position - transform.position).normalized; transform.position += direction * speed * Time.deltaTime;先算出方向向量,再沿方向移动。如果没有normalized,子弹移动速度会随距离变化,显得很怪。
Quaternion是四元数,用于处理旋转。常用API:
Quaternion.Euler(x, y, z):把欧拉角转为四元数;Quaternion.LookRotation(Vector3 forward):让物体朝向某个方向;Quaternion.Slerp(from, to, t):球形插值旋转,过渡更均匀;transform.rotation与transform.localRotation:读取和设置旋转值。
记住一点:不要直接修改transform.rotation的xyz分量,因为四元数不是直观的欧拉角,修改个别分量几乎必然导致奇怪的旋转结果。需要旋转时用Rotate或Quaternion.Euler生成整个旋转量。
6.2 Mathf 提供的常用数学函数
Mathf封装了游戏开发中最常用的数学方法:
Mathf.Clamp(value, min, max):把数值限制在区间内,做血量、能量值上限非常方便;Mathf.Lerp(a, b, t):数值插值,常用于颜色渐变、进度条动画;Mathf.MoveTowards(current, target, maxDelta):匀速逼近目标,适合血条变化;Mathf.Abs、Mathf.Sqrt、Mathf.Pow:基础运算;Mathf.Sin、Mathf.Cos:三角函数,做轨道运动、摇摆动画时常见;Mathf.Repeat(t, length):循环递增,做无限滚动的背景时很实用;Mathf.PingPong(t, length):来回往返,做巡逻的敌人很直观。
举个例子,想要一个来回移动的平台,可以这么写:
float t = Mathf.PingPong(Time.time, moveDuration); float x = startX + Mathf.Lerp(0, moveRange, t); transform.position = new Vector3(x, transform.position.y, transform.position.z);好处是逻辑非常简洁,不需要自己记录方向状态。
6.3 协程:处理时间轴上的逻辑
协程是Unity里处理“延迟执行”“分步执行”的轻量工具。启动方式为StartCoroutine(方法名()),方法内部通过yield挂起执行:
yield return null:等待一帧;yield return new WaitForSeconds(seconds):等待指定秒数;yield return new WaitForSecondsRealtime(seconds):等待真实秒数,不受暂停影响;yield return new WaitUntil(bool条件):等待条件为真;yield return new WaitWhile(bool条件):等待条件变为假;yield return new WaitForEndOfFrame():等待当前帧渲染结束。
协程的停止用StopCoroutine,中止所有协程用StopAllCoroutines。
我看过不少新项目把“每隔几秒生成一个敌人”的逻辑写在Update里,用累加计时器实现。这当然能跑,但代码结构往往很散。用协程则可以把流程从头到尾串起来,可读性更好:
IEnumerator SpawnLoop() { while (true) { SpawnOneEnemy(); yield return new WaitForSeconds(spawnInterval); } }协程不是线程,它依然运行在主线程上,只是让代码逻辑分时执行,所以不用担心线程安全问题。
7. 资源加载与实例化:动态创建对象的核心链路
几乎所有玩法循环都绕不开“生成新东西”和“销毁旧东西”。这块的API虽然简单,但性能习惯值得早点养成。
7.1 Instantiate 与 Destroy 的使用细节
Instantiate(original)用于从预制体或已有对象生成一个新实例,返回值是Object类型,通常需要强转为具体类型或直接使用GameObject变量接收。
常见用法:
GameObject clone = Instantiate(prefab, spawnPoint.position, spawnPoint.rotation); Destroy(clone, 3f);第二个参数可以指定延迟销毁时间,这个重载在制作临时特效、子弹爆炸动画时特别省事。
关于销毁,有个微妙点值得提:Destroy并不是立即把对象从内存中移除,而是标记为“等待销毁”,在帧结束时统一清理。因此,若你销毁后又立即访问该对象的组件,有可能拿到一个“看似存在但已经失效”的引用,这在封装对象池时会带来不少坑。
7.2 Resources.Load 的加载方式
Resources.Load<T>(path)从Assets/Resources目录下加载资源,T可以是GameObject、Texture、AudioClip、TextAsset等。路径不包含扩展名。
简单示例:
GameObject bulletPrefab = Resources.Load<GameObject>("Prefabs/Bullet"); GameObject bullet = Instantiate(bulletPrefab, firePoint.position, firePoint.rotation);使用时有几个原则:
Resources文件夹内资源在构建时会全部打入包里,体积控制要特别注意;- 加载结果应当缓存复用,不要每次用都重新加载;
- 动态加载后需要及时释放,避免资源常驻内存。
对象池思想在这里尤其重要。频繁Instantiate和Destroy会产生大量内存碎片和GC压力。子弹这类高频生成对象,正确的做法是维护一个“对象池”:生成时从池里取,回收时送回池里,而不是真正销毁。这个技巧在实际项目中能明显改善帧率稳定性。我一直提醒自己做任何射击类原型时,第一版逻辑先保证功能正确,第二版就换成对象池,越早换越省事。
8. 常见问题排查与实际避坑记录
最后这部分是我在实际开发和帮朋友排查项目时遇到的典型问题,整理成速查表形式,方便以后遇到类似情况直接对号入座。
| 常见现象 | 可能原因 | 解决思路 |
|---|---|---|
| 移动速度忽快忽慢 | 移动计算未乘以Time.deltaTime | 所有基于帧的位移都乘deltaTime |
| 物理碰撞不触发 | 双方缺碰撞器或刚体,回调用错 | 检查物理组件,区分OnCollisionEnter与OnTriggerEnter |
| 物体消失在场景中但代码还能访问 | Destroy延迟到帧末尾才真正移除 | 不要销毁后立即访问引用,必要时用null判断前先做检查 |
| 游戏暂停后协程不恢复 | timeScale = 0时WaitForSeconds失效 | 改用WaitForSecondsRealtime |
GetComponent返回空引用 | 组件并没有挂在目标对象上 | 用TryGetComponent做安全判断,或在Awake里集中缓存 |
| 资源加载很慢且内存持续增长 | 在Update里重复加载资源 | 将资源加载移到初始化阶段,缓存结果 |
| 场景中搜索对象慢 | 在循环或Update里使用Find系列API | 预先缓存引用,或用事件/单例管理对象关系 |
| 物体位置始终不对 | 混淆了世界坐标与局部坐标 | 检查position和localPosition的使用场景,父节点变换要留意 |
| 旋转出现诡异结果 | 直接修改rotation的个别分量 | 使用Rotate或Quaternion.Euler |
| 触发事件靠概率生效 | OnTriggerStay需要至少一方使用刚体,且没有满足触发条件 | 核对触发双方组件组合,并确认isTrigger勾选状态 |
再补两个踩过的坑。
第一个是刚体放在子物体上的问题。当时做一个怪物身体上的武器碰撞检测,把碰撞器挂在武器子物体上,刚体也加在武器子物体上,结果碰撞检测时灵时不灵。排查了很久才明白:刚体最好挂在根物体上,子物体的碰撞器会自动参与物理计算。如果把刚体分散挂在多个子物体上,反而容易产生奇怪的叠加力和错误碰撞。
第二个是transform.SetParent的worldPositionStays参数。在制作UI面板与场景物体的层级切换时,如果忽略这个参数,子物体的世界位置可能瞬间变化。只要在调用前想清楚:是保持原来的世界位置,还是跟随新父节点调整位置。这两个意图对应不同的布尔值,一旦传错,位置就会出现肉眼可见的跳动。
最后分享一个我自己的学习习惯。我准备了一个本地文档,分门别类记录每个API的“场景、参数、注意事项”,每次写完一个小功能就把用到的API用一两句话记录下来。过一段时间翻看,会发现重复出现的API越来越稳定,真正需要被“记住”的东西其实非常有限。等到自己做项目逻辑时,大部分方法已经被训练成条件反射了。
如果你正在学Unity,不要追求一次性把所有API都记全,选中一个实际的小玩法,比如“主角移动、跳跃、收集金币、敌人巡逻”,把这套逻辑写完,核心API就掌握了大半。之后再慢慢扩展物理、协程、资源加载这些维度,用真实需求倒逼自己查文档、抄代码、改代码,印象远比一遍遍刷手册要深。