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

MVVM на практиці: Побудова ViewModel

Від теорії до коду: анатомія ViewModel, Wrapper Pattern для Domain Model, Properties та Commands, асинхронність через Task, lifecycle management та тестування без UI.

MVVM на практиці: Побудова ViewModel

Вступ: Від теорії до коду

У попередній статті ми дізналися, що MVVM — це природний вибір для JavaFX, і зрозуміли, чому автоматична синхронізація через Bindings перевершує ручне оновлення UI у MVP. Але теорія — це лише половина справи. Справжнє розуміння приходить, коли ви пишете код, стикаєтеся з реальними проблемами та знаходите рішення.

Ця стаття — про практику. Ми покроково побудуємо AudiobookListViewModel для екрану списку аудіокниг, розглянемо кожне рішення, кожну Property, кожен метод. Ми дізнаємося, як саме виглядає ViewModel, які методи він має, які Properties експонує, як він взаємодіє з Repository, як обробляє помилки, як виконує асинхронні операції.

Але перш ніж писати код, потрібно відповісти на фундаментальне питання: що таке ViewModel?

Анатомія ViewModel: Структура та відповідальності

ViewModel — це не просто "клас з Properties". Це адаптер між Domain Model та View, що виконує кілька чітко визначених відповідальностей:

Відповідальність 1: Презентаційна логіка. ViewModel перетворює дані з Domain Model у формат, зручний для відображення. Наприклад, Audiobook.duration (ціле число хвилин) перетворюється у formattedDuration (рядок "3h 15m"). Це не бізнес-логіка (яка належить Model) і не UI-логіка (яка належить View) — це саме презентаційна логіка.

Відповідальність 2: Стан UI. ViewModel зберігає стан, специфічний для UI: який елемент обраний, чи відображається індикатор завантаження, чи є помилка валідації. Domain Model не знає про ці речі — Audiobook не має поля isSelected. Це стан View, але він зберігається у ViewModel, щоб бути тестованим.

Відповідальність 3: Commands (команди). ViewModel містить методи для дій користувача: loadAudiobooks(), deleteSelected(), addToCollection(). Ці методи викликаються з Controller при натисканні кнопок або інших подіях. Вони інкапсулюють логіку дії: валідацію, виклик Repository, оновлення Properties.

Відповідальність 4: Координація. ViewModel координує взаємодію між кількома Repository або Service. Наприклад, при видаленні аудіокниги він може викликати audiobookRepository.delete() та collectionRepository.removeFromAllCollections(). Domain Model не знає про цю координацію — кожен Repository працює зі своєю сутністю.

Відповідальність 5: Обробка помилок. ViewModel перехоплює винятки з Repository, перетворює їх у зрозумілі повідомлення для користувача та встановлює відповідні Properties (errorMessageProperty). View просто відображає ці Properties — він не знає, що сталася помилка JDBC або мережева помилка.

ViewModel ≠ Model. Це критично важливо розуміти. Domain Model (Audiobook, Author) — це сутності бізнес-логіки, незалежні від UI. ViewModel (AudiobookViewModel, AudiobookListViewModel) — це адаптери для конкретного екрану, що містять презентаційну логіку та стан UI. Один Domain Model може мати кілька ViewModel для різних екранів.

Що ViewModel НЕ робить

Так само важливо розуміти, що не є відповідальністю ViewModel:

ViewModel не знає про JavaFX-компоненти. Він не містить посилань на TableView, Button, Label. Він експонує Properties, а View сам підключається до них. Це робить ViewModel тестованим без JavaFX Application Thread.

ViewModel не містить бізнес-логіки. Валідація бізнес-правил (наприклад, "тривалість аудіокниги не може бути від'ємною") належить Domain Model або Service. ViewModel лише викликає ці методи та обробляє результат.

ViewModel не працює безпосередньо з базою даних. Він викликає Repository, а не виконує SQL-запити. Це розділення дозволяє легко замінити реалізацію Repository (наприклад, з JDBC на JPA) без зміни ViewModel.

ViewModel не керує навігацією. Відкриття нового екрану або діалогу — це відповідальність Navigator (окремого компонента, який ми розглянемо у статті 32). ViewModel може сигналізувати про необхідність навігації через Property або Event, але не викликає Stage.show() безпосередньо.


Wrapper Pattern: AudiobookViewModel як обгортка

Перш ніж будувати AudiobookListViewModel (який керує списком), розглянемо AudiobookViewModel — обгортку над одним об'єктом Audiobook. Це фундаментальний патерн у MVVM: Domain Model обгортається у ViewModel, що експонує Properties для UI.

Чому не використовувати Audiobook безпосередньо?

Припустимо, у нас є Domain Model:

public class Audiobook {
    private final UUID id;
    private final String title;
    private final Author author;
    private final Genre genre;
    private final int duration; // У хвилинах
    private final int releaseYear;
    
    // Constructor, getters
}

Чому ми не можемо використати його безпосередньо у TableView? Кілька причин:

Проблема 1: Немає Properties. TableView працює з Properties для автоматичного оновлення. Audiobook.title — це звичайне поле, а TableColumn очікує StringProperty.

Проблема 2: Формат даних. duration — це ціле число хвилин (360). Але у таблиці ми хочемо показати "6h 0m". Де виконувати це форматування? У Domain Model? Ні — це презентаційна логіка.

