Bean ValidationのJavaFX対応

このエントリは JavaFX Advent Calendar 2017 の 19 日目のエントリです。前日は id:planet-az さんによる「 簡単なミュージックプレーヤーをさらにいじってみた 」でした。

はじめに

今回取り上げるのは、 JSR 380 Bean Validation 2.0JavaFX 対応についてです。そうです、Java EE に含まれる Bean Validation が JavaFX に対応したのです! まさか EE の仕様が JavaFX のことを考慮するとは思わなかったので、これは非常に驚きました。

具体的には JavaFX のプロパティ API に対して Bean Validation が対応しました。このエントリではその使い方について紹介したいと思います。

Bean Validation 2.0 について

Bean Validation 2.0 は Java EE 8 に含まれる仕様です。以下の Web サイトに Specification がまとめられています。

http://beanvalidation.org/2.0/spec/

新機能の 1 つとして次のようなものが挙げられています。

  • Support for validating container elements by annotating type arguments of parameterized types, e.g. List<@Positive Integer> positiveNumbers (see Container element constraints); this also includes:
    • More flexible cascaded validation of collection types; e.g. values and keys of maps can be validated now: Map<@Valid CustomerType, @Valid Customer> customersByType
    • Support for java.util.Optional
    • Support for the property types declared by JavaFX
    • Support for custom container types by plugging in additional value extractors (see Value extractor definition)

新たにコンテナ要素のバリデーションに対応しているとあります。コレクションや Optional オブジェクトなどの中身に対してバリデーションを掛けられるようになったのです。そしてこの対応の一環として、 JavaFX のプロパティもデフォルトで対応するようになったのです。 *1

JavaFX アプリケーションでの Bean Validation の使い方

それでは早速使ってみます。折角 JavaFX を使っているので、バインドを活用してみることにします。

作成するサンプルアプリケーション

作ってみるのは次のようなユーザ登録フォームを想定したものです。

f:id:aoe-tk:20171218231023p:plain

テキストフィールドに文字を打ち込むたびにバリデーションを行い、正しい文字列が入力されたタイミングでエラーメッセージが消えるようになります。E-mail、パスワードどちらも OK になれば登録ボタンがクリック可能になります。

f:id:aoe-tk:20171218231509g:plain

E-mail 欄のバリデーションには Bean Validation 2.0 で新たに入った @Email を使ってみることにします。

依存性の設定

まず、JavaFX アプリケーションで Bean Validation を使えるようにします。参照実装である Hibernate Validator を使います。Maven だと pom.xml に次のように依存設定を記述します。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

<%-- Snip --%>

    <dependencies>
        <dependency>
            <groupId>org.hibernate.validator</groupId>
            <artifactId>hibernate-validator</artifactId>
            <version>6.0.4.Final</version>
        </dependency>
        <dependency>
            <groupId>org.glassfish</groupId>
            <artifactId>javax.el</artifactId>
            <version>3.0.0</version>
        </dependency>
    </dependencies>

<%-- Snip --%>

</project>

Model、FXML の実装

Model のコードは次のようになります。

package aoetk.sample.beanvalidation;

import javafx.beans.property.SimpleStringProperty;
import javafx.beans.property.StringProperty;

import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;

public class Model {
    @NotEmpty
    @Email
    private StringProperty email = new SimpleStringProperty("");

    @Size(min = 8)
    private StringProperty password = new SimpleStringProperty("");

    public String getEmail() {
        return email.get();
    }

    public StringProperty emailProperty() {
        return email;
    }

    public void setEmail(String email) {
        this.email.set(email);
    }

    public String getPassword() {
        return password.get();
    }

    public StringProperty passwordProperty() {
        return password;
    }

    public void setPassword(String password) {
        this.password.set(password);
    }
}

フィールドに着目してください。このように JavaFX プロパティに対して直接 Bean Validation のアノテーションを指定可能になっています。

次に View としての FXML のコードを示します。

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

<%-- import略 --%>
<GridPane hgap="10.0" vgap="10.0" xmlns="http://javafx.com/javafx" xmlns:fx="http://javafx.com/fxml/1" fx:controller="aoetk.sample.beanvalidation.Controller">
   <rowConstraints>
      <RowConstraints minHeight="10.0" />
      <RowConstraints minHeight="10.0" />
      <RowConstraints minHeight="10.0" />
   </rowConstraints>
   <columnConstraints>
      <ColumnConstraints hgrow="NEVER" minWidth="10.0" />
      <ColumnConstraints hgrow="ALWAYS" minWidth="10.0" />
   </columnConstraints>
   <children>
      <Label text="E-mail" />
      <Label text="Password" GridPane.rowIndex="1" />
      <Button fx:id="registerButton" mnemonicParsing="false" text="Register" GridPane.columnSpan="2" GridPane.halignment="CENTER" GridPane.rowIndex="2" />
      <VBox GridPane.columnIndex="1">
         <children>
            <TextField fx:id="emailField" prefHeight="27.0" prefWidth="350.0" />
            <Label fx:id="emailErrorLabel" text="Label" textFill="red" />
         </children>
      </VBox>
      <VBox GridPane.columnIndex="1" GridPane.rowIndex="1">
         <children>
            <PasswordField fx:id="passwordField" />
            <Label fx:id="passwordErrorLabel" text="Label" textFill="red" />
         </children>
      </VBox>
   </children>
   <padding>
      <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
   </padding>
</GridPane>

Scene Builder で見ないとちょっと分かりにくいかも知れませんね。入力フィールドに加え、エラーメッセージについても fx:id 属性を与えて Controller 側でアクセスできるようにしている点に注目してください。

Controller でのバインド、バリデーション実装

そして、この Model と View を Controller でバインドし、さらにフィールドに対して入力が発生した場合にバリデーションが実行されるようにします。

// (import略)
public class Controller implements Initializable {
    @FXML
    Label emailErrorLabel;

    @FXML
    Label passwordErrorLabel;

    @FXML
    Button registerButton;

    @FXML
    TextField emailField;

    @FXML
    PasswordField passwordField;

    private StringProperty emailMessage = new SimpleStringProperty("");

    private StringProperty passwordMessage = new SimpleStringProperty("");

    private Model model = new Model();

    private Validator validator;

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        final ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        validator = validatorFactory.getValidator();
        validateModel();

        emailErrorLabel.textProperty().bind(emailMessage);
        passwordErrorLabel.textProperty().bind(passwordMessage);
        registerButton.disableProperty().bind(emailMessage.isNotEmpty().and(passwordMessage.isNotEmpty()));
        emailErrorLabel.managedProperty().bind(emailMessage.isNotEmpty());
        passwordErrorLabel.managedProperty().bind(passwordMessage.isNotEmpty());
        model.emailProperty().bind(emailField.textProperty());
        model.passwordProperty().bind(passwordField.textProperty());
        emailField.textProperty().addListener(observable -> validateModel());
        passwordField.textProperty().addListener(observable -> validateModel());
    }

    private void validateModel() {
        final Set<ConstraintViolation<Model>> violations = validator.validate(model);
        emailMessage.set("");
        passwordMessage.set("");
        for (ConstraintViolation<Model> violation : violations) {
            if (violation.getPropertyPath().toString().equals("email")) {
                emailMessage.set(violation.getMessage());
            } else if (violation.getPropertyPath().toString().equals("password")) {
                passwordMessage.set(violation.getMessage());
            }
        }
    }
}

