Разработка
Модульная архитектура — это не просто теория, вот доказательства
Модульность — это не «проектирование под гипотетические платформы будущего» и не просто «быстрое перемещение блоков UI». Модульность позволяет строить так, чтобы при неизбежном изменении требований вы могли среагировать мгновенно.
Я достиг того этапа в разработке API Canvas, моего первого проекта #buildinpublic, когда сосредоточился на полировке. Такое приложение приятно в использовании, работает интуитивно и не мешает пользователю во время работы.
В основном, это соответствовало действительности. Я очень тщательно отбирал каждый запрос на слияние, поэтому сюрпризов было немного.
Но когда я протестировал сборку для macOS, я был поражен. Хотя мои адаптируемые TabView и коллекции NavigationSplitView работали достаточно хорошо на iPad, на macOS они были ужасно искажены. Кнопки перекрывались, было много лишнего пустого пространства и проблем с расположением панели инструментов — непригодный для использования беспорядок.
Когда я попытался разобраться, как версии для Mac и iPad аналогично организованных приложений, таких как «Заметки» и «Напоминания», достигают того же результата, я понял, что они используют NavigationSplitView верхнего уровня без TabView.
Это означало, что мне нужно было полностью переработать компонентную архитектуру моего приложения. Я ожидал, что этот процесс будет сплошной головной болью. Вместо этого я получил… сюрприз.
Что на самом деле означает модульность
Среднестатистический разработчик, если его попросят дать определение модульности, вероятно, скажет что-то вроде:
Модульность — это когда вы разбиваете свой код на более мелкие, отдельные части или модули, вместо того чтобы хранить все в одном большом файле. Каждый модуль обрабатывает определенную часть функциональности, и они могут работать вместе, но существовать независимо.
Здесь нет ничего неверного, но это опасно неполно; отсутствует объяснение того, почему это важно — а это крайне важно для понимания того, что такое хорошая модульность. Давайте рассмотрим следующий код в качестве примера:
// ParentView.swift
struct ParentView: View {
var width: CGFloat {
UIScreen.main.bounds.width * 0.8
}
var body: some View {
ChildView(parent: self)
}
}
// ChildView.swift
struct ChildView: View {
let parent: ParentView
var body: some View {
Text("Hello!")
.frame(width: parent.width)
Text("Welcome to Swift!")
.frame(width: parent.width)
}
}
Это соответствует всем пунктам приведенного выше определения:
- Код в каждом файле меньше, чем при слиянии
- Компоненты разделены, а не представляют собой «один большой файл»
- Каждый компонент обрабатывает определенную часть функциональности (родительский компонент отвечает за расчет ширины, дочерний — за размещение элементов пользовательского интерфейса)
- Компоненты работают вместе и существуют независимо друг от друга
Однако приведенный выше пример демонстрирует очень плохую модульность. Хотя два компонента «разделены» с точки зрения размещения кода, на самом деле они тесно связаны. Дочерний компонент полностью перестанет работать, если родительский компонент уберет поле ширины.
Это приводит нас к основной проблеме приведенного выше определения: хорошая модульность управляет сложностью за счет разделения задач. Другими словами, компоненты не должны зависеть от идентичности, структуры или реализации своего родителя.
Если мы хотим превратить приведенный выше код в пример хорошей модульности, дочерний компонент должен вместо этого запросить у своего родителя то, что ему нужно — ширину — и родительский компонент будет обязан ее предоставить:
// ParentView.swift
struct ParentView: View {
var width: CGFloat {
UIScreen.main.bounds.width * 0.8
}
var body: some View {
ChildView(availableWidth: width)
}
}
// ChildView.swift
struct ChildView: View {
let availableWidth: CGFloat
var body: some View {
Text("Hello!")
.frame(width: availableWidth)
Text("Welcome to Swift!")
.frame(width: availableWidth)
}
}
Этот код выглядит очень похоже, но в нём есть существенное отличие: ChildView больше не делает никаких предположений о своём родительском компоненте — любой компонент может передать ширину как число с плавающей запятой, и ChildView будет доволен.
В моём случае с API Canvas компонент CollectionsList (показанный на второй слева панели выше) был разработан таким образом, чтобы в качестве входных данных ему передавалась только ViewModel. Его поведение при использовании этих данных полностью самодостаточно; ему всё равно, получил ли он эту ViewModel от TabView, NavigationSplitView или чего-то ещё.
ViewModel как архитектурные точки поднятия
Хотя это звучит очень чисто, в реальном мире всё немного сложнее. Проблема была в небольшом проп-дриллинге (антипаттерн, когда данные передаются глубоко по дереву компонентов через посредников, которым эти данные на самом деле не нужны) — ничего критичного, но мою ViewModel приходилось пробрасывать через несколько слоев компонентов:
ApiCanvasView # Application root
└─ TabView
└─ CollectionsView # ViewModel created here
└─ CollectionsNavigationView
└─ NavigationSplitView
└─ CollectionsList
Это стало проблемой после реорганизации приложения, когда NavigationSplitView стал корневым элементом:
ApiCanvasView # Application root
└─ NavigationSplitView
└─ CollectionsList # No CollectionsView to pass in a ViewModel!
По сути, это легко фиксится на уровне стейт-менеджмента. В документации Apple как раз показано, как с помощью @Environment можно «прокинуть» ViewModel, динамический конфиг или другие данные дочерним компонентам — как прямым, так и на любую глубину вложенности.
Поэтому я вынес инициализацию ViewModel из CollectionsView, добавил её в корневой код инициализации приложения и заинжектил в общее окружение.
struct CollectionsList: View {
enum Mode {
case navigationStack
case sidebar
}
@Environment(CollectionsViewModel.self) private var collectionsViewModel
// This lets us render differently based on what kind of visual
// the parent wants. Since mode is explicitly passed in, no
// assumptions are made about parent context or structure.
let mode: Mode
@ViewBuilder
private var menuItems: some View {
// ...
}
var body: some View {
// Create a wrapper to enable two-way binding with @Environment.
@Bindable var viewModel = collectionsViewModel
if mode == .navigationStack {
List(selection: $viewModel.selectedCollection) {
menuItems
}
} else {
menuItems
}
}
}
Класс CollectionsList отлично работал в боковой панели NavigationSplitView. Потребовались некоторые визуальные доработки, поскольку элементы управления имеют совершенно иную структуру пользовательского интерфейса, но с функциональной точки зрения все было идеально.
Ниже представлены архитектурные схемы, иллюстрирующие состояние до и после миграции:

