MVC vs MVP vs MVVM: Еволюція архітектурних патернів UI
MVC vs MVP vs MVVM: Еволюція архітектурних патернів UI
Вступ: Чому "все в одному класі" — це проблема
Уявіть типовий сценарій розробки JavaFX-додатку без архітектурного патерну. Ви створили контролер AudiobookListController, що керує екраном зі списком аудіокниг. Спочатку він виглядав невинно — кілька десятків рядків коду. Але з часом туди додалися нові функції, і ось що вийшло:
public class AudiobookListController {
@FXML private TableView<Audiobook> audiobookTable;
@FXML private TextField searchField;
@FXML private ComboBox<Genre> genreComboBox;
@FXML private Button addButton;
@FXML private Button deleteButton;
private Connection connection;
private ObservableList<Audiobook> audiobooks;
@FXML
public void initialize() {
// Підключення до бази даних
try {
connection = DriverManager.getConnection("jdbc:h2:./data/audiobook", "sa", "");
} catch (SQLException e) {
showError("Failed to connect to database: " + e.getMessage());
return;
}
// Налаштування колонок таблиці
titleColumn.setCellValueFactory(new PropertyValueFactory<>("title"));
authorColumn.setCellValueFactory(cellData ->
new SimpleStringProperty(cellData.getValue().getAuthor().getFullName())
);
durationColumn.setCellValueFactory(new PropertyValueFactory<>("duration"));
// Завантаження жанрів
loadGenres();
// Завантаження аудіокниг
loadAudiobooks();
// Валідація пошукового поля
searchField.textProperty().addListener((obs, old, newVal) -> {
if (newVal.length() > 100) {
searchField.setStyle("-fx-border-color: red;");
} else {
searchField.setStyle("");
}
filterAudiobooks(newVal);
});
// Активація кнопок залежно від вибору
audiobookTable.getSelectionModel().selectedItemProperty().addListener((obs, old, newVal) -> {
deleteButton.setDisable(newVal == null);
});
}
private void loadGenres() {
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM genres")) {
while (rs.next()) {
Genre genre = new Genre(
UUID.fromString(rs.getString("id")),
rs.getString("name"),
rs.getString("description")
);
genreComboBox.getItems().add(genre);
}
} catch (SQLException e) {
showError("Failed to load genres: " + e.getMessage());
}
}
private void loadAudiobooks() {
audiobooks = FXCollections.observableArrayList();
String sql = """
SELECT a.*, au.first_name, au.last_name, g.name as genre_name
FROM audiobooks a
JOIN authors au ON a.author_id = au.id
JOIN genres g ON a.genre_id = g.id
""";
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
while (rs.next()) {
Author author = new Author(
UUID.fromString(rs.getString("author_id")),
rs.getString("first_name"),
rs.getString("last_name")
);
Genre genre = new Genre(
UUID.fromString(rs.getString("genre_id")),
rs.getString("genre_name"),
null
);
Audiobook audiobook = new Audiobook(
UUID.fromString(rs.getString("id")),
rs.getString("title"),
author,
genre,
rs.getInt("duration"),
rs.getInt("release_year")
);
audiobooks.add(audiobook);
}
audiobookTable.setItems(audiobooks);
} catch (SQLException e) {
showError("Failed to load audiobooks: " + e.getMessage());
}
}
private void filterAudiobooks(String query) {
if (query == null || query.isEmpty()) {
audiobookTable.setItems(audiobooks);
return;
}
ObservableList<Audiobook> filtered = audiobooks.filtered(audiobook ->
audiobook.getTitle().toLowerCase().contains(query.toLowerCase()) ||
audiobook.getAuthor().getFullName().toLowerCase().contains(query.toLowerCase())
);
audiobookTable.setItems(filtered);
}
@FXML
private void onAddClicked() {
// Відкриття діалогу додавання
// ... ще 50 рядків коду
}
@FXML
private void onDeleteClicked() {
Audiobook selected = audiobookTable.getSelectionModel().getSelectedItem();
if (selected == null) return;
// Підтвердження видалення
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
alert.setTitle("Delete Audiobook");
alert.setHeaderText("Are you sure?");
alert.setContentText("Delete '" + selected.getTitle() + "'?");
if (alert.showAndWait().get() == ButtonType.OK) {
try (PreparedStatement stmt = connection.prepareStatement(
"DELETE FROM audiobooks WHERE id = ?")) {
stmt.setString(1, selected.getId().toString());
stmt.executeUpdate();
audiobooks.remove(selected);
} catch (SQLException e) {
showError("Failed to delete audiobook: " + e.getMessage());
}
}
}
private void showError(String message) {
Alert alert = new Alert(Alert.AlertType.ERROR);
alert.setTitle("Error");
alert.setContentText(message);
alert.showAndWait();
}
}
Проблема 1: God Object (Об'єкт-Бог). Контролер знає все і робить все: він керує UI, виконує SQL-запити, маппить ResultSet у об'єкти, валідує дані, форматує повідомлення про помилки. Це порушує принцип Single Responsibility — клас має більше однієї причини для зміни.
Проблема 2: Неможливість тестування. Як написати unit-тест для методу filterAudiobooks()? Він залежить від audiobookTable — JavaFX-компонента, що вимагає JavaFX Application Thread. Щоб протестувати фільтрацію, вам потрібно запустити весь JavaFX-додаток. Це вже не unit-тест, а інтеграційний.
Проблема 3: Жорстка зв'язаність з інфраструктурою. Контролер безпосередньо працює з Connection та PreparedStatement. Якщо ви вирішите замінити H2 на PostgreSQL або додати Connection Pool, вам доведеться змінювати контролер. Якщо ви захочете додати кешування, вам знову доведеться лізти в контролер.
Проблема 4: Дублювання логіки. Якщо у вас є ще один екран зі списком авторів, ви скопіюєте 80% цього коду, змінивши лише назви таблиць та полів. Через рік у вас буде 10 контролерів з майже ідентичною логікою, і будь-яка зміна (наприклад, формат повідомлень про помилки) вимагатиме оновлення всіх 10 файлів.
Проблема 5: Неявні залежності. Читаючи сигнатуру класу, неможливо зрозуміти, що йому потрібно для роботи. Він створює Connection всередині initialize(), тому ви не можете передати mock-об'єкт для тестування. Він залежить від глобального стану (база даних), що робить тести нестабільними.
Ці проблеми не унікальні для JavaFX — вони виникають у будь-якому UI-фреймворку, коли розробники не використовують архітектурні патерни. І саме для вирішення цих проблем протягом останніх 50 років були розроблені MVC, MVP та MVVM — три покоління патернів для організації коду UI-додатків.
У цій статті ми простежимо еволюцію цих патернів, зрозуміємо мотивацію кожного з них, порівняємо їхні переваги та недоліки, та обґрунтуємо, чому саме MVVM є природним вибором для JavaFX.
Model-View-Controller (MVC): Класичний підхід
Model-View-Controller — це патерн, що з'явився у 1979 році в рамках проєкту Smalltalk-80 у дослідницькому центрі Xerox PARC. Його автор, Trygve Reenskaug, шукав спосіб організувати код графічних додатків так, щоб розділити три фундаментально різні відповідальності: дані, відображення та управління.
Три компоненти MVC
Model (Модель) — це серце додатку, що містить бізнес-логіку та дані. Model не знає нічого про те, як ці дані відображаються. Він може бути використаний у консольному додатку, веб-сервісі або desktop-програмі без жодних змін.
У нашому прикладі з аудіокнигами Model — це класи Audiobook, Author, Genre та репозиторії для роботи з базою даних. Model відповідає на питання "що зберігається" та "які операції можливі".
View (Представлення) — це те, що бачить користувач. View відображає дані з Model та передає введення користувача до Controller. View може бути різним для одного й того ж Model: список у таблиці, картки з обкладинками, діаграма статистики.
У JavaFX View — це FXML-файл з TableView, кнопками та текстовими полями. View відповідає на питання "як це виглядає".
Controller (Контролер) — це посередник між View та Model. Він отримує події від View (клік кнопки, введення тексту), інтерпретує їх як команди для Model (завантажити дані, видалити запис), та оновлює View відповідно до змін у Model.
У JavaFX Controller — це клас з анотацією @FXML, що містить методи-обробники подій. Controller відповідає на питання "що робити при дії користувача".
Потік даних у класичному MVC
Оригінальний MVC, розроблений для Smalltalk, мав специфічний потік даних:
- Користувач взаємодіє з View (натискає кнопку).
- View повідомляє Controller про подію.
- Controller оновлює Model (викликає метод
repository.delete(id)). - Model сповіщає View про зміни через Observer Pattern.
- View запитує оновлені дані у Model та перемальовується.
Ключова особливість: View безпосередньо спостерігає за Model. Коли Model змінюється, він генерує подію, на яку підписаний View. Controller не бере участі в оновленні View — він лише змінює Model.
MVC у JavaFX: Проблеми адаптації
Спроба реалізувати класичний MVC у JavaFX виявляє кілька проблем:
Проблема 1: View не може безпосередньо спостерігати за Model. У Smalltalk View міг підписатися на зміни Model через вбудований механізм подій. У Java це вимагає ручної реалізації Observer Pattern для кожного Model-класу, що призводить до величезної кількості boilerplate-коду.
Проблема 2: Controller стає "товстим". Оскільки View не може сам оновлюватися при зміні Model, цю відповідальність бере на себе Controller. Він не лише обробляє події, а й вручну оновлює UI після кожної зміни Model. Це повертає нас до проблеми God Object.
Проблема 3: Тестування View. View у MVC містить логіку відображення (як перетворити дані Model у візуальні елементи). Ця логіка не тестується без запуску UI-фреймворку.
Приклад "товстого" Controller у JavaFX MVC:
public class AudiobookListController {
@FXML private TableView<Audiobook> audiobookTable;
private AudiobookRepository repository;
@FXML
private void onDeleteClicked() {
Audiobook selected = audiobookTable.getSelectionModel().getSelectedItem();
// Оновлення Model
repository.delete(selected.getId());
// Ручне оновлення View (Model не сповіщає View автоматично)
audiobookTable.getItems().remove(selected);
// Оновлення статус-бару
statusLabel.setText("Deleted: " + selected.getTitle());
// Оновлення стану кнопок
deleteButton.setDisable(true);
editButton.setDisable(true);
}
}
Controller не лише викликає repository.delete(), а й вручну видаляє елемент з TableView, оновлює статус-бар та вимикає кнопки. Це змішування відповідальностей.
Переваги MVC
Незважаючи на проблеми адаптації, MVC приніс революційні ідеї:
- Розділення відповідальностей: Дані (Model), відображення (View) та логіка управління (Controller) знаходяться в різних класах.
- Повторне використання Model: Один Model може мати кілька View (таблиця, графік, експорт у CSV).
- Незалежне тестування Model: Бізнес-логіку можна тестувати без UI.
Недоліки MVC для desktop-додатків
- Складність Observer Pattern: Ручна реалізація підписки View на Model вимагає багато коду.
- "Товстий" Controller: Він стає центром, що знає про все — і про View, і про Model.
- Важко тестувати Controller: Він залежить від UI-компонентів JavaFX.
Model-View-Presenter (MVP): Пасивний View
Model-View-Presenter з'явився у 1990-х роках як еволюція MVC для desktop-додатків. Його розробили інженери компанії Taligent (спільне підприємство IBM, Apple та HP) для фреймворку CommonPoint. Ключова ідея MVP — зробити View максимально "дурним", позбавивши його будь-якої логіки.
Три компоненти MVP
Model залишається незмінним — це бізнес-логіка та дані, незалежні від UI.
View стає пасивним — він не знає про Model, не підписується на його зміни, не містить логіки відображення. View — це лише набір методів для маніпуляції UI-елементами: setTitle(String), showError(String), getSelectedItem(). View реалізує інтерфейс, що визначає ці методи.
Presenter — це "розумний" посередник, що містить всю презентаційну логіку. Він отримує події від View, викликає методи Model, та оновлює View через його інтерфейс. Presenter не знає про конкретну реалізацію View (JavaFX, Swing, веб) — він працює лише з інтерфейсом.
Потік даних у MVP
- Користувач взаємодіє з View (натискає кнопку).
- View викликає метод Presenter (наприклад,
presenter.onDeleteClicked()). - Presenter оновлює Model (викликає
repository.delete(id)). - Presenter оновлює View через інтерфейс (
view.removeItem(audiobook),view.showMessage("Deleted")).
Ключова відмінність від MVC: View не спостерігає за Model. Presenter повністю контролює, коли і як оновлюється View.
Приклад MVP у JavaFX
Інтерфейс View:
public interface IAudiobookListView {
void setAudiobooks(List<Audiobook> audiobooks);
void removeAudiobook(Audiobook audiobook);
void showError(String message);
void showSuccess(String message);
void setDeleteButtonEnabled(boolean enabled);
Audiobook getSelectedAudiobook();
}
Реалізація View (JavaFX Controller):
public class AudiobookListView implements IAudiobookListView {
@FXML private TableView<Audiobook> audiobookTable;
@FXML private Button deleteButton;
@FXML private Label statusLabel;
private AudiobookListPresenter presenter;
@FXML
public void initialize() {
// Створення Presenter (у реальному додатку через DI)
AudiobookRepository repository = new JdbcAudiobookRepository(dataSource);
presenter = new AudiobookListPresenter(this, repository);
// Завантаження даних
presenter.loadAudiobooks();
// Делегування подій до Presenter
audiobookTable.getSelectionModel().selectedItemProperty().addListener(
(obs, old, newVal) -> presenter.onSelectionChanged(newVal)
);
}
@FXML
private void onDeleteClicked() {
presenter.onDeleteClicked();
}
// Реалізація інтерфейсу IAudiobookListView
@Override
public void setAudiobooks(List<Audiobook> audiobooks) {
audiobookTable.getItems().setAll(audiobooks);
}
@Override
public void removeAudiobook(Audiobook audiobook) {
audiobookTable.getItems().remove(audiobook);
}
@Override
public void showError(String message) {
statusLabel.setText("Error: " + message);
statusLabel.setStyle("-fx-text-fill: red;");
}
@Override
public void showSuccess(String message) {
statusLabel.setText(message);
statusLabel.setStyle("-fx-text-fill: green;");
}
@Override
public void setDeleteButtonEnabled(boolean enabled) {
deleteButton.setDisable(!enabled);
}
@Override
public Audiobook getSelectedAudiobook() {
return audiobookTable.getSelectionModel().getSelectedItem();
}
}
Presenter:
public class AudiobookListPresenter {
private final IAudiobookListView view;
private final AudiobookRepository repository;
public AudiobookListPresenter(IAudiobookListView view, AudiobookRepository repository) {
this.view = view;
this.repository = repository;
}
public void loadAudiobooks() {
try {
List<Audiobook> audiobooks = repository.findAll();
view.setAudiobooks(audiobooks);
} catch (DataAccessException e) {
view.showError("Failed to load audiobooks: " + e.getMessage());
}
}
public void onDeleteClicked() {
Audiobook selected = view.getSelectedAudiobook();
if (selected == null) {
view.showError("No audiobook selected");
return;
}
try {
repository.delete(selected.getId());
view.removeAudiobook(selected);
view.showSuccess("Deleted: " + selected.getTitle());
view.setDeleteButtonEnabled(false);
} catch (DataAccessException e) {
view.showError("Failed to delete: " + e.getMessage());
}
}
public void onSelectionChanged(Audiobook selected) {
view.setDeleteButtonEnabled(selected != null);
}
}
Переваги MVP
Тестованість Presenter: Це найбільша перевага MVP. Presenter не залежить від JavaFX — він працює з інтерфейсом IAudiobookListView. У тестах ви можете створити mock-реалізацію цього інтерфейсу:
@Test
void shouldDeleteSelectedAudiobook() {
// Given
IAudiobookListView mockView = mock(IAudiobookListView.class);
AudiobookRepository mockRepo = mock(AudiobookRepository.class);
AudiobookListPresenter presenter = new AudiobookListPresenter(mockView, mockRepo);
Audiobook audiobook = new Audiobook("1984", orwell, dystopian, 360);
when(mockView.getSelectedAudiobook()).thenReturn(audiobook);
// When
presenter.onDeleteClicked();
// Then
verify(mockRepo).delete(audiobook.getId());
verify(mockView).removeAudiobook(audiobook);
verify(mockView).showSuccess(contains("Deleted"));
}
Повне розділення View та логіки: View стає "дурним" — він не містить жодної логіки, лише маніпуляції UI-елементами. Всю логіку можна протестувати без запуску JavaFX.
Можливість заміни View: Оскільки Presenter працює з інтерфейсом, ви можете створити різні реалізації View (JavaFX, Swing, веб) без зміни Presenter.
Недоліки MVP
Величезна кількість boilerplate-коду: Для кожної взаємодії між Presenter та View потрібен метод в інтерфейсі. Якщо у вас 20 UI-елементів, інтерфейс IView матиме 40-60 методів (getters та setters для кожного).
Ручне оновлення View: Presenter вручну викликає методи View після кожної зміни. Якщо ви забудете викликати view.setDeleteButtonEnabled(false), кнопка залишиться активною. Немає автоматичної синхронізації.
Складність при багатьох залежностях: Якщо View залежить від кількох Presenter (наприклад, головний екран + бічна панель), управління цими залежностями стає складним.
Model-View-ViewModel (MVVM): Реактивність через Bindings
Model-View-ViewModel з'явився у 2005 році в Microsoft як частина Windows Presentation Foundation (WPF). Його автор, John Gossman, шукав спосіб використати потужну систему Data Binding у WPF для автоматичної синхронізації UI з даними.
MVVM — це еволюція MVP, що вирішує проблему ручного оновлення View через реактивні Properties та Bindings. Замість того, щоб Presenter вручну викликав методи View, ViewModel експонує Properties, до яких View підключається через Bindings. Коли Property змінюється, View автоматично оновлюється.
Три компоненти MVVM
Model залишається незмінним — бізнес-логіка та дані.
View — це UI-розмітка (FXML у JavaFX, XAML у WPF) та мінімальний Controller, що лише ініціалізує Bindings. View не містить логіки — лише декларативні зв'язки з ViewModel.
ViewModel — це адаптер між Model та View, що містить презентаційну логіку та стан UI у вигляді Properties. ViewModel не знає про View (на відміну від Presenter у MVP) — він лише експонує Properties, а View сам підключається до них.
Ключова відмінність: Bindings замість методів
У MVP Presenter викликає методи View:
view.setTitle("New Title");
view.setDeleteButtonEnabled(false);
У MVVM ViewModel змінює Properties, а View автоматично оновлюється через Bindings:
// ViewModel
titleProperty.set("New Title");
deleteButtonEnabledProperty.set(false);
// View (FXML або код)
label.textProperty().bind(viewModel.titleProperty());
deleteButton.disableProperty().bind(viewModel.deleteButtonEnabledProperty().not());
Це фундаментальна зміна парадигми: замість імперативного оновлення ("встанови значення X") ми використовуємо декларативний підхід ("Label завжди відображає titleProperty").
Потік даних у MVVM
- Користувач взаємодіє з View (натискає кнопку).
- View викликає метод ViewModel через Command або обробник події.
- ViewModel оновлює Model (викликає
repository.delete(id)). - ViewModel оновлює свої Properties (
selectedAudiobookProperty.set(null)). - View автоматично оновлюється через Bindings (кнопка стає неактивною, статус-бар змінюється).
Ключова відмінність: ViewModel не викликає методи View. Він лише змінює свої Properties, а View реагує автоматично.
Приклад MVVM у JavaFX
ViewModel:
public class AudiobookListViewModel {
// Properties для UI
private final ObservableList<AudiobookViewModel> audiobooks = FXCollections.observableArrayList();
private final ObjectProperty<AudiobookViewModel> selectedAudiobook = new SimpleObjectProperty<>();
private final StringProperty statusMessage = new SimpleStringProperty("");
private final BooleanProperty isLoading = new SimpleBooleanProperty(false);
// Computed Properties
private final BooleanBinding deleteButtonEnabled;
// Залежності
private final AudiobookRepository repository;
public AudiobookListViewModel(AudiobookRepository repository) {
this.repository = repository;
// Кнопка Delete активна лише коли щось обрано
deleteButtonEnabled = selectedAudiobook.isNotNull();
}
public void loadAudiobooks() {
isLoading.set(true);
// У реальному додатку це буде асинхронно через Task
try {
List<Audiobook> result = repository.findAll();
audiobooks.setAll(result.stream()
.map(AudiobookViewModel::new)
.collect(Collectors.toList()));
statusMessage.set("Loaded " + result.size() + " audiobooks");
} catch (DataAccessException e) {
statusMessage.set("Error: " + e.getMessage());
} finally {
isLoading.set(false);
}
}
public void deleteSelected() {
AudiobookViewModel selected = selectedAudiobook.get();
if (selected == null) return;
try {
repository.delete(selected.getAudiobook().getId());
audiobooks.remove(selected);
selectedAudiobook.set(null);
statusMessage.set("Deleted: " + selected.getTitle());
} catch (DataAccessException e) {
statusMessage.set("Error: " + e.getMessage());
}
}
// Getters для Properties
public ObservableList<AudiobookViewModel> getAudiobooks() { return audiobooks; }
public ObjectProperty<AudiobookViewModel> selectedAudiobookProperty() { return selectedAudiobook; }
public StringProperty statusMessageProperty() { return statusMessage; }
public BooleanProperty isLoadingProperty() { return isLoading; }
public BooleanBinding deleteButtonEnabledProperty() { return deleteButtonEnabled; }
}
AudiobookViewModel (обгортка над Domain Model):
public class AudiobookViewModel {
private final Audiobook audiobook;
private final StringProperty title;
private final StringProperty authorName;
private final StringProperty formattedDuration;
public AudiobookViewModel(Audiobook audiobook) {
this.audiobook = audiobook;
this.title = new SimpleStringProperty(audiobook.getTitle());
this.authorName = new SimpleStringProperty(audiobook.getAuthor().getFullName());
this.formattedDuration = new SimpleStringProperty(formatDuration(audiobook.getDuration()));
}
private String formatDuration(int minutes) {
int hours = minutes / 60;
int mins = minutes % 60;
return String.format("%dh %dm", hours, mins);
}
// Properties для TableView
public StringProperty titleProperty() { return title; }
public StringProperty authorNameProperty() { return authorName; }
public StringProperty formattedDurationProperty() { return formattedDuration; }
// Getters
public String getTitle() { return title.get(); }
public Audiobook getAudiobook() { return audiobook; }
}
Controller (мінімальний, лише Bindings):
public class AudiobookListController {
@FXML private TableView<AudiobookViewModel> audiobookTable;
@FXML private TableColumn<AudiobookViewModel, String> titleColumn;
@FXML private TableColumn<AudiobookViewModel, String> authorColumn;
@FXML private TableColumn<AudiobookViewModel, String> durationColumn;
@FXML private Button deleteButton;
@FXML private Label statusLabel;
@FXML private ProgressIndicator loadingIndicator;
private AudiobookListViewModel viewModel;
@FXML
public void initialize() {
// Створення ViewModel (у реальному додатку через Guice)
AudiobookRepository repository = new JdbcAudiobookRepository(dataSource);
viewModel = new AudiobookListViewModel(repository);
// Налаштування колонок
titleColumn.setCellValueFactory(cellData -> cellData.getValue().titleProperty());
authorColumn.setCellValueFactory(cellData -> cellData.getValue().authorNameProperty());
durationColumn.setCellValueFactory(cellData -> cellData.getValue().formattedDurationProperty());
// Bindings: ViewModel → View
audiobookTable.setItems(viewModel.getAudiobooks());
audiobookTable.getSelectionModel().selectedItemProperty()
.bindBidirectional(viewModel.selectedAudiobookProperty());
statusLabel.textProperty().bind(viewModel.statusMessageProperty());
deleteButton.disableProperty().bind(viewModel.deleteButtonEnabledProperty().not());
loadingIndicator.visibleProperty().bind(viewModel.isLoadingProperty());
// Завантаження даних
viewModel.loadAudiobooks();
}
@FXML
private void onDeleteClicked() {
viewModel.deleteSelected();
}
}
Зверніть увагу: Controller містить лише ініціалізацію Bindings та делегування подій до ViewModel. Вся логіка (валідація, форматування, стан кнопок) знаходиться у ViewModel. Жодного ручного оновлення UI — все через Bindings.
Переваги MVVM
Автоматична синхронізація: Найбільша перевага MVVM. Ви змінюєте Property у ViewModel → UI оновлюється автоматично. Немає ризику забути оновити якийсь елемент.
Тестованість без UI: ViewModel — це POJO з Properties. Його можна тестувати як звичайний Java-клас:
@Test
void shouldDisableDeleteButtonWhenNothingSelected() {
// Given
AudiobookListViewModel viewModel = new AudiobookListViewModel(mockRepository);
// When
viewModel.selectedAudiobookProperty().set(null);
// Then
assertFalse(viewModel.deleteButtonEnabledProperty().get());
}
Менше boilerplate-коду: На відміну від MVP, не потрібно створювати інтерфейс з десятками методів. ViewModel просто експонує Properties.
Декларативний підхід: Bindings у Controller читаються як специфікація: "deleteButton завжди неактивна, коли selectedAudiobook == null". Це самодокументований код.
Природна інтеграція з JavaFX: JavaFX Properties та Bindings створені саме для MVVM. Це не адаптація патерну, а його природна реалізація.
Недоліки MVVM
Складність налагодження Bindings: Кожне з 20 Bindings важко відстежити, чому конкретний UI-елемент оновився. Немає явного виклику методу, на який можна поставити breakpoint.
Overhead Properties: Кожне поле ViewModel має бути Property, що додає трохи overhead у пам'яті та продуктивності (хоча на практиці це рідко є проблеми).
Крива навчання: Розуміння Properties, Bindings, ObservableList вимагає часу. Для початківців це може бути складніше, ніж прямі виклики методів у MVP.
Порівняльна таблиця: MVC vs MVP vs MVVM
Тепер, коли ми розглянули всі три патерни, порівняємо їх за ключовими критеріями:
| Критерій | MVC | MVP | MVVM |
|---|---|---|---|
| View знає про Model? | ✅ Так (спостерігає через Observer) | ❌ Ні (знає лише про Presenter) | ❌ Ні (знає лише про ViewModel) |
| Оновлення View | Автоматичне (Observer Pattern) | Ручне (Presenter викликає методи) | Автоматичне (Data Binding) |
| Тестованість логіки | ⚠️ Середня (Controller залежить від View) | ✅ Висока (Presenter не залежить від UI) | ✅ Висока (ViewModel не залежить від UI) |
| Boilerplate-код | ⚠️ Середній (Observer Pattern) | ❌ Високий (інтерфейс IView з багатьма методами) | ✅ Низький (Properties + Bindings) |
| Складність розуміння | ⚠️ Середня | ✅ Проста (явні виклики методів) | ⚠️ Середня (потрібно розуміти Bindings) |
| Підходить для JavaFX? | ⚠️ Так, але з адаптацією | ✅ Так, але багато коду | ✅✅ Ідеально (Properties вбудовані) |
| Повторне використання логіки | ⚠️ Середнє | ✅ Високе (Presenter незалежний) | ✅ Високе (ViewModel незалежний) |
| Підтримка складних UI | ❌ Погана (Controller розростається) | ⚠️ Середня (багато методів у IView) | ✅ Добра (Bindings масштабуються) |
MVC: Класичний підхід
Коли використовувати:
- Веб-додатки (Spring MVC, Rails)
- Прості desktop-додатки з мінімальною логікою
Переваги:
- Історично перший патерн розділення відповідальностей
- Добре підходить для server-side rendering
Недоліки:
- Складність Observer Pattern у desktop
- "Товстий" Controller у JavaFX
MVP: Пасивний View
Коли використовувати:
- Коли потрібна максимальна тестованість
- Коли View може мати кілька реалізацій (JavaFX, Swing, веб)
- Legacy-проєкти без Properties
Переваги:
- Повна тестованість Presenter
- Явний контроль над оновленням View
Недоліки:
- Величезна кількість boilerplate-коду
- Ручне оновлення View
MVVM: Реактивність
Коли використовувати:
- JavaFX-додатки (природна інтеграція)
- WPF, Android (з LiveData/StateFlow)
- Будь-які UI-фреймворки з Data Binding
Переваги:
- Автоматична синхронізація через Bindings
- Мінімум boilerplate-коду
- Висока тестованість
Недоліки:
- Потрібно розуміти Properties та Bindings
- Складність налагодження Bindings
Чому MVVM для JavaFX: Технічне обґрунтування
Після порівняння трьох патернів стає очевидним: MVVM — це природний вибір для JavaFX. Але чому саме? Розглянемо технічні причини.
JavaFX Properties — готова інфраструктура для MVVM
JavaFX Properties — це не просто обгортки над значеннями. Це повноцінна реактивна система, що була розроблена саме для підтримки MVVM-архітектури. Коли ви використовуєте Properties у ViewModel, ви не адаптуєте патерн до фреймворку — ви використовуєте фреймворк так, як він був задуманий.
Порівняння з іншими фреймворками:
- Swing: Немає вбудованих Properties. Для MVVM потрібно вручну реалізовувати Observer Pattern або використовувати сторонні бібліотеки (Beans Binding). MVP — природніший вибір.
- Android (до Architecture Components): Немає вбудованого Data Binding. MVP був стандартом до появи LiveData та ViewModel.
- WPF: Має DependencyProperty та INotifyPropertyChanged — аналоги JavaFX Properties. MVVM — стандарт для WPF.
- JavaFX: Має Properties, Bindings, ObservableList — все для MVVM "з коробки".
FXML + ViewModel: Розділення UI та логіки
FXML дозволяє описати структуру UI декларативно, а ViewModel містить всю логіку. Controller стає тонким шаром, що лише з'єднує їх через Bindings. Це ідеальне розділення відповідальностей:
- FXML: Що відображається (структура UI).
- ViewModel: Що відбувається (логіка, стан).
- Controller: Як вони з'єднані (Bindings).
У MVP Controller містить логіку оновлення View, що змішує відповідальності. У MVVM Controller — це лише "клей".
Тестування без JavaFX Application Thread
ViewModel — це POJO з Properties. Його можна тестувати як звичайний Java-клас, без запуску JavaFX Application Thread:
@Test
void shouldLoadAudiobooks() {
// Given
AudiobookRepository mockRepo = mock(AudiobookRepository.class);
when(mockRepo.findAll()).thenReturn(List.of(audiobook1, audiobook2));
AudiobookListViewModel viewModel = new AudiobookListViewModel(mockRepo);
// When
viewModel.loadAudiobooks();
// Then
assertEquals(2, viewModel.getAudiobooks().size());
assertEquals("Loaded 2 audiobooks", viewModel.statusMessageProperty().get());
}
Це unit-тест, що виконується за мілісекунди. У MVP ви тестуєте Presenter, але він викликає методи IView, які потрібно мокати. У MVVM ви просто перевіряєте Properties — жодних моків View.
Масштабованість: Складні UI без спагетті-коду
У складних UI (наприклад, форма з 20 полями та валідацією) MVVM масштабується краще:
MVP: Інтерфейс IView матиме 40-60 методів (getter/setter для кожного поля). Presenter викликатиме ці методи після кожної зміни. Код стає важким для читання.
MVVM: ViewModel має 20 Properties. Controller ініціалізує 20 Bindings. Вся валідація та обчислення — у ViewModel через Computed Bindings. Код залишається читабельним.
Інтеграція з Guice: Ін'єкція залежностей
У наступній статті ми інтегруємо MVVM з Google Guice. ViewModel отримуватиме Repository через конструктор, Controller отримуватиме ViewModel через Guice. Это природно працює з MVVM, оскільки ViewModel — це звичайний клас без залежності від UI.
У MVP Presenter також отримує залежності через конструктор, але потребує IView — інтерфейс, що ускладнює ін'єкцію (потрібен custom ControllerFactory).
Практичні завдання
Рівень 1: Розпізнавання патернів
Завдання 1.1: Визначити патерн
Для кожного фрагмента коду визначте, який патерн використовується (MVC, MVP, MVVM):
// Фрагмент A
public class UserController {
@FXML private TextField nameField;
private UserPresenter presenter;
@FXML
public void initialize() {
presenter = new UserPresenter(this);
}
public void setName(String name) {
nameField.setText(name);
}
}
// Фрагмент B
public class UserViewModel {
private final StringProperty name = new SimpleStringProperty();
public StringProperty nameProperty() { return name; }
}
public class UserController {
@FXML private TextField nameField;
private UserViewModel viewModel;
@FXML
public void initialize() {
nameField.textProperty().bind(viewModel.nameProperty());
}
}
Завдання 1.2: Виправити порушення патерну
Наступний код стверджує, що використовує MVVM, але порушує принципи патерну. Знайдіть порушення:
public class ProductViewModel {
private final TableView<Product> productTable; // Порушення?
public ProductViewModel(TableView<Product> table) {
this.productTable = table;
}
public void loadProducts() {
List<Product> products = repository.findAll();
productTable.getItems().setAll(products); // Порушення?
}
}
Рівень 2: Рефакторинг між патернами
Завдання 2.1: Від God Object до MVP
Рефакторте наступний God Object Controller на MVP-архітектуру:
public class BookListController {
@FXML private TableView<Book> bookTable;
@FXML private Button deleteButton;
private Connection connection;
@FXML
public void initialize() {
connection = DriverManager.getConnection("jdbc:h2:./books", "sa", "");
loadBooks();
}
private void loadBooks() {
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery("SELECT * FROM books")) {
while (rs.next()) {
Book book = new Book(rs.getString("title"), rs.getString("author"));
bookTable.getItems().add(book);
}
} catch (SQLException e) {
showError(e.getMessage());
}
}
@FXML
private void onDeleteClicked() {
Book selected = bookTable.getSelectionModel().getSelectedItem();
// ... SQL DELETE
bookTable.getItems().remove(selected);
}
}
Створіть: IBookListView, BookListPresenter, рефакторений BookListController.
Завдання 2.2: Від MVP до MVVM
Рефакторте MVP-код на MVVM з використанням Properties та Bindings:
// MVP
public interface IProductView {
void setProducts(List<Product> products);
void setTotalPrice(double price);
void showError(String message);
}
public class ProductPresenter {
private final IProductView view;
public void loadProducts() {
List<Product> products = repository.findAll();
view.setProducts(products);
double total = products.stream().mapToDouble(Product::getPrice).sum();
view.setTotalPrice(total);
}
}
Створіть ProductViewModel з Properties та покажіть, як Controller підключається через Bindings.
Завдання 2.3: Порівняти кількість коду
Реалізуйте один і той самий функціонал (форма з 5 полями, валідація, кнопка Save) у MVP та MVVM. Порахуйте кількість рядків коду у кожному підході. Який підхід вимагає менше коду?
Рівень 3: Проектування архітектури
Завдання 3.1: Спроектувати MVVM для складного екрану
Спроектуйте MVVM-архітектуру для екрану "Order Management":
- Список замовлень (TableView)
- Фільтрація за статусом (ComboBox: All, Pending, Completed, Cancelled)
- Пошук за номером замовлення (TextField)
- Панель деталей обраного замовлення (справа)
- Кнопки: "Mark as Completed", "Cancel Order", "Refresh"
Створіть структуру класів: OrderViewModel, OrderListViewModel, які Properties потрібні, які Bindings у Controller.
Завдання 3.2: Обґрунтувати вибір патерну
Для кожного сценарію обґрунтуйте вибір патерну (MVC, MVP, MVVM):
- Веб-додаток на Spring Boot з server-side rendering (Thymeleaf).
- Android-додаток з мінімальними вимогами до тестування.
- JavaFX-додаток для медичної системи з високими вимогами до тестування.
- Legacy Swing-додаток, що потребує рефакторингу.
- Новий JavaFX-додаток з нуля з планами на довгострокову підтримку.
Завдання 3.3: Міграція з MVC на MVVM
У вас є існуючий JavaFX-додаток з MVC-архітектурою (10 екранів, 3000 рядків коду у Controllers). Створіть план міграції на MVVM:
- Які екрани мігрувати першими?
- Як забезпечити, щоб додаток працював під час міграції?
- Які метрики використати для оцінки успішності міграції?
- Скільки часу це займе (оцінка)?
Підсумок
У цій статті ми простежимо еволюцію архітектурних патернів UI протягом майже 50 років — від Model-View-Controller (1979) через Model-View-Presenter (1990-ті) до Model-View-ViewModel (2005).
Model-View-Controller був революційним для свого часу, запровадивши ідею розділення даних (Model), відображення (View) та управління (Controller). Але його адаптація до desktop-додатків виявила проблеми: складність Observer Pattern, "товсті" Controllers, важкість тестування.
Model-View-Presenter вирішив проблему тестування, зробивши View пасивним та винісши всю логіку у Presenter. Це дозволило тестувати Presenter без UI, але призвело до величезної кількості boilerplate-коду: інтерфейси з десятками методів, ручне оновлення View після кожної зміни.
Model-View-ViewModel об'єднав переваги обох підходів: тестованість Presenter з MVP та автоматичну синхронізацію з MVC (але через Bindings, а не Observer Pattern). ViewModel експонує Properties, View підключається до них через Bindings, і вся синхронізація відбувається автоматично.
Для JavaFX MVVM — це не просто рекомендація, а природний вибір. JavaFX Properties, Bindings та ObservableList були розроблені саме для підтримки MVVM. Використовуючи MVVM у JavaFX, ви не адаптуєте патерн до фреймворку — ви використовуєте фреймворк так, як він був задуманий.
Ключові переваги MVVM для JavaFX:
- Автоматична синхронізація UI через Bindings — жодного ручного оновлення.
- Висока тестованість — ViewModel тестується як POJO, без JavaFX Application Thread.
- Мінімум boilerplate-коду — Properties замість інтерфейсів з десятками методів.
- Декларативний підхід — Bindings читаються як специфікація поведінки UI.
- Масштабованість — складні UI залишаються керованими.
У наступних статтях ми детально розглянемо практичну реалізацію MVVM: як побудувати ViewModel (стаття 28), як зв'язати його з View через FXML та Controller (стаття 29), як інтегрувати з Google Guice для автоматичної ін'єкції залежностей (стаття 30), та як тестувати всю цю архітектуру (стаття 33).
MVVM — це не просто патерн. Це філософія побудови UI-додатків, де дані та інтерфейс синхронізуються автоматично, де логіка відділена від відображення, де кожен компонент має чітку відповідальність. І саме ця філософія робить JavaFX-додатки масштабованими, тестованими та підтримуваними.
- Martin Fowler: GUI Architectures — класична стаття про еволюцію UI-патернів.
- Microsoft: Introduction to MVVM — офіційна документація Microsoft про MVVM.
- JavaFX MVVM Pattern — стаття на Baeldung з прикладами.
- Android Architecture Components — як Google реалізував MVVM для Android. ::
Properties та Bindings: Реактивність у JavaFX
Від ручного оновлення UI до автоматичної синхронізації: JavaFX Properties, Change Listeners, Bindings, ObservableList та реактивна парадигма для побудови відгукливих інтерфейсів.
MVVM на практиці: Побудова ViewModel
Від теорії до коду: анатомія ViewModel, Wrapper Pattern для Domain Model, Properties та Commands, асинхронність через Task, lifecycle management та тестування без UI.