MVP в JavaFX, часть 2

В этой части мы переделаем архитектуру нашего приложения на MVP формат.

Если изучить структуру нашего текущего приложения мы увидим что-то такое:

Imgur

наша задача отнять у контроллера обязанности по работе с данными и делегировать их в другой класс. Этот класс и будем называть моделью.

Получиться должно что-то такое:

Imgur

главная идея этого разбиения в том, что у нас получается изолированный класс для работы с данными, которые не будет ничего знать о контроллере. Но тем не менее сможет посылать оповещения, на которые этот самый контроллер сможет реагировать.

Создаем модель

создадим в папке models класс FoodModel

// файл ./src/sample/models/FoodModel.java
package sample.models;

public class FoodModel {
}

и начнем потихоньку перетаскивать работу с данными в него

Сначала создадим список под еду. В отличие от контроллера где мы использовали ObservableList, тут будем использовать простой список. Использование простых классов позволяет следовать принципу KISS (Keep it simple, stupid).

Так же это повышает переносимость вашего кода. Кто знает может вам понадобится создавать консольное приложение где использование ObservableList класса являющегося часть JavaFX будет явно излишним.

public class FoodModel {
    ArrayList<Food> foodList = new ArrayList<>();
}

теперь добавим метод, который будет имитировать загрузку данных:

public class FoodModel {
    ArrayList<Food> foodList = new ArrayList<>();

    // добавил метод
    public void load() {
        foodList.clear();
    
        // скопипастили код из контроллера
        foodList.add(new Fruit(100, "Яблоко", true));
        foodList.add(new Chocolate(200, "шоколад Аленка", Chocolate.Type.milk));
        foodList.add(new Cookie(300, "сладкая булочка с Маком", true, true, false));
    }
}

хорошо, у нас теперь есть модель, которая умеет что-то загружать. Подключим ее к контроллеру

Идем в MainFormController и создаем экземпляр класса модели

public class MainFormController implements Initializable {
    // это не трогаем
    public TableView mainTable;
    ObservableList<Food> foodList = FXCollections.observableArrayList();
    
    // создали экземпляр класса модели
    FoodModel foodModel = new FoodModel();

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        // удаляем заполнение формы данными
        // foodList.add(new Fruit(100, "Яблоко", true));
        // foodList.add(new Chocolate(200, "шоколад Аленка", Chocolate.Type.milk));
        // foodList.add(new Cookie(300, "сладкая булочка с Маком", true, true, false));
        // mainTable.setItems(foodList);
        foodModel.load(); // добавляем вызов метода загрузить данные

        // ...
    }
    // ...
}

если запустить, у нас будет пустая форма

Imgur

что в принципе логично.

И так, в соответствии с правилами организации MVP приложения, модель не должна ничего знать о контроллере. И должна быть полностью изолированным классом.

Встает вопрос: как передать данные с модели на форму? Можно конечно создать метод у модели, который будет возвращать данные. Но это будет не канонично и вынудит нас вызывать этот метод каждый раз как мы будем что-то менять/добавлять/загружать.

Для таких случаев человечество придумала использовать паттерн наблюдатель/слушатель (Observer,Listener).

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

Попробуем сделать тоже самое, но для нашей модели. Правим код:

public class FoodModel {
    ArrayList<Food> foodList = new ArrayList<>();

    // Создаем наш любимый функциональный интерфейс
    // с помощью него мы организуем общение между моделью и контроллером
    public interface DataChangedListener {
        void dataChanged(ArrayList<Food> foodList);
    }

    // ну и само собой создаем поле к которому можно будет привязать функцию
    public DataChangedListener dataChangedListener;

    public void load() {
        // это не трогаем
        foodList.clear();
        foodList.add(new Fruit(100, "Яблоко", true));
        foodList.add(new Chocolate(200, "шоколад Аленка", Chocolate.Type.milk));
        foodList.add(new Cookie(300, "сладкая булочка с Маком", true, true, false));

        // а тут добавляем
        // если к dataChangedListener привязали функцию
        if (this.dataChangedListener != null) {
            // то вызываем ее
            dataChangedListener.dataChanged(foodList);
        }
    }
}

