В современной разработке приложений, особенно SwiftUI на основе реактивного и декларативного проектирования, эффективное управление состоянием  —  важнейшая составляющая. Ведь состоянием приложения, по сути, обусловливается то, что пользователь видит на экране в любой момент времени.

Неправильное управление состоянием чревато несогласованностью пользовательских интерфейсов, трудноотслеживаемыми ошибками, ухудшением пользовательского взаимодействия. Поэтому применение эффективных стратегий контроля и реагирования на изменения в состоянии данных  —  от начальной загрузки до обработки обновлений и ошибок  —  важно для создания надежных, сопровождаемых, удобных приложений.

Большинству приложений требуются данные, размещаемые на сервере. Они доставляются оттуда с разной скоростью, поэтому пользователям необходима визуальная обратная связь.

Благодаря присущей SwiftUI реактивности все «упростилось», но состоянием данных по-прежнему нужно управлять  —  чтобы знать, что и когда отображать на экране.

Что, если для полного контроля за потоком данных расширить функциональность типа Result? Это суперудобно для декларативных интерфейсов и реактивного программирования и основано главным образом на применении перечисления RemoteResult, в котором  —  как и в Result  —  имеются те же два cases с успехом success и сбоем failure и соответствующими связанными типами, обычно это Success и Failure: Error.

enum Result<Success, Failure: Error> {
case success(Success)
case failure(Failure)
}

Прежде чем создавать RemoteResult, определимся, что нам нужно. Нам нужен универсальный способ обозначать состояния ресурса, чтобы из представлений реагировать на его изменения. Для этого к случаям успеха и сбоя Result, которыми уже адекватно представлены эти события при попытке загрузить ресурсы, добавляются два новых состояния  —  бездействия idle и загрузки loading.

С idle у нас имеется базовое значение, которым ресурсы инициализируются из самой логики, и без установки опционалов или дополнительного кода, из-за которых разработка загромождается. Идея проста: если ресурс idle, его загрузка еще не началась и остается в состоянии по умолчанию.

С loading у нас имеется способ указать представлениям, что загрузка ресурса выполняется. Это состояние, в котором не содержится связанных данных, ведь их еще нет, зато благодаря ему мы уже точно знаем, что процесс загрузки начался, и нам проще показать пользователю, например, индикатор загрузки. Особенно в SwiftUI.

Вот RemoteResult с этими двумя дополнениями:

enum RemoteResult<Success, Failure: Error> {
case idle
case loading
case success(Success)
case failure(Failure)
}

Всего шесть строк кода, а у нас уже вырисовывается очень универсальный тип. Теперь используем его в представлении SwiftUI. Сначала создадим типы, соответствующие дженерикам Success и Failure.

Success:

struct User {
let name: String
let mail: String
}

Failure:

enum APIError: Error {
case clientError
case serverError
}

Имея данные для замены дженериков, просто включаем в представление SwiftUI следующее:

@State
var userData: RemoteResult<User, APIError> = .idle

Внимание: в образовательных целях и для облегчения понимания воспользуемся простой MV архитектурой, но излагаемое далее применимо к другим архитектурам, таким как MVVM и TCA. Кроме того, контроль за разделением обязанностей и логика, конечно, могут совершенствоваться.

Продолжим. Теперь для приложения имеются типы, а также переменная для хранения данных, при наличии таковых, и их состояния в любой момент времени. Поскольку состояние помечено как @State, его изменения отслеживаются в SwiftUI. Поэтому, когда появляется соответствующее, затрагивающее его изменение, представление обновляется.

В этой связи применение RemoteResult  —  благодаря ResultBuilders  —  прекрасно иллюстрируется очень простым представлением:

struct ContentView: View {

@State
var userData: RemoteResult<User, APIError> = .idle

var body: some View {
VStack {
switch userData {
case .idle:
EmptyView()
case .loading:
ProgressView()
.progressViewStyle(.circular)
case .success(let user):
Text("Hello \(user.name)")
case .failure(_):
ContentUnavailableView("No User was found", systemImage: "person.fill")
}
}
}
}

У конструктора этого представления нет внешних зависимостей. Пользовательские данные находятся в скрытом состоянии, в ожидании вызванного чем-то изменения состояния. Но необязательных переменных нет, и представлению SwiftUI предписано «знать» в любой момент времени, что отображать, руководствуясь состоянием связанных данных.

