API для удаленной асинхронной выборки с помощью Apple Combine.

Combine — это функционально реактивный Swift фреймворк, который недавно реализован для всех платформ Apple, включая Xcode 11. С помощью Сombine очень легко обрабатывать последовательности значений values во времени. Он также позволяет упростить асинхронный код, отказавшись от делегирования и сложных вложенных callbacks.

Но изучение самого фреймворка Сombine на первых порах может показаться не таким уж простым. Дело в том, что основными «игроками» Сombine являются такие абстрактные понятия как «издатели» Publishers, «подписчики» Subscribers и операторы Operators. Есть, конечно, и другие, но без понимания этих 3-х не удастся много достигнуть в понимании логики функционирования Combine. Поэтому статья начинается с очень краткого обзора этих основных понятий. А далее нас ждет приятный сюрприз от Apple. Большинство»издателей» Publishers, «подписчиков» Subscribers и операторов Operators либо уже реализованы в самом Combine, либо они добавлены к уже существующим классам UISession, Timer, NotificationCenter, CoreData. И это существенно облегчает написание кода, который оказывается очень компактным и хорошо читаемым. Вы увидите это на примере приложения, связанного с асинхронной выборкой информации о фильмах из очень популярной сейчас базы данных TMDb. Мы создадим  два различных приложения: UIKit и SwiftUI, и покажем, как с ними работает Combine.

Надеюсь, эта статья облегчит Вам изучение Сombine.

Код для всех приложений, разработанных в этой статье, можно найти на Github.

Сначала кратко рассмотрим основные идеи функционирования фреймворка Combine.

В Combine есть несколько главных компонент:

«Издатель» Publisher.

«Издатели» Publishers — это ТИПЫ, которые доставляют значения values всем, кто этим интересуется. Концепция «издателя» Publisher реализована в Combine в виде протокола protocol, а не конкретного ТИПА. У протокола Publisher есть ассоциированные Generic ТИПЫ для выходного значения Output и ошибки Failure«Издатель», который никогда не публикует ошибку, использует ТИП Never для ошибки Failure.

Apple предоставляет в распоряжение разработчиков конкретные реализации уже готовых «издателей» Publishers: Just, Future, Empty, Deferred, Sequence, @Published и т.д. Добавлены также и определенные «издатели» к классам Foundation: URLSessionNotificationCenter, Timer.

«Подписчик» Subscriber.

Это также протокол protocol, который обеспечивает интерфейс для «подписки» на значение value от «издателя». У него есть ассоциированные Generic ТИПЫ для входного значения Input и ошибки Failure. Очевидно, что ТИПЫ «издателя» Publisher и «подписчика» Subscriber должны совпадать.

Для любого «издателя» Publisher есть два встроенных «подписчика» Subscribers: sink и assign:

«Подписчик» sink основан на двух замыканиях: одно замыкание, receiveValue, выполняется тогда, когда вы получаете значения values, второе замыкание, receiveCompletion, выполняется при завершении «публикации» ( нормальным образом или с ошибкой).

«Подписчик» assign продвигает каждое полученное значение value по направлению к заданному key Path.

«Подписка» Subscription.

Сначала «издатель» Publisher создает и поставляет»подписку» Subscription «подписчику» Subscriber через его метод receive (subscription:):

После этого, Subscription может посылать свои значения values «подписчикам» Subscribers с помощью двух методов:

Если вы завершили работать с «подпиской» Subscription, то можно вызвать её cancel ( ) метод:

«Субъект» Subject.

Это протокол protocol, который обеспечивает интерфейс для обоих клиентов, как для»издателя», так и для»подписчика».  По существу, «субъект»Subject — это «издатель» Publisher, который может принимать входное значение Input и который вы можете использовать для того, чтобы «впрыскивать» значения values в поток (stream) путем вызова метода send(). Это может быть полезно при адаптации существующего императивного кода в Combine Модели.

Оператор Operator.

С помощью оператора вы можете создать нового «издателя» Publisher из другого «издателя» Publisher путем преобразования, фильтрации и даже путем комбинации значений values из множества предыдущих upstream «издателей» Publishers.