теперь мы можем привязать наш контроллер к событию изменения данных, идем в MainFormController и пишем:

public class MainFormController implements Initializable {
    // это не трогаем
    public TableView mainTable;
    ObservableList<Food> foodList = FXCollections.observableArrayList();
    FoodModel foodModel = new FoodModel();

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        // добавляем реакцию на изменения данных
        // тут мы используем наши любимое лямбда-выражение,
        // за счет которого мы передаем в поле модели функцию  
        // эту функцию вызовут из модели, причем модель не будет знать что именно функция делает
        foodModel.dataChangedListener = foods -> {
            // а делает она внутри все очень просто
            // в foods оказывается то что было в модели в foodList
            // и этот foodList преобразуется в observableArrayList 
            // и передается в табличку
            mainTable.setItems(FXCollections.observableArrayList(foods));
        };
        
        // дальше ничего не трогаем
        foodModel.load();
        
        // ...
    }
    // ...   
}

Пробуем запустить:

Imgur

Красота! =)

Кстати, вы можете возразить что это не очень похоже на то что мы делали, когда добавляли обработчики клика на кнопку или клика на узел дерева.

И будете правы ведь обычно привязывают обработчик, используя конструкцию

foodModel.addDataChangedListener( e -> {
    // ...
})

связано это с тем, что часто возникает необходимость привязать несколько слушателей к одному событию.

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

public class FoodModel {
    // это не трогаем
    ArrayList<Food> foodList = new ArrayList<>();
    public interface DataChangedListener {
        void dataChanged(ArrayList<Food> foods);
    }

    // Меняем на private и делаем список
    private ArrayList<DataChangedListener> dataChangedListeners = new ArrayList<>();
    // добавляем метод который позволяет привязать слушателя 
    public void addDataChangedListener(DataChangedListener listener) {
        // ну просто его в список добавляем
        this.dataChangedListeners.add(listener);
    }

    public void load() {
        // это не трогаем
        foodList.clear();
        foodList.add(new Fruit(100, "Яблоко", true));
        foodList.add(new Chocolate(200, "шоколад Аленка", Chocolate.Type.milk));
        foodList.add(new Cookie(300, "сладкая булочка с Маком", true, true, false));

        // а тут делаем цикл по всем слушателям
        for (DataChangedListener listener: dataChangedListeners) {
            // и всех оповещаем
            listener.dataChanged(foodList);
        }
    }
}

соответственно правим контроллер

public class MainFormController implements Initializable {
    // ...

    @Override
    public void initialize(URL location, ResourceBundle resources) {
        // теперь вызываем метод, вместо прямого присваивания
        // прям как со всякими кнопочками
        foodModel.addDataChangedListener(foods -> {
            mainTable.setItems(FXCollections.observableArrayList(foods));
        });
        
        foodModel.load();
        // ...
    }
}

Перетаскиваем добавление объекта

Первое, что нам надо сделать перед перетаскиванием уже существующего кода, это создать в модели метод, который позволит добавить новый объект.

public class FoodModel {
    // ...
    
    public void load() { /* ... */ }
    
    // добавили метод
    public void add(Food food) {
        // просто добавили еду в список
        this.foodList.add(food);
        
        // оповестили всех слушателей что данные изменились
        for (DataChangedListener listener: dataChangedListeners) {
            listener.dataChanged(foodList);
        }
    }
}

так как писать код про оповещение каждый раз лениво, вынесем его вот отдельную функцию

public class FoodModel {
    // ...

    public void load() {
        // это не трогаем
        foodList.clear();
        foodList.add(new Fruit(100, "Яблоко", true));
        foodList.add(new Chocolate(200, "шоколад Аленка", Chocolate.Type.milk));
        foodList.add(new Cookie(300, "сладкая булочка с Маком", true, true, false));

        // тут поменяли
        this.emitDataChanged();
    }

    public void add(Food food) {
        this.foodList.add(food);

        // и тут поменяли
        this.emitDataChanged();
    }
    
    // а это добавили
    private void emitDataChanged() {
        for (DataChangedListener listener: dataChangedListeners) {
            listener.dataChanged(foodList);
        }
    }
}