Меньше слоев, слабее связность и приятный бонус: состояние ViewModel теперь сохраняется при навигации по приложению.
Инъекция через Environment: все еще модульно — но почему?
Вы можете резонно заметить: «Погоди, но ведь это создает допущение, что CollectionsViewModel всегда будет в окружении. Разве это не тот самый тип зависимости от контекста родителя, которого мы стараемся избегать?»
И да, и нет. Прямой Dependency Injection (DI) ничего не подразумевает заранее, в то время как @Environment действительно ожидает наличия значения; если его там не окажется — приложение упадет. Тем не менее, это не нарушает принципы хорошей модульности.
Чтобы понять почему, полезно взглянуть на это как на контракт. Такой подход все еще отвечает основным требованиям модульной архитектуры:
- Зависимость явная и легко обнаружимая: Как и аргумент в
init(), переменная@Environmentобъявляется на верхнем уровне дочернего компонента. Она не спрятана внутри функций,@ViewBuilder, синглтонов или User Defaults. - Родитель контролирует реализацию: Вы можете пробросить в окружение мок или любую совместимую
ViewModelс другими данными, и дочерний компонент продолжит работать. - Компонент не привязан к месту:
CollectionsListтеперь можно переместить в любую часть приложения, и он подхватит ту же самуюViewModel.
@Environment — это не анти-паттерн модульности, это декларативная связность. Компонент декларирует, что ему нужно (контракт), а родитель решает, как это предоставить (реализация). Гибкость сохраняется.
Тест-кейсы, подтвердившие правильность подхода
Результаты показали, что мой фокус на «полировке» оправдал себя. Миграция на macOS не была разовой победой — по мере того как я дорабатывал детали, модульность продолжала приносить свои дивиденды.
Например, когда я взглянул на ландшафтный режим на iPhone, зрелище было не из приятных:
Я довольно долго это обдумывал. Компонент TabView, который я всё ещё использую для iPhone, не позволяет отображать вкладки в верхней части экрана. Это привело меня к непростому выводу: нужно было убрать конфигурацию запроса с глаз долой.
И хотя моим первым инстинктом снова была мысль о том, что это будет больно — модульность всё упростила. Я просто добавил кнопку, которая открывает вкладки конфигурации в модальном окне (sheet), что в итоге привело к более простому и чистому лейауту:
Реализация заняла у меня меньше 5 минут. Если бы у меня не было выстроенных практик модульности, это могло бы затянуться на часы.
Практические советы: как строить так с самого начала
Вот выжимка конкретных советов для разработки, чтобы вы могли принимать правильные решения на ранних этапах. Это сэкономит вам время в критические моменты, не создавая лишнего оверхеда при написании кода.
Не создавайте общие ViewModels глубоко в дереве компонентов. Если нескольким вьюхам нужен доступ к одной и той же ViewModel, создавайте её в их общем предке и используйте @Environment для передачи вниз, а не проп-дриллинг.
Оставляйте по-настоящему локальные ViewModels локальными. Если ViewModel используется только на одном экране и больше нигде, нормально создавать её прямо в этой вью через @State.
Знайте, когда использовать прямой DI. Если вы строите реально переиспользуемый компонент, который может уехать в отдельный пакет, используйте Dependency Injection через init(). Инъекция через Environment подходит для ViewModel уровня всего приложения или конкретного воркфлоу, когда доступ нужен многим экранам — это не для каждой мелкой зависимости.
Думайте категориями «библиотека компонентов», а не «экраны приложения». Когда вы строите компоненты так, будто они попадут в библиотеку, вы вынуждены искать минимально необходимый интерфейс. И не из академического интереса, а из лени: вам не захочется документировать 47 допущений о контексте родителя, поэтому вы их просто устраните.
Тестируйте переиспользование на ранних этапах. Прощупайте почву: попробуйте отрендерить представление настроек в выдвигаемом экране, в табе и так далее. Это сразу покажет, работают ли ваши практики модуляции так, как ожидалось.
Поднимайте состояние к границе совместного использования, ограничивайте жизненным циклом. Определите жизненный цикл данных во ViewModel — один экран, всё приложение, флоу логина и т.д. — и затем поднимите их к ближайшему общему родителю всех вью-потребителей. Если потребитель один — оставьте ViewModel локальной.
Почему модульность важна
У меня ушло едва ли полчаса, чтобы мигрировать с TabView на NavigationSplitView с базовым функционалом, и еще около часа на полировку. Это могло бы занять дни или недели, если бы пришлось всё переписывать с нуля — вот тот самый ROI (окупаемость), который дает грамотная модуляция.
Модульность — это не «проектирование под гипотетические платформы будущего» и не просто «быстрое перемещение блоков UI». Модульность позволяет строить так, чтобы при неизбежном изменении требований вы могли среагировать мгновенно.
API Canvas — это не библиотека компонентов, это целое приложение. Но оно построено как библиотека, а значит, может развиваться так же гибко. Время, которое вы инвестируете в модульность сегодня — это время, которое вы не потратите на переписывание всего приложения завтра.
-
Вовлечение пользователей2 недели назад
Большинство приложений терпят неудачу не из-за плохой «идеи»
-
Новости3 недели назад
Видео и подкасты о мобильной разработке 2026.3
-
Новости2 недели назад
Видео и подкасты о мобильной разработке 2026.4
-
Видео и подкасты для разработчиков2 недели назад
Изоляционно-плагинная архитектура в Dart-приложениях, переносимость на Flutter



