Сайт вебмастера

MVC (Model View Controller) в JavaFX

16-07-2018Reading time ~ 12 min.Java 21004

MVC (Model View Controller / Модель Представление Контроллер) — это даже не паттерн, который имеет понятную реализацию (с примером кода), а некая концепция, призванная упростить разработку, поддержку и изменение программы. Именно поэтому все примеры кода имеют отношение только к конкретно решаемой задаче. Более того, реализация будет ещё зависеть и от используемого языка — в некоторых случаях «классическое» применение MVC может только усложнить код.

Я всегда воспринимал MVC как алгоритм, в котором определяются сущности под определенную задачу. До начала изучения Java, мне как-то не было особой нужды вдаваться во все эти тонкости, поскольку на Delphi, PHP или JavaScript нет ограничений и жестких рамок: задачу можно решать любыми способами. Но Ява заставляет использовать только классы ООП, а значит неизбежно возникает задача на логическое разделение кода.

«Классическое» понимание MVC

При описании MVC, обычно ссылаются на его реализацию в Smalltalk'е в 1978-88 годах. Поскольку в то время взаимодействие с пользователем происходила чуть ли не напрямую от опроса клавиатуры, а вывод на порт дисплея, то чтобы как-то упорядочить этот код и были придуманы разные методологии. Их основаная задача — избавление от спаггети-кода.

Те кто помнит программирование на Бейсике, прекрасно понимают о чем речь: все эти goto делали код совершенно нечитаемым. Со временем такой код был упорядочн с помощью функций, процедур, модулей/библиотек. Дальнейшее развитие привело к ООП, который ввел ограничение видимости, что почти решило проблему пространства имен.

С появлением визуального программирования, когда взаимодействие с пользователем перешло на совершенно другой уровень, концепция MVC немного поменялась, поскольку стало уже не так ясно за что отвечает View (Представление) и какая у него связь с Controller (Контролёр). И это породило жуткую путаницу, поскольку даже элементарная кнопка на форме может быть сразу и Представлением (сама себя отрисовывает), и Контролёром, поскольку может генерировать и обрабатывать события (методы), и даже Моделью, поскольку может содержать в себе необходимые данные.

Про Web вообще лучше и не упоминать. В PHP обычно всё в куче, а попытка сделать «по науке» только приводит к существенному разбуханию кода. :-)

Если вы пытались найти в гугле примеры и описания MVC, то видели насколько велик «разброд и шатание» по этому вопросу. :-) Но, чтобы понять суть MVC достаточно лишь посмотреть на примеры, где нет абстракции, а есть реальный код.

MVC — в первую очередь Модель

Рассмотрим простейший пример. Пусть у нас будет форма с единственной кнопкой. При нажатии на кнопку, происходит подсчёт каждого нажатия и результат выводится в названии кнопки. Покажу на примере Delphi, поскольку Pascal очень прост для понимания.

Алгоритм будет такой — у каждого компонента есть свойство Tag, которое может содержать целое число. В нём и будем хранить количество нажатий. У кнопки создадим событие onClick, где и разместим код. Вот скриншот, где всё сразу видно:

Подсчет количества нажатий

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

Пусть, например, у нас будет кнопка, которая будет запускать какой-то сложный расчет, данные для которого будут браться из Edit1 (текстовое поле), а результат выводиться в Label1.

Новая версия программы

Получится примерно так:

procedure TForm1.Button1Click(Sender: TObject);
var
    s, s1: string;
begin
    // получаем значение из Edit1
    s := Edit1.Text;
 
    // Выполняем какие-то вычисления, результат в s1
    // ...

    s1 := 'Результат';
 
    // выводим результат
    label1.Caption := s1;
end;

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

Очевидно, что здесь возникает дублирование кода, поскольку мы уже прописали весь код в Button1. Самое простое, что можно сделать — это copy/paste в Button2. Но, это не совсем правильно, поскольку в случае изменения алгоритма рассчета, придётся его править в нескольких местах. Логично вынести сам рассчет в отдельную функцию.

