GUIs, States und State Machines
Grafische Benutzerschnittstellen haben in der Regel bestimmte interne Zustände, z.B. welche Bedienelemente sind aktiviert, editierbar, sichtbar. Das kann man aus zwei unterschiedlichen Perspektiven betrachten und entsprechend implementieren:
- Der Zustand der GUI setzt sich aus den internen Zuständen der einzelnen Elemente zusammen. Bei der Behandlung von Benutzereingaben oder anderen Ereignissen werden die relevanten Zustände abgefragt und davon abhängig in der Event Handler Methode reagiert. Dabei werden die Zustände der Elemente in der Regel verändert. Für einfache GUIs funktioniert dies, ist jedoch nicht besonders wartungsfreundlich und skalierbar.
- Der Zustand der GUI wird explizit abgebildet, die Zustände der einzelnen Elemente werden als Konsequenz des GUI-Zustandes betrachtet. Alle vom Benutzer oder System ausgehenden Ereignisse werden an eine State Machine weitergeleitet, die darauf reagiert. Dabei können zustandsabhängig Methoden ausgeführt und der GUI-Zustand geändert werden. Eine Zustandsänderung ändert dabei in der Regel auch die internen Zustände einzelner Elemente.
Für die Beschreibung eines Zustandsmodells als endlicher Automat haben sich unterschiedliche Formalismen entwickelt. Aktuell werden oft Subsets von UML State Charts verwendet.
Die einfachste Implementierung besteht aus geschachtelten if
oder case
Anweisungen, die den auszuführenden Code in Abhängigkeit von aktuellem Zustand und eintreffenden Ereignis auswählen. Bei etwas komplexeren Zustandsmodellen wird dies sehr unübersichtlich. Daher haben sich zur Implementierung von State Machines unterschiedliche Ansätze herausgebildet, die sich durch Pattern beschreiben lassen und für unterschiedliche Implementierungsumgebungen und Aufgabenstellungen geeignet sind. Dafür findet sich im Internet viel Literatur:
- Objects for States, z.B. Wikipedia mit weiterführenden Links
- Methods for States, z.B. in diesem Artikel
- Collections for States, z.B. in diesem Artikel
In Java ab Version 8 und in Groovy sind Lambda-Ausdrücke bzw. Closures verfügbar. Damit lässt sich Funktionalität in Datenstrukturen verwalten. Das ermöglicht eine kompakte Implementierung einer State Machine in Anlehnung an das Methods for States Pattern. Das Kernstück ist eine Map, die den Schlüssel aus aktuellem Zustand und eintreffendem Event berechnet und dafür einen Lambda-Ausdruck bereitstellt, der die ausgelöste Funktionalität implementiert.
Die treffendere Bezeichnung für die hier vorgestellte und im Beispiel verwendete Implementierung ist Methods for Transitions. Grundlagen:
- Auch komplexe UML State Charts mit hierarchischen Zuständen lassen sich mit „elementaren Transitionen“ darstellen, die von einem nicht weiter zerlegten „primitiven“ Zustand in einen anderen primitiven Zustand führen. Eine Transition von oder zu einem zusammengesetzten Zustand wird dabei möglicherweise in Form mehrerer elementarer Transitionen implementiert.
State Chart mit zusammengesetzten Zuständen (aus Wikipedia)
- Die in der State Chart definierten Aktivitäten lassen sich in den Transitions-Closures übersichtlich implementieren
- Zuerst alle exit() Aktivitäten der verlassenen States „von innen nach außen“: {a(), b()}.
- Dann die an die Transition gebundene Aktivität {T()}.
- Abschließend entry() Aktivitäten und Aktivitäten an Transitionen innerhalb von zusammengesetzten Zuständen
{c(), d(), e()}. - Interne Transitionen lassen sich als Transition ohne exit() und entry() implementieren, bei der Ausgangs- und Zielzustand gleich sind.
- Auch Guards und Choices lassen sich implementieren, wenn die Transition-Closure den Zielzustand der Transition überschreiben kann.
State Chart mit Guards und Choice-Pseudozuständen (aus Wikipedia) - Guards werden vor allen Aktivitäten bewertet. Ergeben sie
false
, wird direkt wieder in den Ausgangszustand zurückgekehrt. - Choices werden nach Ausführung von exit()- und Transitions-Aktivitäten bewertet und beeinflussen den Zielzustand und die Auswahl der entry()-Aktivitäten
- Guards werden vor allen Aktivitäten bewertet. Ergeben sie
Implementierung in Groovy
package de.geobe.util.vaadin import groovy.util.logging.Slf4j /** * Implementation of a simple state machine * Created by georg beier on 01.12.2015. * @param S enumertion Type for States * @param E enumeration type for events */ @Slf4j class DialogStateMachine<S extends Enum, E extends Enum> { /** map for actions, indexed by combination of currentState and event */ private Map<Integer,Closure> stateMachine = new HashMap<>() /** map for next states, indexed by combination of currentState and event */ private Map<Integer, S> nextState = new HashMap<>() /** store current state */ private S currentState /** for logging info to identify state machine instance */ private String smid def getCurrentState() { currentState } def setSmId(String id) { smid = id } /** * create instance with initial fromState * @param start initial fromState * @param id identifying string for logging and debug */ DialogStateMachine(S start, String id = 'default') { currentState = start smid = id } /** * add an action to this fromState machine * @param fromState the current state * @param toState the state to go after executing the action. * Can be overwritten by the action if it is returning an Enum<S> value * @param event event that triggers the fromState machine * @param action closure that is executed for combination of fromState and event */ void addTransition(S fromState, S toState, E event, Closure action) { Integer index = trix(fromState, event) stateMachine[index] = action if(toState) nextState[index] = toState } /** * execute closure that is identified by currentState and event. * After execution, statemachine will be * in the following state as defined in addTransition method, * if closure returns no object of type S * in the state returned by the closure. * If no following state is defined, statemachine will stay in currentState. * @param event triggering event * @param params optional parameter to closure. * Caution, closure will receive an Object[] Array * @return the current state after execution */ S execute(E event, Serializable... params) { Integer index = trix(currentState, event) if (stateMachine[index]) { Closure action = stateMachine[index] def result = action(params) def next if (result instanceof S) { next = result } else { next = (nextState[index] ?: currentState) } log.info("Transition $smid: $currentState--$event->$next") currentState = next } else { log.info("ignored event $event in fromState $currentState") } currentState } /** * calculate a unique transition index from current state and triggering event * @param st current state * @param ev event triggering transition * @return a unique Integer computed from state and event */ public Integer trix(S st, E ev) { def t = st.ordinal() + (ev.ordinal() << 12) t } }
Erläuterungen zum Code
- Zeilen 14, 16 und 18
- Zwei Maps enthalten die Kerninformation: Die Closure, die ausgehend vom currentState (18) durch das eintreffende Event getriggert wird, ist in stateMachine(14) abgelegt. nextState (16) enthält entsprechend die Folgezustände. Der Schlüssel für die Maps wird aus State und Event berechnet.
- Zeile 44
- Die state machine wird konfiguriert, indem alle elementaren Transitionen hinzugefügt werden.
- Zeile 63
- Die state machine wird verwendet, indem die Metode execute(…) mit dem eingehenden Event und optionalen Parametern aufgerufen wird. In dieser Methode wird die Closure der Transition aufgerufen und der Folgezustand festgelegt.
- Zeilen 70-72
- Der Folgezustand ergibt sich
- im Regelfall aus dem in der Map nextState abgelegten Wert,
- oder die Closure gibt einen Folgezustand als Ergebnis der Auswertung eines Guard oder Choice zurück,
- oder nextState ist undefiniert, dann bleibt der currentState erhalten (innere Transition).
- Zeile 89
- Der Transitionsindex wird als Summe der Ordinalzahl des State enum Wertes und der um 12 Stellen nach links geschobenen Ordinalzahl des Event enum Wertes berechnet. Damit sind 212 States möglich.