Definiendo comportamiento personalizado en FXML con FXMLLoader

Inyecta comportamiento personalizado en aplicaciones JavaFX utilizando FMXL.

En el primer artículo de esta serie, mencioné los conceptos básicos del formato FXML y una clase de utilería llamada FXMLLoader, que como su nombre lo indica, carga un recurso FXML, analiza su contenido y construye un SceneGraph basado en definiciones encontradas en archivos FXML. Sin embargo, FXMLLoader puede hacer más que eso, especialmente cuando se combina con una técnica desarrollo muy común en Java: inyección de dependencia (DI). Puede resultarte un poco difícil seguir este artículo sin antes leer el artículo anterior si no estás familiarizado con FXMLLoader.

Los diseñadores del formato FXML reconocieron que a los desarrolladores les gustaría modificar algunas configuraciones usando un enfoque programático, como insertar un conjunto dinámico de elementos en un ListView o TableView justo después de que se haya creado la interfaz de usuario, pero antes de que el usuario tenga oportunidad de interactuar con la aplicación. Para realizar dicha tarea, los desarrolladores de JavaFX deben ser capaces de definir el comportamiento personalizado que se debe invocar en un momento específico durante la inicialización de la aplicación. También necesitan una forma de mostrar el widget objetivo (ListView, por ejemplo) en el que se llevará a cabo el comportamiento personalizado. Este comportamiento suena muy similar a lo que proporcionan las herramientas modernas de DI, y esto es precisamente lo que los diseñadores de FXML te permiten hacer. Veamos cómo se logra.

El Controlador

Las vistas de JavaFX que viste en el primer artículo carecían de cualquier comportamiento específico en la aplicación, y eso es bueno, porque mantiene las responsabilidades separadas. Los elementos de vista deberían ser responsables de definir cómo se ve la UI, pero no cómo se comporta la misma; esta es la responsabilidad de la parte del controlador. En términos de FXML, un controlador es un objeto que participa en DI y puede reaccionar a eventos activados por elementos de UI definidos en la vista asociada. A diferencia del punto de entrada principal de una aplicación JavaFX, donde es requierido extender una clase específica (javafx.application .Application), una clase de controlador puede definir su propia jerarquía sin necesecidad de extener o implementer un tipo particular relacionado con JavaFX; esto te da rango libre para desarrollar tus propios tipos y jerarquías de controladores según sea necesario.

Comencemos con una aplicación simple que tiene una UI que define un Botón y un ListView cuyos contenidos se inicializarán programáticamente. Se agregará un nuevo elemento a la lista cada vez que se haga clic en el botón. La definición en FXML es similar a la siguiente:

sample/app.fxml

<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.layout.VBox?>
<VBox xmlns="http://javafx.com/javafx"
  xmlns:fx="http://javafx.com/fxml"
  fx:controller="sample.AppController"
  prefHeight="300.0" prefWidth="100.0">
  <Button text="Add item" onAction="#addItem"/>
  <ListView fx:id="theList"/>
</VBox>

Vemos nuevas características en este archivo. El primero es el atributo fx:controller aplicado al elemento raíz de la UI. Este atributo instruye a FXMLLoader para crear una instancia de un objeto de la clase objetivo y establecerla como el controlador para esta vista. Un objeto instanciado de esta manera requiere que la clase tenga un constructor público sin argumento.

La segunda característica se encuentra en la definición del botón: hay un atributo adicional llamado onAction. Tome nota especial del formato usado en su valor; es el carácter # seguido de un identificador, que resulta ser un método definido en la clase de controlador.

Por último, ten en cuenta el uso de fx:id en ListView; esto instruye a FXMLLoader para mantener una referencia interna al ListView, usando el nombre del atributo como un nombre de variable. Esta variable se usará para inyectar la referencia ListView en el controlador. Veamos cómo se ve el controlador:

sample/AppController.java

package sample;

import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.ListView;

public class AppController {
  @FXML private ListView<String> theList;

  public void initialize() {
    for (int i = 0; i < 5; i++) {
      theList.getItems().add("Item " + i);
    }
  }
    
  public void addItem(ActionEvent ignored) {
    theList.getItems().add("Item " + theList.getItems().size());
  }
}