теперь идем в обработчик кнопки добавления и смотрим что у нас там есть

public void onAddClick(ActionEvent actionEvent) throws IOException {
    FXMLLoader loader = new FXMLLoader();
    loader.setLocation(getClass().getResource("FoodForm.fxml"));
    Parent root = loader.load();

    Stage stage = new Stage();
    stage.setScene(new Scene(root));
    stage.initModality(Modality.WINDOW_MODAL);
    stage.initOwner(this.mainTable.getScene().getWindow());

    stage.showAndWait();

    FoodFormController controller = loader.getController();
    if (controller.getModalResult()) {
        Food newFood = controller.getFood();
        this.foodList.add(newFood);
    }
}

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

Использование модели позволит нам в некотором роде изолировать форму добавления от главной формы. А стало быть понизить связность между компонентами, что есть хорошо. Чем меньше связей между компонентами, тем проше работать с кодом.

То есть мы реализуем такую схему (следуйте за циферками)

Imgur

работать это будет так

  • Наш MainFormController будет создавать новую форму для добавления и передавать в нее ссылку на модель
  • Новая форма FoodFormController будет ждать пока пользователь введет данные и нажмет ОК
  • В реакцию нажатия на ОК мы добавим вызов метода модели add
  • Модель добавив новый объект в свой список оповестит слушателей, что данные изменились
  • Главная формы обновит данные в таблице

И так, добавим поле под модель в форму FoodFormController

public class FoodFormController implements Initializable {
    public FoodModel foodModel; // добавили
    // ...
}

теперь в обработчике нажатия на “Добавить” передадим модель:

public void onAddClick(ActionEvent actionEvent) throws IOException {
    FXMLLoader loader = new FXMLLoader();
    loader.setLocation(getClass().getResource("FoodForm.fxml"));
    Parent root = loader.load();

    Stage stage = new Stage();
    stage.setScene(new Scene(root));
    stage.initModality(Modality.WINDOW_MODAL);
    stage.initOwner(this.mainTable.getScene().getWindow());
    
    // сначала берем контроллер
    FoodFormController controller = loader.getController();
    // передаем модель
    controller.foodModel = foodModel;
    
    // показываем форму
    stage.showAndWait();
    
    /*
    это не нужно больше
    if (controller.getModalResult()) {
        Food newFood = controller.getFood();
        this.foodList.add(newFood);
    }
    */
}

переключаемся на форму добавления и ее обработчик onSaveClick, он у нас вот так выглядит

public void onSaveClick(ActionEvent actionEvent) {
    this.modalResult = true;
    ((Stage)((Node)actionEvent.getSource()).getScene().getWindow()).close();
}

правим его

public void onSaveClick(ActionEvent actionEvent) {
    // вызываем метод добавить модели, и передаем в него еду с формы
    this.foodModel.add(getFood());
    // а это так и оставляем
    ((Stage)((Node)actionEvent.getSource()).getScene().getWindow()).close();
}

пробуем запустить

прекрасно! =)

Переносим редактирование

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

Как правило для этого используют какой-нибудь числовой идентификатор, который у каждого объекта имеет уникальное значение.

Далее по этому идентификатору находят объект и обновляют его значения.

Реализуем это.

Добавляем идентификаторы

Сначала добавим поле id в базовый класс еды Food

public class Food
{
    private int kkal;
    private String title;
    public Integer id = null; // добавили идентификатор, по умолчанию равен null

    // ...
}

идентификаторы будет раздавать модель, для этого создадим в ней поле счетчик и в методе add будет присваивать его значения новосозданным объектам

public class FoodModel {
    ArrayList<Food> foodList = new ArrayList<>();
    private int counter = 1; // добавили счетчик

    // ...

    public void add(Food food) {
        food.id = counter; // присваиваем id еды, значение счетчика
        counter += 1; // увеличиваем счетчик на единицу 
        
        // это не трогаем
        this.foodList.add(food);
        this.emitDataChanged();
    }

    // ...
}

в методе load у нас еда создается без идентификаторов. Сделаем так чтобы она добавлялась через метод add:

public void load() {
    // это не трогаем
    foodList.clear();
    
    // заменили foodList на this
    this.add(new Fruit(100, "Яблоко", true));
    this.add(new Chocolate(200, "шоколад Аленка", Chocolate.Type.milk));
    this.add(new Cookie(300, "сладкая булочка с Маком", true, true, false));

    // это не трогаем
    this.emitDataChanged();
}

тут получается немного не хорошо, из-за того, что оповещение об изменениях происходят каждый раз, когда мы добавляем объект.

Поэтому перегрузим метод add методом, который позволит указать вызывать оповещение или нет, получится вот так:

// добавили параметр emit в метод, 
// если там true то вызывается оповещение слушателей
public void add(Food food, boolean emit) {
    food.id = counter;
    counter += 1;

    this.foodList.add(food);

    if (emit) {
        this.emitDataChanged();
    }
}

// а это получается перегруженный метод, но с одним параметром
// который вызывает add с двумя параметрами,
// передавая в качестве второго параметра true 
// то есть вызывая add с одним параметром будет происходит оповещение
public void add(Food food) {
    add(food, true);
}

ну и заменим в методе load вызовы add на двухаргументные, то есть просто добавим им второй аргумент false

public void load() {
    foodList.clear();
    
    // добавили второй параметр как false
    this.add(new Fruit(100, "Яблоко", true), false);
    this.add(new Chocolate(200, "шоколад Аленка", Chocolate.Type.milk), false);
    this.add(new Cookie(300, "сладкая булочка с Маком", true, true, false), false);

    this.emitDataChanged();
}

Добавляем метод редактирования

Чтобы наша модель могла изменять значения, добавим метод для редактирования.

В его задачи будет входить:

  • найти объект в списке с нужным идентификатором
  • заменить его новым объектом
  • оповестить об изменениях

получится как-то так:

public class FoodModel {
    // ...

    public void edit(Food food) {
        // ищем объект в списке
        for (int i = 0; i< this.foodList.size(); ++i) {
            // чтобы id совпадал с id переданным форме
            if (this.foodList.get(i).id == food.id) {
                 // если нашли, то подменяем объект
                 this.foodList.set(i, food);
                 break;
            }
        }

        this.emitDataChanged();
    }
}

Правим форму редактирования

Переключимся на обработчик редактирования в MainFormController, который выглядит вот так:

public void onEditClick(ActionEvent actionEvent) throws IOException {
    FXMLLoader loader = new FXMLLoader();
    loader.setLocation(getClass().getResource("FoodForm.fxml"));
    Parent root = loader.load();

    Stage stage = new Stage();
    stage.setScene(new Scene(root));
    stage.initModality(Modality.WINDOW_MODAL);
    stage.initOwner(this.mainTable.getScene().getWindow());

    FoodFormController controller = loader.getController();
    controller.setFood((Food) this.mainTable.getSelectionModel().getSelectedItem());

    stage.showAndWait();

    if (controller.getModalResult()) {
        int index = this.mainTable.getSelectionModel().getSelectedIndex();
        this.mainTable.getItems().set(index, controller.getFood());
    }
}

и подкрутим его следующим образом

public void onEditClick(ActionEvent actionEvent) throws IOException {
    FXMLLoader loader = new FXMLLoader();
    loader.setLocation(getClass().getResource("FoodForm.fxml"));
    Parent root = loader.load();

    Stage stage = new Stage();
    stage.setScene(new Scene(root));
    stage.initModality(Modality.WINDOW_MODAL);
    stage.initOwner(this.mainTable.getScene().getWindow());

    FoodFormController controller = loader.getController();
    controller.setFood((Food) this.mainTable.getSelectionModel().getSelectedItem());
    controller.foodModel = foodModel; // передаем модель в контроллер

    stage.showAndWait();
    
    /*
    это не нужно больше
    if (controller.getModalResult()) {
        int index = this.mainTable.getSelectionModel().getSelectedIndex();
        this.mainTable.getItems().set(index, controller.getFood());
    }
    */
}

теперь идем в FoodFormController.

Так как у нас одна форма и на добавление и на редактирование, то мы должны как-то научиться отличать один процесс от другого.

