Современный код для выполнения HTTP запросов в Swift 5 с помощью Combine и применение его в SwiftUI. Часть 1. База данных фильмов.

Выполнение HTTP запросов — это один из самых важных навыков, которые необходимо получить при изучении iOS. В более ранних версиях Swift ( до версии 5) вне зависимости от того, формировали ли вы эти запросы «с нуля» или с использование известного фреймворка Alamofire,  вы в конечном итоге получали сложный и запутанный код с callback типа completionHandler: @escaping(Result<T, APIError>) -> Void.

Появление в Swift 5 нового фреймворка функционального реактивного программирования Combine в сочетании с уже существующими URLSession и Codable предоставляет вам все необходимые инструменты для самостоятельного написания очень компактного кода для выборки данных из интернета.

Мы будем создавать «издателей» Publisher для выборки данных из интернета, на которые в дальнейшем можно будет легко «подписаться» и использовать при проектировании UI как с помощью UIKit, так и с помощью SwiftUI.

В SwiftUI это выглядит более эффектно, если не сказать «фантастически», так как разделение данных и View в SwiftUI осуществляется с помощью ObservableObject классов с @Published свойствами, изменения которых SwiftUI АВТОМАТИЧЕСКИ отслеживает и полностью «перерисовывает» View. В эти ObservableObject классы можно заложить определенную бизнес-логику приложения, так как некоторые из этих  @Published свойств могут напрямую меняться такими «активными» элементами пользовательского интерфейса (UI) как текстовые поля TextField, Picker, Stepper, Toggle и т.д. Другие @Published свойства, напротив, могут быть «пассивными», являясь результатом синхронных и/ или асинхронных преобразований «активных» @Published свойств, но именно они то нас чаще всего и интересуют. Они обычно воспроизводятся в SwiftUI такими пассивными  (UI) элементами, как текст Text, изображение Image, геометрические фигуры и т.д. Зависимость «пассивных» @Published свойств от «активных» @Published свойств очень просто описать с помощью Combine.

Чтобы было понятно, о чём идет речь, приведу конкретные примеры. Сейчас многие сервисы типа базы данных фильмов TMDb или агрегаторов новостей NewsAPI.org и Hacker News предлагают пользователям выбирать различные коллекции фильмов или наборы статей в зависимости от того, что вас интересует. В случае базы данных фильмов  TMDb это могут быть фильмы, которые идут в данный момент в кинотеатрах, или популярные фильмы, или топовые фильмы,  или фильмы, которые скоро появятся на экране. В случае  агрегаторов новостей NewsAPI.org и Hacker News это могут быть последние новости, или новости в какой-нибудь категории — «спорт», «здоровье», «наука», «технологии», «бизнес», или новости от определенного информационного источника «CNN», «ABC news», «Bloomberg» и т.д., или новости, удовлетворяющие какому-то произвольному критерию. Свои желания для сервисов вы обычно «высказывает» в виде Endpoint, который формирует для вас нужный URL.

Так вот, используя фреймворк Combine, вы можете в ObservableObject классах с помощью очень компактного кода (в большинстве случаев не более 10-12 строк) однократно сформировать синхронную и/или асинхронную зависимость списка фильмов или статей (как «пассивных» @Published свойств) от Endpoint (как «активных» @Published свойств) в виде «подписки», которая будет действовать на протяжении всего «жизненного цикла» экземпляра ObservableObject класса. А далее в SwiftUI управлять только тем, что вы хотите увидеть, то есть выбирать нужную вам Endpoint: то ли это будут популярные фильмы, или фильмы, идущие в данный момент на экране,  то ли это будут статьи с последними новостями или статьи в разделе «здоровье». Появление соответствующих фильмов или статей на вашем UI будет обеспечиваться АВТОМАТИЧЕСКИ этими ObservableObject классами и их пассивными @Published свойствами.  В коде SwiftUI у вас никогда не возникнет необходимости  явно запрашивать выборку фильмов или статей, вы будете управлять только тем, ЧТО вы хотите увидеть через Endpoint, вовсе не заботясь о результате, ибо он ВСЕГДА будет правильным и синхронным благодаря работе ObservableObject классов, которые исполняют роль View Model.

Я на трех конкретных примерах покажу, как это работает с помощью применения Combine для формирования HTTP запросов к различным сервисам и использование их в качестве View Model в SwiftUI.

Начнем с разработки приложения для взаимодействия с базой данных фильмов TMDb, а в последующем — приложений для взаимодействия с агрегаторами новостей NewsAPI.org и Hacker News. Во всех трех случаях будет действовать примерно одна и та же схема использования Combine , ибо в приложениях такого рода всегда приходится формировать СПИСКИ фильмов или статей, выбирать сопровождающие их «КАРТИНКИ» (images), ИСКАТЬ в базах данных нужные фильмы или статьи с помощью поисковой строки.

