ScalaFX是如何让OpenJDK 9+实际运行的魔法?

11
环境
  • OpenJDK 64位服务器虚拟机Zulu12.2+3-CA(构建12.0.1+12,混合模式,共享)
  • Scala 2.12.7
  • Windows 10专业版,X86_64
  • IntelliJ IDEA 2019.1.3(旗舰版)

我从GitHub检出了scalafx-hello-world,在IntelliJ中构建并运行它,一切都很好。下面是重要的应用程序实现:

package hello

import scalafx.application.JFXApp
import scalafx.application.JFXApp.PrimaryStage
import scalafx.geometry.Insets
import scalafx.scene.Scene
import scalafx.scene.effect.DropShadow
import scalafx.scene.layout.HBox
import scalafx.scene.paint.Color._
import scalafx.scene.paint._
import scalafx.scene.text.Text

object ScalaFXHelloWorld extends JFXApp {

  stage = new PrimaryStage {
    //    initStyle(StageStyle.Unified)
    title = "ScalaFX Hello World"
    scene = new Scene {
      fill = Color.rgb(38, 38, 38)
      content = new HBox {
        padding = Insets(50, 80, 50, 80)
        children = Seq(
          new Text {
            text = "Scala"
            style = "-fx-font: normal bold 100pt sans-serif"
            fill = new LinearGradient(
              endX = 0,
              stops = Stops(Red, DarkRed))
          },
          new Text {
            text = "FX"
            style = "-fx-font: italic bold 100pt sans-serif"
            fill = new LinearGradient(
              endX = 0,
              stops = Stops(White, DarkGray)
            )
            effect = new DropShadow {
              color = DarkGray
              radius = 15
              spread = 0.25
            }
          }
        )
      }
    }

  }
}

编辑:我的 build.sbt 文件:

// Name of the project
name := "ScalaFX Hello World"

// Project version
version := "11-R16"

// Version of Scala used by the project
scalaVersion := "2.12.7"

// Add dependency on ScalaFX library
libraryDependencies += "org.scalafx" %% "scalafx" % "11-R16"
resolvers += Resolver.sonatypeRepo("snapshots")

scalacOptions ++= Seq("-unchecked", "-deprecation", "-Xcheckinit", "-encoding", "utf8", "-feature")

// Fork a new JVM for 'run' and 'test:run', to avoid JavaFX double initialization problems
fork := true

// Determine OS version of JavaFX binaries
lazy val osName = System.getProperty("os.name") match {
  case n if n.startsWith("Linux") => "linux"
  case n if n.startsWith("Mac") => "mac"
  case n if n.startsWith("Windows") => "win"
  case _ => throw new Exception("Unknown platform!")
}

// Add JavaFX dependencies
lazy val javaFXModules = Seq("base", "controls", "fxml", "graphics", "media", "swing", "web")
libraryDependencies ++= javaFXModules.map( m=>
  "org.openjfx" % s"javafx-$m" % "11" classifier osName
)

之后,我将实现更改为:

package hello

import javafx.application.Application
import javafx.scene.Scene
import javafx.scene.control.Label
import javafx.stage.Stage

class ScalaFXHelloWorld extends Application {
  override def start(stage: Stage): Unit = {
    stage.setTitle("Does it work?")
    stage.setScene(new Scene(
      new Label("It works!")
    ))
    stage.show()
  }
}

object ScalaFXHelloWorld {
  def main(args: Array[String]): Unit = {
    Application.launch(classOf[ScalaFXHelloWorld], args: _*)
  }
}

这里出现了以下错误:

Exception in Application start method
java.lang.reflect.InvocationTargetException
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:567)
    at javafx.graphics/com.sun.javafx.application.LauncherImpl.launchApplicationWithArgs(LauncherImpl.java:464)
    at javafx.graphics/com.sun.javafx.application.LauncherImpl.launchApplication(LauncherImpl.java:363)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:567)
    at java.base/sun.launcher.LauncherHelper$FXHelper.main(LauncherHelper.java:1051)
