Перейти к содержимому

Лекция 15. Презентационные паттерны: MVC, MVP, MVVM

Введение

В предыдущих лекциях мы изучали паттерны, которые помогают организовать внутреннюю структуру кода: порождающие, структурные и поведенческие. Сегодня мы поднимемся на уровень выше — к архитектурным (презентационным) паттернам, которые отвечают на вопрос: как разделить пользовательский интерфейс и логику приложения?

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


1. Зачем отделять представление от логики

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

  • Невозможно тестировать логику без интерфейса. Чтобы проверить расчёт, надо запускать всё окно и кликать мышью.
  • Дублирование. Одна и та же бизнес-логика копируется для веб-версии, десктопа и мобильного приложения.
  • Хрупкость. Изменение цвета кнопки рискует сломать расчёт налога.
  • Параллельная разработка затруднена. Дизайнер и разработчик логики постоянно мешают друг другу в одном файле.

Принцип, лежащий в основе всех презентационных паттернов, — это разделение ответственности (Separation of Concerns) и идея «тонкого» представления: интерфейс должен заниматься только отображением, а вся «умная» работа выносится в отдельные классы. Это прямое следствие принципов SRP (единственная ответственность) и слабой связанности, которые мы обсуждали раньше.

Все три паттерна делят приложение минимум на три части:

  • Model — данные и бизнес-логика (общая для всех трёх паттернов);
  • View — то, что видит пользователь;
  • посредник — связующее звено (Controller, Presenter или ViewModel).

Различаются паттерны именно ролью этого посредника и тем, как View связана с остальными частями.


2. MVC (Model-View-Controller)

MVC — старейший из трёх (появился ещё в Smalltalk в 1970-х). Он делит приложение на три компонента:

  • Model — хранит данные и бизнес-логику, ничего не знает о View и Controller.
  • View — отображает данные. В классическом MVC View может напрямую читать данные из Model (например, через паттерн «Наблюдатель»).
  • Controller — принимает ввод пользователя, вызывает методы Model и выбирает, какую View показать.

Поток данных в MVC

Пользователь → Controller → Model
↓ ↓ (уведомление)
View ←─────────┘

Пользователь действует через Controller; Controller изменяет Model; Model уведомляет View об изменениях; View перечитывает данные и обновляется.

Пример на Python

from typing import List, Callable
# ========== Model ==========
class TaskModel:
"""Данные и бизнес-логика. О View и Controller не знает."""
def __init__(self):
self._tasks: List[dict] = []
self._observers: List[Callable] = []
def add_task(self, title: str, description: str) -> dict:
task = {
"id": len(self._tasks) + 1,
"title": title,
"description": description,
"completed": False,
}
self._tasks.append(task)
self._notify_observers()
return task
def toggle_task(self, task_id: int) -> None:
for task in self._tasks:
if task["id"] == task_id:
task["completed"] = not task["completed"]
self._notify_observers()
return
def get_tasks(self) -> List[dict]:
return self._tasks.copy()
def register_observer(self, observer: Callable) -> None:
self._observers.append(observer)
def _notify_observers(self) -> None:
for observer in self._observers:
observer()
# ========== View ==========
class TaskView:
"""Отвечает только за вывод. Активна: сама форматирует данные."""
def display_tasks(self, tasks: List[dict]) -> None:
print("\n=== Список задач ===")
if not tasks:
print("Задач нет")
for task in tasks:
status = "[x]" if task["completed"] else "[ ]"
print(f"{status} [{task['id']}] {task['title']}")
def display_message(self, message: str) -> None:
print(f"-> {message}")
# ========== Controller ==========
class TaskController:
"""Принимает ввод, управляет Model и View."""
def __init__(self, model: TaskModel, view: TaskView):
self.model = model
self.view = view
# View обновляется при изменении модели (наблюдатель)
self.model.register_observer(self._update_view)
def add_task(self, title: str, description: str = "") -> None:
self.model.add_task(title, description)
self.view.display_message(f"Задача '{title}' добавлена")
def toggle_task(self, task_id: int) -> None:
self.model.toggle_task(task_id)
self.view.display_message(f"Статус задачи #{task_id} изменён")
def _update_view(self) -> None:
self.view.display_tasks(self.model.get_tasks())
# Использование
def demonstrate_mvc():
model = TaskModel()
view = TaskView()
controller = TaskController(model, view)
controller.add_task("Изучить ООП", "Прочитать лекции")
controller.add_task("Написать код", "Реализовать паттерны")
controller.toggle_task(1)

Ключевая черта MVC: Controller тонкий, а View может быть «активной» и напрямую обращаться к Model. Этот паттерн доминирует в веб-фреймворках (Django, Spring MVC, Ruby on Rails), хотя там роли часто слегка смещены.


