Cada desenvolvedor encontra suas vantagens e desvantagens do uso da corotina no Unity. E ele decide em quais cenários aplicá-los e em que dar preferência a alternativas.
Na prática cotidiana, costumo usar corotinas para vários tipos de tarefas. Certa vez, percebi que era eu quem aborrecia e rejeitava muitos recém-chegados.
Interface pesadelo
O mecanismo fornece apenas alguns métodos e várias de suas sobrecargas para trabalhar com corotinas:
Iniciar ( docs )
StartCoroutine(string name, object value = null)
StartCoroutine(IEnumerator routine)
Parar ( documentos )
StopCoroutine(string methodName)
StopCoroutine(IEnumerator routine)
StopCoroutine(Coroutine routine)
Sobrecargas com parâmetros de string (apesar de sua conveniência enganosa) podem imediatamente enviar para o lixo esqueça por pelo menos três razões.
- O uso explícito de nomes de métodos de string complicará a análise futura de códigos, depuração e muito mais.
- De acordo com a documentação, as sobrecargas de string demoram mais e permitem que apenas um parâmetro adicional seja passado.
- Na minha prática, muitas vezes acontecia que nada acontecia quando uma sobrecarga de string
StopCoroutine
era StopCoroutine
. Corutin continuou a ser executado.
Por um lado, os métodos fornecidos são suficientes para atender às necessidades básicas. Mas com o tempo, comecei a perceber que, com o uso ativo, tenho que escrever uma grande quantidade de código padrão - isso é cansativo e prejudica sua legibilidade.
Mais perto do ponto
Neste artigo, quero descrever um pequeno invólucro que utilizo há muito tempo. Graças a ela, com pensamentos sobre as corotinas, fragmentos do código do modelo não aparecem mais na minha cabeça, com os quais tive que dançar ao redor deles. Além disso, toda a equipe ficou mais fácil de ler e entender os componentes onde as corotinas são usadas.
Suponha que tenhamos a seguinte tarefa - escrever um componente que permita mover um objeto para um determinado ponto.
Nesse momento, não importa qual método será usado para mover e em quais coordenadas. Somente uma das muitas opções será selecionada por mim - estas são interpolação e coordenadas globais.
Observe que é altamente recomendável não mover um objeto alterando suas coordenadas “na testa”, ou seja, o construto transform.position = newPosition
se o componente RigidBody
for usado com ele (especialmente no método Update
) ( docs ).
Implementação padrão
Proponho a seguinte opção de implementação para o componente necessário:
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; } } }
Um pouco sobre códigoNo método Move
, é muito importante executar a corotina apenas quando ainda não estiver em execução. Caso contrário, eles podem ser lançados quantos forem desejados e cada um deles moverá o objeto.
threshold
- tolerância. Em outras palavras, a distância até o ponto, aproximando-se da qual assumiremos que alcançamos a meta.
Para que serve?
Dado que todos os componentes ( x
, y
, z
) da estrutura Vector3
são do tipo float
, é uma má idéia usar o resultado da verificação da igualdade de distância do alvo e da tolerância como condição do loop.
Verificamos a distância do alvo para mais / menos, o que nos permite evitar esse problema.
Além disso, se desejar, você pode usar o Mathf.Approximately
( docs ) para uma verificação aproximada da igualdade. Vale a pena notar que, com alguns métodos de movimento, a velocidade pode ser grande o suficiente para que o objeto “salte” o alvo em um quadro. Então o ciclo nunca terminará. Por exemplo, se você usar o método Vector3.MoveTowards
.
Até Vector3
eu sei, no mecanismo do Unity para a estrutura Vector3
, o operador Vector3
já está redefinido de tal forma que Mathf.Approximately
é chamado para verificar a igualdade entre os componentes.
Por enquanto é tudo, nosso componente é bastante simples. E no momento não há problemas. Mas, qual é esse componente que permite mover um objeto para um ponto, mas não oferece uma oportunidade para interrompê-lo. Vamos consertar essa injustiça.
Como você e eu decidimos não passar para o lado ruim, e não usar sobrecargas com parâmetros de string, agora precisamos salvar em algum lugar um link para a corotina em execução. Caso contrário, como então impedi-la?
Adicione um campo:
private Coroutine moveRoutine;
Fix Move
:
public void Move() { if (moveRoutine == null) moveRoutine = StartCoroutine(MoveRoutine()); }
Adicione um método de stop motion:
public void Stop() { if (moveRoutine != null) StopCoroutine(moveRoutine); }
Código inteiro 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; } } }
Uma questão completamente diferente! Pelo menos aplique na ferida.
Então Temos um pequeno componente que executa a tarefa. Qual é a minha indignação?
Problemas e sua solução
Com o tempo, o projeto cresce e, com ele, o número de componentes, incluindo aqueles que utilizam corotinas. E cada vez essas coisas estão me assombrando cada vez mais:
- Chamadas sanduíche em andamento
StartCoroutine(MoveRoutine());
StopCoroutine(moveRoutine);
Só de olhar para eles, meus olhos se contraem e ler um código desse tipo é um prazer duvidoso (concordo que pode ser pior). Mas seria muito melhor e mais visual ter algo assim:
moveRoutine.Start();
moveRoutine.Stop();
- Sempre que você chamar
StartCoroutine
lembre-se de salvar o valor de retorno:
moveRoutine = StartCoroutine(MoveRoutine());
Caso contrário, devido à falta de referência à corotina, você simplesmente não poderá pará-la.
if (moveRoutine == null)
if (moveRoutine != null)
- E outra coisa má que você deve sempre lembrar (e que eu
esqueci de novo especialmente perdido). No final da corotina e antes de cada saída dela (por exemplo, usando yield break
), o valor do campo deve ser redefinido.
moveRoutine = null;
Se você esquecer, receberá uma rotina única. Após a primeira execução em moveRoutine
, o link para a corotina permanecerá e a nova execução não funcionará.
Da mesma maneira, você precisa fazer no caso de uma parada forçada:
public void Stop() { if (moveRoutine != null) { StopCoroutine(moveRoutine); moveRoutine = null; } }
Código com todas as alterações 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; } }
A certa altura, eu realmente quero tirar toda essa máscara em algum lugar e deixar apenas os métodos necessários: Start
, Stop
e mais alguns eventos e propriedades.
Vamos finalmente fazer isso!
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
- um link para a instância MonoBehaviour
à qual a corotina será anexada. Como você sabe, ele deve ser executado no contexto de um componente específico, pois é para ele que os métodos StopCoroutine
e StopCoroutine
. Portanto, precisamos de um link para o componente que será o proprietário da corotina.
Coroutine
- um análogo do campo moveRoutine
no componente MoveToPoint
, contém um link para a corotina atual.
Routine
- o delegado com quem o método que atua como a rotina será comunicado.
Process()
é um pequeno invólucro sobre o método principal de Routine
. É necessário para poder rastrear quando a execução da corotina estiver concluída, redefinir o link para ele e executar outro código naquele momento (se necessário).
IsProcessing
- permite descobrir se a corotina está em execução no momento.
Assim, nos livramos de uma grande quantidade de dor de cabeça e nosso componente assume uma aparência 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; } } }
Tudo o que resta é a própria rotina e algumas linhas de código para trabalhar com ela. Significativamente melhor.
Suponha que uma nova tarefa tenha chegado - você precisa adicionar a capacidade de executar qualquer código depois que o objeto atingir seu objetivo.
Na versão original, teríamos que adicionar um parâmetro delegado adicional a cada rotina que pode ser puxada após sua conclusão.
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(); }
E ligue da seguinte maneira:
moveRoutine = StartCoroutine(moveRoutine(CallbackHandler)); private void CallbackHandler() {
E se houver algum lambda como manipulador, ele parecerá ainda pior.
Com o nosso wrapper, basta adicionar este evento a ele apenas uma vez.
public Action Finish;
private IEnumerator Process() { yield return Routine.Invoke(); Coroutine = null; Finish?.Invoke(); }
E então, se necessário, assine.
moveRoutine.Finished += OnFinish; private void OnFinish() {
Acredito que você já tenha notado que a versão atual do wrapper oferece a capacidade de trabalhar apenas com corotinas sem parâmetros. Portanto, podemos escrever um wrapper generalizado para a corotina com um parâmetro. O resto é feito por analogia.
Mas, para o bem, seria bom primeiro colocar o código, que será o mesmo para todos os wrappers, em alguma classe base, para não escrever a mesma coisa. Estamos lutando contra isso.
Nós o removemos:
Owner
propriedades, IsProcessing
, 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; }
Invólucro sem parâmetros após refatoração 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; } } }
E agora, de fato, um invólucro para corotina com um 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 você pode ver, o código é quase o mesmo. Somente em alguns lugares foram adicionados fragmentos, dependendo do número de argumentos.
Suponha que nos pedissem para atualizar o componente MoveToPoint para que o ponto pudesse ser definido não pela janela do Inspector
no editor, mas por código quando o método Move
foi chamado.
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; } } }
Há muitas opções para expandir a funcionalidade desse wrapper, tanto quanto possível: adicione uma inicialização atrasada, eventos com parâmetros, possível rastreamento do progresso da corotina e muito mais. Mas sugiro parar nesta fase.
O objetivo deste artigo é o desejo de compartilhar os problemas prementes que encontrei e propor uma solução para eles, e não cobrir as possíveis necessidades de todos os desenvolvedores.
Espero que iniciantes e camaradas experientes se beneficiem da minha experiência. Talvez eles compartilhem seus comentários ou apontem os erros que eu poderia ter cometido.