Разработка
От неработающей к тестируемой навигации в SwiftUI: децентрализованный MVVM подход с координаторами
Координаторы в рамках паттерна MVVM централизуют маршрутизацию, устраняя связанность представлений, обеспечивая работу глубоких ссылок и улучшая разделение ответственности и тестируемость.
SwiftUI предоставляет несколько инструментов для управления навигацией, а внедрение NavigationStack и ссылок «значение-цель» улучшило программную навигацию.
Однако в более крупных приложениях стандартная навигация SwiftUI может создавать проблемы с тестируемостью, поддержкой и модульностью. Логика навигации распределена между представлениями, что приводит к связанности и затрудняет поиск кода навигации.
Эти проблемы можно решить путем интеграции координаторов в шаблон MVVM.
Ванильная навигация в SwiftUI интуитивно понятна, но вызывает архитектурные проблемы в масштабе
SwiftUI NavigationStack позволяет создавать сложные иерархии навигации. Например, это типичная схема для приложения с вкладками, где в каждой вкладке есть отдельная навигация для детализации.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
}
}
Tab("Settings", systemImage: "gear") {
NavigationStack {
SettingsView()
}
}
}
}
}
Представление NavigationLink позволяет нам указывать:
- Value-destination ссылки, которые добавляют значения в навигационный путь
- View-destination ссылки, которые добавляют представления непосредственно в стек навигации
Хотя оба варианта полезны, в зависимости от сценария использования, оба создают архитектурные проблемы, если у вас большое приложение.
Вы можете ознакомиться с кодом из этой статьи, скачав полный проект Xcode с GitHub.
Координаторы помогают исключить нетестируемую логику из представлений SwiftUI
Ссылки Value-destination работают с типами. Например, мы можем отобразить RecipeView, когда значение Recipe добавляется в путь навигации.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
RecipeView(recipe: recipe)
}
}
}
// ...
}
}
}
Этот подход остается чисто декларативным до тех пор, пока нам не потребуется проанализировать данные для принятия решения о навигации. Например, наше приложение с рецептами может предлагать рецепты премиум-класса, доступ к которым ограничен пейволом.
struct ContentView: View {
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
if recipe.isPremium {
PaywallView()
} else {
RecipeView(recipe: recipe)
}
}
}
}
// ...
}
}
}
Включение этих проверок вносит в представления логику, не относящуюся к пользовательскому интерфейсу, что создает ряд архитектурных проблем:
- Это нарушает принцип единственной ответственности
- Это приводит к зависимости между представлениями
- Это делает бизнес-логику приложения нетестируемой
Первые две проблемы можно решить сразу, переместив логику навигации в класс координатора.
@Observable final class Coordinator {
@ViewBuilder func destination(for recipe: Recipe) -> some View {
if recipe.isPremium {
PaywallView()
} else {
RecipeView(recipe: recipe)
}
}
}
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView {
Tab("Recipes", systemImage: "list.bullet.clipboard") {
NavigationStack {
RecipesList()
.navigationDestination(for: Recipe.self) { recipe in
coordinator.destination(for: recipe)
}
}
}
// ...
}
}
}
Однако тестирование этого кода не является простым, поскольку атрибут @ViewBuilder приводит к тому, что метод destination(for:) возвращает значение _ConditionalContent<PaywallView, RecipeView>, которое мы не можем проверить.
Мы рассмотрим, как это исправить, к концу статьи.
Координаторы упрощают внедрение сложных зависимостей в модели представления
Другая проблема возникает с представлениями, использующими View Model, которая требует внедрения зависимостей через свой инициализатор.
Поскольку объекты окружения еще недоступны в инициализаторе представления, модель представления должна быть инициализирована в модификаторе представления task(priority:_:) и сохранена в optional свойстве.
@Observable final class NetworkController {
// ...
}
@Observable final class ViewModel {
let networkController: NetworkController
init(networkController: NetworkController) {
self.networkController = networkController
}
}
struct PaywallView: View {
@State private var viewModel: ViewModel?
@Environment(NetworkController.self) private var networkController
var body: some View {
Text("Hello, World!")
.navigationTitle("Paywall")
.task {
guard viewModel == nil else { return }
viewModel = ViewModel(networkController: networkController)
}
}
}
Многие разработчики критикуют такой подход, жалуясь на то, что optional приводят к нескольким раздражающим шагам развертывания в коде представления.
Однако создание экземпляра модели представления в родительском элементе PaywallView приведет к дополнительной зависимости между представлениями и нарушит принципы единственной ответственности и неповторяемости (DRY), особенно когда PaywallView доступен сразу по нескольким путям навигации.
Мы можем избежать этих проблем и удалить опциональные значения из представления, внедряя зависимости в модель представления из координатора.
struct PaywallView: View {
@State private var viewModel: ViewModel
init(viewModel: ViewModel) {
self._viewModel = State(initialValue: viewModel)
}
var body: some View {
Text("Hello, World!")
.navigationTitle("Paywall")
}
}
@Observable final class Coordinator {
let networkController = NetworkController()
@ViewBuilder func destination(for recipe: Recipe) -> some View {
if recipe.isPremium {
let viewModel = ViewModel(networkController: networkController)
PaywallView(viewModel: viewModel)
} else {
RecipeView(recipe: recipe)
}
}
}
Координаторы централизуют разрозненную логику навигации и устраняют зависимости
Иногда может отсутствовать связь между данными и навигацией. В таких случаях SwiftUI предлагает View-destination ссылки, а не Value-destination.
Например, представление «Настройки» может явно указывать целевое представление для каждой строки в форме.
struct SettingsView: View {
var body: some View {
Form {
NavigationLink(destination: { ProfileView() }) {
Label("Profile", systemImage: "person.crop.circle")
}
NavigationLink(destination: { AllergiesView() }) {
Label("Allergies", systemImage: "leaf")
}
}
.navigationTitle("Settings")
}
}
Вы можете собрать сопоставление ссылок Value-destination внутри нескольких модификаторов представления navigationDestination(for:_:) в корне дерева навигации внутри NavigationStack.
Однако ссылки View-destination распределяют обязанности навигации между несколькими представлениями, поскольку они должны находиться в представлении, запускающем навигацию.
Это приводит к зависимости между представлениями, и в больших приложениях становится сложно точно определить точки навигации в коде. Идентификация конкретных маршрутов может занимать много времени, а обновления кода могут затрагивать несвязанные части системы.
Координаторы позволяют собрать все пункты назначения навигации в одном месте, что упрощает понимание всей навигации приложения с первого взгляда без необходимости углубляться в код.
@Observable final class Coordinator {
// ...
@ViewBuilder func profileSettings() -> some View {
ProfileView()
}
@ViewBuilder func allergiesSettings() -> some View {
ProfileView()
}
}
Координатор также устраняет связанность представлений.
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView(selection: $coordinator.tab) {
// ...
}
.environment(coordinator)
}
}
struct SettingsView: View {
@Environment(Coordinator.self) private var coordinator
var body: some View {
Form {
NavigationLink(destination: { coordinator.profileSettings() }) {
Label("Profile", systemImage: "person.crop.circle")
}
NavigationLink(destination: { coordinator.allergiesSettings() }) {
Label("Allergies", systemImage: "leaf")
}
}
.navigationTitle("Settings")
}
}
Координаторы централизуют управление навигацией для глубоких ссылок
Еще одна проблема, возникающая при масштабировании ссылок типа «значение-цель» и «представление-цель», заключается в том, что они децентрализуют управление навигацией, что затрудняет или делает невозможным перевод приложения в определенное состояние с помощью глубокой ссылки.
Вместо этого координатор может контролировать все состояние навигации приложения, включая вкладки и модальные окна.
Во-первых, следует отметить, что ссылки типа «представление-цель» (View-destination) не подходят для глубоких ссылок. Согласно документации Apple:
«Ссылка типа View-destination работает по принципу «запустил и забыл»: SwiftUI отслеживает состояние навигации, но с точки зрения вашего приложения нет хуков состояния, указывающих на то, что вы добавили представление».
Следовательно, нам нужны значения даже для тех путей, которые мы обычно обрабатываем с помощью ссылок типа View-destination.
enum AppSection {
case recipes, settings
}
enum SettingsRoute {
case main, profile, allergies
}
@Observable final class Coordinator {
var appSection: AppSection = .recipes
var settingsPath: [SettingsRoute] = []
//...
@ViewBuilder func view(for route: SettingsRoute) -> some View {
switch route {
case .main: SettingsView()
case .profile: ProfileView()
case .allergies: AllergiesView()
}
}
func handleURL(_ url: URL) {
appSection = .settings
settingsPath = [.main, .allergies]
}
}
Метод view(for:) сопоставляет каждый SettingsRoute с представлением. Метод handleURL(_:) затем может реагировать на глубокую ссылку, переключая приложение на вкладку «Настройки» и добавляя AllergiesView в стек навигации.
Связь устанавливается внутри ContentView, который является корневым представлением, где определяется вся структура навигации приложения.
struct ContentView: View {
@State var coordinator = Coordinator()
var body: some View {
TabView(selection: $coordinator.appSection) {
Tab("Recipes", systemImage: "list.bullet.clipboard", value: .recipes) {
// ...
}
Tab("Settings", systemImage: "gear", value: .settings) {
NavigationStack(path: $coordinator.settingsPath) {
coordinator.view(for: .main)
.navigationDestination(for: SettingsRoute.self) { route in
coordinator.view(for: route)
}
}
}
}
.environment(coordinator)
.onOpenURL { url in
coordinator.handleURL(url)
}
}
}
Глубокие ссылки часто приходят извне приложения, но могут также использоваться внутри него для перехода к определенной точке в ответ на действия или события пользователя.
struct RecipesList: View {
@State var recipes = Recipe.data
var body: some View {
List {
ForEach(recipes) { recipe in
// ...
}
Link(
"Set your allergies",
destination: URL(string: "recipes://settings/allergies")!
)
}
.listStyle(.plain)
.navigationTitle("Recipes")
}
}
Не забудьте указать схему URL-адресов вашего приложения в информации о таргете, чтобы оно могло реагировать на входящие глубокие ссылки.
Координаторы и значения маршрутов позволяют проводить модульное тестирование навигации
Благодаря нашему координатору мы теперь можем написать тест, чтобы убедиться, что глубокая ссылка ведет в нужное место.
@Test func allergiesDeepLink() async throws {
let coordinator = Coordinator()
coordinator.handleURL(URL(string: "recipes://settings/allergies")!)
#expect(coordinator.appSection == .settings)
#expect(coordinator.settingsPath == [.main, .allergies])
}
Между значениями SettingsRoute и соответствующими представлениями по-прежнему существует косвенная связь, которую наш тест не может охватить. Однако это область UI-тестирования, поскольку цель модульного теста — проверить логику приложения.
Это означает, что тестирование целевого адреса для премиум-рецептов также требует явной обработки пути навигации стека Recipes в координаторе.
Заключение
Стандартной навигации SwiftUI достаточно для базовых приложений, но она создает ряд архитектурных проблем в масштабе.
Координаторы в рамках паттерна MVVM централизуют маршрутизацию, устраняя связанность представлений, обеспечивая работу глубоких ссылок и улучшая разделение ответственности и тестируемость.
-
Разработка4 недели назад
Навигация на SwiftUI: чего не хватает и как исправить
-
Видео и подкасты для разработчиков1 неделя назад
Разработка видеоредактора
-
Интервью4 недели назад
Маркетологи в мобайле: Святослав Зее (Head of Performancе, Okko)
-
Аналитика магазинов4 недели назад
Есть ли смысл выпускать платные приложения в 2026 году?



