Über die GitHub Seite von UniRx (den reaktiven Erweiterungen für Unity) bin ich auf folgenden Artikel gestoßen, der eine (meiner Meinung nach) ausgezeichnete Einführung in das Thema "Reactive Programming" bietet. The introduction to Reactive Programming you've been missing
Nachfolgend habe ich versucht meine Erkenntnisse dieses Artikels kurz zusammenzufassen. Zur Info: Ich habe einiges aus diesem Artikel auch direkt übernommen. Danach will ich euch nicht länger auf die Folter spannen und wir versuchen unsere ersten Schritte in UniRx :)
Was macht reaktive Programmierung eigentlich so besonders? Wenn man "reactive" arbeitet ist zunächst einmal ALLES ein Stream! Und genau das wird unser Matra für diesen, möglicherweise auch der nächsten, Blogeinträge sein.
Nun wird es Zeit, dass wir uns auf eine Reise in die Welt der asynchronen Datenströme begeben. Asynchrone Datenströme sind nichts neues. Bussysteme oder die typischen Click-Events repräsentieren asynchrone Event-Streams, auf die man sich registrieren kann. "Reactive" basiert auf der gleichen Idee, nur ++. Das bedeutet, dass man aus allem Streams erzeugen kann, also nicht nur aus den typischen Events (Click, Hover, Focus, etc..) sonder auch aus Variablen, Eingaben, Eigenschaften, usw. Zum Beispiel könnten wir aus solchen Streams ein Hit-Counter System für ein Fighting Spiel erstellen. Zusätzlich könnten wir alle Treffer mitzählen.
Und so beginnt es...
Nun da wir wissen das ALLES ein Stream ist, fügen wir noch eine Prise "funktionale" Magie hinzu und schon können wir Streams beliebig kombinieren. Ein Stream kann der Input für einen anderen Stream sein. Dasselbe gilt für mehrere Streams. Streams können zusammengefügt (merge), gefiltert (filter) und auf andere Streams abgebildet (map) werden. Diese und viele weitere Operatoren von Streams können sehr gut mit Kugeln dargestellt werden. RxMarbles geben eine sehr gute Übersicht über gängige Operatoren. Für Unity wurden den Erweiterungen, Unity spezifische Operatoren hinzugefügt. Dazu aber später mehr :)
Betrachten wir zuerst Streams etwas genauer. Für eine bessere Darstellung habe ich, wie im oben verlinkten Artikel, ASCII Diagramme gewählt. Die Beispiele sind großteils einfach aus dem Artikel The introduction to Reactive Programming you've been missing kopiert, also Kudos to André Stalz :) Wie sieht ein Stream nun aus?
--a---b-c---d---X---|->
a, b, c, d sind die anliegenden Werte
X ist ein Fehler
| ist das 'completed' Signal
---> ist die timeline, also der Verlauf des Streams
Somit zeigt sich, dass ein Stream eine Sequenz von Ereignissen ist, die nach Eintrittszeitpunkt geordnet sind. Dabei kann der Stream 3 unterschiedliche Signale senden: Einen Wert, einen Fehler oder ein "Completed" Signal. Oft werden der Fehler und der Abschluß dabei vernachlässigt und der Fokus rein auf den anliegenden Wert gelegt.
Am Besten wir betrachten das Ganze anhand eines Beispieles. Wir möchten einen Stream, welcher uns anzeigt wie oft das Click Event eines Button oder dgl. auftritt. In den üblichen reaktiven Erweiterungen hat jeder Stream viele Funktionen wie map, filter, scan usw. Wird nun eine dieser Funktionen aufgerufen, wie bei clickStream.map(f), wird ein neuer Stream zurückgegeben, welcher auf dem Click Stream basiert. Der originale Stream wid übrigens NICHT modifiziert. Der Click Stream ist immutable (unveränderlich), eine wesentliche und sehr wichtige Eigenschaft für reaktive Programmierung. Dadurch kann ein Stream von mehreren Beobachtern abonniert werden und alle greifen auf dieselben Daten zu.
Ferner können wir mehrere Funktionen verketten, wie zB.: clickStream.map(f).scan(g) wobei f für eine Funktion mit einem Parameter (auf den die Funktion angewendet wird) steht. Ein Beispiel wäre hier: x => x * 10, welche dafür sorgt, dass ein Stream zurückgegeben wird, welcher die 10fachen Werte zurückgibt. g hingegen steht für eine Funktion mit zwei Parametern, wie zB.: (x, y) => x + y Hier werden alle Werte aufsummiert, wobei x für den Wert steht, welcher am Stream anliegt und y den jeweiligen Summenwert abbildet. Das nachfolgenden Diagramm illustriert den verketteten Aufruf dieser beiden Funktionen. Der counterStream ist dabei der Stream, der zurückgegeben wird.
clickStream: ---c----c--c----c------c-->
vvvvv map(c becomes 1) vvvv
---1----1--1----1------1-->
vvvvvvvvv scan(+) vvvvvvvvv
counterStream: ---1----2--3----4------5-->
map(f) ersetzt den Click-Wert gegen 1 und gibt einen neuen Stream zurück. Der neue Stream dient als Input für scan(g), welche die einzelnen Werte aufsummiert. Der counterStream ist der final zurückgegebene Stream, welcher die aufsummierten Werte beinhaltet UND diese zum jeweiligen Zeitpunkt eines Klicks zurückgibt.Da aber eine Summe aller Klicks wohl eher selten verwendet wird, machen wir die Aufgabe etwas schwieriger. Wir wollen prüfen ob zwischen zwei Events ein gewisser Zeitraum liegt (in diesem Bsp. 250ms). Solange Events mit weniger als 250ms Abstand eintreten, geht der Zähler nach oben. Dafür brauchen wir abseits der bereits bekannten Funktionen, zwei weitere. buffer und throttle. Während throttle einen gewissen Zeitraum definiert, übernimmt buffer das Zusammenfassen der Ergebnisse in Listen. D.h. wird in einem Intervall von weniger als 250ms (welches von throttle zur Verfügung gestellt wird) mehrmals geklickt, gibt buffer nach diesem Intervall eine Liste mit den einzelnen Klicks zurück. Hat diese Liste nun zwei oder mehr Elemente, gibt es ein Event auf das wir reagieren können. In diesem Bsp. wird es nur eine Konsolenausgabe sein, in zukünftigen Beispielen werden wir sicher mehr damit anstellen. :) Wenn wir das Diagramm etwas genauer gestalten, können wir uns es folgendermaßen vorstellen:
clickStream: -c--c--c------c----c--------------c------>
vvvvv Buffer(clickstream.Throttle(250)) vvvv
C C
-------------C-----------C-------------C->
C
vvvvvvvvv Map(get length of lists) vvvvvvvvv
-------------3-------------2-------------1->
vvvvvvvvv Filter(where >= 2) vvvvvvvvvvvvvvv
-------------3-------------2--------------->
Soweit die Theorie. Sehen wir uns mal an, wie das Ganze in Unity aussieht. Da wir hier sehr einfach mit geschachtelten Methoden arbeiten können und uns die Implementierung Dinge wie filter mit Where abnimmt, ist der daraus entstehende Codeteil relativ simpel. Wir erstellen zuerst einen clickStream, der jedes Update überprüft, ob mit der linken Maustaste geklickt wurde. Diesen Stream prüfen wir weniger als 250ms zwischen einzelnen Klicks liegen. Ist der letzte Klick länger als 250ms her, erfolgt eine Konsolenausgabe.
using System;
using UniRx;
using UnityEngine;
public class ReactivePlayground : MonoBehaviour {
void Start () {
var clickStream = Observable.EveryUpdate().Where(_ => Input.GetMouseButtonDown(0));
clickStream.Buffer(clickStream.Throttle(TimeSpan.FromMilliseconds(250)))
.Where(xs => xs.Count >= 2)
.Subscribe(xs => Debug.Log("MultipleClick Detected! Count:" + xs.Count));
}
}
Dieser Codeblock wird nun als Komponente auf ein leeres GameObject gelegt. Wird der Playmode von Unity nun gestartet, werden Klicks im Game Window gezählt.
Wow... wir haben mit nur 2 Codezeilen einen Klick-Zähler gebaut. Wenn man sich nun überlegt, welchen Aufwand man normalerweise betreiben müsste, um so eine simple Funktion zu bauen... Timing erfassen und zwischenspeichern, Klicks zählen & ausgeben... Da bevorzuge ich doch den reaktiven Weg. Auch Meister Pai Mei ist dieser Meinung und gratuliert uns zu unserer ersten Implementierung einer reaktiven Komponente. :)