При обращении к сервисам типа базы данных фильмов TMDb или агрегаторов новостей NewsAPI.org и Hacker News могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ API-key или превысили допустимое количество запросов или еще что-то. Необходимо обрабатывать такого рода ошибки сервиса. Иначе пользователь вашего приложения попадёт в ситуацию, когда вдруг ни с того, ни с сего перестанут обрабатываться какие-либо запросы, оставляя пользователя в полном недоумении с пустым экраном.  Поэтому надо уметь не только выбирать с помощью Combine данные из интернета , но и сообщать об ошибках, которые могут возникнуть при выборке. В этой статье мы уделим внимание обработке ошибок такого рода в Combine.

Код приложения для данной статьи находится на Github.

Модель данных и API сервиса TMDb

Хотя сервис TMDb позволяет выбирать очень обширную информацию о фильмах, Модель интересующих нас данных будет очень простой, она представляет собой фильм Movie  и находится в файле Movie.swift:

Фильм Movie будет содержать только идентификатор id, название title, краткую аннотацию overviewURL кинопостера posterPath и дату выпуска releaseDate. Над фильмами находится надстройка MovieResponse, в которой нас будет интересовать только свойство results, которое и представляет собой массив фильмов [Movie]. Корневая структура MoviesResponse и структура Movie являются Codable, что позволит нам буквально двумя строками кода декодировать JSON данные в Модель. Структура Movie должна быть еще и Identifiable, если мы хотим облегчить себе отображение массива фильмов [Movie] в виде списка List в SwiftUI. Протокол Identifiable требует присутствия свойства id, которое у нас уже есть, так что никаких дополнительных усилий от нас не потребуется.

Обычно сервисы, включая и базу данных TMDb, предлагают всевозможные URLs для получения тех или иных нужных нам ресурсов этого сервиса. Их называют Endpoint. В Swift чаще всего они оформляются в виде перечисления enum Endpoint. Вот как выглядит перечисление enum Endpoint, позволяющее определить URL для обращения к базе данных TMDb за определенным набором фильмов или актеров:

Например, можно выбрать заранее определенные или произвольные наборы фильмов:

  • фильмы, которые идут в данный момент в кинотеатрах — .nowPlaying,
  • популярные фильмы — .popular,
  • топовые фильмы — .topRated,
  • фильмы, которые скоро появятся на экране — .upcoming.
  • произвольную коллекцию фильмов, задавая любую поисковую строку, содержащуюся в названии фильма — .search (searchString: String).

Кроме возможности выбирать коллекции фильмов, в перечислении Endpoint представлена возможность с помощью варианта .credits (movieId: Int) выбирать актёрский состав [MovieCast] и съемочную группу [MovieCrew] для некоторого фильма c идентификатором movieId :

Опции перечисления Endpoint — это то, чем мы будем управлять в нашем приложении, чтобы получить желаемое — нужные массивы фильмов [Movie] и актеров[MovieCast].

Задачей API (Application Programming Interface) для  сервиса TMDb как раз является создание методов, позволяющих получить массив фильмов [Movie] или актеров [MovieCast] на основании той или иной опции перечисления Endpoint. Мы разместим наш API в файле MovieAPI.swift.

Центральной частью нашего API является класс MovieAPI, в котором представлены три метода выборки данных из базы TMDb:

  1. fetchMovies (from endpoint: Endpoint) -> AnyPublisher<[Movie], Never>  — выборка фильмов [Movie] на основе параметра endpoint ,
  2. fetchCredits (for movieId: Int) -> AnyPublisher<[MovieCast], Never> — выборка актеров [MovieCast] для фильма с определенным идентификатором movieId
  3. fetchMoviesErr (from endpoint: Endpoint) -> AnyPublisher<[Movie], MovieError>  — выборка фильмов [Movie] на основе параметра endpoint с сообщениями об ошибках MovieError.

В контексте фреймворка Combine все эти методы возвращают не просто массивы фильмов [Movie] или актеров [MovieCast], а соответствующих «издателей» Publisher. Первые два издателя не возвращают никакой ошибки — Never, а если ошибка выборки или кодирования все-таки имела место, то возвращается пустой массив фильмов [Movie]() или актеров [MovieCast]() без каких-либо сообщений, почему эти массивы оказались пустыми. Последний, третий метод, не только формируем массив фильмов [Movie], но и сообщает о возможной реальной ошибке MovieError, которую мы можем отобразить на (UI).

Для облегчения выбора опции добавим в перечисление Endpoint инициализатор init?  для уже «готовых» списков фильмов (.nowPlaying.popular.topRated или .upcoming) в зависимости от индекса index:

Вернемся в класс MovieAPI и рассмотрим более подробно первый метод fetchMovies (from endpoint: Endpoint)-> AnyPublisher<[Movie], Never>, который выбирает фильмы [Movie] на основе параметра endpoint и не возвращает никакой ошибки Never:

  1. на основе endpoint формируем URL endpoint.absoluteURL для запроса нужной коллекции фильмов  и используем «издателя» dataTaskPublisher(for:), у которого выходным значением Output является кортеж (data: Data, response: URLResponse), а ошибкой Failure — URLError,
  2. с помощью map { } берем из кортежа (data: Data, response: URLResponse) для дальнейшей обработки только данные data
  3. декодируем JSON данные data непосредственно в Модель, которая представлена структурой MovieResponse, содержащей массив фильмов results: [Movie]
  4. с помощью map { } для дальнейшей обработки берем только данные о фильмах — results
  5. при возникновении каких-либо ошибок на предыдущих шагах возвращаем пустой массив [ ],
  6. доставляем результат на main поток, так как предполагаем в дальнейшем его использование при проектировании UI,
  7. «стираем» ТИП «издателя» с помощью eraseToAnyPublisher() и возвращаем экземпляр AnyPublisher.

