ScalaFXでGUIアプリケーションを作ってみる

「JavaFXでGUIアプリケーションを作ってみる」「JavaFX+FXMLでGUIアプリケーションを作ってみる」では、JavaFXを使って簡単なGUIアプリケーションを作った。 ここでは、JVMベースの言語であるScalaとScalaFXライブラリを用いて、同様のアプリケーションを作ってみる。

環境

Windows 8.1 Pro 64 bit版、JDK 8、Eclipse Mars 2

>systeminfo
OS 名:                  Microsoft Windows 8.1 Pro
OS バージョン:          6.3.9600 N/A ビルド 9600
OS ビルドの種類:        Multiprocessor Free
システムの種類:         x64-based PC
プロセッサ:             1 プロセッサインストール済みです。
                        [01]: Intel64 Family 6 Model 69 Stepping 1 GenuineIntel ~1596 Mhz

ScalaおよびScalaFXの概要

Scalaは、Javaバイトコードコンパイルするタイプのプログラミング言語である。 Scalaの特長として型推論、第一級関数およびlambda式のサポート、パターンマッチが挙げられ、Javaコードと同じ意味を持つコードをより簡潔に書くことができる。

// Java
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> squares = numbers.stream().map(x -> x * x).collect(Collectors.toList());
// Scala
val numbers = 1 to 5
val squares = numbers.map(x => x * x)

ScalaFXは、JavaFXScalaから扱うためのライブラリである。 Scalaから直接JavaFXを扱うことも可能だが、ScalaFXを用いることでより自然な形でコードを書くことができる。

Scalaのインストール

まず、Scalaをインストールする。

ダウンロードページからWindowsMSIインストーラをダウンロードし、実行する。 ここで、PATH環境変数への追加はインストーラが行ってくれる。

REPLを起動し、簡単なコードを動かしてみる。

>scala
Welcome to Scala 2.11.8 (Java HotSpot(TM) 64-Bit Server VM, Java 1.8.0_91).
Type in expressions for evaluation. Or try :help.

scala> println("Hello, Scala!")
Hello, Scala!

scala> for (x <- 1 to 25 if x*x > 50) yield 2*x
res1: scala.collection.immutable.IndexedSeq[Int] = Vector(16, 18, 20, 22, 24, 26
, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50)

二つ目のfor文は、Pythonにおけるリスト内包表記に相当するコードである。

sbtのインストール

次に、Scalaにおけるビルドツールsbtをインストールする。

ダウンロードページからWindowsMSIインストーラをダウンロードし、実行する。 Scala同様に、PATH環境変数への追加はインストーラが行ってくれる。

次のようなHelloWorld.scalaファイルを作り、ビルド実行を行ってみる。 なお、初回起動時は必要なライブラリのインストールが行われるため、しばらく時間がかかる。

object HelloWorld {
  def main(args: Array[String]) = println("Hello, Scala!")
}
>sbt
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=256m; sup
port was removed in 8.0
Getting org.fusesource.jansi jansi 1.11 ...
downloading https://repo1.maven.org/maven2/org/fusesource/jansi/jansi/1.11/jansi
-1.11.jar ...
        [SUCCESSFUL ] org.fusesource.jansi#jansi;1.11!jansi.jar (1188ms)
:: retrieving :: org.scala-sbt#boot-jansi
        confs: [default]
        1 artifacts copied, 0 already retrieved (111kB/31ms)
Getting org.scala-sbt sbt 0.13.11 ...
(snip)
> run
[info] Updating {file:/C:/Users/user/tmp/}tmp...
[info] Resolving org.fusesource.jansi#jansi;1.4 ...
[info] Done updating.
[info] Compiling 1 Scala source to C:\Users\user\tmp\target\scala-2.10\classes..
.
[info] 'compiler-interface' not yet compiled for Scala 2.10.6. Compiling...
[info]   Compilation completed in 12.718 s
[info] Running HelloWorld
Hello, Scala!
[success] Total time: 24 s, completed 2016/04/28 9:14:54
>[Ctrl+D]

