Вы сейчас просматриваете Как упаковать JavaFX в exe. Часть 3: GraalVM

Как упаковать JavaFX в exe. Часть 3: GraalVM

  • Автор записи:
  • Запись изменена:31.08.2025
  • Рубрика записи:JavaFx / Java

В этот раз упакуем то же самое приложение в exe, но уже с помощью GraalVM. Этот инструмент позволяет создать нативный образ приложения со всеми зависимостями и модулями из JRE в виде байт-кода.

Hibernate в этот раз использовать не буду совсем, вместо этого сделаю заглушки слоя работы с данными. Сделаю я так по одной причине: GraalVM не переваривает javafx и hibernate в одном месте, по отдельности запросто, но вместе никак, совсем.

Поэтому для решение той же задачи — приложение, которое работает с данными — я применю архитектурный подход: разделю приложение на две независимые части, где exe c интерфейсом приложения будет обращаться к exe со слоем работы с базой данных.

В этой статье будет первый exe с интерфейсом приложения.
Версия java 21.0.8 и остальные зависимости и плагины аналогично той версии, которая будет работать с этой версией Java.

Подготовка POM.

Для начала удалим все лишние плагины и другие настройки

  1. jpackage-maven-plugin и секцию с профилями
  2. javafx-maven-plugin, поскольку запуск буду делать из IDE и сборка jlink образа не нужна
  3. maven-dependency-plugin, поскольку он собирал зависимости для jpackage, а graalvm будет делать это по-своему.
  4. maven-antrun-plugin, в нем теперь нет особой нужды, поскольку ничего кроме target не будет создаваться во время сборки, а значить чистку можно делать стандартным ‘mvn clean’.
  5. maven-jar-plugin, поскольку он не умеет создавать толстые исполняемые jar файлы, а именно это понадобится, поскольку приложение не модульное.

Остается только maven-compiler-plugin для решения проблем с lombok.

Добавляем GraalVM

  1. Установить GraalVm той же версии, на которой разрабатывается приложение.
  2. Лучшее решение использовать GraalVM для разработки, тогда путь к ней нужно указать в JAVA_HOME
  3. Тоже рабочее решение — это разрабатывать на обычной java, а собирать нативный образ с помощью GraalVM, в этом случае нужно задать переменную GRAALVM_HOME и указать в ней путь к папке с разархивированными исходниками скачанными с официального сайта GraalVM.
  4. Также не забыть обновить переменную PATH указав в ней пути до папок bin java и graalVm соответственно.
  5. Проверить так: native-image --version

Main class

Поскольку GraalVM native-image tool не любит (не умеет?) главные классы, которые наследуются от чего-то другого, например, от javafx.application.Application. При сборке образа он то не может найти главный класс приложения, то его класс родитель, даже не смотря на различные аргументы сборки.

В общем применим хитрый ход: сделаем еще один главный класс который будет вызывать уже тот другой главный класс. Получится такая точка входа.

Дополняем POM

Для удобства добавляем свойства

    <properties>
        <main.class>ru.makushimo.javafxexe.Main</main.class>
        <lombok.version>1.18.34</lombok.version>
        <javafx.lib.path>${project.build.directory}/libs</javafx.lib.path>
        <javafx.version>21.0.8</javafx.version>
    </properties>

