Skip to content

10.6 Mejoras prácticas

1. Propiedades estilizables

Se puede diseñar una propiedad JavaFX a través de css usando StyleableProperty. Esto es útil cuando los controles necesitan propiedades que se pueden configurar a través de css.

Para usar StyleableProperty en un Control, se necesita crear un nuevo CssMetaData usando StyleableProperty. Los CssMetaData creados para un control deben agregarse a List<CssMetaData> obtenidos del antecesor del control. Esta nueva lista luego se devuelve desde el archivo getControlCssMetaData().

Por convención, las clases de control que tienen CssMetaData implementarán un método estático getClassCssMetaData() y es habitual que getControlCssMetaData() simplemente devuelva getClassCssMetaData(). El propósito de getClassCssMetaData() es permitir que las subclases incluyan fácilmente los CssMetaData de algún antepasado.

// StyleableProperty
private final StyleableProperty<Color> color =
    new SimpleStyleableObjectProperty<>(COLOR, this, "color");

// Typical JavaFX property implementation
public Color getColor() {
    return this.color.getValue();
}
public void setColor(final Color color) {
    this.color.setValue(color);
}
public ObjectProperty<Color> colorProperty() {
    return (ObjectProperty<Color>) this.color;
}

// CssMetaData
private static final CssMetaData<MY_CTRL, Paint> COLOR =
    new CssMetaData<MY_CTRL, Paint>("-color", PaintConverter.getInstance(), Color.RED) {

    @Override
    public boolean isSettable(MY_CTRL node) {
        return node.color == null || !node.color.isBound();
    }

    @Override
    public StyleableProperty<Paint> getStyleableProperty(MY_CTRL node) {
        return node.color;
    }
};

private static final List<CssMetaData<? extends Styleable, ?>> STYLEABLES;
static {
    // Fetch CssMetaData from its ancestors
    final List<CssMetaData<? extends Styleable, ?>> styleables =
        new ArrayList<>(Control.getClassCssMetaData());
    // Add new CssMetaData
    styleables.add(COLOR);
    STYLEABLES = Collections.unmodifiableList(styleables);
}

// Return all CssMetadata information
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
    return STYLEABLES;
}

@Override
public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
    return getClassCssMetaData();
}

La creación de StyleableProperty y CssMetaData necesita una gran cantidad de código repetitivo y esto se puede reducir mediante el uso de StyleablePropertyFactory . StyleablePropertyFactory contiene métodos para crear StyleableProperty con los CssMetaData correspondientes.

// StyleableProperty
private final StyleableProperty<Color> color =
    new SimpleStyleableObjectProperty<>(COLOR, this, "color");

// Typical JavaFX property implementation
public Color getColor() {
    return this.color.getValue();
}
public void setColor(final Color color) {
    this.color.setValue(color);
}
public ObjectProperty<Color> colorProperty() {
    return (ObjectProperty<Color>) this.color;
}

// StyleablePropertyFactory
private static final StyleablePropertyFactory<MY_CTRL> FACTORY =
    new StyleablePropertyFactory<>(Control.getClassCssMetaData());

// CssMetaData from StyleablePropertyFactory
private static final CssMetaData<MY_CTRL, Color> COLOR =
    FACTORY.createColorCssMetaData("-color", s -> s.color, Color.RED, false);

// Return all CssMetadata information from StyleablePropertyFactory
public static List<CssMetaData<? extends Styleable, ?>> getClassCssMetaData() {
    return FACTORY.getCssMetaData();
}

@Override public List<CssMetaData<? extends Styleable, ?>> getControlCssMetaData() {
    return getClassCssMetaData();
}

2. Tareas

Ahora veremos cómo usar una tarea JavaFX para mantener la IU responsible. Es imperativo que cualquier operación que tarde más de unos pocos cientos de milisegundos se ejecute en un subproceso separado para evitar bloquear la interfaz de usuario. Una tarea concluye la secuencia de pasos en una operación de larga duración y proporciona devoluciones de llamada para los posibles resultados.

La clase Task también mantiene al usuario al tanto de la operación a través de propiedades que se pueden vincular a controles de interfaz de usuario como ProgressBars y Labels. El enlace actualiza dinámicamente la interfaz de usuario. Estas propiedades incluyen

  1. runningProperty : si la tarea se está ejecutando o no
  2. ProgressProperty : el porcentaje completado de una operación.
  3. messageProperty : texto que describe un paso en la operación