ScalaFXのダウンロード

次に、ScalaFXをダウンロードする。

Quick-start guideを参照し、sbtを利用してJARファイルをダウンロードする。 具体的には、次のようなbuild.sbtファイルを用意し、上のHelloWorld.scalaをsbtから実行することで、依存ライブラリとしてJARファイルのダウンロードが行われる。

scalaVersion := "2.11.8"

libraryDependencies ++= Seq(
  "org.scalafx" %% "scalafx" % "8.0.92-R10"
)
>sbt
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=256m; sup
port was removed in 8.0
[info] Set current project to tmp (in build file:/C:/Users/user/tmp/)
> run
[info] Updating {file:/C:/Users/user/tmp/}tmp...
[info] Resolving org.fusesource.jansi#jansi;1.4 ...
[info] downloading https://repo1.maven.org/maven2/org/scalafx/scalafx_2.10/8.0.9
2-R10/scalafx_2.10-8.0.92-R10.jar ...
[info]  [SUCCESSFUL ] org.scalafx#scalafx_2.10;8.0.92-R10!scalafx_2.10.jar (9627
ms)
[info] Done updating.
[info] Running HelloWorld
Hello, Scala!
[success] Total time: 13 s, completed 2016/04/28 9:16:30
>[Ctrl+D]

今回の環境では、JARファイルはC:\Users\%USERNAME%\.ivy2\cache\org.scalafx\scalafx_2.11\jars\scalafx_2.11-8.0.92-R10.jarに保存された。

EclipseプラグインScala IDE for Eclipse)のインストール

Eclipseを用いてScalaコードを書くために、Scala用のEclipseプラグインをインストールする。

まず、Eclipseを起動し、「Help」→「Install new software」を開く。 ダウンロードページに書いてあるUpdate siteのURLを指定し、「Scala IDE for Eclipse」を選択、インストールする。 ダイアログの問いに合わせて再起動するとScala IDE向けに設定を変更するように問われるが、ここでは変更せずそのままの状態にした。

ScalaFXでGUIアプリケーションを作ってみる

「Java+SwingでGUIアプリケーションを作ってみる」と同様に、ドラッグアンドドロップされたファイルのSHA-1ハッシュ値をテーブルに表示するアプリケーションを書いてみる。

Eclipseを起動した後、ツールバーから「New」→「Other」を開き、「Scala Wizards」→「Scala Project」を選択する。 プロジェクト名を「SHA1CalculatorSFX」としてNextを押し、出てくる画面の「Libraries」→「Add External JARs」から上でダウンロードしたScalaFXのJARファイルを依存ライブラリに加えてFinishを押す。 ここで、Scala perspectiveへの切り替えを問われるので、Yesを選択する。

続けて、ツールバーの「New」→「Scala Class」から「main.scala.SHA1CalculatorSFX」クラスを作成する。 なお、パッケージ名を「main.scala」にするのは、後述するJARファイルの作成時にsbtがこれを標準で参照するためである。

SHA1CalculatorSFX.scalaを開き、実際にコードを書くと次のようになる。

package main.scala

// enables conversions by .asScala and .asJava
// http://docs.scala-lang.org/overviews/collections/conversions-between-java-and-scala-collections
import collection.JavaConverters._
// use JavaFX controls that aren't wrapped by ScalaFX
// http://www.scalafx.org/docs/faq_Using_unwrapped_JavaFX_components/
import scalafx.Includes._

import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.security.MessageDigest

import javafx.{concurrent => jfxc}
import scalafx.application.JFXApp
import scalafx.application.JFXApp.PrimaryStage
import scalafx.beans.property.StringProperty
import scalafx.collections.ObservableBuffer
import scalafx.concurrent.Task
import scalafx.scene.Scene
import scalafx.scene.control.TableColumn
import scalafx.scene.control.TableView
import scalafx.scene.control.cell.TextFieldTableCell
import scalafx.scene.input.DragEvent
import scalafx.scene.input.TransferMode
import scalafx.scene.layout.BorderPane