initialize() メソッドの最初で Validator オブジェクトを取得している点については特に説明不要ですね。その後すぐに validateMode() メソッドをコールして初期状態でのバリデーションを実行しています。

エラーメッセージは Validator によるバリデーションの結果出力されたメッセージをそのまま使うようにしました。メッセージを格納する StringProperty オブジェクトを Controller のフィールドとして持たせ、それをエラーメッセージの Label とバインドさせています。

mailErrorLabel.textProperty().bind(emailMessage);
PasswordErrorLabel.textProperty().bind(passwordMessage);

また、このメッセージが空文字の場合はボタンをクリック可能にし、エラーメッセージラベルもシーングラフから取り除くようにしました。これも次のようにバインドを活用しています。
(managed プロパティを false 設定すると一時的にシーングラフから取り除かれます)

registerButton.disableProperty().bind(emailMessage.isNotEmpty().and(passwordMessage.isNotEmpty()));
emailErrorLabel.managedProperty().bind(emailMessage.isNotEmpty());
passwordErrorLabel.managedProperty().bind(passwordMessage.isNotEmpty());

そして、テキストボックスやパスワードフィールドの入力内容を Model のプロパティに反映させるためにバインドします。

model.emailProperty().bind(emailField.textProperty());
model.passwordProperty().bind(passwordField.textProperty());

テキストボックスやパスワードフィールドの textProperty をリッスンし、文字列が入力されるたびにバリデーションを発火します。

emailField.textProperty().addListener(observable -> validateModel());
passwordField.textProperty().addListener(observable -> validateModel());

最後にバリデーションを実行する validateModel() の実装を見てみます。モデルに対してバリデーションを実行し、結果として返ってきた ConstraintViolation オブジェクトのプロパティ名をチェックし、対応するプロパティ用のエラーメッセージ StringProperty オブジェクトにメッセージをセットしています。 後はバインドの仕組みで自動的にメッセージ表示/非表示やボタンのクリック可/不可が制御されます。

private void validateModel() {
    final Set<ConstraintViolation<Model>> violations = validator.validate(model);
    emailMessage.set("");
    passwordMessage.set("");
    for (ConstraintViolation<Model> violation : violations) {
        if (violation.getPropertyPath().toString().equals("email")) {
            emailMessage.set(violation.getMessage());
        } else if (violation.getPropertyPath().toString().equals("password")) {
            passwordMessage.set(violation.getMessage());
        }
    }
}

JavaFX のバインドの仕組みを活用することで、プレゼンテーションサイドのロジックがかなり宣言的になっていることが分かると思います。

このようにして先のスクリーンショットに示したような動作を実現しました。

コードの全体は GitHub にアップしています。

https://github.com/aoetk/javafx-beanvalidation-sample

まとめ

以上に示したように、Bean Validation 2.0 では JavaFX のプロパティを対象としたバリデーションが可能になりました。バインドなど JavaFX の機構を活用して、ユーザに対して応答性の良いバリデーションの仕組みを実装できるようになります。

今回はキー入力のタイミングでバリデーションを行ってみましたが、もちろん他にもフォーカスアウトで実行する、(多くの Web アプリのように) ボタンクリックのタイミングで実行する、あるいはバリデーションエラーを受け取ったときは入力をキャンセルしてそもそも入力できない文字は入れられないようにする...などなど色んな実装方法が考えられます。皆さんなりのやり方で色々試してみるといいでしょう。

明日は...まだ埋まっていないな。みんな書いて書いて!

*1:Value extractor を実装することでカスタムのコンテナに対するバリデーションを行うことも可能になります

JavaFXアプリケーションでJava 9のモジュールを使うときの注意点

このエントリは JavaFX Advent Calendar 2017 の 1 日目のエントリです。最初を飾るのは初めてです。まだ参加者が少ないので、みんな参加してね!

いよいよJava SE 9がリリースされました。やはり9の注目はJigsawことモジュールシステムですね。特にJavaFXにとってはjavapackagerを用いてパッケージングした際の配布サイズを小さくすることができるので、特に重要ですね。

aoe-tk.hatenablog.com

ですが、JavaFXアプリケーションでモジュールシステムを利用すると早速引っ掛かるポイントがあります。それはFXMLです。

次のような FXML を使った簡単なアプリケーションを見てみましょう。

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

<?import javafx.geometry.Insets?>
<?import javafx.scene.control.Button?>
<?import javafx.scene.control.Label?>
<?import javafx.scene.layout.BorderPane?>
<?import javafx.scene.layout.HBox?>
<?import javafx.scene.layout.StackPane?>

<BorderPane maxHeight="-Infinity" maxWidth="-Infinity" minHeight="-Infinity" minWidth="-Infinity" prefHeight="300.0" prefWidth="400.0" xmlns="http://javafx.com/javafx/9" xmlns:fx="http://javafx.com/fxml/1" fx:controller="sample.Controller">
   <center>
      <StackPane prefHeight="150.0" prefWidth="200.0" BorderPane.alignment="CENTER">
         <children>
            <Label text="This is Label." fx:id="label" />
         </children>
      </StackPane>
   </center>
   <bottom>
      <HBox alignment="CENTER" BorderPane.alignment="CENTER">
         <children>
            <Button alignment="CENTER" mnemonicParsing="false" text="Button" onAction="#click" />
         </children>
         <BorderPane.margin>
            <Insets bottom="10.0" left="10.0" right="10.0" top="10.0" />
         </BorderPane.margin>
      </HBox>
   </bottom>
</BorderPane>
package sample;

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

public class Controller {
    @FXML
    private Label label;

    @FXML
    void click(ActionEvent event) {
        label.setText("You clicked!");
    }
}

FXMLのいいところとして、ビューXMLとペアになるControllerクラスに対して、XML側に記述したコンポーネントへのアクセスをDIを使って提供している点ですね。publicなフィールドだけでなく、package privateやprivateなフィールドとしての宣言も可能で、その場合は @FXML アノテーションを付与すると、インスタンスがインジェクトされるようになります。イベント実行メソッドも同様に @FXML アノテーションでバインドできます。

ですが、この @FXML アノテーションを使う場合は注意が必要です。次のように module-info.java を作ってみましょう。

module fxml9sample {
    requires javafx.controls;
    requires javafx.fxml;
    exports sample;
}

すると次のような例外が飛びます。 javafx.fxml モジュールに属するクラスがこのControllerクラスに対してリフレクションによるアクセスを試みるためです。

