Введение

В этой статье речь пойдет о главной проблеме всех однонаправленных архитектур 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 (или другой вид тонкого слоя контроллера/координатора) и переносить как можно больше логики в соответственно смоделированный домен

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

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Luis Recuenco: The Dark Side of Unidirectional Architectures in Swift

Предыдущая статьяАвтоматизация платежей со Stripe и Golang: руководство разработчика
Следующая статьяНаш первый миллиард строк в DuckDB