class FileInfo(path_ : String, sha1sum_ : String) {
  val path = new StringProperty(this, "path", path_)
  val sha1sum = new StringProperty(this, "sha1sum", sha1sum_)
}

object SHA1CalculatorSFX extends JFXApp {

  private val pane = new BorderPane()

  stage = new PrimaryStage {
    scene = new Scene(600, 400) {
      title = "SHA-1 Calculator SFX"
      root = pane
    }
  }

  configureTable(pane)

  private def configureTable(root: BorderPane) = {
    val data = ObservableBuffer[FileInfo]()
    val table = createTableView(data)
    table.columnResizePolicy = TableView.ConstrainedResizePolicy
    root.center = table
  }

  private def createTableView(data: ObservableBuffer[FileInfo]) = {
    new TableView[FileInfo] {
      editable = true
      columns ++= List(
        new TableColumn[FileInfo, String] {
          text = "File Path"
          cellValueFactory = { _.value.path }
          cellFactory = TextFieldTableCell.forTableColumn[FileInfo]()
        }.delegate,
        new TableColumn[FileInfo, String] {
          text = "sha1sum"
          cellValueFactory = { _.value.sha1sum }
          cellFactory = TextFieldTableCell.forTableColumn[FileInfo]()
        }.delegate
      )
      items = data

      onDragOver = (event: DragEvent) => {
        val db = event.getDragboard()
        if (db.hasFiles()) {
          event.acceptTransferModes(TransferMode.COPY)
        } else {
          event.consume()
        }
      }

      onDragDropped = (event: DragEvent) => {
        val db = event.getDragboard()
        var success = false
        if (db.hasFiles()) {
          success = true
          for (file <- db.getFiles().asScala) {
            val fileinfo = new FileInfo(file.getAbsolutePath(), "")
            data.add(fileinfo)
            val task = new SHA1CalculationTask(fileinfo)
            fileinfo.sha1sum <== task.message
            new Thread(task).start()
          }
        }
        event.setDropCompleted(success)
        event.consume()
      }
    }
  }
}

class SHA1CalculationTask(fileinfo: FileInfo) extends Task(new jfxc.Task[Unit] {
  protected def call() = {
    val md = MessageDigest.getInstance("SHA-1")

    // update digest with each 8MB chunk
    val path = fileinfo.path.value
    val istream = new FileInputStream(path)
    val totalBytes = (new File(path)).length()
    var currentBytes = 0L
    try {
      val buf = new Array[Byte](8*1024*1024)
      Iterator.continually(istream.read(buf, 0, buf.length))
        .takeWhile(_ != -1)
        .foreach { n =>
          md.update(buf.slice(0, n))
          currentBytes += n
          val message = "calculating %d%%...".format(100 * currentBytes / totalBytes)
          updateMessage(message)
        }
    } catch {
      case e: IOException => e.printStackTrace()
    } finally {
      istream.close()
    }

    val hexdigest = md.digest().map("%02x".format(_)).mkString
    updateMessage(hexdigest)
  }
})

最初の二つのimport文は、JavaクラスとScalaクラスの変換を行う上で重要なため、常にimportしておくとよい。 Scalaにおいて、valは再代入できない変数(const変数)、varは再代入可能な変数の宣言を意味する。 また、Scalaでは行末のセミコロンを省略することができ、一般に書かないことが多い。

「JavaFXでGUIアプリケーションを作ってみる」のコードと比較すると、次のような箇所でコードがより簡潔に書けることがわかる。

// Java
byte[] digest = md.digest();
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
    sb.append(String.format("%02x", b));
}
// Scala
val hexdigest = md.digest().map("%02x".format(_)).mkString

また、ScalaFXを用いることで、JavaFXにおけるPropertyのバインディングをより直感的に書くことができる。

// Java
fileinfo.sha1sumProperty().bind(task.messageProperty());
// Scala
fileinfo.sha1sum <== task.message

