torutkのブログ

ソフトウェア・エンジニアのブログ

「RxJavaリアクティブプログラミング」を読む会 第5回で、RxJavaのハンズオン?を実施しました #javareading

昨日8月26日のJava読書会「RxJavaリアクティブプログラミングを読む会 第5回」は午前中で本を読了し、午後は、RxJavaを用いたサンプルプログラムを作成するハンズオンを実施しました。

RxJavaリアクティブプログラミング (CodeZine BOOKS)

RxJavaリアクティブプログラミング (CodeZine BOOKS)

ハンズオンのお題

今回の書籍は、割とAPIの解説羅列気味で、サンプルコードもAPI解説のためのサンプルで、具体的な利用イメージが分かりずらいものでした。そこで、ハンズオンとしては具体的な利用イメージを想定しました。

Duke Pizza社は、最近ドローンを使ったピザの配達を始めました。
ピザの配達の品質を把握し改善するために、ドローンの位置と速度をリアルタイムに把握すること、ドローンの配達時間を記録することとなりました。
ドローンには、自己位置等の情報を無線で送信する機能があり、これを受信して、地図上に位置をプロットし、また速度を表示することとします。
配達が完了すると、配達に要した時間を算出します。

作って動かし楽しむことを基本とするため、地図上にシンボルを表示させ、逐次移動するGUIプログラムを題材としました。短時間で作って動かすため、コード量が少なく済むJavaFXGUIを作成します。地図も、リアルなものではなく画像とし、位置も緯度経度やメートル等の実距離は使わず画面座標を扱い、極力単純化しています。

プログラムはPC単独で実行可能とします。実際にはドローンなどはないので、ドローンの位置情報は、プログラム内で擬似生成します。

位置表示と速度計算と2つの処理があるお題を用意しました。これにより、Subscriberを複数設けるためFlowableをホットとするサンプルとなります。

速度の計算には、位置が2つとその間の時間が必要になります。これにより、なんらかの演算が必要になります。例えばscanを使うなど。ハンズオンでは別なアプローチをしている例がありました(2つのFlowableを生成し、zipを使う)。

実習環境の準備

当日は、1名を除きノートPCを持参していただいたので、1〜2人に1台の環境となりました。
皆さんJavaプログラミング環境は搭載済みでした。ただ、開発環境はバラバラで、またGUI開発(JavaFX)経験はない人が多数でした。

統合開発環境の比率

6台中、3台がIDEIntelliJ IDEA(Android Studio含む)、2台がeclipse、1台がNetBeansという比率でした。

自分ではずっとNetBeansを使っているので、JavaFXが極めて簡単に扱えました。しかし、他の環境(特にeclipse)はJavaFXを扱うのが厳しいものがあるというなかなか気づけない問題を認識できました。

eclipseJavaFX不調問題

ハンズオン開始後、JavaFXを使うコードを記述し始めて発生しました。eclipseは、Java SE 8をターゲットとしても、JavaFXAPIであるクラスを認識してくれず、クラス名・import文の補完がされません。また、javafxで始まるパッケージはeclipse独自のコンパイルエラーとして扱われてしまいます。

解決策は、プラグインのe(fx)clipseを入れることです。が、インターネット接続が必須となるため、事前に用意しておかないとハンズオンの場で追加するのは厳しいところです。今回は、eclipseからNetBeansへ変更したケースと、手持ちのスマートホンを使ってテザリングでe(fx)clipseを入れたケースで対応しました。

IntelliJ IDEAはScene Builderを内蔵

ハンズオン開始後、IntelliJ IDEAを使っている人が、Scene BuilderをIntelliJ IDEA内で使っていました。プラグイン?と思いましたが、標準で搭載する機能のようです。

プログラム構成

ハンズオンのプログラム構成は次となります。


src
+- tracker
+- DroneTrackerApp.java
+- DroneTrackerView.fxml
+- DroneTrackerViewController.java
+- DroneTrackerViewModel.java
+- map.png

  • DroneTrackerAppクラスは、JavaFXのApplicationクラスを継承し、実行のエントリとなるクラスです。
  • DroneTrackerView.fxmlは、画面レイアウトを記述するJavaFXのFXMLファイルです。
  • DroneTrackerViewControllerクラスは、上述のFXMLと対になるJavaコードで画面の制御をします。RxJavaのSubscriber(データの消費者)を記述するクラスとなります。
  • DroneTrackerViewModelクラスは、RxJavaのPublisher(データの生産者)を記述するクラスとなります。今回はドローンの位置を乱数で生成します。
  • map.pngは、地図画像ファイルです。今回は距離・精度等は考慮せず見た目だけなので、画像は何でも構いません。
DroneTrackerApp.java
package dronetracker;

import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class DroneTrackerApp extends Application {
    @Override
    public void start(Stage stage) throws Exception {
        Parent root = FXMLLoader.load(getClass().getResource("DroneTrackerView.fxml"));
        Scene scene = new Scene(root);
        stage.setScene(scene);
        stage.show();
    }
}

