Проектування баз даних

View та Controller: Зв'язування з ViewModel через FXML

Від ViewModel до UI: FXML як декларативний опис інтерфейсу, мінімальний Controller з Bindings, ін'єкція ViewModel, FXMLLoader та ControllerFactory, обробка подій та навігація.

View та Controller: Зв'язування з ViewModel через FXML

Вступ: Роль Controller у MVVM

У попередній статті ми побудували AudiobookListViewModel — повноцінний ViewModel з Properties, Commands, фільтрацією та асинхронністю. Але цей ViewModel існує у вакуумі — він не підключений до жодного UI-елемента. Користувач не бачить список аудіокниг, не може натиснути кнопку "Delete", не бачить індикатор завантаження.

Саме тут на сцену виходить View (FXML-розмітка) та Controller (Java-клас, що з'єднує View з ViewModel). Але їхня роль у MVVM радикально відрізняється від того, що ми бачили у MVC або навіть у класичних JavaFX-додатках без архітектурного патерну.

У MVP Controller (Presenter) був "розумним" — він містив всю логіку, викликав методи View, оновлював UI вручну. У MVVM Controller стає "дурним" — він лише ініціалізує Bindings між View та ViewModel. Вся логіка залишається у ViewModel, весь UI описаний у FXML, а Controller — це тонкий шар "клею", що з'єднує їх.

Ця стаття про те, як саме виглядає цей "клей". Ми розглянемо:

  • Як структурувати FXML для MVVM-додатку.
  • Як мінімізувати код у Controller.
  • Як ініціалізувати Bindings між UI-елементами та Properties ViewModel.
  • Як передати ViewModel у Controller (ін'єкція залежностей).
  • Як обробляти події (кліки кнопок) через делегування до ViewModel.
  • Як організувати навігацію між екранами.

Але перш за все, потрібно зрозуміти фундаментальний принцип: Controller у MVVM не містить логіки. Якщо ви пишете if, for, обчислення, SQL-запити у Controller — ви порушуєте MVVM. Весь цей код має бути у ViewModel.

Controller у MVVM — це адаптер. Його єдина відповідальність — з'єднати FXML-елементи (які мають JavaFX Properties) з ViewModel Properties через Bindings. Якщо Controller перевищує 100-150 рядків коду, це сигнал, що щось не так — логіка просочилася з ViewModel у Controller.

FXML: Декларативний опис UI

Перш ніж писати Controller, створимо FXML — XML-розмітку, що описує структуру нашого екрану. FXML — це не просто "HTML для JavaFX". Це декларативний спосіб описати, що відображається, без змішування з логікою того, як воно працює.

Структура FXML для AudiobookListView

Створимо файл audiobook-list-view.fxml для екрану зі списком аудіокниг:

<?xml version="1.0" encoding="UTF-8"?>

<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<?import javafx.geometry.Insets?>

<BorderPane xmlns:fx="http://javafx.com/fxml"
            fx:controller="dev.kostyl.audiobook.controller.AudiobookListController"
            prefWidth="900" prefHeight="600">
    
    <!-- Top: Панель інструментів -->
    <top>
        <VBox spacing="10" style="-fx-background-color: #f5f5f5; -fx-padding: 10;">
            
            <!-- Рядок 1: Кнопки дій -->
            <HBox spacing="10" alignment="CENTER_LEFT">
                <Button fx:id="addButton" text="Add Audiobook" 
                        styleClass="primary-button"/>
                <Button fx:id="editButton" text="Edit" 
                        styleClass="secondary-button"/>
                <Button fx:id="deleteButton" text="Delete" 
                        styleClass="danger-button"/>
                
                <Region HBox.hgrow="ALWAYS"/> <!-- Spacer -->
                
                <Button fx:id="refreshButton" text="Refresh" 
                        styleClass="secondary-button"/>
            </HBox>
            
            <!-- Рядок 2: Фільтри -->
            <HBox spacing="10" alignment="CENTER_LEFT">
                <Label text="Search:"/>
                <TextField fx:id="searchField" 
                           promptText="Search by title or author..." 
                           prefWidth="300"/>
                
                <Label text="Genre:"/>
                <ComboBox fx:id="genreComboBox" 
                          promptText="All genres" 
                          prefWidth="150"/>
            </HBox>
            
        </VBox>
    </top>
    
    <!-- Center: Таблиця аудіокниг -->
    <center>
        <StackPane>
            
            <!-- Таблиця -->
            <TableView fx:id="audiobookTable">
                <columns>
                    <TableColumn fx:id="titleColumn" text="Title" 
                                 prefWidth="250" minWidth="150"/>
                    <TableColumn fx:id="authorColumn" text="Author" 
                                 prefWidth="200" minWidth="120"/>
                    <TableColumn fx:id="genreColumn" text="Genre" 
                                 prefWidth="150" minWidth="100"/>
                    <TableColumn fx:id="durationColumn" text="Duration" 
                                 prefWidth="120" minWidth="80"/>
                    <TableColumn fx:id="releaseYearColumn" text="Year" 
                                 prefWidth="80" minWidth="60"/>
                </columns>
                
                <placeholder>
                    <Label text="No audiobooks found" 
                           style="-fx-text-fill: gray;"/>
                </placeholder>
            </TableView>
            
            <!-- Індикатор завантаження (поверх таблиці) -->
            <ProgressIndicator fx:id="loadingIndicator" 
                               maxWidth="100" maxHeight="100"
                               visible="false"/>
            
        </StackPane>
    </center>
    
    <!-- Bottom: Статус-бар -->
    <bottom>
        <HBox spacing="10" alignment="CENTER_LEFT" 
              style="-fx-background-color: #e0e0e0; -fx-padding: 5 10;">
            
            <Label fx:id="statusLabel" text="Ready"/>
            
            <Region HBox.hgrow="ALWAYS"/> <!-- Spacer -->
            
            <Label fx:id="countLabel" text="0 audiobooks"/>
            
        </HBox>
    </bottom>
    
</BorderPane>

Розбір FXML-структури

Рядки 1-5: XML-декларація та імпорти. <?import> — це аналог import у Java. Імпортуємо пакети JavaFX, що містять компоненти, які використовуємо.

Рядки 7-9: Кореневий елемент. <BorderPane> — це Root Node нашого View. Атрибут fx:controller вказує клас Controller, що керує цим FXML. prefWidth та prefHeight — початкові розміри вікна.

Рядки 12-42: Top — панель інструментів. <top> — це одна з п'яти зон BorderPane. Всередині VBox з двома рядками: кнопки дій (Add, Edit, Delete, Refresh) та фільтри (TextField для пошуку, ComboBox для жанру).

Рядок 16: fx:id — зв'язок з Controller. Кожен елемент, до якого потрібен доступ з Java-коду, має fx:id. Це ім'я поля у Controller, що буде автоматично ініціалізоване через @FXML.

Рядок 24: Region як Spacer. <Region HBox.hgrow="ALWAYS"/> — це порожній елемент, що розтягується на весь доступний простір. Він штовхає кнопку "Refresh" до правого краю.

Рядки 46-72: Center — таблиця. <center> містить StackPane (елементи один на одному). Знизу — TableView, зверху — ProgressIndicator (спочатку невидимий). Коли isLoading = true, індикатор з'являється поверх таблиці.

Рядки 48-60: TableView з колонками. Кожна <TableColumn> має fx:id (для доступу з Controller), text (заголовок), prefWidth (бажана ширина) та minWidth (мінімальна ширина при зміні розміру).

Рядки 62-65: Placeholder. Коли таблиця порожня, показується <placeholder> — Label з текстом "No audiobooks found".

Рядки 77-88: Bottom — статус-бар. <bottom> містить HBox з двома Labels: statusLabel (повідомлення) та countLabel (кількість аудіокниг). Між ними — Region як Spacer.

CSS-класи у FXML: Атрибут styleClass="primary-button" додає CSS-клас до елемента. У окремому CSS-файлі ви можете визначити стилі для цих класів:
.primary-button {
    -fx-background-color: #3b82f6;
    -fx-text-fill: white;
    -fx-font-weight: bold;
}
Це розділяє структуру (FXML) та стилізацію (CSS).

Controller: Мінімальний код з Bindings

Тепер створимо AudiobookListController — Java-клас, що з'єднує FXML з ViewModel. Пам'ятайте: Controller у MVVM не містить логіки — лише ініціалізацію Bindings та делегування подій.

Структура Controller

package dev.kostyl.audiobook.controller;

import dev.kostyl.audiobook.viewmodel.AudiobookListViewModel;
import dev.kostyl.audiobook.viewmodel.AudiobookViewModel;
import javafx.fxml.FXML;
import javafx.scene.control.*;

public class AudiobookListController {
    
    // ===== FXML-ін'єкція UI-елементів =====
    
    @FXML private TableView<AudiobookViewModel> audiobookTable;
    @FXML private TableColumn<AudiobookViewModel, String> titleColumn;
    @FXML private TableColumn<AudiobookViewModel, String> authorColumn;
    @FXML private TableColumn<AudiobookViewModel, String> genreColumn;
    @FXML private TableColumn<AudiobookViewModel, String> durationColumn;
    @FXML private TableColumn<AudiobookViewModel, Integer> releaseYearColumn;
    
    @FXML private TextField searchField;
    @FXML private ComboBox<Genre> genreComboBox;
    
    @FXML private Button addButton;
    @FXML private Button editButton;
    @FXML private Button deleteButton;
    @FXML private Button refreshButton;
    
    @FXML private ProgressIndicator loadingIndicator;
    @FXML private Label statusLabel;
    @FXML private Label countLabel;
    
    // ===== ViewModel =====
    
    private AudiobookListViewModel viewModel;
    
    // ===== Lifecycle =====
    
    @FXML
    public void initialize() {
        // Викликається автоматично після завантаження FXML
        // Тут ініціалізуємо Bindings
    }
    
    public void setViewModel(AudiobookListViewModel viewModel) {
        this.viewModel = viewModel;
        setupBindings();
        viewModel.initialize(); // Завантаження даних
    }
    
    // ===== Ініціалізація Bindings =====
    
    private void setupBindings() {
        // Буде реалізовано далі
    }
    
    // ===== Event Handlers =====
    
    @FXML
    private void onAddClicked() {
        // Делегування до ViewModel або Navigator
    }
    
    @FXML
    private void onEditClicked() {
        viewModel.editSelected();
    }
    
    @FXML
    private void onDeleteClicked() {
        viewModel.deleteSelected();
    }
    
    @FXML
    private void onRefreshClicked() {
        viewModel.refresh();
    }
}

Розбір структури:

Рядки 12-29: FXML-ін'єкція. Кожне поле з @FXML автоматично ініціалізується FXMLLoader, коли він знаходить елемент з відповідним fx:id у FXML. Ім'я поля має точно співпадати з fx:id.

Рядок 33: ViewModel. Це єдина залежність Controller. Вона передається ззовні через setViewModel() (Setter Injection). У реальному додатку це буде через Guice (Constructor Injection).

Рядки 38-42: initialize(). Цей метод викликається автоматично після завантаження FXML та ін'єкції всіх @FXML полів. Тут можна ініціалізувати те, що не залежить від ViewModel (наприклад, налаштування колонок таблиці).

Рядки 44-48: setViewModel(). Цей метод викликається ззовні (з Application або через Guice) для передачі ViewModel. Після отримання ViewModel ми ініціалізуємо Bindings та викликаємо viewModel.initialize() для завантаження даних.

Рядки 52-55: setupBindings(). Тут відбувається магія MVVM — з'єднання UI-елементів з ViewModel Properties через Bindings. Це серце Controller.

Рядки 59-73: Event Handlers. Методи з @FXML, що викликаються при натисканні кнопок. Вони не містять логіки — лише делегують виклик до ViewModel. Зверніть увагу: жодних if, жодних обчислень — лише viewModel.method().

Ініціалізація Bindings: setupBindings()

Тепер реалізуємо setupBindings() — найважливіший метод Controller:

private void setupBindings() {
    // ===== Налаштування колонок таблиці =====
    
    titleColumn.setCellValueFactory(cellData -> 
        cellData.getValue().titleProperty());
    
    authorColumn.setCellValueFactory(cellData -> 
        cellData.getValue().authorNameProperty());
    
    genreColumn.setCellValueFactory(cellData -> 
        cellData.getValue().genreNameProperty());
    
    durationColumn.setCellValueFactory(cellData -> 
        cellData.getValue().formattedDurationProperty());
    
    releaseYearColumn.setCellValueFactory(cellData -> 
        cellData.getValue().releaseYearProperty().asObject());
    
    // ===== Bindings: ViewModel → View (Unidirectional) =====
    
    // Таблиця показує sortedAudiobooks з ViewModel
    audiobookTable.setItems(viewModel.getSortedAudiobooks());
    
    // Прив'язка сортування таблиці до SortedList
    viewModel.getSortedAudiobooks().comparatorProperty()
        .bind(audiobookTable.comparatorProperty());
    
    // Індикатор завантаження видимий, коли isLoading = true
    loadingIndicator.visibleProperty().bind(viewModel.isLoadingProperty());
    
    // Статус-бар показує statusMessage
    statusLabel.textProperty().bind(viewModel.statusMessageProperty());
    
    // Лічильник показує "Showing X of Y audiobooks"
    countLabel.textProperty().bind(
        Bindings.concat("Showing ", 
            viewModel.filteredCountProperty(), 
            " of ", 
            viewModel.totalCountProperty(), 
            " audiobooks")
    );
    
    // Кнопки Edit та Delete активні лише коли щось обрано
    editButton.disableProperty().bind(
        viewModel.deleteButtonEnabledProperty().not());
    deleteButton.disableProperty().bind(
        viewModel.deleteButtonEnabledProperty().not());
    
    // ===== Bindings: View ↔ ViewModel (Bidirectional) =====
    
    // Пошуковий запит синхронізується в обидва боки
    searchField.textProperty().bindBidirectional(
        viewModel.searchQueryProperty());
    
    // Обраний жанр синхронізується в обидва боки
    genreComboBox.valueProperty().bindBidirectional(
        viewModel.selectedGenreProperty());
    
    // Обраний елемент таблиці синхронізується в обидва боки
    audiobookTable.getSelectionModel().selectedItemProperty()
        .bindBidirectional(viewModel.selectedAudiobookProperty());
    
    // ===== Завантаження жанрів у ComboBox =====
    
    // У реальному додатку це буде через окремий ViewModel
    genreComboBox.getItems().addAll(
        new Genre("Fiction"),
        new Genre("Non-Fiction"),
        new Genre("Science"),
        new Genre("History")
    );
}

Розбір Bindings:

Рядки 4-17: Налаштування колонок. setCellValueFactory() вказує, яку Property використовувати для кожної колонки. Лямбда-вираз cellData -> cellData.getValue().titleProperty() означає: "для кожного рядка візьми AudiobookViewModel та поверни його titleProperty". TableView автоматично підключається до цієї Property через Binding.

Рядок 22: Підключення таблиці до даних. setItems(viewModel.getSortedAudiobooks()) — це не Binding у класичному сенсі, але це встановлює зв'язок: таблиця показує sortedAudiobooks. Коли ця колекція змінюється (додавання/видалення елементів), таблиця автоматично оновлюється.

Рядки 25-26: Прив'язка сортування. Коли користувач клікає на заголовок колонки, TableView змінює свій comparator. Ми прив'язуємо цей comparator до SortedList у ViewModel, щоб сортування застосовувалося до даних.

Рядок 29: Unidirectional Binding. loadingIndicator.visibleProperty().bind(...) — це однонаправлена прив'язка. Коли viewModel.isLoading змінюється, loadingIndicator.visible автоматично оновлюється. Але зміна visible вручну неможлива (викине виняток).

Рядки 35-41: Computed Binding. Bindings.concat() створює рядок, що автоматично оновлюється при зміні filteredCount або totalCount. Це декларативний спосіб: "countLabel завжди показує 'Showing X of Y audiobooks'".

Рядки 44-47: Binding з інверсією. editButton.disableProperty().bind(viewModel.deleteButtonEnabledProperty().not()) — кнопка неактивна (disable = true), коли deleteButtonEnabled = false. .not() інвертує Boolean Binding.

Рядки 52-53: Bidirectional Binding. bindBidirectional() синхронізує дві Properties в обидва боки. Користувач вводить текст у searchFieldviewModel.searchQuery оновлюється. ViewModel змінює searchQuery програмно → searchField оновлюється.

Рядки 61-62: Синхронізація вибору. Користувач обирає рядок у таблиці → viewModel.selectedAudiobook оновлюється. ViewModel змінює selectedAudiobook програмно → таблиця оновлює вибір.

Чому так багато Bindings? Кожен Binding — це декларація залежності: "цей UI-елемент завжди відображає цю Property". Це може здаватися багатослівним, але це ціна за автоматичну синхронізацію. Альтернатива (MVP) — десятки методів view.setX() у Presenter.

Event Handlers: Делегування до ViewModel

Тепер, коли Bindings налаштовані, розглянемо обробку подій — що відбувається, коли користувач натискає кнопку або вибирає елемент у таблиці.

У класичному JavaFX-додатку без архітектурного патерну Controller містив би всю логіку обробки подій: перевірку валідності, виклики до бази даних, оновлення UI. У MVVM Controller не містить логіки — він лише делегує виклик до ViewModel.

Прив'язка Event Handlers у FXML

Є два способи прив'язати обробник події до кнопки:

Спосіб 1: Через атрибут onAction у FXML

<Button fx:id="deleteButton" text="Delete" 
        onAction="#onDeleteClicked"/>

Атрибут onAction="#onDeleteClicked" означає: "коли користувач натискає цю кнопку, викликай метод onDeleteClicked() у Controller". Символ # вказує на метод Controller.

Спосіб 2: Програмно у Controller

@FXML
public void initialize() {
    deleteButton.setOnAction(event -> onDeleteClicked());
}

Цей підхід корисний, коли потрібна додаткова логіка (наприклад, передача параметрів) або коли обробник встановлюється динамічно.

Рекомендація: Використовуйте атрибут onAction у FXML для простих випадків (кнопки, меню). Програмний підхід — для складних сценаріїв (динамічні обробники, передача контексту).

Реалізація Event Handlers у Controller

Тепер реалізуємо методи-обробники у AudiobookListController:

// ===== Event Handlers =====

@FXML
private void onAddClicked() {
    // Навігація до форми додавання (буде реалізовано у статті про навігацію)
    // navigator.navigateTo("audiobook-form");
    
    // Поки що — заглушка
    System.out.println("Add button clicked");
}

@FXML
private void onEditClicked() {
    viewModel.editSelected();
}

@FXML
private void onDeleteClicked() {
    // Показуємо діалог підтвердження
    Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
    alert.setTitle("Delete Audiobook");
    alert.setHeaderText("Are you sure you want to delete this audiobook?");
    alert.setContentText(viewModel.getSelectedAudiobook().getTitle());
    
    Optional<ButtonType> result = alert.showAndWait();
    if (result.isPresent() && result.get() == ButtonType.OK) {
        viewModel.deleteSelected();
    }
}

@FXML
private void onRefreshClicked() {
    viewModel.refresh();
}

@FXML
private void onTableRowDoubleClicked(MouseEvent event) {
    if (event.getClickCount() == 2 && viewModel.getSelectedAudiobook() != null) {
        viewModel.editSelected();
    }
}

Розбір обробників:

onAddClicked() — навігація до форми додавання. У реальному додатку тут буде виклик Navigator.navigateTo("audiobook-form"). Поки що це заглушка.

onEditClicked() — делегування до ViewModel. Метод viewModel.editSelected() містить всю логіку: перевірку, чи щось обрано, завантаження даних, навігацію до форми редагування. Controller лише викликає цей метод.

onDeleteClicked() — єдиний обробник з "логікою". Але це не бізнес-логіка — це UI-логіка: показати діалог підтвердження. Це допустимо у Controller, бо діалог — це частина View. Після підтвердження — делегування до viewModel.deleteSelected().

onRefreshClicked() — найпростіший обробник. Лише виклик viewModel.refresh().

onTableRowDoubleClicked() — обробка подвійного кліку на рядку таблиці. Перевіряємо кількість кліків (event.getClickCount() == 2) та наявність вибраного елемента, потім викликаємо viewModel.editSelected().

Діалоги у Controller — виняток з правила. Показ діалогів підтвердження (Alert, Dialog) — це UI-логіка, тому вона може бути у Controller. Але якщо діалог складний (форма з валідацією), створіть окремий ViewModel для нього.

Прив'язка обробника подвійного кліку

Для обробки подвійного кліку на рядку таблиці потрібно програмно встановити обробник у initialize():

@FXML
public void initialize() {
    // Обробка подвійного кліку на рядку таблиці
    audiobookTable.setOnMouseClicked(this::onTableRowDoubleClicked);
}

Альтернативно, можна використати атрибут onMouseClicked у FXML, але для складних подій (подвійний клік, drag-and-drop) програмний підхід зручніший.


Ін'єкція ViewModel: Від Setter Injection до Guice

У попередніх прикладах ми передавали ViewModel через метод setViewModel(). Це працює, але не масштабується: хто викликає setViewModel()? Хто створює ViewModel? Як передати залежності у ViewModel (Repository, Service)?

Рішення — Dependency Injection через Guice. Ми вже розглядали Guice у статті 24, тепер застосуємо його для інтеграції Controller та ViewModel.

Проблема: Хто створює Controller?

JavaFX використовує FXMLLoader для завантаження FXML та створення Controller. За замовчуванням FXMLLoader створює Controller через рефлексію — викликає конструктор без параметрів:

FXMLLoader loader = new FXMLLoader(getClass().getResource("audiobook-list-view.fxml"));
Parent root = loader.load(); // Тут створюється Controller через new AudiobookListController()

Але якщо Controller має залежності (ViewModel, Navigator), конструктор без параметрів не підходить. Потрібен Constructor Injection:

public class AudiobookListController {
    private final AudiobookListViewModel viewModel;
    
    @Inject
    public AudiobookListController(AudiobookListViewModel viewModel) {
        this.viewModel = viewModel;
    }
}

Але FXMLLoader не знає про Guice — він не може викликати injector.getInstance(AudiobookListController.class). Потрібен міст між FXMLLoader та Guice.

Рішення: ControllerFactory

FXMLLoader підтримує ControllerFactory — інтерфейс, що дозволяє кастомізувати створення Controller:

public interface Callback<P, R> {
    R call(P param);
}

FXMLLoader.setControllerFactory() приймає Callback<Class<?>, Object> — функцію, що отримує клас Controller та повертає його екземпляр. Ми можемо передати туди Guice Injector:

FXMLLoader loader = new FXMLLoader(getClass().getResource("audiobook-list-view.fxml"));
loader.setControllerFactory(injector::getInstance); // Guice створює Controller
Parent root = loader.load();

Тепер, коли FXMLLoader зустрічає атрибут fx:controller="dev.kostyl.audiobook.controller.AudiobookListController", він викликає injector.getInstance(AudiobookListController.class), і Guice автоматично ін'єктує всі залежності (ViewModel, Navigator).

Повний приклад: Application з Guice

Створимо клас AudiobookApp — точку входу у додаток:

package dev.kostyl.audiobook;

import com.google.inject.Guice;
import com.google.inject.Injector;
import dev.kostyl.audiobook.infrastructure.AudiobookModule;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Parent;
import javafx.scene.Scene;
import javafx.stage.Stage;

public class AudiobookApp extends Application {
    
    private Injector injector;
    
    @Override
    public void init() {
        // Створення Guice Injector з модулем конфігурації
        injector = Guice.createInjector(new AudiobookModule());
    }
    
    @Override
    public void start(Stage primaryStage) throws Exception {
        // Завантаження FXML з ControllerFactory
        FXMLLoader loader = new FXMLLoader(
            getClass().getResource("/fxml/audiobook-list-view.fxml")
        );
        loader.setControllerFactory(injector::getInstance);
        
        Parent root = loader.load();
        
        // Налаштування Scene та Stage
        Scene scene = new Scene(root, 900, 600);
        scene.getStylesheets().add(
            getClass().getResource("/css/styles.css").toExternalForm()
        );
        
        primaryStage.setTitle("Audiobook Platform");
        primaryStage.setScene(scene);
        primaryStage.show();
    }
    
    @Override
    public void stop() {
        // Закриття ресурсів (Connection Pool, тощо)
        // injector.getInstance(DataSource.class).close();
    }
    
    public static void main(String[] args) {
        launch(args);
    }
}

Розбір коду:

Рядки 14-17: init(). Метод init() викликається перед start() — тут створюємо Guice Injector. AudiobookModule — це Guice Module, що конфігурує всі Bindings (Repository, Service, ViewModel).

Рядки 21-24: Завантаження FXML. Створюємо FXMLLoader та встановлюємо ControllerFactory через injector::getInstance. Це method reference — еквівалент clazz -> injector.getInstance(clazz).

Рядок 26: loader.load(). Тут відбувається магія: FXMLLoader парсить FXML, знаходить fx:controller, викликає injector.getInstance(AudiobookListController.class), Guice створює Controller з усіма залежностями (ViewModel, Navigator), потім FXMLLoader ін'єктує @FXML поля (UI-елементи) та викликає initialize().

Рядки 29-31: Підключення CSS. Завантажуємо CSS-файл для стилізації UI.

Рядки 38-41: stop(). Метод stop() викликається при закритті додатку — тут закриваємо ресурси (Connection Pool, файли).

Guice Module: Конфігурація залежностей

Створимо AudiobookModule — Guice Module, що конфігурує всі Bindings:

package dev.kostyl.audiobook.infrastructure;

import com.google.inject.AbstractModule;
import com.google.inject.Singleton;
import dev.kostyl.audiobook.repository.AudiobookRepository;
import dev.kostyl.audiobook.repository.jdbc.JdbcAudiobookRepository;
import dev.kostyl.audiobook.service.AudiobookService;
import dev.kostyl.audiobook.viewmodel.AudiobookListViewModel;

import javax.sql.DataSource;

public class AudiobookModule extends AbstractModule {
    
    @Override
    protected void configure() {
        // ===== Data Access Layer =====
        
        // DataSource (Connection Pool)
        bind(DataSource.class)
            .toProvider(HikariDataSourceProvider.class)
            .in(Singleton.class);
        
        // Repositories
        bind(AudiobookRepository.class)
            .to(JdbcAudiobookRepository.class);
        
        // ===== Service Layer =====
        
        bind(AudiobookService.class)
            .in(Singleton.class);
        
        // ===== Presentation Layer =====
        
        // ViewModels (Singleton — один екземпляр на весь додаток)
        bind(AudiobookListViewModel.class)
            .in(Singleton.class);
        
        // Controllers створюються FXMLLoader через ControllerFactory
        // Не потрібно bind() для Controllers — вони створюються автоматично
    }
}

Розбір конфігурації:

Рядки 18-20: DataSource. Використовуємо HikariDataSourceProvider (Provider Pattern) для створення Connection Pool. Singleton — один екземпляр на весь додаток.

Рядки 23-24: Repository. Прив'язуємо інтерфейс AudiobookRepository до реалізації JdbcAudiobookRepository. Guice автоматично ін'єктує DataSource у конструктор JdbcAudiobookRepository.

Рядки 28-29: Service. AudiobookService — Singleton, бо він не має стану (stateless). Guice ін'єктує AudiobookRepository у конструктор.

Рядки 34-35: ViewModel. AudiobookListViewModel — Singleton. Це означає, що всі Controller, які залежать від цього ViewModel, отримають один і той самий екземпляр. Це важливо для збереження стану (вибраний елемент, фільтри) між переходами між екранами.

Рядки 37-38: Controllers. Controllers не потрібно прив'язувати у Guice Module — вони створюються FXMLLoader через ControllerFactory. Guice лише ін'єктує їхні залежності (ViewModel, Navigator).

Singleton ViewModel — це нормально? Так, якщо ViewModel не містить стану, специфічного для конкретного екрану. Наприклад, AudiobookListViewModel зберігає список аудіокниг, фільтри, вибраний елемент — це глобальний стан, що має зберігатися між переходами. Але якщо ViewModel містить стан форми (наприклад, AudiobookFormViewModel), він має бути не-Singleton (новий екземпляр для кожного відкриття форми).

FXMLLoader: Життєвий цикл завантаження

Розглянемо детально, що відбувається під час виклику loader.load():

Крок 1: Парсинг FXML

FXMLLoader читає XML-файл та будує дерево об'єктів JavaFX (Node, Layout, Control).

Крок 2: Створення Controller

FXMLLoader знаходить атрибут fx:controller та викликає ControllerFactory.call(AudiobookListController.class). Guice створює Controller через Constructor Injection, ін'єктуючи всі залежності (ViewModel).

Крок 3: Ін'єкція @FXML полів

FXMLLoader знаходить всі поля з @FXML у Controller та ін'єктує відповідні UI-елементи з FXML (за fx:id).

Крок 4: Виклик initialize()

FXMLLoader викликає метод initialize() у Controller (якщо він існує). Тут ініціалізуємо Bindings.

Крок 5: Повернення Root Node

loader.load() повертає кореневий елемент FXML (у нашому випадку — BorderPane), готовий до відображення у Scene.

Діаграма життєвого циклу

Loading diagram...

sequenceDiagram participant App as AudiobookApp participant Loader as FXMLLoader participant Guice as Guice Injector participant Ctrl as AudiobookListController participant VM as AudiobookListViewModel

App->>Loader: new FXMLLoader(fxml)
App->>Loader: setControllerFactory(injector::getInstance)
App->>Loader: load()

Loader->>Loader: Parse FXML
Loader->>Loader: Find fx:controller attribute
Loader->>Guice: getInstance(AudiobookListController.class)

Guice->>Guice: Resolve dependencies
Guice->>VM: new AudiobookListViewModel(service)
Guice->>Ctrl: new AudiobookListController(viewModel)
Guice-->>Loader: Return Controller instance

Loader->>Ctrl: Inject @FXML fields (UI elements)
Loader->>Ctrl: Call initialize()
Ctrl->>Ctrl: setupBindings()
Ctrl->>VM: initialize() (load data)

Loader-->>App: Return Root Node (BorderPane)
App->>App: new Scene(root)
App->>App: stage.setScene(scene)

Пояснення діаграми:

  1. AudiobookApp створює FXMLLoader та встановлює ControllerFactory.
  2. FXMLLoader парсить FXML та знаходить fx:controller.
  3. FXMLLoader викликає Guice.getInstance(AudiobookListController.class).
  4. Guice резолвить залежності: створює AudiobookListViewModelAudiobookService), потім створює AudiobookListControllerAudiobookListViewModel).
  5. FXMLLoader ін'єктує @FXML поля (UI-елементи) у Controller.
  6. FXMLLoader викликає initialize() у Controller.
  7. Controller ініціалізує Bindings (setupBindings()) та викликає viewModel.initialize() для завантаження даних.
  8. FXMLLoader повертає Root Node, готовий до відображення.