Задача выборки актёрского состава возложена на второй метод fetchCredits (for movieId: Int) -> AnyPublisher<[MovieCast], Never>, который является точной семантической копией первого метода:

Рассмотрим подробно, как идет формирование с помощью Combine «издателя» AnyPublisher <[MovieCast], Never>, если нам известен идентификатор фильма movieId:

  1. на основе movieId формируем URL для запроса актерского состава данного фильма Endpoint.credits (movie: movieId)).absoluteURL и создаем «издателя» dataTaskPublisher(for:), у которого выходным значением Output является кортеж (data: Data, response: URLResponse), а ошибкой FailureURLError,
  2. берем с помощью map { } из кортежа (data: Data, response: URLResponse) для дальнейшей обработки только данные data
  3. декодируем JSON данные непосредственно в Модель, которая представлена структурой MovieCreditResponse, содержащей как актёрский состав cast: [MovieCast], так и съёмочную группу crew:[MovieCast] ,
  4. с помощью map { } берем для дальнейшей обработки только данные об актёрском составе cast
  5. при возникновении ошибок на предыдущих шагах возвращаем пустой массив [ ],
  6. доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании UI
  7. «стираем» ТИП «издателя» с помощью eraseToAnyPublisher() и возвращаем экземпляр AnyPublisher.

Этот метод почти абсолютная копия первого метода fetchMovies (from endpoint: Endpoint)-> AnyPublisher<[MovieCast], Never> за исключением того, что на этот раз вместо фильмов мы будем выбирать актеров. Он возвращает нам «издателя» AnyPublisher <[MovieCast], Never> со значением в виде массива актеров [MovieCast] и отсутствием ошибки Never (в случае ошибок возвращается пустой массив актеров [ ]).

Мы выделим общую часть этих двух методов, оформим ее в виде Generic функции fetch(_ url: URL) -> AnyPublisher<T, Error>, возвращающей Generic «издателя» AnyPublisher<T, Error> на основании URL:

Это позволит упростить предыдущие два метода:

Полученные таким образом «издатели» сами по себе «не взлетают», они ничего не поставляют до тех пор, пока на них кто-то не «подпишется». Мы будем использовать их при проектировании UI в SwiftUI и «подпишемся» на них в  ObservableObject классе, который АВТОМАТИЧЕСКИ СИНХРОНИЗИРУЕТ выбранные из интернета данные с View

«Издатели» Publisher как View Model в SwiftUI. Список фильмов.

В SwiftUI единственной абстракцией «внешних изменений», на которую реагируют View, являются «издатели» Publisher. Под «внешними изменениями» можно понимать таймер Timer, уведомление с NotificationCenter или ваш объект Модели, который с помощью протокола ObservableObject можно превратить во внешний единственный «источник истины» (source of truth). 

На обычных «издателей» типа Timer или NotificationCenter  в SwiftUI View реагирует с помощью метода onReceive (_: perform:). Пример использования «издателя» Timer мы представим позже, в третьей статье. В этой статье мы сосредоточимся на том, как сделать нашу Модель для SwiftUI внешним «источником истины» (source of truth) .

Для этого Модель должна реализовать протокол ObservableObject и разместить в нем «обертки» свойств @Published для тех переменных, изменения которых должны воздействовать на это View. Кроме того, классы, реализующие ObservableObject протокол позволяют отделить данные от View в SwiftUI. В этом случае, фактически, ObservableObject класс берет на себя роль View Model для инкапсуляции бизнес-логики и поставки привязанных к View данных.

Давайте рассмотрим, какая нам нужна View Model на конкретном примере отображения различных «готовых» коллекций фильмов: nowPlaying — идущих в данный момент в кинотеатрах, popular — популярных, topRated — топовых, upcoming — фильмов, которые скоро появятся на экране.

В зависимости от того, какой Endpoint выберет пользователь (.nowPlaying, .popular, .topRated или .upcoming), нам нужно обеспечить получение нужного списка фильмов movies, выбранных из базы данных  TMDb :  

В этом очень простом классе MoviesViewModel, реализующем протокол ObservableObject, у нас будет два @Published свойства:

  • одно @Published var indexEndpoint: Int — это индекс Endpoint (условно можно назвать его «входом», так как именно его значением будет управлять пользователь на View), 
  • другое @Published var movies: [Movie] — список соответствующих фильмов (условно «выход», так как он создается путем выборки данных из базы фильмов TMDb, определяемых «входом») и это именно то, что нас интересует в этом приложении.

