La référence fréquente Les coroutines d'Unity3D en détail Le lien est mort. Puisqu'il est mentionné dans les commentaires et les réponses, je vais poster le contenu de l'article ici. Ce contenu provient de ce miroir .
Les coroutines d'Unity3D en détail
De nombreux processus dans les jeux se déroulent sur plusieurs images. Il y a les processus "denses", comme le pathfinding, qui travaillent dur à chaque image, mais qui sont répartis sur plusieurs images pour ne pas avoir un impact trop important sur le framerate. Il y a les processus " épars ", comme les déclencheurs de gameplay, qui ne font rien la plupart du temps, mais qui sont occasionnellement appelés à faire un travail critique. Et il y a des processus variés entre les deux.
Lorsque vous créez un processus qui se déroule sur plusieurs images - sans multithreading - vous devez trouver un moyen de diviser le travail en morceaux qui peuvent être exécutés une par image. Pour tout algorithme avec une boucle centrale, c'est assez évident : un pathfinder A*, par exemple, peut être structuré de telle sorte qu'il maintienne ses listes de nœuds de manière semi-permanente, en ne traitant qu'une poignée de nœuds de la liste ouverte à chaque image, au lieu d'essayer de faire tout le travail en une seule fois. Il faut trouver un équilibre pour gérer la latence. Après tout, si vous bloquez votre fréquence d'images à 60 ou 30 images par seconde, votre processus ne prendra que 60 ou 30 étapes par seconde, ce qui peut rendre le processus trop long dans l'ensemble. Une conception soignée pourrait offrir la plus petite unité de travail possible à un niveau - par exemple, traiter un seul nœud A* - et superposer un moyen de regrouper le travail en plus gros morceaux - par exemple, continuer à traiter les nœuds A* pendant X millisecondes. (Certains appellent cela le "timeslicing", mais pas moi).
Pourtant, permettre à l'œuvre d'être divisée de cette manière signifie que vous devez transférer l'état d'un cadre à l'autre. Si vous décomposez un algorithme itératif, vous devez conserver tous les états partagés entre les itérations, ainsi qu'un moyen de savoir quelle itération doit être exécutée ensuite. En général, ce n'est pas trop grave - la conception d'une "classe d'éclaireur A*" est assez évidente - mais il existe d'autres cas moins agréables. Parfois, vous serez confronté à de longs calculs qui effectuent différents types de travail d'une image à l'autre ; l'objet capturant leur état peut se retrouver avec un grand nombre de " locals " semi-utiles, conservés pour le passage des données d'une image à l'autre. Et si vous avez affaire à un processus épars, vous finissez souvent par devoir implémenter une petite machine d'état juste pour savoir quand le travail doit être effectué.
Ne serait-ce pas génial si, au lieu de devoir suivre explicitement tout cet état sur plusieurs images, et au lieu de devoir faire du multithread et gérer la synchronisation et le verrouillage, etc., vous pouviez simplement écrire votre fonction comme un seul morceau de code, et marquer des endroits particuliers où la fonction doit faire une pause et continuer plus tard ?
Unity - ainsi qu'un certain nombre d'autres environnements et langages - fournit cela sous la forme de Coroutines.
De quoi ont-ils l'air ? En "Unityscript" (Javascript) :
function LongComputation()
{
while(someCondition)
{
/* Do a chunk of work */
// Pause here and carry on next frame
yield;
}
}
En C# :
IEnumerator LongComputation()
{
while(someCondition)
{
/* Do a chunk of work */
// Pause here and carry on next frame
yield return null;
}
}
Comment fonctionnent-ils ? Laissez-moi vous dire, rapidement, que je ne travaille pas pour Unity Technologies. Je n'ai pas vu le code source d'Unity. Je n'ai jamais vu les entrailles du moteur de coroutine de Unity. Cependant, s'ils l'ont implémenté d'une manière radicalement différente de ce que je suis sur le point de décrire, alors je serai assez surpris. Si quelqu'un d'UT veut intervenir et parler de la façon dont cela fonctionne réellement, ce serait génial.
Les grands indices se trouvent dans la version C#. Tout d'abord, notez que le type de retour de la fonction est IEnumerator. Ensuite, notez que l'une des instructions est yield. return. Cela signifie que yield doit être un mot-clé, et comme le support C# d'Unity est vanilla C# 3.5, ce doit être un mot-clé vanilla C# 3.5. En effet, le voici dans MSDN - parler de quelque chose appelé "blocs itérateurs". Alors, qu'est-ce qui se passe ?
Tout d'abord, il y a ce type IEnumerator. Le type IEnumerator agit comme un curseur sur une séquence, fournissant deux membres importants : Current, qui est une propriété indiquant l'élément sur lequel le curseur se trouve actuellement, et MoveNext(), une fonction qui permet de passer à l'élément suivant de la séquence. Parce que IEnumerator est une interface, elle ne spécifie pas exactement comment ces membres sont implémentés ; MoveNext() pourrait simplement ajouter une valeur à Current, ou elle pourrait charger la nouvelle valeur à partir d'un fichier, ou elle pourrait télécharger une image à partir d'Internet, la hacher et stocker le nouveau hachage dans Current ou elle pourrait même faire une chose pour le premier élément de la séquence, et quelque chose de complètement différent pour le second. Vous pourriez même l'utiliser pour générer une séquence infinie si vous le souhaitez. MoveNext() calcule la valeur suivante dans la séquence (en retournant false s'il n'y a plus de valeurs), et Current récupère la valeur qu'elle a calculée.
Normalement, si vous voulez implémenter une interface, vous devez écrire une classe, implémenter les membres, et ainsi de suite. Les blocs Iterator sont un moyen pratique d'implémenter IEnumerator sans tous ces tracas - il suffit de suivre quelques règles, et l'implémentation de IEnumerator est générée automatiquement par le compilateur.
Un bloc d'itérateur est une fonction régulière qui (a) renvoie IEnumerator, et (b) utilise le mot-clé yield. Que fait réellement le mot-clé yield ? Il déclare quelle est la valeur suivante dans la séquence - ou qu'il n'y a plus de valeurs. Le moment où le code rencontre un yield return X ou yield break est le point où IEnumerator.MoveNext() doit s'arrêter ; un yield return X fait que MoveNext() renvoie vrai et que la valeur X est attribuée àCurrent, tandis qu'un yield break fait en sorte que MoveNext() renvoie false.
Maintenant, voici l'astuce. Les valeurs réelles retournées par la séquence n'ont pas d'importance. Vous pouvez appeler MoveNext() à plusieurs reprises, et ignorer Current ; les calculs seront toujours effectués. À chaque fois que MoveNext() est appelé, votre bloc d'itérateurs se déplace jusqu'à l'instruction 'yield' suivante, quelle que soit l'expression qu'elle renvoie réellement. Vous pouvez donc écrire quelque chose comme :
IEnumerator TellMeASecret()
{
PlayAnimation("LeanInConspiratorially");
while(playingAnimation)
yield return null;
Say("I stole the cookie from the cookie jar!");
while(speaking)
yield return null;
PlayAnimation("LeanOutRelieved");
while(playingAnimation)
yield return null;
}
et ce que vous avez réellement écrit est un bloc itérateur qui génère une longue séquence de valeurs nulles, mais ce qui est significatif, ce sont les effets secondaires du travail qu'il effectue pour les calculer. Vous pourriez exécuter cette coroutine en utilisant une simple boucle comme celle-ci :
IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }
Ou, plus utilement, vous pouvez le mélanger à d'autres travaux :
IEnumerator e = TellMeASecret();
while(e.MoveNext())
{
// If they press 'Escape', skip the cutscene
if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}
Tout est dans le timing Comme vous l'avez vu, chaque déclaration yield return doit fournir une expression (comme null) afin que le bloc itérateur ait quelque chose à assigner à IEnumerator.Current. Une longue séquence de nullités n'est pas vraiment utile, mais nous sommes plus intéressés par les effets secondaires. N'est-ce pas ?
Il y a quelque chose de pratique que nous pouvons faire avec cette expression, en fait. Et si, au lieu de donner simplement null et de l'ignorer, nous produisions quelque chose qui nous indique quand nous nous attendons à devoir faire plus de travail ? Souvent, nous aurons besoin de continuer à l'image suivante, bien sûr, mais pas toujours : il y aura de nombreuses fois où nous voudrons continuer après qu'une animation ou un son ait fini de jouer, ou après qu'un certain temps se soit écoulé. Ces while(playingAnimation) yield return null ; sont un peu fastidieuses, vous ne trouvez pas ?
Unity déclare le type de base YieldInstruction, et fournit quelques types dérivés concrets qui indiquent des types particuliers d'attente. Il y a WaitForSeconds, qui reprend la coroutine après que le temps indiqué se soit écoulé. Il y a WaitForEndOfFrame, qui reprend la coroutine à un point particulier plus tard dans la même trame. Vous avez le type Coroutine lui-même, qui, lorsque la coroutine A produit la coroutine B, met en pause la coroutine A jusqu'à ce que la coroutine B soit terminée.
À quoi cela ressemble-t-il du point de vue de l'exécution ? Comme je l'ai dit, je ne travaille pas pour Unity, je n'ai donc jamais vu leur code, mais j'imagine qu'il ressemble un peu à ça :
List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;
foreach(IEnumerator coroutine in unblockedCoroutines)
{
if(!coroutine.MoveNext())
// This coroutine has finished
continue;
if(!coroutine.Current is YieldInstruction)
{
// This coroutine yielded null, or some other value we don't understand; run it next frame.
shouldRunNextFrame.Add(coroutine);
continue;
}
if(coroutine.Current is WaitForSeconds)
{
WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
}
else if(coroutine.Current is WaitForEndOfFrame)
{
shouldRunAtEndOfFrame.Add(coroutine);
}
else /* similar stuff for other YieldInstruction subtypes */
}
unblockedCoroutines = shouldRunNextFrame;
Il n'est pas difficile d'imaginer comment d'autres sous-types de YieldInstruction pourraient être ajoutés pour gérer d'autres cas - le support des signaux au niveau du moteur, par exemple, pourrait être ajouté, avec une YieldInstruction WaitForSignal("SignalName") le supportant. En ajoutant plus de YieldInstructions, les coroutines elles-mêmes peuvent devenir plus expressives - yield return new WaitForSignal("GameOver") est plus agréable à lire quewhile(!Signals.HasFired("GameOver")) yield return null, si vous voulez mon avis, en dehors du fait que le faire dans le moteur pourrait être plus rapide que de le faire en script.
Quelques ramifications non évidentes Il y a deux ou trois choses utiles dans tout cela que les gens oublient parfois et que j'ai pensé devoir souligner.
Tout d'abord, le rendement est juste le rendement d'une expression - n'importe quelle expression - et YieldInstruction est un type régulier. Cela signifie que vous pouvez faire des choses comme :
YieldInstruction y;
if(something)
y = null;
else if(somethingElse)
y = new WaitForEndOfFrame();
else
y = new WaitForSeconds(1.0f);
yield return y;
Les lignes spécifiques yield return new WaitForSeconds(), yield return new WaitForEndOfFrame(), etc., sont courantes, mais elles ne sont pas vraiment des formes spéciales en soi.
Deuxièmement, comme ces coroutines ne sont que des blocs d'itérateurs, vous pouvez les itérer vous-même si vous le souhaitez - vous n'avez pas besoin que le moteur le fasse pour vous. J'ai déjà utilisé cette méthode pour ajouter des conditions d'interruption à une coroutine :
IEnumerator DoSomething()
{
/* ... */
}
IEnumerator DoSomethingUnlessInterrupted()
{
IEnumerator e = DoSomething();
bool interrupted = false;
while(!interrupted)
{
e.MoveNext();
yield return e.Current;
interrupted = HasBeenInterrupted();
}
}
Troisièmement, le fait que vous puissiez céder sur d'autres coroutines peut vous permettre d'implémenter vos propres YieldInstructions, même si elles ne sont pas aussi performantes que si elles étaient implémentées par le moteur. Par exemple :
IEnumerator UntilTrueCoroutine(Func fn)
{
while(!fn()) yield return null;
}
Coroutine UntilTrue(Func fn)
{
return StartCoroutine(UntilTrueCoroutine(fn));
}
IEnumerator SomeTask()
{
/* ... */
yield return UntilTrue(() => _lives < 3);
/* ... */
}
Cependant, je ne le recommanderais pas vraiment - le coût de démarrage d'une Coroutine est un peu lourd à mon goût.
Conclusion J'espère que cela clarifie un peu ce qui se passe réellement lorsque vous utilisez une Coroutine dans Unity. Les blocs itérateurs de C# sont une petite construction intéressante, et même si vous n'utilisez pas Unity, vous trouverez peut-être utile d'en tirer parti de la même manière.