Exception in Application start method
java.lang.reflect.InvocationTargetException
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
(中略)
Caused by: javafx.fxml.LoadException: 
fxml9sample/out/production/fxml9sample/sample/sample.fxml:15

    at javafx.fxml/javafx.fxml.FXMLLoader.constructLoadException(FXMLLoader.java:2625)
    at javafx.fxml/javafx.fxml.FXMLLoader.loadImpl(FXMLLoader.java:2603)
(中略)
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make field private javafx.scene.control.Label sample.Controller.label accessible: module fxml9sample does not "opens sample" to module javafx.fxml
    at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:337)
    at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:281)
    at java.base/java.lang.reflect.Field.checkCanSetAccessible(Field.java:176)
    at java.base/java.lang.reflect.Field.setAccessible(Field.java:170)
    at javafx.fxml/javafx.fxml.FXMLLoader$ControllerAccessor.addAccessibleFields(FXMLLoader.java:3495)
    at javafx.fxml/javafx.fxml.FXMLLoader$ControllerAccessor.access$3900(FXMLLoader.java:3344)
    at javafx.fxml/javafx.fxml.FXMLLoader$ControllerAccessor$1.run(FXMLLoader.java:3460)
    at javafx.fxml/javafx.fxml.FXMLLoader$ControllerAccessor$1.run(FXMLLoader.java:3456)
    at java.base/java.security.AccessController.doPrivileged(Native Method)
    at javafx.fxml/javafx.fxml.FXMLLoader$ControllerAccessor.addAccessibleMembers(FXMLLoader.java:3455)
    at javafx.fxml/javafx.fxml.FXMLLoader$ControllerAccessor.getControllerFields(FXMLLoader.java:3394)
    at javafx.fxml/javafx.fxml.FXMLLoader.injectFields(FXMLLoader.java:1170)
    at javafx.fxml/javafx.fxml.FXMLLoader.access$1600(FXMLLoader.java:105)
    at javafx.fxml/javafx.fxml.FXMLLoader$ValueElement.processValue(FXMLLoader.java:865)
    at javafx.fxml/javafx.fxml.FXMLLoader$ValueElement.processStartElement(FXMLLoader.java:759)
    at javafx.fxml/javafx.fxml.FXMLLoader.processStartElement(FXMLLoader.java:2722)
    at javafx.fxml/javafx.fxml.FXMLLoader.loadImpl(FXMLLoader.java:2552)
    ... 17 more
Exception running application sample.Main

これを防ぐためには、FXMLと対になるControllerクラスが属しているパッケージに対して javafx.fxml モジュールからのリフレクションによるアクセスを許可する必要があります。そのために次のように opens 文を使って javafx.fxml モジュールに対してアクセスを許可する必要があります。

module fxml9sample {
    requires javafx.controls;
    requires javafx.fxml;
    exports sample;
    opens sample to javafx.fxml; // <- これを追加
}

既存のJavaFXアプリケーションをJava 9に移行する際に意外と引っ掛かるポイントなので注意してください。

というわけで、1 日目のエントリは小ネタでした。19 日にも書く予定ですが、そちらはもう少し大きなネタにする予定です。

明日は...執筆時点でまだいない! 誰か書いてー。

JDK9でのjavapackagerについて

はじめに

以前自分の blog にて JDK に付属しているツールである javapackager について紹介したことがあります。このツールは主にクライアントサイド Java アプリケーションを配布可能な形態でパッケージングするためのツールです。ネイティブインストーラも生成することができます。

aoe-tk.hatenablog.com

このエントリではネイティブパッケージに含まれるランタイムについて、次のようなことを述べていました。

昔は JDK を丸ごと放り込むという豪快な感じになっていましたが、最近は結構スリムアップしました。JDK9 の Jigsaw が入るともっと効率よくなるでしょう。

そして遂に Java9 がリリースされました。JDK9 の javapackager のマニュアル には次のような記載があります。

For self-contained applications, the Java Packager for JDK 9 packages applications with a JDK 9 runtime image generated by the jlink tool.

確かに jlink と連動してランタイムイメージを作ると記載されています。つまり、module-info.java を作っていれば、必要なモジュールだけを含んだランタイムイメージを作成することになり、アプリケーションの配布サイズが小さくなることが期待されます。というわけで早速 JDK9 の javapackager を試してみることにしました。

パッケージ対象となるアプリケーション

まず、パッケージ対象となるアプリケーションを作ります。とてもシンプルな JavaFX アプリケーションとして作ります。

package aoetk.sample;

import javafx.application.Application;
import javafx.scene.Scene;
import javafx.scene.layout.StackPane;
import javafx.scene.text.Text;
import javafx.stage.Stage;

public class SampleApp extends Application {
    @Override
    public void start(Stage primaryStage) throws Exception{
        primaryStage.setTitle("Packager Sample");
        StackPane stackPane = new StackPane();
        stackPane.getChildren().add(new Text("Packager Sample"));
        primaryStage.setScene(new Scene(stackPane, 300, 275));
        primaryStage.show();
    }

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

ウィンドウの真ん中にテキストを表示するだけのとてもシンプルな JavaFX アプリケーションです。これを対象にパッケージングを行ってみます。

JDK8でのパッケージング

まず、JDK8 でコンパイルし、パッケージングしてみます。パスを JDK8 に通します。

>set PATH=C:\Program Files\Java\jdk1.8.0_144\bin;%PATH%

>javac -version
javac 1.8.0_144

>where javapackager
C:\Program Files\Java\jdk1.8.0_144\bin\javapackager.exe

JDK8 の javac でコンパイルし、JAR を作ります。

>javac -d bin src\aoetk\sample\SampleApp.java

>javapackager -createjar -nocss2bin -appclass aoetk.sample.SampleApp -srcdir bin -outdir artifact -outfile packager-sample.jar

>dir artifact
 ドライブ C のボリューム ラベルは Windows です
 ボリューム シリアル番号は ACFE-5623 です