Поскольку функция должна принимать данные из Edit'ов, то мы её создаем в классе/объекте формы TForm1.

Две кнопки используют одну функцию

Функция m() завязана на Edit1, поэтому если станет задача использовать какой-то другой компонент, или даже его переименовать, придется править код этой функции. Чтобы этого не делать, нужно модернизировать m() так, чтобы она не была завязана на любые компоненты формы. В этом случае она должна принимать входящий текст как параметр.

Функция, которая Модель

Обратите внимание, что функция m() теперь не привязана к форме или любому другому элементу. Если мы захотим, то можем её вынести в отдельный модуль (unit) и подключать к любой другой форме.

Но при этом, компоненты формы видят эту функцию и могут её использовать.

С точки зрения MVC функция m() выполняет роль Модели. Одно из её ключевых свойст — Модель ничего не знает от том, кто её использует. Её задача — принять какие-то данные, их обработать и отдать результат.

Конечно этот пример безумно примитивный, но он хорошо показывает путь к MVC-приложению. Выделение части кода в Модель, позволяет значительно упростить поддержку программы. В более серьёзных программах, вместо функции обычно используются классы, модули и т.п.

Где же здесь Контролёр и Представление? Вот это — загадка природы. :-) Delphi позволяет оперировать ими одновременно. В теории сама форма и все её копомненты должны описываться Представлением. А контролёр должен отвечать за приём и обработку событий. Но те же кнопки — это довольно сложные объекты, которые сами могут выполнять различные задачи (внутри себя, как в первом примере), поэтому они по сути выполняются на уровне View без вмешательства Контролёра.

Именно такое неявное «поведение» Контролёра и Представления послужило причиной появления Model-View-Presenter, Model-View-ViewModel и т.п. шаблонов проектирования. В них Контролёр по сути заменяется модулем, обслуживающим Представление, но имеющим доступ к Модели.

Теперь посмотрим как дело обстоит в JavaFX.

MVC в JavaFX

Сразу хочу обратить внимание, что речь идёт именно о JavaFX, то есть программах, которые имеют нормальный графический интерфейс. Это немного отличается от консольных java-программ, которые могут организовать связь объектов чуть ли не в любом варианте.

При создании нового JavaFX-приложения генерируется каркас, как я показывал в прошлой статье. Я пока работаю в IntelliJ IDEA, но суть везде одинакова.

Создаётся несколько файлов:

  • Main.java — это точка запуска программы. Здесь же происходит подключение mainform.fxml, на основе которого строится сама форма.
  • Controller.java — это Контролёр, который декларируется как часть MVC.

При этом отстутствует Model.java и View.java, поэтому придется создать их самостоятельно.

Чтобы была конкретика, я покажу на примере простой программы, которая состоит из двух TextField, две кнопки указывающие на «операцию» и кнопку Go, которая выполняет «расчет». На выходе получается строчка, которая состоит из "поле1" + операция + "поле2" и выводит результат в метку формы.

Внешний вид программы

Первое, что можно сделать — это выделить Модель (файл Model.java). Поскольку она ничего не должна знать о Контролёре и представлении, то Модель представляет собой обычный класс, который позже нужно будет использовать в других объектах. Привожу полный код файла.

package org.maxsite;

/*
* здесь вычисления и внутреннее хранение данных
* */

class Model {
 
    private String op = "-"; // операция
    private String num1 = "1"; // первое число
    private String num2 = "2"; // второе число
    private String result = "1-2"; // результат
 
    // выставляет операцию
    void setOp(String o) {
        op = o;
    }
 
    // выставляет num1
    void setNum1(String s) {
        num1 = s;
    }
 
    // выставляет num2
    void setNum2(String s) {
        num2 = s;
    }
 
    // вычисляем результат
    void go() {
        result = num1 + op + num2;
    }
 
    // отдаем результат
    String getResult() {
        return result;
    }
}

Модель должна сама хранить своё состояние. В нашем примере это переменные, которые участвуют в конечном расчёте. Для их установки используются «сеттеры» (set-функции) — прямого доступа к переменным извне нет. Это довольно типовое построение классов.

