InjectingControllerFactory

Im Java-Forum wurde kürzlich die Frage gestellt, wie man bei JavaFX in Verbindung mit dem FXMLLoader Controller verwenden kann, die Konstruktorparameter benötigen.

Die Fragestellerin stand vor folgendem Problem: Der Controller einer FXML-basierten UI benötigt bereits in der initialize-Methode Zugriff auf bestimmte Objekte, die von außen übergeben werden müssen. Damit diese Objekte zum Zeitpunkt der Initialisierung verfügbar sind, wäre es naheliegend, sie über den Konstruktor einzuschleusen.

Anstatt jedoch sofort auf die Lösung „Konstruktor mit Parametern“ zu springen, lohnt es sich, einen Blick auf die verschiedenen Wege zu werfen, über die man Daten in einen JavaFX-Controller einbringen kann. Dabei ist der zeitliche Ablauf entscheidend: Wann findet was genau statt?

Ablauf beim Laden einer UI über den FXMLLoader

Der Ablauf beim Laden einer FXML-basierten Oberfläche mit dem FXMLLoader ist wichtig, um zu verstehen, wann welche Daten bereitstehen und wie man sinnvoll auf sie zugreift:

  1. Konstruktor des Controllers
    Wird sofort nach dem Laden der FXML-Datei instanziiert. Zu diesem Zeitpunkt sind keine @FXML-annotierten Felder gesetzt. Auch initialize() wurde noch nicht aufgerufen. Konstruktoren mit Parametern funktionieren nur, wenn man eine eigene ControllerFactory nutzt.

  2. Injection der @FXML-Felder
    Sobald der Controller instanziiert ist, setzt der FXMLLoader alle Felder mit @FXML, sofern sie in der FXML-Datei deklariert sind und übereinstimmen.

  3. Aufruf der initialize()-Methode
    Ist im Controller entweder das Interface Initializable implementiert oder eine Methode public void initialize() definiert, ruft der FXMLLoader diese Methode nach der Feldinjektion automatisch auf.

  4. Zuweisung des UI-Baums zu einer Scene
    Die Methode load() des FXMLLoader gibt die Wurzel des UI-Baums zurück. Diese wird z.B. in eine Scene eingebettet:

    1
    2
    3
    4
    
    FXMLLoader loader = new FXMLLoader(getClass().getResource("demo.fxml"));
    Parent root = loader.load(); // Schritt 1–3 passieren intern
    Scene scene = new Scene(root);
    stage.setScene(scene);
    

Userdaten in übergeordneter Scene oder Stage

Eine einfache Möglichkeit, Daten an den Controller zu übergeben, besteht darin, diese über die Scene oder Stage zu transportieren. Diese sind nach dem Laden der UI über den FXMLLoader und dem Setzen der Scene verfügbar. Dadurch ist der Zugriff nicht direkt im initialize möglich sondern muss verzögert werden. Wenn man bereits auf dem JavaFX Application Thread sein sollte, reicht ein einfaches Platform.runLater aus, um die Ausführung zu verzögern. Eine andere Option kann sein, einen Listener auf die sceneProperty zu setzen.

Beispiel: Zugriff auf Daten über die Stage

Angenommen, die aufrufende Stelle setzt ein beliebiges Objekt als UserData der Stage:

1
2
3
4
5
6
7
8
FXMLLoader loader = new FXMLLoader(getClass().getResource("demo.fxml"));
Parent root = loader.load();
Scene scene = new Scene(root);
stage.setScene(scene);

// Übergabe beliebiger Objekte über UserData
stage.setUserData(new SessionData("Konrad", "Admin"));
stage.show();

Innerhalb des Controllers könnte man wie folgt auf diese Daten zugreifen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class DemoController {

    @FXML
    private Label welcomeLabel;

    public void initialize() {
        // Noch keine Stage verfügbar – also später reagieren:
        Platform.runLater(() -> {
            Stage stage = (Stage) welcomeLabel.getScene().getWindow();
            Object data = stage.getUserData();
            if (data instanceof SessionData session) {
                welcomeLabel.setText("Willkommen, " + session.userName());
            }
        });
    }
}

