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.displayLabel — LabelResult в 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