Теперь разберёмся с Контролёром (файл Controller.java). Очевидно, что он должен принимать все on-события: у нас это нажатия кнопок (onAction). В этих методах мы прописываем методы модели.

package org.maxsite;
 
/*
* это контролер
* */
 
import javafx.fxml.FXML;
import javafx.scene.control.TextField;
 
public class Controller {
 
    // форма: поля ввода
    @FXML private TextField tf1;
    @FXML private TextField tf2;
 
    // контролер должен знать модель
    // в модели все вычисления и внутренние данные
    private Model model = new Model();
 
    // нажатите кнопки BtnPlus
    @FXML public void onActionBtnPlus() {
        model.setOp("+");
    }
 
    // нажатите кнопки BtnMinus
    @FXML public void onActionBtnMinus() {
        model.setOp("-");
    }
 
    // нажатите кнопки BtnGo - получение результата
    @FXML public void onActionBtnGo() {
        this.go();
    }
 
    // функция для получения и вывода результата
    private void go() {
        // отдаем модели нужные данные
        model.setNum1(tf1.getText());
        model.setNum2(tf2.getText());
 
        // вычисляем результат
        model.go();
 
        // дальше нужно вывести результат
        // ...
    }
}

Контролёр жестко завязан на форму, поэтому именно здесь мы получаем ссылки на TextField, которые используем для отправки в Модель. Кнопка Gо запускает отдельную функцию go(), которая выполняет сразу несколько действий. Можно было бы прописать всё непосредственно в onAction кнопки, но, помня, что программа может расшириться, лучше вынести алгоритмы расчета отдельно.

Остался последний компонент Представление (файл View.java). За что он вообще должен отвечать?

В современном представлении MVC компоненту View отводится роль «автоматического отображения» результата Модели. То есть, как только мы нажали кнопку Go, Контролер выполнил функцию go(), где срабатывает строчка model.go(); и Модель, после всех своих вычислений, должна «уведомить» View, что всё готово, забирай результат.

Данная схема описана в ООП-паттерне «Наблюдатель» (Observer). Чтобы она работала, View должна предварительно «зарегистрироваться» в Модели и иметь некий предопределенный метод, который и «дёргает» Модель, проходясь по списку «слушателей».

В нашем случае мы не будем реализовывать такой паттерн, поскольку у нас очень простая программа, где связи между объектами можно задать «вручную».

В JavaFX приложение строится немного «своеобразно». Когда main-функция принимает fxml-файл, то его контролёр (указанный в fxml-файле) имеет отношение только к этой форме. То есть это не Контролёр приложения, как можно было бы подумать, а Контролёр конкретной формы. По сути это не даже не Контролёр, а View, поскольку логика такова, что вначале создается отображение, а уже после идёт привязка к Контролёру. При этом View (класс/объект) как таковой не создаётся (это вообще внутренняя «кухня» Явы).

Но мы хотим всё-таки выделить View в отдельный класс, чтобы немного упростить Контролёр. При этом Представление должно получить доступ к Label, чтобы вывести результат.

Давайте создадим файл View.java, который выводит в Label любой текст. Например так:

package org.maxsite;
/*
* это представление: то, что выводится на форму
* */
 
import javafx.fxml.FXML;
import javafx.scene.control.Label;
 
class View {
    @FXML private Label LabelResult;
 
    // выводит текст в
    void displayLabel(String s) {
        LabelResult.setText(s);
    }
}

В контролёре нужно теперь прописать объект View, чтобы получить к нему доступ:


private Model model = new Model();
private View view = new View();
...
 
// функция для получения и вывода результата
private void go() {
    ...
    // дальше нужно вывести результат
    view.displayLabel(model.getResult());
}

Пока всё логчично, запускаем программу, но при нажатии на кнопку Go, получаем ошибку java.lang.NullPointerException, что означает null в org.maxsite.View.displayLabelLabelResult в View неиницилизирована и равна null.

Когда мы создаём view = new View();, то новый объект ничего не знает о «реальном» LabelResult, поскольку fxml-файл завязан только на класс Controller, а про View он не в курсе.

