Java8-API-入门手册-全-

Java8 API 入门手册(全)

原文:Beginning Java 8 APIs

协议:CC BY-NC-SA 4.0

一、Swing 简介

在本章中,您将学习

  • 什么是秋千
  • 基于字符的界面和图形用户界面的区别
  • 如何开发最简单的 Swing 程序
  • 什么是 JFrame,它是如何由不同的组件组成的
  • 如何向 JFrame 添加组件
  • 什么是布局管理器以及 Swing 中不同类型的布局管理器
  • 如何创建可重复使用的框架
  • 如何处理事件
  • 如何处理鼠标事件以及如何使用适配器类来处理鼠标事件

什么是 Swing?

Swing 提供了图形用户界面(GUI)组件来开发 Java 应用,其中包含一组丰富的图形,如窗口、按钮、复选框等。什么是图形用户界面?在我定义 GUI 之前,让我先定义一个用户界面(UI)。程序做三件事:

  • 接受用户的输入
  • 处理输入,以及
  • 产生输出

根据输入和输出,用户界面提供了一种在用户和程序之间交换信息的手段。换句话说,用户界面定义了用户和程序之间交互的方式。使用键盘键入文本、使用鼠标选择菜单项或单击按钮都可以为程序提供输入。程序的输出可以以基于字符的文本、诸如条形图的图形、图片等形式显示在计算机显示器上。

你写过很多 Java 程序。您已经看到了这样的程序,用户必须在控制台上以文本输入的形式向程序提供输入,而程序会在控制台上打印输出。用户输入和程序输出都是文本形式的用户界面被称为基于字符的用户界面。GUI 允许用户使用称为控件或小部件的图形元素,使用键盘、鼠标和其他设备与程序进行交互。

图 1-1 显示了一个程序,让用户输入一个人的姓名和出生日期(DOB ),并使用键盘保存信息。这是一个基于字符的用户界面的例子。

A978-1-4302-6662-4_1_Fig1_HTML.jpg

图 1-1。

An example of a program with a character-based user interface

图 1-2 让用户执行相同的动作,但是使用图形用户界面。它在一个窗口中显示六个图形元素。它使用了两个标签(Name:DOB:)、两个文本字段(用户将在其中输入NameDOB值)以及两个按钮(SaveClose)。与基于字符的用户界面相比,图形用户界面使用户与程序的交互更加容易。你能猜出你打算在这一章开发什么样的应用吗?这将是所有关于图形用户界面。GUI 开发很有趣,比基于字符的程序开发稍微复杂一些。一旦你理解了 GUI 开发中涉及的元素,使用它会很有趣。

A978-1-4302-6662-4_1_Fig2_HTML.jpg

图 1-2。

An example of a program with a graphical user interface

本章试图介绍使用 Swing 组件和顶级容器进行 GUI 开发的基础知识。已经注意为那些以前可能没有使用任何编程语言/工具(例如,Visual C++、Visual Basic、VB.NET 或 PowerBuilder)来开发 GUI 的程序员解释 GUI 相关的细节。如果你已经使用了一种 GUI 开发语言/工具,你会更容易理解本章的内容。Swing 是一个庞大的主题,不可能涵盖它的每个细节。它本身就值得一本书。事实上,市场上有几本书专门介绍 Swing。

容器是一个可以容纳其他组件的组件。最高级别的容器称为顶级容器。一个JFrame、一个JDialog、一个JWindow和一个JApplet是顶级容器的例子。一个JPanel就是一个简单容器的例子。一个JButton,一个JTextField,等等。都是组件的例子。在 Swing 应用中,每个组件都必须包含在一个容器中。容器被称为组件的父组件,组件被称为容器的子组件。这种父子关系(或容器包含关系)被称为包含层次结构。要在屏幕上显示组件,顶级容器必须位于容器层次结构的根。每个 Swing 应用必须至少有一个顶级容器。图 1-3 显示了一个 Swing 应用的包容层次结构。顶级容器包含一个名为“容器 1”的容器,容器 1 又包含一个名为“组件 1”的组件和一个名为“容器 2”的容器,容器 2 又包含两个名为“组件 2”和“组件 3”的组件

A978-1-4302-6662-4_1_Fig3_HTML.jpg

图 1-3。

Containment hierarchy in a Swing application

最简单的 Swing 程序

让我们从最简单的 Swing 程序开始。您将显示一个JFrame,这是一个顶级容器,其中没有任何组件。要创建并显示一个JFrame,您需要执行以下操作:

  • 创建一个JFrame对象。
  • 让它可见。

要创建一个JFrame对象,可以使用JFrame类的一个构造函数。其中一个构造函数接受一个字符串,该字符串将显示为JFrame的标题。代表 Swing 组件的类在javax.swing包中,同样的还有JFrame类。下面的代码片段创建了一个标题设置为“Simplest Swing”的JFrame对象:

// Create a JFrame object

JFrame frame = new JFrame("Simplest Swing");

当你创建一个JFrame对象时,默认情况下,它是不可见的。你需要调用它的setVisible (boolean visible)方法来使它可见。如果您将true传递给这个方法,JFrame将变得可见,如果您传递false,它将变得不可见。

// Make the JFrame visible on the screen

frame.setVisible(true);

这就是开发第一个 Swing 应用所要做的全部工作!事实上,您可以将创建和显示一个JFrame的两个语句包装成一个语句,如下所示:

new JFrame("Simplest Swing").setVisible(true);

Tip

创建一个JFrame并让它在main线程中可见并不是启动 Swing 应用的正确方式。但是,这对您将在这里使用的琐碎程序没有任何损害,所以我将继续使用这种方法来保持代码简单易学,这样您就可以专注于您正在学习的主题。还需要理解 Swing 中的事件处理和线程机制,才能理解为什么需要以另一种方式启动 Swing 应用。第三章详细解释了如何启动一个 Swing 应用。创建和显示JFrame的正确方法是包装 GUI 创建并使其在Runnable中可见,并将Runnable传递给javax.swing.SwingUtilitiesjava.awt.EventQueue类的invokeLater()方法,如下所示:

import javax.swing.JFrame;

import javax.swing.SwingUtilities;

...

SwingUtilities.invokeLater(() -> new JFrame("Test").setVisible(true));

清单 1-1 有创建和显示一个JFrame的完整代码。运行该程序时,在屏幕左上角显示一个JFrame,如图 1-4 所示。该图显示了程序在 Windows XP 上运行时的画面。在其他平台上,框架看起来可能有点不同。本章中大多数图形用户界面的截图都是在 Windows XP 上拍摄的。

清单 1-1。最简单的 Swing 程序

// SimplestSwing.java

package com.jdojo.swing;

import javax.swing.JFrame;

public class SimplestSwing {

public static void main(String[] args) {

// Create a frame

JFrame frame = new JFrame("Simplest Swing");

// Display the frame

frame.setVisible(true);

}

}

这不是很令人印象深刻,是吗?不要绝望。随着您对 Swing 的了解越来越多,您将会改进这个程序。这只是向您展示了 Swing 所提供功能的冰山一角。

您可以调整图中 1-4 所示的JFrame的大小,使其变大。将鼠标指针放在显示的JFrame的四个边(左、上、右或下)或四个角上。当您将鼠标指针放在JFrame的边缘时,它的形状会变成一个调整大小的指针(一条两端都有箭头的线)。然后只需拖动调整大小鼠标指针,向您想要的方向调整JFrame的大小。

A978-1-4302-6662-4_1_Fig4_HTML.jpg

图 1-4。

The Simplest Swing frame

图 1-5 显示了调整后的JFrame。注意,在创建JFrame时传递给构造函数的文本“Simplest Swing”显示在JFrame的标题栏中。

A978-1-4302-6662-4_1_Fig5_HTML.jpg

图 1-5。

The Simplest Swing frame after resizing

如何退出 Swing 应用?当运行清单 1-1 中列出的程序时,如何退出?当点击标题栏中的关闭按钮(标题栏上最右边带 X 的按钮)时,JFrame被关闭。但是,程序不会退出。如果您从命令提示符下运行该程序,当您关闭JFrame时,提示符不会返回。您必须强制退出该程序,例如,如果您在 Windows 上从命令提示符运行该程序,请按 Ctrl + C。那么,如何退出 Swing 应用呢?您可以定义一个JFrame的四种行为之一,以确定当JFrame关闭时会发生什么。它们在javax.swing.WindowsConstants接口中被定义为四个常量。JFrame类实现了WindowsConstants接口。您可以使用JFrame.CONSTANT_NAME语法引用所有这些常量(或者您可以使用WindowsConstants.CONSTANT_NAME语法)。这四个常数是

  • DO_NOTHING_ON_CLOSE:当用户关闭JFrame时,该选项不做任何事情。如果你为一个JFrame设置了这个选项,你必须提供一些其他的方法来退出应用,比如一个Exit按钮或者JFrame中的一个Exit菜单选项。
  • HIDE_ON_CLOSE:该选项只是在用户关闭时隐藏一个JFrame。这是默认行为。这就是当你点击标题栏的关闭按钮来关闭清单 1-1 中列出的程序时发生的情况。JFrame只是变得不可见,程序仍在运行。
  • DISPOSE_ON_CLOSE:该选项在用户关闭JFrame时隐藏并处理。处置一个JFrame会释放它所使用的任何操作系统级资源。注意HIDE_ON_CLOSEDISPOSE_ON_CLOSE的区别。当您使用选项HIDE_ON_CLOSE时,一个JFrame只是被隐藏,但它仍然使用所有的操作系统资源。如果你的JFrame经常隐藏和显示,你可以使用这个选项。但是,如果您的JFrame消耗了许多资源,您可能希望使用DISPOSE_ON_CLOSE选项,这样资源可以在不显示时被释放和重用。
  • EXIT_ON_CLOSE:该选项退出应用。当JFrame关闭时,设置该选项有效,如同System.exit()被调用。这个选项应该小心使用。此选项将退出应用。如果屏幕上显示不止一个JFrame或任何其他类型的窗口,对一个JFrame使用此选项将关闭所有其他窗口。请谨慎使用此选项,因为当应用退出时,您可能会丢失任何未保存的数据。

您可以通过将四个常量中的一个传递给setDefaultCloseOperation()方法来设置JFrame的默认关闭行为,如下所示:

// Exit the application when the JFrame is closed

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

你用第一个例子解决了一个问题。另一个问题是JFrame显示时没有可视区域。它只显示标题栏。您需要设置JFrame可见之前或之后的大小和位置。框架的大小由其宽度和高度(以像素为单位)来定义,您可以使用其setSize (int width, int height)方法来设置。该位置由相对于屏幕左上角的JFrame左上角的(x,y)坐标定义。默认情况下,它的位置被设置为(0,0),这就是JFrame显示在屏幕左上角的原因。您可以使用setLocation(int x, int y)方法设置JFrame的(x,y)坐标。如果你想一步设置它的大小和位置,使用它的setBounds(int x, int y, int width, int height)方法。清单 1-2 在最简单的 Swing 程序中解决了这两个问题。

清单 1-2。修订的最简单 Swing 程序

// RevisedSimplestSwing.java

package com.jdojo.swing;

import javax.swing.JFrame;

public class RevisedSimplestSwing {

public static void main(String[] args) {

// Create a frame

JFrame frame = new JFrame("Revised Simplest Swing");

// Set the default close behavior to exit the application

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

// Set the x, y, width and height properties in one go

frame.setBounds(50, 50, 200, 200);

// Display the frame

frame.setVisible(true);

}

}

Tip

您可以通过用一个null参数调用它的setLocationRelativeTo()方法来将一个JFrame放在中间。

JFrame 的组件

您在前面的部分显示了一个JFrame。它看起来是空的;然而,它并不是真的空的。当您创建一个JFrame时,以下事情会自动为您完成:

  • 一个称为根窗格的容器被添加为JFrame的唯一子元素。根窗格是一个容器。它是JRootPane类的一个对象。你可以通过使用JFrame类的getRootPane()方法来获得根窗格的引用。
  • 名为“玻璃窗格”和“分层窗格”的两个容器被添加到根窗格中。默认情况下,玻璃窗格是隐藏的,它放置在分层窗格的顶部。顾名思义,玻璃窗格是透明的,即使你让它看得见,你也能看穿它。分层窗格之所以这样命名,是因为它可以在不同的层中容纳其他容器或组件。可选地,分层窗格可以包含菜单栏。但是,当您创建JFrame时,默认情况下不会添加菜单栏。你可以通过使用JFrame类的getGlassPane()getLayeredPane()方法分别得到玻璃窗格和分层窗格的引用。
  • 一个称为内容窗格的容器被添加到分层窗格中。默认情况下,内容窗格是空的。这是您应该添加所有 Swing 组件的容器,比如按钮、文本字段、标签等。大多数时候,您将使用JFrame的内容窗格。您可以通过使用JFrame类的getContentPane()方法来获取内容窗格的引用。

图 1-6 显示了 a JFrame的组装。根窗格、分层窗格和玻璃窗格覆盖了一个JFrame的整个可视区域。一个JFrame的可视区域是它的大小减去所有四个边上的插图。容器的 Insets 由容器四周的边框所使用的空间组成:顶部、左侧、底部和右侧。对于一个JFrame,顶部的插图代表标题栏的高度。图 1-6 描绘了比根窗格更小的分层窗格,以便更好地可视化。

A978-1-4302-6662-4_1_Fig6_HTML.jpg

图 1-6。

The making of a JFrame

你糊涂了吗?如果你对一个JFrame的所有窗格感到困惑,这里有一个更简单的解释。把一个JFrame想象成一个相框。一个相框有一个玻璃盖,一个玻璃格形式的JFrame也是如此。在玻璃罩后面,你放置你的照片。这就是你的分层窗格。您可以在一个相框中放置多张图片。每张图片将构成玻璃盖后面的一层。只要一张图片没有与另一张完全重叠,您就可以查看它的全部或部分内容。在不同图层中拍摄的所有图片构成了相框的分层窗格。离玻璃盖最远的图片图层是您的内容窗格。通常你的相框里只有一张图片。分层窗格也是如此;默认情况下,它包含一个内容窗格。画框里的画是感兴趣的内容,画放在那里。内容窗格也是如此;所有组件都放在内容窗格中。

下面列出了JFrame的包容层级。一个JFrame在层次的顶端,菜单栏(默认不添加;此处显示是为了完整性),内容窗格位于容器层次结构的底部。

JFrame

root pane

glass pane

layered pane

menu bar

content pane

如果您仍然不能理解JFrame的所有“难点”(阅读窗格),您可以稍后再看这一部分。现在,您只需要理解JFrame的一个窗格,那就是内容窗格,它包含了JFrame的 Swing 组件。您应该将所有想要添加到JFrame的组件添加到它的内容窗格中。您可以按如下方式获取内容窗格的引用:

// Create a JFrame

JFrame frame = new JFrame("Test");

// Get the reference of the content pane

Container contentPane = frame.getContentPane();

向 JFrame 添加组件

本节解释如何将组件添加到JFrame的内容窗格中。使用容器的add()方法(注意内容窗格也是一个容器)将组件添加到容器中。

// Add aComponent to aContainer

aContainer.add(aComponent);

add()方法被重载。除了要添加的组件之外,该方法的参数还取决于其他因素,例如您希望组件在容器中如何布局。下一节将讨论所有版本的add()方法。

我将把当前的讨论限制在为JFrame添加一个按钮上,这是一个 Swing 组件。JButton类的对象代表一个按钮。如果您使用过 Windows,您一定使用过按钮,如消息框上的“确定”按钮、Internet 浏览器窗口上的“后退”和“前进”按钮。通常,JButton包含文本,也称为标签。这就是你如何创建一个JButton:

// Create a JButton with Close text

JButton closeButton = new JButton("Close");

要将closeButton添加到JFrame的内容窗格中,您必须做两件事:

  • 获取JFrame的内容窗格的引用。Container contentPane = frame.getContentPane();
  • 调用内容窗格的add()方法。contentPane.add(closeButton);

这就是将组件添加到内容窗格的全部工作。如果您想使用一行代码添加一个JButton,您可以通过将所有三个语句合并成一个来实现,如下所示:

frame.getContentPane().add(new JButton("Close"));

JFrame添加组件的代码如清单 1-3 所示。当你运行程序时,你得到一个如图 1-7 所示的JFrame。当你点击Close按钮时没有任何反应,因为你还没有给它添加任何动作。

清单 1-3。向 JFrame 添加组件

// AddingComponentToJFrame.java

package com.jdojo.swing;

import javax.swing.JFrame;

import javax.swing.JButton;

import java.awt.Container;

public class AddingComponentToJFrame {

public static void main(String[] args) {

JFrame frame = new JFrame("Adding Component to JFrame");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

// Add a close button

JButton closeButton = new JButton("Close");

contentPane.add(closeButton);

// set the size of the frame 300 x 200

frame.setBounds(50, 50, 300, 200);

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig7_HTML.jpg

图 1-7。

A JFrame with a JButton with Close as its text

代码完成了向JFrame添加带有Close文本的JButton的工作。然而,JButton看起来非常大,它占据了JFrame的整个可视区域。请注意,您已经使用setBounds()方法将JFrame的大小设置为 300 像素宽和 200 像素高。因为JButton填满了整个JFrame,你能把JFrame的尺寸设置的小一点吗?或者,你能为JButton本身设置尺寸吗?这两种建议在这种情况下都行不通。如果你想让JFrame变小,你需要猜测它需要变小多少。如果你想为JButton设置大小,它会惨败;JButton将始终填充JFrame的整个可视区域。这是怎么回事?要完全理解正在发生的事情,您需要阅读下一节关于布局管理器的内容。

Swing 为计算JFrameJButton的大小提供了一个神奇而快速的解决方案。JFrame类的pack()方法就是那个神奇的解决方案。该方法检查您添加到JFrame中的所有组件,决定它们的首选大小,并将JFrame的大小设置为刚好足以显示所有组件。当你调用这个方法时,你不需要设置JFrame的大小。pack()方法将计算JFrame的大小并为您设置。要修复大小调整问题,请移除对setBounds()方法的调用,并添加对pack()方法的调用。注意,setBounds()方法也为JFrame设置了(x,y)坐标。如果还想将JFrame的(x,y)坐标设置为(50,50),可以使用它的setLocation(50, 50)方法。清单 1-4 包含修改后的代码,图 1-8 显示了结果JFrame

清单 1-4。打包 JFrame 的所有组件

// PackedJFrame.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JButton;

public class PackedJFrame {

public static void main(String[] args) {

JFrame frame = new JFrame("Adding Component to JFrame");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

// Add a close button

JButton closeButton = new JButton("Close");

Container contentPane = frame.getContentPane();

contentPane.add(closeButton);

// Calculates and sets appropriate size for the frame

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig8_HTML.jpg

图 1-8。

Packed JFrame with a JButton

到目前为止,您已经成功地将一个JButton添加到一个JFrame中。让我们在同一个JFrame上再加一个JButton。把这个新按钮叫做helpButton。代码将类似于清单 1-4,除了这次您将添加两个JButton类的实例。清单 1-5 包含了完整的程序。图 1-9 显示了运行程序时的结果。

清单 1-5。向 JFrame 添加两个按钮

// JFrameWithTwoJButtons.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JButton;

public class JFrameWithTwoJButtons {

public static void main(String[] args) {

JFrame frame = new JFrame("Adding Component to JFrame");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

// Add two buttons - Close and Help

JButton closeButton = new JButton("Close");

JButton helpButton = new JButton("Help");

Container contentPane = frame.getContentPane();

contentPane.add(closeButton);

contentPane.add(helpButton);

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig9_HTML.jpg

图 1-9。

A JFrame with two buttons: Close and Help. Only the Help button is visible

当您添加了Help按钮时,您丢失了Close按钮。这是否意味着你只能给一个JFrame添加一个按钮?答案是否定的。你可以给一个JFrame添加任意多的按钮。那么,你的Close按钮在哪里?在我回答这个问题之前,您需要理解内容窗格的布局机制。

内容窗格是一个容器。你给它添加组件。但是,它将布局所有组件的任务交给了一个称为布局管理器的对象。布局管理器只是一个 Java 对象,它唯一的工作就是确定容器中组件的位置和大小。清单 1-5 中的例子是精心挑选的,目的是向您介绍布局管理器的概念。存在许多类型的布局管理器。它们在容器内放置组件和确定组件大小的方式不同。

默认情况下,JFrame的内容窗格使用一个名为BorderLayout的布局管理器。由于BorderLayout布局组件的方式,在前面的例子中只显示了Help按钮。事实上,当您添加两个按钮时,内容窗格会收到这两个按钮。为了确认这两个按钮仍然在内容窗格中,在清单 1-5 中的main()方法的末尾添加下面的代码片段,它显示了内容窗格拥有的组件的数量。它将在标准输出上打印一条消息:"Content Pane has 2 components."每个容器都有一个getComponents()方法,该方法返回添加到其中的组件数组。

// Get the components added to the content pane

Component[] comps = contentPane.getComponents();

// Display how many components the content pane has

System.out.println("Content Pane has " + comps.length + " components.");

有了这个背景,是时候学习各种版面管理器了。当我在后面的部分讨论BorderLayout管理器时,您将解决丢失Close按钮的难题。但是在讨论各种布局管理器之前,我将向您介绍一些在使用 Swing 应用时经常使用的实用程序类。

Tip

一个组件一次只能添加到一个容器中。如果将同一个组件添加到另一个容器中,该组件将从第一个容器中移除并添加到第二个容器中。

一些实用程序类

在开始开发一些严肃的 Swing GUIs 之前,有必要提一下一些经常使用的实用程序类。它们是简单的类。它们中的大多数都有一些可以在构造函数中指定的属性,并且有这些属性的 getters 和 setters。

点类

顾名思义,Point类的对象表示二维空间中的一个位置。二维空间中的位置由两个值表示:x 坐标和 y 坐标。Point级在java.awt包里。下面的代码片段演示了它的用法:

// Create an object of the Point class with (x, y) coordinate of (20, 40)

Point p = new Point(20, 40);

// Get the x and y coordinate of p

int x = p.getX();

int y = p.getY();

// Set the x and y coordinate of p to (10, 60)

p.setLocation(10, 60);

Swing 中的Point类的主要用途是设置和获取组件的位置(x 和 y 坐标)。例如,您可以设置一个JButton的位置。

JButton closeButton = new JButton("Close");

// The following two statements do the same thing.

// You will use one of the following statements and not both.

closeButton.setLocation(10, 15);

closeButton.setLocation(new Point(10, 15));

// Get the location of the closeButton

Point p = closeButton.getLocation();

维度类

一个Dimension类的对象包装了一个组件的widthheight。部件的widthheight统称为其尺寸。换句话说,Dimension类的一个对象被用来表示一个组件的大小。您可以使用Dimension类的对象包装任意两个整数。然而,在这一章中,它将被用在组件大小的上下文中。这个类在java.awt包里。

// Create an object of the Dimension class with a width and height of 200 and 20

Dimension d  = new Dimension(200, 20);

// Set the size of closeButton to 200 X 20\. Both of the statements have the same efecct.

// You will use one of the following two statements.

closeButton.setSize(200, 20);

closeButton.setsize(d);

// Get the size of closeButton

Dimension d2 = closeButton.getSize();

int width = d2.width;

int height = d2.height;

Insets 类

Insets类的对象表示容器周围的空间。它包装了四个名为topleftbottomright的属性。它们的值表示容器四边的剩余空间。班级在java.awt包里。

// Create an object of the Insets class

// using its constructor Insets(top, left, bottom, right)

Insets ins = new Insets(20, 5, 5, 5);

// Get the insets of a JFrame

Insets ins = frame.getInsets();

int top = ins.top;

int left = ins.left;

int bottom = ins.bottom;

int right = ins.right;

矩形类

顾名思义,Rectangle类的一个实例代表一个矩形。在java.awt包里。你可以用许多方法定义一个矩形。一个Rectangle由三个属性定义:

  • 左上角的(x,y)坐标
  • 宽度
  • 高度

你可以把一个Rectangle对象想象成一个Point对象和一个Dimension对象的组合;Point对象保存左上角的(x,y)坐标,Dimension对象保存宽度和高度。您可以通过指定属性的不同组合来创建一个Rectangle类的对象。

// Create a Rectangle object whose upper-left corner is at (0, 0)

// with width and height as zero

Rectangle r1 = new Rectangle();

// Create a Rectangle object from a Point object with its width and height as zero

Rectangle r2 = new Rectangle(new Point(10, 10));

// Create a Rectangle object from a Point object and a Dimension object

Rectangle r3 = new Rectangle(new Point(10, 10), new Dimension(200, 100));

// Create a Rectangle object by specifying its upper-left corner's

// coordinate at (10, 10) and width as 200 and height as 100

Rectangle r4 = new Rectangle(10, 10, 200, 100);

Rectangle类定义了许多方法来操作Rectangle对象并查询其属性,例如左上角的(x,y)坐标、宽度和高度。

Rectangle类的对象定义了 Swing 应用中组件的位置和大小。组件的位置和大小被称为它的边界。两个方法,setBounds()getBounds(),可以用来设置和获取任何组件或容器的边界。setBounds()方法是重载的,您可以指定组件或Rectangle对象的 x、y、宽度和高度属性。getBounds()方法返回一个Rectangle对象。在清单 1-2 中,您使用了setBounds()方法来设置框架的 x、y、宽度和高度。请注意,组件的“边界”是其位置和大小的组合。setLocation()setSize()方法的组合将实现与setBounds()方法相同的功能。同样,你也可以用getLocation() (or,getX()getY())和getSize() (or,getWidth()getHeight())的组合来代替使用getBounds()的方法。

布局经理

容器使用布局管理器来计算其所有组件的位置和大小。换句话说,布局管理器的工作是计算容器中所有组件的四个属性(x、y、宽度和高度)。x 和 y 属性确定组件在容器中的位置。宽度和高度属性决定组件的大小。您可能会问,“为什么需要布局管理器来执行计算组件的四个属性的简单任务?难道不能在程序中指定这四个属性,让容器用它们来显示组件吗?”答案是肯定的。您可以在程序中指定这些属性。如果这样做,当调整容器大小时,组件将不会重新定位和调整大小。此外,您必须为您的应用将在其上运行的所有平台指定组件的大小,因为不同的平台呈现的组件略有不同。假设您的应用以多种语言显示文本。一个JButton,比如一个Close按钮的最佳大小,在不同的语言中是不同的,你必须计算每种语言中Close按钮的大小,并根据应用使用的语言进行设置。但是,如果您使用布局管理器,则不必考虑所有这些因素。布局管理器将为您做这些简单但耗时的事情。

使用布局管理器是可选的。如果不使用布局管理器,则需要负责计算和设置容器中所有组件的位置和大小。

从技术上讲,布局管理器是一个实现了LayoutManager接口的 Java 类的对象。从LayoutManager接口继承了另一个名为LayoutManager2的接口。一些布局管理器类实现了LayoutManager2接口。两个接口都在java.awt包里。

布局管理器有很多。一些布局管理器很简单,易于手工编码。有些手工编码非常复杂,应该由 NetBeans 之类的 GUI 构建工具来使用。如果没有可用的布局管理器满足您的需求,您可以创建自己的布局管理器。一些有用的布局管理器可以在互联网上免费获得。有时你需要嵌套它们来获得想要的效果。我将在本节讨论以下布局管理器:

  • FlowLayout
  • BorderLayout
  • CardLayout
  • BoxLayout
  • GridLayout
  • GridBagLayout
  • GroupLayout
  • SpringLayout

每个容器都有一个默认的布局管理器。一个JFrame的内容窗格的默认布局管理器是BorderLayout,对于一个JPanel,它是FlowLayout。它是在创建容器时设置的。您可以通过使用容器的setLayout()方法来更改容器的默认布局管理器。如果不希望容器使用布局管理器,可以将null传递给setLayout()方法。您可以使用容器的getLayout()方法来获取容器当前使用的布局管理器的引用。

// Set FlowLayout as the layout manager for the content pane of a JFrame

JFrame frame = new JFrame("Test Frame");

Container contentPane = frame.getContentPane();

contentPane.setLayout(new FlowLayout());

// Set BorderLayout as the layout manager for a JPanel

JPanel panel = new JPanel();

panel.setLayout(new BorderLayout());

// Get the layout manager for a container

LayoutManager layoutManager = container.getLayout()

从 Java 5 开始,对JFrame上的add()setLayout()方法的调用被转发到它的内容窗格。在 Java 5 之前,在JFrame上调用这些方法会抛出运行时异常。也就是说,在 Java 5 中,这两个调用frame.setLayout()frame.add()将与调用frame.getContentPane().setLayout()frame.getContentPane().add()做同样的事情。注意到JFramegetLayout()方法返回的是JFrame的布局管理器,而不是它的内容窗格,这一点非常重要。为了避免从JFrame到其内容窗格的不对称调用转移(一些调用被转移,一些不被转移)的麻烦,最好直接调用内容窗格的方法,而不是在JFrame上调用它们。

flow layout-流程配置

FlowLayout是 Swing 中最简单的布局管理器。它先水平布局组件,然后垂直布局。它按照组件被添加到容器的顺序放置组件。当水平放置组件时,可以从左到右或从右到左放置。水平布局方向取决于容器的方向。您可以通过调用容器的setComponentOrientation()方法来设置容器的方向。如果你想设置一个容器及其所有子容器的方向,你可以使用applyComponentOrientation()方法。下面是设置容器方向的一段代码:

// Method – 1

// Set the orientation of the content pane of a frame to "right to left"

JFrame frame = new JFrame("Test");

Container pane = frame.getContentPane();

pane.setComponentOrientation(ComponentOrientation.RIGHT_TO_LEFT);

// Method – 2

// Set the orientation of the content pane and all its children to "right to left"

JFrame frame = new JFrame("Test");

Container pane = frame.getContentPane();

pane.applyComponentOrientation(ComponentOrientation.RIGHT_TO_LEFT);

如果您的应用是多语言的,并且组件方向将在运行时决定,您可能希望以一种更通用的方式来设置组件的区域设置和方向,而不是在您的程序中对其进行硬编码。您可以为应用中的所有 Swing 组件全局设置默认语言环境,如下所示:

// "ar" is used for Arabic locale

JComponent.setDefaultLocale(new Locale("ar"));

当您创建一个JFrame时,您可以根据默认的区域设置获取组件的方向,并将其设置为框架及其子框架。这样,您不必为应用中的每个容器设置方向。

// Get the default locale

Locale defaultLocale = JComponent.getDefaultLocale();

// Get the component's orientation for the default locale

ComponentOrientation componentOrientation = ComponentOrientation.getOrientation(defaultLocale);

// Apply the component's default orientation for the whole frame

frame.applyComponentOrientation(componentOrientation);

A FlowLayout试图将所有组件放入一行,给它们自己喜欢的大小。如果一行中容纳不下所有组件,它将开始另一行。每个布局管理器都必须计算它需要布置所有组件的空间的高度和宽度。A FlowLayout要求宽度,这是所有组件的首选宽度之和。它要求高度,即容器中最高组件的高度。它为宽度和高度增加了额外的空间,以考虑组件之间的水平和垂直间隙。清单 1-6 展示了如何为JFrame的内容窗格使用FlowLayout。它向内容窗格添加了三个按钮。图 1-10 显示了使用FlowLayout的三按钮屏幕。

清单 1-6。使用流程布局管理器

// FlowLayoutTest.java

package com.jdojo.swing;

import java.awt.Container;

import java.awt.FlowLayout;

import javax.swing.JButton;

import javax.swing.JFrame;

public class FlowLayoutTest {

public static void main(String[] args) {

JFrame frame = new JFrame("Flow Layout Test");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

contentPane.setLayout(new FlowLayout());

for(int i = 1; i <= 3; i++) {

contentPane.add(new JButton("Button " + i));

}

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig10_HTML.jpg

图 1-10。

Three buttons in a JFrame with a FlowLayout Manager

当水平展开框架时,按钮显示如图 1-11 所示。

A978-1-4302-6662-4_1_Fig11_HTML.jpg

图 1-11。

After the JFrame using a FlowLatout has been expanded horizontally

默认情况下,FlowLayout将所有组件在容器中心对齐。您可以通过调用其setAlignment()方法或在其构造函数中传递对齐来更改对齐,如下所示:

// Set the alignment when you create the layout manager object

FlowLayout flowLayout = new FlowLayout(FlowLayout.RIGHT);

// Set the alignment after you have created the flow layout manager

flowLayout.setAlignment(FlowLayout.RIGHT);

FlowLayout类中定义了以下五个常数来表示五种不同的对齐:LEFTRIGHTCENTERLEADINGTRAILING。前三个常数的定义是显而易见的。LEADING对准可以是左对准也可以是右对准;这取决于组件的方向。如果组件的方向是RIGHT_TO_LEFT,则LEADING对准表示RIGHT。如果组件的方向是LEFT_TO_RIGHT,则LEADING对准表示LEFT。类似地,TRAILING对齐可能意味着向左或向右。如果组件的方向是RIGHT_TO_LEFT,则TRAILING对准表示LEFT。如果组件的方向是LEFT_TO_RIGHT,则TRAILING对准表示RIGHT。使用LEADINGTRAILING而不是RIGHTLEFT总是一个好主意,所以你不必担心你的组件的方向。

你可以在FlowLayout类的构造函数中或者使用它的setHgap()setVgap()方法来设置两个组件之间的间隙。清单 1-7 给出了向一个JFrame添加三个按钮的完整代码。内容窗格使用带有LEADING对齐的FlowLayout,并且JFrame's方向设置为RIGHT_TO_LEFT。运行程序时,JFrame将如图 1-12 所示。

清单 1-7。自定义流程布局

// FlowLayoutTest2.java

package com.jdojo.swing;

import java.awt.ComponentOrientation;

import java.awt.Container;

import java.awt.FlowLayout;

import javax.swing.JButton;

import javax.swing.JFrame;

public class FlowLayoutTest2 {

public static void main(String[] args) {

int horizontalGap = 20;

int verticalGap = 10;

JFrame frame = new JFrame("Flow Layout Test");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

FlowLayout flowLayout =

new FlowLayout(FlowLayout.LEADING, horizontalGap, verticalGap);

contentPane.setLayout(flowLayout);

frame.applyComponentOrientation(

ComponentOrientation.RIGHT_TO_LEFT);

for(int i = 1; i <= 3; i++) {

contentPane.add(new JButton("Button " + i));

}

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig12_HTML.jpg

图 1-12。

A JFrame having three buttons and a customized FlowLayout

你必须记住,一个FlowLayout试图将所有的组件仅排列在一行中。因此,它不要求适合所有组件的高度。相反,它要求容器中最高组件的高度。为了演示这一微妙之处,尝试在JFrame中添加 30 个按钮,这样它们就不在一行中。以下代码片段演示了这一点:

JFrame frame = new JFrame("Welcome to Swing");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

frame.getContentPane().setLayout(new FlowLayout());

for(int i = 1; i <= 30; i++) {

frame.getContentPane().add(new JButton("Button " + i));

}

frame.pack();

frame.setVisible(true);

JFrame如图 1-13 所示。您可以看到 30 个按钮并没有全部显示出来。如果您调整JFrame的大小,使其高度变大,您将能够看到所有按钮,如图 1-14 所示。FlowLayout隐藏了不能在一行中显示的组件。

A978-1-4302-6662-4_1_Fig14_HTML.jpg

图 1-14。

A JFrame with 30 buttons after it is resized

A978-1-4302-6662-4_1_Fig13_HTML.jpg

图 1-13。

A JFrame with 30 buttons. Not all buttons are displayed

FlowLayout的特性有一个非常重要的含义,它试图在一行中布局所有组件。它要求高度刚好能够显示最高的组件。如果您将一个带有FlowLayout管理器的容器嵌套在另一个也使用FlowLayout管理器的容器中,您将永远不会在嵌套的容器中看到多于一行。为了演示这一点,向一个JPanel添加 30 个JButton实例。一个JPanel是一个空容器,默认布局管理器是一个FlowLayout。将JFrame的内容窗格的布局管理器设置为FlowLayout,并将JPanel添加到JFrame的内容窗格。通过这种方式,您可以将带有FlowLayout的容器JPanel嵌套在另一个带有FlowLayout的容器(内容窗格)中。清单 1-8 包含了演示这一点的完整程序。当运行程序时,产生的JFrame如图 1-15 所示。即使您调整JFrame的大小使其高度变大,您也只能看到一行按钮。

清单 1-8。嵌套流程布局管理器

// FlowLayoutNesting.java

package com.jdojo.swing;

import java.awt.FlowLayout;

import javax.swing.JButton;

import javax.swing.JFrame;

import javax.swing.JPanel;

public class FlowLayoutNesting {

public static void main(String[] args) {

JFrame frame = new JFrame("FlowLayout Nesting");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

// Set the content pane's layout to FlowLayout

frame.getContentPane().setLayout(new FlowLayout());

// JPanel is an empty container with a FlowLayout manager

JPanel panel = new JPanel();

// Add thirty JButtons to the JPanel

for(int i = 1; i <= 30; i++) {

panel.add(new JButton("Button " + i));

}

// Add JPanel to the content pane

frame.getContentPane().add(panel);

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig15_HTML.jpg

图 1-15。

A nested FlowLayout always display only one row

我想在结束关于FlowLayout的讨论时指出,由于本节讨论的限制,它在现实应用中的使用非常有限。它通常用于原型制作。

边界布局

将集装箱的空间分为五个区域:北、南、东、西、中。当您向带有BorderLayout的容器添加组件时,您需要指定您想要将组件添加到五个区域中的哪一个。BorderLayout类定义了五个常量来标识这五个区域中的每一个。常量有NORTHSOUTHEASTWESTCENTER。例如,要在北部区域添加一个按钮,您可以编写

// Add a button to the north area of the container

JButton northButton = new JButton("North");

container.add(northButton, BorderLayout.NORTH);

一个JFrame的内容窗格的默认布局是一个BorderLayout。清单 1-9 包含了向JFrame的内容窗格添加五个按钮的完整程序。得到的JFrame如图 1-16 所示。

清单 1-9。向 BorderLayout 添加组件

// BorderLayoutTest.java

package com.jdojo.swing;

import java.awt.BorderLayout;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JButton;

public class BorderLayoutTest {

public static void main(String[] args) {

JFrame frame = new JFrame("BorderLayout Test");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container container = frame.getContentPane();

// Add a button to each of the five areas of the BorderLayout

container.add(new JButton("North"), BorderLayout.NORTH);

container.add(new JButton("South"), BorderLayout.SOUTH);

container.add(new JButton("East"), BorderLayout.EAST);

container.add(new JButton("West"), BorderLayout.WEST);

container.add(new JButton("Center"), BorderLayout.CENTER);

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig16_HTML.jpg

图 1-16。

Five areas of the BorderLayout

您最多可以将一个组件添加到BorderLayout的一个区域。你可以留下一些空白区域。如果您想在一个BorderLayout的区域中添加多个组件,您可以通过将这些组件添加到一个容器中,然后将该容器添加到所需的区域中。

一个BorderLayout (中的五个区域(北、南、东、西、中)的方向是固定的,不依赖于组件的方向。还有四个常量用于指定BorderLayout中的区域。这些常量是PAGE_STARTPAGE_ENDLINE_STARTLINE_ENDPAGE_STARTPAGE_END常量分别与NORTHSOUTH常量相同。LINE_STARTLINE_END常量根据容器的方向改变它们的位置。如果容器的方向是从左向右,LINE_STARTWEST相同,LINE_ENDEAST相同。如果容器的方向是从右向左,LINE_STARTEAST相同,LINE_ENDWEST相同。图 1-17 和图 1-18 描绘了BorderLayout不同组件方向区域的定位差异。

A978-1-4302-6662-4_1_Fig18_HTML.jpg

图 1-18。

A BorderLayout’s areas when the container’s orientation is right to left

A978-1-4302-6662-4_1_Fig17_HTML.jpg

图 1-17。

A BorderLayout’s areas when the container’s orientation is left to right

如果不指定组件的区域,它将被添加到中心。以下两个语句具有相同的效果:

// Assume that the container has a BorderLayout

// Add a button to the container without specifying the area

container.add(new JButton("Close"));

// The above statement is the same as the following

container.add(new JButton("Close"), BorderLayout.CENTER);

我已经说过,你最多可以给一个BorderLayout添加五个组件,五个区域各一个。如果在一个BorderLayout的相同区域添加多个组件会发生什么?也就是说,如果您编写以下代码,会发生什么情况?

// Assume that container has a BorderLayout

container.add(new JButton("Close"), BorderLayout.NORTH);

container.add(new JButton("Help"), BorderLayout.NORTH);

你会发现BorderLayout的北部区域只显示了一个按钮:最后添加的那个按钮。也就是说,北部区域只会显示Help按钮。这就是清单 1-5 中发生的事情。您在JFrame的内容窗格中添加了两个按钮CloseHelp。由于您没有指定要添加它们的BorderLayout的区域,所以它们都被添加到中心区域。由于在BorderLayout的每个区域只能有一个组件,所以Help按钮取代了Close按钮。这就是当您运行清单 1-5 中的程序时没有看到Close按钮的原因。若要解决此问题,请在将两个按钮添加到容器时指定它们的区域。

Tip

如果您在一个BorderLayout托管容器中缺少一些组件,请确保您没有在同一区域添加多个组件。如果将组件添加到混合面积常数的BorderLayout中,PAGE_STARTPAGE_ENDLINE_STARTLINE_END常数优先于NORTHSOUTHEASTWEST常数。也就是说,如果使用add(c1, NORTH)add(c2, PAGE_START)将两个组件添加到一个BorderLayout中,将使用c2,而不是c1

a BorderLayout如何计算组件的大小?它根据组件放置的区域计算组件的大小。它考虑了组件在南北方向的首选高度。但是,它会根据南北方向的可用空间水平拉伸组件的宽度。也就是说,它不考虑南北向组件的首选宽度。它考虑了东西两侧组件的首选宽度,并赋予它们垂直填充整个空间所需的高度。中心区域的组件水平和垂直拉伸,以适应可用空间。也就是说,中心区域不考虑其组件的首选宽度和高度。

菜单布局

CardLayout将容器中的组件排列成一叠卡片。就像一叠卡片一样,在一个CardLayout中只有一张卡片(最上面的卡片)是可见的。它一次只能显示一个组件。你需要使用以下步骤来为一个容器使用一个CardLayout:

  • 创建一个容器,比如一个JPanelJPanel cardPanel = new JPanel();
  • 创建一个CardLayout对象。CardLayout cardLayout = new CardLayout();
  • 为容器设置布局管理器。cardPanel.setLayout(cardLayout);
  • 向容器中添加组件。您需要为每个组件命名。要给cardPanel添加一个JButton,使用下面的语句:cardPanel.add(new JButton("Card 1"), "myLuckyCard");你已经将你的卡命名为myLuckyCard。这个名字可以在CardLayoutshow()方法中使用,使这张卡可见。
  • 调用它的next()方法来显示下一张卡片。cardLayout.next(cardPanel);

CardLayout类提供了几种翻转组件的方法。默认情况下,它显示添加到其中的第一个组件。所有翻转相关的方法都将它管理的容器作为其参数。first()last()方法分别显示第一张和最后一张卡片。previous()next()方法显示当前显示的卡片中的上一张和下一张卡片。如果显示最后一张牌,调用next()方法显示第一张牌。如果显示第一张牌,调用previous()方法显示最后一张牌。

清单 1-10 演示了如何使用一个CardLayout。图 1-19 显示了结果JFrame。当你点击Next按钮时,下一张牌被翻转。程序将两个 JPanels 添加到JFrame的内容窗格中。一个JPanelbuttonPanelNext按钮,它被添加到内容窗格的南部区域。注意,默认情况下,JPanel使用FlowLayout

清单 1-10。CardLayout 的实际应用

// CardLayoutTest.java

package com.jdojo.swing;

import java.awt.Container;

import javax.swing.JFrame;

import java.awt.CardLayout;

import javax.swing.JPanel;

import javax.swing.JButton;

import java.awt.Dimension;

import java.awt.BorderLayout;

public class CardLayoutTest {

public static void main(String[] args) {

JFrame frame = new JFrame("CardLayout Test");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

// Add a Next JButton in a JPanel to the content pane

JPanel buttonPanel = new JPanel();

JButton nextButton = new JButton("Next");

buttonPanel.add(nextButton);

contentPane.add(buttonPanel, BorderLayout.SOUTH);

// Create a JPanel and set its layout to CardLayout

final JPanel cardPanel = new JPanel();

final CardLayout cardLayout = new CardLayout();

cardPanel.setLayout(cardLayout);

// Add five JButtons as cards to the cardPanel

for(int i = 1; i <= 5; i++) {

JButton card = new JButton("Card " + i);

card.setPreferredSize(new Dimension(200, 200));

String cardName = "card" + 1;

cardPanel.add(card, cardName);

}

// Add the cardPanel to the content pane

contentPane.add(cardPanel, BorderLayout.CENTER);

// Add an action listener to the Next button

nextButton.addActionListener(e -> cardLayout.next(cardPanel));

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig19_HTML.jpg

图 1-19。

A CardLayout in action. Click the Next JButton to flip through the cards

程序向Next按钮添加一个动作监听器。我还没有讨论如何给按钮添加一个动作监听器。有必要看看CardLayout的行动。我将在事件处理部分详细讨论如何向按钮添加动作。现在,只需要提到您需要调用JButton类的addActionListener()方法来添加一个动作监听器就足够了。这个方法接受一个类型为ActionListener接口的对象,并有一个名为actionPerformed()的方法。当你点击JButton时,执行actionPerformed()方法中的代码。翻转下一张牌的代码是对cardLayout.next(cardPanel)方法的调用。ActionListener接口是一个函数接口,您可以使用 lambda 表达式来创建它的实例,如下所示:

// Add an action listener to the Next JButton to flip the next card

nextButton.addActionListener(e -> cardLayout.next(cardPanel));

Tip

因为除了一个组件之外,其他组件对用户来说都是隐藏的,所以不经常使用。更容易使用的JTabbedPane提供了类似于CardLayout的功能。我将在第二章的中讨论JTabbedPane。一个JTabbedPane是一个容器,不是一个布局管理器。它将所有组件以选项卡的形式布局,并允许用户在这些选项卡之间切换。

box layout-方块配置

BoxLayout将组件水平排列成一行或垂直排列成一列。您需要使用以下步骤在您的程序中使用一个BoxLayout:

  • 创建一个容器,例如一个JPanelJPanel hPanel = new JPanel();
  • 创建一个BoxLayout类的对象。与其他布局管理器不同,您需要将容器传递给类的构造函数。您还需要将正在创建的盒子的类型(水平或垂直)传递给它的构造函数。该类有四个常量:X_AXISY_AXISLINE_AXISPAGE_AXIS。常量X_AXIS用于创建一个水平BoxLayout,从左到右排列所有组件。常量Y_AXIS用于创建一个从上到下布局所有组件的垂直BoxLayout。另外两个常量LINE_AXISPAGE_AXISX_AXISY_AXIS类似。但是,他们在布局组件时使用容器的方向。// Create a BoxLayout for hPanel to lay out // components from left to right BoxLayout boxLayout = new BoxLayout(hPanel, BoxLayout.X_AXIS);
  • 设置容器的布局。hPanel.setLayout(boxLayout);
  • 将组件添加到容器中。hPanel.add(new JButton("Button 1")); hPanel.add(new JButton("Button 2"));

清单 1-11 使用一个水平BoxLayout来显示三个按钮,如图 1-20 所示。

清单 1-11。使用水平方框布局

// BoxLayoutTest.java

package com.jdojo.swing;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.JButton;

import javax.swing.JPanel;

import javax.swing.BoxLayout;

import java.awt.BorderLayout;

public class BoxLayoutTest {

public static void main(String[] args) {

JFrame frame = new JFrame("BoxLayout Test");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

JPanel hPanel = new JPanel();

BoxLayout boxLayout = new BoxLayout(hPanel, BoxLayout.X_AXIS);

hPanel.setLayout(boxLayout);

for(int i = 1; i <= 3; i++) {

hPanel.add(new JButton("Button " + i));

}

contentPane.add(hPanel, BorderLayout.SOUTH);

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig20_HTML.jpg

图 1-20。

A JFrame with a horizontal BoxLayout with three buttons

A BoxLayout试图给水平布局中的所有组件提供优选的宽度,给垂直布局中的所有组件提供优选的高度。在水平布局中,最高组件的高度给定给所有其他组件。如果它无法调整组件的高度以匹配组中最高的组件,它会将组件沿中心水平对齐。您可以通过使用setAlignmentY()方法设置组件对齐或容器对齐来更改这个默认对齐。在垂直布局中,它试图为所有组件提供首选高度,并试图使所有组件的大小与最宽组件的宽度相同。如果它不能使所有组件具有相同的宽度,它会沿着它们的中心线垂直对齐它们。您可以通过使用setAlignmentX()方法改变组件的对齐或者容器的对齐来改变这个默认的对齐。

javax.swing包包含一个Box类,使得使用BoxLayout更加容易。一个Box容器使用一个BoxLayout作为它的布局管理器。Box类提供了static方法来创建水平或垂直布局的容器。方法createHorizontalBox()createVerticalBox()分别创建一个水平和垂直的盒子。

// Create a horizontal box

Box hBox = Box.createHorizontalBox();

// Create a vertical box

Box vBox = Box.createVerticalBox();

要向Box添加组件,使用它的add()方法,如下所示:

// Add two buttons to the horizontal box

hBox.add(new JButton("Button 1");

hBox.add(new JButton("Button 2");

Box类还允许你创建不可见的组件并将它们添加到一个盒子中,这样你就可以调整两个组件之间的间距。它提供了四种类型的不可见组件:

  • 支柱
  • 刚性区域
  • 补白

胶水是一种不可见的、可扩展的成分。你可以使用Box类的createHorizontalGlue()createVerticalGlue()静态方法创建水平和垂直粘合。以下代码片段在水平框布局中的两个按钮之间使用水平粘附。您还可以使用Box类的createGlue()静态方法创建一个 glue 组件,该组件可以水平和垂直扩展。

Box hBox = Box.createHorizontalBox();

hBox.add(new JButton("First"));

hBox.add(Box.createHorizontalGlue());

hBox.add(new JButton("Last"));

中间有胶水的按钮如图 1-21 所示。图 1-22 显示了容器水平展开后的情况。请注意两个按钮之间的水平空白区域,这是已经扩展的隐形胶水。

A978-1-4302-6662-4_1_Fig22_HTML.jpg

图 1-22。

A horizontal box with two buttons and a horizontal glue between them after resizing

A978-1-4302-6662-4_1_Fig21_HTML.jpg

图 1-21。

A horizontal box with two buttons and a horizontal glue between them

支柱是固定宽度或固定高度的不可见组件。您可以使用以像素为单位的宽度作为参数的createHorizontalStrut()方法创建一个水平支柱。您可以使用以像素为单位的高度作为参数的createVerticalStrut()方法创建一个垂直支柱。

// Add a 100px strut to a horizontal box

hBox.add(Box.createHorizontalStrut(100));

刚性区域是一种不可见的组件,其大小始终相同。您可以通过使用Box类的createRigidArea() static方法来创建一个刚性区域。你需要给它传递一个Dimension对象来指定它的宽度和高度。

// Add a 10x5 rigid area to a horizontal box

hBox.add(Box.createRigidArea(new Dimesnion(10, 5)));

填充器是一种不可见的自定义组件,可以通过指定自己的最小、最大和首选尺寸来创建。Box类的Filler静态嵌套类表示填充符。

// Create a filler, which acts like a glue. Note that the glue is

// just a filler with a minimum and preferred size set to zero and

// a maximum size set to Short.MAX_VALUE in both directions

Dimension minSize = new Dimension(0, 0);

Dimension prefSize = new Dimension(0, 0);

Dimension maxSize = new Dimension(Short.MAX_VALUE, Short.MAX_VALUE);

Box.Filler filler = new Box.Filler(minSize, prefSize, maxSize);

用一个水平和垂直的BoxLayout嵌套盒子可以得到一个非常强大的布局。Box类提供了创建粘合、支撑和刚性区域的便利方法。然而,它们都是Box.Filler类的对象。当最小和首选尺寸设置为零,最大尺寸设置为两个方向的Short.MAX_VALUE时,一个Box.Filler对象充当胶水。当粘附的最大高度设置为零时,它的行为类似于水平粘附。当粘附的最大宽度设置为零时,它的作用类似于垂直粘附。通过使用指定宽度和零高度的最小和首选尺寸,以及指定宽度和Short.MAX_VALUE高度的最大尺寸,您可以使用Box.Filler类创建水平支柱。你能想到用Box.Filler类创建一个刚性区域的方法吗?对于刚性区域,所有尺寸(最小、首选和最大)都是相同的。以下代码片段创建了一个 10x10 的刚性区域:

// Create a 10x10 rigid area

Dimension d = new Dimension(10, 10);

JComponent rigidArea = new Box.Filler(d, d, d);

清单 1-12 演示了如何使用Box类和 glue。图 1-23 显示了水平展开后的结果JFrame。当框架打开时,PreviousNext按钮之间没有间隙。

清单 1-12。使用 Box 类和 Glue 的 BoxLayout

// BoxLayoutGlueTest.java

package com.jdojo.swing;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.JButton;

import javax.swing.Box;

import java.awt.BorderLayout;

public class BoxLayoutGlueTest {

public static void main(String[] args) {

JFrame frame = new JFrame("BoxLayout with Glue");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

Box hBox = Box.createHorizontalBox();

hBox.add(new JButton("<<First"));

hBox.add(new JButton("<Previous"));

hBox.add(Box.createHorizontalGlue());

hBox.add(new JButton("Next>"));

hBox.add(new JButton("Last>>"));

contentPane.add(hBox, BorderLayout.SOUTH);

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig23_HTML.jpg

图 1-23。

A BoxLayout with glue

网格布局

一个GridLayout将组件排列在一个大小相等的矩形网格中。每个组件恰好被放置在一个单元中。它不考虑组件的首选大小。它将可用空间划分为大小相等的单元格,并根据单元格的大小调整每个组件的大小。

您可以指定网格中的行数或列数。如果两者都指定,则只使用行数,并计算列数。假设ncomponents是添加到容器中的组件数量,nrowsncols是指定的行数和列数。如果nrows大于零,则使用以下公式计算网格中的列数:

ncols = (ncomponents + nrows - 1)/nrows

如果nrows为零,则使用以下公式计算网格中的行数:

nrows = (ncomponents + ncols - 1)/ncols

不能为nrowsncols指定负数,并且它们中至少有一个必须大于零。否则,将引发运行时异常。

您可以使用GridLayout类的以下三个构造函数之一创建一个GridLayout:

  • GridLayout()
  • GridLayout(int rows, int cols)
  • GridLayout(int rows, int cols, int hgap, int vgap)

您可以指定行数、列数、水平间距以及网格中两个单元格之间的垂直间距。您也可以使用setRows()setColumns()setHgap()setVgap()方法来设置这些属性。

无参数构造函数创建一行网格。列数与添加到容器中的组件数相同。

// Create a grid layout of one row

GridLayout gridLayout = new GridLayout();

第二个构造函数根据指定的行数或列数创建一个GridLayout

// Create a grid layout of 5 rows. Specify 0 as the number of columns.

// The number of columns will be computed.

GridLayout gridLayout = new GridLayout(5, 0);

// Create a grid layout of 3 columns. Specify 0 as the number of rows.

// The number of rows will be computed.

GridLayout gridLayout = new GridLayout(0, 3);

// Create a grid layout with 2 rows and 3 columns. You have specified

// a non-zero value for rows, so the value for columns will be ignored.

// It will be computed based on the number of components.

GridLayout gridLayout = new GridLayout(2, 3);

第三个构造函数允许您指定行数或列数,以及两个单元格之间的水平和垂直间距。您可以创建一个三行的GridLayout,单元格之间的水平间距为 10 像素,垂直间距为 20 像素,如下所示:

GridLayout gridLayout = new GridLayout(3, 0, 10, 20);

清单 1-13 演示了如何使用一个GridLayout。请注意,您不需要指定组件将放置在哪个单元中。您只需将组件添加到容器中,布局管理器就会决定组件的位置。

清单 1-13。使用网格布局

// GridLayoutTest.java

package com.jdojo.swing;

import java.awt.GridLayout;

import javax.swing.JPanel;

import java.awt.BorderLayout;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JButton;

public class GridLayoutTest {

public static void main(String[] args) {

JFrame frame = new JFrame("GridLayout Test");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

JPanel buttonPanel = new JPanel();

buttonPanel.setLayout(new GridLayout(3,0));

for(int i = 1; i <= 9 ; i++) {

buttonPanel.add(new JButton("Button " + i));

}

contentPane.add(buttonPanel, BorderLayout.CENTER);

frame.pack();

frame.setVisible(true);

}

}

图 1-24 显示了一个带有GridLayout的容器,有三排九个组件。图 1-25 显示了一个带有GridLayout的容器,该容器有三排七个组件。如果你用一个GridLayout调整容器的大小,所有的组件都将被调整大小,它们将是相同的大小。尝试通过运行清单 1-13 中的程序来调整JFrame的大小。

A978-1-4302-6662-4_1_Fig25_HTML.jpg

图 1-25。

A GridLayout with three rows and seven components

A978-1-4302-6662-4_1_Fig24_HTML.jpg

图 1-24。

A GridLayout with three rows and nine components

一个GridLayout是一个简单的手工编码的布局管理器。然而,它并不十分强大,原因有二。首先,它强制每个组件具有相同的大小,其次,您不能指定网格中组件的行号和列号(或确切位置)。也就是说,您只能向GridLayout添加一个组件。它们将按照您将它们添加到容器中的顺序水平排列,然后垂直排列。如果容器的方向是LEFT_TO_RIGHT,组件从左到右,然后从上到下排列。如果容器的方向是RIGHT_TO_LEFT,组件从右到左,然后从上到下排列。使用GridLayout的一个好方法是创建一组相同大小的按钮。例如,假设您将两个带有文本OKCancel的按钮添加到一个容器中,并希望它们具有相同的大小。您可以通过将按钮添加到由GridLayout布局管理器管理的容器中来实现这一点。

网格包布局

GridLayout类似,GridBagLayout将组件布置在按行和列排列的矩形单元网格中。然而,它比GridLayout强大得多。它的强大带来了使用上的复杂性。不如GridLayout好用。在GridBagLayout中你可以定制的东西太多了,以至于很难快速学习和使用它的所有功能。

它可以让你定制组件的许多属性,如大小、对齐、可扩展性等。与GridLayout不同,网格中的所有单元不必大小相同。一个元件不需要精确地放置在一个单元中。一个组件可以水平和垂直跨越多个单元格。您可以指定其单元格内的组件应该如何对齐。

在使用GridBagLayout布局管理器时使用GridBagLayoutGridBagConstraints类。两个类都在java.awt包里。一个GridBagLayout类的对象定义了一个GridBagLayout布局管理器。GridBagConstraints类的对象为GridBagLayout中的组件定义约束。组件的约束用于布局组件。一些约束包括组件在网格中的位置、宽度、高度、单元格内的对齐方式等。

下面的代码片段创建了一个GridBagLayout类的对象,并将其设置为JPanel的布局管理器:

// Create a JPanel container

JPanel panel = new JPanel();

// Set GridBagLayout as the layout manager for the JPanel

GridBagLayout gridBagLayout = new GridBagLayout();

panel.setLayout(gridBagLayout);

让我们以最简单的形式使用GridBagLayout:创建一个框架,将其内容窗格的布局设置为GridBagLayout,并向内容窗格添加九个按钮。这是在清单 1-14 中完成的。图 1-26 显示运行程序时的画面。

清单 1-14。以最简单的形式使用的 GridBagLayout

// SimplestGridBagLayout.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JButton;

import java.awt.GridBagLayout;

public class SimplestGridBagLayout {

public static void main(String[] args) {

String title = "GridBagLayout in its Simplest Form";

JFrame frame = new JFrame(title);

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

contentPane.setLayout(new GridBagLayout());

for(int i = 1; i <= 9; i++) {

contentPane.add(new JButton("Button " + i));

}

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig26_HTML.jpg

图 1-26。

Nine buttons in a GridBagLayout

起初,似乎 a GridBagLayout的行为像是 a FlowLayout。效果就像你用了一个FlowLayout一样。然而,GridBagLayout并不等同于FlowLayout,尽管它有能力像FlowLayout一样工作。它更强大(也更容易出错!)比一个FlowLayout。当您添加九个按钮时,您没有指定它们的单元格。您使用了contentPane.add(Component c)方法来添加按钮。结果是它在一行中放置了一个又一个按钮。

您可以指定GridBagLayout中组件应放置的单元格。要指定组件的单元格,需要调用add(Component c, Object constraints)方法,其中第二个参数是GridBagConstraints类的一个对象。如果您没有为一个GridBagLayout中的组件指定约束对象,它会将该组件放置在下一个单元中。下一个单元是用于放置前一个组件的单元之后的单元。如果没有对GridBagLayout中的任何组件使用约束,所有组件都被放置在一行中,如图 1-26 所示。当我讲述一个GridBagConstraints对象的gridxgridy属性时,我会对此进行更多的讨论。

让我们通过展示它实际上是一个网格布局,并且它将组件放置在一个单元格网格中,来澄清一下GridBagLayout的记录。为了证明这一点,您将在前一个示例中在一个三行三列的单元格网格中显示九个按钮。这一次,只有一点不同:您将为按钮指定单元格在网格中的位置。行号和列号的组合表示单元格在网格中的位置。组件及其单元格的所有属性都是使用一个GridBagConstraints类的对象指定的。它有许多公共实例变量。它的gridxgridy实例变量分别指定单元格的列号和行号。第一列用gridx = 0表示,第二列用gridx = 1表示,依此类推。第一行用gridy = 0表示,第二行用gridy = 1表示,依此类推。

哪个是网格中的第一个单元格—左上角、右上角、左下角还是右下角?这取决于容器的方向。如果容器使用LEFT_TO_RIGHT方向,网格左上角的单元格是第一个单元格。如果容器使用RIGHT_TO_LEFT方向,网格右上角的单元格是第一个单元格。表 1-1 和表 1-2 显示了具有不同容器方向的GridBagLayout中的单元格及其相应的gridxgridy值。这些表格只显示了九个单元格。A GridBagLayout不限于只有九个单元。你想要多少细胞就有多少细胞。确切地说,您可以拥有最多Integer.MAX_VALUE个行和列,这肯定不会在任何应用中使用。

表 1-2。

Values of gridx and gridy for Cells in a Container with RIGHT_TO_LEFT Orientation

| `gridx=2, gridy=0` | `gridx=1, gridy=0` | `gridx=0, gridy=0` | | `gridx=2, gridy=1` | `gridx=1, gridy=1` | `gridx=0, gridy=1` | | `gridx=2, gridy=2` | `gridx=1, gridy=2` | `gridx=0, gridy=2` |

表 1-1。

Values of gridx and gridy for Cells in a Container With LEFT_TO_RIGHT Orientation

| `gridx=0, gridy=0` | `gridx=1, gridy=0` | `gridx=2, gridy=0` | | `gridx=0, gridy=1` | `gridx=1, gridy=1` | `gridx=2, gridy=1` | | `gridx=0, gridy=2` | `gridx=1, gridy=2` | `gridx=2, gridy=2` |

设置组件的gridxgridy属性很容易。您为您的组件创建一个约束对象,这是一个GridBagConstraints类的对象;设置其gridxgridy属性;并将约束对象传递给add()方法。下面的代码片段显示了如何在JButton的约束中设置gridxgridy属性。当您调用container.add(component, constraint)方法时,约束对象被复制到正在添加的组件中,这样您就可以更改它的一些属性,并将其重新用于另一个组件。这样,您不必为添加到GridBagLayout的每个组件创建一个新的约束对象。然而,这种方法容易出错。您可能为某个组件设置了约束,但在为另一个组件重用约束对象时忘记了更改该约束。因此,在重用约束对象时要小心。

// Create a constraint object

GridBagConstraints gbc = new GridBagConstraints();

// Set gridx and gridy properties in the constraint object

gbc.gridx = 0;

gbc.gridy = 0;

// Add a JButton and pass the constraint object as the

// second argument to the add() method.

container.add(new JButton("B1"), gbc);

// Set the gridx property to 1\. The gridy property

// remains as 0 as set previously.

gbc.gridx = 1;

// Add another JButton to the container

container.add(new JButton("B2"), gbc);

清单 1-15 演示了如何为一个组件设置gridxgridy值(或单元号)。图 1-27 显示了运行程序时得到的JFrame

清单 1-15。为 GridBagLayout 中的组件设置 gridx 和 gridy 属性

// GridBagLayoutWithgridxAndgridy.java

package com.jdojo.swing;

import java.awt.GridBagLayout;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.JButton;

import java.awt.GridBagConstraints;

public class GridBagLayoutWithgridxAndgridy {

public static void main(String[] args) {

String title = "GridBagLayout with gridx and gridy";

JFrame frame = new JFrame(title);

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

contentPane.setLayout(new GridBagLayout());

// Create an object for GridBagConstraints to set

// the constraints for each JButton

GridBagConstraints gbc = new GridBagConstraints();

for(int y = 0; y < 3; y++) {

for(int x = 0; x < 3; x++) {

gbc.gridx = x;

gbc.gridy = y;

String text = "Button (" + x + ", " + y + ")";

contentPane.add(new JButton(text), gbc);

}

}

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig27_HTML.jpg

图 1-27。

A GridBagLayout with nine buttons

您可以使用GridBagConstraints对象为组件指定其他约束。使用表 1-3 中列出的一个实例变量设置GridBagConstraints对象中的所有约束。该类还定义了许多常量,如RELATIVEREMAINDER等。请注意,所有实例变量都是小写的。

表 1-3。

Instance Variables of the GridBagConstraints Class

| 实例变量 | 缺省值 | 可能的值 | 使用 | | --- | --- | --- | --- | | `gridx` `gridy` | `RELATIVE` | `RELATIVE` `An integer` | 组件所在网格中单元格的列号和行号。 | | `gridwidth` `gridheight` | `1` | `An integer``RELATIVE` | 用于显示组件的网格单元数。 | | `fill` | `NONE` | `BOTH``HORIZONTAL``VERTICAL` | 指定组件将如何填充网格中分配给它的单元格。 | | `ipadx` `ipady` | `0` | 整数 | 指定添加到其最小大小的组件的内部填充。允许负整数,这将减小组件的最小大小。 | | `insets` | `(0,0,0,0)` | Insets 对象 | 指定组件边缘与其在网格中的单元格之间的外部填充。允许负值。 | | `anchor` | `CENTER` | `CENTER`,`NORTH`,`NORTHEAST`,`EAST`,`SOUTHEAST`,`SOUTH`,`SOUTHWEST`,`WEST`,`NORTHWEST, PAGE_START`,`PAGE_END`,`LINE_START`,`LINE_END`,`FIRST_LINE_START`,`FIRST_LINE_END`,`LAST_LINE_START`,`LAST_LINE_END, BASELINE`,`BASELINE_LEADING`,`BASELINE_TRAILING`,`ABOVE_BASELINE`,`ABOVE_BASELINE_LEADING`,`ABOVE_BASELINE_TRAILING,BELOW_BASELINE`,`BELOW_BASELINE_LEADING`,`BELOW_BASELINE_TRAILING` | 组件在显示区域中的放置位置。 | | `weightx` `weighty` | `0.0` | 正的双精度值 | 调整容器大小时,额外空间(水平和垂直)如何在网格单元格中分布。 |

以下部分详细讨论了每个约束的影响。

gridx 和 gridy 约束

gridxgridy约束指定网格中放置组件的单元。一个组件可以在水平和垂直方向上占据多个单元格。一个组件占据的所有单元格合在一起称为该组件的显示区域。

让我们对gridxgridy约束进行精确定义。它们指定组件显示区域的起始单元格。默认情况下,每个组件只占用一个单元格。我将在下一节讨论gridwidthgridheight约束时讨论如何让一个组件占据多个单元格。关于设置组件的gridxgridy约束值的更多细节,请参考清单 1-15。

您可以为gridxgridy约束中的一个或两个指定一个RELATIVE值。如果你指定了gridxgridy(一个大于或等于零的整数)的值,你就决定了组件将被放置在哪里。如果你指定一个或两个约束值为RELATVE,布局管理器将确定gridx和/或gridy的值。如果你阅读了关于GridBagLayout类的 API 文档,对于gridx和/或gridyRELATIVE值的描述不是很清楚。它只是说,当您将gridx和/或gridy的值指定为RELATIVE时,该组件将被放置在该组件之前添加的组件的旁边。API 文档中的这个描述一清二楚!以下段落将通过示例详细描述gridxgridy的设定值。

案例 1

您已经为gridxgridy指定了值。这是网格中绝对定位的情况。您的组件根据您指定的gridxgridy的值放置。你已经在清单 1-15 中看到了这种例子。

案例 2

您已经为gridx指定了一个值,并将gridy的值设置为RELATIVE。在这种情况下,布局管理器需要确定gridy的值。我们来看一个例子。假设您有三个按钮要放在网格中,并且您有一个container对象,它的布局管理器被设置为GridBagLayout。下面的代码片段将三个按钮添加到网格中。图 1-28 显示了带有三个按钮的屏幕。

A978-1-4302-6662-4_1_Fig28_HTML.jpg

图 1-28。

Specifying gridx and Setting gridy to RELATIVE

GridBagConstraints gbc = new GridBagConstraints();

JButton b1 = new JButton("Button 1");

JButton b2 = new JButton("Button 2");

JButton b3 = new JButton("Button 3");

gbc.gridx = 0;

gbc.gridy = 0;

container.add(b1, gbc);

gbc.gridx = 0;

gbc.gridy = GridBagConstraints.RELATIVE;

container.add(b2, gbc);

gbc.gridx = 1;

gbc.gridy = GridBagConstraints.RELATIVE ;

container.add(b3, gbc);

按钮b1的位置没有混淆,因为您已经指定了gridxgridy的值。它被放置在第一行(gridy = 0)和第一列(gridx = 0)。

对于按钮b2,您已经指定了gridx = 0。您希望将它放在第一列,结果与您预期的一样。您已经将gridy指定为b2RELATIVE。这意味着您在告诉GridBagLayout通过将b2放入第一列(gridx = 0)来为其找到一个合适的行。由于第一行已经被第一列中的b1占据,下一个可用于b2的行是第二行,它被放置在那里。

您已经为按钮b3设置了gridx = 1。这意味着它应该放在第二列。您将它的gridy指定为RELATIVE。这意味着布局管理器需要在第二列中为它找到一行。由于第一行没有任何组件放在第二列,布局管理器将其放在第一行。如果您指定gridx为 0,那么b3会被放置在哪里?再次应用相同的逻辑。由于第一列已经分别在第一行和第二行中有了b1b2,对于b3唯一可用的下一行是第三行,并且布局管理器将把它放在b2的正下方。

案例 3

您已经为gridy指定了一个值,并将gridx的值设置为RELATIVE。在这种情况下,布局管理器需要确定gridx的值。也就是说,基于行号的指定值,布局管理器必须确定其列号。图 1-29 显示了使用以下代码片段时三个按钮的布局。以这种方式布局按钮的逻辑与前面的例子相同,只是这次布局管理器决定了b2b3的列号,而不是它们的行号。

A978-1-4302-6662-4_1_Fig29_HTML.jpg

图 1-29。

Specifying gridy and setting gridx to RELATIVE in a GridBagLayout

gbc.gridx = 0;

gbc.gridy = 0;

container.add(b1, gbc);

gbc.gridx = GridBagConstraints.RELATIVE;

gbc.gridy = 0;

container.add(b2, gbc);

gbc.gridx = GridBagConstraints.RELATIVE;

gbc.gridy = 1;

container.add(b3, gbc);

案例 4

这是将gridxgridy都指定为RELATIVE的四种可能性中的最后一种。布局管理器必须确定要添加的组件的行号和列号。它将首先确定行号。该组件的行将是当前行。当前行是哪一行?默认情况下,第一行(gridy = 0)是当前行。当你添加一个组件时,你也可以指定它的gridwidth约束。它的值之一是REMAINDER,这意味着这是该行中的最后一个组件。如果您将组件添加到第一行,且其gridwidth设置为REMAINDER,则第二行成为当前行。一旦布局管理器确定了组件的行号(即当前行),它会将组件放在该行中最后添加的组件旁边的列中。gridxgridy的默认值为RELATIVE。现在你可以理解为什么清单 1-14 将所有按钮放在第一行,默认情况下,所有按钮都使用RELATIVE作为gridxgridy。因为默认的gridwidth是 1,所以第一行总是当前行。每当添加一个按钮时,第一行(当前行)被指定为它的行,它的列是该行中添加的最后一个按钮的下一列。让我们来看一些例子,在这些例子中,您将把gridxgridy都设置为RELATIVE

例 1:

下面的代码片段展示了如图 1-30 所示的按钮:

gbc.gridx = 0;

gbc.gridy = 0;

container.add(b1, gbc);

gbc.gridx = GridBagConstraints.RELATIVE;

gbc.gridy = GridBagConstraints.RELATIVE;

container.add(b2, gbc);

gbc.gridx = GridBagConstraints.RELATIVE;

gbc.gridy = 1;

container.add(b3, gbc);

A978-1-4302-6662-4_1_Fig30_HTML.jpg

图 1-30。

Specifying Both gridx and gridy as RELATIVE

您通过指定gridx = 0gridy = 0来为b1使用绝对定位。结果是将b1放在第一行第一列。您将b2gridxgridy都指定为RELATIVE。布局管理器必须确定b2的行号和列号。它查看当前行,默认情况下是第一行。因此,它将b2的行号设置为 0。它发现第一列中已经有一个组件(b1)。因此,它为b2设置下一列,即第二列。这里您可以看到b2位于第一行和第二列。理解b3的摆放很简单。因为您已经指定了它的gridy = 1,所以它被放在第二行。它的gridxRELATIVE,因为第一列在第二行中可用,所以它被放在第一列中。

例 2:

下面的代码片段展示了如图 1-31 所示的按钮。请注意,b1按钮被放置在其可用空间的中央,这是默认行为。您可以使用我稍后将讨论的anchor属性定制组件在其分配空间内的放置。

A978-1-4302-6662-4_1_Fig31_HTML.jpg

图 1-31。

Specifying gridx and gridy as RELATIVE with gridwidth as REMAINDER

gbc.gridx = 0;

gbc.gridy = 0;

gbc.gridwidth = GridBagConstraints.REMAINDER;// Last component in the row

container.add(b1, gbc);

gbc.gridx = GridBagConstraints.RELATIVE;

gbc.gridy = GridBagConstraints.RELATIVE;

gbc.gridwidth = 1; // Reset to the default value

container.add(b2, gbc);

gbc.gridx = GridBagConstraints.RELATIVE; gbc.gridy = 1;

container.add(b3, gbc);

您为b1指定了gridx = 0gridy = 0。这次,您将b1gridwidth指定为REMAINDER。这意味着b1是第一行的最后一个组件。因为这是添加到第一行的唯一组件,所以它成为该行的第一个也是最后一个组件。添加b1后,其gridwidthREMAINDER,第二行成为当前行。对于b2,将gridxgridy设置为RELATIVE。布局管理器将第二行(gridy = 1)作为其行号。由于第二行b2之前没有放置元件,所以它将是该行的第一个。这导致将b2放置在第二行第一列。请注意,您将b2b3的值设置为 1。确定b3的位置很简单。因为您将它的gridy指定为 1(第二行),所以它被放在第二行。它的gridx就是RELATIVE。由于b2已经在第一列,所以放在第二列。

gridwidth 和 gridheight 约束

gridwidthgridheight约束分别指定组件显示区域的宽度和高度。两者的默认值都是 1。也就是说,默认情况下,组件放置在一个单元中。如果你为一个组件指定gridwidth = 2,它的显示区域将是两个单元格宽。如果您为一个组件指定gridheight = 2,它的显示区域将是两个单元格高。如果你曾经使用过 HTML 表格,你可以将gridwidthcolspan进行比较,将gridheight与 HTML 表格中单元格的rowspan属性进行比较。

您可以为gridwidthgridheight指定两个预定义的常数。他们是REMAINDERRELATIVEgridwidthREMAINDER值意味着组件将从其gridx单元格跨越到该行的其余部分。换句话说,它是行中的最后一个组件。gridheightREMAINDER值表示它是该列中的最后一个组件。gridwidthRELATIVE值表示组件显示区域的宽度将从其gridx到该行的倒数第二个单元格。gridheightRELATIVE值表示组件显示区域的高度将从其gridy到倒数第二个单元格。让我们为gridwidth分别举一个例子。你可以为gridheight扩展这个概念。唯一的区别是gridwidth影响组件显示区域的宽度,而gridheight影响高度。

以下代码片段将九个按钮添加到一个容器中,第一行三个,第二行六个:

// Expand the component to fill the whole cell

gbc.fill = GridBagConstraints.BOTH;

gbc.gridx = 0;

gbc.gridy = 0;

container.add(new JButton("Button 1"), gbc);

gbc.gridx = 1;

gbc.gridy = 0;

gbc.gridwidth = GridBagConstraints.RELATIVE;

container.add(new JButton("Button 2"), gbc);

gbc.gridx = GridBagConstraints.RELATIVE; gbc.gridy = 0;

gbc.gridwidth = GridBagConstraints.REMAINDER;

container.add(new JButton("Button 3"), gbc);

// Reset gridwidth to its default value 1

gbc.gridwidth = 1;

// Place six JButtons in second row

gbc.gridy = 1;

for(int i = 0; i < 6; i++) {

gbc.gridx = i;

container.add(new JButton("Button " + (i + 4)), gbc);

}

第一句话对你来说是新的。它将GridBagConstraintsfill实例变量设置为BOTH,,这表示添加到单元格中的组件将在两个方向(水平和垂直)上扩展,以填充整个单元格区域。稍后我将更详细地讨论这一点。第一个按钮位于第一行第一列。

第二个按钮位于第一行第二列。它的gridwidth被设置为RELATIVE,这意味着它将从第二列(gridx = 1)跨越到该行的倒数第二列。第一行的最后一列是哪一列?你还不知道。您必须查看添加到GridBagLayout的所有组件,以找出网格中的最大行数和列数。现在,您知道第二个按钮从第二列开始,但是您不知道它将在哪一列结束(或者它将延伸到哪一列)。

让我们看看第三个按钮。您已经指定了它的gridy = 0,这意味着它应该放在第一行。您已经将其gridx设置为RELATIVE,这意味着它将被放置在第一行的第二个按钮之后。您已经将它的gridwidth值设置为REMAINDER,这意味着这是第一行中的最后一个组件。有个有趣的点要注意。第二个按钮将根据需要从第二列扩展到倒数第二列。你是说第三个按钮是第一行的最后一个组件,它应该占据其余的单元格。结果是,由于第二个按钮的gridwidthRELATIVE的贪婪值,第三个按钮将始终只剩下一个单元格(最后一个单元格)。

在第二行中,您添加了六个按钮。每行中的单元格总数由一行中的最大列数决定。因此,每行(第一行和第二行)将有六个单元格。您已经将gridwidth设置为默认值 1,所以第二行中的每个按钮将只占据一个单元格。第一行第一个按钮占一个单元格,第三个按钮占一个单元格,第二个按钮占剩下的四个,如图 1-32 所示。

A978-1-4302-6662-4_1_Fig32_HTML.jpg

图 1-32。

Specifying gridwidth and gridheight

填充约束

A GridBagLayout给出每个组件的首选宽度和高度。列的宽度由列中最宽的部分决定。类似地,行的高度由行中最高的组件决定。fill约束值表示当组件的显示区域大于其尺寸时,组件如何水平和垂直扩展。注意fill约束仅在组件尺寸小于其显示区域时使用。

fill约束有四个可能的值:NONEHORIZONTALVERTICALBOTH。它的默认值是NONE,意思是“不要展开组件”值HORIZONTAL表示“水平扩展组件以填充其显示区域。”值VERTICAL表示“垂直扩展组件以填充其显示区域。”值BOTH表示“水平和垂直扩展组件以填充其显示区域。”

以下代码片段向三行三列的网格添加了九个按钮,如图 1-33 所示。

A978-1-4302-6662-4_1_Fig33_HTML.jpg

图 1-33。

Specifying the fill constraint for a component in a GridBagLayout

gbc.gridx = 0; gbc.gridy = 0;

container.add(new JButton("Button 1"), gbc);

gbc.gridx = 1; gbc.gridy = 0;

container.add(new JButton("Button 2"), gbc);

gbc.gridx = 2; gbc.gridy = 0;

container.add(new JButton("Button 3"), gbc);

gbc.gridx = 0; gbc.gridy = 1;

container.add(new JButton("Button 4"), gbc);

gbc.gridx = 1; gbc.gridy = 1;

container.add(new JButton("This is a big Button 5"), gbc);

gbc.gridx = 2; gbc.gridy = 1;

container.add(new JButton("Button 6"), gbc);

gbc.gridx = 0; gbc.gridy = 2;

container.add(new JButton("Button 7"), gbc);

gbc.gridx = 1; gbc.gridy = 2;

gbc.fill = GridBagConstraints.HORIZONTAL;

container.add(new JButton("Button 8"), gbc);

gbc.gridx = 2; gbc.gridy = 2;

gbc.fill = GridBagConstraints.NONE;

container.add(new JButton("Button 9"), gbc);

第五个按钮决定第二列的宽度,因为它是该列中最宽的JButton。请注意第一行第二列中的空白。它有空白空间,因为对于第二个按钮来说,fill值是NONE,这是默认的,并且第二个按钮没有扩展到占据其显示区域的整个宽度。它被保留为自己喜欢的大小。看第八个按钮。您指定它应该水平扩展,它这样做是为了匹配其显示区域的宽度。

ipadx 和 ipady 约束

ipadxipady约束用于指定组件的内部填充。它们增加了元件的首选尺寸和最小尺寸。默认情况下,两个约束都设置为零。允许负值。这些约束的负值将减小组件的首选和最小尺寸。如果指定了ipadx的值,组件的首选和最小宽度将增加2*ipadx。同样,如果您指定了ipady的值,组件的首选和最小高度将增加2*ipady。这些选项很少使用。ipadxipady的值以像素为单位。

insets 约束

insets约束指定组件周围的外部填充。它在组件周围添加空间。您将insets值指定为java.awt.Insets类的对象。它有一个名为Insets(int top, int left, int bottom, int right)的构造函数。您可以为组件的所有四个边指定填充。默认情况下,insets的值被设置为四边都为零像素的Insets对象。下面的代码片段在一个 3X3 的网格中添加了九个按钮,所有按钮的四边都有五个像素。最终布局如图 1-34 所示。请注意,您已经为所有按钮指定了fill约束为BOTH,但是由于它们的insets约束,您仍然可以看到相邻按钮之间的间隙。insets约束告诉布局管理器在组件边缘和显示区域边缘之间留有空间。

A978-1-4302-6662-4_1_Fig34_HTML.jpg

图 1-34。

Specifying insets for components in a GridBagLayout

gbc.fill = GridBagConstraints.BOTH;

gbc.insets = new Insets(5, 5, 5, 5);

int count = 1;

for(int y = 0; y < 3; y++) {

gbc.gridy = y;

for(int x = 0; x < 3; x++) {

gbc.gridx = x;

container.add(new JButton("Button " + count++), gbc);

}

}

锚点约束

anchor约束指定当一个组件的尺寸小于其显示区域的尺寸时,该组件应放置在其显示区域内的何处。默认情况下,它的值被设置为CENTER,这意味着组件在其显示区域内居中。

GridBagConstraints类中定义了许多常量,可以用作anchor约束的值。所有的常量可以分为三类:绝对的、基于方向的和基于基线的。

绝对值为NORTHSOUTHWESTEASTNORTHWESTNORTHEASTSOUTHWESTSOUTHEASTCENTER。图 1-35 显示了如何将一个组件放置在具有不同绝对锚值的单元内。请注意,图中的所有九个组件都将其fill约束设置为NONE

A978-1-4302-6662-4_1_Fig35_HTML.jpg

图 1-35。

Absolute anchor values and their effects on component location in the display area

基于方向的值是基于容器的ComponentOrientation属性使用的。分别是PAGE_STARTPAGE_ENDLINE_STARTLINE_ENDFIRST_LINE_STARTFIRST_LINE_ENDLAST_LINE_STARTLAST_LINE_END。图 1-36 和图 1-37 显示了当容器的方向设置为LEFT_TO_RIGHTRIGHT_TO_LEFT时使用基于方向的锚值的效果。您可能会注意到,基于方向的值会根据容器使用的方向进行自我调整。

A978-1-4302-6662-4_1_Fig37_HTML.jpg

图 1-37。

Orientation-based anchor values and their effects when the container’s orientation is RIGHT_TO_LEFT

A978-1-4302-6662-4_1_Fig36_HTML.jpg

图 1-36。

Orientation-based anchor values and their effects when the container’s orientation is LEFT_TO_RIGHT

当您希望将一行中的组件沿其基线对齐时,将使用基线-基线锚点的值。一个组件的基线是什么?基线是相对于文本的。它是一条假想的线,文本中的字符就停留在这条线上。一个组件可能有一个基线。通常,组件的基线是组件的上边缘与其显示的文本的基线之间的距离(以像素为单位)。您可以通过使用组件的getBaseline(int width, int height)方法来获取组件的基线值。请注意,您需要传递组件的宽度和高度来获取其基线。不是每个组件都有基线。如果一个组件没有基线,这个方法返回–1。图 1-38 显示了三个组件,一个JLabel、一个JTextField和一个JButton,它们沿着基线排成一行GridBagLayout

A978-1-4302-6662-4_1_Fig38_HTML.jpg

图 1-38。

A JLabel, a JTextField, and a JButton aligned along their baselines

GridBagLayout中的每一行都可以有一个基线。图 1-38 显示了包含三个组件的行的基线。图中的水平实线表示基线。请注意,这条水平实线是一条假想的线,它实际上并不存在。它仅用于演示基线概念。只有当至少一个组件具有有效基线并且其锚值为BASLINEBASELINE_LEADINGBASELINE_TRAILING时,GridBagLayout中的一行才有基线。图 1-39 显示了一些基于基线的锚值。表 1-4 列出了所有可能的值及其描述。

表 1-4。

List of Baseline-Based Anchor’s Values and Descriptions

| 基于基线的锚值 | 竖向定线 | 水平线向 | | --- | --- | --- | | `BASELINE` | 行基线 | 中心 | | `BASELINE_LEADING` | 行基线 | 沿前缘对齐** | | `BASELINE_TRAILING` | 行基线 | 沿后缘对齐*** | | `ABOVE_BASELINE` | 底部边缘接触起始行的基线 | 中心 | | `ABOVE_BASELINE_LEADING` | 底边接触起始行的基线* | 沿前缘对齐** | | `ABOVE_BASELINE_TRAILING` | 底部边缘接触起始行的基线 | 沿后缘对齐*** | | `BELOW_BASELINE` | 上边缘接触起始行的基线* | 中心 | | `BELOW_BASELINE_LEADING` | 顶部边缘接触起始行的基线 | 沿前缘对齐** | | `BELOW_BASELINE_TRAILING` | 上边缘接触起始行的基线* | 沿后缘对齐*** |

*starting row: The phrase “starting row” applies only when a component spans multiple rows. Otherwise, read it as the row in which the component is placed. If a row has no baseline, the component is vertically centered **Leading edge is left edge for LEFT_TO_RIGHT orientation and right edge for RIGHT_TO_LEFT orientation ***Trailing edge is right edge for LEFT_TO_RIGHT orientation and left edge for RIGHT_TO_LEFT orientation

A978-1-4302-6662-4_1_Fig39_HTML.jpg

图 1-39。

Some baseline-based anchor values in action

权重 x 和权重约束

weightxweighty约束控制容器中的额外空间如何在行和列之间分配。weightxweighty的默认值为零。它们可以有任何非负值。

图 1-40 显示一个JFrame使用九个按钮的GridBagLayout。图 1-41 为同一JFrame的水平和垂直展开图。

A978-1-4302-6662-4_1_Fig41_HTML.jpg

图 1-41。

A JFrame with a GridBagLayout having nine buttons after resizing

A978-1-4302-6662-4_1_Fig40_HTML.jpg

图 1-40。

A JFrame with a GridBagLayout having nine buttons with no extra spaces

请注意按钮组周围生成的额外空间。您已经将所有按钮的fill约束设置为BOTH,因此所有按钮都代表了GridBagLayout中的单元格网格。weightxweighty约束保留默认值零。当所有组件的weightxweighty约束设置为零时,容器中的任何额外空间都会出现在容器边缘和单元格网格边缘之间。

weightx值决定了额外水平空间在各列之间的分布,而weighty值决定了额外垂直空间在各行之间的分布。如果所有组件都有相同的weightxweighty,额外的空间会在它们之间平均分配。图 1-42 显示了当weightxweighty设置为 1.0 时的所有九个按钮。您可以为weightx和/或weighty设置任何正值。只要它们对于所有组件都是相同的,额外的空间将在它们之间平均分配。

A978-1-4302-6662-4_1_Fig42_HTML.jpg

图 1-42。

A JFrame with a GridBagLayout having nine buttons after resizing. All buttons have their weightx and weighty set to 1. Extra space is distributed among the display area of all buttons equally

下面是如何根据weightx值计算每一列的额外空间。假设一个带有GridBagLayout的容器被水平扩展以使ES像素的额外空间可用。假设网格中有三列三行。布局管理器将为每列中的组件找到weightx值的最大值。假设cwx1cwx2cwx3分别是第 1 列、第 2 列和第 3 列的weightx的最大值。列 1 将获得(cwx1 * ES)/(cwx1 + cwx2 + cwx3)数量的额外空间。列 2 将获得(cwx2 * ES)/(cwx1 + cwx2 + cwx3)数量的额外空间。第 3 列将获得(cwx3 * ES)/(cwx1 + cwx2 + cwx3)数量的额外空间。有必要通过使用该列中的最大值weightx来计算给予该列的额外空间,以维护单元格网格。使用weighty在单元格之间分配额外垂直空间的计算是类似的

Tip

weightxweighty约束影响组件显示区域的大小和组件本身的大小。对于weightxweighty,通常使用 0.0 到 1.0 之间的值。但是,您可以使用任何非负值。组件的大小受其他约束的影响,如fillgridwidthgridheight等。如果您希望您的组件在额外空间可用时扩展,您需要将其fill约束设置为HORIZONTALVERTICALBOTH。通过使用GridBagLayout类的setConstraints(Component c, GridBagConstraints cons)方法,在将组件添加到容器中之后,您还可以在GridBagLayout中为组件设置约束。

布局

javax.swing包中的SpringLayout类的一个实例代表一个SpringLayout管理器。回想一下,布局管理器的工作是计算容器中组件的四个属性(x、y、宽度和高度)。换句话说,它负责定位容器内的组件并计算它们的大小。一个SpringLayout管理器用弹簧来表示组件的这四个属性。手工编码很麻烦。它是针对 GUI 生成器工具的。在本节中,我将通过手工编写一些简单的例子来介绍这种布局的基础。

什么是春天?在经理的背景下,你可以把弹簧想象成机械弹簧,它可以被拉伸、压缩或保持正常状态。一个Spring类的对象代表了一个SpringLayout中的弹簧。一个Spring对象有四个属性:最小值、首选值、最大值和当前值。你可以把这四个属性想象成它的四种长度。弹簧在最大程度压缩时有最小值。在正常状态下(既不压缩也不拉伸),它有自己的首选值。在最拉伸的状态下,它有最大值。它在任何给定时间点的值就是它的当前值。当弹簧的最小值、首选值和最大值相同时,称为支柱。

你如何创造一个春天?Spring类没有公共构造函数。它包含创建弹簧的工厂方法。要从头开始创建弹簧或支柱,可以使用其重载的constant()静态方法。您也可以使用元件的宽度或高度来建立弹簧。弹簧的最小值、首选值和最大值是根据组件的相应宽度或高度值设置的

// Create a strut of 10 pixels

Spring strutPadding = Spring.constant(10);

// Create a spring having 10, 25 and 50 as its minimum,

// preferred, and maximum value respectively.

Spring springPadding = Spring.constant(10, 25, 50);

// Create a spring from the width of a component named c1

Spring s1 = Spring.width(c1);

// Create a spring from the height of a component named c1

Spring s2 = Spring.height(c1);

Spring类有一些实用方法,可以让你操作 spring 属性。您可以通过使用sum()方法添加两个弹簧来创建一个新弹簧,如下所示:

// Assuming that s1 and s2 are two springs

Spring s3 = Spring.sum(s1, s2);

执行语句时不执行计算sum。相反,弹簧s3存储了s1s2的引用。每当s1s2或两者都改变时,计算s3的值。在这种情况下,s3的行为就像串联了弹簧s1s2一样。

也可以通过从一个弹簧中减去另一个弹簧来创建弹簧。但是,您没有名为subtract()的方法。有一种叫做minus()的方法可以给出弹簧的负值。您可以使用sum()minus()方法的组合来执行减法,如下所示:

// Perform s1 – s2, which is the same as s1 + (-s2)

Spring s4 = Spring.sum(s1, Spring.minus(s2));

要获得两个弹簧s1s2的最大值,可以使用Spring.max(s1, s2)。注意没有对应的方法叫做min()。然而,您可以通过使用minus()max()方法的组合来模拟它,就像这样:

// Minimum of 2 and 5 is the minus of the maximum of –2 and –5.

// To get the minimum of two spring s1 and s2, you can use minus

// of maximum of –s1 and –s2

Spring min = Spring.minus(Spring.max(Spring.minus(s1), Spring.minus(s2)));

你也可以使用scale()方法得到另一个弹簧的一部分。例如,如果您有一个弹簧s1,并且您想要创建一个值为其 40%的弹簧,您可以通过将 0.40f 作为第二个参数传递给scale()方法来实现,如下所示:

String fractionSpring = Spring.scale(s1, 0.40f);

Tip

创建弹簧后,不能更改弹簧的最小值、首选值和最大值。您可以通过使用它的setValue()方法来设置它的当前值。

你刚刚讨论了很多关于弹簧的问题。是时候看看他们的行动了。如何用SpringLayout将组件添加到容器中?在最简单的形式中,您使用容器的add()方法来添加组件。清单 1-16 将一个JFrame的内容窗格的布局设置为一个SpringLayout,并添加了两个按钮。图 1-43 显示运行程序时的JFrame

清单 1-16。最简单的 spring 布局

// SimplestSpringLayout.java

package com.jdojo.swing;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.SpringLayout;

import javax.swing.JButton;

public class SimplestSpringLayout {

public static void main(String[] args) {

JFrame frame = new JFrame("Simplest SpringLayout");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

// Set the content pane's layout as SpringLayout

SpringLayout springLayout = new SpringLayout();

contentPane.setLayout(springLayout);

// Add two JButtons to the content pane

JButton b1 = new JButton("Button 1");

JButton b2 = new JButton("Little Bigger Button 2");

contentPane.add(b1);

contentPane.add(b2);

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig43_HTML.jpg

图 1-43。

The JFrame when you run the SimplestSpringLayout class

图 1-43 显示你只能看到JFrame的标题栏。当您展开 JFrame 时,您会看到如图 1-44 所示的屏幕。请注意,您的两个按钮都在JFrame中。然而,它们是重叠的。最简单的SpringLayout例子可能是最简单的编码;但是,看到结果就没那么简单了。

A978-1-4302-6662-4_1_Fig44_HTML.jpg

图 1-44。

After expanding the JFrame when you run the SimplestSpringLayout class

那么,你最简单的SpringLayout例子有什么问题呢?我提到过一个SpringLayout很难手工编码,你现在看到了!你在框架上使用了pack()方法来给它一个最佳的尺寸。但是您的框架没有显示区域。当您使用SpringLayout时,您必须指定所有组件和容器的 x、y、宽度和高度。这对开发人员来说是太多的工作,这就是为什么我说这个布局管理器是为 GUI 构建者设计的,而不是为手工编码设计的。

让我们再次检查图 1-43 和图 1-44 所示的屏幕。您会看到容器获得了位置(x 和 y ),按钮获得了大小(宽度和高度)。默认情况下,JFrame显示在(0,0)处,这就是您看到容器位置的方式(实际上,您的容器是一个内容窗格)。按钮获得它们默认的最小、首选和最大尺寸(都设置为相同的值),这是您展开屏幕后看到的按钮。默认情况下,SpringLayout将容器中的所有组件定位在(0,0)处。在这种情况下,两个按钮都位于(0,0)处。要解决此问题,请指定两个按钮和内容窗格的 x、y、宽度和高度。

A SpringLayout使用约束来排列组件。Constraints类的对象是SpringLayout类的静态内部类,代表组件和容器的约束。一个Constraints对象允许你使用它的方法指定一个组件的 x、y、宽度和高度。所有四个属性都必须根据一个Spring对象来指定。当您指定这些属性时,您需要使用SpringLayout类中定义的常量之一来指定它们,如表 1-5 中所列。

表 1-5。

List of Constants Defined in the SpringLayout Class

| 常数名称 | 描述 | | --- | --- | | `NORTH` | 它是`y`的同义词。它是组件的顶部边缘。 | | `WEST` | 它是`x`的同义词。它是组件的左边缘。 | | `SOUTH` | 它是组件的底部边缘。其值与`NORTH + HEIGHT`相同。 | | `EAST` | 它是组件的右边缘。和`WEST + WIDTH`一样。 | | `WIDTH` | 组件的宽度。 | | `HEIGHT` | 组件的高度。 | | `HORIZONTAL_CENTER` | 它是组件的水平中心。和`WEST + WIDTH/2`一样。 | | `VERTICAL_CENTER` | 它是组件的垂直中心。和`NORTH + HEIGHT/2`一样。 | | `BASELINE` | 它是组件的基线。 |

可以相对于容器或另一个组件设置组件的 x 和 y 约束。Constraints类的一个对象指定了一个组件的约束。您需要创建一个SpringLayout.Constraints类的对象,并使用它的方法来设置约束的值。当你添加一个组件到一个容器中时,将这个约束对象传递给add()方法。清单 1-17 为两个按钮设置了 x 和 y 约束。注意,值(10,20)和(150,20)是根据Spring对象指定的,它们是从内容窗格的边缘开始测量的。图 1-45 显示运行程序时展开JFrame后的画面。

清单 1-17。为元件设置 x 和 y 约束

// SpringLayout2.java

package com.jdojo.swing;

import javax.swing.SpringLayout;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.JButton;

import javax.swing.Spring;

public class SpringLayout2 {

public static void main(String[] args) {

JFrame frame = new JFrame("SpringLayout2");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

// Set the content pane's layout to a SpringLayout

SpringLayout springLayout = new SpringLayout();

contentPane.setLayout(springLayout);

// Add two JButtons to the content pane

JButton b1 = new JButton("Button 1");

JButton b2 = new JButton("Little Bigger Button 2");

// Create Constraints objects for b1 and b2

SpringLayout.Constraints b1c = new SpringLayout.Constraints();

SpringLayout.Constraints b2c = new SpringLayout.Constraints();

// Create a Spring object for y value for b1 and b2

Spring yPadding = Spring.constant(20);

// Set (10, 20) for (x, y) for b1

b1c.setX(Spring.constant(10));

b1c.setY(yPadding);

// Set (150, 20) for (x, y) for b2

b2c.setX(Spring.constant(150));

b2c.setY(yPadding);

// Use the Constraints object while adding b1 and b2

contentPane.add(b1, b1c);

contentPane.add(b2, b2c);

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig45_HTML.jpg

图 1-45。

After expanding the JFrame when the (x, y) are set for two buttons

您尚未确定JFrame的大小。运行程序时,JFrame仍然显示,没有显示区域。至少这次两个按钮没有重叠。你选择了一个 150 像素的任意值作为b2的 x 值。也就是说,b2的左边缘距离内容窗格的左边缘 150 像素。有一种方法可以指定b2的左边缘应该与b1的右边缘相距指定的距离。为了实现这一点,您需要首先将b1添加到容器中。当您向容器中添加一个组件时,SpringLayout将一个Constraints对象关联到该组件,不管您是否将一个约束对象传递给容器的add()方法。您可以使用SpringLayout类的getConstraint(String edge, Component c)方法来获取组件任何边的约束。下面的代码片段做了同样的事情。它将b1的(x,y)设置为(10,20),将b2的(x,y)设置为(b1的右边缘+ 5,20)。如果用下面的代码片段替换清单 1-17 中添加两个按钮的代码,b2将出现在b1右侧 10 个像素处:

// Create a Spring object for y value for b1 and b2

Spring yPadding = Spring.constant(20);

// Set (10, 20) for (x, y) for b1

b1c.setX(Spring.constant(10));

b1c.setY(yPadding);

// Add b1 to the content pane first

contentPane.add(b1, b1c);

// Now query the layout manager for b1's EAST constraint,

// which is the right edge of b1

Spring b1Right = springLayout.getConstraint(SpringLayout.EAST, b1);

// Add a 5-pixel strut to the right edge of b1 to define the

// left edge of b2 and set it using setX() method on b2c

Spring b2Left = Spring.sum(b1Right, Spring.constant(5));

b2c.setX(b2Left);

b2c.setY(yPadding);

// Now add b2 to the content pane

contentPane.add(b2, b2c);

有一种更简单、更直观的方式来为SpringLayout中的组件设置约束。首先,将所有组件添加到容器中,不用担心它们的约束,然后使用SpringLayout类的putConstraint()方法定义约束。这里有两个版本的putConstraint()方法:

  • void putConstraint(String targetEdge, Component targetComponent, int padding, String sourceEdge,Component sourceComponent)
  • void putConstraint(String targetEdge, Component targetComponent, Spring padding, String sourceEdge, Component sourceComponent)

第一个版本使用支柱。第三个参数(int padding)定义了一个固定弹簧,它将作为两个组件边缘之间的支柱(固定距离)。第二个版本使用弹簧代替。你可以将方法描述理解为,“targetComponenttargetEdgesourceComponentsourceEdge相距padding例如,如果您希望b2的左边缘距离b1的右边缘 5 个像素,您可以调用此方法:

// Set b2's left edge 5 pixels from b1's right edge

springLayout.putConstraint(SpringLayout.WEST, b2, 5,

SpringLayout.EAST, b1);

要将b1(左边缘定义 x 值)的左边缘设置为距离内容窗格的左边缘 10 个像素,可以使用

springLayout.putConstraint(SpringLayout.WEST, b1, 5,

SpringLayout.WEST, contentPane);

让我们回到你的JFrame在调用它的pack()方法时的大小调整问题。您需要设置内容窗格底部和右边的位置,以便pack()方法能够正确地调整它的大小。您将它的下边缘设置为比b1(或b2,离它的下边缘最近的那个)的下边缘低 10 个像素。在本例中,两者距离内容窗格的底部边缘的距离相同。您将它的右边缘设置为距离内容窗格中最右边的JButton的右边缘 10 个像素。以下代码片段实现了这一点:

// Set the bottom edge of the content pane

springLayout.putConstraint(SpringLayout.SOUTH, contentPane, 10,

SpringLayout.SOUTH, b1);

// Set the right edge of the content pane

springLayout.putConstraint(SpringLayout.EAST, contentPane, 10,

SpringLayout.EAST, b2);

清单 1-18 包含了完整的程序,图 1-46 显示了运行程序时的JFrame

清单 1-18。使用 SpringLayout 类的 putConstraint()方法

// NiceSpringLayout.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.SpringLayout;

import javax.swing.JButton;

public class NiceSpringLayout {

public static void main(String[] args) {

JFrame frame = new JFrame("SpringLayout2");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

// Set the content pane's layout to a SpringLayout

SpringLayout springLayout = new SpringLayout();

contentPane.setLayout(springLayout);

// Create two JButtons

JButton b1 = new JButton("Button 1");

JButton b2 = new JButton("Little Bigger Button 2");

// Add two JButtons without using any constraints

contentPane.add(b1);

contentPane.add(b2);

// Now add constraints to both JButtons

// Set x for b1 as 10

springLayout.putConstraint(SpringLayout.WEST, b1, 10,

SpringLayout.WEST, contentPane);

// Set y for b1 as 20

springLayout.putConstraint(SpringLayout.NORTH, b1, 20,

SpringLayout.NORTH, contentPane);

// Set x for b2 as 10 from the right edge of b1

springLayout.putConstraint(SpringLayout.WEST, b2, 10,

SpringLayout.EAST, b1);

// Set y for b1 as 20

springLayout.putConstraint(SpringLayout.NORTH, b2, 20,

SpringLayout.NORTH, contentPane);

/* Now set height and width for the content pane as the bottom

edge of b1 + 10 and right edge of b2 + 10\. Note that source

is b1 for content pane's height and b2 for its width

*/

// Set the bottom edge of the content pane

springLayout.putConstraint(SpringLayout.SOUTH, contentPane, 10,

SpringLayout.SOUTH, b1);

// Set the right edge of the content pane

springLayout.putConstraint(SpringLayout.EAST, contentPane, 10,

SpringLayout.EAST, b2);

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig46_HTML.jpg

图 1-46。

Nice SpringLayout with the JFrame sized automatically

是一个非常强大的布局,可以模仿许多复杂的布局。下面的代码片段有更多的例子。评论解释了它应该做什么。

// Place a JButton b1 horizontally centered at the top of the content pane, you // would set its constraints as below. Replace HORIZONTAL_CENTER with

// VERTICAL_CENTER to center the JButton vertically

springLayout.putConstraint(SpringLayout.HORIZONTAL_CENTER, north, 0,

SpringLayout.HORIZONTAL_CENTER,

contentPane);

// You can set the width of two JButtons, b1 and b2, to be the same by

// assigning the maximum width to the both of them. Assuming that you have

// already added b1 and b2 JButtons to the container

SpringLayout.Constraints b1c = springLayout.getConstraints(b1);

SpringLayout.Constraints b2c = springLayout.getConstraints(b2);

// Get a spring that represents the maximum of the width of b1 and b2,

// and set that spring as width for both b1 and b2

Spring maxWidth = Spring.max(b1c.getWidth(), b2c.getWidth());

b1c.setWidth(maxWidth);

b2c.setWidth(maxWidth);

群组布局

GroupLayoutjavax.swing包里。它是供 GUI 构建者使用的。然而,手工编码也很容易。

A GroupLayout使用了组的概念。组由元素组成。组的元素可以是组件、间隙或另一个组。您可以将间隙视为两个组件之间的不可见区域。

在使用GroupLayout之前,你必须理解组的概念。有两种类型的组:

  • 顺序组
  • 平行链晶

当一个组中的元素一个接一个地连续排列时,称为顺序组。当一组中的元素平行放置时,称为平行组。平行组以四种方式之一对齐其元素:基线、居中、前导和尾随。在GroupLayout中,您需要为每个组件定义两次布局——一次沿着水平轴,一次沿着垂直轴。也就是说,您需要分别指定所有组件如何水平和垂直地组成一个组。让我们看一些组的例子。图 1-47 显示了一组两个组件。

A978-1-4302-6662-4_1_Fig47_HTML.jpg

图 1-47。

Two components, C1 and C2, form a sequential group along the horizontal axis and a parallel group along the vertical axis

在图 1-47 中,两个轴仅用于讨论目的,它们不是布局的一部分。组件一个接一个地放置(从左到右),沿水平轴形成一个连续的组。它们沿着垂直轴形成平行组。沿垂直轴,在平行组中,两个组件沿其顶边对齐。如果您对沿水平轴和垂直轴的顺序组和平行组的可视化有问题,您可以将图 1-47 重绘为图 1-48 。水平方向上的两个虚线箭头(从左到右)表示 C1 和 C2,当您在水平方向上可视化它们的分组时。您可以看到两个箭头是串联的,因此 C1 和 C2 沿着水平轴形成了一个连续的组。垂直方向上的两个虚线箭头(从上到下位于组件 C1 的左侧)表示 C1 和 C2,当您沿着垂直轴查看它们时。你可以看到这两个箭头不是串联的。相反,它们是并行的。因此,C1 和 C2 沿着纵轴形成平行组。您需要找出平行组的对齐方式。在这种情况下,C1 和 C2 沿着它们的上边缘对齐,这在GroupLayout术语中称为前导对齐。

A978-1-4302-6662-4_1_Fig48_HTML.jpg

图 1-48。

Groupings for components C1 and C2

C1 和 C2 还有其他可能的路线吗?平行组中有四种可能的对齐方式:基线对齐、居中对齐、前导对齐和尾随对齐。如果平行组沿垂直轴出现,则所有四种类型的对齐都是可能的。如果平行组沿水平轴出现,则只可能有三种对齐方式(居中、前导和尾随)。沿着垂直轴,前导与顶边相同,尾随与底边相同。沿水平轴,如果组件方向为LEFT_TO_RIGHT,前导为左边缘,如果组件方向为RIGHT_TO_LEFT,前导为右边缘。图 1-49 和图 1-50 显示了沿垂直轴和水平轴的可能对准。该对准由虚线示出。请注意,沿垂直轴,对齐线是水平的,沿水平轴,对齐线是垂直的。GroupLayout.Alignment枚举中的四个常量LEADINGTRAILINGCENTERBASELINE用于表示四种对准类型。

A978-1-4302-6662-4_1_Fig50_HTML.jpg

图 1-50。

The three possible alignments in a parallel group along the horizontal axis in a group for component orientation of LEFT_TO_RIGHT. For RIGHT_TO_LEFT orientation, LEADING and TRAILING will swap edges

A978-1-4302-6662-4_1_Fig49_HTML.jpg

图 1-49。

The four possible alignments in a parallel group along the vertical axis in a group

如何为一个GroupLayout创建连续和并行的组?GroupLayout类包含三个内部类:GroupSequentialGroupParallelGroupGroup是一个抽象类,另外两个类继承自Group类。您不必直接创建这些类的对象。相反,您使用GroupLayout类的工厂方法来创建它们的对象。

GroupLayout类提供了两个单独的方法来创建组:createSequentialGroup()createParallelGroup()。从这些方法的名称可以明显看出它们创建的组的种类。请注意,您需要为平行组指定对齐方式。createParallelGroup()方法被重载。不带参数的版本默认对齐为LEADING。另一个版本允许您指定对齐方式。一旦您有了一个组对象,您就可以分别使用它的addComponent()addGap()addGroup()方法向它添加组件、间隙和组。

你如何使用GroupLayout管理器?以下是使用GroupLayout需要遵循的步骤。假设你要在一个JFrame中放置两个按钮,如图 1-51 所示。

A978-1-4302-6662-4_1_Fig51_HTML.jpg

图 1-51。

The simplest GroupLayout in which two buttons are placed side by side

假设JFrame被命名为frame,两个JButtons被命名为b1b2。首先,您需要创建一个GroupLayout类的对象。它只包含一个将容器引用作为参数的构造函数。这意味着在创建一个GroupLayout类的对象之前,您需要获得对您想要为其创建GroupLayout的容器的引用。

// Get the reference of the container

Container contentPane = frame.getContentPane();

// Create a GroupLayout object

GroupLayout groupLayout = new GroupLayout(contentPane);

// Set the layout manager for the container

contentPane.setLayout(groupLayout);

其次,您需要创建沿水平轴的组件组(称为水平组),并使用setHorizontalGroup()方法将该组设置为GroupLayout。请注意,一个组可以沿任何轴(水平轴和垂直轴)连续或平行。在你的例子中,两个按钮,b1和 b2,沿着水平轴形成一个连续的组。

// Create a sequential group

GroupLayout.SequentialGroup sGroup = groupLayout.createSequentialGroup();

// Add two buttons to the group

sGroup.addComponent(b1);

sGroup.addComponent(b2);

// Set the horizontal group for the GroupLayout

groupLayout.setHorizontalGroup(sGroup);

您可以将所有步骤合并为一步,如下所示:

groupLayout.setHorizontalGroup(groupLayout.createSequentialGroup()

.addComponent(b1)

.addComponent(b2));

最后,沿着垂直轴创建组件组(称为垂直组),并使用setVerticalGroup()方法将该组设置为GroupLayout。两个按钮沿垂直轴形成一个平行组。您可以按如下方式完成此操作:

groupLayout.setVerticalGroup(

groupLayout.createParallelGroup(GroupLayout.Alignment.BASELINE)

.addComponent(b1)

.addComponent(b2));

Tip

在一个GroupLayout中,你不能使用它的add()方法添加一个组件到容器中。相反,您可以沿着水平轴和垂直轴将组件添加到一个组中,并使用setHorizontalGroup()setVerticalGroup()方法将该组添加到GroupLayout中。

清单 1-19 演示了如何使用一个GroupLayout在一个JFrame中并排显示两个按钮。运行程序时,JFrame显示如图 1-51 所示。我稍后将讨论更复杂的例子。

清单 1-19。最简单的组布局

// SimplestGroupLayout.java

package com.jdojo.swing;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.JButton;

import javax.swing.GroupLayout;

public class SimplestGroupLayout {

public static void main(String[] args) {

JFrame frame = new JFrame("Simplest GroupLayout");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

// Create an object of the GroupLayout class for contentPane

GroupLayout groupLayout = new GroupLayout(contentPane);

// Set the content pane's layout to a GroupLayout

contentPane.setLayout(groupLayout);

// Add two JButtons to the content pane

JButton b1 = new JButton("Button 1");

JButton b2 = new JButton("Little Bigger Button 2");

groupLayout.setHorizontalGroup(

groupLayout.createSequentialGroup()

.addComponent(b1)

.addComponent(b2));

groupLayout.setVerticalGroup(

groupLayout.createParallelGroup(GroupLayout.Alignment.BASELINE)

.addComponent(b1)

.addComponent(b2));

frame.pack();

frame.setVisible(true);

}

}

A GroupLayout还有两个特性值得讨论:

  • 它允许您在两个组件之间添加间隙。
  • 它允许您指定组件、间隙和组的调整大小行为。

你可以把间隙想象成一个看不见的组件。有两种类型的间隙:两个组件之间的间隙,以及组件和容器之间的间隙。您可以使用Group类的addGap()方法在两个组件之间添加一个间隙。您可以添加刚性间隙和柔性间隙(如弹簧)。刚性间隙的大小是固定的。灵活间隙有最小、首选和最大尺寸,当调整容器大小时,它就像弹簧一样。在前面的例子中,要在b1b2之间添加一个 10 像素的刚性间隙,你可以这样设置你的水平组:

groupLayout.setHorizontalGroup(groupLayout.createSequentialGroup()

.addComponent(b1)

.addGap(10)

.addComponent(b2));

有三种方法可以在两个组件之间添加间隙。它们基于间隙大小及其调整大小的能力。

  • 您可以使用addGap(int gapSize)在两个组件之间添加一个刚性间隙。
  • 您可以使用addGap(int min, int pref, int max)方法在两个组件之间添加一个灵活的(类似弹簧的)间隙,它有一个最小、一个首选和一个最大尺寸。要添加一个最小、首选和最大尺寸分别为 5、10 和 50 的灵活间隙,可以这样设置水平组:groupLayout.setHorizontalGroup(groupLayout.createSequentialGroup() .addComponent(b1) .addGap(5, 10, 50) .addComponent(b2));
  • 您可以在两个组件之间添加首选间隙。在这种情况下,您可以选择指定间隙的大小,或者让布局管理器为您计算。但是,就此差距而言,您必须指定这两个组件的关联方式。这种间隙有三种:RELATEDUNRELATEDINDENT。如果您要在标签和其对应的字段之间添加首选间隙,您需要在它们之间添加一个RELATED间隙。例如,如果您有一个登录表单,并且您想要在“用户 id:”和用于输入用户 ID 的文本字段之间添加一个首选间隙,那么您可以在它们之间添加一个RELATED间隙。当两个组件属于不同的组时,使用UNRELATED间隙。当您添加一个间隙只是为了缩进一个组件时,您添加了一个INDENT间隙。三种类型的间隙由在LayoutStyle.ComponentPlacement枚举中定义的三个常量RELATEDUNRELATEDINDENT表示。使用addPreferredGap()方法添加首选间隙。以下代码片段在b1b2之间添加了一个RELATED首选间隙:groupLayout.setHorizontalGroup( groupLayout.createSequentialGroup() .addComponent(b1) .addPreferredGap(LayoutStyle.ComponentPlacement.RELATED) .addComponent(b2));

您需要使用GroupLayout.SequentialGroup类的addContainerGap()方法在组件和容器的边缘之间添加一个间隙。该方法被重载。它还允许您指定间隙的首选大小和最大大小。

当您在不同的平台上运行应用时,设置硬编码的间隙可能会产生问题。这就是为什么GroupLayout有两个方法可以让你指定让GroupLayout根据你的应用运行的平台来计算首选的间隙。要让GroupLayout计算并设置两个组件之间的间隙,您需要调用它的setAutoCreateGaps(true)方法。要让它计算并设置组件和容器边缘之间的间隙,需要调用它的setAutoCreateContainerGaps(true)方法。默认情况下,间隙的自动计算是禁用的。替换该语句

// Create an object of the GroupLayout class

GroupLayout groupLayout = new GroupLayout(contentPane);

在清单 1-19 中使用了以下语句

// Create an object of the GroupLayout class and setup gaps

GroupLayout groupLayout = new GroupLayout(contentPane);

groupLayout.setAutoCreateGaps(true);

groupLayout.setAutoCreateContainerGaps(true);

现在,JFrame将如图 1-52 所示。您可以看到布局管理器为您添加了必要的间隙。

A978-1-4302-6662-4_1_Fig52_HTML.jpg

图 1-52。

The simplest GroupLayout with auto gaps enabled

A GroupLayout考虑组件的最小、首选和最大尺寸。当调整容器大小时,布局管理器询问组件的大小并相应地调整它们的大小。但是,您可以通过使用addComponent(Component c, int min, int pref, int max)方法来覆盖这种行为,该方法允许您指定组件的最小、首选和最大大小。您需要理解在GroupLayout类中定义的两个常量的含义。他们是DEFAULT_SIZEPREFERRED_SIZE。它们可用于addComponent()方法中的minprefmax参数。DEFAULT_SIZE表示布局管理器应该向组件请求该尺寸类型并使用它。PREFERRED_SIZE意味着管理器应该使用组件的首选尺寸。例如,如果您希望上一个示例中的JButton b2展开(默认情况下,一个JButton具有相同的minprefmax大小),您可以将它添加到水平组,如下所示:

groupLayout.setHorizontalGroup(groupLayout.createSequentialGroup()

.addComponent(b1)

.addComponent(b2,

GroupLayout.PREFERRED_SIZE,

GroupLayout.PREFERRED_SIZE,

Integer.MAX_VALUE));

通过将PREFERRED_SIZE指定为最小尺寸和首选尺寸,您是在告诉布局管理器b2不应该缩短到其首选尺寸以下。Integer.MAX_VALUE因为它的最大尺寸告诉布局管理器它可以无限扩展。要使一个组件不可调整大小,您可以像使用GroupLayout.PREFERRED_SIZE一样使用它的所有三个大小。

您可以在GroupLayout中嵌套组。让我们来看一个名为b1b2b3b4的四个按钮的布局,如图 1-53 所示。

A978-1-4302-6662-4_1_Fig53_HTML.jpg

图 1-53。

Nested groups in GroupLayout

让我们看看沿水平轴的组件布局。你可以看到两个平行的组(b1b3)和(b2b4),这两组是顺序放置的。让我们在伪代码中用PGSG分别表示并行组和顺序组。注意在PG ( b1b3)中,组件沿LEADING边缘(此处为左边缘)对齐,在PG ( b2b4)中,组件沿TRAILING边缘(此处为右边缘)对齐。让我们将对齐插入到您的伪代码中,这些组将如下所示:PGLEADINGPGTRAILING。为了讨论这个例子,我编造了这个语法。您将很快看到 Java 代码。如果您对可视化排列有问题,您可以参考图 1-54 ,其中每个按钮都由沿水平轴的箭头表示。

A978-1-4302-6662-4_1_Fig54_HTML.jpg

图 1-54。

Four buttons represented by four arrows along horizontal axis

箭头的对齐方式与按钮相同。你可以观察到b1b3的箭头是平行的,b2b4的箭头也是平行的。如果您将两个平行的组可视化,您可以观察到这两个组沿着水平轴组成一个连续的组。为了帮助你形象化这个最终的排列,箭头排列已经在图 1-55 中进行了细化。

A978-1-4302-6662-4_1_Fig55_HTML.jpg

图 1-55。

Four buttons represented by four arrows along horizontal axis

每个平行组显示在虚线矩形内。从虚线矩形出来的箭头表示这些组沿着水平轴是连续的。理解这些组件沿轴的平行和顺序排列可能需要一段时间。一旦你掌握了它,在一个复杂的场景中使用一个GroupLayout将会非常容易。最有可能的是,您将使用 GUI 生成器工具来安排您的组件,并且您不会关心组的复杂性。但是,理解布局背后的概念总是有帮助的。

为了沿着水平轴结束这个讨论,伪代码看起来如下:

Horizontal Group = SG(PGLEADING, PGTRAILING)

类似地,您可以沿着垂直轴可视化分组排列。如果你在视觉上有问题,你可以把四个按钮都画成从上到下的箭头,看看它们是如何沿着纵轴分组的。以下是垂直分组排列:

Vertical Group = SG(PGBASELINE, PGBASELINE)

现在,很容易将伪代码翻译成 Java 代码,如清单 1-20 所示。

清单 1-20。GroupLayout 中的嵌套组

// NestedGroupLayout.java

package com.jdojo.swing;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.JButton;

import javax.swing.GroupLayout;

import static javax.swing.GroupLayout.Alignment.*;

public class NestedGroupLayout {

public static void main(String[] args) {

JFrame frame = new JFrame("Nested Groups in GroupLayout");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

// Set the content's pane layout to GroupLayout

GroupLayout groupLayout = new GroupLayout(contentPane);

groupLayout.setAutoCreateGaps(true);

groupLayout.setAutoCreateContainerGaps(true);

contentPane.setLayout(groupLayout);

// Add four JButtons to the content pane

JButton b1 = new JButton("Button 1");

JButton b2 = new JButton("Little Bigger Button 2");

JButton b3 = new JButton("3");

JButton b4 = new JButton("Button 4");

groupLayout.setHorizontalGroup(

groupLayout.createSequentialGroup()

.addGroup(groupLayout.createParallelGroup(LEADING)

.addComponent(b1)

.addComponent(b3))

.addGroup(groupLayout.createParallelGroup(TRAILING)

.addComponent(b2)

.addComponent(b4))

);

groupLayout.setVerticalGroup(

groupLayout.createSequentialGroup()

.addGroup(groupLayout.createParallelGroup(BASELINE)

.addComponent(b1)

.addComponent(b2))

.addGroup(groupLayout.createParallelGroup(BASELINE)

.addComponent(b3)

.addComponent(b4))

);

frame.pack();

frame.setVisible(true);

}

}

如何使两个组件的大小相同?让我们试着让b1b3大小相同。当使组件可调整大小时,您需要考虑两件事情。首先,您需要考虑组的可调整行为。其次,您需要考虑组内组件的可调整行为。平行组的大小是最大元素的大小。如果你考虑PG{LEADING](b1, b3),这个组的宽度将是b1的大小,因为b1是这个组中最大的组件。默认情况下,JButton的大小是固定的。要使b3伸展到组的大小(这是b1的大小),您必须将它添加到组中,指定它可以扩展为addComponent(b3, GroupLayout.DEFAULT_SIZE, GroupLayout.DEFAULT_SIZE, Integer.MAX_VALUE)。这将迫使b3拉伸到与其组相同的大小,从而与b1的宽度相同。如果两个组件不在同一个平行组中,要使它们大小相同,可以使用GroupLayout类的linkSize()方法。当使用linkSize()方法使组件大小相同时,组件变得不可调整大小,不管它们的最小、首选和最大大小。

// Make b1, b2, b3 and b4 the same size

groupLayout.linkSize(b1, b2, b3, b4);

// Make b1 and b3 the same size horizontally

groupLayout.linkSize(SwingConstants.HORIZONTAL, new Component[]{b1, b3});

当您使用createParallelGroup(GroupLayout.Alignment a, boolean resizable)方法创建一个并行组时,您也可以调整组的大小。如果您将可调整大小的组件放在可调整大小的组中,则当您调整容器大小时,该组将调整大小,从而使组件调整大小。

空布局管理器

到目前为止,您可能已经意识到布局管理器处理容器内组件的定位和大小调整。如果调整了容器的大小,布局管理器将负责重新定位和调整其中组件的大小。如果您不想拥有布局管理器,您将失去这一优势,并且您需要负责容器内所有组件的定位和大小调整。告诉容器你不需要布局管理器是很简单的。只需将布局管理器设置为null,就像这样:

// Do not use a layout manager for myContainer

myContainer.setLayout(null);

您可以将JFrame的内容窗格的布局管理器设置为null,如下所示:

JFrame frame = new JFrame("No Layout Manager Frame");

Container contentPane = frame.getContentPane();

contentPane.setLayout(null);

短语“空布局管理器”仅仅意味着没有布局管理器。它也被称为绝对定位。请注意,您的程序可能运行在不同的平台上。当组件在不同的平台上显示时,它们的大小可能不同,而你的null布局管理器不能解释这种不一致。当你使用一个null布局管理器时,确保你的组件足够大,可以在所有平台上正常显示。

清单 1-21 为JFrame的内容窗格使用了一个null布局管理器。它添加了两个按钮。它还使用setBounds()方法设置按钮和JFrame的位置和大小。图 1-56 显示了最终的JFrame

清单 1-21。使用空布局管理器

// NullLayout.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JButton;

public class NullLayout  {

public static void main(String[] args) {

JFrame frame = new JFrame("Null Layout Manager");

frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = frame.getContentPane();

contentPane.setLayout(null);

JButton b1 = new JButton("Small Button 1");

JButton b2 = new JButton("Big Big Big Button 2...");

contentPane.add(b1);

contentPane.add(b2);

// Must set (x, y) and (width, height) of components

b1.setBounds(10, 10, 100, 20);

b2.setBounds(120, 10, 150, 20);

// Must set the size of JFrame, because it uses a null layout.

// Now, you cannot use the pack() method to compute its size.

frame.setBounds(0, 0, 350, 100);

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig56_HTML.jpg

图 1-56。

A JFrame using a null layout manager

请注意,按钮的标签没有完全显示。这是你在使用null布局管理器时会遇到的问题之一。如果您试图在运行时调整JFrame的大小,您会注意到按钮不会自动调整大小,如果您使用了布局管理器,它们会自动调整大小。布局管理器根据平台、文本和字体来计算JButton的大小,而使用null布局管理器,你应该考虑所有这些因素来计算(大多数时候,你只是猜测)按钮的大小。在 Java 中使用null布局管理器不是一个好的实践,除非你正在原型开发或者学习null布局管理器。

创建可重用的 JFrame

在前面的章节中,您通过实例化JFrame类创建了一个JFrame,并使用该类的main()方法编写构建 GUI 的代码。您示例中的JFrame是不可重用的。到目前为止,您做得很好,因为 Swing 程序很简单,它们的唯一目的是在一个JFrame中显示一些组件。当您开始编写更复杂的 Swing 程序时,这种编程方式不会很好地工作。例如,假设您想在JFrame显示后使JFrame中的JButton不可见或被禁用。因为您已经将所有的JButton声明为main()方法中的局部变量,所以一旦main()方法执行完毕,您将无法访问它们的引用。为了使您的JFrame可重用,并保持添加到JFrame的组件的引用方便,以便您可以在以后引用它们,您需要改变创建JFrame的方法。

这是你创造JFrame的新方法。您创建自己的类,从JFrame类继承它,如下所示:

public class CustomFrame extends JFrame {

// Code for CustomFrame goes here

}

所有组件都在自定义类中声明为实例变量,如下所示:

public class CustomFrame extends JFrame {

// Declare all components in the JFrame as instance variables

JButton okButton = new JButton("OK");

JButton cancelButton = new JButton("Cancel");

}

您有一个向JFrame的内容窗格添加组件的initFrame()方法。您从自定义的构造函数JFrame中调用这个方法。Java 不需要方法initFrame()。这只是为 Swing 应用编写代码的惯例。为了显示您的JFrame,您实例化您的类并使其可见。这种方法有相似的代码,但排列方式不同,因此您可以编写一些更严肃的 Swing 程序。清单 1-22 完成了与清单 1-19 相同的事情。

清单 1-22。创建自定义 JFrame

// CustomFrame.java

package com.jdojo.swing;

import javax.swing.JFrame;

import javax.swing.GroupLayout.Alignment;

import javax.swing.JButton;

import java.awt.Container;

import javax.swing.GroupLayout;

public class CustomFrame extends JFrame {

// Declare all components as instance variables

JButton b1 = new JButton("Button 1");

JButton b2 = new JButton("Little Bigger Button 2");

public CustomFrame(String title) {

super(title);

initFrame();

}

// Initialize the frame and add components to it.

private void initFrame() {

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

GroupLayout groupLayout = new GroupLayout(contentPane);

contentPane.setLayout(groupLayout);

groupLayout.setHorizontalGroup(

groupLayout.createSequentialGroup()

.addComponent(b1)

.addComponent(b2)

);

groupLayout.setVerticalGroup(

groupLayout.createParallelGroup(Alignment.BASELINE)

.addComponent(b1)

.addComponent(b2)

);

}

// Display the CustomFrame

public static void main(String[] args) {

CustomFrame frame = new CustomFrame("Custom Frame");

frame.pack();

frame.setVisible(true);

}

}

事件处理

什么是事件?事件的字面意思是

"某事物在特定时间点的发生."

Swing 应用中事件的含义是相似的。Swing 中的事件是用户在特定时间点采取的动作。例如,在 Swing 应用中,按下按钮、按下键盘上的向下/向上键以及将鼠标移动到组件上方都是事件。有时,在 Swing(或任何基于 GUI 的应用)中发生的事件也被称为“触发事件”或“激发事件”当您说按钮上发生了单击事件时,您是指使用鼠标、空格键或应用允许您按下按钮的任何其他方式按下了按钮。有时你可以使用短语“点击事件已经在按钮上被触发或激发”来表示按钮已经被按下。

当事件发生时,您希望对事件做出响应。在程序中采取一个动作只不过是执行一段代码。为响应事件的发生而采取的行动称为事件处理。事件发生时执行的代码称为事件处理程序。有时,事件处理程序也称为事件侦听器。

如何编写事件处理程序取决于事件的类型和生成事件的组件。有时事件处理程序内置在 Swing 组件中,有时您需要自己编写事件处理程序。比如,当你按下一个JButton时,你需要自己编写事件处理程序。但是,当焦点在文本字段中时,如果按下键盘上的字母键,就会在文本字段中键入相应的字母,因为按键事件有一个由 Swing 提供的默认事件处理程序。

一个事件有三个参与者:

  • 事件的来源
  • 事件
  • 事件处理程序(或事件监听器)

事件源是生成事件的组件。例如,当你按下一个JButton时,点击的事件发生在那个JButton上。在这种情况下,JButton是被点击事件的来源。

事件表示在源组件上发生的动作。Swing 中的事件由一个对象表示,该对象封装了事件的详细信息,例如事件的来源、事件发生的时间、事件发生的类型等。表示事件的对象的类是什么?这取决于所发生事件的类型。每种类型的事件都有一个类。例如,java.awt.event包中的ActionEvent类的一个对象代表一个JButton的点击事件。

我不会在本章讨论所有类型的事件。当我在第二章中讨论组件时,我将列出组件的重要事件。本节将解释如何在 Swing 应用中处理任何类型的事件。

事件处理程序是事件发生时执行的一段代码。像事件一样,事件处理程序也由对象表示,它封装了事件处理代码。哪个类的对象代表一个事件处理程序?这取决于事件处理程序应该处理的事件类型。事件处理程序也称为事件侦听器,因为它侦听源组件中发生的事件。在本章中,我将交替使用“事件处理程序”和“事件监听器”这两个短语。通常,事件侦听器是实现特定接口的对象。事件侦听器必须实现的特定接口取决于它将侦听的事件类型。例如,如果您对监听一个JButton的点击事件感兴趣(换句话说,如果您对处理一个JButton的点击事件感兴趣),您需要一个实现ActionListener接口的类的对象,它在java.awt.event包中。

查看事件处理的三个参与者的描述,似乎您需要编写大量代码来处理一个事件。不完全是。事件处理比看起来容易。我将列出处理一个事件的步骤,然后是一个如何处理一个JButton的点击事件的例子。以下是处理事件的步骤。这些步骤适用于处理任何 Swing 组件上的任何类型的事件。

  • 标识要为其处理事件的组件。假设您已经将组件命名为sourceComponent。所以你的事件源是sourceComponent
  • 标识要为源组件处理的事件。假设您对处理Xxx事件感兴趣。这里的Xxx是一个事件名,您必须用源组件的事件名来替换它。回想一下,一个事件由一个对象表示。事件类的 Java 命名约定可以帮助您识别对象代表Xxx事件的类的名称。对象代表Xxx事件的类被命名为XxxEvent。通常事件类在java.awt.eventjavax.swing.event包中。
  • 是时候为Xxx事件编写一个事件监听器了。回想一下,事件侦听器只不过是实现特定接口的类的对象。你怎么知道你需要在你的事件监听器类中实现什么特定的接口呢?在这里,Java 命名约定再次拯救了您。对于Xxx事件,您需要在事件监听器类中实现一个XxxListener接口。通常事件监听器接口在java.awt.eventjavax.swing.event包中。XxxListener接口将有一个或多个方法。所有用于XxxListener的方法都接受一个类型为XxxEvent的参数,因为这些方法旨在处理一个XxxEvent。例如,假设您有一个XxxListener接口,它有一个名为aMethod()的方法,如public interface XxxListener { void aMethod(XxxEvent event); }所示,您的事件监听器类将如下所示。请注意,您将创建这个类。public class MyXxxEventListener implements XxxListener { public void aMethod(XxxEvent event) { // Your event handler code goes here } }
  • 你差不多完成了。您已经确定了事件源、感兴趣的事件和事件侦听器。只有一样东西不见了。您需要让事件源知道您的事件监听器有兴趣监听它的Xxx事件。这也称为向事件源注册事件侦听器。向事件源注册事件侦听器类的对象。在您的例子中,您将创建一个MyXxxEventListener类的对象。MyXxxEventListener myXxxListener = new MyXxxEventListener();如何向事件源注册一个事件监听器?在这里,Java 命名约定再次派上了用场。如果一个组件(事件源)支持一个Xxx事件,它将有两个方法,addXxxListener(XxxListener l)removeXxxListener(XxxListener l)。当您对组件的Xxx事件感兴趣时,您调用addXxxListener()方法,将事件侦听器作为参数传递。当你不想再监听组件的Xxx事件时,你调用它的removeXxxListener()方法。要添加您的myXxxListener对象作为sourceComponentXxx事件监听器,您需要编写sourceComponent.addXxxListener(myXxxListener);

这就是处理一个Xxx事件所需要做的一切。看起来你必须执行许多步骤来处理一个事件。然而,事实并非如此。你总是可以避免编写一个新的事件监听器类,它通过使用一个匿名内部类来实现XxxListener接口,这个匿名内部类实现了XxxListener接口。例如,您可以用两条语句编写上述代码,如下所示:

// Create an event listener object using an anonymous inner class

XxxListener myXxxListener = new XxxListener() {

public void aMethod(XxxEvent event) {

// Your event handler code goes here

}

};

// Add the event listener to the event source component

sourceComponent.addXxxListener(myXxxListener);

如果侦听器接口是一个函数接口,您可以使用 lambda 表达式来创建它的实例。您的XxxListener是一个函数接口,因为它只包含一个抽象方法。您可以避免创建庞大的匿名类,并将上面的代码重写如下:

// Add the event listener using a lambda expressions

sourceComponent.addXxxListener((XxxEvent event) -> {

// Your event handler code goes here

});

关于处理事件的理论,我已经讨论的够多了。是时候看一个例子了。向一个JButton添加一个事件监听器,然后向一个JFrame添加一个带有文本CloseJButton。当按下JButton时,JFrame关闭,应用退出。当按下JButton按钮时,会产生一个Action事件。一旦您知道了事件的名称,在本例中是Action,您只需要用单词Action替换前面通用示例中的Xxx。您将会知道您需要用来处理JButtonAction事件的类名和方法名。表 1-6 比较了用于处理JButtonAction事件的类/接口/方法的名称和我在讨论中使用的通用名称。

表 1-6。

A Comparison Between Generic Event Handlers With Action Event Handlers for a JButton

| 通用事件 Xxx | JButton 的操作事件 | 评论 | | --- | --- | --- | | `XxxEvent` | `ActionEvent` | `java.awt.event`包中`ActionEvent`类的一个对象代表`JButton`的`Action`事件。 | | `XxxListener` | `ActionListener` | 实现`ActionListener`接口的类的对象代表了`JButton`的`Action`事件处理程序。 | | `addXxxListener` `(XxxListener l)` | `addActionListener` `(ActionListener l)` | 一个`JButton`的`addActionListener()`方法用于为它的`Action`事件添加一个监听器。 | | `removeXxxListener` `(XxxListener l)` | `removeActionListener` `(ActionListener l)` | `JButton`的`removeActionListener()`方法用于移除其`Action`事件的监听器。 |

ActionListener界面很简单。它有一个叫做actionPerformed()的方法。接口声明如下:

public interface ActionListener extends EventListener {

void actionPerformed(ActionEvent event);

}

所有事件监听器接口都继承自EventListener接口,该接口在java.util包中。EventListener接口是一个标记接口,它没有任何方法。它只是充当所有事件侦听器接口的祖先。当一个JButton被按下时,它所有注册的Action监听器的actionPerformed()方法被调用。

使用 lambda 表达式,下面是如何将一个Action侦听器添加到一个JButton中:

// Add an ActionListener to closeButton

closeButton.addActionListener(e -> System.exit(0));

清单 1-23 显示了一个包含一个JButtonJFrame。它向JButton添加了一个Action监听器。Action监听器简单地退出应用。点击JFrame中的Close按钮将关闭应用。

清单 1-23。带有带动作的关闭按钮的 JFrame

// SimplestEventHandlingFrame.java

package com.jdojo.swing;

import java.awt.FlowLayout;

import javax.swing.JFrame;

import javax.swing.JButton;

public class SimplestEventHandlingFrame extends JFrame {

JButton closeButton = new JButton("Close");

public SimplestEventHandlingFrame() {

super("Simplest Event Handling JFrame");

this.initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

// Set a FlowLayout for the content pane

this.setLayout(new FlowLayout());

// Add the Close JButton to the content pane

this.getContentPane().add(closeButton);

// Add an ActionListener to closeButton

closeButton.addActionListener(e -> System.exit(0));

}

public static void main(String[] args) {

SimplestEventHandlingFrame frame =

new SimplestEventHandlingFrame();

frame.pack();

frame.setVisible(true);

}

}

让我们再举一个将Action监听器添加到JButton的例子。这次,给一个JFrame添加两个按钮:一个Close按钮和另一个显示点击次数的按钮。每次单击第二个按钮时,它的文本都会更新,以显示它被单击的次数。您需要使用一个实例变量来维护点击计数。清单 1-24 包含了完整的代码。图 1-57 显示计数器按钮被点击三次后的JFrame

清单 1-24。带有两个带动作的按钮的 JFrame

// JButtonClickedCounter.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.FlowLayout;

import java.awt.event.ActionEvent;

import javax.swing.JButton;

import java.awt.event.ActionListener;

public class JButtonClickedCounter extends JFrame {

int counter;

JButton counterButton = new JButton("Clicked #0");

JButton closeButton = new JButton("Close");

public JButtonClickedCounter() {

super("JButton Clicked Counter");

this.initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

// Set a FlowLayout for the content pane

this.setLayout(new FlowLayout());

// Add two JButtons to the content pane

this.getContentPane().add(counterButton);

this.getContentPane().add(closeButton);

// Add an ActionListener to the counter JButton

counterButton.addActionListener(new ActionListener() {

public void actionPerformed(ActionEvent event) {

// Increment the counter and set the JButton text

counter++;

counterButton.setText("Clicked #" + counter);

}

});

// Add an ActionListener to closeButton

closeButton.addActionListener(new ActionListener() {

public void actionPerformed(ActionEvent event) {

// Exit the application, when this button is pressed

System.exit(0);

}

});

}

public static void main(String[] args) {

JButtonClickedCounter frame = new JButtonClickedCounter();

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_1_Fig57_HTML.jpg

图 1-57。

A JFrame when it is displayed and after the counter JButton is clicked three times

图 1-58 显示了处理Action事件所涉及的类和接口的类图。

A978-1-4302-6662-4_1_Fig58_HTML.jpg

图 1-58。

A class diagram for classes and interfaces realted to Action Event

注意,您没有创建一个ActionEvent类的对象。当按下JButton时,创建一个ActionEvent类的对象,并将其传递给事件处理程序对象的actionPerformed()方法。默认情况下,ActionEventgetActionCommand()方法返回JButton的文本。您可以使用setActionCommand()方法为JButton显式设置动作命令文本。getModifiers()返回动作事件期间按住的ShiftCtrlAlt等修改键的状态。修饰键是键盘上的一个键,只有在与其他键结合使用时才有意义。paramString()方法返回一个描述动作事件的字符串。它通常用于调试目的。

getActionCommand()方法的用途之一是根据JButton上显示的文本采取一些行动。例如,您可能有一个JButton,用于显示或隐藏屏幕上的一些细节。假设您想将一个JButton的文本显示为ShowHide。您可以如下编写它的Action监听器:

JButton showHideButton = new JButton("Hide");

showHideButton.addActionListener(e -> {

if (e.getActionCommand().equals("Show")) {

// Show the details here...

showHideButton.setText("Hide");

}

else {

// Hide the details here...

showHideButton.setText("Show");

}});

在本节中,您学习了如何为组件添加事件处理程序。例子很简单。他们给JButton s 添加了动作事件处理程序。ActionListener接口是一个函数接口,你可以利用 lambda 表达式来编写动作事件监听器。Swing 是在 lambda 表达式出现之前很久开发的。所有事件侦听器接口都不是函数接口,因此您不能使用 lambda 表达式来创建它们的对象。在这些情况下,可以使用匿名类、成员内部类,或者在主类中实现侦听器接口。

处理鼠标事件

您可以处理组件上的鼠标活动(单击、进入、退出、按下和释放)。您将使用一个JButton来试验鼠标事件。一个MouseEvent类的对象代表一个组件上的Mouse事件。现在,您可以猜测,要处理Mouse事件,您将需要使用MouseListener接口。下面是该接口的声明方式:

public interface MouseListener extends EventListener {

public void mouseClicked(MouseEvent e);

public void mousePressed(MouseEvent e);

public void mouseReleased(MouseEvent e);

public void mouseEntered(MouseEvent e);

public void mouseExited(MouseEvent e);

}

MouseListener接口有五个方法。不能使用 lambda 表达式创建鼠标事件处理程序。当特定的鼠标事件发生时,调用MouseListener接口的方法之一。例如,当鼠标指针进入组件的边界时,组件上发生鼠标输入事件,并调用鼠标监听器对象的mouseEntered()方法。当鼠标指针离开组件边界时,会发生鼠标退出事件,并调用mouseExited()方法。其他方法的名称不言自明。

MouseEvent类有许多提供鼠标事件细节的方法:

  • 方法返回鼠标点击的次数。
  • 当事件发生时,getX()getY()方法返回鼠标相对于组件的 x 和 y 位置。
  • getXOnScreen()getYOnScreen()方法返回事件发生时鼠标的绝对 x 和 y 位置。

假设您对处理JButton的两种鼠标事件感兴趣:鼠标进入和鼠标退出事件。JButton的文本发生变化以描述事件。鼠标事件处理程序代码如下:

mouseButton.addMouseListener(new MouseListener() {

@Override

public void mouseClicked(MouseEvent e) {

// Nothing to handle

}

@Override

public void mousePressed(MouseEvent e) {

// Nothing to handle

}

@Override

public void mouseReleased(MouseEvent e) {

// Nothing to handle

}

@Override

public void mouseEntered(MouseEvent e) {

mouseButton.setText("Mouse has entered!");

}

@Override

public void mouseExited(MouseEvent e) {

mouseButton.setText("Mouse has exited!");

}

});

在这段代码中,您为MouseListener接口的所有五个方法提供了一个实现,尽管您只对处理两种鼠标事件感兴趣。您将三个方法的主体留空。

清单 1-25 展示了一个JButton的鼠标进入和退出事件。当显示JFrame时,尝试将鼠标移进和移出JButton的边界,以改变其文本来指示适当的鼠标事件。

清单 1-25。处理鼠标事件

// HandlingMouseEvent.java

package com.jdojo.swing;

import java.awt.FlowLayout;

import javax.swing.JFrame;

import javax.swing.JButton;

import java.awt.event.MouseListener;

import java.awt.event.MouseEvent;

public class HandlingMouseEvent extends JFrame {

JButton mouseButton = new JButton("No Mouse Movement Yet!");

public HandlingMouseEvent() {

super("Handling Mouse Event");

this.initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

this.setLayout(new FlowLayout());

this.getContentPane().add(mouseButton);

// Add a MouseListener to the JButton

mouseButton.addMouseListener(new MouseListener() {

@Override

public void mouseClicked(MouseEvent e) {

}

@Override

public void mousePressed(MouseEvent e) {

}

@Override

public void mouseReleased(MouseEvent e) {

}

@Override

public void mouseEntered(MouseEvent e) {

mouseButton.setText("Mouse has entered!");

}

@Override

public void mouseExited(MouseEvent e) {

mouseButton.setText("Mouse has exited!");

}

});

}

public static void main(String[] args) {

HandlingMouseEvent frame = new HandlingMouseEvent();

frame.pack();

frame.setVisible(true);

}

}

您是否总是必须为事件侦听器接口的所有事件处理方法提供实现,即使您对它们都不感兴趣?不,你没有。秋千的设计者考虑到了这种不便,并设计了一种方法来避免这种情况。Swing 为一些XxxListener接口提供了一个方便的类。这个类被命名为XxxAdapter。我将称它们为适配器类。一个XxxAdapter类被声明为抽象的,它实现了XxxListener接口。XxxAdapter类为XxxListener接口中的所有方法提供了空实现。下面的代码片段显示了具有两个方法m1()m2()XxxListener接口与其对应的XxxAdapter类之间的关系。

public interface XxxListener {

public void m1();

public void m2();

}

public abstract class XxxAdapter implements XxxListener {

@Override

public void m1() {

// No implementation provided here

}

@Override

public void m2() {

// No implementation provided here

}

}

并非所有事件侦听器接口都有相应的适配器类。声明多个方法的事件侦听器接口有一个对应的适配器类。例如,有一个名为MouseAdapter的用于MouseListener接口的适配器类。MouseAdapter对你有什么好处?它可以为您节省几行不必要的代码。如果您只想处理一些鼠标事件,您可以创建一个匿名内部类(或常规内部类),它继承自适配器类并覆盖您感兴趣的唯一方法。下面的代码片段使用MouseAdapter类重写了清单 1-28 中使用的事件处理程序:

mouseButton.addMouseListener(new MouseAdapter() {

@Override

public void mouseEntered(MouseEvent e) {

mouseButton.setText("Mouse has entered!");

}

@Override

public void mouseExited(MouseEvent e) {

mouseButton.setText("Mouse has exited!");

}

});

您可能会注意到,您不必担心MouseListener接口的其他三个方法,因为MouseAdapter类为您提供了空的实现。

对于ActionListener接口,没有名为ActionAdapter的适配器类。你能猜到为什么没有ActionAdapter课吗?由于ActionListener接口只有一个方法,提供一个适配器类不会为您节省任何击键。

请注意,使用适配器类来处理事件并没有什么特别的优势,除了节省一些击键。然而,它也有局限性。如果您希望通过使用主类本身来创建事件处理程序,则不能使用适配器类。通常,您的主类是从JFrame类继承的,Java 不允许您从多个类继承一个类。所以您不能从JFrame类继承您的主类以及适配器类。如果使用适配器类创建事件处理程序,则必须使用匿名内部类或常规内部类。

摘要

Swing 是一个使用 GUI 开发 Java 应用的小部件工具包。开发 Swing 应用中使用的大多数类都在javax.swing包中。GUI 由几个部分组成;每个部分代表一个图形,向用户显示信息,并让他们与应用进行交互。基于 Swing 的 GUI 应用中的每个部分都被称为一个组件,它是一个 Java 对象。可以包含其他组件的组件称为容器。容器和组件排列成父子层次结构。组件包含在一个容器中,该容器又可以包含在另一个容器中。存在两种类型的容器:顶级容器和非顶级容器。顶级容器不包含在另一个容器中,它可以直接显示在桌面上。例如,JFrame类的一个实例代表一个顶级容器,它是一个可以有标题栏、菜单栏、边框和其他组件的窗口。JButton类的一个实例代表一个组件。

顶级容器由许多层组成,如根窗格、分层窗格、玻璃窗格和内容窗格。组件被添加到内容窗格中。

Swing 提供了布局管理器,负责在容器中布局组件。布局管理器是一个负责确定要在容器中显示的组件的位置和大小的对象。每个容器都有一个默认的布局管理器。例如,BorderLayoutJFrame的默认布局管理器。你可以使用容器的setLayout()方法来设置不同的布局管理器。如果组件的布局管理器设置为null,则不使用布局管理器,您负责在容器中布局组件。

是所有布局管理器中最简单的,它先水平布局组件,然后垂直布局。BorderLayout将容器的空间分成五个区域(北、南、东、西、中),用于布局组件。CardLayout将容器中的组件排列成一叠卡片,一次只能看到一个组件。BoxLayout将组件水平排列成一行或垂直排列成一列。GridLayout将组件排列成大小相等的矩形网格,将每个组件放在一个单元格中。GridBagLayout在按行和列排列的矩形单元格网格中布置组件,其中每个组件占据一个或多个单元格。SpringLayout通过定义组件边缘之间的约束来布局组件;约束是根据弹簧定义的。GroupLayout通过形成连续和平行的组件组来布局组件。

事件表示用户操作,例如用户点击按钮。用户通过事件与 Swing 组件进行交互。在程序中采取行动来响应事件被称为事件处理。一个事件有三个参与者:事件源、事件和事件处理程序。事件源是生成事件的组件。事件由一个对象表示,该对象封装了导致事件发生的用户操作的详细信息。事件处理程序是响应事件发生而执行的特定接口的实例。允许您处理事件的组件包含添加和移除事件处理程序的方法。事件处理中使用的类、接口和方法遵循一种命名约定,这种约定使名称易于记忆。

二、Swing 组件

在本章中,您将学习

  • 什么是秋千组件
  • 不同类型的秋千组件
  • 如何验证文本组件中的输入
  • 如何使用菜单和工具栏
  • 如何使用 JTable 和 JTree 组件编辑表格和分层数据
  • 如何使用自定义和标准对话框
  • 如何自定义组件的属性,如颜色、边框、字体等。
  • 如何绘制组件以及如何绘制形状
  • 立即绘画和双缓冲

什么是 Swing 组件?

Swing 提供了大量组件来构建 GUI。在 Java 程序中,Swing 组件是类的一个实例。JComponent类在javax.swing包中,它是所有 Swing 组件的基类。其类层次如图 2-1 所示。

A978-1-4302-6662-4_2_Fig1_HTML.jpg

图 2-1。

The class hierarchy for the JComponent class

该类继承自java.awt.Container类,而后者又继承自java.awt.Component类。JComponent是一个抽象类。您不能直接实例化它。你必须使用它的一个子类,比如JButtonJTextField等。

由于JComponent类继承自Container类,每个JComponent也可以作为一个容器。例如,一个JButton可以充当另一个JButton或其他JComponent的容器。除非 Swing 库已经提供了一个JComponentJPanel作为容器使用,否则你不会使用(或需要)一个JComponent作为容器。但是,这种层次结构允许您编写如下代码:

JButton btn = new JButton("Container JButton");

btn.setLayout(new FlowLayout());

btn.add(new JButton("Container JButton. Do not use."));

作为所有 Swing 组件的基类,JComponent类提供了以下基本功能,这些功能由所有 Swing 组件继承。我将在本章后面详细讨论这些特性。

  • 它为工具提示提供支持。工具提示是当鼠标指针在组件上停留一定时间时显示的简短文本。
  • 它支持可插拔的外观。与组件外观(绘画和布局)和感觉(响应用户与组件的交互,如事件处理)相关的所有方面都由 UI delegate 对象处理。像JComponent类一样,javax.swing.plaf包中的ComponentUI是用作 UI 委托对象的基类。JComponent的每个后代使用不同种类的 UI 委托对象,该对象是从ComponentUI类派生的。比如 a JButton使用ButtonUI,a JLabel使用LabelUI,a JToolTip使用ToolTipUI作为 UI 委托。
  • 它支持在 Swing 组件周围添加边框。边界可以是任何一种预定义的类型(LineBevelTitledEtched等)。)或自定义边框类型。
  • 它为可访问性提供支持。应用的可访问性是指不同能力和残疾的人可以使用它的程度。例如,它可以为视力受损的用户以更大的字体显示文本。这本书不包括 Java 可访问性 API。
  • 它支持双缓冲,有助于屏幕上的平滑绘画。当在屏幕上擦除和绘制组件时,可能会出现闪烁。为了避免任何闪烁,它提供了一个离屏缓冲区。擦除和重画(更新组件)在离屏缓冲区中完成,离屏缓冲区被复制到屏幕上。
  • 它将键盘上的一个键绑定到一个 Swing 组件。您可以用一个ActionListener对象将键盘上的任何键绑定到一个组件。当那个键被按下时,关联的ActionListeneractionPerformed()方法被调用。
  • 当使用布局管理器时,它为布局组件提供支持。它包含获取和设置组件的最小、首选和最大大小的方法。一个JComponent的三种不同的字体大小设置为布局管理器决定JComponent的大小提供了一个提示。

它允许将多个任意属性(key-value对)关联到一个 Swing 组件,并检索这些属性。JComponent 的putClientProperty()getClientProperty()方法允许处理组件属性。

表 2-1 列出了JComponent类的一些常用方法,这些方法可用于所有 Swing 组件。

表 2-1。

Commonly Used Methods of the JComponent Class and Their Descriptions

| 方法名 | 描述 | | --- | --- | | `Border getBorder()` | 返回组件的边框,如果组件没有边框,则返回`null`。 | | `void setBorder(Border border)` | 设置组件的边框。 | | `Object getClientProperty(Object key)` | 返回与指定键关联的值。该值必须使用`putClientProperty (Object key, Object value)`方法设置。 | | `void putClientProperty(Object key, Object value)` | 向组件添加任意键-值对。 | | `Graphics getGraphics()` | 返回组件的图形上下文对象,该对象可用于在组件上绘图。 | | `Dimension getMaximumSize()``Dimension getMinimumSize()``Dimension getPreferredSize()``Dimension getSize(Dimension d)``void setMaximumSize(Dimension d)``void setMinimumSize(Dimension d)``void setPreferredSize(Dimension d)``void setSize(Dimension d)``void setSize(int width, int height)` | 获取/设置组件的最大、最小、首选和实际大小。当您调用`getSize()`方法时,您可以传递一个`Dimension`对象,大小将存储在其中,并返回相同的对象。这样,该方法可以避免创建新的`Dimension`对象。如果您传递了`null`,它将创建一个`Dimension`对象,在其中存储实际大小,并返回该对象。 | | `String getToolTipText()` | 返回此组件的工具提示文本。 | | `void setToolTipText(String text)` | 设置工具提示文本,当鼠标指针在组件上暂停一段指定的时间后,将显示该文本。 | | `boolean isDoubleBuffered()` | 如果组件使用双缓冲,则返回`true`。否则返回`false`。 | | `void setDoubleBuffered(boolean db)` | 设置组件是否应该使用双缓冲来绘制。 | | `boolean isFocusable()` | 如果组件可以获得焦点,则返回`true`。否则返回`false`。 | | `void setFocusable(boolean focusable)` | 设置组件是否可以获得焦点。 | | `boolean isVisible()` | 如果组件可见,则返回`true`。否则返回`false`。 | | `void setVisible(boolean v)` | 将组件设置为可见或不可见。 | | `boolean isEnabled()` | 如果组件被启用,则返回`true`。否则返回`false`。 | | `void setEnabled(boolean e)` | 启用或禁用组件。默认情况下,组件处于启用状态。启用的组件响应用户输入并生成事件。 | | `boolean requestFocus(boolean temporary)``boolean requestFocusInWindow()` | `requestFocus()`和`requestFocusInWindow()`方法都要求组件获得输入焦点。您应该使用`requestFocusInWindow()`方法而不是`requestFocus()`方法,因为它的行为在所有平台上都是一致的。布尔参数指示请求是否是临时的。如果请求肯定会失败,这些方法将返回`false`。如果请求成功,除非被否决,否则它们返回`true`。 | | `boolean isOpaque()` | 如果`JComponent`不透明,则返回`true`。否则,它返回`false`。 | | `void setOpaque(boolean opaque)` | 设置`JComponent`的不透明度。如果一个`JComponent`是不透明的,它将绘制其边界内的每个像素。如果它不是不透明的,它可能会在其边界内绘制一些像素或不绘制像素,从而允许其后面的像素显示出来。默认情况下,`JComponent`类将该值设置为`false`,使其透明。但是,其子类别的不透明度默认值取决于外观和感觉以及特定的组件。 |

表 2-2 列出了可用于所有 Swing 组件的一些常用事件。每个 Swing 组件还支持一些专门的事件。当我讨论这些组件时,我将解释那些专门的事件。注意,表中列出的所有事件都遵循XxxEvent类、XxxListener接口、XxxAdapter抽象类和addXxxListener()方法命名约定,除非另有说明。也就是说,要处理组件的Xxx事件,需要调用它的addXxxListener(XxxListener l)方法,并传递实现XxxListener接口的类的对象。一个XxxListener接口中的所有方法都接受一个XxxEvent类型的参数。如果XxxListener中有不止一个方法,则有一个对应的XxxAdapter抽象类实现XxxListener接口,并为XxxListener方法提供空实现。

表 2-2。

Some Commonly Used Events Available for All Swing Components

| 事件类别名称 | 事件监听器接口 | 描述 | | --- | --- | --- | | `ComponentEvent` | `ComponentListener`方法:`componentShown()` `componentHidden()` `componentResized()` `componentMoved()` | 当组件的可见性、大小或位置更改时,会发生事件。 | | `FocusEvent` | `FocusListener`方法:`focusGained()` `focusLost()` | 当组件获得或失去焦点时,会发生事件。 | | `KeyEvent` | `KeyListener`方法:`keyPressed()` `keyReleased()` `keyTyped()` | 当组件获得焦点并且按下、释放或键入键盘上的某个键时,会发生事件。当您按下或释放键盘上的任何键时,都会触发按键按下和释放事件。仅当键入 Unicode 字符时,才会触发 key typed 事件。例如,当您在键盘上键入字符“a”时,按下一个键、键入一个键和释放一个键事件将按顺序触发。 | | `MouseEvent` | `MouseListener`方法:`mousePressed()` `mouseReleased()` `mouseClicked()` `mouseEntered()` `mouseExited()` | 当在组件上按下、释放和单击鼠标时,会触发鼠标按下、释放和单击事件。当鼠标进入组件的边界时,会触发鼠标进入事件。当鼠标离开组件边界时,触发鼠标退出事件。注意,`MouseAdapter`类实现了三个接口:`MouseListener`、`MouseMotionListener`和`MouseWheelListener`(参见下面的两个鼠标事件)。 | | `MouseEvent` | `MouseMotionListener`方法:`mouseDragged()` `mouseMoved()`注意:它在事件方法中使用一个`MouseEvent`对象作为参数。没有相应的`MouseMotionEvent`类。 | 当您通过按下鼠标按钮将鼠标拖动到组件上时,会触发鼠标拖动事件。即使鼠标离开组件,鼠标拖动事件也会继续触发,直到松开鼠标按钮。当您在组件上移动鼠标,但没有按下鼠标按钮时,会触发鼠标移动事件。您可以使用`MouseAdapter`或`MouseMotionAdapter`抽象类为该事件编写监听器对象。 | | `MouseWheelEvent` | `MouseWheelListener`方法:`mouseWheelMoved()` | 当组件处于焦点时,如果旋转鼠标滚轮,则会触发鼠标滚轮移动事件。如果鼠标没有滚轮,则不会触发此事件。 |

一开始,Java 提供了 AWT(抽象窗口工具)来构建 GUI。所有 AWT 组件都在java.awt包中,它们使用对等体来处理它们的工作方式。如果使用 AWT 创建按钮,操作系统会创建一个相应的按钮,称为,用于处理 AWT 按钮的大部分工作方式。因为每个 AWT 组件都有一个对等体,所以 AWT 组件被称为组件。

在 JDK 1.2 中,Swing 作为 AWT 的替代成为 Java 类库的一部分。大多数 Swing 组件不使用对等体,因此,它们被称为组件。对于每个 AWT 组件,您都会找到相应的 Swing 组件。Swing 提供了一些 AWT 中没有的附加组件,比如JTabbedPane。Swing 组件的名称带有前缀J。例如,为了使用按钮组件,AWT 提供了一个Button类,Swing 提供了一个JButton类。为了显示装饰窗口,AWT 提供了一个Frame类,Swing 提供了一个JFrame类。Swing 中有些组件还是重量级组件。毕竟,基本的 GUI 功能总是由操作系统提供的。Swing 中的所有顶层容器(JFrameJDialogJWindowJApplet)都是重量级组件,它们都有对等体。除了顶级容器,Swing 组件是轻量级组件。Swing 的轻量级组件使用它们的重量级容器区域进行绘制。Swing 的轻量级组件是用 Java 编写的。

AWT 的主要缺点是 GUI 在不同的操作系统上可能看起来不同。AWT 支持在所有平台上都可用的特性。由于对操作系统对等体的依赖,AWT 只能提供矩形组件。Swing 轻量级组件不存在这些限制。在 Swing 中,您可以拥有任何形状的组件,因为 Swing 使用 Java 代码绘制轻量级组件。Swing 提供了可插拔的外观和感觉,因此您不会局限于只看到操作系统绘制的 GUI 组件。虽然允许在同一个应用中混合使用 Swing 和 AWT 组件,但这是不可取的。混合使用它们可能会导致难以调试的问题。这本书只涉及秋千。

在接下来的部分中,我将详细讨论几个 Swing 组件。

JButton

JButton也称为按钮或命令按钮。用户按下或点击一个JButton来执行一个动作。通常,它会显示描述单击时所执行操作的文本。文本也称为标签。一个JButton也支持显示图标。你可以使用表 2-3 中列出的一个构造函数来创建一个。

表 2-3。

Constructors of the JButton Class

| 构造器 | 描述 | | --- | --- | | `JButton()` | 创建一个没有任何标签或图标的`JButton`。 | | `JButton(String text)` | 创建一个`JButton`并将指定的文本设置为其标签。 | | `JButton(Icon icon)` | 创建一个带有图标但没有标签的`JButton`。 | | `JButton(String text, Icon icon)` | 用指定的标签和图标创建一个`JButton`。 | | `JButton(Action action)` | 用一个`Action`对象创建一个`JButton`。在本节的后面,您将会看到一个使用`Action`对象作为`JButton`的例子。 |

您可以创建一个文本为CloseJButton,如下所示:

JButton closeButton = new JButton("Close");

要创建带有图标的JButton,您需要一个图像文件。图标是固定大小的图像。实现javax.swing.Icon接口的类的对象代表一个图标。Swing 提供了一个非常有用的ImageIcon类,它实现了Icon接口。您可以在程序中使用ImageIcon类从图像文件或包含 GIF、JPEG 或 PNG 图像的 URL 创建图标。以下代码片段显示了如何创建带有图标的按钮:

// Create icons

Icon previousIcon = new ImageIcon("C:/img/previous.gif");

Icon nextIcon = new ImageIcon("C:/img/next.gif");

// Create buttons with icons

JButton previousButton = new JButton("Previous", previousIcon);

JButton nextButton = new JButton("Next", nextIcon);

建议您在ImageIcon类的构造函数中的文件路径中使用正斜杠(/)。您指定的文件路径被转换为 URL,并且正斜杠在所有平台上都有效。这个文件路径示例(C:/img/next.gif)是针对 Windows 平台的。图 2-2 显示了一个带有三个按钮的JFrame。两个按钮有图标,一个只有文本。

A978-1-4302-6662-4_2_Fig2_HTML.jpg

图 2-2。

Buttons with an icon and text, and with only text

对于一个JButton只有一个事件,你将在你的 Java 程序中使用大部分时间。它被称为ActionEvent。点击JButton时触发。该接口是一个函数接口,它只包含一个名为actionPerformed(ActionEvent e)的方法。你可以用一个 lambda 表达式来表示一个ActionListener。下面是如何使用 lambda 表达式为ActionEventcloseButton添加代码:

closeButton.addActionListener(() -> {

// The code to handle the action event goes here

});

支持键盘助记键,也称为或。如果焦点在包含JButton的窗口中,按下该键会激活JButton。助记键通常与修饰键(如Alt键)一起按下。修饰键是平台相关的;但是,通常是一个Alt键。例如,假设您将 C 键设置为Close JButton的助记键。当您按下Alt + C时,Close JButton被点击。如果在JButton文本中找到由助记键表示的字符,其第一次出现时会加下划线。

以下代码片段将 C 设置为Close JButton的助记键:

// Set the 'C' key as mnemonic key for closeButton

closeButton.setMnemonic('C');

// You can also use the following code to set a mnemonic key.

// The KeyEvent class is in the java.awt.event package.

closeButton.setMnemonic(KeyEvent.VK_C);

该代码显示了设置助记键的两种方法。当您不使用字符键作为助记键时,可以使用第二种方法。例如,如果您想将F3键设置为助记键,您可以使用第二种方法使用KeyEvent.VK_F3常量。图 2-3 显示了Close按钮,其中文本的第一个字符带有下划线。当您按下Alt + C时,Close JButton被激活(就像您用鼠标点击它一样)。表 2-4 显示了类中常用的方法。

表 2-4。

Commonly Used Methods of the JButton Class

| 方法 | 描述 | | --- | --- | | `Action getAction()` | 返回与`JButton`相关联的`Action`对象。 | | `void setAction(Action a)` | 为`JButton`设置一个`Action`对象。当这个方法被调用时,`JButton`的所有属性都从指定的`Action`对象中刷新。如果已经设置了一个`Action`对象,新的将替换旧的。新的`Action`对象被注册为`ActionListener`。使用`addActionListener()`方法向`JButton`注册的任何其他`ActionListener`保持注册状态。 | | `Icon getIcon()` | 返回与`JButton`相关联的`Icon`对象。 | | `void setIcon(Icon icon)` | 为`JButton`设置图标。 | | `int getMnemonic()` | 返回此`JButton`的键盘助记符。 | | `void setMnemonic(int n)` `void setMnemonic(char c)` | 设置`JButton`的键盘助记符。 | | `String getText()` | 返回`JButton`的文本。 | | `void setText()` | 设置`JButton`的文本。 |

A978-1-4302-6662-4_2_Fig3_HTML.jpg

图 2-3。

A Close button with C as its keyboard mnemonic

让我们用一个对象来创建一个JButton。到目前为止,您已经看到一个JButton只有四个常用属性:文本、图标、助记符和动作监听器。使用JButton的这些属性既简单又直接。使用一个Action物体如何帮助你对付一个JButton?让我们举一个例子,你有一个按钮,比如说Close,放在窗口的不同区域,比如说不同的标签页。如果按钮在一个窗口上放置四次,并且所有按钮的外观和行为都必须相同,那么一个Action对象将帮助你只为Close按钮编写一次代码,并多次使用它。

一个Action对象封装了按钮的状态和行为。您在一个Action对象中设置文本、图标、助记符、工具提示文本、其他属性和ActionListener,并使用同一个Action对象创建JButton的所有实例。这样做的一个明显好处是,如果您想要启用/禁用所有四个 JButtons,您不需要单独启用/禁用它们。相反,您在Action对象中设置enabled属性,它将启用/禁用所有这些属性。让我们将这种用法扩展到菜单项和工具栏。通常在窗口中提供菜单项、工具栏项和按钮来执行相同的操作。在这种情况下,您使用同一个Action对象来创建它们(一个菜单项、一个工具栏项和一个按钮)以保持它们的状态同步。现在你可以意识到Action对象的好处是重用代码和保持多个组件的状态同步。

Action是一个接口。该类为Action接口提供了默认实现。AbstractAction是一个抽象类。你需要从它继承你的类。清单 2-1 定义了一个CloseAction内部类,它继承自AbstractAction类。

清单 2-1。使用 Action 对象创建和配置 JButton

// ActionJButtonTest.java

package com.jdojo.swing;

import java.awt.FlowLayout;

import javax.swing.JFrame;

import javax.swing.JButton;

import java.awt.event.ActionEvent;

import javax.swing.AbstractAction;

import javax.swing.Action;

import java.awt.Container;

public class ActionJButtonTest extends JFrame {

// Inner Class starts here

public class CloseAction extends AbstractAction {

public CloseAction() {

super("Close");

}

@Override

public void actionPerformed(ActionEvent event) {

System.exit(0);

}

} // Inner Class ends here

JButton closeButton1;

JButton closeButton2;

Action closeAction = new CloseAction(); // See inner class above

public ActionJButtonTest() {

super("Using Action object with JButton");

this.initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

this.setLayout(new FlowLayout());

Container contentPane = this.getContentPane();

// Use the same closeAction object to create both Close buttons

closeButton1 = new JButton(closeAction);

closeButton2 = new JButton(closeAction);

contentPane.add(closeButton1);

contentPane.add(closeButton2);

}

public static void main(String[] args) {

ActionJButtonTest frame = new ActionJButtonTest();

frame.pack();

frame.setVisible(true);

}

}

ActionJButtonTest类创建了一个Action对象,它的类型是CloseAction,并用它来创建两个按钮closeButton1closeButton2CloseAction类将文本设置为Close,在其方法中,它简单地退出应用。图 2-4 显示了运行程序时得到的JFrame。它显示了两个Close按钮。单击它们中的任何一个都会调用Action对象的actionPerformed()方法,这将退出应用。

A978-1-4302-6662-4_2_Fig4_HTML.jpg

图 2-4。

Two Close buttons created using the same Action object

如果您想在使用Action对象时为JButton设置任何属性,您可以通过使用Action接口的putValue(String key, Object value)方法来实现。例如,下面的代码片段为对象closeAction设置了工具提示文本和助记键:

// Set the tool tip text for the Action object

closeAction.putValue(Action.SHORT_DESCRIPTION, "Closes the application");

// Set the mneminic key for the Action object

closeAction.putValue(Action.MNEMONIC_KEY, KeyEvent.VK_C);

Tip

如果您使用一个Action对象来配置一个JButton,然后直接更改JButton的属性,则更改后的属性将一直有效,直到您再次在Action对象中更改该属性。假设你已经用一个CloseAction对象创建了两个Close按钮。如果调用closeButton1.setText("Exit"),第一个按钮会将文本显示为Exit。如果调用closeAction.putValue(Action.NAME, "Close/Exit"),两个按钮都会显示文本为Close/Exit

jpanel(jpanel)

一个JPanel是一个可以包含其他组件的容器。您可以设置其布局管理器、边框和背景颜色。通常,您使用一个JPanel来分组相关的组件,并将其添加到另一个容器中,比如添加到一个JFrame的内容窗格中。注意,JPanel是一个容器,但不是顶级容器,而JFrame是一个顶级容器。因此,您不能在 Swing 应用中单独显示一个JPanel,除非您将它添加到顶级容器中。有时,在两个组件之间插入一个JPanel来产生一个间隙。您也可以使用JPanel作为画布进行绘制,例如绘制直线、矩形、圆形等。

JPanel的默认布局管理器是。注意,JFrame的内容窗格的默认布局管理器是一个BorderLayout。您可以选择在JPanel类的构造函数中指定它的布局管理器。您可以在创建它之后通过使用它的setLayout()方法来改变它的布局管理器。表 2-5 列出了JPanel类的构造函数。

表 2-5。

Constructors for the JPanel Class

| 构造器 | 描述 | | --- | --- | | `JPanel()` | 用`FlowLayout`和双缓冲创建一个`JPanel`。 | | `JPanel(boolean isDoubleBuffered)` | 用`FlowLayout`和指定的双缓冲标志创建一个`JPanel`。 | | `JPanel(LayoutManager layout)` | 用指定的布局管理器和双缓冲创建一个`JPanel`。 | | `JPanel(LayoutManager layout, boolean isDoubleBuffered)` | 用指定的布局管理器和双缓冲标志创建一个`JPanel`。 |

下面的代码片段展示了如何创建一个带有BorderLayoutJPanel并向其添加四个按钮。注意,按钮被添加到JPanel,后者又被添加到JFrame的内容窗格。您还可以将一个JPanel添加到另一个JPanel来创建嵌套的复杂组件布局。

// Create a JPanel and four buttons

JPanel buttonPanel = new JPanel(new BorderLayout());

JButton northButton = new JButton("North");

JButton southButton = new JButton("South");

JButton eastButton = new JButton("East");

JButton westButton = new JButton("west");

// Add buttons to the JPanel

buttonPanel.add(northButton, BorderLayout.NORTH);

buttonPanel.add(southButton, BorderLayout.SOUTH);

buttonPanel.add(eastButton, BorderLayout.EAST);

buttonPanel.add(westButton, BorderLayout.WEST);

// Add the buttonPanel to the JFrame's content pane assuming that

// the content's pane layout is set to a BorderLayout

contentPane.add(buttonPanel, BorderLayout.SOUTH);

JLabel

顾名思义,JLabel是一个标签,用于识别或描述屏幕上的另一个组件。它可以显示文本和/或图标。通常,JLabel被放置在它所描述的组件的旁边(右边或左边)或顶部。图 2-5 显示了一个文本设置为Name:JLabel,这是一个指示符,用户应该在它旁边的字段中输入一个名字。

A978-1-4302-6662-4_2_Fig5_HTML.jpg

图 2-5。

A JLabel component with the text Name: and the mnemonic set to N

JLabel的另一个常见用途是显示图像。Swing 不包含像JImage这样的组件来显示图像。你需要使用一个带有IconJLabel来显示图像。表 2-6 列出了该类的构造函数。

表 2-6。

Constructors of the JLabel Class

| 构造器 | 描述 | | --- | --- | | `JLabel()` | 创建一个空字符串作为文本并且没有图标的`JLabel`。 | | `JLabel(Icon icon)` | 创建一个带有图标和空字符串作为文本的`JLabel`。 | | `JLabel(Icon icon, int horizontalAlignment)` | 创建一个带有图标和指定水平对齐方式的`JLabel`。一个`JLabel`垂直排列在其显示区域的中心。您可以将其显示区域中的水平对齐指定为`SwingConstants`类中定义的以下常量之一:`LEFT`、`CENTER`、`RIGHT`、`LEADING`或`TRAILING`。 | | `JLabel(String text)` | 用指定的`text`创建一个`JLabel`。这是最常用的构造函数。它垂直居中对齐,并在其显示区域内与前缘水平对齐。前缘由组件的方向决定。 | | `JLabel(String text, Icon icon, int horizontalAlignment)` | 用指定的`text`、`icon`和水平对齐创建一个`JLabel`。 | | `JLabel(String text, int horizontalAlignment)` | 用指定的`text`和水平对齐创建一个`JLabel`。 |

下面的代码片段展示了一些如何创建JLabel的例子:

// Create a JLabel with a Name: text

JLabel nameLabel = new JLabel("Name:");

// Display an image warning.gif in a JLabel

JLabel warningImage = new JLabel(new Icon("C:/img/warning.gif"));

一个JLabel不会产生任何有趣的事件。但是,它有一些有用的方法,您可以用来定制它。你会非常频繁地使用其中的三种方法:setText()setDisplayedMnemonic()setLabelFor()setText()方法用于设置。setDisplayedMnemonic()方法用于设置JLabel的键盘助记符。如果键盘助记符是出现在JLabel文本中的字符,该字符会加下划线以提示用户。setLabelFor()方法接受对另一个组件的引用,并指出这个JLabel描述了那个组件。两种方法- setDisplayedMnemonic()setLabelFor()协同工作。当按下JLabel的助记键时,焦点被设置到在setLabelFor()方法中使用的组件。图 2-5 所示的JLabel的助记符设置为字符N,你可以看到文本中的字符N带有下划线。当用户按下Alt + N时,焦点将被设置到显示在JLabel右侧的JTextField上。以下代码片段显示了如何创建如图 2-5 所示的组件排列:

// Create a JTextField where the user can enter a name

JTextField nameTextField = new JTextField("Please enter your name...");

// Create a JLabel with N as its mnemonic and nameTextField as its label-for component

JLabel nameLabel = new JLabel("Name:");

nameLabel.setDisplayedMnemonic('N');

nameLabel.setLabelFor(nameTextField);

// Add name label and field to a container, say a contentPane

contentPane.add(nameLabel);

contentPane.add(nameTextField);

JLabel类中还定义了其他方法,允许您设置/获取显示区域内的对齐方式和边界内的文本。如果你观察一个JLabel组件的特性,你会发现它的存在只是为了描述另一个组件——一个真正利他的组件!

文本组件

简单地说,您可以将文本定义为一系列字符。Swing 提供了一组丰富的功能来处理文本。图 2-6 显示了代表 Swing 中文本组件的类的类图。

A978-1-4302-6662-4_2_Fig6_HTML.jpg

图 2-6。

A class diagram for text-related components in Swing

Swing 提供了如此多与文本相关的特性,以至于它有一个单独的包,java x .swing.text,其中包含了所有与文本相关的类。JTextComponent级在javax.swing.text包里。其余的类都在javax.swing包里。

有不同的 Swing 组件来处理不同类型的文本。我们可以根据两个标准对文本组件进行分类:文本中的行数和它们可以处理的文本类型。根据文本组件可以处理的文本行数,您可以将它们进一步分类如下:

  • 单行文本组件
  • 多行文本组件

单行文本组件设计用于处理一行文本,例如用户名、密码、出生日期等。JTextFieldJPasswordFieldJFormattedTextField类的实例代表单行文本组件。

多行文本组件旨在处理多行文本,例如,注释、商店中某个商品的描述、文档等。JTextAreaJEditorPaneJTextPane类的实例代表多行文本组件。

根据文本组件可以处理的文本类型,可以对文本组件进行如下分类:

  • 纯文本组件
  • 样式文本组件

文本(或部分文本)的样式是文本显示的方式,如粗体、斜体、下划线等。、字体和颜色。在文本组件的上下文中,纯文本意味着文本组件中包含的整个文本只使用一种样式显示。JTextFieldJPasswordFieldJFormattedTextFieldJTextArea是纯文本组件的例子。也就是说,您不能在一个JTextArea中显示多行文本,其中文本的某些部分是粗体,而其他部分不是。您可以用粗体显示JTextArea中的整个文本,也可以用普通字体显示整个文本。注意,纯文本并不意味着文本不能有样式。这意味着只有一种样式适用于整个文本(组成文本的所有字符)。

在样式文本中,您可以将不同的样式应用于文本的不同部分。在样式文本中,文本的某些部分可以是粗体(或斜体,更大的字体大小,下划线等。)和一些不是黑体的部分。JEditorPaneJTextPane是样式化组件的例子。

所有 Swing 组件,包括 Swing 文本组件,都基于模型-视图-控制器(MVC)模式。MVC 模式使用三个组件:模型、视图和控制器。模型负责存储内容(文本)。视图负责显示内容。控制器负责响应用户操作。Swing 将视图和控制器组合成一个名为 UI 的对象,负责显示内容并对用户的动作做出反应。它保持了模型的独立性,并由Document接口的一个实例来表示,该实例在javax.swing.text包中。文本组件的模型有时也被称为它的文档。图 2-7 描述了一个 Swing 文本组件的不同部分。

A978-1-4302-6662-4_2_Fig7_HTML.jpg

图 2-7。

Components of the model-view-controller pattern for Swing text components

请注意,视图可能不总是显示文本组件的全部内容。在图 2-7 中,模型包含了威廉·华兹华斯的一首诗的四行,而视图只显示了第一行的一些单词。

Swing 提供了一个默认的Document接口实现,这使得开发人员可以轻松处理常用的文本类型。当您使用文本组件时,它会为您创建一个合适的模型(有时我会在讨论中将其称为文档),该模型适合存储文本组件的内容。图 2-8 显示了Document接口的类图,以及相关的类和接口。图中显示的所有类和接口都在javax.swing.text包中。

A978-1-4302-6662-4_2_Fig8_HTML.jpg

图 2-8。

A class diagram for the document interface and related interfaces and classes

您可以使用setDocument(Document doc)方法为文本组件设置模型。getDocument()方法返回文本组件的模型。

默认情况下,JTextFieldJPasswordFieldJFormattedTextFieldJTextArea使用PlainDocument类的一个实例作为它们的模型。如果您想要为这些文本组件定制模型,您需要创建一个从PlainDocument类继承的类,并覆盖一些方法。

JEditorPaneJTextPane的模型取决于正在编辑和/或显示的内容类型。文本组件中字符的位置使用从零开始的索引。即,文本中的第一个字符出现在索引 0 处。

文本组件

JTextComponent是一个abstract类。它是所有 Swing 文本组件的祖先。它包括所有文本组件都可用的通用功能。表 2-7 列出了JTextComponent类中包含的文本组件的一些常用方法。

表 2-7。

Commonly Used Methods in the JTextComponent Class

| 方法 | 描述 | | --- | --- | | `Keymap addKeymap(String name, Keymap parentKeymap)` | 将新的键映射添加到组件的键映射层次结构中。 | | `void copy()` | 将选定的文本复制到系统剪贴板。 | | `void cut()` | 将选定的文本移动到系统剪贴板。 | | `Action[] getActions()` | 返回文本编辑器的命令列表。 | | `Document getDocument()` | 返回文本组件的模型。 | | `Keymap getKeymap()` | 返回文本组件的当前活动键映射。 | | `static Keymap getKeymap (String keymapName)` | 返回与名为`keymapName`的文档相关联的键映射。 | | `String getSelectedText()` | 返回组件中选定的文本。如果没有选择的文本或者文档是空的,它返回`null`。 | | `int getSelectionEnd()` | 返回选定文本的结束位置。 | | `int getselectionStart()` | 返回选定文本的起始位置。 | | `String getText()` | 返回此文本组件中包含的文本。它返回组件模型中包含的文本,而不是视图显示的内容。 | | `String getText(int offset, int length) throws BadLocationException` | 返回文本组件中包含的一部分文本,从`offset`位置开始,字符数等于`length`。如果`offset`或`length`无效,则抛出`BadLocationException`。例如,如果一个文本组件包含`Hello`作为其文本,`getText(1,3)`将返回`ell`。 | | `TextUI getUI()` | 返回文本组件的用户界面工厂。 | | `boolean isEditable()` | 如果文本组件是可编辑的,则返回`true`。否则,返回`false`。 | | `void paste()` | 将系统剪贴板的内容传输到文本组件模型。如果在组件中选择了文本,则选定的文本将被替换。如果没有选择,内容将插入到当前位置之前。如果系统剪贴板是空的,它什么也不做。 | | `void print()` | 它显示一个打印对话框,让您打印不带页眉和页脚的文本组件的内容。此方法被重载。此方法的其他版本提供了更多打印文本组件内容的功能。 | | `void read(Reader source, Object description) throws IOException` | 将内容从`source`流读入文本组件,丢弃组件的旧内容。`description`是一个描述`source`流的对象。例如,要将文件`test.txt`的文本读入名为`ta`的`JTextArea`中,您可以编写`FileReader fr =` `new FileReader("test.txt");` `ta.read(fr, "Hello");` `fr.close();` | | `void replaceSelection(String newContent)` | 用`newContent`替换所选内容。如果没有选定的内容,它会插入`newContent`。如果`newContent`为`null`或空字符串,则删除选中的内容。 | | `void select(int start, int end)` | 选择`start`和`end`位置之间的文本。 | | `void selectAll()` | 选择文本组件中的所有文本 | | `void setDocument(Document doc)` | 为文本组件设置文档(即模型)。 | | `void setEditable(boolean editable)` | 如果`editable`为`true`,则将文本组件设置为可编辑。如果`editable`为`false`,则将文本组件设置为不可编辑。 | | `void setKeymap(Keymap keymap)` | 设置文本组件的键映射。 | | `void setSelectionEnd(int end)` | 设置选择的结束位置。 | | `void setSelectionStart(int start)` | 设置选择的开始位置。 | | `void setText(String newText)` | 设置文本组件的文本。 | | `void setUI(TextUI newUI)` | 为文本组件设置新的用户界面。 | | `void updateUI()` | 重新加载文本组件的可插入用户界面。 | | `void write(Writer output)` | 将文本组件的内容写入由`output`定义的流。例如,要将名为`ta`的`JTextArea`的文本写入名为`test.txt`的文件,您应该编写`FileWriter wr = new FileWriter("test.txt");` `ta.write(wr);` `wr.close();` |

文本组件最常用的方法是getText()setText(String text)getText()方法将文本组件的内容作为String返回,setText(String text)方法设置参数中指定的文本组件的内容。

jtextfield(jtextfield)

一个JTextFiel d 可以处理(显示和/或编辑)一行纯文本。您可以使用构造函数以多种不同的方式创建一个JTextField。它的构造函数接受

  • 一根绳子
  • 列数
  • 一个Document物体

该字符串指定初始文本。列数指定了宽度。Document对象指定了模型。初始文本的默认值是null,列数为零,文档(或模型)是PlainDocument类的一个实例。

如果不指定列数,其宽度由初始文本决定。它的首选宽度将足以显示整个文本。如果您指定列数,其首选宽度将足够宽,以在JTextField的当前字体中显示指定列数的m个字符。表 2-8 列出了该类的构造函数。

表 2-8。

Constructors of the JTextField Class

| 构造器 | 描述 | | --- | --- | | `JTextField()` | 用初始文本、列数和文档的默认值创建一个`JTextField`。 | | `JTextField(Document document, String text, int columns)` | 创建一个`JTextField`,将指定的`document`作为其模型,`text`作为其初始文本,`columns`作为其列数。 | | `JTextField(int columns)` | 创建一个将指定的`columns`作为列数的`JTextField`。 | | `JTextField(String text)` | 用指定的`text`创建一个`JTextField`作为它的初始文本。 | | `JTextField(String text, int columns)` | 创建一个`JTextField`,将指定的`text`作为其初始文本,将`columns`作为其列数。 |

以下代码片段使用不同的构造函数创建了许多JTextField实例:

// Create an empty JTextField

JTextField emptyTextField = new JTextField();

// Create a JTextField with an initial text of Hello

JTextField helloTextField = new JTextField("Hello");

// Create a JTextField with the number of columns of 20

JTextField nameTextField = new JTextField(20);

一个JTextField可以输入多少个字符?您可以在JTextField中输入的字符数量没有限制。如果你想限制一个JTextField中的人物数量,你需要定制它的模型。请注意,JTextField的型号存储其内容。在看到运行中的定制模型之前,让我们看看在 Swing 中将文本组件的模型和视图分开的强大功能。

让我们创建两个JTextField的实例。您将设置mirroredName的型号与name的型号相同。你正在做一件非常简单的事情。两个文本字段使用相同的模型。这使得两个字段成为彼此的镜像字段。如果您在其中一个窗口中输入文本,相同的文本会自动显示在另一个窗口中。这是怎么发生的?当您在JTextField中输入文本时,它的模型被更新。它的模型中的任何更新都向它的视图(在这种情况下,这两个组件充当视图)发送通知来更新它们自己。由于两个文本字段是具有相同模型的两个视图,模型中的任何更新(通过任一文本字段)都将向两个文本字段发送通知,并且两个文本字段都将更新它们的视图以显示相同的文本。

清单 2-2 展示了如何在两个文本字段之间共享一个模型。运行该程序,并在任一文本字段中输入一些文本。您将看到另一个文本字段与相同的文本同时更新。

清单 2-2。通过与另一个 JTextField 共享模型来镜像 JTextField

// MirroredTextField.java

package com.jdojo.swing;

import javax.swing.JFrame;

import javax.swing.JTextField;

import javax.swing.JLabel;

import java.awt.GridLayout;

import java.awt.Container;

import javax.swing.text.Document;

public class MirroredTextField extends JFrame {

JLabel nameLabel = new JLabel("Name:") ;

JLabel mirroredNameLabel = new JLabel("Mirrored Name:") ;

JTextField name = new JTextField(20);

JTextField mirroredName = new JTextField(20);

public MirroredTextField() {

super("Mirrored JTextField");

this.initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

this.setLayout(new GridLayout(2, 0));

Container contentPane = this.getContentPane();

contentPane.add(nameLabel);

contentPane.add(name);

contentPane.add(mirroredNameLabel);

contentPane.add(mirroredName);

// Set the model for mirroredName to be the same

// as name's model, so they share their content's storage.

Document nameModel = name.getDocument();

mirroredName.setDocument(nameModel);

}

public static void main(String[] args) {

MirroredTextField frame = new MirroredTextField();

frame.pack();

frame.setVisible(true);

}

}

为了拥有自己的JTextField模型,你需要创建一个新的类。新类既可以实现Document接口,也可以从该类继承。后一种方法更简单,也是最常用的。清单 2-3 包含了一个LimitedCharDocument类的代码,它继承自PlainDocument类。当你想限制一个JTextField中的字符数量时,你可以使用这个类作为一个JTextField的模型。默认情况下,它允许用户输入不限数量的字符。您可以在其构造函数中设置允许的字符数。

清单 2-3。表示具有有限数量字符的普通文档的类

// LimitedCharDocument.java

package com.jdojo.swing;

import javax.swing.text.PlainDocument;

import javax.swing.text.BadLocationException;

import javax.swing.text.AttributeSet;

public class LimitedCharDocument extends PlainDocument {

private int limit = -1; // < 0 means an unlimited characters

public LimitedCharDocument() {

}

public LimitedCharDocument(int limit) {

this.limit = limit;

}

@Override

public void insertString(int offset, String str, AttributeSet a)

throws BadLocationException {

String newString = str;

if (limit >=0 && str != null) {

// Check for the limit

int currentLength = this.getLength() ;

int newTextLength = str.length();

if (currentLength + newTextLength > limit) {

newString = str.substring(0, limit - currentLength);

}

}

super.insertString(offset, newString, a);

}

}

LimitedCharDocument类中感兴趣的代码是insertString()方法。Document接口声明了一个方法。PlainDocument类提供了默认的实现。LimitedCharDocument类覆盖默认实现,并检查插入的字符串是否会超过允许的字符数。如果插入的字符串超过了允许的最大字符数,它会砍掉多余的字符。如果将限制设置为负数,则允许无限数量的字符。最后,该方法简单地调用它在PlainDocument类中的实现来执行真正的动作。

每次将文本插入到JTextField中时,都会调用模型的insertString()。此方法获取以下三个参数:

  • int offset:这是琴弦插入JTextField的位置。第一个字符在偏移量 0 处插入,第二个字符在偏移量 1 处插入,依此类推。
  • String str:插入JTextField的是字符串。当您在JTextField中输入文本时,对于您输入的每个字符都会调用insertString()方法,并且该参数将只包含一个字符。但是,当您将文本粘贴到JTextField中或使用其setText()方法时,该参数可能包含多个字符。
  • AttributeSet a:必须与插入文本相关联的属性。

您可以在代码中使用LimitedCharDocument,如下所示:

// Create a JTextField, which will only allow 10 characters

Document tenCharDoc = new LimitedCharDocument(10);

JTextField t1 = new JTextField(tenCharDoc, "your name", 10);

还有另一种方法为一个JTextField设置文档。您需要创建一个继承自JTextField的新类,并覆盖它的createDefaultModel()方法。它在JTextField类中声明为protected,默认情况下,它返回一个PlainDocument。您可以从此方法返回自定义文档类的实例。您的自定义代码JTextField如下所示:

public class TenCharTextField extends JTextField {

@Override

protected Document createDefaultModel() {

// Return a document object that allows maximum 10 characters

return new LimitedCharDocument(10);

}

// Other code goes here

}

只要需要一个容量为 10 个字符的JTextField,就可以使用TenCharTextField类的实例。

JTextField类中的构造函数调用createDefaultModel()方法。因此,您不应该向您的客户JTextField传递一个参数,并使用该参数的值在您的类的createDefaultModel()方法中构建模型。例如,以下代码片段不会产生预期的结果:

static class LimitedCharTextField extends JTextField {

private int maxChars = -1;

public LimitedCharTextField(int maxChars) {

this.maxChars = maxChars;

}

protected Document createDefaultModel() {

/* Wrong use of maxChars!!! By the time this method is called,

maxChars will have its default value of zero. This method will be

called from the constructor of the JTextField class and at that time

the constructor for this class would not start executing.

*/

return new LimitedCharDocument(maxChars);

}

}

有时,您可能希望强制用户以特定格式在文本字段中输入文本,例如以 mm/dd/yyyy 格式输入日期或仅输入数字。这可以通过为JTextField组件使用定制模型来实现。Swing 包含另一个名为JFormattedTextField的文本组件,它允许您设置文本字段的格式。如果您需要一个允许用户以特定格式添加文本的组件,那么JFormattedTextField会使这项工作变得容易得多。我将很快讨论JFormattedTextField

jpassword field(jpassword 字段)

一个JPasswordField是一个JTextField,除了它允许隐藏字段中显示的实际字符。例如,当您使用登录表单输入密码时,您不希望别人越过您的肩膀看到您在屏幕上的密码。默认情况下,它为字段中的每个实际字符显示一个星号(*)字符。这被称为回声字符。默认的回显字符也取决于应用的外观。您可以通过使用它的 s etEchoChar(char newEchoChar)方法来设置自己的 echo 字符。

JPasswordField类与JTextField类具有相同的构造函数集。您可以使用初始文本、列数和一个Document对象的组合来创建一个JPasswordField对象。

// Create a password field 10 characters wide

JPasswordField passwordField = new JPasswordField(10);

出于安全原因,JPasswordFieldgetText()方法已被否决。您应该使用它的getPassword()方法,该方法返回一个数组char。在你使用完这个char数组后,你应该将它的所有元素重置为零。下面的代码片段显示了如何验证在JPasswordField中输入的密码:

// Get the password entered in the field

char c[] = passwordField.getPassword();

// Suppose you have the correct password in a string.

// Usually, you will get it from a file or database

String correctPass = "Hello";

// Do not convert your password in c[] to a String. Rather, convert the correctPass

// to a char array. Or, better you would have correctPass as char array in the first place.

char[] cp = correctPass.toCharArray();

// Use the equals() method of the java.util.Arrays class to compare c and cp for equality

if (Arrays.equals(c, cp)) {

// The password is correct

}

else {

// The password is incorrect

}

// Null out the password that you have in the char arrays

Arrays.fill(c, (char)0);

Arrays.fill(cp, (char)0);

您可以使用setEchoChar()方法设置您选择的回显字符,如下所示:

// Set # as the echo character

password.setEchoChar(‘#');

您可以将JPasswordField作为JTextField使用,方法是将其回显字符设置为零,如下所示:

// Set the echo character to 0, so the actual password characters are visible.

passwordField.setEchoChar((char)0);

Tip

您需要将JPasswordField的回送字符设置为 ASCII 值为零的字符值,这样JPasswordField将显示实际的字符。如果您将回送字符设置为'0' (ASCII 值为 48),实际的密码将不会显示。相反,将为每个实际字符回显一个'0'字符。

JFormattedTextField

一个JFormattedTextField是一个JTextField,具有以下两个附加功能:

  • 它允许您指定编辑和/或显示文本的格式。
  • 当字段中的值为null时,它还允许您指定一种格式。

除了让您获取和设置字段中的文本的getText()setText()方法之外,JFormattedTextField还提供了两个新方法,分别叫做getValue()etValue(),让您可以处理任何类型的数据,而不仅仅是文本。

JFormattedTextField预配置为处理三种数据:数字、日期和字符串。但是,您可以格式化要在该字段中显示的任何对象。您可以使用不同的构造函数以多种方式设置JFormattedTextField的格式,这些构造函数在表 2-9 中列出。

表 2-9。

Constructors of the JFormattedTextField Class

| 构造器 | 描述 | | --- | --- | | `JFormattedTextField()` | 创建一个没有格式化程序的`JFormattedTextField`。你需要使用它的`setFormatterFactory()`或`setValue()`方法来设置一个格式化程序。 | | `JFormattedTextField(Format format)` | 创建一个`JFormattedTextField`,它将使用指定的`format`来格式化字段中的文本。 | | `JFormattedTextField(` `JFormattedTextField.AbstractFormatter formatter)` | 用指定的格式化程序创建一个`JFormattedTextField`。 | | `JFormattedTextField(JFormattedTextField.AbstractFormatterFactory` `factory)` | 用指定的工厂创建一个`JFormattedTextField`。 | | `JFormattedTextField(``JFormattedTextField.AbstractFormatterFactory` | 用指定的工厂和指定的初始值创建一个`JFormattedTextField`。 | | `JFormattedTextField(Object value)` | 用指定的值创建一个`JFormattedTextField`。该字段将根据值的类别自行配置值的格式。如果将一个`null`作为值传递,该字段就无法知道它需要格式化哪种类型的值,并且它根本不会尝试格式化该值。 |

有必要了解格式、格式化程序和格式化程序工厂之间的区别。java.text.Format对象以字符串形式定义了对象的格式。也就是说,它定义了一个对象作为字符串的外观;例如,mm/dd/yyyy格式的日期对象看起来像07/09/2008

格式化程序由一个JFormattedTextField.AbstractFormatter对象表示,它使用一个java.text.Format对象来格式化一个对象。它的工作是将对象转换成字符串,并将字符串转换回对象。

格式化程序工厂是格式化程序的集合。一个JFormattedTextField使用一个格式化程序工厂来获得一个特定类型的格式化程序。格式化程序工厂对象由JFormattedTextField.AbstractFormatterFactory类的一个实例表示。

以下代码片段将dobField配置为将其中的文本格式化为当前区域设置格式的日期:

JFormattedTextField dobField = new JFormattedTextField();

dobField.setValue(new Date());

下面的代码片段配置了一个salaryField来以当前语言环境格式显示一个数字:

JFormattedTextField salaryField = new JFormattedTextField();

salaryField.setValue(new Double(11233.98));

也可以用格式化程序创建一个JFormattedTextField。您需要使用DateFormatterNumberFormatterMaskFormatter类来分别格式化日期、数字和字符串。这些类都在javax.swing.text包里。

// Have a field to format a date in mm/dd/yyyy format

DateFormat dateFormat = new SimpleDateFormat("mm/dd/yyyy");

DateFormatter dateFormatter = new DateFormatter(dateFormat);

dobField = new JFormattedTextField(dateFormatter);

// Have field to format a number in $#0,000.00 format

NumberFormat numFormat = new DecimalFormat("$#0,000.00");

NumberFormatter numFormatter = new NumberFormatter(numFormat);

salaryField = new JFormattedTextField(numFormatter);

您需要使用掩码格式化程序来格式化字符串。掩码格式化程序使用表 2-10 中列出的特殊字符来指定掩码。

表 2-10。

Special Characters Used to Specify a Mask

| 性格;角色;字母 | 描述 | | --- | --- | | `#` | 一个数字 | | `?` | 一封信 | | `A` | 一个字母或一个数字 | | `*` | 任何事 | | `U` | 一个字母,小写字符映射成大写字符 | | `L` | 一个字母,大写字符映射成小写字母 | | `H` | 十六进制数字(a-f,A-F,0-9) | | `'` | 一句引言。它是一个转义字符,用于转义任何特殊格式的字符。 |

为了让用户输入一个###-##-####格式的社会保险号,您创建一个JFormattedTextField,如下所示。注意,构造函数MaskFormatter(String mask)抛出了一个ParseException

MaskFormatter ssnFormatter = null;

JFormattedTextField ssnField = null;

try {

ssnFormatter = new MaskFormatter("###-##-####");

ssnField = new JFormattedTextField(ssnFormatter);

}

catch (ParseException e) {

e.printStackTrace();

}

当使用掩码格式化程序时,您只能使用您在掩码中指定的字符数。所有非特殊字符(见表 2-10 中的特殊字符列表)显示在屏蔽中。掩码中的每个特殊字符都会显示一个占位符(默认为空格)。例如,如果您将遮罩指定为"###-##-####",则JFormattedTextField会将" - - "显示为占位符。您还可以使用MaskFormatter类的setPlaceHolderCharacter(char placeholder)方法为特殊字符指定一个占位符。要在 SNN 字段中显示000-00-0000,您需要使用“0”作为主格式化程序的占位符,如下所示:

ssnFormatter = new MaskFormatter("###-##-####");

ssnFormatter.setPlaceholderCharacter('0');

创建组件后,您可以使用JFormattedTextField的方法来更改格式化程序。例如,要为名为payDateJFormattedTextField设置日期格式,在创建它之后,您可以编写

DateFormatter df = new DateFormatter(new SimpleDateFormat("mm/dd/yyyy"));

DefaultFormatterFactory dff = new DefaultFormatterFactory(df, df, df, df); dobField.setFormatterFactory(dff);

JFormattedTextField让您指定四种类型的格式化程序:

  • 答:当字段中的值为null时使用。
  • 安:当字段有焦点时使用。
  • 答:当字段没有焦点并且有一个非空值时使用。
  • 答:在以上三种格式化程序都不存在的情况下使用。

您可以通过在JFormattedTextField类的构造函数中使用格式化程序工厂或者调用它的setFormatterFactory()方法来指定所有四个格式化程序。JFormattedTextField.AbstractFormatterFactory抽象类的一个实例代表一个格式化程序工厂。javax.swing.text.DefaultFormatterFactory类是JFormattedTextField.AbstractFormatterFactory类的一个实现。当指定格式化程序时,使用同一个格式化程序来代替四个格式化程序。当指定格式化程序工厂时,您可以为四种不同的情况指定不同的格式化程序。

假设您有一个名为dobFieldJFormattedTextField来显示日期。当该字段获得焦点时,您希望让用户以mm/dd/yyyy的格式编辑日期(例如07/07/2008)。当它没有焦点时,您希望以 mmmm dd, yyyy(例如July 07, 2008)格式显示日期。下面的代码片段将完成这项工作:

DateFormatter df = new DateFormatter(new SimpleDateFormat("mmmm dd, yyyy"));

DateFormatter edf = new DateFormatter(new SimpleDateFormat("mm/dd/yyyy"));

DefaultFormatterFactory ddf = new DefaultFormatterFactory(df, df, edf, df);

dobField.setFormatterFactory(ddf);

如果您已经配置了JFormattedTextField来格式化日期,那么您可以使用它的getValue()方法来获得一个Date对象。getValue()方法的返回类型是Object,您需要将返回值转换为类型Date。您可以将光标放在字段中日期值的月、日、年、小时、分钟和秒部分,并使用上/下箭头键更改该特定部分。如果您想在键入时覆盖字段中的值,您需要使用方法setOverwriteMode(true)将格式化程序设置为覆盖模式。

使用JFormattedTextField的另一个好处是可以限制一个字段中可以输入的字符数。回想一下,在上一节中,您是通过为JTextField使用定制文档来实现这一点的。您可以通过设置掩码格式化程序来达到同样的目的。假设您想让用户在一个字段中最多输入两个字符。您可以按如下方式完成此操作:

JFormattedTextField twoCharField = new JFormattedTextField(new MaskFormatter("**"));

JTextArea(人名)

一个JTextArea可以处理多行纯文本。大多数情况下,当您在一个JTextArea中有多行文本时,您将需要滚动功能。一个JTextArea本身不提供滚动。相反,当您需要任何 Swing 组件的滚动功能时,您需要从另一个名为JScrollPane的 Swing 组件获得帮助。

您可以指定用于确定其首选大小的JTextArea的行数和列数。行数用于确定其首选高度。如果您将行数设置为N,这意味着它的首选高度将被设置为显示当前字体设置中文本的N行数。列数用于确定其首选宽度。如果将列数设置为M,则意味着其首选宽度被设置为当前字体设置中字符m(小写 M)宽度的M倍。

一个JTextArea提供了许多构造函数来创建一个JTextArea组件,使用初始文本、模型、行数和列数的组合作为参数,如表 2-11 所示。

表 2-11。

Constructors of the JTextArea Class

| 构造器 | 描述 | | --- | --- | | `JTextArea()` | 用默认模型创建一个`JTextArea`,初始字符串为`null`,行/列为零。 | | `JTextArea(Document doc)` | 用指定的`doc`创建一个`JTextArea`作为它的模型。它的初始字符串被设置为`null`,行/列被设置为零。 | | `JTextArea(Document doc, String text, int rows, int columns)` | 创建一个`JTextArea`,它的所有属性(模型、初始文本、行和列)都在它的参数中指定。 | | `JTextArea(int rows, int columns)` | 用默认模型创建一个`JTextArea`,初始字符串为`null`,指定行/列。 | | `JTextArea(String text)` | 用指定的初始文本创建一个`JTextArea`。设置默认模型,并将行/列设置为零。 | | `JTextArea(String text, int rows, int columns)` | 用指定的文本、行和列创建一个`JTextArea`。使用默认模型。 |

以下代码片段使用不同的初始值创建了许多JTextArea实例:

// Create a blank JTextArea

JTextArea emptyTextArea = new JTextArea();

// Create a JTextArea with 10 rows and 50 columns

JTextArea commentsTextArea = new JTextArea(10, 50);

// Create a JTextArea with 10 rows and 50 columns with an initial text of "Enter resume here"

JTextArea resumeTextArea = new JTextArea("Enter resume here", 10, 50);

非常重要的是要记住,当你使用JTextArea时,通常你的文本尺寸会比它在屏幕上的尺寸大,你需要一个滚动功能。要给一个JTextArea添加滚动功能,您需要将它添加到一个JScrollPane,并将JScrollPane添加到容器,而不是JTextArea。下面的代码片段演示了这个概念。假设您有一个名为myFrameJFrame,其内容窗格的布局设置为BorderLayout,并且您想要在中心区域添加一个可滚动的JTextArea

// Create JTextArea

JTextArea resumeTextArea = new JTextArea("Enter resume here", 10, 50);

// Add JTextArea to a JScrollPane

JScrollPane sp = new JScrollPane(resumeTextArea);

// Get the reference of the content pane of the JFrame

Container contentPane = myFrame.getContentPane();

// Add the JScrollPane (sp) to the content pane, not the JTextArea

contentPane.add(sp, BorderLayout.CENTER);

表 2-12 中有一些JTextArea的常用方法。大多数时候,你会使用它的setText()getText()append()方法。

表 2-12。

Commonly Used Methods of JTextArea

| 方法 | 描述 | | --- | --- | | `void append(String text)` | 将指定的`text`追加到`JTextArea`的末尾。 | | `int getLineCount()` | 返回`JTextArea`中的行数。 | | `int getLineStartOffset(int line) throws BadLocationException` `int getLineEndOffset(int line) throws BadLocationException` | 返回指定`line`数字的开始和结束偏移量(也称为位置,从零开始)。如果`line`号超出范围,抛出异常。当你把这个方法和`getLineCount()`方法结合起来时,它是有用的。您可以在一个循环中使用这三种方法逐行解析包含在`JTextArea`中的文本。 | | `int getLineOfOffset(int offset) throws BadLocationException` | 返回指定的`offset`出现的行号。 | | `boolean getLineWrap()` | 如果设置了换行,返回`true`。否则返回`false`。 | | `int getTabSize()` | 返回用于制表符的字符数。默认情况下,它返回 8。 | | `boolean getWrapStyleWord()` | 如果自动换行设置为`true`,则返回`true`。否则,它返回`false`。 | | `void insert(String text, int offset)` | 在指定的`offset`处插入指定的`text`。如果模型是`null`或者指定的`text`是空的或者是`null`,调用这个方法没有效果。 | | `void replaceRange(String text, int start, int end)` | 用指定的`text`替换`start`和`end`位置之间的文本。 | | `void setLineWrap(boolean wrap)` | 为`JTextArea`设置换行策略。如果换行设置为`true`,如果一行不适合`JTextArea`的宽度,则换行。如果设置为`false`,即使比`JTextArea`的宽度长,也不会换行。默认设置为`false`。 | | `void setTabSize(int size)` | 设置制表符将扩展到指定大小的字符数。 | | `void setWrapStyleWord(boolean word)` | 当换行设置为`true`时,设置自动换行样式。当设置为`true`时,该行在字边界换行。否则,该行将在字符边界处换行。默认情况下,它被设置为`false`。 |

JTextArea使用可配置的策略在其可显示区域换行。如果换行设置为true,并且一条线比元件的宽度长,该线将被换行。默认情况下,换行设置为false。使用setLineWrap(boolean lineWrap)方法设置换行。

一行可以在单词边界或字符边界换行,这由单词换行策略决定。使用setWrapStyleWord(boolean wordWrap)方法设置单词换行策略。只有当调用了setLineWrap(true)时,调用这个方法才会生效。也就是说,换行策略定义了换行策略的细节。图 2-9 显示了一个JFrame中显示的三个JTextArea组件。

A978-1-4302-6662-4_2_Fig9_HTML.jpg

图 2-9。

The effects of line and word wrapping in a JTextArea

对于图中的三个JTextArea组件(从左到右),换行和换行设置分别为(truetrue)、(truefalse)、(falsetrue)。第一个在单词边界处换行。第二个在字符边界换行。第三个没有换行,你不能看到整个文本的宽度。请注意,三个JTextArea组件中的每一个都添加到了JFrame中,而没有添加到 a 中。

编辑器面板

一个JEditorPane是一个文本组件,被设计用来处理不同种类的文本。默认情况下,它知道如何处理纯文本、HTML 和富文本格式(RTF)。虽然它是为编辑和显示多种类型的内容而设计的,但它主要用于显示 HTML 文档,其中只包含基本的 HTML 元素。对 RTF 内容的支持是非常基本的。

JEditorPane使用特定的对象处理特定类型的内容。如果您想在这个组件中处理新类型的内容,您将需要创建一个定制的EditorKit类,它是javax.swing.text.EditorKit类的一个子类。如果你只是使用这个组件来显示 HTML 内容,你不需要担心一个EditorKit;该组件将为您处理相关的功能。使用JEditorPane显示一个 HTML 页面只需要一行代码,如下所示:

// Create a JEditorPane to display yahoo.com 网页

JEditorPane htmlPane = new JEditorPane("http://www.yahoo.com

注意,JEditorPane类的一些构造函数抛出了一个IOException。指定 URL 时,必须使用 URL 的完整形式,以协议开头。您可以通过以下三种不同的方式让JEditorPane知道它需要安装哪种类型的EditorKit来处理它的内容:

  • 通过调用方法
  • 通过调用setPage(URL url)setPage(String url)方法
  • 通过调用方法

JEditorPane预配置为理解三种类型的内容:文本/纯文本、文本/html 和文本/rtf。您可以使用下面的代码显示文本Hello,在 HTML 中使用

标签:

htmlPane.setContentType("text/html");

htmlPane.setText("<html><body><h1>Hello</h1></body></html>");

当您调用它的setPage()方法时,它使用适当的EditorKit来处理 URL 提供的内容。在下面的代码片段中,JEditorPane根据内容类型使用了EditorKit:

// Handle an HTML Page

editorPane.setPage("http://www.yahoo.com

// Handle an RTF file. When you use a file protocol, you may use three slashes instead of one

editorPane.setPage("file:///C:/test.rtf");

JEditorPane将流中的内容读入编辑器窗格。如果它的编辑器套件已经设置为处理 HTML 内容,并且指定的描述是类型javax.swing.text.html.HTMLDocument,则内容将被读取为 HTML。否则,内容将作为纯文本读取。

当您处理 HTML 文档时,您可能希望在单击超链接时导航到不同的页面。为了使用超链接,您需要向添加一个超链接侦听器,并在事件侦听器的hyperlinkUpdate()方法中,使用setPage()方法导航到新页面。超链接上的三种动作之一ENTEREDEXITEDACTIVATED触发hyperlinkUpdate()方法。当鼠标进入超链接区域时发生ENTERED事件,当鼠标离开超链接区域时发生EXITED事件,当单击超链接时发生ACTIVATED事件。当您想使用超链接导航到另一个页面时,请确保在超链接监听器的hyperlinkUpdate()方法中检查了ACTIVATED事件。以下代码片段使用 lambda 表达式将HyperlinkListener添加到JEditorPane:

editorPane.addHyperlinkListener((HyperlinkEvent event) -> {

if (event.getEventType() == HyperlinkEvent.EventType.ACTIVATED) {

try {

editorPane.setPage(event.getURL());

}

catch (IOException e) {

e.printStackTrace();

}

}

});

如果您想知道新页面何时被加载到JEditorPane中,您需要添加一个属性更改监听器来监听它的属性更改事件,并检查名为page的属性是否已经更改。清单 2-4 包含了使用JEditorPane作为浏览器浏览网页的完整代码。当您运行该程序时,您可以在 URL 字段中输入一个网页地址,然后按 enter 键(或按 Go 按钮),浏览器将显示新 URL 的内容。您也可以单击内容中的超链接导航到另一个网页。代码很简单,包含足够的注释来帮助你理解程序逻辑。

清单 2-4。使用 JEditorPane 组件的 HTML 浏览器

// HTMLBrowser.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JLabel;

import javax.swing.JScrollPane;

import javax.swing.Box;

import javax.swing.JEditorPane;

import javax.swing.JTextField;

import javax.swing.JButton;

import java.awt.BorderLayout;

import java.net.URL;

import javax.swing.event.HyperlinkEvent;

import java.beans.PropertyChangeEvent;

import java.net.MalformedURLException;

import java.io.IOException;

public class HTMLBrowser extends JFrame {

JLabel urlLabel = new JLabel("URL:");

JTextField urlTextField = new JTextField(40);

JButton urlGoButton = new JButton("Go");

JEditorPane editorPane = new JEditorPane();

JLabel statusLabel = new JLabel("Ready");

public HTMLBrowser(String title) {

super(title);

initFrame();

}

// Initialize the JFrame and add components to it

private void initFrame() {

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

Box urlBox = this.getURLBox();

Box editorPaneBox = this.getEditPaneBox();

contentPane.add(urlBox, BorderLayout.NORTH);

contentPane.add(editorPaneBox, BorderLayout.CENTER);

contentPane.add(statusLabel, BorderLayout.SOUTH);

}

private Box getURLBox() {

// URL Box consists of a JLabel, a JTextField and a JButton

Box urlBox = Box.createHorizontalBox();

urlBox.add(urlLabel);

urlBox.add(urlTextField);

urlBox.add(urlGoButton);

// Add an action listener to urlTextField, so when the user enters a url

// and presses the enter key, the appplication navigates to the new URL.

urlTextField.addActionListener(e -> {

String urlString = urlTextField.getText();

go(urlString);

});

// Add an action listener to the Go button

urlGoButton.addActionListener(e -> go());

return urlBox;

}

private Box getEditPaneBox() {

// To display HTML, you must make the editor pane non-editable.

// Otherwise, you will see an editable HTML page that doesnot look nice.

editorPane.setEditable(false);

// URL Box consists of a JLabel, a JTextField and a JButton

Box editorBox = Box.createHorizontalBox();

// Add a JEditorPane inside a JScrollPane to provide scolling

editorBox.add(new JScrollPane(editorPane));

// Add a hyperlink listener to the editor pane, so that it

// navigates to a new page, when the user clicks a hyperlink

editorPane.addHyperlinkListener((HyperlinkEvent event) -> {

if (event.getEventType() == HyperlinkEvent.EventType.ACTIVATED) {

go(event.getURL());

} else if (event.getEventType() == HyperlinkEvent.EventType.ENTERED) {

statusLabel.setText("Please click this link to visit the page");

} else if (event.getEventType() == HyperlinkEvent.EventType.EXITED) {

statusLabel.setText("Ready");

}

});

// Add a property change listener, so we can update

// the URL text field with url of the new page

editorPane.addPropertyChangeListener((PropertyChangeEvent e) -> {

String propertyName = e.getPropertyName();

if (propertyName.equalsIgnoreCase("page")) {

URL url = editorPane.getPage();

urlTextField.setText(url.toExternalForm());

}

});

return editorBox;

}

// Navigates to the url entered in the URL JTextField

public void go() {

try {

URL url = new URL(urlTextField.getText());

this.go(url);

}

catch (MalformedURLException e) {

setStatus(e.getMessage());

}

}

// Navigates to the specified URL

public void go(URL url) {

try {

editorPane.setPage(url);

urlTextField.setText(url.toExternalForm());

setStatus("Ready");

}

catch (IOException e) {

setStatus(e.getMessage());

}

}

// Navigates to the specified URL specified as a string

public void go(String urlString) {

try {

URL url = new URL(urlString);

go(url);

}

catch (IOException e) {

setStatus(e.getMessage());

}

}

private void setStatus(String status) {

statusLabel.setText(status);

}

public static void main(String[] args) {

HTMLBrowser browser = new HTMLBrowser("HTML Browser");

browser.setSize(700, 500);

browser.setVisible(true);

// Let us visit yahoo.com

browser.go("http://www.yahoo.com

}

}

以下是该计划的重要部分:

  • 该方法将一个JLabel、一个JTextField和一个JButton打包在一个水平框中,并将其添加到框架的北部区域。它向JTextFieldJButton添加了一个动作监听器,这样当用户在输入新的 URL 后按回车键或 Go 按钮时,浏览器就会导航到新的 URL。
  • 该方法将一个JEditorPane封装在一个JScrollPane中,并将其添加到帧的中心区域。它还向JEditorPane添加了一个超链接监听器和一个属性更改监听器。超链接侦听器用于在用户单击超链接时导航到 URL。当鼠标进入和退出超链接区域时,它还会在状态栏中显示相应的帮助消息。
  • 一个JLabel用于在框架的南部区域显示一条简短信息。
  • 该方法已被重载,它的主要工作是使用setPage()方法导航到一个新页面。
  • main()方法用于测试。它在浏览器中显示雅虎的主页。

作为一项任务,您可以在浏览器中添加BackForward按钮,让用户在已经访问过的网页之间来回导航。

Tip

为了以良好的格式显示 HTML 页面,您需要通过调用setEditable(false)方法使JEditorPane不可编辑。你不应该使用一个JEditorPane来显示所有类型的 HTML 页面,因为它不能处理所有可以嵌入到 HTML 页面中的不同内容。相反,您应该只使用它来显示包含基本 HTML 内容的 HTML 页面,例如应用的 HTML 帮助文件。

耶文本字符串

JTextPane类是JEditorPane类的子类。它是一个专门的组件,用于处理带有嵌入图像和组件的样式化文档。您可以设置字符和段落的属性。如果你想显示一个 HTML,RTF,或者普通文档,JEditorPane是你最好的选择。但是,如果您需要文字处理器提供的丰富功能来编辑/显示样式化的文本,您需要使用JTextPane。这是一台小型文字处理机。它总是适用于样式化的文档,即使其内容是纯文本。本节不可能讨论它的所有特性;它本身就配得上一本小书。我将谈到它的特性,比如设置样式文本、嵌入图像和组件。

一个JTextPane使用一个样式化的文档,它是接口的一个实例。StyledDocument接口继承了Document接口。DefaultStyledDocumentStyledDocument接口的实现类。A JTextPane使用 a DefaultStyledDocument作为其默认型号。Swing 文本组件中的文档由以树状结构组织的元素组成。顶部元素称为根元素。文档中的一个元素是javax.swing.text.Element接口的一个实例。

普通文档有一个根元素。根元素可以有多个子元素。每个子元素由一行文本组成。请注意,在普通文档中,文档中的所有字符都具有相同的属性(或格式样式)。

样式化文档有一个根元素,也称为节。根元素有分支元素,也称为段落。一个段落有一连串的字符。一个字符串是一组共享相同属性的连续字符。例如,“Hello world”字符串定义了一个字符串。然而,“Hello world”字符串定义了两个字符串。注意单词“world”是粗体,而“Hello”不是。这就是为什么他们定义了两个不同的字符运行。在一个样式化的文档中,一个段落以一个换行符结束,除非是最后一个段落,它不需要以换行符结束。您可以在段落级别定义属性,如缩进、行距、文本对齐等。您可以在字符运行级别定义属性,如字体大小、字体系列、粗体、斜体等。图 2-10 和图 2-11 分别显示了普通文档和样式化文档的结构。

A978-1-4302-6662-4_2_Fig11_HTML.jpg

图 2-11。

Structure of a styled document

A978-1-4302-6662-4_2_Fig10_HTML.jpg

图 2-10。

Structure of a plain document

清单 2-5 中的程序使用一个JTextPane开发了一个基本的文字处理器。它允许您编辑文本,并对文本应用粗体、斜体、颜色和对齐等样式。

清单 2-5。使用 JTextPane 和 JButtons 的简单文字处理器

// WordProcessor.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JTextPane;

import javax.swing.JButton;

import java.awt.BorderLayout;

import javax.swing.JPanel;

import javax.swing.text.StyledDocument;

import javax.swing.text.BadLocationException;

import javax.swing.text.Style;

import javax.swing.text.StyleContext;

import javax.swing.text.StyleConstants;

import java.awt.Color;

public class WordProcessor extends JFrame {

JTextPane textPane = new JTextPane();

JButton normalBtn = new JButton("Normal");

JButton boldBtn = new JButton("Bold");

JButton italicBtn = new JButton("Italic");

JButton underlineBtn = new JButton("Underline");

JButton superscriptBtn = new JButton("Superscript");

JButton blueBtn = new JButton("Blue");

JButton leftBtn = new JButton("Left Align");

JButton rightBtn = new JButton("Right Align");

public WordProcessor(String title) {

super(title);

initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

JPanel buttonPanel = this.getButtonPanel();

contentPane.add(buttonPanel, BorderLayout.NORTH);

contentPane.add(textPane, BorderLayout.CENTER);

this.addStyles(); // Add styles to the text pane for later use

insertTestStrings(); // Insert some texts to the text pane

}

private JPanel getButtonPanel() {

JPanel buttonPanel = new JPanel();

buttonPanel.add(normalBtn);

buttonPanel.add(boldBtn);

buttonPanel.add(italicBtn);

buttonPanel.add(underlineBtn);

buttonPanel.add(superscriptBtn);

buttonPanel.add(blueBtn);

buttonPanel.add(leftBtn);

buttonPanel.add(rightBtn);

// Add ation event listeners to buttons

normalBtn.addActionListener(e -> setNewStyle("normal", true));

boldBtn.addActionListener(e -> setNewStyle("bold", true));

italicBtn.addActionListener(e -> setNewStyle("italic", true));

underlineBtn.addActionListener(e -> setNewStyle("underline", true));

superscriptBtn.addActionListener(e -> setNewStyle("superscript", true));

blueBtn.addActionListener(e -> setNewStyle("blue", true));

leftBtn.addActionListener(e -> setNewStyle("left", false));

rightBtn.addActionListener(e -> setNewStyle("right", false));

return buttonPanel;

}

private void addStyles() {

// Get the default style

StyleContext sc = StyleContext.getDefaultStyleContext();

Style defaultContextStyle = sc.getStyle(StyleContext.DEFAULT_STYLE);

// Add some styles to the document, to retrieve and use later

StyledDocument document = textPane.getStyledDocument();

Style normalStyle = document.addStyle("normal", defaultContextStyle);

// Create a bold style

Style boldStyle = document.addStyle("bold", normalStyle);

StyleConstants.setBold(boldStyle, true);

// Create an italic style

Style italicStyle = document.addStyle("italic", normalStyle);

StyleConstants.setItalic(italicStyle, true);

// Create an underline style

Style underlineStyle = document.addStyle("underline", normalStyle);

StyleConstants.setUnderline(underlineStyle, true);

// Create a superscript style

Style superscriptStyle = document.addStyle("superscript", normalStyle);

StyleConstants.setSuperscript(superscriptStyle, true);

// Create a blue color style

Style blueColorStyle = document.addStyle("blue", normalStyle);

StyleConstants.setForeground(blueColorStyle, Color.BLUE);

// Create a left alignment paragraph style

Style leftStyle = document.addStyle("left", normalStyle);

StyleConstants.setAlignment(leftStyle, StyleConstants.ALIGN_LEFT);

// Create a right alignment paragraph style

Style rightStyle = document.addStyle("right", normalStyle);

StyleConstants.setAlignment(rightStyle, StyleConstants.ALIGN_RIGHT);

}

private void setNewStyle(String styleName, boolean isCharacterStyle) {

StyledDocument document = textPane.getStyledDocument();

Style newStyle = document.getStyle(styleName);

int start = textPane.getSelectionStart();

int end = textPane.getSelectionEnd();

if (isCharacterStyle) {

boolean replaceOld = styleName.equals("normal");

document.setCharacterAttributes(start, end - start,

newStyle, replaceOld);

}

else {

document.setParagraphAttributes(start, end - start, newStyle, false);

}

}

private void insertTestStrings() {

StyledDocument document = textPane.getStyledDocument();

try {

document.insertString(0, "Hello JTextPane\n", null);

}

catch (BadLocationException e) {

e.printStackTrace();

}

}

public static void main(String[] args) {

WordProcessor frame = new WordProcessor("Word Processor");

frame.setSize(700, 500);

frame.setVisible(true);

}

}

文字处理程序有点冗长。然而,它做简单、重复的事情。为了更容易理解,我把程序的逻辑分解成了更小的部分。这个程序的目的是展示一个JTextPane,用户可以在这里编辑文本并使用一些按钮对文本应用样式

有八个按钮。其中五种用于格式化文本:普通、粗体、斜体、下划线和上标。Blue按钮用于将文本颜色设置为蓝色。最后两个按钮Left AlignRight Align,用于设置段落左右对齐。

什么是样式,如何为文本和段落设置样式?简单地说,样式是属性(名称-值对)的集合。设置样式很简单;但是,您需要编写几行代码来拥有该样式本身。您可以向JTextPane的文档和JTextPane本身添加样式。你需要使用StyledDocument类的addStyle(String styleName, Style parent)方法。它返回一个Style对象。parent自变量可以是null。如果不是null,未指定的属性将以parent样式解析。一旦有了样式对象,就可以使用StyleConstants类的setXxx()方法来设置该样式中的适当属性。如果你感到困惑,这里有一个回顾。

把一个样式想象成一个有两列的表格:namevalueStyledDocument类的addStyle()方法返回一个空样式(意味着一个空表)。通过使用StyleConstantssetXxx()方法,您正在向样式添加新行(也就是向表格)。一旦表格中至少有一行(即至少定义了一个样式属性),就可以根据样式类型将该样式应用于字符或段落。请注意,您可以使用空样式。空样式可用于从字符范围或段落中移除所有当前样式。下面的代码片段创建了两种样式:第一种是bold,第二种是bold + italic。如果将第一种样式应用于文本,它会将文本格式化为粗体。如果将第二种样式应用于文本,它会将文本格式化为粗体和斜体。注意,您正在将parent样式设置为null

// Get the styled document from the text pane

StyledDocument document = textPane.getStyledDocument();

// Add an empty style named "bold" to the document

Style bold = document.addStyle("bold", null);

// Add bold attribute to this style

StyleConstants.setBold(bold, true);

// From this point on, you can use the bold style

// Let's create a bold + italic style called boldItalic.

// Add an empty style named boldItalic to the document

Style boldItalic = document.addStyle("boldItalic", null);

// Add bold and italic attributes to the boldItalic style

StyleConstants.setBold(boldItalic, true);

StyleConstants.setItalic(boldItalic, true);

// From this point on, you can use the boldItalic style

将样式对象添加到StyledDocument后,您可能需要它的引用。您可以通过使用它的getStyle(String styleName)方法来检索相同样式的引用。

// Get the bold style from document

Style myBoldStyle = document.getStyle("bold");

一旦有了Style对象,就可以使用StyledDocument类的setCharacterAttributes(int offset, int length, AttributeSet s, boolean replace)setParagraphAttributes (int offset, int length, AttributeSet s, boolean replace)方法将样式设置为字符范围或段落。如果 replace 参数被指定为true,该区域的任何旧样式都将被新样式替换。否则,新样式将与旧样式合并。

// Suppose a text pane has more than five characters in it.

// Make the first three characters bold

document.setCharacterAttributes(0, 3, bold, false);

一个StyleContext对象为它们的有效使用定义了一个样式池。您可以获取默认的样式集合,如下所示:

StyleContext sc = StyleContext.getDefaultStyleContext();

Style defaultContextStyle = sc.getStyle(StyleContext.DEFAULT_STYLE);

// Let's add a default context style as normal style's parent.

// We do not add any extra attribute to normal styles

StyledDocument document = textPane.getStyledDocument();

Style normal = document.addStyle("normal", defaultContextStyle);

表 2-13 包含了一系列重要的方法及其描述,可以帮助你理解清单 2-5 中的代码。图 2-12 显示了在简单的文字处理器中输入 E = mc 2 后的样子。

表 2-13。

Methods of the WordProcessor Class With Their Descriptions

| 方法 | 描述 | | --- | --- | | `initFrame()` | 通过向框架添加组件并设置`JFrame`的默认行为来初始化框架。 | | `getButtonPanel()` | 返回一个`JPanel`,它包含所有用于格式化的`JButton`。它还为所有的`JButton`添加了动作监听器 | | `addStyles()` | 它向文档添加样式。默认的上下文样式名为“normal ”,它用作所有其他样式的父样式。粗体、斜体等样式。,是字符级样式,而左和右是段落级样式。这些样式是从文档中获取的,以便在`setNewStyle()`方法中使用。 | | `setNewStyle()` | 它将样式设置为字符范围或段落范围,如其参数`isCharacterStyle`所示。请注意,如果您设置了“正常”样式,您将使用此样式替换整个样式。否则,您将合并样式。这个逻辑由下面的语句决定:`boolean replaceOld = styleName.equals("normal");` | | `insertTestStrings()` | 使用`insertString()`方法将字符串插入到`JTextPane`的文档中。 | | `main()` | 创建并显示字处理器框架。 |

A978-1-4302-6662-4_2_Fig12_HTML.jpg

图 2-12。

A simple word processor using a JTextPane and JButtons

文字处理器没有保存功能。在真实的应用中,您会提示用户保存文件的位置和名称。下面的代码片段将JTextPane的内容保存到当前工作目录中名为test.rtf的文件中:

// Save the contents of the textPane to a file

FileWriter fw = new java.io.FileWriter("test.rtf");

textPane.write(fw);

fw.close();

JTextPanewrite()方法将包含在其文档中的文本写成纯文本。如果你想保存格式化的文本,你需要使用一个RTFEditorKit对象作为它的编辑器工具包,并使用该编辑器工具包的write()方法写入一个文件。下面的代码片段展示了如何使用一个RTFEditorKit对象在一个JTextPane中保存格式化的文本。注意,RTFEditorKit包含一个read()方法来将格式化的文本读回给JTextPane

// Set an RTFEditorKit to a JTextPane right after you create it

JTextPane textPane = new JTextPane();

textPane.setEditorKit(new RTFEditorKit());

// Other code goes here

// Save formatted text from the JTextPane to a file

String fileName = "test.rtf";

FileOutputStream fos = new FileOutputStream(fileName);

RTFEditorKit kit = (RTFEditorKit)textPane.getEditorKit();

StyledDocument doc = textPane.getStyledDocument();

int len = doc.getLength();

kit.write(fos, doc, 0, len);

fos.close();

Tip

如果你想保存添加到一个JTextPane的图标和组件,你需要将一个JTextPane的文档对象序列化到一个文件中,然后加载回来显示相同的内容。

您可以向JTextPane添加任何 Swing 组件和图标。它只是将一个组件或图标包装成一种样式,并在insertString()方法中使用该样式。下面的代码片段展示了如何将一个JButton和一个图标添加到一个:

// Add a Close button to our document

JButton closeButton = new JButton("Close");

closeButton.addActionListener(e -> System.exit(0));

Style cs = doc.addStyle("componentStyle", null);

StyleConstants.setComponent(cs, closeButton);

// Insert the component at the end of the text.

try {

document.insertString(doc.getLength(), "Close Button goes", cs);

}

catch (BadLocationException e) {

e.printStackTrace();

}

JTextPane添加图标类似于向其添加组件,只是您使用了StyleConstants类的setIcon()方法而不是setComponent()方法,并且使用了ImageIcon对象而不是组件,如图所示:

// Add an icon to a JTextPane

StyleConstants.setIcon(myIconStyle, new ImageIcon("myImageFile"));

Tip

你也可以使用JTextPaneinsertComponent(Component c)insertIcon(Icon g)方法来分别插入一个组件和一个图标。

您可以通过使用AbstractDocument类的dump(PrintStream p)方法来查看JTextPane文档的元素结构。以下代码片段在标准输出中显示转储:

// Display the document structure on the standard output

DefaultStyledDocument doc = (DefaultStyledDocument)textPane.getStyledDocument();

doc.dump(System.out);

下面是一个JTextPane的带文本的文档的转储,如图 2-12 所示。它让您对样式化文档的结构有所了解。

<section>

<paragraph

resolver=NamedStyle:default {bold=false,name=default,foreground=sun.swing.PrintColorUIResource[r=51,g=51,b=51],family=Dialog,FONT_ATTRIBUTE_KEY=javax.swing.plaf.FontUIResource[family=Dialog,name=Dialog,style=plain,size=12],size=12,italic=false,}

>

<content>

[0,16][Hello JTextPane

]

<paragraph

resolver=NamedStyle:default {bold=false,name=default,foreground=sun.swing.PrintColorUIResource[r=51,g=51,b=51],family=Dialog,FONT_ATTRIBUTE_KEY=javax.swing.plaf.FontUIResource[family=Dialog,name=Dialog,style=plain,size=12],size=12,italic=false,}

>

<content>

[16,17][

]

<paragraph

resolver=NamedStyle:default {bold=false,name=default,foreground=sun.swing.PrintColorUIResource[r=51,g=51,b=51],family=Dialog,FONT_ATTRIBUTE_KEY=javax.swing.plaf.FontUIResource[family=Dialog,name=Dialog,style=plain,size=12],size=12,italic=false,}

>

<content

bold=true

name=bold

resolver=NamedStyle:normal {name=normal,resolver=AttributeSet,}

>

[17,21][E=mc]

<content

bold=true

name=bold

resolver=NamedStyle:normal {name=normal,resolver=AttributeSet,}

superscript=true

>

[21,22][2]

<content>

[22,23][

]

<bidi root>

<bidi level

bidiLevel=0

>

[0,23][Hello JTextPane

E=mc2

]

验证文本输入

您已经看到了在文本组件中验证文本输入的例子:使用定制模型和使用JFormattedTextField。您可以将一个输入验证器对象附加到任何一个JComponent,包括一个文本组件。输入验证器对象只是一个类的对象,它继承自名为InputVerifier的抽象类。该类声明如下:

public abstract class InputVerifier {

public abstract boolean verify(JComponent input);

public boolean shouldYieldFocus(JComponent input) {

return verify(input);

}

}

您需要覆盖InputVerifier类的verify()方法。该方法包含验证文本字段中的输入的逻辑。如果文本字段中的值有效,则从此方法返回true。否则,你返回false。当文本字段将要失去焦点时,它的输入验证器的verify()方法被调用。只有当文本字段的输入验证器的verify()方法返回true时,文本字段才会失去焦点。文本组件的setInputVerifier()方法用于附加一个输入验证器。下面的代码片段将输入验证器设置为区号字段。它将在该字段中保持焦点,直到用户输入一个三位数的数字区号。如果字段为空,它允许用户导航到另一个字段。

// Create an area code JTextField

JTextField areaCodeField = new JTextField(3);

// Set an input verifier to the area code field

areaCodeField.setInputVerifier(new InputVerifier() {

public boolean verify(JComponent input) {

String areaCode = areaCodeField.getText();

if (areaCode.length() == 0) {

return true;

} else if (areaCode.length() != 3) {

return false;

}

try {

Integer.parseInt(areaCode);

return true;

}

catch(NumberFormatException e) {

return false;

}

}

});

您可以使用setInputVerifier()方法为任何JComponent设置输入验证器。通常,它仅用于文本字段。作为一个良好的 GUI 设计实践,您应该添加一些关于有效输入值的视觉提示,这样用户就可以理解字段中需要什么样的值。例如,您可能希望为“区号”字段添加一个带有文本“区号(三位数):”的标签,或者当用户在字段中输入无效值时显示一条错误消息。如果没有关于输入验证器字段的有效值的视觉线索,用户将被困在字段中,不知道输入哪种值。

做出选择

Swing 提供了以下组件,允许您从选项列表中进行选择:

  • JToggleButton
  • JCheckBox
  • JRadioButton
  • JComboBox
  • JList

可供从列表中选择的选项的数量可以从 2 到 N 变化,其中 N 是大于 2 的数。从选项列表中进行选择有不同的方法:

  • 该选择可以是互斥的。也就是说,用户只能从选项列表中做出一个选择。在互斥选择中,如果用户更改当前选择,则会自动取消选择之前的选择。例如,MaleFemaleUnknown三个选项的性别选择列表是互斥的。用户只能选择三个选项中的一个,而不能同时选择两个或更多。
  • 有一种特殊的选择情况,其中选择数 N 是 2。在这种情况下,选择类型为boolean : truefalse。有时它们也被称为Yes / No选择,或者On / Off选择。
  • 有时,用户可以从选项列表中进行多项选择。例如,您可以向用户提供一个爱好列表,用户可以从列表中选择一个以上的爱好。

Swing 组件使您能够向用户呈现不同种类的选择,并让用户选择零个、一个或多个选项。图 2-13 显示了四个季节名称的秋千组件:SpringSummerFallWinter。该图显示了可用于从列表中选择选项的五种不同类型的 Swing 组件的外观。此图中显示的某些组件可能不适合它所显示的选项。例如,尽管可以使用一组复选框来显示互斥选项的列表,但这不是一个好的 GUI 实践。当选项相互排斥时,一组单选按钮被认为比一组复选框更合适。

A978-1-4302-6662-4_2_Fig13_HTML.jpg

图 2-13。

Swing components to make a selection from a list of choices

是一个双态按钮。这两种状态是选中和取消选中。当您按下切换按钮时,它会在按下和未按下之间切换。按下是其选中状态,未按下是其未选中状态。请注意,JButtonJToggleButton的工作方式和用法不同。一个JButton只有当鼠标按在它上面时才被按下,而一个JToggleButton在按下和未按下状态之间切换。一个JButton用于启动一个动作,而一个JToggleButton用于从可能的选项列表中选择一个选项。通常,一组JToggleButton用于让用户从互斥选项列表中选择一个选项。一个JToggleButton用于当用户有一个boolean选择时,他需要指示truefalse(或者,是或否)。按下状态表示选择了true,未按下状态表示选择了false

a 也有两种状态:选中和未选中。当用户可以从两个或更多选项的列表中选择零个或更多选项时,使用一组JCheckBox es。当用户有一个boolean选择来指示truefalse时,使用一个JCheckBox

a 也有两种状态:选中和未选中。当有两个或更多互斥选项的列表并且用户必须选择一个选项时,使用一组JRadioButton。一个JRadioButton永远不会作为一个独立的组件用于从两个boolean选项truefalse中做出选择。它总是以两个或两个以上为一组使用。当你必须让用户在两个布尔选择truefalse之间进行选择时,应该使用JCheckBox(不是JRadioButton)。

JToggleButtonJCheckBoxJRadioButton的构造函数允许您使用不同参数的组合来创建它们。您可以使用一个Action对象、一个字符串标签、一个图标和一个boolean标志(表示它是否被默认选中)的组合来创建它们。默认情况下,JToggleButtonJCheckBoxJRadioButton未选中。下面的代码片段展示了创建它们的一些方法:

// Create them with no label and no image

JToggleButton tb1 = new JToggleButton();

JCheckBox cb1 = new JCheckBox();

JRadioButton rb1 = new JRadioButton();

// Create them with text as "Multi-Lingual"

JToggleButton tb2 = new JToggleButton("Multi-Lingual");

JCheckBox cb2 = new JCheckBox("Multi-Lingual");

JRadioButton rb2 = new JRadioButton("Multi-Lingual");

// Create them with text as "Multi-Lingual" and selected by default

JToggleButton tb3 = new JToggleButton("Multi-Lingual", true);

JCheckBox cb3 = new JCheckBox("Multi-Lingual", true);

JRadioButton rb3 = new JRadioButton("Multi-Lingual", true);

要选择/取消选择一个JToggleButtonJCheckBoxJRadioButton,需要调用它们的setSelected()方法。要检查它们是否被选中,使用它们的isSelected()方法。以下代码片段显示了如何使用这些方法:

tb3.setSelected(true);        // Select tb3

boolean b1 = tb3.isSelected(); // will store true in b1

tb3.setSelected(false);        // Unselect tb3

boolean b2 = tb3.isSelected(); // will store false in b2

如果选择是互斥的,则必须将所有选择组合在一个按钮组中。在互斥的选项组中,如果您选择了一个选项,则所有其他选项都不会被选中。通常,您为一组互斥的JRadioButtonJToggleButton创建一个按钮组。理论上,您也可以为具有互斥选择的JCheckBox创建一个按钮组。但是,不建议在 GUI 中使用一组互斥的JCheckBox es。

类别的执行个体代表按钮群组。您可以分别通过使用按钮组的add()remove()方法来添加和移除按钮组的JRadioButtonJToggleButton。最初,按钮组的所有成员都是未选中的。要形成一个按钮组,需要将所有互斥的选择组件添加到一个ButtonGroup类的对象中。您不能向容器添加(事实上,您不能添加)一个ButtonGroup对象。您必须将所有选项组件添加到容器中。清单 2-6 包含了显示一组三个互斥JRadioButton的完整代码。

清单 2-6。由三个 JRadioButtons 表示的一组互斥的三个选项

// ButtonGroupFrame.java

package com.jdojo.swing;

import java.awt.BorderLayout;

import java.awt.Container;

import javax.swing.Box;

import javax.swing.ButtonGroup;

import javax.swing.JFrame;

import javax.swing.JRadioButton;

public class ButtonGroupFrame extends JFrame {

ButtonGroup genderGroup = new ButtonGroup();

JRadioButton genderMale = new JRadioButton("Male");

JRadioButton genderFemale = new JRadioButton("Female");

JRadioButton genderUnknown = new JRadioButton("Unknown");

public ButtonGroupFrame() {

this.initFrame();

}

private void initFrame() {

this.setTitle("Mutually Exclusive JRadioButtons Group");

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

// Add three gender JRadioButtons to a ButtonGroup,

// so they become mutually exclusive choices

genderGroup.add(genderMale);

genderGroup.add(genderFemale);

genderGroup.add(genderUnknown);

// Add gender radio button to a vertical Box

Box b1 = Box.createVerticalBox();

b1.add(genderMale);

b1.add(genderFemale);

b1.add(genderUnknown);

// Add the vertical box to the center of the frame

Container contentPane = this.getContentPane();

contentPane.add(b1, BorderLayout.CENTER);

}

public static void main(String[] args) {

ButtonGroupFrame bf = new ButtonGroupFrame();

bf.pack();

bf.setVisible(true);

}

}

是另一种类型的 Swing 组件,它允许您从选项列表中进行选择。或者,它可以包含一个可编辑字段,允许您键入新的选择值。类型参数E是它包含的元素的类型。当屏幕空间有限时,你可以用一个JComboBox代替一组JToggleButtonJCheckBoxJRadioButton。使用JComboBox可以节省屏幕空间。然而,用户必须执行两次点击来进行选择。首先,用户必须点击箭头按钮来显示下拉列表中的选项列表,然后他必须点击列表中的一个选项。用户还可以使用键盘上的上/下箭头键来滚动选项列表,并在组件处于焦点时选择一个选项。您可以通过在一个构造函数中传递选择列表来创建一个JComboBox,如下所示:

// Use an array of String as the list of choices

String[] sList = new String[]{"Spring", "Summer", "Fall", "Winter"};

JComboBox<String> seasons = new JComboBox<>(sList);

// Use a Vector of String as the list of choices

Vector<String> sList2 = new Vector<>(4);

sList2.add("Spring");

sList2.add("Summer");

sList2.add("Fall");

sList2.add("Winter");

JComboBox<String> seasons2 = new JComboBox<>(sList2);

您可以创建一个没有选择的JComboBox,然后通过使用它的一个方法向它添加选择。它还包括从列表中移除选择并获取所选选择的值的方法。表 2-14 显示了JComboBox类的常用方法列表。

表 2-14。

Commonly Used Methods of the JComboBox class

| 方法 | 描述 | | --- | --- | | `void addItem(E item)` | 将项目作为选项添加到列表中。对添加的对象调用`toString()`方法,返回的字符串显示为一个选项。 | | `E getItemAt(int index)` | 从选择列表中返回指定`index`处的项目。索引从零开始,到列表大小减一结束。如果指定的`index`越界,则返回`null`。 | | `int getItemCount()` | 返回选项列表中的项数。 | | `int getSelectedIndex()` | 返回选定项的索引。如果选定的项目不在列表中,则返回–1。请注意,对于可编辑的`JComboBox`,您可以在字段中键入一个新值,该值可能不在选项列表中。在这种情况下,该方法将返回–1。如果没有选择,它也返回–1。 | | `Object getSelectedItem()` | 返回当前选定的项目。如果没有选择,则返回`null`。 | | `void insertItemAt(E item, int index)` | 在列表中指定的`index`处插入指定的`item`。 | | `boolean isEditable()` | 如果`JComboBox`可编辑,则返回`true`。否则,它返回`false`。默认情况下,`JComboBox`是不可编辑的。 | | `void removeAllItems()` | 从列表中移除所有项目。 | | `void removeItem(Object item)` | 从列表中删除指定的`item`。 | | `void removeItemAt(int index)` | 移除指定`index`处的项目。 | | `void setEditable(boolean editable)` | 如果指定的`editable`参数是`true`,则`JComboBox`是可编辑的。否则,它是不可编辑的。用户可以在可编辑的`JComboBox`中键入一个值,这个值不在选择列表中。请注意,新键入的值不会添加到选项列表中。 | | `void setSelectedIndex(int index)` | 选择列表中指定`index`处的项目。如果指定的`index`为–1,则清除选择。如果指定的`index`小于-1 或者大于列表的大小减 1,它抛出一个`IllegalArgumentException`。 | | `void setSelectedItem(Object item)` | 选择字段中的项目。如果指定的`item`存在于列表中,它总是被选中。如果列表中不存在指定的项目,则仅当`JComboBox`可编辑时,该项目才会在字段中被选中。 |

如果您想在JComboBox中选择或取消选择某个项目时得到通知,您可以为其添加一个项目监听器。每当选择或取消选择某个项目时,都会通知项目监听器。请注意,当您更改JComboBox中的选择时,它会触发取消选择的项目事件,随后是选择的事件。下面的代码片段展示了如何向JComboBox添加一个项目监听器。您可以使用ItemEvent类的getItem()方法来找出哪个项目被选中或取消选中。

String[] sList = new String[]{"Spring", "Summer", "Fall", "Winter"};

JComboBox<String> seasons = new JComboBox<>(sList);

// Add an item listener to the combobox

seasons.addItemListener((ItemEvent e) -> {

Object item = e.getItem();

if (e.getStateChange() == ItemEvent.SELECTED) {

// Item has been selected

System.out.println(item + " has been selected");

} else if (e.getStateChange() == ItemEvent.DESELECTED) {

// Item has been deselected

System.out.println(item + " has been deselected");

}

});

是另一个 Swing 组件,它显示一个选项列表,并允许您从该列表中选择一个或多个选项。类型参数T是它包含的元素的类型。一个JList与一个JComboBox的区别主要在于它显示选择列表的方式。一个JList可以在屏幕上显示多个选项,而一个JComboBox可以在你点击箭头按钮时显示选项列表。从这个意义上说,a JList是 a JComboBox的扩展版。一个JList可以在一列或多列中显示选择列表。您可以像创建JComboBox一样创建JList,如下所示:

// Create a JList using an array

String[] items = new String[]{"Spring", "Summer", "Fall", "Winter"};

JList<String> list = new JList<>(items);

// Create a JList using a Vector

Vector<String> items2 = new Vector<>(4);

items2.add("Spring");

items2.add("Summer");

items2.add("Fall");

items2.add("Winter");

JList<String> list2 = new JList<>(items2);

A JList不具备滚动能力。您必须将它添加到一个JScrollPane中,并将JScrollPane添加到容器中,以获得滚动功能,如下所示:

myContainer.add(new JScrollPane(myJList));

您可以配置JList的布局方向,以三种方式排列选项列表:

  • 垂直的
  • 水平环绕
  • 垂直环绕

在默认的垂直排列中,JList中的所有项目都使用一列多行显示。

在水平包装中,所有项目排列成一行和多列。但是,如果一行中容纳不下所有项目,则需要添加新行来显示这些项目。请注意,根据组件的方向,该项可以从左到右或从右到左水平排列。

在垂直包装中,所有项目都排列在一列和多行中。但是,如果一列中容纳不下所有项目,则需要添加新列来显示它们。

您可以使用JList类的setVisibleRowCount(int visibleRows)方法来设置您希望在列表中看到的不需要滚动的可见行数。当您将可见行数设置为零或更小时,JList将根据字段的宽度/高度及其布局方向决定可见行数。您可以使用其setLayoutOrientation(int orientation)方法设置其布局方向,其中方向值可以是在JList类中定义的三个常量之一:JList.VERTICALJList.HORIZONTAL_WRAPJList.VERTICAL_WRAP

您可以使用setSelectionMode(int mode)方法配置JList的选择模式。模式值可以是以下三个值之一。模式值在ListSelectionModel界面中被定义为常量。

  • SINGLE_SELECTION
  • SINGLE_INTERVAL_SELECTION
  • MUTIPLE_INTERVAL_SELECTION

在单一选择模式下,一次只能选择一个项目。如果您更改选择,以前选择的项目将被取消选择。

在单个间隔选择模式中,您可以选择多个项目。但是,选定的项目必须总是连续的。假设您在一个JList中有十个项目,并且您选择了第七个项目。现在,您可以选择列表中的第六个或第八个项目,但不能选择任何其他项目。您可以继续选择更多连续的项目。您可以使用Ctrl键或Shift键和鼠标的组合进行连续选择。

在多间隔部分,您可以不受任何限制地选择多个项目。您可以使用 Ctrl 键或 Shift 键和鼠标的组合来进行选择。

您可以在JList中添加一个列表选择监听器,当选择发生变化时,它会通知您。当选择改变时,调用ListSelectionListenervalueChanged()方法。在一次选择更改过程中,也可能会多次调用此方法。您需要使用ListSelectionEvent对象的getValueIsAdjusting()方法来确保选择更改已经完成,如下面的代码片段所示:

myJList.addListSelectionListener((ListSelectionEvent e) -> {

// Make sure selection change is final

if (!e.getValueIsAdjusting()) {

// The selection changed logic goes here

}

});

表 2-15 列出了JList类的常用方法。注意,JList没有一个直接的方法来给出列表的大小(?? 中选择的数量)。由于每个 Swing 组件都使用一个模型,所以JList也是如此。它的模型是JListModel接口的一个实例。要知道一个JList的选择列表的大小,您需要调用它的模型的getSize()方法,就像这样:

int size = myJList.getModel().getSize();

表 2-15。

Commonly Used Methods of the JList Class

| 方法 | 描述 | | --- | --- | | `void clearSelection()` | 清除`JList`中的选择。 | | `void ensureIndexIsVisible(int index)` | 确保指定`index`处的项目可见。注意,要使不可见的项目可见,必须将`JList`添加到`JScrollPane`中。 | | `int getFirstVisibleIndex()` | 返回最小的可见索引。如果没有可见项目或列表为空,则返回–1。 | | `int getLastVisibleIndex()` | 返回最大的可见索引。如果没有可见项目或列表为空,则返回–1。 | | `int getMaxSelectionIndex()` | 返回最大的选定索引。如果没有选择,则返回–1。 | | `int getMinSelectionIndex()` | 返回最小的选定索引。如果没有选择,则返回–1。 | | `int getSelectedIndex()` | 返回最小的选定索引。如果`JList`选择模式为单选,则返回选中的索引。如果没有选择,则返回–1。 | | `int[] getSelectedIndices()` | 返回一个`int`数组中所有选定项目的索引。如果没有选择,数组将没有元素。 | | `E getSelectedValue()` | 返回第一个选定的项目。如果`JList`为单选模式,则为所选项的值。如果在`JList`中没有选择,则返回`null`。 | | `List getSelectedValuesList()` | 根据列表中的索引以升序返回所有选定项目的列表。如果没有选定的项目,则返回一个空列表。 | | `boolean isSelectedIndex(int index)` | 如果选择了指定的`index`,则返回`true`。否则,它返回`false`。 | | `boolean isSelectionEmpty()` | 如果`JList`中没有选择,则返回`true`。否则,它返回`false`。 | | `void setListData(E[] listData)` `void setListData(Vector listData)` | 在`JList`中设置新的选择列表。 | | `void setSelectedIndex(int index)` | 在指定的`index`选择一个项目。 | | `void setSelectedIndices(int[] indices)` | 选择指定数组中索引处的项目 | | `void setSelectedValue(Object item, boolean shouldScroll)` | 选择列表中存在的指定项目。如果第二个参数是`true`,则滚动到该项使其可见。 |

JSpinner

一个JSpinner组件结合了一个JFormattedTextField和一个可编辑的JComboBox的优点。它允许您在一个JComboBox中设置一个选择列表,同时,您还可以对显示的值应用一种格式。它一次只显示选项列表中的一个值。它允许您输入新值。“spinner”这个名字来源于这样一个事实,它允许您通过使用上下箭头按钮来上下旋转选项列表。在JSpinner中,选择列表的一个特别之处是它必须是一个有序列表。图 2-14 显示了三个用于选择数字、日期和季节值的 JSpinners。

A978-1-4302-6662-4_2_Fig14_HTML.jpg

图 2-14。

JSpinner components in action

因为一个JSpinner为各种选择列表提供了旋转能力,所以它在很大程度上依赖于它的创建模型。事实上,您必须在其构造函数中为JSpinner提供一个模型,除非您想要一个只有整数列表的简单的JSpinner。它支持三种不同的有序选择列表:数字列表、日期列表和任何其他对象列表。它提供了三个类来创建三种不同列表的模型:

  • SpinnerNumberModel
  • SpinnerDateModel
  • SpinnerListModel

旋转器模型是接口的一个实例。它定义了使用JSpinner中的值的getValue()setValue()getPreviousValue()getNextValue()方法。所有这些方法都与Object类的对象一起工作。

这个类为一个JSpinner提供了一个模型,可以让你浏览一个有序的数字列表。您需要在列表中指定最小值、最大值和当前值。当您使用JSpinner的向上/向下按钮时,您还可以指定步进数值,用于步进数字列表。下面的代码片段创建了一个包含从 1 到 10 的数字列表的JSpinner。它让你一步一步地浏览列表。该字段的当前值设置为 5。SpinnerNumberModel类也有一些方法,可以让您在创建 spinner 模型后获取/设置不同的值。

int minValue = 1;

int maxValue = 10;

int currentValue = 5;

int steps = 1;

SpinnerNumberModel nModel = new SpinnerNumberModel(currentValue, minValue, maxValue, steps);

JSpinner numberSpinner = new JSpinner(nModel);

这个类为一个JSpinner提供了一个模型,可以让你浏览一个有序的日期列表。您需要指定开始日期、结束日期、当前值和步骤。下面的代码片段创建了一个JSpinner来一次一天地遍历从 1950 年 1 月 1 日到 2050 年 12 月 31 日的日期列表。当前系统日期被设置为字段的当前值。

Calendar calendar = Calendar.getInstance();

calendar.set(1950, 1, 1);

Date minValue = calendar.getTime();

calendar.set(2050, 12, 31);

Date maxValue = calendar.getTime();

Date currentValue = new Date();

int steps = Calendar.DAY_OF_MONTH; // Must be a Calendar field

SpinnerDateModel dModel = new SpinnerDateModel(currentValue, minValue, maxValue, steps);

dateSpinner = new JSpinner(dModel);

请注意,日期值将以默认的区域设置格式显示。当在模型上使用getNextValue()方法时,使用步长值。带有日期列表的JSpinner可让您通过突出显示日期字段的一部分并使用向上/向下按钮来浏览任何显示的日期字段。假设你的JSpinner使用的日期格式是mm/dd/yyyy。您可以将光标放在字段的年份部分(yyyy),并使用上/下按钮根据年份浏览列表。

这个类为一个JSpinner提供了一个模型,可以让你在一个有序的对象列表中旋转。您只需指定一个对象数组或一个List对象,JSpinner将让您在列表出现在数组或List中时旋转列表。列表中对象的toString()方法返回的String显示为JSpinner中的值。下面的代码片段创建了一个JSpinner来显示四个季节的列表:

String[] seasons = new String[] {"Spring", "Summer", "Fall", "Winter"};

SpinnerListModel sModel = new SpinnerListModel(seasons);

listSpinner = new JSpinner(sModel);

一个JSpinner使用一个编辑器对象来显示当前值。它有以下三个内部类来显示三种不同的有序列表:

  • JSpinner。数字编辑器
  • JSpinner.DateEditor
  • JSpinner。列表编辑器

如果您想以特定的格式显示数字或日期,您需要为JSpinner设置一个新的编辑器。数字和日期编辑器的编辑器类允许您指定格式。下面的代码片段将数字格式设置为“00”,因此数字 1 到 10 显示为01, 02, 03...10。它将日期格式设置为mm/dd/yyyy

// Set the number format to "00"

JSpinner.NumberEditor nEditor = new JSpinner.NumberEditor(numberSpinner, "00");

numberSpinner.setEditor(nEditor);

// Set the date format to mm/dd/yyyy

JSpinner.DateEditor dEditor = new JSpinner.DateEditor(dateSpinner, "mm/dd/yyyy");

dateSpinner.setEditor(dEditor);

Tip

您可以使用JSpinnerSpinnerModel定义的getValue()方法来获取JSpinner中的当前值作为Object. SpinnerNumberModel,而SpinnerDateModel定义分别返回NumberDate对象的getNumber()getDate()方法。

JScrollBar

如果您想要查看比可用空间更大的组件,您需要使用JScrollBarJScrollPane组件。我将在下一节讨论JScrollPane。一个JScrollBar有一个方向属性,决定它是水平显示还是垂直显示。图 2-15 描绘了一个水平JScrollBar

A978-1-4302-6662-4_2_Fig15_HTML.jpg

图 2-15。

A horizontal JScrollBar

一个JScrollBar由四部分组成:两个箭头按钮(每端一个)、一个旋钮(也称为拇指)和一个轨道。单击箭头按钮时,旋钮在轨道上向箭头按钮移动。你可以借助鼠标将旋钮向两端拖动。你也可以通过点击轨道来移动旋钮。

您可以定制一个JScrollBar的各种属性,方法是在构造函数中传递它们的值,或者在创建之后设置它们。表 2-16 列出了一些常用的属性和操作它们的方法。

表 2-16。

Commonly Used Properties of a JScrollBar and Methods to Get/Set Those Properties

| 财产 | 方法 | 描述 | | --- | --- | --- | | `Orientation` | `getOrientation()` `setOrientation()` | 确定`JScrollBar`是水平还是垂直。它的值可以是两个常量之一,`HORIZONTAL`或`VERTICAL`,它们在`JScrollBar`类中定义。 | | `Value` | `getValue()` `setValue()` | 旋钮的位置就是它的值。最初,它被设置为零。 | | `Extent` | `getVisibleAmount()` `setVisibleAmount()` | 这是旋钮的大小。它与轨道的大小成比例。例如,如果轨迹大小代表 150,而您将范围设置为 25,则旋钮大小将是轨迹大小的六分之一。其默认值为 10。 | | `Minimum Value` | `getMinimum()` `setMinimum()` | 它表示的最小值。默认值为零。 | | `Maximum Value` | `getMaximum()` `setMaximum()` | 它表示的最大值。默认值为 100。 |

以下代码片段演示了如何创建具有不同属性的JScrollBar:

// Create a JScrollBar with all default properties. Its orientation

// will be vertical, current value 0, extent 10, minimum 0, and maximum 100

JScrollBar sb1 = new JScrollBar();

// Create a horizontal JScrollBar with default values

JScrollBar sb2 = new JScrollBar(JScrollBar.HORIZONTAL);

// Create a horizontal JScrollBar with a current value of 50,

// extent 15, minimum 1 and maximum 150

JScrollBar sb3 = new JScrollBar(JScrollBar.HORIZONTAL, 50, 15, 1, 150);

JScrollBar的当前值只能设置在其最小值和(最大范围)值之间。一个JScrollBar本身不会给 GUI 增加任何价值。它只有一些属性。您可以将一个AdjustmentListener添加到一个JScrollBar中,当它的值改变时会得到通知。

// Add an AdjustmentListener to a JScrollBar named myScrollBar

myScrollBar.addAdjustmentListener((AdjustmentEvent e) -> {

if (!e.getValueIsAdjusting()) {

// The logic for value changed goes here

}

});

使用一个JScrollBar来滚动一个尺寸大于其显示区域的组件并不简单。如果你想单独使用一个JScrollBar,你需要写大量的代码来完成这个任务。一个JScrollPane让这个任务变得更容易。它负责滚动,无需编写任何额外的代码。

JScrollPane

一个JScrollPane是一个最多可以容纳和展示九个组件的容器,如图 2-16 所示。它使用自己的布局管理器,它是类JScrollPaneLayout的一个对象。

A978-1-4302-6662-4_2_Fig16_HTML.jpg

图 2-16。

The components of a JScrollPane

一个JScrollPane管理的九个组件是两个JScrollBar、一个视口、一个行标题、一个列标题和四个角。

  • 二:在图中,两个滚动条被命名为 HSB 和 VSB。它们是JScrollBar类的两个实例:一个水平的,一个垂直的。一个JScrollPane将为您创建和管理两个JScrollBar。您不需要为此编写任何代码。你唯一需要指出的是你是否想要它们,以及你希望它们何时出现。
  • 答:viewport 是一个区域,在这里一个JScrollPane显示可滚动的组件,比如一个JTextArea。您可以将视口视为窥视孔,通过使用滚动条向上/向下和向右/向左滚动来查看组件。视口是一个 Swing 组件。一个JViewport类的对象代表一个视窗组件。一个JViewport只是一个 Swing 组件的包装器,用来实现该组件的可滚动视图。JScrollPane为组件创建一个JViewport对象,并在内部使用。
  • 行和列标题:图中的行标题缩写为 RH。行/列标题是您可以在JScrollPane中使用的两个可选视窗。使用水平滚动条时,列标题会随之水平滚动。使用垂直滚动条时,行标题会随之垂直滚动。行/列标题的一个很好的用途是在视口中显示图片或绘图的水平和垂直标尺。通常,不使用行/列标题。
  • :一个JScrollPane中可以存在四个角。当两个组件垂直相交时,存在一个角。图中的四个角分别是 C1、C2、C3 和 C4。这些不是JScrollPane给角取的名字。为了便于讨论,我给它们取了一个名字。如果添加行标题和列标题,则存在角 C1。如果添加列标题并且垂直滚动条可见,则角 C2 存在。如果添加行标题并且水平滚动条可见,则角 C3 存在。如果水平滚动条和垂直滚动条都可见,则存在 C4 角。您可以添加任何 Swing 组件作为角组件。唯一的限制是不能在多个角上添加相同的组件。请注意,添加角组件并不能保证它是可见的。仅当拐角根据所讨论的规则存在时,拐角组件才会在拐角中可见。例如,如果您为 C4 角添加了一个角组件,那么只有当水平和垂直滚动条都可见时,它才可见。如果滚动条中的一个或两个都不可见,则角 C4 不存在,并且为该角添加的组件将不可见。

当组件尺寸大于JScrollPane尺寸时,需要一个方向(水平或垂直)的滚动条来查看视窗中的组件。一个JScrollPane让你为垂直和水平滚动条设置滚动条策略。滚动条策略是控制滚动条何时出现的规则。您可以设置以下三种滚动条策略之一:

  • :这意味着JScrollPane应该在需要的时候显示滚动条。当视口中某个方向(水平或垂直)的组件大于其显示区域时,需要滚动条。由JScrollPane决定何时需要滚动条,如果需要,它将使滚动条可见。否则,它会使滚动条不可见。
  • :这意味着JScrollPane应该总是显示滚动条。
  • :这意味着JScrollPane不应该显示滚动条。

滚动条策略由ScrollPaneConstants界面中的六个常量定义。三个常量用于垂直滚动条,三个用于水平滚动条。JScrollPane类实现了ScrollPaneConstants接口。所以您也可以使用JScrollPane类来访问这些常量。定义滚动条策略的常量是XXX_SCROLLBAR_AS_NEEDEDXXX_SCROLLBAR_ALWAYSXXX_SCROLLBAR_NEVER,其中您需要用VERTICALHORIZONTAL替换XXX,这取决于您所指的滚动条策略。垂直滚动条和水平滚动条的滚动条策略的默认值都是“按需显示”。下面的代码片段演示了如何用不同的选项创建一个JScrollPane:

// Create a JScrollPane with no component as its viewport and

// with default scrollbars policy as "As Needed"

JScrollPane sp1 = new JScrollPane();

// Create a JScrollPane with a JTextArea as its viewport and

// with default scrollbars policy as "As Needed"

JTextArea description = new JTextArea(10, 60);

JScrollPane sp2 = new JScrollPane(description);

// Create a JScrollPane with a JTextArea as its viewport and

// both scrollbars policy set to "show always"

JTextArea comments = new JTextArea(10, 60);

JScrollPane sp3 = new JScrollPane(comments,

JScrollPane.VERTICAL_SCROLLBAR_ALWAYS,                                  JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS);

如前所述,当您将组件添加到JScrollPane时,您将JScrollPane添加到容器,而不是组件。一个JScrollPane的视口保持对你添加到JScrollPane的组件的引用。通过查询其视窗,您可以在JScrollPane中获得组件的引用,如下所示:

// Get the reference to the viewport of the JScrollPane sp3

JViewport vp = sp3.getViewport();

// Get the reference to the comments JTextArea added

// to the JScrollPane, sp3, using its viewport reference

JTextArea comments1 = (JTextArea)vp.getView();

如果您创建一个JScrollPane而没有为其视口指定组件,您可以稍后使用其setViewportView()方法将组件添加到其视口,如下所示:

// Set a JTextPane as the viewport component for sp3

sp3.setViewportView(new JTextPane());

进程条

一个JProgressBar用于显示任务的进度。它有一个方向,可以是水平的也可以是垂直的。它有三个相关的值:当前值、最小值和最大值。您可以创建一个进度条,如下所示:

// Create a horizontal progress bar with current, minimum, and maximum values

// set to 0, 0, and 100, respectively.

JProgressBar hpBar1 = new JProgressBar();

// Create a horizontal progress bar with current, minimum, and maximum values

// set to 20, 20, and 200, respectively.

JProgressBar hpbar2 = new JProgressBar(SwingConstants.HORIZONTAL, 20, 200);

// Create a vertical progress bar with current, minimum, and maximum values

// set to 5, 5 and 50, respectively.

JProgressBar vpBar1 = new JProgressBar(SwingConstants.VERTICAL, 5, 50);

随着任务的进展,您需要使用进度条的setValue(int value)方法来设置进度条的当前值,以指示进度。组件将自动更新以反映新值。根据应用的外观和感觉,进度会有不同的反映。有时实心条用于显示进度,有时实心矩形用于显示进度。您可以使用getValue()方法来获取当前值。

您还可以使用setStringPainted()方法显示一个描述进度条当前值的字符串。向该方法传递true会显示字符串值,传递false不会显示字符串值。要绘制的字符串通过调用setString(String s)方法来指定。

有时任务进度的当前值是未知的或不确定的。在这种情况下,您不能设置进度条的当前值。相反,您可以向用户表明任务正在执行。您可以使用其setIndeterminate()方法在不确定模式下设置进度条。向该方法传递true会将进度条置于不确定的模式,传递false会将进度条置于确定的模式。一个JProgressBar组件显示一个动画来指示它的不确定状态。

图 2-17 显示了一个带有两个JProgressBarJFrame,水平JProgressBar处于确定模式,它显示一个字符串来描述进度。垂直JProgressBar已被置于不确定模式;请注意中间显示为动画的实心矩形条。

A978-1-4302-6662-4_2_Fig17_HTML.jpg

图 2-17。

JProgressBars in action

杰利德

一个JSlider可以让你通过沿轨道滑动旋钮从两个整数之间的一组值中选择一个值。它有四个重要的属性:方向、最小值、最大值和当前值。方向决定了它是水平显示还是垂直显示。您可以使用SwingConstants.VERTICALSwingConstants.HORIZONTAL作为其方向的有效值。以下代码片段创建了一个水平JSlider,最小值为 0,最大值为 10,当前值设置为 5:

JSlider points = new JSlider(0, 10, 5);

您可以使用getValue()方法获得JSlider的当前值。通常,用户通过左右滑动水平JSlider旋钮和上下滑动垂直JSlider旋钮来设置JSlider的当前值。您也可以通过使用它的setValue(int value)方法以编程方式设置它的值。

您可以在JSlider上显示次要和主要刻度。您需要设置这些刻度需要显示的间隔,并调用其方法来启用刻度画,如下所示:

points.setMinorTickSpacing(1);

points.setMajorTickSpacing(2);

points.setPaintTicks(true);

您也可以在JSlider中显示显示轨道值的标签。您可以显示标准标签或自定义标签。标准标签将沿着轨迹显示整数值。您可以调用它的setPaintLabels(true)方法来显示主要刻度间距处的整数值。图 2-18 显示了一个带有刻度和标准标签的JSlider

A978-1-4302-6662-4_2_Fig18_HTML.jpg

图 2-18。

A JSlider component with minimum = 0, maximum = 10, current value = 5, minor tick spacing = 1, major tick spacing = 2, tick painting enabled, and showing standard labels

JSlider还允许您设置自定义标签。使用JLabel组件显示JSlider上的标签。您需要创建一个带有value-label对的Hashtable,并使用它的setLabelTable()方法来设置标签。一个value-label副由一个Integer-JLabel副组成。下面的代码片段为值0设置标签Poor,为值5设置标签Average,为值10设置标签Excellent。设置标签表不会显示标签。您必须调用setPaintLabels(true)方法来显示它们。图 2-19 显示了由以下代码片段生成的带有自定义标签的JSlider:

// Create the value-label pairs in a Hashtable

Hashtable labelTable = new Hashtable();

labelTable.put(new Integer(0), new JLabel("Poor"));

labelTable.put(new Integer(5), new JLabel("Average"));

labelTable.put(new Integer(10), new JLabel("Excellent"));

// Set the labels for the JSlider and make them visible

points.setLabelTable(labelTable);

points.setPaintLabels(true);

A978-1-4302-6662-4_2_Fig19_HTML.jpg

图 2-19。

A JSlider with custom labels

JSeparator

当您想要在两个组件或两组组件之间添加分隔符时,JSeparator是一个方便的组件。通常,菜单中使用一个JSeparator来分隔相关菜单项的组。您可以通过指定方向来创建一个水平或垂直的JSeparator。您可以在任何需要使用 Swing 组件的地方使用它。

// Create a horizontal separator

JSeparator hs = new JSeparator(); // By default, the type is horizontal

// Create a vertical separator

JSeparator vs = new JSeparator(SwingConstants.VERTICAL);

一个JSeparator将自己扩展以填充布局管理器提供的尺寸。您可以使用setOrientation()getOrientation()方法来设置和获取JSeparator的方向。

菜单

菜单组件用于以紧凑的形式向用户提供动作列表。您还可以通过使用一组JButton来提供动作列表,其中每个JButton代表一个动作。使用一个菜单或一组JButton来呈现一个动作列表是一个偏好问题。然而,使用菜单有一个明显的优势;与一组JButton相比,它占用的屏幕空间要少得多。通过将一组选项折叠(或嵌套)在另一个选项下,菜单占用的空间更少。例如,如果您使用的是文件编辑器,那么NewOpenSavePrint等选项会嵌套在顶层File菜单选项下。用户需要点击File菜单来查看其下可用的选项列表。典型地,在一组JButton的情况下,所有的JButton对用户来说一直是可见的,并且用户很容易知道哪些动作是可用的。因此,当您决定使用菜单或JButton s 时,需要在空间和可用性之间进行权衡。

还有一种叫做 a 的菜单,它根本不占用屏幕上的任何空间。通常,它会在用户单击鼠标右键时显示。一旦用户做出选择或在显示的弹出菜单区域之外单击鼠标,它就会消失。这是一个超级紧凑的菜单组件。然而,这使得用户很难知道有任何选项可用。有时,屏幕上会显示一条文本消息,说明用户需要右键单击来查看可用选项的列表。JPopupMenu类的一个对象表示 Swing 中的一个弹出菜单。现在让我们看看菜单的作用。

创建菜单并将其添加到JFrame是一个多步骤的过程。以下步骤详细描述了该过程。

创建该类的一个对象,并使用其setJMenuBar()方法将其添加到一个JFrame中。一个JMenuBar是一个空容器,它将保存一个菜单选项列表,一个JMenuBar中的每个选项代表一个选项列表。

// Create a JMenuBar and set it to a JFrame

JMenuBar menuBar = new JMenuBar();

myFrame.setJMenuBar(menuBar);

此时,您有一个空的JMenuBar与一个JFrame相关联。现在,您需要向JMenuBar添加选项列表,也称为顶级菜单选项。类别的物件代表选项清单。一个JMenu也是一个空容器,可以保存代表选项的菜单项。您将需要添加一个JMenu菜单选项。JMenu并不总是显示添加到其中的选项。相反,它会在用户选择JMenu时显示它们。当你使用菜单时,这是你得到紧凑的地方。当您选择一个JMenu时,它会弹出一个窗口,显示其中包含的选项。一旦您从弹出窗口中选择一个选项或点击JMenu外的某处,弹出窗口就会消失。

// Create two JMenu (or two top-level menu options):

// File and Help, and add them to the JMenuBar

JMenu fileMenu = new JMenu("File");

JMenu helpMenu = new JMenu("Help");

menuBar.add(fileMenu);

menuBar.add(helpMenu);

此时,你的JFrame会在顶部区域显示一个菜单栏,有两个选项叫做FileHelp,如图 2-20 所示。如果您选择或点击FileHelp,此时不会有任何反应。

A978-1-4302-6662-4_2_Fig20_HTML.jpg

图 2-20。

A JMenuBar With Two JMenu Options

让我们给你的JMenu添加一些选项。您想在File下显示三个菜单选项,它们是NewOpenExit。您想要在OpenExit选项之间添加一个分隔符(一条水平线作为分隔符)。该类的一个对象代表了一个JMenu中的选项。

// Create menu items

JMenuItem newMenuItem = new JMenuItem("New");

JMenuItem openMenuItem = new JMenuItem("Open");

JMenuItem exitMenuItem = new JMenuItem("Exit");

// Add menu items and a separator to the menu

fileMenu.add(newMenuItem);

fileMenu.add(openMenuItem);

fileMenu.addSeparator();

fileMenu.add(exitMenuItem);

此时,您已经向File菜单添加了三个JMenuItem。当您点击File菜单时,会显示如图 2-21 所示的选项。您可以使用键盘上的向下/向上箭头键滚动浏览File菜单下的选项,或者使用鼠标选择其中一个选项。当您选择File菜单下的任何一个选项时,什么都不会发生,因为您没有给它们添加任何动作。

A978-1-4302-6662-4_2_Fig21_HTML.jpg

图 2-21。

A File JMenu with three options

你可能想在一个菜单项下有两个子选项,比如在New选项下。也就是说,用户可以创建两个不同的东西,PolicyClaim,并且您希望这两个选项在New选项下可用。您没有尝试在选项中嵌套选项。File菜单是JMenu类的一个实例,它代表一个选项列表,并且您想要添加一个New菜单,它也应该显示一个选项列表。你可以很容易地做到这一点。您唯一需要理解的是,JMenu代表一个选项列表,而JMenuItem只代表一个选项。您可以在JMenu上添加一个JMenuItemJMenu。为此,您需要对前面显示的代码片段做一点修改。现在New菜单将是JMenu类的一个实例,而不是JMenuItem类。您将向New菜单添加两个 JMenuItems。下面的代码片段将完成这项工作:

// New is a JMenu – a list of options

JMenu newMenu = new JMenu("New");

JMenuItem policyMenuItem = new JMenuItem("Policy");

JMenuItem claimMenuItem = new JMenuItem("Claim");

newMenu.add(policyMenuItem);

newMenu.add(claimMenuItem);

JMenuItem openMenuItem = new JMenuItem("Open");

JMenuItem exitMenuItem = new JMenuItem("Exit");

fileMenu.add(newMenu);

fileMenu.add(openMenuItem);

fileMenu.addSeparator();

fileMenu.add(exitMenuItem);

现在菜单显示如图 2-22 所示。当您选择File菜单时,New菜单旁边会显示一个箭头,表示它有子菜单。当您选择New菜单时,会显示两个标有PolicyClaim的子菜单。

A978-1-4302-6662-4_2_Fig22_HTML.jpg

图 2-22。

Nesting menus

一个菜单可以嵌套的层数没有限制。然而,两层以上的嵌套被认为不是好的 GUI 实践,因为用户将不得不向下钻取几层才能得到可用的选项。

让菜单工作的最后一步是向菜单项添加动作。您可以向JMenuItem添加动作监听器。当用户选择JMenuItem时,相关的动作监听器会得到通知。下面的代码片段向将退出应用的Exit菜单项添加了一个动作监听器:

// Add an action listener to the Exit menu item

exitMenuItem.addActionListener(e -> System.exit(0));

现在,您已经向 Exit 菜单项添加了一个操作。如果选择它,应用将退出。类似地,您可以向其他菜单项添加操作侦听器,以便在它们被选中时执行操作。

您可以使用setEnabled()方法启用/禁用菜单。尽管可以使菜单可见/不可见,但这样做并不是好的做法。这使得用户很难学习应用。如果您始终保持所有菜单选项可用(处于启用或禁用状态),用户将能够通过了解菜单选项的位置来更快地使用应用。如果你使菜单选项可见/不可见,菜单选项的位置会不断变化,用户每次想使用它们时都必须更加注意菜单选项的位置。

您也可以为菜单选项指定快捷方式。您可以使用setMnemonic()方法通过指定快捷键来添加菜单项的快捷方式。您可以通过按下Alt键和快捷键的组合来调用该菜单项所代表的动作。请注意,菜单项必须可见,其助记键才能起作用。例如,如果您为New菜单选项设置了助记符(N键),您必须选择File菜单,使New菜单选项可见,并按Alt + N调用由New菜单项表示的动作。

如果您想调用菜单项的相关动作,而不管它是否可见,您需要使用setAccelerator()方法来设置它的快捷键。以下代码片段将E键设置为助记键,将Ctrl + E设置为Exit菜单选项的快捷键:

// Set E as mnemonic for Exit menu and Ctrl + E as its accelerator

exitMenuItem.setMnemonic(KeyEvent.VK_E);

KeyStroke cntrlEKey = KeyStroke.getKeyStroke(KeyEvent.VK_E, ActionEvent.CTRL_MASK);

exitMenuItem.setAccelerator(cntrlEKey);

现在你可以通过两种方式调用Exit菜单选项:当Alt + E组合键可见时你可以按下它,或者你可以随时按下Ctrl + E组合键。

您可以使用弹出菜单,该菜单会根据需要显示。弹出菜单的创建类似于JMenu。您需要创建一个JPopupMenu类的实例,它代表一个空的弹出菜单容器,然后向其中添加JMenuItem的实例。您也可以在弹出菜单中包含嵌套菜单,就像在JMenu中一样。

// Create a popup menu

JPopupMenu popupMenu = new JPopupMenu();

// Create three menu items for our popup menu

JMenuItem popup1 = new JMenuItem("Poupup1");

JMenuItem popup2 = new JMenuItem("Poupup2");

JMenuItem popup3 = new JMenuItem("Poupup3");

// Add menu items to the popup menu

popupMenu.add(popup1);

popupMenu.add(popup2);

popupMenu.add(popup3);

由于弹出菜单没有固定的位置,并且是按需显示的,所以您需要知道在哪里以及何时显示它。您需要使用它的show()方法在某个位置显示它。show()方法有三个参数:其空间将用于显示弹出菜单的 invoker 组件,以及它将显示的 invoker 组件上的 x 和 y 坐标。

// Display the popup menu

popupMenu.show(myComponent, xPos, yPos);

通常,当用户单击鼠标右键时,会显示一个弹出菜单。不同的外观和感觉选项使用不同的按键事件来显示弹出菜单。例如,一个外观方案在释放鼠标右键时显示它,而另一个外观方案在按下鼠标右键时显示它。Swing 通过在MouseEvent类中提供一个isPopupTrigger()方法,让您可以轻松地显示弹出菜单。在鼠标按下或释放事件中,您需要调用此方法。如果该方法返回true,显示弹出菜单。以下代码片段将鼠标侦听器与组件相关联,并显示弹出菜单:

// Create a mouse listener

MouseListener ml = new MouseAdapter() {

@Override

public void mousePressed(MouseEvent e) {

if (e.isPopupTrigger()) {

popupMenu.show(e.getComponent(), e.getX(), e.getY());

}

}

@Override

public void mouseReleased(MouseEvent e) {

if (e.isPopupTrigger()) {

popupMenu.show(e.getComponent(), e.getX(), e.getY());

}

}

};

// Add a mouse listener to myComponent

myComponent.addMouseListener(ml);

每当用户右击myComponent,就会出现一个弹出菜单。注意,您需要在mousePressed()mouseReleased()方法中添加相同的代码。它由外观和感觉决定哪个事件应该显示弹出菜单。

清单 2-7 包含了一个显示如何使用菜单的完整程序。节目很长。它执行创建和添加菜单项以及向菜单项添加动作监听器的重复性工作。

清单 2-7。使用菜单和弹出菜单

// JMenuFrame.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JMenuBar;

import javax.swing.JMenu;

import javax.swing.JMenuItem;

import javax.swing.JLabel;

import java.awt.event.ActionListener;

import javax.swing.JTextArea;

import java.awt.BorderLayout;

import java.awt.event.KeyEvent;

import javax.swing.KeyStroke;

import javax.swing.JPopupMenu;

import java.awt.event.MouseAdapter;

import java.awt.event.MouseEvent;

import java.awt.event.MouseListener;

import javax.swing.JScrollPane;

public class JMenuFrame extends JFrame {

JLabel msgLabel = new JLabel("Right click to see popup menu");

JTextArea msgText = new JTextArea(10, 60);

JPopupMenu popupMenu = new JPopupMenu();

public JMenuFrame(String title) {

super(title);

initFrame();

}

// Initialize the JFrame and add components to it

private void initFrame() {

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

// Add the message label and text area

contentPane.add(new JScrollPane(msgText), BorderLayout.CENTER);

contentPane.add(msgLabel, BorderLayout.SOUTH);

// Set the menu bar for the frame

JMenuBar menuBar = getCustomMenuBar();

this.setJMenuBar(menuBar);

// Create a popup menu and add a mouse listener to show it

createPopupMenu();

}

private JMenuBar getCustomMenuBar() {

JMenuBar menuBar = new JMenuBar();

// Get the File and Help menus

JMenu fileMenu = getFileMenu();

JMenu helpMenu = getHelpMenu();

// Add the File and Help menus to the menu bar

menuBar.add(fileMenu);

menuBar.add(helpMenu);

return menuBar;

}

private JMenu getFileMenu() {

JMenu fileMenu = new JMenu("File");

// Set Alt-F as mnemonic for the File menu

fileMenu.setMnemonic(KeyEvent.VK_F);

// Prepare a New Menu item. It will have sub menus

JMenu newMenu = getNewMenu();

fileMenu.add(newMenu);

JMenuItem openMenuItem = new JMenuItem("Open", KeyEvent.VK_O);

JMenuItem exitMenuItem = new JMenuItem("Exit", KeyEvent.VK_E);

fileMenu.add(openMenuItem);

// You can add a JSeparator or just call the convenience method

// addSeparator() on fileMenu. You can replace the following statement

// with fileMenu.add(new JSeparator());

fileMenu.addSeparator();

fileMenu.add(exitMenuItem);

// Add an ActionListener to the Exit menu item

exitMenuItem.addActionListener(e -> System.exit(0));

return fileMenu;

}

private JMenu getNewMenu() {

// New menu will have two sub menus - Policy and Claim

JMenu newMenu = new JMenu("New");

// Add submenus to New menu

JMenuItem policyMenuItem = new JMenuItem("Policy", KeyEvent.VK_P);

JMenuItem claimMenuItem = new JMenuItem("Claim", KeyEvent.VK_C);

newMenu.add(policyMenuItem);

newMenu.add(claimMenuItem);

return newMenu;

}

private JMenu getHelpMenu() {

JMenu helpMenu = new JMenu("Help");

helpMenu.setMnemonic(KeyEvent.VK_H);

JMenuItem indexMenuItem = new JMenuItem("Index", KeyEvent.VK_I);

JMenuItem aboutMenuItem = new JMenuItem("About", KeyEvent.VK_A);

// Set F1 as the accelerator key for the Index menu item

KeyStroke f1Key = KeyStroke.getKeyStroke(KeyEvent.VK_F1, 0);

indexMenuItem.setAccelerator(f1Key);

helpMenu.add(indexMenuItem);

helpMenu.addSeparator();

helpMenu.add(aboutMenuItem);

// Add an action listener to the index menu item

indexMenuItem.addActionListener(e ->

msgText.append("You have selected Help >>Index menu item.\n"));

return helpMenu;

}

private void createPopupMenu() {

// Create a popup menu and add a mouse listener to the frame,

// so a popup menu is displayed when the user clicks a right mouse button

JMenuItem popup1 = new JMenuItem("Popup1");

JMenuItem popup2 = new JMenuItem("Popup2");

JMenuItem popup3 = new JMenuItem("Popup3");

// Create an action listener

ActionListener al =  e -> {

JMenuItem menuItem = (JMenuItem)e.getSource();

String menuText = menuItem.getText();

String msg = "You clicked " + menuText + " menu item.\n";

msgText.append(msg);

};

// Add the same action listener to all popup menu items

popup1.addActionListener(al);

popup2.addActionListener(al);

popup3.addActionListener(al);

// Add menu items to popup menu

popupMenu.add(popup1);

popupMenu.add(popup2);

popupMenu.add(popup3);

// Create a mouse listener to show a popup menu

MouseListener ml = new MouseAdapter() {

@Override

public void mousePressed(MouseEvent e) {

displayPopupMenu(e);

}

@Override

public void mouseReleased(MouseEvent e) {

displayPopupMenu(e);

}

};

// Add a mouse listener to the msg text and label

msgText.addMouseListener(ml);

msgLabel.addMouseListener(ml);

}

private void displayPopupMenu(MouseEvent e) {

// Make sure this mouse event is supposed to show the popup menu.

// Different platforms show the popup menu in different mouse events

if (e.isPopupTrigger()) {

this.popupMenu.show(e.getComponent(), e.getX(), e.getY());

}

}

// Display the CustomFrame

public static void main(String[] args) {

JMenuFrame frame = new JMenuFrame("JMenu and JPopupMenu Test");

frame.pack();

frame.setVisible(true);

}

}

您也可以使用JRadioButtonMenuItemJCheckBoxMenuItem作为菜单中的菜单项。顾名思义,它们显示为单选按钮和复选框,其工作原理与单选按钮和复选框相同。您可以向JMenu添加任何 swing 组件。要使用单选按钮类型的菜单项,您需要将多个JRadioButtonMenuItem组件分组到一个按钮组中,以便它们代表唯一的选择。要处理单选按钮选择的更改,您可以在JRadioButtonMenuItem中添加一个ActionListenerItemListener。要处理JCheckBoxMenuItem中的状态变化,您需要使用一个ItemListener

Tip

我将最终揭示 Swing 中菜单的秘密。Swing 中的菜单项是一个按钮。啊哈!你用按钮工作,称它们为菜单。是的,这是正确的。一个JMenuBar和一个JPopupMenu只是带有一个BoxLayout的容器。继续操作这些容器,设置它们的属性并向它们添加不同的 Swing 组件。一个JMenuItem是一个简单的按钮。一个JMenu是一个按钮,它有一个关联的容器,当你选择它时就会显示出来。

JToolBar

工具栏是一组按钮,在JFrame中为用户提供常用的操作。通常,您会提供一个工具栏和一个菜单。工具栏包含带有小图标的小按钮。通常,它只包含菜单中可用选项的子集。

JToolBar类的一个对象代表一个工具栏。它充当工具栏按钮的容器。它是一个比其他容器更智能的容器,比如一个JPanel。它可以在运行时移动。它可以是漂浮的。如果它是浮动的,它会显示一个手柄,您可以使用它来移动它。您也可以使用句柄在单独的窗口中弹出它。以下代码片段创建了一些工具栏组件:

// Create a horizontal JToolBar

JToolBar toolBar = new JToolBar();

// Create a horizontal JToolBar with a title. The title is

// displayed as a window title, when it floats in a separate window.

JToolBar toolBarWithTitle = new JToolBar("My ToolBar Title");

// Create a Vertical toolbar

JToolBar vToolBar = new JToolBar(JToolBar.VERTICAL);

让我们给工具栏添加一些按钮。工具栏中的按钮需要比普通按钮小。通过将边距设置为零,可以缩小JButton的尺寸。您还应该为每个工具栏按钮添加一个工具提示,为用户提供有关其用法的快速提示。

// Create a button for the toolbar

JButton newButton = new JButton("New");

// Set the margins to 0 to make the button smaller

newButton.setMargin(new Insets(0, 0, 0, 0));

// Set a tooltip for the button

newButton.setToolTipText("Add a new policy");

// Add the New button to the toolbar

toolBar.add(newButton);

通常,在工具栏按钮中只显示小图标。您可以使用JButton的另一个构造函数,它只接受一个Icon对象作为参数。最后,您需要向按钮添加动作监听器,就像您已经向其他 JButtons 添加的那样。当用户单击工具栏中的按钮时,会通知操作监听器,并执行指定的操作。

您可以使用它的setFloatable(boolean floatable)方法设置工具栏浮动/不浮动。默认情况下,工具栏是可浮动的。它的setRollover(boolean rollOver)方法可以让你指定是否只在鼠标停留在工具栏按钮上时才绘制它们的边框。

应该在BorderLayout的北、南、东或西区域添加一个工具栏,以便在不同的区域移动工具栏。清单 2-8 在一个JFrame中显示了一个JToolBar。图 2-23 显示了一个JFrame在其北部区域有一个工具栏。图 2-24 显示了工具栏浮动在独立窗口中的同一JFrame

清单 2-8。在 JFrame 中使用 JToolBar

// JToolBarFrame.java

package com.jdojo.swing;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.JToolBar;

import javax.swing.JButton;

import java.awt.Insets;

import java.awt.BorderLayout;

import javax.swing.JTextArea;

import javax.swing.JScrollPane;

public class JToolBarFrame extends JFrame {

JToolBar toolBar = new JToolBar("My JToolBar");

JTextArea msgText = new JTextArea(3, 45);

public JToolBarFrame(String title) {

super(title);

initFrame();

}

// Initialize the JFrame and add components to it

private void initFrame() {

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

prepareToolBar();

// Add the toolbar in the north and a JTextArea in the center

contentPane.add(toolBar, BorderLayout.NORTH);

contentPane.add(new JScrollPane(msgText), BorderLayout.CENTER);

msgText.append("Move the toolbar around using its" +

" handle at the left end");

}

private void prepareToolBar() {

Insets zeroInset = new Insets(0, 0, 0, 0);

JButton newButton = new JButton("New");

newButton.setMargin(zeroInset);

newButton.setToolTipText("Add a new policy");

JButton openButton = new JButton("Open");

openButton.setMargin(zeroInset);

openButton.setToolTipText("Open a policy");

JButton exitButton = new JButton("Exit");

exitButton.setMargin(zeroInset);

exitButton.setToolTipText("Exit the application");

// Add an action listener to the Exit toolbar button

exitButton.addActionListener(e -> System.exit(0));

toolBar.add(newButton);

toolBar.add(openButton);

toolBar.addSeparator();

toolBar.add(exitButton);

toolBar.setRollover(true);

}

// Display the frame

public static void main(String[] args) {

JToolBarFrame frame = new JToolBarFrame("JToolBar Test");

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_2_Fig24_HTML.jpg

图 2-24。

A JToolBar floating in a separate window

A978-1-4302-6662-4_2_Fig23_HTML.jpg

图 2-23。

A JToolBar with three JButtons placed in the north region of a JFrame

JToolBar 符合动作接口

三个组件:JButtonJMenuItemJToolBar中的一个项目有什么共同点?它们都代表一个动作。有时你给用户同样的选项,作为一个菜单项,作为一个工具栏项,作为一个JButton。如何禁用您使用三个组件提供的选项?难道您不认为您需要至少在三个地方分别禁用它们,因为它们是代表同一选项的三个不同的组件吗?你可能是对的。但是,在 Swing 中有一种更简单的方法来处理这种情况。每当你必须以不同的方式为一个动作提供选项时,你应该使用Action接口。您需要将选项的逻辑和属性包装在一个Action对象中,并使用该对象构建工具栏中的JButtonJMenuItem和项目。如果需要禁用选项,只需在Action对象上调用setEnabled(false)一次,所有选项都会被禁用。在这种情况下,使用一个Action对象会让你的编程生活更容易。让我们看看它的实际效果。让我们创建一个继承自AbstractAction类的ExitAction类。它的actionPerformed()方法简单地退出应用。您使用它的putValue()方法在它的构造函数中设置一些属性,如下所示:

public class ExitAction extends AbstractAction {

public ExitAction(String action) {

super(action);

// Set tooltip text for the toolbar

this.putValue(SHORT_DESCRIPTION, "Exit the application");

// Set a mnemonic key

this.putValue(MNEMONIC_KEY, KeyEvent.VK_E);

}

@Override

public void actionPerformed(ActionEvent e) {

System.exit(0);

}

}

如果你想添加一个Exit菜单项、一个JButton和一个工具栏按钮,你可以先创建一个ExitAction类的对象,然后用它来创建你所有的选项项,如下所示:

ExitAction exitAction = new ExitAction("Exit");

JButton exitButton = new JButton(ExitAction);

JMenuItem exitMenuItem = new JMenuItem(exitAction);

JButton exitToolBarButton = new JButton(exitAction);

exitToolBarButton.setMargin(new Insets(0,0,0,0));

现在你可以将exitButton添加到你的JFrame,将exitMenuItem添加到你的菜单,将exitToolBarButton添加到你的工具栏。它们的行为都一样,因为它们共享同一个exitAction对象。如果您想在所有三个地方禁用退出选项,只需调用一次exitAction.setEnabled(false)即可。

组建

Swing 允许您使用JTable组件以表格形式显示和编辑数据。一个JTable使用行和列显示数据。您可以设置列标题的标签。您还可以在运行时对表中的数据进行排序。使用JTable可以简单到写几行代码,也可以复杂到写几百行代码。一个JTable是一个复杂而强大的 Swing 组件,它本身就值得一章。本节解释了使用JTable的基本知识,并为您提供了一些关于其强大功能的提示。一个JTable使用了许多其他的类和接口,这些都在javax.swing.table包中。JTable类本身在javax.swing包中。

先说最简单的JTable例子。您可以通过使用它的无参数构造函数来创建一个JTable

JTable table = new JTable();

嗯,那很简单。但是,它的列、行和数据会怎么样呢?你得到的只是一张没有可视组件的空桌子。您将在一分钟内解决这些问题。

A JTable不存储数据。它只显示数据。它使用存储数据、列数和行数的模型。接口的一个实例代表了一个 ?? 的模型。DefaultTableModel类是TableModel接口的一个实现。当您使用JTable类的默认构造函数时,Java 会将DefaultTableModel类的一个实例设置为它的模型。如果要添加或删除列/行,必须使用其模型。你可以使用它的getModel()方法得到一个JTable的模型的引用。让我们在表格中添加两行和三列。

// Get the reference of the model of the table

DefaultTableModel tableModel = (DefaultTableModel)table.getModel();

// Set the number of rows to 2

tableModel.setRowCount(2);

// Set the number of columns to 3

tableModel.setColumnCount(3);

让我们为表格中的一个单元格设置值。您可以使用表格模型或表格的setValueAt(Object data, int row, int column)方法来设置其单元格中的值。您将设置“John Jacobs”作为第一行和第一列的值。请注意,第一行和第一列从 0 开始。

// Set the value at (0, 0) in the table's model

tableModel.setValueAt("John Jacobs", 0, 0);

// Set the value at (0, 0) in the table

// Works the same as setting the value using the table's model

table.setValueAt("John Jacobs", 0, 0);

如果您将表格添加到容器中,它将如图 2-25 所示。

A978-1-4302-6662-4_2_Fig25_HTML.jpg

图 2-25。

A JTable with two rows and three columns with default column header labels

确保将表格添加到一个JScrollPane中。请注意,您会得到两行三列。列标题的标签设置为 A、B 和 c。您可以双击任何单元格开始编辑单元格中的值。要获取单元格中包含的值,可以使用表格模型的getValueAt(int row, int column)方法或JTable。它返回一个Object。您还可以通过使用DefaultTableModel类的addColumn()addRow()方法向JTable添加更多的列或行。您可以使用 its model 类的removeRow(int row)方法从模型中删除一行,从而从。

您可以使用模型的setColumnIdentifiers()方法为列标题设置定制标签,如下所示:

// Store the column headers in an array

Object[] columnHeaderLabels = new Object[]{"Name", "DOB", "Gender"};

// Set the column headers for the table using its model

tableModel.setColumnIdentifiers(columnHeaderLabels);

使用自定义列标题,表格看起来如图 2-26 所示。

A978-1-4302-6662-4_2_Fig26_HTML.jpg

图 2-26。

A JTable with two rows, three columns, and custom column header labels

如果您希望列标题始终可见,您必须在JScrollPane中添加一个JTable。如果不将其添加到JScrollPane,当行数超过组件的可用高度时,列标题将不可见。您可以使用JTable的方法获取列标题组件并自己显示(例如,如果JTable在中心区域,则显示在BorderLayout的北部区域)。您可以通过单击某行来选择该行。默认情况下,JTable允许您选择多行。您可以使用JTablegetSelectedRow()方法获取第一个选定的行号,使用getselectedRows()方法获取所有选定行的行号。getSelectedRowCount()方法返回选中的行数。

你从最简单的JTable开始。然而,与所谓的最简单的JTable一起工作并不是一件容易的事情,但是现在你知道了与JTable一起工作的基本知识。

让我们通过使用另一个构造函数创建JTable来重复这个例子。JTable类有另一个构造函数,它接受行数和列数作为参数。您可以创建一个两行三列的JTable,如下所示:

// Create a JTable with 2 rows and 3 columns

JTable table = new JTable(2, 3);

如果要将第一行和第一列的值设置为“John Jacobs”,则不需要使用表的模型。你可以使用JTablesetValueAt()方法来做同样的事情。

table.setValueAt("John Jacobs", 0, 0);

这一个比上一个稍微容易一点。然而,您仍然可以将默认的列标题标签设置为 A、B 和 c。JTable的另外两个构造函数允许您一次性设置行数和列数以及数据。它们的区别仅在于参数类型:一个让您使用一个数组Object,另一个让您使用一个Vector对象。它们声明如下:

  • JTable(Object[][] rowData, Object[] columnNames)
  • JTable(Vector rowData, Vector columnNames)

如果使用二维数组Object来设置行数据,数组的第一维数决定了行数。如果使用一个VectorVector中的元素数量决定了表格中的行数。Vector中的每个元素都应该是一个包含一行数据的Vector对象。下面是如何使用二维数组Object构造一个JTable。图 2-27 显示了显示代码中所有数据集的表格。

A978-1-4302-6662-4_2_Fig27_HTML.jpg

图 2-27。

A JTable with two rows, three columns, and data

// Prepare the column headers

Object[] columnNames = {"ID", "Name", "Gender" } ;

// Create a two-dimensioanl array to contain the table's data

Object[][] rowData = new Object[][] {

{new Integer(100), "John Jacobs", "Male" },

{new Integer(101), "Barbara Gentry", "Female"}

};

// Create a JTable with the data and the column headers

JTable table = new JTable(rowData, columnNames);

到目前为止,您的表的数据是硬编码的。JTable将所有数据视为String,表格中的所有单元格都是可编辑的。例如,您将 ID 列的值设置为整数,但它们仍然显示为左对齐的文本。数字在单元格中应该右对齐。如果你想定制一个JTable,你需要用你自己的模型来做桌子。回想一下,TableModel接口定义了一个JTable的模型。下面是该接口的声明:

public interface TableModel

public int getRowCount();

public int getColumnCount();

public String getColumnName(int columnIndex);

public Class<?> getColumnClass(int columnIndex);

public boolean isCellEditable(int rowIndex, int columnIndex);

public Object getValueAt(int rowIndex, int columnIndex);

public void setValueAt(Object aValue, int rowIndex, int columnIndex);

public void addTableModelListener(TableModelListener l);

public void removeTableModelListener(TableModelListener l);

}

该类实现了TableModel接口。它为TableModel接口的方法提供了一个空的实现。它没有提到数据应该如何存储。如果你想实现你自己的表格模型,你需要从AbstractTableModel类继承你的类。如果您在自定义表模型类中至少实现了以下三个方法,您将获得一个只读表模型:

  • public int getRowCount();
  • public int getColumnCount();
  • public Object getValueAt(int row, int column);

该类继承自AbstractTableModel类。它为接口中的所有方法提供了默认实现。它使用Vectors的一个Vector来存储表的数据。

如果您使用自己的表格模型,您可以更好地控制JTable的工作。清单 2-9 实现了一个简单的表格模型,使用数组的数组来存储数据。

清单 2-9。实现简单的表格模型

// SimpleTableModel.java

package com.jdojo.swing;

import javax.swing.table.AbstractTableModel;

public class SimpleTableModel extends AbstractTableModel {

private Object[][] data = {};

private String[] columnNames = {"ID", "Name", "Gender"};

private Class[] columnClass = {Integer.class, String.class, String.class};

private Object[][] rowData = new Object[][]{

{new Integer(100), "John Jacobs", "Male"},

{new Integer(101), "Barbara Gentry", "Female"}

};

public SimpleTableModel() {

}

@Override

public int getRowCount() {

return rowData.length;

}

@Override

public int getColumnCount() {

return columnNames.length;

}

@Override

public String getColumnName(int columnIndex) {

return columnNames[columnIndex];

}

@Override

public Class getColumnClass(int columnIndex) {

return columnClass[columnIndex];

}

@Override

public boolean isCellEditable(int rowIndex, int columnIndex) {

boolean isEditable = true;

if (columnIndex == 0) {

isEditable = false; // Make the ID column non-editable

}

return isEditable;

}

@Override

public Object getValueAt(int rowIndex, int columnIndex) {

return rowData[rowIndex][columnIndex];

}

@Override

public void setValueAt(Object aValue, int rowIndex, int columnIndex) {

rowData[rowIndex][columnIndex] = aValue;

}

}

在方法中,指定列数据的类;JTable将使用这个信息适当地显示列的数据。例如,它会将列中的数字显示为右对齐。如果您为一个列指定了类型Boolean,那么JTable将在该列的每个单元格中使用一个JCheckBox来显示Boolean值。注意,您已经通过从为 0 的columnIndexisEditable()方法返回false使 ID 列不可编辑。在本例中,您再次对表的数据进行了硬编码。但是,您可以从数据库、数据文件、网络或任何其他数据源读取数据。下面的代码片段使用定制模型来创建一个JTable:

// Use the SimpleTableModel as the model for the table

JTable table = new JTable(new SimpleTableModel());

请注意,您的表模型不允许添加和删除行/列。如果您想要这些扩展功能,您最好从DefaultTableModel类继承 model 类并定制您想要改变的行为。

您可以通过调用其方法setAutoCreateRowSorter(true)将数据排序功能添加到您的JTable中。通过单击列标题,可以对列中的数据进行排序。在您调用这个方法之后,JTable将显示一个向上/向下箭头作为列标题的一部分,以指示一个列是按升序还是降序排序的。您还可以使用一个行过滤器,根据某些标准隐藏JTable中的行,如下所示:

// Set a row sorter for the table

TableRowSorter sorter = new TableRowSorter(table.getModel());

table.setRowSorter(sorter);

// Set an ID filter for the table

RowFilter<SimpleTableModel, Integer> IDFilter = new RowFilter<SimpleTableModel, Integer> () {

@Override

public boolean include(Entry<? extends SimpleTableModel,                                    ? extends Integer> entry) {

SimpleTableModel model = entry.getModel();

int rowIndex = entry.getIdentifier().intValue();

Integer ID = (Integer) model.getValueAt(rowIndex, 0);

if (ID.intValue() <= 100) {

return false; // Do not show rows with an ID <= 100

}

return true;

}

};

sorter.setRowFilter(IDFilter);

上面的代码片段为一个名为tableJTable设置了一个过滤器,这样就不会显示 id 小于或等于 100 的行。RowFilter号是一艘abstract级;您必须覆盖它的include()方法来指定您的过滤标准。它还有几个返回不同种类的RowFilter对象的静态方法,您可以将这些方法直接用于RowSorter对象。以下是创建行过滤器的一些示例:

// Create a filter that will show only rows that starts

// with "John" in the second column (column index = 1)

RowFilter nameFilter = RowFilter.regexFilter("^John*", 1);

// Create a filter that will show only rows that has a

// "Female" value in its third column (column index = 2)

RowFilter genderFilter = RowFilter.regexFilter("^Female$", 2);

// Create a filter that will show only rows that has 3rd,

// 5th and 7th columns values starting with "A"

RowFilter anyFilter1 = RowFilter.regexFilter("^A*", 3, 5, 7);

// Create a filter that will show only rows that has any

// column whose value starts with "A"

RowFilter anyFilter2 = RowFilter.regexFilter("^A*");

您可以将一个TableModelListener添加到一个TableModel中,以监听对表格模型所做的任何更改。

Tip

由于篇幅限制,A JTable有许多特性无法在本节中描述。它还允许您设置一个自定义单元格,呈现为在单元格中显示一个值。例如,您可以在单元格中显示单选按钮,供用户选择,而不是让他们编辑纯文本值。

树形结构

A JTree用于以树状结构显示分层数据,如图 2-28 所示。你可以把一个JTree想象成颠倒显示一棵真实的树。

A978-1-4302-6662-4_2_Fig28_HTML.jpg

图 2-28。

A JTree showing departments and a list of employees in the departments

一个JTree中的每一项被称为一个节点。在图中,部门、销售、约翰等。是节点。节点被进一步分类为分支节点或叶节点。如果一个节点下可以有其他节点,称为其子节点,则称为分支节点。如果一个节点没有子节点,它被称为叶节点。部门、销售和信息技术是分支节点的示例,而 John、Elaine 和 Aarav 是叶节点的示例。现实世界的树中总有一个特殊的分支叫做根。类似地,JTree总是有一个特殊的分支节点,称为根节点。您的JTree有一个名为部门的根节点。在JTree中,您可以通过使用它的setRootVisible(boolean visibility)方法使根节点可见或不可见。

分支节点被称为其子节点的父节点。注意,子节点也可以是分支节点。“销售”、“信息技术”和“广告”是“部门”节点的子节点。销售节点有两个子节点:John 和 Elaine。John 和 Elaine 都有相同的父节点,即销售节点。

同一级别的节点称为兄弟节点。换句话说,具有相同父节点的节点称为兄弟节点。销售、信息技术和广告是兄弟姐妹;约翰和伊莱恩是兄弟姐妹;Tejas 和 Aarav 是兄弟姐妹。两个术语,祖先和后代,在节点的上下文中经常使用。作为父节点的父节点的父节点等等的节点都称为祖先节点。也就是说,从祖父开始的节点都是祖先节点。从孙开始向下的节点都称为后代。例如,Departments 节点是 Elaine 节点的祖先,而 Elaine 节点是 Departments 节点的后代。

你已经学了足够多与 a JTree相关的术语。是时候看看一个JTree在行动了。与JTree相关的类是在javax.swingjavax.swing.tree包中。一个JTree由节点组成。TreeNode接口的一个实例代表一个节点。TreeNode接口声明了提供节点基本信息的方法,比如节点类型(分支或叶子)、父节点、子节点等。

是扩展接口的接口。它声明了额外的方法,允许您通过插入/移除子节点或更改节点对象来更改节点。DefaultMutableTreeNode类是MutableTreeNode接口的一个实现。

在开始创建节点之前,您需要理解节点是 Java 对象的可视化表示(通常是一行文本)。换句话说,节点包装一个对象,通常显示该对象的单行文本表示。节点表示的对象称为该节点的用户对象。因此,在构建节点之前,必须有一个节点将表示的对象。不用担心创建新类来构建节点。您可以只使用一个String来构建您的节点。下面的代码片段创建了一些可以在JTree中使用的节点:

// Create a Departments node

DefaultMutableTreeNode root = new DefaultMutableTreeNode("Departments");

// Create a Sales node

DefaultMutableTreeNode sales = new DefaultMutableTreeNode("Sales");

// Create a John node

DefaultMutableTreeNode john = new DefaultMutableTreeNode("John");

// Create a customer node, assuming you have a Customer class.

// In this case, the node will wrap a Customer object

Customer cust101 = new Customer(101, "Joe");

DefaultMutableTreeNode c101Node = new DefaultMutableTreeNode(cust101);

// If you want to get the user object that a node wraps, you would

// use the getUserObject() method of the DefaultMutableTreeNode class

Customer c101Back = (Customer)c101Node.getUserObject();

一旦有了一个节点,使用add()insert()方法添加子节点就很容易了。add()方法将节点追加到末尾;insert()方法允许您指定新节点的位置。例如,添加一个Sales节点作为您编写的Departments根节点的子节点

root.add(sales);

要将John作为子节点添加到sales,您需要编写

sales.add(john);

一旦准备好了节点,就很容易将它们放入JTree中。您需要通过指定根节点来创建一个JTree

JTree tree = new JTree(root);

JTree类的其他构造函数允许你以不同的方式创建一个JTree。除非你正在学习JTree,否则无参数构造函数不是很有用。它创建了一个添加了一些节点的JTree,如果您想使用JTree进行实验,这可以省去添加节点的麻烦。您还可以通过将一个数组Object或一个ObjectVector传递给它的构造函数来创建一个JTree,作为JTree根的子节点。在添加传入的对象作为其子节点之前,一个根将被添加到新的JTree中。例如,

// Create a JTree. It will create a default root node called Root

// and it will add two, "One" and "Two", child nodes for Root.

// The Root node is not displayed by default.

JTree tree = new JTree(new Object[]{"One", "Two"});

一旦创建了JTree组件,就该在 Swing 容器中显示它了。通常,你给一个JScrollPane添加一个JTree,这样它就有了滚动能力。

myContainer.add(new JScrollPane(tree));

如何访问或浏览JTree节点?有两种方法可以访问JTree中的节点:使用行号和使用树路径。

一个JTree由节点组成。一个JTree如何显示节点?回想一下,节点是TreeNode类的一个实例,它包装任何类型的对象。因此,你可以说节点是对象的包装器。默认情况下,JTree调用节点对象的toString()方法来获取要显示的节点文本。如果您的节点包装了一个对象,该对象的方法没有返回要在JTree节点中显示的有意义的字符串,您可以通过创建一个自定义的JTree并覆盖其convertValueToText()方法来为该节点提供一个自定义字符串。在示例中,您已经将一个String对象包装在一个节点中,并且一个String对象的toString()方法返回字符串本身。假设您想要为Customer对象创建一个节点。确保覆盖Customer类的toString()方法,并返回一个有意义的字符串显示在Customer节点中,如客户名称和 id。

如果从上到下查看JTree节点,每个节点都显示在单独的水平行中。第一个节点(根节点,如果根节点可见)是第零行。第二个在第一行,依此类推。在图 2-28 中,部门、销售、John、Elaine 和信息技术的行号分别为 0、1、2、3 和 4。请注意,只有在显示节点时,才会将行号分配给该节点。当父节点折叠时,节点可能不会显示。例如,广告节点有一些未显示的子节点,并且没有为它们分配行号,因为广告节点(它们的父节点)已折叠。一个JTree的方法返回可视节点的数量。请注意,当您在JTree中展开和折叠节点时,可见节点的数量会发生变化。

一个TreePath类的对象在一个JTree中唯一地代表一个节点。它的结构类似于文件系统中用来表示文件的路径。文件路径通过指定从根文件夹开始的路径来唯一地表示文件,例如/Departments/Sales/John 表示名为 John 的文件,该文件位于根文件夹下的 Departments 文件夹下的 Sales 文件夹下。一个TreePath对象封装了相同类型的信息来表示一个JTree中的一个节点。它由从根开始的有序节点数组组成。例如,如果您需要为示例中的节点 John 构造一个TreePath对象,您可以按如下方式完成:

Object[] path = new Object[] {root, sales, john};

TreePath johnNodePath = new TreePath(path);

TreePath类的方法返回Object数组,getLastPathComponent()方法返回数组的最后一个元素,这是对节点的引用,TreePath对象表示节点的路径。通常,当你使用一个JTree时,你不会构造一个TreePath对象。相反,您可以在JTree事件中使用一个TreePath对象。如果您使用一个JTree,代表一个TreePath对象的数组对象的每个元素都是一个TreeNode的实例。如果您使用默认的树模型,TreePath将由一组对象组成。拥有一个到节点的TreePath,您可以得到节点包装的对象,如下所示:

// Suppose path is an instance of the TreePath class and it represents a node

DefaultMutableTreeNode node = (DefaultMutableTreeNode)path.getLastPathComponent();

Object myObject = node.getUserObject();

一个JTree提供了两个叫做getRowForPath()getPathForRow()的方法来将一个行号转换成一个TreePath,反之亦然。当你很快了解到JTree事件时,你将和TreePath一起工作。

如果您没有为一个JTree的事件编写代码,您将没有一个节点的(除非您存储了节点引用本身,这不是必需的)。在这种情况下,您可以始终从根节点开始,继续沿着树向下导航。一个JTree的模型是一个TreeModel类的实例,它有一个getRoot()方法。一旦获得了根节点的句柄,就可以使用TreeNode类的children()方法,该方法返回一个TreeNode的所有子节点的枚举。下面的代码片段定义了一个方法navigateTree(),如果您将根节点的引用传递给它,它将遍历所有树节点:

public void navigateTree(TreeNode node) {

if (node.isLeaf()) {

System.out.println("Got a leaf node: " + node);

return;

}

else {

System.out.println("Got a branch node: " + node);

Enumeration e = node.children();

while(e.hasMoreElements()) {

TreeNode n = (TreeNode)e.nextElement();

navigateTree(n); // Recursive method call

}

}

}

您可以通过单击来选择树节点。一个JTree使用一个选择模型来跟踪被选择的节点。您需要与其选择模型进行交互,以选择节点或获取关于所选节点的信息。选择模型是TreeSelectionModel接口的一个实例。一个JTree允许用户在三种不同的模式下选择节点。它们由接口中定义的三个常数表示:

  • SINGLE_TREE_SELECTION:允许用户一次只选择一个节点。
  • CONTIGUOUS_TREE_SELECTION:允许用户选择任意数量的相邻节点。
  • DISCONTIGUOUS_TREE_SELECTION:允许用户选择任意数量的节点,没有任何限制。

下面的代码片段演示了如何使用JTree的选择模型的一些方法:

// Get selection model for JTree

TreeSelectionModel selectionModel = tree.getSelectionModel();

// Set the selection mode to discontinuous

selectionModel.setSelectionMode(TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION);

// Get the selected number of nodes

int selectedCount = selectionModel.getSelectionCount();

// Get the TreePath of all selected nodes

TreePath[] selectedPaths = selectionModel.getSelectionPaths();

您可以将一个TreeSelectionListener添加到一个JTree中,当一个节点被选中或取消选中时,它会得到通知。以下代码片段演示了如何将添加到JTree:

// Create a JTree. Java will add some nodes

JTree tree = new JTree();

// Add selection listener to the JTree

tree.addTreeSelectionListener((TreeSelectionEvent event) -> {

TreeSelectionModel selectionModel = tree.getSelectionModel();

TreePath[] paths = event.getPaths();

for (TreePath path : paths) {

Object node = path.getLastPathComponent();

if (selectionModel.isPathSelected(path)) {

System.out.println("Selected: " + node);

}

else {

// Node is deselected

System.out.println("DeSelected: " + node);

}

}

});

您可以通过单击加号或单击节点本身来展开节点。您可以通过单击减号或单击节点本身来折叠节点。当一个节点展开或折叠时,JTree触发两个事件。它按顺序触发树扩展事件和树扩展事件。tree-will-expand 事件在展开或折叠节点之前触发。如果你从这个事件抛出一个ExpandVetoException,展开(或者折叠)就会停止。否则,将触发树扩展事件。以下代码片段演示了如何为这些事件编写代码:

// Add a TreeWillExpandListener

tree.addTreeWillExpandListener(new TreeWillExpandListener() {

@Override

public void treeWillExpand(TreeExpansionEvent event)

throws ExpandVetoException {

System.out.println("Will Expand:" + event.getPath());

}

@Override

public void treeWillCollapse(TreeExpansionEvent event) throws ExpandVetoException {

System.out.println("Will Collapse: " + event.getPath());

}

});

// Add TreeExpansionListener

tree.addTreeExpansionListener(new TreeExpansionListener() {

@Override

public void treeExpanded(TreeExpansionEvent event) {

System.out.println("Exapanded: " + event.getPath());

}

@Override

public void treeCollapsed(TreeExpansionEvent event) {

System.out.println("Collapsed: " + event.getPath());

}

});

Tip

A JTree是一个强大而复杂的 Swing 组件。它可以让你定制几乎所有的东西。每个节点显示在一个JLabel中。分支和叶节点显示的图标不同。默认图标取决于外观。您可以通过创建自己的树单元渲染器来自定义默认图标。您还可以向JTree添加一个TreeModelListener,它会通知您其模型的任何变化。您可以通过使用setEditable(true)方法使JTree可编辑。您可以通过双击来编辑节点的标签。

JTabbedPane 和 JSplitPane

有时,由于空间限制,不可能在一个窗口中显示所有信息。您可以使用JTabbedPane对窗口中的信息进行分组和分离。图 2-29 显示了一个JFrame,它有一个带有两个标签的窗格,标题分别为General InformationContacts,显示一个人的一般信息和联系信息。

A978-1-4302-6662-4_2_Fig29_HTML.jpg

图 2-29。

A JTabbedPane with two tabs

一个JTabbedPane组件充当其他 Swing 组件的容器,以选项卡的方式排列它们。它可以使用标题、图标或两者来显示选项卡。用户需要点击标签来查看标签的内容。使用JTabbedPane最大的好处就是空间共享。一次只能看到JTabbedPane中一个标签的内容。用户可以在选项卡之间切换,以查看另一个选项卡的内容。

一个JTabbedPane也可以让你指定在哪里显示标签。您可以指定将选项卡放置在顶部、底部、左侧或右侧。图 2-29 显示了顶部的选项卡。如果您有一个名为frameJFrame,下面的代码片段会产生如图 2-29 所示的帧。代码向由两个JPanels表示的两个选项卡添加了一个JLabel

JPanel generalInfoPanel = new JPanel();

JPanel contactInfoPanel = new JPanel();

JTabbedPane tabbedPane = new JTabbedPane();

generalInfoPanel.add(new JLabel("General info components go here..."));

contactInfoPanel.add(new JLabel("Contact info components go here..."));

tabbedPane.addTab("General Information", generalInfoPanel);

tabbedPane.addTab("Contacts", contactInfoPanel);

frame.getContentPane().add(tabbedPane, BorderLayout.CENTER);

getTabCount()方法返回一个JTabbedPane中选项卡的数量。一个JTabbedPane中的每个标签都有一个索引。第一个选项卡的索引为 0,第二个选项卡的索引为 1,依此类推。您可以使用其索引来获取表示选项卡的组件。

// Get the reference of the component for the Contact tabs

JPanel contactsPanel = tabbedPane.getTabComponentAt(1);

JSplitPane是一个分割器,可以用来分割两个组件之间的空间。拆分条可以水平或垂直显示。当可用空间小于显示两个组件所需的空间时,用户可以上下或左右移动拆分条,这样一个组件比另一个组件获得更多的空间。如果有足够的空间,两个组件都可以完全显示。

JSplitPane类提供了许多构造函数。您可以使用它的默认构造函数创建它,并使用它的setXxxComponent(Component c)添加两个组件,其中Xxx可以是TopBottomLeftRight。它还允许您指定在更改拆分条的位置时组件的重绘方式。它可以是连续的或非连续的。如果它是连续的,当您移动拆分条时,组件将被重新绘制。如果它是不连续的,当您停止移动拆分条时,组件将被重新绘制。

下面的代码片段显示了添加到一个JSplitPane中的JPanel类的两个实例,该实例又被添加到一个名为frameJFrame的内容窗格中。图 2-30 显示了最终的JFrame

// Create two JPanels and a JSplitPane

JPanel generalInfoPanel = new JPanel();

JPanel contactInfoPanel = new JPanel();

JSplitPane splitPane = new JSplitPane();

generalInfoPanel.add(new JLabel("General info components go here..."));

contactInfoPanel.add(new JLabel("Contact info components go here..."));

// Add two JPanels to the JSplitPane and the JSplitPane

// to the content pane of the JFrame

splitPane.setLeftComponent(generalInfoPanel);

splitPane.setRightComponent(contactInfoPanel);

frame.getContentPane().add(splitPane, BorderLayout.CENTER);

A978-1-4302-6662-4_2_Fig30_HTML.jpg

图 2-30。

Using a JSplitPane to split space between two components

自定义对话框

一个JDialog是顶级的 Swing 容器。它被用作一个临时的顶层容器(或者一个弹出窗口)来帮助主窗口吸引用户的注意力。我不严格地使用窗口这个术语来表示一个 Swing 顶级容器。假设您有一个JFrame,您必须在其中显示一个人的信息。您可能没有足够的空间在JFrame中显示一个人的所有细节。在这种情况下,您只能在一个JFrame上显示基本的个人最低信息,并提供一个标记为“个人详细信息”的按钮。当用户点击这个按钮时,你可以打开一个JDialog,显示这个人的详细信息。这是一个使用JDialog向用户显示信息的例子。使用对话窗口的另一个例子是让用户从文件系统中选择一个文件。您可以向用户显示一个对话框,让他浏览文件系统并选择一个文件。您也可以在下列其他场合使用JDialog:

  • 当您想要确认用户的操作时:这称为。例如,当用户在窗口中选择一个人记录并试图删除该人记录时,您会显示一条确认消息“您确定要删除此人吗?”该对话框显示两个标记为“是”和“否”的按钮,以指示用户的选择。
  • 当您需要用户的一些输入时:这被称为。例如,当焦点移到日期字段时,您可能会在JDialog中显示一个日历,并希望用户选择一个日期。输入对话框可以简单到输入/选择一个值或输入多个值,例如一个人的详细信息。
  • 当您想要向用户显示一些消息时:这称为。例如,当用户将一些信息保存到数据库时,您想要用一条消息通知用户,该消息指示数据库事务的状态。

创建一个对话框非常简单:只需创建一个继承自JDialog类的新类。您可以将任意数量的 Swing 组件添加到您的定制JDialog中,就像您添加到JFrame中一样。一个JDialog让添加组件变得更容易。您不需要获取对其内容窗格的引用来设置其布局管理器和添加组件。相反,您可以调用JDialog本身的setLayout()add()方法。这些方法将调用路由到其内容窗格。默认情况下,JDialog使用BorderLayout作为布局管理器。

清单 2-10 列出了一个自定义的JDialog,它在一个JLabel和一个 OK JButton中显示当前的日期和时间。当用户点击JButton时,JDialog关闭。

清单 2-10。显示当前日期和时间的自定义 JDialog

// DateTimeDialog.java

package com.jdojo.swing;

import java.awt.BorderLayout;

import java.time.LocalDateTime;

import java.time.format.DateTimeFormatter;

import javax.swing.JButton;

import javax.swing.JDialog;

import javax.swing.JLabel;

public class DateTimeDialog extends JDialog {

JLabel dateTimeLabel = new JLabel("Datetime placeholder");

JButton okButton = new JButton("OK");

public DateTimeDialog() {

initFrame();

}

private void initFrame() {

// Release all resources when JDialog is closed

this.setDefaultCloseOperation(JDialog.DISPOSE_ON_CLOSE);

this.setTitle("Current Date and Time");

this.setModal(true);

String currentDateTimeString = getCurrentDateTimeString();

dateTimeLabel.setText(currentDateTimeString);

// There is no need to add components to the content pane.

// You can directly add them to the JDialog.

this.add(dateTimeLabel, BorderLayout.NORTH);

this.add(okButton, BorderLayout.SOUTH);

// Add an action listeenr to the OK button

okButton.addActionListener(e -> DateTimeDialog.this.dispose());

}

private String getCurrentDateTimeString() {

LocalDateTime ldt = LocalDateTime.now();

DateTimeFormatter formatter =

DateTimeFormatter.ofPattern("EEEE MMMM dd, yyyy hh:mm:ss a");

String dateString = ldt.format(formatter);

return dateString;

}

}

DateTimeDialog类是自定义JDialog的一个简单例子。要在您的应用中使用它,您需要创建这个JDialog的一个实例,打包它,并使它可见,如下所示:

DateTimeDialog dateTimeDialog = new DateTimeDialog();

dateTimeDialog.pack();

dateTimeDialog.setVisible(true);

如果您正在显示来自另一个顶级容器的JDialog,比如说一个JFrame或另一个JDialog,您可能希望将它显示在顶级容器的中央。有时你可能想把它显示在屏幕中央。你可以通过使用它的setLocationRelativeTo(Component c)方法将一个JDialog放在顶层容器或屏幕的中央。如果将null作为参数传递,那么JDialog将在屏幕上居中。否则,它将在作为参数传递的组件内居中。

// Center the JDialog within a frame, assuming that myFrame exists

dateTimeDialog.setLocationRelativeTo(myFrame);

// Place the JDialog in the center of screen

dateTimeDialog.setLocationRelativeTo(null);

您可以创建一个拥有者的JDialog,它可以是另一个JDialogJFrameJWindow。通过指定一个JDialog的所有者,您创建了一个父子关系。当JDialog的所有者(或父母)关闭时,JDialog也关闭。当拥有者被最小化或最大化时,JDialog也被最小化或最大化。带有所有者的JDialog总是显示在其所有者的上方。您可以在构造函数中指定一个JDialog的所有者。当您使用它的无参数构造函数创建一个JDialog时,一个隐藏的Frame被创建为它的所有者。注意是个java.awt.Frame,不是javax.swing.JFrameJFrame类继承自Frame类。您还可以创建一个以null为所有者的JDialog,在这种情况下,它没有所有者。

默认情况下,JDialog是可调整大小的。如果你不希望用户调整你的JDialog的大小,你可以通过调用它的setResizable(false)方法来实现。

根据JDialog的焦点行为,可以将其分类为

  • 情态的
  • 非模态的

当显示一个模态JDialog时,它会阻塞应用中其他显示的窗口。换句话说,如果显示了一个模态JDialog,您必须先关闭它,然后才能使用该应用中的任何其他窗口。要制作一个JDialog模态,可以使用它的setModal(true)方法。一些JDialog类的构造函数也让你指定JDialog应该是模态的还是非模态的。

非模态JDialog不会阻止应用中任何其他显示的窗口。您可以在其他窗口和非模态实例JDialog之间切换焦点。默认情况下,JDialog是无模式的。

您也可以为模态JDialog设置模态的范围。一个JDialog可以有四种模态中的一种。它们由java.awt.Dialog.ModalityType枚举中的四个常数定义:

  • MODELESS
  • DOCUMENT_MODAL
  • APPLICATION_MODAL
  • TOOLKIT_MODAL

您可以在其构造函数中或通过使用其方法来指定JDialog的模态类型。

MODELESS的设备类型意味着JDialog不会阻挡任何窗口。

DOCUMENT_MODAL的模态类型意味着JDialog将阻塞其父层次结构中的任何窗口(其所有者、所有者的所有者等等)。它不会阻塞其子层次结构中的任何窗口(其子、子的子等等)。假设你显示了三个窗口:frame是一个JFramedialog1是一只JDialog,主人是framedialog2是另一个JDialog,它的主人是dialog1。如果您为dialog1指定了DOCUMENT_MODAL的设备类型,您可以使用dialog2,但不能使用frame。如果dialog2的设备类型为MODELESS,您可以同时使用dialog1dialog2,因为dialog2不会阻挡任何窗口。

APPLICATION_MODAL的模态类型意味着JDialog将阻止该 Java 应用中的任何窗口,除了其子层次结构中的窗口。

TOOLKIT_MODAL的模态类型意味着JDialog将阻止从同一工具包运行的任何窗口,除了它的子层次结构中的窗口。在 Java 应用中,它与APPLICATION_MODAL相同。当您在使用 Java Web Start 启动的 Applet 或应用中使用它时,它非常有用。你可以把一个浏览器想象成一个应用,多个 Applet 想象成顶层窗口。所有 Applet 都由同一个工具包加载。如果在一个 Applet 中显示一个设备类型为TOOLKIT_MODALJDialog,它将阻止输入到同一浏览器中的任何其他 Applet。您必须授予“toolkitModalityAWTPermission以使 Applet 使用TOOLKIT_MODAL设备。用 Java Web Start 启动的多个应用也会出现同样的行为。

清单 2-11 包含了一个试验JDialog模态类型的程序。对dialog1Modalitydialog2Modality变量使用不同的值,看看它如何影响其他窗口中的阻塞输入。

清单 2-11。试验 JDialog 的通道类型

// JDialogModalityTest.java

package com.jdojo.swing;

import javax.swing.JButton;

import javax.swing.JDialog;

import javax.swing.JFrame;

import java.awt.Dialog.ModalityType;

public class JDialogModalityTest {

public static void main(String[] args) {

JFrame frame = new JFrame("My JFrame");

frame.setBounds(0, 0, 400, 400);

frame.setVisible(true);

final ModalityType dialog1Modality = ModalityType.DOCUMENT_MODAL;

final ModalityType dialog2Modality = ModalityType.DOCUMENT_MODAL;

final JDialog dailog1 = new JDialog(frame, "JDialog 1");

JButton openBtn = new JButton("Open JDialog 2");

openBtn.addActionListener(e -> {

JDialog d2 = new JDialog(dailog1, "JDialog 2");

d2.setBounds(200, 200, 200, 200);

d2.setModalityType(dialog2Modality);

d2.setVisible(true);

});

dailog1.add(openBtn);

dailog1.setBounds(20, 20, 200, 200);

dailog1.setModalityType(dialog1Modality);

dailog1.setVisible(true);

}

}

例如,在 Swing 应用中经常使用JDialog来向用户显示错误消息。每当你需要一个对话窗口时,创建一个自定义的JDialog是很费时间的。秋千的设计者意识到了这一点。他们给了我们JOptionPane类,让我们在使用常用的JDialog类型时更容易。我将在下一节讨论JOptionPane

标准对话框

JOptionPane类让你很容易创建和显示标准的模态对话框。它包含许多静态方法来创建不同种类的JDialog,用细节填充它们,并将它们显示为模态JDialog。当JDialog关闭时,该方法返回一个值来指示用户在JDialog上的动作。注意,JOptionPane类是从JComponent类继承而来的。除了被用作创建标准对话框的工厂之外,JOptionPane类与JDialog类没有任何关系。它还包含返回一个JDialog对象的方法,您可以在您的应用中定制和使用这个对象。您可以显示以下四种标准对话框:

  • 消息对话框
  • 确认对话框
  • 输入对话框
  • 选项对话框

显示标准JDialogJOptionPane类的静态方法的名字类似于showXxxDialog()Xxx可以替换为MessageConfirmInputOption。同样的方法还有另一个版本,叫做showInternalXxxDialog(),它使用一个JInternalFrame来显示对话框细节,而不是一个JDialog。所有四种类型的标准对话框都接受不同类型的参数,并返回不同类型的值。表 2-17 显示了这些方法的参数列表及其描述。

表 2-17。

List of Standard Argument Types and Their Values Used With JOptionPane

| 参数名称 | 参数类型 | 描述 | | --- | --- | --- | | `parentComponent` | `Component` | `JDialog`以指定的父组件为中心。包含该组件的顶层容器成为所显示的`JDialog`的所有者。如果是`null`,则`JDialog`在屏幕上居中。 | | `message` | `Object` | 通常,它是一个需要在对话框中显示为消息的字符串。但是,您可以传递任何对象。如果您传递一个 Swing 组件,它只是简单地显示在对话框中。如果你通过了一个`Icon`,它会显示在一个`JLabel`中。如果您传递任何其他对象,则在该对象上调用`toString()`方法,并显示返回的字符串。你也可以传递一个对象数组(通常是一个字符串数组),数组中的每个元素将一个接一个地垂直显示。 | | `messageType` | `Int` | 它表示您想要显示的消息类型。根据消息的类型,对话框中会显示合适的图标。可用的消息类型由`JOptionPane`类中的下列常量定义:`ERROR_MESSAGE,``INFORMATION_MESSAGE,WARNING_MESSAGE, QUESTION_MESSAGE,PLAIN_MESSAGE.``PLAIN_MESSAGE`类型不显示任何图标。另一个参数是`Icon`类型的,允许您指定自己的图标显示在对话框中。 | | `optionType` | `Int` | 它表示需要在对话框中显示的按钮。下面是在`JOptionPane`类中定义的常数列表,你可以用它来获得对话框中的标准按钮:`DEFAULT_OPTION, YES_NO_OPTION,``YES_NO_CANCEL_OPTION, OK_CANCEL_OPTION``DEFAULT_OPTION`显示一个`OK`按钮。其他选项显示一组按钮,顾名思义。您可以通过向`showOptionDialog()`方法提供`options`参数来定制按钮的数量及其文本。 | | `options` | `Object[]` | 此参数允许您自定义对话框中显示的一组按钮。如果您在数组中传递一个`Component`对象,该组件将显示在按钮行中。如果指定一个`Icon`对象,图标会显示在一个`JButton`中。对于您传递的任何其他类型的对象,将显示一个`JButton`,并且`JButton`的文本是从该对象的`toString()`方法返回的字符串。通常,您传递一个字符串数组作为此参数,以在对话框中显示一组自定义按钮。 | | `title` | `String` | 它是显示为对话框标题的文本。如果不传递此参数,将提供一个默认标题。 | | `initialValue` | `Object` | 该参数用于输入对话框。它表示输入对话框中显示的初始值。 |

通常,当用户关闭对话框时,您希望检查用户使用了什么按钮来关闭对话框。但是也有例外,当对话框只有一个按钮时,比如一个 OK 按钮。在这种情况下,要么您用来显示对话框的方法不返回值,要么您干脆忽略返回值。以下是可用于检查返回值是否相等的常数列表:

  • 确定选项
  • 是 _ 选项
  • 无选项
  • 取消选项
  • 关闭选项

CLOSED_OPTION表示用户使用标题栏上的关闭(X)菜单按钮或使用其他方式(如在 Windows 平台上按下键盘上的 Ctrl + F4 键)关闭了对话框。其他常数表示对话框上正常的按钮用法;例如,OK_OPTION表示用户点击对话框上的 OK 按钮关闭对话框。

JOptionPane还可让您自定义它所显示的按钮的标签。您也不局限于标准的按钮集。也就是说,您可以在对话框中显示任意数量的按钮。在这种情况下,用于显示对话框的JOptionPane方法将为第一次按钮点击返回 0,为第二次按钮点击返回 1,为第三次按钮点击返回 2,依此类推。当稍后讨论JOptionPane类的showOptionDialog()方法时,您将看到这种类型的一个例子。

您可以通过使用JOptionPane类的showMessageDialog()静态方法之一来显示一个消息对话框。消息对话框总是用一个按钮向用户显示某种信息,通常是 OK 按钮。该方法不返回任何值,因为用户所能做的只是单击“确定”按钮关闭对话框。showMessageDialog()方法的签名如下所示:

  • showMessageDialog(Component parentComponent, Object message)
  • showMessageDialog(Component parentComponent, Object message, String title, int messageType)
  • showMessageDialog(Component parentComponent, Object message, String title, int messageType, Icon icon)

下面的代码片段显示了一个消息对话框,如图 2-31 所示。

// Show an information message dialog

JOptionPane.showMessageDialog(null, "JOptionPane is cool!", "FYI",        JOptionPane.INFORMATION_MESSAGE);

A978-1-4302-6662-4_2_Fig31_HTML.jpg

图 2-31。

An information message dialog using the JOptionPane .showMessageDialog() method

您可以使用方法显示确认对话框。当您使用此方法时,您对知道用户的响应感兴趣,这由方法的返回值指示。以下代码片段显示一个确认对话框,如图 2-32 所示,并处理用户的响应:

// Show a confirmation dialog box

int response = JOptionPane.showConfirmDialog(null,

"Are you sure you want to save the changes?",

"Confirm Save Changes",

JOptionPane.YES_NO_CANCEL_OPTION,

JOptionPane.QUESTION_MESSAGE);

switch (response) {

case JOptionPane.YES_OPTION:

System.out.println("You chose yes");

break;

case JOptionPane.NO_OPTION:

System.out.println("You chose no");

break;

case JOptionPane.CANCEL_OPTION:

System.out.println("You chose cancel");

break;

case JOptionPane.CLOSED_OPTION:

System.out.println("You closed the dialog box.");

break;

default:

System.out.println("I do not know what you did ");

}

A978-1-4302-6662-4_2_Fig32_HTML.jpg

图 2-32。

A confirmation dialog box using the JOptionPane.showConfirmDialog() method

您可以使用方法要求用户输入。您可以为用户输入指定初始值。如果希望用户从列表中选择一个值,可以传递包含该列表的对象数组。UI 将在合适的组件中显示列表,如JComboBoxJList。下面的代码片段显示了一个输入对话框,如图 2-33 所示。

// Ask the user to enter some text about JOptionPane

String response = JOptionPane.showInputDialog("Please enter your opinion about input dialog.");

if (response == null) {

System.out.println("You have cancelled the input dialog.");

}

else {

System.out.println("You entered: " + response);

}

A978-1-4302-6662-4_2_Fig33_HTML.jpg

图 2-33。

A simple input dialog

您使用的showInputDialog()方法版本返回一个String,它是用户在输入字段中输入的文本。如果用户取消输入对话框,它返回null

下面的代码片段显示了一个带有选项列表的输入对话框。用户可以从列表中选择一个选项。对话框如图 2-34 所示该版本的方法返回一个Object,而不是一个String

// Show an input dialog that shows the user three options: "Cool!", "Sucks", "Don't know".

// The default selected value is "Don't know".

JComponent parentComponent = null;

Object message = "Please select your opinion about JOptionPane";

String title = "JOptionPane Input Dialog";

int messageType = JOptionPane.INFORMATION_MESSAGE;

Icon icon = null;

Object[] selectionValues = new String[] {"Cool!", "Sucks", "Don't know"};

Object initialSelectionValue = selectionValues[2];

Object response = JOptionPane.showInputDialog(parentComponent, message,                                                                                      title, messageType, icon, selectionValues, initialSelectionValue);

if (response == null) {

System.out.println("You have cancelled the input dialog.");

}

else {

System.out.println("You entered: " + response);

}

A978-1-4302-6662-4_2_Fig34_HTML.jpg

图 2-34。

An input dialog with a list of choices

最后,您可以使用如下声明的方法自定义选项按钮:

int showOptionDialog(Component parentComponent, Object message, String title, int optionType, int messageType, Icon icon, Object[] options, Object initialValue)

options参数指定用户的选项。如果在options参数中传递组件,组件显示为选项。如果您传递任何其他对象,比如字符串,那么会为options数组中的每个元素显示一个按钮。

下面的代码片段显示了如何在对话框中显示自定义按钮。它询问用户对某个JOptionPane的看法。出现的对话框如图 2-35 所示。

JComponent parentComponent = null;

Object message = "How is JOptionPane?";

String title = "JOptionPane Option Dialog";

int messageType = JOptionPane.INFORMATION_MESSAGE;

Icon icon = null;

Object[] options = new String[] {"Cool!", "Sucks", "Don't know" };

Object initialOption = options[2];

int response = JOptionPane.showOptionDialog(null, message, title,

JOptionPane.DEFAULT_OPTION,

JOptionPane.QUESTION_MESSAGE,

icon, options, initialOption);

switch(response) {

case 0:

case 1:

case 2:

System.out.println("You selected:" + options[response]);

break;

case JOptionPane.CLOSED_OPTION:

System.out.println("You closed the dialog box.");

break;

default:

System.out.println("I don't know what you did.");

}

A978-1-4302-6662-4_2_Fig35_HTML.jpg

图 2-35。

Customizing the Option buttons using the JOptionPane.showOptionDialog() method

默认情况下,您在本节中显示的所有对话框都是不可调整大小的。您想要自定义它们,以便它们可以调整大小。通过使用JOptionPanecreateDialog()方法并执行一系列步骤,你可以定制由JOptionPane的静态方法显示的对话框。

Create an object of JOptionPane.   Optionally, customize the properties of JOptionPane using its methods.   Use createDialog() method to get the reference of the dialog box.   Customize the dialog box.   Display the dialog box using its setVisible(true) method.

以下代码片段显示了如图 2-36 所示的自定义可调整大小对话框。

// Show a custom resizable dialog box using

JOptionPane pane = new JOptionPane("JOptionPane is cool!", JOptionPane.INFORMATION_MESSAGE);

String dialogTitle = "Resizable Custom Dialog Using JOptionPane";

JDialog dialog = pane.createDialog(dialogTitle);

dialog.setResizable(true);

dialog.setVisible(true);

A978-1-4302-6662-4_2_Fig36_HTML.jpg

图 2-36。

A custom dialog box using the JOptionPane.createDialog() method

文件和颜色选择器

Swing 有两个内置的 JDialogs,使得从文件系统中选择文件/目录或者以图形方式选择颜色变得更加容易。允许用户从文件系统中选择一个文件。它提供了非静态方法,不像你在JOptionPane中看到的那样,在JDialog中创建和显示文件选择器组件。

是一个 Swing 组件,允许您在JDialog中以图形方式选择颜色。它提供了一个静态方法,正如你在JOptionPane中看到的,它在JDialog中创建和显示了一个颜色选择器组件。

Tip

JFileChooser类提供了创建和显示 JDialogs 的非静态方法,而JColorChooser类提供了用于相同目的的静态方法。拥有静态或非静态方法意味着非静态方法允许您定制JDialog,而静态方法只允许您通过参数定制JDialog。这意味着您可以定制由JFileChooser显示的JDialog,但不能定制JColorChooser。另一个区别是,您必须创建一个JFileChooser类的对象来使用它。最好重用同一个JFileChooser对象,因为它记得最后访问的目录,所以当你重用它时,默认情况下它会将你导航到最后访问的目录。

对话框

以下是在JDialog中显示文件选择器需要执行的步骤。

Create an object of the JFileChooser class.   Optionally, customize its properties using its methods. You can customize properties such as should it let the user choose only files, only directories, or both; should it let the user select multiple files; apply a file filter criteria to show files based on your criteria, etc.   Use one of the three non-static methods, showOpenDialog(), showSaveDialog(), or showDialog(), to display it in a JDialog.   Check for the return value, which is an int, from the method call in the previous step. If it returns JFileChooser.APPROVE_OPTION, the user made a selection. The other two possible return values are JFileChooser.CANCEL_OPTION and JFileChooser.ERROR_OPTION, which indicate that either user cancelled the dialog box or some kind of error occurred. To get the selected file, call the getSelectedFile() or getSelectedFiles() method, which returns a File object and a File array, respectively. Note that a JFileChooser component only lets you select a file from a file system. It does not save or read a file. You can do whatever you like with the file reference returned from it.   You can reuse the file chooser object. It remembers the last visited folder.

默认情况下,JFileChooser开始显示用户默认目录中的文件。您可以在其构造函数中或使用其方法来指定初始目录。

// Create a file chooser with the default initial directory

JFileChooser fileChooser = new JFileChooser();

// Create a file chooser, with an initial directory of C:\myjava.

// You can specify a directory path according to your operating system syntax.

// C:\myjava is using Windows file path syntax.

JFileChooser fileChooser = new JFileChooser("C:\\myjava");

默认情况下,文件选择器只允许选择文件。让我们自定义它,以便您可以选择一个文件或目录。它还应该允许多重选择。以下代码片段完成了这一定制:

// Let the user select files and directories

fileChooser.setFileSelectionMode(JFileChooser.FILES_AND_DIRECTORIES);

// Aloow multiple selection

fileChooser.setMultiSelectionEnabled(true);

让我们显示一个打开的文件选择器对话框,并检查用户是否选择了一个文件。如果用户做出选择,在标准输出中打印文件路径。以下代码片段显示如图 2-37 所示的对话框。

// Display an open file chooser

int returnValue = fileChooser.showOpenDialog(null);

if(returnValue == JFileChooser.APPROVE_OPTION) {

File selectedFile = fileChooser.getSelectedFile();

System.out.println("You selected: " + selectedFile);

}

A978-1-4302-6662-4_2_Fig37_HTML.jpg

图 2-37。

An open file chooser dialog box using a JFileChooser

该类的所有三个方法都接受一个Component参数。它被用作它所显示的JDialog的所有者,并使对话框居中。将null作为其父组件,使其在屏幕上居中。

注意,在图 2-37 中,有两个按钮。一个被标记为Open,另一个Cancel.``Open按钮被称为批准按钮。对话框的标题是Open。当您使用JFileChooser的方法时,您会得到相同的对话框,除了按钮和标题的文本Open被替换为文本Save。您可以在显示对话框标题和批准按钮文本之前对其进行自定义,如下所示:

// Change the dialog's title

fileChooser.setDialogTitle("Open a picture file");

// Change the button's text

fileChooser.setApproveButtonText("Open File");

第三种方法showDialog(),让您指定批准按钮文本和对话框标题,如下所示:

// Open a file chooser with Attach as its title and approve button's text

int returnValue = fileChooser.showDialog(null, "Attach");

if (returnValue == JFileChooser.APPROVE_OPTION) {

File selectedFile = fileChooser.getSelectedFile();

System.out.println("Attaching file: " + selectedFile);

}

请注意,设置 approve 按钮的文本不会更改该方法的返回值。您仍然需要检查它是否返回了一个JFileChooser.APPROVE_OPTION,这样您就可以继续获取所选择的文件。

Tip

当您使用showOpenDialog()showSaveDialog()方法时,approve 按钮的默认文本取决于外观。在 Windows 上,它们分别是OpenSave

一个JFileChooser让你设置一个文件过滤器。文件过滤器是在对话框中显示文件之前应用的一组条件。文件过滤器是FileFilter类的一个对象,它在javax.swing.filechooser包中。FileFilter级是一个abstract级。要创建文件过滤器,您需要创建一个从FileFilter类继承而来的类,并覆盖accept()getDescription()方法。当文件选择器想要显示文件时,使用文件引用调用该方法。如果accept()方法返回true,则显示该文件。否则,不会显示该文件。下面的代码片段创建并设置了一个文件过滤器,只显示一个目录或一个扩展名为doc的文件。请记住,用户需要导航到文件系统,您必须显示目录。

// Create a file filter to show only a directory or .doc files

FileFilter filter = new FileFilter() {

@Override

public boolean accept(File f) {

if (f.isDirectory()) {

return true;

}

String fileName = f.getName().toLowerCase();

if (fileName.endsWith(".doc")) {

return true;

}

return false; // Reject any other files

}

@Override       public String getDescription() {

return "Word Document";

}

};

// Set the file filter

fileChooser.setFileFilter(filter);

int returnValue = fileChooser.showDialog(null, "Attach");

if (returnValue == JFileChooser.APPROVE_OPTION) {

// Process the file

}

基于文件扩展名设置文件过滤器是如此普遍,以至于从FileFilter类继承而来的FileNameExtensionFilter类直接支持它。它的构造函数接受文件扩展名及其描述。第二个参数是可变长度参数。请注意,文件扩展名是文件名中最后一个点之后的部分。如果文件名中没有点,则它没有扩展名。在你创建了一个FileNameExtensionFilter类的对象后,你需要调用文件选择器的方法来设置一个过滤器。下面的代码片段添加了“java”和“jav”作为文件扩展名过滤器。

FileNameExtensionFilter extFilter =

new FileNameExtensionFilter("Java Source File", "java", "jav");

fileChooser.addChoosableFileFilter(extFilter);

您可以在文件选择器中添加多个文件扩展名过滤器。它们作为文件类型显示在文件选择器下拉列表中。如果要限制用户只能选择您设置为文件过滤器的文件,您需要删除允许用户选择任何文件的文件过滤器,该过滤器称为“接受所有文件过滤器”。在 Windows 上,文件类型显示为“All Files(*.*)”。

// Disable "accept all files filter"

fileChooser.setAcceptAllFileFilterUsed(false);

您可以使用方法检查“接受所有文件过滤器”是否已启用,如果文件选择器正在使用此过滤器,该方法将返回true。您可以使用getAcceptAllFileFilter()方法获得“接受所有文件过滤器”的引用。下面的代码片段设置了“接受所有文件过滤器”(如果尚未设置)。

if (!fileChooser.isAcceptAllFileFilterUsed()) {        fileChooser.setAcceptAllFileFilterUsed(true); }

Tip

一个JFileChooser有许多你可以在应用中使用的特性。有时你可能想得到一个文件类型的相关图标。您可以通过使用文件选择器的getIcon(java.io.File file)方法获得文件类型的关联图标,该方法返回一个Icon对象。注意,您可以使用一个JLabel组件来显示一个Icon对象。当它显示在对话框中时,它还提供了一种机制来侦听用户执行的选择更改和其他操作。

颜色选择对话框

JColorChooser允许您使用对话框选择颜色。它是可定制的。可以向默认颜色选择器添加更多面板。也可以将颜色选择器组件嵌入到容器中。它提供了监听颜色选择器组件上的用户操作的方法。它的常见用法非常简单。您需要调用它的showDialog()静态方法,该方法将返回一个代表用户选择的颜色的java.awt.Color对象。否则返回null。我将在本章的后面介绍Color类。

showDialog()方法的签名如下。它允许您指定对话框的父组件和标题。您还可以设置初始颜色,它将显示在对话框中。

  • static Color showDialog(Component parentComponent, String title, Color initialColor)

下面的代码片段让用户使用JColorChooser选择一种颜色,并在标准输出上打印一条消息:

// Display a color chooser dialog

Color color = JColorChooser.showDialog(null, "Select a color", null);

// Check if user selected a color

if (color == null) {

System.out.println("You cancelled or closed the color chooser");

}

else {

System.out.println("You selected color: " + color);

}

窗户

JFrame一样,JWindow是另一个顶级容器。这是一座未经装饰的JFrame。它没有标题栏、窗口菜单等功能。它不是一个非常常用的顶级容器。您可以将它用作启动窗口,当应用启动时显示一次,几秒钟后自动消失。关于如何在 Java 应用中显示闪屏的更多细节,请参考java.awt.SplashScreen类的 API 文档。像JFrame一样,你可以给JWindow添加 Swing 组件。

使用颜色

java.awt.Color类的对象代表一种颜色。您可以使用 RGB(红色、绿色和蓝色)组件创建一个Color对象。RGB 值可以指定为floatint值。作为一个float值,RGB 中每个分量的范围从 0.0 到 1.0。作为一个int值,RGB 中每个分量的范围是从 0 到 255。还有一个叫做 alpha 的成分与颜色相关联。颜色的 alpha 值定义了颜色的透明度。作为一个float,其取值范围为 0.0 到 1.0,作为一个int,其取值范围为 0 到 255。alpha 值为 0.0 或 0 表示颜色完全透明,而值为 1.0 或 255 表示颜色完全不透明。

您可以创建一个Color对象,如下所示。注意构造函数Color(int red, int green, int blue)中 RGB 分量的值。

// Create red color

Color red = new Color(255, 0, 0);

// Create green color

Color green = new Color(0, 255, 0);

// Create blue color

Color blue = new Color(0, 0, 255);

// Create white color

Color white = new Color(255, 255, 255);

// Create black color

Color black = new Color(0, 0, 0);

alpha 分量被隐式设置为 1.0 或 255,这意味着如果不指定颜色的 alpha 分量,则该颜色是不透明的。以下代码片段通过将 alpha 组件指定为 0 来创建红色透明色:

// Create a transparent red color. The last argument of 0 is the alpha value.

Color transparentRed = new Color(255, 0, 0, 0);

Color类为常用的颜色定义了许多颜色常数。例如,您不需要创建红色。相反,你可以使用Color.redColor.RED常数。Color.red常量从 Java 1.0 开始就存在了。Java 1.4 中增加了相同常量Color.RED的大写版本,以遵循常量的命名约定(常量的名称应该是大写的)。同样,你还有Color.blackColor.BLACKColor.greenColor.GREENColor.darkGrayColor.DARK _ GRAY等。如果您有一个Color对象,您可以分别使用它的getRed()getGreen()getBlue()getAlpha()方法获得它的红色、绿色、蓝色和 alpha 组件。

还有另一种方法来指定颜色,那就是使用 HSB(色调、饱和度和亮度)组件。Color类有两个叫做RGBtoHSB()HSBtoRGB()的方法,可以让你从 RBG 模型转换到 HSB 模型,反之亦然。

一个Color对象与 Swing 组件的setBackground(Color c)setForeground(Color c)方法一起使用。所有 Swing 组件都从JComponent继承了这些方法。这些方法调用可能会被外观忽略。背景色是用来绘制组件的颜色,而前景色通常是组件中显示的文本的颜色。当你设置一个组件的背景颜色时,有一件重要的事情需要考虑,那就是透明度。如果组件是透明的,它不会在其边界内绘制像素。相反,它让容器的像素显示出来。为了让背景色生效,你必须通过调用组件的setOpaque(true)方法使其不透明。下面的代码创建了一个JLabel,并将其背景色设置为红色,前景色(或文本)设置为黑色:

JLabel testLabel = new JLabel("Color Test");

// First make the JLabel opaque. By default, a JLabel is transparent.

testLabel.setOpaque(true);

testLabel.setBackground(Color.RED);

testLabel.setForeground(Color.BLACK);

Tip

Color类的对象是不可变的。它没有任何方法可以让你在创建一个Color对象后设置颜色分量值。这使得共享Color对象成为可能。

使用边框

Swing 为您提供了在组件边缘绘制边框的能力。有不同种类的边界:

  • 斜角边框
  • 柔和的斜面边框
  • 蚀刻边框
  • 线条边框
  • 标题边框
  • 哑光边框
  • 空白边框
  • 复合边框

图 2-38 显示了不同种类的边框是如何使用窗口外观来显示的。

A978-1-4302-6662-4_2_Fig38_HTML.jpg

图 2-38。

Different types of borders

尽管您可以为任何 Swing 组件设置边框,但是 Swing 组件的实现可能会忽略它。使用带JPanel的标题边框来产生分组效果是很常见的。许多 GUI 工具都有一个分组框 GUI 组件来对相关组件进行分组。Java 没有分组框组件。如果你需要一个分组效果,你需要把你的相关组件放在一个JPanel里面,并给它设置一个标题边框。图 2-39 显示了一个JPanel,它有五个与地址相关的字段,带有一个标题设置为Address的标题边框。

A978-1-4302-6662-4_2_Fig39_HTML.jpg

图 2-39。

Creating a group box effect using a JPanel with a titled border

为 Swing 组件设置边框很容易:您需要创建一个 border 对象并使用组件的setBorder(Border b)方法。Border是一个由所有类实现的接口,这些类的实例代表一种特定的边界。每种边框都有一个类。你也可以通过从AbstractBorder类继承一个类来创建一个自定义边框。所有与边界相关的类和Border接口都在javax.swing.border包中。

对象是为共享而设计的。虽然您可以直接使用 border 类来创建一个 border 对象,但是建议您使用javax.swing.BorderFactory类来创建一个边框,以便可以共享这些边框对象。BorderFactory类负责边界对象的缓存和共享。你只需要使用它的createXxxBorder()方法来创建一个特定类型的边框,其中Xxx是一个边框类型。表 2-18 列出了所有边界类型的边界等级。

表 2-18。

Available Border Classes

| 边框类型 | 边界等级 | | --- | --- | | 斜角边框 | `BevelBorder` | | 柔和的斜面边框 | `SoftBevelBorder` | | 蚀刻边框 | `EtchedBorder` | | 线条边框 | `LineBorder` | | 标题边框 | `TitledBorder` | | 哑光边框 | `MatteBorder` | | 空白边框 | `EmptyBorder` | | 复合边框 | `CompoundBorder` |

以下代码片段创建了不同种类的边框:

// Create bevel borders

Border bevelRaisedBorder = BorderFactory.createBevelBorder(BevelBorder.RAISED);

Border bevelLoweredBorder = BorderFactory.createBevelBorder(BevelBorder.LOWERED);

// Create soft bevel borders

Border softBevelRaisedBorder = BorderFactory.createSoftBevelBorder(BevelBorder.RAISED);

Border softBevelLoweredBorder = BorderFactory.createSoftBevelBorder(BevelBorder.LOWERED);

// Create etched borders

Border etchedRaisedBorder = BorderFactory.createEtchedBorder(EtchedBorder.RAISED);

Border etchedLoweredBorder = BorderFactory.createEtchedBorder(EtchedBorder.LOWERED);

// Create line borders

Border lineBorder = BorderFactory.createLineBorder(Color.BLACK);

Border lineThickerBorder = BorderFactory.createLineBorder(Color.BLACK, 3);

// Create titled borders

Border titledBorderAtTop =

BorderFactory.createTitledBorder(etchedLoweredBorder,

"Title text goes here",

TitledBorder.CENTER,

TitledBorder.TOP);

Border titledBorderAtBottom =

BorderFactory.createTitledBorder(etchedLoweredBorder,

"Title text goes here",

TitledBorder.CENTER,

TitledBorder.BOTTOM);

// Create a matte border

Border matteBorder = BorderFactory.createMatteBorder(1,3,5,7, Color.BLUE);

// Create an empty border

Border emptyBorder = BorderFactory.createEmptyBorder();

// Create compound borders

Border twoCompoundBorder = BorderFactory.createCompoundBorder(etchedRaisedBorder, lineBorder);

Border threeCompoundBorder =

BorderFactory.createCompoundBorder(titledBorderAtTop, twoCompoundBorder);

您可以为组件设置边框,如下所示:

myComponent.setBorder(matteBorder);

斜角边框通过在边框的内侧和外侧边缘使用阴影和高光,为您提供三维效果。你可以提高或降低效果。柔和的斜面边框是具有柔和边角的斜面边框。

蚀刻的边框给你一种雕刻的效果。它有两种味道:抬高的和放低的。

线条边框只是画一条线。您可以指定线条的颜色和粗细。

您可以为任何边框类型提供标题。边框的标题是可以显示在边框中指定位置的文本,例如在顶部/底部边框的中间或顶部上方/底部下方。您还可以指定标题文本的对齐方式、颜色和字体。请注意,要使用标题边框,您必须有另一个边框对象。标题边框只是让你为另一种边框提供标题文本。

无光边框允许您用图标装饰边框。如果没有图标,可以指定边框的粗细。

空边框,顾名思义,不显示任何东西。你能猜到为什么你需要一个空的边框吗?边框增加了组件周围的空间。如果您只想在组件周围添加空格,可以使用空边框。空白边框允许您分别指定四条边的间距。

复合边框是一种复合边框,允许您将任意两种边框组合成一个 border 对象。嵌套的层数没有限制。您可以通过用前两个边框创建复合边框来组合三个边框,然后将复合边框与第三个边框组合来创建最终的复合边框。

使用字体

字体用于在视觉上表示文本,例如在计算机屏幕、打印纸或任何其他设备上。类的一个对象代表了 Java 程序中的一种字体。你已经在几乎每个程序中使用了Font对象,而没有直接引用Font类。Java 负责用特定的字体显示文本。例如,您一直在使用显示标签的按钮。为了显示按钮的标签,Java 一直使用默认字体。您可以使用Font对象为 Java 程序中显示的任何文本指定字体。在代码中使用Font对象很简单:创建一个Font类的对象,并使用组件的setFont(Font f)方法。在使用Font类之前,让我们定义术语“字体”和相关术语。

在计算机的内存中,一切都是用 0 和 1 表示的数字。所以一个字符在内存中也是用 0 和 1 来表示的。你如何在电脑屏幕或一张纸上表现一个字符?一个字符用一个符号显示在屏幕或纸上。代表字符的符号形状称为。您可以将字形视为字符的图形表示(或图像)。字符和字形之间的关系并不总是一对一的。

一组字符的字形的特定设计称为。注意,字样是字符(字形)的视觉表示的设计方面,它不指字形的特定实现。表 2-19 列出了一些字体类别及其描述和示例文本。如果在不支持所有字体的设备(如 Kindle)上查看,表格中的示例文本可能不会以相同的字体显示。有些字体的名称是泰晤士报、信使报、Helvetica、加拉蒙德等。

表 2-19。

Examples of Typefaces

| 字体 | 描述 | 示例文本 | | --- | --- | --- | | 衬线 | 字形在行尾有结束笔画。请注意衬线字体和无衬线字体中每个字符的结束笔画的区别。在 Windows 上,它被称为 Roman。例如:Times New Roman。 | 敏捷的棕色狐狸... | | 无衬线字体 | 与衬线不同,字形没有结束笔画。比较此类别和衬线的文本示例。你会发现无衬线字体是由普通线条组成的。在 Windows 上,它被称为瑞士。例如:Arial。 | 敏捷的棕色狐狸... | | 草书 | 它看起来像手写文本,其中一个单词中的后续字形通常是连接在一起的。它通常用于书法。在 Windows 上,它被称为脚本。例如:Mistral AV。 | 敏捷的棕色狐狸... | | 幻想 | 这是一种装饰字体。在窗户上,它被称为装饰。例如:影响。 | 敏捷的棕色狐狸... | | 单一间隔 | 代表所有字符的所有字形都具有相同的宽度。在 Windows 上,它被称为 Modern。通常,它用于计算机程序中。 | `The quick brown fox...` |

除了它的形状设计,一个角色的视觉表现还有另外两个组成部分:风格和大小。风格是指其特征,如粗体(黑色或浅色)、斜体和常规(或罗马体)。尺码是 10、12、14 等。字符的高度以磅为单位,其中一磅为 1/72 英寸。字符的宽度在中指定。间距决定了一英寸中可以显示多少个字符。螺距的典型值范围从 8 到 14。

现在让我们来定义术语“字体”字体是以特定字样、风格和大小表示一组字符的一组字形。您可以拥有使用相同字样的字体,但它们具有不同的样式和大小。这种字体(相同的字样,但不同的风格和大小)的集合被称为字体族。例如,Times 是一个字体系列名称,包含 Times Roman、Times Bold、Times Bold Italic 等字体。

根据存储和呈现的方式,字体可以分为位图字体或矢量字体(也称为面向对象字体或轮廓字体)。在位图字体中,每个字符都以特定样式和大小的位图形式(代表每一位)存储。当您需要在屏幕上呈现一个字符或在纸上打印它时,您需要找到该样式和大小的字符的位图并呈现它。在矢量字体中,几何算法定义每个字符的形状,而不涉及特定的大小。当需要以特定大小的矢量字体呈现字符时,该算法适用于该大小。这就是矢量字体也被称为可缩放字体的原因。TrueType 和 PostScript 是使用矢量字体的字体技术。所有 Java 实现都需要支持 TrueType 字体。

计算机上可用的字体数量可能会有很大差异。您的操作系统可能会安装一些字体,您可能会添加一些字体,或者您可能会删除一些字体。由于 Java 是为在各种操作系统上工作而设计的,它允许你使用一种字体的逻辑字体族名称,并且它会为你找出最佳的物理(真正的)字体。这样,您就不必担心实际的字体名称,也不必担心它们是否在所有执行您的程序的计算机上都可用。Java 定义了五种逻辑字体系列名称,并根据运行它的计算机将它们映射到物理字体系列名称。五种逻辑字体系列名称如下:

  • 衬线
  • 无锡里夫
  • 对话
  • 对话输入
  • 单一间隔

创建字体对象时,需要指定三个元素:逻辑系列名称、样式和大小。以下代码片段创建了一些Font对象:

// Create serif, plain font of size 10

Font f1 = new Font(Font.SERIF, Font.PLAIN, 10);

// Create SansSerif, bold font of size 10

Font f2 = new Font(Font.SANS_SERIF, Font.BOLD, 10);

// Create dialog, bold font of size 15

Font f3 = new Font(Font.DIALOG, Font.BOLD, 15);

// Create dialog input, bold and italic font of size 15

Font f4 = new Font(Font.DIALOG_INPUT, Font.BOLD|Font.ITALIC, 15);

Font类包含逻辑字体系列名称的常量。如果你想对一个字体对象应用多种样式,比如粗体和斜体,你需要像在Font.BOLD|Font.ITALIC中一样使用Font.BOLDFont.ITALIC的位掩码联合。

要为 Swing 组件设置字体,您需要使用该组件的方法,就像这样:

JButton closeButton = new JButton("Close");

closeButton.setFont(f4);

Font类有几个方法可以让你使用字体对象。例如,您可以使用getFamily()getStyle()getSize()方法分别获取字体对象的系列名称、样式和大小。

验证组件

组件可以是有效的,也可以是无效的。除非另有说明,本节中的短语“组件”也包括容器。您可以使用isValid()方法来检查组件是否有效。如果组件有效,该方法返回true。否则,它返回false。如果一个组件的大小和位置已经计算出来,并且它的子组件也是有效的,那么这个组件就是有效的。如果一个组件无效,这意味着它的大小和位置需要重新计算,并且需要在它的容器中重新布局。

向容器添加组件或从容器中移除组件时,容器会被标记为无效。在容器第一次可见之前,容器被验证。容器的验证过程计算其容器层次结构中所有子容器的大小和位置。考虑下面的代码片段来显示一个框架:

MyFrame frame = new MyFrame("Test Frame");

frame.pack();

frame.setVisible(true);

pack()方法做两件事:

  • 首先,它计算框架所有子框架的大小和位置(即验证框架)。
  • 第二,它调整框架的大小,使其子框架正好适合它。

代码中的setVisible()方法足够聪明,不会再次验证该帧,因为pack()方法已经验证了该帧。如果你不调用pack()方法,在调用setVisible()方法之前,setVisible()方法将验证框架。

因此,组件在第一次显示之前是有效的。组件是如何失效的?在容器中添加/删除组件会使容器无效。设置某些属性(如组件的大小)也会使该组件无效。当一个组件变得无效时,它的无效性会向上传播到容器层次结构。您还可以通过调用组件或容器的invalidate()方法来使其无效。注意,调用invalidate()方法将使组件无效,并且它将无效性传播到包含层次结构中。它需要将包含层次结构中的所有容器标记为无效的原因是,如果一个组件被再次布局(通过重新计算其大小/位置),它也会影响其他组件的大小/位置。因此,如果一个组件失效了,容器层次结构中的所有组件和容器也会被标记为无效。

如何再次验证组件?你需要使用组件或者容器的validate()方法。与invalidate()方法不同,validate()方法沿着容器层次结构向下传播,它验证调用它的组件的所有子组件/容器。您可能需要在调用validate()方法之后调用repaint()方法,以便重新绘制屏幕。

您也可以重新验证组件。请注意,重新验证选项仅适用于JComponent并且不适用于容器。您可以通过调用组件的方法来重新验证组件。它在父容器上安排一个validate()方法调用。验证组件的哪个父容器?是直系父母、祖父母还是曾祖父母等。?容器可以是验证根。您可以通过使用isValidateRoot()方法来测试一个容器是否是一个验证根。如果这个方法返回true,那么这个容器就是一个验证根。当你在一个组件上调用revalidate()方法时,它在容器层次结构中一直向上,直到它找到一个作为验证根的容器。JRootPaneJScrollPane是验证根。对验证根的validate()方法的调用被安排在事件调度线程上。如果有对revalidate()的多次调用,它们都被组合起来,一个组件只被重新验证一次。

绘制组件和形状

绘画机制是任何 GUI 的核心。你知道在屏幕上显示一个JFrame需要什么吗?这是一个非常复杂的过程。这是通过绘制一个图像来完成的,你在屏幕上看到的是一个JFrame。当你按下JFrame内的一个JButton时,被那个JButton占据的区域会用不同的阴影和颜色重新绘制,给你一种按钮被按下的印象。大多数情况下,Swing 会在适当的时间绘制屏幕的适当区域。您可能会遇到需要重新绘制 Swing 组件区域的情况。例如,当您在一个 Swing 容器中添加或删除一个可见的组件时,您需要验证并重新绘制该容器,以便正确地重新绘制屏幕上修改过的区域。

Swing 中的一切都有一个经理!您还有一个重画管理器,它是该类的一个实例。它提供油漆服务。您可以通过调用组件上的repaint()方法来请求重画组件。repaint()方法被重载。也可以只重画组件的一部分,而不是整个组件。对方法的调用在事件调度线程中排队。当重画管理器开始重画组件时,如果许多重画请求未决,它将只重画组件一次。

如何在 Swing 组件上执行自定义绘制?Swing 允许您使用回调机制在组件上执行自定义绘制。JComponent类有一个名为paintComponent(Graphics g)的回调方法。Graphics级在java.awt包里。它用于在组件上绘图。注意,绘图可以在各种设备上实现,例如在计算机屏幕、屏幕外图像或打印机上。要实现一个组件的自定义绘制,覆盖它的paintComponent()方法。JComponent类中的paintComponent()方法负责绘制组件的背景。为了确保组件的背景被正确绘制,您需要从组件的paintComponent()方法中调用JComponentpaintComponent()方法。该方法的典型代码如下:

import java.awt.Graphics;

public class YourCustomSwingComponent extends ASwingComponent {

@Override

public void paintComponent(Graphics g) {

// Paint the background

super.paintComponent(g);

// Your custom painting code goes here

}

}

每当需要重画或者当程序调用repaint()方法时,组件的paintComponent()方法被调用。

当您在 Swing 组件上调用repaint()方法时,重画管理器可能会不止绘制您请求绘制的组件。在油漆一个部件之前,有许多事情要考虑。在绘制组件时,组件的背景及其与其他组件的重叠区域是需要考虑的两个最重要的事情。如果组件不是不透明的,则必须在绘制该组件之前绘制该组件的容器。这是必要的,这样你就不会看穿组件的垃圾背景。如果一个组件与另一个组件重叠,至少重叠区域必须考虑显示重叠区域的正确颜色和形状。重叠区域的涂漆将包括所有重叠部件的涂漆。

一个对象有许多方法可以用来绘制几何形状和字符串。你可以画不同的形状,如矩形、椭圆形、弧形等。一个Graphics对象有许多绘图属性,如字体、颜色、坐标系(称为平移)、剪辑(定义绘图区域)、要在其上绘图的组件等。在paintComponent()方法参数中的一个Graphics对象已经设置了许多属性。例如,

  • 字体设置为组件的字体。
  • 颜色设置为组件的前景色。
  • 平移设置为组件的左上角。组件的左上角代表原点,即坐标(0,0)。
  • 剪辑被设置为组件中需要绘制的区域。

您可以在paintComponent()方法中更改Graphics对象的这些属性。然而,如果你想改变翻译或剪辑,你需要小心。你应该创建一个Graphics对象的副本,并使用该副本进行绘图,而不是改变原始Graphics对象的属性。您可以使用Graphics类的create()方法来创建一个Graphics对象的副本。确保在Graphics对象的副本上调用dispose()方法,以释放它用尽的系统资源。复制和使用Graphics对象的典型逻辑如下所示:

public void paintComponent(Graphics g) {

// Create a copy of the passed in Graphics object

Graphics gCopy = g.create();

// Change the properties of gCopy and use it for drawing here

// Dispose the copy of the Graphics object

gCopy.dispose();

}

当您为传递给方法的组件使用Graphics对象时,有一些事情需要注意。

  • 它使用笛卡尔坐标系,原点位于组件的左上角。

  • The x-axis extends to the right and y-axis extends down, as shown in Figure 2-40.

    A978-1-4302-6662-4_2_Fig40_HTML.jpg

    图 2-40。

    The coordinate system used by a graphics object inside the paintComponent() method of a component. It shows the coordinates of four corners of a 600 X 200 JPanel

  • 当您使用Graphics对象绘图时,您的绘图可能会超出组件的边界。然而,重画管理器在Graphics对象中设置的剪辑区域之外的任何图形都将被忽略。事实上,在paintComponent()方法返回后,重画管理器将只使用已绘制组件的剪辑区域在屏幕上显示它。这就是为什么你不应该在一个paintComponent()方法中改变Graphics对象的 clip 属性的原因。clip 属性设置为需要绘制的组件区域。

  • Graphics对象的 translation 属性用于设置绘图的坐标系。传递给paintComponent()方法的Graphics对象已经设置了 translation 属性,因此组件的左上角代表坐标系的原点(0,0)。如果您在paintComponent()方法中更改了Graphics对象的 translation 属性,您最好知道自己在做什么。

  • 使用Graphics对象的当前颜色和字体进行绘制。

Graphics类中有许多方法可以让你画出不同种类的形状,比如圆角矩形、弧形、多边形等等。表 2-20 列出了其中的一些方法。关于方法的完整列表,请参考Graphics类的 API 文档。

表 2-20。

Methods of the Graphics Class

| 方法 | 描述 | | --- | --- | | `void drawLine(int x1, int y1, int x2, int y2)` | 从点`(x1, y1)`到点`(x2, y2).`画一条直线 | | `void drawRect(int x, int y,` `int width, int height)` | 绘制左上角坐标为`(x, y)`的矩形。指定的`width`和`height`分别是矩形的宽度和高度。 | | `void fillRect(int x, int y,` `int width, int height)` | 与`drawRect()`方法相同,但有两点不同。它用`Graphics`对象的当前颜色填充该区域。它的宽度和高度比指定的`width`和`height`小一个像素。 | | `void drawOval(int x, int y, int width, int height)` | 绘制一个适合矩形的椭圆形,该矩形以点`(x, y)`作为其左上角并具有指定的宽度和高度。如果你指定相同的宽度和高度,它会画一个圆。 | | `void fillOval(int x, int y, int width, int height)` | 它绘制一个椭圆形并用当前颜色填充该区域。 | | `void drawstring(String str, int x, int y)` | 它绘制指定的字符串`str`。最左边字符的基线在点`(x, y)`。 |

通常,您使用一个JPanel作为定制绘图的画布。清单 2-12 中的代码显示了一个名为的类,它继承自JPanel类。在其构造函数中,它设置自己的首选大小。它覆盖了paintComponent()方法来绘制一些自定义的形状和字符串。图 2-41 显示了运行DrawingCanvas类时的屏幕。

清单 2-12。用作绘图画布的自定义 JPanel

// DrawingCanvas.java

package com.jdojo.swing;

import javax.swing.JPanel;

import java.awt.Graphics;

import java.awt.Dimension;

import java.awt.Graphics2D;

import java.awt.BasicStroke;

import javax.swing.JFrame;

public class DrawingCanvas extends JPanel {

public DrawingCanvas() {

this.setPreferredSize(new Dimension(600, 75));

}

@Override

public void paintComponent(Graphics g) {

// Paint its background

super.paintComponent(g);

// Draw a line

g.drawLine(10, 10, 50, 50);

// Draw a rectangle

g.drawRect(80, 10, 40, 20);

// Draw an oval

g.drawOval(140, 10, 40, 20);

// Fill an oval

g.fillOval(200, 10, 40, 20);

// Draw a circle

g.drawOval(250, 10, 40, 40);

// Draw an arc

g.drawArc(300, 10, 50, 50, 60, 120);

// Draw a string

g.drawString("Hello Swing!", 350, 30);

// Draw a thicker rectangle using Graphics2D

Graphics2D g2d = (Graphics2D)g;

g2d.setStroke(new BasicStroke(4));

g2d.drawRect(450, 10, 50, 50);

}

public static void main(String[] args) {

JFrame frame =

new JFrame("Sample Drawings Using a Graphics Object");

frame.getContentPane().add(new DrawingCanvas());

frame.pack();

frame.setVisible(true);

}

}

A978-1-4302-6662-4_2_Fig41_HTML.jpg

图 2-41。

Drawing shapes on a custom JPanel using a graphics object

在运行时,您会得到一个传递给该方法的Graphics2D类的实例。Graphics2D类继承了Graphics类,它有一个非常强大的 API 来绘制几何图形。例如,当您使用Graphics对象时,它使用 1.0 的描边(线宽)绘制形状。如果使用Graphics2D,可以使用自定义笔画。下面的代码片段在你的DrawingCanvas类的paintComponent()方法中使用 4.0 的笔画绘制一个矩形。要使用paintComponent()方法中的Graphics2D API,将传入的Graphics对象转换为Graphics2D,如下所示:

Graphics2D g2d = (Graphics2D)g;

g2d.setStroke(new BasicStroke(4));

g2d.drawRect(450, 10, 50, 50);

JComponent类有一个返回组件的Graphics对象的方法。如果您需要在组件的paintComponent()方法之外绘制组件,您可以使用这个方法来获取组件的Graphics对象,以便使用它进行绘制。

即时绘画

Swing 负责在适当的时候重新绘制可见的组件区域。你也可以通过调用组件的repaint()方法来请求组件的重画。对repaint()方法的调用是异步的。也就是说,它不是立即执行的。它在事件调度线程上排队,并将在将来的某个时间执行。有时情况可能需要立即上漆。使用组件的paintImmediately()方法立即进行绘制。该方法被重载。这两个版本声明如下:

  • void paintImmediately(int x, int y, int w, int h)
  • void paintImmediately(Rectangle r)

Tip

如果需要更频繁地绘制或循环绘制,调用repaint()方法会更有效。对repaint()方法的多次调用被合并成一次调用,而对paintImmediately()方法的调用是单独进行的。

双重缓冲

可以使用不同的技术在屏幕上绘制组件。如果组件直接绘制在屏幕上,则称为屏幕绘制。如果一个组件是使用离屏缓冲区绘制的,并且该缓冲区是一步复制到屏幕上的,这就叫做双缓冲。还有一种绘制组件的技术叫做翻页。翻页使用计算机显卡的视频指针功能来显示视频,视频指针是视频内容的地址。与双缓冲类似,您绘制要在离屏缓冲区上显示的内容。当您在离屏缓冲区上完成绘制时,您将图形卡的视频指针更改到这个离屏缓冲区,图形卡将负责在屏幕上显示图像。与双缓冲不同,翻页不会将屏幕外缓冲中的内容复制到屏幕上的缓冲中。相反,它将图形卡重定向到新的缓冲区。双缓冲和翻页可以避免组件绘制时屏幕闪烁,从而提供更好的用户体验。

Swing 使用双缓冲来绘制所有组件。它允许您禁用组件的双缓冲。当您禁用双缓冲时,会有一个问题。有时候,禁用双缓冲可能真的没有任何作用。如果正在绘制一个容器,Swing 会检查该容器是否启用了双缓冲。如果为容器启用了双缓冲,那么它的所有子组件都将使用双缓冲。因此,简单地禁用组件上的双缓冲没有什么帮助。如果您想禁用双缓冲,您可能只想在容器层次结构的最顶层禁用它,即JRootPane。重画管理器还允许您为应用全局启用/禁用双缓冲,如下所示:

RepaintManager currentManager = RepaintManager.currentManager(component);

currentManager.setDoubleBufferingEnabled(false);

当启用双缓冲时,Swing 将创建一个离屏图像,并将该离屏图像的图形传递给JComponentpaintComponent()方法。当你在paintComponent()方法中使用Graphics对象绘制任何东西时,本质上你是在屏幕外的图像上绘制。最后,Swing 会将屏幕外的图像复制到屏幕上。

双缓冲还允许你在程序中创建一个离屏图像。您可以绘制该屏幕外图像,并在应用中任何需要的地方使用该图像。您需要使用组件的createImage()方法来创建一个离屏图像。下面的代码创建了一个名为OffScreenImagePanel的自定义JPanel。在它的paintComponent()方法中,它创建一个离屏图像,用红色填充该图像,并使用该图像绘制到JPanel。这是一个微不足道的例子。但是,它演示了在应用中使用离屏图像所需执行的步骤。

public class OffScreenImagePanel extends JPanel{

public OffScreenImagePanel() {

this.setPreferredSize(new Dimension(200, 200));

}

public void paintComponent(Graphics g) {

super.paintComponent(g);

// Create an offscreen image and fill a rectangle with red

int w = this.getWidth();

int h = this.getHeight();

Image offScreenImage = this.createImage(w, h);

Graphics imageGraphics = offScreenImage.getGraphics();

imageGraphics.setColor(Color.RED);

imageGraphics.fillRect(0, 0, w, h);

// Draw the offscreen image on the JPanel

g.drawImage(offScreenImage, 0, 0, null);

}

}

重新访问 JFrame

你已经在你写的几乎每个程序中使用了这一章中的 JFrames。在这一节中,我将讨论一些重要的事件和JFrame的性质。

您可以使用setExtendedState(int state)方法以编程方式设置JFrame的状态。使用JFrame类继承的java.awt.Frame类中定义的常量来指定状态。

// Display the JFrame maximized

frame.setExtendedState(JFrame.MAXIMIZED_BOTH);

通常,你可以使用标题栏角落里的状态按钮或状态菜单来改变JFrame的状态。表 2-21 列出了可用于改变JFrame状态的常数。

表 2-21。

The List of Constants That Define States of a JFrame

| JFrame 状态常数 | 描述 | | --- | --- | | `NORMAL` | `JFrame`以正常尺寸显示。 | | `ICONIFIED` | `JFrame`以最小化状态显示。 | | `MAXIMIZED_HORIZ` | `JFrame`在水平方向最大化显示,但在垂直方向以正常尺寸显示。 | | `MAXIMIZED_VERT` | `JFrame`垂直最大化显示,但水平以正常尺寸显示。 | | `MAXIMIZED_BOTH` | `JFrame`水平和垂直最大化显示。 |

有时你可能想在你的JFrameJDialog中使用一个默认按钮。默认按钮是JButton类的一个实例,当用户按下键盘上的一个键时就会被激活。激活默认按钮的键是由外观定义的。通常,激活默认按钮的键是Enter键。您可以为JRootPane设置一个默认按钮,该按钮出现在JFrameJDialogJWindowJAppletJInternalFrame中。通常,您将OK按钮设置为JDialog上的默认按钮。如果一个JRootPane有一个默认按钮集,按下Enter键将激活那个按钮,如果你有一个动作执行的事件处理程序添加到那个按钮,你的代码将被执行。

// Create a JButton

JButton okButton = new JButton("OK");

// Add an event handler to okButton here...

// Set okButton as the default button

frame.getRootPane().setDefaultButton(okButton);

您可以添加一个窗口监听器到一个JFrame或任何其他顶层 Swing 窗口,它将通知您窗口状态的七种变化。下面的代码片段向名为frameJFrame添加了一个窗口监听器。如果您对监听少量的窗口状态变化感兴趣,您可以使用WindowAdapter类来代替WindowListener接口。WindowAdapter类提供了WindowListener接口中所有七个方法的空实现。

frame.addWindowListener(new WindowListener() {

@Override

public void windowOpened(WindowEvent e) {

System.out.println("JFrame has been made visible first time");

}

@Override

public void windowClosing(WindowEvent e) {

System.out.println("JFrame is closing.");

}

@Override

public void windowClosed(WindowEvent e) {

System.out.println("JFrame is closed.");

}

@Override

public void windowIconified(WindowEvent e) {

System.out.println("JFrame is minimized.");

}

@Override

public void windowDeiconified(WindowEvent e) {

System.out.println("JFrame is restored.");

}

@Override

public void windowActivated(WindowEvent e) {

System.out.println("JFrame is activated.");

}

@Override

public void windowDeactivated(WindowEvent e) {

System.out.println("JFrame is deactivated.");

}

});

// Use the WindowAdapter class to intercept only the window closing event

frame.addWindowListener(new WindowAdapter() {

@Override

public void windowClosing(WindowEvent e) {

System.out.println("JFrame is closing.");

}

});

当你使用完一个窗口(JFrameJDialogJWindow)时,你应该调用它的dispose()方法,这将使它不可见,并释放资源给操作系统。请注意,dispose()方法并不销毁或垃圾收集窗口对象。只要你持有窗口的引用并且它是可访问的,Java 就不会破坏你的窗口,你可以通过调用它的setVisible(true)方法再次显示它。

摘要

Swing 提供了大量组件来开发 GUI 应用。大多数 Swing 组件都是轻量级组件,它们使用 Java 代码进行重绘,而无需使用本机对等组件。JComponent类是所有 Swing 组件的基类。可以包含其他组件的组件称为容器。Swing 提供了两种类型的容器:顶级容器和非顶级容器。顶级容器不包含在另一个容器中,它可以直接显示在桌面上。JFrame类的一个实例代表一个顶级容器。

JButton类的一个对象代表一个按钮。按钮也称为按钮或命令按钮。用户按下或点击一个JButton来执行一个动作。按钮可以显示文本和/或图标。

JPanel类的对象代表一个可以包含其他组件的容器。典型地,一个JPanel被用来将相关的组件组合在一起。一个JPanel是非顶级容器。

JLabel类的对象表示显示文本、图标或两者的标签组件。通常,JLabel中的文本描述了另一个组件。

Swing 提供了几个文本组件,允许您显示和编辑不同类型的文本。JTextField类的一个对象用于处理一行纯文本。JTextArea的一个对象用于处理多行纯文本。JPasswordField的一个对象用于处理单行文本,其中文本中的实际字符被替换为回显字符。JFormattedTextField的一个对象允许您使用一行纯文本,您可以指定文本的格式,例如以 mm/dd/yyy 格式显示日期。JEditorPane的一个对象可以让你处理 HTML 和 RTF 格式的文本。JTextPane的一个对象允许您处理带有嵌入图像和组件的样式化文档。您可以向文本组件添加输入验证器,以验证用户输入的文本。InputVerifier类的一个实例充当输入验证器。您可以使用JComponent类的setInputVerifier()方法为文本组件设置输入验证器。

Swing 提供了许多组件,允许您从项目列表中选择一个或多个项目。这些组件是JToggleButtonJCheckBoxJRadioButtonJComboBoxJList类的对象。ToggleButton可以处于按下或未按下状态,代表是/否选择。JCheckBox可用于表示是/否选择。有时一组CheckBox es 用于让用户选择零个或多个选项。一组JRadioButton用来呈现给用户一组互斥的选项。ComboBox用于为用户提供一组互斥的选项,用户可以选择输入新的选项值。与其他选项相比,ComboBox在屏幕上占用更少的空间,提供组件,因为它折叠了所有选项,用户必须打开选项列表才能做出选择。一个JList让用户从选项列表中选择零个或多个选项。用户可以看到JList中的所有选项。

一个JSpinner组件结合了一个JFormattedTextField和一个可编辑的JComboBox的优点。它允许您像在JComboBox中一样设置一个选择列表,同时,您还可以对显示的值应用一种格式。它一次只显示选项列表中的一个值。它允许您输入新值。

JScrollBar用于提供滚动功能,以查看尺寸大于可用空间的组件。一个JScrollBar可以垂直放置,也可以水平放置。沿着JScrollBar的轨迹拖动一个旋钮,就可以完成划水。您需要编写逻辑来使用JScrollBar组件提供批评功能。

ScollPane是一个容器,用于包装尺寸大于可用空间的组件。ScrollPane提供水平和垂直方向的自动弯曲能力。

一个JProgressBar用于显示任务的进度。它可以具有水平或垂直方向。它有三个相关的值:当前值、最小值和最大值。如果不知道任务的进度,就说JProgressBar处于不确定状态。

一个JSlider可以让你通过沿轨道滑动旋钮从两个整数之间的一组值中选择一个值。

当您想要在两个组件或两组组件之间添加分隔符时,JSeparator是一个方便的组件。通常,菜单中使用一个JSeparator来分隔相关菜单项的组。通常,它显示为水平或垂直实线。

菜单组件用于以紧凑的形式向用户提供动作列表。一个对象JMenuBar类代表一个菜单栏。一个JMenuJMenuItemJCheckBoxMenuItemJRadioButtonMenuItem类的对象代表一个菜单项。

工具栏是一组小按钮,在JFrame中为用户提供常用的操作。通常,您会提供一个工具栏和一个菜单。

JTable用于以表格形式显示和编辑数据。它以行和列的形式显示数据。每列都有一个列标题。行和列是使用从 0 开始的索引的引用。

一个JTree用于以树状结构显示分层数据。一个JTree中的每一项称为一个节点。有子节点的节点称为分支节点。没有子节点的节点称为叶节点。分支节点被称为其子节点的父节点。JTree中没有父节点的第一个节点称为根节点。

一个JTabbedPane组件充当其他 Swing 组件的容器,以选项卡的方式排列它们。它可以使用标题、图标或两者来显示选项卡。一次只能看到一个标签的内容。一个JTabbedPane让你共享多个标签之间的空间。

JSplitPane是一个分割器,可以用来分割两个组件之间的空间。拆分条可以水平或垂直显示。当可用空间小于显示两个组件所需的空间时,用户可以向上/向下或向左/向右移动拆分条,以便一个组件比另一个组件获得更多的空间。如果有足够的空间,两个组件都可以完全显示。

一个JDialog是顶级的 Swing 容器。它被用作一个临时的顶层容器(或作为一个弹出窗口)来帮助主窗口获得用户的注意或用户的输入。JOptionPane类提供了许多静态方法,使用JDialog类的实例向用户显示不同类型的对话框。

允许用户使用内置对话框从文件系统中选择文件/目录。is 允许用户使用内置对话框以图形方式选择颜色。

一个JWindow是一个未分解的顶级容器。它不是一个常用的顶级容器,除了作为一个启动窗口,当应用启动时显示一次,几秒钟后自动消失。

Swing 允许您设置组件的背景色和前景色。java.awt.Color类的对象代表一种颜色。您可以使用红色、绿色、蓝色和 alpha 分量,或者使用色调、饱和度和亮度分量来指定颜色。Color类是不可变的。它提供了几个代表常用颜色的常量,例如,Color.REDColor.BLUE常量代表红色和蓝色。

在 Swing 中,您可以在组件周围绘制一个边框。边界由一个Border接口的实例表示。存在不同类型的边框:斜面边框、柔和斜面边框、蚀刻边框、线条边框、标题边框、无光泽边框、空白边框和复合边框。BorderFactory类提供了创建所有类型边框的工厂方法。

Swing 允许您为组件中显示的文本设置字体。类的一个对象代表了 Java 程序中的一种字体。

组件可以是有效的,也可以是无效的。如果组件无效,组件的isValid()方法返回true。无效组件表示需要重新计算其位置和大小,并且需要重新布局。组件在第一次可见之前是有效的。添加/删除组件和更改属性可能会更改组件的位置和/或大小,这可能会使组件无效。调用validate()方法使组件再次有效。

Swing 可以让你画出多种形状(圆形、矩形、直线、多边形等等。)使用Graphics对象。通常,您使用JPanel作为画布来绘制形状。

Swing 提供了两种重画组件的方式:异步和同步。调用repaint()方法异步绘制组件,调用paintImmediately()方法立即绘制组件。

组件的喷涂可以在屏幕上进行,也可以在屏幕外进行。屏幕上的绘画可能会导致闪烁。绘画可以使用缓冲区在屏幕外进行,缓冲区可以一次性复制到屏幕上以避免闪烁。这种屏幕外绘画被称为双缓冲,它通过在屏幕上提供平滑的绘画来提供更好的用户体验。

三、高级 Swing

在本章中,您将学习

  • 如何在 HTML 格式的 Swing 组件中使用标签
  • 关于 Swing 中的线程模型以及事件调度线程的工作方式
  • 如何在事件调度线程外执行长时间运行的任务
  • 如何在 Swing 中使用可插拔的外观
  • 如何通过 Synth 使用可换肤的外观
  • 如何在 Swing 组件之间执行拖放操作
  • 如何创建多文档界面(MDI)应用
  • 如何使用Toolkit类发出哔哔声并知道屏幕细节
  • 如何使用 JLayer 装饰 Swing 组件
  • 如何创建半透明的窗口
  • 如何创建异形窗口

在 Swing 组件中使用 HTML

通常,使用一种字体和颜色在一行中显示组件上的文本。如果要在组件上使用不同的字体和颜色显示文本,或者多行显示文本,可以使用 HTML 字符串作为组件的文本。Swing 组件内置了将 HTML 文本显示为标签的支持。您可以使用 HTML 格式的字符串作为JButtonJMenuItemJLabelJToolTipJTabbedPaneJTree等的标签。使用一个 HTML 字符串,它应该分别以<html></html>标签开始和结束。例如,如果您想在JButton上显示文本“关闭窗口”作为其标签(以粗体显示关闭,以普通字体显示窗口),您可以如下操作:

JButton b1 = new JButton("<html><b>Close</b> Window</html>");

大多数时候,在<html></html>标签中放置一个 HTML 字符串就可以了。但是,如果 HTML 字符串中的一行以斜杠(/)开头,它可能无法正确显示。例如,<html>/Close Window</html>将不显示任何内容,而<html>/Close Window <b>Problem</b></html>将只显示Problem。为了避免这种问题,您可以像在<html><body>/Close Window</body></html>中一样将 HTML 格式的字符串放在<body> HTML 标签中,它将显示为/Close Window。如何将包含 HTML 标签的字符串显示为标签?Swing 允许您使用html.disable组件的客户端属性禁用默认的 HTML 解释。以下代码片段禁用了JButton的 HTML 属性,并在其标签中使用 HTML 标记:

JButton b3 = new JButton();

b3.putClientProperty("html.disable", Boolean.TRUE);

b3.setText("<html><body>HTML is disabled</body></html>");

您必须在禁用html.disable客户端属性后为组件设置文本。下面的代码片段展示了一些使用 HTML 格式的字符串作为JButton文本的例子。当代码在 Windows XP 上运行时,按钮如图 3-1 所示。

JButton b1 = new JButton();

JButton b2 = new JButton();

JButton b3 = new JButton();

b1.setText("<html><body><b>Close</b> Window</body></html>");

b2.setText("<html><body>Line 1 <br/>Line 2</body></html>");

// Disable HTML text display for b3

b3.putClientProperty("html.disable", Boolean.TRUE);

b3.setText("<html><body>HTML is disabled</body></html>");

A978-1-4302-6662-4_3_Fig1_HTML.jpg

图 3-1。

Using an HTML-formatted string as text for Swing components’ labels

Swing 中的线程模型

Swing 中的大多数类都不是线程安全的。它们被设计成只使用一个线程。这并不意味着不能在 Swing 应用中使用多线程。这意味着你必须理解 Swing 的线程模型来编写线程安全的 Swing 应用。

Swing 的线程安全规则非常简单。它指出,一旦实现了一个 Swing 组件,就必须在事件调度线程上修改或访问该组件的状态。一个组件被认为是实现了,如果它已经被油漆或准备被油漆。当你第一次调用它的pack()setVisible(true)show()方法时,Swing 中的一个顶级容器就实现了。当一个顶级容器被实现时,它的所有子容器也被实现。

什么是事件调度线程?它是 JVM 在检测到正在使用 Swing 应用时自动创建的线程。JVM 使用这个线程来执行 Swing 组件的事件处理程序。假设您有一个带有动作监听器的JButton。当您点击JButton时,actionPerformed()方法中的代码(也就是JButton被点击的事件处理程序代码)由事件调度线程执行。你在前几章的例子中使用了JButton。您从未注意过执行其动作监听器的actionPerformed()方法的线程。通常,在像您一直在使用的简单 Swing 应用中,您不需要担心线程问题。现在您已经知道每个 Swing 应用中都存在一个事件调度线程,让我们来揭开它是如何工作的神秘面纱。在本节的整个讨论中,您将使用两个类。它们是 Swing 应用中用来处理线程模型的助手类。这些类别是

  • SwingUtilities
  • SwingWorker

您如何知道您的代码正在事件调度线程中执行?通过使用该类的静态方法isEventDispatchThread(),很容易知道您的代码是否正在事件分派线程中执行。如果您的代码正在事件调度线程中执行,它将返回true。否则,它返回false。出于调试目的,您可以在 Java 代码中的任何地方编写以下语句。如果它打印出true,这意味着您的代码在事件调度线程中被执行。

System.out.println(SwingUtilities.isEventDispatchThread());

考虑清单 3-1 所示的程序。

清单 3-1。糟糕的 Swing 应用

// BadSwingApp.java

package com.jdojo.swing;

import javax.swing.SwingUtilities;

import java.awt.BorderLayout;

import java.awt.Container;

import javax.swing.JFrame;

import javax.swing.JComboBox;

public class BadSwingApp extends JFrame {

JComboBox<String> combo = new JComboBox<>();

public BadSwingApp(String title) {

super(title);

initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

contentPane.add(combo, BorderLayout.NORTH);

// Add an ItemEvent listener to the combobox

combo.addItemListener(e ->

System.out.println("isEventDispatchThread(): " +

SwingUtilities.isEventDispatchThread()));

combo.addItem("First");

combo.addItem("Second");

combo.addItem("Third");

}

public static void main(String[] args) {

BadSwingApp badSwingApp = new BadSwingApp("A bad Swing App");

badSwingApp.pack();

badSwingApp.setVisible(true);

}

}

这个程序是一个简单的 Swing 应用,但是它包含了一个潜在的 bug。它在一个JFrame中显示一个JComboBox。在initFrame()方法中,它向JComboBox添加了一个项目监听器。然后它给JComboBox增加了三个项目。项目侦听器只是打印一条消息,显示它是否由事件调度线程执行。像往常一样,通过创建框架、打包它并使它可见来运行应用。应用在标准输出中打印以下文本:

isEventDispatchThread(): false

我不是说过执行所有 Swing 组件的事件是事件调度线程的工作吗?让我们不要失去希望,所以在应用运行时,从组合框中选择另一个项目,如"Second""Third"。您会在标准输出中看到以下消息:

isEventDispatchThread(): true

第一次,组合框的 item listener 事件在非事件调度线程上执行,从第二次开始,它在事件调度线程上执行。要知道这个小应用中为什么会发生这种情况,您需要知道事件调度线程是何时创建的,以及它何时开始处理事件。事件分派线程等待从用户与 GUI 的交互中生成的事件。一旦创建了 GUI,所有用户与它的交互都由事件调度线程自动处理。在这种情况下,“主”线程在main()方法中创建了BadSwingApp帧。甚至在 GUI 被创建和显示之前,当代码将第一个项目添加到JComboBox时,项目事件被触发。因为“主”线程运行BadSwingApp帧的创建,所以主线程也处理项目事件。这个程序有两个问题:

  • 首先向组件添加事件处理程序,然后在 GUI 显示之前做一些触发事件处理程序的事情,这不是一个好的做法。将所有事件处理程序添加到 GUI 构建代码末尾的组件中,这是一个经验法则。您可以通过在initFrame()方法中将addItem()调用移动到addItemListener()调用之前来解决这个问题。
  • 您需要在事件调度线程上运行所有 GUI 代码——从 GUI 构建到使其可见。这也是一件简单的事情。你需要使用SwingUtilities类的invokeLater(Runnable r)静态方法。该方法以一个Runnable作为它的参数。它调度Runnable在事件调度线程上运行。下面是启动 Swing 应用的正确方法。在前面章节的任何例子中,您都没有按照这种方式启动 Swing 应用。您总是用main()方法创建和显示您的框架,该方法使用main线程来构建和显示 GUI。我没有遵循构建和显示 GUI 的正确方法,因为我的重点是演示我正在讨论的主题。这是您学习如何正确启动 Swing 应用的好时机。// Correct way to start a Swing application SwingUtilities.invokeLater(() -> { BadSwingApp badSwingApp = new BadSwingApp("一个坏的 Swing App" ); badSwingApp.pack(); badSwingApp.setVisible(true); });如果用这个代码替换清单 3-1 的main(String[] args)方法中的现有代码,应用将在运行时打印isEventDispatchThread(): true,因为SwingUtilities类的invokeLater()方法将调度 GUI 构建代码在事件调度线程上运行。一旦以这种方式启动应用,就可以保证应用的所有事件处理程序都将在事件调度线程上执行。对SwingUtilities.invokeLater(Runnable r)方法的调用将启动事件分派线程,如果它还没有启动的话。

SwingUtilities.invokeLater()方法调用立即返回,其Runnable参数的run()方法被异步执行。也就是说,它的run()方法的执行被排队到事件调度线程中,以便以后执行。

SwingUtilities类中还有另一个重要的静态方法叫做invokeAndWait(Runnable r)。这个方法是同步执行的,直到它的Runnable参数的run()方法在事件分派线程上执行完毕,它才返回。这个方法可能抛出一个InterruptedException或者InvocationTargetException.

Tip

不应该从事件分派线程调用SwingUtilities.invokeAndWait(Runnable r)方法,因为执行该方法调用的线程会一直等到run()方法完成。如果您从事件分派线程执行此方法调用,它将被排队到事件分派线程,并且同一个线程(事件分派线程)将等待。在事件调度线程中执行此方法调用会生成运行时错误。

有时候你可能想使用SwingUtilities类的invokeAndWait()方法来启动一个 Swing 应用,而不是使用invokeLater()方法。例如,下面的代码片段启动一个 Swing 应用,并在控制台上打印一条消息,说明该应用已经启动:

try {

SwingUtilities.invokeAndWait(() -> {

JFrame frame = new JFrame();

frame.pack();

frame.setVisible(true);

});

System.out.println("Swing application is running...");

// You can perform some non-swing related work here

}

catch (Exception e) {

e.printStackTrace();

}

有时,您可能需要在 Swing 应用中执行一项耗时的任务。如果您在事件调度线程上执行耗时的任务,您的应用将变得没有响应,这是用户不喜欢的。您应该在单独的线程中执行长任务,而不是在事件调度线程中。请注意,当任务完成时,您可能希望更新 GUI 或者在组件中显示结果,组件是 GUI 的一部分。这将要求您从非事件调度线程访问 Swing 组件。您可以使用SwingUtilities类的invokeLater()invokeAndWait()方法从单独的线程中更新 Swing 组件。然而,Swing 提供了一个类,这使得在 Swing 应用中使用多线程变得很容易。它负责启动一个新线程,在一个新的后台线程中执行一些代码,在事件调度线程中执行一些代码。您需要知道SwingWorker类中的哪些方法将在新线程和事件分派线程中执行。

SwingWorker<T,V>类被声明为abstract。类型参数T是这个类产生的结果类型,类型参数V是中间结果类型。您必须创建从它继承的自定义类。它包含几个有趣的方法,您可以在其中编写自定义代码:

  • 这是你编写代码来执行一项耗时任务的方法。它在一个单独的工作线程中执行。如果要发布中间结果,可以从这个方法调用SwingWorker类的publish()方法,这个方法又会调用它的process()方法。请注意,您不应该访问该方法中的任何 Swing 组件,因为该方法不会在事件调度线程上执行。
  • process():这个方法是作为publish()方法调用的结果而被调用的。该方法在事件调度线程上执行,您可以自由访问该方法中的任何 Swing 组件。对process()方法的调用可能是对publish()方法多次调用的结果。下面是这两个方法的方法签名:protected final void publish(V... chunks)``protected void process(List<V> chunks)``publish()方法接受一个varargs参数。process()方法将所有参数传递给打包在List中的publish()方法。如果不止一个对publish()方法的调用被组合在一起,process()方法将在它的List参数中获得所有这些参数。
  • done():当doInBackground()方法正常或非正常结束时,在事件调度线程上调用done()方法。您可以用这种方法访问 Swing 组件。默认情况下,此方法不执行任何操作。
  • 当你想在一个单独的线程中开始执行你的任务时,你调用这个方法。这个方法调度SwingWorker对象在一个工作线程上执行。
  • get():这个方法返回从doInBackground()方法返回的任务结果。如果SwingWorker对象还没有完成doInBackground()方法的执行,那么对这个方法的调用就会阻塞,直到结果准备好。不建议在事件调度线程上调用此方法,因为它将阻塞所有事件,直到它返回。
  • cancel(boolean mayInterruptIfRunning):如果任务仍在运行,此方法会取消任务。如果任务尚未开始,则任务永远不会运行。确保检查取消状态和doInBackground()方法中的任何中断,并相应地退出该方法。否则,您的流程将不会响应cancel()调用。
  • isCancelled():如果进程被取消,返回true。否则,它返回false
  • isDone():如果任务已经完成,返回true。任务可以正常完成,也可以通过抛出异常或取消来完成。否则,它返回false

Tip

需要注意的是,SwingWorker对象是一种使用并抛出的类型。也就是说,您不能使用它超过一次。多次调用它的execute()方法没有任何作用。

让我们开始讨论一个简单的SwingWorker类的用法。假设您想在一个单独的线程中执行一个计算一个数字(比如一个整数)的耗时任务。您希望通过轮询来检索处理结果。也就是说,您将定期检查进程是否已经完成处理。下面是SwingWorker类的一个简单用法:

// First, create a custom SwingWorker class, say MySwingWorker.

public class MySwingWorker extends SwingWorker<Integer, Integer> {

@Override

protected Integer doInBackground() throws Exception {

int result = -1;

// Write code to perform the task

return result;

}

}

// Create an object of your SwingWorker class and execute the task

MySwingWorker mySW = new MySwingWorker();

mySW.execute();

// Keep checking for the result periodically. You need to wrap the get()

// call inside a try-catch to handle any exceptions.

if (mySW.isDone()) {

int result = mySW.get();

}

清单 3-2 和清单 3-3 展示了SwingWorker类是如何工作的。当您运行清单 3-3 中的代码时,它会显示一个框架,如图 3-2 所示。您可以通过点击Start按钮启动任务。你可以随时点击Cancel按钮取消任务。中间结果显示在JLabel中。这个SwingWorkerProcessor类很简单。它接受一个SwingWorkerFrame,一个计数器和一个时间间隔。它计算计数器的数字 1 的和。向结果中添加一个数字后,它会在指定的时间间隔内休眠。它使用process()done()方法显示中间迭代和最终结果。

清单 3-2。自定义 SwingWorker 类

// SwingWorkerProcessor.java

package com.jdojo.swing;

import javax.swing.SwingWorker;

import java.util.List;

public class SwingWorkerProcessor extends SwingWorker<Integer, Integer> {

private final SwingWorkerFrame frame;

private int iteration;

private int intervalInMillis;

public SwingWorkerProcessor(SwingWorkerFrame frame, int iteration,

int intervalInMillis) {

this.frame = frame;

this.iteration = iteration;

if (this.iteration <= 0) {

this.iteration = 10;

}

this.intervalInMillis = intervalInMillis;

if (this.intervalInMillis <= 0) {

this.intervalInMillis = 1000;

}

}

@Override

protected Integer doInBackground() throws Exception {

int sum = 0;

for (int counter = 1; counter <= iteration; counter++) {

sum = sum + counter;

// Publish the result to the GUI

this.publish(counter);

// Make sure it listens to an interruption and exits this

// method by throwing an appropriate exception

if (Thread.interrupted()) {

throw new InterruptedException();

}

// Make sure the loop exits, when the task is cancelled

if (this.isCancelled()) {

break;

}

Thread.sleep(intervalInMillis);

}

return sum;

}

@Override

protected void process(List<Integer> data) {

for (int counter : data) {

frame.updateStatus(counter, iteration);

}

}

@Override

public void done() {

frame.doneProcessing();

}

A978-1-4302-6662-4_3_Fig2_HTML.jpg

图 3-2。

Demonstrating the use of the SwingWorker class

}

清单 3-3。演示 SwingWorker 类如何工作的 Swing 应用

// SwingWorkerFrame.java

package com.jdojo.swing;

import javax.swing.JFrame;

import java.awt.Container;

import javax.swing.JLabel;

import javax.swing.JButton;

import java.awt.BorderLayout;

import java.util.concurrent.ExecutionException;

public class SwingWorkerFrame extends JFrame {

String startMessage = "Please click the start button...";

JLabel statusLabel = new JLabel(startMessage);

JButton startButton = new JButton("Start");

JButton cancelButton = new JButton("Cancel");

SwingWorkerProcessor processor;

public SwingWorkerFrame(String title) {

super(title);

initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

cancelButton.setEnabled(false);

contentPane.add(statusLabel, BorderLayout.NORTH);

contentPane.add(startButton, BorderLayout.WEST);

contentPane.add(cancelButton, BorderLayout.EAST);

startButton.addActionListener(e -> startProcessing());

cancelButton.addActionListener(e -> cancelProcessing());

}

public void setButtonStatus(boolean canStart) {

if (canStart) {

startButton.setEnabled(true);

cancelButton.setEnabled(false);

}                 else {

startButton.setEnabled(false);

cancelButton.setEnabled(true);

}

}

public void startProcessing() {

setButtonStatus(false);

processor = new SwingWorkerProcessor(this, 10, 1000);

processor.execute();

}

public void cancelProcessing() {

// Cancel the processing

processor.cancel(true);

setButtonStatus(true);

}

public void updateStatus(int counter, int total) {

String msg = "Processing " + counter + " of " + total;

statusLabel.setText(msg);

}

public void doneProcessing() {

if (processor.isCancelled()) {

statusLabel.setText("Process cancelled ...");

}

else {

try {

// Get the result of processing

int sum = processor.get();

statusLabel.setText("Process completed. Sum is " + sum);

}

catch (InterruptedException | ExecutionException e) {

e.printStackTrace();

}

}

setButtonStatus(true);

}

public static void main(String[] args) {

SwingUtilities.invokeLater(() -> {

SwingWorkerFrame frame

= new SwingWorkerFrame("SwingWorker Frame");

frame.pack();

frame.setVisible(true);

});

}

}

可插拔的外观

Swing 支持可插拔的外观(L&F)。您可以使用UIManager类的setLookAndFeel(String lafClassName)静态方法来更改 Swing 应用的 L&F。该方法引发检查过的异常,这将要求您处理异常。该方法的lafClassName参数是提供 L & F 的类的完全限定名。以下代码片段使用通用 catch 块为 Windows 设置 L & F,以处理所有类型的异常:

String windowsLAF= "com.sun.java.swing.plaf.windows.WindowsLookAndFeel";

try {

UIManager.setLookAndFeel(windowsLAF);

}

catch (Exception e) {

e.printStackTrace();

}

通常,在启动 Swing 应用之前设置 L&F。如果您在 GUI 显示后更改了 L&F,您将需要使用SwingUtilities类的updateComponentTreeUI(container)方法更新 GUI。改变 L & F 可能会强制改变组件的尺寸,你可能想再次使用pack()方法包装你的容器。当你在 GUI 显示后改变应用的 L & F 时,你可能会写下下面三行代码:

// Assuming that frame is a reference to a JFrame object and windowsLAF contains the

// L&F class name for Windows L&F, set the new L&F, update the GUI, and pack the frame.

UIManager.setLookAndFeel(windowsLAF);

SwingUtilities.updateComponentTreeUI(frame);

frame.pack();

下面两个UIManager类的方法返回默认 Java L & F 和系统 L & F 的类名:

  • String getCrossPlatformLookAndFeelClassName()
  • String getSystemLookAndFeelClassName()

系统 L&F 为 Swing 组件提供了本机系统的 L&F,并且会因系统而异。如果您希望您的应用看起来与本机 L&F 相同,您可以通过使用下面这段代码来实现,而不必担心在您的应用将运行的计算机上表示系统 L&F 的类的实际名称:

// Set the system (or native) L&F

UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());

并不总是需要为 Swing 应用设置 L&F。当您启动应用时,Swing 将自己使用默认的 Java L&F。如果对UIManager.setLookAndFeel()的调用失败,你的 Swing 应用将使用当前的 L & F,这是默认的 Java L & F,如果你是第一次尝试设置一个新的 L & F,虽然可以创建自己的 L & F,但这样做并不容易。然而,Java 5.0 添加了 Synth L & F,以便于创建可换肤的 L & F。

您可以使用UIManager类来列出您的计算机上可以在 Swing 应用中使用的所有已安装的 L & F。清单 3-4 中的程序列出了你机器上所有可用的 L & F。输出是程序在 Windows 上运行时获得的;您可能会得到不同的输出。

清单 3-4。了解机器上安装的 L&F

// InstalledLookAndFeel.java

package com.jdojo.swing;

import javax.swing.UIManager;

import javax.swing.UIManager.LookAndFeelInfo;

public class InstalledLookAndFeel {

public static void main(String[] args) {

// Get the list of installed L&F

LookAndFeelInfo[] lafList = UIManager.getInstalledLookAndFeels();

// Print the names and class names of all installed L&F

for (LookAndFeelInfo lafInfo : lafList) {

String name = lafInfo.getName();

String className = lafInfo.getClassName();

System.out.println("Name: " + name +

", Class Name: " + className);

}

}

}

Name: Metal, Class Name: javax.swing.plaf.metal.MetalLookAndFeel

Name: Nimbus, Class Name: javax.swing.plaf.nimbus.NimbusLookAndFeel

Name: CDE/Motif, Class Name: com.sun.java.swing.plaf.motif.MotifLookAndFeel

Name: Windows, Class Name: com.sun.java.swing.plaf.windows.WindowsLookAndFeel

Name: Windows Classic, Class Name: com.sun.java.swing.plaf.windows.WindowsClassicLookAndFeel

清单 3-5 构建了一个JFrame,可以让你试验当前平台上已经安装的 L & F。默认情况下,选择当前 L & F。从列表中选择一个不同的 L & F,应用的 L & F 会相应改变。你会在不同的平台上得到不同的 L & F 列表。图 3-3 和图 3-4 分别显示了应用在 Windows 和 Linux 上运行时的框架。

清单 3-5。在当前平台上试验已安装的外观

// InstalledLAF.java

package com.jdojo.swing;

import java.awt.BorderLayout;

import java.awt.Container;

import java.awt.event.ItemEvent;

import java.util.Map;

import java.util.TreeMap;

import javax.swing.AbstractButton;

import javax.swing.BorderFactory;

import javax.swing.Box;

import javax.swing.ButtonGroup;

import javax.swing.JButton;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JPanel;

import javax.swing.JRadioButton;

import javax.swing.JTextField;

import javax.swing.LookAndFeel;

import javax.swing.SwingUtilities;

import javax.swing.UIManager;

import javax.swing.UIManager.LookAndFeelInfo;

import javax.swing.border.Border;

import javax.swing.border.EtchedBorder;

public class InstalledLAF extends JFrame {

JLabel nameLbl = new JLabel("Name:");

JTextField nameFld = new JTextField(20);

JButton saveBtn = new JButton("Save");

JTextField lafClassNameFld = new JTextField();

ButtonGroup radioGroup = new ButtonGroup();

static final Map<String, String> installedLAF = new TreeMap<>();

static {

for (LookAndFeelInfo lafInfo : UIManager.getInstalledLookAndFeels()) {

installedLAF.put(lafInfo.getName(), lafInfo.getClassName());

}

}

public InstalledLAF(String title) {

super(title);

initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

// Get the current look and feel

LookAndFeel currentLAF = UIManager.getLookAndFeel();

String currentLafName = currentLAF.getName();

String currentLafClassName = currentLAF.getClass().getName();

lafClassNameFld.setText(currentLafClassName);

lafClassNameFld.setEditable(false);

// Build the panels

JPanel topPanel = buildTopPanel();

JPanel leftPanel = buildLeftPanel(currentLafName);

JPanel rightPanel = buildRightPanel();

contentPane.add(topPanel, BorderLayout.NORTH);

contentPane.add(leftPanel, BorderLayout.WEST);

contentPane.add(rightPanel, BorderLayout.CENTER);

}

private void setLAF(String lafClassName) {

try {

UIManager.setLookAndFeel(lafClassName);

SwingUtilities.updateComponentTreeUI(this);

this.pack();

}

catch (Exception e) {

e.printStackTrace();

}

}

private JPanel buildTopPanel() {

JPanel panel = new JPanel();

panel.add(lafClassNameFld);

panel.setBorder(getBorder("L&F Class Name"));

return panel;

}

private JPanel buildLeftPanel(String currentLafName) {

JPanel panel = new JPanel();

panel.setBorder(getBorder("L&F Name"));

Box vBox = Box.createVerticalBox();

// Add a radio button for each installed L&F

for (String lafName : installedLAF.keySet()) {

JRadioButton radioBtn = new JRadioButton(lafName);

if (lafName.equals(currentLafName)) {

radioBtn.setSelected(true);

}

radioBtn.addItemListener(this::changeLAF);

vBox.add(radioBtn);

radioGroup.add(radioBtn);

}

panel.add(vBox);

return panel;

}

private JPanel buildRightPanel() {

JPanel panel = new JPanel();

panel.setBorder(getBorder("Swing Components"));

Box hBox = Box.createHorizontalBox();

hBox.add(nameLbl);

hBox.add(nameFld);

hBox.add(saveBtn);

panel.add(hBox);

return panel;

}

private void changeLAF(ItemEvent e) {

if (e.getSource() instanceof AbstractButton) {

AbstractButton btn = (AbstractButton) e.getSource();

String lafName = btn.getText();

String lafClassName = installedLAF.get(lafName);

this.lafClassNameFld.setText(lafClassName);

try {

UIManager.setLookAndFeel(lafClassName);

SwingUtilities.updateComponentTreeUI(this);

this.pack();

}

catch (Exception ex) {

ex.printStackTrace();

}

}

}

private Border getBorder(String title) {

Border etched = BorderFactory.createEtchedBorder(EtchedBorder.LOWERED);

Border titledBorder = BorderFactory.createTitledBorder(etched, title);

return titledBorder;

}

public static void main(String[] args) {

SwingUtilities.invokeLater(() -> {

InstalledLAF lafApp = new InstalledLAF("Swing L&F");

lafApp.pack();

lafApp.setVisible(true);

});

}

}

A978-1-4302-6662-4_3_Fig4_HTML.jpg

图 3-4。

The InstalledLAF frame on Linux

A978-1-4302-6662-4_3_Fig3_HTML.jpg

图 3-3。

The InstalledLAF frame on Windows

可设置外观的外观

Swing 支持名为 Synth 的基于皮肤的 L&F。什么是皮肤?GUI 中的皮肤是定义 GUI 组件外观的一组属性。Synth 允许您在外部 XML 文件中定义皮肤,并在运行时应用皮肤来改变 Swing 应用的外观。在引入 Synth 之前,您需要编写大量的 Java 代码来拥有一个自定义的 L&F。使用 Synth,您甚至不需要编写一行 Java 代码来拥有一个新的自定义 L & f。Synth L&F 是在一个 XML 文件中定义的。您需要执行以下步骤来使用 Synth L&F:

  • 创建一个 XML 文件并定义 Synth L&F。
  • 创建一个SynthLookAndFeel类的实例。SynthLookAndFeel laf = new SynthLookAndFeel();
  • 使用SynthLookAndFeel对象的load()方法从 XML 文件中加载 Synth L & F。load()方法被重载了。您可以使用 URL 或 XML 文件的输入流。laf.load(url_to_your_synth_xml_file);laf.load(input_steam_for_your_synth_xml_file, MyClass.class);
  • 使用UIManager设置合成器 L&F。UIManager.setLookAndFeel(laf);

让我们讨论一下可以用来加载 XML 文件的加载过程。合成器 L&F 可以使用两种不同的外部资源。

  • 定义 Synth L&F 的 XML 文件
  • Synth XML 文件中使用的图像等资源

当使用 URL 加载 Synth XML 文件时,URL 指向 XML 文件,XML 文件中引用的资源的所有路径都将相对于 URL 进行解析。以下代码片段使用 URL 加载 Synth XML 文件:

URL url = new URL("file:///C:/synth/synth_look_and_feel.xml");

laf.load(url);

您可以使用一个可能指向本地文件系统或网络的 URL 来加载 Synth XML 文件。您可以使用httpftp协议来加载 Synth XML 文件。还可以从 JAR 文件中加载 Synth XML 文件。

当使用load(InputStream input, Class resourceBase)方法加载 Synth XML 文件时,input参数是要加载的 XML 文件的InputStream,而resourceBase类对象用于解析 XML 文件中引用的资源。假设您在 Windows 操作系统的计算机上有以下文件夹结构:

C:\javabook

C:\javabook\images\myimage.png

C:\javabook\synth\synthlaf.xml

C:\javabook\book\chapter3\images\myimage.png

C:\javabook\book\chapter3\synth\synthlaf.xml

C:\javabook\book\chapter3\MyClass.class

假设在类路径中设置了C:\javabook,并且MyClass是在com.jdojo.chapter3包中定义的 Java 类。下面的代码片段加载了synthlaf.xml:

// It will load C:\javabook\synth\synthlaf.xml because you are

// using a forward slash in the file path "/synth/synthlaf.xml"

Class cls = MyClass.class;

InputStream ins = cls.getResourceAsStream("/synth/synthlaf.xml");

laf.load(ins, cls);

// It will load C:\javabook\book\chapter3\synth\synthlaf.xml because you are

// not using a forward slash in the file path "synthlaf.xml"

Class cls = MyClass.class;

InputStream ins = cls.getResourceAsStream("synthlaf.xml");

laf.load(ins, cls);

在这两种情况下,类引用cls将用于解析 XML 文件中引用的资源的路径。例如,如果图像被称为img/myimage.png,它将从C:\javabook\book\chapter3\images\myimage.png.加载;如果图像被称为/img/myimage.png",则加载C:\javabook\images\myimage.png文件。

使用方法的第二个版本,它更灵活。您可以将所有 Synth L&F 文件和相关的资源文件打包到一个 JAR 文件中,而不用担心它们在运行时的实际位置。在开发过程中,您可以将所有 Synth 文件放在一个单独的文件夹中,这个文件夹应该在您的类路径中。您唯一需要注意的是,如果文件名以正斜杠开头,则使用类路径解析路径。如果文件名不是以正斜杠开头,则该类的包路径会添加到文件名的前面,然后使用类路径来解析文件的路径。

让我们开始构建 Synth L&F XML 文件。在开始定义你的 Synth L&F 之前设定你的目标。图 3-5 显示了一个使用 Java 默认 L & F 的示例JFrame

A978-1-4302-6662-4_3_Fig5_HTML.jpg

图 3-5。

A sample JFrame using the default Java L&F

JFrame包含三个部件:一个JLabel、一个JTextField和一个JButton。您将构建一个 XML 文件来为这些组件定义一个 Synth L & F。创建这个屏幕的 Java 代码如清单 3-6 所示。感兴趣的代码在main()方法中(如下所示)。现在,只需创建一个名为synthlaf.xml的空 XML 文件,并将其保存在类路径中。

try {

SynthLookAndFeel laf = new SynthLookAndFeel();

Class cls = SynthLookAndFeelFrame.class;

InputStream ins = cls.getResourceAsStream("/synthlaf.xml");

laf.load(ins, cls);

UIManager.setLookAndFeel(laf);

}

catch (Exception e) {

e.printStackTrace();

}

清单 3-6。为 Swing 组件使用合成 L&F

// SynthLookAndFeelFrame.java

package com.jdojo.swing;

import java.io.InputStream;

import java.awt.Container;

import java.awt.FlowLayout;

import javax.swing.JButton;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JTextField;

import javax.swing.UIManager;

import javax.swing.plaf.synth.SynthLookAndFeel;

public class SynthLookAndFeelFrame extends JFrame {

JLabel nameLabel = new JLabel("Name:");

JTextField nameTextField = new JTextField(20);

JButton closeButton = new JButton("Close");

public SynthLookAndFeelFrame(String title) {

super(title);

initFrame();

}

private void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

Container contentPane = this.getContentPane();

contentPane.setLayout(new FlowLayout());

contentPane.add(nameLabel);

contentPane.add(nameTextField);

contentPane.add(closeButton);

}

public static void main(String[] args) {

try {

SynthLookAndFeel laf = new SynthLookAndFeel();

Class c = SynthLookAndFeelFrame.class;

InputStream ins = c.getResourceAsStream("/synthlaf.xml");

laf.load(ins, c);

UIManager.setLookAndFeel(laf);

}

catch (Exception e) {

e.printStackTrace();

}

SynthLookAndFeelFrame frame =

new SynthLookAndFeelFrame("Synth Look-and-Feel Frame");

frame.pack();

frame.setVisible(true);

}

}

最简单的 Synth XML 文件如下所示:

<?xml version="1.0"?>

<synth version="1">

</synth>

根元素是<synth>,你可以选择指定一个版本号,应该是 1。您尚未在 XML 文件中定义任何与 L & F 相关的样式。让我们用synthlaf.xml文件中的这些内容运行SynthLookAndFeelFrame类。如果您在运行该类时遇到问题,因为它没有找到synthlaf.xml文件,请更改main()方法中的load()方法调用,以使用 URL 而不是InputStream。图 3-6 显示了运行SynthLookAndFeelFrame类时得到的JFrame

A978-1-4302-6662-4_3_Fig6_HTML.jpg

图 3-6。

A JFrame with a Synth L&F where the Synth XML file does not define any styles

你没想到会这样,是吗?你马上就能修好它。默认情况下,Synth L & F 为所有组件设置一个没有边框的白色背景。这就是为什么JLabelJTextFieldJButton一起出现在屏幕上的原因。一个JTextField仍然在屏幕上,但是它没有边框。

我们来定义一种风格。使用<style>元素定义样式。它有一个名为id的强制属性,这是样式的唯一标识符。将样式绑定到组件时,会用到id属性的值。

<?xml version="1.0"?>

<synth version="1">

<style id="buttonStyle">

<!-- Style specific elements go here -->

</style>

</synth>

定义样式本身没有任何作用。您必须将一个样式绑定到一个或多个组件,才能看到该样式的实际效果。将一个样式绑定到一个组件是使用一个<bind>元素完成的,它有三个属性:

  • style
  • type
  • key

style是您绑定到该组件的样式元素的id属性的值。

属性确定绑定的类型。它的值不是region就是name。每个 Swing 部件具有至少一个区域。有些组件有多个区域。组件的所有区域都有一个名称。区域由javax.swing.plaf.synth包中的Region类中的常量定义。例如,JButton有一个名为Button的区域,由Region.BUTTON常数表示;一个JTextField有一个名为TextField的区域,由Region.TEXT_FIELD常量表示;一个JTabbedPane有四个区域,分别称为TabbedPaneContentTabbedPaneTabAreaTabbedPaneTabTabbedPane。请参考Region类的文档以获得完整的区域列表。如果使用值name,则引用组件的getName()方法返回的值。您可以使用组件的setName()方法为其设置一个名称。

该属性是一个正则表达式,用于根据用于type属性的值来匹配区域或名称。例如,正则表达式".*"匹配任何地区或名称。通常,您使用",*"作为key值来将默认样式绑定到所有组件。

下面是一些使用<bind>元素将样式绑定到组件的例子:

<!-- Bind a buttonStyle style to all JButtons -->

<bind style="buttonStyle" type="region" key="Button" />

<!-- Bind a defaultStyle to all Swing components -->

<bind style="defaultStyle" type="region" key=".*" />

<!-- Bind myDefaultStyle to all components whose name returned by their getName() method starts with "com.jdojo". Here \. means one dot and .* means any characters zero or more times -->

<bind style="mydefaultStyle" type="name" key="com\.jdojo.*" />

让我们为一个JButton定义一些样式。所有样式必须在一个<style>元素中定义。您可以使用<opaque>元素设置样式的不透明度。它有一个可能为真或假的value属性,如下所示:

<opaque value="true"/>

组件可以处于以下七种状态之一:ENABLEDMOUSE_OVERPRESSEDDISABLEDFOCUSEDSELECTEDDEFAULT。并非所有组件都支持所有七种状态。您可以定义应用于特定状态或所有状态的样式属性。您可以使用元素定义特定于状态的属性。如果样式属性仅适用于特定的状态,则需要用七个状态值中的一个来指定 value 属性。如果您想要为多个状态定义一些样式属性,您可以用一个AND来分隔状态名称。下面的<style>元素将为一个组件定义当鼠标在它上面并且它也是焦点时的样式:

<state value="MOUSE_OVER AND FOCUSED">

...

</state>

如果同一个状态存在多个样式,则使用与最特定的状态关联的样式定义。假设您已经为两种状态定义了样式:MOUSE_OVERFOCUSED以及MOUSE_OVER。当组件的区域上有鼠标并且它是焦点时,应用第一种样式;如果组件不在焦点上,但是鼠标在它的区域上,则应用第二种样式。

用显示的内容修改synthlaf.xml文件,并重新运行应用:

<?xml version="1.0"?>

<synth version="1">

<style id="buttonStyle">

<opaque value="true"/>

<insets top="4" bottom="4" left="6" right="6"/>

<imageIcon id="closeIconId" path="/img/close_icon.png"/>

<property key="Button.textShiftOffset" type="Integer" value="2"/>

<property key="Button.icon" type="idref" value="closeIconId"/>

<state>

<font name="Serif" size="14" style="BOLD"/>

<color value="LIGHT_GRAY" type="BACKGROUND"/>

<color value="BLACK" type="TEXT_FOREGROUND"/>

</state>

<state value="PRESSED">

<color value="GRAY" type="BACKGROUND"/>

<color value="BLACK" type="TEXT_FOREGROUND"/>

</state>

</style>

<bind style="buttonStyle" type="region" key="Button"/>

</synth>

按下Close键,你会发现比以前好用多了。当你按下它时,它的背景颜色会改变。当它被按下时,它的文本向右和向下移动。

让我们讨论一下这个 XML 文件中使用的所有样式:

  • 样式定义了一个JButton的样式。元素定义了JButton将是不透明的。<insets>元素为JButton设置插图。
  • 元素定义了一个图像资源。这个元素本身不做任何事情。当您需要使用图像时,您将需要在其他地方引用它的id属性的值。它的path属性指的是图像文件的路径。它使用您传递给load()方法的类对象的getResource()方法来定位图像文件。你使用了/img/close_icon.png作为路径。这意味着您需要在一个文件夹下有一个名为images的文件夹,该文件夹在类路径中,并且您需要在images文件夹下放置一个close_icon.png文件。如果您使用 URL 来加载 synth XML 文件,那么图像的路径也会相应地改变。假设您使用 URL 字符串"file:///c:/mysynth/synthlaf.xml"加载了一个 Synth XML 文件。这个 URL 以file:///c:/mysynth/为基础,XML 中的所有路径都将相对于这个基础进行解析。例如,如果您将img/close_icon.png指定为<imageIcon>元素中的路径,file:///c:/mysynth/img/close_icon.png将是用于加载图像文件的路径。如果您将/img/close_icon.png指定为<imageIcon>元素中的路径,它将被视为绝对路径,Synth 将尝试使用file://img/close_icon.png路径加载图像文件。理解使用不同版本的SynthLookAndFeel类的load()方法对资源查找的影响是非常重要的。最好使用 URL,并将所有资源放在 URL 的基本文件夹下。您可以将包括 Synth XML 文件在内的所有资源打包到一个 JAR 文件中,并使用一个 URL 版本的load()方法。

元素用于设置组件的属性。不能使用<property>元素设置组件的任何属性。一个<property>元素有三个属性:keytypevaluekey属性指定属性名。type属性是属性的类型,其值可以是idrefbooleandimensioninsetsintegerstringtype属性是可选的,默认为idref,这意味着value属性的值是引用另一个元素的id。您已经为JButton设置了两个属性。一个是Button.textShiftOffset属性,用于在JButton被按下时移动其文本。另一个属性是称为Button.iconJButton的图像图标。您没有指定type属性,默认为idref<property>元素的value属性是closeIconId,它是定义近景图像的<imageIcon>元素的id

您可以使用元素定义颜色属性。您设置了一个<color>元素的typevalue属性的值。type属性可以有以下四个值之一:FOREGROUNDBACKGROUNDTEXT_FOREGROUNDTEXT_BACKGROUNDFOCUS。您可以使用来自java.awt.Color类的常量名称或#RRGGBB#AARRGGBB形式的十六进制值来指定value属性的值。在十六进制格式中,AARRGGBB是颜色的 alpha、红色、绿色和蓝色分量的值。

您可以使用<font>元素定义字体样式。它有三个属性:namesizestylestyle属性是可选的,默认为PLAINstyle属性的其他值是BOLDITALIC

最后,您组合不同的样式,并将它们放在一个<state>元素下。在您的buttonStyle中,您已经为所有状态设置了一组样式,为PRESSED状态设置了一组样式。请注意,默认情况下,JButton的背景颜色为LIGHT_GRAY。按下时其背景颜色会变为GRAY。当您使用这个 XML 文件运行SynthLookAndFeel类时,屏幕看起来如图 3-7 所示。请注意,您已经为Close按钮设置了一个图标。当您按下Close按钮时,背景颜色会改变。

A978-1-4302-6662-4_3_Fig7_HTML.jpg

图 3-7。

Using an icon with the Synth look and feel

您没有JButtonJTextField的边框。在 Synth 中设置边框有两种方法:可以使用图像或编写 Java 代码。我将讨论设置边界的两种方法。如果你想用一个图像来画一个边框,你需要使用一个<imagePainter>元素,如下所示:

<imagePainter path="/img/line_border.png"

sourceInsets="2 2 2 2"

paintCenter="false"

method="buttonBorder" />

path 属性指定用于绘制边框的图像的路径。属性指定了源图像的插入。painterCenter属性指定是应该画图像的中心还是只画边界。如果你想画一个边框,你应该把这个属性设置为false。如果要绘制一个图像作为背景,应该将这个属性设置为truemethod属性是javax.swing.plaf.synth.SynthPainter类中绘制方法的名称。这个类有一个 paint 方法来绘制每个组件。方法名的形式是paintXxxYyy(),其中Xxx是组件名,Yyy是要绘制的区域。通过去掉“paint”一词并使用小写的第一个字符,method 属性的值被设置为xxxYyy。例如,要绘制按钮的边框,绘制方法名为paintButtonBorder()。此方法的方法属性值为buttonBorder。您还可以使用<imagePainter>元素将图像设置为组件的背景。以下样式将button_background.png设置为JButton的背景:

<imagePainter path="/img/button_background.png"

sourceInsets="2 2 2 2"

paintCenter="true"

method="buttonBackground" />

Tip

默认情况下,<imagePainter>元素中使用的图像被拉伸以适合组件的大小。这意味着,如果您希望多个组件周围有相同的边界,您只需要创建一个图像来表示该边界。如果不希望图像被拉伸,可以将<imagePainter>元素的 stretch 属性设置为 false。

如果你想写 Java 代码来画一个边框,你需要创建一个新的类,它将继承清单 3-7 中列出的SynthPainter类。您需要覆盖特定的绘制方法。这个类覆盖了paintTextFieldBorder()paintButtonBorder()方法。他们只是使用自定义颜色和笔画值绘制一个矩形。

清单 3-7。用于 JTextField 和 JButton 的自定义 Synth 边框绘制器类

// SynthRectBorderPainter.java

package com.jdojo.swing;

import javax.swing.plaf.synth.SynthPainter;

import javax.swing.plaf.synth.SynthContext;

import java.awt.Graphics;

import java.awt.Graphics2D;

import java.awt.BasicStroke;

import java.awt.Color;

public class SynthRectBorderPainter extends SynthPainter {

@Override

public void paintTextFieldBorder(SynthContext context, Graphics g,

int x, int y, int w, int h) {

Graphics2D g2 = (Graphics2D)g;

g2.setStroke(new BasicStroke(2));

g2.setColor(Color.BLUE);

g2.drawRect(x, y, w, h);

}

@Override

public void paintButtonBorder(SynthContext context, Graphics g,

int x, int y, int w, int h) {

Graphics2D g2 = (Graphics2D)g;

g2.setStroke(new BasicStroke(4));

g2.setColor(Color.RED);

g2.drawRect(x, y, w, h);

}

}

现在,您需要在 Synth XML 文件中指定您想要使用自定义 painter 类来绘制您的JButton的边框。一个<object>元素表示 Synth XML 文件中的一个 Java 对象。要指定一个定制的 Java 画师,您可以使用一个<painter>元素,它需要一个<object>元素的id和一个method名称的idref,如下所示:

<object id="borderPainterId" class="com.jdojo.swing.SynthRectBorderPainter"/>

<painter idref="borderPainterId" method="buttonBorder"/>

Synth XML 文件的最终版本如下所示。您已经使用了一个定制的 Java 代码来绘制按钮被按下时的边框,以及没有被按下时的图像图标。使用您的定制 Java 代码来绘制JTextField的边框。您可以修改 XML 内容来为JLabel设置样式。最后,JFrame如图 3-8 所示。

<?xml version="1.0"?>

<synth version="1.0">

<style id="buttonStyle">

<opaque value="true"/>

<insets top="4" bottom="4" left="6" right="6"/>

<imageIcon id="closeIconId" path="/img/close_icon.png"/>

<property key="Button.textShiftOffset" type="Integer" value="2"/>

<property key="Button.icon" type="idref" value="closeIconId"/>

<state>

<imagePainter path="/img/line_border.png" sourceInsets="2 2 2 2"

paintCenter="false" method="buttonBorder"/>

<font name="Serif" size="14" style="BOLD"/>

<color value="LIGHT_GRAY" type="BACKGROUND"/>

<color value="BLACK" type="TEXT_FOREGROUND"/>

</state>

<state value="PRESSED">

<object id="borderPainterId"

class="com.jdojo.swing.SynthRectBorderPainter"/>

<painter idref="borderPainterId" method="buttonBorder"/>

<color value="GRAY" type="BACKGROUND"/>

<color value="BLACK" type="TEXT_FOREGROUND"/>

</state>

</style>

<bind style="buttonStyle" type="region" key="Button"/>

<style id="textFieldStyle">

<insets top="4" bottom="4" left="4" right="4"/>

<state>

<color value="WHITE" type="BACKGROUND"/>

<object id="textFieldPainterId" class="com.jdojo.swing.SynthRectBorderPainter"/>

<painter idref="textFieldPainterId" method="textFieldBorder"/>

</state>

</style>

<bind style="textFieldStyle" type="region" key="TextField"/>

</synth>

A978-1-4302-6662-4_3_Fig8_HTML.jpg

图 3-8。

Using borders in a Synth L&F

拖放

拖放(DnD)是在应用中传输数据的一种方式。您也可以使用带有剪切、复制和粘贴操作的剪贴板来传输数据。

DnD 允许你通过拖拽一个组件到另一个组件上来传输数据。被拖动的组件称为拖动源;它提供要传输的数据。拖动源放在其上的组件称为放置目标;它是数据的接收者。接受拖放动作并导入拖动源提供的数据是拖放目标的责任。使用Transferable对象完成数据传输。Transferablejava.awt.datatransfer包中的一个接口。DnD 机构如图 3-9 所示。

A978-1-4302-6662-4_3_Fig9_HTML.jpg

图 3-9。

The data transfer mechanism used in DnD

Transferable接口包含以下三种方法:

  • DataFlavor[] getTransferDataFlavors()
  • boolean isDataFlavorSupported(DataFlavor flavor)
  • Object getTransferData(DataFlavor flavor) throws UnsupportedFlavorException, IOException

在您学习Transferable接口的三个方法之前,您需要知道为什么您需要一个Transferable对象来使用 DnD 传输数据。为什么拖动目标不直接从拖动源获取数据?您可以使用 DnD 在同一个 Java 应用内、两个 Java 应用之间、从本地应用到 Java 应用以及从 Java 应用到本地应用传输数据。数据传输的范围非常广,它支持多种数据的传输。接口提供了一种将数据及其类型打包到对象中的机制。接收方可以向该对象查询它保存的数据类型,如果数据符合接收方的要求,就导入数据。DataFlavor类的一个对象代表了数据的细节。DataFlavor类我就不详细讨论了。它包含几个常量来定义数据的类型;例如,DataFlavor.stringFlavor代表 Java 的 Unicode 字符串类。Transferable接口的前两个方法给出了关于数据的细节。第三个函数返回数据本身作为一个Object。拖放目标将使用getTransferData()方法获取拖动源提供的数据。

在 Swing 中使用 DnD 很容易。大多数时候,你只需要写一行代码就可以开始使用 DnD。您所需要的就是在组件上启用拖动,就像这样:

// Enable DnD for myComponent

myComponent.setDragEnabled(true);

之后,你就可以开始使用 DnD 了。使用 DnD 依赖于用户界面。在 Windows 平台上,您需要在拖动源上按下鼠标左键来启动拖动动作。要持续拖动拖动源,您需要在按住鼠标左键的同时移动鼠标。当鼠标指针在放置目标上时,释放鼠标左键执行放置操作。在整个 DnD 过程中,用户会收到视觉反馈。

所有文本组件(JFileChooserJColorChooserJListJTreeJTable)都内置了对 DnD 的拖动支持。所有文本组件和JColorChooser都内置了对 DnD 的拖放支持。例如,假设你有一个名为nameFldJTextField和一个名为descTxtAreaJTextArea。要开始在它们之间使用 DnD,您需要编写下面两行代码:

nameFld.setDragEnabled(true);

descTxtArea.setDragEnabled(true);

您可以选择JTextField中的文本,拖动它,并将其放到JTextArea上。在JTextField中选择的文本被传送到JTextArea。您也可以将文本从JTextArea拖到JTextField

数据是如何从一个文本组件传输到另一个文本组件的?它会被复制或移动吗?答案取决于拖动源和用户的动作。拖动源声明它支持的动作。用户的动作决定了发生了什么动作。例如,在 Windows 平台上,简单的拖动表示一个MOVE动作,而按住Ctrl键拖动表示一个Copy动作,按住Ctrl + Shift键拖动表示一个LINK动作。动作由类中声明的常数表示:

  • TranferHandler.COPY
  • TranferHandler.MOVE
  • TranferHandler.COPY_OR_MOVE
  • TranferHandler.LINK
  • TranferHandler.NONE

对于JListJTableJTree组件,拖放动作不是内置的。原因是当拖动源放到这些组件上时,无法预测用户的意图。您需要编写代码来为这些组件设置拖放动作。请注意,它们内置了对拖动动作的支持。DnD 为您提供关于这些组件的放置位置的适当信息。这些组件允许您使用它们的setDropMode(DropMode dm)方法指定拖放模式。放置模式决定了在 DnD 操作期间如何跟踪放置位置。丢弃模式由表 3-1 中列出的java.swing.DropMode枚举中的常数表示。

表 3-1。

The List of DropMode Enum Contants for JList, JTree, and JTable

| DropMode 枚举常量 | 使用组件 | 描述 | | --- | --- | --- | | `ON` | `JList``JTree` | 使用现有项目的索引来跟踪放置位置。 | | `INSERT` | `JList``JTree` | 放置位置被跟踪为数据将被插入的位置。 | | `INSERT_COLS` | `JTable` | 根据将插入新列的列索引来跟踪放置位置。 | | `INSERT_ROWS` | `JTable` | 根据将要插入新行的行索引来跟踪放置位置。 | | `ON_OR_INSERT` | `JList``JTree` | 将放置位置作为`ON`和`INSERT`进行跟踪。 | | `ON_OR_INSERT_ROWS` `ON_OR_INSERT_COLS` | `JTable` | 用期望行或列跟踪`ON`或`INSERT`。 | | `USE_SELECTION` | `JList``JTree` | 其工作原理与`ON`相同。这是默认的丢弃模式。如果拖动到已经选定的组件上,此模式会将选择更改为鼠标光标正在拖动的项目。然而,`ON` drop 模式保持用户的选择不变,并临时选择鼠标光标所拖动的项目。`ON`是用户体验更好的选择。提供此选项只是为了向后兼容。 |

让我们写一些代码来使用带有JList的 DnD。您需要执行以下操作:

  • 创建一个继承自javax.swing.TransferHandler类的新类。
  • 重写新类中的一些方法来处理数据传输。
  • 使用JListsetTransferHandler()方法来设置传输处理程序类的一个实例。

清单 3-8 包含了自定义JList的代码。

清单 3-8。JList 的自定义 TransferHandler

// ListTransferHandler.java

package com.jdojo.swing;

import java.awt.datatransfer.DataFlavor;

import java.awt.datatransfer.StringSelection;

import java.awt.datatransfer.Transferable;

import java.awt.datatransfer.UnsupportedFlavorException;

import java.io.IOException;

import javax.swing.DefaultListModel;

import javax.swing.JComponent;

import javax.swing.JList;

import javax.swing.TransferHandler;

public class ListTransferHandler extends TransferHandler {

@Override

public int getSourceActions(JComponent c) {

return TransferHandler.COPY_OR_MOVE;

}

@Override

protected Transferable createTransferable(JComponent source) {

// Suppress the unchecked cast warning

@SuppressWarnings("unchecked")

JList<String> sourceList = (JList<String>)source;

String data = sourceList.getSelectedValue();

// Uses only the first selected item in the list

Transferable t = new StringSelection(data);

return t;

}

@Override

protected void exportDone(JComponent source, Transferable data, int action) {

// Suppress teh unchecked cast warning

@SuppressWarnings("unchecked")

JList<String> sourceList = (JList<String>)source;

String movedItem = sourceList.getSelectedValue();

if (action == TransferHandler.MOVE) {

// Remove the moved item

DefaultListModel<String> listModel

= (DefaultListModel<String>) sourceList.getModel();

listModel.removeElement(movedItem);

}

}

@Override

public boolean canImport(TransferHandler.TransferSupport support) {

// We only support drop, not copy-paste

if (!support.isDrop()) {

return false;

}

return support.isDataFlavorSupported(DataFlavor.stringFlavor);

}

@Override

public boolean importData(TransferHandler.TransferSupport support) {

// This is necessary to handle paste

if (!this.canImport(support)) {

return false;

}

// Get the data

Transferable t = support.getTransferable();

String data = null;

try {

data = (String) t.getTransferData(DataFlavor.stringFlavor);

if (data == null) {

return false;

}

}

catch (UnsupportedFlavorException | IOException e) {

e.printStackTrace();

return false;

}

// Get the drop location for the JList

JList.DropLocation dropLocation

= (JList.DropLocation) support.getDropLocation();

int dropIndex = dropLocation.getIndex();

// Suppress the unchecked cast warning

@SuppressWarnings("unchecked")

JList<String> targetList = (JList<String>)support.getComponent();

DefaultListModel<String> listModel

= (DefaultListModel<String>)targetList.getModel();

if (dropLocation.isInsert()) {

listModel.add(dropIndex, data);

}

else {

listModel.set(dropIndex, data);

}

return true;

}

}

如果您想只支持一个JList的放下动作,您只需要在您的传输处理程序类中覆盖两个方法:canImport()importData()。如果拖放目标想要传输数据,canImport()方法返回true。否则返回false。在您的代码中,您要确保该操作是拖放操作,并且拖动源提供字符串数据。注意,如果你为一个组件设置一个自定义的TransferHandler对象,同样的TransferHandler对象也将用于剪切-复制-粘贴操作。您的代码仅支持拖放操作。importData()方法从Transferable对象中读取数据,并根据用户的动作在JList中插入或替换项目。

JList的默认TransferHandler处理拖动动作并提供数据。然而,一旦你设置了你自己的TransferHandler,你就失去了默认的特性,你要负责把那个特性添加到你的TransferHandler中。如果你想支持拖动动作,你需要为createTransferable()getSourceActions()方法编写定制代码。第一个方法将数据打包成一个Transferable对象,第二个方法返回拖动源支持的动作类型。StringSelectionTransferable接口的实现,用于传输 Java 字符串。

如果你的拖动源支持一个MOVE动作,你应该提供代码在移动动作之后移除该项。您得到一个占位符来在exportDone()方法中编写清理代码,如清单 3-9 所示。

清单 3-9 中的代码显示了一个JTextField和两个JLists,这让您可以为 JList 演示 DnD。图 3-10 显示了运行清单 3-9 中的程序时得到的JFrame。你可以在三个组件中的任何一个中使用 DnD:?? 和两个 ??。代码中有一个错误。如果您在JList中拖动一个项目,并将其放在同一个JList中,什么也不会发生。这是留给你的一个练习,让你找出这个错误并修复它。我给你一个提示:在将元素添加到ListTransferHandler类的importData()方法中的同一个List之前,尝试移除该元素。此外,这个定制代码只支持JList中的单一选择。您可以在ListTransferHandler类中定制代码,以处理JList中的多重选择。

清单 3-9。使用 DnD 在 Swing 组件之间传输数据

// DragAndDropApp.java

package com.jdojo.swing;

import java.awt.BorderLayout;

import java.awt.Container;

import javax.swing.Box;

import javax.swing.DefaultListModel;

import javax.swing.DropMode;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JList;

import javax.swing.JScrollPane;

import javax.swing.JTextField;

import javax.swing.ListSelectionModel;

import javax.swing.SwingUtilities;

public class DragAndDropApp extends JFrame {

private JLabel newLabel = new JLabel("New:");

private JTextField newTextField = new JTextField(10);

private JLabel sourceLabel = new JLabel("Source");

private JLabel destLabel = new JLabel("Destination");

private JList<String> sourceList = new JList<>(new DefaultListModel<>());

private JList<String> destList = new JList<>(new DefaultListModel<>());

public DragAndDropApp(String title) {

super(title);

populateList();

initFrame();

}

private void initFrame() {

Container contentPane = this.getContentPane();

Box nameBox = Box.createHorizontalBox();

nameBox.add(newLabel);

nameBox.add(newTextField);

Box sourceBox = Box.createVerticalBox();

sourceBox.add(sourceLabel);

sourceBox.add(new JScrollPane(sourceList));

Box destBox = Box.createVerticalBox();

destBox.add(destLabel);

destBox.add(new JScrollPane(destList));

Box listBox = Box.createHorizontalBox();

listBox.add(sourceBox);

listBox.add(destBox);

Box allBox = Box.createVerticalBox();

allBox.add(nameBox);

allBox.add(listBox);

contentPane.add(allBox, BorderLayout.CENTER);

// Our lists support only single selection

sourceList.setSelectionMode(

ListSelectionModel.SINGLE_SELECTION);

destList.setSelectionMode(

ListSelectionModel.SINGLE_SELECTION);

// Enable Drag and Drop for components

newTextField.setDragEnabled(true);

sourceList.setDragEnabled(true);

destList.setDragEnabled(true);

// Set the drop mode to Insert

sourceList.setDropMode(DropMode.INSERT);

destList.setDropMode(DropMode.INSERT);

// Set the transfer handler

sourceList.setTransferHandler(new ListTransferHandler());

destList.setTransferHandler(new ListTransferHandler());

}

public void populateList() {

DefaultListModel<String> sourceModel

= (DefaultListModel<String>) sourceList.getModel();

DefaultListModel<String> destModel

= (DefaultListModel<String>) destList.getModel();

for (int i = 0; i < 5; i++) {

sourceModel.add(i, "Source Item " + i);

destModel.add(i, "Destination Item " + i);

}

}

public static void main(String[] args) {

SwingUtilities.invokeLater(() -> {

DragAndDropApp frame = new DragAndDropApp("Drag and Drop Frame");

frame.pack();

frame.setVisible(true);

});

}

}

A978-1-4302-6662-4_3_Fig10_HTML.jpg

图 3-10。

A JFrame with a few Swing components supporting DnD

多文档界面应用

从广义上讲,根据窗口在应用中的组织方式,有三种类型的应用向用户显示信息。他们是

  • 单一文档界面(SDI)
  • 多文档界面
  • 选项卡式文档界面(TDI)

在 SDI 应用中,任何时候都只打开一个窗口。在 MDI 应用中,打开一个主窗口(也称为父窗口),并且在主窗口中打开多个子窗口。在 TDI 应用中,打开一个窗口,它有多个作为选项卡打开的窗口。Microsoft Notepad 是 SDI 应用的一个示例,Microsoft Word 97 是 MDI 应用的一个示例(Microsoft Word 的较新版本是 SDI),Google Chrome 浏览器是 TDI 应用的一个示例。

您可以使用 Swing 开发 SDI、MDI 和 TDI 应用。在 MDI 应用中,您可以打开多个框架,这些框架将成为JInternalFrame类的实例。您可以用多种方式组织多个内部框架。比如,你可以最大化和最小化它们;您可以以平铺方式并排查看它们,也可以以层叠形式查看它们。下面是您将在 MDI 应用中使用的四个类:

  • JInternalFrame
  • JDesktopPane
  • DesktopManager
  • JFrame

类别的执行个体做为子视窗,永远显示在其父视窗的区域内。在很大程度上,使用它和使用JFrame是一样的。您将 Swing 组件添加到其内容窗格中,使用pack()方法打包它们,并使用setVisible(true)方法使其可见。如果你想监听窗口事件,如激活,停用等。,你需要给JInternalFrame加一个InternalFrameListener而不是一个WindowListener,?? 是用来做JFrame的。您可以在其构造函数中或使用 setter 方法设置各种属性。以下代码片段显示了如何使用JInternalFrame类的实例:

String title = "A Child Window";

Boolean resizable = true;

Boolean closable = true;

Boolean maximizable = true;

Boolean iconifiable = true;

JInternalFrame iFrame =

new JInternalFrame(title, resizable, closable, maximizable, iconifiable);

// Add components to the iFrame using iFrame.add(...)

// Pack eth frame and make it visible

iFrame.pack();

iFrame.setVisible(true);

该类的一个实例被用作作为JInternalFrame类实例的所有子窗口的容器(而不是顶级容器)。它使用一个null布局管理器。你把它加到一个JFrame里。您希望将对桌面窗格的引用作为实例变量存储在JFrame中,以便以后可以使用它来处理子窗口。

// Create a desktop pane

JDesktopPane desktopPane = new JDesktopPane();

// Add all JInternalFrames to the desktopPane

desktopPane.add(iFrame);

您可以使用getAllFrames()方法获取添加到JDesktopPane中的所有JInternalFrames

// Get the list of child windows

JInternalFrame[] frames = desktopPane.getAllFrames();

一个JDesktopPane使用接口的一个实例来管理所有的内部框架。DefaultDesktopManager类是DesktopManager接口的一个实现。如果您想定制桌面管理器管理内部框架的方式,您需要创建自己的从DefaultDesktopManager继承的类。您可以使用JDesktopPanesetDesktopManager()方法设置您的自定义桌面管理器。桌面管理器有很多有用的方法。例如,如果您想以编程方式关闭一个内部框架,您可以使用它的closeFrame()方法。如果您使内部框架可关闭,用户也可以使用提供的上下文菜单来关闭它。您可以使用桌面窗格的getDesktopManager()方法获得桌面管理器的引用。

// Close the internal frame named frame1

desktopPane.getDesktopManager().closeFrame(frame1);

该类被用作顶级容器,并充当JInternalFrame s 的父窗口。它包含一个JDesktopPane的实例。请注意,JFramepack()方法在 MDI 应用中不会有任何好处,因为它的独生子,桌面窗格,使用了一个null布局管理器。您必须显式设置其大小。通常,您会最大化显示JFrame

清单 3-10 展示了如何开发 MDI 应用。Swing 没有提供将内部框架组织成平铺或层叠窗口的方法,这在任何基于 windows 的 MDI 应用中都很常见。通过应用简单的逻辑来组织内部框架并提供菜单项来使用它们,您可以将平铺和层叠功能构建到 Swing MDI 应用中。图 3-11 显示了运行清单 3-10 中的程序时显示的屏幕。

清单 3-10。使用 Swing 开发 MDI 应用

// MDIApp.java

package com.jdojo.swing;

import java.awt.BorderLayout;

import java.awt.Dimension;

import javax.swing.JDesktopPane;

import javax.swing.JFrame;

import javax.swing.JInternalFrame;

import javax.swing.JLabel;

import javax.swing.SwingUtilities;

import javax.swing.UIManager;

public class MDIApp extends JFrame {

private final JDesktopPane desktopPane = new JDesktopPane();

public MDIApp(String title) {

super(title);

initFrame();

}

public void initFrame() {

JInternalFrame frame1

= new JInternalFrame("Frame 1", true, true, true, true);

JInternalFrame frame2

= new JInternalFrame("Frame 2", true, true, true, true);

JLabel label1 = new JLabel("Frame 1 contents...");

frame1.getContentPane().add(label1);

frame1.pack();

frame1.setVisible(true);

JLabel label2 = new JLabel("Frame 2 contents...");

frame2.getContentPane().add(label2);

frame2.pack();

frame2.setVisible(true);

// Default location is (0,0) for a JInternalFrame.

// Set the location of frame2, so that both frames are visible

int x2 = frame1.getX() + frame1.getWidth() + 10;

int y2 = frame1.getY();

frame2.setLocation(x2, y2);

// Add both internal frames to the desktop pane

desktopPane.add(frame1);

desktopPane.add(frame2);

// Finally add the desktop pane to the JFrame

this.add(desktopPane, BorderLayout.CENTER);

// Need to set minimum size for the JFrame

this.setMinimumSize(new Dimension(300, 300));

}

public static void main(String[] args) {

try {

// Set the system look and feel

UIManager.setLookAndFeel(

UIManager.getSystemLookAndFeelClassName());

}

catch (Exception e) {

e.printStackTrace();

}

SwingUtilities.invokeLater(() -> {

MDIApp frame = new MDIApp("MDI Frame");

frame.pack();

frame.setVisible(true);

frame.setExtendedState(frame.MAXIMIZED_BOTH);

});

}

}

A978-1-4302-6662-4_3_Fig11_HTML.jpg

图 3-11。

An MDI application in Swing run on Windows

当您使用 MDI 应用时,您需要使用JOptionPaneshowInternalXxxDialog()方法,而不是showXxxDialog()方法。例如,在一个 MDI 应用中,您使用JOptionPane.showInternalMessageDialog()方法来代替JOptionPane.showMessageDialog()showInternalXxxDialog()版本显示对话框,所以它们总是显示在顶层容器中,而showXxxDialog()版本显示一个对话框,它可以被拖动到 MDI 应用顶层容器的边界之外。

Tip

重要的是预先决定你是否想要开发一个 SDI、MDI 或 TDI 应用。从一种类型转换到另一种类型不是一件容易的事情。

工具箱类

Java 需要与本地系统通信,以提供大多数基本的 GUI 功能。它在每个平台上使用一个特定的类来实现这一点。java.awt.Toolkit是一个抽象类。Java 使用每个平台上的Toolkit类的一个子类与本地工具包系统通信。Toolkit类提供了一个静态的getDefaultToolkit()工厂方法来获取在特定平台上使用的工具箱对象。这个Toolkit类包含了一些有用的方法,可以让你调整屏幕尺寸和分辨率,访问系统剪贴板,发出嘟嘟声等等。表 3-2 列出了Toolkit类的一些方法。该表包含通过 HeadlessExceotion 的方法。当在不支持键盘、显示器或鼠标的环境中调用依赖于键盘、显示器或鼠标的代码时,将引发 HeadlessException。

表 3-2。

The List of a Few Useful Methods of the java.awt.Toolkit Class

| 工具包类的方法 | 描述 | | --- | --- | | `abstract void beep()` | 发出嘟嘟声。当应用中出现严重错误时,它有助于提醒用户。 | | `static Toolkit getDefaultToolkit()` | 返回应用中使用的当前`Toolkit`实例。 | | `abstract int getScreenResolution() throws HeadlessException` | 以每英寸点数的形式返回屏幕分辨率。 | | `abstract Dimension getScreenSize() throws HeadlessException` | 返回一个包含屏幕宽度和高度的`Dimension`对象,以像素为单位。 | | `abstract Clipboard getSystemClipboard() throws HeadlessException` | 返回代表系统剪贴板的`Clipboard`类的实例。 |

下面的代码片段展示了一些如何使用Toolkit类的例子:

/* Copy the selected text from a JTextArea named dataTextArea to the system clipboard.

If there is no text selection, beep and display a message.

*/

Toolkit toolkit = Toolkit.getDefaultToolkit();

String data = dataTextArea.getSelectedText();

if (data == null || data.equals("")) {

toolkit.beep();

JOptionPane.showMessageDialog(null, "Please select the text to copy.");

}

else {

Clipboard clipboard = toolkit.getSystemClipboard();

// Pack data as a string in a Transferable object

Transferable transferableData = new StringSelection(data);

clipboard.setContents(transferableData, null);

}

/* Paste text from the system clipboard to a TextArea, named dataTextArea.

If there is no text in the system clipboard, beep and display a message.

*/

Toolkit toolkit = Toolkit.getDefaultToolkit();

Clipboard clipboard = toolkit.getSystemClipboard();

Transferable data = clipboard.getContents(null);

if (data != null && data.isDataFlavorSupported(DataFlavor.stringFlavor)) {

try {

String text = (String)data.getTransferData(DataFlavor.stringFlavor);

dataTextArea.replaceSelection(text);

}

catch (Exception e) {

e.printStackTrace();

}

}

else {

toolkit.beep();

JOptionPane.showMessageDialog(null, "No text in the system clipboard to paste");

}

/* Set the size of a JFrame to the size of the screen. Note that you can also use the

frame.setExtendedState(JFrame.MAXIMIZED_BOTH) method to use full screen area for a Jframe.

*/

JFrame frame = new JFrame("My Frame");

frame.setSize(Toolkit.getDefaultToolkit().getScreenSize());

使用 JLayer 装饰组件

JLayer类表示一个 Swing 组件。它用于修饰另一个组件,该组件称为目标组件。它允许您在它修饰的组件上执行自定义绘制。它还可以接收在其边界内生成的所有事件的通知。换句话说,JLayer允许您基于它所修饰的组件中发生的事件来执行定制处理。

当您使用JLayer类时,您还需要使用LayerUI类。一个JLayer将它的工作委托给一个LayerUI进行自定义绘制和事件处理。要使用JLayer做任何有意义的事情,您需要创建LayerUI类的子类,并覆盖其适当的方法来编写您的代码。

在 Swing 应用中使用JLayer需要以下步骤。

Create a subclass of the LayerUI class. Override its various methods to implement the custom processing for the component. The LayerUI class takes a type parameter that is the type of the component it will work with.   Create an object of the LayerUI subclass.   Create a Swing component (target component) that you want to decorate with a JLayer such as a JTextField, a JPanel, etc.   Create an object of the JLayer class, passing the target component and the object of the LayerUI subclass to its constructor.   Add the JLayer object to your container, not the target component.

让我们来看一个JLayer的动作。假设你想用一个JLayer在一个JTextField组件周围画一个蓝色的矩形边框。第一步是创建LayerUI的子类。清单 3-11 包含了从LayerUI类继承而来的BlueBorderUI类的代码。它覆盖了LayerUI类的paint()方法。

清单 3-11。LayerUI 类的子类,用于在图层周围绘制蓝色边框

// BlueBorderUI.java

package com.jdojo.swing;

import java.awt.Color;

import java.awt.Graphics;

import java.awt.Graphics2D;

import javax.swing.JComponent;

import javax.swing.JTextField;

import javax.swing.plaf.LayerUI;

public class BlueBorderUI extends LayerUI<JTextField> {

@Override

public void paint(Graphics g, JComponent layer) {

// Let the superclass paint the component first

super.paint(g, layer);

// Create a copy of the Graphics object

Graphics gTemp = (Graphics2D) g.create();

// Get the dimension of the layer

int width = layer.getWidth();

int height = layer.getHeight();

// Draw a blue rectangle that is custom your border

gTemp.setColor(Color.BLUE);

gTemp.drawRect(0, 0, width, height);

// Destroy the copy of the Graphics object

gTemp.dispose();

}

}

每当需要绘制目标组件时,就会调用LayerUIpaint()方法。LayerUI类的方法接收两个参数。第一个参数是可以用来在组件上绘制的Graphics对象的引用。第二个参数是对JLayer对象的引用,而不是目标组件。您可以使用第二个参数获得目标组件的引用,即JLayer正在修饰的组件。您可以将第二个参数转换为JLayer类型,并使用JLayer类的getView()方法,该方法返回目标组件的引用。paint()方法内部的逻辑很简单。它创建了一个Graphics参数的副本,并在组件周围画了一个蓝色的矩形。该方法传入的Graphics对象是为绘制该组件而设置的。建议复制传入的Graphics对象,因为更改传入的Graphics对象可能会导致意外结果。

现在你已经准备好使用带有一个JLayerBlueBorderUI在一个JTextField周围画一个蓝色的边框。以下代码片段显示了逻辑:

// Create a JTextField as usual

JTextField firstName = new JTextField(10);

// Create an object of the BlueBorderUI

LayerUI<JTextField> ui = new BlueBorderUI();

// Create a JLayer object by wrapping the JTextField and BlueBorderUI

JLayer<JTextField> layer = new JLayer(firstName, ui);

// Add the layer object to a container, say the content pane of a frame.

// Note that you add the layer and not the component to a container.

contentPane.add(layer)

目标组件和LayerUI可能会在您创建它时被传递给一个JLayer。如果您不知道目标组件和/或JLayerLayerUI,您可以稍后使用JLayer类的setView()setUI()方法传递它们。JLayer类的getView()getUI()方法让您分别获得当前目标组件的引用和JLayerLayerUI

清单 3-12 展示了如何使用一个JLayer在两个JTextField组件周围画一个边框。代码简单明了。当你运行这个程序时,它会在一个JFrame中显示两个带有蓝色边框的JTextField组件。

清单 3-12。使用 JLayer 装饰 JTextFeild 组件

// JLayerBlueBorderFrame.java

package com.jdojo.swing;

import java.awt.FlowLayout;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JLayer;

import javax.swing.JTextField;

import javax.swing.SwingUtilities;

import javax.swing.plaf.LayerUI;

public class JLayerBlueBorderFrame extends JFrame {

private JLabel firstNameLabel = new JLabel("First Name:");

private JLabel lastNameLabel = new JLabel("Last Name:");

private JTextField firstName = new JTextField(10);

private JTextField lastName = new JTextField(10);

public JLayerBlueBorderFrame(String title) {

super(title);

initFrame();

}

public void initFrame() {

this.setLayout(new FlowLayout());

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

// Create an object of the LayerUI subclass - BlueBorderUI

LayerUI<JTextField> ui = new BlueBorderUI();

// Wrap the LayerUI and two JTextFields in two JLayers.

// Note that a LayerUI object can be shared by multiple JLayers

JLayer<JTextField> layer1 = new JLayer<>(firstName, ui);

JLayer<JTextField> layer2 = new JLayer<>(lastName, ui);

this.add(firstNameLabel);

this.add(layer1); // Add layer1, not firstName to the frame

this.add(lastNameLabel);

this.add(layer2); // Add layer2, not lastName to the frame

}

public static void main(String[] args) {

SwingUtilities.invokeLater(() -> {

JLayerBlueBorderFrame frame

= new JLayerBlueBorderFrame("JLayer Test Frame");

frame.pack();

frame.setVisible(true);

});

}

}

让我们看一个如何使用JLayer处理目标组件事件的例子。一个JLayer将事件处理任务委托给关联的LayerUI。您需要执行以下步骤来处理LayerUI子类中的事件。

Register for the events that a JLayer will process.   Write the event handler code in an appropriate method of the LayerUI subclass.

您需要调用JLayer类的setLayerEventMask(long layerEventMask)方法来注册一个JLayer感兴趣的所有事件。该方法的layerEventMask参数必须是AWTEvent常量的位掩码。例如,如果一个名为layerJLayer对按键和焦点事件感兴趣,您可以调用这个方法,如下所示:

int layerEventMask = AWTEvent.KEY_EVENT_MASK | AWTEvent.FOCUS_EVENT_MASK;

layer.setLayerEventMask(layerEventMask);

通常,JLayerLayerUI子类的installUI()方法中注册事件。您需要在您的子类中覆盖LayerUI类的installUI()方法。卸载 UI 时,需要将JLayer的事件掩码设置为零。这是通过uninstallUI()方法完成的。下面的代码片段显示了一个JLayer注册一个焦点事件并重置其事件掩码:

public class SmartBorderUI extends LayerUI<JTextField> {

@Override

public void installUI(JComponent c) {

super.installUI(c);

JLayer layer = (JLayer)c;

// Register for the focus event

layer.setLayerEventMask(AWTEvent.FOCUS_EVENT_MASK);

}

@Override

public void uninstallUI(JComponent c) {

super.uninstallUI(c);

JLayer layer = (JLayer)c;

// Reset the event mask

layer.setLayerEventMask(0);

}

// Other code goes here

}

当一个注册的事件被传递给JLayer时,关联的LayerUIeventDispatched(AWTEvent event, JLayer layer)方法被调用。您可能想在您的LayerUI子类中覆盖这个方法来处理所有注册的事件。从技术上讲,您重写此方法来处理事件是正确的。然而,有一种更好的方法在LayerUI子类中提供事件处理代码。LayerUI类的eventDispatched()方法在接收到一个事件时调用一个适当命名的方法。这些方法被声明为

protected void processXxxEvent(XxxEvent e, JLayer layer).

这里,Xxx是登记事件的名称。下面的代码片段展示了事件类型的示例以及当JLayer接收到该类事件时调用的方法声明:

public class SmartBorderUI extends LayerUI<JTextField> {

@Override

protected void processFocusEvent(FocusEvent e, JLayer layer) {

// Process the focus event here

}

@Override

protected void processKeyEvent(KeyEvent e, JLayer layer) {

// Process the key event here

}

@Override

protected void processMouseEvent(MouseEvent e, JLayer layer) {

// Process the mouse event here

}

// Other code goes here...

}

这就是在JLayer中处理事件所需要做的一切。让我们改进前面的例子。这一次,JLayer将在JTextField周围画一个边框,其颜色将取决于JTextField是否有焦点。当它获得焦点时,会绘制一个红色边框。当它失去焦点时,会绘制一个蓝色边框。

清单 3-13 包含了一个继承自LayerUISmartBorderUI类的代码。它的paint()方法根据目标组件是否有焦点来绘制红色或蓝色边框。它的installUI()方法为焦点事件注册。unInstallUI()方法通过将事件掩码设置为零来取消焦点事件的注册。它的processFocusEvent()方法处理焦点事件。请注意,当目标组件上发生焦点事件时,将调用此方法。它调用repaint()方法,后者又会调用paint()方法,后者根据组件的焦点状态绘制边框。

清单 3-13。基于焦点装饰 JTextField 的 LayerUI 子类

// SmartBorderUI.java

package com.jdojo.swing;

import java.awt.AWTEvent;

import java.awt.Color;

import java.awt.Graphics;

import java.awt.Graphics2D;

import java.awt.event.FocusEvent;

import javax.swing.JComponent;

import javax.swing.JLayer;

import javax.swing.JTextField;

import javax.swing.plaf.LayerUI;

public class SmartBorderUI extends LayerUI<JTextField> {

@Override

public void paint(Graphics g, JComponent layer) {

// Let the superclass paint the component first

super.paint(g, layer);

Graphics gTemp = (Graphics2D) g.create();

int width = layer.getWidth();

int height = layer.getHeight();

// Suppress the unchecked warning

@SuppressWarnings("unchecked")

JLayer<JTextField> myLayer = (JLayer<JTextField>)layer;

JTextField field = (JTextField)myLayer.getView();

// When in focus, draw a red rectangle. Otherwise, draw a blue rectangle

Color bColor;

if (field.hasFocus()) {

bColor = Color.RED;

}

else {

bColor = Color.BLUE;

}

gTemp.setColor(bColor);

gTemp.drawRect(0, 0, width, height);

gTemp.dispose();

}

@Override

public void installUI(JComponent c) {

// Let the superclass do its job

super.installUI(c);

// Set the event mask for the layer stating that it is interested

// in listening to the focus event for its target

JLayer layer = (JLayer)c;

layer.setLayerEventMask(AWTEvent.FOCUS_EVENT_MASK);

}

@Override

public void uninstallUI(JComponent c) {

// Let the superclass do its job

super.uninstallUI(c);

JLayer layer = (JLayer) c;

// Set the event mask back to zero

layer.setLayerEventMask(0);

}

@Override

protected void processFocusEvent(FocusEvent e, JLayer layer) {

layer.repaint();

}

}

清单 3-14 包含了使用带有JLayerSmartBorderUI类的代码。当你运行这个程序时,它会显示一个带有两个JTextField组件的JFrame。在JTextField组件之间改变焦点将会改变它们的边框颜色。

清单 3-14。基于焦点使用 Jlayer 装饰 JTextField 组件

// JLayerSmartBorderFrame.java

package com.jdojo.swing;

import java.awt.FlowLayout;

import javax.swing.JFrame;

import javax.swing.JLabel;

import javax.swing.JLayer;

import javax.swing.JTextField;

import javax.swing.SwingUtilities;

import javax.swing.plaf.LayerUI;

public class JLayerSmartBorderFrame extends JFrame {

private JLabel firstNameLabel = new JLabel("First Name:");

private JLabel lastNameLabel = new JLabel("Last Name:");

private JTextField firstName = new JTextField(10);

private JTextField lastName = new JTextField(10);

public JLayerSmartBorderFrame(String title) {

super(title);

initFrame();

}

public void initFrame() {

this.setLayout(new FlowLayout());

this.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);

// Create an object of LayerUI subclass - SmartBorderUI

LayerUI<JTextField> ui = new SmartBorderUI();

// Wrap the LayerUI and two JTextFields in two JLayers

JLayer<JTextField> layer1 = new JLayer<>(firstName, ui);

JLayer<JTextField> layer2 = new JLayer<>(lastName, ui);

this.add(firstNameLabel);

this.add(layer1); // Add layer1 and not firstName to the frame

this.add(lastNameLabel);

this.add(layer2); // Add layer2 and not lastName to the frame

}

public static void main(String[] args) {

SwingUtilities.invokeLater(() -> {

JLayerSmartBorderFrame frame

= new JLayerSmartBorderFrame("JLayer Test Frame");

frame.pack();

frame.setVisible(true);

});

}

}

半透明的窗户

在讨论 Swing 中的半透明窗口之前,让我们定义三个术语:

  • 透明的
  • 半透明的
  • 不透明的

如果一个东西是透明的,你就能看穿它;清水是透明的。如果某物是不透明的,你就看不透它;混凝土墙是不透明的。如果某物是半透明的,你可以看穿它,但不清楚。如果某物是半透明的,它部分允许光线通过;塑料窗帘是半透明的。术语“透明”和“不透明”描述两种相反的状态,而术语“半透明”描述透明和不透明之间的状态。

您可以定义窗口(如JFrame)的半透明程度。90%半透明的窗口是 10%不透明的。窗口的半透明程度可以使用像素的颜色分量的 alpha 值来定义。您可以使用Color类的构造函数定义颜色的 alpha 值:

  • Color(int red, int green, int blue, int alpha)
  • Color(float red, float green, float blue, float alpha)

当颜色分量根据int值指定时,alpha参数的值指定在 0 到 255 之间。对于float类型参数,其值介于 0.0 和 1.0 之间。alpha值为 0 或 0.0 表示透明(100%半透明,0%不透明)。alpha值 255 或 1.0 表示不透明(0%半透明,完全不透明)。

支持窗口中的三种半透明形式。它们由枚举的以下三个常数表示:

  • 在这种半透明形式中,窗口中的像素要么是不透明的,要么是透明的。也就是说,像素的 alpha 值为 0.0 或 1.0。
  • TRANSLUCENT:在这种形式的半透明中,一个窗口中的所有像素都具有相同的半透明性,可以用 0.0 到 1.0 之间的 alpha 值来定义。
  • 在这种半透明的形式中,窗口中的每个像素可以有自己的 alpha 值,在 0.0 到 1.0 之间。它可以让你在一个窗口中定义每个像素的透明度。

不是所有的平台都支持这三种半透明形式。在使用半透明之前,您必须检查程序中支持的半透明形式。否则,您的代码可能会抛出UnsupportedOperationExceptionGraphicsDevice类的isWindowTranslucencySupported()方法让你检查平台支持的半透明形式。清单 3-15 演示了如何检查平台上的半透明支持。清单中的代码很短,不言自明。为了缩短代码,我省略了后续示例中的检查。

清单 3-15。检查平台上的半透明支持

// TranslucencySupport.java

package com.jdojo.swing;

import java.awt.GraphicsDevice;

import java.awt.GraphicsEnvironment;

import static java.awt.GraphicsDevice.WindowTranslucency.*;

public class TranslucencySupport {

public static void main(String[] args) {

GraphicsEnvironment graphicsEnv

= GraphicsEnvironment.getLocalGraphicsEnvironment();

GraphicsDevice graphicsDevice

= graphicsEnv.getDefaultScreenDevice();

// Print the translucency supported by the platform

boolean isSupported

= graphicsDevice.isWindowTranslucencySupported(

PERPIXEL_TRANSPARENT);

System.out.println("PERPIXEL_TRANSPARENT supported: "

+ isSupported);

isSupported

= graphicsDevice.isWindowTranslucencySupported(TRANSLUCENT);

System.out.println("TRANSLUCENT supported: " + isSupported);

isSupported = graphicsDevice.isWindowTranslucencySupported(

PERPIXEL_TRANSLUCENT);

System.out.println("PERPIXEL_TRANSLUCENT supported: "

+ isSupported);

}

}

让我们看看一个统一的半透明JFrame在行动。你可以使用setOpacity(float opacity)方法设置JFrame的透明度。指定的opacity的值必须在 0.0f 和 1.0f 之间。在窗口上调用此方法之前,必须满足以下三个条件:

  • 平台必须支持TRANSLUCENT半透明。您可以使用清单 3-15 中的逻辑来检查平台是否支持TRANSLUCENT半透明。
  • 窗户必须是未经装饰的。你可以通过调用setUndecorated(false)方法来取消JFrameJDialog的修饰。
  • 窗口不能处于全屏模式。您可以使用GraphicsDevice类的setFullScreenWindow(Window w)方法将窗口置于全屏模式。

如果不满足所有条件,将窗口的不透明度设置为 1.0f 以外会抛出IllegalComponentStateException

清单 3-16 演示了如何使用一个均匀的半透明JFrame。下面两个语句在清单中的initFrame()方法中得到一个半透明的JFrame。第一条语句确保框架未被修饰,第二条语句根据不透明度设置框架的透明度。

清单 3-16。使用均匀半透明的 JFrame

// UniformTranslucentFrame.java

package com.jdojo.swing;

import java.awt.BorderLayout;

import javax.swing.JButton;

import javax.swing.JFrame;

import javax.swing.SwingUtilities;

public class UniformTranslucentFrame extends JFrame {

private JButton closeButton = new JButton("Close");

public UniformTranslucentFrame(String title) {

super(title);

initFrame();

}

public void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

// Make sure the frame is undecorated

this.setUndecorated(true);

// Set 40% opacity. That is, 60% translucency.

this.setOpacity(0.40f);

// Set its size

this.setSize(200, 200);

// Center it on the screen

this.setLocationRelativeTo(null);

// Add a button to close the window

this.add(closeButton, BorderLayout.SOUTH);

// Exit the aplication when the close button is clicked

closeButton.addActionListener(e -> System.exit(0));

}

public static void main(String[] args) {

SwingUtilities.invokeLater(() -> {

UniformTranslucentFrame frame

= new UniformTranslucentFrame("Translucent Frame");

frame.setVisible(true);

});

}

}

// Make sure the frame is undecorated

this.setUndecorated(true);

// Set 40% opacity. That is, 60% translucency.

this.setOpacity(0.40f);

运行该程序时,您可以通过JFrame显示区看到屏幕上的内容。一个Close按钮被添加到框架上以关闭它。

让我们来看看一个每像素半透明JFrame的作用。您将在JPanel中创建一个渐变效果(渐隐效果),方法是为其显示区域中不同像素的背景色设置不同的 alpha 值。你可以用不同的方法获得每像素的透明度。最简单的方法是使用一个带有背景色的JPanel,并将 alpha 组件设置为所需的透明度。以下代码片段说明了这一点:

// Create a frame and set its properties

JFrame frame = new JFrame();

frame.setUndecorated(true);

frame.setBounds(0, 0, 200, 200);

// Set the background color of the frame to all zero, so that the per-pixel translucency works

frame.setBackground(new Color(0, 0, 0, 0));

// Create a blue JPanel with 128 alpha component

JPanel panel = new JPanel();

int alpha = 128;

Color bgColor = new Color(0, 0, 255, alpha);

panel.setBackground(bgColor);

// Add the JPanel to the frame and display it

frame.add(panel);

frame.setVisible(true);

代码中有两点不同。首先,它将框架的背景颜色设置为所有颜色组件都设置为 0,以实现每像素的半透明性。其次,它将包含 alpha 组件的JPanel的背景色设置为 128。你可以添加另一个JPanelJFrame中,它的背景颜色使用不同的 alpha 组件。这将在JFrame上给你两个区域,它们的像素使用不同的透明度。

如果你使用一个GradientPaint类的对象来绘制你的JPanel,你可以得到一个更好的效果。一个GradientPaint对象用线性渐变图案填充一个Shape。它要求您指定两个点,p1 和 p2,以及每个点的颜色,c1 和 c2。p1 和 p2 之间连接线上的颜色将按比例从 c1 变为 c2。

清单 3-17 包含了一个自定义JPanel的代码,它使用一个GradientPaint对象来绘制它的区域。JPanel的背景颜色是在其构造函数中指定的。它覆盖了paintComponent()来提供自定义的绘画效果。渐变颜色图案由Graphics2D提供。该方法检查它是否有一个Graphics2D对象。起点 p1 是JPanel的左上角。起点 c1 的颜色与构造函数中传递的颜色相同。它使用 255 作为它的 alpha 分量。第二个点 p2 是JPanel的右上角,颜色相同,使用了零 alpha 组件。这将给JPanel一个渐变效果,从左边不透明到右边逐渐变透明。您可以通过更改这两个点和它们的 alpha 组件值来获得不同的渐变图案。它将GradientPaint对象设置为Graphics2D对象的Paint对象,并调用fillRect()方法来绘制。

清单 3-17。一个自定义 JPanel,使用每像素半透明的渐变颜色效果

// TranslucentJPanel.java

package com.jdojo.swing;

import java.awt.Color;

import java.awt.GradientPaint;

import java.awt.Graphics;

import java.awt.Graphics2D;

import java.awt.Paint;

import javax.swing.JPanel;

public class TranslucentJPanel extends JPanel {

private int red = 240;

private int green = 240;

private int blue = 240;

public TranslucentJPanel(Color bgColor) {

this.red = bgColor.getRed();

this.green = bgColor.getGreen();

this.blue = bgColor.getBlue();

}

@Override

protected void paintComponent(Graphics g) {

if (g instanceof Graphics2D) {

int width = this.getWidth();

int height = this.getHeight();

float startPointX = 0.0f;

float startPointY = 0.0f;

float endPointX = width;

float endPointY = 0.0f;

Color startColor = new Color(red, green, blue, 255);

Color endColor = new Color(red, green, blue, 0);

// Create a GradientPaint object

Paint paint = new GradientPaint(startPointX, startPointY,

startColor,

endPointX, endPointY,

endColor);

Graphics2D g2D = (Graphics2D) g;

g2D.setPaint(paint);

g2D.fillRect(0, 0, width, height);

}

}

}

清单 3-18 包含了查看每像素透明度的代码。它添加了背景色为红色、绿色和蓝色的TranslucentJPanel类的三个实例。添加了一个Close按钮来关闭框架。

清单 3-18。在 JFrame 中使用每像素半透明

// PerPixelTranslucentFrame.java

package com.jdojo.swing;

import java.awt.Color;

import java.awt.GridLayout;

import javax.swing.JButton;

import javax.swing.JFrame;

import javax.swing.SwingUtilities;

public class PerPixelTranslucentFrame extends JFrame {

private JButton closeButton = new JButton("Close");

public PerPixelTranslucentFrame(String title) {

super(title);

initFrame();

}

public void initFrame() {

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

// Make sure the frame is undecorated

this.setUndecorated(true);

// Set the background color with all components as zero,

// so per-pixel translucency is used

this.setBackground(new Color(0, 0, 0, 0));

// Set its size

this.setSize(200, 200);

// Center it on the screen

this.setLocationRelativeTo(null);

this.getContentPane().setLayout(new GridLayout(0, 1));

// Create and add three JPanel with different color gradients

this.add(new TranslucentJPanel(Color.RED));

this.add(new TranslucentJPanel(Color.GREEN));

this.add(new TranslucentJPanel(Color.BLUE));

// Add a button to close the window

this.add(closeButton);

closeButton.addActionListener(e -> System.exit(0));

}

public static void main(String[] args) {

SwingUtilities.invokeLater(() -> {

PerPixelTranslucentFrame frame

= new PerPixelTranslucentFrame("Per-Pixel Translucent Frame");

frame.setVisible(true);

});

}

}

图 3-12 显示了程序运行时的JFrame。请注意框架中的渐变效果。从左向右移动时,每个面板都变得更加透明。图中显示的文本不是JFrame的一部分。显示JFrame时,文本显示在背景中。可以透过JFrame的半透明部分看到。

A978-1-4302-6662-4_3_Fig12_HTML.jpg

图 3-12。

A JFrame using per-pixel translucency

异形窗

Swing 允许你创建一个定制形状的窗口,比如圆形的JFrame,椭圆形的JDialog等等。你可以通过使用Window类的setShape(Shape s)方法给一个窗口定制形状。窗户的形状只受你想象力的限制。您可以通过使用java.awt.geom包中的类组合多个形状来创建一个形状。下面的代码片段创建一个形状,该形状包含一个放置在矩形上方的椭圆。最后,它将自定义形状设置为一个JFrame

// Create a shape with an ellipse over a rectangle

Ellipse2D.Double ellipse = new Ellipse2D.Double(0, 0, 200, 100);

Rectangle2D.Double rect = new Rectangle2D.Double(0, 100, 200, 200);

// Combine an ellipse and a rectangle into a Path2D object to get a new shape

Path2D path = new Path2D.Double();

path.append(rect, true);

path.append(ellipse, true);

// Create a JFrame

JFrame frame = new JFrame("A Custom Shaped JFrame");

// Set the custom shape to the JFrame

Frame.setShape(path);

一个Window在屏幕上拥有一个矩形区域。如果您给窗口指定了自定义形状,它的某些部分可能会被剪切掉。不属于自定义形状的形状窗口部分不可见,也不可单击。图 3-13 显示了一个定制形状的窗口,在矩形上方放置了一个椭圆。该窗口包含一个Close按钮。椭圆四个角周围的区域不可见,也不可点击。

A978-1-4302-6662-4_3_Fig13_HTML.jpg

图 3-13。

A custom shaped window with an ellipse placed above a rectangle

要使用异形窗,必须满足以下三个标准:

  • 平台必须支持PERPIXEL_TRANSPARENT半透明。您可以使用清单 3-15 中的逻辑来检查是否支持PERPIXEL_TRANSPARENT半透明。
  • 窗户必须是未经装饰的。你可以通过调用setUndecorated(false)方法来取消JFrameJDialog的修饰。
  • 窗口不能处于全屏模式。您可以使用GraphicsDevice类的setFullScreenWindow(Window w)方法将窗口置于全屏模式。

清单 3-19 包含了显示一个如图 3-13 所示的形状JFrame的代码。

清单 3-19。使用定制形状的 JFrame

// ShapedFrame.java

package com.jdojo.swing;

import java.awt.BorderLayout;

import java.awt.geom.Path2D;

import java.awt.geom.Ellipse2D;

import java.awt.geom.Rectangle2D;

import javax.swing.JButton;

import javax.swing.JFrame;

import javax.swing.SwingUtilities;

public class ShapedFrame extends JFrame {

private JButton closeButton = new JButton("Close");

public ShapedFrame() {

initFrame();

}

public void initFrame() {

// Make sure the frame is undecorated

this.setUndecorated(true);

this.setDefaultCloseOperation(EXIT_ON_CLOSE);

this.setSize(200, 200);

// Create a shape with an ellipse placed over a rectangle

Ellipse2D.Double ellipse = new Ellipse2D.Double(0, 0, 200, 100);

Rectangle2D.Double rect = new Rectangle2D.Double(0, 100, 200, 200);

// Combine the ellipse and rectangle into a Path2D object and

// set it as the shape for the JFrame

Path2D path = new Path2D.Double();

path.append(rect, true);

path.append(ellipse, true);

this.setShape(path);

// Add a Close button to close the frame

this.add(closeButton, BorderLayout.SOUTH);

closeButton.addActionListener(e -> System.exit(0));

}

public static void main(String[] args) {

SwingUtilities.invokeLater(() -> {

// Display the custom shaped frame

ShapedFrame frame = new ShapedFrame();

frame.setLocationRelativeTo(null);

frame.setVisible(true);

});

}

}

清单中的initFrame()方法中的以下代码部分很有意思:

// Make sure the frame is undecorated

this.setUndecorated(true);

// Create a shape with an ellipse placed over a rectangle

Ellipse2D.Double ellipse = new Ellipse2D.Double(0, 0, 200, 100);

Rectangle2D.Double rect = new Rectangle2D.Double(0, 100, 200, 200);

// Combine the ellipse and rectangle into a Path2D object and

// set it as the shape for the JFrame

Path2D path = new Path2D.Double();

path.append(rect, true);

path.append(ellipse, true);

this.setShape(path);

第一条语句确保JFrame没有被修饰。创建了两个形状,一个椭圆和一个矩形。它们的坐标和大小被设置为将椭圆放置在矩形上。一个Path2D.Double对象用于将椭圆和矩形连接成一个自定义的Shape对象。Path2Djava.awt.geom包中的一个abstract类。它声明了两个静态内部类Path2D.DoublePath2D.Float,分别以双精度和单精度浮点数存储形状的坐标。Shape是在java.awt包中声明的接口。Path2D类实现了Shape接口。注意,Window类中的setShape()方法将Shape接口的一个实例作为参数。Path2D类的append()方法将指定的Shape对象的几何图形附加到路径上。append()方法的第二个参数是一个指示器,指示您是否希望使用线段连接两个形状。如果是true,对moveTo()方法的调用被转换成lineTo()方法。在这种情况下,true的值对于这个参数来说没有意义。请研究一下java.awt.geom包中的类,了解更多可以在 Java 应用中使用的有趣形状。

摘要

Swing 组件内置了将 HTML 文本显示为标签的支持。您可以使用 HTML 格式的字符串作为JButtonJMenuItemJLabelJToolTipJTabbedPaneJTree等的标签。使用一个 HTML 字符串,它应该分别以<html></html>标签开始和结束。如果不希望 Swing 将 HTML 标签中的文本解释为组件的 HTML,可以通过调用组件上的putClientProperty("html.disable", Boolean.TRUE)方法来禁用该特性。

Swing 组件不是线程安全的。你应该从一个叫做事件调度线程的线程中更新组件的状态。组件的所有事件处理程序都在事件调度线程中执行。Swing 自动创建事件调度线程。Swing 提供了一个名为SwingUtilities的实用程序类来处理事件调度线程;它的invokeLater(Runnable r)方法调度指定的Runnable在事件调度线程上执行。构建 Swing GUI 并在事件调度线程上显示它是安全的。如果该方法由事件调度线程执行,SwingUtilities类的isEventDispatchThread()返回 true。

在事件调度线程上运行长时间运行的任务会使您的 GUI 无响应。Swing 提供了一个SwingWorker类来在工作线程上执行长时间运行的任务,这些工作线程不是事件调度线程。SwingWorker类提供了在事件调度线程上发布任务结果的特性,可以安全地更新 Swing 组件。

Swing 提供了可插拔的 L&F。它附带了一些预定义的 L&F。您可以使用UIManager.setLookAndFeel()方法为您的应用设置一个新的 L & F。

Swing 支持名为 Synth 的可换肤 L&F,它允许您在外部 XML 文件中定义 L&F。

拖放(DnD)是一种在应用组件之间传输数据的方式。Swing 支持 Swing 组件之间、Swing 组件和本机组件之间的 DnD。使用 DnD,您可以在两个组件之间复制、移动和链接数据。

使用 Swing,您可以开发一个多文档界面(MDI)应用,它由桌面管理器管理的多个框架组成。MPI 应用中的帧可以以不同的方式排列;例如,它们可以分层排列,它们可以级联,它们可以并排放置,等等。

Swing 提供了一个Toolkit类的实例来与本地系统通信。这个类包含了很多有用的方法,比如发出哔哔声,知道屏幕分辨率和大小等等。

Swing 让你拥有半透明的窗口。半透明性可以被定义为对于窗口中的所有像素都是相同的,或者以每个像素为基础。

在 Swing 中,您并不局限于只有矩形窗口。它可以让你创建异形窗口。异形窗可以是任何形状,例如圆形、椭圆形或任何定制的形状。

四、Applet

在本章中,您将学习

  • 什么是 applet
  • 如何开发、部署和运行 Applet
  • 如何使用<applet>标签在 HTML 文档中嵌入 applet
  • 如何安装和配置运行 Applet 的 Java 插件
  • 如何使用appletviewer程序运行 Applet
  • Applet 的生命周期
  • 如何将参数传递给 Applet
  • 如何发布 Applet 的参数和描述
  • 如何在 Applet 中使用图像和音频剪辑
  • 如何定制 Java 策略文件以授予 Applet 权限
  • 如何签署 Applet

什么是 Applet?

是一种嵌入在 HTML 文档中并在 web 浏览器中运行的 Java 程序。组成 applet 的编译后的 Java 代码存储在 web 服务器上。web 浏览器通过互联网从 web 服务器下载 applet 代码,并在浏览器的上下文中本地运行该代码。通常,applet 有一个图形用户界面(GUI)。一个 applet 有许多安全限制,包括它在客户端计算机上能访问什么和不能访问什么。对 Applet 的限制是必要的,因为 Applet 可能不是由同一个人开发和使用的。如果允许恶意编写的小应用完全访问客户端机器,它可能会对客户端机器造成有害影响。例如,安全限制不允许 applet 访问文件系统或在客户机上启动程序。假设你打开一个网页,里面有一个 applet 可以读取你机器上的文件。在你不知情的情况下,一个恶意的 Applet 会把你的私人信息发送到它的服务器上。为了保护 applet 用户免受这种伤害,有必要在运行 applet 时设置安全限制。使用策略文件可以配置许多安全限制。我将在本章后面讨论如何配置 Applet 安全策略。

虽然 a 和 Applet 没有关系,但是我还是来解释一下两者的区别。像 applet 一样,servlet 也是部署在 web 服务器上的 Java 程序。与 applet 不同,servlet 运行在 web 服务器本身上,它不包括 GUI。

开发小应用

开发 applet 有四个步骤:

  • 为 applet 编写 Java 代码
  • 打包和部署 applet 文件
  • 安装和配置 Java 插件
  • 查看子视图

为 applet 编写 Java 代码与为 Swing 应用编写代码没有太大区别。您只需要学习一些将在代码中使用的 Applet 的标准类和方法。

小应用部署在网络服务器上,并通过互联网/内联网使用网络浏览器在网页中查看。您还可以在开发和测试期间使用 applet 查看器查看 applet。JDK 发布了一个 appletviewer 程序。安装 JDK 时,appletviewer 程序会安装在您机器上的JAVA_HOME\bin目录中。我将在本章后面详细讨论如何使用 appletviewer。

要在网页中查看 applet,您需要在 HTML 文档中嵌入对 applet 的引用。您可以使用三种 HTML 标签中的任何一种,即<applet><object><embed>来将 applet 嵌入到 HTML 文档中。我将很快详细讨论这些标签的使用。

接下来的两节讨论如何为 applet 编写 Java 代码,以及如何查看 applet。

编写 Applet

您的 applet 类必须是 Java 提供的标准 applet 类的子类。有两个标准的 applet 类:

  • java.applet.Applet
  • javax.swing.JApplet

Applet类支持 AWT GUI 组件,而JApplet类支持 Swing GUI 组件。JApplet类继承自Applet类。在这一章中我将只讨论JApplet。清单 4-1 显示了你能拥有的最简单的 applet 的代码。

清单 4-1。最简单的 Applet

// SimplestApplet.java

package com.jdojo.applet;

import javax.swing.JApplet;

public class SimplestApplet extends JApplet {

// No extra code is needed for your simplest applet

}

SimplestApplet没有任何 GUI 部件或逻辑。从技术上来说,它是一个完整的 Applet。如果您在浏览器中测试这个 applet,您所看到的只是网页中的一个空白区域。

让我们创建另一个带有 GUI 的 applet,这样您可以在浏览器中看到一些东西。新的 applet 叫做HelloApplet,如清单 4-2 所示。

清单 4-2。使用 JLabel 显示消息的 HelloApplet Applet

// HelloApplet.java

package com.jdojo.applet;

import java.awt.Container;

import java.awt.FlowLayout;

import javax.swing.JApplet;

import javax.swing.JButton;

import javax.swing.JLabel;

import javax.swing.JOptionPane;

import javax.swing.JTextField;

import static javax.swing.JOptionPane.INFORMATION_MESSAGE;

public class HelloApplet extends JApplet {

@Override

public void init() {

// Create Swing components

JLabel nameLabel = new JLabel("Your Name:");

JTextField nameFld = new JTextField(15);

JButton sayHelloBtn = new JButton("Say Hello");

// Add an action litener to the button to display the message

sayHelloBtn.addActionListener(e -> sayHello(nameFld.getText()));

// Add Swing components to the content pane of the applet

Container contentPane = this.getContentPane();

contentPane.setLayout(new FlowLayout());

contentPane.add(nameLabel);

contentPane.add(nameFld);

contentPane.add(sayHelloBtn);

}

private void sayHello(String name) {

String msg = "Hello there";

if (name.length() > 0) {

msg = "Hello " + name;

}

// Display the hello message

JOptionPane.showMessageDialog(null,

msg, "Hello", INFORMATION_MESSAGE);

}

}

这个类的代码看起来熟悉吗?这类似于使用自定义的JFrameJApplet类包含一个init()方法。您需要重写该方法,并向 applet 添加 GUI 部件。我将很快详细讨论用 applet 的方法编写代码。像一个JFrameJApplet有一个内容窗格,包含 applet 的组件。您在JApplet的内容窗格中添加了一个JLabel、一个JTextField和一个JButton。程序逻辑很简单。用户可以输入姓名并点击显示消息的Say Hello按钮。

与 Swing 应用不同,您不应该在 applet 的构造函数中添加任何 GUI,即使它在大多数情况下都可以工作。调用 applet 的构造函数来创建 applet 类的对象。applet 对象在创建时不会获得其“applet”状态。它还是一个普通的 Java 对象。如果在构造函数中使用 applet 的任何特性,这些特性将无法正常工作,因为 applet 对象只是一个简单的 Java 对象,而不是真正意义上的“applet”。在创建之后,它获得 applet 的状态,并且它的init()方法被显示它的环境(通常是浏览器)调用。这就是为什么您需要将所有 GUI 代码(或任何初始化代码)放在它的init()方法中的原因。Applet类提供了一些其他的标准方法,您可以覆盖这些方法并编写您的逻辑来在 applet 中执行不同种类的工作。

运行 applet 的方式与运行 Swing 应用的方式不同。注意,applet 类没有main()方法。然而,从技术上来说,可以在 applet 中添加一个方法,但这对运行 applet 没有任何帮助。要查看 applet 的运行情况,您需要一个 HTML 文件。你应该有 HTML 的基本知识来使用 applet,但是你不需要成为 HTML 的专家。我将在下一节讨论如何查看 applet。这时候你就要编译HelloApplet类了。您将拥有一个名为HelloApplet.class的类文件。

部署 Applet

Applet 是 Java 程序。但是,它们不能像其他 Java 程序一样直接运行。在运行 applet 之前,您需要做一些准备工作。Applet 需要部署后才能使用。applet 部署分为两个部分:

  • 定义 applet GUI 和逻辑的 Java 代码
  • 一个 HTML 文档,包含 applet 的详细信息,如类名、包含类文件的存档文件名、宽度、高度等。

在上一节中,您已经看到了如何为 applet 编写 Java 代码。

使用<applet>标签将 applet 细节嵌入到 HTML 文档中。applet 代码和 HTML 文档都被部署到 web 服务器上。客户机上的浏览器向 web 服务器请求 HTML 文档。当浏览器在 HTML 文档中找到<applet>标记时,它会读取 applet 的详细信息,从 web 服务器下载 applet 代码,并在浏览器中将代码作为 applet 运行。这是否意味着您需要一个 web 服务器来查看您的 Applet 的运行情况?答案是否定的。你可以不用网络服务器来测试你的 Applet。如果您想让用户可以使用您的 Applet,您需要一个 web 服务器来部署您的 Applet。以下部分描述了如何为 applet 创建 HTML 文档,以及如何将 applet 部署到不同的环境中。

创建 HTML 文档

一个<applet>标签被用来在一个 HTML 文档中嵌入一个 applet。下面是一个<applet>标签的例子:

<applet code="com.jdojo.applet.HelloApplet" width="300" height="100" archive="myapplets.jar">

This browser does not support Applets.

</applet>

您需要指定标签的以下强制属性:

  • code
  • width
  • height
  • archive

属性指定了 applet 的全限定类名。可选地,您可以将.class附加到 applet 的完全限定名。例如,以下两个<applet>标签的工作原理相同:

<!-- Use fully qualified name of the applet class as code -->

<applet code="com.jdojo.applet.HelloApplet">

...

</applet>

<!-- Use fully-qualified name of the applet class followed by .class -->

<applet code="com.jdojo.applet.HelloApplet.class">

...

</applet>

您也可以使用正斜杠而不是点来分隔子包名称。例如,您也可以将 code 属性的值指定为"com/jdojo/applet/HelloApplet"com/jdojo/applet/HelloApplet.class

widthheight属性分别指定网页中 applet 区域的初始宽度和高度。您可以用像素或百分比指定widthheight属性。如果值是数字,则以像素为单位;例如,width="150"表示 150 像素的width。如果值后面有一个百分号(%),则表示显示 Applet 的容器的尺寸百分比;例如,width="50%"表示 applet 的宽度将是其容器的 50%。通常,容器是浏览器窗口。

如果您正在使用 Java 7 Update 51 或更高版本来查看 applet,archive属性是必需的。您需要将一个 applet 的所有文件——类文件和其他资源文件——捆绑到一个 JAR 文件中。将 applet 文件捆绑在一个 JAR 文件中会使文件变得更小,从而加快 applet 用户的下载速度。属性的值是包含 applet 文件的 JAR 文件的名称。

如果浏览器不支持<applet>标签,您可能希望在网页中显示一条消息。消息应该放在<applet></applet>标签之间,如下所示。如果浏览器支持 Applet,它将忽略该消息。

<applet>

Inform the user that the browser does not support applets.

</applet>

清单 4-3 显示了用于测试 applet 的helloapplet.html文件的内容。注意,<applet>标签不包含archive属性,该属性允许您测试 applet,而不必创建 JAR 文件。

清单 4-3。helloapplet.html 档案的内容

<html>

<head>

<title>Hello Applet</title>

</head>

<body>

<h3>Hello Applet in Action</h3>

<applet code="com.jdojo.applet.HelloApplet" width="200" height="100">

This browser does not support Applets.

</applet>

</body>

</html>

在生产中部署 Applet

在生产环境中,您必须使用 JAR 文件部署 applet,并使用可信机构颁发的证书对 JAR 文件进行签名。对 JAR 进行自签名是行不通的。在测试环境中,您可以忽略这个需求,并且您可以使用一个未签名的 JAR 文件或者简单地使用类文件。在本章中,我将向你展示如何忽略这个需求来测试 Applet。如果你是第一次学习 Applet,你可以跳到下一节。当您需要在生产环境中部署您的 applet 时,您可以重温这一节。

使用以下步骤来打包和部署 Applet。这些步骤指的是与创建的 JAR 文件相关的术语和命令。有关创建 JAR 文件的更多细节,请参考《Java 语言特性入门》( ISBN: 978-1-4302-6658-7)一书中的第八章。

Create a manifest file (say manifest.mf). It must contain a Permissions attribute. The following shows the contents of the manifest file:

Manifest-Version: 1.0

Permissions: sandbox

Permissions属性的另一个值是all-permissionssandbox的值表示 applet 将在安全沙箱中运行,并且不需要访问客户端机器上的任何额外资源。all-permissions的值表示 applet 需要访问客户端的机器。

Create a JAR file that contains all class files for the applet and the manifest file created in the previous section. You can use the following command to create the JAR file named helloapplet.jar:

jar cvfm helloapplet.jar manifest.mf com\jdojo\applet\*.class

Sign the helloapplet.jar file with the certificate you obtained from a trusted authority. Obtaining a certificate costs money (approximately $100). If you are just learning applets, you can skip this step.  The “Signing Applets section later in this chapter explains in detail how to sign an applet.   Deploy the signed helloapplet.jar file to the web server. You will need to consult the documentation of your web server on how to deploy applets. Some web servers provide deployment screens to let you deploy your JAR files and some let you drop the JAR file into a specific directory. The typical way of deploying files to a web server is to let the development IDE such as NetBeans and Eclipse package and deploy the necessary project files for you.

部署 Applet 进行测试

如果你需要按照上一节描述的步骤来进行测试,那么打包和部署一个 applet 就太麻烦了。您可以在文件系统中保存所有的类文件和 HTML 文件,并测试您的 applet。我假设您有 applet 文件,并且它们的完整路径类似于以下路径:

  • C:\myapplets\helloapplet.html
  • C:\myapplets\com\jdojo\applet\HelloApplet.class

使用 Windows 上使用的文件路径语法显示路径。如果您使用的不是 Windows,请将它们更改为操作系统使用的路径语法。

您不需要将 applet 文件存储在特定的目录中,如C:\myapplets。您可以用任何目录的路径替换目录C:\myapplets。但是,您必须保留在C:\myapplets目录之后的文件路径。在您阅读更多章节后,您将能够使用存储 applet 文件的目录结构。

如果您已经创建了helloapplet.jar文件来测试 applet,我假设您已经将archive属性添加到了helloapplet.html文件中的<applet>标签中作为archive="helloapplet.jar",并且文件路径如下所示:

  • C:\myapplets\helloapplet.html
  • C:\myapplets\helloapplet.jar

安装和配置 Java 插件

浏览器使用 Java 插件来运行 Applet。在运行 applet 之前,您必须安装和配置 Java 插件。

安装 Java 插件

Java 运行时环境(JRE)也称为 Java 插件或 Java 附加组件。当您安装 JDK 时,JRE(以及 Java 插件)已经为您安装好了。运行 Applet 的客户机不需要安装 JDK。它可以只安装 JRE。您可以从 www.oracle.com 下载 JRE 的最新版本,在撰写本文时是 8.0。JRE 可以免费下载、安装和使用。

在 Windows 8 上,使用 64 位 JRE 8.0,我只能在 Internet Explorer 中运行我的 Applet。我不得不卸载 64 位的 JRE 8.0,安装 32 位的 JRE 9.0,这样我的 Applet 才能在所有浏览器中运行,比如谷歌 Chrome、Mozilla Firefox 和 Internet Explorer。

在 Linux 上,您需要做一些手动设置来为 Firefox 浏览器安装 Java 插件。请按照 www.oracle.com/technetwork/java/javase/manual-plugin-install-linux-136395.html 中的说明在 Linux 上设置 Java 插件。

打开 Java 控制面板

您可以使用 Java 控制面板程序配置 Java 插件。Java 控制面板程序启动如图 4-1 所示的窗口。

A978-1-4302-6662-4_4_Fig1_HTML.jpg

图 4-1。

The Java Control Panel

在 Windows 8 上,您可以通过以下步骤访问 Java 控制面板。

Open Search by pressing the Windows logo key + W. Make sure to select “Everywhere” for the search location. By default “Settings” is selected.   Enter “Java,” “Java Control Panel,” or “Configure Java” as the search term.   Click the Java icon to open the Java Control Panel.   If you could not find the Java Control Panel using Search, open the Control Panel by right-clicking the Start icon and selecting Control Panel from the menu. In the top-right corner in the Control Panel, you get a Search field. Enter “Java” in the Search field and you will see a program named Java. Click the program name to open the Java Control Panel.

在 Windows 7 上,您可以通过以下步骤访问 Java 控制面板。

Click the Start button, and then select the Control Panel option from the menu.   Enter Java Control Panel in the Search field in Control Panel.   Click the Java icon to open the Java Control Panel.   Tip

在 Windows 上,您可以通过运行位于JRE_HOME\bin目录下的文件javacpl.exe直接启动 Java 控制面板。对于 JRE 8,默认路径是C:\Program Files\Java\jre8\bin\javacpl.exe

在 Linux 上,您可以通过从终端窗口运行ControlPanel程序来访问 Java 控制面板。ControlPanel程序安装在JRE_HOME\bin目录中,其中 JRE_HOME 是您安装 JRE 的目录。假设您已经在/java8/jre目录下安装了 JRE。您需要从终端窗口运行以下命令:

[/java8/jre/bin]$ ./ControlPanel

在 Mac OS X (10.7.3 及更高版本)上,您可以使用以下步骤访问 Java 控制面板:

  • 点击屏幕左上角的苹果图标,进入系统偏好设置。
  • 单击 Java 图标访问 Java 控制面板。

配置 Java 插件

您可以使用 Java 控制面板为 Java 插件配置各种设置。在这一节中,我将描述如何绕过运行 applets 的签名 JAR 要求。打开 Java 控制面板,选择安全选项卡,如图 4-2 所示。

A978-1-4302-6662-4_4_Fig2_HTML.jpg

图 4-2。

Configuring the security settings in the Java Control Panel

标记为“在浏览器中启用 Java 内容”的复选框可让您启用/停用在浏览器中运行 Applet 的支持。默认情况下,此复选框处于选中状态,Applet 可以在浏览器中运行。如果未选中此复选框,您将无法在浏览器中运行 Applet。

第二个设置是安全级别,可以通过滑动垂直滑块控件的旋钮来设置。它可以设置为以下三个值:

  • 非常高:这是最严格的安全级别设置。只有具有有效证书并且在主 JAR 文件的清单中包含了Permissions属性的已签名的 Applet 才允许在有安全提示的情况下运行。所有其他 Applet 都被阻止。
  • 高:这是推荐的最低默认安全级别设置。使用有效或过期证书签名的 Applet 以及在主 JAR 文件的清单中包含Permissions属性的 Applet 允许在有安全提示的情况下运行。当无法检查证书的撤销状态时,Applet 也允许在安全提示下运行。所有其他 Applet 都被阻止。
  • 中:仅阻止请求所有权限的未签名 Applet。所有其他 Applet 都允许在安全提示下运行。不建议选择此安全级别。如果你运行一个恶意的 Applet,它会使你的计算机更容易受到攻击。

出于测试目的,您可以将安全级别设置为中。这将允许您测试打包在未签名的 JAR 文件中的 Applet。您也不需要在清单文件中包含Permissions属性。它还允许您从文件系统测试您的 Applet,避免了 web 服务器部署您的 Applet 的需要。完成测试后,您应该将安全设置改回推荐的高或非常高。请注意,当您尝试运行任何不符合安全要求的 Applet 时,使用“中”安全级别设置会向您显示警告。当您收到警告时,您需要确认是否要继续运行 Applet,尽管存在安全风险。

“安全”选项卡上的第三项设置称为“例外站点列表”。这使您可以绕过指定站点的安全级别设置所需的安全要求。点击“编辑站点列表”按钮,打开异常站点列表对话框,如图 4-3 所示。

A978-1-4302-6662-4_4_Fig3_HTML.jpg

图 4-3。

The Exception Site List Dialog Box

点击Add按钮。您将看到为该位置添加了一个空行。输入位置的file:///(注意三个///)。再次点击Add按钮。第二次点击Add按钮会显示安全警告信息,说明添加file://(注二/ /)有安全风险。点击警告对话框上的Continue按钮。您会得到另一个空行位置。输入http://localhost:8080。重复此步骤再添加一个位置, http://www.jdojo.com 。异常站点列表对话框应如图 4-4 所示。现在,单击OK按钮返回到安全选项卡。

A978-1-4302-6662-4_4_Fig4_HTML.jpg

图 4-4。

The Exception Site List Dialog Box

从现在开始,无论“安全级别”设置如何,您都可以从以下三个站点运行所有 Applet:

一旦您完成测试您的 Applet,请从例外列表中删除这些网站,这样您的计算机就不会运行恶意 Applet。

查看 Applet

如果您已经遵循了前面几节中的步骤,查看 applet 就像在浏览器中输入hellapapplet.html文件的 URL 一样简单。按照以下步骤查看 Applet。

Open the browser of your choice, such as Google Chrome. Mozilla Firefox, or Internet Explorer.   Press Ctrl + O or select the Open menu option from the File menu. You will get a browse/open dialog box. Navigate to the directory in which you have stored the helloapplet.html file and open it in the browser.   Depending on the settings in the Java Control Panel, you may get security warnings, which you need to ignore.   Alternatively, you can enter the URL for the HTML file directly. If you saved the helloapplet.html file in the C:\myapplets directory in windows, you can enter the URL as file:///C:/myapplets/helloapplet.html.   If everything was set up correctly, you will see the applet running in your browser as shown in Figure 4-5. Enter your name and click the Say Hello button to display a greeting dialog box.

A978-1-4302-6662-4_4_Fig5_HTML.jpg

图 4-5。

The Hello Applet running from the file system in the Google Chrome browser

如果您无法使用这些步骤查看 Applet,请阅读下一节,该节将描述如何使用appletviewer在测试期间查看 Applet。

使用 appletviewer 测试 Applet

您可以使用appletviewer命令查看 Applet。它在JAVA_HOME\bin文件夹中作为appletviewer程序提供,其中JAVA_HOME是您机器上的 JDK 安装文件夹。以下是命令语法的一般形式:

appletviewer <options> <urls>

在<options>中,您可以指定命令的各种选项。您必须指定一个或多个由空格分隔的包含 Applet 文档的 URL。您可以使用以下任何命令来查看上一节中描述的 applet。在 Microsoft Windows 上,可以使用命令提示符输入命令。在 Linux 上,使用终端窗口。

appletviewerhttp://www.jdojo.com/myapplets/helloapplet.html

或者

appletviewer file:///C:/myapplets/helloapplet.html

当您运行上述命令时,可能会出现以下错误:

'appletviewer' is not recognized as an internal or external command, operable program or batch file.

如果您收到上述错误,您需要指定 appletviewer 命令的完整路径,例如C:\java8\bin\appletviewer,假设您已经在C:\java8目录中安装了 JDK。您可以在 Windows 命令提示符下尝试以下命令:

C:\java8\appletviewerhttp://www.jdojo.com/myapplets/helloapplet.html

图 4-6 显示了在 appletviewer 窗口中运行的 Applet。请注意,appletviewer 只显示 URL 中指定的文档中的 applet。所有其他 HTML 内容都会被忽略。例如,applet 不显示您在<h3>标签中添加的来自helloapplet.html文件的文本。

A978-1-4302-6662-4_4_Fig6_HTML.jpg

图 4-6。

The Hello Applet running from the file system in the Google Chrome browser

如果您想使用appletviewer命令查看多个 Applet,您可以通过在命令行上指定多个 URL 来实现。每个 Applet 将显示在单独的 Applet 查看器窗口中。以下命令可用于显示来自两个不同 web 服务器的两个 Applet,其中URL_PART1可以是http://www.myserver1.com/myapplets1URL_PART2可以是 http://www.myserver2.com/myapplets2 :

appletviewer URL_PART1/applet1.html URL_PART2/applet2.html

appletviewer 命令在单独的窗口中显示文档中的每个 Applet。例如,如果applet1.html包含两个 Applet,而applet2.html包含三个 Applet,上述命令将打开五个 Applet 查看器窗口。如果 URL 引用的文档不包含任何 applet,appletviewer命令不做任何事情。URL 引用的文档中的内容将被忽略,与 applet 相关的部分除外。appletviewer 窗口有一个名为“applet”的主菜单,可以让你重新加载、重启、停止、保存一个 Applet,等等。

您可以为appletviewer命令指定三个选项:

  • -debug
  • -encoding
  • -Jjavaoptions

选项允许您在调试模式下启动 appletviewer。您可以使用选项指定 URL 引用的文档编码。选项允许您为 JVM 指定任何 Java 选项。选项的–J部分被删除,剩余部分被传递给 JVM。以下是使用这些选项的示例。注意,要为 appletviewer 指定 classpath 环境变量,需要指定两次–J选项。

appletviewer –debug your_document_url_goes_here

appletviewer –encoding ISO-8859-1 your_document_url_goes_here

appletviewer –J-classpath -Jc:\myclasses your_document_url_goes_here

Tip

如果您使用 NetBeans IDE 开发 Applet,请右键单击 Applet 文件,例如 IDE 中的HelloApplet.java,并选择Run File菜单选项,在 appletviewer 中运行您的 Applet。

使用 codebase 属性

HelloApplet示例中,您将 Java 类文件和 HTML 文件放在同一个父目录下。您的文件放置如下:

  • ANY_DIR\html_file
  • ANY_DIR\package_directories\class_file

您不必遵循上述目录结构来使用您的 Applet。存储 applet 的 HTML 文件的父目录称为文档。存储 Java 类文件(总是考虑放置 applet 类的包所需的目录结构)的父目录称为代码库。您可以使用codebase属性在<applet>标签中为您的 applet 指定一个代码库。如果不指定codebase属性,则文档库被用作codebasecodebase属性可以是相对 URL,也可以是绝对 URL。使用代码库的绝对 URL 为存储 applet 类文件开辟了另一种可能性。您可以将 applet 的 HTML 文件存储在一个 web 服务器上,而将 Java 类存储在另一个 web 服务器上。在这种情况下,您必须为 java 类指定一个绝对的codebase

使用 HTML 文档中的<base>标签的href属性的值来解析codebase属性的相对 URL。如果 HTML 文档中没有指定<base>标签,则使用下载 HTML 文档的 URL 来解析相对的codebase URL。我们来看一些例子。

例 1

一个helloapplet.html文件的内容如下。注意,您包含了一个<base>标记,并且没有为<applet>标记指定codebase属性。

<html>

<head>

<title>Hello Applet</title>

<base href="http://www.jdojo.com/myapplets/myclasses

</head>

<body>

<applet code="com.jdojo.applet.HelloApplet" width="150" height="100">

This browser does not support Applets.

</applet>

</body>

</html>

使用 URL http://www.jdojo.com/myapplets/helloapplet.html 下载文档。既然你已经指定了<base>标签,浏览器将在 http://www.jdojo.com/myapplets/myclasses/com/jdojo/applet/HelloApplet.class 寻找 applet 的类文件。

示例 2

一个helloapplet.html文件的内容如下。注意,您包括了<base>标签,并且没有将<applet>标签的codebase属性指定为mydir

<html>

<head>

<title>Hello Applet</title>

<base href="http://www.jdojo.com/myapplets/myclasses

</head>

<body>

<applet code="com.jdojo.applet.HelloApplet" width="150" height="100"

codebase="mydir">

This browser does not support Applets.

</applet>

</body>

</html>

使用 URL http://www.jdojo.com/myapplets/helloapplet.html 下载文档。既然你已经指定了<base>标签,浏览器将在 http://www.jdojo.com/myapplets/myclasses/mydir/com/jdojo/applet/HelloApplet.class 寻找 applet 的类文件。注意,mydircodebase值是使用<base>标签的href值解析的。如果您将codebase值指定为../xyzdir(两个点表示向上一个目录),浏览器将在 http://www.jdojo.com/myapplets/xyzdir/com/jdojo/applet/HelloApplet.class 处查找类文件。注意,出于安全原因,有些浏览器不允许您指定两个点来表示目录层次结构中的上一级,作为codebase URL 的一部分。

例 3

一个helloapplet.html文件的内容如下。请注意,您没有包含<base>标签,而是为<applet>标签指定了codebase属性。

<html>

<head>

<title>Hello Applet</title>

</head>

<body>

<applet code="com.jdojo.applet.HelloApplet"

width="150" height="100" codebase="abcdir">

This browser does not support Applets.

</applet>

</body>

</html>

使用 URL http://www.jdojo.com/myapplets/helloapplet.html 下载文档。由于您没有指定<base>标签,代码库的相对 URL 将使用用于下载 HTML 文件的 URL 进行解析,浏览器将在 http://www.jdojo.com/myapplets/abcdir/com/jdojo/applet/HelloApplet.class 处查找类文件。

如果您为codebase使用绝对 URL,浏览器将使用该绝对 URL 查找 applet 的类文件,而不管 HTML 文件中是否存在标签,也不管 HTML 文件是从哪里下载的。让我们考虑下面的<applet>标签:

<applet code="com.jdojo.applet.HelloApplet" width="150" height="100"

codebase="http://www.jdojo.com/myclasses

This browser does not support Applets.

</applet>

浏览器会在 http://www.jdojo.com/myclasses/com/jdojo/applet/HelloApplet.class 寻找 Applet 的类文件。如果想将 applet 的类文件和 HTML 文件存储在不同的服务器上,需要将codebase值指定为绝对 URL。

Applet类提供了两个名为getDocumentBase()getCodeBase()的方法来分别获取文档基 URL 和代码基 URL。getDocumentBase()方法返回嵌入了<applet>标签的文档的 URL。例如,如果您在浏览器中输入 URL http://www.jdojo.com/myapplets/helloapplet.html 来查看 Applet,那么getDocumentBase()方法将返回 http://www.jdojo.com/myapplets/helloapplet.htmlgetCodeBase()方法返回用于下载 applet 的 Java 类的目录的 URL。从这个方法返回的 URL 取决于许多因素,正如您刚才在示例中看到的那样。

Applet 的生命周期

applet 在其存在期间会经历不同的阶段。它被创建、初始化、启动、停止和销毁。applet 首先通过调用其构造函数来创建。在创建它的时候,它是一个简单的 Java 对象,并没有获得它的“applet”状态。在创建之后,它获得它的 applet 状态,并且在Applet类中有四个方法被浏览器调用。您可以在这些方法中放置代码来执行不同种类的逻辑。这些方法如下:

  • init()
  • start()
  • stop()
  • destroy()

init()方法

在 applet 被实例化和加载后,浏览器调用该方法。您可以重写此方法来放置为您的 applet 执行初始化逻辑的任何代码。通常,您将在这个方法中放置代码来为您的 applet 创建 GUI。这个方法在 applet 的生命周期中只被调用一次。

start()方法

紧接在init()方法之后调用start()方法。它可能被多次调用。假设您正在查看网页中的 applet,并且您通过替换 applet 的网页在同一浏览器窗口(或选项卡)中打开了另一个网页。如果你返回到前一个网页,如果 Applet 被缓存,它的start()方法将被再次调用。如果当你用另一个网页替换 Applet 的网页时,Applet 被破坏了,它的生命周期将重新开始,它的init()start()方法将被依次调用。您可以在此方法中放置任何启动进程的代码,例如当 applet 显示在网页上时的动画。

stop()方法

该方法是start()方法的对应物。它可能被多次调用。通常,当显示 applet 的网页被另一个网页替换时,会调用该函数。它也在调用destroy()方法之前被调用。通常,在这个方法中放置代码来停止任何进程,比如在start()方法中启动的动画。

destroy()方法

当 applet 被销毁时,调用该方法。您可以放置执行逻辑的代码来释放在 applet 生命周期中被占用的任何资源。总是在调用destroy()方法之前调用stop()方法。这个方法在 applet 的生命周期中只被调用一次。

清单 4-4 包含了一个 applet 的代码,当调用 applet 的init()start()stop()destroy()方法时,它会显示一个对话框。它包括消息中调用start()stop()方法的次数。

清单 4-4。演示 Applet 生命周期的 Applet

// AppletLifeCycle.java

package com.jdojo.applet;

import javax.swing.JApplet;

import javax.swing.JLabel;

import javax.swing.JOptionPane;

public class AppletLifeCycle extends {

private int startCount = 0;

private int stopCount = 0;

@Override

public void init() {

this.getContentPane().add(new JLabel("Applet Life Cycle!!!"));

JOptionPane.showMessageDialog(null, "init()");

}

@Override

public void start() {

startCount++;

JOptionPane.showMessageDialog(null, "start(): " + startCount);

}

@Override

public void stop() {

stopCount++;

JOptionPane.showMessageDialog(null, "stop(): " + stopCount);

}

@Override

public void destroy() {

JOptionPane.showMessageDialog(null, "destroy()");

}

}

清单 4-5 包含了查看AppletLifeCycleApplet 的 HTML 文件的内容。它假设 HTML 文件和 Java 类文件将放在如下所示的目录结构中:

ANY_DIR\appletlifecycle.html

ANY_DIR\com\jdojo\applet\AppletLifeCycle.class

如果您有不同的目录结构,您可能需要在一个<applet>标签中包含codebase属性。您可以使用前面描述的步骤查看 Applet。

清单 4-5。查看 AppletLifeCycle Applet 的 AppletLifeCycle 文件的内容

<html>

<head>

<title>Lifecycle of an Applet</title>

</head>

<body>

<applet code="com.jdojo.applet.AppletLifeCycle"

height="200" width="200">

This browser does not support Applets.

</applet>

</body>

</html>

将参数传递给 Applet

您可以让 Applet 的用户通过在 HTML 文档中向它传递参数来配置 Applet。您可以使用<applet>标签中的<param>标签向 applet 传递参数。<param>标签有两个属性叫做namevalue<param>标签的namevalue属性分别用于指定参数的名称和值。您可以使用多个<param>标签向 applet 传递多个参数。下面的 HTML 代码片段向 applet 传递两个参数:

<applet code="MyApplet" width="100" height="100">

<param name="buttonHeight" value="20" />

<param name="buttonText" value="Hello" />

</applet>

参数名为buttonHeightbuttonText,其值分别为20Hello。确保 applet 参数的名称有意义,对阅读它们的用户有意义。从技术上讲,任何字符串都可以作为参数名。比如,从技术上来说,p1p2是和buttonHeightbuttonText一样好的参数名。然而,后者对用户更有意义。

Applet类提供了一个方法,该方法接受参数名作为其参数,并将参数值作为String返回。注意,不管参数的值是多少,它总是返回一个String。例如,如果您想将参数buttonHeight的值20作为一个整数,您需要在 applet 的 Java 代码中将String转换成一个整数。传递给getParameter()方法的参数名称不区分大小写;getParameter("buttonHeight")getParameter("BUTTONHEIGHT")都返回与String相同的20值。如果 HTML 文档中没有设置指定的参数,getParameter()方法返回null。以下代码片段演示了如何在 applet 代码中使用getParameter()方法:

// buttonHeight and buttonText will get the values 20 and Hello

String buttonHeight = getParameter("buttonHeight");

String buttonText = getParameter("buttonText") ;

// bgColor will be null as there is no backgroundColor parameter set

String bgColor = getParameter("backgroundColor");

您可以使用参数自定义 applet 的一些方面。如果参数值发生变化,您不必更改代码。如果您向 applet 传递参数,请确保为每个参数分配一个默认值,以防在 HTML 文档中没有设置该值。例如,您可以将 applet 的背景颜色设置为 applet 的参数。如果没有设置,可以默认为灰色或白色。

清单 4-6 显示了一个AppletParameters applet 的代码。它使用两个 GUI 组件,一个显示欢迎消息的JTextArea和一个JButton。欢迎消息和按钮的文本可以通过两个名为welcomeTexthelloButtonText的参数定制。applet 代码读取其init()方法中的两个参数值。如果 HTML 文档中没有设置参数的默认值,它将设置这些参数的默认值。清单 4-7 包含了 HTML 文件的内容,图 4-7 显示了 Applet 的运行。图 4-8 显示了当您点击 Say Hello 按钮时显示的消息框。

清单 4-6。使用标签向 Applet 传递参数

// AppletParameters.java

package com.jdojo.applet;

import java.awt.Container;

import java.awt.FlowLayout;

import javax.swing.JApplet;

import javax.swing.JButton;

import javax.swing.JOptionPane;

import javax.swing.JScrollPane;

import javax.swing.JTextArea;

public class AppletParameters extends JApplet {

private JTextArea welcomeTextArea = new JTextArea(2, 20);

private JButton helloButton = new JButton();

@Override

public void init() {

Container contentPane = this.getContentPane();

contentPane.setLayout(new FlowLayout());

contentPane.add(new JScrollPane(welcomeTextArea));

contentPane.add(helloButton);

// Show parameters when the button is clicked

helloButton.addActionListener(e -> showParameters());

// Make the welcome JTextArea non-editable

welcomeTextArea.setEditable(false);

// Display the welcome message

String welcomeMsg = this.getParameter("welcomeText");

if (welcomeMsg == null || welcomeMsg.equals("")) {

welcomeMsg = "Welcome!";

}

welcomeTextArea.setText(welcomeMsg);

// Set the hello button text

String helloButtonText = this.getParameter("helloButtonText");

if (helloButtonText == null || helloButtonText.equals("")) {

helloButtonText = "Hello";

}

helloButton.setText(helloButtonText);

}

private void showParameters() {

String welcomeText = this.getParameter("welcomeText");

String helloButtonText = this.getParameter("helloButtonText");

String msg = "Parameters passed from HTML are\nwelcomeText="

+ welcomeText + "\nhelloButtonText=" + helloButtonText;

JOptionPane.showMessageDialog(null, msg);

}

}

清单 4-7。用于查看 Applet 参数 Applet 的 AppletParameters 文件的内容

<html>

<head>

<title>Applet Parameters</title>

</head>

<body>

<applet code="com.jdojo.applet.AppletParameters"

width="300" height="50">

<param name="welcomeText"

value="Welcome to the applet world!"/>

<param name="helloButtonText"

value="Say Hello"/>

This browser does not support Applets.

</applet>

</body>

</html>

A978-1-4302-6662-4_4_Fig8_HTML.jpg

图 4-8。

The AppletParameters applet running in a browser

A978-1-4302-6662-4_4_Fig7_HTML.jpg

图 4-7。

The AppletParameters applet running in a browser Tip

您还可以使用Applet类的方法来获取标签的属性值。例如,您可以使用 getParameter("code")来获取标签的code属性的值。

发布 Applet 的参数信息

applet 允许您发布关于它所接受的参数的信息。您可以开发一个知道其参数的 applet。您的 Applet 可能会被其他用户使用不同的 Applet 查看器查看。发布您的 applet 接受的参数可能对托管 applet 的程序和查看它的用户有所帮助。例如,applet 查看器可以让用户交互地改变 applet 的参数并重新加载 applet。Applet类提供了一个方法,您需要在 applet 类中覆盖它来发布关于 applet 参数的信息。它返回一个二维(nX3)数组String。默认情况下,它返回null。数组的行数应该等于它接受的参数数。每行应该有三列,包含参数的名称、类型和描述。在你的 Applet 中实现getParameterInfo()方法并不是你的 Applet 工作的必要条件。但是,通过这种方法提供关于 applet 参数的信息是一种很好的做法。让我们假设下面的<applet>标签用于显示您的 applet:

<applet code="MyApplet" width="100" height="100">

<param name="buttonHeight" value="20" />

<param name="buttonText" value="Hello" />

</applet>

MyApplet类方法的一个可能实现如下。注意,作为开发者,你只是 Applet 参数信息的发布者。这取决于 applet 浏览器程序以他们选择的任何方式使用它。

public class MyApplet extends JApplet {

// Other code for applet goes here...

// Public applet's parameter info

public String[][] getParameterInfo() {

String[][] parametersInfo =

{ {"buttonHeight",

"integer",

"Height for the Hello button in pixel"

},

{"buttonText",

"String",

"Hello button's text"

}

};

return parametersInfo;

}

}

发布 Applet 的信息

Applet类提供了一个应该返回 applet 文本描述的方法。该方法的默认实现返回null。从这个方法返回 applet 的简短描述是一个很好的实践,这样 applet 的用户可以对 applet 有更多的了解。该描述可以由用于查看 Applet 的工具以某种方式显示。下面的代码片段说明了如何使用getAppletInfo()方法来提供关于 applet 的信息:

public class MyApplet extends JApplet {

// Other applet's logic goes here...

public String getAppletInfo() {

return "My Demo Applet, Version 1.0, No Copyright";

}

}

标签的其他属性

表 4-1 列出了<applet>标签的所有属性及其用法。除了这个表中列出的属性,你还可以使用其他一些标准的 HTML 属性,比如idstyle等。带着<applet>标签。

表 4-1。

The List of Attributes for the tag

| 名字 | 使用 | | --- | --- | | `Code` | 指定 Applet 的类的完全限定名或 Applet 的类文件名。 | | `codebase` | 指定包含 applet 类的基本目录的 URL。如果未指定,则使用标记中指定的文档的基本 URL 或下载文档的 URL 作为其值。它的值可以是相对或绝对 URL。相对 URL 是基于``标签值中的文档基本 URL(如果存在)或下载文档的 URL 来解析的。 | | `Width` | 以像素或其容器宽度的百分比指定 Applet 的宽度。例如,`width="100"`将 applet 的宽度指定为 100 像素,而`width="30%"`将 applet 的宽度指定为其容器宽度的 30%。 | | `height` | 以像素或其容器高度的百分比指定 applet 的高度。例如,`height="200"`指定 applet 的高度为 200 像素,而`height="20%"`指定 applet 的高度为其容器高度的 20%。 | | `archive` | 指定逗号分隔的归档文件(JAR 或 ZIP 文件)列表。存档文件可能包含类别和其他资源,如图像、音频等。,由 applet 使用。存档文件可以使用相对或绝对 URL。使用`codebase`属性的值解析相对 URL。如果您的 applet 使用打包在归档文件中的多个类和其他资源,下载时间会大大减少。如果你不使用存档,你的 applet 的每个类和资源将在需要的时候单独下载。如果您对它们进行存档,存档中包含的所有文件都将通过一个到服务器的连接进行下载,从而减少了下载时间。您可以选择将一些文件放在归档中,将一些文件放在目录中。如果您的 applet 需要一个资源,它首先在归档中寻找,然后在服务器上由`codebase`属性值指定的目录中寻找。 | | `object` | 指定包含 applet 序列化形式的文件的名称。您可以指定一个`code`属性或一个`object`属性,但不能同时指定两者。当 applet 显示时,它将被反序列化,并且它的`init()`方法不会被调用。它的`start()`方法将被调用。这个属性不常使用。 | | `Name` | 指定 Applet 的名称。您可以使用 Applet 的名称来查找在同一网页中运行的其他 Applet。您还可以通过使用一个``标签来指定 applet 的名称,该标签的`name`属性值为“name”。下面这两个都会把 Applet 的名字设置为`myapplet1` : `` `...` ``或者`` `` ``你可以通过使用`Applet`类的`getParameter("name")`方法来获得一个 Applet 的名字。 | | `alt` | 指定当浏览器理解``标签但无法运行 applet 时要显示的替换文本。最好在``和``标签之间使用文本来显示替换文本,替换文本中也可以包含 HTML 格式。 | | `align` | 指定 Applet 相对于周围内容的位置。其值可以是`bottom`、`middle`、`top`、`left`或`right`。请注意,该属性指定了 applet 相对于其周围环境的位置,而不是相对于其容器的位置。比如使用`align="middle"`不会让 Applet 出现在浏览器窗口中间。如果您想将 applet 放在浏览器窗口的中间,您需要使用另一种 HTML 技术,例如将``标签放在另一个容器(如`

`)中,然后设置`align`属性。例如,下面这段 HTML 代码将在浏览器窗口的中央放置一个 applet:`

``...``

` | | `hspace` | 以像素为单位指定 applet 左侧和右侧的间距。 | | `vspace` | 以像素为单位指定 Applet 顶部和底部的间距。 |

在 Applet 中使用图像

在 applet 中使用图像很简单。Applet类有一个重载的getImage()方法,该方法返回一个java.awt.Image对象。下面是这种方法的两个版本:

  • Image getImage(URL imageAbsoluteURL)
  • Image getImage(URL baseURL, String imageURLPath)

第一个版本采用图像的绝对 URL,如 http://www.jdojo.com/myapplets/img/welcome.jpg 。第二个版本采用图像的基本 URL 和 URL 路径。使用第一个参数解析图像的 URL,该参数是基本 URL。考虑 applet 中的以下代码片段:

URL baseURL = new URL("http://www.jdojo.com/myapplets/abc.html

Image welcomeImage = getImage(baseURL, "img/welcome.jpg");

将使用基本 URL 和来自 http://www.jdojo.com/myapplets/img/welcome.jpg 的相对图像的 URL 来获取welcome.jpg文件的内容。考虑 applet 中的以下代码片段:

URL baseURL = new URL("http://www.jdojo.com/myapplets/abc.html

Image welcomeImage = getImage(baseURL, "/img/welcome.jpg");

这一次,图像 URL 路径(/img/welcome.jpg)以正斜杠开始。该 URL 路径将被解析为 http://www.jdojo.com/img/welcome.jpg 。您可以将所有图像存储在存储 HTML 文档的目录下,并始终使用从getDocumentBase()方法返回的文档基 URL 作为基 URL 来获取图像。

getImage()方法立即返回。当 Applet 需要绘制图像时,就会下载图像。

清单 4-8 包含了一个使用图像的 applet 的代码。清单 4-9 给出了查看 applet 的 HTML 内容。

清单 4-8。在 Applet 中使用图像

// ImageApplet.java

package com.jdojo.applet;

import java.awt.Container;

import java.awt.Image;

import javax.swing.ImageIcon;

import javax.swing.JApplet;

import javax.swing.JLabel;

public class ImageApplet extends JApplet {

JLabel imageLabel;

@Override

public void init() {

Container contentPane = this.getContentPane();

Image img = this.getWelcomeImage();

if (img == null) {

imageLabel = new JLabel("Image parameter not set...");

}

else {

imageLabel = new JLabel(new ImageIcon(img));

}

contentPane.add(imageLabel);

}

private Image getWelcomeImage() {

Image img = null;

String imageURL = this.getParameter("welcomeImageURL");

if (imageURL != null) {

img = this.getImage(this.getDocumentBase(), imageURL);

}

return img;

}

}

清单 4-9。imageapplet.html 档案的内容

<html>

<head>

<title>Using Images in Applet</title>

</head>

<body>

<applet code="com.jdojo.applet.ImageApplet"

width="250" height="200">

<param name="welcomeImageURL"

value="img/welcome.jpg"/>

This browser does not support Applets.

</applet>

</body>

</html>

此示例假设了以下目录结构,其中ANY_DIR表示您的 web 服务器或本地文件系统中的目录:

  • ANY_DIR\myapplets\imageapplet.html
  • ANY_DIR\myapplets\images\welcome.jpg
  • ANY_DIR\myapplets\com\jdojo\applet\ImageApplet.class

相对于文档库的图像 URL 路径被指定为参数。如果 HTML 代码中没有指定图像 URL,applet 会在JLabel中显示一个字符串。如果您的目录结构与上面列出的不同,您需要修改 applet 的代码或 HTML 内容,然后才能成功运行该示例。

在 Applet 中播放音频剪辑

在 Applet 中播放音频剪辑很容易。Applet类有一个重载的getAudioClip()方法,该方法返回一个java.applet.AudioClip接口的实例。下面是这种方法的两个版本:

  • AudioClip getAudioClip(URL audioAbsoluteURL)
  • AudioClip getAudioClip(URL baseURL, String audioURLPath)

该方法的工作方式与getImage()方法相同。它会立即返回。音频剪辑在播放时被载入。AudioClip接口声明如下:

package java.applet;

public interface AudioClip {

void play();

void stop();

void loop();

}

您可以使用play()方法开始播放音频剪辑。您可以使用stop()方法停止播放音频剪辑。您可以使用其loop()方法循环播放音频剪辑。以下代码片段演示了如何在 applet 中使用音频剪辑:

// Assuming that audios/myaudio.wav is stored under a directory

// where the HTML file for the applet is stored

AudioClip clip = getAudioClip(getDocumentBase(), "audios/myaudio.wav");

clip.play(); //* Play the clip

// Other logic goes here

clip.stop(); // Stop the clip

Applet类包含一些方便的方法,让你不用处理AudioClip接口就可以播放一个音频剪辑。Applet 将下载音频剪辑并为您播放。您只需要指定音频剪辑的 URL 并使用Applet类的play()方法,如下所示:

// Assuming that the following code is inside your applet class

this.play(this.getDocumentBase(), "audios/myfav.wav");

如果你想在 Java 应用中播放一个音频剪辑,使用Applet类的newAudioClip()静态方法来获取AudioClip对象,如下所示:

URL myFavAudioURL  = new URL("http://www.jdojo.com/myfav.wav

AudioClip clip = Applet.newAudioClip(myFavAudioURL);

clip.play();

与 Applet 环境交互

applet 上下文是指运行 applet 的环境,例如浏览器、applet 浏览器等。接口的一个实例代表了 applet 的上下文。Applet类提供了一个返回 applet 上下文的方法。使用一个AppletContext对象,你可以打开一个新的文档,在 applet 的容器状态栏中显示一条消息,并获得对同一文档中运行的另一个 applet 的引用。下面的代码片段演示了AppletContext对象的一些用法:

// Get the applet context object

AppletContext context = getAppletContext();

// Open the Yahoo's home page in a new window

URL yahooURL = null;

try {

yahooURL = new URL("http://www.yahoo.com

context.showDocument(yahooURL, "_blank");

}

catch (MalformedURLException e) {

e.printStackTrace();

}

// Show a brief message in the status bar

context.showStatus("Welcome to the applet world!");

// Get reference of another applet named "crazyApplet"

Applet crazyApplet = context.getApplet("crazyApplet");

if (crazyApplet != null) {

context.showStatus("Found the crazy applet...");

// Now you can invoke methods on crazyApplet

}

该方法打开由第一个参数指定的另一个文档。通过使用它的第二个参数,您可以控制显示新文档的窗口。第二个参数的有效值为:"_self""_parent""_top""_blank""any existing/non-existing frame/window name"。标准 HTML/JavaScript 代码中也使用相同的值。

方法用于在浏览器的状态栏中显示一条简短但不太重要的消息。浏览器也使用相同的状态栏来显示消息。您不应该使用这种方法显示需要用户注意的重要消息。用户可能看不到您的消息,或者它可能在用户有机会看到之前被覆盖。如果需要显示重要消息,应该考虑在 applet 区域显示。

getApplet()getApplets()方法用于查找同一文档中运行的其他 Applet。有关 Applet 如何与其他 Applet 通信的更多详细信息,请参考下一节。

Tip

创建 applet 对象时,applet 上下文不可用。在 applet 的构造函数中调用getAppletContext()方法会返回nullgetImage()getAudioClip()方法调用AppletContext对象中相应的方法。由于 applet 的AppletContext对象在 applet 的构造函数执行时不可用,所以不要在其构造函数中使用Applet类的getImage()getAudioClip()方法。

Applet、HTML 和 JavaScript 的交流

applet 可以使用它的showDocument()方法打开另一个 HTML 文档。它还可以通过使用它的showStatus()方法在浏览器的状态栏上显示一条简短的消息。当您使用 Applet 时,还有许多其他的可能性。事实上,小应用、HTML 和 JavaScript 可以愉快地共存,它们可以相互融合。以下是一些可能性:

  • 一个 applet 可以与同一个 HTML 文档中的另一个 applet 通信。
  • applet 可以通过调用 JavaScript 函数与 JavaScript 进行通信。
  • JavaScript 可以通过访问 applet 的方法和字段与 applet 进行通信。

图 4-9 显示了 Applet、HTML 和 JavaScript 之间可能的交互。

A978-1-4302-6662-4_4_Fig9_HTML.jpg

图 4-9。

Communication between applets, HTML, and JavaScript

在一个 applet 能够与另一个 applet 通信之前,它必须找到它想要与之通信的 applet。AppletContext类有两个方法让 applet 在同一个 HTML 文档中找到另一个 applet:

  • Applet getApplet(String appletName)
  • Enumeration<Applet> getApplets()

getApplet()方法要求您传递您正在寻找的 applet 的名称,如果找到了,它将返回 applet 的引用。如果没有找到 applet,它返回null。要使此方法有效,您必须为要找到的 applet 指定一个名称。您可以使用如下所示的方法:

import java.applet.Applet;

...

Applet app = getAppletContext().getApplet("applet2");

if (app == null) {

// applet2 is not found

}

else {

// Work with applet2 object.

}

getApplets()方法返回页面上所有 Applet 的Enumeration,包括调用该方法的 Applet。您可以使用如下所示的方法:

import java.applet.Applet;

import java.util.Enumeration;

...

Enumeration<Applet> e = getAppletContext().getApplets();

while(e.hasMoreElements()) {

Applet app = e.nextElement();

// Work with app applet now

}

applet 可以使用netscape.javascript.JSObject类与 JavaScript 通信。JSObject类不是标准 Java 库的一部分。如果你已经安装了 JRE,它被打包在plugin.jar文件中,这个文件存储在一个JRE_HOME\lib文件夹中。如果你在你的 Applet 中使用了JSObject,你需要在你的CLASSPATH中包含这个文件,这样你的 Applet 的代码才能被编译。您可以使用JSObject.getWindow()静态方法获取浏览器窗口的引用。您需要将 applet 的引用传递给JSObject.getWindow()方法。以下代码片段演示了如何从 applet 调用 JavaScript 方法:

// Need to import the JSObject class

import netscape.javascript.JSObject;

// Get the reference of the browser window

JSObject browserWindow = (JSObject)JSObject.getWindow(this);

/* You need to use the call() method of the browserWindow passing the

JavaScript function name as a string and arguments as an Object array.

Assume that helloJS(msgText) is a JavaScript function which accepts a

string argument and returns some value.

*/

String methodName = "helloJS";

Object[] methodArgs = {"Hello from applet"};

Object returnValue = browserWindow.call(methodName, methodArgs);

要从 applet 内部访问 JavaScript,您必须在您的<applet>标签中包含一个属性。您的 applet 标签将如下所示:

<applet code="MyApplet" width="100" height="100" MAYSCRIPT>

...

</applet>

JavaScript 提供对文档中所有 Applet 的引用,作为文档对象的一个applets属性。属性是一个数组。您可以使用从零开始的索引或 applet 名称来访问它。假设在一个名为applet1applet2的文档中有两个 Applet。假设 HTML 文档中的所有 Applet 都有一个pushMessage(String msg)方法,下面的 JavaScript 函数具有调用页面上所有 Applet 和applet1pushMessage()方法的代码:

// A JavaScript function.

// Call the pushMessage() method of all applets on the page

function pushMessageToAllApplets() {

for(var i = 0; i < document.applets.length; i++) {

document.applets[i].pushMessage("Hello");

}

}

// A JavaScript function.

// Call the pushMessage() method of applet1 on the page

function pushMessageToApplet1() {

document.applets["applet1"].pushMessage("Hello applet1");

}

您可以从 JavaScript 代码中访问 applet 的任何公共方法或字段。请注意,JavaScript 不是编译语言,如果 applet 的方法或字段名称不存在,它可能会抛出运行时错误。

打包归档中的 Applet

你可以打包所有的 Java 类和资源,比如图片、音频等。对于存档(JAR 或 ZIP 文件)中的 applet。您可以使用一个或多个归档来打包您的 applet 资源。所有归档文件的名称都在一个逗号分隔的列表中指定为<applet>标记的archive属性值。使用codebase属性值解析归档文件的名称。

<applet code="MyApplet"

width="200"

height="200"

codebase="resources"

archive="myclasses1.jar, myclasses2.jar, myimages.zip">

</applet>

如果将所有资源打包到档案中,就不必在 web 服务器上维护特定的目录结构来存储类和其他资源。在归档中为 applet 打包所有资源在 applet 加载时间方面有很大的优势。它大大缩短了 applet 的加载时间,因为它使用一个连接来下载整个档案,而不是为每个要下载的文件连接一次。但是,如果由于某种原因,您不能将所有的 applet 类和资源打包到档案中,您可以将一些保存在目录中,将一些打包到档案中。如果 Applet 需要资源(类文件、图像、音频剪辑等)。),它首先在归档中查找,然后在服务器上查找。

事件调度线程和 Applet

在第三章的中,我介绍了很多关于 Swing 应用中事件调度线程的角色。关于事件调度线程和 Swing 的讨论也适用于 Applet,因为 Applet 也使用 Swing 组件。四个 applet 生命周期方法init()start()stop()destroy()由 applet 查看器(通常是 web 浏览器)调用,它们不在事件调度线程上调用。您应该编写自己的程序,以便所有与 Swing 相关的代码都在事件调度线程上执行。您已经用init()方法构建了 GUI,现在您知道了init()方法不是在事件调度线程上执行的。所以,你没有遵循正确的方法来使用 Swing 组件。在您的 Applet 中,您还没有遇到任何与事件调度线程相关的问题,因为到目前为止,这些例子都是微不足道的。如果您正在开发生产级别的 Applet,您需要遵循建议的准则。

您需要使用invokeAndWait()SwingUtilities类的方法在事件调度线程上运行您的代码。通常,您使用invokeLater()方法,以便您的代码被安排稍后在事件调度线程上运行。invokeLater()方法立即返回。你不应该使用invokeLater()方法从 applet 的init()方法构建 GUI。原因很明显。applet 查看器(通常是 web 浏览器)依次调用 applet 的init()start()方法。当init()方法返回时,它调用start()方法。如果您使用init()方法中的invokeLater()方法构建 GUI,那么init()方法将立即返回(不一定是在运行构建 GUI 的代码之后,而是在调度 GUI 构建代码稍后运行之后), applet 查看器将调用start()方法。也就是说,当start()方法执行时,您的 GUI 可能还没有准备好。然而,假设您的 applet 必须在init()方法返回之前初始化,这样您就可以在它的start()方法中执行接下来的步骤。这就是为什么您需要使用invokeAndWait()方法在 applet 的init()方法中构建 GUI 的原因,这样您就可以确保当调用start()方法时,您的 GUI 已经就位。下面是编写 applet 的init()方法的正确方法。清单 4-10 重写了HelloApplet类,并将其命名为BetterHelloApplet。它使用方法为 applet 构建 GUI。在事件上调用initApplet()方法——从init()方法调度线程。

清单 4-10。使用事件调度线程在 Applet 中构建 GUI

// BetterHelloApplet.java

package com.jdojo.applet;

import javax.swing.JApplet;

import javax.swing.SwingUtilities;

import java.awt.Container;

import java.awt.FlowLayout;

import java.lang.reflect.InvocationTargetException;

import javax.swing.JButton;

import javax.swing.JLabel;

import javax.swing.JOptionPane;

import javax.swing.JTextField;

import static javax.swing.JOptionPane.INFORMATION_MESSAGE;

import static javax.swing.JOptionPane.ERROR_MESSAGE;

public class BetterHelloApplet extends JApplet {

@Override

public void init() {

try {

// Build the GUI on thw event-dispatching thread

SwingUtilities.invokeAndWait(() -> initApplet());

}

catch (InterruptedException | InvocationTargetException e) {

JOptionPane.showMessageDialog(null, e.getMessage(),

"Error", ERROR_MESSAGE);

}

}

private void initApplet() {

// This method is supposed to be executed on the

// event-dispatching thread

// Create Swing components

JLabel nameLabel = new JLabel("Your Name:");

JTextField nameFld = new JTextField(15);

JButton sayHelloBtn = new JButton("Say Hello");

// Add an action litener to the button to show the hello message

sayHelloBtn.addActionListener(e -> sayHello(nameFld.getText()));

// Add Swing components to the content pane of the applet

Container contentPane = this.getContentPane();

contentPane.setLayout(new FlowLayout());

contentPane.add(nameLabel);

contentPane.add(nameFld);

contentPane.add(sayHelloBtn);

}

private void sayHello(String name) {

String msg = "Hello there";

if (name.length() > 0) {

msg = "Hello " + name;

}

// Display the hello message

JOptionPane.showMessageDialog(null, msg, "Hello", INFORMATION_MESSAGE);

}

}

在其他地方选择使用SwingUtilities类的invokeAndWait()invokeLater()方法取决于手头的情况。根据经验,您需要使用SwingUtilities类的两个方法之一,在事件调度线程中执行 applet 的init()start()stop()destroy()方法的代码。您可以使用SwingWorker类在后台线程中执行任何任务,并使用事件调度线程与 Swing 组件进行协调。请参考第三章了解在 Swing 应用中使用线程的详细信息。

用 Applet 绘画

Applet类继承自java.awt.Panel类。JApplet类继承自Applet类。如果你想直接在 applet 表面绘制图形或字符串,你需要覆盖它的paint(Graphics g)方法并编写你的代码。注意,如果您添加 Swing 组件并绘制到它们的表面上,您需要覆盖那些 Swing 组件的paintComponent(Graphics g)方法。或者,您可以覆盖Applet类的paint()方法并执行如清单 4-11 所示的绘制。

清单 4-11。使用 paint()方法绘制字符串的 Applet

// DrawingHelloApplet.java

package com.jdojo.applet;

import javax.swing.JApplet;

import java.awt.Graphics;

public class DrawingHelloApplet extends JApplet {

@Override

public void paint(Graphics g) {

super.paint(g);

g.drawString("Hello Applet!", 10, 20 );

}

}

Java 代码可信吗?

有两种 Java 代码可以在您的机器上运行:可信代码和不可信代码。没有硬性的规则来指定哪些 Java 代码总是可信的,哪些是不可信的。然而,有一些规则你可以遵循。默认情况下,您应该将 web 浏览器通过 Internet 下载来运行 Applet 的所有 Java 代码归类为不可信代码,因为您不知道是谁为 Applet 编写了代码。您可以将所有在您的机器上作为应用运行的本地 Java 代码归类为可信代码。当代码试图访问某些特权资源(如本地文件系统)时,可信代码和不可信代码之间的区别就显现出来了。默认情况下,Java 将所有本地存储的代码都视为可信代码,以授予对特权资源的完全访问权限。它将通过网络下载的代码视为不可信代码。它不会向不受信任的代码授予对特权资源的访问权限。Java 允许您使用策略文件将特权资源的访问权授予某些代码,而不是其他代码。让我们考虑一个例子来理解如何使用策略文件在 Java 中定制安全性。清单 4-12 包含了SecurityTest类的代码。它将一条文本消息写入一个名为c:\sec_demo.txt的文件。文件路径适用于 Windows 平台。您可以在运行该程序时根据自己的选择修改文件路径。

清单 4-12。将文本消息写入文件的 SecurityTest 类

// SecurityTest.java

package com.jdojo.applet;

import java.io.IOException;

import java.nio.file.Files;

import java.nio.file.Path;

import java.nio.file.Paths;

public class SecurityTest {

public static void main(String[] args) throws IOException {

// Message to be written to the file

String msg = "Testing Java filee permission security...";

// Change the path C:\sec_demo.txt to conform to the

// syntax supported by your operating system

Path filePath = Paths.get("C:\\sec_demo.txt");

// Write message to the file

Files.write(filePath, msg.getBytes());

// Print a message

System.out.println("Test message written to " + filePath);

}

}

您可以使用以下命令运行该类:

java com.jdojo.applet.SecurityTest

上述命令将在标准输出中打印以下消息:

Test message written to c:\sec_demo.txt

使用下面的命令运行同一个SecurityTest类会产生运行时错误。显示了部分错误消息:

java -Djava.security.manager com.jdojo.applet.SecurityTest

Exception in thread "main" java.security.AccessControlException: access denied ("java.io.FilePermission" "C:\sec_demo.txt" "write")

...

第二次运行SecurityTest类时,您将-Djava.security.作为 JVM 选项传递。该选项告诉 JVM 使用安全管理器运行该类。当您第一次运行这个类时,它是在没有安全管理器的情况下运行的,程序能够访问文件系统并写入文件。当安全管理器存在时,它检查授予执行代码的权限,执行代码需要访问一些资源。因为您没有授予代码写入文件的权限,所以当您第二次运行该类时会收到安全异常。

策略文件控制 Java 安全性。两个默认的策略文件授予 Java 代码权限。JRE_HOME\jre\lib\security\java.policy是系统范围的策略文件,其中JRE_HOME是安装 Java 运行时环境的目录。另一个策略文件是特定于用户的,它存储在USER_HOME\.java.policy中,其中USER_HOME是由user.home系统属性定义的用户主目录。注意特定于用户的默认 Java 策略文件名前面的点(.java.policy)。您还可以拥有自定义策略文件,并在运行应用时在命令行指定它们的 URL。

JRE_HOME\lib\security\java.security中存储了一个配置文件,它包含关于默认策略文件位置的详细信息以及其他与安全相关的细节。以下是 JRE 安装中提供的java.security文件的部分内容。它说明了两个 Java 策略文件的名称——一个在 Java 主目录中,一个在用户主目录中。您可以按照键的模式向其中添加更多的默认策略文件。

# The default is to have a single system-wide policy file,

# and a policy file in the user's home directory.

policy.url.1=file:${java.home}/lib/security/java.policy

policy.url.2=file:${user.home}/.java.policy

为了让示例工作并让它写入到一个C:\sec_demo.txt文件中,让我们创建一个名为jsec.policy的文件,并将以下文本添加到该文件中:

grant {

permission java.io.FilePermission "c:\\sec_demo.txt", "read, write";  };

};

将自定义安全文件另存为C:\jsec.policy。自定义策略文件中的 grant 语句声明如下:将对C:\sec_demo.txt文件的读写权限授予任何代码。写权限足以运行该示例。但是,您已经在策略文件中授予了读取和写入权限。注意 grant 语句中文件路径中的两个反斜杠(C:\\sec_demo.txt)。策略文件解析器将把两个反斜杠翻译成一个,文件路径将被视为C:\sec_demo.txt。用下面的命令运行SecurityTest类。整个命令在一行中输入。

java -Djava.security.manager -Djava.security.policy=file:/C:/jsec.policy com.jdojo.applet.SecurityTest

以下消息将打印在标准输出上:

Test message written to c:\sec_demo.txt

这一次,您指示 JVM 使用安全管理器和位于file:/C:/jsec.policy URL 的策略文件来运行SecurityTest类。请注意,您使用 URL 来定位策略文件,而不是文件路径。这意味着您可以将您的策略文件存储在 web 服务器上,并使用类似于 http://www.jdojo.com/mysec.policy 的 URL 来定位定制的策略文件。请注意,默认情况下,两个策略文件(一个系统范围的策略文件和一个用户特定的策略文件)仍然与您的自定义策略文件一起使用。如果不想创建自定义策略文件,可以在两个默认策略文件中的任何一个中添加上述权限,应用将运行相同的权限。

我不会详细讨论 Java 安全策略文件格式。JDK/JRE 附带了一个名为policytool的实用程序,它可以让你图形化地使用 Java 策略文件。它安装在JAVA_HOME\bin文件夹中,其中JAVA_HOME是你机器上的 JDK 或 JRE 安装文件夹。

为了开始讨论 applets 的安全限制和定制,我将讨论更多关于策略文件格式的内容。以下是在策略文件中授予权限的更多示例。您也可以在策略文件中使用 Java 注释。

在策略文件中授予权限的简化通用语法是

grant signedby "<signer names>", codebase "<code base URL>" {

permission <permission class name> "<target name>", "<actions>";

};

<...>中的文本由策略文件的作者提供。许多选项都是可选的。您可以在一个grant块中包含多个permission子句。一个策略文件中可以有多个grant块。signedby选项表示权限只授予由签名者签名的代码。例如,考虑下面的grant模块:

grant signedby "John" {

...

};

grant块表示如果代码由John签名,则许可被授予。

考虑下面的grant块:

grant signedby "John, Robert, Cheryl" {

...

};

grant块表示如果代码由JohnRobertCheryl签名,则许可被授予。我将在下一节讨论更多关于代码签名的内容。如果signedby选项不存在,权限将根据其他标准授予代码,而不管代码是否经过签名。

选项用于向从特定 URL 执行的代码授予权限。考虑下面的grant块:

grant codebase "file:/c:/classes" {

...

};

grant块表示来自file:/c:/classes URL 的代码将被授予权限。如果codebase选项不存在,那么从任何位置下载和执行的代码都会被授予权限。在 Java 策略文件中授予权限的一些示例如下:

/* Grant read and write permission to the file c:\sec_demo.txt

to code signed or unsigned and downloaded from any location.

*/

grant {

permission java.io.FilePermission "c:\\sec_demo.txt", "read, write";

};

/* Grant write permission to the file c:\sec_demo.txt to code signed or

unsigned and downloaded from file:/C:/classes/ URL.

*/

grant codebase "file:/C:/classes/" {

permission java.io.FilePermission "c:\\sec_demo.txt", "write";

};

/* Grant two permissions to the code signed by John and downloaded

from the file:/C:/classes/ URL.

1\. Grant the execute permission on the file c:\crazy.exe

2\. Grant the read permission for the system property user.home, so

the code can execute the statement System.getProperty("user.home").

If this permission is not granted, reading the property "user.home"

will throw a security exception.

*/

grant signedby "John", codebase "file:/C:/classes/" {

permission java.io.FilePermission "c:\\crazy.exe", "execute";

permission java.util.PropertyPermission "user.home", "read";

};

您可以将java.io.FilePermission授予一个文件或目录。您可以使用文件或目录路径以及一组操作来授予对文件的权限。您可以在文件/目录上授予任意组合的readwritedeleteexecute权限。多个操作用逗号分隔。策略文件支持不同的格式来指定文件/目录路径,如表 4-2 中所列。

表 4-2。

The List of File/Directory Path Format Used in Granting the java.io.FilePermission

| 文件/目录路径格式 | 描述 | | --- | --- | | 文件路径:`C:\mydir\test.txt` | 仅授予对此文件的权限。 | | 目录路径:`C:\mydir`或`C:\mydir\` | 仅授予对此目录的权限。(对于目录,一个尾随文件分隔符被视为没有尾随分隔符。在 UNIX 上是正斜杠比如`/usr/mydir`或者`/usr/mydir/`,在 Windows 上是反斜杠。 | | `C:\mydir\*` | 授予对`C:\mydir`目录下所有文件的权限。注意,它并没有授予对`C:\mydir`目录本身的权限。 | | `*` | 授予当前目录下所有文件的权限。 | | `C:\mydir\-` | 递归授予`C:\mydir`及其子目录下的所有文件和文件夹权限。 | | `-` | 递归授予当前目录及其子目录下的所有文件和文件夹权限。 | | `<>` | 授予对文件系统下所有文件和文件夹的权限。例如,下面的 grant 子句授予对文件系统中所有文件的所有代码的读取权限:`grant {` `permission java.io.FilePermission "<>", "read";` `};` |

Applet 的安全限制

默认情况下,applet 的代码被视为不受信任的代码,它在安全管理器下运行。如果您使用文件协议从本地文件系统运行 applet,浏览器可能会放宽一些限制。这些限制适用于使用网络协议(如httphttps)下载 Applet 代码的情况。默认情况下,通过网络下载的 applet 代码被认为是不可信的,即使下载代码的 web 服务器正在本地运行。以下是适用于不受信任的代码和 Applet 的部分限制列表:

  • 它不能访问本地文件系统。
  • 它不能连接到任何机器,除了从其下载代码的机器。
  • 它无法加载本机库。
  • 它不能在运行它的机器上启动程序。
  • 它只能读取一些系统属性,这些属性被认为是无害的。它可以读取系统属性,如OS.nameOS.versionjava.version等。它不能读取有潜在风险的系统属性,如user.homeuser.dirjava.class.path等。
  • 它不能使用System.exit()方法调用退出 JVM。
  • 它向用户显示带有一些视觉提示的弹出窗口,以指示弹出窗口是从 applet 显示的,并且它是不可信的。

Applet 如何执行一些受限制的任务?有两种方法可以让 applet 执行受限制的任务:

  • 您可以自定义策略文件并授予特定权限。
  • 你可以使用签名的 Applet。

清单 4-13 包含了试图读取user.home系统属性的 applet 的代码。清单 4-14 包含了查看 applet 的 HTML 代码。

清单 4-13。试图读取 user.home 系统属性的 Applet

// ReadUserHomeApplet.java

package com.jdojo.applet;

import javax.swing.JApplet;

import javax.swing.JTextArea;

import java.io.StringWriter;

import java.io.PrintWriter;

import javax.swing.JScrollPane;

import java.awt.Container;

public class ReadUserHomeApplet extends JApplet {

JTextArea msgTextArea = null;

@Override

public void init() {

String msg = "";

try {

String userHome = System.getProperty("user.home");

msg = "User's Home Directory is '" + userHome + "'";

}

catch (Throwable t) {

msg = this.getStackTrace(t);

}

this.msgTextArea = new JTextArea(msg, 10, 40);

Container contentPane = this.getContentPane();

contentPane.add(new JScrollPane(msgTextArea));

}

public String getStackTrace(Throwable t) {

StringWriter sw = new StringWriter();

PrintWriter pw = new PrintWriter(sw, true);

t.printStackTrace(pw);

pw.close();

return sw.toString();

}

}

清单 4-14。用于查看 ReadUserHomeApplet Applet 的 ReadUserHomeApplet 文件的内容

<html>

<head>

<title>Read User Home Directory</title>

</head>

<body>

<applet code="com.jdojo.applet.ReadUserHomeApplet"

width="400"

height="300">

This browser does not support Applets.

</applet>

</body>

</html>

清单 4-13 中的 applet 代码非常简单。它使用System.getProperty("user.home")方法读取用户的主目录。默认情况下,不允许 applet 读取user.home系统属性。当您查看这个 Applet 时,您会得到一个安全异常。部分异常消息如下:

java.security.AccessControlException: access denied ("java.util.PropertyPermission" "user.home" "read")

...

异常消息表明 applet 的代码没有类型为java.util.PropertyPermissionread权限来读取user.home系统属性。下面的grant块将这个权限授予所有代码:

grant {

permission java.util.PropertyPermission "user.home", "read";

};

将这个grant块添加到主目录下的一个.java.policy文件中。如果您的主目录中没有.java.policy文件,您可以用这个名称创建一个新文件,并将上面的授权添加到其中。在 Windows XP 上,您的主目录是C:\Documents and Settings\<your-user- name>。您还可以通过在一个没有运行在安全管理器下的独立 Java 应用中执行System.getProperty("user.home")语句来获取用户的主目录路径。在用户主目录的.java.policy文件中添加上述授权后,ReadUserHomeApplet applet 显示类似如下的消息:

User’s Home Directory is 'C:\Documents and Settings\sharan'

确保在完成这个例子后,从.java.policy文件中删除了授权块。否则,任何 Applet 都可以在您不知情的情况下读取您机器上用户的主目录。在下一节中,我将使用一个签名的 applet 来演示相同的例子。

签名 Applet

当 applet 在浏览器中运行时,它不能访问客户机上的任何东西,除了一些信息,如操作系统名、JVM 版本等。当您想要像访问 Java 应用一样访问 applet 时,您需要使用签名的 applet。

签名 applet 背后的概念与签名文档背后的概念相同。通过签署文档,您批准了它,并且通过批准它,您对文档中包含的内容负责。它不能保证在您签名后其他人不能篡改文档。但是,如果对文件的真实性有任何疑问,可以联系您进行核实。任何可以验证你的签名并信任你的人都可以认为你签署的文件是真实的。这个概念同样适用于 applet。回想一下,applet 的代码在默认情况下是不可信的。如果 applet 是一个签名的 applet,applet 的用户在 Java 策略文件中授予签名的 applet 权限,或者他可以信任 applet 并即时授予权限。

在签署 applet 之前,您必须有一个名为私钥/公钥的密钥对。您可以使用安装在JAVA_HOME\bin文件夹中的keytool命令生成一个密钥对,其中JAVA_HOME是您安装了 JDK 或 JRE 的文件夹。生成的密钥存储在一个称为密钥库的数据库中。keytool命令还允许您创建一个密钥库数据库。私钥是您的秘密密钥,而公钥是供想要验证您签名的公众使用的。一旦您有了一个密钥对,您需要生成一个证书请求(您可以使用带有–certreq选项的keytool命令来生成一个证书请求)发送给证书颁发机构(CA)以获得证书。CA 是颁发数字证书的组织。数字证书由您提供的公钥和您的身份组成。CA 将向您收取颁发证书的适当费用。DigiCert、Thawte 和 VeriSign 是一些可用的 ca。您也可以自己颁发证书,这就是您在本节中为了演示目的而要做的事情。但是,如果您在 web 上部署 applet 供公众使用,您需要花一些钱从可信的 CA 处获得证书,以增加 applet 签名的真实性。公众更有可能会相信 Thawte 颁发的证书,而不是你。注意从 Java 7 开始,您的 applet 必须被签名,打包在一个 JAR 文件中,其中包含一个具有属性Permissions的清单文件。否则,默认情况下,您的 Applet 不会运行。

您需要将您的 applet 类打包到一个 JAR 文件中,以便您可以对其进行签名。您可以使用JAVA_HOME\bin文件夹下的命令用您的秘密私钥对 JAR 文件进行签名,其中JAVA_HOME是 JDK 或 JRE 的安装文件夹。还有其他工具可以用来对 JAR 文件进行签名。签名过程将把证书和公钥放在 JAR 文件中,供 JAR 文件的用户验证签名。

以下步骤将引导您完成签名和使用 applet 的过程。

步骤 1:开发 Applet

您需要编写 applet 的源代码,并将其编译成类文件。使用清单 4-13 中列出的ReadUserHomeApplet类。在这一步结束时,您将拥有一个ReadUserHomeApplet.class文件。

步骤 2:将类文件打包到 JAR 文件中

用以下内容创建一个manifest.mf文件。记得在文件末尾加一个空行。

Manifest-Version: 1.0

Permissions: sandbox

使用以下命令创建一个signedapplet.jar文件。在你的JDK_HOME\bin文件夹中有jar命令。

jar cvfm signedapplet.jar manifest.mf com/jdojo/applet/ReadUserHomeApplet.class

确保在 JAR 文件中,类文件的路径被设置为com/jdojo/applet,,这与其包相同。要确保 JAR 文件包含类文件的正确路径,请使用以下命令:

jar -tf signedapplet.jar

META-INF/

META-INF/MANIFEST.MF

com/jdojo/applet/ReadUserHomeApplet.class

在这一步结束时,您将拥有一个signedapplet.jar文件。

步骤 3:生成私钥/公钥对

使用keytool命令生成一个私钥/公钥对,如下所示:

keytool -genkey -keystore mykeystore –alias Kishori

上面的命令将创建一个名为mykeystore的密钥库文件。它将生成一个私有/公共密钥对,您可以使用别名Kishori来使用它。请注意,您需要为您的密钥对指定一个别名。从现在开始,您将使用别名Kishori来引用您的密钥库中的密钥。上面的命令将询问识别您的详细信息。你需要输入这些信息。密钥库文件受密码保护。私钥也受密码保护。使用上述命令时,您需要使用新密码。记住这些密码,因为您将被要求提供这些密码来访问别名Kishori所引用的密钥库或密钥对。

您可以使用以下命令亲自验证生成的密钥:

keytool -keystore mykeystore -selfcert –alias Kishori

在这一步的最后,您将拥有一个名为mykeystore的文件,并且您将生成一个别名为Kishori的密钥对。

步骤 4:签署 JAR 文件

使用以下命令对 JAR 文件进行签名:

jarsigner -keystore mykeystore signedapplet.jar Kishori

以上命令将提示您输入密钥库密码。在这一步结束时,您将拥有一个签名的signedapplet.jar文件。如果您列出了signedapplet.jar文件的目录,您会注意到jarsigner命令向其中添加了更多的文件,如下所示:

jar -tf signedapplet.jar

META-INF/MANIFEST.MF

META-INF/KISHORI.SF

META-INF/KISHORI.DSA

META-INF/

com/jdojo/applet/ReadUserHomeApplet.class

步骤 5:创建 HTML 文件

创建一个 HTML 文件来查看签名的 applet,如清单 4-15 所示。注意<applet>标签使用了一个archive属性。

清单 4-15。signedreaduserhomeapplet.html 档案的内容

<html>

<head>

<title>Read User Home Directory (signed Applet)</title>

</head>

<body>

<applet code="com.jdojo.applet.ReadUserHomeApplet"

width="400" height="300"

archive="signedapplet.jar">

This browser does not support Applets.

</applet>

</body>

</html>

步骤 6:查看签名的 Applet

当您试图查看已签名的 applet 时,Java 插件会显示一个安全警告窗口。它允许您查看用于签署 applet 的签名的详细信息。您可以单击“运行”按钮来运行 Applet。勾选“始终信任来自该发行商的内容”复选框,点击“运行”将使 Java 插件将该证书作为可信证书导入,如图 4-10 所示。

A978-1-4302-6662-4_4_Fig10_HTML.jpg

图 4-10。

The Certificates dialog in the Java Control Panel

一旦 Java 插件将证书存储在其受信任的证书存储库中,它将在将来信任该证书,而不提示您。您可以稍后通过转到 Java 控制面板➤安全选项卡➤管理器证书从存储库中删除曾经信任的证书。

通过信任一个已签名的 Applet,您就给了它所有的权限。如果您仍然希望使用 Java 策略文件将权限应用于已签名的可信 Applet,您需要使用 Java 策略文件中的usePolicy java.lang.RuntimePermission,这将指示 Java 插件不提示用户接受已签名的 Applet 的证书。相反,它将应用策略文件中授予该 applet 的权限。

策略文件(系统范围的或用户特定的策略文件)中的以下条目将指导 Java 插件始终使用策略文件:

grant {

permission java.lang.RuntimePermission "usePolicy";

};

您的 applet 将能够访问您在策略文件中授予的资源。Java 插件不会提示用户信任 applet。

您还可以通过转到 Java 控制面板➤高级选项卡➤安全用户环境,关闭“允许用户授予对已签名内容的权限”选项,来指示 Java 插件不提示用户授予对已签名 Applet 的访问权限。如果未选中此选项,当用户试图查看 Applet 时,将会收到安全警告。默认情况下,此选项处于启用状态。

摘要

applet 是一种 Java 程序,设计用于嵌入 HTML 文档并在 web 浏览器中运行。从技术上讲,applet 是一个继承自AppletJApplet类的 Java 类。如果您从JApplet类继承了 applet 的类,那么为 applet 编写代码与为 Swing 应用编写代码非常相似。Applet 使用 Swing 组件来构建 GUI。

使用<applet>标签将 applet 嵌入到 HTML 文档中。HTML 文档可以是静态 HTML 文件,也可以是动态生成的,例如在 JSP 页面中。通常,applet 的 Java 类文件和资源(如图像和音频)被打包在 JAR 文件中,并存储在 web 服务器上。在客户机上,浏览器请求 HTML 文档,解析 HTML 文档中的<applet>标记,下载 applet 的代码,并在浏览器中运行代码。默认情况下,Applet 在安全沙箱中运行,它们不能访问客户机的机器资源,如本地文件系统。使用 Java 策略文件或签署 applet 的 JAR 文件可以放宽这些限制。

标签用来在 HTML 文档中嵌入一个 applet。<applet>标签包含四个强制属性,称为codewidthheightarchive。属性指定了 applet 类的完全限定名。widthheight属性指定浏览器中 applet 显示区域的宽度和高度。属性指定了包含 applet 文件的归档文件(JAR/ZIP)。注意,如果您想使用默认的 Java 插件设置运行 applet,那么archive属性是强制的。

您需要安装 Java 插件(JRE 的一部分)来在您机器上的浏览器中运行 Applet。您可以使用随 Java 插件一起安装的 Java 控制面板程序来配置 Java 插件。

applet 有一个生命周期。它的四个方法init()start()stop()destroy()在其生命周期中被自动调用。在 applet 被实例化和加载后,浏览器调用init()方法。start()方法在init()方法之后被调用,并且可能被多次调用。stop()方法是start()方法的对应方法,可能会被多次调用。当 applet 被销毁时,在 applet 的生命周期结束时调用destroy()方法。这些方法不会在事件调度线程上调用。使用SwingUtilities类,在第三章中有描述,在事件调度线程中运行任何 GUI 相关的代码。

对象的一个实例代表运行它的 Applet 的上下文。您可以使用这个对象与 applet 的上下文进行交互,比如来自 Java 代码的浏览器。

在 Java 7 和更高版本中,默认情况下,如果不满足以下条件,Applet 将被阻止:

  • applet 应该打包在一个签名的 JAR 文件中。
  • applet 的 JAR 文件中的清单文件应该包含一个Permissions属性,其值可以是sandboxall-permissions
  • applet 的 JAR 文件应该用可信机构颁发的证书进行签名。

您可以通过使用 Java 控制面板配置 Java 插件设置来放宽这些限制,尽管不建议这样做。

五、网络编程

在本章中,您将学习

  • 什么是网络编程
  • 什么是网络协议套件
  • 什么是 IP 地址,不同的 IP 编址方案是什么
  • 特殊 IP 地址及其用途
  • 什么是端口号以及如何使用它们
  • 使用 TCP 和 UDP 客户端和服务器套接字在远程计算机之间进行通信
  • URI、URL 和 URN 的定义以及如何在 Java 程序中表示它们
  • 如何使用非阻塞套接字
  • 如何使用异步套接字通道
  • 面向数据报的套接字通道和多播数据报通道

前几节旨在为没有计算机科学背景的读者快速概述与网络技术相关的基础知识。如果你理解 IP 地址、端口号、网络协议套件等术语。,您可以跳过这些章节,开始阅读“Socket API 和客户机-服务器范例”一节。

什么是网络编程?

网络是一组两台或多台计算机或其他类型的电子设备(如打印机),它们为了共享信息而连接在一起。连接到网络的每个设备称为一个节点。与网络相连的计算机称为主机。Java 中的网络编程包括编写 Java 程序,以促进网络上不同计算机上运行的进程之间的信息交换。

Java 使编写网络程序变得容易。向另一台计算机上运行的进程发送消息就像向本地文件系统写入数据一样简单。类似地,接收从另一台计算机上运行的进程发送的消息就像从本地文件系统中读取数据一样简单。本章中的大多数程序都将涉及通过网络读写数据,它们类似于文件 I/O。请参考《Java 语言特性入门》( ISBN: 978-1-4302-6658-7)一书中的第七章到第十章,了解文件 I/O 的更多详细信息。您将了解到本章中的几个新类,它们有助于网络上两台计算机之间的通信。

要理解或编写本章中的 Java 程序,您不需要具备网络技术的高级知识。本章涵盖了网络通信中所涉及的一些事物的高级细节。

网络可以根据不同的标准进行分类。根据网络分布的地理区域,网络可分为以下几类

  • 局域网(LAN):它覆盖一个小区域,如一栋大楼或一组大楼。
  • 校园网(CAN):它覆盖一个校园,如大学校园,在校园内互连多个局域网。
  • 城域网:它比局域网覆盖更大的地理区域。通常,它覆盖一个城市。
  • 广域网(WAN):它覆盖更大的地理区域,例如一个国家的一个区域或世界上不同国家的多个区域。

当两个或两个以上的网络通过路由器(也称为网关)连接在一起时,这被称为网间互联,由此形成的组合网络被称为网间互联,简称 internet(注意小写的 I)。全球互联网络,包括世界上所有连接在一起的网络,被称为互联网(注意大写的 I)。

基于拓扑结构(网络中节点的排列),网络可以分为星形、树形、环形、总线式、混合式等。

根据网络用于传输数据的技术,网络可以分为以太网、本地话、光纤分布式数据接口(FDDI)、令牌环、异步传输模式(ATM)等。

我不会涉及不同种类的网络的任何细节。请参考任何关于网络的标准教材,详细了解网络和网络技术。

计算机上两个进程之间的通信很简单,它是使用操作系统定义的进程间通信来实现的。当在互联网上的两台不同的计算机上运行的两个进程需要通信时,这是一项非常乏味的任务。在 internet 上两台计算机上的两个进程开始通信之前,您需要考虑通信的许多方面。您需要考虑的一些要点如下:

  • 两台计算机可能使用不同的技术,如不同的操作系统、不同的硬件等。
  • 它们可能位于使用不同网络技术的两个不同网络上。
  • 它们可能被使用不同技术的许多其它网络分隔开。也就是说,两台计算机不在两个直接互连的网络上。您需要考虑的不仅仅是两个网络,而是来自一台计算机的数据必须通过才能到达另一台计算机的所有网络。
  • 他们可能相隔几英里,或者在地球的另一边。你如何高效地传输信息而不用担心两台电脑之间的距离?
  • 一台计算机可能不理解另一台计算机发送的信息。
  • 通过网络发送的信息可能会被复制、延迟或丢失。接收方和发送方应该如何处理这些异常情况?

简单地说,网络上的两台计算机使用消息(0 和 1 的序列)进行通信。必须有定义良好的规则来处理上述问题(以及许多其他问题)。处理特定任务的规则集称为协议。处理网络通信涉及许多类型的任务。有一个协议来处理每个特定的任务。有一组协议(也称为协议组)一起用于处理网络通信。

网络协议组

现代网络之所以被称为网络,是因为它们以称为数据包的块传输数据。每个分组独立于其他分组传输。这使得使用不同的路由从同一台计算机向同一目的地传输数据包变得容易。然而,如果一台计算机向一台远程计算机发送两个数据包,而第二个数据包在第一个数据包到达之前到达,这可能会成为一个问题。因此,每个数据包都有一个数据包编号和目的地址。在目的计算机上有重新排列无序到达的数据包的规则。下面的讨论试图解释用于处理网络通信中的分组的一些机制。

图 5-1 显示了一个被称为互联网参考模型或 TCP/IP 分层模型的分层协议套件。这是使用最广泛的协议套件。模型中的每一层都执行定义明确的任务。拥有分层协议模型的主要优点是任何层都可以被改变而不影响其他层。新协议可以添加到任何层,而无需更改其他层。

A978-1-4302-6662-4_5_Fig1_HTML.jpg

图 5-1。

The Internet Protocol Suite showing its five protocol layers

每一层只知道它上面和下面的那一层。协议组中的每一层都有两个接口,一个用于上层,一个用于下层。例如,传输层有到应用层和互联网层的接口。也就是说,传输层只知道如何与应用层和 internet 层通信。它对网络接口层或物理层一无所知。

Java 程序等用户应用使用应用层与远程应用进行通信。用户应用必须指定它想要用来与远程应用通信的协议。应用层中的协议定义了用于格式化消息以及将含义与包含在消息中的信息(例如消息类型、描述它是请求还是响应等)相关联的规则。应用层格式化消息后,将消息移交给传输层。应用层中的协议的例子是超文本传输协议(HTTP)、文件传输协议(FTP)、Gopher、电信网络(Telnet)、简单邮件传输协议(SMTP)和网络新闻传输协议(NNTP)。

传输层协议处理将消息从一台计算机上的一个应用传输到远程计算机上的另一个应用的方式。它控制数据流、数据传输过程中的错误处理以及两个应用之间的连接。例如,用户应用可能会将非常大的数据块移交给传输层,以便传输到远程应用。远程计算机可能无法一次处理如此大量的数据。传输层负责一次向远程计算机传送适量的数据,这样远程应用就可以根据自己的能力处理数据。通过网络传递到远程计算机的数据可能由于各种原因在途中丢失。传输层负责重新传输丢失的数据。请注意,应用层只向传输层传递一次要传输的数据。在传输过程中,是传输层(而不是应用层)跟踪传递的数据和丢失的数据。可能有多个应用正在运行,所有这些应用都使用不同的协议,并与不同的远程应用交换信息。传输层负责将发送到远程应用的消息正确地传递出去。例如,您可能正在使用 HTTP 协议从一个远程 web 服务器浏览 Internet,并使用 FTP 协议从另一个 FTP 服务器下载文件。您的计算机正在接收来自两台远程计算机的消息,这些消息是针对您的计算机上运行的两个不同的应用的,一个 web 浏览器接收 HTTP 数据,一个 FTP 应用接收 FTP 数据。传输层负责将传入的数据传递给适当的应用。您可以看到协议组的不同层在网络数据传输中扮演着不同的角色。根据所使用的传输层协议,传输层将相关信息添加到消息中,并将其传递到下一层,即 internet 层。传输层中使用的协议示例有传输控制协议(TCP)、用户数据报协议(UDP)和流控制传输协议(SCTP)。

互联网层接受来自传输层的消息,并准备适合通过互联网发送的数据包。它包括互联网协议(IP)。IP 准备的数据包也称为 IP 数据报。除了其他信息外,它还包括一个报头和一个数据区。报头包含发送方的 IP 地址、目的 IP 地址、生存时间(TTL,整数)、报头校验和以及协议中指定的许多其他信息。IP 将消息准备成数据报,准备通过互联网传输。IP 数据报报头中的 TTL 根据路由器的数量指定了 IP 数据报在需要被丢弃之前可以保持传输多长时间。它的大小是一个字节,其值可以在 1 到 255 之间。当 IP 数据报在到达目的地的路由中到达路由器时,路由器将 TTL 值减 1。如果递减值为零,路由器将丢弃该数据报,并使用互联网控制消息协议(ICMP)向发送方发回一条错误消息。如果 TTL 值仍然是正数,路由器将数据报转发到下一个路由器。IP 使用地址方案,为每台计算机分配一个唯一的地址。该地址称为 IP 地址。我将在下一节详细讨论 IP 编址方案。互联网层将 IP 数据报交给下一层,即网络接口层。互联网层中的协议的例子是互联网协议(IP)、互联网控制消息协议(ICMP)、互联网组管理协议(IGMP)和互联网协议安全(IPsec)。

网络接口层准备要在网络上传输的数据包。这个数据包称为一个帧。网络接口层位于物理层之上,物理层包括硬件。请注意,IP 层使用 IP 地址来标识网络上的目的地。IP 地址是一个虚拟地址,完全由软件维护。硬件不知道 IP 地址,也不知道如何使用 IP 地址传输帧。必须为硬件提供硬件地址,也称为介质访问控制(MAC)地址,它需要将帧传输到目的地。这一层从 IP 地址中解析出目的硬件地址,并将其放入帧头中。它将帧移交给物理层。网络接口层中的协议示例有开放最短路径优先(OSPF)、点对点协议(PPP)、点对点隧道协议(PPTP)和第 2 层隧道协议(L2TP)。

物理层由硬件组成。它负责将信息比特转换成信号,并通过线路传输信号。

Tip

数据包是一个通用术语,在网络编程中用来表示一个独立的数据块。协议的每一层也使用特定的术语来表示它所处理的数据包。例如,一个数据包在 TCP 层被称为一个段;它在 IP 层被称为数据报;它在网络接口和物理层被称为帧。每一层在准备要通过网络传输的数据包时,都会将报头(有时也包括报尾)添加到从上一层接收的数据包中。当每一层从其下一层接收到数据包时,它会执行相反的操作。它从数据包中删除报头;如果需要,执行一些操作;并将数据包移交给其上一层。

当应用发送的数据包到达远程计算机时,它必须以相反的顺序通过同一层协议。每一层都将删除其报头,执行一些操作,并将数据包传递给其上一层。最后,数据包到达远程应用时的格式与它从发送方计算机上的应用开始时的格式相同。图 5-2 显示了发送方和接收方计算机的数据包传输。P1、P2、P3 和 P4 是相同数据的不同格式的数据包。目的地的协议层从其下一层接收相同的数据包,该数据包是由同一协议层传递给发送方计算机上的下一层的。

A978-1-4302-6662-4_5_Fig2_HTML.jpg

图 5-2。

Transmission of packets through the protocol layers on the sender’s and receiver’s computers

IP 寻址方案

IP 使用称为 IP 地址的唯一地址将 IP 数据报路由到目的地。IP 地址唯一标识计算机和路由器之间的连接。通常,IP 地址标识一台计算机。但是,需要强调的是,它标识的是计算机和路由器之间的连接,而不仅仅是计算机。路由器也会被分配一个 IP 地址。一台计算机可以使用多个路由器连接到多个网络,并且计算机和路由器之间的每个连接都有一个唯一的 IP 地址。在这种情况下,计算机将被分配多个 IP 地址,该计算机被称为多宿主计算机。多宿主增加了计算机网络连接的可用性。如果一个网络连接失败,计算机可以使用其他可用的网络连接。

IP 地址包含两部分——网络标识符(我称之为前缀)和主机标识符(我称之为后缀)。前缀唯一地标识互联网上的网络;后缀在网络中唯一标识主机。两台主机的 IP 地址可能有相同的后缀,只要它们有不同的前缀。

互联网协议有两个版本——IP v4(或简称 IP)和 IPv6,其中 v4 和 v6 分别代表版本 4 和版本 6。IPv6 也被称为下一代互联网协议(IPng)。注意没有 IPv5。IP 最流行的时候是在第 4 版。在 IPng 被分配第 6 版之前,第 5 版已经被分配给了另一个叫因特网流协议(ST)的协议。

IPv4 和 IPv6 都使用 IP 地址来标识网络上的主机。然而,两个版本中的寻址方案有很大不同。接下来的两节将讨论 IPv4 和 IPv6 使用的编址方案。

由于 IP 地址必须是唯一的,其分配由一个名为(IANA)的组织控制。IANA 为属于某个组织的每个网络分配一个唯一的地址。该组织使用网络地址和一个唯一的数字为网络上的每台主机形成一个唯一的 IP 地址。IANA 将 IP 地址分配给五个地区互联网注册管理机构(RIR),这些机构在表 5-1 中列出的特定地区分配 IP 地址。你可以在 www.iana.com 找到更多关于如何从 IANA 获得你所在地区的网络地址的信息。

表 5-1。

The List of Regional Internet Registries for Allocating Network IP Addresses

| 地区互联网注册管理机构名称 | 覆盖的区域 | | --- | --- | | 非洲网络信息中心 | 非洲地区 | | 亚太网络信息中心 | 亚洲/太平洋地区 | | 美国互联网号码注册局(ARIN) | 北美地区 | | 拉丁美洲和加勒比互联网地址注册处 | 拉丁美洲和一些加勒比海岛屿 | | 欧洲 IP 网络网络协调中心(RIPE NCC) | 欧洲、中东和中亚 |

IPv4 寻址方案

IPv4(或简称 IP)使用 32 位数字来表示 IP 地址。IP 地址包含两部分—前缀和后缀。前缀标识网络,后缀标识网络上的主机,如图 5-3 所示。

A978-1-4302-6662-4_5_Fig3_HTML.jpg

图 5-3。

IPv4 addressing scheme

人类要记住二进制格式的 32 位数字并不容易。IPv4 允许您使用四个十进制数字的替代形式。每个十进制数的范围是从 0 到 255。程序负责将十进制数转换成计算机将使用的 32 位二进制数。IPv4 的十进制数字格式称为点分十进制格式,因为点用于分隔两个十进制数字。每个十进制数代表 32 位数字中 8 位包含的值。例如,二进制格式的 IPv4 地址11000000101010000000000111100111可以表示为点分十进制格式的192.168.1.231。将二进制 IPv4 转换为十进制的过程如图 5-4 所示。在192.168.1.231中,192.168.1部分标识网络地址(前缀),而231(后缀)部分标识该网络上的主机。

A978-1-4302-6662-4_5_Fig4_HTML.jpg

图 5-4。

Parts of an IPv4 address in binary and decimal formats

你怎么知道192.168.1代表 IPv4 地址192.168.1.231中的前缀?规则控制 IPv4 中前缀和后缀的值。在本节后面讨论网络的类类型时,我将讨论如何识别 IPv4 中的前缀和后缀。

IPv4 地址如何在前缀和后缀之间划分其 32 位?IPv4 地址空间分为五类,称为网络类,分别为ABCDE。类别类型定义了 32 位中有多少位将用于表示 IP 地址的网络地址部分。前缀中的前导位定义了 IP 地址的类别。这也称为自我识别或有类 IP 地址,因为您可以通过查看 IP 地址来判断它属于哪个类。

表 5-2 列出了 IPv4 中的五个网络类别及其特征。IP 地址中的前导位标识网络的类别。例如,如果一个 IP 地址看起来像0XXX,其中XXX是 32 位中的最后 31 位,那么它属于A类网络;如果一个 IP 地址看起来像110XXX,其中XXX是 32 位的最后 29 位,它属于C类网络。只能有类别A类型的128网络,每个网络可以有16777214台主机。一个A类网络可以拥有的主机数量非常大,一个网络不太可能拥有那么多主机。在 class C类型的网络中,一个网络可以拥有的最大主机数量限制为 254 台。

表 5-2。

Five Classes of IPv4 in the Classful Addressing Scheme

| 网络类 | 前缀 | 后缀 | 前缀中的前导位 | 网络数量 | 每个网络的主机数量 | | --- | --- | --- | --- | --- | --- | | `A` | 8 位 | 24 位 | `0` | `128` | `16777214` | | `B` | 16 位 | 16 位 | `10` | `16384` | `65534` | | `C` | 24 位 | 8 位 | `110` | `2097152` | `254` | | `D` | 未定义 | 未定义 | `1110` | 未定义 | 未定义 | | `E` | 未定义 | 未定义 | `1111` | 未定义 | 未定义 |

如果为一个组织分配了一个来自类别C的网络地址,而该组织只有 10 台主机连接到网络,会发生什么情况?该网络中 IP 地址的剩余插槽仍未使用。回想一下,IP 地址中的主机(或后缀)部分在网络中必须是唯一的(前缀部分)。另一方面,如果一个组织需要将 300 台计算机连接到网络,它需要获得两个C类网络地址,因为获得一个能够容纳65534主机的B类网络地址将再次浪费大量 IP 地址。

注意,如果为后缀分配的位数是N,则可以使用的主机数是2 N -2。两位模式(全 0 和全 1)不能用于主机地址。它们有特殊的用途。这就是一个C类网络最多可以有 254 台主机而不是 256 台主机的原因。类别D地址用作组播地址。类别E地址被保留。

互联网的快速发展和大量未使用的 IP 地址促使人们制定新的编址方案。该方案仅基于一个标准,即应该能够在 IP 地址的前缀和后缀部分之间使用任意边界,而不是 8、16 和 24 位的预定义边界。这将使未使用的地址最少。例如,如果一个组织需要一个只有 20 台主机的网络的网络号,该组织只能使用 27 位前缀和 5 位后缀。

子网划分和超网划分这两个术语用于描述后缀中的一些位用作前缀、前缀中的一些位用作后缀的情况。当后缀中的位用作前缀时,实质上是以主机地址为代价创建更多的网络地址。额外的网络地址称为子网。子网划分是通过使用一个称为子网掩码或地址掩码的数字来实现的。子网掩码是一个 32 位的数字,用于根据 IP 地址计算网络地址。使用子网掩码消除了网络类别必须预先定义 IP 地址的网络号部分的限制。对 IP 地址和子网掩码执行逻辑AND以计算网络号。在这种编址方案中,IP 地址总是用其子网掩码来指定。IP 地址后面跟一个正斜杠和子网掩码。例如,140.10.11.9/255.255.0.0表示带有子网掩码255.255.0.0的 IP 地址140.10.11.9。可以使用四个十进制部分在 0 到 255 范围内的任何子网掩码。在这个例子中,140.10.11.9是一个类B地址。类别B地址使用 16 位作为前缀,16 位作为后缀。让我们把后缀去掉 6 位,加到前缀上。现在,前缀是 22 位,后缀只有 10 位。通过这样做,您以主机数量为代价创建了额外的网络数量。要描述这个子网划分方案中的 IP 地址,您需要使用子网掩码255.255.252.0。如果您将此子网掩码用作140.10.11.9/255.255.252.0来书写 IP 地址,网络地址将被计算为140.10.8.0,如下所示:

IP Address: 10001100 00001010 00001011 00001001

Subnet Mask: 11111111 11111111 11111100 00000000

------------------------------------------------

Logical AND: 10001100 00001010 00001000 00000000

(140)    (10)      (8)      (0)

无类域间路由(CIDR)是另一种 IPv4 寻址方案,在该方案中,IPv4 地址被指定为四个带点的十进制数字以及由正斜杠分隔的另一个十进制数字,例如192.168.1.231/24,其中最后一个数字24表示 32 位 IPv4 地址中的前缀长度(或用于网络号的位数)。请注意,CIDR 编址方案允许您在 32 位 IPv4 的任意位定义前缀/后缀边界。通过将这些位从前缀移到后缀,您可以组合多个网络并增加每个网络的主机数量。这称为超网划分。您可以使用 CIDR 符号创建超网和子网。

IPv4 寻址方案中的一些 IP 地址是为广播和多播 IP 地址保留的。我将在本章后面讨论广播和组播。

IPv6 寻址方案

IPv6 是 IP 的新版本,是 IPv4 的继任者。在快速发展的互联网世界中,IPv4 中的地址空间正在耗尽。IPv6 旨在提供足够的地址空间,以便在未来几十年内,世界上的每台计算机都可以获得一个唯一的 IP 地址。以下是 IPv6 的一些主要功能:

  • IPv6 使用 128 位数字作为 IP 地址,而不是 IPv4 中使用的 32 位数字。
  • 它的 IP 数据包报头格式与 IPv4 不同。IPv4 的每个数据报只有一个报头,而 IPv6 的每个数据报只有一个基本报头,后跟多个可变长度的扩展报头。
  • IPv6 支持比 IPv4 更大的数据报。
  • 在 IPv4 中,路由器执行 IP 数据包分段。在 IPv6 中,应该由发送方主机而不是路由器来执行数据包分段。这意味着使用 IPv6 的主机必须预先知道最大传输单元(MTU)的路径,即所有网络到目的主机的最大数据包大小的最小值。当 IP 数据报不得不进入一个比数据报离开的网络具有更小传输容量的网络时,就会发生 IP 数据报的分段。在 IPv4 中,分段由路由器执行,它检测路由中传输容量较低的网络。因为 IPv6 只允许主机执行分段,所以主机必须发现最小大小的数据报,该数据报可以通过所有可能的路由从源主机路由到目的主机。
  • IPv6 支持在报头中指定数据报的路由信息,以便路由器可以使用它通过特定的路由来路由数据报。此功能有助于传递时间关键的信息。
  • IPv6 具有可扩展性。可以将任意数量的扩展报头添加到 IPv6 数据报中,这可以用一种新的方式来解释。

IPv6 使用 128 位 IP 地址。它使用易于理解的符号以文本形式表示 IP 地址。这 128 位被分成 8 个字段,每个字段 16 位。每个字段都以十六进制形式书写,并用冒号分隔。以下是 IPv6 地址的一些示例:

  • F6DC:0:0:4015:0:BA98:C0A8:1E7
  • F6DC:0:0:7678:0:0:0:A21D
  • F6DC:0:0:0:0:0:0:A21D
  • 0:0:0:0:0:0:0:1

IPv6 地址中有许多字段的值为零是很常见的,尤其是对于所有 IPv4 地址。IPv6 地址表示法允许您通过使用两个连续的冒号来压缩连续的零值字段。在一个地址中,只能使用两个冒号来隐藏一次连续的零值字段。可以使用零压缩技术重写上述 IPv6 地址:

  • F6DC::4015:0:BA98:C0A8:1E7
  • F6DC:0:0:7678::A21D
  • F6DC::A21D
  • ::1

注意,我们可以只抑制第二个地址F6DC:0:0:7678::A21D中两组连续零字段中的一组。将其重写为F6DC::7678::A21D是无效的,因为它不止一次使用了两个冒号。您可以使用两个冒号来隐藏连续的零字段,这些零字段可能出现在地址字符串的开头、中间或结尾。如果一个地址中包含所有的零,你可以简单地把它表示为::

您也可以在 IPv6 地址中混合十六进制和十进制格式。当您有一个 IPv4 地址并希望以 IPv6 格式书写时,这种表示法非常有用。您可以使用如上所述的十六进制表示法编写前六个 16 位字段,并对 IPv4 的后两个 16 位字段使用点分十进制表示法。混合记数法采用X:X:X:X:X:X:D.D.D.D的形式,其中X是十六进制数,D是十进制数。您可以使用以下符号重写上述 IPv6 地址:

  • F6DC::4015:0:BA98:192.168.1.231
  • F6DC:0:0:7678::0.0.162.29
  • F6DC::0.0.162.29
  • ::0.0.0.1

与 IPv4 不同,IPv6 不基于网络类别分配 IP 地址。像 IPv4 一样,它使用 CIDR 地址,因此 IP 地址中前缀和后缀之间的边界可以在任意位指定。例如,::1可以用 CIDR 符号表示为::1/128,其中 128 是前缀长度。

Tip

当 IPv6 地址作为 URL 的一部分在文字字符串中使用时,应该用括号([])括起来。此规则不适用于 IPv4。例如,如果您使用 IPv4 地址访问回环地址上的 web 服务器,您可以使用类似于 http://127.0.0.1/index.html 的 URL。在 IPv6 地址符号中,您需要使用类似http://[::1]/index.html的 URL。在使用之前,请确保您的浏览器在其 URL 中支持 IPv6 地址表示法。

特殊 IP 地址

一些 IP 地址用于特殊目的。一些这样的 IP 地址如下:

  • 环回 IP 地址
  • 单播 IP 地址
  • 多播 IP 地址
  • 任播 IP 地址
  • 广播 IP 地址
  • 未指定的 IP 地址

以下部分详细描述了这些特殊 IP 地址的使用。

环回 IP 地址

您需要至少两台通过网络连接的计算机来测试或运行网络程序。当您想在项目的开发阶段测试网络程序时,有时建立网络可能不可行或不理想。IP 的设计者们意识到了这种需求。IP 编址方案中规定将 IP 地址视为环回地址,以便只使用一台计算机测试网络程序。当协议组中的 Internet 层检测到回送 IP 地址作为 IP 数据报的目的地时,它不会将数据包传递给它下面的协议层(即网络接口层)。相反,它会掉头(或环回,因此得名环回地址)并将数据包路由回同一台计算机上的传输层。传输层将数据包传送到同一台主机上的目的地进程,就像数据包来自远程主机一样。回送 IP 地址使得使用一台计算机测试网络程序成为可能。图 5-5 描述了 IP 处理发往环回 IP 地址的互联网数据包的方式。数据包不会离开源计算机。它被互联网层截获,并被路由回它所来自的同一台计算机。

A978-1-4302-6662-4_5_Fig5_HTML.jpg

图 5-5。

An Internet packet that has a loopback IP address as its destination is routed back to the same computer from the Internet protocol in the internet layer

回送 IP 地址是保留地址,IP 不需要将回送 IP 地址作为目的地址的数据包转发到网络接口层。

在 IPv4 寻址方案中,127.X.X.X块是为环回地址保留的,其中X是 0 到 255 之间的十进制数。通常,127.0.0.1被用作 IPv4 中的环回地址。但是,您并不局限于仅使用127.0.0.1作为唯一的环回地址。如果您愿意,也可以使用127.0.0.2127.3.5.11作为有效的环回地址。通常,名称localhost被映射到计算机上的回送地址127.0.0.1

在 IPv6 寻址方案中,只有一个环回地址,足以对网络程序执行任何本地测试。就是0:0:0:0:0:0:0:1或者干脆就是::1

单播 IP 地址

单播是网络上两台计算机之间的一对一通信,其中 IP 数据包被传送到一台远程主机。单播 IP 地址标识网络上唯一的主机。IPv4 和 IPv6 支持单播 IP 地址。

多播 IP 地址

多播是一种一对多的通信,其中一台计算机发送一个 IP 数据包,该数据包被传送到多台远程计算机。多播让您实现组交互的概念,如音频或视频会议,其中一台计算机向组中的所有计算机发送信息。使用多播代替多个单播的好处是发送方只发送一份数据包。数据包的一个副本会尽可能长地沿着网络传输。如果包的接收者在多个网络上,则在需要时制作包的副本,并且包的每个副本被独立地路由。最后,每个接收者都会收到一份单独的数据包副本。多播是群组成员之间通信的有效方式,因为它减少了网络流量。

一个 IP 数据包只有一个目的 IP 地址。如何使用多播将 IP 数据包传送到多台主机?IP 在其地址空间中包含一些地址作为多播地址。如果数据包的地址是组播地址,该数据包将被传送到多台主机。多播数据包传送的概念与活动的组成员资格相同。当一个组形成时,该组被给予一个组 ID。寻址到该组 ID 的任何信息被传递给所有组成员。在多播通信中,使用多播 IP 地址(类似于组 ID)。多播数据包被寻址到该多播地址。每个感兴趣的主机向其感兴趣的本地路由器注册其 IP 地址,以便在该多播地址上进行通信。主机和本地路由器之间的注册过程是使用互联网组管理协议(IGMP)完成的。当路由器收到带有组播地址的数据包时,它会将该数据包的副本发送给向其注册了该组播地址的每台主机。接收者可以通过通知路由器来选择在任何时候离开多播组。

多播数据包在到达接收主机之前可能会经过许多路由器。多播数据包的所有接收者可能不在同一个网络上。有许多处理多播数据包路由的协议,如距离矢量多播路由协议(DVMRP)。

IPv4 和 IPv6 都支持组播寻址。在 IPv4 中,D类网络地址用于多播。也就是说,IPv4 中的多播地址中的四个最高位是1110。在 IPv6 中,组播地址的前 8 位设置为 1。也就是说,IPv6 中的组播地址总是以FF开头。例如,FF0X:0:0:0:0:0:2:0000是 IPv6 中的组播地址。

任播 IP 地址

任播是一种一对一的群组通信,其中一台计算机向一组计算机发送数据包,但该数据包只发送给该组中的一台计算机。IPv4 不支持任播。IPv6 支持任播。在任播中,同一个地址被分配给多台计算机。当路由器收到发往任播地址的数据包时,它会将该数据包传送到最近的计算机。当服务已经在许多主机上复制,并且您想要在离客户端最近的主机上提供服务时,任播是有用的。有时,任播寻址也称为集群寻址。使用单播地址空间中的任播地址。您无法通过查看位的排列来区分单播地址和任播地址。当同一个单播地址被分配给多台主机时,它被视为一个任播地址。请注意,路由器必须知道分配了任播地址的主机,这样它才能将寻址到该任播地址的数据包传送到最近的主机之一。

广播 IP 地址

广播是一种一对多的通信,其中一台计算机发送一个数据包,该数据包将被传送到网络上的所有计算机。IPv4 分配一些地址作为广播地址。当所有 32 位都设置为 1 时,它就形成了一个广播地址,数据包将被发送到本地子网上的所有主机。当主机地址中的所有位都设置为 1 并且指定了网络地址时,它就形成了指定网络号的广播地址。例如,255.255.255.255是本地子网的广播地址,192.168.1.255是网络192.168.1.0的广播地址。IPv6 没有广播地址。在 IPv6 中,您需要使用多播地址作为广播地址。

未指定的 IP 地址

IPv4 中的0.0.0.0和 IPv6 中的::(注意,::表示 128 位 IPv6 地址,所有位都设置为零)被称为未指定地址。主机使用此地址作为源地址来表示它还没有 IP 地址,例如在启动过程中,它还没有被分配 IP 地址。

端口号

端口号是 16 位无符号整数,范围从 0 到 65535。有时端口号也简称为端口。一台计算机运行许多进程,这些进程与远程计算机上运行的其他进程进行通信。当传输层收到来自 Internet 层的传入数据包时,它需要知道该数据包应该发送到该计算机上的哪个进程(运行在应用层)。端口号是一个逻辑编号,传输层使用它来识别计算机上数据包的目的进程。

每个传入传输层的数据包都有一个协议;例如,传输层中的 TCP 协议处理程序处理 TCP 数据包,而传输层中的 UDP 协议处理程序处理 UDP 数据包。

在应用层,一个进程使用它希望与远程进程通信的每个通信信道的独立协议。一个进程为它为特定协议打开的每个通信信道使用一个唯一的端口号,并在传输层的特定协议模块中注册该端口号。因此,对于特定协议,端口号必须是唯一的。例如,进程 P1 可以对 TCP 协议使用端口号 1988,而另一个被调用的进程 P2 可以在同一台计算机上对 UDP 协议使用相同的端口号 1988。主机上的进程使用远程进程的协议和端口号向远程进程发送数据。

计算机上的进程如何开始与远程进程通信?例如,当你访问雅虎的网站时,你只需输入 http://www.yahoo.com 作为网页地址。在该网页地址中,http表示应用层协议,其使用 TCP 作为传输层协议, www.yahoo.com 是机器名称,其使用域名系统(DNS)解析为 IP 地址。由 www.yahoo.com 标识的机器可能正在运行许多进程,这些进程可能使用http协议。您的网络浏览器连接到 www.yahoo.com 上的哪个进程?由于许多人使用雅虎的网站,它需要在一个众所周知的端口运行它的http服务,这样每个人都可以使用那个端口连接到它。通常,http web 服务器运行在端口 80。可以用 http://www.yahoo.com:80 ,和用 http://www.yahoo.com 一样。并不总是需要在端口 80 运行http web 服务器。如果您没有在端口 80 运行您的http web 服务器,想要使用您的http服务的人必须知道您正在使用的端口。IANA 负责推荐哪些端口号用于知名服务。IANA 将端口号分为三个范围:

  • 已知端口:0 -1023
  • 注册端口:1024 - 49151
  • 动态和/或专用端口:49152 - 65535

众所周知的端口号被全球提供的最常用的服务使用,例如 HTTP、FTP 等。表 5-3 列出了一些用于知名应用层协议的知名端口。通常,您需要管理权限才能使用计算机上众所周知的端口。

表 5-3。

A Partial List of Well-Known Ports Used for Some Application Layer Protocols

| 应用层协议 | 通道数 | | --- | --- | | `echo` | `7` | | `FTP` | `21` | | `Telnet` | `23` | | `SMTP` | `25` | | `HTTP` | `80` | | `POP3` | `110` | | `NNTP` | `119` |

组织(或用户)可以在应用要使用的已注册端口范围中向 IANA 注册一个端口号。例如,已经为 RMI 注册表注册了 1099 (TCP/UDP)端口(RMI 代表远程方法调用)。

任何应用都可以使用动态/专用端口号范围内的端口号。

套接字 API 和客户机-服务器范例

我还没有开始讨论在 Java 程序中使网络通信成为可能的 Java 类。在这一节中,我将介绍套接字和在两台远程主机之间的网络通信中使用的客户机-服务器范例。

在前面几节中,我简要介绍了不同的底层协议及其职责。是时候在协议栈中向上移动,讨论应用层和传输层之间的交互了。应用如何使用这些协议与远程应用通信?操作系统提供了一个称为套接字的应用接口(API ),它允许两个远程应用进行通信,从而利用协议栈中的低层协议。套接字不是另一层协议。它是传输层和应用层之间的接口。它提供了两层之间的标准通信方式,这反过来又提供了两个远程应用之间的标准通信方式。

有两种插座:

  • 面向连接的套接字
  • 无连接插座

面向连接的套接字也称为流套接字。无连接套接字也称为数据报套接字。请注意,数据总是使用 IP 数据报从互联网上的一台主机一次一个数据报地发送到另一台主机。

传输层中使用的传输控制协议(TCP)是提供面向连接的套接字的最广泛使用的协议之一。应用将数据移交给 TCP 套接字,TCP 负责将数据流式传输到目的主机。TCP 处理所有问题,如排序、分段、组装、丢失数据检测、重复数据传输等。这给应用的印象是,数据像连续的字节流一样从源应用流向目标应用。使用 TCP 套接字的两台主机之间不存在硬件级的物理连接。都是用软件实现的。有时也称为 a。两个套接字的组合唯一地定义了一个连接。

在面向连接的套接字通信中,客户端和服务器端创建套接字,建立连接,并交换信息。TCP 负责处理数据传输过程中可能出现的错误。TCP 也称为可靠的传输层协议,因为它保证数据的传输。如果它由于某种原因不能传递数据,它将通知发送方应用有关错误的情况。发送数据后,它会等待接收方的确认,以确保数据到达目的地。然而,TCP 提供的可靠性是有代价的。与无连接协议相比,开销更大,速度也更慢。TCP 确保发送方向接收方发送一定量的数据,这可以由接收方的缓冲区大小来处理。它还处理网络上的流量拥塞。当它检测到交通拥堵时,它会减慢数据传输速度。Java 支持 TCP 套接字。

在传输层中使用的用户数据报协议(UDP)是使用最广泛的提供无连接套接字的协议。它不可靠,但是快得多。它允许您发送有限大小的数据—一次一个包,这与 TCP 不同,TCP 允许您将数据作为任意大小的流发送,处理将数据分段成适当大小的包的细节。当您使用 UDP 发送数据时,无法保证数据传递。然而,它仍然在许多应用中使用,并且工作得非常好。发送方将 UDP 数据包发送到目的地,然后忘记了它。如果接收者得到它,它得到它。否则,接收方无法知道是否有 UDP 数据包发送给了它。您可以将 TCP 和 UDP 中使用的通信与电话和邮件中使用的通信进行比较。电话交谈是可靠的,它提供了通信双方之间的确认。当你邮寄一封信时,你不知道收信人什么时候收到,或者他是否收到了。UDP 和 TCP 还有一个重要的区别。UDP 不保证数据的排序。也就是说,如果您使用 UDP 向目的地发送五个数据包,这五个数据包可能以任何顺序到达。但是,TCP 保证数据包将按照发送的顺序传送。Java 支持 UDP 套接字。

你应该使用哪种协议:TCP 还是 UDP?这取决于应用将如何使用。如果数据完整性至关重要,您应该使用 TCP。如果速度优先于较低的数据完整性,您应该使用 UDP。例如,文件传输应用应该使用 TCP,而视频会议应用应该使用 UDP。如果您丢失了几个像素的视频数据,这对视频会议没有太大影响。它可以继续。但是,如果在传输文件时丢失了几个字节的数据,该文件可能根本就不可用。

两个远程应用如何开始通信?哪个应用发起通信?应用如何知道远程应用有兴趣与之通信?你有没有拨过一个公司的客服电话和客服代表通话?如果你与一家公司的客户服务代表交谈过,你就已经体验过两个远程应用的通信。我将在本节中参考使用公司客服的机制来解释远程通信。你和一个公司的代表在两个遥远的地方。你需要一项服务,公司就提供这项服务。换句话说,你是客户,公司是服务商(或者服务器)。你不知道什么时候你会需要公司的服务。公司提供了客服电话,你可以联系公司。公司还做了一件事。公司必须做什么来为你提供服务?你能猜到吗?它会按照给你的电话号码等待你的来电。沟通必须发生在你和公司之间,而公司已经通过被动等待你的电话在沟通中向前迈出了一步。一旦你拨了公司的号码,一个连接就建立了,你和公司的代表交换信息。最后,你们俩都挂断了电话,停止了交流。使用套接字的网络通信类似于您和公司代表之间的通信。如果你理解了这个通信的例子,理解套接字就很容易了。

两个远程应用使用一对套接字进行通信。任何通信都需要两个端点。套接字是通信信道两端的通信端点。一对套接字上的通信遵循典型的客户机-服务器通信范例。一个应用创建一个套接字,被动地等待另一个远程应用的联系。等待远程应用联系它的应用被称为服务器应用或简称为服务器。另一个应用创建一个套接字,并启动与等待的服务器应用的通信。这被称为客户端应用或简称为客户端。在客户端和服务器可以交换信息之前,必须执行许多其他步骤。例如,服务器必须公布其位置和其他详细信息,以便客户可以联系它。

套接字会经历不同的状态。每个状态都标志着一个事件。套接字的状态告诉我们套接字能做什么和不能做什么。通常,套接字的生命周期由表 5-4 中列出的八个原语来描述。

表 5-4。

The List of Typical Socket Primitives and Their Descriptions

| 基元 | 描述 | | --- | --- | | `Socket` | 创建一个套接字,应用使用该套接字作为通信端点。 | | `Bind` | 将本地地址与套接字关联。本地地址包括 IP 地址和端口号。端口号必须是 0 到 65535 之间的数字。对于计算机上用于套接字的协议,它应该是唯一的。例如,如果 TCP 套接字使用端口 12456,UDP 套接字也可以使用相同的端口号 12456。 | | `Listen` | 为客户端请求定义其等待队列的大小。它只能由面向连接的服务器套接字执行。 | | `Accept` | 等待客户端请求到达。它只能由面向连接的服务器套接字执行。 | | `Connect` | 尝试建立到服务器套接字的连接,该套接字正在等待一个`accept`原语。它是由面向连接的客户端套接字执行的。 | | `Send/Sendto` | 发送数据。通常`send`表示在面向连接的套接字上的发送操作,而`Sendto`表示在无连接套接字上的发送操作。 | | `Receive/ReceiveFrom` | 接收数据。他们是`Send`和`Sendto`的对应。 | | `Close` | 关闭连接。 |

下面几节详细阐述了每个套接字原语。

套接字原语

服务器通过指定套接字的类型来创建套接字:流套接字或数据报套接字。

绑定原语

原语将套接字与本地 IP 地址和端口号相关联。请注意,一台主机可以有多个 IP 地址。套接字可以绑定到主机的一个 IP 地址或所有 IP 地址。将套接字绑定到主机的所有可用 IP 地址也称为绑定到通配符地址。绑定为这个套接字保留端口号。没有其他套接字可以使用该端口号进行通信。传输协议(TCP 和 UDP)将使用绑定端口来路由发往此套接字的数据。在这一节的后面,我将详细解释传输层和套接字之间的数据传输。现在,只需要理解,在绑定中,套接字告诉传输层这是我的 IP 地址和端口号,如果您获得了寻址到该地址的任何数据,请将该数据传递给我。套接字绑定的 IP 地址和端口号分别称为套接字的本地地址和本地端口。

Listen 原语

服务器通知操作系统将套接字置于被动模式,以便它等待传入的客户端请求。此时,服务器还没有准备好接受任何客户机请求。服务器还指定套接字的等待队列大小。当客户机在这个套接字上联系服务器时,客户机请求被放入那个队列中。最初,队列是空的。如果客户机在这个套接字上联系服务器,并且等待队列已满,那么客户机的请求将被拒绝。

接受原语

服务器通知操作系统这个套接字已经准备好接受客户机请求。如果服务器使用无连接传输协议(如 UDP)的套接字,则不执行此步骤。对 TCP 服务器套接字执行此步骤。当套接字向操作系统发送接受消息时,它会一直阻塞,直到接收到客户端对新连接的请求。

连接原语

只有面向连接的客户端套接字执行此步骤。这是套接字通信中最重要的阶段。客户端套接字向服务器套接字发送请求以建立连接。服务器套接字已经发出了accept,并且一直在等待客户端请求的到达。客户机套接字发送服务器套接字的 IP 地址和端口号。回想一下,服务器套接字在开始侦听和接受来自外部的连接之前绑定了 IP 地址和端口号。随着它的请求,客户机套接字也发送它自己的 IP 地址和它已经绑定到的端口号。

这时出现了一个重要的问题。TCP 等传输层如何知道来自客户端的数据包(以连接请求的形式)必须被传递给服务器套接字?在绑定阶段,套接字指定其本地 IP 地址和本地端口号,以及远程 IP 地址和远程端口号。如果服务器套接字只想接受来自特定远程主机 IP 地址和端口号的连接,它可以这样做。通常,服务器套接字将接受来自任何客户端的连接,并将指定一个未指定的 IP 地址和一个零端口号作为其远程地址。服务器套接字将五条信息(本地 IP 地址、本地端口号、远程 IP 地址、远程端口号和缓冲区)传递给传输层。传输层将它们存储在一个称为传输控制块(TCB)的特殊结构中,以备将来使用。当来自外部的数据包到达传输层时,它会根据传入数据包中包含的四条信息来查找其 TCB,。回想一下,客户端将每个 TCP 数据包中的源地址和目的地址发送给服务器。传输层试图找到与源地址和目的地址相关联的缓冲区。如果它找到一个缓冲区,它将传入的数据传输到缓冲区,并通知套接字缓冲区中有它的一些信息。如果服务器套接字接受来自任何客户机的请求(远程地址全为零),来自任何客户机的数据都将被路由到其缓冲区。

一旦服务器套接字检测到来自客户机的请求,它就用远程客户机的地址信息创建一个新的套接字。使用绑定新的套接字,并且创建新的缓冲区并将其绑定到该组合地址。事实上,为一个套接字创建了两个缓冲区:一个用于传入数据,一个用于传出数据。此时,服务器套接字让新套接字与请求连接的客户机套接字进行通信。服务器套接字本身可以关闭自己(不再接受客户端的连接请求),或者它可以再次开始等待接受另一个客户端的连接请求。

在两个套接字(客户机和服务器)之间建立连接后,它们可以交换信息。TCP 连接支持全双工连接。也就是说,数据可以同时双向发送或接收。

客户端套接字在尝试连接到服务器之前,知道其本地 IP 地址、本地端口号、远程 IP 地址和远程端口号。在客户端,TCB 的创建遵循类似的规则。

一旦客户机和服务器套接字就位,两个套接字(客户机套接字和专用于客户机的服务器套接字)就定义了一个连接。

服务器套接字就像坐在办公室(服务器)前台的接待员。一位客户走进来,先和接待员交谈。连接请求来自客户端到服务器,首先联系服务器套接字。接待员将客户交给另一名员工。在这一点上,接待员的工作对客户来说已经结束了。她继续她的工作,等待迎接另一个客户来到办公室。与此同时,第一个客户可以继续与另一个员工交谈,只要他需要。类似地,服务器套接字创建一个新的套接字,并将该新的套接字分配给客户机,以便进行任何进一步的通信。一旦服务器套接字为客户机分配了一个新的套接字,它的工作就结束了。它将等待来自另一个客户端的另一个连接请求。请注意,除了许多其他细节之外,一个套接字还有五个重要的相关信息:协议、本地 IP 地址、本地端口号、远程 IP 地址和远程端口号。

Send/Sendto 原语

这是套接字发送数据的阶段。

接收/接收自原语

这是套接字接收数据的阶段。

封闭原语

是说再见的时候了。最后,服务器和客户端套接字关闭连接。

后续部分将讨论支持不同类型套接字的 Java 类,以便于网络编程。与网络编程相关的 Java 类在java.netjavax.netjavax.net.ssl包中。

表示机器地址

互联网协议使用机器的 IP 地址来传送数据包。在程序中使用 IP 地址并不容易,因为它是数字格式。您可能能够记住并使用 IPv4 地址,因为它们的长度只有四个十进制数字。记住和使用 IPv6 地址有点困难,因为它们是十六进制格式的八个数字。每台电脑也有一个名字,如 www.yahoo.com 。在你的程序中使用一个计算机名会使你的生活变得更容易。Java 提供了允许您在 Java 程序中使用计算机名或 IP 地址的类。如果您使用计算机名,Java 会使用 DNS 将计算机名解析为其 IP 地址。

InetAddress类的对象代表一个 IP 地址。它有两个子类,Inet4AddressInet6Address,分别代表 IPv4 和 IPv6 地址。InetAddress类没有公共构造函数。它提供了六个工厂方法来创建它的对象。它们如下。所有人都扔出一个检查过的UnknownHostException

  • static InetAddress[] getAllByName(String host)
  • static InetAddress getByAddress(byte[] addr)
  • static InetAddress getByAddress(String host, byte[] addr)
  • static InetAddress getByName(String host)
  • static InetAddress getLocalHost()
  • static InetAddress getLoopbackAddress()

host参数指的是标准格式的计算机名或 IP 地址。addr参数以字节数组的形式引用 IP 地址的各个部分。如果指定 IPv4 地址,addr必须是 4 元素的byte数组。对于 IPv6 地址,它应该是一个 16 元素的byte数组。InetAddress类负责使用 DNS 将主机名解析为 IP 地址。

有时,一台主机可能有多个 IP 地址。getAllByName()方法将所有地址作为InetAddress对象的数组返回。

通常,使用四个工厂方法之一创建一个InetAddress类的对象,并在套接字创建和连接期间将该对象传递给其他方法。下面的代码片段演示了它的一些用法。当您使用InetAddress类或它的子类时,您将需要处理异常。

// Get the IP address of the yahoo web server

InetAddress yahooAddress = InetAddress.getByName("www.yahoo.com

// Get the loopback IP address

InetAddress loopbackAddress = InetAddress.getByName(null);

/* Get the address of the local host. Typically, a name "localhost" is

mapped to a loopback address. Here, we are trying to get the IP address

of the local computer where this code executes and not the loopback address.

*/

InetAddress myComputerAddress = InetAddress.getLocalHost();

清单 5-1 展示了InetAddress类及其一些方法的使用。运行该程序时,您可能会得到不同的输出。

清单 5-1。演示 InetAddress 类的用法

// InetAddressTest.java

package com.jdojo.net;

import java.io.IOException;

import java.net.InetAddress;

public class InetAddressTest {

public static void main(String[] args) {

// Printwww.yahoo.com

printAddressDetails("www.yahoo.com

// Print the loopback address details

printAddressDetails(null);

// Print the loopback address details using IPv6 format

printAddressDetails("::1");

}

public static void printAddressDetails(String host) {

System.out.println("Host '" + host + "' details starts...");

try {

InetAddress addr = InetAddress.getByName(host);

System.out.println("Host IP Address: " + addr.getHostAddress());

System.out.println("Canonical Host Name: " + addr.getCanonicalHostName());

int timeOutinMillis = 10000;

System.out.println("isReachable(): " + addr.isReachable(timeOutinMillis));

System.out.println("isLoopbackAddress(): " + addr.isLoopbackAddress());

}

catch (IOException e) {

e.printStackTrace();

}

finally {

System.out.println("Host '" + host + "' details ends...");

System.out.println("");

}

}

}

Host 'www.yahoo.com

Host IP Address: 98.139.183.24

Canonical Host Name: ir2.fp.vip.bf1.yahoo.com

isReachable(): false

isLoopbackAddress(): false

Host 'www.yahoo.com

Host 'null' details starts...

Host IP Address: 127.0.0.1

Canonical Host Name: 127.0.0.1

isReachable(): true

isLoopbackAddress(): true

Host 'null' details ends...

Host '::1' details starts...

Host IP Address: 0:0:0:0:0:0:0:1

Canonical Host Name: BHMIS-J00BXFL-D.corporate.local

isReachable(): true

isLoopbackAddress(): true

Host '::1' details ends...

表示套接字地址

套接字地址包含两部分,一个 IP 地址和一个端口号。InetSocketAddress类的一个对象代表一个套接字地址。您可以使用以下构造函数来创建一个InetSocketAddress类的对象:

  • InetSocketAddress(InetAddress addr, int port)
  • InetSocketAddress(int port)
  • InetSocketAddress(String hostname, int port)

所有构造函数都会尝试将主机名解析为 IP 地址。如果主机名无法解析,套接字地址将被标记为未解析,您可以使用isUnresolved()方法进行测试。如果不希望该类在创建其对象时解析地址,可以使用以下工厂方法来创建套接字地址:

  • static InetSocketAddress createUnresolved(String host, int port)

getAddress()方法返回一个InetAddress对象。如果主机名没有被解析,getAddress()方法返回null。如果您将未解析的InetSocketAddress对象与套接字一起使用,则在绑定过程中会尝试解析主机名。

清单 5-2 显示了如何创建已解析和未解析的InetSocketAddress对象。运行该程序时,您可能会得到不同的输出。

清单 5-2。创建 InetSocketAddress 对象

// InetSocketAddressTest.java

package com.jdojo.net;

import java.net.InetSocketAddress;

public class InetSocketAddressTest {

public static void main(String[] args) {

InetSocketAddress addr1 = new InetSocketAddress("::1", 12889);

printSocketAddress(addr1);

InetSocketAddress addr2 =

InetSocketAddress.createUnresolved("::1", 12881);

printSocketAddress(addr2);

}

public static void printSocketAddress(InetSocketAddress sAddr) {

System.out.println("Socket Address: " + sAddr.getAddress());

System.out.println("Socket Host Name: " + sAddr.getHostName());

System.out.println("Socket Port: " + sAddr.getPort());

System.out.println("isUnresolved(): " + sAddr.isUnresolved());

System.out.println();

}

}

Socket Address: /0:0:0:0:0:0:0:1

Socket Host Name: HYE6754

Socket Port: 12889

isUnresolved(): false

Socket Address: null

Socket Host Name: ::1

Socket Port: 12881

isUnresolved(): true

创建 TCP 服务器套接字

ServerSocket类的对象代表 Java 中的 TCP 服务器套接字。一个ServerSocket对象用于接受来自远程客户端的连接请求。ServerSocket类提供了许多构造函数。您可以使用 no-args 构造函数创建一个未绑定的服务器套接字,并使用它的bind()方法将其绑定到一个本地端口和一个本地 IP 地址。以下代码片段显示了如何创建服务器套接字:

// Create an unbound server socket

ServerSocket serverSocket = new ServerSocket();

// Create a socket address object

InetSocketAddress endPoint = new InetSocketAddress("localhost", 12900);

// Set the wait queue size to 100

int waitQueueSize = 100;

// Bind the server socket to localhost and at port 12900 with

// a wait queue size of 100

serverSocket.bind(endPoint, waitQueueSize);

ServerSocket类中没有单独的listen()方法对应于listen套接字原语。它的bind()方法负责指定套接字的等待队列大小。

通过使用ServerSocket类的以下任意构造函数,可以在一个步骤中组合createbindlisten操作。等待队列大小的默认值是 50。本地 IP 地址的默认值是通配符地址,这意味着服务器的所有 IP 地址。

  • ServerSocket(int port)
  • ServerSocket(int port, int waitQueueSize)
  • ServerSocket(int port, int waitQueueSize, InetAddress bindAddr)

您可以将套接字创建和绑定步骤合并到一个语句中,如下所示:

// Create a server socket at port 12900, with 100 as the wait

// queue size and at the localhost loopback address

ServerSocket serverSocket =

new ServerSocket(12900, 100, InetAddress.getByName("localhost"));

一旦创建并绑定了服务器套接字,它就可以接受来自远程客户端的连接请求。要接受远程连接请求,需要在服务器套接字上调用accept()方法。在来自远程客户端的请求到达其等待队列之前,accept()方法调用会一直阻塞。当服务器套接字接收到一个连接请求时,它从请求中读取远程 IP 地址和远程端口号,并创建一个新的活动套接字。新创建的活动套接字的引用从accept()方法返回。Socket类的一个对象代表新的活动套接字。accept()方法返回一个新的主动套接字,因为它不是像服务器套接字那样等待远程请求的被动套接字。它是一个活动套接字,因为它是为与远程客户端的活动通信而创建的。有时这个活动套接字也称为连接套接字,因为它处理连接上的数据传输。

// Wait for a new remote connection request

Socket activeSocket = serverSocket.accept();

一旦服务器套接字从accept()方法调用返回,服务器应用中的套接字数量就增加一个。您有一个被动服务器套接字和一个主动套接字。新的活动套接字是新客户端连接在服务器上的端点。此时,您需要使用新的活动套接字来处理与客户端的通信。

现在,您可以在新套接字表示的连接上读写数据了。Java TCP 套接字提供全双工连接。它允许您从连接中读取数据以及向连接中写入数据。为此,Socket类包含两个名为getInputStream()getOutputStream()的方法。getInputStream()方法返回一个InputStream对象,您可以使用它从连接中读取数据。getOutputStream()方法返回一个OutputStream对象,您可以使用它将数据写入连接。您可以使用InputStreamOutputStream对象,就好像您正在从本地文件系统上的一个文件中读取和写入一样。我假设您熟悉 Java I/O。如果您不熟悉 Java I/O,请在继续本节之前参考《Java 语言特性入门》( ISBN: 978-1-4302-6658-7)一书中的第七章。但是,您仍然可以在本章后面的小节中了解 UDP 套接字。当你在连接上读/写完数据后,关闭InputStream/OutputStream,最后关闭套接字。下面的代码片段从客户端读取一条消息,并将其回显给客户端。请注意,在开始通信之前,服务器和客户端必须就消息的格式达成一致。以下代码片段假设客户端一次发送一行文本:

// Create a buffered reader and a buffered writer from the socket's

// input and output streams, so that we can read/write one line at a time

BufferedReader br = new BufferedReader(

new InputStreamReader(activeSocket.getInputStream()));

BufferedWriter bw = new BufferedWriter(

new OutputStreamWriter(activeSocket.getOutputStream()));

您可以使用brbw来读取文件或写入文件。从输入流中读取数据的尝试会一直阻塞,直到数据在连接上可用。

// Read one line of text from the connection

String inMsg = br.readLine();

// Write some text to the output buffer

bw.write('hello from server");

bw.flush();

最后,使用套接字的close()方法关闭连接。关闭套接字也会关闭其输入和输出流。事实上,您可以关闭三个(输入流、输出流或套接字)中的一个,其他两个将自动关闭。试图在关闭的套接字上读/写会抛出一个java.net.SocketException。您可以通过使用其isClosed()方法来检查套接字是否关闭,如果套接字关闭,该方法将返回true

// Close the socket

activeSocket.close();

Tip

一旦你关闭了一个套接字,你就不能再使用它。在使用新套接字之前,必须创建一个新套接字并绑定它。

服务器处理两种工作:接受新的连接请求和响应已经连接的客户端。如果回应客户只需要很少的时间,您可以使用如下所示的策略:

ServerSocket serverSocket = create a server socket here;

while(true) {

Socket activeSocket = serverSocket.accept();

// Handle the client request on activeSocket here

}

上述策略一次处理一个客户端。只有当并发传入连接数非常低,并且客户端的请求只需要很少的时间来响应时,它才适用。如果一个客户端请求需要很长时间才能得到响应,那么所有其他客户端都必须等待才能得到服务。

处理多个客户机请求的另一个策略是在一个单独的线程中处理每个客户机的请求,这样服务器就可以同时为多个客户机服务。以下伪代码概述了这一策略:

ServerSocket serverSocket = create a server socket here;

while(true) {

Socket activeSocket = serverSocket.accept();

Runnable runnable = () -> {

// Handle the client request on the activeSocket here

};

new Thread(runnable).start(); // start a new thread

}

上面的策略似乎工作得很好,直到为并发客户端连接创建了太多的线程。另一个在大多数情况下都有效的策略是用一个线程池来服务所有的客户端连接。如果池中的所有线程都忙于为客户端提供服务,那么请求应该等待,直到有一个线程可以为其提供服务。

清单 5-3 包含了一个 echo 服务器的完整代码。它创建一个新线程来处理每个客户端请求。您现在可以运行 echo 服务器程序了。然而,它不会做很多事情,因为你没有一个客户端程序连接到它。在下一节学习如何创建 TCP 客户端套接字之后,您将看到它的实际应用。

清单 5-3。基于 TCP 套接字的 Echo 服务器

// TCPEchoServer.java

package com.jdojo.net;

import java.io.BufferedReader;

import java.io.BufferedWriter;

import java.io.IOException;

import java.io.InputStreamReader;

import java.io.OutputStreamWriter;

import java.net.InetAddress;

import java.net.ServerSocket;

import java.net.Socket;

public class TCPEchoServer {

public static void main(String[] args) {

try {

// Create a Server socket

ServerSocket serverSocket = new ServerSocket(12900, 100,

InetAddress.getByName("localhost"));

System.out.println("Server started at: " + serverSocket);

// Keep accepting client connections in an infinite loop

while (true) {

System.out.println("Waiting for a connection...");

// Accept a connection

final Socket activeSocket = serverSocket.accept();

System.out.println("Received a connection from " +

activeSocket);

// Create a new thread to handle the new connection

Runnable runnable =

() -> handleClientRequest(activeSocket);

new Thread(runnable).start(); // start a new thread

}

}

catch (IOException e) {

e.printStackTrace();

}

}

public static void handleClientRequest(Socket socket) {

BufferedReader socketReader = null;

BufferedWriter socketWriter = null;

try {

// Create a buffered reader and writer for teh socket

socketReader = new BufferedReader(

new InputStreamReader(socket.getInputStream()));

socketWriter = new BufferedWriter(

new OutputStreamWriter(socket.getOutputStream()));

String inMsg = null;

while ((inMsg = socketReader.readLine()) != null) {

System.out.println("Received from client: " + inMsg);

// Echo the received message to the client

String outMsg = inMsg;

socketWriter.write(outMsg);

socketWriter.write("\n");

socketWriter.flush();

}

}

catch (IOException e) {

e.printStackTrace();

}

finally {

try {

socket.close();

}

catch (IOException e) {

e.printStackTrace();

}

}

}

}

创建 TCP 客户端套接字

Socket类的一个对象代表一个 TCP 客户端套接字。您已经看到了Socket类的对象如何与 TCP 服务器套接字一起工作。对于服务器套接字,您从服务器套接字的accept()方法获得了一个Socket类的对象作为返回值。对于客户端套接字,您必须执行三个额外的步骤:创建、绑定和连接。Socket类提供了许多构造函数,让您指定远程 IP 地址和端口号。这些构造函数将套接字绑定到一个本地主机和一个可用的端口号。以下代码片段显示了如何创建 TCP 客户端套接字:

// Create a client socket, which is bound to the localhost at any

// available port; connected to remote IP of 192.168.1.2 at port 3456

Socket socket = new Socket("192.168.1.2", 3456);

// Create an unbound client socket. bind it, and connect it.

Socket socket = new Socket();

socket.bind(new InetSocketAddress("localhost", 14101));

socket.connect(new InetSocketAddress("localhost", 12900));

一旦获得一个连接的Socket对象,就可以分别使用getInputStream()getOutputStream()方法来使用它的输入和输出流。您可以在连接上读/写,就像您使用输入和输出流读/写文件一样。

清单 5-4 包含了一个 echo 客户端应用的完整代码。它接收来自用户的输入,将输入发送到 echo 服务器,如清单 5-3 所示,并在标准输出上打印服务器的响应。echo 服务器和 echo 客户机这两个应用必须就它们将要交换的消息格式达成一致。他们一次交换一行文本。值得注意的是,您必须为通过连接发送的每条消息附加一个新行,因为您使用的是BufferedReader类的readLine()方法,该方法只有在遇到新行时才返回。客户端应用必须使用服务器套接字接受连接的相同 IP 地址和端口号。

清单 5-4。基于 TCP 套接字的 Echo 客户端

// TCPEchoClient.java

package com.jdojo.net;

import java.io.BufferedReader;

import java.io.BufferedWriter;

import java.io.IOException;

import java.io.InputStreamReader;

import java.io.OutputStreamWriter;

import java.net.Socket;

public class TCPEchoClient {

public static void main(String[] args) {

Socket socket = null;

BufferedReader socketReader = null;

BufferedWriter socketWriter = null;

try {

// Create a socket that will connect to localhost

// at port 12900\. Note that the server must also be

// running at localhost and 12900.

socket = new Socket("localhost", 12900);

System.out.println("Started client socket at " +

socket.getLocalSocketAddress());

// Create a buffered reader and writer using the socket's

// input and output streams

socketReader = new BufferedReader(

new InputStreamReader(socket.getInputStream()));

socketWriter = new BufferedWriter(

new OutputStreamWriter(socket.getOutputStream()));

// Create a buffered reader for user's input

BufferedReader consoleReader =

new BufferedReader(new InputStreamReader(System.in));

String promptMsg = "Please enter a message (Bye to quit):";

String outMsg = null;

System.out.print(promptMsg);

while ((outMsg = consoleReader.readLine()) != null) {

if (outMsg.equalsIgnoreCase("bye")) {

break;

}

// Add a new line to the message to the server,

// because the server reads one line at a time.

socketWriter.write(outMsg);

socketWriter.write("\n");

socketWriter.flush();

// Read and display the message from the server

String inMsg = socketReader.readLine();

System.out.println("Server: " + inMsg);

System.out.println(); // Print a blank line

System.out.print(promptMsg);

}

}

catch (IOException e) {

e.printStackTrace();

}

finally {

// Finally close the socket

if (socket != null) {

try {

socket.close();

}

catch (IOException e) {

e.printStackTrace();

}

}

}

}

}

将 TCP 服务器和客户端放在一起

图 5-6 显示了三个客户端连接到一个服务器的设置。两个Socket对象,一端一个,代表一个连接。服务器中的ServerSocket对象一直在等待来自客户机的连接请求。

A978-1-4302-6662-4_5_Fig6_HTML.jpg

图 5-6。

A client-server setup using ServerSocket and socket objects

清单 5-3 和清单 5-4 列出了 TCP echo 服务器和客户端应用的完整程序。您需要首先运行TCPEchoServer类,然后运行TCPEchoClient类。服务器应用等待客户端应用连接。客户端应用提示用户在控制台上输入文本消息。一旦用户输入一条文本消息并按下Enter键,客户端应用就会将该文本发送到服务器。服务器用相同的消息进行响应。这两个应用都在标准输出中打印关于对话的详细信息。以下是 echo 服务器和 echo 客户端的输出。您可以运行TCPEchoClient应用的多个实例。服务器应用在单独的线程中处理每个客户端连接。

以下是服务器应用的输出示例:

Server started at: ServerSocket[addr=localhost/127.0.0.1,port=0,localport=12900]

Waiting for a connection ...

Received a connection from Socket[addr=/127.0.0.1,port=1698,localport=12900]

Waiting for a connection ...

Received from client: Hello

以下是客户端应用的输出示例:

Started client socket at /127.0.0.1:1698

Please enter a message (Bye to quit):Hello

Server: Hello

Please enter a message (Bye to quit):Bye

使用 UDP 套接字

基于 UDP 的套接字是无连接的,是基于数据报的,而 TCP 套接字是面向连接的,是基于流的。作为无连接套接字的效果是两个套接字(客户机和服务器)在通信之前不建立连接。回想一下,TCP 有一个服务器套接字,其唯一的功能是侦听来自远程客户端的连接请求。由于 UDP 是一种无连接协议,因此在使用 UDP 时不会有服务器套接字。在 TCP 套接字中,客户端和服务器之间具有面向流的数据传输的印象是由 TCP 在传输层产生的,因为它具有面向连接的特性。TCP 维护连接两端传输的数据的状态。UDP 是一种无连接协议,其含义是每一方(客户端和服务器)发送或接收一个数据块,而无需事先了解它们之间的通信。在使用 UDP 的通信中,发送到同一目的地的每个数据块都独立于以前发送的数据。使用 UDP 发送的数据块称为数据报或 UDP 数据包。每个 UDP 数据包都有数据、目的 IP 地址和目的端口号。UDP 是一种不可靠的协议,因为它不保证数据包到目标接收方的传递和传递顺序。

Tip

尽管 UDP 是一种无连接协议,但您可以在应用中使用 UDP 构建面向连接的通信。您将需要编写逻辑来处理丢失的数据包、无序的数据包传递以及许多其他事情。TCP 在传输层提供了所有这些特性,您的应用不必担心这些特性。

使用 UDP 套接字编写应用比使用 TCP 套接字编写应用更容易。您只需要处理两个类:

  • DatagramPacket
  • DatagramSocket

DatagramPacket类的对象代表 UDP 数据报,它是 UDP 套接字上的数据传输单元。DatagramSocket类的一个对象代表一个 UDP 套接字,用于发送或接收数据报数据包。以下是使用 UDP 套接字需要执行的步骤:

Create an object of the DatagramSocket class and bind it to a local IP address and a local port number.   Create an object of the DatagramPacket class to hold the destination address and the data to be transmitted.   Use the send() method to send the datagram packet to its destination. On the receiving end, use the receive() method to read the datagram packet.

您可以使用其中一个构造函数来创建一个DatagramSocket类的对象。它们都将创建套接字,并将其绑定到本地 IP 地址和本地端口号。请注意,UDP 套接字没有远程 IP 地址和远程端口号,因为它从未连接到远程套接字。它可以从/向任何 UDP 套接字接收/发送数据报分组。

// Create a UDP Socket bound to a port number 15900 at localhost

DatagramSocket udpSocket = new DatagramSocket(15900, "localhost");

DatagramSocket类提供了一个bind()方法,允许您将套接字绑定到一个本地 IP 地址和一个本地端口号。通常,您不需要使用此方法,因为您在构造函数中指定了它需要绑定到的套接字地址,就像您刚才所做的那样。

一个DatagramPacket包含三样东西:目的 IP 地址、目的端口号和数据。DatagramPacket类的构造函数分为两类。其中一个类别的构造函数允许您创建一个DatagramPacket对象来接收数据包。它们只需要缓冲区大小、偏移量和缓冲区中数据的长度。另一类构造函数允许您创建一个DatagramPacket对象来发送数据包。它们要求您指定目的地址和数据。如果您已经创建了一个没有指定目的地址的DatagramPacket,您可以使用setAddress()setPort()方法设置目的地址。

创建数据包以接收数据的DatagramPacket类的构造函数如下:

  • DatagramPacket(byte[] buf, int length)
  • DatagramPacket(byte[] buf, int offset, int length)

创建数据包发送数据的DatagramPacket类的构造函数如下:

  • DatagramPacket(byte[] buf, int length, InetAddress address, int port)
  • DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port)
  • DatagramPacket(byte[] buf, int length, SocketAddress address)
  • DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)

以下代码片段演示了创建数据报数据包的一些方法:

// Create a packet to receive 1024 bytes of data

byte[] data = new byte[1024];

DatagramPacket packet = new DatagramPacket(data, data.length);

// Create a packet that a has buffer size of 1024, but it will receive

// data starting at offset 8 (offset zero means the first element in

// the array) and it will receive only 32 bytes of data.

byte[] data2 = new byte[1024];

DatagramPacket packet2 = new DatagramPacket(data2, 8, 32);

// Create a packet to send 1024 bytes of data that has a destination

// address of "localhost" and port 15900\. Will need to populate data3

// array before sending the packet.

byte[] data3 = new byte[1024];

DatagramPacket packet3 = new DatagramPacket(data3, 1024,

InetAddress.getByName("localhost"), 15900);

// Create a packet to send 1024 bytes of data that has a destination

// address of "localhost" and port 15900\. Will need to populate data4

// array before sending the packet. The code sets the destination

// address by calling methods on the packet instead of specifying it

// in its constructor.

byte[] data4 = new byte[1024];

DatagramPacket packet4 = new DatagramPacket(data4, 1024);

packet4.setAddress(InetAddress.getByName("localhost"));

packet4.setPort(15900);

了解数据包中的数据始终具有指定的偏移量和长度非常重要。在从数据包中读取数据时,您需要使用这两条信息。假设一个receivedPacket对象引用代表一个从远程 UDP 套接字接收的DatagramPacketDatagramPacket类的getData()方法返回包的缓冲区(一个字节数组)。数据包可以有比从远程客户端接收的数据更大的缓冲区。在这种情况下,您必须使用偏移量和长度从缓冲区中读取接收到的数据,而不接触缓冲区中的垃圾数据。如果数据包的缓冲区大小小于接收的数据大小,多余的字节会被忽略。您应该使用类似如下所示的代码来读取套接字接收的数据。关键是你应该从指定的offset开始使用接收缓冲区中的数据,使用的字节数由它的length属性决定。

// Get the packet's buffer, offset, and length

byte[] dataBuffer = receivedPacket.getData();

int offset = receivedPacket.getOffset();

int length = receivedPacket.getLength();

// Copy the received data using offset and length to receivedData array,

// which will hold all good data

byte[] receivedData = new byte[length];

System.arraycopy(dataBuffer, offset, receivedData, 0, length);

创建 UDP 套接字(客户机和服务器)就像创建一个DatagramSocket类的对象一样简单。你可以用它的send()方法发送一个包。您可以使用receive()方法从远程套接字接收数据包。receive()方法阻塞,直到一个包到达。您向receive()方法提供一个空的数据包。套接字用它从远程套接字接收的信息填充它。如果所提供的数据报分组的数据缓冲区大小小于所接收的数据报分组的数据缓冲区大小,则所接收的数据被无声地截断以适合所提供的数据报分组。如果所提供的数据报包的数据缓冲区大小大于所接收的数据报包的数据缓冲区大小,则套接字会将所接收的数据复制到其偏移量和长度属性所指示的数据段中所提供的数据缓冲区,而不会触及缓冲区的其他部分。请注意,可用的数据缓冲区大小不是字节数组的大小。相反,它是由长度定义的。例如,假设您有一个数据报数据包,其字节数组包含 32 个元素,偏移量为 2,数据缓冲区长度为 8。如果您将这个数据报数据包传递给receive()方法,将复制最多 8 个字节的接收数据。数据将从缓冲器中的第三个元素复制到第十一个元素,分别由偏移量 2 和长度 8 表示。

// Create a UDP socket bound to a port number 15900 at localhost

DatagramSocket socket = new DatagramSocket(15900,

InetAddress.getByName("localhost"));

// Send a packet assuming that you have a datagram packet in p

socket.send(p);

// Receive a packet

DatagramPacket p2 = new DatagramPacket(new byte[1024], 1024);

socket.receive(p2);

创建 UDP Echo 服务器

使用 UDP 创建 echo 服务器非常容易。它只需要四行真正的代码。使用以下步骤创建 UDP echo 服务器:

Create a DatagramSocket object to represent a UDP socket.   Create a DatagramPacket object to receive the packet from a remote client.   Call the receive() method of the socket to wait for a packet to arrive.   Call the send() method of the socket passing the same packet that you received. When a UDP packet is received by a server, it contains the sender’s address. You do not need to change anything in the packet to echo back the same message to the sender of the packet. When you prepare a datagram packet for sending, you need to set a destination address. When the packet arrives at its destination, it contains its sender’s address. This is useful in case the receiver wants to respond to the sender of the datagram packet.

以下代码片段显示了如何编写 UDP echo 服务器:

DatagramSocket socket = new DatagramSocket(15900);

DatagramPacket packet = new DatagramPacket(new byte[1024], 1024);

while(true) {

// Receive the packet

socket.receive(packet);

//Send back the same packet to the sender

socket.send(packet);

}

清单 5-5 是 UDP echo 服务器相同代码的扩展版本。它包含与上面所示相同的基本逻辑。此外,它还有代码来处理错误,并在标准输出中打印数据包的详细信息。

清单 5-5。基于 UDP 套接字的 Echo 服务器

// UDPEchoServer.java

package com.jdojo.net;

import java.io.IOException;

import java.net.DatagramPacket;

import java.net.DatagramSocket;

import java.net.InetAddress;

public class UDPEchoServer {

public static void main(String[] args) {

final int LOCAL_PORT = 15900;

final String SERVER_NAME = "localhost";

try {

DatagramSocket udpSocket =

new DatagramSocket(LOCAL_PORT,

InetAddress.getByName(SERVER_NAME));

System.out.println("Created UDP server socket at " +

udpSocket.getLocalSocketAddress() + "...");

// Wait for a message in a loop and echo the same

// message to the sender

while (true) {

System.out.println("Waiting for a UDP packet" +

" to arrive...");

// Prepare a packet to hold the received data

DatagramPacket packet =

new DatagramPacket(new byte[1024], 1024);

// Receive a packet

udpSocket.receive(packet);

// Print the packet details

displayPacketDetails(packet);

// Echo the same packet to the sender

udpSocket.send(packet);

}

}

catch (IOException e) {

e.printStackTrace();

}

}

public static void displayPacketDetails(DatagramPacket packet) {

// Get the message

byte[] msgBuffer = packet.getData();

int length = packet.getLength();

int offset = packet.getOffset();

int remotePort = packet.getPort();

InetAddress remoteAddr = packet.getAddress();

String msg = new String(msgBuffer, offset, length);

System.out.println("Received a packet:[IP Address=" +

remoteAddr + ", port=" + remotePort +

", message=" + msg + "]");

}

}

清单 5-6 包含客户端应用的程序,该程序使用 UDP 套接字向/从 UDP echo 服务器发送/接收消息。请注意,客户端和服务器一次交换一行文本。

清单 5-6。基于 UDP 套接字的 Echo 客户端

// UDPEchoClient.java

package com.jdojo.net;

import java.io.BufferedReader;

import java.io.InputStreamReader;

import java.net.DatagramPacket;

import java.net.DatagramSocket;

import java.net.InetAddress;

import java.net.UnknownHostException;

public class UDPEchoClient {

public static void main(String[] args) {

DatagramSocket udpSocket = null;

BufferedReader br = null;

try {

// Create a UDP socket at localhost using an available port

udpSocket = new DatagramSocket();

String msg = null;

// Create a buffered reader to get an input from a user

br = new BufferedReader(new InputStreamReader(System.in));

String promptMsg = "Please enter a message (Bye to quit):";

System.out.print(promptMsg);

while ((msg = br.readLine()) != null) {

if (msg.equalsIgnoreCase("bye")) {

break;

}

// Prepare a packet to send to the server

DatagramPacket packet = UDPEchoClient.getPacket(msg);

// Send the packet to the server

udpSocket.send(packet);

// Wait for a packet from the server

udpSocket.receive(packet);

// Display the packet details received from

// the server

displayPacketDetails(packet);

System.out.print(promptMsg);

}

}

catch (Exception e) {

e.printStackTrace();

}

finally {

// Close the socket

if (udpSocket != null) {

udpSocket.close();

}

}

}

public static void displayPacketDetails(DatagramPacket packet) {

byte[] msgBuffer = packet.getData();

int length = packet.getLength();

int offset = packet.getOffset();

int remotePort = packet.getPort();

InetAddress remoteAddr = packet.getAddress();

String msg = new String(msgBuffer, offset, length);

System.out.println("[Server at IP Address=" + remoteAddr +

", port=" + remotePort + "]: " + msg);

// Add a line break

System.out.println();

}

public static DatagramPacket getPacket(String msg)

throws UnknownHostException {

// We will send and accept a message of 1024 bytes in length.

// longer messages will be truncated

final int PACKET_MAX_LENGTH = 1024;

byte[] msgBuffer = msg.getBytes();

int length = msgBuffer.length;

if (length > PACKET_MAX_LENGTH) {

length = PACKET_MAX_LENGTH;

}

DatagramPacket packet = new DatagramPacket(msgBuffer, length);

// Set the destination address and the port number

int serverPort = 15900;

final String SERVER__NAME = "localhost";

InetAddress serverIPAddress =

InetAddress.getByName(SERVER__NAME);

packet.setAddress(serverIPAddress);

packet.setPort(serverPort);

return packet;

}

}

为了测试 UDP echo 应用,您需要运行UDPEchoServerUDPEchoClient类。您需要首先运行服务器。客户端应用将提示您输入一条消息。输入一条短信并按下Enter键将该短信发送至服务器。服务器将回显相同的消息。两个应用都在标准输出上显示正在交换的消息。它们还显示数据包的详细信息,如发送者的 IP 地址和端口号。服务器应用使用端口号 15900,客户端应用使用计算机上任何可用的 UDP 端口。如果您得到一个错误,这意味着端口号 15900 正在使用,因此您需要在服务器程序中更改端口号,并在客户端程序中使用新的端口号来寻址数据包。该服务器被设计为同时处理多个客户端。您可以运行UDPEchoClient类的多个实例。请注意,服务器在无限循环中运行,您必须手动停止服务器应用。

以下是服务器控制台上的日志示例:

Created UDP server socket at /127.0.0.1:15900...

Waiting for a UDP packet to arrive...

Received a packet:[IP Address=/127.0.0.1, port=1522, message=Hello]

Waiting for a UDP packet to arrive...

Received a packet:[IP Address=/127.0.0.1, port=1522, message=Nice talking to you]

Waiting for a UDP packet to arrive...

以下是客户端控制台上的日志示例:

Please enter a message (Bye to quit):Hello

[Server at IP Address=localhost/127.0.0.1, port=15900]: Hello

Please enter a message (Bye to quit):Nice talking to you

[Server at IP Address=localhost/127.0.0.1, port=15900]: Nice talking to

You

Please enter a message (Bye to quit):bye

已连接的 UDP 套接字

UDP 套接字不像 TCP 套接字那样支持端到端连接。DatagramSocket类包含一个connect()方法。这种方法允许应用将 UDP 数据包的发送和接收限制到特定端口号上的特定 IP 地址。考虑以下代码片段:

InetAddress localIPAddress = InetAddress.getByName("192.168.11.101");

int localPort = 15900;

DatagramSocket socket = new DatagramSocket(localPort, localIPAddress);

// Connect the socket to a remote address

InetAddress remoteIPAddress = InetAddress.getByName("192.168.12.115");

int remotePort = 17901;

socket.connect(remoteIPAddress, remotePort);

套接字绑定到本地 IP 地址192.168.11.101和本地 UDP 端口号15900。它连接到一个远程 IP 地址192.168.12.115和一个远程 UDP 端口号17901。这意味着socket对象只能用于向/从另一个运行在 IP 地址192.168.12.115和端口号17901的 UDP 套接字发送/接收数据包。在 UDP 套接字上调用了connect()方法之后,您不需要为传出的数据报数据包设置目的地 IP 地址和端口号。套接字会将在connect()方法调用中使用的目的 IP 地址和端口号添加到所有传出的数据包中。如果您在发送数据包之前提供了目的地址,那么套接字将确保数据包中提供的目的地址与connect()方法调用中使用的远程地址相同。否则,send()方法将抛出一个IllegalArgumentException

使用 UDP 套接字的connect()方法有两个优点:

  • 每次您发送数据包时,它都会设置传出数据包的目的地址。
  • 它限制套接字只与远程主机通信,该主机的 IP 地址在connect()方法的调用中使用。

现在您了解了 UDP 套接字是无连接的,并且您没有使用 UDP 套接字的真正连接。DatagramSocket类中的connect()方法没有为 UDP 套接字提供任何类型的连接。相反,它有助于将通信限制到特定的远程 UDP 套接字。

UDP 多播套接字

Java 支持 UDP 多播套接字,这些套接字可以接收发送到多播 IP 地址的数据报数据包。MulticastSocket类的一个对象代表一个多播套接字。使用MulticastSocket套接字与使用DatagramSocket套接字类似,只有一点不同:多播套接字是基于组成员的。在创建并绑定了一个多播套接字之后,需要调用它的joinGroup(InetAddress multiCastIPAddress)方法,使这个套接字成为由指定的多播 IP 地址multiCastIpAddress定义的多播组的成员。一旦它成为一个多播组的成员,任何发送到该组的数据包都将被传送到这个套接字。一个多播组中可以有多个成员。多播套接字可以是多个多播组的成员。如果一个成员决定不接收来自一个组的多播包,它可以通过调用leaveGroup(InetAddress multiCastIPAddress)方法离开该组。

在 IPv4 中,224.0.0.0239.255.255.255范围内的任何 IP 地址都可以作为组播地址发送数据报包。IP 地址224.0.0.0是保留的,你不应该在你的应用中使用它。多播 IP 地址不能用作数据报数据包的源地址,这意味着您不能将套接字绑定到多播地址。

套接字本身不一定要成为多播组的成员才能将数据报数据包发送到多播地址。

Java 7 为DatagramChannel类添加了 IP 多播功能。请参阅本章后面的“使用数据报通道的多播”一节,了解如何使用数据报通道进行 IP 多播。注意,DatagramChannel类是在 Java 1.4 中添加的,它没有 IP 多播功能。

清单 5-7 包含一个创建组播套接字的程序,该套接字接收寻址到 230.1.1.1 组播 IP 地址的数据包。

清单 5-7。接收 UDP 多播消息的 UDP 多播套接字

// UDPMultiCastReceiver.java

package com.jdojo.net;

import java.io.IOException;

import java.net.DatagramPacket;

import java.net.InetAddress;

import java.net.MulticastSocket;

public class UDPMultiCastReceiver {

public static void main(String[] args) {

int mcPort = 18777;

String mcIPStr = "230.1.1.1";

MulticastSocket mcSocket = null;

InetAddress mcIPAddress = null;

try {

mcIPAddress = InetAddress.getByName(mcIPStr);

mcSocket = new MulticastSocket(mcPort);

System.out.println("Multicast Receiver running at:" +

mcSocket.getLocalSocketAddress());

// Join the group

mcSocket.joinGroup(mcIPAddress);

DatagramPacket packet =

new DatagramPacket(new byte[1024], 1024);

while (true) {

System.out.println("Waiting for a multicast message...");

mcSocket.receive(packet);

String msg = new String(packet.getData(),

packet.getOffset(),

packet.getLength());

System.out.println("[Multicast Receiver] Received:" + msg);

}

}

catch (Exception e) {

e.printStackTrace();

}

finally {

if (mcSocket != null) {

try {

mcSocket.leaveGroup(mcIPAddress);

mcSocket.close();

}

catch (IOException e) {

e.printStackTrace();

}

}

}

}

}

清单 5-8 包含了一个向同一个组播地址发送消息的程序。请注意,您可以运行UDPMulticastReceiver类的多个实例,所有这些实例都将成为同一个多播组的成员。当您运行UDPMulticastSender类时,它将向组发送一条消息,组中的所有成员都将收到同一条消息的副本。UDPMulticastSender类使用DatagramSocket,而不是MulticastSocket来发送多播消息。

清单 5-8。UDP 数据报套接字、多播发送器应用

// UDPMultiCastSender.java

package com.jdojo.net;

import java.net.DatagramPacket;

import java.net.DatagramSocket;

import java.net.InetAddress;

public class UDPMultiCastSender {

public static void main(String[] args) {

int mcPort = 18777;

String mcIPStr = "230.1.1.1";

DatagramSocket udpSocket = null;

try {

// Create a datagram socket

udpSocket = new DatagramSocket();

// Prepare a message

InetAddress mcIPAddress = InetAddress.getByName(mcIPStr);

byte[] msg = "Hello multicast socket".getBytes();

DatagramPacket packet =

new DatagramPacket(msg, msg.length);

packet.setAddress(mcIPAddress);

packet.setPort(mcPort);

udpSocket.send(packet);

System.out.println("Sent a multicast message.");

System.out.println("Exiting application");

}

catch (Exception e) {

e.printStackTrace();

}

finally {

if (udpSocket != null) {

try {

udpSocket.close();

}

catch (Exception e) {

e.printStackTrace();

}

}

}

}

}

URI、URL 和 URN

统一资源标识符(URI)是标识资源的字符序列。征求意见稿(RFC) 3986 定义了 URI 的通用语法。此 RFC 的全文可在 http://www.ietf.org/rfc/rfc3986.txt 获得。资源标识符可以通过位置、名称或两者来标识资源。本节概述了 URI。如果您对 URI 的细节感兴趣,建议您阅读 RFC3986。

使用位置来标识资源的 URI 称为统一资源定位器(URL)。例如, http://www.yahoo.com/index.html 表示在主机 www.yahoo.com 上标识名为index.html的文档的 URL。URL 的另一个例子是mailto: ksharan@jdojo.com,其中mailto协议指示解释它的应用打开电子邮件应用,向 URL 中指定的电子邮件地址发送电子邮件。在这种情况下,URL 没有定位任何资源。相反,它是识别电子邮件的细节。您还可以使用mailto协议设置电子邮件的主题和正文部分。因此,URL 并不总是意味着资源的位置。有时资源可能是抽象的,如mailto协议的情况。使用 URL 找到资源后,可以对资源执行一些操作,如检索、更新或删除。如何执行操作的细节取决于 URL 中使用的方案。URL 只是标识资源位置和定位它的方案的一部分,而不是可以在资源上执行的任何操作的细节。

使用名称来标识资源的 URI 称为统一资源名(URN)。例如,URN:ISBN:978-1-4302-6661-7表示一个 URN,它使用国际标准书号(ISBN)名称空间来标识一本书。

URL 和 URN 是 URI 的子集。因此,关于 URI 的讨论同时适用于 URL 和 URN。URI 的详细语法取决于它使用的方案。在这一节中,我将介绍 URI 的一般语法,它通常是一个 URL。下一节将探讨在 Java 程序中用来表示 URIs 和 URL 的 Java 类。

URI 可以是绝对的,也可以是相对的。一个相对的 URI 总是在另一个绝对的 URI 的背景下被解释,后者被称为基本的 URI。换句话说,你必须有一个绝对的 URI,才能使相对的 URI 有意义。

绝对 URI 具有以下通用格式:

<scheme>:<scheme-specific-part>

<scheme-specific-part>依赖于<scheme>。例如,http方案使用一种格式,而mailto方案使用另一种格式。URI 的另一种通用形式如下。通常,但不是必须的,它代表一个 URL。

<scheme>://<authority><path>?<query>#<fragment>

这里,<scheme>表示访问资源的方法。是协议名称,如httpftp等。在 URI 规范中,我们都使用术语“协议”来表示“方案”。如果术语“scheme”使您迷惑,那么无论它何时出现在本节中,您都可以将其理解为“protocol”。URI 需要<scheme><path>零件。所有其他部分都是可选的。<path>部分可以是空字符串。

<authority>部分表示服务器名(或 IP 地址)或特定于方案的注册表。如果<authority>部分代表一个服务器名,它可以写成<userinfo>@host:port的形式。如果 URI 中有一个<authority>,它以两个正斜杠开始;这是一个可选部分。例如,一个标识机器上本地文件系统中的一个文件的 URL 使用file方案作为file:///c:/documents/welcome.doc

URI 语法在它的<path>部分使用了分层语法,它在服务器上定位资源。<path>的多个部分由正斜杠(/)分隔。

<query>部分表示通过执行指定的查询获得资源。它由用“与”符号(&)分隔的名称-值对组成。名称和值由等号(=)分隔。例如,id=123&rate=5.5是一个查询,它有两个部分,idrateid的值是123,而rate的值是5.5

<fragment>部分标识次要资源,通常是由 URI 的另一部分标识的主要资源的子集。

下面是一个 URI 的例子,它也被分成几个部分:

URI: http://www.jdojo.com/java/intro.html?id=123#conclusion

Scheme:    http

Authority:www.jdojo.com

Path:      /java/intro.html

Query:    id=123

Fragment:  conclusion

URI 代表一个 URL,该 URL 指向在 www.jdojo.com 服务器上名为intro.html的文档。方案http表明可以使用http协议检索文档。查询id=123表明该文档是通过执行该查询获得的。片段部分conclusion可以由使用该文档的不同应用进行不同的解释。在 HTML 文档的情况下,片段部分被 web 浏览器解释为主文档的一部分。

并非 URI 的所有部分都是强制性的。哪些部分是强制性的,哪些部分是可选的,这取决于所使用的方案。使用 URI 来标识资源的目标之一是使其具有普遍的可读性。因此,有一组定义明确的字符可以用来表示 URI。URI 语法使用一些具有特殊含义的保留字符,它们只能用于 URI 的特定部分。在其他部分,需要对保留字符进行转义。使用百分号后跟十六进制格式的 ASCII 值对字符进行转义。例如,空格的 ASCII 值在十进制格式中是 32,在十六进制格式中是 20。如果您想在 URI 中使用空格字符,您必须使用%20,它是空格的转义形式。因为百分号被用作转义字符的一部分,所以您必须使用%25来表示 URI 中的%字符(25 是十进制数 37 的十六进制值。%的 ASCII 值是十进制的 37)。例如,如果要在查询中使用值 5.2%,以下是无效的 URI:

http://www.jdojo.com/details?rate=5.2%

要使其成为有效的 URI,您需要将百分号字符转义为%25,如下所示:

http://www.jdojo.com/details?rate=5.2%25

理解相对 URI 的用法很重要。一个相对的 URI 总是在一个绝对的 URI 的背景下被解释,后者被称为基础 URI。绝对的 URI 始于一个计划。一个亲戚 URI 继承了它的基地 URI 的一些部分。让我们考虑一个引用 HTML 文档的 URI,如下所示:

http://www.jdojo.com/java/intro.html

URI 所指的文件是intro.html。它的路径是/java/intro.html。假设名为brief_intro.htmldetailed_intro.html的两个文档(物理上或逻辑上)与intro.html位于相同的路径层次结构中。以下是所有三个文档的绝对 URIs:

如果您已经在intro.html上下文中,使用它们的名字而不是它们的绝对 URI 来引用另外两个文档会更容易。在intro.html环境中意味着什么?当您使用 http://www.jdojo.com/java/intro.html URI 来标识一个资源时,它有三个部分:一个方案(http)、一个服务器名( www.jdojo.com )和一个文档路径(/java/intro.html)。该路径表明该文档位于java路径层次结构下,而后者又位于路径层次结构的根。所有细节——方案、服务器名称、路径细节,不包括文档名称本身(intro.html)—构成了intro.html文档的上下文。如果您查看上面列出的其他两个文档的 URI,您会注意到关于它们的所有细节都与intro.html相同。换句话说,您可以声明其他两个文档的上下文与intro.html的相同。在这种情况下,以intro.html文档的绝对 URI 作为基本 URI,其他两个文档的相对 URIs 是它们的名称:brief_intro.htmldetailed_intro.html。可以列举如下:

在该列表中,两个相对 URIs 从基本 URI 继承方案、服务器名称和路径层次结构。需要强调的是,一个相对的 URI 如果不指明它的基地 URI 是没有意义的。

当必须使用相对 URI 时,它必须被解析为其等价的绝对 URI。URI 规范规定了解决相对 URI 的规则。我将讨论一些最常用的相对 URIs 形式及其解决方案。有两个特殊字符用于定义 URI 的<path>部分。它们是一个点和两个点。点表示当前的路径层次。两个点表示路径层次中的上一级。您一定见过在文件系统中使用这两组字符来表示当前目录和父目录。您可以用同样的方式思考它们在 URI 中的含义,但是 URI 并不假定任何目录层次结构。在 URI 中,路径被认为是分层的,它根本不依赖于文件系统的分层结构。然而,在实践中,当您使用基于 web 的应用时,URL 通常被映射到一个文件系统层次结构。在 URI 的规范化形式中,点会被适当替换。比如s://sn/a/./b归一化为s://sn/a/bs://sn/a/../b归一化为s://sn/b。非规范化和规范化形式指的是同一个 URL。规范化格式移除了多余的字符。只看两个 URIs,你不能说他们是否指的是同一个资源。在比较它们是否相等之前,必须对它们进行规范化。在比较过程中,方案、服务器名称和十六进制数字不区分大小写。以下是解决相对 URI 的一些规则:

  • 如果一个 URI 以一个计划开始,它被认为是一个绝对的 URI。
  • 如果相对 URI 以权威开始,它从其基础 URI 继承方案。
  • 如果一个相对 URI 是一个空字符串,它与基 URI 相同。
  • 如果相对 URI 只有片段部分,则解析后的 URI 将使用新片段。如果一个 URI 有一个片段,它将被相对 URI 的片段所取代。否则,将相对 URI 的片段添加到基本 URI 中。
  • 相对 URI 的路径不是以正斜杠(/)开始的。如果基本 URI 有路径,则删除基本 URI 中路径的最后一个组件,并附加相对 URI。注意,路径的最后一部分可能是一个空字符串,如 http://www.abc.com/
  • 如果相对 URL 以路径开头,而路径又以正斜杠(/)开头,则基本 URI 路径将被相对 URI 路径替换。

表 5-5 包含使用这些规则的例子。表中的示例符合 Java URI 和 URL 类中遵循的规则。Java 规则在某些情况下与 URI 规范中的规则略有不同。

表 5-5。

Examples of How a Relative URI is Resolved to an Absolute URI Using a Base URI

| 基本 URI | 相对 URI | 解析相对 URI | 相对 URI 的描述 | | --- | --- | --- | --- | | `h://sn/a/b/c` | `http://sn2/foo` | `h://sn2/foo` | 这绝对是 URI。 | | `h://sn/a/b/c` | `//sn2/h/k` | `h://sn2/h/k` | 它始于一个权威 | | `h://sn/a/b/c` |   | `h://sn/a/b/c` | 它是一个空字符串。 | | `h://sn/a/b/c` | `#k` | `h://sn/a/b/c#k` | 它只包含一个片段。 | | `h://sn/a/b/c#a` | `#k` | `h://sn/a/b/c#k` | 它只包含一个片段。 | | `h://sn/a/b/` | `foo` | `h://sn/a/b/foo` | 路径不是以/开头。 | | `h://sn/a/b/c` | `foo` | `h://sn/a/b/foo` | 路径不是以/开头。 | | `h://sn/a/b/c?d=3` | `foo` | `h://sn/a/b/foo` | 路径不是以/开头。 | | `h://sn/` | `foo` | `h://sn/foo` | 路径不是以/开头。 | | `h://sn` | `foo` | `h://sn/foo` | 路径不是以/开头。 | | `h://sn/a/b/` | `/foo` | `h://sn/foo` | 路径以/开头。 | | `h://sn/a/b/c` | `/foo` | `h://sn/foo` | 路径以/开头。 | | `h://sn/a/b/c?d=3` | `/foo` | `h://sn/foo` | 路径以/开头。 | | `h://sn/` | `/foo` | `h://sn/foo` | 路径以/开头。 | | `h://sn` | `/foo` | `h://sn/foo` | 路径以/开头。 |

Tip

您也可以使用主机名或 IP 地址作为 URI 中的授权机构。IPv4 可以使用点分十进制格式,如 http://192.168.10.178/docs/toc.html 。IPv6 必须用括号括起来,如http://[1283::8:800:200C:A43A]/docs/toc.html

作为 Java 对象的 URI 和 URL

Java 将 URI 和 URL 表示为对象。它提供了以下四个类,可用于在 Java 程序中将 URI 和 URL 作为对象进行处理:

  • java.net.URI
  • java.net.URL
  • java.net.URLEncoder
  • java.net.URLDecoder

一个URI类的对象代表一个 URI。URL类的一个对象代表一个 URL。URLEncoderURLDecoder是帮助编码和解码 URI 字符串的实用程序类。在下一节中,我将讨论用于检索由 URL 标识的资源的其他 Java 类。

URI类有许多构造函数。它们允许您传递 URI 各部分(模式、权限、路径、查询和片段)的可变组合。所有的构造函数都会抛出一个名为URISyntaxException的检查异常。他们抛出一个异常,因为你用来构造一个URI对象的字符串可能不符合 URI 规范。

// Create a URI object

URI baseURI = new URI("http://www.yahoo.com

// Create a URI with relative URI string and resolve it using baseURI

URI relativeURI = new URI("welcome.html");

URI resolvedRelativeURI = baseURI.resolve(relativeURI);

清单 5-9 展示了如何在 Java 程序中使用URI类。

清单 5-9。演示 java.net.URI 类用法的示例类

// URITest.java

package com.jdojo.net;

import java.net.URI;

import java.net.URISyntaxException;

public class URITest {

public static void main(String[] args) {

String baseURIStr = "http://www.jdojo.com/javaintro.html

"id=25&rate=5.5%25#foo";

String relativeURIStr = "../sports/welcome.html";

try {

URI baseURI = new URI(baseURIStr);

URI relativeURI = new URI(relativeURIStr);

// Resolve the relative URI with respect to the base URI

URI resolvedURI = baseURI.resolve(relativeURI);

printURIDetails(baseURI);

printURIDetails(relativeURI);

printURIDetails(resolvedURI);

}

catch (URISyntaxException e) {

e.printStackTrace();

}

}

public static void printURIDetails(URI uri) {

System.out.println("URI:" + uri);

System.out.println("Normalized:" + uri.normalize());

String parts = "[Scheme=" + uri.getScheme() +

", Authority=" + uri.getAuthority() +

", Path=" + uri.getPath() +

", Query:" + uri.getQuery() +

", Fragment:" + uri.getFragment() + "]";

System.out.println(parts);

System.out.println();

}

}

URI: http://www.jdojo.com/javaintro.html?id=25&rate=5.5%25#foo

Normalized: http://www.jdojo.com/javaintro.html?id=25&rate=5.5%25#foo

[Scheme=http, Authority=www.jdojo.com

URI:../sports/welcome.html

Normalized:../sports/welcome.html

[Scheme=null, Authority=null, Path=../sports/welcome.html, Query:null, Fragment:null]

URI:http://www.jdojo.com/../sports/welcome.html

Normalized:http://www.jdojo.com/../sports/welcome.html

[Scheme=http, Authority=www.jdojo.com

您也可以使用toURL()方法从URI对象获取URL对象,如下所示:

URL baseURL = baseURI.toURL();

您还可以使用 URI 类的create(String str) static方法创建一个URI对象。create()方法不会抛出一个检查过的异常。它抛出一个运行时异常。因此,它的使用不会强制您处理异常。只有当您知道 URI 字符串是格式良好的时,才应该使用此方法。

URI uri2 = URI.create("http://www.yahoo.com

java.net.URL类的一个实例代表 Java 程序中的一个 URL。虽然每个 URL 也是一个 URI,但是 Java 并没有从URI类继承URL类。Java 使用术语协议来指代 URI 规范中的方案部分。您可以通过提供一个将 URL 的所有部分连接在一起的字符串,或者通过单独提供 URL 的各个部分来创建 URL 对象。如果您为创建 URL 对象提供的字符串无效,URL 类的构造函数将抛出一个MalformedURLException checked 异常。当你创建一个URL对象时,你必须处理这个异常。

清单 5-10 展示了如何创建一个URL对象。URL类允许您使用它的一个构造函数从一个相对 URL 和一个基本 URL 创建一个绝对 URL。

清单 5-10。演示 java.net.URL 类用法的示例类

// URLTest.java

package com.jdojo.net;

import java.net.URL;

public class URLTest {

public static void main(String[] args) {

String baseURLStr = "http://www.ietf.org/rfc/rfc3986.txt

String relativeURLStr = "rfc2732.txt";

try {

URL baseURL = new URL (baseURLStr);

URL resolvedRelativeURL = new URL(baseURL, relativeURLStr);

System.out.println("Base URL:" + baseURL);

System.out.println("Relative URL String:" + relativeURLStr);

System.out.println("Resolved Relative URL:" + resolvedRelativeURL);

}

catch (Exception e) {

e.printStackTrace();

}

}

}

Base URL:http://www.ietf.org/rfc/rfc3986.txt

Relative URL String:rfc2732.txt

Resolved Relative URL:http://www.ietf.org/rfc/rfc2732.txt

通常,创建一个URL对象来检索由 URL 标识的资源。注意,只要 URL 的文本格式良好,并且处理 URL 的协议可用,就可以创建一个URL类的对象。在 Java 程序中成功创建一个URL对象并不保证在 URL 中指定的服务器上存在该资源。URL类提供了一些方法,您可以结合其他类使用这些方法来检索由 URL 标识的资源。

URL类确保它能够处理 URL 字符串中指定的协议。例如,它不会让你创建一个带有字符串ppp:// www.sss.com/URL对象,除非你为它开发并提供一个ppp协议的协议处理程序。我将在下一节详细讨论如何检索由 URL 标识的资源。

有时,您事先并不知道 URL 字符串的各个部分。您在运行时从程序的其他部分或用户那里获得 URL 的各个部分作为输入。在这种情况下,您需要对 URL 的各个部分进行编码,然后才能使用它们来创建一个URL对象。有时你得到一个编码形式的字符串,你希望它被解码。编码后的字符串将所有受限字符正确转义。

URLEncoderURLDecoder类分别用于编码和解码字符串。URLEncoder.encode(String source, String encoding) static方法用于使用指定的编码对源字符串进行编码。URLDecoder.decode(String source, String encoding) static方法用于使用指定的编码对源字符串进行解码。以下代码片段显示了如何编码/解码字符串。通常,在 URL 的查询部分对名称-值对的值部分进行编码/解码。请注意,不要试图对整个 URL 字符串进行编码。否则,它将对一些保留字符(如正斜杠)进行编码,结果 URL 字符串将无效。

String source = "this is a test for 2.5% and &" ;

String encoded = URLEncoder.encode(source, "utf-8");

String decoded = URLDecoder.decode(encoded, "utf-8");

System.out.println("Source: " + source);

System.out.println("Encoded: " + encoded);

System.out.println("Decoded: " + decoded);

Source: this is a test for 2.5% and &

Encoded: this+is+a+test+for+2.5%25+and+%26

Decoded: this is a test for 2.5% and &

访问 URL 的内容

URL 有一个协议,用于与托管 URL 内容的远程应用进行通信。例如,URL http://www.yahoo.com/index.html 使用的是http协议。在 URL 中,您可以指定协议套件中应用层使用的协议。当您需要访问 URL 的内容时,计算机将使用协议组中较低层的某种协议(传输层、互联网层等)。)与远程主机通信。http应用层协议在较低层使用TCP/IP协议。在分布式应用中,经常需要检索(或读取)由 URL 标识的资源(可以是文本、html 内容、图像文件、音频/视频文件或任何其他类型的信息)。虽然每次需要读取 URL 的内容时都可以打开一个 socket,但是对于程序员来说,这是非常耗时和繁琐的。毕竟,程序员需要一些方法来提高效率,而不是为看似例行公事的工作编写重复的代码。Java 设计者意识到了这种需求,他们提供了一种非常简单(是的,非常简单)的方法来从/向 URL 读取/写入数据。本节将探索从非常简单到非常复杂的从/向 URL 读取/写入数据的一些方法。

当数据在协议组中从一层传递到另一层时,每一层都会向数据添加一个报头。由于 URL 使用应用层的协议,所以它也包含自己的头。报头的格式取决于所使用的协议。当http请求被发送到远程主机时,源主机中的应用层向数据添加http报头。远程主机有一个处理http协议的应用层,它使用报头信息来解释内容。总之,URL 数据将有两个部分:标题部分和内容部分。URL类和其他一些类可以让你读/写 URL 的头部和内容部分。我将从读取 URL 内容这个最简单的例子开始。

在读取/写入 URL 之前,您需要有一个可以访问的工作 URL。您可以阅读互联网上公开的任何 URL 的内容。对于这个讨论,我将假设您熟悉 Java Server Pages (JSP ),并且您可以访问一个 web 服务器来部署 JSP 页面。如果您不了解 JSP,您可以将本节示例中使用的 URL 替换为任何公开可用的 URL;例如,URL http://www.yahoo.com 就可以了,您应该能够运行所有的示例。将数据写入 URL 略有不同。如果你能运行你的 JSP 来看看如何写一个 URL 会更容易。我假设您已经在 web 服务器上部署了一个 web 应用,它有一个名为echo_params.jsp的网页。

清单 5-11 显示了这个 JSP 页面的内容。它执行两件事。它读取HTTP请求方法,可以是 GET 或 POST,并打印它。它读取通过HTTP请求传入的所有参数,并打印参数名称和值的列表。

清单 5-11。echo_params.jsp 文件的内容

<%@ page contentType="text/html;charset=windows-1252"%>

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;

charset=windows-1252"/>

<title>Echo Request Method and Parameters</title>

</head>

<body>

<h1>URL Connection Test</h1>

<%

out.println("Request Method: " + request.getMethod());

out.println("<br/><br/>");

out.println("<u>List of Parameter Names and Values</u><br/>");

java.util.Enumeration paramNames =

request.getParameterNames();

while(paramNames.hasMoreElements()) {

String paramName  = (String)paramNames.nextElement();

String paramValue = request.getParameter(paramName);

out.println("Name: " + paramName + ", Value: " + paramValue);

out.println("<br/>");

}

%>

</body>

</html>

通过编写如下所示的两行代码,URL类允许您读取 URL 的内容(不是标题):

URL url = new URL("your URL string goes here");

InputStream ins = url.openStream();

清单 5-12 给出了读取 URL 内容的完整程序。您需要根据您的 web 服务器设置更改该程序中的 URL。输出显示您确实访问了 JSP,JSP 获得传递给它的查询(id=123)并传回生成的 HTML 内容。HTML 请求是使用 GET 方法发送的。如果您想使用 POST 方法向一个 URL 发送请求,您将需要使用URLConnection类,我将在下面讨论它。为了更好的可读性,我对输出进行了格式化。

清单 5-12。一个简单的 URL 内容阅读器程序

// SimpleURLContentReader.java

package com.jdojo.net;

import java.io.BufferedReader;

import java.io.IOException;

import java.io.InputStream;

import java.io.InputStreamReader;

import java.net.URL;

public class SimpleURLContentReader {

public static String getURLContent(String urlStr) {

BufferedReader br = null;

try {

URL url = new URL(urlStr);

// Get the input stream

InputStream ins = url.openStream();

// Wrap input stream into a reader

br = new BufferedReader(new InputStreamReader(ins));

StringBuilder sb = new StringBuilder();

String msg = null;

while ((msg = br.readLine()) != null) {

sb.append(msg);

sb.append("\n"); // Append a new line

}

return sb.toString();

}

catch (IOException e) {

e.printStackTrace();

}

finally {

if (br != null) {

try {

br.close();

}

catch (IOException e) {

e.printStackTrace();

}

}

}

// If we get here it means there was an error

return null;

}

public static void main(String[] args) {

String urlStr = "``http://localhost:8080/docsapp/

"echo_params.jsp?id=123";

String content = getURLContent(urlStr);

System.out.println(content);

}

}

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;

charset=windows-1252"/>

<title>Echo Request Method and Parameters</title>

</head>

<body>

<h1>URL Connection Test</h1>

Request Method: GET

<br/><br/>

<u>List of Parameter Names and Values</u><br/>

Name: id, Value: 123

<br/>

</body>

</html>

一旦获得输入流,就可以用它来读取 URL 的内容。读取 URL 内容的另一种方式是使用URL类的方法。因为getContent()可以返回任何类型的内容,所以它的返回类型是Object类型。在使用对象的内容之前,您需要检查它返回哪种对象。例如,它可能返回一个InputStream对象,在这种情况下,您将需要从输入流中读取数据。以下是两种版本的getContent()方法:

  • final Object getContent() throws IOException
  • final Object getContent(Class[] classes) throws IOException

方法的第二个版本允许您传递类类型的数组。它将尝试将内容对象转换为您按指定顺序传递给它的某个类。如果内容对象不匹配任何类型,它将返回null。您仍然需要编写if语句来知道从getContent()方法返回的是什么类型的对象,如下所示:

URL baseURL = new URL ("your url string goes here");

Class[] c = new Class[] {String.class, BufferedReader.class, InputStream.class};

Object content = baseURL.getContent(c);

if (content == null) {

// Contents are not of any of the three kinds

}

else if (content instanceof String) {

// You got a string

}

else if (content instanceof BufferedReader) {

// You got a reader

}

else if (content instanceof InputStream) {

// You got an input stream

}

如果您使用openStream()getContent()方法读取 URL 的内容,那么URL类会处理许多在内部使用套接字的复杂性。这种方法的缺点是您无法控制连接设置。您不能使用这种方法将数据写入 URL。此外,您无权访问 URL 中使用的协议的标头信息。不要绝望;Java 提供了另一个名为URLConnection的类,让您以一种简单明了的方式完成所有这些工作。URLConnection是一个abstract类,你不能直接创建它的对象。您需要使用 URL 对象的openConnection()方法来获得一个URLConnection对象。URL类将处理URLConnection对象的创建,这将适合于处理 URL 中使用的协议的数据。下面的代码片段显示了如何使用一个URLConnection对象来读取和写入数据到一个 URL:

URL url = new URL("your URL string goes here");

// Get a connection object

URLConnection connection = url.openConnection();

// Indicate that you will be writing to the connection

connection.setDoOutput(true);

// Get output/input streams to write/read data

OutputStream ous = connection.getOutputStream();

InputStream ins = connection.getInputStream(); // Caution. Read below

URL类的方法返回一个URLConnection对象,它还没有连接到 URL 源。在连接之前,您必须为该对象设置所有与连接相关的参数。例如,如果您想将数据写入 URL,您必须在连接之前调用连接对象上的setDoOutput(true)方法。当你调用一个URLConnection对象的connect()方法时,它就会被连接起来。但是,当您调用需要连接的方法时,它是隐式连接的。例如,向 URL 写入数据并读取 URL 的数据或头字段将自动连接URLConnection对象,如果它还没有连接的话。

如果你想避免在使用URLConnection读写 URL 数据时出现问题,你必须遵循以下几点:

  • 当您只从 URL 读取数据时,您可以使用它的getInputStream()方法获得输入流。使用输入流读取数据。它将使用 GET 方法向远程主机发出请求。也就是说,如果要向 URL 传递一些参数,必须通过向 URL 添加查询部分来实现。
  • 如果你在从一个 URL 中写入和读取数据,你必须在连接前调用setDoOutput(true)。在开始读取数据之前,必须先将数据写入 URL。将数据写入 URL 会将请求方法更改为POST。在将数据写入 URL 之前,您甚至无法获得输入流。事实上,getInputStream()方法向远程主机发送一个请求。您的目的是将数据发送到远程主机,并从远程主机读取响应。这一次变得非常棘手。这里有更多的解释,使用一段代码,假设connection是一个URLConnection object:

// Incorrect – 1\. Get input and output streams

// you must get the output stream first

InputStream ins = connection.getInputStream();

OutputStream ous = connection.getOutputStream();

// Incorrect – 2\. Get output and input streams

// you must get the output stream and finish writing

// before you should get the input stream

OutputStream ous = connection.getOutputStream();

InputStream ins = connection.getInputStream();

// Correct. Get output stream and get done with it.

// And, then get the input stream and read data.

OutputStream ous = connection.getOutputStream();

// Write logic to write data using ous object here. Make sure

// you are done writing data before you call the

// getInputStream() method as shown below

InputStream ins = connection.getInputStream();

// Write logic to read data

  • 使用getInputStream()方法和使用任何方法(如getHeaderField(String headerName))读取标题字段具有相同的效果。URL 的服务器提供标题和内容。一个URLConnection必须发送请求才能得到它们。

清单 5-13 包含了向清单 5-11 中列出的echo_params.jsp页面写入/从中读取数据的完整代码。注意,您需要将echo_params.jsp页面部署到一个 web 服务器上,这个示例才能运行。为了更好的可读性,我对输出进行了格式化。

清单 5-13。向/从 URL 写入/读取数据的 URL 读取器/写入器类

// URLConnectionReaderWriter.java

package com.jdojo.net;

import java.io.BufferedReader;

import java.io.BufferedWriter;

import java.io.IOException;

import java.io.InputStream;

import java.io.InputStreamReader;

import java.io.OutputStream;

import java.io.OutputStreamWriter;

import java.io.UnsupportedEncodingException;

import java.net.URL;

import java.net.URLConnection;

import java.net.URLEncoder;

import java.util.Map;

public class URLConnectionReaderWriter {

public static String getURLContent(String urlStr, String input) {

BufferedReader br = null;

BufferedWriter bw = null;

try {

URL url = new URL(urlStr);

URLConnection connection = url.openConnection();

// Must call setDoOutput(true) to indicate that we

// will write to the connection. By default, it is fals

// By default, setDoInput() is set to true.

connection.setDoOutput(true);

// Now, connect to the remote object

connection.connect();

// Write data to the URL first before reading the response

OutputStream ous = connection.getOutputStream();

bw = new BufferedWriter(new OutputStreamWriter(ous));

bw.write(input);

bw.flush();

bw.close();

// Must be placed after writing the data. Otherwise,

// it will result in error, because if write is performed,

// read must be performed after the write

printRequestHeaders(connection);

InputStream ins = connection.getInputStream();

// Wrap the input stream into a reader

br = new BufferedReader(new InputStreamReader(ins));

StringBuffer sb = new StringBuffer();

String msg = null;

while ((msg = br.readLine()) != null) {

sb.append(msg);

sb.append("\n"); // Append a new line

}

return sb.toString();

}

catch (IOException e) {

e.printStackTrace();

}

finally {

if (br != null) {

try {

br.close();

}

catch (IOException e) {

e.printStackTrace();

}

}

}

// If we arrive here it means there was an error

return null;

}

public static void printRequestHeaders(URLConnection connection) {

Map headers = connection.getHeaderFields();

System.out.println("Request Headers are:");

System.out.println(headers);

System.out.println();

}

public static void main(String[] args) {

// Change the URL to point to the echo_params.jsp page

// on your web server

String urlStr = "http://www.jdojo.com/docsapp/echo_params.jsp

String query = null;

try {

// Encode the query. We need to encode only the value

// of the name parameter. Other names and values are fine

query = "id=789&name=" +

URLEncoder.encode("John & Co.", "utf-8");

// Get the content and display it on the console

String content = getURLContent(urlStr, query);

System.out.println(content);

}

catch (UnsupportedEncodingException e) {

e.printStackTrace();

}

}

}

Request Headers are:

{null=[HTTP/1.1 200 OK], Date=[Fri, 19 Dec 2008 02:15:14 GMT], Content-Length=[402], Set-Cookie=[JSESSIONID=567B1B9F853DD22DD73AB8452E220E0A; Path=/examples], Content-Type=[text/html;charset=windows-1252], Server=[Apache-Coyote/1.1]}

<html>

<head>

<meta http-equiv="Content-Type" content="text/html;

charset=windows-1252"/>

<title>Echo Request Method and Parameters</title>

</head>

<body>

<h1>URL Connection Test</h1>

Request Method: POST

<br/><br/>

<u>List of Parameter Names and Values</u><br/>Name: name, Value: John & Co.

<br/>Name: id, Value: 789

<br/>

</body>

</html>

这一次,您将使用 POST 方法向 URL 发送数据。注意,您发送的数据已经使用URLEncoder类进行了编码。您只需要对 name 字段的值进行编码,即"John & Co.",因为值中的&符号将与查询字符串中的名称-值对分隔符冲突。如果你改变任何语句的顺序,程序有大量的注释来警告你任何危险。

该程序打印出一个java.util.Map对象中返回的所有标题的信息。该类提供了几种获取标头字段值的方法。对于常用的头,它提供了一个直接的方法。例如,名为getContentLength()getContentType()getContentEncoding()的方法返回头字段的值,分别表示 URL 内容的长度、类型和编码。如果您知道标题字段名或它的索引,您可以使用getHeaderField(String headerName)getHeaderField(int headerIndex)方法来获取它的值。getHeaderFields()方法返回一个Map对象,它的键代表标题字段名称,值代表标题字段值。读取标题字段时要小心,因为它对URLConnection对象的影响与读取内容相同。如果您希望将数据写入 URL,您必须先写入数据,然后才能读取标题字段。

Java 允许您使用jar协议读取 JAR 文件的内容。假设您有一个名为myclasses.jar的 JAR 文件,其中有一个路径为myfolder/Abc.class的类文件。您可以从 URL 获取一个JarURLConnection,并使用它的方法来访问 JAR 文件数据。请注意,您只能从 URL 读取 JAR 文件内容。您不能写入 JAR 文件 URL。下面的代码片段显示了如何获得一个JarURLConnection对象。您将需要使用它的方法来获取 JAR 特定的数据。

String str = "jar:http://www.abc.com/myclasses.jar!/myfolder/Abc.class

URL url = new URL(str);

JarURLConnection connection = (JarURLConnection)url.openConnection();

// Use the connection object to access any jar related data.

Tip

在本节中,你已经读到了许多关于使用URLConnection对象的警告。还有一点:一个URLConnection对象只能用于一个请求。它基于“获得-使用-丢弃”的概念。如果您希望多次从一个 URL 写入或读取数据,您必须每次单独调用 URL 的openConnection()

非阻塞套接字编程

在前面的章节中,我讨论了 TCP 和 UDP 套接字。SocketServerSocket类的connect()accept()read()write()方法阻塞,直到操作完成。例如,如果客户端套接字的线程调用read()方法从服务器读取数据,直到数据可用,它就会被阻塞。如果您可以在客户端套接字上调用read()方法并开始做其他事情,直到来自服务器的数据到达,这不是很好吗?当从服务器获得数据时,将通知客户机套接字,客户机套接字将在适当的时间读取数据。套接字编程面临的另一个大问题是服务器应用的可伸缩性。在前面的小节中,我建议您需要创建一个新的线程来处理每个客户端连接,或者您将有一个线程池来处理所有的客户端连接。无论哪种方式,你都将在你的程序中创建和维护一堆线程。如果您不必在一个服务器程序中处理线程来处理多个客户端,那不是很好吗?非阻塞套接字通道提供了所有这些好的特性。一如既往,一个好的特性有一个与之相关的价格标签;非阻塞套接字通道也是如此。这需要一点学习过程。你习惯于编程,事情按顺序发生。有了非阻塞的套接字通道,你将需要改变在程序中执行事情的思维方式。改变心态需要时间。你的程序将会执行多项不会按顺序执行的任务。如果您是第一次学习 Java,您可以跳过这一节,以后当您在编写复杂的 Java 程序方面获得更多经验时再来看它。

假设您已经很好地理解了使用ServerSocketSocket类的套接字编程。还假设您对使用缓冲区和通道的 Java 新输入/输出有基本的了解。本节使用了一些包含在java.niojava.nio.channelsjava.nio.charset包中的类。

让我们从比较阻塞和非阻塞套接字通信中涉及的类开始。表 5-6 列出了阻塞和非阻塞套接字应用中使用的主要类。

表 5-6。

Comparison of Classes Involved in Blocking and Non-Blocking Socket Programming

| 用于阻止基于套接字的通信的类 | 在基于非阻塞套接字的通信中使用的类 | | --- | --- | | `ServerSocket` | `ServerSocketChannel``ServerSocket`类依然存在于幕后。 | | `Socket` | `SocketChannel``Socket`类依然存在于幕后。 | | `InputStream` `OutputStream` | 不存在相应的类。`SocketChannel`用于读取/写入数据 | | 不存在相应的类。 | `Selector` | | 不存在相应的类。 | `SelectionKey` |

您将使用一个ServerSocketChannel对象主要是为了在服务器中接受一个新的连接请求,而不是使用一个ServerSocketServerSocket并没有消失。它仍在幕后运作。如果需要内部使用的ServerSocket对象的引用,可以通过使用ServerSocketChannel对象的socket()方法来获取。你可以把一个ServerSocketChannel对象看作是一个ServerSocket对象的包装器。

您将使用一个SocketChannel来代替一个Socket在客户端和服务器之间进行通信。一个Socket物体仍在幕后发挥作用。您可以使用SocketChannel类的socket()方法来获取Socket对象的引用。你可以把一个SocketChannel对象看作是一个Socket对象的包装器。

在我开始讨论非阻塞套接字为您提供更高效、更可伸缩的应用接口所使用的机制之前,看一个真实的例子会有所帮助。让我们讨论一下在快餐店点餐和上菜的方式。假设餐馆在任何时候都期望最多十个顾客,最少零个顾客。一位顾客来到餐厅,下了订单,然后就可以享用食物了。那家餐馆应该雇用多少服务员?在最好的情况下,它可能只使用一台服务器来处理接收来自所有客户的订单并为他们提供食物。在最坏的情况下,它可能有十台服务器—一台服务器留给一个客户。在后一种情况下,如果餐厅只有三个客户,那么将有七个服务器处于空闲状态。

让我们在餐馆管理中走中间道路。让我们在厨房有几个服务器做饭,在柜台有一个服务器接收订单。一位顾客来了,向柜台的服务员下了订单,顾客得到一个订单 id,顾客离开柜台,柜台的服务员将订单传递给厨房的一个服务员,服务员开始接受下一位顾客的订单。此时,顾客可以在准备订单的同时自由地做其他事情。柜台的服务员正在接待其他顾客。厨房里的服务员正忙着根据订单准备食物。没有人在等谁。一旦订单中的食品准备好,柜台的服务员就会从厨房的服务员那里收到食品,并呼叫订单号,这样下订单的顾客就可以取走他的食品。顾客可以分多次购买食物。当厨房正在准备他点的菜的时候,他可以吃已经端给他的食物。这种建筑是餐馆中最高效的建筑。它让每个人大部分时间都很忙,并有效地利用了资源。这是非阻塞套接字通道遵循的方法。

另一种方法是,顾客进来,下订单,等到他的订单完成并有人为他服务,然后下一个顾客下订单,以此类推。这是阻塞套接字遵循的方法。如果您了解快餐店为有效利用资源所采取的方法,您就可以很容易地理解非阻塞套接字通道。在下面的讨论中,我将比较餐馆示例中使用的人和非阻塞套接字中使用的对象。

我们先来讨论一下服务器端的情况。服务器端是你的餐厅。在柜台与所有顾客打交道的人被称为“挑选者”。选择器是Selector类的一个对象。它唯一的工作就是与外界互动。它位于与服务器交互的远程客户端和服务器内部的事物之间。远程客户机从不与在服务器内部工作的对象交互,就像餐馆里的顾客从不与厨房里的服务器直接交互一样。图 5-7 显示了非阻塞套接字通道通信的架构。它显示了选择器在体系结构中的位置。

A978-1-4302-6662-4_5_Fig7_HTML.jpg

图 5-7。

Architecture of Non-Blocking Client-Server Sockets

不能使用选择器对象的构造函数直接创建选择器对象。您需要调用它的open()静态方法来获得一个选择器对象,如下所示:

// Get a selector object

Selector selector = Selector.open();

ServerSocketChannel用于监听来自客户端的新连接请求。同样,您不能使用其构造函数创建新的ServerSocketChannel对象。你需要调用它的open()静态方法如图所示:

// Get a server socket channel

ServerSocketChannel ssChannel = ServerSocketChannel.open();

默认情况下,服务器套接字通道或套接字通道是阻塞通道。您需要对其进行配置,使其成为非阻塞通道,如下所示:

// Configure the server socket channel to be non-blocking

ssChannel.configureBlocking(false);

您的服务器套接字通道需要绑定到一个本地 IP 地址和一个本地端口号,以便远程客户端可以联系它以获得新的连接。您使用它的bind()方法绑定一个服务器套接字通道。Java 7 中的ServerSocketChannelSocketChannel中增加了bind()方法。在 Java 7 之前,您需要在与通道相关联的套接字上调用bind()方法。

InetAddress hostIPAddress = InetAddress.getByName("localhost");

int port = 19000;

// Prior to Java 7

ssChannel.socket().bind(new InetSocketAddress(hostIPAddress, port));

// Java 7 and later

ssChannel.bind(new InetSocketAddress(hostIPAddress, port));

现在迈出了最重要的一步。服务器套接字必须向选择器注册自己,以显示对某种操作的兴趣。这就像餐馆里的比萨饼师傅让柜台上的服务员知道他已经准备好为顾客做比萨饼了,他需要在下比萨饼的订单时得到通知。有四种操作可以在选择器中注册通道。它们被定义为表 5-7 中列出的SelectionKey类中的整数常量。

表 5-7。

The List of Operations Recognized by the Selector

| 操作类型 | 值(SelectionKey 类中的常数) | 谁可以注册这个操作 | 描述 | | --- | --- | --- | --- | | `Connect` | `OP_CONNECT` | `SocketChannel`在客户端 | 选择器将通知连接操作的进度。 | | `Accept` | `OP_ACCEPT` | 在服务器上 | 当客户端请求新连接时,选择器会发出通知 | | `Read` | `OP_READ` | `SocketChannel`在客户端和服务器端 | 当通道准备好读取某些数据时,选择器会发出通知。 | | `Write` | `OP_WRITE` | `SocketChannel`在客户端和服务器端 | 当通道准备好写入一些数据时,选择器将发出通知。 |

ServerSocketChannel只监听接受新的客户端连接请求,因此,它只能注册一个操作,如下所示:

// Register the server socket channel with the selector for accept operation

ssChannel.register(selector, SelectionKey.OP_ACCEPT);

ServerSocketChannelregister()方法返回一个类型为SelectionKey的对象。您可以将这个对象视为带有选择器的注册证书。如果以后需要使用这个 key 对象,可以将它存储在一个变量中。该示例忽略了这一点。选择器有您的密钥(注册细节)的副本,并且它将在将来使用它来通知您您的通道已经准备好的任何操作。

此时,您的选择器已经准备好拦截客户机连接的传入请求,并将其传递给服务器套接字通道。假设此时有一个客户端试图连接到服务器套接字通道。选择器和服务器套接字通道之间的交互是如何发生的?当选择器检测到它有一个已注册的键,并准备好进行操作时,它将该键(SelectionKey类的一个对象)放在一个称为就绪集的单独组中。一个java.util.Set对象代表一个就绪集合。您可以通过调用一个Selector对象的select()方法来确定处于就绪状态的键的数量。

// Get the key count in the ready set

int readyCount = selector.select();

一旦您在就绪集合中获得至少一个就绪密钥,您需要获得该密钥并查看详细信息,您可以从就绪集合中获得所有就绪密钥,如下所示:

// Get the set of ready keys

Set readySet = selector.selectedKeys();

请注意,您为一个或多个操作注册了一个密钥。您需要查看特定操作的关键细节。如果一个键准备好接受新的连接请求,它的isAcceptable()方法将返回true。如果一个键准备好进行连接操作,它的isConnectable()方法将返回true。如果一个键准备好进行读写操作,它的isReadable()isWritable()方法将返回true。您可能会发现有一种方法可以检查每种操作类型的准备情况。当您处理就绪集合时,您还需要从就绪集合中移除密钥。下面是在服务器应用中处理就绪集的一些典型代码。无限循环在服务器应用中很典型,因为一旦完成了当前的就绪集,就需要继续寻找下一个就绪集。

while(true) {

// Get count of keys in the ready set. If ready key count is

// greater than zero, process each key in the ready set.

}

以下代码片段显示了可用于处理就绪集中所有键的典型逻辑:

SelectionKey key = null;

Iterator iterator = readySet.iterator();

while (iterator.hasNext()) {

// Get the next ready selection key object

key = (SelectionKey)iterator.next();

// Remove the key from ready set

iterator.remove();

// Process the key according to the operation

if (key.isAcceptable()) {

// Process new connection

}

if (key.isReadable()) {

// Read from the channel

}

if (key.isWritable()) {

// Write to the channel

}

}

如何在服务器套接字通道上接受来自远程客户端的连接请求?逻辑类似于使用ServerSocket对象接受远程连接请求。一个SelectionKey对象引用了注册它的ServerSocketChannel。您可以使用channel()方法访问SelectionKey对象的ServerSocketChannel对象。您需要调用ServerSocketChannel对象上的accept()方法来接受新的连接请求。accept()方法返回SocketChannel类的一个对象,用于与远程客户端通信(读写)。您需要将新的SocketChannel对象配置为非阻塞套接字通道。您需要理解的最重要的一点是,新的SocketChannel对象必须向选择器注册自己的读、写或两种操作,以开始在连接通道上读/写数据。以下代码片段显示了接受远程连接请求的逻辑:

ServerSocketChannel ssChannel = (ServerSocketChannel)key.channel();

SocketChannel sChannel = (SocketChannel)ssChannel.accept();

sChannel.configureBlocking(false);

// Register only for read. Your message is small and you write it back

// to the client as soon as you read it.

sChannel.register(key.selector(), SelectionKey.OP_READ);

如果您希望向选择器注册套接字通道以进行读取和写入,您可以如下所示进行操作:

// Register for read and write

sChannel.register(key.selector(), SelectionKey.OP_READ | SelectionKey.OP_WRITE);

一旦您的套接字通道向选择器注册,当它从远程客户端接收到任何数据时,或者当您可以在其通道上向远程客户端写入数据时,它将通过选择器的就绪集得到通知。

如果数据在套接字通道上变得可用,key.isReadable()将为该套接字通道返回true。典型的读取操作如下所示。要使用通道和缓冲区读取数据,您必须对 Java NIO(新输入/输出)有基本的了解。

SocketChannel sChannel = (SocketChannel) key.channel();

ByteBuffer buffer = ByteBuffer.allocate(1024);

int bytesCount = sChannel.read(buffer);

String msg = "";

if (bytesCount > 0) {

buffer.flip();

Charset charset = Charset.forName("UTF-8");

CharsetDecoder decoder = charset.newDecoder();

CharBuffer charBuffer = decoder.decode(buffer);

msg = charBuffer.toString();

System.out.println("Received Message: " + msg);

}

如果您可以写入通道,选择器会将相关的键放入其就绪集合中,其isWritable()方法将返回true。同样,您需要理解 Java NIO 来使用ByteBuffer对象在通道上写数据。

SocketChannel sChannel = (SocketChannel)key.channel();

String msg = "message to be sent to remote client goes here";

ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());

sChannel.write(buffer);

客户端发生的事情很容易理解。首先获得一个选择器对象,然后通过调用SocketChannel.open()方法获得一个SocketChannel对象。此时,您需要在连接到服务器之前将套接字通道配置为非阻塞的。现在,您已经准备好向选择器注册您的套接字通道了。通常,您向选择器注册连接、读取和写入操作。处理选择器就绪集的方式与在服务器应用中处理选择器就绪集的方式相同。读取和写入通道的代码类似于服务器端代码。以下代码片段显示了客户端应用中使用的典型逻辑:

InetAddress serverIPAddress = InetAddress.getByName("localhost");

int port = 19000;

InetSocketAddress serverAddress = new InetSocketAddress(serverIPAddress, port);

// Get a selector

Selector selector = Selector.open();

// Create and configure a client socket channel

SocketChannel channel = SocketChannel.open();

channel.configureBlocking(false);

// Connect to the server

channel.connect(serverAddress);

// Register the channel for connect, read and write operations

int operations = SelectionKey.OP_CONNECT | SelectionKey.OP_READ | SelectionKey.OP_WRITE;

channel.register(selector, operations);

// Process the ready set of the selector here

当您在客户端SocketChannel获得一个连接操作时,这可能意味着连接成功或失败。您可以在SocketChannel对象上调用finishConnect()方法来完成连接过程。如果连接失败,finishConnect()调用将抛出一个IOException。通常,您可以按如下方式处理连接操作:

if (key.isConnectable()) {

try {

// Call to finishConnect() is in a loop as it is non-blocking

// for your channel

while(channel.isConnectionPending()) {

channel.finishConnect();

}

}

catch (IOException e) {

// Cancel the channel's registration with the selector

key.cancel();

e.printStackTrace();

}

}

是时候使用这些通道构建 echo 客户端应用和 echo 服务器应用了。清单 5-14 和清单 5-15 分别包含了 echo 服务器和 echo 客户端的非阻塞套接字通道的完整代码。

清单 5-14。一个非阻塞套接字通道回显服务器程序

// NonBlockingEchoServer.java

package com.jdojo.net;

import java.io.IOException;

import java.net.InetAddress;

import java.net.InetSocketAddress;

import java.nio.ByteBuffer;

import java.nio.CharBuffer;

import java.nio.channels.SelectionKey;

import java.nio.channels.Selector;

import java.nio.channels.ServerSocketChannel;

import java.nio.channels.SocketChannel;

import java.nio.charset.Charset;

import java.nio.charset.CharsetDecoder;

import java.util.Iterator;

import java.util.Set;

public class NonBlockingEchoServer {

public static void main(String[] args) throws Exception {

InetAddress hostIPAddress = InetAddress.getByName("localhost");

int port = 19000;

// Get a selector

Selector selector = Selector.open();

// Get a server socket channel

ServerSocketChannel ssChannel = ServerSocketChannel.open();

// Make the server socket channel non-blocking and bind it to an address

ssChannel.configureBlocking(false);

ssChannel.socket().bind(new InetSocketAddress(hostIPAddress, port));

// Register a socket server channel with the selector for accept operation,

// so that it can be notified when a new connection request arrives

ssChannel.register(selector, SelectionKey.OP_ACCEPT);

// Now we will keep waiting in a loop for any kind of request

// that arrives to the server - connection, read, or write

// request. If a connection request comes in, we will accept

// the request and register a new socket channel with the selector

// for read and write operations. If read or write requests come

// in, we will forward that request to the registered channel.

while (true) {

if (selector.select() <= 0) {

continue;

}

processReadySet(selector.selectedKeys());

}

}

public static void processReadySet(Set readySet) throws Exception {

SelectionKey key = null;

Iterator iterator = null;

iterator = readySet.iterator();

while (iterator.hasNext()) {

// Get the next ready selection key object

key = (SelectionKey) iterator.next();

// Remove the key from the ready key set

iterator.remove();

// Process the key according to the operation it is ready for

if (key.isAcceptable()) {

processAccept(key);

}

if (key.isReadable()) {

String msg = processRead(key);

if (msg.length() > 0) {

echoMsg(key, msg);

}

}

}

}

public static void processAccept(SelectionKey key) throws IOException {

// This method call indicates that we got a new connection

// request. Accept the connection request and register the new

// socket channel with the selector, so that client can

// communicate on a new channel

ServerSocketChannel ssChannel = (ServerSocketChannel)key.channel();

SocketChannel sChannel = (SocketChannel) ssChannel.accept();

sChannel.configureBlocking(false);

// Register only for read. Our message is small and we write it

// back to the client as soon as we read it

sChannel.register(key.selector(), SelectionKey.OP_READ);

}

public static String processRead(SelectionKey key) throws Exception {

SocketChannel sChannel = (SocketChannel) key.channel();

ByteBuffer buffer = ByteBuffer.allocate(1024);

int bytesCount = sChannel.read(buffer);

String msg = "";

if (bytesCount > 0) {

buffer.flip();

Charset charset = Charset.forName("UTF-8");

CharsetDecoder decoder = charset.newDecoder();

CharBuffer charBuffer = decoder.decode(buffer);

msg = charBuffer.toString();

System.out.println("Received Message: " + msg);

}

return msg;

}

public static void echoMsg(SelectionKey key, String msg) throws IOException {

SocketChannel sChannel = (SocketChannel) key.channel();

ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());

sChannel.write(buffer);

}

}

清单 5-15。一个非阻塞套接字通道回显客户端程序

// NonBlockingEchoClient.java

package com.jdojo.net;

import java.io.BufferedReader;

import java.io.IOException;

import java.io.InputStreamReader;

import java.net.InetAddress;

import java.net.InetSocketAddress;

import java.nio.ByteBuffer;

import java.nio.CharBuffer;

import java.nio.channels.SelectionKey;

import java.nio.channels.Selector;

import java.nio.channels.SocketChannel;

import java.nio.charset.Charset;

import java.nio.charset.CharsetDecoder;

import java.util.Iterator;

import java.util.Set;

public class NonBlockingEchoClient {

private static BufferedReader userInputReader = null;

public static void main(String[] args) throws Exception {

InetAddress serverIPAddress = InetAddress.getByName("localhost");

int port = 19000;

InetSocketAddress serverAddress = new InetSocketAddress(serverIPAddress, port);

// Get a selector

Selector selector = Selector.open();

// Create and configure a client socket channel

SocketChannel channel = SocketChannel.open();

channel.configureBlocking(false);

channel.connect(serverAddress);

// Register the channel for connect, read and write operations

int operations =

SelectionKey.OP_CONNECT | SelectionKey.OP_READ | SelectionKey.OP_WRITE;

channel.register(selector, operations);

userInputReader = new BufferedReader(new InputStreamReader(System.in));

while (true) {

if (selector.select() > 0) {

boolean doneStatus = processReadySet(selector.selectedKeys());

if (doneStatus) {

break;

}

}

}

channel.close();

}

public static boolean processReadySet(Set readySet) throws Exception {

SelectionKey key = null;

Iterator iterator = null;

iterator = readySet.iterator();

while (iterator.hasNext()) {

// Get the next ready selection key object

key = (SelectionKey) iterator.next();

// Remove the key from the ready key set

iterator.remove();

if (key.isConnectable()) {

boolean connected = processConnect(key);

if (!connected) {

return true; // Exit

}

}

if (key.isReadable()) {

String msg = processRead(key);

System.out.println("[Server]: " + msg);

}

if (key.isWritable()) {

String msg = getUserInput();

if (msg.equalsIgnoreCase("bye")) {

return true; // Exit

}

processWrite(key, msg);

}

}

return false; // Not done yet

}

public static boolean processConnect(SelectionKey key) {

SocketChannel channel = (SocketChannel) key.channel();

try {

// Call the finishConnect() in a loop as it is non-blocking

// for your channel

while (channel.isConnectionPending()) {

channel.finishConnect();

}

}

catch (IOException e) {

// Cancel the channel's registration with the selector

key.cancel();

e.printStackTrace();

return false;

}

return true;

}

public static String processRead(SelectionKey key) throws Exception {

SocketChannel sChannel = (SocketChannel) key.channel();

ByteBuffer buffer = ByteBuffer.allocate(1024);

sChannel.read(buffer);

buffer.flip();

Charset charset = Charset.forName("UTF-8");

CharsetDecoder decoder = charset.newDecoder();

CharBuffer charBuffer = decoder.decode(buffer);

String msg = charBuffer.toString();

return msg;

}

public static void processWrite(SelectionKey key, String msg) throws IOException {

SocketChannel sChannel = (SocketChannel) key.channel();

ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());

sChannel.write(buffer);

}

public static String getUserInput() throws IOException {

String promptMsg = "Please enter a message(Bye to quit): ";

System.out.print(promptMsg);

String userMsg = userInputReader.readLine();

return userMsg;

}

}

您需要首先运行NonBlockingEchoServer类,然后运行NonBlockingEchoClient类的一个或多个实例。它们的工作方式类似于另外两个 echo 客户机-服务器程序。请注意,这一次,在客户端应用中输入消息后,您可能看不到来自服务器的消息。客户端应用向服务器发送消息,并且不等待消息被回显。相反,当套接字通道收到来自选择器的通知时,它会处理服务器消息。因此,有可能同时从服务器获得两条消息的回显。为了保持代码的简单性和可读性,这些例子中省略了异常处理。

套接字安全权限

您可以使用java.net.SocketPermission类的实例来控制 Java 程序使用套接字的访问。用于在 Java 策略文件中授予套接字权限的通用格式如下:

grant {

permission java.net.SocketPermission "target", "actions";

};

目标的形式是<host name>:<port range>。动作的可能值是acceptconnectlistenresolve

只有当“localhost”用作主机名时,listen动作才有意义。resolve动作指的是 DNS 查找,如果其他三个动作中的任何一个存在,它就是隐含的。

主机名可以是 DNS 名称或 IP 地址。您可以在 DNS 主机名中使用星号(*)作为通配符。如果使用星号,它必须用作 DNS 名称中最左边的字符。如果主机名只包含一个星号,则表示任何主机。主机名的“localhost”指的是本地机器。如下所述,您可以用不同的格式指定主机名的端口范围。这里,N1 和 N2 表示端口号(0 到 65535),并假设 N1 小于 N2。表 5-8 列出了用于指示端口范围的格式。

表 5-8。

The Format for java.net.SocketPermission Security Settings

| 端口范围值 | 描述 | | --- | --- | | `N1` | 只有一个端口号——N1 | | `N1-N2` | 从 N1 到 N2 的端口号 | | `N1-` | N1 和更大地区的端口号 | | `-N1` | N1 及以下国家的端口号 |

以下是在 Java 策略文件中使用java.net.SocketPermission的例子:

// Grant to all codebase

grant {

// Permission to connect with 192.168.10.123 at port 5000

permission java.net.SocketPermission "192.168.10.123:5000", "connect";

// Connect permission to any host at port 80

permission java.net.SocketPermission "*:80", "connect";

// All socket permissions to on port >=1024 on the localhost

permission java.net.SocketPermission "localhost:1024-", "listen, accept, connect";

};

异步套接字通道

Java 7 增加了对异步套接字操作的支持,比如连接、读取和写入。异步套接字操作使用以下两个套接字通道类来执行:

  • java.nio.channels.AsynchronousServerSocketChannel
  • java.nio.channels.AsynchronousSocketChannel

一个AsynchronousServerSocketChannel作为一个服务器套接字来监听新的客户端连接。一旦它接受了一个新的客户机连接,客户机和服务器之间的交互就由两端的一个AsynchronousSocketChannel处理。异步套接字通道的设置与同步套接字非常相似。这两种设置的主要区别在于,异步套接字操作的请求会立即返回,并在操作完成时通知请求者,而在同步套接字操作中,套接字操作的请求会一直阻塞,直到操作完成。由于异步套接字通道操作的异步特性,处理套接字操作完成或失败的代码有点复杂。

在异步套接字通道中,使用异步套接字通道类的方法之一请求操作。该方法立即返回。稍后您会收到操作完成或失败的通知。允许您请求异步操作的方法被重载。一个版本返回一个Future对象,让您检查所请求操作的状态。有关使用Future对象的详细信息,请参考《Java 语言特性入门》( ISBN: 978-1-4302-6658-7)一书中的第六章。这些方法的另一个版本让你通过一个CompletionHandler。当请求的操作成功完成时,调用CompletionHandlercompleted()方法。当请求的操作失败时,调用CompletionHandlerfailed()方法。下面的代码片段演示了处理请求的异步套接字操作的完成/失败的两种方法。它展示了服务器套接字通道如何异步接受客户端连接。

/* Using a Future Object */

// Get a server socket channel instance

AsynchronousServerSocketChannel server = get a server instance...;

// Bind the socket to a host and a port

server.bind(your_host, your_port);

// Start accepting a new client connection. Note that the accept()

// method returns immediately by returning a Future object

Future<AsynchronousSocketChannel> result = server.accept();

// Wait for the new client connection by calling the get() method of

// the Future object. Alternatively, you can poll the Future object

// periodically using its isDone() method

AsynchronousSocketChannel newClient = result.get();

// Handle the newClient here and call the server.accept() again to accept

// another client connection

/* Using a CompletionHandler Object */

// Get a server socket channel instance

AsynchronousServerSocketChannel server = get a server instance...;

// Bind the socket to a host and a port

server.bind(your_host, your_port);

// Start accepting a new client connection. The accept() method returns

// immediately. The completed() or failed() method of the ConnectionHandler

// will be called upon completion or failure of the requested operation

YourAnyClass attach = ...; // Get an attachment

server.accept(attach, new ConnectionHandler());

上面版本的accept()方法接受任何类的对象作为附件。它可能是一个null参考。附件被传递给完成处理程序的completed()failed()方法,在本例中完成处理程序是ConnectionHandler的一个对象。ConnectionHandler类可能如下所示。

private static class ConnectionHandler implements CompletionHandler<AsynchronousSocketChannel, YourAnyClass> {

@Override

public void completed(AsynchronousSocketChannel client, YourAnyClass attach) {

// Handle the new client connection here and again start

// accepting a new client connection

}

@Override

public void failed(Throwable e, YourAnyClass attach) {

// Handle the failure here

}

}

在这一节中,我将详细介绍以下三个步骤。在讨论过程中,我将构建一个由 echo 服务器和客户机组成的应用。客户端将向服务器异步发送消息,服务器将消息异步回显给客户端。假设您熟悉使用缓冲区和通道。

  • 设置异步服务器套接字通道
  • 设置异步客户端套接字通道
  • 将异步服务器和客户端套接字通道投入使用

设置异步服务器套接字通道

AsynchronousServerSocketChannel类的一个实例被用作异步服务器套接字通道来监听新的客户端连接。一旦建立了到客户机的连接,就使用AsynchronousSocketChannel类的一个实例与客户机通信。AsynchronousServerSocketChannel类的静态open()方法返回一个AsynchronousServerSocketChannel类的对象,该对象尚未绑定。

// Create an asynchronous server socket channel object

AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open();

// Bind the server to the localhost and the port 8989

String host = "localhost";

int port = 8989;

InetSocketAddress sAddr = new InetSocketAddress(host, port);

server.bind(sAddr);

此时,您的服务器套接字通道可以通过调用它的accept()方法来接受新的客户端连接,如下所示。代码使用了两个类,AttachmentConnectionHandler,这将在后面描述。

// Prepare the attachment

Attachment attach = new Attachment();

attach.server = server;

// Accept new connections

server.accept(attach, new ConnectionHandler());

通常,服务器应用会无限期运行。您可以通过在main()方法中等待主线程来使服务器应用永远运行,如下所示:

try {

// Wait indefinitely until someone interrups the main thread

Thread.currentThread().join();

}

catch (InterruptedException e) {

e.printStackTrace();

}

您将使用完成处理程序机制来处理服务器套接字通道的完成/失败通知。下面这个类的对象将被用来作为完成处理程序的附件。附件对象用于传递服务器套接字的上下文,该服务器套接字可能在完成处理程序的completed()failed()方法中使用。

class Attachment {

AsynchronousServerSocketChannel server;

AsynchronousSocketChannel client;

ByteBuffer buffer;

SocketAddress clientAddr;

boolean isRead;

}

您需要一个实现来处理一个accept()调用的完成。让我们称您的类为ConnectionHandler,如图所示:

private static class ConnectionHandler implements CompletionHandler<AsynchronousSocketChannel, Attachment> {

@Override

public void completed(AsynchronousSocketChannel client, Attachment attach) {

try {

// Get the client address

SocketAddress clientAddr = client.getRemoteAddress();

System.out.format("Accepted a connection from %s%n", clientAddr);

// Accept another connection

attach.server.accept(attach, this);

// Handle the client connection by invoking an asyn read

Attachment newAttach = new Attachment();

newAttach.server = attach.server;

newAttach.client = client;

newAttach.buffer = ByteBuffer.allocate(2048);

newAttach.isRead = true;

newAttach.clientAddr = clientAddr;

// Create a new completion handler for reading to and writing

// from the new client

ReadWriteHandler readWriteHandler = new ReadWriteHandler();

// Read from the client

client.read(newAttach.buffer, newAttach, readWriteHandler);

}

catch (IOException e) {

e.printStackTrace();

}

}

@Override

public void failed(Throwable e, Attachment attach) {

System.out.println("Failed to accept a connection.");

e.printStackTrace();

}

}

ConnectionHandler类很简单。在其failed()方法中,它打印异常栈跟踪。在它的completed()方法中,它打印一个新的客户端连接已经建立的消息,并通过再次调用服务器套接字上的accept()方法开始监听另一个新的客户端连接。注意附件在另一个completed()方法内部的accept()方法调用中的重用。它再次使用同一个CompletionHandler对象。注意,attach.server.accept(attach, this)方法调用使用关键字this来引用完成处理程序的同一个实例。最后,它准备了一个Attachment类的新实例,该实例包装了处理(读取和写入)新客户端连接的细节,并在客户端套接字上调用read()方法来从客户端读取。注意,read()方法使用了另一个完成处理程序,它是该类的一个实例。ReadWriteHandler的代码如下:

private static class ReadWriteHandler implements CompletionHandler<Integer, Attachment> {

@Override

public void completed(Integer result, Attachment attach) {

if (result == -1) {

try {

attach.client.close();

System.out.format("Stopped listening to the client %s%n",

attach.clientAddr);

}

catch (IOException ex) {

ex.printStackTrace();

}

return;

}

if (attach.isRead) {

// A read to the client was completed

// Get the buffer ready to read from it

attach.buffer.flip();

int limits = attach.buffer.limit();

byte bytes[] = new byte[limits];

attach.buffer.get(bytes, 0, limits);

Charset cs = Charset.forName("UTF-8");

String msg = new String(bytes, cs);

// Print the message from the client

System.out.format("Client at %s says: %s%n", attach.clientAddr, msg);

// Let us echo back the same message to the client

attach.isRead = false; // It is a write

// Prepare the buffer to be read again

attach.buffer.rewind();

// Write to the client again

attach.client.write(attach.buffer, attach, this);

}

else {

// A write to the client was completed.

// Perform another read from the client

attach.isRead = true;

// Prepare the buffer to be filled in

attach.buffer.clear();

// Perform a read from the client

attach.client.read(attach.buffer, attach, this);

}

}

@Override

public void failed(Throwable e, Attachment attach) {

e.printStackTrace();

}

}

completed()方法的第一个参数result是从客户端读取或写入客户端的字节数。它的值-1 表示流结束,在这种情况下,客户端套接字关闭。如果读操作完成,它会在标准输出上显示读取的文本,并将相同的文本写回客户端。如果对客户端的写操作已完成,它会在同一客户端上执行读操作。

清单 5-16 包含了异步服务器套接字通道的完整代码。它使用三个内部类:一个用于附件,一个用于连接完成处理程序,一个用于读/写完成处理程序。现在可以运行该类了。但是,它不会做任何工作,因为它需要一个客户端连接到它,以回显从客户端发送的消息。在下一节中,您将开发您的异步客户机套接字通道,然后,在下一节中,您将一起测试服务器和客户机套接字通道。

清单 5-16。使用异步服务器套接字通道的服务器应用

// AsyncEchoServerSocket.java

package com.jdojo.net;

import java.io.IOException;

import java.net.SocketAddress;

import java.nio.ByteBuffer;

import java.nio.charset.Charset;

import java.net.InetSocketAddress;

import java.nio.channels.CompletionHandler;

import java.nio.channels.AsynchronousSocketChannel;

import java.nio.channels.AsynchronousServerSocketChannel;

public class AsyncEchoServerSocket {

private static class Attachment {

AsynchronousServerSocketChannel server;

AsynchronousSocketChannel client;

ByteBuffer buffer;

SocketAddress clientAddr;

boolean isRead;

}

private static class ConnectionHandler implements

CompletionHandler<AsynchronousSocketChannel, Attachment> {

@Override

public void completed(AsynchronousSocketChannel client, Attachment attach) {

try {

// Get the client address

SocketAddress clientAddr = client.getRemoteAddress();

System.out.format("Accepted a connection from %s%n",

clientAddr);

// Accept another connection

attach.server.accept(attach, this);

// Handle the client connection by using an asyn read

ReadWriteHandler rwHandler = new ReadWriteHandler();

Attachment newAttach = new Attachment();

newAttach.server = attach.server;

newAttach.client = client;

newAttach.buffer = ByteBuffer.allocate(2048);

newAttach.isRead = true;

newAttach.clientAddr = clientAddr;

client.read(newAttach.buffer, newAttach, rwHandler);

}

catch (IOException e) {

e.printStackTrace();

}

}

@Override

public void failed(Throwable e, Attachment attach) {

System.out.println("Failed to accept a connection.");

e.printStackTrace();

}

}

private static class ReadWriteHandler

implements CompletionHandler<Integer, Attachment> {

@Override

public void completed(Integer result, Attachment attach) {

if (result == -1) {

try {

attach.client.close();

System.out.format(

"Stopped listening to the client %s%n",

attach.clientAddr);

}

catch (IOException ex) {

ex.printStackTrace();

}

return;

}

if (attach.isRead) {

// A read to the client was completed

// Get the buffer ready to read from it

attach.buffer.flip();

int limits = attach.buffer.limit();

byte bytes[] = new byte[limits];

attach.buffer.get(bytes, 0, limits);

Charset cs = Charset.forName("UTF-8");

String msg = new String(bytes, cs);

// Print the message from the client

System.out.format("Client at %s says: %s%n",

attach.clientAddr, msg);

// Let us echo back the same message to the client

attach.isRead = false; // It is a write

// Prepare the buffer to be read again

attach.buffer.rewind();

// Write to the client

attach.client.write(attach.buffer, attach, this);

}

else {

// A write to the client was completed. Perform

// another read from the client

attach.isRead = true;

// Prepare the buffer to be filled in

attach.buffer.clear();

// Perform a read from the client

attach.client.read(attach.buffer, attach, this);

}

}

@Override

public void failed(Throwable e, Attachment attach) {

e.printStackTrace();

}

}

public static void main(String[] args) {

try (AsynchronousServerSocketChannel server =

AsynchronousServerSocketChannel.open()) {

// Bind the server to the localhost and the port 8989

String host = "localhost";

int port = 8989;

InetSocketAddress sAddr =

new InetSocketAddress(host, port);

server.bind(sAddr);

// Display a message that server is ready

System.out.format("Server is listening at %s%n", sAddr);

// Prepare the attachment

Attachment attach = new Attachment();

attach.server = server;

// Accept new connections

server.accept(attach, new ConnectionHandler());

try {

// Wait until the main thread is interrupted

Thread.currentThread().join();

}

catch (InterruptedException e) {

e.printStackTrace();

}

}

catch (IOException e) {

e.printStackTrace();

}

}

}

设置异步客户端套接字通道

在客户端应用中,AsynchronousSocketChannel类的一个实例被用作异步客户端套接字通道。AsynchronousSocketChannel类的静态open()方法返回一个AsynchronousSocketChannel类型的开放通道,该通道尚未连接到服务器套接字通道。通道的connect()方法用于连接到服务器套接字通道。下面的代码片段显示了如何创建异步客户端套接字通道并将其连接到服务器套接字通道。它使用一个Future对象来完成与服务器的连接。

// Create an asynchronous socket channel

AsynchronousSocketChannel channel = AsynchronousSocketChannel.open();

// Connect the channel to the server

String serverName = "localhost";

int serverPort = 8989;

SocketAddress serverAddr = new InetSocketAddress(serverName, serverPort);

Future<Void> result = channel.connect(serverAddr);

System.out.println("Connecting to the server...");

// Wait for the connection to complete

result.get();

// Connection to the server is complete now

System.out.println("Connected to the server...");

一旦客户端套接字通道连接到服务器,您就可以开始使用通道的read()write()方法异步地从服务器读取数据和向服务器写入数据。这两种方法都允许您使用一个Future对象或一个CompletionHandler对象来处理操作的完成。您将使用如图所示的Attachment类将上下文传递给完成处理程序:

class Attachment {

AsynchronousSocketChannel channel;

ByteBuffer buffer;

Thread mainThread;

boolean isRead;

}

在该类中,channel实例变量保存对客户端通道的引用。buffer实例变量保存对数据缓冲区的引用。您将使用相同的数据缓冲区进行读取和写入。mainThread实例变量保存对应用主线程的引用。当客户端通道完成时,您可以中断正在等待的主线程,这样客户端应用就终止了。isRead实例变量指示操作是读还是写。如果是true,则表示是读操作。否则,它是一个写操作。

清单 5-17 包含了异步客户端套接字通道的完整代码。它使用了两个名为Attachment和的内部类。Attachment类的一个实例被用作read()write()异步操作的附件。一个ReadWriteHandler类的实例被用作read()write()操作的完成处理器。它的getTextFromUser()方法提示用户在标准输入上输入消息,并返回用户输入的消息。完成处理程序的completed()方法检查它是读操作还是写操作。如果是读取操作,它会在标准输出中打印从服务器读取的文本。它会提示用户输入另一条消息。如果用户输入Bye,它通过中断正在等待的主线程来终止应用。注意,当程序退出try程序块时,通道自动关闭,因为它是在main()方法的try-with-resources程序块中打开的。

清单 5-17。异步客户端套接字通道

// AsyncEchoClientSocket.java

package com.jdojo.net;

import java.io.BufferedReader;

import java.io.IOException;

import java.io.InputStreamReader;

import java.net.InetSocketAddress;

import java.net.SocketAddress;

import java.nio.ByteBuffer;

import java.nio.charset.Charset;

import java.util.concurrent.Future;

import java.nio.channels.CompletionHandler;

import java.util.concurrent.ExecutionException;

import java.nio.channels.AsynchronousSocketChannel;

public class AsyncEchoClientSocket {

private static class Attachment {

AsynchronousSocketChannel channel;

ByteBuffer buffer;

Thread mainThread;

boolean isRead;

}

private static class ReadWriteHandler

implements CompletionHandler<Integer, Attachment> {

@Override

public void completed(Integer result, Attachment attach) {

if (attach.isRead) {

attach.buffer.flip();

// Get the text read from the server

Charset cs = Charset.forName("UTF-8");

int limits = attach.buffer.limit();

byte bytes[] = new byte[limits];

attach.buffer.get(bytes, 0, limits);

String msg = new String(bytes, cs);

// A read from the server was completed

System.out.format("Server Responded: %s%n", msg);

// Prompt the user for another message

msg = this.getTextFromUser();

if (msg.equalsIgnoreCase("bye")) {

// Interrupt the main thread, so the program terminates

attach.mainThread.interrupt();

return;

}

// Prepare buffer to be filled in again

attach.buffer.clear();

byte[] data = msg.getBytes(cs);

attach.buffer.put(data);

// Prepared buffer to be read

attach.buffer.flip();

attach.isRead = false; // It is a write

// Write to the server

attach.channel.write(attach.buffer, attach, this);

}

else {

// A write to the server was completed. Perform another

// read from the server

attach.isRead = true;

// Prepare the buffer to be filled in

attach.buffer.clear();

// Read from the server

attach.channel.read(attach.buffer, attach, this);

}

}

@Override

public void failed(Throwable e, Attachment attach) {

e.printStackTrace();

}

private String getTextFromUser() {

System.out.print("Please enter a message (Bye to quit):");

String msg = null;

BufferedReader consoleReader =

new BufferedReader(new InputStreamReader(System.in));

try {

msg = consoleReader.readLine();

}

catch (IOException e) {

e.printStackTrace();

}

return msg;

}

}

public static void main(String[] args) {

// Use a try-with-resources to open a channel

try (AsynchronousSocketChannel channel

= AsynchronousSocketChannel.open()) {

// Connect the client to the server

String serverName = "localhost";

int serverPort = 8989;

SocketAddress serverAddr =

new InetSocketAddress(serverName, serverPort);

Future<Void> result = channel.connect(serverAddr);

System.out.println("Connecting to the server...");

// Wait for the connection to complete

result.get();

// Connection to the server is complete now

System.out.println("Connected to the server...");

// Start reading from and writing to the server

Attachment attach = new Attachment();

attach.channel = channel;

attach.buffer = ByteBuffer.allocate(2048);

attach.isRead = false;

attach.mainThread = Thread.currentThread();

// Place the "Hello" message in the buffer

Charset cs = Charset.forName("UTF-8");

String msg = "Hello";

byte[] data = msg.getBytes(cs);

attach.buffer.put(data);

attach.buffer.flip();

// Write to the server

ReadWriteHandler readWriteHandler = new ReadWriteHandler();

channel.write(attach.buffer, attach, readWriteHandler) ;

// Let this thread wait for ever on its own death until interrupted

attach.mainThread.join();

}

catch (ExecutionException | IOException e) {

e.printStackTrace();

}

catch(InterruptedException e) {

System.out.println("Disconnected from the server.");

}

}

}

将服务器和客户端放在一起

此时,您的异步服务器和客户端程序已经准备好了。您需要使用以下步骤来运行服务器和客户端。

运行服务器应用

运行清单 5-16 中列出的AsyncEchoServerSocket类。您应该会在标准输出中得到如下消息:

Server is listening at localhost/127.0.0.1:8989

如果您得到上述消息,您需要进行下一步。如果您没有收到上述消息,很可能是端口 8989 正在被另一个进程使用。在这种情况下,您应该会收到以下错误消息:

java.net.BindException: Address already in use: bind

如果您得到“地址已被使用”的错误消息,您需要将AsyncEchoServerSocket类中的端口值从 8989 更改为其他值,然后重新运行AsyncEchoServerSocket类。如果在服务器程序中更改端口号,还必须在客户端程序中更改端口号以匹配服务器端口号。服务器套接字通道监听一个端口,客户端必须连接到服务器正在监听的同一个端口。

运行客户端应用

在继续此步骤之前,请确保您能够成功执行上一步。运行清单 5-17 中列出的AsyncEchoClientSocket类的一个或多个实例。如果客户端应用能够成功连接到服务器,您应该会在标准输出中看到以下消息:

Connecting to the server...

Connected to the server...

Server Responded: Hello

Please enter a message (Bye to quit):

当您尝试运行AsyncEchoClientSocket类时,可能会收到以下错误消息:

Connecting to the server...

java.util.concurrent.ExecutionException: java.io.IOException: The remote system refused the network connection.

通常,此错误消息指示以下问题之一:

  • 服务器没有运行。如果是这种情况,请确保服务器正在运行。
  • 客户端正试图连接到与服务器侦听的主机和端口不同的主机和端口上的服务器。如果是这种情况,请确保服务器和客户端使用相同的主机名(或 IP 地址)和端口号。

您需要手动停止服务器程序,例如在 Windows 的命令提示符下按下Ctrl + C键。

面向数据报的套接字通道

java.nio.channels.DatagramChannel类的一个实例代表一个数据报通道。默认情况下,它是阻止的。您可以使用configureBlocking(false)方法将其配置为非阻塞的。

要创建一个DatagramChannel,您需要调用它的一个open()静态方法。如果您想将其用于 IP 多播,您需要指定多播组的地址类型(或协议族)作为其open()方法的参数。open()方法创建了一个DatagramChannel对象,这个对象没有被连接。如果您希望您的数据报通道只向特定的远程主机发送和接收数据报,您需要使用它的connect()方法将通道连接到那个特定的主机。未连接的数据报通道可以向任何远程主机发送数据报,也可以从任何远程主机接收数据报。以下部分概述了使用数据报通道发送/接收数据报通常需要的步骤。

创建数据报通道

您可以使用DatagramChannel类的open()方法创建数据报通道。以下代码片段显示了创建数据报通道的三种不同方式:

// Create a new datagram channel to send/receive datagram

DatagramChannel channel = DatagramChannel.open();

// Create a datagram channel to receive datagrams from a multicast group

// that uses IPv4 address type

DatagramChannel ipv4MulticastChannel = DatagramChannel.open(StandardProtocolFamily.INET);

// Create a datagram channel to receive datagrams from a multicast group

// that uses IPv6 address type

DatagramChannel iPv6MulticastChannel = DatagramChannel.open(StandardProtocolFamily.INET6);

设置频道选项

您可以使用DatagramChannel类的setOption()方法设置通道选项。有些选项必须在将通道绑定到特定地址之前设置,而有些可以在绑定之后设置。在 Java 7 中,setOption()方法被添加到了DatagramChannel类中。如果您使用的是以前的 Java 版本,您将需要使用socket()方法来获取DatagramSocket引用,并使用DatagramSocket类的方法之一来设置通道选项。下面的代码片段显示了如何设置通道选项。表 5-9 包含插座选项列表及其描述。套接字选项在StandardSocketOptions类中被定义为常量。

// To bind multiple sockets to the same socket address,

// you need to set the SO_REUSEADDR option for the socket

// In Java 7 and later

channel.setOption(StandardSocketOptions.SO_REUSEADDR, true)

// Prior to Java 7

DatagramSocket socket = channel.socket();

socket.setReuseAddress(true);

表 5-9。

The List of Standard Socket Options

| 套接字选项名称 | 描述 | | --- | --- | | `SO_SNDBUF` | 套接字发送缓冲区的大小,以字节为单位。它的值属于`Integer`类型。 | | `SO_RCVBUF` | 套接字接收缓冲区的大小,以字节为单位。它的值属于`Integer`类型。 | | `SO_REUSEADDR` | 对于数据报套接字,它允许多个程序绑定到同一个地址。它的值属于`Boolean`类型。应该为使用数据报通道的 IP 多播启用此选项。 | | `SO_BROADCAST` | 允许传输广播数据报。它的值属于类型`Boolean`。 | | `IP_TOS` | 互联网协议(IP)报头中的服务类型(ToS)八位字节。它的值属于`Integer`类型。 | | `IP_MULTICAST_IF` | 网际协议(IP)多播数据报的网络接口。其值是一个`NetworkInterface`类型的引用。 | | `IP_MULTICAST_TTL` | 网际协议(IP)多播数据报的生存时间。其值属于类型`Integer`,范围为 0 到 255。 | | `IP_MULTICAST_LOOP` | 网际协议(IP)多播数据报的环回。它的值属于类型`Boolean`。 |

绑定数据报通道

使用DatagramChannel类的bind()方法将数据报通道绑定到特定的本地地址和端口。如果使用null作为绑定地址,这个方法会自动将套接字绑定到一个可用的地址。Java 7 中的DatagramChannel类中添加了bind()方法。如果您使用的是以前的 Java 版本,您可以使用其底层套接字绑定数据报通道。以下代码片段显示了如何绑定数据报通道:

/* In Java 7 and later */

// Bind the channel to any available address automatically

channel.bind(null);

// Bind the channel to "localhost" and port 8989

InetSocketAddress sAddr = new InetSocketAddress("localhost", 8989);

channel.bind(sAddr);

/* Prior to Java 7 */

// Get the socket reference

DatagramSocket socket = channel.socket();

// Bind the channel to any available address automatically

socket.bind(null);

// Bind the channel to "localhost" and port 8989

InetSocketAddress sAddr = new InetSocketAddress("localhost", 8989);

socket.bind(sAddr);

发送数据报

要向远程主机发送数据报,请使用DatagramChannel类的send()方法。该方法接受一个ByteBuffer和一个远程SocketAddress。如果在未绑定的数据报通道上调用send()方法,send()方法会自动将通道绑定到一个可用的地址。

// Prepare a message to send

String msg = "Hello";

ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());

// Pack the remote address and port into an object

InetSocketAddress serverAddress = new InetSocketAddress("localhost", 8989);

// Send the message to the remote host

channel.send(buffer, serverAddress);

DatagramChannel类的receive()方法让数据报通道从远程主机接收数据报。这种方法要求你提供一个ByteBuffer来接收数据。接收到的数据被复制到指定的ByteBuffer的当前位置。如果ByteBuffer的可用空间少于接收到的数据,多余的数据将被自动丢弃。receive()方法返回远程主机的地址。如果数据报通道处于非阻塞模式,receive()方法通过返回null立即返回。否则,它会一直等待,直到收到数据报。

// Prepare a ByteBufer to receive data

ByteBuffer buffer = ByteBuffer.allocate(1024);

// Wait to receive data from a remote host

SocketAddress remoteAddress = channel.receive(buffer);

关闭频道

最后,使用其方法关闭数据报通道。

// Close the channel

channel.close();

清单 5-18 包含了一个作为回应服务器的程序。清单 5-19 有一个程序作为客户端。echo 服务器等待来自远程客户端的消息。它回显从远程客户端接收的消息。在启动客户端程序之前,您需要启动 echo 服务器程序。您可以同时运行多个客户端程序。

清单 5-18。基于数据报通道的 Echo 服务器

// DGCEchoServer.java

package com.jdojo.net;

import java.io.IOException;

import java.net.InetSocketAddress;

import java.net.SocketAddress;

import java.nio.ByteBuffer;

import java.nio.channels.DatagramChannel;

public class DGCEchoServer {

public static void main(String[] args) {

DatagramChannel server = null;

try {

// Create a datagram channel and bind it to localhost at port 8989

server = DatagramChannel.open();

InetSocketAddress sAddr = new InetSocketAddress("localhost", 8989);

server.bind(sAddr);

ByteBuffer buffer = ByteBuffer.allocate(1024);

// Wait in an infinite loop for a client to send data

while (true) {

System.out.println("Waiting for a message from" +

" a remote host at " + sAddr);

// Wait for a client to send a message

SocketAddress remoteAddr = server.receive(buffer);

// Prepare the buffer to read the message

buffer.flip();

// Convert the buffer data into a String

int limits = buffer.limit();

byte bytes[] = new byte[limits];

buffer.get(bytes, 0, limits);

String msg = new String(bytes);

System.out.println("Client at " + remoteAddr +

" says: " + msg);

// Reuse the buffer to echo the message to the client

buffer.rewind();

// Send the message back to the client

server.send(buffer, remoteAddr);

// Prepare the buffer to receive the next message

buffer.clear();

}

}

catch (IOException e) {

e.printStackTrace();

}

finally {

// Close the channel

if (server != null) {

try {

server.close();

}

catch (IOException e) {

e.printStackTrace();

}

}

}

}

}

清单 5-19。基于数据报通道的客户端程序

// DGCEchoClient.java

package com.jdojo.net;

import java.io.IOException;

import java.net.InetSocketAddress;

import java.nio.ByteBuffer;

import java.nio.channels.DatagramChannel;

public class DGCEchoClient {

public static void main(String[] args) {

DatagramChannel client = null;

try {

// Create a new datagram channel

client = DatagramChannel.open();

// Bind the client to any available local address and port

client.bind(null);

// Prepare a message for the server

String msg = "Hello";

ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());

InetSocketAddress serverAddress =

new InetSocketAddress("localhost", 8989);

// Send the message to the server

client.send(buffer, serverAddress);

// Reuse the buffer to receive a response from the server

buffer.clear();

// Wait for the server to respond

client.receive(buffer);

// Prepare the buffer to read the message

buffer.flip();

// Convert the buffer into a string

int limits = buffer.limit();

byte bytes[] = new byte[limits];

buffer.get(bytes, 0, limits);

String response = new String(bytes);

// Print the server message on the standard output

System.out.println("Server responded: " + response);

}

catch (IOException e) {

e.printStackTrace();

}

finally {

// Close the channel

if (client != null) {

try {

client.close();

}

catch (IOException e) {

e.printStackTrace();

}

}

}

}

}

使用数据报信道的多播

Java 7 为数据报通道增加了对 IP 多播的支持。对接收多播数据报感兴趣的数据报通道加入多播组。发送到多播组的数据报被传递给其所有成员。以下部分概述了设置对接收多播数据报感兴趣的客户端应用通常需要的步骤。

创建数据报通道

创建数据报通道以使用特定的多播地址类型,如下所示。在您的应用中,您将使用 IPv4 或 IPv6,而不是两者都使用。

// Need to use INET protocol family for an IPv4 addressing scheme

DatagramChannel client = DatagramChannel.open(StandardProtocolFamily.INET);

// Need to use INET6 protocol family for an IPv6 addressing scheme

DatagramChannel client = DatagramChannel.open(StandardProtocolFamily.INET6);

设置频道选项

使用setOption()方法设置客户端通道的选项,如下所示:

// Let other sockets reuse the same address

client.setOption(StandardSocketOptions.SO_REUSEADDR, true);

绑定频道

将客户端通道绑定到本地地址和端口,如下所示:

int MULTICAST_PORT = 8989;

client.bind(new InetSocketAddress(MULTICAST_PORT));

设置多播网络接口

设置套接字选项IP_MULTICAST_IF,指定客户端通道将加入多播组的网络接口。

// Get the reference of a network interface named "eth1"

NetworkInterface interf = NetworkInterface.getByName("eth1");

// Set the IP_MULTICAST_IF option

client.setOption(StandardSocketOptions.IP_MULTICAST_IF, interf);

清单 5-20 包含了一个完整的程序,它打印出你的机器上所有可用的网络接口的名称。它还打印网络接口是否支持多播以及它是否启动。在您的机器上运行代码时,您可能会得到不同的输出。您需要使用一个支持多播的可用网络接口的名称,并且该网络接口应该处于运行状态。例如,如输出所示,在我的机器上,名为eth1的网络接口启动并支持多播,所以我使用eth1作为处理多播消息的网络接口。

清单 5-20。列出机器上可用的网络接口

// ListNetworkInterfaces.java

package com.jdojo.net;

import java.net.NetworkInterface;

import java.net.SocketException;

import java.util.Enumeration;

public class ListNetworkInterfaces {

public static void main(String[] args) {

try {

Enumeration<NetworkInterface> e =

NetworkInterface.getNetworkInterfaces();

while (e.hasMoreElements()) {

NetworkInterface nif = e.nextElement();

System.out.println("Name: " + nif.getName() +

", Supports Multicast: " + nif.supportsMulticast() +

", isUp(): " + nif.isUp()) ;

}

}

catch (SocketException ex) {

ex.printStackTrace();

}

}

}

Name: lo,    Supports  Multicast:  true,  isUp(): true

Name: eth0,  Supports  Multicast:  true,  isUp(): false

Name: wlan0,  Supports  Multicast:  true,  isUp(): false

Name: eth1,  Supports  Multicast:  true,  isUp(): true

Name: net0,  Supports  Multicast:  false, isUp(): false

Name: net1,  Supports  Multicast:  false, isUp(): true

Name: wlan1,  Supports  Multicast:  true,  isUp(): false

Name: eth2,  Supports  Multicast:  true,  isUp(): false

Name: eth3,  Supports  Multicast:  true,  isUp(): false

Name: eth4,  Supports  Multicast:  true,  isUp(): false

Name: wlan2,  Supports  Multicast:  true,  isUp(): false

Name: wlan3,  Supports  Multicast:  true,  isUp(): false

Name: wlan4,  Supports  Multicast:  true,  isUp(): false

Name: wlan5,  Supports  Multicast:  true,  isUp(): false

Name: wlan6,  Supports  Multicast:  true,  isUp(): false

Name: wlan7,  Supports  Multicast:  true,  isUp(): false

Name: wlan8,  Supports  Multicast:  true,  isUp(): false

Name: wlan9,  Supports  Multicast:  true,  isUp(): false

Name: wlan10, Supports  Multicast:  true,  isUp(): false

加入多播组

现在是使用如下的join()方法加入多播组的时候了。请注意,您必须为该组使用多播 IP 地址。

String MULTICAST_IP = "239.1.1.1";

// Join the multicast group on interf interface

InetAddress group = InetAddress.getByName(MULTICAST_IP);

MembershipKey key = client.join(group, interf);

join()方法返回一个MembershipKey类的对象,表示数据报通道与多播组的成员关系。如果数据报通道不再对接收多播数据报感兴趣,它可以使用keydrop()方法从多播组中删除其成员。

Tip

数据报信道可以决定只从选择的源接收多播数据报。您可以使用MembershipKey类的block(InetAddress source)方法来阻止来自指定的source地址的多播数据报。它的unblock(InetAddress source)让你解锁一个先前被封锁的源地址。

接收消息

此时,接收寻址到多播组的数据报只需调用通道上的receive()方法,如下所示:

// Prepare a buffer to receive the message from the multicast group

ByteBuffer buffer = ByteBuffer.allocate(1048);

// Wait to receive a message from the multicast group

client.receive(buffer);

使用完该频道后,您可以从组中删除其成员,如下所示:

// We are no longer interested in receiving multicast message from the group.

// So, we need to drop the channel's membership from the group

key.drop();

关闭频道

最后,您需要使用它的close()方法关闭通道,如下所示:

// Close the channel

client.close();

要向多播组发送消息,您不需要成为该多播组的成员。您可以使用DatagramChannel类的send()方法向多播组发送数据报。

清单 5-21 包含一个有三个常量的类,这三个常量在后面的两个类中用来构建多播应用。这些常量包含多播 IP 地址、多播端口号和多播网络接口名称,将在后续示例中使用。请确保MULTICAST_INTERFACE_NAME常量的值eth1是您的机器上支持多播的网络接口名称,并且它是打开的。通过运行清单 5-20 中的程序,你可以得到你的机器上所有网络接口的列表。

清单 5-21。一个基于 DatagramChannel 的组播客户端程序

// DGCMulticastUtil.java

package com.jdojo.net;

public class DGCMulticastUtil {

public static final String MULTICAST_IP = "239.1.1.1";

public static final int MULTICAST_PORT = 8989;

/* You need to change the following network interface name "eth1"

to the network interface name that supports multicast and is up

on your machine. Please run class ListNetworkInterfaces to get

the list of all available network interface on your machine.

*/

public static final String MULTICAST_INTERFACE_NAME = "eth1";

}

清单 5-22 包含了一个加入多播组的程序。它等待来自多播组的消息到达,打印该消息,然后退出。清单 5-23 包含一个向多播组发送消息的程序。您可以运行DGCMulticastClient类的多个实例,然后运行DGCMulticastServer类。所有客户端实例都应该在标准输出中接收和打印相同的消息。

清单 5-22。一个基于 DatagramChannel 的组播客户端程序

// DGCMulticastClient.java

package com.jdojo.net;

import java.io.IOException;

import java.net.InetAddress;

import java.net.InetSocketAddress;

import java.net.NetworkInterface;

import java.net.StandardProtocolFamily;

import java.net.StandardSocketOptions;

import java.nio.ByteBuffer;

import java.nio.channels.DatagramChannel;

import java.nio.channels.MembershipKey;

public class DGCMulticastClient {

public static void main(String[] args) {

MembershipKey key = null;

// Create, configure and bind the client datagram channel

try (DatagramChannel client =

DatagramChannel.open(StandardProtocolFamily.INET)) {

// Get the reference of a network interface

NetworkInterface interf = NetworkInterface.getByName(

DGCMulticastUtil.MULTICAST_INTERFACE_NAME);

client.setOption(StandardSocketOptions.SO_REUSEADDR, true);

client.bind(new InetSocketAddress(DGCMulticastUtil.MULTICAST_PORT));

client.setOption(StandardSocketOptions.IP_MULTICAST_IF, interf);

// Join the multicast group on the interf interface

InetAddress group =

InetAddress.getByName(DGCMulticastUtil.MULTICAST_IP);

key = client.join(group, interf);

// Print some useful messages for the user

System.out.println("Joined the multicast group:" + key);

System.out.println("Waiting for a message from the" +

" multicast group....");

// Prepare a data buffer to receive a message from the multicast group

ByteBuffer buffer = ByteBuffer.allocate(1048);

// Wait to receive a message from the multicast group

client.receive(buffer);

// Convert the message in the ByteBuffer into a string

buffer.flip();

int limits = buffer.limit();

byte bytes[] = new byte[limits];

buffer.get(bytes, 0, limits);

String msg = new String(bytes);

System.out.format("Multicast Message:%s%n", msg);

}

catch (IOException e) {

e.printStackTrace();

}

finally {

// Drop the membership from the multicast group

if (key != null) {

key.drop();

}

}

}

}

清单 5-23。一个基于 DatagramChannel 的多播程序,它向多播组发送消息

// DGCMulticastServer.java

package com.jdojo.net;

import java.io.IOException;

import java.net.InetSocketAddress;

import java.net.NetworkInterface;

import java.net.StandardSocketOptions;

import java.nio.ByteBuffer;

import java.nio.channels.DatagramChannel;

public class DGCMulticastServer {

public static void main(String[] args) {

// Get a datagram channel object to act as a server

try (DatagramChannel server = DatagramChannel.open()) {

// Bind the server to any available local address

server.bind(null);

// Set the network interface for outgoing multicast data

NetworkInterface interf = NetworkInterface.getByName(

DGCMulticastUtil.MULTICAST_INTERFACE_NAME);

server.setOption(StandardSocketOptions.IP_MULTICAST_IF, interf);

// Prepare a message to send to the multicast group

String msg = "Hello from multicast!";

ByteBuffer buffer = ByteBuffer.wrap(msg.getBytes());

// Get the multicast group reference to send data to

InetSocketAddress group

= new InetSocketAddress(DGCMulticastUtil.MULTICAST_IP,

DGCMulticastUtil.MULTICAST_PORT);

// Send the message to the multicast group

server.send(buffer, group);

System.out.println("Sent the multicast message: " + msg);

}

catch (IOException e) {

e.printStackTrace();

}

}

}

进一步阅读

用 Java 进行网络编程是一个很大的话题。有几本书是专门写这个话题的。本章仅涵盖 Java 中可用的网络编程支持的基础知识。Java 还支持使用安全套接字层(SSL)协议的安全套接字通信。安全套接字通信编程的类在javax.net.ssl包中。本章不包括 SSL 套接字。我还没有介绍许多可以在 Java 程序中使用的套接字选项。如果你想用 Java 进行高级网络编程,建议你在读完这一章后,读一本专门用 Java 进行网络编程的书。

摘要

网络是一组两台或多台计算机或其他类型的电子设备(如打印机),它们为了共享信息而连接在一起。连接到网络的每个设备称为一个节点。与网络相连的计算机称为主机。Java 中的网络编程包括编写 Java 程序,以促进网络上不同计算机上运行的进程之间的信息交换。

两台远程主机之间的通信是通过称为 Internet 参考模型或 TCP/IP 分层模型的分层协议套件来执行的。该协议组由五层组成,即应用层、传输层、互联网层、网络接口层和物理层。Java 程序等用户应用使用应用层与远程应用进行通信。传输层协议处理将消息从一台计算机上的一个应用传输到远程计算机上的另一个应用的方式。互联网层接受来自传输层的消息,并准备适合通过互联网发送的数据包。它包括互联网协议(IP)。由 IP 准备的数据包也称为 IP 数据报,它由报头和数据区以及其他信息组成。网络接口层准备要在网络上传输的数据包。这个数据包称为一个帧。网络接口层位于物理层之上,物理层涉及硬件。物理层由硬件组成。它负责将信息比特转换成信号,并通过线路传输信号。

IP 地址唯一标识计算机和路由器之间的连接。互联网协议有两个版本——IP v4(或简称 IP)和 IPv6,其中 v4 和 v6 分别代表版本 4 和版本 6。IPv6 也被称为下一代互联网协议(IPng)。在 Java 程序中,InetAddress类的一个对象代表一个 IP 地址。InetAddress类有两个子类,Inet4AddressInet6Address,分别代表 IPv4 和 IPv6 地址。

端口号是 16 位无符号整数,范围从 0 到 65535,用于唯一标识特定协议的进程。

InetSocketAddress类的一个对象表示一个套接字地址,它结合了一个 IP 地址和一个端口号。

ServerSocket类的一个对象代表一个 TCP 服务器套接字,用于接受来自远程主机的连接。Socket类的一个对象代表一个服务器/客户端套接字。客户端和服务器应用使用Socket类的对象交换信息。Socket类提供了getInputStream()getOutputStream()方法来分别获取套接字的输入和输出流。套接字的输入流用于从套接字读取数据,套接字的输出流用于向套接字写入数据。

DatagramPacket类的对象代表 UDP 数据报,它是 UDP 套接字上的数据传输单元。DatagramSocket类的一个对象代表一个 UDP 服务器/客户端套接字。

统一资源标识符(URI)是标识资源的字符序列。使用位置来标识资源的 URI 称为统一资源定位器(URL)。使用名称来标识资源的 URI 称为统一资源名(URN)。URL 和 URN 是 URI 的子集。java.net.URI类的一个对象代表 Java 中的一个 URI。java.net.URL类的一个对象代表 Java 中的一个 URL。Java 提供了访问由 URL 标识的内容的类。

Java 支持使用java.nio.channels包中的ServerSocketChannelSocketChannelSelectorSelectionKey类的非阻塞套接字通道。

Java 还通过java.nio.channels包中的AsynchronousServerSocketChannelAsynchronousSocketChannel类支持异步套接字通道。

Java 通过DatagramChannel类支持面向数据报的套接字通道。数据报通道也支持 IP 多播。

六、JDBC API

在本章中,您将学习

  • 什么是 JDBC 空气污染指数
  • JDBC 司机的类型
  • Java DB 的简要概述
  • 如何使用 JDBC 驱动程序连接到数据库
  • 什么是事务隔离级别
  • JDBC 数据类型到 Java 数据类型的映射
  • 如何在 Java 程序中执行 SQL 语句并处理结果
  • 使用行集、批量更新和大型对象(lob)
  • 如何检索 SQL 警告并启用 JDBC 跟踪

什么是 JDBC 空气污染指数?

JDBC API 提供了一个标准的独立于数据库的接口来与任何表格数据源进行交互。大多数时候,它用于与关系数据库管理系统(RDBMs)进行交互。但是,使用 JDBC API,可以与任何表格数据源进行交互,比如 Excel 电子表格、平面文件等。通常,您使用 JDBC API 来连接数据库、查询数据和更新数据。它还允许您使用独立于数据库的语法在数据库中执行 SQL 存储过程。

使用数据库的主要目的是管理业务数据。每个数据库都为开发人员提供了以下三种管理数据的方法:

  • 标准的 SQL 语法
  • 标准 SQL 语法的扩展,称为专有 SQL 语法
  • 一种专有的编程语言

例如,Oracle 数据库使用 PL/SQL 作为编程语言,您可以用它来编写存储过程、函数和触发器。Microsoft SQL Server 使用 Transact-SQL (T-SQL)作为编程语言来编写存储过程、函数和触发器。如果您想要处理数据库中的一组行,您需要知道在特定的数据库相关语言中处理游标的语法和逻辑。使用 JDBC API 可以减轻您在不同数据库中学习不同语法来处理游标的痛苦。它要求您使用标准的 SQL 语法编写一个查询(SELECT语句)。它提供 Java APIs 以独立于数据库的方式处理查询的结果集。

使用 JDBC API 访问数据库中的数据隐藏了不同类型数据库中存在的实现差异。它通过使用接口定义其大多数 API 并让数据库供应商(或任何第三方供应商)提供这些接口的实现来实现数据库透明性。供应商提供的用于与特定数据库交互的实现类的集合称为 JDBC 驱动程序。不同的数据库(或同一数据库)有不同种类的 JDBC 驱动程序。它们的实现方式不同。一些 JDBC 驱动程序是用纯 Java 编写的。对于纯 Java 实现的 JDBC 驱动程序,您只需要在您的应用中包含供应商提供的类CLASSPATH。一些 JDBC 驱动程序需要在客户机上安装专有软件来与数据库交互。下一节讨论 JDBC 驱动程序类型。

系统需求

本章是关于使用 Java 程序与数据库交互的。您必须能够访问 Oracle 数据库、Microsoft SQL Server、Sybase 数据库、DB2、MySQL、Java DB 等数据库。您还需要为您的数据库安装 JDBC 驱动程序。一些 JDBC 驱动程序不需要特殊安装。相反,您可以通过将提供的 JDBC 驱动程序文件(通常是一个 JAR 文件)放在您机器上的CLASSPATH中来使用它们。如果您无权访问数据库和所需的 JDBC 驱动程序,您将无法运行本章中列出的示例。所有主要的数据库供应商都可以从他们的官方网站免费下载 JDBC 驱动程序。必要时,本章提供了在几个 DBMS(MySQL、Java DB (Apache Derby)、Oracle 数据库、DB2、Microsoft SQL Server 和 Sybase Adaptive Server Anywhere)中创建数据库对象和例程的语法和脚本。如果您使用 DBMS 来运行本章中的 JDBC 程序,除了这里列出的程序之外,您将需要参考 DBMS 文档以获得创建数据库对象的语法。

JDBC 司机的类型

您可以在 Java 程序中使用三种类型的 JDBC 驱动程序来连接 DBMS。图 6-1 显示了这些 JDBC 驱动程序的架构。本节简要介绍这些类型的 JDBC 驱动程序。

A978-1-4302-6662-4_6_Fig1_HTML.jpg

图 6-1。

The architecture of JDBC drivers Note

在 Java 8 之前,JDBC API 提供了另一种叫做 JDBC-ODBC 桥的 JDBC 驱动程序。Java 8 中已经删除了这个驱动。

JDBC 本地 API 驱动程序

JDBC 本地 API 驱动程序使用特定于 DBMS 的本地库来执行所有数据库活动。它将 JDBC 调用翻译成特定于 DBMS 的调用,DBMS 本地库与数据库通信。要使用这种类型的驱动程序,您必须安装特定于 DBMS 的客户端软件。它依赖于平台。

JDBC 网络驱动程序

JDBC 网络驱动程序是用纯 Java 编写的。它需要一个服务器来处理数据库。驱动程序将 JDBC 调用转换为网络协议,并将调用传递给服务器。服务器将网络调用转换成特定于 DBMS 的调用。在客户机上运行的 JDBC 驱动程序不知道服务器将用来执行数据库活动的技术(或 DBMS 驱动程序类型)。服务器可以使用不同类型的数据库驱动程序连接到不同的数据库,并且对客户端是透明的。它是一个独立于平台的驱动程序。客户机只需要包含使用驱动程序所需的 Java 类。客户机上不需要额外安装。

JDBC 司机

JDBC 驱动程序也称为直接数据库纯 Java 驱动程序。它是用纯 Java 编写的。它将 JDBC 调用转换为特定于 DBMS 的调用,并将这些调用直接发送到数据库。这是在 Applet 中使用的最合适的驱动程序类型。你所需要做的就是在你的应用或 Applet 中包含驱动 JAR/ZIP 文件。所有主要的 DBMS 供应商都提供这种类型的 JDBC 驱动程序。

Java DB 的简要概述

您将需要访问关系数据库来运行本章中的示例。如果您没有访问数据库的权限,可以在安装 JDK 时使用安装在计算机上的 Java DB 数据库。Java DB 是一个基于 Java 编程语言和 SQL 的关系数据库管理系统。它是 Apache Derby 项目的 Oracle 版本。关于 Java DB 的技术文档可以在 http://docs.oracle.com/javadb/ 找到。

在接下来的小节中,我将讨论 Java DB 入门所需的最基本的信息,比如如何在 Java DB 中启动、停止和运行 SQL 命令。

Java DB 安装文件

假设JDK_HOME是您安装 JDK 的目录。例如,如果您在 Windows 上的C:\java8中安装了 JDK,JDK_HOME的值将是C:\java8;如果您在类似 UNIX 的操作系统上的/home/ksharan/java8中安装了 JDK,JDK_HOME的值将是/home/ksharan/java8。Java DB 文件安装在JDK_HOMEdb子目录中。db目录包含两个子目录binlibbin子目录包含许多用于数据库的命令文件。例如,startNetworkServer.bat文件用于在 Windows 上启动网络 Java DB 服务器,startNetworkServer用于在 UNIX 上启动网络 Java DB 服务器;您可以使用 Windows 上的ij.bat命令和 UNIX 上的ij命令连接到 Java DB 并运行 SQL 命令(ij代表交互式 Java DB)。lib子目录包含所有用于 Java DB 的 JAR 文件。

配置 Java 数据库

通常,安装 JDK8 后,不需要配置 Java DB。如果在启动数据库或从命令行运行 SQL 命令时遇到任何错误,您需要设置以下环境变量:

  • DERBY_HOME环境变量设置为JDK_HOME\db目录。
  • JAVA_HOME环境变量设置为JDK_HOME目录。
  • PATH环境变量中包含JDK_HOME\bin目录。

当使用 Java DB 服务器和客户端应用时,需要在类路径中包含一些 Java DB 库。所有库都是位于JDK_HOME\db\lib目录下的 JAR 文件。表 6-1 包含 Java DB 库的列表。

表 6-1。

Libraries Used in Java DB Server and Client Applications

| 库名 | 描述 | | --- | --- | | `derby.jar` | 包含 Java DB 数据库引擎代码。用于在嵌入式模式下运行的 Java DB。对于在服务器模式下运行的 Java DB,服务器上需要它。 | | `derbytools.jar` | 运行所有 Java DB 工具所需,如`ij`、`dblook`等。 | | `derbyrun.jar` | 用于启动 Java DB 工具的可执行 JAR 文件。包括`CLASSPATH`中的这个文件还包括`CLASSPATH`中的`derby.jar`、`derbyclient.jar`、`derbytools.jar`、`derbynet.jar`文件。 | | `derbynet.jar` | 包含 Java DB 网络服务器代码。需要启动 Java DB 网络服务器。 | | `derbyclient.jar` | 包含 Java DB 网络客户端 JDBC 驱动程序。Java 应用需要通过网络连接到 Java DB 服务器。 |

运行 Java DB 服务器

Java DB 可以在两种模式下运行:

  • 嵌入式模式
  • 服务器模式

在嵌入式模式下,Java DB 是在与 Java 应用相同的 JVM 中为单用户 Java 应用启动的。Java 应用启动和停止 Java 数据库。这是学习使用 JDBC API 进行数据库编程的最合适的模式。在这种模式下,使用 Java DB 不需要进行任何设置。我将在后面详细解释如何使用这种模式。除非另有说明,本章中的所有示例都使用在此模式下运行的 Java DB。

在服务器模式下,多个用户可以通过网络同时使用 Java DB。Java DB 运行在一个单独的 JVM 中。在单独的 JVM 中运行的应用可能会连接到以这种模式运行的 Java DB。

您可以使用命令提示符或 NetBeans IDE 来管理 Java DB。以下部分将对两者进行解释。

使用命令提示符

使用以下命令在服务器模式下启动 Java DB:

c:\java8\db\bin> startNetworkServer

Tue Jun 17 23:25:27 CDT 2014 : Security manager installed using the Basic server security policy.

Tue Jun 17 23:25:27 CDT 2014 : Apache Derby Network Server - 10.10.1.3 - (1557168) started and ready to accept connections on port 1527

您可能会在启动服务器时得到一个AccessControlException。错误消息可能如下所示:

java.security.AccessControlException: access denied ("java.net.SocketPermission" "localhost:1527" "listen,resolve")

要解决AccessControlException,您可以在没有安装安全管理器的情况下启动服务器,如下所示:

c:\java8\db\bin> startNetworkServer -noSecurityManager

您还可以通过在JRE_HOME\lib\security\java.policy文件中授予listenresolve访问服务器启动所在的主机和端口的权限来解析。java.policy文件中的以下条目授予所需的访问权限:

grant {

permission java.net.SocketPermission "localhost:1527", "listen";

};

默认情况下,在服务器模式下,Java DB 从本地主机(或回送 IP 地址)和端口 1527 启动。如果要从其他计算机访问 Java DB,需要在命令行或属性文件中配置一些属性。

配置 Java DB 属性最简单的方法是在命令行上设置它们。以下命令启动 Java DB 服务器,该服务器在端口号 1537 处监听myhost:

c:\java8\db\bin>startNetworkServer -h myhost -p 1537

您还可以使用java命令来启动 Java DB 服务器。下面的命令启动 Java DB 服务器,另外设置CLASSPATHderby.system.home属性:

C:\java8\db\bin>java -classpath C:\java8\db\lib\derbynet.jar -Dderby.system.home=C:\myderbyhome org.apache.derby.drda.NetworkServerControl start -h localhost

您可以在名为的文本文件中设置 Java DB 属性,该文件位于由derby.system.home属性指定的目录中。您可以在启动 Java DB 服务器时指定derby.system.home属性。如果没有指定derby.system.home属性,默认为当前工作目录。

我使用 NetBeans 为用户ksharan在 Windows 和 Linux 上运行 Java DB。默认情况下,NetBeans IDE 会将derby.system.home属性设置为用户主目录下的.netbeans-derby子目录。使用 NetBeans IDE,将derby.properties文件放在我的机器上,如下所示:

  • 在 Windows 上:C:\Users\ksharan\.netbeans-derby\derby.properties
  • 在 Linux 上:/home/ksharan/.netbeans-derby/derby.properties

derby.drda.host属性设置为您希望在服务器模式下启动 Java DB 的主机名或 IP 地址。如果将该属性设置为 0.0.0.0,Java DB 将监听所有网络接口。设置derby.drda.portNumber属性来监听不同于默认端口 1527 的端口。以下是设置自定义主机和端口号的derby.properties文件的内容:

# Contents of the derby.properties file

# Set the IP address 192.168.1.1 as the host

derby.drda.host=192.168.1.1

# Set 1528 as the port number

derby.drda.portNumber=1528

使用以下命令停止在服务器模式下运行的 Java DB。请注意,您需要使用单独的命令提示符运行该命令。

c:\java8\db\bin>stopNetworkServer

Tue Jun 17 23:26:49 CDT 2014 : Apache Derby Network Server - 10.10.1.3 - (1557168) shutdown

Tip

Java DB 数据库服务器可以有几个数据库。Java DB 中的数据库是可移植的。每个数据库的文件都存储在单独的目录中。移动 Java DB 数据库就像移动该数据库的目录一样简单。该目录的名称与数据库名称相同。默认情况下,所有数据库目录都存储在derby.system.home属性中指定的目录中。

启动 Java DB 服务器后,您可以使用ij命令行工具连接到它并执行 SQL 命令。ij刀具位于JDK\db\bin目录中。假设 Java DB 服务器正在本地主机的端口 1527 上运行,下面的命令启动ij工具,连接到名为beginningJavaDB的 Java DB 数据库,执行SELECT SQL 语句,并使用exit命令退出工具。如果数据库中没有 person 表,那么在执行SELECT语句时可能会出现错误。

c:\java8\db\bin>ij

ij version 10.10

ij> connect 'jdbc:derby://localhost:1527/beginningJavaDB';

ij> select * from person;

PERSON_ID  |FIRST_NAME          |LAST_NAME          |&|DOB      |INCOME

--------------------------------------------------------------------------

0 rows selected

ij> exit;

c:\java8\db\bin>

如果希望在嵌入式模式下使用 Java DB 数据库,则不需要启动 Java DB 服务器。下面的命令设置类路径,设置derby.system.home属性,并启动ij工具。注意,该命令是在一行中输入的。启动ij工具后,我以嵌入式模式连接到beginningJavaDB数据库并执行 SQL 命令,如图所示。如果数据库中没有person表,那么在执行SELECT语句时可能会出错。

c:\java8\db\bin>java -classpath C:\java8\db\lib\derbyrun.jar; -Dderby.system.home=C:\Users\ksharan\.netbeans-derby org.apache

.derby.tools.ij

ij version 10.10

ij>connect 'jdbc:derby:beginningJavaDB';

ij> select * from person;

PERSON_ID  |FIRST_NAME          |LAST_NAME          |&|DOB      |INCOME

--------------------------------------------------------------------------

0 rows selected

ij> exit;

c:\java8\db\bin>

使用 NetBeans IDE

使用 NetBeans IDE 时,使用 Java DB 很容易。您可以在 NetBeans IDE 的 Navigator 中使用“服务”选项卡来启动、停止和运行 Java DB 中的 SQL 命令。图 6-2 显示了 NetBeans IDE 中Services选项卡上Databases节点下的 Java DB。如果您没有看到Services选项卡,您可以通过选择菜单选项Windows > Services或按下Ctrl + 5来显示它。

A978-1-4302-6662-4_6_Fig2_HTML.jpg

图 6-2。

Using Java DB on the Services tab from inside the NetBeans IDE

要启动和停止 Java DB,请在Services选项卡上选择 Java DB 节点,右键单击并选择适当的选项。

Java DB 中的所有数据库都列在 Java DB 节点下。Java DB 安装了一个名为sample的预建数据库。您可以通过选择Create Database选项创建自己的数据库,如图 6-2 所示。

要连接到一个特定的数据库,选择 Java DB 节点下的数据库名称,右键单击,选择 connect 菜单选项,如图 6-3 所示。

A978-1-4302-6662-4_6_Fig3_HTML.jpg

图 6-3。

Connecting to a Java DB database from inside the NetBeans IDE

连接到数据库后,可以使用 NetBeans IDE 在该数据库中执行 SQL 命令。Services选项卡中的Databases节点包含每个 Java DB 数据库的一个Database Connection节点。为您的数据库选择数据库连接节点,点击右键,选择Execute Command菜单选项,如图 6-4 所示。它将打开一个 SQL 编辑器。您可以在 SQL 编辑器中输入 SQL 命令。要执行 SQL 命令,使用Run SQL工具栏按钮或按Ctrl + Shift + E

A978-1-4302-6662-4_6_Fig4_HTML.jpg

图 6-4。

Executing SQL commands in Java DB from inside the NetBeans IDE

默认情况下,NetBeans IDE 将所有 Java DB 数据库存储在用户主目录中名为.netbeans-derby的子目录中。您可以使用“Java DB 属性”对话框来更改默认位置。通过选择图 6-5 所示的Databases > Data DB > Right-click > Properties菜单,可以打开 Java DB 属性对话框。

A978-1-4302-6662-4_6_Fig5_HTML.jpg

图 6-5。

Setting properties for Java DB in the NetBeans IDE

您可以使用图 6-6 所示的 Java DB 属性对话框更改默认的 Java DB 安装目录和数据库位置。图中显示我将默认数据库位置更改为 Windows 上的C:\kishori\books\java_8\projects\Test目录。

A978-1-4302-6662-4_6_Fig6_HTML.jpg

图 6-6。

The Java Db Properties dialog

创建数据库表

使用 JDBC API 的主要目的是操作数据库表中包含的数据。您可以使用 SQL 语句SELECTINSERTUPDATEDELETE来操作表中的数据,这些语句直接使用表名。有时,您可能不会在 JDBC 调用中直接引用表名。相反,您可以使用 JDBC API 执行存储过程,存储过程使用表名。不管怎样,当你和 JDBC 一起工作时,你最终会使用表格。在本章的大部分时间里,您将使用一个表。你将把你的桌子命名为person。当您需要使用 JDBC 处理特定类型的数据库时,您可以在此过程中创建更多的表。假设您已经在自己选择的数据库中创建了一个名为person的表。表格描述如表格 6-2 所示。

表 6-2。

Generic Description of a Database Table Named Person

| 列名 | 数据类型 | 长度 | 允许空值 | 评论 | | --- | --- | --- | --- | --- | | `person_id` | `integer` |   | `No` | `Primary Key` | | `first_name` | `string` | `20` | `No` |   | | `last_name` | `string` | `20` | `No` |   | | `gender` | `string` | `1` | `No` |   | | `Dob` | `date` |   | `Yes` |   | | `income` | `double` |   | `Yes` |   |

此表中显示的列的数据类型是通用的。您需要使用特定于您的 DBMS 的数据类型。例如,对于first_name列,您可以在 Oracle 数据库中使用数据类型varchar2(20),在 SQL Server 数据库中使用数据类型varchar(20)。类似地,对于person_id列,您可以在 Oracle 数据库中使用数据类型number(8, 0),在 SQL Server 数据库中使用数据类型int

每个 DBMS 都提供了一个工具,或者是基于字符的,或者是图形的,或者两者都有,让你使用数据库对象,如表、存储过程、函数等。例如,您可以使用来自 Oracle 的用于 Oracle DBMS 的 Oracle SQL*PLUS 工具、来自 Microsoft 的用于 SQL Server DBMS 的 SQL Server Management Studio 工具、来自 Sybase 的用于 Adaptive Server Anywhere (ASA)的 Interactive SQL 工具等。

以下部分展示了在不同数据库中创建person表的数据库脚本。您需要查阅数据库文档,了解如何运行脚本来创建person表。

Note

所有创建表和存储过程的数据库脚本都可以在本书的源代码所在的dbscripts\<DBMS-Name>目录下找到,其中<DBMS-Name>是 Oracle、DB2 等 DBMS 的名称。

甲骨文数据库

create table person (

person_id number(8,0) not null,

first_name varchar2(20) not null,

last_name varchar2(20) not null,

gender char(1) not null,

dob date,

income number(10,2),

constraint pk_person primary key(person_id)

);

Adaptive Server Anywhere 数据库

create table (

person_id integer not null default null,

first_name varchar(20) not null default null,

last_name varchar(20) not null default null,

gender char(1) not null default null,

dob date null default null,

income double null default null,

primary key (person_id)

);

SQL Server 数据库

create table person (

person_id int NOT NULL,

first_name varchar(20) NOT NULL,

last_name varchar(20) NOT NULL,

gender char(1) NOT NULL,

dob datetime NULL,

income decimal(10,2) NULL,

constraint pk_person primary key (person_id)

);

DB2 数据库

create table person (

person_id integer not null,

first_name varchar(20) not null,

last_name varchar(20) not null,

gender character (1)  not null,

dob date,

income double,

constraint pk_person_id primary key (person_id)

);

MySQL 数据库

create table (

person_id integer not null primary key,

first_name varchar(20) not null,

last_name varchar(20) not null,

gender char(1) not null,

dob datetime null,

income double null

);

Java DB 数据库

create table (

person_id integer not null,

first_name varchar(20) not null,

last_name varchar(20) not null,

gender char(1) not null,

dob date,

income double,

primary key(person_id)

);

您可以运行清单 6-3 中显示的程序,在 Java DB 中创建 person 表。要在另一个数据库中创建 person 表,您可能需要更改程序中的CREATE TABLE语法。

连接到数据库

以下是连接到数据库时需要遵循的步骤。

  • 获取 JDBC 驱动程序,并将其添加到您机器上的CLASSPATH环境变量中。
  • DriverManager注册 JDBC 驱动程序。
  • 构造一个连接 URL。
  • 使用DriverManagergetConnection()静态方法建立连接。

以下部分详细描述了这些步骤。

获取 JDBC 驱动程序

在使用 JDBC 连接到数据库之前,您需要有数据库的 JDBC 驱动程序。您可以从数据库供应商那里获得 JDBC 驱动程序。例如,如果您正在使用 Oracle DBMS,您可以从其官方网站 www.oracle.com 下载 JDBC 驱动程序。所有支持 JDBC 的数据库供应商都会让你从他们的官方网站免费下载他们 DBMS 的 JDBC 驱动程序。通常,JDBC 驱动程序捆绑在一个或多个 JAR/ZIP 文件中。

如果您使用的是 Java DB,当您安装 JDK 时,JDBC 驱动程序会被复制到您的机器上。您不需要下载任何额外的 JDBC 驱动程序。

设置类路径

如果您正在使用 JDBC 驱动程序,您需要将您的 JDBC 驱动程序的 JAR/ZIP 文件放在您机器上的CLASSPATH中,以便您的 Java 程序可以使用为您的数据库实现 JDBC 驱动程序的 Java 类。

如果您正在使用 Java DB,请参考表 6-1 中您将需要在您的案例中使用的 JAR 文件。为了在嵌入式模式下运行本章中所有使用 Java DB 的例子,您将需要在CLASSPATH中的derby.jar文件。derby.jar文件是在嵌入式模式下使用 Java DB 所需的 JDBC 驱动程序。如果您通过网络连接到 Java DB,您将需要在CLASSPATH中包含derbyclient.jar文件。

注册一个 JDBC 司机

您需要注册一个 JDBC 驱动程序,您想用它来连接数据库。一个 JDBC 驱动程序在该类中注册。

什么是 JDBC 司机?从技术上讲,JDBC 驱动程序是一个实现了java.sql.Driver接口的类。DBMS 供应商提供 JDBC 驱动程序类以及它使用的任何其他类。在向DriverManager类注册之前,您必须知道 JDBC 驱动程序类的名称。如果您不知道驱动程序类的名称,请参考 DBMS 的 JDBC 驱动程序文档。

在下一节中,我将列出一些 DBMSs 的驱动程序类名。根据 DBMS 的版本或驱动程序类的供应商,该名称可能会有所不同。有时不同的供应商为相同的 DBMS 提供驱动程序。不同的供应商将使用不同的驱动程序类名和不同的连接 URL 格式来连接到相同的 DBMS。

为什么需要用DriverManager注册 JDBC 司机?Java 不知道如何连接到数据库。它依赖于 JDBC 驱动程序来连接数据库。把 JDBC 驱动程序想象成一个 Java 类,它的对象将被DriverManager用来连接数据库。问题是,“?? 如何知道您想要用来连接数据库的 JDBC 驱动程序?”当然,它自己没有办法知道 JDBC 驱动程序。因此,向DriverManager注册一个驱动程序只是告诉DriverManager你的 JDBC 驱动程序类名。通过注册一个 JDBC 驱动程序,你告诉DriverManager如果你要求DriverManager建立一个数据库连接,它需要尝试使用这个驱动程序。你能用DriverManager注册多个 JDBC 驱动吗?是的。您可以注册多个 JDBC 驱动程序。当您需要建立到数据库的连接时,您必须将一个连接 URL 传递给DriverManagerDriverManager将连接 URL 逐个传递给所有注册的驱动程序,并要求它们使用您在连接 URL 中提供的信息连接到数据库。如果驱动程序识别出连接 URL,它将连接到数据库并将连接返回给DriverManager。接口的对象表示 Java 程序中的数据库连接。如果没有一个注册的驱动程序识别一个连接 URL,DriverManager将抛出一个SQLException,声明它找不到合适的驱动程序。

DriverManager注册 JDBC 驾驶员有三种方式:

  • 通过设置jdbc.drivers系统属性
  • 通过将驱动程序类装入 JVM
  • 通过使用DriverManager类的registerDriver()方法

设置 jdbc.drivers 系统属性

您可以使用系统属性注册 JDBC 驱动程序类名。您可以在您的计算机中全局设置该属性;您可以在运行应用时在命令行上传递此属性,也可以使用方法在应用中设置此属性。冒号分隔每个要注册的驱动程序。以下是一些例子:

// Register Sybase and Oracle drivers in the Java code

String drivers = "com.sybase.jdbc2.jdbc.SybDriver:oracle.jdbc.driver.OracleDriver";

System.setProperty("jdbc.drivers", drivers);

// Pass driver names to be registered as command-line arguments.

// The following command is entered in one line.

java -Djdbc.drivers=com.sybase.jdbc2.jdbc.SybDriver:oracle.jdbc.driver.OracleDriver com.jdojo.jdbc.Test

加载驱动程序类

您可以创建驱动程序类的对象。当驱动程序类被加载到 JVM 中时,它向DriverManager注册自己。对于要加载的类,您可以使用一个Class.forName("driver class name")方法或创建该类的一个对象,如下所示:

// Register the Oracle JDBC driver

new oracle.jdbc.driver.OracleDriver();

// Register the Oracle JDBC driver using the Class.forName() method.

// Exception handling has been omitted.

Class.forName("oracle.jdbc.driver.OracleDriver")

// Register the Java DB embedded driver

new org.apache.derby.jdbc.EmbeddedDriver();

// Register the Java DB network client driver

new org.apache.derby.jdbc.ClientDriver();

您不需要保留驱动程序对象的引用,因为目标是在 JVM 中加载驱动程序类。当驱动程序的类被加载到 JVM 中时,驱动程序类的静态初始化器被执行,其中驱动程序类向DriverManager注册自己。

使用 registerDriver()方法

你可以用一个 JDBC 驱动类的对象调用DriverManager类的registerDriver(java.sql.Driver driver)静态方法来注册 JDBC 驱动。

// Register the Oracle JDBC driver with DriverManager

DriverManager.registerDriver(new oracle.jdbc.driver.OracleDriver());

// Register the Java DB embedded driver

DriverManager.registerDriver(new org.apache.derby.jdbc.EmbeddedDriver());

// Register the Java DB network client driver

DriverManager.registerDriver(new org.apache.derby.jdbc.ClientDriver());

您可以按照上述三种方法之一注册 JDBC 驱动程序。第一种方式更灵活。它让您无需更改 Java 代码就可以更改 JDBC 驱动程序。您还可以使用系统属性或作为命令行参数来指定连接 URL(下面将讨论)。这样,您不仅可以使用不同的 JDBC 驱动程序,还可以使用不同的 DBMS,而无需修改 Java 代码。

构建连接 URL

使用连接 URL 建立数据库连接。连接 URL 的格式取决于 DMBS 和 JDBC 驱动程序。连接 URL 有三个部分。冒号分隔连接 URL 的两个部分。定义连接 URL 的语法是

<protocol>:<sub-protocol>:<data-source-details>

<protocol>部分总是被设置为jdbc<sub-protocol>部分是特定于供应商的。<data-source-details>部分是 DBMS 特有的,用于定位数据库。在某些情况下,您还可以在 URL 的最后部分指定一些连接属性。以下是使用 Oracle 瘦 JDBC 驱动程序连接到 Oracle DBMS 的连接 URL 示例:

jdbc:oracle:thin:@localhost:1521:chanda

一如既往,协议部分是jdbc。子协议部分是oracle:thin,它将甲骨文公司标识为供应商,以及它将使用的驱动程序类型,即thin。数据源细节部分是@localhost:1521:chanda。它有三个子部分。@localhost标识服务器名。您可以使用 Oracle 数据库服务器的 IP 地址或机器名。然后,它包含运行 Oracle 传输网络底层(TNS)监听程序的端口号。最后一部分是 Oracle 的实例名,在本例中是chanda。以下是在 Java DB 服务器中标识数据库的连接 URL 的另一个示例:

jdbc:derby://192.168.1.3:1527/beginningJavaDB;create=true

一如既往,协议部分是jdbc。子协议部分是derby,它标识 Apache Derby DBMS。回想一下,Java DB 是 Oracle 发布的 Apache Derby 项目。192.168.1.3:1527部分是运行 Java DB 服务器的机器的 IP 地址和端口号。数据库名为beginningJavaDB。最后一部分,create=true,是连接属性,表示如果名为beginningJavaDB的数据库不存在,就用这个名称创建一个新的数据库。

以下部分描述了某些 DBMSs 的连接 URL 的格式。您需要访问供应商的官方网站来下载特定的 JDBC 驱动程序。您还可以在供应商的网站上获得有关使用 JDBC 驱动程序的详细信息。

Oracle 数据库

DBMS:

Vendor: Oracle Corporation

Web Site:http://www.oracle.com

Driver Type: JDBC Driver (thin - Pure Java)

URL Format: jdbc:oracle:thin:@<server>:<port>:<instance>

URL Example: jdbc:oracle:thin:@localhost:1521:chanda

Driver Class: oracle.jdbc.driver.OracleDriver

100%用 Java 实现。如果您使用瘦 Oracle 驱动程序,则不需要安装任何特定于 Oracle 的配置软件。如果您在 applet 中使用 JDBC 连接到 Oracle 数据库,您应该使用以下驱动程序:

DBMS: Oracle 10g

Vendor: Oracle Corporation

Web Site:http://www.oracle.com

Driver Type: JDBC-Native Driver (OCI - Oracle Call Interface)

URL Format: jdbc:oracle:oci:@<tns-alias>

URL Example: jdbc:oracle:oci:@orcl

Driver Class: oracle.jdbc.driver.OracleDriver

您需要安装 Oracle 客户端软件才能使用 OCI 驱动程序。JDBC 驱动程序将标准的 JDBC 呼叫转换为 OCI 呼叫,然后发送到数据库。URL 的<tns-alias>部分来自于tnsnames.ora文件中的一个条目。tnsnames.ora文件中典型的 TNS 别名条目如下所示:

ORCL =

(DESCRIPTION =

(ADDRESS = (PROTOCOL = TCP)(HOST = HYE6754)(PORT = 1521))

(CONNECT_DATA =

(SERVER = DEDICATED)

(SERVICE_NAME = orcl)

)

)

Oracle JDBC 驱动程序还允许您将 TNS 别名的整个文本指定为 JDBC 连接 URL 的一部分,如下所示:

String dbURL="jdbc:oracle:oci:@(DESCRIPTION =" +

"(ADDRESS = (PROTOCOL = TCP)(HOST = HYE6754)(PORT = 1521))" +

"(CONNECT_DATA =(SERVER = DEDICATED)(SERVICE_NAME = orcl)))";

Adaptive Server Anywhere 数据库

DBMS:

Driver Type: JDBC Driver (Pure Java)

Vendor: Sybase Inc.

Web Site:http://www.sybase.com

URL Format: jdbc:sybase:Tds:<server>:<port>

URL Example: jdbc:sybase:Tds:localhost:2638

Driver Class: com.sybase.jdbc2.jdbc.SybDriver

SQL Server 数据库

您可以使用以下两种 JDBC 驱动程序之一来连接到 SQL Server 数据库:

// Driver #

DBMS: SQL Server

Vendor: Microsoft Corporation

Web Site:http://www.microsoft.com

Driver Type: JDBC Driver (Pure Java)

URL Format: jdbc:SQLserver://<server>:<port>

URL Example: jdbc:SQLserver://HYE6754:1433;Database=chanda

Driver Class: com.microsoft.SQLserver.jdbc.SQLServerDriver

// Driver #2

DBMS: SQL Server

Vendor: SourceForge Inc.

Web Site:http://www.sourceforge.net

Driver Type: JDBC Driver (Pure Java)

URL Format: jdbc:jtds:<server_type>://<server>:<port>/<database>;<props>

URL Example: jdbc:jtds:sqlserver://HYE6754:1433/chanda

Driver Class: net.sourceforge.jtds.jdbc.Driver

当您使用驱动程序#2 时,您可以将sqlserversybase指定为<server_type>,以分别连接到 SQL Server 或 Sybase DBMS。<props>是一个逗号分隔的property=value对列表,其中property是数据库属性的名称,value是其值。例如,如果您想指定用户和密码作为 URL 的一部分,您可以使用<props>作为user=myuserid;password=mysecretpassword

URL 的部分内容<port><database><props>是可选的。如果不指定它们,将使用它们的默认值。对于 SQL Server,<port>的默认值是 1433,对于 Sybase 是 7100。

MySQL 数据库

DBMS: MySQL Server 5.

Vendor: Oracle Corporation

Web Site:http://www.oracle.com

Driver Type: JDBC Driver (Pure Java)

URL Format: jdbc:mySQL://<server>:<port>/<database>?<props>

URL Example: jdbc:mySQL://HYE6754:3306/chanda

Driver Class: com.mySQL.jdbc.Driver

对于 MySQL 数据库,连接 URL 中的大多数部分都是可选的。例如,您可以使用 MySQL 的最短连接 URL 作为jdbc:mySQL://,所有其他部分将被假定为它们的默认值。<server><port>的默认值为localhost3306。您可以提供一个逗号分隔的<server>:<port>值列表,用作故障转移服务器。如果不提供<database>的值,可以在建立连接后调用Connection对象上的setCatalog("catalog name")方法,或者在所有查询中提供目录名。您已经在示例 URL 中指定了chanda作为您的数据库。<props>是一个由符号(&)分隔的name=value对列表。例如,您可以通过连接 URL 传递用户 id 和密码,如下所示。它使用 root 作为用户 id,使用chanda作为密码。

jdbc:mySQL://localhost:3306/chanda?user=root&password=chanda.

DB2 数据库

DBMS:

Vendor: IBM

Web Site:http://www.ibm.com

Driver Type: JDBC Driver (Pure Java)

URL Format: jdbc:db2://<server>:<port>/<database>?<props>

URL Example: jdbc:db2://localhost:50000/chandaDB

Driver Class: com.ibm.db2.jcc.DB2Driver

您可以使用jdbc:db2:jdbc:db2j:net:作为 URL 的初始部分。如果 URL 以jdbc:db2:开头,则表明该连接是到 DB2 UDB 家族中的一个服务器。如果 URL 以jdbc:db2j:net:开头,则表明该连接是到一个远程 IBM(R)Cloudscape(TM)服务器的。URL 中的<props>部分是数据库连接的name=value对属性的逗号分隔列表。例如,以下 URL 将userpassword属性分别指定为adminsecret:

jdbc:db2://localhost:5021/chandaDB:user=admin;password=secret;

有关可以在 JDBC 连接 URL 中设置的属性的更多详细信息,请访问 IBM 的官方网站。

Java DB 数据库

DBMS: Java DB (Apache Derby)

Web Site:http://www.oracle.com

Driver Type: JDBC Driver (Pure Java)

URL Format: jdbc:derby://<server>:<port>/<database>;<props>

URL Example: jdbc:derby://localhost:1527/beginningJavaDB;create=true

Driver Class: org.apache.derby.jdbc.

默认的用户名和密码分别是appapp。属性create=true被指定为创建一个空数据库,如果它不存在的话。Java DB 还有其他类型的 JDBC 驱动程序。当 Java DB 作为服务器运行并且您的应用作为客户机访问它时,客户机驱动程序允许您连接到它。还可以在应用运行的同一个 JVM 中启动 Java DB,应用和 Java DB 将在同一个进程中运行。当 Java DB 与您的应用在同一个进程中运行时,您可以使用嵌入式 JDBC 驱动程序来访问数据库。

为嵌入式 Java DB 加载 JDBC 驱动程序会启动 Java DB 数据库。以下是在嵌入式模式下启动 Java DB 并连接到名为beginningJavaDB的数据库的连接 URL 示例:

jdbc:derby:beginningJavaDB

回想一下,Java DB 数据库有一个与数据库名称同名的目录。JDBC 驱动程序如何使用这个连接 URL 找到beginningJavaDB目录?它将使用由derby.system.home属性指定的目录。如果未指定属性,它将使用当前目录。以下 java 命令通过指定derby.system.home属性来启动 Java 应用:

java -Dderby.system.home=C:\myDatabases com.jdojo.jdbc.MyApp

如果在MyApp类中使用数据库名,将在C:\myDatabases目录中进行搜索。

您还可以在连接 URL 中指定数据库目录的完整路径。以下连接 URL 指定了 Windows 上数据库的完整路径:

jdbc:derby:C:/myDatabases/beginningJavaDB

在数据库完整路径中,您可以在 Windows 和 UNIX 上使用正斜杠作为路径分隔符。

如果您的数据库目录在类路径中,您可以使用classpath子协议构造一个连接 URL,如下所示:

jdbc:derby:classpath:beginningJavaDB

连接 URL 将在类路径中寻找一个beginningJavaDB目录。如果你的数据库在test目录下的某个目录的类路径中,你可以如下构造连接 URL:

jdbc:derby:classpath:test/beginningJavaDB

Java DB 非常灵活,允许您指定连接 URL。它还允许您从 JAR/ZIP 文件访问只读数据库。以下连接 URL 在C:\myDatabases.jar文件中的测试目录下查找beginningJavaDB数据库:

jdbc:derby:jar:(C:/myDatabases.jar)test/beginningJavaDB

建立数据库连接

是时候连接到数据库了。您需要使用DriverManager类的getConnection()静态方法来建立到数据库的连接。它返回一个java.sql.Connection接口的对象,代表数据库连接。getConnection()方法使用一个java.util.Properties对象接受一个连接 URL、一个用户 id、一个密码和任意数量的名称-值对。getConnection()方法被重载:

  • static Connection getConnection(String url) throws SQLException
  • static Connection getConnection(String url, Properties info) throws SQLException
  • static Connection getConnection(String url, String user, String password) throws SQLException

您会发现,使用 JDBC 驱动程序对数据库进行的几乎每个操作都需要处理异常,这很烦人。这是一个检查过的异常,编译器会通过将代码放在try-catch块中或者使用throws子句来强制您处理它。即使你只写了一行代码,你最终也会使用一个try-catch块。您将为一行代码创建一个带有一些静态方法的实用程序类,它将为您处理异常。每当您需要使用这种单行代码功能时,您将使用实用程序类方法,而不是直接使用 JDBC 方法。这种方法将避免本章示例中臃肿的代码。

以下代码片段建立了与运行在嵌入式模式下的 Java DB 中名为beginningJavaDB的数据库的连接:

// Register the JDBC driver

Driver derbyEmbeddedDriver = new org.apache.derby.jdbc.EmbeddedDriver();

DriverManager.registerDriver(derbyEmbeddedDriver);

// Prepare the connection URL

String dbURL = "jdbc:derby:beginningJavaDB;create=true";

Connection conn = null;

try {

conn = DriverManager.getConnection(dbURL, "root", "chanda");

System.out.println("Connected to database successfully");

// Perform database activities here...

}

catch(SQLException e) {

e.printStackTrace();

}

finally {

if (conn != null) {

try {

// Close the connection

conn.close();

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

连接接口从接口继承。这意味着你也可以使用一个try-with-resources块来获得一个Connection,当控件退出try块时,这个Connection会自动关闭。您可以使用try-with-resources块重写上述代码,如下所示:

// Register the JDBC driver

Driver derbyEmbeddedDriver = new org.apache.derby.jdbc.EmbeddedDriver();

DriverManager.registerDriver(derbyEmbeddedDriver);

// Prepare the connection URL

String dbURL = "jdbc:derby:beginningJavaDB;create=true";

try (``Connection conn = DriverManager.getConnection(dbURL, "root", "chanda")

System.out.println("Connected to database successfully");

// Perform database activities here...

}

catch (SQLException e) {

e.printStackTrace();

}

如果您需要连接到任何其他数据库,您将需要更改两件事:您注册的 JDBC 驱动程序和连接 URL。驱动程序和连接 URL 都是特定于 DBMS 的。注意代码中使用了try-catch-finally块。当您完成一个数据库连接时,您需要使用Connection对象的close()方法来关闭它。Connection对象的方法抛出一个SQLException,迫使你使用另一个try-catch块。在典型的 Java 程序中,您不会在连接到数据库后立即关闭连接。您将使用Connection对象来执行一些数据库活动,然后关闭连接。

清单 6-1 包含了一个JDBCUtil类的代码,你将在本章中使用它来处理数据库连接。它的所有方法都是静态的,用于建立和关闭数据库连接、关闭Statement、关闭ResultSet、提交事务、回滚事务等。我将在本章后面讨论StatementResultSet对象。

清单 6-1。将用于处理数据库的实用程序类

// JDBCUtil.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.Driver;

import java.sql.DriverManager;

import java.sql.ResultSet;

import java.sql.SQLException;

import java.sql.Statement;

public class JDBCUtil {

public static Connection getConnection() throws SQLException {

// Register the Java DB embedded JDBC driver

Driver derbyEmbeddedDriver = new org.apache.derby.jdbc.EmbeddedDriver();

DriverManager.registerDriver(derbyEmbeddedDriver);

// Construct the connection URL

String dbURL = "jdbc:derby:beginningJavaDB;create=true;";

String userId = "root";

String password = "chanda";

// Get a connection

Connection conn = DriverManager.getConnection(dbURL, userId, password);

// Set the auto-commit off

conn.setAutoCommit(false);

return conn;

}

public static void closeConnection(Connection conn) {

try {

if (conn != null) {

conn.close();

}

}

catch (SQLException e) {

e.printStackTrace();

}

}

public static void closeStatement(Statement stmt) {

try {

if (stmt != null) {

stmt.close();

}

}

catch (SQLException e) {

e.printStackTrace();

}

}

public static void closeResultSet(ResultSet rs) {

try {

if (rs != null) {

rs.close();

}

}

catch (SQLException e) {

e.printStackTrace();

}

}

public static void commit(Connection conn) {

try {

if (conn != null) {

conn.commit();

}

}

catch (SQLException e) {

e.printStackTrace();

}

}

public static void rollback(Connection conn) {

try {

if (conn != null) {

conn.rollback();

}

}

catch (SQLException e) {

e.printStackTrace();

}

}

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

System.out.println("Connetced to the database.");

}

catch (SQLException e) {

e.printStackTrace();

}

finally {

JDBCUtil.closeConnection(conn);

}

}

}

要连接到数据库,您将使用JDBCUtil.getConnection()方法。要关闭一个连接,您将使用JDBCUtil.closeConnection()方法。getConnection()方法使用 Java DB 特定的 JDBC 驱动程序类和连接 URL 格式。您必须更改getConnection()方法中的代码,这将特定于您想要连接的 DBMS。需要注意的是,在运行本章中的其他示例之前,你必须能够运行JDBCUtil类,并确保你能够成功连接到 DBMS。

Tip

初学者最常犯的一个错误是没有在CLASSPATH中包含 JDBC 驱动的 Java 类(通常是一个 JAR/ZIP 文件)。确保CLASSPATH中包含了你的 JDBC 驾驶相关课程。例如,在类路径中包含derby.jar文件以使用 Java DB embedded JDBC 驱动程序。

设置自动提交模式

当连接到数据库时,Connection对象的自动提交属性默认设置为true。如果连接处于自动提交模式,SQL 语句在成功执行后会自动提交。如果连接不处于自动提交模式,您必须调用Connection对象的commit()rollback()方法来提交或回滚事务。通常,在 JDBC 应用中禁用连接的自动提交模式,以便应用逻辑控制事务的最终结果。要禁用自动提交模式,需要在连接建立后调用Connection对象上的setAutoCommit(false)。如果连接 URL 允许您设置自动提交模式,您也可以将其指定为连接 URL 的一部分。在获得一个Connection对象后,在JDBCUtil.getConnection()方法中将连接的自动提交模式设置为false

// Get a connection

Connection conn = DriverManager.getConnection(dbURL, userId, password);

// Set the auto-commit off

conn.setAutoCommit(false);

如果您已经为您的连接启用了自动提交模式,则不能使用它的commit()rollback()方法。在启用了自动提交模式的Connection对象上调用commit()rollback()方法会抛出异常。JDBC 还允许您在事务中使用保存点,以便您可以对事务应用部分回滚。在这一章的后面我会有一个使用保存点的例子。

如果在事务处理过程中调用了setAutoCommit()方法来更改连接的自动提交模式,则事务会在此时提交。通常,您会在连接到数据库后立即设置连接的自动提交模式。

提交和回滚事务

如果对某个连接禁用了自动提交模式,您可以使用commit()rollback()方法来提交或回滚事务。JDBC 应用中执行数据库事务的典型伪代码如下所示:

Connection conn = get a connection;

// Disable auto-commit mode

conn.setAutoCommit(false);

// Perform database transaction activities here

IF transaction is successful THEN

conn.commit(); // Commit the transaction

ELSE

conn.rollback(); // Rollback the transaction

END IF

conn.close();            // Close the connection

错误处理代码未显示。通常,一个try-catchtry-catch-finally块代替了上面显示的IF语句。

事务隔离级别

在多用户数据库中,您经常会遇到以下两个术语:

  • 数据并发
  • 数据一致性

数据并发是指多个用户并发使用相同数据的能力。数据一致性指的是当多个用户同时操作数据时,保持数据的准确性。随着数据并发性的增加(即更多的用户处理相同的数据),必须小心维护所需的数据一致性级别。数据库使用锁并通过将一个事务与另一个事务隔离来维护数据一致性。一个事务与另一个事务的隔离程度取决于所需的数据一致性级别。让我们看看在支持多个并发事务的多用户环境中,数据一致性可能会受到损害的三种现象。

肮脏的阅读

在脏读中,一个事务从另一个事务读取未提交的数据。考虑以下步骤序列,这些步骤由于脏读而导致数据不一致:

  • 事务 A 在表中插入了一个新行,但它还没有提交它。
  • 事务 B 读取由事务 a 插入的未提交行。
  • 事务 A 回滚更改。
  • 此时,事务 B 只剩下一行不存在的数据。

不可重复读取

在不可重复读取中,当事务重新读取数据时,它发现数据已被另一个已提交的事务修改。考虑以下步骤序列,这些步骤由于不可重复的读取而导致数据不一致:

  • 事务 A 读取一行。
  • 事务 B 修改或删除同一行,并提交更改。
  • 事务 A 重新读取同一行,发现该行已被修改或删除。

幻像读取

在幻像读取中,当事务重新执行同一查询时,它会找到更多满足该查询的数据。考虑以下步骤序列,由于幻像读取,这些步骤会导致数据不一致:

  • 事务 A 执行一个查询(比如 Q ),并找到 X 行与该查询匹配。
  • 事务 B 插入一些满足查询 Q 标准的行并提交。
  • 事务 A 重新执行相同的查询(Q ),并找到与该查询匹配的 Y 行(Y > X)。

请注意,不可重复读取和幻像读取之间的区别在于,前者会发现行在读取之间发生了变化,而后者会发现有更多的行匹配相同的查询。

ANSI SQL-92 标准根据上述三种数据一致性情况定义了四个事务隔离级别。每个隔离级别都定义了允许或不允许哪种数据不一致。四个事务隔离级别如下:

  • 未提交读取
  • 已提交读取
  • 可重复读
  • 可序列化

表 6-3 显示了四个隔离级别和三种允许的情况。由 DBMS 决定如何实现这些隔离级别。DBMS 可以提供额外的隔离级别。DBMS 实现相同隔离级别的方式可能会稍有不同。有关 DBMS 支持的隔离级别的更多详细信息,请参考 DBMS 文档。

表 6-3。

Four Isolation Levels Defined by ANSI SQL-92

| 隔离级别 | 肮脏的阅读 | 不可重复读取 | 幻像读取 | | --- | --- | --- | --- | | 未提交读取 | 允许 | 允许 | 允许 | | 已提交读取 | 不允许 | 允许 | 允许 | | 可重复读 | 不允许 | 不允许 | 允许 | | 可序列化 | 不允许 | 不允许 | 不允许 |

Java 在Connection接口中定义了以下四个常量,它们对应于 ANSI SQL-92 标准定义的四个隔离级别:

  • TRANSACTION_READ_UNCOMMITTED
  • TRANSACTION_READ_COMMITTED
  • TRANSACTION_REPEATABLE_READ
  • TRANSACTION_SERIALIZABLE

您可以使用Connection接口的setTransactionIsolation(int level)方法为数据库连接设置事务的隔离级别。

// Get a Connection object

Connection conn = get a connection object...;

// Set the transaction isolation level to read committed

conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

您可以使用Connection接口的方法来获取连接的事务隔离级别的当前设置。默认的事务隔离级别依赖于 JDBC 驱动程序。您还可以使用下面三个DatabaseMetaData接口的方法来更深入地了解 DBMS 支持的事务隔离级别。方法名是不言自明的。

  • int getDefaultTransactionIsolation() throws SQLException
  • boolean supportsTransactions() throws SQLException
  • boolean supportsTransactionIsolationLevel(int level) throws

Connection接口定义了一个TRANSACTION_NONE常量来表示 JDBC 驱动程序不支持事务,并且它不是一个 JDBC 兼容的驱动程序。该常量不与setTransactionIsolation()方法一起使用。getTransactionIsolation()方法可能会返回这个常量。您可以随时更改Connection对象的事务隔离。但是,如果在事务进行过程中更改连接的事务隔离,则更改连接的事务隔离的效果取决于 JDBC 驱动程序。

JDBC 类型到 Java 类型的映射

JDBC API 允许您在 Java 环境中访问和操作存储在数据库中的数据。数据库使用自己的数据类型,而 Java 使用自己的数据类型。表 6-4 列出了 JDBC 数据类型和 Java 数据类型之间的映射。

表 6-4。

Data Types Mapping Between JDBC and Java

| JDBC 类型 | Java 类型 | | --- | --- | | `ARRAY` | `java.sql.Array` | | `BIGINT` | `long` | | `BINARY` | `byte[]` | | `BIT` | `boolean` | | `BLOB` | `java.sql.Blob` | | `BOOLEAN` | `boolean` | | `CHAR` | `String` | | `CLOB` | `java.sql.Clob` | | `DATALINK` | `java.net.URL` | | `DATE` | `java.sql.Date` | | `DATE` | `java.time.LocalDate` | | `DECIMAL` | `java.math.BigDecimal` | | `DISTINCT` | `Mapping of underlying type` | | `DOUBLE` | `double` | | `FLOAT` | `double` | | `INTEGER` | `int` | | `JAVA_OBJECT` | `underlying Java class` | | `LONGNVARCHAR` | `String` | | `LONGVARBINARY` | `byte[]` | | `LONGVARCHAR` | `String` | | `NCHAR` | `String` | | `NCLOB` | `java.sql.NClob` | | `NUMERIC` | `java.math.BigDecimal` | | `NVARCHAR` | `String` | | `REAL` | `float` | | `REF` | `java.sql.Ref` | | `REF_CURSOR` | `Java.sql.ResultSet` | | `ROWID` | `java.sql.RowId` | | `SMALLINT` | `short` | | `SQLXML` | `java.sql.SQLXML` | | `STRUCT` | `java.sql.Struct` | | `TIME` | `java.sql.Time` | | `TIME` | `java.time.LocalTime` | | `TIME_WITH_TIMEZONE` | `java.time.OffsetTime` | | `TIMESTAMP` | `java.sql.Timestamp` | | `TIMESTAMP_WITH_TIMEZONE` | `java.time.OffsetDateTime` | | `TINYINT` | `byte` | | `VARBINARY` | `byte[]` | | `VARCHAR` | `String` |

Java 8 增加了名为REF_CURSORTIME_WITH_TIMEZONETIMESTAMP_WITH_TIMEZONE的 JDBC 类型。在 Java 8 之前,您可以使用java.sql包中的Date、time 和Timestamp类来处理日期、时间和时间戳 JDBC 类型。在 Java 8 中,与日期和时间相关的 JDBC 类型也被映射到java.time包中新的日期和时间类。例如,您可以为DATE JDBC 类型使用一个java.sql.Date或一个java.time.LocalDate对象。如果您正在为 JDBC DATE类型使用java.time包中的日期和时间相关对象,您将需要将它们用作对象,并使用类似getObject()setObject()的方法来获取和设置它们的值。在java.sql包中的DateTimeTimestamp类中添加了几个方法,以便于在java.time包中的 SQL 日期/时间和新日期/时间之间进行转换。

Tip

在撰写本文时,Java DB 和 Oracle DBMSs 提供的 JDBC 驱动程序中还不支持新的 JDBC 类型。

JDBC Type列中列出的所有值都被定义为Types类中的常量。Java 8 添加了一个名为JDBCType的新枚举类型,它包含与Types类中的常量同名的常量。JDBCType枚举继承了 Java 8 中添加的SQLType接口。当一个数据类型在一个方法的参数中被期望时,你将在旧方法中看到参数的类型为int,你将需要在Type类中传递一个常量。Java 8 重载了其中的一些方法,转而使用JDBCType枚举。为了类型安全,尽可能对数据类型使用JDBCType枚举中的常量。

如果您必须在 Java 代码中引用 JDBC 类型,您需要使用来自Types类的相应常量。例如,假设您需要为一个PreparedStatement对象中的一个参数设置一个null值。参数类型为int类型。PreparedStatement接口提供了一个setNull()方法,如下:

  • void setNull(int parameterIndex, int sqlType) throws SQLException

该方法的第二个参数接受sqlType,它是 JDBC 数据类型,由java.sql.Types类中的常数定义。假设PreparedStatement中参数的索引为 2。您将调用如下所示的setNull()方法:

myPreparedStmt.setNull(2, java.sql.Types.INTEGER);

这个表还告诉您从数据库中读取数据所需的 Java 变量的类型。假设数据库表中的一列被声明为varchar(20)。表 6-4 将 JDBC VARCHAR数据类型映射到 Java String类型。这意味着您需要在 Java 程序中使用一个String引用类型变量来保存数据库中一个VARCHAR类型的值。假设您正在使用一个被声明为varchar(20)ResultSet对象从数据库表中读取一个first_name列的值。您的代码将类似于以下代码:

String firstName = myResultSet.getString("first_name");

当您获取、设置或更新跨越JDBC-JAVA边界的值时,本章将使用该表中显示的映射。在 JDBC 程序中处理数据时,您将使用三组方法:getXxx()setXxx()updateXxx(),其中Xxx表示数据类型,如intStringDate等。这些方法出现在本章使用的许多界面中,如PreparedStatementResultSet等。

使用一种方法将数据从 JDBC 环境读入 Java 程序。一个setXxx()方法用于在 Java 程序中设置一个值,该值最终将被传递到 JDBC 环境中。使用一种方法来更新从 JDBC 环境中检索的数据元素,更新后的值将再次传递到 JDBC 环境。例如,您使用getInt()setInt()updateInt()来读取、设置和更新数据库中的INTEGER类型的值,该值在 Java 代码中表示为int数据类型。您可以使用getObject()setObject()updateObject()方法来处理所有数据类型,前提是为该方法提供的参数与实际数据类型的赋值兼容。只要有可能,隐式数据类型转换就由 JDBC API 在内部执行。例如,如果一个 JDBC 类型映射到 Java 中的一个短类型,您可以使用getShort()方法读取它的值。如果使用getInt()方法读取一个短值,该短值将被隐式转换为int。另一个例子是使用getString()方法读取 JDBC INTEGER值。假设您想从结果集中读取一个INTEGER类型列person_id的值。您可以使用下面两个语句中的任何一个。JDBC 驱动程序将在第二条语句中执行从intString的隐式转换。

int personIdInt = myResultSet.getInt("person_id");

String personIdStr = myResultSet.getString("person_id");

了解数据库

不同的 DBMSs 可能以不同的方式支持相同的数据库特性,或者根本不支持。有时,JDBC 驱动程序可能会对底层 DBMS 支持的功能进行包装。该接口的一个实例通过 JDBC 驱动程序为您提供了有关 DBMS 所支持的功能的详细信息。JDBC 驱动程序供应商为DatabaseMetaData接口提供了实现类。你可以使用Connection对象的方法得到一个DatabaseMetaData对象,如下所示:

Connection conn = JDBCUtil.getConnection();

// Get DatabaseMetaData object

DatabaseMetaData dbmd = conn.getMetaData();

清单 6-2 包含了完整的代码,它打印了一些关于你所连接的数据库的信息。输出显示了关于 Java DB、支持的特性和 JDBC 驱动程序的数据库信息。您可能会得到不同的输出。

清单 6-2。使用 DatabaseMetaData 对象了解 DBMS

// DatabaseMetaDataTest.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.DatabaseMetaData;

public class DatabaseMetaDataTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Get DatabaseMetaData object

DatabaseMetaData dbmd = conn.getMetaData();

System.out.println("About the database...");

String dbName = dbmd.getDatabaseProductName();

String dbVersion = dbmd.getDatabaseProductVersion();

String dbURL = dbmd.getURL();

System.out.println("Database Name:" + dbName);

System.out.println("Database Version:" + dbVersion);

System.out.println("Database URL:" + dbURL);

System.out.printf("%nAbout JDBC driver...%n");

String driverName = dbmd.getDriverName();

String driverVersion = dbmd.getDriverVersion();

System.out.println("Driver Name:" + driverName);

System.out.println("Driver Version:" + driverVersion);

System.out.printf("%nAbout supported features...%n");

boolean ansi92BiEntry = dbmd.supportsANSI92EntryLevelSQL();

boolean ansi92Intermediate =

dbmd.supportsANSI92IntermediateSQL();

boolean ansi92Full = dbmd.supportsANSI92FullSQL();

boolean supportsBatchUpdates = dbmd.supportsBatchUpdates();

System.out.println("Supports Entry Level ANSI92 SQL:" +

ansi92BiEntry);

System.out.println("Supports Intermediate Level ANSI92 SQL:" +

ansi92Intermediate);

System.out.println("Supports Full Level ANSI92 SQL:" +

ansi92Full);

System.out.println("Supports batch updates:" +

supportsBatchUpdates);

}

catch (SQLException e) {

e.printStackTrace();

}

finally {

JDBCUtil.closeConnection(conn);

}

}

}

About the database...

Database Name:Apache Derby

Database Version:10.10.1.3 - (1557168)

Database URL:jdbc:derby:beginningJavaDB

About JDBC driver...

Driver Name:Apache Derby Embedded JDBC Driver

Driver Version:10.10.1.3 - (1557168)

About supported features...

Supports Entry Level ANSI92 SQL:true

Supports Intermediate Level ANSI92 SQL:false

Supports Full Level ANSI92 SQL:false

Supports batch updates:

DatabaseMetaData接口有很多方法。更多细节请参考这个接口的 API 文档。通常,工具使用该接口向用户呈现 DBMS 支持的特性。如果您正在开发一个可能使用不同的 DBMS 和 JDBC 驱动程序的 JDBC 项目,您将需要使用一个DatabaseMetaData对象,这样您就可以在运行时通知用户,基于 JDBC 驱动程序和他们使用的 DBMS,您的应用将支持哪些特性。

执行 SQL 语句

您可以使用 JDBC 驱动程序执行不同类型的 SQL 语句。根据 SQL 语句在 DBMS 中执行的工作类型,可以将其分类如下:

  • 答:DDL 语句的例子有CREATE TABLEALTER TABLE等。
  • 答:DML 语句的例子有SELECTINSERTUPDATEDELETE等。
  • 答:DCL 语句的例子有GRANTREVOKE
  • 答:TCL 语句的例子有COMMITROLLBACKSAVEPOINT等。

您可以使用不同类型的 JDBC 语句对象执行 DDL、DML 和 DCL 语句。接口的一个实例代表了 Java 程序中的一个 SQL 语句。您可以使用Connection对象的方法来执行 TCL 语句。

Java 使用三种不同的接口来表示不同格式的 SQL 语句:

  • Statement
  • PreparedStatement
  • CallableStatement

PreparedStatement接口继承自Statement接口,CallableStatement接口继承自PreparedStatement接口。您根本不需要担心这些接口的实现细节。JDBC 驱动程序的供应商将为这些接口提供实现类。您只需要知道在一个Connection对象上调用哪个方法来获得一个特定类型的Statement对象。

如果您有一个字符串形式的 SQL 语句,您可以使用一个Statement对象来执行它。SQL 语句可能会也可能不会返回结果集。通常,SELECT语句返回一个包含零个或多个记录的结果集。字符串格式的 SQL 语句在每次执行时都会被编译。

如果您想预编译一条 SQL 语句并多次执行它,您可以使用PreparedStatement。它允许您以使用占位符的字符串形式指定 SQL 语句。在执行语句之前,您需要提供占位符的值。使用PreparedStatement对象比使用Statement对象更好,原因如下:

  • PreparedStatement通过编译一次语句并多次执行来提高 JDBC 应用的性能。

  • PreparedStatement允许您使用 Java 数据类型在 SQL 语句中提供值,而不是使用字符串。例如,假设您想编写一个查询来获取出生日期晚于 1970 年 1 月 1 日的人员记录。您可以编写一个如下的查询:select * from person where dob > '1970-01-01'但是,这个查询不会在所有数据库中正确执行。它假设可以用yyyy-mm-dd格式指定日期文字。不同的数据库对日期字符串使用不同的格式。如果您使用一个PreparedStatement,您可以重写这个查询,如下所示:select * from person where dob > ?您可以使用一个java.sql.Date对象来指定dob标准的值,JDBC 驱动程序会将它转换成日期数据类型的 DBMS 特定值。

  • 字符串形式的 SQL 语句可能会受到使用 SQL 注入技术的黑客攻击。考虑一个 SQL 注入的小例子,如下面的代码所示,用于一个getSQL()方法:public String getSQL(String personID) { String SQL = "select * from person " + "where person_id = " + personId; return SQL; }该方法接受一个personId并返回一个SELECT语句。如果这个方法被称为getSQL("101"),你不会有任何问题。您将得到如下所示的 SQL 语句:select * from person where person_id = 101假设person_idperson表的主键,该查询将从数据库中返回最多一条记录。但是,如果该方法被调用为getSQL("101 or 1 = 1"),它将返回如下SELECT语句:select * from person where person_id = 101 or 1 = 1上述语句在生产数据库中执行是危险的。它会将person表中的所有记录返回给客户端,这可能会带来安全风险。它还可能降低数据库服务器和/或应用服务器的性能,这可能导致其他用户拒绝服务。一个PreparedStatement使用占位符构造一个字符串格式的 SQL。上面的SELECT语句将被写成:String pSQL = "select * from person where person_id = ?";注意语句中问号的使用。问号用作占位符。稍后使用PreparedStatement对象的方法提供它的值。使用PreparedStatement消除了 SQL 注入的威胁。

您可以使用一个CallableStatement对象来执行数据库中的数据库存储过程或函数。存储过程可能返回结果集。

让我们在随后的章节中一次一个地看看这三种类型的Statement对象。

执行 SQL 语句的结果

当您执行 SQL 语句时,DBMS 可能返回零个或多个结果。结果可能包括更新计数(数据库中受影响的记录数)或结果集(一组记录)。

当您执行一个SELECT语句时,它返回一个结果集。当您执行一个UPDATEDELETE语句时,它返回一个更新计数,即数据库中受 SQL 影响的记录数。

当您执行存储过程时,它可能会返回多个更新计数以及多个结果集。当从 SQL 执行中返回的更新计数和结果集的结果可能是混合的时,处理结果就变得更加棘手。一个 JDBC 驱动程序将让你按照从数据库返回的顺序得到结果。有关如何处理多个结果集和更新计数的完整讨论和示例,请参考本章后面的“处理语句的多个结果”一节。

使用语句界面

您可以使用Statement来执行任何类型的 SQL 语句,只要 JDBC 驱动程序和 DBMS 支持该 SQL 语句。通常,使用它的三种方法之一来执行一条 SQL 语句,这三种方法分别叫做execute()executeUpdate()executeQuery()。这些方法是重载的。以下是接受 SQL 语句作为字符串的一个版本的列表:

  • boolean execute(String SQL) throws SQLException
  • int executeUpdate(String SQL) throws SQLException
  • ResultSet executeQuery(String SQL) throws SQLException

在我讨论在代码中使用Statement对象的三个方法中的哪一个之前,下面是使用对象执行 SQL 语句的步骤:

Get a connection object. Connection conn = JDBCUtil.getConnection();   Create a Statement object using the createStatement() method of the Connection object. Statement stmt = conn.createStatement();   Execute one or more SQL statements by calling one of the three methods of the Statement object. // Increase everyone's income by 10% String sql = "update person set income = income * 1.1"; int rowsUpdated = stmt.executeUpdate(sql); // Execute other SQL statements using stmt   Close the Statement object to release the resources. stmt.close();   Commit the transaction. conn.commit();

Statement接口中的方法是一个通用方法,可以用来执行任何类型的 SQL 语句。通常,它用于执行不返回结果集的 SQL 语句,比如像CREATE TABLE这样的 DDL 语句。从execute()方法返回的值表明了返回结果集的状态。如果第一个结果是一个ResultSet对象,它返回true。如果第一个结果是更新计数或者没有结果从 DBMS 返回,则返回false

该方法用于执行更新数据库中数据的 SQL 语句,如INSERTUPDATEDELETE语句。它返回数据库中受语句执行影响的行数。您可以使用这个方法来执行其他类型的 SQL 语句,比如不返回任何内容的CREATE TABLE语句。当 SQL 语句不返回任何内容时,该方法返回零。你不应该使用这个方法来执行一个SELECT语句。

Tip

Java 8 增加了一个executeLargeUpdate()方法,其工作原理与executeUpdate()方法相同,除了它返回一个long而不是一个int。当您希望更新计数超过Integer.MAX_VALUE时,请使用此方法。

该方法是专门为执行一条产生一个且仅一个结果集的 SQL 语句而设计的。它最适合执行一个SELECT语句。虽然您可以使用Statement接口的这个方法来执行一个产生结果集的存储过程,但是您应该使用专门设计的CallableStatement接口的execute()方法来执行一个存储过程。

一个Statement对象执行一个存储在字符串中的 SQL 语句。数据库有自己的数据类型。如何以字符串格式传递所有内容?有时,您可能需要在 SQL 语句中使用一些可能无法以字符串格式表示的对象,例如二进制大型对象。您可以使用一个PreparedStatement来更好地控制 SQL 语句的准备,因为 SQL 语句不能用字符串格式表达。

最常见的情况是,在用字符串格式表示日期、时间和时间戳值时会遇到问题。假设您希望将出生日期大于 1970 年 1 月 25 日的所有人的收入增加 20%。您的 update 语句可能如下所示:

String sql = "update person " +

"set income = income * 1.2 " +

"where dob > '1970-01-25'";

不是所有的 DBMSs 都将'1970-01-25'识别为日期。JDBC 为日期、时间和时间戳数据类型定义了转义序列。它的形式是

{<type> '<value>'}

表 6-5 列出了您需要在 SQL 字符串中使用的日期、时间和时间戳转义序列的格式和示例。JDBC 驱动程序会将转义序列转换成适合数据库的格式。您可以使用日期转义序列重写上述 update 语句,如下所示:

String sql = "update person " +

"set income = income * 1.2 " +

"where dob > {d '1970-01-25'}";

表 6-5。

JDBC Escape Sequences for Date, Time, and Timestamp Data Types

| 数据类型 | | 格式 | 例子 | | --- | --- | --- | --- | | `Date` | `d` | `yyyy-mm-dd` | `{d '1970-01-25'}` | | `Time` | `t` | `hh:mm:ss` | `{t '01:09:50'}` | | `Timestamp` | `ts` | `yyyy-mm-dd hh:mm:ss.f...` | `{ts '1970-01-25 01:09:50'}` | | 时间戳格式中的(`.f...`)部分是秒的小数部分,这是可选的。`yyyy –`四位数年份`mm` -两位数月份`dd`-两位数日期`hh`-小时`mm`-分钟`ss`-秒`f`-秒的小数部分 |

本章中的大多数示例都使用数据库中的 person 表。假设您已经在正在使用的数据库中创建了person表。人员表的通用定义如表 6-2 所示。如果您还没有创建表,您可以运行清单 6-3 中的程序。该程序使用 Java DB 的CREATE TABLE语法。如果您使用的是除 Java DB 之外的 DBMS,请在运行程序之前更改语法。当person表创建成功时,它打印以下消息:

Person table created.

如果person表已经存在,程序会为 Java DB 打印以下错误消息:

Table/View 'PERSON' already exists in Schema 'ROOT'.

对于 Java DB 之外的 DBMS,错误消息可能有所不同,但它传达的意思是相同的,即数据库中已经存在person表。

清单 6-3。在数据库中创建人员表

// CreatePersonTable.java

package com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.Statement;

public class CreatePersonTable {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Create a SQL string

String SQL = "create table person ( " +

"person_id integer not null, " +

"first_name varchar(20) not null, " +

"last_name varchar(20) not null, " +

"gender char(1) not null, " +

"dob date, " +

"income double," +

"primary key(person_id))";

Statement stmt = null;

try {

stmt = conn.createStatement();

stmt.executeUpdate(SQL);

}

finally {

JDBCUtil.closeStatement(stmt);

}

// Commit the transaction

JDBCUtil.commit(conn);

System.out.println("Person table created.");

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

}

清单 6-4 包含了在person表中插入三条记录的完整代码。注意,它使用了JDBCUtil类的实用方法(见清单 6-1)来执行一些活动,比如获取一个Connection对象,关闭Statement对象,提交/回滚一个事务,等等。如果你不止一次运行清单 6-4 中的程序,它会打印一条错误信息,说明你试图在person表中插入重复的键,因为你已经定义了person_id作为表中的主键,每次你运行程序,它都会插入相同的一组person_id值。

清单 6-4。使用语句对象执行 SQL INSERT 语句

// InsertPersonTest.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.Statement;

public class InsertPersonTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Insert 3 person records

insertPerson(conn, 101, "John", "Jacobs",

"M", "{d '1970-01-01'}", 60000);

insertPerson(conn, 102, "Donna", "Duncan",

"F", "{d '1960-01-01'}", 70000);

insertPerson(conn, 103, "Buddy", "Rice",

"M", "{d '1975-01-01'}", 45000);

// Commit the transaction

JDBCUtil.commit(conn);

System.out.println("Inserted persons successfully.");

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void insertPerson(Connection conn, int personId,

String firstName, String lastName, String gender, String dob,

double income) throws SQLException {

// Create a SQL string

String SQL = "insert into person " +

"(person_id, first_name, last_name," +

" gender, dob, income) " +

"values " +

"(" + personId + ", " +

"'" + firstName + "'" + ", " +

"'" + lastName + "'" + ", " +

"'" + gender + "'" + ", " +

dob + ", " +

income + ")";

Statement stmt = null;

try {

stmt = conn.createStatement();

stmt.executeUpdate(SQL);

}

finally {

JDBCUtil.closeStatement(stmt);

}

}

}

您可以使用Statement对象执行任何其他 SQL 语句,比如UPDATEDELETE语句。清单 6-5 和清单 6-6 展示了如何使用一个Statement对象执行UPDATEDELETE语句。

清单 6-5。使用语句对象执行 SQL UPDATE 语句

// UpdatePersonTest.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.Statement;

public class UpdatePersonTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Give everyone a 5% raise

giveRaise(conn, 5.0);

// Commit the transaction

JDBCUtil.commit(conn);

System.out.println("Updated person records successfully.");

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void giveRaise(Connection conn, double percentRaise)

throws SQLException {

String SQL = "update person " +

"set income = income + income * " + (percentRaise/100);

Statement stmt = null;

try {

stmt = conn.createStatement();

int updatedCount = stmt.executeUpdate(SQL);

// Print how many records were updated

System.out.println("Gave raise to " +

updatedCount + " person(s).");

}

finally {

JDBCUtil.closeStatement(stmt);

}

}

}

清单 6-6。使用语句对象执行 SQL DELETE 语句

// DeletePersonTest.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.Statement;

public class DeletePersonTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Delete the person with person_id = 101

deletePerson(conn, 101);

// Commit the transaction

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void deletePerson(Connection conn, int personId)

throws SQLException {

String SQL = "delete from person " +

"where person_id = " + personId;

Statement stmt = null;

try {

stmt = conn.createStatement();

int deletedCount = stmt.executeUpdate(SQL);

// Print how many persons were deleted

System.out.println("Deleted " +

deletedCount + " person(s).");

}

finally {

JDBCUtil.closeStatement(stmt);

}

}

}

使用 PreparedStatement 接口

PreparedStatement接口继承自Statement接口。它比Statement接口更适合执行 SQL 语句。如果 DBMS 支持 SQL 语句预编译,它会预编译 SQL 语句。如果语句执行多次,它将重用预编译的 SQL 语句。它允许您准备一个字符串格式的 SQL 语句,使用占位符作为输入参数。

SQL 字符串中的问号是输入参数的占位符,该参数的值将在语句执行前提供。假设您想使用一个PreparedStatementperson表中插入一条记录。字符串格式的 SQL 语句如下所示:

String sql = "insert into person " +

"(person_id, first_name, last_name, gender, dob, income) " +

"values " +

"(?, ?, ?, ?, ?, ?)";

在本例中,六个问号中的每一个都是一个值的占位符。第一个问号是person_id的占位符,第二个问号是first_name的占位符,依此类推。每个占位符都有一个索引。SQL 字符串中的第一个占位符的索引为 1,第二个占位符的索引为 2,依此类推。注意,占位符的索引从 1 开始,而不是从 0 开始。

您可以使用Connection对象的方法创建一个PreparedStatementprepareStatement()方法被重载了。最简单的形式是,它接受如下 SQL 字符串:

String sql = "your sql statement goes here";

Connection conn = JDBCUtil.getConnection();

// Obtain a PreparedStatement for the sql

PreparedStatement pstmt = conn.prepareStatement(sql);

下一步是使用PreparedStatement接口的setXxx()方法为占位符逐个提供值,其中Xxx是占位符的数据类型。setXxx()方法接受两个参数:第一个是占位符的索引,第二个是占位符的值。setXxx()方法的第二个参数必须与Xxx兼容,?? 是占位符的数据类型。如果您想为INSERT语句的六个占位符设置值,以便在person表中插入一条记录,您可以按如下方式操作:

pstmt.setInt(1, 301);        // person_id

pstmt.setString(2, "Tom");  // first name

pstmt.setString(3, "Baker"); // last name

pstmt.setString(4, "M");    // gender

/* Set dob as January 25, 1970\. This time, you have a lot more control

on the data type. You need to use the java.sql.Date data type to set

the dob. You can use the valueOf() static method to get a java.sql.Date

object from a date in a string format

*/

java.sql.Date dob = java.sql.Date.valueOf("1970-01-25");

pstmt.setDate(5, dob);    // dob

pstmt.setDouble(6, 45900); // income

现在是时候将带有占位符值的 SQL 语句发送到数据库了。使用execute()executeUpdate()executeQuery()方法之一在PreparedStatement中执行 SQL 语句。这些方法没有参数。回想一下,Statement接口有相同的方法,它们将 SQL 字符串作为参数。PreparedStatement接口添加了三个同名的方法,它们没有参数,因为它在创建时就获得了 SQL 字符串。

// Execute the INSERT statement in pstmt

pstmt.executeUpdate();

如何重用一个PreparedStatement?只需重新填充占位符值,并再次调用它的一个execute()方法。当您再次对一个PreparedStatement对象调用setXxx()方法时,它先前为指定占位符设置的值将被新值覆盖。一个PreparedStatement甚至在它被执行后仍然保持它的占位符的设置值。因此,如果要为多次执行的占位符设置相同的值,只需为该占位符设置一次值。如果想清除所有占位符的值,可以使用PreparedStatement接口的clearParameters()方法。以下代码片段再次设置所有六个占位符的值,并执行语句:

// Set new values for placeholder

pstmt.setInt(1, 401);        // person_id

pstmt.setString(2, "Pam");  // first name

pstmt.setString(3, "Baker"); // last name

pstmt.setString(4, "F");    // gender

pstmt.setDate(5, java.sql.Date.valueOf("1970-01-25")); // dob

pstmt.setDouble(6, 25900);  // income

// Execute the INSERT statement in pstmt to insert another row

pstmt.executeUpdate();

当您执行完一个PreparedStatement对象中的语句时,您需要使用它的方法关闭它。

// Close the PreparedStatement

pstmt.close();

清单 6-7 展示了如何使用一个PreparedStatement对象来执行一个INSERT SQL 语句。注意,这个例子重用了PreparedStatementperson表中插入两条记录。

清单 6-7。使用 PreparedStatement 对象执行 INSERT 语句

// PreparedStatementTest.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.Date;

import java.sql.PreparedStatement;

import java.sql.SQLException;

import java.sql.Types;

public class PreparedStatementTest {

public static void main(String[] args) {

Connection conn = null;

PreparedStatement pstmt = null;

try {

conn = JDBCUtil.getConnection();

pstmt = getInsertSQL(conn);

// Need to get dob in java.sql.Date object

Date dob = Date.valueOf("1970-01-01");

// Insert two person records

insertPerson(pstmt, 401, "Sara", "Jain", "F", dob, 0.0);

insertPerson(pstmt, 501, "Su", "Chi", "F", null, 10000.0);

// Commit the transaction

JDBCUtil.commit(conn);

System.out.println("Updated person records successfully.");

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeStatement(pstmt);

JDBCUtil.closeConnection(conn);

}

}

public static void insertPerson(PreparedStatement pstmt,

int personId, String firstName, String lastName,

String gender, Date dob, double income) throws SQLException {

// Set all the input parameters

pstmt.setInt(1, personId);

pstmt.setString(2, firstName);

pstmt.setString(3, lastName);

pstmt.setString(4, gender);

// Set the dob value properly if it is null

if (dob == null) {

pstmt.setNull(5, Types.DATE);

}

else {

pstmt.setDate(5, dob);

}

pstmt.setDouble(6, income);

// Execute the statement

pstmt.executeUpdate();

}

public static PreparedStatement getInsertSQL(Connection conn)

throws SQLException {

String SQL = "insert into person " +

"(person_id, first_name, last_name, gender, dob, income) " +

"values " +

"(?, ?, ?, ?, ?, ?)";

PreparedStatement pstmt = conn.prepareStatement(SQL);

return pstmt;

}

}

可调用语句接口

CallableStatement接口继承自PreparedStatement接口。它用于调用数据库中的 SQL 存储过程或函数。您也可以使用Statement对象调用存储过程或函数。但是,使用CallableStatement是首选方式。

JDBC API 使得使用标准语法调用 SQL 存储过程和函数成为可能。为了执行存储过程,不同的 DBMS 可能使用不同的语法。如果使用 JDBC API 调用存储过程,只需学习一种在所有 DBMSs 中执行存储过程的标准方法。JDBC 规范为存储过程/函数定义了转义序列,以便在数据库中执行它们。

要查明您的 DBMS 是否支持存储过程,您可以调用一个DatabaseMetaData对象的方法。如果 DBMS 支持存储过程,它返回true。JDBC 驱动程序可以让你使用相同的语法调用 DBMS 函数。要知道是否可以使用相同的语法调用 DBMS 函数,可以使用DatabaseMetaData对象的supportsStoredFunctionsUsingCallSyntax()方法。如果它返回true,您可以使用相同的语法来调用数据库函数。从现在开始,我将使用短语“存储过程”来表示数据库存储过程和函数。

调用存储过程的一般语法如下:

{? = call <procedure_name>(param1, param2, param3, ...)}

对存储过程的调用放在大括号({})中。第一个问号是存储过程返回值的占位符。返回值的占位符后跟= call。如果存储过程不返回值,则省略? =部分。<procedure_name>是存储过程的名称。如果存储过程接受任何参数,则参数列表将包含在过程名后面的括号中。如果存储过程不接受任何参数,则省略<procedure_name>后的左括号、参数列表和右括号。表 6-6 列出了一些使用通用语法调用存储过程的例子。

表 6-6。

Examples of Using Stored Procedure Escape Syntax for Calling Database Stored Procedures

| 存储过程描述 | 调用存储过程的语法 | | --- | --- | | 不接受参数,不返回值 | `{call }` | | 接受两个`IN`参数,不返回值 | `{call (?, ?)}` | | 接受两个`IN`和一个`OUT`参数,不返回值 | `{call (?, ?, ?)}` | | 不接受参数返回值 | `{? = call }` | | 接受两个`IN`参数返回值 | `{? = call (?, ?)}` | | 接受两个`IN`和一个`OUT`参数返回值 | `{? = call (?, ?, ?)}` |

一个存储过程可以接受不同类型的参数:INOUTINOUT。所有类型的参数都可以使用占位符(问号)。仅通过查看使用占位符的 SQL 字符串无法区分参数的类型。这取决于您是否知道哪个占位符属于类型INOUTINOUT参数,并相应地处理它们。接下来的三节将描述如何处理CallableStatement中的INOUTINOUT参数类型。

在参数中使用

IN参数类型意味着调用者在调用存储过程时必须为该参数传递一个值。在执行一个CallableStatement之前,您必须调用一个setXxx()方法来设置所有IN类型参数的值。否则,当您试图在某些参数未设置的情况下执行CallableStatement时,将会出现错误。

假设在一个 SQL 语句中有两个IN参数,它们的占位符位于索引 1 和索引 2。索引 1 处的IN参数为int类型,索引 2 处的double类型。您的代码逻辑将类似于所示的代码:

CallableStatement cstmt = prepare the call...;

// Set the value of the IN parameter at index 1

cstmt.setInt(1, 101);

// Set the value of the IN parameter at index 2

cstmt.setDouble(2, 22.56);

// Execute the statement here

使用 OUT 参数

OUT参数类型意味着调用者必须为该参数向存储过程传递一个占位符,存储过程将设置该值,调用者可以在存储过程完成执行后读取该值。在执行CallableStatement之前,必须通过调用CallableStatement接口的registerOutParameter(int placeholderIndex, int sqlType)registerOutParameter(int parameterIndex, java.sql.SQLType sqlType)方法注册一个OUT参数。在执行存储过程之后,您需要使用一个getXxx()方法来读取OUT参数的值。

假设 SQL 语句中有一个索引为 2 的OUT参数,它的类型为double。下面是注册它并读取其值的方法:

CallableStatement cstmt = prepare the call...;

// Register the OUT parameter at index 2

cstmt.registerOutParameter(2, java.sql.Types.DOUBLE);

// Execute the statement here

// Read the value of the OUT parameter

double outParamValue = cstmt.getDouble(2);

使用 INOUT 参数

一个INOUT参数作为INOUT参数类型的组合。调用者可以使用一个INOUT参数类型向存储过程传递一个值。存储过程在执行过程中改变INOUT参数的值,在存储过程执行完毕后,调用者可以读取存储过程设置的值。在执行存储过程之前,必须使用CallableStatement接口的registerOutParameter(int placeholderIndex, int sqlType)registerOutParameter(int parameterIndex, java.sql.SQLType sqlType)方法注册INOUT参数。您需要使用CallableStatement接口的setXxx()方法之一来设置INOUT参数的值。在存储过程被执行后,您需要使用CallableStatement接口的getXxx()方法之一来读取从存储过程传回的值。

假设 SQL 语句中有一个索引为 1 的INOUT参数,它的类型为double。下面是注册它、向它传递一个值并读取它的值的方法:

CallableStatement cstmt = prepare the call...;

// Register the INOUT parameter at index 1

cstmt.registerOutParameter(1, java.sql.Types.DOUBLE);

// Set a value of 55.78 for the INOUT parameter

cstmt.setDouble(1, 55.78);

// Execute the statement here

// Read the value of the INOUT parameter

double inOutParamValue = cstmt.getDouble(1);

返回参数不属于参数类型

如果一个存储过程返回值,并且您想要捕获返回值,那么它的占位符(第一个问号)必须使用CallableStatement接口的registerOutParameter()方法注册为一个OUT参数。如果返回值占位符出现在调用语法中,它总是第一个OUT参数,您需要在registerOutParameter()getXxx()方法中使用 1 作为它的索引

执行可调用语句

在执行存储过程之前,您需要通过调用Connection对象的prepareCall()方法来准备一个CallableStatementprepareCall()方法接受一个 SQL 字符串作为参数。下面的代码片段展示了如何准备一个CallableStatement:

Connection conn = JDBCUtil.getConnection();

String SQL = "{call myProcedure}";

CallableStatement cstmt = conn.prepareCall(SQL);

CallableStatement接口没有添加任何新方法来执行 SQL 语句。要执行 SQL 语句,您需要调用以下三种不带参数的方法之一。这三个方法都继承自PreparedStatement接口。

  • execute()
  • executeUpdate()
  • executeQuery()

CallableStatement对象中执行 SQL 语句所需的方法取决于存储过程的执行结果。

  • 如果它返回混合结果(结果集和更新计数),使用execute()方法。
  • 如果它返回一个更新计数,使用executeUpdate()方法。
  • 如果它返回一个ResultSet,使用executeQuery()方法。

让我们看一些使用不同类型的参数和有/没有返回值调用存储过程的例子。

示例#1

存储过程:process_salary

注释:它不接受任何参数,也不返回值

Connection conn = JDBCUtil.getConnection();

String sql = "{call process_salary}";

CallableStatement cstmt = conn.prepareCall(sql);

cstmt.execute();

实施例 2

存储过程:give_raise(integer person_id IN, double raise IN)

备注:它接受两个IN参数,不返回值。

Connection conn = JDBCUtil.getConnection();

String sql = "{call give_raise(?, ?)}";

CallableStatement cstmt = conn.prepareCall(sql);

// Set the value for person_id parameter at index 1

cstmt.setInt(1, 101);

// Set the value for raise parameter at index 2

cstmt.setDouble(2, 4.5);

// Execute the stored procedure

cstmt.execute();

实施例 3

存储过程:get_employee_count(integer dept_id IN) RETURNS integer

注释:它接受一个IN参数并返回一个整数值。

Connection conn = JDBCUtil.getConnection();

String sql = "{? = call get_employee_count(?)}";

CallableStatement cstmt = conn.prepareCall(sql);

// Register the first placeholder - the return value as an OUT parameter

cstmt.registerOutParameter(1, java.sql.Types.INTEGER);

// Set the value for dept_id parameter at index 2

cstmt.setInt(2, 1001);

// Execute the stored procedure

cstmt.execute();

// Read the returned value - our first OUT parameter has an index of 1

int employeeCount = cstmt.getInt(1);

System.out.println("Employee Count is " + employeeCount);

实施例 4

存储过程:give_raise(person_id int IN, raise double IN, old_income double OUT, new_income double OUT)

备注:它接受两个IN参数和两个OUT参数。

Connection conn = JDBCUtil.getConnection();

String sql = "{call give_raise(?, ?, ?, ?)}";

CallableStatement cstmt = conn.prepareCall(sql);

// Register the OUT parameters: old_income(index 3), new_income(index 4)

cstmt.registerOutParameter(3, Types.DOUBLE);

cstmt.registerOutParameter(4, Types.DOUBLE);

// Set values for person_id at index 1 and for raise at index 2

cstmt.setInt(1, 1001);

cstmt.setDouble(2, 4.5);

// Execute the stored procedure

cstmt.execute();

// Read the values of the OUT parameters old_income(index 3)

// and new_income (index 4)

double oldIncome = cstmt.getDouble(3);

double newIncome = cstmt.getDouble(4);

System.out.println("Old Income:" + oldIncome);

System.out.println("New Income:" + newIncome);

Tip

您可以使用文字值或占位符来传递IN参数的值。如果您为一个IN参数使用一个占位符,您将需要在执行存储过程之前使用setXxx()方法来设置它的值。最好为IN参数使用占位符,并使用setXxx()来设置其值。例如,假设一个存储过程,process_person(integer person_id IN),接受一个IN类型的参数。您可以将调用语法准备为"{call process_person(1001)}""{call process_person(?)}"。在后一种情况下,您将需要使用setInt(1, 1001)方法来设置person_id参数的值。

让我们讨论一个例子,在这个例子中,您在数据库中创建一个存储过程,并在 Java 程序中使用CallableStatement调用它。您创建了一个名为give_raise的存储过程。它接受两个名为person_idraiseIN参数。它接受两个OUT参数来传回一个person_id的新旧收入值。如果此人的收入为null,则将收入设置为20000。如果没有找到一个人,它在两个OUT参数中返回null

以下是一些 DBMSs 的give_raise过程的 SQL 脚本。在运行清单 6-9 中的程序之前,您需要运行 DBMS 的脚本。如果您没有找到适用于您正在使用的 DBMS 的脚本,您可以通过查看此表中的代码,轻松地为您的 DBMS 编写代码。在本章的后面,您将看到一个生成结果集的存储过程的示例。

Adaptive Server Anywhere 数据库

create procedure give_raise(IN @person_id integer, IN @raise double,

OUT @old_income double, OUT @new_income double)

begin

select @old_income = null, @new_income = null;

if exists(select null from person where person_id = @person_id) then

select income into @old_income

from person

where person_id = @person_id;

if @old_income is null then

select 20000.00 into @new_income;

else

select @old_income * (1 + @raise/100) into @new_income;

end if;

update person

set income = @new_income

where person_id = @person_id;

end if;

end;

MySQL 数据库

DELIMITER $$

DROP PROCEDURE IF EXISTS give_raise $$

CREATE PROCEDURE give_raise(in person_id_param int, in raise double,

out old_income double, out new_income double)

BEGIN

set old_income = null, new_income = null;

if exists(select null from person where person_id=person_id_param) then

select income into old_income

from person

where person_id = person_id_param;

if old_income is null then

select 20000.00 into new_income;

else

select old_income * (1 + raise/100) into new_income;

end if;

update person

set income = new_income

where person_id = person_id_param;

end if;

END $$

DELIMITER ;

甲骨文数据库

create or replace procedure give_raise(person_id_param number,

raise_param number,

old_income out number,

new_income out number)

is

person_count number;

begin

old_income := null;

new_income := null;

select count(*)

into person_count

from person

where person_id = person_id_param;

if person_count = 1 then

select income into old_income

from person

where person_id = person_id_param;

if old_income is null

new_income := 20000.00;

else

new_income := old_income * (1 + raise_param/100) ;

end if;

update person

set income = new_income

where person_id = person_id_param;

end if;

end give_raise;

SQL Server 数据库

-- Drop stored procedure if it already

IF EXISTS (

SELECT *

FROM INFORMATION_SCHEMA.ROUTINES

WHERE SPECIFIC_SCHEMA = N'dbo'

AND SPECIFIC_NAME = N'give_raise'

)

DROP PROCEDURE dbo.give_raise

GO

CREATE PROCEDURE dbo.give_raise

@person_id int,

@raise decimal(5, 2),

@old_income decimal(10, 2) OUTPUT,

@new_income decimal(10, 2) OUTPUT

AS

BEGIN

SET NOCOUNT OFF

SELECT @old_income = null, @new_income = null;

IF EXISTS (SELECT null FROM person WHERE person_id = @person_id)

BEGIN

SELECT @old_income =

FROM person

WHERE person_id = @person_id;

IF @old_income is null

SELECT @new_income = 20000.00;

ELSE

SELECT @new_income = @old_income * (1 + @raise/100);

update person

set income = @new_income

WHERE person_id = @person_id;

END;

END;

GO

DB2 数据库

create procedure give_raise(IN person_id_param int,

IN raise_param double,

OUT old_income double,

OUT new_income double)

language sql

begin

declare person_count int;

set old_income = null;

set new_income = null;

select count(*) into person_count

from person

where person_id = person_id_param;

if person_count = 1 then

select income into old_income

from person

where person_id = person_id_param;

if old_income is null then

set new_income = 20000.00;

else

set new_income = old_income * (1 + raise_param/100) ;

end if;

update person

set income = new_income

where person_id = person_id_param;

end if;

end

@

注意:@在上面的语法中用作语句结束符来创建存储过程。

Java DB 数据库

Java DB 允许您使用 Java 编程语言编写存储过程。您可以将类的静态方法用作 Java DB 中的存储过程。为了获得执行存储过程的数据库连接的引用,您将jdbc:default:connection作为连接 URL 传递给DriverManager。清单 6-8 包含了JavaDBGiveRaiseSp类的代码,该类的giveRaise()静态方法将被用作存储过程。在将该方法用作存储过程之前,您需要做一些设置工作,这将在下面描述。现在,只需编译该类并将其包含在CLASSPATH中。

清单 6-8。Java DB 中 give_raise 存储过程的 Java 代码

// JavaDBGiveRaiseSp.java

package com.jdojo.jdbc;

import java.sql.SQLException;

import java.sql.PreparedStatement;

import java.sql.Connection;

import java.sql.DriverManager;

import java.sql.ResultSet;

public class JavaDBGiveRaiseSp {

public static void giveRaise(int personId, double raise,

double[] oldIncomeOut, double[] newIncomeOut) throws SQLException {

double oldIncome = 0.0;

double newIncome = 0.0;

// Must use the following URL to get the reference of the Conenction

// object in whose context this method is called.

String dbURL = "jdbc:default:connection";

Connection conn = DriverManager.getConnection(dbURL);

String sql = "select income from person where person_id = ?";

PreparedStatement pstmt = conn.prepareStatement(sql);

pstmt.setInt(1, personId);

ResultSet rs = pstmt.executeQuery();

if (!rs.next()) {

return;

}

oldIncome = rs.getDouble("income");

if (rs.wasNull()) {

newIncome = 20000.00;

}

else {

newIncome = oldIncome * (1 + raise / 100);

}

String updateSql = "update person " +

"set income = ? " +

"where person_id = ?";

PreparedStatement updateStmt =

conn.prepareStatement(updateSql);

updateStmt.setDouble(1, newIncome);

updateStmt.setInt(2, personId);

updateStmt.executeUpdate();

// Close the statement

updateStmt.close();

oldIncomeOut[0] = oldIncome;

newIncomeOut[0] = newIncome;

}

}

在为存储过程编写了 Java 代码之后,需要在 Java DB 数据库中创建存储过程。使用以下命令创建give_raise存储过程:

--Command to create a stored procedure

CREATE PROCEDURE give_raise(IN person_id integer, IN raise double, OUT old_income Double, OUT new_income Double)

PARAMETER STYLE JAVA

LANGUAGE JAVA

MODIFIES SQL DATA

EXTERNAL NAME 'com.jdojo.jdbc.JavaDBGiveRaiseSp.giveRaise';

您可以使用ij命令行工具或 NetBeans IDE 来执行该命令。有关如何在 Java DB 中执行 SQL 命令的更多详细信息,请参考本章前面的“Java DB 概述”一节。

为了让存储过程在 Java DB 中工作,您需要在将JavaDBGiveRaiseSp类打包成 JAR 文件后,将其安装到数据库中。请参考 Java DB 文档,了解如何将 Java JAR 安装到数据库中。使 Java DB 可以使用 Java 存储过程代码的另一种方法(也是更简单的方法)是将该类包含在用户的类路径中。如果您正在使用 NetBeans IDE 运行本章中的示例,则不需要执行此步骤。JavaDBGiveRaiseSp类包含在 NetBeans 项目中,因此,当示例从 NetBeans IDE 内部运行时,该类已经存在于类路径中。

清单 6-9 显示了执行存储过程give_raise的完整代码。您可以通过在main()方法中为person_idraise使用不同的值来运行CallableStatementTest类。

清单 6-9。使用 CallableStatement 语句调用存储过程

// CallableStatementTest.java

package  com.jdojo.jdbc;

import java.sql.CallableStatement;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.Types;

public class CallableStatementTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Give a 5% raise to person_id 101

giveRaise(conn, 102, 5.0);

// Give a 5% raise to dummy person_id

giveRaise(conn, -100, 5.0);

// Commit the transaction

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void giveRaise(Connection conn, int personId,

double raise) throws SQLException {

String SQL = "{call app.give_raise(?, ?, ?, ?)}";

CallableStatement cstmt = null;

try {

// Prepare the call

cstmt = conn.prepareCall(SQL);

// Set the IN parameters

cstmt.setInt(1, personId);

cstmt.setDouble(2, raise);

// Register the OUT parameters

cstmt.registerOutParameter(3, Types.DOUBLE);

cstmt.registerOutParameter(4, Types.DOUBLE);

// Execute the stored procedure

int updatedCount = cstmt.executeUpdate();

// Read the OUT parameters values

double oldIncome = cstmt.getDouble(3);

boolean oldIncomeisNull = cstmt.wasNull();

double newIncome = cstmt.getDouble(4);

boolean newIncomeisNull = cstmt.wasNull();

// Display the results

System.out.println("Updated Record: " + updatedCount);

System.out.println("Old Income: " + oldIncome +

", New Income: " + newIncome);

System.out.println("Old Income was null: " +

oldIncomeisNull +

", New Income is null: " +

newIncomeisNull);

}

finally {

JDBCUtil.closeStatement(cstmt);

}

}

}

处理结果集

通过在数据库中执行 SQL SELECT 语句获得的一组行称为结果集。JDBC 允许您在数据库中执行 SELECT 语句,并使用一个ResultSet接口实例在 Java 程序中处理返回的结果集。以下部分讨论了使用 JDBC API 处理结果集的不同方式。

什么是结果集?

当您在数据库中执行查询(一个SELECT语句)时,它会以结果集的形式返回匹配的记录。您可以将结果集视为按行和列排列的数据。SELECT语句决定结果集中包含的行数和列数。Statement(或PreparedStatementCallableStatement)对象将查询结果作为ResultSet对象返回。我在这里使用了两个短语:“结果集”和“结果集”对于“结果集”,我指的是行和列形式的数据,而对于“结果集”,我指的是实现了允许您访问和操作数据的ResultSet接口的类的实例。一个ResultSet对象还包含关于结果集中列的属性的信息,比如列的数据类型、列的名称等等。

一个ResultSet对象维护一个光标,它指向结果集中的一行。它的工作方式类似于数据库程序中的光标对象。您可以将光标滚动到结果集中的特定行,以访问或操作该行的列值。光标一次只能指向一行。它在特定时间点指向的行称为当前行。将ResultSet对象的光标移动到结果集中的一行有不同的方法。我将很快讨论移动光标的所有不同方式。

在查看示例之前,需要讨论一下ResultSet对象的以下三个属性:

  • Scrollability
  • Concurrency
  • Holdability

确定ResultSet滚动行的能力。默认情况下,ResultSet只能向前滚动。当你有一个只能向前滚动的ResultSet时,你可以将光标从第一行移动到最后一行。一旦移动到最后一行,就不能重用ResultSet对象,因为不能在只能向前滚动的ResultSet中向后滚动。您还可以创建一个可以向前和向后滚动的ResultSet。我将把这个ResultSet称为双向可滚动的ResultSet。双向可滚动的ResultSet有另一个属性叫做更新敏感度。它决定了当您滚动浏览结果集的行时,基础数据库中的更改是否会反映在结果集中。对滚动敏感的ResultSet显示数据库中所做的更改,而对滚动不敏感的不会显示打开ResultSet后数据库中所做的更改。ResultSet接口中的以下三个常量用于指定ResultSet的可滚动性:

  • TYPE_FORWARD_ONLY:允许ResultSet对象只向前移动。
  • TYPE_SCROLL_SENSITIVE:允许ResultSet对象向前和向后移动。它使其他事务或同一事务中的语句对底层数据库所做的更改对ResultSet对象可见。这种类型的ResultSet知道通过其他方式对其数据所做的更改。
  • TYPE_SCROLL_INSENSITIVE:允许ResultSet对象向前和向后移动。滚动时,ResultSet对象看不到其他事务或同一事务中的语句对底层数据库所做的更改。这种类型的ResultSet在打开时确定其数据集,如果通过除了这个ResultSet对象本身之外的任何其他方式更新,数据集不会改变。如果您想获得最新的数据,您必须重新执行查询。

ConcurrencyResultSet更新数据的能力。默认情况下,ResultSet是只读的,不允许您更新它的数据。如果您想通过ResultSet更新数据库中的数据,您需要从 JDBC 驱动程序请求一个可更新的结果集。ResultSet接口中的以下两个常量用于指定ResultSet的并发性:

  • CONCUR_READ_ONLY:将结果集设为只读。
  • CONCUR_UPDATABLE:使结果集可更新。

指的是ResultSet在其相关的事务被提交后的状态。当事务被提交时,ResultSet可以被关闭或保持打开。ResultSetholdability的默认值取决于 JDBC 驱动程序。使用在ResultSet接口中定义的以下两个常量之一来指定ResultSetholdability:

  • HOLD_CURSORS_OVER_COMMIT:事务提交后保持ResultSet打开。
  • CLOSE_CURSORS_AT_COMMIT:交易提交后关闭ResultSet

在使用这些属性之前,您需要验证您的 JDBC 驱动程序文档是否支持这些属性。您可以通过使用下面三种DatabaseMetaData接口的方法来获得关于一个ResultSet对象的 JDBC 驱动程序所支持的属性的信息。回想一下,您可以使用一个Connection对象的getMetaData()方法获得一个DatabaseMetaData对象。

  • supportsResultSetType()
  • supportsResultSetConcurrency()
  • supportsResultSetHoldability()

清单 6-10 展示了如何使用这些方法来检查这些ResultSet属性。对这些方法的调用被放在一个try-catch块中以捕捉一个Throwable,因为一些 JDBC 驱动程序在不支持某个特性时会抛出一个运行时异常。输出是针对 Java DB DBMS 的。当您连接到不同的 DBMS 时,可能会得到不同的输出。

清单 6-10。检查 JDBC 驱动程序支持的结果集的属性

// SupportedResultSetProperties.

package com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.DatabaseMetaData;

import static java.sql.ResultSet.CLOSE_CURSORS_AT_COMMIT;

import static java.sql.ResultSet.CONCUR_READ_ONLY;

import static java.sql.ResultSet.CONCUR_UPDATABLE;

import static java.sql.ResultSet.HOLD_CURSORS_OVER_COMMIT;

import static java.sql.ResultSet.TYPE_FORWARD_ONLY;

import static java.sql.ResultSet.TYPE_SCROLL_INSENSITIVE;

import static java.sql.ResultSet.TYPE_SCROLL_SENSITIVE;

import java.sql.SQLException;

public class SupportedResultSetProperties {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

DatabaseMetaData dbmd = conn.getMetaData();

System.out.println("Supported result set scrollability.");

printScrollabilityInfo(dbmd);

System.out.println();

System.out.println("Supported result set concurrency.");

printConcurrencyInfo(dbmd);

System.out.println();

System.out.println("Supported result set holdability.");

printHoldabilityInfo(dbmd);

}

catch (SQLException e) {

e.printStackTrace();

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void printScrollabilityInfo(DatabaseMetaData dbmd) {

try {

boolean forwardOnly

= dbmd.supportsResultSetType(TYPE_FORWARD_ONLY);

boolean scrollSensitive

= dbmd.supportsResultSetType(TYPE_SCROLL_SENSITIVE);

boolean scrollInsensitive

= dbmd.supportsResultSetType(TYPE_SCROLL_INSENSITIVE);

System.out.println("Forward-Only: " + forwardOnly);

System.out.println("Scroll-Sensitive: " + scrollSensitive);

System.out.println("Scroll-Insensitive: " + scrollInsensitive);

}

catch (SQLException e) {

System.out.println("Could not get scrollability information.");

System.out.println("Error Message:" + e.getMessage());

}

}

public static void printConcurrencyInfo(DatabaseMetaData dbmd) {

try {

boolean forwardOnlyReadOnly

= dbmd.supportsResultSetConcurrency(TYPE_FORWARD_ONLY,

CONCUR_READ_ONLY);

boolean forwardOnlyUpdatable

= dbmd.supportsResultSetConcurrency(TYPE_FORWARD_ONLY,

CONCUR_UPDATABLE);

boolean scrollSensitiveReadOnly

= dbmd.supportsResultSetConcurrency(

TYPE_SCROLL_SENSITIVE,

CONCUR_READ_ONLY);

boolean scrollSensitiveUpdatable

= dbmd.supportsResultSetConcurrency(

TYPE_SCROLL_SENSITIVE,

CONCUR_UPDATABLE);

boolean scrollInsensitiveReadOnly

= dbmd.supportsResultSetConcurrency(

TYPE_SCROLL_INSENSITIVE,

CONCUR_READ_ONLY);

boolean scrollInsensitiveUpdatable

= dbmd.supportsResultSetConcurrency(

TYPE_SCROLL_INSENSITIVE,

CONCUR_UPDATABLE);

System.out.println("Scroll Forward-Only and " +

"Concurrency Read-Only: " +

forwardOnlyReadOnly);

System.out.println("Scroll Forward-Only and " +

"Concurrency Updatable: " +

forwardOnlyUpdatable);

System.out.println("Scroll Sensitive and " +

"Concurrency Read-Only: " +

scrollSensitiveReadOnly);

System.out.println("Scroll Sensitive and " +

"Concurrency Updatable: " +

scrollSensitiveUpdatable);

System.out.println("Scroll Insensitive and " +

"Concurrency Read-Only: " +

scrollInsensitiveReadOnly);

System.out.println("Scroll Insensitive and " +

"Concurrency Updatable: " +

scrollInsensitiveUpdatable);

}

catch (SQLException e) {

System.out.println("Could not get concurrency information.");

System.out.println("Error Message:" + e.getMessage());

}

}

public static void printHoldabilityInfo(DatabaseMetaData dbmd) {

try {

boolean holdOverCommit

= dbmd.supportsResultSetHoldability(

HOLD_CURSORS_OVER_COMMIT);

boolean closeAtCommit

= dbmd.supportsResultSetHoldability(

CLOSE_CURSORS_AT_COMMIT);

System.out.println("Hold Over Commit: " + holdOverCommit);

System.out.println("Close At Commit: " + closeAtCommit);

}

catch (SQLException e) {

System.out.println("Could not get concurrency information.");

System.out.println("Error Message:" + e.getMessage());

}

}

}

Supported result set scrollability.

Forward-Only: true

Scroll-Sensitive: false

Scroll-Insensitive: true

Supported result set concurrency.

Scroll Forward-Only and Concurrency Read-Only: true

Scroll Forward-Only and Concurrency Updatable: true

Scroll Sensitive and Concurrency Read-Only: false

Scroll Sensitive and Concurrency Updatable: false

Scroll Insensitive and Concurrency Read-Only: true

Scroll Insensitive and Concurrency Updatable: true

Supported result set holdability.

Hold Over Commit: true

Close At Commit: true

获取结果集

您可以使用StatementPreparedStatementCallableStatement从数据库中获得结果集。在简单的情况下,您用将返回一个ResultSetSELECT语句调用一个Statement对象或一个PreparedStatement对象的executeQuery()方法。以下是获取只进可滚动结果集的典型方法:

Connection conn = JDBCUtil.getConnection();

Statement stmt = conn.createStatement();

String sql = "select person_id, first_name, last_name, dob, income " +

"from person";

// Execute the query to get the result set

ResultSet rs = stmt.executeQuery(sql);

// Process the result set using the rs variable

executeQuery()方法返回的ResultSet已经打开,它准备好被循环以获取相关数据。开始时,光标指向结果集中的第一行。您必须将光标移动到有效行,然后才能访问该行的列值。ResultSetnext()方法用于将光标移动到下一行。当第一次调用next()方法时,它将光标移动到结果集中的第一行。

考虑next()方法的返回值是非常重要的。它返回一个boolean值。如果光标定位到一个有效的行,它返回true。否则返回false。如果你第一次在一个空的ResultSet对象上调用next()方法,它将返回false,因为没有有效的行可以移动。如果当前行是结果集中的最后一行,调用next()方法会将光标定位在最后一行之后,并返回false。处理只进可滚动ResultSet对象的典型代码片段如下:

ResultSet rs = get a result set object;

// Move the cursor to the next row by calling the next() method

while(rs.next()) {

// Process the current row in rs here

}

// Done with the ResultSet

当光标位于只能向前滚动的ResultSet对象的最后一行之后时,除了使用它的close()方法关闭它之外,不能对它做任何事情。只能向前滚动的对象类似于一个create-use-and-throw项目。你也不能重新打开一个ResultSet。要再次遍历结果集数据,必须重新执行查询并获得一个新的ResultSet。然而,对于双向可滚动的ResultSet,情况有所不同,它允许您任意多次地遍历行。您将很快看到一个双向可滚动的ResultSet对象。

程序退出 while 循环后,光标指向结果集中最后一行之后的行。最后一行之后第一行之前是什么行?它们只是想象中的两排。它们在现实中并不存在。当您想要多次循环结果集或者当您的方法中有一个ResultSet对象作为参数时,ResultSet对象的这两个光标位置让您可以做出决定。当您没有创建ResultSet时,您必须正确地知道光标位置,以便以特定的顺序处理行。下面ResultSet接口的四个方法让你知道光标是在第一行之前,在第一行,在最后一行,还是在最后一行之后。

  • boolean isBeforeFirst() throws SQLException
  • boolean isFirst() throws SQLException
  • boolean isLast() throws SQLException
  • boolean isAfterLast() throws SQLException

方法名是不言自明的。对于只向前滚动的ResultSet,对这些方法的支持是可选的。通常,对于只进可滚动的ResultSet,不需要使用这些方法。

一个ResultSet对象允许您使用它的一个getXxx()方法从当前行读取列的值,其中Xxx是列的数据类型。JDBC 支持的每个Xxx数据类型都有一个getXxx()方法。例如,要从列中读取一个intdoubleStringObjectBlob值,可以分别使用ResultSet接口的getInt()getDouble()getString()getObject()getBlob()方法。您必须在getXxx()方法中指定要读取其值的列的索引或名称。这些方法被重载。一个版本接受一个int参数,允许您使用列索引,另一个版本接受一个String参数,允许您使用列标签。如果查询中未指定列标签,您可以指定列名。结果集中的第一列的索引为 1。假设您有下面的ResultSet查询:

select person_id as "Person ID", first_name, last_name from person

ResultSet中,person_id列的列索引为 1,first_name列的列索引为 2,last_name列的列索引为 3。您已经将Person ID指定为person_id列的列标签。您还没有为first_namelast_name列指定列标签。要获得person_id列的值,您需要使用getInt(1)getInt("PERSON ID")。要获得first_name列的值,您需要使用getString(2)getString("first_name")

Tip

getXxx()方法中使用列标签或名称是不区分大小写的。也就是说,您可以使用getInt("person id")getInt("PERSON ID")来获取一个person_id列的值。在本章中,我将使用术语“列名”来指代列标签或名称。

以下代码片段显示了如何读取结果集中当前行的列值:

Connection conn = JDBCUtil.getConnection();

Statement stmt = conn.createStatement();

String SQL = "select person_id, first_name, last_name, dob, income " +

"from person";

ResultSet rs = stmt.executeQuery(SQL);

// Move the cursor to the next row one by one

while(rs.next()) {

// Process the current row in rs

int personId = rs.getInt("person_id");

String firstName = rs.getString("first_name");

String lastName = rs.getString("last_name");

java.sql.Date dob = rs.getDate("dob");

double income = rs.getDouble("income");

// Do something with column values

}

您可以使用列索引重写 while 循环中的代码。

while(rs.next()) {

// Process the current row in rs

int personId = rs.getInt(1);

String firstName = rs.getString(2);

String lastName = rs.getString(3);

java.sql.Date dob = rs.getDate(4);

double income = rs.getDouble(5);

// Do something with column

}

ResultSetgetXxx()方法中使用列索引还是列名是个人喜好的问题。有时您可能事先不知道列的名称,例如当用户向您传递要执行的查询时,您必须使用结果集中的数据。当您不知道列名时,应该使用列索引。您可以使用对象获取ResultSet对象中的列名。有关更多详细信息,请参考“ResultSetMetaData”部分。

ResultSet中,当一列有一个null值时,getXxx()方法返回Xxx数据类型的默认值。例如,对于数值数据类型(intdoublebyte等)。),当列有一个null值时,getXxx()方法返回零。返回数据类型的默认值而不是返回一个null的原因是在 Java 中原始数据类型不能有一个null值。当列有一个null值时,getXxx()方法返回boolean数据类型的false。如果Xxx是引用类型,则getXxx()返回null。如果您想知道使用getXxx()方法读取的列值是否是null,您需要在调用getXxx()方法后立即调用wasNull()方法。如果wasNull()方法返回true,则结果集中的列值为null。如果wasNull()方法返回false,则结果集中的列值不是null。注意,该方法不接受任何参数,它使用一个getXxx()方法返回最后读取的列的null值状态。下面是一段代码,演示了对列的null值检查:

ResultSet rs = get a result set object;

java.sql.Date dob = rs.getDate("dob");

if (rs.wasNull()) {

System.out.println("DOB is null");

}

else {

System.out.println("DOB is " + dob);

}

ResultSet对象的getDate()方法返回一个java.sql.Date对象。java.sql.Date类的toString()方法返回一个yyyy-mm-dd格式的字符串。如果您需要将日期值转换成任何其他格式,您需要使用一个java.text.SimpleDateFormat类的对象来格式化您的日期值。ResultSetgetTime()getTimestamp()方法分别返回一个java.sql.Time对象和一个java.sql.Timestamp对象。java.sql.Time类的方法返回一个hh:mm:ss格式的字符串。java.sql.Timestamp类的toString()方法返回一个yyyy-mm-dd hh:mm:ss.fffffffff格式的字符串。

让我们看一个使用一个Statement对象和一个PreparedStatement对象处理一个ResultSet的完整例子。清单 6-11 演示了如何在数据库中执行查询并处理结果。

清单 6-11。使用语句和 PreparedStatement 获取和处理结果集

// QueryPersonTest.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.PreparedStatement;

import java.util.Date;

import java.sql.ResultSet;

import java.sql.SQLException;

import java.sql.Statement;

import java.text.SimpleDateFormat;

public class QueryPersonTest {

// Will be used to format dates

private static final SimpleDateFormat sdf =

new SimpleDateFormat("MM/dd/yyyy");

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

System.out.println("Using Statement Object...");

displayPersonUsingStatement(conn, 101);

displayPersonUsingStatement(conn, 102);

System.out.println("Using PreparedStatement Object...");

displayPersonUsingPreparedStatement(conn, 101);

displayPersonUsingPreparedStatement(conn, 102);

// Commit the transaction

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void displayPersonUsingStatement(Connection conn,

int inputPersonId) throws SQLException {

String SQL = "select person_id, first_name, last_name, " +

" gender, dob, income from person " +

" where person_id = " + inputPersonId;

Statement stmt = null;

ResultSet rs = null;

try {

stmt = conn.createStatement(

ResultSet.TYPE_SCROLL_SENSITIVE,

ResultSet.CONCUR_UPDATABLE);

rs = stmt.executeQuery(SQL);

printResultSet(rs);

}

finally {

// Closing the Statement closes the associated ResultSet

JDBCUtil.closeStatement(stmt);

}

}

public static void displayPersonUsingPreparedStatement(

Connection conn, int inputPersonId) throws SQLException {

String SQL = "select person_id, first_name, last_name, " +

" gender, dob, income from person " +

" where person_id = ?";

PreparedStatement pstmt = null;

ResultSet rs = null;

try {

pstmt = conn.prepareStatement(SQL);

// Set the IN parameter for person_id

pstmt.setInt(1, inputPersonId);

// Execute the query

rs = pstmt.executeQuery();

printResultSet(rs);

}

finally {

// Closing the Statement closes the ResultSet

JDBCUtil.closeStatement(pstmt);

}

}

public static void printResultSet(ResultSet rs) throws SQLException {

while (rs.next()) {

int personId = rs.getInt("person_id");

String firstName = rs.getString("first_name");

String lastName = rs.getString("last_name");

String gender = rs.getString("gender");

Date dob = rs.getDate("dob");

boolean isDobNull = rs.wasNull();

double income = rs.getDouble("income");

boolean isIncomeNull = rs.wasNull();

// Format the dob in MM/dd/YYYY format

String formattedDob = null;

if (!isDobNull) {

formattedDob = formatDate(dob);

}

System.out.print("Person ID:" + personId);

System.out.print(", First Name:" + firstName);

System.out.print(", Last Name:" + lastName);

System.out.print(", Gender:" + gender);

if (isDobNull) {

System.out.print(", DOB:null");

}

else {

System.out.print(", DOB:" + formattedDob);

}

if (isIncomeNull) {

System.out.println(", Income:null");

}

else {

System.out.println(", Income:" + income);

}

}

}

public static String formatDate(Date dt) {

if (dt == null) {

return "";

}

String formattedDate = sdf.format(dt);

return formattedDate;

}

}

displayPersonUsingStatement()方法接受一个Connection对象和一个人 id 作为参数。它使用一个Statement对象来检索一个ResultSet中的人员详细信息。它调用printResultSet()方法来打印ResultSet中的所有行。您的ResultSet最多有一行,因为person _ id是您的person表中的主键,并且您在查询的 where 子句中使用它。查看在 while 循环中移动光标的细节,并使用printResultSet()方法中适当的getXxx()方法读取每一列的值。在打印之前,dob列的值被格式化为mm/dd/yyyy格式。

displayPersonUsingPreparedStatement()方法使用一个PreparedStatement对象来执行查询。注意,您必须在PreparedStatement上使用setXxx()方法来设置查询的输入参数。该代码使用pstmt.setInt(1inputPersonId)来设置查询的 where 子句中的人员 id 值。

main()方法调用这两个方法来打印同一个人 id 的详细信息。在这个例子中,您没有从预编译PreparedStatement对象中获益,因为您是为每个 person id 单独调用这个方法的。如果你想用不同的输入多次执行同一个PreparedStatement,你可以在你的程序中存储PreparedStatement的引用并重用它。这个例子的目的是向你展示如何使用一个PreparedStatement来处理一个查询,我尽量保持程序逻辑简单。

获取结果集中的行数

你如何知道一个ResultSet中的行数?简单的回答是,ResultSet不知道它包含多少行。在ResultSet接口中没有返回结果集中行数的方法。

ResultSet接口包含一个返回ResultSet中当前行号的方法。如果没有当前行,则返回零,例如当光标位于第一行之前或最后一行之后时。在只能向前滚动的ResultSet中,对getRow()方法的支持是可选的。你可以说getRow()方法对于确定一个ResultSet对象中的行数没有任何帮助。您需要应用一些自定义逻辑来获得结果集中的行数。以下是可以用来获取结果集中行数的一些方法。没有一个是没有缺点的。

滚动所有行

这个方法应用了一个逻辑,在获得ResultSet之后,使用next()方法遍历所有行。它维护一个计数器变量,每次循环迭代递增 1。退出循环后,计数器变量包含ResultSet中的行数。以下代码片段显示了这一逻辑:

ResultSet rs = get a result set object;

// Initialize rowCount to 0

int rowCount = 0;

while(rs.next()) {

// Increment rowCount by 1

rowCount++;

// Process the result set data for the current row

}

// Now, the rowCount variable contains the number of rows in rs

System.out.println("Row Count: " + rowCount);

如果在处理结果集的行之前需要结果集中的行数,这个逻辑将迫使您两次获取结果集:一次获取行数,另一次处理行。在获得第一个结果集和第二个结果集之间,数据库中的数据可能会发生变化,这将使第一次执行的行数无效。只有在遍历完所有行后需要结果集中的行数时,这种方法才是简单的。

执行单独的查询

此方法执行单独的查询来获取结果集中的行数。假设您想知道通过执行查询在结果集中返回的行数,如下所示:

select person_id, first_name, last_name, gender, dob, income

from person

where dob > {d '1970-01-25'}

要获得该查询返回的行数,可以执行如下查询:

select count(*)

from person

where dob > {d '1970-01-25'}

结果集中第一行的第一列的值将给出主查询返回的行数。但是,这种方法也有同样的缺点,即数据库中的行可能会在两次查询执行之间发生变化。

使用双向可滚动结果集

在这个方法中,您将需要创建一个可以向前和向后两个方向滚动的ResultSet对象。当你创建一个Statement对象时,你可以指定一个ResultSet的可滚动属性。关于创建可双向滚动的可滚动ResultSet对象的更多细节,请参考下一节。确保你的 JDBC 驱动程序支持可以双向滚动的ResultSet。在获得ResultSet之后,调用它的last()方法将光标移动到结果集中的最后一行。当光标位于最后一行时,调用getRow()方法。getRow()方法将返回最后一行的行号,这将是结果集中的行数。如果想在获得行数后处理结果集,可以调用它的beforeFirst()方法将光标滚动到第一行之前,并启动一个 while 循环来再次处理结果集中的行。

JDBC 驱动程序可能不支持可以双向滚动的ResultSet对象。在这种情况下,它可能会返回一个只能向前滚动的ResultSet对象。在得到一个ResultSet对象后,在对它调用last()方法之前,检查它是否支持双向滚动是非常重要的。如果您调用last()方法,一个只进的ResultSet对象将抛出一个SQLException。您可以通过调用ResultSet对象的getType()方法来获取它的可滚动属性。清单 6-12 展示了这种获取结果集中行数的方法。

清单 6-12。获取双向可滚动结果集中的行数

// ResultSetRowCountTest.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.ResultSet;

import java.sql.SQLException;

import java.sql.Statement;

import static java.sql.ResultSet.CONCUR_READ_ONLY;

import static java.sql.ResultSet.TYPE_SCROLL_INSENSITIVE;

public class ResultSetRowCountTest {

public static void main(String[] args) {

Connection conn = null;

Statement stmt = null;

try {

// Get a Connection

conn = JDBCUtil.getConnection();

// Request a bi-directional scrollable ResultSet

stmt = conn.createStatement(TYPE_SCROLL_INSENSITIVE,

CONCUR_READ_ONLY);

String SQL = "select person_id, first_name, last_name, dob, " +

"income from person";

// Execute the query

ResultSet rs = stmt.executeQuery(SQL);

// Make sure you got a bi-directional ReseutSet

int cursorType = rs.getType();

if (cursorType == ResultSet.TYPE_FORWARD_ONLY) {

System.out.println("JDBC driver returned a " +

"forward - only cursor.");

}

else {

// Move the cursor to the last row

rs.last();

// Get the last row number, which is the row count

int rowCount = rs.getRow();

System.out.println("Row Count: " + rowCount);

// Place the cursor before the first row to

// process all rows again

rs.beforeFirst();

}

// Process the result

while (rs.next()) {

System.out.println("Person ID: " + rs.getInt(1));

}

}

catch (SQLException e) {

e.printStackTrace();

}

finally {

JDBCUtil.closeStatement(stmt);

JDBCUtil.commit(conn);

JDBCUtil.closeConnection(conn);

}

}

}

双向可滚动结果集

当你创建一个Statement,准备一个PreparedStatement,或者使用Connection接口的不同方法准备一个CallableStatement时,你可以通过指定 scrollability 属性来请求一个双向可滚动的ResultSet的 JDBC 驱动程序。下面是Connection接口的方法列表,这些方法隐式或显式地允许您指定ResultSet对象的滚动属性。方法声明中的throws子句已被排除。他们都抛出一个。

  • Statement createStatement()
  • Statement createStatement(int scrollability, int concurrency)
  • Statement createStatement(int scrollability, int concurrency, int holdability)
  • PreparedStatement prepareStatement(String SQL)
  • PreparedStatement prepareStatement(String SQL, int scrollability, int concurrency)
  • PreparedStatement prepareStatement(String SQL, int scrollability, int concurrency, int holdability)
  • CallableStatement prepareCall(String SQL)
  • CallableStatement prepareCall(String SQL, int scrollability, int concurrency)
  • CallableStatement prepareCall(String SQL, int scrollability, int concurrency, int holdability)

并非所有 JDBC 驱动程序都支持结果集的所有三种类型的可滚动性属性。但是,所有驱动程序都至少支持只进结果集。ResultSet对象的可滚动性的默认值是TYPE_FORWARD_ONLY。当您在这些方法之一中指定结果集的可滚动性时,如果 JDBC 驱动程序不支持该类型的可滚动性,则驱动程序不会生成错误。相反,它将返回一个结果集,其滚动类型与所请求的滚动类型非常匹配。如果你指定了一个ResultSet的可滚动性而不是只进,那么使用getType()方法检查返回的ResultSet对象的可滚动性类型是一个好的实践。以下代码片段显示了如何测试一个ResultSet对象的可滚动性属性:

Connection conn = JDBCUtil.getConnection();

// Request a bi-directional change insensitive ResultSet

Statement stmt = conn.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE,

ResultSet.CONCUR_READ_ONLY);

String SQL = "your select statement goes here";

// Get a result set

ResultSet rs = stmt.executeQuery(SQL);

// Let's see what type of result set the JDBC driver returned

int cursorType = rs.getType();

if (cursorType == ResultSet.TYPE_FORWARD_ONLY) {

System.out.println("ResultSet is TYPE_FORWARD_ONLY");

}

else if (cursorType == ResultSet.TYPE_SCROLL_SENSITIVE) {

System.out.println("ResultSet is TYPE_SCROLL_SENSITIVE");

}

else if (cursorType == ResultSet.TYPE_SCROLL_INSENSITIVE) {

System.out.println("ResultSet is TYPE_SCROLL_INSENSITIVE");

}

一个ResultSet的并发性的缺省值是只读的,如常量ResultSet.CONCUR_READ_ONLY所示。您只能从具有只读并发性的ResultSet中读取数据。如果您想使用ResultSet更新数据,比如更改列的值、插入新行或删除现有行,您必须有一个并发性为ResultSet.CONCUR_UPDATABLEResultSet。并非所有 JDBC 驱动程序都支持可更新并发。您可以向 JDBC 驱动程序请求一个具有可更新并发性的ResultSet对象。如果 JDBC 驱动程序不支持它,它将返回一个只读的ResultSet对象。您可以检查ResultSet对象的并发性,如下所示:

Connection conn = JDBCUtil.getConnection();

// Request a bidirectional change insensitive ResultSet

// with concurrency as CONCUR_UPDATABLE

Statement stmt = conn.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE,

ResultSet.CONCUR_UPDATABLE);

String SQL = "your select statement goes here";

// Get a result set

ResultSet rs = stmt.executeQuery(SQL);

// Let's see what type of concurrency the JDBC driver returned

int concurrency = rs.getConcurrency();

if (concurrency == ResultSet.CONCUR_READ_ONLY) {

System.out.println("ResultSet is CONCUR_READ_ONLY");

}

else if (concurrency == ResultSet.CONCUR_UPDATABLE) {

System.out.println("ResultSet is CONCUR_UPDATABLE");

}

JDBC 驱动程序决定了ResultSet保持能力的默认值。不同的 JDBC 驱动程序对此属性有不同的默认值。您可以使用ResultSetgetHoldability()方法检查ResultSet的可保持性。你也可以使用ConnectiongetHoldability()方法来获得ResultSet对象的这个属性。以下是如何检查ResultSet的可握性:

Connection conn = JDBCUtil.getConnection();

// Request a bidirectional change insensitive ResultSet with concurrency

// as CONCUR_UPDATABLE and holdability of HOLD_CURSORS_OVER_COMMIT

Statement stmt = conn.createStatement(ResultSet.TYPE_SCROLL_INSENSITIVE,

ResultSet.CONCUR_UPDATABLE,                                        ResultSet.HOLD_CURSORS_OVER_COMMIT);

String SQL = "your select statement goes here";

// Get a result set

ResultSet rs = stmt.executeQuery(SQL);

// Let's see what type of holdability the JDBC driver returned

int holdability = conn.getHoldability(); // Java 1.4 and later

//int holdability = rs.getHoldability(); // Java 6 and later

if (holdability == ResultSet.HOLD_CURSORS_OVER_COMMIT) {

System.out.println("ResultSet is HOLD_CURSORS_OVER_COMMIT");

}

else if (holdability == ResultSet.CLOSE_CURSORS_AT_COMMIT) {

System.out.println("ResultSet is CLOSE_CURSORS_AT_COMMIT");

}

Tip

getType()getConcurrency()getHoldability()方法抛出一个SQLException,您必须在代码中处理它。

滚动浏览结果集的行

ResultSet接口中有许多方法可以让您将光标位置移动到结果集中的一行。游标可以指向两组行。一组行由两个假想的行组成,一个在第一行之前,一个在最后一行之后。另一组行由匹配查询的行组成。表 6-7 显示了一个ResultSet的行列结构。如表中的>所示,ResultSet中的光标在创建时位于第一行之前。

表 6-7。

Rows and Column Structures of a ResultSet

|   | 第 1 栏 | 第 2 栏 | 第 3 栏 | 第 4 栏 | 第 5 栏 | 第 6 栏 | | --- | --- | --- | --- | --- | --- | --- | | 第一行之前> | 想象中的一排 | | 第一行 | `101` | `John` | `Jacobs` | `M` | `01/01/1970` | `45000.00` | | 第 2 行 | `102` | `Donna` | `Duncan` | `F` | `01/01/1960` | `35000.00` | | 第 3 行 | `102` | `Buddy` | `Rice` | `M` | `01/01/1965` | `25000.00` | | 最后一行之后 | 想象中的一排 |

该表显示了结果集中包含与查询条件匹配的数据的三行。请注意,ResultSet不会一次检索一个查询的所有行。a ResultSet将从数据库中检索的行数取决于 JDBC 驱动程序。它可以选择从数据库中一次检索一行。您可以使用ResultSet对象的setFetchSize(int fetchSize)方法提示 JDBC 驱动程序,以便在需要更多行时从数据库中获取指定数量的行。什么时候ResultSet需要从数据库中获取更多的行?如果您将光标定位到不在其缓存中的行,那么ResultSet需要获取更多的行。例如,调用一个ResultSet的方法可能会触发从数据库中提取。假设一个ResultSet一次获取 10 条记录。如果您第一次调用next()方法,它将获取并缓存 10 条记录,对于对其next()方法的 9 次后续调用,它将从其缓存中为您提供行。获取和缓存ResultSet的行依赖于 JDBC 驱动程序和底层 DBMS。

可以使用ResultSet接口的getRow()方法获取光标当前所在行的行号。如果光标位于第一行之前或最后一行之后,getRow()方法返回零。

如果有一个ResultSet对象将其可滚动性设置为仅向前,那么只能使用它的next()方法来移动光标,这将使它的光标向前移动一行。一旦光标指向最后一行之后,调用next()方法就没有效果了。如果next()方法指向查询返回的一行,它将返回true。否则返回false

如果一个ResultSet具有双向可滚动性,你有很多方法来改变它的光标位置。在这种类型的ResultSet中也可以使用next()方法,将光标从当前位置向前移动一行。所有光标移动方法都可以分为两类:

  • 相对光标移动方法
  • 绝对光标移动方法

相对光标移动方法相对于光标的当前位置向前或向后移动光标。在这个类别中有两种类型的方法:一种将光标从当前位置向前或向后移动一行,另一种将光标向前或向后移动指定的行数。这种类型的光标移动的一个例子是将光标从当前位置移动到下一/前一行。表 6-8 列出了相对光标移动方法,其类别如Relative所示。

绝对光标移动方法将光标移动到特定行,而不考虑当前光标的位置。在这个类别中有两种类型的方法:一种接受行号将光标移动到该行,例如行号 8,另一种将光标移动到已知位置,例如最后一行。这种类型的光标移动的例子是将光标移动到第八行、第一行、最后一行、第一行之前或最后一行之后,等等。表 6-8 列出了绝对光标移动方法,其类别如Absolute所示。表中的所有方法都抛出一个SQLException

Tip

您只能使用next()方法在只能向前滚动的ResultSet中移动光标。在只进可滚动的ResultSet上使用任何其他方法都会抛出异常。所有光标移动方法都可用于双向可滚动的ResultSet对象。小心使用last()方法。该方法调用将强制 JDBC 驱动程序从数据库中检索所有行。如果 DBMS 不支持双向可滚动游标,JDBC 驱动程序将必须在客户端缓存所有行。对于非常大的结果集,它可能会对应用的性能产生负面影响。

表 6-8。

The Cursor Movement Methods of the ResultSet Interface

| 方法 | 种类 | 描述 | | --- | --- | --- | | `boolean next()` | `Relative` | 将光标从当前位置向前移动一行。如果光标位于结果集中的有效行,它将返回`true`。如果光标位于最后一行之后,它将返回`false`。当光标已经位于最后一行之后时,如果你调用它,它可能抛出一个异常或者返回`false`。这种行为是 JDBC 驱动程序相关的。 | | `boolean previous()` | `Relative` | 它是`next()`方法的对等物。它将光标从当前位置向后移动一行。如果光标位于结果集中的有效行,它将返回`true`。如果光标位于第一行之前,它将返回`false`。 | | `boolean relative(int rows)` | `Relative` | 将光标从当前位置向前或向后移动指定的数量`rows`。对于`rows`的正值,例如`relative(5)`,向前移动光标。`rows`的负值如`relative(-5)`将光标向后移动。叫`relative(0)`没有效果。分别调用`relative(1)`和`relative(-1)`与调用`next()`和`previous()`效果相同。如果要移动的指定行数超出了行的范围(包括第一行之前和最后一行之后),光标将根据指定的移动方向定位在第一行之前或最后一行之后。如果光标被定位到一个有效的行,它返回`true`。否则,返回`false`。当您调用此方法并且光标没有定位到有效行时,例如,当光标定位在第一行之前或最后一行之后时,一些 JDBC 驱动程序会抛出`SQLException`。在这种情况下,一些 JDBC 司机只是返回`false`。 | | `boolean first()` |   | 将光标移动到结果集中的第一行。如果光标定位到第一行,它返回`true`。如果结果集为空,它将返回`false`。 | | `boolean last()` | `Absolute` | 将光标移动到结果集中的最后一行。如果光标定位到最后一行,它返回`true`。如果结果集为空,它将返回`false`。 | | `void beforeFirst()` | `Absolute` | 将光标定位在第一行之前。调用此方法对空结果集没有影响。 | | `void afterLast()` | `Absolute` | 将光标定位在最后一行之后。调用此方法对空结果集没有影响。 | | `boolean absolute(int row)` | `Absolute` | 将光标移动到指定的`row`号。它接受正的和负的行号。如果指定了一个正的行号,则从开始算起。如果指定了负的行号,则从末尾开始计数。假设结果集中有 10 行。调用`absolute(1)`会将光标定位到第一行。调用`absolute(2)`会将光标定位到第二行。调用`absolute(-1)`会将光标定位到倒数第一行,也就是最后一行。调用`absolute(-2)`会将光标定位到倒数第二行。调用`absolute(8)`和`absolute(-3)`的效果与将光标定位在 10 行结果集中的第 8 行相同。调用`absolute(0)`将光标定位在第一行之前。如果光标定位到一个有效行,它返回`true`。否则返回`false`。任何将光标移出有效行范围的尝试都会将光标定位在第一行之前或最后一行之后,这取决于移动的方向。调用`absolute(1)`与调用`first()`相同。调用`absolute(-1)`与调用`last()`相同。 |

知道光标在结果集中的位置

ResultSet界面中的五个方法让你知道光标当前的位置。如果光标在特定位置,四个方法返回一个trueboolean值。这些方法是isBeforeFirst()isFirst()isLast()isAfterLast()。方法名是不言自明的。如果在空结果集上调用它们,它们将返回false。第五种方法称为getRow(),它将当前行号作为int返回。如果光标位于第一行之前、最后一行之后或者结果集为空,则返回 0。

关闭结果集

您可以通过调用close()方法来关闭ResultSet对象。在已经关闭的ResultSet上调用close()方法没有任何效果。

ResultSet rs = get a result set object;

// Process the rs object...

// Close the result set

rs.close();

关闭ResultSet对象会释放与之相关的资源。在下列情况下,ResultSet对象也可以被隐式关闭:

  • 当产生ResultSet对象的Statement对象关闭时,它会自动关闭ResultSet对象。
  • 当一个Statement对象被重新执行时,其先前打开的ResultSet对象被关闭。
  • 如果一个Statement对象产生多个结果集,那么检索下一个结果集将关闭先前检索的ResultSet
  • 如果它是一个只能向前滚动的ResultSet,当它的next()方法返回false作为优化的一部分时,JDBC 驱动程序可以选择关闭它。一旦next()方法为只能向前滚动的ResultSet返回false,无论如何你都不能对那个ResultSet做任何事情。

除了调用它的close()isClosed()方法之外,不能在关闭的ResultSet上执行任何活动。调用任何其他方法都会抛出一个SQLException。然而,当一个ResultSet对象被关闭时,一切并没有丢失。您仍然可以在关闭的ResultSet上获得以下信息:

  • 如果您在结果集打开时访问了结果集中的BlobClobNClobSQLXML对象,则这些对象在ResultSet关闭后仍然有效。它们至少在交易期间有效。如果您有一个来自打开时的ResultSet对象的ResultSetMetaData对象,您仍然可以使用它来获得关于结果集的元数据信息。以下代码片段显示了语句的正确和错误顺序:ResultSet rs = get a result set object; ResultSetMetaData rsmd = rs.getMetaData(); rs.close(); // rs is closed // You can still use rsmd object to get info about rs System.out.println("Column Count:" + rsmd.getColumnCount()); // Can use only isClosed() and close() method on rs because it is closed. ResultSetMetaData rsmd = rs.getMetaData(); // An error

对结果集进行更改

您可以使用ResultSet对数据库表执行插入、更新和删除操作。为了在ResultSet上执行更新,ResultSet对象的并发性必须是ResultSet.CONCUR_UPDATABLE。在ResultSet中插入新行和更新现有行是一个两步过程,而删除行是一个一步过程。在两步过程中,您需要首先在ResultSet对象中进行更改,然后调用它的一个方法将更改发送到数据库。在一步式流程中,对ResultSet的更改会自动传播到数据库。

使用结果集插入行

到目前为止,您只知道结果集中有两个虚构的行。它们是第一排之前的排和最后一排之后的排。然而,在ResultSet中还有一个虚构的行,称为插入行。您可以将此行视为一个空的新行,它充当要插入的新行的临时区域。您可以使用ResultSet对象的moveToInsertRow()方法将光标定位到插入行。当光标移动到插入行时,它会记住它以前的位置。您可以调用moveToCurrentRow()方法将光标从插入行移回到先前的当前行。因此,插入新行的第一步是将光标移动到插入行。

// Move the cursor to an insert row to add a new row

rs.moveToInsertRow();

此时,在临时区域中插入了一个新行,所有列都有未定义的值。调用getXxx()方法读取列值可能会在此时抛出异常。一旦光标定位在插入行,您需要使用ResultSet接口的updateXxx()方法之一设置所有列的值(至少对于不可为空的列),其中Xxx是列的数据类型。updateXxx()方法的第一个参数是列索引或列名,第二个参数是列值。如果您想使用ResultSetperson表中插入一个新行,您的updateXxx()方法调用将如下所示:

// Leave dob and income unset to use null values for them

rs.updateInt("person_id", 501);

rs.updateString("first_name", "Richard");

rs.updateString("last_name", "Castillo");

rs.updateString("gender", "M");

一旦更新了列的值,就可以使用getXxx()方法从ResultSet中检索新值。

新行尚未完成。在新行成为ResultSet的一部分之前,您必须将更改发送到数据库。您可以通过调用ResultSet接口的insertRow()方法将新插入的行发送到数据库,如下所示:

// Send changes to the database

rs.insertRow();

insertRow()方法的调用可能会也可能不会使插入的行成为数据库中的永久行。如果为Connection启用了自动提交模式,那么insertRow()调用也将提交您的事务。在这种情况下,新行将永久成为数据库的一部分。如果Connection的自动提交模式被禁用,您可以通过提交事务使插入永久化,或者通过回滚事务取消插入。请注意,提交或回滚事务将提交或回滚所有挂起的活动,而不仅仅是新插入的行。

一旦将插入的行发送到数据库,就可以通过调用moveToCurrentRow()方法移动到先前的当前行。在调用moveToInsertRow()方法后,在调用insertRow()方法前移动到另一行,丢弃新行。

清单 6-13 展示了如何使用一个ResultSet来插入一个新行。在方法中获得ResultSet对象后,它检查它是否可更新。如果ResultSet对象不可更新,它会打印一条消息指出这一点,并且不做任何事情。最后,它打印结果集中的所有行。打印的记录也包括新行。注意,您不需要为新行更新dobincome列的值,当 JDBC 驱动程序在person表中插入新行时,它将使用null的值。如果您在同一个数据库中多次运行该程序,则会打印一条错误消息,因为该程序将尝试再次插入一个带有相同person_id的人员记录,从而导致表中出现重复行。

清单 6-13。使用结果集插入新行

// ResultSetInsert.java

package com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.ResultSet;

import java.sql.SQLException;

import java.sql.Statement;

import static java.sql.ResultSet.CONCUR_UPDATABLE;

import static java.sql.ResultSet.TYPE_FORWARD_ONLY;

public class ResultSetInsert {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Add a new row

addRow(conn);

// Commit the transaction

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void addRow(Connection conn) throws SQLException {

String SQL = "select person_id, first_name, "

+ "last_name, gender, dob, income  "

+ "from person";

Statement stmt = null;

try {

stmt = conn.createStatement(TYPE_FORWARD_ONLY,

CONCUR_UPDATABLE);

// Get the result set

ResultSet rs = stmt.executeQuery(SQL);

// Make sure your resultset is updatable

int concurrency = rs.getConcurrency();

if (concurrency != ResultSet.CONCUR_UPDATABLE) {

System.out.println("The JDBC driver does not " +

"support updatable result sets.");

return;

}

// First insert a new row to the ResultSet

rs.moveToInsertRow();

rs.updateInt("person_id", 501);

rs.updateString("first_name", "Richard");

rs.updateString("last_name", "Castillo");

rs.updateString("gender", "M");

// Send the new row to the database

rs.insertRow();

// Move back to the current row

rs.moveToCurrentRow();

// Print all rows in the result set

while (rs.next()) {

System.out.print("Person ID: " +

rs.getInt("person_id") +

", First Name: " +

rs.getString("first_name") +

", Last Name: " +

rs.getString("last_name"));

System.out.println();

}

}

finally {

JDBCUtil.closeStatement(stmt);

}

}

}

使用结果集更新行

下面是更新一个ResultSet对象中现有行的步骤。

Move the cursor to a valid row in the result set. Note that you can update data only for an existing row. It is obvious that the cursor should not be positioned before the first row or after the last row if you want to update the data in a row.   Call an updateXxx() method for a column to update the column’s value.   If you do not want to go ahead with the changes made using updateXxx() method calls, you need to call the cancelRowUpdates() method of the ResultSet to cancel the changes.   When you are done updating all the column’s values for the current row, call the updateRow() method to send the changes to the database. If the auto-commit mode is enabled for the Connection, changes will be committed. Otherwise, you need to commit the changes to the database.   If you move the cursor to a different row before calling the updateRow(), all your changes made using the updateXxx() method calls will be discarded.   There is another way to lose your updates to columns in a row. If you call the refreshRow() method after calling updateXxx(), but before calling updateRow(), your changes will be lost because the JDBC driver will refresh the row’s data from the database.

清单 6-14 展示了如何使用一个ResultSet对象来更新一行。它使每个非零收入的人的收入增加了 10%。如果一个人的收入是null,则更新收入为 10000.00。

清单 6-14。使用结果集更新数据

// ResultSetUpdate.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.ResultSet;

import static java.sql.ResultSet.CONCUR_UPDATABLE;

import static java.sql.ResultSet.TYPE_FORWARD_ONLY;

import java.sql.SQLException;

import java.sql.Statement;

public class ResultSetUpdate {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Give everyone a 10% raise

giveRaise(conn, 10.0);

// Commit the transaction

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

e.printStackTrace();

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void giveRaise(Connection conn,

double raise) throws SQLException {

String SQL = "select person_id, first_name, last_name, " +

"income from person";

Statement stmt = null;

try {

stmt = conn.createStatement(TYPE_FORWARD_ONLY,

CONCUR_UPDATABLE);

// Get the result set

ResultSet rs = stmt.executeQuery(SQL);

// Make sure our resultset is updatable

int concurrency = rs.getConcurrency();

if (concurrency != CONCUR_UPDATABLE) {

System.out.println("The JDBC driver does not "+

"support updatable result sets.");

return;

}

// Give everyone a raise

while (rs.next()) {

double oldIncome = rs.getDouble("income");

double newIncome = 0.0;

if (rs.wasNull()) {

// null income starts at 10000.00

oldIncome = 10000.00;

newIncome = oldIncome;

}

else {

// Increase the income

newIncome =

oldIncome + oldIncome * (raise / 100.0);

}

// Update the income column with the new value

rs.updateDouble("income", newIncome);

// Print the details about the changes

int personId = rs.getInt("person_id");

String firstName = rs.getString("first_name");

String lastName = rs.getString("last_name");

System.out.println(firstName + " " + lastName +

" (person id=" + personId +

") income changed from " +

oldIncome + " to " + newIncome);

// Send the changes to the database

rs.updateRow();

}

}

finally {

JDBCUtil.closeStatement(stmt);

}

}

}

使用结果集删除行

ResultSet中删除一行比更新和插入一行更容易。以下是删除行的步骤。

Position the cursor at a valid row.   Call the deleteRow() method of the ResultSet to delete the current row.

deleteRow()方法从ResultSet对象中删除该行,同时从数据库中删除该行。除了回滚事务之外,没有其他方法可以取消删除操作。如果在Connection对象上启用了自动提交模式,deleteRow()将从数据库中永久删除该行。

ResultSet对象中删除一行的典型代码如下:

ResultSet rs = get an updatable result set object;

// Scroll to the row you want to delete, say the first row

rs.next();

// Delete the current row

rs.delete(); // Row is deleted from the result set and the database

// Commit or rollback changes depending on your processing logic

处理语句的多个结果

有时执行一条 SQL 语句可能会返回多个结果。通常,通过执行存储过程可以获得多个结果。结果包括更新计数和结果集。例如,如果您执行一个存储过程来更新一些记录并返回一个结果集,您将得到两个结果。第一个是更新计数,第二个是结果集。有些 DBMSs 允许您禁止发回更新计数。例如,您可以在 SQL Server DBMS 的存储过程中使用SET NOCOUNT ONSET NOCOUNT OFF选项来禁用或启用更新计数结果。请查阅 DBMS 文档,了解可用于抑制更新计数结果的选项。

如果可能返回多个结果,使用Statement对象的方法。您需要处理四件事情来处理多个结果。您可能知道也可能不知道结果返回的顺序。你首先需要考虑的是execute()方法的返回值。您需要使用Statement接口的以下三种方法来访问所有结果:

  • getMoreResults()
  • getUpdateCount()
  • getResultSet()

execute()方法可能产生许多结果,也可能没有结果。您必须一次读取一个结果。您必须先滚动到一个结果,然后才能阅读它。execute()方法滚动到第一个结果,如果有的话。如果第一个结果是一个ResultSet,那么execute()方法返回true。如果第一个结果是更新计数或者没有结果,则返回false。您可以通过调用Statement对象的getResultSet()方法来检索ResultSet结果。您可以通过调用它的getUpdateCount()方法来检索更新计数。

您可以通过调用一个StatementgetMoreResults()方法滚动到下一个结果。如果滚动到一个ResultSet结果,该方法返回true。如果滚动到一个更新计数结果或者没有更多的结果,它返回false

处理多个结果的规则不是很混乱吗?那么,什么标准决定了你有更多的结果要处理呢?这有点难以分辨。您将需要编写几行代码来处理通过执行一条语句返回的多个结果。下面的代码片段将逻辑放在一起。它使用了一个CallableStatement对象。你也可以使用一个Statement或者PreparedStatement对象。以下逻辑不依赖于多个结果的顺序或计数:

Callable cstmt = get a callable statement object;

boolean hasResultSet = cstmt.execute();

int updateCount = cstmt.getUpdateCount();

while (hasResultSet || updateCount != -1) {

if (hasResultSet) {

// The cursor is pointing to a ResultSet object

ResultSet rs = cstmt.getResultSet();

// Process the result set here

System.out.println("Got a result set");

}

else {

System.out.println("Got an update Count: " + updateCount);

}

// Move the cursor to the next result

hasResultSet = cstmt.getMoreResults();

// Get the new update count

updateCount = cstmt.getUpdateCount();

}

// When we get to this point, all results have been processed.

Tip

当您调用getMoreResults()方法时,之前通过使用getResultSet()方法获得的ResultSet被关闭。如果您想保留先前访问的ResultSet,您可以使用另一个版本的接受参数的getMoreResults()方法,它允许您指定如何处理打开的ResultSet对象。像往常一样,关闭Statement对象将关闭所有的ResultSet

从存储过程获取结果集

我已经详细介绍了处理通过执行SELECT语句产生的结果集(见清单 6-11)。存储过程也可以产生结果集。在大多数数据库中,在存储过程中生成结果集很容易。这只是在存储过程中编写一个SELECT语句的问题。以下是在 Adaptive Server Anywhere DBMS 中创建返回结果集的存储过程的示例。存储过程的名称是get_person_details。它接受一个整型参数,也就是person_id的值。为了返回一个结果集,它只是从person表中为传入的person_id选择列。

-- Adaptive Server Anywhere 9.0

create procedure get_person_details(@person_id integer)

as

begin

select person_id, first_name, last_name, gender, dob, income

from person

where person_id = @person_id

end

在 Oracle 数据库的存储过程中生成结果集略有不同。您需要在 Oracle 数据库中使用REF CURSOR类型来产生结果集。首先,您使用一个示例来处理由 DBMS 而不是 Oracle 的存储过程产生的结果集。请参考您的 DBMS 及其 JDBC 文档,了解它如何支持在存储过程中生成结果集。在本节的最后,您将看到一个使用 Oracle DBMS 在存储过程中生成结果集的示例。

本节包含在一些 DBMSs 中创建一个get_person_details存储过程的数据库脚本。如果您正在使用这些 DBMS 中的一个,您需要运行 DBMS 的脚本,然后才能运行本节中的示例。如果您的 DBMS 没有列出,您可以复制逻辑并在 DBMS 中创建一个get_person_details存储过程,它会产生一个结果集。

如果您的存储过程只产生一个结果集,那么处理该结果集是很简单的。以下是从存储过程处理一个结果集的步骤:

  • 使用 JDBC 标准语法以字符串格式构造存储过程调用。String sql = "{call get_person_details(?)}";
  • 使用上一步中创建的 SQL 语法准备一个CallableStatementCallableStatement cstmt = conn.prepareCall(sql);
  • 设置需要传递给存储过程的任何IN参数。在您的例子中,您将传递一个person_id给存储过程,并且您需要设置一个person_id作为IN参数。cstmt.setInt(1, 101);
  • 调用CallableStatement对象的executeQuery()方法,该方法将存储过程产生的结果集作为ResultSet返回。ResultSet rs = cstmt.executeQuery();
  • 像往常一样处理ResultSet对象,遍历它的行并使用getXxx()方法读取列值。

Tip

如果你的存储过程返回多个结果集,你需要使用CallableStatement接口的execute()方法,而不是executeQuery()方法。有关如何处理由存储过程产生的多个结果集的更多细节,请参考“处理来自一个语句的多个结果”一节。

以下是在不同 DBMSs 中创建get_person_details存储过程的数据库脚本。

MySQL 数据库

DELIMITER $$

DROP PROCEDURE IF EXISTS get_person_details $$

CREATE PROCEDURE get_person_details(in person_id_param int)

BEGIN

select person_id, first_name, last_name, gender, dob, income

from person

where person_id = person_id_param;

END $$

DELIMITER ;

Adaptive Server Anywhere 数据库

create procedure get_person_details(@person_id integer)

as

begin

select person_id, first_name, last_name, gender, dob, income

from person

where person_id = @person_id

end

甲骨文数据库

CREATE OR

PACKAGE JDBC_TEST_PKG

AS

type person_cursor_type is ref cursor;

END JDBC_TEST_PKG;

create or replace PROCEDURE GET_PERSON_DETAILS

( person_id_param IN NUMBER,

person_cursor OUT jdbc_test_pkg.person_cursor_type

)

AS

BEGIN

open person_cursor for

select person_id, first_name, last_name, gender, dob, income

from person

where person_id = person_id_param;

END GET_PERSON_DETAILS;

SQL Server 数据库

-- Drop stored procedure if it already exists

IF EXISTS (

SELECT *

FROM INFORMATION_SCHEMA.ROUTINES

WHERE SPECIFIC_SCHEMA = N'dbo'

AND SPECIFIC_NAME = N'get_person_details'

)

DROP PROCEDURE dbo.get_person_details

GO

CREATE PROCEDURE dbo.get_person_details

@person_id int

AS

BEGIN

SELECT person_id, first_name, last_name, gender, dob, income

FROM person

WHERE person_id = @person_id;

END;

GO

DB2 数据库

create procedure get_person_details(in person_id_param int)

result sets

language sql

begin

declare c1 cursor with return for

select person_id, first_name, last_name, gender, dob, income

from person

where person_id = person_id_param;

open c1;

end

@

在上面的语法中,@符号用作语句结束符。

Java DB 数据库

对于 Java DB 数据库,您需要将存储过程编写为 Java 类中的一个方法,如清单 6-15 所示。

清单 6-15。Java DB 中 get_person_details 存储过程的 Java 代码

// JavaDBGetPersonDetailsSp.java

package com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.DriverManager;

import java.sql.PreparedStatement;

import java.sql.ResultSet;

import java.sql.SQLException;

public class JavaDBGetPersonDetailsSp {

public static void getPersonDetails(int personId,

ResultSet[] personDetailRs) throws SQLException {

// Must use the following URL to get the reference of

// the Connection object in whose context this method

// is called.

String dbURL = "jdbc:default:connection";

Connection conn = DriverManager.getConnection(dbURL);

String sql = "select person_id, first_name, " +

"last_name, gender, dob, income " +

"from person " +

"where person_id = ?";

PreparedStatement pstmt = conn.prepareStatement(sql);

pstmt.setInt(1, personId);

ResultSet rs = pstmt.executeQuery();

personDetailRs[0] = rs;

/* Do not close pstmt or rs here. They are meant to be

procssed and closed by the caller of this stored

procedure.

*/

}

}

在 Java DB 数据库中创建get_person_details存储过程的命令如下。有关在 Java DB 中使用存储过程的更多细节,请参考清单 6-8 以及包含该清单的部分中的相关步骤。

-- Command to create the stored procedure

CREATE PROCEDURE get_person_details(IN person_id integer)

PARAMETER STYLE JAVA

LANGUAGE JAVA

READS SQL DATA

DYNAMIC RESULT SETS 1

EXTERNAL NAME 'com.jdojo.jdbc.JavaDBGetPersonDetailsSp.getPersonDetails';

清单 6-16 包含了执行存储过程和处理存储过程产生的结果集的完整代码。它使用QueryPersonTest类的printResultSet()静态方法(见清单 6-11)来打印一个人的详细信息。该程序对于在服务器端具有对结果集的本机支持的数据库有效。请参考本节后面的示例来处理 Oracle 数据库中存储过程产生的结果集。

清单 6-16。处理存储过程产生的结果集

// StoredProcedureResultSetTest.java

package  com.jdojo.jdbc;

import java.sql.CallableStatement;

import java.sql.Connection;

import java.sql.ResultSet;

import java.sql.SQLException;

public class StoredProcedureResultSetTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Print details for person_id 101

printPersonDetails(conn, 101);

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void printPersonDetails(Connection conn,

int personId) throws SQLException {

String SQL = "{ call get_person_details(?) }";

CallableStatement cstmt = null;

try {

cstmt = conn.prepareCall(SQL);

// Set the IN

cstmt.setInt(1, personId);

ResultSet rs = cstmt.executeQuery();

// Process the result set

QueryPersonTest.printResultSet(rs);

}

finally {

JDBCUtil.closeStatement(cstmt);

}

}

}

现在,是时候只使用 Oracle 数据库了。下面是处理 Oracle 数据库中存储过程产生的结果集所需的步骤。

Construct the stored procedure call in a string format using the JDBC standard syntax. You will have an additional OUT parameter for an Oracle stored procedure. In an Oracle database, the stored procedure will pass back the reference of a REF CURSOR type in that OUT parameter. In your case, the first parameter is of the IN type and it will be used to pass a person_id. The second parameter is an OUT parameter of type oracle.jdbc.OracleTypes.CURSOR. Note that you must have the JAR file(s) for the Oracle JDBC driver included in the CLASSPATH to use the oracle.jdbc.OracleTypes.CURSOR interface. String sql = "{call get_person_details(?, ?)}";   Prepare a CallableStatement using the SQL syntax created in the previous step. CallableStatement cstmt = conn.prepareCall(sql);   Set any IN parameters that need to be passed to the stored procedure. In your case, you will pass a person_id to the stored procedure and you need to set that person_id as an IN parameter. Register the OUT parameter as oracle.jdbc.OracleTypes.CURSOR type. cstmt.setInt(1, 101); cstmt.registerOutParameter(2, oracle.jdbc.OracleTypes.CURSOR);   Call the execute() method of the CallableStatement object. cstmt.execute();   Get the ResultSet object, which is passed back in the second OUT parameter using the getObject() method and cast it as ResultSet. ResultSet rs = (ResultSet)cstmt.getObject(2);   Process the ResultSet object as usual by looping through its rows and using its getXxx() methods to read the column values.

清单 6-17 包含了在 Oracle 数据库中执行get_person_details存储过程并处理存储过程产生的结果集的完整代码。确保您有返回到 Oracle 数据库的连接的JDBCUtil.getConnection()方法(参见清单 6-1)。在运行清单 6-17 中的程序之前,还必须在 Oracle 数据库中编译必要的包和过程,如本节中为 Oracle 所列。请注意,您需要取消出现在printPersonDetails()方法中的以下语句的注释:

//cstmt.registerOutParameter(2, oracle.jdbc.OracleTypes.CURSOR);

我对它进行了注释,这样整个类都可以编译了。取消对该语句的注释后,需要在类路径中添加 Oracle JDBC 驱动程序 JAR 文件来编译该类。

清单 6-17。处理 Oracle 数据库中存储过程的结果集

// OracleStoredProcedureResultSetTest.java

package com.jdojo.jdbc;

import java.sql.CallableStatement;

import java.sql.Connection;

import java.sql.ResultSet;

import java.sql.SQLException;

public class OracleStoredProcedureResultSetTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Print details for person_id 101

printPersonDetails(conn, 101);

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void printPersonDetails(Connection conn,

int personId) throws SQLException {

String sql = "{ call get_person_details(?, ?) }";

CallableStatement cstmt = null;

try {

cstmt = conn.prepareCall(sql);

// Set the IN parameters

cstmt.setInt(1, personId);

/* Uncomment the following statement after you have

the Oracle JDBC driver in CLASSPATH.

Register the second parameter as an OUT parameter

which will return the REF CURSOR (the ResultSet) */

//cstmt.registerOutParameter(2,

//      oracle.jdbc.OracleTypes.CURSOR);

// Execute the stored procedure

cstmt.execute();

// Get the result set from the OUT parameter

ResultSet rs = (ResultSet) cstmt.getObject(2);

// Process the result set

QueryPersonTest.printResultSet(rs);

}

finally {

JDBCUtil.closeStatement(cstmt);

}

}

}

许多数据库都支持REF CURSOR类型。Java 8 通过添加代表 Java 中REF CUSROR数据类型的JDBCType.REF_CURSOR枚举常量,增加了对 JDBC API 中REF CURSOR数据类型的直接支持。使用这个 JDBC 类型,你将能够使用REF CURSOR类型,而不用在你的 Java 程序中使用专有的 JDBC 类。例如,您将能够在printPersonDetails()方法中注册REF CURSOR数据库类型的OUT参数,如下所示:

cstmt.registerOutParameter(2, JDBCType.REF_CURSOR);

Tip

在撰写本文时,JDBCType.REF_CURSOR类型还没有在 Oracle JDBC 驱动程序中实现。如果使用此类型注册 REF CURSOR 数据库类型,将会出现运行时错误,并显示一条错误消息,指出此数据类型尚未实现。使用 Java 8 中添加的DatabaseMetaData接口的supportsRefCursors()方法,了解数据库是否支持 REF CURSOR。

元数据

一个ResultSet对象包含通过执行查询返回的数据行和关于列的详细信息。它包含的关于结果集中的列的信息称为结果集元数据。ResultSetMetaData接口的一个对象表示结果集元数据。你可以通过调用ResultSet接口的getMetaData()方法得到一个ResultSetMetaData对象。

ResultSet rs = get result set object;

ResultSetMetaData rsmd = rs.getMetaData();

一个ResultSetMetaData包含了一个结果集中所有列的大量信息。除了getColumnCount()之外,ResultSetMetaData接口中的所有方法都接受结果集中的列索引作为参数。它包含表名、名称、标签、数据库数据类型、Java 中的类名、可空性、精度等。一列。它还包含结果集中的列数。它的getTableName()方法返回一列的表名;getColumnName()方法返回列的名称;getColumnLabel()方法返回列的标签;getColumnTypeName()方法返回数据库中的列类型;getColumnClassName()方法返回用于表示列数据的 Java 类。它的getColumnCount()方法返回结果集中的列数。

列标签是一个很好的可打印文本,用于查询中的列名之后。以下查询使用"Person ID"作为person_id列的列标签。first_name列没有指定的标签。

select person_id as "Person ID", first_name from person

如果上述查询用于一个结果集,getColumnLabel(1)方法调用将返回"Person ID",而getColumnName(1)将返回person_id。如果查询中没有指定列标签,那么getColumnLabel()方法将返回列名。

清单 6-18 展示了如何使用一个ResultSetMetaData对象来了解更多关于结果集的信息。显示了 Java DB 的输出。当您使用不同的 JDBC 驱动程序时,可能会得到不同的输出,因为数据库列类型到 JDBC 列类型的映射取决于 JDBC 驱动程序。

清单 6-18。使用 ResultSetMetaData 对象获取有关结果集的信息

// ResultSetMetaDataTest.java

package  com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.ResultSet;

import java.sql.ResultSetMetaData;

import java.sql.SQLException;

import java.sql.Statement;

public class ResultSetMetaDataTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

String SQL = "select person_id as \"Person ID\", " +

"first_name as \"First Name\", " +

"gender as Gender, " +

"dob as \"Birth Date\", " +

"income as Income " +

"from person";

// Print the reSult set matadata

printMetaData(conn, SQL);

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void printMetaData(Connection conn, String SQL)

throws SQLException {

Statement stmt = conn.createStatement();

try {

ResultSet rs = stmt.executeQuery(SQL);

ResultSetMetaData rsmd = rs.getMetaData();

int columnCount = rsmd.getColumnCount();

System.out.println("Column Count:" + columnCount);

for (int i = 1; i <= columnCount; i++) {

System.out.println("Index:" + i +

", Name:" + rsmd.getColumnName(i) +

", Label:" + rsmd.getColumnLabel(i) +

", Type Name:" + rsmd.getColumnTypeName(i) +

", Class Name:" + rsmd.getColumnClassName(i));

}

}

finally {

JDBCUtil.closeStatement(stmt);

}

}

}

Index:1, Name:Person ID, Label:Person ID, Type Name:INTEGER, Class Name:java.lang.Integer

Index:2, Name:First Name, Label:First Name, Type Name:VARCHAR, Class Name:java.lang.String

Index:3, Name:GENDER, Label:GENDER, Type Name:CHAR, Class Name:java.lang.String

Index:4, Name:Birth Date, Label:Birth Date, Type Name:DATE, Class Name:java.sql.Date

Index:5, Name:INCOME, Label:INCOME, Type Name:DOUBLE, Class Name:java.lang.Double

如果您必须编写通用代码来处理任何或未知的结果集,您会发现一个ResultSetMetaData对象是不可或缺的。例如,假设您想要开发一个 Swing 应用,它将允许用户输入一个查询,并且您将在一个JTable中显示查询数据。要构造JTable,您必须知道结果集中的列数。您可以使用ResultSetMetaData对象的getColumnCount()方法来了解结果集中的列数。您可以使用该对象中可用的许多其他方法来构造一个合适的JTable

使用行集

接口的一个实例是一个结果集的包装器。RowSet接口继承自ResultSet接口。简单地说,RowSet是一个 Java 对象,包含一组来自表格数据源的行。表格数据源可以是数据库、平面文件、电子表格等。RowSet接口在javax.sql封装中。以下是RowSet相对于ResultSet的优势:

  • 让 JDBC 编程更简单。当你使用一个ResultSet对象时,你必须同时处理ConnectionStatement对象。一个RowSet对开发者隐藏了使用ConnectionStatement对象的复杂性。你只需要处理一个对象,那就是一个RowSet对象。
  • ResultSet不是Serializable,因此,它不能通过网络发送,也不能保存到磁盘供以后使用。一个RowSet就是Serializable。它可以通过网络发送或保存到磁盘上以备后用。
  • 一个ResultSet总是连接到一个数据源。一个RowSet对象不需要一直连接到它的数据源。它可以在需要时连接到数据库,例如检索/更新数据源中的数据。
  • 默认情况下,RowSet是可滚动和可更新的。
  • 一个RowSet的两个属性,序列化和无连接性,使得它在瘦客户端环境中非常有用,比如移动设备或 web 应用。瘦客户机不需要 JDBC 驱动程序。它可能从中间层获取断开连接的RowSet中的数据。它可以修改数据并将修改后的RowSet发送给中间层,中间层可以连接到数据源并更新数据。您可以在 applet 或 web 页面中使用这种技术。您可以拥有一个 servlet,它可以连接到一个数据库,在一个断开的RowSet中检索数据,并将其传递给一个 applet。applet 可以修改数据,并将修改后的RowSet发送给 servlet,servlet 可以连接到数据库更新数据。这样,applet 根本不需要使用 JDBC 驱动程序或任何与数据库连接相关的东西。还有一种RowSet类型可用于处理 XML 数据的 web 使用。
  • 一个ResultSet使用一个数据库作为它的数据源。您并不局限于使用一个数据库作为带有RowSet的数据源。您可以实现一个RowSet来使用任何表格数据源。
  • 一个RowSet遵循 JavaBeans 模型进行属性设置和事件通知,这使得使用支持 JavaBeans 开发的可视化工具开发一个RowSet成为可能。
  • 一个RowSet al so 支持在检索到数据后过滤数据。在ResultSet中无法过滤数据。如果使用ResultSet,则必须在查询中使用WHERE子句来过滤数据库本身的数据。
  • 从数据源中检索到两个或多个数据集后,使用RowSet可以根据它们的列值连接它们。一个数据集可以从数据库中检索,另一个数据集可以从平面文件中检索。当你使用一个ResultSet时,这是完全不可能的。使用ResultSet时,可以在填充ResultSet的查询中使用 SQL 连接来连接多个数据集。

你还需要意识到使用RowSet的一些缺点。

  • 一个特定的实现可以在存储器中缓存数据。使用这种类型的RowSet时需要小心。不要使用这些RowSet获取大量数据。否则,可能会降低应用的速度。
  • 对于RowSet中的缓存数据,当对数据源进行更改时,RowSet中的数据和数据源中的数据更有可能不一致。

javax.sql.rowset包中的以下接口定义了五种类型的行集:

  • JdbcRowSet
  • CachedRowSet
  • WebRowSet
  • FilteredRowSet
  • JoinRowSet

每种类型的行集都有适合特定需求的功能。所有这些行集接口都直接或间接地继承自RowSet接口。RowSet接口继承自ResultSet接口。因此,ResultSet接口中的所有方法也适用于所有类型的行集。图 6-7 描绘了行集接口的类图。

A978-1-4302-6662-4_6_Fig7_HTML.jpg

图 6-7。

A class diagram for the interfaces defining rowsets

谁为行集接口提供了实现类?通常,数据库供应商应该为行集提供实现类。他们可以将它们作为 JDBC 驱动程序的一部分或作为单独的包提供。第三方也可以提供行集实现类。作为开发人员,您还可以提供行集实现来满足特定需求。

创建行集

接口的一个实例允许您创建不同类型的对象,而不用关心行集实现类。要获得一个RowSetFactory,你需要使用RowSetProvider类的newFactory()静态方法。RowSetFactory接口有五种方法来创建五种类型的行集。这些方法被命名为createXxxRowSet(),其中Xxx可以是CachedFilteredJdbcJoinWeb。例如,您将使用一个RowSetFactorycreateJdbcRowSet()方法来创建一个JdbcRowSet。下面的代码片段展示了如何创建一个JdbcRowSet:

import java.sql.SQLException;

import javax.sql.rowset.JdbcRowSet;

import javax.sql.rowset.RowSetFactory;

import javax.sql.rowset.RowSetProvider;

...

JdbcRowSet jdbcRs = null;

try {

// Get the RowSetFactory implementation

RowSetFactory rsFactory = RowSetProvider.newFactory();

// Create a JdbcRowSet object

jdbcRs = rsFactory.createJdbcRowSet();

// Work with jdbcRs here

}

catch (SQLException e) {

e.printStackTrace();

}

finally {

if (jdbcRs != null) {

try {

// Close the RowSet

jdbcRs.close();

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

RowSetProvider类的方法搜索RowSetFactory接口的实现类,如下所示:

  • 它寻找系统属性javax.sql.rowset.RowSetFactory的值。您可以在命令行上指定该属性值。以下命令在运行com.jdojo.jdbc.Test类时将该属性值设置为com.jdojo.MyRowSetFactoryImpl类:java -Djavax.sql.rowset.RowSetFactory=com.jdojo.MyRowSetFactoryImpl com.jdojo.jdbc.Test
  • 服务提供者 API 在所有可用的 JAR 文件中查找运行时在META-INF/services/javax.sql.rowset.RowSetFactory下的类名。
  • 它为RowSetFactory接口寻找平台的默认实现。

RowSetProvider类有另一个名为newFactory(String factoryClassName, ClassLoader cl)的静态方法,让您指定要使用的RowSetFactory实现的类名。当运行时有多个RowSetFactory提供者可用,并且您想要使用一个特定的提供者时,这种方法非常有用。假设您已经创建了一个实现了RowSetFactory接口的类com.jdojo.jdbc.MyRowSetFactory。下面的代码片段显示了如何使用这个版本的方法来使用您自己的RowSetFactory类的实现:

String factoryClassName = "com.jdojo.jdbc.MyRowSetFactory";

// Use null as the second argument to use the current Thread's context classLoader

RowSetFactory factory = RowSetProvider.newFactory(factoryClassName, null);

// Create a JdbcRowSet

JdbcRowSet jdbcRs = factory.createJdbcRowSet();

设置行集连接属性

一个RowSet是一个 JavaBeans 组件。您可以使用可视化开发工具在设计时设置其属性。您也可以在运行时设置它的属性。通常情况下,RowSet需要连接到数据源来检索和更新数据。您可以根据 JDBC URL 或数据源名称来设置RowSet的数据库连接属性。当您使用 JDBC URL 时,RowSet将使用 JDBC 驱动程序连接到向DriverManager类注册的数据库。您可以如下设置RowSet对象的 JDBC 连接属性:

// Register the JDBC driver with the DriverManager here

// Create a RowSet

RowSet rs = create a RowSet;

// Set the conection properties for the RowSet

rs.setUrl("jdbc:derby:beginningJavaDB");

rs.setUsername("root");

rs.setPassword("chanda");

您不需要建立到数据库的连接。RowSet将在需要时负责建立连接。

或者,您可以为RowSet对象设置一个数据源名称。它将使用 JNDI 服务查找数据源名称,以获得一个用于连接数据库的DataSource对象。

RowSet rs = create a RowSet;

rs.setDataSourceName("jdbc/myTestDB");

您需要设置数据源名称或 JDBC URL。如果两者都设置,则最近设置的非空值将用于连接到数据库。

并非所有的RowSets都连接到数据库。例如,如果您使用一个RowSet通过网络发送数据,您不需要设置它的连接属性。然而,如果一个RowSet对象需要与一个数据库交互,那么您必须在调用任何需要数据库连接的RowSet的方法之前设置这些属性。

为行集设置命令

您在上一节中了解到,您不需要担心一个Connection对象使用一个RowSet。使用RowSet的好处不止于此。当你使用RowSet时,你也不需要担心StatementPreparedStatementCallableStatement物体。但是,您必须指定一个命令来为RowSet对象生成结果集。该命令将以 SQL SELECT语句或存储过程调用的形式出现在字符串中。您可以使用问号作为运行时要传递给命令的任何参数的占位符。要在运行时设置参数值,您需要使用RowSet接口的setXxx(int paramIndex, Xxx paramValue)方法之一。使用RowSet命令中的参数与使用PreparedStatement的参数是一样的。以下代码片段包含为RowSet对象设置命令的一些示例:

RowSet rs = create a RowSet;

/* Example 1 */

// Command to select all rows from the person table

String sqlCommand = "select person_id, first_name, last_name from person";

// Set the command to the RowSet object

rs.setCommand(sqlCommand);

/* Example 2 */

// Command to select rows from the person table with two parameters that

// will be the range of the income

String sqlCommand = "select person_id, first_name, last_name, income " +

"from person " +

"where income between ? and ?;

// Set the command to the RowSet object

rs.setCommand(sqlCommand);

// Set the range of income between 20000.0 and 30000.0

rs.setDouble(1, 20000.0);

rs.setDouble(2, 30000.0);

/* Example 3 */

// Command to execute a stored procedure that accepts two parameters that will //be the

// range of the income. The getPersons() stored procedure produces a result set

String sqlCommand = "{call getPersons(?, ?)}";

// Set the command to the RowSet object

rs.setCommand(sqlCommand);

// Set the range of income between 20000.0 and 30000.0

rs.setDouble(1, 20000.0);

rs.setDouble(2, 30000.0);

用数据填充行集

一个RowSet可以用多种方式填充数据:

  • 通过执行诸如 SQL SELECT或存储过程之类的命令
  • 通过给它提供一个ResultSet,它将从提供的ResultSet中读取所有数据
  • 通过将 XML 数据读入其中
  • 通过使用任何其他自定义方法

如果您想通过执行命令向RowSet填充数据,您需要调用它的execute()方法,如下所示:

// Execute its command to populate the RowSet

rs.execute();

在执行了execute()方法之后,RowSet中有数据,您需要滚动到一行来读取/更新其列的值。填充RowSet的其他方法取决于RowSet的类型。我将在描述特定类型的RowSet的章节中讨论每种类型的一个例子

滚动浏览行集的行

简单来说,RowSetResultSet的包装器。它从ResultSet接口继承了所有的光标移动方法。默认情况下,所有的RowSet对象都是双向可滚动和可更新的。但是,请查看您的RowSet的实现文档,看看它是否对可滚动性或可更新性施加了任何限制。下面的代码片段显示了一个典型的 while 循环,用于滚动所有行并从行中读取一些列值。它与您用来滚动浏览ResultSet对象的工具相同。

RowSet rs = create a RowSet;

...

while(rs.next()) {

// Read values for person_id and first_name from the current row

int personID = rs.getInt("person_id");

String firstName = rs.getString("first_name");

// Perform other processing here

}

更新行集中的数据

更新RowSet中的数据类似于更新ResultSet中的数据。要更新一列的值,需要将光标移动到一行,使用一个updateXxx()方法为一列设置新值,并调用RowSetupdateRow()方法将更改永久保存在RowSet中。

要插入新行,需要通过调用RowSetmoveToInsertRow()方法将光标移动到插入行。您需要使用一种updateXxx()方法为插入行中的列设置值。最后,你调用RowSetinsertRow()方法。

要删除一行,需要将光标移动到要删除的行,调用RowSetdeleteRow()方法。

如何以及何时将对一个RowSet对象的更改传播到数据库取决于RowSet的类型。我将在接下来的几节中讨论更新不同类型的RowSet

Tip

您可以通过调用setReadOnly(true)方法将RowSet设为只读。

RowSetUtil 类

您需要在使用行集的示例中使用重复的代码,比如提供数据库连接属性、获取一个RowSetFactory实例以及打印一个RowSet的行。清单 6-19 包含了您将在本节中使用的RowSetUtil类的完整代码。它的setConnectionParameters()方法加载一个 JDBC 驱动程序并设置它的连接参数。它的getRowSetFactory()方法返回一个RowSetFactory实例。它的printPersonRecord()方法打印来自RowSet的记录,假设RowSet至少包含来自person表的person_idfirst_namelast_name列。

清单 6-19。帮助使用行集的实用程序类

// RowSetUtil.java

package com.jdojo.jdbc;

import java.sql.Driver;

import java.sql.DriverManager;

import java.sql.SQLException;

import javax.sql.RowSet;

import javax.sql.rowset.RowSetFactory;

import javax.sql.rowset.RowSetProvider;

public class RowSetUtil {

private static boolean driverLoaded = false;

public static void setConnectionParameters(RowSet rs) throws SQLException {

// Register the JDBC driver only once for your database

if (!driverLoaded) {

// Change the JDBC driver class for your database

Driver derbyEmbeddedDriver =

new org.apache.derby.jdbc.EmbeddedDriver();

DriverManager.registerDriver(derbyEmbeddedDriver);

driverLoaded = true;

}

// Set the rowset database connection properties

String dbURL = "jdbc:derby:beginningJavaDB;create=true;";

String userId = "root";

String password = "chanda";

rs.setUrl(dbURL);

rs.setUsername(userId);

rs.setPassword(password);

}

public static RowSetFactory getRowSetFactory() {

try {

RowSetFactory factory = RowSetProvider.newFactory();

return factory;

}

catch (SQLException e) {

throw new RuntimeException(e);

}

}

// Print person id and name for each person record

public static void printPersonRecord(RowSet rs) throws SQLException {

while (rs.next()) {

int personId = rs.getInt("person_id");

String firstName = rs.getString("first_name");

String lastName = rs.getString("last_name");

System.out.println("Row #" + rs.getRow() + ":" +

" Person ID:" + personId +

", First Name:" + firstName +

", Last Name:" + lastName);

}

System.out.println();

}

}

JdbcRowSet

JdbcRowSet也称为连接的行集,因为它总是保持数据库连接。你可以把一个JdbcRowSet想象成一个ResultSet的薄包装。由于ResultSet al方式维护数据库连接,JdbcRowSet也是如此。它添加了一些允许您配置连接行为的方法。您可以使用它的setAutoCommit()方法来启用或禁用连接的自动提交模式。您可以使用它的commit()rollback()方法来提交或回滚对其数据所做的更改。

JDBC 驱动程序或基础数据库可能不支持双向可滚动和可更新的结果集。在这种情况下,JdbcRowSet实现可以提供这样的特性。清单 6-20 使用一个JdbcRowSetperson表中读取指定范围内所有person_id的记录。注意,代码试图通过使用RowSetlast()方法来打印检索到的行数。最后,它使用RowSetUtil类的printPersonRecord()方法打印行集中的记录。

清单 6-20。使用 JdbcRowSet 从表中读取记录

// JdbcRowSetTest.java

package com.jdojo.jdbc;

import java.sql.SQLException;

import javax.sql.rowset.JdbcRowSet;

import javax.sql.rowset.RowSetFactory;

public class JdbcRowSetTest {

public static void main(String[] args) {

RowSetFactory factory = RowSetUtil.getRowSetFactory();

// Use a try-with-resources block

try (JdbcRowSet jdbcRs = factory.createJdbcRowSet()) {

// Set the connection parameters

RowSetUtil.setConnectionParameters(jdbcRs);

// Set the command and input parameters

String sqlCommand = "select person_id, first_name, " +

"last_name from person " +

"where person_id between ? and ?";

jdbcRs.setCommand(sqlCommand);

jdbcRs.setInt(1, 101);

jdbcRs.setInt(2, 301);

// Retrieve the data

jdbcRs.execute();

// Scroll to the last row to get the row count It may throw an

// exception if the underlying JdbcRowSet implementation

// does not support a bi-directional scrolling result set.

try {

jdbcRs.last();

System.out.println("Row Count: " + jdbcRs.getRow());

// Position the cursor before the first row

jdbcRs.beforeFirst();

}

catch(SQLException e) {

System.out.println("JdbcRowSet implementation" +

" supports forward-only scrolling");

}

// Print the records in the rowset

RowSetUtil.printPersonRecord(jdbcRs);

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

使用JdbcRowSet更新数据类似于使用ResultSet更新数据。请确保为行集正确设置了自动提交模式。在使用JdbcRowSet的情况下,所有的方法都将用于JdbcRowSet对象,而不是ResultSet对象。

清单 6-21 包含检索一个人的记录并将其收入更新为 65000.00 的完整代码。注意,在更新列的值之后和滚动到另一行之前,必须调用JdbcRowSetupdateRow()方法。否则,您的更改将会丢失,就像在ResultSet中更新数据一样。在JdbcRowSet的情况下,你不能直接访问Connection对象。您需要使用JdbcRowSet对象的commit()rollback()方法来提交和回滚对数据库的更改。

清单 6-21。更新 JdbcRowSet 中的数据

// JdbcRowSetUpdateTest.java

package com.jdojo.jdbc;

import java.sql.SQLException;

import javax.sql.rowset.JdbcRowSet;

import javax.sql.rowset.RowSetFactory;

public class JdbcRowSetUpdateTest {

public static void main(String[] args) {

RowSetFactory factory = RowSetUtil.getRowSetFactory();

// Use a try-with-resources block

try (JdbcRowSet jdbcRs = factory.createJdbcRowSet()) {

// Set the connection parameters

RowSetUtil.setConnectionParameters(jdbcRs);

// Set the auto-commit mode to false

jdbcRs.setAutoCommit(false);

// Set the command and input parameters

String sqlCommand = "select person_id, first_name, " +

"last_name, income from person " +

"where person_id = ?";

jdbcRs.setCommand(sqlCommand);

jdbcRs.setInt(1, 101);

// Retrieve the data

jdbcRs.execute();

// If a row is retrieved, update the first row's income

// column to 65000.00

if (jdbcRs.next()) {

int personId = jdbcRs.getInt("person_id");

jdbcRs.updateDouble("income", 65000.00);

jdbcRs.updateRow();

// Commit the changes

jdbcRs.commit();

System.out.println("Income has been set to " +

"65000.00 for person_id=" + personId);

}

else {

System.out.println("No person record was found.");

}

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

行集

CachedRowSet也称为断开连接的行集,因为它在不需要数据库连接时会与数据库断开连接。它只在需要与数据库交互的期间保持数据库连接打开。一旦完成连接,它就会断开连接。例如,当它需要检索或更新数据时,它连接到数据库。

它检索该命令生成的所有数据,并将数据缓存在内存中。应注意不要在CachedRowSet中检索大量数据。否则,它可能会降低应用的性能。它提供了一个名为的新特性,可以让您以块的形式处理大量数据。在本节中,您将看到一个分页的示例。

一个CachedRowSet总是可序列化、可滚动和可更新的。您可以将其保存到磁盘或通过网络发送。并非所有的CachedRowSet都需要连接到数据源。例如,你可以在一个CachedRowSet中检索数据,并将它的副本发送给另一个应用,比如说一个运行在网络浏览器中的小应用。在这种情况下,applet 可以读取/更新CachedRowSet中的数据,而不需要数据库连接。当 applet 使用完CachedRowSet后,它可以将更新后的行集发送到服务器。CachedRowSet在 applet 中使用时不需要数据库连接。您可以使用以下四种方法之一在CachedRowSet对象中填充数据:

  • void execute() throws SQLException
  • void execute(Connection conn) throws SQLException
  • void populate(ResultSet data) throws SQLException
  • void populate(ResultSet rs, int startRow) throws SQLException

如果您已经为一个CachedRowSet设置了数据库连接属性,那么您可以使用方法。它将使用已经设置好的连接属性连接到数据库,并对行集执行命令以用数据填充它。另一个版本的execute()方法接受一个Connection,它将用于用数据填充CachedRowSet。使用populate()方法用来自ResultSet的数据填充CachedRowSet。另一个版本的populate()方法接受一个起始行号,从这里它将行从ResultSet读入CachedRowSet

使用CachedRowSet的方法时,需要注意一些限制。这个方法使用一个提供数据的ResultSet对象。在将ResultSet传递给该方法之前,可以将光标移动到特定的行。例如,假设当您将光标传递给populate()方法时,光标位于ResultSet的第十行。当你调用第一个版本的populate()方法时会发生什么?它会尝试读取ResultSet对象中的所有行还是会从第 11 行读取数据?当当前行是 10 时,从第 5 行开始调用第二个版本的populate()方法会发生什么?CachedRowSet接口中这些方法的 Java 文档没有提供这些情况的任何信息。细节由实现类决定。然而,如果您只是检索ResultSet对象并将其传递给populate()方法的任一版本,它将按预期运行。

您可以使用size()方法获得CachedRowSet中的行数。注意,对于一个JdbcRowSet,您需要将光标移动到最后一行,并调用它的getRow()方法来获取其中的行数。由于CachedRowSet在内存中缓存所有的行,所以它可以随时提供所有行的计数。注意size()方法不适用于JdbcRowSet

// Get the row count in a CachedRowSet

int rowCount = myCachedRowSet.size();

清单 6-22 演示了如何使用一个CachedRowSet从数据库中检索行。它类似于使用一个JdbcRowSet,除了您可以使用它的size()方法来获得检索的行数。一个CachedRowSet总是双向可滚动的。

清单 6-22。使用 CachedRowSet 检索数据

// CachedRowSetTest.java

package com.jdojo.jdbc;

import java.sql.SQLException;

import javax.sql.rowset.CachedRowSet;

import javax.sql.rowset.RowSetFactory;

public class CachedRowSetTest {

public static void main(String[] args) {

RowSetFactory factory = RowSetUtil.getRowSetFactory();

// Use a try-with-resources block

try (CachedRowSet cachedRs = factory.createCachedRowSet()) {

// Set the connection parameters

RowSetUtil.setConnectionParameters(cachedRs);

String sqlCommand = "select person_id, first_name, last_name " +

"from person " +

"where person_id between 101 and 501";

cachedRs.setCommand(sqlCommand);

cachedRs.execute();

// Print the records in cached rowset

System.out.println("Row Count: " + cachedRs.size());

RowSetUtil.printPersonRecord(cachedRs);

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

一个CachedRowSet提供了一个额外的特性,可以让您以块的形式检索由一个命令生成的行。一次检索的行块称为一个页面。您可以将页面视为一组行,您可以决定集合中的行数。一页中的最大行数称为页面大小。CachedRowSet让您通过调用它的setPageSize(int size)方法来设置页面大小。假设一个CachedRowSet命令生成 500 行。通过调用它的setPageSize(90),它将一次最多检索 90 行。当您调用它的execute()方法时,它将检索前 90 行。要检索接下来的 90 行,您需要调用它的nextPage()方法。当它已经检索了 5 页(450 行)时,调用nextPage()将检索剩余的 50 行。它还提供了一个previousPage()方法来检索上一页。您可以使用CachedRowSetnextPage()previousPage()方法来获取和处理大块的结果集。如果有更多的页面需要检索,这两种方法都会返回true。否则,它们返回false。通常,在使用分页功能时,会使用 do-while 循环和 while 循环。外部的 do-while 循环将滚动页面,内部的 while 循环将滚动当前页面中的行。以下代码片段显示了使用分页的CachedRowSet的典型处理逻辑:

CachedRowSet cachedRs = create and set properties for a cached rowset here;

// Set the page size to 90

cachedRs.setPagesize(90);

// Retrieves the first page

cachedRs.execute();

do {

// Process each row in the page

while(cachedRs.next()) {

// Process a row here...

}

// Retrieve the next page of rows

}

while (cachedRs.nextPage());

清单 6-23 包含了演示CachedRowSet分页特性的完整代码。它使用大小为 2 的页面一次一页地从person表中检索所有记录。通常,您不会在程序中检索表中的所有行。person的桌子只有几排。我这样做只是为了演示,让代码更简单、更小。

清单 6-23。使用 CachedRowSet 的分页功能

// CachedRowSetPagingTest.java

package com.jdojo.jdbc;

import java.sql.SQLException;

import javax.sql.rowset.CachedRowSet;

import javax.sql.rowset.RowSetFactory;

public class CachedRowSetPagingTest {

public static void main(String[] args) {

RowSetFactory factory = RowSetUtil.getRowSetFactory();

// Use a try-with-resources block

try (CachedRowSet cachedRs = factory.createCachedRowSet()) {

// Set the connection parameters

RowSetUtil.setConnectionParameters(cachedRs);

// Set the command and teh page size

String sqlCommand = "select person_id, first_name, last_name " +

"from person";

cachedRs.setCommand(sqlCommand);

cachedRs.setPageSize(2); // page size is 2

// Execute the command

cachedRs.execute();

int pageCounter = 1;

// Retrieve and print person records one page at a time

do {

System.out.println("Page #" + pageCounter +

" (Row Count=" + cachedRs.size() + ")");

// Print the record in the current page

RowSetUtil.printPersonRecord(cachedRs);

// Increment the page count by 1

pageCounter++;

}

while (cachedRs.nextPage());

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

您可以更新CachedRowSet中的数据,并将更改保存回数据库。为CachedRowSet保存更改到数据库的过程与JdbcRowSet不同。让CachedRowSet的保存过程有所不同有两个主要原因。首先,它是断开的,您不想经常连接到数据库。其次,更新的数据可能与数据库中存储的数据有冲突。

CachedRowSet中插入、更新和删除行的过程与在JdbcRowSet中相同。更改当前行的值后,需要调用updateRow()方法。与JdbcRowSet不同,当您调用updateRow()方法时,CachedRowSet不会将更改发送到数据库。使用insertRow()deleteRow()方法的方式与使用ResultSetJdbcRowSet方法的方式相同。当与CachedRowSet一起使用时,这些方法不会将更改发送到数据库。

在您对一个CachedRowSet进行更改之后,您可以通过调用它的方法将更改发送到数据库,如果您已经将commit-on-accept-change的值设置为true,该方法可能会提交更改。关于如何让你设置commit-on-accept-change值,你需要参考CachedRowSet的实现细节。如果设置为false,则需要使用CachedRowSet接口的commit()rollback()方法提交或回滚更改。

一个CachedRowSet必须处理其中的数据和数据库中的数据之间可能存在的冲突。例如,您可能已经从数据库中检索了一行,更改了数据,并将更改保存在CachedRowSet中很长一段时间。当您准备好保存更改时,另一个用户可能已经在您之前更改了相同行的值。一个CachedRowSet使用一个同步提供者对象来同步数据库的变化。它使用另一个对象(同步解析器)来解决在同步过程中检测到的任何冲突。当在acceptChanges()方法调用期间检测到冲突时,它抛出一个SyncProviderException。您可以使用SyncProviderException对象的getSyncResolver()方法获得同步解析器对象,它是SyncResolver接口的一个实例。SyncResolver对象允许您浏览所有冲突,并将冲突行中的值更改为新的已解决值。当检测到冲突时,您需要使用SyncResolver对象的setResolvedValue()方法来设置已解决的值。

清单 6-24 展示了如何更新一个CachedRowSet。当检测到冲突时,它不会为数据元素设置已解析的值。相反,它只是打印冲突的细节。

清单 6-24。更新和检测 CachedRowSet 中的冲突

// CachedRowSetUpdateTest.

package com.jdojo.jdbc;

import java.sql.SQLException;

import javax.sql.rowset.CachedRowSet;

import javax.sql.rowset.RowSetFactory;

import javax.sql.rowset.spi.SyncProviderException;

import javax.sql.rowset.spi.SyncResolver;

import static javax.sql.rowset.spi.SyncResolver.DELETE_ROW_CONFLICT;

import static javax.sql.rowset.spi.SyncResolver.INSERT_ROW_CONFLICT;

import static javax.sql.rowset.spi.SyncResolver.UPDATE_ROW_CONFLICT;

public class CachedRowSetUpdateTest {

public static void main(String[] args) throws SQLException {

RowSetFactory factory = RowSetUtil.getRowSetFactory();

CachedRowSet cachedRs = factory.createCachedRowSet();

try {

// Set the connection parameters for the CachedRowSet

RowSetUtil.setConnectionParameters(cachedRs);

String sqlCommand = "select person_id, first_name, last_name, "

+ "gender, dob, income "

+ "from person "

+ "where person_id between 101 and 301";

cachedRs.setKeyColumns(new int[]{1});

cachedRs.setCommand(sqlCommand);

cachedRs.execute();

// Print the records in the cached rowset

System.out.println("Before Update");

System.out.println("Row Count: " + cachedRs.size());

RowSetUtil.printPersonRecord(cachedRs);

// Update income to 23000.00 for the first row

if (cachedRs.size() > 0) {

updateRow(cachedRs, 1, 23000.00);

}

// Insert a new row

insertNewRow(cachedRs);

// Send changes to the database

cachedRs.acceptChanges();

System.out.println("After Update");

System.out.println("Row Count: " + cachedRs.size());

cachedRs.beforeFirst();

RowSetUtil.printPersonRecord(cachedRs);

}

catch (SyncProviderException spe) {

// When acceptChanges() detects some conflicts

SyncResolver resolver = spe.getSyncResolver();

// Print the details about the conflicts

printConflicts(resolver, cachedRs);

}

catch (SQLException e) {

e.printStackTrace();

}

finally {

if (cachedRs != null) {

try {

cachedRs.close();

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

}

public static void insertNewRow(CachedRowSet cachedRs) throws SQLException {

// Move cursor to the insert-row

cachedRs.moveToInsertRow();

// Set the values for columns in the new row

cachedRs.updateInt("person_id", 751);

cachedRs.updateString("first_name", "Mason");

cachedRs.updateString("last_name", "Baker");

cachedRs.updateString("gender", "M");

cachedRs.updateDate("dob", java.sql.Date.valueOf("2006-01-02"));

cachedRs.updateDouble("income", 0.00);

// Insert the new row in the rowset. It is not sent to the

// database, until the acceptChanges() method is called

cachedRs.insertRow();

// Must move back to the current row

cachedRs.moveToCurrentRow();

}

public static void updateRow(CachedRowSet cachedRs, int row, double newIncome)

throws SQLException {

// Set the values for columns in the new row

cachedRs.absolute(row);

cachedRs.updateDouble("income", newIncome);

cachedRs.updateRow();

}

public static void printConflicts(SyncResolver resolver, CachedRowSet cachedRs) {

try {

while (resolver.nextConflict()) {

int status = resolver.getStatus();

String operation = "None";

if (status == INSERT_ROW_CONFLICT) {

operation = "insert";

}

else if (status == UPDATE_ROW_CONFLICT) {

operation = "update";

}

else if (status == DELETE_ROW_CONFLICT) {

operation = "delete";

}

// Get person_id from the database

Object oldPersonId

= resolver.getConflictValue("person_id");

// Get person ID from the cached rowset

int row = resolver.getRow();

cachedRs.absolute(row);

Object newPersonId = cachedRs.getObject("person_id");

// Use setResolvedValue() method to set resolved value

// for a column

// resolver.setResolvedValue(columnName,resolvedValue);

System.out.println("Conflict detected in row #"

+ row

+ " during " + operation + " operation."

+ " person_id in database is " + oldPersonId

+ " and person_id in rowset is " + newPersonId);

}

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

网络浏览器

WebRowSet接口继承自CachedRowSet接口。它为CachedRowSet增加了两个特性:从 XML 文档中读取数据和元数据,并将数据和元数据导出到 XML 文档中。它添加了两种方法来提供 XML 支持,分别是readXML()writeXML()。两个都超负荷了。它们接受基于流或基于字符的源/接收器。使用readXML()方法将 XML 数据、属性和元数据从源(a java.io.InputStreamjava.io.Reader)读入WebRowSet,并使用writeXML()方法将数据、属性和元数据从WebRowSet对象写入目的地,目的地可以是java.io.OutputStreamjava.io.Writer。以下代码片段显示了如何将WebRowSet的内容和属性导出到字符串:

WebRowSet webRs = get a web rowset with data...;

// Create a StringWriter object to hold the exported XML

StringWriter sw = new StringWriter();

// Write the XML representation of webRs into sw

webRs.writeXml(sw);

// Get the String object from sw

String webRsXML = sw.toString();

此时,webRsXML包含了webRs对象的 XML 表示。您可以将它传递给应用的另一个模块,在那里您可以用相同的数据、属性和元数据重新创建WebRowSet。下面的代码片段展示了如何将 XML 文档导入到WebRowSet中:

// Create a StringReader object from an XML string

StringReader sr = new StringReader(webRsXML);

// Create an empty WebRowSet object

RowSetFactory factory = RowSetUtil.getRowSetFactory();

WebRowSet newWebRs = factory.createWebRowSet();

// Import (or read) the XML contents into the new, empty WebRowSet

newWebRs.readXml(sr);

此时,webRsnewWebRs处于相同的状态。一个WebRowSet可以很容易地将其内容导出为 XML,并将 XML 文档导入其中。您可以使用这些过程来获取 XML 文档并将其发送到另一个应用,比如一个 applet,它不需要通过 JDBC 连接到数据库。当 applet 完成对WebRowSet的更改后,它可以将其导出为 XML 文档,并将其传递给另一个具有 JDBC 连接的应用,以便将更改与数据库同步。

WebRowSet导出的 XML 包含三组信息:属性、元数据和数据。属性是指为行集设置的属性。元数据包含有关行集中列的信息,如列计数、列名、列数据类型等。XML 中的数据节包含行集中的原始数据和已更改数据。

清单 6-25 展示了如何将一个WebRowSet对象导出为 XML。您将在这个清单的输出中发现三个元素:<properties><metadata><data>。该程序更改在行集中检索到的第一行的姓氏。您可能会注意到行集跟踪其数据中所做的更改,如第一行中出现的<updateRow>元素所示。运行这个程序时,您可能会得到不同的输出。输出取决于您在person表中的数据。

清单 6-25。将 WebRowSet 的状态导出为 XML 文档

// WebRowSetXMLTest.java

package com.jdojo.jdbc;

import java.io.StringWriter;

import java.sql.SQLException;

import javax.sql.rowset.RowSetFactory;

import javax.sql.rowset.WebRowSet;

public class WebRowSetXMLTest {

public static void main(String[] args) {

RowSetFactory factory = RowSetUtil.getRowSetFactory();

// Use a try-with-resources block

try (WebRowSet webRs = factory.createWebRowSet()) {

// Set the connection parameters for the WebRowSet

RowSetUtil.setConnectionParameters(webRs);

String sqlCommand = "select person_id, first_name, last_name " +

"from person " +

"where person_id between ? and ?";

webRs.setCommand(sqlCommand);

webRs.setInt(1, 101);

webRs.setInt(2, 102);

webRs.execute();

// Change the last name for the first record

if (webRs.first()) {

webRs.updateString("last_name", "Who knows?");

}

// Get the XML representation of of the WebRowSet

StringWriter sw = new StringWriter();

webRs.writeXml(sw);

String webRsXML = sw.toString();

// Print the exported XML from the WebRowSet

System.out.println(webRsXML);

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

<?xml version="1.0"?>

<webRowSet xmlns="http://java.sun.com/xml/ns/jdbc" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance

xsi:schemaLocation=" http://java.sun.com/xml/ns/jdbc http://java.sun.com/xml/ns/jdbc/webrowset.xsd

<properties>

<command>select person_id, first_name, last_name from person where person_id between ? and ?</command>

<concurrency>1008</concurrency>

<datasource><null/></datasource>

<escape-processing>true</escape-processing>

<fetch-direction>1000</fetch-direction>

<fetch-size>0</fetch-size>

<isolation-level>2</isolation-level>

<key-columns>

</key-columns>

<map>

</map>

<max-field-size>0</max-field-size>

<max-rows>0</max-rows>

<query-timeout>0</query-timeout>

<read-only>true</read-only>

<rowset-type>ResultSet.TYPE_SCROLL_INSENSITIVE</rowset-type>

<show-deleted>false</show-deleted>

<table-name>person</table-name>

<url>jdbc:derby:beginningJavaDB;create=true;</url>

<sync-provider>

<sync-provider-name>com.sun.rowset.providers.RIOptimisticProvider</sync-provider-name>

<sync-provider-vendor>Oracle Corporation</sync-provider-vendor>

<sync-provider-version>1.0</sync-provider-version>

<sync-provider-grade>2</sync-provider-grade>

<data-source-lock>1</data-source-lock>

</sync-provider>

</properties>

<metadata>

<column-count>3</column-count>

<column-definition>

<column-index>1</column-index>

<auto-increment>false</auto-increment>

<case-sensitive>false</case-sensitive>

<currency>false</currency>

<nullable>0</nullable>

<signed>true</signed>

<searchable>true</searchable>

<column-display-size>11</column-display-size>

<column-label>PERSON_ID</column-label>

<column-name>PERSON_ID</column-name>

<schema-name>ROOT</schema-name>

<column-precision>10</column-precision>

<column-scale>0</column-scale>

<table-name>PERSON</table-name>

<catalog-name></catalog-name>

<column-type>4</column-type>

<column-type-name>INTEGER</column-type-name>

</column-definition>

<column-definition>

<column-index>2</column-index>

<auto-increment>false</auto-increment>

<case-sensitive>true</case-sensitive>

<currency>false</currency>

<nullable>0</nullable>

<signed>false</signed>

<searchable>true</searchable>

<column-display-size>20</column-display-size>

<column-label>FIRST_NAME</column-label>

<column-name>FIRST_NAME</column-name>

<schema-name>ROOT</schema-name>

<column-precision>20</column-precision>

<column-scale>0</column-scale>

<table-name>PERSON</table-name>

<catalog-name></catalog-name>

<column-type>12</column-type>

<column-type-name>VARCHAR</column-type-name>

</column-definition>

<column-definition>

<column-index>3</column-index>

<auto-increment>false</auto-increment>

<case-sensitive>true</case-sensitive>

<currency>false</currency>

<nullable>0</nullable>

<signed>false</signed>

<searchable>true</searchable>

<column-display-size>20</column-display-size>

<column-label>LAST_NAME</column-label>

<column-name>LAST_NAME</column-name>

<schema-name>ROOT</schema-name>

<column-precision>20</column-precision>

<column-scale>0</column-scale>

<table-name>PERSON</table-name>

<catalog-name></catalog-name>

<column-type>12</column-type>

<column-type-name>VARCHAR</column-type-name>

</column-definition>

</metadata>

<data>

<currentRow>

<columnValue>101</columnValue>

<columnValue>John</columnValue>

<columnValue>Jacobs</columnValue>

<updateRow>Who knows?</updateRow>

</currentRow>

<currentRow>

<columnValue>102</columnValue>

<columnValue>Donna</columnValue>

<columnValue>Duncan</columnValue>

</currentRow>

</data>

</webRowSet>

谁来决定一个WebRowSet实现应该理解的 XML 格式?如果WebRowSet的所有实现使用不同的 XML 格式,那么从使用一个实现的WebRowSet导出的 XML 不能导入到使用另一个实现的WebRowSet对象中。为了避免这种可移植性问题,Oracle 为标准的WebRowSet XML 格式提供了一个 XML 模式。该模式在 http://java.sun.com/xml/ns/jdbc/webrowset.xsd 可用。标准的WebRowSet实现应该使用这个模式来导出和导入WebRowSet实现,以确保与其他实现的可移植性。

过滤器行集

FilteredRowSet接口继承自WebRowSet接口。它在客户端为行集提供筛选功能。您可以在 SQL 命令中使用 where 子句对行集应用筛选器,该命令在数据库中执行。FilteredRowSet允许您在从数据库中检索数据后过滤行集的行。您可以将FilteredRowSet视为一个行集,它允许您基于一组标准(称为过滤器)查看它的行。对行集设置筛选器不会从行集中删除行。相反,它只允许您访问那些符合过滤标准的行。该筛选器还适用于插入、更新和删除行集中的行。您只能读取、插入、更新和删除符合筛选条件的行。您可以在任何想要查看行集的所有行的时候重置筛选器。过滤器是实现javax.sql.rowset.Predicate接口的类的对象。下面是Predicate接口的声明:

public interface Predicate {

boolean evaluate(RowSet rs);

boolean evaluate(Object value, int colIndex) throws SQLException;

boolean evaluate(Object value, String colName) throws SQLException;

}

如果该方法为一行返回true,那么它就是可见的。否则,它会被过滤掉,您无法访问它。所有三个版本的evaluate()方法都在内部调用。Oracle 对行集的引用实现没有为Predicate接口提供实现。在这一节中,我将讨论一个Predicate接口的实现。

FilteredRowSet接口增加了两个方法,一个是设置过滤器,一个是获取过滤器:

  • Predicate getFilter()
  • void setFilter(Predicate filter) throws SQLException

方法为行集设置筛选器。将null设置为过滤器会从FilteredRowSet中重置(或移除)过滤器,并使所有行都可以访问。您可以将过滤器设置为FilteredRowSet,如下所示:

// Create a FilteredRowSet

FilteredRowSet filteredRs = create a filtered row set;

// Set properties and retrieve data in the rowset

// Create a Filter

Predicate filter = create a filter object;

// Set the filter

filteredRs.setFilter(filter);

// Work with the filtered rowset here

// Remove the filter

filteredRs.setFilter(null);

清单 6-26 包含了实现范围过滤器的代码。它基于数值列的范围。

清单 6-26。谓词接口的实现

// RangeFilter.java

package com.jdojo.jdbc;

import java.sql.SQLException;

import javax.sql.RowSet;

import javax.sql.rowset.Predicate;

public class RangeFilter implements Predicate {

private final int columnIndex;

private final String columnName;

private final double min;

private final double max;

public RangeFilter(int columnIndex, String columnName,

double min, double max) {

this.columnIndex = columnIndex;

this.columnName = columnName;

this.min = min;

this.max = max;

}

@Override

public boolean evaluate(RowSet rs) {

// Make sure we have a good row number to evaluate

try {

if (rs.getRow() <= 0) {

return false;

}

}

catch (SQLException e) {

e.printStackTrace();

}

boolean showRow = false;

Object value = null;

try {

value = rs.getObject(columnName);

if (value instanceof Number) {

double num = ((Number) value).doubleValue();

showRow = (num >=min && num <= max);

}

}

catch (SQLException e) {

showRow = false;

e.printStackTrace();

throw new RuntimeException(e);

}

return showRow;

}

@Override

public boolean evaluate(Object value, int columnIndex) {

boolean showRow = false;

if (columnIndex == this.columnIndex

&& value instanceof Number) {

double num = ((Number) value).doubleValue();

showRow = (num >=min && num <= max);

}

return showRow;

}

@Override

public boolean evaluate(Object value, String columnName) {

boolean showRow = false;

if (this.columnName.equalsIgnoreCase(columnName)

&& value instanceof Number) {

double num = ((Number)value).doubleValue();

showRow = (num >=min && num <= max);

}

return showRow;

}

}

假设person_id是您的行集中的第一列,并且您只希望看到person_id在 101 和 501 之间的行。您可以使用RangeFilter类的对象为行集设置过滤器,如下所示:

FilteredRowSet filteredRs = get a filtered row set...;

Predicate filter = new RangeFilter(1, "person_id", 101, 501);

filteredRs.setFilter(filter);

RangeFilter类是Predicate接口的简单实现。您需要有一个稍微复杂一点的可以在生产环境中使用的实现。例如,您可以允许基于多列的筛选条件。

清单 6-27 展示了如何使用一个FilteredRowSet。该程序的输出将取决于person表中的数据。在SQL SELECT(使用WHERE子句)中,FilteredRowSet不能替代过滤器。您不应该在FilteredRowSet中检索大量的行并设置过滤器。这可能会降低应用的性能。当您在程序中获得断开连接(或缓存)的行集,并且无法控制其检索过程时,应该使用它。如果您的FilteredRowSet不表示数据库表中的行,比如从平面文件中检索数据,这也很有用。

清单 6-27。使用 FilteredRowSet

// FilteredRowSetTest.java

package com.jdojo.jdbc;

import java.sql.SQLException;

import javax.sql.rowset.Predicate;

import javax.sql.rowset.FilteredRowSet;

import javax.sql.rowset.RowSetFactory;

public class FilteredRowSetTest {

public static void main(String[] args) {

RowSetFactory factory = RowSetUtil.getRowSetFactory();

// Use a try-with-resources block

try (FilteredRowSet filteredRs

= factory.createFilteredRowSet()) {

// Set the connection parameters

RowSetUtil.setConnectionParameters(filteredRs);

// Prepare, set, and execute the command

String sqlCommand= "select person_id, first_name, last_name " +

"from person";

filteredRs.setCommand(sqlCommand);

filteredRs.execute();

// Print the retrieved records

System.out.println("Before Filter - Row count: " +

filteredRs.size());

RowSetUtil.printPersonRecord(filteredRs);

// Set a filter

Predicate filter = new RangeFilter(1, "person_id", 101, 102);

filteredRs.setFilter(filter);

// Print the retrieved records

System.out.println("After Filter - Row count: " +

filteredRs.size());

filteredRs.beforeFirst();

RowSetUtil.printPersonRecord(filteredRs);

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

JoinRowSet

JoinRowSet接口继承自WebRowSet接口。它提供了将两个或多个断开连接的行集组合(或联接)成一个行集的能力。使用 SQL JOIN将两个或多个表中的行连接到一个查询中。JoinRowSet允许您在两个或更多行集之间使用 SQL JOIN,而不需要在查询中使用 SQL JOIN

使用JoinRowSet很容易。您在多个行集中检索数据:CachedRowSetWebRowSetFilteredRowSet。创建一个空的JoinRowSet,并通过调用它的addRowSet()方法将所有行集添加到其中。当添加更多行集时,添加到JoinRowSet的第一个行集成为建立连接的参考行集。您可以单独指定行集中的JOIN列,也可以在将行集添加到。

SQL JOIN有五种标准类型:

  • INNER_JOIN
  • LEFT_OUTER_JOIN
  • RIGHT_OUTER_JOIN
  • FULL_JOIN
  • CROSS_JOIN

一个JoinRowSet允许您在行集之间建立所有上述的 SQL JOIN。除了CROSS_JOIN,它给出了行集中行的笛卡尔积,所有其他连接都基于连接的行集中的匹配列。有两种方法可以指定匹配列:

  • 如果参与JOIN的行集实现了Joinable接口,您可以使用它的setMatchColumn()方法之一来指定JOIN列。Joinable接口定义了多个版本的setMatchColumn()方法和其他方法来处理JOIN列。
  • 当使用addRowSet()方法之一将行集添加到JoinRowSet时,可以设置JOIN列。

JoinRowSet接口的实现可能不支持所有五种类型的JOIN。您可以使用JoinRowSet接口的以下五种方法来检查实现是否支持特定的SQL JOIN类型:

  • boolean supportsInnerJoin()
  • boolean supportsLeftOuterJoin()
  • boolean supportsRightOuterJoin()
  • boolean supportsFullJoin()
  • boolean supportsCrossJoin()

您可以使用其方法在一个JoinRowSet中指定一个JOIN类型,该方法接受五个JOIN常量之一:INNER_JOINLEFT_OUTER _ JOINRIGHT_OUTER_JOINFULL_JOINCROSS_JOIN。默认情况下,它使用INNER_JOIN,这是基于匹配列的相等性。

您必须至少有两个行集才能使用JoinRowSet。让一个JoinRowSet只保存一个行集中的行是没有意义的。它的名字“Join”本身就意味着它代表了至少两个行集之间的一个JOIN。联接行集中列的名称或索引不必相同。联接列的数据类型不必相同。但是,联接列的数据类型必须能够比较它们的值。

在前面的例子中,您只使用了person表。您仍然可以只使用一个表来基于person_id列形成一个SQL JOIN。您的第一个行集将从person表中选择person_idfirst_name。第二个行集将从person表中选择person_idlast_name。您将使用INNER_JOIN连接基于person_id的两个行集,这是JoinRowSet的默认设置。清单 6-28 展示了如何使用JoinRowSet来实现这一点。

清单 6-28。使用 JoinRowSet 建立 SQL 连接

// JoinRowSetTest.java

package com.jdojo.jdbc;

import java.sql.SQLException;

import javax.sql.rowset.CachedRowSet;

import javax.sql.rowset.JoinRowSet;

import javax.sql.rowset.RowSetFactory;

public class JoinRowSetTest {

public static void main(String[] args) {

RowSetFactory factory = RowSetUtil.getRowSetFactory();

// Use a try-with-resources block

try (CachedRowSet cachedRs1 = factory.createCachedRowSet();

CachedRowSet cachedRs2 = factory.createCachedRowSet();

JoinRowSet joinRs = factory.createJoinRowSet() ) {

// Set the connection parameters

RowSetUtil.setConnectionParameters(cachedRs1);

RowSetUtil.setConnectionParameters(cachedRs2);

String sqlCommand1 = "select person_id, first_name " +

"from person " +

"where person_id in (101, 102)";

String sqlCommand2 = "select person_id, last_name " +

"from person " +

"where person_id in (101, 102, 103)";

cachedRs1.setCommand(sqlCommand1);

cachedRs2.setCommand(sqlCommand2);

cachedRs1.execute();

cachedRs2.execute();

// Create a JoinRowSet for cachedRs1 and cachedRs2

// joining them based on the person_id column

joinRs.addRowSet(cachedRs1, "person_id");

joinRs.addRowSet(cachedRs2, "person_id");

System.out.println("Row Count: "  + joinRs.size());

RowSetUtil.printPersonRecord(joinRs);

}

catch (SQLException e) {

e.printStackTrace();

}

}

}

您只能将非空行集添加到JoinRowSet。将空行集添加到JoinRowSet会抛出一个SQLExceptionJoinRowSet可以实现基于多列的 SQL JOIN。添加到JoinRowSet的行集数量没有限制。但是,应该注意不要在一个JoinRowSet中添加太多包含大量行的行集。这可能会降低应用的速度,因为需要对大量行执行JOIN操作。

JoinRowSet的方法返回一个CachedRowSet,表示基于其中建立的JOIN的行。返回的CachedRowSet不包含通过JoinRowSet对数据进行的任何更改。您可以对JoinRowSet中的数据进行修改,并将修改应用到数据库中,就像您对CachedRowSet所做的那样。确保在调用acceptChanges()方法之前设置了JoinRowSet所需的属性。例如,您需要设置它的数据库连接属性、命令等。,因此它将拥有将更改应用到数据库所需的信息。

使用大型对象(LOB)

JDBC API 支持处理存储在数据库中的大型对象。大型对象的类型可能是以下类型之一。

  • 二进制大对象
  • 字符大对象
  • 国家字符大对象(NClob)

LOB 列的数据通常不存储在数据库表本身中。数据库将 LOB 的数据存储在其他位置。它存储表中数据位置的引用(或指针)。对存储在表中的 LOB 的引用也称为。LOB 列的数据是存储在表中还是存储在其他位置由 DBMS 根据某些标准决定。例如,DBMS 可以决定,如果 LOB 的大小小于 10k,它将把它存储在表中,如果它变大,它将存储在其他位置,而表将存储定位器。当您检索 LOB 类型的列的数据时,通常 JDBC 驱动程序只检索 LOB 的定位器。当您需要实际的数据时,您需要在定位器上执行更多的操作来获取数据。通常 LOB 的定位器比实际数据的指针拥有更多的数据信息,比如它知道数据的长度。

Blob 用于存储二进制数据。Clob 用于存储字符数据。NClob 用于存储 Unicode 字符数据。请查阅 DBMS 文档,了解它用于 Blob、Clob 和 NClob 类型 lob 的数据类型名称。Oracle DBMS 具有与BlobClobNClob相同的名称,作为可以用来定义表中列的数据类型。JDBC API 允许您分别使用java.sql.Blobjava.sql.Clobjava.sql.NClob接口来处理 Blob、Clob 和 NClob。

您将完成一个使用BlobClob数据类型的例子。该示例将使用一个 Java DB 数据库。Java DB 分别通过其 Blob 和 Clob 数据库数据类型支持 lob 的 Blob 和 NClob 类型。

让我们创建一个名为person_detail的表,用于将一个人的照片存储为 Blob,并将他的纯文本简历存储在 Clob 列中。以下是在 Java DB 中创建表的脚本:

create table person_detail (

person_detail_id integer not null,

person_id integer not null,

picture blob,

resume clob,

primary key (person_detail_id),

foreign key (person_id) references person(person_id)

);

您可以运行清单 6-29 中的程序在 Java DB 数据库中创建person_detail表,假设JDBCUtil.getConnection()方法被配置为向 Java DB 数据库返回一个Connection。如果您使用的是 Java DB 之外的 DBMS,请更改清单 6-29 中程序的CREATE TABLE脚本,以匹配您的数据库的语法。

清单 6-29。在 Java DB 中创建 person_detail 表

// CreatePersonDetailTable.java

package com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.Statement;

public class CreatePersonDetailTable {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Create a SQL string

String SQL = "create table person_detail( " +

"person_detail_id integer not null, " +

"person_id integer not null, " +

"picture blob, " +

"resume clob, " +

"primary key (person_detail_id), " +

"foreign key (person_id) references person(person_id))";

Statement stmt = null;

try {

stmt = conn.createStatement();

stmt.executeUpdate(SQL);

}

finally {

JDBCUtil.closeStatement(stmt);

}

// Commit the transaction

JDBCUtil.commit(conn);

System.out.println("Person table created successfully.");

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

}

正在检索 LOB 数据

您可以分别使用ResultSet接口的getBlob()getClob()getNClob()方法从结果集中检索 Blob、Clob 和 NClob 列的数据。这些方法分别返回java.sql.Blobjava.sql.Clobjava.sql.NClob接口的一个对象。这些接口包括许多方法,允许您查询 LOB 对象并操作它们所表示的数据。以下代码片段从person_detail表中读取等于 1001 的person_detail_id的行:

Connection conn = JDBCUtil.getConnection();

String SQL = "select person_id, picture, resume " +

"from person_detail " +

"where person_detail_id = ?";

PreparedStatement pstmt = null;

pstmt = conn.prepareStatement(SQL);

pstmt.setInt(1, 1001);

ResultSet rs = pstmt.executeQuery();

while(rs.next()) {

int personId = rs.getInt("person_id");

Blob pictureBlob = rs.getBlob("picture");

Clob resumeClob = rs.getClob("resume");

}

在您从ResultSet中获得一个BlobClob对象后,您将需要读取数据。BlobClob接口包含一个方法,该方法返回Blob对象中的字节数和Clob对象中的字符数。NClob接口继承了Clob接口。对Clob接口的讨论也适用于NClob接口。如果你想在一个字节数组中读取一个Blob的数据,在一个String对象中读取Clob的数据,你可以这样做。注意,BlobClob接口的length()方法返回long

// Read picture in a byte array

int pictureLength = (int)pictureBlob.length();

byte[] pictureData = pictureBlob.getBytes(1, pictureLength);

// Read resume in a string

int resumeLength = (int)resumeClob.length();

String resume = resumeClob.getSubString(1, resumeLength);

Blob接口的getBytes(int start, int length)方法中,第一个参数是字节在Blob对象中的起始位置,第二个参数是要读取的字节数。Blob对象中第一个字节的位置是 1,而不是 0。类似地,Clob接口的getSubString(int start, int length)方法接受字符在Clob对象中的起始位置和要返回的字符数。Clob对象中第一个字符的位置是 1,而不是 0。

Tip

在 JDBC 程序的任何上下文中使用起始位置时都要小心。在 JDBC API 中,事情从位置 1 开始,而在 Java 的其他部分如数组中,事情从位置 0 开始。

大多数时候,你不会在一个数组或者一个String对象中读取BlobClob的数据。它们可能包含大量数据。BlobClob接口让你分别使用InputStreamReader来读取它们的数据。通常,您会从BlobClob对象中读取数据,并将它们存储在磁盘上的一个文件中。这是你怎么做的。Blob接口包含一个getBinaryStream()方法,该方法返回一个InputStream。您可以使用那个InputStream来读取包含在Blob对象中的数据。类似地,Clob接口包含一个getCharacterStream()方法,该方法返回一个Reader。您可以使用那个Reader对象来读取包含在Clob对象中的字符。

// Read picture data and save it to a file

String pictureFilePath = "c:\\mypicture.bmp";

FileOutputStream fos = new FileOutputStream(pictureFilePath);

InputStream in = pictureBlob.getBinaryStream();

int b = -1;

while((b = in.read()) != -1) {

fos.write((byte)b);

}

fos.close();

// Read resume data and save it to a file

String resumeFilePath = "c:\\myresume.txt";

FileWriter fw = new FileWriter(resumeFilePath);

Reader reader = resumeClob.getCharacterStream();

int b = -1;

while((b = reader.read()) != -1) {

fw.write((char)b);

}

fw.close();

创建 LOB 数据

在上一节中,您学习了如何将 LOB 数据从数据库读入 Java 程序。在本节中,您将学习如何在 Java 程序中创建一个 LOB,并将 LOB 数据发送到数据库,以将其存储在表的列中。Connection接口包含三种创建 LOB 的方法:

  • Blob createBlob() throws SQLException
  • Clob createClob() throws SQLException
  • NClob createNClob() throws SQLException

您可以使用其中一种方法来创建特定类型的空 LOB。例如,要在数据库中存储一张图片和简历,您需要创建一个Blob对象和一个Clob对象,如下所示:

Connection conn = JDBCUtil.getConnection();

Blob pictureBlob = conn.createBlob();

Clob resumeClob = conn.createClob();

一旦获得了BlobClob对象,有两种方法可以向它们写入数据。您可以使用Blob对象的方法向其写入数据,该方法接受Blob对象中您想要写入的位置,以及字节数组中的数据。您也可以使用OutputStream将数据写入Blob对象。您需要调用它的方法,该方法接受写入数据的起始位置并返回一个OutputStream。你需要使用那个OutputStream来写数据到Blob。以下是两个方法的签名:

  • int setBytes(long pos, byte[] bytes) throws SQLException
  • OutputStream setBinaryStream(long pos) throws SQLException

下面的代码片段显示了如何将数据写入Blob。它从存储图片的文件中读取数据,并将所有字节写入一个Blob对象。while 循环从文件中一次读取一个字节,以保持代码简单易读。在现实世界的程序中,你将一次读写更多的数据。

// Get the output stream of the Blob object to write the picture data to it.

int startPosition = 1; // start writing from beginning

OutputStream out = pictureBlob.setBinaryStream(startPosition);

// Get ready to read from a file

String picturePath = "picture.jpg";

FileInputStream fis = new FileInputStream(picturePath);

// Read from the file and write to the Blob object

int b = -1;

while ((b = fis.read()) != -1) {

out.write(b);

}

fis.close();

out.close();

Clob接口提供了以下三种方法向Clob对象写入数据:

  • int setString(long pos, String str) throws SQLException
  • int setString(long pos, String str, int offset, int len) throws SQLException
  • Writer setCharacterStream(long pos) throws SQLException

该方法允许您在指定位置向其写入一个String。第二个版本的setString()方法允许您指定源字符串中开始读取的偏移量以及要从源字符串中读取的字符数。该方法返回一个Writer,您可以用它将 Unicode 字符的数据写入ClobClob接口还包含一个方法,该方法返回一个OutputStream,您可以用它来编写 ASCII 编码的字符。

下面的代码片段显示了如何将数据写入Clob。它从一个文件中读取数据,该文件以文本格式存储一份简历,并将所有字符写入一个Clob对象。while 循环从文件中一次读取一个字符,以保持代码简单易读。在现实世界的程序中,你将一次读写更多的字符。

// Get the Character output stream of the Clob object to write the resume data to it.

int startPosition = 1; // start writing from beginning

Writer writer = resumeClob.setCharacterStream(startPosition);

// Get ready to read from a file

String resumePath = "resume.txt";

FileReader fr = new FileReader(resumePath);

// Read from the file and write to the Clob object

int b = -1;

while ((b = fr.read()) != -1) {

writer.write(b);

}

fr.close();

writer.close();

最后,是时候将 LOB 的数据写入数据库了。您可以使用PreparedStatement接口的setBlob()setClob()方法来设置BlobClob数据,如图所示:

Connection conn = JDBCUtil.getConnection();

String SQL = "insert into person_detail " +

"(person_detail_id, person_id, picture, resume) " +

"values " +

"(?, ?, ?, ?)";

PreparedStatement pstmt = null;

pstmt = conn.prepareStatement(SQL);

pstmt.setInt(1, 1);  // set person_detail_id

pstmt.setInt(2, 101); // Set person_id

Blob pictureBlob = conn.createBlob();

// Write data to pictureBlob object here

pstmt.setBlob(3, pictureBlob);

Clob resumeClob = conn.createClob();

// Write data to resumeClob object here

pstmt.setClob(4, resumeClob);

// Insert the record into the database

pstmt.executeUpdate();

ResultSet接口还包括updateBlob()updateClob()方法,您可以使用它们通过一个ResultSet对象更新BlobClob对象。BlobClob对象可能需要大量资源。一旦你完成了它们,你需要通过调用它们的free()方法来释放它们所拥有的资源。

Tip

PreparedStatement中设置Blob对象数据的另一种方法是使用它的setBinaryStream()setObject()方法。在PreparedStatement中设置Clob对象数据的另一种方法是使用它的setAsciiStream()setCharacterStream()setObject()方法。

清单 6-30 包含了完整的代码,展示了如何在包含BlobClob列的表格中插入记录。已经在 Java DB 中测试过了。它从名为picture.jpg的文件中读取图片数据,从名为resume.txt的文件中读取简历数据。假设这两个文件都在当前目录中。如果文件不存在,程序将打印一条消息,其中包含预期的完整路径。如果您想使用不同的文件,请在main()方法中更改文件路径。程序在person_detail表中插入一条记录,并检索相同的数据,将其保存到本地磁盘的当前目录中。多次运行该程序会打印一条错误消息,因为它会尝试在person_detail表中插入一条重复记录。每次运行该程序时都将检索个人详细信息。

清单 6-30。读取和写入 Blob 和 Clob 数据数据库列

// LOBTest.java

package com.jdojo.jdbc;

import java.io.FileInputStream;

import java.io.FileNotFoundException;

import java.io.FileOutputStream;

import java.io.FileReader;

import java.io.FileWriter;

import java.io.IOException;

import java.io.InputStream;

import java.io.OutputStream;

import java.io.Reader;

import java.io.Writer;

import java.nio.file.Files;

import java.nio.file.Path;

import java.nio.file.Paths;

import java.sql.Blob;

import java.sql.Clob;

import java.sql.Connection;

import java.sql.PreparedStatement;

import java.sql.ResultSet;

import java.sql.SQLException;

public class LOBTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Insert a record in the person_detail table. Files

// picture.jpg and resume.txt are assumed to be in

// the working directory

String inPicturePath = "picture.jpg";

String inResumePath = "resume.txt";

// Make sure that the files exist

ensureFileExistence(inPicturePath);

ensureFileExistence(inResumePath);

try {

// Insert a person_detail record

insertPersonDetail(conn, 1, 101,

inPicturePath, inResumePath);

// Commit the transaction

JDBCUtil.commit(conn);

System.out.println(

"Inserted person details successfully");

}

catch(SQLException e) {

System.out.print("Inserting person details failed: ");

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

// These files will be created in the current directory

String outPicturePath = "out_picture.jpg";

String outResumePath = "out_resume.txt";

try {

// Read the person_detail record

retrievePersonDetails(conn, 1,

outPicturePath, outResumePath);

// Commit the transaction

JDBCUtil.commit(conn);

System.out.println(

"Retrieved and saved person details successfully.");

}

catch(SQLException e) {

System.out.print("Retrieving person details failed: ");

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

}

catch (Exception e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void insertPersonDetail(Connection conn,

int personDetailId,

int personId,

String pictureFilePath,

String resumeFilePath)

throws SQLException {

String SQL = "insert into person_detail " +

"(person_detail_id, person_id, picture, resume) " +

"values " +

"(?, ?, ?, ?)";

PreparedStatement pstmt = null;

try {

pstmt = conn.prepareStatement(SQL);

pstmt.setInt(1, personDetailId);

pstmt.setInt(2, personId);

// Set the picture data

if (pictureFilePath != null) {

// We need to create a Blob object first

Blob pictureBlob = conn.createBlob();

readInPictureData(pictureBlob, pictureFilePath);

pstmt.setBlob(3, pictureBlob);

}

// Set the resume data

if (resumeFilePath != null) {

// We need to create a Clob object first

Clob resumeClob = conn.createClob();

readInResumeData(resumeClob, resumeFilePath);

pstmt.setClob(4, resumeClob);

}

pstmt.executeUpdate();

}

catch (IOException | SQLException e) {

throw new SQLException(e);

}

finally {

JDBCUtil.closeStatement(pstmt);

}

}

public static void retrievePersonDetails(Connection conn,

int personDetailId,

String picturePath,

String resumePath) throws SQLException {

String SQL = "select person_id, picture, resume " +

"from person_detail " +

"where person_detail_id = ?";

PreparedStatement pstmt = null;

try {

pstmt = conn.prepareStatement(SQL);

pstmt.setInt(1, personDetailId);

ResultSet rs = pstmt.executeQuery();

while (rs.next()) {

int personId = rs.getInt("person_id");

Blob pictureBlob = rs.getBlob("picture");

if (pictureBlob != null) {

savePicture(pictureBlob, picturePath);

pictureBlob.free();

}

Clob resumeClob = rs.getClob("resume");

if (resumeClob != null) {

saveResume(resumeClob, resumePath);

resumeClob.free();

}

}

}

catch (IOException | SQLException e) {

throw new SQLException(e);

}

finally {

JDBCUtil.closeStatement(pstmt);

}

}

public static void readInPictureData(Blob pictureBlob,

String pictureFilePath)

throws FileNotFoundException, IOException, SQLException {

// Get the output stream of the Blob object to write

// the picture data to it.

int startPosition = 1; // start writing from the beginning

OutputStream out = pictureBlob.setBinaryStream(startPosition);

FileInputStream fis = new FileInputStream(pictureFilePath);

// Read from the file and write to the Blob object

int b = -1;

while ((b = fis.read()) != -1) {

out.write(b);

}

fis.close();

out.close();

}

public static void readInResumeData(Clob resumeClob,

String resumeFilePath)

throws FileNotFoundException, IOException, SQLException {

// Get the character output stream of the Clob object

// to write the resume data to it.

int startPosition = 1; // start writing from the beginning

Writer writer = resumeClob.setCharacterStream(startPosition);

FileReader fr = new FileReader(resumeFilePath);

// Read from the file and write to the Clob

int b = -1;

while ((b = fr.read()) != -1) {

writer.write(b);

}

fr.close();

writer.close();

}

public static void savePicture(Blob pictureBlob, String filePath)

throws SQLException, IOException {

FileOutputStream fos = new FileOutputStream(filePath);

InputStream in = pictureBlob.getBinaryStream();

int b = -1;

while ((b = in.read()) != -1) {

fos.write((byte) b);

}

fos.close();

}

public static void saveResume(Clob resumeClob, String filePath)

throws SQLException, IOException {

FileWriter fw = new FileWriter(filePath);

Reader reader = resumeClob.getCharacterStream();

int b = -1;

while ((b = reader.read()) != -1) {

fw.write((char) b);

}

fw.close();

}

public static void ensureFileExistence(String filePath) {

Path path = Paths.get(filePath);

if (!Files.exists(path)) {

throw new RuntimeException("File " +

path.toAbsolutePath() + " does not exist");

}

}

}

批量更新

您看到了使用StatementPreparedStatementCallableStatement接口的例子,这些接口允许您一次向数据库发送一个 SQL 命令(或存储过程调用)。JDBC API 包括一个批量更新特性,允许您将多个更新命令批量(以一个包的形式)发送到一个数据库进行执行。批量更新大大提高了性能。您可以在批量更新中使用的更新命令有 SQL INSERTUPDATEDELETE,和存储过程。批处理中的命令不应产生结果集。否则,JDBC 司机会抛出一个SQLException。命令应该生成更新计数,该计数将指示数据库中受该命令执行影响的行数。

如果你使用一个Statement来执行一批命令,你可以在同一批中有不同的命令。例如,一个命令可以是 SQL INSERT语句,另一个可以是 SQL UPDATE语句。

如果您使用PreparedStatementCallableStatement执行一批命令,您将使用多组输入参数执行一个命令。批次更新中使用的必须传回更新计数,而且不应该产生结果集。否则,JDBC 司机会扔出一辆SQLException

Tip

批量更新是一个可选功能,可由 JDBC 驱动程序提供。如果 JDBC 驱动支持批量更新,DatabaseMetaData对象的supportsBatchUpdates()方法将返回true。您可以使用Connection对象的getMetaData()方法来获得DatabaseMetaData对象。在执行批处理更新时,您应该关闭自动提交模式,这样您应该能够提交或回滚整个批处理。如果打开了自动提交模式,当执行批处理中的某个命令出错时,提交行为取决于 JDBC 驱动程序的实现。

如何批量执行多个命令?这是一个多步骤的过程。

Create a Statement, a PreparedStatement, or a CallableStatement by using an appropriate method of a Connection object. At this point, there is no difference between executing one command and using a batch of commands.   Use the addBatch() method to add a command to the batch. Each type of statement object maintains a list of batch commands internally. The addBatch() method adds the command to the internal list of batch commands. You need to call the addBatch() method once for each command in the batch that you want to bundle together for execution.   If you want to clear the list of batch commands without executing them, you can call the clearBatch() method of the Statement interface to do so.   Use the executeBatch() method to send the batch of commands to the database for execution in one go.

理解Statement接口方法的行为很重要。如果批处理中的所有命令都成功执行,它将返回一个数组int。数组包含的元素数量与批处理中的命令数量一样多。数组中的每个元素都包含从命令返回的更新计数。数组中元素的顺序与批处理中命令的顺序相同。有时,批处理中的命令可能会无错误地执行,但 JDBC 驱动程序无法获取更新计数值。在这种情况下,数组中会返回一个值Statement.SUCCESS_NO_INFO

Tip

Java 8 在Statement接口中添加了一个executeLargeBatch()方法,其工作原理与executeBatch方法相同,除了它返回一个long[]而不是一个int[]。当您预计批处理中任何命令的更新计数超过Integer.MAX_VALUE时,使用此方法。

如果批处理中的命令执行失败,JDBC 驱动程序抛出一个BatchUpdateException。JDBC 驱动程序决定在失败时是继续执行批处理中的后续命令,还是在第一次失败时停止批处理执行。如何知道批处理中哪个命令失败了?当抛出一个BatchUpdateException时,可以使用它的getUpdateCounts(),它返回一个int数组。更新计数数组包含在批处理中执行的命令的更新计数。如果 JDBC 驱动程序在不考虑失败的情况下批量执行所有命令,则返回的数组将包含与批量中的命令数量一样多的元素。如果一个命令失败,它在数组中的对应值将是Statement.EXECUTE_FAILED。如果BatchUpdateException对象的getUpdateCounts()方法返回的元素数量少于批处理中的命令数量,这意味着 JDBC 驱动程序在第一次失败后停止处理任何命令。

以下代码片段显示了如何使用Statement对象来执行批量更新:

Connection conn = JDBCUtil.getConnection();

Statement stmt = conn.createStatement();

// Add batch update commands

stmt.addBatch("insert into t1...);

stmt.addBatch("insert into t2...);

stmt.addBatch("update t3 set...);

stmt.addBatch("delete from t4...);

// Execute the batch updates

int[] updateCount = null;

try {

updatedCount = stmt.executeBatch();

System.out.println("Batch executed successfully.");

}

catch (BatchUpdateException e) {

System.out.println("Batch failed.");

}

下面的代码片段展示了如何使用一个PreparedStatement对象来执行批量更新。如果使用CallableStatement,逻辑将是相同的,除了字符串格式的 SQL 的构造。PreparedStatement接口中的addBatch()方法不接受任何参数。

String sql = "delete from person where person_id = ?";

Connection conn = JDBCUtil.getConnection();

PreparedStatement pstmt = conn.prepareStatement(sql);

// Add two commands to the batch.

// Command #1: Set the input parameter and add it to the batch.

pstmt.setInt(201);

pstmt.addBatch();

// Command #1: Set the input parameter and add it to the batch.

pstmt.setInt(301);

pstmt.addBatch();

// Execute the batch update

int[] updateCount = null;

try {

updatedCount = pstmt.executeBatch();

System.out.println("Batch executed successfully.");

}

catch (BatchUpdateException e) {

System.out.println("Batch failed.");

}

清单 6-31 包含了演示如何使用批量更新的完整代码。它还展示了如何处理从executeUpdate()方法和BatchUpdateException返回的批量更新的结果。insertPersonStatement()insertPersonPreparedStatement()方法做同样的工作:第一个使用Statement,第二个使用PreparedStatement。在main()方法中,对insertPersonPreparedStatement()方法的调用被注释。您需要使用其中一种方法,但不能两种都用。

清单 6-31。使用 JDBC API 的批量更新功能

// BatchUpdateTest.java

package com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.Statement;

import java.sql.BatchUpdateException;

import java.sql.PreparedStatement;

import java.sql.Types;

import java.sql.Date;

public class BatchUpdateTest {

public static void main(String[] args) {

Connection conn = null;

try {

conn = JDBCUtil.getConnection();

// Prepare the data

int[] personIds = {801, 901};

String[] firstNames = {"Matt", "Greg"};

String[] lastNames = {"Flower", "Rice"};

String[] genders = {"M", "M"};

String[] dobString = {"{d '1960-04-01'}",

"{d '1962-03-01'}"};

double[] incomes = {56778.00, 89776.00};

// Use batch update using the Statement objects

insertPersonStatement(conn, personIds, firstNames,

lastNames, genders, dobString, incomes);

// Use batch update using the PreparedStatement objects

/*

java.sql.Date[] dobDate = {Date.valueOf("1960-04-01"),

Date.valueOf("1962-03-01") };

insertPersonPreparedStatement(conn, personIds,

firstNames,lastNames, genders, dobDate, incomes);

*/

// Commit the transaction

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

public static void insertPersonStatement(Connection conn,

int[] personId,

String[] firstName, String[] lastName,

String[] gender, String[] dob,

double[] income) throws SQLException {

int[] updatedCount = null;

Statement stmt = null;

try {

stmt = conn.createStatement();

for (int i = 0; i < personId.length; i++) {

String SQL = "insert into person " +

"(person_id, first_name, last_name," +

" gender, dob, income) " +

"values " +

"(" + personId[i] + ", " +

"'" + firstName[i] + "'" + ", " +

"'" + lastName[i] + "'" + ", " +

"'" + gender[i] + "'" + ", " +

dob[i] + ", " +

income[i] + ")";

// Add insert command to the batch

stmt.addBatch(SQL);

}

// Execute the batch

updatedCount = stmt.executeBatch();

System.out.println("Batch executed successfully.");

printBatchResult(updatedCount);

}

catch (BatchUpdateException e) {

// Let us see how many commands were successful

updatedCount = e.getUpdateCounts();

System.out.println("Batch failed.");

int commandCount = personId.length;

if (updatedCount.length == commandCount) {

System.out.println(

"JDBC driver continues to execute all"

+ " commands in a batch after a failure.");

}

else {

System.out.println(

"JDBC driver stops executing subsequent"

+ " commands in a batch after a failure.");

}

// Re-throw the exception

throw e;

}

finally {

JDBCUtil.closeStatement(stmt);

}

}

public static void insertPersonPreparedStatement(

Connection conn, int[] personId,

String[] firstName, String[] lastName,

String[] gender, java.sql.Date[] dob,

double[] income) throws SQLException {

int[] updatedCount = null;

String SQL = "insert into person " +

"(person_id, first_name, last_name, gender, dob," +

" income) " +

" values " +

"(?, ?, ?, ?, ?, ?)";

PreparedStatement pstmt = null;

try {

pstmt = conn.prepareStatement(SQL);

for (int i = 0; i < personId.length; i++) {

// Set input parameters

pstmt.setInt(1, personId[i]);

pstmt.setString(2, firstName[i]);

pstmt.setString(3, lastName[i]);

pstmt.setString(4, gender[i]);

if (dob[i] == null) {

pstmt.setNull(5, Types.DATE);

}

else {

pstmt.setDate(5, dob[i]);

}

pstmt.setDouble(6, income[i]);

// Add insert command with current input parameters

pstmt.addBatch();

}

// Execute the batch

updatedCount = pstmt.executeBatch();

System.out.println("Batch executed successfully.");

printBatchResult(updatedCount);

}

catch (BatchUpdateException e) {

// Let us see how many commands were successful

updatedCount = e.getUpdateCounts();

System.out.println("Batch failed.");

int commandCount = personId.length;

if (updatedCount.length == commandCount) {

System.out.println(

"JDBC driver continues to execute all" +

"commands in a batch after a failure.");

}

else {

System.out.println(

"JDBC driver stops executing subsequent" +

"commands in a batch after a failure.");

}

// Re-throw the exception

throw e;

}

finally {

JDBCUtil.closeStatement(pstmt);

}

}

public static void printBatchResult(int[] updateCount) {

System.out.println("Batch Results...");

for (int i = 0; i < updateCount.length; i++) {

int value = updateCount[i];

if (value >=0) {

System.out.println("Command #" + (i + 1)

+ ": Success. Update Count=" + value);

} else if (value >=Statement.SUCCESS_NO_INFO) {

System.out.println("Command #" + (i + 1)

+ ": Success. Update Count=Unknown");

} else if (value >=Statement.EXECUTE_FAILED) {

System.out.println("Command #" + (i + 1) + ": Failed");

}

}

}

}

事务中的保存点

数据库事务由一个或多个作为工作单元的更改组成。事务中的保存点就像一个标记,标记事务中的一个点,以便在需要时,事务可以回滚(或撤消)到该点。让我们以在person表中插入五条记录为例,如下所示:

Connection conn = JDBCUtil.getConnection();

Statement stmt = conn.createStatement();

stmt.execute("insert into person..."); // insert 1

stmt.execute("insert into person..."); // insert 2

stmt.execute("insert into person..."); // insert 3

stmt.execute("insert into person..."); // insert 4

stmt.execute("insert into person..."); // insert 5

此时,您只有两个选择:要么提交事务,这将在person表中插入所有五条记录,要么回滚事务,这样这五条记录都不会被插入。您可以按以下方式执行提交或回滚

conn.commit(); // Save all five records

或者

conn.rollback(); // Do not save any of the five records

保存点将允许您在上述两个INSERT语句之间设置一个标记。Savepoint接口的一个对象代表事务中的一个保存点。要标记事务中的保存点,只需调用ConnectionsetSavepoint()方法。该方法被重载。一个版本不接受参数,另一个版本接受字符串,即保存点的名称。setSavepoint()方法返回一个Savepoint对象,它是你的标记,你必须保存它以备将来使用。让我们在每个INSERT语句后使用一个保存点重写上面的逻辑。

Connection conn = JDBCUtil.getConnection();

Statement stmt = conn.createStatement();

stmt.execute("insert into person...");        // insert 1

Savepoint sp1 = conn.setSavepoint();          // savepoint 1

stmt.execute("insert into person...");        // insert 2

Savepoint sp2 = conn.setSavepoint();          // savepoint 2

stmt.execute("insert into person...");        // insert 3

Savepoint sp3 = conn.setSavepoint();          // savepoint 3

stmt.execute("insert into person...");        // insert 4

Savepoint sp4 = conn.setSavepoint();          // savepoint 4

stmt.execute("insert into person...");        // insert 5

在这一点上,如果您想要撤销对person表的上述五个插入中的任何一个,您就可以对事务进行更好的控制。现在你可以使用另一个版本的Connection对象的方法,它接受一个Savepoint对象。如果您想撤消保存点 4 之后所做的所有更改,可以按如下方式进行:

// Rolls back insert 5 only

conn.rollback(sp4);

如果您想撤消保存点 2 之后所做的所有更改,可以按如下方式进行:

// Rolls back inserts 3, 4, and 5

conn.rollback(sp2);

如果回滚到保存点 1,只有第一个插入将保留在事务中。回滚到保存点后,您能改变主意吗?假设,在您调用conn.rollback(sp2)之后,您意识到您犯了一个错误,并且您想要回滚 insert 4 和 5,而不是 insert 3。对conn.rollback(sp2)的调用将回滚三个插入:3、4 和 5。在返回到保存点 2 之后,您是否可以选择仅返回到保存点 3?不。在这种情况下你没有任何选择。一旦回滚到某个保存点(比如说,spx),在该保存点spx之后创建的所有保存点都会被释放,并且您不能再次引用它们。如果你引用一个已释放的保存点,JDBC 驱动程序将抛出一个SQLException。以下代码片段将引发一个:

conn.rollback(sp2); // Will release sp3, and sp4

conn.rollback(sp3); // Will throw an exception. sp3 is already released.

请注意,当您将事务回滚到保存点时,该保存点本身不会被释放。当您调用conn.rollback(sp2)时,保存点sp2仍然有效。您可以在以后添加更多的保存点,并再次回滚到保存点sp2

您还可以通过调用一个Connection对象的releaseSavepoint(Savepoint sp)方法来显式释放一个保存点。释放保存点也会释放在此保存点之后创建的所有后续保存点。例如,调用conn.releaseSavepoint(sp2)将释放保存点sp2sp3sp4。当事务被提交或完全回滚时,事务中的所有保存点都被释放。如果您使用通过上述任何方式释放的保存点,JDBC 驱动程序将抛出一个SQLException。清单 6-32 显示了如何在事务中使用保存点。

清单 6-32。在事务中使用保存点

// SavePointTest.java

package com.jdojo.jdbc;

import java.sql.Connection;

import java.sql.SQLException;

import java.sql.PreparedStatement;

import java.sql.Savepoint;

public class SavePointTest {

public static void main(String[] args) {

Connection conn = null;

try {

// Connect to the database

conn = JDBCUtil.getConnection();

conn.setAutoCommit(false);

// SQL Statement

String SQL = "update person " +

"set income = ? " +

"where person_id = ?";

PreparedStatement pstmt = conn.prepareStatement(SQL);

pstmt.setDouble(1, 20000);

pstmt.setInt(2, 101);

pstmt.execute();

// Set a save point

Savepoint sp1 = conn.setSavepoint();

// Change the income to 25000 and execute the SQL again

pstmt.setDouble(1, 25000);

pstmt.execute();

// Set a save point

Savepoint sp2 = conn.setSavepoint();

// Perform some more database changes here

// Roll back the transaction to the save point sp1,

// so that income for person_id 101 will remain set

// to 20000 and not the 25000

conn.rollback(sp1);

// Commit the transaction

JDBCUtil.commit(conn);

}

catch (SQLException e) {

System.out.println(e.getMessage());

JDBCUtil.rollback(conn);

}

finally {

JDBCUtil.closeConnection(conn);

}

}

}

Tip

您可以通过使用DatabaseMetaData对象的supportsSavepoints()方法来检查 JDBC 驱动程序是否支持保存点。

使用数据源

你需要一个Connection来与数据库通信。到目前为止,您已经使用DriverManager类获得了一个Connection。您需要用DriverManager注册数据库驱动程序,并指定数据库连接属性的细节。所有这些都必须在主应用逻辑中的 Java 代码中完成。如果与数据库连接相关的任何事情发生变化,您必须更改处理建立数据库连接的代码。

JDBC API 提供了在 Java 应用中获取Connection的另一种方式。您可以使用javax.sql.DataSource接口连接到数据库。在这种使用数据库连接的替代方式中,事情被分成两个逻辑模块:连接管理和连接使用。

  • 一个模块负责在服务器上配置和部署DataSource对象,该服务器允许使用 Java 命名和目录接口(JNDI)服务进行查找。配置包括设置DataSource对象的属性,它将使用这些属性建立到数据库的连接,例如服务器名、端口号、网络协议等。部署包括通过给已配置的DataSource对象一个逻辑名,将其存储在 JNDI 服务器上。部署也被称为绑定,因为一个DataSource对象被绑定到一个逻辑名称。通常,系统管理员会执行此步骤。给予一个DataSource对象的逻辑名被公布给需要查找DataSource对象的开发人员。通常,您在应用服务器中使用一个DataSource,它使用 J2EE 技术。应用服务器为您提供了一个 GUI 工具来配置和部署DataSource对象。
  • Java 应用需要一个Connection对象的实例,它使用一个DataSource的逻辑名通过 JNDI API 执行查找。查找操作返回一个DataSource接口的实例。您可以使用getConnection()方法从DataSource对象中获取一个Connection对象。getConnection()方法被重载。一个版本不接受参数,另一个版本接受userIdpassword作为参数。getConnection()方法的工作方式类似于DriverManager类的getConnection()方法。开发人员执行这一步骤。

通常,您在服务器上配置和部署一个DataSource,可以使用 JNDI 服务获得它。下面是一个示例代码片段,您可以用它来以编程方式配置和部署一个DataSource。它创建了一个由MYSQL JDBC 提供的DataSource驱动程序。

import com.mySQL.jdbc.jdbc2.optional.MySQLDataSource;

import javax.naming.InitialContext;

import javax.naming.Context;

...

// Create a DataSource object

MySQLDataSource mds = new MySQLDataSource();

mds.setServerName("localhost");

mds.setPortNumber(3306);

mds.setUser("root");

mds.setPassword("chanda");

// Get the initial context

Context ctx = new InitialContext();

// Bind (or register) the DataSource object under a logical name "jdbc/mydb"

ctx.bind("jdbc.mydb", mds);

需要连接到数据库的 Java 应用将使用绑定时提供给它的逻辑名DataSource执行查找。当您需要一个Connection对象时,您需要编写一段典型的 Java 代码:

import javax.sql.DataSource;

import java.sql.Connection;

import javax.naming.InitialContext;

import javax.naming.Context;

...

// Get the initial context

Context ctx = new InitialContext();

// Perform a lookup for the DataSource using its logical name "jdbc/mydb"

DataSource ds = (DataSource)ctx.lookup("jdbc/mydb");

// Get a Connection object from the DataSource object

Connection conn = ds.getConnection();

// Perform other database related tasks...

// Close the connection

conn.close()

JDBC API 提供了另外两种类型的数据源接口:javax.sql.ConnectionPoolDataSourcejavas.sql.XADataSourceConnectionPoolDataSource接口包含一个getPooledConnection()方法,该方法返回一个PooledConnection接口的实例。XADataSource接口包含一个getXAConnection()方法,该方法返回一个XAConnection接口的实例。

接口的实现提供了连接池功能,以提高应用的性能。基本的DataSource实现在从数据库获得一个Connection对象时连接,在Connection对象关闭时断开与数据库的连接。一个ConnectionPoolDataSource实现维护一个数据库连接池。当需要数据库连接时,它从自己的池中提供一个连接。当一个数据库连接被关闭时,它不会从数据库中物理地断开Connection对象。相反,它将连接对象返回到池中以供重用。建立数据库连接是一个耗时的过程。通过在使用ConnectionPoolDataSource的应用中使用连接池,可以极大地提高应用的性能。当可以建立的数据库连接数有限时,连接池机制也很有用。在这种情况下,您可以维护一个有限数量的连接池,用户将轮流使用这些连接。

接口的实现提供了对分布式事务的支持,这些事务涉及多个数据库。事务管理器用于与XADataSource对象一起管理分布式事务。通常,XADataSource也支持连接池。

正在检索 SQL 警告

有时,DBMS 会发出 SQL 警告,而不是抛出异常。SQL 警告指示数据库交互已经完成;然而,一切都不对劲。JDBC API 允许您使用java.sql.SQLWarning对象检索 DBMS 发出的 SQL 警告。SQLWarning类继承自SQLException类。一个SQLWarning存储 SQL 警告链。可以在ConnectionStatement(包括PreparedStatementCallableStatement)或ResultSet上发出 SQL 警告。您可以使用这些对象的getWarnings()方法来检索与这些对象相关联的第一个警告对象。如果对象中没有报告警告,该方法返回null。一旦对这些对象调用了getWarnings()方法,它们的警告就会被清除。您也可以通过调用它们的clearWarnings()方法来清除它们的警告。请注意,必须打开这些对象才能访问其上报告的警告。一旦执行或重新执行一个Statement对象,其警告将被重置。以下代码片段可用于打印任何对象上报告的警告详细信息— ConnectionStatementResultSet:

// Check for warnings.

// Here xxx is either a Connection, Statement or ResultSet object

SQLWarning warning = xxx.getWarnings();

while(warning != null) {

int errorCode = warning.getErrorCode();

String sqlState = warning.getSQLState();

String warningMsg = warning.getMessage();

// Print the details

System.out.println("Warning: " + warningMsg +

"SQL State: " + sqlState +

"Error Code:" + errorCode);

// Get the next warning

warning = warning.getNextWarning();

}

启用 JDBC 跟踪

您可以启用 JDBC 跟踪,将 JDBC 活动记录到一个PrintWriter对象中。如果使用DriverManager连接数据库,可以使用DriverManagersetLogWriter(PrintWriter out)静态方法来设置日志记录器。如果你正在使用一个DataSource,你可以使用它的setLogWriter(PrintWriter out)方法来设置一个日志记录器。将null设置为日志写入器会禁用 JDBC 跟踪。以下代码片段将日志写入器设置为 Windows 上的C:\jdbc.log文件:

// Sets the log writer to a file c:\jdbc.log

PrintWriter pw = new PrintWriter("C:\\jdbc.log");

DriverManager.setLogWriter(pw);

当您在启用 Java 安全性的情况下调用DriverManager类的setLogWriter()方法时,Java 会检查一个java.sql.SQLPermission。您可以将此权限授予安全策略文件中正在执行的代码。下面是安全策略文件中一个条目的示例,该条目授予在DriverManager上执行setLogWriter()方法的权限:

grant {

permission java.sql.SQLPermission "setLog";

};

摘要

JDBC API 提供了一个标准的独立于数据库的接口来与任何表格数据源交互,包括关系数据库管理系统(RDBMS ),如 Oracle、SQL Server、DB2、Java DB、MySQL 等。JDBC 驱动程序简化了 Java 程序中数据库的连接。JRE 不包括任何 JDBC 车手。JDBC 驱动程序由 DBMS 供应商提供。JDBC API 中的类和接口在java.sqljavax.sql包中。

DriverManager类方便了 JDBC 驱动程序的注册,以连接到不同类型的数据库。当传入数据库连接属性时,如服务器位置、协议、数据库名称、用户 id、密码等。时,DriverManager使用注册的 JDBC 驱动程序连接到数据库,并返回一个代表数据库连接的Connection接口的对象。

您可以对一个Connection对象使用getMetaData()方法来获得一个DatabaseMetaData对象。DatabaseMetaData对象包含关于数据库的信息,如数据库支持的特性、数据库中的所有表等。

JDBC API 提供了 SQL 类型和 Java 类型之间的映射。JDBC 驱动程序执行两种类型之间的转换。这隐藏了不同数据库中数据类型名称及其内部表示的差异。例如,您可以在 Java 程序中使用一个java.sql.Date对象来表示一个 SQL 日期值,而不管您使用的是什么 DBMS。JDBC 驱动程序将负责将java.sql.Date中的值转换成 DBMS 特定的日期值,反之亦然。

Statement用于从 Java 程序中执行字符串形式的 SQL 语句。SQL 语句返回的结果集在 Java 程序中作为ResultSet接口的对象可用。

一个PreparedStatement用于执行带参数的 SQL 语句。SQL 语句是预编译的,以便在重复使用带有不同参数的相同 SQL 语句时提供更快的执行速度。在 SQL 语句中使用输入参数作为占位符还可以防止黑客使用 SQL 注入进行攻击。

CallableStatement用于调用数据库中的 SQL 存储过程或函数。不同的 DBMSs 使用不同的语法来调用存储过程和函数。JDBC API 提供了一个独立于 DBMS 的语法来使用CallableStatement调用存储过程和函数。

A ResultSet表示按照行和列定义的表格数据。通常,通过执行从数据库返回结果集的 SQL 语句,可以获得一个ResultSet。一个ResultSet只能向前滚动或者同时向前和向后滚动。所有 JDBC 司机将支持至少一个只进ResultSet。一个ResultSet也可以用来更新数据库中的数据。

一个RowSet是一个ResultSet的包装器。一个RowSet隐藏了与一个ResultSet一起工作的复杂性。一个JdbcRowSet,也称为连接的行集,一直维护着一个数据库连接。CachedRowSet也称为断开连接的行集,它只在需要时使用数据库连接。一个WebRowSet是一个CachedRowSet,它支持从 XML 文档导入数据和将其数据导出到 XML 文档。一个FilteredRowSet是一个WebRowSet,在客户端提供过滤能力。一个JoinRowSet是一个WebRowSet,它提供了将两个或多个不连接的行集组合(或连接)成一个行集的能力。

JDBC 支持使用数据库大型对象,通常称为 Blob、Clob 和 NClob。

为了获得更好的性能,您可以使用 JDBC API 的批量更新特性一次向数据库发送多个 SQL 命令。通过StatementPreparedStatementCallableStatement接口支持批量更新。Statement对象的addBatch()方法用于向批处理添加一个 SQL 命令。executeBatch()方法将批处理中的所有 SQL 命令发送到数据库执行。

数据库事务由一个或多个作为工作单元的更改组成。事务中的保存点是一个标记,它标记事务中的一个点,以便在需要时,事务可以回滚到标记的点。Savepoint接口的一个实例代表一个保存点。您可以使用Connection对象的setSavepoint()方法在事务中创建一个保存点。您可以在Connection对象的rollback()方法中指定一个保存点,将事务回滚到指定的保存点。

DBMS 可能会发出 SQL 警告,而不是抛出异常。SQLWarning类的一个实例代表一组 SQL 警告。SQL 警告嵌套在一个SQLWarning对象中。使用ConnectionStatementResultSet对象的getWarnings()方法来获取与它们相关的 SQL 警告。使用SQLWarning类的getNextWarning()方法从集合中检索下一个 SQL 警告。

您可以启用 JDBC 跟踪,将 JDBC 活动记录到一个PrintWriter对象中。您可以使用DriverManagersetLogWriter(PrintWriter out)静态方法来设置日志记录器。

6# 七、Java 远程方法调用

在本章中,您将学习

  • 什么是 Java 远程方法调用(RMI)和 RMI 架构
  • 如何开发和打包 RMI 服务器和客户机应用
  • 如何启动rmiregistry、RMI 服务器和客户端应用
  • 如何对 RMI 应用进行故障排除和调试
  • RMI 应用中的动态类下载
  • RMI 应用中远程对象的垃圾收集

什么是 Java 远程方法调用?

Java 支持各种应用架构,这些架构决定了应用代码如何以及在哪里部署和执行。在最简单的应用架构中,所有的 Java 代码都驻留在一台机器上,一个 JVM 管理所有的 Java 对象以及它们之间的交互。这是一个独立应用的例子,其中所需要的只是一台可以启动 JVM 的机器。Java 还支持分布式应用架构,其中应用的代码和执行可以分布在多台机器上。

在第四章中,你看到了 Java Applet,其中 Java 类被部署在 web 服务器上。web 浏览器将 applet 类下载到客户机上,并在客户机上运行的 JVM 中执行。在 applet 的情况下,Java 代码仍然在一个 JVM 中执行。在第五章中,您学习了 Java 网络编程,其中涉及到至少两个运行在不同机器上的 JVM,它们为客户机和服务器套接字执行 Java 代码。通常,套接字用于在两个应用之间传输数据。在套接字编程中,客户端程序可以向服务器程序发送消息。服务器程序创建一个 Java 对象,调用该对象上的方法,并将方法调用的结果返回给客户端程序。最后,客户端程序使用套接字读取结果。在这种情况下,客户机能够调用驻留在不同 JVM 中的 Java 对象上的方法。这种可能性为称为分布式编程的新应用架构打开了大门,在分布式编程中,一个应用可以利用多台机器,运行多个 JVM 来处理业务逻辑。虽然可以使用套接字编程来调用驻留在不同 JVM(也可能在不同的机器上)中的对象的方法,但是编写代码并不容易。为了实现这一点,Java 提供了一种称为 Java 远程方法调用(Java RMI)的独立机制。

Java RMI 允许 Java 应用调用远程 JVM 中 Java 对象的方法。我将使用术语“远程对象”来指代由 JVM 创建和管理的 Java 对象,而不是管理调用该“远程对象”上的方法的 Java 代码的 JVM 通常,远程对象还意味着它是由 JVM 管理的,该 JVM 运行在访问它的机器之外的机器上。然而,Java 对象作为远程对象并不一定要存在于不同机器上的 JVM 中。出于学习目的,您将使用一台机器在一个 JVM 中部署远程对象,并在不同的 JVM 中启动另一个应用来访问远程对象。RMI 允许您将远程对象视为本地对象。在内部,它使用套接字来处理对远程对象的访问并调用其方法。

RMI 应用由两个程序组成,一个客户机和一个服务器,它们运行在两个不同的 JVM 中。服务器程序创建许多 Java 对象,并使远程客户机程序可以访问这些对象来调用这些对象上的方法。客户端程序需要知道远程对象在服务器上的位置,这样它就可以对它们调用方法。服务器程序创建一个远程对象,并将其引用注册(或绑定)到 RMI 注册表。RMI 注册表是一种名称服务,用于将远程对象引用绑定到名称,因此客户端可以使用注册表中基于名称的查找来获取远程对象的引用。RMI 注册表运行在独立于服务器程序的进程中。它是作为名为rmiregistry的工具提供的。当你在你的机器上安装一个 JDK/JRE 时,它被复制到 JDK/JRE 安装目录下的bin子目录中。

在客户端程序获得远程对象的远程引用后,它调用使用该引用的方法,就好像它是对本地对象的引用一样。RMI 技术负责调用在不同机器上的不同 JVM 上运行的服务器程序中的远程引用上的方法的细节。在 RMI 应用中,Java 代码是根据接口编写的。服务器程序包含接口的实现。客户机程序使用接口和远程对象引用来调用存在于服务器 JVM 中的远程对象上的方法。所有支持 Java RMI 的 Java 库类都在java.rmi包及其子包中。

RMI 体系结构

图 7-1 以简化的形式显示了 RMI 架构。图中的矩形框表示 RMI 应用中的一个组件。箭头线显示了沿箭头方向从一个组件发送到另一个组件的消息。显示从 1 到 11 的数字的椭圆表示在典型的 RMI 应用中发生的步骤序列。我将在本节详细讨论所有步骤。

A978-1-4302-6662-4_7_Fig1_HTML.jpg

图 7-1。

The RMI architecture

让我们假设您已经开发了运行 RMI 应用所需的所有 Java 类和接口。在这一节中,您将浏览运行 RMI 应用时涉及的所有步骤。在接下来的几节中,您将开发每一步所需的 Java 代码。

RMI 应用的第一步是在服务器中创建一个 Java 对象。该对象将被用作远程对象。要使普通的 Java 对象成为远程对象,还需要执行一个额外的步骤。这一步被称为导出远程对象。当一个普通的 Java 对象作为远程对象导出时,它就可以接收/处理来自远程客户机的调用了。导出过程产生一个远程对象引用(也称为存根)。远程引用知道导出对象的细节,比如它的位置和可以远程调用的方法。该步骤在图 7-1 中未标注。它发生在服务器程序内部。当这一步完成时,远程对象已经在服务器中创建好了,并准备好接收远程方法调用。

下一步由服务器执行,向 RMI 注册中心注册(或绑定)远程引用。服务器为它在 RMI 注册表中注册的每个远程引用选择一个惟一的名称。远程客户端需要使用相同的名称在 RMI 注册表中查找远程引用。这在图 7-1 中标记为#1。当这一步完成时,RMI 注册中心已经注册了远程对象引用,并且对调用远程对象上的方法感兴趣的客户机可以从 RMI 注册中心请求它的引用。

Tip

出于安全原因,RMI 注册中心和服务器必须运行在同一台机器上,以便服务器可以向 RMI 注册中心注册远程引用。如果没有施加这种限制,黑客可能会从他的机器上向您的 RMI 注册表注册他自己的有害 Java 对象。

这一步包括客户机和 RMI 注册中心之间的交互。通常,客户机和 RMI 注册中心运行在两台不同的机器上。客户机向 RMI 注册中心发送一个远程引用的查找请求。客户端使用名称在 RMI 注册表中查找远程引用。该名称与步骤#1 中服务器用来绑定 RMI 注册表中的远程引用的名称相同。在图 7-1 中,查找步骤被标记为#2。RMI 注册中心将远程引用(或存根)返回给图 7-1 中标记为步骤#3 的客户端。如果远程引用没有在 RMI 注册表中与客户机在查找请求中使用的名称绑定,RMI 注册表将抛出一个NotBoundException。如果这一步成功完成,客户机就收到了运行在服务器上的远程对象的远程引用(或存根)。

在这一步中,客户机调用存根上的一个方法。如图 7-1 中步骤#4 所示。此时,存根连接到服务器并传输调用远程对象上的方法所需的信息,例如方法的名称、方法的参数等。存根知道服务器的位置以及如何联系服务器上的远程对象的细节。该步骤在图 7-1 中标为步骤#5。网络层的许多不同层参与了从存根到服务器的信息传输。

骨架是客户端存根的服务器端副本。它的工作是接收存根发送的数据。这如图 7-1 中的步骤#6 所示。在一个框架收到数据后,它将数据重组为更有意义的格式,并调用远程对象上的方法,如图 7-1 中的步骤 7 所示。一旦服务器上的远程方法调用结束,框架就接收方法调用的结果(步骤#8),并通过网络层将信息传输回存根(步骤#9)。存根接收远程方法调用的结果(步骤#10),重组结果,并将结果传递给客户端程序(步骤#11)。

可以重复步骤#4 到#11 来调用同一远程对象上的相同或不同的方法。如果一个客户机想要调用一个不同的远程对象上的方法,它必须在启动一个远程方法调用之前首先执行步骤#2 和#3。

在 RMI 应用中,典型的情况是,客户机在开始时联系 RMI 注册中心以获得远程对象的存根。如果客户机需要运行在服务器上的另一个远程对象的存根,它可以通过调用它已经拥有的存根上的方法来获得它。请注意,远程对象的方法也可以向远程客户端返回一个存根。这样,远程客户端可以在启动时只在 RMI 注册表中执行一次查找。除了在 RMI 注册表中查找远程对象引用之外,为 RMI 应用编写的 Java 代码与非 RMI 应用没有什么不同。

开发 RMI 应用

这一节将向您介绍编写 Java 代码来开发 RMI 应用的步骤。您将开发一个远程工具 RMI 应用,它将允许您执行三件事情:从服务器回显一条消息,从服务器获取当前日期和时间,以及将两个整数相加。编写 RMI 应用涉及以下步骤:

  • 编写远程接口。
  • 在类中实现远程接口。这个类的对象充当远程对象。
  • 编写服务器程序。它创建一个实现远程接口的类的对象,并将其注册到 RMI 注册表中。
  • 编写一个客户端程序来访问服务器上的远程对象。

编写远程接口

远程接口类似于任何其他 Java 接口,其方法应该从运行在不同 JVM 中的远程客户端调用。它有四个特殊要求:

  • 它必须扩展Remote接口。Remote接口是一个没有声明任何方法的标记接口。
  • 远程接口中的所有方法都必须抛出一个RemoteException或异常,即它的超类,如IOExceptionExceptionRemoteException是被检查的异常。远程方法还可以抛出任意数量的其他特定于应用的异常。
  • 远程方法可以接受远程对象的引用作为参数。它也可以返回远程对象的引用作为它的返回值。如果远程接口中的方法接受或返回远程对象引用,则参数或返回类型必须声明为类型Remote,而不是实现Remote接口的类的类型。
  • 远程接口只能在其方法的参数或返回值中使用三种数据类型。它可以是基本类型、远程对象或可序列化的非远程对象。远程对象通过引用传递,而非远程可序列化对象通过复制传递。如果一个对象的类实现了java.io.Serializable接口,那么这个对象就是可序列化的。

您将您的远程接口命名为RemoteUtility。清单 7-1 包含了RemoteUtility远程接口的代码。它包含三个方法,分别叫做echo()getServerTime()add(),它们提供了你想要的三个功能。

清单 7-1。RemoteUtility 接口

// RemoteUtility.java

package com.jdojo.rmi;

import java.rmi.Remote;

import java.rmi.RemoteException;

import java.time.ZonedDateTime;

public interface RemoteUtility extends Remote {

// Echoes a string message back to the client

String echo(String msg) throws RemoteException;

// Returns the current date and time to the client

ZonedDateTime getServerTime() throws RemoteException;

// Adds two integers and returns the result to the client

int add(int n1, int n2) throws RemoteException;

}

实现远程接口

这一步包括创建一个实现远程接口的类。你将把这个类命名为RemoteUtilityImpl。它将实现RemoteUtility远程接口,并将提供三种方法的实现:echo()getServerTime()add()。这个类中可以有任意数量的其他方法。您必须做的唯一一件事就是为在RemoteUtility远程接口中定义的所有方法提供实现。远程客户端将只能调用该类的远程方法。如果在这个类中定义的方法不同于远程接口中定义的方法,那么这些方法对于远程方法调用是不可用的。但是,您可以使用其他方法来实现远程方法。清单 7-2 包含了RemoteUtilityImpl类的代码。

清单 7-2。RemoteUtility 远程接口的实现类

// RemoteUtilityImpl.java

package com.jdojo.rmi;

import java.time.ZonedDateTime;

public class RemoteUtilityImpl implements RemoteUtility {

public RemoteUtilityImpl() {

}

@Override

public String echo(String msg) {

return msg;

}

@Override

public ZonedDateTime getServerTime() {

return ZonedDateTime.now();

}

@Override

public int add(int n1, int n2) {

return n1 + n2;

}

}

远程对象实现类非常简单。它实现了RemoteUtility接口,并为该接口的三个方法提供了实现。注意,RemoteUtilityImpl类中的这些方法没有声明它们抛出了一个RemoteException。声明所有远程方法抛出一个RemoteException的要求是针对远程接口的,而不是实现远程接口的类。

有两种方法可以为远程接口编写实现类。一种方法是从java.rmi.server.UnicastRemoteObject类继承它。另一种方法是不从任何类或者从除了UnicastRemoteObject类之外的任何类继承它。清单 7-2 采用了后一种方法。它没有从任何类继承RemoteUtilityImpl类。

如果远程接口的实现类继承自UnicastRemoteObject类或其他类,会有什么不同呢?远程接口的实现类用于创建远程对象,远程对象的方法被远程调用。这个类的对象必须经过一个导出过程,这使得它适合于远程方法调用。UnicastRemoteObject类的构造函数自动为您导出对象。所以,如果你的实现类继承自UnicastRemoteObject类,它将为你以后的整个过程节省一步。有时你的实现类必须从另一个类继承,这将迫使你不要从UnicastRemoteObject类继承它。你需要注意的一点是,UnicastRemoteObject类的构造函数抛出一个RemoteException.如果你从UnicastRemoteObject类继承远程对象实现类,实现类的构造函数必须在其声明中抛出一个RemoteException。清单 7-3 通过继承UnicastRemoteObject类重写了RemoteUtilityImpl类。这个实现中有两个新东西——它在类声明中使用了extends子句,在构造函数声明中使用了throws子句。其他一切都保持不变。当你在本章的后面编写服务器程序时,我将讨论使用清单 7-2 和清单 7-3 中所示的RemoteUtilityImpl类的实现的区别。

清单 7-3。通过从 UnicastRemoteObject 类继承 RemoteUtilityImpl 类来重写它

// RemoteUtilityImpl.java

package com.jdojo.rmi;

import java.rmi.RemoteException;

import java.rmi.server.UnicastRemoteObject;

import java.time.ZonedDateTime;

public class RemoteUtilityImpl extends UnicastRemoteObject implements RemoteUtility {

// Must throw RemoteException

public RemoteUtilityImpl() throws RemoteException {

}

@Override

public String echo(String msg) {

return msg;

}

@Override

public ZonedDateTime getServerTime() {

return ZonedDateTime.now();

}

@Override

public int add(int n1, int n2) {

return n1 + n2;

}

}

编写 RMI 服务器程序

服务器程序的职责是创建远程对象,并使远程客户端可以访问它。服务器程序执行以下任务:

  • 安装安全管理器。
  • 创建并导出远程对象。
  • 向 RMI 注册应用注册远程对象。

后续部分将详细讨论这些步骤。

安装安全管理器

您需要确保服务器代码在安全管理器下运行。如果 RMI 程序没有运行安全管理器,它就不能从远程位置下载 Java 类。没有安全管理器,它只能使用本地 Java 类。在 RMI 服务器和 RMI 客户机中,程序可能需要从远程位置下载类文件。您将很快看到从远程位置下载 Java 类的例子。当在安全管理器下运行 Java 程序时,还必须通过 Java 策略文件控制对特权资源的访问。以下代码片段显示了如何安装安全管理器(如果尚未安装)。您可以使用java.lang.SecurityManager类或java.rmi.RMISecurityManager类的对象来安装安全管理器。

SecurityManager secManager = System.getSecurityManager();

if (secManager == null) {

System.setSecurityManager(new SecurityManager());

}

安全管理器通过策略文件控制对特权资源的访问。您需要设置适当的权限来访问 Java RMI 应用中使用的资源。对于本例,您将向所有代码授予所有权限。但是,您应该在生产环境中使用适当控制的策略文件。您需要在策略文件中输入以下条目来授予所有权限:

grant {

permission java.security.AllPermission;

};

通常,Java 策略文件驻留在计算机上用户的主目录中,它被命名为.java.policy.。注意,文件名以点开始。

创建和导出远程对象

RMI 服务器程序执行的下一步是创建一个实现远程接口的类的对象,它将作为一个远程对象。在您的例子中,您将创建一个RemoteUtilityImpl类的对象。

RemoteUtilityImpl remoteUtility = new RemoteUtilityImpl();

您需要导出一个远程对象,以便远程客户端可以调用它的远程方法。如果您的远程对象类(本例中为RemoteUtility类)继承自UnicastRemoteObject类,那么您不需要导出它。它会在您创建时自动导出。如果您的远程对象的类不是从UnicastRemoteObject类继承的,您需要使用UnicastRemoteObject类的exportObject()静态方法之一显式导出它。当您导出远程对象时,您可以指定一个端口号,它可以在该端口号上侦听远程方法调用。默认情况下,它监听端口 0,这是一个匿名端口。以下语句导出远程对象:

int port = 0;

RemoteUtility remoteUtilityStub =        (RemoteUtility)UnicastRemoteObject.exportObject(remoteUtility, port);

exportObject()方法返回导出的远程对象的引用,也称为存根或远程引用。您需要保留存根的引用,这样就可以向 RMI 注册中心注册它。

注册远程对象

服务器程序执行的最后一步是使用名称向 RMI 注册表注册(或绑定)远程对象引用。RMI 注册中心是一个提供名称服务的独立应用。要在 RMI 注册表中注册一个远程引用,您必须首先找到它。RMI 注册表在特定端口的机器上运行。默认情况下,它运行在端口 1099 上。找到注册表后,需要调用它的bind()方法来绑定远程引用。您也可以使用它的方法,如果指定名称的旧绑定已经存在,该方法将替换旧绑定。用的名字是一个String。您将使用名称MyRemoteUtility作为您的远程引用的名称。最好遵循 RMI 注册表中绑定引用对象的命名约定,以避免名称冲突。

Registry registry = LocateRegistry.getRegistry("localhost", 1099);

String name = "MyRemoteUtility";

registry.rebind(name, remoteUtilityStub);

这就是编写服务器程序所需的全部内容。清单 7-4 包含了 RMI 服务器的完整代码。它假设RemoteUtilityImpl类不继承清单 7-2 中列出的UnicastRemoteObject类。

清单 7-4。一个 RMI 远程服务器程序

// RemoteServer.java

package com.jdojo.rmi;

import java.rmi.RemoteException;

import java.rmi.registry.LocateRegistry;

import java.rmi.registry.Registry;

import java.rmi.server.UnicastRemoteObject;

public class RemoteServer {

public static void main(String[] args) {

SecurityManager secManager = System.getSecurityManager();

if (secManager == null) {

System.setSecurityManager(new SecurityManager());

}

try {

RemoteUtilityImpl remoteUtility = new RemoteUtilityImpl();

// Export the object as a remote object

int port = 0; // an anonymous port

RemoteUtility remoteUtilityStub

= (RemoteUtility) UnicastRemoteObject.exportObject(

remoteUtility, port);

// Locate the registry

Registry registry = LocateRegistry.getRegistry("localhost", 1099);

// Bind the exported remote reference in the registry

String name = "MyRemoteUtility";

registry.rebind(name, remoteUtilityStub);

System.out.println("Remote server is ready...");

}

catch (RemoteException e) {

e.printStackTrace();

}

}

}

如果您使用清单 7-3 中列出的RemoteUtilityImpl类的实现,您将需要修改清单 7-4 中的代码。try-catch块中的代码将变为如下代码。所有其他代码将保持不变。

RemoteUtilityImpl remoteUtility = new RemoteUtilityImpl();

// No need to export the object

// Locate the registry

Registry registry = LocateRegistry.getRegistry("localhost", 1099);

// Bind the exported remote reference in the registry

String name = "MyRemoteUtility";

registry.rebind(name, remoteUtility);

System.out.println("Remote server is ready...");

您还没有准备好启动您的服务器程序。我将在接下来的小节中讨论如何启动 RMI 应用。

出于安全原因,您只能从与 RMI 注册表运行在同一台机器上的 RMI 服务器程序将远程引用绑定到 RMI 注册表。否则,黑客可能会将任何任意的、可能有害的远程引用绑定到您的 RMI 注册表。默认情况下,LocateRegistry类的getRegistry()静态方法返回一个运行在同一台机器上端口 1099 的注册表的存根。您可以只使用下面的代码来定位服务器程序中的注册表。

// Get a registry stub for a local machine at port 1099

Registry registry = LocateRegistry.getRegistry();

请注意,对LocateRegistry.getRegistry()方法的调用并不试图连接到注册表应用。它只是返回注册表的存根。这个存根控件上的后续调用、bind()rebind()或任何其他方法调用尝试连接到注册中心应用。

编写 RMI 客户端程序

RMI 客户端程序调用远程对象上的方法,这些方法存在于远程服务器上。客户端程序必须做的第一件事是知道远程对象的位置。RMI 服务器程序创建并知道远程对象的位置。发布远程对象的位置细节是服务器程序的责任,这样客户端就可以定位和使用它。服务器程序通过将远程对象与 RMI 注册表绑定来发布远程对象的位置细节,并给它一个名称,在您的例子中是MyRemoteUtility。客户端程序联系 RMI 注册表,并执行基于名称的查找来获取远程引用。获得远程引用后,客户端程序调用远程引用上的方法,这些方法在服务器中执行。通常,RMI 客户端程序执行以下操作:

  • 它确保它在安全管理器下运行。

SecurityManager secManager = System.getSecurityManager();

if (secManager == null) {

System.setSecurityManager(new SecurityManager());

}

它定位远程引用被服务器绑定的注册表。您必须知道机器名或 IP 地址,以及运行 RMI 注册表的端口号。在真实的 RMI 程序中,您不会在客户端程序中使用localhost来定位注册表。相反,RMI 注册中心将在一台单独的机器上运行。对于您的例子,您将在同一台机器上运行所有三个程序——RMI 注册表、服务器和客户机。

// Locate the registry

Registry registry = LocateRegistry.getRegistry("localhost", 1099);

  • 它使用Registry接口的lookup()方法在注册表中执行查找。它将绑定的远程引用的名称传递给lookup()方法,并获取远程引用(或存根)。注意,lookup()方法必须使用与服务器绑定/重新绑定远程引用相同的名称。lookup()方法返回一个Remote对象。您必须将其转换为远程接口的类型。以下代码片段将从lookup()方法返回的远程引用转换为RemoteUtility接口类型:

String name = "MyRemoteUtility";

RemoteUtility remoteUtilStub = (RemoteUtility)registry.lookup(name);

  • 它调用远程引用(或存根)上的方法。客户端程序将remoteUtilStub引用视为对本地对象的引用。对它进行的任何方法调用都被发送到服务器执行。所有远程方法都抛出一个RemoteException。当您调用任何远程方法时,您必须处理RemoteException

// Call the echo() method

String reply = remoteUtilStub.echo("Hello from the RMI client.");

清单 7-5 包含了你的客户端程序的完整代码。暂时不要运行这个程序。在接下来的几节中,您将一步一步地运行您的 RMI 应用。您可能会注意到,编写 RMI 代码并不复杂。RMI 中不同组件的管道是复杂的。

清单 7-5。一个 RMI 远程客户程序

// RemoteClient.java

package com.jdojo.rmi;

import java.rmi.NotBoundException;

import java.rmi.RemoteException;

import java.rmi.registry.LocateRegistry;

import java.rmi.registry.Registry;

import java.time.ZonedDateTime;

public class RemoteClient {

public static void main(String[] args) {

SecurityManager secManager = System.getSecurityManager();

if (secManager == null) {

System.setSecurityManager(new SecurityManager());

}

try {

// Locate the registry

Registry registry =

LocateRegistry.getRegistry("localhost", 1099);

String name = "MyRemoteUtility";

RemoteUtility remoteUtilStub =

(RemoteUtility) registry.lookup(name);

// Echo a message from the server

String msg = "Hello";

String reply = remoteUtilStub.echo(msg);

System.out.println("Echo Message: " + msg +

", Echo reply: " + reply);

// Get the server date and time with the zone info

ZonedDateTime serverTime = remoteUtilStub.getServerTime();

System.out.println("Server Time: " + serverTime);

// Add two integers

int n1 = 101;

int n2 = 207;

int sum = remoteUtilStub.add(n1, n2);

System.out.println(n1 + " + " + n2 + " = " + sum);

}

catch (RemoteException | NotBoundException e) {

e.printStackTrace();

}

}

}

分离服务器和客户端代码

在 RMI 应用中,将服务器和客户机程序的代码分开是很重要的。服务器程序需要有以下三个组件:

  • 远程接口
  • 远程接口的实现类
  • 服务器程序

客户端程序需要有以下两个组件。

  • 远程接口
  • 客户端程序

客户端程序不应该知道实现远程接口的实现类。让客户机程序可以访问这个类违背了开发 RMI 应用的目的。您可以拥有运行服务器和客户端程序所需的附加类。

对于您的示例,您可以将服务器和客户机类文件分开,或者放在两个目录结构中,或者放在两个 JAR 文件中。您将在utilserver.jarutilclient.jar中分别打包服务器和客户端程序的类文件。

utilserver.jar文件中的文件有

  • RemoteUtility.class
  • RemoteUtilityImpl.class
  • RemoteServer.class

utilclient.jar文件中的文件有

  • RemoteUtility.class
  • RemoteClient.class

生成存根和框架

当使用UnicastRemoteObject类导出远程对象时,RMI 需要一个存根类。您可以执行以下两项操作之一:

  • 您可以使用UnicastRemoteObject类来继承您的远程接口实现类,它将自动导出您的远程对象。
  • 您可以使用UnicastRemoteObject类的exportObject()方法显式导出远程对象。

在这两种情况下,当导出一个远程对象时,RMI 需要一个存根类。在 Java 5 之前,您需要执行一个额外的步骤来为远程接口实现类生成存根类。这是通过使用 JDK 安装文件夹的bin子目录中的rmic命令来完成的。运行该命令,传递远程接口实现类的完全限定名,如下所示:

rmic com.jdojo.rmi.RemoteUtilityImpl

您可能需要适当地设置CLASSPATH环境变量,以便rmic能够找到您指定作为其参数的类。上面的命令将在RemoteUtilityImpl.class文件所在的同一个文件夹中生成以下两个类文件。

  • RemoteUtilityImpl_Stub.class
  • RemoteUtilityImpl_Skel.class

您需要在utilserver.jar文件中包含这两个类文件。请注意,只有在使用 Java 5 之前的 Java 版本时,才需要这一步。如果您的客户端程序运行的是 Java 5 之前的 Java 版本,而您的服务器运行的是 Java 5 或更高版本,您也需要执行此步骤。如果您有兴趣查看为这两个类文件生成的 Java 源代码,您可以使用带有rmic命令的–keep(或-keepgenerated)选项,这将为这些类生成 Java 源代码。以下命令将生成四个文件,两个.class文件和两个.java文件。

rmic –keep com.jdojo.chapter5.RemoteUtilityImpl

运行 RMI 应用

您需要按照以下特定顺序启动 RMI 应用中涉及的所有程序:

  • 运行 RMI 注册表。
  • 运行 RMI 服务器程序。
  • 运行 RMI 客户端程序。

如果您在运行任何程序时遇到任何问题,请参考本章后面的“排除 RMI 应用故障”一节。

您的服务器和客户端程序使用安全管理器。在成功运行 RMI 应用之前,必须正确配置 java 策略文件。出于学习目的,您可以将所有安全权限授予 RMI 应用。您可以通过创建一个名为rmi.policy的文本文件(您可以使用您想要的任何其他文件名)并输入以下内容来做到这一点,这将向所有代码授予所有权限:

grant {

permission java.security.AllPermission;

};

当运行 RMI 客户机或服务器程序时,需要使用java.security.policy JVM 选项将rmi.policy文件设置为 Java 安全策略文件。假设您已经将rmi.policy文件保存在 Windows 的C:\文件夹中。

java -Djava.security.policy=file:/c:/rmi.policy <other-options>

这种设置 Java 策略文件的方法是临时的。它应该仅用于学习目的。您需要在生产环境中设置细粒度的安全性。

运行 RMI 注册表

RMI 注册表应用是随 JDK/JRE 安装一起提供的。它被复制到相应安装主文件夹的bin子文件夹中。在 Windows 平台上,它是rmiregistry.exe可执行文件。您可以通过使用命令提示符启动rmiregistry应用来运行 RMI 注册表。它接受将在其上运行的端口号。默认情况下,它运行在端口 1099 上。以下命令在 Windows 上使用命令提示符在端口 1099 启动它:

C:\java8\bin> rmiregistry

以下命令在端口 8967 启动 RMI 注册表:

C:\java8\bin> rmiregistry 8967

rmiregistry应用不在提示符下打印任何启动信息。通常,它是作为后台进程启动的。

很可能,该命令在您的机器上不起作用。使用该命令,您将能够成功启动rmiregistry。然而,当您在下一节运行 RMI 服务器应用时,您将得到ClassNotFoundExceptionrmiregistry应用需要访问 RMI 服务器应用中使用的一些类(已注册的类)。有三种方法可以让rmiregistry使用这些类:

  • 适当地设置类路径。
  • java.rmi.server.codebase JVM 属性设置为包含rmiregistry所需类的 URL。
  • 将名为java.rmi.server.useCodebaseOnly的 JVM 属性设置为false。在 JDK 7u21(也在 JDK 6u45 和 JDK 5u45)中,此属性默认设置为 true。之前默认设置为false。如果该属性设置为falsermiregistry可以从服务器下载需要的类文件。

在启动rmiregistry之前,以下命令将serverutil.jar文件添加到CLASSPATH:

C:\java8\bin> SET CLASSPATH=C:\utilserver.jar

C:\java8\bin> rmiregistry

除了设置CLASSPATH使类对 rmiregistry 可用之外,还可以设置java.rmi.server.codebase JVM 属性,这是一个用空格分隔的 URL 列表,如下所示:

rmiregistry -J-Djava.rmi.server.codebase=file:/C:/utilserver.jar

下面的命令重置CLASSPATH并将 JVM 的java.rmi.server.useCodebaseOnly属性设置为false,这样rmiregistry将从 RMI 服务器下载任何需要的类文件。您的示例将使用以下命令:

C:\java8\bin> SET CLASSPATH=

C:\java8\bin> rmiregistry -J-Djava.rmi.server.useCodebaseOnly=false

运行 RMI 服务器

在运行 RMI 服务器之前,必须运行 RMI 注册表。回想一下,服务器在一个安全管理器下运行,该安全管理器要求您授予在 Java 策略文件中执行某些操作的权限。确保您已经在策略文件中输入了所需的授权。您可以使用以下命令来运行服务器程序。命令文本在一行中输入;为了清楚起见,已经用多行显示了它。命令文本中的每个部分都应该用空格分隔,而不是换行。在命令中,您需要更改 JAR 和策略文件的路径,以反映它们在您的机器上的路径。

java -cp C:\utilserver.jar

-Djava.rmi.server.codebase=file:/C:/utilserver.jar

-Djava.security.policy=file:/c:/rmi.policy

com.jdojo.rmi.RemoteServer

–cp选项将CLASSPATH设置为utilserver.jar文件。如果您不想使用 JAR 文件来打包与服务器相关的类文件,您可以使用任何其他的CLASSPATH设置,以便服务器程序可以运行。如果您已经设置了适当的CLASSPATH,您可以从命令文本中删除–cp选项和CLASSPATH值。

你需要设置一个java.rmi.server.codebase属性。如果 RMI 注册表和客户机程序需要下载它们没有的类文件,它们就会使用这个方法。该属性的值是一个 URL,它可以指向本地文件系统、web 服务器、FTP 服务器或任何其他资源。URL 可以指向一个 JAR 文件,就像本例中一样,也可以指向一个目录。如果它指向一个目录,URL 必须以正斜杠结束。以下命令使用文件夹作为其基本代码。如果 RMI 注册中心和客户机需要任何类文件,它们将尝试从 URL 文件/C:/myrmi/classes/下载类文件。

java -cp C:\utilserver.jar

-Djava.rmi.server.codebase=file:/C:/myrmi/classes/

com.jdojo.rmi.RemoteServer

您还可以设置一个java.rmi.server.codebase属性来指向一个 web 服务器,在那里您可以存储您需要的类文件,如下所示:

java -cp C:\utilserver.jar

-Djava.rmi.server.codebase=http://www.jdojo.com/rmi/classes/

com.jdojo.rmi.RemoteServer

如果将类文件存储在多个位置,可以指定所有位置,用空格分隔,如下所示:

java -cp C:\utilserver.jar

-Djava.rmi.server.codebase="http://www.jdojo.com/rmi/classes/

com.jdojo.rmi.RemoteServer

它将一个位置指定为目录,将另一个位置指定为 JAR 文件。一个使用http协议,另一个使用ftp协议。这两个值由一个空格隔开,它们在一行上,而不是如图所示的两行。当您运行服务器或客户端程序时,可能会出现ClassNotFoundException,这很可能是由于java.rmi.server.codebase属性的设置不正确,或者根本没有设置该属性。

运行 RMI 客户端程序

成功启动 RMI 注册表和服务器应用之后,就该启动 RMI 客户机应用了。您可以使用以下命令来运行客户端程序:

java -cp C:\utilclient.jar

-Djava.rmi.server.codebase=file:/C:/utilclient.jar

-Djava.security.policy=file:/c:/rmi.policy

com.jdojo.rmi.RemoteClient

当您运行上面的命令时,您不必包含一个java.rmi.server.codebase选项。客户端程序也可以使用以下命令运行。当客户端程序成功运行时,您应该能够在控制台上看到输出。运行该程序时,您可能会得到不同的输出,因为它会打印当前日期和时间以及运行服务器应用的服务器的时区信息。

java -cp C:\utilclient.jar

-Djava.security.policy=file:/c:/rmi.policy

com.jdojo.rmi.RemoteClient

Echo Message: Hello, Echo reply: Hello

Server Time: 2014-06-22T13:11:31.790-05:00[America/Chicago]

101 + 207 = 308

RMI 应用故障排除

在第一次运行 RMI 应用之前,很可能会出现许多异常。本节将列出一些您可能会收到的例外情况。它还将列出这些异常的一些可能原因和一些可能的解决方案。当您试图运行 RMI 应用时,不可能列出所有可能的错误。通过查看异常的栈输出,您应该能够找出大多数错误。

Java . RMI . stubnotfounindexception

当你试图运行一个服务器程序时,你得到一个StubNotFoundException。异常栈跟踪类似于以下内容:

java.rmi.StubNotFoundException: Stub class not found: com.jdojo.rmi.RemoteUtilityImpl_Stub; nested exception is:

java.lang.ClassNotFoundException: com.jdojo.rmi.RemoteUtilityImpl_Stub

at sun.rmi.server.Util.createStub(Util.java:292)...

这种异常可能由于多种原因而发生。以下是您可以寻找并解决的一些原因:

  • 您可能正在使用 Java 5 之前的 Java 版本运行服务器程序。您必须使用rmic命令创建存根和框架,并在运行服务器程序时让 JVM 可以访问它们。更多细节请参考“生成存根和框架”一节。
  • 当您导出远程对象并且不传递端口号时,可能会出现此错误:

RemoteUtility remoteUtilityStub =

(RemoteUtility)UnicastRemoteObject.exportObject(remoteUtility);

  • 如果您没有向UnicastRemoteObject类的exportObject()方法传递端口号来导出远程对象,您必须首先使用rmic命令生成存根和框架。更多细节请参考“生成存根和框架”一节。解决这个问题的另一种方法是向exportObject()方法传递一个端口号。端口号 0(零)表示匿名端口。

RemoteUtility remoteUtilityStub =

(RemoteUtility)UnicastRemoteObject.exportObject(remoteUtility,0);

Java . RMI . server . export exception

当您试图运行rmiregistry应用或服务器应用时,您会得到一个ExportException。当您试图运行rmiregistry应用时,如果您得到这个异常,异常栈跟踪将类似于图中所示。

java.rmi.server.ExportException:Port already in use: 1099; nested exception is:

java.net.BindException: Address already in use: JVM_Bind...

它表明端口号 1099(在您的情况下可能是不同的号码)已经被使用。也许您已经在端口 1099(这是一个rmiregistry应用的默认端口号)上启动了rmiregistry应用,或者某个其他应用正在使用端口 1099。您可以执行以下两项操作之一来解决此问题:

  • 您可以停止正在使用端口 1099 的应用,并在端口 1009 启动rmiregistry应用。
  • 您可以在 1099 以外的端口启动rmiregistry应用。

如果在运行服务器程序时得到一个ExportException,这是由于远程对象的导出过程失败造成的。导出过程失败的原因有很多。以下异常栈跟踪(显示了部分跟踪)是由两次导出同一个远程对象引起的:

java.rmi.server.ExportException: object already exported

at sun.rmi.transport.ObjectTable.putTarget(ObjectTable.java:189)

at sun.rmi.transport.Transport.exportObject(Transport.java:92)...

检查您的服务器程序,并确保您只导出远程对象一次。从UnicastRemoteObject类继承远程对象实现类并使用UnicastRemoteObject类的exportObject()方法来导出远程对象是一个常见的错误。当您从UnicastRemoteObject类继承远程对象的实现类时,您创建的远程对象会被自动导出。如果您尝试使用exportObject()方法再次导出它,您将得到这个异常。在讨论远程接口实现类时,我已经多次强调了这一点。当你开发一个 RMI 应用时,记住这句话,“犯错是程序员,惩罚 Java。”即使是 RMI 程序设置中的一个小错误,也可能花费你数小时的时间来检测和修复。

Java . security . accesscontrolexception

当您的 Java 策略文件没有运行 RMI 应用所必需的grant条目时,您会得到这个异常。下面是一个异常的部分栈跟踪,它是在您尝试运行服务器程序,并尝试将远程对象绑定到 RMI 注册表时导致的:

java.security.AccessControlException: access denied (java.net.SocketPermission 127.0.0.1:1099 connect,resolve)...

注册中心、服务器和客户端之间的通信是使用套接字执行的。为了安全起见,您必须在 Java 策略文件中授予适当的套接字权限,以便 RMI 应用的三个组件能够进行通信。大多数与安全相关的异常可以通过在 Java 策略文件中授予适当的权限来修复。

Java . lang . class notfounindexception

当没有找到 Java 运行时需要的类文件时,您会得到一个ClassNotFoundException异常。到目前为止,您一定已经多次收到这个异常。大多数情况下,当CLASSPATH没有正确设置时,您会收到这个异常。在 RMI 应用中,这个异常可能是另一个异常的原因。以下栈跟踪显示抛出了java.rmi.ServerException异常,其原因在于ClassNotFoundException异常:

java.rmi.ServerException: RemoteException occurred in server thread; nested exception is:

java.rmi.UnmarshalException: error unmarshalling arguments; nested exception is:

java.lang.ClassNotFoundException: com.jdojo.rmi.RemoteUtility

...

Caused by: java.lang.ClassNotFoundException: com.jdojo.rmi.RemoteUtility

at java.net.URLClassLoader$1.run(URLClassLoader.java:220)

at java.net.URLClassLoader$1.run(URLClassLoader.java:209)

当您运行服务器或客户端应用时,java.rmi.server.codebase选项没有正确设置或根本没有设置,就会引发这种类型的异常。

当服务器程序在没有使用java.rmi.server.codebase选项的情况下启动,并且rmiregistry应用在没有设置CLASSPATH的情况下运行时,这个异常被抛出。当您试图将一个远程引用与一个rmiregistry应用绑定/重新绑定时,服务器应用会将该远程引用发送给rmiregistry应用。在 JVM 中将远程引用表示为 Java 对象之前,rmiregistry应用必须加载该类。此时,rmiregistry将尝试从服务器启动时使用java.rmi.server.codebase属性指定的位置下载所需的类文件。

如果在运行客户端程序时遇到这个异常,请确保在运行客户端程序时设置了java.rmi.server.codebase属性。

请在运行服务器和客户端程序时检查CLASSPATHjava.rmi.server.codebase属性,以避免此异常。

当您运行客户端程序时,您会得到一个ClassNotFoundException,因为服务器无法找到在服务器端解组客户端调用所需的一些类定义。异常的部分栈跟踪示例如下所示:

java.rmi.ServerException: RemoteException occurred in server thread; nested exception is: java.rmi.UnmarshalException: error unmarshalling arguments; nested exception is: java.lang.ClassNotFoundException: com.jdojo.rmi.Square

at sun.rmi.server.UnicastServerRef.dispatch(UnicastServerRef.java:336)

at sun.rmi.transport.Transport$1.run(Transport.java:159)...

在远程接口中定义的远程方法可以接受参数,该参数可以是接口或类类型。客户端可以传递实现接口的类的对象或远程接口的方法签名中定义的类型的子类的对象。如果服务器上不存在该类定义,服务器将尝试使用客户端应用中设置的java.rmi.server.codebase属性下载该类。您需要确保出现该错误的类(异常栈跟踪显示类名为com.jdojo.rmi.Square)在服务器 JVM 的CLASSPATH中,或者在运行远程客户端时设置java.rmi.server.codebase属性,以便服务器可以下载该类。

调试 RMI 应用

通过将名为java.rmi.server.logCalls的 JVM 属性设置为true,可以为 RMI 服务器应用打开 RMI 日志记录。默认情况下,它被设置为false。以下命令启动您的RemoteServer应用,将java.rmi.server.logCalls属性设置为true:

java -cp C:\utilserver.jar

-Djava.rmi.server.logCalls=true

–Djava.rmi.server.codebase="http://www.myurl.com/rmiclasses

com.jdojo.rmi.RemoteServer

当服务器 JVM 的java.rmi.server.logCalls属性被设置为true时,对服务器的所有传入调用以及在传入调用执行期间抛出的任何异常的栈跟踪都被记录到标准错误中。

RMI 运行时还允许您将服务器应用中的传入调用记录到一个文件中,而不考虑为服务器 JVM 的java.rmi.server.logCalls属性设置的值。您可以使用java.rmi.server.RemoteServer类的setLog(OutputStream out)静态方法将所有来电细节记录到一个文件中。通常,您在服务器程序代码的开头设置用于日志记录的文件输出流,比如您的com.jdojo.rmi.RemoteServer类的main()方法中的第一条语句。下面的代码片段支持将远程服务器应用中的调用记录到 C :\rmi.log文件中。您可以通过使用null作为setLog()方法中的OutputStream来禁用呼叫记录。

try {

java.io.OutputStream os = new java.io.FileOutputStream("C:\\rmi.log");

java.rmi.server.RemoteServer.setLog(os);

}

catch (FileNotFoundException e) {

System.err.println("Could not enable incoming calls logging.");

e.printStackTrace();

}

当安全管理器安装在服务器上时,允许记录到文件的运行代码必须有一个java.util.logging.LoggingPermission,其权限目标为“control”。Java 策略文件中的以下 grant 条目将授予该权限。您还必须在 Java 策略文件中授予日志文件(本例中为 C :\rmi.log)的“写”权限。

grant {

permission java.io.FilePermission "c:\\rmi.log", "write";

permission java.util.logging.LoggingPermission "control";

};

如果您想要获得关于 RMI 客户机应用的调试信息,那么在启动 RMI 客户机应用时,将一个非标准的sun.rmi.client.logCalls属性设置为true。它将显示关于标准错误的调试信息。由于该属性不是公共规范的一部分,因此在未来的版本中可能会被删除。关于调试选项的更多细节,您需要参考 RMI 规范。

动态类下载

JVM 在创建类的对象之前加载类定义。它使用一个类加载器在运行时加载一个类。类加载器是java.lang.ClassLoader类的一个实例。类装入器必须先找到类的字节码,然后才能将其定义装入 JVM。Java 类加载器能够从任何位置加载类的字节码,例如本地文件系统、网络等。一个 JVM 中可以有多个类装入器,它们可以是系统定义的,也可以是自定义的。

JVM 在启动时创建一个类加载器,称为类加载器。引导类加载器负责加载基本 JVM 功能所需的初始类。基于父子关系,类装入器被组织成树状结构。引导类装入器没有父类。所有其他类装入器都将引导类装入器作为它们的直接或间接父类。在一个典型的类加载过程中,当一个类加载器被要求加载一个类的字节码时,它要求它的父类加载这个类,这个类反过来又要求它的父类,以此类推,直到引导类加载器得到加载这个类的请求。如果没有一个父类装入器能够装入该类,收到装入该类的初始请求的类装入器将尝试装入该类。

RMI 运行时使用一个特殊的 RMI 类加载器,负责加载 RMI 应用中的类。当一个对象在 RMI 应用中从一个 JVM 传递到另一个 JVM 时,发送 JVM 必须序列化和封送该对象,接收 JVM 必须反序列化和解封送该对象。发送 JVM 将属性java.rmi.server.codebase的值添加到对象的序列化流中。当在另一端接收到对象流时,接收 JVM 必须使用类装入器装入对象的类定义,然后才能将对象流转换成 Java 对象。JVM 指示 RMI 类加载器加载对象的类定义,它已经以流的形式接收到了该定义。类装入器试图从它的 JVM CLASSPATH中装入类定义。如果使用CLASSPATH没有找到类定义,那么类加载器使用对象流中java.rmi.server.codebase属性的值来加载类定义。

注意,java.rmi.server.codebase属性是在一个 JVM 中设置的,它用于下载另一个 JVM 中的类定义。当您运行 RMI 服务器或客户端程序时,可以设置该属性。当一方(服务器或客户机)将一个对象传输到另一方时,另一方没有字节码来表示正在接收的对象的类定义,发送方必须在发送对象时设置java.rmi.server.codebase属性,以便接收端可以使用该属性下载类字节码。java.rmi.server.codebase属性的值是一个用空格分隔的 URL 列表。

从安全的角度来看,从 RMI 服务器下载代码到客户机可能没有问题。有时从客户端下载代码到服务器可能被认为是不安全的。默认情况下,禁止从远程 JVM 下载类。RMI 允许您通过使用一个java.rmi.server.useCodebaseOnly属性来启用/禁用这个特性。默认设置为true。如果设置为true,JVM 的类装入器将只从本地CLASSPATH或本地设置的java.rmi.server.codebase属性装入类。也就是说,如果设置为true,类加载器将不会从接收到的对象流中读取java.rmi.server.codebase的值来下载类定义。相反,它会在它的 JVM CLASSPATH中寻找类定义,并使用设置为它自己的 JVM 的java.rmi.server.codebase属性值的 URL。也就是说,当java.rmi.server.useCodebaseOnly属性被设置为true时,RMI 类加载器忽略从发送 JVM 在对象流中发送的codebase的值。属性名useCodebaseOnly似乎用词不当。如果把它命名为useLocallySetCodebaseOnly,可能会更好地表达它的意思。以下是在运行 RMI 服务器时如何设置该属性:

java -cp C:\utilserver.jar

–Djava.rmi.server.codebase="http://www.myurl.com/rmiclasses

-Djava.rmi.server.useCodebaseOnly=true

com.jdojo.rmi.RemoteServer

Tip

从 JDK 7u21 开始(也在 JDK 6u45 和 JDK 5u45 中),java.rmi.server.codebase属性的默认值被设置为true。它的默认值曾经是false。这意味着,默认情况下,不允许应用从其他 JVM 下载类。

java.rmi.server.useCodebaseOnly属性设置为true有两种含义:

  • 如果服务器需要一个类作为来自客户端的远程调用的一部分,它将总是在它的CLASSPATH中查找,或者它将使用您为服务器程序设置的java.rmi.server.codebase的值。在上面的例子中,服务器中的所有类都必须在它的CLASSPATH中或者在 URL http://www.myurl.com/rmiclasses 中找到。
  • 如果客户端需要在远程方法调用中使用新的类类型,新的类类型必须预先为服务器所知,因为服务器永远不会使用客户端关于从何处下载所需新类的指令(在客户端使用java.rmi.server.codebase属性设置)。这意味着您必须使远程客户机将要使用的新类在服务器的CLASSPATH中或者在指定为服务器的java.rmi.server.codebase属性的 URL 中可用。当远程方法接受一个接口类型,并且客户端发送实现该接口的类的对象时,可能会出现这种情况。在这种情况下,服务器可能没有与客户端相同的接口新实现的定义。

如果您为运行 RMI 客户机应用的 JVM 将java.rmi.server.useCodebaseOnly属性设置为true,那么上面的论点同样适用于运行 RMI 客户机应用。如果客户端应用的这个属性被设置为true,那么您必须使所有必需的类对客户端可用,要么将它们放在它的CLASSPATH中,要么将它们放在 URL 中,并将 URL 设置为客户端的java.rmi.server.codebase属性的值。

远程对象的垃圾收集

在 RMI 应用中,远程对象是在服务器上的 JVM 中创建的。RMI 注册表和远程客户端保存远程对象的引用。远程对象会被垃圾收集吗?而且,如果它确实被垃圾收集了,它是什么时候发生的,又是如何发生的?本地对象的垃圾收集很容易。在同一个 JVM 中创建和引用了一个本地对象。对于垃圾收集器来说,确定一个本地对象在 JVM 中不再被引用是一件容易的事情。

在 RMI 应用中,您需要一个垃圾收集器来跟踪远程 JVM 中远程对象的引用。假设一个 RMI 服务器创建了一个RemoteUtilityImpl类的远程对象,五个客户机获得了它的远程引用。RMI 注册中心也是一个客户机,它在绑定/重新绑定过程中获取远程引用。服务器何时以及如何垃圾收集被五个客户端引用的RemoteUtilityImpl类的唯一对象?

拥有远程对象的服务器上的 JVM 和五个不同客户机上的五个 JVM 必须交互,因此当服务器 JVM 中的远程对象不再被任何远程客户机使用时,可以对其进行垃圾收集。在这个讨论中,让我们忽略服务器 JVM 中远程对象的本地引用。远程客户端和 RMI 服务器之间交互依赖于许多不可靠的因素。例如,网络可能会中断,远程客户端可能无法与服务器通信。第二个要考虑的问题是谁发起远程客户机和服务器之间的交互?是服务器不断询问远程客户端是否有实时远程引用吗?是远程客户端一直告诉服务器它仍然有一个活动的远程引用吗?客户机和服务器之间交互的责任由双方分担。远程客户端需要向服务器更新其远程引用的有效性。如果服务器在一段时间内没有收到任何客户端的消息,它会单方面决定将远程对象作为未来垃圾收集的候选对象。

RMI 垃圾收集器是基于引用计数的。参考计数具有关联的租约。租约有有效期。当一个远程客户机(包括一个 RMI 注册中心)获得一个对远程对象的引用时,它向服务器上的 RMI 运行时发送一个消息,请求租用那个远程对象引用。服务器向该客户端授予特定时间段的租约。服务器将该远程对象的引用计数递增 1,并将租约发送回客户端。默认情况下,RMI 服务器授予远程对象 10 分钟的租约。现在,以下是一些可能性:

  • 客户机可以在其从服务器获得租用的时间段内完成远程对象引用。
  • 客户可能希望将租期延长一段时间。
  • 客户端崩溃。服务器不接收来自客户端的任何消息,并且客户端获取的远程引用的租期到期。

让我们看看每一种可能性。客户端在三种不同的情况下向服务器发送消息。它在第一次收到远程引用时就发送一条消息。它告诉服务器它有一个远程对象的引用。第二次,当它想要更新远程引用的租约时,它向服务器发送一条消息。第三次,当完成远程引用时,它向服务器发送一条消息。事实上,当一个远程引用在客户机应用中被垃圾收集时,它会向服务器发送一条消息,表明它已经完成了对远程对象的处理。在内部,远程客户端发送给服务器的消息只有两种类型:脏的和干净的。发送脏消息以获得租约,发送干净消息以移除/取消租约。这两条消息使用java.rmi.dgc.DGC接口的dirty()clean()方法从远程客户端发送到服务器。作为一名开发人员,除了可以自定义租期之外,您对这些消息(发送或接收)没有任何控制权。租用时间段控制这些消息发送到服务器的频率。

当一个客户机完成一个远程对象引用时,它向服务器发送一个消息,表明它已经完成了。当客户机的 JVM 中的远程引用被垃圾收集时,发送该消息。因此,重要的是,一旦使用完毕,就将客户端程序代码中的远程引用设置为null。否则,服务器将继续保留远程对象,即使远程客户端不再使用它。您无法控制该消息从远程客户端发送到服务器的时间。要加快这个消息的发送,您所能做的就是将客户机代码中的远程对象引用设置为null,这样 garage collector 将尝试对它进行垃圾收集,并向服务器发送一个干净的消息。

RMI 运行时跟踪远程客户端 JVM 中远程引用的租约。当租约到期到一半时,远程客户端会向服务器发送一个租约续订请求,并续订租约。当远程客户机的租约为远程引用续订时,服务器会跟踪租约到期时间,并且不会对远程对象进行垃圾收集。理解为远程引用设置租期的重要性是很重要的。如果太小,大量的网络带宽将用于频繁更新租约。如果它太大,服务器将保持远程对象存活更长时间,以防客户端完成其远程引用,并且不通知服务器取消租用。我将简要讨论如何在 RMI 应用中设置租期值。

如果服务器没有从远程客户机听到任何关于客户机已经获得的远程引用的租用的消息,则在租用期到期后,它简单地取消租用并将该远程对象的引用计数减 1。这种由服务器做出的单方面决定对于处理行为不良的远程客户端(没有告诉服务器它是通过远程引用完成的)或任何可能阻止远程客户端与服务器通信的网络/系统故障非常重要。

当所有客户机都完成了对一个远程对象的远程引用时,它在服务器中的引用计数将下降到零。当远程客户端的租约到期或者它已经向服务器发送了干净的消息时,远程客户端被认为完成了远程引用。在这种情况下,RMI 运行时将使用弱引用来引用远程对象,因此如果没有对远程对象的本地引用,它可能会被垃圾收集。

默认情况下,租期设置为 10 分钟。当您启动 RMI 服务器时,您可以使用java.rmi.dgc.leaseValue属性来设置租期。租期的值以毫秒为单位指定。以下命令启动服务器程序,租期设置为 5 分钟(300000 毫秒)。命令文本在一行中输入,两部分用空格分开,而不是像所示的那样用换行符分开;为了清楚起见,我使用了换行符来分隔命令的各个部分。

java -cp C:/utilserver.jar

-Djava.rmi.dgc.leaseValue=300000

-Djava.rmi.server.codebase=file:/C:/utilserver.jar

com.jdojo.rmi.RemoteServer

除了设置租用时间段之外,所有事情都由 RMI 运行时处理。RMI 运行时为您提供了关于远程对象垃圾收集的更多信息。它可以告诉你远程对象的引用计数何时降到零。如果一个远程对象持有一些资源,而您希望在没有远程客户端引用它时释放这些资源,那么得到这个通知是很重要的。很容易得到这个通知。你所要做的就是在你的远程对象实现类中实现java.rmi.server.Unreferenced接口。其声明如下:

public interface Unreferenced {

void unreferenced()

|

当远程对象的远程引用计数变为零时,调用unreferenced()方法。如果你想在你的例子中为RemoteUtility远程对象得到一个通知,你需要修改RemoteUtilityImpl类的声明,如清单 7-6 所示。

清单 7-6。RemoteUtilityImpl 类的修改版本,它实现未引用的接口

// RemoteUtilityImpl.java

package com.jdojo.rmi;

import java.rmi.server.Unreferenced;

import java.time.ZonedDateTime;

public class RemoteUtilityImpl implements RemoteUtility, Unreferenced {

public RemoteUtilityImpl() {

}

@Override

public String echo(String msg) {

return msg;

}

@Override

public ZonedDateTime getServerTime() {

return ZonedDateTime.now();

}

@Override

public int add(int n1, int n2) {

return n1 + n2;

}

@Override

public void unreferenced() {

System.out.println("RemoteUtility unreferenced at: " +

ZonedDateTime.now());

}

}

您可能会注意到,这一次,RemoteUtilityImpl类实现了Unreferenced接口,并提供了unreferenced()方法的实现,该方法在标准输出中打印一条消息,同时显示其引用计数变为零的时间。RMI 运行时将调用unreferenced()方法。为了测试是否调用了unreferenced()方法,您可以启动 RMI 注册表应用,然后启动 RMI 服务器应用。RMI 注册表将继续更新远程对象的租约。只要 RMI 注册中心还在运行,您就永远不会看到unreferenced()方法被调用。您需要关闭 RMI 注册表应用,并等待远程对象引用的租约到期,或者在您关闭它时被 RMI 注册表取消。RMI 注册表关闭后,您将在服务器程序的标准输出上看到由unreferenced()方法打印的消息。

RMI 注册表应该被用作启动远程客户端的引导工具。稍后,远程客户端可以接收远程对象的引用,作为对另一个远程对象的方法调用。如果远程客户机通过对远程对象的远程方法调用接收远程对象引用,则该远程对象的引用不需要在 RMI 注册表中注册。在这种情况下,在最后一个远程客户机完成远程引用后,当远程对象被绑定到 RMI 注册表时,服务器将对其进行垃圾收集,而不是将其保存在内存中。

摘要

Java 远程方法调用(RMI)允许在一个 JVM 中运行的程序调用在另一个 JVM 中运行的 Java 对象上的方法。RMI 提供了一个 API 来使用 Java 编程语言开发分布式应用。

一个 RMI 应用包含运行在三个 JVM 中的三个应用:rmiregistry应用、服务器应用和客户机应用。JDK 附带了rmiregistry应用。您负责开发服务器和客户端应用。服务器应用创建称为远程对象的 Java 对象,并将它们注册到rmiregistry中,供客户机以后查找名称。客户端应用使用逻辑名称在rmiregistry中查找远程对象,并取回远程对象的引用。客户端应用调用发送到服务器应用的远程对象引用上的方法,以便在远程对象上执行该方法。方法执行的结果从服务器应用发送回客户端应用。

RMI 应用必须遵循一些规则来开发远程通信所涉及的类和接口。您需要创建一个必须从Remote接口继承的接口(称为远程接口)。接口中的所有方法必须包含一个至少抛出RemoteExceptionthrows子句。远程对象的类必须实现远程接口。服务器应用创建实现远程接口的类的对象,导出该对象以给出真实远程对象的状态,并将其注册到rmiregistry。客户端应用只需要远程接口。

如果这三个应用中的任何一个需要本地没有的类,它们可以在运行时动态下载。对于动态下载类的 JVM 来说,java.rmi.server.useCodebaseOnly属性必须设置为false。默认情况下,它被设置为true,这将禁止动态下载 JVM 中的类。除了远程对象引用,JVM 还接收名为java.rmi.server.codebase的属性值,这是 JVM 可以下载(如果它自己的java.rmi.server.useCodebaseOnly属性设置允许的话)使用远程对象引用所需的类的 URL。

RMI 应用中有几个组件协同工作,这使得调试变得很困难。通过在 JVM 属性java.rmi.server.logCalls设置为true的情况下运行 RMI 服务器,您可以记录对它的所有调用。所有对服务器的调用都将被记录为标准错误。您还可以将 RMI 服务器调用记录到文件中。

RMI 为运行在 RMI 服务器中的远程对象提供自动垃圾收集。远程对象的垃圾收集基于引用计数和租约。当客户机应用获得远程对象的引用时,它也从服务器应用获得远程对象的租约。租约在一段时间内有效。只要客户端应用保留远程对象引用,它就会定期更新租约。服务器应用跟踪远程对象的引用计数和租约。当客户机应用完成远程引用时,它向服务器应用发送一条消息,服务器应用将远程对象的引用计数减 1。当远程对象的引用计数在服务器应用中减少到零时,远程对象被垃圾收集。

八、Java 本地接口

在本章中,您将学习

  • 什么是 Java 本地接口(JNI)
  • 如何编写使用本地方法的 Java 程序
  • 如何编写 C++程序来实现本机方法
  • 如何在 Windows 和 Linux 上为 Java 中使用的方法的本地实现创建一个共享库
  • Java 类型和 JNI 类型之间的数据类型映射
  • 如何在本机代码中使用 Java 字符串和数组
  • 如何创建 Java 对象,并在本机代码中访问这些对象的字段和方法
  • 本机代码中的异常处理
  • 如何在本机代码中嵌入 JVM
  • 如何在本机代码中使用 JNI 处理线程同步

什么是 Java 原生接口?

Java 本地接口(Java Native Interface,JNI)是一种编程接口,它有助于 Java 代码与用 C、C++、FORTRAN 等本地语言编写的代码之间的交互。JNI 支持直接从 Java 调用 C 和 C++函数。如果需要使用用任何其他语言(如 FORTRAN)编写的本机代码,可以使用 C/C++包装函数从 Java 中调用它。互动可以双向进行。Java 代码可以调用本机代码,反之亦然,如图 8-1 所示。

A978-1-4302-6662-4_8_Fig1_HTML.jpg

图 8-1。

The JNI architecture

Java 使用本机方法调用本机代码。Java 上下文中的本地方法是用 Java 声明并以本地语言(如 C/C++)实现的方法。本机方法实现被编译成一个共享库,由 JVM 加载。共享库在 Windows 上称为动态链接库(DLL ),在 UNIX 上称为共享对象(SO)。在 Java 代码中,以同样的方式调用 Java 方法和本机方法。Java 程序被编译成一种与平台无关的格式,称为字节码。本机代码被编译成依赖于平台的格式。因此,如果一个 Java 应用使用本机代码,它就不能再移植到其他平台,除非您在所有平台上开发相同的共享库。有时,您可能会访问本机代码中特定于平台的特性,这些特性是从 Java 应用中使用的;在这种情况下,您应该知道您的 Java 应用不能在其他平台上运行。

当 Java 通过其类库提供了丰富的特性时,为什么还会有人使用 JNI 呢?出于以下原因,可能有必要使用 JNI 来访问 Java 中的本机代码:

  • 如果一个 Java 应用需要实现一些特定于平台的特性,而这些特性使用 Java APIs 是不可能实现的。
  • 您可能已经有了用本地语言编写的遗留代码,并且希望在 Java 应用中重用它。
  • 您正在开发一个时间关键的 Java 应用,其中 Java 代码的执行速度没有预期的快。您可以将 Java 代码中时间关键的部分转移到本机代码中。

您应该考虑在 Java 应用中使用 JNI 作为最后的手段。您必须探索使用 Java APIs 实现所需特性的所有可能性。使用 JNI 还会改变开发应用所需的技能。从事 Java 应用开发的开发人员要么接受本地语言(C/C++)的培训,要么让懂本地语言的新开发人员加入团队。在 Java 应用中使用本机代码会降低应用的稳定性,并且容易出现安全风险,因为本机代码是在 JVM 之外运行的。

在本章中,我将使用 C++来实现本地方法。你可以用 C 语言来代替。本章中列出的所有 C++代码示例只需稍加修改就可以移植到 C 语言中。每当您需要对 C++代码进行更改以将其转换为 C 时,我都会详细说明 C++代码和 C 代码之间的区别。

系统需求

你需要一个能创建共享库的 C 或 C++编译器。您还需要在计算机上安装一个 JDK 来生成 C/C++头文件。本章中引用的本机代码是使用 NetBeans 8.0 和 Cygwin 作为 Windows 平台上的 C++编译器开发的。Java 8 用于编译和运行 Java 代码。然而,使用 Cygwin 作为 C++编译器并不是运行任何示例的必要条件。您可以使用任何其他 C/C++编译器在您的平台上创建共享库。请访问 https://netbeans.org/kb/trails/cnd.html 了解更多关于如何配置 NetBeans 使用 C++的详细信息。

JNI 入门

开发使用 JNI 的 Java 应用包括以下步骤:

  • 编写 Java 程序
  • 编译 Java 程序
  • 创建 C/C++头文件
  • 编写 C/C++程序
  • 创建共享库
  • 运行 Java 程序

后续部分将详细讨论每个步骤。

编写 Java 程序

使用 JNI 的 Java 程序与纯 Java 程序的区别仅在于两个方面:

  • 加载共享库
  • 声明本机方法

包含本机方法实现的共享库必须在 Java 调用本机方法之前加载。使用java.lang.System类的loadLibrary(String libraryNameWithoutExtension)静态方法加载共享库,如下所示:

// Load a shared library named beginningjava

System.loadLibrary("beginningjava");

您还可以使用java.lang.Runtime类的loadLibrary()方法加载共享库。在内部,System类的loadLibrary()方法调用了Runtime类的loadLibrary()方法。上述代码可以重写如下:

// Load the shared library

Runtime.getRuntime().loadLibrary("beginningjava");

注意,您需要向loadLibrary()方法传递一个共享库名,不带任何特定于平台的前缀和文件扩展名。例如,如果您的共享库文件名在 Windows 上是beginningjava.dll或者在 UNIX 上是beginningjava.so,那么您需要使用beginningjava作为共享库名。loadLibrary()方法将添加文件扩展名来查找共享库。这样,您不需要更改 Java 代码,如果您打算在不同的平台上运行相同的 Java 代码,Java 代码会加载共享库。

你也可以使用SystemRuntime类的load()方法加载一个共享库。load()方法接受带有文件扩展名的共享库的绝对路径。如果 Windows 平台上的beginningjava.dll文件在C:\myjni目录中,那么对load()方法的调用将如下所示:

// Load the shared library

System.load("C:\\myjni\\beginningjava.dll");

注意,使用load()方法迫使您使用共享库的绝对路径和文件扩展名,这使得您的 Java 代码不可移植到其他平台。在本章的例子中,你将使用System类的loadLibrary()方法来加载共享库。如果无法加载特定的库,load()loadLibrary()方法会抛出一个java.lang.UnsatisfiedLinkError

loadLibrary()方法如何在只知道库名的情况下找到文件系统中的共享库文件?有两种方法可以让 JVM 知道共享库的位置:

  • 将包含共享库的目录包含到 Windows 上的PATH环境变量和 UNIX 上的LD_LIBRARY_PATH环境变量中。
  • 使用java.library.path JVM 属性作为命令行选项,指定包含共享库的目录(或多个目录,用分号分隔)。以下命令假设beginningjava共享库位于C:\myjni\lib目录中:java -Djava.library.path=C:\myjni\lib your-class-name-to-run

Java 中使用的本机方法没有用 Java 编写的主体,因为它的实现存在于本机代码中。但是,在使用本机方法之前,需要用 Java 声明它。它是使用native关键字声明的。Java 代码中的native方法声明以分号结束。下面的代码片段声明了一个名为hello()native方法,它没有参数并返回void

public class Test {

// Declare a native method called hello()

public``native

}

用 Java 代码调用本机方法与调用任何其他 Java 方法是一样的。

Test test = new Test();

test.hello();

您可以声明一个本机方法具有publicprivateprotected或包级范围。一个native方法可以被声明为static或非静态的。一个 Java 类中可以有任意多的native方法。

不能将native方法声明为abstract。这意味着一个接口不能有native方法,因为在一个接口中声明的所有方法如果没有声明为staticdefault,那么它们都是abstract。一个abstract方法意味着该方法的实现缺失,它将在 Java 中实现,而native方法意味着该方法的实现缺失,它在本机代码中实现。将一个方法同时声明为nativeabstract将会混淆在哪里寻找该方法的实现——在 Java 代码中还是在本地代码中。这就是方法声明不能使用两个修饰符abstractnative组合的原因。

关键字native只能用于声明方法。您不能将字段声明为native。下面的代码片段声明了两个名为WillCompileWontCompile的类。类WillCompile包含了native关键字的有效用法,而类WontCompile展示了native关键字的无效用法。

public class WillCompile {

public native void m1();

private native void m2();

protected native void m3();

native void m4();

public static native void m5();

public native int m6(String str);

// A non-native method (Java-only method)

public int add(int a, int b) {

return a + b;

}

}

// Sample of Illegal use of native keyword in a Java class

public class WontCompile {

// A field cannot be native

private native String name;

// A method cannot be abstract as well as native

public abstract native String getName();

}

现在,您已经准备好编写 Java 代码来调用您的第一个本机方法。您将把您的native方法命名为hello()。它不接受任何参数,也不返回任何值。稍后您将在 C++中实现它,它将在标准输出中打印一条消息Hello JNI。清单 8-1 给出了HelloJNI类的完整代码。

清单 8-1。使用名为 hello()的本机方法的 HelloJNI 类

// HelloJNI.java

package com.jdojo.jni;

public class HelloJNI {

static {

// Load the shared library using its name only

System.loadLibrary("beginningjava");

}

// Declare the native method

public native void hello();

public static void main(String[] args) {

// Create a HelloJNI object

HelloJNI helloJNI = new HelloJNI();

// Call the native method

helloJNI.hello();

}

}

HelloJNI类执行三件事情:

  • 它在静态初始化器中加载一个beginningjava共享库(Windows 上的beginningjava.dll和类 UNIX 操作系统上的beginningjava.so)。注意,当你编写和编译HelloJNI类时,你不需要有beginningjava共享库。运行HelloJNI类时需要共享库。static { System.loadLibrary("beginningjava "); }
  • 它声明了一个名为hello()native方法,稍后将在 C++代码中实现。public native void hello();Java 编译器会用hello() native方法声明编译HelloJNI类,而不需要实现该方法的本机代码。在运行时调用该方法时,将需要该方法的实现。
  • 它在main()方法中创建了一个HelloJNI类的对象,并对该对象调用了hello()方法。HelloJNI helloJNI = new HelloJNI(); helloJNI.hello();

HelloJNI类的代码很简单。使用native方法并不需要在 Java 代码中做什么特别的事情。您还不能运行这个类,因为当您运行它的时候,它会寻找一个包含hello()方法本机代码的beginningjava共享库,您还没有编写这个库。

编译 Java 程序

编译使用本机方法的 Java 程序与编译任何其他 Java 程序是一样的。在编译HelloJNI类时,没有什么特殊的设置需要应用。您可以使用javac命令来编译它,就像这样:

javac HelloJNI.java

该命令将生成一个HelloJNI.class文件,该文件将包含HelloJNI类的类定义,其完全限定名为com.jdojo.jni.HelloJNI。确保您有可用的HelloJNI.class文件,因为它是执行下一步所必需的。

创建 C/C++头文件

在开始用 C/C++编写本机方法的代码之前,您需要生成一个头文件,其中包含您的 C/C++方法声明。当您编写您的hello() native方法的实现时,您将使用这个头文件。Java 和 C/C++中的hello()方法的方法签名差别很大。

您不需要担心如何在 C/C++中编写方法签名的细节,Java 代码将使用这些签名。JDK 提供了一个名为javah的工具,可以为您生成所有需要的头文件。javah工具位于JDK_HOME\bin文件夹中,其中JDK_HOME是 JDK 的安装文件夹。例如,如果你在 Windows 的C:\java8目录中安装了 JDK,那么javah工具就在C:\java8\bin中。该工具接受 Java 类的完全限定类名,并生成一个扩展名为.h的头文件,其中包含指定类中声明的所有native方法的方法签名。以下命令将为HelloJNI类中的所有native方法声明生成一个 C/C++头文件:

javah com.jdojo.jni.HelloJNI

javah工具将在CLASSPATH中寻找HelloJNI类。如果不在CLASSPATH中,可以使用–classpath或- cp命令行选项指定CLASSPATH,如下所示:

javah –cp C:\myclasses com.jdojo.jni.HelloJNI

该命令将在当前目录下生成一个名为com_jdojo_jni_HelloJNI.h的头文件。默认情况下,生成的文件名基于该类的完全限定名。类名中的点被替换为下划线,文件的扩展名为.h。您还可以通过使用一个–o选项来指定javah命令将生成的头文件名称。您可以通过执行javah –help命令来查看javah命令支持的其他选项。清单 8-2 显示了com_jdojo_jni_HelloJNI.h文件的内容。

清单 8-2。com_jdojo_jni_HelloJNI.h 文件的内容

/* DO NOT EDIT THIS FILE - it is machine generated */

#include <jni.h>

/* Header for class com_jdojo_jni_HelloJNI */

#ifndef _Included_com_jdojo_jni_HelloJNI

#define _Included_com_jdojo_jni_HelloJNI

#ifdef __cplusplus

extern "C" {

#endif

/*

* Class:    com_jdojo_jni_HelloJNI

* Method:    hello

* Signature: ()V

*/

JNIEXPORT void JNICALL Java_com_jdojo_jni_HelloJNI_hello

(JNIEnv *, jobject);

#ifdef __cplusplus

}

#endif

#endif

您不需要担心头文件中的细节。您只需要为您的native hello()方法生成的方法签名。Java 代码中的方法签名void hello()已经被翻译成 C/C++代码的如下方法签名:

JNIEXPORT void JNICALL Java_com_jdojo_jni_HelloJNI_hello (JNIEnv *, jobject);

JNIEXPORTJNICALL是两个宏。关键字void表示native方法不返回任何值。javah命令使用一个规则在头文件中生成native方法的名称。在这种情况下,方法名是Java_com_jdojo_jni_HelloJNI_hello。稍后我将讨论javah工具使用的命名规则的细节。虽然 Java 代码中的hello()方法的方法声明不接受任何参数,但是头文件中的native方法声明接受两个参数。将本地语言中的所有本地方法声明接受比 Java 代码中声明的参数数量多两个的参数作为一条规则。附加参数作为本地语言中的方法的第一和第二参数被添加。第一个参数是一个指向JNIEnv类型对象的指针,这是一个函数指针表,便于本地环境和 Java 环境之间的交互。第二个参数的类型为jobjectjclass。如果native方法在 Java 代码中被声明为非静态的,那么第二个参数的类型是jobject,它是对调用本机方法的 Java 对象的引用。它类似于 Java 中每个非静态方法内部都有的this引用。由于 Java 中的本机hello()方法已经被声明为非静态的,所以第二个参数类型是类型jobject。如果本地方法在 Java 中被声明为 static,第二个参数将是类型jclass,它将是对 JVM 中调用native方法的类对象的引用。

在这一步的最后,您应该有一个名为com_jdojo_jni_HelloJNI.h的头文件,其内容如清单 8-2 所示。

编写 C/C++程序

清单 8-3 显示了您需要为hello()本地方法编写的 C/C++代码。下一节将介绍使用 NetBeans IDE 设置项目和编写 C++代码的分步过程。C++的源代码文件被命名为hellojni.cpp。在这种情况下,如果您选择使用 C 语言,代码将是相同的。注意,hello是 Java 代码中本地方法的名称,而在 C/C++中它被命名为Java_com_jdojo_jni_HelloJNI_hello

清单 8-3。hello()本机方法的 C/C++实现

// hellojni.cpp

#include <stdio.h>

#include <jni.h>

#include "com_jdojo_jni_HelloJNI.h"

JNIEXPORT void JNICALL Java_com_jdojo_jni_HelloJNI_hello(JNIEnv *env, jobject obj) {

printf("Hello JNI\n");

return;

}

这是这个程序做的事情。它使用三个 C/C++编译器预处理器include指令来包含三个头文件:stdio.hjni.hcom_jdojo_jni_HelloJNI.h。它包括使用标准输入/输出功能的stdio.h,使用 JNI 相关功能的jni.h,以及包含与您的hello() native方法相关的功能的com_jdojo_jni_HelloJNI.h

安装 JDK 时,jni.h文件被复制到JDK_HOME\include目录。例如,如果你在C:\java8安装了 JDK,那么jni.h文件将会在C:\java8\include目录下。在JDK_HOME\include目录下有一个子目录。子目录名称取决于平台。它在 Windows 上被命名为win32,在 Linux 上被命名为linux,等等..在编译hellojni.cpp文件时,需要使用以下两个目录作为包含路径选项:

  • C:\java8\include
  • C:\java8\include\win32

这些内含路径是给窗户用的。请根据您的平台进行更改。

您可以将com_jdojo_jni_HelloJNI.h文件放在机器上的任何目录中。在编译hellojni.cpp文件时,您需要在 include-path 选项中包含包含该文件的目录。

函数签名是从com_jdojo_jni_HelloJNI.h头文件中复制的。您将这两个参数命名为envobj。在代码中为这些参数使用什么名称并不重要。

JNIEXPORT void JNICALL Java_com_jdojo_jni_HelloJNI_hello

(JNIEnv *env, jobject obj)

您已经通过添加两个语句为native方法提供了实现。第一条语句使用printf()函数在标准输出上打印消息Hello JNI,第二条语句从函数返回,如下所示:

printf("Hello JNI\n");

return;

创建共享库

在本节中,您将把hellojni.cpp文件编译成一个名为beginningjava的共享库。共享库在 Windows 上是一个名为beginningjava.dll的文件,在类 UNIX 操作系统上是一个名为beginningjava.so的文件。您的操作系统可能对共享库使用不同的文件扩展名。有许多编译器可用于从 C/C++代码创建共享库。本节说明如何在上创建共享库

  • Windows 使用名为g++的 GNU C++编译器,被称为 MinGW 编译器(Minimalist GNU for Windows)。
  • 在 Fedora Linux 上使用名为g++.的 GNU C++编译器

要创建共享库,您可以在命令提示符或 IDE(如 Windows 上的 Microsoft Visual Studio 或 Windows 和 Linux 上的 NetBeans)上使用 C/C++编译器。请注意,NetBeans 没有附带 C/C++编译器。要使用 NetBeans IDE 创建共享库,您需要下载一个编译器,如 MinGW 或 Cygwin。

在 Windows 上创建共享库

以下部分描述了如何在 Windows 上安装名为g++的 MinGW C++编译器,以及如何通过命令提示符使用它来创建名为beginningjava.dll的共享库。

安装 MinGW C/C++编译器

按照以下步骤安装 MinGW 编译器:

  • http://sourceforge.net/projects/mingw 下载 MinGW 编译器并安装在你的机器上。
  • 假设您已经在C:\MinGW目录中安装了 MinGW。您需要安装 MinGW 的以下软件包:mingw-developer-toolkit、migw32-base、mingw32-gcc-g++和 msys-base。如果您已将 MinGW 安装在另一个目录中,请在本节的以下讨论中将此目录路径替换为您的安装目录路径。
  • C:\MinGW\bin目录添加到系统PATH环境变量中。如果不设置系统 PATH 环境变量,则可以通过在命令提示符下设置 PATH 环境变量来使用 MinGW。
  • 验证C:\MinGw\bin\g++.exe文件存在于您的机器上。g++C++编译器,gcc是 MinGW 使用的 C 编译器。你将使用本章中的 C++代码和 g++编译器来编译 C++代码。
使用 g++命令

您需要使用g++命令来创建一个共享库。创建共享库需要两种类型的文件:

  • 包含 C++代码的 C++源文件。在这种情况下,您将其命名为hellojin.cpp,如清单 8-3 所示。
  • 清单 8-2 所示的com_jdojo_jni_HelloJNI.h头文件。
  • 与 JNI 相关的头文件位于JDK_HOME\include和 J DK_HOME\include\win32目录中,其中JDK_HOME是您安装 JDK 的目录。

您可以向g++编译器传递几个选项。以下命令显示了创建共享库所需的最少选项:

g++ -Wl,--kill-at -shared –I<include-dir> -o <output-file> <source-files>

这里,

  • -Wl,<option>用于将选项传递给链接器。<option>是一个逗号分隔的链接器选项列表。在这个命令中,您将--kill-at选项传递给链接器,以便在导出符号之前从符号中去除标准调用后缀(@nn)。如果您没有指定这个选项,当您运行使用共享库的 Java 程序时,您将得到一个java.lang.UnsatisfiedLinkError
  • –shared选项表示您想要创建一个共享库。
  • –I<include-dir>选项用于传递包含头文件的目录(。h 文件)。您可以对每个目录重复此选项一次。
  • –o <output-file>选项指定输出文件名。在您的例子中,您将使用名为beginningjava.dll的输出文件。
  • <source-files>是用空格分隔的 C++源文件列表。

为了简化生成共享库的命令语法,我假设您的计算机上存在以下目录和文件:

  • C:\dll\hellojni.cpp
  • C:\dll\com_jdojo_jni_HelloJNI.h
  • C:\java8\include
  • C:\java8\include\win32

以下命令将在C:\dll目录中生成beginingjava.dll文件。为了清楚起见,命令的每个部分都显示在单独的行中;您将在一行中输入整个命令。

C:\> g++ -Wl,--kill-at -shared

-IC:/java8/include -IC:/java8/include/win32 -IC:/dll

-o C:/dll/beginningjava.dll

C:/dll/hellojni.cpp

请注意文件路径中正斜杠的使用。对于 Windows 上的 g++命令,您可以使用正斜杠或反斜杠作为路径分隔符。请更改命令中的路径,以匹配您计算机上这些文件和目录的路径。

如果您没有将 PATH 环境变量设置为C:\MinGW\bin目录,当您运行g++命令时,您可能会得到以下错误:

'g++' is not recognized as an internal or external command,operable program or batch file

Note

在 Windows 上,如果您想将 NetBeans IDE 与 MinGW 一起使用,请参考以下链接获取设置说明: https://netbeans.org/community/releases/80/cpp-setup-instructions.html .

在 Linux 上创建共享库

以下部分描述了如何在 Fedora Linux 上安装名为g++的 GNU C++编译器,以及如何在终端上使用它来创建名为beginningjava.so的共享库。

安装 MinGW C/C++编译器

在 Linux 上安装g++编译器很容易。在 Linux 终端上运行以下命令将安装g++编译器:

$ yum install gcc-c++

运行该命令时,您可能会收到以下消息:

$ yum install gcc-c++

You need to be root to perform this command.

$

如果您收到此消息,您需要以 root 用户身份登录来安装编译器。使用su –命令以 root 身份登录,在出现提示时输入 root 密码,然后运行yum命令。

$ su –

Password: Enter Your Password Here

# yum install gcc-c++

在安装过程中,yum命令会多次提示您确认编译器安装文件的下载。当你得到这些提示时,你需要回答是。如果您的机器上已经安装了 g++编译器,那么yum命令将会打印一条相应的消息。

这就是在 Linux 上安装 g+=编译器的全部工作。

使用 g++命令

您需要使用g++命令来创建一个共享库。创建共享库需要两种类型的文件:

  • 包含 C++代码的 C++源文件。在这种情况下,您将其命名为hellojni.cpp,如清单 8-3 所示。
  • 清单 8-2 所示的com_jdojo_jni_HelloJNI.h头文件。
  • 与 JNI 相关的头文件位于JDK_HOME/include和 J DK_HOME/include/win32目录中,其中JDK_HOME是您安装 JDK 的目录。

您可以向g++编译器传递几个选项。以下命令显示了创建共享库所需的最少选项:

g++ -shared –I<include-dir> -o <output-file> <source-files>

在这里,

  • –shared选项表示您想要创建一个共享库。
  • –I<include-dir>选项用于传递包含头文件的目录(。h文件)。您可以对每个目录重复此选项一次。
  • –o <output-file>选项指定输出文件名。在您的例子中,您将使用输出文件名beginningjava.so
  • <source-files>是用空格分隔的 C++源文件列表。

为了简化生成共享库的命令语法,我假设您的计算机上存在以下目录和文件:

  • /home/ksharan/slib/hellojni.cpp
  • /home/ksharan/slib/com_jdojo_jni_HelloJNI.h
  • /home/ksharan/java8/include
  • /home/ksharan/java8/include/linux

以下命令将在/home/ksharan/slib目录中生成beginingjava.so文件。为了清楚起见,命令的每个部分都显示在单独的行中;您将在一行中输入整个命令。

$ g++ -shared

-I/home/ksharan/java8/include -I/home/ksharan/java8/include/linux -I/home/ksharan/slib

-o /home/ksharan/slib/beginningjava.so

/home/ksharan/slib/hellojni.cpp

请更改命令中的路径,以匹配您计算机上这些文件和目录的路径。

Note

在 Linux 上,如果您想将 NetBeans IDE 与 g++编译器一起使用,请参考下面的设置说明链接: https://netbeans.org/community/releases/80/cpp-setup-instructions.html .

运行 Java 程序

在继续运行 Java 类之前,请确保您能够创建共享库(Windows 上的beginningjava.dll文件和类 UNIX 操作系统上的beginningjava.so文件)。如果您不能创建共享库,您可以使用本书源代码提供的共享库。共享库位于一个名为cplusplus的目录中。

现在你已经准备好运行你的HelloJNI Java 类了,如清单 8-1 所示。假设您已经将beginningjava共享库文件放在了C:\myjni\lib目录中。使用以下命令运行HelloJNI类:

C:\> java -Djava.library.path=C:\myjni\lib com.jdojo.jni.HelloJNI

-Djava.library.path=C:\myjni\lib选项指示 JVM 在C:\myjni\lib目录中寻找共享库。如果上述命令成功运行,它将在标准输出中打印一条消息Hello JNI。或者,您也可以将包含共享库的目录添加到 PATH 环境变量中,Java 运行时会找到它。如果共享库在当前目录中,Windows 也将找到共享库,而不设置java.library.path选项。下列命令显示如何为当前会话设置 PATH 环境变量(在 Windows 上)并运行类:

C:\> SET PATH=C:\myjni\lib;%PATH%

C:\> java com.jdojo.jni.HelloJNI

本机函数命名规则

该命令使用基于名称管理的命名规则,在 C/C++头文件中生成本机方法名称。Java 运行时使用相同的规则将 Java 本地方法名解析为共享库中的本地函数名。使用名称管理规则,以便为本机函数生成的名称是没有名称冲突的有效 C/C++名称。您可以将名称篡改视为简单地用组成有效函数名的字符替换无效字符。本机函数名是基于以下部分生成的,这些部分用下划线连接在一起:

  • 方法名以单词Java开头。
  • 包含本机方法声明的 Java 类的包的完全限定名。下划线用作包/子包分隔符。
  • Java 中的本机方法名。
  • 对于重载的本机方法,两个下划线后跟被破坏的方法的签名

Java 运行时对本地函数使用两个名称——一个短名称和一个长名称。短名称不使用两个下划线后跟被破坏的方法的签名。Java 运行时首先在共享库中搜索简称。如果没有找到使用短名称的函数,它将使用长名称进行搜索。被破坏的名称使用了表 8-1 中所示的转换表。

表 8-1。

The Escape Sequence Used in the Name-Mangling Process

| 原始字符 | 替代字符 | | --- | --- | | 任何非 ASCII Unicode 字符 | `_0xxxx`注意 _ `oxxxx`中使用的字母都是小写的,比如`_0abcd` | | `_`(下划线) | `_1` | | `;`(一个分号) | `_2` | | ``(一个开始方括号) | `_3` |

诸如分号和以方括号开头的字符可能作为 Java 内部使用的方法参数签名的一部分出现。表 [8-2 显示了。Java 代码和 Java 内部使用的方法签名。

表 8-2。

Examples of Java Method’s Declaration and Internally Used Method Signatures

| 方法声明 | 内部使用的方法签名 | | --- | --- | | `public static void javaPrintMsg(java.lang.String)` | `(Ljava/lang/String;)V` | | `public void javaCallBack()` | `()V` | | `public static void main(java.lang.String[])` | `([Ljava/lang/String;)V` |

如果您声明了一个类型为java.lang.String的参数,它在内部被用作Ljava/lang/String;。要了解 Java 内部使用的方法的签名,您需要使用带有–s选项的javap命令。下面的命令将打印出com.jdojo.jni.HelloJNI类中所有方法的方法签名。您可以使用–private选项打印所有方法的签名,包括private方法。

javap -s -private com.jdojo.jni.HelloJNI

如果您需要在本地代码的 JNI 函数中使用 Java 方法的方法签名,您应该运行javap命令来获取签名,而不是手动输入。您可以学习用于组成 Java 内部使用的方法签名的规则。然而,使用javap命令可以很容易地获得这些信息。让我们考虑一下清单 8-4 所示的Test类中一些本地方法的声明。

清单 8-4。带有一些本机方法声明的测试类

package com.jdojo.jni;

public class Test {

private native void sayHello();

private native void printMsg(String msg);

private native int[] increment(int[] num, int incrementValue);

private native double myMethod(int i, String s[], String ss);

private native double myMethod(double i, String s[], String ss);

private native double myMethod(short i, String s[], String ss);

}

如果编译Test类并运行命令

javah com.jdojo.jni.Test

您会得到一个com_jdojo_jni_Test.h头文件,其内容如清单 8-5 所示。

清单 8-5。为 com.jdojo.jni.Test 类生成的头文件

/* DO NOT EDIT THIS FILE - it is machine generated */

#include <jni.h>

/* Header for class com_jdojo_jni_Test */

#ifndef _Included_com_jdojo_jni_Test

#define _Included_com_jdojo_jni_Test

#ifdef __cplusplus

extern "C" {

#endif

/*

* Class:    com_jdojo_jni_Test

* Method:    sayHello

* Signature: ()V

*/

JNIEXPORT void JNICALL Java_com_jdojo_jni_Test_sayHello

(JNIEnv *, jobject);

/*

* Class:    com_jdojo_jni_Test

* Method:    printMsg

* Signature: (Ljava/lang/String;)V

*/

JNIEXPORT void JNICALL Java_com_jdojo_jni_Test_printMsg

(JNIEnv *, jobject, jstring);

/*

* Class:    com_jdojo_jni_Test

* Method:    increment

* Signature: (II)[I

*/

JNIEXPORT jintArray JNICALL Java_com_jdojo_jni_Test_increment

(JNIEnv *, jobject, jintArray, jint);

/*

* Class:    com_jdojo_jni_Test

* Method:    myMethod

* Signature: (I[Ljava/lang/String;Ljava/lang/String;)D

*/

JNIEXPORT jdouble JNICALL Java_com_jdojo_jni_Test_myMethod__I_3Ljava_lang_String_2Ljava_lang_String_2

(JNIEnv *, jobject, jint, jobjectArray, jstring);

/*

* Class:    com_jdojo_jni_Test

* Method:    myMethod

* Signature: (D[Ljava/lang/String;Ljava/lang/String;)D

*/

JNIEXPORT jdouble JNICALL Java_com_jdojo_jni_Test_myMethod__D_3Ljava_lang_String_2Ljava_lang_String_2

(JNIEnv *, jobject, jdouble, jobjectArray, jstring);

/*

* Class:    com_jdojo_jni_Test

* Method:    myMethod

* Signature: (S[Ljava/lang/String;Ljava/lang/String;)D

*/

JNIEXPORT jdouble JNICALL Java_com_jdojo_jni_Test_myMethod__S_3Ljava_lang_String_2Ljava_lang_String_2

(JNIEnv *, jobject, jshort, jobjectArray, jstring);

#ifdef __cplusplus

}

#endif

#endif

您可以查看为不同的native方法声明生成的本地函数名。不要担心用于函数参数的数据类型。我将在下一节介绍 Java 和本地语言之间的数据类型映射。

数据类型映射

JNI 定义了 Java 中使用的数据类型和本地函数之间的映射。表 [8-3 列出了 Java 和本地 C/C++语言之间的原始数据类型映射。请注意,在 Java 中,您只需在原始数据类型的名称前添加一个j,就可以在 C/C++中获得等效的数据类型名称。JNI 还定义了一个名为jsize的数据类型,用来存储长度,比如数组或者字符串的长度。

表 8-3。

The Mapping Between Java Primitive Data Types and JNI Native Data Types

| Java 原始类型 | 本地原始类型 | 描述 | | --- | --- | --- | | `boolean` | `jboolean` | 无符号 8 位 | | `byte` | `jbyte` | 带符号的 8 位 | | `char` | `jchar` | 无符号 16 位 | | `double` | `jdouble` | 64 位 | | `float` | `jfloat` | 32 位 | | `int` | `jint` | 有符号 32 位 | | `long` | `jlong` | 有符号 64 位 | | `short` | `jshort` | 有符号 16 位 | | `void` | `void` | 不适用的 |

JNI 定义了 Java 引用类型的引用类型等价物。不可能在 JNI 中为所有可以在 Java 中创建的引用类型定义单独的类型。所有 Java 引用类型都可以映射到名为jobject的 JNI 引用类型。你有一些专门的 JNI 引用类型,代表 Java 中常用的引用类型,比如 JNI 的jstring代表 Java 中的java.lang.String。表 8-4 列出了 Java 和 JNI 之间的引用类型映射。

表 8-4。

The Reference Type Mapping Between Java and JNI

| Java 引用类型 | JNI 类型 | | --- | --- | | 任何 Java 对象 | `jobject` | | `java.lang.String` | `jstring` | | `java.lang.Class` | `jclass` | | `java.lang.Throwable` | `jthrowable` |

JNI 定义了单独的引用类型来表示 Java 数组。类型jarray是表示任何 Java 数组类型的通用数组类型。在 Java 中,每种类型的数组都有专门的数组类型。在 JNI,数组类型被命名为jxxxArray,其中 x xx可以是objectbooleanbytechardoublefloatintlongshort。比如 C/C++中的jintArray在 Java 中代表一个int数组。注意,Java 中的所有引用类型数组在 C/C++中都用jobjectArray类型表示。

当使用 JNI 处理 C/C++代码时,您会遇到另一种称为jvalue的类型。它是在 C/C++中定义的联合类型,如下所示:

typedef union jvalue {

jboolean z;

jbyte    b;

jchar    c;

jshort  s;

jint    i;

jlong    j;

jfloat  f;

jdouble  d;

jobject  l;

} jvalue

请注意,jvalue联合类型在 Java 中没有对等的类型。通常,jvalue类型被定义为内置函数中的参数类型,这些内置函数是 JNI API 的一部分。

在 C/C++中使用 JNI 函数

JNI 函数允许您访问本机代码中的 JVM 数据结构和对象。有时,它们允许您将数据转换成在 Java 和本地环境之间传递的特定格式。所有本地函数都有自己的第一个参数,这个参数总是指向JNIEnv的指针,而这个指针又指向一个包含所有 JNI 函数指针的表。

总有两个版本的函数可以在类型JNIEnv:上调用,一个用于 C,一个用于 C++。该函数的 C 版本接受一个指向JNIEnv的指针作为第一个参数,而 C++不会有那个第一个参数。相同方法的两个版本,C 和 C++,被不同地调用。下面的代码片段展示了在 C 和 C++中调用 JNI 函数的区别,假设FuncXxx是函数名,env是指向JNIEnv类型的指针:

// C style

(*env)->FuncXxx(env, list-of-arguments...);

// C++ style

env->FuncXxx(list-of-arguments...);

本章使用 C++方式调用 JNI 函数。通过使用上面的代码片段作为参考,您可以轻松地将代码转换为 C 风格。

作为一个具体的例子,下面是GetStringUTFChars() JNI 函数的函数签名,它允许你将 Java 字符串转换成 UTF-8 字符串格式:

// C Version of the GetStringUTFChars() JNI function

const char * GetStringUTFChars(JNIEnv *env, jstring string, jboolean *isCopy);

// C++ Version of the GetStringUTFChars() JNI function

const char * GetStringUTFChars(jstring string, jboolean *isCopy);

如果您想在 C 或 C++中调用这个函数,您的代码将如下所示:

// C Code

const char *utfMsg = (*env)->GetStringUTFChars(env, msg, iscopy);

// C++ Code

const char *utfMsg = env->GetStringUTFChars(msg, iscopy);

使用字符串

Java 和 C/C++中字符串的表示方式不同。在 Java 中,字符串被表示为 16 位 Unicode 字符序列,而在 C/C++中,字符串是指向以空字符结尾的字符序列的指针。本机代码中的jstring引用类型表示java.lang.String类的一个实例,它是一个 16 位 Unicode 字符序列。JNI 具有将 Java 字符串转换成本地字符串的功能,反之亦然。一组字符串函数处理 UTF-8 字符串,另一组处理 Unicode 字符串。当 Java 将一个字符串传递给本机代码时,在使用它之前,必须将本机代码中的字符串转换为本机格式(UTF-8 或 Unicode)。同样的逻辑也适用于将字符串从本机代码返回到 Java。您必须将原生字符串转换为jstring的实例,然后才能将其返回到 Java。

让我们从一个例子开始,在这个例子中,你将把一个字符串从 Java 代码传递给 C/C++代码。C/C++代码会将 Java 字符串转换成原生的 UTF 8 格式,并使用printf()函数将其打印在标准输出上。Java 中的本地方法声明如下:

  • public native void printMsg(String msg);
  • public native String getMsg();

printMsg()方法接受一个 Java 字符串,它的本地函数将把它打印在标准输出上。getMsg()方法将一个本地字符串返回给 Java,Java 将在标准输出中打印它。清单 8-6 包含了声明这两个本地方法的 Java 代码。注意,静态 initialize 加载了您在上一节中创建的名为beginningjava的共享库。这一次,您将需要在共享库中包含新的本机方法的 C++代码。

清单 8-6。将字符串从 Java 传递到本机函数,反之亦然

// JNIStringTest.java

package com.jdojo.jni;

public class JNIStringTest {

static {

System.loadLibrary("beginningjava");

}

public native void printMsg(String msg);

public native String getMsg();

public static void main(String[] args) {

JNIStringTest stringTest = new JNIStringTest();

String javaMsg = "Hello from Java to JNI";

stringTest.printMsg(javaMsg);

String nativeMsg = stringTest.getMsg();

System.out.println(nativeMsg);

}

}

以下是 C/C++中printMsg()getMsg()的本机函数声明:

  • JNIEXPORT void JNICALL Java_com_jdojo_jni_JNIStringTest_printMsg(JNIEnv *env, jobject obj, jstring msg);
  • JNIEXPORT jstring JNICALL Java_com_jdojo_jni_JNIStringTest_getMsg(JNIEnv *env, jobject obj);

注意,本地函数中的前两个参数是类型JNIEnvjobjectprintMsg()函数包含类型为jstring的第三个参数,其返回类型为voidgetMsg()函数只包含两个标准参数,它返回一个jstring

要将jstring转换成 UTF-8 原生字符串,需要使用GetStringUTFChars() JNI 函数,可以通过使用JNIEnv引用来访问该函数。GetStringUTFChars() JNI 函数有两个版本:一个用于 C,一个用于 C++。

GetStringUTFChars()函数将 Java 字符串(在 C/C++代码的jstring中)转换成 UTF 8 格式,并返回一个指向转换后的 UTF 8 字符串的指针。如果失败,它返回NULLGetStringUTFChars()函数可能需要在内存中复制原始的 Java 字符串对象,以便将其转换为 UTF-8 格式。函数的isCopy参数是一个指向boolean变量的指针,可以用来检查这个函数是否必须复制原始的 Java 字符串。如果isCopy不是NULL,如果复制了 Java 字符串,则它被设置为JNI_TRUE。否则设置为JNI_FALSE。一旦处理完这个函数的返回值,就必须调用ReleaseStringUTFChars()方法来释放内存。此方法的 C 和 C++样式签名如下:

// C Style

void ReleaseStringUTFChars(JNIEnv *env, jstring string, const char *utf);

// C++ Style

void ReleaseStringUTFChars(jstring string, const char *utf);

清单 8-7 包含了 C++中本地方法printMsg()getMsg()的实现。代码在本书源代码的jnistringtest.cpp文件中。getMsg()的代码很简单。它使用NewStringUTF() JNI 函数从本地字符串中获取一个 Java 字符串。

清单 8-7。jnistringtest.cpp 文件的内容

// jnistringtest.cpp

#include <stdio.h>

#include <jni.h>

#include "com_jdojo_jni_JNIStringTest.h"

JNIEXPORT void JNICALL Java_com_jdojo_jni_JNIStringTest_printMsg

(JNIEnv *env, jobject obj, jstring msg) {

const char *utfMsg;

jboolean *iscopy = NULL;

// Get the UTF string

utfMsg = env->GetStringUTFChars(msg, iscopy);

if (utfMsg == NULL) {

printf("Could not convert Java string to UTF-8 string.\n");

return;

}

// Print the message on the standard output

printf("%s\n", utfMsg);

// Release the memory

env->ReleaseStringUTFChars(msg, utfMsg);

}

JNIEXPORT jstring JNICALL Java_com_jdojo_jni_JNIStringTest_getMsg

(JNIEnv *env, jobject obj) {

const char *utfMsg = "Hello from JNI to Java";

jstring javaString = env->NewStringUTF(utfMsg);

return javaString;

}

运行JNIStringTest类的javah命令来创建com_jdojo_jni_JNIStringTest.h C++头文件。

javah com.jdojo.jni.JNIStringTest

要在同一个名为beginningjava的共享库中包含hellojni.cppjnistringtest.cpp文件的 C++内容,需要将这两个文件作为源文件传递给 g+=命令。下面是 Windows 上的命令,假设您已经将两个源文件的头文件放在了C:\dll目录中。

C:\> g++ -Wl,--kill-at -shared

-IC:/java8/include -IC:/java8/include/win32 -IC:/dll

-o C:/dll/beginningjava.dll

C:/dll/hellojni.cpp C:/dll/jnistringtest.cpp

请参考“在 Linux 上创建共享库”一节,在 Linux 上用两个 C++源文件创建共享库。

现在你已经准备好运行清单 8-6 中列出的JNIStringTest类了。它将生成以下输出:

Hello from JNI to Java

Hello from Java to JNI

您可以使用GetStringUTFLength(jstring string) JNI 函数来获得以字节为单位的jstring的长度,以 UTF-8 格式表示它。JNI 也有让您使用 Unicode 原生字符串的功能。Unicode 字符串函数被命名为不带单词“UTF”的 UTF 字符串函数。例如,要用 Unicode 字符获得一个jstring的长度,您需要一个GetStringLength()函数,而不是GetStringUTFLength()函数。为了从 Unicode 字符构造一个新的 Java String (a jstring),我们有一个NewString() JNI 函数,而不是NewStringUTF() JNI 函数,后者从 UTF 8 本地字符串创建一个 Java 字符串。有时您可能需要将jstring中的 Java String转换成本地编码,反之亦然。您可以使用java.lang.String类,它有一组丰富的构造函数和方法,允许您将一种编码的字符串转换为字节数组,反之亦然。我将在后面的章节中介绍如何在本机代码中访问 Java 类。

使用数组

JNI 允许您将一组原语或引用类型从 Java 传递到本机代码,反之亦然。您不能在本机代码中直接访问或使用 Java 数组。您将需要使用 JNI 函数在本机代码中处理 Java 数组。JNI 为原始数组和引用数组提供了一组不同的函数。有些函数是两种类型共有的。本节中使用的所有与数组相关的方法都使用 C++版本。给它们加上JNIEnv *env作为第一个参数,得到对应的 C 版本。

GetArrayLength()方法返回基元或引用类型的数组长度。它的宣言是

jsize GetArrayLength(jarray array)

您可以使用New<Xxx>Array()方法创建一个原始类型的数组,其中<Xxx>BooleanByteDoubleFloatIntLongShort的原始类型之一。您需要将基本类型数组的长度作为参数传递给该方法。如果无法创建数组,它将返回NULL。以下代码片段创建了一个长度均为 10 的int数组和一个double数组:

jintArray iArray = env->NewIntArray(10);

jdoubleArray dArray = env->NewDoubleArray(10);

您可以使用Get<Xxx>ArrayElements()来获取原始数组的内容,其中<Xxx>BooleanByteCharDoubleFloatIntLongShort的原始类型之一。兹声明如下:

<RRR> *Get<Xxx>ArrayElements(<AAA> array, jboolean *isCopy)

Here, <RRR>为 JNI 原生数据类型,如jintjdouble,<AAA>为 JNI 数组类型,如jintArrayjdoubleArray等。isCopy参数指示返回的数组元素是否是原始数组的副本。如果isCopy不是NULL,如果复制了原始数组,则设置为JNI_TRUE。如果没有复制原始数组,则设置为JNI_FALSE。您还可以对本机代码中的数组元素进行更改,这些更改将反映到原始数组中。您需要释放元素,这些元素是在您使用完之后通过这个方法获得的。您需要使用Release<Xxx>ArrayElements()方法来释放数组元素,声明如下:

void Release<Xxx>ArrayElements(<AAA> array, <RRR> *elems, jint mode)

Release<Xxx>ArrayElements()函数中的最后一个参数mode指示如何释放在本机代码中用于数组元素的缓冲区。其值可以是 0、JNI_COMMITJNI_ABORT。0 表示复制回内容并释放elems缓冲区;JNI_COMMIT表示复制回内容,但不释放elems缓冲区;而JNI_ABORT意味着释放缓冲区而不复制回可能的更改。下面的代码片段用本机代码访问一个int Java 数组,并在标准输出中打印它的所有元素值:

jintArray num = get a Java array...;

const jsize count = env->GetArrayLength(num);

jboolean isCopy;

jint *intNum = env->GetIntArrayElements(num, &isCopy);

for (jsize i = 0; i < count; i++) {

printf("%i\n", intNum[i]);

}

// Release the intNum buffer without copying back any changes made to the array elements

env->ReleaseIntArrayElements(num, intNum, JNI_ABORT);

本机代码中的引用类型 Java 数组被区别对待。你可以使用NewObjectArray()函数创建一个新的引用类型数组。该方法声明如下:

jobjectArray NewObjectArray(jsize length, jclass elementClass, jobject initialElement)

请注意,您需要使用数组元素的类类型对象来创建一个引用数组。最后一个参数是初始化数组所有元素的初始元素。

与基元类型数组不同,您不需要获取引用类型数组的数组元素来访问它们。您可以使用GetObjectArrayElement()功能一次访问一个元素。您可以使用SetObjectArrayElement()函数来设置引用类型的数组元素的值。这些方法声明如下:

  • jobject GetObjectArrayElement(jobjectArray array, jsize index)
  • void SetObjectArrayElement(jobjectArray array, jsize index, jobject value)

让我们看看在 JNI 应用中使用数组的例子。清单 8-8 包含使用数组声明三个本地方法的 Java 代码。

清单 8-8。在本机代码中访问和操作数组的示例

// JNIArrayTest.java

package com.jdojo.jni;

import java.util.Arrays;

public class JNIArrayTest {

static {

System.loadLibrary("beginningjava");

}

// Three native method declarations

public native int sum(int[] num);

public native String concat(String[] str);

public native int[] increment(int[] num, int incrementBy);

public static void main(String[] args) {

JNIArrayTest test = new JNIArrayTest();

int[] num = {1, 2, 3, 4, 5};

String[] str = {"One", "Two", "Three", "Four", "Five" } ;

System.out.println("Original Number Array: " + Arrays.toString(num));

System.out.println("Original String Array: " + Arrays.toString(str));

int sum = 0;

sum = test.sum(num);

System.out.println("Sum: " + sum);

String concatenatedStr = test.concat(str);

System.out.println("Concatenated String: " + concatenatedStr);

int increment = 5;

int[] incrementedNum = test.increment(num, increment);

System.out.println("Increment By: " + increment);

System.out.println("Incremented Number Arrays: " +

Arrays.toString(incrementedNum));

}

}

sum()本地方法接受一个int数组,并返回其所有元素的总和作为int。当您调用sum()方法时,注意不要在int数组中传递大的数字。否则,结果可能会溢出。concat()本地方法接受一个String数组。它连接数组中的所有元素并返回一个String对象。increment()本地方法接受一个int数组和一个int数字。它返回一个新的int数组,该数组包含原数组中按指定数字递增的所有元素。main()方法包含测试三个本地方法的代码。

运行JNIArrayTest类的javah命令来创建com_jdojo_jni_JNIArrayTest.h C++头文件。

javah com.jdojo.jni.JNIArrayTest

清单 8-9 包含了jniarraytest.cpp文件中三个本地方法的 C++实现。concat()方法的实现假设String数组中所有元素的长度不会超过 500 字节。请参考上一节关于如何在共享库中包含 C+=源文件的内容。

清单 8-9。jniarraytest.cpp 文件的内容,带有 sum()、concat()和 increment()本机方法的 C++实现

// jniarraytest.cpp

#include <jni.h>

#include <cstring>

#include "com_jdojo_jni_JNIArrayTest.h"

JNIEXPORT jint JNICALL Java_com_jdojo_jni_JNIArrayTest_sum

(JNIEnv *env, jobject obj, jintArray num) {

jint sum = 0;

const jsize count = env->GetArrayLength(num);

jboolean isCopy;

jint *intNum = env->GetIntArrayElements(num, &isCopy);

for (jsize i = 0; i < count; i++) {

sum += intNum[i];

}

// Release the intNum buffer without copying back any changes made to the array elements

env->ReleaseIntArrayElements(num, intNum, JNI_ABORT);

return sum;

}

JNIEXPORT jstring JNICALL Java_com_jdojo_jni_JNIArrayTest_concat

(JNIEnv *env, jobject obj, jobjectArray strArray) {

const int MAX_LENGTH = 500;

char dest[MAX_LENGTH];

for (int i = 0; i < MAX_LENGTH; i++) {

dest[i] = (char)NULL;

}

const jsize count = env->GetArrayLength(strArray);

for (jsize i = 0; i < count; i++) {

// Get the string object from the array

jstring strElement =

(jstring) env->GetObjectArrayElement(strArray, i);

const char *tempStr = env->GetStringUTFChars(strElement, NULL);

if (tempStr == NULL) {

printf("Could not convert Java string to UTF-8 string.\n");

return NULL;

}

// Concatenate tempStr to dest

strcat(dest, tempStr);

// Release the memory used by tempStr

env->ReleaseStringUTFChars(strElement, tempStr);

// Delete the local reference of jstring

env->DeleteLocalRef(strElement);

}

jstring returnStr = env->NewStringUTF(dest);

return returnStr;

}

JNIEXPORT jintArray JNICALL Java_com_jdojo_jni_JNIArrayTest_increment

(JNIEnv *env, jobject obj, jintArray num, jint incrementBy) {

const jsize count = env->GetArrayLength(num);

jboolean isCopy;

jint *intNum = env->GetIntArrayElements(num, &isCopy);

jintArray modifiedNumArray = env->NewIntArray(count);

jboolean isNewArrayCopy;

jint *modifiedNumElements =

env->GetIntArrayElements(modifiedNumArray, &isNewArrayCopy);

for (jint i = 0; i < count; i++) {

modifiedNumElements[i] = intNum[i] + incrementBy;

}

if (isCopy == JNI_TRUE) {

env -> ReleaseIntArrayElements(num, intNum, JNI_COMMIT);

}

if (isNewArrayCopy == JNI_TRUE) {

env -> ReleaseIntArrayElements(modifiedNumArray,

modifiedNumElements,

JNI_COMMIT);

}

return modifiedNumArray;

}

运行清单 8-8 所示的JNIArrayTest类将产生以下输出:

Original Number Array: [1, 2, 3, 4, 5]

Original String Array: [One, Two, Three, Four, Five]

Sum: 15

Concatenated String: OneTwoThreeFourFive

Increment By: 5

Incremented Number Arrays: [6, 7, 8, 9, 10]

在本机代码中访问 Java 对象

您可以以不同的方式在本机代码中使用 Java 对象:您可以

  • 用本机代码创建 Java 对象。
  • 从本机代码访问 JVM 中存在的 Java 对象和类。
  • 访问/修改本机代码中 Java 对象的字段。
  • 从本机代码中调用 Java 实例和 Java 对象的静态方法。

以下部分描述了在本机代码中使用 Java 对象所需的步骤。

获取类引用

jclass类型的实例表示本机代码中的类对象。如果你调用一个本地函数,在 Java 类中声明为staticnative,你的本地函数总是获得类对象的引用作为第二个参数。有时你可能有一个 Java 对象的引用在jobject类型中,你想得到它的类对象引用。您需要使用GetObjectClass() JNI 函数来获取 Java 对象的类对象的引用,如下所示:

jobject obj = get the reference to a Java object;

jclass cls = env->GetObjectClass(obj);

使用FindClass() JNI 函数通过类名获得类对象的引用。您需要在FindClass()方法中使用类的完全限定名,方法是用正斜杠替换包名中的点。如果您试图获取数组的类对象的引用,您需要使用数组类签名。为了获得对java.lang.String类的类对象的引用,您需要使用java/lang/String作为类名。要获得int[]的类对象引用,需要使用[I作为类名。要知道数组类型的类的正确签名,可以在该数组类型的类中声明一个字段,并使用带有–s–private选项的javap命令。以下代码片段演示了如何获取某些 Java 引用类型的类对象的引用:

jclass cls;

// Get the reference of the java.lang.String class object

cls = env->FindClass("java/lang/String");

// Get the reference of the int[] array class object

cls = env->FindClass("[I");

// Get the reference of the int[][] array class object

cls = env->FindClass("[[I");

// Get the reference of the String[] array class object. Note a semi-colon in signature

cls = env->FindClass("[Ljava/lang/String;");

访问 Java 对象/类的字段和方法

在访问本地代码中的 Java 对象/类的字段之前,必须获得字段 ID。您需要使用GetFieldID() JNI 函数获取实例字段的字段 ID,使用GetStaticFieldID() JNI 函数获取静态字段的字段 ID。这两种方法的签名如下:

  • jfieldID GetFieldID(jclass cls, const char *name, const char *sig)
  • jfieldID GetStaticFieldID(jclass cls, const char *name, const char *sig)

cls参数是类对象的引用,定义了实例/静态字段。name参数是字段的名称。sig参数是字段的签名。您需要使用带有–s–private选项的javap命令来获取类中定义的字段的签名。

您需要使用Get<Xxx>Field() JNI 函数来获取实例字段的值,使用GetStatic<Xxx>Field() JNI 函数来获取静态字段的值,其中<Xxx>是字段的类型,其值可以是BooleanByteCharDoubleFloatIntLongShortObjectSet<Xxx>Field()SetStatic<Xxx>Field() JNI 函数允许您分别设置实例和静态字段的值。这些方法的声明如下,其中<RRR>是本地数据类型,例如,如果<Xxx>int,则<RRR>jint:

  • <RRR> Get<Xxx>Field(jobject obj, jfieldID fieldID)
  • <RRR> GetStatic<Xxx>Field(jclass clazz, jfieldID fieldID)
  • void Set<Xxx>Field(jobject obj, jfieldID fieldID, <RRR> value)
  • void SetStatic<Xxx>Field(jclass clazz, jfieldID fieldID, <RRR> value)

假设objjobject的一个实例(即一个 Java 对象引用)cls是它的类引用。在由cls表示的类中有两个类型为int的字段numcountnum字段是实例字段,而count字段是静态字段。以下代码片段显示了如何在本机代码中访问这两个字段并将它们的值递增 1:

// Get the field ID of num and count fields

jfieldID numFieldId = env->GetFieldID(cls, "num", "I");

jfieldID countFieldId = env->GetStaticFieldID(cls, "count", "I");

// Get the field values

jint numValue = env->GetIntField(obj, numFieldId);

jint countValue = env->GetStaticIntField(cls, countFieldId);

// Increment the values by 1 and set them back to the fields

numValue = numValue + 1;

countValue = countValue + 1;

env->SetIntField(obj, numFieldId, numValue);

env->SetStaticIntField(cls, countFieldId, countValue);

在本机代码中使用 Java 对象/类的方法的步骤类似于使用它们的字段。在访问方法之前,您需要获取方法的方法 ID。您可以使用GetMethodID()GetStaticMethodID(JNI 函数来分别获取实例方法和静态方法的方法 ID。他们的声明如下:

  • jmethodID GetMethodID(jclass clazz, const char *name, const char *sig)
  • jmethodID GetStaticMethodID(jclass clazz, const char *name, const char *sig)

该方法的name是它的简单名称,可以使用带有–s–private选项的javap命令来获得它的签名。下面的代码片段展示了如何从一个 Java 类的几个方法中获取方法 ID,假设cls表示类对象引用:

jmethodID methodID

// Method is "void objectCallBack()"

methodID = env->GetMethodID(cls, "objectCallBack", "()V");

// Method is "static void classCallBack()"

methodID = env->GetStaticMethodID(cls, "classCallBack", "()V");

// Method is "int getLength(String str)"

methodID = env->GetMethodID(cls, "getLength", "(Ljava/lang/String;)I");

// Method is "int[] increment(int[], int)"

methodID = env->GetMethodID(cls, "increment", "([II)[I");

调用实例或static方法很容易。您需要使用对象/类、方法 ID 和方法参数(如果有的话)来调用方法。您可以使用以下任何方法来调用对象的实例方法:

  • <RRR> Call<Xxx>Method(jobject obj, jmethodID methodID, arg1, arg2...)
  • <RRR> Call<Xxx>MethodA(jobject obj, jmethodID methodID, const jvalue *args)
  • <RRR> Call<Xxx>MethodV(jobject obj, jmethodID methodID, va_list args)

这里,方法名中的<Xxx>是该方法的返回类型,可以是BooleanByteCharDoubleFloatIntLongShortObjectVoid<RRR>是方法的返回类型,根据对应的<Xxx>值,可以是jbooleanjbytejcharjdoublejfloatjintjlongjshort, jobjectvoidCall<Xxx>Method()Call<Xxx>MethodA()Call<Xx>MethodV()之间的区别在于您希望如何将参数传递给方法。Call<Xxx>Method()方法允许您将参数作为逗号分隔的列表传递给方法。Call<Xxx>MethodA()方法允许您将参数作为jvalue类型的数组传递给方法。Call<Xxx>MethodV()方法允许您将参数作为va_list传递给一个方法。以下代码片段显示了如何调用实例方法,假设objjobject类型的引用,方法 ID 是methodID:

// Method is "void m1()"

env->CallVoidMethod(obj, methodID);

// Method is "void m2(int a)"

env->CallVoidMethod(obj, methodID, 109);

// Method is "int m2(double a)"

jint value = env->CallIntMethod(obj, methodID, 109.23);

调用静态方法类似于调用实例方法。你需要使用一个类对象引用来调用一个静态方法。您需要使用下列 JNI 函数之一来调用静态方法。注意,用于调用static方法的 JNI 函数名包含单词Static

  • <RRR> CallStatic<Xxx>Method(jclass cls, jmethodID methodID, arg1, arg2...)
  • <RRR> CallStatic<Xxx>MethodA(jclass cls, jmethodID methodID, jvalue *args)
  • <RRR> CallStatic<Xxx>MethodV(jclass cls, jmethodID methodID, va_list args)

JNI 允许您从对象的类层次结构中的任何类调用对象的实例方法。当你使用一个Call<Xxx>Method()函数时,它使用对象的类来调用方法。考虑以下类层次结构:

// A.java

package com.jdojo.jni;

public class A {

public int m1() {

return 1;

}

}

// B.java

package com.jdojo.jni;

public class B extends A {

@Override

public int m1() {

return 3;

}

}

// C.java

package com.jdojo.jni;

public class C extends B {

@Override

public int m1() {

return 3;

}

}

BC覆盖了m1()方法。如果您使用CallIntMethod()调用类C的对象的m1()方法,它将调用类C中的m1()方法并返回 3。JNI 允许您使用类C的对象调用类A或类B中的m1()方法。要从超类中调用对象的方法,需要使用以下 JNI 方法之一:

  • <RRR> CallNonvirtual<Xxx>Method(jobject obj, jclass cls, jmethodID methodID, arg1, arg2...)
  • <RRR> CallNonvirtual<Xxx>MethodA(jobject obj, jclass cls, jmethodID methodID, const jvalue *args)
  • <RRR> CallNonvirtual<Xxx>MethodV(jobject obj, jclass cls, jmethodID methodID, va_list args)

您需要在这些版本的方法中使用对象及其类的引用。必须使用需要调用该方法的类来获取methodID。例如,下面的代码片段在类C的对象上调用类Bm1()方法。代码还创建了一个C类的对象。

// Get the class references for B and C

jclass bCls = env->FindClass("com/jdojo/jni/B");

jclass cCls = env->FindClass("com/jdojo/jni/C");

// Get method ID for the constructor of class C

jmethodID cConstrctorID = env->GetMethodID(cCls, "<init>", "()V");

// Create an object of class C

jobject cObject = env->NewObject(cCls, cConstrctorID);

// Get the method ID for the m1() method in class B

jmethodID bMethodID = env->GetMethodID(bCls, "m1", "()I");

// Call the m1() method in class B using an object of class C

jint h = env->CallNonvirtualIntMethod(cObject, bCls, bMethodID);

// will print 2, which is returned from m1() in class B

printf("%i\n", h);

让我们来看一个在本地代码中访问 Java 对象的字段和方法的完整示例。清单 8-10 包含 Java 代码,其中一个名为JNIJavaObjectAccessTest的类包含两个名为numcount的字段。它还包含两个名为objectCallBack()classCallBack()的方法。您将访问本机代码中的字段和方法。它有一个名为callBack()的本地方法。callBack() native方法将numcount字段增加 1,并调用objectCallBack() and classCallBack()方法。在运行JNIJavaObjectAccessTest类之前,您需要生成com_jdojo_jni_JNIJavaObjectAccessTest.h C++头文件和共享库,包括来自jnijavaobjectaccesstest.cpp文件的内容,如清单 8-11 所示。

清单 8-10。从本机代码访问 Java 对象/类的字段和方法

// JNIJavaObjectAccessTest.java

package com.jdojo.jni;

public class JNIJavaObjectAccessTest {

static {

System.loadLibrary("beginningjava");

}

private int num = 10;

private static int count = 1001;

public void objectCallBack() {

System.out.println("Inside objectCallBack() method.");

}

public static void classCallBack() {

System.out.println("Inside classCallBack() method.");

}

public native void callBack();

public int hashCode() {

return -9999;

}

public static void main(String[] args) {

JNIJavaObjectAccessTest test = new JNIJavaObjectAccessTest();

System.out.println("Before calling native method...");

System.out.println("num = " + test.num);

System.out.println("count = " + count);

// Call native method

test.callBack();

System.out.println("After calling native method...");

System.out.println("num = " + test.num);

System.out.println("count = " + count);

}

}

Before calling native method...

num = 10

count = 1001

Inside objectCallBack() method.

Inside classCallBack() method.

After calling native method...

num = 11

count = 1002

清单 8-11。jnijavaobjectsaccesstest.cpp 文件的内容,该文件包含 JNIJavaObjectAccessTest 类中声明的 callBack()本机方法的 C++实现

// jnijavaobjectaccesstest.cpp

#include <stdio.h>

#include <jni.h>

#include "com_jdojo_jni_JNIJavaObjectAccessTest.h"

JNIEXPORT void JNICALL Java_com_jdojo_jni_JNIJavaObjectAccessTest_callBack

(JNIEnv *env, jobject obj) {

jclass cls;

// Get the class reference for the object

cls = env->GetObjectClass(obj);

if (cls == NULL) {

return;

}

// Access the fields

jfieldID numFieldId = env->GetFieldID(cls, "num", "I");

jfieldID countFieldId = env->GetStaticFieldID(cls, "count", "I");

jint numValue = env->GetIntField(obj, numFieldId);

jint countValue = env->GetStaticIntField(cls, countFieldId);

numValue = numValue + 1;

countValue = countValue + 1;

env->SetIntField(obj, numFieldId, numValue);

env->SetStaticIntField(cls, countFieldId, countValue);

// Call the instance method

jmethodID instanceMethodID = env->GetMethodID(cls,

"objectCallBack",

"()V");

if (instanceMethodID != 0) {

env->CallVoidMethod(obj, instanceMethodID);

}

// Call the static method

jmethodID staticMethodID = env->GetStaticMethodID(cls,

"classCallBack",

"()V");

if (staticMethodID != 0) {

env->CallStaticVoidMethod(cls, staticMethodID);

}

return;

}

创建 Java 对象

JNI 允许您在不调用任何构造函数或调用特定构造函数的情况下,用本机代码创建 Java 对象。您需要使用AllocObject() JNI 函数为一个 Java 对象分配内存,而不需要调用它的任何构造函数。请注意,根据数据类型,所有实例字段都有默认值。使用AllocObject() JNI 函数时,实例字段不会被初始化,也不会调用实例初始化器。下面是用 Java 为一个类的对象分配内存的代码片段:

jclass cls = get the class reference;

jobject obj = env->AllocObject(cls);

if (obj == NULL) {

// The object could not be created. Handle the error condition.

}

通过使用下列 JNI 函数之一调用 Java 类的特定构造函数,可以创建 Java 对象。这些函数的不同之处仅在于如何传递构造函数的参数。

  • jobject NewObject(jclass clazz, jmethodID methodID, arg1, arg2...)
  • jobject NewObjectA(jclass clazz, jmethodID methodID, const jvalue *args)
  • jobject NewObjectV(jclass clazz, jmethodID methodID, va_list args)

methodID参数是您想要调用的构造函数的方法 ID。当您想要获取类的构造函数的方法 ID 时,有一个特殊的字符串用于方法名。你需要使用<init>$init$作为构造函数的方法名。考虑清单 8-12 所示的名为IntWrapper的类的代码。

清单 8-12。一个用本地代码演示 Java 对象创建的示例类

// IntWrapper.java

package com.jdojo.jni;

public class IntWrapper {

private int value = -1;

public IntWrapper() {

}

public IntWrapper(int value) {

this.value = value;

}

public int getValue() {

return value;

}

}

您可以在本机 C++代码中获取对IntWrapper类的引用,如下所示:

jclass wrapperCls = env->FindClass("com/jdojo/jni/IntWrapper");

以下 C++代码在不调用构造函数的情况下为一个IntWrapper对象分配内存:

jobject wrapperObject = env->AllocObject(wrapperCls);

此时,wrapperObject存在于内存中,其实例字段value仍然具有默认值 0。如果您在此时调用wrapperObject上的getValue()方法,它将返回 0 而不是–1,正如您所料。

如果你想通过调用一个构造函数来创建一个 Java 类的对象,你需要使用NewObject() JNI 函数。下面的代码片段通过调用无参数构造函数创建了一个IntWrapper类的对象。构造函数的签名取决于它接受的参数的数量和类型。对于无参数构造函数,签名是()V。如果构造函数接受一个int参数,它的签名将是(I)V。您可以通过使用带有–s选项的javap命令来获得一个类的构造函数的签名。如果您还想包含private成员的签名,请使用带有javap–private选项。

// Get the method ID for the default constructor of class IntWrapper

jmethodID mid = env->GetMethodID(wrapperCls, "<init>", "()V");

// Create an object of class IntWrapper using the default constructor

jobject wrapperObject = env->NewObject(wrapperCls, mid);

此时,如果在wrapperObject上调用getValue()方法,它将返回-1,这是value实例字段的初始值。当调用构造函数时,所有实例字段都被初始化。

下面的代码片段调用了第二个版本的IntWrapper类的构造函数,它接受一个int参数。它将 999 作为构造函数IntWrapper(int value)的参数值进行传递。

// Get the method ID for the constructor for class IntWrapper

jmethodID wrapperConstrctorID = env->GetMethodID(wrapperCls, "<init>","(I)V");

// Create an object of class IntWrapper passing 999 to the constructor

jobject wrapperObject = env->NewObject(wrapperCls, wrapperConstrctorID, 999);

此时,如果你调用abcObject上的getValue()方法,它将返回 999,这个值是在它创建的时候在它的构造函数中设置的。

Tip

AllocObject()NewObject() JNI 函数只能用于创建非数组引用类型的对象。你需要使用NewObjectArray() JNI 函数来创建一个特定类型的数组。

异常处理

JNI 允许您处理本机代码中的异常。本机代码可以检测和处理由于调用 JNI 函数而在 JVM 中引发的异常。本机代码也可以抛出异常,该异常可以传播到 Java 代码。本机代码中的异常处理机制不同于 Java 代码。当 Java 代码中抛出异常时,控制权会立即转移到最近的能够处理异常的catch块。如果在本机代码执行过程中引发异常,本机代码将继续执行,异常将保持挂起状态,直到控制权返回给 Java 代码。一旦异常挂起,除了释放本机资源的函数之外,不应该执行任何其他 JNI 函数。有两种方法可以检测本机代码中的 JNI 函数调用是否导致了异常:

  • 通过检查函数的特殊返回值
  • 通过检查函数返回后是否出现异常

如果出现异常,一些 JNI 函数会返回一个特殊值。举个例子,如果你调用了FindClass() JNI 函数,没有找到类,那么可能会抛出四个异常中的任何一个:ClassFormatErrorClassCircularityErrorNoClassDefFoundError或者OutOfMemoryError。如果抛出了四个异常中的任何一个,FindClass() JNI 函数将返回NULL作为特殊值。您应该在调用FindClass() JNI 函数后立即检查NULL的返回值,并编写代码来处理该异常。通常,您将控件返回给调用方,以便调用方可以处理异常,如下所示:

jclass cls = env->FindClass("abc/xyz/NonExistentClass");

if (cls == NULL) {

/* Here, free up any resources you had held and return. Exception is pending at            this time. It will be thrown when the control returns to the Java code.

*/

return;

}

在某些情况下,不可能从 JNI 函数返回一个特殊值来指示异常已经发生。假设您正在用本机代码访问一个 Java 数组,并且已经超出了该数组的边界。在这种情况下,JVM 抛出一个类型为ArrayIndexOutOfBoundsException的异常。你可以调用一个发生异常的 Java 对象的方法。在这种情况下,你需要在 JNI 函数调用后立即使用ExceptionOccurred()ExceptionCheck() JNI 函数来检查是否有异常发生。这些函数具有以下特征:

  • jthrowable ExceptionOccurred()
  • jboolean ExceptionCheck()

如果在函数调用过程中发生异常,函数将返回该异常对象的引用。否则,它返回NULL。如果在函数调用过程中出现异常,ExceptionCheck()函数返回JNI_TRUE。否则返回JNI_FALSE。下面的代码片段演示了如何使用这些函数。你只需要使用两个功能中的一个,而不是同时使用两个。

// Using method ExceptionOccurred()

// Call a JNI function, which may throw an exception

jthrowable e = env->ExceptionOccurred();

if (e != NULL) {

/* Free up any resources that you had held and return. Exception is pending at this

time. It will be thrown when the control returns to the Java code.

*/

return;

}

// Using method ExceptionCheck()

// Call a JNI function, which may throw an exception

jboolean gotException = env->ExceptionCheck();

if (gotException) {

/* Free up any resources that you had held and return. Exception is pending at

this time. It will be thrown when the control returns to the Java code.

*/

return;

}

一旦检测到本机代码中发生的异常,您有三种选择:

  • 清除异常并在本机代码中处理它。
  • 将控制返回给 Java 代码,让 Java 代码处理异常。
  • 清除异常,在本机代码中处理它,并从本机代码中抛出一个 Java 代码可以处理的新异常。

以下部分解释了处理异常的三种方式。

在本机代码中处理异常

您可以清除异常并在本机代码中处理异常情况。使用ExceptionClear() JNI 函数清除挂起的异常,如图所示:

// Call a JNI function, which may throw an exception

jboolean gotException = env->ExceptionCheck();

if (gotException) {

// Clear the exception

env->ExceptionClear();

// Write some code to take care of the exceptional condition

}

一旦清除了异常,该异常就不再处于挂起状态。

在 Java 代码中处理异常

您可以使用语句将控件返回给调用方,并让调用方处理异常,如下所示:

// Call a JNI function, which may throw an exception

jboolean gotException = env->ExceptionCheck();

if (gotException) {

/* Free up any resources that you had held and return. Exception  is pending at this time. It will be thrown when the control returns to the caller.

*/

return;

}

从本机代码引发新的异常

您可以在本机代码中处理该异常,清除该异常,并引发新的异常。请注意,从本机代码抛出异常并不会将控制权转移回 Java 代码。您必须编写代码,比如一个return语句,将控制权转移回 Java 代码,所以您抛出的异常是用 Java 处理的。您可以使用以下两个 JNI 函数之一在本机代码中引发异常。两个函数成功时返回零,失败时返回负整数。

  • jint Throw(jthrowable obj)
  • jint ThrowNew(jclass clazz, const char *message)

Throw()函数接受一个jthrowable对象。该函数接受异常的类引用和一条消息。下面的代码片段展示了如何使用ThrowNew()函数抛出一个java.lang.Exception:

if (someErrorConditionIsTrue) {

jclass cls = env->FindClass("java/lang/Exception");

// Check for exception here (omitted)

env->ThrowNew(cls, "your error message goes here");

return;

}

Tip

如果你想打印本机代码中异常的栈跟踪,你可以使用ExceptionDescribe() JNI 函数。它在标准错误上打印异常栈跟踪。如果想从本机代码中引发致命错误,可以使用FatalError(const char *msg) JNI 函数。函数不会返回,JVM 也不会从这个错误中恢复。Java 代码中声明的本机方法也可以像 Java 非本机方法一样使用throws子句。以下是 Java 类中有效的本机方法声明:

public native int myMethod() throws Exception;

创建 JVM 的实例

到目前为止,您已经看到了使用本机代码的 Java 应用。现在你可以看到相反的情况了。即使用 Java 代码的本机应用。为什么要使用本地应用中的 Java 代码?出于以下原因,您可能希望使用本机应用中的 Java 代码:

  • 您可能已经有了一个用 Java 编写的应用,并且想要使用现有的代码。
  • Java 提供了一套丰富的类库。您可能希望在本机应用中利用 Java 类库。

JNI API 中允许您用本机代码创建和加载 JVM 的部分称为。JNI 允许您在本地应用中嵌入 JVM。也就是说,您可以从本地应用创建 JVM,并像在 Java 应用中一样使用 Java 类。用本机代码创建一个 JVM 只需要几行代码。您所需要做的就是准备要传递给 JVM 的初始参数,并调用JNI_CreateJavaVM()调用 API 函数来创建 JVM。

传递给 JVM 的初始参数是一个JavaVMInitArgs结构,定义如下:

typedef struct JavaVMInitArgs {

jint version;

jint nOptions;

JavaVMOption *options;

jboolean ignoreUnrecognized;

} JavaVMInitArgs;

version字段表示 JNI 版本,并且必须至少设置为 JNI 版本 1 2。nOptions字段被设置为您想要传递给 JVM 的选项的数量。选项字段是一个JavaVMOption结构的数组,定义如下:

typedef struct JavaVMOption {

char *optionString;

void *extraInfo;

} JavaVMOption;

如果ignoreUnrecognized设置为JNI_TRUE,则JNI_CreateJavaVM()功能将忽略未识别的选项。如果设置为JNI_FALSEJNI_CreateJavaVM()函数一遇到未识别的选项就会返回JNI_ERR

JavaVMOption结构中的optionString字段是一个字符串,它是默认平台编码中 JVM 选项的值。

extraInfo字段用于特殊类型的 JVM 参数。它代表一个用于重定向 JVM 消息的函数挂钩、一个 JVM 退出挂钩或一个 JVM 中止挂钩。extraInfo字段代表的挂钩类型取决于optionString字段的值。如果optionString字段的值为vfprintfexitabort,则extraInfo字段分别表示 JVM 消息重定向挂钩、JVM 退出挂钩或 JVM 中止挂钩。注意vfprintf钩子只将 JVM 消息重定向到钩子。它不会将System.outSystem.err消息重定向到钩子。如果您在本机代码中设置了一个vsprintf钩子,并在 Java 代码中使用了System.out/System.errprint()/println()方法之一,那么这些消息将不会被重定向到您的vfprintf钩子。您需要使用System类的setOut()setErr()方法来重定向System.outSystem.err消息。JVM 的退出钩子在 JVM 正常终止时被调用,比如通过在 Java 代码中调用System.exit(int exitCode)方法。JVM 的中止钩子在 JVM 异常终止时被调用。下面的代码片段展示了如何用不同的 VM 钩子填充extraInfo字段。首先,定义三个函数作为三种类型的钩子。请注意,函数必须具有相同的签名,如以下代码片段所示:

jint JNICALL jvmMsgRedirection_hook(FILE *stream, const char *format, va_list args) {

// You can log the VM message here.

// Let us just print the VM message on the standard output.

return vfprintf(stdout, format, args);

}

void JNICALL jvmExit_hook(jint code) {

// You can do some cleanup work here

printf("VM exited with exit code %i\n", code);

}

void JNICALL jvmAbort_hook() {

printf("VM was aborted\n");

}

JavaVMOption jvmOption[3];

// Add JVM hooks

options[0].optionString = "vfprintf";

options[0].extraInfo = jvmMsgRedirection_hook;

options[1].optionString = "exit";

options[1].extraInfo = jvmExit_hook;

options[2].optionString = "abort";

options[2].extraInfo = jvmAbort_hook;

下面的代码片段展示了如何用 JVM 的初始参数填充一个JavaVMInitArgs结构。它只设置了两个参数,java.class.pathjava.lib.path。如果需要,可以设置更多的 JVM 参数。

// Populate the JVM options in JavaVMOption structure

const jint MAX_OPTIONS = 2; // will pass two arguments to the JVM

JavaVMOption options[MAX_OPTIONS];

// Our first argument is java.class.path (CLASSPATH for JVM)

options[0].optionString = "-Djava.class.path=.;c:\\myjni\\classes";

// Our second argument is java.library.path (PATH to find a shared library)

options[1].optionString = "-Djava.library.path=c:\\myjni\\libs";

// Populate JavaVMInitArgs structure with options details

JavaVMInitArgs vm_args;

vm_args.version  = JNI_VERSION_1_2;

vm_args.nOptions = MAX_OPTIONS;

vm_args.options  = options;

vm_args.ignoreUnrecognized = true;

一旦在一个JavaVMInitArgs结构中准备好了 JVM 参数,就只需要一次 JNI 函数调用,就可以用本机代码创建一个 JVM 了。JNI_CreateJavaVM() JNI 函数接受三个参数。第一个参数是一个指向代表 JVM 的JavaVM结构的指针。第二个参数是一个指向JNIEnv结构的指针,它是 JNI 接口。第三个参数是 JVM 的初始参数。下面的代码片段展示了如何用本机代码创建 JVM。您需要检查JNI_CreateJavaVM()函数可能返回的任何错误。如果不能创建 JVM,它返回JNI_ERR

JNIEnv *env;

JavaVM *jvm;

long status;

status = JNI_CreateJavaVM(&jvm, (void**)&env, &vm_args);

if (status == JNI_ERR) {

printf("Could not create VM. Exiting application...\n");

return 1;

}

一旦获得了JNIEnv结构,就可以用它来查找一个类,创建该类的一个对象,并对该对象执行任何方法。事实上,它允许您使用 JNI 访问整个 JVM。

在使用完 JVM 之后,您需要销毁它。

// Destroy JVM

jvm->DestroyJavaVM();

清单 8-13 包含了一个带有静态方法printMsg()EmbeddedJVMJNI类的代码,用于在标准输出中打印一条消息。稍后,您将使用本机代码创建一个 JVM,并调用printMsg()方法。

清单 8-13。嵌入式 JVMJNI Java 类

// EmbeddedJVMJNI.java

package com.jdojo.jni;

public class EmbeddedJVMJNI {

public static void printMsg(String msg) {

System.out.println(msg);

}

}

清单 8-14 中列出的 C++控制台应用创建一个 JVM 并调用EmbeddedJVMJNI类的printMsg()方法。本书的源代码包含了createjvm.cpp文件中的 C++代码。该程序允许您将类路径指定为命令行参数。如果不指定类路径,它将使用当前目录作为类路径。

清单 8-14。在本地应用中创建 jvm 的 createjvm.cpp 文件的内容

// createjvm.cpp

#include <jni.h>

#include <iostream>

#include <string>

int main(int argc, char **argv) {

std::string classpath("");

if (argc < 2) {

std::cout << "You did not pass the classpath."

<< " Using the current directory as the classpath.\n";

classpath = ".";

}

else {

classpath = argv[1];

}

std::string classpathOption("-Djava.class.path=");

classpathOption = classpathOption + classpath;

// Pass the classpath as an argument to the JVM

const jint MAX_OPTIONS = 1;

JavaVMOption options[MAX_OPTIONS];

options[0].optionString = (char *)(classpathOption.c_str());;

// Prepare the JVM initial arguments

JavaVMInitArgs vm_args;

vm_args.version = JNI_VERSION_1_2;

vm_args.nOptions = MAX_OPTIONS;

vm_args.options = options;

vm_args.ignoreUnrecognized = true;

// Create the JVM

JavaVM *jvm;

JNIEnv *env;

long status = JNI_CreateJavaVM(&jvm, (void**) &env, &vm_args);

if (status == JNI_ERR) {

std::cout << "Could not create VM. Exiting application...\n";

return 1;

}

const char *className = "com/jdojo/jni/EmbeddedJVMJNI";

jclass cls = env->FindClass(className);

if (cls == NULL) {

// Print exception stack trace and destroy the JVM

env->ExceptionDescribe();

jvm->DestroyJavaVM();

return 1;

}

if (cls != NULL) {

jmethodID mid = env->GetStaticMethodID(cls, "printMsg",

"(Ljava/lang/String;)V");

if (mid != NULL) {

jstring m = env->NewStringUTF("Hello from C++...\n");

env->CallStaticVoidMethod(cls, mid, m);

if (env->ExceptionCheck()) {

env->ExceptionDescribe();

env->ExceptionClear();

}

}

}

// Destroy JVM

jvm->DestroyJavaVM();

return 0;

}

您需要将createjvm.cpp文件编译成可执行文件。当你编译这个程序的时候,你需要提供jvm.lib文件的路径,这个文件安装在 Windows 的JAVA_HOME\lib目录下。假设您已经在 Windows 上的C:\java8中安装了 JDK,您可以使用下面的命令在 Windows 上创建createjvm.exe文件:

C:> g++ -IC:/java8/include -IC:/java8/include/win32

-o createjvm

createjvm.cpp

C:/java8/lib/jvm.lib

该命令在一行中输入,但为了便于阅读,它显示在多行中。该命令的前两行与您之前用来创建共享库的行相同。–o选项用于指定可执行输出文件名,在本例中是createjvm。最后一个选项是需要静态链接的名为jvm.lib的库的路径。

下面的命令将在 Linux 上创建一个createjvm可执行文件,假设您已经在/home/ksharan/java8目录中安装了 JDK:

$ g++ -I/home/ksharan/java8/include -I/home/ksharan/java8/include/linux

-o createjvm

createjvm.cpp

/home/ksharan/java8/jre/lib/i386/client/libjvm.so

在 Windows 上,当你运行createjvm.exe应用时,它会寻找jvm.dll共享库,这个库在JRE_HOME\bin\client目录中。您需要在PATH环境变量中包含包含jvm.dll文件的目录。

C:\> SET PATH=C:\java8\bin\client;%PATH%

C:\> createjvm C:\myclasses

Hello from C++...

当您运行createjvm.exe文件时,您可能会得到以下错误:

Exception in thread "main" java.lang.NoClassDefFoundError: com/jdojo/jni/EmbeddedJVMJNI

Caused by: java.lang.ClassNotFoundException: com.jdojo.jni.EmbeddedJVMJNI

...

该错误表明类路径设置不正确,JVM 无法找到EmbeddedJVMJNI类。使用上面的命令,在C:\myclasses目录中搜索该类。要修复这个错误,要么使用正确的类路径参数运行createjvm应用,要么将com\jdojo\jni\EmbeddedJVMJNI.class文件移动到C:\myclasses目录中。

在 Linux 上,您需要设置LD_LIBRARY_PATH,这样当 createjvm 应用运行时,就会加载libjvm.so文件。您可以按如下方式进行设置:

$ export LD_LIBRARY_PATH=/home/ksharan/java8/jre/lib/i386/client

现在,您已经准备好运行 createjvm 应用,如下所示:

$ ./createjvm /home/ksharan/myclasses

Hello from C++...

该命令将在/home/ksharan/myclasses目录中搜索com/jdojo/jni/EmbeddedJVMJNI.class

本机代码中的同步

JNI 提供了两个名为MonitorEnter()MonitorExit()的函数,用于在多线程环境中同步对本地代码的访问。这些函数被串联使用,它们的使用等同于在 Java 代码中使用synchronized关键字。这些函数声明如下:

  • jint MonitorEnter(jobject obj)
  • jint MonitorExit(jobject obj)

如果成功,两个函数都返回 0 ( JNI_OKjni.h头文件中被定义为 0),如果失败,则返回负数。您必须检查它们的返回值,以正确处理代码同步。下面是使用同步的 Java 代码示例:

Object someObject = get the reference of a java object;

// Other logic goes here

synchronized(someObject) {

// Synchronized code goes here

}

等效的本机代码如下:

jobject someObject = get the reference of a java object;

// Other logic goes here

jint enterStatus = env->MonitorEnter(someObject);

if (enterStatus != JNI_OK) {

// Handle the error condition here

}

// Synchronized code goes here

jint exitStatus = env->MonitorExit(someObject);

if (exitStatus != JNI_OK ) {

// Handle the error condition here

}

Java wait()notify()没有对等的 JNI 函数来帮助线程同步。然而,您总是可以从本机代码中调用这两个 Java 方法。

摘要

Java 本地接口(Java Native Interface,JNI)是一种编程接口,它有助于 Java 程序与用 C、C++、FORTRAN 等本地语言编写的程序之间的交互。JNI 使得在 Java 代码中使用一个方法并在本地语言(如 C 或 C++)中实现该方法成为可能。JNI 还使得将 JVM 嵌入到可以访问 Java 类库的本地应用中成为可能。

在 Java 中使用但在本地语言中实现的方法称为本地方法,使用关键字native声明。Java 中的native方法没有主体。它的主体用分号表示。native方法的实现是用本地语言编写的,并被编译成一个共享库。使用java.library.path JVM 选项使共享库对 Java 运行时可用,或者它们位于 PATH 环境变量中。

javah命令用于生成本地语言所需的头文件。它将包含本机方法的类的完全限定类名作为参数。

JNI 定义了 Java 和本机代码中使用的数据类型之间的映射。比如jbooleanjcharjint等。相当于本地的booleancharint等。Java 中的原始数据类型。本机代码中的jclassjobjectjstring类型映射到 Java 中的ClassObjectString类。

JNI 提供了一些函数来促进字符串的 Java 表示和本机表示之间的转换。它还提供了特殊的函数来访问 Java 数组和数组元素的长度。

JNI 还允许您在本机代码中创建 Java 对象。您还可以访问本机代码中 Java 对象的字段和方法。

Java 中的Throwable类型被映射到本机代码中的类型jthrowable。JNI 允许您处理本机代码中的异常。本机代码可以检测和处理由于调用 JNI 函数而在 JVM 中引发的异常。本机代码也可以抛出异常,该异常可以传播到 Java 代码。如果在本机代码执行过程中引发异常,本机代码将继续执行,异常将保持挂起状态,直到控制权返回给 Java 代码。

JNI 允许您将 JVM 嵌入到本地应用中,从而为它们提供对丰富的 Java 类库的完全访问权。JNI API 中允许您用本机代码创建和加载 JVM 的部分称为调用 API。JVM 是使用调用 API 提供的JNI_CreateJavaVM()方法在本机代码中创建的。

在多线程环境中,可以通过使用两个名为MonitorEnter()MonitorExit()的 JNI 函数来同步对本机代码中关键部分的访问。这些函数被串联使用,它们的使用等同于在 Java 代码中使用synchronized关键字。

九、JavaFX 简介

在本章中,您将学习

  • JavaFX 是什么
  • 如何编写简单的 JavaFX 程序
  • JavaFX 中的属性、绑定和可观察集合
  • 事件处理
  • 使用布局窗格、控制、2D 形状和在画布上绘图
  • 应用效果、变换和动画
  • 使用 FXML 在 JavaFX 应用中构建 ui
  • 在 JavaFX 中打印节点

JavaFX 是一个庞大的主题,它本身就值得写一本书。这是一个介绍性章节,向您展示 JavaFX 提供的特性。这些主题都没有全面涵盖。

JavaFX 是什么?

JavaFX 是一个开源的基于 Java 的 GUI 框架,用于开发富客户端应用。它可以与市场上的其他框架相媲美,如 Adobe Flex 和 Microsoft Silverlight。JavaFX 也被视为 Swing 在 Java 平台 GUI 开发技术领域的继承者。JavaFX 库作为一个公共 Java API 提供。JavaFX 包含几个特性,这些特性使它成为开发富客户端应用的首选:

  • JavaFX 是用 Java 编写的,使您能够利用所有 Java 特性,如多线程、泛型、lambda 表达式等。您可以使用自己选择的任何 Java 编辑器(如 NetBeans)来创作、编译、运行、调试和打包您的 JavaFX 应用。
  • JavaFX 通过其库支持数据绑定。
  • JavaFX 代码可以使用任何 JVM 支持的脚本语言编写,如 Visage、Groovy、Scala、Nashorn 等。
  • JavaFX 提供了两种构建 UI 的方法:使用 Java 代码和使用 FXML。FXML 是一种基于 XML 的可脚本化标记语言,用于以声明方式定义 UI。Oracle 提供了一个名为 Scene Builder 的工具,这是一个用于构建 FXML 的可视化编辑器。
  • JavaFX 提供了丰富的多媒体支持,如播放音频和视频。它利用了平台上可用的编解码器。
  • JavaFX 允许您在应用中嵌入 web 内容。
  • JavaFX 为应用效果和动画提供了现成的支持,这对开发游戏应用非常重要。在 JavaFX 中,只需编写几行代码就可以实现复杂的动画。

JavaFX 平台由以下组件组成,以利用 Java 本地库以及平台上可用的硬件和软件。这些部件的布置如图 9-1 所示。

A978-1-4302-6662-4_9_Fig1_HTML.jpg

图 9-1。

Components making up the JavaFX platform

  • JavaFX 公共 API
  • 量子工具包
  • 棱镜
  • 玻璃开窗工具包
  • 媒体引擎
  • 网络引擎

JavaFX 中的 GUI 被构造为场景图。场景图是被称为节点的可视元素的集合,这些节点以树状层次结构排列。使用公共 JavaFX API 构建场景图。场景图中的节点可以处理用户输入和手势。它们可以有效果、转换和状态。场景图中的节点类型包括简单的用户界面(UI)控件,例如按钮、文本字段、2D 和 3D 形状、图像、媒体(音频和视频)、web 内容、图表等。

Prism 是用于渲染场景图形的硬件加速图形管道。如果硬件加速渲染在平台上不可用,则使用 Java 2D 作为后备渲染机制。例如,在使用 Java 2D 进行渲染之前,JavaFX 将尝试在 Windows 上使用 DirectX,在 Mac、Linux 和嵌入式平台上使用 OpenGL。

使用本机操作系统提供图形和窗口服务,如 windows 和计时器。该工具包还负责管理事件队列。在 JavaFX 中,事件队列由一个称为 JavaFX 应用线程的操作系统级线程管理。所有用户输入事件都在 JavaFX 应用线程上调度。JavaFX 要求只能在 JavaFX 应用线程上修改实时场景图形。

Prism 使用单独的线程,而不是 JavaFX 应用线程来进行渲染。它通过在处理下一帧的同时渲染一帧来加速渲染过程。当场景图形被修改时,例如,通过在文本字段中输入文本,Prism 需要重新渲染场景图形。使用一个称为脉冲事件的事件来实现场景图形与 Prism 的同步。当场景图形被修改并且需要重新渲染时,一个脉冲事件在 JavaFX 应用线程上排队。脉冲事件表示场景图形与 Prism 中的渲染层不同步,应该渲染 Prism 级别的最新帧。脉冲事件被限制在每秒最大 60 帧。

媒体引擎负责在 JavaFX 中提供媒体支持,例如,回放音频和视频。它利用了平台上可用的编解码器。媒体引擎使用单独的线程处理媒体帧,JavaFX 应用线程将帧与场景图形同步。媒体引擎基于 GStreamer,这是一个开源的多媒体框架。

web 引擎负责处理嵌入在场景图中的 web 内容(HTML)。Prism 负责呈现 web 内容。web 引擎基于 Webkit,这是一个开源的 web 浏览器引擎。它支持 HTML5、级联样式表(CSS)、JavaScript 和文档对象模型(DOM)。

该工具包是对底层组件(如 Prism、Glass、媒体引擎和 Web 引擎)的抽象。它还有助于低层组件之间的协调。

JavaFX 的历史

JavaFX 最初是由 Chris Oliver 在 Seebeyond 开发的,它被称为 F3(形式跟随功能)。F3 是一种易于开发 GUI 应用的 Java 脚本语言。它提供了声明性语法、静态类型、类型推断、数据绑定、动画、2D 图形、Swing 组件等。Seebeyond 被太阳微系统公司收购,F3 在 2007 年被命名为 JavaFX。甲骨文在 2010 年收购了太阳微系统公司。甲骨文在 2013 年开源了 JavaFX。

JavaFX 的第一个版本发布于 2008 年第四季度。JavaFX 的当前版本是 8.0 版。JavaFX 的版本号从 2.2 跃升到 8.0。从 Java 8 开始,Java SE 和 JavaFX 的版本号将是相同的。将来,Java SE 和 JavaFX 的主要版本将同时发布,并且它们的版本将保持同步。比如 JavaFX 9 会和 Java SE 9 一起发布,JavaFX 10 会和 Java SE 10 一起发布等等。

表 9-1 包含 JavaFX 的发布列表。从 Java SE 8 的发布开始,JavaFX 就是 Java SE 运行时库的一部分,您不需要执行任何额外的设置来编译和运行 JavaFX 程序。

表 9-1。

JavaFX Releases

| 出厂日期 | 版本 | 评论 | | --- | --- | --- | | 2008 年第四季度 | JavaFX 1.0 | 这是 JavaFX 的最初版本。它使用一种称为 JavaFX Script 的声明语言来编写 JavaFX 代码。 | | Q1,2009 年 | JavaFX 1.1 | 引入了对 JavaFX Mobile 的支持。 | | Q2,2009 年 | JavaFX 1.2 |   | | Q2,2010 年 | JavaFX 1.3 |   | | 2010 年第三季度 | JavaFX 1.3.1 |   | | 2011 年第四季度 | java fx 2.0 | 不再支持 JavaFX script 和 JavaFX Mobile。它使用 Java 编程语言编写 JavaFX 代码。 | | 2012 年,Q2 | JavaFX 2.1 | 引入了对 Mac OS 桌面版的支持。 | | 2012 年第三季度 | JavaFX 2.2 |   | | 2014 年,Q1 | JavaFX 8.0 | JavaFX 版本从 2.2 跳到了 8.0。JavaFX 和 Java SE 版本将从 Java 8 开始匹配。 |

系统需求

要使用本章中的示例,您需要安装 JDK8。没有必要使用 NetBeans IDE 来编译和运行本书中的程序。但是,NetBeans IDE 具有创建、运行和打包 JavaFX 应用的特殊功能,这使得使用 NetBeans 开发 JavaFX 应用更加容易。您可以使用任何其他 IDE,如 Eclipse、JDeveloper、IntelliJ IDEA 等。或者只使用命令提示符来编译和运行 JavaFX 程序。

JavaFX 运行时库

所有 JavaFX 类都打包在一个名为jfxrt.jar的 JAR 文件中,该文件位于JRE_HOME\lib\ext目录中,其中JRE_HOME是 JRE 的安装目录。

如果您在命令行上编译和运行 JavaFX 程序,您不需要担心在类路径中设置 JavaFX 运行时 JAR 文件。Java 8 编译器(javac命令)和启动器(java命令)自动将 JavaFX 运行时 JAR 文件包含在类路径中。

当您创建 Java 或 JavaFX 项目时,NetBeans IDE 会自动在类路径中包含 JavaFX 运行时 JAR 文件。如果您使用的是 NetBeans 之外的 ide,您可能需要在 IDE 类路径中包含jfxrt.jar,以便从 IDE 内部编译和运行 JavaFX 应用。

JavaFX 源代码

有经验的开发人员有时更喜欢查看 JavaFX 库的源代码,以了解幕后是如何实现的。Oracle 提供了 JavaFX 源代码。Java 8 安装复制了 JDK 主目录中的源代码。文件名为javafx-src.zip。将文件解压到一个目录中,使用您喜欢的 Java 编辑器打开源代码。

您的第一个 JavaFX 应用

您的第一个 JavaFX 应用将在一个窗口中显示文本Hello JavaFX。通过添加尽可能少的代码行,并了解代码的作用以及为什么需要它,您将采取渐进的方法来开发您的第一个 JavaFX 应用。

创建 HelloJavaFX 类

JavaFX 应用是一个必须从Application类继承的类。Application级在javafx.application包里。你将命名你的类为HelloFXApp,它将被保存在com.jdojo.jfx包中。

// HelloFXApp.java

package com.jdojo.jfx;

import javafx.application.Application;

public class HelloFXApp extends Application {

// Application logic goes here

}

该程序包括包声明、导入语句和类声明。代码中没有类似 JavaFX 的内容。它看起来像任何其他 Java 代码。然而,通过从Application类继承HelloFXApp类,您已经满足了 JavaFX 应用的一个需求。此时HelloFXApp类不会编译。

重写 start()方法

如果您尝试编译HelloFXApp class,将会导致以下编译时错误:

HelloFXApp is not abstract and does not override abstract method start(Stage) in Application

该错误表明Application类包含一个抽象的start(Stage stage)方法,该方法没有在HelloFXApp类中被覆盖。作为 Java 开发人员,您知道下一步该做什么:要么将HelloJavaFX类声明为抽象类,要么为start()方法提供一个实现。您需要为这个类中的start()方法提供一个实现。Application类中的start()方法声明如下:

  • public abstract void start(Stage stage) throws java.lang.Exception

以下是为您的应用修改的代码:

// HelloFXApp.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.stage.Stage;

public class HelloFXApp extends Application {

@Override

public void start(Stage stage) {

// The logic for starting the application goes here

}

}

在修订后的代码中,您加入了两件事:

  • 您已经添加了一个另外的import语句来从javafx.stage包中导入Stage类。
  • 您实现了start()方法。该方法的throws子句被删除,这符合 Java 中覆盖方法的规则。

start()方法是 JavaFX 应用的入口点。它由 JavaFX 应用启动器调用。注意,start()方法被传递了一个Stage类的实例,这被称为应用的初级阶段。您可以根据需要在应用中创建更多阶段。但是,主阶段总是由 JavaFX 运行时为您创建的。

Tip

每个 JavaFX 应用类都必须从Application类继承,并为start(String stage)方法提供实现。

展示舞台

类似于现实世界中的舞台,JavaFX 舞台用于显示场景。场景具有视觉效果,如文本、形状、图像、控件、动画、效果等。—用户可以与之交互,所有基于 GUI 的应用都是如此。

在 JavaFX 中,主舞台是场景的容器。根据应用的运行环境,stage 的外观会有所不同。您不需要根据环境采取任何行动,因为 JavaFX 运行时会为您处理所有细节。例如,如果应用作为桌面应用运行,主舞台将是一个带有标题栏和显示场景区域的窗口;如果应用作为 applet 在 web 浏览器中运行,主阶段将是浏览器窗口中的嵌入区域。

由应用启动器创建的初级阶段没有场景。在下一节中,您将为您的舞台创建一个场景。

您必须展示舞台才能看到场景中包含的视觉效果。使用show()方法显示阶段。或者,您可以使用方法为舞台设置标题。HelloFXApp类的修订代码如下:

// HelloFXApp.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.stage.Stage;

public class HelloFXApp extends Application {

@Override

public void start(Stage stage) {

// Set a title for the stage

stage.setTitle("Hello JavaFX Application");

// Show the stage

stage.show();

}

}

启动应用

您已经准备好运行您的第一个 JavaFX 应用。您可以使用以下两个选项之一来运行 JavaFX 应用:

  • 启动 JavaFX 应用不需要在类中包含方法。当运行从Application类继承的 Java 类时,如果正在运行的类不包含main()方法,则java命令会启动 JavaFX 应用。
  • 在 JavaFX 应用类中包含一个main()方法。在main()方法中,调用Application类的launch()静态方法来启动 JavaFX 应用。该方法将一个String数组作为参数,它是传递给 JavaFX 应用的参数。

如果您使用第一个选项,您不需要为HelloJavaFX类编写任何额外的代码。如果您使用第二个选项,用main()方法修改后的HelloFXApp类代码如清单 9-1 所示。

清单 9-1。没有场景的 JavaFX 应用

// HelloFXApp.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.stage.Stage;

public class HelloFXApp extends Application {

public static void main(String[] args) {

// Launch the JavaFX application

Application.launch(args);

}

@Override

public void start(Stage stage) {

stage.setTitle("Hello JavaFX Application");

stage.show();

}

}

main()方法调用launch()方法,后者将做一些设置工作,并调用HelloFXApp类的start()方法。你的start()方法为初级阶段设置标题并显示阶段。

使用以下命令编译HelloFXApp类:

javac com/jdojo/intro/HelloFXApp.java

使用以下命令运行HelloFXApp类:

java com.jdojo.jfx.HelloFXApp

显示一个带有标题栏的窗口,如图 9-2 所示。

A978-1-4302-6662-4_9_Fig2_HTML.jpg

图 9-2。

A JavaFX stage without a scene

窗口的主要区域是空的。这是舞台将显示其场景的内容区域。因为您还没有舞台场景,所以您会看到一个空白区域。标题栏显示您在start()方法中设置的标题。

您可以使用窗口标题栏中的关闭菜单选项关闭应用。在 Windows 上使用 Alt + F4 关闭窗口。您可以使用平台提供的任何其他选项来关闭窗口。

Tip

直到所有窗口都关闭或者应用使用Platform.exit()方法退出,类Applicationlaunch()方法才返回。Platform级在javafx.application包里。

您还没有在 JavaFX 中看到任何令人兴奋的东西。你需要等待,直到你在下一部分创建一个场景。

添加 main()方法

如前一节所述,Java 8 启动器(java命令)不需要启动 JavaFX 应用的方法。如果您想要运行的类继承自Application类,java命令会通过自动调用Application.launch()方法来启动 JavaFX 应用。

如果使用 NetBeans IDE 创建 JavaFX 项目,并且通过运行 JavaFX 项目来运行应用,则不需要使用main()方法来启动 JavaFX 应用。但是,当您将 JavaFX 应用类作为文件运行时,NetBeans IDE 要求您有一个main()方法,例如,通过选择HelloFXApp文件,右键单击它,然后从菜单中选择Run File选项。

一些 ide 仍然需要main()方法来启动 JavaFX 应用。本章中的所有例子都将包括启动 JavaFX 应用的main()方法。

向舞台添加场景

这个类的一个实例在javafx.scene包中,代表一个场景。一个舞台包含一个场景。场景包含视觉内容。

场景的内容以树状层次排列。层次结构的顶部是根节点。根节点可能包含子节点,子节点又可能包含它们的子节点,依此类推。必须有根节点才能创建场景。您将使用一个VBox作为根节点。VBox代表垂直框,垂直排列其子项。

VBox root = new VBox();

Tip

javafx.scene.Parent类继承的任何节点都可以用作场景的根节点。几个节点,称为布局窗格或容器,例如,VBoxHBoxPaneFlowPaneGridPaneTilePane等。,可以用作根节点。Group是一个特殊的容器,将它的子容器组合在一起。

可以有子节点的节点提供了一个返回其子节点的ObservableList的方法。要向节点添加子节点,只需将子节点添加到ObservableList中。下面的代码片段向VBox添加了一个Text节点:

// Create a VBox node

VBox root = new VBox();

// Create a Text node

Text msg = new Text("Hello JavaFX");

// Add the Text node to the VBox as a child node

root.getChildren().add(msg);

Scene类包含几个构造函数。您将使用允许您指定场景的根节点和大小的那个。以下语句创建一个场景,以VBox作为根节点,宽度为 300 像素,高度为 50 像素:

// Create a scene

Scene scene = new Scene(root, 300, 50);

您需要通过调用Stage类的方法将场景设置为舞台。

// Set the scene to the stage

stage.setScene(scene);

就这样。您已经用一个场景完成了您的第一个 JavaFX 程序。清单 9-2 包含了完整的程序。程序显示如图 9-3 所示的窗口。

清单 9-2。JavaFX 应用,其场景包含一个文本节点

// HelloFXApp.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.scene.Scene;

import javafx.scene.layout.VBox;

import javafx.scene.text.Text;

import javafx.stage.Stage;

public class HelloFXApp extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

Text msg = new Text("Hello JavaFX");

VBox root = new VBox();

root.getChildren().add(msg);

Scene scene = new Scene(root, 300, 50);

stage.setScene(scene);

stage.setTitle("Hello JavaFX Application");

stage.show();

}

}

A978-1-4302-6662-4_9_Fig3_HTML.jpg

图 9-3。

A JavaFX application with scene having a Text node

改进 HelloFX 应用

JavaFX 能够做的事情比您到目前为止看到的要多得多。让我们增强第一个程序,并添加更多的用户界面元素,如按钮和文本字段。这一次,用户将能够与应用进行交互。使用Button类的实例创建一个按钮,如下所示:

// Create a button with "Exit" text

Button exitBtn = new Button("Exit");

当一个按钮被点击时,一个ActionEvent被触发。您可以添加一个ActionEvent处理程序来处理该事件。使用方法为按钮设置一个ActionEvent处理程序。下面的语句为按钮设置了一个ActionEvent处理程序。处理程序终止应用。您可以使用 lambda 表达式或匿名类来设置ActionEvent处理程序。以下代码片段展示了这两种方法:

// Using a lambda expression

exitBtn.setOnAction(e -> Platform.exit());

// Using an anonymous class

import javafx.event.ActionEvent;

import javafx.event.EventHandler;

...

exitBtn.setOnAction(new EventHandler<ActionEvent>() {

@Override

public void handle(ActionEvent e) {

Platform.exit();

}

});

清单 9-3 中的程序展示了如何在场景中添加更多的节点。该程序使用Label类的方法将Label的填充颜色设置为蓝色。稍后我将简要讨论在 JavaFX 中使用 CSS。

清单 9-3。在 JavaFX 应用中与用户交互

// ImprovedHelloFXApp.java

package com.jdojo.jfx;

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.control.TextField;

import javafx.scene.layout.VBox;

import javafx.stage.Stage;

public class ImprovedHelloFXApp extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

Label nameLbl = new Label("Enter your name:");

TextField nameFld = new TextField();

Label msg = new Label();

msg.setStyle("-fx-text-fill: blue;");

// Create buttons

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

Button exitBtn = new Button("Exit");

// Add the event handler for the Say Hello button

sayHelloBtn.setOnAction(e -> {

String name = nameFld.getText();

if (name.trim().length() > 0) {

msg.setText("Hello " + name);

}

else {

msg.setText("Hello there");

}

});

// Add the event handler for the Exit button

exitBtn.setOnAction(e -> Platform.exit());

// Create the root node

VBox root = new VBox();

// Set the vertical spacing between children to 5px

root.setSpacing(5);

// Add children to the root node

root.getChildren().addAll(nameLbl, nameFld, msg, sayHelloBtn, exitBtn);

Scene scene = new Scene(root, 350, 150);

stage.setScene(scene);

stage.setTitle("Improved Hello JavaFX Application");

stage.show();

}

}

改进后的 HelloFX 程序显示如图 9-4 所示的窗口。该窗口包含两个标签、一个文本字段和两个按钮。一个VBox被用作场景的根节点。在文本字段中输入一个名称,然后单击Say Hello按钮查看 hello 消息。在不输入姓名的情况下点击Say Hello按钮会显示信息Hello there。应用在一个Label控件中显示一条消息。点击Exit按钮退出应用。

A978-1-4302-6662-4_9_Fig4_HTML.jpg

图 9-4。

A JavaFX Application with two labels, a text field, and two buttons

JavaFX 应用的生命周期

JavaFX runtime 创建了几个线程,用于在应用的不同阶段执行不同的任务。在本节中,您只对那些在生命周期中用于调用Application类的方法的线程感兴趣。JavaFX 运行时在其他线程中创建了两个名为

  • Java FX-启动器
  • JavaFX 应用线程

Application类的方法创建这些线程。在 JavaFX 应用的生命周期中,JavaFX 运行时按顺序调用 JavaFX 应用类的以下方法:

  • 无参数构造函数
  • init()
  • start()
  • stop()

JavaFX 运行时在 JavaFX 应用线程上创建指定应用类的实例。

JavaFX-Launcher 线程调用应用类的方法。Application类中的init()方法实现为空。您可以在应用类中重写此方法。不允许在 JavaFX-Launcher 线程上创建StageScene。它们必须在 JavaFX 应用线程上创建。因此,不能在init()方法中创建StageScene。试图这样做将引发运行时异常。创建 UI 控件是很好的,例如,按钮、形状等。在init()方法中。

JavaFX 应用线程调用应用类的start(Stage stage)方法。注意,Application类中的方法被声明为abstract,您必须在您的应用类中覆盖这个方法。

此时,launch()方法等待 JavaFX 应用完成。

当应用完成时,JavaFX 应用线程调用应用类的方法。在Application类中,stop()方法的默认实现是空的。当应用停止时,您必须在应用类中重写此方法来执行您的逻辑。

清单 9-4 中的程序展示了 JavaFX 应用的生命周期。它显示一个阶段和一个Exit按钮。当显示 stage 时,您将看到输出的前三行。您需要点击Exit按钮来关闭该阶段,以查看输出的最后一行。

清单 9-4。JavaFX 应用的生命周期

// FXLifeCycleApp.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.application.Platform;

import javafx.scene.Group;

import javafx.scene.Scene;

import javafx.scene.control.Button;

import javafx.stage.Stage;

public class FXLifeCycleApp extends Application {

public FXLifeCycleApp() {

String name = Thread.currentThread().getName();

System.out.println("FXLifeCycleApp() constructor: " + name);

}

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void init() {

String name = Thread.currentThread().getName();

System.out.println("init() method: " + name);

}

@Override

public void start(Stage stage) {

String name = Thread.currentThread().getName();

System.out.println("start() method: " + name);

// Add an Exit button to the scene

Button exitBtn = new Button("Exit");

exitBtn.setOnAction(e -> Platform.exit());

Scene scene = new Scene(new Group(exitBtn), 300, 100);

stage.setScene(scene);

stage.setTitle("JavaFX Application Life Cycle");

stage.show();

}

@Override

public void stop() {

String name = Thread.currentThread().getName();

System.out.println("stop() method: " + name);

}

}

FXLifeCycleApp() constructor: JavaFX Application Thread

init() method: JavaFX-Launcher

start() method: JavaFX Application Thread

stop() method: JavaFX Application Thread

终止 JavaFX 应用

JavaFX 应用可以显式或隐式终止。您可以通过调用Platform.exit()方法显式终止 JavaFX 应用。当这个方法被调用时,在start()方法之后或者从该方法内部,调用Application类的stop()方法,然后 JavaFX 应用线程被终止。此时,如果只有守护线程在运行,JVM 将退出。如果从构造函数或Application类的init()方法调用该方法,则stop()方法可能不会被调用。

Tip

JavaFX 应用可以在 web 浏览器中运行。在 web 环境中调用Platform.exit()方法可能没有任何效果。

当最后一个窗口关闭时,JavaFX 应用可以隐式终止。使用Platform类的静态setImplicitExit(boolean implicitExit)方法可以打开或关闭这种行为。将 true 传递给此方法将打开此行为。将 false 传递给此方法会关闭此行为。默认情况下,此行为是打开的。这就是为什么在迄今为止的大多数例子中,当你关闭窗口时,你的应用被终止。当这个行为打开时,在终止 JavaFX 应用线程之前,调用Application类的stop()方法。终止 JavaFX 应用线程并不总是会终止 JVM。如果所有正在运行的非守护进程线程都终止了,JVM 也会终止。如果 JavaFX 应用的隐式终止行为被关闭,您必须调用Platform类的exit()方法来终止应用。

什么是属性和绑定?

属性是影响类的状态和/或行为的类的公共可访问属性。即使属性是可公开访问的,它的使用(读/写)也会调用隐藏实际实现的方法来访问数据。属性是可观察的,所以当它的值改变时,感兴趣的人会得到通知。属性可以是只读、只写或读写的。只读属性有 getter,但没有 setter。只写属性有 setter,但没有 getter。读写属性有一个 getter 和一个 setter。

与 C#等其他编程语言不同,Java 中的属性在语言级别不受支持。Java 对属性的支持来自 JavaBeans API 和设计模式。关于 Java 中属性的更多细节,请参考 JavaBeans 规范,可以从 www.oracle.com/technetwork/java/javase/documentation/spec-136004.html 下载。

在编程中,术语绑定在许多上下文中使用。在这里,我想在数据绑定的上下文中定义它。数据绑定定义了程序中数据元素(通常是变量)之间的关系,以保持它们的同步。在 GUI 应用中,数据绑定经常用于将数据模型中的元素与相应的 UI 元素同步。考虑以下语句,假设 x、y 和 z 是数值变量:

x = y + z;

该语句定义 x、y 和 z 之间的绑定。当执行该语句时,x 的值与 y 和 z 的值之和同步。绑定还具有时间因子。在上面的语句中,x 的值被绑定到 y 和 z 的和,并且在语句执行时有效。在执行语句之前和之后,x 的值可能不等于 y 和 z 的总和。有时,希望绑定持续一段时间。考虑以下使用listPricediscountstaxes定义绑定的语句:

soldPrice = listPrice - discounts + taxes;

在这种情况下,您希望保持绑定永远有效,这样无论何时listPrice, discountstaxes发生变化,都会正确计算销售价格。在这个绑定中,listPrice, discountstaxes被称为依赖关系,据说soldPrice被绑定到listPricediscountstaxes

为了使绑定正常工作,有必要在依赖关系发生变化时通知它。支持绑定的编程语言提供了一种用依赖关系注册侦听器的机制。当依赖关系变得无效或发生变化时,所有侦听器都会得到通知。当绑定接收到这样的通知时,它可以将其自身与其依赖项同步。

绑定可以是急切绑定,也可以是懒惰绑定。在急切绑定中,当依赖关系发生变化时,会立即重新计算绑定变量。在惰性绑定中,当绑定变量的依赖关系改变时,不会重新计算绑定变量;下次读取其值时会重新计算。与急切绑定相比,惰性绑定的性能更好。

绑定可以是单向的也可以是双向的。单向绑定只在一个方向上起作用:依赖关系的变化会传播到绑定的变量。双向绑定在两个方向上都起作用,其中绑定变量和依赖项保持它们的值彼此同步。通常,双向绑定只在两个变量之间定义。例如,一个双向绑定,x = yy = x,声明xy的值总是相同的。

从数学上讲,不可能唯一地定义多个变量之间的双向绑定。在上面的例子中,售出价格绑定是单向绑定。如果您想使它成为一个双向绑定,那么当销售价格发生变化时,不可能唯一地计算标价、折扣和税的值。在另一个方向有无限多的可能性。

带有 GUI 的应用为用户提供 UI 小部件,如文本字段、复选框、按钮等。,来操作数据。UI 小部件中显示的数据必须与底层数据模型同步,反之亦然。在这种情况下,需要双向绑定来保持 UI 和数据模型同步。

JavaFX 中的属性和绑定

JavaFX 通过属性和绑定 API 支持属性、事件和绑定。JavaFX 中对属性的支持是 JavaBeans 属性的一大进步。JavaFX 中的所有属性都是可观察的。可以观察到它们的失效和值的变化。您可以拥有读写或只读属性。所有读写属性都支持绑定。JavaFX 中的属性可以表示一个值或一组值。

在 JavaFX 中,属性是对象。每种属性都有一个属性类层次结构。例如,IntegerPropertyDoublePropertyStringProperty类分别代表intdoubleString类型的属性。这些类是abstract。它们有两种类型的实现类:一种表示读写属性,另一种表示只读属性的包装。例如,SimpleDoublePropertyReadOnlyDoubleWrapper类是具体的类,它们的对象分别用作类型double的读写和只读属性。以下是如何创建初始值为 100 的IntegerProperty的示例:

IntegerProperty counter = new SimpleIntegerProperty(100);

属性类提供两对 getter 和 setter 方法:

  • get()set()方法
  • getValue()setValue()方法

方法分别获取和设置属性的值。对于基本类型属性,它们使用基本类型值。比如对于IntegerPropertyget()方法的返回类型和set()方法的参数类型都是intgetValue()setValue()方法处理对象类型;比如它们的返回类型和参数类型都是IntegerPropertyInteger

对于引用类型属性,比如StringPropertyObjectProperty<T>,两对 getter 和 setter 都使用对象类型。也就是说,StringPropertyget()getValue()方法都返回一个String,而set()setValue()方法都带有一个String参数。对于基本类型的自动装箱,使用 getter 和 setter 的哪个版本并不重要。getValue()setValue()方法的存在是为了帮助你根据对象类型编写通用代码。

以下代码片段使用了一个及其get()set()方法。该属性是一个读写属性,因为它是SimpleIntegerProperty类的对象。

IntegerProperty counter = new SimpleIntegerProperty(1);

int counterValue = counter.get();

System.out.println("Counter:" + counterValue);

counter.set(2);

counterValue = counter.get();

System.out.println("Counter:" + counterValue);

Counter:1

Counter:2

使用只读属性有点棘手。一个ReadOnlyXxxWrapper类包装了Xxx类型的两个属性:一个只读,一个读写。两种属性都是同步的。它的方法返回一个ReadOnlyXxxProperty对象。下面的代码片段显示了如何创建一个只读的Integer属性:

// Create a read-only wrapper property

ReadOnlyIntegerWrapper idWrapper = new ReadOnlyIntegerWrapper(100);

// Get the read-only version of the read-only wrapper property object

ReadOnlyIntegerProperty id = idWrapper.getReadOnlyProperty();

System.out.println("idWrapper:" + idWrapper.get());

System.out.println("id:" + id.get());

// Change the value

idWrapper.set(101);

System.out.println("idWrapper:" + idWrapper.get());

System.out.println("id:" + id.get());

idWrapper:100

id:100

idWrapper:101

id:101

该属性是读写的,而id属性是只读的。当idWrapper中的值改变时,id中的值自动改变。要在类中定义一个只读属性,需要将idWrapper声明为私有实例变量。如果类外需要它的值,则返回id,这样外界可以读取该值,但不能更改它。

Tip

通常,包装属性用作类的私有实例变量。类别可以在内部变更属性。它的一个方法返回包装类的只读属性对象,因此同一个属性对于外界是只读的。

可以使用代表单个值的七种类型的属性。这些属性的基类被命名为XxxProperty,只读基类被命名为ReadOnlyXxxProperty,包装类被命名为ReadOnlyXxxWrapper。每种类型的Xxx值列于表 9-2 中。

表 9-2。

Property Classes That Wrap a Single Value

| 类型 | Xxx 值 | | --- | --- | | `int` | `Integer` | | `long` | `Long` | | `float` | `Float` | | `double` | `Double` | | `boolean` | `Boolean` | | `String` | `String` | | `Object` | `Object` |

属性对象包装了三条信息:

  • 包含它的 bean 的引用
  • 一个名字
  • 一种价值观

当您创建属性对象时,您可以提供以上三条信息中的全部信息,也可以不提供。像SimpleXxxPropertyReadOnlyXxxWrapper这样命名的具体属性类提供了四个构造函数,让您提供这三条信息的组合。下面是该类的构造函数:

  • SimpleIntegerProperty()
  • SimpleIntegerProperty(int initialValue)
  • SimpleIntegerProperty(Object bean, String name)
  • SimpleIntegerProperty(Object bean, String name, int initialValue)

初始值的默认值取决于属性的类型。对于数值类型是零,false对于boolean类型是零,null对于引用类型是零。

属性对象可以是 bean 的一部分,也可以是独立的对象。指定的bean是对包含该属性的 bean 对象的引用。对于独立的属性对象,可以是null。其默认值为null

属性的名字就是它的名字。如果未提供,则默认为空字符串。

下面的代码片段创建一个属性对象作为 bean 的一部分,并设置所有三个值。SimpleStringProperty类的构造函数的第一个参数是this,它是Person bean 的引用;第二个参数,"name",是属性的名称;"Li",还有第三个论点,就是财产的价值。

public class Person {

private StringProperty name = new SimpleStringProperty(this, "name", "Li");

// More code for the Person goes here

}

每个属性类都包含分别返回 bean 引用和属性名的getBean()getName()方法。

在 JavaFX Beans 中使用属性

在上一节中,您看到了 JavaFX 属性作为独立对象的使用。在本节中,您将在类中使用它们来定义属性。让我们创建一个具有三个属性(ISBNtitleprice))的Book类,将使用 JavaFX 属性类对其进行建模。

在 JavaFX 中,不将类的属性声明为基本类型之一。相反,您使用 JavaFX 属性类之一。Book类的title属性将声明如下。照常宣布private

public class Book {

private StringProperty title =

new SimpleStringProperty(this, "title", "Unknown");

}

您为属性声明了一个public getter,按照惯例,它被命名为XxxProperty,其中Xxx是属性的名称。getter 返回属性的引用。对于您的title属性,getter 将被命名为titleProperty,如图所示:

public class Book {

private StringProperty title =

new SimpleStringProperty(this, "title", "Unknown");

public final StringProperty titleProperty() {

return title;

}

}

如下面设置和获取书名的代码片段所示,Book类的声明可以很好地处理title属性:

Book beginningJava8 = new Book();

beginningJava8.titleProperty().set("Beginning Java 8");

String title = beginningJava8.titleProperty().get();

根据 JavaFX 设计模式(不是针对任何技术需求),JavaFX 属性有一个 getter 和 setter,类似于 JavaBeans 中的 getter 和 setter。getter 的返回类型和 setter 的参数类型与属性值的类型相同。title属性的getTitle()setTitle()方法声明如下:

public class Book {

private StringProperty title =

new SimpleStringProperty(this, "title", "Unknown");

public final StringProperty titleProperty() {

return title;

}

public final String getTitle() {

return title.get();

}

public final void setTitle(String title) {

this.title.set(title);

}

}

注意,getTitle()setTitle()方法在内部使用title属性对象来获取和设置标题值。

Tip

在 JavaFX 中,按照惯例,类的属性的 getters 和 setters 被声明为final。添加了使用 JavaBeans 命名约定的额外的 getters 和 setters,以使该类能够与使用旧 JavaBeans 命名约定来标识类属性的旧工具和框架进行互操作。

以下代码片段显示了对Book类的只读ISBN属性的声明:

public class Book {

private ReadOnlyStringWrapper ISBN =

new ReadOnlyStringWrapper(this, "ISBN", "Unknown");

public final String getISBN() {

return ISBN.get();

}

public final ReadOnlyStringProperty ISBNProperty() {

return ISBN.getReadOnlyProperty();

}

// More code for the Book class goes here

}

关于只读ISBN属性的声明,请注意以下几点:

  • 它使用了ReadOnlyStringWrapper类而不是SimpleStringProperty类。
  • 属性值没有设置器。你可以声明一个;但是,必须是私人的。
  • 属性值的 getter 与读写属性的 getter 工作方式相同。
  • ISBNProperty()方法使用ReadOnlyStringProperty作为返回类型,而不是ReadOnlyStringWrapper。它从包装对象获取属性对象的只读版本,并返回该版本。

对于Book类的用户,它的ISBN属性是只读的。但是,它可以在内部进行更改,并且该更改将自动反映在 property 对象的只读版本中。清单 9-5 显示了Book类的完整代码。

清单 9-5。具有一个只读属性和两个读写属性的 Book 类

// Book.java

package com.jdojo.jfx;

import javafx.beans.property.DoubleProperty;

import javafx.beans.property.ReadOnlyStringProperty;

import javafx.beans.property.ReadOnlyStringWrapper;

import javafx.beans.property.SimpleDoubleProperty;

import javafx.beans.property.SimpleStringProperty;

import javafx.beans.property.StringProperty;

public class Book {

private StringProperty title =

new SimpleStringProperty(this, "title", "Unknown");

private DoubleProperty price =

new SimpleDoubleProperty(this, "price", 0.0);

private ReadOnlyStringWrapper ISBN =

new ReadOnlyStringWrapper(this, "ISBN", "Unknown");

public Book() {

}

public Book(String title, double price, String ISBN) {

this.title.set(title);

this.price.set(price);

this.ISBN.set(ISBN);

}

public final String getTitle() {

return title.get();

}

public final void setTitle(String title) {

this.title.set(title);

}

public final StringProperty titleProperty() {

return title;

}

public final double getprice() {

return price.get();

}

public final void setPrice(double price) {

this.price.set(price);

}

public final DoubleProperty priceProperty() {

return price;

}

public final String getISBN() {

return ISBN.get();

}

public final ReadOnlyStringProperty ISBNProperty() {

return ISBN.getReadOnlyProperty();

}

}

清单 9-6 测试了Book类的属性。它创建一个Book对象,打印细节,更改一些属性,然后再次打印细节。注意该方法使用了ReadOnlyProperty参数类型。所有属性类都直接或间接地实现了ReadOnlyProperty接口。

属性实现类的方法返回一个格式良好的字符串,该字符串包含属性的所有相关信息。我没有使用 property 对象的toString()方法,因为我想向您展示 JavaFX 属性的不同方法的用法。

清单 9-6。一个 BookPropertyTest 类来测试 Book 类的属性

// BookPropertyTest.java

package com.jdojo.jfx;

import javafx.beans.property.ReadOnlyProperty;

public class BookPropertyTest {

public static void main(String[] args) {

Book book = new Book("Beginning Java 8", 49.99, "1430266619");

System.out.println("After creating the Book object...");

// Print Property details

printDetails(book.titleProperty());

printDetails(book.priceProperty());

printDetails(book.ISBNProperty());

// Change the book's properties

book.setTitle("Harnessing JavaFX 8.0");

book.setPrice(9.49);

System.out.println("\nAfter changing the Book properties...");

// Print Property details

printDetails(book.titleProperty());

printDetails(book.priceProperty());

printDetails(book.ISBNProperty());

}

public static void printDetails(ReadOnlyProperty<?> p) {

String name = p.getName();

Object value = p.getValue();

Object bean = p.getBean();

String beanClassName =

(bean == null) ? "null" : bean.getClass().getSimpleName();

String propClassName = p.getClass().getSimpleName();

System.out.print(propClassName);

System.out.print("[Name:" + name);

System.out.print(", Bean Class:" + beanClassName);

System.out.println(", Value:" + value + "]");

}

}

After creating the Book object...

SimpleStringProperty[Name:title, Bean Class:Book, Value:Beginning Java 8]

SimpleDoubleProperty[Name:price, Bean Class:Book, Value:49.99]

ReadOnlyPropertyImpl[Name:ISBN, Bean Class:Book, Value:1430266619]

After changing the Book properties...

SimpleStringProperty[Name:title, Bean Class:Book, Value:Harnessing JavaFX 8.0]

SimpleDoubleProperty[Name:price, Bean Class:Book, Value:9.49]

ReadOnlyPropertyImpl[Name:ISBN, Bean Class:Book, Value:1430266619]

处理属性失效事件

当属性值的状态第一次从有效变为无效时,属性会生成一个无效事件。JavaFX 中的属性使用惰性计算。当已经无效的属性由于其值的状态再次改变而再次变得无效时,不会生成无效事件。无效属性在重新计算时变得有效,例如通过调用属性的get()getValue()方法。

清单 9-7 是一个程序,它演示了什么时候为属性生成失效事件。该程序包括足够的注释来帮助你理解其中的逻辑。

清单 9-7。测试 JavaFX 属性的失效事件

// InvalidationTest.java

package com.jdojo.jfx;

import javafx.beans.Observable;

import javafx.beans.property.IntegerProperty;

import javafx.beans.property.SimpleIntegerProperty;

public class InvalidationTest {

public static void main(String[] args) {

// Create a property

IntegerProperty counter = new SimpleIntegerProperty(100);

// Add an invalidation listener to the counter property using a

// method reference. The invalidated() method of thi class will

// be called when the counter property becomes invalid..

counter.addListener(InvalidationTest::invalidated);

System.out.println("Before changing the counter value-1");

counter.set(101);

System.out.println("After changing the counter value-1");

/*

* At this point counter property is invalid and further changes

* to its value will not generate any invalidation events.

*/

System.out.println();

System.out.println("Before changing the counter value-2");

counter.set(102);

System.out.println("After changing the counter value-2");

// Make the counter property valid by calling its get() method

int value = counter.get();

System.out.println("Counter value = " + value);

/* At this point the counter property is valid and further changes

to its value will generate invalidation events.

*/

// Try setting the same value

System.out.println();

System.out.println("Before changing the counter value-3");

counter.set(102);

System.out.println("After changing the counter value-3");

// Try setting a different value

System.out.println();

System.out.println("Before changing the counter value-4");

counter.set(103);

System.out.println("After changing the counter value-4");

}

public static void invalidated(Observable prop) {

System.out.println("Counter is invalid.");

}

}

Before changing the counter value-1

Counter is invalid.

After changing the counter value-1

Before changing the counter value-2

After changing the counter value-2

Counter value = 102

Before changing the counter value-3

After changing the counter value-3

Before changing the counter value-4

Counter is invalid.

After changing the counter value-4

一开始,程序创建一个名为counterIntegerProperty,并向属性添加一个无效监听器。

// Create the counter property

IntegerProperty counter = new SimpleIntegerProperty(100);

// Add an invalidation listener to the counter proeprty

counter.addListener(InvalidationTest::invalidated);

当您创建属性对象时,它是有效的。当您将counter属性更改为 101 时,它会触发一个失效事件。此时,counter属性变得无效。当您将其值更改为 102 时,它不会触发无效事件,因为它已经无效。您使用get()方法读取counter值,使其再次有效。现在,您将同样的 a 值 102 设置为counter,这不会触发一个无效事件,因为该值并没有真正改变;它的值已经是 102 了。counter属性仍然有效。最后,您将它的值改为一个不同的值,果然,一个无效事件被触发。

Tip

您并不局限于在一个属性中只添加一个失效侦听器。您可以根据需要添加任意数量的失效侦听器。如果不再需要失效侦听器,请确保通过调用属性的方法移除它。否则,可能会导致内存泄漏。

处理属性更改事件

您可以注册一个ChangeListener来接收关于属性更改事件的通知。每次属性值更改时,都会触发属性更改事件。一个ChangeListenerchanged()方法接收三个值:

  • 属性对象的引用
  • 财产的旧价值
  • 属性的新值

您将运行一个类似的测试用例来测试属性变更事件,就像您在上一节中对失效事件所做的那样。清单 9-8 包含的程序演示了为属性生成的变更事件。

清单 9-8。测试 JavaFX 属性的更改事件

// ChangeTest.java

package com.jdojo.jfx;

import javafx.beans.property.IntegerProperty;

import javafx.beans.property.SimpleIntegerProperty;

import javafx.beans.value.ObservableValue;

public class ChangeTest {

public static void main(String[] args) {

// Create a counter property

IntegerProperty counter = new SimpleIntegerProperty(100);

// Add a change listener to the counter property

counter.addListener(ChangeTest::changed);

System.out.println("Before changing the counter value-1");

counter.set(101);

System.out.println("After changing the counter value-1");

System.out.println();

System.out.println("Before changing the counter value-2");

counter.set(102);

System.out.println("After changing the counter value-2");

// Try setting the same value

System.out.println();

System.out.println("Before changing the counter value-3");

counter.set(102); // No change event will be fired.

System.out.println("After changing the counter value-3");

// Try setting a different value

System.out.println();

System.out.println("Before changing the counter value-4");

counter.set(103);

System.out.println("After changing the counter value-4");

}

public static void changed(ObservableValue<? extends Number> prop,

Number oldValue,

Number newValue) {

System.out.print("Counter changed: ");

System.out.println("Old = " + oldValue + ", new = " + newValue);

}

}

Before changing the counter value-1

Counter changed: Old = 100, new = 101

After changing the counter value-1

Before changing the counter value-2

Counter changed: Old = 101, new = 102

After changing the counter value-2

Before changing the counter value-3

After changing the counter value-3

Before changing the counter value-4

Counter changed: Old = 102, new = 103

After changing the counter value-4

一开始,程序创建一个名为counterIntegerProperty

// Create a counter property

IntegerProperty counter = new SimpleIntegerProperty(100);

加个ChangeListener有点小技巧。IntegerPropertyBase类中的addListener()方法声明如下:

  • void addListener(ChangeListener<? super Number> listener)

如果使用泛型,那么IntegerPropertyChangeListener必须按照Number类或者Number类的超类来编写。将ChangeListener添加到counter属性的三种方法如下。代码使用了匿名类,我将在最后将其转换成 lambda 表达式。

// Method-1: Using generics and the Number class

counter.addListener(new ChangeListener<Number>() {

@Override

public void changed(ObservableValue<? extends Number> prop,

Number oldValue,

Number newValue) {

System.out.print("Counter changed: ");

System.out.println("Old = " + oldValue + ", new = " + newValue);

}});

// Method-2: Using generics and the Object class

counter.addListener(new ChangeListener<Object>() {

@Override

public void changed(ObservableValue<? extends Object> prop,

Object oldValue,

Object newValue) {

System.out.print("Counter changed: ");

System.out.println("Old = " + oldValue + ", new = " + newValue);

}});

// Method-3: Not using generics. It may generate compile-time warnings.

counter.addListener(new ChangeListener() {

@Override

public void changed(ObservableValue prop,

Object oldValue,

Object newValue) {

System.out.print("Counter changed: ");

System.out.println("Old = " + oldValue + ", new = " + newValue);

}});

清单 9-8 使用了第一种利用泛型的方法;如您所见,ChangeTest类中的changed()方法的签名与方法-1 中的changed()方法签名相匹配。您已经使用带有方法引用的 lambda 表达式添加了一个ChangeListener,如下所示:

// Add a change listener using a method reference

counter.addListener(ChangeTest::changed);

输出显示,当属性值更改时,会触发属性更改事件。用相同的值调用set()方法不会触发属性更改事件。

与生成失效事件不同,属性使用对其值的急切评估来生成更改事件,因为它必须将新值传递给属性更改侦听器。

JavaFX 中的属性绑定

在 JavaFX 中,绑定是一个计算结果为值的表达式。绑定由一个或多个被称为依赖项的可观察值组成。绑定观察其依赖关系的变化,并在需要时自动重新计算其值。JavaFX 对所有绑定都使用惰性求值。当绑定最初被定义或者当它的依赖关系改变时,它的值被标记为无效。无效绑定的值在下次被请求时计算,通常使用它的get()getValue()方法。JavaFX 中的所有属性类都内置了对绑定的支持。

让我们讨论一个 JavaFX 中绑定的简单例子。考虑以下表示两个整数xy之和的表达式:

x + y

表达式x + y表示一个绑定,它有两个依赖项,xy。你可以给它起一个名字sum如下:

sum = x + y

为了在 JavaFX 中实现上述逻辑,您创建了两个IntegerProperty变量,xy:

IntegerProperty x = new SimpleIntegerProperty(100);

IntegerProperty y = new SimpleIntegerProperty(200);

以下语句创建了一个名为sum的绑定,表示xy的总和:

NumberBinding sum = x.add(y);

一个绑定有一个返回true的方法,如果它是有效的;否则返回false。您可以使用方法intValue()longValue()floatValue()doubleValue()分别获得Number的值,如intlongfloatdouble。清单 9-9 中的程序展示了如何创建和使用一个绑定。

清单 9-9。在 JavaFX 中使用简单绑定

// BindingTest.java

package com.jdojo.jfx;

import javafx.beans.binding.NumberBinding;

import javafx.beans.property.IntegerProperty;

import javafx.beans.property.SimpleIntegerProperty;

public class BindingTest {

public static void main(String[] args) {

// Create two properties x and y

IntegerProperty x = new SimpleIntegerProperty(100);

IntegerProperty y = new SimpleIntegerProperty(200);

// Create a binding: sum = x + y

NumberBinding sum = x.add(y);

System.out.println("After creating sum");

System.out.println("sum.isValid(): " + sum.isValid());

// Let us get the value of sum, so it computes its value and

// becomes valid

int value = sum.intValue();

System.out.println();

System.out.println("After requesting value");

System.out.println("sum.isValid(): " + sum.isValid());

System.out.println("sum = " + value);

// Change the value of x

x.set(250);

System.out.println();

System.out.println("After changing x");

System.out.println("sum.isValid(): " + sum.isValid());

// Get the value of sum again

value = sum.intValue();

System.out.println();

System.out.println("After requesting value");

System.out.println("sum.isValid(): " + sum.isValid());

System.out.println("sum = " + value);

}

}

After creating sum

sum.isValid(): false

After requesting value

sum.isValid(): true

sum = 300

After changing x

sum.isValid(): false

After requesting value

sum.isValid(): true

sum = 450

sum绑定被创建时,它是无效的,并且不知道它的值。从输出中可以明显看出这一点。一旦您使用sum.intValue()方法请求了它的值,它就会计算它的值并将自己标记为有效。当您更改它的一个依赖项时,它将变得无效,直到您再次请求它的值。

Tip

绑定在内部将失效侦听器添加到它的所有依赖项中。当它的任何依赖项无效时,它会将自己标记为无效。无效的绑定并不意味着它的值已经改变。这意味着下次请求值时,它需要重新计算它的值。

在 JavaFX 中,还可以将属性绑定到绑定。回想一下,绑定是一个自动与其依赖项同步的表达式。使用此定义,绑定属性是其值基于表达式计算的属性,当依赖关系更改时,该属性会自动同步。假设您有三个称为 x、y 和 z 的属性,如下所示:

IntegerProperty x = new SimpleIntegerProperty(10);

IntegerProperty y = new SimpleIntegerProperty(20);

IntegerProperty z = new SimpleIntegerProperty(60);

可以使用Property接口的bind()方法将属性z绑定到表达式x + y,,如下所示:

// Bind z to x + y

z.bind(x.add(y));

请注意,您不能编写z.bind(x + y),因为+操作符不知道如何将两个IntegerProperty对象的值相加。您需要使用绑定 API 来创建绑定表达式。

现在,当xy或两者都改变时,z属性无效。下次请求z的值时,它会重新计算表达式x.add(y)来获得它的值。

您可以使用Property接口的方法来解除绑定属性。对未绑定或从未绑定的属性调用unbind()方法没有任何效果。您可以解除z属性的绑定,如下所示:

// Unbind the z proeprty

z.unbind();

解除绑定后,属性表现为普通属性,独立保持其值。换句话说,解除属性绑定会断开属性与其依赖项之间的链接。清单 9-10 显示了如何将一个属性绑定到一个由其他属性组成的表达式。

清单 9-10。将属性绑定到表达式

// BoundProperty.java

package com.jdojo.jfx;

import javafx.beans.property.IntegerProperty;

import javafx.beans.property.SimpleIntegerProperty;

public class BoundProperty {

public static void main(String[] args) {

// Create three properties

IntegerProperty x = new SimpleIntegerProperty(10);

IntegerProperty y = new SimpleIntegerProperty(20);

IntegerProperty z = new SimpleIntegerProperty(60);

// Create the binding z = x + y

z.bind(x.add(y));

System.out.println("After binding z: Bound = " +

z.isBound() + ", z = " + z.get());

// Change x and y

x.set(15);

y.set(19);

System.out.println("After changing x and y: Bound = " +

z.isBound() + ", z = " + z.get());

// Unbind z

z.unbind();

// Will not affect the value of z as it is not bound

// to x and y anymore

x.set(100);

y.set(200);

System.out.println("After unbinding z: Bound = " +

z.isBound() + ", z = " + z.get());

}

}

After binding z: Bound = true, z = 30

After changing x and y: Bound = true, z = 34

After unbinding z: Bound = false, z = 34

绑定有一个方向,即传播更改的方向。JavaFX 支持两种类型的属性绑定:单向绑定和双向绑定。单向绑定只在一个方向起作用;依赖项中的更改会传播到绑定属性,反之亦然。双向绑定在两个方向上都起作用;依赖项的更改反映在属性中,反之亦然。

接口Propertybind()方法在属性和ObservableValue之间创建了一个单向绑定,这可能是一个复杂的表达式。bindBidirectional()方法在一个属性和同类型的另一个属性之间创建一个双向绑定。

上例中的语句z.bind(x.add(y))创建了一个单向绑定。在单向绑定中,不能更改 bind 属性。它的值总是使用它的依赖项来计算。试图更改单向绑定属性的值会引发RuntimeException

双向绑定在两个方向上都起作用。它有一些限制。它只能在相同类型的属性之间创建。也就是说,双向绑定只能是 x = y 和 y = x 类型,其中 x 和 y 属于同一类型。

// Create two properties called x and y

IntegerProperty x = new SimpleIntegerProperty(10);

IntegerProperty y = new SimpleIntegerProperty(20);

// Create bidirectional binding between x and y

x.bindBidirectional(y);

// Now, both x and y are 20\. The values and x and y are

// always the same when x or y is changed.

// Remove the bidirectional binding between x and y

x.unbindBidirectional(y);

// Now, x and y maintain their values independent of each other.

JavaFX 应用中经常使用绑定来将 UI 元素的属性绑定到其他 UI 元素的属性或数据模型。让我们看一个使用绑定的 JavaFX GUI 应用的例子。您将创建一个带有一个以屏幕为中心的圆的屏幕。圆的周长将接触屏幕的较近的边。如果屏幕的宽度和高度相同,那么圆周将接触屏幕的所有四个边。

试图开发一个没有绑定的中心圆的屏幕是一项单调乏味的任务。javafx.scene.shape包中的Circle类代表一个圆。它有三个属性,DoubleProperty类型的centerXcenterYradiuscenterXcenterY属性定义了圆心的(x,y)坐标。radius属性定义了圆的半径。默认情况下,圆用黑色填充。将centerXcenterYradius设置为默认值 0.0,创建一个圆,如下所示:

Circle c = new Circle();

接下来,将圆添加到一个组中,并以该组作为其根节点创建一个场景,如下所示:

Group root = new Group(c);

Scene scene = new Scene(root, 150, 150);

以下绑定将根据场景的大小来定位和调整圆的大小:

// The center of the circle is always in the center of the scene

c.centerXProperty().bind(scene.widthProperty().divide(2));

c.centerYProperty().bind(scene.heightProperty().divide(2));

// The radius of the circle will be always the half of the minimum

// of the width and height of the scene

c.radiusProperty().bind(Bindings.min(scene.widthProperty(), scene.heightProperty())

.divide(2));

前两个绑定将圆的centerXcenterY分别绑定到场景的宽度和高度的中间。第三个绑定将圆的radius绑定到场景最小宽度和高度的一半(见divide(2))。就这样!当应用运行时,绑定 API 具有保持圆圈居中的魔力。

清单 9-11 包含了完整的程序。图 9-5 显示程序初始运行时的屏幕。尝试调整窗口大小,你会注意到圆心总是在场景的中间。

清单 9-11。使用绑定 API 保持一个以场景为中心的圆

// CenteredCircle.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.beans.binding.Bindings;

import javafx.scene.Group;

import javafx.scene.Scene;

import javafx.scene.shape.Circle;

import javafx.stage.Stage;

public class CenteredCircle extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

Circle c = new Circle();

Group root = new Group(c);

Scene scene = new Scene(root, 100, 100);

// Bind circle's centerX, centerY, and radius

// to scene's properties

c.centerXProperty().bind(scene.widthProperty().divide(2));

c.centerYProperty().bind(scene.heightProperty().divide(2));

c.radiusProperty().bind(Bindings.min(scene.widthProperty(),

scene.heightProperty())

.divide(2));

// Set the stage properties and make it visible

stage.setTitle("A Centered Circle");

stage.setScene(scene);

stage.sizeToScene();

stage.show();

}

}

A978-1-4302-6662-4_9_Fig5_HTML.jpg

图 9-5。

A circle centered on the scene

可观察的集合

JavaFX 中的可观察集合是 Java 编程语言中集合的扩展。Java 中的集合框架有ListSetMap接口。JavaFX 添加了以下三种类型的可观察集合,可以观察到它们内容的变化:

  • 可观察的列表
  • 可观察的集合
  • 可观察的地图

JavaFX 通过以下三个新接口支持可观察集合:

  • 可观察列表
  • 可观察集
  • 可观察地图

这三个接口从java.util包中继承了ListSetMap。除了从 Java 集合接口继承之外,JavaFX 集合接口还继承了Observable接口。所有 JavaFX 可观察集合接口和类都在javafx.collections包中。图 9-6 显示了表示可观察集合的接口的部分类图。

A978-1-4302-6662-4_9_Fig6_HTML.jpg

图 9-6。

A partial class diagram for observable collection interfaces in JavaFX

JavaFX 中的可观察集合有两个额外的特性:

  • 它们支持失效通知,因为它们是从Observable接口继承的。
  • 它们支持更改通知。您可以注册更改侦听器,当其内容发生更改时会得到通知。

FXCollections类是一个使用 JavaFX 集合的实用程序类。它由所有静态方法组成。JavaFX 不公开列表、集合和映射的实现类。您需要使用FXCollections类中的一个工厂方法来创建ObservableListObservableSetObservableMap接口的对象。以下代码片段显示了如何创建可观察集合:

// Create an observable list with two elements

ObservableList<String> list = FXCollections.observableArrayList("One", "Two");

// Create an observable set with two elements

ObservableSet<String> set = FXCollections.observableSet("one", "two");

// Create an observable map and two key-value pairs

ObservableMap<String, Integer> map = FXCollections.observableHashMap();

map.put("one", 1);

map.put("two", 2);

您可以向可观察的集合添加失效和更改侦听器。将一个InvalidationListener添加到可观察集合与将一个InvalidationListener添加到您在上一节看到的属性是一样的。每种类型的可观察集合都有自己的更改监听器类型:

  • ListChangeListener接口的一个实例代表了一个ObservableList的变更监听器。
  • SetChangeListener接口的一个实例代表了一个ObservableSet的变更监听器。
  • MapChangeListener接口的一个实例代表了一个ObservableMap的变更监听器。

使用可观察集合的方法向它们添加更改侦听器。所有可观察集合的变更监听器接口都声明了一个名为Change的静态内部类,它封装了相应类型集合中的变更。例如,您有一个ListChangeListener.Change静态内部类来封装一个ObservableList中的变化。向变更监听器传递一个Change类的实例。你需要使用Change类的方法来迭代所有的修改。Change类包含几个方法来提供对特定集合所做更改的细节。以下代码片段显示了如何将更改侦听器添加到ObservableList和:

// Create an observable list with two elements

ObservableList<String> list = FXCollections.observableArrayList("One", "Two");

// Add a change listener to the list

list.addListener((ListChangeListener.Change<? extends String> change) -> {

System.out.println("The list has changed.");

});

// Create an observable set

ObservableSet<String> set = FXCollections.observableSet("one", "two");

// Add a change listener to the set

set.addListener((SetChangeListener.Change<? extends String> change) -> {

System.out.println("The list has changed.");

});

让我们看一个如何处理ObservableList中的变化的详细例子。观察一个ObservableList的变化有点棘手。列表可以有多种变化。有些变化可能是排他性的,而有些变化可能与其他变化一起发生。列表中的元素可以被置换、更新、替换、添加和删除。您可以使用它的方法向一个ObservableList添加一个变更监听器,该方法接受一个ListChangeListener接口的实例。每次列表发生变化时,都会调用侦听器的方法。下面的代码片段展示了如何向ObservableList<String>添加一个变更监听器。onChanged()方法简单;当它被通知一个改变时,它在标准输出上打印一个消息。

// Create an observable list

ObservableList<String> list = FXCollections.observableArrayList();

// Add a change listener to the list

list.addListener((ListChangeListener.Change<? extends String> change) -> {

System.out.println("List has changed.");

});

清单 9-12 包含了一个完整的程序,展示了如何检测一个ObservableList的变化。添加更改侦听器后,它会操作列表,每次都会通知侦听器,从输出中可以明显看出这一点。这个程序被简化以保持其简短和可读性。ListChangeListener.Change对象包含列表中变更的所有细节,如受影响的范围、添加和删除的大小等。

清单 9-12。检测可观察列表中的变化

// ObservableListTest.java

package com.jdojo.jfx;

import javafx.collections.FXCollections;

import javafx.collections.ListChangeListener;

import javafx.collections.ObservableList;

public class ObservableListTest {

public static void main(String[] args) {

// Create a list with some elements

ObservableList<String> list =

FXCollections.observableArrayList("one", "two");

System.out.println("After creating the list: " + list);

// Add a ChangeListener tp teh list

list.addListener(ObservableListTest::onChanged);

// Add some more elements to the list

list.addAll("three", "four");

System.out.println("After addAll() - list: " + list);

// We have four elements. Remove the middle two

// from index 1 (inclusive) to index 3 (exclusive)

list.remove(1, 3);

System.out.println("After remove() - list: " + list);

// Retain only the element "one"

list.retainAll("one");

System.out.println("After retainAll() - list: " + list);

// Replace the first element in the list

list.set(0, "ONE");

System.out.println("After set() - list: " + list);

}

public static void onChanged(ListChangeListener.Change<? extends String> change) {

while (change.next()) {

if (change.wasPermutated()) {

System.out.println("A permutation is detected.");

}

else if (change.wasUpdated()) {

System.out.println("An update is detected.");

}

else if (change.wasReplaced()) {

System.out.println("A replacement is detected.");

}

else {

if (change.wasRemoved()) {

System.out.println("A removal is detected.");

}

else if (change.wasAdded()) {

System.out.println("An addition is detected.");

}

}

}

}

}

After creating the list: [one, two]

An addition is detected.

After addAll() - list: [one, two, three, four]

A removal is detected.

After remove() - list: [one, four]

A removal is detected.

After retainAll() - list: [one]

A replacement is detected.

After set() - list: [ONE]

事件处理

一般来说,术语“事件”用于描述感兴趣的事件。在 GUI 应用中,事件是用户与应用交互的发生。点击鼠标、按下键盘上的键等。是 JavaFX 应用中的事件示例。

JavaFX 中的事件由javafx.event.Event类或其任何子类的对象表示。JavaFX 中的每个事件都有三个属性:

  • 事件源
  • 事件目标
  • 事件类型

当事件发生时,您通常通过执行一段代码来执行一些处理。为响应事件而执行的这段代码称为事件处理程序或事件过滤器。我将很快阐明事件处理程序和事件过滤器之间的区别。现在,把它们都看作一段代码,我把它们都称为事件处理程序。当您想要处理 UI 元素的事件时,需要向 UI 元素添加事件处理程序。当 UI 元素检测到事件时,它会执行您的事件处理程序。

调用事件处理程序的 UI 元素是这些事件处理程序的事件源。当一个事件发生时,它会通过一连串的事件调度程序。事件的源是事件调度程序链中的当前元素。当事件通过事件调度程序链中的一个调度程序传递到另一个调度程序时,事件源会发生变化。

事件目标是事件的目的地。事件目标决定了事件在处理过程中行进的路线。假设鼠标点击发生在一个Circle节点上。在这种情况下,Circle节点是鼠标点击事件的事件目标。

事件类型描述发生的事件的类型。事件类型以分层的方式定义。每个事件类型都有一个名称和一个父类型。

JavaFX 中所有事件共有的三个属性由三个不同类的对象表示。特定事件定义了附加的事件属性;例如,表示鼠标事件的 event 类添加了描述鼠标光标位置、鼠标按钮状态等的属性。

表 9-3 列出了事件处理中涉及的类和接口。JavaFX 有一个事件交付机制,它定义了事件发生和处理的细节。

表 9-3。

The List of Classes Involved in Events Processing

| 名字 | 类别/接口 | 描述 | | --- | --- | --- | | `Event` | 班级 | 此类的一个实例表示一个事件。存在几个`Event`类的子类来表示特定类型的事件。 | | `EventTarget` | 连接 | 此接口的一个实例代表一个事件目标。 | | `EventType` | 班级 | 这个类的一个实例代表一个事件类型,例如鼠标按下,鼠标释放。鼠标移动等。 | | `EventHandler` | 连接 | 此接口的一个实例表示一个事件处理程序或一个事件过滤器。它的`handle()`方法在它注册的事件发生时被调用。 |

事件处理机制

当事件发生时,作为事件处理的一部分,会执行几个步骤:

  • 事件目标选择
  • 事件路线构建
  • 事件路径遍历

事件目标选择

事件处理的第一步是选择事件目标。回想一下,事件目标是事件的目的节点。基于事件类型选择事件目标。对于鼠标事件,事件目标是鼠标光标处的节点。鼠标光标处可以有多个节点。例如,您可以在矩形上放置一个圆。鼠标光标处最顶端的节点被选为事件目标。

关键事件的事件目标是具有焦点的节点。节点如何获得焦点取决于节点的类型。例如,TextField可以通过在其中单击鼠标或使用焦点遍历键(如 Windows 操作系统上的 Tab 或 Shift-Tab)来获得焦点。形状如CirclesRectangles等。默认情况下,不获得焦点。如果你想让它们接收按键事件,你可以通过调用Node类的requestFocus()方法给它们焦点。

事件路线构建

事件通过事件调度链中的事件调度程序传播。事件调度链是事件路由。事件的初始和默认路线由事件目标决定。默认事件路由由从阶段开始到事件目标节点的容器子路径组成。

假设你在一个HBox中放置了一个Circle和一个Rectangle,并且HBox是一个StageScene的根节点。当您点击Circle时,Circle成为事件目标。Circle构造默认的事件路径,它是从阶段开始到事件目标(Circle)的路径。

事实上,事件路由由与节点相关联的事件调度程序组成。但是,出于实际和理解的目的,您可以将事件路由视为由节点组成的路径。通常,您不直接与事件调度程序打交道。

图 9-7 显示了鼠标点击事件的事件路径。事件路线上的节点以灰色背景填充显示。事件路线上的节点由实线连接。注意,当点击Circle时,作为场景图一部分的Rectangle不是事件路径的一部分。

A978-1-4302-6662-4_9_Fig7_HTML.jpg

图 9-7。

Construction of the default event route for an event

事件分派链(或事件路由)有一个头和一个尾。在这种情况下,StageCircle分别是事件调度链的头和尾。随着事件处理的进展,可以修改初始事件路线。通常,但不是必须的,在事件遍历步骤中,事件会经过其路由中的所有节点两次,如下一节所述。

事件路径遍历

事件路径遍历包括两个阶段:

  • 捕获阶段
  • 起泡阶段

一个事件在其路由中经过每个节点两次:一次在捕获阶段,一次在冒泡阶段。您可以为特定的事件类型向节点注册事件过滤器和事件处理程序。在捕获阶段,当事件通过节点时,执行事件过滤器。在冒泡阶段,当事件通过节点时,执行事件处理程序。事件过滤器和处理程序作为事件源在当前节点的引用中传递。随着事件从一个节点传播到另一个节点,事件源不断变化。然而,事件目标从事件路径遍历的开始到结束保持不变。

在路由遍历期间,节点可以使用事件过滤器或处理程序中的事件,从而完成事件的处理。消费一个事件只需调用事件对象上的consume()方法。当一个事件被消费时,事件处理被停止,即使路由中的一些节点根本没有被遍历。

事件捕获阶段

在捕获阶段,事件从其事件调度链的头部移动到尾部。图 9-8 显示了本例中Circle在捕获阶段的鼠标点击事件的行进。图中的向下箭头表示事件传播的方向。当事件通过一个节点时,为该节点注册的事件过滤器被执行。请注意,对于当前节点,事件捕获阶段只执行事件过滤器,而不执行事件处理程序。

A978-1-4302-6662-4_9_Fig8_HTML.jpg

图 9-8。

The event capture phase

在这个例子中,假设没有事件过滤器消耗该事件,则按顺序执行StageSceneHBoxCircle的事件过滤器。

您可以为一个节点注册多个事件过滤器。如果节点使用了它的一个事件过滤器中的事件,那么在事件处理停止之前,它的其他尚未执行的事件过滤器将被执行。假设您已经为示例中的Scene注册了五个事件过滤器,执行的第一个事件过滤器使用该事件。在这种情况下,Scene的其他四个事件过滤器仍将被执行。对Scene执行第五个事件过滤器后,事件处理将停止,事件不会传播到剩余的节点(HBoxCircle)。

在事件捕获阶段,您可以拦截针对节点子节点的事件(并提供通用响应)。例如,在本例中,您可以将鼠标点击事件的事件过滤器添加到Stage中,以拦截其所有子节点的所有鼠标点击事件。您可以通过在父节点的事件过滤器中使用事件来阻止事件到达其目标。例如,如果您在过滤器中为Stage使用鼠标点击事件,那么该事件将不会到达它的目标,比如说Circle

事件冒泡阶段

在冒泡阶段,事件从其事件调度链的尾部移动到头部。图 9-9 显示了你的例子中的Circle的鼠标点击事件在冒泡阶段的传播。

A978-1-4302-6662-4_9_Fig9_HTML.jpg

图 9-9。

The event bubbling phase

图中的向上箭头表示事件传播的方向。当事件通过一个节点时,执行该节点的注册事件处理程序。注意,事件冒泡阶段执行当前节点的事件处理程序,而事件捕获阶段执行事件过滤器。

在您的示例中,CircleHBoxSceneStage的事件处理程序按顺序执行,假设没有事件过滤器使用该事件。请注意,事件冒泡阶段从事件的目标开始,向上行进到父子层次结构中的最高父级。

您可以为一个节点注册多个事件处理程序。如果节点使用了它的一个事件处理程序中的事件,那么在事件处理停止之前,它的其他尚未执行的事件处理程序将被执行。假设您已经为示例中的Circle注册了五个事件处理程序,执行的第一个事件处理程序使用该事件。在这种情况下,Circle的其他四个事件处理程序仍然会被执行。在执行了Circle的第五个事件处理程序后,事件处理将停止,事件不会传播到剩余的节点(HBox, SceneStage))。

通常,事件处理程序注册到目标节点,以提供对事件的特定响应。有时,事件处理程序安装在父节点上,为其所有子节点提供默认事件响应。如果事件目标决定为事件提供特定的响应,它可以通过添加事件处理程序和使用事件来实现,从而阻止事件在事件冒泡阶段到达父节点。

我们来讨论一个微不足道的例子。假设您想在用户单击窗口内的任何位置时显示一个消息框。您可以向窗口注册一个事件处理程序来显示消息框。当用户在窗口的圆圈内单击时,您希望显示特定的消息。您可以向 circle 注册一个事件处理程序,以提供特定的消息并使用该事件。这将在单击圆圈时提供特定的事件响应,而对于其他节点,窗口提供默认的事件响应。

创建事件过滤器和处理程序

创建事件过滤器和处理程序就像创建实现EventHandler接口的类的对象一样简单。在 Java 8 之前,您会使用内部类来创建事件过滤器和处理程序。

EventHandler<MouseEvent> aHandler = new EventHandler<MouseEvent>() {

@Override

public void handle(MouseEvent e) {

// Event handling code goes here

}

};

在 Java 8 中,使用 lambda 表达式是创建事件过滤器和处理程序的最佳选择,如下所示:

EventHandler<MouseEvent> aHandler = e -> {

// Event handling code goes here

};

本章使用 lambda 表达式创建事件过滤器和处理程序。如果你不熟悉 Java 8 中的 lambda 表达式,建议你至少学习一下基础知识,这样你就能理解事件处理代码了。下面的代码片段创建了一个MouseEvent处理程序。它打印发生的鼠标事件的类型。

EventHandler<MouseEvent> mouseEventHandler =

e -> System.out.println("Mouse event type: " + e.getEventType());

注册事件过滤器和处理程序

如果某个节点对处理特定类型的事件感兴趣,您需要向该节点注册这些事件类型的事件过滤器和处理程序。当事件发生时,节点的已注册事件过滤器和处理程序的handle()方法按照前面章节中讨论的规则被调用。如果节点不再对处理事件感兴趣,您需要从节点中注销事件过滤器和处理程序。注册和取消注册事件筛选器和处理程序也分别称为添加和删除事件筛选器和处理程序。

JavaFX 提供了两种向节点注册和取消注册事件过滤器和处理程序的方法:

  • 使用addEventFilter()addEventHandler()removeEventFilter()removeEventHandler()方法
  • 使用onXxx便利属性

您可以使用addEventFilter()addEventHandler()方法分别向节点注册事件过滤器和处理程序。这些方法在Node类、Scene类和Window类中定义。有些类如MenuItemTreeItem可以是事件目标;然而,它们不是从Node类继承的。

  • <T extends Event> void addEventFilter(EventType<T> eventType, EventHandler<? super T> eventFilter)
  • <T extends Event> void addEventHandler(EventType<T> eventType, EventHandler<? super T> eventHandler)

这些方法有两个参数。第一个参数是事件类型,第二个是EventHandler接口的对象。

您可以使用下面的代码片段来处理Circle的鼠标点击事件:

import javafx.scene.shape.Circle;

import javafx.event.EventHandler;

import javafx.scene.input.MouseEvent;

...

// Create a circle

Circle circle = new Circle (100, 100, 50);

// Create a MouseEvent filter

EventHandler<MouseEvent> mouseEventFilter =

e -> System.out.println("Mouse event filter has been called.");

// Create a MouseEvent handler

EventHandler<MouseEvent> mouseEventHandler =

e -> System.out.println("Mouse event handler has been called.");

// Register the MouseEvent filter and handler to the Circle

// for mouse-clicked events

circle.addEventFilter(MouseEvent.MOUSE_CLICKED, mouseEventFilter);

circle.addEventHandler(MouseEvent.MOUSE_CLICKED, mouseEventHandler);

代码创建了两个EventHandler对象,它们在控制台上打印一条消息。在这个阶段,它们不是事件过滤器或处理程序。他们只是两个EventHandler物体。请注意,给引用变量命名并打印使用单词 filter 和 handler 的消息,对它们作为过滤器和处理程序的状态没有任何影响。最后两条语句将一个EventHandler对象注册为事件过滤器,将另一个注册为事件处理程序;两者都注册了鼠标单击事件。

NodeSceneWindow类包含事件属性来存储一些选定事件类型的事件处理程序。属性名使用事件类型模式。它们被命名为onXxx。例如,onMouseClicked属性存储鼠标点击事件类型的事件处理程序,onKeyTyped属性存储按键输入事件的事件处理程序,等等。您可以使用这些属性的setOnXxx()方法来注册节点的事件处理程序。例如,使用setOnMouseClicked()方法为鼠标点击事件注册一个事件处理程序,使用setOnKeyTyped()方法为键入事件注册一个事件处理程序,等等。各种类中的setOnXxx()方法被认为是注册事件处理程序的便利方法。

您需要记住关于onXxx便利属性的一些要点:

  • 它们只支持事件处理程序的注册,不支持事件过滤器。如果您需要注册事件过滤器,请使用addEventFilter()方法。
  • 它们只支持为一个节点注册一个事件处理程序。可以使用addEventHandler()方法为一个节点注册多个事件处理程序。
  • 这些属性只存在于节点类型的常用事件中。例如,onMouseClicked属性存在于NodeScene类中,但不存在于Window类中;onShowing属性存在于Window类中,但不存在于NodeScene类中。

下面的代码片段显示了如何使用便利的onMouseClicked属性来设置一个圆形的事件处理程序:

// Create a circle

Circle circle = new Circle (100, 100, 50);

// Create a MouseEvent handler

EventHandler<MouseEvent> eventHandler =

e -> System.out.println("Mouse event handler has been called.");

// Register the handler using the setter method for the onMouseClicked

// convenience event property

circle.setOnMouseClicked(eventHandler);

下面的代码片段展示了如何使用Button类的setOnAction()便利方法向Button添加一个ActionEvent处理程序:

// Create a button

Button exitBtn = new Button("Exit");

// Add the event handler for the Exit button

exitBtn.setOnAction(e -> Platform.exit());

便利事件属性没有提供单独的方法来注销事件处理程序。将属性设置为null会取消注册已经注册的事件处理程序。

// Unregister the mouse-clicked event handler for the circle

circle.setOnMouseClicked(null);

定义onXxx事件属性的类也定义了返回注册事件处理程序的引用的getOnXxx() getter 方法。如果没有设置事件处理程序,getter 方法返回null

清单 9-13 包含了一个显示事件路由和处理机制的程序。它还展示了如何使用事件及其效果。图 9-10 显示运行程序时的屏幕。

清单 9-13。处理和消费事件

// EventHandling.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.event.EventHandler;

import javafx.geometry.Insets;

import javafx.scene.Scene;

import javafx.scene.control.CheckBox;

import javafx.scene.input.MouseEvent;

import static javafx.scene.input.MouseEvent.MOUSE_CLICKED;

import javafx.scene.layout.HBox;

import javafx.scene.paint.Color;

import javafx.scene.shape.Circle;

import javafx.scene.shape.Rectangle;

import javafx.stage.Stage;

public class EventHandling extends Application {

private CheckBox consumeEventCbx =

new CheckBox("Consume Mouse Click at Circle");

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

Circle circle = new Circle(50, 50, 50);

circle.setFill(Color.CORAL);

Rectangle rect = new Rectangle(100, 100);

rect.setFill(Color.TAN);

HBox root = new HBox();

root.setPadding(new Insets(20));

root.setSpacing(20);

root.getChildren().addAll(circle, rect, consumeEventCbx);

Scene scene = new Scene(root);

// Register mouse-clicked event handlers to all nodes,

// except the rectangle and checkbox

EventHandler<MouseEvent> handler = e -> handleEvent(e);

EventHandler<MouseEvent> circleMeHandler =

e -> handleEventforCircle(e);

stage.addEventHandler(MOUSE_CLICKED, handler);

scene.addEventHandler(MOUSE_CLICKED, handler);

root.addEventHandler(MOUSE_CLICKED, handler);

circle.addEventHandler(MOUSE_CLICKED, circleMeHandler);

stage.setScene(scene);

stage.setTitle("Event Handling");

stage.show();

}

public void handleEvent(MouseEvent e) {

print(e);

}

public void handleEventforCircle(MouseEvent e) {

print(e);

if (consumeEventCbx.isSelected()) {

e.consume();

}

}

public void print(MouseEvent e) {

String type = e.getEventType().getName();

String source = e.getSource().getClass().getSimpleName();

String target = e.getTarget().getClass().getSimpleName();

// Get coordinates of the mouse relative to the event source

double x = e.getX();

double y = e.getY();

System.out.println("Type=" + type + ", Target=" + target +

", Source=" + source +

", location(" + x + ", " + y + ")");

}

}

A978-1-4302-6662-4_9_Fig10_HTML.jpg

图 9-10。

Handling and consuming events

程序将一个Circle、一个Rectangle和一个CheckBox添加到一个HBox中。HBox是一个容器,它将它的子容器水平地排列在一行上。将HBox作为根节点添加到场景中。事件处理程序被添加到StageSceneHBoxCircle。注意,为了保持程序逻辑简单,您对Circle使用了不同的事件处理程序。当选择CheckBox时,Circle的事件处理程序消耗鼠标点击事件,从而阻止事件向上传播到HBoxScene,Stage。如果没有选择CheckBox,则Circle上的鼠标点击事件从Circle行进到HBoxSceneStage。运行程序并使用鼠标,点击场景的不同区域来查看效果。请注意,HBoxSceneStage的鼠标点击事件处理程序被执行,即使您点击了Circle之外的点,因为它们在被点击节点的事件分派链中。

点击CheckBox不会执行HBoxSceneStage的鼠标点击事件处理程序,而点击Rectangle会。你能想出这种行为的原因吗?原因很简单。CheckBox有一个默认的事件处理程序,它采取默认的动作并消费事件,防止它沿着事件调度链向上传播。Rectangle不消费事件,允许它沿着事件调度链向上移动。

Tip

事件过滤器中的事件目标使用事件不会影响任何其他事件过滤器的执行。但是,它防止了事件冒泡阶段的发生。在最顶层节点的事件处理程序中使用事件对事件处理没有任何影响,最顶层节点是事件调度链的头。

布局窗格

可以使用两种类型的布局来排列场景图中的节点:

  • 静态布局
  • 动态布局

在静态布局中,节点的位置和大小只计算一次,并且在调整窗口大小时保持不变。当窗口具有节点最初布局的大小时,用户界面看起来很好。

在动态布局中,每当用户动作需要改变节点的位置、大小或两者时,场景图中的节点被布局。通常,更改一个节点的位置或大小会影响场景图中其他节点的位置和大小。当调整窗口大小时,动态布局强制重新计算一些或所有节点的位置和大小。

静态和动态布局各有优缺点。静态布局让开发人员可以完全控制用户界面的设计。它能让你在合适的时候利用可用空间。动态布局需要更多的编程工作,逻辑也更加复杂。通常,JavaFX 等支持 GUI 的编程语言通过库支持动态布局。库解决了动态布局的大多数用例。如果它们不能满足你的需求,你必须努力推出你自己的动态布局。

布局窗格是包含其他节点的节点,这些节点称为其子节点(或子节点)。布局窗格的职责是在需要时对其子元素进行布局。布局窗格也称为容器或布局容器。

布局窗格有一个布局策略,它控制布局窗格如何布局其子窗格。例如,布局窗格可以水平、垂直或以任何其他方式布置其子元素。容器的布局策略是一组计算其子容器的位置和大小的规则。节点有三种类型大小,称为首选大小、最小大小和最大大小。大多数容器试图给孩子他们喜欢的尺寸。节点的实际(或当前)大小可能与其首选大小不同。节点的当前大小取决于窗口的大小、容器的布局策略以及节点的扩展和收缩策略等。

JavaFX 包含几个容器类。图 9-11 显示了容器类的类图。容器类是Parent类的直接或间接子类。

A978-1-4302-6662-4_9_Fig11_HTML.jpg

图 9-11。

A class diagram for container classes in JavaFX

一个Group允许你将效果和变换应用到它的所有子节点上。Group级在javafx.scene包里。

Region类的子类用于布局子元素。它们可以用 CSS 样式化。Region类及其大多数子类都在javafx.scene.layout包中。

诚然,容器需要是Parent类的子类。然而,并不是所有的Parent类的子类都是容器。例如,Button类是Parent类的子类;但是,它是一个控件,而不是一个容器。必须将节点添加到容器中,使其成为场景图的一部分。容器根据其布局策略来布局其子容器。如果不希望容器管理节点的布局,需要将节点的managed属性设置为 false。

一个节点一次只能是一个容器的子节点。如果将一个节点添加到一个容器中,而该节点已经是另一个容器的子节点,则在将该节点添加到第二个容器之前,会将其从第一个容器中删除。通常,需要嵌套容器来创建复杂的布局。也就是说,您可以将一个容器作为子节点添加到另一个容器中。

该类包含三个方法来获取容器的子级列表:

  • protected ObservableList<Node> getChildren()
  • public ObservableList<Node> getChildrenUnmodifiable()
  • protected <E extends Node> List<E> getManagedChildren()

该方法返回一个容器的子节点的可修改的ObservableList。如果要将节点添加到容器中,可以将该节点添加到该列表中。这是容器类最常用的方法。您一直在使用这种方法将孩子添加到像GroupHBoxVBox等容器中。从第一期节目开始。

注意对getChildren()方法的protected访问。如果Parent类的子类不想成为一个容器,它将保持对这个方法的访问为protected。例如,Button and TextField之类的控件相关类将此方法保留为protected,因此您不能向它们添加子节点。一个容器类覆盖了这个方法并使之成为public。例如,GroupPane类将这个方法公开为public

该方法在Parent类中被声明为public。它返回一个只读的ObservableList子节点。它在两种情况下很有用:

  • 您需要将容器的子列表传递给一个不应该修改该列表的方法。
  • 你想知道控件是由什么组成的,而不是容器。

该方法具有protected访问权限。容器类不将其公开为public。他们在内部使用它来获取布局过程中托管子级的列表。您将使用这个方法推出您自己的容器类。

表 9-4 包含 JavaFX 中容器类的简要描述。本章不可能讨论所有类型的容器。在这一节中,我将向您展示一些使用它们的例子。

表 9-4。

Container Classes with a Brief Descriptions in JavaFX

| 集装箱等级 | 描述 | | --- | --- | | `Group` | 将效果和变换一起应用到它的所有子对象。 | | `Pane` | 用于其子节点的绝对定位。 | | `HBox` | 在单行中水平排列子项。 | | `VBox` | 在单列中垂直排列子项。 | | `FlowPane` | 在行或列中水平或垂直排列子项。如果一行或一列放不下它们,它们将按指定的宽度或高度换行。 | | `BorderPane` | 将布局区域划分为顶部、右侧、底部、左侧和中间区域,并将其每个子区域放置在五个区域之一。 | | `StackPane` | 以从后到前的栈方式排列子项。 | | `TilePane` | 在统一大小的单元格网格中排列子级。 | | `GridPane` | 在可变大小单元格的网格中排列子级。 | | `AnchorPane` | 通过将子项的边缘锚定到布局区域的边缘来排列子项。 | | `TextFlow` | 布局富文本,其内容可能由几个`Text`节点组成。 |

容器是用来装孩子的。您可以在创建容器对象时或创建后将子对象添加到容器中。所有容器类都提供接受 varargs Node类型参数的构造函数来添加初始的一组子元素。有些容器提供构造函数来添加初始的一组子容器,并为容器设置初始属性。

创建容器后,您还可以随时向容器中添加子容器。容器将它们的子容器存储在一个可观察的列表中,可以使用getChildren()方法检索该列表。向容器添加节点就像向可观察列表添加节点一样简单。下面的代码片段展示了如何在创建HBox时和创建后向其添加子元素。

// Create two buttons

Button okBtn = new Button("OK");

Button cancelBtn = new Button("Cancel");

// Create an HBox with two buttons as its children

HBox hBox1 = new HBox(okBtn, cancelBtn);

// Create an HBox with two buttons with 20px horizontal spacing between them

double hSpacing = 20;

HBox hBox2 = new HBox(hSpacing, okBtn, cancelBtn);

// Create an empty HBox, and afterwards add two buttons to it

HBox hBox3 = new HBox();

hBox3.getChildren().addAll(okBtn, cancelBtn);

Tip

当你需要在一个容器中添加多个子节点时,使用ObservableListaddAll()方法,而不是多次使用add()方法。

清单 9-14 中的程序展示了如何使用一个BorderPane、一个HBox和一个VBox来排列 UI 元素,如图 9-12 所示。

清单 9-14。使用边框窗格容器

// BorderPaneTest.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.geometry.Insets;

import javafx.scene.Node;

import javafx.scene.Scene;

import javafx.scene.control.Button;

import javafx.scene.control.Label;

import javafx.scene.control.TextArea;

import javafx.scene.control.TextField;

import javafx.scene.layout.BorderPane;

import javafx.scene.layout.HBox;

import javafx.scene.layout.Priority;

import javafx.scene.layout.VBox;

import javafx.stage.Stage;

public class BorderPaneTest extends Application {

public static void main(String[] args) {

Application.launch(args);

}

public void start(Stage stage) {

// Set the top and left child nodes to null

Node top = null;

Node left = null;

// Build the content nodes for the center region

VBox center = getCenter();

// Create the right child node

Button okBtn = new Button("Ok");

Button cancelBtn = new Button("Cancel");

// Make the OK and cancel buttons the same size

okBtn.setMaxWidth(Double.MAX_VALUE);

VBox right = new VBox(okBtn, cancelBtn);

right.setStyle("-fx-padding: 10;");

// Create the bottom child node

Label statusLbl = new Label("Status: Ready");

HBox bottom = new HBox(statusLbl);

BorderPane.setMargin(bottom, new Insets(10, 0, 0, 0));

bottom.setStyle("-fx-background-color: lavender;" +

"-fx-font-size: 7pt;" +

"-fx-padding: 10 0 0 0;" );

BorderPane root =

new BorderPane(center, top, right, bottom, left);

root.setStyle("-fx-background-color: lightgray;");

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Using a BorderPane");

stage.show();

}

private VBox getCenter() {

// A Label and a TextField in an HBox

Label nameLbl = new Label("Name:");

TextField nameFld = new TextField();

HBox.setHgrow(nameFld, Priority.ALWAYS);

HBox nameFields = new HBox(nameLbl, nameFld);

// A Label and a TextArea

Label descLbl = new Label("Description:");

TextArea descText = new TextArea();

descText.setPrefColumnCount(20);

descText.setPrefRowCount(5);

VBox.setVgrow(descText, Priority.ALWAYS);

// Box all controls in a VBox

VBox center = new VBox(nameFields, descLbl, descText);

return center;

}

}

A978-1-4302-6662-4_9_Fig12_HTML.jpg

图 9-12。

A BorderPane using some controls in its top, right, bottom, and center regions

注意清单 9-14 中容器方法的使用。您可以使用 CSS 自定义 JavaFX 中容器和控件的视觉外观。JavaFX 中的 CSS 属性的命名和工作方式与用于在浏览器中定制 HTML 内容的 CSS 属性非常相似。JavaFX 中的 CSS 属性以-fx-开头;比如指定字体大小的 CSS 属性名是-fx-font-size。还可以使用 CSS 文件为 JavaFX 应用设置样式。清单 9-15 展示了如何通过给场景的根节点添加一个样式来在场景周围添加填充和一个圆形的蓝色边框。图 9-13 显示了窗口中的结果场景。

清单 9-15。使用 CSS 向场景添加填充和圆形蓝色边框

// CSSTest.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.scene.Scene;

import javafx.scene.control.Label;

import javafx.scene.control.TextField;

import javafx.scene.layout.GridPane;

import javafx.stage.Stage;

public class CSSTest extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

TextField fNameFld = new TextField();

Label fNameLbl = new Label("First Name:");

TextField lNameFld = new TextField();

Label lNameLbl = new Label("Last Name:");

GridPane root = new GridPane();

root.addRow(0, fNameLbl, fNameFld);

root.addRow(1, lNameLbl, lNameFld);

// Set a CSS for the GridPane

root.setStyle("-fx-padding: 10;" +

"-fx-border-style: solid inside;" +

"-fx-border-width: 2;" +

"-fx-border-insets: 5;" +

"-fx-border-radius: 5;" +

"-fx-border-color: blue;");

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Using CSS");

stage.show();

}

}

A978-1-4302-6662-4_9_Fig13_HTML.jpg

图 9-13。

Using CSS to add pading and a rounded, blue border around the scene

在 JavaFX 中使用 CSS 是一个大话题。本章不详细讨论 JavaFX 中的 CSS。请参考以下网页获取 CSS 参考指南,该指南列出了可以使用 CSS 设置样式的所有节点的所有 CSS 属性:

docs.oracle.com/javase/8/javafx/api/javafx/scene/doc-files/cssref.html

控制

JavaFX 允许您使用 GUI 组件创建应用。具有 GUI 的应用执行三项任务:

  • 接受用户通过键盘、鼠标等输入设备的输入。
  • 处理输入(或根据输入采取行动)
  • 显示输出

用户界面提供了一种在应用和用户之间交换输入和输出信息的方式。使用键盘输入文本、使用鼠标选择菜单项以及点击按钮都是向 GUI 应用提供输入的例子。该应用使用文本、图表、对话框等在计算机显示器上显示输出。

用户使用称为控件或小部件的图形元素与 GUI 应用进行交互。按钮、标签、文本字段、文本区域、单选按钮和复选框是控件的几个例子。键盘、鼠标和触摸屏等设备用于向控件提供输入。控件还可以向用户显示输出。控件生成指示用户和控件之间发生某种交互的事件。例如,使用鼠标或空格键按下按钮会生成一个动作事件,指示用户已经按下了该按钮。

JavaFX 提供了一组丰富的易于使用的基本和高级控件。控件通常被添加到布局窗格中来定位和调整它们的大小。不可能讨论所有的控制。我将列出 JavaFX 中的大多数控件,并简要描述它们的功能。

JavaFX 中的每个控件都由一个类的实例来表示。如果多个控件共享基本功能,则它们从一个公共基类继承。控件类在javafx.scene.control包中。控件类是Control类的一个子类,直接或间接,而后者又继承自Region。回想一下,Region类继承自Parent类。所以,技术上来说,一个Control也是一个Parent

一个Parent可以生孩子。通常,控件由作为其子节点的另一个节点(有时是多个节点)组成。控件类不通过getChildren()方法公开其子类的列表,因此您不能向它们添加任何子类。

控件类通过返回一个ObservableList<Node>getChildrenUnmodifiable()方法公开其内部不可修改的子控件列表。使用控件不需要知道控件的内部子级。然而,如果你需要他们的孩子的列表,getChildrenUnmodifiable()方法会给你。

图 9-14 显示了一些常用控件的类的类图。控件类的列表比类图中显示的要大得多。表 9-5 包含 JavaFX 中大多数控件的列表及其简要描述。

表 9-5。

JavaFX Controls

| 控制 | 描述 | | --- | --- | | `Label` | 不可编辑的文本控件,通常用于显示另一个控件的标签。 | | `Button` | 表示命令按钮控件。它可以显示文本和图标。当它被激活时会产生一个`ActionEvent`。 | | `Hyperlink` | 表示一个超链接控件,它看起来像网页中的超链接。当它被激活时会产生一个`ActionEvent`。 | | `MenuButton` | 看起来像按钮,行为像菜单。当它被激活时,它以弹出菜单的形式显示一个选项列表。要在菜单选项被选中时执行命令,您需要将`ActionEvent`处理程序添加到添加到 MenuButton 的`MenuItem`中。 | | `ToggleButton` | 表示双态按钮控件。这两种状态是选中和取消选中。 | | `RadioButton` | 表示单选按钮。它用于从选项列表中提供一个互斥的选项。 | | `CheckBox` | 表示三态选择控件。三种状态是选中、未选中和未定义。 | | `ChoiceBox` | 允许用户从预定义项目的小列表中选择一个项目。 | | `ComboBox` | `ChoiceBox`控件的高级版本。它有很多特性,比如可编辑性、改变列表中项目的外观等。,这些不是在`ChoiceBox`中提供的。 | | `ListView` | 为用户提供从项目列表中选择多个项目的能力。通常,列表中的所有或多个项目始终对用户可见。 | | `ColorPicker` | 允许用户从标准调色板中选择颜色或以图形方式定义自定义颜色。 | | `DatePicker` | 允许用户从日历弹出窗口中选择日期。 | | `TextField` | 表示单行文本输入控件。 | | `PasswordField` | 表示单行文本输入控件,用于输入密码或敏感文本,其中实际文本被屏蔽。 | | `TextArea` | 表示多行文本输入控件。 | | `ProgressIndicator` | 用于在圆形区域显示任务的进度。 | | `ProgressBar` | 用于在矩形区域中显示任务的进度。 | | `TitledPane` | 用于显示带有标题栏的内容(通常是一组控件),标题栏可能包含标题文本和图形。它可以处于展开或折叠状态。在折叠状态下,只有标题栏可见。在展开状态下,内容和标题栏都可见。 | | `Accordion` | 用作一组`TitledPane`控件的容器,其中一次只有一个`TitledPane`可见。 | | `Pagination` | 用于显示一个大的单一内容,方法是将它分成称为页面的小块,如搜索结果。 | | `Tooltip` | 用于当鼠标悬停在控件上时,在弹出窗口中短时间显示控件的附加信息。 | | `ScrollBar` | 用于向控件添加滚动功能。 | | `ScrollPane` | 提供节点的可滚动视图。 | | `Separator` | 用于分隔两组控件的水平线或垂直线。 | | `Slider` | 用于通过沿轨道滑动拇指(或旋钮)以图形方式从数值范围中选择数值。 | | `MenuBar` | 用作菜单容器的水平栏。 | | `Menu` | 包含可操作项目的列表,可根据需要显示,例如,通过单击它。 | | `MenuItem` | 代表`menu`中可操作的选项。 | | `ContextMenu` | 根据请求显示菜单项列表的弹出控件。 | | `ToolBar` | 用于显示一组节点,这些节点在屏幕上提供常用的操作项。 | | `TabPane` | 显示由`Tab`类的实例表示的多个选项卡页面。一次只能看到一个选项卡页面的内容。 | | `Tab` | 表示一个`TabPane`中的标签页。 | | `HTMLEditor` | 在 JavaFX 中提供丰富的文本编辑功能。 | | `FileChooser` | 允许您以图形方式从文件系统中选择文件。 | | `DirectoryChooser` | 允许您使用依赖于平台的目录对话框来选择目录 | | `TableView` | 用于使用行和列显示和编辑表格数据。 | | `TreeView` | 用于显示和编辑以树状结构排列的分层数据。 | | `TreeTableView` | `TableView`和`TreeView`控件的组合。提供拥有下钻表格的能力。 | | `WebView` | 显示网页。 |

A978-1-4302-6662-4_9_Fig14_HTML.jpg

图 9-14。

A partial class diagram for control classes in JavaFX

清单 9-16 创建了一个使用 JavaFX 控件输入个人信息的表单,比如名字、姓氏、出生日期和性别,如图 9-15 所示。输入数据,点击Save按钮,将输入的数据显示在窗口bottomTextArea中。该表单使用以下控件:

  • 两个用于输入名字和姓氏的TextField控件实例。
  • 一个用于输入出生日期的DatePicker控件。
  • 用于选择性别的ChoiceBox控件。
  • 保存数据的Button控件。
  • 关闭窗口的Button控件。
  • 点击Save按钮时显示输入数据的TextArea控件。

清单 9-16。使用 JavaFX 控件创建表单以输入个人详细信息

// PersonView.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.scene.Scene;

import javafx.scene.control.Button;

import javafx.scene.control.ChoiceBox;

import javafx.scene.control.DatePicker;

import javafx.scene.control.Label;

import javafx.scene.control.TextArea;

import javafx.scene.control.TextField;

import javafx.scene.layout.GridPane;

import javafx.scene.layout.VBox;

import javafx.stage.Stage;

public class PersonView extends Application {

// Labels

Label fNameLbl = new Label("First Name:");

Label lNameLbl = new Label("Last Name:");

Label bDateLbl = new Label("Birth Date:");

Label genderLbl = new Label("Gender:");

// Fields

TextField fNameFld = new TextField();

TextField lNameFld = new TextField();

DatePicker bDateFld = new DatePicker();

ChoiceBox<String> genderFld = new ChoiceBox<>();

TextArea dataFld = new TextArea();

// Buttons

Button saveBtn = new Button("Save");

Button closeBtn = new Button("Close");

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) throws Exception {

// Populate the gender choice box

genderFld.getItems().addAll("Male", "Female", "Unknown");

// Set the preferred rows and columns for the text area

dataFld.setPrefColumnCount(30);

dataFld.setPrefRowCount(5);

GridPane grid = new GridPane();

grid.setHgap(5);

grid.setVgap(5);

// Place the controls in the grid

grid.add(fNameLbl, 0, 0); // column=0, row=0

grid.add(lNameLbl, 0, 1); // column=0, row=1

grid.add(bDateLbl, 0, 2); // column=0, row=2

grid.add(genderLbl, 0, 3); // column=0, row=3

grid.add(fNameFld, 1, 0); // column=1, row=0

grid.add(lNameFld, 1, 1); // column=1, row=1

grid.add(bDateFld, 1, 2); // column=1, row=2

grid.add(genderFld, 1, 3); // column=1, row=3

grid.add(dataFld, 1, 4, 3, 2); // column=1, row=4, colspan=3, rowspan=3

// Add buttons and make them the same width

VBox buttonBox = new VBox(saveBtn, closeBtn);

saveBtn.setMaxWidth(Double.MAX_VALUE);

closeBtn.setMaxWidth(Double.MAX_VALUE);

grid.add(buttonBox, 2, 0, 1, 2); // column=2, row=0, colspan=1, rowspan=2

// Show the data in the text area when the Save button is clicked

saveBtn.setOnAction(e -> showData());

// Close the window when the Close button is clicked

closeBtn.setOnAction(e -> stage.hide());

// Set a CSS for the GridPane to add a padding and a blue border

grid.setStyle("-fx-padding: 10;" +

"-fx-border-style: solid inside;" +

"-fx-border-width: 2;" +

"-fx-border-insets: 5;" +

"-fx-border-radius: 5;" +

"-fx-border-color: blue;");

Scene scene = new Scene(grid);

stage.setScene(scene);

stage.setTitle("Person Details");

stage.sizeToScene();

stage.show();

}

private void showData() {

String data = "First Name = " + fNameFld.getText() +

"\nLast Name=" + lNameFld.getText() +

"\nBirth Date=" + bDateFld.getValue() +

"\nGender=" + genderFld.getValue();

dataFld.setText(data);

}

}

A978-1-4302-6662-4_9_Fig15_HTML.jpg

图 9-15。

A form using JavaFX controls to enter person details

使用 2D 形状

JavaFX 提供了各种节点来绘制不同类型的形状,如线条、圆形、矩形等。您可以将形状添加到场景图。您可以绘制 2D 和 3D 形状。在这一节,我将向你展示如何绘制 2D 形状。在 JavaFX 中使用 3D 形状有一个学习曲线。由于篇幅限制,我不会在本书中讨论 3D 形状。所有 2D 形状类都在javafx.scene.shape包中。表示 2D 形状的类继承自抽象的Shape类,如图 9-16 所示。

A978-1-4302-6662-4_9_Fig16_HTML.jpg

图 9-16。

A class diagram for 2D shapes

形状具有由其属性定义的大小和位置。例如,widthheight属性定义了一个矩形的大小;radius属性定义圆的大小,xy属性定义矩形左上角的位置,centerXcenterY属性定义圆心。

在布局过程中,父形状不会调整形状的大小。只有当形状的与大小相关的属性改变时,形状的大小才会改变。您可能会看到类似“JavaFX 形状不可调整大小”的语句这意味着在布局过程中,形状不能根据其父形状调整大小。它们只能通过更改属性来调整大小。

形状有内部和描边。定义形状内部和笔画的所有属性都在Shape类中声明。属性指定了填充 ?? 内部的颜色。默认的fillColor.BLACKstroke属性指定轮廓线条的颜色,默认为null,除了LinePolylinePathColor.BLACK作为默认stroke颜色。

Shape 类包含一个 smooth 属性,默认情况下为 true。其 true 值指示应该使用抗锯齿提示来呈现形状。如果设置为 false,将不使用抗锯齿提示,这可能会导致形状的边缘不清晰。

清单 9-17 中的程序创建了一个圆、一个矩形、一条直线、一个代表平行四边形的多边形、一个代表六边形的折线和一个带弦的圆弧。形状如图 9-17 所示。请注意关于在此程序中创建形状的以下几点:

  • 它创建了一个半径为 40px 的圆。
  • 它创建一个 100 像素宽、75 像素高的矩形。
  • 它从(0,0)和(50,50)创建一条直线。
  • 它通过连接四个点:(30.0,0.0)、(130.0,0.0)、(100.00,50.0)和(0.0,50.0),创建一个表示平行四边形的多边形。通过连接第一个点和最后一个点,多边形会自动闭合。
  • 它创建一条表示六边形的折线。多段线类似于多边形,只是它不会自动闭合。请注意,折线构造函数中的第一个点(100.0,0.0)和最后一个点(100.0,0.0)是相同的,因此它是闭合的。
  • 它使用Arc类的构造函数Arc(double centerX, double centerY, double radiusX, double radiusY, double startAngle, double length)创建一个弧线。弧可以是弦的、圆的或开放的。程序使用圆弧类型作为ArcType.CHORD,通过一条直线(一条弦)连接圆弧上的两个极值点。

清单 9-17。在 JavaFX 中使用 2D 图形

// ShapeTest.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.scene.Scene;

import javafx.scene.layout.HBox;

import javafx.scene.paint.Color;

import javafx.scene.shape.Arc;

import javafx.scene.shape.ArcType;

import javafx.scene.shape.Circle;

import javafx.scene.shape.Line;

import javafx.scene.shape.Polygon;

import javafx.scene.shape.Polyline;

import javafx.scene.shape.Rectangle;

import javafx.stage.Stage;

public class ShapeTest extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

// Create a circle with an yellow fill and a black stroke of 2.0px

Circle circle = new Circle(40);

circle.setFill(Color.YELLOW);

circle.setStroke(Color.BLACK);

circle.setStrokeWidth(2.0);

// Create a rectangle

Rectangle rect = new Rectangle(100, 75);

rect.setFill(Color.RED);

// Create a line

Line line = new Line(0, 0, 50, 50);

line.setStrokeWidth(5.0);

line.setStroke(Color.GREEN);

// Create a parallelogram

Polygon parallelogram = new Polygon();

parallelogram.getPoints().addAll(30.0, 0.0,

130.0, 0.0,

100.00, 50.0,

0.0, 50.0);

parallelogram.setFill(Color.AZURE);

parallelogram.setStroke(Color.BLACK);

// Create a hexagon

Polyline hexagon = new Polyline(100.0, 0.0,

120.0, 20.0,

120.0, 40.0,

100.0, 60.0,

80.0, 40.0,

80.0, 20.0,

100.0, 0.0);

hexagon.setFill(Color.WHITE);

hexagon.setStroke(Color.BLACK);

// A CHORD arc with no fill and a stroke

Arc arc = new Arc(0, 0, 50, 100, 0, 90);

arc.setFill(Color.TRANSPARENT);

arc.setStroke(Color.BLACK);

arc.setType(ArcType.CHORD);

// Add all shapes to an HBox

HBox root =

new HBox(circle, rect, line, parallelogram, hexagon, arc);

root.setSpacing(10);

root.setStyle("-fx-padding: 10;" +

"-fx-border-style: solid inside;" +

"-fx-border-width: 2;" +

"-fx-border-insets: 5;" +

"-fx-border-radius: 5;" +

"-fx-border-color: blue;");

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("2D Shapes");

stage.show();

}

}

A978-1-4302-6662-4_9_Fig17_HTML.jpg

图 9-17。

Some 2D shapes in JavaFX

在 JavaFX 中,Path类以及其他许多类,如MoveToLineToHLineToVLineTo,可以用来绘制非常复杂的形状。JavaFX 还使用编码字符串中的路径数据的SVGPath类支持可伸缩矢量图形(SVG)。SVG 规范可以在 www.w3.org/TR/SVG 找到。以字符串格式构造路径数据的详细规则可以在 www.w3.org/TR/SVG/paths.html 找到。JavaFX 部分支持 SVG 规范。这本书没有详细介绍使用PathSVGPath类创建 2D 形状。清单 9-18 展示了如何使用PathSVGPath类创建三角形,如图 9-18 所示。有关如何使用这些类的详细信息,请参考 JavaFX API 文档。

清单 9-18。使用 Path 和 SVGPath 类创建 2D 形状

// PathTest.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.scene.Scene;

import javafx.scene.layout.HBox;

import javafx.scene.paint.Color;

import javafx.scene.shape.LineTo;

import javafx.scene.shape.MoveTo;

import javafx.scene.shape.Path;

import javafx.scene.shape.SVGPath;

import javafx.stage.Stage;

public class PathTest extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

// Create a triangle using a Path

Path pathTriangle = new Path(new MoveTo(50, 0),

new LineTo(0, 50),

new LineTo(100, 50),

new LineTo(50, 0));

pathTriangle.setFill(Color.LIGHTGRAY);

pathTriangle.setStroke(Color.BLACK);

// Create a triangle using a SVGPath

SVGPath svgTriangle = new SVGPath();

svgTriangle.setContent("M50, 0 L0, 50 L100, 50 Z");

svgTriangle.setFill(Color.LIGHTGRAY);

svgTriangle.setStroke(Color.BLACK);

// Add all shapes to an HBox

HBox root = new HBox(pathTriangle, svgTriangle);

root.setSpacing(10);

root.setStyle("-fx-padding: 10;" +

"-fx-border-style: solid inside;" +

"-fx-border-width: 2;" +

"-fx-border-insets: 5;" +

"-fx-border-radius: 5;" +

"-fx-border-color: blue;");

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("2D Shapes using Path and SVGPath Classes");

stage.show();

}

}

A978-1-4302-6662-4_9_Fig18_HTML.jpg

图 9-18。

Creating triangles using the Path and SVGPath classes

在画布上画画

通过javafx.scene.canvas包,JavaFX 提供了 Canvas API,该 API 提供了一个绘图表面来使用绘图命令绘制形状、图像和文本。该 API 还提供了对绘图表面的像素级访问,您可以在表面上写入任何像素。API 由以下两个类组成:

  • Canvas
  • GraphicsContext

画布是用作绘图表面的位图图像。Canvas类的一个实例代表一个画布。它继承自Node类。因此,画布是一个可以添加到场景图中的节点,可以对其应用效果和变换。一个Canvas有一个与之关联的图形上下文,用于向Canvas发出绘图命令。GraphicsContext类的一个实例代表一个图形上下文。

Canvas类包含两个构造函数。无参数构造函数创建一个空的Canvas。稍后,您可以使用画布的widthheight属性来设置画布的大小。另一个构造函数将Canvaswidthheight作为参数。以下代码片段显示了如何创建画布:

// Create a Canvas of zero width and height

Canvas canvas = new Canvas();

// Set the canvas size

canvas.setWidth(400);

canvas.setHeight(200);

// Create a 400X200 canvas

Canvas canvas = new Canvas(400, 200);

一旦创建了一个Canvas,您需要使用 get getGraphicsContext2D()方法获取它的图形上下文,如下所示:

// Get the graphics context of the canvas

GraphicsContext gc = canvas.getGraphicsContext2D();

所有绘图命令都作为方法在GraphicsContext类中提供。落在Canvas边界之外的图形被剪切。画布使用缓冲区。绘图命令将必要的参数推送到缓冲区。值得注意的是,在将Canvas添加到场景图形之前,您应该使用来自任何一个线程的图形上下文。一旦Canvas被添加到场景图形中,图形上下文应该只在 JavaFX 应用线程上使用。

清单 9-19 中的程序展示了如何在画布上绘制一个圆角矩形、一个椭圆形和文本。图 9-19 显示了所有绘图的画布。使用strokeRoundRect(double x, double y, double w, double h, double arcWidth, double arcHeight)方法绘制一个圆角矩形;fillOval(double x, double y, double w, double h)方法用于绘制实心椭圆。使用strokeText(String text, double x, double y)方法来绘制文本。

清单 9-19。在画布上画画

// CanvasTest.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.scene.Scene;

import javafx.scene.canvas.Canvas;

import javafx.scene.canvas.GraphicsContext;

import javafx.scene.layout.Pane;

import javafx.scene.paint.Color;

import javafx.stage.Stage;

public class CanvasTest extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

// Create a canvas

Canvas canvas = new Canvas(300, 100);

// Get the graphics context of the canvas

GraphicsContext gc = canvas.getGraphicsContext2D();

// Set line width and fill color

gc.setLineWidth(2.0);

gc.setFill(Color.RED);

// Draw a rounded rectangle

gc.strokeRoundRect(10, 10, 50, 50, 10, 10);

// Fill an oval

gc.fillOval(70, 10, 50, 20);

// Draw text

gc.strokeText("Hello Canvas", 150, 20);

Pane root = new Pane();

root.getChildren().add(canvas);

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Drawing on a Canvas");

stage.show();

}

}

A978-1-4302-6662-4_9_Fig19_HTML.jpg

图 9-19。

A rectangle, an ellipse, and text drawn on a canvas

应用效果

效果是接受一个或多个图形输入、对输入应用算法并产生输出的过滤器。通常,将效果应用于节点以创建视觉上吸引人的用户界面。效果的例子有阴影、模糊、扭曲、发光、反射、混合、不同类型的照明等。JavaFX 库提供了几个与效果相关的类。效果是有条件的特征。如果应用于节点的效果在平台上不可用,则它们将被忽略。图 9-20 显示了四个使用投影、模糊、发光和绽放效果的Text节点。

A978-1-4302-6662-4_9_Fig20_HTML.jpg

图 9-20。

Text nodes with different effects

在 JavaFX 中对节点应用效果很容易。Node类包含一个effect属性,指定应用于节点的效果。默认情况下,是null。要应用一个效果,创建一个特定效果类的对象,并使用setEffect()方法将其设置为节点。下面的代码片段将投影效果应用于一个Text节点:

Text t1 = new Text("Drop Shadow");

t1.setFont(Font.font(24));

t1.setEffect(new DropShadow());

Effect类的一个实例代表一种效果。Effect类是所有效果类的抽象基础。所有效果类都在javafx.scene.effect包里。

清单 9-20 中的程序创建了Text节点,并对它们应用了效果。Text节点如图 9-20 所示。

清单 9-20。对节点应用效果

// EffectTest.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.scene.Scene;

import javafx.scene.effect.Bloom;

import javafx.scene.effect.BoxBlur;

import javafx.scene.effect.DropShadow;

import javafx.scene.effect.Glow;

import javafx.scene.layout.HBox;

import javafx.scene.layout.StackPane;

import javafx.scene.paint.Color;

import javafx.scene.shape.Rectangle;

import javafx.scene.text.Font;

import javafx.scene.text.FontWeight;

import javafx.scene.text.Text;

import javafx.stage.Stage;

public class EffectTest extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

Text t1 = new Text("Drop Shadow!");

t1.setFont(Font.font(24));

t1.setEffect(new DropShadow());

Text t2 = new Text("Blur!");

t2.setFont(Font.font(24));

t2.setEffect(new BoxBlur());

Text t3 = new Text("Glow!");

t3.setFont(Font.font(24));

t3.setEffect(new Glow());

Text t4 = new Text("Bloom!");

t4.setFont(Font.font("Arial", FontWeight.BOLD, 24));

t4.setFill(Color.WHITE);

t4.setEffect(new Bloom(0.10));

// Stack the Text node with bloom effect over a Reactangle

Rectangle rect = new Rectangle(100, 30, Color.GREEN);

StackPane spane = new StackPane(rect, t4);

HBox root = new HBox(t1, t2, t3, spane);

root.setSpacing(20);

root.setStyle("-fx-padding: 10;" +

"-fx-border-style: solid inside;" +

"-fx-border-width: 2;" +

"-fx-border-insets: 5;" +

"-fx-border-radius: 5;" +

"-fx-border-color: blue;");

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Applying Effects");

stage.show();

}

}

Tip

应用于Group的效果会应用于它的所有子对象。也可以链接多个效果,其中一个效果的输出成为链中下一个效果的输入。

应用变换

变换是坐标空间中点到它们自身的映射,保持它们之间的距离和方向。几种类型的变换可以应用于坐标空间中的点。JavaFX 支持以下类型的转换:

  • 翻译
  • 循环
  • 大剪刀
  • 规模
  • 姻亲

抽象Transform类的一个实例表示 JavaFX 中的一个转换。Transform类包含节点上所有类型转换使用的公共方法和属性。它包含创建特定类型转换的工厂方法。图 9-21 显示了代表不同类型转换的类的类图。类名与它们提供的转换类型相匹配。所有的课程都在javafx.scene.transform包里。

A978-1-4302-6662-4_9_Fig21_HTML.jpg

图 9-21。

A class diagram for transform-related classes

仿射变换是保留点、线和平面的广义变换。平行线在变换后保持平行。它可能不会保留线之间的角度和点之间的距离。但是,直线上各点之间的距离比保持不变。平移、缩放、相似变换、相似变换、反射、旋转和剪切都是仿射变换的例子。

Affine类的一个实例代表一个仿射变换。这个类对于初学者来说不容易使用。它的使用需要矩阵等高级数学知识。如果你需要一个特定类型的转换,使用特定的子类,如TranslateShear等。而不是使用通用的Affine类。您也可以组合多个单独的转换来创建一个更复杂的转换。

使用转换很容易。然而,有时会令人困惑,因为有多种方法来创建和应用它们。创建Transform实例有两种方法:

  • 使用Transform类的工厂方法之一,例如,translate()方法创建一个Translate对象,rotate()方法创建一个Rotate对象,等等。
  • 使用特定的类来创建特定类型的转换,例如,Translate类用于转换,Rotate类用于旋转,等等。

以下两个Translate对象代表相同的翻译:

double tx = 20.0;

double ty = 10.0;

// Using the factory method in the Transform class

Translate translate1 = Transform.translate(tx, ty);

// Using the Translate class constructor

Translate translate2 = new Translate(tx, ty);

有两种方法可以将变换应用到节点:

  • 使用Node类中的特定属性。例如,使用Node类的translateXtranslateYtranslateZ属性将翻译应用于节点。请注意,不能以这种方式应用剪切变换。
  • 使用节点的transforms序列。Node类的getTransforms()方法返回一个ObservableList<Transform>。用所有的Transform对象填充这个列表。Transforms将按顺序应用。您可以仅使用此方法来应用剪切变换。

这两种应用Transforms的方法工作起来没有什么不同。当我讨论转换的具体类型时,我将讨论它们的区别。有时可以使用这两种方法来应用转换,在这种情况下,在节点属性上的转换集之前应用transforms序列中的转换。

下面的代码片段对一个Rectangle应用了三种转换,称为剪切、缩放和平移:

// Create a rectangle

Rectangle rect = new Rectangle(100, 50, Color.LIGHTGRAY);

// Apply transforms using the transforms sequence of the Rectangle

Transform shear = Transform.shear(2.0, 1.2);

Transform scale = Transform.scale(1.1, 1.2);

rect.getTransforms().addAll(shear, scale);

// Apply a translation using the translatex and translateY

// properties of the Node class

rect.setTranslateX(10);

rect.setTranslateY(10);

使用transforms序列应用剪切和缩放变换。使用Node类的translateXtranslateY属性来应用翻译。在transforms序列中的变换,剪切和缩放,在平移之后依次应用。讨论所有类型的转换超出了本书的范围。有关更多详细信息,请参考 JavaFX API 文档。

清单 9-21 显示了如何对矩形应用平移、旋转、缩放和剪切变换。它创建两个大小相同、位置相同的矩形。矩形使用不同的填充颜色来区分它们。平移、旋转、缩放和剪切变换将应用于黄色填充的矩形。浅灰色填充的矩形不会应用任何变换。图 9-22 显示了两个矩形。

清单 9-21。对节点应用变换

// TransformationTest.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.scene.Scene;

import javafx.scene.layout.Pane;

import javafx.scene.paint.Color;

import javafx.scene.shape.Rectangle;

import javafx.scene.transform.Rotate;

import javafx.scene.transform.Scale;

import javafx.scene.transform.Shear;

import javafx.scene.transform.Translate;

import javafx.stage.Stage;

public class TransformationTest extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

Rectangle rect1 = new Rectangle(100, 50, Color.LIGHTGRAY);

rect1.setStroke(Color.BLACK);

Rectangle rect2 = new Rectangle(100, 50, Color.YELLOW);

rect2.setStroke(Color.BLACK);

// Apply a translation, rotate, scale and shear transformations

// to rect2

Translate translate = new Translate(50, 10);

Rotate rotate = new Rotate(30, 0, 0);

Scale scale = new Scale(0.5, 0.5);

Shear shear = new Shear(0.5, 0.5);

rect2.getTransforms().addAll(translate, rotate, scale, shear);

Pane root = new Pane(rect1, rect2);

root.setPrefSize(200, 100);

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Applying Transformations");

stage.show();

}

}

A978-1-4302-6662-4_9_Fig22_HTML.jpg

图 9-22。

Two rectangles, one with transformations and one without transformations

动画

在现实世界中,动画意味着某种通过快速连续显示图像而产生的运动。例如,当你看电影时,你看到的图像变化如此之快,以至于你会产生一种运动错觉。

在 JavaFX 中,动画被定义为随时间改变节点的属性。如果改变的属性决定了节点的位置,JavaFX 中的动画将产生电影中的运动错觉。不是所有的动画都必须包含运动;例如,随时间改变一个Shapefill属性是 JavaFX 中一个不涉及运动的动画。

为了理解动画是如何执行的,理解一些关键概念是很重要的:

  • 时间表
  • 关键帧
  • 关键字值
  • 内插器

动画是在一段时间内完成的。时间轴表示在给定时刻动画期间与关键帧相关联的时间进程。一个关键帧表示在时间轴上特定时刻被激活的节点的状态。关键帧有相关的键值。键值表示节点的属性值以及要使用的插值器。

假设你想在 10 秒内将场景中的一个圆从左向右水平移动。图 9-23 显示了几个位置的圆。粗水平线表示时间线。实线圆圈表示时间轴上特定时刻的关键帧。与关键帧相关联的键值显示在顶行。例如,第五秒关键帧的圆的translateX属性值为 500,在图中显示为 tx=500。

A978-1-4302-6662-4_9_Fig23_HTML.jpg

图 9-23。

Animating a circle along a horizontal line using a timeline

时间线、关键帧和关键值由开发人员提供。在你的例子中,你有五个关键帧。如果 JavaFX 在五个相应的时刻只显示五个关键帧,动画看起来会不稳定。为了提供平滑的动画,JavaFX 需要在时间轴上的任意时刻插入圆的位置。也就是说,JavaFX 需要在两个连续的关键帧之间创建中间关键帧。JavaFX 在插值器的帮助下完成这项工作。默认情况下,它使用一个线性插值器,该插值器随时间线性地改变动画的属性。也就是说,如果时间线上的时间超过了 x%,则属性值将是初始目标值和最终目标值之间的 x%。在图中,JavaFX 使用插值器创建了带虚线轮廓的圆。

JavaFX 中提供动画的类在javafx.animation包中,除了Duration类在javafx.util包中。图 9-24 显示了动画相关类的类图。

A978-1-4302-6662-4_9_Fig24_HTML.jpg

图 9-24。

A class diagram for core classes used in animation

抽象的Animation类代表一个动画。它包含所有类型的动画使用的通用属性和方法。JavaFX 支持两种类型的动画:

  • 时间轴动画
  • 过渡

在时间轴动画中,创建时间轴并向其添加关键帧。JavaFX 使用插值器创建中间关键帧。Timeline类的一个实例由时间轴动画表示。这种类型的动画需要您编写更多的代码,但是它给了您更多的控制。

通常执行几种类型的动画,例如,沿着路径移动节点,随时间改变节点的不透明度等。这些类型的动画被称为过渡。它们使用内部时间表来执行。Transition类的一个实例代表一个过渡动画。Transition类的几个子类支持特定类型的转换。例如,FadeTransition类通过随时间改变节点的不透明度来实现渐隐效果动画。您创建了一个Transition类的子类实例,指定了要动画的属性的初始值和最终值,以及动画的持续时间。JavaFX 负责创建时间轴和执行动画。这种类型的动画更容易使用。

有时,您可能希望按顺序或同时执行多个过渡。SequentialTransitionParallelTransition类分别允许您顺序地和同时地执行一组转换。

Duration类在javafx.util包中。它以毫秒、秒、分钟和小时表示持续时间。它是一个不可变的类。一个Duration代表一个动画的每个周期的时间量。一个Duration可以代表一个正的或负的持续时间。

KeyValue类的一个实例表示一个键值,该键值是在动画过程中针对特定间隔插入的。它概括了三件事:

  • 一个目标
  • 目标的结束值
  • 插值器

目标是一个WritableValue,它将所有 JavaFX 属性限定为一个目标。结束值是时间间隔结束时的目标值。插值器用于计算中间关键帧。

关键帧定义了时间轴上指定点的节点的目标状态。目标状态由与关键帧相关联的关键值来定义。一个关键帧包含四件事:

  • 时间轴上的瞬间
  • 一套KeyValues
  • 一个名字
  • 一个ActionEvent处理者

时间轴上与关键帧相关联的瞬间由一个Duration定义,它是时间轴上关键帧的偏移量。这组KeyValue定义了关键帧目标的最终值。一个关键帧可以有一个可选的名称,该名称可以用作一个提示点,以便在动画过程中跳转到它所定义的时刻。Animation类的getCuePoints()方法返回Timeline上提示点的ObservableMap。可选地,您可以将一个ActionEvent处理程序附加到一个KeyFrame上。在动画过程中,当关键帧到达时,调用ActionEvent处理程序。KeyFrame类的一个实例代表一个关键帧。

使用时间轴动画

时间轴动画用于制作节点的任何属性的动画。Timeline类的一个实例代表一个时间轴动画。使用时间轴动画包括以下步骤:

  • 构建关键帧
  • 创建一个带有关键帧的Timeline对象
  • 设置动画属性
  • 使用play()方法运行动画

您可以在创建Timeline时或之后添加关键帧。Timeline实例将所有关键帧保存在一个ObservableList<KeyFrame>对象中。getKeyFrames()方法返回列表的引用。您可以随时修改关键帧列表。如果时间轴动画已经在运行,您需要停止并重新启动它,以获得修改后的关键帧列表。

Timeline类包含几个构造函数:

  • Timeline()
  • Timeline(double targetFramerate)
  • Timeline(double targetFramerate, KeyFrame... keyFrames)
  • Timeline(KeyFrame... keyFrames)

无参数构造函数创建一个没有关键帧的Timeline,动画以最佳速度运行。其他构造函数允许您指定动画的目标帧速率(即每秒的帧数)和关键帧。请注意,关键帧添加到Timeline的顺序并不重要。The timeline会根据他们的时间偏移对他们进行排序。

清单 9-22 中的程序启动了一个时间轴动画,从右到左水平滚动一个文本,直到永远。图 9-25 为动画截图。

清单 9-22。使用时间轴动画滚动文本节点

// ScrollingText.java

package com.jdojo.jfx;

import javafx.animation.KeyFrame;

import javafx.animation.KeyValue;

import javafx.animation.Timeline;

import javafx.application.Application;

import javafx.geometry.VPos;

import javafx.scene.Scene;

import javafx.scene.layout.Pane;

import javafx.scene.text.Font;

import javafx.scene.text.Text;

import javafx.stage.Stage;

import javafx.util.Duration;

public class ScrollingText extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

Text msg = new Text("JavaFX animation is cool!");

msg.setTextOrigin(VPos.TOP);

msg.setFont(Font.font(24));

Pane root = new Pane(msg);

root.setPrefSize(500, 70);

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Scrolling Text");

stage.show();

/* Set up a Timeline animation */

// Get the scene width and the text width

double sceneWidth = scene.getWidth();

double msgWidth = msg.getLayoutBounds().getWidth();

// Create the initial and final key frames

KeyValue initKeyValue =

new KeyValue(msg.translateXProperty(), sceneWidth);

KeyFrame initFrame = new KeyFrame(Duration.ZERO, initKeyValue);

KeyValue endKeyValue =

new KeyValue(msg.translateXProperty(), -1.0 * msgWidth);

KeyFrame endFrame =

new KeyFrame(Duration.seconds(3), endKeyValue);

// Create a Timeline object

Timeline timeline = new Timeline(initFrame, endFrame);

// Let the animation run forever

timeline.setCycleCount(Timeline.INDEFINITE);

// Start the animation

timeline.play();

}

}

A978-1-4302-6662-4_9_Fig25_HTML.jpg

图 9-25。

Scrolling text using a timeline animation

执行动画的逻辑在start()方法中。该方法首先创建一个Text对象,一个带有Text对象的Pane,并为舞台设置一个场景。在展示舞台之后,它设置一个动画。首先,它得到场景和Text物体的宽度。

double sceneWidth = scene.getWidth();

double msgWidth = msg.getLayoutBounds().getWidth();

创建了两个关键帧:一个用于时间= 0 秒,另一个用于时间= 3 秒。动画使用Text对象的translateX属性来改变其水平位置,使其看起来滚动。在 0 秒时,Text被定位在场景宽度,所以它是不可见的。在 3 秒钟时,它被放置在场景左侧,距离等于它的长度,因此它也是不可见的。

KeyValue initKeyValue = new KeyValue(msg.translateXProperty(), sceneWidth);

KeyFrame initFrame = new KeyFrame(Duration.ZERO, initKeyValue);

KeyValue endKeyValue = new KeyValue(msg.translateXProperty(), -1.0 * msgWidth);

KeyFrame endFrame = new KeyFrame(Duration.seconds(3), endKeyValue);

用两个关键帧创建一个Timeline对象。

Timeline timeline = new Timeline(initFrame, endFrame);

默认情况下,动画将只运行一次。也就是说,Text将从右向左滚动一次,动画将停止。可以设置动画的循环次数,即动画需要运行的次数。通过将循环计数设置为Timeline.INDEFINITE,您可以永远运行动画,如下所示:

timeline.setCycleCount(Timeline.INDEFINITE);

最后,通过调用play()方法启动动画。

timeline.play();

这个例子有一个缺陷。当场景宽度改变时,滚动文本不会更新其初始水平位置。只要场景宽度发生变化,就可以通过更新初始关键帧来解决这个问题。将下面的语句添加到清单 9-22 的start()方法。它为场景的width添加了一个ChangeListener,用于更新关键帧并重启动画。

scene.widthProperty().addListener( (prop, oldValue , newValue) -> {

KeyValue kv = new KeyValue(msg.translateXProperty(), scene.getWidth());

KeyFrame kf = new KeyFrame(Duration.ZERO, kv);

timeline.stop();

timeline.getKeyFrames().clear();

timeline.getKeyFrames().addAll(kf, endFrame);

timeline.play();

});

创建一个只有一个关键帧的Timeline动画是可能的。关键帧被视为最后一个关键帧。Timeline使用动画属性的当前值合成一个初始关键帧(时间= 0 秒)。若要查看效果,请替换该语句

Timeline timeline = new Timeline(initFrame, endFrame);

在清单 9-22 中有以下语句

Timeline timeline = new Timeline(endFrame);

Timeline将用Text对象的translateX属性的当前值 0.0 创建一个初始关键帧。这一次,Text的滚动方式有所不同。开始滚动时,将Text设置为 0.0,并向左滚动,因此它超出了场景。

断续器

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

JavaFX 场景图是 Java 对象的层次结构。XML 格式非常适合存储表示某种层次结构的信息。所以用 FXML 存储场景图是非常直观的。

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

让我们快速预览一下 FXML 文档的样子。你想创建一个简单的用户界面。它由一个带LabelVBox和一个Button组成。清单 9-23 包含了构建 UI 的 JavaFX 代码,这是您所熟悉的。清单 9-24 包含了构建相同 UI 的 FXML 版本。

清单 9-23。在 JavaFX 中构建对象图

import javafx.scene.layout.VBox;

import javafx.scene.control.Label;

import javafx.scene.control.Button;

VBox root = new VBox();

root.getChildren().addAll(new Label("FXML is cool"),

new Button("Say Hello"));

清单 9-24。在 FXML 中构建对象图

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

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

<?import javafx.scene.control.Label?>

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

<VBox>

<children>

<Label text="FXML is cool"/>

<Button text="Say Hello"/>

</children>

</VBox>

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

FXML 文档只是一个文本文件。通常,文件名有一个.fxml扩展名,例如hello.fxml。例如,您可以使用记事本在 Windows 上创建 FXML 文档。如果您使用过 XML,就会知道在文本编辑器中编辑大型 XML 文档并不容易。Oracle 提供了一个名为 Scene Builder 的可视化编辑器,用于编辑 FXML 文档。场景构建器是开源的。你可以从 www.oracle.com/technetwork/java/javase/downloads/javafxscenebuilder-info-2157684.html 下载它的最新版本。Scene Builder 也可以集成到 NetBeans IDE 中,因此您可以在 NetBeans IDE 中使用 Scene Builder 编辑 FXML 文档。本书不讨论场景构建器的使用。

在这一节中,我将介绍 FXML 的基础知识。您将使用 FXML 开发一个简单的 JavaFX 应用。该应用包括以下内容:

  • VBox
  • Label
  • Button

VBoxspacing被设置为 10px。LabelButtontext属性被设置为FXML is cool!Say Hello。当点击Button时,Label中的文本变为Hello from FXML!。图 9-26 显示了应用显示的窗口的两个实例。

A978-1-4302-6662-4_9_Fig26_HTML.jpg

图 9-26。

Two instances of a window whose scene graph is created using FXML

清单 9-25 中的程序是这个示例应用的 JavaFX 实现,它使用 Java 编程语言来构建 UI。

清单 9-25。FXML 示例应用的 JavaFX 版本

// SayHelloFX.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.event.ActionEvent;

import javafx.scene.Scene;

import javafx.scene.control.Button;

import javafx.scene.control.Label;

import javafx.scene.layout.VBox;

import javafx.stage.Stage;

public class SayHelloFX extends Application {

private Label msgLbl = new Label("FXML is coll!");

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

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

// Set the preferred width of the lable

msgLbl.setPrefWidth(150);

// Set the ActionEvent handler for the button

sayHelloBtn.setOnAction(this::sayHello);

VBox root = new VBox(10);

root.getChildren().addAll(msgLbl, sayHelloBtn);

root.setStyle("-fx-padding: 10;" +

"-fx-border-style: solid inside;" +

"-fx-border-width: 2;" +

"-fx-border-insets: 5;" +

"-fx-border-radius: 5;" +

"-fx-border-color: blue;");

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Hello FXML");

stage.show();

}

public void sayHello(ActionEvent e) {

msgLbl.setText("Hello from FXML!");

}

}

现在让我们构建清单 9-25 中程序的另一个版本,其中 UI 将使用 FXML 构建。用清单 9-26 所示的内容创建一个 FXML 文件sayhello.fxml。清单 9-26 是你的例子的 FXML 文档。它将为图 9-26 所示的场景创建根元素。将sayhello.fxml文件保存在resources/fxml目录中,其中resources目录的父目录包含在应用的类路径中。假设,在 Windows 上,你已经在类路径中添加了C:\myjavafxsayhello.fxml的路径将是

C:\myjavafx\resources\fxml\sayhello.fxml

清单 9-26。sayhello.fxml 文件的内容

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

<?language javascript?>

<?import javafx.scene.Scene?>

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

<?import javafx.scene.control.Label?>

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

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

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

<Button fx:id="sayHelloBtn" text="Say Hello" onAction="sayHello()"/>

<style>

-fx-padding: 10;

-fx-border-style: solid inside;

-fx-border-width: 2;

-fx-border-insets: 5;

-fx-border-radius: 5;

-fx-border-color: blue;

</style>

<fx:script>

function sayHello() {

msgLbl.setText("Hello from FXML!");

}

</fx:script>

</VBox>

您已经为VBox设置了spacing属性,为LabelButton控件设置了fx:id属性。您已经使用一个<style>属性元素设置了VBoxstyle属性。您可以选择使用样式属性或属性元素来设置样式。您使用了一个属性元素,因为样式值是一个大字符串,如果在多行中输入,可读性会更好。元素用一个函数sayHello()定义了一个脚本块。该函数设置由msgLbl fx:id属性标识的Label的文本属性。您已经将sayHello()功能设置为ButtononAction属性,所以当点击Button时,会执行sayHello()功能。

要从 FML 构建 UI,需要将其加载到 JavaFX 程序中。加载 FXML 是由FXMLLoader类的一个实例执行的,它在javafx.fxml包中。

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

// Build the URL to locate the FXMl file

String fxmlDocUrl = "file:///C:/resources/fxml/test.fxml";

URL fxmlUrl = new URL(fxmlDocUrl);

// Create an FXMLLoader object and set its location that is the URL of the

// FML contents

FXMLLoader loader = new FXMLLoader();

loader.setLocation(fxmlUrl);

// Load the FXML that will return a VBox

VBox root = loader.<VBox>load();

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

// The return type of the load() method will be inferred as VBox

VBox root = loader.load();

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

FXMLLoader loader = new FXMLLoader();

String fxmlDocPath = "C:\\resources\\fxml\\test.fxml";

FileInputStream fxmlStream = new FileInputStream(fxmlDocPath);

VBox root = loader.<VBox>load(fxmlStream);

在内部,FXMLLoader使用流读取文档,这可能会抛出一个IOExceptionFXMLLoader类中 l oad()方法的所有版本都会抛出一个IOException。为了简单起见,我在这些代码片段中省略了异常处理。在您的应用中,您需要处理异常。

加载 FXML 文档后,下一步做什么?此时,FXML 的角色已经结束,JavaFX 代码应该接管它。

清单 9-27 中的程序包含了这个例子的 JavaFX 代码。它加载存储在sayhello.fxml文件中的 FXML 文档。程序从类路径加载文档。加载器返回一个VBox,它被设置为场景的根。代码的其余部分与您以前使用过的相同。请注意start()方法声明中的一个区别。该方法声明它可能抛出一个IOException,您必须添加它,因为您已经在方法内部调用了FXMLLoaderload()方法。运行程序时,会显示如图 9-26 所示的窗口。点击按钮,Label的文本将会改变。

清单 9-27。使用 FXML 构建图形用户界面

// SayHelloFXML.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.fxml.FXMLLoader;

import java.io.IOException;

import java.net.URL;

import javafx.scene.Scene;

import javafx.scene.layout.VBox;

import javafx.stage.Stage;

public class SayHelloFXML extends Application {

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) throws IOException {

// Construct a URL for the FXML document

URL fxmlUrl = this.getClass()

.getClassLoader()

.getResource("resources/fxml/sayhello.fxml");

// Load the FXML document

VBox root = FXMLLoader.<VBox>load(fxmlUrl);

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Hello FXML");

stage.show();

}

}

FXML 提供了比你在这个例子中看到的更多的东西。使用 FXML,您可以将 UI 元素绑定到 JavaFX、数据绑定和事件处理中的变量,创建自定义控件等。讨论这些特性超出了本书的范围。

印刷

JavaFX 8 通过javafx.print包中的 Print API 增加了对打印节点的支持。API 由以下类和一些枚举(未列出)组成:

  • Printer
  • PrinterAttributes
  • PrintResolution
  • PrinterJob
  • JobSettings
  • Paper
  • PaperSource
  • PageLayout
  • PageRange

这些类的实例代表打印过程的不同组件。例如,Printer代表可以用于打印作业的打印机;一个PrinterJob代表一个可以发送到Printer进行打印的打印作业;a Paper代表打印机等上可用的纸张尺寸。

Print API 提供了对打印节点的支持,这些节点可能会也可能不会附加到场景图。打印 web 页面的内容,而不是包含 web 页面的WebView节点,这是一个常见的需求。javafx.scene.web.WebEngine类包含一个打印网页内容的print(PrinterJob job)方法,而不是WebView节点。

如果在打印过程中修改了节点,打印的节点可能看起来不正确。注意,一个节点的打印可能跨越多个脉冲事件,导致正在打印的内容的同时改变。为了确保正确打印,请确保正在打印的节点在打印过程中没有被修改。

节点可以打印在任何线程上,包括 JavaFX 应用线程。建议在后台线程上提交大型、耗时的打印作业,以保持 UI 的响应性。

Print API 中的类是最终的,因为它们表示现有的打印设备属性。它们中的大多数都没有提供任何公共构造函数,因为你不能创建一个打印设备。相反,您可以使用类中的工厂方法来获取它们的引用。

静态方法返回机器上已安装打印机的可见列表。请注意,当安装新打印机或移除旧打印机时,方法返回的打印机列表可能会随时间而变化。使用PrintergetName()方法获取Printer代表的打印机名称。以下代码片段列出了运行该代码的机器上安装的所有打印机。您可能会得到不同的输出。

import javafx.collections.ObservableSet;

import javafx.print.Printer;

...

ObservableSet<Printer> allPrinters = Printer.getAllPrinters();

for(Printer p : allPrinters) {

System.out.println(p.getName());

}

ImageRight Printer

Microsoft XPS Document Writer

PDF995

Sybase DataWindow PS

\\pro-print1\IS-HP4000

\\pro-print1\IS-HP4650(Color)

Printer.getDefaultPrinter()方法返回默认的Printer。如果没有安装打印机,该方法可能返回null。机器上的默认打印机可以更改。因此,该方法可能在调用之间返回不同的打印机,并且返回的打印机可能在一段时间后无效。以下代码片段显示了如何获取默认打印机:

Printer defaultPrinter = Printer.getDefaultPrinter();

if (defaultPrinter!= null) {

String name = defaultPrinter.getName();

System.out.println("Default printer name: " + name);

}

else {

System.out.println("No printers installed.");

}

打印节点很简单:创建一个PrinterJob并调用它的printPage()方法,传递要打印的节点。使用具有所有默认设置的默认打印机打印节点只需要三行代码:

PrinterJob printerJob = PrinterJob.createPrinterJob();

printerJob.printPage(myNode); // myNode is the node to be printed

printerJob.endJob();

在现实世界的应用中,您希望处理错误,上面的代码重写如下:

// Create a printer job for the default printer

PrinterJob printerJob = PrinterJob.createPrinterJob();

if (printerJob!= null) {

// Print the node

boolean printed = printerJob.printPage(node);

if (printed) {

// End the printer job

printerJob.endJob();

}

else {

System.out.println("Printing failed.");

}

}

else {

System.out.println("Could not create a printer job.");

}

您可以使用PrinterJob类的createPrinterJob()静态方法来创建打印机作业。该方法被重载,如下所示:

  • static PrinterJob createPrinterJob()
  • static PrinterJob createPrinterJob(Printer printer)

不带-args 的方法为默认打印机创建一个打印机作业。您可以使用其他版本的方法为指定的打印机创建打印机作业。

您可以通过调用setPrinter()方法来更改PrinterJob的打印机。如果新打印机不支持当前的打印机作业设置,新打印机会自动重置这些设置。

// Set a new printer for the printer job

printerJob.setPrinter(myNewPrinter);

null设置为作业的打印机将使用默认打印机。使用PrinterJob类的以下printPage()方法之一打印节点:

  • boolean printPage(Node node)
  • boolean printPage(PageLayout pageLayout, Node node)

该方法的第一个版本只接受要打印的节点作为参数。它使用作业的默认页面布局进行打印。第二个版本允许您指定打印节点的页面布局。如果打印成功,该方法返回 true。否则,它返回 false。

完成打印后,调用endJob()方法。如果作业可以成功地假脱机到打印机队列,则该方法返回 true。否则,它将返回 false,这可能表示作业无法假脱机或已经完成。作业成功完成后,就不能再重复使用了。

您可以使用PrinterJobcancelJob()方法取消打印作业。打印可能不会立即取消,例如,当页面正在打印时。取消会尽快发生。如果出现以下情况,则该方法没有任何效果

  • 已经请求取消该作业。
  • 这项工作已经完成。
  • 作业有错误。

PrinterJob类包含一个只读的jobStatus属性,表示打印作业的当前状态。状态由PrinterJob.JobStatus枚举的下列常量之一定义:

  • NOT_STARTED
  • PRINTING
  • CANCELED
  • DONE
  • ERROR

NOT_STARTED状态表示一个新任务。在这种状态下,可以配置作业并开始打印。PRINTING状态表示作业已请求打印至少一页,并且尚未终止打印。在这种状态下,无法配置作业。

其他三种状态,CANCELEDDONEERROR,表示作业的终止状态。一旦作业处于其中一种状态,就不应该重复使用。当状态变为CANCELEDERROR时,不需要调用endJob()方法。当打印成功并且调用了endJob()方法时,进入DONE状态。

清单 9-28 中的程序显示了如何打印节点。它显示一个TextArea,您可以在其中输入文本。提供了两个Button:一个打印TextArea节点,另一个打印整个场景。当开始打印时,打印作业状态显示在Label中。print()方法中的代码与您在示例中看到的代码相同。该方法包括在Label中显示作业状态的逻辑。程序显示如图 9-27 所示的窗口。运行程序,在TextArea, and中输入文本点击两个按钮之一进行打印。

清单 9-28。打印节点

// PrintingNodes.java

package com.jdojo.jfx;

import javafx.application.Application;

import javafx.print.PrinterJob;

import javafx.scene.Node;

import javafx.scene.Scene;

import javafx.scene.control.Button;

import javafx.scene.control.Label;

import javafx.scene.control.TextArea;

import javafx.scene.layout.HBox;

import javafx.scene.layout.VBox;

import javafx.stage.Stage;

public class PrintingNodes extends Application {

private Label jobStatus = new Label();

public static void main(String[] args) {

Application.launch(args);

}

@Override

public void start(Stage stage) {

VBox root = new VBox(5);

Label textLbl = new Label("Text:");

TextArea text = new TextArea();

text.setPrefRowCount(10);

text.setPrefColumnCount(20);

text.setWrapText(true);

// Button to print the TextArea node

Button printTextBtn = new Button("Print Text");

printTextBtn.setOnAction(e -> print(text));

// Button to print the entire scene

Button printSceneBtn = new Button("Print Scene");

printSceneBtn.setOnAction(e -> print(root));

HBox jobStatusBox =

new HBox(5, new Label("Print Job Status:"), jobStatus);

HBox buttonBox = new HBox(5, printTextBtn, printSceneBtn);

root.getChildren().addAll(textLbl, text, jobStatusBox, buttonBox);

Scene scene = new Scene(root);

stage.setScene(scene);

stage.setTitle("Printing Nodes");

stage.show();

}

private void print(Node node) {

jobStatus.textProperty().unbind();

jobStatus.setText("Creating a printer job...");

// Create a printer job for teh default printer

PrinterJob job = PrinterJob.createPrinterJob();

if (job != null) {

// Show the printer job status

jobStatus.textProperty().bind(job.jobStatusProperty().asString());

// Print the node

boolean printed = job.printPage(node);

if (printed) {

// End the printer job

job.endJob();

}

else {

jobStatus.textProperty().unbind();

jobStatus.setText("Printing failed.");

}

}

else {

jobStatus.setText("Could not create a printer job.");

}

}

}

A978-1-4302-6662-4_9_Fig27_HTML.jpg

图 9-27。

A window letting the user print text in a TextArea and the scene

打印 API 提供了更多的打印特性,比如显示打印对话框。更多细节请参考 JavaFX API 文档中的javafx.print包中的类。

摘要

JavaFX 是一个开源的基于 Java 的 GUI 框架,用于开发富客户端应用。它是 Swing 在 Java 平台 GUI 开发技术领域的继承者。

JavaFX 中的 GUI 分阶段显示。stage 是Stage类的一个实例。舞台是桌面应用中的窗口,也是 web 应用中浏览器中的区域。一个舞台包含一个场景。场景包含一组以树状结构排列的节点(图形)。

JavaFX 应用继承自Application类。JavaFX 运行时创建名为 primary stage 的第一个 stage,并调用 application 类的start()方法,传递 primary stage 的引用。开发人员需要向舞台添加一个场景,并使舞台可见。

JavaFX 支持属性类,其实例用于表示类的属性。属性支持单向和双向绑定。如果属性绑定到表达式,属性值会自动与表达式的值同步。属性支持失效和更改通知。感兴趣的人可以注册这些通知。当属性变得无效或其值发生变化时,它们会收到通知。当属性的依赖关系发生变化时,该属性将变得无效。

JavaFX 提供了可观察的列表、集合和映射,它们是ObservableListObservableSetObservableMap接口的实例。可以观察到它们的失效和变化。FXCollections class包含创建这种可观察集合的实例的工厂方法。

JavaFX 支持 UI 元素的事件处理。您可以为 UI 元素注册事件处理程序。当事件发生时,将执行您注册的事件处理程序。

JavaFX 提供了布局窗格,它们是节点的容器。它们以特定的方式排列节点。例如,HBox布局窗格通过将节点水平放置在一行来排列节点,而VBox布局窗格通过将节点垂直放置在一列来排列节点。JavaFX 提供了丰富的控件集,如ButtonLabelChoiceBoxComboBoxTextFieldDatePicker等。HTMLEditor控件提供了编辑富文本的功能。WebView节点用于显示网页内容。

JavaFX 为绘制 2D 和 3D 图形提供了广泛的支持。它提供了画布 API 来使用绘图命令在画布上绘制 2D 形状。Canvas API 还允许您访问(读写)画布表面上的像素。

通过编写几行代码,可以将效果、变换和动画应用于场景中的节点。JavaFX 支持 FXML,这是一种基于 XML 的标记语言,用于为 JavaFX 应用构建 GUI。您可以使用打印 API 打印节点和网页内容。

十、使用 Java 编写脚本

在本章中,您将学习

  • 什么是 Java 脚本
  • 如何从 Java 执行脚本以及如何向脚本传递参数
  • 如何在执行脚本时使用ScriptContext
  • 如何在脚本中使用 Java 编程语言
  • 如何实现脚本引擎
  • 如何使用jrunscriptjjs命令行工具来执行脚本

Java 中的脚本是什么?

有人认为 Java 虚拟机(JVM)可以执行只用 Java 编程语言编写的程序。然而,事实并非如此。JVM 执行语言无关的字节码。如果程序可以被编译成 Java 字节码,它可以执行用任何编程语言编写的程序。

是一种编程语言,它为您提供了编写脚本的能力,这些脚本由称为的运行时环境(或解释器)进行评估(或解释)。脚本是使用脚本语言的语法编写的字符序列,用作由解释器执行的程序的源。解释器解析脚本,产生中间代码,中间代码是程序的内部表示,并执行中间代码。解释器将脚本中使用的变量存储在名为。

通常,与编译编程语言不同,脚本语言中的源代码(称为脚本)不是编译的,而是在运行时解释的。然而,用一些脚本语言编写的脚本可以被编译成 Java 字节码,由 JVM 运行。

Java 6 为 Java 平台增加了脚本支持,允许 Java 应用执行用 Rhino JavaScript、Groovy、Jython、JRuby、Nashorn JavaScript 等脚本语言编写的脚本。支持双向通信。它还允许脚本访问由宿主应用创建的 Java 对象。Java 运行时和脚本语言运行时可以相互通信并利用彼此的特性。

Java 对脚本语言的支持来自 Java 脚本 API。Java 脚本 API 中的所有类和接口都在javax.script包中。

在 Java 应用中使用脚本语言有几个好处:

  • 大多数脚本语言都是动态类型的,这使得编写程序更加简单。
  • 它们为开发和测试小型应用提供了一种更快捷的方式。
  • 最终用户可以进行定制。
  • 脚本语言可以提供 Java 中没有的特定领域的特性。

脚本语言也有一些缺点。例如,动态类型有利于编写更简单的代码;然而,当一个类型被错误地解释时,它就变成了一个缺点,你必须花很多时间去调试它。

Java 中的脚本支持让您可以利用两个世界的优势:它允许您使用 Java 编程语言来开发应用的静态类型、可伸缩和高性能部分,并使用适合特定领域需求的脚本语言来开发其他部分。

我将在本章中频繁使用术语脚本引擎。是一种执行用特定脚本语言编写的程序的软件组件。通常,但不一定,脚本引擎是脚本语言的解释器的实现。Java 已经实现了几种脚本语言的解释器。它们公开了编程接口,因此 Java 程序可以与它们进行交互。

JDK 7 和一个叫做 Rhino JavaScript 的脚本引擎捆绑在一起。JDK 8 用一个轻量级、更快的脚本引擎 Nashorn JavaScript 取代了 Rhino JavaScript 引擎。本章讨论的是 Nashorn JavaScript,而不是 Rhino JavaScript。请访问 www.mozilla.org/rhino 了解更多关于 Rhino JavaScript 文档的详细信息。如果你想把用 Rhino JavaScript 编写的程序迁移到 Nashorn,请访问 https://wiki.openjdk.java.net/display/Nashorn/Rhino+Migration+Guide 的 Rhino 迁移指南。如果你有兴趣在 JDK 8 中使用 Rhino JavaScript,请访问页面 https://wiki.openjdk.java.net/display/Nashorn/Using+Rhino+JSR-223+engine+with+JDK8

Java 包含一个名为jrunscript的命令行 shell,可以用来以交互模式或批处理模式运行脚本。jrunscript shell 是脚本语言中立的;JDK 7 的默认语言是 Rhino JavaScript,JDK 8 的默认语言是 Nashorn。我将在本章后面详细讨论jrunscript外壳。JDK 8 包括另一个名为jjs的命令行工具,它调用 Nashorn 引擎并提供特定于 Nashorn 的命令行选项。如果你正在使用 Nashorn,你应该使用jjs命令行工具而不是jrunscript。我将在本章后面讨论jjs命令行工具。

Java 可以执行任何为脚本引擎提供实现的脚本语言的脚本。比如 Java 可以执行 Nashorn JavaScript、Rhino JavaScript、Groovy、Jython、JRuby 等编写的脚本。本章中的示例使用 Nashorn JavaScript 语言。

在本章中,术语“Nashorn”、“Nashorn 引擎”、“Nashorn JavaScript”、“Nashorn JavaScript 引擎”、“Nashorn 脚本语言”和“JavaScript”作为同义词使用。

执行您的第一个脚本

在本节中,您将使用 Nashorn 在标准输出上打印一条消息。使用任何其他脚本语言都可以使用相同的步骤来打印消息,只有一点不同:您需要使用特定于脚本语言的代码来打印消息。要用 Java 运行脚本,您需要执行以下三个步骤:

  • 创建脚本引擎管理器。
  • 从脚本引擎管理器获取脚本引擎的实例。
  • 调用脚本引擎的eval()方法执行脚本。

脚本引擎管理器是类的一个实例。

// Create an script engine manager

ScriptEngineManager manager = new ScriptEngineManager();

接口的一个实例代表了 Java 程序中的一个脚本引擎。一个ScriptEngineManagergetEngineByName(String engineShortName)方法用于获取一个脚本引擎的实例。要获得 Nashorn 引擎的实例,使用JavaScript作为引擎的简称,如下所示:

// Get the reference of a Nashorn engine

ScriptEngine engine = manager.getEngineByName("JavaScript");

Tip

脚本引擎的简称区分大小写。有时一个脚本引擎有多个简称。Nashorn 发动机有以下简称:nashornNashornjsJSJavaScriptjavascriptECMAScriptecmascript。您可以使用引擎的任何简称,通过使用ScriptEngineManager类的getEngineByName()方法来获得它的实例。

在 Nashorn 中,函数在标准输出上打印一条消息,字符串是用单引号或双引号括起来的字符序列。以下代码片段在一个String对象中存储了一个脚本,该脚本在标准输出中打印Hello Scripting!:

// Store a Nashorn script in a string

String script = "print('Hello Scripting!')";

如果要在 Nashorn 中使用双引号将字符串括起来,该语句将如下所示:

// Store a Nashorn script in a string

String script = "print(\"Hello Scripting!\")";

要执行脚本,需要将其传递给脚本引擎的eval()方法。脚本引擎在运行脚本时可能会抛出一个ScriptException。因此,当您调用ScriptEngineeval()方法时,您需要处理这个异常。以下代码片段执行存储在script变量中的脚本:

try {

engine.eval(script);

}

catch (ScriptException e) {

e.printStackTrace();

}

清单 10-1 包含了在标准输出上打印消息的完整代码。

清单 10-1。使用 Nashorn 在标准输出上打印信息

// HelloScripting.java

package com.jdojo.script;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class HelloScripting {

public static void main(String[] args) {

// Create a script engine manager

ScriptEngineManager manager = new ScriptEngineManager();

// Obtain a Nashorn script engine from the manager

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Store the script in a String

String script = "print('Hello Scripting!')";

try {

// Execute the script

engine.eval(script);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

}

Hello Scripting!

使用其他脚本语言

在 Java 程序中使用除 Nashorn 之外的脚本语言非常简单。在使用脚本引擎之前,您只需要执行一项任务:在应用类路径中包含特定脚本引擎的 JAR 文件。脚本引擎的实现者提供这些 JAR 文件。

Java 使用一种发现机制来列出其 JAR 文件已经包含在应用类路径中的所有脚本引擎。接口的一个实例用于创建和描述一个脚本引擎。脚本引擎的提供者为ScriptEngineFactory接口提供了一个实现。ScriptEngineManager的方法返回一个所有可用脚本引擎工厂的List<ScriptEngineFactory>ScriptEngineFactorygetScriptEngine()方法返回ScriptEngine的一个实例。工厂的其他几个方法返回关于引擎的元数据。

清单 10-2 显示了如何打印所有可用脚本引擎的细节。输出显示 Groovy、Jython 和 JRuby 的脚本引擎可用。它们之所以可用,是因为我已经将它们引擎的 JAR 文件添加到了我机器上的类路径中。当您在类路径中包含了脚本引擎的 JAR 文件,并且想知道脚本引擎的简称时,这个程序会很有帮助。运行该程序时,您可能会得到不同的输出。

清单 10-2。列出所有可用的脚本引擎

// ListingAllEngines.java

package com.jdojo.script;

import java.util.List;

import javax.script.ScriptEngineFactory;

import javax.script.ScriptEngineManager;

public class ListingAllEngines {

public static void main(String[] args) {

ScriptEngineManager manager = new ScriptEngineManager();

// Get the list of all available engines

List<ScriptEngineFactory> list = manager.getEngineFactories();

// Print the details of each engine

for (ScriptEngineFactory f : list) {

System.out.println("Engine Name:" + f.getEngineName());

System.out.println("Engine Version:" +

f.getEngineVersion());

System.out.println("Language Name:" + f.getLanguageName());

System.out.println("Language Version:" +

f.getLanguageVersion());

System.out.println("Engine Short Names:" + f.getNames());

System.out.println("Mime Types:" + f.getMimeTypes());

System.out.println("----------------------------");

}

}

}

Engine Name:

Engine Version:2.5.3

Language Name:python

Language Version:2.5

Engine Short Names:[python, jython]

Mime Types:[text/python, application/python, text/x-python, application/x-python]

----------------------------

Engine Name:JSR 223 JRuby Engine

Engine Version:1.7.0.preview1

Language Name:ruby

Language Version:jruby 1.7.0.preview1

Engine Short Names:[ruby, jruby]

Mime Types:[application/x-ruby]

----------------------------

Engine Name:Groovy Scripting Engine

Engine Version:2.0

Language Name:Groovy

Language Version:2.0.0-rc-2

Engine Short Names:[groovy, Groovy]

Mime Types:[application/x-groovy]

----------------------------

Engine Name:Oracle Nashorn

Engine Version:1.8.0_05

Language Name:ECMAScript

Language Version:ECMA - 262 Edition 5.1

Engine Short Names:[nashorn, Nashorn, js, JS, JavaScript, javascript, ECMAScript, ecmascript]

Mime Types:[application/javascript, application/ecmascript, text/javascript, text/ecmascript]

----------------------------

表 10-1 列出了在 Java 应用中使用脚本引擎之前,如何安装脚本引擎的详细信息。网站列表和说明在撰写本文时仍然有效;它们可能在阅读时失效。但是,它们向您展示了脚本语言的脚本引擎是如何安装的。如果你对使用 Nashorn 感兴趣,你不需要在你的机器上安装任何东西。Nashorn 在 JDK 8 中可用。

表 10-1。

Installation Details for Installing Some Script Engines

| 脚本引擎 | 版本 | 网站(全球资讯网的主机站) | 安装说明 | | --- | --- | --- | --- | | 绝妙的 | Two point three | [`groovy.codehaus.org`](http://groovy.codehaus.org) | 下载 Groovy 的安装文件;这是一个压缩文件。拉开拉链。在`embeddable`文件夹中查找名为`groovy-all-2.0.0-rc-2.jar`的 JAR 文件。将这个 JAR 文件添加到类路径中。 | | 脚本语言 | 2.5.3 | [`www.jython.org`](http://www.jython.org/) | 下载 Jython 安装程序文件,它是一个 JAR 文件。提取`jython.jar`文件并将其添加到类路径中。 | | JRuby | 1.7.13 | [`www.jruby.org`](http://www.jruby.org/) | 下载 JRuby 安装文件。您可以选择下载一个 ZIP 文件。拉开拉链。在`lib`文件夹中,您将找到一个需要包含在类路径中的`jruby.jar`文件。 |

清单 10-3 展示了如何使用 JavaScript、Groovy、Jython 和 JRuby 在标准输出上打印消息。如果脚本引擎不可用,程序会打印一条消息说明这一点。

清单 10-3。使用不同的脚本语言在标准输出上打印消息

// HelloEngines.java

package com.jdojo.script;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class HelloEngines {

public static void main(String[] args) {

// Get the script engine manager

ScriptEngineManager manager = new ScriptEngineManager();

// Try executing scripts in Nashorn, Groovy, Jython, and JRuby

execute(manager, "JavaScript", "print('Hello JavaScript')");

execute(manager, "Groovy", "println('Hello Groovy')");

execute(manager, "jython", "print 'Hello Jython'");

execute(manager, "jruby", "puts('Hello JRuby')");

}

public static void execute(ScriptEngineManager manager,

String engineName,

String script) {

// Try getting the engine

ScriptEngine engine = manager.getEngineByName(engineName);

if (engine == null) {

System.out.println(engineName + " is not available.");

return;

}

// If we get here, it means we have the engine installed.

// So, run the script

try {

engine.eval(script);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

}

Hello JavaScript

Hello Groovy

Hello Jython

Hello JRuby

有时,您可能只是为了好玩而想使用脚本语言,并且您不知道用于在标准输出中打印消息的语法。ScriptEngineFactory类包含一个名为getOutputStatement(String toDisplay)的方法,您可以用它来查找在标准输出中打印文本的语法。以下代码片段显示了如何获取 Nashorn 的语法:

// Get the script engine factory for Nashorn

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

ScriptEngineFactory factory = engine.getFactory();

// Get the script

String script = factory.getOutputStatement("\"Hello JavaScript\"");

System.out.println("Syntax: " + script);

// Evaluate the script

engine.eval(script);

Syntax: print("Hello JavaScript")

Hello JavaScript

对于其他脚本语言,使用它们的引擎工厂来获得语法。

探索 javax.script 包

Java 中的 Java 脚本 API 由少量的类和接口组成。它们在javax.script包里。本章包含了对这个包中的类和接口的简要描述。我将在后面的章节中讨论它们的用法。

ScriptEngine 和 ScriptEngineFactory 接口

接口是 Java 脚本 API 的主接口,它的实例有助于执行用特定脚本语言编写的脚本。

ScriptEngine接口的实现者也提供了ScriptEngineFactory接口的实现。一辆ScriptEngineFactory执行两项任务:

  • 它创建脚本引擎的实例。
  • 它提供关于脚本引擎的信息,如引擎名称、版本、语言等。

AbstractScriptEngine 类

AbstractScriptEngine类是一个抽象类。它为ScriptEngine接口提供了部分实现。除非实现脚本引擎,否则不能直接使用该类。

ScriptEngineManager 类

类为脚本引擎提供了发现和实例化机制。它还维护一个键-值对的映射,作为存储状态的Bindings接口的实例,由它创建的所有脚本引擎共享。

可编译接口和 CompiledScript 类

该接口可以可选地由脚本引擎实现,该脚本引擎允许编译脚本以便重复执行,而无需重新编译。

该类是一个抽象类。它是由脚本引擎的提供者扩展的。它以编译形式存储脚本,无需重新编译即可重复执行。请注意,使用ScriptEngine重复执行脚本会导致脚本每次都要重新编译,从而降低性能。

支持脚本编译不需要脚本引擎。如果支持脚本编译,它必须实现Compilable接口。

可调用的接口

该接口可以可选地由脚本引擎实现,该脚本引擎可以允许调用先前已经编译过的脚本中的过程、函数和方法。

绑定接口和简单绑定类

实现接口的类的一个实例是一个键-值对的映射,有一个限制,即一个键必须是非空的,非空的String。它扩展了java.util.Map接口。SimpleBindings类是Bindings接口的一个实现。

ScriptContext 接口和 SimpleScriptContext 类

接口的实例充当 Java 主机应用和脚本引擎之间的桥梁。它用于将 Java 主机应用的执行上下文传递给脚本引擎。脚本引擎可以在执行脚本时使用上下文信息。脚本引擎可以将其状态存储在实现ScriptContext接口的类的实例中,Java 主机应用可以访问该接口。

SimpleScriptContext类是ScriptContext接口的一个实现。

ScriptException 类

该类是一个异常类。如果在脚本的执行、编译或调用过程中出现错误,脚本引擎会抛出一个ScriptException。该类包含三个有用的方法,分别叫做getLineNumber()getColumnNumber()getFileName()。这些方法报告发生错误的脚本的行号、列号和文件名。ScriptException类覆盖了Throwable类的getMessage()方法,并在它返回的消息中包含行号、列号和文件名。

发现和实例化脚本引擎

您可以使用ScriptEngineFactoryScriptEngineManager创建脚本引擎。谁真正负责创建一个脚本引擎:ScriptEngineFactoryScriptEngineManager,或者两者都有?简单的回答是,ScriptEngineFactory总是负责创建脚本引擎的实例。下一个问题是“a ScriptEngineManager的作用是什么?”

A ScriptEngineManager使用服务提供者机制来定位所有可用的脚本引擎工厂。它搜索类路径和其他标准目录中的所有 JAR 文件。它寻找一个资源文件,这是一个名为javax.script.ScriptEngineFactory的文本文件,位于名为META-INF/的目录下。资源文件由实现ScriptEngineFactory接口的类的完全限定名组成。每个类名在单独的一行中指定。该文件可能包含以#字符开头的注释。示例资源文件可能包含以下内容,其中包括两个脚本引擎工厂的类名:

#Java Kishori Script Engine Factory class

com.jdojo.script.JKScriptEngineFactory

#Another factory class

com.jdojo.script.FunScriptFactory

一个ScriptEngineManager定位并实例化所有可用的ScriptEngineFactory类。您可以使用ScriptEngineManager类的方法获得所有工厂类的实例列表。当您调用管理器的一个方法来根据一个标准获得一个脚本引擎时,例如通过名称获得一个引擎的getEngineByName(String shortName)方法,管理器搜索该标准的所有工厂并返回匹配的脚本引擎引用。如果没有工厂能够提供匹配的引擎,经理返回null。请参考清单 10-2,了解更多关于列出所有可用工厂和描述它们可以创建的脚本引擎的细节。

现在你知道了ScriptEngineManager并不创建脚本引擎的实例。相反,它查询所有可用的工厂,并将工厂创建的脚本引擎的引用传递回调用者。

为了使讨论完整,让我们添加一个创建脚本引擎的方法。您可以通过三种方式创建脚本引擎的实例:

  • 直接实例化脚本引擎类。
  • 直接实例化脚本引擎工厂类,调用其getScriptEngine()方法。
  • 使用ScriptEngineManager类的getEngineByXxx()方法之一。

建议使用ScriptEngineManager类来获取脚本引擎的实例。这个方法允许由同一个管理器创建的所有引擎共享一个状态,这个状态是作为Bindings接口的一个实例存储的一组键-值对。ScriptEngineManager实例存储这个状态。

Tip

一个应用中可能有多个ScriptEngineManager类的实例。在这种情况下,每个ScriptEngineManager实例维护一个它创建的所有引擎共有的状态。也就是说,如果两个引擎是由ScriptEngineManager类的两个不同实例获得的,那么这些引擎将不会共享由它们的管理器维护的一个公共状态,除非您以编程方式实现。

执行脚本

一个ScriptEngine可以执行一个String和一个Reader中的脚本。使用Reader,您可以执行存储在网络或文件中的脚本。ScriptEngine接口的方法的以下版本之一用于执行脚本:

  • Object eval(String script)
  • Object eval(Reader reader)
  • Object eval(String script, Bindings bindings)
  • Object eval(Reader reader, Bindings bindings)
  • Object eval(String script, ScriptContext context)
  • Object eval(Reader reader, ScriptContext context)

eval()方法的第一个参数是脚本的源。第二个参数允许您将信息从宿主应用传递到脚本引擎,这些信息可以在脚本执行期间使用。

在清单 10-1 中,您看到了如何使用第一个版本的eval()方法使用一个String对象来执行一个脚本。在本节中,您将把您的脚本存储在一个文件中,并使用一个Reader对象作为脚本的源,它将使用第二个版本的eval()方法。下一节将讨论eval()方法的其他四个版本。通常,脚本文件会被赋予一个.js扩展名。

清单 10-4 显示了一个名为helloscript.js的文件的内容。它在 Nashorn 中只包含一个在标准输出中打印消息的语句。

清单 10-4。helloscript.js 文件的内容

// Print a message

print('Hello from JavaScript!');

清单 10-5 中的 Java 程序执行存储在helloscript.js文件中的脚本,该文件应该存储在当前目录中。如果没有找到脚本文件,程序会在需要的地方打印出helloscript.js文件的完整路径。如果您在执行脚本文件时遇到问题,请尝试使用main()方法中的绝对路径,比如 Windows 上的C:\scripts\helloscript.js,假设helloscript.js文件保存在C:\scripts目录中。

清单 10-5。执行存储在文件中的脚本

// ReaderAsSource.java

package com.jdojo.script;

import java.io.IOException;

import java.io.Reader;

import java.nio.file.Files;

import java.nio.file.Path;

import java.nio.file.Paths;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class ReaderAsSource {

public static void main(String[] args) {

// Construct the script file path

String scriptFileName = "helloscript.js";

Path scriptPath = Paths.get(scriptFileName);

// Make sure the script file exists. If not, print the full path of

// the script file and terminate the program.

if (! Files.exists(scriptPath) ) {

System.out.println(scriptPath.toAbsolutePath() +

" does not exist.");

return;

}

// Get the Nashorn script engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

try {

// Get a Reader for the script file

Reader scriptReader = Files.newBufferedReader(scriptPath);

// Execute the script in the file

engine.eval(scriptReader);

}

catch (IOException | ScriptException e) {

e.printStackTrace();

}

}

}

Hello from JavaScript!

在实际应用中,您应该将所有脚本存储在允许修改脚本而无需修改和重新编译 Java 代码的文件中。在本章的大部分例子中,你不会遵循这个规则;您将把您的脚本存储在String对象中,以保持代码简短。

传递参数

Java 脚本 API 允许您将参数从主机环境(Java 应用)传递到脚本引擎,反之亦然。在本节中,您将看到宿主应用和脚本引擎之间的参数传递机制的技术细节。

从 Java 代码向脚本传递参数

Java 程序可以向脚本传递参数。Java 程序也可以在脚本执行后访问脚本中声明的全局变量。让我们讨论一个简单的例子,Java 程序向脚本传递一个参数。考虑清单 10-6 中的程序,它将一个参数传递给一个脚本。

清单 10-6。从 Java 程序向脚本传递参数

// PassingParam.java

package com.jdojo.script;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class PassingParam {

public static void main(String[] args) {

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Store the script in a String. Here, msg is a variable

// that we have not declared in the script

String script = "print(msg)";

try {

// Store a parameter named msg in the engine

engine.put("msg", "Hello from Java program");

// Execute the script

engine.eval(script);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

}

Hello from Java program

程序将一个脚本存储在一个String对象中,如下所示:

// Store a Nashorn script in a String object

String script = "print(msg)";

在语句中,脚本是

print(msg)

注意msg是在print()函数调用中使用的变量。脚本没有声明msg变量,也没有给它赋值。如果你试图在不告诉引擎什么是msg变量的情况下执行上述脚本,引擎将抛出一个异常,声明它不理解变量msg的含义。这就是将参数从 Java 程序传递到脚本引擎的概念发挥作用的地方。

可以通过几种方式将参数传递给脚本引擎。最简单的方法是使用脚本引擎的put(String paramName, Object paramValue)方法,它接受两个参数:

  • 第一个参数是参数的名称,它需要与脚本中变量的名称相匹配。
  • 第二个参数是参数的值。

在您的例子中,您希望将一个名为msg的参数传递给脚本引擎,它的值是一个String。对方法的调用是

// Store the value of the msg parameter in the engine

engine.put("msg", "Hello from Java program");

注意,在调用eval()方法之前,必须先调用引擎的put()方法。在您的例子中,当引擎试图执行print(msg)时,它将使用您传递给引擎的msg参数的值。

大多数脚本引擎允许您使用传递给它的参数名作为脚本中的变量名。当您传递名为msg的参数值并在清单 10-6 的脚本中将它用作变量名时,您看到了这种例子。脚本引擎可能要求在脚本中声明变量,例如,在 PHP 中变量名必须以$前缀开头,而在 JRuby 中全局变量名包含$前缀。如果您想将名为msg的参数传递给 JRuby 中的脚本,您的代码应该如下所示:

// Get the JRuby script engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("jruby");

// Must use the $ prefix in JRuby script

String script = "puts($msg)";

// No $ prefix used in passing the msg parameter to the JRuby engine

engine.put("msg", "Hello from Java");

// Execute the script

engine.eval(script);

传递给脚本的 Java 对象的属性和方法可以在脚本中访问,就像在 Java 代码中访问一样。不同的脚本语言使用不同的语法来访问脚本中的 Java 对象。例如,你可以在清单 10-6 所示的例子中使用表达式msg.toString(),输出将是相同的。在这种情况下,您正在调用变量msgtoString()方法。将清单 10-6 中赋值给script变量的语句改为如下并运行程序,这将产生相同的输出:

String script = "println(msg.toString())";

从脚本向 Java 代码传递参数

脚本引擎可以使变量在其全局范围内对 Java 代码可用。ScriptEngineget(String variableName)方法用于访问 Java 代码中的那些变量。它返回一个 Java Object。全局变量的声明依赖于脚本语言。以下代码片段声明了一个全局变量,并在 JavaScript 中为其赋值:

// Declare a variable named year in Nashorn

var year = 1969;

清单 10-7 包含了一个程序,展示了如何从 Java 代码中访问 Nashorn 中的一个全局变量。

清单 10-7。在 Java 代码中访问脚本全局变量

// AccessingScriptVariable.java

package com.jdojo.script;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class AccessingScriptVariable {

public static void main(String[] args) {

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Write a script that declares a global variable named year and

// assign it a value of 1969.

String script = "var year = 1969";

try {

// Execute the script

engine.eval(script);

// Get the year global variable from the engine

Object year = engine.get("year");

// Print the class name and the value of the variable year

System.out.println("year's class:" +

year.getClass().getName());

System.out.println("year's value:" + year);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

}

year's class:java.lang.Integer

year's value:1969

程序在脚本中声明了一个全局变量year,并给它赋值 1969,如下所示:

String script = "var num = 1969";

当脚本执行时,引擎将year变量添加到它的状态中。在 Java 代码中,引擎的get()方法用于检索year变量的值,如下所示:

Object year = engine.get("year");

当脚本中声明了year变量时,您没有指定它的数据类型。脚本变量值到适当 Java 对象的转换是自动执行的。如果您在 Java 7 中运行程序,您的输出将显示java.lang.Double作为类名,1960.0 作为year变量的值。这是因为 Java 7 使用 Rhino 脚本引擎将 1969 年解释为Double,而 Java 8 使用 Nashorn 脚本引擎将其解释为Integer

高级参数传递技术

为了理解参数传递机制的细节,必须清楚地理解三个术语:绑定、范围和上下文。这些术语起初令人困惑。本节使用以下步骤解释参数传递机制:

  • 首先,它定义了这些术语。
  • 其次,它定义了这些术语之间的关系。
  • 第三,它解释了如何在 Java 代码中使用它们。

粘合剂

是一组键-值对,其中所有键都必须是非空的非空字符串。在 Java 代码中,BindingsBindings接口的一个实例。SimpleBindings类是Bindings接口的一个实现。脚本引擎可以提供自己的Bindings接口实现。

Tip

如果你熟悉java.util.Map界面,就很容易理解BindingsBindings接口继承了Map<String,Object>接口。因此,Bindings只是一个Map,它的键必须是非空的非空字符串。

清单 10-8 显示了如何使用一个Bindings。它创建一个SimpleBindings的实例,添加一些键值对,检索键值,删除键值对,等等。Bindings接口的get()方法返回null,如果键不存在或者键存在且其值为null。如果你想测试一个键是否存在,你需要调用它的contains()方法。

清单 10-8。使用绑定对象

// BindingsTest.java

package com.jdojo.script;

import javax.script.Bindings;

import javax.script.SimpleBindings;

public class BindingsTest {

public static void main(String[] args) {

// Create a Bindings instance

Bindings params = new SimpleBindings();

// Add some key-value pairs

params.put("msg", "Hello");

params.put("year", 1969);

// Get values

Object msg = params.get("msg");

Object year = params.get("year");

System.out.println("msg = " + msg);

System.out.println("year = " + year);

// Remove year from Bindings

params.remove("year");

year = params.get("year");

boolean containsYear = params.containsKey("year");

System.out.println("year = " + year);

System.out.println("params contains year = " + containsYear);

}

}

msg = Hello

year = 1969

year = null

params contains year = false

你不能单独使用一个Bindings。通常,您会使用它将参数从 Java 代码传递到脚本引擎。ScriptEngine接口包含一个返回Bindings接口实例的方法。这个方法给脚本引擎一个机会来返回Bindings接口的特定实现的实例。您可以使用如下所示的方法:

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Instead of instantiating the SimpleBindings class, use the

// createBindings() method of the engine

Bindings params = engine.createBindings();

// Work with params as usual

范围

让我们转到下一个术语,即范围。一个作用域用于一个BindingsBindings的范围决定了它的键值对的可见性。您可以让多个Bindings出现在多个作用域中。但是,一个Bindings只能出现在一个作用域中。你如何指定一个Bindings的范围?我很快会谈到这一点。

使用Bindings的作用域可以让您按照层次顺序为脚本引擎定义参数变量。如果在引擎状态下搜索变量名,首先搜索优先级较高的Bindings,然后是优先级较低的Bindings。返回找到的第一个变量值。

Java 脚本 API 定义了两个范围。它们在ScriptContext接口中被定义为两个int常量。他们是

  • ScriptContext.ENGINE_SCOPE
  • ScriptContext.GLOBAL_SCOPE

引擎范围的优先级高于全局范围。如果将两个具有相同键的键-值对添加到两个Bindings(一个在引擎范围内,一个在全局范围内),每当必须解析与键同名的变量时,将使用引擎范围内的键-值对。

理解作用域对于一个Bindings的作用是如此重要,以至于我将通过另一个类比来解释它。考虑一个有两组变量的 Java 类:一组包含类中的所有实例变量,另一组包含方法中的所有局部变量。这两组变量及其值是两个BindingsBindings中变量的类型定义了作用域。为了便于讨论,我将定义两个范围:实例范围和本地范围。当执行一个方法时,首先在局部范围Bindings中查找变量名,因为局部变量优先于实例变量。如果在本地作用域Bindings中没有找到变量名,就在实例作用域Bindings中查找。当一个脚本被执行时,Bindings和它们的作用域扮演着相似的角色。

定义脚本上下文

脚本引擎在上下文中执行脚本。您可以将上下文视为脚本执行的环境。Java 宿主应用为脚本引擎提供了两样东西:脚本和脚本需要执行的上下文。接口的一个实例代表一个脚本的上下文。SimpleScriptContext类是ScriptContext接口的一个实现。脚本上下文由四部分组成:

  • 一组Bindings,其中每个Bindings与一个不同的作用域相关联
  • 脚本引擎用来读取输入的Reader
  • 脚本引擎用来写输出的一个Writer
  • 脚本引擎用来写入错误输出的错误Writer

上下文中的一组用于将参数传递给脚本。上下文中的读取器和写入器分别控制脚本的输入源和输出目的地。例如,通过将文件编写器设置为编写器,可以将脚本的所有输出发送到文件。

每个脚本引擎都维护一个默认的脚本上下文,用于执行脚本。到目前为止,您已经在没有提供脚本上下文的情况下执行了几个脚本。在这些情况下,脚本引擎使用它们的默认脚本上下文来执行脚本。在这一节中,我将介绍如何单独使用ScriptContext接口的实例。在下一节中,我将介绍如何在脚本执行期间将一个ScriptContext接口的实例传递给一个ScriptEngine

您可以使用该类创建一个ScriptContext接口的实例,如下所示:

// Create a script context

ScriptContext ctx = new SimpleScriptContext();

一个SimpleScriptContext类的实例维护两个Bindings实例:一个用于引擎范围,一个用于全局范围。当您创建SimpleScriptContext的实例时,就会创建引擎范围内的Bindings。要使用全局范围Bindings,您需要创建一个Bindings接口的实例。

默认情况下,SimpleScriptContext类将上下文的输入读取器、输出写入器和错误写入器分别初始化为标准输入System.in、标准输出System.out和标准错误输出System.err。您可以使用ScriptContext接口的getReader()getWriter()getErrorWriter()方法分别从ScriptContext中获取阅读器、编写器和错误编写器的引用。还提供了 Setter 方法来设置读取器和编写器。下面的代码片段显示了如何获取阅读器和编写器。它还展示了如何将 writer 设置为FileWriter以将脚本输出写入文件。

// Get the reader and writers from the script context

Reader inputReader = ctx.getReader();

Writer outputWriter = ctx.getWriter();

Writer errWriter = ctx.getErrorWriter();

// Write all script outputs to an out.txt file

Writer fileWriter = new FileWriter("out.txt");

ctx.setWriter(fileWriter);

在创建了SimpleScriptContext之后,您可以开始在引擎范围Bindings中存储键值对,因为当您创建SimpleScriptContext对象时,在引擎范围中创建了一个空的Bindings。该方法用于向一个Bindings添加一个键值对。您必须为Bindings提供键名、值和范围。下面的代码片段添加了三个键值对。

// Add three key-value pairs to the engine scope bindings

ctx.setAttribute("year", 1969, ScriptContext.ENGINE_SCOPE);

ctx.setAttribute("month", 9, ScriptContext.ENGINE_SCOPE);

ctx.setAttribute("day", 19, ScriptContext.ENGINE_SCOPE);

如果您想在全局范围内将键值对添加到一个Bindings中,您将需要首先创建并设置Bindings,如下所示:

// Add a global scope Bindings to the context

Bindings globalBindings = new SimpleBindings();

ctx.setBindings(globalBindings, ScriptContext.GLOBAL_SCOPE);

现在,您可以使用方法在全局范围内向Bindings添加键值对,如下所示:

// Add two key-value pairs to the global scope bindings

ctx.setAttribute("year", 1982, ScriptContext.GLOBAL_SCOPE);

ctx.setAttribute("name", "Boni", ScriptContext.GLOBAL_SCOPE);

此时,您可以看到ScriptContext实例的状态,如图 10-1 所示。

A978-1-4302-6662-4_10_Fig1_HTML.jpg

图 10-1。

A pictorial view of an instance of the SimpleScriptContext class

您可以在ScriptContext上执行多项操作。您可以使用setAttribute(String name, Object value, int scope)方法为已存储的密钥设置不同的值。对于指定的键和范围,可以使用removeAttribute(String name, int scope)方法移除键-值对。您可以使用getAttribute(String nameint scope)方法获取指定范围内的键值。

使用ScriptContext可以做的最有趣的事情是检索一个键值,而不用使用它的getAttribute(String name)方法指定它的作用域。一个ScriptContext首先在引擎范围Bindings中搜索关键字。如果在引擎范围内没有找到,则在全局范围内搜索Bindings。如果在这些范围中找到该键,则返回首先找到该键的范围中的相应值。如果两个范围都不包含该键,则返回null

在您的示例中,您已经在引擎范围和全局范围中存储了名为year的键。当首先搜索引擎范围时,下面的代码片段从引擎范围返回关键字year的 1969。getAttribute()方法的返回类型是Object

// Get the value of the key year without specifying the scope.

// It returns 1969 from the Bindings in the engine scope.

int yearValue = (Integer)ctx.getAttribute("year");

您只在全局范围内存储了名为name的键。如果尝试检索其值,将首先搜索引擎范围,这不会返回匹配项。随后,搜索全局范围并返回值"Boni",如下所示:

// Get the value of the key named name without specifying the scope.

// It returns "Boni" from the Bindings in the global scope.

String nameValue = (String)ctx.getAttribute("name");

您还可以检索特定范围内的键值。以下代码片段从引擎范围和全局范围中检索关键字"year"的值:

// Assigns 1969 to engineScopeYear and 1982 to globalScopeYear

int engineScopeYear = (Integer)ctx.getAttribute("year", ScriptContext.ENGINE_SCOPE);

int globalScopeYear = (Integer)ctx.getAttribute("year", ScriptContext.GLOBAL_SCOPE);

Tip

Java 脚本 API 只定义了两个作用域:引擎和全局。ScriptContext接口的子接口可以定义额外的作用域。ScriptContext接口的方法以List<Integer>的形式返回受支持范围的列表。请注意,作用域表示为整数。ScriptContext界面中的两个常量ENGINE_SCOPEGLOBAL_SCOPE分别被赋值为 100 和 200。当在出现在多个范围中的多个Bindings中搜索一个键时,首先搜索具有较小整数值的范围。因为引擎范围的值 100 小于全局范围的值 200,所以当您不指定范围时,首先在引擎范围中搜索一个键。

清单 10-9 展示了如何使用一个实现了ScriptContext接口的类的实例。请注意,您不能在应用中单独使用ScriptContext。它由脚本引擎在脚本执行期间使用。最常见的是,你通过一个ScriptEngine和一个ScriptEngineManager间接地操纵一个ScriptContext,这将在下一节详细讨论。

清单 10-9。使用 ScriptContext 接口的实例

// ScriptContextTest.java

package com.jdojo.script;

import java.util.List;

import javax.script.Bindings;

import javax.script.ScriptContext;

import javax.script.SimpleBindings;

import javax.script.SimpleScriptContext;

import static javax.script.ScriptContext.ENGINE_SCOPE;

import static javax.script.ScriptContext.GLOBAL_SCOPE;

public class ScriptContextTest {

public static void main(String[] args) {

// Create a script context

ScriptContext ctx = new SimpleScriptContext();

// Get the list of scopes supported by the script context

List<Integer> scopes = ctx.getScopes();

System.out.println("Supported Scopes: " + scopes);

// Add three key-value pairs to the engine scope bindings

ctx.setAttribute("year", 1969, ENGINE_SCOPE);

ctx.setAttribute("month", 9, ENGINE_SCOPE);

ctx.setAttribute("day", 19, ENGINE_SCOPE);

// Add a global scope Bindings to the context

Bindings globalBindings = new SimpleBindings();

ctx.setBindings(globalBindings, GLOBAL_SCOPE);

// Add two key-value pairs to the global scope bindings

ctx.setAttribute("year", 1982, GLOBAL_SCOPE);

ctx.setAttribute("name", "Boni", GLOBAL_SCOPE);

// Get the value of year without specifying the scope

int yearValue = (Integer)ctx.getAttribute("year");

System.out.println("yearValue = " + yearValue);

// Get the value of name

String nameValue = (String)ctx.getAttribute("name");

System.out.println("nameValue = " + nameValue);

// Get the value of year from engine and global scopes

int engineScopeYear = (Integer)ctx.getAttribute("year", ENGINE_SCOPE);

int globalScopeYear = (Integer)ctx.getAttribute("year", GLOBAL_SCOPE);

System.out.println("engineScopeYear = " + engineScopeYear);

System.out.println("globalScopeYear = " + globalScopeYear);

}

}

Supported Scopes: [100, 200]

yearValue = 1969

nameValue = Boni

engineScopeYear = 1969

globalScopeYear =

把它们放在一起

在这一节中,我将向您展示Bindings的实例及其作用域、ScriptContextScriptEngineScriptEngineManager和宿主应用是如何协同工作的。重点将是如何使用一个ScriptEngine和一个ScriptEngineManager在不同的范围内操作存储在Bindings中的键值对。

一个ScriptEngineManager在一个Bindings中维护一组键值对。它允许您使用以下四种方法操作这些键值对:

  • void put(String key, Object value)
  • Object get(String key)
  • void setBindings(Bindings bindings)
  • Bindings getBindings()

该方法向Bindings添加一个键值对。get()方法返回指定键的值;如果没有找到密钥,它将返回null。使用setBindings()方法可以替换发动机管理器的BindingsgetBindings()方法返回ScriptEngineManagerBindings的引用。

默认情况下,每个ScriptEngine都有一个被称为默认上下文的ScriptContext。回想一下,除了读者和作者,一个ScriptContext有两个Bindings:一个在引擎范围内,一个在全局范围内。当一个ScriptEngine被创建时,它的引擎作用域Bindings为空,它的全局作用域Bindings引用创建它的ScriptEngineManagerBindings

默认情况下,由ScriptEngineManager创建的ScriptEngine的所有实例共享ScriptEngineManagerBindings。在同一个 Java 应用中可能有多个ScriptEngineManager实例。在这种情况下,由同一个ScriptEngineManager创建的ScriptEngine的所有实例共享ScriptEngineManagerBindings作为它们默认上下文的全局作用域Bindings

下面的代码片段创建了一个ScriptEngineManager,用于创建ScriptEngine的三个实例:

// Create a ScriptEngineManager

ScriptEngineManager manager = new ScriptEngineManager();

// Create three ScriptEngines using the same ScriptEngineManager

ScriptEngine engine1 = manager.getEngineByName("JavaScript");

ScriptEngine engine2 = manager.getEngineByName("JavaScript");

ScriptEngine engine3 = manager.getEngineByName("JavaScript");

现在,让我们给ScriptEngineManagerBindings添加三个键值对,给每个ScriptEngine的引擎范围Bindings添加两个键值对。

// Add three key-value pairs to the Bindings of the

manager.put("K1", "V1");

manager.put("K2", "V2");

manager.put("K3", "V3");

// Add two key-value pairs to each engine

engine1.put("KE11", "VE11");

engine1.put("KE12", "VE12");

engine2.put("KE21", "VE21");

engine2.put("KE22", "VE22");

engine3.put("KE31", "VE31");

engine3.put("KE32", "VE32");

图 10-2 显示了代码片段执行后ScriptEngineManager和三个ScriptEngine的状态。从图中可以明显看出,所有ScriptEngine的默认上下文共享ScriptEngineManagerBindings作为它们的全局作用域Bindings

A978-1-4302-6662-4_10_Fig2_HTML.jpg

图 10-2。

A pictorial view of three ScriptEngines created by a ScriptEngineManager

ScriptEngineManager中的Bindings可以通过以下方式修改:

  • 通过使用ScriptEngineManagerput()方法
  • 通过使用ScriptEngineManagergetBindings()方法获取Bindings的参考,然后在Bindings上使用put()remove()方法
  • 通过使用ScriptEngine的方法在默认上下文的全局范围内获取Bindings的引用,然后在Bindings上使用put()remove()方法

当一个ScriptEngineManager中的Bindings被修改时,由这个ScriptEngineManager创建的所有ScriptEngine的默认上下文中的全局作用域Bindings被修改,因为它们共享同一个Bindings

每个ScriptEngine的默认上下文分别维护一个引擎范围Bindings。要将一个键-值对添加到一个ScriptEngine的引擎作用域Bindings,使用它的方法如下所示:

ScriptEngine engine1 = null; // get an engine

// Add an "engineName" key with its value as "Engine-1" to the

// engine scope Bindings of the default context of engine1

engine1.put("engineName", "Engine-1");

ScriptEngine的方法从其引擎作用域Bindings返回指定key的值。以下语句返回“Engine-1”,这是engineName键的值。

String eName = (String)engine1.get("engineName");

在默认的ScriptEngine上下文中,获得全局作用域Bindings的键值对需要两个步骤。首先,您需要使用如下所示的方法获取全局作用域Bindings的引用:

Bindings e1Global = engine1.getBindings(ScriptContext.GLOBAL_SCOPE);

现在,您可以使用e1Global引用来修改引擎的全局范围Bindings。下面的语句向e1Global Bindings添加了一个键值对:

e1Global.put("id", 89999);

因为所有的ScriptEngine共享一个ScriptEngine的全局作用域Bindings,上面的代码片段将把关键字id及其值添加到所有脚本引擎的默认上下文的全局作用域Bindings,这些脚本引擎是由创建engine1的同一个ScriptEngineManager创建的。不建议使用如上所示的代码修改ScriptEngineManager中的Bindings。您应该使用ScriptEngineManager引用来修改Bindings,这使得代码的读者对逻辑更加清楚。

清单 10-10 展示了本节讨论的概念。A ScriptEngineManager向它的Bindings添加两个键-值对,键为n1n2。创建了两个脚本引擎;他们在引擎范围Bindings中添加了一个名为engineName的键。当脚本被执行时,脚本中的engineName变量的值从ScriptEngine的引擎范围中被使用。脚本中变量n1n2的值是从ScriptEngine的全局作用域Bindings中获取的。在第一次执行该脚本后,每个ScriptEngine向它们的引擎范围Bindings添加一个名为n2的键,该键具有不同的值。当您第二次执行脚本时,变量n1的值从引擎的全局作用域Bindings中检索,而变量n2的值从引擎作用域Bindings中检索,如输出所示。

清单 10-10。使用由同一个 ScriptEngineManager 创建的引擎的全局和引擎范围绑定

// GlobalBindings.java

package com.jdojo.script;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class GlobalBindings {

public static void main(String[] args) {

ScriptEngineManager manager = new ScriptEngineManager();

// Add two numbers to the Bindings of the manager that will be

// shared by all its engines

manager.put("n1", 100);

manager.put("n2", 200);

// Create two JavaScript engines and add the name of the engine

// in the engine scope of the default context of the engines

ScriptEngine engine1 = manager.getEngineByName("JavaScript");

engine1.put("engineName", "Engine-1");

ScriptEngine engine2 = manager.getEngineByName("JavaScript");

engine2.put("engineName", "Engine-2");

// Execute a script that adds two numbers and prints the result

String script = "var sum = n1 + n2; "

+ "print(engineName + ' - Sum = ' + sum)";

try {

// Execute the script in two engines

engine1.eval(script);

engine2.eval(script);

// Now add a different value for n2 for each engine

engine1.put("n2", 1000);

engine2.put("n2", 2000);

// Execute the script in two engines again

engine1.eval(script);

engine2.eval(script);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

}

Engine-1 - Sum = 300

Engine-2 - Sum = 300

Engine-1 - Sum = 1100

Engine-2 - Sum = 2100

由一个ScriptEngineManager创建的所有 ScriptEngines 共享的全局作用域Bindings的故事还没有结束。这是最复杂、最令人困惑的事情!现在重点将放在使用ScriptEngineManager类的方法和ScriptEngine接口的效果上。考虑以下代码片段:

// Create a ScriptEngineManager and two ScriptEngines

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine1 = manager.getEngineByName("JavaScript");

ScriptEngine engine2 = manager.getEngineByName("JavaScript");

// Add two key-value pairs to the manager

manager.put("n1", 100);

manager.put("n2", 200);

图 10-3 显示了执行上述脚本后引擎管理器及其引擎的状态。此时,ScriptEngineManager中只存储了一个Bindings,两个脚本引擎将它作为它们的全局作用域Bindings来引用。

A978-1-4302-6662-4_10_Fig3_HTML.jpg

图 10-3。

Initial state of ScriptEngineManager and two ScriptEngines

让我们创建一个新的Bindings,并使用其方法将其设置为ScriptEngineManagerBindings,如下所示:

// Create a Bindings, add two key-value pairs to it, and set it as the new Bindings

// for the manager

Bindings newGlobal = new SimpleBindings();

newGlobal.put("n3", 300);

newGlobal.put("n4", 400);

manager.setBindings(newGlobal);

图 10-4 显示了代码执行后ScriptEngineManager和两个ScriptEngine的状态。请注意,ScriptEngineManager有了新的Bindings,而两个ScriptEngine仍然将旧的Bindings称为它们的全球范围Bindings

A978-1-4302-6662-4_10_Fig4_HTML.jpg

图 10-4。

State of ScriptEngineManager and two ScriptEngines after a new Bindings is set to the ScriptEngineManager

此时,对ScriptEngineManagerBindings所做的任何更改都不会反映在两个ScriptEngine的全局作用域Bindings中。您仍然可以对两个ScriptEngine共享的Bindings进行更改,并且两个ScriptEngine都将看到其中一个所做的更改。

让我们创建一个新的ScriptEngine,如图所示:

// Create a new ScriptEngine

ScriptEngine engine3 = manager.getEngineByName("JavaScript");

回想一下,ScriptEngine在创建时获得了全局作用域Bindings,BindingsScriptEngineManagerBindings相同。执行上述语句后,ScriptEngineManager和三个脚本引擎的状态如图 10-5 所示。

A978-1-4302-6662-4_10_Fig5_HTML.jpg

图 10-5。

State of ScriptEngineManager and three ScriptEngines after the third ScriptEngine is created

这里是对所谓的ScriptEngine s 的全局范围的“全球性”的另一种扭曲。这一次,您将使用一个ScriptEnginesetBindings()方法来设置它的全局范围Bindings。图 10-6 显示了执行以下代码片段后ScriptEngineManager和三个脚本引擎的状态:

// Set a new Bindings for the global scope of engine1

Bindings newGlobalEngine1 = new SimpleBindings();

newGlobalEngine1.put("n5", 500);

newGlobalEngine1.put("n6", 600);

engine1.setBindings(newGlobalEngine1, ScriptContext.GLOBAL_SCOPE);

A978-1-4302-6662-4_10_Fig6_HTML.jpg

图 10-6。

State of ScriptEngineManager and Three ScriptEngines After a New Global Scope Bindings Is Set for engine1 Tip

默认情况下,一个ScriptEngineManager创建的所有脚本引擎共享它的Bindings作为它们的全局作用域Bindings。如果您使用一个ScriptEnginesetBindings()方法来设置它的全局作用域Bindings,或者如果您使用一个ScriptEngineManagersetBindings()方法来设置它的Bindings,您就打破了“全球性”链,如本节所讨论的。为了保持“全局”链的完整性,您应该总是使用ScriptEngineManagerput()方法将键值对添加到它的Bindings中。要从由ScriptEngineManager创建的所有 ScriptEngines 的全局范围中删除一个键-值对,您需要使用ScriptEngineManagergetBindings()方法获取Bindings的引用,并在Bindings上使用remove()方法。

使用自定义脚本上下文

在上一节中,您看到每个ScriptEngine都有一个默认的脚本上下文。ScriptEngineget()put()getBindings()setBindings()方法在默认ScriptContext下运行。当ScriptEngineeval()方法没有指定ScriptContext时,使用引擎的默认上下文。ScriptEngineeval()方法的以下两个版本使用其默认上下文来执行脚本:

  • Object eval(String script)
  • Object eval(Reader reader)

您可以将一个Bindings传递给下面两个版本的方法:

  • Object eval(String script, Bindings bindings)
  • Object eval(Reader reader, Bindings bindings)

这些版本的eval()方法不使用默认的ScriptEngine上下文。他们使用一个新的ScriptContext,它的引擎作用域Bindings是传递给这些方法的,全局作用域Bindings与引擎的默认上下文相同。注意,eval()方法的这两个版本保持了ScriptEngine的默认上下文不变。

您可以将一个ScriptContext传递给下面两个版本的eval()方法:

  • Object eval(String script, ScriptContext context)
  • Object eval(Reader reader, ScriptContext context)

这些版本的eval()方法使用指定的上下文来执行脚本。它们保持ScriptEngine的默认上下文不变。

三组eval()方法允许您使用不同的隔离级别执行脚本:

  • 第一组让所有脚本共享默认上下文。
  • 第二组让脚本使用不同的引擎作用域Bindings并共享全局作用域Bindings
  • 第三组让脚本在隔离的ScriptContext中执行。

清单 10-11 展示了如何使用不同版本的eval()方法在不同的隔离级别执行脚本。该程序使用三个变量,分别叫做msgn1n2。它显示存储在msg变量中的值。将n1n2的值相加,并显示总和。该脚本打印出在计算总和时使用了什么值n1n2n1的值存储在由所有ScriptEngine的默认上下文共享的ScriptEngineManagerBindings中。n2的值存储在默认上下文和自定义上下文的引擎范围中。该脚本使用引擎的默认上下文执行两次,一次在开始,一次在结束,以证明在eval()方法中使用自定义BindingsScriptContext不会影响ScriptEngine的默认上下文中的Bindings。该程序在其main()方法中声明了一个throws子句,以使代码更短。

清单 10-11。使用不同的隔离级别执行脚本

// CustomContext.java

package com.jdojo.script;

import javax.script.Bindings;

import javax.script.ScriptContext;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

import javax.script.SimpleScriptContext;

import static javax.script.SimpleScriptContext.ENGINE_SCOPE;

import static javax.script.SimpleScriptContext.GLOBAL_SCOPE;

public class CustomContext {

public static void main(String[] args) throws ScriptException {

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Add n1 to Bindings of the manager, which will be shared

// by all engines as their global scope Bindings

manager.put("n1", 100);

// Prepare the script

String script = "var sum = n1 + n2;" +

"print(msg + " +

"' n1=' + n1 + ', n2=' + n2 + " +

"', sum=' + sum);";

// Add n2 to the engine scope of the default context of the engine

engine.put("n2", 200);

engine.put("msg", "Using the default context:");

engine.eval(script);

// Use a Bindings to execute the script

Bindings bindings = engine.createBindings();

bindings.put("n2", 300);

bindings.put("msg", "Using a Bindings:");

engine.eval(script, bindings);

// Use a ScriptContext to execute the script

ScriptContext ctx = new SimpleScriptContext();

Bindings ctxGlobalBindings = engine.createBindings();

ctx.setBindings(ctxGlobalBindings, GLOBAL_SCOPE);

ctx.setAttribute("n1", 400, GLOBAL_SCOPE);

ctx.setAttribute("n2", 500, ENGINE_SCOPE);

ctx.setAttribute("msg", "Using a ScriptContext:", ENGINE_SCOPE);

engine.eval(script, ctx);

// Execute the script again using the default context to

// prove that the default context is unaffected.

engine.eval(script);

}

}

Using the default context: n1=100, n2=200, sum=300

Using a Bindings: n1=100, n2=300, sum=400

Using a ScriptContext: n1=400, n2=500, sum=900

Using the default context: n1=100, n2=200, sum=300

eval()方法的返回值

ScriptEngineeval()方法返回一个Object,这是脚本中的最后一个值。如果脚本中没有最后一个值,它将返回null。依赖于脚本中的最后一个值很容易出错,同时也令人困惑。下面的代码片段展示了一些为 Nashorn 使用eval()方法返回值的例子。代码中的注释指示方法的返回值。

Object result = null;

// Assigns 3 to result

result = engine.eval("1 + 2;");

// Assigns 7 to result

result = engine.eval("1 + 2; 3 + 4;");

// Assigns 6 to result

result = engine.eval("1 + 2; 3 + 4; var v = 5; v = 6;");

// Assigns 7 to result

result = engine.eval("1 + 2; 3 + 4; var v = 5;");

// Assigns null to result

result = engine.eval("print(1 + 2)");

最好不要依赖于eval()方法的返回值。您应该将一个 Java 对象作为参数传递给脚本,并让脚本将脚本的返回值存储在该对象中。在执行了eval()方法之后,您可以查询这个 Java 对象的返回值。清单 10-12 包含一个包装整数的Result类的代码。您将向脚本传递一个Result类的对象,脚本将在其中存储返回值。脚本完成后,您可以在 Java 代码中读取存储在 Result 对象中的整数值。需要将Result声明为公共的,以便脚本引擎可以访问它。清单 10-13 中的程序展示了如何将一个Result对象传递给一个用值填充Result对象的脚本。该程序在main()方法的声明中包含一个throws子句,以保持代码简短。

清单 10-12。包装整数的结果类

// Result.java

package com.jdojo.script;

public class Result {

private int val = -1;

public void setValue(int x) {

val = x;

}

public int getValue() {

return val;

}

}

清单 10-13。在结果对象中收集脚本的返回值

// ResultBearingScript.java

package com.jdojo.script;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class ResultBearingScript {

public static void main(String[] args) throws ScriptException {

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Pass a Result object to the script. The script will store the

// result of the script in the result object

Result result = new Result();

engine.put("result", result);

// Store the script in a String

String script = "3 + 4; result.setValue(101);";

// Execute the script, which uses the passed in Result object to

// return a value

engine.eval(script);

// Use the result object to get the returned value from the script

int returnedValue = result.getValue(); // Will be 101

System.out.println("Returned value is " + returnedValue);

}

}

Returned value is 101

引擎范围绑定的保留键

通常,引擎范围Bindings中的一个键代表一个脚本变量。有些键是保留的,它们有特殊的含义。它们的值可以通过引擎的实现传递给引擎。一个实现可以定义附加的保留密钥。

表 10-2 包含所有保留键的列表。这些键在ScriptEngine接口中也被声明为常量。脚本引擎的实现不需要在引擎范围绑定中将所有这些键传递给引擎。作为开发人员,您不应该使用这些键将参数从 Java 应用传递到脚本引擎。

表 10-2。

The List of Reserved Keys for Engine Scope Bindings

| 钥匙 | ScriptEngine 接口中的常数 | 键值的含义 | | --- | --- | --- | | " javax.script.argv " | `ScriptEngine.ARGV` | 用来传递一个数组对象来传递一组位置参数。 | | " javax.script.engine " | `ScriptEngine.ENGINE` | 脚本引擎的名称。 | | " javax.script.engine_version " | `ScriptEngine.ENGINE_VERSION` | 脚本引擎的版本。 | | " javax.script.filename " | `ScriptEngine.FILENAME` | 用于传递文件或资源的名称,即脚本的来源。 | | " javax.script.language " | `ScriptEngine.LANGUAGE` | 脚本引擎支持的语言的名称。 | | " javax.script.language_version " | `ScriptEngine.LANGUAGE_VERSION` | 引擎支持的脚本语言版本。 | | " javax.script.name " | `ScriptEngine.NAME` | 脚本语言的简称。 |

更改默认脚本上下文

您可以分别使用getContext()setContext()方法来获取和设置ScriptEngine的默认上下文,如下所示:

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Get the default context of the ScriptEngine

ScriptContext defaultCtx = engine.getContext();

// Work with defaultCtx here

// Create a new context

ScriptContext ctx = new SimpleScriptContext();

// Configure ctx here

// Set ctx as the new default context for the engine

engine.setContext(ctx);

注意,为一个ScriptEngine设置一个新的默认上下文不会使用ScriptEngineManagerBindings作为它的全局作用域Bindings。如果您希望新的默认上下文使用ScriptEngineManagerBindings,您需要显式设置它,如下所示:

// Create a new context

ScriptContext ctx = new SimpleScriptContext();

// Set the global scope Bindings for ctx the same as the Bindings for the manager

ctx.setBindings(manager.getBindings(), ScriptContext.GLOBAL_SCOPE);

// Set ctx as the new default context for the engine

engine.setContext(ctx);

将脚本输出发送到文件

您可以自定义脚本执行的输入源、输出目标和错误输出目标。您需要为用于执行脚本的ScriptContext设置适当的读取器和写入器。下面的代码片段将把脚本输出写到当前目录中名为jsoutput.txt的文件中:

// Create a

FileWriter writer = new FileWriter("jsoutput.txt");

// Get the default context of the engine

ScriptContext defaultCtx = engine.getContext();

// Set the output writer for the default context of the engine

defaultCtx.setWriter(writer);

该代码为ScriptEngine的默认上下文设置了一个自定义输出编写器,在使用默认上下文的脚本执行过程中将会用到这个编写器。如果您想使用定制的输出编写器来执行特定的脚本,您需要使用一个定制的ScriptContext并设置它的编写器。

Tip

ScriptContext设置自定义输出编写器不会影响 Java 应用标准输出的目的地。要重定向 Java 应用的标准输出,您需要使用System.setOut()方法。

清单 10-14 显示了如何将脚本执行的输出写到名为jsoutput.txt的文件中。该程序在标准输出中打印输出文件的完整路径。运行该程序时,您可能会得到不同的输出。您需要在文本编辑器中打开输出文件来查看脚本的输出。

清单 10-14。将脚本输出写入文件

// CustomScriptOutput.java

package com.jdojo.script;

import java.io.File;

import java.io.FileWriter;

import java.io.IOException;

import javax.script.ScriptContext;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class CustomScriptOutput {

public static void main(String[] args) {

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Print the absolute path of the output file

File outputFile = new File("jsoutput.txt");

System.out.println("Script output will be written to " +

outputFile.getAbsolutePath());

FileWriter writer = null;

try {

writer = new FileWriter(outputFile);

// Set a custom output writer for the engine

ScriptContext defaultCtx = engine.getContext();

defaultCtx.setWriter(writer);

// Execute a script

String script = "print('Hello custom output writer')";

engine.eval(script);

}

catch (IOException | ScriptException e) {

e.printStackTrace();

}

finally {

if (writer != null) {

try {

writer.close();

}

catch (IOException e) {

e.printStackTrace();

}

}

}

}

}

Script output will be written to C:\jsoutput.txt

在脚本中调用过程

脚本语言可以允许创建过程、函数和方法。Java 脚本 API 允许您从 Java 应用中调用这样的过程、函数和方法。在本节中,我将使用术语“过程”来表示过程、函数和方法。当讨论的上下文需要时,我将使用特定的术语。

并非所有脚本引擎都需要支持过程调用。Nashorn JavaScript 引擎支持过程调用。如果脚本引擎支持,则脚本引擎类的实现必须实现接口。在调用过程之前,检查脚本引擎是否实现了Invocable接口是开发人员的责任。调用过程包括四个步骤:

  • 检查脚本引擎是否支持过程调用。
  • 将发动机参考转换为Invocable类型。
  • 评估包含该过程源代码的脚本。
  • 使用Invocable接口的invokeFunction()方法调用过程和函数。使用invokeMethod()方法来调用在脚本语言中创建的对象的方法。

以下代码片段检查脚本引擎实现类是否实现了接口:

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Make sure the script engine implements the Invocable interface

if (engine instanceof Invocable) {

System.out.println("Invoking procedures is supported.");

else

System.out.println("Invoking procedures is not supported.");

}

第二步是将引擎引用转换为Invocable接口类型。

Invocable inv = (Invocable)engine;

第三步是评估脚本,因此脚本引擎编译并存储过程的编译形式,供以后调用。以下代码片段执行此步骤:

// Declare a function named add that adds two numbers

String script = "function add(n1, n2) { return n1 + n2; }";

// Evaluate the function. Call to eval() does not invoke the function.

// It just compiles it.

engine.eval(script);

最后一步是调用过程或函数。

// Invoke the add function with 30 and 40 as the function's arguments.

// It is as if you called add(30, 40) in the script.

Object result = inv.invokeFunction("add", 30, 40);

invokeFunction()的第一个参数是过程或函数的名称。第二个参数是 varargs,用于指定过程或函数的参数。invokeFunction()方法返回过程或函数返回的值。

清单 10-15 显示了如何调用一个函数。它调用用 Nashorn JavaScript 编写的函数。

清单 10-15。调用用 Nashorn JavaScript 编写的函数

// InvokeFunction.java

package com.jdojo.script;

import javax.script.Invocable;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class InvokeFunction {

public static void main(String[] args) {

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Make sure the script engine implements the Invocable interface

if (!(engine instanceof Invocable)) {

System.out.println("Invoking procedures is not supported.");

return;

}

// Cast the engine reference to the Invocable type

Invocable inv = (Invocable)engine;

try {

String script = "function add(n1, n2) { return n1 + n2; }";

// Evaluate the script first

engine.eval(script);

// Invoke the add function twice

Object result1 = inv.invokeFunction("add", 30, 40);

System.out.println("Result1 = " + result1);

Object result2 = inv.invokeFunction("add", 10, 20);

System.out.println("Result2 = " + result2);

}

catch (ScriptException | NoSuchMethodException e) {

e.printStackTrace();

}

}

}

Result1 = 70

Result2 = 30

面向对象或基于对象的脚本语言可以让您定义对象及其方法。您可以使用Invocable接口的invokeMethod()方法调用这些对象的方法,声明如下:

Object invokeMethod(Object objectRef, String name, Object... args)

第一个参数是对象的引用,第二个参数是要在对象上调用的方法的名称,第三个参数是 varargs 参数,用于将参数传递给被调用的方法。

清单 10-16 演示了在 Nashorn JavaScript 中创建的对象上调用方法。注意,该对象是在 Nashorn 脚本中创建的。要从 Java 调用对象的方法,需要通过脚本引擎获取对象的引用。程序评估使用add()方法创建对象的脚本,并将其引用存储在名为calculator的变量中。engine.get("calculator")方法返回对 Java 代码的calculator对象的引用。

清单 10-16。在 Nashorn JavaScript 中创建的对象上调用方法

// InvokeMethod.java

package com.jdojo.script;

import javax.script.Invocable;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class InvokeMethod {

public static void main(String[] args) {

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Make sure the script engine implements the Invocable interface

if (!(engine instanceof Invocable)) {

System.out.println("Invoking methods is not supported.");

return;

}

// Cast the engine reference to the Invocable type

Invocable inv = (Invocable) engine;

try {

// Declare a global object with an add() method

String script = "var calculator = new Object();" +

"calculator.add = function add(n1, n2){return n1 + n2;}";

// Evaluate the script first

engine.eval(script);

// Get the calculator object reference created in the script

Object calculator = engine.get("calculator");

// Invoke the add() method on the calculator object

Object result = inv.invokeMethod(calculator, "add", 30, 40);

System.out.println("Result = " + result);

}

catch (ScriptException | NoSuchMethodException e) {

e.printStackTrace();

}

}

}

Result = 70

Tip

使用Invocable界面重复执行程序、函数和方法。具有过程、函数和方法的脚本评估将中间代码存储在引擎中,从而在重复执行时获得性能增益。

在脚本中实现 Java 接口

Java 脚本 API 允许您用脚本语言实现 Java 接口。Java 接口的方法可以使用顶层过程或对象的实例方法在脚本中实现。

用脚本语言实现 Java 接口的优点是,您可以用 Java 代码使用接口的实例,就好像接口是用 Java 实现的一样。您可以将接口的实例作为参数传递给 Java 方法。

Invocable接口的getInterface()方法用于获取在脚本中实现的 Java 接口的实例。该方法有两个版本:

  • <T> T getInterface(Class<T> cls)
  • <T> T getInterface(Object obj, Class<T> cls)

第一个版本用于获取 Java 接口的实例,该接口的方法在脚本中作为顶级过程实现。接口类型作为参数传递给该方法。假设你有一个Calculator接口,如清单 10-17 所示,它有两个方法叫做add()subtract()

清单 10-17。计算器界面

// Calculator.java

package com.jdojo.script;

public interface Calculator {

int add (int n1, int n2);

int subtract (int n1, int n2);

}

考虑以下两个用 JavaScript 编写的顶级函数:

function add(n1, n2) {

return n1 + n2;

}

function subtract(n1, n2) {

return n1 - n2;

}

以上两个函数提供了Calculator接口的两个方法的实现。JavaScript 引擎编译完上述函数后,您可以获得一个Calculator接口的实例,如下所示:

// Cast the engine reference to the Invocable type

Invocable inv = (Invocable)engine;

// Get the reference of the Calculator interface

Calculator calc = inv.getInterface(Calculator.class);

if (calc == null) {

System.err.println("Calculator interface implementation not found.");

}

else {

// Use calc to call add() and subtract() methods

}

您可以添加两个数字,如下所示:

int sum = calc.add(15, 10);

清单 10-18 展示了如何在 Nashorn 中使用顶级过程实现一个 Java 接口。请查阅脚本语言的文档,了解它如何支持此功能。

清单 10-18。使用脚本中的顶级函数实现 Java 接口

// UsingInterfaces.java

package com.jdojo.script;

import javax.script.Invocable;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class UsingInterfaces {

public static void main(String[] args) {

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Make sure the script engine implements Invocable interface

if (!(engine instanceof Invocable)) {

System.out.println("Interface implementation in script"

+ " is not supported.");

return;

}

// Cast the engine reference to the Invocable type

Invocable inv = (Invocable) engine;

// Create the script for add() and subtract() functions

String script = "function add(n1, n2) { return n1 + n2; } "

+ "function subtract(n1, n2) { return n1 - n2; }";

try {

// Compile the script that will be stored in the engine

engine.eval(script);

// Get the interface implementation

Calculator calc = inv.getInterface(Calculator.class);

if (calc == null) {

System.err.println("Calculator interface " +

"implementation not found.");

return;

}

int result1 = calc.add(15, 10);

System.out.println("add(15, 10) = " + result1);

int result2 = calc.subtract(15, 10);

System.out.println("subtract(15, 10) = " + result2);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

}

add(15, 10) = 25

subtract(15, 10) = 5

方法的第二个版本用于获取 Java 接口的实例,该接口的方法实现为对象的实例方法。它的第一个参数是用脚本语言创建的对象的引用。对象的实例方法实现作为第二个参数传入的接口类型。Nashorn 中的以下代码创建了一个对象,其实例方法实现了Calculator接口:

// Create an object

var calc = new Object();

// Add add() and subtract() methods to the calc object

calc.add = function add(n1, n2) {

return n1 + n2;

};

calc.subtract = function subtract(n1, n2) {

return n1 - n2;

};

当脚本对象的实例方法实现 Java 接口的方法时,您需要执行一个额外的步骤。在获取接口的实例之前,需要获取脚本对象的引用,如下所示:

// Get the reference of the global script object obj

Object calc = engine.get("calc");

// Get the implementation of the Calculator interface

Calculator calculator = inv.getInterface(calc, Calculator.class);

清单 10-19 展示了如何使用 Nashorn 将 Java 接口的方法实现为对象的实例方法。

清单 10-19。将 Java 接口的方法实现为脚本中对象的实例方法

// ScriptObjectImplInterface.java

package com.jdojo.script;

import javax.script.Invocable;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class ScriptObjectImplInterface {

public static void main(String[] args) {

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

// Make sure the engine implements the Invocable interface

if (!(engine instanceof Invocable)) {

System.out.println("Interface implementation in " +

"script is not supported.");

return;

}

// Cast the engine reference to the Invocable type

Invocable inv = (Invocable)engine;

String script = "var calc = new Object(); " +

"calc.add = function add(n1, n2) {return n1 + n2; }; " +

"calc.subtract = function subtract(n1, n2) {return n1 - n2;};";

try {

// Compile and store the script in the engine

engine.eval(script);

// Get the reference of the global script object calc

Object calc = engine.get("calc");

// Get the implementation of the Calculator interface

Calculator calculator = inv.getInterface(calc, Calculator.class);

if (calculator == null) {

System.err.println("Calculator interface " +

"implementation not found.");

return;

}

int result1 = calculator.add(15, 10);

System.out.println( "add(15, 10) = " + result1);

int result2 = calculator.subtract(15, 10);

System.out.println("subtract(15, 10) = " + result2);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

}

add(15, 10) = 25

subtract(15, 10) =

使用编译的脚本

脚本引擎可以允许编译脚本并重复执行它。执行编译后的脚本可以提高应用的性能。脚本引擎可以以 Java 类、Java 类文件的形式或特定于语言的形式编译和存储脚本。

并非所有脚本引擎都需要支持脚本编译。支持脚本编译的脚本引擎必须实现Compilable接口。Nashorn 引擎支持脚本编译。以下代码片段检查脚本引擎是否实现了接口:

// Get the script engine reference

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("YOUR_ENGINE_NAME");

if (engine instanceof Compilable) {

System.out.println("Script compilation is supported.");

}

else {

System.out.println("Script compilation is not supported.");

}

一旦您知道脚本引擎实现了Compilable接口,您就可以将其引用转换为Compilable类型

// Cast the engine reference to the Compilable type

Compilable comp = (Compilable)engine;

Compilable接口包含两个方法:

  • CompiledScript compile(String script) throws ScriptException
  • CompiledScript compile(Reader script) throws ScriptException

该方法的两个版本仅在脚本源的类型上有所不同。第一个版本接受脚本作为String,第二个版本接受脚本作为Reader

该方法返回一个CompiledScript类的对象。CompiledScript是一个抽象类。脚本引擎的提供者提供了这个类的具体实现。一个CompiledScript与创建它的ScriptEngine相关联。CompiledScript类的getEngine()方法返回与其关联的ScriptEngine的引用。

要执行编译后的脚本,您需要调用CompiledScript类的以下eval()方法之一:

  • Object eval() throws ScriptException
  • Object eval(Bindings bindings) throws ScriptException
  • Object eval(ScriptContext context) throws ScriptException

没有任何参数的eval()方法使用脚本引擎的默认脚本上下文来执行编译后的脚本。当你向另外两个版本传递一个Bindings或一个ScriptContext时,它们的工作方式与ScriptEngine接口的eval()方法相同。

清单 10-20 显示了如何编译并执行一个脚本。它使用不同的参数将相同的编译脚本执行两次。

清单 10-20。使用编译的脚本

// CompilableTest .java

package com.jdojo.script;

import javax.script.Bindings;

import javax.script.Compilable;

import javax.script.CompiledScript;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class CompilableTest {

public static void main(String[] args) {

// Get the Nashorn engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JavaScript");

if (!(engine instanceof Compilable)) {

System.out.println("Script compilation not supported.");

return;

}

// Cast the engine reference to the Compilable type

Compilable comp = (Compilable)engine;

try {

// Compile a script

String script = "print(n1 + n2)";

CompiledScript cScript = comp.compile(script);

// Store n1 and n2 script variables in a Bindings

Bindings scriptParams = engine.createBindings();

scriptParams.put("n1", 2);

scriptParams.put("n2", 3);

cScript.eval(scriptParams);

// Execute the script again with different values for n1 and n2

scriptParams.put("n1", 9);

scriptParams.put("n2", 7);

cScript.eval(scriptParams);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

}

5

16

在脚本语言中使用 Java

脚本语言允许在脚本中使用 Java 类库。每种脚本语言都有自己的使用 Java 类的语法。讨论所有脚本语言的语法是不可能的,也超出了本书的范围。在这一节中,我将讨论在 Nashorn 中使用一些 Java 构造的语法。关于纳申语的完整报道,请参考 https://wiki.openjdk.java.net/display/Nashorn/Main 的网站。

声明变量

在脚本语言中声明变量与 Java 无关。通常,脚本语言允许您在不声明变量的情况下为变量赋值。变量的类型是在运行时根据它们存储的值的类型确定的。

在 Nashorn 中,关键字var用于声明一个变量。如果您愿意,可以在变量声明中省略关键字var。以下代码片段声明了两个变量,并为它们赋值:

// Declare a variable named msg using the var keyword

var msg = "Hello";

// Declare a variable named greeting without using the keyword var

greeting = "Hello";

导入 Java 类

在 Nashorn 中,有四种方法可以在脚本中导入 Java 类:

  • 使用Packages全局对象
  • 使用 Java 全局对象的type()函数
  • 使用importPackage()importClass()功能
  • with子句中使用 JavaImporter

下面几节将详细描述在脚本中导入 Java 类的四种方式。

使用包全局对象

Nashorn 将a ll Java 包定义为名为Packages的全局变量的属性。例如,java.langjavax.swing包可以分别称为Packages.java.langPackages.javax.swing。以下代码片段使用了 Nashorn 中的java.util.Listjavax.swing.JFrame:

// Create a List

var list1 = new Packages.java.util.ArrayList();

// Create a JFrame

var frame1 = new Packages.javax.swing.JFrame("Test");

Nashorn 将javajavaxorgcomedunet声明为全局变量,分别是Packages.javaPackages.javaxPackages.orgPackages.comPackages.eduPackages.net的别名。本书示例中的类名以前缀com开头,例如com.jdojo.script.Test。要在 JavaScript 代码中使用这个类名,可以使用Packages.com.jdojo.script.Testcom.jdojo.script.Test。但是,如果一个类名不是以这些预定义的前缀之一开头,您必须使用Packages全局变量来访问它;例如,如果您的类名是p1.Test,您需要在 JavaScript 代码中使用Packages.p1.Test来访问它。以下代码片段为Packages.javaPackages.javax使用了 java 和 javax 别名:

// Create a List

var list2 = new java.util.ArrayList();

// Create a JFrame

var frame2 = new javax.swing.JFrame("Test");

使用 Java 全局对象

Java 7 中的 Rhino JavaScript 也支持将包作为Packages对象的属性来访问。使用Packages对象速度较慢并且容易出错。Nashorn 定义了一个名为Java的新的全局对象,它包含许多有用的函数来处理 Java 包和类。如果你使用的是 Java 8 或更高版本,你应该使用Java对象而不是Packages对象。Java对象的type()函数将 Java 类型导入到脚本中。您需要传递要导入的 Java 类型的完全限定名。在 Nashorn 中,以下代码片段导入了java.util.ArrayList类并创建了它的对象:

// Import java.util.ArrayList type and call it ArrayList

var ArrayList = Java.type("java.util.ArrayList");

// Create an object of the ArrayList type

var list = new ArrayList();

在代码中,您将从Java.type()函数返回的导入类型称为ArrayList,这也是导入的类的名称。这样做是为了让下一条语句看起来像是用 Java 编写的。第二条语句的读者会知道你正在创建一个ArrayList类的对象。但是,您可以为导入的类型指定任何想要的名称。下面的代码片段导入java.util.ArrayList并将其命名为MyList:

// Import java.util.ArrayList type and call it MyList

var MyList = Java.type("java.util.ArrayList");

// Create an object of the MyList type

var list2 = new MyList();

使用 importPackage()和 importClass()函数

Rhino JavaScript 允许在脚本中使用 Java 类型的简单名称。Rhino JavaScript 有两个名为importPackage()importClass()的内置函数,分别用于从一个包中导入所有类和从一个包中导入一个类。出于兼容性的考虑,Nashorn 保留了这些功能。要在 Nashorn 中使用这些功能,您需要使用load()功能从mozilla_compat.js文件中加载兼容模块。以下代码片段使用这些函数重写了上述逻辑:

// Load the compatibility module. It is needed in Nashorn, not in Rhino.

load("nashorn:mozilla_compat.js");

// Import ArrayList class from the java.util package

importClass(java.util.ArrayList);

// Import all classes from the javax.swing package

importPackage(javax.swing);

// Use simple names of classes

var list1 = new ArrayList();

var frame1 = new JFrame("Test");

JavaScript 不会自动从java.lang包中导入所有类,因为 JavaScript 类名称相同,例如StringObjectNumbe r 等。,会与java.lang包中的类名冲突。要使用来自java.lang包的类,您可以导入它或者使用PackagesJava变量来使用它的完全限定名。您不能从java.lang包中导入所有的类。下面的代码片段生成了一个错误,因为 JavaScript 中已经定义了String类名:

// Load the compatibility module. It is needed in Nashorn, not in Rhino.

load("nashorn:mozilla_compat.js");

importClass(java.lang.String); // An

如果你想使用java.lang.String类,你需要使用它的完全限定名。以下代码片段使用内置的 JavaScript String类和java.lang.String类:

var javaStr = new java.lang.String("Hello"); // Java String class

var jsStr = new String("Hello");            // JavaScript String class

如果java.lang包中的类名与 JavaScript 顶级类名不冲突,可以使用importClass()函数导入 Java 类。例如,您可以使用下面的代码片段来使用java.lang.System类:

// Load the compatibility module. It is needed in Nashorn, not in Rhino.

load("nashorn:mozilla_compat.js");

importClass(java.lang.System);

var jsStr = new String("Hello");

System.out.println(jsStr);

在上面的代码片段中,jsStr是一个 JavaScript String,它被传递给接受java.lang.String类型的System.out.println() Java 方法。在这种情况下,JavaScript 自动处理从 JavaScript 类型到 Java 类型的转换。

使用 JavaImporter 对象

在 JavaScript 中,您可以通过在with子句中使用JavaImporter对象引用来使用简单的类名。JavaImporter类的构造函数接受 Java 包和类的列表。您可以创建一个JavaImporter对象,如下所示:

// Import all classes from the java.lang package

var langPkg = new JavaImporter(Packages.java.lang);

// Import all classes from the java.lang and java.util packages and the

// JFrame class from the javax.swing package

var pkg2 = JavaImporter(java.lang, java.util, javax.swing.JFrame);

注意第一条语句中使用了new操作符。第二条语句没有使用new操作符。这两个语句在 JavaScript 中都有效。

以下代码片段创建了一个JavaImporter对象,并在子句中使用它:

// Create a Java importer for java.lang and java.util packages

var javaLangAndUtilPkg = JavaImporter(java.lang, java.util);

// Use the imported types in the with clause

with (javaLangAndUtilPkg) {

var list = new ArrayList();

list.add("one");

list.add("two");

System.out.println("Hello");

System.out.println("List is " + list);

}

Hello

List is [one, two]

创建和使用 Java 对象

使用带有构造函数的new操作符在脚本中创建新的 Java 对象。以下代码片段在 Nashorn 中创建了一个String对象:

// Create a Java String object

var javaString = new java.lang.String("A Java string");

在大多数脚本语言中,访问 Java 对象的方法和属性是相似的。一些脚本语言允许您使用属性名调用对象的 getter 和 setter 方法。Nashorn 中的以下代码片段创建了一个java.util.Date对象,并使用属性名和方法名访问该对象的方法:

var dt = new java.util.Date();

var year = dt.year + 1900;

var month = dt.month + 1;

var date = dt.getDate();

println("Date:" + dt);

println("Year:" + year + ", Month:" + month + ", Day:" + date);

Date:Wed Jul 09 00:35:31 CDT 2014

Year:2014, Month:7, Day:9

使用 JavaScript 时,理解不同类型的String对象很重要。一个String对象可能是一个 JavaScript String对象或者一个 Java java.lang.String对象。JavaScript 为其String类定义了一个length属性,而 Java 为其java.lang.String类定义了一个方法。以下代码片段显示了创建和访问 JavaScript String和 Java java.lang.String对象的长度的不同:

// JavaScript String

var jsStr = new String("Hello JavaScript String");

print("JavaScript String: " + jsStr);

print("JavaScript String Length: " + jsStr.length);

// Java String

var javaStr = new java.lang.String("Hello Java String");

print("Java String: " + javaStr);

print("Java String Length: " + javaStr.length());

JavaScript String: Hello JavaScript String

JavaScript String Length: 23

Java String: Hello Java String

Java String Length: 17

使用重载的 Java 方法

Java 在编译时解决重载的方法调用。也就是说,Java 编译器确定代码运行时将调用的方法的签名。考虑清单 10-21 所示的PrintTest类的代码。您可能会在第二行得到不同的输出。

清单 10-21。在 Java 中使用重载方法

// PrintTest.java

package com.jdojo.script;

public class PrintTest {

public void print(String str) {

System.out.println("print(String): " + str);

}

public void print(Object obj) {

System.out.println("print(Object): " + obj);

}

public void print(Double num) {

System.out.println("print(Double): " + num);

}

public static void main(String[] args) {

PrintTest pt = new PrintTest();

Object[] list = new Object[]{"Hello", new Object(), 10.5};

for(Object arg : list) {

pt.print(arg);

}

}

}

print(Object): Hello

print(Object): java.lang.Object@affc70

print(Object): 10.5

当运行PrintTest类时,对print()方法的所有三个调用都调用PrintTest类的同一个版本print(Object)。当代码被编译时,Java 编译器将调用pt.print(arg)视为对带有Object类型参数的print()方法的调用(这是arg的类型),因此将该调用绑定到print(Object)方法。

在脚本语言中,变量的类型在运行时是已知的,而不是在编译时。脚本语言的解释器根据方法调用中参数的运行时类型适当地解析重载的方法调用。以下 JavaScript 代码的输出显示了对PrintTest类的print()方法的调用在运行时根据参数的类型进行解析:

// In JavaScript

var pt = new com.jdojo.script.PrintTest();

var list = ["Hello", new Object(), 10.5];

for (var i = 0; i < list.length; ++i) {

pt.print(list[i]);

}

print(String): Hello

print(Object): [object Object]

print(Double): 10.5

JavaScript 允许您显式选择重载方法的特定版本。您可以传递要用对象引用调用的重载方法的签名。以下代码片段选择了print(Object)版本:

// In JavaScript

var pt = new com.jdojo.script.PrintTest();

pt"print(java.lang.Object)"; // Calls print(Object)

pt"print(java.lang.Double)"; // Calls print(Double)

print(Object): 10.5

print(Double): 10.

使用 Java 数组

在 Rhino 和 Nashorn 中,用 JavaScript 创建 Java 数组的方式是不同的。在 Rhino 中,您需要使用java.lang.reflect.Array类的newInstance()静态方法创建一个 Java 数组。Nashorn 也支持这种语法。下面的代码片段展示了如何使用 Rhino 语法创建和访问 Java 数组:

// Create a java.lang.String array of 2 elements, populate it, and print the // elements

// Rhino you were able to use java.lang.String as the first argument, but in // Nashorn, you

// need to use java.lang.String.class instead.

var strArray = java.lang.reflect.Array.newInstance(java.lang.String.class, 2);

strArray[0] = "Hello";

strArray[1] = "Array";

for(var i = 0; i < strArray.length; i++) {

print(strArray[i]);

}

Hello

Array

创建原始类型数组,如intdouble等。,您需要将它们的TYPE常量用于它们对应的包装类,如下所示:

// Create an int array of 2 elements, populate it, and print the elements

var intArray = java.lang.reflect.Array.newInstance(java.lang.Integer.TYPE, 2);

intArray[0] = 100;

intArray[1] = 200;

for(var i = 0; i < intArray.length; i++) {

print(intArray[i]);

}

100

200

Nashorn 支持创建 Java 数组的新语法。首先,使用方法创建适当的 Java 数组类型,然后使用熟悉的new操作符创建数组。下面的代码片段展示了如何在 Nashorn 中创建一个包含两个元素的String[]:

// Get the java.lang.String[] type

var StringArray = Java.type("java.lang.String[]");

// Create a String[] array of 2 elements

var strArray = new StringArray (2);

strArray[0] = "Hello";

strArray[1] = "Array";

for(var i = 0; i < strArray.length; i++) {

print(strArray[i]);

}

Hello

Array

Nashorn 支持以同样的方式创建原始类型的数组。以下代码片段在 Nashorn 中创建了两个元素的int[]:

// Get the int[] type

var IntArray = Java.type("int[]");

// Create a int[] array of 2 elements

var intArray = new IntArray(2);

intArray[0] = 100;

intArray[1] = 200;

for(var i = 0; i < intArray.length; i++) {

print(intArray[i]);

}

100

200

当需要 Java 数组时,可以使用 JavaScript 数组。JavaScript 将执行从 JavaScript 数组到 Java 数组的必要转换。假设你有一个PrintArray类,如清单 10-22 所示,它包含一个接受String数组作为参数的print()方法。

清单 10-22。PrintArray 类

// PrintArray.java

package com.jdojo.script;

public class PrintArray {

public void print(String[] list) {

System.out.println("Inside print(String[] list):");

for(String s : list) {

System.out.println(s);

}

}

}

以下 JavaScript 代码片段将一个 JavaScript 数组传递给PrintArray.print(String[])方法。JavaScript 负责将本机数组转换成一个String数组,如输出所示。

// Create a JavaScript array and populate it with three strings

var names = new Array();

names[0] = "Rhino";

names[1] = "Nashorn";

names[2] = "JRuby";

// Create an object of the PrintArray class

var pa = new com.jdojo.script.PrintArray();

// Pass a JavaScript array to the PrintArray.print(String[] list) method

pa.print(names);

Inside print(String[] list):

Rhino

Nashorn

JRuby

Nashorn 使用Java.to()Java.from()函数支持 Java 和 JavaScript 数组之间的数组类型转换。Java.to()函数将 JavaScript 数组类型转换成 Java 数组类型。该函数将 array 对象作为第一个参数,将目标 Java 数组类型作为第二个参数。目标数组类型可以指定为字符串或类型对象。下面的代码片段将 JavaScript 数组转换成 Java String[]:

// Create a JavaScript array and populate it with three integers

var personIds = [100, 200, 300];

// Convert the JavaScript integer array to java String[]

var JavaStringArray = Java.to(personIds, "java.lang.String[]")

如果函数中的第二个参数被省略,JavaScript 数组将被转换为 Java Object[]

该函数将 Java 数组类型转换为 JavaScript 数组。该函数将 Java 数组作为参数。以下代码片段显示了如何将 Java int[]转换为 JavaScript 数组:

// Create a Java int[]

var IntArray = Java.type("int[]");

var personIds = new IntArray(3);

personIds[0] = 100;

personIds[1] = 200;

personIds[2] = 300;

// Convert the Java int[] array to a JavaScript array

var jsArray = Java.from(personIds);

// Print the elements in the JavaScript array

for(var i = 0; i < jsArray.length; i++) {

print(jsArray[i]);

}

100

200

300

Nashorn 似乎无法将 Java String[]转换成 JavaScript 数组。在以下脚本中尝试这样做将导致如下所示的错误:

// Create a Java String object

var str = new java.lang.String("Rhino,Nashorn,JRuby");

var strDelimiter = new java.lang.String(",");

var strArray = str.split(strDelimiter);

// Convert the Java String[] array to a JavaScript array

var jsArray = Java.from(strArray); // Nashorn throws an ScriptException here

// Print the elements in tje JavaScript array

for(var i = 0; i < jsArray.length; i++) {

print(jsArray[i]);

}

javax.script.ScriptException: TypeError: Can only convert Java arrays and lists to JavaScript arrays. Cant convert object of type {0}. in <eval> at line number 8...

Tip

从 JavaScript 函数返回一个 JavaScript 数组到 Java 代码是可能的。您需要在 Java 代码中提取原生数组的元素,因此您需要在 Java 中使用特定于 JavaScript 的类。不建议使用这种方法。您应该将 JavaScript 数组转换为 Java 数组,并从 JavaScript 函数返回 Java 数组,这样 Java 代码就只处理 Java 类。

扩展 Java 类实现接口

JavaScript 允许您在 JavaScript 中扩展 Java 类和实现 Java 接口。以下部分描述了实现这一点的不同方法。

使用脚本对象

您需要创建一个包含接口方法实现的脚本对象,并使用new操作符将其传递给 Java 接口的构造函数。在 Java 中,接口没有构造函数,也不能和new操作符一起使用。然而,JavaScript 让你做到了这一点。

让我们实现清单 10-17 所示的Calculator接口。下面的语句创建了一个实现add()subtract()方法的脚本对象。请注意,这两个方法的实现由逗号分隔。方法名及其实现由冒号分隔。

var calFuncObj =  {

add: function (n1, n2) {

return n1 + n2;

},

subtract: function (n1, n2) {

return n1 - n2;

}

};

以下语句创建接口的实现:

var calc = new com.jdojo.script.Calculator(calFuncObj);

现在您可以开始使用calc对象,就像它是Calculator接口的实现一样,如下所示:

var n1 = 15;

var n2 = 10;

var result1 = calc.add(n1, n2);

var result2 = calc.subtract(n1, n2);

print(n1 + " + " + n2 + " = " + result1);

print(n1 + " - " + n2 + " = " + result2);

15 + 10 = 25

15 - 10 = 5

使用匿名的类语法

该方法使用的语法与在 Java 中创建匿名类的语法非常相似。以下语句实现了 Java Calculator接口,并创建了该实现的实例:

var calc = new com.jdojo.script.Calculator() {

add: function (n1, n2) {

return n1 + n2;

},

subtract: function (n1, n2) {

return n1 - n2;

}

};

现在你可以像以前一样使用calc对象。

使用 JavaAdapter 对象和 Java.extend()函数

JavaScript 允许您实现多个接口,并使用JavaAdapter类扩展一个类。然而,与 JDK 捆绑在一起的 Rhino JavaScript 实现已经覆盖了JavaAdapter的实现,它只允许你实现一个接口;它不允许你扩展一个类。JavaAdapter构造函数的第一个参数是要实现的接口,第二个参数是实现方法的脚本对象。要在 Nashorn 中使用该对象,您需要加载 Rhino 兼容性模块。下面的代码片段使用JavaAdapter实现了Calculator接口:

// Need to load the compatibility module in Nashorn.

// You do not need to the following load() call in Rhino.

load("nashorn:mozilla_compat.js");

var calFuncObj =  {

add: function (n1, n2) {

return n1 + n2;

},

subtract: function (n1, n2) {

return n1 - n2;

}

};

var calc = new JavaAdapter(com.jdojo.script.Calculator, calFuncObj);

现在你可以像以前一样使用calc对象。

Nashorn 提供了一种更好的方法,可以让您使用Java.extend()函数扩展一个类并实现多个接口。在extend()函数中,你可以传递最多一个类类型和多个接口类型。它返回一个组合了所有传入类型的类型。您需要使用前面讨论过的匿名类语法来为新类型的抽象方法提供实现,或者覆盖被扩展类型的现有方法。下面的代码片段使用了Java.extend()方法来实现Calculator接口:

// Get the Calculator interface type

var CalculatorType = Java.type("com.jdojo.script.Calculator");

// Get a type that extends the Calculator type

var CalculatorExtender = Java.extend(CalculatorType);

// Implement the abstract methods in CalculatorExtender

// using an anonymous class like syntax

var calc = new CalculatorExtender() {

add: function (n1, n2) {

return n1 + n2;

},

subtract: function (n1, n2) {

return n1 - n2;

}

};

var n1 = 15;

var n2 = 10;

var result1 = calc.add(n1, n2);

var result2 = calc.subtract(n1, n2);

print(n1 + " + " + n2 + " = " + result1);

print(n1 + " - " + n2 + " = " + result2);

15 + 10 = 25

15 - 10 = 5

使用 JavaScript 函数

有时一个 Java 接口只有一个方法。在这些情况下,您可以传递一个 JavaScript 函数对象来代替接口的实现。Java 中的Runnable接口只有一个方法run()。当需要在 JavaScript 中使用Runnable接口的实例时,可以传递一个 JavaScript function 对象。

下面的代码片段展示了如何创建一个Thread对象并启动它。在类的构造函数中,传递的是 JavaScript 函数对象myRunFunc,而不是接口的实例。

function myRunFunc() {

print("A thread is running.");

}

// Call Thread(Runnable) constructor and pass the myRunFunc function object // that

// will serve as an implementation for the run() method of the Runnable    // interface.

var thread = new java.lang.Thread(myRunFunc);

thread.start();

A thread is running.

使用 Lambda 表达式

JavaScript 支持可以用作 lambda 表达式的匿名函数。下面是一个匿名函数,它将一个数字作为参数并返回其平方值:

function (n) {

return n * n;

}

下面是一个使用匿名函数作为 lambda 表达式在 JavaScript 中创建一个Runnable对象的例子。在Thread类的构造函数中使用了Runnable对象。

var Thread = Java.type("java.lang.Thread");

// Create a Thread using a Runnable object. The Runnable

// object is created using an anonymous function as a lambda expressions.

var thread = new Thread(function() {

print("Hello Thread");

});

// Start the thread

thread.start();

使用 lambda 表达式的 JavaScript 代码的 Java 等效代码如下:

// Create a Thread  using a Runnable object. The Runnable object is created using a

// lambda expressions.

Thread thread = new Thread(() -> {

System.out.println("Hello Thread");

});

// Start the thread

thread.start();

实现脚本引擎

实现一个成熟的脚本引擎不是一件简单的任务,这超出了本书的范围。本节旨在为您提供实现脚本引擎所需的设置的简要但完整的概述。在本节中,您将实现一个简单的脚本引擎,称为JKScript引擎。它将使用以下规则计算算术表达式:

  • 它将计算由两个操作数和一个运算符组成的算术表达式。
  • 表达式可能有两个数字文字、两个变量,或者一个数字文字和一个变量作为操作数。数字文字必须是十进制格式。不支持十六进制、八进制和二进制数字文本。
  • 表达式中的算术运算仅限于加、减、乘和除。
  • 它会将+-*/识别为算术运算符。
  • 引擎将返回一个Double对象作为表达式的结果。
  • 可以使用引擎的全局范围或引擎范围绑定将表达式中的操作数传递给引擎。
  • 它应该允许从一个String对象和一个java.io.Reader对象执行脚本。然而,一个Reader应该只有一个表达式作为其内容。
  • 它不会实现InvocableCompilable接口。

使用这些规则,脚本引擎的一些有效表达式如下:

  • 10 + 90
  • 10.7 + 89.0
  • +10 + +90
  • num1 + num2
  • num1 * num2
  • 78.0 / 7.5

在实现脚本引擎时,您需要为以下两个接口提供实现:

  • javax.script.ScriptEngineFactory界面。
  • javax.script.ScriptEngine界面

作为JKScript脚本引擎实现的一部分,你将开发表 10-3 中列出的三个类。在随后的部分中,您将开发这些类。

表 10-3。

The List of Classes to be Developed for the JKScript Script Engine

| 班级 | 描述 | | --- | --- | | `Expression` | `Expression`类是脚本引擎的核心。它执行解析和评估算术表达式的工作。它在`JKScriptEngine`类的`eval()`方法中使用。 | | `JKScriptEngine` | 接口的一个实现。它扩展了实现`ScriptEngine`接口的`AbstractScriptEngine`类。`AbstractScriptEngine`类为`ScriptEngine`接口的`eval()`方法的几个版本提供了标准实现。您需要实现下面两个版本的`eval()`方法:`Object eval(String, ScriptContext)` `Object eval(Reader, ScriptContext)` | | `JKScriptEngineFactory` | 接口的一个实现。 |

表达式类

Expression类包含解析和评估算术表达式的主要逻辑。清单 10-23 包含了这个类的完整代码。

清单 10-23。分析和计算算术表达式的表达式类

// Expression.java

package com.jdojo.script;

import java.util.regex.Matcher;

import java.util.regex.Pattern;

import javax.script.ScriptContext;

public class Expression {

private String exp;

private ScriptContext context;

private String op1;

private char op1Sign = '+';

private String op2;

private char op2Sign = '+';

private char operation;

private boolean parsed;

public Expression(String exp, ScriptContext context) {

if (exp == null || exp.trim().equals("")) {

throw new IllegalArgumentException(this.getErrorString());

}

this.exp = exp.trim();

if (context == null) {

throw new IllegalArgumentException("ScriptContext cannot be null.");

}

this.context = context;

}

public String getExpression() {

return exp;

}

public ScriptContext getScriptContext() {

return context;

}

public Double eval() {

// Parse the expression

if (!parsed) {

this.parse();

this.parsed = true;

}

// Extract the values for the operand

double op1Value = getOperandValue(op1Sign, op1);

double op2Value = getOperandValue(op2Sign, op2);

// Evaluate the expression

Double result = null;

switch (operation) {

case '+':

result = op1Value + op2Value;

break;

case '-':

result = op1Value - op2Value;

break;

case '*':

result = op1Value * op2Value;

break;

case '/':

result = op1Value / op2Value;

break;

default:

throw new RuntimeException("Invalid operation:" + operation);

}

return result;

}

private double getOperandValue(char sign, String operand) {

// Check if operand is a double

double value;

try {

value = Double.parseDouble(operand);

return sign == '-' ? -value : value;

}

catch (NumberFormatException e) {

// Ignore it. Operand is not in a format that can be

// converted to a double value.

}

// Check if operand is a bind variable

Object bindValue = context.getAttribute(operand);

if (bindValue == null) {

throw new RuntimeException(operand + " is not found in the script context.");

}

if (bindValue instanceof Number) {

value = ((Number) bindValue).doubleValue();

return sign == '-' ? -value : value;

}

else {

throw new RuntimeException(operand + " must be bound to a number.");

}

}

public void parse() {

// Supported expressiona are of the form v1 op v2, where v1 and v2

// are variable names or numbers, and op could be +, -, *, or /

// Prepare the pattern for the expected expression

String operandSignPattern = "([+-]?)";

String operandPattern = "([\\p{Alnum}\\p{Sc}_.]+)";

String whileSpacePattern = "([\\s]*)";

String operationPattern = "([+*/-])";

String pattern = "^" + operandSignPattern + operandPattern +

whileSpacePattern + operationPattern + whileSpacePattern +

operandSignPattern + operandPattern + "$";

Pattern p = Pattern.compile(pattern);

Matcher m = p.matcher(exp);

if (!m.matches()) {

// The expression is not in the expected format

throw new IllegalArgumentException(this.getErrorString());

}

// Get operand-1

String temp = m.group(1);

if (temp != null && !temp.equals("")) {

this.op1Sign = temp.charAt(0);

}

this.op1 = m.group(2);

// Get operation

temp = m.group(4);

if (temp != null && !temp.equals("")) {

this.operation = temp.charAt(0);

}

// Get operand-2

temp = m.group(6);

if (temp != null && !temp.equals("")) {

this.op2Sign = temp.charAt(0);

}

this.op2 = m.group(7);

}

private String getErrorString() {

return "Invalid expression[" + exp + "]" +

"\nSupported expression syntax is: op1 operation op2" +

"\n where op1 and op2 can be a number or a bind variable" +

" , and operation can be +, -, *, and /.";

}

@Override

public String toString() {

return "Expression: " + this.exp + ", op1 Sign = " +

op1Sign + ", op1 = " + op1 + ", op2 Sign = " +

op2Sign + ", op2 = " + op2 + ", operation = " + operation;

}

}

Expression类被设计用来解析和评估以下形式的算术表达式

op1 operation op2

这里,op1op2是两个操作数,可以是十进制格式的数字或变量,operation可以是+-*/

建议使用的Expression类是

Expression exp = new Expression(expression, scriptContext);

Double value = exp.eval();

让我们详细讨论一下Expression类的重要组件。

实例变量

实例变量expcontext分别是表达式和对表达式求值的ScriptContext。它们被传递给这个类的构造器。

实例变量op1op2分别表示表达式中的第一个和第二个操作数。实例变量op1Signop2Sign分别代表表达式中第一个和第二个操作数的符号,可以是“+”或“-”。当使用parse()方法解析表达式时,操作数及其符号被填充。

实例变量operation表示要对操作数执行的算术运算(+、-、*或/)。

实例变量parsed用于跟踪表达式是否已经被解析。该方法将其设置为true

构造函数

构造函数接受一个表达式和一个ScriptContext,确保它们不是null,并将它们存储在实例变量中。它在将表达式存储到实例变量exp之前,从表达式中删除前导和尾随空格。

parse()方法

parse()方法将表达式解析成操作数和操作。它使用正则表达式来解析表达式文本。正则表达式要求表达式文本采用以下形式:

  • 第一个操作数的可选符号+或-
  • 第一个操作数,可以由字母数字、货币符号、下划线和小数点的组合组成
  • 任意数量的空格
  • 操作符号可以是+、-、*或/
  • 第二个操作数的可选符号+或-
  • 第二个操作数,可以由字母数字、货币符号、下划线和小数点的组合组成

正则表达式([+-]?)将匹配操作数的可选符号。正则表达式([\\p{Alnum}\\p{Sc}_.]+)会匹配一个操作数,可能是十进制数,也可能是名字。正则表达式([\\s]*)将匹配任意数量的空格。正则表达式([+*/-])将匹配一个操作符。所有正则表达式都用括号括起来形成组,这样就可以捕获表达式的匹配部分。

如果表达式匹配正则表达式,parse()方法将匹配部分存储到各自的实例变量中。

注意,匹配操作数的正则表达式并不完美。它将允许几种无效的情况,比如一个操作数有多个小数点,等等。然而,对于这个演示目的,它将做。

getOperandValue()方法

此方法在解析表达式后的表达式求值过程中使用。如果操作数是一个double数,它通过应用操作数的符号返回值。否则,它会在ScriptContext中查找操作数的名称。如果在ScriptContext中没有找到操作数的名称,它抛出一个RuntimeException。如果在ScriptContext中找到操作数的名称,它将检查该值是否为数字。如果该值是一个数字,则在将符号应用于该值后返回该值;否则抛出一个RuntimeException

方法不支持十六进制、八进制和二进制格式的操作数。例如,像“0x2A + 0b1011”这样的表达式将不会被视为具有两个带int文字的操作数的表达式。读者可以增强这种方法,以支持十六进制、八进制和二进制格式的数字文字。

eval()方法

该方法计算表达式并返回一个double值。首先,如果表达式还没有被解析,它就解析它。注意,对eval()的多次调用只解析表达式一次。

它获取两个操作数的值,执行运算,并返回表达式的值。

JKScriptEngine 类

清单 10-24 包含了JKScript脚本引擎的实现。它的eval(String, ScriptContext)方法包含主逻辑。

Expression exp = new Expression(script, context);

Object result = exp.eval();

它创建了一个Expression类的对象。它调用评估表达式并返回结果的Expression对象的eval()方法。

eval(ReaderScriptContext)方法从Reader中读取所有行,将它们连接起来,并将结果String传递给eval(String, ScriptContext)方法来计算表达式。注意一个Reader必须只有一个表达式。一个表达式可以拆分成多行。Reader中的空白被忽略。

清单 10-24。JKScript 脚本引擎的实现

// JKScriptEngine.java

package com.jdojo.script;

import java.io.BufferedReader;

import java.io.IOException;

import java.io.Reader;

import javax.script.AbstractScriptEngine;

import javax.script.Bindings;

import javax.script.ScriptContext;

import javax.script.ScriptEngineFactory;

import javax.script.ScriptException;

import javax.script.SimpleBindings;

public class JKScriptEngine extends AbstractScriptEngine {

private ScriptEngineFactory factory;

public JKScriptEngine(ScriptEngineFactory factory) {

this.factory = factory;

}

@Override

public Object eval(String script, ScriptContext context)

throws ScriptException {

try {

Expression exp = new Expression(script, context);

Object result = exp.eval();

return result;

}

catch (Exception e) {

throw new ScriptException(e.getMessage());

}

}

@Override

public Object eval(Reader reader, ScriptContext context)

throws ScriptException {

// Read all lines from the Reader

BufferedReader br = new BufferedReader(reader);

String script = "";

String str = null;

try {

while ((str = br.readLine()) != null) {

script = script + str;

}

}

catch (IOException e) {

throw new ScriptException(e);

}

// Use the String version of eval()

return eval(script, context);

}

@Override

public Bindings createBindings() {

return new SimpleBindings();

}

@Override

public ScriptEngineFactory getFactory() {

return factory;

}

}

JKScriptEngineFactory 类

清单 10-25 包含了JKScript引擎的ScriptEngineFactory接口的实现。它的一些方法返回“未实现”字符串,因为您不支持这些方法公开的功能。JKScriptEngineFactory类中的代码不言自明。如在getNames()方法中编码的那样,可以使用名称为jksJKScriptjkscriptScriptEngineManager来获得JKScript引擎的实例。

清单 10-25。JKScript 脚本引擎的 ScriptEngineFactory 实现

// JKScriptEngineFactory.java

package com.jdojo.script;

import java.util.ArrayList;

import java.util.Arrays;

import java.util.Collections;

import java.util.List;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineFactory;

public class JKScriptEngineFactory implements ScriptEngineFactory {

@Override

public String getEngineName() {

return "JKScript Engine";

}

@Override

public String getEngineVersion() {

return "1.0";

}

@Override

public List<String> getExtensions() {

return Collections.unmodifiableList(Arrays.asList("jks"));

}

@Override

public List<String> getMimeTypes() {

return Collections.unmodifiableList(

Arrays.asList("text/jkscript") );

}

@Override

public List<String> getNames() {

List<String> names = new ArrayList<>();

names.add("jks");

names.add("JKScript");

names.add("jkscript");

return Collections.unmodifiableList(names);

}

@Override

public String getLanguageName() {

return "JKScript";

}

@Override

public String getLanguageVersion() {

return "1.0";

}

@Override

public Object getParameter(String key) {

switch (key) {

case ScriptEngine.ENGINE:

return getEngineName();

case ScriptEngine.ENGINE_VERSION:

return getEngineVersion();

case ScriptEngine.NAME:

return getEngineName();

case ScriptEngine.LANGUAGE:

return getLanguageName();

case ScriptEngine.LANGUAGE_VERSION:

return getLanguageVersion();

case "THREADING":

return "MULTITHREADED";

default:

return null;

}

}

@Override

public String getMethodCallSyntax(String obj, String m, String[] p) {

return "Not implemented";

}

@Override

public String getOutputStatement(String toDisplay) {

return "Not implemented";

}

@Override

public String getProgram(String[] statements) {

return "Not implemented";

}

@Override

public ScriptEngine getScriptEngine() {

return new JKScriptEngine(this);

}

}

准备部署

在为JKScript脚本引擎打包类之前,您需要再执行一个步骤。创建一个名为META-INF的目录。在META-INF目录下,创建一个名为services的子目录。在services目录下,创建一个名为javax.script.ScriptEngineFactory的文本文件。请注意,文件名必须是所提到的名称,并且不应该有任何扩展名,如.txt

编辑javax.script.ScriptEngineFactory文件并输入如清单 10-26 所示的内容。文件中的第一行是以#号开头的注释。第二行是JKScript脚本引擎工厂类的完全限定名。

清单 10-26。名为 javax . script . scriptenginefactory 的文件的内容

#The factory class for the JKScript engine

com.jdojo.script.JKScriptEngineFactory

为什么一定要执行这一步?您将把javax.script.ScriptEngineFactory文件和JKScript引擎的类文件打包在一个 JAR 文件中。脚本引擎的发现机制在类路径的所有 JAR 文件的META-INF/services目录中搜索这个文件。如果找到该文件,将读取其内容,所有脚本工厂类都将被实例化并包含在脚本引擎工厂列表中。因此,这一步对于让您的JKScript引擎被ScriptEngineManager自动发现是必要的。

打包 jscript 文件

您需要将JKScript脚本引擎的所有文件打包到一个名为jkscript.jar的 JAR 文件中。您也可以将该文件命名为任何其他名称。以下是文件及其目录的列表。注意,一个空的manifest.mf文件将在这种情况下工作。

  • com\jdojo\script\Expression.class
  • com\jdojo\script\JKScriptEngine.class
  • com\jdojo\script\JKScriptEngineFactory.class
  • META-INF\manifest.mf
  • META-INF\services\javax.script.ScriptEngineFactory

您可以手动创建jkscript.jar文件,方法是将上面列出的所有文件(除了manifest.mf文件)复制到一个目录中,比如 Windows 上的C:\build,然后从C:\build目录执行以下命令:

C:\build> jar cf jkscript.jar com\jdojo\script\*.class META-INF\services\*.*

使用 jscript 脚本引擎

是时候测试你的JKScript脚本引擎了。第一步也是最重要的一步是将您在上一节中创建的jkscript.jar包含到应用类路径中。一旦在应用类路径中包含了jkscript.jar文件,使用JKScript与使用任何其他脚本引擎没有什么不同。

下面的代码片段创建了一个使用JKScript作为名称的JKScript脚本引擎的实例。你也可以用它的其他名字,jksjkscript

// Create the JKScript engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JKScript");

if (engine == null) {

System.out.println("JKScript engine is not available. ");

System.out.println("Add jkscript.jar to CLASSPATH.");

}

else {

// Evaluate your JKScript

}

清单 10-27 包含一个使用JKScript脚本引擎评估不同类型表达式的程序。执行存储在String对象和文件中的表达式。一些表达式使用数值和一些绑定变量,它们的值在引擎范围和引擎的默认ScriptContext的全局范围内的绑定中传递。注意,这个程序期望在当前目录中有一个名为jkscript.txt的文件,其中包含一个可以被JKScript脚本引擎理解的算术表达式。如果脚本文件不存在,程序将在标准输出中打印一条消息,其中包含预期脚本文件的路径。您可能会在最后一行得到不同的输出。

清单 10-27。使用 JKScript 脚本引擎

// JKScriptTest.java

package com.jdojo.script;

import java.io.FileNotFoundException;

import java.io.IOException;

import java.io.Reader;

import java.nio.file.Files;

import java.nio.file.Path;

import java.nio.file.Paths;

import javax.script.ScriptEngine;

import javax.script.ScriptEngineManager;

import javax.script.ScriptException;

public class JKScriptTest {

public static void main(String[] args) throws FileNotFoundException, IOException {

// Create JKScript engine

ScriptEngineManager manager = new ScriptEngineManager();

ScriptEngine engine = manager.getEngineByName("JKScript");

if (engine == null) {

System.out.println("JKScript engine is not available. ");

System.out.println("Add jkscript.jar to CLASSPATH.");

return;

}

// Test scripts as String

testString(manager, engine);

// Test scripts as a Reader

testReader(manager, engine);

}

public static void testString(ScriptEngineManager manager,

ScriptEngine engine) {

try {

// Use simple expressions with numeric literals

String script = "12.8 + 15.2";

Object result = engine.eval(script);

System.out.println(script + " = " + result);

script = "-90.0 - -10.5";

result = engine.eval(script);

System.out.println(script + " = " + result);

script = "5 * 12";

result = engine.eval(script);

System.out.println(script + " = " + result);

script = "56.0 / -7.0";

result = engine.eval(script);

System.out.println(script + " = " + result);

// Use global scope bindings variables

manager.put("num1", 10.0);

manager.put("num2", 20.0);

script = "num1 + num2";

result = engine.eval(script);

System.out.println(script + " = " + result);

// Use global and engine scopes bindings. num1 from

// engine scope and num2 from global scope will be used.

engine.put("num1", 70.0);

script = "num1 + num2";

result = engine.eval(script);

System.out.println(script + " = " + result);

// Try mixture of number literal and bindings. num1 from

// the engine scope bindings will be used

script = "10 + num1";

result = engine.eval(script);

System.out.println(script + " = " + result);

}

catch (ScriptException e) {

e.printStackTrace();

}

}

public static void testReader(ScriptEngineManager manager,

ScriptEngine engine) {

try {

Path scriptPath = Paths.get("jkscript.txt").toAbsolutePath();

if (!Files.exists(scriptPath)) {

System.out.println(scriptPath +

" script file does not exist.");

return;

}

try(Reader reader = Files.newBufferedReader(scriptPath);) {

Object result = engine.eval(reader);

System.out.println("Result of " +

scriptPath + " = " + result);

}

}

catch(ScriptException | IOException e) {

e.printStackTrace();

}

}

}

12.8 + 15.2 = 28.0

-90.0 - -10.5 = -79.5

5 * 12 = 60.0

56.0 / -7.0 = -8.0

num1 + num2 = 30.0

num1 + num2 = 90.0

10 + num1 = 80.

Result of C:\jkscript.txt = 190.0

jrunscript 命令行 Shell

JDK 包括一个名为jrunscript的命令行脚本外壳。它独立于脚本引擎,可以用来评估任何脚本,包括您的JKScript。您可以在JAVA_HOME\bin目录中找到这个 shell,其中JAVA_HOME是您安装 JDK 的目录。在这一节中,我将讨论如何使用jrunscript shell 来评估使用不同脚本引擎的脚本。

语法

使用jrunscript shell 的语法是

jrunscript [options] [arguments]

[options][arguments]都是可选的。但是,如果两者都指定了,[options]必须在[arguments]之前。表 10-4 列出了jrunscript外壳的所有可用选项。

表 10-4。

The List of Options for the jrunscript shell

| [计]选项 | 描述 | | --- | --- | | `-classpath ` | 用于指定类路径。 | | `-cp ` | 同选项`-classpath`。 | | `-D=` | 为 Java 运行时设置系统属性。 | | `-J` | 将指定的``传递给运行`jrunscript`的 JVM。 | | `-l ` | 允许您指定要使用的脚本语言。默认情况下,Rhino JavaScript 用于 JDK 6 和 JDK 7。在 JDK 8 中,Nahsorn 是默认值。如果您想使用 JavaScript 之外的语言,比如说 JKScript,您将需要使用`-` cp 或`-classpath`选项来包含包含脚本引擎的 JAR 文件。 | | `-e

posted @ 2024-08-06 16:33  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报