Erklärung

In diesem Beispiel wird die Methode Platform.runLater verwendet, um sicherzustellen, dass der Zugriff auf die Stage erst erfolgt, wenn diese vollständig aufgebaut ist und das Scene-Graph verfügbar ist. Erst dann kann der Controller auf das Window zugreifen und die zuvor gesetzten Daten lesen. Die Klasse SessionData ist dabei eine einfache Record-Klasse zur Übergabe beliebiger Informationen.

Daten nach der Erzeugung des Controllers übergeben

Ein weiterer gangbarer Weg besteht darin, den Controller vom FXMLLoader erzeugen zu lassen – also keinen eigenen Controller-Factory-Mechanismus zu verwenden – und erst nach dem Laden der UI auf den Controller zuzugreifen, um diesem die notwendigen Daten zu übergeben.

Der FXMLLoader bietet hierzu die Methode getController(), mit der man auf den erzeugten Controller zugreifen kann.

Beispiel: Zuweisung von Daten nach dem Laden

1
2
3
4
5
FXMLLoader loader = new FXMLLoader(getClass().getResource("demo.fxml"));
Parent root = loader.load(); // Der Controller wird intern erzeugt

DemoController controller = loader.getController(); // Zugriff nach dem Laden
controller.setSessionData(new SessionData("Konrad", "Admin")); // Daten zuweisen

Der Controller sollte dabei eine entsprechende Methode zur Übergabe der Daten bereitstellen, z.B.:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class DemoController {

    private SessionData sessionData;

    public void setSessionData(SessionData sessionData) {
        this.sessionData = sessionData;
    }

    @FXML
    private Label welcomeLabel;

    public void initialize() {
        if (sessionData != null) {
            welcomeLabel.setText("Willkommen, " + sessionData.userName());
        }
    }
}

Hinweis zur Initialisierung

In obigem Beispiel könnte es passieren, dass die initialize()-Methode bereits vor dem Setzen der Daten aufgerufen wurde. In solchen Fällen sollte die setSessionData()-Methode selbst für eine nachträgliche Initialisierung sorgen:

1
2
3
4
5
6
public void setSessionData(SessionData sessionData) {
    this.sessionData = sessionData;
    if (welcomeLabel != null) {
        welcomeLabel.setText("Willkommen, " + sessionData.userName());
    }
}

Alternativ kann man auch einen expliziten Initialisierungsaufruf nach dem Laden vorsehen, wenn komplexere Logik erforderlich ist.

FXMLLoader mit eigener ControllerFactory

Wenn ein Controller zwingend Konstruktorparameter benötigt, kann man dem FXMLLoader eine eigene ControllerFactory übergeben. Diese wird zur Erzeugung des Controllers verwendet – anstelle der standardmäßigen Instanziierung über den no-args-Konstruktor.

Für einfache Fälle reicht bereits eine Lambda-Funktion, um z.B. einen Controller mit bestimmten Abhängigkeiten zu erzeugen.

Beispiel: Übergabe einer ControllerFactory

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
SessionData session = new SessionData("Konrad", "Admin");

FXMLLoader loader = new FXMLLoader(getClass().getResource("demo.fxml"));