Альтернативні підходи до ін'єкції ViewModel

Ми розглянули Constructor Injection через Guice. Але є інші підходи:

Підхід 1: Setter Injection (без Guice)

public class AudiobookListController {
    private AudiobookListViewModel viewModel;
    
    public void setViewModel(AudiobookListViewModel viewModel) {
        this.viewModel = viewModel;
        setupBindings();
    }
}

Після завантаження FXML вручну викликаємо setViewModel():

FXMLLoader loader = new FXMLLoader(getClass().getResource("audiobook-list-view.fxml"));
Parent root = loader.load();
AudiobookListController controller = loader.getController();
controller.setViewModel(new AudiobookListViewModel(service));

Переваги: Простота, не потрібен Guice.
Недоліки: Ручне створення залежностей, не масштабується.

Підхід 2: Field Injection через Guice

public class AudiobookListController {
    @Inject
    private AudiobookListViewModel viewModel;
    
    @FXML
    public void initialize() {
        setupBindings();
    }
}

Guice ін'єктує viewModel через поле (Field Injection).

Переваги: Менше коду (немає конструктора).
Недоліки: Field Injection вважається anti-pattern (складніше тестувати, неявні залежності).

Підхід 3: Constructor Injection через Guice (рекомендований)

public class AudiobookListController {
    private final AudiobookListViewModel viewModel;
    