Вы видите здесь множество знакомых имен операторов: compactMap, map, filter, dropFirst, append.

Встроенные в Foundation «издатели» Publishers.

Apple также предоставляет разработчикам несколько уже встроенных функциональных возможностей Combine во фреймворке Foundation, то есть»издателей» Publishers, для таких задач, как выборка данных с помощью URLSession, работа с уведомлениями с помощью Notification, таймер Timer и наблюдение за свойствами на основе KVO. Эта встроенная совместимость действительно поможет нам интегрировать фреймворк Combine в наш текущий проект.
Чтобы узнать больше об этом, можно посмотреть статью «The ultimate Combine framework tutorial in Swift».

Что мы научимся делать с помощью Combine?

В этой статье мы научимся применять фреймворк Combine для выборки данных о фильмах с сайта TMDb API. Вот что мы будем вместе изучать:

  • Применение «издателя» Future для создания замыкания с promise для единственного значения: либо value, либо ошибки.
  • Применение «издателя» URLSession.datataskPublisher для «подписки» на данные data, публикуемые заданным URL.
  • Применение оператора tryMap для преобразования данных data с помощью другого «издателя» Publisher.
  • Применение оператора decode для преобразования данных data в Decodable объект и опубликование его для передачи в последующие элементы цепочки «вниз по течению».
  • Применение оператора sink для «подписки» на «издателя» Publisher с помощью замыканий.
  • Применение оператора assign для «подписки» на «издателя» Publisher и присвоения поставляемого им значения value заданному key Path.

Начальный проект

Прежде, чем мы начнем, мы должны должны зарегистрироваться, чтобы получить API ключ на сайте TMDb. Вам нужно также загрузить начальный проект из репозитория GitHub.
Убедитесь, что вы разместили свой API ключ в классе class MovieStore в константе let apiKey.

Вот основные строительные блоки, из которых мы создадим наш проект:

  • 1. Внутри файла Movie.swift находятся Модели, которые мы будем использовать в нашем проекте. Корневая структура struct MoviesResponse реализует протокол Сodable, и мы воспользуемся этим при декодировании JSON данных в Модель. В структуре MoviesResponse есть свойство results, которое также реализует протокол Decodable и представляет собой коллекцию фильмов [Movie]. Именно она нас и интересует:

  • 2. Перечисление enum MovieStoreAPIError реализует протокол Error. Наше API будет использовать это перечисление для представления разнообразного рода ошибок:ошибок получения URL urlError,  ошибок  декодирования decodingError и ошибок выборки данных responseError.

  • 3. В нашем API есть протокол MovieService с единственным методом fetchMovies (from endpoint: Endpoint), который выбирает фильмы [Movie] на основе параметра endpoint. Сам по себе Endpoint — это перечисление enum, которое представляет endpoint для обращения к базе данных TMDb API с целью выборки таких фильмов, как nowPlaying (последние), popular (популярные), topRated (топовые) и upcoming (скоро на экране).

  • 4. Класс MovieStore — это конкретный класс, который реализует протокол MovieService для выборки данных с сайта TMDb API. Внутри этого класса мы реализуем метод fetchMovies (…), используя Combine.

  • 5. Класс MovieListViewController — это основной ViewController класс, в котором мы реализуем с помощью метода sink «подписку» на метод выборки фильмов fetchMovies (…), который возвращает «издателя» Future, а затем обновим таблицу TableView с помощью новых данных о фильмах movies, используя новый DiffableDataSourceSnapshot  API.

Прежде, чем мы начнем, давайте изучим некоторые основные компоненты Combine, которые мы будем использовать для API удаленной выборки данных.

«Подписываемся» на «издателя» с помощью sink и его замыканий.

Наиболее простой способ «подписаться» на «издателя» Publisher — это использовать sink с его замыканиями, одно из которых будет выполняться всякий раз, когда мы получаем новое значение value, а другое — когда «издатель» закончит поставку значений values.

