View та Controller: Зв'язування з ViewModel через FXML
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.
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.
styleClass="primary-button" додає CSS-клас до елемента. У окремому CSS-файлі ви можете визначити стилі для цих класів:.primary-button {
-fx-background-color: #3b82f6;
-fx-text-fill: white;
-fx-font-weight: bold;
}
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 в обидва боки. Користувач вводить текст у searchField → viewModel.searchQuery оновлюється. ViewModel змінює searchQuery програмно → searchField оновлюється.
Рядки 61-62: Синхронізація вибору. Користувач обирає рядок у таблиці → viewModel.selectedAudiobook оновлюється. ViewModel змінює selectedAudiobook програмно → таблиця оновлює вибір.
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().
Прив'язка обробника подвійного кліку
Для обробки подвійного кліку на рядку таблиці потрібно програмно встановити обробник у 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).
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.
Діаграма життєвого циклу
Пояснення діаграми:
AudiobookAppстворюєFXMLLoaderта встановлюєControllerFactory.FXMLLoaderпарсить FXML та знаходитьfx:controller.FXMLLoaderвикликаєGuice.getInstance(AudiobookListController.class).- Guice резолвить залежності: створює
AudiobookListViewModel(зAudiobookService), потім створюєAudiobookListController(зAudiobookListViewModel). FXMLLoaderін'єктує@FXMLполя (UI-елементи) у Controller.FXMLLoaderвикликаєinitialize()у Controller.- Controller ініціалізує Bindings (
setupBindings()) та викликаєviewModel.initialize()для завантаження даних. 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).
Недоліки: Трохи більше коду.
Порівняння: Controller у MVC, MVP та MVVM
Підсумуємо, як змінюється роль Controller у різних архітектурних патернах:
== MVC (Model-View-Controller) Controller — це "мозок" додатку. Він:
- Отримує події від View (кліки кнопок).
- Викликає методи Model (бізнес-логіка, доступ до даних).
- Оновлює View вручну (
view.setTitle(),view.showError()).
Проблема: Controller знає про View та Model — сильна зв'язаність. Складно тестувати (потрібен UI).
== MVP (Model-View-Presenter) Presenter — це "розумний" посередник. Він:
- Отримує події від View через інтерфейс (
view.onAddClicked()). - Викликає методи Model.
- Оновлює View через інтерфейс (
view.displayAudiobooks(list)).
Переваги: View — це інтерфейс, легко тестувати (mock View). Presenter не залежить від JavaFX.
Недоліки: Багато boilerplate-коду (методи view.setX() для кожного UI-елемента).
== MVVM (Model-View-ViewModel) Controller — це "дурний" адаптер. Він:
- Ініціалізує Bindings між View та ViewModel.
- Делегує події до ViewModel (
viewModel.deleteSelected()). - Не містить логіки — лише з'єднує View з ViewModel.
ViewModel — це "розумний" компонент. Він:
- Містить всю логіку (валідація, фільтрація, асинхронність).
- Експонує Properties для Bindings.
- Не знає про View (не залежить від JavaFX UI-класів).
Переваги: Автоматична синхронізація через Bindings, легко тестувати ViewModel (POJO), мінімальний код у 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.
MVVM на практиці: Побудова ViewModel
Від теорії до коду: анатомія ViewModel, Wrapper Pattern для Domain Model, Properties та Commands, асинхронність через Task, lifecycle management та тестування без UI.
Інтеграція MVVM з Guice: Автоматична ін'єкція залежностей
Від ручного створення об'єктів до Dependency Injection: Guice Module, ControllerFactory, Constructor Injection, Scopes (Singleton vs Prototype), AssistedInject для параметризованих ViewModel.