Site icon AppTractor

Модульная архитектура — это не просто теория, вот доказательства

Я достиг того этапа в разработке 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 действительно ожидает наличия значения; если его там не окажется — приложение упадет. Тем не менее, это не нарушает принципы хорошей модульности.

Чтобы понять почему, полезно взглянуть на это как на контракт. Такой подход все еще отвечает основным требованиям модульной архитектуры:

  1. Зависимость явная и легко обнаружимая: Как и аргумент в init(), переменная @Environment объявляется на верхнем уровне дочернего компонента. Она не спрятана внутри функций, @ViewBuilder, синглтонов или User Defaults.
  2. Родитель контролирует реализацию: Вы можете пробросить в окружение мок или любую совместимую ViewModel с другими данными, и дочерний компонент продолжит работать.
  3. Компонент не привязан к месту: CollectionsList теперь можно переместить в любую часть приложения, и он подхватит ту же самую ViewModel.

@Environment — это не анти-паттерн модульности, это декларативная связность. Компонент декларирует, что ему нужно (контракт), а родитель решает, как это предоставить (реализация). Гибкость сохраняется.

Тест-кейсы, подтвердившие правильность подхода

Результаты показали, что мой фокус на «полировке» оправдал себя. Миграция на macOS не была разовой победой — по мере того как я дорабатывал детали, модульность продолжала приносить свои дивиденды.

Например, когда я взглянул на ландшафтный режим на iPhone, зрелище было не из приятных:

Я довольно долго это обдумывал. Компонент TabView, который я всё ещё использую для iPhone, не позволяет отображать вкладки в верхней части экрана. Это привело меня к непростому выводу: нужно было убрать конфигурацию запроса с глаз долой.

И хотя моим первым инстинктом снова была мысль о том, что это будет больно — модульность всё упростила. Я просто добавил кнопку, которая открывает вкладки конфигурации в модальном окне (sheet), что в итоге привело к более простому и чистому лейауту:

Реализация заняла у меня меньше 5 минут. Если бы у меня не было выстроенных практик модульности, это могло бы затянуться на часы.

Практические советы: как строить так с самого начала

Вот выжимка конкретных советов для разработки, чтобы вы могли принимать правильные решения на ранних этапах. Это сэкономит вам время в критические моменты, не создавая лишнего оверхеда при написании кода.

Не создавайте общие ViewModels глубоко в дереве компонентов. Если нескольким вьюхам нужен доступ к одной и той же ViewModel, создавайте её в их общем предке и используйте @Environment для передачи вниз, а не проп-дриллинг.

Оставляйте по-настоящему локальные ViewModels локальными. Если ViewModel используется только на одном экране и больше нигде, нормально создавать её прямо в этой вью через @State.

Знайте, когда использовать прямой DI. Если вы строите реально переиспользуемый компонент, который может уехать в отдельный пакет, используйте Dependency Injection через init(). Инъекция через Environment подходит для ViewModel уровня всего приложения или конкретного воркфлоу, когда доступ нужен многим экранам — это не для каждой мелкой зависимости.

Думайте категориями «библиотека компонентов», а не «экраны приложения». Когда вы строите компоненты так, будто они попадут в библиотеку, вы вынуждены искать минимально необходимый интерфейс. И не из академического интереса, а из лени: вам не захочется документировать 47 допущений о контексте родителя, поэтому вы их просто устраните.

Тестируйте переиспользование на ранних этапах. Прощупайте почву: попробуйте отрендерить представление настроек в выдвигаемом экране, в табе и так далее. Это сразу покажет, работают ли ваши практики модуляции так, как ожидалось.

Поднимайте состояние к границе совместного использования, ограничивайте жизненным циклом. Определите жизненный цикл  данных во ViewModel — один экран, всё приложение, флоу логина и т.д. — и затем поднимите их к ближайшему общему родителю всех вью-потребителей. Если потребитель один — оставьте ViewModel локальной.

⚠️ Важное уточнение: То, что ViewModel может жить на уровне приложения, не значит, что она должна там жить. Хорошая лакмусовая бумажка: «Если пользователь прыгает между разными экранами, ожидает ли он, что данные в этой ViewModel сохранятся?»

Почему модульность важна

У меня ушло едва ли полчаса, чтобы мигрировать с TabView на NavigationSplitView с базовым функционалом, и еще около часа на полировку. Это могло бы занять дни или недели, если бы пришлось всё переписывать с нуля — вот тот самый ROI (окупаемость), который дает грамотная модуляция.

Модульность — это не «проектирование под гипотетические платформы будущего» и не просто «быстрое перемещение блоков UI». Модульность позволяет строить так, чтобы при неизбежном изменении требований вы могли среагировать мгновенно.

API Canvas — это не библиотека компонентов, это целое приложение. Но оно построено как библиотека, а значит, может развиваться так же гибко. Время, которое вы инвестируете в модульность сегодня — это время, которое вы не потратите на переписывание всего приложения завтра.

Источник

Exit mobile version