 C:\Users\aoe\develop\packager-sample\artifact のディレクトリ

2017/10/08  00:32    <DIR>          .
2017/10/08  00:32    <DIR>          ..
2017/10/08  00:32             1,354 packager-sample.jar
               1 個のファイル               1,354 バイト
               2 個のディレクトリ  366,187,679,744 バイトの空き領域

この JAR に対し、ネイティブインストールイメージを作ります。今回はインストール後のイメージサイズを知りたいので、 -native の引数に image を渡してインストールイメージのみを作ります。

>javapackager -deploy -native image -outdir package -outfile packager-sample -srcdir artifact -srcfiles packager-sample.jar -appclass aoetk.sample.SampleApp -name "jdk8-sample" -title "JDK8Sample" -BappVersion=1.0 -Bwin.menuGroup="JDK8Sample"
アプリケーション・バンドルを作成しています: C:\Users\aoe\develop\packager-sample\package内のjdk8-sample
"モジュール: [java.rmi, java.sql, javafx.web, jdk.charsets, java.logging, java.xml.crypto, java.xml, jdk.xml.dom, jdk.jfr, java.datatransfer, jdk.packager.services, jdk.httpserver, javafx.base, jdk.net, java.desktop, java.naming, javafx.controls, java.prefs, java.security.sasl, jdk.naming.rmi, jdk.zipfs, java.base, jdk.crypto.ec, jdk.management.agent, java.management, java.sql.rowset, javafx.swing, jdk.jsobject, jdk.sctp, java.smartcardio, jdk.unsupported, jdk.jdwp.agent, jdk.scripting.nashorn, java.instrument, java.security.jgss, jdk.management, java.compiler, javafx.graphics, jdk.security.auth, java.scripting, javafx.fxml, jdk.dynalink, javafx.media, jdk.accessibility, java.management.rmi, jdk.naming.dns, jdk.security.jgss, jdk.localedata]をランタイム・イメージに追加しています。"
警告: Windows Defenderが原因でJavaパッケージャが機能しないことがあります。問題が発生した場合は、リアルタイム・モニタリン グを無効にするか、ディレクトリ"C:\Users\aoe\AppData\Local\Temp\"の除外を追加することにより、問題に対処できます。
結果のアプリケーション・バンドル: C:\Users\aoe\develop\packager-sample\package

これで jdk8-sample ディレクトリの下にインストールイメージが作られます。これはインストール後にインストールディレクトリに展開される構成そのものです。

f:id:aoe-tk:20171009000534p:plain

ディレクトリサイズは次のように 167MB と、単純なアプリケーションにしては随分大きなサイズになっていることが分かります。

f:id:aoe-tk:20171009000554p:plain

JDK9でのパッケージング

では Java9 で導入されたモジュールシステムを利用してパッケージングしてみることにしましょう。環境を JDK9 に変更します。

>set PATH=C:\Program Files\Java\jdk-9\bin;%PATH%

>javac -version
javac 9

>where javapackager
C:\Program Files\Java\jdk-9\bin\javapackager.exe

モジュールの設定を行います。まずはこのアプリケーションがどのモジュールを利用しているかを調べてみましょう。

>jdeps -s artifact\packager-sample.jar
packager-sample.jar -> java.base
packager-sample.jar -> javafx.base
packager-sample.jar -> javafx.graphics

java.base モジュールの他に javafx.base モジュール、 javafx.graphics モジュールに依存していることが分かります。 javafx.graphics モジュールは javafx.base モジュールに依存しているので module-info.java は次のように javafx.graphics モジュールへの依存を記載すれば OK です。

module aoetk.sample.packager {
    requires javafx.graphics;
    exports aoetk.sample;
}

これを JDK9 のコンパイラを使ってコンパイルし、JAR を作ります。

>javac -d bin src\module-info.java src\aoetk\sample\SampleApp.java

>javapackager -createjar -nocss2bin -appclass aoetk.sample.SampleApp -srcdir bin -outdir artifact -outfile packager-sample.jar

>dir artifact
 ドライブ C のボリューム ラベルは Windows です
 ボリューム シリアル番号は ACFE-5623 です

 C:\Users\aoe\develop\packager-sample\artifact のディレクトリ

2017/10/08  00:52    <DIR>          .
2017/10/08  00:52    <DIR>          ..
2017/10/08  00:52             1,642 packager-sample.jar
               1 個のファイル               1,642 バイト
               2 個のディレクトリ  366,004,936,704 バイトの空き領域

これを同じように javapackager を用いてインストールイメージを作るのですが、モジュールを使ったアプリケーションをパッケージングする場合は指定する引数が異なります。ですが、現在の javapackager のマニュアル はこの変更に追いついていません...。執筆時点で javapackager のコマンドラインオプションについて正確な記述があったのは JEP 275: Modular Java Application Packaging のみでした。

具体的には -srcdir-srcfiles-appclass の指定が無くなり、モジュールを使った Java アプリケーションを java コマンドで実行するときと同じようにモジュールパス (対象モジュールの JAR が置かれているディレクトリ) を -p (もしくは --module-path) で、実行クラスを -m (もしくは --module) でモジュール名とクラス名を組み合わせて指定します。

>javapackager -deploy -native image -outdir package -outfile packager-sample -p artifact -m aoetk.sample.packager/aoetk.sample.SampleApp -name "jdk9-sample" -title "JDK9Sample" -BappVersion=1.0 -Bwin.menuGroup="JDK9Sample"
アプリケーション・バンドルを作成しています: C:\Users\aoe\develop\packager-sample\package内のjdk9-sample
モジュールaoetk.sample.packagerは存在しません。
"モジュール: [aoetk.sample.packager]をランタイム・イメージに追加しています。"
モジュールaoetk.sample.packagerは存在しません。
警告: Windows Defenderが原因でJavaパッケージャが機能しないことがあります。問題が発生した場合は、リアルタイム・モニタリン グを無効にするか、ディレクトリ"C:\Users\aoe\AppData\Local\Temp\"の除外を追加することにより、問題に対処できます。
結果のアプリケーション・バンドル: C:\Users\aoe\develop\packager-sample\package

これで jdk9-sample ディレクトリの下にインストールイメージが作られます。

f:id:aoe-tk:20171009000635p:plain

ディレクトリサイズを見ると 84.9MB と JDK8 の場合に比べて半分程度になっていることが分かります。確かに Jigsaw の効果が出ていますね!

f:id:aoe-tk:20171009000656p:plain

インストールイメージの中身について

なお、インストールイメージの中身を調べてみると、こちらも興味深いものがありました。 アプリケーションや JRE の JAR が見当たらない のです! runtime ディレクトリの下を覗いてみると、何やら modules というそれっぽいファイルがあります。

f:id:aoe-tk:20171009000713p:plain

このファイル、JAR か JMOD ファイルかと思いきや、特に ZIP 圧縮されていません。バイナリエディタで覗いてみると時々 CAFEBABE が登場しており、単にクラスファイルを 1 つのファイルにまとめたもののように見受けられます。色々調べてみましたが、このファイルが何であるかの解説を見つけられませんでした。誰か知っている人いますか?

(2017/10/10) 追記

id:MATSUZAKI 様よりコメントで情報を頂きました。jimage 形式のファイルであるとのことです。jimage については Java Magazine の Vol.25 に説明がありました。

jimage形式は、モジュール化されたランタイムに必要なクラスやリソースを管理するコンテナの形式です。 jimageファイルは、従来のようなzipベースの圧縮ではなく、クラスやリソースを高速に検索できるようにインデックスが付けられています。jimageのコンテンツ領域には、そのイメージのすべてのクラスとリソースが含まれており、位置情報にひも付けて管理されています。

JDK には jimage というコマンドがあったので (なお、現時点での JDK9 のドキュメントにはこのコマンドについての説明は見当たらず...) 、このコマンドで中身を閲覧してみました。

>jimage list modules
jimage: modules

Module: aoetk.sample.packager
    META-INF/MANIFEST.MF
    aoetk/sample/SampleApp.class
    module-info.class

Module: java.base
    META-INF/services/java.nio.file.spi.FileSystemProvider
    com/sun/crypto/provider/AESCipher$AES128_CBC_NoPadding.class
    com/sun/crypto/provider/AESCipher$AES128_CFB_NoPadding.class
    com/sun/crypto/provider/AESCipher$AES128_ECB_NoPadding.class
    ...

確かにクラスファイルやリソースファイルが含められていますね。

まとめ

ということで、Project Jigsaw の恩恵で、Java SE 9 からはアプリケーションのインストールイメージをより絞って配布が可能であることが分かりました。OracleOracle Java SEサポート・ロードマップ において、Java アプリケーションの配布は (予め配布先に Java をインストールさせるのではなく) JRE も一緒にバンドルした自己完結型パッケージングでの配布を推奨しています。jlink と javapackager を最大限に活用してきましょう。

Bash on Windows上のファイルをうっかりWindows側でいじってしまったときの対処

Windows10 になってから Linux サブシステムが搭載され、Windows 上で Bash on Ubuntu が動かせるようになりました。Windows 上で仮想環境ではない生の Linux 環境と Bash シェルが使えるようになってとっても嬉しいですね。
この Bash on Ubuntu on Windows を利用する上での注意点として Linux サブシステム上のファイルを Windows 側のアプリからいじってはいけない というものがあります。Linux 側のファイルのメタデータ情報は NTFS の拡張属性に保存していますが、Windows 側のアプリケーションでこのファイルを操作すると拡張属性に入っている情報を消してしまう可能性があるためです。
ですが、先日実際にそれをやらかしてしまい、ファイルシステムがおかしくなってしまって焦りました。何とか復帰できたので備忘録的にまとめておきました。

まず Bash on Ubuntu 上でファイルを作ってみます。 f:id:aoe-tk:20170921235906p:plain

このファイルをうっかり Windows 側のツールで開いて修正、保存しちゃいます。 f:id:aoe-tk:20170921235924p:plain f:id:aoe-tk:20170921235943p:plain

これをもう一度 Bash 側で見てみます。 f:id:aoe-tk:20170922000003p:plain

あれ、タイムスタンプもファイルサイズも変わっていない (ファイルが消えてしまう場合もあります) 。 Ubuntu 上の Vim で開いてみると Windows 側で追加した文字列が反映されていません。 f:id:aoe-tk:20170922000123p:plain

しかも書き換えようとしても保存に失敗します! f:id:aoe-tk:20170922000142p:plain

じゃあファイルを消して作り直そうと思ったら、ファイルを消したはずなのに「入力/出力エラーです」と出て、消したはずのファイルを作ることもできません! f:id:aoe-tk:20170922000206p:plain

実際にこの現象が起きたときはかなり焦りました。以下に対処方を示していきますね。

Windows の Explorer でファイルの状態を確認します。ただし、Linux サブシステムのファイルは「保護されたオペレーティングシステムファイル」に当たるため、デフォルトでは Explorer から見えないようになっています。Explorer の設定を変更し、「保護されたオペレーティングシステムファイルを表示しない」のチェックを外します。 f:id:aoe-tk:20170922000234p:plain

Linux サブシステムのファイルシステム%LOCALAPPDATA%\lxss 以下にあります。 f:id:aoe-tk:20170922000249p:plain

Explorer で問題となったファイルのあるパスを確認すると、消したはずのファイルが残っていることが分かります。これを Explorer 上で削除します。 f:id:aoe-tk:20170922000304p:plain

これで再び Ubuntu 上でファイルを作成できるようになります。やれやれ。 f:id:aoe-tk:20170922000334p:plain

復旧したら Explorer の設定は元に戻しておきましょう。またうっかり触ってしまわないように。

とまあこんな感じで直すことができました。このように色々おかしなことになるので、Windows 側のアプリから Linux サブシステム上のファイルをいじってしまわないように注意しましょう (逆は問題ありません) 。もうちょっと何とかならないかなあ。

getterやsetter以外のメソッドをJavaBeansのアクセッサにする

ネット上で、Java への dis として「なんで Java のプロパティアクセッサはわざわざ get/set で始まる名前のメソッドにしないといけないんだ!」というのをよく見かけます。ですが実は JavaBeans の仕様としてはいわゆる getter/setter でないメソッドもプロパティのアクセッサにすることができます。余りにも JavaBeans のことがボロクソに言われるのでかっとなって書きましたw

まずは以下の Java クラスを見てください。

/**
 * getter/setterを使わないJavaBeansの例.
 */
public class Test {
    private String わーい;

