Cómo hacer que las corutinas en Unity sean un poco más convenientes

Cada desarrollador encuentra sus ventajas y desventajas de usar la rutina en Unity. Y decide en qué escenarios aplicarlos y en qué dar preferencia a las alternativas.


En la práctica diaria, a menudo uso corutinas para varios tipos de tareas. Una vez, me di cuenta de que era yo quien molestaba y rechazaba a muchos recién llegados en ellos.


Interfaz de pesadilla


El motor proporciona solo un par de métodos y varias de sus sobrecargas para trabajar con corutinas:


Lanzamiento ( docs )


  • StartCoroutine(string name, object value = null)
  • StartCoroutine(IEnumerator routine)

Stop ( docs )


  • StopCoroutine(string methodName)
  • StopCoroutine(IEnumerator routine)
  • StopCoroutine(Coroutine routine)

Las sobrecargas con par√°metros de cadena (a pesar de su conveniencia enga√Īosa) pueden inmediatamente enviar a la basura olvidar por al menos tres razones.


  • El uso expl√≠cito de los nombres de m√©todos de cadena complicar√° el an√°lisis de c√≥digo futuro, la depuraci√≥n y m√°s.
  • Seg√ļn la documentaci√≥n, las sobrecargas de cadenas tardan m√°s y permiten pasar solo un par√°metro adicional.
  • En mi pr√°ctica, a menudo result√≥ que no suced√≠a nada cuando se StopCoroutine una sobrecarga de cadena StopCoroutine . Corutin continu√≥ siendo ejecutado.

Por un lado, los métodos proporcionados son suficientes para cubrir las necesidades básicas. Pero con el tiempo, comencé a notar que con el uso activo tengo que escribir una gran cantidad de código repetitivo; esto es agotador y perjudica su legibilidad.


M√°s cerca del punto


En este art√≠culo quiero describir un peque√Īo contenedor que he estado usando durante mucho tiempo. Gracias a ella, pensando en las corutinas, ya no aparecen en mi cabeza fragmentos del c√≥digo de la plantilla con los que tuve que bailar alrededor. Adem√°s, todo el equipo se ha vuelto m√°s f√°cil de leer y comprender los componentes donde se usan las rutinas.


Supongamos que tenemos la siguiente tarea: escribir un componente que le permita mover un objeto a un punto dado.


En este momento, no importa qué método se utilizará para moverse y en qué coordenadas. Solo seleccionaré una de las muchas opciones: la interpolación y las coordenadas globales.


Tenga en cuenta que no se recomienda mover el objeto cambiando sus coordenadas "frente", es decir, la construcción transform.position = newPosition si el componente RigidBody se usa con él (especialmente en el método Update ) ( docs ).


Implementación estándar


Propongo la siguiente implementación del componente necesario:


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

Un poco sobre el código

En el m√©todo Move , es muy importante ejecutar la rutina solo cuando a√ļn no se est√° ejecutando. De lo contrario, se pueden lanzar tantos como se desee y cada uno de ellos mover√° el objeto.


threshold - tolerancia. En otras palabras, la distancia al punto, acerc√°ndose a la cual asumiremos que hemos alcanzado la meta.


Para que sirve


Dado que todos los componentes ( x , y , z ) de la estructura Vector3 son de tipo float , es una mala idea usar el resultado de verificar la igualdad de distancia al objetivo y la tolerancia como condición de bucle.


Verificamos la distancia al objetivo para m√°s / menos, lo que nos permite evitar este problema.


Adem√°s, si lo desea, puede usar el Mathf.Approximately ( docs ) para una verificaci√≥n aproximada de la igualdad. Vale la pena se√Īalar que con algunos m√©todos de movimiento, la velocidad puede llegar a ser lo suficientemente grande como para que el objeto "salte" al objetivo en un cuadro. Entonces el ciclo nunca terminar√°. Por ejemplo, si usa el m√©todo Vector3.MoveTowards .


Hasta donde sé, en el motor de Unity para la estructura Vector3 , el operador Vector3 ya está redefinido de tal manera que Mathf.Approximately Se llama Mathf.Approximately para verificar la igualdad de componentes.


Eso es todo por ahora, nuestro componente es bastante simple. Y por el momento no hay problemas. Pero, ¬Ņqu√© es este componente que le permite mover un objeto a un punto, pero no brinda la oportunidad de detenerlo? Arreglemos esta injusticia.


Dado que usted y yo decidimos no pasar al lado malo, y no usar sobrecargas con par√°metros de cadena, ahora tenemos que guardar en alg√ļn lugar un enlace a la rutina en ejecuci√≥n. De lo contrario, ¬Ņc√≥mo detenerla?


A√Īadir un campo:


 private Coroutine moveRoutine; 

Fix Move :


 public void Move() { if (moveRoutine == null) moveRoutine = StartCoroutine(MoveRoutine()); } 

Agregue un método de stop motion:


 public void Stop() { if (moveRoutine != null) StopCoroutine(moveRoutine); } 

Código completo
 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; } } } 

¬°Un asunto completamente diferente! Al menos aplicar a la herida.


Entonces Tenemos un peque√Īo componente que realiza la tarea. ¬ŅCu√°l es mi indignaci√≥n?


Problemas y su solución.