Рассмотрим реальное применение этого представления, начнем с создания функции извлечения данных из удаленного источника:

private func getUserData() async {
self.userData = .loading
let request = URLRequest(url: URL(string: "API_URL")!)
do {
let (data, _) = try await URLSession.shared.data(for: request)
let userData = try JSONDecoder().decode(User.self, from: data)
self.userData = .success(userData)
} catch {
self.userData = .failure(error) // Управление ошибками упрощено.
}
}

Остается вызвать ее, в SwiftUI выборка данных автоматизируется очень удобным методом с первого появления экрана.

Нужно лишь добавить в конце VStack это:

.task {
await getUserData()
}

При этом у нас имеется практически вся логика, выполняется основное предназначение RemoteResult  —  автоматическое управление состояниями и реагирование представления на изменения в связанных данных. Когда представление инстанцируется, данные находятся в состоянии idle, а затем переводятся в loading благодаря методу onAppear, которым запускается функция getUserData.

Изменение состояния данных с idle на loading чревато пересчитыванием представления в SwiftUI, вместо EmptyView отображается индикатор загрузки ProgressView. У функции getUserData два возможных результата: либо успешная загрузка ресурса, и тогда RemoteResult становится success(userData), либо по какой-то причине неудачная, и тогда RemoteResult становится failure(error). В обоих случаях поток данных проходит по всем возможным путям с предоставлением необходимой информации, чтобы в любой момент времени реагировать на состояние данных и/или передавать его пользователю.

Все, изложенное выше, основано на выводах статьи из блога Бобби Бобака.

Использование с «The Composable Architecture»

Мы создали и использовали RemoteResult, но вряд ли так программируют на продакшене. Там обычно приходится применять архитектуры или шаблоны программирования, в которых представление отделяется от логики. Тем не менее RemoteResult остается невероятно полезным.

Приведу пример из личного опыта: в повседневной работе использую TCA  —  The Composable Architecture от PointFree  —  и UseCases. Как же адаптировать RemoteResult для подобного сценария?

Вернемся к перечислению. Как использовать RemoteResult в приложении на TCA? Для этого контроль за RemoteResult перемещаем в состояние редьюсера, связанного с представлением, так, чтобы RemoteResult контролировалось там, а изменение состояния, которым запускается обновление представления, управлялось оттуда.

Внесем простое изменение, ведь работа TCA заключается в «выявлении различий» состояния всякий раз, когда из ViewStore связанного представления наблюдается какое-либо изменение. Для этого состояние должно соответствовать Equatable, и одно из изменений, которые нужно внести в RemoteResult, именно такое  —  сделать его Equatable.

Но внимание: такими же Equatable нужно сделать связанные типы RemoteResult для Success и Failure.

Примерно так:

enum RemoteResult<Success: Equatable, Failure: Equatable & Error> : Equatable {
case idle
case loading
case success(Success)
case failure(Failure)
}

Благодаря этому простому изменению, RemoteResult теперь совместимо с логикой сравнения состояний TCA, никаких серьезных изменений не требуется. Так представления загрузки и другие преимущества связываются с изменениями в этом состоянии, размещенном в состоянии редьюсера.

Одной проблемой меньше. Теперь обратимся к UseCases. Это структуры с единственным методом execute, в котором обычно возвращается тип Result<Something, APIError> и решается, что делать в случае успеха или сбоя UseCase из редьюсера.

Использовать RemoteResult в UseCase бессмысленно, ведь этой операции не быть в состоянии idle. Я никогда не сохраняю экземпляр UseCase: создаю и выполняю его на одной строке, обычно как асинхронную задачу благодаря async/await. Поэтому, зная, что idle и loading предназначены исключительно для управления представлениями, очень удобно иметь возможность поглощать в RemoteResult возвращаемое значение UseCase, как только оно получено.

Поскольку RemoteResult основан на Result, для этого в инициализатор внисится простое изменение:

enum RemoteResult<Success: Equatable, Failure: Equatable & Error>: Equatable {
case idle
case loading
case success(Success)
case failure(Failure)

init(_ result: Result<Success, Failure>) {
switch result {
case .success(let data):
self = .success(data)
case .failure(let error):
self = .failure(error)
}
}
}

И точно так же, с этими двумя небольшими изменениями, логика контролируется из редьюсера или ViewModel без потери функциональности, привносимой RemoteResult в управление представлениями.

Вот примеры:

