Темная сторона однонаправленных архитектур Swift
Введение
В этой статье речь пойдет о главной проблеме всех однонаправленных архитектур 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 (или другой вид тонкого слоя контроллера/координатора) и переносить как можно больше логики в соответственно смоделированный домен.
Правда, такая “свободная архитектура” может очень легко выйти из-под контроля, если не соблюдать осторожность, в то время как однонаправленные архитектуры обычно отличаются более жесткими ограничениями и строго предусматривают, где и как обрабатываются состояния и эффекты. У всего есть свои плюсы и минусы. Делайте разумный выбор.
Comments