    public String すごーい() {
        return わーい;
    }

    public void たーのしー(String myProperty) {
        this.わーい = myProperty;
    }

    @Override
    public String toString() {
        return "Test{" +
                "わーい='" + わーい + '\'' +
                '}';
    }
}

いわゆる getter/setter がありませんね。このクラスを次のように Introspector に掛けてプロパティを抽出し、実行してみます。

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.lang.reflect.InvocationTargetException;

/**
 * Beanの利用例.
 */
public class JavaBeansSampleApp {
    public static void main(String[] args) {
        try {
            Test test = new Test();
            BeanInfo testBeanInfo = Introspector.getBeanInfo(test.getClass());
            PropertyDescriptor[] propertyDescriptors = testBeanInfo.getPropertyDescriptors();
            for (PropertyDescriptor pd : propertyDescriptors) {
                if ("わーい".equals(pd.getName())) {
                    System.out.println("わーいプロパティのsetter: " + pd.getWriteMethod());
                    System.out.println("わーいプロパティのgetter: " + pd.getReadMethod());
                    pd.getWriteMethod().invoke(test, "なにこれー?");
                }
            }
            System.out.println(test);
        } catch (IntrospectionException | InvocationTargetException | IllegalAccessException e) {
            e.printStackTrace();
        }
    }
}

結果は次のようになります。

わーいプロパティのsetter: public void aoetk.sample.Test.たーのしー(java.lang.String)
わーいプロパティのgetter: public java.lang.String aoetk.sample.Test.すごーい()
Test{わーい='なにこれー?'}

set で始まっていない たーのしー() メソッドが setter として認識され、get で始まっていない すごーい() メソッドが getter として認識されています。プロパティの writeMethod を実行するとちゃんと たーのしー() メソッドが実行されて値が設定されています。

からくりについて解説しましょう。次のように Bean に対応した BeanInfo 実装クラスを用意し、Bean と同じパッケージに置きます。以下の例では BeanInfo インターフェースのデフォルトメソッドを実装した SimpleBeanInfo クラスを継承し、必要なメソッドだけ実装しています。

/**
 * {@link Test}のJavaBeans情報.
 */
public class TestBeanInfo extends SimpleBeanInfo {
    @Override
    public PropertyDescriptor[] getPropertyDescriptors() {
        try {
            return new PropertyDescriptor[]{
                    new PropertyDescriptor("わーい", Test.class, "すごーい", "たーのしー")
            };
        } catch (IntrospectionException e) {
            return null;
        }
    }
}

ここでは getPropertyDescriptors() メソッドをオーバーライドし、 PropertyDescriptorインスタンスを返しています。もうお分かりですね。ここでこの JavaBean のプロパティの情報 (プロパティ名、アクセッサメソッド) を返しているわけです。

Introspector の API ドキュメント には次のような解説が記載されています。

Fooクラスについては、情報の問い合わせ時にnull以外の値を提供するFooBeanInfoクラスがあれば、明示的な情報を取得できます。まず、ターゲットのBeanクラスの完全指定されたパッケージ名に「BeanInfo」を付加して新規のクラス名とし、BeanInfoクラスを検索します。これに失敗した場合は、この完全指定されたパッケージ名の最後のクラス名にあたる部分を使って、BeanInfoパッケージ検索パスに指定されたパッケージごとに該当クラスを検索します。
(中略)
クラスの明示的なBeanInfoが見つからない場合は、低レベルのリフレクションを使ってクラスのメソッドを調べ、標準設計パターンを適用して、プロパティのアクセス用メソッド、イベント・ソース・メソッド、publicメソッドを識別します。
(以下略)

つまり、JavaBeans を作るとき、本来は対になる BeanInfo 実装クラスを用意し、Bean の情報を提供する必要があるのです。ですが、毎回それをやっては面倒なので、便宜的にあの命名規則が用意されているわけです。CoC の先駆けだったわけですね (ちなみに JavaBeans は 20 世紀に誕生した仕様です) 。

この BeanInfo ですが次のような情報を提供することができます。