3. MVP (Model-View-Presenter)

MVP — эволюция MVC, в которой View становится пассивной. View больше не знает о Model вообще и не содержит логики. Вся логика отображения уходит в Presenter.

  • Model — те же данные и бизнес-логика.
  • View — пассивный интерфейс. Умеет только показывать то, что ему передали, и сообщать о действиях пользователя Presenter’у. Часто описывается интерфейсом (абстрактным классом).
  • Presenter — посредник. Получает события от View, дёргает Model, забирает результат и сам передаёт его обратно во View методами вроде show_tasks.

Отличие от MVC

MVCMVP
Связь View ↔ ModelView читает Model напрямуюView не знает о Model
Кто обновляет ViewView сама (наблюдатель)Presenter явно
Соотношениеодин Controller на много Viewодин Presenter на одну View

Главная выгода MVP — пассивное представление (Passive View). Поскольку View сводится к простому интерфейсу, её легко заменить заглушкой (mock) и тестировать Presenter без реального UI.

Пример на Python

from abc import ABC, abstractmethod
from typing import List
# ========== Интерфейс View ==========
class TaskViewInterface(ABC):
"""Контракт пассивного представления."""
@abstractmethod
def show_tasks(self, tasks: List[dict]) -> None: ...
@abstractmethod
def show_message(self, message: str) -> None: ...
@abstractmethod
def get_user_input(self, prompt: str) -> str: ...
# ========== Конкретная реализация View ==========
class ConsoleTaskView(TaskViewInterface):
"""Только ввод-вывод, никакой логики."""
def show_tasks(self, tasks: List[dict]) -> None:
print("\n=== Список задач ===")
for task in tasks:
status = "[x]" if task["completed"] else "[ ]"
print(f"{status} {task['title']}")
def show_message(self, message: str) -> None:
print(f"-> {message}")
def get_user_input(self, prompt: str) -> str:
return input(prompt)
# ========== Presenter ==========
class TaskPresenter:
"""Вся логика представления здесь. Работает через интерфейс View."""
def __init__(self, model: TaskModel, view: TaskViewInterface):
self.model = model
self.view = view
def on_add_task_clicked(self) -> None:
title = self.view.get_user_input("Введите название задачи: ")
if title:
self.model.add_task(title, "")
self.view.show_message("Задача добавлена")
self.refresh()
def on_task_toggled(self, task_id: int) -> None:
self.model.toggle_task(task_id)
self.refresh()
def refresh(self) -> None:
# Presenter сам забирает данные и кладёт их во View
self.view.show_tasks(self.model.get_tasks())

Заметьте: Presenter зависит от TaskViewInterface, а не от конкретного класса. В тесте можно подсунуть фейковую View, которая записывает вызовы, и проверить, что Presenter передал нужные задачи. Это и есть «высокая тестируемость» MVP.


4. MVVM (Model-View-ViewModel)

MVVM — паттерн, придуманный в Microsoft для технологий с привязкой данных (data binding): WPF, затем Android (Jetpack), iOS (SwiftUI), Vue.js.

  • Model — данные и бизнес-логика.
  • View — представление. Не содержит логики и декларативно привязывается к свойствам ViewModel.
  • ViewModel — предоставляет данные в виде наблюдаемых свойств (observable) и команды. View «слушает» эти свойства: как только значение меняется, интерфейс обновляется автоматически.

Привязка данных

Ключевое отличие MVVM от MVP: Presenter в MVP сам вызывает методы View, а ViewModel в MVVM не знает о View вообще. Связь устанавливается через механизм data binding — обычно двусторонний: изменение в ViewModel меняет UI, а ввод пользователя в UI меняет свойство ViewModel.

В «настоящих» фреймворках binding встроен. В чистом Python его придётся сымитировать своим маленьким Observable.

Пример на Python