Для формирования зависимости между этими двумя свойствами («входом» и «выходом») мы должны при инициализации экземпляра класса MoviesViewModel протянуть цепочку от входного «издателя» $indexEndpoint до выходного «издателя» AnyPublisher<[Movie], Never>, на которого в последствии мы «подпишемся» с помощью оператора assign (to: \.movies, on: self) и получим массив фильмов movies.

Использование фреймворка Combine предполагает, что как только мы поставили @Published перед свойством indexEndpoint, то мы можем начать использовать его и как простое свойство indexEndpoint, и как издателя $indexEndpoint. Важно отметить, что нам нужно тянуть цепочку НЕ просто от свойства indexEndpoint, а именно от издателя $indexEndpoint, который будет участвовать в создании UI с помощью SwiftUI и там изменяться с помощью Picker или Stepper.

В нашем классе MovieAPI уже есть функция fetchMovies (from: Endpoint), которая возвращает «издателя» AnyPublisher<[Movie], Never>, в зависимости от значения Endpoint, и нам нужно только каким-то образом использовать значение издателя $indexEndpoint в качестве аргумента этой функции.  Для этого в Combine существует оператор flatMap:

Оператор flatMap создает нового «издателя» на основе данных, полученных от предыдущего «издателя».

Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью очень простого «подписчика» assign (to: \.movies, on: self) и присваиваем полученное от «издателя» значение @Published массиву movies:

Мы только что создали в init( ) АСИНХРОННОГО «издателя» и «подписались» на него, в результате получив AnyCancellable «подписку» и это легко проверить, если мы сохраним нашу «подписку» в константе let subscription:

AnyCancellable «подписка» позволяет вызывающей стороне в любой момент отметить «подписку» и далее не получать значений от «издателя», но более того, как только AnyCancellable «подписка» (а в нашем случае это константа subscription) покидает свою область действия, память, занятая «издателем» освобождается. Поэтому, как только init( ) завершится, эта «подписка» будет удалена ARC, так и не успев присвоить полученную с задержкой по времени асинхронную информацию массиву movies. Для сохранения такой «подписки» необходимо создать ЗА ПРЕДЕЛАМИ init() переменную var cancellableSet, которая сохранит нашу AnyCancellable «подписку» в этой переменной в течении всего “жизненного цикла” экземпляра класса MoviesViewMode. Она будет удалена из памяти при deinit экземпляра класса View Model

Запоминается AnyCancellable «подписка» в переменной cancellableSet с помощью оператора store ( in: &self.cancellableSet):

В этом случае «подписка» на АСИНХРОННОГО «издателя», которую мы создали в init( ), будет сохраняться в течение всего “жизненного цикла” экземпляра класса MoviesViewModel. Мы можем как угодно менять значение издателя $indexEndpoint, и всегда благодаря сформированной «подписке» у нас будет синхронизированный массив фильмов movies без каких-либо дополнительных усилий.

Теперь, когда у нас есть View Model для наших фильмов, приступим к созданию UI. В SwiftUI для синхронизации View c ObservableObject используется @ObservedObject переменная.

Добавим в структуру ContentView нашу View Model как @ObservedObject переменную var moviesViewModel и заменим Text ("Hello, World!") на список фильмов List, в котором разместим фильмы moviesViewModel.movies, полученные  из нашей View Model:

Теперь, любые изменения @Published свойств, объявленных в нашей ObservableObject Модели и представленных в View в виде @ObservedObject переменной, будут приводить к «перерисовке» View. А это фундаментальная концепция архитектуры MVVM, которая была заявлена для SwiftUI как преемник MVC.

В результате получим список фильмов для фиксированного и заданного по умолчанию индекса indexEndpoint = 2, то есть .upcoming :

Добавим на наш экран UI элемент для управления тем, какую коллекцию фильмов мы хотим показывать. Мы будем это делать путем изменения индекса indexEndpoint. Для этого может подойти Stepper или Picker. Давайте попробуем использовать Picker.

Пользователь будет управлять Picker, и при этом изменять «входного» «издателя» $moviesViewModel.indexEndpoint:

… а на экране с помощью списка List, управляемого нашим «выходным» издателем moviesViewModel.movies, будет формироваться соответствующая коллекция фильмов:

Никакой дополнительной синхронизации между View и View Model не требуется. SwiftUI АВТОМАТИЧЕСКИ «подписывается» на ObservableObject и его @Published свойства оказываются ответственными за то, что будет показано на экране.  

SwiftUI и View Model. Изображения кинопстеров и фотографий актеров.

Модель Movie содержит данные о местонахождении кинопостера posterPath:

…,  а Модель актера MovieCast содержит данные о местонахождении портрета актера profilePath:

В любом из этих двух случаев мы должны выбрать изображение UIImage из базы данных TMDb. Местоположение изображения в TMDb зависит от его размера Size:

Указав размер Size и данные poster о кинопостере фильма или портрете актера, вы можете получить URL изображения с помощью метода path ( poster: String) -> URL?. На основании этого URL мы в дальнейшем должны получить само изображения UIImage из базы фильмов TMDb

