Лекция 9. Архитектурные принципы и презентационные паттерны
Введение
На этой паре мы поднимаемся над уровнем «как написать экран» и спрашиваем: как организовать код так, чтобы приложение можно было развивать месяцами и годами, не утонув в собственной сложности. Это и есть архитектура.
Архитектура — это не библиотека и не фреймворк. Это набор решений о том, какие части системы существуют, кто за что отвечает и как части общаются друг с другом. В Expo/React Native у нас есть компоненты, хуки, навигация, запросы к API — и от того, как мы их раскладываем по «полочкам», зависит судьба проекта.
Сегодня разберём:
- зачем нужна архитектура и какова цена её отсутствия;
- базовые принципы: SoC, SOLID, DRY/KISS/YAGNI, связанность и связность;
- презентационные паттерны MVC, MVP, MVVM — детально, с потоками данных;
- что обрабатывать в представлении, а что — в логике;
- антипаттерны и «запахи», по которым видно, что архитектура поехала.
1. Зачем нужна архитектура
1.1. Что такое архитектура (формально)
По стандарту ISO/IEC/IEEE 42010, архитектура — это фундаментальная организация системы: её компоненты, их взаимосвязи между собой и со средой, а также принципы проектирования и эволюции. Проще говоря — это «скелет» приложения и правила, по которым он растёт.
Важно: архитектура существует всегда. Вопрос только в том, спроектирована она осознанно или сложилась стихийно. «У нас нет архитектуры» означает «у нас плохая, неконтролируемая архитектура».
1.2. Зачем она нужна
Хорошая архитектура обеспечивает архитектурные качества (quality attributes):
- Модифицируемость — новую фичу можно добавить, не переписывая половину кода.
- Тестируемость — логику можно проверить без запуска UI и реальной сети.
- Понятность — новый разработчик находит нужное место за минуты, а не за дни.
- Переносимость и заменяемость — можно сменить источник данных (REST → GraphQL) или библиотеку состояния, не трогая бизнес-правила.
- Надёжность — ошибки локализуются и не «растекаются» по всему приложению.
1.3. Цена отсутствия архитектуры
Когда правил нет, проект деградирует предсказуемо:
- Хрупкость: правка в одном экране ломает другой, не связанный с ним напрямую.
- Жёсткость: любое изменение тянет за собой каскад правок в десятках файлов.
- Невозможность тестов: бизнес-логика прибита к UI и сети, её нельзя изолировать.
- Дублирование: одни и те же форматирования/валидации копируются по экранам.
- Замедление команды: чем больше код, тем медленнее идёт разработка (вместо ускорения).
- Рост «bus factor»: систему понимает один человек, и его уход — катастрофа.
Эти проблемы накапливаются как технический долг. Архитектурные «запахи» — это сигналы долга, который придётся обслуживать; чем позже, тем дороже.
Метафора: писать без архитектуры — как строить дом без чертежа. Первый этаж получится быстро, но к третьему стены начнут расходиться.
2. Архитектурные принципы
2.1. Разделение ответственностей (Separation of Concerns, SoC)
Главный принцип: каждый «концерн» (забота) живёт в своём месте. Классическое разделение на три зоны:
- Представление (UI) — рисует интерфейс и ловит ввод пользователя. Не знает, откуда берутся данные и по каким правилам считаются.
- Логика (домен) — формулирует правила предметной области: что значит «оформить заказ», «можно ли удалить задачу». Независима от платформы.
- Данные — доступ к источникам (HTTP, БД, кеш). Скрывает конкретику: компонент не должен знать URL эндпоинта.
Если эти зоны перемешаны — нарушено SoC, и начинаются проблемы из раздела 1.3.
2.2. SOLID применительно к RN/Expo
SOLID — пять принципов объектно-ориентированного дизайна, которые отлично работают и в мире хуков/модулей.
- S — Single Responsibility (единственная ответственность). У модуля одна причина для изменения. Экран отвечает за отображение, репозиторий — за доступ к данным, сервис — за конкретную операцию. Не сваливаем всё в один компонент.
- O — Open/Closed (открыт для расширения, закрыт для изменения). Новую функциональность добавляем новой реализацией интерфейса/нового хука, а не правкой существующего рабочего кода.
- L — Liskov Substitution (подстановка). Mock-репозиторий и реальный репозиторий взаимозаменяемы: код, работающий с интерфейсом, не должен «догадываться», какая реализация под ним. Это и делает возможным тестирование.
- I — Interface Segregation (разделение интерфейсов). Маленькие интерфейсы под конкретный сценарий лучше одного «толстого». Компоненту-списку нужен
getTodos(), а не весь API-клиент целиком. - D — Dependency Inversion (инверсия зависимостей). Верхние уровни зависят от абстракций, а не от деталей. ViewModel зависит от интерфейса
TodoRepository, а не отfetch/axiosнапрямую.
2.3. DRY, KISS, YAGNI
- DRY (Don’t Repeat Yourself) — не дублируйте знание. Логика форматирования цены или валидации email должна жить в одном месте. Но осторожно: преждевременное «обобщение» двух случайно похожих кусков — тоже зло.
- KISS (Keep It Simple, Stupid) — простейшее решение, которое работает, обычно лучшее. Не вводите пять слоёв абстракции там, где хватает функции.
- YAGNI (You Aren’t Gonna Need It) — не пишите функциональность «на будущее», которую сейчас никто не просит. Спекулятивная гибкость = лишняя сложность сегодня.
KISS и YAGNI уравновешивают SOLID и DRY: цель не «максимум абстракций», а достаточно абстракций под реальные потребности.
2.4. Низкая связанность и высокая связность
Два ключевых свойства хорошей структуры:
- Связанность (coupling) — степень зависимости модулей друг от друга. Стремимся к низкой: изменение одного модуля не должно требовать правок в куче других.
- Связность (cohesion) — логическая целостность модуля, насколько его части служат одной цели. Стремимся к высокой: всё, что относится к «корзине», лежит вместе, а не размазано по проекту.
Идеал: модули внутри плотные (высокая связность), а между собой соединены тонкими, явными контрактами (низкая связанность). Тогда систему легко менять по частям.
2.5. Чистые функции и изоляция эффектов
- Бизнес-логику предпочтительно оформлять чистыми функциями: одни и те же входные данные → один и тот же результат, без побочных эффектов. Их тривиально тестировать.
- Побочные эффекты (сеть, хранилище, время, случайность) изолируем в адаптерах/сервисах на краю системы. Тогда «ядро» остаётся детерминированным.
3. Презентационные паттерны: MVC, MVP, MVVM
Презентационные паттерны описывают, как разделить отображение и логику представления. Все три разделяют систему на «модель» (данные/правила) и «вид» (UI), но по-разному организуют посредника между ними.
3.1. MVC — Model-View-Controller
Участники:
- Model — данные и бизнес-правила.
- View — отображение, показывает состояние модели.
- Controller — принимает ввод пользователя, обновляет модель, выбирает View.
Поток данных:
Пользователь → View → Controller → Model ↑ | └──── обновление ─────┘ (View читает Model, Controller их связывает)Плюсы: простота, хорошо для несложных экранов; разделение данных и отображения. Минусы: Controller быстро разрастается — собирает на себя всю логику. На iOS это вошло в фольклор как «Massive View Controller». Границы между View и Controller размыты.
3.2. MVP — Model-View-Presenter
Участники:
- Model — данные и правила (как в MVC).
- View — пассивная: только отображает то, что ей говорят, и пробрасывает события. Никакой логики.
- Presenter — содержит всю логику представления, общается с Model, командует View через интерфейс.
Поток данных:
Пользователь → View → Presenter → Model ↑ | └─ команды ┘ (Presenter явно вызывает методы View: view.showLoading(), view.renderList(items), view.showError(msg))Плюсы: View максимально «глупая», поэтому Presenter тестируется как обычный класс/функции, без UI. Чёткое разделение. Минусы: много «ручной» связки (boilerplate) — Presenter вызывает методы View по одному; интерфейс View разрастается.
3.3. MVVM — Model-View-ViewModel
Участники:
- Model — данные и правила.
- View — отображает состояние и подписывается на него; декларативна.
- ViewModel — хранит наблюдаемое состояние (observable) и логику представления. Не знает о конкретной View.
Ключевое отличие: связывание данных (data binding). View не вызывают «в лоб» — она сама реагирует на изменение состояния ViewModel. Это идеально ложится на декларативный UI.
Поток данных:
Пользователь → View → (действие) → ViewModel → Model ↑ | └── состояние (binding)─┘ (View автоматически перерисовывается при изменении state)В RN/Expo MVVM почти «нативен»:
- Функциональный компонент = View.
- Кастомный хук со стейтом и эффектами = ViewModel.
- Реактивность React (перерисовка при смене state) = механизм биндинга.
// ViewModel: вся логика экрана в хукеfunction useTodosViewModel(repo) { const [state, setState] = useState({ status: 'idle', items: [] });
async function load() { setState({ status: 'loading', items: [] }); try { const items = await repo.getTodos(); // repo — абстракция (D из SOLID) setState({ status: 'success', items }); } catch (e) { setState({ status: 'error', items: [] }); } }
return { state, load };}
// View: только отображение и проброс событийfunction TodosScreen({ repo }) { const { state, load } = useTodosViewModel(repo); useEffect(() => { load(); }, []);
if (state.status === 'loading') return <Spinner />; if (state.status === 'error') return <ErrorView onRetry={load} />; return <TodoList items={state.items} />;}View здесь не содержит ни запросов, ни обработки ошибок — только «как нарисовать состояние». Вся логика — в ViewModel, и её можно протестировать отдельно от компонента.
3.4. Сравнительная таблица
| Критерий | MVC | MVP | MVVM |
|---|---|---|---|
| Посредник | Controller | Presenter | ViewModel |
| Роль View | активная, читает Model | пассивная, «глупая» | декларативная, подписана на state |
| Связь View↔логика | прямые вызовы | Presenter командует View | биндинг (реакция на state) |
| Связь с View | тесная | через интерфейс View | ViewModel не знает о View |
| Тестируемость логики | низкая (логика в Controller+UI) | высокая | высокая |
| Boilerplate | мало | много (ручная связка) | мало (биндинг автоматический) |
| Риск разрастания | «Massive Controller» | толстый интерфейс View | «God-хук» при недосмотре |
| Где уместен | простые экраны | где нужен строгий контроль и тесты | декларативный UI (React, SwiftUI) |
3.5. Что выбирать в React Native
Рекомендация — MVVM на хуках. Декларативная природа React делает биндинг бесплатным: компонент автоматически перерисовывается при смене состояния. Достаточно вынести логику экрана в кастомный хук (useXxxViewModel) — и вы получаете MVVM без лишней церемонии.
- Простой экран без логики — можно вообще без формального паттерна (всё в компоненте, это нормально для мелочей; помним KISS/YAGNI).
- Экран с состоянием, загрузкой, ошибками — выносим логику в хук-ViewModel.
- MVP/MVC в чистом виде в RN применяют редко: MVP даёт лишний boilerplate, классический MVC плохо ложится на функциональные компоненты.
4. Где что обрабатывать: представление vs логика
Граница между View и логикой — главный практический вопрос. Ориентир:
В представлении (View / компонент):
- рендеринг разметки и стилей;
- локальное UI-состояние (открыт ли дропдаун, фокус поля);
- проброс событий пользователя «наверх» (в ViewModel);
- отображение состояния, которое ему дали.
В логике (ViewModel / домен / данные):
- загрузка и сохранение данных (через репозиторий);
- бизнес-правила и валидация («сумма > 0», «email корректен»);
- обработка ошибок и решения «повторить/показать дефолт»;
- преобразование данных в форму, удобную для отображения (view-модель).
Простое правило-эвристика: если строку кода нужно протестировать без рендеринга UI — она не должна жить в JSX. Если внутри компонента появился fetch, if (response.status === 401) или формула расчёта скидки — логика «протекла» в представление.
Полезно мыслить UI как конечный автомат: Idle → Loading → Success/Empty → Error. ViewModel управляет переходами, View лишь рисует текущее состояние. Это убирает целый класс багов «забыли обработать ветку».
5. Антипаттерны и «запахи»
«Запах» (code smell) — не баг, но признак, что структура портится. Узнавать их — половина дела.
5.1. God-компонент (God-хук / God-module)
Один файл/компонент/хук делает всё: рендер, состояние, сетевые запросы, навигацию, форматирование. Признаки: 500+ строк, десятки useState, всё связано со всем.
Почему плохо: невозможно тестировать, переиспользовать, понять. Нарушает SRP и высокую связность.
Лечение: выделить ViewModel-хук, разбить на под-компоненты, вынести данные в репозиторий.
5.2. Бизнес-логика в UI
Расчёт цены, правила доступа, валидация прямо в JSX или в обработчике onPress компонента. Почему плохо: логику нельзя протестировать без UI, она дублируется между экранами, меняется вместе с вёрсткой. Лечение: вынести в чистые функции / домен / ViewModel.
5.3. Прямые запросы из экрана
Компонент сам зовёт fetch('https://api...'), знает URL, заголовки, формат ответа.
Почему плохо: жёсткая связь UI↔HTTP. Нельзя сменить источник, нельзя подставить mock для теста, дублирование сетевого кода.
Лечение: ввести репозиторий/сервис как фасад; компонент зависит от абстракции (Dependency Inversion).
5.4. «Процедурная куча»
Слоёв нет вовсе: всё свалено в utils и компоненты, абстракций нет, форматы данных дублируются. Лечение: ввести явные слои (presentation/domain/data) хотя бы концептуально.
5.5. Скрытые сайд-эффекты
Функция незаметно меняет глобальное состояние, пишет в хранилище, дёргает аналитику. Почему плохо: поведение непредсказуемо, тесты «плавают». Лечение: делать эффекты явными, изолировать на краю системы (раздел 2.5).
5.6. Жёсткая связь UI ↔ конкретная библиотека
UI напрямую завязан на конкретный стор/HTTP-клиент по всему коду. Почему плохо: смена библиотеки = переписать всё. Лечение: изолировать за тонким адаптером/хуком.
Запахи — повод для рефакторинга, а не для паники. Фиксируйте их и обслуживайте планово, иначе технический долг превратится в «архитектурное банкротство».
Краткие итоги
- Архитектура есть всегда — выбор лишь в том, осознанная она или стихийная. Цена отсутствия: хрупкость, жёсткость, невозможность тестов, замедление команды.
- Базовые принципы: SoC (разделить UI/домен/данные), SOLID (особенно SRP и Dependency Inversion), DRY/KISS/YAGNI как баланс, низкая связанность + высокая связность.
- Презентационные паттерны разделяют отображение и логику представления: MVC (простой, риск «Massive Controller»), MVP (пассивная View, много boilerplate, хорошо тестируется), MVVM (биндинг состояния, минимум связки).
- В React Native выбирают MVVM на хуках: компонент = View, кастомный хук = ViewModel, реактивность React = биндинг.
- Граница View/логика: в UI — рендер и локальное UI-состояние; в логике — данные, бизнес-правила, ошибки. Эвристика: тестируется без UI → не место в JSX.
- Антипаттерны: God-компонент, бизнес-логика в UI, прямые запросы из экрана, скрытые эффекты. Лечатся выделением слоёв, ViewModel и репозиториев.
Вопросы для самопроверки
- (Вопрос 21) Что такое архитектура приложения и какова цена её отсутствия? Перечислите принципы SoC, SOLID, DRY/KISS/YAGNI и объясните, что означают низкая связанность и высокая связность.
- (Вопрос 22) Сравните презентационные паттерны MVC, MVP и MVVM: роли участников, потоки данных, плюсы и минусы. Какой паттерн обычно выбирают в React Native и почему?
- (Вопрос 29) Какие архитектурные антипаттерны и «запахи» вы знаете (God-компонент, бизнес-логика в UI, прямые запросы из экрана)? Чем они опасны и как их лечить? Где должна жить бизнес-логика, а что остаётся в представлении?