Введение
В этой статье речь пойдет о главной проблеме всех однонаправленных архитектур Swift. Собственно говоря, это не проблема однонаправленных архитектур как таковых. Скорее, это проблема моделирования действий или событий как значений. Я ее называю “пинг-понг-проблемой”. Все дело в “скачках” между разными местами кода, которые приходится преодолевать, чтобы получить целостное представление обо всем потоке. Рассмотрим для начала простой пример.
func handle(event: Event) { switch event { case .onAppear: state = .loading return .task { let numbers = try await apiClient.numbers() await send(.numbersDownloaded(numbers)) } case .numbersDownloaded(let values): state = .loaded(values) return .none } }
Этот код довольно легко читается, но есть еще более простая его версия:
func onAppear() { Task { state = .loading let numbers = try await apiClient.numbers() state = .loaded(numbers) } }
В этой версии не только меньше кода. В ней более связный и понятный код.
При моделировании события как значения теряется возможность читать весь код сверху вниз как целостную часть поведения. Приходится перебирать событие за событием, чтобы сформировать целостное понимание конкретного потока: некое входное событие вызывает какое-то событие обратной связи, которое вызывает другое событие обратной связи и т. д. Между этими событиями может быть множество различных связей, что усложняет понимание кода.
Но, как правило, применение любой архитектуры к любому тривиальному примеру выглядит как чрезмерный инжиниринг.
Представьте, что в предыдущем коде используется что-то вроде архитектуры CLEAN.
func onAppear() { Task { state = .loading let numbers = try await usecase.numbers() state = .loaded(values) } } class UseCase { let repository: RepositoryContract func numbers() async throws -> [Int] { repository.numbers() } } protocol RepositoryContract { func numbers() async throws -> [Int] } class Repository: RepositoryContract { let apiClient: APIClient func numbers() async throws -> [Int] { apiClient.numbers() } }
В обоих случаях введена ненужная косвенность. Чтобы понять такой код, приходится заглядывать в разные места.
Даже ради слоев и абстракций, позволяющих управлять сложностью, обеспечивать гибкость и облегчать тестирование, не стоит забывать об утрате важного принципа программного обеспечения — “локальности поведения” (также называемого “локальным обоснованием”). Как и во всем, здесь не обойтись без компромисса.
Моделирование событий как значений обладает большими преимуществами, например дает возможность полностью отследить изменения состояния и вызвавшие их события. Но это сопряжено с риском для удобочитаемости. Напомню, что код всегда должен быть оптимизирован для удобства прочтения.
Теперь рассмотрим более сложный пример, вдохновленный рабочим процессом фреймворка Mobius от Spotify.
Пинг-понг-проблема на практике
Используя однонаправленную архитектуру, реализуем простой экран входа в систему со следующими требованиями:
- Пользователь может ввести e-mail и пароль для входа в систему.
- В случае отсутствия интернета пользователь не сможет войти в систему.
- Электронная почта сначала проходит локальную валидацию, а затем удаленную.
- Пароль не проходит валидацию.
- Перед попыткой входа в систему пользователь должен пройти процедуру подтверждения.
У нас будет три разных реализации, но сначала разберемся с двумя общими частями: состоянием и эффектами.
struct State: Equatable {
var online: Bool = true
var email: Email = .init()
var password: String = ""
var loggingIn: Bool = false
var canLogin: Bool {
online && email.valid && password.count > 8
}
struct Email: Equatable {
var rawValue: String = ""
var valid: Bool = false
var currentValidation: Validation? = nil
enum Validation {
case local
case remote
}
}
}
enum Effects {
static func login() async throws -> String {
"dummy token"
}
static func localEmailValidation(_ email: String) -> Bool {
email.contains("@")
}
static func remoteEmailValidation(_ email: String) async -> Bool {
return Bool.random()
}
static func showConfirmation(text: String, yes: () async -> Void, no: () async -> Void) async {}
static func onlineStream() -> AsyncStream<Bool> {}
}
Реализация 1: полная однонаправленная архитектура
class ViewReducer: Reducer { enum Input { case onAppear case emailInputChanged(String) case passwordInputChanged(String) case loginButtonClicked } enum Feedback { case internetStateChanged(online: Bool) case loginSuccessful(token: String) case loginFailed case emailLocalValidation(valid: Bool) case emailRemoteValidation(valid: Bool) case loginAlertConfirmation(confirm: Bool) } enum Output { case showErrorToast case loginFinished(_ token: String) } func reduce(message: Message<Input, Feedback>, into state: inout State) -> Effect<Feedback, Output> { switch message { // ПОМЕТКА: - Входные события case .input(.onAppear): return .run { send in for await online in Effects.onlineStream() { await send(.internetStateChanged(online: online)) } } case .input(.emailInputChanged(let value)): state.email.rawValue = value state.email.valid = false state.email.currentValidation = .local let email = state.email.rawValue return .run { send in let valid = Effects.localEmailValidation(email) await send(.emailLocalValidation(valid: valid)) } case .input(.passwordInputChanged(let value)): state.password = value return .none case .input(.loginButtonClicked): guard state.canLogin else { fatalError("Shouldn't be here") } return .run { send in await Effects.showConfirmation(text: "Are you sure?") { await send(.loginAlertConfirmation(confirm: true)) } no: { await send(.loginAlertConfirmation(confirm: false)) } } // ПОМЕТКА: - События обратной связи case .feedback(.emailLocalValidation(valid: let valid)): guard valid else { state.email.currentValidation = nil return .none } state.email.currentValidation = .remote let email = state.email.rawValue return .run { send in let valid = await Effects.remoteEmailValidation(email) await send(.emailRemoteValidation(valid: valid)) } case .feedback(.emailRemoteValidation(valid: let valid)): state.email.valid = valid state.email.currentValidation = nil return .none case .feedback(.loginAlertConfirmation(true)): state.loggingIn = true return .run { send in do { let token = try await Effects.login() await send(.loginSuccessful(token: token)) } catch { await send(.loginFailed) } } case .feedback(.loginAlertConfirmation(false)): return .none case .feedback(.loginSuccessful(token: let token)): state.loggingIn = false return .output(.loginFinished(token)) case .feedback(.loginFailed): state.loggingIn = false return .output(.showErrorToast) case .feedback(.internetStateChanged(let online)): state.online = online return .none } } }
Чтобы разобраться со всем процессом входа, нужно понять, как обрабатывается множество разнообразных событий:
- входное событие emailInputChanged -> событие обратной связи emailLocalValidation -> событие обратной связи emailRemoteValidation;
- входное событие loginButtonClicked -> событие обратной связи loginAlertConfirmation ->событие обратной связи loginSuccessful.
Чтобы понять, что происходит, когда пользователь вводит e-mail и нажимается кнопка входа (login button), приходится посмотреть довольно много событий и переходов между ними. Даже если объединить связанные события в операторе switch, чтобы минимизировать скачкообразность переходов между ними, этот код все равно будет слишком громоздким с резкими скачками и косвенными указаниями.
События обратной связи — вот что производит все эти “пинг-понг-скачки” туда-сюда. Что произойдет, если их убрать? Посмотрим.
Реализация 2: удаление событий обратной связи
Напишем ViewModel с нуля. На этот раз будем моделировать только входные и выходные события как значения.
class ViewModel { enum Input { case onAppear case emailInputChanged(String) case passwordInputChanged(String) case loginButtonClicked } enum Output { case showErrorToast case loginFinished(_ token: String) } private(set) var state: State let stream: AsyncStream<Output> private let continuation: AsyncStream<Output>.Continuation init() { let (stream, continuation) = AsyncStream.makeStream(of: Output.self) self.stream = stream self.continuation = continuation self.state = .init() } func send(_ input: Input) { switch input { case .onAppear: Task { for await online in Effects.onlineStream() { self.state.online = online } } case .emailInputChanged(let value): state.email.rawValue = value state.email.valid = false state.email.currentValidation = .local let valid = Effects.localEmailValidation(value) guard valid else { state.email.currentValidation = nil return } state.email.currentValidation = .remote Task { let valid = await Effects.remoteEmailValidation(value) state.email.valid = valid state.email.currentValidation = nil } case .passwordInputChanged(let value): state.password = value case .loginButtonClicked: guard state.canLogin else { fatalError("Shouldn't be here") } Task { await Effects.showConfirmation(text: "Are you sure?") { state.loggingIn = true defer { state.loggingIn = false } do { let token = try await Effects.login() continuation.yield(.loginFinished(token)) } catch { continuation.yield(.showErrorToast) } } no: { // Ничего } } } } }
Удаление событий обратной связи значительно упростило код. Теперь снова можно читать сверху вниз, и мы видим, что связанные части находятся вместе. Этот код получился более целостным, чем раньше.
Реализация 3: удаление событий как значений
При отсутствии необходимости вести лог и отслеживать входные события, лучше избежать этого массива переходов, используя вместо них простые функции.
class ViewModel { enum Output { case showErrorToast case loginFinished(_ token: String) } private(set) var state: State let stream: AsyncStream<Output> private let continuation: AsyncStream<Output>.Continuation init() { let (stream, continuation) = AsyncStream.makeStream(of: Output.self) self.stream = stream self.continuation = continuation self.state = .init() } func onAppear() { Task { for await online in Effects.onlineStream() { self.state.online = online } } } func emailInputChanged(value: String) { state.email.rawValue = value state.email.valid = false state.email.currentValidation = .local let valid = Effects.localEmailValidation(value) guard valid else { state.email.currentValidation = nil return } state.email.currentValidation = .remote Task { let valid = await Effects.remoteEmailValidation(value) state.email.valid = valid state.email.currentValidation = nil } } func passwordInputChanged(value: String) { state.password = value } func loginButtonClicked() { guard state.canLogin else { fatalError("Shouldn't be here") } Task { await Effects.showConfirmation(text: "Are you sure?") { state.loggingIn = true defer { state.loggingIn = false } do { let token = try await Effects.login() continuation.yield(.loginFinished(token)) } catch { continuation.yield(.showErrorToast) } } no: { // Ничего } } } }
На мой взгляд, эта последняя версия кода читается гораздо лучше, чем любая другая.
Заключение
Как видите, события обратной связи и скачкообразные переходы между ними могут повлиять на удобочитаемость кода, затрудняя его понимание и получение целостного представления о функциональности.
Примите во внимание, что представленный пример демонстрирует максимально проблематичный случай. Можно привести и другие примеры, в которых более ограниченный способ управления состоянием, позволяющий ему изменяться только через события, приводит к гораздо более понятному коду.
Мне было важно показать, чем порой оборачивается решение о применении однонаправленной архитектуры. И дело не только в пинг-понг-проблеме, но и в рассогласовании со SwiftUI, где связывания и другие обертки свойств не вписываются естественным образом и требуют дополнительного кодового шаблона.
Мало того, рассогласование происходит и с эргономикой статического анализа и навигации Xcode. Мы теряем возможность перейти непосредственно к определению функции (переходя к case в enum, вынуждены искать этот case в операторе switch, чтобы увидеть реализацию конкретного события) или использовать удобные функции вроде “иерархии вызовов” в Xcode.
На мой взгляд, более безопасный подход для большинства разработчиков — опираться на минималистическую ViewModel (или другой вид тонкого слоя контроллера/координатора) и переносить как можно больше логики в соответственно смоделированный домен.
Правда, такая “свободная архитектура” может очень легко выйти из-под контроля, если не соблюдать осторожность, в то время как однонаправленные архитектуры обычно отличаются более жесткими ограничениями и строго предусматривают, где и как обрабатываются состояния и эффекты. У всего есть свои плюсы и минусы. Делайте разумный выбор.
Читайте также:
- GoFr и Gin: сравнительное исследование
- Как построить масштабируемый API на Go с помощью Gin
- Создание пользовательских метрик Prometheus в Golang и отправка оповещений в Slack с Grafana
Читайте нас в Telegram, VK и Дзен
Перевод статьи Luis Recuenco: The Dark Side of Unidirectional Architectures in Swift