    @Inject
    public AudiobookListController(AudiobookListViewModel viewModel) {
        this.viewModel = viewModel;
    }
    
    @FXML
    public void initialize() {
        setupBindings();
        viewModel.initialize();
    }
}

Переваги: Явні залежності, легко тестувати (можна передати mock у конструктор), immutability (final).
Недоліки: Трохи більше коду.

Рекомендація: Використовуйте Constructor Injection через Guice для production-додатків. Setter Injection — для прототипів та навчальних проєктів.

Порівняння: Controller у MVC, MVP та MVVM

Підсумуємо, як змінюється роль Controller у різних архітектурних патернах:

== MVC (Model-View-Controller) Controller — це "мозок" додатку. Він:


Практичні завдання

Рівень 1: Базове зв'язування

Завдання 1.1: Створіть FXML-файл author-list-view.fxml з таблицею авторів (колонки: First Name, Last Name, Birth Year) та кнопками Add, Edit, Delete.

Завдання 1.2: Створіть AuthorListController з @FXML полями для всіх UI-елементів.

Завдання 1.3: Реалізуйте метод setupBindings(), що прив'язує колонки таблиці до Properties AuthorViewModel.

Рівень 2: Bindings та обробка подій

Завдання 2.1: Додайте у audiobook-list-view.fxml CheckBox "Show only favorites". Прив'яжіть його до BooleanProperty showOnlyFavorites у ViewModel через Bidirectional Binding.

