UI/UX: Учимся использовать настоящий MVC

Весь UI - это композиция MVC
Весь UI — это композиция MVC

В 1972 году лаборатория Xerox PARC первой в мире изобрела компьютеры с графическим пользовательским интерфейсом (GUI). До этого момента все компьютеры управлялись через перфокарты или консоль. Это было прорывное изобретение. Всё оставшееся десятилетие инженеры лаборатории потратили на его улучшение. Именно они изобрели первые компьютерные мыши и язык SmallTalk, который заложил основу ООП. В ~1987 году Trygve Reenskaug создаёт паттерн MVC для более эффективной разработки GUI. Именно этот паттерн по праву считается первым в мире шаблоном проектирования.

MVC задумывался как способ упрощения организации кода в графических интерфейсах. Он разделял элементы приложения на три категории: Model, View и Controller. Model представляла из себя реализацию ментальной модели, которая укладывалась в голове пользователя. View являлось представлением этой модели на экране. А Controller был набором методов, через которые пользователь мог взаимодействовать с моделями и программой в целом.

Идеи MVC не имели ничего общего с архитектурой всего приложения. Каждый MVC был маленьким, и отвечал за небольшие элементы на экране: кнопку, навигационное меню, input формы. К сожалению истинная ценность MVC со временем потерялась. Сейчас этот паттерн больше известен как способ структурировать файлы в серверных web-приложениях. С чем он, по моему мнению, справляется плохо.

В этой статье я расскажу о том, с чем MVC справляется хорошо; об идеях, которые были заложены в него создателем. Мы рассмотрим, как MVC улучшит UI и UX ваших приложений, сделав их понятнее и прозрачнее для пользователей. Рассмотрим способы организации кода по этому паттерну, чтобы вы смогли сразу применить эти знания в своей работе. А так же посмотрим на несколько вариаций этого паттерна, которые улучшают тестирование и ускоряют разработку.

Категория MVC
Категория MVC

Что такое MVC

На дворе 1970-ые года. Программисты всю жизни вводили в компьютер данные через перфокарты или через консоль. Достаточно было написать команду на понятном языке и компьютер всё сделает сам. А теперь начали появляться «какие-то GUI», где для получения того же результата предлагалось водить по столу какой-то коробочкой и нажимать курсором на прямоугольники.

Раньше никто с такими технологиями не работал. Нужно было сделать GUI привычнее и понятнее. И исследователи из PARC обратились к тому, что сейчас мы бы назвали иммерсивностью или скевоморфизмом (первое больше про поведение, а второе про внешний вид).

Идея была в том, чтобы превратить абстрактные прямоугольники на экране в элементы, по своему поведению и внешнему виду похожие на модели из реального мира. Если на экране кнопка, то она должна выглядеть как кнопка и нажиматься как кнопка. Если это форма с полями, то пусть она выглядит как бланк с пропусками. Если это навигация по страницам (они же документы), то пусть навигация выглядит как закладки в каталогах. Своего рода «Принцип наименьшего удивления». Эту идею в последствии доведут до совершенства Стив Джобс, Стив Возняк и инженеры компании Apple.

Models

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

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

Это не обязательно должен быть предмет из реального мира. Главное — чтобы пользователь находил модель понятной. Чтобы в его голове сложился пазл: «Это вещь обладает такими свойствами, и я могу вот так с ней взаимодействовать».

Как нам смоделировать форму аутентификации? Какие свойства и поведение определяют такую форму? Ответив на эти вопросы мы получим её модель. Основные свойства формы — это набор input’ов, куда нужно вводить данные. А основные варианты взаимодействия — это ввод и отправка этих данных. Чуть позже мы посмотрим, как такие модели можно выразить в коде.

P.S. Один из способов подобного моделирования — это сделать объект, в котором будут свойства с данными из текстовых полей и метод для отправки всей формы на сервер. Если слушая про ООП вам начинают рассказывать про моделирование реального мира через классы «котов» и «машин», то знайте, что это пошло отсюда. Только когда Алан Кей работал над SmallTalk, он предполагал, что главная задача объектов — это обмен сообщениями. А моделирование поведения это лишь необходимое условие. Поэтому если ваша программа работает с котами — это еще не значит, что вам нужно создавать классы котов, потому что коты могут и не обмениваться сообщениями. И тогда классы превратятся не в полезный инструмент организации взаимодействия, а в способ организации кода, что уже дело вкуса.

Вариант абстрагирования
Вариант абстрагирования «Формы регистрации» к модели

Views

Теперь, когда у нас есть модель, нам нужно отобразить её на экране. Главное условие здесь — дизайн должен с точностью отображать модель. Он должно отражать все данные модели, и ясно показывать все варианты взаимодействия с ней. Дизайн, отражающий модель, мы будем называть View.

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

