日曜プログラミング

休日趣味でやってるプログラミング関連記事をダラダラと

Clojure + JavaFX で無理やり REPL

タイトルは元々前から何とかできないかとちょいちょい調べてたのだが、先日以下のブログの記事を見つけて Scala REPL でできるならきっと Clojure だってできるはずだー、と無意味なライバル心が芽生えて改めてがっつり調べてみたくなったのが発端w
Scala REPL で無理矢理JavaFX : 人生、気合いと具合 - blog

Clojure で REPL をオミットしない GUI ライブラリとなると Swing ベースなら seesaw 一択なんだけども、まあ世に出てからあまり期間が経ってないからなのか JavaFX ベースのものはなかなかお目にかかれない。発展途上のものだと upshot 初めいくつか見つかるんだが、そのどれも更新が途絶えてる。そもそも JavaFX 自体がそんなに魅力的じゃないのか・・・?と言う疑問は強引にどこかへ押しやりそれならば自分で何とかしてみるかと実験してみる。

ちなみに upshot だと REPL 上での開発可なのだが、これもソースを読むと Swing 埋め込み用の JFXPanel のインスタンス化時に暗に JavaFX ランタイム初期化を行っているのを利用しているらしく、それって JavaFX Application Thread で走らないのでは・・・?とか思い(未検証)、今回の実験では何とか JavaFX Application Thread は走らせる方向でアプローチしてみることにした。

調べた事

ここは箇条書き的に調査経緯のような事を書くだけに留める。リンクやソースまじえてちゃんと書くと結構な手間になってメンドいので。また、ここは最終的にどうしたと言うのに直接つながるものでもないので無視して飛ばしてもらっても構いません。

  • JavaFX にも Swing EDT と似たような役割の Java FX Application Thread*1と呼ばれるイベント処理やら画面更新を行うスレッドがある
  • Java Application Thread を起動するトリガーは Application.launch メソッド。
  • メインウィンドウに相当するのが Stage でそれはちょくちょく Primary Stage と呼ばれる。
  • Primary Stage を表示するコードは Application を extends したクラスの start メソッド内で引数で渡されるため、このフレームワーク(JSR-296とか言うのらしい)に従う場合他で参照する事はできない。
  • Prymary Stage は JavaFX Application Thread or それが立ち上がってる Java プロセスで 1 つのみと推奨されてる(API doc 読むだけだとできなくはないような事は書いてる)
  • Primary Stage が引数として実際に渡されるのはいつよと言うのはこれまた Application.launch を呼んだ時
  • さらに Application.launch が使えるのも 1 プロセスで一度きり。JavaFX 内部的にどうも launch 使ったかどうかのフラグっぽいものを管理してて、REPL で一度画面出して閉じた後に再度呼んでも例外になる。ググると Application.launch は 2 回呼ぶ事ができないと言う記事をちょくちょく見かけるのもこの為。
  • Application.launch なんか無視して Platform.runLater でいいじゃないと言う方法もなくはないが Application.launch のソース眺めてると Preloader やら他にも結構色々やっててこれをまた自前でやるのもアホらしいし、またそれら前処理を無視するとしても GUI 更新用コードにいちいち runLater かますというのも何かと面倒。
  • 先にも少し書いたが upshot の JFXPanel コンストラクタ呼ぶ事によるトリックもありっちゃありだが、この方法の場合 JavaFX Application Thread になるの?と言う疑問が残る
    • 加えて JavaFX でわざわざ Swing EDT とは別の JavaFX Application Thread を用意したのもどうやらハードウェアアクセラレーションでの描画をさせる為と言うのが一つあってその恩恵がなくなるかもしれないのもなんだかなと
  • 触発された ScalaFX で無理やり REPL というブログ記事とおそらく同様の promise, deliver を利用した Primary Stage を取得してる Clojure ライブラリも見かけたが、イマイチ良く分からないものを使うのもどうなんだろうと(あくまで自分がね)

こうして見ると Java Application Thread を走らせたまま如何に Primary Stage(Scene) を他から参照できるようにするかに腐心してますな。

で最終的にどうしたのか

普通に Application を extends した Java クラスを用意するが、そのフィールドに Stage, Scene 型の static フィールドを宣言して start 時にそいつを頂いて保持しちゃいましょう、と言うだけ。

以降中身をまじえて手順を書いていくが、前提として leiningen と emacs + cider 環境構築は済んでいるものとしている事に注意。

leiningen プロジェクト作成

lein new app の後にてきとーなプロジェクト名で。ここでは手元で試した wrap-jfx とする。