2.1. Demostración

Las siguientes capturas de pantalla muestran el funcionamiento de una aplicación de recuperación de HTML.

Ingresar una URL y presionar "Ir" iniciará una tarea JavaFX. Al ejecutarse, la tarea hará visible un HBox que contiene una barra de progreso y una etiqueta. ProgressBar y Label se actualizan a lo largo de la operación.

tareas pb

Cuando finaliza la recuperación, se invoca al metodo succeeded() y se actualiza la interfaz de usuario. Tenga en cuenta que la llamada a succeeded() se lleva a cabo en el subproceso FX, por lo que es seguro manipular los controles.

contenido de las tareas

Si hubo un error al recuperar el HTML, se invoca a failed() y se muestra una alerta de error. failed() también tiene lugar en el subproceso FX. Esta captura de pantalla muestra una entrada no válida. Se usa una "h" en la URL en lugar de "http".

error de tareas

2.2. Código

Se coloca un controlador de eventos en el botón Obtener HTML que crea la tarea. El punto de entrada de la Tarea es el método call() que comienza llamando a updateMessage() y updateProgress(). Estos métodos se ejecutan en el subproceso FX y generarán actualizaciones en cualquier propiedad enlazada.

El programa continúa emitiendo un HTTP GET usando clases estándar de java.net. Se crea una cadena "retval" a partir de los caracteres recuperados. Las propiedades de mensaje y progreso se actualizan con más llamadas a updateMessage() y updateProgress(). El método call() finaliza con la devolución de la cadena que contiene el texto HTML.

En una operación exitosa, se invoca la devolución de llamada de éxito (). getValue() es un método de tarea que devolverá el valor acumulado en la tarea (recuerde "retval"). El tipo del valor es lo que se proporciona en el argumento genérico, en este caso "String". Esto podría ser un tipo complejo como un objeto de dominio o una colección. La operación de éxito () se ejecuta en el subproceso FX, por lo que la cadena getValue () se establece directamente en el área de texto.

Si la operación falla, se lanza una excepción. La excepción es capturada por la tarea y convertida en una llamada fallida(). fail() también es seguro para subprocesos FX y muestra una alerta.

String url = tfURL.getText();

Task<String> task = new Task<String>() {

    @Override
    protected String call() throws Exception {

        updateMessage("Getting HTML from " + url );
        updateProgress( 0.5d, 1.0d );

        HttpURLConnection c = null;
        InputStream is = null;
        String retval = "";

        try {

            c = (HttpURLConnection) new URL(url).openConnection();

            updateProgress( 0.6d, 1.0d );
            is = c.getInputStream();
            int ch;
            while( (ch=is.read()) != -1 ) {
                retval += (char)ch;
            }

        } finally {
            if( is != null ) {
                is.close();
            }
            if( c != null ) {
                c.disconnect();
            }
        }

        updateMessage("HTML retrieved");
        updateProgress( 1.0d, 1.0d );

        return retval;
    }

    @Override
    protected void succeeded() {
        contents.setText( getValue() );
    }

    @Override
    protected void failed() {
        Alert alert = new Alert(Alert.AlertType.ERROR, getException().getMessage() );
        alert.showAndWait();
    }
};

Tenga en cuenta que la tarea no actualiza la barra de progreso y la etiqueta de estado directamente. En su lugar, Task realiza llamadas seguras a updateMessage() y updateProgress(). Para actualizar la interfaz de usuario, se utiliza el enlace JavaFX en las siguientes declaraciones.

1
2
3
bottomControls.visibleProperty().bind( task.runningProperty() );
pb.progressProperty().bind( task.progressProperty() );
messageLabel.textProperty().bind( task.messageProperty() );

Task.runningProperty es un valor booleano que se puede vincular a bottomControls HBox visibleProperty. Task.progressProperty es un doble que se puede vincular a ProgressBarprogressProperty. Task.messageProperty es una cadena que se puede vincular a la etiqueta de estado textProperty.

Para ejecutar la tarea, cree un subproceso que proporcione la tarea como argumento del constructor e invoque start().

new Thread(task).start();