  • プロパティの情報
  • イベントの情報
  • メソッドの情報
  • アイコン

アイコンとかイベントって何?と思いましたか。JavaBeans は本来可搬性のあるソフトウェアコンポーネント、特に GUI コンポーネントを開発するために作られた規格です。つまり、IDE の「ポトペタツール」に追加して使えるソフトウェア部品を作るためのものなのです。BeanInfo ではそのツールに対して提供する情報を定義します。

f:id:aoe-tk:20170426222842p:plain

こうして考えると、単なる DTO として JavaBeans を使うのはオーバースペックであることも分かりますね。なぜこんなにあちこちで JavaBeans が使われるようになったのかは歴史的な経緯が色々あるのですが、まあそれはまたの機会で。

なお、このエントリでやったいたずらは業務のコードではやらないでくださいね。いたずらに混乱を招くだけです。酒の席でのネタにとどめてくださいw

全体のコードは gist にアップしてあります。

https://gist.github.com/aoetk/e5a09f67f2ebbc206d0770b21116b69e

パーフェクトJava EEの感想

Java EE 7 に対応した解説本として出版されたパーフェクト Java EE を 著者のお一人である上妻 (id:n_agetsuma) さんから頂きました。ありがとうございます! 簡単ではありますが、本についての感想をまとめました。

gihyo.jp

実は本を頂いたのはもう半年前の夏のことだったのですが、色々と忙しい状況が続き、感想文の公開がこんなに遅くなってしまいました。ごめんなさい!

本の特色

この本の特色は、Java EE の Web Profile に内容を絞ったという思い切った点にあります。説明する対象を絞ることで、これまでの和書の Java EE 解説本よりも、各仕様の解説が深くなっており、多様な機能、意外と知られていない新機能などがしっかり網羅されています。ただ、そのために jBatch が対象から抜けちゃったのはちょっと残念だったかなと思っています。

また、著作メンバーが Oracle に近くない人で構成されているので、変にバイアスが掛かっていないというのも特色かなと思っています。いわゆる「よいしょ」的な内容が少なく、かなりぶっちゃけたことも書かれているので、実際に利用する人にとっても参考になるのではないでしょうか。

良かったと思った点

この本で特によかったと思ったのは次のような点です。

  • CDI についてガッツリ解説された初めての和書です。
    • Web 上の情報でもここまできっちり網羅された解説は (少なくとも日本語では) 見たことがありません。
    • この点だけでも買う価値があると言ってもいいと思っています。
  • 最新の JPA 仕様をきっちりカバーしています。
    • 新機能である Entity Graph もしっかり押さえられています。
    • JPA を利用するうえで必要とされる解説が一通り網羅されています。
  • Web Profile の範囲で EE6、7 で新たに追加された新機能をしっかりと押さえています。
    • 特に日本語で紹介される機会が少なかった非同期 ServletServlet のモジュール化、WebSocket、JPA Criteria API もきっちり解説されています。
    • 「こんなことできるかな?」を調べるためのリファレンスとして使えると思います。
  • JTA の解説がきちんとされているのも個人的にはポイント大でした。
  • そして、先行して EE8 の MVC も取り上げられている!と言いたかったところですが...嗚呼、あんなことになるなんて...。

イマイチだなと思った点

ちょっとここは良くなかったかなあと思った点も挙げておきます。

  • 読者の対象に初めて Java EE を使って Web アプリケーションを開発しようとしている人が入っていましたが、その割には開発環境構築の説明が不親切だと思いました。
    • やはり、IDE とセットでの環境構築の解説はすべきだと思います。
    • 記述内容も過去に古い J2EE での開発をしてきたことを前提としたところもありました。
  • 章立てが良くないと感じました。
    • いきなり CDI から入るのは違和感がありました。
    • 実際に作って試しながら進めてもらうには、やはりフロントから入っていくのが王道だと思います。
  • JSP は省略すべきではなかった!
    • 一応最後に付録で説明されていますが、ちょっとおざなりすぎかなと。その割には本文中で JSP を使っている箇所も多いですし。
    • ここは異論がある人も多いと思いますが、私は JSP はまだまだ重要だと思っています。JSP はまだまだ利用されている箇所が多く、情報を必要としている人は多くいるはずです。
    • JSP もバージョンアップごとに地味に機能追加がされています。そのような情報をカバーしていると喜ぶ人も多いんじゃないかなと思いました。

この本を読むべき人

以上、感じたことをつらつらと書きましたが、中々の良書だと思っています。特に次のような人にお勧めしたいですね。

  • これまで Java EE (J2EE) で開発してきたけど、最新の仕様にキャッチアップできていないなあと感じている人。
  • 現在 Java EE (J2EE) ベースのシステムを開発、保守しており、リファレンスを求めている人。
  • Spring Framework ベースのシステムを開発している人も読むべきです。Spring も何だかんだ言って EE を利用しており (特に ServletJPA) 、役に立つ記述は多いです。

Java 9で強化されたデスクトップ環境関連のAPIをJavaFXで使ってみる

このエントリは JavaFX Advent Calendar 2016 の 18 日目のエントリです。前日は id:nodamushi さんによる「 JavaFX9からPlatformに追加されるAPIについて 」でした。

はじめに

今回のエントリは JDK9 に新たに取り込まれる "JEP 272: Platform-Specific Desktop Features" を JavaFX から触ってみるというものです。

実は JDK9 では AWT/Swing/Java2D にかなり手が入ります。次のように多くの JEP が 9 には取り込まれます。

利用者に見えない内部的な強化や HiDPI 対応のような流石に対応しないとまずいものが中心ですが、その中でも JEP 272 はアプリケーション開発者側から見ても大きな機能追加になります。

まずはこの JEP 272 について紹介し、そしてこれが JavaFX からどの程度利用できるか調べてみた結果について述べていきたいと思います。

JEP 272 について

JEP 272 は "Platform-Specific Desktop Features" の名の通り、デスクトップ環境特有の機能を Java からも利用できるようにするというものです。それもプラットフォーム固有の機能も積極的に利用するというものです。

ご存じの通り Java のデスクトップ GUI アプリケーションでは、そのアプリケーションを実行するデスクトップ環境が提供している機能を利用することは中々難しかったりします。クロスプラットフォームアプリケーションの宿命かも知れません。

ですが、過去に Java 6 でそのための API が追加されたことがありました。