// Übergabe der ControllerFactory als Lambda
loader.setControllerFactory(clazz -> {
    if (clazz == DemoController.class) {
        return new DemoController(session);
    } else {
        try {
            return clazz.getDeclaredConstructor().newInstance();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
});

Parent root = loader.load();

Der Controller selbst besitzt dann nur noch einen Konstruktor und keine Setter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class DemoController {

    private final SessionData sessionData;

    public DemoController(SessionData sessionData) {
        this.sessionData = sessionData;
    }

    @FXML
    private Label welcomeLabel;

    @FXML
    public void initialize() {
        welcomeLabel.setText("Willkommen, " + sessionData.userName());
    }
}

Erklärung

Durch die Verwendung der setControllerFactory-Methode geben wir dem FXMLLoader die volle Kontrolle darüber, wie Controller instanziiert werden. In diesem Fall prüfen wir, ob der gewünschte Controller DemoController ist und geben eine manuell erstellte Instanz zurück. Für andere Klassen greifen wir auf den Standardkonstruktor zurück.

Der große Vorteil: Die benötigten Daten sind bereits beim Konstruktoraufruf verfügbar – wir umgehen so die Unsicherheiten bezüglich des Zeitpunkts, an dem initialize() aufgerufen wird. Die Lösung ist daher ideal für klar definierte Abhängigkeiten, besonders bei einer stärker modularisierten Architektur oder bei Verwendung eines Dependency-Injection-Konzepts.

Problematik: Kapselung

Bei den bisherigen Lösungen haben wir klare Abhängigkeiten: Der Code, der den FXMLLoader aufruft, muss sowohl die FXML-Ressource kennen als auch exakt wissen, welche Controllerklasse instanziiert wird und welche Abhängigkeiten diese benötigt.

Das führt zu zwei Nachteilen:

  1. Starke Kopplung zwischen UI-Lader und Controller – Änderungen am Controller erfordern oft Änderungen an der ladenden Stelle.
  2. Zirkuläre Abhängigkeiten – der Controller kennt typischerweise Teile der Anwendung (Model, Services), aber auch die Anwendung muss nun den Controller kennen, um ihn korrekt zu instanziieren.

Ziel einer guten Architektur ist es jedoch, solche Abhängigkeiten zu vermeiden. Idealerweise kennen wir nur die FXML-Ressource und delegieren alles Weitere. Der konkrete Controller-Typ sollte dem aufrufenden Code egal sein.

Eine Möglichkeit, diese Kapselung zu erreichen, ist es, dass Controller ein gemeinsames Interface implementieren, über das sie ihre Abhängigkeiten entgegennehmen. So kann der aufrufende Code generisch mit dem Controller kommunizieren – ohne dessen Typ kennen zu müssen.

Beispiel: Übergabe über ein Interface

1
2
3
public interface InjectsSessionData {
    void setSessionData(SessionData data);
}

Der Controller:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
public class DemoController implements InjectsSessionData {

    private SessionData sessionData;

    @Override
    public void setSessionData(SessionData data) {
        this.sessionData = data;
    }

    @FXML
    private Label welcomeLabel;

    @FXML
    public void initialize() {
        if (sessionData != null) {
            welcomeLabel.setText("Willkommen, " + sessionData.userName());
        }
    }
}

Der Aufrufer muss nur noch prüfen, ob der Controller das Interface unterstützt:

1
2
3
4
5
6
7
FXMLLoader loader = new FXMLLoader(getClass().getResource("demo.fxml"));
Parent root = loader.load();
Object controller = loader.getController();

if (controller instanceof InjectsSessionData injectTarget) {
    injectTarget.setSessionData(new SessionData("Konrad", "Admin"));
}

Damit lösen wir die konkrete Kopplung an die Controllerklasse auf und können generisch mit beliebigen Controllern umgehen, die das gewünschte Interface implementieren.

Generische Lösung

In vielen Java-Projekten kommen Dependency-Injection-Frameworks zum Einsatz, die Abhängigkeiten automatisch auflösen und verwalten. Der Gedanke liegt also nahe, eine ähnliche Mechanik für JavaFX-Controller einzuführen.

Vorgehen bei DI Frameworks (abstrakt)

In einem typischen Dependency-Injection-Framework laufen mehrere Aufgaben zusammen:

a) Erkennung von Komponenten
Das Framework erkennt automatisch Klassen, die als Komponenten genutzt werden sollen, z. B. durch Annotationen wie @Component oder durch explizite Registrierung. Das erleichtert die Verwaltung und reduziert manuellen Aufwand.

b) Erzeugung und Verwaltung von Instanzen
Die Instanzen der Komponenten werden vom Framework verwaltet. Dabei kann z. B. konfiguriert werden, ob es sich um Einzelinstanzen (Singletons) oder neue Objekte pro Verwendung handelt. Dadurch entfällt für den Entwickler die Verantwortung für Lebenszyklus und Wiederverwendung.