Como puedes observar, el controlador se parece a una clase Java normal; sin embargo, hay un nuevo elemento en uso: la anotación @FXML. Este es el enlace que permite a FXMLLoader realizar inyección de elementos en la instancia del controlador objectivo. El uso de esta anotación aprovecha el nombre variable definido para la referencia ListView. Observa que el nombre del campo es el mismo que el nombre de la variable; también define el tipo de elementos que ListView puede contener: en este caso, una cadena de caracteres. Siguiendo esta convención de nombres, FXMLLoader relacionará los nombres de los campos con los nombres de las variables y los tipos de campos de los widgets objetivos. Dicho esto, ocurrirá una excepción en tiempo de ejecución si el campo se define como Choicebox<String> en su lugar. Este error puede ser evitado si el IDE está al tanto las convenciones de FXML.

La segunda característica es la definición de un método público cuyo nombre debe ser initialize y que no toma argumentos. FXMLLoader invoca este método después de que se hayan realizado todas las inyecciones de componentes. Este es el momento adecuado para realizar la inicialización de datos, ya que las referencias de los widgets han sido resueltas completamente e inyectadas hasta este punto. Eso es precisamente lo que sucede en este ejemplo al utilizar ListView con datos predeterminados. Si el método se llamare de otra manera, FXMLLoader no lo podría invocar en el momento requerido. Esta es una restricción inamovible definida por las convenciones de FXMLLoader.

Finalmente, puedes observar a un el código que reacciona a la invocación de acciones, definido con un método cuyo nombre coincide con el valor del atributo onAction asociado con el botón, como puedes ver en el archivo FXML. Este controlador reacciona al botón presionado; por lo tanto, se necesita un ActionEvent como argumento. Toma nota de este tipo, ya que cambiará según el tipo de evento al que desees que el código reaccione. Por ejemplo, el argumento debe ser de tipo MouseEvent si el código debe reaccionar a los movimientos del ratón. Este controlador de eventos se llama inmediatamente a medida que se hace clic en el botón, y también se invoca dentro del hilo de ejecución de UI. Eso no es un problema para esta aplicación trivial, porque el controlador simplemente agrega un nuevo elemento a la lista. Sin embargo, ten en cuenta esta configuración, ya que normalmente una aplicación abre una conexión de red, accede a una base de datos o archivo, o realiza todo tipo de cálculos que deben ejecutarse fuera del hilo de ejecución de UI. Cuando los datos estén listos, el código tendrá que volver a colocarlos en los elementos de la interfaz de usuario dentro del hilo de ejecución de UI. Por lo tanto, ten en cuenta las estructuras de manejo de hilos proporcionadas por JavaFX; si el procesamiento se realiza de forma incorrecta, es posible que obtengas excepciones en tiempo de ejecución, o una aplicación que no responda, daños en los datos o algo mucho peor.

En resumen, un controlador es responsable de proporcionar un comportamiento a una vista FXML, y establece una relación entre ellos utilizando el atributo fx:controller en el nodo raíz de una vista FXML. El FXMLLoader crea una instancia del controlador automáticamente, lo cual funciona bien la mayor parte del tiempo. Sin embargo, ¿qué pasa si necesitas que el controlador se instancie de una manera diferente? Tal vez requieras personalizaciones adicionales que deben realizarse antes de que se hayan inyectado todos los elementos anotados con @FXML. No te preocupes puede usar un marco DI para esta tarea. La siguiente sección muestra cómo hacer esto.

JSR 330 e Inyección de Dependencias

JSR 330 define los principios básicos para realizar DI en una apliacción Java. Existen varias implementaciones con a tu disposición. Por simplicidad, no mostraré la configuración de una herramienta de DI específica, sino que usaré la API estándar proporcionada por JSR 330 para los siguientes ejemplos.
Volvamos a hacer el ejemplo anterior utilizando un mecanismo de proveedor de datos en lugar de insertar datos en ListView mediante un simple bucle. Este mecanismo proveedor de datos se define utilizando una interfaz como la que sigue:

sample/DataProvider.java

package sample;

import java.util.Collection;

public interface DataProvider<T> {
  Collection<T> getData();
}

Muy bien, pueds implementar esta interfaz de cualquier forma que requiera la aplicación, como capturar los datos de un archivo o base de datos a través de JDBC u otros medios. La clase de controlador se ve así una vez que ha sido actualizada con este nuevo mecanismo:

sample/AppController.java

package sample;

import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.scene.control.ListView;
import javax.inject.Inject;

public class AppController {
  @FXML private ListView<String> theList;

  private final DataProvider<String> dataProvider;

  @Inject
  public AppController(DataProvider<String> dp) {
      this.dataProvider = dp;
  }

  public void initialize() {
    theList.getItems().addAll(dataProvider.getData());
  }

  public void addItem(ActionEvent ignored) {
    theList.getItems().add("Item " + theList.getItems().size());
  }
}

