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


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



52   0  

Comments

    Ничего не найдено.