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ódigoEn 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; } } }
DebriefingOwner
: 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() {
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() {
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.