Como tornar as corotinas no Unity um pouco mais convenientes

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ódigo

No 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.


  • Verificações constantes:

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

Debriefing

Owner - 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() { // do something } 

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() { // do something } 

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.


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


All Articles