なお、ScalaFXにおいてTaskクラスを使う際は、scalafx.concurrent.Taskに加えjavafx.concurrent.Taskもimportして使う必要がある。

コードを保存した後「Run」を押して実行し、適当にファイルをドラッグアンドドロップした後のスクリーンショットを次に示す。

f:id:inaz2:20160429033620p:plain

JavaFXで書いた場合と同じように動作していることが確認できる。

実行可能なJARファイルを作ってみる

Scalaで実行可能なJARファイルを作成する際は、Eclipseからではなく、sbtとsbt-assemblyプラグインを用いて行う。

まずはじめに、JAVA_HOME環境変数C:\Program Files\Java\jdk1.8.0_91として作成しておく。

次に、プロジェクトフォルダを開き、project\assembly.sbtを次のような内容で作成する。 これにより、sbt-assemblyプラグインが有効になる。

addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.3")

そして、ビルド設定ファイルbuild.sbtを次のような内容で作成する。

name := "SHA-1 Calculator SFX"

version := "1.0.0"

scalaVersion := "2.11.8"

unmanagedJars in Compile += Attributed.blank(file(System.getenv("JAVA_HOME") + "/jre/lib/ext/jfxrt.jar"))

libraryDependencies ++= Seq(
  "org.scalafx" %% "scalafx" % "8.0.92-R10"
)

fork := true

JavaFXを利用するには、unmanagedJarsにjfxrt.jarを追加する必要があることに注意。

最後に、コマンドプロンプトから次のコマンドを実行してJARファイルを作成する。

>sbt assembly
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=256m; sup
port was removed in 8.0
[info] Loading project definition from C:\Users\user\workspace\SHA1CalculatorSFX
\project
[info] Set current project to SHA-1 Calculator SFX (in build file:/C:/Users/user
/workspace/SHA1CalculatorSFX/)
[info] Updating {file:/C:/Users/user/workspace/SHA1CalculatorSFX/}sha1calculator
sfx...
[info] Resolving org.scala-sbt.ivy#ivy;2.3.0-sbt-2cc8d2761242b072cedb0a04cb39435
[info] Resolving org.fusesource.jansi#jansi;1.4 ...
[info] downloading https://repo.scala-sbt.org/scalasbt/sbt-plugin-releases/com.e
ed3si9n/sbt-assembly/scala_2.10/sbt_0.13/0.14.3/jars/sbt-assembly.jar ...
[info]  [SUCCESSFUL ] com.eed3si9n#sbt-assembly;0.14.3!sbt-assembly.jar (5117ms)

(snip)
[info] Done updating.
[info] Compiling 1 Scala source to C:\Users\user\workspace\SHA1CalculatorSFX\tar
get\scala-2.11\classes...
[warn] there was one deprecation warning; re-run with -deprecation for details
[warn] one warning found
[info] Including: scalafx_2.11-8.0.92-R10.jar
[info] Including: scala-reflect-2.11.8.jar
[info] Including: scala-library-2.11.8.jar
[info] Including: jfxrt.jar
[info] Checking every *.class/*.jar file's SHA-1.
[info] Merging files...
[warn] Merging 'com\sun\webkit\network\about' with strategy 'rename'
[warn] Merging 'META-INF\INDEX.LIST' with strategy 'discard'
[warn] Merging 'META-INF\MANIFEST.MF' with strategy 'discard'
[warn] Strategy 'discard' was applied to 2 files
[warn] Strategy 'rename' was applied to a file
[info] SHA-1: a3f61d268f0ea843866218070f9a3e97d0ce749c
[info] Packaging C:\Users\user\workspace\SHA1CalculatorSFX\target\scala-2.11\SHA
-1 Calculator SFX-assembly-1.0.0.jar ...
[info] Done packaging.
[success] Total time: 90 s, completed 2016/04/29 3:27:46

生成されたJARファイルを実行すると、上と同様のウィンドウが表示されることが確認できる。 なお、Scala用のライブラリも必要とすることから、Javaで作った場合よりファイルサイズは大きくなる。

関連リンク