Para cualquier operación de ejecución prolongada (archivo IO, la red), use una tarea JavaFX para mantener la capacidad de respuesta de su aplicación. La tarea JavaFX le brinda a su aplicación una forma consistente de manejar operaciones asincrónicas y expone varias propiedades que se pueden usar para eliminar la lógica repetitiva y de programación.

2.3. Código completo

El código se puede probar en un solo archivo .java.

public class ProgressBarApp extends Application {

    private HBox bottomControls;
    private ProgressBar pb;
    private Label messageLabel;

    private TextField tfURL;

    private TextArea contents;

    @Override
    public void start(Stage primaryStage) throws Exception {

        Parent p = createMainView();

        Scene scene = new Scene(p);

        primaryStage.setTitle("ProgressBarApp");
        primaryStage.setWidth( 667 );
        primaryStage.setHeight( 376 );
        primaryStage.setScene( scene );
        primaryStage.show();
    }

    private Parent createMainView() {

        VBox vbox = new VBox();
        vbox.setPadding( new Insets(10) );
        vbox.setSpacing( 10 );

        HBox topControls = new HBox();
        topControls.setAlignment(Pos.CENTER_LEFT);
        topControls.setSpacing( 4 );

        Label label = new Label("URL");
        tfURL = new TextField();
        HBox.setHgrow( tfURL, Priority.ALWAYS );
        Button btnGetHTML = new Button("Get HTML");
        btnGetHTML.setOnAction( this::getHTML );
        topControls.getChildren().addAll(label, tfURL, btnGetHTML);

        contents = new TextArea();
        VBox.setVgrow( contents, Priority.ALWAYS );

        bottomControls = new HBox();
        bottomControls.setVisible(false);
        bottomControls.setSpacing( 4 );
        HBox.setMargin( bottomControls, new Insets(4));

        pb = new ProgressBar();
        messageLabel = new Label("");
        bottomControls.getChildren().addAll(pb, messageLabel);

        vbox.getChildren().addAll(topControls, contents, bottomControls);

        return vbox;
    }

    public void getHTML(ActionEvent evt) {

        String url = tfURL.getText();

        Task<String> task = new Task<String>() {

            @Override
            protected String call() throws Exception {

                updateMessage("Getting HTML from " + url );
                updateProgress( 0.5d, 1.0d );

                HttpURLConnection c = null;
                InputStream is = null;
                String retval = "";

                try {

                    c = (HttpURLConnection) new URL(url).openConnection();

                    updateProgress( 0.6d, 1.0d );
                    is = c.getInputStream();
                    int ch;
                    while( (ch=is.read()) != -1 ) {
                        retval += (char)ch;
                    }

                } finally {
                    if( is != null ) {
                        is.close();
                    }
                    if( c != null ) {
                        c.disconnect();
                    }
                }

                updateMessage("HTML retrieved");
                updateProgress( 1.0d, 1.0d );

                return retval;
            }

            @Override
            protected void succeeded() {
                contents.setText( getValue() );
            }

            @Override
            protected void failed() {
                Alert alert = new Alert(Alert.AlertType.ERROR, getException().getMessage() );
                alert.showAndWait();
            }
        };

        bottomControls.visibleProperty().bind( task.runningProperty() );
        pb.progressProperty().bind( task.progressProperty() );
        messageLabel.textProperty().bind( task.messageProperty() );

        new Thread(task).start();
    }

    public static void main(String[] args) {
        launch(args);
    }
}

3. Evitar Nulos en ComboBoxes

Para usar a ComboBoxen JavaFX, declare una Lista de elementos y establezca un valor inicial usando setValue(). El ComboBoxmétodo getValue() recupera el valor seleccionado actualmente. Si no se proporciona un valor inicial, el control tiene un valor nulo predeterminado.

El valor nulo es un problema cuando ComboBoximpulsa otra lógica como una transformación a mayúsculas o la búsqueda de un registro de base de datos. Si bien generalmente se usa una verificación nula para evitar este tipo de error, se prefiere un objeto vacío para simplificar el código. Los cuadros combinados a menudo aparecen en grupos y la técnica de objetos vacíos reduce las comprobaciones nulas en la interacción de los cuadros combinados relacionados y en las operaciones de guardar y cargar.