Caused by: java.lang.RuntimeException: Exception in Application start method
    at javafx.graphics/com.sun.javafx.application.LauncherImpl.launchApplication1(LauncherImpl.java:900)
    at javafx.graphics/com.sun.javafx.application.LauncherImpl.lambda$launchApplication$2(LauncherImpl.java:195)
    at java.base/java.lang.Thread.run(Thread.java:835)
Caused by: java.lang.IllegalAccessError: superclass access check failed: class com.sun.javafx.scene.control.ControlHelper (in unnamed module @0x40ac0fa0) cannot access class com.sun.javafx.scene.layout.RegionHelper (in module javafx.graphics) because module javafx.graphics does not export com.sun.javafx.scene.layout to unnamed module @0x40ac0fa0
    at java.base/java.lang.ClassLoader.defineClass1(Native Method)
    at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1016)
    at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:151)
    at java.base/jdk.internal.loader.BuiltinClassLoader.defineClass(BuiltinClassLoader.java:802)
    at java.base/jdk.internal.loader.BuiltinClassLoader.findClassOnClassPathOrNull(BuiltinClassLoader.java:700)
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:623)
    at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
    at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
    at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:521)
    at javafx.scene.control.Control.<clinit>(Control.java:86)
    at hello.ScalaFXHelloWorld.start(ScalaFXHelloWorld.scala:39)
    at javafx.graphics/com.sun.javafx.application.LauncherImpl.lambda$launchApplication1$9(LauncherImpl.java:846)
    at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runAndWait$12(PlatformImpl.java:455)
    at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$10(PlatformImpl.java:428)
    at java.base/java.security.AccessController.doPrivileged(AccessController.java:389)
    at javafx.graphics/com.sun.javafx.application.PlatformImpl.lambda$runLater$11(PlatformImpl.java:427)
    at javafx.graphics/com.sun.glass.ui.InvokeLaterDispatcher$Future.run(InvokeLaterDispatcher.java:96)
    at javafx.graphics/com.sun.glass.ui.win.WinApplication._runLoop(Native Method)
    at javafx.graphics/com.sun.glass.ui.win.WinApplication.lambda$runLoop$3(WinApplication.java:174)
    ... 1 more
Exception running application hello.ScalaFXHelloWorld

现在我的问题是:ScalaFX有什么优点可以避免模块问题?


据我所知,你的代码看起来很好。你能发布你的_SBT_构建文件(或等效文件)的内容吗? - Mike Allen
我需要进行测试。 与此同时,只有一些观察:(1)尝试将scalaVersion设置为“2.12.8”或“2.13.0”,以防万一,因为与_Java_ 9+的兼容性仍在发展中,并且较新的_Scala_版本可能更好; (2)我注意到您正在使用_ZuluFX_,其中包括_OpenJFX_ 12,但您还下载了_OpenJFX_ 11模块作为依赖库 - 尝试注释掉build.sbt中的libraryDependencies语句。 - Mike Allen
@MikeAllen,我之前提供了错误的信息:实际上是Azul的Zulu 12(我在原帖中已经编辑了我的环境)。 - Hannes
@MikeAllen 如果您需要,我可以为两个版本提供完整的IDEA项目,并告诉我如何和在哪里提供。我的做法是:我克隆了ScalaFX示例,尝试了一下(没有任何修改),它可以工作。然后我只修改了我在这里发布的单个文件,没有修改其他任何内容。这就是为什么我不明白发生了什么事情。 - Hannes
让我们在聊天中继续这个讨论 - Mike Allen
显示剩余4条评论
3个回答

5

补充Jonathan Crosmer的回答:

将类名和对象名命名不同之所以有效,是因为Java启动器实际上在主类扩展javafx.application.Application时具有特殊行为。如果您有Java源代码可用,则相关代码可以在JAVA_HOME/lib/src.zip/java.base/sun/launcher/LauncherHelper.java中找到。特别是有两个方法是有意义的:

public static Class<?> checkAndLoadMain(boolean, int ,String)

//In nested class FXHelper
private static void setFXLaunchParameters(String, int)

第一种方法有一个检查,看主类是否扩展了javafx.application.Application。如果是这样,该方法将用嵌套类FXHelper替换主类,后者有自己的public static void main(String[] args)
第二种方法直接由第一种方法调用,尝试加载JavaFX运行时。但是,它的做法是通过首先通过java.lang.ModuleLayer.boot().findModule(JAVAFX_GRAPHICS_MODULE_NAME)加载模块javafx.graphics。如果此调用失败,Java将抱怨找不到JavaFX运行时,然后立即通过System.exit(1)退出。
回到SBT和Scala,还有一些细节需要注意。首先,如果主对象和扩展javafx.application.Application的类具有相同的名称,则Scala编译器将生成一个类文件,该文件既扩展Application又具有public static void main(...)。这意味着上述特殊行为将被触发,Java启动器将尝试将JavaFX运行时作为模块加载。由于SBT目前没有关于模块的概念,因此JavaFX运行时不会在模块路径上,并且对findModule(...)的调用将失败。

另一方面,如果主对象与主类具有不同的名称,则Scala编译器将在不扩展Application的类中放置public static void main(...),这反过来意味着main()方法将正常执行。

在我们继续之前,我们应该注意,虽然SBT没有将JavaFX运行时放在模块路径上,但它确实将其放在了类路径上。这意味着JavaFX类对JVM可见,只是无法作为模块加载。毕竟

一个模块化的JAR文件在所有可能的方面都与普通的JAR文件相同,只是它还在其根目录中包含一个module-info.class文件。
(来自模块系统的现状
然而,如果一个方法碰巧调用了比如说Application.launch(...),Java将很高兴地从类路径加载javafx.application.ApplicationApplication.launch(...)同样可以访问JavaFX的其他部分,一切都能正常工作。
这也是不分叉运行JavaFX应用程序的原因。在这种情况下,SBT总是直接调用public static void main(...),这意味着不会触发Java启动器的特殊行为,并且JavaFX运行时将在类路径上被找到。
这里有一个片段,可以看到上述行为的实际效果:

Main.scala:

object Main {
  def main(args: Array[String]): Unit = {
    /*
    Try to load the JavaFX runtime as a module. This is what happens if the main class extends
    javafx.application.Application.
     */
    val foundModule = ModuleLayer.boot().findModule("javafx.graphics").isPresent
    println("ModuleLayer.boot().findModule(\"javafx.graphics\").isPresent = " + foundModule) // false

    /*
    Try to load javafx.application.Application directly, bypassing the module system. This is what happens if you
    call Application.launch(...)
     */
    var foundClass = false
    try{
      Class.forName("javafx.application.Application")
      foundClass = true
    }catch {
      case e: ClassNotFoundException => foundClass = false
    }
    println("Class.forName(\"javafx.application.Application\") = " + foundClass) //true
  }
}

build.sbt:

name := "JavaFXLoadTest"

version := "0.1"

scalaVersion := "2.13.2"

libraryDependencies += "org.openjfx" % "javafx-controls" % "14"

fork := true

这里有一些有趣的侦探工作,但让我们明确一件事:_SBT_不会从源代码中生成任何内容——它只是一个构建系统。是_Scala_编译器生成代码。我会进一步研究这个问题... - Mike Allen
恭喜你找出了问题所在!讽刺的是罪魁祸首被命名为 FXHelper :) - Jonathan Crosmer
希望有一天Java和Scala能够合作得足够好,以至于构建和部署JavaFX应用程序不再那么痛苦!这些都是很棒的技术,一旦它们运行起来,但肯定还有一些令人困惑的问题需要克服才能起步。 - Jonathan Crosmer
1
请注意,这是纯Java环境下的讨论:http://mail.openjdk.java.net/pipermail/openjfx-dev/2018-June/021977.html - Jarek

