JavaFX17-学习手册-十二-

142 阅读1小时+

JavaFX17 学习手册(十二)

原文:Learn JavaFX 17

协议:CC BY-NC-SA 4.0

二十四、理解 JavaFX 中的并发性

在本章中,您将学习:

  • 为什么在 JavaFX 中需要一个并发框架

  • Worker<V>接口如何表示并发任务

  • 如何运行一次性任务

  • 如何运行可重用任务

  • 如何运行计划任务

本章的例子在com.jdojo.concurrent包中。为了让它们工作,您必须在module-info.java文件中添加相应的一行:

...
opens com.jdojo.concurrent to javafx.graphics, javafx.base;
...

对并发框架的需求

Java(包括 JavaFX) GUI(图形用户界面)应用程序本质上是多线程的。多个线程执行不同的任务,以保持 UI 与用户操作同步。与 Swing 和 AWT 一样,JavaFX 使用一个称为 JavaFX 应用程序线程的线程来处理所有 UI 事件。场景图中表示 UI 的节点不是线程安全的。设计非线程安全的节点有利也有弊。它们更快,因为不涉及同步。缺点是需要从单个线程访问它们,以避免处于非法状态。JavaFX 设置了一个限制,即只能从一个线程(JavaFX 应用程序线程)访问实时场景图形。这个限制间接地强加了另一个限制,即 UI 事件不应该处理长时间运行的任务,因为它会使应用程序没有响应。用户将得到应用程序被挂起的印象。

清单 24-1 中的程序显示如图 24-1 所示的窗口。它包含三个控件:

img/336502_2_En_24_Fig1_HTML.png

图 24-1

无响应的用户界面示例

  • 显示任务进度的Label

  • 一个启动按钮来启动任务

  • 一个退出按钮,用于退出应用程序

// UnresponsiveUI.java
package com.jdojo.concurrent;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class UnresponsiveUI extends Application {
        Label statusLbl = new Label("Not Started...");
        Button startBtn = new Button("Start");
        Button exitBtn = new Button("Exit");

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

        @Override
        public void start(Stage stage) {
                // Add event handlers to the buttons
                startBtn.setOnAction(e -> runTask());
                exitBtn.setOnAction(e -> stage.close());

                HBox buttonBox = new HBox(5, startBtn, exitBtn);
                VBox root = new VBox(10, statusLbl, buttonBox);
                Scene scene = new Scene(root);
                stage.setScene(scene);
                stage.setTitle("An Unresponsive UI");
                stage.show();
        }

        public void runTask() {
                for(int i = 1; i <= 10; i++) {
                   try {
                     String status = "Processing " + i + " of " + 10;
                     statusLbl.setText(status);
                     System.out.println(status);
                     Thread.sleep(1000);
                   }
                   catch (InterruptedException e) {
                     e.printStackTrace();
                   }
                }
        }
}

Listing 24-1Performing a Long-Running Task in an Event Handler

程序很简单。当你点击开始按钮时,一个持续十秒钟的任务开始。任务的逻辑在runTask()方法中,该方法简单地运行一个循环十次。在循环内部,任务让当前线程(JavaFX 应用程序线程)休眠一秒钟。这个程序有两个问题。

点击开始按钮,并立即尝试点击退出按钮。点击退出按钮,直到任务完成才生效。一旦你点击开始按钮,你就不能在窗口上做任何其他事情,除了等待十秒钟任务完成。也就是说,应用程序在十秒钟内没有响应。这就是你将这个类命名为UnresponsiveUI的原因。

runTask()方法的循环中,程序在标准输出中打印任务的状态,并在窗口的Label中显示。您会在标准输出中看到更新的状态,但不会在Label中看到。

反复强调 JavaFX 中的所有 UI 事件处理程序都运行在一个线程上,这个线程就是 JavaFX 应用程序线程。当点击 Start 按钮时,在 JavaFX 应用线程中执行runTask()方法。当任务正在运行时点击 Exit 按钮时,会为 Exit 按钮生成一个ActionEvent事件,并在 JavaFX 应用程序线程上排队。作为开始按钮的ActionEvent处理程序的一部分,在线程完成运行runTask()方法之后,退出按钮的ActionEvent处理程序在同一线程上运行。

场景图形更新时会生成脉冲事件。脉冲事件处理程序也在 JavaFX 应用程序线程上运行。在循环内部,Labeltext属性被更新了十次,这产生了脉冲事件。然而,场景图没有被刷新以显示Label的最新文本,因为 JavaFX 应用程序线程忙于运行任务,它没有运行脉冲事件处理程序。

这两个问题都是因为只有一个线程来处理所有的 UI 事件处理程序,而您在开始按钮的ActionEvent处理程序中运行了一个长时间运行的任务。

解决办法是什么?你只有一个选择。您不能更改处理 UI 事件的单线程模型。不得在事件处理程序中运行长时间运行的任务。有时,作为用户操作的一部分,业务需要处理大型作业。解决方案是在一个或多个后台线程中运行长时间运行的任务,而不是在 JavaFX 应用程序线程中。

清单 24-2 中的程序是你第一次错误地尝试提供解决方案。Start按钮的ActionEvent处理程序调用startTask()方法,这将创建一个新线程并在新线程中运行runTask()方法。

// BadUI.java
package com.jdojo.concurrent;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class BadUI extends Application {
        Label statusLbl = new Label("Not Started...");
        Button startBtn = new Button("Start");
        Button exitBtn = new Button("Exit");

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

        @Override

        public void start(Stage stage) {
                // Add event handlers to the buttons
                startBtn.setOnAction(e -> startTask());
                exitBtn.setOnAction(e -> stage.close());

                HBox buttonBox = new HBox(5, startBtn, exitBtn);
                VBox root = new VBox(10, statusLbl, buttonBox);
                Scene scene = new Scene(root);
                stage.setScene(scene);
                stage.setTitle("A Bad UI");
                stage.show();
        }

        public void startTask() {
                // Create a Runnable
                Runnable task = () -> runTask();

                // Run the task in a background thread
                Thread backgroundThread = new Thread(task);

                // Terminate the running thread if the application exits
                backgroundThread.setDaemon(true);

                // Start the thread
                backgroundThread.start();
        }

        public void runTask() {
            for(int i = 1; i <= 10; i++) {
              try {
                String status = "Processing " + i + " of " + 10;
                statusLbl.setText(status);
                System.out.println(status);
                Thread.sleep(1000);
              }
              catch (InterruptedException e) {
                e.printStackTrace();
              }
            }
        }

}

Listing 24-2A Program Accessing a Live Scene Graph from a Non-JavaFX Application Thread

运行程序,点击开始按钮。引发运行时异常。异常的部分堆栈跟踪如下:

Exception in thread "Thread-4" java.lang.IllegalStateException:
Not on FX application thread; currentThread = Thread-4
  at com.sun.javafx.tk.Toolkit.checkFxUserThread(Toolkit.java:209)
  at com.sun.javafx.tk.quantum.QuantumToolkit.checkFxUserThread(
      QuantumToolkit.java:393)...
   at com.jdojo.concurrent.BadUI.runTask(BadUI.java:47)...

runTask()方法中的以下语句生成了异常:

statusLbl.setText(status);

JavaFX 运行时检查是否必须从 JavaFX 应用程序线程访问实时场景。runTask()方法在一个新线程上运行,名为 Thread-4,如堆栈跟踪所示,它不是 JavaFX 应用程序线程。上述语句从 JavaFX 应用程序线程之外的线程为作为实时场景图一部分的Label设置了text属性,这是不允许的。

如何从 JavaFX 应用程序线程之外的线程访问实时场景图?简单的答案是你不能。复杂的答案是,当一个线程想要访问一个实时场景图时,它需要运行 JavaFX 应用程序线程中访问场景图的那部分代码。javafx.application包中的Platform类提供了两个静态方法来处理 JavaFX 应用程序线程:

  • public static boolean isFxApplicationThread()

  • public static void runLater(Runnable runnable)

如果调用此方法的线程是 JavaFX 应用程序线程,则isFxApplicationThread()方法返回 true。否则,它返回 false。

runLater()方法调度指定的Runnable在未来某个未指定的时间在 JavaFX 应用程序线程上运行。

Tip

如果您有使用 Swing 的经验,那么 JavaFX 中的Platform.runLater()就是 Swing 中的SwingUtilities.invokeLater()的对等物。

让我们来解决BadUI应用程序中的问题。清单 24-3 中的程序是访问现场图形逻辑的正确实现。图 24-2 显示了程序显示窗口的快照。

img/336502_2_En_24_Fig2_HTML.png

图 24-2

在后台线程中运行任务并正确更新实时场景图形的 UI

// ResponsiveUI.java
package com.jdojo.concurrent;

import javafx.application.Application;
import javafx.application.Platform;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class ResponsiveUI extends Application {
        Label statusLbl = new Label("Not Started...");
        Button startBtn = new Button("Start");
        Button exitBtn = new Button("Exit");

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

        @Override

        public void start(Stage stage) {
                // Add event handlers to the buttons
                startBtn.setOnAction(e -> startTask());
                exitBtn.setOnAction(e -> stage.close());

                HBox buttonBox = new HBox(5, startBtn, exitBtn);
                VBox root = new VBox(10, statusLbl, buttonBox);
                Scene scene = new Scene(root);
                stage.setScene(scene);
                stage.setTitle("A Responsive UI");
                stage.show();
        }

        public void startTask() {
                // Create a Runnable
                Runnable task = () -> runTask();

                // Run the task in a background thread
                Thread backgroundThread = new Thread(task);

                // Terminate the running thread if the application exits
                backgroundThread.setDaemon(true);

                // Start the thread
                backgroundThread.start();
        }

        public void runTask() {
          for(int i = 1; i <= 10; i++) {
            try {
              String status = "Processing " + i + " of " + 10;

              // Update the Label on the JavaFx Application Thread
              Platform.runLater(() -> statusLbl.setText(status));
              System.out.println(status);
              Thread.sleep(1000);
            }
            catch (InterruptedException e) {
              e.printStackTrace();
            }
          }
        }

}

Listing 24-3A Responsive UI That Runs Long-Running Tasks in a Background Thread

程序会替换语句

statusLbl.setText(status);

BadUI类中用语句

// Update the Label on the JavaFx Application Thread
Platform.runLater(() -> statusLbl.setText(status));

现在,为Label设置text属性发生在 JavaFX 应用程序线程上。 Start 按钮的ActionEvent处理程序在后台线程中运行任务,从而释放 JavaFX 应用程序线程来处理用户动作。任务的状态会定期在Label中更新。在任务处理过程中,您可以点击退出按钮。

您是否克服了 JavaFX 的事件调度线程模型带来的限制?答案是有也有没有,你用了一个微不足道的例子来论证这个问题。你已经解决了这个小问题。然而,在现实世界中,在 GUI 应用程序中执行长时间运行的任务并不那么简单。例如,您的任务运行逻辑和 UI 紧密耦合,因为您在runTask()方法中引用了Label,这在现实世界中是不可取的。您的任务不返回结果,也没有可靠的机制来处理可能发生的错误。您的任务不能被可靠地取消、重新启动或安排在将来运行。

JavaFX 并发框架可以回答所有这些问题。该框架提供了在一个或多个后台线程中运行任务并在 GUI 应用程序中发布任务的状态和结果的可靠方式。该框架是本章讨论的主题。我花了几页来说明 JavaFX 中的并发框架。如果您理解了本节中提出的问题的背景,那么理解框架就很容易了。

了解并发框架 API

Java 通过java.util.concurrent包中的库包含了一个全面的 Java 编程语言并发框架。JavaFX 并发框架非常小。它构建在 Java 语言并发框架之上,记住它将在 GUI 环境中使用。图 24-3 显示了 JavaFX 并发框架中的类的类图。

img/336502_2_En_24_Fig3_HTML.png

图 24-3

JavaFX 并发框架中的类的类图

该框架由一个接口、四个类和一个枚举组成。

接口的一个实例代表一个需要在一个或多个后台线程中执行的任务。任务的状态可以从 JavaFX 应用程序线程中观察到。

TaskServiceScheduledService类实现了Worker接口。它们代表不同类型的任务。它们是抽象类。Task类的一个实例代表一个一次性任务。A Task不能重复使用。Service类的一个实例代表一个可重用的任务。ScheduledService类继承自Service类。一个ScheduledService是一个可以被安排在指定的时间间隔后重复运行的任务。

Worker.State枚举中的常量代表了Worker的不同状态。

WorkerStateEvent类的一个实例表示当Worker的状态改变时发生的一个事件。您可以将事件处理程序添加到所有三种类型的任务中,以监听它们的状态变化。

了解 Worker 接口

Worker<V>接口为 JavaFX 并发框架执行的任何任务提供了规范。Worker是在一个或多个后台线程中执行的任务。通用参数VWorker结果的数据类型。如果Worker没有产生结果,使用Void作为通用参数。任务的状态是可观察的。任务的状态在 JavaFX 应用程序线程上发布,使任务能够与场景图通信,这是 GUI 应用程序中通常需要的。

员工的状态转换

在生命周期中,Worker会经历不同的状态。Worker.State枚举中的常量代表了Worker的有效状态:

  • Worker.State.READY

  • Worker.State.SCHEDULED

  • Worker.State.RUNNING

  • Worker.State.SUCCEEDED

  • Worker.State.CANCELLED

  • Worker.State.FAILED

图 24-4 显示了一个Worker可能的状态转换,其中Worker.State枚举常量代表状态。

img/336502_2_En_24_Fig4_HTML.png

图 24-4

工人可能的状态转换路径

当一个Worker被创建时,它处于READY状态。在开始执行之前,它转换到SCHEDULED状态。当它开始运行时,它处于RUNNING状态。成功完成后,WorkerRUNNING状态转换到SUCCEEDED状态。如果Worker在执行过程中抛出异常,它将转换到FAILED状态。使用cancel()方法可以取消Worker。它可以从READYSCHEDULEDRUNNING状态转换到CANCELLED状态。这些是单触发Worker的正常状态转换。

可重用的Worker可以从CANCELLEDSUCCEEDEDFAILED状态转换到图中虚线所示的READY状态。

工人的属性

Worker接口包含九个只读属性,代表任务的内部状态:

  • title

  • message

  • running

  • state

  • progress

  • workDone

  • totalWork

  • value

  • exception

当您创建一个Worker时,您将有机会指定这些属性。这些属性也可以随着任务的进行而更新。

属性表示任务的标题。假设一个任务产生素数。你可以给这个任务一个标题“质数生成器”

message属性表示任务处理过程中的详细消息。假设一个任务产生几个素数;您可能希望定期或在适当的时候向用户提供反馈信息,比如“生成 X 个质数,共 Y 个质数”

running属性告知Worker是否正在运行。当工人处于SCHEDULEDRUNNING状态时,这是真的。否则就是假的。

state属性指定Worker的状态。它的值是Worker.State枚举的常量之一。

totalWorkworkDoneprogress属性代表任务的进度。totalWork是要完成的总工作量。workDone是已经完成的工作量。progressworkDonetotalWork的比值。如果它们的值未知,则设置为–1.0。

属性表示任务的结果。只有当Worker成功到达SUCCEEDED状态时,它的值才为非空。有时,任务可能不会产生结果。在这些情况下,通用参数V将是Void,而value属性将总是null

任务可能会因引发异常而失败。exception属性表示在任务处理过程中抛出的异常。只有当Worker的状态为FAILED时才不为空。它属于Throwable类型。

通常,当任务正在进行时,您希望在场景图中显示任务的详细信息。并发框架确保在 JavaFX 应用程序线程上更新Worker的属性。因此,可以将场景图中 UI 元素的属性绑定到这些属性。您还可以将InvalidationChangeListener添加到这些属性中,并从这些侦听器中访问现场图。

在随后的章节中,您将讨论Worker接口的具体实现。让我们创建一个可重用的 GUI,在所有的例子中使用。GUI 基于一个Worker来显示其属性的当前值。

示例的实用程序类

让我们创建程序的可重用 GUI 和非 GUI 部分,以便在后续部分的示例中使用。清单 24-4 中的WorkerStateUI类构建了一个GridPane来显示一个Worker的所有属性。它与一个Worker<ObservableList<Long>>一起使用。它通过 UI 元素向它们显示一个Worker的属性。通过向构造器传递一个Worker或者调用bindToWorker()方法,可以将Worker的属性绑定到 UI 元素。

// WorkerStateUI.java
package com.jdojo.concurrent;

