JavaFX17-现代-Java-客户端权威指南-全-

JavaFX17 现代 Java 客户端权威指南(全)

原文:The Definitive Guide to Modern Java Clients with JavaFX 17

协议:CC BY-NC-SA 4.0

一、客户端 Java 入门

作者斯蒂芬·陈

客户端技术是构建任何用户交互界面的基础。因为他们是用户看到的应用程序的第一部分,他们也给你的观众留下了最大的影响。因此,重要的是用户界面看起来不错,并且易于使用和直观。

无论是桌面、移动、平板还是嵌入式设备,Java 客户端技术都为构建现代用户体验提供了一个简单而优雅的解决方案。因为 Java 语言是跨平台的,所以这减少了为多屏幕和多种形式构建和维护应用程序的工作量。此外,作为最广泛使用的编程语言之一,任何人都可以帮助维护您的代码,使其成为未来的坚实基础。

在这一章中,我们将展示 Java 客户端技术的一些实例,并指导您构建自己的跨平台客户端,以展示实现这一点是多么容易。

Java 客户端技术的应用

几十年来,Java 客户端技术已经被用于各种应用程序,从商业应用程序到开发工具甚至游戏。此外,现在 Java 运行在移动和嵌入式平台上,您也可以在手机、平板电脑和 Raspberry Pi 设备上找到 Java 应用程序。通常很难判断您是否正在使用 Java 应用程序,因为它与所需的 Java 库打包在一起,所以看起来就像任何其他本机应用程序一样。

我们将探索几个不同的 Java 客户机应用程序,您可能使用过也可能没有使用过,以便让您了解这项技术的潜力。

商业中的 Java 客户端

Java 客户端技术是企业内部应用程序的主要部分。这是因为它非常擅长构建高度定制的应用程序,这些应用程序具有复杂的控件,如图形、树、表格和甘特图。通过一次构建应用程序并利用 Java 的跨平台能力,企业节省了初始实现成本和维护成本。

Java 客户端技术在行业中的常见用例是高速交易、列车监控和调度、供应链管理、医学成像和库存管理。MINT systems 开发了一个培训和资源管理系统(TRMS ),已被众多商业航空公司采用,如阿联酋航空、捷蓝航空、Azul Linhas Aéreas Brasileiras、联邦快递、汉莎航空集团和 Avianca-Taca 集团。 1

img/468104_2_En_1_Fig1_HTML.png

图 1-1

MINT 航空公司培训与资源管理软件系统 2

图 1-1 显示了薄荷 TRMS 的一个更复杂的用户界面屏幕。它利用了树、表、带和甘特图,这些都是使用最新的 Java 客户端技术 JavaFX 实现的。JavaFX 是一个用户界面工具包,它提供了构建现代应用程序所需的所有布局、控件和图表。这展示了一个非常复杂的视图,在任何其他跨平台技术中实现它都是一个挑战。

要了解如何使用预构建的 JavaFX 控件轻松构建复杂应用程序的更多信息,请查看第四章“JavaFX 控件深度剖析”

游戏和 3D

Java 客户端技术也非常适合构建游戏。有史以来最受欢迎的游戏之一是由一个人使用 Java 技术开发的。Markus Persson(又名 Notch)在 2009 年发布了《我的世界》的开发版本。所有最初的开发都是在他的业余时间完成的,直到 alpha 版本赚了足够的钱,他可以创办自己的公司 Mojang,全职专注于游戏。它现在是世界上票房第二高的视频游戏,每月有 9100 万用户。 4

img/468104_2_En_1_Fig2_HTML.jpg

图 1-2

由@tingsterchin 5 创建的来自 Tingsterland 的《我的世界》服务器示例

《我的世界》的成功很大程度上是通过大型的修改社区,他们构建插件来改变游戏的行为并增强游戏,使其远远超出了原始游戏的限制。图 1-2 显示了一个年轻开发人员创建的客户《我的世界》服务器的例子。Java 通过动态类加载和安全的沙箱模型为构建可扩展的应用程序提供了一个很好的平台。全球还拥有 1200 万 Java 开发人员, 6 不乏开发专业知识和人才。

《我的世界》完全是用 Java 构建的,使用了 Swing 和 Java 2D 等客户端技术以及一个名为 LWJGL 的 Java 游戏库。Java 和这些库提供的高度抽象使得 Notch 有可能在短时间内开发《我的世界》,并支持各种平台,而无需庞大的开发团队。

JavaFX 中内置的 3D 支持是一个更容易上手的 3D 库。你可以在第 8“Java FX 3D”一章中找到更多关于 3D 图形的信息。

移动会议应用

Java 客户端技术不仅仅适用于桌面。使用 Gluon 开发的移动 JavaFX 技术, 7 你可以在手机、平板电脑和嵌入式设备上运行你的 Java 客户端,比如 Raspberry Pi。现有的 JavaFX 应用程序可以直接移植到移动设备上,只需对控件的样式稍作修改,就可以在不同的屏幕尺寸上运行。对于处理特定于移动设备的 API,Gluon 提供了 Charm Down,它提供了与硬件特性的跨平台集成。

JavaFX mobile 的一个很好的例子是 Devoxx 会议应用程序。这最初是为旧金山的 JavaOne 大会构建的,并为开源社区做出了贡献。Devoxx 会议选择了它,并做了大量的工作,将其扩展为一个通用的会议应用程序,为每年在世界各地举行的几十个 Devoxx 和 Voxxed 会议提供服务。

img/468104_2_En_1_Fig3_HTML.png

图 1-3

Devoxx 会议移动应用。从左至右:会议选择、演讲人列表、会场导航 8

图 1-3 显示了会议应用程序中的几个不同屏幕,用于选择活动、展示演讲者和导航到会场。Devoxx conference family 的创始人斯蒂芬·让桑(Stephan Schmidt)表示:“JavaFX 移动技术帮助我们将多个原生应用简化为一个在 iOS 和 Android 设备上得到良好支持的跨平台应用。这对与会者来说是更好的体验,也更容易保持更新。”

在本章的后面,我们有一个简单的移动示例来展示使用这项技术有多简单,在第十一章“iOS 和 Android 的本地移动应用”中有更全面的指导

客户端 Java 的现代方法

虽然客户机 Java 技术已经存在了很长时间,但是开发生态系统一直处于不断的变化之中。在移动、云计算和应用程序分发方面已经取得了显著的进步,这些进步影响了您构建和分发客户端应用程序的方式。这本书旨在通过指导你设计和实现最佳实践,使你成为一名成功的现代应用程序开发人员。

我们将在此描述并在本书其余部分强调的三个具体最佳实践如下:

  1. 先瞄准手机。

  2. 为云而构建。

  3. 包装你的平台。

首先锁定手机

自 iPhone 和 Android 分别于 2007 年和 2008 年问世以来,智能手机的使用率一直在稳步上升。如图 1-4 所示,截至 2021 年,移动智能手机和平板电脑的 web 流量已超过桌面,占所有 web 请求的 54.8%。因此,对于成功的应用程序来说,移动不仅仅是一个选项,而是一个必需的界面。

img/468104_2_En_1_Fig4_HTML.png

图 1-4

自 2009 年以来移动使用占全球网络流量的百分比 9

智能手机已经发展到拥有处理能力、内存、存储和分辨率来运行传统上被认为是纯桌面的完整应用程序的地步。在许多使用情况下,带有蓝牙键盘的平板电脑可以很容易地用作台式机的替代品。此外,智能手机和平板电脑都内置无线互联网,这使得即使在没有宽带的情况下也可以使用它们。

因此,越来越多的“智能手机依赖”用户只能通过手机上网,却没有可用于台式机或笔记本电脑连接的宽带。如图 1-5 所示,28%的美国千禧一代(18-29 岁)依赖智能手机。只有当您的应用程序有移动版本时,这些用户才能使用您的应用程序!

img/468104_2_En_1_Fig5_HTML.png

图 1-5

皮尤研究中心 10 按年龄段划分的依赖智能手机的美国公民

如前所述,JavaFX 拥有强大的移动功能,并通过 OpenJDK 的贡献者 Gluon 得到了增强。通过使用 JavaFX mobile,您可以一次编写一个应用程序代码库,然后面向多个屏幕,包括智能手机、平板电脑和桌面。这使您的应用程序比那些不允许用户将工作随身携带的纯桌面应用程序具有巨大的竞争优势。在第十一章“iOS 和 Android 的原生移动应用”中了解更多信息!

为云而构建

应用程序后端的模式已经从内部转移到了云。这是因为最终用户对他们如何与数据交互的期望发生了变化。历史上,用户会在本地拥有和管理他们的数据。随着可用的高速连接、可访问的加密和安全性以及每个用户多个屏幕的出现,这种期望已经改变。现在,用户希望数据始终在线和可用,以便可以从任何设备上使用,并轻松共享和协作。

一个很好的例子是 eteoBoard,这是一个由德国 Saxonia Systems AG 创建的数字协作 scrum 板。它旨在通过创建跨多个位置的扩展项目团队室来解决分布式团队的问题。如图 1-6 所示,这是通过使用大型监视器上的电话会议设备和显示在由 JavaFX 技术驱动的大型触摸屏监视器上的电子项目板来实现的。

img/468104_2_En_1_Fig6_HTML.jpg

图 1-6

用于管理项目积压 11 的电子白板示例

eteoBoard 应用程序使用 SynchronizeFX 12 在多个客户端之间实时同步用户界面状态。所有的项目数据从 Atlassian 吉拉或微软 Team Foundation Server 加载和存储,两者都是基于云的敏捷生命周期管理包,具有 REST 接口。从最终用户的角度来看,所有这些都是透明的,他们可以获得当前项目数据的最新视图,因此他们可以关注团队的进展。

这表明用户期望数据总是在线和可用的,这使得客户端应用程序需要与云后端紧密集成。有关如何在客户端应用程序中利用云的更多信息,请查看第 9“Java FX、Web 和云基础设施”一章中的“为云构建”一节

打包您的平台

台式电脑甚至移动设备已经发展到硬盘和网络传输的问题对于用户体验来说是次要的。如图 1-7 所示,十大移动应用的大小稳步增长,平均超过 1 GB。这意味着像让所有应用程序共享 Java 运行时这样的小优化不值得额外的步骤、复杂性和失败场景。

img/468104_2_En_1_Fig7_HTML.jpg

图 1-7

由 Sensor Tower 13 收集的 2013 年以来十大 iOS 应用的文件大小

不推荐使用像 Applets 和 Web Start 这样的传统技术来分发应用程序。由于它们依赖于共享的 Java 运行时,错误配置的系统很容易导致最终用户无法运行您的应用程序。更糟糕的是,如果不保持更新,这些技术会带来安全问题。因此,这些已被弃用,不应使用。 14

相反,您应该将应用程序需要的所有东西打包成一个发行版。这包括运行应用程序所需的类文件、库,甚至 Java 运行时。虽然这看起来包含了很多内容,但它平均只额外花费 20-30mb,并保证您的用户将拥有一个可用的 Java 运行时,该运行时已经过您正在使用的应用程序的特定版本的测试。

Java 14 重新引入了一个名为 jpackage 的工具,它负责将 Java 运行时与您的应用程序捆绑在一起进行分发。你可以在第十章“打包桌面应用”中找到更多关于这个和其他打包解决方案的信息此外,第十一章“iOS 和 Android 的本地移动应用”以此为基础,介绍了如何打包您的移动应用并在 iOS 和 Android 设备上的应用商店中发布。

设置您的环境

为了开始客户端编程,我们将使用 JavaFX 技术制作一个小的示例应用程序。为此,我们需要一个现代的 Java 版本以及 JavaFX 库。我们的建议是始终使用最新版本的 OpenJDK,因为它提供了最新的安全补丁,并且由 Oracle 免费支持。此外,如果您遵循打包应用程序的最佳实践,那么最终用户安装的 Java 版本并不重要,因为您将把最新的 Java 运行时与您的应用程序捆绑在一起。

可以从 http://jdk.java.net 下载 OpenJDK。只需从图 1-8 所示的页面中选择最新的“准备使用”版本,在撰写本文时,该版本是 Java 开发工具包(JDK) 17。版本每 6 个月增加一次,所以你的版本可能会有所不同。

img/468104_2_En_1_Fig8_HTML.jpg

图 1-8

jdk.java.net 上的 OpenJDK 下载站点

OpenJDK 没有安装程序,但是从命令行安装它非常容易。以下是每个操作系统如何轻松做到这一点的说明。

macOS JDK 安装

打开一个终端,转到下载 OpenJDK 的文件夹。您可以使用以下命令对其进行解压缩:

tar xf openjdk-17_osx-x64_bin.tar.gz

确保用正确的文件名替换您下载的 OpenJDK 版本。然后,您需要将它移动到 JDK 文件夹中,以便它能够被识别:

sudo mv jdk-17.jdk /Library/Java/JavaVirtualMachines/

同样,用正确的文件夹名替换您解压缩的 OpenJDK 版本,并输入您的管理员密码,因为这是一个受保护的文件夹。

最后,为了测试您的新 Java 安装是否被识别,运行java命令:

java -version

对于您安装的 OpenJDK 版本,您应该会看到如下输出:

openjdk version "17" 2021-09-16
OpenJDK Runtime Environment (build 17+??-????)
OpenJDK 64-Bit Server VM (build 17+??-????, mixed mode, sharing)

Windows JDK 安装

Windows JDK 以 zip 文件的形式出现。要安装它,将其解压缩到合适的文件位置,如C:/Program Files/Java/.如果您之前没有安装 JDK,该文件夹可能不存在,但可以用管理员权限创建。对该文件夹的复制操作也需要管理员权限,Windows 会提示您进行确认。

接下来,您需要创建 JAVA_HOME 环境变量,许多工具都希望设置这个变量。为此,请打开“系统属性”对话框,您可以在其中编辑环境变量。该对话框在现代 Windows 操作系统中隐藏得相当好,但是可以通过按下Windows+R并输入sysdm.cpl经由运行对话框可靠地访问,如图 1-9 所示。

img/468104_2_En_1_Fig9_HTML.jpg

图 1-9

使用运行对话框调出系统属性

一旦系统属性对话框打开,选择高级选项卡,该对话框应出现在图 1-10 所示的屏幕上。

img/468104_2_En_1_Fig10_HTML.png

图 1-10

显示“高级”选项卡的“系统属性”对话框

在此选项卡上,单击“环境变量…”按钮。这将弹出如图 1-11 所示的对话框,允许您创建和编辑环境变量。

img/468104_2_En_1_Fig11_HTML.png

图 1-11

环境变量对话框

在环境变量对话框中,我们将创建一个新变量并修改路径。要创建新变量,请单击“系统变量”下的“新建…”按钮将这个变量命名为“JAVA_HOME”,并给它一个值,这个值就是新解压的 OpenJDK 发行版的位置。为了避免输入错误,您可以使用“浏览目录…”按钮来选择您之前创建的文件夹。确切的值将根据您的 JDK 版本而有所不同,但我的是“C:\Program Files\Java\jdk-17”。

接下来,修改Path环境变量以包含对JDK bin 文件夹的引用。为此,选择“Path”变量,可在系统变量下找到,点击“编辑…”将出现如图 1-12 所示的对话框。

img/468104_2_En_1_Fig12_HTML.png

图 1-12

Windows 路径环境变量编辑对话框

点击“新建”按钮,输入“%JAVA_HOME%\bin”作为数值。如果您使用的是早期版本的 Windows,您可能只有一个简单的文本字段,其中各个路径元素用分号分隔。如果是这种情况,只需转到字段的末尾,输入 JDK bin 值,并在前面加一个分号。如果您以前安装过 Java,您可能需要找到它的路径条目并删除它,这样它就不会覆盖您的新安装。完成后,单击“确定”并退出对话框。

现在,您可以通过打开命令提示符并键入 Java version 命令来测试 OpenJDK 安装是否正常工作:

java -version

如果安装正确,您应该得到如下所示的输出,指示您成功安装的 OpenJDK 的版本:

openjdk version "17" 2021-09-16
OpenJDK Runtime Environment (build 17+??-????)
OpenJDK 64-Bit Server VM (build 17+??-????, mixed mode, sharing)

Linux JDK 安装

如果你在 Linux 上,安装 OpenJDK 轻而易举。大多数 Linux 发行版都预装了 OpenJDK,可以通过为您的托管包运行适当的命令来轻松更新它。

以下是针对不同 Linux 发行版的命令示例(根据最新的 OpenJDK 版本进行适当修改):

  • Ubuntu,Debian: sudo apt-get install openjdk-17-jdk

  • Red Hat,Oracle Linux: sudo yum install java-17-openjdk-devel

如果您新安装的 Java 发行版没有被选作缺省版本,您可以使用update-alternatives命令修改缺省的 Java 版本,如果它没有出现在列表中,您可以添加新的 OpenJDK 发行版。

JavaFX 安装

JavaFX 不再作为 OpenJDK 的标准部分,所以必须单独下载和配置。JavaFX SDK 由官方 OpenJFX 贡献者 Gluon 构建和打包。他们的 builds 可以从 https://gluonhq.com/products/javafx/ 下载,如图 1-13 所示。

img/468104_2_En_1_Fig13_HTML.jpg

图 1-13

由 Gluon 提供的 JavaFX 下载站点

为您之前安装的 OpenJDK 版本选择匹配的 JavaFX 版本,在撰写本文时是 JavaFX 17。您可能需要向下滚动“长期支持”构建部分,并在页面的更下方找到“最新版本”。为您选择的平台下载 SDK。

下载完成后,将 JavaFX SDK 解压缩到您选择的目录中。出于本书的目的,我们将假设您将它留在了下载文件夹中,但是您可以随意将它移动到一个更永久的位置。

现在您的 Java 和 JavaFX 安装已经准备好了,您可以创建一个现代化的客户端应用程序了。在下一节中,我们将带您完成第一个客户机应用程序的步骤。

您的第一个现代 Java 客户端

我们将指导您使用 JavaFX 技术创建您的第一个客户端应用程序,Java FX 技术是可用的最现代的用户界面平台。有一套丰富的 Java 开发工具也可以用 JavaFX 构建 ui,所以对于第一个教程,您甚至不需要编写一行代码。然而,这将使您对现代客户端技术的可能性有所了解,并为后续章节中介绍的 UI 概念打下基础。

用 IntelliJ IDEA 编写客户端应用程序

可以用您选择的任何 IDE 编写 JavaFX 应用程序,包括 IntelliJ、NetBeans、Eclipse 或 Visual Studio 代码。然而,我们将使用 IntelliJ Community Edition 介绍客户端开发,这是 Java 编码的行业标准,对客户端开发完全免费。

要下载 IntelliJ 的最新版本,请前往 https://www.jetbrains.com/idea/ 并点击“下载”这将把你带到图 1-14 所示的下载页面,在这里你可以选择免费下载开源社区版。

img/468104_2_En_1_Fig14_HTML.jpg

图 1-14

IntelliJ 社区版下载

下载后,安装 IntelliJ 并启动它。您将看到一些配置 IntelliJ 的选项。你可以随意定制,但默认值应该没问题。创建一个新项目,你会得到如图 1-15 所示的对话框。

img/468104_2_En_1_Fig15_HTML.jpg

图 1-15

IntelliJ 中的新项目创建对话框

它应该会自动获取您之前配置的系统 JDK。在这里,您可以在左侧窗格中选择 JavaFX,它将为您提供一个 Java FX 应用程序模板。点击“下一步”,选择一个项目名称,如“HelloModernWorld”,然后点击“完成”这将在如图 1-16 所示的新窗口中打开您的项目。

img/468104_2_En_1_Fig16_HTML.jpg

图 1-16

你好,现代世界项目已创建

标准项目创建一个最小的 JavaFX 应用程序,其中包含一个主类、一个控制器和一个用于用户界面的 FXML 文件。这个项目还不能运行,因为它找不到我们之前下载的 JavaFX 库,这从红色突出显示中可以明显看出。为了解决这个问题,我们需要在 JavaFX 库上添加一个模块依赖。

在“文件”菜单中,选择“项目结构…”以打开项目设置对话框。在这里,选择左侧的“模块”并选择“依赖关系”选项卡,以进入图 1-17 所示的屏幕。

img/468104_2_En_1_Fig17_HTML.jpg

图 1-17

IntelliJ 中的项目结构对话框

要添加 JavaFX 库,请单击窗格底部的“+”符号并选择“JARs or directories”然后导航到您先前下载并解压缩的 OpenJFX JDK,并选择“lib”文件夹。单击“OK”,这将完成依赖关系,修复语法突出显示。

您可以尝试通过进入“运行”菜单并选择“运行‘Main’”来运行应用程序,但是 JavaFX 的配置并不完整,您仍然会得到如图 1-18 所示的错误。

img/468104_2_En_1_Fig18_HTML.jpg

图 1-18

缺少 JavaFX 运行时组件时出现运行错误

要添加缺少的运行时组件,请打开配置对话框,方法是转到“运行”菜单并选择“编辑配置…”这将打开运行/调试配置对话框,您可以在其中输入传递给 Java 虚拟机的 VM 选项。

要指定要使用的 JavaFX 运行时库,请按如下方式传入 VM 选项:

--module-path /Users/schin/Downloads/javafx-sdk-17/lib --add-modules javafx.controls,javafx.fxml

确保将模块路径修改为用户的正确位置(必须是完全限定的)、JavaFX SDK 的正确版本以及特定于平台的路径分隔符。确保正确的一个简单方法是右键单击该字段并选择“Insert Path ”,这样您将得到一个标准的文件系统选择器,IntelliJ 将为您的操作系统创建正确的路径格式。

还要注意,我们只指定了javafx.controlsjavafx.fxml模块。如果您的应用程序需要额外的模块,请确保在这个逗号分隔的列表中指定它们。然而,如果模块被模块间的依赖关系自动包含,例如所有其他模块都依赖的javafx.base,那么您通常可以排除这些模块。

单击“确定”关闭此对话框并再次运行您的应用程序。这一次,应用程序应该编译和执行无误;然而,由于它只是一个存根,您将得到一个如图 1-19 所示的空窗口。

img/468104_2_En_1_Fig19_HTML.jpg

图 1-19

空 Hello World 应用程序

在下一节中,我们将向您展示如何修改这个应用程序来构建一个快速的 Hello Modern World 应用程序。

使用场景构建器快速开发应用程序

Scene Builder 是快速构建现代客户端应用程序的绝佳工具。当您构建应用程序时,它提供了所见即所得的可视化表示,为布局和控件提供了方便的调色板,为添加到场景图的组件提供了属性编辑器。它还直接操作作为中间格式的 FXML 文件,以声明方式指定用户界面,而不会影响 Java 代码。

IntelliJ 模板已经包含了样板代码,可以根据 FXML 文件创建新的应用程序,因此我们只需在 Scene Builder 中编辑 FXML 文件来修改用户界面。

Scene Builder 也是由 Gluon 构建、打包和分发的,所以我们可以从获得 JavaFX 的同一个网站下载它。进入 https://gluonhq.com/products/scene-builder/ ,你会看到一个类似于图 1-20 的下载页面,在这里你可以获得适合你的操作系统的场景构建器的正确版本。

img/468104_2_En_1_Fig20_HTML.jpg

图 1-20

场景生成器下载页面由 Gluon 提供

安装并运行 Scene Builder 后,将显示一个欢迎对话框,您可以在其中打开现有项目。导航到 HelloModernWorld 项目文件夹,选择位于src/sample目录中的sample.fxml文件。

这将打开基本的场景生成器用户界面,并显示如图 1-21 所示的空项目。

img/468104_2_En_1_Fig21_HTML.png

图 1-21

在场景构建器中打开的示例项目

对于这个例子,我们将添加几个组件来展示 JavaFX 的图像和文本功能。首先,单击左侧窗格中的“Controls”并将一个新的“ImageView”拖动到中间窗格中,以将其添加到场景图中,这将添加图像控件,如图 1-22 所示。

img/468104_2_En_1_Fig22_HTML.png

图 1-22

添加到场景图的 ImageView 控件

作为背景,我们将使用来自美国宇航局的知识共享许可图像,显示空间站上用于测量太空海风的 RapidScat 仪器的一部分。您可以从以下 URL 下载 1024 像素版本:

http://bit.ly/RapidScat1024

将该文件放在与 FXML 文件相同的文件夹中,然后通过更新右窗格中的图像属性在 Scene Builder 中选择它。要打开文件选择对话框,请单击文本输入字段旁边的“…”按钮。

要增加图像的大小,请单击右侧字段中的“布局”并删除“适合宽度”和“适合高度”值。这将改变布局,以自动缩放到我们导入的图像的大小,而不是如图 1-23 所示限制它。

img/468104_2_En_1_Fig23_HTML.png

图 1-23

显示 RapidScat 仪器 15 作为背景的图像视图

接下来,点击左边的“容器”并拖动一个AnchorPane作为左下方“层次”窗格中GridPane的子节点。这将允许我们添加额外的控件,我们可以拖动场景生成器和位置自由。

在 AnchorPane 下,您可以添加一些不同的控件来编写示例应用程序的文本。我推荐Label控件,它可以在左上角窗格的控件类别下找到。为了创建如图 1-24 所示的布局,我添加了三个标签并修改了字体、大小和颜色,如下所示:

img/468104_2_En_1_Fig24_HTML.jpg

图 1-24

Hello Modern World 应用程序上的文本覆盖

  • 你好世界:字体,快递;尺寸,96px;颜色,白色

  • Java:字体,系统;尺寸,77px;颜色,#FFA518

  • FX:字体,系统;尺寸,77px;颜色,#5382A1

最后,为了让文本突出一点,你可以添加一个视觉效果。在“层次”面板中选择 AnchorPane 元素,进入“修改”菜单,选择“设置效果”子菜单。您可以选择不同的效果应用于场景图中的任何元素。选择“绽放”效果,会得到鲜明的视觉风格。

转到“文件”菜单并选择“保存”,保存对 FXML 文件的更改这将自动更新项目中的文件,并允许您立即运行应用程序并查看更改。

切换回 IntelliJ IDEA。在运行项目之前,我们将对 Main.java 类进行一些更新:

  • 删除Scene上的尺寸约束。只需删除构造器中指定固定大小的第二个和第三个参数。

  • 更改窗口标题。只需更新setTitle调用,将窗口命名为“Hello Modern World ”,以匹配项目名称。

更新后的 Main.java 代码如清单 1-1 所示。

public class Main extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception{
        Parent root = FXMLLoader.load(getClass().getResource("sample.fxml"));
        primaryStage.setTitle("Hello Modern World");
        primaryStage.setScene(new Scene(root));
        primaryStage.show();
    }
    public static void main(String[] args) {
        launch(args);
    }
}

Listing 1-1Main class for Hello Modern World

现在尝试再次运行您的项目。通过对 FXML 文件和 Main.java 类的更新,您应该会看到一个现代的 Hello World 示例,如图 1-25 所示。

img/468104_2_En_1_Fig25_HTML.png

图 1-25

已完成 Hello Modern World 应用程序

现代客户端开发之路

本章通过真实世界中使用的应用程序示例,为理解 Java 客户端编程的最新发展奠定了基础,您可能甚至没有意识到这些应用程序是用这种非常强大的编程语言编写的。此外,您还了解了为什么您应该采用移动优先的方法来构建现代客户端应用程序,为云构建并为最终用户打包您的应用程序。您还能够成功地在 JavaFX 中构建您的第一个现代客户端应用程序,Java FX 是 Java 客户端框架的最新版本。

在接下来的章节中,我们将更详细地讨论这些主题,详细阐述如何为多个平台打包您的应用程序,构建您的应用程序以与 REST 和其他云架构集成,并展示 Java 客户端框架的最新功能。我们希望你和我们一样渴望学习如何构建现代化的 ui,来书写和分享这些知识。

二、JavaFX 基础知识

盖尔·安德森和保罗·安德松写的

在您的系统上安装了 Java SDK 和 JavaFX 之后,让我们创建一些应用程序并探索 JavaFX 的基础。首先,我们将描述 JavaFX 应用程序的基本结构,以及使 JavaFX 成为现代客户机的强大选择的一些特性。我们将向您展示如何创建具有吸引力和响应性的 ui。我们将看看 FXML,它是一种基于 XML 的标记语言,允许您定义和配置 UI。我们还将介绍 Scene Builder,这是一个用于设计和配置 JavaFX UI 的独立拖放工具。

为了进一步改进或完全重新设计您的 UI,JavaFX 使用级联样式表(CSS)。我们将向您展示几种在 JavaFX 中使用 CSS 的方法。

JavaFX 属性提供了强大的绑定机制。我们将介绍 JavaFX 属性和绑定。我们将展示为什么 JavaFX observables 和 binding 有助于创建比庞大的侦听器更不容易出错的紧凑代码。我们还将探索几个布局控件,并向您展示将动画融入 UI 是多么容易。

我们将用一个示例应用程序来结束本章,该应用程序使用 JavaFX 集合、一个可编辑的表单和用于典型数据库 CRUD 操作的按钮来实现一个主从 UI。

在这一章中,我们将介绍后续章节更深入讨论的主题。这是为了让您体验 JavaFX 的潜力,并为在本书中进一步探索 JavaFX 提供基础知识。开始吧!

JavaFX 舞台和场景图

JavaFX 应用程序由 JavaFX 平台控制,JavaFX 平台是构建应用程序对象和 Java FX 应用程序线程的运行时系统。要构建 JavaFX 应用程序,必须扩展 JavaFX 应用程序类。JavaFX 运行时系统控制应用程序的生命周期,并调用应用程序的start()方法。

JavaFX 用了一个剧场的比喻:顶层容器就是舞台,由平台为你构建。在桌面应用程序中,舞台就是窗口。它的外观取决于主机系统,并因 macOS、Windows 和 Linux 平台而异。通常,窗口用调整大小、最小化和退出应用程序的控件来修饰。也可以建造无装饰的窗户。您也可以为其他环境专门化应用程序类。例如,使用 Gluon 移动应用框架,您的程序扩展了移动应用,这是一个专门为移动设备编写的应用类。

JavaFX 是单线程的

您必须始终在 JavaFX 应用程序线程上构造和修改舞台及其场景对象。注意 JavaFX(像 Swing 一样)是单线程 UI 模型。对于 JavaFX 开发人员来说,这是一个非常简单的限制。当您创建 UI 元素、响应事件处理程序、使用动画管理动态内容或在场景图中进行更改时,工作会继续在 JavaFX 应用程序线程上执行。

但是,为了保持 UI 的响应性,您应该将长时间运行的工作分配给单独线程中的后台任务。在这种情况下,修改 UI 的工作必须与在后台线程上执行的工作分开。幸运的是,JavaFX 有一个开发良好的并发 API,可以帮助开发人员将长期运行的任务分配给一个或多个单独的线程。这使得 UI 线程能够响应用户事件。这些主题将在第十三章“机器学习和 JavaFX”中探讨。

分层节点结构

继续剧院的比喻,舞台拥有一个场景。场景由 JavaFX 元素组成,比如根元素,它是顶层场景元素,包含所谓的场景图。

场景图是一个严格的层次结构,它由可视化应用程序的元素组成。这些元素被称为节点。一个节点只有一个父节点(根节点除外),并且可以包含其他节点。或者节点可以是没有子节点的叶节点。必须将节点添加到场景图中,以便参与该场景的渲染。此外,一个节点只能添加到一个场景中一次,除非先将其移除,然后再添加到其他地方。

父节点通常通过根据布局规则和您配置的任何约束在场景中排列子节点来管理子节点。JavaFX 对 2D 图形使用二维坐标系,原点在场景的左上角,如图 2-1 所示。x 轴上的坐标值向右增加,y 轴值随着场景向下移动而增加。

img/468104_2_En_2_Fig1_HTML.jpg

图 2-1

JavaFX 2D 坐标系

JavaFX 还支持 3D 图形,并用 z 轴值表示第三维,提供深度。请参见第八章“JavaFX 3D”,以“深入”了解 JavaFX 的三维功能。

除了相对于父对象的局部坐标系之外,JavaFX 还有一个绝对坐标系。在每种情况下,坐标系的原点都是父坐标系的左上角。通常,布局控件隐藏了场景中组件放置的复杂性,并为您管理其子组件的放置。组件放置基于特定的布局控件以及您对它的配置方式。

也可以嵌套布局控件。例如,可以将多个 VBox 控件放在一个 HBox 中,或将一个 AnchorPane 放在 SplitPane 控件的一个窗格中。其他父节点是更复杂的可视节点,如 TextField、TextArea 和 Button。这些节点有受管理的子部分。例如,按钮包括带标签的文本部分和可选图形。此图形可以是任何节点类型,但通常是图像或图标。

回想一下,叶节点没有子节点。示例包括形状(如矩形、椭圆形、直线、路径和文本)和 ImageView,即用于呈现图像的节点。

一个简单的形状示例

图 2-2 显示了一个名为 MyShapes 的简单 JavaFX 应用程序,它在应用程序窗口的中心显示一个椭圆和一个文本元素。此窗口的外观因底层平台而异。调整窗口大小时,可见元素将在调整后的空间中保持居中。尽管这是一个简单的程序,但是关于 JavaFX 呈现、布局特性和节点还有很多东西需要学习。

img/468104_2_En_2_Fig2_HTML.jpg

图 2-2

MyShapes 应用程序

清单 2-1 显示了 MyShapes 程序的源代码。类 MyShapes 是主类,它扩展了应用程序。JavaFX 运行时系统实例化 MyShapes 和初级 Stage,并将其传递给被覆盖的start()方法。运行时系统为您调用start()方法。

package org.modernclient;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Ellipse;
import javafx.scene.text.Font;
import javafx.scene.text.Text;
import javafx.stage.Stage;
public class MyShapes extends Application {
    @Override
    public void start(Stage stage) throws Exception {
        // Create an Ellipse and set fill color
        Ellipse ellipse = new Ellipse(110, 70);
        ellipse.setFill(Color.LIGHTBLUE);
        // Create a Text shape with font and size
        Text text = new Text("My Shapes");
        text.setFont(new Font("Arial Bold", 24));
        StackPane stackPane = new StackPane();
        stackPane.getChildren().addAll(ellipse, text);
        Scene scene = new Scene(stackPane, 350, 230,
                       Color.LIGHTYELLOW);
        stage.setTitle("MyShapes with JavaFX");
        stage.setScene(scene);
        stage.show();
    }
    public static void main(String[] args) {
        launch(args);
    }
}

Listing 2-1MyShapes.java

请注意引用了javafx.applicationjavafx.scenejavafx.stage中的包的导入语句。

Note

确保为任何导入语句指定正确的包。一些 JavaFX 类,比如 Rectangle,与它们的 AWT 或 Swing 对应物具有相同的类名。所有 JavaFX 类都是包javafx的一部分。

这个程序创建几个节点,并将它们添加到 StackPane 布局容器中。该程序还创建场景,配置舞台,并显示舞台。让我们详细看看这些步骤。

首先,我们创建一个椭圆形状,以像素为单位提供宽度和高度。因为椭圆扩展了形状,所以我们也可以配置任何形状属性。这包括“填充”,它允许您指定内部绘制值。

颜色

形状的填充属性可以是 JavaFX 颜色、线性渐变、径向渐变或图像。我们简单讨论一下色彩。您可以用几种方法在 JavaFX 中指定颜色。这里,我们将椭圆填充属性设置为Color.LIGHTBLUE。JavaFX 颜色类中目前有 147 种预定义的颜色,按字母顺序从ALICEBLUEYELLOWGREEN命名。但是,您也可以使用十六进制或十进制的 web RGB 值来指定颜色。您可以选择提供透明度的 alpha 值。完全不透明为 1,完全透明为 0。例如,0.5 的透明度显示了颜色,但也让背景色显示出来。

以下是设定形状填充颜色的几个示例:

ellipse.setFill(Color.LIGHTBLUE);              // Light blue, fully opaque
ellipse.setFill(Color.web("#ADD8E6"));         // Light blue, fully opaque
ellipse.setFill(Color.web("#ADD8E680"));       // Light blue, .5 opaque
ellipse.setFill(Color.web("0xADD8E6"));        // Light blue, fully opaque
ellipse.setFill(Color.web("0xADD8E680"));      // Light blue, .5 opaque
ellipse.setFill(Color.rgb(173, 216, 230));     // Light blue, fully opaque
ellipse.setFill(Color.rgb(173, 216, 230, .5)); // Light blue, .5 opaque

第五章,“掌握视觉和 CSS 设计”,向您展示了使用 CSS 和 JavaFX 指定颜色、渐变和图像的附加选项。

值得注意的是,您可以插入颜色的值,这就是 JavaFX 构建渐变的方式。我们将告诉你如何创建一个线性渐变。

文本是一种形状

接下来,我们创建一个文本对象。文本也是具有附加属性的形状,如字体、文本对齐方式、文本和环绕宽度。构造器提供文本,setFont()方法设置它的字体。

JavaFX 坐标系

注意,我们创建了椭圆和文本节点,但是它们还没有出现在我们的场景图中。在我们将它们添加到场景中之前,我们必须将这些节点放在某种布局容器中。布局控件在管理场景图时非常重要。这些控件不仅可以为您安排组件,还可以响应事件,例如调整大小、添加或移除元素,以及对场景图中一个或多个节点大小的任何更改。

为了向您展示布局控件的重要性,让我们用一个组替换清单 2-1 中的 StackPane,并手动指定位置。组是管理其子节点的父节点,但不提供任何布局功能。这里我们创建一个组,并用构造器添加椭圆和文本元素。然后我们指定组作为场景的根节点:

        Group group = new Group(ellipse, text);
         ...
        Scene scene = new Scene(group, 350, 230, Color.LIGHTYELLOW);

组为其子对象使用默认对齐设置,并将所有内容放置在原点(0,0),即场景的左上角。对于文本,默认位置是文本元素的左下边缘。在这种情况下,唯一可见的部分将是延伸到下边缘下方的字母(“我的形状”的小写字母“y”和“p”)。椭圆将以组原点(0,0)为中心,因此只有右下象限可见。

这种安排显然不是我们想要的。要解决这个问题,让我们手动将 350 × 230 场景中的形状居中,如下所示:

        Group group = new Group(ellipse, text);
        // Manually placing components is tedious and error-prone
        ellipse.setCenterX(175);
        ellipse.setCenterY(115);
        text.setX(175-(text.getLayoutBounds().getWidth()/2));
        text.setY(115+(text.getLayoutBounds().getHeight()/2));
        ...
        Scene scene = new Scene(group, 350, 230, Color.LIGHTYELLOW);

现在,形状将会很好地在场景中居中。但这仍不理想。当窗口调整大小时,这些形状将停留在场景中的这些坐标上(除非您编写代码来检测和响应窗口调整)。你不想这么做。而是使用 JavaFX 布局控件!

布局控件

现在让我们稍微绕道来讨论一些常见的布局控件。要管理场景的节点,可以使用一个或多个控件。每个控件都是为特定的布局配置而设计的。此外,您可以嵌套布局控件来管理节点组,并指定布局应该如何响应事件,如调整托管节点的大小或对其进行更改。您可以指定对齐设置以及边距控制和填充。

有几种方法可以将节点添加到布局容器中。您可以使用布局容器的构造器添加子节点。您还可以将方法getChildren().add()用于单个节点,将方法getChildren().addAll()用于多个节点。此外,一些布局控件有专门的方法来添加节点。现在让我们看看几个常用的布局控件,向您展示 JavaFX 如何为您构建一个场景。

面板

一个方便简单的布局容器是 StackPane,我们在清单 2-1 中使用了它。此布局控件按照您添加节点的顺序从后向前堆叠其子控件。注意,我们首先添加椭圆,使它出现在文本节点的后面。在相反的顺序中,椭圆会遮蔽文本元素。

默认情况下,StackPane 将其所有子节点居中。您可以为子级提供不同的对齐方式,或者将对齐方式应用于 StackPane 中的特定节点。例如,

        // align the text only
        stackPane.setAlignment(text, Pos.BOTTOM_CENTER);

将文本节点沿 StackPane 的下边缘居中。现在,当您调整窗口大小时,椭圆保持居中,文本保持锚定在窗口的下边缘。若要指定所有托管节点与下边缘的对齐方式,请使用

        // align all managed nodes
        stackPane.setAlignment(Pos.BOTTOM_CENTER);

虽然椭圆和文本都出现在窗口的底部,但它们不会相对于彼此居中,因为它们将在各自的底部边缘对齐。

锚板

AnchorPane 根据配置的定位点管理其子节点,即使容器调整大小时也是如此。您可以为组件指定距窗格边缘的偏移。在这里,我们向 AnchorPane 添加一个标签,并以 10 像素的偏移量将其锚定到窗格的左下方:

        AnchorPane anchorPane = new AnchorPane();
        Label label = new Label("My Label");
        anchorPane.getChildren().add(label);
        AnchorPane.setLeftAnchor(label, 10.0);
        AnchorPane.setBottomAnchor(label, 10.0);

AnchorPane 通常用作顶级布局管理器来控制边距,即使在调整窗口大小时也是如此。

组件

GridPane 允许您将子节点放在大小灵活的二维网格中。组件可以跨越行和/或列,但是给定行中所有组件的行大小是一致的。同样,对于给定的列,列的宽度是一致的。GridPane 有专门的方法将节点添加到由列号和行号指定的特定单元格中。可选参数允许您指定列和行的跨度值。例如,这里的第一个标签放在对应于第 0 列和第 0 行的单元格中。第二个标签放在对应于第 1 列和第 0 行的单元格中,它跨越两列(第二列和第三列)。我们还必须提供一个行跨度值(这里设置为 1):

        GridPane gridPane = new GridPane();
        gridPane.add(new Label("Label1"), 0, 0);
        gridPane.add(new Label("Label2 is very long"), 1, 0, 2, 1);

GridPane 对于在容纳各种大小的列或行的表单中布局组件很有用。GridPane 还允许节点跨越多列或多行。我们在我们的主-细节 UI 示例中使用 GridPane(参见本章后面的“将它们放在一起”)。

FlowPane 和 TilePane

FlowPane 以水平流或垂直流的方式管理其子节点。默认方向是水平的。可以用构造器或者使用方法setOrientation()指定流向。这里,我们用构造器指定一个垂直方向:

        FlowPane flowpane = new FlowPane(Orientation.VERTICAL);

FlowPane 根据可配置的边界包装子节点。如果调整包含流窗格的窗格的大小,布局将根据需要调整流。单元的大小取决于节点的大小,除非所有节点的大小都相同,否则它不会是统一的网格。这种布局对于大小可变的节点很方便,例如 ImageView 节点或形状。TilePane 类似于 FlowPane,只是 TilePane 使用大小相等的单元格。

BorderPane

BorderPane 对于具有离散部分的桌面应用程序来说很方便,包括一个顶部工具栏(顶部)、一个底部状态栏(底部)、一个中心工作区(中心)和两个侧边区域(左右)。这五个部分中的任何一个都可以是空的。下面是一个边框窗格的示例,中间是一个矩形,顶部是一个标签:

        BorderPane borderPane = new BorderPane();
        Label colorLabel = new Label("Color: Lightblue");
        colorLabel.setFont(new Font("Verdana", 18));
        borderPane.setTop(colorLabel);
        Rectangle rectangle = new Rectangle(100, 50, Color.LIGHTBLUE);
        borderPane.setCenter(rectangle);
        borderPane.setAlignment(colorLabel, Pos.CENTER);
        borderPane.setMargin(colorLabel, new Insets(20,10,5,10));

请注意,默认情况下,BorderPane 对中心区域使用居中对齐,对顶部使用左对齐。为了保持顶部区域标签居中,我们用Pos.CENTER配置它的对齐方式。我们还用 BorderPane 静态方法setMargin()设置标签周围的边距。Insets 构造器接受四个值,分别对应于上、右、下和左边缘。类似的对齐和边距配置也适用于其他布局组件。

分屏

SplitPane 将布局空间划分为多个水平或垂直配置的区域。分隔线是可移动的,通常在 SplitPane 的每个区域中使用其他布局控件。我们在我们的主-细节 UI 示例中使用 SplitPane(参见本章后面的“将它们放在一起”)。

HBox、VBox 和按钮栏

HBox 和 VBox 布局控件为子节点提供单一水平或垂直位置。您可以将 HBox 节点嵌套在 VBox 中以获得类似网格的效果,或者将 VBox 节点嵌套在 HBox 组件中。ButtonBar 便于在水平容器中放置一排大小相等的按钮。

有关这些和其他布局控件的详细信息,请参见第四章“JavaFX 控件深入研究”

大吵大闹

回到清单 2-1 ,场景持有场景图,由它的根节点定义。首先,我们构建场景并提供stackPane作为根节点。然后,我们以像素为单位指定它的宽度和高度,并为背景提供一个可选的 fill 参数(Color.LIGHTYELLOW)。

剩下的就是配置舞台了。我们提供一个标题,设置场景,展示舞台。JavaFX 运行时渲染我们的场景,如图 2-2 所示。

图 2-3 显示了我们的 MyShapes 应用程序的场景图的层次视图。根节点是 StackPane,它包含两个子节点 Ellipse 和 Text。

img/468104_2_En_2_Fig3_HTML.jpg

图 2-3

MyShapes 场景图

增强 MyShapes 应用程序

与旧的 UI 工具包相比,JavaFX 的优势之一是可以轻松地将效果、渐变和动画应用到场景图中的节点。我们将反复讨论场景图节点的概念,因为这是 JavaFX 运行时高效呈现应用程序可视部分的方式。现在让我们对 MyShapes 进行一些修改,向您展示其中的一些功能。因为 JavaFX 能够插值颜色,所以您可以使用颜色来定义渐变。渐变赋予形状深度,可以是径向的,也可以是线性的。让我们给你看一个线性梯度。

线性梯度

线性渐变需要两种或两种以上的颜色,称为色标。渐变色标由一种颜色和一个介于 0 和 1 之间的偏移量组成。此偏移量指定沿渐变放置颜色的位置。渐变计算从一个色标到下一个色标的比例阴影。

在我们的例子中,我们将使用三个色标:Color.DODGERBLUEColor.LIGHTBLUEColor.GREEN。第一个停靠点的偏移量为 0,第二个偏移量为. 5,第三个偏移量为 1.0,如下所示:

        Stop[] stops = new Stop[] { new Stop(0, Color.DODGERBLUE),
                new Stop(0.5, Color.LIGHTBLUE),
                new Stop(1.0, Color.LIGHTGREEN)};

LinearGradient 构造器指定 x 轴范围,后跟 y 轴范围。下面的线性渐变具有恒定的 x 轴,但其 y 轴是变化的。这被称为垂直梯度。(我们在程序 MyShapes2 中使用这个垂直渐变,如图 2-4 所示。)

        // startX=0, startY=0, endX=0, endY=1
        LinearGradient gradient = new LinearGradient(0, 0, 0, 1, true,
                CycleMethod.NO_CYCLE, stops);

Boolean true 表示渐变贯穿形状(其中 0 和 1 与形状成比例),而NO_CYCLE表示图案不重复。布尔值 false 表示渐变的 x 和 y 值相对于父级的本地坐标系。

要制作水平渐变,请指定 x 轴的范围,并使 y 轴保持不变,如下所示:

        // startX=0, startY=0, endX=1, endY=0
        LinearGradient gradient = new LinearGradient(0, 0, 1, 0, true,
                CycleMethod.NO_CYCLE, stops);

其他组合允许您指定对角线渐变或反向渐变,其中颜色以相反的顺序出现。

阴影

接下来,让我们添加一个投影效果的椭圆。您可以指定投影的颜色、半径以及 x 和 y 偏移。半径越大,阴影越大。偏移量表示相对于形状外边缘的阴影位置。这里,我们指定半径为 30 个像素,在形状的右下方偏移 10 个像素:

        ellipse.setEffect(new DropShadow(30, 10, 10, Color.GRAY));

这些偏移模拟从场景左上角发出的光源。当偏移为 0 时,阴影包围整个形状,就好像光源直接照射在场景上方。

反射

反射效果镜像组件,并淡入透明,这取决于您如何配置其顶部和底部不透明度、分数和偏移。让我们给文本节点添加一个反射效果。我们将使用 0.8 作为分数,这样反射将是反射分量的十分之八。偏移以像素为单位指定反射从下边缘以下多远开始。我们指定 1 个像素(默认值为 0)。倒影从完全不透明(顶部不透明度)开始,并过渡到完全透明(底部不透明度),除非您修改顶部和底部不透明度值:

        Reflection r = new Reflection();
        r.setFraction(.8);
        r.setTopOffset(1.0);
        text.setEffect(r);

图 2-4 显示了在窗口中运行的增强的 MyShapes 程序。您会看到应用于椭圆的线性渐变填充、椭圆上的投影以及应用于文本的反射效果。

img/468104_2_En_2_Fig4_HTML.jpg

图 2-4

增强型 MyShapes 应用程序(MyShapes2)

配置操作

现在是时候让我们的应用程序做点什么了。JavaFX 用鼠标、手势、触摸或按键定义了各种类型的标准输入事件。这些输入事件类型都有特定的处理程序来处理它们。

现在让我们把事情简单化。我们将向您展示如何编写一个事件处理程序来处理单个鼠标点击事件。我们将创建处理程序,并将其附加到场景图中的一个节点上。程序的行为将根据哪个节点获取处理程序而有所不同。我们可以在文本、椭圆或 StackPane 节点上配置鼠标单击处理程序。

下面是将动作事件处理程序添加到文本节点的代码:

        text.setOnMouseClicked(mouseEvent -> {
            System.out.println(mouseEvent.getSource().getClass()
                 + " clicked.");
        });

当用户在文本中单击时,程序显示该行

        class javafx.scene.text.Text clicked.

如果用户在背景区域(堆栈窗格)或椭圆内单击,则不会发生任何事情。如果我们将同一个侦听器附加到椭圆而不是文本,我们会看到这条线

        class javafx.scene.shape.Ellipse clicked.

请注意,因为文本对象出现在堆栈窗格中的椭圆前面,所以单击文本对象不会调用事件处理程序。即使这些场景图节点出现在彼此的顶部,它们在层次中也是单独的节点。也就是说,一个不在另一个里面;相反,它们都是由堆栈窗格管理的不同叶节点。在这种情况下,如果希望两个节点都响应鼠标单击,可以将鼠标事件处理程序附加到两个节点上。或者您可以只将一个事件处理程序附加到 StackPane 节点。然后,在窗口内的任何地方单击鼠标都会触发处理程序,输出如下:

        class javafx.scene.layout.StackPane clicked.

让我们做一些更令人兴奋的事情,将动画应用到 MyShapes 程序中。

动画

当您使用内置的转换 API 时,JavaFX 使动画变得非常容易。每个 JavaFX 转换类型控制一个或多个节点(或形状)属性。例如,FadeTransition 控制节点的不透明度,随时间改变属性。要逐渐淡出某些东西,您可以将其不透明度从完全不透明(1)更改为完全透明(0)。TranslateTransition 通过修改节点的 translateX 和 translateY 属性(如果在 3D 中工作,则为 translateZ)来移动节点。

您可以使用 ParallelTransition 并行播放多个过渡,或者使用 SequentialTransition 顺序播放多个过渡。要控制两个连续转换之间的时序,使用 PauseTransition 或使用转换方法setDelay()配置转换开始前的延迟。您还可以使用 transition action 事件处理程序属性onFinished来定义转换完成时的动作。

过渡从方法play()playFromStart()开始。方法play()在当前时间开始转换;方法playFromStart()总是从时间 0 开始。其他方法还有stop()pause()。您可以用getStatus()查询一个转换的状态,它会返回一个动画。状态枚举值:RUNNINGPAUSEDSTOPPED

所有过渡都支持通用属性durationautoReversecycleCountonFinishedcurrentTime,以及nodeshape(针对特定形状的过渡)。

现在让我们为 MyShapes 程序定义一个 RotateTransition。当用户在窗口内单击时,旋转开始。图 2-5 显示了旋转过渡期间运行的程序。

img/468104_2_En_2_Fig5_HTML.jpg

图 2-5

具有 RotateTransition 的 MyShapes 应用程序(MyShapes2)

清单 2-2 展示了 MyShapes 程序的start()方法中的动画代码。

public class MyShapes extends Application {
    @Override
    public void start(Stage stage) throws Exception {
         ...
        // Define RotateTransition
        RotateTransition rotate = new RotateTransition(
                       Duration.millis(2500), stackPane);
        rotate.setToAngle(360);
        rotate.setFromAngle(0);
        rotate.setInterpolator(Interpolator.LINEAR);
        // configure mouse click handler
        stackPane.setOnMouseClicked(mouseEvent -> {
            if (rotate.getStatus().equals(Animation.Status.RUNNING)) {
                rotate.pause();
            } else {
                rotate.play();
            }
        });
        ...
    }
}

Listing 2-2Using RotateTransition

RotateTransition 构造器指定 2500 毫秒的持续时间,并将转换应用于 StackPane 节点。旋转动画从角度 0 开始,线性地进行到角度 360,提供一次完整的旋转。当用户单击 StackPane 布局控件内的任意位置时,动画开始。

在这个例子中有一些有趣的事情需要注意。首先,因为我们在 StackPane 节点上定义了转换,所以旋转应用于 StackPane 的所有子节点。这意味着不仅椭圆和文本形状会旋转,投影和反射效果也会旋转。

其次,事件处理程序检查转换状态。如果动画正在进行(运行),事件处理程序会暂停过渡。如果它没有运行,它会用play()启动它。因为play()在过渡的当前时间开始,所以pause()后跟play()从暂停的地方恢复过渡。

JavaFX 属性

通过操纵节点的属性来控制节点。JavaFX 属性类似于常规的 JavaBean 属性。它们有 setters 和 getters,通常保存值,并遵循相同的命名约定。但是 JavaFX 属性更强大,因为它们是可观察的。在这一节中,我们将介绍 JavaFX 属性、侦听器和绑定的概念,它们帮助您配置和控制场景图中的节点。

您已经看到了如何通过操纵与节点相关的属性来配置场景图形中的节点。例如,椭圆中的 fill 属性提供了形状的内部颜色。同样,高度和宽度属性定义了椭圆的大小。font 属性定义文本的字体,它的 text 属性保存单词“我的形状”

因为 JavaFX 属性是可观察的,所以您可以定义在属性值更改或无效时得到通知的侦听器。此外,您可以使用内置的绑定机制将一个或多个属性的值链接到另一个属性。您可以指定单向绑定或双向绑定。您甚至可以定义自己的 JavaFX 属性,并将它们作为模型对象或控制对象的一部分包含在您的程序中。

为了使用绑定表达式或将监听器附加到 JavaFX 属性,您必须通过属性的属性 getter 来访问属性。按照惯例,属性 getter 是小写字母的属性名称,后跟大写字母“p”的单词 property。例如,fill 属性的属性 getter 是fillProperty() ,,节点的 opacity 属性的属性 getter 是opacityProperty()。使用任何属性 getter,您都可以访问属性元数据(比如用属性 getter 方法getName()访问它的名称,用属性 getter 方法getValue()访问它的值)。让我们首先向您展示属性侦听器。

属性侦听器

应用于对象属性(不是集合)的 JavaFX 属性侦听器有两种类型:失效侦听器和更改侦听器。当属性值不再有效时,将触发失效侦听器。对于这个例子和后面的例子,我们将讨论 MyShapesProperties 程序,它基于前面的 MyShapes 应用程序。在这个新程序中,我们添加了第二个文本对象,放在旋转 StackPane 下面的 VBox 布局控件中。图 2-6 显示了使用顶级 VBox 更新后的场景图。

img/468104_2_En_2_Fig6_HTML.png

图 2-6

我的形状属性场景图

失效侦听器

失效侦听器有一个方法,您可以用 lambda 表达式覆盖它。让我们先向您展示非 lambda 表达式,这样您就可以看到完整的方法定义。当您单击 StackPane 时,鼠标单击处理程序会像以前一样旋转 StackPane 控件。第二个 Text 对象显示 RotationTransition 动画的状态,该动画由只读 status 属性管理。你会看到RUNNINGPAUSEDSTOPPED。图 2-7 显示动画暂停。

img/468104_2_En_2_Fig7_HTML.jpg

图 2-7

带有失效侦听器的 MyShapesProperties 应用程序

失效侦听器包括一个允许您访问该属性的可观察对象。因为可观察对象是非泛型的,所以必须应用适当的类型转换来访问属性值。这里有一种方法可以在附加到动画 status 属性的监听器中访问该属性的值。注意,我们用属性 getter 方法statusProperty()附加了监听器:

        rotate.statusProperty().addListener(new InvalidationListener() {
            @Override
            public  void invalidated(Observable observable) {
                text2.setText("Animation status: " +
                    ((ObservableObjectValue<Animation.Status>)observable)
                    .getValue());
            }
        });

这里我们用 lambda 表达式实现了同一个监听器:

        rotate.statusProperty().addListener(observable -> {
            text2.setText("Animation status: " +
                ((ObservableObjectValue<Animation.Status>)observable)
                .getValue());
        });

因为我们只访问 status 属性值,所以可以用方法getStatus()绕过 observable,返回一个 enum。这避免了转换表达式:

        rotate.statusProperty().addListener(observable -> {
            text2.setText("Animation status: " + rotate.getStatus());
        });

更改听众

当您需要访问一个可观察对象的前一个值以及它的当前值时,请使用更改侦听器。变更监听器提供可观察值和新旧值。更改监听器的成本可能会更高,因为它们必须跟踪更多的信息。下面是一个非 lambda 版本的更改监听器,它显示旧值和新值。请注意,您不必强制转换这些参数,因为更改侦听器是通用的:

        rotate.statusProperty().addListener(
                    new ChangeListener<Animation.Status>() {
            @Override
            public void changed(
                ObservableValue<? extends Animation.Status> observableValue,
                  Animation.Status oldValue, Animation.Status newValue) {
                    text2.setText("Was " + oldValue + ", Now " + newValue);
                }
        });

下面是具有更紧凑的 lambda 表达式的版本:

        rotate.statusProperty().addListener(
                    (observableValue, oldValue, newValue) -> {
            text2.setText("Was " + oldValue + ", Now " + newValue);
        });

图 2-8 显示了运行的 MyShapesProperties,其中一个更改监听器连接到动画的 status 属性。现在我们可以显示以前和当前的值。

img/468104_2_En_2_Fig8_HTML.jpg

图 2-8

带有更改监听器的 MyShapesProperties 应用程序

有约束力的

JavaFX 绑定是一种灵活的、API 丰富的机制,让您在许多情况下避免编写侦听器。使用绑定将 JavaFX 属性的值链接到一个或多个其他 JavaFX 属性。属性绑定可以是单向或双向的。当属性是同一类型时,单向的bind()方法可能就是您所需要的。然而,当属性具有不同的类型或者您想要基于多个属性计算一个值时,那么您将需要 fluent 和 bindings APIs。您还可以使用自定义绑定创建自己的绑定方法。

单向绑定

最简单的绑定形式是将一个属性的值链接到另一个属性的值。这里,我们将text2的 rotate 属性绑定到stackPane的 rotate 属性:

        text2.rotateProperty().bind(stackPane.rotateProperty());

这意味着对stackPane旋转的任何改变都会立即更新text2的旋转属性。当在 MyShapesProperties 程序中设置此绑定时,StackPane 内的任何单击都会启动旋转过渡。这使得堆叠板和text2组件一起旋转。StackPane 旋转是因为我们启动了为该节点定义的 RotateTransition。由于绑定表达式,text2节点旋转。

请注意,在绑定属性时,除非先解除属性绑定,否则无法显式设置其值。

双向绑定

双向绑定提供了两个属性之间的双向关系。当一个属性更新时,另一个属性也会更新。下面是一个包含两个文本属性的示例:

        text2.textProperty().bindBidirectional(text.textProperty());

两个文本控件最初都显示“我的形状”当用户在stackPane内单击并且stackPane旋转时,由于改变监听器,两个文本属性现在都将包含动画状态。

双向绑定不是完全对称的;两个属性的初始值都采用在对bindBidirectional()的调用中传递的属性值。与bind()不同,在使用双向绑定时,可以显式设置任一属性。

流畅 API 和绑定 API

当不止一个属性需要参与绑定时,或者当需要执行某种计算或转换时,fluent 和 bindings APIs 可以帮助您构造绑定表达式。例如,以下绑定表达式显示 StackPane 从 0 度到 360 度旋转时的旋转角度。text 属性是一个字符串,rotate 属性是一个 double。绑定方法asString()将 double 转换为 string,将数字格式化为小数点右边的一个数字:

        text2.textProperty().bind(stackPane.rotateProperty()
               .asString("%.1f"));

对于一个更复杂的例子,让我们根据动画是否正在运行来更新text2的 stroke 属性(它的颜色)。这里我们基于三元表达式构建了一个与When的绑定。这会在动画运行时将笔划颜色设置为绿色,在动画停止或暂停时设置为红色:

        text2.strokeProperty().bind(new When(rotate.statusProperty()
               .isEqualTo(Animation.Status.RUNNING))
               .then(Color.GREEN).otherwise(Color.RED));

text2 text 属性是在 change listener 中设置的,它附加到我们前面展示的动画状态属性。

图 2-9 显示了复杂绑定表达式附加到text2 strokeProperty的应用程序 MyShapesProperties。由于动画正在运行,stroke 属性被设置为Color.GREEN

img/468104_2_En_2_Fig9_HTML.jpg

图 2-9

带有 fluent 和绑定 API 的 MyShapesProperties 应用程序

有关 JavaFX 属性和绑定的其他示例,请参见第三章“属性和绑定”

使用 FXML

您已经看到了 JavaFX APIs 如何创建场景图节点并为您配置它们。MyShapes 和 MyShapesProperties 程序仅使用 JavaFX 代码来构建和配置这些对象。另一种方法是用 FXML 声明场景图节点,FXML 是一种基于 XML 的标记符号。FXML 允许您以声明的格式描述和配置场景图形。这种方法有几个优点:

  • FXML 标记结构是分层的,因此它反映了场景图的结构。

  • FXML 描述了您的视图,并支持模型-视图-控制器(MVC)架构,为大型应用程序提供了更好的结构。

  • FXML 减少了创建和配置场景图节点所需编写的 JavaFX 代码。

  • 您可以使用 Scene Builder 设计您的用户界面。这个拖放工具是一个独立的应用程序,提供场景的可视化渲染。Scene Builder 会为您生成 FXML 标记。

  • 您还可以使用文本编辑器和 IDE 编辑器编辑 FXML 标记。

FXML 影响你程序的结构。主应用程序类现在调用 FXMLLoader。这个加载器解析您的 FXML 标记,创建 JavaFX 对象,并将场景图插入到根节点的场景中。可以有多个 FXML 文件,通常每个文件都有一个对应的 JavaFX 控制器类。该控制器类可能包括事件处理程序或其他动态更新场景的语句。控制器还包括管理特定视图的业务逻辑。

让我们回到我们的 MyShapes 示例(现在称为 MyShapesFXML ),使用 FXML 文件作为视图,使用 CSS 作为样式。图 2-10 显示了我们程序中的文件,这些文件是为了与构建工具或 ide 一起使用而排列的。

img/468104_2_En_2_Fig10_HTML.jpg

图 2-10

带有 FXML 和 CSS 的 MyShapesFXML

JavaFX 源代码出现在 java 子目录下。资源子目录包含 FXML 和 CSS 文件(这里是 Scene.fxmlStyles.css )。

这个程序包括一个旋转堆栈面板、VBox 控件和第二个文本对象。清单 2-3 显示了描述我们的场景图的 FXML 代码:一个包含 StackPane 和 Text 元素的顶级 VBox。堆叠面板包括椭圆和文本形状。

<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.effect.DropShadow?>
<?import javafx.scene.effect.Reflection?>
<?import javafx.scene.layout.StackPane?>
<?import javafx.scene.layout.VBox?>
<?import javafx.scene.paint.LinearGradient?>
<?import javafx.scene.paint.Stop?>
<?import javafx.scene.shape.Ellipse?>
<?import javafx.scene.text.Font?>
<?import javafx.scene.text.Text?>
<VBox alignment="CENTER" prefHeight="350.0" prefWidth="350.0" spacing="50.0"
 xmlns:="http://javafx.com/javafx/10.0.1" xmlns:fx=http://javafx.com/fxml/1
 fx:controller="org.modernclient.FXMLController">
    <children>
        <StackPane fx:id="stackPane" onMouseClicked="#handleMouseClick"
                               prefHeight="150.0" prefWidth="200.0">
            <children>
                <Ellipse radiusX="110.0" radiusY="70.0">
                    <fill>
                        <LinearGradient endX="0.5" endY="1.0" startX="0.5">
                            <stops>
                                <Stop color="DODGERBLUE" />
                                <Stop color="LIGHTBLUE" offset="0.5" />
                                <Stop color="LIGHTGREEN" offset="1.0" />
                            </stops>
                        </LinearGradient>
                    </fill>
                    <effect>
                        <DropShadow color="GREY" offsetX="5.0"
                                                 offsetY="5.0" />
                    </effect>
                </Ellipse>
                <Text text="My Shapes">
                    <font>
                        <Font name="Arial Bold" size="24.0" />
                    </font>
                    <effect>
                        <Reflection fraction="0.8" topOffset="1.0" />
                    </effect>
                </Text>
            </children>
        </StackPane>
        <Text fx:id="text2" text="Animation Status: ">
            <font>
                <Font name="Arial Bold" size="18.0" />
            </font>
        </Text>
    </children>
</VBox>

Listing 2-3Scene.fxml

顶层容器包括 JavaFX 控制器类的名称和属性fx:controller。VBox 指定其对齐方式、首选大小和间距,后跟其子元素:StackPane 和 Text。在这里,我们用首选的大小配置 StackPane。一个特殊的属性fx:id指定了对应于这个节点的变量名。在 JavaFX 控制器类中,您现在会看到这个变量名用 StackPane 的@FXML进行了注释。这是您访问在 FXML 文件中声明的控制器类中的对象的方式。

此外,StackPane 指定了一个名为#handleMouseClickonMouseClicked事件处理程序。这个事件处理程序在 JavaFX 控制器类中也用@FXML进行了注释。

这里,StackPane 子节点 Ellipse 和 Text 是在子 FXML 节点中声明的。两者都没有关联的fx:id属性,因为控制器类不需要访问这些对象。您还可以看到线性渐变、投影和反射效果配置。

注意,带有fx:idtext2”的文本对象出现在 StackPane 定义之后。这使得第二个文本对象出现在 VBox 的 StackPane 下。我们还指定了一个fx:id属性来从 JavaFX 控制器访问这个节点。

控制器类别

现在让我们向您展示控制器类。您会注意到代码更加紧凑,因为对象实例化和配置代码不再由 Java 语句完成。所有这些现在都在 FXML 标记中指定。清单 2-4 显示了 FXMLController.java 的控制器代码。

package org.modernclient;
import javafx.animation.Animation;
import javafx.animation.Interpolator;
import javafx.animation.RotateTransition;
import javafx.beans.binding.When;
import javafx.fxml.FXML;
import javafx.fxml.Initializable;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.text.Text;
import javafx.util.Duration;
import java.net.URL;
import java.util.ResourceBundle;
public class FXMLController implements Initializable {
    @FXML
    private StackPane stackPane;
    @FXML
    private Text text2;
    private RotateTransition rotate;
    @Override
    public void initialize(URL url, ResourceBundle rb) {
        rotate = new RotateTransition(Duration.millis(2500), stackPane);
        rotate.setToAngle(360);
        rotate.setFromAngle(0);
        rotate.setInterpolator(Interpolator.LINEAR);
        rotate.statusProperty().addListener(
                           (observableValue, oldValue, newValue) -> {
            text2.setText("Was " + oldValue + ", Now " + newValue);
        });
        text2.strokeProperty().bind(new When(rotate.statusProperty()
                 .isEqualTo(Animation.Status.RUNNING))
                 .then(Color.GREEN).otherwise(Color.RED));
    }
    @FXML
    private void handleMouseClick(MouseEvent mouseEvent) {
        if (rotate.getStatus().equals(Animation.Status.RUNNING)) {
            rotate.pause();
        } else {
            rotate.play();
        }
    }
}

Listing 2-4FXMLController.java

控制器类实现 Initializable 并覆盖运行时为您调用的方法initialize(),。重要的是,私有类字段stackPanetext2@FXML标注。@FXML注释将控制器类中的变量名与 FXML 文件中描述的对象相关联。控制器类中没有创建这些对象的代码,因为 FXMLLoader 会为您完成这些工作。

initialize()方法在这里做了三件事。首先,它创建并配置 RotateTransition,并将其应用于stackPane节点。其次,它向转换的 status 属性添加了一个更改侦听器。第三,text2 stroke 属性的绑定表达式根据旋转过渡的状态指定其颜色。

带有handleMouseClick()@FXML注释表示 FXML 文件配置了事件处理程序。此鼠标单击事件处理程序启动和停止旋转过渡的动画。

JavaFX 应用程序类

主应用程序类 MyShapesFXML 现在变得非常简单。它的工作是调用 FXMLLoader,解析 FXML ( Scene.fxml ),构建场景图,并返回场景图根。您所要做的就是像以前一样构建场景对象并配置舞台,如清单 2-5 所示。

package org.modernclient;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import javafx.stage.Stage;
public class MyShapesFXML extends Application {
    @Override
    public void start(Stage stage) throws Exception {
        Parent root = FXMLLoader.load(getClass()
                           .getResource("/fxml/Scene.fxml"));
        Scene scene = new Scene(root, Color.LIGHTYELLOW);
        scene.getStylesheets().add(getClass()
            .getResource("/styles/Styles.css").toExternalForm());
        stage.setTitle("MyShapesApp with JavaFX");
        stage.setScene(scene);
        stage.show();
    }
    public static void main(String[] args) {
        launch(args);
    }
}

Listing 2-5MyShapesFXML.java

添加 CSS

现在让我们向您展示如何将您自己的风格与 CSS 结合起来。JavaFX 的一个优点是它能够用 CSS 样式化节点。JavaFX 附带了一个默认样式表, Modena.css 。您可以扩充这些默认样式,或者用新样式替换它们。我们在文件 Styles.css 中找到的示例 CSS 文件是一个单独的样式类(mytext),它将其字体样式设置为斜体,如清单 2-6 所示。

.mytext {
    -fx-font-style: italic;
}

Listing 2-6Styles.css

要使用这个样式表,您必须首先在应用程序的start()方法或 FXML 文件中加载文件。清单 2-5 展示了如何在 MyShapesFXML.java 加载样式表。一旦文件被添加到可用的样式表中,您就可以将样式类应用到一个节点。例如,要将单独定义的样式类应用于特定节点,请使用

      text2.getStyleClass().add("mytext");

这里,“mytext”是样式类,text2是我们程序中的第二个文本对象。

或者,您可以在 FXML 文件中指定样式表。这种方法的优点是,现在在 Scene Builder 中可以使用样式。下面是修改后的 Scene.fxml 文件,它加载这个定制的 CSS 文件并将定制的 CSS 样式类应用到text2文本节点:

...
<VBox alignment="CENTER" prefHeight="350.0" prefWidth="350.0" spacing="50.0"
         stylesheets="@../styles/Styles.css"
         xmlns:="http://javafx.com/javafx/10.0.1"
         xmlns:fx="http://javafx.com/fxml/1"
         fx:controller="org.modernclient.FXMLController">
    <children>

<StackPane fx:id="stackPane" onMouseClicked="#handleMouseClick" prefHeight="150.0" prefWidth="200.0">
           ... code removed ...
        </StackPane>
        <Text fx:id="text2" styleClass="mytext" text="Animation Status: ">
            <font>
                <Font name="Arial Bold" size="18.0" />
            </font>
        </Text>
    </children>
</VBox>

有关如何在 JavaFX 应用程序中使用 CSS 的深入讨论,请参见第五章“掌握可视化和 CSS 设计”。

使用场景构建器

Scene Builder 最初由 Oracle 开发,现在是开源的。可以从胶子这里下载: https://gluonhq.com/products/scene-builder/ 。Scene Builder 是一个独立的拖放工具,用于创建 JavaFX UIs。图 2-11 显示了主场景构建器窗口,文件 Scene.fxml 来自 MyShapesFXML 程序。

img/468104_2_En_2_Fig11_HTML.png

图 2-11

带有场景构建器的 FXML 文件

左上角的窗口显示了 JavaFX 组件库。这个资源库包括容器、控制、形状、3D 等等。从该窗口中,选择组件并将其放到中间可视视图中的场景上,或者放到左下区域中显示的文档窗口上。

“文档”窗口显示场景图形层次。您可以选择组件并在树中移动它们。右侧窗口是一个检查器窗口,允许您配置每个组件,包括其属性、布局设置和代码。在图 2-11 中,StackPane 在文档层次结构窗口中被选中,并出现在中央可视视图中。在检查器窗口中,OnMouseClicked 属性被设置为#handleMouseClick,这是 JavaFX 控制器类中相应方法的名称。

在构建真实世界中基于表单的 ui 时,Scene Builder 尤其有用。您可以可视化场景层次,并轻松配置布局和对齐设置。

把这一切放在一起

现在是时候构建一个更有趣的 JavaFX 应用程序了,它实现了一个主从视图。当我们向您展示这个应用程序时,我们将解释几个 JavaFX 特性,它们有助于您控制 UI 并保持数据和应用程序的一致性。

首先,我们使用场景构建器来构建和配置 UI。我们的示例包括一个 Person 模型类和一个保存数据的底层 ObservableList。该程序允许用户进行更改,但我们不保存任何数据。JavaFX 有管理数据集合的 ObservableLists,您可以编写侦听器和绑定表达式来响应任何数据更改。该程序使用事件处理程序和绑定表达式的组合来保持应用程序状态的一致性。

主从用户界面

对于 UI,我们在左侧窗口(主视图)中使用一个 JavaFX ListView 控件,在右侧窗口(详细视图)中使用一个表单。在 Scene Builder 中,我们选择一个 AnchorPane 作为顶级组件,并选择场景图根。SplitPane 布局窗格将应用程序视图分为两个部分,每个部分都将 AnchorPane 作为其主容器。图 2-12 显示了正在运行的个人 UI 应用程序。

img/468104_2_En_2_Fig12_HTML.jpg

图 2-12

个人 UI 应用程序

ListView 控件允许您选择人员对象。在这里,第一个人被选中,这个人的详细信息出现在右边的表单控件中。

表单控件具有以下布局:

  • 该表单包含一个 GridPane(两列四行),其中包含 Person 的firstnamelastname字段的文本字段。

  • TextArea 保存 Person 的notes字段。第一列中的标签标记这些控件中的每一个。

  • GridPane 的底行由一个 ButtonBar 组成,它跨越两列,默认情况下在右侧对齐。按钮栏将其所有按钮的大小调整为最宽按钮标签的宽度,以便按钮具有统一的大小。

  • 这些按钮允许您执行新建(创建人员并将该人员添加到列表中)、更新(编辑所选人员)和删除(从列表中删除所选人员)。

  • 绑定表达式查询应用程序的状态,并启用或禁用按钮。图 2-12 显示了在删除按钮启用的情况下,新建和更新按钮被禁用。

图 2-13 显示了我们的人 UI 应用场景图的层次视图。

img/468104_2_En_2_Fig13_HTML.png

图 2-13

人员 UI 场景图层次结构

图 2-14 显示了应用程序的文件结构。Person.java 的包含人模型代码,SampleData.java 的提供初始化应用程序的数据。FXMLController.java是 JavaFX 控制器类,PersonUI.java持有主应用程序类。在资源下,FXML 文件 Scene.fxml 描述了 UI。****

img/468104_2_En_2_Fig14_HTML.jpg

图 2-14

人员 UI 应用程序文件结构

模型

让我们从清单 2-7 中显示的 Person 类开始。这是我们在这个应用程序中使用的“模型”。

我们的 Person 类有三个字段:firstnamelastnamenotes。这些字段被实现为 JavaFX 属性,使它们可以被观察到。我们遵循前面描述的命名约定来实现 getter、setter 和属性 getter。幸运的是,JavaFX 提供了方便的类来帮助您创建属性。这里我们使用SimpleStringProperty()将每个字段构造为 JavaFX 字符串属性。

package org.modernclient.model;
import javafx.beans.Observable;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import javafx.util.Callback;
import java.util.Objects;
public class Person {
    private final StringProperty firstname = new SimpleStringProperty(
           this, "firstname", "");
    private final StringProperty lastname = new SimpleStringProperty(
           this, "lastname", "");
    private final StringProperty notes = new SimpleStringProperty(
           this, "notes", "sample notes");
    public Person() {
    }
    public Person(String firstname, String lastname, String notes) {
        this.firstname.set(firstname);
        this.lastname.set(lastname);
        this.notes.set(notes);
    }
    public String getFirstname() {
        return firstname.get();
    }
    public StringProperty firstnameProperty() {
        return firstname;
    }
    public void setFirstname(String firstname) {
        this.firstname.set(firstname);
    }
    public String getLastname() {
        return lastname.get();
    }
    public StringProperty lastnameProperty() {
        return lastname;
    }
    public void setLastname(String lastname) {
        this.lastname.set(lastname);
    }
    public String getNotes() {
        return notes.get();
    }
    public StringProperty notesProperty() {
        return notes;
    }
    public void setNotes(String notes) {
        this.notes.set(notes);
    }
    @Override
    public String toString() {
        return firstname.get() + " " + lastname.get();
    }
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Person person = (Person) obj;
        return Objects.equals(firstname, person.firstname) &&
                Objects.equals(lastname, person.lastname) &&
                Objects.equals(notes, person.notes);
    }
    @Override
    public int hashCode() {
        return Objects.hash(firstname, lastname, notes);
    }
}

Listing 2-7model.Person.java

可观察列表

使用 JavaFX 集合时,通常会使用 ObservableLists 来检测侦听器的列表变化。此外,显示数据列表的 JavaFX 控件需要可观察的列表。这些控件自动更新 UI 以响应列表修改。当我们带您浏览我们的示例程序时,我们将解释其中的一些复杂性。

实现 ListView 选择

ListView 控件在可观察的列表中显示项目,并允许您选择一个或多个项目。要在右视图的表单字段中显示一个选定的人,您需要为selectedItemProperty使用一个更改监听器。每当用户从 ListView 中选择不同的项目或取消选择选定的项目时,都会调用此更改侦听器。您可以使用鼠标以及箭头键、Home(第一个项目)和 End(最后一个项目)进行选择。在 Mac 上,使用 Fn+左箭头键表示 Home,使用 Fn+右箭头键表示 End。对于取消选择(在 Mac 上是 command+click,在 Linux 或 Windows 上是 Ctrl+click),新值为 null,我们清除所有的表单控件字段。清单 2-8 显示了 ListView 选择更改监听器。

listView.getSelectionModel().selectedItemProperty().addListener(
        personChangeListener = (observable, oldValue, newValue) -> {
            // newValue can be null if nothing is selected
            selectedPerson = newValue;
            modifiedProperty.set(false);
            if (newValue != null) {
                // Populate controls with selected Person
                firstnameTextField.setText(selectedPerson.getFirstname());
                lastnameTextField.setText(selectedPerson.getLastname());
                notesTextArea.setText(selectedPerson.getNotes());
            } else {
                firstnameTextField.setText("");
                lastnameTextField.setText("");
                notesTextArea.setText("");
            }
        });

Listing 2-8ListView selection change listener

布尔属性modifiedProperty跟踪用户是否改变了表单中三个文本控件中的任何一个。我们在每次 ListView 选择后重置该标志,并在绑定表达式中使用该属性来控制更新按钮的 disable 属性。

使用多重选择

默认情况下,ListView 控件实现单项选择,因此最多只能选择一项。ListView 还提供多重选择,您可以通过配置选择模式来启用它,如下所示:

    listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);

有了这个设置,每次用户用Ctrl+clickcommand+click向选择中添加另一个项目时,selectedItemProperty监听器就会被新的选择调用。getSelectedItems()方法返回当前选择的所有项目,newValue参数是最近选择的值。例如,以下更改监听器收集多个选定的项目并打印它们:

listView.getSelectionModel().selectedItemProperty().addListener(
        personChangeListener = (observable, oldValue, newValue) -> {
      ObservableList<Person> selectedItems =
                      listView.getSelectionModel().getSelectedItems();
      // Do something with selectedItems
      System.out.println(selectedItems);
 });

我们的 Person UI 应用程序对 ListView 使用单选模式。

列表视图和排序

假设您想先按姓氏再按名字对姓名列表进行排序。JavaFX 有几种方法对列表进行排序。由于我们需要对名称进行排序,我们将把底层的 ObservableArrayList 包装在一个 SortedList 中。为了在 ListView 中保持列表的排序,我们用排序后的列表调用 ListView 的setItems()方法。比较器指定排序。首先,我们比较每个人的姓氏进行排序,然后根据需要比较名字。为了设置排序,setComparator()方法使用一个匿名类,或者更简洁地说,一个 lambda 表达式:

// Use a sorted list; sort by lastname; then by firstname
SortedList<Person> sortedList = new SortedList(personList);
sortedList.setComparator((p1, p2) -> {
    int result = p1.getLastname().compareToIgnoreCase(p2.getLastname());
    if (result == 0) {
        result = p1.getFirstname().compareToIgnoreCase(p2.getFirstname());
    }
    return result;
});
listView.setItems(sortedList);

注意,比较器参数p1p2被推断为 Person 类型,因为 SortedList 是泛型的。

有关 ListView 控件的更深入的讨论,包括使用单元格和单元格工厂的高级编辑和显示功能,请参见第四章“JavaFX 控件深入探讨”

人员 UI 应用程序操作

我们的 Person UI 应用程序实现了三个操作:删除(从底层列表中删除选定的 Person 对象)、新建(创建一个 Person 对象并将其添加到底层列表中)和更新(对选定的 Person 对象进行更改并更新底层列表)。让我们详细检查一下每个操作,着眼于了解更多关于 JavaFX 特性的知识,这些特性可以帮助您构建这种类型的应用程序。

删除某人

控制器类包括一个删除按钮的动作事件处理程序。下面是定义删除按钮的 FXML 片段:

        <Button fx:id="removeButton" mnemonicParsing="false"
                     onAction="#removeButtonAction" text="Delete" />

fx:id属性命名按钮,以便 JavaFX 控制器类可以访问它。onAction属性对应于控制器代码中的 ActionEvent 处理程序。我们在这个应用程序中没有使用键盘快捷键,所以我们将属性mnemonicParsing设置为 false。

Note

当助记键分析为真时,可以指定一个键盘快捷键来激活带标签的控件,例如 Alt+F 来打开文件菜单。通过在标签中的目标字母前面加上下划线字符来定义键盘快捷键。

您不能直接更新 SortedList,但是可以将更改应用到它的底层列表(ObservableList personList)。每当您添加或删除项目时,SortedList 总是保持其元素排序。

下面是控制器类中的事件处理程序:

        @FXML
        private void removeButtonAction(ActionEvent actionEvent) {
            personList.remove(selectedPerson);
        }

这个处理程序从后台可观察数组列表中删除选中的 Person 对象。ListView 控件的选择改变监听器设置selectedPerson,如清单 2-8 所示。

注意,我们在这里不必检查selectedPerson是否为空。为什么不呢?您将看到,当selectedItemProperty为空时,我们禁用了删除按钮。这意味着当用户取消选择 ListView 控件中的元素时,永远不会调用 Delete 按钮的 action 事件处理程序。下面是控制删除按钮的禁用属性的绑定表达式:

        removeButton.disableProperty().bind(
           listView.getSelectionModel().selectedItemProperty().isNull());

这个优雅的语句使事件处理程序更加紧凑,因此不容易出错。按钮disableProperty和选择模型selectedItemProperty都是 JavaFX 可观察的。因此,您可以在绑定表达式中使用它们。当bind()参数的值改变时,调用bind()的属性自动更新。

添加一个人

New 按钮将一个人添加到列表中,并随后更新 ListView 控件。新项目总是被排序,因为当元素被添加到包装列表时,列表会重新排序。下面是定义新按钮的 FXML。类似于删除按钮,我们定义了fx:idonAction属性:

        <Button fx:id="createButton" mnemonicParsing="false"
                 onAction="#createButtonAction" text="New" />

什么情况下应该禁用新建按钮?

  • 单击“新建”时,不应选择列表视图中的任何项目。因此,如果selectedItemProperty不为空,我们禁用新按钮。请注意,您可以使用 command+单击或 Ctrl+单击来取消选择选定的项目。

  • 如果名字或姓氏字段为空,我们就不应该创建新的人员。因此,如果这两个字段中有一个为空,我们将禁用“新建”按钮。但是,我们允许注释字段为空。

以下是实现这些限制的绑定表达式:

        createButton.disableProperty().bind(
            listView.getSelectionModel().selectedItemProperty().isNotNull()
                .or(firstnameTextField.textProperty().isEmpty()
                .or(lastnameTextField.textProperty().isEmpty())));

现在让我们向您展示新的按钮事件处理程序:

        @FXML
        private void createButtonAction(ActionEvent actionEvent) {
            Person person = new Person(firstnameTextField.getText(),
                    lastnameTextField.getText(), notesTextArea.getText());
            personList.add(person);
            // and select it
            listView.getSelectionModel().select(person);
        }

首先,我们使用表单的文本控件创建一个新的 Person 对象,并将这个人添加到包装列表中(ObservableList personList)。为了让这个人的数据立即可见和可编辑,我们选择了新添加的人。

更新某人

人员的更新不像其他操作那样简单。在我们深入研究原因的细节之前,让我们先来看看 Update 按钮的 FXML 代码,它与其他按钮类似:

        <Button fx:id="updateButton" mnemonicParsing="false"
                 onAction="#updateButtonAction" text="Update" />

默认情况下,排序列表不响应变化的单个数组元素。例如,如果人“Ethan Nieto”更改为“Ethan Abraham”,列表将不会像添加或删除项目时那样重新排序。有两种方法可以解决这个问题。首先是删除该项,然后用新值重新添加。

第二种方法是为底层对象定义一个提取器。提取器定义了发生变化时应该观察的属性。通常,不会观察到对单个列表元素的更改。提取器标志返回的可观察对象更新列表 ChangeListener 中的更改。因此,要使 ListView 控件在更改单个元素后显示正确排序的列表,您需要定义一个带有提取器的 ObservableList。

提取器的好处是您只包括影响排序的属性。在我们的例子中,属性firstnamelastname影响列表的顺序。这些属性应该放在提取器中。

提取器是模型类中的静态回调方法。这是我们的 Person 类的提取器:

    public class Person {
     ...
         public static Callback<Person, Observable[]> extractor =
             p-> new Observable[] {
                p.lastnameProperty(), p.firstnameProperty()
             };
    }

现在控制器类可以使用这个提取器来声明一个名为personList的 ObservableList,如下所示:

    private final ObservableList<Person> personList =
              FXCollections.observableArrayList(Person.extractor);

设置提取器后,排序后的列表会检测到firstnamePropertylastnameProperty的变化,并根据需要重新排序。

接下来,我们定义何时启用更新按钮。在我们的应用程序中,如果没有选择任何项目,或者如果 firstname 或 lastname 文本字段为空,则应该禁用 Update 按钮。最后,如果用户尚未对表单的文本组件进行更改,我们将禁用 Update。我们用一个名为modifiedProperty的 JavaFX 布尔属性跟踪这些变化,这个属性是用 JavaFX 布尔属性助手类 SimpleBooleanProperty 创建的。我们在 JavaFX 控制器类中将该布尔值初始化为 false,如下所示:

    private final BooleanProperty modifiedProperty =
        new SimpleBooleanProperty(false);

我们在 ListView 选择更改监听器中将这个布尔属性重置为 false(清单 2-8 )。当在可以改变的三个字段中的任何一个发生击键时,modifiedProperty被设置为 true:名字、姓氏和注释控件。下面是击键事件处理程序,当在这三个控件的焦点内检测到击键时,将调用该处理程序:

    @FXML
    private void handleKeyAction(KeyEvent keyEvent) {
        modifiedProperty.set(true);
    }

当然,FXML 标记必须为所有三个文本控件配置属性onKeyReleased来调用击键事件处理程序。下面是firstname文本字段的 FXML,它将handleKeyAction事件处理程序链接到该控件的按键释放事件:

    <TextField fx:id="firstnameTextField" onKeyReleased="#handleKeyAction"
        prefWidth="248.0"
        GridPane.columnIndex="1"
        GridPane.hgrow="ALWAYS" />

下面是更新按钮的绑定表达式,如果selectedItemProperty为空、modifiedProperty为假或者文本控件为空,则该表达式被禁用:

    updateButton.disableProperty().bind(
        listView.getSelectionModel().selectedItemProperty().isNull()
            .or(modifiedProperty.not())
            .or(firstnameTextField.textProperty().isEmpty()
            .or(lastnameTextField.textProperty().isEmpty())));

现在让我们向您展示更新按钮的动作事件处理程序。当用户在 ListView 控件中选择一项并对任何文本字段进行至少一次更改后单击 Update 按钮时,将调用此处理程序。

但是还有一件家务要做。在开始用表单控件的值更新所选项之前,我们必须删除selectedItemProperty上的监听器。为什么呢?回想一下,对firstnamelastname属性的更改将动态地影响列表,并可能对其进行重新排序。此外,这可能会改变 ListView 对当前所选项的想法,并调用 ChangeListener。为了防止这种情况,我们在更新过程中删除了侦听器,并在更新完成后重新添加侦听器。在更新过程中,选定的项目保持不变(即使列表重新排序)。因此,我们清除了modifiedProperty标志以确保更新按钮被禁用:

@FXML
private void updateButtonAction(ActionEvent actionEvent) {
    Person p = listView.getSelectionModel().getSelectedItem();
    listView.getSelectionModel().selectedItemProperty()
                 .removeListener(personChangeListener);
    p.setFirstname(firstnameTextField.getText());
    p.setLastname(lastnameTextField.getText());
    p.setNotes(notesTextArea.getText());
    listView.getSelectionModel().selectedItemProperty()
                 .addListener(personChangeListener);
    modifiedProperty.set(false);
}

有记录的人员用户界面

Java 16 中令人兴奋的新特性之一是记录。记录允许您对保存不可变数据和描述状态的类进行建模,通常只需要一行代码。让我们重构 Person UI 示例,将 Java 记录用于 Person 模型类。我们这样做有几个原因。

  • 随着应用程序利用新的 Java 特性,使用 JavaFX 的现代 Java 客户机将继续发展。毕竟,JavaFX 是用 Java APIs 实现的,当然可以利用新特性。

  • 我们的 UI 示例是记录的一个很好的候选,因为使用 Person 记录而不是 class 是一种简单的方法。

  • 我们最初用 JavaFX 属性实现了 Person,这些属性是可观察和可变的。但是,在我们的应用环境中,这种可变性是必要的,甚至是可取的吗?

  • Java 记录有助于提高代码的可读性,因为通常只有一行代码定义了模型类的状态。

个人记录

我们用它的名字和它的不可变组件来声明一个记录;每个组件都有名称和类型。这些组件是生成的类中的最终实例字段。Java 为字段生成访问器方法,为方法equals()hashCode()toString()生成构造器和默认实现。

下面是新的 Person 类,它比清单 2-7 中显示的非记录版本要短得多!

public record Person (String firstname, String lastname, String notes) {
    @Override
    public String toString() {
        return firstname + " " + lastname;
    }
}

注意,我们提供了自己的 toString()实现来替换自动生成的toString(),因为 ListView 使用它来显示每个 Person 对象。生成的访问器方法是firstname()lastname()notes(),以匹配记录头中声明的元素。我们更新了我们的应用程序,使用这些名称来代替传统的 getter 形式。这影响了selectedItemProperty改变监听器和排序列表比较器。

不需要对 createButtonAction 或removeButtonAction事件处理程序进行任何更改。创建我们的人员对象示例列表(SampleData.java)的代码也没有变化。

但是,记录确实需要对 updateButtonAction 事件处理程序进行更改。因为 Person 对象现在是不可变的,所以我们不能更新它的字段。因此,要更新一个人,我们必须创建一个新的人对象,删除旧的人对象,并将新的人对象添加到支持列表中。排序后的列表会自动用新数据更新。下面是新的updateButtonAction事件处理程序:

   @FXML
    private void updateButtonAction(ActionEvent actionEvent) {
        Person person = new Person(firstnameTextField.getText(), lastnameTextField.getText(),
                    notesTextArea.getText());
        personList.remove(listView.getSelectionModel().getSelectedItem());
        personList.add(person);
        listView.getSelectionModel().select(person);
        modifiedProperty.set(false);
    }

通过删除和添加人员,更新过程变得更加简单。不再需要检测更改的提取器,也不需要在更新期间临时删除 selectedItemProperty 更改侦听器。

通过将 Person 限制为不可变的容器,我们极大地简化了 Person 和程序的可读性。然而,JavaFX 属性和绑定仍然是维护 UI 状态的理想特性。

要点总结

这一章涵盖了很多领域。让我们回顾一下要点:

  • JavaFX 是一个现代的 UI 工具包,可以在桌面、移动和嵌入式环境中高效运行。

  • JavaFX 使用了一个剧院的比喻。运行时系统创建主阶段并调用应用程序的start()方法。

  • 创建一个层次场景图,并在场景中安装根节点。

  • JavaFX 运行时系统在 JavaFX 应用程序线程上执行所有 UI 更新和场景图形修改。任何长时间运行的工作都应该被放到单独线程中的后台任务中,以保持 UI 的响应性。JavaFX 有一个开发良好的并发库,可以帮助您将 UI 代码与后台代码分开。

  • JavaFX 支持 2D 和 3D 图形。2D 图形中的原点是场景的左上角。

  • JavaFX 包括一组丰富的布局控件,允许您在场景中排列组件。您可以嵌套布局控件并指定调整大小的标准。

  • JavaFX 将场景图定义为节点的层次集合。节点由其属性来描述。

  • JavaFX 属性是可观察的。您可以附加侦听器,并使用富绑定 API 将属性相互链接,并检测更改。

  • JavaFX 允许您定义称为过渡的高级动画。

  • 场景图的分层性质意味着父节点可以将渲染工作委托给其子节点。

  • JavaFX 支持各种各样的事件,让您对用户输入和场景图的变化做出反应。

  • 虽然可以完全用 Java 编写 JavaFX 应用程序,但更好的方法是用 FXML 编写可视化描述,FXML 是一种用于指定 UI 内容的标记语言。FXML 有助于将可视代码与模型和控制器代码分开。

  • 每个 FXML 文件通常描述一个场景并配置一个控制器。

  • 场景构建器是一个方便的拖放工具,用于定义和配置场景中的组件。

  • 您可以使用在 FXML 或 Java 代码中加载的 CSS 文件自定义 JavaFX 控件和表单的样式。

  • 可观察列表允许您监听列表中的变化。

  • JavaFX 有几个显示数据列表的控件。您可以使用 ObservableLists 配置这些控件,以便控件可以自动检测到更改。

  • 通过在模型类中定义一个提取器,可以对单个元素的变化做出反应。提取器指定应该发出变更事件信号的属性。

  • 用 SortedList 包装 ObservableList 可以保持列表的排序。比较器允许您自定义排序标准。

  • 考虑用 Java 记录实现不可变的模型类。记录是从 Java 16 开始的一个新的 Java 特性。

三、属性和绑定

作者:韦琪高

天道之徒具有生命力和毅力。

与此相对应的

上位者不停地保持自己的生命力。

—易经

前两章向您介绍了客户端 Java 的概况和 JavaFX 的基础知识。在本章中,我们将深入探讨绑定和属性框架,这是 JavaFX 的一部分,它与声明性 UI 语言 FXML 和可视化 UI 设计器场景生成器一起,使 JavaFX 桌面和移动客户端应用程序编写起来优雅而愉快。

javafx.base模块是 JavaFX 属性和绑定框架的主页。它导出以下包:

  • javafx.beans

  • javafx.beans.binding

  • javafx.beans.property

  • javafx.beans.property.adapter

  • javafx.beans.value

  • javafx.collections

  • javafx.collections.transformation

  • javafx.event

  • javafx.util

  • javafx.util.converter

我们将关注javafx.beansjavafx.collections包及其子包。

关键概念

属性和绑定 API 的核心是一组接口,它们赋予我们讨论的两个核心概念以生命:属性绑定。图 3-1 显示了这些接口。

img/468104_2_En_3_Fig1_HTML.jpg

图 3-1

JavaFX 属性和绑定框架的关键接口

可观察和无效监听器

通过Observable接口,您可以将InvalidationListener注册到PropertyBinding,这样当PropertyBinding变为无效时,您将会收到通知。如果调用其set()setValue()方法时使用的值不同于其当前保存的值,则Property会失效。当Bindinginvalidate()方法被调用或其依赖项失效时,该Binding将失效。InvalidationListener中的回调方法具有以下签名,使您可以访问对Observable对象的引用:

void invalidated(Observable observable);

Note

如果连续多次使用相同的值调用 setters,JavaFX 中的属性只会触发一次失效事件。

ObservableValue 和 ChangeListener

ObservableValue接口允许您用PropertyBinding注册ChangeListener s,这样当PropertyBinding的值从一个值变为另一个值时,您会收到通知。通知以回调方法的形式出现在ChangeListener中,带有以下签名,允许您访问其值已更改的属性或绑定的引用,以及旧值和新值:

void changed(ObservableValue<? extends T> observable,
             T oldValue, T newValue)

Note

InvalidationListenerChangeListener的弱版本,以及本章后面介绍的一些其他监听器,有助于避免内存泄漏。

可写值和只读属性

WritableValue接口向一个Property提供了setValue()方法。ReadOnlyProperty接口向一个Property注入两个方法:一个getBean()方法返回属性的持有者,一个getName()方法返回属性的描述性名称。如果属性不是更大对象的一部分,或者描述性名称不重要,这两种方法都可能返回 null。

JavaFX 属性

随着所有预备工作的结束,我们终于可以看看Property界面了。它提供了五种方法:

void bind(ObservableValue<? extends T> observable);
void unbind();
boolean isBound();
void bindBidirectional(Property<T> other);
void unbindBidirectional(Property<T> other);

bind()方法在PropertyObservableValue之间建立了一个单向绑定unbind()方法释放绑定。并且isBound()方法报告单向绑定是否有效。一旦生效,单向绑定将建立前者对后者的依赖关系Property上的set()setValue()方法会抛出一个RuntimeExceptionget()getValue()方法会返回ObservableValue的值。

bindBidirectional()方法在两个Property对象之间建立了一个双向绑定unbindBidirectional()方法释放它。一旦生效,在任一属性上调用set()setValue()将导致两个对象的值都被更新。

Caution

每个Property一次最多可以有一个活动的单向绑定。它可以有任意多的双向绑定。isBound()方法只适用于单向绑定。用不同的ObservableValue第二次调用bind()会解除之前的绑定并用新的替换它。

总结一下我们到目前为止所讨论的内容,我们看到一个Property可以保存一个值,当它的值改变时可以通知其他人,并且可以绑定到其他人以反映绑定对象的值。清单 3-1 展示了一个运行这些功能的愚蠢程序。

package org.modernclients.propertiesandbindings;
import javafx.beans.InvalidationListener;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.value.ChangeListener;
public class PropertiesExample {
    private static IntegerProperty i1;
    public static void main(String[] args) {
        createProperty();
        addAndRemoveInvalidationListener();
        addAndRemoveChangeListener();
        bindAndUnbindOnePropertyToAnother();
    }
    private static void createProperty() {
        System.out.println();
        i1 = new SimpleIntegerProperty(1024);
        System.out.println("i1 = " + i1);
        System.out.println("i1.get() = " + i1.get());
        System.out.println("i1.getValue() = "
                + i1.getValue());
    }
    private static void addAndRemoveInvalidationListener() {
        System.out.println();
        final InvalidationListener invalidationListener =
                observable -> {
                    System.out.println(
                            "The observable has been " +
                                    "invalidated: " +
                                    observable + ".");
                };
        i1.addListener(invalidationListener);
        System.out.println("Added invalidation listener.");
        System.out.println("Calling i1.set(2048).");
        i1.set(2048);
        System.out.println("Calling i1.setValue(3072).");
        i1.setValue(3072);
        i1.removeListener(invalidationListener);
        System.out.println("Removed invalidation listener.");
        System.out.println("Calling i1.set(4096).");
        i1.set(4096);
    }
    private static void addAndRemoveChangeListener() {
        System.out.println();
        final ChangeListener<Number> changeListener =
                (observableValue,
                 oldValue,
                 newValue) -> {
                    System.out.println(
                            "The observableValue has " +
                                    "changed: oldValue = " +
                                    oldValue +
                                    ", newValue = " +
                                    newValue);
                };
        i1.addListener(changeListener);
        System.out.println("Added change listener.");
        System.out.println("Calling i1.set(5120).");
        i1.set(5120);
        i1.removeListener(changeListener);
        System.out.println("Removed change listener.");
        System.out.println("Calling i1.set(6144).");
        i1.set(6144);
    }

    private static void bindAndUnbindOnePropertyToAnother() {
        System.out.println();
        IntegerProperty i2 = new SimpleIntegerProperty(0);
        System.out.println("i2.get() = " + i2.get());
        System.out.println("Binding i2 to i1.");
        i2.bind(i1);
        System.out.println("i2.get() = " + i2.get());
        System.out.println("Calling i1.set(7168).");
        i1.set(7168);
        System.out.println("i2.get() = " + i2.get());
        System.out.println("Unbinding i2 from i1.");
        i2.unbind();
        System.out.println("i2.get() = " + i2.get());
        System.out.println("Calling i1.set(8192).");
        i1.set(8192);
        System.out.println("i2.get() = " + i2.get());
    }
}

Listing 3-1PropertiesExample.java

Note

本节的源代码可以在本书随附的源代码包的第三章中找到。它被组织成一个带有子项目的 Gradle 项目,每个子项目对应一个示例。

这个程序是不言自明的,你几乎可以在脑海中想象它是如何执行的。我们在程序中使用了一个抽象类IntegerProperty及其具体实现SimpleIntegerProperty。它保存一个原始的int值。

清单 3-2 展示了工作中的双向绑定。

package org.modernclients.propertiesandbindings;

import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
public class BidirectionalBindingExample {
    public static void main(String[] args) {
        System.out.println("Constructing two StringProperty" +
                " objects.");
        StringProperty prop1 = new SimpleStringProperty("");
        StringProperty prop2 = new SimpleStringProperty("");
        System.out.println("Calling bindBidirectional.");
        prop2.bindBidirectional(prop1);
        System.out.println("prop1.isBound() = " +
                prop1.isBound());
        System.out.println("prop2.isBound() = " +
                prop2.isBound());
        System.out.println("Calling prop1.set(\"prop1" +
                " says: Hi!\")");
        prop1.set("prop1 says: Hi!");
        System.out.println("prop2.get() returned:");
        System.out.println(prop2.get());
        System.out.println("Calling prop2.set(prop2.get()" +
                " + \"\\nprop2 says: Bye!\")");
        prop2.set(prop2.get() + "\nprop2 says: Bye!");
        System.out.println("prop1.get() returned:");
        System.out.println(prop1.get());
    }
}

Listing 3-2BidirectionalBindingExample.java

创建绑定

在上一节中,我们探讨了 JavaFX 属性和绑定框架的关键接口。我们还学习了关于Property物体的基础知识。在这一节中,我们拿起框架的另一半并检查Binding s。

JavaFX 绑定

Binding接口提供了四种方法:

boolean isValid();
void invalidate();
ObservableList<?> getDependencies();
void dispose();

一个Binding有效性可以用isValid()方法查询,用invalidate()方法设置。它有一个可以通过getDependencies()方法获得的依赖项的列表。最后,dispose()方法发出信号表示Binding将不再被使用,它所使用的资源可以被清理。

因此,Binding表示具有多个依赖关系的单向绑定。每个依赖都可以向Binding发送失效事件,使其失效。当通过get()getValue()调用查询Binding的值时,如果它被无效,它的值将根据依赖关系的值重新计算。该值将被缓存并用于后续的值查询,直到Binding再次失效。这种懒惰的值评估是 JavaFX 属性和绑定框架高效的原因。附加一个ChangeListener强制急切评估。

由于一个绑定可以用作另一个绑定的依赖项,因此可以构建复杂的绑定树。这是 JavaFX 属性和绑定框架强大功能的另一个来源。

Caution

与任何复杂的结构一样,必须小心避免性能下降和行为错误,尤其是在高负载的情况下。

与属性不同,框架不提供具体的绑定类。因此,所有绑定都是自定义绑定,有几种方法可以创建它们:

  • 扩展一个抽象基类,比如DoubleBinding

  • 在实用程序类Bindings中使用工厂方法

  • 在属性和绑定类中使用 fluent API 方法

通过直接扩展创建绑定

清单 3-3 展示了一个程序,它通过直接扩展DoubleBinding来创建一个绑定,并使用它来计算矩形的面积。

package org.modernclients.propertiesandbindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
public class DirectExtensionExample {
    public static void main(String[] args) {
        System.out.println("Constructing x with value 2.0.");
        final DoubleProperty x =
                new SimpleDoubleProperty(null, "x", 2.0);
        System.out.println("Constructing y with value 3.0.");
        final DoubleProperty y =
                new SimpleDoubleProperty(null, "y", 3.0);
        System.out.println("Creating binding area" +
                " with dependencies x and y.");
        DoubleBinding area = new DoubleBinding() {
            {
                super.bind(x, y);
            }
            @Override
            protected double computeValue() {
                System.out.println("computeValue()" +
                        " is called.");
                return x.get() * y.get();
            }
        };
        System.out.println("area.get() = " + area.get());
        System.out.println("area.get() = " + area.get());
        System.out.println("Setting x to 5");
        x.set(5);
        System.out.println("Setting y to 7");
        y.set(7);
        System.out.println("area.get() = " + area.get());
    }
}

Listing 3-3DirectExtensionExample.java

这里,我们通过覆盖其唯一的抽象方法computeValue()来扩展DoubleBinding类,以计算边长为xy的矩形的面积。我们还调用超类的bind()方法来使属性xy成为我们的依赖。

运行该程序会将以下内容打印到控制台:

Constructing x with value 2.0.
Constructing y with value 3.0.
Creating binding area with dependencies x and y.
computeValue() is called.
area.get() = 6.0
area.get() = 6.0
Setting x to 5
Setting y to 7
computeValue() is called.
area.get() = 35.0

注意,当我们连续两次调用area.get()时,computeValue()只被调用一次。

特定于类型的专门化

在我们进入下一个创建绑定的方法之前,我们需要向您提供一些关于键接口的一般性质及其特定于类型的专门化的细节。

本章前面的例子包括像IntegerPropertyStringPropertyDoubleBinding这样的类。这些是通用类型Property<T>Bindings<T>的专门类。由于 Java 的原始类型和引用类型二分法,直接使用泛型类型,比如Property<Integer>,同时处理原始值会导致装箱和拆箱效率低下。为了减少这种成本,泛型类型的特定于类型的专门化被构造为基元booleanintlongfloatdouble值,以这样的方式,当它们的get()set()方法被调用时,以及当进行内部计算时,基元类型从不被装箱和取消装箱。出于一致性原因,也为StringObject引用类型构建了类似的专门化。这说明了BooleanPropertyIntegerPropertyLongPropertyFloatPropertyDoublePropertyStringPropertyObjectProperty类的存在。

Caution

不要被名称IntegerProperty所迷惑,以为它是一个Integer对象的容器。真的不是。它是原始int价值观的容器。其他基于原语的类也是如此。

这些特定于类型的专门化的另一个方面是使用Number作为类型参数来派生数字原语类型的专门化。一个实际的结果是,任何数字属性都可以在任何其他数字属性或绑定上调用bind()。清单 3-4 显示了一个说明这一点的程序。

package org.modernclients.propertiesandbindings;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.FloatProperty;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.LongProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.beans.property.SimpleFloatProperty;
import javafx.beans.property.SimpleIntegerProperty;
import javafx.beans.property.SimpleLongProperty;
public class NumericPropertiesExample {
    public static void main(String[] args) {
        IntegerProperty i =
                new SimpleIntegerProperty(null, "i", 1024);
        LongProperty l =
                new SimpleLongProperty(null, "l", 0L);
        FloatProperty f =
                new SimpleFloatProperty(null, "f", 0.0F);
        DoubleProperty d =
                new SimpleDoubleProperty(null, "d", 0.0);
        System.out.println("Constructed numerical" +
                " properties i, l, f, d.");
        System.out.println("i.get() = " + i.get());
        System.out.println("l.get() = " + l.get());
        System.out.println("f.get() = " + f.get());
        System.out.println("d.get() = " + d.get());
        l.bind(i);
        f.bind(l);
        d.bind(f);
        System.out.println("Bound l to i, f to l, d to f.");
        System.out.println("i.get() = " + i.get());
        System.out.println("l.get() = " + l.get());
        System.out.println("f.get() = " + f.get());
        System.out.println("d.get() = " + d.get());
        System.out.println("Calling i.set(2048).");
        i.set(2048);
        System.out.println("i.get() = " + i.get());
        System.out.println("l.get() = " + l.get());
        System.out.println("f.get() = " + f.get());
        System.out.println("d.get() = " + d.get());
        d.unbind();
        f.unbind();
        l.unbind();
        System.out.println("Unbound l to i, f to l, d to f.");
        f.bind(d);
        l.bind(f);
        i.bind(l);
        System.out.println("Bound f to d, l to f, i to l.");
        System.out.println("Calling d.set(10000000000L).");
        d.set(10000000000L);
        System.out.println("d.get() = " + d.get());
        System.out.println("f.get() = " + f.get());
        System.out.println("l.get() = " + l.get());
        System.out.println("i.get() = " + i.get());
    }
}

Listing 3-4NumericPropertiesExample.java

运行此应用程序会产生以下输出:

Constructed numerical properties i, l, f, d.
i.get() = 1024
l.get() = 0
f.get() = 0.0
d.get() = 0.0
Bound l to i, f to l, d to f.
i.get() = 1024
l.get() = 1024
f.get() = 1024.0
d.get() = 1024.0
Calling i.set(2048).
i.get() = 2048
l.get() = 2048
f.get() = 2048.0
d.get() = 2048.0
Unbound l to i, f to l, d to f.
Bound f to d, l to f, i to l.
Calling d.set(10000000000L).
d.get() = 1.0E10
f.get() = 1.0E10
l.get() = 10000000000
i.get() = 1410065408

绑定中的工厂方法

Bindings类包含 200 多个工厂方法,这些方法用现有的可观察值和常规值进行新的绑定。这些方法被重载以考虑参数类型的无数组合。

add()subtract()multiply()divide()方法显而易见,用两个数值创建一个新的数值绑定,其中至少有一个是可观察的值。清单 3-5 中的程序演示了它们的用法。它使用以下公式计算带有顶点(x1y1x2y2x3y3)的三角形的面积

area = (x1*y2 + x2*y3 + x3*y1 – x1*y3 – x2*y1 – x3*y2)/2

package org.modernclients.propertiesandbindings;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.NumberBinding;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
public class TriangleAreaExample {
    public static void main(String[] args) {
        IntegerProperty x1 = new SimpleIntegerProperty(0);
        IntegerProperty y1 = new SimpleIntegerProperty(0);
        IntegerProperty x2 = new SimpleIntegerProperty(0);
        IntegerProperty y2 = new SimpleIntegerProperty(0);
        IntegerProperty x3 = new SimpleIntegerProperty(0);
        IntegerProperty y3 = new SimpleIntegerProperty(0);
        final NumberBinding x1y2 = Bindings.multiply(x1, y2);
        final NumberBinding x2y3 = Bindings.multiply(x2, y3);
        final NumberBinding x3y1 = Bindings.multiply(x3, y1);
        final NumberBinding x1y3 = Bindings.multiply(x1, y3);
        final NumberBinding x2y1 = Bindings.multiply(x2, y1);
        final NumberBinding x3y2 = Bindings.multiply(x3, y2);
        final NumberBinding sum1 = Bindings.add(x1y2, x2y3);
        final NumberBinding sum2 = Bindings.add(sum1, x3y1);
        final NumberBinding diff1 =
                Bindings.subtract(sum2, x1y3);
        final NumberBinding diff2 =
                Bindings.subtract(diff1, x2y1);
        final NumberBinding determinant =
                Bindings.subtract(diff2, x3y2);
        final NumberBinding area =
                Bindings.divide(determinant, 2.0D);
        x1.set(0); y1.set(0);
        x2.set(6); y2.set(0);
        x3.set(4); y3.set(3);
        printResult(x1, y1, x2, y2, x3, y3, area);
        x1.set(1); y1.set(0);
        x2.set(2); y2.set(2);
        x3.set(0); y3.set(1);
        printResult(x1, y1, x2, y2, x3, y3, area);
    }
    private static void printResult(IntegerProperty x1,
                                    IntegerProperty y1,
                                    IntegerProperty x2,
                                    IntegerProperty y2,
                                    IntegerProperty x3,
                                    IntegerProperty y3,
                                    NumberBinding area) {
        System.out.println("For A(" +
                x1.get() + "," + y1.get() + "), B(" +
                x2.get() + "," + y2.get() + "), C(" +
                x3.get() + "," + y3.get() +
                "), the area of triangle ABC is " +
                area.getValue());
    }
}

Listing 3-5TriangleAreaExample.java

运行该程序会将以下内容打印到控制台:

For A(0,0), B(6,0), C(4,3), the area of triangle ABC is 9.0
For A(1,0), B(2,2), C(0,1), the area of triangle ABC is 1.5

Bindings中的其他工厂方法包括逻辑运算符and()or()not();数字运算符min()max()negate();空测试操作符isNull()isNotNull();字符串运算符length()isEmpty()isNotEmpty();以及关系运算符equal()equalIgnoreCase()greaterThan()graterThanOrEqual()lessThan()lessThanOrEqual()notEqual()notEqualIgnoreCase()。那些方法的名字是自我描述的,它们都做你认为它们做的事情。例如,为了确保只有在选择了收件人并且输入的金额大于零时,才启用“汇款”按钮,我们可以编写

sendBtn.disableProperty().bind(Bindings.not(
    Bindings.and(recipientSelected,
        Bindings.greaterThan(amount, 0.0))));

有一组名为createDoubleBinding()等的工厂方法,允许您从一个Callable和一组依赖项创建一个绑定。我们在清单 3-3 中创建的DoubleBinding可以简化为

DoubleBinding area = Bindings.createDoubleBinding(() -> {
      return x.get() * y.get();
}, x, y);

可以使用convert()concat()和几个重载的format()方法将非字符串可观察值转换为可观察字符串值,将几个可观察字符串值连接在一起,并将可观察数值或日期值格式化为可观察字符串值。要在Label中显示温度值,我们可以使用以下绑定:

tempLbl.textProperty().bind(Bindings.format("%2.1f \u00b0C", temperature));

随着 temperature 属性值的变化,温度的格式化字符串表示形式也随之变化。比如temperature为 37.5 时,标签会显示 37.5 C。

有一组名为select()selectInteger()等的工厂方法作用于JavaFX Bean,这些 Java 类符合 Java FX Bean 约定。还有一些方法对可观察集合起作用,这些可观察集合不包含单个值,而是包含一个ListMapSet或一个元素数组。我们将在本章后面的章节中介绍它们。

使用 Fluent API 创建绑定

流畅的 API 由一组协调的类组成,这些类的方法被设计成以这样一种方式链接在一起,即当大声读出方法链时,这些方法链以类似散文的句子描述它们做了什么。用于创建绑定的 fluent API 包含在IntegerExpression系列的类中。这些表达式类是属性类和绑定类的超类。因此,流畅的 API 方法很容易从熟悉的属性和绑定类中获得。通过浏览表达式类的 Javadocs,您可以对这些方法有所了解。总的来说,它们反映了Bindings类所能提供的。以下是使用 fluent API 构建的几个绑定示例:

recipientSelected.and(amount.greaterThan(0.0)).not()
temperature.asString("%2.1f \u00b0C")

它们相当于我们在上一节中使用来自Bindings类的工厂方法构建的绑定。

这里值得指出的一个事实是,特定于类型的数值表达式的所有方法都是在返回类型为NumberBindingNumberExpression基本接口中定义的,并且在具有相同参数签名但返回类型更特定的特定于类型的表达式类中被覆盖。这被称为协变返回类型覆盖,并且从 Java 5 开始就是 Java 语言的一个特性。这一事实的结果之一是,用 fluent API 构建的数字绑定比用Bindings类中的工厂方法构建的绑定有更多的特定类型。

清单 3-6 显示了清单 3-5 中三角形区域示例的流畅 API 版本。

package org.modernclients.propertiesandbindings;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.NumberBinding;
import javafx.beans.binding.StringExpression;
import javafx.beans.property.IntegerProperty;
import javafx.beans.property.SimpleIntegerProperty;
public class TriangleAreaFluentExample {
    public static void main(String[] args) {
        IntegerProperty x1 = new SimpleIntegerProperty(0);
        IntegerProperty y1 = new SimpleIntegerProperty(0);
        IntegerProperty x2 = new SimpleIntegerProperty(0);
        IntegerProperty y2 = new SimpleIntegerProperty(0);
        IntegerProperty x3 = new SimpleIntegerProperty(0);
        IntegerProperty y3 = new SimpleIntegerProperty(0);
        final NumberBinding area = x1.multiply(y2)
                .add(x2.multiply(y3))
                .add(x3.multiply(y1))
                .subtract(x1.multiply(y3))
                .subtract(x2.multiply(y1))
                .subtract(x3.multiply(y2))
                .divide(2.0D);
        StringExpression output = Bindings.format(
                "For A(%d,%d), B(%d,%d), C(%d,%d)," +
                        " the area of triangle ABC is %3.1f",
                x1, y1, x2, y2, x3, y3, area);
        x1.set(0); y1.set(0);
        x2.set(6); y2.set(0);
        x3.set(4); y3.set(3);
        System.out.println(output.get());
        x1.set(1); y1.set(0);
        x2.set(2); y2.set(2);
        x3.set(0); y3.set(1);
        System.out.println(output.get());
    }
}

Listing 3-6TriangleAreaFluentExample.java

运行该程序会将以下内容打印到控制台:

For A(0,0), B(6,0), C(4,3), the area of triangle ABC is 9.0
For A(1,0), B(2,2), C(0,1), the area of triangle ABC is 1.5

When允许你在一个流畅的 API 中表达 if/then/else 逻辑。您可以使用构造器或Bindings类中的when()工厂方法构造这个类的对象,并传入一个ObservableBooleanValue。在When对象上重载的then()方法返回一个嵌套的条件构建器类的对象,该类又重载了返回一个绑定对象的otherwise()方法。这允许您通过以下方式建立绑定:

new When(condition).then(result).otherwise(alternative)

这里,condition是一个ObservableBooleanValueresultalternative是类似的类型,可以是可观测的,也可以是不可观测的。最终绑定的类型类似于resultalternative的类型。

清单 3-7 展示了这个 API 的使用示例。这里,我们使用 Heron 公式计算边长分别为abc的三角形的面积

area = sqrt(s * (s – a) * (s – b) * (s – c))

其中s = (a + b + c) / 2是半参数。回想一下,在三角形中,任何两条边的和都大于第三条边。

package org.modernclients.propertiesandbindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.binding.When;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
public class HeronsFormulaExample {
    public static void main(String[] args) {
        DoubleProperty a = new SimpleDoubleProperty(0);
        DoubleProperty b = new SimpleDoubleProperty(0);
        DoubleProperty c = new SimpleDoubleProperty(0);
        DoubleBinding s = a.add(b).add(c).divide(2.0d);
        final DoubleBinding areaSquared = new When(
                a.add(b).greaterThan(c)
                        .and(b.add(c).greaterThan(a))
                        .and(c.add(a).greaterThan(b)))
                .then(s.multiply(s.subtract(a))
                        .multiply(s.subtract(b))
                        .multiply(s.subtract(c)))
                .otherwise(0.0D);
        a.set(3);
        b.set(4);
        c.set(5);
        System.out.printf("Given sides a = %1.0f," +
                        " b = %1.0f, and c = %1.0f," +
                        " the area of the triangle is" +
                        " %3.2f\n", a.get(), b.get(), c.get(),
                Math.sqrt(areaSquared.get()));
        a.set(2);
        b.set(2);
        c.set(2);
        System.out.printf("Given sides a = %1.0f," +
                        " b = %1.0f, and c = %1.0f," +
                        " the area of the triangle is" +
                        " %3.2f\n", a.get(), b.get(), c.get(),
                Math.sqrt(areaSquared.get()));
    }
}

Listing 3-7HeronsFormulaExample.java

运行该程序会将以下内容打印到控制台:

Given sides a = 3, b = 4, and c = 5, the area of the triangle is 6.00
Given sides a = 2, b = 2, and c = 2, the area of the triangle is 1.73

应该注意的是,fluent API 有其局限性。随着关系变得更加复杂或者超出了可用的运算符,直接扩展方法是首选。清单 3-8 展示了这样一个程序,它解决了与清单 3-7 相同的问题。

package org.modernclients.propertiesandbindings;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
public class HeronsFormulaDirectExtensionExample {
    public static void main(String[] args) {
        final DoubleProperty a = new SimpleDoubleProperty(0);
        final DoubleProperty b = new SimpleDoubleProperty(0);
        final DoubleProperty c = new SimpleDoubleProperty(0);
        DoubleBinding area = new DoubleBinding() {
            {
                super.bind(a, b, c);
            }
            @Override
            protected double computeValue() {
                double a0 = a.get();
                double b0 = b.get();
                double c0 = c.get();
                if ((a0 + b0 > c0) && (b0 + c0 > a0) &&
                        (c0 + a0 > b0)) {
                    double s = (a0 + b0 + c0) / 2.0D;
                    return Math.sqrt(s * (s - a0) *
                            (s - b0) * (s - c0));
                } else {
                    return 0.0D;
                }
            }
        };
        a.set(3);
        b.set(4);
        c.set(5);
        System.out.printf("Given sides a = %1.0f," +
                        " b = %1.0f, and c = %1.0f," +
                        " the area of the triangle" +
                        " is %3.2f\n", a.get(), b.get(),
                c.get(), area.get());
        a.set(2);
        b.set(2);
        c.set(2);
        System.out.printf("Given sides a = %1.0f," +
                        " b = %1.0f, and c = %1.0f," +
                        " the area of the triangle" +
                        " is %3.2f\n", a.get(), b.get(),
                c.get(), area.get());
    }
}

Listing 3-8HeronsFormulaDirectExtensionExample.java

可观察的集合

JavaFX 在包javafx.collectionsjavafx.collections.transformation中提供了对可观察集合的支持。

他们引入了另外四个Observable的子接口,与我们在本章前面章节学习的ObservableValue接口一起。分别是ObservableListObservableMapObservableSetObservableArray。observable list、map 和 set 还分别扩展了ListMapSet Java 集合的框架接口,因此可以像普通集合一样使用。因为它们只保存装箱的原始值,所以不需要特定于类型的专门化。另一方面,可观察数组在内部保存一个数组,并具有针对intfloat类型的特定于类型的专门化。它们在 JavaFX 3D API 中使用。

这些接口的主要目的是允许您注册和取消注册变更监听器。除此之外,ObservableList接口还有额外的方法,以更有效的方式操作可观察列表。ObservableMapObservableSet接口没有附加的方法。带有ObservableIntegerArrayObservableFloatArray子接口的ObservableArray接口拥有操纵可观察数组的方法。

FXCollections 中的工厂和实用程序方法

FXCollections实用程序类包含创建可观察集合和数组的工厂方法。它们类似于java.util.Collections中的工厂方法,除了它们返回可观察的集合和数组。它们是创建系统提供的可观察集合和数组的唯一方法。

FXCollections实用程序类还提供了一些方法来操作它创建的ObservableList对象。这些方法包括copy()fill()replaceAll()reverse()rotate()shuffle()sort()方法。它们执行与它们的java.util.Collections对等物相同的功能,除了它们注意最小化生成的列表改变通知的数量。

清单 3-9 显示了FXCollections方法的用法。

package org.modernclients.propertiesandbindings;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.MapChangeListener;
import javafx.collections.ObservableFloatArray;
import javafx.collections.ObservableList;
import javafx.collections.ObservableMap;
import javafx.collections.ObservableSet;
import javafx.collections.SetChangeListener;
import java.util.Arrays;
import java.util.Comparator;
import java.util.Random;
public class FXCollectionsExample {
    public static void main(String[] args) {
        ObservableList<String> list =
                FXCollections.observableArrayList();
        ObservableMap<String, String> map =
                FXCollections.observableHashMap();
        ObservableSet<Integer> set =
                FXCollections.observableSet();
        ObservableFloatArray array =
                FXCollections.observableFloatArray();
        list.addListener((ListChangeListener<String>) c -> {
            System.out.println("\tlist = " +
                    c.getList());
        });
        map.addListener((MapChangeListener<String, String>) c -> {
            System.out.println("\tmap = " +
                    c.getMap());
        });
        set.addListener((SetChangeListener<Integer>) c -> {
            System.out.println("\tset = " +
                    c.getSet());
        });
        array.addListener((observableArray,
                           sizeChanged, from, to) -> {
            System.out.println("\tarray = " +
                    observableArray);
        });
        manipulateList(list);
        manipulateMap(map);
        manipulateSet(set);
        manipulateArray(array);
    }

    private static void manipulateList(
            ObservableList<String> list) {
        System.out.println("Calling list.addAll(\"Zero\"," +
                " \"One\", \"Two\", \"Three\"):");
        list.addAll("Zero", "One", "Two", "Three");
        System.out.println("Calling copy(list," +
                " Arrays.asList(\"Four\", \"Five\")):");
        FXCollections.copy(list,
                Arrays.asList("Four", "Five"));
        System.out.println("Calling replaceAll(list," +
                " \"Two\", \"Two_1\"):");
        FXCollections.replaceAll(list, "Two", "Two_1");
        System.out.println("Calling reverse(list):");
        FXCollections.reverse(list);
        System.out.println("Calling rotate(list, 2):");
        FXCollections.rotate(list, 2);
        System.out.println("Calling shuffle(list):");
        FXCollections.shuffle(list);
        System.out.println("Calling shuffle(list," +
                " new Random(0L)):");
        FXCollections.shuffle(list, new Random(0L));
        System.out.println("Calling sort(list):");
        FXCollections.sort(list);
        System.out.println("Calling sort(list, c)" +
                " with custom comparator: ");
        FXCollections.sort(list, new Comparator<String>() {
            @Override
            public int compare(String lhs, String rhs) {
                // Reverse the order
                return rhs.compareTo(lhs);
            }
        });
        System.out.println("Calling fill(list," +
                " \"Ten\"): ");
        FXCollections.fill(list, "Ten");
    }
    private static void manipulateMap(
            ObservableMap<String, String> map) {
        System.out.println("Calling map.put(\"Key\"," +
                " \"Value\"):");
        map.put("Key", "Value");
    }
    private static void manipulateSet(
            ObservableSet<Integer> set) {
        System.out.println("Calling set.add(1024):");
        set.add(1024);
    }

    private static void manipulateArray(
            ObservableFloatArray array) {
        System.out.println("Calling  array.addAll(3.14159f," +
                " 2.71828f):");
        array.addAll(3.14159f, 2.71828f);
    }
}

Listing 3-9FXCollectionsExample.java

在这里,我们使用FXCollections工厂方法创建了一个可观察列表、一个可观察映射、一个可观察集合和一个可观察数组,给它们附加了监听器,并以某种方式操纵它们,包括对列表使用FXCollections实用方法,对数组使用ObservableFloatArray方法。

运行该程序会将以下内容打印到控制台:

Calling list.addAll("Zero", "One", "Two", "Three"):
        list = [Zero, One, Two, Three]
Calling copy(list, Arrays.asList("Four", "Five")):
        list = [Four, Five, Two, Three]
Calling replaceAll(list, "Two", "Two_1"):
        list = [Four, Five, Two_1, Three]
Calling reverse(list):
        list = [Three, Two_1, Five, Four]
Calling rotate(list, 2):
        list = [Five, Four, Three, Two_1]
Calling shuffle(list):
        list = [Five, Four, Two_1, Three]
Calling shuffle(list, new Random(0L)):
        list = [Three, Five, Four, Two_1]
Calling sort(list):
        list = [Five, Four, Three, Two_1]
Calling sort(list, c) with custom comparator:
        list = [Two_1, Three, Four, Five]
Calling fill(list, "Ten"):
        list = [Ten, Ten, Ten, Ten]
Calling map.put("Key", "Value"):
        map = {Key=Value}
Calling set.add(1024):
        set = [1024]
Calling  array.addAll(3.14159f, 2.71828f):
        array = [3.14159, 2.71828]

更改可观察集合的侦听器

ObservableListObservableMapObservableSetObservableArray接口提供了addListener()removeListener()方法来注册和取消注册侦听器,以便在底层集合或数组发生变化时得到通知。对应的ListChangeListenerMapChangeListenerSetChangeListener接口都有一个onChanged()回调方法,其参数是一个嵌套的Change类。并且ArrayChangeListener接口有一个带显式参数的onChanged()回调方法。

清单 3-10 显示了一个程序,其中一个ObservableList<String>被操纵,相应的Change对象在一个作为 lambda 实现的附加ListChangeListener中被查询。

package org.modernclient.propertiesandbindings;

import javafx.beans.Observable;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;

import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import static javafx.collections.ListChangeListener.Change;
public class ObservableListExample {
    public static void main(String[] args) {
        ObservableList<String> strings =
                FXCollections.observableArrayList();
        strings.addListener((Observable observable) -> {
            System.out.println("\tlist invalidated");
        });
        strings.addListener((Change<? extends String> change) -> {
            System.out.println("\tstrings = " +
                    change.getList());
        });
        System.out.println("Calling add(\"First\"): ");
        strings.add("First");
        System.out.println("Calling add(0, \"Zeroth\"): ");
        strings.add(0, "Zeroth");
        System.out.println("Calling addAll(\"Second\"," +
                " \"Third\"): ");
        strings.addAll("Second", "Third");
        System.out.println("Calling set(1," +
                " \"New First\"): ");
        strings.set(1, "New First");
        final List<String> list =
                Arrays.asList("Second_1", "Second_2");
        System.out.println("Calling addAll(3, list): ");
        strings.addAll(3, list);
        System.out.println("Calling remove(2, 4): ");
        strings.remove(2, 4);
        final Iterator<String> iterator =
                strings.iterator();
        while (iterator.hasNext()) {
            final String next = iterator.next();
            if (next.contains("t")) {
                System.out.println("Calling remove()" +
                        " on iterator: ");
                iterator.remove();
            }
        }
        System.out.println("Calling removeAll(" +
                "\"Third\", \"Fourth\"): ");
        strings.removeAll("Third", "Fourth");
    }
}

Listing 3-10ObservableListExample.java

运行该程序会将以下内容打印到控制台:

Calling add("First"):
        list invalidated
        strings = [First]
Calling add(0, "Zeroth"):
        list invalidated
        strings = [Zeroth, First]
Calling addAll("Second", "Third"):
        list invalidated
        strings = [Zeroth, First, Second, Third]
Calling set(1, "New First"):
        list invalidated
        strings = [Zeroth, New First, Second, Third]
Calling addAll(3, list):
        list invalidated
        strings = [Zeroth, New First, Second, Second_1, Second_2, Third]
Calling remove(2, 4):
        list invalidated
        strings = [Zeroth, New First, Second_2, Third]
Calling remove() on iterator:
        list invalidated
        strings = [New First, Second_2, Third]
Calling remove() on iterator:
        list invalidated

        strings = [Second_2, Third]
Calling removeAll("Third", "Fourth"):
        list invalidated
        strings = [Second_2]

ListChangeListener 中的更改事件

在上一节中,我们只查询了ListChangeListener.Change对象的list属性,该属性引用了被观察的列表。这个对象保存了更多关于底层列表变化的信息。它代表一个或多个离散的变更,每个变更都可以是添加元素、删除元素、替换元素或置换元素。变更界面为您提供了查询变更各个方面的方法。

next()reset()方法控制遍历离散变化的游标。当调用onChanged()时,光标位于第一个离散变化之前。一旦光标位于一个有效的离散变更上,wasAdded()wasRemoved()wasReplaced()wasPermuted()方法会告诉您这是哪种离散变更。

一旦知道了光标所在的离散变化,就可以调用其他方法来获得有关离散变化的更多细节。对于添加的元素,您可以获得from(含)和to(不含)索引、addedSizeaddedSubList。对于删除的元素,您可以获得删除元素的fromto(同from)索引、removedSizeremoved列表。对于被替换的元素,可以认为是先删除后添加,应检查与添加和删除相关的信息。对于元素置换,getPermutation(int i)方法将 before 索引映射到 after 索引。

清单 3-11 显示了一个带有漂亮的打印实现ListChangeListener的程序,当一个变更事件被触发时,它打印出Change对象的细节。

package org.modernclients.propertiesandbindings;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
public class ListChangeEventExample {
    public static void main(String[] args) {
        ObservableList<String> strings =
                FXCollections.observableArrayList();
        strings.addListener(new MyListener());
        System.out.println("Calling addAll(\"Zero\"," +
                " \"One\", \"Two\", \"Three\"): ");
        strings.addAll("Zero", "One", "Two", "Three");
        System.out.println("Calling" +
                " FXCollections.sort(strings): ");
        FXCollections.sort(strings);
        System.out.println("Calling set(1, \"Three_1\"): ");
        strings.set(1, "Three_1");
        System.out.println("Calling setAll(\"One_1\"," +
                " \"Three_1\", \"Two_1\", \"Zero_1\"): ");
        strings.setAll("One_1", "Three_1", "Two_1", "Zero_1");
        System.out.println("Calling removeAll(\"One_1\"," +
                " \"Two_1\", \"Zero_1\"): ");
        strings.removeAll("One_1", "Two_1", "Zero_1");
    }
    private static class MyListener implements
            ListChangeListener<String> {
        @Override
        public void onChanged(
                Change<? extends String> change) {
            System.out.println("\tlist = " +
                    change.getList());
            System.out.println(prettyPrint(change));
        }
        private String prettyPrint(
                Change<? extends String> change) {
            StringBuilder sb =
                    new StringBuilder("\tChange event data:\n");
            int i = 0;
            while (change.next()) {
                sb.append("\t\tcursor = ")
                        .append(i++)
                        .append("\n");
                final String kind =
                        change.wasPermutated() ? "permutated" :
                        change.wasReplaced() ? "replaced" :
                        change.wasRemoved() ? "removed" :
                        change.wasAdded() ? "added" :
                        "none";
                sb.append("\t\tKind of change: ")
                        .append(kind)
                        .append("\n");
                sb.append("\t\tAffected range: [")
                        .append(change.getFrom())
                        .append(", ")
                        .append(change.getTo())
                        .append("]\n");
                if (kind.equals("added") ||
                        kind.equals("replaced")) {
                    sb.append("\t\tAdded size: ")
                            .append(change.getAddedSize())
                            .append("\n");
                    sb.append("\t\tAdded sublist: ")
                            .append(change.getAddedSubList())
                            .append("\n");
                }

                if (kind.equals("removed") ||
                        kind.equals("replaced")) {
                    sb.append("\t\tRemoved size: ")
                            .append(change.getRemovedSize())
                            .append("\n");
                    sb.append("\t\tRemoved: ")
                            .append(change.getRemoved())
                            .append("\n");
                }
                if (kind.equals("permutated")) {
                    StringBuilder permutationSB =
                            new StringBuilder("[");
                    int from = change.getFrom();
                    int to = change.getTo();
                    for (int k = from; k < to; k++) {
                        int permutation =
                                change.getPermutation(k);
                        permutationSB.append(k)
                                .append("->")
                                .append(permutation);
                        if (k < change.getTo() - 1) {
                            permutationSB.append(", ");
                        }
                    }
                    permutationSB.append("]");
                    String permutation =
                            permutationSB.toString();
                    sb.append("\t\tPermutation: ")
                            .append(permutation).append("\n");
                }
            }
            return sb.toString();
        }
    }
}

Listing 3-11ListChangeEventExample.java

运行该程序会将以下内容打印到控制台:

Calling addAll("Zero", "One", "Two", "Three"):
        list = [Zero, One, Two, Three]
        Change event data:
                cursor = 0
                Kind of change: added
                Affected range: [0, 4]
                Added size: 4
                Added sublist: [Zero, One, Two, Three]
Calling FXCollections.sort(strings):
        list = [One, Three, Two, Zero]
        Change event data:
                cursor = 0
                Kind of change: permutated
                Affected range: [0, 4]
                Permutation: [0->3, 1->0, 2->2, 3->1]
Calling set(1, "Three_1"):
        list = [One, Three_1, Two, Zero]
        Change event data:
                cursor = 0
                Kind of change: replaced
                Affected range: [1, 2]
                Added size: 1
                Added sublist: [Three_1]
                Removed size: 1
                Removed: [Three]
Calling setAll("One_1", "Three_1", "Two_1", "Zero_1"):
        list = [One_1, Three_1, Two_1, Zero_1]
        Change event data:
                cursor = 0

                Kind of change: replaced
                Affected range: [0, 4]
                Added size: 4
                Added sublist: [One_1, Three_1, Two_1, Zero_1]
                Removed size: 4
                Removed: [One, Three_1, Two, Zero]
Calling removeAll("One_1", "Two_1", "Zero_1"):
        list = [Three_1]
        Change event data:
                cursor = 0
                Kind of change: removed
                Affected range: [0, 0]
                Removed size: 1
                Removed: [One_1]
                cursor = 1
                Kind of change: removed
                Affected range: [1, 1]
                Removed size: 2
                Removed: [Two_1, Zero_1]

MapChangeListener 中的更改事件

MapChangeListener.Change事件比其对应的可观察列表要简单得多,因为它只反映一个键的变化。因此,没有next()也没有reset()方法是必要的。如果多个键受到影响,将触发多个更改事件。

wasAdded()wasRemoved()方法指示是否添加或移除一个键。你总能找到受变化影响的key。而如果加了一个键,就可以在valueAdded得到;如果一把钥匙被拿掉,你就可以拿到valueRemoved

清单 3-12 显示了一个操纵可观察地图并记录生成的变更事件的程序。

package org.modernclients.propertiesandbindings;
import javafx.collections.FXCollections;
import javafx.collections.MapChangeListener;
import javafx.collections.ObservableMap;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
public class MapChangeEventExample {
    public static void main(String[] args) {
        ObservableMap<String, Integer> map =
                FXCollections.observableHashMap();
        map.addListener(new MyListener());
        System.out.println("Calling put(\"First\", 1): ");
        map.put("First", 1);
        System.out.println("Calling put(\"First\", 100): ");
        map.put("First", 100);
        Map<String, Integer> anotherMap = new HashMap<>();
        anotherMap.put("Second", 2);
        anotherMap.put("Third", 3);
        System.out.println("Calling putAll(anotherMap): ");
        map.putAll(anotherMap);
        Iterator<Map.Entry<String, Integer>> entryIterator =
                map.entrySet().iterator();
        while (entryIterator.hasNext()) {
            final Map.Entry<String, Integer> next =
                    entryIterator.next();
            if (next.getKey().equals("Second")) {
                System.out.println("Calling remove on" +
                        " entryIterator: ");
                entryIterator.remove();
            }
        }
        final Iterator<Integer> valueIterator =
                map.values().iterator();
        while (valueIterator.hasNext()) {
            final Integer next = valueIterator.next();
            if (next == 3) {
                System.out.println("Calling remove on" +
                        " valueIterator: ");
                valueIterator.remove();
            }
        }
    }

    private static class MyListener implements
            MapChangeListener<String, Integer> {
        @Override
        public void onChanged(
                Change<? extends String, ? extends Integer>
                        change) {
            System.out.println("\tmap = " + change.getMap());
            System.out.println(prettyPrint(change));
        }

        private String prettyPrint(
                Change<? extends String, ? extends Integer>
                        change) {
            StringBuilder sb =
                    new StringBuilder("\tChange event" +
                            " data:\n");
            sb.append("\t\tWas added: ")
                    .append(change.wasAdded())
                    .append("\n");
            sb.append("\t\tWas removed: ")
                    .append(change.wasRemoved())
                    .append("\n");
            sb.append("\t\tKey: ")
                    .append(change.getKey())
                    .append("\n");
            sb.append("\t\tValue added: ")
                    .append(change.getValueAdded())
                    .append("\n");
            sb.append("\t\tValue removed: ")
                    .append(change.getValueRemoved())
                    .append("\n");
            return sb.toString();
        }
    }
}

Listing 3-12MapChangeEventExample.java

SetChangeListener 中的更改事件

SetChangeListener.Change事件甚至比可观察地图的事件更简单,因为当可观察集合被修改时不涉及任何值。

清单 3-13 显示了一个操纵一个可观察集合并记录生成的变更事件的程序。

package org.modernclients.propertiesandbindings;
import javafx.collections.FXCollections;
import javafx.collections.ObservableSet;
import javafx.collections.SetChangeListener;
import java.util.Arrays;
public class SetChangeEventExample {
    public static void main(String[] args) {
        ObservableSet<String> set =
                FXCollections.observableSet();
        set.addListener(new MyListener());
        System.out.println("Calling add(\"First\"): ");
        set.add("First");
        System.out.println("Calling addAll(" +
                "Arrays.asList(\"Second\", \"Third\")): ");
        set.addAll(Arrays.asList("Second", "Third"));
        System.out.println("Calling remove(" +
                "\"Third\"): ");
        set.remove("Third");
    }
    private static class MyListener
            implements SetChangeListener<String> {
        @Override
        public void onChanged(Change<? extends String>
                                      change) {
            System.out.println("\tset = " +
                    change.getSet());
            System.out.println(prettyPrint(change));
        }
        private String prettyPrint(
                Change<? extends String> change) {
            StringBuilder sb =
                    new StringBuilder("\tChange" +
                            " event data:\n");
            sb.append("\t\tWas added: ")
                    .append(change.wasAdded())
                    .append("\n");
            sb.append("\t\tWas removed: ")
                    .append(change.wasRemoved())
                    .append("\n");
            sb.append("\t\tElement added: ")
                    .append(change.getElementAdded())
                    .append("\n");
            sb.append("\t\tElement removed: ")
                    .append(change.getElementRemoved())
                    .append("\n");
            return sb.toString();
        }
    }
}

Listing 3-13SetChangeEventExample.java

更改 ArrayChangeListener 中的事件

ArrayChangeListener中的onChanged()方法具有以下签名:

public void onChanged(T observableArray,
    boolean sizeChanged, int from, int to);

正如许多管理器类的数组一样,ObservableArray有一个容量和一个大小。容量是底层支持数组的长度,大小是包含应用程序数据的元素的数量。大小总是小于或等于容量。ensureCapacity()方法将容量设置为指定的值,并在必要时重新分配底层数组。resize()方法改变大小。如果新容量大于旧容量,则容量会增加。如果新的大小大于旧的大小,多余的元素用零填充。如果新的大小小于旧的大小,后备数组不会收缩,但丢失的元素会用零填充。trimToSize()方法将容量缩小到大小。clear()方法将可观察数组的大小调整为零。size()方法返回可观察数组的当前大小。

ObservableArrayObservableIntegerArray,ObservableFloatArray的特定类型专门化重载了以特定类型方式操作底层数组的方法。get()方法获取指定索引处的值。set()方法在指定的索引处设置一个值或一组值。addAll()方法将附加元素添加到可观察数组中。setAll()方法替换可观察数组中的元素。toArray()方法返回一个填充了可观察数组内容的原始数组。get()set()方法可能会抛出ArrayIndexOutOfBoundsException

清单 3-14 显示了一个操纵ObservableIntegerArray并显示变更通知的程序。

package org.modernclients.propertiesandbindings;
import javafx.collections.FXCollections;
import javafx.collections.ObservableIntegerArray;
public class ArrayChangeEventExample {
    public static void main(String[] args) {
        final ObservableIntegerArray ints =
                FXCollections.observableIntegerArray(10, 20);
        ints.addListener((array,
                          sizeChanged, from, to) -> {
            StringBuilder sb =
                    new StringBuilder("\tObservable Array = ")
                            .append(array)
                            .append("\n")
                            .append("\t\tsizeChanged = ")
                            .append(sizeChanged).append("\n")
                            .append("\t\tfrom = ")
                            .append(from).append("\n")
                            .append("\t\tto = ")
                            .append(to)
                            .append("\n");
            System.out.println(sb.toString());
        });
        ints.ensureCapacity(20);
        System.out.println("Calling addAll(30, 40):");
        ints.addAll(30, 40);
        final int[] src = {50, 60, 70};
        System.out.println("Calling addAll(src, 1, 2):");
        ints.addAll(src, 1, 2);
        System.out.println("Calling set(0, src, 0, 1):");
        ints.set(0, src, 0, 1);
        System.out.println("Calling setAll(src):");
        ints.setAll(src);
        ints.trimToSize();
        final ObservableIntegerArray ints2 =
                FXCollections.observableIntegerArray();
        ints2.resize(ints.size());
        System.out.println("Calling copyTo(0, ints2," +
                " 0, ints.size()):");
        ints.copyTo(0, ints2, 0, ints.size());
        System.out.println("\tDestination = " + ints2);
    }
}

Listing 3-14ArrayChangeEventExample.java

此应用程序的输出如下所示:

Calling addAll(30, 40):
        Observable Array = [10, 20, 30, 40]
                sizeChanged = true
                from = 2
                to = 4
Calling addAll(src, 1, 2):
        Observable Array = [10, 20, 30, 40, 60, 70]
                sizeChanged = true
                from = 4
                to = 6
Calling set(0, src, 0, 1):
        Observable Array = [50, 20, 30, 40, 60, 70]
                sizeChanged = false
                from = 0
                to = 1
Calling setAll(src):
        Observable Array = [50, 60, 70]
                sizeChanged = true
                from = 0
                to = 3
Calling copyTo(0, ints2, 0, ints.size()):
        Destination = [50, 60, 70]

为可观察集合创建绑定

Bindings实用程序类包括从可观察集合中创建绑定的工厂方法。

重载方法valueAt()booleanValueAt()integerValueAt()longValueAt()floatValueAt()doubleValueAt()stringValueAt()从相同类型的可观察集合和适当类型的索引或键(可观察的或不可观察的)中创建适当类型的绑定。

例如,如果authorizations是一个代表Person对象授权状态的ObservableMap<Person, Boolean>,而user是一个ObjectProperty<Person>对象,那么booleanValueAt(authorizations, user)就是一个代表用户授权状态的BooleanBinding

重载的bindContent()方法将不可观察集合绑定到同类的可观察集合,确保不可观察集合与可观察集合具有相同的内容。unbindContent()方法删除了这样的内容绑定。重载的bindContentBidirectional()方法绑定两个同类的可观察集合,确保它们有相同的内容。unbindContentBidirectional()方法删除了这样的双向内容绑定。

JavaFX Beans

在前面的小节中,我们研究了单独的 JavaFX 属性和可观察集合。现在我们研究如何将它们组合成更大的单元,以形成更有意义的软件组件。

Java Beans 的概念几乎从一开始就存在。它引入了三个架构概念:属性事件方法。Java 中的方法很简单。事件是通过侦听器接口和事件对象提供的,JavaFX 控件仍在使用这些接口和对象。属性是使用现在非常熟悉的公共 getter 和 setter 方法提供的。

JavaFX 引入了 JavaFX Bean 概念,其中除了 getter 和 setter,JavaFX Bean 属性还有一个属性 getter 。对于类型为double的名为height的属性,有以下三种方法:

public final double getHeight();
public final void setHeight(double height);
public DoubleProperty heightProperty();

正如传统的属性通常使用相同类型的支持字段来实现一样,JavaFX Bean 属性通常使用适当的Property类型的支持字段来实现。由于这些属性是引用类型,对于具有许多属性的 JavaFX Bean,可能会创建许多额外的对象。根据使用模式,可以使用不同的策略来实现这些属性。

Note

只读 JavaFX Bean 属性可以用一个 getter 和一个返回 JavaFX 属性的只读版本的属性 getter 来定义。

急切实例化的属性

实现 JavaFX Bean 属性的最简单策略是急切实例化属性策略。每个属性都由在构造时实例化的适当属性类型支持。getter 和 setter 简单地调用后台属性的get()set()方法。属性 getter 返回支持属性本身。清单 3-15 显示了一个具有intStringColor属性的 JavaFX Bean。

package org.modernclients.propertiesandbindings;
import javafx.beans.property.*;
import javafx.scene.paint.Color;
public class JavaFXBeanModelExample {
    private IntegerProperty i =
            new SimpleIntegerProperty(this, "i", 0);
    private StringProperty str =
            new SimpleStringProperty(this, "str", "Hello");
    private ObjectProperty<Color> color =
            new SimpleObjectProperty<Color>(this, "color",
                    Color.BLACK);
    public final int getI() {
        return i.get();
    }
    public final void setI(int i) {
        this.i.set(i);
    }
    public IntegerProperty iProperty() {
        return i;
    }
    public final String getStr() {
        return str.get();
    }
    public final void setStr(String str) {
        this.str.set(str);
    }
    public StringProperty strProperty() {
        return str;
    }
    public final Color getColor() {
        return color.get();
    }
    public final void setColor(Color color) {
        this.color.set(color);
    }
    public ObjectProperty<Color> colorProperty() {
        return color;
    }
}

Listing 3-15JavaFXBeanModelExample.java

注意,我们使用了具有完整上下文的属性构造器,包括 bean、属性名和初始化属性的初始值。

半延迟实例化属性

如果 setter 和属性 getter 从未被调用,getter 将总是返回一个属性的默认值;你不需要一个属性实例来知道这一点。这是半懒惰实例化策略的基础。在这种策略中,只有在使用不同于默认值的值调用 setter 或调用属性 getter 时,属性才会被实例化。这种策略最适合具有许多属性的 JavaFX Beans,其中只有少数属性被设置。

清单 3-16 展示了这种策略的一个例子。

package org.modernclients.propertiesandbindings;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
public class JavaFXBeanModelHalfLazyExample {
    private static final String DEFAULT_STR = "Hello";
    private StringProperty str;
    public final String getStr() {
        if (str != null) {
            return str.get();
        } else {
            return DEFAULT_STR;
        }
    }
    public final void setStr(String str) {
        if ((this.str != null) ||
                !(str.equals(DEFAULT_STR))) {
            strProperty().set(str);
        }
    }
    public StringProperty strProperty() {
        if (str == null) {
            str = new SimpleStringProperty(this,
                    "str", DEFAULT_STR);
        }
        return str;
    }
}

Listing 3-16JavaFXBeanModelHalfLazyExample.java

完全延迟实例化的属性

深入思考半懒惰实例化策略,我们会问自己,“当调用 setter 时,我们真的需要实例化属性吗?”答案当然是否定的,如果我们有地方放它,就像过去一样。这就产生了全懒惰实例化策略。在这种策略中,只有在调用属性 getter 时,属性才会被实例化。只有当属性对象已经被实例化时,getter 和 setter 才会检查它;否则,它们会通过一个单独的支持字段。

清单 3-17 展示了这种策略的一个例子。

package org.modernclients.propertiesandbindings;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
public class JavaFXBeanModelFullLazyExample {
    private static final String DEFAULT_STR = "Hello";
    private StringProperty str;
    private String _str = DEFAULT_STR;
    public final String getStr() {
        if (str != null) {
            return str.get();
        } else {
            return _str;
        }
    }
    public final void setStr(String str) {
        if (this.str != null) {
            this.str.set(str);
        } else {
            _str = str;
        }
    }
    public StringProperty strProperty() {
        if (str == null) {
            str = new SimpleStringProperty(this,
                    "str", DEFAULT_STR);
        }
        return str;
    }
}

Listing 3-17JavaFXBeanModelFullLazyExample.java

选择绑定

现在我们已经理解了 JavaFX Bean 的概念,我们可以回到Bindings实用程序类,学习select()selectInteger()方法等等。他们有如下签名:

selectInteger(Object root, String... steps);

这些选择操作符允许您创建观察深度嵌套的 JavaFX Bean 属性的绑定。这里,root是作用域中的对象引用,每个step是手边对象的属性,指向下一个对象,依此类推。

最好用一个例子来说明这个概念。考虑类Lighting(在javafx.scene.effect中)。它有一个名为light的属性,类型为Light。并且Light有一个名为colorColor类型的属性(在javafx.scene.paint中)。清单 3-18 显示了一个构建选择绑定的程序,该绑定到达root对象的lightcolor

package org.modernclients.propertiesandbindings;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.ObjectBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.scene.effect.Light;
import javafx.scene.effect.Lighting;
import javafx.scene.paint.Color;
public class SelectBindingExample {
    public static void main(String[] args) {
        ObjectProperty<Lighting> root =
                new SimpleObjectProperty<>();
        final ObjectBinding<Color> colorBinding =
                Bindings.select(root, "light", "color");
        colorBinding.addListener((o, oldValue, newValue) ->
                System.out.println("\tThe color changed:\n" +
                        "\t\told color = " + oldValue +
                        ",\n\t\tnew color = " + newValue));
        System.out.println("firstLight is black.");
        Light firstLight = new Light.Point();
        firstLight.setColor(Color.BLACK);
        System.out.println("secondLight is white.");
        Light secondLight = new Light.Point();
        secondLight.setColor(Color.WHITE);
        System.out.println("firstLighting has firstLight.");
        Lighting firstLighting = new Lighting();
        firstLighting.setLight(firstLight);
        System.out.println("secondLighting has secondLight.");
        Lighting secondLighting = new Lighting();
        secondLighting.setLight(secondLight);
        System.out.println("Making root observe" +
                " firstLighting.");
        root.set(firstLighting);
        System.out.println("Making root observe" +
                " secondLighting.");
        root.set(secondLighting);
        System.out.println("Changing secondLighting's" +
                " light to firstLight");
        secondLighting.setLight(firstLight);
        System.out.println("Changing firstLight's" +
                " color to red");
        firstLight.setColor(Color.RED);
    }
}

Listing 3-18SelectBindingExample.java

运行该程序会将以下内容打印到控制台:

firstLight is black.
secondLight is white.
firstLighting has firstLight.
secondLighting has secondLight.
Making root observe firstLighting.
        The color changed:
                old color = null,
                new color = 0x000000ff
Making root observe secondLighting.
        The color changed:
                old color = 0x000000ff,
                new color = 0xffffffff
Changing secondLighting's light to firstLight
        The color changed:
                old color = 0xffffffff,
                new color = 0x000000ff
Changing firstLight's color to red
        The color changed:
                old color = 0x000000ff,
                new color = 0xff0000ff

改编 Java Beans

对于多年来编写的许多老式 Java Bean,JavaFX 在javafx.beans.property.adapter包中提供了一组适配器类,将 Java Bean 属性转换为 JavaFX 属性。

回想一下,如果在属性改变时触发了一个PropertyChange事件,那么 Java Bean 属性就是一个绑定属性。如果一个VetoableChange事件在被改变时被触发,那么它就是一个约束属性。如果一个注册的监听器抛出一个PropertyVetoException,这个改变不会生效。

清单 3-19 显示了一个具有普通属性name、绑定属性address和约束属性phoneNumberPerson bean。

package org.modernclients.propertiesandbindings;
import java.beans.PropertyChangeListener;
import java.beans.PropertyChangeSupport;
import java.beans.PropertyVetoException;
import java.beans.VetoableChangeListener;
import java.beans.VetoableChangeSupport;
public class Person {
    private PropertyChangeSupport propertyChangeSupport;
    private VetoableChangeSupport vetoableChangeSupport;
    private String name;
    private String address;
    private String phoneNumber;
    public Person() {
        propertyChangeSupport =
                new PropertyChangeSupport(this);
        vetoableChangeSupport =
                new VetoableChangeSupport(this);
    }
    public String getName() {
        return name;
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getAddress() {
        return address;
    }
    public void setAddress(String address) {
        String oldAddress = this.address;
        this.address = address;
        propertyChangeSupport.firePropertyChange("address",
                oldAddress, this.address);
    }
    public String getPhoneNumber() {
        return phoneNumber;
    }
    public void setPhoneNumber(String phoneNumber)
            throws PropertyVetoException {
        String oldPhoneNumber = this.phoneNumber;
        vetoableChangeSupport.fireVetoableChange("phoneNumber",
                oldPhoneNumber, phoneNumber);
        this.phoneNumber = phoneNumber;
        propertyChangeSupport.firePropertyChange("phoneNumber",
                oldPhoneNumber, this.phoneNumber);
    }
    public void addPropertyChangeListener(PropertyChangeListener l) {
        propertyChangeSupport.addPropertyChangeListener(l);
    }
    public void removePropertyChangeListener(PropertyChangeListener l) {
        propertyChangeSupport.removePropertyChangeListener(l);
    }
    public PropertyChangeListener[] getPropertyChangeListeners() {
        return propertyChangeSupport.getPropertyChangeListeners();
    }
    public void addVetoableChangeListener(VetoableChangeListener l) {
        vetoableChangeSupport.addVetoableChangeListener(l);
    }
    public void removeVetoableChangeListener(VetoableChangeListener l) {
        vetoableChangeSupport.removeVetoableChangeListener(l);
    }
    public VetoableChangeListener[] getVetoableChangeListeners() {
        return vetoableChangeSupport.getVetoableChangeListeners();
    }
}

Listing 3-19Person.java

类型为String的 Java Bean 属性可以通过使用JavaBeanStringPropertyBuilder被改编成JavaBeanStringProperty:

JavaBeanStringPropertyBuilder.create()
        .bean(person)
        .name("name")
        .build();

这遵循熟悉的构建器模式:您调用静态的create()方法来获得构建器的实例,然后调用构建器实例上的bean()name()方法来配置构建器,告诉它要适应哪个 bean 和哪个属性。最后,您调用构建器上的build()方法来获得修改后的 JavaFX 属性。

builder 类有更多的方法可以用来处理更深奥的情况,例如,当 getter 或 setter 不遵循熟悉的命名约定,而是使用元数据指定时。

清单 3-20 显示了一个将Person类的三个 Java Bean 属性改编成JavaBeanStringProperty对象的程序。

package org.modernclients.propertiesandbindings;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.adapter.JavaBeanStringProperty;
import javafx.beans.property.adapter.JavaBeanStringPropertyBuilder;
import java.beans.PropertyVetoException;
public class JavaBeanPropertiesExample {
    public static void main(String[] args)
            throws NoSuchMethodException {
        adaptJavaBeansProperty();
        adaptBoundProperty();
        adaptConstrainedProperty();
    }
    private static void adaptJavaBeansProperty()
            throws NoSuchMethodException {
        Person person = new Person();
        JavaBeanStringProperty nameProperty =
                JavaBeanStringPropertyBuilder.create()
                        .bean(person)
                        .name("name")
                        .build();
        nameProperty.addListener((observable, oldValue, newValue) -> {
            System.out.println("JavaFX property " +
                    observable + " changed:");
            System.out.println("\toldValue = " +
                    oldValue + ", newValue = " + newValue);
        });
        System.out.println("Setting name on the" +
                " JavaBeans property");
        person.setName("Weiqi Gao");
        System.out.println("Calling fireValueChange");
        nameProperty.fireValueChangedEvent();
        System.out.println("nameProperty.get() = " +
                nameProperty.get());
        System.out.println("Setting value on the" +
                " JavaFX property");
        nameProperty.set("Johan Vos");
        System.out.println("person.getName() = " +
                person.getName());
    }
    private static void adaptBoundProperty()
            throws NoSuchMethodException {
        System.out.println();
        Person person = new Person();
        JavaBeanStringProperty addressProperty =
                JavaBeanStringPropertyBuilder.create()
                        .bean(person)
                        .name("address")
                        .build();
        addressProperty.addListener((observable, oldValue, newValue) -> {
            System.out.println("JavaFX property " +
                    observable + " changed:");
            System.out.println("\toldValue = " +
                    oldValue + ", newValue = " + newValue);
        });
        System.out.println("Setting address on the" +
                " JavaBeans property");
        person.setAddress("12345 main Street");
    }

    private static void adaptConstrainedProperty()
            throws NoSuchMethodException {
        System.out.println();
        Person person = new Person();
        JavaBeanStringProperty phoneNumberProperty =
                JavaBeanStringPropertyBuilder.create()
                        .bean(person)
                        .name("phoneNumber")
                        .build();
        phoneNumberProperty.addListener((observable,
                                         oldValue, newValue) -> {
            System.out.println("JavaFX property " +
                    observable + " changed:");
            System.out.println("\toldValue = " +
                    oldValue + ", newValue = " + newValue);
        });
        System.out.println("Setting phoneNumber on the" +
                " JavaBeans property");
        try {
            person.setPhoneNumber("800-555-1212");
        } catch (PropertyVetoException e) {
            System.out.println("A JavaBeans property" +
                    " change is vetoed.");
        }
        System.out.println("Bind phoneNumberProperty" +
                " to another property");
        SimpleStringProperty stringProperty =
                new SimpleStringProperty("866-555-1212");
        phoneNumberProperty.bind(stringProperty);
        System.out.println("Setting phoneNumber on the" +
                " JavaBeans property");
        try {
            person.setPhoneNumber("888-555-1212");
        } catch (PropertyVetoException e) {
            System.out.println("A JavaBeans property" +
                    " change is vetoed.");
        }
        System.out.println("person.getPhoneNumber() = " +
                person.getPhoneNumber());
    }
}

Listing 3-20JavaBeanPropertiesExample.java

注意,因为name不是一个绑定属性,所以调用person.setName()不会自动将新值传播给改编后的nameProperty。我们必须呼吁nameProperty上的fireValueChangedEvent()来实现这一点。对于绑定属性address,调用person.setAddress()会自动将新值传播到addressProperty。对于受约束的属性phoneNumber,在我们将适配的phoneNumberProperty绑定到另一个stringProperty之后,调用person.setPhoneNumber()会抛出一个PropertyVetoException,新值被拒绝。

运行该程序会将以下内容打印到控制台:

Setting name on the JavaBeans property
Calling fireValueChange
JavaFX property StringProperty [bean: org.modernclients.propertiesand
bindings.Person@5a8e6209, name: name, value: Weiqi Gao] changed:
        oldValue = null, newValue = Weiqi Gao
nameProperty.get() = Weiqi Gao
Setting value on the JavaFX property
JavaFX property StringProperty [bean: org.modernclients.propertiesand
bindings.Person@5a8e6209, name: name, value: Johan Vos] changed:
        oldValue = Weiqi Gao, newValue = Johan Vos
person.getName() = Johan Vos
Setting address on the JavaBeans property
JavaFX property StringProperty [bean: org.modernclients.propertiesand
bindings.Person@1f36e637, name: address, value: 12345 main Street] changed:
        oldValue = null, newValue = 12345 main Street
Setting phoneNumber on the JavaBeans property
JavaFX property StringProperty [bean: org.modernclients.propertiesand
bindings.Person@35d176f7, name: phoneNumber, value: 800-555-1212] changed:
        oldValue = null, newValue = 800-555-1212
Bind phoneNumberProperty to another property
JavaFX property StringProperty [bean: org.modernclients.propertiesand
bindings.Person@35d176f7, name: phoneNumber, value: 866-555-1212] changed:
        oldValue = 800-555-1212, newValue = 866-555-1212
Setting phoneNumber on the JavaBeans property
A JavaBeans property change is vetoed.
person.getPhoneNumber() = 866-555-1212

摘要

在本章中,您学习了 JavaFX 属性和绑定框架的基础知识。现在,您应该了解以下重要原则:

  • JavaFX 属性和绑定保存值并向附加的侦听器触发事件。

  • 当值无效时,将触发一个无效事件。并且在重新计算该值时触发一个 change 事件,这可能是延迟的。

  • 对于booleanintlongfloatdoubleStringObject类型,存在通用键接口的类型特定的专门化。对于原始类型,它们避免装箱和取消装箱。

  • ChangeListener附加到一个属性会强制进行急切评估。

  • 可以通过直接扩展、使用Bindings中的工厂方法或者使用 fluent API 来创建新的绑定。

  • 对于ListMapSetintfloat数组,存在可观察集合。它们的变化事件比可观察值更复杂。

  • 可观察的集合和数组是使用FXCollections实用程序类中的工厂方法创建的。

  • JavaFX Bean 属性由一个 getter、一个 setter 和一个属性 getter 定义。

  • JavaFX Bean 属性可以通过急切实例化、半懒惰实例化和全懒惰实例化策略来实现。

  • 旧式的 Java Bean 属性可以很容易地适应 JavaFX 属性。

资源

以下是使用本章内容的有用资源:

四、JavaFX 控制深入

乔纳森·贾尔斯写的

当 JavaFX 在 2007 年首次发布时,它没有任何用户界面控件可供用户放入他们的用户界面。开发人员不得不满足于要么创建自己的基本 UI 控件,要么从 Java 附带的 Swing 工具包中导入 UI 组件。从 JavaFX 1.2 开始,这种情况开始改善,引入了许多非常重要的 UI 控件,如ButtonProgressBarListView。在随后的版本中,JavaFX 开始获得一套完整且广受好评的 UI 控件,提供了为企业环境中的应用程序构建用户界面的能力。

本章将介绍核心 JavaFX 17 版本中的大多数 UI 控件。由于本章的页数有限,代码示例有意保持简短。请放心,本书的代码库包括一个全面的演示应用程序,涵盖了所有 JavaFX UI 控件,代码可以复制/粘贴到您自己的应用程序中。

用户界面控件模块

从 JavaFX 9 开始,几乎所有的 UI 控件都封装在javafx.controls模块中。 1 该模块被拆分成四个导出包,如下图所示:

  • javafx.scene.chart:这个包包含了构建图表的图表组件,比如折线图、条形图、面积图、饼图、气泡图和散点图。这些将不作为本章的一部分。

  • javafx.scene.control:这个包包含 javafx 中几乎所有用户界面控件的 API。这是我们将在本章介绍的主要软件包。

  • javafx.scene.control.cell:这个包包含了大量预先构建的“细胞工厂”的 API,我们将在本章后面的“高级控件”部分更深入地讨论这些 API。

  • javafx.scene.control.skin:这个包包含每个 UI 控件的“皮肤”或可视组件。我们不会在本章中讨论这个包,因为它超出了本书的范围。

什么是 UI 控件?

在 JavaFX 的上下文中,一个有效的问题是:什么是 UI 控件 一个简单的定义可能是,它是一个可视组件,构成用户界面的一小部分,并且通常是交互式的(但不总是)。从最严格的意义上来说,UI 控件从Control类扩展而来,但是一个更宽松的定义允许任何从Node扩展而来的组件被认为是 UI 控件。出于本章的考虑,讨论的大多数 UI 控件都是从Control类扩展而来的。

这就不可避免的引出了下一个问题:什么是控制类? Control是从Parent扩展而来的类,而Parent本身又是从Node扩展而来的。在通常引用的 MVC 2 命名法中,一个Control可以被认为是模型。在任何使用 JavaFX UI 控件构建的用户界面中,开发人员应该只与Control类进行交互,因为这些是所有 API 操作和读取控件状态的地方。

因为Control职业是从Node延伸出来的,它被赋予了Node所拥有的所有能力。这意味着 UI 控件可以根据需要修改效果、旋转、缩放和许多其他属性。还可以以标准方式添加鼠标、滑动、拖动、触摸、按键输入等事件处理程序。将 UI 控件添加到场景图的方式也与任何Node相同——通过将它添加到带有相关尺寸信息、布局约束等的布局容器中。

从 JavaFX 9 开始,如前所述,所有 UI 控件的视觉效果,即皮肤,也已经成为javafx.scene.control.skin包中的公共 API。皮肤是公共 API 的原因是为了使开发人员能够对它们进行子类化,从而覆盖 UI 控件的默认视觉效果。

JavaFX 基本控件

JavaFX 中存在一个 UI 控件子集,可以认为它对几乎所有的用户界面都至关重要,但是无论从最终用户的角度还是从 UI 开发人员的角度来看,它们都是简单易用的,从这个意义上来说,它们是基本的。本节将依次介绍这些基本控件。从 JavaFX 17 开始,基本 UI 控件可以分为三个子组:

  1. “贴标签”控件:ButtonCheckBoxHyperlinkLabelRadioButtonToggleButton

  2. “文本输入”控件:TextFieldTextAreaPasswordField

  3. “其他”简单控件:ProgressBarProgressIndicatorSlider

标签控件

大多数显示只读文本的控件都是从一个叫做Labeled的公共抽象超类扩展而来的。这个类指定了一组公共属性,用于处理对齐、字体、图形(和图形定位)、换行等等,当然,也用于显示文本本身。因为Labeled是抽象的,所以一般不直接使用,但是很多实际的 UI 控件都是从中延伸出来的,包括ButtonCheckBoxHyperlinkLabelRadioButtonToggleButton。除了这些基本控制外,其他更高级的控制(将在本章后面介绍)也受益于Labeled,包括MenuButtonTitledPaneCell

Labeled最重要的属性 3 如表 4-1 所示。

表 4-1

标签类的属性

|

1 属性

|

类型

|

描述

|
| --- | --- | --- |
| alignment | ObjectProperty<Pos> | 指定文本和图形的对齐方式。 |
| contentDisplay | ObjectProperty<ContentDisplay> | 指定图形相对于文本的位置。 |
| font | ObjectProperty<Font> | 文本使用的默认字体。 |
| graphic | ObjectProperty<Node> | Labeled的可选图标。 |
| textAlignment | ObjectProperty<TextAlignment> | 指定多行文字时文字行的行为。 |
| text | StringProperty | 要在标签中显示的文本。 |
| wrapText | BooleanProperty | 指定超出宽度时文本是否应换行。 |

如上所述,因为Labeled类是抽象的,大多数开发人员不直接使用这个类。相反,它们使用 JavaFX 附带的一个具体子类,现在将更详细地介绍这个子类。

标签

因为Labeled非常全面,所以具体的Label类非常简单——它只增加了一个额外的 API。这被称为labelFor,用于使用助记符使用户界面控件的键盘导航更简单,以及为盲人和弱视者改善文本到语音的输出。当使用一个Label来描述另一个控件(例如一个Slider)时,最好通过说label.labelFor(slider)来将Label实例与Slider实例相关联。这意味着当焦点对准Slider控件时,面向全盲或部分盲人群的屏幕阅读软件可以读出Label的文本,以帮助向用户描述Slider的用途。

纽扣

Button类通过提供可点击的视觉启示,使用户能够执行一些动作。当用户点击一个按钮时,它变成“待命”,当鼠标被释放时,它“开火”,然后变成“解除待命”按钮最重要的属性如表 4-2 所示。

表 4-2

Button(和 ButtonBase)类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| armed | ReadOnlyBooleanProperty 4 | 指示用户当前是否正在单击按钮。 |
| cancelButton | BooleanProperty | 如果为真,按钮将处理Escape键的按下。 |
| defaultButton | BooleanProperty | 如果为真,按钮将处理Enter键的按下。 |
| onAction | ObjectProperty<EventHandler<ActionEvent>> | 触发Button时执行的回调。 |

关联的方法只有一种,那就是fire()法。可以调用这个方法以编程方式触发Button,从而导致关联的onAction事件被调用。更常见的情况是当用户直接点击按钮时,结果是一样的——触发按钮并调用安装的任何onAction事件处理程序。处理动作事件的代码如清单 4-1 所示。

var button = new Button("Click Me!");
button.setOnAction(event -> System.out.println("Button was clicked"));

Listing 4-1Creating a JavaFX Button instance that handles clicks by printing to the console

检验盒

通常,CheckBox使用户能够指定某事是真还是假。在 JavaFX 中这是可能的,但是也有能力显示第三种状态:indeterminate。默认情况下,JavaFX CheckBox只会在选中和未选中状态之间切换(这反映在selected属性中)。为了支持通过indeterminate状态的切换,开发人员必须将allowIndeterminate属性设置为 true。启用时,可以读取indeterminate属性和selected属性,以确定CheckBox的状态。

因为CheckBox是一个Labeled控件,所以它支持在复选框旁边显示textgraphic。只有几个非常重要的附加属性,如表 4-3 所示。清单 4-2 显示了复选框的典型用法。

表 4-3

复选框类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| allowIndeterminate | BooleanProperty | 确定CheckBox是否应该切换到不确定状态。 |
| indeterminate | BooleanProperty | 指定CheckBox当前是否不确定。 |
| selected | BooleanProperty | 指定当前是否选择了CheckBox。 |

CheckBox cb = new CheckBox("Enable Power Plant");
cb.setIndeterminate(false);
cb.setOnAction(e -> log("Action event fired"));
cb.selectedProperty()
    .addListener(i -> log("Selected state change to " + cb.isSelected()));

Listing 4-2Creating a CheckBox instance that is determinate (i.e., only toggles between selected and unselected)

超链接

Hyperlink控件本质上是一个Button控件,以超链接的形式呈现——带下划线的文本——就像人们期望在网站上看到的一样。因此,Hyperlink的 API 相当于Button类,只是增加了一个小的属性:一个visited属性来指示用户是否点击了链接,如表 4-4 所示。如果visited为真,开发者可以选择不同的Hyperlink样式。清单 4-3 显示了超链接的典型用法。

表 4-4

超链接类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| visited | BooleanProperty | 当用户第一次触发超链接时切换到 true。 |

var hyperlink = new Hyperlink("Click Me!");
hyperlink.setOnAction(event -> log("Hyperlink was clicked"));

Listing 4-3Creating a Hyperlink instance and listening for it to be clicked

开关按钮

ToggleButton是一个Button(意味着它仍然可以触发动作事件),但通常这不是最好的方法。这是因为ToggleButton的意图是toggle它的selected属性状态在被选中和未被选中之间,每次点击一次。当选择一个ToggleButton时,它的视觉外观是不同的,看起来像是被“推入”ToggleButton实例可以添加到ToggleGroup来控制选择。

什么是 ToggleGroup?

ToggleGroup是一个简单的类,它包含了一系列Toggle实例,它管理这些实例的选定状态。ToggleGroup保证一次最多只能选择一个Toggle

Toggle是一个具有两个属性的接口——selectedtoggleGroup。实现这个接口的类包括ToggleButtonRadioButtonRadioMenuItem

如何使用 ToggleButton 和 ToggleGroup?

归结起来就是,通过实例化一个ToggleGroup实例和多个ToggleButton实例,并将每个ToggleButton上的toggleGroup属性设置为单个ToggleGroup实例,一个ToggleButton可以与一个ToggleGroup相关联。这显示在清单 4-4 中。

在这样做的时候,这个组中的ToggleButton实例有一个附加的约束:任何时候只能选择一个ToggleButton。如果用户选择新的ToggleButton,先前选择的ToggleButton将被取消选择。当 ToggleButtons 被放置在ToggleGroup中时,没有被选中的ToggleButton实例有效(即被选中的ToggleButton可以不被选中)。ToggleButton 的主要属性如表 4-5 所示。

表 4-5

ToggleButton 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| selected | BooleanProperty | 指示是否选择了切换。 |
| toggleGroup | ObjectProperty<ToggleGroup> | 这个ToggleButton所属的ToggleGroup。 |

// create a few toggle buttons
ToggleButton tb1 = new ToggleButton("Toggle button 1");
ToggleButton tb2 = new ToggleButton("Toggle button 2");
ToggleButton tb3 = new ToggleButton("Toggle button 3");

// create a toggle group and add all the toggle buttons to it
ToggleGroup group = new ToggleGroup();
group.getToggles().addAll(tb1, tb2, tb3);
// it is possible to add an onAction listener for each button
tb1.setOnAction(e -> log("ToggleButton 1 was clicked on!"));
// but it is better to add a listener to the toggle group  selectedToggle property
group.selectedToggleProperty()
    .addListener(i -> log("Selected toggle is " + group.getSelectedToggle()));

Listing 4-4Creating three ToggleButtons and adding them to a single ToggleGroup and listening to selection changes

单选按钮

RadioButton是一个ToggleButton,应用了不同的样式,当放在ToggleGroup中时,行为也略有不同。虽然ToggleGroup中的 ToggleButtons 可以全部取消选择,但是对于ToggleGroup中的 radio button,用户没有办法取消选择所有的 radio button。这是因为,从视觉上看,一个RadioButton只能被点击进入选中状态。后续的点击没有影响(当然不会导致取消选择)。因此,取消选择一个RadioButton的唯一方法是在同一个ToggleGroup中选择一个不同的RadioButton

因为 RadioButton 的 API 本质上等同于 ToggleButton,所以请参考清单 4-4 中的 ToggleButton 代码示例。唯一的区别是用 RadioButton 实例替换 ToggleButton 实例。

文本输入控件

在简单的Labeled控件之后,下一组控件是主要用于文本输入的三个控件,即TextAreaTextFieldPasswordFieldTextField设计用于接收用户的单行输入,而TextArea设计用于接收多行输入。PasswordFieldTextField扩展而来,允许用户通过屏蔽用户输入来输入敏感信息。在所有这三种情况下,这些控件都不接受富文本输入(参见本章后面的HTMLEditor控件了解富文本输入的一个选项)。

TextAreaTextField从一个名为TextInputControl的抽象类扩展而来,该抽象类提供了一组基本功能,以及许多适用于这两个类的属性和方法(其中最重要的显示在表 4-6 中)。例如,TextInputControl支持插入符号定位(插入符号是一种闪烁的光标,指示文本输入将出现的位置)、文本选择和格式化,当然还有编辑。

表 4-6

TextInputControl 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| anchor | ReadOnlyIntegerProperty | 文本选择的锚点。锚点和插入符号之间的范围代表文本选择范围。 |
| caretPosition | ReadOnlyIntegerProperty | 插入符号在文本中的当前位置。 |
| editable | BooleanProperty | 用户是否可以编辑控件中的文本。 |
| font | ObjectProperty<Font> | 用于呈现文本的字体。 |
| length | ReadOnlyIntegerProperty | 在控件中输入的字符数。 |
| promptText | StringProperty | 没有用户输入时显示的文本。 |
| selectedText | ReadOnlyStringProperty | 通过鼠标、键盘或编程方式在控件中选择的文本。 |
| textFormatter | ObjectProperty<TextFormatter<?>> | 请参见“文本格式化程序”一节 |
| text | StringProperty | 该控件的文本内容。 |

文本格式化程序

在我们深入研究具体的控件之前,我们将首先快速转移话题,讨论一下前文提到的TextFormatter API。一个TextFormatter有两种不同的机制,使它能够影响文本输入控件中接受和显示的内容:

  1. 可以截取和修改用户输入的过滤器。这有助于保持文本的理想格式。可以使用默认的文本提供者来提供初始文本。

  2. 值转换器和值可用于提供表示 v 类型值的特殊格式,如果控件是可编辑的,并且用户更改了文本,则值会更新以对应于文本。

有可能只有一个过滤器或值转换器的格式化程序。然而,如果没有提供值转换器,设置一个值将导致一个IllegalStateException,并且该值总是空的。

文本字段、密码字段和文本区域

如前所述,TextField控件用于从用户处接收单行无格式文本。这对于请求用户名、电子邮件地址等的表单非常理想。两个关键属性是textonActiontext属性已经讨论过了,因为它是从TextInputControl继承而来的,而onAction的功能正如我们已经讨论过的Button和类似的类一样:当 Enter 键被按下时,TextField会发出一个ActionEvent信号,提醒开发人员用户已经选择“提交”他们的输入。清单 4-5 展示了使用文本字段控件的标准方法。

TextField textField = new TextField();
textField.setPromptText("Enter name here");

// this is fired when the user hits the Enter key
textField.setOnAction(e -> log("Entered text is: " + textField.getText()));

// we can also observe input in real time
textField.textProperty()
    .addListener((o, oldValue, newValue) -> log("current text input is " + newValue));

Listing 4-5Creating and using a TextField control

PasswordField 的功能与 TextField 完全相同,只是它隐藏了用户输入,因此在一定程度上防止了用户背后的窥探。此外,出于安全原因,PasswordField 不支持剪切和复制操作(但是粘贴仍然有效)。PasswordField 上没有其他属性或 API。

TextArea 控件是为多行用户输入设计的,但同样只支持无格式文本。TextArea 控件最适合在不需要单行输入的情况下使用。例如,如果你想让你的用户提供反馈(可能跨越多个句子或段落),文本区域是最好的选择。因为 TextArea 是为多行输入设计的,所以有一些有用的属性值得熟悉,如表 4-7 所示。

表 4-7

TextArea 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| prefColumnCount | IntegerProperty | 文本列的首选数量。 |
| prefRowCount | IntegerProperty | 首选文本行数。 |
| wrapText | BooleanProperty | 当一行超出可用宽度时,是换行还是让文本区域水平滚动。 |

其他简单控件

除了Labeled控件和文本输入控件之外,还有另外三个可以被认为是“简单”的控件:ProgressBarProgressIndicatorSlider

进度条和进度条指示器

JavaFX 提供了两个向用户显示进度的 UI 控件:ProgressBarProgressIndicator。就 API 而言,它们非常接近,因为ProgressBar扩展了ProgressIndicator并且没有增加额外的 API。ProgressIndicator最重要的属性如表 4-8 所示。

这两个控件都可以用来显示进度,或者可以设置为不确定的状态,以向用户指示工作正在进行,但此时进度未知。

为了显示进度,开发人员应该将progress属性设置为 0.0 到 1.0 之间的值。这乍看起来可能违反直觉——为什么使用 0.0 到 1.0 之间的范围,而不是 0-100 的范围?答案并不清楚,这是否是整个 JavaFX UI 工具包在处理百分比时有意识的设计选择。要使进度控件切换到不确定的形式,只需将progress属性值设置为–1。当这个操作完成后,indeterminate属性将从假变为真。

清单 4-6 中显示了一个简单的使用示例。

表 4-8

ProgressIndicator 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| indeterminate | ReadOnlyBooleanProperty | 一个布尔标志,指示不确定进度动画是否正在播放。 |
| progress | DoubleProperty | 实际进度(在 0.0 和 1.0 之间),或者可以设置为–1 表示不确定。 |

ProgressBar p2 = new ProgressBar();
p2.setProgress(0.25F);

Listing 4-6Creating a ProgressBar that will show 25% progress

滑块

滑块控件用于使用户能够在某个最小/最大范围内指定一个值。这是通过向用户显示“轨迹”和“拇指”来实现的用户可以拖动滑块来更改值。因此毫不奇怪,滑块控件的三个最重要的属性是它的minmaxvalue属性,如表 4-9 所示。清单 4-7 显示了一个使用滑块的简单例子。

表 4-9

Slider 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| blockIncrement | DoubleProperty | 点击轨道时Slider移动的幅度。 |
| max | DoubleProperty | Slider所代表的最大值。 |
| min | DoubleProperty | 由Slider表示的最小值。 |
| orientation | ObjectProperty<Orientation> | Slider是水平还是垂直。 |
| value | DoubleProperty | 由Slider表示的当前值。 |

Slider slider = new Slider(0.0f, 1.0f, 0.5f);
slider.valueProperty()
    .addListener((o, oldValue, newValue) -> log("Slider value is " + newValue));

Listing 4-7Creating a slider that will have a range between 0.0 and 1.0

容器控件

现在我们已经学习了简单的 UI 控件,我们可以继续学习一些更令人兴奋的控件。本节将讨论“容器”控件,即用于包含和显示其他用户界面元素的控件。这些容器控件提供了一些额外的功能,可以折叠它们的内容,提供一个选项卡式的界面来改变视图,或者其他功能。

手风琴和标题面板

TitledPane是一个显示标题区域和内容区域的容器,能够通过单击标题区域来展开和折叠内容区域。这对于用户界面中的侧面板等是有用的,因为它允许信息被显示,但是可选地被用户折叠,使得他们只看到他们需要看到的。

TitledPaneLabeled扩展而来,所以正如我们之前讨论的,有大量的属性可以定制显示。但是应该注意,这些Labeled属性只应用于TitledPane的标题区域,而不是内容区域。标题面板的主要性能如表 4-10 所示。

表 4-10

TitledPane 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| animated | BooleanProperty | 当TitledPane展开和折叠时是否有动画。 |
| collapsible | BooleanProperty | 用户是否可以折叠TitledPane。 |
| content | ObjectProperty<Node> | 在TitledPane的内容区域显示的节点。 |
| expanded | BooleanProperty | TitledPane当前是否展开。 |
| text | StringProperty | 显示在TitledPane标题区域的文本。 |

随着TitledPane的引入,我们可以继续关注Accordion,这是一个简单的包含零个或多个 TitledPanes 的控件。当一个Accordion显示给用户时,它只允许一个TitledPane在任何时候展开。展开不同的TitledPane将导致当前展开的TitledPane被折叠。

只有一个值得注意的属性——expandedPane——即代表当前扩展的TitledPaneObjectProperty<TitledPane>,如表 4-11 所示。

表 4-11

Accordion 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| expandedPane | ObjectProperty<TitledPane> | Accordion中当前展开的TitledPane。 |

为了将标题窗格添加到Accordion中,我们使用getPanes()方法来检索标题窗格的ObservableList,并将适用的标题窗格添加到该列表中。这样做的结果是标题窗格将按照它们在列表中出现的顺序垂直堆叠显示。清单 4-8 中显示了一个代码示例。

TitledPane t1 = new TitledPane("TitledPane 1", new Button("Button 1"));
TitledPane t2 = new TitledPane("TitledPane 2", new Button("Button 2"));
TitledPane t3 = new TitledPane("TitledPane 3", new Button("Button 3"));
Accordion accordion = new Accordion();
accordion.getPanes().addAll(t1, t2, t3);

Listing 4-8Creating three TitledPanes and adding them all to a single Accordion

按钮栏

ButtonBar控件是在 JavaFX 8u40 版本中添加的,所以它相对较新,相对不为人知。ButtonBar可以被认为本质上是Button控件的HBox(尽管它可以与任何Node一起工作),增加的功能是为运行用户界面的操作系统按正确的顺序放置所提供的按钮。这对于对话框非常有用,例如,Windows、macOS 和 Linux 都有不同的按钮顺序。有少量有用的属性,如表 4-12 所示,清单 4-9 演示了如何创建和填充一个ButtonBar实例。

表 4-12

ButtonBar 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| buttonMinWidth | DoubleProperty | 放置在ButtonBar中的所有按钮的最小宽度。 |
| buttonOrder | StringProperty | ButtonBar中按钮的排序。 |

// Create the ButtonBar instance
ButtonBar buttonBar = new ButtonBar();

// Create the buttons to go into the ButtonBar
Button yesButton = new Button("Yes");
ButtonBar.setButtonData(yesButton, ButtonData.YES);

Button noButton = new Button("No");
ButtonBar.setButtonData(noButton, ButtonData.NO);

// Add buttons to the ButtonBar
buttonBar.getButtons().addAll(yesButton, noButton);

Listing 4-9Creating a ButtonBar with “Yes” and “No” buttons. Ordering will depend on the operating system that this code is executed on

滚动窗格

ScrollPane是一个对几乎每个用户界面都至关重要的控件——当内容超出用户界面边界时,能够水平和垂直滚动。例如,想象一个图像处理程序,如 Adobe Photoshop。在这个用户界面中,您可以放大到绘图的一小部分,水平和垂直滚动条允许您移动这一部分以查看相邻的部分。

与其他一些 UI 工具包不同,没有必要用ScrollPane来包装 UI 控件,如ListViewTableView等,因为它们有内置的滚动功能,并由开发人员来处理。因此,ScrollPane通常由开发人员在做一些相对定制的事情时使用。清单 4-10 给出了一个例子,表 4-13 给出了 ScrollPane 的属性。

表 4-13

ScrollPane 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| content | ObjectProperty<Node> | 要显示的节点。 |
| fitToHeight | BooleanProperty | 将尝试调整内容大小以匹配视窗高度。 |
| fitToWidth | BooleanProperty | 将尝试调整内容大小以匹配视窗的宽度。 |
| hbarPolicy | ObjectProperty<ScrollBarPolicy> | 设置何时显示水平滚动条的策略。 |
| hmax | DoubleProperty | 最大允许值。 |
| hmin | DoubleProperty | 允许的最小值。 |
| hvalue | DoubleProperty | ScrollPane的当前水平位置。 |
| vbarPolicy | ObjectProperty<ScrollBarPolicy> | 设置何时显示垂直滚动条的策略。这可以是 ScrollPane 中的枚举常量之一。ScrollBarPolicy:始终、按需或从不。 |
| vmax | DoubleProperty | 允许的最大值。 |
| vmin | DoubleProperty | 允许的最小值。 |
| vvalue | DoubleProperty | ScrollPane的当前垂直位置。 |

// in this sample we create a linear gradient to make the scrolling visible
Stop[] stops = new Stop[] { new Stop(0, Color.BLACK), new Stop(1, Color.RED)};
LinearGradient gradient = new LinearGradient(0, 0, 1500, 1000, false, CycleMethod.NO_CYCLE, stops);
// we place the linear gradient inside a big rectangle
Rectangle rect = new Rectangle(2000, 2000, gradient);
// which is placed inside a scrollpane that is quite small in comparison
ScrollPane scrollPane = new ScrollPane();
scrollPane.setPrefSize(120, 120);
scrollPane.setContent(rect);
// and we then listen (and log) when the user is scrolling vertically or horizontally
ChangeListener<? super Number> o = (obs, oldValue, newValue) -> {
    log("x / y values are: (" + scrollPane.getHvalue() + ", " + scrollPane.getVvalue() + ")");
};
scrollPane.hvalueProperty().addListener(o);
scrollPane.vvalueProperty().addListener(o);

Listing 4-10Creating a ScrollPane instance

分屏

SplitPane控件接受两个或更多的孩子,并用可拖动的分隔线将他们画出来。然后,用户可以使用这个分隔器给一个孩子更多的空间,代价是占用另一个孩子的空间。一个SplitPane控件非常适合有一个主要内容区域的用户界面,然后在内容区域的左/右/底部有一个区域被用来显示更多上下文相关的信息。在这种情况下,用户可以根据需要给主要内容区域或特定于上下文的区域留出额外的空间。

历史上,UI 工具包只支持两个子代(即“左”和“右”或“上”和“下”),但是 JavaFX 取消了这一限制,允许无限数量的子代,只有一个限制:所有子代必须具有相同的分隔线方向。这意味着一个SplitPane对于所有分隔线只有一个方向属性(如表 4-14 所示)。然而,有一种方法可以解决这个问题:简单地将SplitPane实例嵌入到另一个实例中,这样最终的结果是由水平和垂直方向上的分隔线按照期望的顺序操作组成。

表 4-14

jsplitpane 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| orientation | ObjectProperty<Orientation> | 拆分窗格的方向。 |

SplitPane控件观察其子控件的最小和最大尺寸属性。它永远不会将节点的大小减小到其最小大小以下,也不会给它提供超过其最大大小的大小。因此,建议将添加到SplitPane的所有节点包装在单独的布局容器中,这样布局容器可以处理节点的大小调整,而不会影响 SplitPane 的功能。

分隔线的位置范围从 0 到 1.0(包括 0 和 1.0)。位置 0 将把分隔线放在SplitPane的左/顶端加上节点的最小尺寸。位置 1.0 会将分割线放置在SplitPane的最右/最下边缘减去节点的最小尺寸。分割器位置为 0.5 会将分割器放置在SplitPane的中间。将分隔线位置设置为大于节点的最大大小位置将导致分隔线设置在节点的最大大小位置。将分隔线位置设置为小于节点的最小大小位置将导致分隔线设置在节点的最小大小位置。

清单 4-11 显示了一个创建 SplitPane 的例子。

final StackPane sp1 = new StackPane();
sp1.getChildren().add(new Button("Button One"));

final StackPane sp2 = new StackPane();
sp2.getChildren().add(new Button("Button Two"));

final StackPane sp3 = new StackPane();
sp3.getChildren().add(new Button("Button Three"));

SplitPane splitPane = new SplitPane();
splitPane.getItems().addAll(sp1, sp2, sp3);
splitPane.setDividerPositions(0.3f, 0.6f, 0.9f);

Listing 4-11Creating a SplitPane instance with three children (and therefore two dividers)

塔帕布

是一个 UI 控件,可以向用户显示选项卡式界面。例如,你们中的大多数人都熟悉首选 web 浏览器中的选项卡式界面,因此不需要打开多个窗口——每个窗口对应一个想要打开的页面。

表 4-15 概述了最重要的属性,但两个最有用的属性是side属性和tabClosingPolicy属性。side属性用于指定标签页将显示在TabPane的哪一侧(默认为Side.TOP,这意味着标签页将位于TabPane的顶部)。tabClosingPolicy用于指定用户是否可以关闭标签——有一个TabClosingPolicy枚举,有三个有效值:

  1. UNAVAILABLE:用户不能关闭标签页。

  2. SELECTED_TAB:当前选中的标签页在标签页区域会有一个小的关闭按钮(显示为小“x”)。当选择不同的选项卡时,关闭按钮将从先前选择的选项卡中消失,而显示在新选择的选项卡上。

  3. ALL_TABS:所有在TabPane中可见的标签页都会有一个小的关闭按钮。

JavaFX TabPane通过暴露一个Tab实例的ObservableList来工作。每个Tab实例由一个title属性和一个content属性组成。当一个Tab被添加到tabs列表中时,它将按照在列表中出现的顺序显示在用户界面中。清单 4-12 展示了这一点。

表 4-15

tabpage 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| rotateGraphic | BooleanProperty | 当选项卡放置在左侧/右侧时,图形是否应该旋转以正确显示。 |
| selectionModel | ObjectProperty<SingleSelectionModel> | 在TabPane中使用的选择模型。 5 |
| side | ObjectProperty<Side> | 将显示选项卡的位置。 |
| tabClosingPolicy | ObjectProperty<TabClosingPolicy> | 如前文所述。 |

TabPane tabPane = new TabPane();
tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE);

for (int i = 0; i < 5; i++) {
    Tab tab = new Tab("Tab " + I, new Rectangle(200, 200, randomColor()));
    tabPane.getTabs().add(tab);
}

Listing 4-12How to instantiate and use a TabPane

工具栏

ToolBar控件是一个非常简单的 UI 控件。在其最常见的排列中,它可以被认为是一种风格化的HBox——也就是说,它以背景渐变的方式水平呈现添加到它上面的任何节点。添加到ToolBar中最常见的元素是其他 UI 控件,如ButtonToggleButtonSeparator,但是对于在ToolBar中可以放置什么没有限制,只要它是Node

ToolBar控件确实提供了一项有用的功能——它支持溢出的概念,因此,如果要显示的元素多于显示所有元素的空间,它会从ToolBar中删除“溢出”的元素,并显示一个溢出按钮,单击该按钮会弹出一个包含所有ToolBar溢出元素的菜单。

如表 4-16 所述,ToolBar提供了垂直方向,因此它可以放置在应用程序用户界面的左侧或右侧,尽管这不如放置在用户界面顶部常见,通常就在菜单栏下方。

创建工具栏的例子如清单 4-13 所示。

表 4-16

ToolBar 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| orientation | ObjectProperty<Orientation> | ToolBar应该是水平还是垂直。 |

ToolBar toolBar = new ToolBar();
toolBar.getItems().addAll(
    new Button("New"),
    new Button("Open"),
    new Button("Save"),
    new Separator(),
    new Button("Clean"),
    new Button("Compile"),
    new Button("Run"),
    new Separator(),
    new Button("Debug"),
    new Button("Profile")
);

Listing 4-13Instantiating a ToolBar with multiple Button and Separator instances

其他控制

html 编辑器

HTMLEditor控件使用户能够创建内部格式化为 HTML 内容的富文本输入。控件提供了许多用户界面控件来指定字体大小、颜色和类型,以及对齐方式等。

关于HTMLEditor控件需要注意的一点是,因为它依赖于 JavaFX WebView组件来呈现用户输入,所以这个控件不包含在javafx.controls模块中,而是包含在javafx.web模块中,并且可以在javafx.scene.web包中找到它。

尽管向最终用户提供了大量的功能,但是使用HTMLEditor的开发人员所能获得的 API 却少得惊人。没有相关的属性,唯一相关的方法是htmlText的 getter 和 setter 方法。这些方法使用一个String进行操作,并且期望这个String包含有效的 HTML。66

页码

理解分页控件最简单的方法是想象 Google 搜索结果页面,页面底部是“Gooooooooogle”文本和数字“1,2,3,…10”这些数字中的每一个都代表一页结果,用户可以点击它们进入该页。重要的是,Google 不会预先确定要放在当前页面之外的任何页面上的元素——其他页面只有在被请求时才会被确定。

这正是 JavaFX 中分页所提供的功能。分页类的关键属性如表 4-17 所示。分页是一种表示多个页面的抽象方式,其中只有当前显示的页面实际存在于场景图中,所有其他页面都是根据请求生成的。

这是我们在本章中第一次遇到利用 JavaFX UI 控件中的“回调”功能的情况。这是在pageFactory中使用的,允许根据用户的请求按需生成页面。随着本章的深入,我们将会更多地遇到这种方法,所以值得花时间来确保您理解清单 4-14 中发生了什么,尤其是在设置了pageFactory的地方。

表 4-17

分页类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| currentPageIndex | IntegerProperty | 正在显示的当前页面索引。 |
| pageCount | IntegerProperty | 可显示的总页数。 |
| pageFactory | ObjectProperty<Callback<Integer,Node>> | 回调函数,返回对应于给定索引的页面。 |

Pagination pagination = new Pagination(10, 0);
pagination.setPageFactory(pageIndex -> {
    VBox box = new VBox(5);
    for (int i = 0; i < 10; i++) {
        int linkNumber = pageIndex * 10 + i;
        Hyperlink link = new Hyperlink("Hyperlink #" + linkNumber);
        link.setOnAction(e -> log("Hyperlink #" + linkNumber + " clicked!"));
        box.getChildren().add(link);
    }
    return box;
});

Listing 4-14Instantiating a Pagination control with ten pages

滚动条

ScrollBar控件本质上是一个样式不同的Slider控件。它包括一个可以移动拇指的轨道,以及两端用于递增和递减值(从而移动拇指)的按钮。ScrollBar通常不在与Slider相同的环境中使用——相反,它通常被用作更复杂的 UI 控件的一部分。例如,它用在ScrollPane控件中以支持垂直和水平滚动,并且用在稍后讨论的ListViewTableViewTreeViewTreeTableView控件中。

表 4-18 介绍了ScrollBar控件最重要的属性。

表 4-18

ScrollBar 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| blockIncrement | DoubleProperty | 点按轨道时拇指移动的程度。 |
| max | DoubleProperty | 最大允许值。 |
| min | DoubleProperty | 允许的最小值。 |
| orientation | ObjectProperty<Orientation> | ScrollBar是水平还是垂直。 |
| unitIncrement | DoubleProperty | 调用 increment/decrement 方法时值的调整量。 |
| value | DoubleProperty | ScrollBar的当前值。 |

分离器

Separator控件可能是整个 JavaFX UI 工具包中最简单的控件。它是一个缺乏任何交互性的控件,只是被设计成在用户界面的相关部分画一条线。例如,这通常在ToolBar控件中用于将按钮分组为子组。在弹出菜单中使用类似的方法,但是如前所述,在菜单的情况下,需要使用SeparatorMenuItem,而不是这里讨论的标准Separator控件。

默认情况下,Separator是垂直定向的,这样当放置在水平的ToolBar中时就可以正确绘制。这可以通过修改orientation属性来控制。

纺纱机

在 JavaFX 8u40 中,Spinner控件是最近才引入 JavaFX 的。一个Spinner可以被认为是一个单行的TextField,它可能是可编辑的,也可能是不可编辑的,增加了增量和减量箭头来遍历一些值。表 4-19 介绍了这种控制的最关键属性。

因为一个Spinner可以用于遍历各种类型的值(integerfloatdouble,甚至是某种类型的List),所以Spinner遵从一个SpinnerValueFactory来处理遍历值范围的实际过程(以及精确地如何遍历)。JavaFX 附带了许多内置的SpinnerValueFactory类型(用于doublesintegersLists),并且可以根据定制需求编写定制的SpinnerValueFactory实例。清单 4-15 中的代码示例演示了整数值工厂,双精度和列表值工厂以相同的方式运行。

表 4-19

Spinner 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| editable | BooleanProperty | 用户是否能够键入文本输入。 |
| editor | ReadOnlyObjectProperty<TextField> | Spinner所使用的编辑器控件。 |
| promptText | StringProperty | 没有用户输入时显示的提示文本。 |
| valueFactory | ObjectProperty<SpinnerValueFactory<T>> | 如前文所述。 |
| value | ReadOnlyObjectProperty<T> | 用户选择的值。 |

Spinner<Integer> spinner = new Spinner<>();
spinner.setValueFactory(new SpinnerValueFactory.IntegerSpinnerValueFactory(5, 10));

spinner.valueProperty().addListener((o, oldValue, newValue) -> {
        log("value changed: '" + oldValue + "' -> '" + newValue + "'");
});

Listing 4-15Creating a Spinner with an integer value factory

工具提示

工具提示是常见的 UI 元素,通常用于当鼠标悬停在Node上时,在场景图中显示关于Node的附加信息。任何Node都可以显示工具提示。在大多数情况下,会创建一个Tooltip,并修改它的text属性以向用户显示纯文本。然而,Tooltip能够在其中显示任意的节点场景图——这是通过创建场景图并在 tooltip graphic属性中设置它来实现的。

您可以使用清单 4-16 中所示的方法在任何节点上设置工具提示。

Rectangle rect = new Rectangle(0, 0, 100, 100);
Tooltip t = new Tooltip("A Square");
Tooltip.install(rect, t);

Listing 4-16Adding a tooltip to any Node in the JavaFX scene graph

然后,该工具提示将参与典型的工具提示语义(即,在悬停时出现等等)。注意,Tooltip不需要卸载:当它没有被任何Node引用时,它将被垃圾收集。然而,也可以用同样的方式手动卸载工具提示。

单个工具提示可以安装在多个目标节点或多个控件上。

因为大多数工具提示都显示在 UI 控件上,所以所有控件都有特殊的 API 来减少安装Tooltip的麻烦。清单 4-17 中的例子展示了如何为Button控件创建工具提示。

Button button = new Button("Hover Over Me");
button.setTooltip(new Tooltip("Tooltip for Button"));

Listing 4-17Adding a tooltip to a UI control using convenience API

工具提示类的关键属性如表 4-20 所示。

表 4-20

工具提示类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| graphic | ObjectProperty<Node> | 要在工具提示弹出窗口中显示的图标或任意复杂的场景图形。 |
| text | StringProperty | 要在工具提示弹出窗口中显示的文本。 |
| wrapText | BooleanProperty | 当文本超出工具提示宽度时是否换行。 |

弹出控件

JavaFX 附带了一套全面的“弹出”控件。这意味着,在幕后,它们被放置在它们自己的窗口中,该窗口独立于用户界面的主阶段,因此,它们可能出现在窗口之外,无论它们比窗口本身更高还是更宽。开发人员不需要关心位置、大小或与此相关的任何细节,但是理解这些细节是很有用的。

本节涵盖了 JavaFX 中使用该弹出功能的所有 UI 控件。对于它们中的许多来说,它们也利用相同的 API 来构建菜单,所以在依次讨论每个控件之前,我们将首先讨论这个公共功能。

基于菜单的控件

菜单和菜单项

在 JavaFX 中构建菜单从MenuMenuItem类开始。值得注意的是,这两个类都没有真正扩展Control,这是因为它们被设计用来表示菜单结构,但是实现是由 JavaFX 在幕后处理的。

MenuItem的行为方式与Button基本相同。它支持一组相似的属性—textgraphiconAction。除此之外,它还增加了对指定键盘快捷键的支持(例如,Ctrl+C)。这些详见表 4-21 。

因为MenuItem只是从Object扩展而来,它本身是没有用的,不能以标准方式添加到 JavaFX 用户界面中。使用MenuItem的方式是通过Menu类,它充当MenuItem实例的容器。Menu类有一个getItems()方法,它以大多数其他 JavaFX APIs 的标准方式工作——开发人员将MenuItem实例添加到getItems()方法中,然后每当Menu向用户显示时,这些项目就会显示在其中。

这引出了几个重要的问题:

  1. Java FX 如何支持嵌套菜单(即一个菜单包含一个子菜单,子菜单本身可能包含更多子菜单)?这可以简单地通过Menu类本身从MenuItem扩展来处理。这意味着每当 API 允许一个MenuItem,它也隐含地支持Menu

  2. Java FX 如何支持带有复选框或单选状态的菜单项? JavaFX 附带了两个子类——CheckMenuItemRadioMenuItem——支持这一点。CheckMenuItem有一个selected属性,每当用户点击菜单项时,该属性将在 true 和 false 之间切换。RadioMenuItem的功能与RadioButton类似——它应该与一个ToggleGroup相关联,然后 JavaFX 将强制每次最多选择一个RadioMenuItem

  3. 如何将菜单项分组?在用户界面中处理这种情况的常见方式是使用分隔符。正如本章前面提到的,不可能将一个Separator直接添加到一个Menu中(因为它不是从MenuItem扩展而来的),因此 JavaFX 附带了SeparatorMenuItem,它将一个Separator放置到Menu中的位置,即SeparatorMenuItem在菜单项列表中的位置。

  4. 自定义菜单元素呢?例如,如果我想在菜单中显示滑块或文本字段,该怎么办? JavaFX 通过CustomMenuItem类支持这一点。通过使用这个类,开发人员可以将任意的Node嵌入到content属性中。

表 4-21

MenuItem 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| accelerator | ObjectProperty<KeyCombination> | 访问此菜单项的键盘快捷键。 |
| disable | BooleanProperty | 菜单项是否应该是用户交互的。 |
| graphic | ObjectProperty<Node> | 显示在菜单项文本左侧的图形。 |
| onAction | ObjectProperty<EventHandler<ActionEvent>> | 单击菜单项时要调用的事件处理程序。 |
| text | StringProperty | 要在菜单项中显示的文本。 |
| visible | BooleanProperty | 菜单项在菜单中是否可见。 |

菜单条

到目前为止,我们已经讨论了指定一个Menu所需的 API,但是没有讨论如何将它显示给用户。到目前为止,将菜单添加到 JavaFX 用户界面的最常见方式是通过MenuBar控件。这个类传统上被放在用户界面的顶部(例如,如果使用了一个BorderLayout,它通常被设置为顶部节点),它的构造简单,只需创建一个实例,然后将Menu实例添加到调用getMenus()返回的列表中。

在某些操作系统上(尤其是 macOS),通常很少会在应用程序窗口的顶部看到菜单栏,因为 macOS 在屏幕的最顶部有一个“系统菜单栏”。该系统菜单栏是特定于应用程序上下文的,因为每当聚焦的应用程序改变时,它就改变其内容。JavaFX 支持这一点,MenuBar类有一个useSystemMenuBar属性,如果设置为 true,将从应用程序窗口中删除MenuBar,转而使用系统菜单栏本地呈现菜单栏。这将在有系统菜单栏(macOS)的平台上自动发生,但对没有系统菜单栏的平台没有影响(在这种情况下,MenuBar将定位在用户界面中,无论它是由应用程序开发人员指定出现的)。

清单 4-18 展示了如何创建一个带有菜单和菜单项的MenuBar

// Firstly we create our menu instances (and populate with menu items)
final Menu fileMenu = new Menu("File");
final Menu helpMenu = new Menu("Help");

// we are creating a Menu here to add as a submenu to the File menu
Menu newMenu = new Menu("Create New...");
newMenu.getItems().addAll(
        makeMenuItem("Project", console),
        makeMenuItem("JavaFX class", console),
        makeMenuItem("FXML file", console)
);
// add menu items to each menu
fileMenu.getItems().addAll(
        newMenu,
        new SeparatorMenuItem(),
        makeMenuItem("Exit", console)
);
helpMenu.getItems().addAll(makeMenuItem("Help", console));
// then we create the MenuBar instance and add in the menus
MenuBar menuBar = new MenuBar();
menuBar.getMenus().addAll(fileMenu, helpMenu);

Listing 4-18Creating a MenuBar with two menus (the first of which has a submenu)

菜单按钮和拆分菜单按钮

JavaFX 应用程序中向用户显示菜单的另一种方式是通过MenuButtonSplitMenuButton类。这些类关系非常密切,但是它们的工作方式稍有不同,所以我们将在下面分别介绍它们。

MenuButton是一个类似按钮的控件,无论何时点击它,都会显示一个包含所有添加到项目列表中的MenuItem元素的菜单。因为MenuButton类扩展自ButtonBase(?? 本身扩展自Labeled),所以与 JavaFX Button控件有大量的 API 重叠。比如MenuButton有相同的onAction事件,还有textgraphic属性等等。但是请注意,对于MenuButton,设置onAction没有任何效果,因为MenuButton不会触发onAction事件,因为这是用来显示弹出窗口的。表 4-22 概述了 MenuItem 引入的属性,清单 4-19 演示了如何在代码中使用 MenuButton。

SplitMenuButton扩展了MenuButton类,但与MenuButton不同的是,SplitMenuButton的视觉效果将按钮本身分成两部分——一个“动作”区域和一个“菜单打开”区域。当用户点击“动作”区域时,SplitMenuButton实质上就像是一个Button一样——执行与onAction属性相关的任何代码。当用户点击“菜单打开”区域时,弹出菜单被显示,用户可以像往常一样与菜单交互。清单 4-20 演示了如何在代码中使用 SplitMenuButton。

表 4-22

MenuButton 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| popupSide | ObjectProperty<Side> | 上下文菜单应该显示在相对于按钮的一侧。 |

SplitMenuButton splitMenuButton = new SplitMenuButton();
// this is the text in the 'action' area
splitMenuButton.setText("Perform action!");
// these are the menu items to display in the popup menu
splitMenuButton.getItems().addAll(
        makeMenuItem("Burgers", console),
        makeMenuItem("Pizza", console),
        makeMenuItem("Hot Dog", console));
// splitMenuButton does fire an onAction event,
// when the 'action' area is pressed
splitMenuButton.setOnAction(e -> log("SplitMenuButton onAction event"));

Listing 4-20An example of using SplitMenuButton

MenuButton menuButton = new MenuButton("Choose a meal...");
menuButton.getItems().addAll(
        makeMenuItem("Burgers", console),
        makeMenuItem("Pizza", console),
        makeMenuItem("Hot Dog", console));
// because the MenuButton does not have an 'action' area,
// onAction does nothing
menuButton.setOnAction(e -> log("MenuButton onAction event"));

Listing 4-19An example of using MenuButton

上下文菜单

是一个包含菜单项的弹出控件。这意味着它永远不会被添加到场景图中,而是被直接调用(通过两个show()方法)或作为用户使用普通鼠标或键盘操作请求显示上下文菜单的结果(最常见的是通过按鼠标右键)。

为了尽可能容易地指定和显示上下文菜单,根Control类有一个contextMenu属性。当某些事件发生时(例如,按下鼠标右键),UI 控件被配置为检查是否指定了上下文菜单,如果是,则自动显示它。例如,Tab类就有一个这样的contextMenu属性,每当用户右键单击TabPane内的Tab,就会出现开发者指定的上下文菜单。清单 4-21 显示了一个设置了上下文菜单的按钮,每当鼠标右键被按下时就会显示出来。

// create a standard JavaFX Button
Button button = new Button("Right-click Me!");
button.setOnAction(event -> log("Button was clicked"));
// create a ContextMenu
ContextMenu contextMenu = new ContextMenu();
contextMenu.getItems().addAll(
        makeMenuItem("Hello", console),
        makeMenuItem("World!", console),
        new SeparatorMenuItem(),
        makeMenuItem("Goodbye Again!", console)
);

Listing 4-21Specifying a ContextMenu and adding it to a Button instance

在某些情况下,我们希望在一个没有从Control扩展的类上显示一个ContextMenu。在这些情况下,我们可以简单地利用ContextMenu上的两个show()方法之一,在相关事件发生时显示它。有两种show()方法可用:

  1. show(Node anchor, double screenX, double screenY):该方法将在指定的屏幕坐标显示上下文菜单。

  2. show(Node anchor, Side side, double dx, double dy):该方法将在指定锚节点的指定侧(顶部、右侧、底部或左侧)显示上下文菜单,x 轴和 y 轴的移动量分别由dxdy指定(还要注意,如果需要的话dxdy可以是负数,但最常见的是这些值可以是零)。

通过将这两个 show 方法中的一个与适当的事件处理程序(特别是 onContextMenuRequested 和 onMousePressed API,在所有 JavaFX 节点子类上都可用)结合使用,我们可以获得想要的结果。清单 4-22 展示了如何在 JavaFX Rectangle 类上显示 ContextMenu(缺少 Control 子类的 setContextMenu API)。

Rectangle rectangle = new Rectangle(50, 50, Color.RED);
rectangle.setOnContextMenuRequested(e -> {
    // show the contextMenu to the right of the rectangle with zero
    // offset in x and y directions
    contextMenu.show(rectangle, Side.RIGHT, 0, 0);
});

Listing 4-22Adding a ContextMenu to a JavaFX Rectangle by manually showing it when requested

选择框

ChoiceBox是一个 JavaFX UI 控件,单击时显示弹出菜单,但它不是通过MenuItem实例构造的。相反,ChoiceBox是一个泛型类(例如,ChoiceBox<T>,其中类的类型也是用于items列表的类型。换句话说,不是让用户指定菜单项,而是用零个或多个 T 类型的对象来构造一个ChoiceBox,这些是在弹出菜单中显示给用户的内容。

因为 T 类的默认toString()方法可能不合适或者过于易读,ChoiceBox支持converter属性的概念(属于StringConverter<T>类型)。如果指定了一个converter,那么ChoiceBox将从items列表(类型为 T)中获取每个元素,并通过converter传递,这将返回一个更易于阅读的字符串,显示在弹出菜单中。

当用户在ChoiceBox中做出选择时,value属性将被更新以反映这个新的选择。当value属性改变时,ChoiceBox控件也会触发一个onAction ActionEvent,因此开发人员可以选择是观察value属性还是添加一个onAction事件处理程序。

由于ChoiceBox的 UI 设计,这个控件最适合相对较小的元素列表。如果要显示的元素数量很大,通常建议开发人员使用ComboBox控件。

ChoiceBox 的主要属性在表 4-23 中列出,它们的使用在清单 4-23 中演示。

表 4-23

ChoiceBox 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| converter | ObjectProperty<StringConverter<T>> | 允许一种方法来转换项目列表的可视化表示。 |
| items | ObjectProperty<ObservableList<T>> | 要在ChoiceBox中显示的项目。 |
| selectionModel | ObjectProperty<SingleSelectionModel<T>> | ChoiceBox的选择模式。 |
| showing | ReadOnlyBooleanProperty | 指示ChoiceBox弹出窗口是否可见。 |
| value | ObjectProperty<T> | ChoiceBox中的当前选择。 |

ChoiceBox<String> choiceBox = new ChoiceBox<>();
choiceBox.getItems().addAll(
    "Choice 1",
    "Choice 2",
    "Choice 3",
    "Choice 4"
);
choiceBox.getSelectionModel()
        .selectedItemProperty()
        .addListener((o, oldValue, newValue) -> log(newValue));

Listing 4-23Creating a ChoiceBox with four choices and a listener

基于组合框的控件

除了基于菜单的控件之外,还有许多基于用户交互弹出的其他控件。本节介绍了一组控件,它们都可以归类为“组合框”控件在 JavaFX 中,这个集合中的控件都是从ComboBoxBase类(其属性如表 4-24 所示)扩展而来的,称为ComboBoxColorPickerDatePicker

因为所有的ComboBoxBase子类共享一个共同的父类,所以它们的 API 是统一的,并且有许多显著的相似之处:

  • 它们显示为一个按钮,单击该按钮将弹出一些 UI,允许用户进行选择。

  • 有一个value属性代表用户选择的当前值。

  • 通过适当地设置editable属性,它们通常可以是可编辑的或不可编辑的。当控件可编辑时,它会在旁边显示一个TextField和一个按钮——TextField允许用户输入,按钮会显示弹出窗口。当控件不可编辑时,整个控件将显示为一个按钮。

  • show()hide()两种方法可以通过编程使弹出窗口显示出来,或者如果已经显示出来,则隐藏起来。

表 4-24

ComboBoxBase 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| editable | BooleanProperty | 控件是否显示接收用户输入的文本输入区域。 |
| onAction | ObjectProperty<EventHandler<ActionEvent>> | 用户设置新值时的事件处理程序。 |
| promptText | StringProperty | 要显示的提示文本——是否显示取决于子类别。 |
| value | ObjectProperty<T> | 用户最近的选择(或输入,如果可编辑的话)。 |

组合框

ComboBox在概念上与ChoiceBox控件非常相似,但是当需要显示大量元素时,它的功能更全面,性能更高。组合框中添加的属性如表 4-25 所示,并在清单 4-24 中用代码演示。

表 4-25

ComboBox 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| cellFactory | ObjectProperty<Callback<ListView<T>,ListCell<T>>> | 用于自定义项目的呈现。 |
| converter | ObjectProperty<StringConverter<T>> | 将用户键入的输入(可编辑时)转换为 T 类型的对象以设置为值。 |
| items | ObjectProperty<ObservableList<T>> | 要在弹出窗口中显示的元素。 |
| placeholder | ObjectProperty<Node> | 当组合框没有项目时显示什么。 |
| selectionModel | ObjectProperty<SingleSelectionModel<T>> | ComboBox的选择型号。 |

ComboBox<String> comboBox = new ComboBox<>();
comboBox.getItems().addAll(
        "Apple",
        "Carrot",
        "Orange",
        "Banana",
        "Mango",
        "Strawberry"
);
comboBox.getSelectionModel()
        .selectedItemProperty()
        .addListener((o, oldValue, newValue) -> log(newValue));

Listing 4-24Creating a ComboBox with multiple choices and a listener

颜色选择器

ColorPicker控件是ComboBox的一种特殊形式,专门用于允许用户选择颜色值。7ColorPicker控件并没有在ComboBoxBase之上增加任何额外的功能,但是用户界面当然是大不相同的。使用颜色选择器与其他控件非常相似,如清单 4-25 所示。

ColorPicker控件提供了一个带有预定义颜色集的调色板。如果用户不想从预定义的颜色集中进行选择,他们可以通过与自定义颜色对话框进行交互来创建自定义颜色。该对话框提供 RGB、HSB 和 web 交互模式,以创建新的颜色。它还允许修改颜色的不透明度。

一旦定义了新的颜色,用户可以选择是保存它还是直接使用它。如果保存了新颜色,该颜色将出现在调色板上的自定义颜色区域。

final ColorPicker colorPicker = new ColorPicker();
colorPicker.setOnAction(e -> {
    Color c = colorPicker.getValue();

System.out.println("New Color RGB = "+c.getRed()+" "+c.getGreen()+" "+c.getBlue());
});

Listing 4-25Creating a ColorPicker and listening for selection changes

日期选择器

就像ColorPicker是选择颜色的ComboBoxBase的专门化一样,DatePicker是选择日期的ComboBoxBase的专门化——在这里是一个java.time.LocalDate值。DatePicker中引入的属性如表 4-26 所示,其在代码中的使用如清单 4-26 所示。

表 4-26

DatePicker 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| chronology | ObjectProperty<Chronology> | 使用哪个日历系统。 |
| converter | ObjectProperty<StringConverter<LocalDate>> | 将文本输入转换为本地日期,反之亦然。 |
| dayCellFactory | ObjectProperty<Callback<DatePicker,DateCell>> | 单元格工厂自定义弹出窗口中的单个日单元格。 |
| showWeekNumbers | BooleanProperty | 弹出窗口是否应该显示周数。 |

final DatePicker datePicker = new DatePicker();
datePicker.setOnAction(e -> {
    LocalDate date = datePicker.getValue();
    System.err.println("Selected date: " + date);
});

Listing 4-26Creating a DatePicker and listening for selection changes

JavaFX 对话框

自 8u40 发布以来,JavaFX 附带了一套全面的对话框 API 来提醒、查询和通知用户。有一个 API 可以在创建自定义对话框的过程中弹出一个信息提示。最简单的情况是,开发人员应该使用Alert类来显示预构建的对话框。想提示用户输入文本或从选项列表中选择的开发者分别使用TextInputDialogChoiceDialog会更好。使用 Dialog 和 DialogPane 类可以创建完全自定义的对话框。

在讨论对话框时,有两个术语是开发人员应该熟悉的。它们是“模态的”和“阻塞的”,当它们之间有明显的区别时,通常可以互换使用。尽管如此,这两个术语很容易定义:

  • 一个模态对话框出现在另一个窗口的顶部,阻止用户点击那个窗口,直到对话框被关闭。

  • 阻塞对话框会导致代码执行在导致对话框出现的那一行停止。这意味着,一旦对话框关闭,将从该行代码继续执行。这可以被认为是一个同步对话。使用阻塞对话框更简单,因为开发人员可以从对话框中检索返回值并继续执行,而不需要依赖侦听器和回调。

在 JavaFX 中,默认情况下所有的对话框都是模态的,但是通过在Dialog上使用initModality(Modality)方法也有可能是非模态的。对于阻塞,这取决于开发者——他们可以选择调用showAndWait()进行阻塞,调用show()进行非阻塞对话。

警报

对于只想向用户显示对话框的开发人员来说,Alert 类是最简单的选项。有许多带有不同图标和默认按钮的预置选项。创建一个警报只是调用指定了所需的AlertType的构造器。AlertType用于配置默认显示哪些按钮和图形。以下是这些选项的简要总结:

  • 确认:最好用于确认用户在执行某个操作之前是确定的。显示一个蓝色问号图像和“取消”和“确定”按钮。

  • 错误:最好用于通知用户出现了错误。显示一个红色“X”图像和一个“确定”按钮。

  • 信息:最好用来通知用户一些有用的信息。显示一个蓝色的“I”图像(代表“信息”)和一个“确定”按钮。

  • None:这将导致没有图像和按钮被设置。除非要提供自定义实现,否则很少使用这种方法。

  • 警告:最好用来警告用户一些事实或悬而未决的问题。显示一个黄色感叹号图像和一个“确定”按钮。

在大多数情况下,开发人员只需从前面文本中概述的选项中选择适当的警告类型,然后提供他们希望向用户显示的文本。一旦创建了警报,它就可以如清单 4-27 所示显示。

alert.showAndWait()
      .filter(response -> response == ButtonType.OK)
      .ifPresent(response -> formatSystem());

Listing 4-27Creating an alert, waiting to see if the user selects the OK button, and, if so, performing an action

选择对话框

是一个向用户显示选项列表的对话框,用户最多可以从中选择一项。换句话说,这个对话框将使用一个控件,比如一个ChoiceBoxComboBox(这是一个实现细节;开发者不能指定他们的偏好)以使用户能够做出选择。这个选择随后将被返回给开发人员,以采取适当的行动,如清单 4-28 所示。

ChoiceDialog<String> dialog = new ChoiceDialog<>("Cat", "Dog", "Cat", "Mouse");
dialog.showAndWait()
    .ifPresent(result -> log("Result is " + result));

Listing 4-28Creating a ChoiceDialog with default choice of “Cat” and three choices. Dialog is modal and blocking, and if the user clicks the “OK” button, output is printed to console

ChoiceDialog 类的关键属性如表 4-27 所示。

表 4-27

ChoiceDialog 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| selectedItem | ReadOnlyObjectProperty<T> | 用户在对话框中选择的项目。 |

TextInputDialog

TextInputDialog 类似于 ChoiceDialog,只是它不允许用户从弹出列表中进行选择,而是允许用户提供单行文本输入。

表 4-28 中显示了 TextInputDialog 类的关键方法,以及如何在清单 4-29 中创建 TextInputDialog 的示例。

表 4-28

TextInputDialog 类上的方法

|

方法

|

类型

|

描述

|
| --- | --- | --- |
| getEditor() | TextField | 对话框中显示的是用户的TextField。 |

TextInputDialog dialog = new TextInputDialog ("Please enter your name");
dialog.showAndWait()
    .ifPresent(result -> log("Result is " + result));

Listing 4-29Creating a TextInputDialog. Dialog is modal and blocking, and if the user clicks the “OK” button, their input is printed to console

对话框和对话页面

对话框是 JavaFX 中最灵活的对话框选项,支持对话框的完整配置。这允许创建诸如用户名/密码提示、复杂表单等对话框。

当一个对话框被实例化时,开发者可以指定一个单一的泛型类型 R,它代表了result属性的类型。这很重要,因为这是我们作为开发人员在对话框关闭时将收到的内容。

这可能会引出一个明显的问题:R 型应该是什么?答案是,这取决于用户到底需要什么。例如,在密码提示的情况下,它可能是一个UsernamePassword类的实例。

因为对话框类不知道它正在显示的内容,因此不知道如何将用户输入的值转换成 R 类型的实例,所以开发人员有必要设置resultConverter属性。当 R 型不是VoidButtonType时,这是必需的。如果不注意这一点,开发人员会发现他们的代码中抛出了ClassCastException,因为没有通过结果转换器从ButtonType转换过来。

一旦对话框被实例化,下一步就是配置它。与创建自定义对话框相关的最重要属性如表 4-29 所示。

表 4-29

对话框类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| contentText | StringProperty | 要显示的主要文本。 |
| dialogPane | ObjectProperty<DialogPane> | Dialog中的根节点。包含此处显示的大多数其他属性。 |
| graphic | ObjectProperty<Node> | 要显示的图形。 |
| headerText | StringProperty | 要在标题区域显示的文本(在contentText上方)。 |
| result | ObjectProperty<R> | 对话框关闭后返回的值。 |
| resultConverter | ObjectProperty<Callback<ButtonType, R>> | 将用户按钮点击转换成结果的 API。 |
| title | StringProperty | 向用户显示的对话框标题。 |

在内部,Dialog 将可视区域的所有布局处理委托给嵌入式 DialogPane 实例。事实上,许多属性只是转发到这个对话框。8DialogPane API 提供了很多在对话框级别没有公开的附加功能,开发者可以通过调用 Dialog 实例上的 getDialogPane()来检索当前安装的 Dialog pane。

高级控制

需要涵盖的最后一组控件是“高级”控件:ListViewTreeViewTableViewTreeTableView。这些控件包含最多的 API 和最多的功能。这四个控件有很多共同的概念,所以本节将深入探讨ListView(四个控件中最简单的一个),然后在更高的层次上讨论其他三个控件。

列表视图

一个ListView控件用于向用户显示元素列表。ListView是一个泛型类,所以ListView<T>能够包含 T 类型的项目。与大多数 UI 控件一样,用项目填充ListView非常容易——只需将 T 类型的元素添加到items列表中。元素在项目列表中出现的顺序将与它们在ListView中显示的顺序一致。

因为ListView(与本节中的所有“高级”控件一样)是“虚拟化的”,所以当列表中的元素数量增加时,它不会付出性能损失。这是因为,在幕后,ListView只创建足够的“单元格”来包含ListView可见区域中的元素。例如,如果ListView的高度足以容纳 20 行,那么ListView可以选择创建 22 个单元格,并在用户滚动列表时重用这些单元格。

ListView控件具有selectionModelfocusModel属性,使开发人员能够精确控制用户界面中选择和关注的内容。这些概念将在后面的“选择和焦点模型”部分更深入地讨论。

通常,ListView垂直滚动,但是通过改变orientation属性,它也可以被配置为水平滚动。该属性和其他重要属性如表 4-30 所示。

表 4-30

ListView 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| cellFactory | ObjectProperty<Callback<ListView<T>, ListCell<T>>> | 参见“细胞和细胞工厂”一节。 |
| editable | BooleanProperty | ListView 是否支持编辑单元格。 |
| focusModel | ObjectProperty<FocusModel<T>> | 请参考“选择和焦点模型”一节。 |
| items | ObjectProperty<ObservableList<T>> | 要在列表视图中显示的元素。 |
| orientation | ObjectProperty<Orientation> | ListView 是垂直还是水平的。 |
| placeholder | ObjectProperty<Node> | 项目列表为空时在 ListView 中显示的文本。 |
| selectionModel | ObjectProperty<MultipleSelectionModel<T>> | 请参考“选择和焦点模型”一节。 |

细胞和细胞工厂

在本节的高级控件(ListView、TreeView、TableView 和 TreeTableView)中,它们的 API 的一个共同点是它们都支持“单元工厂”的概念这在概念上类似于我们已经在本章中讨论过的其他工厂,例如Pagination控件中的页面工厂。

单元格工厂的目的是在 UI 控件(例如 ListView)请求时创建单元格,这就引出了一个问题:单元格到底是什么?在 JavaFX 意义上,它是从 javafx.scene.control.Cell 类扩展而来的类。Cell 类被标记,这意味着它公开了本章开始时讨论的所有 API,并且一个单元格被用来呈现 ListView 和其他控件中的单个“行”。单元格还用于 TableView 和 TreeTableView 中的每个单独的“单元格”。

每个单元格都与一个数据项相关联(由Cell item 属性表示)。单元格单独负责呈现该项。根据所使用的单元格类型,项目可以表示为一个字符串,或者使用其他 UI 控件,比如CheckBoxSlider

单元格“堆叠”在类似 ListView 的 UI 控件中,如前所述,单元格工厂用于根据控件的需求生成这些单元格。但是当单元被重用时,它们是如何更新的呢?有一个名为updateItem的关键方法,每当 UI 控件(例如 ListView)要重用一个单元格时就会调用它。当开发人员提供定制的单元格工厂时,他们必须覆盖这个方法,因为它提供了一个钩子,开发人员可以在单元格被更新以包含新内容时,也更新单元格的表示以更好地表示这个新内容。

因为到目前为止,单元格最常见的用例是向用户显示文本,所以这个用例专门针对Cell进行了优化。这是通过从Labeled扩展而来的Cell来完成的。这意味着Cell的子类只需要设置text属性,而不是创建一个单独的Label并在Cell中设置。然而,对于不仅仅需要纯文本的情况,可以将任何Node放在Cell graphic属性中。尽管有这个术语,图形可以是任何一种图形,并且是完全交互式的。例如,一个ListCell可能配置有一个Button作为它的图形。表 4-31 概述了Cell类的一些更关键的属性。

表 4-31

Cell 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| editable | BooleanProperty | Cell实例是否可以进入编辑状态。 |
| editing | ReadOnlyBooleanProperty | Cell当前是否处于编辑状态。 |
| empty | ReadyOnlyBooleanProperty | Cell是否有项目。 |
| item | ObjectProperty<T> | Cell当前表示的对象。 |
| selected | ReadOnlyBooleanProperty | 用户是否选择了Cell。 |

还有其他的使用案例。在ListView中支持编辑很容易——当一个单元格进入“编辑”状态时,同样的updateItem方法被调用,在这段代码中,开发人员可以选择检查单元格的编辑状态,如果是这样,开发人员可以选择删除文本并用一个TextField替换它,允许用户直接自定义输入。

当使用 UI 控件(如 ListView)时,开发人员不直接使用 Cell,而是使用特定于控件的子类(在 ListView 中,这将是 ListCell)。对于 TableView 和 TreeTableView 控件,实际上有两种单元格类型——TableRow/TreeTableRow 和 table cell/TreeTableCell——但我们将在本章后面讨论这种区别。尽管有这种额外的复杂性,开发人员可以安慰自己,总的来说,他们必须简单地理解 Cell 类的基础知识,他们将能够以几乎相同的方式为所有 UI 控件创建单元格工厂。清单 4-30 展示了开发者如何创建一个定制的ListCell类。

public class ColorRectCell extends ListCell<String> {
    private final Rectangle rect = new Rectangle(100, 20);
    @Override public void updateItem(String item, boolean empty) {
        super.updateItem(item, empty);
        if (empty || item == null) {
            setGraphic(null);
        } else {
            rect.setFill(Color.web(item));
            setGraphic(rect);
        }
    }
}

Listing 4-30Creating a custom ListCell subclass and overriding updateItem

单元格编辑

当自定义单元格可编辑时,我们只需扩展清单 4-31 中所示的updateItem方法,添加检查来查看单元格是否用于表示控件中的当前编辑索引,就可以启用对它的支持。

对于许多常见的情况,已经存在许多预构建的单元工厂,它们支持使用核心 JavaFX APIs 进行编辑,包含在javafx.scene.control.cell包中,在下一节中将更详细地讨论。

在预建的可编辑单元格不存在的情况下,只要用户执行相关的交互操作(通常是在单元格内双击,但也有键盘快捷键),就可以按照清单 4-31 中所示的代码在编辑和非编辑状态之间轻松切换。注意,要启用编辑,不仅单元格必须支持编辑,而且开发人员必须将ListView editable属性设置为true

public class EditableListCell extends ListCell<String> {
    private final TextField textField;
    public EditableListCell() {
        textField = new TextField();
        textField.addEventHandler(KeyEvent.KEY_PRESSED, e -> {
            if (e.getCode() == KeyCode.ENTER) {
                commitEdit(textField.getText());
            } else if (e.getCode() == KeyCode.ESCAPE) {
                cancelEdit();
            }
        });
        setGraphic(textField);
        setContentDisplay(ContentDisplay.TEXT_ONLY);
    }
    @Override public void updateItem(String item, boolean empty) {
        super.updateItem(item, empty);
        setText(item);
        setContentDisplay(isEditing() ?
            ContentDisplay.GRAPHIC_ONLY : ContentDisplay.TEXT_ONLY);
    }
    @Override public void startEdit() {
        super.startEdit();
        setContentDisplay(ContentDisplay.GRAPHIC_ONLY);
        textField.requestFocus();
    }
    @Override public void commitEdit(String s) {
        super.commitEdit(s);
        setContentDisplay(ContentDisplay.TEXT_ONLY);
    }
    @Override public void cancelEdit() {
        super.cancelEdit();
        setContentDisplay(ContentDisplay.TEXT_ONLY);
    }
}

Listing 4-31Creating a ListCell that supports editing state

预制细胞工厂

如前所述,JavaFX 附带了许多预构建的单元工厂,使得定制 ListView 和其他内容变得非常容易。如果您需要接受文本输入,有一个 TextFieldListCell 类(用于 ListView)。如果您想在 TableColumn 中显示进度,存在一个 ProgressBarTableCell(用于 TableView)。利用这些预建的单元工厂是显而易见的,因为它们已经在最广泛的配置中进行了开发和测试,并且已经被开发来避免常见的性能问题。表 4-32 总结了所有可用的预制细胞工厂,清单 4-32 演示了如何使用其中一个细胞工厂。

表 4-32

预制细胞工厂

|

类型

|

支持的 UI 控件

|
| --- | --- |
| CheckBox | ListViewTableViewTreeViewTreeTableView |
| ChoiceBox | ListViewTableViewTreeViewTreeTableView |
| ComboBox | ListViewTableViewTreeViewTreeTableView |
| ProgressBar | TableViewTreeTableView |
| TextField | ListViewTableViewTreeViewTreeTableView |

ListView<String> listView = new ListView<>();
listView.setEditable(true);
listView.setCellFactory(param -> new TextFieldListCell<>());

Listing 4-32Using a pre-built cell factory to customize the editing style of a ListView

TreeView

TreeView控件是 JavaFX UI toolkit 中的定位控件,用于向用户显示树状数据结构,例如,用于表示文件系统或公司层次结构。TreeView控件通过在树枝上显示“公开”节点(即箭头)来显示分层结构,允许它们展开和折叠。当树分支展开时,其子节点显示在分支下方,但有一定的缩进量,以清楚地表明子节点属于其父节点。

与 JavaFX ListView控件不同,Java FXListView控件只公开一个项目列表,而TreeView控件只包含一个开发人员必须指定的root属性。root属性属于类型TreeItem<T>(其中 T 对应于TreeView实例本身的类型,因为TreeView也有一个泛型类型)。不出所料,root属性表示了TreeView的根元素,所有后代都是从它派生的。树形视图的主要属性如表 4-33 所示。

表 4-33

TreeView 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| cellFactory | ObjectProperty<Callback<TreeView<T>,TreeCell<T>>> | 用于创建所有单元的单元工厂。 |
| editable | BooleanProperty | TreeView是否能够进入编辑状态。 |
| editingItem | ReadOnlyObjectProperty<TreeItem<T>> | 当前正在编辑的TreeItem。 |
| expandedItemCount | ReadOnlyIntegerProperty | 在TreeView中可见的树节点总数。 |
| focusModel | ObjectProperty<FocusModel<TreeItem<T>>> | 请参考“选择和焦点模型”一节。 |
| root | ObjectProperty<TreeItem<T>> | TreeView中的根树项目。 |
| selectionModel | ObjectProperty<MultipleSelectionModel<TreeItem<T>>> | 请参考“选择和焦点模型”一节。 |
| showRoot | BooleanProperty | 无论根是否显示。否则,根的所有子元素都将显示为根元素。 |

TreeItem是一个相对简单的类,其行为方式与前面讨论的MenuItem相似,因为它不是从Control甚至Node扩展而来的类。它纯粹是一个模型类,用来表示树项目的抽象概念(或者是一个有自己子节点的树枝,或者是一个没有子节点的树叶)。TreeItem 的属性如表 4-34 所示。

表 4-34

TreeItem 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| expanded | BooleanProperty | 此 TreeItem 是展开还是折叠。 |
| graphic | ObjectProperty<Node> | 在任何文本或其他表示旁边显示的图形。 |
| leaf | ReadOnlyBooleanProperty | 此 TreeItem 是叶节点还是有子节点。 |
| parent | ReadOnlyObjectProperty<TreeItem<T>> | 此 TreeItem 的父 TreeItem,如果它是根,则为 null。 |
| value | ObjectProperty<T> | TreeItem 的值–这是将在 TreeView/TreeTableView 控件的单元格中呈现的内容。 |

表视图

TableView顾名思义,使开发者能够向用户展示表格数据。因此,这个控件可以被认为是一个支持多列数据的ListView,而不是一个ListView中的单个列。随之而来的是大量的附加功能:可以对列进行排序、重新排序、调整大小和嵌套,单个列可以安装自定义的单元工厂,可以设置调整大小策略来控制如何为列分配可用空间,等等。TableView 的主要属性如表 4-35 所示。

表 4-35

TableView 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| columnResizePolicy | ObjectProperty<Callback<ResizeFeatures, Boolean>> | 这在调整列或表的大小时处理重新分配列空间。 |
| comparator | ReadOnlyObjectProperty<Comparator<S>> | 基于 sortOrder 列表中的表列的当前比较器。 |
| editable | BooleanProperty | TableView是否能够进入编辑状态。 |
| editingCell | ReadOnlyObjectProperty<TablePosition<S,?>> | 当前正在编辑的任何单元格的位置。 |
| focusModel | ObjectProperty<TableViewFocusModel<S>> | 请参考“选择和焦点模型”一节。 |
| items | ObjectProperty<ObservableList<S>> | 要在 TableView 中显示的元素。 |
| placeholder | ObjectProperty<Node> | 项目列表为空时在 TableView 中显示的文本。 |
| rowFactory | ObjectProperty<Callback<TableView<S>,TableRow<S>>> | rowFactory 负责创建一整行 TableCells(针对所有列)。 |
| selectionModel | ObjectProperty<TableViewSelectionModel<S>> | 请参考“选择和焦点模型”一节。 |
| sortPolicy | ObjectProperty<Callback<TableView<S>,Boolean>> | 指定应该如何执行排序。 |
| tableMenuButtonVisible | BooleanProperty | 指定菜单按钮是否应显示在 TableView 的右上角。 |

TableView有一个通用类型 S,用于指定项目列表中允许的元素的值。这个列表中的每个元素代表了TableView中一整行的支持对象。例如,如果TableView要显示人对象,那么我们将定义一个TableView<Person>并将所有相关的人添加到items列表中。

开发人员有时会对TableView有一个items列表感到惊讶,因为这会引出一个问题:如何将这些项目转换成需要在TableView的每个“单元格”中显示的值(例如,假设我们的TableView有显示一个人的名字、姓氏和电子邮件地址的列)?答案是这是开发人员创建的每个TableColumn实例的责任,因此在这种情况下,我们期望开发人员创建三个TableColumn实例,名字、姓氏和电子邮件地址各一个。

表列和树表列

TableColumn存在于 JavaFX UI 控件中没有从Control扩展的类集合中(我们之前讨论过的例子包括MenuItemMenuTreeItem)。TableColumnTableColumnBase扩展而来,由于TreeTableView有相似(但不完全相同)的 API,因此有必要创建TreeTableColumn。尽管TableViewTreeTableView需要不同的类,但仍然有大量的重叠,这就是为什么大多数 API 都在TableColumnBase上。TableColumnBase 的关键属性如表 4-36 所示。

表 4-36

TableColumnBase 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| comparator | ObjectProperty<Comparator<T>> | 当此列是表sortOrder列表的一部分时使用的比较器。 |
| editable | BooleanProperty | 指定此列是否支持编辑。 |
| graphic | ObjectProperty<Node> | 要在列标题区域显示的图形。 |
| parentColumn | ReadOnlyObjectProperty<TableColumnBase<S,?>> | 请参考“嵌套列”一节。 |
| resizable | BooleanProperty | 用户是否可以更改列的宽度。 |
| sortable | BooleanProperty | 用户是否可以对列进行排序。 |
| sortNode | ObjectProperty<Node> | 当列是排序顺序列表的一部分时显示的“排序箭头”。 |
| text | StringProperty | 要在列标题区域中显示的文本。 |
| visible | BooleanProperty | 该列是否向用户显示。 |
| width | ReadOnlyDoubleProperty | 列的宽度。 |

TableColumn是一个泛型类,有两个泛型类型,S 和 T,其中 S 是与TableView泛型类型相同的类型,T 是将在TableColumn表示的特定列中使用的值的类型。

当创建一个TableColumn实例时,要设置的两个最重要的属性是列text属性(在列标题区域显示什么)和列cellValueFactory属性(用于填充列中的单个单元格)。 9

TableColumn 显然是为与 TableView 一起使用而设计的,但是它不能与 TreeTableView 一起使用,这可能会让你们中的一些人感到惊讶(很快会有更详细的介绍)。这是因为 TableColumn 做了一些 API 假设,将它直接绑定到 TableView API。因此,另一个名为 TreeTableColumn 的类与 TreeTableView 结合使用,可以达到相同的效果。在很大程度上,我们关心的 API 是可互换的,所以 Table 4-37 为 TableColumn 引入了这些 API,但是请放心,TreeTableView API 以几乎相同的形式存在。

表 4-37

TableColumn 和 TreeTableColumn 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| cellFactory | ObjectProperty<Callback<TableColumn<S,T>,TableCell<S,T>>> | 此表列中所有单元格的单元格工厂。 |
| cellValueFactory | ObjectProperty<Callback<CellDataFeatures<S,T>,ObservableValue<T>>> | 此表格列中所有单元格的单元格值工厂。 |
| sortType | ObjectProperty<SortType> | 指定当此列是排序的一部分时,它应该是升序还是降序。 |

ObservableList<Person> data = ...
TableView<Person> tableView = new TableView<Person>(data);
TableColumn<Person,String> firstNameCol = new TableColumn<Person,String>
("First Name");
firstNameCol.setCellValueFactory(new Callback<CellDataFeatures<Person, String>, ObservableValue<String>>() {
    public ObservableValue<String> call(CellDataFeatures<Person, String> p) {

// p.getValue() returns the Person instance for a particular TableView row
        return p.getValue().firstNameProperty();
    }
});
tableView.getColumns().add(firstNameCol);

Listing 4-33Code required to create a TableColumn and specify a cell value factory

清单 4-33 中的方法假设从p.getValue()返回的对象有一个可以简单返回的 JavaFX ObservableValue。这样做的好处是,TableView将在内部创建绑定,以确保如果返回的ObservableValue发生变化,单元格内容将自动刷新。

还有一个更简洁的选择——它利用反射来达到同样的效果,而不需要编写前面的代码。清单 4-34 演示了这一点,其中我们使用了PropertyValueFactory类并传入了我们希望观察的属性的名称(在本例中是“名字”)。在内部,JavaFX 将试图找到一个名为firstNameProperty()的属性方法,如果找到了,就绑定到它。如果没有找到,它将寻找getFirstName()并显示返回值(显然没有绑定)。

TableColumn<Person,String> firstNameCol = new TableColumn<Person,String>
("First Name");
firstNameCol.setCellValueFactory(new PropertyValueFactory("firstName"));

Listing 4-34Example of using PropertyValueFactory

TableColumn必须与 JavaFX 之前创建的类交互,或者通常不希望使用 JavaFX API 获取属性的情况下,可以将返回值包装在ReadOnlyObjectWrapper实例中。参见清单 4-35 中的示例。

firstNameCol.setCellValueFactory(new Callback<CellDataFeatures<Person, String>, ObservableValue<String>>() {
    public ObservableValue<String> call(CellDataFeatures<Person, String> p) {
        return new ReadOnlyObjectWrapper(p.getValue().getFirstName());
    }
});

Listing 4-35Wrapping non-property values for use in a JavaFX TableView

对于TreeTableView控件,存在一个类似于 PropertyValueFactory 的类,称为TreeItemPropertyValueFactory。它执行与PropertyValueFactory类相同的功能,但是它被设计成与作为TreeTableView类的数据模型的一部分的TreeItem类一起工作。

嵌套列

JavaFX TableView 和 TreeTableView 控件都内置了对列嵌套的支持。这意味着,例如,您可能有一个“姓名”列,其中包含两个子列,分别代表名字和姓氏。“Name”列在很大程度上是装饰性的——它不涉及提供单元格值工厂或单元格工厂(这是子列的责任),但用户可以使用它来重新排序列位置和调整所有子列的大小。

创建嵌套列很简单,如清单 4-36 所示。

TableColumn firstNameCol = new TableColumn("First Name");
TableColumn lastNameCol = new TableColumn("Last Name");
TableColumn nameCol = new TableColumn("Name");
nameCol.getColumns().addAll(firstNameCol, lastNameCol);

Listing 4-36Creating a “Name” column with two nested child columns

TableView 中的细胞工厂

我们已经在ListView的上下文中介绍了细胞工厂,但是TableView(和TreeTableView)中的细胞工厂稍微有些微妙。这是因为,与ListViewTreeView不同,在TableViewTreeTableView类中,有两个可能放置细胞工厂的地方。

首先,可以在TableViewTreeTableView控件上指定“行工厂”。行工厂负责显示整行信息,因此自定义行工厂必须小心谨慎地适当显示所有列。因此,开发人员很少创建行工厂。

相反,开发人员倾向于在单个TableColumn(或TreeTableColumn,对于TreeTableView的情况)上指定一个定制的细胞工厂。当在TableColumn上设置一个单元工厂时,它的功能与在ListView上设置的单元工厂的功能非常相似——它只专注于表示单个单元(即一列/一行的交叉点),而不是一整行。这很有效,因为在大多数情况下,我们希望以相同的方式显示给定列中的所有单元格,因此通过在TableColumn上指定一个定制的单元格工厂,我们可以很容易地实现这一点。事实上,为TableColumn编写定制单元工厂的方法本质上与为ListView编写定制单元工厂的方法完全相同。

TreeTableView

现在我们已经介绍了TreeViewTableView,剩下的是介绍TreeTableView,从 API 的角度来看,它包含了TreeViewTableView的元素。因此,为了简化讨论并避免重复,关于TreeTableView的这一部分将主要用于详细描述两个控件TreeTableView中的哪一个继承了它的 API。

TreeTableView的高度概括是它使用了与TreeView相同的TreeItem API,因此需要开发者在TreeTableView中设置根节点。这也意味着没有物品清单,比如在ListViewTableView中。类似地,TreeTableView控件使用与TableView控件相同的基于TableColumn的方法,除了不使用特定于TableViewTableColumn类之外,开发人员将需要使用大体相当的TreeTableColumn类。

就向最终用户显示的功能而言,TreeTableView基本上等同于TableView,增加了扩展/折叠分支以及在显示分支子节点时缩进分支子节点的能力。TreeTableView有很多属性,列于表 4-38 。

表 4-38

TreeTableView 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| columnResizePolicy | ObjectProperty<Callback<ResizeFeatures, Boolean>> | 这在调整列或表大小时处理重新分配列空间。 |
| comparator | ReadOnlyObjectProperty<Comparator<TreeItem<S>>> | 基于 sortOrder 列表中的表列的当前比较器。 |
| editable | BooleanProperty | TreeTableView是否能够进入编辑状态。 |
| editingCell | ReadOnlyObjectProperty<TreeTablePosition<S,?>> | 当前正在编辑的任何单元格的位置。 |
| expandedItemCount | ReadOnlyIntegerProperty | 在TreeTableView中可见的树节点总数。 |
| focusModel | ObjectProperty<TreeTTableViewFocusModel<S>> | 请参考“选择和焦点模型”一节。 |
| items | ObjectProperty<ObservableList<S>> | 要在 TableView 中显示的元素。 |
| placeholder | ObjectProperty<Node> | 如果项目列表为空,则在 TreeTableView 中显示的文本。 |
| root | ObjectProperty<TreeItem<S>> | TreeTableView中的根树项目。 |
| rowFactory | ObjectProperty<Callback<TreeTableView<S>,TreeTableRow<S>>> | rowFactory 负责创建一整行的 TreeTableCells(针对所有列)。 |
| selectionModel | ObjectProperty<TreeTableViewSelectionModel<S>> | 请参考“选择和焦点模型”一节。 |
| sortPolicy | ObjectProperty<Callback<TreeTableView<S>,Boolean>> | 指定应该如何执行排序。 |
| tableMenuButtonVisible | BooleanProperty | 指定菜单按钮是否应显示在 TreeTableView 的右上角。 |
| treeColumn | ObjectProperty<TreeTableColumn<S,?>> | 哪一列应该在其中绘制显示节点。 |

选择和焦点模型

JavaFX 附带的许多 UI 控件始终公开选择或焦点模型。这种抽象使得开发人员更容易理解所有的 UI 控件,因为它们为常见的场景提供了相同的 API。在许多 API 中,SelectionModel API 被广泛使用,所以我们将首先介绍它。

选择模型

SelectionModel是一个抽象类,用一个通用类型 T 扩展,表示相关 UI 控件中所选项的类型。因为SelectionModel是抽象的,大多数用例通常基于提供的两个子类之一:SingleSelectionModelMultipleSelectionModel。顾名思义,SingleSelectionModel用于一次只能选择一个选项的 UI 控件中(例如在TabPane中,一次只能选择一个Tab),而MultipleSelectionModel支持同时存在多个选项(例如在ListView中可以同时选择多行)。

除了表 4-39 中提到的两个主要属性之外,还有许多执行选择、清除选择和查询给定索引或项目当前是否被选中的方法。

表 4-39

SelectionModel 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| selectedIndex | ReadOnlyIntegerProperty | UI 控件中当前选定的单元格索引。 |
| selectedItem | ReadOnlyObjectProperty<T> | UI 控件中当前选定的项。 |

沿着继承层次进一步向下,MultipleSelectionModel引入了额外的 API,允许开发人员一次选择多行,并在状态改变时观察selectedIndicesselectedItems列表。

继承的最后一层是特定于表的选择模型(TableViewSelectionModelTreeTableViewSelectionModel)。这些类添加了 API 来改变行和单元格选择之间的选择模式,当处于单元格选择模式时,可以根据单元格的行/列交叉点来选择单元格。他们还提供了一个selectedCells列表来监听状态变化。

焦点模型

JavaFX 中的焦点概念在应用于 UI 控件时可能有点奇怪,因为这个术语有很多。在 JavaFX 中,更正确地使用焦点与用户通过用户界面“切换”时发生的事情有关——他们在各种 UI 控件和节点之间转移焦点。无论这些元素中的哪一个具有焦点,都可以接受所有其他的键盘输入。

一些 UI 控件已经重载了 focus 这个术语,也意味着更准确地说是“内部焦点”ListViewTreeViewTableViewTreeTableView控件都有焦点模型,允许编程操作和观察这个内部焦点。在这点上,焦点不是在一个Node上,而是在 UI 控件内部的一个元素上,我们不关心Node,而是在那一行中的值(类型 T)(以及那一行的索引位置)。

在许多方面,FocusModel可以被认为与SingleSelectionModel非常相似,因为只能有一个焦点元素,这使得FocusModel的 API 比我们已经讨论过的更常见的MultipleSelectionModel更简单。FocusModel 的两个主要属性如表 4-40 所示。

表 4-40

FocusModel 类的属性

|

财产

|

类型

|

描述

|
| --- | --- | --- |
| focusedIndex | ReadOnlyIntegerProperty | UI 控件中当前聚焦的单元格索引。 |
| focusedItem | ReadOnlyObjectProperty<T> | UI 控件中当前获得焦点的项。 |

摘要

本章系统地介绍了 JavaFX 17 附带的所有 UI 控件。现在,您应该已经有足够的知识来更容易地创建由适当的 UI 控件组成的用户界面。

正如本章开头所提到的,本书的源代码示例中提供了一个配套应用程序,它演示了作为核心 JavaFX 发行版的一部分提供的所有 UI 控件。我们鼓励您执行此应用程序,以便更熟悉每个 UI 控件的操作方式,并更好地了解如何在您自己的开发中使用 UI 控件。

承认

如果没有 JavaFX 社区成员的贡献,为本章开发的配套应用程序就不可能完全实现。因此,我想借此机会感谢以下人士:阿比纳伊·阿加瓦尔、福阿德·阿尔马尔基、阿尔马斯·拜马甘别托夫、弗兰克·德尔博特、西里尔·费希尔和侯赛因·里马兹。谢谢!

此外,这一章是由 Abhinay Agarwal 专家审查。谢谢!

五、掌握视觉和 CSS 设计

尤金·雷日科夫写的

级联样式表或 CSS 是一种样式表语言,用于描述以 XML 及其方言(如 HTML、SVG 和 MathML)编写的文档的表示。它成为 web 开发中事实上的标准,用于描述 web 页面或 web 应用程序的所有表示方面。它是开放网络的核心语言之一,并根据 W3C 规范跨浏览器进行了标准化。

因此,这样一种成熟的表示语言作为 JavaFX 的一部分被实现来简化框架的所有表示方面的描述是很自然的,比如字体、颜色、填充、效果等等。

在本章中,我们将讨论以下主题:

  • JavaFX 中级联样式表的介绍

  • 应用 CSS 技术

  • 高级 CSS API

  • 在 JavaFX 应用程序中使用 CSS 的好处

级联样式表介绍

在最基本的层面上,CSS 只有两个构建块,如图 5-1 所示:

属性:标识符,表示一个特征(字体、颜色等。)

值:每个属性都有一个值,该值指示由属性描述的特性必须如何改变。

img/468104_2_En_5_Fig1_HTML.png

图 5-1

属性和值,CSS 的构建块

属性和值的配对被称为 CSS 声明。CSS 声明存在于 CSS 声明块中,它们依次与选择器配对。成对的选择器和声明块产生 CSS 规则集(或简称为规则)。

CSS 的 JavaFX 方言对属性使用了前缀 -fx ,以清楚地将它们与 web 属性区分开来,并避免任何兼容性问题,因为许多属性共享相同的名称。这种样式表可能是这样的:

/* resources/chapterX/introduction/styles.css */
.root {
   -fx-background-color: white;
   -fx-padding: 20px;
}
.label {
   -fx-background-color: black;
   -fx-text-fill: white;
   -fx-padding: 10px;
}

。root 选择器指的是场景的根,而。标签选择器引用标签类的一个实例。这再简单不过了——让我们在这个小应用程序中使用这个 CSS:

// chapterX/introduction/HelloCSS.java
public void start(Stage primaryStage) {
   Label label = new Label("Stylized label");
   VBox root = new VBox(label);
   Scene scene = new Scene( root, 200, 100 );
   scene.getStylesheets().add(      getClass().getResource("styles.css").toExternalForm());
   primaryStage.setTitle("My first CSS application");
   primaryStage.setScene(scene);
   primaryStage.show();
}

前面代码的关键特性(以粗体突出显示)是动态加载样式表并将其应用于应用程序场景的部分。图 5-2 显示了没有样式的应用程序旁边的结果应用程序。

正如所料,场景背景颜色和填充是不同的。此外,除了填充之外,我们的标签还有不同的背景和文本颜色。

img/468104_2_En_5_Fig2_HTML.png

图 5-2

有 CSS 样式的应用程序(左)和没有 CSS 样式的应用程序(右)

JavaFX 17 CSS 规则的完整描述可以在 https://openjfx.io/javadoc/17/javafx.graphics/javafx/scene/doc-files/cssref.html 的 JavaFX CSS 参考指南中找到。JavaFX CSS 的所有方面都记录在那里。

样式被应用于场景图的节点,与 CSS 应用于 HTML DOM 元素的方式非常相似——它们首先被应用于父节点,然后被应用于其子节点。完成这项工作的代码经过了高度优化,只将 CSS 应用于需要这种更改的场景图分支。仅当节点是场景图形的一部分时,才会对其进行样式化,并且在以下条件下会重新应用样式:

  • 更改为节点的伪类状态、样式类、ID、内联样式或父级。

  • 样式表被添加到场景中或从场景中移除。

选择器负责将样式匹配到场景图节点,并且可以基于 JavaFX 类名、对象 ID 或者只是分配给特定节点的样式类。让我们看看每个用例。

基于类名的选择器

所有顶级 JavaFX 类都有它们的选择器对应物,命名约定是小写的类名,用连字符分隔单词。专用选择器。根是为场景的根节点保留的样式。下面是一个样式化ListView控件的例子:

.list-view {
   -fx-background-color: lightgrey;
   -fx-pref-width: 250px;
}

如您所见,相同的基于连字符的方法也应用于属性。这里的-fx-pref-width CSS 属性被自动解释为ListView控件的prefWidth属性。

也可以通过使用节点的短类名作为选择器来寻址节点,但不推荐这样做。

基于自定义样式类的选择器

自定义样式类也可用于样式化场景图节点。在这种情况下,必须手动将样式类分配给节点。多个样式类可以分配给同一个节点:

/* Stylesheet */
.big-bold-text {
   -fx-font-weight: bold;
   -fx-font-size: 20pt;
}
// JavaFX code
label.getStyleClass().add("big-bold-text");

基于对象 ID 的选择器

有时需要处理节点上的特定实例。这是通过使用#符号以与 web CSS 相同的方式完成的。ID 必须手动分配给需要特殊样式的节点实例,并且在场景图中必须是唯一的:

/* Stylesheet */
#big-bold-label {
   -fx-font-weight: bold;
   -fx-font-size: 20pt;
}
// JavaFX code
label.setId("big-bold-label");

应用 CSS 样式

加载 CSS 样式表

样式表通常用作应用程序中的资源,因此 Java 资源 API 是加载它的最佳方式。最常见的方法是将资源添加到场景的“样式表”属性中:

// best way of loading stylesheets
  scene.getStylesheets().add(
     getClass().getResource("styles.css").toExternalForm()
  );
  // the following works, but not recommended
  // since it is prone to problems with refactoring
  scene.getStylesheets().add( "package/styles.css");

前面的代码将样式表作为资源从当前类所在的文件夹中加载。

因为 CSS 资源是一个 URL,所以也可以加载远程 CSS 资源:

// remote stylesheet
  scene.getStylesheets().add( "http://website/folder/styles.css" );

请注意,也可以对一个场景应用多个样式表。JavaFX CSS 引擎将在幕后组合这些样式。

在许多情况下,希望将全局样式表应用于整个应用程序,即同时应用于所有场景。这可以通过调用Application.setUserAgentStyleSheet API 来完成。传递 null 将使应用程序返回到默认样式表。目前,JavaFX 提供了两个默认的样式表,它们被定义为常量:

// original stylesheet ( JavaFX 2 and before )
  Application.setUserAgentStylesheet( STYLESHEET_CASPIAN );
  // default stylesheet since JavaFX 8
  Application.setUserAgentStylesheet( STYLESHEET_MODENA );

从 JavaFX 8u20 开始,还可以为场景和SubScene设置用户代理样式表。这允许场景和子场景具有不同于平台默认值的样式。在SubScene上设置用户代理时,将使用其样式,而不是默认平台的样式或场景中设置的任何用户代理样式表。

将 CSS 样式应用于 JavaFX 节点

除了将样式表应用于整个场景,您还可以将它们应用于从javafx.scene.Parent继承的任何节点。该 API 与场景类的 API 完全相同。当您将 CSS 应用于特定节点时,它仅应用于节点本身及其子层次结构中的所有节点。

也可以使用它的setStyle API 来设计节点的样式。这种方法有它自己的优点和缺点,但是,在讨论它们之前,让我们看看它是如何工作的:

  // chapterX/applying/ApplyingStyles.java
  public class ApplyingStyles extends Application {
    private Label label = new Label("Stylized label");
    // Simplistic implementation of numeric field
    private TextField widthField = new TextField("250") {
        @Override
        public void replaceText(int start, int end, String text) {
            if (text.matches("[0-9]*")) {
                super.replaceText(start, end, text);
            }
        }
        @Override
        public void replaceSelection(String replacement) {
            if (replacement.matches("[0-9]*")) {
                super.replaceSelection(replacement);
            }
        }
    };
    private void updateLabelStyle() {
        label.setStyle(
                "-fx-background-color: black;" +
                "-fx-text-fill: white;" +
                "-fx-padding: 10;" +
                "-fx-pref-width: " + widthField.getText() + "px;"
        );
    }
    @Override
    public void start(Stage primaryStage) {
        updateLabelStyle();
        widthField.setOnAction( e -> updateLabelStyle());
        VBox root = new VBox(10, label, widthField);
        root.setStyle(
            "-fx-background-color: lightblue;" +
            "-fx-padding: 20px;");
        Scene scene = new Scene( root, 250, 100 );
        primaryStage.setTitle("My first CSS application");
        primaryStage.setScene(scene);
        primaryStage.show();
    }
}

在前面的代码中,样式直接应用于根控件和标签控件。在根的例子中,我们应用了静态样式,也就是说,根总是浅蓝色的,填充 20 个像素。对于标签,我们选择应用动态样式,其首选宽度来自我们输入到widthField中的值。编辑完数字后,我们一按回车键,它就会改变。图 5-3 显示了更新后的用户界面。

img/468104_2_En_5_Fig3_HTML.png

图 5-3

使用 setStyle 进行动态样式更新

只有在您的样式必须非常动态的情况下,才推荐使用这种方法,通常是基于您自己的 UI 更改,就像前面的例子一样。它对于快速原型制作也非常有用。

在所有其他情况下,拥有外部 CSS 样式是最佳选择,因为它们不需要更改代码,因此不需要重新编译,并且可以在外部编辑。它们还具有更好的性能特征。

很多情况下,可以不使用setStyle,而是调用相应的 API 方法。这是获得最佳性能的地方,因为不涉及 CSS 处理。下面是我们如何替换前面的动态 CSS 属性:

// CSS way
  label.setStyle("-fx-pref-width: 500px");
// JavaFX API way
  label.setPrefWidth(500);

CSS 样式也可以以类似的方式应用于 FXML。任何组件都可以通过以下三种方式之一来设置样式:

  • 通过分配外部样式表中定义的样式类

  • 通过直接使用 style 属性设置样式

  • 通过分配样式表

  <!-- assign a style class -->
  <Label styleClass="fancy-label" />
  <!-- assign a style directly -->
  <Label style="-fx-pref-width: 500px" />
  <!-- assign a stylesheet -->
  <Label stylesheets="@styles.css" />

您刚刚看到了几种将样式应用于 JavaFX 节点的方法。尽管您可以交替应用它们,但 JavaFX 按以下顺序为它们定义了优先级规则:

  1. 应用用户代理样式表。

  2. 应用由 JavaFX API 调用设置的值。

  3. 应用由场景或节点样式表属性设置的样式。

  4. 应用节点的样式属性中的样式。

如您所见,节点的样式将覆盖任何以前的样式设置。这是一个常见的混淆来源,但是规则很清楚——如果您使用setStyle API 设置您的样式,JavaFX 将忽略所有其他方法。

高级 CSS 技术

使用后代选择器

与 web CSS 类似,JavaFX CSS 引擎支持选择器将样式与场景图形节点相匹配。由于 CSS 选择器是一个广为人知的主题,我们将简单地展示几个在 JavaFX CSS 中如何使用它们的例子:

/* all labels */
.label,
.text {
   -fx-font-weight: bold;
   -fx-font-size: 20pt;
}
/* all children of #big-bold */
#big-bold .label {
   -fx-font-weight: bold;
   -fx-font-size: 20pt;
}
/* only direct children of #big-bold */
#big-bold > .label {
   -fx-font-weight: bold;
   -fx-font-size: 20pt;
}

使用伪类

JavaFX CSS 还支持伪类,这允许您定义与 JavaFX 节点的不同状态相对应的样式。JavaFX 没有实现 CSS 标准中指定的所有伪类。相反,每个节点定义一组支持的伪类,可以在 JavaFX CSS 参考指南中找到。

例如,Button 支持以下伪类:

| **全副武装** | 武装变量为真时适用。 | | **取消** | 如果此按钮接收到 VK_ESC,且事件未被消费,则应用此选项。 | | **默认** | 如果该事件未被消费,则在该按钮接收到 VK _ 回车时应用。 |

下面是我们如何利用它们:

/* all buttons will have a font size of 1.1 em */
.button {
   -fx-font-size: 1.1em;
}
/* default buttons will have bigger font size and color*/
.button:default {
   -fx-font-size: 1.2em;
   -fx-font-fill: blue;
}

使用导入

从 JavaFX 8u20 开始,部分支持 CSS @import 规则。目前,只允许无条件导入(不支持媒体类型限定符)。此外,导入语句应该出现在样式表的顶部。

这个特性极大地简化了复杂风格的开发,允许关注点的分离。

可以从本地或远程样式表导入样式:

@import "styles.css"
@import url ("http://website/folder/styles.css")

样式表中的字体加载

从 JavaFX 8 开始,还提供了对@font-face 规则的部分支持,它允许自定义字体加载:

@font-face {
   font-family: 'sample';
   font-style: normal;
   font-weight: normal;
   src: local('sample'),    url('http://font.samples/resources/sample.ttf';) format('truetype');
}

请注意,也支持使用 URL 的远程字体加载。

font-family 属性定义了名称,现在可以在整个样式表中使用。

重用样式

为了实现更高的灵活性,JavaFX CSS 支持常量——一种非标准的 CSS 特性。目前,只有颜色可以被定义为常量。除了许多预定义的命名颜色之外,还可以定义自定义常量,这在《参考指南》中称为“查找颜色”使用查找到的颜色,您可以引用在当前节点或其任何父节点上设置的任何其他颜色属性。这个强大的功能允许在应用程序中使用通用的颜色主题。查找到的颜色是“实时”的,并对任何样式更改做出反应,因为它们在应用之前不会被查找到。

这是一个很棒的功能的简单例子:

.root { -my-button-background: #f00 }
.button { -fx-background-color: -my-button-background }

还有另一种在样式表的其他地方重用样式的方法,那就是使用 inherit 关键字。它只是允许子元素重用其父元素中定义的样式:

.root {
   -fx-font-fill: green;
.button {
   -fx-font-size: 1.1em;
}
.button:default {
   -fx-font-size: 1.2em;
   -fx-font-fill: inherited;
}

在前面的示例中,默认按钮将从根元素继承颜色。

使用高级颜色定义

JavaFX 指定了多种方法来定义绘制值。这些例子如下:

  • 线性梯度

  • 径向梯度

  • 可选重复的图像模式

  • 纯色

使用线性渐变

线性渐变语法定义如下:

linear-gradient( [ [from <point> to <point>] | [ to <side-or-corner>], ]? [ [ repeat | reflect ], ]? <color-stop>[, <color-stop>]+)
where <side-or-corner> = [left | right] || [top | bottom]

线性渐变创建一种渐变,它穿过“从”点和“到”点之间的直线上的所有停止颜色。如果点是百分比,它们是相对于被填充区域的大小。百分比和长度不能混合在一个渐变中。

如果既没有重复也没有反射,那么CycleMethod默认为“NO_CYCLE”

如果既没有给出【从也没有给出【到,那么渐变方向默认为“到底”停止符合 W3C 颜色停止语法,并被相应地规范化。下面是一些例子:

/*
  *  gradient from top left to bottom right
  * with yellow at the top left corner and red in the bottom right
  */
 -fx-text-fill: linear-gradient(to bottom right, yellow, red);
/* same as above but using percentages */
-fx-text-fill: linear-gradient(from 0% 0% to 100% 100%, yellow 0%, green 100%);
/*
  * create a 50px high bar at the top with a 3 color gradient
  * white with underneath for the rest of the filled area.
/*
-fx-text-fill: linear-gradient(from 0px 0px to 0px 50px, gray, darkgray 50%, dimgray 99%, white);

使用径向渐变

径向渐变语法定义如下:

radial-gradient([ focus-angle <angle>, ]? [ focus-distance <percentage>, ]? [ center <point>, ]? radius [ <length> | <percentage> ] [ [ repeat | reflect ], ]? <color-stop>[, <color-stop>]+)

径向渐变创建从中心点向半径向外辐射的所有停止颜色的渐变。如果未给出中心点,则中心默认为(0,0)。百分比值是相对于填充区域的大小而言的。百分比和长度大小不能在单个渐变函数中混合使用。

如果既没有重复也没有反射,那么CycleMethod默认为“NO_CYCLE”

停止符合 W3C 颜色停止语法,并相应地进行规范化:

-fx-text-fill: radial-gradient(radius 100%, red, darkgray, black);
-fx-text-fill: radial-gradient(focus-angle 45deg, focus-distance 20%, center 25% 25%, radius 50%, reflect, gray, darkgray 75%, dimgray);

使用图像模式

这提供了将图像图案用作颜料的能力。以下是图像模式的语法:

image-pattern(<string>, [<size>, <size>, <size>, <size>[, <boolean>]?]?)

参数按顺序定义如下:

| **<弦>** | 图像的 URL。 | | **<大小>** | 锚定矩形的 *x* 原点。 | | **<大小>** | 锚定矩形的 *y* 原点。 | | **<大小>** | 锚定矩形的宽度。 | | **<大小>** | 锚定矩形的高度。 | | **<布林>** | 比例标志,指示开始和结束位置是比例的还是绝对的。 |

下面是一些使用图像模式的例子:

-fx-text-fill: image-pattern("img/wood.png");
-fx-text-fill: image-pattern("img/wood.png", 20%, 20%, 80%, 80%);
-fx-text-fill: image-pattern("img/wood.png", 20%, 20%, 80%, 80%, true);
-fx-text-fill: image-pattern("img/wood.png", 20, 20, 80, 80, false);

图像图案也可用于生成基于平铺图像的填充,这相当于

image-pattern("img/wood.png", 0, 0, imageWith, imageHeight, false);

平铺或重复图像模式的语法是

repeating-image-pattern(<string>)

唯一的参数是图像的 URI。下面是一个重复图像模式的示例:

repeating-image-pattern("img/wood.png")

使用 RGB 颜色定义

RGB 颜色模型用于数字颜色应用。它有许多不同的支持形式:

#<digit><digit><digit>
| #<digit><digit><digit><digit><digit><digit>
| rgb( <integer> , <integer> , <integer> )
| rgb( <integer> %, <integer>% , <integer>% )
| rgba( <integer> , <integer> , <integer> , <number> )
| rgba( <integer>% , <integer>% , <integer> %, <number> )

以下是设置标签文本填充的不同 RGB 格式的示例:

.label { -fx-text-fill: #f00              } /* #rgb */
.label { -fx-text-fill: #ff0000           } /* #rrggbb */
.label { -fx-text-fill: rgb(255,0,0)      }
.label { -fx-text-fill: rgb(100%, 0%, 0%) }
.label { -fx-text-fill: rgba(255,0,0,1)   }

如您所见,有三种类型的 RGB 格式:

| **RGB 十六进制** | 十六进制表示法中 RGB 值的格式是“#”后面紧跟三个或六个十六进制字符。通过复制数字,而不是添加零,三位数 RGB 表示法(#rgb)被转换为六位数形式(#rrggbb)。例如,#fb0 扩展为#ffbb00。这确保了白色(#ffffff)可以用短符号(#fff)来指定,并且消除了对显示器颜色深度的任何依赖性。 | | **RGB 十进制或百分比** | 函数表示法中 rgb 值的格式是“RGB(“后跟一个由三个数值(三个十进制整数值或三个百分比值)组成的逗号分隔列表,后跟”)。”整数值 255 对应于 100%和十六进制记法中的 F 或 FF:RGB(255,255,255) = rgb(100%,100%,100%) = #FFF。数值周围允许有空格字符。 | | **RGB + Alpha** | 这是 RGB 颜色模型的扩展,包括指定颜色不透明度的“alpha”值。这是通过 rgba(...)接受第四个参数,即 alpha 值。alpha 值必须是介于 0.0(表示完全透明)和 1.0(完全不透明)之间的数字。与 rgb()函数一样,红色、绿色和蓝色值可以是十进制整数或百分比。 |

以下示例指定了相同的颜色:

.label { -fx-text-fill: rgb(255,0,0) } /* integer range 0 — 255*/
.label { -fx-text-fill: rgba(255,0,0,1) /* the same, with explicit opacity of 1 */
.label { -fx-text-fill: rgb(100%,0%,0%) } /* float range 0.0% — 100.0% */
.label { -fx-text-fill: rgba(100%,0%,0%,1) } /* the same, with explicit opacity of 1 */

使用 HSB 颜色定义

也可以使用 HSB(有时称为 HSV)颜色模型来指定颜色,如下所示:

hsb( <number> , <number>% , <number>% ) |
hsba( <number> , <number>% , <number>% , <number> )

第一个数字是色调,范围是 0-360 度。

第二个数字是饱和度,百分比在 0-100%之间。

第三个数字是亮度,也是 0-100%范围内的百分比。

hsba(...)表单在末尾接受第四个参数,这是一个在 0.0–1.0 范围内的 alpha 值,分别指定完全透明到完全不透明。

使用颜色功能

JavaFX CSS 引擎为一些颜色计算函数提供支持。这些函数在应用颜色样式的同时从输入颜色计算新颜色。这使得一个颜色主题可以使用一个单一的基础颜色和其他从它计算出来的变体来指定。有两种颜色功能:deriveladder

derive( <color> , <number>% )

derive 函数获取一种颜色,并计算该颜色的更亮或更暗的版本。第二个参数是亮度偏移,表示派生颜色应该有多亮或多暗。正百分比表示较亮的颜色,负百分比表示较暗的颜色。值–100%表示全黑,0%表示亮度没有变化,100%表示全白:

ladder(<color> , <color-stop> [, <color-stop>]+)

梯形函数在颜色之间插值。效果就好像使用提供的光圈创建了一个渐变,然后使用提供的的亮度来索引该渐变中的颜色值。亮度为 0%时,使用渐变 0.0 端的颜色;亮度为 100%时,使用渐变 1.0 端的颜色;在 50%亮度时,使用渐变中点 0.5 处的颜色。请注意,实际上没有渲染任何渐变。这仅仅是产生单一颜色的插值函数。

停止符合 W3C 颜色停止语法,并被相应地规范化。

例如,如果希望文本的颜色根据背景的亮度而为黑色或白色,可以使用下面的方法:

background: white;
-fx-text-fill: ladder(background, white 49%, black 50%);

得到的-fx-text-fill值将是黑色的,因为背景(白色)的亮度为 100%,渐变上 1.0 处的颜色为黑色。如果我们将背景颜色改为黑色或深灰色,亮度将小于 50%,给出白色的-fx-text-fill值。

使用效果定义

JavaFX CSS 目前支持来自 JavaFX 平台的DropShadowInnerShadow效果。关于各种效果参数的语义的更多细节,请参见javafx.scene.effect中的类文档。

阴影

DropShadow 是一种高级效果,用于渲染其背后给定内容的阴影:

| **模糊型** | [高斯|一通道盒|三通道盒|二通道盒]。 | | **颜色** | 阴影颜色。 | | **号** | 阴影模糊内核的半径,在范围[0.0...127.0],典型值 10。 | | **号** | 阴影的蔓延。扩散是源材料贡献为 100%的半径部分。半径的剩余部分将具有由模糊内核控制的贡献。扩散为 0.0 将导致阴影的分布完全由模糊算法决定。“扩散”为 1.0 将导致源材质不透明度向外的实体增长到半径的极限,在半径处非常明显地截止到透明度。值应在范围[0.0...1.0]. | | **号** | 水平方向上的阴影偏移量,以像素为单位。 | | **号** | 垂直方向上的阴影偏移量,以像素为单位。 |
dropshadow( <blur-type> , <color> , <number> , <number> , <number> , <number> )

内心阴影

内部阴影是一种高级效果,在给定内容的边缘内渲染阴影:

| **模糊型** | [高斯|一通道盒|三通道盒|二通道盒]。 | | **颜色** | 阴影颜色。 | | **号** | 阴影模糊内核的半径,在范围[0.0...127.0],典型值 10。 | | **号** | 阴影的窒息。扼流圈是源材料贡献为 100%的半径部分。半径的剩余部分将具有由模糊内核控制的贡献。阻塞值为 0.0 将导致阴影的分布完全由模糊算法决定。扼流值为 1.0 将导致阴影从边缘到半径的极限向内增长,并在半径内对透明度有一个非常明显的截止。值应在范围[0.0...1.0]. | | **号** | 水平方向上的阴影偏移量,以像素为单位。 | | **号** | 垂直方向上的阴影偏移量,以像素为单位。 |
innershadow( <blur-type> , <color> , <number> , <number> , <number> , <number> )

有用的提示和技巧

研究 Modena 样式表

如前所述,Modena 是 JavaFX 8 中引入的默认用户代理样式表。

它包含了大量有用的定义,任何希望在 JavaFX 应用程序中使用 CSS 样式的人都应该学习一下。

根据摩德纳风格定义主题

Modena 样式表颜色定义基于几个属性,这些属性可以在它的根部分找到。最重要的是-fx-base,它是所有对象的基础颜色:

.root {
    /**********************************************************************
     *                                    *
     * The main color palette from which the rest of the colors are derived.   *
     *                                    *
     *********************************************************************/
    /* A light grey that is the base color for objects.  Instead of using
     * -fx-base directly, the sections in this file will typically use -fx-color.
     */
    -fx-base: #ececec;
    /* A very light grey used for the background of windows.  See also
     * -fx-text-background-color, which should be used as the -fx-text-fill
     * value for text painted on top of backgrounds colored with -fx-background.
     */
    -fx-background: derive(-fx-base,26.4%);
    /* Used for the inside of text boxes, password boxes, lists, trees, and
     * tables.  See also -fx-text-inner-color, which should be used as the
     * -fx-text-fill value for text painted on top of backgrounds colored
     * with -fx-control-inner-background.
     */
    -fx-control-inner-background: derive(-fx-base,80%);
    /* Version of -fx-control-inner-background for alternative rows */
    -fx-control-inner-background-alt: derive(-fx-control-inner-background,-2%);
    ....
}

这使我们可以轻松地重新定义样式表的整体颜色主题。例如,创建一个主题,它与著名的 IntelliJ IDEA“Darcula”主题非常相似:

.root {
    -fx-base: rgba(60, 63, 65, 255);
}

它不仅设置了所有适当的对象颜色,还正确显示了文本颜色,因为 Modena 样式表使用梯形方法来计算适当的对比色。

使用 CSS 定义图标

我们不需要使用 Java 代码来加载和分配图像,而是可以使用 CSS 定义来更简单地完成。让我们看一下标签的例子,它的样式类为“image-label”:

.image-label {
  -fx-graphic: url("icon.jpg");
}

使用 URL,我们只需将适当的资源分配给-fx-graphic 属性。这从我们的应用程序中删除了不必要的样式代码,同时在样式和代码之间给出了一个清晰的分离。

通过使用颜色常量实现 CSS 的可重用性

如前所述,JavaFX CSS 引擎支持一个非标准特性,称为颜色常量。这些常量只能在样式表的根部分定义,但是可以在整个应用程序中重用。这不仅提高了可重用性,而且给你的应用程序一个漂亮一致的外观。

使用透明颜色

在许多情况下,应用程序的设计要求使用特定的颜色,并完全控制背景色。例如,您正在设计自定义控件的样式,但是不知道将在哪里使用它。您希望将控件的颜色与任何背景完美融合。颜色不透明的救援!

让我们看看这种技术如何让我们混合颜色。

img/468104_2_En_5_Fig4_HTML.png

图 5-4

使用不透明度混合颜色

在图 5-4 中,你可以看到透明色是如何与几乎任何背景完美融合的。相比之下,100%不透明度的颜色不会混合,而且看起来常常不协调。

为了欣赏这样的设计,让我们来看看 Trello 板。

img/468104_2_En_5_Fig5_HTML.jpg

图 5-5

Trello Boards 用户界面利用高级 CSS 样式

在图 5-5 中,你可以看到按钮、搜索栏,甚至是面板本身是如何与用户选择的任何背景完美融合的。

高级 CSS API

可以用新的定制样式类、属性和伪类来扩展标准 JavaFX CSS。这些技术在开发新的自定义控件时特别有用,并且需要对 CSS API 有透彻的理解。

为了说明 JavaFX CSS API 的特性,我们将创建一个简单的自定义控件。这个控件将代表天气类型,显示相关的图标和文本。为了简单起见,我们将从标准的 JavaFX 标签扩展这个控件。此外,我们还将添加一个自定义的伪样式来表示一种危险的天气类型,这将允许我们以不同的方式对这些类型进行样式化。

首先,我们定义枚举,代表我们关心的天气类型。因为我们将使用名为“常规天气图标”的图标字体来显示图标,所以我们将把相关的字体字符传递到每个枚举中。一个额外的枚举参数将允许我们定义哪种天气是危险的:

// chapterX/cssapi/WeatherType.java
import javafx.scene.text.Text;

public enum WeatherType {

    SUNNY("\uf00d", false),
    CLOUDY("\uf013", false),
    RAIN("\uf019", false),
    THUNDERSTORM("\uf033", true);
    private final boolean dangerous;
    private final String c;
    WeatherType(String c, boolean dangerous) {
        this.c = c;
        this.dangerous = dangerous;
    }
    public boolean isDangerous() {
        return dangerous;
    }
    Text buildGraphic() {
        Text text = new Text(c);
        text.setStyle("-fx-font-family: 'Weather Icons Regular'; -fx-font-size: 25;");
        return text;
    }
}

图标将由文本控件表示。我们图标的字符将被设置为文本。我们还将设计它的样式,确保它使用合适的字体系列和大小。方法buildGraphic将为特定的枚举构建文本控件。

是时候构建我们的自定义控件了!

首先,我们要定义表示控件的样式类、天气属性和新伪类的常量:

private static final String STYLE_CLASS       = "weather-icon";
private static final String WEATHER_PROP_NAME = "-fx-weather";
private static final String PSEUDO_CLASS_NAME = "dangerous";

接下来,我们将定义我们的 styleable 属性。这是一种特殊类型的属性,可以从 CSS 中设置样式。该属性将采用我们的 WeatherType 的值,并将相应地更改我们控件的图标和文本:

private StyleableObjectProperty<WeatherType> weatherTypeProperty = new StyleableObjectProperty<>(WeatherType.SUNNY) {
        @Override
        public CssMetaData<? extends Styleable, WeatherType> getCssMetaData() {
            return WEATHER_TYPE_METADATA;
        }
        @Override
        public Object getBean() {
            return WeatherIcon.this;
        }
        @Override
        public String getName() {
            return WEATHER_PROP_NAME;
        }
        @Override
        protected void invalidated() {
            WeatherType weatherType = get();
            dangerous.set( weatherType.isDangerous());
            setGraphic(weatherType.buildGraphic());
            setText(get().toString());
        }
    };

因为我们的属性值是枚举类型,所以我们使用StyleableObjectProperty<WeatherType>。invalidate 方法的实现定义了当我们的属性改变时会发生什么。这里我们使用新实例化的天气类型来设置控件的图形和文本。我们还在这里设置了伪类,后面会讲到。

该属性还返回一个名为WEATHER_TYPE_METADATA的东西。在 JavaFX 中,CssMetaData实例提供关于 CSS 样式和允许 CSS 设置属性值的钩子的信息。它封装了 CSS 属性名称、CSS 值转换成的类型以及属性的默认值。

CssMetaData是可以在. css 文件中用语法表示的值和StyleableProperty之间的桥梁。CSS 元数据和StyleableProperty之间是一一对应的。通常,一个节点的CssMetaData将包括其祖先的CssMetaData

为了大大减少实现 StyleableProperty 和CssMetaData所需的样板代码量,我们将使用StyledPropertyFactory类。这个类定义了很多方法来创建StyleableProperty的实例和相应的CssMetaData:

private static final StyleablePropertyFactory<WeatherIcon> STYLEABLE_PROPERTY_FACTORY = new
             StyleablePropertyFactory<>(Region.getClassCssMetaData());
    private static CssMetaData<WeatherIcon, WeatherType> WEATHER_TYPE_METADATA =
            STYLEABLE_PROPERTY_FACTORY.createEnumCssMetaData(
                    WeatherType.class, WEATHER_PROP_NAME, x -> x.weatherTypeProperty);
@Override
    public List<CssMetaData<? extends Styleable, ?>> getControlCss
    MetaData() {
        return List.of(WEATHER_TYPE_METADATA);
    }

我们还实现了getControlCssMetaData方法,该方法允许 JavaFX CSS 引擎通过返回控件样式属性列表来了解控件的 CSS 元数据的所有信息。

剩下的就是实现我们的伪类了。由于我们的控制中只有两种状态,危险和正常,我们可以将伪类实现为布尔属性。每当属性改变时,我们调用一个特殊的方法 pseudoClassStateChanged,让 CSS 引擎知道状态已经改变:

private BooleanProperty dangerous = new BooleanPropertyBase(false) {
        public void invalidated() {
            pseudoClassStateChanged(DANGEROUS_PSEUDO_CLASS, get());
        }
        @Override public Object getBean() {
            return WeatherIcon.this;
        }
        @Override public String getName() {
            return PSEUDO_CLASS_NAME;
        }
    };

现在只剩下几个整容的变化了。让我们看看我们控制的完整状态:

// chapterX/cssapi/WeatherIcon.java
public class WeatherIcon extends Label {
    private static final String STYLE_CLASS       = "weather-icon";
    private static final String WEATHER_PROP_NAME = "-fx-weather";
    private static final String PSEUDO_CLASS_NAME = "dangerous";

    private static PseudoClass DANGEROUS_PSEUDO_CLASS = PseudoClass.getPseudoClass(PSEUDO_CLASS_NAME);

    private static final StyleablePropertyFactory<WeatherIcon> STYLEABLE_PROPERTY_FACTORY =
            new StyleablePropertyFactory<>(Region.getClassCssMetaData());

    private static CssMetaData<WeatherIcon, WeatherType> WEATHER_TYPE_METADATA =
            STYLEABLE_PROPERTY_FACTORY.createEnumCssMetaData(
                    WeatherType.class, WEATHER_PROP_NAME, x -> x.weatherTypeProperty);
    public WeatherIcon() {
        getStyleClass().setAll(STYLE_CLASS);
    }

    public WeatherIcon(WeatherType weatherType ) {
        this();
        setWeather( weatherType);
    }
    private BooleanProperty dangerous = new BooleanPropertyBase(false) {
        public void invalidated() {
            pseudoClassStateChanged(DANGEROUS_PSEUDO_CLASS, get());
        }
        @Override public Object getBean() {
            return WeatherIcon.this;
        }
        @Override public String getName() {
            return PSEUDO_CLASS_NAME;
        }
    };

    private StyleableObjectProperty<WeatherType> weatherTypeProperty = new StyleableObjectProperty<>(WeatherType.SUNNY) {

        @Override
        public CssMetaData<? extends Styleable, WeatherType> getCssMetaData() {
            return WEATHER_TYPE_METADATA;
        }
        @Override
        public Object getBean() {
            return WeatherIcon.this;
        }
        @Override
        public String getName() {
            return WEATHER_PROP_NAME;
        }
        @Override
        protected void invalidated() {
            WeatherType weatherType = get();
            dangerous.set( weatherType.isDangerous());
            setGraphic(weatherType.buildGraphic());
            setText(get().toString());
        }
    };
    @Override

    public List<CssMetaData<? extends Styleable, ?>> getControlCss
    MetaData() {
        return List.of(WEATHER_TYPE_METADATA);
    }
    public WeatherType weatherProperty() {
        return weatherTypeProperty.get();
    }
    public void setWeather(WeatherType weather) {
        this.weatherTypeProperty.set(weather);
    }
    public WeatherType getWeather() {
        return weatherTypeProperty.get();
    }
}

让我们通过创建一个小的应用程序来测试这个控件,这个应用程序在各种不同的状态下创建这个控件,它使用 CSS 和 Java 代码来设置我们的天气类型。

首先,让我们定义我们的 CSS:

  • 我们首先加载我们的字体。JavaFX 要求字体资源与 CSS 文件位于同一位置。

  • 定义根样式。

  • 定义两个自定义样式类。雷雨和雨。他们相应地设置天气类型。

  • 定义两种状态的样式:正常和危险。危险状态以红色背景显示。

/* resources/chapterX/cssapi/styles.css */
@font-face {
    font-family: 'Weather Icons Regular';
    src: url('weathericons-regular-webfont.ttf');
}
.root {
    -fx-background-color: lightblue;
    -fx-padding: 20px;
}
.thunderstorm {
    -fx-weather: THUNDERSTORM;
}
.rain {
    -fx-weather: RAIN;
}
.weather-icon {
    -fx-graphic-text-gap: 30;
    -fx-padding: 10;
}
.weather-icon:dangerous {
    -fx-background-color: rgba(255, 0, 0, 0.25);
}

我们的测试应用程序几乎是微不足道的。我们使用 CSS 样式类或代码创建了几个设置天气类型的 WeatherIcon 控件。然后,我们使用垂直布局(VBox)呈现它们:

/* chapterX/cssapi/WeatherApp.java */
public class WeatherApp extends Application {
    @Override
    public void start(Stage primaryStage)  {
        WeatherIcon rain = new WeatherIcon();
        rain.getStyleClass().add("rain");
        WeatherIcon thunderstorm = new WeatherIcon();
        thunderstorm.getStyleClass().add("thunderstorm");
        WeatherIcon clouds = new WeatherIcon( WeatherType.CLOUDY);
        VBox root = new VBox(10, rain, thunderstorm, clouds);
        root.setAlignment(Pos.CENTER);
        Scene scene = new Scene( root);
        scene.getStylesheets().add( getClass().getResource("styles.css").toExternalForm());
        primaryStage.setTitle("WeatherType Application");
        primaryStage.setScene(scene);
        primaryStage.show();
    }
}

如图 5-6 所示,“危险”雷暴天气以红色背景突出显示,而其余天气图标则没有。

img/468104_2_En_5_Fig6_HTML.png

图 5-6

使用伪类进行动态样式更新的 WeatherType 应用程序

在这个例子中,我们选择天气类型一改变,就自动改变伪类。将伪类属性公开为 public 是非常可能的,这将为我们提供一种独立于天气类型来更改它的方法。

我们已经展示了一种非常强大的方法来扩展 JavaFX CSS,使用附加的样式类、可样式化的属性和伪类来表示使用高级 JavaFX CSS APIs 的附加组件状态。

JavaFX 应用程序中的 CSS:摘要

总的来说,能够使用 CSS 样式表设计 UI 是一个巨大的飞跃。它带来了以下好处:

  • 职责分工

代码和样式是明确分开的,可以独立更新。

  • 更高的设计一致性

CSS 样式表可以很容易地被重用,给开发者更大的设计一致性。

  • 轻量级代码

因为代码与样式是分离的,所以它不会被只做样式的部分重载,这提供了更轻量级的代码。

  • 快速改变造型的能力

只需在样式表中切换几个定义就可以改变样式,而不需要修改任何代码。还可以基于硬件平台或操作系统轻松提供完全不同的风格。

六、高性能显示

威廉·安东尼奥·西西里

JavaFX 是一个用于创建丰富用户界面的完整平台。它有一套完整的控件可供使用,并允许开发人员使用 CSS 来设计他们的应用程序。在 JavaFX 提供的所有控件中,我们拥有强大的画布。使用 Canvas,我们可以利用 JavaFX 硬件加速图形创建视觉上令人印象深刻的图形应用程序。在这一章中,我们将探索 Canvas 使用已知算法和技术创建动态图形应用程序的能力。

假设您的任务是创建一个 JavaFX 游戏。您可以使用标准的控件 API 来实现它,但是控件并不适合它。如果您必须构建一个模拟或其他类型的需要持续更新屏幕的应用程序,也是如此。对于这种情况,我们通常使用画布。

来自 JavaFX API 的 Canvas 类似于来自其他平台和编程语言的 canvas,由于它是 Java,我们可以将其移植到移动和嵌入式设备,并利用 JavaFX 硬件加速。作为 Java 库的一部分的另一个巨大优势是,我们可以使用无限数量的可用 API 来检索信息,这些信息稍后可以显示在画布上,例如,访问远程服务或数据库来检索可以使用画布以独特方式显示的数据。

就像按钮或标签一样,javafx.scene.canvas.Canvas 是 Node 的子类,这意味着它可以添加到 javafx 场景图中,并应用转换、事件侦听器和效果。然而,要使用 Canvas,我们需要另一个类 GraphicsContext,所有神奇的事情都发生在这里。从 GraphicsContext 中,我们可以访问在画布上绘制以构建应用程序的所有方法。目前,JavaFX 仅支持 2D 图形上下文,但这足以创建高性能图形。

使用画布

要开始使用 Canvas,我们先画几个简单的几何图形和一段文字。在清单 6-1 中,您可以看到一个利用 GraphicsContext 绘制简单表单和文本的小应用程序。

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.scene.text.Font;
import javafx.scene.text.TextAlignment;
import javafx.stage.Stage;
public class HelloCanvas extends Application {
        private static final String MSG = "JavaFX Rocks!";
        private static final int WIDTH = 800;
        private static final int HEIGHT = 600;
        public static void main(String[] args) {
                launch();
        }
        @Override
        public void start(Stage stage) throws Exception {
                Canvas canvas = new Canvas(800, 600);
                GraphicsContext gc = canvas.getGraphicsContext2D();
                gc.setFill(Color.WHITESMOKE);
                gc.fillRect(0, 0, WIDTH, HEIGHT);
                gc.setFill(Color.DARKBLUE);
                gc.fillRoundRect(100, 200, WIDTH - 200, 180, 90, 90);
                gc.setTextAlign(TextAlignment.CENTER);
                gc.setFont(Font.font(60));
                gc.setFill(Color.LIGHTBLUE);
                 gc.fillText(MSG, WIDTH / 2, HEIGHT / 2);
                 gc.setStroke(Color.BLUE);
                 gc.strokeText(MSG, WIDTH / 2, HEIGHT / 2);
                 stage.setScene(new Scene(new StackPane(canvas), WIDTH, HEIGHT));
                 stage.setTitle("Hello Canvas");
                 stage.show();
        }
}

Listing 6-1Hello Canvas application

如前所述,Java FX . scene . canvas . graphics context 类用于指示将在画布上绘制什么,通过它,我们可以使用例如 fillRect 和 fillOval 来填充几何形状。为了选择用于填充几何形状的颜色,我们使用 setFill 方法,该方法接受 Paint 类型的对象。Color 是 Paint 的子类,它有内置的颜色供我们使用,所以我们不必选择实际的颜色红色、绿色和蓝色值。我们可以挑选一些可用的颜色。像 setFill 一样,我们也可以使用 strokeRect 和 strokeOval 等方法来描边几何形状和文本,并且可以使用 setStroke 来设置描边颜色。更改笔触和填充就像使用带有调色板的画笔一样,在进行实际绘制或绘画之前,您必须先用所需的颜色绘制画笔。该应用的结果如图 6-1 所示。

img/468104_2_En_6_Fig1_HTML.png

图 6-1

一个简单的画布应用程序,绘制一个矩形和一个文本

当我们画东西时,我们还必须提供它的 x 和 y 位置,这类似于我们必须在笛卡尔坐标系中追踪函数时所做的事情。熟悉 Canvas 如何看到 x 和 y 位置对于正确编写表单很重要,它基本上是从左上角开始考虑 y 的。对于 x 来说,是一样的;但是,较高的 y 值意味着您正在绘制的元素将接近应用程序的底部。使用清单 6-2 中的代码,我们可以生成图 6-2 中的应用程序,它在画布中显示各种 x,y 坐标。在一个嵌套的 for 循环中,我们画出矩形,并用文本画出小椭圆来显示每个 x,y 点。请注意,在绘制文本之前,我们必须将填充改为白色,然后选择红色来绘制椭圆形。

img/468104_2_En_6_Fig2_HTML.png

图 6-2

这个应用程序展示了 x 和 y 位置如何在 JavaFX 画布中工作

Canvas canvas = new Canvas(WIDTH, HEIGHT);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFont(Font.font(12));
gc.setFill(Color.BLACK);
gc.fillRect(0, 0, WIDTH, HEIGHT);
gc.setStroke(Color.LIGHTGRAY);
for (int i = 0; i < WIDTH; i+=RECT_S) {
        for (int j = 0; j < HEIGHT; j+=RECT_S) {
                gc.strokeRect(i, j, RECT_S, RECT_S);
                gc.setFill(Color.WHITE);
                gc.fillText("x=" + i + ",y=" + j, i + 2, j + 12);
                gc.setFill(Color.RED);
                gc.fillOval(i - 4, j - 4, 8, 8);
        }
}

Listing 6-2Drawing x,y coordinates

使用事件处理和画布绘制功能,我们可以改变图形的创建方式。例如,允许用户在画布上自由绘制,如清单 6-3 所示,其中我们注册了一个鼠标按下的监听器,并开始绘制路径。然后在 onMouseDragged 上,我们不断地向路径添加行。如果用户停止拖动并再次按下鼠标按钮,就会创建一个新路径。当用户用鼠标辅助按钮点击画布时,我们在画布上的所有内容上绘制背景,并对其进行清理。创建路径的方法允许您交互式地构建几何形状;在这种情况下,我们只是用它来使绘图更精确(我们可以画小点来代替,创建路径),但这部分 API 还有许多其他应用程序。结果是一个简单的绘画应用程序,如图 6-3 所示。

img/468104_2_En_6_Fig3_HTML.jpg

图 6-3

一个小的 JavaFX 绘图应用程序

public void start(Stage stage) throws Exception {
        Canvas canvas = new Canvas(800, 600);
        GraphicsContext ctx = canvas.getGraphicsContext2D();
        ctx.setLineWidth(10);
        canvas.setOnMousePressed(e -> ctx.beginPath());
        canvas.setOnMouseDragged(e -> {
                ctx.lineTo(e.getX(), e.getY());
                ctx.stroke();
        });
        canvas.setOnMouseClicked(e -> {
                if(e.getButton() == MouseButton.SECONDARY) {
                        clear(ctx);
                }
        });
        stage.setTitle("Drawing on Canvas");
        stage.setScene(new Scene(new StackPane(canvas), WIDTH, HEIGHT));
        stage.show();
        clear(ctx);
}
public void clear(GraphicsContext ctx) {
        ctx.setFill(Color.DARKBLUE);
        ctx.fillRect(0, 0, WIDTH, HEIGHT);
        ctx.setStroke(Color.ALICEBLUE);
}

Listing 6-3Drawing on a canvas

到目前为止,我们只是探索了创建形状和文本的高级 GraphicsContext 方法。如果我们想要构建更复杂的图形,我们可能需要直接处理像素,一个接一个。幸运的是,这可以通过使用 PixelWriter 轻松实现,pixel writer 可以从 GraphicsContext 访问。使用像素写入器,我们可以设置画布中每个像素的颜色。像素的数量取决于画布的大小,例如,如果它的大小为 800 × 600,那么它有 480000 个像素,可以使用 x 和 y 点分别访问这些像素。换句话说,我们可以通过从 x = 0 迭代到 x = Canvas.getWidth 来遍历画布的每个像素,在这个迭代中,我们可以从 y = 0 迭代到 y = canvas.getHeight。将其转换为代码,我们可以看到清单 6-4 中的内容,这导致画布具有随机像素,如图 6-4 所示。

img/468104_2_En_6_Fig4_HTML.png

图 6-4

带有随机像素的画布

Canvas canvas = new Canvas(WIDTH, HEIGHT);
GraphicsContext gc = canvas.getGraphicsContext2D();
for (int i = 0; i < canvas.getWidth(); i++) {
        for (int j = 0; j < canvas.getHeight(); j++) {
                gc.getPixelWriter().setColor(i, j, Color.color(Math.random(), Math.random(), Math.random()));
        }
}

Listing 6-4Writing random colors to each pixel of a canvas

GraphicsContext 类还允许您绘制复杂的路径、其他几何形状和图像,并配置内容的显示方式。为了探索所有 Canvas 和 GraphicsContext 的可能性,我们建议您阅读 Javadocs,在那里您将找到所有可用的方法以及如何使用它们的信息。

赋予画布应用程序生命

为了创建我们在本章开始时描述的那种应用程序,我们需要不断地更新画布来创建动画或模拟。有许多不同的方法可以实现这一点;然而,为了保持简单,我们将从处理编程语言中获得灵感,并创建一个重复调用的方法 draw 和一个在抽象类 GraphicApp 上只调用一次的 setup。在这一章中,我们将使用 GraphicApp 来探索一些已知的算法,因为它有一些会在所有例子中重复的代码。使用这个抽象类,我们可以专注于设置和绘制,而不必在每个示例中重复自己。让我们通过检查清单 6-5 中的源代码来理解它的作用。

import javafx.animation.KeyFrame;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.scene.Node;
import javafx.scene.Scene;
import javafx.scene.canvas.Canvas;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.layout.BorderPane;
import javafx.scene.paint.Color;
import javafx.scene.paint.Paint;
import javafx.stage.Stage;
import javafx.util.Duration;
public abstract class GraphicApp extends Application {
        protected int width = 800;
        protected int height = 600;
        protected GraphicsContext graphicContext;
        private Paint backgroundColor = Color.BLACK;
        private Timeline timeline = new Timeline();
        private int frames = 30;
        private BorderPane root;
        private Stage stage;
        @Override
        public void start(Stage stage) throws Exception {
                this.stage = stage;
                Canvas canvas = new Canvas(width, height);
                graphicContext = canvas.getGraphicsContext2D();
                canvas.requestFocus();
                root = new BorderPane(canvas);
                stage.setScene(new Scene(root));
                setup();
                canvas.setWidth(width);
                canvas.setHeight(height);
                startDrawing();
                stage.show();
                internalDraw();
        }
        public abstract void setup();
        public abstract void draw();
        public void title(String title) {
                stage.setTitle(title);
        }
        public void background(Paint color) {
                backgroundColor = color;
        }
        public void frames(int frames) {
                this.frames = frames;
                startDrawing();
        }

        public void setBottom(Node node) {
                root.setBottom(node);
        }
        private void internalDraw() {
                graphicContext.setFill(backgroundColor);
                graphicContext.fillRect(0, 0, width, height);
                draw();
        }
        private void startDrawing() {
                timeline.stop();
                if (frames > 0) {
                        timeline.getKeyFrames().clear();
                        KeyFrame frame = new                         KeyFrame(Duration.millis(1000 /
                        frames), e -> internalDraw());
                        timeline.getKeyFrames().add(frame);
                        timeline.setCycleCount(Timeline.INDEFINITE);
                        timeline.play();
                }
        }

public double map(double value, double start1, double stop1, double start2, double stop2) {
        return start2 + (stop2 - start2) * ((value - start1) / (stop1 - start1));
        }
}

Listing 6-5The GraphicApp abstract class provides a skeleton for creating animated graphics using Canvas

注意方法 draw 和 setup 是抽象的。要创建应用程序,我们必须扩展 GraphicApp 并实现这些方法。方法 draw 调用频率由 Timeline 类控制,正如您在方法 startDrawing 中看到的,其中唯一的帧持续时间由 frame int 参数控制,该参数表示每秒的帧数。在方法 draw 上,可以访问 grahicsContext 参数,它属于 GraphicsContext 类型,然后开始创建您的应用程序。使用 grahicsContext,还可以访问画布来注册侦听器,这样就可以响应用户输入。方法映射是将一个范围的值转换为另一个范围的实用程序。最后,您可以使用 setBottom 方法将自定义控件添加到底部。

使用 GraphicApp,我们可以专注于我们的视觉效果。例如,让我们创建一个弹跳球应用程序。这个应用程序简单地绘制了几个椭圆,当它们到达应用程序边界时会改变方向。你可以在清单 6-6 中看到,我们专注于我们的想法,这是一个使用类 ball 表示一个球的模型元素,然后为它生成随机值;对于 draw 中的每次迭代,我们更新球的位置并将其绘制在屏幕上。

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
public class BouncingBalls extends GraphicApp {
        private static final int TOTAL_BALLS = 20;
        List<Ball> balls = new ArrayList<>();
        public static void main(String[] args) {
                launch(args);
        }
        @Override
        public void setup() {
                Random random = new Random();
                for (int i = 0; i < TOTAL_BALLS; i++) {
                        Ball ball = new Ball();
                        ball.circ = random.nextInt(100) + 10;
                        ball.x = random.nextInt(width - ball.circ);
                        ball.y = random.nextInt(height - ball.circ);
                        ball.xDir = random.nextBoolean() ? 1: -1;
                        ball.yDir = random.nextBoolean() ? 1: -1;
                        ball.color = Color.color(Math.random(),
                        Math.random(), Math.random());
                        balls.add(ball);
                }
                background(Color.DARKCYAN);
        }
        @Override
        public void draw() {
                for (Ball ball : balls) {
                        ball.update();
                        ball.draw(graphicContext);
                }
        }
        public class Ball {
                int x, y, xDir = 1, yDir = 1, circ;
                Color color;
                public void update() {
                        if (x + circ > width || x < 0) {
                                xDir *= -1;
                        }
                        if (y + circ > height || y < 0) {
                                yDir *= -1;
                        }
                        x += 5 * xDir;
                        y += 5 * yDir;
                }
                public void draw(GraphicsContext gc) {
                        gc.setLineWidth(10);
                        gc.setFill(color);
                        gc.fillOval(x, y, circ, circ);
                        gc.setStroke(Color.BLACK);
                        gc.strokeOval(x, y, circ, circ);
                }
        }
}

Listing 6-6The bouncing balls

example

当您运行这个应用程序时,您将看到球在画布上向四周移动,如图 6-5 所示。您可以通过添加交叉点检测、物理、事件处理或任何其他使其有用或酷的效果来改进它。

img/468104_2_En_6_Fig5_HTML.png

图 6-5

弹跳球示例

说到这里,让我们使用我们的 GraphicsApp 来探索一些已知的算法。

粒子系统

威廉·里维斯在论文《粒子系统:一种对一类模糊对象建模的技术》中引入了粒子系统,他将粒子系统定义为“许多许多微小粒子的集合,它们共同代表一个模糊对象。”你可以认为它有两个主要部分:发射器和粒子。一个发射器不断创造粒子,最终会死亡。粒子系统的应用包括:

  • 游戏效果:爆炸,碰撞

  • 动画:火,云,波浪撞击石头

  • 模拟:空间,生物的繁殖

用几行代码创建一个非常简单的粒子系统是可能的,但是这种类型的系统可能相当复杂,这取决于我们想要实现什么。对于简单和高级的粒子系统,我们基本上需要两个类:粒子和发射器。粒子类取决于发射器,一个发射器可以有一个或无限个粒子。

使用这些类,我们可以构建一个具有单个发射器的应用程序,它可以生成向随机方向移动的粒子。参见图 6-6 以及清单 6-7 中生成它的代码。

img/468104_2_En_6_Fig6_HTML.png

图 6-6

一个非常简单的粒子系统

import java.util.ArrayList;
import java.util.List;
import java.util.Random;
import java.util.stream.Collectors;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
public class ParticleSystem extends GraphicApp {
        private List<Emitter> emitters = new ArrayList<>();
        Random random = new Random();
        public static void main(String[] args) {
                launch();
        }
        @Override
        public void setup() {
                frames(50);
                width = 1200;
                height = 800;
                // you can change it to onMouseDragged
                graphicContext.getCanvas().setOnMouseDragged(e ->                 emitters.
                add(new Emitter(5, e.getSceneX(), e.getSceneY())));
                title("Simple Particle System");
        }
        @Override
        public void draw() {
                for (Emitter emitter : emitters) {
                        emitter.emit(graphicContext);
                }
        }
        public class Emitter {
                List<Particle> particles = new ArrayList<>();
                int n = 1;
                double x, y;
                public Emitter(int n, double x, double y) {
                        this.n = n;
                        this.x = x;
                        this.y = y;
                }
                public void emit(GraphicsContext gc) {
                        for (int i = 0; i < n; i++) {
                                int duration = random.nextInt(200) + 2;
                                double yDir = random.nextDouble() * 2.0 +                                 -1.0;
                                double xDir = random.nextDouble() * 2.0 +                                 -1.0;
                                Particle p = new Particle(x, y, duration,                                 xDir, yDir);
                                particles.add(p);
                        }
                        for (Particle particle : particles) {
                                particle.step();
                                particle.show(gc);
                        }
                        particles = particles.stream().filter(p ->                         p.duration > 0).collect(Collectors.toList());
                }
        }

        public class Particle {
                int duration;
                double x, y, yDir, xDir;
                public Particle(double x, double y, int duration, double yDir, double xDir) {
                        this.x = x;
                        this.y = y;
                        this.duration = duration;
                        this.yDir = yDir;
                        this.xDir = xDir;
                }
                public void step() {
                        x += xDir;
                        y += yDir;
                        duration--;
                }
                public void show(GraphicsContext gc) {
                        gc.setFill(Color.rgb(255, 20, 20, 0.6));
                        gc.fillOval(x, y, 3, 3);
                }
        }
}

Listing 6-7Very simple particle system

在清单 6-7 的代码中,我们在用户点击画布的位置生成了一个粒子系统。请注意,每次调用 emit 方法时,类 Emitter 都会生成粒子,并且它还会绘制现有的粒子;这两个动作可以用两种不同的方法分开。一个粒子是一个简单的椭圆形;它有一个持续时间,一个初始的 x 和 y 位置,一个 y 和 x 方向。发射器发射所有粒子,完成后,所有过时的粒子都会被移除。代码灵活且易于扩展,例如,当我们在画布中移除鼠标点击事件监听器并将其更改为使用鼠标拖动事件时,我们可以使用粒子系统“编写”。见图 6-7 。

img/468104_2_En_6_Fig7_HTML.png

图 6-7

使用鼠标拖动粒子系统

为了使粒子系统可配置,让我们在应用程序的底部添加一个控制面板,这样用户可以配置发射器和粒子的许多方面,以试验粒子系统的全部潜力。为此,我们创建了一个应用程序,允许用户在单击画布时添加新的发射器。参见图 6-8 中我们的可配置粒子系统。

img/468104_2_En_6_Fig8_HTML.png

图 6-8

可配置粒子系统

创建发射器的代码可以在清单 6-8 中找到。它的工作方式很简单。当画布上发生点击时,一个新的发射器被添加到列表中;在 draw 方法中,调用每个粒子系统的 emit 方法。底部窗格中的配置(见图 6-8 )在创建时被传递给每个发射器,如果用户选择切换按钮静态配置,特定发射器的配置不会实时更新。

@Override
public void setup() {
        frames(20);
        width = 1200;
        height = 800;
        GridPane gpConfRoot = buildConfigurationRoot();
        TitledPane tpConf = new TitledPane("Configuration", gpConfRoot);
        tpConf.setCollapsible(false);
        setBottom(tpConf);
        graphicContext.getCanvas().setOnMouseClicked(e -> {
                Emitter newEmitter;
                if (globalConf.cloneConfProperty.get()) {
                        newEmitter = new Emitter(e.getSceneX(),
                        e.getSceneY(), globalConf.clone());
                } else {
                        newEmitter = new Emitter(e.getSceneX(),
                        e.getSceneY(), globalConf);
                }
                emitters.add(newEmitter);
        });
        title("Particle System configurable");
}
@Override
public void draw() {
        for (Emitter emitter : emitters) {
                emitter.emit(graphicContext);
        }
}

Listing 6-8Emitter creation and calling draw

配置对象包含发射器用来创建粒子的各种信息。ParticleSystemConf 类(参见清单 6-9 )使用 JavaFX 属性,因此属性值可以直接绑定到我们添加到底部窗格的控件。这些属性控制每次调用 emit 时产生的粒子数、粒子在应用程序中存在的帧数(粒子持续时间)以及代表粒子不透明度的椭圆大小。你也可以选择粒子的颜色,如果它将在一条直线上移动,或者如果它将振荡,如果它应该有一个淡出效果。最后,这个配置还有一个克隆方法,它允许我们创建一个新的配置,这个配置不会绑定到清单 6-9 中所示的控件。

public class ParticleSystemConf {
        IntegerProperty numberOfParticlesProperty = new SimpleIntegerProperty();
        IntegerProperty durationProperty = new SimpleIntegerProperty();
        DoubleProperty sizeProperty = new SimpleDoubleProperty();
        DoubleProperty opacityProperty = new SimpleDoubleProperty();
        BooleanProperty oscilateProperty = new SimpleBooleanProperty();
        BooleanProperty fadeOutProperty = new SimpleBooleanProperty();
        ObjectProperty<Color> colorProperty = new SimpleObjectProperty<>();
        BooleanProperty cloneConfProperty = new SimpleBooleanProperty();

        public ParticleSystemConf clone() {
                ParticleSystemConf newConf = new ParticleSystemConf();
                newConf.numberOfParticlesProperty.
                set(numberOfParticlesProperty.get());
                newConf.durationProperty.set(durationProperty.get());
                newConf.sizeProperty.set(sizeProperty.get());
                newConf.opacityProperty.set(opacityProperty.get());
                newConf.oscilateProperty.set(oscilateProperty.get());
                newConf.fadeOutProperty.set(fadeOutProperty.get());
                newConf.colorProperty.set(colorProperty.get());
                return newConf;
        }
}

Listing 6-9The configuration object

配置的所有字段稍后都绑定到添加到应用程序底部的控件:

cbBackgrounColor.valueProperty().addListener((a, b, c) -> background(c));
globalConf.numberOfParticlesProperty.bind(sldNumberOfParticles.valueProperty());
globalConf.durationProperty.bind(sldDuration.valueProperty());
globalConf.oscilateProperty.bind(cbOscillate.selectedProperty());
globalConf.sizeProperty.bind(sldPParticleSize.valueProperty());
globalConf.opacityProperty.bind(sldOpacity.valueProperty());
globalConf.fadeOutProperty.bind(cbFadeOut.selectedProperty());
globalConf.colorProperty.bind(cbColor.valueProperty());
globalConf.cloneConfProperty.bind(tbClone.selectedProperty());

最后,所有的配置都在发射器和粒子类中使用,如清单 6-10 所示。

public class Emitter {
        List<Particle> particles = new ArrayList<>();
        double x, y;
        private ParticleSystemConf conf;
        public Emitter(double x, double y, ParticleSystemConf conf) {
                this.x = x;
                this.y = y;
                this.conf = conf;
        }
        public void emit(GraphicsContext gc) {
                for (int i = 0; i < conf.numberOfParticlesProperty.get();
                i++) {
                        Particle p = new Particle(x, y, conf);
                        particles.add(p);
                }
                for (Particle particle : particles) {
                        particle.step();
                        particle.show(gc);
                }
              particles = particles.stream().filter(p -> p.duration >               0).collect(Collectors.toList());
        }
}
public class Particle {
        int duration, initialDuration;
        double x, y, yDir, xDir, size, opacity, currentOpacity;
        Color color = Color.YELLOW;
        boolean oscilate, fadeOut;
        public Particle(double x, double y, ParticleSystemConf conf) {
                this.x = x;
                this.y = y;
                this.oscilate = conf.oscilateProperty.get();
                this.size = conf.sizeProperty.get();
                this.initialDuration = conf.durationProperty.get() + 1;
                this.yDir = random.nextGaussian() * 2.0 - 1.0;
                this.xDir = random.nextGaussian() * 2.0 + -1.0;
                this.opacity = conf.opacityProperty.get();
                this.fadeOut = conf.fadeOutProperty.get();
                this.duration = initialDuration;
                this.currentOpacity = opacity;
                this.color = conf.colorProperty.get();
        }
        public void step() {
                x += xDir;
                y += yDir;
                if (oscilate) {
                        x += Math.sin(duration) * 10;
                        y += Math.cos(duration) * 10;
                }
                if (fadeOut) {
                        currentOpacity = map(duration, 0,                         initialDuration, 0, opacity);
                }
                duration--;
        }
        public void show(GraphicsContext gc) {
                Color cl = Color.color(color.getRed(), color.getGreen(), color.getBlue(), currentOpacity);
                gc.setFill(cl);
                gc.fillOval(x, y, size, size);
        }
}

Listing 6-10Particle and Emitter classes using the configuration object

本章没有分享所有可配置粒子系统的代码;但是,您可以在与本书相关的 GitHub 资源库中找到它。当你运行这个应用程序时,你会注意到如果你添加很多发射器,这些发射器有很多由帧生成的粒子,主要是如果你每秒有太多的帧,你会很快使它变慢。您可以按照本章末尾提供的提示来提高性能。有几个不错的特性可以添加到这个应用程序中:

  • 粒子格式选择

  • 粒子取向

  • 将可视化导出到文件或可以在其他应用程序中重用的格式

我们会将这些任务作为练习留给您!

分形

分形的粗略定义是由类似于它自身的其他小几何形状形成的几何形状。使用分形,我们可以创造美丽迷人的艺术,也可以理解自然界的图案形成。在我们的例子中,我们将使用分形探索画布容量。

Mandelbrot 集是一个由复数序列生成的著名分形。要构建 Mandelbrot 集合,必须迭代函数 f(z) = z 2 + c,从 0 开始用自己的结果值填充它。这个函数趋于无穷大;然而,有几个中间值可能会导致有趣的结果。例如,如果你迭代一个图像像素,并将像素映射到 Mandelbrot 发送的接受值,然后使用像素写入器,当结果趋于无穷大时将像素颜色设置为白色,否则设置为黑色,结果将如图 6-9 所示。请注意,在这幅图中,小部分与整体相似。看起来我们到处都有一个小曼德勃罗。它的代码在清单 6-11 中。

img/468104_2_En_6_Fig9_HTML.jpg

图 6-9

最简单 Mandelbrot 集

private final int MAX_ITERATIONS = 100;
private double zx, zy, cX, cY, tmp;
int i;
@Override
public void setup() {
        width = 1200;
        height = 800;
        frames(0);
}
@Override
public void draw() {
        long start = System.currentTimeMillis();
        for (int x = 0; x < width; x++) {
                for (int y = 0; y < height; y++) {
                        zx = zy = 0;
                      // the known range of accepted values for cx and cy
                        cX = map(x, 0, width, -2.5, 1.0);
                        cY = map(y, 0, height, -1, 1.0);
                        i = 0;
                        while (zx * zx + zy * zy < 4 && i <                         MAX_ITERATIONS) {
                                tmp = zx * zx - zy * zy + cX;
                                zy = 2.0 * zx * zy + cY;
                                zx = tmp;
                                i++;
                        }
                        // if it is not exploding to infinite
                        if (i < MAX_ITERATIONS) {
                                graphicContext.getPixelWriter().setColor(                                x, y, Color.WHITE);
                        } else {
                                graphicContext.getPixelWriter().setColor(x, y, Color.BLACK);
                        }
                }
        }
        System.out.println("GEnerating mandelbrot took " + (System.currentTimeMillis() - start)  + " ms");
}

Listing 6-11Simplest Mandelbrot

如果在网络视频中搜索 Mandelbrot,会发现缩放效果、不同颜色等非常有趣的特效。由于着色算法和缩放效果,这是可能的。让我们首先通过允许假缩放来改进原始的 Mandelbrot。这可以通过操作 GraphicsApp 的根窗格,将画布包装在一个非常大的堆栈窗格中,然后包装在一个提供滚动功能的滚动窗格中来完成。画布大小可以使用事件侦听器来更改:当用户用左键单击滚动窗格时,它会放大;当用户使用右键单击时,它会缩小;单击中间的按钮可以重置缩放比例并使窗格居中。这些都是在清单 6-12 中的设置方法中完成的,在这里你可以看到缩放的技巧:我们实际上是在缩放画布;这不是真正的变焦。在图 6-10 中,你可以看到没有缩放的结果。缩放效果如图 6-11 所示。请注意,它不调整分辨率,因此,正如我们所说,一个假的缩放。

@Override
public void setup() {
        width = 1200;
        height = 800;
        Canvas canvas = graphicContext.getCanvas();
        BorderPane bp = (BorderPane) canvas.getParent();
        bp.setCenter(null);
        StackPane p = new StackPane(canvas);
        p.setMinSize(20000, 20000);
        ScrollPane sp = new ScrollPane(p);
        sp.setPrefSize(1200, 800);
        sp.setVvalue(0.5);
        sp.setHvalue(0.5);
        bp.setCenter(sp);
        sp.setOnMouseClicked(e -> {
                double zoom = 0.2;
                double scaleX = canvas.getScaleX();
                double scaleY = canvas.getScaleY();
                if (e.getButton() == MouseButton.SECONDARY &&                  (canvas.getScaleX() > 0.5)) {
                        canvas.setScaleX(scaleX - zoom);
                        canvas.setScaleY(scaleY - zoom);
                } else if (e.getButton() == MouseButton.PRIMARY) {
                        canvas.setScaleX(scaleX + zoom);
                        canvas.setScaleY(scaleY + zoom);
                } else if (e.getButton() == MouseButton.MIDDLE) {
                        sp.setVvalue(0.5);
                        sp.setHvalue(0.5);
                        canvas.setScaleY(1);
                        canvas.setScaleX(1);
                }
        });
        canvas.setOnMousePressed(canvas.getOnMouseClicked());
        frames(0);
        title("Mandelbrot with color and zoom");
}

Listing 6-12Trick for zoom into the application canvas

对于着色,我们修改了 Mandelbrot 颜色。选择一个相对于上一次迭代的值,而不是白色。使用这个值,我们可以使用生成的颜色。例如,使用清单 6-13 中的值,我们为外部颜色设置了略带紫色的值,为边框设置了绿色的值,如图 6-10 所示。

// if the steps above are not heading towards infinite we draw the pixel with a specific color
if (i < MAX_ITERATIONS) {
        double newC = ((double) i) / ((double) MAX_ITERATIONS);
        Color c;
        if(newC > 0.4)
        c = Color.color(newC, 0.8, newC);
        else c = Color.color(0.2, newC, 0.2);
        graphicContext.getPixelWriter().setColor(x, y, c);
} else {
        graphicContext.getPixelWriter().setColor(x, y, Color.BLACK);
}

Listing 6-13Adding colors to the Mandelbrot

non-infinite values

img/468104_2_En_6_Fig11_HTML.jpg

图 6-11

放大到曼德勃罗

img/468104_2_En_6_Fig10_HTML.jpg

图 6-10

带有颜色和缩放的 Mandelbrot

曼德尔布洛特就这样了。花点时间修改代码,尝试生成更有趣的颜色,摆弄参数。作为我们的下一个视觉效果,我们将为实时实验创建一个面板,并扩展 Mandelbrot 以允许我们测试 Julia 集值,生成其他分形形式。

Julia 集是 Mandelbrot 虚值和实值的固定值的集合。使用这些固定值,我们可以创建从 Mandelbrot 派生的表单。在我们的代码中,我们只是停止从 Mandelbrot 计算 cx 和 ci 变量,而是让用户使用添加到根窗格底部的 JavaFX 滑块为它们选择一个值。中央窗格使用我们在 Mandelbrot 中使用的相同的缩放技巧,这一次我们将让用户选择分形形式的许多不同参数的值,生成独特的图像。我们在 Mandelbrot 代码中为生成 Julia 集所做的更改可以在清单 6-14 中看到,其中 cx 和 ci 来自一个配置对象,我们将很快对此进行描述。此外,颜色现在来自一个特定的方法,将采取用户配置。

@Override
public void draw() {
        running.set(true);
        totalIterations++;
        for (int x = 0; x < width; x++) {
                for (int y = 0; y < height; y++) {
                        zx = zy = 0;
                        zx = 1.5 * (x - width / 2) / (0.5 * width);
                        zy = (y - height / 2) / (0.5 * height);
                        i = 0;
                        while (zx * zx + zy * zy < 4 && i < totalIterations) {
                                tmp = zx * zx - zy * zy + conf.cx;
                                zy = 2.0 * zx * zy + conf.ci;
                                zx = tmp;
                                i++;
                        }
                        Color c = conf.infinityColor;
                        if (i < totalIterations) {
                        double newC = ((double) i) / ((double) totalIterations);
                        c = getColor(newC);
                     }
                     graphicContext.getPixelWriter().setColor(x, y, c);
                }
        }
        if (totalIterations > conf.maxIterations) {
                running.set(false);
                frames(0);
        }
}
private Color getColor(double newC) {
        double r = newC, g = newC, b = newC;
        if (newC > conf.threshold) {
                if (!conf.computedLighterR)
                        r = conf.lighterR;
                if (!conf.computedLighterG)
                        g = conf.lighterG;
                if (!conf.computedLighterB)
                        b = conf.lighterB;
        } else {
                if (!conf.computedDarkerR)
                        r = conf.darkerR;
                if (!conf.computedDarkerG)
                        g = conf.darkerG;
                if (!conf.computedDarkerB)
                        b = conf.darkerB;
        }
        return Color.color(r, g, b);
}

Listing 6-14Code for Julia sets

. Now the values come from configuration objects

但是,该配置没有使用绑定,因为 draw()方法中 for 循环内部的绑定将比使用基本类型慢得多。为了使配置对象与配置保持一致,我们使用了侦听器,因此对于 UI 中的每个元素,我们都有一个侦听器,它将在控件发生更改时更新配置对象。这样,绘制分形形式的循环就不会因为使用绑定而出现性能问题。配置和底部窗格结构可在清单 6-15 中找到。在图 6-12 中,您可以看到运行中的应用程序。

img/468104_2_En_6_Fig12_HTML.png

图 6-12

我们的 Julia 集分形应用

您在图 6-12 中看到的每个控件解释如下:

  • 浅色:高于阈值的值的颜色。您可以为每个值(RGB)使用一个滑块,如果您选择自动,该特定颜色部分的值将从我们在清单 6-14 中看到的算法中获取。

  • 深色:就像浅色一样,但用于低于阈值的值。

  • 阈值:划分颜色的阈值。我们可以选择高于或低于阈值的颜色值。

  • 内部颜色:一个颜色选择器,允许您在计算值趋于无穷大时选择默认颜色。

  • 迭代次数:一个微调器,包含迭代次数的可能值。迭代是我们在检查它是否趋于无穷大之前进行计算的次数。

  • cx 和 cy:这些滑块是 Julia 集的已知值范围。改变它就会改变分形的形式。

  • 动画按钮将显示分形演化的每一步,从迭代 1 开始绘制,直到你在迭代中选择的数字。

使用这些控件,你可以创建真正有趣的分形,如图 6-13 所示。

img/468104_2_En_6_Fig13_HTML.png

图 6-13

使用我们的应用程序生成的分形

public static class JuliaSetConf {
        public double threshold = 0.8;
        public double lighterR = 0.7;
        public double lighterG = 0.7;
        public double lighterB = 0.7;
        public double darkerR = 0.3;
        public double darkerG = 0.3;
        public double darkerB = 0.3;
        public double cx = -0.70176;
        public double ci = -0.3842;
        public boolean computedLighterR = true;
        public boolean computedLighterG = true;
        public boolean computedLighterB = true;
        public boolean computedDarkerR = true;
        public boolean computedDarkerG = true;
        public boolean computedDarkerB = true;
        public Color infinityColor = Color.GOLDENROD;
        public int maxIterations = MAX_ITERATIONS / 2;
}
private Node createConfPanel() {
        VBox vbConf = new VBox(5);
        Slider spLigherR = slider(conf.lighterR);
        Slider spLigherG = slider(conf.lighterG);
        Slider spLigherB = slider(conf.lighterB);
        CheckBox chkUseComputedLighterR = checkBox();
        CheckBox chkUseComputedLighterG = checkBox();
        CheckBox chkUseComputedLighterB = checkBox();
        vbConf.getChildren().add(new HBox(10, new Label("Lighter Colors"),
                        spLigherR, chkUseComputedLighterR, spLigherG,
                        chkUseComputedLighterG, spLigherB,                          chkUseComputedLighterB));
        Slider spDarkerR = slider(conf.darkerR);
        Slider spDarkerG = slider(conf.darkerG);
        Slider spDarkerB = slider(conf.darkerB);

        CheckBox chkUseComputedDarkerR = checkBox();
        CheckBox chkUseComputedDarkerG = checkBox();
        CheckBox chkUseComputedDarkerB = checkBox();
        vbConf.getChildren().add(new HBox(10, new Label("Darker Colors"),
                        spDarkerR, chkUseComputedDarkerR, spDarkerG,
                        chkUseComputedDarkerG, spDarkerB,                         chkUseComputedDarkerB));
        Slider sldThreshold = slider(conf.threshold);
        Spinner<Integer> spMaxIterations = new Spinner<>(10,         MAX_ITERATIONS, MAX_ITERATIONS / 2);
        spMaxIterations.valueProperty().addListener(c ->         updateConf.run());
        ColorPicker clInifinity = new ColorPicker(conf.infinityColor);
        clInifinity.valueProperty().addListener(c -> updateConf.run());
        HBox hbGeneral = new HBox(5, new Label("Threshold"), sldThreshold,
                        new Label("Inner Color"), clInifinity,
                        new Label("Iterations"), spMaxIterations);
        hbGeneral.setAlignment(Pos.CENTER_LEFT);
        vbConf.getChildren().add(hbGeneral);
        Slider sldX = slider(-1, 1.0, conf.cx);
        sldX.setMinSize(300, 10);
        Slider sldI = slider(-1, 1.0, conf.ci);
        sldI.setMinSize(300, 10);
        Button btnRun = new Button("Animate");
        // since we are not using bind we need to get all the properties here
        updateConf = () -> {
                conf.lighterR = spLigherR.getValue();
                conf.lighterG = spLigherG.getValue();
                conf.lighterB = spLigherB.getValue();
                conf.darkerR = spDarkerR.getValue();
                conf.darkerG = spDarkerG.getValue();
                conf.darkerB = spDarkerB.getValue();
                conf.threshold = sldThreshold.getValue();
                conf.computedLighterR =                 chkUseComputedLighterR.isSelected();
                conf.computedLighterG =                 chkUseComputedLighterG.isSelected();
                conf.computedLighterB =                 chkUseComputedLighterB.isSelected();
                conf.computedDarkerR =
                conf.computedDarkerG =                 chkUseComputedDarkerG.isSelected();
                conf.computedDarkerB =                 chkUseComputedDarkerB.isSelected();
                conf.cx = sldX.getValue();
                conf.ci = sldI.getValue();
                conf.infinityColor = clInifinity.getValue();
                conf.maxIterations = spMaxIterations.getValue();
                totalIterations = conf.maxIterations;
                frames(TOTAL_FRAMES);
        };

btnRun.setOnAction(e -> {
                updateConf.run();
                totalIterations = 1;
        });
        HBox hbSet = new HBox(5, new Label("cX"), sldX, new Label("cI"), sldI, btnRun);
        vbConf.getChildren().add(hbSet);
        TitledPane pnConf = new TitledPane("Configuration", vbConf);
        pnConf.setExpanded(true);
        pnConf.setCollapsible(false);
        pnConf.disableProperty().bind(running);
        return pnConf;
}
private CheckBox checkBox() {
        CheckBox checkBox = new CheckBox("Auto");
        checkBox.setSelected(true);
        checkBox.selectedProperty().addListener(c -> updateConf.run());
        return checkBox;
}
private Slider slider(double d) {
        return slider(0.0, 1.0, d);
}
private Slider slider(double min, double max, double d) {
        Slider slider = new Slider(min, max, d);
        slider.setShowTickLabels(true);
        slider.setShowTickMarks(true);
        slider.setMajorTickUnit(0.1);
        slider.valueProperty().addListener(c -> updateConf.run());
        return slider;
}

Listing 6-15Code for the Julia set

高性能

到目前为止的性能还没有讨论。焦点完全集中在使用 JavaFX APIs 创建我们的算法上,这意味着我们只信任前面提到的 JavaFX 硬件加速特性。如果你运行分形和粒子的例子,你会注意到,一旦我们把它推到极限,性能是妥协。在本章的最后一部分,我们将进行更深入的讨论,讨论为什么 JavaFX 本身不会为你的应用带来最佳性能,并根据 Sean M. Phillips 于 2018 年 5-6 月在 Java Magazine 发表的文章《JavaFX 中的生产者-消费者实现》提出解决方案。

JavaFX 是单线程的。所有的渲染都是在一个线程上完成的,这意味着如果你用一个长时间运行的任务来保持线程,它不会显示任何东西,直到任务完成。当您在 JavaFX 应用程序的 start 方法中编写代码时,您已经在 JavaFX 主线程上了。为了弄清楚这种行为,请看清单 6-16 中代码的应用程序。在这个应用程序中,我们有一个动画标签;我们还有一个按钮。当你点击按钮时,我们调用 Thread.sleep,动画就停止了。你甚至不能点击按钮。原因是主线程被我们的 Thread.sleep 调用锁定了!

import javafx.animation.ScaleTransition;
import javafx.animation.Transition;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.layout.BorderPane;
import javafx.stage.Stage;
import javafx.util.Duration;
public class LockedThread extends Application {
        public static void main(String[] args) {
                launch();
        }
        @Override
        public void start(Stage stage) throws Exception {
                Label lblHello = new Label("Hello World");
                ScaleTransition st = new                 ScaleTransition(Duration.seconds(1));
                st.setAutoReverse(true);
                st.setCycleCount(Transition.INDEFINITE);
                st.setByX(2);
                st.setByY(2);
                st.setNode(lblHello);
                Button btnLock = new Button("Sleep for 10 seconds");
                BorderPane bp = new BorderPane(lblHello);
                bp.setBottom(btnLock);
                stage.setScene(new Scene(bp, 300, 200));
                stage.show();
                btnLock.setOnAction(e -> {
                        try {
                                Thread.sleep(10000);
                        } catch (InterruptedException e1) {
                                e1.printStackTrace();
                        }
                });
                st.play();
        }
}

Listing 6-16Locking the main JavaFX thread

教训是不要在主线程上做繁重的任务。解决方案是使用一个单独的线程进行实际处理,一旦完成,就在 JavaFX 线程上更新画布(或用户界面)。有了这种方法,主线程的负载就减轻了,应用程序应该可以平稳运行。

现在我们知道了这一点,我们将尝试在不同的线程上调用图形上下文或进行任何 JavaFX 控件更改,您将看到类型为Java . lang . illegalstateexception的异常,消息不在 FX 应用程序线程上。为了确保 JavaFX 线程上有东西在运行,我们可以使用javafx . application . platform . run later传递一个 runnable,它稍后将在 Java FX 线程上运行:platform . run later(()GC . fill text("安全填充文本",0,0)) 。换句话说,确保在主线程上做 JavaFX 控件更新;否则,我们可能会面临前面提到的异常。

然而,Platform.runLater 不会解决我们在 JavaFX 中并发编程所面临的所有问题。javafx.concurrent 包中还有其他实用工具,主要是 javafx.concurrent.Task 类,对于异步任务非常有用。对于这一章,我们将探讨 Sean M. Phillips 在 2018 年 5 月至 6 月的 Java 杂志中介绍的高密度数据模式:“JavaFX 中的生产者-消费者实现。”

如果您查看了上面提到的文章,您会注意到这个想法是有一个线程来执行硬处理并将结果推送到一个队列,然后另一个线程在结果可用时获取结果并更新画布。第一个线程被称为生产者,它负责在不接触 JavaFX 线程的情况下进行硬处理。生产者生成的结果被添加到一个Java . util . concurrent . concurrentlinkedqueue中,由第二个线程(消费者线程)接收,然后在 JavaFX 线程上进行图形处理。

为了在现实世界的应用程序中展示这种模式,让我们创建一个 Conway 的生命游戏的实现。在维基百科的同名文章中,你会发现生命的游戏是一个细胞自动机,其中如果一个细胞由于人口过多而有三个以上的邻居,那么这个细胞就会死亡,少于两个邻居的细胞会因人口不足而死亡,被恰好三个邻居包围的死亡细胞会重生,而有两个或三个邻居的细胞仍然活着。

我们在清单 6-17 中实现了我们的生活游戏。单元格由布尔值表示,其中 true 表示活动单元格。我们可以设置每个单元格的大小以及应用程序的宽度和高度,这意味着单元格的数量可以通过宽度除以单元格的大小乘以高度除以单元格的大小来计算。应用程序将为每个活细胞写一个大小为 cellSize 的正方形,然后根据我们之前讨论的规则计算下一代细胞。确定一个单元是否存活取决于邻居的数量,在 countNeighbours 方法中,我们采用了不同的方法来计算邻居的数量,即检查每个邻居的位置,并排除邻居检查会导致错误的情况。这种方法将我们从 if/else 丑陋的实现中拯救出来。因为我们需要对每个单元格的邻居求和,所以我们必须在 for-for 循环中遍历每个单元格,以找到每个单元格的邻居,正如您在 newGeneration 方法中看到的那样。

import java.util.Arrays;
import javafx.scene.canvas.GraphicsContext;
import javafx.scene.paint.Color;
public class GameOfLife {
        private int columns;
        private int rows;
        private int cellSize;
        public GameOfLife(int columns, int rows, int cellSize) {
                this.columns = columns;
                this.rows = rows;
                this.cellSize = cellSize;
        }
        public boolean[][] newCells() {
                boolean[][] newCells = new boolean[columns][rows];
                for (int i = 0; i < columns; i++) {
                        for (int j = 0; j < rows; j++) {
                                newCells[i][j] = Math.random() > 0.5;
                        }
                }
                return newCells;
        }
        public void drawCells(boolean[][] cells, GraphicsContext graphicContext) {
                for (int i = 0; i < columns; i++) {
                        for (int j = 0; j < rows; j++) {
                                if (cells[i][j]) {
                                     graphicContext.setFill(Color.BLACK);
                                     graphicContext.fillRect(i *                                      cellSize, j * cellSize, cellSize,                                      cellSize);
                                }
                        }
                }
        }

       public boolean[][] newGeneration(boolean previousGeneration[][]) {
                boolean[][] newGeneration = new boolean[columns][rows];
                for (int i = 0; i < columns; i++) {
                        for (int j = 0; j < rows; j++) {
                                updateCell(previousGeneration,                                 newGeneration, i, j);
                        }
                }
                return newGeneration;
        }
        private void updateCell(boolean[][] previousGeneration, boolean[][] newGeneration, int i, int j) {
                int countNeighbours = countNeighbours(previousGeneration,                 i, j);
                if (previousGeneration[i][j] && (countNeighbours < 2 ||                 countNeighbours > 3)) {
                        newGeneration[i][j] = false;
                } else if (!previousGeneration[i][j] && countNeighbours                 == 3) {
                        newGeneration[i][j] = true;
                } else if (previousGeneration[i][j]) {
                        newGeneration[i][j] = true;
                }
        }
        private int countNeighbours(boolean[][] copy, int i, int j) {
                int[][] borders = {
                                {i - 1, j -1}, {i -1, j}, {i -1, j+ 1},
                                {i, j -1}, {i, j + 1},
                                {i +1, j - 1}, {i +1, j}, {i +1, j +1}
                };
                return (int) Arrays.stream(borders)
                        .filter(b -> b[0] > -1 &&
                                        b[0] < columns &&
                                        b[1] > -1      &&
                                        b[1] < rows    &&
                                        copy[b[0]][b[1]])
                        .count();
        }
}

Listing 6-17A Game of Life implementation

赋予这个游戏生命的第一个简单方法是使用 GraphicApp 的子类,它将完成 draw 方法中的所有工作。每次调用 draw 时,都会呈现当前的生成,新的生成将替换当前的生成。如您所知,draw 方法运行在 JavaFX 线程上,这意味着该实现将使用一个线程来完成所有工作。这个实现可以在清单 6-18 中找到,结果可以在图 6-14 中看到。

img/468104_2_En_6_Fig14_HTML.jpg

图 6-14

我们的生活游戏

import javafx.scene.paint.Color;
public class GameOfLifeFXThread extends GraphicApp {
        final int WIDTH = 2500;
        final int HEIGHT = 2500;
        final int CELL_SIZE = 5;
        boolean currentGeneration[][];
        int columns = WIDTH / CELL_SIZE;
        int rows = HEIGHT / CELL_SIZE;
        private GameOfLife gameOfLife;
        public static void main(String[] args) {
                launch();
        }
        @Override
        public void setup() {
                width = WIDTH;
                height = HEIGHT;
                gameOfLife = new GameOfLife(columns, rows, CELL_SIZE);
                currentGeneration = gameOfLife.newCells();
                background(Color.DARKGRAY);
                title("Game of Life");
                frames(5);
        }
        @Override
        public void draw() {
                long initial = System.currentTimeMillis();
                gameOfLife.drawCells(currentGeneration, graphicContext);
                System.out.println("Time to render " +
                 (System.currentTimeMillis() - initial));
                initial = System.currentTimeMillis();
                currentGeneration = gameOfLife.newGeneration(currentGeneration);
                System.out.println("Time to calculate new generation: " +                  (System.currentTimeMillis() - initial));
        }
}

Listing 6-18Game of Life running on the application main thread

如果您运行清单 6-18 中大小为 2500 × 2500、单元格大小为 5 (2500 × 2500 × 5)的实现,您将在控制台中看到,计算下一代的时间大约是实际呈现单元格所用时间的 30 倍,这意味着当 JavaFX 线程被锁定时,大部分时间用于计算新的一代。当我们简单地将 cell 的大小更改为 2(记住,单元格的数量取决于单元格的大小)时,应用程序变得非常慢并且没有响应,因为现在主线程被锁定进行新的生成。在图 6-15 中可以看到控制台上的输出,是 2500 × 2500 × 2 采集的。

img/468104_2_En_6_Fig15_HTML.png

图 6-15

渲染时间×计算新一代的时间

考虑到您正在多核计算机上运行《生命的游戏》,我们可以做一点小小的改动,将 newGeneration 方法中的外部循环(或列循环)转换为使用并行流。这是通过向 GameOfLife 类添加一个新方法实现的,您可以在清单 6-19 中看到。使用单元大小为 2 的 2500 × 2500,我们可以在四核机器中有大约 30%的提高,使应用程序更快。结果如图 6-16 所示。请记住,并行并不是解决问题的灵丹妙药。你必须观察你制造的平行负载是否值得;否则,在内核之间分配工作的时间可能会比执行实际处理的时间长,从而导致性能下降,而不是性能提高。

img/468104_2_En_6_Fig16_HTML.png

图 6-16

计算新一代时使用并行流后的处理时间

public boolean[][] newGenerationParallel(boolean previousGeneration[][]) {
        boolean[][] newGeneration = new boolean[columns][rows];
        IntStream.range(0, columns).parallel().forEach(i -> {
                for (int j = 0; j < rows; j++) {
                     updateCell(previousGeneration, newGeneration, i, j);
                }
        });
        return newGeneration;
}

Listing 6-19Method using parallel stream when checking the neighbors for all cells in a column

因为我们在 JavaFX 线程上运行所有的东西,所以我们所能做的改进是有限的。然而,如果我们使用已经提到的高密度模式的相同思想,我们可以有令人印象深刻的结果。应用程序很少会变得无响应,因为它会将所有处理从 JavaFX 主线程中取出,并且只调用 Platform.runLater()来呈现数据。所有处理都将在一个生产者任务中进行,该任务计算新的生成,并将结果添加到一个 ConcurrentLinkedQueue 中。结果稍后由另一个任务(消费者任务)轮询,然后在应用程序主线程上更新画布。我们可以通过每 X 毫秒轮询一次结果来控制每秒的帧数,例如,如果您希望每秒 10 帧,您可以让消费者线程在每次轮询队列结果时休眠 100 毫秒,或者您可以不断轮询结果并更新画布,因为最重要的结果是应用程序的其余部分将平稳运行,而不会对最终用户产生任何影响,这意味着用户可能会看到缓慢的动画,但他们仍然可以更改控件或执行其他任务。结果代码可以在清单 6-20 中找到。还可以做进一步的改进,比如使用线程来计算新一代。在这种情况下,简单地在流上调用 parallel 可能没有帮助,因为 parallel 使用所有的内核,这意味着它可能会饿死渲染线程,因为所有的内核都将用于新一代计算,因此需要更复杂的并行编程。

import java.util.concurrent.ConcurrentLinkedQueue;
import org.examples.canvas.GraphicApp;
import javafx.application.Platform;
import javafx.concurrent.Task;
import javafx.scene.paint.Color;
public class GameOfLifePublisherConsumer extends GraphicApp {
        final int WIDTH = 2500;
        final int HEIGHT = 2500;
        final int CELL_SIZE = 2;
        boolean currentGeneration[][];
        int columns = WIDTH / CELL_SIZE;
        int rows = HEIGHT / CELL_SIZE;
        // this is the desired number of frames
        int numberOfFramesPerSecond = 0;
        private GameOfLife gameOfLife;
        ConcurrentLinkedQueue<boolean[][]> cellsQueue;
        public static void main(String[] args) {
                launch();
        }
        @Override
        public void setup() {
                cellsQueue = new ConcurrentLinkedQueue<>();
                width = WIDTH;
                height = HEIGHT;
                gameOfLife = new GameOfLife(columns, rows, CELL_SIZE);
                currentGeneration = gameOfLife.newCells();
                Task<Void> producerTask = new Task<Void>() {
                        @Override
                        protected Void call() throws Exception {
                                while(true) {
                                       cellsQueue.add(currentGeneration);
                                       currentGeneration =                                        gameOfLife.newGeneration(current
                                       Generation);
                                }
                        }
                };
                Task<Void> consumerTask = new Task<Void>() {
                        @Override
                        protected Void call() throws Exception {
                                while (true) {
                                        while (!cellsQueue.isEmpty()) {
                                                boolean[][] data =                                                 cellsQueue.poll();
                                                Platform.runLater(() -> {
                                                     // we need to draw                                                      the background                                                      because we are                                                      not using draw                                                      loop anymore
                                                     graphicContext.set

                                                   Fill(Color.LIGHTGRAY);                                                    graphicContext.
                                                   fillRect(0, 0,
                                                   width, height);
                                                   gameOfLife.
                                                   drawCells(data,                                                    graphicContext);
                                                });
                                               if(numberOfFramesPerSecond                                                    > 0) {
                                                     Thread.sleep(1000 /                                                         numberOfFramesPer
                                                        Second);
                                                }
                                        }
                                }
                        }
                };
                Thread producerThread = new Thread(producerTask);
                producerThread.setDaemon(true);
                Thread consumerThread = new Thread(consumerTask);
                consumerThread.setDaemon(true);
                producerThread.start();
                consumerThread.start();
                frames(0);
                title("Game of Life Using High-Density Data Pattern");
        }
        @Override
        public void draw() {
                // we don't use the main loop anymore, but we have to                 draw the background in draw cells
        }
}

Listing 6-20Game of Life with high-density data pattern

结论

JavaFX 可以用来生成非常复杂的可视化。与任何允许创建用户界面的框架一样,很容易创建出性能很差的东西。然而,在这一章中,我们解释了一些提示和技巧,即使在复杂的场景图和大量节点的情况下,它们也能让您获得出色的性能。

有了本章中讨论的 JavaFX 应用程序线程的基本知识,您就可以利用 JavaFX 提供的功能来获得更好的性能。

七、连接 Swing 和 JavaFX

作者:斯文·雷默斯

一个新的 UI 工具包的主要优势之一是可以保护您在现有应用程序中的投资。本章将向您展示如何将遗留 Swing 组件集成到现代 JavaFX UI 中,以及如何将现代 UI 元素集成到现有的 Swing 应用程序中。

因为将一个现有的 Swing 桌面应用程序迁移到一个纯 JavaFX 应用程序具有挑战性,而且并不总是必要的,所以本章描述了可用于集成的技术,并提供了迁移过程的技巧和策略。

Note

为了理解一些概念,很好地理解 Swing 技术是有帮助的。要全面掌握一些例子的细节,请参考本书的其他章节或一些好的 Swing 深度材料。

将 JavaFX 集成到 Swing 中

Java 桌面应用程序的典型迁移路径是使用 JavaFX 的新可用控件,例如 WebView,它最终允许在标准 Swing 应用程序中嵌入真正的浏览器。

JFXPanel:内置 JavaFX 的 Swing 组件

实现这一点的方法是使用位于javafx.swing模块的javafx.embed.swing包中的特殊摆动JComponentJFXPanel。它允许您将 JavaFX 场景图嵌入到 Swing 容器层次结构中。JFXPanel需要的有趣方法是

  • 公共空场景(最终场景新闻场景)

附加要在此 JFXPanel 中显示的场景对象。可以在事件调度线程或 JavaFX 应用程序线程上调用此方法。

Swing 编码规则要求始终从 Swing 事件线程创建和访问 Swing 组件。JFXPanel 在这方面有所不同。它也可以通过 FX 应用程序线程进行管理。这在复杂场景的情况下很有帮助,这可能需要在 FX 应用程序线程上显式创建 JavaFX 组件。

除了线程方面,我们将在集成过程中更详细地讨论,这里要认识到的第一件重要事情是 JavaFX 嵌入不能在NodeControl级别上工作,而是在完整的Scene级别上工作。因此,如果需要嵌入一个Node,例如一个Chart,您不能仅仅将Chart实例添加到 Swing 组件层次结构中。相反,您必须创建一个完整的Scene,并使用JFXPanel将它作为 Swing 组件包装器添加到您的Scene中。

这样,在清单 7-1 中可以看到一个完整的 JavaFX 集成场景的小例子。

Note

所有下面的例子都尽可能地简化,以便集成处理变得显而易见,而不是按照面向对象或函数式编程的架构。通常,唯一需要的是一个 main 方法,您可以将示例代码复制并粘贴到该方法中。如果需要更多的特殊代码,这将在示例描述中指出。

SwingUtilities.invokeLater(() -> {
            var frame = new JFrame("JavaFX 17 integrated in Swing");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            var jfxPanel = new JFXPanel();
            var button = new Button("Hello FX");
            var scene = new Scene(button);
            jfxPanel.setScene(scene);
            jfxPanel.setPreferredSize(new Dimension(100,200));
            var panel = new JPanel(new BorderLayout());
            panel.add(new JLabel("Hello Swing North"), BorderLayout.NORTH);
            panel.add(new JLabel("Hello Swing South"), BorderLayout.SOUTH);
            panel.add(jfxPanel, BorderLayout.CENTER);
            frame.setContentPane(panel);
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });

Listing 7-1Simple JavaFX in Swing embedding

这段代码将生成一个包含三个可见部分的 Swing JFrame,一个 Swing JLabel在 JavaFX Button之上,另一个 Swing JLabel之上,如图 7-1 所示。

img/468104_2_En_7_Fig1_HTML.jpg

图 7-1

简单的 JavaFX 集成

这里特别有趣的是组件的布局。一个主要方面是正确设置JFXPanel的首选尺寸。如果您注释掉设置首选大小,您将看到 JFXPanel 在运行示例后调整到最小的Button大小。你得到的初始视图应该类似于图 7-2 所示。这是对 JavaFX 11 行为的改变,在 Java FX 11 中,JFXPanel 没有正确的首选大小。

img/468104_2_En_7_Fig2_HTML.jpg

图 7-2

无需设置首选大小的简单 JavaFX 集成

解决了这个初始集成问题后,让我们更深入地研究这个解决方案提供的可能性。

因为 JFXPanel 是一个 Swing 组件,所以这为创建多个组件实例并将其添加到 Swing 组件层次结构中提供了机会。举个简单的例子,清单 7-1 中的应用程序被更改为使用两个 JavaFX Label和一个 Swing JLabel,如清单 7-2 所示。

SwingUtilities.invokeLater(() -> {
        var frame = new JFrame("JavaFX 17 integrated in Swing (multiple)");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        var northJfxPanel = new JFXPanel();
        var northButton = new Button("Hello FX North");
        var northScene = new Scene(northButton);
        northJfxPanel.setScene(northScene);
        northJfxPanel.setPreferredSize(new Dimension(200,50));
        var southJfxPanel = new JFXPanel();
        var southButton = new Button("Hello FX South");
        var southScene = new Scene(southButton);
        southJfxPanel.setScene(southScene);
        southJfxPanel.setPreferredSize(new Dimension(200,50));
        var panel = new JPanel(new BorderLayout());
        panel.add(northJfxPanel, BorderLayout.NORTH);
        panel.add(southJfxPanel, BorderLayout.SOUTH);
        panel.add(new JLabel("Hello Swing"), BorderLayout.CENTER);
        frame.setContentPane(panel);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
   });

Listing 7-2Multiple JavaFX Scenes in Swing

如果运行这个程序,显示的输出应该类似于图 7-3 。

img/468104_2_En_7_Fig3_HTML.jpg

图 7-3

多个 JavaFX 场景

到目前为止,与真实的集成场景相比,所有的例子都非常简单。一个典型的场景是将WebView集成到现有的 Swing 应用程序中。通过对清单 7-1 的一些小的修改,集成了一个 WebView 而不是原来应用程序的按钮,如清单 7-3 所示。

SwingUtilities.invokeLater(() -> {
            var frame = new JFrame("JavaFX 17 integrated in Swing");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            var jfxPanel = new JFXPanel();
            var panel = new JPanel(new BorderLayout());
            panel.add(new JLabel("Hello Swing North"), BorderLayout.NORTH);
            panel.add(new JLabel("Hello Swing South"), BorderLayout.SOUTH);
            Platform.runLater(() -> {
                var webView = new WebView();
                var scene = new Scene(webView);
                webView.getEngine().load("https://openjfx.io/");
                jfxPanel.setScene(scene);
                jfxPanel.setPreferredSize(new Dimension(400,600));
                SwingUtilities.invokeLater(() -> {
                    panel.add(jfxPanel, BorderLayout.CENTER);
                    frame.pack();
                    frame.setLocationRelativeTo(null);
                });
            });
            frame.setContentPane(panel);
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });

Listing 7-3Adding a WebView to a Swing application

运行这个例子会显示一个WebView呈现位于两个 Swing JLabel之间的 OpenJFX 主页,如图 7-4 所示。

img/468104_2_En_7_Fig4_HTML.png

图 7-4

嵌入在 Swing 应用程序中的 WebView

看代码,与原代码相比有明显的变化。创建Scene需要几个线程的改变,以便在正确的 UI 线程上完成所有事情。为了更好地理解,让我们先来看看线程的细节。

穿线

在混合了 JavaFX 节点和 Swing 组件的应用程序中线程化是一件复杂的事情。

正如上一节已经暗示的那样,必须考虑两条主线:

  • JavaFX 应用程序线程

  • AWT 事件队列

第一个线程与 JavaFX 的所有事情相关联,例如,向已经渲染的(实时)场景图添加新节点,或者更改属于已经渲染的场景图的节点的属性。

第二个线程与 Swing UI 工具包(从 AWT 继承,因此得名)相关联,例如,所有 Swing 组件的创建都应该在这个线程上进行。组合这些工具包将会导致在一个线程或另一个线程上的大量跳跃,以确保所有的事情总是在正确的线程上被触发和完成。

Note

系统属性javafx.embed.singleThread是可用的,如果设置为true,它将切换两个 UI 工具包以使用相同的线程。这种行为是实验性的,可能会导致不希望的行为,因此请谨慎使用。

还有一点需要特别注意,特别是 WebView 可能是最希望与 Swing 集成的 JavaFX 控件。所有其他 JavaFX 控件都可以在 WebView 之外的任何线程上创建。由于一些初始化问题,WebView 必须在 JavaFX 应用程序线程上创建,引用 JDK-8087718:

理论上,应该可以通过推迟初始化调用直到 WebKit 代码的第一次实际使用来消除限制。在实践中,这样的改变很可能会变得非常重要,主要是因为有大量的入口点可能会也可能不会导致“第一次真正的使用”

有了这些关于线程的知识,让我们再来看看上一个例子的初始化代码。

代码序列的第一个显著部分是在 JavaFX 应用程序线程上设置JFXPanel期间执行一些代码的必要性。这段代码完成后,需要在 AWT-EventQueue 上运行另一段代码。执行块的嵌套保证了正确的顺序。因此,一个通用的代码序列大致看起来像清单 7-4 中的伪代码。

Platform.runLater(() -> {
    // ensure JavaFX all necessary init is done
    SwingUtilities.invokeLater(() -> {
        // now come back to update Swing component hierarchy accordingly
    });
});

Listing 7-4Abstract sequence with dedicated thread-sensitive code

Note

有两个实用方法有助于确保或检测代码在正确的线程上执行:javax.swing.SwingUtilities.isEventDispatchThread()javax.application.Platform.isFxApplicationThread()。无论是在断言中使用以保证线程,还是作为简单的调试支持,它们都有助于使线程的使用更加透明。

随着对如何在正确的线程上运行代码有了更好的理解,集成的下一步是提供 JavaFX Node s 和 Swing JComponent s 之间的交互。

Swing 和 JavaFX 之间的交互

集成的下一步是两个 UI 工具包的组件之间的交互。看看 JavaFX 和 Swing 的线程模型,这将需要一些额外的仪式。JavaFX 节点/控件的更改将在 JavaFX 应用程序线程上通知,并且需要在 AWT-EventQueue 上更改 Swing 组件。处理从 JavaFX 到 Swing 的事件需要切换线程,即在正确的线程上执行代码块(lambdas)。这种模式类似于下面的代码片段:

NODE.setOnXXX(e ->
        SwingUtilities.invokeLater(() -> JCOMPONENT.setYYY(ZZZZ))).

例如,按下和释放鼠标按钮时,应更改 south 标签的文本。基于上述代码策略,必要的代码是

button.setOnMousePressed(e ->
        SwingUtilities.invokeLater(() -> southLabel.setText("FX Button Pressed")));
button.setOnMouseReleased(e ->
        SwingUtilities.invokeLater(() -> southLabel.setText("Hello Swing South")));

第一条语句在按下鼠标按钮时触发southLabel文本的改变,一旦松开按钮,第二条语句将文本改变回其原始值。完整的应用可以在清单 7-5 中看到。

SwingUtilities.invokeLater(() -> {
    var frame = new JFrame("JavaFX 17 integrated in Swing");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    var jfxPanel = new JFXPanel();
    var button = new Button("Hello FX");
    var scene = new Scene(button);
    jfxPanel.setScene(scene);
    jfxPanel.setPreferredSize(new Dimension(200,100));
    jfxPanel.setBorder(new EmptyBorder(5,5,5,5));
    var panel = new JPanel(new BorderLayout());
    panel.add(new JLabel("Hello Swing North"), BorderLayout.NORTH);
    var southLabel = new JLabel("Hello Swing South");
    panel.add(southLabel, BorderLayout.SOUTH);
    button.setOnMousePressed(e ->
    SwingUtilities.invokeLater(() -> southLabel.setText("FX Button Pressed")));
    button.setOnMouseReleased(e ->
    SwingUtilities.invokeLater(() -> southLabel.setText("Hello Swing South")));
    panel.add(jfxPanel, BorderLayout.CENTER);
    frame.setContentPane(panel);
    frame.pack();
    frame.setLocationRelativeTo(null);
    frame.setVisible(true);
});

Listing 7-5Interactive JavaFX in Swing embedding

这种相互作用在两个方向上都是一样的。为了显示从 Swing 开始的交互,让我们将最后一个示例更改为在南部区域包含一个 Swing JButton,并添加一些对它的监听:

southButton.addMouseListener(new MouseAdapter() {
     @Override
     public void mousePressed(MouseEvent e) {
         Platform.runLater(() -> button.setText("Swing Button Pressed"));
     }
     @Override
     public void mouseReleased(MouseEvent e) {
         Platform.runLater(() -> button.setText("Hello FX"));
     }
 });

可以看到,交互将从 AWT-EventQueue 开始,然后转移到 JavaFX 应用程序线程,以更改 JavaFX Button的 text 属性。完整的示例代码可以在清单 7-6 中看到。

SwingUtilities.invokeLater(() -> {
    var frame = new JFrame("JavaFX 17 bidirectional interaction in Swing");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    var jfxPanel = new JFXPanel();
    var button = new Button("Hello FX");
    var scene = new Scene(button);
    jfxPanel.setScene(scene);
    jfxPanel.setPreferredSize(new Dimension(200,100));
    jfxPanel.setBorder(new EmptyBorder(5,5,5,5));
    var panel = new JPanel(new BorderLayout());
    panel.add(new JLabel("Hello Swing North"), BorderLayout.NORTH);
    var southButton = new JButton("Hello Swing South Button");
    panel.add(southButton, BorderLayout.SOUTH);
    button.setOnMousePressed(e ->
            SwingUtilities.invokeLater(() -> southButton.setText("FX Button Pressed")));
    button.setOnMouseReleased(e ->
            SwingUtilities.invokeLater(() -> southButton.setText("Hello Swing South")));
    southButton.addMouseListener(new MouseAdapter() {
        @Override
        public void mousePressed(MouseEvent e) {
            Platform.runLater(() -> button.setText("Swing Button Pressed"));
        }
     @Override
     public void mouseReleased(MouseEvent e) {
        Platform.runLater(() -> button.setText("Hello FX"));
     }
   });
    panel.add(jfxPanel, BorderLayout.CENTER);
    frame.setContentPane(panel);
    frame.pack();
    frame.setLocationRelativeTo(null);
    frame.setVisible(true);
});

Listing 7-6Interactive bidirectional JavaFX

in Swing

运行该应用程序会显示以下状态(参见图 7-5 至 7-7 )。

img/468104_2_En_7_Fig7_HTML.jpg

图 7-7

点击 Swing 按钮后的状态

img/468104_2_En_7_Fig6_HTML.jpg

图 7-6

单击 JavaFX 按钮后的状态

img/468104_2_En_7_Fig5_HTML.png

图 7-5

交互演示的开始状态

交互性的下一个层次是向 Swing 应用程序动态添加 JavaFX 场景。这是更复杂的应用程序框架通常需要的特性,因为它们会动态地改变 Swing 组件层次结构。用多个 JavaFX Scene修改前面的示例,这样第二个JFXPanel将作为 Swing 按钮单击的结果被添加。主要的变化是 ActionListener 是必需的:

swingButton.addActionListener(e -> {
      var southJfxPanel = new JFXPanel();
      var southButton = new Button("Hello FX South");
      var southScene = new Scene(southButton);
      southJfxPanel.setPreferredSize(new Dimension(200,50));
      panel.add(southJfxPanel, BorderLayout.SOUTH);
      Platform.runLater(() -> {
          southJfxPanel.setScene(southScene);
          SwingUtilities.invokeLater(frame::pack);
      });
  });

JFXPanel 本身的创建可以在 AWT-EventQueue 上完成(如前所述),但在这种情况下,场景的设置必须在 JavaFX 应用程序线程上完成;为了确保面板的可见性,需要再次调整框架的大小。一旦场景设置好,这必须在 AWT-EventQueue 上完成。

运行清单 7-7 中所示的示例将在一个 Swing JFrame 中显示两个 JFXPanels,如图 7-8 所示。

img/468104_2_En_7_Fig8_HTML.jpg

图 7-8

添加场景后的状态

SwingUtilities.invokeLater(() -> {
    var frame = new JFrame("JavaFX 17 integrated in Swing (multiple, dynamic)");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    var northJfxPanel = new JFXPanel();
    var northButton = new Button("Hello FX North");
    var northScene = new Scene(northButton);
    northJfxPanel.setScene(northScene);
    northJfxPanel.setPreferredSize(new Dimension(200,50));
    var panel = new JPanel(new BorderLayout());
    panel.add(northJfxPanel, BorderLayout.NORTH);
    var swingButton = new JButton("Add FX Scene in South");
    swingButton.addActionListener(e -> {
        var southJfxPanel = new JFXPanel();
        var southButton = new Button("Hello FX South");
        var southScene = new Scene(southButton);
        southJfxPanel.setPreferredSize(new Dimension(200,50));
        panel.add(southJfxPanel, BorderLayout.SOUTH);
        Platform.runLater(() -> {
            southJfxPanel.setScene(southScene);
            SwingUtilities.invokeLater(frame::pack);
        });
    });
    panel.add(swingButton, BorderLayout.CENTER);
    frame.setContentPane(panel);
    frame.pack();
    frame.setLocationRelativeTo(null);
    frame.setVisible(true);
});

Listing 7-7Interactive bidirectional dynamic JavaFX

in Swing

下一个逻辑步骤是交互删除一个JFXPanel。出于演示的目的,最后一个例子增加了删除北JFXPanel的可能性(见清单 7-8 )。

SwingUtilities.invokeLater(() -> {
    var frame = new JFrame("JavaFX 17 integrated in Swing (multiple, dynamic)");
    frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
    var northJfxPanel = new JFXPanel();
    var northButton = new Button("Hello FX North");
    var northScene = new Scene(northButton);
    northJfxPanel.setScene(northScene);
    northJfxPanel.setPreferredSize(new Dimension(200,50));
    var panel = new JPanel(new BorderLayout());
    panel.add(northJfxPanel, BorderLayout.NORTH);
    var northSwingButton = new JButton("Remove FX Scene in North");
    northSwingButton.addActionListener(e -> {
       panel.remove(northJfxPanel);
       frame.pack();
    });
        var southSwingButton = new JButton("Add FX Scene in South");
        southSwingButton.addActionListener(e -> {
            var southJfxPanel = new JFXPanel();
            var southButton = new Button("Hello FX South");
            var southScene = new Scene(southButton);
            southJfxPanel.setPreferredSize(new Dimension(200,50));
            panel.add(southJfxPanel, BorderLayout.SOUTH);
            Platform.runLater(() -> {
                southJfxPanel.setScene(southScene);
                SwingUtilities.invokeLater(frame::pack);
            });
        });
        var swingInside = new JPanel(new BorderLayout());
        swingInside.add(northSwingButton, BorderLayout.NORTH);
        swingInside.add(southSwingButton, BorderLayout.SOUTH);
        panel.add(swingInside, BorderLayout.CENTER);
        frame.setContentPane(panel);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
    });

Listing 7-8Adding/removing of JFXPanel in Swing

运行该示例显示了两个旋转按钮——一个用于移除北部的 JFXPanel,另一个用于添加南部的 JFXPanel,如图 7-9 所示。

img/468104_2_En_7_Fig9_HTML.jpg

图 7-9

添加/删除 JFXPanels

应用程序的结果取决于按钮点击的顺序。如果先点击将JFXPanel加到南边的按钮,面板会出现,点击移除按钮会移除北边的JFXPanel(结果如图 7-10 )。

img/468104_2_En_7_Fig10_HTML.jpg

图 7-10

首先添加然后移除 JFXPanel 的结果

如果以相反的顺序单击按钮,北部面板将被删除,但南部面板不能再添加。这是因为 JavaFX 具有一个特性,即只要最后一个 JavaFX 窗口关闭,就会自动启动 JavaFX 运行时的关闭。这个特性在默认情况下是启用的,因此移除唯一的JFXPanel会触发关闭,之后所有对运行时的调用,例如将JFXPanel添加到南方,都不再起作用。这种行为可以通过禁用implicitExit功能来改变:

Platform.setImplicitExit(false);

Note

如果您试图在 Swing 上创建 JavaFX 的一些通用集成,禁用这个特性可能总是一个好主意,以确保 JavaFX 运行时不会意外关闭。

使用 JavaFX 和 Swing 进行拖放

更复杂的 Swing 应用程序通常会有某种拖放支持,要么在应用程序内部,要么从应用程序外部将内容拖入其中。第二个用例不是集成中的特例,因为放置目标要么是 Swing JComponent,要么是 JavaFX 节点。这允许对每种技术使用默认的丢弃处理。第一种情况更有趣,因为拖动源和拖放目标基于不同的 UI 技术。

图 7-11 显示了一个应用示例。

img/468104_2_En_7_Fig11_HTML.jpg

图 7-11

使用 JavaFX 和 Swing 进行拖放

有两个 Swing JTextField和一个 JavaFX Label。拖动操作允许从北或南 Swing TextField拖动选定的文本,并将其放到 JavaFX Label上。虽然这听起来像是很多复杂的线程,但事实并非如此。大多数复杂的交互都是在工具箱级别完成的,对用户来说是不可见的。

首先需要的是一个拖拽开始的交互,如清单 7-9 所示。

private static class MouseDragAdapter extends MouseAdapter {
      @Override
      public void mousePressed(MouseEvent e) {
          var component = (JComponent) e.getSource();
          component.getTransferHandler().
                      exportAsDrag(component, e, TransferHandler.COPY);
      }
  }

Listing 7-9Swing MouseAdapter for drag start

所示的代码片段定义了一个MouseListener并覆盖了mousePressed方法,以确保通过按下鼠标按钮,组件的内容被导出为拖动内容。这样,我们现在可以查看清单 7-10 中的完整代码。

SwingUtilities.invokeLater(() -> {
            var frame = new JFrame("JavaFX 17 DnD in Swing");
            frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
            var jfxPanel = new JFXPanel();
            var label = new Label("Hello FX");
            var scene = new Scene(button);
            jfxPanel.setScene(label);
            jfxPanel.setPreferredSize(new Dimension(200, 100));
            label.setOnDragOver(event -> {
                var dragboard = event.getDragboard();
                if (dragboard.getContentTypes().
                    contains( DataFormat.lookupMimeType("application/
                    x-java-serialized-object"))) {
                    event.acceptTransferModes(TransferMode.COPY);
                }
                event.consume();
            });
            label.setOnDragDropped(event -> {
                var dataFormat = DataFormat.
                                lookupMimeType("application/x-java-serialized-object");
                var dragboard = event.getDragboard();
                if (dragboard.hasContent(dataFormat)) {
                    String content = (String) dragboard.getContent(dataFormat);
                    label.setText(content);
                }
                event.setDropCompleted(true);
                event.consume();
            });
            var panel = new JPanel(new BorderLayout());
            var northField = new JTextField("Hello Swing North");
            northField.setDragEnabled(true);
            northField.addMouseListener(new MouseDragAdapter());
            var southField = new JTextField("Hello Swing South");
            southField.setDragEnabled(true);
            southField.addMouseListener(new MouseDragAdapter());
            panel.add(northField, BorderLayout.NORTH);
            panel.add(southField, BorderLayout.SOUTH);
            panel.add(jfxPanel, BorderLayout.CENTER);
            frame.setContentPane(panel);
            frame.pack();
            frame.setLocationRelativeTo(null);
            frame.setVisible(true);
        });

Listing 7-10Drag from Swing to JavaFX

有两个不同的部分确保了 JavaFX Label的成功。第一段代码确保在检测组件上发生拖动的过程中,如果在DragBoard内容类型中有兼容的MimeType,则设置拖动的接受模式。这样做了,唯一缺少的就是对真正跌落的反应。这段代码确保了预期的MimeType的可用性,以正确的格式从DragBoard中检索数据,并使用这些数据来更改Label的显示文本。

由于所有这些处理方法都是从 UI 工具包中调用的,所以所有的处理都已经在正确的线程上,所以在这个例子中不需要进行线程切换。

Note

从一个JFXPanel中的一个节点拖放到另一个JFXPanel中的另一个节点与普通 JavaFX 场景中任意两个节点之间的拖放没有什么不同。操作的源和目标对 Swing 上下文中的嵌入一无所知。这是将复杂的 JavaFX 节点/控件集成到 Swing 应用程序中的一个重要因素。

集成在 Swing 中的 JavaFX 3D

JavaFX 最引人注目的特性之一是支持 3D 渲染以及 2D 和 3D 的混合,这使得高级可视化的创建变得简单。因为 JFXPanel 只是获取任何场景并将其嵌入到 Swing 组件层次结构中,所以这也可以用于支持 3D 的场景。

基于本书中使用的一个 3D 例子,清单 7-11 展示了一个 3D 集成的例子。

    SwingUtilities.invokeLater(() -> {
        var frame = new JFrame("JavaFX 17 3D integrated in Swing");
        frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
        var jfxPanel = new JFXPanel();
        var camera = createCamera();
        var box = new Box(10, 10, 10);
        var view = new Group(box, camera);
        var scene = new Scene(view, 640, 480);
        scene.setCamera(camera);
        jfxPanel.setScene(scene);
        jfxPanel.setPreferredSize(new Dimension(200,100));
        var panel = new JPanel(new BorderLayout());
        panel.add(new JLabel("Hello Swing North"), BorderLayout.NORTH);
        panel.add(new JLabel("Hello Swing South"), BorderLayout.SOUTH);
        panel.add(jfxPanel, BorderLayout.CENTER);
        frame.setContentPane(panel);
        frame.pack();
        frame.setLocationRelativeTo(null);
        frame.setVisible(true);
        Platform.runLater(() -> animate());
    });
private static Camera createCamera() {
    Camera answer = new PerspectiveCamera(true);
    answer.getTransforms().addAll(rotateX, rotateY, rotateZ, translateZ);
    return answer;
}
private static void animate() {
    Timeline timeline = new Timeline(
            new KeyFrame(Duration.seconds(0),
                    new KeyValue(translateZ.zProperty(), -20),
                    new KeyValue(rotateX.angleProperty(), 90),
                    new KeyValue(rotateY.angleProperty(), 90),
                    new KeyValue(rotateZ.angleProperty(), 90)),
            new KeyFrame(Duration.seconds(5),
                    new KeyValue(translateZ.zProperty(), -80),
                    new KeyValue(rotateX.angleProperty(), -90),
                    new KeyValue(rotateY.angleProperty(), -90),
                    new KeyValue(rotateZ.angleProperty(), -90))
    );
    timeline.setCycleCount(Animation.INDEFINITE);
    timeline.setAutoReverse(true);
    timeline.play();
}

Listing 7-113D embedded in Swing

运行示例显示了一个带有两个 Swing 标签的 Swing 应用程序,一个在 3D 动画 JavaFX 场景的上方,一个在下方,如图 7-12 所示。

img/468104_2_En_7_Fig12_HTML.jpg

图 7-12

集成在 Swing 中的 3D 渲染

将 Swing 集成到 JavaFX 中

在现有 Swing 应用程序中集成了新的 JavaFX 控件后,本节将介绍如何在 JavaFX 场景图中使用众所周知的大型基于 Swing 的库,例如 NASA 的 world wind(https://worldwind.arc.nasa.gov/java)

实现这一点的方法是使用特殊的 Java FXNodeSwingNode。它允许你在场景图中嵌入一个秋千JComponent。其中 JFXPanel 是包装 JavaFX 场景的 JComponent,SwingNode 是包装 Swing 组件层次结构的 JavaFX 节点。所有显示和讨论的关于线程、交互等的内容对于在 JavaFX 应用程序中集成 Swing 组件也是有效的。因为从交互的角度来看,JavaFX 节点和 Swing JComponent 这两个元素不知道它们是如何集成的,所以无论是 Swing 中的 JavaFX 还是 JavaFX 中的 Swing 都没有关系。主要区别在于,在 UI 树的构建过程中,要么应用 Swing 规则,要么应用 JavaFX 规则。列表 7-12 显示了一个简单的例子。

@Override
public void start(Stage stage) throws Exception {
    var borderPane = new BorderPane();
    var swingNode = new SwingNode();
    var scene = new Scene(borderPane, 200, 200);
    borderPane.setCenter(swingNode);
    borderPane.setBottom(new Label("JavaFX Bottom"));
    SwingUtilities.invokeLater(() -> {
        var panel = new JPanel();
        panel.setLayout(new BorderLayout());
        panel.add(new JLabel("Swing North"), BorderLayout.CENTER);
        swingNode.setContent(panel);
        borderPane.layout();
    });
    stage.setScene(scene);
    stage.show();
}

Listing 7-12Swing embedded in JavaFX

运行该应用程序的结果如图 7-13 所示。

img/468104_2_En_7_Fig13_HTML.jpg

图 7-13

嵌入 JavaFX 的 Swing

迁移策略

在 UI 工具包之间进行迁移总是一个冗长而复杂的过程。JavaFX 通过提供无缝的双向集成组件—JFXPanelSwingNode缓解了这一问题。这允许从完整的基于 Swing 的应用程序到完整的 JavaFX 应用程序的任意迁移步骤。

通常,迁移路径从复杂的基于 Swing 的应用程序开始,并尝试尽可能多地摆脱 Swing,或者尝试集成 JavaFX 中可用的更好的组件或控件。所以第一站总是基于 JFXPanel 的方法。

使用“divide et impera”策略,在您现有的 Swing 组件层次结构中寻找可以轻松替换为 JavaFX 组件的组件。

当您这样做时,您的应用程序的越来越多的部分将开始成为 JavaFX,并且您可以开始将已经转换的 JFXPanels 重组为更大的场景图。如果仍然有一些 Swing 组件不能被转换,那么仍然有可能重用原始的 Swing 组件,将其包装在 SwingNode 中,并将其用作场景图的一部分。

这种同时使用 JFXPanel 和 SwingNode 的方法至少在理论上允许透明的增量迁移,尽管这样做的细节可能很复杂。

大规模集成

JavaFX 在构建最初基于 Swing 的混搭应用程序方面提供了增强的集成可能性,这使得创建两种技术的复杂组合变得很容易。

一个非常突出的例子是一个试图将 JavaFX 快速应用开发工具 Scene Builder 嵌入 Apache NetBeans(https://netbeans.apache.org)——一个基于 Swing 的 IDE 的项目。实际项目详见 https://github.com/svenreimers/nbscenebuilder

图 7-14 显示了集成的示例截图。

img/468104_2_En_7_Fig14_HTML.jpg

图 7-14

Apache NetBeans 中的场景构建器集成

结论

JavaFX 具有两种兼容性策略,允许将 JavaFX UI 部件嵌入到现有的 Swing 应用程序中,并允许在新的 JavaFX 应用程序中重用 Swing 组件,是构建新的跨平台富客户端应用程序的首选。本章涵盖的要点如下:

  • JavaFX 提供了一个名为JFXPanel的 Swing 组件,用于将 JavaFX 场景图形集成到 Swing 中。

  • JavaFX 提供了SwingNode来将 Swing 组件集成到 JavaFX 中。

  • 在处理两个提供自己专用 UI 线程的 UI 工具包时,需要特别注意。

  • 两个 UI 工具包中的节点和组件之间的交互很容易实现。

  • 大规模集成当然是可能的,并且可以保护您现有的投资。

八、JavaFX 3D

作者:约翰·沃斯和何塞·佩雷达

现代 UI 平台应该能够在二维屏幕上处理三维数据可视化。这通常是与工程、建筑、科学或医学成像相关的应用程序的要求。JavaFX APIs 为三维形状提供了许多基类,并提供了许多处理这些形状及其渲染的方法,同时考虑了环境因素,如光线、相机和材料属性。

最重要的是,第三方框架提供了额外的形状和功能,开发人员可以使用它们来创建三维场景,并在二维屏幕上进行渲染。

在本章中,我们将概述 JavaFX APIs 中可用的功能,并简要介绍第三方扩展。我们从基本概念开始,一旦我们涵盖了这些,我们将把其中的一些概念结合到更具交互性的示例中。

先决条件

通常,将三维对象投影到二维屏幕上,考虑光线、材质行为和相机视点,是一个计算密集型过程。虽然只使用软件渲染就可以做到这一点,但这通常会导致渲染速度缓慢,不利于提供良好的用户体验。在其架构中,JavaFX 允许尽可能利用现代渲染解决方案和硬件加速。

因此,JavaFX 只允许渲染和操作三维场景,前提是底层硬件能够做到这一点。JavaFX 平台提供了一种方法

javafx.application.Platform.isSupported(ConditionalFeature feature)

只有在运行的 JavaFX 平台支持特定特性时,它才返回 true。根据您的硬件和操作系统,某些功能将受到支持,而其他功能可能不受支持。一个特定的 ConditionalFeature 指定是否支持 3D:

ConditionalFeature.SCENE3D

在运行时,JavaFX 平台将选择最佳的渲染管道。它将总是尝试使用硬件加速的渲染管道。在 Windows 系统上,这是 D3DPipeline,它支持 Direct3D。所有实现都支持 ConditionalFeature。因此能够渲染 JavaFX 3D 场景。

在 Linux、macOS、Android、iOS 和大多数嵌入式系统上,将使用 es2 管道,它将使用 OpenGL 来呈现 JavaFX 节点。OpenGL 定义了许多扩展,这些扩展在特定的实现中可能存在,也可能不存在。其中一个扩展是对 NPOT 的支持,它允许存储维度不是 2 的幂的纹理。如果此扩展可用,则 JavaFX 平台支持 ConditionalFeature.SCD。

实际上,大多数现代系统都支持 JavaFX 3D。移动设备通常具有支持硬件加速的强大 GPU,因为这些设备经常用于呈现高度动态的交互式内容(例如,图像、视频)。在硬件陈旧或不受支持的情况下,JavaFX 会优雅地打印一条关于缺少 3D 支持的消息,而不是为用户提供一个缓慢且无响应的界面。

形状入门

JavaFX 平台包含许多现成可用的三维形状。除了这些预定义的形状,开发人员还可以创建自己的三维对象。所有这些形状都是常规的 JavaFX 节点;因此,它们可以与这些节点组合在一起。有许多与三维对象相关的附加特性和属性在二维世界中是不相关的,我们将在本章的后面讨论这些。

作为一个非常简单的例子,我们展示了如何在一个 JavaFX 场景中组合一个简单的 JavaFX 标签和一个三维球体。可以在 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/simplesphere 找到的清单 8-1 中的代码实现了这一点。

package org.modernclientjava.hello3d;
import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.shape.Sphere;
import javafx.stage.Stage;
public class SimpleSphere extends Application {
    @Override
    public void start(Stage stage) throws Exception {
        Sphere sphere = new Sphere(50);
        Label label = new Label("Hello, JavaFX 3D");
        label.setTranslateY(80);
        Group root = new Group(label, sphere);
        root.setTranslateX(320);
        root.setTranslateY(240);
        Scene scene = new Scene(root, 640, 480);
        stage.setTitle("JavaFX 3D Sphere");
        stage.setScene(scene);
        stage.show();
    }
    public static void main(String[] args) {
        launch();
    }
}

Listing 8-1SimpleSphere source code

该样本的输出如图 8-1 所示。

img/468104_2_En_8_Fig1_HTML.png

图 8-1

SimpleSphere 示例的输出

虽然这张图片非常简单,并没有提供很多实际价值,但重要的是,在 JavaFX 中,开始向场景添加三维对象非常容易。

我们用单参数构造器创建了一个球体

Sphere sphere = new Sphere(50);

这将创建一个半径为 50 像素的球体。这个球体的中心是我们坐标系的中心,在三维空间中是在(0,0,0)。

为了展示如何在单个场景中组合 2D 和 3D 对象,我们还创建了一个标签:

Label label = new Label("Hello, JavaFX 3D");

我们不希望标签与球体重叠;因此,我们将其沿 y 轴向下移动 80 个像素:

label.setTranslateY(80);

然后,我们将球体和标签组合在一个组中,如下所示:

Group root = new Group(label, sphere);

我们想把我们组的内容放在场景的中心。我们将创建一个宽度为 640 像素、高度为 480 像素的场景,因此我们将通过水平移动 320 像素和垂直移动 240 像素来将组移动到中心:

root.setTranslateX(320);
root.setTranslateY(240);
Scene scene = new Scene(root, 640, 480);

剩下的代码只是将场景分配给舞台,设置标题,并显示舞台。

尽管开发人员只需编写很少的代码就可以开始使用 JavaFX 3D APIs,但在幕后仍有很多工作要做。形状具有材质属性,投影由场景上的摄像头实现,有光源负责照亮场景。我们将在本章的后面讨论材质、相机和灯光。

JavaFX APIs 允许开箱即用地创建许多基本的 3D 形状:球体、盒子和圆柱体。通过使用 MeshView 类,开发人员可以很容易地添加他们自己的形状,我们将在后面进行描述。

以下代码片段显示了单个场景中的一个球体、一个立方体和一个长方体:

Sphere sphere= new Sphere(50);
sphere.setTranslateX(-100);
Box box = new Box(40,50,60);
Cylinder cylinder = new Cylinder(50, 80);
cylinder.setTranslateX(100);
Group root = new Group(sphere, box, cylinder);
root.setRotationAxis(new Point3D(.2,.5,.7));
root.setRotate(45);
root.setTranslateX(320);
root.setTranslateY(240);
Scene scene = new Scene(root, 640, 480);

为了清楚地表明我们在这里处理的是三维形状,我们旋转了整个组,以便可以看到形状的不同侧面。(未示出的)旋转轴由{x,y,z}坐标为{0,0,0}的场景的中心点和像素坐标为{0.2,0.5,0.7}的所提供的点定义。这两个点定义了整个组围绕其旋转的线。

稍后我们将更多地讨论坐标系和平移。

img/468104_2_En_8_Fig2_HTML.png

图 8-2

SphereCylinderBox 示例的输出

运行这个可以在 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/spherecylinderbox 找到的应用程序,屏幕输出如图 8-2 所示。

此示例显示了 JavaFX 中预定义的三种三维形状:球体、长方体和圆柱体。这些形状都是 javafx.scene.shape 包(二维形状也位于该包中)的一部分,它们扩展了 javafx.scene.shape.Shape3D 类。第四个类 MeshView 允许开发人员创建自己的形状。

在讨论不同的形状之前,我们将解释 JavaFX 平台使用的坐标系。如图 8-3 所示,JavaFX 使用右手坐标系。在该系统中,x 轴和 y 轴位于屏幕区域,z 轴垂直于屏幕,指向远离观察者的方向。坐标系的原点在屏幕的左上角。

img/468104_2_En_8_Fig3_HTML.png

图 8-3

JavaFX 3D 中的坐标系

默认情况下,JavaFX 使用放置在负值 z 处的平行相机,因此会查看正 z 值的方向。该相机使用正交投影,其中所有节点都垂直投影到{x,y}平面上。稍后,我们将讨论另一种类型的相机,透视相机,它使用不同的投影。

因为如果节点沿 z 轴的坐标改变,默认相机不会以不同的方式渲染节点,所以除非旋转,否则很难看到长方体的形状。这就是我们在第二个例子中所做的。我们围绕从(0,0,0)开始并包含(0.2,0.5,0.7)的轴旋转整个组。

三维形状的 translate 和 rotate 属性以及 rotationAxis 属性是从 JavaFX Node 类继承的。

Shape3D

所有 JavaFX 3D 形状的超类是javafx.scene.shape.Shape3D。此基类提供所有形状共享的通用功能。该功能由三个属性定义:material、drawMode 和 cullFace。遵循 JavaFX API 约定,可以直接访问这些属性,并且可以通过相应的 get 和 set 方法访问它们的值:

  • void setMaterial(Material)

  • Material getMaterial()

  • ObjectProperty<Material> materialProperty()

  • void setDrawMode(DrawMode)

  • DrawMode getDrawMode()

  • ObjectProperty<DrawMode> drawModeProperty()

  • void setCullFace(CullFace)

  • CullFace getCullFace()

  • ObjectProperty<CullFace> cullFaceProperty()

Material 类包含一组渲染属性,用于控制 3D 形状对灯光的反应。它赋予 3D 形状独特的外观。我们将在后面的章节中介绍材质类的层次结构。现在,只需知道层次结构中有一个名为 PhongMaterial 的具体类,并且它有一个构造器,该构造器为其漫反射颜色接受一个颜色参数。

DrawMode 枚举有两个声明符:LINE 和 FILL。drawMode 的 DrawMode 属性。线将导致 JavaFX 运行时将 3D 形状渲染为线框。drawMode 的 DrawMode 属性。FILL 将导致 JavaFX 运行时将 3D 形状渲染为实体。默认情况下,设置填充。

CullFace 枚举有三个声明符:NONE、BACK 和 FRONT。它控制 JavaFX 运行时如何渲染 3D 形状的每个组成多边形(也称为面)。通过称为面剔除的过程,JavaFX 运行时可能会删除 3D 形状中的一些面,从而提高 3D 模型的性能。cullFace 的一个 CullFace 属性。NONE 将导致 JavaFX 运行时不执行任何面剔除。cullFace 的一个 CullFace 属性。BACK 将导致 JavaFX 运行时剔除所有背面。cullFace 的一个 CullFace 属性。正面将导致 JavaFX 运行时剔除所有正面。默认情况下,设置 BACK。

我们将在关于用户定义的三维造型的章节中更详细地讨论三维造型的面。

范围

javafx.scene.shape.Sphere 类描述一个球体。一个球体有三个构造器:

  • 球体()

  • 球体(双半径)

  • 球体(双半径,整数分割)

在这些构造器中,半径描述了球体的半径。如果未提供该值,将使用半径 1.0。

该划分与用于生成围绕其赤道的球体形状的三角形的数量有关。在渲染过程中,一个球体由许多三角形组成。该数值越大,球体越平滑,但计算时间也会增加。默认情况下,使用 64 个划分,这导致网格有近 4000 个三角形。

创建的球体的中心位于坐标系的原点,因此位于(0,0,0)。

包厢

javafx.scene.shape.Box 类描述一个盒子。Box 类有两个构造器:

  • 方框()

  • 盒子(双倍宽度、双倍高度、双倍深度)

这些构造器是自我解释的。如果没有指定宽度、高度和深度,它们都被设置为 2,将生成 12 个三角形。

圆筒

javafx.scene.shape.Cylinder 类描述一个圆柱体。这个类有三个构造器:

  • 圆筒

  • 圆柱体(双半径,双高度)

  • 圆柱体(双半径、双高度、整数分割)

显然,半径参数对应于圆柱体的半径,而高度参数对应于其高度。如果没有提供这些参数,默认半径为 1,默认高度为 2。

与球体中的分割概念类似,圆柱体中的分割参数描述了用于渲染圆柱体底部区域的三角形数量。默认值为 64,导致网格由 256 个三角形组成。圆柱体的 Javadoc 指定了该参数的最小数目:

注意,刻度至少应为 3。任何小于该值的值都将被固定为 3。

创建用户定义的三维形状

前面几节展示了如何轻松使用标准 JavaFX 3D 形状来创建 3D 场景。实际上,典型的 3D 环境使用比简单的球体、圆柱体或立方体更复杂的形状。

JavaFX 允许开发人员完全定义他们的自定义形状,包括几何图形和材料。MeshView 类描述了允许这样做的 JavaFX 节点。MeshView 实例具有相应的网格实例,用于描述 3D 形状。

MeshView 类具有以下构造器:

  • 网格视图()

  • 网格视图(网格网格)

默认构造器创建一个没有MeshMeshView。单参数构造器用指定的mesh创建一个MeshViewmesh是一个可读写的对象属性。Mesh抽象类及其TriangleMesh具体子类存储了 3D 形状的几何信息。TriangleMesh中的几何信息包括以下内容:

  • 定义网格中顶点格式的顶点格式:顶点由点和纹理坐标组成(顶点格式。默认为 POINT_TEXTCOORD)或点、法线和纹理坐标(VertexFormat。点 _ 法线 _ 文本坐标)。

  • 3D 形状的所有顶点或点的三维坐标。

  • 3D 形状使用的二维纹理坐标。

  • 如果顶点格式设置为 POINT_NORMAL_TEXTCOORD,则 3D 形状使用的三维法线。

  • 使用 POINT_TEXTCOORD 时,3D 形状的每个面都是由顶点列表中的顶点索引和纹理坐标列表中的纹理索引定义的三角形,设置 POINT_NORMAL_TEXTCOORD 时,还由法线列表的法线索引定义。

  • 面平滑组,使 JavaFX 运行时将同一平滑组中的面平滑地连接到它们的公共边上,并将不在同一平滑组中的面之间的边作为硬边。当设置了 POINT_NORMAL_TEXTCOORD 时,不使用。

出于效率原因,TriangleMesh类将这些信息存储在可观察数组中。以下公共方法允许您访问这些可观察的数组及其大小:

  • ObservableFloatArray getPoints()

  • ObservableFloatArray getTexCoords()

  • ObservableFloatArray getNormals()

  • ObservableFaceArray getFaces()

  • ObservableIntegerArray getFaceSmoothingGroups()

  • int getPointElementSize()

  • int getTexCoordElementSize()

  • int getNormalElementSize()

  • int getFaceElementSize()

getPoints()方法返回一个ObservableFloatArray,可以用来添加三维顶点坐标。这个可观察的浮点数组的大小必须是 3 的倍数,数组的元素被解释为 x0y0z0x1y1z1 、…,其中( x0y0z0 )是第一个顶点的坐标

getTexCoords()方法返回一个ObservableFloatArray,可以用来添加二维纹理坐标。这个可观察的浮点数组的大小必须是 2 的倍数,数组的元素解释为 u0v0u1v1 、…,其中( u0v0 )是第一个纹理点,( u1v1 )是第二个纹理点,以此类推。我们将在“材质”一节中详细介绍纹理坐标。现在,将纹理坐标理解为二维图像中的点就足够了,左上角的点具有坐标(0,0),右下角的点具有坐标(1,1)。

getNormals()方法返回一个 ObservableFloatArray,可以用来添加三维法线。这个可观察的浮动数组的大小必须是 3 的倍数,数组的元素解释为 nx0,ny0,nz0,nx1,ny1,nz1,… ,其中( nx0,ny0,nz0 )是第一法线,( nx1,ny1,nz1 )是第二法线,以此类推。每个法线都可以解释为在给定点处垂直于 3D 形状表面的方向,指向外部。

getFaces()方法返回一个ObservableFaceArray,您可以使用它向 3D 形状添加面。ObservableFaceArrayObservableIntegerArray接口的子接口。

当 VertexFormat。使用 POINT_TEXTCOORD,这个数组的大小必须是 6 的倍数,数组的元素解释为 p0t0p1t1p2t2p3t3p4t4p5 p1t1p2t2p3t3p4t4p5t5 定义第二个面,以此类推。 在定义一个面的六个整数中, p 值是概念点数组的索引,它是实际点数组长度的三分之一,因为我们认为实际点数组中的三个浮点元素构成一个概念点,而 t 值是概念纹理坐标数组的索引,它是实际纹理坐标数组长度的一半,因为我们认为实际纹理坐标数组中的两个浮点元素构成一个概念纹理坐标对。

If VertexFormat。使用 POINT_NORMAL_TEXTCOORD,数组的大小必须是 9 的倍数,数组的元素被解释为第一个面的 p0,n0,T0,p1,n1,t1,p2,n2,t2 ,其余面依此类推。

getFaceSmoothingGroups()方法返回一个ObservableIntegerArray,您可以使用它为 3D 形状的面定义平滑组。您可以将此数组留空,在这种情况下,JavaFX 运行时会将 3D 形状的所有面视为属于同一个平滑组,从而生成表面处处平滑的 3D 形状。这就是Sphere预定义 3D 形状的基础TriangleMesh的情况。如果填充这个数组,那么必须用与概念面相同数量的元素填充它,这是实际面数组长度的六分之一,因为我们认为实际面数组中的六个 int 元素构成了一个概念面。面平滑组数组中的每个元素表示 3D 形状的一个面,当且仅当当每个 int 值被视为 32 个单独的位时,两个面的表示共享一个公共位时,这两个面属于同一平滑组。在一个TriangleMesh中最多可以有 32 个面部平滑组。这个限制可以通过使用法线和顶点格式 POINT_NORMAL_TEXTCOORD 来克服。在这种情况下,没有必要定义面平滑组。

getPointElementSize()方法总是返回 3。getTexCoordElementSize()方法总是返回 2。getNormalElementSize()方法总是返回 3。getFaceElementSize()方法为顶点格式 POINT_TEXTCOORD 返回 6,为顶点格式 POINT_NORMAL_TEXTCOORD 返回 9。

3D 形状中的每个面都有两条边。在 3D 图形编程中,区分这两面是正面还是背面是很重要的。JavaFX 3D 使用逆时针缠绕顺序来定义正面。想象自己站在三角形的一边,按照每个顶点在人脸定义中出现的顺序,描出三角形的边。如果看起来你是以逆时针方向描绘边缘,那么你是站在脸的前侧。这个正面和背面的概念就是CullFace.FRONTCullFace.BACK枚举声明符所指的。默认情况下,Shape3D使用CullFace.BACK设置,这意味着不渲染面的背面。

以下代码片段创建了一个简单的四面体,它是一个包含四个三角形面的形状:

@Override
public void start(Stage stage) throws Exception {
    float length = 100f;
    TriangleMesh mesh = new TriangleMesh();
    mesh.getPoints().addAll(
            0f,0f,0f,
            length,0f,0f,
            0f,length,0f,
            0f,0f,length);
    mesh.getTexCoords().addAll(
            0f,0f,
            0f,1f,
            1f,0f,
            1f,1f);
    mesh.getFaces().addAll(
            0,0,2,1,1,2,
            0,0,3,1,2,2,
            0,0,1,1,3,2,
            1,0,2,1,3,2);
    MeshView meshView = new MeshView(mesh);
    meshView.setRotationAxis(new Point3D(1,1,1));
    meshView.setRotate(30);
    meshView.setTranslateX(100);
    meshView.setTranslateY(100);
    Group group = new Group(meshView);
    Scene scene = new Scene(group);
    stage.setScene(scene);
    stage.show();
}

在这个示例中,我们创建了一个边长为length的四面体。我们首先定义四面体中使用的四个点。这些点的(x,y,z)坐标分别是,(0,0,0),(长度,0,0),(0,长度,0),和(0,0,长度)。

我们定义了四个纹理坐标:(0,0)、(0,1)、(1,0)和(1,1)。

接下来,定义四个面。

图 8-4 显示了我们刚刚创建的三角形网格。添加了轴来说明坐标系。

以正面人脸#0 为例,用索引( 002112 )或( 0,2,1 )定义点,用索引( 0,1,2 )定义纹理坐标。

顶点#0 位于原点;顶点#1 位于 X+轴上原点的长度距离处;并且顶点#2 位于 Y+轴上原点的长度距离处。

img/468104_2_En_8_Fig4_HTML.png

图 8-4

四面体的三角形网格

创建网格后,我们创建一个网格视图:

MeshView meshView = new MeshView(meshView).

如果我们使用上面定义的坐标,JavaFX 中默认的摄像机投影将只显示一个面。通过旋转网格,我们能够看到更多的脸。这是通过设置 rotationAxis 和 rotateValue 来实现的。我们还移动网格的中心,使其位于可见空间内:

meshView.setRotationAxis(new Point3D(1,1,1));
meshView.setRotate(30);
meshView.setTranslateX(100);
meshView.setTranslateY(100);

最后,我们将 meshView 添加到场景中并渲染舞台。

该程序的结果如图 8-5 所示。

img/468104_2_En_8_Fig5_HTML.jpg

图 8-5

场景中渲染的四面体

现在我们对基本形状有了更多的了解,我们可以扩展之前创建的示例。我们将所有的形状加在一起,并允许用户修改绘制模式、剔除、颜色和旋转。

让我们将这个自定义形状添加到一个更复杂的场景中。清单 8-2 中的样品可以在 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/shapesandmesh 找到。

public class ShapesAndMesh extends Application {
    private Model model;
    private View view;
    public ShapesAndMesh() {
        model = new Model();
    }
    @Override
    public void start(Stage stage) throws Exception {
        view = new View(model);
        hookupEvents();
        stage.setTitle("Pre-defined 3D Shapes Example");
        stage.setScene(view.scene);
        stage.show();
    }
    private void hookupEvents() {
        view.drawModeComboBox.setOnAction(event -> {
            ComboBox<DrawMode> drawModeComboBox =
                (ComboBox<DrawMode>) event.getSource();
            model.setDrawMode(drawModeComboBox.getValue());
        });
        view.cullFaceComboBox.setOnAction(event -> {
            ComboBox<CullFace> cullFaceComboBox =
                (ComboBox<CullFace>) event.getSource();
            model.setCullFace(cullFaceComboBox.getValue());
        });
    }
    public static void main(String[] args) {
        launch(args);
    }

    private static class Model {
        private DoubleProperty rotate =
            new SimpleDoubleProperty(this, "rotate", 60.0d);
        private ObjectProperty<DrawMode> drawMode =
            new SimpleObjectProperty<>(this, "drawMode", DrawMode.FILL);
        private ObjectProperty<CullFace> cullFace =
            new SimpleObjectProperty<>(this, "cullFace", CullFace.BACK);
        public final double getRotate() {
            return rotate.doubleValue();
        }
        public final void setRotate(double rotate) {
            this.rotate.set(rotate);
        }
        public final DoubleProperty rotateProperty() {
            return rotate;
        }
        public final DrawMode getDrawMode() {
            return drawMode.getValue();
        }
        public final void setDrawMode(DrawMode drawMode) {
            this.drawMode.set(drawMode);
        }
        public final ObjectProperty<DrawMode>
            drawModeProperty() {
            return drawMode;
        }
        public final CullFace getCullFace() {
            return cullFace.get();
        }
        public final void setCullFace(CullFace cullFace) {
            this.cullFace.set(cullFace);
        }
        public final ObjectProperty<CullFace>
            cullFaceProperty() {
            return cullFace;
        }
    }

    private static class View {
        public Scene scene;
        public Sphere sphere;
        public Cylinder cylinder;
        public Box box;
        public MeshView meshView;
        public ComboBox<DrawMode> drawModeComboBox;
        public ComboBox<CullFace> cullFaceComboBox;
        public Slider rotateSlider;
        public View(Model model) {
            sphere = new Sphere(50);
            cylinder = new Cylinder(50, 100);
            box = new Box(100, 100, 100);
            meshView = createMeshView(100);
            sphere.setTranslateX(100);
            cylinder.setTranslateX(300);
            box.setTranslateX(500);
            meshView.setTranslateX(700);
            sphere.setMaterial(new PhongMaterial(Color.RED));
            cylinder.setMaterial(new PhongMaterial(Color.YELLOW));
            box.setMaterial(new PhongMaterial(Color.BLUE));
            meshView.setMaterial(new PhongMaterial(Color.GREEN));
            setupShape3D(sphere, model);
            setupShape3D(cylinder, model);
            setupShape3D(box, model);
            setupShape3D(meshView, model);
            Group shapesGroup = new Group(sphere, cylinder, box, meshView);
            SubScene subScene = new SubScene(shapesGroup,
                800, 400, true, SceneAntialiasing.BALANCED);
            drawModeComboBox = new ComboBox<>();
            drawModeComboBox.setItems(
                FXCollections.observableArrayList(
                    DrawMode.FILL, DrawMode.LINE));
            drawModeComboBox.setValue(DrawMode.FILL);
            cullFaceComboBox = new ComboBox<>();
            cullFaceComboBox.setItems(
                FXCollections.observableArrayList(
                    CullFace.BACK, CullFace.FRONT,
                    CullFace.NONE));
            cullFaceComboBox.setValue(CullFace.BACK);
            HBox hbox1 = new HBox(10, new Label("DrawMode:"),
                drawModeComboBox,
                new Label("CullFace:"), cullFaceComboBox);
            hbox1.setPadding(new Insets(10, 10, 10, 10));
            hbox1.setAlignment(Pos.CENTER_LEFT);
            rotateSlider = new Slider(-180.0d, 180.0d, 60.0d);
            rotateSlider.setMinWidth(400.0d);
            rotateSlider.setMajorTickUnit(10.0d);
            rotateSlider.setMinorTickCount(5);
            rotateSlider.setShowTickMarks(true);
            rotateSlider.setShowTickLabels(true);
            rotateSlider.valueProperty().bindBidirectional(
                model.rotateProperty());
            HBox hbox2 = new HBox(10,
                new Label("Rotate Around (1, 1, 1) Axis:"),
                rotateSlider);
            hbox2.setPadding(new Insets(10, 10, 10, 10));
            hbox2.setAlignment(Pos.CENTER_LEFT);
            VBox controlPanel = new VBox(10, hbox1, hbox2);
            controlPanel.setPadding(new Insets(10, 10, 10, 10));
            Group groupSubScene = new Group(subScene);
            BorderPane root = new BorderPane(groupSubScene,
                null, null, controlPanel, null);
            scene = new Scene(root, 800, 600, true,
                SceneAntialiasing.BALANCED);
        }

        private void setupShape3D(Shape3D shape3D,
            Model model) {
            shape3D.setTranslateY(240.0d);
            shape3D.setRotationAxis(
                new Point3D(1.0d, 1.0d, 1.0d));
            shape3D.drawModeProperty().bind(
                model.drawModeProperty());
            shape3D.cullFaceProperty().bind(
                model.cullFaceProperty());
            shape3D.rotateProperty().bind(
                 model.rotateProperty());
        }
        private MeshView createMeshView(float length) {
            TriangleMesh mesh = new TriangleMesh();
            mesh.getPoints().addAll(
                    0f, 0f, 0f,
                    length, 0f, 0f,
                    0f, length, 0f,
                    0f, 0f, length);
            mesh.getTexCoords().addAll(
                    0f, 0f,
                    0f, 1f,
                    1f, 0f,
                    1f, 1f);
            mesh.getFaces().addAll(
                    0, 0, 2, 1, 1, 2,
                    0, 0, 3, 1, 2, 2,
                    0, 0, 1, 1, 3, 2,
                    1, 0, 2, 1, 3, 2);
            MeshView meshView = new MeshView(mesh);
            return meshView;
        }
    }
}

Listing 8-2ShapesAndMesh source code

运行该示例时,将会看到图 8-6 中的输出。

img/468104_2_En_8_Fig6_HTML.jpg

图 8-6

ShapesAndMesh 的输出

在这个例子中,我们结合了到目前为止我们所学的所有主题。我们还包括了一个新的节点:子场景节点。我们将解释这个节点是什么以及它的用途。

我们创建基本的形状盒子,立方体和圆柱体,我们创建一个网格视图,就像我们之前展示的那样。对于每个形状,我们调用 setupShape3D 方法来执行以下操作:

private void setupShape3D(Shape3D shape3D, Model model) {
    shape3D.setTranslateY(240.0d);
    shape3D.setRotationAxis(new Point3D(1.0d, 1.0d, 1.0d));
    shape3D.drawModeProperty().bind(model.drawModeProperty());
    shape3D.cullFaceProperty().bind(model.cullFaceProperty());
    shape3D.rotateProperty().bind(model.rotateProperty());
}

形状的 drawMode 绑定到模型中的 drawModeProperty 的值。同样的原则也适用于 cullFace 和 rotate 属性。

我们添加了一个组合框,允许选择绘制模式,可以是线条或填充。默认情况下,将选择填充:

drawModeComboBox = new ComboBox<>();
drawModeComboBox.setItems(
    FXCollections.observableArrayList(
         DrawMode.FILL, DrawMode.LINE));
drawModeComboBox.setValue(DrawMode.FILL);

当用户更改 drawMode 的值时,模型上的 drawMode 属性将被修改。这是通过以下代码片段实现的:

view.drawModeComboBox.setOnAction(event -> {
    ComboBox<DrawMode> drawModeComboBox =
        (ComboBox<DrawMode>) event.getSource();
    model.setDrawMode(drawModeComboBox.getValue());
});

由于模型的 drawMode 属性被绑定到每个形状的 drawMode,由于

shape3D.drawModeProperty().bind(model.drawModeProperty());

当用户在组合框中选择不同的值时,该形状将具有不同的绘制模式。

“cullFace”组合框的值以完全相同的方式转移到形状的“cullFace”属性。

旋转滑块选择的值更容易传输。

旋转滑块的创建过程如下:

rotateSlider = new Slider(-180.0d, 180.0d, 60.0d);
rotateSlider.setMinWidth(400.0d);
rotateSlider.setMajorTickUnit(10.0d);
rotateSlider.setMinorTickCount(5);
rotateSlider.setShowTickMarks(true);
rotateSlider.setShowTickLabels(true);

接下来,该值立即绑定到模型上 rotateProperty 的值:

rotateSlider.valueProperty().bindBidirectional(model.rotateProperty());

由于每个形状的 rotateProperty 都绑定到模型的 rotateProperty,因此每当用户更改幻灯片时,形状的旋转值也会随之改变。

假设在此示例中,我们将一个 2D 节点(控制面板)与另一个使用 3D 形状的节点混合在一起,那么将 3D 功能的使用仅限于具有 3D 形状的节点是一个很好的做法。

子场景节点是一个容器,它可以拥有自己的摄影机以及自己的深度缓冲和场景抗锯齿设置。例如,这允许将相机变换仅应用于该节点内的内容,而场景的其余部分不会受到影响。

子场景可以嵌入到主场景或另一个子场景中。

场景和子场景都可以请求深度缓冲支持或场景抗锯齿支持。不要求它们只包含 2D 形状而不包含任何 3D 形状。但是如果他们这样做,深度缓冲允许深度排序渲染以避免深度冲突,这与 z 轴上每个形状的可视化有关。抗锯齿会影响整个渲染场景或子场景的平滑度。它可以被禁用或平衡。深度缓冲和场景抗锯齿都是有条件的功能。

在此示例中,这是通过使用此构造器实现的:

SubScene subScene =
    new SubScene(shapesGroup, 800, 400, true, SceneAntialiasing.BALANCED);

照相机

当在二维屏幕上显示三维世界时,不知何故需要使用从三维世界到 2D 世界的投影。通常,投影由摄像机的概念来管理。即使你没有指定一个摄像机,总会有一个假定存在。默认相机是一个平行相机,它将场景图形投影到位于 z=0 的平面上,并且该相机正看着正 z 方向。

JavaFX Camera 类是一个抽象类,有两个具体的子类:ParallelCamera 和 PerspectiveCamera。

平行摄像机

ParallelCamera 使用正交投影,忽略透视。对于 2D 世界,这提供了非常好的结果,但是对于三维物体,这是不现实的。正交投影的结果是对象的投影尺寸不依赖于相机和对象之间的垂直距离。例如,人类的视觉不是这样工作的:离我们眼睛较近的物体看起来比同样大小但距离较远的物体要大。

使用考虑了透视的相机可以实现更真实的投影,因为在这种情况下,对象的投影确实取决于它们与相机的相对距离。

透视照相机

PerspectiveCamera是 JavaFX 中允许透视投影的摄像机实现。PerspectiveCamera 类具有以下公共构造器:

  • PerspectiveCamera()

  • PerspectiveCamera(boolean fixedEyeAtCameraZero)

默认构造器创建一个PerspectiveCamera,并将fixedEyeAtCameraZero设置为 false。单参数构造器用指定的fixedEyeAtCameraZero创建一个PerspectiveCameraCamerasNode s,可以放在 JavaFX 场景中。新创建的PerspectiveCamera与新创建的SphereCylinderBox一样,其中心或眼睛位于三维空间的原点(0,0,0)。眼睛看向正 z 方向。作为一个NodePerspectiveCamera本身可以通过RotateTranslateScale等 3D 变换,甚至是通用的Affine进行变换。truefixedEyeAtCameraZero设置保证在这样的变换后,PerspectiveCamera的眼睛随之移动并保持在摄像机的零位。falsefixedEyeAtCameraZero设置允许眼睛偏离相机的零位,以适应场景中的情况。这对于渲染 2D 场景很有用,并且只有当相机本身没有以任何方式变换时才有意义。因此,对于 3D 模型的使用,您应该总是使用单参数构造器,传入一个truefixedEyeAtCameraZero

PerspectiveCamera类有以下公共方法:

  • void setFieldOfView(double)

  • double getFieldOfView()

  • DoubleProperty fieldOfViewProperty()

  • void setVerticalFieldOfView(boolean)

  • boolean isVerticalFieldOfView()

  • BooleanProperty verticalFieldOfViewProperty()

  • boolean isFixedEyeAtCameraZero()

这些方法为PerspectiveCamera类定义了两个属性fieldOfViewverticalFieldOfViewfieldOfView是一个双精度属性,以度为单位表示透视摄像机的视野。默认值为 30 度。verticalFieldOfView是一个布尔属性,决定视野属性是否适用于投影平面的垂直维度。isFixedEyeAtCameraZero()方法返回构造PerspectiveCamerafixedEyeAtCameraZero标志。

PerspectiveCamera也从Camera抽象类继承了两个双重属性:nearClipfarClip。比nearClip更靠近眼睛的物体和比farClip更远离眼睛的物体不会在场景中渲染。

在清单 8-3 中的程序(可从 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/perspectivecamera 获得)中,我们创建了一个Box和一个PerspectiveCamera。与之前的例子不同,在这个程序中,我们像变换Box一样变换 3D 对象本身,我们保持Box固定,并用围绕 x 轴旋转、围绕 y 轴旋转和沿着 z 轴平移的组合来变换PerspectiveCamera。我们制作了从 90 度到–90 度的旋转角度和在 5 秒钟内从–20 度到–80 度的 z 平移的动画。

package org.modernclientjava.javafx3d;
import javafx.animation.Animation;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Application;
import javafx.scene.Camera;
import javafx.scene.Group;
import javafx.scene.PerspectiveCamera;
import javafx.scene.Scene;
import javafx.scene.shape.Box;
import javafx.scene.transform.Rotate;
import javafx.scene.transform.Translate;
import javafx.stage.Stage;
import javafx.util.Duration;
public class PerspectiveCameraDemo extends Application {
    private final Rotate rotateX = new Rotate(-20, Rotate.X_AXIS);
    private final Rotate rotateY = new Rotate(-20, Rotate.Y_AXIS);
    private final Rotate rotateZ = new Rotate(-20, Rotate.Z_AXIS);
    private final Translate translateZ = new Translate(0, 0, -100);
    @Override
    public void start(Stage stage) throws Exception {
        Camera camera = createCamera();
        Box box = new Box(10, 10, 10);
        Group view = new Group(box, camera);
        Scene scene = new Scene(view, 640, 480);
        scene.setCamera(camera);
        stage.setTitle("PerspectiveCamera Example");
        stage.setScene(scene);
        stage.show();
        animate();
    }

    private Camera createCamera() {
        Camera camera = new PerspectiveCamera(true);
        camera.getTransforms().addAll(rotateX, rotateY, rotateZ, translateZ);
        return camera;
    }
    private void animate() {
        Timeline timeline = new Timeline(
                new KeyFrame(Duration.seconds(0),
                        new KeyValue(translateZ.zProperty(), -20),
                        new KeyValue(rotateX.angleProperty(), 90),
                        new KeyValue(rotateY.angleProperty(), 90),
                        new KeyValue(rotateZ.angleProperty(), 90)),
                new KeyFrame(Duration.seconds(5),
                        new KeyValue(translateZ.zProperty(), -80),
                        new KeyValue(rotateX.angleProperty(), -90),
                        new KeyValue(rotateY.angleProperty(), -90),
                        new KeyValue(rotateZ.angleProperty(), -90))
        );
        timeline.setCycleCount(Animation.INDEFINITE);
        timeline.play();
    }
    public static void main(String[] args) {
        launch(args);
    }
}

Listing 8-3PerspectiveCamera source code

运行这个示例显示了一个动画,图 8-7 是这个动画的截图。

img/468104_2_En_8_Fig7_HTML.png

图 8-7

透视相机输出的屏幕截图

光线

JavaFX 3D 图形 API 的照明类层次结构由LightBase抽象类及其具体子类AmbientLightPointLight组成。他们属于javafx.scene套餐。一个AmbientLight是一个似乎来自四面八方的光源。A PointLight是在空间中有一个固定点,并向远离自身的所有方向均匀辐射光线的光。它们是Node s,所以可以添加到 JavaFX 场景中,为场景提供照明。也可以使用Translate变换将它们移动到所需的位置。如果它们被添加到容器中,那么当容器被转换时,它们会随着容器一起移动。

了解 LightBase 类

LightBase抽象类有以下公共方法:

  • void setColor(Color)

  • Color getColor()

  • ObjectProperty<Color> colorProperty()

  • void setLightOn(boolean)

  • boolean isLightOn()

  • BooleanProperty lightOnProperty()

  • ObservableList<Node> getScope()

  • ObservableList<Node> getExclusionScope()

这些方法为LightBase类定义了两个属性colorlightOn。属性的类型是Color,它定义了光线的颜色。lightOn属性是一个布尔属性,控制灯是否打开。getScope()方法返回一个NodeObservableList,当这个列表为空时,灯光会影响场景中的所有Node。当此列表不为空时,灯光只影响列表中包含的Node s。JavaFX 13 中新增的getExlusionScope()方法返回一个ObservableList of Node s,该列表中的任何节点或列表中父节点下的任何节点都不受光照影响,除非范围列表中存在更接近的父节点。

了解 AmbientLight

AmbientLight类有以下构造器:

  • AmbientLight()

  • AmbientLight(Color color)

默认构造器创建一个默认颜色为Color.WHITEAmbientLight。单参数构造器用指定的颜色创建一个AmbientLight。除了从LightBase基类继承的方法之外,AmbientLight类没有额外的公共方法。

了解点光源

PointLight类有以下构造器:

  • PointLight()

  • PointLight(Color color)

默认构造器创建一个默认颜色为Color.WHITEPointLight。单参数构造器用指定的颜色创建一个PointLight。除了从LightBase类继承的方法之外,PointLight类没有额外的公共方法。

从 JavaFX 16 开始,光的强度可以通过衰减来设置为随距离而降低。

衰减公式为

attn = 1 / (ca + la * dist + qa * dist²)

其中calaqa分别控制距离dist上强度衰减的常量、线性和二次行为。光线在空间中给定点的有效颜色是color * attn。尽管不切实际,但指定衰减系数为负值是可能的。

因此,有四个新属性来设置光衰减:

  • DoubleProperty constantAttenuationProperty()

  • DoubleProperty linearAttenuationProperty()

  • DoubleProperty quadraticAttenuationProperty()

  • DoubleProperty maxRangeProperty()

清单 8-4 中显示的程序(可从 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/lightdemo 获得)说明了 JavaFX 3D 场景中灯光的使用。两个PointLight,一个红色,一个蓝色,被添加到已经有三个Boxes和一个PerspectiveCamera的场景中。对于每种灯光,在窗口底部都添加了一个控制面板,让您可以看到灯光的效果。对于每盏灯,一个CheckBox可以让它开或关,三个滑块可以改变灯的位置坐标。对于红灯,几个RadioButtons允许将每个 B ox either添加到范围列表或排除列表。对于蓝光,几个控件允许定义距离和三个常量的值来衰减光。

package org.modernclientjava.javafx3d;

import javafx.application.Application;
import javafx.beans.binding.Bindings;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.*
import javafx.scene.control.*;
import javafx.scene.layout.*;
import javafx.scene.paint.Color;
import javafx.scene.shape.Box;
import javafx.scene.shape.Rectangle;
import javafx.scene.transform.Rotate;
import javafx.scene.transform.Translate;
import javafx.stage.Stage;
import javafx.util.converter.NumberStringConverter;

public class LightDemo extends Application {
    private final Model model;

    public LightDemo() {
        model = new Model();
    }

    @Override
    public void start(Stage stage) {
        View view = new View(model);
        stage.setTitle("Light Example");
        stage.setScene(view.scene);
        stage.show();
    }

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

    private static class Model {
        private final DoubleProperty redLightX = new SimpleDoubleProperty(this, "redLightX", 20.0d);
        private final DoubleProperty redLightY = new SimpleDoubleProperty(this, "redLightY", -15.0d);
        private final DoubleProperty redLightZ = new SimpleDoubleProperty(this, "redLightZ", -20.0d);
        private final DoubleProperty blueLightX = new SimpleDoubleProperty(this, "blueLightX", 15.0d);
        private final DoubleProperty blueLightY = new SimpleDoubleProperty(this, "blueLightY", -15.0d);
        private final DoubleProperty blueLightZ = new SimpleDoubleProperty(this, "blueLightZ", -5.0d);

        public DoubleProperty redLightXProperty() {
            return redLightX;
        }

        public DoubleProperty redLightYProperty() {
            return redLightY;
        }

        public DoubleProperty redLightZProperty() {
            return redLightZ;
        }

        public DoubleProperty blueLightXProperty() {
            return blueLightX;
        }

        public DoubleProperty blueLightYProperty() {
            return blueLightY;
        }

        public DoubleProperty blueLightZProperty() {
            return blueLightZ;
        }
    }

    private static class View {
        public Scene scene;

        public Box box1;
        public Box box2;
        public Box box3;
        public PerspectiveCamera camera;
        public PointLight redLight;
        public PointLight blueLight;

        private View(Model model) {
            box1 = new Box(10, 10, 10);
            box1.setId("Box1");
            box1.getTransforms().add(new Translate(-15, 0, 0));
            box2 = new Box(10, 10, 10);
            box2.setId("Box2");
            box3 = new Box(10, 10, 10);
            box3.setId("Box3");
            box3.getTransforms().add(new Translate(15, 0, 0));

            camera = new PerspectiveCamera(true);

            Rotate rotateX = new Rotate(-20, Rotate.X_AXIS);
            Rotate rotateY = new Rotate(-20, Rotate.Y_AXIS);
            Rotate rotateZ = new Rotate(-20, Rotate.Z_AXIS);
            Translate translateZ = new Translate(0, 0, -60);

            camera.getTransforms().addAll(rotateX, rotateY, rotateZ,
                                          translateZ);

            redLight = new PointLight(Color.RED);
            redLight.translateXProperty().bind(model.redLightXProperty());
            redLight.translateYProperty().bind(model.redLightYProperty());
            redLight.translateZProperty().bind(model.redLightZProperty());

            blueLight = new PointLight(Color.BLUE);
            blueLight.translateXProperty().bind(
                model.blueLightXProperty());
            blueLight.translateYProperty().bind(
                model.blueLightYProperty());
            blueLight.translateZProperty().bind(
                model.blueLightZProperty());

            Group group = new Group(new Group(box1, box2, box3),
                    camera, redLight, blueLight);
            SubScene subScene = new SubScene(group, 640, 400, true,
                                       SceneAntialiasing.BALANCED);
            subScene.setCamera(camera);

            // Red Light
            Tab redTab = new Tab("Red Light");
            redTab.setClosable(false);
            Rectangle red = new Rectangle(10, 10);
            red.fillProperty().bind(
                Bindings.when(redLight.lightOnProperty()).then(Color.RED)
                        .otherwise(Color.DARKGREY));
            redTab.setGraphic(red);

            CheckBox redLightOn = new CheckBox("Light On/Off");
            redLightOn.setSelected(true);
            redLight.lightOnProperty().bind(redLightOn.selectedProperty());

            Slider redLightXSlider = createSlider(20);
            Slider redLightYSlider = createSlider(-20);
            Slider redLightZSlider = createSlider(-20);
            redLightXSlider.valueProperty().bindBidirectional(
                 model.redLightXProperty());
            redLightYSlider.valueProperty().bindBidirectional(
                 model.redLightYProperty());
            redLightZSlider.valueProperty().bindBidirectional(
                 model.redLightZProperty());

            HBox hbox1 = new HBox(10, new Label("x:"), redLightXSlider,
                    new Label("y:"), redLightYSlider,
                    new Label("z:"), redLightZSlider);
            hbox1.setPadding(new Insets(10, 10, 10, 10));
            hbox1.setAlignment(Pos.CENTER);

            HBox hbox2 = new HBox(10,
                    createScopeToggles(box1),
                    createScopeToggles(box2),
                    createScopeToggles(box3));
            hbox2.setPadding(new Insets(10, 10, 10, 10));
            hbox2.setAlignment(Pos.CENTER);

            VBox redControlPanel = new VBox(10, redLightOn, hbox1, hbox2);
            redControlPanel.setPadding(new Insets(10, 10, 10, 10));
            redControlPanel.setAlignment(Pos.CENTER);
            redTab.setContent(redControlPanel);

            // Blue Light
            Tab blueTab = new Tab("Blue Light");
            blueTab.setClosable(false);
            Rectangle blue = new Rectangle(10, 10);
            blue.fillProperty().bind(
                Bindings.when(blueLight.lightOnProperty())
                   .then(Color.BLUE)
                   .otherwise(Color.DARKGREY));
            blueTab.setGraphic(blue);

            CheckBox blueLightOn = new CheckBox("Light On/Off");
            blueLightOn.setSelected(true);
            blueLight.lightOnProperty().bind(
                blueLightOn.selectedProperty());

            Slider blueLightXSlider = createSlider(15);
            Slider blueLightYSlider = createSlider(-15);
            Slider blueLightZSlider = createSlider(-15);
            blueLightXSlider.valueProperty().bindBidirectional(
                model.blueLightXProperty());
            blueLightYSlider.valueProperty().bindBidirectional(
                model.blueLightYProperty());
            blueLightZSlider.valueProperty().bindBidirectional(
                model.blueLightZProperty());

            HBox hbox3 = new HBox(10, new Label("x:"), blueLightXSlider,
                    new Label("y:"), blueLightYSlider,
                    new Label("z:"), blueLightZSlider);
            hbox3.setPadding(new Insets(10, 10, 10, 10));
            hbox3.setAlignment(Pos.CENTER);

            HBox hbox4 = new HBox(50, addLightControls(blueLight));
            hbox4.setPadding(new Insets(10, 10, 10, 10));
            hbox4.setAlignment(Pos.CENTER);

            VBox blueControlPanel = new VBox(10, blueLightOn,
                 hbox3, hbox4);
            blueControlPanel.setPadding(new Insets(10, 10, 10, 10));
            blueControlPanel.setAlignment(Pos.CENTER);
            blueTab.setContent(blueControlPanel);

            TabPane tabPane = new TabPane(redTab, blueTab);
            BorderPane borderPane = new BorderPane(
                 subScene, null, null, tabPane, null);
            scene = new Scene(borderPane);
        }

        private Slider createSlider(double value) {
            Slider slider = new Slider(-40, 40, value);
            slider.setShowTickMarks(true);
            slider.setShowTickLabels(true);
            return slider;
        }

        // since JavaFX 13 -->
        private Pane createScopeToggles(Node node) {
            RadioButton none = new RadioButton("none");
            none.setOnAction(a -> {
                redLight.getScope().remove(node);
                redLight.getExclusionScope().remove(node);
            });

            RadioButton scoped = new RadioButton("scoped");
            scoped.setOnAction(a -> redLight.getScope().add(node));

            RadioButton excluded = new RadioButton("excluded");
            excluded.setOnAction(a ->
                redLight.getExclusionScope().add(node));
            none.setSelected(true);

            ToggleGroup tg = new ToggleGroup();
            tg.getToggles().addAll(none, scoped, excluded);
            var vBox = new VBox(5, none, scoped, excluded);
            return new HBox(10, new Label(node.getId()), vBox);
        }

        // since JavaFX 16 -->
        private HBox addLightControls(PointLight light) {
            VBox range = createSliderControl("range",
                 light.maxRangeProperty(), 0, 100, light.getMaxRange());
            VBox c = createSliderControl("constant",
                 light.constantAttenuationProperty(), -1, 1,
                 light.getConstantAttenuation());
            VBox lc = createSliderControl("linear",
                 light.linearAttenuationProperty(), -1, 1,
                 light.getLinearAttenuation());
            VBox qc = createSliderControl("quadratic",
                light.quadraticAttenuationProperty(), -1, 1,
                light.getQuadraticAttenuation());
            return new HBox(10, range, c, lc, qc);
        }

        private VBox createSliderControl(String name,
          DoubleProperty property, double min, double max, double start) {
            Slider slider = new Slider(min, max, start);
            slider.setShowTickMarks(true);
            slider.setShowTickLabels(true);
            property.bindBidirectional(slider.valueProperty());
            TextField tf = new TextField();
            tf.textProperty().bindBidirectional(slider.valueProperty(),
                new NumberStringConverter());
            tf.setMaxWidth(40);
            return new VBox(5, new Label(name), new HBox(slider, tf));
        }
    }
}

Listing 8-4LightDemo source code

运行此示例显示了图 8-8 中的输出,您可以在其中修改不同的控件以查看不同的效果。

img/468104_2_En_8_Fig8_HTML.png

图 8-8

LightDemo 输出

材料

既然我们已经介绍了预定义和用户定义的 3D 形状、摄像机和灯光,在本节中,我们将讨论 JavaFX 3D graphics API 中剩下的最后一个主题。material API 由Material抽象类和它的具体子类PhongMaterial组成。Material类是一个没有任何公共方法的抽象基类。在所有实际情况下,使用的都是PhongMaterial类。材质类描述 3D 表面的物理属性以及它们如何与灯光交互。

了解 PhongMaterial

PhongMaterial类有以下构造器:

  • PhongMaterial()

  • PhongMaterial(Color diffuseColor)

  • PhongMaterial(Color diffuseColor, Image diffuseMap, Image specularMap, Image bumpMap, Image selfIlluminationMap)

默认构造器用默认的Color.WHITEdiffuseColor创建一个PhongMaterial。单参数构造器用指定的diffuseColor创建一个PhongMaterial。第三个构造器用指定的diffuseColordiffuseMapspecularMapbumpMapselfIlluminationMap创建一个PhongMaterial。在我们讲述了PhongMaterial类的属性之后,我们将讨论这些参数的含义。

PhongMaterial类有以下公共方法:

  • void setDiffuseColor(Color)

  • Color getDiffuseColor()

  • ObjectProperty<Color> diffuseColorProperty()

  • void setSpecularColor(Color)

  • Color getSpecularColor()

  • ObjectProperty<Color> specularColorProperty()

  • void setSpecularPower(double)

  • double getSpecularPower()

  • DoubleProperty specularPowerProperty()

  • void setDiffuseMap(Image)

  • Image getDiffuseMap()

  • ObjectProperty<Image> diffuseMapProperty()

  • void setSpecularMap(Image)

  • Image getSpecularMap()

  • ObjectProperty<Image> specularMapProperty()

  • void setBumpMap(Image)

  • Image getBumpMap()

  • ObjectProperty<Image> bumpMapProperty()

  • void setSelfIlluminationMap(Image)

  • Image getSelfIlluminationMap()

  • ObjectProperty<Image> selfIlluminationMapProperty()

这些方法为PhongMaterial类定义了七个读写属性。diffuseColorspecularColor是类型Color的对象属性。specularPower是一个双重财产。diffuseMapspecularMapbumpMapselfIlluminationMap属性是Image类型的对象属性。七个属性中的五个可以在第三个构造器中指定。然而,一旦构造了一个PhongMaterial,它的属性也可以被改变。

在我们之前的几个例子中,我们使用了单参数的PhongMaterial构造器,其中我们指定了 3D 形状的漫射颜色。漫射色就是我们通常认为的物体的颜色。

镜面反射颜色是从光亮表面(如镜子或其他抛光良好的表面)反射的高光颜色。

有关漫反射颜色和镜面反射颜色的更多信息,请参考 https://en.wikipedia.org/wiki/Diffuse_reflectionhttps://en.wikipedia.org/wiki/Specular_highlight

清单 8-5 中的程序可从 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/specularcolordemo 获得,该程序为球体的材质添加一种镜面反射颜色。

package org.modernclientjava.javafx3d;
import javafx.application.Application;
import javafx.scene.Group;
import javafx.scene.PointLight;
import javafx.scene.Scene;
import javafx.scene.paint.Color;
import javafx.scene.paint.PhongMaterial;
import javafx.scene.shape.Sphere;
import javafx.stage.Stage;
public class SpecularColorDemo extends Application {
    private View view;
    @Override
    public void start(Stage stage) throws Exception {
        view = new View();
        stage.setTitle("Specular Color Example");
        stage.setScene(view.scene);
        stage.show();
    }
    public static void main(String[] args) {
        launch(args);
    }
    private static class View {
        public Scene scene;
        public Sphere sphere;
        public PointLight light;
        private View() {
            sphere = new Sphere(100);
            PhongMaterial material =
                new PhongMaterial(Color.BLUE);
            material.setSpecularColor(Color.LIGHTBLUE);
            material.setSpecularPower(10.0d);
            sphere.setMaterial(material);
            sphere.setTranslateZ(300);
            light = new PointLight(Color.WHITE);
            Group group = new Group(sphere, light);
            group.setTranslateY(240);
            group.setTranslateX(320);
            scene = new Scene(group, 640, 480);
        }
    }
}

Listing 8-5SpecularColorDemo source code

如果我们运行这个程序,输出如图 8-9 所示。

img/468104_2_En_8_Fig9_HTML.png

图 8-9

镜面反射镜的输出

在这个程序中,我们让一束白光照射到一个球体上,漫射颜色为蓝色,镜面反射颜色为浅蓝色,镜面反射能力为 10。PhongMaterial 中默认的镜面反射功率是 32.0。因此,我们的球体显示的焦点比默认的要少。镜面反射能力的值越高,白点就越聚焦。较低的高光功率值将导致更大、更模糊的白色区域。

向 3D 形状添加纹理

diffuseMapspecularMapdiffuseColorspecularColor的作用相同,只是贴图为 3D 形状表面的不同点提供不同的颜色值。类似地,bumpMapselfIlluminationMap被映射到 3D 形状的表面上的点。selfIlluminationMap提供了即使没有光线照射 3D 物体也会发光的颜色。bumpMap根本不包含颜色信息。它包含表面每个点的法向量信息(恰好是三个数字,可以编码为 RGB 颜色),当在颜色渲染计算期间考虑这些信息时,将导致凹凸的外观。

表面上的点到图像上的点的映射是TriangleMesh的纹理坐标的工作。在本章的前面,我们用用户定义的TriangleMesh构建了一个MeshView。实际上,预定义的 3D 形状也是基于内部的TriangleMesh es。因此,它们也能够被纹理化。回想一下,在一张TriangleMesh中,每张脸由六个索引定义——P0t0p1t1p2t2 ,其中 p0p1p2 是点阵列的索引, t0t1 无论哪种方式,用 t0t1t2 查找texCoords数组,得到( u0v0 )、( u1v1 )、( u2v2 )坐标。由这三个坐标确定的图像的三角形部分被映射到 3D 形状的面上。

我们现在将世界地图的图像作为纹理应用到一个球体和一个立方体上。图 8-10 中的图像可以在 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/earthsphere 的代码目录中找到。

img/468104_2_En_8_Fig10_HTML.jpg

图 8-10

地球墨卡托投影图像

清单 8-6 展示了 EarthSphere 程序,我们使用这个图像作为球体的diffuseMap来制作一个地球仪。

package org.modernclientjava.javafx3d;
import javafx.application.Application;
import javafx.beans.property.DoubleProperty;
import javafx.beans.property.SimpleDoubleProperty;
import javafx.scene.Group;
import javafx.scene.PerspectiveCamera;
import javafx.scene.PointLight;
import javafx.scene.Scene;
import javafx.scene.image.Image;
import javafx.scene.input.MouseEvent;
import javafx.scene.paint.Color;
import javafx.scene.paint.PhongMaterial;
import javafx.scene.shape.Sphere;
import javafx.scene.transform.Rotate;
import javafx.stage.Stage;
public class EarthSphere extends Application {
    double anchorX, anchorY;
    private double anchorAngleX = 0;
    private double anchorAngleY = 0;
    private final DoubleProperty angleX = new SimpleDoubleProperty(0);
    private final DoubleProperty angleY = new SimpleDoubleProperty(0);
    PerspectiveCamera scenePerspectiveCamera =
       new PerspectiveCamera(false);
    public static void main(String[] args) {
        launch(args);
    }
    @Override
    public void start(Stage stage) {
        stage.setTitle("EarthSphere");
        Image diffuseMap = new Image(EarthSphere.class
                .getResource("/earth-mercator.jpg")
                .toExternalForm());
        PhongMaterial earthMaterial = new PhongMaterial();
        earthMaterial.setDiffuseMap(diffuseMap);
        final Sphere earth = new Sphere(400);
        earth.setMaterial(earthMaterial);
        final Group parent = new Group(earth);
        parent.setTranslateX(450);
        parent.setTranslateY(450);
        parent.setTranslateZ(100);
        Rotate xRotate;
        Rotate yRotate;
        parent.getTransforms().setAll(
                xRotate = new Rotate(0, Rotate.X_AXIS),
                yRotate = new Rotate(0, Rotate.Y_AXIS)
        );

        xRotate.angleProperty().bind(angleX);
        yRotate.angleProperty().bind(angleY);
        final Group root = new Group();
        root.getChildren().add(parent);
        final Scene scene = new Scene(root, 900, 900, true);
        scene.setFill(Color.BLACK);
        scene.setOnMousePressed((MouseEvent event) -> {
            anchorX = event.getSceneX();
            anchorY = event.getSceneY();
            anchorAngleX = angleX.get();
            anchorAngleY = angleY.get();
        });
        scene.setOnMouseDragged((MouseEvent event) -> {
            angleY.set(anchorAngleY + anchorX - event.getSceneX());
        });
        PointLight pointLight = new PointLight(Color.WHITE);
        pointLight.setTranslateX(400);
        pointLight.setTranslateY(400);
        pointLight.setTranslateZ(-3000);
        scene.setCamera(scenePerspectiveCamera);
        root.getChildren().addAll(pointLight, scenePerspectiveCamera);
        stage.setScene(scene);
        stage.show();
    }
}

Listing 8-6EarthSphere source code

请注意,该图像是墨卡托投影中的世界地图,这是由球体节点在内部创建的三角形网格所需要的。由于定义了纹理坐标,基于这些点与它们所属的三角形的纹理坐标的插值,球体表面的每个单点都被映射到图像的像素中。当程序运行时,显示 EarthSphere 窗口,如图 8-11 所示。

img/468104_2_En_8_Fig11_HTML.jpg

图 8-11

地球圈层输出

我们可以将相同的图像应用于圆柱体或立方体材质的漫射贴图。

与 JavaFX 3D 场景交互

JavaFX 3D 图形 API 中的 3D 形状支持所有 JavaFX 鼠标和触摸事件。您的 JavaFX 3D 程序可以充分利用这些事件来实现交互式行为。事实上,我们已经在 EarthSphere、EarthCylinder、EarthBox 和 MeshCube 程序中实现了鼠标交互。在 EarthSphere 中,我们在场景中设置事件处理程序:

scene.setOnMousePressed((MouseEvent event) -> {
    anchorX = event.getSceneX();
    anchorY = event.getSceneY();
    anchorAngleX = angleX.get();
    anchorAngleY = angleY.get();
});
scene.setOnMouseDragged((MouseEvent event) -> {
    angleY.set(anchorAngleY + anchorX - event.getSceneX());
});

在这段代码中,anchorXanchorY是该类的 double 字段,angleXangleY是 double 属性,它们绑定到父节点的旋转度数,该父节点包含围绕 x-y-轴的球体。这里,当鼠标按在屏幕上时,我们将鼠标指针的屏幕坐标捕捉为anchorXanchorY。我们还查询包含球体的父节点绕 x-y 轴的旋转角度。当鼠标被拖动时,我们通过增加新鼠标位置的anchorX和屏幕 x 坐标之间的差值来改变包含球体的父节点绕 y 轴的旋转角度。所以,如果点击屏幕,向右拖动鼠标,anchorX – event.getScreenX()是负数;因此,我们减小了围绕 y 轴的旋转角度。因为在这个程序中,y-轴指向下方,减少围绕 y 轴的旋转实际上使球体看起来向右旋转,与鼠标拖动的方向相匹配。

了解 PickResult 类

JavaFX 3D 运行时提供了关于鼠标指针与 3D 场景相互作用的增强信息。该信息是根据相关事件对象中的类PickResult的对象提供的。它包含在javafx.scene.input包中。以下事件对象提供了一个getPickResult()方法,允许您检索这个PickResult对象:

  • ContextMenuEvent

  • DragEvent

  • GestureEvent

    • RotateEvent

    • ScrollEvent

    • SwipeEvent

    • ZoomEvent

  • MouseEvent

    • MouseDragEvent
  • TouchPoint

PickResult类提供了以下公共构造器:

  • PickResult(EventTarget, double, double)

  • PickResult(Node, Point3D, double)

  • PickResult(Node, Point3D, double, int, Point2D)

  • PickResult(Node, Point3D, double, int, Point3D, Point2D)

前两个构造器用于创建处理 2D 场景的PickResult,第三个和第四个构造器创建包含 3D 信息的PickResultPickResult通常由 JavaFX 3D 运行时创建。JavaFX 应用程序代码通常通过调用事件对象上的访问器方法来获取PickResult s。

PickResult类提供了以下公共方法:

  • Node getIntersectedNode()

  • Point3D getIntersectedPoint()

  • Point3D getIntersectedNormal()

  • double getIntersectedDistance()

  • int getIntersectedFace()

  • Point2D getIntersectedTexCoord()

当用户在 3D 形状上按下鼠标时,它在特定面的特定点接触 3D 形状。从对SceneSubScene有效的摄像机眼开始,到 3D 形状上的点结束的线段称为拾取光线。3D 形状上的点称为相交点PickResult提供了关于这个交叉点的信息。getIntersectedNode()方法返回 3D 形状本身,可以是SphereCylinderBoxMeshViewgetIntersectedPoint()方法返回相交点的三维坐标。坐标相对于 3D 形状的局部坐标系。对于半径为 100 的Sphere,返回的Point3D的坐标( xyz )将满足x2+y2+z2=]getIntersectedNormal()方法返回拾取的 3D 形状的相交法线。getIntersectedDistance()方法返回从摄像机的眼睛到交叉点的距离。这是 3D 模型的世界坐标系中拾取光线的长度。getIntersectedFace()方法返回包含MeshView交点的面的面号,该面有用户定义的面。对于预定义的 3D 形状SphereCylinderBox,它返回FACE_UNDEFINEDgetIntersectedTexCoord()方法返回相交点的纹理坐标。与getIntersectedFace()方法不同,该方法将返回用户定义和预定义 3D 形状的纹理坐标。

清单 8-7 中的程序为一个球体的鼠标按下和鼠标拖动事件设置一个事件处理程序,当你在球体上按下或拖动鼠标时,根据交叉点的坐标改变球体的颜色。该代码也可以在 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/eventdemo 找到。

package org.modernclientjava.javafx3d;
import javafx.application.Application;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.event.EventHandler;
import javafx.geometry.Point3D;
import javafx.scene.Group;
import javafx.scene.Scene;
import javafx.scene.input.PickResult;
import javafx.scene.paint.Color;
import javafx.scene.paint.Material;
import javafx.scene.paint.PhongMaterial;
import javafx.scene.shape.Sphere;
import javafx.stage.Stage;
import static java.lang.Math.abs;
import static java.lang.Math.min;
public class EventDemo extends Application {
    private Model model;
    private View view;
    public EventDemo() {
        model = new Model();
    }
    @Override
    public void start(Stage primaryStage) throws Exception {
        view = new View(model);
        primaryStage.setTitle("Sphere with MouseEvents");
        primaryStage.setScene(view.scene);
        primaryStage.show();
    }
    public static void main(String[] args) {
        launch(args);
    }
    private static class Model {
        private ObjectProperty<Material> material =
            new SimpleObjectProperty<>(
                this, "material", new PhongMaterial());
        public Material getMaterial() {
            return material.get();
        }
        public ObjectProperty<Material> materialProperty() {
            return material;
        }
        public void setMaterial(Material material) {
            this.material.set(material);
        }
    }

    private static class View {
        public static final int SPHERE_RADIUS = 200;
        public Scene scene;
        public Sphere sphere;
        private View(Model model) {
            sphere = new Sphere(SPHERE_RADIUS);
            sphere.materialProperty().bind(model.materialProperty());
            EventHandler<javafx.scene.input.MouseEvent> handler = event ->
                {
                    PickResult pickResult = event.getPickResult();
                    Point3D point = pickResult.getIntersectedPoint();
                    model.setMaterial(
                    new PhongMaterial(makeColorOutOfPoint3D(point)));
                };
            sphere.setOnMouseClicked(handler);
            sphere.setOnMouseDragged(handler);
            Group group = new Group(sphere);
            group.setTranslateX(320);
            group.setTranslateY(240);
            scene = new Scene(group, 640, 480);
        }
        private Color makeColorOutOfPoint3D(Point3D point) {
            double x = point.getX();
            double y = point.getY();
            double z = point.getZ();
            return Color.color(normalize(x), normalize(y), normalize(z));
        }
        private double normalize(double x) {
            return min(abs(x) / SPHERE_RADIUS, 1);
        }
    }
}

Listing 8-7EventDemo source code

当程序运行时,一个球体将被渲染,如图 8-12 所示。当您按下或拖动鼠标时,将调用 handler 方法。该方法将检测目标 Point3D,并使用 makeColorOutOfPoint3D 方法来改变球体的颜色。

img/468104_2_En_8_Fig12_HTML.jpg

图 8-12

事件演示的输出

第三方软件:FXyz 3D

FXyz 3D 是一个开源的 JavaFX 3D 可视化和组件库,可以在 https://github.com/FXyz/FXyz 找到。它最初的目的是增强 JavaFX 内置的 3D 特性,提供额外的原语、复合对象、控件和数据可视化。这些年来它一直在增长。美国宇航局使用的深空轨迹探测器(DSTE)工具是 FXyz 众所周知的用例之一。

该库包含四个子项目:

  • FXyz-Core 包含许多图元,如棱柱、棱锥、四面体、分段球体、圆环、弹簧或结,以及许多实用网格,如 Polyline3D、SurfacePlot、ScatterPlot、Text3D、SVG3D 或 Bezier。所有这些网格都可以用颜色、图像、图案和密度图进行纹理处理。有可视化组件,如立方体世界或天空盒和其他许多有用的工具。

  • FXyz-Client 是 ControlsFX fxsampler 的扩展版本,用于特定的 3D 可视化选项。

  • FXyz 导入器允许从已知格式(如 OBJ 或 Maya)导入复杂的 3D 模型。

  • FXyz-Samples 提供了许多可以用采样器运行的样本。

核心、客户端和导入器组件可从 Maven Central 获得,并可包含在 Maven 项目中,如下所示:

        <dependency>
            <groupId>org.fxyz3d</groupId>
            <artifactId>fxyz3d</artifactId>
            <version>0.5.4</version>
        </dependency>
or gradle project as follows:
repositories {
    mavenCentral()
}
dependencies {
    implementation 'org.fxyz3d:fxyz3d:0.5.4'
}

FXyz 3D 示例

一旦包含了核心依赖项,在 JavaFX 项目中使用任何 FXyz 原语都很简单。清单 8-8 创建一个 SpringMesh,它带有一个基于弧位置的密度贴图来生成纹理。

该代码可在 https://github.com/modernclientjava/mcj-samples/tree/master/ch08-3Dgraphics/fxyzdemo 获得。

package org.modernclientjava.javafx3d;
import javafx.application.Application;
import javafx.scene.paint.Color;
import javafx.scene.shape.CullFace;
import javafx.scene.transform.Rotate;
import javafx.scene.Group;
import javafx.scene.PerspectiveCamera;
import javafx.scene.Scene;
import javafx.scene.SceneAntialiasing;
import javafx.stage.Stage;
import org.fxyz3d.shapes.primitives.SpringMesh;
public class FxyzDemo extends Application {
   @Override
    public void start(Stage primaryStage) {
        PerspectiveCamera camera = new PerspectiveCamera(true);
        camera.setNearClip(0.1);
        camera.setFarClip(10000.0);
        camera.setTranslateX(-50);
        camera.setTranslateZ(-30);
        camera.setRotationAxis(Rotate.Y_AXIS);
        camera.setRotate(45);
        SpringMesh spring = new SpringMesh(10, 2, 2, 8 * 2 * Math.PI, 200, 100, 0, 0);
        spring.setCullFace(CullFace.NONE);
        spring.setTextureModeVertices3D(1530, p -> p.f);
        Scene scene = new Scene(new Group(spring), 600, 400, true, SceneAntialiasing.BALANCED);
        scene.setFill(Color.BISQUE);
        scene.setCamera(camera);
        primaryStage.setScene(scene);
        primaryStage.setTitle("FXyz3D Sample");
        primaryStage.show();
    }
}

Listing 8-8FXyzDemo source code

运行此示例显示了图 8-13 中的输出。

img/468104_2_En_8_Fig13_HTML.jpg

图 8-13

FXyzDemo 的输出

结论

JavaFX 核心 API 已经为创建高级 3D 场景提供了基础。这些 API 允许大量的灵活性和配置,它们使用与其他 3D 引擎相似的概念。

本着 JavaFX 的精神,这些 API 主要为构建在这些 API 之上的其他框架提供基础,并提供特定于领域的功能,例如 FXyz 框架。

还应该注意的是,作为 JavaFX(从 JavaFX 13 开始)中的一个新功能,可以使用共享缓冲区将来自其他(本地)应用程序的内容与 JavaFX 混合。举个这样的例子,你可以看看 https://github.com/miho/NativeFX

九、JavaFX、Web 和云基础设施

由约翰·沃斯、布鲁诺·博尔赫斯和何塞·佩雷达创作

正如这个术语本身所暗示的,客户端应用程序很少是自包含的。为了正常运行,它们需要访问其他组件,例如服务器应用程序。JavaFX 作为一个 Java 框架,允许应用程序开发人员利用广泛的 Java 生态系统。为与 REST APIs、web 组件、SOAP 端点和加密 API 等交互而创建的库可以在 JavaFX 应用程序中使用。因此,JavaFX 应用程序与非 JavaFX 应用程序没有什么特别之处。然而,JavaFX 中有许多特性使开发人员能够创建到其他(后端或服务器端)组件的简单、安全、可靠的连接。

在本章中,我们将讨论将后端组件与 JavaFX 应用程序集成的两种方法。首先,我们将讨论 WebView 组件。JavaFX WebView 控件的核心具有 web 浏览器的大部分功能,它允许网页上的脚本元素和 JavaFX 应用程序中的 Java 函数之间的双向交互。它通常被用作一种简单的方法,将网站的现有功能呈现到桌面应用程序中。

这种方法如图 9-1 所示。

img/468104_2_En_9_Fig1_HTML.png

图 9-1

使用 WebView 集成后端功能

接下来,讨论一种更灵活、更通用的方法,用于将 JavaFX 控件连接到云中或后端基础设施中的远程端点。在这种方法中,JavaFX 应用程序直接与后端 API 通信,如图 9-2 所示。

img/468104_2_En_9_Fig2_HTML.png

图 9-2

JavaFX 应用程序直接与后端 API 通信

与网络整合

使用 WebView 组件,您可以在 JavaFX 应用程序中显示 HTML5 内容。这适用于本地 HTML 文件或网页。该组件基于 WebKit,功能非常强大。可能的应用范围从文档的显示到完整 web 应用程序的集成。非常实用的是,通过 JavaScript 桥很容易与 WebView 组件的内容进行交互。这允许我们从 JavaFX 修改页面并对用户操作做出反应。

显示网页

对于简单的用例,WebView API 本身非常容易使用。WebView 和相关类是 javafx.web 模块的一部分。因此,如果我们想要使用它,我们应该确保将 javafx.web 模块添加到我们的模块路径中。使用命令行方法,这是通过添加

--add-modules javafx.web

将 Maven 与 javafx-maven-plugin 一起使用时,我们可以很容易地添加一个依赖项,如下所示:

<dependency>
    <groupId>org.openjfx</groupId>
    <artifactId>javafx-web</artifactId>
    <version>17.0.1</version>
</dependency>

这也在样本存储库中的webviewdemo样本的pom.xml文件中进行了说明。

我们可以马上从一个代码示例开始。核心组件是WebView节点。我们可以像其他节点一样将它添加到场景图中。我们请求它的WebEngine,并传递一个 URL 给它,如下所示:

public class WebViewDemo extends Application {
    @Override
    public void start(Stage primaryStage) {
        WebView webView = new WebView();
        WebEngine engine = webView.getEngine();
        engine.load("https://openjfx.io");
        Scene scene = new Scene(webView, 300, 250);
        primaryStage.setTitle("JavaFX WebView Demo");
        primaryStage.setScene(scene);
        primaryStage.show();
    }
}

img/468104_2_En_9_Fig3_HTML.jpg

图 9-3

在 JavaFX WebView 中加载网站

三行突出显示的代码足以加载如图 9-3 所示的网页。通过右键单击,您可以打开页面上的上下文菜单,并根据当前状态,停止或重新启动页面加载。默认情况下,此菜单可用。对于最常见的用例,您不需要它。让我们用下面的命令禁用它:

webView.setContextMenuEnabled(false);

添加导航和历史记录

要使用 WebEngine 的 load 命令在网站之间导航,可以向 JavaFX 应用程序添加按钮或菜单,以便在单击时显示特定页面。为了响应点击,只需调用 engine.load(" http://myurl.com ")来指示 web 引擎为您加载页面。让我们在我们的例子中用一个菜单栏来试试这个,如图 9-4 所示。为此,我们首先将 WebView 放入一个 BorderPane 中,这样我们可以很容易地在它上面插入一个 MenuBar。像这样更改开始方法:

img/468104_2_En_9_Fig4_HTML.jpg

图 9-4

为导航添加菜单栏

public void start(Stage primaryStage) {
        WebView webView = new WebView();
        webView.setContextMenuEnabled(false);
        WebEngine engine = webView.getEngine();
        engine.load("https://openjfx.io ");
        BorderPane borderPane= new BorderPane(webView);
        MenuBar menuBar = new MenuBar();
        final Menu navigateMenu = new Menu("Navigate");
        MenuItem home = new MenuItem("Home");
        navigateMenu.getItems().addAll(home);
        home.setOnAction(e -> engine.load("https://github.com/openjdk/jfx"));
        menuBar.getMenus().add(navigateMenu);
        borderPane.setTop(menuBar);
        Scene scene = new Scene(borderPane, 640, 400);
        primaryStage.setTitle("JavaFX WebView Demo");
        primaryStage.setScene(scene);
        primaryStage.show();
    }

网络引擎也让我们可以使用导航历史。web 引擎的 getHistory 方法返回具有 getEntries 方法的 History 对象。这是一个可观察的列表,我们可以用它来跟踪变化。让我们用它来填充一个菜单:

Menu historyMenu = new Menu("History");
        engine.getHistory().getEntries().addListener((ListChangeListener.Change<? extends Entry> c) -> {
            c.next();
            for (Entry e: c.getAddedSubList()) {
                for(MenuItem i: historyMenu.getItems()){
                    if (i.getId().equals(e.getUrl())){
                        historyMenu.getItems().remove(i);
                    }
                }
            }
            for (Entry e: c.getAddedSubList()) {
                final MenuItem menuItem = new MenuItem(e.getUrl());
                menuItem.setId(e.getUrl());
                menuItem.setOnAction(a->engine.load(e.getUrl()));
                historyMenu.getItems().add(menuItem);
            }
        });
        menuBar.getMenus().addAll(navigateMenu, historyMenu);

img/468104_2_En_9_Fig5_HTML.jpg

图 9-5

访问浏览历史

结果如图 9-5 所示。对于构建一个好的基本浏览器来说,这并不需要太多的努力。现在是时候改善用户体验了。目前,我们没有得到加载过程的指示。

显示加载进度

进度条在这里会很有帮助。我们可以在加载新内容时将其显示在页面底部。幸运的是,WebEngine正在使用 javafx 并发 API,因此我们可以使用Property来轻松跟踪进度。因此,让我们在 BorderPane 中添加一个 ProgressBar 来显示进度:

        ProgressBar progressBar = new ProgressBar();
        progressBar.progressProperty().bind(
                engine.getLoadWorker().progressProperty());
     progressBar.visibleProperty().bind(engine.getLoadWorker().
stateProperty().isEqualTo(State.RUNNING));
        borderPane.setBottom(progressBar);

我们还将 ProgressBar 的 visibleProperty 绑定到 Worker 的 stateProperty。一个非常小的代码示例完美地说明了属性和绑定与 JavaFX APIs 的其余部分是如何集成的。不需要监听器,这确实有助于提高代码的可读性。带有工作进度条的示例如图 9-6 所示。

img/468104_2_En_9_Fig6_HTML.jpg

图 9-6

加载页面时显示进度

从网站到 API

在本节中,我们将展示如何在 JavaFX 应用程序中轻松呈现现有的网站。通过将网站上的 JavaScript 功能与 JavaFX 应用程序中的 Java 功能相链接,可以增强网站功能并使其更具交互性,或者将其与网络浏览器不可用的功能相集成(例如,与设备的连接)。

虽然这通常是基于现有网站创建桌面应用程序的快速而简单的解决方案,但在大多数情况下,它不提供后端提供的丰富功能,也没有利用 JavaFX APIs 提供的丰富功能。

在本章的第二部分,我们将讨论如何访问后端功能,并以更细粒度的方式将其与 JavaFX 控件集成。

面向云的构建

大约在 2012 年,云最终成为主流,但只有极少数玩家仍然主导着市场。然而,竞争是巨大的,每个季度都会发布服务和 API。为云构建应用程序通常意味着利用这些高度可用和可扩展的基础设施即服务,以及高效、易用和易于集成的平台即服务。无论是为您快速调配的虚拟机或数据库,还是只需要订阅密钥的人脸检测 API,所有这些资源都有一个共同点:管理、维护和扩展它们的不是您,而是您的云提供商。

云通常还意味着构建基于 web 的应用程序或基于 web 的 REST APIs,它们将在云中运行,并与同样位于云中的资源进行对话。如何构建和部署这些应用和服务通常取决于微服务和云原生架构剧本。云资源的可伸缩性模式非常适合这些 web 应用程序和微服务。当一切都在互联网上,并且通过浏览器、一些基于 HTTP 的自动化客户端或消息系统访问应用程序时,桌面应用程序常见的许多挑战(如版本更新、数据缓存、服务重新路由等)根本不存在,或者更容易在云中解决。

那么当面向用户的应用程序是富客户端时会发生什么呢?开发人员如何利用云,为什么?这里的重点是通过不必在客户端本身开发某些算法和业务逻辑,而是将它们转移到开发人员可以更好地控制和更快地交付更新的地方,或者使用现成的服务 API(否则将花费大量开发时间在富客户端中构建),来为他们的现代客户端桌面应用程序提供价值。

与客户端简单地显示带有网站的 WebView 相比,这种方法要强大得多。部分功能可以在客户端实现,而其他部分可以卸载给云提供商。

JavaFX 云应用程序的架构

大多数现代企业系统都有多层架构,其中基于 web 的前端和业务逻辑是分离的。典型的 web 框架查询业务层,要么使用直接(Java)调用,要么使用 REST APIs。如下图所示。

img/468104_2_En_9_Figa_HTML.jpg

业务逻辑不是在 web 应用程序内部实现的,而是通常在使用某种特定企业架构的后端组件中实现的,这提供了额外的功能,如安全性、可伸缩性、监控等。相同的概念可以应用于 JavaFX 客户端应用程序的情况,如下所示。

img/468104_2_En_9_Figb_HTML.jpg

在这种情况下,JavaFX 应用程序调用的 API 层与典型的 web 应用程序调用的 API 层相同。这种方法的优点是只需要一个 API 层来服务不同类型的客户端。

通常,API 层会公开一个 REST 接口。许多 web 框架提供了对 REST 接口的访问,也有大量的 Java 库允许开发人员访问 REST 接口,并将 REST 接口的输出转换为 Java 对象。

作为一个例子,我们将使用 OpenWeather API,这是一个通过 REST 接口访问的 API 层。许多基于 web 的应用程序都使用这个 API,我们将展示如何在 Java 客户端应用程序中利用这个 API 的三种方法。

用例:查询 OpenWeather

我们现在将展示一个 JavaFX 应用程序,它查询 OpenWeather API ( https://openweathermap.org )来检索给定位置的天气。我们可以简单地使用 WebView 并呈现现有的 OpenWeather 网站,但是我们希望利用 JavaFX 控件的强大功能,或者我们希望将天气数据无缝集成到现有的 JavaFX UI 应用程序中。

我们现在将解释如何编写一个从 OpenWeather 中检索查询的应用程序。为了做到这一点,第一件事就是在门户( https://home.openweathermap.org/users/sign_up )注册并获得默认的 API 密匙或者生成一个新的( https://home.openweathermap.org/api_keys )。

在下面的代码片段中,您会注意到 API 键有时是必需的。OpenWeather API 允许单个帐户每小时进行有限次数的呼叫。因此,最好创建并使用自己的 API 密钥,而不是使用共享的 API 密钥。这就是为什么我们通常用例如“XXXXXXXXXXX”来替换真正的密钥。确保用您从 OpenWeather 门户获得的真实 API 密钥替换这个“XXXXXXXXXXX”。

首先,让我们创建一个简单的应用程序来显示给定时间和城市的天气:

public class WeatherApp extends Application {
    private static final String API_KEY = "XXXXXXXXXXX";
    private static final String CITY = "London";
    private ImageView imageView;
    private Label weatherLabel;
    private Label descriptionLabel;
    private Label tempLabel;
    @Override
    public void start(Stage stage) {
        imageView = new ImageView();
        imageView.setFitHeight(100);
        imageView.setPreserveRatio(true);
        imageView.setEffect(new DropShadow());
        Label label = new Label("The weather in " + CITY);
        weatherLabel = new Label();
        descriptionLabel = new Label();
        descriptionLabel.getStyleClass().add("desc");
        tempLabel = new Label();
        tempLabel.getStyleClass().add("temp");
        VBox root = new VBox(10,
            label, imageView, weatherLabel, descriptionLabel, tempLabel);
        root.setAlignment(Pos.CENTER);
        Scene scene = new Scene(root, 600, 400);
        scene.getStylesheets().add(
            WeatherApp.class.getResource("/styles.css").toExternalForm());
        stage.setScene(scene);
        stage.setTitle("The Weather App");
        stage.show();
        retrieveWeather();
    }
    private void retrieveWeather() {
        // TODO
    }
}

这段代码利用了我们在本书前面章节中讨论过的布局和控件。唯一的新部分是“retrieveWeather()”调用that目前还没有实现。在本章的剩余部分,我们将通过多种方式实现这一点。

除了代码之外,在应用程序中应用一些样式信息通常也是很好的。在此示例中,我们使用以下 CSS 内容:

.label {
    -fx-font-size: 1.4em;
    -fx-text-fill: blue;
}
.label.desc {
    -fx-font-size: 1.2em;
    -fx-text-fill: gray;
}
.label.temp {
    -fx-font-size: 1.1em;
    -fx-text-fill: green;
}

获取给定系统的天气数据所需的查询由 OpenWeather API 定义,并在 https://openweathermap.org/current 中描述。

注意,API 定义可能会改变,所以如果 OpenWeather 改变了它的 API,当前的方法可能需要做一些小的修改!

要使用我们的 API 键查询给定城市的 OpenWeather,基本上我们只需要创建以下查询:

"https://api.openweathermap.org/data/2.5/weather?appid="
    + API_KEY + "&q=" + CITY

当这样的查询被发送到 OpenWeather API 时,我们将得到 JSON 格式的响应,如下所示:

{"coord":{"lon":-0.13,"lat":51.51},
 "weather":[{"id":500,"main":"Rain",
 "description":"light rain","icon":"10d"}],"base":"stations",
 "main":{"temp":290.14,"pressure":1012,"humidity":68,
 "temp_min":288.15,"temp_max":292.59},"visibility":10000,
 "wind":{"speed":4.1,"deg":180},"clouds":{"all":40},
 "dt":1563527401,"sys":{"type":1,"id":1414,"message":0.0137,
 "country":"GB","sunrise":1563509115,"sunset":1563566876},
 "timezone":3600,"id":2643743,"name":"London","cod":200}

有多种方法可以处理这个响应。在 Java 生态系统中,存在许多处理 JSON 输入并将其绑定到 Java 对象的库。为了促进 JSON 数据和 Java 对象之间的映射,我们将使用模型实体将 JSON 字符串反序列化为如下内容:

public class Model {
    private long id;
    private long dt;
    private Clouds clouds;
    private Coord coord;
    private Wind wind;
    private String cod;
    private String visibility;
    private long timezone;
    private Sys sys;
    private String name;
    private String base;
    private List<Weather> weather = new ArrayList<>();
    private Main main;
    // Getters & setters
}
public class Clouds {
    private String all;
    // Getters & setters
}
public class Coord {
    private float lon;
    private float lat;
    // Getters & setters
}
public class Main {
    private float humidity;
    private float pressure;
    private float temp_max;
    private float temp_min;
    private float temp;
    private float feels_like;
    // Getters & setters
}
public class Sys {
    private String message;
    private String id;
    private long sunset;
    private long sunrise;
    private String type;
    private String country;
    // Getters & setters
}
public class Weather {
    private int id;
    private String icon;
    private String description;
    private String main;
    // Getters & setters
}
public class Wind {
    private float speed;
    private float deg;
    private float gust;
    // Getters & setters
}

如您所见,所描述的模型映射到 JSON 服务提供的输出。一些框架要求对象字段的名称与 JSON 字段的名称完全匹配。其他的允许注释来提供匹配。在某些情况下,字段是必需的,而在其他情况下,getters 和 setters 是必需的。

案例一:杰克逊

将 JSON 反序列化为 Java 对象的一个非常流行和灵活的框架是 Jackson 项目,它提供了一个 JSON Java 解析器( https://github.com/FasterXML/jackson )。

我们可以简单地将依赖项添加到我们的项目中:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.12.3</version>
</dependency>
 (for Maven)

or

dependencies {
    implementation "com.fasterxml.jackson.core:jackson-databind:2.12.3"
}
(for Gradle)

现在我们可以完成我们的retrieveWeather通话了:

private void retrieveWeather() {
    try {
            String restUrl =
                "https://api.openweathermap.org/data/2.5/weather?appid="
                + API_KEY + "&q=" + CITY;
            ObjectMapper objectMapper = new ObjectMapper();
            Model model = objectMapper.readValue(
                 new URL(restUrl), Model.class);
            updateModel(model);
        } catch (Throwable e) {
            System.out.println("Error: " + e);
            e.printStackTrace();
        }
    }
    private void updateModel(Model model) throws MalformedURLException, URISyntaxException {
        if (model != null) {
            if (!model.getWeather().isEmpty()) {
                Weather w = model.getWeather().get(0);
                imageView.setImage(new Image(new URL("http://openweathermap.org/img/wn/" + w.getIcon() + "@2x.png").toURI().toString()));
                weatherLabel.setText(w.getMain());
                descriptionLabel.setText(w.getDescription());
            }
            tempLabel.setText(String.format("%.2f °C - %.1f%%",             model.getMain().getTemp() - 273.15, model.getMain().getHumidity()));
        }
    }

如果您已经在使用 Jackson API,这段代码看起来会非常熟悉。从服务器端应用程序和从 JavaFX 应用程序使用 Jackson API 实际上没有区别。

我们不会详细讨论杰克逊,但你可以从代码中了解正在发生的事情。

首先,构建包含所需 API 请求的 URL:

String restUrl =
                "https://api.openweathermap.org/data/2.5/weather?appid="
                + API_KEY + "&q=" + CITY;

URL 要求填写 API_KEY 和感兴趣的城市。

接下来,我们使用 Jackson 代码来检索信息,并将结果转换成 Java 对象:

ObjectMapper objectMapper = new ObjectMapper();
            Model model = objectMapper.readValue(
                 new URL(restUrl), Model.class);

ObjectMapper API 是 Jackson 的一部分,它允许解析 REST 方法的输出,创建所提供类的新实例(在本例中为“Model”),并将该类的字段与返回的 JSON 数据中的字段进行映射。

最后,调用“updateModel”来确保检索到的信息在场景中可视化。为此,我们使用了本书前面讨论过的 JavaFX APIs。

现在我们运行如图 9-7 所示的应用程序。

img/468104_2_En_9_Fig7_HTML.jpg

图 9-7

运行的应用程序

这种方法非常容易实现,尤其是如果您熟悉 Jackson 或另一个 Java 中的 JSON 解析库。

虽然这种方法非常方便,但是有一些点是 JavaFX 特有的,应该加以考虑。

例如,解析是在同步调用中完成的,以完成查询给定 URL、等待结果、检索和解析响应的整个过程。如果出现任何问题,比如超时或网络错误,将只有一个异常,并且 UI 可能会冻结,因此我们应该将该调用包装在 JavaFX 服务中。

此外,虽然模型类非常通用,并且是面向对象开发中的良好实践,但它没有利用 JavaFX 中的“可观察”概念。因此,需要样板代码来更新 UI。

我们采用的方法可以想象如下:

img/468104_2_En_9_Figc_HTML.jpg

情况二:连接

Gluon 的 Connect 库( https://github.com/gluonhq/connect )不仅像 Jackson 一样反序列化 JSON 响应还以异步的方式进行;此外,它返回 JavaFX 可观察列表或对象,JavaFX UI 控件可以直接使用这些列表或对象。使用 Gluon Connect 可以省去将解析的对象转换成 JavaFX 可观察实例的额外步骤,因为它直接填充或操作这些实例。视觉上,这可以显示如下:

img/468104_2_En_9_Figd_HTML.jpg

使用 Gluon Connect 需要向我们的项目添加以下依赖项:

<dependency>
    <groupId>com.gluonhq</groupId>
    <artifactId>connect</artifactId>
    <version>2.0.1</version>
</dependency>
<dependency>
    <groupId>org.glassfish</groupId>
    <artifactId>jakarta.json</artifactId>
    <version>1.1.5</version>
    <scope>runtime</scope>
</dependency>
 (for Maven)

or

dependencies {
    implementation "com.gluonhq:connect:2.0.1"
    runtimeOnly 'org.glassfish:jakarta.json:1.1.5
}
(for Gradle)

And now our updateWeather call can be done as follows:
    private void retrieveWeather() {
        GluonObservableObject<Model> weather = getWeather();
        weather.setOnFailed(e -> System.out.println("Error: " + e));
        weather.setOnSucceeded(e -> updateModel(weather.get()));
    }
    private GluonObservableObject<Model> getWeather() {
        RestClient client = RestClient.create()
                .method("GET")
                .host("http://api.openweathermap.org/")
                .connectTimeout(10000)
                .readTimeout(1000)
                .path("data/2.5/weather")
                .header("accept", "application/json")
                .queryParam("appid", API_KEY)
                .queryParam("q", CITY);
        return DataProvider.retrieveObject(
             client.createObjectDataReader(Model.class));
    }

运行这个应用程序会产生和以前一样的输出,但是代码更加简洁。“retrieveWeather”方法首先创建一个“GluonObservable”对象的实例。这个对象的创建创建了一个“RestClient ”,它定义了我们需要查询的 REST 接口,然后它使用一个“DataProvider”来提供一个“GluonObservableObject ”,该对象包含来自结果 JSON 输出的解析信息。

我们可以在这个对象上设置回调方法。例如,当数据被成功检索时,我们用提供的天气信息更新模型。

我们创建的应用程序非常简单,但您会发现它比简单地在 WebView 控件中呈现网站更加灵活和动态。通过访问外部 API,JavaFX 应用程序中的代码看起来类似于访问这些外部 API 的后端代码,如图 9-8 所示。

img/468104_2_En_9_Fig8_HTML.png

图 9-8

JavaFX 应用程序看起来类似于访问外部 API 的后端代码

JavaFX 客户端或 web 客户端与 API 层建立连接,然后 API 层可能会使用更复杂的业务逻辑,与数据层对话,并以某种方式处理请求。

客户端代码驻留在云环境中和最终用户的桌面上有一个非常重要的区别。在第一种情况下,云环境在大多数情况下可以被视为可信环境。然而,在代码驻留在最终用户桌面上的情况下,这不再是真的。最终用户自己或设备上的其他恶意应用程序可能会获得 API 密钥。只要 API 密钥存储在设备上,任何能够访问设备的人都可能以某种方式使用或滥用它。如下图所示:

img/468104_2_En_9_Fige_HTML.jpg

在 OpenWeather 查询的情况下,这可能不是最致命的问题,但是一般来说,提供对关键或敏感功能的访问的 API 密钥不应该存储在最终用户系统上。

为了解决这个问题,使用中间件组件作为可信云基础设施和用户的客户端设备(桌面/移动/嵌入式)之间的桥梁是有益的。

然后,API 密钥保存在中间件组件上,该组件托管在一个安全的环境中,如下所示:

img/468104_2_En_9_Figf_HTML.jpg

如果设备现在被黑客攻击,或者客户端应用程序不知何故受到损害,黑客将无法访问 API 密钥。在最坏的情况下,黑客可以与中间层对话,假装他们是设备的原始所有者,然后用真正的 API 密钥执行调用,但他们自己不会访问这个密钥。

通过在设备和中间层之间提供最佳的安全性和认证,甚至可以避免这种情况。

这个中间件组件可以扩展并导出通用的云功能(例如,访问无服务器容器),如图 9-9 所示。

img/468104_2_En_9_Fig9_HTML.png

图 9-9

中间件组件可以被扩展以导出通用的云功能

在下一段中,我们将解释如何使用 Gluon CloudLink ( https://docs.gluonhq.com/cloudlink/ )编写 OpenWeather 应用程序,这是 Gluon 的一个中间件解决方案,为 JavaFX 应用程序提供特定的支持。

案例三:胶子云链

Gluon CloudLink 提供了比简单地代表设备存储 API 密钥更多的功能。我们不会详细讨论这个功能,但是我们将在下面的例子中使用一个方便的特性。我们不会在 JavaFX 客户机上创建 REST 请求,而是使用基于 web 的仪表板在 Gluon CloudLink 中编写请求。

在我们添加代码之前,我们需要访问 Gluon 仪表板( https://gluon.io ),使用我们的凭证访问,并转到 API 管理来添加一个远程函数。远程功能是一个可以从 Java 客户端调用的组件(我们将在下一个代码片段中展示),但它是在中间件上执行的(因此在 Gluon CloudLink 内部)。这有很多好处,包括灵活性:如果 REST API 以某种方式发生了变化(例如,添加或重命名了一个参数),我们可以在 Gluon Dashboard 控制台中重新定义远程函数,而不必更改客户端代码。特别是在您不能轻松地立即更新所有客户机实例的环境中,这可能会产生很大的不同。

在客户机仪表板中,当创建远程函数时,我们为请求添加两个查询参数:appidq。我们用 API 键设置前者的值,所以客户端不需要这样做,而后者的值将在客户端设置。正如我们之前强调的,认识到 API 密匙存储在中间件中(托管在云中)并且只在中间件和远程服务之间发送是很重要的。客户端应用程序没有访问此 API 密钥;因此,密钥被黑客获取的风险被最小化。

我们可以在测试字段中设置一对值,这样我们就可以从图 9-10 所示的仪表板中直接测试远程功能。

img/468104_2_En_9_Fig10_HTML.png

图 9-10

从胶子仪表板测试功能

一旦测试成功(我们应该看到一个 200 响应和一个 JSON 字符串),我们就可以回到我们的项目,在 pom.xml 文件中添加 CloudLink 依赖项。这是通过将胶子库添加到库列表中来实现的:

repositories {
    mavenCentral()
    maven {
       url 'https://nexus.gluonhq.com/nexus/content/repositories/releases/'
    }
}

我们还必须向 pom.xml 添加一些依赖项,如下所示:

<dependency>
     <groupId>com.gluonhq</groupId>
     <artifactId>charm-cloudlink-client</artifactId>
     <version>6.0.1</version>
 </dependency>
 <dependency>
     <groupId>org.glassfish</groupId>
     <artifactId>javax.json</artifactId>
     <version>1.1.4</version>
 </dependency>
 <dependency>
     <groupId>org.glassfish</groupId>
     <artifactId>jakarta.json</artifactId>
     <version>1.1.5</version>
 </dependency>
 <dependency>
     <groupId>com.gluonhq.attach</groupId>
     <artifactId>storage</artifactId>
     <version>4.0.11</version>
 </dependency>

Gluon CloudLink 客户端代码设计用于桌面、移动和嵌入式设备。为了做到这一点,它抽象了一些在不同平台上有不同实现的功能。开源项目 Gluon Attach 提供了这种抽象,以及针对桌面、iOS 和 Android 平台的实现。例如,数据在平台上的存储方式在这些目标之间是不同的,这被 Gluon CloudLink 客户端代码所使用。因此,我们向 com.gluonhq.attach.storage 服务添加了一个依赖项。为了使应用程序平台独立,我们还添加了 gluonfx-maven-plugin,它确保使用正确的附加服务的本机实现:

<plugin>
     <groupId>com.gluonhq</groupId>
     <artifactId>gluonfx-maven-plugin</artifactId>
     <version>1.0.2</version>
     <configuration>
         <attachList>
             <list>storage</list>
         </attachList>
         <mainClass>org.modernclientjava.WeatherApp</mainClass>
     </configuration>
 </plugin>

最后,我们将代码修改为

private void retrieveWeather() {
    GluonObservableObject<Model> weather = getWeather();
    weather.setOnFailed(e -> System.out.println("Error: " + e));
    weather.setOnSucceeded(e -> updateModel(weather.get()));
}
private GluonObservableObject<Model> getWeather() {
    RemoteFunctionObject functionObject = RemoteFunctionBuilder
            .create("weather")
            .param("q", CITY)
            .object();
    return functionObject.call(Model.class);
}

正如您所看到的,在来自客户端的远程函数调用中只有一个查询参数。

客户端中唯一需要的凭证是那些访问 CloudLink 中间件的凭证。在src/main/resources需要一个名为gluoncloudlink_config.json的文件:

{
  "gluonCredentials": {
    "applicationKey" : "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
    "applicationSecret": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
  }
}

此文件的内容可以从仪表板➤凭据➤客户端检索。

因为我们希望依赖于平台的附加实现由 gluonfx 插件来处理,所以我们使用

mvn gluonfx:run

这将检测当前平台并使用正确的实现。

结论

JavaFX 应用程序是常规的 Java 应用程序,可以使用所有现有的 Java 功能。使用 JavaFX WebView 控件,可以在 JavaFX 应用程序中呈现网页,并在网页功能(在 JavaScript 中)和客户端上运行的 Java 引擎之间创建交互。

对于客户机应用程序与云中企业功能的细粒度、安全和灵活的集成,可以使用现有的 Java 框架。开发人员应该非常清楚客户端系统的具体特征,尤其是与安全性相关的特征。因此,中间件(例如,Gluon CloudLink)允许开发人员屏蔽客户端应用程序的安全敏感信息。

十、为桌面打包应用

由 José Pereda、Johan Vos 和 Gail Anderson 撰写

创建 Java 客户端应用程序只是应用程序生命周期的第一步。您的应用程序将由与之交互的最终用户使用。无论您的应用程序是供内部使用、仅供内部网使用还是供广大公众使用,您都希望使用您的应用程序的障碍尽可能低。最终用户在开始使用应用程序之前不喜欢复杂的过程。

在这一章中,我们将解释如何构建最终用户可以按照安装其他(非 Java)应用程序的相同方法来安装和使用的应用程序。我们将探索两个重要的现代工具,用于创建满足最终用户期望的捆绑 Java 应用程序:jpackage 和 GraalVM 的本机映像。

Web 与桌面应用程序

当您创建一个 Java 客户端应用程序,或者通过扩展创建任何客户端应用程序时,最终用户将直接访问您的应用程序。这与创建云或基于 web 的应用程序非常不同,在这种情况下,您的代码可能运行在云或服务器基础架构上,最终用户通常通过 web 浏览器访问您的代码。在这种情况下,您必须将您的应用程序供应到云或服务器,有一些特定的工具可以帮助您完成这项任务。

您或您的 IT 部门控制着应用程序的运行环境。您知道周围的环境,包括操作系统、资源限制和可用的软件。这种情况如图 10-1 所示。

img/468104_2_En_10_Fig1_HTML.jpg

图 10-1

应用程序运行的环境

对于客户端应用程序,您需要确保您的应用程序可以安装在所有客户端系统上。如果幸运的话,您的应用程序将被部署在完全由您或您的 IT 组织控制的 intranet 上。但是通常情况下,您无法控制应用程序应该运行的环境。通常假设您的应用程序应该可以在所有操作系统上运行,您不知道最终用户是否安装了 Java,如果安装了,是哪个或哪些版本。与云环境中的部署相反,您不能自己在最终用户的系统上安装应用程序。您依赖这个最终用户来执行所需的指令;因此,您希望这些说明尽可能简单和熟悉。客户端部署场景如图 10-2 所示。

img/468104_2_En_10_Fig2_HTML.jpg

图 10-2

桌面部署场景

应用程序部署的演变

在过去,大多数 Java 客户端应用程序的部署都需要在应用程序代码、其依赖项和运行应用程序所需的 Java 虚拟机(JVM)之间进行明确的分离。从 1995 年的 Java Applets 开始,到后来的 Java Web Start,Java 客户端应用程序的开发人员可以假设在最终用户系统上已经安装了一些 Java 虚拟机。如果不是这样,开发人员可以假设存在某种工具来帮助最终用户安装 JVM。

因此,开发人员必须使用构建工具将程序代码、依赖项和资源捆绑到一个应用程序中,然后允许最终用户在他们已经安装的 JVM 上安装该应用程序——参见图 10-3 。

img/468104_2_En_10_Fig3_HTML.png

图 10-3

遗留开发过程

这个概念背后的基本原理很清楚:当一个客户机上运行多个 Java 应用程序时,用一个 JVM 安装为不同的应用程序提供基础是有意义的。由于 Java 非常注重向后兼容性,所以假设您的应用程序仍然可以在 JVM 的更高版本上运行也是合理的。因此,最终用户系统上的单个 JVM 就足以满足所有 Java 客户端应用程序的需求。因此,这个概念需要更少的磁盘空间(JVM 在应用程序之间共享)和更少的带宽(JVM 代码不需要随应用程序一起传输;假设它是存在的)。

近年来,许多发展使得这种方法变得不那么引人注目:

  • 典型的磁盘空间和带宽正在增加。今天的普通用户正在使用更高容量的存储容量和流容量。其中一个驱动因素是媒体流和存储的增加。与典型流服务的需求相比,JVM 代码的传输和存储相对较小。因此,JVM 代码不应该与应用程序一起发送或与应用程序一起存储,因为这需要太多的带宽或存储容量的论点是不相关的。

  • Java 开发工具包的新版本节奏给最终用户带来了更多的责任。许多用户发现在他们的 PC 上看到一个弹出窗口要求升级到 Java 的新版本是很烦人的。他们的应用程序可能使用 Java,但是这对最终用户应该是透明的。虽然不同的应用程序可能仍然运行在较旧的 JVM 上,但是开发人员通常希望将较新的 JVM 中的新特性用于他们的应用程序。这导致更多的弹出窗口让最终用户感到烦恼。

  • 应用商店(app stores)的概念改变了移动领域,也在桌面领域获得了发展。使用应用程序商店,最终用户只需决定使用统一的“点击安装”概念来安装应用程序。执行应用程序所需的一切都被认为是该应用程序的一部分。因此,最终用户不会管理没有给用户增加功能的必需组件(例如,JVM)。

Java 生态系统见证了这一演变。JavaFX 库以前包含一个“javafxpackager”工具,该工具允许开发人员为不同的平台(Linux、Windows、macOS)打包他们的 JavaFX 应用程序,包括一个 Java 虚拟机和所有依赖项。

在 Java 平台模块系统中,JVM 本身被分解成许多模块,这种情况发生了变化。现在可以创建 JVM 的子版本,只包含特定应用程序需要的模块。由于打包与所有应用程序(不仅仅是需要用户界面的客户端应用程序)相关,一个新的 Java 打包工具是从 Java 14 开始的预览版和在 Java 16 中完成的 JDK 的一部分。我们将在下一节讨论这个叫做 jpackage 的工具。

除了 jpackage 之外,还有另一个工具允许您将应用程序捆绑到一个自包含的本机应用程序中,即 GraalVM 的本机映像。使用这个工具,应用程序及其依赖项可以提前编译成本地应用程序。我们将在本章的后面讨论这个工具。

图 10-4 显示了将应用程序与其依赖项和资源以及 JVM 捆绑在一起的概念。

img/468104_2_En_10_Fig4_HTML.png

图 10-4

现代发展进程

打包工具

什么是 jpackage?

从 JDK 14 的预览版开始,到 JDK 16 的最终版本,jpackage 工具是 JDK 发行版的一部分。jpackage 的规格是 JEP 392: https://openjdk.java.net/jeps/392 。(jpackage 的原始规格是 JEP 343。)

这个 JEP 的总结很清楚:

创建一个打包自包含 Java 应用程序的新工具。

这个总结符合我们在本章前面讨论的内容:我们希望创建包含 JVM 的自包含 Java 应用程序。

根据该网站,JEP 的目标如下:

基于 JavaFX javapackager 工具创建一个打包工具,该工具

  • 支持本机打包格式,为最终用户提供自然的安装体验。这些格式包括 Windows 上的 msi 和 exe,macOS 上的 pkg 和 dmg,Linux 上的 deb 和 rpm。

  • 允许在打包时指定启动时参数。

  • 可以直接从命令行调用,也可以通过 ToolProvider API 以编程方式调用。

由此可见,jpackage 工具构建在 JavaFX 打包工具之上,JavaFX 打包工具以前是 JavaFX 8 的一部分。此外,jpackage 工具被设计为支持流行的桌面操作系统的现有的和通用的本机打包格式。因此,用 jpackage 创建的 Java 应用程序的安装方式类似于大多数其他本机应用程序的安装方式。

Java 客户端应用程序现在结合了两大优势:

1.由于一次编写,随处运行的范例,开发人员必须编写的代码实际上是独立于平台的。

2.多亏了 jpackage 工具,虽然代码本身是平台无关的,但是部署过程完全符合特定的平台。

因为 jpackage 是 JDK 发行版的一部分,所以它像普通的 java 或 javac 命令一样容易调用。jpackage 可执行文件与 java 和 javac 可执行文件位于同一目录下。

当被调用时,jpackage 基于提供的代码、依赖项和资源创建一个本地应用程序。它将 Java 运行时与本机应用程序捆绑在一起,当应用程序被执行时,打包的 Java 运行时将执行 Java 代码,类似于运行时被单独安装在系统上的情况。如图 10-5 所示。

img/468104_2_En_10_Fig5_HTML.png

图 10-5

带包部署

使用包

要开始使用 jpackage 工具,您需要使用 Java 16 或更高版本;但是,正如后面将要解释的,您可以将您的应用程序与其他版本的 Java 运行时捆绑在一起。

jpackage 工具支持三种应用程序类型:

  • 在类路径上运行的非模块化应用程序,来自一个或多个 jar 文件

  • 具有一个或多个模块化 jar 文件或 jmod 文件的模块化应用程序

  • 链接到自定义运行时映像的模块化应用程序

注意,对于前两种情况,jpackage 运行 jlink 为应用程序创建一个 Java 运行时,并将其捆绑到最终映像中。在第三种情况下,您提供一个绑定到映像中的自定义运行时。

jpackage 的输出是一个自包含的应用程序映像。该图像可以包括以下内容:

  • 本机应用程序启动器(由 jpackage 生成)

  • 应用程序资源(如 jar、icns、ico、png)

  • 配置文件(如 plist、cfg、properties)

  • 启动器的助手库

  • Java 运行时映像,包括执行 Java 字节码所需的文件

虽然 jpackage 是包含独立于平台的 java 和 javac 命令的同一个目录的一部分,但是 jpackage 根据您使用的平台略有不同。这是有意义的,因为 jpackage 的输出是特定于平台的。因此,jpackage 可以被认为是平台无关的 Java 字节码和平台相关的本机可执行文件的中间部分。

因为 jpackage 能够为当前平台构建可执行文件,所以您必须在您想要支持的所有平台上运行 jpackage。不支持将一个平台的 Java 应用程序交叉编译为其他平台的本机可执行文件。

套装程式使用状况

jpackage 的用法如下:

jpackage <options>

软件包选项

表格 10-1 到 10-7 显示了 jpackage 可以使用的不同选项。

表 10-7

jpackage–用于创建应用程序包的平台相关选项

| -win-目录选择器 | 添加一个对话框,使用户能够选择产品的安装目录。此选项仅在 Windows 上运行时可用。 | | -赢菜单 | 将应用程序添加到系统菜单。此选项仅在 Windows 上运行时可用。 | | 赢菜单组 | 放置此应用程序的开始菜单组。此选项仅在 Windows 上运行时可用。 | | -每用户安装成功率 | 请求以每个用户为基础执行安装。此选项仅在 Windows 上运行时可用。 | | -赢-捷径 | 为应用程序创建桌面快捷方式。此选项仅在 Windows 上运行时可用。 | | - win-upgrade-uuid | 与此包的升级相关的 UUID。此选项仅在 Windows 上运行时可用。 | | - linux-package-name | Linux 包的名称。默认为应用程序名称。只有在 Linux 上运行时,此选项才可用。 | | - linux-deb-maintainer | 的维护者。deb 包。只有在 Linux 上运行时,此选项才可用。 | | - linux-menu-group | 此应用程序所在的菜单组。只有在 Linux 上运行时,此选项才可用。 | | - linux-package-deps | 应用程序所需的包或功能。只有在 Linux 上运行时,此选项才可用。 | | -Linux-rpm-许可证类型 | RPM 的许可类型(“许可:”。规格)。只有在 Linux 上运行时,此选项才可用。 | | - linux-app-release | 释放转速值。DEB 控制文件的规范文件或 Debian 版本值。只有在 Linux 上运行时,此选项才可用。 | | - linux-app-category | RPM 的组值。DEB 控制文件的规范文件或部分值。只有在 Linux 上运行时,此选项才可用。 | | -Linux-快捷键 | 为应用程序创建快捷方式。只有在 Linux 上运行时,此选项才可用。 |

表 10-6

jpackage–用于创建应用程序包的选项

| - app-image | 用于构建可安装包的预定义应用程序映像的位置(绝对路径或相对于当前目录)。请参见创建-应用程序-图像模式选项来创建应用程序图像。 | | -文件关联 | 包含键值对列表的属性文件的路径(绝对路径或相对于当前目录的路径)。关键字“扩展”、“mime 类型”、“图标”和“描述”可用于描述关联。该选项可以多次使用。 | | -安装目录 | macOS 或 Linux 上应用程序安装目录的绝对路径。应用程序安装位置的相对子路径,例如 Windows 上的“Program Files”或“AppData”。 | | -许可文件 | 许可证文件的路径(绝对路径或相对于当前目录的路径)。 | | -资源目录 | 覆盖 jpackage 资源的路径(绝对路径或相对于当前目录的路径)。图标、模板文件和 jpackage 的其他资源可以通过向该目录添加替换资源来覆盖。 | | -运行时图像 | 要安装的预定义运行时映像的路径(绝对路径或相对于当前目录的路径)。创建运行时安装程序时需要选项。 |

表 10-5

jpackage–用于创建应用程序启动器的平台相关选项

| -win-控制台 | 为应用程序创建控制台启动器。应为需要控制台交互的应用程序指定。此选项仅在 Windows 上运行时可用。 | | - mac 软件包标识符 | 唯一标识 macOS 应用程序的标识符。默认为主类名。只能使用字母数字(A–Z、A–Z、0–9)、连字符(-)和句点(。)字符。此选项仅在 macOS 上运行时可用。 | | - mac-package-name | 出现在菜单栏中的应用程序的名称。这可以不同于应用程序名称。此名称必须少于 16 个字符,并且适合在菜单栏和应用程序信息窗口中显示。默认为应用程序名称。此选项仅在 macOS 上运行时可用。 | | - mac 包签名前缀 | 在对应用程序包进行签名时,该值会作为所有需要签名的组件的前缀,这些组件没有现有的包标识符。此选项仅在 macOS 上运行时可用。 | | - mac 符号 | 请求对包进行签名。此选项仅在 macOS 上运行时可用。 | | - mac 签名钥匙串 | 要使用的钥匙串的路径(绝对路径或相对于当前目录的路径)。如果没有指定,则使用标准的钥匙串。此选项仅在 macOS 上运行时可用。 | | - mac 签名密钥用户名 | Apple 签名身份名称中的团队名称部分。比如“开发者 ID 应用:”。此选项仅在 macOS 上运行时可用。 |

表 10-4

jpackage–用于创建应用程序启动器的选项

| -添加-启动器 = | 启动程序的名称和包含键值对列表的属性文件的路径(绝对路径或相对于当前目录的路径)。可以使用键" module "、" add-modules "、" main-jar "、" main-class "、" arguments "、" java-options "、" app-version "、" icon "、" win-console "。这些选项被添加到或用于覆盖原始命令行选项,以构建一个额外的替代启动器。主应用程序启动器将通过命令行选项构建。可以使用此选项构建其他备选启动器,并且可以多次使用此选项来构建多个其他启动器。 | | -参数

| 如果没有给启动程序提供命令行参数,则传递给主类的命令行参数。该选项可以多次使用。 |
| - java 选项 | 传递给 Java 运行时的选项。该选项可以多次使用。 |
| -主级 | 要执行的应用程序主类的限定名。只有在指定了- main-jar 的情况下,才能使用此选项。 |
| -主罐

| 应用程序的主 JAR,包含主类(指定为相对于输入路径的路径)。可以指定- module 或- main-jar 选项,但不能同时指定两者。 |
| -模块或-m [/

]

| 应用程序的主模块(以及可选的主类)。该模块必须位于模块路径上。指定此选项时,主模块将在 Java 运行时映像中链接。可以指定- module 或- main-jar 选项,但不能同时指定两者。 |

表 10-3

包–用于创建应用程序映像的选项

| -图标 | 应用程序包图标的路径(绝对路径或相对于当前目录的路径)。 | | -输入或-i | 包含要打包的文件的输入目录的路径(绝对路径或相对于当前目录)。输入目录中的所有文件都将打包到应用程序映像中。 |

表 10-2

包–用于创建运行时映像的选项

| -添加模块 [,...] | 要添加的以逗号(",")分隔的模块列表。这个模块列表连同主模块(如果指定的话)将作为- add-module 参数传递给 jlink。如果未指定,则只使用主模块(如果指定了- module)或默认的模块集(如果指定了- main-jar)。该选项可以多次使用。 | | -模块路径或-p ... | 每个路径要么是模块的目录,要么是模块化 jar 的路径,并且是当前目录的绝对路径或相对路径。对于多个路径,在 Linux 和 macOS 上用冒号(:)或分号(;)或使用- module-path 选项的多个实例。该选项可以多次使用。 | | - jlink 选项 | 要传递给 jlink 的以空格分隔的选项列表。如果未指定,则默认为“—strip-native-commands–strip-debug–no-man-pages–no-header-files”。该选项可以多次使用。 | | -运行时图像 | 将复制到应用程序映像中的预定义运行时映像的路径(绝对路径或相对于当前目录的路径)。如果没有指定- runtime-image,jpackage 将使用选项- strip-debug、- no-header-files、- no-man-pages 和- strip-native-commands 运行 jlink 来创建运行时映像。 |

表 10-1

jpackage–通用选项

| @ | 从文件中读取选项和/或模式。该选项可以多次使用。 | | -键入或-t | 要创建的包的类型。有效值为{"app-image "、" exe "、" msi "、" rpm "、" deb "、" pkg "、" dmg"}。如果未指定此选项,将创建依赖于平台的默认类型。 | | -应用程序版本 | 应用程序和/或包的版本。 | | -版权 | 应用程序的版权。 | | -描述 | 应用程序的描述。 | | -救命或者-h | 将带有当前平台的每个有效选项的列表和描述的用法文本打印到输出流并退出。 | | -名称或-n | 应用程序和/或包的名称。 | | - dest 或-d | 放置生成的输出文件的路径(绝对路径或相对于当前目录的路径)。默认为当前工作目录。 | | -温度 | 用于创建临时文件的新目录或空目录的路径(绝对路径或相对于当前目录的路径)。如果指定,临时目录将不会在任务完成时删除,必须手动删除。如果未指定,将在任务完成时创建并删除一个临时目录。 | | -供应商 | 应用程序的供应商。 | | -冗长 | 启用详细输出。 | | -版本 | 将产品版本打印到输出流并退出。 |

要求

jpackage 创建的映像与开发人员为本地平台创建的其他应用程序没有什么不同。因此,jpackage 也使用用于为特定操作系统生成本机应用程序的相同工具。

对于 Windows,为了生成原生包,开发人员需要安装:

  • WiX 工具集,一个生成 exe 和 msi 安装程序的免费第三方工具
WiX 设置

https://wixtoolset.org/releases/ 下载 WiX 工具集。当前版本是 3.11.2。下载完成后,使用安装程序进行处理,完成后,从命令行将其添加到 path 中

setx /M PATH "%PATH%;C:\Program Files (x86)\WiX Toolset v3.11\bin"

样品

我们现在将展示几个示例,向您展示如何使用 jpackage。这些示例本身是非常简单的 JavaFX 应用程序,我们不会在本章中讨论它们的功能。

让我们从没有构建工具或插件的终端窗口开始使用 jpackage。因为根据运行的平台不同,使用 jpackage 的方式也会略有不同,所以我们区分了在 Windows、macOS 和 Linux 上使用 jpackage 的方式。

非模块化应用:示例 1

作为第一个例子,我们解释如何打包一个本身不是模块的 Java 应用程序。应用程序仍然使用 JavaFX 模块,但是应用程序本身没有特定的 module-info.java。

我们描述了如何将这个应用程序打包到 Windows、Mac 和 Linux 的安装程序中。我们遵循的模式对于每个平台都是相似的:

  1. 定义一些环境变量。

  2. 将 JavaFX 应用程序编译成 Java 字节码。

  3. 运行并测试应用程序。

  4. 创建一个包含应用程序的 jar 文件。

  5. 使用 jpackage 创建一个安装程序。

Windows 说明

以下是为非模块化应用程序创建安装程序的必要步骤,如下所示:

https://github.com/modernclientjava/mcj-samples/tree/master/ch10-packaging/Sample1

克隆示例,并从终端将 cd 复制到应用程序的根目录中。

这前四个步骤与常规的 Java 编译和运行没有什么不同。

  1. 导出这些环境变量:
set JAVA_HOME="C:\Users\<user>\Downloads\jdk-17"
set PATH_TO_FX="C:\Users\<user>\Downloads\javafx-sdk-17\lib"
set PATH_TO_FX_MODS="C:\Users\<user>\Downloads\javafx-jmods-17"

请注意,如果您将不同的 JDK 添加到 PATH 环境变量中,这将具有优先权。

  1. 编译您的应用程序并将 fxml 和 css 资源文件复制到路径out\org\modernclients:

  2. 运行和测试:

dir /s /b src\*.java > sources.txt & javac --module-path %PATH_TO_FX% --add-modules javafx.controls,javafx.fxml -d out @sources.txt & del sources.txt

copy src\org\modernclients\scene.fxml out\org\modernclients\ & copy src\org\modernclients\styles.css out\org\modernclients\

  1. 创建一个罐子:
java --module-path %PATH_TO_FX% --add-modules javafx.controls,javafx.fxml -cp out org.modernclients.Main

  1. 创建安装程序。
mkdir libs
jar --create --file=libs\sample1.jar --main-class=org.modernclients.Main -C out .

在这一步,我们使用 jpackage 创建一个安装程序。我们在表 10-1 中展示了可以提供给 jpackage 的不同选项。在以下命令中,我们指定了许多选项:

%JAVA_HOME%\bin\jpackage --type exe -d installer -i libs --main-jar sample1.jar -n Sample1 --module-path %PATH_TO_FX_MODS% --add-modules javafx.controls,javafx.fxml --main-class org.modernclients.Main

结果,jpackage 创建了可以分发的Sample1-1.0.exe (26 MB),只需要双击就可以安装应用程序(图 10-6 )。

img/468104_2_En_10_Fig6_HTML.jpg

图 10-6

示例 1 Windows installer

使用--verbose运行 jpackage 工具会显示以下输出,这有助于确定 jpackage 如何构建安装程序,它在哪里存储默认资源,以及如何定制这些设置:

Running candle.exe
Running light.exe
Detected [light.exe] version [3.11.2.4516].
Detected [candle.exe] version [3.11.2.4516].
WiX 3.11.2.4516 detected. Enabling advanced cleanup action.
Using default package resource java48.ico [icon] (add Sample1.ico to the resource-dir to customize).
Using default package resource WinLauncher.template [Template for creating executable properties file] (add Sample1.properties to the resource-dir to customize).
MSI ProductCode: 6ad6fbff-52ef-3f2f-959a-a12e4c9b1f68.
MSI UpgradeCode: 4e3a7148-be2c-3a36-bc72-feb6033ea68f.
Using default package resource main.wxs [Main WiX project file] (add main.wxs to the resource-dir to customize).
Using default package resource overrides.wxi [Overrides WiX project file] (add overrides.wxi to the resource-dir to customize).
Preparing MSI config: C:\Users\<user>\AppData\Local\Temp\jdk.jpackage13545744068176887418\images\win-exe.image\Sample1-1.0.msi.
Generating MSI: C:\Users\<user>\AppData\Local\Temp\jdk.jpackage13545744068176887418\images\win-exe.image\Sample1-1.0.msi.
Running candle.exe in C:\Users\<user>\AppData\Local\Temp\jdk.jpackage13545744068176887418\images\win-msi.image\Sample1
Command:
    candle.exe -nologo C:\Users\<user>\AppData\Local\Temp\jdk.jpackage13545744068176887418\config\main.wxs -ext WixUtilExtension -arch x64 [...]
Output:
    main.wxs

Returned: 0
Running candle.exe in C:\Users\<user>\AppData\Local\Temp\jdk.jpackage13545744068176887418\images\win-msi.image\Sample1
Command:
    candle.exe -nologo C:\Users\<user>\AppData\Local\Temp\jdk.jpackage13545744068176887418\config\bundle.wxf -ext WixUtilExtension -arch x64 [...]
Output:
    bundle.wxf
Returned: 0
Running light.exe in C:\Users\<user>\AppData\Local\Temp\jdk.jpackage13545744068176887418\images\win-msi.image\Sample1
Command:
    light.exe -nologo -spdb -ext WixUtilExtension […]
Output:
    C:\Users\<user>\AppData\Local\Temp\jdk.jpackage13545744068176887418\config\main.wxs(53) : warning LGHT1076 : ICE61: This product should remove only older versions of itself. No Maximum version was detected for the current product. (JP_DOWNGRADABLE_FOUND)
Returned: 0
Generating EXE for installer to: C:\Users\<user>\Downloads\mcj-samples\ch10-packaging\Sample1\installer.
Using default package resource WinInstaller.template [Template for creating executable properties file] (add WinInstaller.properties to the resource-dir to customize).
Installer (.exe) saved to: C:\Users\<user>\Downloads\mcj-samples\ch10-packaging\Sample1\installer
Succeeded in building EXE Installer Package package

修改安装程序

我们可以向 jpackage 命令添加更多选项。例如,我们可以将应用程序添加到系统菜单,创建桌面快捷方式,让用户选择安装目录,并使用基于来自

https://hg.openjdk.java.net/duke/duke/raw-file/e71b60779736/3D/Duke%20Waving/openduke.png

运行以下 jpackage 命令构建定制的安装程序,该安装程序将创建如图 10-7 所示的应用程序:

img/468104_2_En_10_Fig7_HTML.jpg

图 10-7

带有自定义图标的应用程序

%JAVA_HOME%\bin\jpackage --type exe -d installer -i libs --main-jar sample1.jar -n Sample1 --module-path %PATH_TO_FX_MODS% --add-modules javafx.controls,javafx.fxml --main-class org.modernclients.Main --win-menu --win-shortcut --win-dir-chooser --icon assets\win\openduke.ico

苹果

这些是为非模块化应用程序创建安装程序的必要步骤,类似于示例 1:

  1. 导出这些环境变量:
https://github.com/modernclientjava/mcj-samples/tree/master/ch10-packaging/Sample1

  1. 编译您的应用程序并将 fxml 和 css 资源文件复制到路径out/org/modernclients:
export JAVA_HOME=/Users/<user>/Downloads/jdk-17.jdk/Contents/Home/
export PATH_TO_FX=/Users/<user>/Downloads/javafx-sdk-17/lib/
export PATH_TO_FX_MODS=/Users/<user>/Downloads/javafx-jmods-17/

  1. 运行和测试:
javac --module-path $PATH_TO_FX --add-modules javafx.controls,javafx.fxml -d out $(find src -name "*.java")

cp src/org/modernclients/scene.fxml src/org/modernclients/styles.css out/org/modernclients/

  1. 创建一个罐子:
java --module-path $PATH_TO_FX --add-modules javafx.controls,javafx.fxml -cp out org.modernclients.Main

  1. 创建安装程序:
mkdir libs
jar --create --file=libs/sample1.jar --main-class=org.modernclients.Main -C out .

$JAVA_HOME/bin/jpackage --type dmg -d installer -i libs --main-jar sample1.jar -n Sample1 --module-path $PATH_TO_FX_MODS --add-modules javafx.controls,javafx.fxml --main-class org.modernclients.Main

img/468104_2_En_10_Fig8_HTML.jpg

图 10-8

Sample1 macOS 安装程序

这构建了Sample1-1.0.dmg (83 MB),如图 10-8 所示,你可以分发它。它只需要双击安装应用程序。

--verbose运行 jpackage 工具展示了 jpackage 如何构建安装程序以及它在哪里存储默认资源。当您想要确定 jpackage 存储默认资源的位置并定制这些设置时,请使用--verbose选项,如以下示例输出所示:

Building DMG package for Sample1
Building PKG package for Sample1
"Adding modules: [javafx.controls, javafx.fxml] to runtime image."
jlink arguments: [--output /var/folders/90/fcwm6f8s0d39jnv8vc0_rww00000gn/T/jdk.jpackage3310158689456557035/img/image-8684840536936452979/Sample1.app/Contents/runtime/Contents/Home --module-path /Users/<user>/Downloads/javafx-jmods-17-ea-13:/Users/<user>/Downloads/jdk-17.jdk/Contents/Home/jmods --add-modules javafx.controls,javafx.fxml --strip-native-commands --strip-debug --no-man-pages --no-header-files]
Using default package resource GenericApp.icns [icon]  (add Sample1.icns to the resource-dir to customize)
Preparing Info.plist: /var/folders/90/fcwm6f8s0d39jnv8vc0_rww00000gn/T/jdk.jpackage3310158689456557035/img/image-8684840536936452979/Sample1.app/Contents/Info.plist
Using default package resource Info-lite.plist.template [Application Info.plist]  (add Info.plist to the resource-dir to customize)
Using default package resource Runtime-Info.plist.template [Java Runtime Info.plist]  (add Runtime-Info.plist to the resource-dir to customize)
Using default package resource background_pkg.png [pkg background image]  (add Sample1-background.png to the resource-dir to customize)
Preparing distribution.dist: /var/folders/90/fcwm6f8s0d39jnv8vc0_rww00000gn/T/jdk.jpackage3310158689456557035/config/distribution.dist
no default package resource  [script to run after application image is populated]  (add Sample1-post-image.sh to the resource-dir to customize)
Running [pkgbuild, --root, /var/folders/90/fcwm6f8s0d39jnv8vc0_rww00000gn/T/jdk.jpackage3310158689456557035/img/image-9477246125921380963, --install-location, /Applications, --analyze, -/var/folders/90/fcwm6f8s0d39jnv8vc0_rww00000gn/T/jdk.jpackage3310158689456557035/config/cpl.plist]
pkgbuild: Inferring bundle components from contents of /var/folders/90/fcwm6f8s0d39jnv8vc0_rww00000gn/T/jdk.jpackage3310158689456557035/img/image-9477246125921380963
pkgbuild: Writing new component property list to /var/folders/90/fcwm6f8s0d39jnv8vc0_rww00000gn/T/jdk.jpackage3310158689456557035/config/cpl.plist
Preparing package scripts
Using default package resource preinstall.template [PKG preinstall script]  (add preinstall to the resource-dir to customize)
Using default package resource postinstall.template [PKG postinstall script]  (add postinstall to the resource-dir to customize)
...

修改安装程序

我们还可以根据 https://hg.openjdk.java.net/duke/duke/file/e71b60779736/3D/Duke%20Waving/openduke.png 中的公爵图像添加一个自定义图标:

$JAVA_HOME/bin/jpackage --type dmg -d installer -i libs --main-jar sample1.jar -n Sample1 --module-path $PATH_TO_FX_MODS --add-modules javafx.controls,javafx.fxml --main-class org.modernclients.Main --icon assets/mac/openduke.icns

我们得到的结果如图 10-9 所示。

img/468104_2_En_10_Fig9_HTML.jpg

图 10-9

定制安装程序

Linux 操作系统

以下是为非模块化应用程序(如 Sample1:

https://github.com/modernclientjava/mcj-samples/tree/master/ch10-packaging/Sample1

以下是为基于 Debian 的发行版创建安装程序的说明:

  1. 导出这些环境变量:

  2. 编译您的应用程序并将 fxml 和 css 资源文件复制到路径out/org/modernclients:

export JAVA_HOME=/home/<user>/Downloads/jdk-17/
export PATH_TO_FX=/home/<user>/Downloads/javafx-sdk-17/lib/
export PATH_TO_FX_MODS=/home/<user>/Downloads/javafx-jmods-17/

  1. 运行和测试:
javac --module-path $PATH_TO_FX --add-modules javafx.controls,javafx.fxml -d out $(find src -name "*.java")

cp src/org/modernclients/scene.fxml src/org/modernclients/styles.css out/org/modernclients/

  1. 创建一个罐子:
java --module-path $PATH_TO_FX --add-modules javafx.controls,javafx.fxml -cp out org.modernclients.Main

  1. 创建安装程序:
mkdir libs
jar --create --file=libs/sample1.jar --main-class=org.modernclients.Main -C out .

$JAVA_HOME/bin/jpackage --type deb -d installer -i libs --main-jar sample1.jar -n Sample1 --module-path $PATH_TO_FX_MODS --add-modules javafx.controls,javafx.fxml --main-class org.modernclients.Main

该命令的结果是在名为“installer”的目录中创建一个名为sample1-1.0.deb的文件用文件浏览器定位这个文件,显示 sample1-1.0.deb 是一个 Debian 包(图 10-10 )。

img/468104_2_En_10_Fig10_HTML.jpg

图 10-10

示例 1 Linux 安装程序

模块化应用:示例 2

我们的第二个应用程序是模块化应用程序。源代码可以在 https://github.com/modernclientjava/mcj-samples/tree/master/ch10-packaging/Sample2 找到。

它包含一个 module-info.java 文件,jpackage 工具可以处理这个文件来处理模块依赖关系。module-info 文件非常简单:它声明了对 javafx.controls 和 javafx.fxml 模块的依赖关系,并导出了模块 org.modernclients。

module-info.java 文件如下所示:

module modernclients {
    requires javafx.controls;
    requires javafx.fxml;
    opens org.modernclients to javafx.fxml;
    exports org.modernclients;
}

Windows 操作系统

以下是在 Windows 上为模块化应用程序创建安装程序的必要步骤:

  1. 导出这些环境变量:

  2. 编译您的应用程序并将 fxml 和 css 资源文件复制到路径out\org\modernclients:

set JAVA_HOME="C:\Users\<user>\Downloads\jdk-17"
set PATH_TO_FX="C:\Users\<user>\Downloads\javafx-sdk-17\lib"
set PATH_TO_FX_MODS="C:\Users\<user>\Downloads\javafx-jmods-17"

  1. 运行和测试:
dir /s /b src\*.java > sources.txt & javac --module-path %PATH_TO_FX% --add-modules javafx.controls,javafx.fxml -d mods\modernclients @sources.txt & del sources.txt

copy src\org\modernclients\scene.fxml mods\modernclients\org\modernclients\ & copy src\org\modernclients\styles.css mods\modernclients\org\modernclients\

  1. 使用 jlink 创建自定义图像:
java --module-path %PATH_TO_FX%;mods -m modernclients/org.modernclients.Main

  1. 运行并测试映像:
%JAVA_HOME%\bin\jlink --module-path %PATH_TO_FX_MODS%;mods
--add-modules modernclients --output image

  1. 创建安装程序:
image\bin\java -m modernclients/org.modernclients.Main

%JAVA_HOME%\bin\jpackage --type exe -d installer -n Sample2 -m modernclients/org.modernclients.Main --runtime-image image

因此,您将获得可以分发的Sample2-1.0.exe (32 MB),并且它只需要双击来安装应用程序。

苹果

以下是在 macOS 上为模块化应用程序创建安装程序的必要步骤:

  1. 导出这些环境变量:

  2. 编译您的应用程序并将 fxml 和 css 资源文件复制到路径out/org/modernclients:

export JAVA_HOME=/Users/<user>/Downloads/jdk-17.jdk/Contents/Home/
export PATH_TO_FX=/Users/<user>/Downloads/javafx-sdk-17/lib/
export PATH_TO_FX_MODS=/Users/<user>/Downloads/javafx-jmods-17/

  1. 运行和测试:
javac --module-path $PATH_TO_FX -d mods/modernclients $(find src -name "*.java")

cp src/org/modernclients/scene.fxml src/org/modernclients/styles.css mods/modernclients/org/modernclients/

  1. 使用 jlink 创建自定义图像:
java --module-path $PATH_TO_FX:mods -m modernclients/org.modernclients.Main

  1. 运行并测试映像:
$JAVA_HOME/bin/jlink --module-path $PATH_TO_FX_MODS:mods --add-modules modernclients --output image

  1. 创建安装程序:
image/bin/java -m modernclients/org.modernclients.Main

$JAVA_HOME/bin/jpackage --type dmg -d installer -n Sample2 -m modernclients/org.modernclients.Main --runtime-image image

因此,您将获得可以分发的Sample2-1.0.dmg (38.3 MB),并且只需双击即可安装应用程序。

Linux 操作系统

以下是在 Linux 上为模块化应用程序创建安装程序的必要步骤:

  1. 导出这些环境变量:

  2. 编译您的应用程序并将 fxml 和 css 资源文件复制到路径out/org/modernclients:

export JAVA_HOME=/home/<user>/Downloads/jdk-17/
export PATH_TO_FX=/home/<user>/Downloads/javafx-sdk-17/lib/
export PATH_TO_FX_MODS=/home/<user>/Downloads/javafx-jmods-17/

  1. 运行和测试:
javac --module-path $PATH_TO_FX -d mods/modernclients $(find src -name "*.java")

cp src/org/modernclients/scene.fxml src/org/modernclients/styles.css mods/modernclients/org/modernclients/

  1. 使用 jlink 创建自定义图像:
java --module-path $PATH_TO_FX:mods -m modernclients/org.modernclients.Main

  1. 运行并测试映像:
$JAVA_HOME/bin/jlink --module-path $PATH_TO_FX_MODS:mods --add-modules modernclients --output image

  1. 创建安装程序:
image/bin/java -m modernclients/org.modernclients.Main

$JAVA_HOME/bin/jpackage --type deb -d installer -n Sample2 -m modernclients/org.modernclients.Main --runtime-image image

因此,您将获得可以分发或安装的Sample2-1.0.deb

Gradle 项目

前面的示例解释了如何使用命令行 jpackage 工具。与大多数命令一样,在现有的构建工具中使用它们通常是有意义的,例如 Maven 或 Gradle。

虽然你可以创建一个任务添加到你的 build.gradle 文件中,并提供运行 jpackage 工具所需的选项,但是有一个插件可以帮你做到这一点:塞尔班·约尔达切的 org.beryx.jlink 插件(见 https://badass-jlink-plugin.beryx.org/ )。

以下是使用 Gradle 为模块化应用程序创建安装程序的必要步骤,如下所示:

  1. 编辑 build.gradle 并检查所需的 JDK 路径。请注意,我们提供的插件版本是出版物发布时的最新版本;您应该检查这些插件版本号的更新。
https://github.com/modernclientjava/mcj-samples/tree/master/ch10-packaging/Sample3

  1. 运行和测试:
plugins {
    id 'application'
    id 'org.openjfx.javafxplugin' version '0.0.10'
    id 'org.beryx.jlink' version '2.24.1'
}
repositories {
    mavenCentral()
}
javafx {
   version = 17.0.1
    modules = [ 'javafx.controls', 'javafx.fxml' ]
}

application {
    mainModule = "modernclients"
    mainClass = "org.modernclients.Main"
}

jlink {
    options = ['--strip-debug', '--compress', '2', '--no-header-files', '--no-man-pages']
    launcher {
        name = 'sample3'
    }
    jpackage {
        if (javafx.getPlatform().name() == "OSX") {
            installerType = "dmg"
            jpackageHome =
            "/Users/<user>/Downloads/jdk-17.jdk/Contents/Home"
        } else if (javafx.getPlatform().name() == "WINDOWS") {
            installerType = "exe"
            jpackageHome = "C:\\Users\\<user>\\Downloads\\jdk-17"
            installerOptions = ['--win-menu', '--win-shortcut', '--win-dir-chooser']
        } else if (javafx.getPlatform().name() == "LINUX") {
            installerType = "deb"
            jpackageHome = "/home/<user>/Downloads/jdk-17"
        }

    }
}

  1. 创建自定义图像:
./gradlew run (Mac OSX or Linux)
gradlew run (Windows)

  1. 运行并测试映像:
./gradlew jlink (Mac OSX or Linux)
gradlew jlink (Windows)

  1. 创建安装程序:
build/image/bin/sample3 (Mac OSX or Linux)
build\image\bin\sample3 (Windows)

./gradlew jpackage (Mac OSX or Linux)
gradlew jpackage (Windows)

因此,您将在 macOS 上获得sample3-1.0.dmg (35.8 MB),在 Windows 上获得 sample3-1.0.exe(34.5 MB),或者在 Linux 上获得可以分发的 sample3-1.0-1_amd64.deb (33.8 MB),它只需要双击即可安装应用程序。

使用 GraalVM 的本机映像

jpackage 工具允许您为特定的操作系统构建本机应用程序。Java 运行时与应用程序捆绑在一起,当执行原生应用程序时,它将在内部使用 Java 运行时来执行字节码。通常,Java 运行时包含一个将 Java 字节码编译成本机代码的实时(JIT)编译器。

构建本机应用程序的另一个选择是将编译步骤移到构建时间。有了 GraalVM 的原生映像,Java 代码就提前编译好了(AOT)。这意味着 Java 字节码在被执行之前和被捆绑到应用程序之前被编译成本机代码。

因此,生成的二进制文件不再包含 Java 运行时。图 10-11 显示了这种情况。

img/468104_2_En_10_Fig11_HTML.jpg

图 10-11

本机映像开发流程

尽管 GraalVM 项目(“在任何地方更快地运行程序”)已经活跃了很多年,但它直到最近才成为一个产品。GraalVM 仍在发展,它的一部分与 OpenJDK 项目集成在一起,反之亦然。我们建议您定期关注 https://graalvm.org 网站以及 GitHub 网站上 https://github.com/oracle/graal 的开源代码。

虽然 GraalVM 提供了 AOT 编译器,可以将 Java 字节码翻译成给定平台的本机代码,但是要将程序代码链接成可执行文件,还需要更多的操作。幸运的是,有开源工具可以帮助开发人员实现这一点。GluonFX 插件(来自 gluonhq.com)允许开发人员基于现有的 Java 代码为 Linux、macOS 和 Windows 创建本机映像。

这个插件也为移动应用程序生成本地图像,我们将在下一章讨论。

我们现在将向您展示如何使用 GluonFX 插件通过 HelloFX 示例应用程序构建一个本机可执行文件。

平台要求

为了构建本机映像,您可以使用 JDK 11 或 JDK 11。我们将简要描述 Maven 和 Gradle 项目在 macOS、Linux 和 Windows 上的需求。

您可以为每个目标系统下载 JDK 11 或 JDK 11。例如,您可以从以下 URL 下载 AdoptOpenJDK(为目标平台选择合适的版本):

https://adoptopenjdk.net/releases.html

您可以从这个 URL 下载 Gluon GraalVM 版本(为目标平台选择合适的版本):

https://github.com/gluonhq/graal/releases/

macOS 的要求

要使用该插件在 macOS 平台上开发和部署本机应用程序,您需要一台装有 macOS 10.13.2 或更高版本以及 Xcode 11 或更高版本的 Mac,可从 Mac App Store 获得。下载并安装 Xcode 后,打开它接受许可条款。

下载并安装后,将JAVA_HOME设置为 JDK,例如:

export JAVA_HOME=/Users/<user>/Downloads/jdk-11.0.11+9/Contents/Home

对 Linux 的要求

下载适用于 Linux 的 JDK 后,导出适用于 Linux 平台 JDK 的 JAVA_HOME 环境变量,例如:

export JAVA_HOME=/home/<user>/Downloads/jdk-11.0.11+9

对 Windows 的要求

下载用于 Windows 的 JDK 后,为 Windows 平台 JDK 设置 JAVA_HOME 环境变量,例如:

set JAVA_HOME=C:\path\to\ jdk-11.0.11+9 2

将 JAVA_HOME 添加到环境变量列表中(高级系统设置)。

除了 Java JDK,还需要微软 Visual Studio 2019。社区版就足够了,可以从

https://visualstudio.microsoft.com/downloads/

在安装过程中,请确保至少选择以下单个组件:

  • 选择英语语言包。

  • C++/CLI 支持 v142 构建工具(v 14.25 或更高版本)。

  • MSVC v 142–VS 2019 c++ x64/x86 构建工具(v 14.25 或更高版本)。

  • Windows 通用 CRT SDK。

  • Windows 10 SDK (10.0.19041.0 或更高版本)。

注意,所有构建命令都必须在名为 VS 2019 x64 原生工具命令提示符的 Visual Studio 2019 命令提示符中执行。

《守则》

这个例子的代码在 GitHub 上:

https://github.com/gluonhq/gluon-samples/tree/master/HelloFX/src/main/java/hellofx

清单 10-1 显示了 HelloFX 主类,清单 10-2 显示了 styles.css 文件。

.label {
    -fx-text-fill: blue;
}

Listing 10-2File styles.css

package hellofx;
import javafx.application.Application;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class HelloFX extends Application {
    public void start(Stage stage) {
        String javaVersion = System.getProperty("java.version");
        String javafxVersion = System.getProperty("javafx.version");
        Label label = new Label("Hello, JavaFX " + javafxVersion + ",
           running on Java " + javaVersion + ".");
        ImageView imageView = new ImageView(
           new Image(HelloFX.class.getResourceAsStream("openduke.png")));
        imageView.setFitHeight(200);
        imageView.setPreserveRatio(true);
        VBox root = new VBox(30, imageView, label);
        root.setAlignment(Pos.CENTER);
        Scene scene = new Scene(root, 640, 480);
        scene.getStylesheets().add(
           HelloFX.class.getResource("styles.css").toExternalForm());
        stage.setScene(scene);
        stage.show();
    }
   public static void main(String[] args) {
        launch(args);
    }
}

Listing 10-1HelloFX.java

Maven 项目

如果您有一个 Java 或 JavaFX 项目,并且您使用 Maven 作为构建工具,那么您可以包含这个插件来开始创建本地应用程序。

该插件可在以下位置找到:

https://github.com/gluonhq/gluonfx-maven-plugin

清单 10-3 显示了一个 Maven 项目的 pom 文件。

<project xmlns:="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>hello</groupId>
    <artifactId>hellofx</artifactId>
    <version> 1.0.0-SNAPSHOT</version>
    <packaging>jar</packaging>
    <name>hellofx</name>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>11</maven.compiler.source>
        <maven.compiler.target>11</maven.compiler.target>
        <javafx.version>17.0.1</javafx.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <release>11</release>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version> 0.0.8</version>
                <configuration>
                    <mainClass>hellofx.HelloFX</mainClass>
                </configuration>
            </plugin>
            <plugin>
                <groupId>com.gluonhq</groupId>
                <artifactId> gluonfx-maven-plugin</artifactId>
                <version>1.0.7</version>
                <configuration>
                    <mainClass>hellofx.HelloFX</mainClass>
                </configuration>
            </plugin>
        </plugins>
    </build>
    <pluginRepositories>
        <pluginRepository>
            <id>gluon-releases</id>
            <url>
                http://nexus.gluonhq.com/nexus/content/repositories/releases
            </url>
        </pluginRepository>
    </pluginRepositories>
</project>

Listing 10-3pom.xml file

Gradle 项目

如果您有一个 Java 或 JavaFX 项目,并且您使用 Gradle 作为构建工具,那么您可以包含该插件来开始创建本地应用程序。

该插件可在以下位置找到:

https://github.com/gluonhq/gluonfx-gradle-plugin.

清单 10-4 显示了 build.gradle 文件,清单 10-5 显示了 gradle 项目的 settings.gradle 文件。

pluginManagement {
    repositories {
        gradlePluginPortal()
    }
}
rootProject.name = 'HelloFX'

Listing 10-5File settings.gradle

plugins {
    id 'application'
    id 'org.openjfx.javafxplugin' version '0.0.10'
    id 'com.gluonhq.gluonfx-gradle-plugin' version '1.0.3'
}
repositories {
    mavenCentral()
}
gluonfx {
}
javafx {
    modules = [ "javafx.controls" ]
}
mainClassName = 'hellofx.HelloFX'

Listing 10-4File build.gradle

构建项目

第一步是将项目作为一个常规的 Java 项目来构建和运行(在一个用于本地开发的常规 JVM 上,例如 HotSpot)。

与格雷尔:

./gradlew clean build run (Mac OSX or Linux)
gradlew clean build run (Windows)

使用 Maven:

mvn clean javafx:run

结果如图 10-12 所示。

img/468104_2_En_10_Fig12_HTML.jpg

图 10-12

在 OpenJDK 16 上运行 HelloFX

我们现在将编译、打包和运行本机桌面应用程序。

编制

与 Gradle 一起运行:

./gradlew nativeCompile (Mac OS or Linux)
gradlew nativeCompile (Windows)

或者用 Maven:

mvn gluonfx:compile

您需要等到任务成功完成(根据您的计算机,可能需要 5 分钟或更长时间)。您将看到在这个过程中提供的反馈,比如清单 10-6 。

...
[INFO] ==================== COMPILE TASK ====================

[SUB] [hellofx.hellofx:13197]    classlist:   1,810.51 ms,  0.96 GB
[SUB] [hellofx.hellofx:13197]        (cap):   2,187.64 ms,  0.96 GB
[SUB] [hellofx.hellofx:13197]        setup:   4,359.53 ms,  0.96 GB
[SUB] [hellofx.hellofx:13197]     (clinit):     812.63 ms,  4.65 GB
[SUB] [hellofx.hellofx:13197]   (typeflow):  19,802.32 ms,  4.65 GB
[SUB] [hellofx.hellofx:13197]    (objects):  29,770.98 ms,  4.65 GB
[SUB] [hellofx.hellofx:13197]   (features):   2,568.14 ms,  4.65 GB
[SUB] [hellofx.hellofx:13197]     analysis:  54,581.85 ms,  4.65 GB
[SUB] [hellofx.hellofx:13197]     universe:   1,677.48 ms,  4.65 GB
[SUB] [hellofx.hellofx:13197]      (parse):  10,890.07 ms,  5.43 GB
[SUB] [hellofx.hellofx:13197]     (inline):  10,567.77 ms,  6.10 GB
[SUB] [hellofx.hellofx:13197]    (compile):  35,567.94 ms,  6.16 GB
[SUB] [hellofx.hellofx:13197]      compile:  60,462.59 ms,  6.16 GB
[SUB] [hellofx.hellofx:13197]        image:   7,202.38 ms,  6.16 GB
[SUB] [hellofx.hellofx:13197]        write:   1,006.42 ms,  6.16 GB
[SUB] # Printing build artifacts to: hellofx.hellofx.build_artifacts.txt
[SUB] [hellofx.hellofx:13197]      [total]: 131,599.90 ms,  6.16 GB
[INFO] BUILD SUCCESS
[INFO] Total time:  02:22 min

Listing 10-6Output during the native compilation phase

结果你会在target/gluonfx/{target-architecture}/gvm/tmp/SVM- *** /下看到 hellofx.hellofx.o (65.0 MB)或者 hellofx.hellofx.obj。

如果不是这样,请在target/gluonfx/{target-architecture}/gvm/log下的日志文件中检查任何可能的故障。

既然应用程序的 Java 代码已经编译成本机代码,我们可以使用nativeLink任务将生成的代码与所需的库和资源打包在一起。

与 Gradle 一起运行:

./gradlew nativeLink (Mac OSX or Linux)
gradlew nativeLink (Windows)

或者用 Maven:

mvn gluonfx:link

链接步骤在目标子目录target/gluonfx/{target-architecture}/HelloFX (65.4 MB)或 windows 的 target \ Glu onfx \ x86 _ 64-Windows \ hello FX . exe 中生成可执行文件。图 10-13 显示了 macOS 文件系统中的可执行文件。

img/468104_2_En_10_Fig13_HTML.jpg

图 10-13

HelloFX 可执行文件

奔跑

最后,您可以使用 Gradle 运行它:

./gradlew nativeRun (Mac OS or Linux)
gradlew nativeRun (Windows)

或者用 Maven:

mvn gluonfx:run

您应该得到如图 10-14 所示的输出。

img/468104_2_En_10_Fig14_HTML.jpg

图 10-14

运行 HelloFX 的输出

请注意,您可以将这个本机应用程序分发到任何具有匹配架构(macOS、Linux 或 Windows)的机器上,并像任何其他常规应用程序一样直接运行它。

结论

将应用程序代码与所有必需的依赖项、Java 运行时和资源打包在一起,在桌面、移动和嵌入式设备上越来越流行。

历史上的缺点,包括较大的尺寸和较长的下载时间,由于带宽和存储的改善而变得不那么重要。

将一个应用程序打包到一个自包含的包中的优势意味着减少了最终用户的麻烦,他们可以使用与其他应用程序相似的安装方法。

JavaFX 应用程序是常规的 Java 应用程序。因此,对于 JavaFX 应用程序,您也可以使用现有的打包工具,比如 jpackage、jlink 和 Graal Native Image。

由于这些工具发展迅速,我们建议您密切关注 GitHub 资源库 https://github.com/gluonhq/gluon-samples 中的示例,因为它们将会更新到最新版本。

十一、iOS 和 Android 的原生移动应用

由何塞·佩雷达和约翰·沃斯撰写

Java 最初是作为嵌入式设备的编程语言出现的。20 世纪 90 年代初,Sun Microsystems 内部的一个团队为一组下一代硬件产品开发了一个软件堆栈。

这些硬件产品的原型被称为 Star7,看起来像是介于手机和平板电脑之间的东西。这些设备的软件最初代号为 Oak,也就是我们现在所知的 Java。

硬件和软件的发展走上了截然不同的道路。Java 软件因使用小程序制作网页动画而大受欢迎,后来成为开发企业和云应用程序的首选语言。

手机和设备制造商、电信运营商和内容提供商之间的复杂互动决定了硬件的发展。商业模式非常分散,在很长一段时间里,开发者一般都不容易接触到移动设备。

随着应用商店的日益流行,开发人员为移动平台编写应用变得更加容易。此外,现在大多数手机要么基于 Android,要么基于 iOS。这减少了两个主要平台的碎片。

由于 Java 最初是为移动和嵌入式系统开发的,所以创建 Java 应用程序并在移动设备上运行它们是非常有意义的,特别是因为 Java 领域最近的发展使得向移动设备交付高性能应用程序变得很容易。

为什么在移动设备上使用 JavaFX

在当今的数字基础设施中,网页通常用于以简单的方式呈现来自后端的信息。

台式机和笔记本电脑系统广泛用于处理需要用户交互、数据同步、高性能渲染和云集成的应用程序。因此,它们补充了 web 应用程序。

IT 领域的发展导致移动设备的重要性日益增加,例如手机和平板电脑。因此,IT 后端现在必须服务于三个不同的渠道:基于 web 的前端、桌面应用程序和移动应用程序。如图 11-1 所示。

img/468104_2_En_11_Fig1_HTML.png

图 11-1

服务于不同渠道的业务后端

JavaFX 是 Java,因此支持一次编写,随处运行的范例,非常适合创建在台式机和笔记本电脑上运行的应用程序,但也可以在手机和平板电脑上运行。

在前一章中,我们展示了如何将 JavaFX 应用程序转换成客户机本地的应用程序。当我们谈论“本地”时,我们指的是两个事物的组合:

  1. 代码在设备的本地方法中执行。交付给手机的应用程序直接执行机器代码,而不是在设备上解释或翻译。这允许快速执行。

  2. 没有中间呈现引擎(例如,浏览器),JavaFX 控件直接使用客户端上可用的硬件加速图形引擎来呈现。

在本章中,我们将解释如何在移动设备上部署 JavaFX 应用程序,从而利用在这些设备上使用的硬件加速的本机渲染。

移动应用的不同方法

为移动设备创建应用程序有多种方法,它们可以分为多种类别。任何分类都是人为的,所以我们在这里使用的只是一种可能性。

我们考虑三种不同的方法,如图 11-2 所示:

img/468104_2_En_11_Fig2_HTML.png

图 11-2

移动应用的三种可能方式

  1. 基于网络的移动应用

  2. 使用硬件加速本地渲染的应用

  3. 使用特定于操作系统的本机控件的应用程序

这些选项各有优势,三个选项都有有效的用例。

特定于操作系统的本机控件

使用特定于操作系统的本机控件,如方法 3 中的情况,可以实现与本机操作系统的真正平滑集成。在这种情况下,本地控件(例如,按钮、标签、列表等。)用于呈现用户界面。对于最终用户来说,这是很方便的,因为他们认识到他们在其他应用程序中也使用的典型 UI 组件。

这种方法需要与目标操作系统相关的非常特殊的技能。由于大多数移动应用程序同时面向 Android 和 iOS,并且这些平台都有自己的原生用户界面方法,因此遵循这种方法的应用程序通常由不同的团队创建:一个用于 Android,一个用于 iOS。

此外,特定于操作系统的本机控件会发生快速变化。虽然许多最终用户喜欢这些环境中的快速创新,但对于软件开发人员来说,这往往是一个问题,因为他们必须经常升级他们的原生应用程序,否则他们可能会过时。

移动网站

最简单的方法通常是简单地创建一个移动友好的网站,并在移动设备上可用的移动浏览器中呈现——可选地与应用程序图标集成,以便用户可以更容易地启动应用程序。

理论上,用于在桌面浏览器中呈现的同一网站可以在移动设备上呈现。然而,这些网站通常是为大屏幕创建的,并且使用鼠标控制来操作。使用鼠标点击按钮比在触摸设备上触摸相同的按钮更容易。一般来说,网络体验与移动体验有很大不同。习惯在移动设备上使用原生应用的用户经常对网站感到失望,这损害了品牌。

设备本机呈现

JavaFX 方法就属于这一类。JavaFX 有自己的一套控件,开发者可以轻松创建自己的控件。JavaFX 的渲染是在目标平台的硬件加速驱动程序之上完成的。目前,用于 iOS 的 JavaFX 和用于 Android 的 JavaFX 的渲染管道都使用 OpenGL,使用与 macOS 和 Linux 上的渲染相同的代码。OpenGL 是一个非常成熟和稳定的协议,iOS 或 Android 的原生 UI 控件的变化并不影响 OpenGL 的开发。事实上,许多游戏开发者也在使用原生 OpenGL,他们希望在移动设备上实现最大的性能和灵活性。

与使用 web 浏览器进行渲染相比,JavaFX 渲染更加“本机”,因为它不需要中间 web 浏览器,而是直接针对用于渲染本机 iOS 或本机 Android 控件的相同本机驱动程序。

就其核心而言,iOS 和大多数 Android 设备都是采用 AArch64 处理器的系统,运行某种 Linux。这些系统,以及 OpenGL,在业界被广泛使用,而不是由一个移动设备制造商控制。因此,它们提供了一个稳定的基础,避免了供应商锁定。

特定于应用的 JavaFX 堆栈如图 11-3 所示。

img/468104_2_En_11_Fig3_HTML.jpg

图 11-3

JavaFX 堆栈

你好,iOS 和 Android 上的 JavaFX

虽然 Android 和 iOS 是非常不同的系统,但 Java 开发人员的体验将非常相似。因此,虽然我们在这里只讨论 iOS,但是同样的原理和工具也适用于 Android。

GluonFX 插件降低复杂性

将应用程序部署到移动设备比在本地开发机器(笔记本电脑或台式机)上部署应用程序稍微复杂一些。这主要有两个原因:

  • 当编译一个必须在与我们用来编译的系统相同的系统上执行的应用程序时,我们可以利用开发系统的现有工具链,包括编译器和链接器。然而,在为另一个系统编译应用程序时,我们需要考虑操作系统和该系统的体系结构。这通常被称为交叉编译,被认为更复杂。

  • 移动应用商店的运营商 Google 和 Apple 对于创建移动应用都有自己的附加要求,例如,与签名和最终可执行包的结构相关的要求。

Java 普遍成功的原因之一是庞大的生态系统。不同的公司和个人提供了一套工具和库,使得开发人员更容易从事他们的项目。

对于移动设备上的 JavaFX,Gluon 创建了许多插件,使得 Java 开发人员可以更容易地在移动设备上部署 Java 应用程序。这些插件处理交叉编译的复杂性和来自各自应用商店的特定需求。

这些插件目前可用于 Maven 和 Gradle 项目。由于流行的 Java IDEs 对 Maven 和 Gradle 有一流的支持,所以很容易使用现有的 ide 来创建和维护移动 JavaFX 应用程序。

在我们的项目中使用 Maven 或 Gradle 构建工具,我们可以使用 GluonFX 插件在我们的本地(开发)系统上运行项目(依赖于 JavaFX 插件),还可以创建一个可以部署到目标平台的本地映像。

开发流程

虽然至少在理论上,可以创建一个移动应用程序,并且只在移动设备上测试/运行它,但是强烈建议首先在桌面上工作。

典型的部署周期包含许多步骤:

  • 写点代码。

  • 编译代码。

  • 运行代码。

  • 测试输出和行为是否符合预期。

这些步骤经常需要重复,导致给定项目的许多部署周期。

应该清楚的是,移动设备上的部署周期比桌面设备上的部署周期花费更多的时间。尤其是对移动设备来说,编译代码需要更多的时间。因此,最好使用您的台式机或笔记本电脑开发系统进行部署。我们将在下面描述的工具使您能够在移动和桌面设备上使用完全相同的代码,并且行为也是相似的。

当然,移动体验仍然是不同的,这只能在移动设备上进行真正的测试。例如,旋转、收缩和缩放等手势必须在特定的移动设备上进行微调,以便尽可能直观。

典型的移动应用开发流程如图 11-4 所示。

img/468104_2_En_11_Fig4_HTML.jpg

图 11-4

移动应用的开发流程

在参考图中,大部分开发是在桌面上完成的。应用程序得到了改进,包括业务逻辑部分和 UI 部分。在这个阶段,像NullPointerException s、错误的值或不正确的 UI 元素这样的问题被修复。

在给定的时刻,应用程序在桌面上按预期工作。业务逻辑符合需求,UI 遵循设计。此时,应用程序被部署在移动设备上并经过测试。作为这些测试的结果,在桌面或移动设备上执行新的周期。例如,如果在移动设备上的测试导致一个隐藏的问题浮出水面,建议返回到桌面周期并添加一个失败的测试。这个问题可以在桌面上解决。

另一方面,如果检测到特定于移动设备的问题(例如,缩放太快),则该问题可以在移动设备上的开发周期中直接修复。

《守则》

我们将向您展示一个非常简单的 HelloFX 应用程序,它可以在桌面和 iPhone 或 Android 设备上运行。这个例子可以在这里找到(Gradle 和 Maven):

https://github.com/modernclientjava/mcj-samples/tree/master/ch11-Mobile/Gradle/HelloFX
https://github.com/modernclientjava/mcj-samples/tree/master/ch11-Mobile/Maven/HelloFX

清单 11-1 显示了 HelloFX 主类,清单 11-2 显示了 styles.css 文件。

.label {
    -fx-text-fill: blue;
}

Listing 11-2File styles.css

package hellofx;
import javafx.application.Application;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;
import javafx.stage.Stage;
public class HelloFX extends Application {
    public void start(Stage stage) {
        String javaVersion = System.getProperty("java.version");
        String javafxVersion = System.getProperty("javafx.version");
        Label label = new Label("Hello, JavaFX " + javafxVersion +
           ", running on Java " + javaVersion + ".");
        ImageView imageView = new ImageView(
           new Image(HelloFX.class.getResourceAsStream("openduke.png")));
        imageView.setFitHeight(200);
        imageView.setPreserveRatio(true);
        VBox root = new VBox(30, imageView, label);
        root.setAlignment(Pos.CENTER);
        Scene scene = new Scene(root, 640, 480);
        scene.getStylesheets().add(
           HelloFX.class.getResource("styles.css").toExternalForm());
        stage.setScene(scene);
        stage.show();
    }
   public static void main(String[] args) {
        launch(args);
    }
}

Listing 11-1HelloFX.java file

清单 11-3 显示了 build.gradle 文件,清单 11-4 显示了 gradle 项目的 settings.gradle 文件。

pluginManagement {
    repositories {
         gradlePluginPortal()
    }
}
rootProject.name = 'HelloFX'

Listing 11-4File settings.gradle

plugins {
    id 'application'
    id 'org.openjfx.javafxplugin' version '0.0.10'
    id 'com.gluonhq.gluonfx-gradle-plugin' version '1.0.3'
}

repositories {
    mavenCentral()
}

dependencies {
}

gluonfx {
    target = 'host'
    if (project.hasProperty('target')) {
        target = project.getProperty('target')
    }
}

javafx {
    version = "17.0.1"
    modules = [ "javafx.controls" ]
}

mainClassName = 'hellofx.HelloFX'

Listing 11-3File build.gradle

build.gradle文件显示,除了常规的应用程序插件,我们还使用了两个特殊的插件:

    id 'org.openjfx.javafxplugin' version '0.0.10'
    id 'com.gluonhq.gluonfx-gradle-plugin' version '1.0.3'

javafxplugin是开发 JavaFX 应用程序和处理 JavaFX 模块和依赖关系的通用插件。这是您通常用于开发 JavaFX 应用程序的插件。

gluonfx-gradle-plugin是 Gluon 的插件,能够为 iOS 和 Android 设备交叉编译代码。

为了使用这些插件,我们必须告诉 Gradle 在哪里搜索插件,这些插件位于通用的 Gradle 插件门户中。这解释了清单 11-4 中显示的settings.gradle文件。

与 Gradle 的构建文件类似,您可以使用 pom 文件来声明如何使用 Maven 构建应用程序。

清单 11-5 显示了一个 Maven 项目的等价 pom 文件。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>hello</groupId>
    <artifactId>hellofx</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>hellofx</name>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>11</maven.compiler.release>
        <javafx.version>17.0.1</javafx.version>
        <mainClassName>hellofx.HelloFX</mainClassName>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
            </plugin>

            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <configuration>
                    <mainClass>${mainClassName}</mainClass>
                </configuration>
            </plugin>

            <plugin>
                <groupId>com.gluonhq</groupId>
                <artifactId>gluonfx-maven-plugin</artifactId>
                <version>1.0.7</version>
                <configuration>
                    <target>${gluonfx.target}</target>
                    <mainClass>${mainClassName}</mainClass>
                </configuration>
            </plugin>

        </plugins>
    </build>

    <profiles>
        <profile>
            <id>ios</id>
            <properties>
                <gluonfx.target>ios</gluonfx.target>
            </properties>
        </profile>
        <profile>
            <id>android</id>
            <properties>
                <gluonfx.target>android</gluonfx.target>
            </properties>
        </profile>
    </profiles>

</project>

Listing 11-5File pom.xml

ios

要求

要使用该插件在 iOS 平台上开发和部署本机应用程序,您需要一台装有 macOS 10.15.6 或更高版本以及 Xcode 11 或更高版本的 Mac,可从 Mac App Store 获得。下载并安装 Xcode 后,打开它接受许可条款。

或者,在没有 Mac 的情况下,GitHub Actions 可以用于远程构建和部署(参见 https://docs.gluonhq.com/#platforms_ios_github_actions )。

JavaFX 应用程序可以在 JVM 上运行,不需要任何额外的要求,因此任何 JDK 11+都应该足够了。然而,要创建本地映像并部署到移动设备,您将需要 GraalVM。胶子建造版本的最新版本可以在 https://github.com/gluonhq/graal/releases/latest 找到。选择 macOS 的 Darwin 版本。

一旦下载并安装,你需要设置GRAALVM_HOME指向它,就像

export GRAALVM_HOME=
     /path/to/graalvm-svm-darwin-gluon-21.2.0-dev/Contents/Home
export JAVA_HOME=$GRAALVM_HOME

最后,如果你想通过 App Store 测试和发布你的应用,你需要加入苹果开发者计划。但是,如果你只想在你自己的 iOS 设备上测试,你可以使用免费供应

按照此链接中的说明获取有效的预置描述文件和有效的签名身份: https://docs.gluonhq.com/client/#_ios_deployment

构建项目

第一步是将项目作为一个常规的 Java 项目来构建和运行(在一个用于本地开发的常规 JVM 上)。

与格雷尔:

    ./gradlew clean run

使用 Maven:

    mvn clean gluonfx:run

结果如图 11-5 所示。

img/468104_2_En_11_Fig5_HTML.png

图 11-5

在 OpenJDK 11.0.2 上运行 HelloFX

这些指令将编译应用程序及其依赖项,并使用系统的默认 Java 虚拟机运行生成的类。这与任何其他常规 Java 开发非常相似。这里不涉及交叉编译。

正如我们之前所说的,使用常规虚拟机验证应用程序在您的开发系统上正常工作是非常重要的。部署到移动设备时将执行的 AOT 编译需要很长时间,因此只有当项目准备好进入这个阶段时才应该调用它。

编制

典型的 Gradle run任务将检查应用程序是否已编译;否则,它将重新编译所需的类。

对于在移动设备上运行的应用程序,编译任务必须手动调用,因为这可能需要很长时间。这是为了允许开发者修改与 Java 文件无关的东西(但是,例如,与应用程序图标无关),而不必每次都经历漫长的编译阶段。

Gradle 和 Maven 都可以进行编译。

与 Gradle 一起运行:

    ./gradlew -Ptarget=ios nativeCompile

与 Maven 一起运行:

    mvn -Pios gluonfx:compile

然后等待一段时间(取决于您的机器,可能需要 5 分钟或更长时间),直到任务成功完成。如果您检查终端,您可以看到过程中提供的反馈,如清单 11-6 所示。

==================== COMPILE TASK ====================
We will now compile your code for arm64-apple-ios. This may take some time.
[SUB] [hellofx.hellofx:19668]    classlist:   2,087.16 ms,  0.96 GB
[SUB] [hellofx.hellofx:19668]        (cap):     206.66 ms,  0.96 GB
[SUB] [hellofx.hellofx:19668]        setup:   2,126.59 ms,  0.96 GB
[SUB] [hellofx.hellofx:19668]     (clinit):     850.75 ms,  5.56 GB
[SUB] [hellofx.hellofx:19668]   (typeflow):  36,924.18 ms,  5.56 GB
[SUB] [hellofx.hellofx:19668]    (objects):  29,823.15 ms,  5.56 GB
[SUB] [hellofx.hellofx:19668]   (features):   2,873.60 ms,  5.56 GB
[SUB] [hellofx.hellofx:19668]     analysis:  72,256.12 ms,  5.56 GB
[SUB] [hellofx.hellofx:19668]     universe:   2,483.79 ms,  5.66 GB
[SUB] [hellofx.hellofx:19668]      (parse):   3,013.34 ms,  5.66 GB
[SUB] [hellofx.hellofx:19668]     (inline):  12,337.56 ms,  7.09 GB
[SUB] [hellofx.hellofx:19668]    (compile):  41,955.76 ms,  6.97 GB
[SUB] [hellofx.hellofx:19668]    (bitcode):   4,023.85 ms,  6.97 GB
[SUB] [hellofx.hellofx:19668]    (prelink):   8,245.57 ms,  6.97 GB
[SUB] [hellofx.hellofx:19668]       (llvm): 118,298.09 ms,  6.97 GB
[SUB] [hellofx.hellofx:19668]   (postlink):  12,859.86 ms,  6.97 GB
[SUB] [hellofx.hellofx:19668]      compile: 201,311.01 ms,  6.97 GB
[SUB] [hellofx.hellofx:19668]        image:  11,973.28 ms,  7.05 GB
[SUB] [hellofx.hellofx:19668]        write:   2,908.47 ms,  7.05 GB
[SUB] [hellofx.hellofx:19668]      [total]: 295,742.00 ms,  7.05 GB

Listing 11-6Output during the native compilation phase for iOS

结果在target/gluonfx/arm64-ios/gvm/tmp/SVM-16 *** /hellofx.hellofx.o下可以找到 54.6 MB 的 hellofx.hellofx.o。

如果不是这样,请在target/gluonfx/arm64-ios/gvm/log下的日志文件中检查任何可能的故障。

接下来的步骤会更快,但它们需要有效的签名身份和有效的预置描述文件,以便您在应用程序部署到设备之前对其进行签名。

链接和包

既然应用程序的 Java 代码已经编译成本机代码,我们就可以将生成的代码与所需的库和资源打包在一起,对应用程序进行签名,并执行更多特定于 iOS 的任务。

插件在nativeLink任务中结合了这种打包。

与 Gradle 一起运行:

    ./gradlew -Ptarget=ios nativeLink

与 Maven 一起运行:

    mvn -Pios gluonfx:link

它产生 target/Glu onfx/arm 64-IOs/hello FX . app(136.3 MB)。

现在,如果你想构建一个可以提交到 App Store 的 IPA 文件(见 https://docs.gluonhq.com/#platforms_ios_distribution ,可以运行

    ./gradlew -Ptarget=ios nativePackage

或者与 Maven 一起运行:

    mvn -Pios gluonfx:package

奔跑

恭喜你!您的手机应用程序现已准备就绪!您可以将此应用程序部署到您的手机上,如下所述。

插入你的 iOS 设备,运行 Gradle:

    ./gradlew -Ptarget=ios nativeRun

或者与 Maven 一起运行:

    mvn -Pios gluonfx:nativerun

请注意,您需要解锁您的设备。一旦安装,它将启动(图 11-6 )。

img/468104_2_En_11_Fig6_HTML.jpg

图 11-6

iOS 上的 HelloFX 应用

机器人

要求

要使用该插件在 Android 上开发和部署本地应用程序,您需要一台 Linux 机器。或者,你可以从 Windows PC 上使用 WSL2 ( https://docs.microsoft.com/en-us/windows/wsl/install-win10 ),也可以使用 GitHub 动作远程完成(参见 https://docs.gluonhq.com/#platforms_android_github_actions )。

JavaFX 应用程序可以在 JVM 上运行,不需要任何额外的要求,因此任何 JDK 11+都应该足够了。然而,要创建本地映像并部署到移动设备,您将需要 GraalVM。胶子建造版本的最新版本可以在 https://github.com/gluonhq/graal/releases/latest 找到。选择 Linux 版本。

一旦下载并安装,你需要设置GRAALVM_HOME指向它,就像

export GRAALVM_HOME=
     /path/to/graalvm-svm-linux-gluon-21.2.0-dev/
export JAVA_HOME=$GRAALVM_HOME

Android SDK 和 NDK 需要为 Android 平台构建应用程序。两者都将被 GluonFX 插件自动下载并配置所需的包。

编制

Gradle 和 Maven 都可以进行编译。

与 Gradle 一起运行:

    ./gradlew -Ptarget=android nativeCompile

与 Maven 一起运行:

    mvn -Pandroid gluonfx:compile

然后等待一段时间(取决于您的机器,可能需要 3 分钟或更长时间),直到任务成功完成。如果您检查终端,您可以看到过程中提供的反馈,如清单 11-7 所示。

==================== COMPILE TASK ====================
We will now compile your code for aarch64-linux-android. This may take some time.
[SUB] Warning: Ignoring server-mode native-image argument --no-server.
[SUB] [hellofx.hellofx:4176]    classlist:   1,692.66 ms,  0.96 GB
[SUB] [hellofx.hellofx:4176]        (cap):     222.33 ms,  0.96 GB
[SUB] [hellofx.hellofx:4176]        setup:   2,350.95 ms,  0.96 GB
[SUB] [hellofx.hellofx:4176]     (clinit):   1,055.23 ms,  5.45 GB
[SUB] [hellofx.hellofx:4176]   (typeflow):  26,771.58 ms,  5.45 GB
[SUB] [hellofx.hellofx:4176]    (objects):  30,115.69 ms,  5.45 GB
[SUB] [hellofx.hellofx:4176]   (features):   2,812.99 ms,  5.45 GB
[SUB] [hellofx.hellofx:4176]     analysis:  62,782.08 ms,  5.45 GB
[SUB] [hellofx.hellofx:4176]     universe:   2,307.81 ms,  5.45 GB
[SUB] [hellofx.hellofx:4176]      (parse):   2,497.79 ms,  5.45 GB
[SUB] [hellofx.hellofx:4176]     (inline):   3,905.41 ms,  5.66 GB
[SUB] [hellofx.hellofx:4176]    (compile):  46,861.15 ms,  6.27 GB
[SUB] [hellofx.hellofx:4176]      compile:  56,083.83 ms,  6.27 GB
[SUB] [hellofx.hellofx:4176]        image:   9,097.73 ms,  5.37 GB
[SUB] [hellofx.hellofx:4176]        write:     558.92 ms,  5.37 GB
[SUB] [hellofx.hellofx:4176]      [total]: 135,607.09 ms,  5.37 GB

Listing 11-7Output during the native compilation phase for Android

结果在target/gluonfx/aarch64-android/gvm/tmp/SVM-16 *** /hellofx.hellofx.o下可以找到 84.7 MB 的 hellofx.hellofx.o。

如果不是这样,请在target/gluonfx/aarch64-android/gvm/log下的日志文件中检查任何可能的故障。

链接和包

既然应用程序的 Java 代码已经编译成本机代码,我们就可以将生成的代码与所需的库和资源链接并打包。

插件在任务nativeLinknativePackage中结合了这种打包。

与 Gradle 一起运行:

    ./gradlew -Ptarget=android nativeLink nativePackage

与 Maven 一起运行:

    mvn -Pandroid gluonfx:link gluonfx:package

它产生target/gluonfx/aarch64-android/gvm/hellofx.apk (28.1 MB)。这个文件可以提交到 Google Play,前提是它已经被签署发布(见 https://docs.gluonhq.com/#platforms_android_distribution )。

奔跑

您现在可以将这个 apk 部署到您的 Android 手机上,如下所述。

插入您的 Android 设备,并运行 Gradle:

    ./gradlew -Ptarget=android nativeInstall nativeRun

或者与 Maven 一起运行:

    mvn -Pandroid gluonfx:install gluonfx:nativerun

一旦安装,它将启动(图 11-7 )。

img/468104_2_En_11_Fig7_HTML.jpg

图 11-7

Android 上的 HelloFX 应用程序

它是如何工作的?

作为一名普通的 Java 开发人员,尤其是 JavaFX 开发人员,您可以专注于 Java APIs,并实现使您的应用程序工作所需的代码。Java 平台本身将确保您的 Java 应用程序被翻译成 Java 字节码,该字节码不依赖于操作系统(例如,Windows、macOS、Linux 变体、iOS)或处理器(例如,ARM 64、Intel x86-64、ARMv6hf)。

典型的 Java 方法是,下一步,在本地系统上执行 Java 字节码,是使用在目标系统上运行的 Java 虚拟机(JVM)来实现的。在这种情况下,JVM 使用特定于目标的本机指令解释字节码并执行它。大多数 JVM 还包含一个实时(JIT)编译器,可以将常用的 Java 方法动态转换为本机代码。一般来说,本机代码比解释代码运行得快得多;因此,Java 应用程序的性能通常会在运行一段时间后得到提高。将 Java 字节码编译成本机代码需要一些时间,而且在此期间应用程序正在运行,因此不会立即达到最佳性能。

在前一章中,我们介绍了用于创建基于 JavaFX 应用程序的本机包的 Graal 本机映像工具。前一章中讨论的优点同样适用于移动设备,而且在移动设备上使用这种方法还有另外一个原因。苹果不允许在 iOS 设备上运行时生成动态代码。因此,典型的 Java 方法,即首先解释代码,然后(在运行时)优化代码,在 iOS 设备上是不允许的。

仅在运行时使用解释器运行移动 Java 应用程序是可能的,但是解释器比执行本地代码慢得多。

最重要的是,一个 JVM 安装可以处理大量应用程序的(旧的)服务器端方法在移动设备上并不常见。移动应用程序是自包含的应用程序,捆绑了它们所有的依赖关系——除了本机操作系统提供的一小组 API。在运行时下载额外的库或组件来满足特定应用程序所需的依赖是绝对不行的。

上一节演示的 Gluon 客户端插件包含将 Java 应用程序转换成字节码所需的工具;调用 Graal 本机映像工具将字节码转换为本机代码,包括所需的 VM 功能;并将结果链接到可部署到移动设备的可执行文件中。

这示意性地显示在图 11-8 中。

img/468104_2_En_11_Fig8_HTML.jpg

图 11-8

本机应用程序工作流

由 Gluon 客户端工具获得的结果原生映像在概念上与使用特定于 OS 的工具(例如,用于创建 iOS 应用的 Xcode 和用于创建 Android 应用的 Android Studio)创建的原生映像没有区别。开发人员仍然需要将这个图像上传到应用程序商店,从而记录应用程序、提供屏幕截图等等。

使用插件选项

默认情况下,插件将使用最佳配置将您的应用程序部署到移动设备上。插件和 GraalVM 中的不同组件分析您的代码及其依赖关系,以决定是否包含原生库、使用反射和 JNI 等等。预计这些分析工具将随着时间的推移而改进。

因为目前不可能用分析工具覆盖所有的边缘情况,所以插件允许开发者设置配置特定的设置(例如,用于反射的附加类,包括本地符号等)。)

联邦主义者

将添加到已包含的默认捆绑包列表中的附加完全限定捆绑包资源列表

     com/sun/javafx/scene/control/skin/resources/controls
     com.sun.javafx.tk.quantum.QuantumMessagesBundle

例如,如果您使用一个用于内部化目的的资源包,比如src/resources/hellofx/hello.properties(和hello_EN.properties等等),您将需要使用 Gradle,

bundlesList = ["hellofx.hello"]

或者使用 Maven

<bundlesList>
    <list>hellofx.hello</list>
</bundlesList>

资源列表

将添加到默认资源列表中的附加资源模式或扩展的列表,默认资源列表已经包括

   png, jpg, jpeg, gif, bmp, ttf, raw
   xml, fxml, css, gls, json, dat,
   license, frag, vert, obj

例如,如果你正在使用一个属性文件(没有包含在资源包中),比如src/resources/hellofx/logging.properties,你将需要使用 Gradle,

resourcesList = ["properties"]

或者使用 Maven

<resourcesList>
    <list>properties</list>
</resourcesList>

反射列表

将添加到默认反射列表中的附加完全限定类的列表,默认反射列表已经包含了大多数 JavaFX 类。

当前列表被添加到下的文件中

{build/target}/gluonfx/gvm/reflectionconfig-$target.json

金利斯特

将添加到默认 JNI 列表中的附加完全限定类的列表,该列表已经包含了大多数 JavaFX 类。

当前列表被添加到下的文件中

{build/target}/gluonfx/gvm/jniconfig-$target.json

运行代理任务/目标

作为反射和 JNI 列表的替代,runAgent 任务在桌面上运行项目,与javafx-maven-plugin、GraalVM 的 JVM (HotSpot)和native-image-agent相结合来记录 Java 应用程序的行为。它为反射、JNI、资源、代理和序列化生成配置文件,这些文件将由本机映像生成使用。

如果需要,这个目标应该在其他目标之前执行,并且需要用户干预来发现所有可到达的类,通过运行 Gradle:

    ./gradlew runAgent

或者与 Maven 一起运行:

    mvn gluonfx:runagent

创建真正的移动应用

虽然为桌面创建的 JavaFX 应用程序可以在移动设备上运行,但在许多情况下,您可以通过调整应用程序以适应移动应用来改善用户体验。这样做,你最终得到的是特定于移动设备的代码,而不是特定于桌面设备的代码,你可能会问,与用本地语言进行移动开发相比,这有什么优势。不过,这有一些很好的理由:

  • 您的大部分应用程序代码仍然可以共享。所有的业务逻辑,还有很大一部分 UI 代码,都可以在移动端和桌面端共享。

  • 有了合适的框架(例如,Glisten),许多现有的 JavaFX 控件都可以进行移动应用。在这种情况下,使用完全相同的 JavaFX 代码。

  • 对于移动和桌面之间完全不同的 UI 组件,至少代码都是用 Java 编写的,可以由相同的开发人员编写,由相同的工具编译,并集成到相同的 CI 基础架构中。

一个好的客户端应用程序架构可以清晰地将业务逻辑和 UI 组件分开。在 JavaFX 中,应用程序的 UI 部分可以被进一步分离。数据通常保存在ObservableObjectObservableList的实例中,它们与 UI 组件的渲染细节没有直接关系——因此,它们可以被认为是通用组件的一部分。

有许多方法可以使 JavaFX 应用程序的 UI 组件更加移动化,我们将简要讨论以下内容:

  • 对移动和桌面使用不同的样式表。

  • 对手机使用特定的控制。

Gluon 的 glorin 框架[参见 https://docs.gluonhq.com/#_glisten_apis ]是 Gluon Mobile 的一部分,结合了这两种方法。

不同的样式表

JavaFX 用户界面灵活且易于更改的原因之一是因为对 CSS 的支持,这将在第五章“掌握可视化和 CSS 设计”中讨论样式表使用 CSS 文件以声明的方式定义了用户界面不同组件的外观。它们与实现逻辑是分离的,这允许许多组合。

看看我们在本章前面讨论的 HelloFX 应用程序的代码,在 JavaFX 应用程序中添加样式表很容易,例如,在应用程序的start方法中:

        Scene scene = new Scene(root, 640, 480);
        scene.getStylesheets().add(
            HelloFX.class.getResource("styles.css").toExternalForm());

在这段代码中,我们将名为styles.css的样式表添加到场景图中。我们在这里使用的样式表非常简单,它描述了一个Label控件中文本的样式:

.label {
    -fx-text-fill: blue;
}

我们可以创建第二个样式表,将-fx-text-fill属性设置为不同的颜色,例如红色。

我们可以将styles.css文件复制到styles2.css并编辑如下:

.label {
    -fx-text-fill: red;
}

我们现在修改应用程序,以便加载这个样式表,而不是原来的样式表:

        Scene scene = new Scene(root, 640, 480);
        scene.getStylesheets().add(
            HelloFX.class.getResource("styles2.css").toExternalForm());

如果我们现在运行这个应用程序,我们会看到文本颜色确实发生了变化,如图 11-9 所示。

这显示了通过提供不同的样式表来重新配置应用程序的用户界面是多么容易,但是我们现在为所有部署更改了样式表。我们真正想要的是基于目标平台加载不同的样式表。

一个简单的解决方案是检查系统属性" os.name ",并基于此加载不同的样式表,如下面的代码片段所示:

        Scene scene = new Scene(root, 640, 480);
        If (System.getProperty("os.name").equals("ios")) {
            scene.getStylesheets().add(
                HelloFX.class.getResource("styles.css").toExternalForm());
        } else {
            scene.getStylesheets().add(
                HelloFX.class.getResource("styles2.css").toExternalForm());
        }

这个代码片段将导致在 iOS 系统(设备或模拟器)上运行的情况下应用"styles.css"样式表,在所有其他情况下将使用"styles2.css"样式表。

img/468104_2_En_11_Fig9_HTML.png

图 11-9

带有 CSS 的 HelloFX 应用程序

有了 Gluon Mobile,你可以走得更远。与 Gluon Mobile 捆绑的组件之一是开源框架 Gluon Attach,它是在 https://github.com/gluonhq/attach 开发的。

Gluon Attach 包含许多服务,这些服务公开了一个 Java API,并使用特定的 API 在不同的平台上实现。由 Gluon Attach 实现的示例服务有位置、存储、应用内计费、图片、蓝牙低能耗等等。

使用 Attach,可以检测平台(例如 iOS 或 Android)和尺寸(例如手机或平板电脑)。这允许为 iPad 系统加载一个特定的样式表,为 Android 手机加载另一个特定的样式表。

下面的代码片段展示了如何检测到这一点,它将在每种情况下使用不同的样式表:

        Scene scene = new Scene(root, 640, 480);
        if (Platform.isIOS()) {
            scene.getStylesheets().add(
                HelloFX.class.getResource("styles.css").toExternalForm());
        } else if (Platform.isAndroid()) {
            scene.getStylesheets().add(
                HelloFX.class.getResource("styles2.css").toExternalForm());
        }

在这种情况下,我们利用com.gluonhq.attach.util.Platform来获取应用程序运行的当前平台。

为了能够导入这个类,我们需要 build.gradle 文件中的依赖项,如清单 11-8 所示。

repositories {
    mavenCentral()
    maven {
        url 'https://nexus.gluonhq.com/nexus/content/repositories/releases'
    }
}
dependencies {
    implementation "com.gluonhq:charm-glisten:6.0.6"
    implementation "com.gluonhq.attach:util:4.0.11"
}

Listing 11-8Adding Attach to a Gradle project

我们在 pom 文件中需要类似的依赖,如清单 11-9 所示。

<dependencies>
    <dependency>
        <groupId>com.gluonhq</groupId>
        <artifactId>charm-glisten</artifactId>
        <version>6.0.6</version>
    </dependency>
    <dependency>
        <groupId>com.gluonhq.attach</groupId>
        <artifactId>util</artifactId>
        <version>4.0.11</version>
    </dependency>
</dependencies>
 <repositories>
    <repository>
        <id>Gluon</id>
        <url>
          https://nexus.gluonhq.com/nexus/content/repositories/releases
        </url>
    </repository>
</repositories>

Listing 11-9Adding Attach to a Maven project

特定于手机的控件

虽然我们可以用样式表做很多事情,但是有些控件确实只与移动设备相关。

为移动设备创建一个控件与为桌面应用程序创建一个控件没有什么不同,这已经在第七章“桥接 Swing 和 JavaFX”中讨论过了

Gluon Mobile 包含了许多特定于移动设备的控件,这些控件在典型的移动应用程序中经常遇到。这些控件的列表位于

https://docs.gluonhq.com/charm/javadoc/6.0.6/com.gluonhq.charm.glisten/com/gluonhq/charm/glisten/control/package-summary.html

作为一个例子,我们将展示一个使用FloatingActionButton控件的应用程序。这个项目可以在这里找到(Gradle 和 Maven):

https://github.com/modernclientjava/mcj-samples/tree/master/ch11-Mobile/Gradle/HelloGluon
https://github.com/modernclientjava/mcj-samples/tree/master/ch11-Mobile/Maven/HelloGluon

《守则》

清单 11-10 显示了build.gradle文件,清单 11-11 显示了 Gradle 项目的settings.gradle文件。

pluginManagement {
    repositories {

gradlePluginPortal()
    }
}

rootProject.name = 'HelloGluon'

Listing 11-11File settings.gradle

plugins {
    id 'application'
    id 'org.openjfx.javafxplugin' version '0.0.10'
    id 'com.gluonhq.gluonfx-gradle-plugin' version '1.0.3'
}

repositories {
    mavenCentral()
    maven {
        url 'https://nexus.gluonhq.com/nexus/content/repositories/releases/'
    }
}

dependencies {
    implementation "com.gluonhq:charm-glisten:6.0.6"
}

gluonfx {
    target = 'host'
    if (project.hasProperty('target')) {
        target = project.getProperty('target')
    }

    attachConfig {
        version = "4.0.11"
        services 'display', 'lifecycle', 'statusbar', 'storage'
    }

}

javafx {
    version = "17-ea+16"
    modules = [ "javafx.controls" ]
}

mainClassName = "$moduleName/com.gluonhq.hello.HelloGluon"

Listing 11-10File build.gradle

如果您有一个 Maven 项目,清单 11-12 显示了等价的 pom 文件。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.gluonhq.hello</groupId>
    <artifactId>hellogluon</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>hellogluon</name>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>11</maven.compiler.release>
        <javafx.version>17.0.1</javafx.version>
        <attach.version>4.0.11</attach.version>
        <mainClassName>com.gluonhq.hello.HelloGluon</mainClassName>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>com.gluonhq</groupId>
            <artifactId>charm-glisten</artifactId>
            <version>6.0.6</version>
        </dependency>
        <dependency>
            <groupId>com.gluonhq.attach</groupId>
            <artifactId>display</artifactId>
            <version>${attach.version}</version>
        </dependency>
        <dependency>
            <groupId>com.gluonhq.attach</groupId>
            <artifactId>lifecycle</artifactId>
            <version>${attach.version}</version>
        </dependency>
        <dependency>
            <groupId>com.gluonhq.attach</groupId>
            <artifactId>statusbar</artifactId>
            <version>${attach.version}</version>
        </dependency>
        <dependency>
            <groupId>com.gluonhq.attach</groupId>
            <artifactId>storage</artifactId>
            <version>${attach.version}</version>
        </dependency>
        <dependency>
            <groupId>com.gluonhq.attach</groupId>
            <artifactId>util</artifactId>
            <version>${attach.version}</version>
        </dependency>
    </dependencies>

    <repositories>
        <repository>
            <id>Gluon</id>
            <url>https://nexus.gluonhq.com/nexus/content/repositories/releases</url>
        </repository>
    </repositories>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
            </plugin>

            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.6</version>
                <configuration>
                    <mainClass>${mainClassName}</mainClass>
                </configuration>
            </plugin>

            <plugin>
                <groupId>com.gluonhq</groupId>
                <artifactId>gluonfx-maven-plugin</artifactId>
                <version>1.0.7</version>
                <configuration>
                    <target>${gluonfx.target}</target>
                    <attachList>
                        <list>display</list>
                        <list>lifecycle</list>
                        <list>statusbar</list>
                        <list>storage</list>
                    </attachList>
                    <mainClass>${mainClassName}</mainClass>
                </configuration>
            </plugin>
        </plugins>

    </build>

    <profiles>
        <profile>
            <id>ios</id>
            <properties>
                <gluonfx.target>ios</gluonfx.target>
            </properties>
        </profile>
        <profile>
            <id>android</id>
            <properties>
                <gluonfx.target>android</gluonfx.target>
            </properties>
        </profile>
    </profiles>
</project>

Listing 11-12pom.xml file

清单 11-13 显示了HelloGluon主类,清单 11-14 显示了 styles.css 文件。

.label {
    -fx-font-size: 2em;
    -fx-text-fill: -primary-swatch-700;
}

Listing 11-14File styles.css

package hellofx;

import com.gluonhq.attach.display.DisplayService;
import com.gluonhq.attach.util.Platform;
import com.gluonhq.charm.glisten.application.MobileApplication;
import com.gluonhq.charm.glisten.control.AppBar;
import com.gluonhq.charm.glisten.control.FloatingActionButton;
import com.gluonhq.charm.glisten.mvc.View;
import com.gluonhq.charm.glisten.visual.MaterialDesignIcon;
import com.gluonhq.charm.glisten.visual.Swatch;
import javafx.geometry.Dimension2D;
import javafx.geometry.Pos;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.VBox;

public class HelloGluon extends MobileApplication {
    @Override
    public void init() {
        addViewFactory(HOME_VIEW, () -> {
            FloatingActionButton fab =
                new FloatingActionButton(MaterialDesignIcon.SEARCH.text,
                    e -> System.out.println("Search"));
            ImageView imageView = new ImageView(new Image(
                HelloGluon.class.getResourceAsStream("openduke.png")));
            imageView.setFitHeight(200);
            imageView.setPreserveRatio(true);
            Label label = new Label("Hello, Gluon Mobile!");
            VBox root = new VBox(20, imageView, label);
            root.setAlignment(Pos.CENTER);
            View view = new View(root) {
                @Override
                protected void updateAppBar(AppBar appBar) {
                    appBar.setTitleText("Gluon Mobile");
                }
            };
            fab.showOn(view);
            return view;
        });
    }

    @Override
    public void postInit(Scene scene) {
        Swatch.LIGHT_GREEN.assignTo(scene);
        scene.getStylesheets().add(
            HelloGluon.class.getResource("styles.css").toExternalForm());
        if (Platform.isDesktop()) {
            Dimension2D dimension2D = DisplayService.create()
                    .map(display -> display.getDefaultDimensions())
                    .orElse(new Dimension2D(640, 480));
            scene.getWindow().setWidth(dimension2D.getWidth());
            scene.getWindow().setHeight(dimension2D.getHeight());
        }
    }
    public static void main(String[] args) {
        launch();
    }
}

Listing 11-13HelloFX.java file

构建项目

第一步是将项目作为一个常规的 Java 项目来构建和运行(在一个用于本地开发的常规 JVM 上,例如 HotSpot)。

与格雷尔:

    ./gradlew clean build run

使用 Maven:

    mvn clean gluonfx:run

结果如图 11-10 所示。

img/468104_2_En_11_Fig10_HTML.jpg

图 11-10

在桌面上运行 HelloGluon

一旦项目准备就绪,我们现在将在 iOS 上编译、打包和运行应用程序(这同样适用于 Android)。

编译链接

与 Gradle 一起运行:

    ./gradlew -Ptarget=ios nativeBuild

或者与 Maven 一起运行:

    mvn -Pios gluonfx:build

运行

与 Gradle 一起运行:

    ./gradlew -Ptarget=ios nativeRun

或者与 Maven 一起运行:

    mvn -Pios gluonfx:nativerun

结果如图 11-11 所示。

img/468104_2_En_11_Fig11_HTML.jpg

图 11-11

iOS 上的 HelloGluon 应用程序

摘要

JavaFX 应用程序非常适合部署在移动设备上。JavaFX 平台和 Graal 原生图像组件的结合,集成在 Gluon 移动客户端包中,使所有 Java 开发人员能够使用他们的 Java 技能,创建可以上传到流行的移动应用商店的应用。

有许多工具可以帮助开发人员以非常熟悉的方式将 Java 和 JavaFX 应用程序部署到移动设备上。

为了使应用程序真正对移动设备友好并适应移动设备环境,可以使用许多框架(例如,Gluon Attach 和 Glisten)。

十二、树莓派上的 JavaFX 17

由 José Pereda 撰写

在本章中,您将了解如何开始使用 Raspberry Pi 设备,以及运行 Java 和 JavaFX 17 应用程序所需的步骤,讨论进行本地或远程开发的方法以及如何进行远程部署。

您将看到不同的示例,从非常基本的 Java 和 JavaFX 应用程序开始,最后您将看到一个更复杂的项目,该项目试图在 GPS 设备的帮助下创建一个自制的车载导航系统。

树莓派简介

树莓派和 Arduino 是持续了 10 多年的创客运动的基石。但这些也是物联网(IoT)的基础,多年来,物联网不仅在业余爱好者中,而且在许多工业领域都得到了发展。它们甚至在 STEAM(科学、技术、工程、艺术和数学)计划中发挥了更大的作用,该计划直接针对我们孩子的教育。

事实上,Raspberry Pi 是作为一种小型、廉价的计算机诞生的,旨在供孩子们在学校的早期阶段学习编程。作为覆盆子的立国之本( www.raspberrypi.org )

我们的使命是将计算和数字制作的力量传递到世界各地的人们手中。

作为证明,Raspberry Pi 的常见发行版预装了 Scratch、Python 或 Java。截至 2021 年初,已售出超过 4000 万台,其中大部分对应于 2016 年发布的 Raspberry Pi 3 Model B,以及 2019 年发布的最新型号 Raspberry Pi 4 Model B。

无论你是一个业余爱好者,你从事专业的物联网项目,还是你有想要学习计算的孩子,都有很多理由可以让你考虑在一个树莓派上做一个非常小的投资。

本章将简要介绍如何开始使用它,以及如何在这个嵌入式设备上编程和运行 Java 和 JavaFX 应用程序。

树莓派入门

如何入门可以跟着 www.raspberrypi.org/documentation/

初始套件

在那里,您将根据您的预算找到所需的组件。以下是入门和完成本章示例的最低要求。

树莓派

如果你还没做过,买一个树莓派 4 型号 B 1 。您必须选择 1gb、2gb、4gb 或 8 GB 内存(取决于您的需求和预算)。

其主要规格如下:

  • SoC: Broadcom BCM2711,Cortex-A72 (ARMv8),64 位 SoC,1.5 GHz

  • GPU:500 MHz 的 Broadcom VideoCore VI

  • RAM: 1、2、4 或 8 GB LPDDR2 SDRAM

  • Wi-Fi 和蓝牙:2.4 GHz 和 5 GHz IEEE 802.11.b/g/n/ac 无线局域网,蓝牙 5.0

  • 网络:基于 USB 2.0 的千兆以太网

  • 图形:H.264 MPEG-4 解码(1080 p30);H.264 编码(1080 p30);OpenGL ES 3.1/3.2 图形

  • 通用输入/输出(GPIO):扩展的 40 引脚 GPIO 接头

  • 端口:2 个 USB 2.0 端口,2 个 USB 3.0 端口,2 个通过 micro-HDMI 的 HDMI;CSI 摄像机端口,用于连接 Pi 摄像机;DSI 显示端口,用于连接 Pi 触摸屏显示器;4 极立体声输出和复合视频端口

  • PoE:以太网供电支持

电源适配器

您可以购买完整的启动套件,也可以选择所需的附件,包括至少一个 5 V USB-C 电源适配器和一个 SD 卡。

sd 卡

跟随 www.raspberrypi.org/documentation/setup/

  • 选择 8gb 或 16 GB 的 SD 卡。我会选择 SanDisk Ultra micro sdhc 16 GB Class 10。

    有预装了 NOOBS 的 SD 卡,但也可以使用位于 www.raspberrypi.org/software/ 的 Raspberry Pi Imager 应用程序轻松下载和安装图像。

  • 键盘和鼠标是可选的。两者都需要 USB 连接。

班长

您可以使用任何带有 HDMI 连接的显示器或电视显示器,但有一个专用的 Raspberry Pi 触摸显示器,如下链接所述:

  • 它是一个 7 英寸的 LCD 显示器,通过 DSI 连接器连接到 Raspberry Pi:
 www.raspberrypi.org/documentation/hardware/display/README.md

  • 分辨率:全彩色显示器输出高达 800 × 480(与 HDMI 连接相比不是很好),具有能够检测十个手指的电容触摸感应功能。

  • 需要外接电源(使用另一个 micro-USB 电源比通过同一个 Pi 板连接更方便)。

  • 一个既能安装树莓皮又能展示的好盒子很方便。

  • 需要将显示器旋转 180 度(见本章下文)。

www.raspberrypi.org/products/raspberry-pi-touch-display/

安装 SD

跟随 www.raspberrypi.org/documentation/installation/installing-img/README.md

我从 www.raspberrypi.org/software/operating-systems/ 用桌面软件镜像选择树莓派 OS 2 。当然,你可以选择 Lite(没有桌面和预装软件)或任何其他发行版。

总之:

  • 为您的操作系统下载并安装 Raspberry Pi Imager。打开后,你会看到如图 12-1 所示的图像

img/468104_2_En_12_Fig1_HTML.jpg

图 12-1

在 Mac 上运行 Raspberry Pi 成像仪

  • 选择图像,默认为 Raspberry Pi OS (32 位)带桌面。

    或者,对于 64 位,您可以从 https://downloads.raspberrypi.org/raspios_arm64/img/ 下载图像。下载 zip 文件并解压缩,以提取。iso 文件。然后选择菜单末端的Use custom选项。

  • 插入 SD 卡后,单击选择存储将文件写入其中。你可能需要一个 SD 读卡器。一个 USB SD 卡读卡器简单又非常方便。等到它完成,并提取 SD 卡。

树莓派配置

www.raspberrypi.org/documentation/configuration/。第一次启动时,如果你安装了桌面,它将启动 X11,并显示一个配置对话框。按照说明配置您的语言、键盘设置和 Wi-Fi。应用更改并重新启动。

一旦它重新启动,X11 启动,从顶部菜单,选择首选项➤树莓派配置。

或者,如果您从命令行或通过 SSH 运行,您可以通过运行以下命令获得如图 12-2 所示的基于终端的工具:

$ sudo raspi-config

img/468104_2_En_12_Fig2_HTML.jpg

图 12-2

运行 raspi-config

raspi 配置

www.raspberrypi.org/documentation/configuration/raspi-config.md

  • 配置系统设置:

    • 更改用户密码:Raspbian 上的默认用户是pi,密码是raspberry。你可以在这里改变。为了方便,我将设置相同的密码:pi,这就方便了。当然,它与安全密码完全相反。

    • 主机名:它是该 Pi 在网络上的可见名称。如果需要,更改默认raspberrypi,例如,如果您有多个设备。

    • 无线局域网:如果在第一次 X11 会话中没有设置,请输入 SSID 和密码。它允许使用 Raspberry Pi 4 的内置无线连接来连接到无线网络,并允许您使用 SSH 从您通常的开发机器上远程工作。

    • 引导/自动登录:使用此选项将您的引导首选项更改为控制台或桌面。我们将选择控制台/命令行,但桌面也可以使用。为了方便起见,您也可以选择自动登录。

  • 本地化选项:

    • 如果您之前在第一次 X11 会话期间没有设置它,请从键盘布局、时区、区域设置和 Wi-Fi 国家代码中选取。这些菜单上的所有选项默认为英制或 GB,除非您更改它们。
  • 界面选项:

    • 相机:如果你有,你必须在这里启用它。

    • SSH:启用它;这是远程访问所必需的。

  • 性能选项:

    • GPU 内存:如果要运行 JavaFX,至少为 GPU 设置 256–512 MB。
  • 更新:推荐,但可能需要一段时间(仅当 Pi 连接到网络时)。

  • 完成后,重新启动。

请注意,您可以在/boot/config.txt文件中直接找到应用的设置。

再次登录后,您可以检查 Wi-Fi 设置:

$ sudo nano /etc/wpa_supplicant/wpa_supplicant.conf

您可以根据需要添加任意数量的 SSIDs。你可以用

$ sudo iwlist wlan0 scan

以找到任何给定位置的可用网络。

运行ifconfig查看您的 Pi 是否连接到网络。如图 12-3 所示,wlan0在一个给定的本地 IP 地址上被连接,数据包被接收和发送(RX,TX)。

img/468104_2_En_12_Fig3_HTML.jpg

图 12-3

运行 ifconfig

默认情况下,使用 DHCP。如果您需要一个静态 IP 地址,检查链接 www.raspberrypi.org/documentation/configuration/tcpip/README.md 并运行

$ sudo nano /etc/dhcpcd.conf

配置您的eth0wlan0静态 IP 地址。

如果您有 7 英寸的 Raspberry Pi 显示屏,您需要将其旋转 180 度。编辑config.txt文件:

$ sudo nano /boot/config.txt

在文件末尾添加以下内容:

lcd_rotate=2

然后,保存(Ctrl+O),退出(Ctrl+X)。

最后,请注意,在关闭 Raspberry Pi 之前,千万不要在它打开时从电源上拔下它,以防止损坏文件系统的风险。要获得关闭它的正确方法,请运行

$ sudo shutdown –h now

然后稍等片刻,断开电源。

通过 SSH 的远程连接

大多数时候,我们将通过 SSH 连接到 Raspberry Pi,以无标题方式运行(即 Pi 上没有专用的监视器和键盘),从我们的开发机器直接访问(包括复制/粘贴和两者之间的文件传输选项)。

SSH 内置于 Linux 发行版和 macOS 中。对于 Windows 和移动设备,可以使用第三方 SSH 客户端。在 Linux 和 macOS 上,您可以使用 SSH 从 Linux 电脑、Mac 或其他 Raspberry Pi 连接到您的 Raspberry Pi,而无需安装其他软件。在 Windows 上,最常用的客户端叫做 PuTTY,可以从 greenend.org.uk 下载。 www.raspberrypi.org/documentation/remote-access/ssh/windows10.md

通常您会通过以下方式登录

$ ssh pi@<IP>

您需要提供设备的 IP 地址。如果您已经连接到该 IP,您可以使用hostname –I来查找它,但如果不是这样,您可以尝试使用nmap或移动应用程序(如 Fing)在本地网络中查找该设备的 IP。

通常树莓派使用 DHCP,这意味着它没有固定的地址,重启后它可能会改变。这对于 SSH 连接来说并不方便。我们可以尝试设置一个固定的 IP,或者我们也可以尝试使用它的主机名来连接它,前提是它被广播到网络。这适用于 Raspberry Pi 操作系统,因为它使用多播 DNS 协议。

img/468104_2_En_12_Fig4_HTML.jpg

图 12-4

启动 SSH 会话

因为 macOS 和 Linux 都使用 Bonjour,所以都支持 mDNS。在 Windows 上,你可以从这里安装 Bonjour,比如 https://support.apple.com/kb/DL999

在这种情况下,您可以通过hostname.local登录

$ ssh pi@raspberrypi.local

输入密码后,您将可以访问树莓派(图 12-4 )。您将第一次看到安全性/真实性警告。键入 yes 继续。

最后,将您的开发机器的 SSH 公钥添加到设备上是很方便的,因此当运行 SSH 或 SCP 命令时,您不会一直被提示输入密码:

$ ssh-copy-id pi@<IP>

更多详情可以看看 www.raspberrypi.org/documentation/remote-access/ssh/passwordless.md

Java 11

树莓派 OS 自带 Java for ARM 安装。如果你运行java –version,它会打印出来

$ java -version
openjdk version "11.0.11" 2021-04-20
OpenJDK Runtime Environment (build 11.0.11+9-post-Debian-1deb10u1)
OpenJDK 64-Bit Server VM (build 11.0.11+9-post-Debian-1deb10u1, mixed mode)

但是,如果您在没有安装 Java 的发行版上运行(比如 Raspberry Pi OS Lite ),您可以很容易地用

$ sudo apt update
$ sudo apt install default-jdk

测试 Java 11

让我们测试 Java 11 和启动单文件源代码程序特性:

$ cd /home/pi/
$ mkdir ModernClients
$ cd ModernClients
$ nano Test.java

添加一个如清单 12-1 所示的 main 方法,或者从 Sample0: https://github.com/modernclientjava/mcj-samples/tree/master/ch12-RaspberryPi/Sample0 中复制文件。

public class Test {
    public static void main(String... args) {
        System.out.println("Hello Java " +
             System.getProperty("java.version") + " for ARM!");
    }
}

Listing 12-1Sample0

保存,退出(Ctrl+O,Ctrl+X),然后运行:

$ java Test.java

应打印出图 12-5 的结果。

img/468104_2_En_12_Fig5_HTML.jpg

图 12-5

在树莓派上运行 Java 11

祝贺您在全新的 Raspberry Pi 上运行您的第一个 Java 11 应用程序!现在 Java 已经成功安装了,您可以进入下一步:安装 JavaFX。

安装 JavaFX 17

ARM 32 或 AArch64 的 JavaFX 17 builds 可以从 https://gluonhq.com/products/javafx 下载。这些 JavaFX 源代码与桌面平台(Windows、macOS、Linux)上使用的 Java FX 源代码完全相同,但是具有针对 ARM 和 32/64 位的特定驱动程序。注意 Linux 的常规 JavaFX 发行版不能工作,因为它是为 x86-64 构建的。

32 位

从 SSH 会话中,下载 SDK,将其移动到/opt,并解压缩:

$ wget https://gluonhq.com/download/javafx-17-ea-sdk-linux-arm32/ -O openjfx-17-ea+14_linux-arm32_bin-sdk.zip
$ sudo mv openjfx-17-ea+14_linux-arm32_bin-sdk.zip /opt
$ cd /opt
$ sudo unzip openjfx-17-ea+14_linux-arm32_bin-sdk.zip
$ sudo rm openjfx-17-ea+14_linux-arm32_bin-sdk.zip

如果您查看 lib 文件夹下的文件列表,您会发现不同 JavaFX 模块的 jar,以及 ARM 的本地库。

Note

虽然您会发现 media 和 web JavaFX 模块,但 ARM 尚不支持这些模块。摇摆也不是。

64 位

从 SSH 会话中,下载 SDK,将其移动到/opt,并解压缩:

$ wget https://gluonhq.com/download/javafx-17-ea-sdk-linux-aarch64-monocle/ -O openjfx-17-ea+14_monocle-linux-aarch64_bin-sdk.zip
$ sudo mv openjfx-17-ea+14_monocle-linux-aarch64_bin-sdk.zip /opt
$ cd /opt
$ sudo unzip openjfx-17-ea+14_monocle-linux-aarch64_bin-sdk.zip
$ sudo rm openjfx-17-ea+14_monocle-linux-aarch64_bin-sdk.zip

如果您查看 lib 文件夹下的文件列表,您会发现不同 JavaFX 模块的 jar,以及 ARM 的本地库。

Note

JavaFX SDK 64 bits 支持 ARM 上的媒体和 web JavaFX 模块。不支持 Swing。

直接呈现管理器(DRM)

DRM 是一个内核模块,它提供对直接渲染基础架构客户端的直接硬件访问。

Raspberry Pi 支持开源的 VC4/V3D DRM 驱动。与 Raspberry Pi 4 捆绑在一起的 GPU 是 Broadcom VideoCore VI,支持 OpenGL ES 3.2 并使用 V3D 驱动程序,而 Raspberry Pi 3 中的 Broadcom VideoCore IV 只能支持 OpenGL ES 2.0 并使用 VC4 驱动程序。

如果尚未启用,要访问硬件加速,您可以启用可选的覆盖编辑config.txt文件

$ sudo nano /boot/config.txt

在文件的末尾,您应该有

# Enable DRM VC4 V3D drive
dtoverlay=vc4-fkms-v3d

然后,保存您所做的任何修改(Ctrl+O),并退出(Ctrl+X)。如果需要,重新启动。

您应该检查设备/dev/dri/card0(或/dev/dri/card1)是否存在。

运行 JavaFX 应用程序

为了支持硬件加速渲染,JavaFX 依赖于许多低级驱动程序和库,这些驱动程序和库并不总是默认安装在所有嵌入式系统上。例如,Raspberry Pi OS Lite 发行版就是这种情况,您可以使用

$ sudo apt install libegl-mesa0 libegl1 libgbm1 libgles2 libpango-1.0.0 libpangoft2-1.0-0

JavaFX 对 DRM 的支持是 Gluon 的商业扩展。你可以通过设置环境变量ENABLE_GLUON_COMMERCIAL_EXTENSIONS来启用它,如果你的应用是非商业性的,或者如果你从 Gluon 获得了一个有效的许可(访问 https://gluonhq.com/contact-embedded/ )。

要在当前会话中启用它,请运行

$ export ENABLE_GLUON_COMMERCIAL_EXTENSIONS=true

或者将其添加到您的。bash 文件(为了方便起见,我们也导出PATH_TO_FX):

$ nano /home/pi/.bashrc
export ENABLE_GLUON_COMMERCIAL_EXTENSIONS=true
export PATH_TO_FX=/opt/javafx-sdk-17/lib

保存并退出。

要为任何远程会话启用它,将它添加到environment文件也很方便:

$ nano /etc/environment
ENABLE_GLUON_COMMERCIAL_EXTENSIONS=true

保存并退出。

样本 1

现在让我们试着从链接 https://github.com/modernclientjava/mcj-samples/tree/master/ch12-RaspberryPi/Sample1 运行 HelloFX 示例,它是基于 https://openjfx.io/openjfx-docs/ 示例的。

清单 12-2 包含了从应用程序 JavaFX 类扩展而来的 HelloFX Java 类的代码。

package org.modernclients.raspberrypi;
import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.control.Label;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;

public class HelloFX extends Application {
    @Override
    public void start(Stage stage) {
        String javaVersion = System.getProperty("java.version");
        String javafxVersion = System.getProperty("javafx.version");
        Label label = new Label("Hello, JavaFX " + javafxVersion +
            ", running on Java " + javaVersion + ".");
        Scene scene = new Scene(new StackPane(label), 800, 480);
        stage.setScene(scene);
        stage.setTitle("Hello JavaFX");
        stage.show();
    }
    public static void main(String[] args) {
        launch(args);
    }
}

Listing 12-2Sample1

要现在运行它,从 SSH 会话中,让我们首先克隆带有示例的存储库:

$ cd /home/pi/Downloads
$ wget https://github.com/modernclientjava/mcj-samples/archive/master.zip
$ unzip master.zip
$ mv mcj-samples-master /home/pi/ModernClients

现在输入样本 1:

$ cd /home/pi/ModernClients/ch12-RaspberryPi/Sample1
$ javac --module-path $PATH_TO_FX --add-modules=javafx.controls \
    src/org/modernclients/raspberrypi/HelloFX.java -d dist
$ sudo -E java --module-path $PATH_TO_FX --add-modules=javafx.controls \
    -Dmonocle.platform=EGL -Dembedded=monocle -Dglass.platform=Monocle \
    -Dmonocle.egl.lib=$PATH_TO_FX/libgluon_drm-1.1.6.so \
    -cp dist/. org.modernclients.raspberrypi.HelloFX

该应用程序将会运行,但只会在连接的显示器上显示。您可以从 SSH 终端使用 Ctrl+C 退出应用程序。或者,您也可以尝试终止 Java 进程:

$ sudo killall -9 java

如果一切都按预期运行,您将得到图 12-6 中的结果,流程的输出将显示如下

[GluonDRM] use GPU at /dev/dri/card0 and display id -1

但是,如果该过程失败,并且您收到关于设备不具备 DRM 功能的警告,您可以再次尝试在前面的命令行中添加以下选项:

-Degl.displayid=/dev/dri/card1

img/468104_2_En_12_Fig6_HTML.png

图 12-6

在树莓派上运行 JavaFX 11

JavaFX 鼠标事件需要写权限才能访问硬件,这就是为什么我们需要使用sudo;否则,应用程序将会启动,但会在控制台上显示一个异常:

Udev: Failed to write to /sys/class/input/mice/uevent

检查您是否有权限访问输入设备:

java.io.FileNotFoundException: /sys/class/input/mice/uevent (Permission denied)
    at java.base/java.io.FileOutputStream.open0(Native Method)
...
    at javafx.graphics/com.sun.glass.ui.monocle.SysFS.write(SysFS.java:121)
...

既然我们已经运行了示例,我们可以试着从 X11 会话中运行它,只要我们安装了带有桌面软件的 Raspberry Pi 操作系统。

从现在的树莓派,我们运行startx;然后我们打开一个终端,输入

$ cd /home/pi/ModernClients/ch12-RaspberryPi/Sample1
$ sudo java --module-path $PATH_TO_FX --add-modules=javafx.controls \
    -cp dist/. org.modernclients.raspberrypi.HelloFX

由于我们已经移除了单片眼镜选项,现在我们有了一个常规的窗口应用程序(图 12-7 )。我们可以用鼠标关闭它并停止 Java 进程。

img/468104_2_En_12_Fig7_HTML.png

图 12-7

在 X11 上运行 JavaFX

样本 2

现在让我们通过使用 Gradle 或 Maven 这样的构建工具,并利用 Maven Central 上提供的 ARM 32/AArch64 的 JavaFX 构件,来尝试简化命令行过程。

无论如何,你还是需要检索胶子 DRM 库。您可以在/opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so下下载包含它的整个 SDK,也可以从以下网址下载:

  • 32 位:

  • 64 位:

$ sudo wget http://download2.gluonhq.com/drm/lib-1.1.6/arm32/libgluon_drm.so -O /opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so

$ sudo wget http://download2.gluonhq.com/drm/lib-1.1.6/aarch64/libgluon_drm.so -O /opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so

格拉德尔

让我们运行样本 2:

$ cd /home/pi/ModernClients/ch12-RaspberryPi/Sample2/

清单 12-3 中显示的 build.gradle 文件包含任务run,该任务添加了运行进程所需的 JVM 参数。

plugins {
  id 'application'
}

repositories {
    mavenCentral()
}

def osArch = System.properties['os.arch']
def version = "17-ea+14"
def platform = osArch == "arm" ? "linux-arm32-monocle" :
              "linux-aarch64-monocle"

mainClassName = "org.modernclients.raspberrypi.HelloFX"

dependencies {
    implementation "org.openjfx:javafx-base:$version:$platform"
    implementation "org.openjfx:javafx-graphics:$version:$platform"
    implementation "org.openjfx:javafx-controls:$version:$platform"
}

compileJava {
    doFirst {
        options.compilerArgs = [
                '--module-path', classpath
                      .filter(j -> j.toString().contains(osArch)).asPath,
                '--add-modules', 'javafx.controls'
        ]
    }
}

run {
    doFirst{
        environment "ENABLE_GLUON_COMMERCIAL_EXTENSIONS", "true"
        jvmArgs = [
                '-Dmonocle.platform=EGL', '-Dembedded=monocle',
                '-Dglass.platform=Monocle',
                "-Dmonocle.egl.lib=
                   /opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so",
                '--module-path', classpath
                      .filter(j -> j.toString().contains(osArch)).asPath,
                '--add-modules', 'javafx.controls'
        ]
    }
}

Listing 12-3Sample2 build.gradle file

你可以直接跑

$ ./gradlew run

第一次它将下载 Gradle 7.0.1,并且它将创建一个 Gradle 守护进程,所以它可能需要一段时间才能开始。

按 Ctrl+C 退出应用程序。请注意,有时应用程序不会关闭,因为仍然有一些 Gradle 守护线程在运行。您可以通过找到 Java 进程的 ID 来阻止它们

$ ps –aux
$ sudo kill <pid of Java process>

或者直接与

$ sudo killall -9 java

还要注意的是,您可以通过编辑文件让 sudo 访问 Gradle 进程

$ nano gradlew

并在最后加上

exec sudo "$JAVACMD"...

保存并退出(Ctrl+O,Ctrl+X)。

专家

另一种选择是使用 Maven 工具和javafx-maven-plugin。您可以很容易地在您的 Pi 上安装 Maven

$ sudo apt-get install maven

清单 12-4 显示了运行示例所需的 pom.xml 文件。

<project xmlns:="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.modernclients.raspberrypi</groupId>
    <artifactId>hellofx</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>11</maven.compiler.release>
        <javafx.version>17-ea+14</javafx.version>
        <main.class>org.modernclients.raspberrypi.HelloFX</main.class>
        <runtime.jvm.options/>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
    </dependencies>

    <profiles>
        <profile>
            <id>default</id>
            <activation>
                <activeByDefault>true</activeByDefault>
            </activation>
        </profile>
        <profile>
            <id>pi</id>
            <properties>
                <runtime.jvm.options>-Dmonocle.platform=EGL,
                  -Dembedded=monocle,-Dglass.platform=Monocle,
         -Dmonocle.egl.lib=/opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so
            </runtime.jvm.options>
            </properties>
        </profile>
    </profiles>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
            </plugin>
            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>0.0.8</version>
                <configuration>
                    <mainClass>${main.class}</mainClass>
                    <options>${runtime.jvm.options}</options>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

Listing 12-4Sample2 pom.xml file

在桌面上,你可以运行它

mvn javafx:run

在你的树莓皮上,你可以尽情奔跑

mvn -Ppi -Djavafx.monocle=true javafx:run

注意,现在所有的 JavaFX 依赖项都是从 Maven Central 下载的,我们必须使用-Djavafx.monocle=true来选择包含 Monocle 的依赖项。

远程运行 JavaFX 应用程序

虽然这些项目是在 Raspberry Pi 上本地编译和构建的,但与在您的机器上构建相比,它要慢得多,而且 IDE 的缺乏或 SSH 开发的不便促使人们寻找不同的方法:在您的常规机器上开发,然后在 Pi 上部署和运行。

另一方面,我们机器上的开发要快得多,但是我们仍然有部署问题:我们需要将应用程序的相关文件复制到 Raspberry Pi,然后才能在上面运行。

有几个选项可以复制所需的文件,比如经典的 FTP 甚至 SCP(通过 SSH 发送文件的命令)。这意味着你可以在电脑之间复制文件,从你的 Raspberry Pi 到你的台式机或笔记本电脑,反之亦然。

例如,假设我们有样本 3: modernclientjava/mcj-samples/tree/master/ch12-RaspberryPi/Sample3。我们在机器上用 Maven 编译并构建它,然后将结果类复制到 Raspberry Pi:

$ cd mcj-samples-master/ch12-RaspberryPi/Sample3
$ mvn clean compile
$ cd ..
$ scp -r Sample3 pi@raspberrypi.local:/home/pi/ModernClients/ch12-RaspberryPi/Sample3
(add password)

现在,我们可以从 SSH 终端运行:

$ cd /home/pi/ModernClients/ch12-RaspberryPi/Sample3/target
$ sudo -E java --module-path $PATH_TO_FX:classes -Dmonocle.platform=EGL \
     -Dembedded=monocle -Dmonocle.egl.lib=
          /opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so \
    -Dglass.platform=Monocle \
    -m hellofx/org.modernclients.raspberrypi.MainApp

虽然这是可行的,但这是一个乏味且容易出错的手动过程,如果我们能够将这一步骤集成到我们的 IDE 中,或者我们能够为我们的构建工具提供一个插件,那就更好了。

让我们检查一些选项。

Java 远程平台

NetBeans 不久前提出了远程平台的概念。您可以在另一台机器上定义 JVM 的设置,并使用 Ant 任务在那台机器上部署和运行 SSH。

这对于树莓派来说非常方便。

要安装 Apache NetBeans 12.4,您可以访问链接 https://netbeans.apache.org/download/nb124/nb124.html 并选择适合您平台的安装程序。

安装完成后,请转到工具➤ Java 平台。单击添加平台…并选择远程 Java 标准版。

提供关于平台的一些细节:远程平台的名称,(例如Pi 17);主机(可以是raspberrypi.local);用户,pi;密码;和远程 JRE 路径,/usr/lib/jvm/java-11-openjdk-arm64。见图 12-8 。

img/468104_2_En_12_Fig8_HTML.jpg

图 12-8

远程平台配置

创建远程平台后,确保将sudo添加到 exec 前缀,如图 12-9 所示。

最后,在 Raspberry Pi 可用的情况下,单击 Test Platform,看到测试成功了。否则,请确保所有字段都设置正确。

让我们用一个例子来试试远程平台。按照这里的说明, https://openjfx.io/openjfx-docs/#IDE-NetBeans ,在没有构建工具的情况下创建一个新的 Java 应用程序,或者从链接 https://github.com/modernclientjava/mcj-samples/tree/master/ch12-RaspberryPi/Sample4 下载 Sample4。

首先,确保应用在你的机器上运行良好。

现在在 NetBeans 上,编辑Properties,选择RunRuntime Platform,挑选Pi 17。提供一个配置名,如Pi17。确保提供 JavaFX SDK 的路径,并在 VM 选项中包含 Monocle 选项,如图 12-10 所示

--module-path /opt/javafx-sdk-17/lib --add-modules=javafx.controls -Dembedded=monocle -Dglass.platform=Monocle

并关闭对话框。

img/468104_2_En_12_Fig10_HTML.png

图 12-10

设置项目属性以在远程平台上运行

img/468104_2_En_12_Fig9_HTML.png

图 12-9

向远程平台添加 exec 前缀

当在桌面或 Raspberry Pi 上运行相同的应用程序时,可以方便地根据它运行的平台来调整它的窗口大小,如清单 12-5 所示。

String platform = System.getProperty("glass.platform");
Rectangle2D bounds;
if ("Monocle".equals(platform)) {
    bounds = Screen.getPrimary().getBounds();
} else {
    bounds = new Rectangle2D(0, 0, 600, 400);
}
Scene scene = new Scene(
    new StackPane(label), bounds.getWidth(), bounds.getHeight());

Listing 12-5Configure window size based on platform

现在从 Pi17 配置再次运行。您将在 NetBeans 输出窗口中看到连接详细信息:

Connecting to raspberrypi.local:22
cmd : mkdir -p '/home/pi/NetBeansProjects//Sample4/dist'
Connecting to raspberrypi.local:22
done.
profile-rp-calibrate-passwd:
Connecting to raspberrypi.local:22
cmd : cd '/home/pi/NetBeansProjects//Sample4';
'/usr/lib/jvm/java-11-openjdk-arm64/bin/java'  -Dfile.encoding=UTF-8
 --module-path=/opt/javafx-sdk-17/lib -Dmonocle.platform=EGL
-Dembedded=monocle -Dmonocle.egl.lib=
      /opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so
-Dglass.platform=Monocle --add-modules=javafx.controls  -jar /home/pi/NetBeansProjects//Sample4/dist/HelloFX11.jar

您将在 Pi 的显示屏上看到您的应用程序运行良好,同时您可以在 NetBeans 输出窗口中看到该过程的输出。您甚至可以调试应用程序。

但是,这种方法有几个问题:它只能在 NetBeans 上工作,对于 Maven 或 Gradle 项目无效。

Gradle SSH Plugin

另一个选项是来自 https://gradle-ssh-plugin.github.io 的 SSH Gradle 插件。它将在终端或任何支持 Gradle 的 IDE 上运行 Gradle 项目。

现在让我们从 IntelliJ(或者您选择的任何 IDE)运行这个示例, https://github.com/modernclientjava/mcj-samples/tree/master/ch12-RaspberryPi/Sample5

编辑build.gradle文件,并验证所需的配置:工作目录、Java home、JavaFX 路径和您的远程配置(主机、用户和密码),如清单 12-6 所示。

plugins {
    id 'application'
    id 'org.openjfx.javafxplugin' version '0.0.10'
    id 'org.hidetake.ssh' version '2.10.1'
}
repositories {
    mavenCentral()
}
javafx {
    modules = [ 'javafx.controls', 'javafx.fxml' ]
}
mainClassName = "$moduleName/org.modernclients.raspberrypi.MainApp"
def workingDir = '/home/pi/ModernClients/ch12-RaspberryPi/
def javaHome = '/usr'
def javafxHome = '/opt/javafx-sdk-17/lib'
task libs(type: Copy) {
    dependsOn 'jar'
    into "${buildDir}/libs/"
    from configurations.compileClasspath
}
remotes {
    pi17 {
        host = 'raspberrypi.local'
        user = 'pi'
        password = 'pi'
    }
}
task runRemoteEmbedded {
    dependsOn 'libs'
    ssh.settings {
        knownHosts = allowAnyHosts
    }
    doLast {
        ssh.run {
            session(remotes.pi17) {
                execute "mkdir -p ${workingDir}/${project.name}/dist"

                fileTree("${buildDir}/libs")
                        .filter { it.isFile() && ! it.name.startsWith('javafx')}
                        .files
                        .each { put from:it,
                    into: "${workingDir}/${project.name}/dist/${it.name}"}
                executeSudo "-E ${javaHome}/bin/java -Dfile.encoding=UTF-8 " +
                        "--module-path=${javafxHome}/lib:
                        ${workingDir}/${project.name}/dist " +
                        "-Dmonocle.platform=EGL -Dembedded=monocle
                         -Dglass.platform=Monocle " +
                        "-Dmonocle.egl.lib=
                           ${javafxHome}/libgluon_drm-1.1.6.so " +
                          "-classpath '${workingDir}/${project.name}/dist/*' " +
                        "-m ${project.mainClassName}"
            }
        }
    }
}

Listing 12-6Gradle build file for Sample5

Note

为了方便,任务设置allowAnyHosts,主机密钥检查关闭。它将打印一条警告消息,指出该过程容易受到中间人攻击,不建议将其用于生产。

有了这个插件,在终端按 Ctrl+C 只会杀死 Gradle 进程,而不会杀死应用程序。要解决这个问题,一定要在用户界面上添加一个“退出”按钮。

从 IDE Gradle 的窗口运行runRemoteEmbedded任务,如图 12-11 ,或者从终端运行:

$ ./gradlew runRemoteEmbedded

应用程序将被构建、部署到 Pi 并在其上执行,您将在您的终端中获得流程的输出,如图 12-11 所示。

img/468104_2_En_12_Fig11_HTML.png

图 12-11

执行 runRemoteEmbedded 任务

创建 JavaFX 本机映像

您可以创建 JavaFX 应用程序的本机映像,并在 Raspberry Pi 上运行它,唯一的要求是 AArch64 (64 位)架构是当前唯一受支持的架构。

鉴于设备的硬件限制和本机映像进程的高 CPU/内存要求,创建本机映像的推荐方法是从桌面 Linux 机器(x86-64)进行交叉编译。

这台机器应该有从 https://github.com/gluonhq/graal/releases/latest 下载并安装的 GraalVM for Linux,包括

$ export GRAALVM_HOME=/path/to/graalvm-svm-linux-gluon-21.2.0-dev

然后,对于交叉编译,您还需要

$ sudo apt-get install g++-aarch64-linux-gnu

清单 12-7 显示了定义了pi概要文件的样本 6 的 pom 文件。它包括一些可能会根据您的设置而改变的属性。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.modernclients.raspberrypi</groupId>
    <artifactId>hellofx</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>jar</packaging>

    <name>HelloFX</name>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.release>11</maven.compiler.release>
        <javafx.version>17-ea+14</javafx.version>
        <javafx.maven.plugin.version>0.0.6</javafx.maven.plugin.version>
        <gluonfx.maven.plugin.version>1.0.3</gluonfx.maven.plugin.version>
        <runtime.jvm.options/>
        <runtime.options/>
        <remote.host.name/>
        <remote.dir/>
        <main.class>org.modernclients.raspberrypi.HelloFX</main.class>
    </properties>

    <dependencies>

        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
            </plugin>

            <plugin>
                <groupId>org.openjfx</groupId>
                <artifactId>javafx-maven-plugin</artifactId>
                <version>${javafx.maven.plugin.version}</version>
                <configuration>
                    <mainClass>${main.class}</mainClass>
                    <options>${runtime.jvm.options}</options>
                </configuration>
            </plugin>

            <plugin>
                <groupId>com.gluonhq</groupId>
                <artifactId>gluonfx-maven-plugin</artifactId>
                <version>${gluonfx.maven.plugin.version}</version>
                <configuration>
                    <target>${gluonfx.target}</target>
                    <mainClass>${main.class}</mainClass>
                    <runtimeArgs>${runtime.options}</runtimeArgs>
                    <remoteHostName>${remote.host.name}</remoteHostName>
                    <remoteDir>${remote.dir}</remoteDir>
                </configuration>
            </plugin>
        </plugins>

    </build>

    <profiles>
        <profile>
            <id>pi</id>
            <properties>
                <gluonfx.target>linux-aarch64</gluonfx.target>
                <remote.host.name>pi@raspberrypi.local</remote.host.name>
                <remote.dir>/home/pi/ModernClients/
                     ch12-RaspberryPi/Sample6</remote.dir>
                <runtime.options>-Duse.fullscreen=true,
                     -Dmonocle.platform=EGL,-Dembedded=monocle,
                     -Dglass.platform=Monocle</runtime.options>
                <runtime.jvm.options>-Dmonocle.egl.lib=
                     /opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so,
                     ${runtime.options}</runtime.jvm.options>
            </properties>
        </profile>
    </profiles>

</project>

Listing 12-7Maven pom file for Sample6

在您的 Linux 机器上获得 Sample6:

$ wget https://github.com/modernclientjava/
mcj-samples/archive/refs/heads/master.zip -O ~/Downloads/ModernClients.zip
$ unzip ~/Downloads/ModernClients.zip
$ cd ~/Downloads/mcj-samples-master/ch12-RaspberryPi/Sample6

现在可以使用以下代码构建本机映像

$ mvn -Ppi gluonfx:build

这将运行编译阶段,并将编译后的对象链接到可执行文件中。几分钟后,一旦该过程完成,您就可以将二进制文件部署到您的 Pi,前提是您已经在 pom 中正确定义了remoteHostNameremoteDir:

$ mvn -Ppi gluonfx:install

最后,您可以通过 SSH 从您的机器上运行,使用

$ mvn -Ppi gluonfx:nativerun

按 Ctrl+C 完成应用程序。

或者,您也可以从命令行直接在 Pi 上运行本机映像

$ cd /home/pi/ModernClients/ch12-RaspberryPi/Sample6
$ sudo -E ./HelloFX -Dmonocle.platform=EGL \
-Dembedded=monocle -Dglass.platform=Monocle

使用依赖项

到目前为止,我们已经看到了非常简单的用例,它们有助于我们开始并正确设置一切。

现在我们来看一个更复杂的例子。

DIY 车载导航系统

以下项目是一个自制车载导航系统的概念验证。为此,我们将在树莓派上安装 GPS。JavaFX 应用程序将显示一张地图,GPS 读数将用于在我们当前位置的地图中心。

材料清单

树莓派 4 型号 B

7″显示屏 800×480;笼子是可选的,但建议使用。

树莓派和显示器的 5 V 电源适配器。电源组是可选的,但建议用于现场测试。

GPS:通用异步收发器(UART)系列 GPS Neo-7M (micro-USB 可选)(图 12-12 ),比如这个: http://wiki.keyestudio.com/index.php/KS0319_keyestudio_GPS_Module

用于 GPIO 连接的四根母-母跳线。

微型 USB: USB 适配器(如果 GPS 分线架安装微型 USB)是可选的。

GPS 天线是可选的(但使用时,必须移除电容器 C2)。

img/468104_2_En_12_Fig12_HTML.jpg

图 12-12

UART 串行 GPS Neo-7M。图片来自 http://wiki.keyestudio.com/File:KS0319.png

GPIO 设置

我们将使用通用输入/输出(GPIO)引脚从 GPS 获取串行读数。

Raspberry Pi 串行端口由两个信号组成,一个发送信号(TxD)和一个接收信号(RxD),可在 4 Model B 上的引脚 8 和 10 处获得(相当于图 12-13 中的接线 Pi 编号 15 和 16,按此顺序)。

img/468104_2_En_12_Fig13_HTML.jpg

图 12-13

树莓派 4 型号 B GPIO 引脚排列。图片来自 https://pi4j.com/assets/documentation/headerpins_in_header.png

默认情况下,Raspberry Pi 上的串行端口被配置为控制台端口,用于与 Linux OS shell 通信。为了从软件程序访问串行端口,我们必须对其进行配置。打开一个 SSH 会话,运行

$ sudo raspi-config

选择接口选项,现在选择串行端口。

现在,您必须选择No来禁用登录 shell 对串行的访问,然后选择Yes来启用硬件串行端口(或通用异步收发器的 UART)。接受并重启你的树莓派。

GPIO 连接

GPS 模块需要四个连接,可使用四根跨接线母-母连接;参见图 12-14 ,从 GPIO 引脚的右到左:

  • VCC 引脚连接到引脚 2(电源 5 V),红色跳线

  • GND 引脚连接到引脚 6(接地),黄色跳线

  • RXD 引脚连接到引脚 8 (TxD UART,WiringPi 15),绿色跳线

  • TXD 引脚连接到引脚 10 (RxD UART,WiringPi 16),蓝色跳线

img/468104_2_En_12_Fig14_HTML.png

图 12-14

GPS 和 Raspberry Pi 4 型 GPIO 连接

请注意,可以使用分线板和引脚带状电缆,将 GPIO 引脚延伸到试验板,这样可以更容易地连接到 GPS。

所需的 GPS 软件

我们需要从终端安装以下软件

$ sudo apt-get install gpsd gpsd-clients

其中gpsd是 GPS 接收器的接口守护程序。完成后,如果已经连接了 GPS,您可以从编号最小的串行端口开始读取,方法是

$ gpsd /dev/ttyS0

或者,如果您已将 USB 连接到

gpsd /dev/ttyUSB0

启动gpsd的最佳选择是这项服务:

$ sudo service gpsd start

服务启动后,您可以使用以下命令验证其状态

$ sudo systemctl status gpsd.socket

这将显示如图 12-15 所示的内容。

img/468104_2_En_12_Fig15_HTML.png

图 12-15

gpsd 服务已启动

如果需要,您可以通过编辑文件来修改默认设置

$ sudo nano /etc/default/gpsd

一旦一切正常运行,您就可以使用

$ cgps /dev/ttyS0

或者用

$ gpsmon /dev/ttyS0

如果你在室内,全球定位系统很可能无法连接到任何卫星,你不会收到任何价值。但是你仍然会得到一些读数。

如果你把你的 Raspberry Pi 带到户外,只要 Wi-Fi 连接保持,你仍然可以通过 SSH 连接到你的机器,并可视化这些读数,得到如图 12-16 所示的东西。

img/468104_2_En_12_Fig16_HTML.png

图 12-16

gpsd 服务处于活动状态并正在运行

NMEA 读物

NMEA 是国家海洋电子协会的首字母缩写,NMEA 0183 是所有 GPS 制造商支持的标准数据格式,使用 ASCII 串行通信协议。有不同的消息类型或句子,它们都以标题$GP和句子代码开始,如GLL代表地理位置、纬度、经度,以*和校验和结尾。一条可能的消息看起来像这样

$GPGLL,5139.69658,N,00947.18207,W,200557.00,A,A*72

要了解所有可能的句子以及如何解析它们,可以看这个链接: http://aprs.gids.nl/nmea/

清单 12-8 显示了我们将在应用程序中使用的模型类,用于跟踪来自 GPS 的一些变量,如纬度、经度、高度或卫星数量,清单 12-9 显示了最重要的 NMEA 消息(如 GPRMC 或 GPGGA)的可能解析器。

package org.modernclients.raspberrypi.gps.service;
import javafx.beans.property.FloatProperty;
import javafx.beans.property.Property;
import org.modernclients.raspberrypi.gps.model.GPSPosition;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.function.UnaryOperator;
import java.util.logging.Logger;
public class NMEAParser {
    private static final Logger logger =
        Logger.getLogger(NMEAParser.class.getName());
    interface SentenceParser {
        boolean parse(String [] tokens, GPSPosition position);
    }
    private static final Map<String, SentenceParser> sentenceParsers =
        new HashMap<>();
    private final GPSPosition position;
    public NMEAParser(GPSPosition position) {
        this.position = position;
        sentenceParsers.put("GPGGA", new GPGGA());
        sentenceParsers.put("GPGGL", new GPGGL());
        sentenceParsers.put("GPRMC", new GPRMC());
        sentenceParsers.put("GPRMZ", new GPRMZ());
        sentenceParsers.put("GPVTG", new GPVTG());
    }
    public GPSPosition parse(final String line) {
        if (line.startsWith("$") && checksum(line)) {
            String[] tokens = line.substring(1).split(",");
            String type = tokens[0];
            if (sentenceParsers.containsKey(type)) {
                sentenceParsers.get(type).parse(tokens, position);
            }
            position.updatefix();
        }

        return position;
    }
    // parsers
    class GPGGA implements SentenceParser {
        @Override
        public boolean parse(String [] tokens, GPSPosition position) {
            parseCoordinate(tokens[2], tokens[3], "S",
                position.latitudeProperty());
            parseCoordinate(tokens[4], tokens[5], "W",
                position.longitudeProperty());
            doParse(tokens[1], Float::parseFloat, position.timeProperty());
            doParse(tokens[6], Integer::parseInt, position.qualityProperty());
            doParse(tokens[7], Integer::parseInt, position.satellitesProperty());
            return doParse(tokens[9], Float::parseFloat,
                position.altitudeProperty());
        }
    }
    class GPGGL implements SentenceParser {
        @Override
        public boolean parse(String [] tokens, GPSPosition position) {
            parseCoordinate(tokens[1], tokens[2], "S",
                position.latitudeProperty());
            parseCoordinate(tokens[3], tokens[4], "W",
                position.longitudeProperty());
            return doParse(tokens[5], Float::parseFloat, position.timeProperty());
        }
    }
    class GPRMC implements SentenceParser {
        @Override
        public boolean parse(String [] tokens, GPSPosition position) {
            doParse(tokens[1], Float::parseFloat, position.timeProperty());
            parseCoordinate(tokens[3], tokens[4], "S",
                position.latitudeProperty());
            parseCoordinate(tokens[5], tokens[6], "W",
                position.longitudeProperty());
            doParse(tokens[7], Float::parseFloat, position.velocityProperty());
            return doParse(tokens[8], Float::parseFloat,
                position.directionProperty());
        }

    }
    class GPVTG implements SentenceParser {
        @Override
        public boolean parse(String [] tokens, GPSPosition position) {
            return doParse(tokens[3], Float::parseFloat,
                position.directionProperty());
        }
    }
    class GPRMZ implements SentenceParser {
        @Override
        public boolean parse(String [] tokens, GPSPosition position) {
            return doParse(tokens[1], Float::parseFloat,
                position.altitudeProperty());
        }
    }
    private boolean parseCoordinate(String token, String direction, String
        defaultDirection, FloatProperty property) {
        if (token == null || token.isEmpty() || direction == null ||
            direction.isEmpty()) {
            return false;
        }
        int minutesPosition = token.indexOf('.') - 2;
        if (minutesPosition < 0) {
            return false;
        }

        float minutes = Float.parseFloat(token.substring(minutesPosition));
        float decimalDegrees = Float.parseFloat(token.substring(minutesPosition))
            / 60.0f;
        float degree = Float.parseFloat(token) - minutes;
        float wholeDegrees = (int) degree / 100;
        float coordinateDegrees = wholeDegrees + decimalDegrees;
        if (direction.startsWith(defaultDirection)) {
            coordinateDegrees = -coordinateDegrees;
        }
        property.setValue(coordinateDegrees);
        return true;
    }
    private <T> boolean doParse(String token, Function<String, T> operator,
        Property<T> property) {
        if (token == null || token.isEmpty()) {
            return false;
        }
        try {
            property.setValue(operator.apply(token));
            return true;
        } catch (NumberFormatException nfe) { }
        return false;
    }
    private static boolean checksum(String line) {
        if (line == null || ! line.contains("$") || ! line.contains("*")) {
            return false;
        }
        String sentence = line.substring(1, line.lastIndexOf("*"));
        String lineChecksum = "0x" + line.substring(line.lastIndexOf("*") + 1);
        int c = 0;
        for (char s : sentence.toCharArray()) {
            c ^= s;
        }
        String hex = String.format("0x%02X", c);
        boolean result = hex.equals(lineChecksum);
        if (! result) {
            logger.warning("There was an error in the checksum of " + line);
        }
        return result;
    }
}

Listing 12-9NMEAParser class

package org.modernclients.raspberrypi.gps.model;
import javafx.beans.property.*;
public class GPSPosition {
    // time
    private final FloatProperty time = new SimpleFloatProperty(this, "time");
    public final FloatProperty timeProperty() { return time; }
    // getter & setter
    // latitude
    private final FloatProperty latitude = new SimpleFloatProperty(this,
        "latitude");
    public final FloatProperty latitudeProperty() { return latitude; }
    // getter & setter
    // longitude
    private final FloatProperty longitude = new SimpleFloatProperty(this,
        "longitude");
    public final FloatProperty longitudeProperty() { return longitude; }
    // getter & setter
    // direction
    private final FloatProperty direction = new SimpleFloatProperty(this,
        "direction");
    public final FloatProperty directionProperty() { return direction; }
    // getter & setter
    // altitude
    private final FloatProperty altitude = new SimpleFloatProperty(this,
        "altitude");
    public final FloatProperty altitudeProperty() { return altitude; }
    // getter & setter
    // velocity
    private final FloatProperty velocity = new SimpleFloatProperty(this,
        "velocity");
    public final FloatProperty velocityProperty() { return velocity; }
    // getter & setter
    // satellites
    private final IntegerProperty satellites = new SimpleIntegerProperty(this,
        "satellites");
    public final IntegerProperty satellitesProperty() { return satellites; }
    // getter & setter
    // quality
    private final IntegerProperty quality = new SimpleIntegerProperty(this,
         "quality");
    public final IntegerProperty qualityProperty() { return quality; }
    // getter & setter
    // fixed
    private final BooleanProperty fixed = new SimpleBooleanProperty(this,
        "fixed");
    public final BooleanProperty fixedProperty() { return fixed; }
    // getter & setter
    public void updatefix() {
        fixed.set(quality.get() > 0);
    }

    @Override
    public String toString() {
        return "GPSPosition{" +
                "time=" + time.get() +
                ", latitude=" + latitude.get() +
                ", longitude=" + longitude.get() +
                ", direction=" + direction.get() +
                ", altitude=" + altitude.get() +
                ", velocity=" + velocity.get() +
                ", quality=" + quality.get() +
                ", satellites =" + satellites.get() +
                ", fixed=" + fixed.get() +
                '}';
    }
}

Listing 12-8GPSPosition class

GPIO 和 Java
Pi4J

Pi4J 是一个 Java 库,可用于访问 Raspberry Pi 的 GPIO 引脚。正如你可以在 http://pi4j.com/ 读到的

这个项目旨在为 Java 程序员提供一个友好的面向对象的 I/O API 和实现库,以访问 Raspberry Pi 平台的全部 I/O 功能。这个项目抽象了低级本机集成和中断监控,使 Java 程序员能够专注于实现他们的应用程序业务逻辑。

我们将使用它最新的稳定版本 1.4,所以我们只需要在构建中包含依赖关系:

dependencies {
    implementation 'com.pi4j:pi4j-core:1.4'
}

使用 Pi4J,创建一个Serial对象就像

Serial serial = SerialFactory.createInstance();

然后,我们可以向它添加一个侦听器,这样我们就可以对任何传入的串行事件做出反应,并且我们可以根据通常的设置来配置串行。

注意,这个库只能在 Raspberry Pi 上运行,但是它可以在您的机器上使用和编译。

威灵皮

在使用 Pi4J 之前,必须在 Raspberry Pi: WiringPi 上提供一个本机依赖项。

虽然 Raspberry Pi 3 型号内置了它,但在型号 4 B 上,您需要执行以下步骤来安装最新(非官方)版本:

sudo apt-get remove wiringpi -y
sudo apt-get --yes install git-core gcc make
cd ~/Downloads
git clone https://github.com/WiringPi/WiringPi --branch master
   --single-branch wiringpi
sudo ~/buildings/wiringpi/build

GPS 服务

清单 12-10 显示了打开串口并开始监听串行事件的服务类,一个接一个地提取从gpsd进程接收到的所有句子。

package org.modernclients.raspberrypi.gps.service;
import com.pi4j.io.gpio.GpioController;
import com.pi4j.io.gpio.GpioFactory;
import com.pi4j.io.serial.*;
import javafx.application.Platform;
import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;
import org.modernclients.raspberrypi.gps.model.GPSPosition;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import java.io.IOException;
import java.nio.charset.Charset;
import java.util.logging.Logger;
public class GPSService {
    private static final Logger logger =
        Logger.getLogger(GPSService.class.getName());
    @Inject
    private GPSPosition gpsPosition;
    private Serial serial;
    private GpioController gpio;
    private NMEAParser nmea;
    private StringBuilder gpsOutput;
    private final StringProperty line = new SimpleStringProperty();
    @PostConstruct
    private void postConstruct() {
        if (!"monocle".equals(System.getProperty("embedded"))) {
            return;
        }
        nmea = new NMEAParser(gpsPosition);
        gpsOutput = new StringBuilder();
        gpio = GpioFactory.getInstance();
        serial = SerialFactory.createInstance();
        serial.addListener(event -> {
            try {
                String s = event.getString(Charset.defaultCharset())
                        .replaceAll("\n", "")
                        .replaceAll("\r", "");
                gpsOutput.append(s);
                processReading();
            } catch (IOException e) {
                logger.warning("Error processing event " + event);
                e.printStackTrace();
            }

        });
        SerialConfig config = new SerialConfig();
        try {
            String defaultPort = SerialPort.getDefaultPort();
            logger.info("Connecting to default port = " + defaultPort);
            config.device(defaultPort)
                    .baud(Baud._9600)
                    .dataBits(DataBits._8)
                    .parity(Parity.NONE)
                    .stopBits(StopBits._1)
                    .flowControl(FlowControl.NONE);
            serial.open(config);
            logger.info("Connected: " + serial.isOpen());
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }
    private void processReading() {
        if (gpsOutput == null || gpsOutput.toString().isEmpty()) {
            return;
        }
        String reading = gpsOutput.toString().trim();
        if (!reading.contains("$")) {
            return;
        }
        String[] split = reading.split("\\$");
        for (int i = 0; i < split.length - 1; i++) {
            String line = "$" + split[i];
            gpsOutput.delete(0 , line.length());
            if (line.length() > 1) {
                logger.fine("GPS: " + line);
                Platform.runLater(() -> {
                    nmea.parse(line);
                    this.line.set(line);
                });
            }

            if (i == split.length - 2) {
                gpsOutput.insert(0, "$");
            }
        }
    }
    public final StringProperty lineProperty() {
        return line;
    }
    public void stop() {
        logger.info("Stopping Serial and GPIO");
        if (serial != null) {
            try {
                serial.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        if (gpio != null) {
            gpio.shutdown();
        }
    }
}

Listing 12-10GPSService class

当从一个串行端口读取时,我们必须意识到我们有一个连续的字节流,所以我们必须把它们正确地转换成字符串并取出每个句子。这就是processReading方法在单个StringBuilder的帮助下所做的事情。

另外,注意这个线程是在后台运行的,所以每当有一个新句子时,我们将在 JavaFX 应用程序线程上使用Platform::runLater和 JavaFX 属性一起使用它。

对于每个句子,我们将调用 NMEA 解析器并用新值更新 GPSPosition 对象。

用户界面

现在让我们定义 JavaFX 接口:我们将显示一张地图,该地图将以从 GPS 读数中检索到的纬度和经度坐标为中心。

胶子图

Gluon Maps ( https://gluonhq.com/labs/maps/ )是一个开源的 JavaFX 11+库,它提供了一个地图查看器组件,从 OpenStreetMap 呈现基于图块的地图。此处项目可用: https://github.com/gluonhq/maps

我们可以在视图的中心添加一个MapView容器,并使用一个MapLayer来呈现我们的位置。在内置 GPS 传感器的移动设备上,我们可以使用胶子定位服务,但在 Raspberry Pi(或任何连接了 GPS 传感器的台式机)上,我们可以使用前面文本中列出的GPSService

要添加地图,我们需要以下依赖项:

repositories {
    mavenCentral()
    maven {
       url 'https://nexus.gluonhq.com/nexus/content/repositories/releases/'
    }
}
dependencies {
    implementation 'com.gluonhq:maps:2.0.0-ea+4'
    implementation 'com.gluonhq.attach:storage:4.0.11:desktop'
    implementation 'com.gluonhq.attach:util:4.0.11'
}

为了方便起见,我们将定义一个PoiLayer,它可以基于纬度和经度将 JavaFX 节点放置在基本地图的顶部,这将是我们感兴趣的点(清单 12-11 )。

package org.modernclients.raspberrypi.gps.view;
import com.gluonhq.maps.MapLayer;
import com.gluonhq.maps.MapPoint;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.geometry.Point2D;
import javafx.scene.Node;
import javafx.util.Pair;
public class PoiLayer extends MapLayer {
    private final ObservableList<Pair<MapPoint, Node>> points;
    public PoiLayer() {
        points = FXCollections.observableArrayList();
    }
    public void addPoint(MapPoint p, Node icon) {
        points.add(new Pair(p, icon));
        this.getChildren().add(icon);
        this.markDirty();
    }
    @Override
    protected void layoutLayer() {
        for (Pair<MapPoint, Node> candidate : points) {
            MapPoint point = candidate.getKey();
            Node icon = candidate.getValue();
            Point2D mapPoint = getMapPoint(point.getLatitude(),
                point.getLongitude());
            icon.setVisible(true);
            icon.setTranslateX(mapPoint.getX());
            icon.setTranslateY(mapPoint.getY());
        }
    }
}

Listing 12-11PoiLayer class

加力燃烧室

Afterburner 是一个方便的极简 MVP 框架,基于 Adam Bien 的配置约定和依赖注入,可以在这个链接找到: https://github.com/AdamBien/afterburner.fx 。为了使用它,我们需要

repositories {
    mavenCentral()
}
dependencies {
    implementation 'com.airhacks:afterburner.fx:1.7.0'
    implementation 'javax.annotation:javax.annotation-api:1.3.2'
}

场景构建器

最后,我们将使用 https://gluonhq.com/products/scene-builder/ 的场景构建器 16.0 在我们的机器上用 FXML 设计 UI。

将胶子图导入到 Scene Builder 自定义控件库中很方便(图 12-17 )。

img/468104_2_En_12_Fig17_HTML.png

图 12-17

将胶子贴图导入场景构建器

然后我们可以创建一个新的 FXML 文件,带有一个顶部的BorderPane容器,并拖放所需的组件:一个工具栏在顶部,一个MapView在中间,一个VBox在右边带有标签以显示当前的 GPSPosition 值,一个ListView在底部显示 NMEA 句子(图 12-18 )。

img/468104_2_En_12_Fig18_HTML.png

图 12-18

在场景构建器中设计用户界面

请注意,使用加力燃烧室,我们将创建以下文件:

Java 类:

  • 从 FXMLView 扩展而来的org.modernclients.raspberrypi.gps.view.UIView(清单 12-12 ),FXML view 是一个方便的容器,按照惯例负责加载 FXML、CSS 或属性文件

  • org.modernclients.raspberrypi.gps.view.UIPresenter(列表 12-16 )

资源文件:

  • org . modern clients . raspberrypi . GPS . view . ui . fxml(列表 12-13 )

  • org . modern clients . raspberrypi . GPS . view . ui . CSS(列表 12-14 )

  • org . modern clients . raspberrypi . GPS . view . ui . properties(列表 12-15 )

button.show.log=Show Log
button.zoom.in=+
button.zoom.out=-
button.exit=Exit
label.time=Time
label.position=Position
label.altitude=Altitude
label.direction=Direction
label.speed=Speed
label.quality=Quality
label.satellites=Number of Satellites
label.gps=GPS Status: {0}
label.gps.fixed=fixed
label.gps.not-fixed=not fixed

Listing 12-15ui.properties file

.box {
    -fx-padding: 20;
    -fx-spacing: 10;
    -fx-border-color: darkgray;
    -fx-border-width: 0 0 0 1;
}
.label.gps-data {
    -fx-text-fill: blue;
    -fx-font-size: 1.1em;
}
.label.gps {
    -fx-text-fill: darkgray;
    -fx-font-size: 1.0em;
}

Listing 12-14ui.css file

<?xml version="1.0" encoding="UTF-8"?>
<?import com.gluonhq.maps.MapView?>
<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.control.ListView?>
<?import javafx.scene.control.Separator?>
<?import javafx.scene.control.ToggleButton?>
<?import javafx.scene.control.ToolBar?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.Pane?>
<?import javafx.scene.layout.VBox?>
<BorderPane fx:id="pane" xmlns:="http://javafx.com/javafx/11.0.1" xmlns:fx="http://javafx.com/fxml/1" fx:controller="org.modernclients.raspberrypi.gps.view.UIPresenter">
   <bottom>
      <ListView fx:id="listView" maxHeight="200.0" BorderPane.alignment="CENTER" />
   </bottom>
   <right>
      <VBox prefHeight="250.0" prefWidth="200.0" styleClass="box"
         BorderPane.alignment="CENTER">
         <children>
            <VBox>
               <children>
                  <Label styleClass="gps" text="%label.time" />
                  <Label fx:id="timeLabel" styleClass="gps-data" />
               </children>
            </VBox>
            <VBox>
               <children>
                  <Label styleClass="gps" text="%label.position" />
                  <Label fx:id="positionLabel" styleClass="gps-data" />
               </children>
            </VBox>
            <VBox>
               <children>
                  <Label styleClass="gps" text="%label.altitude" />
                  <Label fx:id="altitudeLabel" styleClass="gps-data" />
               </children>
            </VBox>
            <VBox>
               <children>

                  <Label styleClass="gps" text="%label.direction" />
                  <Label fx:id="directionLabel" styleClass="gps-data" />
               </children>
            </VBox>
            <VBox layoutX="10.0" layoutY="112.0">
               <children>
                  <Label styleClass="gps" text="%label.speed" />
                  <Label fx:id="speedLabel" styleClass="gps-data" />
               </children>
            </VBox>
            <VBox layoutX="10.0" layoutY="146.0">
               <children>
                  <Label styleClass="gps" text="%label.quality" />
                  <Label fx:id="qualityLabel" styleClass="gps-data" />
               </children>
            </VBox>
            <VBox layoutX="10.0" layoutY="146.0">
               <children>
                  <Label styleClass="gps" text="%label.satellites" />
                  <Label fx:id="satellitesLabel" styleClass="gps-data" />
               </children>
            </VBox>
         </children>
      </VBox>
   </right>
   <top>
      <ToolBar BorderPane.alignment="CENTER">
         <items>
            <Label fx:id="statusLabel" styleClass="gps-data" text="%label.gps" />
            <Pane maxWidth="1.7976931348623157E308" prefWidth="200.0" />
            <Separator orientation="VERTICAL" />
            <ToggleButton fx:id="showLog"
               mnemonicParsing="false" text="%button.show.log" />
            <Separator layoutX="324.0" layoutY="10.0" orientation="VERTICAL" />
            <Button mnemonicParsing="false"
                onAction="#onZoomIn" text="%button.zoom.in" />
            <Button layoutX="10.0" layoutY="10.0" mnemonicParsing="false"
                onAction="#onZoomOut" text="%button.zoom.out" />
            <Separator layoutX="440.0" layoutY="10.0" orientation="VERTICAL" />
            <Button layoutX="20.0" layoutY="20.0"
               mnemonicParsing="false" onAction="#onExit"
               text="%button.exit" />
         </items>

         <padding>
            <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
         </padding>
      </ToolBar>
   </top>
   <center>
      <MapView fx:id="mapView" BorderPane.alignment="CENTER" />
   </center>
</BorderPane>

Listing 12-13ui.fxml file

package org.modernclients.raspberrypi.gps.view;
import com.airhacks.afterburner.views.FXMLView;
import java.util.ResourceBundle;
public class UIView extends FXMLView {
    public UIView() {
        this.bundle = ResourceBundle.getBundle(bundleName);
    }
}

Listing 12-12UIView class

一旦我们有了所有这些文件,现在是时候添加演示者了(清单 12-16 )。

package org.modernclients.raspberrypi.gps.view;
import com.gluonhq.maps.MapPoint;
import com.gluonhq.maps.MapView;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.fxml.FXML;
import javafx.scene.control.Label;
import javafx.scene.control.ListView;
import javafx.scene.control.ToggleButton;
import javafx.scene.layout.BorderPane;
import javafx.scene.paint.Color;
import javafx.scene.shape.Circle;
import org.modernclients.raspberrypi.gps.model.GPSPosition;
import org.modernclients.raspberrypi.gps.service.GPSService;
import javax.inject.Inject;
import java.text.MessageFormat;
import java.util.ResourceBundle;
import java.util.logging.Logger;

public class UIPresenter {
private static final Logger logger =
    Logger.getLogger(UIPresenter.class.getName());
    @FXML private BorderPane pane;
    @FXML private Label statusLabel;
    @FXML private MapView mapView;
    @FXML private ListView<String> listView;
    @FXML private Label timeLabel;
    @FXML private Label positionLabel;
    @FXML private Label altitudeLabel;
    @FXML private Label directionLabel;
    @FXML private Label speedLabel;
    @FXML private Label qualityLabel;
    @FXML private Label satellitesLabel;
    @FXML private ToggleButton showLog;
    @FXML private ResourceBundle resources;
    @Inject private GPSService service;
    @Inject private GPSPosition gpsPosition;
    private MapPoint mapPoint;
    public void initialize() {
        logger.info("Platform: " + System.getProperty("embedded"));
        mapView = new MapView();
        mapPoint = new MapPoint(50.0d, 4.0d);
        mapView.setCenter(mapPoint);
        mapView.setZoom(15);
        PoiLayer poiLayer = new PoiLayer();
        poiLayer.addPoint(mapPoint, new Circle(7, Color.RED));
        mapView.addLayer(poiLayer);
        pane.setCenter(mapView);
        service.lineProperty().addListener((obs, ov, nv) -> {
            logger.fine(nv);
            listView.getItems().add(nv);
            listView.scrollTo(listView.getItems().size() - 1);
            if (listView.getItems().size() > 100) {
                listView.getItems().remove(0);
            }

        });
        gpsPosition.timeProperty().addListener((obs, ov, nv) -> {
            statusLabel.setText(
               MessageFormat.format(resources.getString("label.gps"),
                  gpsPosition.isFixed() ?
                    resources.getString("label.gps.fixed") :
                    resources.getString("label.gps.not-fixed")));
            mapPoint.update(gpsPosition.getLatitude(),
                 gpsPosition.getLongitude());
            mapView.setCenter(mapPoint);
        });
        timeLabel.textProperty().bind(Bindings.createStringBinding(() -> {
            float time = gpsPosition.getTime();
            int hour = (int) (time / 10000f);
            int min = (int) ((time - hour * 10000) / 100f);
            int sec = (int) (time - hour * 10000 - min * 100);
            return String.format("%02d:%02d:%02d UTC", hour, min, sec);
        }, gpsPosition.timeProperty()));
        positionLabel.textProperty().bind(Bindings.format("%.6f, %.6f",
             gpsPosition.latitudeProperty(),
             gpsPosition.longitudeProperty()));
        altitudeLabel.textProperty().bind(Bindings.format("%.1f m",
            gpsPosition.altitudeProperty()));
        speedLabel.textProperty().bind(Bindings.format("%.2f m/s",
            gpsPosition.velocityProperty()));
        directionLabel.textProperty().bind(Bindings.format("%.2f °",
            gpsPosition.directionProperty()));
        qualityLabel.textProperty().bind(Bindings.format("%d",
            gpsPosition.qualityProperty()));
        satellitesLabel.textProperty().bind(Bindings.format("%d",
            gpsPosition.satellitesProperty()));
        statusLabel.setText(MessageFormat.format(resources.getString("label.gps"),
            resources.getString("label.gps.not-fixed")));
        listView.managedProperty().bind(listView.visibleProperty());
        listView.visibleProperty().bind(showLog.selectedProperty());
        showLog.setSelected(false);
    }

    public void stop() {
        service.stop();
    }
    @FXML private void onExit(){
        Platform.exit();
    }
    @FXML private void onZoomIn() {
        if (mapView.getZoom() < 19) {
            mapView.setZoom(mapView.getZoom() + 1);
        }
    }
    @FXML private void onZoomOut() {
        if (mapView.getZoom() > 1) {
            mapView.setZoom(mapView.getZoom() - 1);
        }
    }
}

Listing 12-16UIPresenter class

GPSPositionGPSService对象被注入到 presenter 中,不同标签的文本属性被绑定到 JavaFX 属性。请注意,在应用程序关闭时停止服务是很重要的。这将关闭串行端口并释放 GPIO 控制器。

应用程序类

我们的主类将为场景创建一个视图并启动应用程序(清单 12-17 )。基于 Raspberry Pi 屏幕设置场景尺寸很重要。

package org.modernclients.raspberrypi.gps;
import com.airhacks.afterburner.injection.Injector;
import javafx.application.Application;
import javafx.geometry.Rectangle2D;
import javafx.scene.Scene;
import javafx.stage.Screen;
import javafx.stage.Stage;
import org.modernclients.raspberrypi.gps.view.UIPresenter;
import org.modernclients.raspberrypi.gps.view.UIView;
public class MainApp extends Application {
    private UIPresenter controller;
    @Override
    public void start(Stage stage) throws Exception {
        Rectangle2D bounds = Screen.getPrimary().getBounds();
        UIView ui = new UIView();
        controller = (UIPresenter) ui.getPresenter();
        Scene scene = new Scene(ui.getView(),
            bounds.getWidth(), bounds.getHeight());
        stage.setTitle("Embedded Maps");
        stage.setScene(scene);
        stage.show();
    }
    @Override
    public void stop() throws Exception {
        controller.stop();
        Injector.forgetAll();
    }
    public static void main(String[] args) {
        launch(args);
    }
}

Listing 12-17MainApp class

最后,清单 12-18 显示了生成模块org.modernclients.raspberrypi.gps的模块信息描述符,清单 12-19 显示了build.gradle文件。

plugins {
    id 'application'
    id 'org.openjfx.javafxplugin' version '0.0.10'
    id 'org.hidetake.ssh' version '2.10.1'
}
repositories {
    mavenCentral()
    maven {
        url 'http://nexus.gluonhq.com/nexus/content/repositories/releases/'
    }
}
dependencies {
    implementation 'com.pi4j:pi4j-core:1.4'
    implementation 'com.gluonhq:maps:2.0.0-ea+4'
    implementation 'com.gluonhq.attach:storage:4.0.11:desktop'
    implementation 'com.gluonhq.attach:util:4.0.11'
    implementation 'com.airhacks:afterburner.fx:1.7.0'
    implementation 'javax.annotation:javax.annotation-api:1.3.2'
}
javafx {
    modules = [ 'javafx.controls', 'javafx.fxml' ]
}
mainClassName = "$moduleName/org.modernclients.raspberrypi.gps.MainApp"
jar {
    manifest {
        attributes 'Main-Class': 'org.modernclients.raspberrypi.gps.MainApp'
    }
}
def workingDir = '/home/pi/ModernClients/ch12-RaspberryPi/Sample7'
def javaHome = '/usr'
def javafxHome = '/opt/javafx-sdk-17/lib'
task libs(type: Copy) {
    dependsOn 'jar'
    into "${buildDir}/libs/"
    from configurations.runtimeClasspath
}
remotes {
    pi17 {
        host = 'raspberrypi.local'
        user = 'pi'
        password = 'pi'
    }
}
task runRemoteEmbedded {
    dependsOn 'libs'
    ssh.settings {
        knownHosts = allowAnyHosts
    }
    doLast {
        ssh.run {
            session(remotes.pi17) {
                execute "mkdir -p ${workingDir}/${project.name}/dist"

                fileTree("${buildDir}/libs")
                        .filter { it.isFile() && ! it.name.startsWith('javafx')}
                        .files
                        .each { put from:it,
                    into: "${workingDir}/${project.name}/dist/${it.name}"}
                executeSudo "-E ${javaHome}/bin/java -Dfile.encoding=UTF-8 " +
                        "--module-path=${javafxHome}/lib:
                        ${workingDir}/${project.name}/dist " +
                        "-Dmonocle.platform=EGL -Dembedded=monocle
                         -Dglass.platform=Monocle " +
                        "-Dmonocle.egl.lib=
                           ${javafxHome}/libgluon_drm-1.1.6.so " +
                          "-classpath '${workingDir}/${project.name}/dist/*' " +
 "-m ${project.mainClassName}"
            }
        }
    }
}

Listing 12-19build.gradle file

module org.modernclients.raspberrypi.gps {
    requires javafx.controls;
    requires javafx.fxml;
    requires pi4j.core;
    requires com.gluonhq.maps;
    requires afterburner.fx;
    requires java.annotation;
    requires java.logging;
    opens org.modernclients.raspberrypi.gps.model to afterburner.fx;
    opens org.modernclients.raspberrypi.gps.service to afterburner.fx;
    opens org.modernclients.raspberrypi.gps.view to afterburner.fx, javafx.fxml;
    exports org.modernclients.raspberrypi.gps;
}

Listing 12-18module-info.java descriptor

完整的项目可以在这里找到:

https://github.com/modernclientjava/mcj-samples/tree/master/ch12-RaspberryPi/Sample7

部署和测试

下载项目,构建它,并运行它,以验证它在您的机器上工作。即使你没有 GPS,它也应该在固定位置显示带有地图的 UI。

然后启动您的 Raspberry Pi,验证显示器和 GPS 已连接并位于室外,并从 SSH 终端启动gpsd服务:

$ sudo service gpsd start

现在从你的机器上运行

$ ./gradlew runRemoteEmbedded

并检查应用程序是否已部署到 Raspberry Pi。如果一切正常,你应该每秒都在读取 GPS 语句并获得更新的经纬度坐标,一张地图将会以你当前的位置为中心(图 12-19 )。

img/468104_2_En_12_Fig19_HTML.png

图 12-19

DIY 车载导航系统运行

您也可以从 SSH 终端直接运行它(或者使用键盘从 Raspberry Pi 运行):

$ cd /home/pi/ModernClients/ch12-RaspberryPi/embeddedGPS/dist
$ sudo -E java -p /opt/javafx-sdk-17/lib:. -Dmonocle.platform=EGL
  -Dembedded=monocle -Dglass.platform=Monocle
  -Dmonocle.egl.lib/opt/javafx-sdk-17/lib/libgluon_drm-1.1.6.so
  -cp . -m org.modernclients.raspberrypi.gps/org.modernclients.raspberrypi.gps.MainApp

下次挑战

如果你能够让它工作,现在你的下一个挑战是让你的树莓派和显示器由一个电源供电,这样你就可以在移动时运行应用程序,无论是走路还是开车。使用网络共享会很方便,用你的移动设备创建一个热点,这样就可以从 OpenStreetMap 下载所需的地图。如本章开头所述,您可以将设备的 SSID 添加到wpa_supplicant.conf文件中。

结论

在本章中,您了解了如何配置 Raspberry Pi 4 Model B 来与 Java 11+和 JavaFX 17 一起工作。在基本示例的帮助下,您看到了如何在本地运行应用程序,以及在常规桌面计算机上进行开发时如何更方便地使用 SSH 和远程部署。

一旦讲述了运行 JavaFX 应用程序的基础知识,您就有机会了解一个更复杂的项目,包括通过 GPIO 引脚连接的 GPS 传感器、解析 NMEA 读数,以及使用带有 Afterburner 框架的 Gluon Scene Builder 创建 UI,其中包括 Gluon 地图,以跟踪您的位置。

虽然 Raspberry Pi 是一款嵌入式设备,不能与普通机器相比,但实际的 Pi 4 Model B 是一款非常有能力的设备,可以在台式机不适合的地方运行 UI 应用程序。

十三、机器学习和 JavaFX

威廉·安东尼奥·西西里

机器学习最近再次成为热门话题,主要是因为大量数据被生成和存储,以及处理能力的提高。机器学习算法远不止是一个研究课题;它们被公司用作竞争优势。在本章中,我们将讨论最著名的机器学习算法,重点是人工神经网络,并展示 JavaFX 如何与可靠的机器学习库 DeepLearning4J (DL4J)一起使用。我们将重点关注可以直接与 JavaFX 交互的视觉神经网络模型。

什么是机器学习

当你开发一个系统时,你必须准确地编写它应该做什么。你开发一个算法,一步一步地描述一个特定的流程必须如何执行。

机器学习技术是不同的,因为它们不需要明确的编程步骤。这些技术无需显式编程即可返回结果。你不是给它编程,而是“教”机器如何使用数据。

在机器学习领域,我们有两种不同类型的算法用于不同的任务,具有不同的性能和精度。我们将这些算法分为两大类:

  • 监督学习

  • 无监督学习

这两个类别都需要数据作为输入。

监督学习

在监督学习中,我们有利用标记数据的算法,这意味着您将为算法提供问题的样本实例,以便它可以学习如何对同一问题的新的未标记实例进行分类。例如,假设你有一些狗和猫的图像,你把这些图像用在一些算法上。在教完算法后,你可以向它输入新的图像,它应该会告诉你新图像中包含的是猫还是狗。

要教算法,需要输入信息,很多信息,调整算法参数,直到它能合理预测新数据。这个过程叫做训练。假设您想在照片中识别您的家人,并且您有数千张您家庭成员的照片。一旦这些照片被正确标记,你就可以用它们来喂给一个算法,一旦算法有了很好的精度,它就可以用来预测新的图片,有希望识别你的家庭成员!

无监督学习

当你有数据,你没有进一步的信息,但你仍然想检索一些知识,你可以使用无监督学习;根据所选的算法,它可以对数据的某些实例进行分组。无监督学习的一个已知示例是推荐系统,在该系统中,您使用特定系统上的用户数据向他们推荐其他产品或电影。

要使用机器学习技术,可以在多种可用算法中进行选择。对于监督学习,我们有回归、决策树等等。对于无监督学习,你会发现聚类,异常检测,等等。对于监督和非监督学习,我们有神经网络,我们将在本章中探讨。

人工神经网络

由于可用于训练的大量数据以及高性能 CPU 和 GPU,人工神经网络非常有名,并得到了高度的讨论和研究。神经网络基本元素是人工神经元,它以“神经神经元”为基础,由输入数(x)乘以其权重(w)并以一个偏差求和而成,结果插入一个激活函数中。然后我们有了结果(y)。这些神经元被组织成可以有 n 个神经元的层。各层以不同的架构连接,最后我们有了如图 13-1 所示的人工神经网络。现在的人工神经网络是由成千上万个神经元组成的,有时有几百层。这些大型人工神经网络是深度学习方法的一部分,我们将在本章中看到一些著名的深度神经网络架构。

img/468104_2_En_13_Fig1_HTML.jpg

图 13-1

神经网络的一个例子

训练过程是神经网络发挥作用的关键。在训练之前,神经网络从随机权重开始。训练包括在神经网络中输入数据,测量它与实际信息的距离,然后调整权重,直到结果接近实际值(也称为地面真实值)。例如,如果你有一个神经网络可以预测给定的图像是猫还是狗,你输入一只猫,它返回的结果是猫有 80%是狗,你计算一个误差(结果离地面真实数据有多远),然后使用称为反向传播的过程来调整神经网络的权重,并用数千只猫和狗的图像重复它,直到你有一个好的结果。在训练期间,你应该关注过度配合,但这超出了本书的范围。

有相当多非常知名的神经网络架构可供使用,其中大部分是由大公司或人工智能研究人员提出的。你可能会创建自己的神经网络,获得大量数据,并对其进行训练,这样你就可以在你的应用中使用它;然而,在本章中,我们将使用预训练的神经网络。是的,优秀的灵魂得到了一些非常著名的神经网络架构,使用一些已知的数据集(例如,ImageNet)训练它们,并且,一旦它们被训练,使它们可用于应用中;这些被称为预训练神经网络模型。

经过预训练的神经网络的强大之处在于,它已经为某个数据集调整了所有权重,这意味着它已经可供使用,并且您可以使用自己的数据再次调整权重,使其准备好处理新的类,从而重用来自其他图像或数据的知识。

卷积神经网络

深入所有神经网络架构和技术超出了本书的范围;然而,由于我们将主要使用卷积神经网络(CNN),其架构对于检测图像中的模式很有用,而不必编写特定的模式,因此我们来讨论一下。为了理解 CNN 是如何工作的,以图 13-2 为例,这是我妻子在一个应用程序中画的一只蜜蜂,我们将在后面讨论。

img/468104_2_En_13_Fig2_HTML.png

图 13-2

CNN 要分析的图像示例

观察这只蜜蜂,我们可以识别出一些图案:一只翅膀是曲线,身体也有几条曲线和一个填充的部分,头部是椭圆形,等等。你可能不知道是怎么做到的,但是你的大脑识别出了这些模式,并得出结论,这是一幅蜜蜂的图画。CNN 包含一个卷积层,与池化和规范化层一起使用,可以识别这些模式,您不必对其进行硬编码。这都是在培训过程中学到的。

img/468104_2_En_13_Figa_HTML.jpg

然后,CNN 层的输出被传递到一个完全连接的架构,该架构将以代表每一类图像(蜜蜂、青蛙、狗等)的神经元结束。).当你实际预测一幅图像时,每一类都有百分之一的机会属于某一类。请看下面的例子,应用程序背后的 CNN 知道我们试图输入一个鼠标图像(大约 78%的鼠标),但它也说有很小的机会是一只狮子(大约 13%),如图 13-3 !

img/468104_2_En_13_Fig3_HTML.png

图 13-3

CNN 进行的图像预测的结果

要了解更多关于 CNN 的信息,请查看斯坦福( http://cs231n.github.io/convolutional-networks/ )的文章和 ImageNet 竞赛获胜者的论文。

Eclipse DeepLearning4J:用于神经网络的 Java API

如果你搜索深度学习和 Java,你会找到一些库。出于我们的目的,我们将使用 Eclipse DeepLearning4J (DL4J),它允许轻松的数据矢量化、神经网络创建和训练,并提供可立即使用的预训练模型,甚至可以在移动设备上运行。

DL4J 使用的核心库是 ND4J。我们可以用 ND4J 进行 n 大小的向量运算;因此,它用于 DL4J 中的所有神经网络运算。例如,您希望为训练或预测而加载的每个数据都被转换为 ND4J INDArray 对象,因此它可以在训练过程中为神经网络提供信息。有关 ND4J 的更多信息,请参见第十四章“使用 JavaFX 的科学应用”

在 ND4J 之上,我们有 DataVec 库。处理神经网络就是处理数据,神经网络上的数据由 n 个大小的数字向量表示。你不能简单地将图像或文本字符串的二进制文件输入到神经网络中。你必须转换它;DataVec 拥有所有的工具,可以将图像、文本、CSV 文件等转换成神经网络。稍后,我们将使用 DataSetIterators,它将清楚地说明它是如何有用的。

使用 Maven 的 DeepLearning4J 设置很简单;你必须添加 nd4j-native-platform 和 deeplearning4j-core 依赖项。对于这一章,我们还需要 deeplearning4j-zoo 来利用可用的神经网络模型。对于本章,我们使用 deeplearning4j 1.0.0-beta7:

    <dependency>
      <groupId>org.nd4j</groupId>
      <artifactId>nd4j-native-platform</artifactId>
      <version>${dl4j.version}</version>
    </dependency>
    <dependency>
      <groupId>org.deeplearning4j</groupId>
      <artifactId>deeplearning4j-core</artifactId>
      <version>${dl4j.version}</version>
    </dependency>
    <dependency>
      <groupId>org.deeplearning4j</groupId>
      <artifactId>deeplearning4j-zoo</artifactId>
      <version>${dl4j.version}</version>
    </dependency>

我们不会创建神经网络,但为了体验如何使用 DeepLearning4J 创建神经网络,您可以查看 DeepLearning4J 示例( https://github.com/eclipse/deeplearning4j-examples ),例如 LeNet 版本(第一个 CNN 架构),该版本将用于本章的第一个 JavaFX 应用程序:

MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder().seed(seed)
                .activation(Activation.IDENTITY)
                .weightInit(WeightInit.XAVIER)
                .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIEN T_DESCENT)
                .updater(updater)
                .cacheMode(cacheMode)
                .trainingWorkspaceMode(workspaceMode)
                .inferenceWorkspaceMode(workspaceMode)
                .cudnnAlgoMode(cudnnAlgoMode)
                .convolutionMode(ConvolutionMode.Same)
                .list()
                // block 1
                .layer(0, new ConvolutionLayer.Builder(new int[] {5, 5}, new int[] {1, 1}).name("cnn1")
                                .nIn(inputShape[0]).nOut(20).activation(Activation.RELU).build())
                .layer(1, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX, new int[] {2, 2},
                                new int[] {2, }).name("maxpool1").build())
                // block 2
                .layer(2, new ConvolutionLayer.Builder(new int[] {5, 5}, new int[] {1, 1}).name("cnn2").nOut(50)
                                .activation(Activation.RELU).build())
                .layer(3, new SubsamplingLayer.Builder(SubsamplingLayer.PoolingType.MAX, new int[] {2, 2},
                                new int[] {2, 2}).name("maxpool2").build())
                // fully connected
                .layer(4, new DenseLayer.Builder().name("ffn1").activation(Activation.RELU).nOut(500).build())
                // output
                .layer(5, new OutputLayer.Builder(LossFunctions.LossFunction.MCXENT).name("output")
                                .nOut(numClasses).activation(Activation.SOFTMAX) // radial basis function required
                                .build())
                .setInputType(InputType.convolutionalFlat(inputShape[2], inputShape[1], inputShape[0]))
                .build();

从 JavaFX 应用程序训练神经网络

如果你没有训练过的模型,你可以自己训练。这将需要数据,大量的数据,以及神经网络参数和架构的知识。为了交互和可视化神经网络训练过程的进度,我们将使用 JavaFX 应用程序。

为了演示如何从 JavaFX 训练神经网络,我们创建了一个具有以下特性的小应用程序:

  • 查看培训和测试的进度。这可能需要几个月、几天或几个小时。在我们的案例中,我们将进行一个快速培训,这将需要几个小时。您可以在 JavaFX 应用程序中跟踪进度。

  • 能够调整一些超参数:时期数、迭代次数和批量大小。还要为训练和测试输入图像文件以及图像信息选择路径。

  • 训练后导出模型,并导入要训练的模型配置。

img/468104_2_En_13_Fig4_HTML.png

图 13-4

一个 JavaFX 应用程序,用于可视化神经网络训练过程的进度

图 13-4 所示的应用程序是使用本书中已经讨论过的控件构建的。图表还用于显示神经网络“学习”的进度,当该过程完成时,您可以将现在训练好的神经网络保存到您的磁盘上。导出的模型可用于数据新实例的真实预测。

为了探索完整的代码,您可以查看类 TrainingHelperApp(可在github.com/Apress/definitive-guide-modern-java-clients-javafx17访问)。这里我们将重点介绍 JavaFX 如何访问 DL4J APIs。用于训练的 DL4J 基础模型被封装在 NeuralNetModel 接口中,使用 Java 服务提供者接口实现该接口提供定制模型是可能的。默认情况下,我们有一个基于 LeNet 的内置 DL4J 模型,LeNet 是 Yann LeCun 创建的第一个卷积神经网络。神经网络类型为 org . deep learning 4j . nn . multilayer . multilayer network . DL4J 也提供计算图。对于这个例子,让我们保持多层网络:

import org.deeplearning4j.nn.multilayer.MultiLayerNetwork;
public interface NeuralNetModel {
    public String getId();
    public MultiLayerNetwork getModel(int[] inputShape, int numClasses);
}

组合框中填充了 NeuralNetModel 的可用实现,实际的模型是使用 getModel 方法访问的。在运行培训流程之前,用户必须选择培训和测试目录。目录应该有一个结构,其中图像位于与其类别相对应的文件夹下,例如,猫图像必须位于名为 cat 的文件夹中。当单击按钮 Run 时,将检索所有输入的信息,然后将其传递给 prepareForTraining 方法:

    private void prepareForTraining(String modelId, int[] inputShape, int epochs, int batchSize, File trainingDir, File testingDir) {
        status("Preparing for training...");
        runningProperty.set(true);
        try {
            DataSetIterator trainingIterator = DL4JHelper.createIterator(trainingDir, inputShape[1], inputShape[2], inputShape[0], batchSize, SEED);
            DataSetIterator testingIterator = DL4JHelper.createIterator(testingDir, inputShape[1], inputShape[2], inputShape[0], batchSize, SEED);
            var currentModel = getNeuralNetById(modelId).getModel(inputShape, trainingIterator.getLabels().size());
            lastModel.set(currentModel);
            currentModel.setListeners(new AsyncScoreIterationListener(this::updateScore));
            clearSeries();
            launchTrainingThread(epochs, trainingIterator, testingIterator);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

在 prepareForTraining 中,用户选择的目录用于创建 DataSetIterator。DL4J 为我们提供了一个迭代器 API,使得加载外部文件以输入神经网络变得容易。在我们的例子中,我们基于图像文件创建一个迭代器,标签是基于给定图像文件的父路径生成的。它负责为我们处理一切艰难的工作;否则,我们必须将图像加载到一个数组中,以便输入到神经网络中。创建的迭代器还提供了关于我们的数据集中有多少标签(或类)的信息,我们使用输入的输入形状和神经网络模型 ID 来检索实际的模型。在此之后,我们注册一个侦听器,每次更新分数时都会调用它,我们使用一个方法引用来更新分数以注册它,最后我们启动调用 launchTrainingThread 的训练过程,传递我们创建的历元和迭代器的数量:

    private void launchTrainingThread(int epochs, DataSetIterator trainingIterator, DataSetIterator testingIterator) {
        var currentModel = lastModel.get();
        new Thread(() -> {
                var result  = "";
                int epochNum = 0;
                for (int i = 0; i < epochs; i++) {
                    epochNum = (i +1);
                    currentModel.fit(trainingIterator);
                    status("Evaluating...");
                    Evaluation eval = currentModel.evaluate(testingIterator);
                    double progress = (double) i / (double) epochs;
                    var accuracy =  eval.accuracy();
                    var precision = eval.precision();
                    var f1 = eval.f1();
                    updateSeries(accuracySeries, epochNum, accuracy);
                    updateSeries(precisionSeries, epochNum, precision);
                    updateSeries(f1Series, epochNum, f1);
                    testingIterator.reset();
                    trainingIterator.reset();
                    result = "( A: " + evalutionFormat.format(accuracy)  +
                             ", P: " + evalutionFormat.format(precision) +
                             ", F1:" + evalutionFormat.format(f1) + " )";
                    if (stopRequested) {
                        status("Stop Requested on epoch "  + epochNum + ".                         Results: " + result);
                        stopRequested = false;
                        break;
                    } else {
                        status("Epoch " + epochNum  + "/" + epochs + " " +                         result);
                        setProgress(progress);
                    }
                }
                status("Process stoped at epoch " + epochNum  + ".
                Results: " + result);
                Platform.runLater(() -> runningProperty.set(false));
        }).start();
    }

在这个方法中,我们开始获取用户选择的模型;然后,我们启动一个包含执行训练的过程的线程。在不同的线程中这样做的原因是为了避免锁定 JavaFX 线程;这样,如果我们认为我们已经达到了好的结果,我们就可以停止这个过程。训练过程基本上是拟合模型中的迭代器,对模型进行评估。模型评估返回常用的度量来查看模型有多好:准确度、精确度和 f1 分数。每个指标都有一个相应的图表 XYSeries,在培训过程中会更新。一切都发生在历元时间,因此在从 0 到历元数的 for 循环中;但是,如果用户在应用程序运行时单击 Run 按钮,那么 stopRequested 标志就会变为 true,流程就会停止并允许用户导出模型。

您可能已经注意到,在这个方法上,我们不直接与 JavaFX 控件交互;相反,我们必须调用 status、updateSeries 和 setProcess。这些方法更新 JavaFX 线程中与 JavaFX 相关的类。请参见以下内容:

    private void setProgress(final double progress) {
        Platform.runLater(() ->  progressProperty.set(progress));
    }
    private void updateScore(Integer i, Double d) {
        updateSeries(scoreSeries, i, d);
    }
    private void updateSeries(XYChart.Series<Number, Number> series, Integer i, Double d) {
        Platform.runLater(() -> series.getData().add(new XYChart.Data<>(i, d)));
    }
    private void status(String txt) {
        Platform.runLater(() ->  txtProgress.set(txt));
    }

当训练过程结束或停止时,可以导出现在已训练的模型,这意味着它将调整权重并准备好预测新数据。这可以在方法 exportModel 上简单地完成:

    private void exportModel(ActionEvent event) {
        var source = (Button) event.getSource();
        var modelOutputFile = fileChooser.showSaveDialog(source.getScene().getWindow());
        if (modelOutputFile != null) {
            try {
                ModelSerializer.writeModel(lastModel.get(), modelOutputFile, true);
                status("Model saved to " + modelOutputFile.getAbsolutePath());
            } catch (IOException e1) {
                e1.printStackTrace();
            }
        }
    }

将图像从 JavaFX 读入神经网络

您可能会遇到这样的情况:您必须从 JavaFX 应用程序内部获取内容,然后输入到神经网络中以使用其输出。比如在一个游戏中,你想把实际的屏幕传递给一个神经网络来调整游戏参数;或者,如果您正在运行模拟,您可以将模拟的状态输入到神经网络中进行实时预测。在这些情况下,我们需要知道如何从 JavaFX 获得快照,以输入到神经网络中。

你可能听说过 Quick,Draw!这是谷歌的一个在线工具,可以猜测你在画什么。谷歌把这个开放给每个人来玩这个工具,还存储了所有的图纸,超过了 10 亿张图纸。好消息是他们把数据提供给所有人使用( https://quickdraw.withgoogle.com/data )。

数据有几种不同的二进制格式,有数百个类,每个类有数千幅图像。为了简化训练过程,我们只上动物课(狗、猫等)。)并将它们转换成人类可读的格式 PNG,这种格式也可以从 DL4J 数据集迭代器类中访问。我们还把图像做成黑白的,并把它们的尺寸调整到 28 × 28,这与著名的手写数字数据集 MNIST 的图像尺寸相同。这样我们可以使用 DL4J 示例中的 MNIST 神经网络架构,MnistClassifier ( http://projects.rajivshah.com/blog/2017/07/14/QuickDraw )。我们将使用一个简单的 LeNet 神经网络来预测输入了什么涂鸦;然而,我们将展示的应用程序可以适用于其他神经网络模型。

应用程序可以在一个单独的类 GuessTheAnimal 中找到。运行该应用程序会产生类似于图 13-5 的屏幕截图。第一步是声明包含输入图像大小、模型位置和我们在训练模型时使用的类的常量。在我们的例子中,我们选择了一些动物类。我们使用了一个具有 68%准确率和 15 个类别的模型。您可以使用我们上一节中的应用程序来训练您自己的模型,并构建一个更精确的模型:

    private static final String MODEL_PATH = "/quickdraw-model-15-68.zip";
    private static final String CLASSES[] = { "Bee", "Bird", "Cat", "Dog", "Duck", "Elephant", "Fish", "Frog", "Horse", "Lion", "Mouse", "Pig", "Rabbit", "Snake", "Spider" };
    private static final int INPUT_WIDTH = 28;
    private static final int INPUT_HEIGHT = 28;
    private static final Double THRESHOLD = 0.1d;

img/468104_2_En_13_Fig5_HTML.png

图 13-5

由 LeNet 神经网络分析的图形

该应用程序由两部分组成:加载模型和构建 UI。为了加载模型,我们使用 ModelSerializer 类。注意,您可以使用从我们之前讨论的应用程序中导出的任何模型。只要确保相应地调整常量。加载模型很简单。我们只需要调用 model serializer . restore multilayernetwork 并传递包含模型的文件或输入流。请参见 initModelAndLoader 方法。在此方法中,我们还创建了 NativeImageLoader,它是实际将内容转换为 INDArray 的类,这使它对神经网络非常有用:

    private void initModelAndLoader() throws IOException {
        model = ModelSerializer
                .restoreMultiLayerNetwork(GuessTheAnimal.class.getResource AsStream(MODEL_PATH));
        model.init();
        loader = new NativeImageLoader(INPUT_WIDTH, INPUT_HEIGHT, 1, true);
    }

UI 内置在 buildUI()方法中,由三个控件组成:用于接收用户绘图的画布、触发预测过程的按钮和包含输出的标签。当用户在画布上拖动鼠标时,应用程序会画出小圆圈,给人一种铅笔在纸上写字的感觉。右键单击清除画布和结果标签:

private StackPane buildUI() {
    var canvas = new Canvas(APP_WIDTH, APP_HEIGHT);
    var btnGuess = new Button("Guess!");
    var lblOutput = new Label("");
    var root = new StackPane(canvas, btnGuess, lblOutput);
    lblOutput.setTextFill(Color.RED);
    txtOutput = lblOutput.textProperty();
    ctx = canvas.getGraphicsContext2D();
    ctx.setLineWidth(30);
    canvas.setOnMouseDragged(e -> {
        ctx.setFill(Color.BLACK);
        ctx.fillOval(e.getX() - 15, e.getY() - 15, 30, 30);
    });
    canvas.setOnMouseClicked(e -> {
        if (e.getButton() == MouseButton.SECONDARY) {
            clearCanvas();
        }
    });
    btnGuess.setOnAction(evt -> {
        var predictions = predictCanvasContent();
        var pairs = sortAndMap(predictions);
        txtOutput.set(pairs.toString());
    });
    StackPane.setAlignment(btnGuess, Pos.BOTTOM_LEFT);
    StackPane.setAlignment(lblOutput, Pos.BOTTOM_RIGHT);
    return root;
}

此代码最重要的部分是我们获取画布内容并将其转换为 INDArray,以便可以输入到神经网络模型中。这是在方法 predictCanvasContent 和 getScaledImage 中完成的。在 getScaledImage 上,我们将画布的屏幕截图转换为 WritableImage,使用 SwingFXUtils 将其转换为 java.awt.Image,最后将其写入 BufferedImage,buffered image 也会将图像缩放到与神经网络模型中使用的图像相同的大小。我们还将最后预测的图像保存到外部文件中;它对调试很有用。在 predictCanvasContent 中,我们将 scaledImage 转换为 INDArray,然后输入到神经网络模型中。模型本身返回一个包含 1 个× 15 个位置的 INDArray 因此,在预测之后,我们将其转换为地图,并过滤结果低于我们定义为常量(默认为 0.1)的阈值的结果:

private Map<String, Double> predictCanvasContent() {
    try {
        var img = getScaledImage();
        INDArray image = loader.asRowVector(img);
        INDArray output = model.output(image);
        double[] doubleVector = output.toDoubleVector();
        var results = new HashMap<String, Double>();
        for (int i = 0; i < doubleVector.length; i++) {
            results.put(CLASSES[i], doubleVector[i]);
        }
        return results.entrySet().stream().filter(e -> e.getValue() > THRESHOLD)
        .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
}
private BufferedImage getScaledImage() {
    var canvas = ctx.getCanvas();
    WritableImage writableImage = new WritableImage((int) canvas.getWidth(), (int) canvas.getHeight());
    canvas.snapshot(null, writableImage);
    Image tmp = SwingFXUtils.fromFXImage(writableImage, null).getScaledInstance(INPUT_WIDTH, INPUT_HEIGHT, Image.SCALE_SMOOTH);
    BufferedImage scaledImg = new BufferedImage(INPUT_WIDTH, INPUT_HEIGHT, BufferedImage.TYPE_BYTE_GRAY);
    Graphics graphics = scaledImg.getGraphics();
    graphics.drawImage(tmp, 0, 0, null);
    graphics.dispose();
    try {
        File outputfile = new File("last_predicted_image.jpg");
        ImageIO.write(scaledImg, "jpg", outputfile);
    } catch (IOException e) {
        e.printStackTrace();
    }
    return scaledImg;
}

检测视频中的对象

对于下一个应用,我们将探索一个叫做 YOLO 的神经网络模型架构(你只看一次)。关于 YOLO 如何工作的总结可以在它的论文中找到: 1

我们将对象检测重新定义为一个单一的回归问题,直接从图像像素到边界框坐标和类别概率。使用我们的系统,你只需要看一次(YOLO)图像就可以预测物体的存在和位置。

本文还提供了以下简化其工作方式的图片。

原文是 2016 年的。后来,更多的论文被引入。最新的是给 YOLO3 的,更精准更快。训练一个 YOLO 神经网络需要比平常更大的图像;第一个 YOLO 版本的输入大小是 448 × 448,这意味着在个人电脑上需要很长时间。幸运的是,DL4J 提供了 TinyYOLO 和 YOLO2,随时可供我们使用。在本节中,我们将探索一个 JavaFX 应用程序,用于检测正在播放的视频中的对象。

应用程序开始声明一些对应用程序很重要的常量。让我们看看每个常量:

  • APP_WIDTH 和 APP_HEIGHT:实际的图像大小。

  • TARGET_VIDEO:支持的视频文件的 URL。如果它在类路径中,可以直接使用它的路径;不过 JavaFX 也支持 URL 协议,所以可以使用协议 file:/{path to file}加载本地文件。

  • 阈值:切割值。检测到的值小于阈值的对象不会出现在神经网络输出中。

  • 标签:用于训练神经网络模型的标签。默认情况下,它具有用于训练 DL4J 默认模型的类,但是可以将其更改为自定义 YOLO 模型。

  • 输入宽度、输入高度、输入通道:YOLO 神经网络使用的输入图像信息。它还使用 YOLO2 DL4J 型号的值。

  • GRID_W 和 GRID_H:将原始图像划分为一个网格,输出检测到的物体位置与这个网格相关;因此,在计算输出框时,需要使用网格大小。可以使用 org . deep learning 4j . zoo . model . helper . darknet helper getGridWidth 和 getGridHeight 方法进行计算。

  • 每秒帧数:每秒扫描的帧数。如果更高,处理时间会更长,但检测到的对象高光看起来会更精确。

    private static final double APP_WIDTH = 800;
    private static final double APP_HEIGHT = 600;
    private static final String TARGET_VIDEO = "/path/to/video";
    private static final double THRESHOLD = 0.65d;
    private static final String[] LABELS = { "person", "bicycle", "car", "motorbike", "aeroplane", "bus", "train", "truck", "boat", "traffic light", "fire hydrant", "stop sign", "parking meter", "bench", "bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "backpack", "umbrella", "handbag", "tie", "suitcase", "frisbee", "skis", "snowboard", "sports ball", "kite", "baseball bat", "baseball glove", "skateboard", "surfboard", "tennis racket", "bottle", "wine glass", "cup", "fork", "knife", "spoon", "bowl", "banana", "apple", "sandwich", "orange", "broccoli", "carrot", "hot dog", "pizza", "donut", "cake", "chair", "sofa", "pottedplant", "bed", "diningtable", "toilet", "tvmonitor", "laptop", "mouse", "remote", "keyboard", "cell phone", "microwave", "oven", "toaster", "sink", "refrigerator", "book", "clock", "vase", "scissors", "teddy bear", "hair drier", "toothbrush" };
    private final int INPUT_WIDTH = 608;
    private final int INPUT_HEIGHT = 608;
    private final int INPUT_CHANNELS = 3;
    private final int GRID_W = INPUT_WIDTH / 32;
    private final int GRID_H = INPUT_HEIGHT / 32;
    private final double FRAMES_PER_SECOND = 20d;

应用程序 UI 由一个用于视频回放的媒体视图、一个包含突出显示检测到的对象的矩形的窗格和一个显示正在运行的任务执行进度的标签组成。所有东西都堆放在 StackPane 上,标签朝向底部。这些都是在 start 方法中完成的,但是在构建 UI 之前,我们为每个矩形生成颜色,这些颜色将突出显示检测到的对象并初始化 YOLO2 模型。注意,第一次执行这段代码时,它将下载预训练的模型;因此,这可能需要一段时间:

for (int i = 0; i < LABELS.length; i++) {
    colors.put(LABELS[i], Color.hsb((i + 1) * 20, 0.5, 1.0));
}
var yoloModel = (ComputationGraph)  YOLO2.builder().build().initPretrained();
String videoPath = DetectObjectsInVideoImproved.class.getResource(TARGET_VIDEO).toString();
imageLoader = new NativeImageLoader(INPUT_WIDTH, INPUT_HEIGHT, INPUT_CHANNELS,
        new ColorConversionTransform(COLOR_BGR2RGB));
var media = new Media(videoPath);
var mp = new MediaPlayer(media);
var view = new MediaView(mp);
Label lblProgress = new Label();
lblProgress.setTextFill(Color.LIGHTGRAY);
view.setFitWidth(APP_WIDTH);
view.setFitHeight(APP_HEIGHT);
view.setPreserveRatio(false);
pane = new Pane();
pane.setMinWidth(APP_WIDTH);
pane.setMinHeight(APP_HEIGHT);
var root = new StackPane(view, pane, lblProgress);
StackPane.setAlignment(lblProgress, Pos.BOTTOM_CENTER);
stage.setScene(new Scene(root, APP_WIDTH, APP_HEIGHT));
stage.show();
stage.setTitle("Detect Objects");

该应用程序还允许用户通过单击来暂停视频,由于我们在媒体视图的顶部有一个窗格,所以我们在窗格上而不是在媒体视图上注册鼠标监听器:

        pane.setOnMouseClicked(e -> {
            if (mp.getStatus() == Status.PLAYING) {
                mp.pause();
            } else if (mp.getStatus() == Status.PAUSED) {
                mp.play();
            } else if (mp.getStatus() == Status.STOPPED) {
                mp.seek(mp.getStartTime());
                mp.play();
            }
        });
        mp.setOnEndOfMedia(() -> {
            mp.stop();
            pane.getChildren().forEach(c -> c.setVisible(false));
        });

预测不是实时进行的。原因是在没有 GPU 处理的机器中,单个预测几乎需要 500 ms。当拥有 GPU 时,预测过程的一部分将由多个 GPU 核心来完成,从而使其速度更快。YOLO 的论文谈到每秒 155 帧;然而,拍摄 JavaFX 节点的快照将很难实现这一结果,但是在这个应用程序中,使用快照是因为您可以预处理媒体视图节点,然后运行 YOLO(例如,缩放或应用效果),将 JavaFX 的功能与 YOLO 结合起来。此外,你可能不想运行 YOLO 的视频。任何 JavaFX 节点都可以成为 YOLO 预测的对象,因此它有更多的可能性。

在我们的例子中,技巧不是实时进行预测,而是在应用程序运行时收集帧并安排预测任务,创建包含检测到的对象高亮显示的 JavaFX 节点,然后在视频顶部显示它。注意,一旦创建了组,我们就给它一个 ID,这样我们就可以根据为用户显示的当前框架隐藏或显示它。我们跟踪每个任务以避免多余的执行,只有在任务完成后,带有检测到的对象的组才会添加到窗格中(请参见 target.setOnSucceeded)。换句话说,视频至少需要播放一次,以便收集所有帧并安排处理。在 trackTasks 中跟踪预定的任务,一旦给定帧 ID 的任务完成,我们将高亮显示包含检测到的对象的组,并隐藏其他对象。一切都是在一个监听器中完成的,这个监听器附加到媒体播放器播放的当前时间,所以它只在视频播放的时候被调用;否则,它不会收集帧进行处理:

        var finishedTasks = new AtomicInteger();
        var previousFrame = new AtomicLong(-1);
        mp.currentTimeProperty().addListener((obs, o, n) -> {
            if(n.toMillis() < 50d) return;
            Long millis = Math.round(n.toMillis() / (1000d / FRAMES_PER_SECOND));
            final var nodeId = millis.toString();
            if(millis  == previousFrame.get()) {
                return;
            }
            previousFrame.set(millis);
            trackTasks.computeIfAbsent(nodeId, v -> {
                var scaledImage = getScaledImage(view);
                PredictFrameTask target = new PredictFrameTask(yoloModel, scaledImage);
                target.setOnSucceeded(e -> {
                    var detectedObjectGroup = getNodesForTask(nodeId, target);
                    Platform.runLater(() -> pane.getChildren().add(detectedObjectGroup));
                    updateProgress(lblProgress, trackTasks.size(), finishedTasks.incrementAndGet());
                });
                Thread thread = new Thread(target);
                thread.setDaemon(true);
                thread.start();
                return true;
            });
            updateProgress(lblProgress, trackTasks.size(),
            finishedTasks.get());
            pane.getChildren().forEach(node -> node.setVisible(false));
            Optional.ofNullable(pane.lookup("#" + nodeId)).ifPresent(node -> node.setVisible(true));
        });
    }

既然您已经了解了我们是如何收集帧并处理它们的,那么让我们来考虑一下实际的帧处理。在调度任务之前,有一个对 getScaledImage 的调用,其结果将被传递给任务。这个方法获得媒体视图的快照,就像我们以前做的一样,但是这次我们得到的不是黑白图像,而是彩色图像。YOLO 输入图像使用三个通道,每种颜色一个通道(红色、绿色和蓝色):

    private BufferedImage getScaledImage(Node targetNode) {
        writableImage = new WritableImage((int) targetNode.getBoundsInLocal().getWidth(), (int) targetNode.getBoundsInLocal().getHeight());
        targetNode.snapshot(null, writableImage);
        Image tmp = SwingFXUtils.fromFXImage(writableImage, null).getScaledInstance(INPUT_WIDTH, INPUT_HEIGHT, Image.SCALE_SMOOTH);
        BufferedImage scaledImg = new BufferedImage(INPUT_WIDTH, INPUT_HEIGHT, BufferedImage.TYPE_INT_RGB);
        Graphics graphics = scaledImg.getGraphics();
        graphics.drawImage(tmp, 0, 0, null);
        graphics.dispose();
        return scaledImg;
    }

在 setOnSucceeded 侦听器上,我们将处理任务返回的检测到的对象。每个对象具有每个检测到的矩形的初始点以及其他信息,但是只需要点 x 来构建矩形以突出检测到的对象。然而,在创建矩形之前,需要转换坐标。首先,我们需要弄清楚是否需要缩放,因为应用程序大小可能大于图像输入,并且所有坐标都是相对于输入的,然后计算原始图像上的实际坐标,因为所有坐标都是相对于网格的,最后计算矩形的宽度和高度。除了矩形之外,我们还添加了一个标签来显示预测的类,它被添加到一个组中,因此我们可以唯一地处理标签和矩形。对每个检测到的对象都这样做:

   private Group getNodesForTask(final String nodeId, PredictFrameTask    target) {
        try {
            var predictedObjects = target.get();
            var detectedObjectGroup = getPredictionNodes(predictedObjects);
            detectedObjectGroup.setId(nodeId);
            detectedObjectGroup.setVisible(false);
            return detectedObjectGroup;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    private Group getPredictionNodes(List<DetectedObject> objs) {
        Group grpObject = new Group();
        objs.stream().map(this::createNodesForDetectedObject)
                     .flatMap(l -> l.stream())
                     .forEach(grpObject.getChildren()::add);
        return grpObject;
    }
    private List<Node> createNodesForDetectedObject(DetectedObject obj) {
        double[] xy1 = obj.getTopLeftXY();
        double[] xy2 = obj.getBottomRightXY();
        var w  = INPUT_WIDTH;
        var h  = INPUT_HEIGHT;
        var wScale  = (APP_WIDTH / w);
        var hScale  = (APP_HEIGHT / h);
        var x1 = (w ∗ xy1[0] / GRID_W) * wScale;
        var y1 = (h ∗ xy1[1] / GRID_H) * hScale;
        var x2 = (w ∗ xy2[0] / GRID_W) * wScale;
        var y2 = (h ∗ xy2[1] / GRID_H) * hScale;
        var rectW = x2 - x1;
        var rectH = y2 - y1;
        var label = LABELS[obj.getPredictedClass()];
        Rectangle rect = new Rectangle(x1, y1, rectW, rectH);
        rect.setFill(Color.TRANSPARENT);
        Color color = colors.get(label);
        rect.setStroke(color);
        rect.setStrokeWidth(2);
        Label lbl = new Label(label);
        lbl.setTranslateX(x1 + 2);
        lbl.setTranslateY(y1 + 2);
        lbl.setTextFill(color);
        lbl.setFont(Font.font(Font.getDefault().getFamily(), FontWeight.EXTRA_BOLD, FontPosture.ITALIC, 10));
        return List.of(rect, lbl);
    }

最后,我们有一个任务,它实现了 javafx.concurrent.Task 并为它提供了一个 List 类型。JavaFX 并发任务允许我们运行一些繁重的操作,然后检索结果,而不占用 JavaFX 主线程。这个预测几乎和我们快速绘制中所做的一样!例子。主要区别在于,为了提取对象,现在我们使用 org . deep learning 4j . nn . layers . obj detect . yolo 2 output layer 类中的一个实用程序方法来从 Array:

   public class PredictFrameTask extends Task<List<DetectedObject>> {
        ComputationGraph yoloModel;
        BufferedImage scaledImage;
        public PredictFrameTask(ComputationGraph yoloModel, BufferedImage scaledImage) {
            this.yoloModel = yoloModel;
            this.scaledImage = scaledImage;
        }
        @Override
        protected List<DetectedObject> call() throws Exception {
            return predictObjects();
        }
        private List<DetectedObject> predictObjects() {
            org.deeplearning4j.nn.layers.objdetect.Yolo2OutputLayer yout =
                    (org.deeplearning4j.nn.layers.objdetect.Yolo2OutputLayer)yoloModel.getOutputLayer(0);
            try {
                var imgMatrix = imageLoader.asMatrix(scaledImage);
                var scaler = new ImagePreProcessingScaler(0, 1);
                scaler.transform(imgMatrix);
                INDArray output = yoloModel.outputSingle(imgMatrix);
                return yout.getPredictedObjects(output, THRESHOLD);
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
        }

的结果中获取预测的对象

这是构建您自己的应用程序的一个非常简单的起点。考虑构建自己的 YOLO 驱动的应用程序的可能性,如在一个属性中寻找入侵者,计算街道上的汽车数量,在大图像中寻找对象,等等!

十四、使用 JavaFX 的科学应用

由 Johan Vos 撰写

最近取得的许多惊人进展都是在数据科学领域,或者至少是与之相关的领域。不同形式的机器学习、大数据计算和量子计算正迅速对整个社会产生越来越大的影响。

这些进步的技术基础是基于不同的科学研究领域。通常情况下,技术基础是与实现无关的。不同的语言、平台和二进制文件可以用来实现技术基础。

在这一章中,我们将解释为什么 Java,特别是客户机 Java(包括 JavaFX ),是那些想用他们掌握的语言创建科学应用程序的开发人员的最佳选择。

我们将首先演示几个真实的示例,然后解释一种更通用的方法,它允许您用 Java 创建科学应用程序,包括进行与科学应用程序相关的研究工作。

用于太空探索的 JavaFX

使用 JavaFX 技术的一个伟大的科学应用是深空轨道探测器,或 DSTE。这是由 a.i. solutions 创造的产品,由 NASA 使用。该产品在位于 https://ai-solutions.com/dste 的 a.i. solutions 网站上有所描述。

据该网站介绍,深空轨道探索者是一个交互式软件包,结合了尖端的多体轨道设计技术和创新的可视化技术,以大幅减少轨道设计所花的时间。

通过 DSTE,用户可以设计空间物体的轨迹,这通常需要大量的计算。通常,在计算完成之前,这些计算是在没有任何可视化或交互性的情况下完成的。DSTE 的产品使设计过程更加“敏捷”和互动。

DSTE 的主要好处之一是它允许在任务设计过程中使用交互式可视化。这样,选择满足特定任务约束的轨道变得更加容易和直观。

结合高性能计算和复杂可视化的需求是 Java 和 JavaFX 的一个很好的用例。Java 本身是非常可扩展的,有大量强大的 Java APIs 和框架可以帮助开发人员和操作人员在多核环境中扩展 Java 代码。JavaFX 平台允许包括 3D 模型和画布渲染在内的可视化,从而使用硬件加速渲染,利用 GPU 的可用性。由于 JavaFX 是纯 Java,这种呈现可以很容易地与负责高性能计算的代码片段集成。

Deep Space Trajectory Explorer 以多种方式利用 JavaFX 平台提供的性能。它包含 2D 和 3D 的不同视图。许多视图都支持点击-拖动功能。画布组件用于在屏幕上呈现数百万个链接的数据点,而不会冻结布局。过滤器允许选择和取消选择许多选项,所产生的变化实时呈现在视图中。

图 14-1 1 显示了由 DSTE 产品生成的屏幕截图,显示了该工具提供的一些视图。

img/468104_2_En_14_Fig1_HTML.jpg

图 14-1

DSTE 生成的截图

深空轨道探测器不是一个容易创建的应用程序。它需要深入了解轨道背后的物理学、高性能计算和 UI 开发。JavaFX 是整个解决方案中的关键组件,证明了该平台的强大。

用于量子计算的 JavaFX

量子计算正在科学环境和 IT 部门中迅速获得兴趣。

量子计算的前景之一是,一些用经典计算机极难解决或实际上不可能解决的问题,可以用量子计算机轻松解决。尤其是在算法表现出指数级时间行为的领域,量子计算机可以大有作为。

量子计算机使用一些从根本上存在于自然界的核心概念,但在经典计算机中没有。

在经典计算机中,最细粒度的单位是位。一位不是 0 就是 1。在量子计算机中,最精细的单位是量子位。一个量子位可以保存值 0 或 1,但它也可以处于所谓的叠加态,在这种状态下,它保存 0 和 1 的线性组合。然而在测量时,一个量子位总是返回 0 或 1。因此,量子计算的算法必须利用叠加态,而不需要在处理过程中测量量子位。

在经典计算中,比特是由门操纵的。例如,非门将翻转一位的值。当该位在进入门之前为 0 时,门之后的结果将为 1,反之亦然。

同样,在量子计算中,量子位是由量子门操纵的。

虽然有一些非常早期的量子计算机实验芯片可用,但量子计算还没有为主流开发做好准备。制造一台具有足够量子位的量子计算机,并使其在合理的时间内保持可用的实际需求是巨大的。因此,只有少数几个原型具有有限数量的量子位。

然而,由于量子计算的巨大潜在影响,许多开发人员已经在研究可能受益于量子计算的算法。通常,本地或基于云的模拟器用于开发这些算法,一些公司现在开始提供真正的量子计算机作为云服务。

这些算法通常用编程语言开发,并使用电路可视化来可视化。

其中一个量子模拟器 Strange 正在使用一个名为 StrangeFX 的配套工具,该工具是使用 JavaFX 构建的,用于呈现电路。StrangeFX 可以在 GitHub 的 https://github.com/redfx-quantum/strangefx 获得。

StrangeFX 允许开发人员将量子门拖到量子位线上。当他们这样做时,本地仿真器评估电路并实时显示结果。JavaFX 的 draganddrop 功能为开发量子电路提供了一种非常直观的方式,并且与量子模拟算法的集成非常简单,因为两个组件都是用 Java 编写的。

图 14-2 显示了 StrangeFX 的一个简单截图,显示了三个量子位和工具栏中的一些门,可以拖动到量子位线上。

img/468104_2_En_14_Fig2_HTML.jpg

图 14-2

StrangeFX 允许开发人员将量子门拖到量子位线上

一个更复杂的例子包括图 14-3 所示的 Grover 搜索的模拟,这是一个著名的量子算法。

img/468104_2_En_14_Fig3_HTML.jpg

图 14-3

格罗弗氏 sarch 的模拟

使用 JShell

在不久前,我们看到了两个不同领域的活动之间的断层线:一方面是使用编程语言创建应用程序的开发人员,另一方面是从事基础设施和运营工作的人员。开发人员主要在一个孤立的环境中为特定的问题开发业务解决方案。一旦问题得到解决,解决方案就被交给 it 部门,IT 部门必须将其投入生产。这两个世界之间的差距导致了许多与可伸缩性、文档、版本、责任、依赖性等相关的问题。“它为我工作”的情况经常阻碍了从创建业务解决方案的开发人员到将解决方案投入大规模生产的运营人员的过渡。

这种差距现在通常通过所谓的“DevOps”方法来解决,其中开发和运营的一些重叠部分被集中在一个方法或团队中。如图 14-4 所示,包括容器化在内的许多软件改进使得开发和运营能够更加紧密地合作。

img/468104_2_En_14_Fig4_HTML.jpg

图 14-4

DevOps 方法

开发和运营的重叠部分在跨域 DevOps 环境中解决。

如今,人们对数据科学的兴趣越来越浓厚,这表明两个群体之间出现了一条新的断层线:从事研究的人和从事生产开发的开发人员。通常情况下,这项研究是由那些有极其困难和复杂的科学问题需要解决的科学家来完成的。那些研究人员应该关注核心问题,而不是特定编程语言的语法或特定行为。因此,像 Matlab 和 Python 这样伟大的科学平台或语言经常被科学家用来解决这些核心问题。

然而,一旦核心问题得到解决,它通常需要集成到产品中并投入生产。这经常会产生新的问题。科学平台专注于帮助研究人员找到科学问题的最佳解决方案,而不是找到与数据库、web 服务以及高可用性和安全性服务集成的最佳方式。

后者是 Java 擅长的领域。然而,当科学家不得不使用与 Java 企业开发人员相同的环境时,他们的生产力很可能会下降。

当进行纯研究时,迭代周期与开发业务应用程序和运行集成测试非常不同。在研究过程中,科学家希望衡量改变单个变量的影响,或者他们希望在现有的深度学习模型中添加一个新的层。他们应该能够检查他们算法的参数和中间值。这不同于调试业务应用程序。它需要与算法本身进行更快、更深入的交互,而不需要重新编译应用程序或运行单元测试。

在下文中,我们将展示由于 JShell,现代 Java 是如何支持这种快速的科学发展的。从 Java 9 开始,JShell 工具包含在 Java SE 发行版中。这是一个所谓的 REPL,是“读取-评估-打印-循环”的缩写,它为开发人员提供了一个简单的交互式环境,用于创建和检查应用程序和算法。JShell 构建在 Java 之上,您可以利用 JShell 的所有 Java APIs,包括 JavaFX APIs。

因此,JShell 是一个很好的工具,允许在科学研究和生产开发之间进行转换,如图 14-5 所示。

img/468104_2_En_14_Fig5_HTML.jpg

图 14-5

JShell 允许科学开发和业务环境集成之间的过渡

我们将首先展示 JShell 的基本功能。接下来,我们将展示如何使用 ND4J 库在 JShell 中轻松处理线性代数。最后,我们将演示如何使用 JavaFX 和 JShell 在原型开发过程中实现真正简单快速的可视化。

使用 JShell

启动 JShell 非常容易。JShell 是一个工具,与 javac 和 java 包装器位于同一个目录中。因此,如果您设法将 java 和 javac 添加到您的路径中,只需调用

jshell

应该启动工具。一旦 JShell 启动,您就进入了 JShell 环境,您可以在其中输入命令或语句。

启动 JShell 后,您会看到以下内容:

img/468104_2_En_14_Figa_HTML.jpg

请注意,版本号显示为“17-内部”这样做的原因是这个截图(以及接下来的截图)是在二进制版本可用之前用 OpenJDK 17 的定制版本创建的。

JShell 允许您创建 Java 语句。例如,可以在 JShell 提示符后编写以下语句:

System.out.println("Hello, JShell");

它将立即导致以下响应:

img/468104_2_En_14_Figb_HTML.jpg

虽然这看起来与创建常规 Java 应用程序的方式非常相似,但重要的是要注意,我们不必创建包、类或 main 方法。以下语句等效于创建包含以下定义的类 HelloWorld:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, Jshell");
    }
}

接下来,我们必须编译这个类

javac HelloWorld.java

并使用运行它

java HelloWorld

Note

从 Java 11 开始,可以跳过编译步骤,直接使用java HelloWorld.java运行类。

虽然最终结果是相同的(“Hello,JShell”正在打印),但是步骤却非常不同。当处理复杂的或者模块化的软件时,创建一个带有修饰符的类和方法是非常重要的,在这种情况下不应该被看作是开销。然而,当我们只是想知道前面的代码片段中会打印出什么内容时,使用 JShell 的方法给了我们更快的答案。

使用 JShell 打印“Hello,JShell”并不是很有野心,但是我们展示了您可以在 Java 应用程序和 JShell 中使用相同的 Java 语法。因此,熟悉 Java 是 JShell 的关键优势之一。

科学应用通常需要数学运算。我们现在将展示一个具有更多数学功能的示例。

以下代码片段将打印取值为 0、30、60 和 90 度的参数的正弦值:

img/468104_2_En_14_Figc_HTML.png

同样,请注意这个代码片段与实现相同功能的 Java 应用程序是多么相似。为了方便起见,这样的 Java 应用程序如下所示:

import java.util.stream.*;
public class JshellSin {
    public static void main(String[] args) {
        IntStream.range(0, 4)
            .mapToDouble(i -> 30\. * i * 2* Math.PI/360.)
            .forEach(d -> System.out.println(Math.sin(d)));
    }
}

JShell 的一大优点是集成的编辑器功能。我们可以使用向上/向下箭头移动到上一条/下一条语句并编辑该语句。

例如,假设我们在算法中犯了一个错误。我们想打印余弦,而不是正弦。

我们可以很容易地做到这一点,按一次向上箭头键,这将再次显示前一行,我们可以修改它,以便我们用“Math.cos”替换“Math.sin”

按 return 键会立即重新计算表达式并打印结果:

img/468104_2_En_14_Figd_HTML.png

因此,JShell 允许我们通过编辑语句并立即获得关于结果的反馈来试验我们的语句或算法。

这种方法已经更类似于科学家如何使用 Python 和 Matlab 来创建算法。

JShell 的一个巨大优势是代码与新的或现有的 Java 应用程序 100%兼容。JShell 代码可以很容易地粘贴到 Java 类中。

JShell 包含许多用于保存和加载代码片段的命令。然后可以将这些片段粘贴到您的应用程序中。

因此,由科学家使用 JShell 完成的最终成果,可以立即被使用 IDE 并将科学算法与项目中的其他组件集成在一起的 Java 开发人员使用。JShell 片段可以封装在一个私有方法中,提交给一个执行程序,周围是头参数,包括安全凭证等等。

虽然我们触及了 JShell 的一些核心概念,但是我们仅仅触及了可能的表面。在本章的剩余部分,我们将关注如何在结合科学工作和高质量可视化的环境中使用 JShell。如果你想了解更多关于 JShell 本身的知识,建议你去 https://docs.oracle.com/en/java/javase/17/jshell/introduction-jshell.html 看看 JShell 官方产品页面。

关于 ND4J

在前一章中介绍的 ND4J 线性代数库允许 Java 开发人员以一种对 Java 开发人员非常方便的方式访问高性能的线性代数功能。

ND4J 在提供线性代数工具的平台相关库之上提供了一个抽象层。ND4J 这个名字指的是 Java 的 N 维线性代数。许多平台(例如 Windows、macOS、Linux、iOS、Android)都包含针对特定平台进行了高度优化的线性代数库。此外,特定硬件(例如,GPU)的可用性可以导致一些功能的甚至更特定的实现。

因为性能在数据科学领域非常重要,所以 Java 开发人员能够利用这些本地库提供的功能是至关重要的。然而,如果开发人员不得不编写只适用于特定硬件或操作系统配置的应用程序,这将是一个痛苦。

这就是 ND4J 及其依赖项提供解决方案的地方。ND4J 的顶层提供了用户可以与之交互的 API。这些是 Java 开发人员和科学家都很熟悉的 Java APIs,因为它们提供了线性代数库中的典型功能。在幕后,这些 API 被映射到最佳可用的本地库。

ND4J 库为希望使用数学功能的 Java 开发人员提供了很多价值,同时也为希望他们的工作能够轻松集成到新的或现有的 Java 应用程序中的研究人员和科学家提供了很多价值,无论他们是在大型企业或云环境中运行,还是在嵌入式或移动设备上运行,或者在两者之间运行。

在我们展示如何在 JShell 中使用 ND4J 之前,我们先展示一个非常简单的应用程序,它使用 ND4J 进行基本的矩阵操作。

考虑以下示例:

import org.nd4j.linalg.api.ndarray.INDArray;
import org.nd4j.linalg.factory.Nd4j;
public class HelloNd4j {
    public static void main(String[] args) {
        INDArray a = Nd4j.zeros(3,5);
        System.out.println("Matrix a has 3 rows and 5 columns:\n"+a);
        System.out.println("++++++++++++++++++++++++++++++\n");
        INDArray b = Nd4j.create(new double[] {0.,1.,2.,3.,4.,5.},
        new int[] {2,3});
        INDArray c = Nd4j.create(new double[] {2.,-1.,3.}, new int[] {3,1});
        System.out.println("Matrix b has 2 rows and 3 columns:\n"+b);
        System.out.println("++++++++++++++++++++++++++++++\n");
        System.out.println("Vector c has 3 elements:\n"+c);
        System.out.println("++++++++++++++++++++++++++++++\n");
        INDArray d = b.mmul(c);
        System.out.println("matrix product of b x c  =\n"+d);
        System.out.println("++++++++++++++++++++++++++++++\n");
    }
}

ND4J 库要求其他库在类路径中可用。因为我们不想为我们到底需要什么库而烦恼,所以我们将这部分委托给一个构建工具,例如 Maven。在 pom.xm 文件中,我们声明我们需要什么,Maven 将确保所有相关的依赖项都被下载并放到类路径中。

以下 pom.xml 可用于实现这一点:

<project xmlns:="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
  http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <packaging>jar</packaging>
  <groupId>org.modernclient</groupId>
  <artifactId>nd4jshell</artifactId>
  <version>1.0.0</version>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>org.nd4j</groupId>
      <artifactId>nd4j-native-platform</artifactId>
      <version>1.0.0-M1</version>
    </dependency>
    <dependency>
      <groupId>org.openjfx</groupId>
      <artifactId>javafx-controls</artifactId>
      <version>17.0.1</version>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.0</version>
        <configuration>
          <release>11</release>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>1.6.0</version>
        <executions>
          <execution>
            <goals>
              <goal>java</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <mainClass>org.modernclient.HelloNd4j</mainClass>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

如果我们使用

mvn compile exec:java

我们看到以下输出:

Matrix a has 3 rows and 5 columns:
[[         0,         0,         0,         0,         0],
 [         0,         0,         0,         0,         0],
 [         0,         0,         0,         0,         0]]
++++++++++++++++++++++++++++++
Matrix b has 2 rows and 3 columns:
[[         0,    1.0000,    2.0000],
 [    3.0000,    4.0000,    5.0000]]
++++++++++++++++++++++++++++++
Vector c has 3 elements:
[2.0000,
 -1.0000,
 3.0000]
++++++++++++++++++++++++++++++
matrix product of b x c  =
[5.0000,
 17.0000]
++++++++++++++++++++++++++++++

在这个简单的应用程序中,我们创建了几个矩阵和一个向量,并将一个矩阵和一个向量相乘。虽然这些不是特别的计算,但它们展示了 ND4J 是如何工作的。如果您想了解更多关于 ND4J 的内容,以及与之相关的项目,我们建议您看一下 https://deeplearning4j.org/docs/latest/nd4j-overview .在下一节中,我们将解释如何在 JShell 中轻松集成基于 ND4J 的应用程序。

在 JShell 中使用 ND4J

通过键入以下命令,我们可以使用与应用程序相同的类路径启动 JShell,该应用程序托管在相同的目录中

mvn compile com.github.johnpoth:jshell-maven-plugin:1.3:run

请注意,这要求 pom.xml 文件与我们键入的命令位于同一个目录中。pom.xml 文件包含应用程序的依赖关系,基于这些依赖关系(包括可传递的依赖关系),JShell-maven-plugin 组成提供给 JShell 的类路径。用于此示例的 pom 文件如下所示:

<project xmlns:="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
  http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <packaging>jar</packaging>
  <groupId>org.modernclient</groupId>
  <artifactId>plotjshell</artifactId>
  <version>1.0.0</version>
  <url>http://maven.apache.org</url>
  <dependencies>
    <dependency>
      <groupId>org.openjfx</groupId>
      <artifactId>javafx-controls</artifactId>
      <version>17.0.1</version>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.8.0</version>
        <configuration>
          <release>11</release>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>1.6.0</version>
        <executions>
          <execution>
            <goals>
              <goal>exec</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
            <executable>java</executable>
            <longModulepath>false</longModulepath>
            <arguments>
                <argument>--module-path</argument>
                <classpath />
                <argument>--add-modules</argument>
                <argument>javafx.controls</argument>
                <argument>-classpath</argument>
                <classpath />
                <argument>org.modernclient.Plot</argument>
            </arguments>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

因此,前面文本中显示的命令将使用应用程序中也使用的类路径启动 JShell。首先,我们导入应用程序中需要的包:

img/468104_2_En_14_Fige_HTML.jpg

我们现在从前面的应用程序中逐个输入命令。JShell 会在输入一行后立即给出输出。使用 ND4J API 的第一个命令将初始化 ND4J 后端,在此期间,将选择并初始化线性代数函数的最佳提供者。对此的反馈打印在第一个命令的结果之前。

在我们的例子中,第一个命令是创建一个 3 × 5 的矩阵,其中包含所有的零。输入此命令会生成以下输出:

img/468104_2_En_14_Figf_HTML.jpg

Note

基于包括操作系统、CPU 和 GPU 在内的参数组合,Nd4j 库有许多不同的实现。其中一些实现提供了比其他实现更多(或不同)的日志输出,因此您可能会看到与之前粘贴的输出不同的输出。重要的一行是最后一行,在这里打印命令的结果。

我们现在可以继续输入命令并检查输出。例如,在进入声明 2 × 3 矩阵的第一行后,我们得到以下输出:

img/468104_2_En_14_Figg_HTML.png

输入最后一个命令(忽略 System.out.println)后,矩阵向量乘法的输出如下所示:

img/468104_2_En_14_Figh_HTML.png

这与我们从申请中得到的结果相同。

我们现在可以用 JShell 做的一件好事就是在这个结果的基础上构建。例如,如果我们想要将结果向量中的所有元素与标量 3 相乘,我们输入命令 d.mul(3)。ND4J Javadoc 解释说 mul 命令会将矩阵元素乘以一个给定的数——参见 https://deeplearning4j.org/api/latest/org/nd4j/linalg/api/ndarray/INDArray.html#mul-java.lang.Number-

我们不必重新运行现有代码或重新编译应用程序。我们只需输入命令,结果就会立即显示出来:

img/468104_2_En_14_Figi_HTML.png

最后,我们将展示一些使用 JShell 可以完成的操作。我们展示的例子没有科学意义,但是它们应该说明了 JShell 工具的灵活性,与典型的开发周期相比,典型的开发周期包括在 IDE 中修改源代码、重新编译和从头开始运行。

在执行了上一个示例中的命令后,我们想要创建一个新函数。在这个新函数中,矩阵(或向量)的所有元素都乘以一个数,然后从结果中减去另一个数。

我们将命名为 someOperation 的函数在 Java 语法中定义如下:

INDArray someOperation(INDArray src, int m, int s) {
        return src.mul(m).add(-s);
}

在 JShell 中定义函数与在 Java 应用程序中定义函数非常相似。我们只需输入函数定义。JShell 将确认所创建的函数,从那时起,我们可以在所有操作中使用该函数:

img/468104_2_En_14_Figj_HTML.jpg

例如,我们现在可以在之前创建的 b 矩阵上使用这个新函数。我们将 b 的所有元素乘以 2,然后每个元素减去 1。为了清楚起见,我们首先打印 b 的当前值,这很容易通过简单地输入 b 来完成;在 JShell 提示符下:

img/468104_2_En_14_Figk_HTML.jpg

请注意结果前面的“$31”。当没有使用结果变量时,JShell 会自动自己创建一个变量,并将结果赋给这个变量。这些变量以后可以再次使用,类似于其他变量的使用方式。

在 JShell 中使用 JavaFX

由于 JShell 是构建在 JVM 之上的,所以任何运行在 JVM 上的库、框架或应用程序都可以使用 JShell 运行。使用 REPL 来创建 JavaFX 应用程序听起来有些矫枉过正,而且在许多情况下确实不建议这样做。然而,JavaFX 中的 Java 可视化技术允许快速可视化数据,这在开发科学应用程序时非常有用。

在展示这种快速可视化的例子之前,我们先解释如何在 JShell 中执行 JavaFX 应用程序。但是首先,我们将展示启动独立 JavaFX 应用程序的另一种方法。

启动独立 JavaFX 代码

通常,JavaFX 应用程序会扩展 javafx.application.Application。其 start 方法由 javafx 运行时调用。JavaFX 启动器管理 JavaFX 运行时的引导。

然而,我们也可以直接启动 JavaFX 运行时,并在单个方法中运行 JavaFX 应用程序。以下代码片段显示了如何做到这一点:

package org.modernclient;
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.StackPane;
import javafx.stage.Stage;
public class StandAlone {
    public static void showHello() {
        Platform.startup(() -> {});
        Platform.setImplicitExit(false);
        Platform.runLater( () -> {
            Label label = new Label ("Hello, standalone JavaFX");
            Button button = new Button ("Click me");
            button.setOnAction(e -> {label.setText("Clicked");});
            button.setTranslateY(50);
            StackPane box = new StackPane();
            box.getChildren().addAll(label, button);
            Scene s = new Scene(box, 200, 200);
            Stage stage = new Stage();
            stage.setTitle("StandAlone Hello");
            stage.setScene(s);
            stage.show();
        });
    }
    public static void main(String[] args) {
        showHello();
    }
}

在这个应用程序中,main 方法调用静态方法 showHello(),该方法通过调用

Platform.startup(() -> {})

该方法将启动 JavaFX 运行时,并在成功完成启动后调用提供的 Runnable。在我们的例子中,我们不会立即调用 Runnable 因此,我们传递一个空的 Runnable。一旦该方法返回,JavaFX 应用程序线程被创建,我们可以使用 Platform.runLater()语句来创建或修改 SceneGraph,类似于我们应该对 JavaFX 应用程序执行的操作,其中运行时由 JavaFX 启动器启动。

我们首先用 Maven 编译这个类,使用

mvn compile

如果您喜欢使用命令行编译,可以通过以下命令轻松完成:

javac -p /opt/javafx-sdk-17/lib --add-modules javafx.controls src/main/java/org/modernclient/StandAlone.java
where /opt/javafx-sdk-17 should be replaced with the location where you downloaded the JavaFX 17 SDK.

我们用它来运行

mvn exec:exec

或者,如果您使用命令行编译并将类编译到与源代码相同的目录中

java -p /opt/javafx-sdk-17/lib --add-modules javafx.controls -cp src/main/java/ org.modernclient.StandAlone

结果如下所示:

img/468104_2_En_14_Figl_HTML.jpg

为了让 Maven 正确启动这个应用程序,我们必须向 pom.xml 提供模块路径和所需的模块(javafx.controls)。

<plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>1.6.0</version>
        <executions>
          <execution>
            <goals>
              <goal>exec</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
            <executable>java</executable>
            <longModulepath>false</longModulepath>
            <arguments>
                <argument>--module-path</argument>
                <classpath />
                <argument>--add-modules</argument>
                <argument>javafx.controls</argument>
                <argument>-classpath</argument>
                <classpath />
                <argument>org.modernclient.StandAlone</argument>
            </arguments>
        </configuration>
      </plugin>

在这个代码片段中,我们配置了 exec 任务来调用 Java 命令,但是我们没有使用 Maven 本来会使用的默认启动器。相反,我们手动告诉 Java,它应该从类路径(包括依赖 javafx-controls)中获取模块路径,并添加 javafx.controls 模块。

运行这个应用程序也可以在命令行上完成。例如,如果 JavaFX SDK 安装在/opt/javafx-sdk-17 中,则以下内容有效:

java -p /opt/javafx-sdk-17/lib/ --add-modules javafx.controls -cp target/classes org.modernclient.StandAlone

JShell 中的 JavaFX 应用程序

我们现在可以在 JShell 中运行代码了。正如我们之前提到的,JShell 使用 JVM 来执行。我们可以简单地在 JShell 中输入相同的命令,输出会立即告诉我们发生了什么。

如果我们假设 JavaFX SDK 安装在/opt/javafx-sdk-11.0.12 中,以下命令将启动 JShell,正确设置模块路径,并添加 javafx.controls 模块:

jshell --module-path /opt/javafx-sdk-11.0.12/lib/ --add-modules javafx.controls

我们现在可以输入最终构成 JavaFX 应用程序的命令,从导入开始:

img/468104_2_En_14_Figm_HTML.jpg

注意,我们没有添加包声明。这涉及到了普通 Java 应用程序和 JShell 代码之间的一点区别。创建包的目的是公开功能并在其他组件和其他库中使用这些功能。JShell 的概念是提供一个独立的、交互式的、自包含的环境;因此,公开包没有意义。

既然已经添加了导入,我们就可以创建 showHello()方法了。我们将首先以一种效率较低的方式使用 JShell,但是在下一节中,我们将展示如何以一种更高效的方式来实现这一点。

现在,我们简单地复制粘贴 showHello 方法。这将产生以下输出:

img/468104_2_En_14_Fign_HTML.jpg

JShell 编辑器允许将语句或声明拆分成多行。解析器认为在输入第一行之后,还需要更多的内容。因此,它只会在我们完成方法声明之后处理它。它将检测到最后的右花括号。

在 JShell 的早期版本中,显示了一个警告,告诉我们 static 关键字被忽略了。在 JShell 中,所有的顶级声明都是静态的,所以 keyword 在这个上下文中没有用。这是常规应用程序和 JShell 上下文中的代码之间的另一个区别。

既然方法已经声明了,我们就可以调用它了。这可以通过在 JShell 提示符下调用 showHello()来完成:

img/468104_2_En_14_Figo_HTML.jpg

该语句立即返回,并将呈现与前面的屏幕截图相同的图像。

JShell 中的 JavaFX 库

虽然上一节中的示例可以工作,但是它非常冗长,需要手动键入或复制粘贴,并且不允许快速原型化。JavaFX 和 JShell 结合的真正好处来自提供简单内容的库和函数,这些内容可以从 JShell 语句中调用。通常,这些函数是在 Java 文件中创建的,经过编译后可供 JShell 使用。这与 JShell 如何使用 ND4J 库非常相似。

例如,我们创建了一个简单的函数,它创建了一个包含一些分散数据的 JavaFX 图表。

我们将该函数编写为常规的 Java 函数,如下面的代码所示:

package org.modernclient;
import javafx.application.*;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.scene.Scene;
import javafx.scene.chart.NumberAxis;
import javafx.scene.chart.ScatterChart;
import javafx.scene.chart.XYChart;
import javafx.scene.chart.XYChart.Data;
import javafx.stage.Stage;
public class Plot {
    public static void scatter(double[] x, double[] y, String title) {
        Platform.startup(() -> {});
        Platform.setImplicitExit(false);
        Platform.runLater( () -> {
        NumberAxis xAxis = new NumberAxis();
        NumberAxis yAxis = new NumberAxis();
        ScatterChart chart = new ScatterChart(xAxis, yAxis);
        ObservableList<XYChart.Series> chartData =         FXCollections.observableArrayList();
        XYChart.Series<Number, Number> series = new XYChart.Series<>();
        ObservableList<Data<Number, Number>> data =         FXCollections.observableArrayList();
        for (int i = 0; i < x.length; i++) {
            Data<Number, Number> d = new Data<>(x[i],y[i]);
            data.add(d);
        }
        series.setData(data);
        chartData.setAll(series);
        chart.setData(chartData);
        Scene s = new Scene(chart, 400, 400);
        Stage stage = new Stage();
        stage.setTitle(title);
        stage.setScene(s);
        stage.show();
        });
    }
    public static void main(String[] args) {
        double[] x = new double[]{0.,1.,2.};
        double[] y = new double[]{0.,10.,16.};
        scatter(x, y, "plot");
    }
}

在这段代码中,我们定义了一个带有三个参数的 scatter 函数:一个包含 x 坐标的 double 数组、一个包含 y 坐标的 double 数组和一个图表标题。scatter 函数初始化 JavaFX 运行时,然后创建包含参数提供的数据的散点图。图表被添加到场景中并呈现在舞台上。

我们在这个类中添加了一个 main 函数,这样我们就可以使用常规的 Java 调用来测试这个函数。在这个主函数中,我们传递三个数据点(因此 x 数组有三个值,y 数组有三个值),并提供标题“plot”

为了获得图 14-6 中的 UI,我们将使用与之前相同的方法编译并运行应用程序:

img/468104_2_En_14_Fig6_HTML.jpg

图 14-6

运行应用程序的结果

Mvn compile exec:exec

我们现在将使这个函数对 JShell 可用,并从那里调用它。

我们使用之前使用的 Maven 插件启动 JShell:

mvn com.github.johnpoth:jshell-maven-plugin:1.1:run

确保在包含用于绘图代码的 pom.xml 文件的目录中调用该命令。这将把编译后的类和依赖项添加到类和模块路径中。

当 JShell 启动时,会显示以下消息:

img/468104_2_En_14_Figp_HTML.jpg

我们首先通过输入以下命令导入绘图类

Import org.modernclient.Plot;

我们还创建了两个数组,包含我们想要可视化的数据点的 x 和 y 值:

img/468104_2_En_14_Figq_HTML.jpg

最后,为了创建如图 14-7 所示的图形,我们调用散布函数如下:

img/468104_2_En_14_Fig7_HTML.jpg

图 14-7

由前面的代码生成的新绘图

Plot.scatter(c, d, "plot from jshell");

JShell 的一个很好的特性是有许多内置命令,允许它更有效地与编辑器一起工作。例如,/list 命令显示了按顺序执行的命令。成功应用前面的步骤后,/list 命令的结果如下所示:

img/468104_2_En_14_Figr_HTML.jpg

这张截图清楚地表明,在 JShell 中调用预定义的函数比在 JShell 中手动声明所有函数要容易得多。根据经验,如果希望函数经常被修改,可以在 JShell 中声明它。但是如果您主要想评估函数并检查结果,那么在类中声明它们并在 JShell 环境中导入它们更合适。

结论

Java 和 JavaFX 的结合为科学工作提供了坚实的基础。Java 本身已经具有很高的性能和高度的可伸缩性。使用 JShell 和 ND4J ,(数据)科学家可以使用熟悉的线性代数例程,从事需要灵活操作数据的研究项目。

通常,设计过程中的交互式可视化缩短了开发周期并提高了结果的质量。在研究阶段引入 JavaFX 可视化允许实时可视化和高度交互式的科学应用。

posted @ 2024-08-06 16:34  绝不原创的飞龙  阅读(106)  评论(0编辑  收藏  举报