Site icon AppTractor

От неработающей к тестируемой навигации в SwiftUI: децентрализованный 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 позволяет нам указывать:

Хотя оба варианта полезны, в зависимости от сценария использования, оба создают архитектурные проблемы, если у вас большое приложение.

Вы можете ознакомиться с кодом из этой статьи, скачав полный проект 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)
		}
	}
}

Примечание

Возможно, потребуется также внедрить NetworkController в Coordinator через инициализатор.

Координаторы централизуют разрозненную логику навигации и устраняют зависимости

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

Источник

Exit mobile version