Помните, что в Combine каждая «подписка» возвращает Cancellable, который будет удален как только мы покинем наш контекст. Чтобы поддерживать «подписку» более длительное время, например, для асинхронного получения значений values, нам нужно сохранить «подписку» в свойстве subscription1. Это позволило нам последовательно получить все значения values (7,8,3,4).

Future с помощью Promise асинхронно «публикует»  единственное значение: либо value, либо ошибку Failure.

Во фреймворке Combine «издатель» Future можно использовать для асинхронного получения единственного значения ТИПА Result с помощью замыкания. У замыкания один параметр — Promise, который является функцией ТИПа (Result<Output, Failure>) -> Void.

Давайте рассмотрим простейший пример, чтобы понять, как функционирует «издатель» Future:

Мы создаём Future с успешным результатом ТИПА Int и ошибкой ТИПА Never. Внутри замыкания Future  мы используем DispatchQueue.main.asyncAfter ( … ), чтобы задержать выполнение кода на 2 секунды, и тем самым имитируем асинхронное поведение. Внутри замыкания возвращаем Promise с успешным результатом promise (.success( … )) в виде целого случайного значения Int в диапазоне между 0 и 100. Далее мы используем две подписки на futurecancellable и cancellable1 — и обе дают один и тот же результат, хотя внутри генерируется случайное число.

Примечание 1. Следует отметить, что «издатель» Future имеет некоторые особенности поведения по сравнению с другими «издателями»:

  • «Издатель» Future всегда «публикует» ОДНО значение (value или ошибку) и на этом завершает свою работу.
  • «Издатель» Future является классом class (reference type) в отличие от других «издателей» которые преимущественно являются структурами struct ( value type), и ему передается в виде параметра замыкание Promise, которое создается сразу же при инициализации экземпляра «издателя» Future. То есть замыкание Promise передается до того, как какой-нибудь «подписчик» subscriber вообще подпишется на экземпляр «издателя» Future. «Издатель» Future вообще не требует для своего функционирования «подписчика», как этого требуют все остальные обычные «издатели» Publishers. Именно поэтому в вышеприведенном коде печатается текст «Hello from inside the future!» только один раз.
  • «Издатель» Future является eager (нетерпеливым) «издателем» в отличие от большинства остальных lazy «издателей» («публикуют» только при наличие «подписки») . Лишь однажды замыкание «издателя» Future вызывает свой Promise, результат запоминается и затем поставляется текущему и будущим «подписчикам». Из вышеприведенного кода мы видим, что при повторной «подписке» sink к издателю future всегда выдается одно и то же»случайное» значение  (в данном случае 6, но может быть и другое, но всегда одно и то же), хотя в замыкании используется случайное Int значение.

Такая логика»издателя» Future позволяет успешно использовать его для запоминания асинхронного ресурсо-затратного вычисляемого результата и не беспокоить «сервер» для последующих многократных «подписок». 

Если вас такая логика «издателя» Future не устраивает и вы хотите, чтобы ваш Future вызывался lazy и каждый раз вы бы получали новые случайные Int значения, то вам следует «обернуть» Future в Deferred :

Мы будем использовать Future классическим образом, как это предлагается в Combine, то есть как «разделяемого» вычисляемого асинхронного «издателя».

  • Примечание 2. Нужно сделать еще одно замечание относительно «подписки» с помощью sink к асинхронному «Издателю». Метод sink возвращает AnyCancellable, который мы не запоминаем постоянно. Это означает, что Swift разрушит AnyCancellable к тому времени, когда вы покинете данный контекст (scope), что собственно и происходит на main thread. Таким образом, получается, что AnyCancellable оказывается разрушенным прежде, чем замыкание с Promise сможет стартовать на main thread. Когда AnyCancellable разрушается, то вызывается его метод cancel, который в этом случае аннулирует «подписку».  Именно поэтому мы запоминаем наши sink «подписки» к future в переменных cancellable и cancellable1 или в Set<AnyCancellable> ( ).

Использование Combine для выборки фильмов movies с сайта TMDb

Начнем с того, что вы откроете стартовый проект и перейдете к файлу MovieStore.swift и методу fetchMovies с пустой реализацией:

С помощью метода fetchMovies мы можем выбирать различные фильмы, задавая определенные значения входному параметру endpoint ТИПА Endpoint. ТИП  Endpoint — это перечисление enum, которое принимает значения nowPlaying (текущие), upcoming (скоро выйдут на экран), popular (популярные),  topRated (топовые):

Давайте начнем с инициализации Future с callback замыканием. Полученное Future мы затем вернем.

Внутри callback замыкания мы генерируем URL для соответствующего значения входного параметра endpoint с помощью функции generateURL (with endpoint:Endpoint) :

Если правильного URL сформировать не удалось, то возвращаем ошибку с помощь promise (.failure ( .urlError (… )), в противном случае идем дальше и реализуем «издателя» URLSession.dataTaskPublisher.

Для «подписки» на данные с некоторого URL мы можем использовать встроенный в класс URLSession метод datataskPublisher, который получает URL как параметр и возвращает «издателя» Publisher с выходными данными Output ТИПА кортежа (data: Data, response: URLResponse) и ошибки Failure ТИПА URLError.

Для преобразования одного «издателя» Publisher в другого «издателя» Publisher используем оператор tryMap. По сравнению с map, оператор tryMap может «выбрасывать» throws ошибку Error внутри замыкания, которое возвращает нам нового «издателя» Publisher.

На следующем шаге мы будем использовать оператор tryMap для проверки кода statusСode http ответа response, чтобы убедиться, что его значение находится между 200 и 300. Если нет, то мы выбрасываем throws значение ошибки responseError перечисления enum MovieStoreAPIError. В противном случае (когда нет ошибок) мы просто возвращаем полученные данные data следующему в цепочке «издателю» Publisher.

На следующем шаге будем использовать оператор decode, который декодирует выходные JSON данные предыдущего tryMap «издателя» в Модель MovieResponse с помощью JSONDecoder.

jsonDecoder настраиваем на определенный формат даты :

Чтобы обработка выполнялась на main thread, мы будет использовать оператор receive(on:) и передадим ему в качестве входного параметра RunLoop.main.  Это позволит «подписчику» получить значение value на main потоке.

Наконец мы добрались до конца нашей цепочки преобразований, и там мы используем sink для получения «подписки» subscription на сформированную upstream «цепочку» «издателей» Publishers. Для инициализации экземпляра класса Sink нам понадобятся две вещи, хотя одна из них — необязательная:

  1. замыкание receiveValue: . Будет вызываться всякий раз, когда «подписка» subscription получает новое значение value от «издателя» Publisher.
  2. замыкание receiveCompletion: (Необязательное). Будет вызвано после того, как «издатель» Publisher закончит публикацию значения value, ему передается перечисление completion, которое мы можем использовать для проверки того, действительно ли «публикация» значений закончена или завершение произошло по причине возникновения ошибки.

Внутри замыкания receiveValue, мы просто вызываем promise с вариантом .success и значением $0.results, которым в нашем случае является массив фильмов  movies. Внутри замыкания receiveCompletion мы проверяем, есть ли у completion ошибка error, затем передаем соответствующую ошибку promise с вариантом .failure.

Заметьте, что мы здесь собираем все ошибки, «выброшенные» на предыдущих этапах «цепочки издателей».

Запоминание «подписки» subscription в свойстве  Set<AnyCancellable>

Помните, что «подписка» subscription является Cancellable,  Этот ТИП является протоколом, который уничтожает и очищает все после завершения функции fetchMovies. Чтобы гарантировать сохранение «подписки» subscription и после завершения этой функции, нам необходимо запомнить «подписку» subscription во внешней по отношению к функции fetchMovies переменной. В нашем случае мы используем свойство subscriptions, которое имеет ТИП Set<AnyCancellable> и применяем метод .store (in: &self.subscriptions), который обеспечивает нам работоспособность «подписки» после того, как функции fetchMovies завершит свою работу.  

На этом мы заканчиваем формирование метода fetchMovies выборки фильмов из базы данных TMDb с помощью фреймворка Combine. Метод fetchMovies в качестве входного параметра from принимает значение перечисления enum Endpoint, то есть какие конкретно фильмы нас интересуют: 

  1. .nowPlaying  — фильмы, которые сейчас идут на экране,
  2. .upcoming — фильмы, которые скоро выйдут на экран,
  3. .popular — популярные фильмы,
  4. .topRated  — топовые фильмы, то есть с очень высоким рейтингом.

Давайте попробуем применить этот API к проектированию приложения с обычным UIKit пользовательским интерфейсов в виде таблицы Table View Controller :

и к приложению, пользовательский интерфейс которого построен с помощью нового декларативного фреймворка SwiftUI:

«Подписываемся» на фильмы movies из обычного View Controller и заполняем данными movies таблицу Table View.

Мы перемещаемся в файл MovieListViewController.swift и в методе viewDidLoad вызываем метод fetchMovies.

Внутри нашего метода fetchMovies мы используем разработанный ранее movieAPI и его метод fetchMovies с параметром .nowPlaying в качестве endpoint входного параметра from. То есть мы будем выбирать фильмы, которые в данный момент идут на экранах кинотеатров.

Метод movieAPI.fetchMovies (from:.nowPlaying) возвращает «издателя» Future, на которого мы «подписываемся» с помощью sink, и снабжаем его двумя замыканиями. В замыкании receiveCompletion проверяем, есть ли ошибка error и выводим экстренное предупреждение пользователю alert с отображением сообщения об ошибке.

В замыкании receiveValue мы вызываем метод generateSnapshot и передаем ему выбранные фильмы movies.

Функция generateSnapshot генерирует новый NSDiffableDataSourceSnapshot, используя наши movies, и применяет полученный snapshot к  diffableDataSource нашей таблицы.

Запускаем приложение и смотрим, как UIKit работает вместе с «издателями» Publishers и «подписчиками» из фреймворка Combine. Это очень простое приложение, демонстрирующее совместную работу UIKit и Combine, но не позволяющее настраивать с помощью параметра ТИПА Endpoint список фильмов на различные коллекции фильмов — демонстрируемые сейчас на экране, популярные, высоко-рейтинговые или те, которые собираются появиться на экране  в ближайшее время.  Конечно, это можно сделать, добавив любой UI элемент для задания значений перечисления Endpoint, например, с помощью Stepper или Segmented Control, а затем обновить пользовательский интерфейс. Это общеизвестно, и мы не будем это делать  в приложении на основе UIKit, а оставим это новому декларативному фреймворку SwiftUI.

Код для приложения на основе UIKit можно найти на Github в папке CombineFetchAPICompleted-UIKit.

Используем SwiftUI для отображения фильмов movies.

Создаём новое приложение CombineFetchAPI-MY со SwiftUI интерфейсом с помощью меню File-> New -> Project и выбираем шаблон Single View App в разделе iOS:

Затем указываем имя проекта и способ создания UISwiftUI:

Далее задаём местоположения проекта и копируем в новый проект файл Модели Movie.swift и размещаем его в папке Model, необходимые для взаимодействия с TMDb API файлы MovieStore.swiftMovieStoreAPIError.swift и MovieService.swift, и размещаем их соответственно в папках MovieService и Protocol:

В SwiftUI требуется, чтобы Модель была Codable, если мы собираемся наполнять её JSON данными, и Identifiable, если мы хотим облегчить себе отображение списка фильмов [Movie] в виде списка List.  Модели в SwiftUI не требуется быть Equatable и Hashable, как этого требовал UIKit API для UITableViewDiffableDataSource в предыдущем UIKit приложении. Поэтому убираем из структуры struct Movie все методы, связанные с протоколами Equatable и Hashable:

.  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .  .

Есть прекрасная статья Identifiable, в которой показано различие и сходство между Swift протоколами Identifiable, Hashable и Equatable.

Пользовательский интерфейс, который мы создадим с помощью SwiftUI, основан на том, что мы меняем endpoint при выборке данных и получаем нужную нам коллекцию фильмов, которая представляется в виде списка:

Также, как и в случае с UIKit, выборка данных производится с помощью функции movieAPI.fetchMovies (from endpoint: Endpoint), которая получает нужный endpoint и возвращает «издателя» Future<[Movie, MovieStoreAPIError]>. Если мы взглянем на перечисление Endpoint, то увидим, что мы можем инициализировать нужный вариант case в перечислении Endpoint и соответственно нужную коллекцию фильмов с помощью индекса index:

Следовательно, для получения нужной нам коллекции фильмов movies, достаточно задать соответствующий индекс indexEndPoint перечисления Endpoint. Давайте сделаем это в View Model, которая в нашем случае будет классом final class MoviesViewModel, реализующем протокол ObservableObject. Добавим в наш проект новый файл  MoviesViewModel.swift для нашей View Model:

В этом очень простом классе у нас два @Published свойства: одно @Published var indexEndpoint: Int — входное, другое @Published var movies: [Movie] — выходное. Как только мы поставили @Published перед свойством var indexEndpoint: Int мы  можем начать использовать его и как простое свойство indexEndpoint, и как издателя $indexEndpoint.

При инициализации экземпляра нашего класса MoviesViewModel мы должны протянуть цепочку от входного «издателя»  $indexEndpoint до выходного «издателя» ТИПА AnyPublisher<[Movie], Never>, который мы получаем с помощью уже известной нам функции movieAPI.fetchMovies (from: Endpoint (index: indexPoint)) и оператора flatMap.

Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью очень простого «подписчика» assing (to: \.movies, on: self) и присваиваем полученное от «издателя» значение выходному массиву movies.  Мы можем применять «подписку» assing (to: \.movies, on: self)  только в том случае, если «издатель» не выбрасывает ошибку, то есть имеет ТИП ошибки Never. Как этого добиться? С помощью оператора replaceError(with: [ ]), который заменит любые ошибки на пустой массив фильмов movies.

То есть первый более простой вариант нашего SwiftUI приложения не будет выводить пользователю информацию о возможных ошибках.

Теперь, когда у нас есть View Model для наших фильмов, приступим к созданию UI. Добавим в файл ContentView.swift нашу View Model как @EnvironmentObject переменную var moviesViewModel и заменим Text(«Hello, World!»)
на Text(«\(moviesViewModel.indexEndpoint)»), который просто отображает индекс indexEndpoint варианта коллекции фильмов.

По умолчанию в нашей  View Model индекс коллекции indexEndpoint = 2 , то есть мы при старте приложения должны увидеть фильмы, которые в ближайшее время выйдут на экран (Upcoming):

Затем добавим UI элементы для управления тем, какую коллекцию фильмов мы хотим показывать. Это Stepper :

… и Picker:

Оба используют «издателя» $moviesViewModel.indexEndpoint нашей View Model, и с помощью одного из них (все равно какого) мы можем выбрать требуемую нам коллекцию фильмов:

Затем добавляем список полученных фильмов с помощью List и ForEach и минимальными атрибутами самого фильма movie:

Список отображаемых фильмов moviesViewModel.movies мы также берем из нашей View Model:

Мы НЕ используем «издателя» $moviesViewModel.movies со знаком $, потому что не собираемся в этом списке фильмов ничего редактировать. Мы используем обычное свойство moviesViewModel.movies.

Можно сделать список фильмов более интересным, если отображать в каждой строчке списка кинопостер соответствующего фильма, URL которого представлен в структуре Movie:

Мы заимствуем эту возможность у Thomas Ricouard из его прекрасного проекта MovieSwiftUI.

Также как и в случае загрузки фильмов movies, для изображения UIImage у нас появляется сервис ImageService, который реализует с помощью Combine метод fetchImage, возвращающий «издателя» AnyPublisher<UIImage?, Never> :

… и final class ImageLoader: ObservableObject, реализующий протокол ObservableObject с @Published свойством image: UIImage?:

Единственное требование, которое выдвигает протокол  ObservableObject — это наличие свойства objectWillChange. SwiftUI использует это свойство для понимания того, что в экземпляре этого класса что-то изменилось, и как только это произошло, обновляет все Views, зависимые от экземпляра этого класса. Обычно компилятор автоматически создает свойство objectWillChange,  а все @Published свойства также автоматически уведомляют его об этом. В случае каких-то экзотических ситуаций вы можете вручную создать objectWillChange и уведомлять его о происшедших изменениях. У нас именно такой случай. 

В классе ImageLoader мы имеем единственное @Published свойство var image:UIImage?. Оно в данной остроумной реализации является одновременно и входным и выходным, При инициализации экземпляра класса ImageLoader, мы используем «издателя» $image и при «подписке» на него вызываем функцию loadImage(), которая загружает требуемое нам изображение poster нужного размера size и присваивает его @Published свойству var image:UIImage?. Об этих изменениях мы уведомляем  objectWillChange.

В таблице у нас может быть много таких изображений, что приводит к существенным временным затратам, поэтому мы используем кэширование экземпляров imageLoader класса ImageLoader:

У нас есть специальное View для воспроизведения кинопостера MoviePosterImage:

… и мы будем использовать его при отображении списка фильмов в нашем основном ContentView:

Код для приложения на основе SwiftUI без отображения ошибок можно найти на Github в папке CombineFetchAPI-NOError.

Отображение ошибок удаленной асинхронной выборки фильмов.

До сих пор мы не использовали и не отображали ошибки, возникающие в процессе удаленной асинхронной выборки фильмов с сайта TMDb. Хотя используемая нами функция movieAPI.fetchMovies (from endpoint: Endpoint), позволяет это сделать, так как возвращает «издателя» Future<[Movie, MovieStoreAPIError]>.

Для того, чтобы учесть ошибки, добавляем в нашу View Model еще одно @Published свойство moviesError: MovieStoreAPIError?, которое представляет ошибку. Это Optional свойство, его начальное значение равное nil, что соответствует отсутствию ошибки:

Для того, чтобы получить эту ошибку moviesError, нам придется немного изменить инициализацию класса MoviesViewModel и использовать более сложного «подписчика» sink:

Ошибку moviesError можно отобразить на UI, если она не равна nil …

с помощью AlertView:

Мы имитировали эту ошибку, просто убрав правильный API ключ:

Код для приложения на основе SwiftUI с отображением ошибок можно найти на Github в папке CombineFetchAPI-Error

Если вы изначально планировали не обрабатывать ошибки, то можно обойтись без Future<[Movie],MovieStoreAPIError>, а вернуть обычный AnyPublisher<[Movie], Never> в методе fetchMoviesLight:

Отсутствие ошибок (Never) позволяет нам использовать очень простого «подписчика» assign(to: \.movies, on: self) :

Все будет работать как и прежде:

Заключение

Применять фреймворк Combine для обработки последовательности асинхронно появляющихся во времени значений values, очень просто и легко. Операторы, которые предлагает Combine, — мощные и гибкие. Combine позволяет нам избежать написание сложного асинхронного кода путем использования цепочки upstream «издателей» Publishers, применения операторов и встроенных «подписчиков»  Subscribers. Combine построен на более низком уровне, чем Foundation, во многих случаях не нуждается в Foundation и имеет потрясающее быстродействие. 

SwiftUI также сильно завязан на Combine благодаря своим @ObservableObject, @Binding и @EnvironmentObject.
iOS разработчики давно ждали от Apple официального фреймворка такого рода и наконец в этом году это случилось. 

Ссылки:

Fetching Remote Async API with Apple Combine Framework

try! Swift NYC 2019 — Getting Started with Combine

«The ultimate Combine framework tutorial in Swift».

Combine: Asynchronous Programming with Swift

Visualize Combine Magic with SwiftUI Part 1 ()

Visualize Combine Magic with SwiftUI – Part 2 (Operators, subscribing, and canceling in Combine)

Visualize Combine Magic with SwiftUI Part 3 (See Combine Merge and Append in Action)

Visualize Combine Magic with SwiftUI — Part 4

Visualize Combine Magic with SwiftUI — Part 5

Getting Started With the Combine Framework in Swift

Transforming Operators in Swift Combine Framework: Map vs FlatMap vs SwitchToLatest

Combine’s Future

Using Combine

URLSession and the Combine framework