В современной разработке приложений, особенно 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.
Читайте также:
- Добавляем в приложение SwiftUI холст Freeform, чат и видеозвонки
- Работа с графиками в SwiftUI: руководство для начинающих
- Подробно об акторах в Swift
Читайте нас в Telegram, VK и Дзен
Перевод статьи Juan Colilla: Use ‘RemoteResult’ to claim back your data state control