Con el tiempo, el proyecto crece y, con él, la cantidad de componentes, incluidos los que usan corutinas. Y cada vez, estas cosas me persiguen cada vez más:


  • Llamadas continuas de emparedado

 StartCoroutine(MoveRoutine()); 

 StopCoroutine(moveRoutine); 

Solo mirarlos hace que mi ojo se contraiga, y leer ese código es un placer dudoso (estoy de acuerdo, puede ser peor). Pero sería mucho más agradable y visual tener algo así:


 moveRoutine.Start(); 

 moveRoutine.Stop(); 

  • Cada vez que llame a StartCoroutine debe recordar guardar el valor de retorno:

 moveRoutine = StartCoroutine(MoveRoutine()); 

De lo contrario, debido a la falta de referencia a la rutina, simplemente no puede detenerla.


  • Comprobaciones constantes:

 if (moveRoutine == null) 

 if (moveRoutine != null) 

  • Y otra cosa malvada que siempre debes recordar (y que yo olvid√© de nuevo especialmente perdido). Al final de la rutina y antes de cada salida (por ejemplo, usando el yield break ), es necesario restablecer el valor del campo.
     moveRoutine = null; 

Si olvida, recibirá una rutina de una sola vez. Después de la primera ejecución en moveRoutine , el enlace a la rutina continuará y la nueva ejecución no funcionará.


De la misma manera, debe hacerlo en el caso de una parada forzada:


 public void Stop() { if (moveRoutine != null) { StopCoroutine(moveRoutine); moveRoutine = null; } } 

Código con todos los cambios
 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; } } 

En un momento, realmente quiero sacar una vez toda esta mascarada en alg√ļn lugar, y dejarme solo los m√©todos necesarios: Start , Stop y un par de eventos y propiedades m√°s.


¬°Hag√°moslo finalmente!


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

Debriefing

Owner : un enlace a la instancia de MonoBehaviour a la que se adjuntará la rutina. Como sabe, debe ejecutarse en el contexto de un componente particular, ya que es a él a quien StopCoroutine métodos StartCoroutine y StopCoroutine . En consecuencia, necesitamos un enlace al componente que será el propietario de la rutina.


Coroutine : un an√°logo del campo moveRoutine en el componente MoveToPoint , contiene un enlace a la corutina actual.


Routine : el delegado con quien se comunicar√° el m√©todo que act√ļa como la rutina.


Process() es un peque√Īo contenedor sobre el m√©todo de Routine principal. Es necesario para poder rastrear cuando se completa la ejecuci√≥n de la rutina, restablecer el enlace y ejecutar otro c√≥digo en ese momento (si es necesario).


IsProcessing : le permite saber si la corutina se est√° ejecutando actualmente.


Por lo tanto, nos deshacemos de una gran cantidad de dolor de cabeza y nuestro componente adquiere un aspecto completamente diferente:


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

Todo lo que queda es la propia rutina y algunas líneas de código para trabajar con ella. Significativamente mejor.


Supongamos que ha llegado una nueva tarea: debe agregar la capacidad de ejecutar cualquier código después de que el objeto haya alcanzado su objetivo.


En la versión original, tendríamos que agregar un parámetro delegado adicional a cada corutina que se pueda extraer después de su finalización.


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

Y llame de la siguiente manera:


 moveRoutine = StartCoroutine(moveRoutine(CallbackHandler)); private void CallbackHandler() { // do something } 

Y si hay algo de lambda como manejador, entonces se ve a√ļn peor.


Con nuestro contenedor, es suficiente agregar este evento solo una vez.


 public Action Finish; 

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

Y luego, si es necesario, suscríbase.


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

Creo que ya ha notado que la versión actual del contenedor proporciona la capacidad de trabajar solo con corutinas sin parámetros. Por lo tanto, podemos escribir un contenedor generalizado para la rutina con un parámetro. El resto se hace por analogía.


Pero, para bien, sería bueno poner primero el código, que será el mismo para todos los contenedores, en alguna clase base, para no escribir lo mismo. Estamos luchando contra esto.


Lo quitamos:


  • Owner , Coroutine , IsProcessing
  • Evento Finished

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

Contenedor sin parámetros después de refactorizar
 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; } } } 

Y ahora, de hecho, una envoltura para la rutina con un par√°metro:


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

Como puede ver, el c√≥digo es casi el mismo. Solo en algunos lugares se agregaron fragmentos, dependiendo del n√ļmero de argumentos.


Supongamos que se nos pide que actualicemos el componente MoveToPoint para que el punto se pueda establecer no a través de la ventana del Inspector en el editor, sino por código cuando se llamó al método 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; } } } 

Hay muchas opciones para expandir la funcionalidad de este contenedor tanto como sea posible: agregue un lanzamiento retrasado, eventos con par√°metros, posible seguimiento del progreso de la rutina y m√°s. Pero sugiero detenerse en esta etapa.


El propósito de este artículo es el deseo de compartir los problemas apremiantes que encontré y proponerles una solución, y no cubrir las posibles necesidades de todos los desarrolladores.


Espero que tanto los camaradas principiantes como los experimentados se beneficien de mi experiencia. Quiz√°s compartir√°n sus comentarios o se√Īalar√°n errores que podr√≠a haber cometido.


Source: https://habr.com/ru/post/442622/


All Articles