Este artículo presenta un par de ComboBoxes relacionados. Una selección de país en uno ComboBoxmodifica la lista de elementos de ciudad disponibles en un segundo ComboBox. No se requiere ninguna selección. El usuario puede presionar Guardar Buttonen cualquier momento y, si no se realiza ninguna selección ComboBox, se devolverá un objeto vacío, en este caso una Cadena vacía.

Esta es una captura de pantalla de la aplicación. Si selecciona "Suiza" de un valor inicial vacío, la ciudad se llenará ComboBoxde ciudades suizas. Seleccionando la ciudad "Zurich" y presionando Guardar recuperará esos valores.

captura de pantalla de combo no nulo
Figura 64. Cuadros combinados relacionados

3.1. Estructura de datos

Las estructuras de datos que soportan la aplicación son una Lista de países y un Mapa de ciudades. El Mapa de ciudades utiliza el país como clave.

NoNullComboApp.clase
public class NoNullComboApp extends Application {

    private List<String> countries = new ArrayList<>();

    private Map<String, List<String>> citiesMap = new LinkedHashMap<>();

    private void initData() {

        String COUNTRY_FR = "France";
        String COUNTRY_DE = "Germany";
        String COUNTRY_CH = "Switzerland";

        countries.add(COUNTRY_FR); countries.add(COUNTRY_DE); countries.add(COUNTRY_CH);

        List<String> frenchCities = new ArrayList<>();
        frenchCities.add("Paris");
        frenchCities.add("Strasbourg");

        List<String> germanCities = new ArrayList<>();
        germanCities.add("Berlin");
        germanCities.add("Cologne");
        germanCities.add("Munich");

        List<String> swissCities = new ArrayList<>();
        swissCities.add("Zurich");

        citiesMap.put(COUNTRY_FR, frenchCities );
        citiesMap.put(COUNTRY_DE, germanCities );
        citiesMap.put(COUNTRY_CH, swissCities );
    }
}

Para recuperar el conjunto de ciudades de un país determinado, utilice el método get() del Mapa. El método containsKey() se puede utilizar para determinar si el mapa contiene o no un valor para el país especificado. En este ejemplo, containsKey() se usará para manejar el caso del objeto vacío.

3.2. interfaz de usuario

La interfaz de usuario es un par de cuadros combinados con etiquetas y un botón Guardar. Los controles se colocan en a VBoxy justificados a la izquierda. El VBoxestá envuelto en un TilePaney centrado. Se TilePaneutilizó ya que no se estira VBoxhorizontalmente.

NoNullComboApp.clase
    @Override
    public void start(Stage primaryStage) throws Exception {

        Label countryLabel = new Label("Country:");
        country.setPrefWidth(200.0d);
        Label cityLabel = new Label("City:");
        city.setPrefWidth(200.0d);
        Button saveButton = new Button("Save");

        VBox vbox = new VBox(
                countryLabel,
                country,
                cityLabel,
                city,
                saveButton
        );
        vbox.setAlignment(Pos.CENTER_LEFT );
        vbox.setSpacing( 10.0d );

        TilePane outerBox = new TilePane(vbox);
        outerBox.setAlignment(Pos.CENTER);

        Scene scene = new Scene(outerBox);

        initData();
    }

3.3. Valores iniciales

Como se mencionó anteriormente, si no se especifica un valor para un ComboBox, se devolverá un valor nulo en una llamada a getValue(). Aunque existen varias técnicas defensivas (si se verifica, métodos Commons StringUtils) para defenderse de NullPointerExceptions, es mejor evitarlas por completo. Esto es especialmente cierto cuando las interacciones se vuelven complejas o hay varios ComboBoxes que permiten selecciones vacías.

NoNullComboApp.clase
1
2
3
4
5
6
country.getItems().add("");
country.getItems().addAll( countries );
country.setValue( "" );  // empty selection is object and not null

city.getItems().add("");
city.setValue( "" );

En esta aplicación, el país ComboBoxno se cambiará, por lo que sus elementos se agregan en el método start(). El país comienza con una selección inicial vacía al igual que la ciudad. Ciudad, en este punto, contiene un único elemento vacío.

3.4. Interacción