Проблема 3: Вкладені об'єкти. author — це об'єкт Author з полями firstName та lastName. Але у таблиці ми хочемо показати "George Orwell" (повне ім'я). TableColumn не може автоматично витягти це з вкладеного об'єкта.

Проблема 4: Стан UI. Якщо ми хочемо зберігати, чи обрана аудіокнига, чи є помилка валідації, де це зберігати? У Domain Model? Ні — це стан View.

Рішення: Wrapper ViewModel

Створюємо AudiobookViewModel, що обгортає Audiobook та експонує Properties:

public class AudiobookViewModel {
    
    private final Audiobook audiobook; // Оригінальний Domain Model
    
    // Properties для UI
    private final StringProperty title;
    private final StringProperty authorName;
    private final StringProperty genreName;
    private final StringProperty formattedDuration;
    private final IntegerProperty releaseYear;
    
    // Стан UI (не належить Domain Model)
    private final BooleanProperty selected = new SimpleBooleanProperty(false);
    
    public AudiobookViewModel(Audiobook audiobook) {
        this.audiobook = audiobook;
        
        // Ініціалізація Properties з даних Domain Model
        this.title = new SimpleStringProperty(audiobook.getTitle());
        this.authorName = new SimpleStringProperty(audiobook.getAuthor().getFullName());
        this.genreName = new SimpleStringProperty(audiobook.getGenre().getName());
        this.formattedDuration = new SimpleStringProperty(formatDuration(audiobook.getDuration()));
        this.releaseYear = new SimpleIntegerProperty(audiobook.getReleaseYear());
    }
    
    private String formatDuration(int minutes) {
        int hours = minutes / 60;
        int mins = minutes % 60;
        return String.format("%dh %dm", hours, mins);
    }
    
    // Getters для Properties (для TableView)
    public StringProperty titleProperty() { return title; }
    public StringProperty authorNameProperty() { return authorName; }
    public StringProperty genreNameProperty() { return genreName; }
    public StringProperty formattedDurationProperty() { return formattedDuration; }
    public IntegerProperty releaseYearProperty() { return releaseYear; }
    public BooleanProperty selectedProperty() { return selected; }
    
    // Getters для значень (для зручності)
    public String getTitle() { return title.get(); }
    public String getAuthorName() { return authorName.get(); }
    public int getReleaseYear() { return releaseYear.get(); }
    
    // Доступ до оригінального Domain Model (для передачі у Repository)
    public Audiobook getAudiobook() { return audiobook; }
    public UUID getId() { return audiobook.getId(); }
}

Розбір коду: Анатомія Wrapper ViewModel

Рядок 3: Зберігання оригінального Domain Model. Ми зберігаємо посилання на Audiobook, щоб мати доступ до оригінальних даних. Це потрібно, коли ми передаємо об'єкт у Repository (наприклад, repository.update(audiobook)).

Рядки 6-10: Properties для відображення. Кожне поле, що відображається у UI, стає Property. Це дозволяє TableView автоматично оновлюватися при зміні даних.

Рядок 13: Стан UI. selected — це стан, специфічний для UI. Domain Model не знає, чи обрана аудіокнига у таблиці. Це стан View, але ми зберігаємо його у ViewModel, щоб він був тестованим.

Рядки 15-24: Ініціалізація Properties. У конструкторі ми витягуємо дані з Domain Model та ініціалізуємо Properties. Зверніть увагу на formatDuration() — це презентаційна логіка, що перетворює хвилини у читабельний формат.

Рядки 26-30: Форматування. Метод formatDuration() — приклад презентаційної логіки. Він не належить Domain Model (бо це не бізнес-правило) і не належить View (бо це не UI-код). Він належить ViewModel.

Рядки 33-38: Getters для Properties. Ці методи потрібні для TableColumn.setCellValueFactory(). TableView викликає titleProperty() для кожного рядка та підключається до Property через Binding.

Рядки 41-44: Getters для значень. Це зручні методи для отримання поточного значення без виклику .get(). Наприклад, viewModel.getTitle() замість viewModel.titleProperty().get().

Рядки 47-48: Доступ до Domain Model. Коли потрібно передати об'єкт у Repository, ми викликаємо getAudiobook(). Це дозволяє Repository працювати з Domain Model, не знаючи про ViewModel.

Двостороння синхронізація: Редагування даних

У прикладі вище Properties ініціалізуються один раз у конструкторі. Але що, якщо користувач редагує дані? Наприклад, у формі редагування аудіокниги користувач змінює назву. Як синхронізувати зміни між ViewModel та Domain Model?

Підхід 1: Immutable Domain Model + створення нового об'єкта.

public class AudiobookViewModel {
    private Audiobook audiobook;
    private final StringProperty title;
    
    public AudiobookViewModel(Audiobook audiobook) {
        this.audiobook = audiobook;
        this.title = new SimpleStringProperty(audiobook.getTitle());
        
        // Listener: при зміні Property оновлюємо Domain Model
        title.addListener((obs, old, newVal) -> {
            this.audiobook = new Audiobook(
                audiobook.getId(),
                newVal, // Нова назва
                audiobook.getAuthor(),
                audiobook.getGenre(),
                audiobook.getDuration(),
                audiobook.getReleaseYear()
            );
        });
    }
}

Переваги: Domain Model залишається immutable (безпечний для багатопоточності). Недоліки: Створення нового об'єкта при кожній зміні — overhead.

Підхід 2: Mutable Domain Model + пряме оновлення.

public class AudiobookViewModel {
    private final Audiobook audiobook; // Mutable
    private final StringProperty title;
    
    public AudiobookViewModel(Audiobook audiobook) {
        this.audiobook = audiobook;
        this.title = new SimpleStringProperty(audiobook.getTitle());
        
        title.addListener((obs, old, newVal) -> {
            audiobook.setTitle(newVal); // Пряме оновлення
        });
    }
}

Переваги: Простота, немає overhead. Недоліки: Domain Model стає mutable, що може призвести до проблем у багатопоточному середовищі.

Підхід 3: Відкладене оновлення (рекомендований).

public class AudiobookViewModel {
    private final Audiobook audiobook;
    private final StringProperty title;
    
    public AudiobookViewModel(Audiobook audiobook) {
        this.audiobook = audiobook;
        this.title = new SimpleStringProperty(audiobook.getTitle());
        // Listener НЕ додається — оновлення відбувається при збереженні
    }
    
    public Audiobook toAudiobook() {
        // Створення нового об'єкта з даних Properties
        return new Audiobook(
            audiobook.getId(),
            title.get(),
            audiobook.getAuthor(),
            audiobook.getGenre(),
            audiobook.getDuration(),
            audiobook.getReleaseYear()
        );
    }
}

Використання:

// У ViewModel форми редагування
public void save() {
    Audiobook updated = audiobookViewModel.toAudiobook();
    repository.update(updated);
}

Переваги: Domain Model залишається immutable, оновлення відбувається лише при явному збереженні, легко скасувати зміни (просто не викликати save()). Це рекомендований підхід для форм редагування.

Коли використовувати який підхід:
  • Immutable + Listener: Коли потрібна миттєва синхронізація (наприклад, обчислення загальної суми у кошику).
  • Mutable + Listener: Коли Domain Model вже mutable і немає вимог до багатопоточності.
  • Відкладене оновлення: Для форм редагування, де користувач може скасувати зміни.

Побудова AudiobookListViewModel: Крок за кроком

Тепер, коли ми розуміємо Wrapper Pattern, побудуємо AudiobookListViewModel — ViewModel для екрану зі списком аудіокниг. Цей ViewModel керує колекцією, фільтрацією, вибором елементів та діями користувача.

Крок 1: Визначення вимог

Перш ніж писати код, визначимо, що має робити наш екран:

Функціональність:

  1. Відображати список аудіокниг у таблиці.
  2. Дозволяти обирати аудіокнигу (один елемент).
  3. Фільтрувати список за пошуковим запитом (назва або автор).
  4. Фільтрувати за жанром (ComboBox).
  5. Показувати індикатор завантаження під час завантаження даних.
  6. Показувати повідомлення про помилки.
  7. Показувати статус: "Showing X of Y audiobooks".
  8. Кнопка "Delete" активна лише коли щось обрано.
  9. Кнопка "Refresh" завантажує дані знову.

Properties, які потрібні:

  • ObservableList<AudiobookViewModel> — список аудіокниг для таблиці.
  • ObjectProperty<AudiobookViewModel> — обраний елемент.
  • StringProperty — пошуковий запит.
  • ObjectProperty<Genre> — обраний жанр для фільтрації.
  • BooleanProperty — чи відбувається завантаження.
  • StringProperty — повідомлення про помилку або статус.
  • IntegerProperty — загальна кількість аудіокниг.
  • IntegerProperty — кількість відфільтрованих аудіокниг.

Computed Properties:

  • BooleanBinding — чи активна кнопка Delete (залежить від вибору).

Commands (методи):

  • loadAudiobooks() — завантажити дані з Repository.
  • deleteSelected() — видалити обрану аудіокнигу.
  • refresh() — перезавантажити дані.

Крок 2: Оголошення Properties

public class AudiobookListViewModel {
    
    // Дані
    private final ObservableList<AudiobookViewModel> allAudiobooks = FXCollections.observableArrayList();
    private final FilteredList<AudiobookViewModel> filteredAudiobooks;
    private final SortedList<AudiobookViewModel> sortedAudiobooks;
    
    // Вибір
    private final ObjectProperty<AudiobookViewModel> selectedAudiobook = new SimpleObjectProperty<>();
    
    // Фільтри
    private final StringProperty searchQuery = new SimpleStringProperty("");
    private final ObjectProperty<Genre> selectedGenre = new SimpleObjectProperty<>();
    
    // Стан UI
    private final BooleanProperty isLoading = new SimpleBooleanProperty(false);
    private final StringProperty statusMessage = new SimpleStringProperty("Ready");
    private final StringProperty errorMessage = new SimpleStringProperty();
    
    // Лічильники
    private final IntegerProperty totalCount = new SimpleIntegerProperty(0);
    private final IntegerProperty filteredCount = new SimpleIntegerProperty(0);
    
    // Computed Properties
    private final BooleanBinding deleteButtonEnabled;
    private final BooleanBinding hasError;
    
    // Залежності
    private final AudiobookRepository repository;
    
    // Конструктор буде далі...
}

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

Рядки 4-6: Три рівні колекції. allAudiobooks — оригінальний список з Repository. filteredAudiobooks — обгортка, що показує лише елементи, які відповідають фільтрам. sortedAudiobooks — обгортка над filtered, що підтримує сортування. Саме sortedAudiobooks підключається до TableView.

Рядок 9: Обраний елемент. ObjectProperty<AudiobookViewModel> зберігає поточний вибір. Він синхронізується з TableView.selectionModel через bidirectional binding.

Рядки 12-13: Фільтри. searchQuery та selectedGenre — Properties, до яких підключаються TextField та ComboBox. При зміні цих Properties автоматично оновлюється предикат filteredAudiobooks.

Рядки 16-18: Стан UI. isLoading керує видимістю індикатора завантаження. statusMessage — текст у статус-барі. errorMessage — повідомлення про помилку (якщо не null, показується Alert).

Рядки 21-22: Лічильники. totalCount — кількість всіх аудіокниг. filteredCount — кількість після фільтрації. Використовуються для статус-бару: "Showing 5 of 20 audiobooks".

Рядки 25-26: Computed Properties. deleteButtonEnabled обчислюється на основі selectedAudiobook (активна, якщо щось обрано). hasError обчислюється на основі errorMessage (true, якщо помилка не null).

Рядок 29: Залежності. Repository впроваджується через конструктор (Dependency Injection). У реальному додатку це буде через Guice.

Крок 3: Ініціалізація у конструкторі

public AudiobookListViewModel(AudiobookRepository repository) {
    this.repository = repository;
    
    // Ініціалізація FilteredList
    filteredAudiobooks = new FilteredList<>(allAudiobooks, p -> true);
    
    // Оновлення предикату при зміні фільтрів
    searchQuery.addListener((obs, old, newVal) -> updatePredicate());
    selectedGenre.addListener((obs, old, newVal) -> updatePredicate());
    
    // Ініціалізація SortedList
    sortedAudiobooks = new SortedList<>(filteredAudiobooks);
    
    // Bindings для лічильників
    totalCount.bind(Bindings.size(allAudiobooks));
    filteredCount.bind(Bindings.size(filteredAudiobooks));
    
    // Computed Properties
    deleteButtonEnabled = selectedAudiobook.isNotNull();
    hasError = errorMessage.isNotNull().and(errorMessage.isNotEmpty());
}

Розбір ініціалізації:

Рядок 5: FilteredList з предикатом "показати все". Початковий предикат p -> true означає, що всі елементи видимі. Пізніше ми оновимо предикат через updatePredicate().

Рядки 8-9: Listeners для фільтрів. При зміні searchQuery або selectedGenre викликається updatePredicate(), що оновлює предикат filteredAudiobooks. Це автоматично оновлює TableView.

Рядок 12: SortedList. Обгортка над filteredAudiobooks, що підтримує сортування. Її comparatorProperty буде прив'язана до TableView.comparatorProperty, щоб сортування працювало при кліку на заголовок колонки.

Рядки 15-16: Bindings для лічильників. totalCount завжди дорівнює розміру allAudiobooks. filteredCount завжди дорівнює розміру filteredAudiobooks. Це Bindings — вони оновлюються автоматично при зміні колекцій.

Рядки 19-20: Computed Properties. deleteButtonEnabled — це BooleanBinding, що обчислюється як selectedAudiobook != null. hasError — це errorMessage != null && !errorMessage.isEmpty().

Крок 4: Метод updatePredicate() — фільтрація

private void updatePredicate() {
    filteredAudiobooks.setPredicate(audiobook -> {
        // Фільтр за жанром
        Genre genre = selectedGenre.get();
        if (genre != null && !audiobook.getAudiobook().getGenre().equals(genre)) {
            return false;
        }
        
        // Фільтр за пошуковим запитом
        String query = searchQuery.get();
        if (query == null || query.trim().isEmpty()) {
            return true; // Показати всі (якщо немає пошукового запиту)
        }
        
        String lowerCaseQuery = query.toLowerCase();
        
        // Пошук за назвою або автором
        return audiobook.getTitle().toLowerCase().contains(lowerCaseQuery)
            || audiobook.getAuthorName().toLowerCase().contains(lowerCaseQuery);
    });
}

Розбір логіки фільтрації:

Рядки 3-7: Фільтр за жанром. Якщо жанр обраний (selectedGenre != null), показуємо лише аудіокниги цього жанру. Зверніть увагу: ми викликаємо audiobook.getAudiobook().getGenre() — спочатку отримуємо Domain Model з ViewModel, потім його жанр.

Рядки 10-13: Перевірка пошукового запиту. Якщо запит порожній, показуємо всі елементи (що пройшли фільтр за жанром).

Рядки 18-19: Пошук за назвою або автором. Перевіряємо, чи містить назва або ім'я автора пошуковий запит (без урахування регістру). Це простий пошук — у реальному додатку можна використати більш складні алгоритми (fuzzy search, tokenization).

Чому фільтрація у ViewModel, а не у Repository? Фільтрація за пошуковим запитом — це UI-логіка (користувач вводить текст у реальному часі). Якби ми робили SQL-запит при кожній зміні тексту, це було б неефективно. Натомість ми завантажуємо всі дані один раз та фільтруємо їх локально. Для великих датасетів (тисячі записів) краще використовувати серверну фільтрацію через Repository.

Крок 5: Метод loadAudiobooks() — завантаження даних

public void loadAudiobooks() {
    isLoading.set(true);
    errorMessage.set(null);
    statusMessage.set("Loading audiobooks...");
    
    try {
        // Виклик Repository (синхронний — у реальному додатку через Task)
        List<Audiobook> audiobooks = repository.findAll();
        
        // Маппінг Domain Model → ViewModel
        List<AudiobookViewModel> viewModels = audiobooks.stream()
            .map(AudiobookViewModel::new)
            .collect(Collectors.toList());
        
        // Оновлення колекції
        allAudiobooks.setAll(viewModels);
        
        // Оновлення статусу
        statusMessage.set("Loaded " + audiobooks.size() + " audiobooks");
        
    } catch (DataAccessException e) {
        // Обробка помилки
        errorMessage.set("Failed to load audiobooks: " + e.getMessage());
        statusMessage.set("Error");
        
        // Логування (у реальному додатку через Logger)
        System.err.println("Error loading audiobooks: " + e);
        
    } finally {
        isLoading.set(false);
    }
}

Розбір логіки завантаження:

Рядки 2-4: Встановлення стану "завантаження". isLoading = true показує індикатор завантаження. errorMessage = null очищає попередню помилку. statusMessage інформує користувача.

Рядок 8: Виклик Repository. Тут відбувається реальне завантаження даних з бази. У цьому прикладі виклик синхронний (блокує UI-потік), але у наступному розділі ми зробимо його асинхронним через Task.

Рядки 11-13: Маппінг у ViewModel. Кожен Audiobook (Domain Model) обгортається у AudiobookViewModel. Це Stream API — елегантний спосіб трансформації колекцій.

Рядок 16: Оновлення колекції. setAll() замінює весь вміст allAudiobooks новими даними. Це автоматично оновлює filteredAudiobooks, sortedAudiobooks та TableView (через Bindings).

Рядки 21-27: Обробка помилок. Якщо Repository викинув виняток, ми встановлюємо errorMessage. View підключений до цієї Property через Binding та показує Alert або Label з помилкою.

Рядок 30: Завжди вимикаємо індикатор. finally гарантує, що isLoading стане false навіть при помилці. Інакше індикатор залишиться крутитися вічно.

Крок 6: Метод deleteSelected() — видалення

public void deleteSelected() {
    AudiobookViewModel selected = selectedAudiobook.get();
    
    // Валідація: чи щось обрано?
    if (selected == null) {
        errorMessage.set("No audiobook selected");
        return;
    }
    
    try {
        // Виклик Repository
        repository.delete(selected.getId());
        
        // Оновлення UI
        allAudiobooks.remove(selected);
        selectedAudiobook.set(null);
        
        // Оновлення статусу
        statusMessage.set("Deleted: " + selected.getTitle());
        
    } catch (DataAccessException e) {
        errorMessage.set("Failed to delete audiobook: " + e.getMessage());
        System.err.println("Error deleting audiobook: " + e);
    }
}

Розбір логіки видалення:

Рядки 2-8: Валідація. Перевіряємо, чи щось обрано. Якщо ні — встановлюємо errorMessage та виходимо. View покаже цю помилку користувачу.

Рядок 12: Виклик Repository. Видаляємо з бази даних. Зверніть увагу: ми передаємо selected.getId(), а не весь об'єкт. Repository працює з ID.

Рядки 15-16: Оновлення UI. Видаляємо елемент з allAudiobooks — це автоматично оновлює TableView. Скидаємо вибір (selectedAudiobook = null) — це автоматично вимикає кнопку Delete (через Binding).

Рядок 19: Позитивний фідбек. Показуємо користувачу, що операція успішна. Це важливо для UX — користувач має бачити результат своїх дій.

Рядки 21-23: Обробка помилок. Якщо видалення не вдалося (наприклад, запис вже видалений іншим користувачем), показуємо помилку. Елемент залишається у списку.

Синхронні операції блокують UI. У цьому прикладі repository.delete() виконується у JavaFX Application Thread, що блокує UI. Для тривалих операцій (мережеві запити, великі файли) використовуйте асинхронність через Task (наступний розділ).

Крок 7: Getters для Properties

// Getters для Properties (для Controller)

public SortedList<AudiobookViewModel> getSortedAudiobooks() {
    return sortedAudiobooks;
}

public ObjectProperty<AudiobookViewModel> selectedAudiobookProperty() {
    return selectedAudiobook;
}

public StringProperty searchQueryProperty() {
    return searchQuery;
}

public ObjectProperty<Genre> selectedGenreProperty() {
    return selectedGenre;
}

public BooleanProperty isLoadingProperty() {
    return isLoading;
}

public StringProperty statusMessageProperty() {
    return statusMessage;
}

public StringProperty errorMessageProperty() {
    return errorMessage;
}

public IntegerProperty totalCountProperty() {
    return totalCount;
}

public IntegerProperty filteredCountProperty() {
    return filteredCount;
}

public BooleanBinding deleteButtonEnabledProperty() {
    return deleteButtonEnabled;
}

public BooleanBinding hasErrorProperty() {
    return hasError;
}

Чому так багато getters? Кожна Property, до якої підключається View, потребує getter. Controller викликає ці методи для створення Bindings. Це може здаватися багатослівним, але це ціна за автоматичну синхронізацію.

Альтернатива: Деякі розробники роблять Properties публічними (public final StringProperty searchQuery), щоб уникнути getters. Але це порушує інкапсуляцію — зовнішній код може замінити Property (viewModel.searchQuery = new SimpleStringProperty()), що зламає Bindings.


Асинхронність у ViewModel: Task та Platform.runLater()

У попередніх прикладах loadAudiobooks() та deleteSelected() виконувалися синхронно — у JavaFX Application Thread. Це означає, що під час виконання SQL-запиту UI "зависає" — користувач не може взаємодіяти з додатком.

Для тривалих операцій (JDBC-запити, читання файлів, мережеві запити) потрібна асинхронність. JavaFX надає клас javafx.concurrent.Task для виконання операцій у фоновому потоці з автоматичним поверненням у UI-потік.

Асинхронний loadAudiobooks() через Task

public void loadAudiobooks() {
    isLoading.set(true);
    errorMessage.set(null);
    statusMessage.set("Loading audiobooks...");
    
    // Створення Task для фонового виконання
    Task<List<Audiobook>> task = new Task<>() {
        @Override
        protected List<Audiobook> call() throws Exception {
            // Цей код виконується у ФОНОВОМУ потоці
            return repository.findAll();
        }
    };
    
    // Обробник успішного завершення (виконується у UI-потоці)
    task.setOnSucceeded(event -> {
        List<Audiobook> audiobooks = task.getValue();
        
        // Маппінг у ViewModel
        List<AudiobookViewModel> viewModels = audiobooks.stream()
            .map(AudiobookViewModel::new)
            .collect(Collectors.toList());
        
        // Оновлення UI (безпечно — ми у UI-потоці)
        allAudiobooks.setAll(viewModels);
        statusMessage.set("Loaded " + audiobooks.size() + " audiobooks");
        isLoading.set(false);
    });
    
    // Обробник помилки (виконується у UI-потоці)
    task.setOnFailed(event -> {
        Throwable exception = task.getException();
        errorMessage.set("Failed to load audiobooks: " + exception.getMessage());
        statusMessage.set("Error");
        isLoading.set(false);
        
        System.err.println("Error loading audiobooks: " + exception);
    });
    
    // Запуск Task у новому потоці
    new Thread(task).start();
}

Розбір асинхронного коду:

Рядки 7-13: Створення Task. Task<List<Audiobook>> — це generic клас, де List<Audiobook> — тип результату. Метод call() виконується у фоновому потоці (не UI-потік). Тут безпечно виконувати тривалі операції.

Рядки 16-28: Обробник успіху. setOnSucceeded() викликається автоматично у JavaFX Application Thread, коли call() завершився успішно. Тут безпечно оновлювати UI-компоненти та Properties.

Рядки 31-38: Обробник помилки. setOnFailed() викликається у UI-потоці, якщо call() викинув виняток. task.getException() повертає виняток, що стався у фоновому потоці.

Рядок 41: Запуск Task. Створюємо новий потік та запускаємо Task. Альтернатива: використовувати ExecutorService для керування пулом потоків (рекомендовано для production).

ExecutorService для Task: Замість new Thread(task).start() краще використовувати ExecutorService:
private final ExecutorService executor = Executors.newFixedThreadPool(4);

public void loadAudiobooks() {
    // ... створення Task ...
    executor.submit(task);
}

public void dispose() {
    executor.shutdown(); // При закритті ViewModel
}
Це дозволяє контролювати кількість одночасних потоків та уникати витоку ресурсів.

Прив'язка Task.progressProperty до ProgressBar

Task має вбудовані Properties для відстеження прогресу:

Task<List<Audiobook>> task = new Task<>() {
    @Override
    protected List<Audiobook> call() throws Exception {
        List<Audiobook> audiobooks = repository.findAll();
        
        // Оновлення прогресу (0.0 - 1.0)
        updateProgress(audiobooks.size(), audiobooks.size());
        
        return audiobooks;
    }
};

// У Controller: прив'язка до ProgressBar
progressBar.progressProperty().bind(task.progressProperty());

Для складніших операцій (завантаження файлів, обробка великих датасетів) можна оновлювати прогрес поступово:

@Override
protected List<Audiobook> call() throws Exception {
    List<Audiobook> audiobooks = repository.findAll();
    int total = audiobooks.size();
    
    for (int i = 0; i < total; i++) {
        // Обробка кожного елемента
        processAudiobook(audiobooks.get(i));
        
        // Оновлення прогресу
        updateProgress(i + 1, total);
    }
    
    return audiobooks;
}

Lifecycle ViewModel: Ініціалізація та очищення

ViewModel має життєвий цикл: він створюється, використовується та знищується. Правильне керування цим циклом критично важливе для уникнення memory leaks (витоку пам'яті).

Метод initialize(): Ініціалізація після створення

Деякі операції не можна виконати у конструкторі (наприклад, завантаження даних, що вимагає асинхронності). Для цього створюється метод initialize():

public class AudiobookListViewModel {
    
    // ... Properties та конструктор ...
    
    public void initialize() {
        // Завантаження даних при створенні ViewModel
        loadAudiobooks();
        
        // Підписка на зовнішні події (якщо потрібно)
        // eventBus.subscribe(AudiobookAddedEvent.class, this::onAudiobookAdded);
    }
}

Використання у Controller:

@FXML
public void initialize() {
    viewModel = injector.getInstance(AudiobookListViewModel.class);
    
    // Ініціалізація Bindings
    setupBindings();
    
    // Ініціалізація ViewModel (завантаження даних)
    viewModel.initialize();
}

Метод dispose(): Очищення ресурсів

Коли View закривається, ViewModel має очистити ресурси: відписатися від listeners, зупинити фонові потоки, закрити з'єднання.

public class AudiobookListViewModel {
    
    private final ExecutorService executor = Executors.newFixedThreadPool(4);
    private final List<ChangeListener<?>> listeners = new ArrayList<>();
    
    public void dispose() {
        // Зупинка ExecutorService
        executor.shutdown();
        try {
            if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {
                executor.shutdownNow();
            }
        } catch (InterruptedException e) {
            executor.shutdownNow();
        }
        
        // Відписка від listeners (якщо зберігали посилання)
        listeners.forEach(listener -> {
            // Відписка від Properties
        });
        listeners.clear();
        
        // Очищення колекцій
        allAudiobooks.clear();
    }
}

Виклик dispose() у Controller:

public class AudiobookListController {
    
    private AudiobookListViewModel viewModel;
    
    public void shutdown() {
        // Викликається при закритті вікна
        viewModel.dispose();
    }
}

Підключення до Stage.onCloseRequest:

primaryStage.setOnCloseRequest(event -> {
    controller.shutdown();
});
Memory Leaks через Listeners: Якщо ViewModel підписується на Properties або події, що живуть довше за нього (наприклад, глобальний EventBus), він залишиться в пам'яті навіть після закриття View. Завжди відписуйтесь у dispose().

Тестування ViewModel без UI

Найбільша перевага MVVM — ViewModel тестується як звичайний Java-клас, без запуску JavaFX Application Thread. Розглянемо кілька прикладів unit-тестів.

Тест 1: Завантаження аудіокниг

@ExtendWith(MockitoExtension.class)
class AudiobookListViewModelTest {
    
    @Mock
    private AudiobookRepository mockRepository;
    
    private AudiobookListViewModel viewModel;
    
    @BeforeEach
    void setUp() {
        viewModel = new AudiobookListViewModel(mockRepository);
    }
    
    @Test
    void shouldLoadAudiobooks() {
        // Given
        Audiobook audiobook1 = new Audiobook("1984", orwell, dystopian, 360);
        Audiobook audiobook2 = new Audiobook("Sapiens", harari, nonFiction, 900);
        
        when(mockRepository.findAll()).thenReturn(List.of(audiobook1, audiobook2));
        
        // When
        viewModel.loadAudiobooks();
        
        // Then
        assertEquals(2, viewModel.getSortedAudiobooks().size());
        assertEquals("1984", viewModel.getSortedAudiobooks().get(0).getTitle());
        assertEquals("Loaded 2 audiobooks", viewModel.statusMessageProperty().get());
        assertFalse(viewModel.isLoadingProperty().get());
    }
}

Що тестується: Виклик loadAudiobooks() завантажує дані з Repository, маппить їх у ViewModel, оновлює Properties.

Тест 2: Видалення обраної аудіокниги

@Test
void shouldDeleteSelectedAudiobook() {
    // Given
    Audiobook audiobook = new Audiobook("1984", orwell, dystopian, 360);
    when(mockRepository.findAll()).thenReturn(List.of(audiobook));
    
    viewModel.loadAudiobooks();
    viewModel.selectedAudiobookProperty().set(viewModel.getSortedAudiobooks().get(0));
    
    // When
    viewModel.deleteSelected();
    
    // Then
    verify(mockRepository).delete(audiobook.getId());
    assertEquals(0, viewModel.getSortedAudiobooks().size());
    assertNull(viewModel.selectedAudiobookProperty().get());
    assertTrue(viewModel.statusMessageProperty().get().contains("Deleted"));
}

Що тестується: Видалення викликає Repository, оновлює колекцію, скидає вибір, оновлює статус.

Тест 3: Валідація — помилка при відсутності вибору

@Test
void shouldSetErrorWhenDeletingWithoutSelection() {
    // Given
    viewModel.selectedAudiobookProperty().set(null);
    
    // When
    viewModel.deleteSelected();
    
    // Then
    verify(mockRepository, never()).delete(any());
    assertNotNull(viewModel.errorMessageProperty().get());
    assertTrue(viewModel.errorMessageProperty().get().contains("No audiobook selected"));
}

Що тестується: Спроба видалення без вибору встановлює errorMessage та не викликає Repository.

Тест 4: Фільтрація за пошуковим запитом

@Test
void shouldFilterAudiobooksBySearchQuery() {
    // Given
    Audiobook audiobook1 = new Audiobook("1984", orwell, dystopian, 360);
    Audiobook audiobook2 = new Audiobook("Sapiens", harari, nonFiction, 900);
    Audiobook audiobook3 = new Audiobook("Foundation", asimov, sciFi, 480);
    
    when(mockRepository.findAll()).thenReturn(List.of(audiobook1, audiobook2, audiobook3));
    viewModel.loadAudiobooks();
    
    // When
    viewModel.searchQueryProperty().set("orwell");
    
    // Then
    assertEquals(1, viewModel.getSortedAudiobooks().size());
    assertEquals("1984", viewModel.getSortedAudiobooks().get(0).getTitle());
    assertEquals(1, viewModel.filteredCountProperty().get());
    assertEquals(3, viewModel.totalCountProperty().get());
}

Що тестується: Зміна searchQuery автоматично оновлює filteredAudiobooks через предикат. Лічильники оновлюються через Bindings.

Тест 5: Computed Property — deleteButtonEnabled

@Test
void shouldDisableDeleteButtonWhenNothingSelected() {
    // Given
    viewModel.selectedAudiobookProperty().set(null);
    
    // Then
    assertFalse(viewModel.deleteButtonEnabledProperty().get());
}

@Test
void shouldEnableDeleteButtonWhenItemSelected() {
    // Given
    Audiobook audiobook = new Audiobook("1984", orwell, dystopian, 360);
    AudiobookViewModel audiobookVM = new AudiobookViewModel(audiobook);
    
    // When
    viewModel.selectedAudiobookProperty().set(audiobookVM);
    
    // Then
    assertTrue(viewModel.deleteButtonEnabledProperty().get());
}

Що тестується: deleteButtonEnabled — це Binding, що автоматично обчислюється на основі selectedAudiobook. Жодного ручного оновлення — все через реактивність.

Тестування без JavaFX: Ці тести виконуються без запуску JavaFX Application Thread, тому що ViewModel — це POJO з Properties. Properties працюють у будь-якому потоці. Це робить тести швидкими (мілісекунди) та стабільними.

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

Рівень 1: Базові операції з ViewModel

Завдання 1.1: Створити простий ViewModel

Створіть GenreViewModel — обгортку над Genre:

  • Properties: name, description, audiobookCount (кількість аудіокниг цього жанру).
  • Метод toGenre() для перетворення назад у Domain Model.

Завдання 1.2: Додати валідацію

Розширте AudiobookViewModel валідацією:

  • titleError — помилка, якщо назва порожня або довша за 255 символів.
  • durationError — помилка, якщо тривалість <= 0.
  • isValid — Binding, що обчислюється як titleError == null && durationError == null.

Завдання 1.3: Форматування дати

Додайте до AudiobookViewModel Property formattedReleaseDate, що перетворює releaseYear (int) у рядок "Released in 1984".

Рівень 2: Складні ViewModel

Завдання 2.1: ViewModel з кількома фільтрами

Створіть AuthorListViewModel з фільтрацією:

  • Пошук за ім'ям або прізвищем.
  • Фільтр за кількістю книг (ComboBox: "All", "1-5 books", "6-10 books", "10+ books").
  • Сортування за алфавітом або кількістю книг.

Завдання 2.2: Master-Detail ViewModel

Створіть AudiobookDetailViewModel, що показує деталі обраної аудіокниги:

  • Properties: title, authorName, genreName, description, coverImageUrl.
  • Метод loadDetails(UUID audiobookId) — завантажує повні дані з Repository.
  • Property isLoading для індикатора завантаження.

Завдання 2.3: ViewModel з обчисленнями

Створіть CollectionViewModel для колекції аудіокниг:

  • ObservableList<AudiobookViewModel> — список аудіокниг у колекції.
  • totalDuration — Binding, що обчислює загальну тривалість (сума всіх аудіокниг).
  • averageDuration — Binding, що обчислює середню тривалість.
  • formattedTotalDuration — рядок "Total: 25h 30m".

Рівень 3: Архітектура та інтеграція

Завдання 3.1: Асинхронний ViewModel

Рефакторте AudiobookListViewModel, щоб всі операції з Repository виконувалися асинхронно через Task:

  • loadAudiobooks() — асинхронне завантаження.
  • deleteSelected() — асинхронне видалення.
  • Додайте ProgressBar, що показує прогрес завантаження.

Завдання 3.2: ViewModel з кешуванням

Розширте AudiobookListViewModel кешуванням:

  • При першому виклику loadAudiobooks() завантажити дані з Repository.
  • При наступних викликах використовувати кеш (якщо дані не старіші за 5 хвилин).
  • Метод forceRefresh() — ігнорує кеш та завантажує дані знову.

Завдання 3.3: Повний CRUD ViewModel

Створіть AudiobookFormViewModel для форми додавання/редагування аудіокниги:

  • Properties для всіх полів: title, author, genre, duration, releaseYear, description.
  • Валідація кожного поля з errorProperty.
  • isValid — Binding, що обчислює загальну валідність форми.
  • Метод save() — створює або оновлює аудіокнигу через Repository.
  • Метод reset() — скидає форму до початкового стану.
  • Unit-тести для всієї логіки.

Підсумок

У цій статті ми перейшли від теорії MVVM до практичної реалізації, покроково побудувавши AudiobookListViewModel — повноцінний ViewModel для екрану зі списком аудіокниг.

Ключові концепції, які ми вивчили:

Wrapper Pattern: Domain Model (Audiobook) обгортається у ViewModel (AudiobookViewModel), що експонує Properties для UI. Це розділяє бізнес-логіку (Domain Model) та презентаційну логіку (ViewModel).

Структура ViewModel: ViewModel містить Properties для даних та стану UI, Computed Properties для обчислюваних значень, Commands (методи) для дій користувача, та залежності (Repository, Service) через конструктор.

Фільтрація та сортування: FilteredList та SortedList дозволяють фільтрувати та сортувати дані без зміни оригінальної колекції. Предикат оновлюється при зміні фільтрів через Listeners.

Асинхронність: Task виконує тривалі операції у фоновому потоці з автоматичним поверненням у UI-потік через setOnSucceeded() та setOnFailed(). Це запобігає блокуванню UI.

Lifecycle Management: ViewModel має методи initialize() (ініціалізація після створення) та dispose() (очищення ресурсів). Правильне керування lifecycle запобігає memory leaks.

Тестованість: ViewModel тестується як POJO без JavaFX Application Thread. Unit-тести перевіряють логіку, валідацію, фільтрацію, Bindings — все без запуску UI.

Що робить ViewModel:

  • Адаптує Domain Model для View (форматування, обчислення).
  • Зберігає стан UI (вибір, завантаження, помилки).
  • Координує виклики Repository та Service.
  • Обробляє помилки та перетворює їх у зрозумілі повідомлення.

Що ViewModel НЕ робить:

  • Не знає про JavaFX-компоненти (TableView, Button).
  • Не містить бізнес-логіки (вона у Domain Model).
  • Не працює безпосередньо з базою даних (через Repository).
  • Не керує навігацією (через Navigator).

У наступній статті ми розглянемо View та Controller — як зв'язати ViewModel з FXML через Bindings, як мінімізувати код у Controller, як організувати структуру FXML-файлів. Ми побачимо, як весь код, що ми написали у цій статті, підключається до реального UI та працює разом як єдина система.

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

Корисні ресурси:
Copyright © 2026