Пользователь должен заполнить поля ФИО и Email. Сделаем два input’а: один для имени, другой для email’а. Чтобы лучше отразить модель — создадим label для каждого input’а. Можем ещё улучшить input’ы, заменив их форму с обычного текста на специфическую для каждого типа (ФИО разложим на 3 input’а, а у email будем отдельно вводит имя пользователя и адрес сайта).

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

Согласие с пользовательским соглашением отобразим checkbox’ом.

Для отправки формы можно использовать обычную кнопку, которая сделает HTTP запрос. Или, если это внутренняя система с которой работают операторы, мы можем послать форму на печать. Данные подставятся в бланк (как на предыдущей картинке слева) и клиент поставит свою подпись на распечатанном документе.

Варианты графических представлений для модели
Варианты графических представлений для модели «Формы регистрации»

Обратите внимание, что на основе одной модели можно придумать огромное кол-во разных View, исходя из контекста, специфики компании, бюджета на дизайн и т.п. Потенциальное кол-во ограничено лишь вашей фантазией.

Controllers

Настало время запрограммировать взаимодействие пользователя с моделью. Пользователь генерирует события через устройства ввода (клавиатуру, мышь, сенсор). Модель реагирует на эти события и изменяется, согласно заложенной в ней логике. В обязанности контроллера же входит принимать эти события из разных источников и перенаправлять их в нужные модели.

При инициализации приложения или его отдельной части контроллеры подписываются на события устройств ввода, после чего следят за ними и реагируют на те события, которые входят в их зону ответственности. Для JavaScript подпиской контроллера на событие может служить вызов метода document.addEventListener('someEvent', controller). В данном случае контроллером будет функция, передаваемая в метод вторым аргументом.

Для одной модели может существовать несколько контроллеров. Представьте, что вы работаете с документом и хотите его сохранить. Вы можете сделать это через меню, выбрав пункт «Сохранить». На это отреагирует контроллер, который может называться DocumentMenuController. А можете нажать «Ctrl+S», на который отреагирует DocumentKeyboardController.

Все действия с моделью сначала идут в контроллер. Модель не подписывается на события устройств ввода. У неё свои обязанности. Такой подход помогает не только оставлять модель чистой, но и собирать события из нескольких источников одновременно.

Генерировать события для модели может не только пользователь, но и система. Например, данные должны отобразиться только тогда, когда будет завершён GET запрос с получением этих данных. Завершение запроса и будет событием, на которое среагирует контроллер.

Варианты Controller'ов для модели
Варианты Controller’ов для модели «Документ»

Запрограммируем ProgressBar с помощью MVC

Теперь давайте углубимся в детали реализации и посмотрим, как можно создать MVC в коде программы. Будем моделировать «ожидание окончания загрузки». В качестве языка используем TypeScript.

Model

Начнём с модели. Как человек представляет себе «ожидание окончания загрузки»? Если мы говорим только про факт загрузки, то она либо есть, либо нет. Это boolean значение. Если мы что-то делаем долгое время, то было бы неплохо показывать человеку прогресс. Например, полосочкой, которая постепенно заполняется. Вот у нас получилось 2 модели.

Loader в коде мог бы выглядеть так:

// Модель показывает факт наличия загрузки. Она либо есть, либо нет.
type Loader = {
    isLoading: boolean,
}

class LoaderModel
{
    // Данные модели
    private loader: Loader = {
        isLoading: false,
    };

	public get model(): Loader
	{
	    return this.loader;
	}

    // Интерфейс для контроллера
    public start(): void {
        this.loader.isLoading = true;
    }

    public cancel(): void {
        this.loader.isLoading = false;
    }

    public finish(): void {
        this.loader.isLoading = false;
    }
}

Обратите внимание, что в классе модели есть несколько методов, работающих с ней. Это важный аспект при разработке моделей. Когда мы моделируем концепцию, мы продумываем не только необходимые для существования этой концепции данные, но и как пользователь ожидает с этими данными взаимодействовать. В случае с Loader он ожидает, что загрузка может начаться, может завершиться…, а может и быть отменена.

Мы редко имеем такую возможность в WEB. Когда запрос уже отправлен на сервер, мы не можем на него повлиять. И как следствие не можем его отменить. Анализ модели заставил нас подумать о том, что пользователь мог бы ожидать такого поведения: «Если я устану ждать загрузку, то я нажму на крестик и загрузка прекратится». Мы можем не реализовывать такое поведение. Но мы знаем, что если у нас будет такая возможность, то этот метод лучше добавить в наш контроллер. Так компонент будет ближе к представлению, которое о нём есть у пользователя.