4
我无法完全重现你的问题,但我已经成功创建和运行了一个仅使用JavaFX(即不使用ScalaFX)的项目。以下是我使用的内容(其他所有内容都在构建文件中指定): (我尝试使用Zulu OpenJDK 12来构建和运行该项目,这也可以。然而,最好使用与JDK匹配的OpenJFX版本。)
当我尝试使用你的原始源代码和build.sbt时,在命令行上执行sbt run命令时遇到了以下错误:
D:\src\javafx11>sbt run
[info] Loading global plugins from {my home directory}\.sbt\1.0\plugins
[info] Loading project definition from D:\src\javafx11\project
[info] Loading settings for project javafx11 from build.sbt ...
[info] Set current project to JavaFX 11 Hello World (in build file:/D:/src/javafx11/)
[info] Running (fork) hello.ScalaFXHelloWorld
[error] Error: JavaFX runtime components are missing, and are required to run this application
[error] Nonzero exit code returned from runner: 1
[error] (Compile / run) Nonzero exit code returned from runner: 1
[error] Total time: 1 s, completed Aug 11, 2019, 3:17:07 PM

正如我在原始评论中提到的那样。
我认为这很奇怪,因为代码已经编译了,这意味着编译器能够很好地找到JavaFX运行时。
然后,我尝试在构建文件中注释掉fork := true,而不使用forking运行程序。你猜怎么着?程序可以正常运行,没有错误!

JavaFX application running

关于在使用JDK版本9+时,使用SBT可能会遗漏一些内容,但这表明SBT未能正确运行分叉进程。我可以通过在构建文件末尾添加以下内容来强制分叉进程正确运行:

val fs = File.separator
val fxRoot = s"${sys.props("user.home")}${fs}.ivy2${fs}cache${fs}org.openjfx${fs}javafx-"
val fxPaths = javaFXModules.map {m =>
  s"$fxRoot$m${fs}jars${fs}javafx-$m-11-$osName.jar"
}
javaOptions ++= Seq(
  "--module-path", fxPaths.mkString(";"),
  "--add-modules", "ALL-MODULE-PATH"
)