import javafx.beans.binding.When;
import javafx.collections.ObservableList;
import javafx.concurrent.Worker;
import javafx.scene.control.Label;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.TextArea;
import javafx.scene.layout.GridPane;
import javafx.scene.layout.HBox;

public class WorkerStateUI extends GridPane {
        private final Label title = new Label("");
        private final Label message = new Label("");
        private final Label running = new Label("");
        private final Label state = new Label("");
        private final Label totalWork = new Label("");
        private final Label workDone = new Label("");
        private final Label progress = new Label("");
        private final TextArea value = new TextArea("");
        private final TextArea exception = new TextArea("");
        private final ProgressBar progressBar = new ProgressBar();

        public WorkerStateUI() {
                addUI();
        }

        public WorkerStateUI(Worker<ObservableList<Long>> worker) {
                addUI();
                bindToWorker(worker);
        }

        private void addUI() {
                value.setPrefColumnCount(20);
                value.setPrefRowCount(3);
                exception.setPrefColumnCount(20);
                exception.setPrefRowCount(3);
                this.setHgap(5);
                this.setVgap(5);
                addRow(0, new Label("Title:"), title);
                addRow(1, new Label("Message:"), message);
                addRow(2, new Label("Running:"), running);
                addRow(3, new Label("State:"), state);
                addRow(4, new Label("Total Work:"), totalWork);
                addRow(5, new Label("Work Done:"), workDone);
                addRow(6, new Label("Progress:"),
                         new HBox(2, progressBar, progress));
                addRow(7, new Label("Value:"), value);
                addRow(8, new Label("Exception:"), exception);
        }

        public void bindToWorker(final Worker<ObservableList<Long>> worker) {
                // Bind Labels to the properties of the worker
                title.textProperty().bind(worker.titleProperty());
                message.textProperty().bind(worker.messageProperty());
                running.textProperty().bind(
                         worker.runningProperty().asString());
                state.textProperty().bind(
                         worker.stateProperty().asString());
                totalWork.textProperty().bind(
                         new When(worker.totalWorkProperty().isEqualTo(-1))
                    .then("Unknown")
                    .otherwise(worker.totalWorkProperty().asString()));
                workDone.textProperty().bind(
                         new When(worker.workDoneProperty().isEqualTo(-1))
                    .then("Unknown")

                    .otherwise(worker.workDoneProperty().asString()));
                progress.textProperty().bind(
                         new When(worker.progressProperty().isEqualTo(-1))
                    .then("Unknown")
                    .otherwise(worker.progressProperty().multiply(100.0)
                            .asString("%.2f%%")));
                progressBar.progressProperty().bind(
                         worker.progressProperty());
                value.textProperty().bind(
                         worker.valueProperty().asString());

                // Display the exception message when an exception occurs
                     // in the worker
                worker.exceptionProperty().addListener(
                         (prop, oldValue, newValue) -> {
                        if (newValue != null) {
                            exception.setText(newValue.getMessage());
                        } else {
                            exception.setText("");
                        }
                });
        }
}

Listing 24-4A Utility Class to Build UI Displaying the Properties of a Worker

清单 24-5 中的PrimeUtil类是一个实用程序类,用于检查一个数是否是质数。

// PrimeUtil.java
package com.jdojo.concurrent;

public class PrimeUtil {
        public static boolean isPrime(long num) {
            if (num <= 1 || num % 2 == 0) {
                    return false;
            }

            int upperDivisor = (int)Math.ceil(Math.sqrt(num));
            for (int divisor = 3; divisor <= upperDivisor; divisor += 2) {
                    if (num % divisor == 0) {
                            return false;
                    }
            }
            return true;
        }

}

Listing 24-5A Utility Class to Work with Prime Numbers

使用任务类

Task<V>类的一个实例代表一个一次性任务。一旦任务完成、取消或失败,就不能重新启动。Task<V>类实现了Worker<V>接口。因此,Worker<V>接口指定的所有属性和方法在Task<V>类中都是可用的。

Task<V>类继承自FutureTask<V>类,后者是 Java 并发框架的一部分。FutureTask<V>实现了Future<V>RunnableFuture<V>Runnable接口。所以一个Task<V>也实现了所有这些接口。

创建任务

如何创建一个Task<V>?创建一个Task<V>很容易。您需要子类化Task<V>类,并为抽象方法call()提供一个实现。call()方法包含执行任务的逻辑。下面的代码片段展示了一个Task实现的框架:

// A Task that produces an ObservableList<Long>
public class PrimeFinderTask extends Task<ObservableList<Long>> {
        @Override
        protected ObservableList<Long>> call() {
                // Implement the task logic here...
        }
}

更新任务属性

通常,您会希望随着任务的进行更新其属性。必须在 JavaFX 应用程序线程上更新和读取这些属性,这样才能在 GUI 环境中安全地观察它们。Task<V>类提供了特殊的方法来更新它的一些属性:

  • protected void updateMessage(String message)

  • protected void updateProgress(double workDone, double totalWork)

  • protected void updateProgress(long workDone, long totalWork)

  • protected void updateTitle(String title)

  • protected void updateValue(V value)

您向updateProgress()方法提供了workDonetotalWork属性的值。progress属性将被设置为workDone/totalWork。如果workDone大于totalWork或者两者都小于–1.0,该方法抛出运行时异常。

有时,您可能希望在其value属性中发布任务的部分结果。为此使用了updateValue()方法。任务的最终结果是其call()方法的返回值。

所有的updateXxx()方法都在 JavaFX 应用程序线程上执行。它们的名称表示它们更新的属性。从Taskcall()方法中调用它们是安全的。如果您想直接从call()方法中更新Task的属性,您需要将代码包装在一个Platform.runLater()调用中。

监听任务转换事件

Task类包含以下属性,允许您为其状态转换设置事件处理程序:

  • onCancelled

  • onFailed

  • onRunning

  • onScheduled

  • onSucceeded

下面的代码片段添加了一个onSucceeded事件处理程序,当任务转换到SUCCEEDED状态时会调用该处理程序:

Task<ObservableList<Long>> task = create a task...
task.setOnSucceeded(e -> {
        System.out.println("The task finished. Let us party!")
});

取消任务

使用以下两种cancel()方法之一取消任务:

  • public final boolean cancel()

  • public boolean cancel(boolean mayInterruptIfRunning)

第一个版本从执行队列中删除任务或停止其执行。第二个版本让您指定运行任务的线程是否被中断。确保在call()方法中处理InterruptedException。一旦您检测到这个异常,您需要快速完成call()方法。否则,对cancel(true)的调用可能无法可靠地取消任务。可以从任何线程调用cancel()方法。

Task到达一个特定的状态时,它的以下方法被调用:

  • protected void scheduled()

  • protected void running()

  • protected void succeeded()

  • protected void cancelled()

  • protected void failed()

它们在Task类中的实现是空的。它们应该被子类覆盖。

运行任务

一个TaskRunnable也是一个FutureTask。要运行它,您可以使用一个后台线程或一个ExecutorService:

// Schedule the task on a background thread
Thread backgroundThread = new Thread(task);
backgroundThread.setDaemon(true);
backgroundThread.start();

// Use the executor service to schedule the task
ExecutorService executor = Executors.newSingleThreadExecutor();
executor.submit(task);

主要查找器任务示例

是时候看看Task的行动了。清单 24-6 中的程序是Task<ObservableList<Long>>的一个实现。它检查指定的lowerLimitupperLimit之间的质数。它返回该范围内的所有数字。请注意,任务线程在检查一个数字是否为质数之前会休眠一小段时间。这样做是为了给用户一个长期运行任务的印象。在现实世界的应用程序中不需要它。如果作为取消请求的一部分,任务被中断,call()方法处理一个InterruptedException并结束任务。

对方法updateValue()的调用几乎不需要解释:

updateValue(FXCollections.<Long>unmodifiableObservableList(results));

每次找到一个质数,结果列表就会更新。上述语句将结果列表包装在一个不可修改的可观察列表中,并将其发布给客户端。这使得客户端可以访问任务的部分结果。这是发布部分结果的一种快速而肮脏的方式。如果call()方法返回一个原始值,那么可以重复调用updateValue()方法。

Tip

在这种情况下,每当您找到一个新的质数时,您就在创建一个新的不可修改的列表,出于性能原因,这在生产环境中是不可接受的。发布部分结果的有效方法是为Task声明一个只读属性;在 JavaFX 应用程序线程上定期更新只读属性;让客户端绑定到只读属性以查看部分结果。

// PrimeFinderTask.java
// ...find in the book's download area.

Listing 24-6Finding Prime Numbers Using a Task<Long>

清单 24-7 中的程序包含了使用你的PrimeFinderTask类构建 GUI 的完整代码。图 24-5 显示任务运行时的窗口。您需要点击开始按钮来开始任务。点击取消按钮取消任务。任务一旦完成,就被取消或失败;您不能重启它,并且StartCancel按钮都被禁用。请注意,当任务找到一个新的质数时,它会立即显示在窗口上。

img/336502_2_En_24_Fig5_HTML.png

图 24-5

使用质数查找器任务的窗口

// OneShotTask.java
// ...find in the book's download area.

Listing 24-7Executing a Task in a GUI Environment

使用服务类

Service<V>类是Worker<V>接口的一个实现。它封装了一个Task<V>。它通过允许启动、取消、重置和重启来使Task<V>可重用。

创建服务

记住一个Service<V>封装了一个Task<V>。因此,你需要一个Task<V>来拥有一个Service<V>Service<V>类包含一个返回Task<V>的抽象保护createTask()方法。要创建一个服务,您需要子类化Service<V>类并为createTask()方法提供一个实现。

下面的代码片段创建了一个封装了您之前创建的PrimeFinderTaskService:

// Create a service
Service<ObservableList<Long>> service = new Service<ObservableList<Long>>() {
        @Override
        protected Task<ObservableList<Long>> createTask() {
                // Create and return a Task
                return new PrimeFinderTask();
        }

};

每当服务启动或重启时,都会调用服务的createTask()方法。

更新服务属性

Service类包含所有属性(titlemessagestatevalue等)。)表示一个Worker的内部状态。它添加了一个executor属性,这是一个java.util.concurrent.Executor。该属性用于运行Service。如果没有指定,就会创建一个守护线程来运行Service

Task类不同,Service类不包含用于更新其属性的updateXxx()方法。它的属性被绑定到底层Task<V>的相应属性。当Task更新其属性时,这些变化会自动反映到Service和客户端。

监听服务转换事件

Service类包含所有用于设置状态转换监听器的属性,就像Task类所包含的一样。它增加了一个onReady property。该属性指定了一个状态转换事件处理程序,当Service转换到READY状态时会调用该处理程序。请注意,Task类不包含onReady属性,因为Task在创建时处于READY状态,并且它再也不会转换到READY状态。然而,一个Service可以多次处于READY状态。当Service被创建、复位和重启时,它会转换到READY状态。Service类还包含一个受保护的ready()方法,该方法将被子类覆盖。当Service转换到READY状态时,调用ready()方法。

取消服务

使用cancel()方法取消一个Service:该方法将Servicestate设置为CANCELLED

正在启动服务

调用Service类的start()方法会启动一个Service。该方法调用createTask()方法获得一个Task实例并运行Task。当调用其start()方法时,服务必须处于READY状态:

Service<ObservableList<Long>> service = create a service
...
// Start the service
service.start();

重置服务

调用Service类的reset()方法重置Service。重置会将所有的Service属性恢复到初始状态。state被设置为READY。仅当Service处于SUCCEEDEDFAILEDCANCELLEDREADY中的一种结束状态时,才允许复位Service。如果Service处于SCHEDULEDRUNNING状态,调用reset()方法会抛出运行时异常。

重新启动服务

调用Service类的restart()方法重启一个Service。如果任务存在,它会取消任务,重置服务,然后启动它。它依次调用服务对象上的三个方法:

  • cancel()

  • reset()

  • start()

Prime Finder 服务示例

清单 24-8 中的程序展示了如何使用ServiceService对象被创建并存储为一个实例变量。这个Service对象管理一个PrimeFinderTask对象,这是一个Task来寻找两个数之间的质数。增加了四个按钮:启动/重启取消复位退出。第一次启动Service启动按钮标记为重启。这些按钮的功能和它们的标签一样。当您无法调用按钮时,它们会被禁用。图 24-6 为点击开始按钮后的窗口截图。

img/336502_2_En_24_Fig6_HTML.png

图 24-6

使用服务查找质数的窗口

// PrimeFinderService.java
// ...find in the book's download area.

Listing 24-8Using a Service to Find Prime Numbers

使用 ScheduledService 类

ScheduledService<V>是一个Service<V>,自动重启。它可以在成功完成或失败时重新启动。故障重启是可配置的。ScheduledService<V>类继承自Service<V>类。ScheduledService适用于使用轮询的任务。例如,你可以用它每十分钟刷新一次比赛的比分或互联网上的天气预报。

创建 ScheduledService

创建一个ScheduledService的过程与创建一个Service的过程相同。您需要子类化ScheduledService<V>类,并为createTask()方法提供一个实现。

下面的代码片段创建了一个封装了您之前创建的PrimeFinderTaskScheduledService:

// Create a scheduled service
ScheduledService<ObservableList<Long>> service =
    new ScheduledService <ObservableList<Long>>() {
        @Override
        protected Task<ObservableList<Long>> createTask() {
                // Create and return a Task
                return new PrimeFinderTask();
        }
};

在手动或自动启动或重启服务时,调用服务的createTask()方法。请注意,ScheduledService会自动重启。您可以通过调用start()restart()方法来手动启动和重启它。

Tip

启动、取消、重置和重启ScheduledService的工作方式与Service上的这些操作相同。

更新 ScheduledService 属性

ScheduledService<ScheduledService>类继承了Service<V>类的属性。它添加了以下可用于配置服务计划的属性:

  • lastValue

  • delay

  • period

  • restartOnFailure

  • maximumFailureCount

  • backoffStrategy

  • cumulativePeriod

  • currentFailureCount

  • maximumCumulativePeriod

一个ScheduledService<V>被设计成运行多次。服务计算的当前值没有太大意义。您的类添加了一个新属性lastValue,它的类型是V,它是服务计算的最后一个值。

delay是一个Duration,它指定了服务启动和开始运行之间的延迟。服务在指定的延迟时间内保持在SCHEDULED状态。仅当手动调用start()restart()方法启动服务时,延迟才会生效。当服务自动重新启动时,是否接受 delay 属性取决于服务的当前状态。例如,如果服务在其定期计划之后运行,它将立即重新运行,忽略 delay 属性。默认延迟为零。

period是一个Duration,它指定了上一次运行和下一次运行之间的最短时间。默认周期为零。

restartOnFailure指定服务失败时是否自动重启。默认情况下,它被设置为 true。

currentFailureCount是定期服务失败的次数。当计划服务手动重新启动时,它将重置为零。

maximumFailureCount指定了服务在转换到FAILED状态之前可以失败的最大次数,并且不会自动重新启动。请注意,您可以随时手动重新启动计划服务。默认情况下,它被设置为Integer.MAX_VALUE

backoffStrategy是一个Callback<ScheduledService<?>,Duration>,它计算Duration以添加到每次故障的周期中。通常,如果服务失败,您希望在重试之前减慢速度。假设服务每 10 分钟运行一次。如果第一次失败,您可能希望在 15 分钟后重新启动它。如果第二次失败,您希望将重新运行时间增加到 25 分钟,依此类推。ScheduledService类提供了三个内置的退避策略作为常量:

  • EXPONENTIAL_BACKOFF_STRATEGY

  • LINEAR_BACKOFF_STRATEGY

  • LOGARITHMIC_BACKOFF_STRATEGY

重新运行间隔是根据非零时段和当前故障计数计算的。连续失败运行之间的时间在指数backoffStrategy中呈指数增长,在线性backoffStrategy中呈线性增长,在对数backoffStrategy中呈对数增长。LOGARITHMIC_BACKOFF_STRATEGY是默认设置。当period为零时,使用以下公式。计算的持续时间以毫秒为单位:

  • Exponential : Math.exp(currentFailureCount)

  • Linear: currentFailureCount

  • Logarithmic: Math.log1p(currentFailureCount)

以下公式用于非空值period:

  • Exponential: period + (period * Math.exp(currentFailureCount)

  • Linear: period + (period * currentFailureCount)

  • Logarithmic: period + (period * Math.log1p(currentFailureCount))

cumulativePeriod是一个Duration,它是当前运行失败和下一次运行之间的时间。它的值是使用backoffStrategy属性计算的。它会在计划服务成功运行时重置。它的值可以使用maximumCumulativePeriod属性来限定。

监听计划服务转换事件

ScheduledServiceService经历相同的转换状态。成功运行后,它会自动经过READYSCHEDULEDRUNNING状态。根据计划服务的配置方式,它可能会在运行失败后自动经历相同的状态转换。

您可以监听状态转换并覆盖与转换相关的方法(ready()running()failed()等)。)为一个Service就可以了。当您在ScheduledService子类中覆盖与转换相关的方法时,确保调用 super 方法来保持您的ScheduledService正常工作。