Progress в коде мог бы выглядеть так:

// Модель показывает прогресс какого-либо действия в процентном (%) эквиваленте
type Progress = {
   percentages: number,
   isGoing: boolean,
}

class ProgressModel
{
    // Данные модели
    private progress: Progress = {
        percentages: 0,
        isGoing: false,
    };

    public get model(): Progress {
        return this.progress;
	}

    // Интерфейс для контроллера
    public start(): void {
        this.progress.percentages = 0;
        this.progress.isGoing = true;
    }

    public finish(): void {
        this.progress.percentages = 0;
        this.progress.isGoing = false;
    }

    public update(percentages: number): void {
        switch (true) {
	        case percentages <= 0:
		        this.progress.percentages = 0;
		        break;
			case percentages >= 100:
				this.progress.percentages = 100;
				break;
			default:
				this.progress.percentages = percentages;
        }
    }
}

В модели Progress мы ввели ограничения на возможные состояния данных. Progress.percentages может принимать значение только от 0 до 100, потому что это проценты.

View

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

Варианты маленьких дизайнов для модели Loader
Варианты маленьких дизайнов для модели Loader

Для большого Loader, например на всю страницу, можно использовать что-то простое:

Простой большой Loader
Простой большой Loader

А можно превратить всю страницу в произведение искусства:

Сложный и красивый большой Loader
Сложный и красивый большой Loader

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

Observer между Model и View

»View реагирует на изменения в Model» — гласит одна из стрелок на второй схеме в статье. Как это понимать? Каждый раз, когда изменяется состояние модели (Model), графическое представление этой модели (View) должно отразить это изменение для пользователя. Когда модель Loader переходит из состояния isLoading = false в isLoading = true, на экране должен появиться Loader. Когда isLoading снова становится false, тогда Loader с экрана должен исчезнуть.

Существует несколько способов реализации такого реагирования:

Через Контроллер/Mediator:

  1. Контроллер изменяет модель

  2. Контроллер получает новое состояние модели

  3. Контроллер отдаёт его в View

С помощью паттерна Observer:

  1. Модель даёт возможность другим объектам подписываться на обновления своих состояний. При каждом обновлении объект модели посылает всем подписавшимся на него своё новое состояние

  2. View при создании подписывается на обновление модели

  3. Когда модель изменяется, она отправляет View своё новое состояние

С помощью паттерна Proxy:

  1. Создаётся объект типа Proxy с тем же интерфейсом, что и у модели. Теперь любой метод, вызванный на модели, будет обрабатываться объектом Proxy до и после вызова

  2. Добавляем в Proxy объект View

  3. При каждом вызове метода на модели он будет проходить через Proxy, и когда метод модели завершит свою работу, Proxy будет брать новое состояние модели и прокидывать его в View

Можно придумать еще много других вариантов. Некоторые из них описаны в GOF. Другие поставляются вместе с UI-Framework’ами.

Полная реализация

Обычно я пишу client-side на Vue.js, поэтому воспользуюсь его механизмами для создания подписки на обновления. Вот так может выглядеть полная реализация модели Progress и одного из её представлений -ProgressBar:

Полная реализация

Структура директорий:

src
|-- components
	|-- progress
		|-- progressModel.ts
		|-- ProgressBar.vue

Модель Progress:

// progressModel.ts
import {type DeepReadonly, readonly, ref} from "vue";

export interface Progress {
	percentages: number,
	isGoing: boolean,
}

export class ProgressModel
{
	private progress = ref({
		percentages: 0,
		isGoing: false,
	});

	public get model(): DeepReadonly {
        // Чтобы данные не изменили в обход методов модели
		return readonly(this.progress.value);
	}

	public start(): void {
		this.progress.value = {
			percentages: 0,
			isGoing: true,
		}
	}

	public finish(): void {
		this.progress.value = {
			percentages: 0,
			isGoing: false,
		}
	}

	public update(percentages: number): void {
		const progress: Progress = {
			percentages: this.model.percentages,
			isGoing: this.model.isGoing,
		};
		switch (true) {
			case percentages <= 0:
				progress.percentages = 0;
				break;
			case percentages >= 100:
				progress.percentages = 100;
				break;
			default:
				progress.percentages = percentages;
		}
		this.progress.value = {
			percentages: progress.percentages,
			isGoing: progress.isGoing,
		}
	}
}

Одно из многих возможных View для модели Progress. В данном случае это ProgressBar (постепенно заполняющаяся горизонтальная шкала):


import type {Progress} from "@/components/progress/progressModel.ts";

defineProps<{
	progress: Progress,
}>();

Тестовый контроллер (простой, чтобы можно было покликать):