Нам уже знакома эта задача. В классе ImageLoader создадим «издателя»  AnyPublisher<UIImage?, Never> со значением изображения UIImage? и отсутствием ошибки Never (в действительности, если ошибки возникают, то в качестве изображения возвращается nil). К этому «издателю» можно «подписаться» для получения изображения UIImage? при проектировании пользовательского интерфейса (UI). Исходными данными для такого «издателя» является url:

Рассмотрим подробно, как идет формирование с помощью Combine «издателя» AnyPublisher <UIImage?, Never>, если нам известен url:

  1. если url равен nil, то возвращаем Just(nil),
  2. на основе url формируем «издателя» dataTaskPublisher(for:), у которого выходным значением Output является кортеж (data: Data, response: URLResponse) и ошибкой Failure — URLError,
  3. берем с помощью map {} из кортежа (data: Data, response: URLResponse) для дальнейшей обработки только данные data, и формируем UIImage,
  4. при возникновении ошибок на предыдущих шагах возвращаем nil,
  5. доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании UI
  6. «стираем» ТИП «издателя» и возвращаем экземпляр AnyPublisher.

Вы видите, что код достаточно компактный и хорошо читаемый, нет никаких callbacks.

Приступим к созданию View Model для изображения UIImage?. Это класс ImageLoader, реализующий протокол ObservableObject, с двумя @Published свойствами:
одно    — @Published url: URL? — это URL изображения,
другое — @Published var image: UIImage? — это само изображение из базы TMDb:

И опять при инициализации экземпляра класса ImageLoader мы должны протянуть цепочку от входного «издателя» $url до выходного «издателя» AnyPublisher<UIImage?, Never>, на которого в последствии мы  «подпишемся» и получим нужное нам изображение image.

Используем оператор flatMap и очень простого «подписчика» assign (to: \image, on: self) с целью присваивания полученного от «издателя» значения свойству @Published image:

И опять в переменной cancellableSet запоминается AnyCancellable «подписка» с помощью оператора store(in: &self.cancellableSet).

Теперь, когда у нас есть View Model для изображения UIImage? с сервера TMDb, приступим к созданию UI для его отображения на экране.

Добавим в структуру MoviePosterImage нашу View Model как @ObservedObject переменную var imageLoader: ImageLoader и обычную переменную var posterSize: PosterStyle.Size:

При инициализации нашей View Model imageLoader изображение image равно nil и вместо изображения на экране будет серый прямоугольник Rectangle() нужного размера до тех пор, пока изображение не загрузится и свойство @Published image не изменится, что, в свою очередь, вызовет «перерисовку» MoviePosterImage, и мы получим изображение Image (uiImage: self.imageLoader.image!):

Это произойдет АВТОМАТИЧЕСКИ.  Нам ничего для этого не надо делать.

Однако, не всегда изображение image равное nil превращается в изображение UIImage. Если url по какой-то причине равен nil, то изображение image никогда не обновится и останется пустым. Следовательно, по значению url мы с самого начала можем определить, следует ли ожидать обновления изображения или нет. Мы должны показать это пользователю. Поэтому, если  url не равен nil, а само изображение image равное nil, то поверх серого прямоугольника  мы разместим, например, анимируемый текст «Loading …», который будет «заместителем» (placeholder)  для вот-вот готового появиться настоящего изображения UIImage:

Теперь мы будем наблюдать такую картину при загрузке постеров фильмов …

… или портретов артистов:

Чтобы каждый раз не выбирать изображения из базы данных TMDb, давайте воспользуемся уже готовым кэшем NSCache <NSString, ImageLoader> для экземпляров класса ImageLoader,  а также предусмотрим два метода их получения: 
один для кинопостера фильма movie  — func loaderFor (movie: Movie<) -> ImageLoader,
а другой — для портрета актера cast  — func loaderFor (cast: MovieCast<) -> ImageLoader

Ключами для выборки и записи данных из/в кэша являются строковые представления идентификаторов фильмов movie.id и актеров cast.id:

Если в кэше loaders нужного загрузчика изображений не окажется, то c помощью ImageAPI.Size вычисляется URL и инициируется новый экземпляр класса ImageLoader, которые записывается в кэш loaders для последующего использования. Сама View Model для загрузки изображений в виде класса ImageLoader является универсальной для загрузки изображения и может использоваться в любом другом приложении.

Список фильмов с кинопостерами и детальной информацией об актерском составе.

Вернем к списку фильмов в ContentView. Давайте  для каждого фильма movie добавим кинопостер MoviePosterImage, который разместим в  горизонтальном стеке HStack:

В результате мы получим список фильмов с кинопостерами:

Давайте в ContentView вместо List для списка фильмов будем использовать совершенно отдельный MoviesList:

В этом случае ContentView существенно упростится:

Теперь займемся списком актеров для определенного фильма с идентификатором movieId. Для выборки актеров из базы данных TMDb в MovieAPI у нас есть функция fetchCredits (for: movieId):