project.clj に依存関係と javac コンパイルパス追加

こないな感じで。

(defproject wrap-jfx "0.1.0"
  :description "FIXME: write description"
  :url "http://example.com/FIXME"
  :license {:name "Eclipse Public License"
            :url "http://www.eclipse.org/legal/epl-v10.html"}
  :dependencies [[org.clojure/clojure "1.5.1"]
                 [local.oracle/javafxrt "2.2.45"]]
  :java-source-paths ["src"]
  :aot [wrap-jfx.core]
  :main wrap-jfx.core
  :target-path "target/%s"
  :profiles {:uberjar {:aot :all}})

項目の最初で軽く Java クラスを用意すると書いたが、lein javac できるようにする為に :java-source-path を指定する。ここでは *.clj と同じ場所に置く事にするので ["src"] を指定した。leiningen ではコンパイル順は :aot 指定した名前空間Clojure ソースよりも前に Java ソースをコンパイルするようになっており、repl 起動時もそれは適用されるのでこれで問題なし。

javafx.application.Application を extends する Java ソースを用意

以下のコードを src\wrap_jfx\Primary.java として保存する。

package wrap_jfx;

import javafx.application.Application;
import javafx.stage.Stage;
import javafx.scene.Scene;
import javafx.scene.Group;

public class Primary extends Application {

  public static Stage primaryStage;  // ← ここと、
  public static Scene primaryScene;  // ← ここと、

  public void start(Stage stage) {
    primaryStage = stage;            // ← ここ
    primaryScene = new Scene(new Group());
    primaryStage.setScene(primaryScene);
    primaryStage.show();
  }
}

基本、白地のメインウィンドウを起動・表示するための最低限の骨組みだけ。ポイントはコメントの部分で通常 Application#start メソッド内でしか扱えないトップレベルの Stage(Primary Stage とか良く呼んでる)を static field に保持して公開しちゃう。ついでに最初に入れる Scene も(あんまそう呼ぶのは聞かないが) Primary Scene と言う扱いとして同じく sttic field に入れちゃう。

コマンドプロンプトの lein repl でウィンドウ表示

emacs+cider repl でなくあくまでコマンドプロンプト上で起動する事に注意。プロジェクトフォルダ以下でこんな感じでコマンドを打つ。

wrap-jfx>lein repl
Compiling 1 source files to C:\wrap-jfx\target\repl\classes
(起動メッセージ省略)

wrap-jfx.core=> (import javafx.application.Application)
javafx.application.Application
wrap-jfx.core=> (Application/launch Primary nil)

画面の左上はこんな感じ。ほんとに白地で何もないので左上だけ。
f:id:shinmuro:20140129221746p:plain

コマンドプロンプトでやる事は以上。REPL もウィンドウもそのままにして放置する。つかコマンド・プロンプト側ではウィンドウ閉じるまで何もできない。まあここまでは正直 ClojureJava のみで書いた時と大して変わりゃしない。

emacs からコマンドプロンプトで起動した nREPL ポートに繋ぐ

プロジェクトの core.clj を開いて C-c M-c で既に立ち上がってるコマンドプロンプト側の nREPL に繋ぐ。ミニバッファで IP とポートを聞かれるが大抵探してくれるのかデフォルトで表示され Enter 2 回叩くだけで良いはず。ウィンドウ起動したコマンドプロンプトでは何もできないが、意外にも emacs から別セッションで繋いだ REPL はふつーに触れる。多分意味ないけどこの時の core.clj の中身は ns で例の Java クラスを import してるだけ。

(ns wrap-jfx.core
  (:import wrap_jfx.Primary))

Primary Stage にタイトル入れてみる

さて、emacs 側で立ち上げた repl で C-n M-n なり直接 in-ns するなりした後以下を実行してみる。

(.setTitle Primary/primaryStage "Java App Thread 外から失礼")

するとこんな感じでタイトル変わったw

f:id:shinmuro:20140129221757p:plain

これって試してないけど実は seesaw でもできるのかな?JavaFX 関連の資料読んでると UI 絡みの表示更新は大概 JavaFX Application Thread 上でやってねとあるのでぶっちゃけ邪道な気がしてるのだが、実際これでゴリゴリやれるのかもう少し遊んでみたいと思う。

また、static フィールドに持たせる事によるリスクについてあるのかどうかから含めて考慮できてないので Java にも詳しい方は指摘してもらえると助かります。

*1:あんま略するの見た事ないけど JFXAT とか誰か略してくれんかなあ