Приложение для взаимодействия с агрегатором новостей NewsAPI.org.
В предыдущей статье на примере базы данных фильмов TMDb была представлена стратегия применения Combine
для формирования HTTP
запросов и использования их во View Model
для управления UI
, спроектированного с помощью SwiftUI
. В этой статье мы в точности воспроизведем ту же самую стратегию для разработки приложения, взаимодействующего с агрегатором новостей NewsAPI.org.
Но в отличии от базы данных фильмов TMDb, которая идеально спроектирована и очень аккуратно поддерживается, агрегатор оперативных новостей NewsAPI.org наполняется разными информационными агенствами, а они, например, могут по-разному кодировать отсутствие «КАРТИНКИ» (image
) для своих статей, или предоставлять её только для private
доступа. Поэтому вы указываете отличную от nil
ссылку на «КАРТИНКУ» (image
), а она вовсе необязательно появится на экране, вы можете получить сообщение о её недоступности. Кроме того, при обращении к серверу NewsAPI.org могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ API-key
или превысили допустимое количество запросов, обусловленное вашим тарифом. Необходимо обрабатывать такого рода ошибки сервера. Иначе пользователь вашего приложения попадёт в ситуацию, когда вдруг ни с того, ни с сего, сервер NewsAPI.org перестанет обрабатывать какие-либо запросы, оставляя пользователя в полном недоумении с пустым экраном.
Следовательно, надо уметь не только выбирать с помощью Combine
данные из интернета, но и обрабатывать сообщения об ошибках. В этой статье мы покажем, как обработка ошибок в Combine
при выборке данных позволила построить информативный UI
в SwiftUI
.
Рекомендуется зарегистрироваться на веб-сайте NewsAPI.org, чтобы получить ключ API, который будет обязательным для выполнения любых запросов к службе NewsAPI.org. Вы должны разместить его в файле NewsAPI.swift.
Код приложения для данной статьи находится на Github.
Воспроизведем для сервиса NewsAPI.org стратегию применения Combine
, представленную в предыдущей статье.
Модель данных и API сервиса NewsAPI.org.
Сервис NewsAPI.org позволяет выбирать информацию об актуальных новостных статьях [Article]
и их источниках [Source]
. Наша Модель данных будет очень простой, она находится в файле Articles.swift:
import Foundation
struct NewsResponse: Codable {
let status: String?
let totalResults: Int?
let articles: [Article]
}
struct Article: Codable, Identifiable {
let id = UUID()
let title: String
let description: String?
let author: String?
let urlToImage: String?
let publishedAt: Date?
let source: Source
}
struct SourcesResponse: Codable {
let status: String
let sources: [Source]
}
struct Source: Codable,Identifiable {
let id: String?
let name: String?
let description: String?
let country: String?
let category: String?
let url: String?
}
Статья Article
будет содержать идентификатор id
, название title
, описание description
, автора author
, URL
«картинки» urlToImage
, дату публикации publishedAt
и источник публикации source
. Над статьями [Article]
находится надстройка NewsResponse
, в которой нас будет интересовать только свойство articles
, которое и представляет собой массив статей. Корневая структура NewsResponse
и структура Article
являются Codable
, что позволит нам буквально двумя строками кода декодировании JSON
данные в Модель. Структура Article
должна быть еще и Identifiable
, если мы хотим облегчить себе отображение массива статей [Article]
в виде списка List
в SwiftUI
. Протокол Identifiable
требует присутствия свойства id
, которое мы обеспечим искусственным уникальным идентификатором UUID()
.
Источник информации Source
будет содержать идентификатор id
, название name
, описание description
, страну country
, категорию источника публикации category
, URL
сайта url
. Над источниками информации [Source]
находится надстройка SourcesResponse
, в которой нас будет интересовать только свойство sources
, которое и представляет собой массив источников информации. Корневая структура SourcesResponse
и структура Source
являются Codable
, что позволит нам очень просто декодировании JSON
данные в Модель. Структура Source
должна быть еще и Identifiable
, если мы хотим облегчить себе отображение массива источников информации [Source]
в виде списка List
в SwiftUI
. Протокол Identifiable
требует присутствия свойства id
, которое у нас уже есть, так что никаких дополнительных усилий от нас не потребуется.
Теперь рассмотрим, какой нам нужен API
для сервиса NewsAPI.org, и разместим его в файле NewsAPI.swift. Центральной частью нашего API
является класс NewsAPI
, в котором представлены два метода выборки данных из агрегатора новостей NewsAPI.org — статей [Article]
и источников информации [Source]
:
fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never>
— выборка статей[Article]
на основе параметраendpoint
,fetchSources (for country: String) -> AnyPublisher<[Source], Never>
— выборка источников информации[Source]
для определенной страныcountry
.
Эти методы возвращают не просто массив статей [Article]
или массив источников информации [Source]
, а соответствующих «издателей» Publisher
нового фреймворка Combine
. Оба издателя не возвращают никакой ошибки — Never
, а если ошибка выборки или кодирования все-таки имела место, то возвращается пустой массив статей [Article]()
или источников информации [Source]()
без каких-либо сообщений, почему эти массивы оказались пустыми.
То, какие статьи или источники информации мы хотим выбрать сервера NewsAPI.org, будем указывать с помощью перечисления enum Endpoint
:
enum Endpoint {
case topHeadLines
case articlesFromCategory(_ category: String)
case articlesFromSource(_ source: String)
case search (searchFilter: String)
case sources (country: String)
var baseURL:URL {URL(string: "https://newsapi.org/v2/")!}
func path() -> String {
switch self {
case .topHeadLines, .articlesFromCategory:
return "top-headlines"
case .search,.articlesFromSource:
return "everything"
case .sources:
return "sources"
}
}
}
Это :
- последние новости
.topHeadLines
, - новости определенной категории (
sports
,healthy
,science
,business
,technology
).articlesFromCategory(_ category: String)
, - новости определенного источника информации (
CNN
,ABC News
,Fox News
и т.д.).articlesFromSource(_ source: String)
, - любые новости
.search (searchFilter: String)
, отвечающие определенному условиюsearchFilter
, - источники информации
.sources (country:String)
для определённой страныcountry
.
Для облегчения инициализации нужной нам опции добавим в перечисление Endpoint
инициализатор init?
для различного рода списков статей и источников информации в зависимости от индекса index
и строки text
, которая имеет различный смысл для разных опций перечисления :
Реализация класса NewsAPI
выглядит очень похоже на класс MovieAPI
из предыдущего приложения. Там присутствует та же самая Generic
функция, возвращающая «издателя» AnyPublisher<T, Never>
, который на основании заданного url
асинхронно получает JSON
информацию, декодирует и размещает её непосредственно в Codable
Модели T
:
Этот код используется для получения конкретного «издателя» Publisher
, если исходными данными для url
является, например, Endpoint
для сервиса NewsAPI.org . Он позволяет сформировать на выходе различные Модели — массив статей[Article]
или массив источников информации [Source]
:
Я не буду подробно останавливаться на этапах формирования этих «издателей», так как они подробно описаны в предыдущей статье.
Полученные таким образом «издатели» сами по себе «не взлетают», они ничего не поставляют до тех пор, пока на них кто-то не «подпишется». Мы будем использовать их при проектировании UI
в SwiftUI
и «подпишемся» на них в ObservableObject
классе, который АВТОМАТИЧЕСКИ СИНХРОНИЗИРУЕТ выбранные из интернета данные с View
.
«Издатели» Publisher
как View Model в SwiftUI
. Список статей.
Давайте сначала рассмотрим, как в SwiftUI
должны функционировать полученные «издатели» на конкретном примере отображения различного рода статей:.topHeadLines
— последних новостей, .articlesFromCategory(_ category: String)
— новостей для определенной категории, .articlesFromSource(_ source: String)
— новостей для определенного источника информации, .search (searchFilter: String)
— новостей, выбранных по определенному условию.
В зависимости от того, какой Endpoint
выберет пользователь, нам нужно обновлять список статей articles
, выбранных с сайта NewsAPI.org. Для этого мы создадим очень простой класс ArticlesViewModel
, реализующий протокол ObservableObject
с тремя @Published
свойствами:
- одно
@Published var indexEndpoint: Int
— это индексEndpoint
(условно можно назвать его «входом», так как его значение регулируется пользователем наView
), - второе
@Published var searchString: String
— это строка, с помощью которой осуществляется поиск произвольных статей или указывается категория интересующих нас статей (также условно можно назвать его «входом», так как его значение регулируется пользователем наView
с помощью текстового поляTextField
), - третье
@Published var articles: [Article]
— список соответствующих статей (условно «выход», так как он создается путем выборки данных с сайта NewsAPI.org, определяемых «входами»).
Как только мы поставили @Published
перед свойствами indexEndpoint
или searchString
, мы можем начать использовать их и как простые свойства indexEndpoint
и $searchString
, так и «издатели» $indexEndpoint
и $searchString
.
В классе ArticlesViewModel
, можно не просто декларировать интересующие нас свойства, но и прописать бизнес-логику их взаимодействия. С этой целью при инициализации экземпляра класса ArticlesViewModel
в init?
мы можем создать «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса ArticlesViewModel
и реализовать зависимость списка статей articles
от индекса indexEndpoint
и строки поиска searchString
.
Для этого в Combine
мы протягиваем цепочку от «издателей» $indexEndpoint
и $searchString
до выходного «издателя» AnyPublisher<[Article], Never>
, у которого значение — это список статей articles
. Впоследствии мы «подпишемся» на него с помощью оператора assign (to: \.articles, on: self)
и получим нужный нам список статей articles
как «выходное» @Published
свойство, определяющее UI.
Мы должны тянуть цепочку НЕ просто от свойств indexEndpoint
и searchString
, а именно от «издателей» $indexEndpoint
и $searchString
, которые будет участвовать в создании UI
с помощью SwiftUI
и именно их мы там будем изменять с помощью элементов пользовательского интерфейса Picker
и TextField
.
Как мы будем это делать?
В нашем арсенале уже есть функция fetchArticles (from: Endpoint)
, которая находится в классе NewsAPI
и возвращает «издателя» AnyPublisher<[Article], Never>
, в зависимости от значения Endpoint
, и нам остаётся только каким-то образом использовать значения «издателей» $indexEndpoint
и $searchString
, чтобы превратить их в аргумент этой функции endpoint
.
Cначала объединим «издателей» $indexEndpoint
и $searchString
. Для этого в Combine
существует оператор Publishers.CombineLatest
:
Для создания нового «издателя» на основе данных, полученных от предыдущего «издателя» в Combine
используется оператор flatMap
:
Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью очень простого «подписчика» assign (to: \.articles, on: self)
и присваиваем полученное от «издателя» значение @Published
массиву articles
:
Мы только что создали в init( )
АСИНХРОННОГО «издателя» и «подписались» на него, в результате получив AnyCancellable
«подписку». Основное свойство AnyCancellable
«подписки» состоит в том, что как только она покидает свою область действия, занятая ею память автоматически освобождается. Поэтому, как только init( )
завершится, эта «подписка» будет удалена ARC
, так и не успев присвоить полученную с задержкой по времени асинхронную информацию массиву articles
.
Для сохранения такой «подписки» необходимо создать ЗА ПРЕДЕЛАМИ инициализатора init()
переменную var cancellableSet
, которая сохранит нашу AnyCancellable
«подписку» в этой переменной в течении всего “жизненного цикла” экземпляра класса ArticlesViewMode
.
Запоминается AnyCancellable
«подписка» в переменной cancellableSet
с помощью оператора .store ( in: &self.cancellableSet)
:
Для того чтобы сократить число обращений к серверу при наборе поисковой строки searchString
, мы должны использовать не непосредственно самого «издателя» строки поиска $searchString
, а его модифицированный вариант validString
:
«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в init( )
:
… будет сохраняться в течение всего “жизненного цикла” экземпляра класса ArticlesViewModel
.
Мы можем как угодно менять значение «издателей» $indexEndpoint
и /или searchString
, и всегда благодаря созданной «подписке» у нас будет соответствующий значениям этих двух издателей массив статей articles
без каких-либо дополнительных усилий. Такой ObservableObject
класс обычно называют View Model
.
Теперь, когда у нас есть View Model
для наших статей, приступим к созданию пользовательского интерфейса (UI
). В SwiftUI
для синхронизации View
c ObservableObject
Моделью используется @ObservedObject
переменная, ссылающаяся на экземпляр класса этой Модели. Именно эта пара — ObservableObject
класс и @ObservedObject
переменная, ссылающаяся на экземпляр этого класса — управляют изменением пользовательского интерфейса (UI
) в SwiftUI
.
Добавим в структуру ContentViewArticles
переменную var articlesViewModel
, имеющую ТИП ArticlesViewModel
, и заменим Text ("Hello, World!")
на список статей ArticlesList
, в котором разместим статьи articlesViewModel.articles
, полученные из нашей View Model
.
В результате получим список статей для фиксированного и заданного по умолчанию индекса indexEndpoint = 0
, то есть .topHeadLines
— последние новости:
Добавим на наш экран UI
элемент для управления тем, какой набор статей мы хотим показывать. Будем с помощью Picker
изменять индекса $articlesViewModel.indexEndpoint
. Присутствие символа $
обязательно, так как это означает изменение значения, поставляемого @Published
«издателем». Сразу же срабатывает «подписка» на этого «издателя», инициированная нами в init ()
, «выходной» @Published
издатель» articles
изменится и на экране мы увидим другой список статей:
Таким образом мы можем получать массивы статей для всех трех опций — "topHeadLines"
, "search"
и "from category"
:
… но для фиксированной и заданной по умолчанию поисковой строки searchString = "sports"
(там, где она требуется):
Однако для опции "search"
необходимо предоставить пользователю текстовое поле SearchView
для ввода поисковой строки:
В результате пользователь сможем искать любые новости по набранной поисковой строке:
Для опции "from category"
необходимо предоставить пользователю возможность выбрать категорию и начинаем мы с категории science
:
В результате пользователь сможем искать любые новости по выбранной категории новостей — science
, health
, business
, technology
:
Мы видим, как очень простая ObservableObject
Модель, имеющая два управляемых пользователем @Published
свойства — indexEndpoint
и searchString
— позволяет выбрать широкий спектр информации с сайта NewsAPI.org.
Список источников информации.
Давайте рассмотрим, как в SwiftUI
будет функционировать полученный в классе NewsAPI
«издатель»fetchSources (for country: String) -> AnyPublisher<[Source], Never>
при отображении различных источников информации.
Мы получим список источников информации для различных стран:
… и возможность поиска их по названию:
… а также детальную информацию о выбранном источнике: его имя, категорию, страну, краткое описание и ссылку на сайт:
Если кликнуть на ссылке, то попадем на сайт этого источника информации.
Для того, чтобы все это работало нужна предельно простая ObservableObject
Модель, имеющая всего два управляемых пользователем @Published
свойств — searchString
и country
:
И опять используем ту же схему при инициализации в init
экземпляра класса SourcesViewModel
мы создаём «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса SourcesViewModel
и реализовать зависимость списка источников информации sources
от страны country
и строки поиска searchString
.
С помощью Combine
мы тянем цепочку от «издателей» $searchString
и $country
до выходного «издателя» AnyPublisher<[Source], Never>
, значением которого является список источников информации. Мы «подписываемся» на него с помощью оператора assign (to: \.sources, on: self)
, получаем нужный нам список источников информации sources
и запоминаем полученную AnyCancellable
«подписку» в переменной cancellableSet
с помощью оператора .store ( in: &self.cancellableSet)
.
Теперь, когда у нас есть View Model
для наших источников информации, приступим к созданию UI
. В SwiftUI
для синхронизации View
c ObservableObject
Моделью используется @ObservedObject
переменная, ссылающаяся на экземпляр класса этой Модели.
Добавим в структуру ContentViewSources
переменную var sourcesViewModel
, имеющую ТИП SourcesViewModel
, уберём Text ("Hello, World!")
и разместим свой View
для каждого из 3-х @Published
свойств sourcesViewModel
:
- текстовое поле
SearchView
для строки поискаsearchString
, -
Picker
для страныcountry
, - список
SourcesList
источников информации.
В результате получим нужное нам View
:
На этом экране мы управляем только строкой поиска с помощью текстового поля SearchView
и «страной» с помощью Picker
, а остальное происходит АВТОМАТИЧЕСКИ.
Список источников информации SourcesList
содержит минимальные сведения о каждом источники — наименование source.name
и краткое описание source.description
:
… но позволяет получить более подробную информацию о выбранном источнике с помощью ссылки NavigationLink
, в которой в качестве destination
мы указываем DetailSourceView
, у которого исходными данными являются сам источник информации source
и нужный экземпляр класса ArticlesViewModel
, позволяющий получить список его статей articles
:
Посмотрите, как изящно мы получаем список статей для выбранного источника информации source
в списке источников SourcesList
. Нам помогает наш старый знакомый — класс ArticlesViewModel
, для которого мы должны задать оба «входных» @Published
свойства:
- индекс
indexEndpoint = 3
, то есть опцию.articlesFromSource (_source:String)
, соответствующую выборке статей для фиксированного источникаsource
, - строку
searchString
в качестве самого источника (а точнее его идентификатора)source.id
:
Вообще, если вы посмотрите на всё приложение NewsApp, то нигде не увидите, чтобы мы явно запрашивали выборку статей или источников информации с сайта NewsAPI.org. Мы управляем только @Published
данными, а View Model
делает свою работу: выбирает нужные нам статьи и источники информации.
Загрузка изображения UIImage для статьи Article.
Модель статьи Article
содержит URL
сопровождающего её изображения urlToImage
:
На основании этого URL
мы в дальнейшем должны получить само изображения UIImage с сайта NewsAPI.org.
Нам уже знакома эта задача. Давайте попробуем воспользоваться тем же универсальным «загрузчиком изображения» ImageLoader
, который мы использовали в предыдущей статье при работа с базой данных фильмов TMDb:
Логика этого «загрузчика изображений» состоит в том, что вы загружаете изображение из отличного от nil
URL
при условии, что оно предварительно не загружалось, то есть image == nil
. Если в процессе загрузки будет обнаружена какая-либо ошибка, то изображение будет отсутствовать, то есть image
останется равным nil
.
В SwiftUI
мы показываем изображение с помощью ArticleImage
, который использует для этого экземпляр imageLoader
класса ImageLoader
. Если его изображение image
не равно nil
, то оно показывается с помощью Image (...)
, а вот если оно равно nil
, то в зависимости от того, чему равно его url
— либо ничего не показывается — EmptyView()
, либо показывается прямоугольник Rectangle
с вращающемся текстом Text("Loading...")
:
Эта логика прекрасно работает только для случая, когда вы точно знаете, что для url
, отличного от nil
вы получите изображение image
, как это было в предыдущей статье при работа с базой данных фильмов TMDb. С агрегатором новостей NewsAPI.org дело обстоит иначе. В статьях некоторых источников информации дается отличный от nil
URL
изображения, но доступ к нему закрыт. В этом случае мы получим прямоугольник Rectangle
с вращающемся текстом Text("Loading...")
, который никогда не будет заменен:
В этой ситуации, если URL
изображения отличается от nil
, то равенство изображения image
nil
может означать как то, что изображение загружается, так и то, что при загрузке произошла ошибка и мы никогда не получим изображение image
. Для того, чтобы различить эти две ситуации мы добавляем в класс ImageLoader
к двум уже имеющимся @Published
свойствам ещё одно:
-
@Published var noData = false
— это булево значение, с помощью которого мы будем обозначать отсутствие данных изображения по причине возникшей ошибке при выборке.
При создании «подписки» в init
ловим все ошибки Error
, возникающие при загрузке изображения, и аккумулируем их присутствие в @Published
свойстве self.noData = true
. Если загрузка прошла успешно, то получаем изображение image
.
«Издателя» AnyPublisher<UIImage?, Error>
на основе url
создаем в функции fetchImageErr (for url: URL?)
:
Мы начинаем создание метода fetchImageErr
с инициализации «издателя» Future
, который можно использовать для асинхронного получения единственного значения ТИПА Result
с помощью замыкания. У замыкания один параметр — Promise
, который является функцией ТИПа (Result<Output, Failure>) -> Void
:
Полученное Future
мы превратим в AnyPublisher <UIImage?, Error>
с помощью оператора «стирания ТИПА» eraseToAnyPublisher()
.
Далее мы выполним следующие шаги, учитывая при этом все возможные ошибки (мы не будем идентифицировать ошибки, нам просто важно знать, что ошибка есть):
- 0. проверяем
url
наnil
иnoData
наtrue
(то есть уже известно, что при выборке возникает ошибка и получить изображение не удастся): если это так, то возвращаем ошибку, если нет — передаемurl
далее по цепочке, - 1. создаем «издателя»
dataTaskPublisher(for:)
, у которого на входе —url
, а выходным значениемOutput
является кортеж(data: Data, response: URLResponse)
и ошибкаURLError
,
- 2. анализируем с помощью
tryMap { }
полученный кортежа(data: Data, response: URLResponse)
: еслиresponse.statusCode
находится в диапазоне200...299
, то для дальнейшей обработки берем только данныеdata
. В противном случае «выбрасываем» ошибку (неважно какую), - 3. в
map
преобразуем данныеdata
вUIImage
, - 4. доставляем результат на
main
поток, так как предполагаем в дальнейшем использование при проектированииUI
- «подписываемся» на полученного «издателя» с помощью
sink
и его замыканийreceiveCompletion
иreceiveValue,
5. если в замыкании receiveCompletion
обнаруживаем ошибку error
, то сообщаем о ней с помощью promise (.failure(error)))
,
6. в замыкании receiveValue
сообщаем об успешном получении массива фильмов с помощью promise (.success($0))
,
- 7. запоминаем полученную «подписку» в переменной
var subscriptions = Set<AnyCancellable>()
, чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра классаImageLoader
, - 8. «стираем» ТИП «издателя» и возвращаем экземпляр
AnyPublisher
.
Возвращаемся к ArticleImage
, в котором будем использовать новую @Published
переменную noData
. Если данных изображения нет, то мы ничего не будем отображать, то есть EmptyView
:
Напоследок мы упакуем все наши возможности показа данных с агрегатора новостей NewsAPI.org в TabView
:
Отображение ошибок при выборке и декодировании JSON данных с сервера NewsAPI.org.
При обращении к серверу NewsAPI.org могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ API-key
или, имея тариф разработчика, который ничего не стоит, превысили допустимое количество запросов или еще что-то. При этом сервер NewsAPI.org снабжает вас HTTP кодом и соответствующим сообщением :
Необходимо обрабатывать такого рода ошибки сервера. Иначе пользователь вашего приложения попадёт в ситуацию, когда вдруг ни с того, ни с сего, сервер NewsAPI.org перестанет обрабатывать какие-либо запросы, оставляя пользователя в полном недоумении с пустым экраном.
До сих пор при выборке статей [Article]
и источников информации [Source]
с сервера NewsAPI.org мы игнорировали все ошибки, и в случае их появления возвращали в качестве результата пустые массивы [Article]()
и [Source]()
.
Приступая к обработке ошибок, давайте на основе уже существующего метода fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never>
выборки статей создадим в классе NewsAPI
другой метод fetchArticlesErr (from endpoint: Endpoint) -> AnyPublisher<[Article], NewsError>
, который будет возвращать не только массив статей [Article]
, но и возможную ошибку NewsError
:
func fetchArticlesErr(from endpoint: Endpoint) ->
AnyPublisher<[Article], NewsError> {
. . . . . . . .
}
Этот метод, также, как и метод fetchArticles
, на входе принимает endpoint
и возвращает «издателя» со значением в виде массива статей [Article]
, но вместо отсутствия ошибки Never,
у нас может присутствовать ошибка, определяемая перечислением NewsError
: (точно таким же как в предыдущей статье, только с другим названием)
Начнем создание нового метода с инициализации «издателя» Future
, который можно использовать для асинхронного получения единственного значения ТИПА Result
с помощью замыкания. У замыкания один параметр — Promise
, который является функцией ТИПа (Result<Output, Failure>) -> Void
:
Полученное Future
мы превратим в нужного нам «издателя» AnyPublisher <[Article], NewsError>
с помощью оператора «стирания ТИПА» eraseToAnyPublisher()
.
Далее мы повторим все шаги, которые мы делали в методе fetchArticles
, но при этом будем учитывать все возможные ошибки:
- 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
, снабжая её данныеdata
, преобразованными в строкуString
, содержащую истинную ошибку сервера, - 3. декодируем
JSON
данные непосредственно в Модель, которая представлена структуройNewsResponse
, - 4. доставляем результат на
main
поток, так как предполагаем в дальнейшем использование при проектированииUI
- «подписываемся» на полученного «издателя» с помощью
sink
и его замыканийreceiveCompletion
иreceiveValue,
5. если в замыкании receiveCompletion
обнаруживаем ошибку error
, то сообщаем о ней с помощью promise (.failure(...)))
,
6. в замыкании receiveValue
сообщаем об успешном получении массива статей с помощью promise (.success($0.articles))
,
- 7. запоминаем полученную «подписку» в переменной
var subscriptions = Set<AnyCancellable>()
, чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра классаNewsAPI
, - 8. «стираем» ТИП «издателя» и возвращаем экземпляр
AnyPublisher
.
Следует отметить, что «издатель» dataTaskPublisher(for:)
отличается от своего прототипа dataTask
тем, что в случае ошибки сервера, когда response.statusCode
НЕ находится в диапазоне 200...299
, он всё равно поставляет успешное значения в виде кортежа (data: Data, response: URLResponse)
, а не ошибку в виде (Error, URLResponse?)
. В этом случае реальная информация об ошибке сервера содержится в data
. «Издатель» dataTaskPublisher(for:)
поставляет ошибку URLError
, если возникает ошибка на клиентской стороне (невозможность связаться с сервером, запрет системы безопасности ATS
и т.д.) .
Если мы хотим отображать ошибки в SwiftUI
, то нужна соответствующая View Model
, которую мы назовем ArticlesViewModelErr
:
В классе ArticlesViewModelErr
, реализующем протокол ObservableObject
, у нас на этот раз ЧЕТЫРЕ @Published
свойства:
@Published var indexEndpoint: Int
— это индексEndpoint
(условно можно назвать его «входным», так как регулируется пользователем наView
),@Published var searchString: String
— это поисковая строка, смысл которой зависит отEndpoint
: это может быть «категория» новостей. источник информации или действительно поисковая строка (условно это свойство также можно назвать «входным», так как оно будет регулироваться пользователем наView
),-
@Published var articles: [Article]
— список соответствующих статей (условно «выходное», так как получается путем выборки данных с сайта NewsAPI.org ) - свойство
@Published var articlesError: NewsError?
— это ошибка, которая может возникнуть на любом этапе выборки данных с сайта NewsAPI.org.
При инициализации экземпляра класса ArticlesViewModelErr
мы опять должны протянуть цепочку от входного «издателей» $indexEndpoint
и $searchString
до выходного «издателя» AnyPublisher<[Article],NewsError>
, на которого мы «подписываемся» с помощью «подписчика» sink
и получаем либо массив статей articles
, либо ошибку articlesError
.
В нашем NewsAPI мы уже сконструировали функцию fetchArticlesErr (from endpoint: Endpoint), которая возвращает «издателя» AnyPublisher<[Article], NewsError>, в зависимости от значения endpoint, и нам нужно только каким-то образом использовать значения «издателей» $indexEndpoint
и $searchString
, чтобы превратить их в аргумент этой функции endpoint
.
Для начала объединим «издателей» $indexEndpoint
и $searchString
. Для этого в Combine
существует оператор Publishers.CombineLatest
:
Затем мы должны установить для полученного «издателя» ТИП ошибки равный требуемому NewsError
:
Далее мы хотим воспользоваться функцией fetchArticlesErr (from endpoint: Endpoint)
из нашего NewsAPI
. Как обычно, мы будем это делать с помощью оператора flatMap
, который создает нового «издателя» на основе данных, полученных от предыдущего «издателя»:
Затем мы «подписываемся» на этого вновь полученного «издателя» с помощью «подписчика» sink
и используем его замыкания receiveCompletion
и receiveValue
для получения от «издателя» либо значения массива статей articles
, либо ошибки articlesError
:
Естественно, нам необходимо запомнить полученного в результате «подписчика» в некоторой внешней по отношению к init()
переменной cancellableSet
. Иначе мы не сможем асинхронно получить значение articles
или ошибку articlesError
после завершения init()
:
Для того чтобы сократить число обращений к серверу при наборе поисковой строки searchString
, мы должны использовать не непосредственно самого «издателя» строки поиска $searchString
, а его модифицированный вариант validString
:
«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в init()
, будет сохраняться в течение всего “жизненного цикла” экземпляра класса ArticlesViewModelErr
:
Приступим к коррекции нашего UI
с целью возможных ошибок выборки данных.
В SwiftUI
в уже имеющейся структуре ContentVieArticles
используем другую, только что полученную View Model
, лишь добавив в названии буквы «Err». Это экземпляр класса ArticlesViewModelErr
, который «улавливает» ошибку выборки и/или декодирования данных о статьях с сервера NewsAPI.org:
И ещё добавляем показ экстренного сообщения Alert
для случая появления ошибки.
Например, если неверный API
ключ:
struct APIConstants {
// News API key url: https://newsapi.org
static let apiKey: String = "API_KEY"
. . . . . . . . . . . . .
}
… то мы получим такое сообщение:
Если лимит запросов исчерпан, то мы получим такое сообщение:
Возвращаясь к методу выборки статей [Article]
с возможной ошибкой NewsError
, мы можем упростить его код, если будем использовать Generic
«издателя» AnyPublisher<T,NewsError>
, который на основании заданного url
асинхронно получает JSON
информацию, размещает её непосредственно в Codable
Модели T
и сообщает об ошибке NewsError
:
Как мы знаем, этот код очень легко использовать для получения конкретного «издателя», если исходными данными для url
является Endpoint
для агрегатора новостей NewsAPI.org или страна country
источника информации, а на выходе требуются различные Модели — например, список статей или источников информации:
Заключение.
Для создания приложения, взаимодействующего с агрегатора новостей NewsAPI.org, мы воспользовались в точности той же самой технологией, которую мы использовали в предыдущей статье для работы с базой данных фильмов TMDb.
Также как и в предыдущей статье мы опирались на код простого Generic
«издателя» AnyPublisher<T, Never>
, который асинхронно получает JSON
информацию и размещает её непосредственно в Codable
Модели T
на основании заданного url
:
Мы использовали его для получения «издателя» AnyPublisher<[Article], Never>
, публикующего набор статей, и «издателя» AnyPublisher<[Source], Never>
, публикующего список источников информации с агрегатора новостей NewsAPI.org:
Полученных «издателей», как оказалось, очень просто «заставить работать» в ObservableObject
классах, которые с помощью своих @Published
свойств управляют UI
, спроектированным с помощью SwiftUI
. Эти классы иногда называют View Model
.
Вот пример View Model
для отображения различных списков статей articles
в зависимости от индекса indexEndpoint
нужной нам коллекции статей и поисковой строки searchString
:
Эта простейшая View Model
позволяет нам получить различные наборы статей и источников информации с сервера NewsAPI.org :
- последние новости,
- новости, выбранных для определенной категории (
sports
,healthy
,science
,business
,technology
) , - новости, выбранных для определенного источника информации (
CNN
,ABC News
,Fox News
и т.д.) , - любых новостей, выбранных по определенному условию
searchFilter
, содержащемуся в заголовке статьи, - источники информации.
Используя аналогичный подход, создаём «издателя» AnyPublisher<UIImage?, Error>
для выборки изображений, который на основе URL
асинхронно выбирает данные и пытается получить изображение UIImage
. Если при выборке данных изображения произошла ошибка, то она фиксируется нашим «издателем» и мы не будем ожидать появления такого изображения на экране, даже если его URL
не равен nil
. Это особенно актуально для агрегатора оперативных новостей NewsAPI.org , который наполняется разными информационными агенствами, а они, например, могут по-разному кодировать отсутствие изображения для своих статей, или предоставлять её только для private
доступа. Загрузчики изображений кэшируются в памяти для того, чтобы избежать повторной асинхронной выборки данных.
В статье показано, как учитывать ошибки, возникающие при обращении к серверу NewsAPI.org, когда, возможно, неправильно задан ключ API-key
или превышено допустимое количество запросов, определяемое тарифом подписки.
Код приложения для данной статьи находится на 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» на русском языке)