Мы создаем View Model для списка актеров в виде класса CastViewModel и действуем по той же схеме: наш класс CastViewModel реализует протокол ObservableObject. У этого класса есть два @Published свойства:

одно .  —  @Published var movieId — это идентификатор фильма, 
другое —  @Published var casts: [MovieCast] — список актёров данного фильма.

При инициализации экземпляра класса CastViewModel, мы должны протянуть цепочку от входного «издателя» $movieId  до выходного «издателя» AnyPublisher<[MovieCast], Never>, на которого затем мы «подписываемся» и получаем массив актеров [MovieCast].

И опять в переменной cancellableSet запоминается AnyCancellable «подписка» с помощью оператора store(in: &self.cancellableSet).

Теперь, когда у нас есть View Model для наших актеров, приступим к созданию UI. Добавим в структуру CastsList нашу View Model как @ObservedObject переменную var castsViewModel и список актеров castViewModel.casts в виде горизонтального ScrollView и HStack:

В результате получаем горизонтально прокручиваемый список актеров:


Этот список можно использовать в DetailView, который предоставляет детальную информацию о фильме movie:

Здесь у нас и постер фильма MoviePosterImage, и его краткое содержание movie.overview, и дата выпуска movie.releaseDate, и список актеров CastsList.

DetailView будем использовать в NavigationLink в качестве destination для списка фильмов MoviesList:

Теперь мы можем получать детальную информацию о любом фильме из этих списков:

Формирование произвольного списка фильмов по запросу.

Давайте рассмотрим, какая нам нужна View Model на примере отображения произвольного списка фильмов, сформированного по запросу на основании поисковой строки, содержащейся в названии фильмов:

В зависимости от того, какой текст в поле поиска наберет пользователь, мы будем обновлять список фильмов movies, выбранных с сайта TMDb:  

В классе MoviesSearchViewModel, реализующем протокол ObservableObject, у нас опять два @Published свойства:  @Published var name: String — это поисковая строка, другое — @Published var movies: [Movie] — список соответствующих фильмов из базы TMDb.

При инициализации экземпляра класса MoviesSearchViewModel мы должны протянуть цепочку от входного «издателя» $name  до выходного «издателя» AnyPublisher<[Movie], Never>, на которого  мы «подписываемся» и получаем массив фильмов [Movie].

Во-первых, для текстового поля ввода, в котором «публикуется» $name мы должны использовать оператор debounce, чтобы получить единственный сигнал в заданном временном окне 0.1 :

Использование оператора removeDuplicates гарантирует, что мы не будем публиковать одни и те же значения снова и снова в этом временном окне:

 Далее мы должны задействовать уже имеющуюся в нашем MovieAPI функция fetchMovies (from: Endpoint), которая возвращает «издателя» AnyPublisher<[Movie], Never> в зависимости от значения Endpoint, и нам нужно каким-то образом использовать значение издателя $name> в качестве ассоциированного значения для Endpoint.search(name). Для этого в Combine предназначен оператор flatMap:

Внутри flatMap мы будем вызывать функцию MovieAPI.shared.fetchMovies (from:.search(searchString: name)) только в том случае, если длина поисковой строки укладывается в диапазон 2...30. В противном случае мы будем возвращать пустой массив фильмов [Movie]( ). В этом случае удобно использовать «издателя» Future и его замыкание promise:

Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью очень простого «подписчика» assign (to: \.movies, on: self) и присваиваем асинхронно полученное от «издателя» значение @Published массиву movies:

В переменной cancellableSet запоминается полученная «подписка» с помощью оператора store(in: &self.cancellableSet). Это обеспечивает действие «подписки» в течение всего “жизненного цикла” экземпляра класса MoviesSearchViewModel :

Теперь у нас есть View Model для поиска фильмов, и мы можем приступить к проектированию UI.

В SwiftUI cоздаём новую структуру ContentViewSearch, которая состоит всего из 2-х View: одно SearchView — текстовое поле для поисковой строки, второе MoviesList — список «найденных»фильмов:

В качестве @ObservedObject переменной var moviesModel используется экземпляр только что разработанного класса MoviesSearchViewModel, который позволяет нам «улавливать» изменения поисковой строки в «издателе» $moviesModel.name

…и осуществлять выборку соответствующих фильмов moviesModel.movies из базы фильмов TMDb:

Отображение ошибок при выборке и декодировании JSON данных с сервера.

При обращении к серверу могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ API-key или превысили допустимое количество запросов или еще что-то. При этом сервер TMDb  снабжает вас HTTP кодом и соответствующим сообщением.

Необходимо обрабатывать такого рода ошибки сервера. Иначе пользователь вашего приложения попадёт в ситуацию, когда вдруг ни с того, ни с сего, сервер TMDb перестанет обрабатывать какие-либо запросы, оставляя пользователя в полном недоумении с пустым экраном.

До сих пор при выборке фильмов [Movie] и актеров [MovieCast] с сервера TMDb  мы игнорировали все ошибки, и в случае их появления возвращали в качестве результата пустые массивы [Movie]() и   [MovieCast]]().