这是通过将下载的ivy管理的JavaFX jar文件添加到Java的模块路径来实现的。然而,这不适用于运行独立应用程序的情况。可能sbt-native-packager可以为已完成的应用程序提供必要的环境来运行,但我还没有尝试过。
我在GitHub上发布了完整的解决方案。
让我知道这是否有所帮助。同时,我将研究SBTJDK 9+模块的支持,看看是否有更简单的解决方案... 更新: 我已经SBT团队提出了问题(#4941),以便更详细地研究此问题。 更新2: 我修复了一个阻止该解决方案在Linux上工作的问题。执行git pull以更新源代码。

更新3

我还应该提到,最好使用IntelliJ通过SBT运行应用程序,这样可以保持简单并确保应用程序的环境已正确配置。

要做到这一点,请转到IntelliJ Run菜单,并选择Edit Configurations...选项。单击对话框左上角的+按钮,在**Add New Configuration下的列表中选择sbt Task,然后进行以下配置:

Adding an SBT run configuration

这将首先编译和构建应用程序(如果需要)。
注意:_VM参数是用于运行SBT的,与SBT如何运行您的分叉应用程序无关。
(您还可以添加SBT运行配置来测试您的代码。)

非常感谢您的努力!只剩下一个问题:是否有一种方法可以在IntelliJ内调试应用程序?调试模式似乎不使用sbt,因此无法启动应用程序... - Hannes
@Hannes 是的,你可以调试一个运行配置。在_IntelliJ_的Run菜单中,前两个选项分别是运行和调试上一次执行的运行配置。你可以通过Run菜单的**Debug...**选项选择特定的运行配置进行调试,这样你就可以选择要调试的运行配置了。如果你有任何进一步的问题,请告诉我... - Mike Allen
我看到当我点击调试图标时应用程序已经启动,但是由于某种原因它不会在断点处停止。你尝试过这个吗? - Hannes
@Hannes 是的。测试代码中有断点吗?如果是这样,您将不得不调试运行您的测试的运行配置。 - Mike Allen
不,它们在“HelloWorld.scala”的第20行,例如。 - Hannes
显示剩余4条评论

3

我遇到了完全相同的问题,并找到了一个异常奇怪且简单的解决方案。 tldr; 将主类命名为与JavaFX应用程序类不同的名称。以下是一个示例:

import javafx.application.Application
import javafx.event.ActionEvent
import javafx.event.EventHandler
import javafx.scene.Scene
import javafx.scene.control.Button
import javafx.scene.layout.StackPane
import javafx.stage.Stage

object HelloWorld {
  def main(args: Array[String]): Unit = {
    Application.launch(classOf[HelloWorld], args: _*)
  }
}

// Note: Application class name must be different than main class name to avoid JavaFX path initialization problems!  Try renaming HelloWorld -> HelloWorld2
class HelloWorld extends Application {
  override def start(primaryStage: Stage): Unit = {
    primaryStage.setTitle("Hello World!")
    val btn = new Button
    btn.setText("Say 'Hello World'")
    btn.setOnAction(new EventHandler[ActionEvent]() {
      override def handle(event: ActionEvent): Unit = {
        System.out.println("Hello World!")
      }
    })
    val root = new StackPane
    root.getChildren.add(btn)
    primaryStage.setScene(new Scene(root, 300, 250))
    primaryStage.show()
  }
}

以上代码会抛出原始问题中的异常。如果我将类HelloWorld重命名为HelloWorld2(保留对象HelloWorld,并将启动调用更改为classOf [HelloWorld2]),则可以正常运行。我怀疑这也是ScalaFX起作用的“魔力”,因为它在其自己的JFXApp类型中包装了JavaFX应用程序,创建了一个隐藏的Application类。
为什么有效?我不完全确定,但是当使用标准的Run配置(右键单击HelloWorld并“运行HelloWorld.main()”)在IntelliJ中运行每个代码片段时,然后在输出中单击“/home/jonathan/.jdks/openjdk-14.0.1/bin/java ...”来展开它,显示一个命令,其中包括“--add-modules javafx.base,javafx.graphics”,以及其他内容。在第二个版本中,使用重命名的HelloWorld2应用程序时,该命令不包括此内容。我无法想象IntelliJ如何决定使命令不同,但我只能猜测它有一些推断它是JavaFX应用程序并尝试通过自动添加“--add-modules”来提供帮助的东西...?无论如何,模块列表并不包括所需的所有模块,因此例如创建按钮需要“javafx.controls”,您会收到错误。但是当主类与应用程序名称不匹配时,任何魔法推断都会被关闭,并且build.sbt中的标准类路径就可以正常工作。
有趣的后续:如果我使用“sbt run”从sbt shell运行应用程序,则模式相同(HelloWorld失败,但重命名应用程序类可以解决问题),但错误消息更加直观但仍然无法帮助:“错误:缺少JavaFX运行时组件,并且需要运行此应用程序”。所以也许不完全是IntelliJ的问题,而与JavaFX和Jigsaw有关?无论如何,这是一个谜,但至少我们有一个简单的解决方案。

我可能找到了为什么这个有效的原因。请查看我的答案。 - Delphi1024
这很奇怪,因为伴生object的名称与其关联的class始终不同。_Scala_编译器会将object的名称HelloWorld装饰为类型HelloWorld$(即附加$),但类仍然是HelloWorld,因此它们应该始终在_Java all_中显示不同。我会进一步研究这个问题... - Mike Allen
Scala编译器将始终在HelloWorld类中放置对象方法的static版本,该版本仅向HelloWorld$转发调用。这使它们可以在Java代码中使用,而无需编写带有$的标识符。Scala之旅将其称为静态转发。这意味着,如果您有一个与主对象配对的伴生类,即使方法的实际内容位于带有$的类文件中,它也将具有public static void main(...) - Delphi1024

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接