Prime Finder 计划服务示例

让我们将PrimeFinderTaskScheduledService一起使用。一旦开始,ScheduledService将永远继续运行。如果失败五次,它将通过转换到FAILED状态退出。您可以随时手动取消并重新启动该服务。

清单 24-9 中的程序展示了如何使用ScheduledService。该程序与清单 24-8 中显示的程序非常相似,除了两个地方。服务是通过子类化ScheduledService类创建的:

// Create the scheduled service
ScheduledService<ObservableList<Long>> service = new ScheduledService<ObservableList<Long>>() {
        @Override
        protected Task<ObservableList<Long>> createTask() {
                return new PrimeFinderTask();
        }
};

start()方法的开头配置ScheduledService,设置delayperiod,maximumFailureCount属性:

// Configure the scheduled service
service.setDelay(Duration.seconds(5));
service.setPeriod(Duration.seconds(30));
service.setMaximumFailureCount(5);

图 24-7 、 24-8 和 24-9 显示了ScheduledService未启动时、在SCHEDULED状态下观察延迟时间时以及运行时的状态。使用取消Reset按钮取消和重置服务。一旦服务被取消,您可以通过点击Restart按钮手动重启。

img/336502_2_En_24_Fig9_HTML.png

图 24-9

ScheduledService 已启动并正在运行

img/336502_2_En_24_Fig8_HTML.png

图 24-8

ScheduledService 第一次启动,它正在观察延迟时间

img/336502_2_En_24_Fig7_HTML.png

图 24-7

ScheduledService 未启动

// PrimeFinderScheduledService.java
// ...find in the book's download area.

Listing 24-9Using a ScheduledService to Run a Task

摘要

Java(包括 JavaFX) GUI 应用程序本质上是多线程的。多个线程执行不同的任务,以保持 UI 与用户操作同步。与 Swing 和 AWT 一样,JavaFX 使用一个称为 JavaFX 应用程序线程的线程来处理所有 UI 事件。场景图中表示 UI 的节点不是线程安全的。设计非线程安全的节点有利也有弊。它们更快,因为不涉及同步。缺点是需要从单个线程访问它们,以避免处于非法状态。JavaFX 设置了一个限制,即只能从一个线程(JavaFX 应用程序线程)访问实时场景图形。这个限制间接地强加了另一个限制,即 UI 事件不应该处理长时间运行的任务,因为它会使应用程序没有响应。用户将得到应用程序被挂起的印象。JavaFX 并发框架构建在 Java 语言并发框架之上,记住它将在 GUI 环境中使用。该框架由一个接口、四个类和一个枚举组成。它提供了一种设计多线程 JavaFX 应用程序的方法,该应用程序可以在工作线程中执行长时间运行的任务,保持 UI 的响应性。

接口的一个实例代表一个需要在一个或多个后台线程中执行的任务。任务的状态可以从 JavaFX 应用程序线程中观察到。TaskServiceScheduledService类实现了Worker接口。它们代表不同类型的任务。它们是抽象类。

Task类的一个实例代表一个一次性任务。A Task不能重复使用。

Service类的一个实例代表一个可重用的任务。

ScheduledService类继承自Service类。一个ScheduledService是一个可以被安排在指定的时间间隔后重复运行的任务。

Worker.State枚举中的常量代表了Worker的不同状态。WorkerStateEvent类的一个实例表示当Worker的状态改变时发生的一个事件。您可以将事件处理程序添加到所有三种类型的任务中,以监听它们的状态变化。

下一章将讨论如何在 JavaFX 应用程序中加入音频和视频。

二十五、播放音频和视频

在本章中,您将学习:

  • 什么是媒体 API

  • 如何播放简短的音频剪辑

  • 如何播放媒体(音频和视频)以及如何跟踪播放的不同方面,如播放速率、音量、播放时间、重复播放和媒体错误

本章的例子在com.jdojo.media包中。为了让它们工作,您必须在module-info.java文件中添加相应的一行:

...
opens com.jdojo.media to javafx.graphics, javafx.base;
...

了解媒体 API

JavaFX 支持通过 JavaFX Media API 播放音频和视频。还支持静态媒体文件和实时提要的 HTTP 实时流。支持多种媒体格式,包括 AAC、AIFF、WAV 和 MP3。还支持包含 VP6 视频和 MP3 音频的 FLV 以及 H.264/AVC 视频格式的 MPEG-4 多媒体容器。对特定媒体格式的支持取决于平台。某些媒体播放功能和格式不需要任何额外安装;有些需要安装第三方软件。有关 JavaFX 的系统要求和支持的媒体格式的详细信息,请参考位于 https://openjfx.io/javadoc/17/javafx.media/javafx/scene/media/package-summary.html#SupportedMediaTypes 的网页。

媒体 API 由几个类组成。图 25-1 显示了一个类图,它只包括媒体 API 中的核心类。API 中的所有类都包含在javafx.scene.media包中。

img/336502_2_En_25_Fig1_HTML.png

图 25-1

媒体 API 中核心类的类图

AudioClip用于以最小的延迟播放一小段音频剪辑。通常,这对于声音效果很有用,声音效果通常是很短的音频剪辑。使用MediaMediaPlayerMediaView类播放较长的音频和视频。

MediaMediaPlayer类用于播放音频和视频。Media类的一个实例代表一个媒体资源,可以是音频或视频。它提供有关介质的信息,例如介质的持续时间。MediaPlayer类的一个实例提供了播放媒体的控件。

MediaView类的一个实例提供了由MediaPlayer播放的媒体的视图。一个MediaView用于观看视频。

当您尝试播放媒体时,可能会出现一些问题,例如,媒体格式可能不受支持,或者媒体内容可能已损坏。MediaException类的一个实例表示在媒体回放期间可能发生的特定类型的媒体错误。当出现与介质相关的错误时,会生成一个MediaErrorEvent。您可以通过向媒体对象添加适当的事件处理程序来处理该错误。

我将在这一章中详细介绍如何在媒体 API 中使用这些类和其他支持类。

播放短音频剪辑

AudioClip类的一个实例用于以最小的延迟播放一小段音频剪辑。通常,这对于播放简短的音频剪辑很有用,例如,当用户出错时发出嘟嘟声,或者在游戏应用程序中产生简短的声音效果。

AudioClip类只提供了一个构造器,它接受一个字符串形式的 URL,即音频源的 URL。音频剪辑会立即以原始、未压缩的形式加载到内存中。这就是为什么您不应该将此类用于长时间播放的音频剪辑的原因。源 URL 可以使用 HTTP、file 和 JAR 协议。这意味着您可以播放来自互联网、本地文件系统和 JAR 文件的音频剪辑。

以下代码片段使用 HTTP 协议创建了一个AudioClip:

String clipUrl = "http://www.jdojo.com/myaudio.wav";
AudioClip audioClip = new AudioClip(clipUrl);

当一个AudioClip对象被创建时,音频数据被加载到内存中,并准备好立即播放。使用play()方法播放音频,使用stop()方法停止播放:

// Play the audio
audioClip.play();
...
// Stop the playback

audioClip.stop();

清单 25-1 中的程序展示了如何使用AudioClip类播放一个音频剪辑。它声明了一个实例变量来存储AudioClip引用。在init()方法中创建了AudioClip,以确保当窗口在start()方法中显示时,剪辑可以播放。您也可以在构造器中创建AudioClipstart()方法增加了开始和停止按钮。它们的动作事件处理程序分别开始和停止回放。

// AudioClipPlayer.java
package com.jdojo.media;

import com.jdojo.util.ResourceUtil;
import java.net.URL;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.layout.HBox;
import javafx.scene.media.AudioClip;
import javafx.stage.Stage;

public class AudioClipPlayer extends Application {
        private AudioClip audioClip;

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

        @Override
        public void init() {
                URL mediaUrl =
                    ResourceUtil.getResourceURL("media/chimes.wav");

            // Create an AudioClip, which loads the audio data
                // synchronously
            audioClip = new AudioClip(mediaUrl.toExternalForm());
        }

        @Override

        public void start(Stage stage) {
            Button playBtn = new Button("Play");
            Button stopBtn = new Button("Stop");

            // Set event handlers for buttons
            playBtn.setOnAction(e -> audioClip.play());
            stopBtn.setOnAction(e -> audioClip.stop());

            HBox root = new HBox(5, playBtn, stopBtn);
            root.setStyle("-fx-padding: 10;");
            Scene scene = new Scene(root);
            stage.setScene(scene);
            stage.setTitle("Playing Short Audio Clips");
            stage.show();
        }
}

Listing 25-1Playing Back an Audio Clip Using an AudioClip Instance

AudioClip类支持在播放剪辑时设置一些音频属性:

  • cycleCount

  • volume

  • rate

  • balance

  • pan

  • priority

除了cycleCount之外,前面的所有属性都可以在AudioClip类上设置。Splay()方法的后续调用将使用它们作为默认值。play()方法也可以覆盖特定回放的缺省值。必须在AudioClip上指定cycleCount属性,所有后续回放将使用相同的值。

The cycleCount指定调用play()方法时剪辑播放的次数。它默认为一个,只播放一次剪辑。您可以使用以下三个INDEFINITE常量之一作为cycleCount来播放AudioClip循环,直到停止:

  • AudioClip.INDEFINITE

  • MediaPlayer.INDEFINITE

  • Animation.INDEFINITE

以下代码片段显示了如何无限期地播放一个音频剪辑五次:

// Play five times
audioClip.setCycleCount(5);
...
// Loop forever
audioClip.setCycleCount(AudioClip.INDEFINITE);

volume指定播放的相对音量。有效范围是 0.0 到 1.0。值 0.0 表示静音,而 1.0 表示最大音量。

rate指定播放音频的相对速度。有效范围是 0.125 到 8.0。值 0.125 表示剪辑播放速度慢八倍,值 8.0 表示剪辑播放速度快八倍。速率影响播放时间和音高。默认速率为 1.0,以正常速率播放剪辑。

balance指定左右声道的相对音量。有效范围是–1.0 到 1.0。值–1.0 将左声道的回放设定为正常音量,并将右声道静音。值 1.0 将右声道的回放设置为正常音量,并将左声道静音。默认值为 0.0,将两个通道中的回放设置为正常音量。

pan指定剪辑在左右声道之间的分布。有效范围是–1.0 到 1.0。值为–1.0 会将片段完全移到左通道。值为 1.0 会将剪辑完全移到右通道。默认值为 0.0,正常播放剪辑。设定单声道片段的声相值与设定平衡效果相同。您应该仅为使用立体声的音频剪辑更改此属性的默认值。

priority指定片段相对于其他片段的优先级。它仅在播放的剪辑数量超过系统限制时使用。将停止播放优先级较低的剪辑。它可以设置为任何整数。默认优先级设置为零。

play()方法被重载。它有三个版本:

  • Void play()

  • void play(double volume)

  • void play(double volume, double balance, double rate, double pan, int priority)

该方法的无参数版本使用在AudioClip上设置的所有属性。其他两个版本可以覆盖特定回放的指定属性。假设AudioClip的音量设置为 1.0。调用play()会以 1.0 的音量播放剪辑,调用play(0.20)会以 0.20 的音量播放剪辑,而AudioClip的音量属性保持为 1.0 不变。也就是说,带有参数的play()方法允许您在每次回放的基础上覆盖AudioClip属性。

AudioClip类包含一个isPlaying()方法来检查剪辑是否还在播放。如果剪辑正在播放,它将返回true。否则返回false

播放媒体

JavaFX 提供了一个统一的 API 来处理音频和视频。您使用相同的类来处理这两者。媒体 API 在内部将它们视为对 API 用户透明的两种不同类型的媒体。从现在开始,我将使用术语媒体来表示音频和视频,除非另有说明。

媒体 API 包含三个播放媒体的核心类:

  • Media

  • MediaPlayer

  • MediaView

创建媒体对象

Media类的一个实例代表一个媒体资源,可以是音频或视频。它提供与媒体相关的信息,例如持续时间、元数据、数据等等。如果媒体是视频,则提供视频的宽度和高度。一个Media对象是不可变的。它是通过提供媒体资源的字符串 URL 来创建的,如以下代码所示:

// Create a Media
String mediaUrl = "http://www.jdojo.com/mymusic.wav";
Media media = new Media(mediaUrl);

Media类包含以下属性,所有属性(除了onError)都是只读的:

  • duration

  • width

  • height

  • error

  • onError

duration以秒为单位指定媒体的持续时间。它是一个Duration对象。如果持续时间未知,则为Duration.UNKNOWN

widthheight分别以像素为单位给出源媒体的宽度和高度。如果媒体没有宽度和高度,它们被设置为零。

erroronError属性是相关的。error属性代表加载媒体时发生的MediaExceptiononError是一个Runnable对象,您可以设置它在错误发生时得到通知。发生错误时调用Runnablerun()方法:

// When an error occurs in loading the media, print it on the console
media.setOnError(() -> System.out.println(player.getError().getMessage()));

创建一个 MediaPlayer 对象

MediaPlayer提供控制,例如播放、暂停、停止、搜索、播放速度、音量调节,用于播放媒体。MediaPlayer只提供了一个接受Media对象作为参数的构造器:

// Create a MediaPlayer
MediaPlayer player = new MediaPlayer(media);

你可以使用MediaPlayer类的getMedia()方法从MediaPlayer中获取媒体的引用。

Media类一样,MediaPlayer类也包含用于报告错误的erroronError属性。当MediaPlayer出现错误时,Media对象也会报告同样的错误。

MediaPlayer类包含许多属性和方法。我将在随后的章节中讨论它们。

创建一个媒体视图节点

一个MediaView是一个节点。它提供了由MediaPlayer播放的媒体的视图。请注意,音频剪辑没有视觉效果。如果你尝试为一个音频内容创建一个MediaView,它将是空的。要观看视频,您需要创建一个MediaView并将其添加到场景图中。

MediaView类提供了两个构造器,一个是无参数构造器,另一个以MediaPlayer作为参数:

  • public MediaView()

  • public MediaView(MediaPlayer mediaPlayer)

无参数构造器创建一个附加到任何MediaPlayerMediaView。您需要使用mediaPlayer属性的 setter 来设置一个MediaPlayer:

// Create a MediaView with no MediaPlayer
MediaView mediaView = new MediaView();
mediaView.setMediaPlayer(player);

另一个构造器让您为MediaView指定一个MediaPlayer:

// Create a MediaView
MediaView mediaView = new MediaView(player);

结合媒体媒体播放器媒体视图

一个媒体的内容可以被多个Media对象同时使用。然而,一个Media对象在其生命周期中只能与一个媒体内容相关联。

一个Media对象可以与多个MediaPlayer对象相关联。然而,一只MediaPlayer在其一生中只与一只Media相关联。

一个MediaView可以可选地与一个MediaPlayer相关联。当然,与MediaPlayer无关的MediaView没有任何视觉效果。可以更改MediaViewMediaPlayer。改变MediaViewMediaPlayer类似于改变电视频道。MediaView的视图由其当前的MediaPlayer提供。您可以将同一个MediaPlayer与多个MediaViews相关联。不同的MediaViews在播放过程中可能会显示同一媒体的不同部分。图 25-2 显示了媒体播放中涉及的三类对象之间的关系。

img/336502_2_En_25_Fig2_HTML.png

图 25-2

不同媒体相关对象在媒体回放中的角色以及它们之间的关系

媒体播放器示例

现在,您已经有足够的背景知识来理解用于播放音频和视频的机制。清单 25-2 中的程序使用ResourceUtil查找文件位置来播放视频剪辑。程序使用一个视频文件resources/media/gopro.mp4。这个文件可能没有包含在源代码中,因为它大约有 50MB。如果 JavaFX 支持您自己的媒体文件格式,您可以在此程序中替换它。

// QuickMediaPlayer.java
package com.jdojo.media;

import com.jdojo.util.ResourceUtil;
import java.net.URL;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.media.Media;
import javafx.scene.media.MediaPlayer;
import javafx.scene.media.MediaView;
import javafx.stage.Stage;
import static javafx.scene.media.MediaPlayer.Status.PLAYING;

public class QuickMediaPlayer extends Application {
        public static void main(String[] args) {
            Application.launch(args);
        }