Toma nota que el controlador define un constructor con un solo argumento y es anotado con @Inject. Este simple cambio contradice las convenciones de FXMLLoader para crear instancias de un controlador. Debemos encontrar otra forma de permitir que FXMLLoader funcione con la clase de controlador. Afortunadamente existe una manera. FXMLLoader se puede configurar para utilizar una estrategia diferente para crear instancias del controlador: definiendo un ControllerFactory. Con este conocimiento, solo tiene que regresar al punto de entrada de la aplicación y configurar la instancia de FXMLLoader con esta nueva información, por ejemplo:

sample/AppMain.java

package sample;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
import java.net.URL;

public class AppMain extends Application {
  @Override
  public void start(Stage primaryStage) throws Exception {
    // Initialized via DI framework
    AppController controller = ...
    URL location = getClass().getResource("app.fxml");
    FXMLLoader fxmlLoader = new FXMLLoader(location);
    // set the ControllerFactory
    fxmlLoader.setControllerFactory(param -> controller);
    VBox vbox = fxmlLoader.load();
    Scene scene = new Scene(vbox);
    primaryStage.setScene(scene);
    primaryStage.sizeToScene();
    primaryStage.show();
  }
}

Listo. La instancia del controlador se obtiene del contenedor DI. Esta operación de búsqueda varía de una herramienta a otra por lo tanto se omite en este ejemplo. Incluso puedes aplicar una expresión lambda para definir un ControllerFactory simple. Dado que la clase AppController ahora está administrada por el contenedor DI, puedes aprovechar otras características como los métodos anotados con @PostConstruct, lo que permite más personalizaciones en el controlador. Solo recuerda que cualquier método anotado con @PostConstruct se invocará antes de que cualquier campo anotado con @FXML sea inyectado en la instancia del controlador. Como efecto lateral de esta configuración, el valor de fx:controller se convierte en irrelevante. Sin embargo, sigue siendo una buena idea especificar el tipo real del controlador suministrado por el contenedor DI, porque el IDE te dará pistas sobre los tipos y nombres de widgets que pueden ser inyectados usando las anotaciones @FXML, así como los nombres de los gestores de eventos suministrados por el controlador. Como alternativa, podría haber establecido la propiedad del controlador directamente en FXMLLoader, pero decidí mostrar el enfoque de fábrica, porque podría resultar útil para calcular el valor de forma perezosa.

Hasta este punto, hemos trabajado con vistas y controladores. Pero como sabes hay varios patrones de diseño que se pueden utilizar para diseñar una aplicación de interfaz de usuario, la mayoría de ellos son una variación del patrón Modelo-Vista-Controlador (MVC), como Model-View-Presenter y Model-View-ViewModel. Estos patrones marcan una diferenciación explícita de cada miembro por responsabilidad, lo que mantiene la implementación limpia y ordenada, lo que hace que sea fácil orientarse en la aplicación.

Es posible que te preguntes dónde podría estar el modelo en los ejemplos que hemos visto. La respuesta es dicho model se encuentra implícito dentro del controlador. Si tuviera que mantener un estado, como una ObservableList<String> de todos los elementos, es probable que el primer lugar para colocarlo sea en el controlador mismo. Para una aplicación pequeña como esta, esta decisión podría ser la adecuada, pero a medida que la aplicación crezca en complejidad y características, rápidamente te darás cuenta de que esta decisión no es escalable. Por lo tanto, te recomiendo que pienses en términos del patrón MVC y sus variantes, como por ejemplo, coloca el comportamiento en el controlador solamente, dejando los datos y el estado en una clase de modelo. Las instancias de esta clase pueden crearse manualmente o a través de un contenedor DI; depende de tí decidir cual técnica es mas conveniente dados los requisitos particulares de la aplicación.

Conclusión

FXML proporciona una forma declarativa para definir interfaces de usuario en JavaFX, pero no se puede usar para definir el comportamiento. Se puede especificar un controlador para proporcionar el comportamiento requerido. Los controladores pueden participar en DI para localizar a los componentes de UI que necesitan configurar. Los controladores también pueden definir gestores de eventos que puedan enlazarse directamente a los componentes UI utilizando un formato especial en el archivo FXML. FXMLLoader es capaz de crear instancias de controladores utilizando una estrategia predeterminada; sin embargo, esta estrategia puede cambiarse si se requiriera un mecanismo de instanciación diferente.

La versión original de este artículo fue publicada en Oracle Java Magazine, Sep 2017.

ˆ Back To Top