Сопровождение к докладу «MVVM, про которую не пишут в туториалах» — пример модуля на MVVM-архитектуре iOS-приложений hh.ru, который можно открыть, запустить и потрогать.
Это упрощённая реконструкция, а не наш продакшн-код. Настоящий базовый слой не в опенсорсе, поэтому здесь он воспроизведён мини-слоем поверх нативных iOS 17 API: та же механика, те же имена, малая доля объёма. Отличия перечислены ниже.
Скелет модуля — схема из доклада:
Состав:
Sources/Module— мини-слой, повторяющий механику базового слоя (в монорепозитории — фичаModule): 17 файлов, ~600 строк.Sources/Example— экран из доклада «Почему скрываете?»: модуль из 6 файлов и упрощённые компоненты вместо дизайн-системы.Tests/ModuleTests— тесты на утверждения доклада о механизме обновления экрана.
Нужен Xcode 15.3 или новее (iOS 17+). App-таргета нет — экран запускается превью:
- Открыть
Package.swiftв Xcode. - Открыть
Sources/Example/Preview/BlacklistedReasonPreview.swift. - Показать Canvas (⌥⌘↩) — экран откроется шторкой, как в приложении.
Тесты: схема mvvm-example-Package, ⌘U.
| Утверждение | Где смотреть |
|---|---|
| Состояние экрана — одна структура; фокус поля ввода лежит рядом с бизнес-полями | ApplicantBlacklistedReasonState.swift |
| Вью — функция от состояния: читает состояние, возвращает описание экрана из трёх слотов | ApplicantBlacklistedReasonView.swift, ModuleViewBody.swift |
| Экран обновляется сам: базовый слой записывает, какие свойства вью прочитала, и перерисовывает только при их изменении | ModuleViewManager.swift + тест test_changeOfPropertyNotReadByView_causesNoViewBodyUpdate |
| Несколько изменений за один цикл RunLoop схлопываются в одну перерисовку | наблюдатель beforeWaiting в ModuleViewManager.swift + тест test_fiveChangesInOneRunLoopCycle_causeSingleViewBodyUpdate |
| Зависимости записываются заново при каждом пересчёте — экран может начать зависеть от новых свойств | тест test_dependenciesAreRecordedAgain_onEveryViewBodyUpdate |
Двусторонняя связь: TextArea(value: $model.state.anotherReasonText) — ни одной строки синхронизации |
ApplicantBlacklistedReasonView.swift, ViewBindable.swift + тест test_viewBinding_writesThroughToState_andTriggersViewBodyUpdate |
| Роутер — строчка объявления и список возможностей, подключаемых протоколами | ApplicantBlacklistedReasonRouter.swift, ScreenCancelling.swift |
| Шесть файлов модуля | Sources/Example/Modules/BlacklistedReason/ |
| Базовый слой на UIKit, компоненты на SwiftUI | ModuleViewController.swift — контроллер собирает экран по описанию, слоты рендерит SwiftUI |
Ни одной подписки руками: в модуле нет ни Combine, ни делегатов,
ни renderState — перерисовку ведёт базовый слой.
- Объём. Настоящий базовый слой — 70 файлов, ~4300 строк: контейнерные
модули (
Pages/Stack/Tabs), вложенные сабмодули, аналитика показа экрана, жизненный цикл из пятнадцати методов, свой список поверхUICollectionViewс быстрым диффом. Здесь — только механика, о которой рассказывает доклад. - Наблюдение. В монорепозитории — Perception,
бэкпорт Observation на старые iOS (
@Perceptible,withPerceptionTracking). Здесь — нативный Observation iOS 17 (@Observable,withObservationTracking). Механика та же. - Вёрстка. В монорепозитории компоненты — дизайн-система Magritte,
написанная инструментами Levitan
(декларативный слой над UIKit и SwiftUI); контент экрана — надстройка
над
UICollectionView. Здесь слоты описания рендерятся SwiftUI черезUIHostingController, дизайн-система не воспроизводится.@ViewBindableиViewBindingв проде определены в Levitan. - Навигация. В монорепозитории — Nivelir
(DSL для навигации по экранам) и фабрики экранов через DI. Здесь роутер
держит слабую ссылку на контроллер,
ScreenCancellingзакрывает экран модально,ScreenFactoryпустая. - Вью-модель примера — без аналитики и сервисов: в проде
onSendTapотмечает вакансию скрытой через сервис и трекает события.
Sources/
Module/ # мини-слой: механика базового слоя
ModuleScreen.swift # точка сборки модуля
ModuleView.swift # вью — функция от состояния
ModuleViewModel.swift # база вью-модели
ModuleViewManager.swift # трекинг чтений + коалесценция перерисовок
ModuleViewController.swift # собирает экран по описанию
ViewBody/ # описание экрана: три слота и настройки
Binding/ # @ViewBindable и ViewBinding
Routing/ # ModuleRouter и ScreenCancelling
Example/
Modules/BlacklistedReason/ # модуль экрана — 6 файлов
Models/ # доменная модель причин
Components/ # упрощённые компоненты вместо дизайн-системы
DI/ # ScreenFactory
Preview/ # запуск экрана в превью
Tests/
ModuleTests/ # тесты механизма обновления

