Лучший опыт

Темная сторона однонаправленных архитектур Swift.

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

Правда, такая “свободная архитектура” может очень легко выйти из-под контроля, если не соблюдать осторожность, в то время как однонаправленные архитектуры обычно отличаются более жесткими ограничениями и строго предусматривают, где и как обрабатываются состояния и эффекты. У всего есть свои плюсы и минусы. Делайте разумный выбор.