Выполнение 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
, краткую аннотацию overview
, URL
кинопостера 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:
fetchMovies (from endpoint: Endpoint) -> AnyPublisher<[Movie], Never>
— выборка фильмов[Movie]
на основе параметраendpoint
,fetchCredits (for movieId: Int) -> AnyPublisher<[MovieCast], Never>
— выборка актеров[MovieCast]
для фильма с определенным идентификаторомmovieId
,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
:
- на основе
endpoint
формируемURL
endpoint.absoluteURL
для запроса нужной коллекции фильмов и используем «издателя»dataTaskPublisher(for:)
, у которого выходным значениемOutput
является кортеж(data: Data, response: URLResponse)
, а ошибкойFailure
—URLError
,
- с помощью map { } берем из кортежа
(data: Data, response: URLResponse)
для дальнейшей обработки только данныеdata
, - декодируем
JSON
данныеdata
непосредственно в Модель, которая представлена структуройMovieResponse
, содержащей массив фильмовresults: [Movie]
- с помощью
map { }
для дальнейшей обработки берем только данные о фильмах —results
, - при возникновении каких-либо ошибок на предыдущих шагах возвращаем пустой массив
[ ]
, - доставляем результат на
main
поток, так как предполагаем в дальнейшем его использование при проектированииUI
, - «стираем» ТИП «издателя» с помощью
eraseToAnyPublisher()
и возвращаем экземплярAnyPublisher
.
Задача выборки актёрского состава возложена на второй метод fetchCredits (for movieId: Int) -> AnyPublisher<[MovieCast], Never>
, который является точной семантической копией первого метода:
Рассмотрим подробно, как идет формирование с помощью Combine
«издателя» AnyPublisher <[MovieCast], Never>
, если нам известен идентификатор фильма movieId
:
- на основе
movieId
формируемURL
для запроса актерского состава данного фильмаEndpoint.credits (movie: movieId)).absoluteURL
и создаем «издателя»dataTaskPublisher(for:)
, у которого выходным значениемOutput
является кортеж(data: Data, response: URLResponse)
, а ошибкойFailure
—URLError
,
- берем с помощью
map { }
из кортежа(data: Data, response: URLResponse)
для дальнейшей обработки только данныеdata
, - декодируем
JSON
данные непосредственно в Модель, которая представлена структуройMovieCreditResponse
, содержащей как актёрский составcast: [MovieCast]
, так и съёмочную группуcrew:[MovieCast]
, - с помощью
map { }
берем для дальнейшей обработки только данные об актёрском составеcast
, - при возникновении ошибок на предыдущих шагах возвращаем пустой массив
[ ]
, - доставляем результат на
main
поток, так как предполагаем в дальнейшем использование при проектированииUI
- «стираем» ТИП «издателя» с помощью
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
:
- если
url
равенnil
, то возвращаемJust(nil)
, - на основе
url
формируем «издателя»dataTaskPublisher(for:)
, у которого выходным значениемOutput
является кортеж(data: Data, response: URLResponse)
и ошибкойFailure
—URLError
,
- берем с помощью
map {}
из кортежа(data: Data, response: URLResponse)
для дальнейшей обработки только данныеdata
, и формируемUIImage
, - при возникновении ошибок на предыдущих шагах возвращаем
nil
, - доставляем результат на
main
поток, так как предполагаем в дальнейшем использование при проектированииUI
- «стираем» ТИП «издателя» и возвращаем экземпляр
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
свойства:
@Published var indexEndpoint: Int
— это индексEndpoint
(условно можно назвать его «входным», так как регулируется пользователем наView
),-
@Published var movies: [Movie]
— список соответствующих фильмов (условно «выходное», так как получается путем выборки данных из базы фильмов TMDb) - свойство
@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
свойства:
@Published var nameJSON: String
— это имяJSON
файла («условно «входное» свойство),@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» на русском языке)