Durch die Implementierung gängiger Entwurfsmuster für die Spieleprogrammierung in Ihrem Unity-Projekt können Sie effizient eine saubere, organisierte und lesbare Codebasis erstellen und pflegen. Design Patterns reduzieren nicht nur das Refactoring und den Zeitaufwand für das Testen, sie beschleunigen auch die Einführungs- und Entwicklungsprozesse und tragen so zu einer soliden Grundlage für das Wachstum Ihres Spiels, Entwicklungsteams und Unternehmens bei.
Betrachten Sie Entwurfsmuster nicht als fertige Lösungen, die Sie kopieren und in Ihren Code einfügen können, sondern als zusätzliche Werkzeuge, die Ihnen bei richtiger Anwendung helfen können, größere, skalierbare Anwendungen zu erstellen.
Diese Seite erklärt das Beobachtermuster und wie es dazu beitragen kann, das Prinzip der losen Kopplung zwischen Objekten, die miteinander interagieren, zu unterstützen.
Der Inhalt hier basiert auf dem kostenlosen E-Book, Level up your code with game programming patternsdas bekannte Entwurfsmuster erklärt und praktische Beispiele für deren Verwendung in Ihrem Unity-Projekt enthält.
Weitere Artikel aus der Reihe Unity Design Patterns für die Spieleprogrammierung finden Sie im Unity Best Practices Hub, oder klicken Sie auf die folgenden Links:
Zur Laufzeit kann in Ihrem Spiel alles Mögliche passieren. Was passiert, wenn der Spieler einen Feind vernichtet? Oder wenn sie einen Power- oder Level-Up erhalten? Oft braucht man einen Mechanismus, der es einigen Objekten erlaubt, andere zu benachrichtigen, ohne direkt auf sie zu verweisen. Leider führt dies mit zunehmender Größe Ihrer Codebasis zu unnötigen Abhängigkeiten, die zu Inflexibilität und übermäßigem Aufwand bei der Codepflege führen können.
Das Beobachtermuster ist eine gängige Lösung für dieses Problem. Es ermöglicht Ihren Objekten zu kommunizieren, aber lose gekoppelt zu bleiben, indem eine "one-to-many"-Abhängigkeit verwendet wird. Wenn ein Objekt seinen Zustand ändert, werden alle abhängigen Objekte automatisch benachrichtigt.
Eine Analogie, um dies zu veranschaulichen, ist ein Funkturm, der an viele verschiedene Hörer sendet. Es muss nicht wissen, wer sich einschaltet, sondern nur, dass die Sendung auf der richtigen Frequenz und zur richtigen Zeit ausgestrahlt wird.
Das Objekt, über das gesendet wird, wird als Subjekt bezeichnet. Die anderen Objekte, die zuhören, werden Beobachter oder manchmal auch Abonnenten genannt (auf dieser Seite wird durchgehend der Name Beobachter verwendet).
Der Vorteil dieses Musters besteht darin, dass es das Subjekt vom Beobachter entkoppelt, der die Beobachter nicht wirklich kennt und sich nicht darum kümmert, was sie tun, sobald sie das Signal empfangen. Während die Beobachter vom Subjekt abhängig sind, wissen die Beobachter selbst nichts übereinander.
Die Beobachter werden einfach benachrichtigt, wenn sich der Zustand des Subjekts ändert, so dass sie sich entsprechend aktualisieren können. Auf diese Weise wird es einfacher, den Code zu ändern oder zu erweitern, ohne andere Teile des Systems zu beeinträchtigen.
Ein weiterer Vorteil ist, dass das Beobachtermuster die Entwicklung von wiederverwendbarem Code fördert, da Beobachter in verschiedenen Kontexten wiederverwendet werden können, ohne dass sie geändert werden müssen. Und schließlich verbessert es oft die Lesbarkeit des Codes, da die Abhängigkeiten zwischen den Objekten klar definiert sind.
Sie können Ihre eigenen Subjekt-Beobachter-Klassen entwerfen, aber das ist in der Regel unnötig, da C# das Muster bereits mit Ereignissen implementiert. Das Beobachtermuster ist so weit verbreitet, dass es in die Sprache C# integriert ist, und das aus gutem Grund: Es kann Ihnen helfen, modularen, wiederverwendbaren und wartbaren Code zu erstellen.
Was ist ein Ereignis? Es handelt sich um eine Benachrichtigung, die anzeigt, dass etwas passiert ist, und die ein paar Schritte umfasst:
Der Verleger (auch als Subjekt bezeichnet) erstellt ein Ereignis auf der Grundlage eines Delegierten, der eine bestimmte Funktionssignatur festlegt. Das Ereignis ist lediglich eine Aktion, die das Subjekt zur Laufzeit ausführt (z. B. Schaden nehmen, auf eine Schaltfläche klicken usw.). Der Verleger führt eine Liste seiner Abhängigen (der Beobachter) und sendet ihnen Benachrichtigungen, wenn sich sein Zustand ändert, was durch dieses Ereignis dargestellt wird.
Die Beobachter erstellen dann jeweils eine Methode, die als Ereignisbehandler bezeichnet wird und der Signatur des Delegaten entsprechen muss. Bei den Beobachtern handelt es sich um Objekte, die Benachrichtigungen vom Herausgeber erhalten und sich entsprechend aktualisieren.
Jeder Ereignisbehandler des Beobachters abonniert das Ereignis des Verlegers. Sie können so viele Beobachter in das Abonnement aufnehmen, wie Sie benötigen. Alle warten auf die Auslösung des Ereignisses.
Wenn der Herausgeber das Eintreten eines Ereignisses zur Laufzeit signalisiert, wird dies als Auslösen des Ereignisses bezeichnet. Dies wiederum ruft die Event-Handler der Beobachter auf, die daraufhin ihre eigene interne Logik ausführen.
Auf diese Weise lassen Sie viele Komponenten auf ein einziges Ereignis des Subjekts reagieren. Wenn die Versuchsperson anzeigt, dass eine Schaltfläche angeklickt wurde, können die Beobachter eine Animation oder einen Ton abspielen, eine Zwischensequenz auslösen oder eine Datei speichern. Ihre Antwort kann alles Mögliche sein, weshalb man häufig das Beobachtermuster verwendet, um Nachrichten zwischen Objekten zu senden.
Delegierte versus Ereignisse
Ein Delegat ist ein Typ, der eine Methodensignatur definiert. So können Sie Methoden als Argumente an andere Methoden übergeben. Stellen Sie sich das wie eine Variable vor, die anstelle eines Wertes einen Verweis auf eine Methode enthält.
Ein Ereignis hingegen ist im Wesentlichen eine spezielle Art von Delegat, das es Klassen ermöglicht, auf eine lose gekoppelte Weise miteinander zu kommunizieren. Allgemeine Informationen über die Unterschiede zwischen Delegaten und Ereignissen finden Sie unter Unterscheidung zwischen Delegaten und Ereignissen in C#.
Schauen wir uns an, wie Sie einen einfachen Betreff/Verlag im folgenden Code definieren können.
In der Subjektklasse im Codebeispiel unten erben Sie von MonoBehaviour, um es einfacher an ein GameObject anhängen zu können, obwohl das keine Voraussetzung ist.
Es steht Ihnen zwar frei, einen eigenen benutzerdefinierten Delegaten zu definieren, aber Sie können auch die System.Action verwenden, die für die meisten Anwendungsfälle geeignet ist. Im Code-Beispiel ist es nicht nötig, Parameter mit dem Ereignis zu senden, aber wenn das nötig wäre, ist es so einfach wie die Verwendung des Delegaten Action<T> und die Übergabe als List<T> innerhalb der spitzen Klammern (bis zu 16 Parameter).
Im Codeschnipsel ist ThingHappened das eigentliche Ereignis, das das Subjekt in der DoThing-Methode aufruft.
Der "?."-Operator ist ein Null-Bedingungs-Operator, was bedeutet, dass das Ereignis nur aufgerufen wird, wenn es nicht Null ist. Die Invoke-Methode wird verwendet, um ein Ereignis auszulösen, was bedeutet, dass sie alle Ereignis-Handler ausführt, die das Ereignis abonniert haben. In diesem Fall löst die DoThing-Methode das ThingHappened-Ereignis aus, wenn es nicht null ist, und führt alle Ereignis-Handler aus, die das Ereignis abonniert haben.
Sie können ein Beispielprojekt herunterladen, das den Observer und andere Entwurfsmuster in Aktion zeigt. Dieses Codebeispiel ist hier verfügbar.
Um auf das Ereignis zu hören, können Sie eine Beispiel-Beobachterklasse erstellen, wie das unten stehende, reduzierte Codebeispiel (auch im Github-Projekt verfügbar).
Hängen Sie dieses Skript an ein GameObject als Komponente an und verweisen Sie auf das subjectToObserver im Inspector, um auf das Ereignis ThingHappened zu warten.
Die Methode OnThingHappened kann jede Logik enthalten, die der Beobachter als Reaktion auf das Ereignis ausführt. Oft fügen Entwickler das Präfix "On" hinzu, um den Event-Handler zu kennzeichnen (verwenden Sie die Namenskonvention aus Ihrem Style Guide).
Im Wachzustand oder beim Start können Sie das Ereignis mit dem Operator += abonnieren. Das kombiniert die OnThingHappened-Methode des Beobachters mit der ThingHappened-Methode des Subjekts.
Wenn irgendetwas die DoThing-Methode des Subjekts ausführt, löst dies das Ereignis aus. Dann wird der OnThingHappened-Eventhandler des Beobachters automatisch aufgerufen und die Debug-Anweisung gedruckt.
Anmerkung: Wenn Sie den Beobachter zur Laufzeit löschen oder entfernen, während er noch das Ereignis ThingHappened abonniert hat, kann der Aufruf dieses Ereignisses zu einem Fehler führen. Daher ist es wichtig, das Ereignis in der OnDestroy-Methode des MonoBehaviour mit einem -=-Operator zu geeigneten Zeitpunkten im Lebenszyklus des Objekts abzubestellen.
Wenn Sie das Beispielprojekt herunterladen und in den Ordner mit dem Namen 11 Observer gehen, finden Sie ein Beispiel, das eine einfache Schaltfläche (ExampleSubject) sowie einen Lautsprecher (AudioObserver), eine Animation (AnimObserver) und einen Partikeleffekt (ParticleSystemObserver) zeigt.
Wenn Sie auf die Schaltfläche klicken, ruft das ExampleSubject ein ThingHappened-Ereignis auf. Der AudioObserver, der AnimObserver und der ParticleSystemObserver rufen daraufhin ihre Ereignisbehandlungsmethoden auf.
Die Beobachter können sich auf demselben oder auf verschiedenen GameObjects befinden. Beachten Sie, dass der AnimObserver die Tastenanimation auf dem ExampleSubject erzeugt, während der AudioObserver und der ParticleSystemObserver andere GameObjects belegen.
Das ButtonSubject ermöglicht es dem Benutzer, ein Clicked-Ereignis mit der Maustaste auszulösen. Mehrere andere GameObjects mit den Komponenten AudioObserver und ParticleSystemObserver können dann auf ihre eigene Weise auf das Ereignis reagieren.
Die Bestimmung, welches Objekt ein Subjekt und welches ein Beobachter ist, hängt nur vom Sprachgebrauch ab. Alles, was das Ereignis auslöst, ist das Subjekt, und alles, was auf das Ereignis reagiert, ist der Beobachter. Verschiedene Komponenten desselben GameObjects können Subjekte oder Beobachter sein. Selbst ein und dieselbe Komponente kann in einem Kontext Subjekt und in einem anderen Kontext Beobachter sein.
Zum Beispiel fügt der AnimObserver im Beispiel der Schaltfläche eine kleine Bewegung hinzu, wenn sie angeklickt wird. Es agiert als Beobachter, obwohl es Teil des ButtonSubject GameObjects ist.
Unity enthält auch ein separates System von UnityEventsdas die UnityAction Delegat aus der UnityEngine.Events API verwendet. Es kann im Inspektor konfiguriert werden (der eine grafische Schnittstelle für das Beobachtermuster bietet), so dass Entwickler angeben können, welche Methoden aufgerufen werden sollen, wenn das Ereignis ausgelöst wird.
Wenn Sie mit dem UI-System von Unity gearbeitet haben (z. B. beim Erstellen des OnClick-Ereignisses eines UI-Buttons), haben Sie bereits einige Erfahrung damit.
In der obigen Abbildung ruft das OnClick-Ereignis der Schaltfläche die OnThingHappened-Methoden der beiden AudioObserver auf und löst eine Antwort aus. So können Sie das Ereignis eines Subjekts ohne Code einrichten.
UnityEvents sind nützlich, wenn Sie Designern oder Nicht-Programmierern die Möglichkeit geben wollen, Gameplay-Events zu erstellen. Beachten Sie jedoch, dass sie langsamer sein können als die entsprechenden Ereignisse oder Aktionen aus dem System-Namensraum. UnityActions haben außerdem den zusätzlichen Vorteil, dass sie zum Aufrufen von Methoden verwendet werden können, die Argumente annehmen, während UnityEvents auf Methoden ohne Argumente beschränkt sind.
Wägen Sie bei der Auswahl von UnityEvents und UnityActions zwischen Leistung und Nutzung ab. UnityEvents sind einfacher und benutzerfreundlicher, aber in Bezug auf die Arten von Methoden, die aufgerufen werden können, stärker eingeschränkt. Einige könnten auch argumentieren, dass sie fehleranfälliger sein können, wenn sie alle Ereignisse im Inspector offenlegen.
Ein Beispiel finden Sie im Modul Erstellen eines einfachen Nachrichtensystems mit Ereignissen auf Unity Learn.
Die Implementierung eines Ereignisses bedeutet zwar einen gewissen Mehraufwand, bietet aber auch Vorteile:
Das Beobachtermuster hilft, Ihre Objekte zu entkoppeln: Der Herausgeber der Veranstaltung muss nichts über die Teilnehmer der Veranstaltung selbst wissen. Anstatt eine direkte Abhängigkeit zwischen einer Klasse und einer anderen zu schaffen, kommunizieren Subjekt und Beobachter unter Beibehaltung eines gewissen Grades an Trennung (loose-coupling).
Sie müssen es nicht selbst bauen: C# enthält ein etabliertes Ereignissystem, und Sie können System.Action-Delegate verwenden, anstatt eigene Delegaten zu definieren. Alternativ dazu bietet Unity auch UnityEvents und UnityActions.
Jeder Beobachter implementiert seine eigene Logik zur Ereignisbehandlung: Auf diese Weise behält jedes beobachtende Objekt die Logik bei, die es braucht, um zu reagieren. Dies erleichtert die Fehlersuche und den Unit-Test.
Es ist gut für die Benutzeroberfläche geeignet: Ihr Kernspiel-Code kann getrennt von Ihrer UI-Logik leben. Ihre UI-Elemente warten dann auf bestimmte Spielereignisse oder Bedingungen und reagieren entsprechend. Die MVP- und MVC-Muster verwenden zu diesem Zweck das Beobachtermuster.
Sie sollten aber auch die folgenden Vorbehalte beachten:
Dies führt zu zusätzlicher Komplexität: Wie bei anderen Mustern ist auch bei der Erstellung einer ereignisgesteuerten Architektur zu Beginn mehr Aufwand erforderlich. Seien Sie auch vorsichtig beim Löschen von Subjekten oder Beobachtern. Stellen Sie sicher, dass Sie die Registrierung von Beobachtern in OnDestroy aufheben, damit der Speicherverweis ordnungsgemäß freigegeben wird, wenn der Beobachter nicht mehr benötigt wird.
Die Beobachter benötigen einen Verweis auf die Klasse, die das Ereignis definiert: Die Beobachter sind weiterhin von der Klasse abhängig, die das Ereignis veröffentlicht. Die Verwendung eines statischen EventManagers (siehe nächster Abschnitt), der alle Ereignisse verarbeitet, kann helfen, die Objekte voneinander zu trennen
Die Leistung kann ein Problem sein: Die ereignisgesteuerte Architektur führt zu zusätzlichem Overhead. Große Szenen und viele GameObjects können die Leistung beeinträchtigen.
Obwohl hier nur eine Grundversion des Beobachtermusters vorgestellt wird, können Sie dieses Muster erweitern, um alle Anforderungen Ihrer Spielanwendung zu erfüllen.
Berücksichtigen Sie diese Vorschläge bei der Einrichtung des Beobachtermodells:
Verwenden Sie die Klasse ObservableCollection: C# bietet eine dynamische ObservableCollection um bestimmte Änderungen zu verfolgen. Es kann Ihre Beobachter benachrichtigen, wenn Elemente hinzugefügt oder entfernt werden oder wenn die Liste aktualisiert wird.
Übergeben Sie eine eindeutige Instanz-ID als Argument: Jedes GameObject in der Hierarchie hat eine eindeutige Instanz-ID. Wenn Sie ein Ereignis auslösen, das für mehr als einen Beobachter gelten könnte, übergeben Sie die eindeutige ID in das Ereignis (verwenden Sie den Typ Action<int>). Dann führen Sie die Logik im Event-Handler nur aus, wenn das GameObject mit der eindeutigen ID übereinstimmt.
Erstellen Sie einen statischen EventManager: Da Ereignisse einen Großteil des Gameplays steuern können, verwenden viele Unity-Anwendungen einen statischen oder singleton EventManager. Auf diese Weise können sich Ihre Beobachter auf eine zentrale Quelle von Spielereignissen als Thema beziehen, um die Einrichtung zu erleichtern.
Das FPS Microgame hat eine gute Implementierung eines statischen EventManagers, der benutzerdefinierte GameEvents implementiert und statische Hilfsmethoden zum Hinzufügen oder Entfernen von Listenern enthält.
Das Unity Open Project stellt auch eine Spielarchitektur vor, bei der ScriptableObjects UnityEvents weiterleiten. Sie verwendet Ereignisse, um Audio abzuspielen oder neue Szenen zu laden.
Erstellen Sie eine Ereignis-Warteschlange: Wenn Sie viele Objekte in Ihrer Szene haben, möchten Sie die Ereignisse vielleicht nicht alle auf einmal auslösen. Stellen Sie sich die Kakophonie von tausend Objekten vor, die Geräusche wiedergeben, wenn Sie ein einziges Ereignis aufrufen: Die Kombination des Beobachtermusters mit dem Befehlsmuster ermöglicht es Ihnen, Ihre Ereignisse in einer Ereigniswarteschlange zu kapseln. Dann können Sie einen Befehlspuffer verwenden, um die Ereignisse nacheinander abzuspielen oder sie bei Bedarf selektiv zu ignorieren (z. B. wenn Sie eine maximale Anzahl von Objekten haben, die gleichzeitig Geräusche machen können).
Das Beobachter-Muster ist ein wichtiger Bestandteil des Model-View-Presenter (MVP)-Architekturmusters, das in diesem E-Book behandelt wird Verbessern Sie Ihren Code mit Programmiermustern für Spiele.
Viele weitere Tipps zur Verwendung von Entwurfsmustern in Ihren Unity-Anwendungen sowie die SOLID-Prinzipien finden Sie in dem kostenlosen E-Book Verbessern Sie Ihren Code mit Programmiermustern für Spiele.
Alle fortgeschrittenen technischen E-Books und Artikel zu Unity sind im Best Practices Hub verfügbar. Die E-Books sind auch auf der Seite mit den fortgeschrittenen bewährten Verfahren in der Dokumentation verfügbar.