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

Практика 15. Практическая работа 15. MVC, MVP, MVVM

Цель

  • Понять, как презентационные паттерны решают задачу разделения интерфейса и логики (Separation of Concerns) и делают View «тонкой».
  • Реализовать одну и ту же фичу (счётчик и список дел) в трёх стилях — MVC, MVP, MVVM — консольно, без GUI.
  • Прочувствовать разницу в роли посредника (Controller / Presenter / ViewModel) и в способе обновления View (сама / явно / через привязку данных).
  • Научиться оценивать тестируемость представления и выбирать паттерн под задачу.

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

  • Все три паттерна делят приложение на Model (данные и бизнес-логика, общая часть), View (то, что видит пользователь) и посредник между ними.
  • MVC. Controller принимает ввод и меняет Model; View активна — может сама читать Model и обновляться (часто через «Наблюдатель»). Прост, доминирует в вебе.
  • MVP. View пассивна и скрыта за интерфейсом, о Model не знает. Presenter явно дёргает Model и сам передаёт результат во View методами вроде show_tasks. Высокая тестируемость: вместо реальной View подставляют заглушку (mock).
  • MVVM. ViewModel выставляет наблюдаемые свойства (Observable) и команды, о View ничего не знает. View декларативно привязывается к свойствам и обновляется автоматически (data binding). В чистом Python binding имитируют собственным Observable.
  • Ключевое различие посредников: Presenter сам вызывает методы View; ViewModel — нет, связь идёт через привязку. Подробности, схемы потоков данных и примеры — в lecture_15.md.

Задания

Задание 1. Общая Model для счётчика и списка дел

Сначала вынесите бизнес-логику в Model, не зависящую ни от какого паттерна.

Требования:

  1. Класс CounterModel с полем _value: int и методами increment(), decrement() (не ниже нуля), reset(), get_value().
  2. Поддержка наблюдателей: register_observer(callback) и приватный _notify() после каждого изменения. Model не знает о View/посреднике и не содержит ввода-вывода.

Скелет:

from typing import Callable, List
class CounterModel:
def __init__(self) -> None:
self._value = 0
self._observers: List[Callable[[], None]] = []
def increment(self) -> None:
self._value += 1
self._notify()
def decrement(self) -> None:
... # не уходить ниже нуля
def register_observer(self, cb: Callable[[], None]) -> None:
self._observers.append(cb)
def _notify(self) -> None:
for cb in self._observers:
cb()

Проверьте: изменение значения вызывает всех зарегистрированных наблюдателей; в Model нет ни одного print и ни одного input.


Задание 2. Счётчик в стиле MVC

Реализуйте счётчик на основе CounterModel в стиле MVC.

Требования:

  1. CounterViewактивна: метод display(value) печатает состояние, display_message(text) — служебные сообщения. View умеет форматировать данные.
  2. CounterController принимает model и view, в конструкторе подписывает обновление View на Model (наблюдатель). Методы on_increment(), on_decrement(), on_reset() дёргают Model.
  3. Обновление View происходит через уведомление Model, а не вручную из каждого метода контроллера.

Скелет:

class CounterController:
def __init__(self, model: CounterModel, view: CounterView) -> None:
self.model = model
self.view = view
self.model.register_observer(self._render)
def on_increment(self) -> None:
self.model.increment()
def _render(self) -> None:
self.view.display(self.model.get_value())

Проверьте: контроллер «тонкий»; после on_increment() View обновляется сама по цепочке Model → наблюдатель → View. Опишите в комментарии поток данных.


Задание 3. Список дел в стиле MVP

Реализуйте список задач (add, toggle, показать) в стиле MVP с пассивной View за интерфейсом.

Требования:

  1. Расширьте Model до TaskModel (хранит задачи {"id", "title", "completed"}, методы add_task(title), toggle_task(id), get_tasks()).
  2. Абстрактный TaskViewInterface(ABC) с методами show_tasks(tasks), show_message(text), get_input(prompt). Реализация ConsoleTaskView — только ввод-вывод, никакой логики.
  3. TaskPresenter(model, view: TaskViewInterface): методы on_add(), on_toggle(id), refresh(). Presenter сам забирает данные из Model и сам передаёт их во View; зависит от интерфейса, не от конкретного класса.

Скелет:

from abc import ABC, abstractmethod
class TaskViewInterface(ABC):
@abstractmethod
def show_tasks(self, tasks: list[dict]) -> None: ...
@abstractmethod
def show_message(self, text: str) -> None: ...
class TaskPresenter:
def __init__(self, model, view: TaskViewInterface) -> None:
self.model, self.view = model, view
def on_add(self, title: str) -> None:
self.model.add_task(title)
self.view.show_message("Задача добавлена")
self.refresh()
def refresh(self) -> None:
self.view.show_tasks(self.model.get_tasks())

Проверьте: напишите FakeView(TaskViewInterface), которая запоминает переданные задачи в список вместо печати, и тестом убедитесь, что после on_add() Presenter передал во View нужные данные без реального ввода-вывода.


Задание 4. Тот же список дел в стиле MVVM

Реализуйте список задач в стиле MVVM с привязкой данных через Observable.

Требования:

  1. Класс Observable со свойством value (геттер/сеттер) и методом subscribe(callback); сеттер уведомляет подписчиков только при изменении значения.
  2. TaskViewModel(model) выставляет наблюдаемые свойства tasks и message, подписывается на Model и обновляет tasks.value при изменениях. Методы add(), toggle(id) дёргают Model и пишут в message. ViewModel не знает о View.
  3. TaskViewMVVM(view_model) в конструкторе подписывается на свойства ViewModel и печатает их при изменении. После настройки привязок View нигде явно не вызывается.

Скелет:

class Observable:
def __init__(self, value=None) -> None:
self._value = value
self._subs: list = []
@property
def value(self):
return self._value
@value.setter
def value(self, new) -> None:
if self._value != new: # уведомлять только при изменении
self._value = new
for cb in self._subs:
cb(new)

Проверьте: в демонстрации вызываются только методы ViewModel (view_model.add(...)), а вывод появляется сам. Объясните, почему ViewModel не хранит ссылку на View.


Задание 5*. Сравнительная таблица и рефлексия (повышенной сложности)

  1. Оформите таблицу по реализованным фичам: для MVC, MVP, MVVM укажите — кто обновляет View, знает ли посредник о View, есть ли data binding, как тестировать.
  2. Кратко (5–8 строк) ответьте: какой из трёх паттернов для консольного приложения оказался самым избыточным и почему; где MVVM-привязка реально окупится.

Критерии оценки

КритерийВес
Задание 1: чистая Model с наблюдателями, без ввода-вывода15%
Задание 2: MVC — активная View, тонкий Controller, обновление через наблюдатель20%
Задание 3: MVP — пассивная View за интерфейсом, Presenter явно управляет View25%
Задание 4: MVVM — Observable, привязка, ViewModel не знает о View25%
Задание 5*: сравнительная таблица и обоснованная рефлексия5%
Качество кода: имена, docstrings, аннотации типов, отсутствие логики во View10%

Штрафы: бизнес-логика или print/input внутри Model; логика во View (MVP/MVVM); ссылка на View внутри ViewModel; Presenter, зависящий от конкретного класса View, а не от интерфейса; ручное обновление View там, где должна работать привязка.


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

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