c) Auflösen und Injizieren von Abhängigkeiten
Bei der Erstellung neuer Objekte erkennt das Framework automatisch die benötigten Abhängigkeiten (z. B. Konstruktorparameter oder Felder) und stellt passende Instanzen zur Verfügung. Dies ermöglicht lose Kopplung und fördert Testbarkeit und Wiederverwendbarkeit.

Umsetzung im Rahmen von JavaFX

Für eine einfache, aber effektive Implementierung einer Mini-DI-Lösung in einem JavaFX-Kontext können wir die Schritte a–c pragmatisch umsetzen:

a) Komponentenregistrierung manuell durch den Entwickler
Anstatt automatisch mit Annotationen oder Scans zu arbeiten, lassen wir den Entwickler selbst festlegen, welche Instanzen als „injectable“ gelten sollen. Das kann z. B. über eine einfache Map<Class<?>, Object> geschehen. So behalten wir die Kontrolle und vermeiden unnötige Komplexität.

b) Controller-Erzeugung durch eine eigene ControllerFactory
Die Controller-Erzeugung wird zentral über eine spezielle ControllerFactory abgewickelt. Diese prüft, ob der gewünschte Controller bekannt ist, analysiert die Konstruktoren und erstellt bei Bedarf neue Instanzen.

c) Abhängigkeitsauflösung über Konstruktoranalyse
Um die Abhängigkeiten zu erkennen, wird zur Laufzeit der Konstruktor der Zielklasse ausgewertet. Wir wählen z. B. den Konstruktor mit der größten Anzahl an Parametern, bei dem alle Parameter-Typen über unsere Registrierungs-Map abgedeckt sind. Dann rufen wir diesen Konstruktor mit den passenden Werten auf. Diese einfache Form der Constructor-Injection ist klar, effizient und gut nachvollziehbar.

Mit dieser Strategie erreichen wir eine flexible und generische Möglichkeit, JavaFX-Controller mit beliebigen Abhängigkeiten auszustatten – ganz ohne Framework oder Annotationen.

Generische ControllerFactory – Beispielimplementierung

Eine mögliche Implementation für eine generische ControllerFactory, die Constructor Injection unterstützt, könnte wie folgt aussehen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package de.neitzel.fx.injectfx;

import javafx.util.Callback;

import java.lang.reflect.Constructor;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;

/**
 * The InjectingControllerFactory is responsible for creating controller instances for JavaFX that
 * support dependency injection. It uses a parameter map to resolve and supply dependencies to
 * controller constructors dynamically during instantiation.
 *
 * This class simplifies the process of injecting dependencies into JavaFX controllers by analyzing
 * the constructors of the given controller classes at runtime. It selects the constructor that best
 * matches the available dependencies in the parameter map and creates an instance of the controller.
 *
 * It implements the Callback interface to provide compatibility with the JavaFX FXMLLoader, allowing
 * controllers with dependencies to be injected seamlessly during the FXML loading process.
 */
public class InjectingControllerFactory implements Callback<Class<?>, Object> {
   /**
    * A map that stores class-to-object mappings used for dependency injection
    * in controller instantiation. This map is utilized to resolve and supply
    * the required dependencies for constructors during the creation of controller
    * instances.
    *
    * Each key in the map represents a class type, and the corresponding value
    * is the instance of that type. This allows the {@link InjectingControllerFactory}
    * to use the stored instances to dynamically match and inject dependencies
    * into controllers at runtime.
    */
   private final Map<Class<?>, Object> parameterMap = new HashMap<>();

