Comment rendre les coroutines dans Unity un peu plus pratiques

Chaque développeur trouve ses avantages et ses inconvénients à utiliser coroutine dans Unity. Et il décide dans quels scénarios les appliquer et dans lesquels privilégier les alternatives.


Dans la pratique quotidienne, j'utilise souvent des coroutines pour différents types de tâches. Une fois, j'ai réalisé que c'était moi qui agaçais et rejetais de nombreux nouveaux venus en eux.


Interface cauchemar


Le moteur ne fournit que quelques méthodes et plusieurs de leurs surcharges pour travailler avec des coroutines:


Lancement ( docs )


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

Arrêter ( docs )


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

Les surcharges avec des paramètres de chaîne (malgré leur commodité trompeuse) peuvent immédiatement envoyer à la poubelle oublier pour au moins trois raisons.


  • L'utilisation explicite des noms de méthode de chaîne compliquera l'analyse future du code, le débogage et plus encore.
  • Selon la documentation, les surcharges de chaînes prennent plus de temps et ne permettent de transmettre qu'un seul paramètre supplémentaire.
  • Dans ma pratique, il s'est avéré assez souvent que rien ne s'est produit lorsqu'une surcharge de chaîne StopCoroutine été StopCoroutine . Corutin a continué d'être exécuté.

D'une part, les méthodes fournies sont suffisantes pour couvrir les besoins de base. Mais au fil du temps, j'ai commencé à remarquer qu'avec une utilisation active, je dois écrire une grande quantité de code passe-partout - c'est fatigant et nuit à sa lisibilité.


Plus près du but


Dans cet article, je veux décrire un petit wrapper que j'utilise depuis longtemps. Grâce à elle, avec des pensées sur les coroutines, des fragments du code modèle n'apparaissent plus dans ma tête avec lesquels je devais danser autour d'eux. De plus, toute l'équipe est devenue plus facile à lire et à comprendre les composants où les coroutines sont utilisées.


Supposons que nous ayons la tâche suivante - écrire un composant qui vous permet de déplacer un objet vers un point donné.


En ce moment, peu importe la méthode qui sera utilisée pour se déplacer et dans quelles coordonnées. Seule une des nombreuses options sera sélectionnée par moi - ce sont l'interpolation et les coordonnées globales.


Veuillez noter qu'il est fortement recommandé de ne pas déplacer un objet en changeant ses coordonnées «front», c'est-à-dire la construction transform.position = newPosition si le composant RigidBody est utilisé avec lui (en particulier dans la méthode Update ) ( docs ).


Implémentation standard


Je propose la mise en œuvre suivante du composant nécessaire:


 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 peu de code

Dans la méthode Move , il est très important d'exécuter coroutine uniquement lorsqu'il n'est pas déjà en cours d'exécution. Sinon, ils peuvent être lancés autant que vous le souhaitez et chacun d'eux déplacera l'objet.


threshold - tolérance. En d'autres termes, la distance au point, approchant, nous supposerons que nous avons atteint le but.


À quoi ça sert?


Étant donné que tous les composants ( x , y , z ) de la structure Vector3 sont de type float , utiliser le résultat de la vérification de l'égalité de la distance à la cible et de la tolérance comme condition de boucle est une mauvaise idée .


Nous vérifions la distance de la cible pour plus / moins, ce qui nous permet d'éviter ce problème.


En outre, si vous le souhaitez, vous pouvez utiliser la Mathf.Approximately ( docs ) pour une vérification approximative de l'égalité. Il convient de noter qu'avec certaines méthodes de déplacement, la vitesse peut s'avérer suffisamment élevée pour que l'objet «saute» la cible dans une image. Alors le cycle ne finira jamais. Par exemple, si vous utilisez la méthode Vector3.MoveTowards .


Pour Vector3 que je sache, dans le moteur Unity pour la structure Vector3 , l'opérateur Vector3 est déjà redéfini de telle sorte que Mathf.Approximately est appelé pour vérifier l'égalité au niveau des composants.


C'est tout pour l'instant, notre composant est assez simple. Et pour le moment il n'y a pas de problèmes. Mais, quel est ce composant qui vous permet de déplacer un objet vers un point, mais ne fournit pas l'occasion de l'arrêter. Corrigeons cette injustice.


Puisque vous et moi avons décidé de ne pas passer du mauvais côté et de ne pas utiliser de surcharge avec des paramètres de chaîne, nous devons maintenant enregistrer quelque part un lien vers la coroutine en cours d'exécution. Sinon, comment alors l'arrêter?


Ajoutez un champ:


 private Coroutine moveRoutine; 

Fix Move :


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

Ajoutez une méthode de stop motion:


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

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

Une toute autre affaire! Appliquer au moins sur la plaie.


Alors. Nous avons un petit composant qui effectue la tâche. Quelle est mon indignation?


Problèmes et leur solution


Au fil du temps, le projet se développe, et avec lui le nombre de composants, y compris ceux utilisant des coroutines. Et à chaque fois, ces choses me hantent de plus en plus:


  • Appels sandwich en cours

 StartCoroutine(MoveRoutine()); 

 StopCoroutine(moveRoutine); 