  • 特定のファイル・タイプに関連付けられたデフォルト・アプリケーションと対話する機能を提供する java.awt.Desktop クラス。
    • デスクトップ環境で設定されたデフォルトブラウザやエディタ、メーラなどを Java 側から起動することができます。
  • デスクトップ環境のシステムトレイにアクセスし、アプリケーション独自のトレイアイコン、メニューを追加できる java.awt.SystemTray クラス。

システムトレイを使ったりすると、よりネイティブアプリケーションっぽくなりますよね。ですが、その後の各 OS の進化に伴い、Java から使えないデスクトップ環境の機能がどんどん増えてきました。

例えば、Windows のタスクバー、Mac の Dock では次のようにプログレスバーを表示することができるようになっています。

f:id:aoe-tk:20161218003523p:plain:w1000 f:id:aoe-tk:20161218003543p:plain:w1000

バッジも表示できるようになっていますね。

f:id:aoe-tk:20161218003757p:plainf:id:aoe-tk:20161218003806p:plain:h94

でもこれらの機能には Java からアクセスすることができません。

また、Mac 特有の話ですが、Mac で実行するアプリケーションでは、メニューバーにアプリケーション名のメニューが通常のメニューの左に表示されます。アプリケーションの About ダイアログや環境設定ダイアログはここから開けるようにするよう Mac 上で動くアプリケーションは統一されています。でも、Mac 特有の話なので、Java アプリケーションはここにアクセスすることができません。

f:id:aoe-tk:20161218004452p:plain:w250

JEP 272 はこういったデスクトップ環境が提供する機能へ Java アプリケーションでもアクセスできるようにするものなのです。

提供されている機能を列挙します。

  • デスクトップ環境が起こすイベント (スリープ、サインアウト、フォアグラウンド/バックグラウンドの切り替えなど) に対してイベントリスナやイベントハンドラを登録する。
  • タスクバーへのアクセス。プログレス表示やバッジ表示、コンテキストメニューの追加を行える。
  • Mac 特有のアプリケーションメニューへのアクセス。

API レベルでは次のような追加になります。