FXMLで定義した画面レイアウトをロードして表示するJavaFXのApplication継承クラスの最小限のコードです。

DroneTrackerView.fxml

Scene Builderツールを使って作成しました。トップレベルのコンテナはAnchorPaneとし、そこにCanvas、Button、Labelを貼ります。Canvasは地図画像とドローンの位置を表すシンボルを描画します。Buttonは実行開始、Labelは速度表示に使います。

DroneTrackerViewController.java

抜粋して説明します。

public class DroneTrackerViewController implements Initializable {
    @FXML
    private Canvas mapCanvas;
    @FXML
    private Label velocityLabel;
    
    private Image mapImage;
    private ObjectProperty<Point2D> positionProperty = new SimpleObjectProperty<>();
    private DroneTrackerViewModel model = new DroneTrackerViewModel();

    @FXML
    public void startMapping(ActionEvent event) {
        :(中略)
    }

    @Override
    public void initialize(URL url, ResourceBundle rb) {
        mapImage = new Image(getClass().getResourceAsStream("map.png"));
        positionProperty.addListener((obs, ov, nv) -> drawMap());
        positionProperty.set(Point2D.ZERO);
    }

    private void drawMap() {
        :(中略)
    }
}

FXMLで記述した画面レイアウトで配置した画像・シンボル描画用のCanvas、速度表示用のLabelをインジェクションするフィールドを@FXMLアノテーション付きで定義します。フィールド名はFXML側でfx:idに記述します。

FXMLで配置したButtonを押したときに実行するメソッドを@FXMLアノテーション付きで定義します。メソッド名はFXML側でonActionに記述します。

initializeメソッドは、FXMLLoaderでFXMLをロードしたときに実行されます。

位置を画像上で表示更新するためにJavaFXのプロパティを使っています。位置はPoint2D型を使用し、そのプロパティはObjectPropertyとなります。位置が更新されると、このプロパティに新しい位置をセットすることで、画像・シンボル描画をするメソッドdrawMapを呼び出すようにリスナー登録しています。

drawMapメソッドは次のようになります。

    private void drawMap() {
        GraphicsContext gc = mapCanvas.getGraphicsContext2D();
        gc.drawImage(mapImage, 0, 0);
        drawDrone(gc, positionProperty.get().getX(), positionProperty.get().getY());
    }
    
    private void drawDrone(GraphicsContext gc, double x, double y) {
        gc.setFill(Color.BLUE);
        gc.fillOval(x, y, 16, 16);
    }

Canvasに背景の画像を描画し、ドローンの位置に円を塗りつぶし描画しています。

開始ボタンを押したときに、2つのSubscriberを登録します。

    @FXML
    public void startMapping(ActionEvent event) {
        Flowable<Point2D> publisher = model.getPublisher().publish().autoConnect(2);
        publisher.subscribe(positionProperty::set, System.err::println);
        publisher.scan(new Pair<Point2D, Point2D>(Point2D.ZERO, Point2D.ZERO),
                (pair, point) -> new Pair<Point2D, Point2D>(pair.getValue(), point)
        ).map(this::calcVelocity).subscribe(d -> {
            Platform.runLater(() -> velocityLabel.setText(String.valueOf(d)));
        });
    }

複数のSubscriberを使用するので、Flowableをホットにする必要があり、publishを呼んでいます。
1つ目のSubscriberは、位置の更新用です。onNextでは、位置を保持するプロパティに新しい位置をセットします。このプロパティには先にリスナーを登録しているので、位置が更新されると先のdrawMapを実行します。

2つ目のSubscriberは、scanを使って一つ前の位置と今の位置をPairに詰め、mapでPairから速度(double)に変換し、それをラベルに設定します。

速度の計算は次のメソッドです。

    private double calcVelocity(Pair<Point2D, Point2D> pair) {
        double velocity = pair.getKey().distance(pair.getValue()) / 1.0;
        return velocity;
    }
DroneTrackerViewModel.java
public class DroneTrackerViewModel {
    private Flowable<Point2D> dronePositionPublisher;
    private Point2D prePoint = Point2D.ZERO;
    private Random random = new Random();
    
    public DroneTrackerViewModel() {
        this(1L, TimeUnit.SECONDS);
    }
            
    public DroneTrackerViewModel(long time, TimeUnit unit) {
        dronePositionPublisher = Flowable.interval(time, unit)
                .map(i -> generatePosition());
    }

    public Flowable<Point2D> getPublisher() {
        return dronePositionPublisher;
    }
            
    private Point2D generatePosition() {
        double deltaX = random.nextGaussian() + 1;
        double deltaY = random.nextGaussian() + 1;
        Point2D point = prePoint.add(deltaX, deltaY);
        prePoint = point;
        return point;
    }
}

RxJavaのデータ生産者を作成します。
一定間隔でデータを生成するのに、intervalを使います。
intervalは、指定した周期で0, 1, 2, ...と通知するので、mapで位置を生成します。

位置の生成は、初期値(0,0)とし、以降XとYの増分値を1を平均とした正規分布の乱数で生成し、現在の位置に増分を加えて新しい位置としています。