Cuando se cambia el valor del país, se ComboBoxdebe reemplazar el contenido de la ciudad. Es común usar clear() en la lista de respaldo; sin embargo, esto producirá un valor nulo en ComboBox(sin elementos, sin valor). En su lugar, use removeIf() con una cláusula para mantener un único elemento vacío. Con la lista limpia de todos los datos (excepto el elemento vacío), los contenidos recién seleccionados se pueden agregar con addAll().

NoNullComboApp.clase
country.setOnAction( (evt) -> {

    String cty = country.getValue();

    city.getItems().removeIf( (c) -> !c.isEmpty() );

    if( citiesMap.containsKey(cty) ) {  // not an empty key
        city.getItems().addAll( citiesMap.get(cty) );
    }
});

saveButton.setOnAction( (evt) -> {
    System.out.println("saving country='" + country.getValue() +
                       "', city='" + city.getValue() + "'");
});

La acción del botón Guardar imprimirá los valores. En ningún caso se devolverá un valor nulo desde getValue().

Si es un desarrollador de Java, ha escrito "si no es nulo" miles de veces. Sin embargo, proyecto tras proyecto, veo NullPointerExceptions que resaltan los casos que se perdieron o las nuevas condiciones que surgieron. Este artículo presentó una técnica para mantener objetos vacíos en ComboBoxes estableciendo un valor inicial y usando removeIf() en lugar de clear() al cambiar listas. Aunque este ejemplo usó objetos String, esto se puede expandir para trabajar con objetos de dominio que tienen una implementación hashCode/equals, una representación de objeto vacía y cellFactory o toString() para producir una vista vacía.

3.5. Código completo

El código se puede probar en un solo archivo .java.

NoNullComboApp.clase
public class NoNullComboApp extends Application {

    private final ComboBox<String> country = new ComboBox<>();
    private final ComboBox<String> city = new ComboBox<>();

    private List<String> countries = new ArrayList<>();

    private Map<String, List<String>> citiesMap = new LinkedHashMap<>();

    @Override
    public void start(Stage primaryStage) throws Exception {

        Label countryLabel = new Label("Country:");
        country.setPrefWidth(200.0d);
        Label cityLabel = new Label("City:");
        city.setPrefWidth(200.0d);
        Button saveButton = new Button("Save");

        VBox vbox = new VBox(
                countryLabel,
                country,
                cityLabel,
                city,
                saveButton
        );
        vbox.setAlignment(Pos.CENTER_LEFT );
        vbox.setSpacing( 10.0d );

        TilePane outerBox = new TilePane(vbox);
        outerBox.setAlignment(Pos.CENTER);

        Scene scene = new Scene(outerBox);

        initData();

        country.getItems().add("");
        country.getItems().addAll( countries );
        country.setValue( "" );  // empty selection is object and not null

        city.getItems().add("");
        city.setValue( "" );

        country.setOnAction( (evt) -> {

            String cty = country.getValue();

            city.getItems().removeIf( (c) -> !c.isEmpty() );

            if( citiesMap.containsKey(cty) ) {  // not an empty key
                city.getItems().addAll( citiesMap.get(cty) );
            }
        });

        saveButton.setOnAction( (evt) -> {
           System.out.println("saving country='" + country.getValue() +
                                      "', city='" + city.getValue() + "'");
        });

        primaryStage.setTitle("NoNullComboApp");
        primaryStage.setScene( scene );
        primaryStage.setWidth( 320 );
        primaryStage.setHeight( 480 );
        primaryStage.show();
    }

    public static void main(String[] args) {
        launch(args);
    }

    private void initData() {

        String COUNTRY_FR = "France";
        String COUNTRY_DE = "Germany";
        String COUNTRY_CH = "Switzerland";

        countries.add(COUNTRY_FR); countries.add(COUNTRY_DE); countries.add(COUNTRY_CH);

        List<String> frenchCities = new ArrayList<>();
        frenchCities.add("Paris");
        frenchCities.add("Strasbourg");

        List<String> germanCities = new ArrayList<>();
        germanCities.add("Berlin");
        germanCities.add("Cologne");
        germanCities.add("Munich");

        List<String> swissCities = new ArrayList<>();
        swissCities.add("Zurich");

        citiesMap.put(COUNTRY_FR, frenchCities );
        citiesMap.put(COUNTRY_DE, germanCities );
        citiesMap.put(COUNTRY_CH, swissCities );
    }
}