Einige Aufgaben in der Spieleentwicklung sind nicht synchron, sondern asynchron. Dies bedeutet, dass sie nicht linear innerhalb des Spielcodes ausgeführt werden. Einige dieser asynchronen Aufgaben können recht lange dauern, während andere mit intensiven Berechnungen verbunden sind.
Durchführen von Netzwerkanfragen
Laden von Szenen, Ressourcen und anderen Assets
Dateien lesen und schreiben
Künstliche Intelligenz zur Entscheidungsfindung
Lange Animationssequenzen
Verarbeitung großer Datenmengen
Wegfindung
Da nun der gesamte Unity-Code in einem Thread ausgeführt wird, ist es von entscheidender Bedeutung, dass jede Aufgabe wie eine der oben genannten, wenn sie synchron ausgeführt würde, zur Blockierung des Hauptthreads und damit zu Frame-Drops führen würde.
Hallo zusammen, mein Name ist Dmitrii Ivashchenko und ich bin der Leiter des Entwicklungsteams bei MY.GAMES. In diesem Artikel werden wir über die Vermeidung solcher Probleme sprechen. Wir empfehlen asynchrone Programmiertechniken, um diese Aufgaben in einem separaten Thread auszuführen, sodass der Hauptthread frei bleibt, um andere Aufgaben auszuführen. Dies wird dazu beitragen, ein reibungsloses und reaktionsschnelles Gameplay und (hoffentlich) zufriedene Spieler zu gewährleisten.
Lassen Sie uns zunächst über Coroutinen sprechen. Sie wurden 2011 in Unity eingeführt, noch bevor async/await in .NET erschien. In Unity ermöglichen uns Coroutinen, eine Reihe von Anweisungen über mehrere Frames hinweg auszuführen, anstatt sie alle auf einmal auszuführen. Sie ähneln Threads, sind jedoch leichtgewichtig und in die Update-Schleife von Unity integriert, wodurch sie sich gut für die Spieleentwicklung eignen.
(Historisch gesehen waren Coroutinen übrigens die erste Möglichkeit, asynchrone Operationen in Unity auszuführen, daher geht es in den meisten Artikeln im Internet um sie.)
Um eine Coroutine zu erstellen, müssen Sie eine Funktion mit dem Rückgabetyp IEnumerator
deklarieren. Diese Funktion kann jede Logik enthalten, die die Coroutine ausführen soll.
Um eine Coroutine zu starten, müssen Sie die StartCoroutine
Methode auf einer MonoBehaviour
Instanz aufrufen und die Coroutine-Funktion als Argument übergeben:
public class Example : MonoBehaviour { void Start() { StartCoroutine(MyCoroutine()); } IEnumerator MyCoroutine() { Debug.Log("Starting coroutine"); yield return null; Debug.Log("Executing coroutine"); yield return null; Debug.Log("Finishing coroutine"); } }
In Unity sind mehrere Ertragsanweisungen verfügbar, z. B. WaitForSeconds
, WaitForEndOfFrame
, WaitForFixedUpdate
, WaitForSecondsRealtime
, WaitUntil
und einige andere. Es ist wichtig zu bedenken, dass ihre Verwendung zu Zuordnungen führt und daher nach Möglichkeit wiederverwendet werden sollte.
Betrachten Sie beispielsweise diese Methode aus der Dokumentation:
IEnumerator Fade() { Color c = renderer.material.color; for (float alpha = 1f; alpha >= 0; alpha -= 0.1f) { ca = alpha; renderer.material.color = c; yield return new WaitForSeconds(.1f); } }
Mit jeder Iteration der Schleife wird eine neue Instanz von new WaitForSeconds(.1f)
erstellt. Stattdessen können wir die Erstellung außerhalb der Schleife verschieben und Zuweisungen vermeiden:
IEnumerator Fade() { Color c = renderer.material.color; **var waitForSeconds = new WaitForSeconds(0.2f);** for (float alpha = 1f; alpha >= 0; alpha -= 0.1f) { ca = alpha; renderer.material.color = c; yield return **waitForSeconds**; } }
Eine weitere wichtige Eigenschaft ist, dass yield return
mit allen von Unity bereitgestellten Async
Methoden verwendet werden kann, da AsyncOperation
s Nachkommen von YieldInstruction
sind:
yield return SceneManager.LoadSceneAsync("path/to/scene.unity");
Abgesehen davon haben Coroutinen auch einige Nachteile, die es zu beachten gilt:
MonoBehaviour
gebunden, das sie startet. Wenn das GameObject
ausgeschaltet oder zerstört wird, wird die Coroutine nicht mehr verarbeitet.try-catch-finally
Struktur kann aufgrund der Yield-Syntax nicht verwendet werden.yield return
, bevor der nächste Code ausgeführt wird.Versprechen sind ein Muster, um asynchrone Vorgänge zu organisieren und besser lesbar zu machen. Sie sind aufgrund ihrer Verwendung in vielen JavaScript-Bibliotheken von Drittanbietern populär geworden und werden seit ES6 nativ implementiert.
Wenn Sie Promises verwenden, geben wir sofort ein Objekt von Ihrer asynchronen Funktion zurück. Dadurch kann der Aufrufer auf die Lösung (oder einen Fehler) des Vorgangs warten.
Im Wesentlichen bedeutet dies, dass asynchrone Methoden Werte zurückgeben und sich wie synchrone Methoden „verhalten“ können: Anstatt den endgültigen Wert sofort zurückzugeben, geben sie ein „Versprechen“, dass sie irgendwann in der Zukunft einen Wert zurückgeben werden.
Es gibt mehrere Promises-Implementierungen für Unity:
Die Interaktion mit einem Promise erfolgt hauptsächlich über Callback-Funktionen .
Sie können eine Rückruffunktion definieren, die aufgerufen wird, wenn ein Versprechen aufgelöst wird, und eine andere Rückruffunktion, die aufgerufen wird, wenn das Versprechen abgelehnt wird. Diese Rückrufe erhalten das Ergebnis der asynchronen Operation als Argumente, die dann zur Ausführung weiterer Operationen verwendet werden können.
Gemäß diesen Spezifikationen der Promises/A+-Organisation kann ein Promise einen von drei Zuständen haben:
Pending
: Der Anfangszustand bedeutet, dass der asynchrone Vorgang noch läuft und das Ergebnis des Vorgangs noch nicht bekannt ist.Fulfilled
( Resolved
): Der gelöste Zustand wird von einem Wert begleitet, der das Ergebnis des Vorgangs darstellt.Rejected
: Wenn der asynchrone Vorgang aus irgendeinem Grund fehlschlägt, wird das Versprechen als „abgelehnt“ bezeichnet. Dem abgelehnten Status ist der Grund für das Scheitern beigefügt.Darüber hinaus können Versprechen miteinander verkettet werden, sodass das Ergebnis eines Versprechens zur Bestimmung des Ergebnisses eines anderen Versprechens verwendet werden kann.
Sie können beispielsweise ein Promise erstellen, das einige Daten von einem Server abruft, und diese Daten dann verwenden, um ein weiteres Promise zu erstellen, das einige Berechnungen und andere Aktionen durchführt:
var promise = MakeRequest("https://some.api") .Then(response => Parse(response)) .Then(result => OnRequestSuccess(result)) .Then(() => PlaySomeAnimation()) .Catch(exception => OnRequestFailed(exception));
Hier ist ein Beispiel für die Organisation einer Methode, die einen asynchronen Vorgang ausführt:
public IPromise<string> MakeRequest(string url) { // Create a new promise object var promise = new Promise<string>(); // Create a new web client using var client = new WebClient(); // Add a handler for the DownloadStringCompleted event client.DownloadStringCompleted += (sender, eventArgs) => { // If an error occurred, reject the promise if (eventArgs.Error != null) { promise.Reject(eventArgs.Error); } // Otherwise, resolve the promise with the result else { promise.Resolve(eventArgs.Result); } }; // Start the download asynchronously client.DownloadStringAsync(new Uri(url), null); // Return the promise return promise; }
Wir könnten Coroutinen auch in ein Promise
einschließen:
void Start() { // Load the scene and then show the intro animation LoadScene("path/to/scene.unity") .Then(() => ShowIntroAnimation()) .Then( ... ); } // Load a scene and return a promise Promise LoadScene(string sceneName) { // Create a new promise var promise = new Promise(); // Start a coroutine to load the scene StartCoroutine(LoadSceneRoutine(promise, sceneName)); // Return the promise return promise; } IEnumerator LoadSceneRoutine(Promise promise, string sceneName) { // Load the scene asynchronously yield return SceneManager.LoadSceneAsync(sceneName); // Resolve the promise once the scene is loaded promise.Resolve(); }
Und natürlich können Sie mit ThenAll
/ Promise.All
und ThenRace
/ Promise.Race
jede beliebige Kombination der Versprechen-Ausführungsreihenfolge organisieren:
// Execute the following two promises in sequence Promise.Sequence( () => Promise.All( // Execute the two promises in parallel RunAnimation("Foo"), PlaySound("Bar") ), () => Promise.Race( // Execute the two promises in a race RunAnimation("One"), PlaySound("Two") ) );
Trotz aller Benutzerfreundlichkeit haben Versprechen auch einige Nachteile:
Die Async/Await-Funktion ist seit Version 5.0 (2012) Teil von C# und wurde in Unity 2017 mit der Implementierung der .NET 4.x-Laufzeit eingeführt.
In der Geschichte von .NET lassen sich folgende Phasen unterscheiden:
BeginSmth
Methode gibt die IAsyncResult
Schnittstelle zurück. Die EndSmth
-Methode akzeptiert IAsyncResult
; Wenn der Vorgang zum Zeitpunkt des EndSmth
Aufrufs nicht abgeschlossen ist, wird der Thread blockiert.Task
und Task<TResult>
verbessert.
Die vorherigen Ansätze wurden aufgrund des Erfolgs des letzten Ansatzes obsolet.
Um eine asynchrone Methode zu erstellen, muss die Methode mit dem Schlüsselwort async
gekennzeichnet sein, ein „ await
enthalten und der Rückgabewert muss Task
, Task<T>
oder void
sein (nicht empfohlen).
public async Task Example() { SyncMethodA(); await Task.Delay(1000); // the first async operation SyncMethodB(); await Task.Delay(2000); // the second async operation SyncMethodC(); await Task.Delay(3000); // the third async operation }
In diesem Beispiel erfolgt die Ausführung wie folgt:
SyncMethodA
) ausgeführt.await Task.Delay(1000)
wird gestartet und wird voraussichtlich ausgeführt. In der Zwischenzeit wird der Code gespeichert, der aufgerufen werden soll, wenn der asynchrone Vorgang abgeschlossen ist (die „Fortsetzung“).SyncMethodB
) ausgeführt wird.await Task.Delay(2000)
) wird gestartet und wird voraussichtlich ausgeführt. Gleichzeitig bleibt die Fortsetzung – der Code nach der zweiten asynchronen Operation ( SyncMethodC
) – erhalten.SyncMethodC
ausgeführt, gefolgt von der Ausführung und dem Warten auf den dritten asynchronen await Task.Delay(3000)
.
Dies ist eine vereinfachte Erklärung, da async/await tatsächlich syntaktischer Zucker ist, der das bequeme Aufrufen asynchroner Methoden und das Warten auf deren Abschluss ermöglicht.
Sie können auch jede beliebige Kombination von Ausführungsaufträgen mithilfe von WhenAll
und WhenAny
organisieren:
var allTasks = Task.WhenAll( Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }) ); allTasks.ContinueWith(t => { Console.WriteLine("All the tasks are completed"); }); var anyTask = Task.WhenAny( Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }), Task.Run(() => { /* ... */ }) ); anyTask.ContinueWith(t => { Console.WriteLine("One of tasks is completed"); });
Der C# -Compiler wandelt asynchrone/await-Aufrufe in eine IAsyncStateMachine
Zustandsmaschine um, bei der es sich um einen sequentiellen Satz von Aktionen handelt, die ausgeführt werden müssen, um den asynchronen Vorgang abzuschließen.
Jedes Mal, wenn Sie eine Warteoperation aufrufen, schließt die Zustandsmaschine ihre Arbeit ab und wartet auf den Abschluss dieser Operation. Anschließend führt sie die nächste Operation weiter aus. Dies ermöglicht die Ausführung asynchroner Vorgänge im Hintergrund, ohne den Hauptthread zu blockieren, und macht außerdem asynchrone Methodenaufrufe einfacher und lesbarer.
Somit wird die Example
in die Erstellung und Initialisierung eines Zustandsautomaten mit der Annotation [AsyncStateMachine(typeof(ExampleStateMachine))]
umgewandelt, und der Zustandsautomat selbst verfügt über eine Anzahl von Zuständen, die der Anzahl der Warteaufrufe entspricht.
Beispiel der transformierten Methode Example
[AsyncStateMachine(typeof(ExampleStateMachine))] public /*async*/ Task Example() { // Create a new instance of the ExampleStateMachine class ExampleStateMachine stateMachine = new ExampleStateMachine(); // Create a new AsyncTaskMethodBuilder and assign it to the taskMethodBuilder property of the stateMachine instance stateMachine.taskMethodBuilder = AsyncTaskMethodBuilder.Create(); // Set the currentState property of the stateMachine instance to -1 stateMachine.currentState = -1; // Start the stateMachine instance stateMachine.taskMethodBuilder.Start(ref stateMachine); // Return the Task property of the taskMethodBuilder return stateMachine.taskMethodBuilder.Task; }
Beispiel einer generierten Zustandsmaschine ExampleStateMachine
[CompilerGenerated] private sealed class ExampleStateMachine : IAsyncStateMachine { public int currentState; public AsyncTaskMethodBuilder taskMethodBuilder; private TaskAwaiter taskAwaiter; public int paramInt; private int localInt; void IAsyncStateMachine.MoveNext() { int num = currentState; try { TaskAwaiter awaiter3; TaskAwaiter awaiter2; TaskAwaiter awaiter; switch (num) { default: localInt = paramInt; // Call the first synchronous method SyncMethodA(); // Create a task awaiter for a delay of 1000 milliseconds awaiter3 = Task.Delay(1000).GetAwaiter(); // If the task is not completed, set the current state to 0 and store the awaiter if (!awaiter3.IsCompleted) { currentState = 0; taskAwaiter = awaiter3; // Store the current state machine ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter3, ref stateMachine); return; } // If the task is completed, jump to the label after the first await goto Il_AfterFirstAwait; case 0: // Retrieve the awaiter from the taskAwaiter field awaiter3 = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; // Jump to the label after the first await goto Il_AfterFirstAwait; case 1: // Retrieve the awaiter from the taskAwaiter field awaiter2 = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; // Jump to the label after the second await goto Il_AfterSecondAwait; case 2: // Retrieve the awaiter from the taskAwaiter field awaiter = taskAwaiter; // Reset the taskAwaiter field taskAwaiter = default(TaskAwaiter); currentState = -1; break; Il_AfterFirstAwait: awaiter3.GetResult(); // Call the second synchronous method SyncMethodB(); // Create a task awaiter for a delay of 2000 milliseconds awaiter2 = Task.Delay(2000).GetAwaiter(); // If the task is not completed, set the current state to 1 and store the awaiter if (!awaiter2.IsCompleted) { currentState = 1; taskAwaiter = awaiter2; // Store the current state machine ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter2, ref stateMachine); return; } // If the task is completed, jump to the label after the second await goto Il_AfterSecondAwait; Il_AfterSecondAwait: // Get the result of the second awaiter awaiter2.GetResult(); // Call the SyncMethodC SyncMethodC(); // Create a new awaiter with a delay of 3000 milliseconds awaiter = Task.Delay(3000).GetAwaiter(); // If the awaiter is not completed, set the current state to 2 and store the awaiter if (!awaiter.IsCompleted) { currentState = 2; taskAwaiter = awaiter; // Set the stateMachine to this ExampleStateMachine stateMachine = this; // Await the task and pass the state machine taskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); return; } break; } // Get the result of the awaiter awaiter.GetResult(); } catch (Exception exception) { currentState = -2; taskMethodBuilder.SetException(exception); return; } currentState = -2; taskMethodBuilder.SetResult(); } void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine) { /*...*/ } }
Im AwaitUnsafeOnCompleted
Aufruf wird der aktuelle Synchronisationskontext SynchronizationContext
abgerufen. SynchronizationContext ist ein Konzept in C#, das zur Darstellung eines Kontexts verwendet wird, der die Ausführung einer Reihe asynchroner Vorgänge steuert. Es wird verwendet, um die Ausführung von Code über mehrere Threads hinweg zu koordinieren und sicherzustellen, dass Code in einer bestimmten Reihenfolge ausgeführt wird. Der Hauptzweck von SynchronizationContext besteht darin, eine Möglichkeit zur Steuerung der Planung und Ausführung asynchroner Vorgänge in einer Multithread-Umgebung bereitzustellen.
In verschiedenen Umgebungen verfügt der SynchronizationContext
über unterschiedliche Implementierungen. In .NET gibt es beispielsweise:
System.Windows.Threading.DispatcherSynchronizationContext
System.Windows.Forms.WindowsFormsSynchronizationContext
System.Threading.WinRTSynchronizationContext
System.Web.AspNetSynchronizationContext
Unity verfügt außerdem über einen eigenen Synchronisierungskontext, UnitySynchronizationContext
, der es uns ermöglicht, asynchrone Vorgänge mit Bindung an die PlayerLoop-API zu verwenden. Das folgende Codebeispiel zeigt, wie man mit Task.Yield()
ein Objekt in jedem Frame dreht:
private async void Start() { while (true) { transform.Rotate(0, Time.deltaTime * 50, 0); await Task.Yield(); } }
Ein weiteres Beispiel für die Verwendung von async/await in Unity zum Stellen einer Netzwerkanfrage:
using UnityEngine; using System.Net.Http; using System.Threading.Tasks; public class NetworkRequestExample : MonoBehaviour { private async void Start() { string response = await GetDataFromAPI(); Debug.Log("Response from API: " + response); } private async Task<string> GetDataFromAPI() { using (var client = new HttpClient()) { var response = await client.GetStringAsync("https://api.example.com/data"); return response; } } }
Dank UnitySynchronizationContext
können wir UnityEngine
Methoden (z. B. Debug.Log()
) direkt nach Abschluss eines asynchronen Vorgangs sicher verwenden, da die Ausführung dieses Codes im Haupt-Unity-Thread fortgesetzt wird.
Mit dieser Klasse können Sie ein Task
Objekt verwalten. Es wurde erstellt, um alte asynchrone Methoden an TAP anzupassen, ist aber auch sehr nützlich, wenn wir eine Task
um einen lang andauernden Vorgang wickeln möchten, der bei einem bestimmten Ereignis auftritt.
Im folgenden Beispiel wird das Task
Objekt in taskCompletionSource
3 Sekunden nach dem Start abgeschlossen und wir erhalten das Ergebnis in der Update
Methode:
using System.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { private TaskCompletionSource<int> taskCompletionSource; private void Start() { // Create a new TaskCompletionSource taskCompletionSource = new TaskCompletionSource<int>(); // Start a coroutine to wait 3 seconds // and then set the result of the TaskCompletionSource StartCoroutine(WaitAndComplete()); } private IEnumerator WaitAndComplete() { yield return new WaitForSeconds(3); // Set the result of the TaskCompletionSource taskCompletionSource.SetResult(10); } private async void Update() { // Await the result of the TaskCompletionSource int result = await taskCompletionSource.Task; // Log the result to the console Debug.Log("Result: " + result); } }
In C# wird ein Abbruchtoken verwendet, um zu signalisieren, dass eine Aufgabe oder ein Vorgang abgebrochen werden soll. Das Token wird an die Aufgabe oder Operation übergeben, und der Code innerhalb der Aufgabe oder Operation kann das Token regelmäßig überprüfen, um festzustellen, ob die Aufgabe oder Operation gestoppt werden sollte. Dies ermöglicht einen sauberen und ordnungsgemäßen Abbruch einer Aufgabe oder eines Vorgangs, anstatt ihn einfach abrupt abzubrechen.
Abbruchtokens werden häufig in Situationen verwendet, in denen eine lang laufende Aufgabe vom Benutzer abgebrochen werden kann oder wenn die Aufgabe nicht mehr benötigt wird, z. B. eine Schaltfläche zum Abbrechen in einer Benutzeroberfläche.
Das Gesamtmuster ähnelt der Verwendung von TaskCompletionSource
. Zuerst wird eine CancellationTokenSource
erstellt, dann wird ihr Token
an die asynchrone Operation übergeben:
public class ExampleMonoBehaviour : MonoBehaviour { private CancellationTokenSource _cancellationTokenSource; private async void Start() { // Create a new CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); // Get the token from the CancellationTokenSource CancellationToken token = _cancellationTokenSource.Token; try { // Start a new Task and pass in the token await Task.Run(() => DoSomething(token), token); } catch (OperationCanceledException) { Debug.Log("Task was cancelled"); } } private void DoSomething(CancellationToken token) { for (int i = 0; i < 100; i++) { // Check if the token has been cancelled if (token.IsCancellationRequested) { // Return if the token has been cancelled return; } Debug.Log("Doing something..."); // Sleep for 1 second Thread.Sleep(1000); } } private void OnDestroy() { // Cancel the token when the object is destroyed _cancellationTokenSource.Cancel(); } }
Wenn der Vorgang abgebrochen wird, wird eine OperationCanceledException
ausgelöst und die Eigenschaft Task.IsCanceled
wird auf true
gesetzt.
Es ist wichtig zu beachten, dass Task
von der .NET-Laufzeitumgebung und nicht von Unity verwaltet werden. Wenn das Objekt, das die Aufgabe ausführt, zerstört wird (oder wenn das Spiel den Spielmodus im Editor verlässt), wird die Aufgabe weiterhin wie von Unity ausgeführt Es gibt keine Möglichkeit, es abzubrechen.
Sie müssen await Task
immer mit dem entsprechenden CancellationToken
begleiten. Dies führt zu einer gewissen Redundanz des Codes, und in Unity 2022.2 wurden integrierte Token auf der MonoBehaviour
Ebene und der gesamten Application
angezeigt.
Sehen wir uns an, wie sich das vorherige Beispiel ändert, wenn das destroyCancellationToken
des MonoBehaviour
Objekts verwendet wird:
using System.Threading; using System.Threading.Tasks; using UnityEngine; public class ExampleMonoBehaviour : MonoBehaviour { private async void Start() { // Get the cancellation token from the MonoBehaviour CancellationToken token = this.destroyCancellationToken; try { // Start a new Task and pass in the token await Task.Run(() => DoSomething(token), token); } catch (OperationCanceledException) { Debug.Log("Task was cancelled"); } } private void DoSomething(CancellationToken token) { for (int i = 0; i < 100; i++) { // Check if the token has been cancelled if (token.IsCancellationRequested) { // Return if the token has been cancelled return; } Debug.Log("Doing something..."); // Sleep for 1 second Thread.Sleep(1000); } } }
Wir müssen nicht mehr manuell eine CancellationTokenSource
erstellen und die Aufgabe in der OnDestroy
Methode abschließen. Für Aufgaben, die nicht mit einem bestimmten MonoBehaviour
verknüpft sind, können wir UnityEngine.Application.exitCancellationToken
verwenden. Dadurch wird die Aufgabe beim Verlassen des Wiedergabemodus (im Editor) oder beim Beenden der Anwendung beendet.
Trotz der Benutzerfreundlichkeit und der von .NET Tasks bereitgestellten Funktionen weisen sie bei der Verwendung in Unity erhebliche Nachteile auf:
Task
sind zu umständlich und verursachen viele Zuordnungen.Task
ist nicht dem Unity-Threading (Single-Thread) zugeordnet.
Die UniTask- Bibliothek umgeht diese Einschränkungen, ohne Threads oder SynchronizationContext
zu verwenden. Das Fehlen von Zuordnungen wird durch die Verwendung des strukturbasierten Typs UniTask<T>
erreicht.
UniTask erfordert die .NET 4.x-Skriptlaufzeitversion, wobei Unity 2018.4.13f1 die offiziell niedrigste unterstützte Version ist.
Sie können auch alle AsyncOperations
mit Erweiterungsmethoden in UnitTask
konvertieren:
using UnityEngine; using UniTask; public class AssetLoader : MonoBehaviour { public async void LoadAsset(string assetName) { var loadRequest = Resources.LoadAsync<GameObject>(assetName); await loadRequest.AsUniTask(); var asset = loadRequest.asset as GameObject; if (asset != null) { // Do something with the loaded asset } } }
In diesem Beispiel verwendet die LoadAsset
Methode Resources.LoadAsync
, um ein Asset asynchron zu laden. Die AsUniTask
Methode wird dann verwendet, um die von LoadAsync
zurückgegebene AsyncOperation
in eine UniTask
umzuwandeln, die abgewartet werden kann.
Wie zuvor können Sie mit UniTask.WhenAll
und UniTask.WhenAny
jede beliebige Kombination der Ausführungsreihenfolge organisieren:
using System.Threading; using Cysharp.Threading.Tasks; using UnityEngine; public class Example : MonoBehaviour { private async void Start() { // Start two Tasks and wait for both to complete await UniTask.WhenAll(Task1(), Task2()); // Start two Tasks and wait for one to complete await UniTask.WhenAny(Task1(), Task2()); } private async UniTask Task1() { // Do something } private async UniTask Task2() { // Do something } }
In UniTask gibt es eine weitere Implementierung von SynchronizationContext
namens UniTaskSynchronizationContext
, die zur Erzielung einer besseren Leistung als Ersatz für UnitySynchronizationContext
verwendet werden kann.
In der ersten Alpha-Version von Unity 2023.1 wurde die Awaitable
Klasse eingeführt. Awaitable-Coroutinen sind asynchrone/await-kompatible aufgabenähnliche Typen, die für die Ausführung in Unity entwickelt wurden. Im Gegensatz zu .NET-Aufgaben werden sie von der Engine und nicht von der Laufzeit verwaltet.
private async Awaitable DoSomethingAsync() { // awaiting built-in events await Awaitable.EndOfFrameAsync(); await Awaitable.WaitForSecondsAsync(); // awaiting .NET Tasks await Task.Delay(2000, destroyCancellationToken); await Task.Yield(); // awaiting AsyncOperations await SceneManager.LoadSceneAsync("path/to/scene.unity"); // ... }
Sie können erwartet und als Rückgabetyp einer asynchronen Methode verwendet werden. Im Vergleich zu System.Threading.Tasks
sind sie weniger ausgefeilt, nutzen aber leistungssteigernde Abkürzungen, die auf Unity-spezifischen Annahmen basieren.
Hier sind die Hauptunterschiede im Vergleich zu .NET-Aufgaben:
Awaitable
Objekt kann nur einmal gewartet werden; Es kann nicht von mehreren asynchronen Funktionen erwartet werden.Awaiter.GetResults()
wird bis zum Abschluss nicht blockiert. Der Aufruf vor Abschluss des Vorgangs ist ein undefiniertes Verhalten.ExecutionContext
. Aus Sicherheitsgründen erfassen .NET-Aufgaben beim Warten Ausführungskontexte, um Identitätswechselkontexte über asynchrone Aufrufe hinweg weiterzugeben.SynchronizationContext
. Coroutine-Fortsetzungen werden synchron vom Code ausgeführt, der die Vervollständigung auslöst. In den meisten Fällen erfolgt dies über den Unity-Hauptrechner.ObjectPool
wurde verbessert, um Stack<T>
-Grenzprüfungen in typischen Get/Release-Sequenzen zu vermeiden, die von asynchronen Zustandsmaschinen generiert werden.
Um das Ergebnis eines längeren Vorgangs zu erhalten, können Sie den Typ Awaitable<T>
verwenden. Sie können den Abschluss eines Awaitable
mithilfe von AwaitableCompletionSource
und AwaitableCompletionSource<T>
verwalten , ähnlich wie bei TaskCompletitionSource
:
using UnityEngine; using Cysharp.Threading.Tasks; public class ExampleBehaviour : MonoBehaviour { private AwaitableCompletionSource<bool> _completionSource; private async void Start() { // Create a new AwaitableCompletionSource _completionSource = new AwaitableCompletionSource<bool>(); // Start a coroutine to wait 3 seconds // and then set the result of the AwaitableCompletionSource StartCoroutine(WaitAndComplete()); // Await the result of the AwaitableCompletionSource bool result = await _completionSource.Awaitable; // Log the result to the console Debug.Log("Result: " + result); } private IEnumerator WaitAndComplete() { yield return new WaitForSeconds(3); // Set the result of the AwaitableCompletionSource _completionSource.SetResult(true); } }
Manchmal sind umfangreiche Berechnungen erforderlich, die zum Einfrieren des Spiels führen können. Hierfür ist es besser, die Awaitable-Methoden zu verwenden: BackgroundThreadAsync()
und MainThreadAsync()
. Sie ermöglichen es Ihnen, den Hauptthread zu verlassen und zu ihm zurückzukehren.
private async Awaitable DoCalculationsAsync() { // Awaiting execution on a ThreadPool background thread. await Awaitable.BackgroundThreadAsync(); var result = PerformSomeHeavyCalculations(); // Awaiting execution on the Unity main thread. await Awaitable.MainThreadAsync(); // Using the result in main thread Debug.Log(result); }
Auf diese Weise beseitigen Awaitables die Nachteile der Verwendung von .NET-Aufgaben und ermöglichen außerdem das Warten auf PlayerLoop-Ereignisse und AsyncOperations.
Wie wir sehen können, gibt es mit der Entwicklung von Unity immer mehr Tools zum Organisieren asynchroner Vorgänge:
Einheit | Coroutinen | Versprechen | .NET-Aufgaben | UniTask | Integrierte Stornierungstoken | Erwartete API |
---|---|---|---|---|---|---|
5.6 | ✅ | ✅ | | | | |
2017.1 | ✅ | ✅ | ✅ | | | |
2018.4 | ✅ | ✅ | ✅ | ✅ | | |
2022.2 | ✅ | ✅ | ✅ | ✅ | ✅ | |
2023.1 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Wir haben alle wichtigen Möglichkeiten der asynchronen Programmierung in Unity betrachtet. Abhängig von der Komplexität Ihrer Aufgabe und der von Ihnen verwendeten Unity-Version können Sie eine breite Palette von Technologien von Coroutines und Promises bis hin zu Tasks und Awaitables nutzen, um ein reibungsloses und nahtloses Gameplay in Ihren Spielen zu gewährleisten. Vielen Dank fürs Lesen und wir freuen uns auf Ihre nächsten Meisterwerke.