Завдання 2.2: Реалізуйте обробник подвійного кліку на рядку таблиці, що відкриває діалог з деталями аудіокниги.

Завдання 2.3: Додайте ProgressBar, що показує прогрес завантаження даних. Прив'яжіть його до DoubleProperty loadingProgress у ViewModel.

Рівень 3: Guice Integration

Завдання 3.1: Створіть AuthorModule (Guice Module) з Bindings для AuthorRepository, AuthorService, AuthorListViewModel.

Завдання 3.2: Модифікуйте AudiobookApp, щоб він завантажував author-list-view.fxml через FXMLLoader з ControllerFactory.

Завдання 3.3: Реалізуйте навігацію між AudiobookListView та AuthorListView через кнопку "Manage Authors". Створіть простий Navigator (клас з методом navigateTo(String viewName)), що змінює Root Node у Scene.


Підсумок

У цій статті ми з'єднали ViewModel з UI через View (FXML) та Controller. Ключові висновки:

Controller у MVVM — це мінімальний адаптер. Його єдина відповідальність — ініціалізувати Bindings між UI-елементами та ViewModel Properties. Якщо Controller перевищує 100-150 рядків, це сигнал, що логіка просочилася з ViewModel.

FXML — це декларативний опис UI. Він описує структуру (що відображається), а не логіку (як воно працює). Атрибут fx:id з'єднує FXML-елементи з @FXML полями у Controller.

Bindings — це серце MVVM. Unidirectional Bindings (bind()) для відображення даних з ViewModel у View. Bidirectional Bindings (bindBidirectional()) для синхронізації введення користувача з ViewModel. Computed Bindings (Bindings.concat(), Bindings.when()) для складних залежностей.

Event Handlers делегують виклики до ViewModel. Controller не містить логіки обробки подій — лише виклик viewModel.method(). Виняток — UI-логіка (діалоги підтвердження).

Guice інтегрується через ControllerFactory. FXMLLoader.setControllerFactory(injector::getInstance) дозволяє Guice створювати Controllers з автоматичною ін'єкцією залежностей (ViewModel, Navigator).

Життєвий цикл завантаження: FXMLLoader парсить FXML → створює Controller через ControllerFactory → ін'єктує @FXML поля → викликає initialize() → повертає Root Node.

У наступній статті ми розглянемо повну інтеграцію MVVM з Guice: як організувати модулі, як керувати життєвим циклом ViewModel (Singleton vs Prototype), як передавати параметри між екранами, та як тестувати Controller з mock ViewModel.

Copyright © 2026