        @Override
        public void start(Stage stage) {
            // Locate the media content
                URL mediaUrl = ResourceUtil.getResourceURL("media/gopro.mp4");
            String mediaStringUrl = mediaUrl.toExternalForm();

            // Create a Media
            Media media = new Media(mediaStringUrl);

            // Create a Media Player
            MediaPlayer player = new MediaPlayer(media);

            // Automatically begin the playback
            player.setAutoPlay(true);

            // Create a 400X300 MediaView
            MediaView mediaView = new MediaView(player);
            mediaView.setFitWidth(400);
            mediaView.setFitHeight(300);

            // Create Play and Stop player control buttons and add action
            // event handlers to them
            Button playBtn = new Button("Play");
            playBtn.setOnAction(e -> {
                    if (player.getStatus() == PLAYING) {
                            player.stop();
                            player.play();
                    } else {
                            player.play();
                    }

            });

            Button stopBtn = new Button("Stop");
            stopBtn.setOnAction(e -> player.stop());

            // Add an error handler
            player.setOnError(() ->
                    System.out.println(player.getError().getMessage()));

            HBox controlBox = new HBox(5, playBtn, stopBtn);
            BorderPane root = new BorderPane();

            // Add the MediaView and player controls to the scene graph
            root.setCenter(mediaView);
            root.setBottom(controlBox);

            Scene scene = new Scene(root);
            stage.setScene(scene);
            stage.setTitle("Playing Media");
            stage.show();
        }
}

Listing 25-2Using the Media, MediaPlayer, and MediaView Classes to Play a Media

start()方法中的前两条语句为媒体文件准备了一个字符串 URL:

// Locate the media content
URL mediaUrl = ResourceUtil.getResourceURL("media/gopro.mp4");
String mediaStringUrl = mediaUrl.toExternalForm();

如果您想播放来自 Internet 的媒体,您可以用类似下面的语句替换这三个语句:

String mediaStringUrl = "http://www.jdojo.com/video.flv";

程序创建一个Media、一个MediaPlayer和一个MediaView。它将MediaPlayerautoPlay属性设置为 true,这将尽快开始播放媒体:

// Automatically begin the playback
player.setAutoPlay(true);

MediaView的尺寸设定为 400 像素宽 300 像素高。如果媒体是视频,视频将被缩放以适合此大小。你会看到一个空的音频区。您可以稍后增强MediaView,这样它将占用媒体所需的空间。

创建了PlayStop按钮。事件处理程序被添加到它们中。它们可以分别用于开始和停止播放。当媒体已经在播放时,点按“播放”按钮会停止播放并再次播放媒体。

播放媒体时,很多事情都会出错。程序为MediaPlayer设置onError属性,T1 是一个Runnable。它的run()方法在错误发生时被调用。run()方法在控制台上打印错误消息:

// Add an error handler

player.setOnError(() -> System.out.println(player.getError().getMessage()));

当你运行程序时,视频应该会自动播放。您可以使用屏幕底部的按钮停止和重放它。如果有错误,您将在控制台上看到一条错误消息。

Tip

类可以播放音频和视频。你所需要做的就是改变源的 URL 来指向你想要播放的媒体。

处理回放错误

RuntimeException类继承而来的MediaException类的一个实例表示一个可能发生在MediaMediaPlayerMediaView中的媒体错误。媒体播放可能由于多种原因而失败。API 用户应该能够识别特定的错误。MediaException类定义了一个静态枚举MediaException.Type,其常量标识了错误的类型。MediaException类包含一个getType()方法,该方法返回MediaException.Type枚举的一个常量。

  • MEDIA_CORRUPTED

  • MEDIA_INACCESSIBLE

  • MEDIA_UNAVAILABLE

  • MEDIA_UNSPECIFIED

  • MEDIA_UNSUPPORTED

  • OPERATION_UNSUPPORTED

  • PLAYBACK_HALTED

  • PLAYBACK_ERROR

  • UNKNOWN

MEDIA_CORRUPTED错误类型表示介质损坏或无效。MEDIA_INACCESSIBLE错误类型表示介质不可访问。但是,媒体可能存在。MEDIA_UNAVAILABLE错误类型表示介质不存在或不可用。MEDIA_UNSPECIFIED错误类型表示尚未指定介质。MEDIA_UNSUPPORTED错误类型表示该平台不支持该媒体。OPERATION_UNSUPPORTED错误类型表示平台不支持在介质上执行的操作。PLAYBACK_HALTED错误类型表示停止播放的不可恢复的错误。PLAYBACK_ERROR错误类型表示不属于任何其他描述类别的回放错误。UNKNOWN错误类型表示发生了未知错误。

MediaMediaPlayer类包含一个属于MediaExceptionerror属性。所有三个类——MediaMediaPlayerMediaView——都包含一个onError属性,这是一个在发生错误时调用的事件处理程序。这些类中的onError属性的类型不一致。对于MediaMediaPlayer类是一个Runnable,对于MediaView类是一个MediaErrorEvent。下面的代码片段显示了如何处理MediaMediaPlayerMediaView上的错误。他们在控制台上打印错误详细信息:

player.setOnError(() -> {
        System.out.println(player.getError().getMessage());
});

media.setOnError(() -> {
        System.out.println(player.getError().getMessage());
});

mediaView.setOnError((MediaErrorEvent e) ->  {
        MediaException error = e.getMediaError();
        MediaException.Type errorType = error.getType();
        String errorMsg = error.getMessage();
        System.out.println("Error Type:" + errorType +
              ", error mesage:" + errorMsg);
});

在 JavaFX 应用程序线程上调用媒体错误处理程序。因此,从处理程序更新场景图是安全的。

建议您将MediaMediaPlayerMediaView对象的创建放在try-catch块中,并适当地处理异常。这些对象的onError处理程序在对象被创建后被涉及。如果在创建这些对象的过程中出现错误,这些处理程序将不可用。例如,如果您尝试使用的媒体类型不受支持,创建Media对象会导致错误:

try {
        Media media = new Media(mediaStringUrl);
        ...
}
catch (MediaException e) {
        // Handle errors here
}

媒体播放器的状态转换

一个MediaPlayer总有一个状态。只读的status属性表示MediaPlayer的当前状态。在MediaPlayer上执行动作时,状态会改变。它不能直接设置。MediaPlayer的状态由MediaPlayer.Status枚举中的八个常量之一定义:

  • UNKNOWN

  • READY

  • PLAYING

  • PAUSED

  • STALLED

  • STOPPED

  • HALTED

  • DISPOSED

当调用以下方法之一时,MediaPlayer从一种状态转换到另一种状态:

  • play()

  • pause()

  • stop()

  • dispose()

图 25-3 显示了一个MediaPlayer的状态转换。图 25-3 不包括HALTEDDISPOSED状态,因为这两种状态都是终端状态。

img/336502_2_En_25_Fig3_HTML.png

图 25-3

媒体播放器的状态及其转换

MediaPlayer被创建时,其状态为UNKNOWN。一旦媒体被预卷并准备好播放,MediaPlayerUNKNOWN转换到READY。一旦MediaPlayer退出UNKNOWN状态,在其生命周期内就不能重新进入。

当调用play()方法时,MediaPlayer转换到PLAYING状态。此状态表示媒体正在播放。注意如果autoPlay属性设置为 true,MediaPlayer可能在创建后不需要显式调用play()方法就可以进入PLAYING状态。

MediaPlayer正在播放时,如果它的缓冲区中没有足够的数据可以播放,它可能会进入STALLED状态。该状态表示MediaPlayer正在缓冲数据。当足够的数据被缓冲时,它回到PLAYING状态。当一个MediaPlayer被停止时,调用pause()stop()方法,它分别转换到PAUSEDSTOPPED状态。在这种情况下,缓冲继续进行;然而,一旦缓冲了足够的数据,MediaPlayer不会转换到PLAYING状态。而是停留在PAUSEDSTOPPED状态。

调用pause()方法将MediaPlayer转换到PAUSED状态。调用stop()方法将MediaPlayer转换到STOPPED状态。

如果出现不可恢复的错误,MediaPlayer转换到HALTED终端状态。该状态表示MediaPlayer不能再次使用。如果您想再次播放媒体,您必须创建一个新的MediaPlayer

dispose()方法释放所有与MediaPlayer相关的资源。然而,MediaPlayer使用的Media对象仍然可以使用。调用dispose()方法将MediaPlayer转换到终端状态DISPOSED

在应用程序中显示MediaPlayer的状态是很常见的。向 status 属性添加一个ChangeListener来监听任何状态变化。

通常,当MediaPlayer的状态改变时,您会对收到通知感兴趣。有两种方法可以获得通知:

  • 通过向状态属性添加一个ChangeListener

  • 通过设置状态更改处理程序

如果您对监听任何类型的状态变化感兴趣,第一种方法是合适的。以下代码片段展示了这种方法:

MediaPlayer player = new MediaPlayer(media);

// Add a ChangeListener to the player
player.statusProperty().addListener((prop, oldStatus, newStatus) -> {
        System.out.println("Status changed from " + oldStatus +
               " to " + newStatus);
});

如果您对处理特定类型的状态更改感兴趣,第二种方法是合适的。MediaPlayer类包含以下可设置为Runnable对象的属性:

  • onReady

  • onPlaying

  • onRepeat

  • onStalled

  • onPaused

  • onStopped

  • onHalted

MediaPlayer进入特定状态时,调用Runnable对象的run()方法。例如,当玩家进入PLAYING状态时,调用onPlaying处理程序的run()方法。以下代码片段显示了如何为特定类型的状态更改设置处理程序:

// Add a handler for PLAYING status
player.setOnPlaying(() -> {
        System.out.println("Playing...");
});

// Add a handler for STOPPED status
player.setOnStopped(() -> {
        System.out.println("Stopped...");
});

重复媒体播放

媒体可以重复播放指定的次数,甚至可以无限期播放。cycleCount属性指定回放将被重复的次数。默认情况下,它被设置为 1。将其设置为MediaPlayer.INDEFINITE可无限重复播放,直到播放器暂停或停止播放。只读的currentCount属性被设置为已完成的播放周期数。当媒体正在播放第一个循环时,它被设置为零。在第一个周期结束时,它被设置为 1;它在第二个周期结束时增加到 2;等等。以下代码将设置四次回放周期:

// The playback should repeat 4 times
player.setCycleCount(4);

当播放周期的媒体结束时,您会收到通知。为MediaPlayer类的onEndOfMedia p属性设置一个Runnable来获取通知。注意,如果回放持续四个周期,媒体结束通知将被发送四次:

player.setOnEndOfMedia(() -> {
        System.out.println("End of media...");
});

您可以添加一个onRepeat事件处理程序,当一个回放周期的媒体结束并且回放将要重复时,将调用该事件处理程序。它在onEndOfMedia事件处理程序之后被调用:

player.setOnRepeat(() -> {
        System.out.println("Repeating...");
});

跟踪媒体时间

显示媒体持续时间和播放所用的时间对观众来说是一个重要的反馈。很好地理解这些持续时间类型对于开发一个好的媒体播放仪表板是很重要的。不同类型的持续时间可以与媒体相关联:

  • 媒体播放的当前持续时间

  • 媒体播放的持续时间

  • 媒体播放一个周期的持续时间

  • 开始偏移时间

  • 结束偏移时间

默认情况下,媒体按其原始持续时间播放。例如,如果媒体的持续时间为 30 分钟,则媒体将在一个循环中播放 30 分钟。MediaPlayer让您指定回放的长度,可以是媒体持续时间中的任何时间。例如,对于每个回放周期,您可以指定只播放媒体的中间 10 分钟(第 11 到第 12 分钟)。媒体播放的长度由MediaPlayer类的以下两个属性指定:

  • startTime

  • stopTime

这两个属性都属于Duration类型。startTimestopTime分别是媒体在每个周期开始和停止播放的时间偏移量。默认情况下,startTime设置为Duration.ZERO,而stopTime设置为媒体的持续时间。以下代码片段设置了这些属性,因此媒体将从第 10 分钟播放到第 21 分钟:

player.setStartTime(Duration.minutes(10));
player.setStartTime(Duration.minutes(21));

以下限制适用于startTimestopTime值:

0 ≤ startTime < stopTime
startTime < stopTime ≤ Media.duration

只读的currentTime属性是媒体播放中的当前时间偏移。只读的cycleDuration属性是stopTimestartTime的区别。它是每个循环的播放长度。The只读totalDuration属性指定播放的总持续时间,如果播放被允许继续直到结束。它的值是cycleDuration乘以cycleCount。如果cycleCountINDEFINITE,则totalDurationINDEFINITE。如果媒体持续时间为UNKNOWN,则totalDuration将为UNKNOWN

当您从网络播放媒体时,MediaPlayer可能会因为没有足够的数据继续播放而停止。只读的bufferProgressTime属性给出了媒体可以不间断播放的持续时间。

控制回放速率

MediaPlayerrate属性指定回放的速率。有效范围是 0.0 到 8.0。例如,2.0 的rate播放媒体的速度是正常速度的两倍。默认值为 1.0,以正常速率播放媒体。只读的currentRate属性是回放的当前速率。以下代码会将速率设置为正常速率的三倍:

// Play the media at 3x
player.setRate(3.0);

控制播放音量

MediaPlayer类中的三个属性控制媒体中音频的音量:

  • volume

  • mute

  • balance

volume指定音频的音量。范围是 0.0 到 1.0。值为 0.0 会使音频听不见,而值为 1.0 会以最大音量播放。默认值为 1.0。

mute指定音频是否由MediaPlayer产生。默认情况下,其值为 false,并产生音频。将其设置为 true 不会产生音频。请注意,设置mute属性不会影响volume属性。假设volume设置为 1.0,静音设置为真。没有产生音频。当mute设置为 false 时,音频将使用 1.0 的volume属性,并以最大音量播放。以下代码将音量设置为一半:

// Play the audio at half the full volumne
player.setVolumne(0.5);
...
// Mute the audio
player.setMute(true)

balance指定左右声道的相对音量。有效范围是–1.0 到 1.0。值–1.0 将左声道的回放设定为正常音量,并将右声道静音。值 1.0 将右声道的回放设置为正常音量,并将左声道静音。默认值为 0.0,将两个通道中的回放设置为正常音量。

定位媒体播放器

您可以使用seek(Duration position)方法将MediaPlayer定位在特定的回放时间:

// Position the media at the fifth minutes play time
player.seek(Duration.minutes(5.0));

调用seek()方法没有任何效果,如果

  • MediaPlayer处于STOPPED状态。

  • 媒体持续时间为Duration.INDEFINITE

  • 您将nullDuration.UNKNOWN传递给seek()方法。

  • 在所有其他情况下,该位置被夹在MediaPlayerstartTimestopTime之间。

在媒体上标记位置

您可以将标记与媒体时间线上的特定点相关联。标记是简单的文本,在许多方面都很有用。你可以用它们来插入广告。例如,您可以插入 URL 作为标记文本。当到达标记时,您可以暂停播放媒体并播放另一个媒体。注意,播放另一个媒体需要创建新的MediaMediaPlayer对象。你可以重用一个MediaView。播放广告视频时,将MediaView与新的MediaPlayer联系起来。广告播放结束后,将MediaView重新关联到主MediaPlayer

Media类包含一个返回ObservableMap<String, Duration>getMarkers()方法。您需要在地图中添加(键,值)对来添加标记。以下代码片段向媒体添加了三个标记:

Media media = ...
ObservableMap<String, Duration> markers = media.getMarkers();
markers.put("START", Duration.ZERO);
markers.put("INTERVAL", media.getDuration().divide(2.0));
markers.put("END", media.getDuration());

当到达一个标记时,MediaPlayer触发一个MediaMarkerEvent。您可以在MediaPlayeronMarker属性中为该事件注册一个处理程序。下面的代码片段显示了如何处理MediaMarkerEvent。事件的getMarker()方法返回一个Pair<String, Duration>,其键和值分别是标记文本和标记持续时间:

// Add a marker event handler
player.setOnMarker((MediaMarkerEvent e) -> {
        Pair<String, Duration> marker = e.getMarker();
        String markerText = marker.getKey();
        Duration markerTime = marker.getValue();
        System.out.println("Reached the marker " + markerText +
               " at " + markerTime);
});

显示媒体元数据

一些元数据可以被嵌入到描述媒体的媒体中。通常,元数据包含标题、艺术家姓名、专辑名称、流派、年份等等。下面的代码片段显示了当MediaPlayer进入READY状态时媒体的元数据。不要试图在创建Media对象后立即读取元数据,因为元数据可能不可用:

Media media = ...
MediaPlayer player = new MediaPlayer(media);

// Display the metadata data on the console
player.setOnReady(() -> {
        ObservableMap<String, Object> metadata = media.getMetadata();
        for(String key : metadata.keySet()) {
            System.out.println(key + " = " + metadata.get(key));
        }
});

您无法确定媒体中是否有元数据或媒体可能包含的元数据类型。在您的应用程序中,您可以只查找标题、艺术家、专辑和年份。或者,您可以读取所有元数据,并在两列表中显示它们。有时,元数据可能包含艺术家的嵌入图像。您需要检查映射中值的类名才能使用该图像。

定制媒体视图

如果媒体有视图(如视频),您可以使用以下属性自定义视频的大小、区域和质量:

  • fitHeight

  • fitWidth

  • preserveRatio

  • smooth

  • viewport

  • x

  • y

fitWidthfitHeight属性分别指定调整后的视频宽度和高度。默认情况下,它们为零,这意味着将使用媒体的原始宽度和高度。

属性指定在调整大小时是否保留媒体的纵横比。默认情况下,它是假的。

smooth属性指定在调整视频大小时使用的过滤算法的质量。默认值取决于平台。如果设置为 true,则使用质量更好的过滤算法。请注意,质量较好的过滤需要更多的处理时间。对于较小的视频,您可以将其设置为 false。对于较大的视频,建议将该属性设置为 true。

视口是一个矩形区域,用于查看图形的一部分。通过viewportxy属性,您可以指定将在MediaView中显示的视频中的矩形区域。视口是在原始媒体帧的坐标系中指定的Rectangle2Dxy属性是视口左上角的坐标。回想一下,一个MediaPlayer可以关联多个MediaViews。将多个MediaViews与视口一起使用,可以给观众分割视频的印象。使用一个带有视窗的MediaView,你可以让观众只看到视频可视区域的一部分。

一个MediaView是一个节点。因此,为了给观众更好的视觉体验,还可以对MediaView应用效果和变换。

开发媒体播放器应用程序

开发一个好看的、可定制的媒体播放器应用程序需要仔细的设计。我已经介绍了 JavaFX 中媒体 API 提供的大部分特性。结合开发用户界面和媒体 API 的知识,您可以设计和开发自己的媒体播放器应用程序。开发应用程序时,请记住以下几点:

  • 应用程序应该能够指定媒体源。

  • 应用程序应该提供一个 UI 来控制媒体播放。

  • 当媒体源改变时,您将需要创建一个新的Media对象和一个MediaPlayer。您可以通过使用setMediaPlayer()方法设置新的MediaPlayer来重用MediaView

摘要

JavaFX 支持通过 JavaFX Media API 播放音频和视频。还支持静态媒体文件和实时提要的 HTTP 实时流。支持多种媒体格式,如 AAC、AIFF、WAV 和 MP3。支持包含 VP6 视频和 MP3 音频的 FLV 以及 H.264/AVC 视频格式的 MPEG-4 多媒体容器。对特定媒体格式的支持取决于平台。某些媒体播放功能和格式不需要任何额外安装;但是有些需要安装第三方软件。媒体 API 由几个类组成。API 中的所有类都包含在javafx.scene.media包中。

一个AudioClip用于以最小的延迟播放一个短的音频剪辑。通常,这对于声音效果很有用,声音效果通常是很短的音频剪辑。使用MediaMediaPlayerMediaView类播放较长的音频和视频。

MediaMediaPlayer类用于播放音频和视频。Media类的一个实例代表一个媒体资源,可以是音频或视频。它提供有关介质的信息,例如介质的持续时间。MediaPlayer类的一个实例提供了播放媒体的控件。一个MediaPlayer总是指示播放的状态。只读的status属性表示MediaPlayer的当前状态。当在MediaPlayer上执行一个动作时status改变。状态可以是未知、就绪、正在播放、暂停、停止、停止或已处置。

MediaView类的一个实例提供了由MediaPlayer播放的媒体的视图。一个MediaView用于观看视频。

当您尝试播放媒体时,可能会出现一些问题,例如,媒体格式可能不受支持,或者媒体内容可能已损坏。MediaException类的一个实例表示在媒体回放期间可能发生的特定类型的媒体错误。当出现与介质相关的错误时,会生成一个MediaErrorEvent。您可以通过向媒体对象添加适当的事件处理程序来处理该错误。

下一章将讨论 FXML,这是一种基于 XML 的语言,用于为 JavaFX 应用程序构建用户界面。

二十六、理解 FXML

在本章中,您将学习:

  • 什么是 FXML

  • 如何编辑 FXML 文档

  • FXML 文档的结构

  • 如何在 FXML 文档中创建对象

  • 如何在 FXML 文档中指定资源的位置

  • 如何在 FXML 文档中使用资源包

  • 如何从一个 FXML 文档引用其他 FXML 文档

  • 如何在 FXML 文档中引用常量

  • 如何引用其他元素以及如何在 FXML 文档中复制元素

  • 如何在 FXML 文档中绑定属性

  • 如何使用 FXML 创建自定义控件

本章的例子在com.jdojo.fxml包中。为了让它们工作,您必须在module-info.java文件中添加相应的一行:

...
opens com.jdojo.fxml to javafx.graphics, javafx.base;
...

什么是 FXML?

FXML 是一种基于 XML 的语言,旨在为 JavaFX 应用程序构建用户界面。您可以使用 FXML 构建整个场景或场景的一部分。FXML 允许应用程序开发人员将构建 UI 的逻辑与业务逻辑分开。如果应用程序的 UI 部分发生变化,您不需要重新编译 JavaFX 代码。相反,您可以使用文本编辑器更改 FXML 并重新运行应用程序。您仍然使用 JavaFX 通过 Java 语言编写业务逻辑。FXML 文档是 XML 文档。理解本章需要 XML 的基础知识。

JavaFX 场景图是 Java 对象的层次结构。XML 格式非常适合存储表示某种层次结构的信息。所以用 FXML 存储场景图是非常直观的。在 JavaFX 应用程序中使用 FXML 构建场景图是很常见的。然而,FXML 的使用并不仅限于构建场景图。它可以构建 Java 对象的分层对象图。事实上,它只能用来创建一个对象,比如一个Person类的对象。

让我们快速预览一下 FXML 文档的样子。首先,创建一个简单的 UI,它由一个带有一个 ?? 的 ?? 和一个 ?? 组成。清单 26-1 包含了构建 UI 的 JavaFX 代码,这是您所熟悉的。清单 26-2 包含了用于构建相同 UI 的 FXML 版本。

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Button?>

<VBox>
        <children>

                <Label text="FXML is cool"/>
                <Button text="Say Hello"/>
        </children>
</VBox>

Listing 26-2A Code Snippet to Build an Object Graph in FXML

import javafx.scene.layout.VBox;
import javafx.scene.control.Label;
import javafx.scene.control.Button;

VBox root = new VBox();
root.getChildren().addAll(new Label("FXML is cool"), new Button("Say Hello"));

Listing 26-1A Code Snippet to Build an Object Graph in JavaFX

FXML中的第一行是 XML 解析器使用的标准 XML 声明。它在 FXML 中是可选的。如果省略,则版本和编码分别假定为 1 和 UTF-8。接下来的三行是导入语句,对应于 Java 代码中的导入语句。代表 UI 的元素,如VBoxLabelButton,与 JavaFX 类同名。<children>标签指定了VBox的子节点。使用各自元素的text属性来指定LabelButton的文本属性。

编辑 FXML 文档

FXML 文档只是一个文本文件。通常,文件名有一个.fxml扩展名(例如hello.fxml)。例如,您可以使用记事本在 Windows 中创建 FXML 文档。如果您使用过 XML,就会知道在文本编辑器中编辑大型 XML 文档并不容易。胶子公司提供了一个名为场景构建器的可视化编辑器,用于编辑 FXML 文档。场景构建器是开源的。可以从 https://gluonhq.com/products/scene-builder/ 下载其最新版本。Scene Builder 也可以集成到一些 ide 中,因此您可以在 IDE 中使用 Scene Builder 编辑 FXML 文档。本书不讨论场景构建器。

FXML 基础

本节涵盖了 FXML 的基础知识。您将开发一个简单的 JavaFX 应用程序,它由以下内容组成:

  • VBox

  • Label

  • Button

VBoxspacing属性被设置为 10px。LabelButtontext属性被设置为“FXML 太酷了!”还有“问好”。当点击Button时,Label中的文本变为“Hello from FXML!”。图 26-1 显示了应用程序显示的窗口的两个实例。

img/336502_2_En_26_Fig1_HTML.png

图 26-1

窗口的两个实例,其场景图形是使用 FXML 创建的

清单 26-3 中的程序是示例应用程序的 JavaFX 实现。如果你已经完成了书中的这一章,这个程序应该很容易。

// HelloJavaFX.java
package com.jdojo.fxml;

import javafx.application.Application;
import javafx.event.ActionEvent;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class HelloJavaFX extends Application {
        private final Label msgLbl = new Label("FXML is cool!");

        private final Button sayHelloBtn = new Button("Say Hello");

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

        @Override
        public void start(Stage stage) {
                // Set the preferred width of the label
                msgLbl.setPrefWidth(150);

                // Set the ActionEvent handler for the button
                sayHelloBtn.setOnAction(this::sayHello);

                VBox root = new VBox(10);
                root.getChildren().addAll(msgLbl, sayHelloBtn);
                root.setStyle("""
                         -fx-padding: 10;
                   -fx-border-style: solid inside;
                   -fx-border-width: 2;
                   -fx-border-insets: 5;
                   -fx-border-radius: 5;
                   -fx-border-color: blue;""");
                Scene scene = new Scene(root);
                stage.setScene(scene);
                stage.setTitle("Hello FXML");
                stage.show();
        }

        public void sayHello(ActionEvent e) {
                msgLbl.setText("Hello from FXML!");
        }

}

Listing 26-3The JavaFX Version of the FXML Example Application

创建 FXML 文件

让我们创建一个 FXML 文件sayhello.fxml。将文件存储在resources/fxml目录中,其中resources目录由ResourceUtil类正确寻址。

添加 UI 元素

FXML 文档的根元素是对象图中的顶级对象。你的顶层对象是一个VBox。因此,FXML 的根元素应该是

<VBox>
</VBox>

你怎么知道要在对象图中表示一个VBox,你需要在 FXML 中使用一个<VBox>标签?这既困难又容易。这很困难,因为没有关于 FXML 标记的文档。这很简单,因为 FXML 有几条规则解释标签名的构成。例如,如果一个标记名是一个类的简单名或完全限定名,该标记将创建该类的一个对象。前面的元素将创建一个VBox类的对象。可以使用完全限定的类名重写前面的 FXML:

<javafx.scene.layout.VBox>
</javafx.scene.layout.VBox>

在 JavaFX 中,布局窗格有子级。在 FXML 中,布局窗格的子元素是子元素。您可以为VBox添加一个Label和一个Button,如下所示:

<VBox>
        <Label></Label>
        <Button></Button>
</VBox>

这为这个示例应用程序定义了对象图的基本结构。它将创建一个带有一个Label和一个ButtonVBox。剩下的讨论将集中在添加细节上,例如,为控件添加文本和为VBox设置样式。

前面的 FXML 显示了LabelButtonVBox的子元素。从 GUI 的角度来看,确实如此。但是,从技术上来说,它们属于VBox对象的children属性,而不直接属于VBox。为了更专业(也更详细),您可以重写前面的 FXML,如下所示:

<VBox>
        <children>
                <Label></Label>
                <Button></Button>
        <children>
</VBox>

您如何知道可以忽略前面 FXML 中的<children>标签,并仍然得到相同的结果?JavaFX 库在javafx.beans包中包含一个注释DefaultProperty。它可以用来注释类。它包含一个String类型的值元素。元素指定类的属性,该属性应被视为 FXML 中的默认属性。如果 FXML 中的子元素不表示其父元素的属性,则它属于父元素的默认属性。VBox类继承自Pane类,其声明如下:

@DefaultProperty(value="children")
public class Pane extends Region {...}

Pane类的注释使children属性成为 FXML 中的默认属性。VBoxPane类继承了这个注释。这就是前面的 FXML 中可以省略<children>标签的原因。如果您在一个类上看到了DefaultProperty注释,这意味着您可以省略 FXML 中默认属性的标签。

在 FXML 中导入 Java 类型

要在 FXML 中使用 Java 类的简单名称,必须像在 Java 程序中一样导入类。有一个例外。在 Java 程序中,不需要从java.lang包中导入类。然而,在 FXML 中,您需要从所有包中导入类,包括java.lang包。导入处理指令用于从包中导入一个类或所有类。以下处理指令导入了VBoxLabelButton类:

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Button?>

以下导入处理指令从javafx.scene.controljava.lang包中导入所有类:

<?import javafx.scene.control.*?>
<?import java.lang.*?>

FXML 不支持导入静态成员。请注意,import 语句不使用尾随分号。

在 FXML 中设置属性

您可以在 FXML 中设置 Java 对象的属性。如果属性声明遵循 JavaBean 约定,则可以在 FXML 中设置对象的属性。设置属性有两种方式:

  • 使用 FXML 元素的属性

  • 使用属性元素

属性名称或属性元素名称与正在设置的属性的名称相同。下面的 FXML 创建一个Label并使用属性设置其text属性:

<Label text="FXML is cool!"/>

下面的 FXML 使用属性元素实现了同样的目的:

<Label>
        <text>FXML is cool!</text>
</Label>

下面的 FXML 创建一个Rectangle,并使用属性设置它的xywidthheightfill属性:

<Rectangle x="10" y="10" width="100" height="40" fill="red"/>

FXML 将属性值指定为Strings。自动应用适当的转换将String值转换为所需的类型。在前面的例子中,fill属性的值“红色”将被自动转换成一个Color对象,width属性的值“100”将被转换成一个双精度值,依此类推。

使用属性元素设置对象属性更加灵活。当可以从String自动转换类型时,可以使用属性。假设您想将一个Person类的对象设置为一个对象的属性。这可以使用属性元素来完成。下面的 FXML 设置了类MyCls的对象的person属性:

<MyCls>
        <person>
                <Person>
                        <!-- Configure the Person object here -->
                </Person>
        </person>
</MyCls>

只读属性是有 getter 但没有 setter 的属性。使用 property 元素可以在 FXML 中设置两种特殊类型的只读属性:

  • 只读的List属性

  • 只读的Map属性

使用 property 元素设置只读的List属性。property 元素的所有子元素都将被添加到属性 getter 返回的List中。下面的 FXML 设置了一个VBox的只读children属性:

<VBox>
        <children>
                <Label/>
                <Button/>
        <children>
</VBox>

您可以使用 property 元素的属性向只读的Map属性添加条目。属性的名称和值成为Map中的键和值。下面的代码片段声明了一个类Item,它有一个只读的map属性:

public class Item {
        private Map<String, Integer> map = new HashMap<>();
        public Map getMap() {
                return map;
        }
}

下面的 FXML 创建一个Item对象,并用两个条目(“n1”,100)和(“n2”,200)设置它的map属性。注意属性 n1 和 n2 的名称成为了Map中的键:

<Item>
        <map n1="100" n2="200"/>
</Item>

Java 对象有一种特殊类型的属性,称为静态属性。静态属性未在对象的类上声明。相反,它是使用另一个类的静态方法设置的。假设您想要为将被放置在VBox中的Button设置边距。JavaFX 代码如下所示:

Button btn = new Button("OK");
Insets insets = new Insets(20.0);;
VBox.setMargin(btn, insets);
VBox vbox = new VBox(btn);

通过为Button设置一个VBox.margin属性,您可以在 FXML 中实现同样的功能:

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Button?>
<?import javafx.geometry.Insets?>

<VBox>
    <Button text="OK">
        <VBox.margin>
            <Insets top="20.0" right="20.0" bottom="20.0" left="20.0"/>
        </VBox.margin>
    </Button>
</VBox>

您不能从String创建Insets对象,因此,您不能使用属性来设置 margin 属性。您需要使用属性元素来设置它。当您在 FXML 中使用GridPane时,您可以设置rowIndexcolumnIndex静态,如下所示:

<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.control.Button?>

<GridPane>
        <Button text="OK">
                <GridPane.rowIndex>0</GridPane.rowIndex>
                <GridPane.columnIndex>0</GridPane.columnIndex>
        </Button>
</GridPane>

因为rowIndexcolumnIndex属性也可以表示为Strings,所以可以使用属性来设置它们:

<GridPane>
        <Button text="OK" GridPane.rowIndex="0" GridPane.columnIndex="0"/>
</GridPane>

指定 FXML 命名空间

FXML 没有 XML 架构。它使用的名称空间需要使用名称空间前缀“fx”来指定。在大多数情况下,FXML 解析器会计算出标记名,比如作为类的标记名、类的属性等等。fxML 使用特殊的元素和属性名,必须用“FX”名称空间前缀进行限定。以下 fxML 声明了“FX”命名空间前缀:

<VBox xmlns:fx="http://javafx.com/fxml">...</VBox>

或者,您可以在命名空间 URI 中追加 FXML 的版本。FXML 解析器将验证它可以解析指定的 XML 代码。在撰写本文时,唯一支持的版本是 1.0:

<VBox xmlns:fx="http://javafx.com/fxml/1.0">...</VBox>

FXML 版本可以包括点、下划线和破折号。只比较下划线和破折号第一次出现之前的数字。以下三个声明都将 FXML 版本指定为 1.0:

<VBox xmlns:fx="http://javafx.com/fxml/1">...</VBox>
<VBox xmlns:fx="http://javafx.com/fxml/1.0-ea">...</VBox>
<VBox xmlns:fx="http://javafx.com/fxml/1.0-rc1-2014_03_02">...</VBox>

Tip

<fx:script>标签就是这种命名空间标签的一个例子。它用于向 FXML 文件添加脚本逻辑。但是,尽量避免。首先,FXML 中的脚本支持似乎不是很稳定。其次,将处理逻辑添加到 FXML 前端定义中被认为不是一种好的编程风格。最好使用控制器,我们简称它。

为对象分配标识符

在 FXML 中创建的对象可以在同一文档的其他地方引用。在 JavaFX 代码中获取在 FXML 中创建的 UI 对象的引用是很常见的。您可以通过首先用一个fx:id属性标识 FXML 中的对象来实现这一点。属性的值是对象的标识符。如果对象类型有一个id属性,该属性的值也将被设置。注意 JavaFX 中的每个Node都有一个id属性,可以用来在 CSS 中引用它们。以下是为Label指定fx:id属性的示例:

<Label fx:id="msgLbl" text="FXML is cool!"/>

现在,您可以使用msgLbl来引用Label。属性fx:id有几种用法。例如,它用于在加载 FXML 时将 UI 元素的引用注入到 JavaFX 类的实例变量中。我将在单独的部分讨论这一点。

添加事件处理程序

可以为 FXML 中的节点设置事件处理程序。设置事件处理程序类似于设置任何其他属性。JavaFX 类定义了onXxx属性来为Xxx事件设置事件处理程序。例如,Button类包含一个onAction属性来设置一个ActionEvent处理程序。在 FXML 中,可以指定两种类型的事件处理程序:

  • 编写事件处理程序脚本

  • 控制器事件处理程序

在本书中,我们只讨论控制器事件处理程序,因为通常最好将编程逻辑远离 GUI。我将在“在 FXML 中使用控制器”一节中讨论如何指定控制器事件处理程序。

*### 正在加载 FXML 文档

FXML 文档定义了 JavaFX 应用程序的视图(GUI)部分。您需要加载 FXML 文档来获得它所代表的对象图。加载 FXML 是由FXMLLoader类的一个实例执行的,它在javafx.fxml包中。

FXMLLoader类提供了几个构造器,允许您指定位置、字符集、资源包和其他用于加载文档的元素。您至少需要指定 FXML 文档的位置,这是一个URL。该类包含执行文档实际加载的load()方法。以下代码片段从 Windows 中的本地文件系统加载 FXML 文档:

String fxmlDocUrl = "file:///C:/resources/fxml/test.fxml";
URL fxmlUrl = new URL(fxmlDocUrl);
FXMLLoader loader = new FXMLLoader();
loader.setLocation(fxmlUrl);
VBox root = loader.<VBox>load();

load()方法有一个通用的返回类型。在前面的代码片段中,您已经在对load()方法(loader.<VBox>load())的调用中清楚地表达了您的意图,即您期望从 FXML 文档中得到一个VBox实例。如果您愿意,可以省略通用参数:

// Will work
VBox root = loader.load();

FXMLLoader支持使用InputStream加载 FXML 文档。下面的代码片段使用一个InputStream加载相同的 FXML 文档:

FXMLLoader loader = new FXMLLoader();
String fxmlDocPath = "C:\\resources\\fxml\\test.fxml";
FileInputStream fxmlStream = new FileInputStream(fxmlDocPath);
VBox root = loader.<VBox>load(fxmlStream);

在内部,FXMLLoader使用流读取文档,这可能会抛出一个IOException。所有版本的 l oad()方法在FXMLLoader类中抛出IOException。您在前面的示例代码中省略了异常处理代码。在您的应用程序中,您需要处理异常。

FXMLLoader类包含了几个版本的load()方法。有些是实例方法,有些是静态方法。如果您想从加载器中检索更多信息,比如控制器引用、资源包、位置、字符集和根对象,您需要创建一个FXMLLoader实例并使用 instance load()方法。如果你只想加载一个 FXML 文档而不考虑任何其他细节,你需要使用静态的load()方法。以下代码片段使用静态load()方法加载 FXML 文档:

String fxmlDocUrl = "file:///C:/resources/fxml/test.fxml";
URL fxmlUrl = new URL(fxmlDocUrl);
VBox root = FXMLLoader.<VBox>load(fxmlUrl);

加载 FXML 文档后,下一步做什么?至此,FXML 的作用已经结束,您的 JavaFX 代码应该接管了。我将在本文后面讨论加载器。

清单 26-4 中的程序有这个例子的 JavaFX 代码。它加载存储在 sayHello.fxml 文件中的 FXML 文档。程序使用ResourceUtil实用程序类加载文档。加载器返回一个VBox,它被设置为场景的根。除了在start()方法的声明中有一处不同之外,代码的其余部分与您一直使用的相同。该方法声明它可能抛出一个IOException,这是您必须添加的,因为您已经在方法内部调用了FXMLLoaderload()方法。运行程序时,显示如图 26-1 所示的窗口。

// SayHelloFXML.java
package com.jdojo.fxml;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import java.io.IOException;
import java.net.URL;
import com.jdojo.util.ResourceUtil;
import javafx.scene.Scene;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class SayHelloFXML extends Application {
        public static void main(String[] args) {
                Application.launch(args);
        }

        @Override
        public void start(Stage stage) throws IOException {
            // Construct a URL for the FXML document
                URL fxmlUrl =
                    ResourceUtil.getResourceURL("fxml/sayhello.fxml");

                // Load the FXML document
                VBox root = FXMLLoader.<VBox>load(fxmlUrl);
                Scene scene = new Scene(root);
                stage.setScene(scene);
                stage.setTitle("Hello FXML");
                stage.show();
        }
}

Listing 26-4Using FXML to Build the GUI

如果你点击这个按钮,什么都不会发生。我们将在下一节讨论将 UI 连接到 Java 代码。

在 FXML 中使用控制器

控制器只是一个类名,其对象由 FXML 创建,用于初始化 UI 元素。FXML 允许您使用fx:controller属性在根元素上指定一个控制器。注意,每个 FXML 文档只允许一个控制器,如果指定了控制器,必须在根元素上指定。

下面的 FXML 为VBox元素指定了一个控制器:

<VBox fx:controller="com.jdojo.fxml.SayHelloController"
      xmlns:fx="http://javafx.com/fxml">
</VBox>

控制器需要符合一些规则,它可以用于不同的原因:

  • 控制器由 FXML 加载器实例化。

  • 控制器必须有一个公共的无参数构造器。如果它不存在,FXML 加载器将无法实例化它,这将在加载时引发异常。

  • 控制器可以有可访问的方法,这些方法可以被指定为 FXML 中的事件处理程序。关于“可访问”的含义,请参考下面的讨论

  • FXML 加载器将自动寻找控制器的可访问实例变量。如果可访问实例变量的名称与元素的fx:id属性匹配,则 FXML 中的对象引用会自动复制到控制器实例变量中。这个特性使得控制器可以引用 FXML 中的 UI 元素。控制器可以在以后使用它们,比如将它们绑定到模型。

  • 控制器可以有一个可访问的initialize()方法,该方法应该不带参数,返回类型为void。FXML 加载器将在 FXML 文档加载完成后调用initialize()方法。

清单 26-5 显示了您将在本例中使用的控制器类的代码。

// SayHelloController.java
package com.jdojo.fxml;

import java.net.URL;
import java.util.ResourceBundle;
import javafx.fxml.FXML;
import javafx.scene.control.Label;

public class SayHelloController {
        // The reference of msgLbl will be injected by the FXML loader
        @FXML
        private Label msgLbl;

        // location and resources will be automatically injected by the
           // FXML loader
        @FXML
        private URL location;

        @FXML
        private ResourceBundle resources;

        // Add a public no-args constructor explicitly just to
        // emphasize that it is needed for a controller
        public SayHelloController() {
        }

        @FXML
        private void initialize() {
                System.out.println("Initializing SayHelloController...");
                System.out.println("Location = " + location);
                System.out.println("Resources = " + resources);
        }

        @FXML
        private void sayHello() {
                msgLbl.setText("Hello from FXML!");
        }
}

Listing 26-5A Controller Class

控制器类在一些成员上使用了一个@FXML注释。@FXML注释可以用在字段和方法上。它不能用于类和构造器。通过在成员上使用@FXML注释,您声明了 FXML 加载器可以访问成员,即使它是私有的。FXML loader 使用的公共成员不需要用@FXML注释。然而,用@FXML注释公共成员并不是错误。最好用@FXML注释来注释 FXML 加载器使用的所有成员,公共的和私有的。这告诉代码的读者成员是如何被使用的。

下面的 FXML 将控制器类的sayHello()方法设置为Button的事件处理程序:

<VBox fx:controller="com.jdojo.fxml.SayHelloController"
      xmlns:fx="http://javafx.com/fxml">
        <Button fx:id="sayHelloBtn" text="Say Hello" onAction="#sayHello"/>
...
</VBox>

有两个特殊的实例变量可以在控制器中声明,它们由 FXML 加载器自动注入:

  • @FXML private URL location;

  • @FXML private ResourceBundle resources;

location是 FXML 文档的位置。resources是 FXML 中使用的ResourceBundle的引用,如果有的话。