Приступая к обработке ошибок, давайте на основе уже существующего  метода fetchMovies (from endpoint: Endpoint) -> AnyPublisher<[Movie], Never> создадим в MovieAPI другой метод fetchMoviesErr (from endpoint: Endpoint) -> AnyPublisher<[Movie], MovieError> выборки фильмов с возможной ошибкой:

func fetchMoviesErr(from endpoint: Endpoint) ->
                            AnyPublisher<[Movie], MovieError> {

. . . . . . . .
}

Этот метод, также, как и метод fetchMovies, на входе принимает endpoint и возвращает «издателя» со значением в виде массива фильмов [Movie], но вместо отсутствия ошибки Never, у нас может присутствовать ошибка, определяемая перечислением MovieError:

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

Полученное Future мы превратили в AnyPublisher <[Movie], MovieError> с помощью оператора «стирания ТИПА» eraseToAnyPublisher().

Далее мы повторим все шаги, которые мы делали в методе fetchMovies, но при этом будем учитывать все возможные ошибки:

  • 0. на основе endpoint формируем URL endpoint.absoluteURL для запроса нужной коллекции фильмов, проверяем url на nil: если nil, то возвращаем ошибку .urlError, если нет — передаем url далее по цепочке,
  • 1. создаем «издателя» dataTaskPublisher(for:), у которого на входе — url, а выходным значением Output является кортеж (data: Data, response: URLResponse) и ошибка URLError,
  • 2. анализируем с помощью tryMap { } полученный кортежа (data: Data, response: URLResponse): если response.statusCode находится в диапазоне 200...299, то для дальнейшей обработки берем только данные data. В противном случае «выбрасываем» ошибку .responseError,
  • 3. декодируем JSON данные непосредственно в Модель, которая представлена структурой MoviesResponse,
  • 4. доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании UI
  • «подписываемся» на полученного «издателя» с помощью sink и его замыканий receiveCompletion и receiveValue,

5. если в замыкании receiveCompletion обнаруживаем ошибку error, то сообщаем о ней с помощью promise (.failure(...))),

6. в замыкании receiveValue сообщаем об успешном получении массива фильмов с помощью promise (.success($0.results))

  • 7. запоминаем полученную «подписку» в переменной var subscriptions = Set<AnyCancellable>(), чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра класса MovieAPI,
  • 8. «стираем» ТИП «издателя» и возвращаем экземпляр AnyPublisher.

Следует отметить, что «издатель» dataTaskPublisher(for:) отличается от своего прототипа dataTask тем, что в случае ошибки сервера, когда response.statusCode НЕ находится в диапазоне 200...299, он поставляет успешное значения в виде кортежа (data: Data, response: URLResponse), а не ошибку в виде (Error, URLResponse?). В этом случае реальная информация об ошибке содержится в data.

Если мы хотим отображать ошибки, то нужна соответствующая View Model, которую мы назовем MoviesViewModelErr:

В классе MoviesViewModelErr, реализующем протокол ObservableObject, у нас на этот раз ТРИ @Published свойства:

  1.  @Published var indexEndpoint: Int — это индекс Endpoint (условно можно назвать его «входным», так как регулируется пользователем на View), 
  2.  @Published var movies: [Movie] — список соответствующих фильмов (условно «выходное», так как получается путем выборки данных из базы фильмов TMDb)
  3.  свойство @Published var moviesError: MovieError? — это ошибка, которая может возникнуть на любом этапе  выборки данных из базы фильмов TMDb.

При инициализации экземпляра класса MoviesViewModel мы должны протянуть цепочку от входного «издателя» $indexEndpoint  до выходного «издателя» AnyPublisher<[Movie],MovieError>, на которого мы «подписываемся» с помощью «подписчика» sink и получаем либо массив фильмов movies, либо ошибку moviesError.

В нашем MovieAPI мы уже сконструировали  функцию fetchMoviesErr (from: Endpoint), которая возвращает «издателя» AnyPublisher<[Movie], MovieError>, в зависимости от значения Endpoint, и нам нужно только каким-то образом использовать значение «издателя» $indexEndpoint в качестве аргумента этой функции. 

Прежде всего мы должны установить для «входного» «издателя» $indexEndpoint ТИП ошибки равный требуемому MovieError:

Далее мы хотим воспользоваться функцией fetchMoviesErr (from: Endpoint) из нашего MovieAPI. Как обычно, мы будем это делать с помощью оператора flatMap, который создает нового «издателя» на основе данных, полученных от предыдущего «издателя»:

Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью «подписчика» sink и используем его замыкания receiveCompletion и receiveValue для получения от «издателя» либо значения массива фильмов movies, либо ошибки movieError:

Естественно, нам необходимо запомнить полученного в результате «подписчика» в некоторой внешней по отношению к init() переменной cancellableSet. Иначе мы не сможем асинхронно получить значение movies или ошибку movieError после завершения init():

Приступим к созданию UI.

В SwiftUI cоздаём новую структуру ContentViewErr, которая в точности совпадает со структурой ContentView, но в которой используется другая View Model в качестве @ObservedObject переменной var moviesViewModel. Это экземпляр вновь полученного класса MoviesViewModelErr, который «улавливает» ошибку выборки и декодирования данных из базы фильмов TMDb, если такая произойдет:

Если происходит ошибка, то появляется экстренное сообщение Alert.

Например, если неверный API ключ:

struct APIConstants {
    /// TMDB API key url: https://themoviedb.org
    static let apiKey: String = "API_KEY" 
    
   .  .  .  .  .  .  .  .  .  .  .  .  .
}

… то мы получим такое сообщение:

Если в Модели указано несуществующее поле:

… то  мы получим такое сообщение:

Считываем JSON данные о фильмах из файла приложения.

Иногда ваши данные могут располагаться просто в JSON файле в Bundle директории вашего приложения:

Для этого случая мы также можем создать View Model, а UI практически останется неизменным. Эта View Model представляет собой  класс с именем MoviesViewModelF,  буква «F» говорит о том, что данные о фильмах будут считываться из файла в Bundle.main приложения : 

Опять у нас два @Published свойства:

  1.  @Published var nameJSON: String — это имя JSON файла («условно «входное» свойство), 
  2.  @Published var movies: [Movie] — список соответствующих фильмов (условно «выходное», так как получается путем выборки данных из базы фильмов TMDb)

И опять при инициализации экземпляра класса MoviesViewModelF мы должны протянуть цепочку от входного «издателя» $nameJSON  до выходного «издателя» AnyPublisher<[Movie], Never>, на которого в последствии мы можем «подписаться» и получить массив фильмов [Movie]. Ошибки не учитываются.

Схема такая же, как и в прошлый раз, но на этот раз мы во flatMap получаем имя JSON-файла nameJSON и используем его для получения данных data из файла с этим именем. В остальном мы выполняем те же операции и ту же «подписку» assign.

Теперь в SwiftUI создаём простейшую структуру ContentViewErr  c MoviesViewModelF в качестве  View Model:

В результате мы получим список фильмов, находящихся в файле movies.json:

Напоследок мы упакуем все наши возможности показа фильмов из базы данных TMDb в TabView:

Заключение.

Мы узнали, как просто выполнять HTTP запросы с помощью Combine, его URLSession «издателя» dataTaskPublisher и Codable. Если не нужно отслеживать ошибки, то получается очень простой Generic код из 5 строк, для «издателя» AnyPublisher<T, Never>, который асинхронно получает JSON информацию и размещает её непосредственно в Codable Модели T на основании заданного url:

Этот код очень легко использовать для получения конкретного издателя, если исходными данными для url является, например Endpoint для базы данных фильмов  TMDb или идентификатор фильма movieId, и на выходе требуются различные Модели — например, коллекция фильмов или актерский состав:

 Если нужно учитывать ошибки, то код для Generic «издателя» немного усложнится, но все равно это будет очень простой код без каких-либо callbacks:

Точно так же с помощью Generic функции можно получить «издателя» AnyPublisher<T, Never>  для JSON файла, находящегося в Bundle файле приложения:

Используя технологию выполнения HTTP запросы с помощью Combine, можно создать «издателя» AnyPublisher<UIImage?, Never>, который асинхронно выбирает данные и получает изображение UIImage? на основе URL. Загрузчики изображений ImageLoader кэшируются в памяти для того, чтобы избежать повторной асинхронной выборки данных.

Полученных «издателей» можно очень просто «заставить работать» в ObservableObject классах, которые с помощью своих @Published свойств
управляют вашим UI, спроектированным с помощью SwiftUI. Эти классы называют View Model, так как в нем есть так называемые «входные» @Published свойства, соответствующие активным UI элементам (текстовым полям TextField, Stepper, Picker, переключатели Toggle и т.д.) и «выходные» @Published свойства, состоящие в основном из пассивных UI элементов (текстов Text, изображений Image, геометрических фигур Circle(), Rectangle() и т.д.
Вот пример View Model для «отображения различных коллекций фильмов movies в зависимости от индекса этой коллекции indexEndpoint:

Достаточно в инициализаторе init ( ) прописать с помощью Combine связь между «входным» активным издателем $indexEndpoint и «выходным «издателем» movies, и вашему UI АВТОМАТИЧЕСКИ будет  обеспечена синхронизация с данными:

Этой идеей пронизано всё приложение для работы с базой данных фильмов TMDb, представленное в этой статье. Она оказалась достаточно универсальной и в следующих статьях мы покажем, как она работает при взаимодействии с агрегаторами новостей NewsAPI.org и Hacker News.

Код находится на Github.

Ссылки:

Modern Networking in Swift 5 with URLSession, Combine and Codable

URLSession.DataTaskPublisher’s failure type

Combine: Asynchronous Programming with Swift

«SwiftUI & Combine: Лучше вместе»

Introducing Combine — WWDC 2019 — Videos — Apple Developer. session 722

(конспект сессии 722 «Введение в Combine» на русском языке)

Combine in Practice — WWDC 2019 — Videos — Apple Developer. session 721

(конспект сессии 721 «Практическое применение Combine» на русском языке)