Le simple fait de les regarder fait trembler mes yeux, et lire un tel code est un plaisir douteux (je suis d'accord, cela peut être pire). Mais ce serait beaucoup plus agréable et plus visuel d'avoir quelque chose comme ça:


 moveRoutine.Start(); 

 moveRoutine.Stop(); 

  • Chaque fois que vous appelez StartCoroutine vous devez vous rappeler de sauvegarder la valeur de retour:

 moveRoutine = StartCoroutine(MoveRoutine()); 

Sinon, en raison du manque de référence à la coroutine, vous ne pouvez tout simplement pas l'arrêter.


  • Contrôles constants:

 if (moveRoutine == null) 

 if (moveRoutine != null) 

  • Et une autre chose mauvaise dont vous devez toujours vous souvenir (et que je encore oublié spécialement raté). À la toute fin de la coroutine et avant chaque sortie de celle-ci (par exemple, en utilisant la yield break ), il est nécessaire de réinitialiser la valeur du champ.
     moveRoutine = null; 

Si vous oubliez, vous recevrez une coroutine unique. Après la première exécution dans moveRoutine , le lien vers la coroutine restera et la nouvelle exécution ne fonctionnera pas.


De la même manière, vous devez faire en cas d'arrêt forcé:


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

Code avec toutes les modifications
 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; } } 

À un moment donné, je veux vraiment sortir une fois cette mascarade quelque part et ne me laisser que les méthodes nécessaires: Start , Stop et quelques autres événements et propriétés.


Faisons-le enfin!


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

Débriefing

Owner - un lien vers l'instance MonoBehaviour à laquelle la coroutine sera attachée. Comme vous le savez, elle doit être exécutée dans le contexte d'un composant particulier, car c'est à lui qu'appartiennent les méthodes StartCoroutine et StopCoroutine . En conséquence, nous avons besoin d'un lien vers le composant qui sera le propriétaire de la coroutine.


Coroutine - un analogue du champ moveRoutine dans le composant MoveToPoint , contient un lien vers la coroutine actuelle.


Routine - le délégué avec lequel la méthode faisant office de coroutine sera communiquée.


Process() est un petit wrapper sur la méthode Routine principale. Il est nécessaire pour pouvoir tracer lorsque l'exécution de la coroutine est terminée, réinitialiser le lien vers celui-ci et exécuter un autre code à ce moment (si nécessaire).


IsProcessing - vous permet de savoir si la coroutine est en cours d'exécution.


Ainsi, nous nous débarrassons d'une grande quantité de maux de tête, et notre composant prend un aspect complètement différent:


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

Il ne reste que la coroutine elle-même et quelques lignes de code pour travailler avec. Beaucoup mieux.


Supposons qu'une nouvelle tâche soit arrivée - vous devez ajouter la possibilité d'exécuter n'importe quel code une fois que l'objet a atteint son objectif.


Dans la version originale, nous devrions ajouter un paramètre délégué supplémentaire à chaque coroutine qui peut être extraite après son achèvement.


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

Et appelez comme suit:


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

Et s'il y a du lambda en tant que gestionnaire, cela semble encore pire.


Avec notre wrapper, il suffit d'y ajouter une seule fois cet événement.


 public Action Finish; 

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

Et puis, si nécessaire, abonnez-vous.


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

Je crois que vous avez déjà remarqué que la version actuelle du wrapper offre la possibilité de travailler uniquement avec des coroutines sans paramètres. Par conséquent, nous pouvons écrire un wrapper généralisé pour coroutine avec un paramètre. Le reste se fait par analogie.


Mais, pour le bien, ce serait bien de mettre d'abord le code, qui sera le même pour tous les wrappers, dans une classe de base, afin de ne pas écrire la même chose. Nous combattons cela.


Nous le supprimons:


  • Propriétaire des Owner , Coroutine , IsProcessing
  • Événement 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; } 

Wrapper sans paramètre après refactoring
 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; } } } 

Et maintenant, en fait, un wrapper pour coroutine avec un paramètre:


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

Comme vous pouvez le voir, le code est presque le même. Ce n'est qu'à certains endroits que des fragments ont été ajoutés, selon le nombre d'arguments.


Supposons que l'on nous ait demandé de mettre à jour le composant MoveToPoint afin que le point puisse être défini non pas via la fenêtre Inspector dans l'éditeur, mais par code lors de l'appel de la méthode 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; } } } 

Il existe de nombreuses options pour étendre autant que possible les fonctionnalités de ce wrapper: ajouter un lancement retardé, des événements avec des paramètres, un suivi possible de la progression de la coroutine, etc. Mais je suggère d'arrêter à ce stade.


Le but de cet article est le désir de partager les problèmes urgents que j'ai rencontrés et de leur proposer une solution, et non de couvrir les besoins éventuels de tous les développeurs.


J'espère que les camarades débutants et expérimentés bénéficieront de mon expérience. Peut-être partageront-ils leurs commentaires ou signaleront-ils des erreurs que j'aurais pu commettre.


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


All Articles