from typing import Any, Callable, List
class Observable:
"""Наблюдаемое значение — основа привязки данных."""
def __init__(self, value: Any = None):
self._value = value
self._observers: List[Callable] = []
@property
def value(self) -> Any:
return self._value
@value.setter
def value(self, new_value: Any) -> None:
if self._value != new_value:
self._value = new_value
self._notify()
def subscribe(self, observer: Callable) -> None:
self._observers.append(observer)
def _notify(self) -> None:
for observer in self._observers:
observer(self._value)
class TaskViewModel:
"""Готовит данные для View. О самой View ничего не знает."""
def __init__(self, model: TaskModel):
self.model = model
self.tasks = Observable([])
self.message = Observable("")
self.model.register_observer(self._on_model_changed)
self._on_model_changed()
def add_task(self, title: str) -> None:
self.model.add_task(title, "")
self.message.value = f"Задача '{title}' добавлена"
def toggle_task(self, task_id: int) -> None:
self.model.toggle_task(task_id)
self.message.value = f"Задача #{task_id} обновлена"
def _on_model_changed(self) -> None:
self.tasks.value = self.model.get_tasks()
class TaskViewMVVM:
"""View лишь подписывается на свойства ViewModel."""
def __init__(self, view_model: TaskViewModel):
self.vm = view_model
# Привязка: реагируем на изменения свойств
self.vm.tasks.subscribe(self._on_tasks_changed)
self.vm.message.subscribe(self._on_message_changed)
def _on_tasks_changed(self, tasks: List[dict]) -> None:
print("\n=== Список задач ===")
for task in tasks:
status = "[x]" if task["completed"] else "[ ]"
print(f"{status} {task['title']}")
def _on_message_changed(self, message: str) -> None:
if message:
print(f"-> {message}")
# Использование
def demonstrate_mvvm():
model = TaskModel()
view_model = TaskViewModel(model)
view = TaskViewMVVM(view_model) # привязки настроены в конструкторе
view_model.add_task("Задача 1")
view_model.add_task("Задача 2")
view_model.toggle_task(1)

Обратите внимание: в demonstrate_mvvm мы вызываем методы ViewModel, а View обновляется сама — мы её больше нигде не трогаем. Это и есть привязка данных в действии.

Когда MVVM уместен: в средах с готовой инфраструктурой binding (WPF, SwiftUI, Android, Vue/React-подобные). Там, где binding приходится писать руками, MVVM часто избыточен — проще обойтись MVP.


5. Сравнение трёх паттернов

АспектMVCMVPMVVM
ПосредникControllerPresenterViewModel
Связь View ↔ ModelView знает ModelView не знает ModelView не знает Model
Роль Viewактивнаяпассивнаяпассивная, декларативная
Кто обновляет ViewView сама / наблюдательPresenter явноdata binding
Знает ли посредник о Viewдада (через интерфейс)нет
Связь Presenter/VM ↔ View1 : 11 : много
Data bindingнетнетда
Тестируемостьсредняявысокаяочень высокая
Сложностьнизкаясредняявысокая
Типичная средавеб (Django, Rails)десктоп, Android (старый)WPF, SwiftUI, Vue

Что выбрать под задачу

  • MVC — когда есть фреймворк, уже построенный вокруг MVC (веб-разработка), или для простых приложений, где не нужна сложная развязка View и Model.
  • MVP — когда нужна высокая тестируемость логики представления и нет встроенного механизма привязки данных. Хороший выбор для десктопных приложений на «голых» виджетах.
  • MVVM — когда платформа предоставляет data binding «из коробки». Тогда ViewModel избавляет от ручного обновления UI и даёт максимально тонкую View.

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


Краткие итоги

  • Презентационные паттерны решают задачу разделения интерфейса и логики (Separation of Concerns), делая View «тонкой».
  • Все три паттерна содержат Model (данные и бизнес-логика) и различаются ролью посредника между View и Model.
  • MVC: Controller обрабатывает ввод; View может знать о Model и обновляться сама. Прост, доминирует в вебе.
  • MVP: View пассивна и скрыта за интерфейсом; Presenter явно ею управляет. Высокая тестируемость.
  • MVVM: ViewModel выставляет наблюдаемые свойства; View привязывается к ним через data binding и обновляется автоматически. Уместен там, где binding встроен в платформу.
  • Выбор паттерна определяется платформой, наличием data binding и требованиями к тестируемости — а не модой.

Вопросы для самопроверки

  1. Какую общую проблему решают паттерны MVC, MVP и MVVM? Сформулируйте через принцип Separation of Concerns.
  2. Что общего у всех трёх паттернов и в чём принципиальное различие между ними?
  3. Почему в MVC View считается «активной», а в MVP — «пассивной»?
  4. Как пассивное представление в MVP повышает тестируемость? Что подставляют вместо реальной View в тестах?
  5. Чем ViewModel в MVVM отличается от Presenter в MVP по способу связи с View?
  6. Что такое data binding и почему MVVM без него теряет смысл?
  7. Почему ViewModel не должна хранить ссылку на View?
  8. Для каких задач и сред вы выберете MVC, MVP, MVVM соответственно? Приведите по примеру платформы.
  9. Может ли применение MVVM быть оверинжинирингом? В каком случае?
  10. Как паттерн «Наблюдатель» используется внутри реализаций MVC и MVVM из этой лекции?