当事件处理程序属性值以散列符号(#)开始时,它向 FXML loader 表明sayHello是控制器中的方法。控制器中的事件处理程序方法应该符合一些规则:

  • 该方法可以不带参数,也可以只带一个参数。如果它接受一个参数,参数类型必须是与它应该处理的事件兼容的类型赋值。

  • 拥有该方法的两个版本并不是错误:一个不带参数,另一个只有一个参数。在这种情况下,使用带有单个参数的方法。

  • 按照惯例,方法返回类型应该是void,因为没有返回值的接受者。

  • FXML 加载器必须可以访问该方法:将其公开或者用@FXML对其进行注释。

当 FXML 加载器加载完 FXML 文档后,它调用控制器的initialize()方法。该方法不应采用任何参数。FXML 加载器应该可以访问它。在控制器中,您使用了@FXML注释使 FXML 加载器可以访问它。

FXMLLoader类允许您使用setController()方法为代码中的根元素设置控制器。使用getController()方法从加载器获取控制器的参考。开发人员在获取控制器的引用时会犯一个常见的错误。这个错误是由于load()方法的设计方式造成的。load()方法有七个重载版本:其中两个是实例方法,五个是静态方法。要使用getController()方法,必须创建一个FXMLLoader类的对象,并确保使用该类的一个实例方法来加载文档。下面是一个常见错误的例子:

URL fxmlUrl = new URL("file:///C:/resources/fxml/test.fxml");

// Create an FXMLLoader object – a good start
FXMLLoader loader = new FXMLLoader();

// Load the document -- mistake
VBox root = loader.<VBox>load(fxmlUrl);

// loader.getController() will return null
Test controller = loader.getController();
// controller is null here

前面的代码创建了一个FXMLLoader类的对象。然而,在loader变量中调用的load(URL url)方法是静态的load()方法,而不是实例load()方法。因此,loader实例从未获得控制器,当您向它请求控制器时,它会返回null。为了消除混淆,下面是load()方法的实例和静态版本,其中只有前两个版本是实例方法:

  • <T> T load()

  • <T> T load(InputStream inputStream)

  • static <T> T load(URL location)

  • static <T> T load(URL location, ResourceBundle resources)

  • static <T> T load(URL location, ResourceBundle resources, BuilderFactory builderFactory)

  • static <T> T load(URL location, ResourceBundle resources, BuilderFactory builderFactory, Callback<Class<?>,Object> controllerFactory)

  • static <T> T load(URL location, ResourceBundle resources, BuilderFactory builderFactory, Callback<Class<?>,Object> controllerFactory, Charset charset)

以下代码片段是使用load()方法的正确方式,因此您可以在 JavaFX 代码中获得控制器的引用:

URL fxmlUrl = new URL("file:///C:/resources/fxml/test.fxml");

// Create an FXMLLoader object – a good start
FXMLLoader loader = new FXMLLoader();
loader.setLocation(fxmlUrl);

// Calling the no-args instance load() method - Correct
VBox root = loader.<VBox>load();

// loader.getController() will return the controller
Test controller = loader.getController();

现在,您已经有了这个示例应用程序的控制器。让我们修改 FXML 来匹配控制器。清单 26-6 显示了修改后的 FXML。它保存在资源/fxml 目录下的 sayhellowithcontroller.fxml 文件中。

<?xml version="1.0" encoding="UTF-8"?>
<?language javascript?>

<?import javafx.scene.Scene?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Button?>

<VBox fx:controller="com.jdojo.fxml.SayHelloController" spacing="10" xmlns:fx="http://javafx.com/fxml">
        <Label fx:id="msgLbl" text="FXML is cool!" prefWidth="150"/>
        <Button fx:id="sayHelloBtn" text="Say Hello" onAction="#sayHello"/>
        <style>
                -fx-padding: 10;
                -fx-border-style: solid inside;
                -fx-border-width: 2;
                -fx-border-insets: 5;
                -fx-border-radius: 5;
                -fx-border-color: blue;
        </style>
</VBox>

Listing 26-6The Contents of the sayhellowithcontroller.fxml File

清单 26-7 中的程序是这个例子的 JavaFX 应用程序。代码与清单 26-4 中显示的代码非常相似。主要的区别是使用控制器的 FXML 文档。加载文档时,加载器调用控制器的initialize()方法。该方法打印一条消息,包括所使用的资源包引用的位置。当您单击按钮时,控制器的sayHello()方法被调用,该方法在Label中设置文本。请注意,Label引用是由 FXML 加载器自动注入控制器的。

// SayHelloFXMLMain.java
package com.jdojo.fxml;

import java.io.IOException;
import java.net.URL;
import com.jdojo.util.ResourceUtil;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class SayHelloFXMLMain extends Application {
        public static void main(String[] args) {
                Application.launch(args);
        }

        @Override
        public void start(Stage stage) throws IOException {
                // Construct a URL for the FXML document
                     URL fxmlUrl = ResourceUtil.getResourceURL(
                         "fxml/sayhellowithcontroller.fxml");
                VBox root = FXMLLoader.<VBox>load(fxmlUrl);
                Scene scene = new Scene(root);
                stage.setScene(scene);
                stage.setTitle("Hello FXML");
                stage.show();
        }
}

Listing 26-7A JavaFX Application Class Using FXML and a Controller

在 FXML 中创建对象

使用 FXML 的主要目的是创建一个对象图。所有类的对象不是以相同的方式创建的。例如,一些类提供构造器来创建它们的对象,一些静态的valueOf()方法,和一些工厂方法。FXML 应该能够创建所有类的对象,或者至少应该给你一些控制权来决定如何创建这些对象。在下面几节中,我将讨论在 FXML 中创建对象的不同方法。

使用无参数构造器

使用无参数构造器在 FXML 中创建对象很容易。如果一个元素名是一个类名,它有一个无参数的构造器,那么这个元素将创建一个该类的对象。下面的元素创建了一个VBox对象,因为VBox类有一个无参数构造器:

<VBox>
        ...
</VBox>

使用静态 valueOf()方法

有时候,不可变类提供了一个valueOf()方法来构造一个对象。如果valueOf()方法被声明为静态的,它可以接受单个String参数并返回一个对象。您可以使用fx:value属性通过方法创建一个对象。假设你有一个 Xxx 类,它包含一个静态的valueOf(String s)方法。以下是 Java 代码:

Xxx x = Xxx.valueOf("a value");

在 FXML 中也可以这样做

<Xxx fx:value="a value"/>

请注意,您已经声明了valueOf()方法应该能够接受一个String参数,该参数限定了该类别中的以下两个方法:

  • public static Xxx valueOf(String arg)

  • public static Xxx valueOf(Object arg)

以下元素创建了值为 100 和“Hello”的LongString对象:

<Long fx:value="100"/>
<String fx:value="Hello"/>

注意,String类包含一个创建空字符串的无参数构造器。如果您需要一个内容为空字符串的String对象,您仍然可以使用无参数构造器:

<!-- Will create a String object with "" as the content -->
<String/>

当使用前面的元素时,不要忘记导入类LongString,因为 FXML 不会自动从java.lang包中导入类。

值得注意的是,fx:value属性创建的对象类型是从valueOf()对象返回的对象类型,而不是元素的类类型。考虑下面这个Yyy类的方法声明:

public static Zzz valueOf(String arg);

以下元素将创建什么类型的对象?

<Yyy fx:value="hello"/>

如果你的答案是Yyy,那就错了。一般认为元素名是Yyy,所以创建了一个Yyy类型的对象。前面的元素与调用Yyy.valueOf("Hello")相同,后者返回一个Zzz类型的对象。因此,前面的元素创建了一个Zzz类型的对象,而不是Yyy类型的对象。尽管这种用例是可能的,但这是一种令人困惑的设计类的方式。通常,类Xxx中的valueOf()方法返回一个Xxx类型的对象。

使用工厂方法

有时,一个类提供工厂方法来创建它的对象。如果一个类包含一个返回对象的静态无参数方法,那么可以使用带有fx:factory属性的方法。下面的元素使用LocalDate类的now()工厂方法在 FXML 中创建一个LocalDate:

<?import java.time.LocalDate?>
<LocalDate fx:factory="now"/>

有时,您需要在 FXML 中创建 JavaFX 集合。FXCollections类包含几个创建集合的工厂方法。下面的 FXML 片段创建了一个ObservableList<String>,将四个水果名称添加到列表中:

<?import java.lang.String?>
<?import javafx.collections.FXCollections?>
<FXCollections fx:factory="observableArrayList">
        <String fx:value="Apple"/>
        <String fx:value="Banana"/>
        <String fx:value="Grape"/>
        <String fx:value="Orange"/>
</FXCollections>

清单 26-8 中的 FXML 是使用fx:factory属性创建ObservableList的一个例子。该列表用于设置一个ComboBoxitems属性。列表中的值“橙色”被设置为默认值。VBox将显示一个Label和一个ComboBox,上面列出了四种水果的名称。

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.ComboBox?>
<?import java.lang.String?>
<?import javafx.collections.FXCollections?>

<VBox xmlns:fx="http://javafx.com/fxml">
        <Label text="List of Fruits"/>
        <ComboBox>
            <items>
                <FXCollections fx:factory="observableArrayList">
                        <String fx:value="Apple"/>
                        <String fx:value="Banana"/>
                        <String fx:value="Grape"/>
                        <String fx:value="Orange"/>
                </FXCollections>
            </items>

            <value>
                <String fx:value="Orange"/>
            </value>
        </ComboBox>
</VBox>

Listing 26-8Creating a ComboBox, Populating It, and Selecting an Item

使用生成器

如果FXMLLoader不能创建一个类的对象,它会寻找一个可以创建该对象的构建器。构建器是Builder接口的一个实现。接口在javafx.util包中,它包含一个方法build():

public interface Builder<T> {
   public T build();
}

知道如何构建一个特定类型的对象。一个Builder与一个BuilderFactory一起使用,后者是同一个包中的另一个接口:

public interface BuilderFactory {
    public Builder<?> getBuilder(Class<?> type);
}

FXMLLoader允许你使用一个BuilderFactory。当它不能使用所有其他方法创建一个类的对象时,它通过传递对象的类型作为方法参数来调用BuilderFactorygetBuilder()方法。如果BuilderFactory返回一个非空的Builder,加载程序将在Builder中设置正在创建的对象的所有属性。最后,它调用Builderbuild()方法来获取对象。FXMLLoader类使用JavaFXBuilderFactory的一个实例作为默认的BuilderFactory

FXMLLoader支持两种类型的Builders:

  • 如果Builder实现了Map接口,则使用put()方法将对象属性传递给Builder。向put()方法传递属性的名称和值。

  • 如果Builder没有实现Map接口,那么对于 FXML 中指定的所有属性,Builder应该包含基于 JavaBeans 约定的 getter 和 setter 方法。

考虑清单 26-9 中Item类的声明。默认情况下,FXML 不能创建一个Item对象,因为它没有无参数构造器。该类有两个属性,id 和 name。

// Item.java
package com.jdojo.fxml;

public class Item {
        private Long id;
        private String name;

        public Item(Long id, String name) {
                this.id = id;
                this.name = name;
        }

        public Long getId() {
                return id;
        }

        public void setId(Long id) {
                this.id = id;
        }

        public String getName() {
                return name;
        }

        public void setName(String name) {
                this.name = name;
        }

        @Override
        public String toString() {
                return "id=" + id + ", name=" + name;
        }
}

Listing 26-9An Item Class That Does Not Have a no-args Constructor

清单 26-10 包含一个 FXML 文件items.fxml的内容。它用Item类的三个对象创建了一个ArrayList。如果您使用FXMLLoader加载这个文件,您会收到一个错误,提示加载程序无法实例化Item类。

<!-- items.fxml -->
<?import com.jdojo.fxml.Item?>
<?import java.util.ArrayList?>
<ArrayList>
        <Item name="Kishori" id="100"/>
        <Item name="Ellen" id="200"/>
        <Item name="Kannan" id="300"/>
</ArrayList>

Listing 26-10FXML to Create a List of Item Objects

让我们创建一个Builder来构建一个Item类的对象。清单 26-11 中的ItemBuilder类是Item类的Builder。它声明了idname实例变量。当FXMLLoader遇到这些属性时,加载程序将调用相应的 setters。setters 将值存储在实例变量中。当加载器需要对象时,它调用build()方法,该方法构建并返回一个Item对象。

// ItemBuilder.java
package com.jdojo.fxml;

import javafx.util.Builder;

public class ItemBuilder implements Builder<Item> {
        private Long id;
        private String name;

        public Long getId() {
                return id;
        }

        public String getName() {
                return name;
        }

        public void setId(Long id) {
                this.id = id;
        }

        public void setName(String name) {
                this.name = name;
        }

        @Override
        public Item build() {
                return new Item(id, name);
        }

}

Listing 26-11A Builder for the Item Class That Uses Property Setters to Build an Object

现在,您需要为Item类型创建一个BuilderFactory。清单 26-12 中显示的ItemBuilderFactory类实现了BuilderFactory接口。当getBuilder()被传递给Item类型时,它返回一个ItemBuilder对象。否则,它返回默认的 JavaFX builder。

// ItemBuilderFactory.java
package com.jdojo.fxml;

import javafx.util.Builder;
import javafx.util.BuilderFactory;
import javafx.fxml.JavaFXBuilderFactory;

public class ItemBuilderFactory implements BuilderFactory {
        private final JavaFXBuilderFactory fxFactory =
              new JavaFXBuilderFactory();

        @Override

        public Builder<?> getBuilder(Class<?> type) {
                // You supply a Builder only for Item type
                if (type == Item.class) {
                        return new ItemBuilder();
                }

                // Let the default Builder do the magic
                return fxFactory.getBuilder(type);
        }
}

Listing 26-12A BuilderFactory to Get a Builder for the Item Type

清单 26-13 和 26-14 有Item类型的BuilderBuilderFactory实现的代码。这次,Builder通过扩展AbstractMap类实现了Map接口。它覆盖了put()方法来读取传入的属性及其值。entrySet()方法需要被覆盖,因为它在AbstractMap类中被定义为抽象的。您没有任何有用的实现。你只是抛出一个运行时异常。build()方法创建并返回一个Item类型的对象。BuilderFactory实现类似于清单 26-12 中的实现,除了它返回一个ItemBuilderMap作为Item类型的Builder

// ItemBuilderFactoryMap.java

package com.jdojo.fxml;

import javafx.fxml.JavaFXBuilderFactory;
import javafx.util.Builder;
import javafx.util.BuilderFactory;

public class ItemBuilderFactoryMap implements BuilderFactory {
        private final JavaFXBuilderFactory fxFactory =
               new JavaFXBuilderFactory();

        @Override
        public Builder<?> getBuilder(Class<?> type) {
                if (type == Item.class) {
                    return new ItemBuilderMap();
                }
                return fxFactory.getBuilder(type);
        }
}

Listing 26-14Another BuilderFactory to Get a Builder for the Item Type

// ItemBuilderMap.java
package com.jdojo.fxml;

import java.util.AbstractMap;
import java.util.Map;
import java.util.Set;
import javafx.util.Builder;

public class ItemBuilderMap extends AbstractMap<String, Object> implements Builder<Item> {
        private String name;
        private Long id;

        @Override

        public Object put(String key, Object value) {
                if ("name".equals(key)) {
                    this.name = (String)value;
                } else if ("id".equals(key)) {
                    this.id = Long.valueOf((String)value);
                } else {
                    throw new IllegalArgumentException(
                               "Unknown Item property: " + key);
                }

                return null;
        }

        @Override
        public Set<Map.Entry<String, Object>> entrySet() {
                throw new UnsupportedOperationException();
        }

        @Override
        public Item build() {
                return new Item(id, name);
        }

}

Listing 26-13A Builder for the Item Class That Implements the Map Interface

让我们为Item类测试两个Builder。清单 26-15 中的程序对Item类使用了两个Builder。它从items.fxml文件中加载Item列表,假设该文件位于resources/fxml目录中。

// BuilderTest.java

package com.jdojo.fxml;

import java.io.IOException;
import java.net.URL;
import com.jdojo.util.ResourceUtil;
import java.util.ArrayList;
import javafx.fxml.FXMLLoader;
import javafx.util.BuilderFactory;

public class BuilderTest {
        public static void main(String[] args) throws IOException {
            // Use the Builder with property getter and setter
            loadItems(new ItemBuilderFactory());

            // Use the Builder with Map
            loadItems(new ItemBuilderFactoryMap());
        }

        public static void
           loadItems(BuilderFactory builderFactory) throws IOException {
                URL fxmlUrl = ResourceUtil.getResourceURL("fxml/items.fxml");

            FXMLLoader loader = new FXMLLoader();
            loader.setLocation(fxmlUrl);
            loader.setBuilderFactory(builderFactory);
            ArrayList items = loader.<ArrayList>load();
            System.out.println("List:" + items);
        }
}

List:[id=100, name=Kishori, id=200, name=Ellen, id=300, name=Kannan]
List:[id=100, name=Kishori, id=200, name=Ellen, id=300, name=Kannan]

Listing 26-15Using Builders to Instantiate Item Objects in FXML

Tip

您提供给FXMLLoaderBuilderFactory将替换默认的BuilderFactory。您需要确保您的BuilderFactory为您的定制类型返回一个特定的Builder,为其余的类型返回默认的Builder。目前,FXMLLoader不允许使用一个以上的BuilderFactory

在 FXML 中创建可重用对象

有时,您需要创建不直接属于对象图的对象。但是,它们可能在 FXML 文档中的其他地方使用。例如,您可能想创建一个InsetsColor并在几个地方重用它们。使用ToggleGroup是一个典型的用例。一个ToggleGroup被创建一次,并与几个RadioButton对象一起使用。

您可以使用<fx:define>块在 FXML 中创建一个对象,而不使其成为对象组的一部分。您可以通过其他元素属性值中的fx:id来引用在<fx:define>块中创建的对象。属性值必须以美元符号($)为前缀:

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Button?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.ToggleGroup?>
<?import javafx.scene.control.RadioButton?>

<VBox fx:controller="com.jdojo.fxml.Test" xmlns:fx="http://javafx.com/fxml">
    <fx:define>
        <Insets fx:id="margin" top="5.0" right="5.0"
                   bottom="5.0" left="5.0"/>
        <ToggleGroup fx:id="genderGroup"/>
    </fx:define>
    <Label text="Gender" VBox.margin="$margin"/>
    <RadioButton text="Male" toggleGroup="$genderGroup"/>
    <RadioButton text="Female" toggleGroup="$genderGroup"/>
    <RadioButton text="Unknown" toggleGroup="$genderGroup" selected="true"/>
    <Button text="Close" VBox.margin="$margin"/>
</VBox>

前面的 FXML 在一个<fx:define>块中创建了两个对象,一个Insets和一个ToggleGroup。他们被赋予了"margin"fx:id"genderGroup"。通过"$margin""$genderGroup"在作为对象图一部分的控件中引用它们。

Tip

如果属性的值以符号开始,它被认为是对对象的引用。如果要使用前导符号开始,它被认为是对对象的引用。如果要使用前导符号作为值的一部分,请用反斜杠("\$hello")对其进行转义。

在属性中指定位置

以@符号开头的属性值表示位置。如果@符号后跟一个正斜杠(@/),则该位置被认为是相对于CLASSPATH的。如果@符号后面没有正斜杠,则该位置被认为是相对于正在处理的 FXML 文件的位置。

在下面的 FXML 中,将根据包含元素的 FXML 文件的位置来解析图像 URL:

<ImageView>
        <Image url="@resources/picture/ksharan.jpg"/>
</ImageView>

在下面的 FXML 中,图像 URL 将相对于CLASSPATH进行解析:

<ImageView>
        <Image url="@/resources/picture/ksharan.jpg"/>
</ImageView>

如果您想使用前导@符号作为属性值的一部分,请用反斜杠("\@not-a-location"对其进行转义。

使用资源包

在 FXML 中使用ResourceBundle比在 Java 代码中使用要容易得多。在属性值中指定来自ResourceBundle的键使用默认Locale的相应值。如果一个属性值以%符号开头,它将被视为资源包中的键名。运行时,属性值将来自FXMLLoader中指定的ResourceBundle。如果您想在属性值中使用前导%符号,请用反斜杠将其转义(例如,"\%hello")。

考虑清单 26-16 中的 FXML 内容。它使用"%greetingText"作为Labeltext属性的值。属性值以%符号开始。FXMLLoader将在ResourceBundle中查找" greetingText "的值,并将其用于text属性。这一切都是为你做的,甚至不用写一行代码!

<?import javafx.scene.control.Label?>
<Label text="%greetingText"/>

Listing 26-16The Contents of the greetings.fxml File

清单 26-17 和 26-18 有ResourceBundle文件的内容:一个默认Locale名为greetings.properties,一个印度Locale名为greetings_hi.properties。文件名中的后缀_hi表示印度语 Hindi。

# The Indian greeting

greetingText = Namaste

Listing 26-18The Contents of the greetings_hi.properties File

# The default greeting
greetingText = Hello

Listing 26-17The Contents of the greetings.properties File

清单 26-19 中的程序使用了带有FXMLLoaderResourceBundleResourceBundle是从CLASSPATHresources/resourcebundles目录中加载的。FXML 文件从类别ResourceUtil中引用的文件夹resources/fxml/greetings.fxml中加载。该程序从 FXML 文件中加载了两次Label:一次是默认的地区 US,另一次是将默认的Locale改为 India Hindi。两个Label都显示在VBox中,如图 26-2 所示。

img/336502_2_En_26_Fig2_HTML.png

图 26-2

使用资源包填充文本属性的标签

// ResourceBundleTest.java
package com.jdojo.fxml;

import java.io.IOException;
import java.net.URL;
import com.jdojo.util.ResourceUtil;
import java.util.Locale;
import java.util.ResourceBundle;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;

import javafx.scene.control.Label;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class ResourceBundleTest extends Application {
        public static void main(String[] args) {
                Application.launch(args);
        }

        @Override
        public void start(Stage stage) throws IOException {
                URL fxmlUrl =
                    ResourceUtil.getResourceURL("fxml/greetings.fxml");

            // Create a ResourceBundle to use in FXMLLoader
            String resourcePath = "resources/resourcebundles/greetings";
            ResourceBundle resourceBundle =
                    ResourceBundle.getBundle(resourcePath);

            // Load the Label for default Locale
            Label defaultGreetingLbl =
                    FXMLLoader.<Label>load(fxmlUrl, resourceBundle);

            // Change the default Locale and load the Label again
            Locale.setDefault(new Locale("hi", "in"));

            // We need to recreate the ResourceBundler to pick up the
                // new default Locale

            resourceBundle = ResourceBundle.getBundle(resourcePath);

            Label indianGreetingLbl =
                    FXMLLoader.<Label>load(fxmlUrl, resourceBundle);

            // Add both Labels to a Vbox
            VBox root =
                    new VBox(5, defaultGreetingLbl, indianGreetingLbl);
            Scene scene = new Scene(root);
            stage.setScene(scene);
            stage.setTitle("Using a ResourceBundle in FXML");
            stage.show();
        }
}

Listing 26-19Using a Resource Bundle with the FXMLLoader

包括 FXML 文件

使用<fx:include>元素,一个 FXML 文档可以包含另一个 FXML 文档。嵌套文档生成的对象图包含在嵌套文档在包含文档中出现的位置。<fx:include>元素接受一个source属性,该属性的值是嵌套文档的路径:

<fx:include source="nested_document_path"/>

如果嵌套文档路径以正斜杠开头,则该路径相对于CLASSPATH被解析。否则,它相对于包含文档的路径进行解析。

<fx:include>元素可以有fx:id属性和所有可用于被包含对象的属性。包含文档中指定的属性会覆盖包含文档中的相应属性。例如,如果您包含一个 FXML 文档,它会创建一个Button,那么您可以在包含文档和被包含文档中指定text属性。加载包含文档时,将使用包含文档的text属性。

FXML 文档可以选择使用根元素的fx:controller属性指定一个控制器。规则是每个 FXML 文档最多可以有一个控制器。嵌套文档时,每个文档都可以有自己的控制器。FXMLLoader允许您将嵌套的控制器引用注入到主文档的控制器中。您需要遵循命名约定来注入嵌套控制器。主文档的控制器应该有一个可访问的实例变量,其名称为

Instance variable name = "fx:id of the fx:include element" + "Controller"

如果<fx:include>元素的fx:id是“xxx”,那么实例变量名应该是xxxController

考虑清单 26-20 和 26-21 中显示的两个 FXML 文档。closebutton.fxml文件创建一个Button,将其文本属性设置为Close,并附加一个动作事件处理程序。事件处理程序使用 JavaScript 语言。它关闭包含它的窗口。

假设两个文件在同一个目录中,maindoc.fxml包括closebutton.fxml。它为<fx:include>元素指定了textfx:id属性。注意,包含的 FXML 指定“Close”作为测试属性,maindoc.fxml覆盖了它并将其设置为“Close”。

<!-- maindoc.fxml -->
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.Label?>

<VBox fx:controller="com.jdojo.fxml.MainDocController" xmlns:fx="http://javafx.com/fxml">
        <Label text="Testing fx:include"/>

        <!-- Override the text property of the included Button -->
        <fx:include source="closebutton.fxml" fx:id="includedCloseBtn"
               text="Hide"/>
</VBox>

Listing 26-21An FXML Document Using an <fx:include> Element

<!-- closebutton.fxml -->
<?language javascript?>
<?import javafx.scene.control.Button?>

<Button fx:controller="com.jdojo.fxml.CloseBtnController"
        text="Close"
        fx:id="closeBtn"
        onAction="#closeWindow"
        xmlns:fx="http://javafx.com/fxml">
</Button>

Listing 26-20An FXML Document That Creates a Close Button to Close the Containing Window

两个 FXML 文档都指定了清单 26-22 和 26-23 中列出的控制器。注意,主文档的控制器声明了两个实例变量:一个将引用被包含的Button,另一个将引用被包含文档的控制器。请注意,Button的引用也将包含在嵌套文档的控制器中。

// MainDocController.java

package com.jdojo.fxml;

import javafx.fxml.FXML;
import javafx.scene.control.Button;

public class MainDocController {
        @FXML
        private Button includedCloseBtn;

        @FXML
        private CloseBtnController includedCloseBtnController;

        @FXML
        public void initialize() {
                System.out.println("MainDocController.initialize()");
                // You can use the nested controller here
        }

}

Listing 26-23The Controller Class for the Main Document

// CloseBtnController.java

package com.jdojo.fxml;

import javafx.fxml.FXML;
import javafx.scene.control.Button;

public class CloseBtnController {
        @FXML
        private Button closeBtn;

        @FXML
        public void initialize() {
                System.out.println("CloseBtnController.initialize()");
        }

}

Listing 26-22The ControllerClass for the FXML Defining the Close Button

清单 26-24 中的程序加载maindoc.fxml并将加载的VBox添加到场景中。它显示一个窗口,带有closebutton.fxml文件中的隐藏按钮。点击Hide按钮将关闭窗口。

// FxIncludeTest.java
package com.jdojo.fxml;

import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
 import com.jdojo.util.ResourceUtil;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;

public class FxIncludeTest  extends Application {
        public static void main(String[] args) {
                Application.launch(args);
        }

        @Override

        public void
           start(Stage stage) throws MalformedURLException, IOException {
                     URL fxmlUrl = ResourceUtil.getResourceURL(
                         "fxml/maindoc.fxml");

                FXMLLoader loader = new FXMLLoader();
                loader.setLocation(fxmlUrl);
                VBox root = loader.<VBox>load();
                Scene scene = new Scene(root);
                stage.setScene(scene);
                stage.setTitle("Nesting Documents in FXML");
                stage.show();
        }
}

Listing 26-24Loading and Using a Nested FXML Document

使用常量

类、接口和枚举可以定义常量,这些常量是静态的最终变量。您可以使用fx:constant属性来引用这些常量。属性值是常量的名称。元素的名称是包含该常量的类型的名称。例如,对于Long.MAX_VALUE,您可以使用以下元素:

<Long fx:constant="MAX_VALUE"/>

注意,所有枚举常量都属于这个类别,可以使用fx:constant属性访问它们。以下元素访问Pos.CENTER枚举常量:

<Pos fx:constant="CENTER"/>

下面的 FXML 内容访问来自IntegerLong类以及Pos枚举的常量。它将VBoxalignment属性设置为Pos.CENTER:

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.TextField?>
<?import java.lang.Integer?>
<?import java.lang.Long?>
<?import javafx.scene.text.FontWeight?>
<?import javafx.geometry.Pos?>

<VBox xmlns:fx="http://javafx.com/fxml">
        <fx:define>
                <Integer fx:constant="MAX_VALUE" fx:id="minInt"/>
        </fx:define>
        <alignment><Pos fx:constant="CENTER"/></alignment>
        <TextField text="$minInt"/>
        <TextField>
                <text><Long fx:constant="MIN_VALUE"/></text>
        </TextField>

</VBox>

引用另一个元素

您可以使用<fx:reference>元素引用文档中的另一个元素。fx:id属性指定了引用元素的fx:id:

<fx:reference source="fx:id of the source element"/>

下面的 FXML 内容使用一个<fx:reference>元素来引用一个Image:

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.image.Image?>
<?import javafx.scene.image.ImageView?>
<VBox xmlns:fx="http://javafx.com/fxml">
        <fx:define>
            <Image url="resources/picture/ksharan.jpg" fx:id="myImg"/>
        </fx:define>
        <ImageView>
             <image>
                <fx:reference source="myImg"/>
            </image>
        </ImageView>

</VBox>

请注意,您也可以使用变量解引用方法重写前面的 FXML 内容,如下所示:

<VBox xmlns:fx="http://javafx.com/fxml">
        <fx:define>
            <Image url="resources/picture/ksharan.jpg" fx:id="myImg"/>
        </fx:define>
        <ImageView image="$myImg"/>
</VBox>

复制元素

有时,您想要复制一个元素。在这个上下文中,复制是通过复制源对象的属性来创建新对象。您可以使用<fx:copy>元素来实现:

<fx:copy source="fx:id of the source object" />

若要复制对象,该类必须提供复制构造器。复制构造器接受同一个类的对象。假设您有一个包含复制构造器的Item类:

public class Item {
        private Long id;
        private String name;

        public Item() {
        }

        // The copy constructor
        public Item(Item source) {
                this.id = source.id + 100;
                this.name = source.name + " (Copied)";
        }
        ...
}

下面的 FXML 文档在<fx:define>块中创建了一个Item对象。它多次复制Item对象,并将它们添加到ComboBox的项目列表中。注意,使用一个<fx:reference>元素将源Item本身添加到条目列表中:

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.ComboBox?>
<?import javafx.collections.FXCollections?>
<?import com.jdojo.fxml.Item?>

<VBox xmlns:fx="http://javafx.com/fxml">
        <fx:define>
                <Item name="Kishori" id="100" fx:id="myItem"/>
        </fx:define>
        <ComboBox value="$myItem">
            <items>
                <FXCollections fx:factory="observableArrayList">
                    <fx:reference source="myItem"/>
                    <fx:copy source="myItem" />
                    <fx:copy source="myItem" />
                    <fx:copy source="myItem" />
                    <fx:copy source="myItem" />
                </FXCollections>
            </items>
        </ComboBox>

</VBox>

FXML 中的绑定属性

FXML 支持简单的属性绑定。您需要使用属性的属性将其绑定到另一个元素或文档变量的属性。属性值以$符号开始,后面跟着一对花括号。以下 FXML 内容创建了一个带有两个TextFieldVBoxmirrorText字段的text属性被绑定到mainText字段的文本属性:

<?import javafx.scene.layout.VBox?>
<?import javafx.scene.control.TextField?>

<VBox xmlns:fx="http://javafx.com/fxml">
        <TextField fx:id="mainText" text="Hello"/>
        <TextField fx:id="mirrorText" text="${mainText.text}" disable="true"/>
</VBox>

创建自定义控件

您可以使用 FXML 创建自定义控件。让我们创建一个带有两个Label、一个TextField、一个PasswordField和两个Button的登录表单。注意,根元素是一个<fx:root>。元素创建了一个对之前创建的元素的引用。使用setRoot()方法在FXMLLoader中设置<fx:root>元素的值。属性指定了将要注入的根的类型。

<!-- login.fxml -->
<?import javafx.scene.layout.GridPane?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.TextField?>
<?import javafx.scene.control.PasswordField?>

<fx:root type="javafx.scene.layout.GridPane"
              xmlns:fx="http://javafx.com/fxml">
        <Label text="User Id:" GridPane.rowIndex="0"
               GridPane.columnIndex="0"/>
        <TextField fx:id="userId" GridPane.rowIndex="0"
               GridPane.columnIndex="1"/>
        <Label text="Password:" GridPane.rowIndex="1"
               GridPane.columnIndex="0"/>
        <PasswordField fx:id="pwd" GridPane.rowIndex="1"
               GridPane.columnIndex="1"/>
        <Button fx:id="okBtn" text="OK" onAction="#okClicked"
               GridPane.rowIndex="0" GridPane.columnIndex="2"/>
        <Button fx:id="cancelBtn" text="Cancel" onAction="#cancelClicked"
               GridPane.rowIndex="1" GridPane.columnIndex="2"/>
</fx:root>

Listing 26-25The FXML Contents for a Custom Login Form

清单 26-26 中的类表示自定义控件的 JavaFX 部分。您将创建一个LogInControl类的对象,并将其用作任何其他标准控件。该类也用作login.fxml的控制器。在构造器中,类加载 FXML 内容。在加载内容之前,它将自己设置为FXMLLoader中的根和控制器。实例变量允许在类中注入userIdpwd控件。当点击Button时,您只需在控制台上打印一条消息。如果您想在实际应用程序中使用这个控件,还需要做更多的工作。当点击 OKCancel 按钮时,您需要为用户提供一种挂钩事件通知的方式。

// LoginControl.java
package com.jdojo.fxml;

import java.io.IOException;
import java.net.URL;
import com.jdojo.util.ResourceUtil;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.control.PasswordField;
import javafx.scene.control.TextField;
import javafx.scene.layout.GridPane;

public class LoginControl extends GridPane {
        @FXML
        private TextField userId;

        @FXML
        private PasswordField pwd;

        public LoginControl() {
                // Load the FXML
                     URL fxmlUrl =
                         ResourceUtil.getResourceURL("fxml/login.fxml");
                FXMLLoader loader = new FXMLLoader();
                loader.setLocation(fxmlUrl);
                loader.setRoot(this);
                loader.setController(this);
                try {
                        loader.load();
                }
                catch (IOException exception) {
                   throw new RuntimeException(exception);
                }
        }

        @FXML
        private void initialize() {
                // Do some work
        }

        @FXML
        private void okClicked() {
                System.out.println("Ok clicked");
        }

        @FXML
        private void cancelClicked() {
            System.out.println("Cancel clicked");
        }

        public String getUserId() {
                return userId.getText();
        }

        public String getPassword() {
                return pwd.getText();
        }
}

Listing 26-26A Class Implementing the Custom Control

清单 26-27 中的程序展示了如何使用自定义控件。使用自定义控件就像创建 Java 对象一样简单。自定义控件扩展了GridPane;所以可以作为一个GridPane。在 FXML 中使用控件与使用其他控件没有什么不同。该控件提供了一个无参数的构造器,这将允许通过使用一个类名为<LoginControl>的元素在 FXML 中创建它。

// LoginTest.java
package com.jdojo.fxml;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.GridPane;
import javafx.stage.Stage;

public class LoginTest extends Application {
        public static void main(String[] args) {
                Application.launch(args);
        }

        @Override
        public void start(Stage stage) {
                // Create the Login custom control
                GridPane root = new LoginControl();
                Scene scene = new Scene(root);
                stage.setScene(scene);
                stage.setTitle("Using FXMl Custom Control");
                stage.show();
        }

}

Listing 26-27Using the Custom Control

摘要

FXML 是一种基于 XML 的语言,用于为 JavaFX 应用程序构建用户界面。您可以使用 FXML 构建整个场景或场景的一部分。FXML 允许应用程序开发人员将构建 UI 的逻辑与业务逻辑分开。如果应用程序的 UI 部分发生了变化,您不需要重新编译 JavaFX 代码:使用文本编辑器更改 FXML 并重新运行应用程序。您仍然使用 JavaFX 通过 Java 语言编写业务逻辑。FXML 文档是 XML 文档。

在 JavaFX 应用程序中使用 FXML 构建场景图是很常见的。然而,FXML 的使用并不仅限于构建场景图。它可以构建 Java 对象的分层对象图。事实上,它只能用来创建一个对象,比如一个Person类的对象。

FXML 文档只是一个文本文件。通常,文件名有一个.fxml扩展名(例如hello.fxml)。您可以使用任何文本编辑器来编辑 FXML 文档。胶子公司提供了一个名为场景构建器的开源可视化编辑器,用于编辑 FXML 文档。场景生成器也可以集成到一些 ide 中。

FXML 允许您使用无参数构造器、valueOf()方法、工厂方法和构建器来创建对象。

有时,您需要创建不直接属于对象图的对象。但是,它们可能在 FXML 文档中的其他地方使用。您可以使用<fx:define>块在 FXML 中创建一个对象,而不使其成为对象组的一部分。您可以通过其他元素属性值中的fx:id来引用在<fx:define>块中创建的对象。属性值必须以美元符号($)为前缀。

FXML 允许您通过指定资源的位置来引用资源。以@符号开头的属性值表示位置。如果@符号后面跟一个正斜杠(@/),则该位置被认为是相对于CLASSPATH的。如果@符号后面没有正斜杠,则该位置被认为是相对于正在处理的 FXML 文件的位置。

在 FXML 中使用ResourceBundle比在 Java 代码中使用要容易得多。在属性值中指定来自ResourceBundle的键使用默认Locale的相应值。如果一个属性值以%符号开头,它将被视为资源包中的键名。运行时,属性值将来自FXMLLoader中指定的ResourceBundle。如果您想在属性值中使用前导%符号,请用反斜杠将其转义(例如,"\%hello")。

使用<fx:include>元素,一个 FXML 文档可以包含另一个 FXML 文档。嵌套文档生成的对象图包含在嵌套文档在包含文档中出现的位置。

类、接口和枚举可以定义常量,这些常量是静态的最终变量。您可以使用fx:constant属性来引用这些常量。属性值是常量的名称。元素的名称是包含该常量的类型的名称。例如,对于Long.MAX_VALUE,可以使用元素<Long fx:constant="MAX_VALUE"/>

您可以使用<fx:reference>元素引用文档中的另一个元素。属性fx:id指定了被引用元素的fx:id。您可以使用<fx:copy>元素复制一个元素。它将通过复制源对象的属性来创建一个新对象。

FXML 支持简单的属性绑定。您需要使用属性的属性将其绑定到另一个元素或文档变量的属性。属性值以$符号开始,后面跟着一对花括号。您可以使用 FXML 创建自定义控件。

下一章将讨论 JavaFX 中的打印 API,它允许您在 JavaFX 应用程序中配置打印机和打印节点。*