Лекция 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
| MVC | MVP | |
|---|---|---|
| Связь View ↔ Model | View читает Model напрямую | View не знает о Model |
| Кто обновляет View | View сама (наблюдатель) | Presenter явно |
| Соотношение | один Controller на много View | один Presenter на одну View |
Главная выгода MVP — пассивное представление (Passive View). Поскольку View сводится к простому интерфейсу, её легко заменить заглушкой (mock) и тестировать Presenter без реального UI.
Пример на Python
from abc import ABC, abstractmethodfrom 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. Сравнение трёх паттернов
| Аспект | MVC | MVP | MVVM |
|---|---|---|---|
| Посредник | Controller | Presenter | ViewModel |
| Связь View ↔ Model | View знает Model | View не знает Model | View не знает Model |
| Роль View | активная | пассивная | пассивная, декларативная |
| Кто обновляет View | View сама / наблюдатель | Presenter явно | data binding |
| Знает ли посредник о View | да | да (через интерфейс) | нет |
| Связь Presenter/VM ↔ View | — | 1 : 1 | 1 : много |
| 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 и требованиями к тестируемости — а не модой.
Вопросы для самопроверки
- Какую общую проблему решают паттерны MVC, MVP и MVVM? Сформулируйте через принцип Separation of Concerns.
- Что общего у всех трёх паттернов и в чём принципиальное различие между ними?
- Почему в MVC View считается «активной», а в MVP — «пассивной»?
- Как пассивное представление в MVP повышает тестируемость? Что подставляют вместо реальной View в тестах?
- Чем ViewModel в MVVM отличается от Presenter в MVP по способу связи с View?
- Что такое data binding и почему MVVM без него теряет смысл?
- Почему ViewModel не должна хранить ссылку на View?
- Для каких задач и сред вы выберете MVC, MVP, MVVM соответственно? Приведите по примеру платформы.
- Может ли применение MVVM быть оверинжинирингом? В каком случае?
- Как паттерн «Наблюдатель» используется внутри реализаций MVC и MVVM из этой лекции?