Как выйти из этой ситуации? Первый, довольно простой способ — «в лоб». Нужно объявить LabelResult в Controller, и явно его передавать в view.displayLabel вторым параметром. Таким образом View ничего не будет знать ни о Контролёре, ни о Модели и ей вообще всё равно что и как расположено на форме.

@FXML private Label LabelResult;
     
// контролер должен знать модель
// в модели все вычисления и внутренние данные
private Model model = new Model();
private View view = new View();
...

// функция для получения и вывода результата
private void go() {
    ...
    // дальше нужно вывести результат
    view.displayLabel(model.getResult(), LabelResult);
}

Минус этого подхода в том, что View оказывается жестко завязан на передаваемый тип Label для вывода результата. Если мы поменяем тип, то придется менять и Контролёр и Представление. Кроме того, Контролёру необходимо знать, то что ему особо и не нужно — LabelResult используется только для передачи во View. Было бы логичней, чтобы View сам решал куда следует выводить данные.

Очевидно, что проблема здесь только в области видимости в View метки LabelResult. А за область видимости в ООП «отвечает» механизм наследования (точнее расширения, но это не суть). Поэтому возникает простое решение — пусть Контролёр будет расширять View — за счёт этого, не нужно будет создавать новый объект, а поля формы станут доступны во View.

Таким образом наш View.java возвращается к первоначальному виду (код выше), а Контролер становится таким (привожу полный код, чтобы не запутаться):

package org.maxsite;
/*
* это контролер
* */
 
import javafx.fxml.FXML;
import javafx.scene.control.TextField;
 
public class Controller extends View {
    // форма: поля ввода
    @FXML private TextField tf1;
    @FXML private TextField tf2;
 
    // контролер должен знать модель
    // в модели все вычисления и внутренние данные
    private Model model = new Model();
 
    // нажатите кнопки BtnPlus
    @FXML public void onActionBtnPlus() {
        model.setOp("+");
    }
 
    // нажатите кнопки BtnMinus
    @FXML public void onActionBtnMinus() {
        model.setOp("-");
    }
 
    // нажатите кнопки BtnGo - получение результата
    @FXML public void onActionBtnGo() {
        this.go();
    }
 
    // функция для получения и вывода результата
    private void go() {
        // отдаем модели нужные данные
        model.setNum1(tf1.getText());
        model.setNum2(tf2.getText());
 
        // вычисляем результат
        model.go();
 
        // дальше нужно вывести результат
        displayLabel(model.getResult());
    }
}

Желающие поизучать код, могут скачать архив.

Что в итоге?

  • Модель содержит бизнес-логику, то есть все вычисления, без привязки к чему-либо.
  • Контролёр выполняет роль диспетчера, принимая сообщения и получая результат.
  • Представление только выводит данные, причём самостоятельно.

Очевидно, что в данной схеме можно реализовать и паттерн «Наблюдатель» между Моделью и Контролёром (который будет передавать данные в Представление). Сам Контролёр может подключать и другие классы, если нужно расширить функциональность (это другие ООП-паттерны).

PS. Почему это работает?

По какой причине View вдруг стал видеть LabelResult? Было бы логичней, если бы View расширялся от Controller, а тут «обратная» связь... Для меня самого это загадка, но думаю, что дело в загрузчике FXMLLoader в main-файле. При загрузке fxml-файла, FXMLLoader видит, что есть связь между Controller и View (класс и суперкласс), поэтому все аннотированные переменные и функции получают общую область видимости. Если убрать аннотацию @FXML у LabelResult, то программа перестает работать с той же ошибкой (null-инициализация).

Таким образом, данный способ построения MVC приложения годится только для JavaFX-программ. Для других видов нужно в main-файле явно создать зависимость между Моделью, Контролёром и Представлением, чтобы получить похожий результат.

Естественно, существуют и другие алгоритмы MVC, но на мой взгляд этот самый простой для понимания. :-)

Продолжение: Реализация паттерна Observer в MVC (Model View Controller) для JavaFX

Related Posts