Для этого мы добавим поле id и будем в него заносить идентификатор редактируемого объекта, когда речь будет идти об редактировании, либо оставлять там null если объект добавляется.

Получится как-то так:

public class FoodFormController implements Initializable {
    // ...
    private Integer id = null; // добавили поле под идентификатор

    @Override
    public void initialize(URL location, ResourceBundle resources) { /* ... */}
    
    // ...

    public void setFood(Food food) {
        this.cmbFoodType.setDisable(food != null);
        
        // присвоим значение идентификатора, 
        // если передали еду то есть food != null, то используем food.id
        // иначе запихиваем в this.id значение null 
        this.id = food != null ? food.id : null;

        if (food != null) {
            // ...
        }
    }

    // ...
}

теперь можно обновить реакцию на кнопку “Сохранить”

public void onSaveClick(ActionEvent actionEvent) {
    // проверяем передали ли идентификатор
    if (this.id != null) {
        // если передавали значит у нас редактирование
        // собираем еду с формы
        Food food = getFood();
        // подвязываем переданный идентификатор к собранной с формы еды
        food.id = this.id;
        // отправляем в модель на изменение
        this.foodModel.edit(food);
    } else {
        // ну а если у нас добавление, просто добавляем объект
        this.foodModel.add(getFood());
    }
    ((Stage)((Node)actionEvent.getSource()).getScene().getWindow()).close();
}

кстати теперь нам больше не нужны поля, связанные с результатом модальной формы, так что смело их убираем

public class FoodFormController implements Initializable {
    // ...

    // private Boolean modalResult = false; // УДАЛЯЕМ ЭТО ПОЛЕ
    private Integer id = null;

    public void onCancelClick(ActionEvent actionEvent) {
        // this.modalResult = false; // ЭТУ СТРОЧКУ ТОЖЕ УДАЛЯЕМ
        ((Stage)((Node)actionEvent.getSource()).getScene().getWindow()).close();
    }

    /*
    И ЭТОТ МЕТОД БОЛЬШЕ НЕ НУЖЕН
    public Boolean getModalResult() {
        return modalResult;
    }
    */
}

запускаем и проверяем

Работает!

Правим удаление

Тут все просто, добавляем в модель метод для удаления

public class FoodModel {
    // ...
    
    // для удаления достаточно идентификатора
    public void delete(int id)
    {
        for (int i = 0; i< this.foodList.size(); ++i) {
            // ищем в цикле еду с данным айдишником
            if (this.foodList.get(i).id == id) {
                // если нашли то удаляем
                this.foodList.remove(i);
                break;
            }
        }
        
        // оповещаем об изменениях
        this.emitDataChanged();
    }

    private void emitDataChanged() { /* ... */ }
}

и подкручиваем в MainFormController реакцию на удаление, она выглядит у нас так:

public void onDeleteClick(ActionEvent actionEvent) {
    Food food = (Food) this.mainTable.getSelectionModel().getSelectedItem();
    Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
    alert.setTitle("Подтверждение");
    alert.setHeaderText(String.format("Точно удалить %s?", food.getTitle()));

    Optional<ButtonType> option = alert.showAndWait();
    if (option.get() == ButtonType.OK) {
        this.mainTable.getItems().remove(food);
    }
}

так как тут нет никаких форм, почти ничего не меняется, получается так:

public void onDeleteClick(ActionEvent actionEvent) {
    // ...

    Optional<ButtonType> option = alert.showAndWait();
    if (option.get() == ButtonType.OK) {
        foodModel.delete(food.id); // тут вызываем метод модели, и передаем идентификатор
    }
}

проверяем:

отлично!

Глядим на код контроллера и видим, что ObservableList который мы создавали в контроллере для хранения данных больше не нужен

public class MainFormController implements Initializable {
    public TableView mainTable;

    // можно удалить этот список, наш контроллер больше данных не хранит
    //ObservableList<Food> foodList = FXCollections.observableArrayList();

    FoodModel foodModel = new FoodModel();
    
    // ...
}

поздравляю теперь ваше приложение соответсвует архитектуре MVP! =)

Перейти к следующей части