struct LoginUseCase {
func execute(loginInfo: LoginEncoder) async -> Result<LoginDecoded, APIError> {
do {
let authData: LoginDecoded = try await Environment.apiClient.send(
LoginRequest(
body: [
"user": loginInfo.user,
"password": loginInfo.password
]
),
authorized: false
)
return .success(authData)
} catch let error as APIError {
return .failure(error)
} catch {
console(error)
return .failure(.UNDERLYING_ERROR(error))
}
}
}

Здесь показан UseCase, где асинхронно аутентифицируется пользователь.

То же выполняется в TCA из редьюсера:

import ComposableArchitecture

struct LoginReducer: Reducer {
struct State: Equatable {
var loginData: RemoteResult<LoginDecoded, APIError> = .idle
}

enum Action: Equatable {
case loginPressed
case _loginExecuted(Result<LoginDecoded, APIError>)
}

var body: some ReducerOf<Self> {
Reduce { state, action in
case .loginButtonPressed:
state.loginData = .loading
let loginInfo: LoginEncoder = .init(
user: state.user,
password: state.password,
)
return .run { send in
let authData: Result<LoginDecoded, APIError> = await LoginUseCase().execute(loginInfo: loginInfo)
await send(._loginExecuted(loginData))
}

case ._loginExecuted(let loginResponse):
switch loginResponse {
case .success(let loginData):
// Управляем данными аутентификации
case .failure(let error):
// Реагируем на любую ошибку во время вызова
}
}
}
}

И то же во ViewModel:

import Combine  

class LoginViewModel: ObservableObject {

@Published
var loginData: RemoteResult<LoginDecoded, APIError> = .idle

func login() async -> Result<LoginDecoded, APIError> {
loginData = .loading
let loginInfo: LoginEncoder = .init(
user: state.user,
password: state.password,
)
let authData: Result<LoginDecoded, APIError> = await LoginUseCase().execute(loginInfo: loginInfo)
return authData
}
}

Этими примерами я завершаю статью о том, за счет чего и почему RemoteResult  —  это хороший способ контроля за потоком данных со SwiftUI и реактивным программированием. Надеюсь, вы чему-то научились или увидели что-то в новом свете.

Всегда есть что улучшить…

Публикация оригинальной статьи вызвала различные мнения и предположения, которые привели к изучению потенциальных улучшений, а также интересным обсуждениям ее содержания. Цели написания статьи были разные, но главная  —  обучение, как для меня, так и для читателей.

Статью пришлось расширить сначала небольшим уточнением, предложенным техлидом iOS Tymit Джулианом Алонсо.

Предусматривать, а не требовать

Одна из внесенных в статью корректировок касается соответствия Equatable  —  для облегчения интеграции перечисления с RemoteResult, в частности эта сигнатура:

enum RemoteResult<Success: Equatable, Failure: Equatable & Error>: Equatable

Как справедливо указывает Джулиан, такой установкой главной сигнатуры мы требуем, чтобы RemoteResult и все элементы внутри Success и Failure соответствовали Equatable, а это не всегда необходимо или желаемо, ведь иногда Equatable приходится делать модели. Если они очень сложные, это превращается в головную боль.

Корректировка делается так, чтобы в этом требовании применения RemoteResult с моделями и ошибками, которые не являются Equatable, учитывались и особые случаи, где они Equatable являются. Таким образом, например, не теряется совместимость с TCA.

Тогда итоговый код будет таким:

enum RemoteResult<Success, Failure: Error> {
case idle
case loading
case success(Success)
case failure(Failure)

init(_ result: Result<Success, Failure>) {
switch result {
case .success(let data):
self = .success(data)
case .failure(let error):
self = .failure(error)
}
}
}

extension RemoteResult: Equatable where Success: Equatable, Failure: Equatable {}

С этим кодом, пока Success и Failure будут Equatable, таким же будет и RemoteResult, но не обязательно. И благодаря тому, что Success и Failure будут Equatable, в Swift автоматически синтезируется статическая функция ==, с которой код избавляется от всех этих rhs, lhs

Так ли нужно это «idle»?

Майкл Лонг предложил интересную дискуссию в комментариях. Он утверждает, что case с idle совершенно не нужен, поскольку заменяется тем, что переменная состояния делается необязательной, поэтому case .idle представляется как nil.

