每个开发人员都会发现在Unity中使用协程的优缺点。 并且他决定在哪些方案中应用它们,以及在哪些方案中优先考虑替代方案。
在日常练习中,我经常将协程用于各种类型的任务。 一次,我意识到是我惹恼并拒绝了许多新来者。
噩梦界面
该引擎仅提供了几种方法以及使用协程的几种重载:
启动 ( docs )
StartCoroutine(string name, object value = null)
StartCoroutine(IEnumerator routine)
停止 ( docs )
StopCoroutine(string methodName)
StopCoroutine(IEnumerator routine)
StopCoroutine(Coroutine routine)
具有字符串参数的重载(尽管它们具有欺骗性的便利)可以立即 送到垃圾箱 至少出于三个原因而忘记。
- 字符串方法名称的明确使用将使将来的代码分析,调试等更加复杂。
- 根据文档,字符串重载需要更长的时间,并且只允许传递一个附加参数。
- 在我的实践中,经常发现
StopCoroutine
字符串重载时什么也没有发生。 Corutin继续被处决。
一方面,提供的方法足以满足基本需求。 但是随着时间的流逝,我开始注意到,在频繁使用的情况下,我必须编写大量的样板代码-这很累人,并且损害了其可读性。
更接近重点
在本文中,我想描述一个我已经使用很长时间的小型包装器。 多亏了她,对协程的想法使模板代码的片段不再出现在我的脑海中,我不得不与它们共舞。 此外,整个团队变得更容易阅读和理解使用协程的组件。
假设我们有以下任务-编写一个组件,使您可以将对象移动到给定点。
此时,将使用哪种方法以及在什么坐标下移动都无关紧要。 我将选择许多选项中的仅一个-这些是插值和全局坐标。
请注意,强烈建议不要通过更改对象的“前额”坐标来移动对象,即,如果将RigidBody
组件与对象一起使用(特别是在Update
方法中),则构造transform.position = newPosition
( docs )。
标准实施
我为所需组件提出以下实现选项:
using IEnumerator = System.Collections.IEnumerator; using UnityEngine; public sealed class MoveToPoint : MonoBehaviour { public Vector3 target; [Space] public float speed; public float threshold; public void Move() { if (moveRoutine == null) StartCoroutine(MoveRoutine()); } private IEnumerator MoveRoutine() { while (Vector3.Distance(transform.position, target) > threshold) { transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime); yield return null; } } }
关于代码的一点在Move
方法中,仅在尚未运行协程时非常重要。 否则,可以根据需要启动它们,并且它们中的每一个都会移动对象。
threshold
-公差。 换句话说,到点的距离,即我们已经达到目标的接近距离。
这是为了什么
考虑到Vector3
结构的所有分量( x
, y
, z
)均为float
类型,使用检查到目标的距离相等和公差为循环条件的结果是一个坏主意 。
我们或多或少地检查到目标的距离,这可以避免这个问题。
另外,如果需要,您可以使用Mathf.Approximately
( docs )方法进行相等性近似检查。 值得注意的是,通过某些移动方法,速度可能会变得足够大,以使对象在一帧中“跳跃”目标。 然后,循环将永远不会结束。 例如,如果您使用Vector3.MoveTowards
方法。
Vector3
我所知,在Vector3
结构的Unity引擎中, Vector3
运算符已经以Mathf.Approximately
调用的方式进行了重新定义,以检查各个组件的相等性。
到此为止,我们的组件非常简单。 目前没有问题。 但是,该组件是什么允许您将对象移动到某个点,但没有提供停止它的机会。 让我们解决这种不公正现象。
由于您和我决定不走到邪恶的一面,并且不对字符串参数使用重载,因此现在我们需要将链接保存到正在运行的协程的某个位置。 否则,该如何制止她?
添加一个字段:
private Coroutine moveRoutine;
修正Move
:
public void Move() { if (moveRoutine == null) moveRoutine = StartCoroutine(MoveRoutine()); }
添加定格方法:
public void Stop() { if (moveRoutine != null) StopCoroutine(moveRoutine); }
整个代码 using IEnumerator = System.Collections.IEnumerator; using UnityEngine; public sealed class MoveToPoint : MonoBehaviour { public Vector3 target; [Space] public float speed; public float threshold; private Coroutine moveRoutine; public void Move() { if (moveRoutine == null) moveRoutine = StartCoroutine(MoveRoutine()); } public void Stop() { if (moveRoutine != null) StopCoroutine(moveRoutine); } private IEnumerator MoveRoutine() { while (Vector3.Distance(transform.position, target) > threshold) { transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime); yield return null; } } }
完全不同的事情! 至少适用于伤口。
这样啊 我们有一个执行任务的小组件。 我的愤慨是什么?
问题及其解决方案
随着时间的流逝,该项目不断发展,并且组件的数量也随之增加,包括使用协程的组件。 每次,这些事情越来越困扰我:
StartCoroutine(MoveRoutine());
StopCoroutine(moveRoutine);
仅仅看着它们会使我的眼睛抽搐,而阅读这样的代码是一种可疑的乐趣(我同意,可能会更糟)。 但是拥有这样的东西会更好,更直观:
moveRoutine.Start();
moveRoutine.Stop();
- 每次调用
StartCoroutine
必须记住要保存返回值:
moveRoutine = StartCoroutine(MoveRoutine());
否则,由于缺乏对协程的引用,您根本无法阻止它。
if (moveRoutine == null)
if (moveRoutine != null)
- 还有你必须永远记住的另一件事(我
再次忘记 特别错过)。 在协程的最末端并且在每次退出协程之前(例如,使用yield break
),都必须重置字段值。
moveRoutine = null;
如果您忘记了,您将收到一次性的协程。 在moveRoutine
第一次运行后,与协程的链接将保留,并且新运行将不起作用。
同样,在强制停止的情况下,您需要执行以下操作:
public void Stop() { if (moveRoutine != null) { StopCoroutine(moveRoutine); moveRoutine = null; } }
进行所有更改的代码 public sealed class MoveToPoint : MonoBehaviour { public Vector3 target; [Space] public float speed; public float threshold; private Coroutine moveRoutine; public void Move() { moveRoutine = StartCoroutine(MoveRoutine()); } public void Stop() { if (moveRoutine != null) { StopCoroutine(moveRoutine); moveRoutine = null; } } private IEnumerator MoveRoutine() { while (Vector3.Distance(transform.position, target) > threshold) { transform.localPosition = Vector3.Lerp(transform.position, target, speed * Time.deltaTime); yield return null; } moveRoutine = null; } }
有一次,我真的想在某个地方消除整个化妆舞会,只剩下必要的方法: Start
, Stop
以及更多事件和属性。
终于开始吧!
using System.Collections; using System; using UnityEngine; public sealed class CoroutineObject { public MonoBehaviour Owner { get; private set; } public Coroutine Coroutine { get; private set; } public Func<IEnumerator> Routine { get; private set; } public bool IsProcessing => Coroutine != null; public CoroutineObject(MonoBehaviour owner, Func<IEnumerator> routine) { Owner = owner; Routine = routine; } private IEnumerator Process() { yield return Routine.Invoke(); Coroutine = null; } public void Start() { Stop(); Coroutine = Owner.StartCoroutine(Process()); } public void Stop() { if (IsProcessing) { Owner.StopCoroutine(Coroutine); Coroutine = null; } } }
汇报Owner
-链接到协程的MonoBehaviour
实例的链接。 如您所知,它必须在特定组件的上下文中执行,因为StartCoroutine
和StopCoroutine
方法StopCoroutine
。 因此,我们需要一个链接,该链接将成为协程的所有者。
协程MoveToPoint
组件中moveRoutine
字段的类似物,包含指向当前协程的链接。
Routine
-将与充当协程的方法进行通信的委托人。
Process()
是主要Routine
方法的小型包装。 为了能够跟踪协程执行的完成时间,有必要重置它的链接并在那一刻执行另一个代码(如有必要)。
IsProcessing
允许您确定协程当前是否正在运行。
因此,我们摆脱了很多麻烦,并且我们的组件呈现出完全不同的外观:
using IEnumerator = System.Collections; using UnityEngine; public sealed class MoveToPoint : MonoBehaviour { public Vector3 target; [Space] public float speed; public float threshold; private CoroutineObject moveRoutine; private void Awake() { moveRoutine = new CoroutineObject(this, MoveRoutine); } public void Move() => moveRoutine.Start(); public void Stop() => moveRoutine.Stop(); private IEnumerator MoveRoutine() { while (Vector3.Distance(transform.position, target) > threshold) { transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime); yield return null; } } }
剩下的只是协程本身和使用它的几行代码。 明显更好。
假设有一个新任务-您需要添加在对象达到其目标之后执行任何代码的功能。
在原始版本中,我们必须为每个协程添加一个附加的委托参数,该协程可以在完成后被拉出。
private IEnumerator MoveRoutine(System.Action callback) { while (Vector3.Distance(transform.position, target) > threshold) { transform.position = Vector3.Lerp(transform.position, target, speed * Time.deltaTime); yield return null; } moveRoutine = null callback?.Invoke(); }
并调用如下:
moveRoutine = StartCoroutine(moveRoutine(CallbackHandler)); private void CallbackHandler() {
而且如果有一些lambda作为处理程序,那么它看起来甚至更糟。
使用我们的包装器,只需将此事件添加一次即可。
public Action Finish;
private IEnumerator Process() { yield return Routine.Invoke(); Coroutine = null; Finish?.Invoke(); }
然后,如有必要,请订阅。
moveRoutine.Finished += OnFinish; private void OnFinish() {
我相信您已经注意到,当前版本的包装器提供了仅使用没有参数的协程的能力。 因此,我们可以为带有一个参数的协程编写一个通用包装器。 其余类似地完成。
但是,从好的方面来说,最好将代码(对于所有包装器都相同)放到某个基类中,以免编写相同的东西。 我们正在为此而战。
我们将其删除:
- 属性
Owner
, Coroutine
, IsProcessing
- 活动结束
using Action = System.Action; using UnityEngine; public abstract class CoroutineObjectBase { public MonoBehaviour Owner { get; protected set; } public Coroutine Coroutine { get; protected set; } public bool IsProcessing => Coroutine != null; public abstract event Action Finished; }
重构后的无参数包装器 using System; using System.Collections; using UnityEngine; public sealed class CoroutineObject : CoroutineObjectBase { public Func<IEnumerator> Routine { get; private set; } public override event Action Finished; public CoroutineObject(MonoBehaviour owner, Func<IEnumerator> routine) { Owner = owner; Routine = routine; } private IEnumerator Process() { yield return Routine.Invoke(); Coroutine = null; Finished?.Invoke(); } public void Start() { Stop(); Coroutine = Owner.StartCoroutine(Process()); } public void Stop() { if(IsProcessing) { Owner.StopCoroutine(Coroutine); Coroutine = null; } } }
现在,实际上,是一个带有一个参数的协程包装器:
using System; using System.Collections; using UnityEngine; public sealed class CoroutineObject<T> : CoroutineObjectBase { public Func<T, IEnumerator> Routine { get; private set; } public override event Action Finished; public CoroutineObject(MonoBehaviour owner, Func<T, IEnumerator> routine) { Owner = owner; Routine = routine; } private IEnumerator Process(T arg) { yield return Routine.Invoke(arg); Coroutine = null; Finished?.Invoke(); } public void Start(T arg) { Stop(); Coroutine = Owner.StartCoroutine(Process(arg)); } public void Stop() { if(IsProcessing) { Owner.StopCoroutine(Coroutine); Coroutine = null; } } }
如您所见,代码几乎相同。 仅在某些位置添加了片段,具体取决于参数的数量。
假设要求我们更新MoveToPoint组件,以便不能通过编辑器中的“ Inspector
窗口来设置点,而可以通过调用Move
方法时的代码来设置。
using IEnumerator = System.Collections.IEnumerator; using UnityEngine; public sealed class MoveToPoint : MonoBehaviour { public float speed; public float threshold; private CoroutineObject<Vector3> moveRoutine; public bool IsMoving => moveRoutine.IsProcessing; private void Awake() { moveRoutine = new CoroutineObject<Vector3>(this, MoveRoutine); } public void Move(Vector3 target) => moveRoutine.Start(target); public void Stop() => moveRoutine.Stop(); private IEnumerator MoveRoutine(Vector3 target) { while (Vector3.Distance(transform.position, target) > threshold) { transform.localPosition = Vector3.Lerp(transform.position, target, speed); yield return null; } } }
有许多选项可用于尽可能扩展此包装器的功能:添加延迟启动,带有参数的事件,可能的协程进展跟踪等。 但我建议在此阶段停止。
本文的目的是希望分享我遇到的紧迫问题并提出解决方案,而不是满足所有开发人员的可能需求。
我希望无论是初学者还是有经验的同志都将从我的经验中受益。 也许他们会分享他们的意见或指出我可能犯的错误。