如何在Unity中使协程更加方便

每个开发人员都会发现在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 = newPositiondocs )。


标准实施


我为所需组件提出以下实现选项:


 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结构的所有分量( xyz )均为float类型,使用检查到目标的距离相等和公差为循环条件的结果是一个坏主意


我们或多或少地检查到目标的距离,这可以避免这个问题。


另外,如果需要,您可以使用Mathf.Approximatelydocs )方法进行相等性近似检查。 值得注意的是,通过某些移动方法,速度可能会变得足够大,以使对象在一帧中“跳跃”目标。 然后,循环将永远不会结束。 例如,如果您使用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; } } 

有一次,我真的想在某个地方消除整个化妆舞会,只剩下必要的方法: StartStop以及更多事件和属性。


终于开始吧!


 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实例的链接。 如您所知,它必须在特定组件的上下文中执行,因为StartCoroutineStopCoroutine方法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() { // do something } 

而且如果有一些lambda作为处理程序,那么它看起来甚至更糟。


使用我们的包装器,只需将此事件添加一次即可。


 public Action Finish; 

 private IEnumerator Process() { yield return Routine.Invoke(); Coroutine = null; Finish?.Invoke(); } 

然后,如有必要,请订阅。


 moveRoutine.Finished += OnFinish; private void OnFinish() { // do something } 

我相信您已经注意到,当前版本的包装器提供了仅使用没有参数的协程的能力。 因此,我们可以为带有一个参数的协程编写一个通用包装器。 其余类似地完成。


但是,从好的方面来说,最好将代码(对于所有包装器都相同)放到某个基类中,以免编写相同的东西。 我们正在为此而战。


我们将其删除:


  • 属性OwnerCoroutineIsProcessing
  • 活动结束

 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; } } } 

有许多选项可用于尽可能扩展此包装器的功能:添加延迟启动,带有参数的事件,可能的协程进展跟踪等。 但我建议在此阶段停止。


本文的目的是希望分享我遇到的紧迫问题并提出解决方案,而不是满足所有开发人员的可能需求。


我希望无论是初学者还是有经验的同志都将从我的经验中受益。 也许他们会分享他们的意见或指出我可能犯的错误。


Source: https://habr.com/ru/post/zh-CN442622/


All Articles