Выполнение
HTTP
запросов — это один из самых важных навыков, которые необходимо получить при разработке iOS
приложений. В более ранних версиях Swift
(до версии 5) вне зависимости от того, формировали ли вы эти запросы «с нуля» или с использование известного фреймворка Alamofire, вы в конечном итоге получали сложный и запутанный код с callback
типа completionHandler: @escaping(Result<T, APIError>) -> Void
.Появление в
Swift 5
нового фреймворка функционального реактивного программирования Combine
в сочетании с уже существующими URLSession
и Codable
предоставляет вам все необходимые инструменты для самостоятельного написания очень компактного кода для выборки данных из интернета.В этой статье в соответствии с концепцией
Combine
мы будем создавать «издателей» Publisher
для выборки данных из интернета, на которые в дальнейшем сможем легко «подписаться» и использовать при проектировании UI
как с помощью UIKit
, так и с помощью SwiftUI
.В
SwiftUI
это выглядит более лаконично и более эффектно, так как действие «издателей» Publisher
не ограничивается просто выборкой данных, а простирается дальше, вплоть до управления пользовательским интерфейсом (UI
). Дело в том, что в SwiftUI
разделение данных и View
осуществляется с помощью ObservableObject
классов с @Published
свойствами, изменения которых SwiftUI
АВТОМАТИЧЕСКИ отслеживает и полностью «перерисовывает» свои View
. В эти
ObservableObject
классы можно очень просто заложить определенную бизнес-логику приложения, если некоторые из этих @Published
свойств являются результатом синхронных и/ или асинхронных преобразований других @Published
свойств, которые можно напрямую менять такими «активными» элементами пользовательского интерфейса (UI
) как текстовые поля TextField
, Picker
, Stepper
, Toggle
и т.д. Чтобы было понятно, о чём идет речь, приведу конкретные примеры. Сейчас многие сервисы типа агрегаторов новостей NewsAPI.org и Hacker News предлагают пользователям выбирать различные наборы статей в зависимости от того, что их интересует. В случае агрегатора новостей NewsAPI.org это могут быть последние новости, или новости в какой-нибудь категории — «спорт», «здоровье», «наука», «технологии», «бизнес», или новости от определенного информационного источника «CNN», «ABC news», «Bloomberg» и т.д. Свои желания для сервисов пользователь обычно «высказывает» в виде
Endpoint
, которые формируют для него нужный URL
.Так вот, используя фреймворк
Combine
, вы можете в ObservableObject
классах с помощью очень компактного кода (в большинстве случаев не более 10-12 строк) однократно сформировать синхронную и/или асинхронную зависимость списка статей от Endpoint
в виде «подписки» «пассивных» @Published
свойств на «активные» @Published
свойства. Эта «подписка» будет действовать на протяжении всего «жизненного цикла» экземпляра ObservableObject
класса. А далее в SwiftUI
вы предоставите пользователю возможность управлять только «активными» @Published
свойства в виде Endpoint
, то есть тем, ЧТО он хочет видеть: то ли это будут статьи с последними новостями или статьи в разделе «здоровье». Появление же самих статей с последними новостями или статей в разделе «здоровье» на вашем UI
будет обеспечиваться АВТОМАТИЧЕСКИ этими ObservableObject
классами и их «пассивными» @Published свойствами. В коде SwiftUI
у вас никогда не возникнет необходимости явно запрашивать выборку статей, ибо за их правильное и синхронное отображение на экране ответственны ObservableObject
классы, которые исполняют роль View Model
.Я покажу, как это работает на примере агрегаторов новостей NewsAPI.org и Hacker News и базы данных фильмов TMDb в серии статей. Во всех трех случаях будет действовать примерно одна и та же схема использования
Combine
, ибо в приложениях такого рода всегда приходится формировать СПИСКИ фильмов или статей, выбирать сопровождающие их «КАРТИНКИ» (images), ИСКАТЬ в базах данных нужные фильмы или статьи с помощью поисковой строки.При обращении к такого рода сервисам могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ
API-key
или превысили допустимое количество запросов или еще что-то. Необходимо обрабатывать такого рода ошибки, иначе вы рискуете оставить пользователя в полном недоумении с пустым экраном. Поэтому надо уметь не только выбирать с помощью Combine
данные из интернета, но и сообщать об ошибках, которые могут возникнуть при выборке, и управлять их появлением на экране.Начнём выработку стратегии с разработки приложения, взаимодействующего с агрегатором новостей NewsAPI.org. Надо сказать, что в этом приложении
SwiftUI
будет использоваться в минимальном объеме без всяких изысков и исключительно для того, чтобы показать как Combine
с его «издателями» Publisher
и «подпиской» Subscription
воздействуют на UI
. Рекомендуется зарегистрироваться на веб-сайте NewsAPI.org и получить ключ
API
, который необходим для выполнения любых запросов к сервису NewsAPI.org. Вы должны разместить его в файле NewsAPI.swift в структуре APIConstants
.Код приложения для данной статьи находится на Github.
Модель данных и 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
, которая имеет различный смысл для разных опций перечисления:init? (index: Int, text: String = "sports") {
switch index {
case 0: self = .topHeadLines
case 1: self = .search(searchFilter: text)
case 2: self = .articlesFromCategory(text)
case 3: self = .articlesFromSource(text)
case 4: self = .sources (country: text)
default: return nil
}
}
Вернемся в класс
NewsAPI
и рассмотрим более подробно первый метод fetchArticles (from endpoint: Endpoint)-> AnyPublisher<[Article], Never>
, который выбирает статьи [Article]
на основе параметра endpoint
и не возвращает никакой ошибки — Never
:func fetchArticles(from endpoint: Endpoint) -> AnyPublisher<[Article], Never> {
guard let url = endpoint.absoluteURL else { // 0
return Just([Article]()).eraseToAnyPublisher()
}
return
URLSession.shared.dataTaskPublisher(for:url) // 1
.map{$0.data} // 2
.decode(type: NewsResponse.self, // 3
decoder: APIConstants .jsonDecoder)
.map{$0.articles} // 4
.replaceError(with: []) // 5
.receive(on: RunLoop.main) // 6
.eraseToAnyPublisher() // 7
}
- на основе
endpoint
формируемURL
для запроса нужного списка статейendpoint.absoluteURL
, если этого не удалось сделать, то возвращаем пустой массив статей[Article]()
- используем «издателя»
dataTaskPublisher(for:)
, у которого выходным значениемOutput
является кортеж(data: Data, response: URLResponse)
, а ошибкойFailure
-URLError
, - с помощью
map { }
берем для дальнейшей обработки из кортежа(data: Data, response: URLResponse)
только данныеdata
, - декодируем
JSON
данныеdata
непосредственно в Модель, которая представлена структуройNewsResponse
, содержащей массив статейarticles: [Atricle]
- с помощью
map { }
для дальнейшей обработки берем только данные о статьях -articles
, - при возникновении каких-либо ошибок на предыдущих шагах возвращаем пустой массив
[ ]
, - доставляем результат на
main
поток, так как предполагаем в дальнейшем его использование при проектированииUI
, - «стираем» ТИП «издателя» с помощью
eraseToAnyPublisher()
и возвращаем экземплярAnyPublisher
.
Задача выборки источников информации возложена на второй метод -
fetchSources (for country: String) -> AnyPublisher<[Source], Never>
, который является точной семантической копией первого метода за исключением того, что на этот раз вместо статей [Article]
мы будем выбирать источники информации [Source]
:func fetchSources() -> AnyPublisher<[Source], Never> {
guard let url = Endpoint.sources.absoluteURL else { // 0
return Just([Source]()).eraseToAnyPublisher()
}
return
URLSession.shared.dataTaskPublisher(for:url) // 1
.map{$0.data} // 2
.decode(type: SourcesResponse.self, // 3
decoder: APIConstants .jsonDecoder)
.map{$0.sources} // 4
.replaceError(with: []) // 5
.receive(on: RunLoop.main) // 6
.eraseToAnyPublisher() // 7
}
Он возвращает нам «издателя»
AnyPublisher <[Source], Never>
со значением в виде массива источников информации [Source]
и отсутствием ошибки Never
(в случае ошибок возвращается пустой массив источников [ ]
).Мы выделим общую часть этих двух методов, оформим ее в виде
Generic
функции fetch(_ url: URL) -> AnyPublisher<T, Error>
, возвращающей Generic
«издателя» AnyPublisher<T, Error>
на основании URL
:// Асинхронная выборка на основе URL
func fetch<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
URLSession.shared.dataTaskPublisher(for: url) // 1
.map { $0.data} // 2
.decode(type: T.self, decoder: APIConstants.jsonDecoder) // 3
.receive(on: RunLoop.main) // 4
.eraseToAnyPublisher() // 5
}
Это позволит упростить предыдущие два метода:
// Асинхронная выборка статей
func fetchArticles(from endpoint: Endpoint)
-> AnyPublisher<[Article], Never> {
guard let url = endpoint.absoluteURL else {
return Just([Article]()).eraseToAnyPublisher() // 0
}
return fetch(url) // 1
.map { (response: NewsResponse) -> [Article] in // 2
return response.articles }
.replaceError(with: [Article]()) // 3
.eraseToAnyPublisher() // 4
}
// Асинхронная выборка источников информации
func fetchSources(for country: String)
-> AnyPublisher<[Source], Never> {
guard let url = Endpoint.sources(country: country).absoluteURL
else {
return Just([Source]()).eraseToAnyPublisher() // 0
}
return fetch(url) // 1
.map { (response: SourcesResponse) -> [Source] in // 2
response.sources }
.replaceError(with: [Source]()) // 3
.eraseToAnyPublisher() // 4
}
Полученные таким образом «издатели» ничего не поставляют до тех пор, пока на них кто-то не «подпишется». Мы можем сделать это при проектировании
UI
.«Издатели» Publisher
как View Model
в SwiftUI
. Список статей.
Сейчас совсем немного о логики функционирования
SwiftUI
.В
SwiftUI
единственной абстракцией «внешних изменений», на которую реагируют View
, являются «издатели» Publisher
. Под «внешними изменениями» можно понимать таймер Timer
, уведомление с NotificationCenter
или ваш объект Модели, который с помощью протокола ObservableObject
можно превратить во внешний единственный «источник истины» (source of truth). На обычных «издателей» типа
Timer
или NotificationCenter
View
реагирует с помощью метода onReceive (_: perform:)
. Пример использования «издателя» Timer
мы представим позже, в третьей статье, посвященной созданию приложения для Hacker News. В этой статье мы сосредоточимся на том, как сделать нашу Модель для
SwiftUI
внешним «источником истины» (source of truth).Давайте сначала рассмотрим, как в
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
«подписку» и это легко проверить, если мы сохраним нашу «подписку» в константе let subscription
:Основное свойство
AnyCancellable
«подписки» состоит в том, что как только она покидает свою область действия, занятая ею память автоматически освобождается. Поэтому, как только init( )
завершится, эта «подписка» будет удалена ARC
, так и не успев присвоить полученную с задержкой по времени асинхронную информацию массиву articles
. Асинхронной информации попросту некуда «приземляться», у неё в прямом смысле «земля ушла из-под ног».Для сохранения такой «подписки» необходимо создать ЗА ПРЕДЕЛАМИ инициализатора
init()
переменную var cancellableSet
, которая сохранит нашу AnyCancellable
«подписку» в этой переменной в течении всего “жизненного цикла” экземпляра класса ArticlesViewMode
. Поэтому убираем константу
let subscription
и запоминается нашу AnyCancellable
«подписку» в переменной cancellableSet
с помощью оператора .store ( in: &self.cancellableSet)
:«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в
init( )
, будет сохраняться в течение всего “жизненного цикла” экземпляра класса ArticlesViewModel
.Мы можем как угодно менять значение «издателей»
$indexEndpoint
и /или searchString
, и всегда благодаря созданной «подписке» у нас будет соответствующий значениям этих двух издателей массив статей articles
без каких-либо дополнительных усилий. Такой ObservableObject
класс обычно называют View Model
.Для того чтобы сократить число обращений к серверу при наборе поисковой строки
searchString
, мы должны использовать не непосредственно самого «издателя» строки поиска $searchString
, а его модифицированный вариант validString
:Теперь, когда у нас есть
View Model
для наших статей, приступим к созданию пользовательского интерфейса (UI
). В SwiftUI
для синхронизации View
c ObservableObject
Моделью используется @ObservedObject
переменная, ссылающаяся на экземпляр класса этой Модели. Именно эта пара - ObservableObject
класс и @ObservedObject
переменная, ссылающаяся на экземпляр этого класса — управляют изменением пользовательского интерфейса (UI
) в SwiftUI
.Добавим в структуру
ContentView
экземпляр класса ArticleViewModel
в виде переменной var articleViewModel
и заменим 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
:И опять используем ту же схему: при инициализации экземпляра класса
SourcesViewModel
класса в init
мы создаём «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса 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
экземпляр класса SourcesViewModel
в виде переменной var 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
с помощью функции fetchImage(for url: URL?) -> AnyPublisher<UIImage?, Never>
создадим «издателя» AnyPublisher<UIImage?, Never>
со значением изображения UIImage?
и отсутствием ошибки Never
(в действительности, если ошибки возникают, то в качестве изображения возвращается nil
). На этого «издателя» можно «подписаться» для получения изображения UIImage?
при проектировании пользовательского интерфейса (UI
). Исходными данными для функции fetchImage(for url: URL?)
является 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?
- это само изображение с сайта NewsAPI.org:
И опять при инициализации экземпляра класса
ImageLoader
мы должны протянуть цепочку от входного «издателя» $url
до выходного «издателя» AnyPublisher<UIImage?, Never>
, на которого в последствии мы «подпишемся» и получим нужное нам изображение image
:Используем оператор
flatMap
и очень простого «подписчика» assign (to: \image, on: self)
с целью присваивания полученного от «издателя» значения свойству @Published image
:И опять в переменной
cancellableSet
запоминается AnyCancellable
«подписка» с помощью оператора store(in: &self.cancellableSet)
.Логика этого «загрузчика изображений» состоит в том, что вы загружаете изображение из отличного от
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
, то равенство nil
изображения image
может означать как то, что изображение загружается, так и то, что при загрузке произошла ошибка и мы никогда не получим изображение 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. запоминаем полученную «подписку» в переменной
cancellableSet
, чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра класса 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()
.Далее в новом методе
fetchArticlesErr
мы повторим все шаги, которые мы делали в методе 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))
,
- 5. если в замыкании
- 7. запоминаем полученную «подписку» в переменной
var subscriptions
, чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра класса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
с целью отображения на нем возможных ошибок выборки данных. В SwiftU
I в уже имеющейся структуре 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
источника информации, а на выходе требуются различные Модели — например, список статей или источников информации:Заключение.
Мы узнали, как просто выполнять
HTTP
запросы с помощью Combine
, его URLSession
«издателя» dataTaskPublisher
и Codable
. Если не нужно отслеживать ошибки, то получается очень простой Generic
код из 5 строк, для «издателя» AnyPublisher<T, Never>
, который асинхронно получает JSON
информацию и размещает её непосредственно в Codable
Модели T
на основании заданного url
:Этот код очень легко использовать для получения конкретного издателя, если исходными данными для
url
является, например Endpoint
, а на выходе требуются различные Модели — например, набор статей или список источников информации.Если нужно учитывать ошибки, то код для
Generic
«издателя» немного усложнится, но все равно это будет очень простой код без каких-либо callbacks:Используя технологию выполнения
HTTP
запросы с помощью Combine
, можно создать «издателя» AnyPublisher<UIImage?, Never>
, который асинхронно выбирает данные и получает изображение UIImage? на основе URL
. Загрузчики изображений ImageLoade
r кэшируются в памяти для того, чтобы избежать повторной асинхронной выборки данных.Полученные всевозможные «издатели» можно очень просто «заставить работать» в ObservableObject классах, которые с помощью своих @Published свойств управляют вашим UI, спроектированным с помощью SwiftUI. Эти классы обычно играют роль View Model, так как в них есть так называемые «входные» @Published свойства, соответствующие активным UI элементам (текстовым полям TextField, Stepper, Picker, переключатели Toggle и т.д.) и «выходные» @Published свойства, состоящие в основном из пассивных UI элементов (текстов Text, изображений Image, геометрических фигур Circle(), Rectangle() и т.д.
Этой идеей пронизано всё приложение для работы с агрегатором новостей NewsAPI.org, представленное в этой статье. Она оказалась достаточно универсальной и использовалась при разработке приложения для базы данных фильмов TMDb и агрегатора новостей Hacker News, о которых будет рассказано в следующих статьях.
Код приложения для данной статьи находится на Github.
P.S.
1. Хочу обратить ваше внимание на то, что если вы пользуетесь симулятором для приложения, представленного в этой статье, то знай, что
NavigationLink
на симуляторе работает с ошибкой. Вы можете воспользоваться NavigationLink
на симуляторе только 1 раз. Т.е. вы использовали ссылку, вернулись назад, кликаете на ту же ссылку — и ничего не происходит. До тех пор пока вы не воспользуетесь другой ссылкой, первая не заработает, зато вторая станет недоступной. Но такое наблюдается только на симуляторе, на реальном устройстве все работает нормально.2. Некоторые источники информации все еще используют
http
вместо https
для «картинок» своих статей. Если вы определённо хотите увидеть эти «картинки», но не можете контролировать источник их появления, то вам придётся настроить систему безопасности ATS ( App Transport Security)
на получение этих http
«картинок», но это, конечно, не самая хорошая идея. Можно воспользоваться более безопасными вариантами.Ссылки:
Современный код для выполнения HTTP запросов в Swift 5 с помощью Combine и применение его в SwiftUI. Часть 1 Фильмы.
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» на русском языке)
Denismih
Отличная статья, автору спасибо.