Привычный набор зависимостей

        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-controls</artifactId>
            <version>${javafx.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjfx</groupId>
            <artifactId>javafx-fxml</artifactId>
            <version>${javafx.version}</version>
        </dependency>

        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-api</artifactId>
            <version>2.0.17</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.5.18</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <scope>compile</scope>
        </dependency>

Для сборки толстого исполняемого jar приложения использую maven-shade-plugin с настройками из документации к этому плагину.

            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.6.0</version>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                        <configuration>
                            <transformers>
                                <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                    <mainClass>${main.class}</mainClass>
                                </transformer>
                            </transformers>
                            <createDependencyReducedPom>false</createDependencyReducedPom>
                        </configuration>
                    </execution>
                </executions>
            </plugin>

Проверим, что исполняемый jar все таки собирается и внезапно еще и запускается без танцев с бубном.

mvn clean package
java -jar target/javafx-exe-1.0.0.jar

Если все сработало тогда запустится наше приложение

Теперь усложним его до вида todo-list.

Task model.

Создадим модель для отображения строки в таблице задач в интерфейсе приложения

Task Service

Для работы с данными приложения создадим сервис-заглушку. Для проверки работы интерфейса этого достаточно, а потом переделаем его для работы с внешним сервисом слоя работы с базой данных (второй exe).

Task Controller

Класс, который будет предоставлять всю закадровую логику работы интерфейса. Он будет брать и отдавать данные через task service


import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleStringProperty;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableView;
import javafx.scene.control.TextField;

public class TaskController {
    @FXML private TableView<Task> taskTable;
    @FXML private TableColumn<Task, String> nameColumn;
    @FXML private TableColumn<Task, Boolean> doneColumn;
    @FXML private TextField taskInput;

    private final TaskService taskService = new TaskService();

    private ObservableList<Task> tasks = FXCollections.observableArrayList();

    @FXML
    public void initialize() {
        nameColumn.setCellValueFactory(cellData -> new SimpleStringProperty(cellData.getValue().getName()));
        doneColumn.setCellValueFactory(cellData -> new SimpleBooleanProperty(cellData.getValue().isDone()));
        taskTable.setItems(tasks);
        loadTasks();
    }

    private void loadTasks() {
        tasks.clear();
        tasks.addAll(taskService.getAllTasks());
    }

    @FXML
    private void handleAdd() {
        String name = taskInput.getText();
        if (name.isEmpty()) {
            return;
        };

        Task task = new Task();
        task.setName(name);
        task.setDone(false);

        taskService.addTask(task);

        taskInput.clear();
        loadTasks();
    }

    @FXML
    private void handleDelete() {
        Task selected = taskTable.getSelectionModel().getSelectedItem();
        if (selected == null) {
            return;
        };

        taskService.deleteTask(selected);

        loadTasks();
    }

    @FXML
    private void handleToggle() {
        Task selected = taskTable.getSelectionModel().getSelectedItem();
        if (selected == null) {
            return;
        };

        selected.setDone(!selected.isDone());

        taskService.updateTask(selected);

        loadTasks();
    }
}

Запускаем и проверяем, что оно работает.

Теперь повторно проверяем толстый Jar

mvn clean package
java -jar target/javafx-exe-1.0.0.jar

GraalVM native-agent

По инструкции перед запуском сборки нативного образа нужно подготовить специальные конфигурационные файлы описывающие ресурсы, рефлексивные вызовы, JNI вызовы и т.к., которые помогут правильно собрать приложение в бинарный код. Все эти удобные штуки типа рефлексии или AOP вообще не работают в виде собранного бинарного exe, поэтому GraalVM все подобные ситуации сначала переделывает в виде обычного кода, а конфиги ему в этом помогают.

Обычно фреймворки типа Spring или Quarkus предоставляют такие файлы для целей сборки нативного образа, но в данный момент их в проекте нет и придется делать все вручную.

GraalVM toolset предоставляет так называемый tracing agent, это java агент, с которым нужно запустить приложение и протыкать в нем все сценарии в интерфейсе, чтобы код пробежался по всем маршрутам, а агент смог их засечь и правильно записать в конфиг файлы.

Для использования агента нужно создать в проекте следующий каталог src/main/resources/META-INF/native-agent. В него потом сложим полученные json файлы.

Добавим POM профиль для запуска приложения в режиме агента трассировки.

        <profile>
            <id>native-agent</id>
            <build>
                <plugins>

                    <plugin>
                        <groupId>org.codehaus.mojo</groupId>
                        <artifactId>exec-maven-plugin</artifactId>
                        <version>3.5.1</version>
                        <!-- Запуск приложения -->
                        <executions>
                            <execution>
                                <id>java-agent</id>
                                <goals>
                                    <goal>exec</goal>
                                </goals>
                                <phase>none</phase>
                                <configuration>
                                    <executable>java</executable>
                                    <workingDirectory>${project.build.directory}</workingDirectory>
                                    <arguments>
                                        <argument>-agentlib:native-image-agent=config-output-dir=${project.build.directory}/native-image</argument>
                                        <argument>-classpath</argument>
                                        <classpath/>
                                        <argument>${main.class}</argument>
                                    </arguments>
                                </configuration>
                            </execution>
                        </executions>
                    </plugin>

                </plugins>
            </build>
        </profile>

Затем выполним команду для запуска приложения.

mvn clean package -Pnative-agent -DskipTests exec:exec@java-agent

Она соберет толстый jar и запустит его. Затем протыкаем все сценарии и закроем приложение. в папке target/native-image появилось несколько json файлов. Скопируем их не глядя в src/main/resources/META-INF/native-image.

Далее по мере развития приложения агента нужно будет запускать опять и затем объединять полученные json файлы с уже существующими. Тогда новый функционал правильно попадет в нативный образ.

GraalVM Native image

Для сборки нативного образа добавим новый профиь с плагином native-image-plugin. При этом он будет собирать образ из толстого jar проекта. Срабатывать он будет после фазы package общей для всех профилей. Никаких специальных настроек для того, чтобы сборка начиналась именно с этого jar нет, плагин сам находит его в target по имени проекта и его версии.

<profile>
            <id>native-jar</id>
            <build>
                <plugins>
                    <!--  Скопировать зависимости в отдельную папку -->
                    <plugin>
                        <groupId>org.apache.maven.plugins</groupId>
                        <artifactId>maven-dependency-plugin</artifactId>
                        <version>3.8.1</version>
                        <executions>
                            <execution>
                                <id>copy-dlls</id>
                                <phase>prepare-package</phase>
                                <goals>
                                    <goal>copy-dependencies</goal>
                                </goals>
                                <configuration>
                                    <outputDirectory>${javafx.lib.path}</outputDirectory>
                                    <includeScope>runtime</includeScope>
                                    <includeArtifactIds>javafx-graphics,javafx-controls</includeArtifactIds>
                                    <includeGroupIds>org.openjfx</includeGroupIds>
                                    <stripVersion>true</stripVersion>
                                </configuration>
                            </execution>
                        </executions>
                    </plugin>
                    <plugin>
                        <groupId>org.graalvm.buildtools</groupId>
                        <artifactId>native-maven-plugin</artifactId>

                        <extensions>true</extensions>
                        <executions>
                            <execution>
                                <id>build-native</id>
                                <goals>
                                    <goal>compile-no-fork</goal>
                                </goals>
                                <phase>package</phase>
                            </execution>
                        </executions>
                        <configuration>
                            <mainClass>${main.class}</mainClass>
                            <imageName>${project.artifactId}</imageName>

                            <skipNativeTests>true</skipNativeTests>
                            <fallback>false</fallback>
                            <verbose>true</verbose>

                            <resourceIncludedPatterns>
                                <pattern>.*\.fxml$</pattern>
                                <pattern>.*\.css$</pattern>
                                <pattern>.*\.bss$</pattern>
                                <pattern>.*\.obj$</pattern>
                                <pattern>.*\.dll$</pattern>
                                <pattern>.*\.icu$</pattern>
                            </resourceIncludedPatterns>

                            <buildArgs>
                                <!-- путь к файлам конфигурации -->
                                <buildArg>-H:ConfigurationFileDirectories=src/main/resources/META-INF/native-image/</buildArg>
                                <!-- Выводить подробно ошибки при сборке-->
                                <buildArg>-H:+ReportExceptionStackTraces</buildArg>
                                <!-- включить протоколы -->
                                <buildArg>-H:+UnlockExperimentalVMOptions</buildArg>
                                <buildArg>--enable-http</buildArg>
                                <buildArg>--enable-https</buildArg>
                                <!-- Путь к javafx файлам зависимостей -->
                                <buildArg>-Djava.library.path=${javafx.lib.path}</buildArg>
                                <!-- Указание на то чтобы инициализировать эти модули при запуске а не при сборке-->
                                <buildArg>--initialize-at-run-time=javafx,com.sun.javafx,com.sun.glass,com.sun.prism,org.slf4j,ch.qos.logback,org.jboss.logging</buildArg>

                            </buildArgs>
                        </configuration>
                    </plugin>
                </plugins>
            </build>
        </profile>

Кроме этого также надо отредактировать resource-config.json, собранный агентом. Как оказалось агент не шибко то и умный и может поймать примерно 80% всех настроек. Остальные нужно добавлять методом проб и ошибок. Посмотреть ошибку сборки, понять ее (или нагуглить) и внести недостающее в json. Даже если образ соберется, это вообще не гарантия того, что приложение запустится. читаем полученные ошибки, пытаемся понять чего в супе не хватает (или гуглим) и вносим изменения. Например такой простой пример как у меня запускался с пустым окном, потому что в образе не добавились ресурсы типа modena.css и прочие dll утилиты для графического интерфейса.

Вот что мне пришлось добавить и изменить:

resource-config.json

  1. все вхождения для объявления ресурсов (dll, css, xml и т.д.), которые начинаются с префиксов «javafx.graphics:» или «javafx.controls:» заменяем на такую же строку но без префикса, например было «javafx.controls:\\Qapi-ms-win-crt-private-l1-1-0.dll\\E», стало «\\Qapi-ms-win-crt-private-l1-1-0.dll\\E».
    По идее агент все указал правильно, нашел нужные ресурсы внутри jar зависимостей, которые тоже нашел, однако сборщик образа такие записи не может правильно обработать, даже если эти зависимости лежат прамо в толстом jar. После замены путей к ресурсам далее нужно явно указать где именно лежат jar файлы javafx, чтобы сборщик не заблудился.
  2. Добавить эти ресурсы в список
{
    "pattern":"\\\\Qcom/sun/javafx/scene/control/skin/modena/modena.bss\\\\E"
  },{
    "pattern":"com/sun/prism/d3d/hlsl/.*\\.obj"
  }, {
    "pattern":"com/sun/scenario/effect/impl/hw/d3d/hlsl/.*\\.obj"
  }

3. Добавить ресурсы из проекта, которых пока нет, но они будут, этим сэкономим себе кучу нервов в будущем.

{
    "pattern":"ru/makushimo/javafxexe/.*\\.png"
  },{
    "pattern":"ru/makushimo/javafxexe/.*\\.jpg"
  },{
    "pattern":"ru/makushimo/javafxexe/.*\\.svg"
  }, {
    "pattern":"ru/makushimo/javafxexe/.*\\.css"
  }, {
    "pattern":"ru/makushimo/javafxexe/.*\\.fxml"
  }

reflect-config.json

Добавить это, хз что это, но на него ругается приложение при запуске, если его нет:

{
  "name":"com.sun.prism.shader.FillPgram_Color_Loader",
  "allDeclaredFields":true,
  "allPublicFields":true,
  "allDeclaredMethods":true,
  "allPublicMethods":true,
  "allDeclaredConstructors":true,
  "allPublicConstructors":true,
  "methods":[{"name":"loadShader","parameterTypes":["com.sun.prism.ps.ShaderFactory","java.io.InputStream"] }]
},
{
  "name":"com.sun.prism.shader.FillPgram_ImagePattern_Loader",
  "allDeclaredFields":true,
  "allPublicFields":true,
  "allDeclaredMethods":true,
  "allPublicMethods":true,
  "allDeclaredConstructors":true,
  "allPublicConstructors":true,
  "methods":[{"name":"loadShader","parameterTypes":["com.sun.prism.ps.ShaderFactory","java.io.InputStream"] }]
}

maven-dependency-plugin

Последний штрих, в профиль «native-jar» добавим плагин для сборка javafx jar-файлов в одно место.

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-dependency-plugin</artifactId>
  <version>3.8.1</version>
  <executions>
     <execution>
       <id>copy-dlls</id>
       <phase>prepare-package</phase>
       <goals>
         <goal>copy-dependencies</goal>
       </goals>
       <configuration>
         <outputDirectory>${javafx.lib.path}</outputDirectory>
         <includeScope>runtime</includeScope>
         <includeArtifactIds>javafx-graphics,javafx-controls</includeArtifactIds>
         <includeGroupIds>org.openjfx</includeGroupIds>
         <stripVersion>true</stripVersion>
       </configuration>
     </execution>
   </executions>
 </plugin>

А в конфигурацию плагина native-image выше добавили аргумент, указывающий на эту папку /libs.

<buildArg>-Djava.library.path=${javafx.lib.path}</buildArg>

Вот теперь приложение не только собирается в exe, но еще и запускается и даже работает.

Заключение

Как показал этот опыт, GraalVM еще безнадежно сырой, поскольку даже с встроенной поддержкой javafx в сборке нативных образов все не работает из коробки. Пока что доверять сложным инструментам нельзя. Мануалы на официальном сайте не сходятся с тем что видишь у себя в консоли вывода логов, мало того, что оно как есть не работает, так документация еще и путает, показывает настройки, которых просто нет в плагине последней версии, на которые логи трассировки указывают как на устаревшие и так далее. Как говорится, используй на свой страх и риск.

Код на этот успешный вариант тут.