   /**
    * Adds a mapping between a class and its corresponding object instance
    * to the parameter map used for dependency injection.
    *
    * @param clazz  The class type to be associated with the provided object instance.
    * @param object The object instance to be injected for the specified class type.
    */
   public void addInjectingData(Class<?> clazz, Object object) {
      parameterMap.put(clazz, object);
   }

   /**
    * Creates an instance of a controller class using a constructor that matches the dependencies
    * defined in the parameter map. The method dynamically analyzes the constructors of the given
    * class and attempts to instantiate the class by injecting required dependencies.
    *
    * @param controllerClass the class of the controller to be instantiated
    * @return an instance of the specified controller class
    * @throws RuntimeException if an error occurs while creating the controller instance or if no suitable constructor is found
    */
   @Override
   public Object call(Class<?> controllerClass) {
      try {
         Optional<Constructor<?>> bestConstructor = Stream.of(controllerClass.getConstructors())
                 .filter(constructor -> canBeInstantiated(constructor, parameterMap))
                 .findFirst();

         if (bestConstructor.isPresent()) {
            Constructor<?> constructor = bestConstructor.get();
            Object[] parameters = Stream.of(constructor.getParameterTypes())
                    .map(parameterMap::get)
                    .toArray();

            return constructor.newInstance(parameters);
         } else {
            throw new IllegalStateException("Kein passender Konstruktor gefunden für " + controllerClass.getName());
         }
      } catch (Exception e) {
         throw new RuntimeException("Fehler beim Erstellen der Controller-Instanz für " + controllerClass.getName(), e);
      }
   }

   /**
    * Determines if a given constructor can be instantiated using the provided parameter map.
    * This method checks if the parameter map contains entries for all parameter types required
    * by the specified constructor.
    *
    * @param constructor The constructor to be evaluated for instantiability.
    * @param parameterMap A map where keys are parameter types and values are the corresponding
    *                     instances available for injection.
    * @return {@code true} if the constructor can be instantiated with the given parameter map;
    *         {@code false} otherwise.
    */
   private boolean canBeInstantiated(Constructor<?> constructor, Map<Class<?>, Object> parameterMap) {
      return Stream.of(constructor.getParameterTypes()).allMatch(parameterMap::containsKey);
   }
}

(Siehe auch InjectingControllerFactory in NeitzelLib @ GitHub)

Beschreibung

Diese Factory ermöglicht die Instanziierung eines JavaFX-Controllers mit Hilfe von Constructor Injection – ohne auf ein externes Framework angewiesen zu sein.

  • Entwickler können gezielt Objekte über addInjectingData(...) registrieren.
  • Beim Aufruf von call(...) sucht die Factory den ersten Konstruktor, für den alle Parameter in der Registrierungs-Map (parameterMap) vorhanden sind.
  • Dieser Konstruktor wird dann mit den passenden Werten aufgerufen.

Die Klasse implementiert Callback<Class<?>, Object>, was bedeutet, dass sie direkt per setControllerFactory(...) beim FXMLLoader verwendet werden kann.


Beispiel: Verwendung der InjectingControllerFactory

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
InjectingControllerFactory factory = new InjectingControllerFactory();
factory.addInjectingData(SessionData.class, new SessionData("Konrad", "Admin"));

FXMLLoader loader = new FXMLLoader(getClass().getResource("demo.fxml"));
loader.setControllerFactory(factory);

Parent root = loader.load();
Scene scene = new Scene(root);
stage.setScene(scene);
stage.show();

Beispielcontroller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class DemoController {

    private final SessionData sessionData;

    public DemoController(SessionData sessionData) {
        this.sessionData = sessionData;
    }

    @FXML
    private Label welcomeLabel;

    @FXML
    public void initialize() {
        welcomeLabel.setText("Willkommen, " + sessionData.userName());
    }
}

Mit dieser Lösung kann man lose gekoppelte, testbare JavaFX-Controller bauen, ohne sich auf Annotationen oder komplexe Frameworks verlassen zu müssen.