Der häufig referenzierte Link zu Unity3D-Coroutinen im Detail ist tot. Da es in den Kommentaren und Antworten erwähnt wird, werde ich den Inhalt des Artikels hier veröffentlichen. Dieser Inhalt stammt aus diesem Spiegel .
Unity3D-Coroutinen im Detail
Viele Prozesse in Spielen finden im Verlauf mehrerer Frames statt. Sie haben "dichte" Prozesse wie die Pfadfindung, die in jedem Frame hart arbeiten, aber auf mehrere Frames aufgeteilt werden, um die Framerate nicht zu stark zu beeinflussen. Sie haben "spärliche" Prozesse, wie z. B. Gameplay-Trigger, die in den meisten Frames nichts bewirken, aber gelegentlich zu kritischer Arbeit aufgefordert werden. Und Sie haben verschiedene Prozesse zwischen den beiden.
Wenn Sie einen Prozess erstellen, der über mehrere Frames hinweg ausgeführt wird - ohne Multithreading -, müssen Sie eine Möglichkeit finden, die Arbeit in Blöcke aufzuteilen, die einzeln pro Frame ausgeführt werden können. Für jeden Algorithmus mit einer zentralen Schleife ist es ziemlich offensichtlich: Ein A * -Pfadfinder kann beispielsweise so strukturiert werden, dass er seine Knotenlisten semipermanent verwaltet und nur eine Handvoll Knoten aus der offenen Liste jedes Frames verarbeitet, anstatt es zu versuchen die ganze Arbeit auf einmal erledigen. Es muss ein gewisser Ausgleich vorgenommen werden, um die Latenz zu verwalten. Wenn Sie Ihre Framerate auf 60 oder 30 Bilder pro Sekunde beschränken, dauert Ihr Prozess nur 60 oder 30 Schritte pro Sekunde, und dies kann dazu führen, dass der Prozess nur dauert insgesamt zu lang. Ein ordentliches Design bietet möglicherweise die kleinstmögliche Arbeitseinheit auf einer Ebene - z Verarbeiten Sie einen einzelnen A * -Knoten - und überlagern Sie ihn, um die Arbeit in größere Blöcke zu gruppieren - z. B. verarbeiten Sie A * -Knoten weiterhin X Millisekunden lang. (Einige Leute nennen dies "Timeslicing", obwohl ich es nicht tue).
Wenn Sie jedoch zulassen, dass die Arbeit auf diese Weise aufgeteilt wird, müssen Sie den Status von einem Frame zum nächsten übertragen. Wenn Sie einen iterativen Algorithmus auflösen, müssen Sie den gesamten Status beibehalten, der über die Iterationen hinweg gemeinsam genutzt wird, sowie nachverfolgen, welche Iteration als Nächstes ausgeführt werden soll. Das ist normalerweise nicht schlecht - das Design einer 'A * Pathfinder-Klasse' ist ziemlich offensichtlich - aber es gibt auch andere Fälle, die weniger angenehm sind. Manchmal stehen Sie vor langen Berechnungen, die von Frame zu Frame unterschiedliche Arbeiten ausführen. Das Objekt, das seinen Status erfasst, kann zu einem großen Durcheinander von nützlichen "Einheimischen" führen, die für die Weitergabe von Daten von einem Frame zum nächsten aufbewahrt werden. Und wenn Sie mit einem spärlichen Prozess zu tun haben, müssen Sie häufig eine kleine Zustandsmaschine implementieren, um zu verfolgen, wann überhaupt gearbeitet werden sollte.
Wäre es nicht ordentlich, wenn Sie, anstatt all diesen Status explizit über mehrere Frames hinweg verfolgen zu müssen und anstatt Multithread zu betreiben und Synchronisation und Sperren usw. zu verwalten, Ihre Funktion einfach als einen einzelnen Codeabschnitt schreiben könnten, und Markieren Sie bestimmte Stellen, an denen die Funktion angehalten und zu einem späteren Zeitpunkt fortgesetzt werden soll.
Unity bietet dies - zusammen mit einer Reihe anderer Umgebungen und Sprachen - in Form von Coroutinen.
Wie sehen sie aus? In "Unityscript" (Javascript):
function LongComputation()
{
while(someCondition)
{
/* Do a chunk of work */
// Pause here and carry on next frame
yield;
}
}
In C #:
IEnumerator LongComputation()
{
while(someCondition)
{
/* Do a chunk of work */
// Pause here and carry on next frame
yield return null;
}
}
Wie arbeiten Sie? Lassen Sie mich kurz sagen, dass ich nicht für Unity Technologies arbeite. Ich habe den Unity-Quellcode nicht gesehen. Ich habe noch nie den Mut von Unitys Coroutine-Engine gesehen. Wenn sie es jedoch auf eine Weise implementiert haben, die sich grundlegend von dem unterscheidet, was ich beschreiben werde, bin ich ziemlich überrascht. Wenn sich jemand von UT einschalten und darüber sprechen möchte, wie es tatsächlich funktioniert, dann wäre das großartig.
Die großen Hinweise sind in der C # -Version. Beachten Sie zunächst, dass der Rückgabetyp für die Funktion IEnumerator ist. Und zweitens ist zu beachten, dass eine der Aussagen die Rendite ist. Dies bedeutet, dass Yield ein Schlüsselwort sein muss. Da die C # -Unterstützung von Unity Vanilla C # 3.5 ist, muss es sich um ein Vanilla C # 3.5-Schlüsselwort handeln. In der Tat ist es hier in MSDN - es spricht von etwas, das als "Iteratorblöcke" bezeichnet wird. So was ist los?
Erstens gibt es diesen IEnumerator-Typ. Der IEnumerator-Typ verhält sich wie ein Cursor über einer Sequenz und stellt zwei wichtige Elemente bereit: Current, eine Eigenschaft, die Ihnen das Element angibt, über dem sich der Cursor derzeit befindet, und MoveNext (), eine Funktion, die zum nächsten Element in der Sequenz wechselt. Da IEnumerator eine Schnittstelle ist, wird nicht genau angegeben, wie diese Mitglieder implementiert werden. MoveNext () könnte einfach einen zu Current hinzufügen, oder es könnte den neuen Wert aus einer Datei laden, oder es könnte ein Bild aus dem Internet herunterladen und es hashen und den neuen Hash in Current speichern ... oder es könnte sogar eines für das erste tun Element in der Sequenz und etwas ganz anderes für die Sekunde. Sie können es sogar verwenden, um eine unendliche Sequenz zu generieren, wenn Sie dies wünschen. MoveNext () berechnet den nächsten Wert in der Sequenz (gibt false zurück, wenn keine weiteren Werte vorhanden sind).
Wenn Sie eine Schnittstelle implementieren möchten, müssen Sie normalerweise eine Klasse schreiben, die Mitglieder implementieren usw. Iteratorblöcke sind eine bequeme Möglichkeit, IEnumerator ohne großen Aufwand zu implementieren. Sie befolgen nur einige Regeln, und die IEnumerator-Implementierung wird vom Compiler automatisch generiert.
Ein Iteratorblock ist eine reguläre Funktion, die (a) IEnumerator zurückgibt und (b) das Schlüsselwort yield verwendet. Was macht das Yield-Keyword eigentlich? Es gibt an, was der nächste Wert in der Sequenz ist - oder dass es keine weiteren Werte gibt. Der Punkt, an dem der Code auf eine Ertragsrendite X oder eine Ertragsunterbrechung stößt, ist der Punkt, an dem IEnumerator.MoveNext () anhalten sollte. Eine Ertragsrückgabe X bewirkt, dass MoveNext () true zurückgibt und Current der Wert X zugewiesen wird, während eine Ertragsunterbrechung bewirkt, dass MoveNext () false zurückgibt.
Hier ist der Trick. Es muss keine Rolle spielen, welche tatsächlichen Werte von der Sequenz zurückgegeben werden. Sie können MoveNext () wiederholt aufrufen und Current ignorieren. Die Berechnungen werden weiterhin durchgeführt. Jedes Mal, wenn MoveNext () aufgerufen wird, wird Ihr Iteratorblock zur nächsten 'Yield'-Anweisung ausgeführt, unabhängig davon, welchen Ausdruck er tatsächlich liefert. So können Sie etwas schreiben wie:
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;
}
und was Sie tatsächlich geschrieben haben, ist ein Iteratorblock, der eine lange Folge von Nullwerten generiert. Was jedoch von Bedeutung ist, sind die Nebenwirkungen der Arbeit, die er zur Berechnung dieser Werte leistet. Sie können diese Coroutine mit einer einfachen Schleife wie der folgenden ausführen:
IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }
Oder, nützlicher, Sie könnten es mit anderen Arbeiten mischen:
IEnumerator e = TellMeASecret();
while(e.MoveNext())
{
// If they press 'Escape', skip the cutscene
if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}
Wie Sie gesehen haben, muss jede Yield Return-Anweisung einen Ausdruck (wie null) enthalten, damit der Iteratorblock IEnumerator.Current tatsächlich etwas zuweisen kann. Eine lange Folge von Nullen ist nicht gerade nützlich, aber wir sind mehr an den Nebenwirkungen interessiert. Sind wir nicht?
Mit diesem Ausdruck können wir tatsächlich etwas Praktisches anfangen. Was wäre, wenn wir, anstatt nur Null zu geben und es zu ignorieren, etwas liefern würden, das anzeigt, wann wir voraussichtlich mehr Arbeit leisten müssen? Oft müssen wir sicher mit dem nächsten Frame weitermachen, aber nicht immer: Es wird viele Male geben, in denen wir weitermachen möchten, nachdem eine Animation oder ein Sound abgespielt wurde oder nachdem eine bestimmte Zeit verstrichen ist. Diejenigen, die while (playAnimation) ergeben, geben null zurück. Konstrukte sind etwas langweilig, findest du nicht?
Unity deklariert den YieldInstruction-Basistyp und stellt einige konkrete abgeleitete Typen bereit, die bestimmte Arten von Wartezeiten angeben. Sie haben WaitForSeconds, mit dem die Coroutine nach Ablauf der festgelegten Zeit fortgesetzt wird. Sie haben WaitForEndOfFrame, mit dem die Coroutine zu einem bestimmten Zeitpunkt später im selben Frame fortgesetzt wird. Sie haben den Coroutine-Typ selbst, der, wenn Coroutine A Coroutine B ergibt, Coroutine A pausiert, bis Coroutine B beendet ist.
Wie sieht das aus Sicht der Laufzeit aus? Wie gesagt, ich arbeite nicht für Unity, deshalb habe ich ihren Code nie gesehen. aber ich würde mir vorstellen, dass es ein bisschen so aussehen könnte:
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;
Es ist nicht schwer vorstellbar, wie weitere YieldInstruction-Subtypen hinzugefügt werden könnten, um andere Fälle zu behandeln. Beispielsweise könnte die Unterstützung von Signalen auf Engine-Ebene hinzugefügt werden, wobei eine YieldInstruction von WaitForSignal ("SignalName") dies unterstützt. Durch Hinzufügen weiterer YieldInstructions können die Coroutinen selbst ausdrucksvoller werden - Yield Return New WaitForSignal ("GameOver") ist besser zu lesen als während (! Signals.HasFired ("GameOver")) Yield Return Null, wenn Sie mich fragen, ganz abgesehen davon die Tatsache, dass es in der Engine schneller sein könnte als in einem Skript.
Ein paar nicht offensichtliche Konsequenzen Es gibt ein paar nützliche Dinge an all dem, die die Leute manchmal vermissen, und ich dachte, ich sollte darauf hinweisen.
Erstens liefert YieldInstruction nur einen Ausdruck - einen beliebigen Ausdruck - und YieldInstruction ist ein regulärer Typ. Dies bedeutet, dass Sie Dinge tun können wie:
YieldInstruction y;
if(something)
y = null;
else if(somethingElse)
y = new WaitForEndOfFrame();
else
y = new WaitForSeconds(1.0f);
yield return y;
Die spezifischen Zeilen ergeben return new WaitForSeconds (), return return new WaitForEndOfFrame () usw. sind häufig, aber keine eigenständigen Sonderformen.
Zweitens, da diese Coroutinen nur Iteratorblöcke sind, können Sie sie selbst durchlaufen, wenn Sie möchten - Sie müssen die Engine nicht für Sie erledigen lassen. Ich habe dies verwendet, um einer Coroutine zuvor Interrupt-Bedingungen hinzuzufügen:
IEnumerator DoSomething()
{
/* ... */
}
IEnumerator DoSomethingUnlessInterrupted()
{
IEnumerator e = DoSomething();
bool interrupted = false;
while(!interrupted)
{
e.MoveNext();
yield return e.Current;
interrupted = HasBeenInterrupted();
}
}
Drittens kann die Tatsache, dass Sie anderen Coroutinen nachgeben können, es Ihnen ermöglichen, Ihre eigenen YieldInstructions zu implementieren, wenn auch nicht so leistungsfähig, als ob sie von der Engine implementiert würden. Beispielsweise:
IEnumerator UntilTrueCoroutine(Func fn)
{
while(!fn()) yield return null;
}
Coroutine UntilTrue(Func fn)
{
return StartCoroutine(UntilTrueCoroutine(fn));
}
IEnumerator SomeTask()
{
/* ... */
yield return UntilTrue(() => _lives < 3);
/* ... */
}
Ich würde dies jedoch nicht wirklich empfehlen - die Kosten für den Start einer Coroutine sind für meinen Geschmack etwas hoch.
Fazit Ich hoffe, dies verdeutlicht ein wenig, was wirklich passiert, wenn Sie eine Coroutine in Unity verwenden. Die Iteratorblöcke von C # sind ein tolles kleines Konstrukt, und selbst wenn Sie Unity nicht verwenden, ist es möglicherweise nützlich, sie auf die gleiche Weise zu nutzen.
IEnumerator
/IEnumerable
(oder die generischen Entsprechungen) zurückgeben und dasyield
Schlüsselwort enthalten . Nach Iteratoren suchen.