  • java.awt.desktop パッケージの追加
    • デスクトップ環境で発生するイベントに対応した各種イベントクラスや、それに対するイベントリスナ、ハンドラが定義されている。
  • java.awt.Desktop クラスにメソッド追加
    • デスクストップ環境で発生する各種イベントに対して、イベントリスナの追加、ハンドラの登録を行うメソッドが追加されている。
  • java.awt.Taskbar クラスの新規追加
    • タスクバー (Mac での Dock) を操作するためのメソッドが定義されている。

この APIAWT の API です。でもこのエントリは JavaFX Advent Calendar のエントリです! なので、JavaFX からこれら機能を利用できるかを調べてみることにしましょう。

JavaFX から JEP 272 を利用する

それでは JavaFX から試してみることにします。ソースコードの全体は gist にアップしています。

https://gist.github.com/aoetk/7d5cc13e64239d1233e6dd879fed682e

Desktop クラスの利用

java.awt.Desktop クラスを使うと、デスクトップ環境で発生する様々なイベントに対して応答することができるようになります。まずはこのクラスのインスタンスを取得してみます。

if (Desktop.isDesktopSupported()) {
    Desktop desktop = Desktop.getDesktop();
    addAppEvents(desktop);
    setSystemMenuHandler(desktop);
} else {
    System.out.println("デスクトップはサポートされていません.");
}

実行している環境が Desktop クラスをサポートしているかを確認するメソッドがあるので、どの環境でも動かせるよう、必ずこれでチェックするようにしましょう。static メソッドである getDesktop() メソッドを使ってインスタンスを取得します。

デスクトップ環境で発生するイベントに対して応答できるようにしてみます。イベントが発生するとその旨を ListView に表示するようにします。スクリーンのスリープを例に取ると次のようになります。

// (中略)
@FXML
ListView<String> displayList;

private ObservableList<String> eventList = FXCollections.observableArrayList();
// (中略)

private void addAppEvents(Desktop desktop) {
    // (中略)
    if (desktop.isSupported(Desktop.Action.APP_EVENT_SCREEN_SLEEP)) {
        desktop.addAppEventListener(new ScreenSleepListener() {
            @Override
            public void screenAboutToSleep(ScreenSleepEvent screenSleepEvent) {
                addMessage("画面がスリープしようとしています.");
            }

            @Override
            public void screenAwoke(ScreenSleepEvent screenSleepEvent) {
                addMessage("画面がスリープから復帰しました.");
            }
        });
    } else {
        System.out.println("ScreenSleepEventはサポートされていません.");
    }
    // (中略)
}

// (中略)
private void addMessage(String msg) {
    Platform.runLater(() -> eventList.add(msg));
}

Desktop#addAppEventListner() メソッドを使って各種イベントリスナを登録します。画面スリープに対応するイベントは ScreenSleepEvent になります。イベント別にサポート有無をチェック可能なので、チェックするようにしましょう。

JavaFX からの利用に当たって注意点があります。それはイベントリスナの処理は AWT のイベントディスパッチスレッドで実行されるということです。このスレッドは JavaFX のアプリケーションスレッドとは別スレッドです。従って、 Platform.runLater() メソッドに処理をくるむ必要があります (addMessage() メソッドの実装に注目) 。

この他にも AppForegroundEvent (フォアグランド/バックグラウンドの変化に反応するイベント) 、 AppHiddenEvent (Mac 特有の「アプリケーションを隠す」に反応するイベント) 、SystemSleepEvent (システムのスリープに反応するイベント) 、 UserSessionEvent (ログインユーザのスイッチに反応するイベント) に対するリスナをセットして実行してみました。

Mac では全てのイベントに対応しています。アプリケーション起動後、「アプリケーションを隠して復帰 -> スクリーンをスリープして復帰 -> システムをスリープして復帰 -> ユーザスイッチをして復帰」を行った結果を示します。

f:id:aoe-tk:20161218020017p:plain:w532

バックグラウンドに回ったことを検知できるのはいいですね。そのタイミングで処理を停止してリソースの消費を防いだりするようなことができますね。

Windows 環境では残念ながら SystemSleepEventUserSessionEvent にしかサポートしていませんでした。システムをスリープ、復帰させた結果を示します。

f:id:aoe-tk:20161218020602p:plain:w301

Mac のアプリケーションメニューの利用

次に Mac のアプリケーションメニューにアクセスしています。これも Desktop クラスに対してハンドラを登録する形でカスタマイズします。まずは About メニューから。

if (desktop.isSupported(Desktop.Action.APP_ABOUT)) {
    // 現時点ではJavaFXでは何も起きない
    desktop.setAboutHandler(aboutEvent -> {
        Alert alert = new Alert(Alert.AlertType.INFORMATION);
        alert.setContentText("オリジナルのAboutダイアログです.");
        alert.setHeaderText("設定");
        alert.show();
    });
} else {
    System.out.println("Aboutメニューはサポートされていません.");
}

Desktop#setAboutHandler() メソッドを使い、About メニューがクリックされた時の応答処理を登録します。オリジナルのダイアログを出そうとしたのですが...JavaFX ではそもそも About メニューの追加がされませんでした。( ;∀;)

ちなみに Swing アプリケーションで試したときはうまく動作しました。

次に設定メニューです。こちらは Desktop#setPreferencesHandler() メソッドを使い、設定メニューがクリックされた時の応答処理を登録します。

if (desktop.isSupported(Desktop.Action.APP_PREFERENCES)) {
    // 現時点ではJavaFXではエラーが起きる
    desktop.setPreferencesHandler(preferencesEvent -> {
        Alert alert = new Alert(Alert.AlertType.INFORMATION);
        alert.setContentText("オリジナルの設定ダイアログです.");
        alert.setHeaderText("設定");
        alert.show();
    });
}

ですが、このコードを実行すると Cocoa 側から次のエラーメッセージが返ってきました...。

2016-12-17 21:21:08.978 java[1903:815004] *** -[__NSArrayM insertObject:atIndex:]: object cannot be nil
2016-12-17 21:21:08.984 java[1903:815004] (
    0   CoreFoundation                      0x00007fff88a2c452 __exceptionPreprocess + 178
    1   libobjc.A.dylib                     0x00007fff89405f7e objc_exception_throw + 48
    2   CoreFoundation                      0x00007fff88942a40 checkForCloseTag + 0
    3   AppKit                              0x00007fff8e19f8ce -[NSMenu insertItem:atIndex:] + 521
    4   libawt_lwawt.dylib                  0x00000001353c156f addMenuItem + 174
    5   libawt_lwawt.dylib                  0x00000001353c13f0 -[ApplicationDelegate _updatePreferencesMenu:enabled:] + 195
    6   JavaNativeFoundation                0x000000013536fd60 +[JNFRunLoop _performCopiedBlock:] + 17
(以下略)
)

AWT のフレームが生成されていることを前提に処理を進めているように推測されます。これはちょっと残念。なお、Swing で試したときはちゃんと動作しました。

というわけで、結論としては JavaFX では現状 Mac のアプリケーションメニューの利用はできないということになります。

Taskbar の利用

次にタスクバー (Dock) の利用を試してみることにします。バッジの表示とプログレス表示を試してみます。

if (Taskbar.isTaskbarSupported()) {
    Taskbar taskbar = Taskbar.getTaskbar();
    if (taskbar.isSupported(Taskbar.Feature.ICON_BADGE_NUMBER)) {
        taskbar.setIconBadge("10");
    } else {
        System.out.println("タスクバーのアイコンバッジへの数値登録はサポートされていません.");
    }
    addAction(taskbar);
} else {
    System.out.println("タスクバーはサポートされていません.");
}

タスクバーへのアクセスは Taskbar クラスを通して行います。インスタンスは static メソッドである Taskbar.getTaskbar() メソッドを使って取得します。

アイコンバッジのセットには Taskbar#setIconBadge() メソッドを使います。Mac 環境で試すと次のようにバッジの表示に成功しました!

f:id:aoe-tk:20161218174009p:plain:w423

Windows 環境は Taskbar クラスの利用そのものは可能だったものの、アイコンバッジの設定はサポートされていませんでした。まあ、Windows でのタスクバーアイコンへのバッジ登録が可能になったのは Anniversary Update からですしね。

次にプログレス表示を試してみます。次のように、JavaFX アプリ側のプログレス表示と、タスクバー側のプログレス表示を同時に行うようにしてみました。 AnimationTimer を使って表示しています。

AnimationTimer timer = new AnimationTimer() {
    private long startTime;

    @Override
    public void handle(long currentTime) {
        long elapsedTime = currentTime - startTime;
        if (elapsedTime > PROGRESS_TIME) {
            stop();
            progress.setProgress(1.0);
            startButton.setDisable(false);
        } else {
            double rateForProgressBar = Long.valueOf(elapsedTime).doubleValue() / PROGRESS_TIME;
            progress.setProgress(rateForProgressBar);
            int rateForTaskBar = (int) (rateForProgressBar * 100);
            if (taskbar.isSupported(Taskbar.Feature.PROGRESS_VALUE)) {
                taskbar.setProgressValue(rateForTaskBar);
            }
        }
    }

    @Override
    public void start() {
        startTime = System.nanoTime();
        progress.setProgress(0);
        if (taskbar.isSupported(Taskbar.Feature.PROGRESS_VALUE)) {
            taskbar.setProgressValue(0);
        } else {
            System.out.println("タスクバーのプログレス表示はサポートされていません.");
        }
        super.start();
    }
};

タスクバーのプログレス表示は Taskbar#setProgressValue() メソッドを使います。0 から 100 の間の数値をセットします。JavaFXプログレスバーは 0 から 1 と違うんですよね...。

Mac で試したところ、次のように Dock 側に進捗を表示できるようになりました!

f:id:aoe-tk:20161218175830g:plain

Windows は残念ながら未サポートでした。何でや、Windows7 の時からサポートしてたのに!

まとめ

というわけで JEP 272 の機能を JavaFX で試してみました。分かったことをまとめると次の通りです。

  • JavaFX からも JEP 272 の機能は一部を除き利用可能。
    • ただし、イベントリスナの処理は AWT のスレッドで実行される点に注意。
  • Mac のアプリケーションメニューにアクセスする機能は JavaFX からは使えない。
  • フル機能が使えるのは Mac だけ。Windows では一部の機能しかサポートしていない。
    • Linux では Ubuntu の Unity 環境でのみ使えるようです。今回は時間がなくて試せませんでした。

Mac から優先して実装されているのは理由があります。かつて Mac 環境向けの JavaApple 自身が開発、提供しており、こういた Mac 環境特有の機能にアクセスするために EAWT という API を提供していました。

Java 7 以降、Mac 向けの Java は OpenJDK で開発されるようになりますが、この EAWT は 7、8 にはバンドルが続いていました。ですが 9 からは提供をやめることになり、代替機能を Java 側で用意することになりました。それがこの JEP 272 であるということです。

ともあれ、よりネイティブアプリケーションっぽく振る舞えるような機能が実装されたのは嬉しいことです。今後のアップデートで Mac 以外の環境向けにも実装が進んでいくことでしょう。JavaFX 向けには 10 から同等機能の実装が予定されていますが、前倒しで実装とかされないかなあ。 *1

明日は誕生日枠の id:yumix_h さんの予定です。

*1:8 の時も u40 でかなりの機能追加が入ったことがあったので