Jeder Entwickler findet seine Vor- und Nachteile bei der Verwendung von Coroutine in Unity. Und er entscheidet, in welchen Szenarien er sie anwendet und in welchen Alternativen bevorzugt wird.
In der täglichen Praxis verwende ich häufig Coroutinen für verschiedene Arten von Aufgaben. Einmal wurde mir klar, dass ich es war, der viele Neuankömmlinge in ihnen verärgerte und ablehnte.
Nightmare-Schnittstelle
Die Engine bietet nur einige Methoden und einige ihrer Überlastungen für die Arbeit mit Coroutinen:
Starten ( docs )
StartCoroutine(string name, object value = null)
StartCoroutine(IEnumerator routine)
Stop ( docs )
StopCoroutine(string methodName)
StopCoroutine(IEnumerator routine)
StopCoroutine(Coroutine routine)
Überladungen mit String-Parametern (trotz ihrer trügerischen Bequemlichkeit) können sofort auftreten in den Müll werfen aus mindestens drei Gründen vergessen.
- Die explizite Verwendung von String-Methodennamen erschwert die zukünftige Code-Analyse, das Debuggen und vieles mehr.
- Laut Dokumentation dauern Stringüberladungen länger und erlauben nur die Übergabe eines zusätzlichen Parameters.
- In meiner Praxis stellte sich ziemlich oft heraus, dass beim
StopCoroutine
einer StopCoroutine
nichts passiert ist. Corutin wurde weiterhin hingerichtet.
Einerseits reichen die angebotenen Methoden aus, um die Grundbedürfnisse abzudecken. Aber im Laufe der Zeit bemerkte ich, dass ich bei aktiver Nutzung eine große Menge Boilerplate-Code schreiben muss - dies ist anstrengend und beeinträchtigt die Lesbarkeit.
Näher am Punkt
In diesem Artikel möchte ich einen kleinen Wrapper beschreiben, den ich schon lange benutze. Dank ihr, mit Gedanken an Coroutinen, erscheinen keine Fragmente des Vorlagencodes mehr in meinem Kopf, mit denen ich um sie herum tanzen musste. Darüber hinaus ist es für das gesamte Team einfacher geworden, die Komponenten zu lesen und zu verstehen, in denen Coroutinen verwendet werden.
Angenommen, wir haben die folgende Aufgabe: Schreiben einer Komponente, mit der Sie ein Objekt an einen bestimmten Punkt verschieben können.
In diesem Moment spielt es keine Rolle, welche Methode zum Bewegen verwendet wird und in welchen Koordinaten. Nur eine der vielen Optionen wird von mir ausgewählt - dies sind Interpolation und globale Koordinaten.
Bitte beachten Sie, dass es dringend davon abgeraten wird, das Objekt durch Ändern seiner Koordinaten „Stirn“, transform.position = newPosition
des Konstrukts transform.position = newPosition
wenn die RigidBody
Komponente damit verwendet wird (insbesondere in der Update
Methode) ( docs ).
Standardimplementierung
Ich schlage die folgende Implementierungsoption für die erforderliche Komponente vor:
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; } } }
Ein bisschen über CodeBei der Move
Methode ist es sehr wichtig, Coroutine nur auszuführen, wenn sie noch nicht ausgeführt wird. Andernfalls können sie beliebig viele gestartet werden und jeder von ihnen bewegt das Objekt.
threshold
. Mit anderen Worten, die Entfernung zum Punkt, bei der wir davon ausgehen, dass wir das Ziel erreicht haben.
Wofür ist es?
Angesichts der Tatsache, dass alle Komponenten ( x
, y
, z
) der Vector3
Struktur vom Typ float
, ist es eine schlechte Idee , das Ergebnis der Überprüfung der Entfernung zum Ziel und der Toleranz als Schleifenbedingung zu verwenden.
Wir überprüfen die Entfernung zum Ziel auf mehr / weniger, um dieses Problem zu vermeiden.
Falls gewünscht, können Sie auch die Mathf.Approximately
( docs ) -Methode für eine ungefähre Überprüfung der Gleichheit verwenden. Es ist erwähnenswert, dass sich bei einigen Bewegungsmethoden die Geschwindigkeit als groß genug herausstellen kann, damit das Objekt das Ziel in einem Frame „springt“. Dann wird der Zyklus niemals enden. Zum Beispiel, wenn Sie die Vector3.MoveTowards
Methode verwenden.
Vector3
ich weiß, ist in der Unity-Engine für die Vector3
Struktur der Operator Vector3
bereits so neu definiert, dass Mathf.Approximately
aufgerufen wird, um die komponentenweise Gleichheit zu überprüfen.
Das ist alles für den Moment, unsere Komponente ist ganz einfach. Und im Moment gibt es keine Probleme. Was ist diese Komponente, mit der Sie ein Objekt an einen Punkt verschieben können, die jedoch keine Möglichkeit bietet, es zu stoppen? Lassen Sie uns diese Ungerechtigkeit beheben.
Da Sie und ich beschlossen haben, nicht auf die böse Seite zu gehen und keine Überladungen mit Zeichenfolgenparametern zu verwenden, müssen wir jetzt irgendwo einen Link zur laufenden Coroutine speichern. Wie kann man sie sonst aufhalten?
Fügen Sie ein Feld hinzu:
private Coroutine moveRoutine;
Fix Move
:
public void Move() { if (moveRoutine == null) moveRoutine = StartCoroutine(MoveRoutine()); }
Fügen Sie eine Stop-Motion-Methode hinzu:
public void Stop() { if (moveRoutine != null) StopCoroutine(moveRoutine); }
Ganzer Code 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; } } }
Eine ganz andere Sache! Zumindest auf die Wunde auftragen.
Also. Wir haben eine kleine Komponente, die die Aufgabe ausführt. Was ist meine Empörung?
Probleme und ihre Lösung
Mit der Zeit wächst das Projekt und damit die Anzahl der Komponenten, einschließlich der Komponenten, die Coroutinen verwenden. Und jedes Mal verfolgen mich diese Dinge mehr und mehr:
StartCoroutine(MoveRoutine());
StopCoroutine(moveRoutine);
Wenn ich sie nur ansehe, zuckt mein Auge, und das Lesen eines solchen Codes ist ein zweifelhaftes Vergnügen (ich stimme zu, es kann schlimmer sein). Aber es wäre viel schöner und visueller, so etwas zu haben:
moveRoutine.Start();
moveRoutine.Stop();
- Bei jedem Aufruf von
StartCoroutine
Sie daran denken, den Rückgabewert zu speichern:
moveRoutine = StartCoroutine(MoveRoutine());
Andernfalls können Sie es aufgrund des fehlenden Verweises auf Coroutine einfach nicht aufhalten.
if (moveRoutine == null)
if (moveRoutine != null)
- Und noch eine böse Sache, an die du dich immer erinnern musst (und an die ich
wieder vergessen besonders vermisst). Ganz am Ende der Coroutine und vor jedem Verlassen der Coroutine (z. B. mit yield break
) muss der Feldwert zurückgesetzt werden.
moveRoutine = null;
Wenn Sie vergessen, erhalten Sie eine einmalige Coroutine. Nach dem ersten Lauf in moveRoutine
die Verbindung zur Coroutine bestehen und der neue Lauf funktioniert nicht.
Auf die gleiche Weise müssen Sie im Falle eines erzwungenen Stopps Folgendes tun:
public void Stop() { if (moveRoutine != null) { StopCoroutine(moveRoutine); moveRoutine = null; } }
Code mit allen Änderungen 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; } }
Irgendwann möchte ich wirklich einmal irgendwo diese ganze Maskerade herausnehmen und mir nur die notwendigen Methoden überlassen: Start
, Stop
und ein paar weitere Ereignisse und Eigenschaften.
Lass es uns endlich tun!
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; } } }
NachbesprechungOwner
- Ein Link zur MonoBehaviour
Instanz, an die die Coroutine angehängt wird. Wie Sie wissen, muss es im Kontext einer bestimmten Komponente ausgeführt werden, da ihm die Methoden StartCoroutine
und StopCoroutine
. Dementsprechend benötigen wir einen Link zu der Komponente, die der Eigentümer der Coroutine sein wird.
Coroutine
- Ein Analogon zum Feld moveRoutine
in der MoveToPoint
Komponente enthält einen Link zur aktuellen Coroutine.
Routine
- der Delegierte, mit dem die als Coroutine fungierende Methode kommuniziert wird.
Process()
ist ein kleiner Wrapper über die Hauptroutinenmethode. Es ist erforderlich, um nachverfolgen zu können, wann die Ausführung von Coroutine abgeschlossen ist, die Verknüpfung zurückzusetzen und in diesem Moment einen anderen Code auszuführen (falls erforderlich).
IsProcessing
- Ermöglicht es Ihnen herauszufinden, ob Coroutine gerade ausgeführt wird.
So werden wir eine Menge Kopfschmerzen los und unsere Komponente sieht ganz anders aus:
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; } } }
Alles, was bleibt, ist Coroutine selbst und ein paar Codezeilen, um damit zu arbeiten. Deutlich besser.
Angenommen, eine neue Aufgabe ist gekommen - Sie müssen die Möglichkeit hinzufügen, beliebigen Code auszuführen, nachdem das Objekt sein Ziel erreicht hat.
In der Originalversion müssten wir jeder Coroutine einen zusätzlichen Delegate-Parameter hinzufügen, der nach Abschluss abgerufen werden kann.
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(); }
Und rufen Sie wie folgt an:
moveRoutine = StartCoroutine(moveRoutine(CallbackHandler)); private void CallbackHandler() {
Und wenn es etwas Lambda als Handler gibt, dann sieht es noch schlimmer aus.
Mit unserem Wrapper reicht es aus, dieses Ereignis nur einmal hinzuzufügen.
public Action Finish;
private IEnumerator Process() { yield return Routine.Invoke(); Coroutine = null; Finish?.Invoke(); }
Und dann, wenn nötig, abonnieren.
moveRoutine.Finished += OnFinish; private void OnFinish() {
Ich glaube, Sie haben bereits bemerkt, dass die aktuelle Version des Wrappers die Möglichkeit bietet, nur mit Coroutinen ohne Parameter zu arbeiten. Daher können wir einen verallgemeinerten Wrapper für Coroutine mit einem Parameter schreiben. Der Rest erfolgt analog.
Aber zum Guten wäre es schön, zuerst den Code, der für alle Wrapper gleich ist, in eine Basisklasse zu setzen, um nicht dasselbe zu schreiben. Wir bekämpfen das.
Wir entfernen es:
Owner
, Coroutine
, IsProcessing
Finished
Veranstaltung
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; }
Parameterloser Wrapper nach dem 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; } } }
Und jetzt tatsächlich ein Wrapper für Coroutine mit einem Parameter:
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; } } }
Wie Sie sehen können, ist der Code fast der gleiche. Nur an einigen Stellen wurden Fragmente hinzugefügt, abhängig von der Anzahl der Argumente.
Angenommen, wir wurden gebeten, die MoveToPoint-Komponente zu aktualisieren, damit der Punkt nicht über das Inspector
im Editor festgelegt werden kann, sondern per Code, wenn die Move
Methode aufgerufen wird.
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; } } }
Es gibt viele Optionen, um die Funktionalität dieses Wrappers so weit wie möglich zu erweitern: Hinzufügen eines verzögerten Starts, Ereignisse mit Parametern, mögliche Verfolgung des Coroutine-Fortschritts und mehr. Aber ich schlage vor, an dieser Stelle anzuhalten.
Der Zweck dieses Artikels ist der Wunsch, die dringenden Probleme, auf die ich gestoßen bin, zu teilen und eine Lösung für sie vorzuschlagen, und nicht die möglichen Bedürfnisse aller Entwickler abzudecken.
Ich hoffe, dass sowohl Anfänger als auch erfahrene Kameraden von meiner Erfahrung profitieren werden. Vielleicht teilen sie ihre Kommentare mit oder weisen auf Fehler hin, die ich hätte machen können.