Это действительно так. Конкретно этот case вполне заменяется управлением необязательной переменной состояния без потери какой-либо функциональности, но это не значит, что данный подход лучше или хуже.

Мое мнение: это скорее связано со стилем программирования того или иного человека. То есть сравнение между случаями перечисления, а также проверка наличия значения в переменной, что само по себе является сравнением случаев перечисления,  —  это, насколько я правильно понял, две высокооптимизированные операции в Swift, поэтому нет технических причин предпочитать один подход другому.

Тем не менее по-прежнему считаю, что case .idle  —  лучшая альтернатива, и объясню почему. Если отказаться от idle в RemoteResult и сделать его необязательным в состоянии, получится вот что:

struct ContentView: View {

@State
var userData: RemoteResult<User, CustomError>?

var body: some View {
VStack {
if let userData {
switch userData {
case .loading:
ProgressView()
.progressViewStyle(.circular)
case .success(let user):
Text("Hello \(user.name)")
case .failure(_):
ContentUnavailableView("No User was found", systemImage: "person.fill")
}
} else {
EmptyView()
}
}
.task {
await getUserData()
}
}
}

Сравним с исходным кодом:

struct ContentView: View {

@State
var userData: RemoteResult<User, CustomError> = .idle

var body: some View {
VStack {
switch userData {
case .idle:
EmptyView()
case .loading:
ProgressView()
.progressViewStyle(.circular)
case .success(let user):
Text("Hello \(user.name)")
case .failure(_):
ContentUnavailableView("No User was found", systemImage: "person.fill")
}
}
.task {
await getUserData()
}
}
}

Код практически тот же, но при применении опционала приходится распаковывать его значение, чтобы убедиться, что мы можем его использовать.

Вот другой вариант сокращения кода с помощью опционала:

struct ContentView: View {

@State
var userData: RemoteResult<User, CustomError>?

var body: some View {
VStack {
switch userData {
case .loading:
ProgressView()
.progressViewStyle(.circular)
case .success(let user):
Text("Hello \(user.name)")
case .failure(_):
ContentUnavailableView("No User was found", systemImage: "person.fill")
default:
EmptyView()
}
}
.task {
await getUserData()
}
}
}

Но в этом случае задается default, а это станет заметно, когда пожелаем увеличить количество состояний, которые содержатся в RemoteResult, ведь компилятор не «заставит» учитывать их все.

Еще один курьез заключается в самой сути опционалов Swift, потому что опционал  —  это обертка над типом, который инкапсулируется ею в перечисление. Опционалы в Swift создаются так:

enum Optional<WrappedType> : ExpressibleByNilLiteral {

case none
case some(WrappedType)

public init(_ some: WrappedType)
}

Если следовать предложению Майкла Лонга и сделать RemoteResult<Success, Failure> необязательным, будет установлена такая переменная состояния:

enum Optional<RemoteResult<Success, Failure>> : ExpressibleByNilLiteral {

case none
case some(RemoteResult<Success, Failure>)

public init(_ some: RemoteResult<Success, Failure>)
}

А этот код чреват следующими курьезами при выполнении switch в userData:

switch userData {
case .loading:
case .success(let user):
case .failure(let error):
case .none:
case .some(let remoteResult):
switch remoteResult {
case .loading:
case .success(let user):
case .failure(let error)
}
}

Оператор switch показан без возвращаемого содержимого случаев: так лучше видно, сколько здесь заключено возможностей и насколько запутанным он может стать.

Если заполнить этот switch тем, что возвращается из представления, возникнут несоответствия или дублирующиеся случаи и получится код, который трудно понять из-за очевидного отсутствия логики. Хотя функционально case some в нем никогда не окажется, поскольку нет fallthrough, которым разрешается продолжение выполнения после нахождения совпадения. Switch никто так не использует, просто интересно было узнать, что такая возможность имеется.

В итоге нет, наверное, очевидного варианта использования .idle, которым оправдано его существование по сравнению с использованием опционалов. Но реальность такова, что при его наличии код намного структурированнее и удобнее для восприятия. И так проще следовать практикам безопасного программирования, поскольку состояние всегда находится в известном значении одной из четырех возможностей, предусмотренных в RemoteResult.

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

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


Перевод статьи Juan Colilla: Use ‘RemoteResult’ to claim back your data state control

Предыдущая статьяПриемы работы в терминале Linux для повышения продуктивности
Следующая статьяМатематика, скрывающаяся за “проклятием размерности”