Hacker News, чей API
мы собираемся использовать в этой статье, является социальным сайтом, сфокусированным на компьютерах и предпринимательстве. Если вы с ним ещё не знакомы, то вы найдёте там много интересного.
В предыдущих статьях на примере базы данных фильмов TMDb и агрегатора новостей NewsAPI.org была представлена стратегия применения Combine
для формирования HTTP
запросов и использования их во View Model
для управления UI
, спроектированного с помощью SwiftUI
. В этой статье мы в точности воспроизведем ту же самую стратегию для разработки приложения, взаимодействующего с агрегатором новостей Hacker News, но добавим работу с «внешним» издателем Timer
и для простоты исключим обработку ошибок.
Надо сказать, что выборка статей на ресурсе Hacker News имеет совершенно другую логику, чем в новостном агрегаторе NewsAPI.org, но технология, основанная на выполнении HTTP запросов с помощью Combine
, прекрасно показывает свою гибкость и в этой ситуации. Кроме того, информация там очень часто обновляется и использование внешнего «издателя» Timer
в View Model
для изменения UI
в SwiftUI
позволит автоматически отслеживать поступающие на сайт новые истории (Story
), именно так их называют на этом ресурсе.
API
агрегатора новостей Hacker News можно использовать совершенно свободно и не требуется никакой регистрации для аккаунта разработчика. Это здорово, потому что вы можете сразу начать работать над кодом без длительной регистрации, как мы делали это с другими public APIs
.
Наша стратегия состоит в том, что мы создаём с помощью Combine
«издателей» Publisher
для выборки данных из интернета, на которые затем «подписываемся» в ObservableObject
классах с @Published
свойствами, изменения которых SwiftUI
АВТОМАТИЧЕСКИ отслеживает и полностью «перерисовывает» свои View
.
В эти ObservableObject
классы мы закладываем определенную бизнес-логику приложения, пользуясь тем, что некоторые из этих @Published
свойств могут напрямую меняться либо такими «активными» элементами пользовательского интерфейса (UI
) как текстовые поля TextField
, Picker
, Stepper
, Toggle
, либо с помощью внешних «издателей» типа Timer
, а другие @Published
свойства, напротив, могут быть «пассивными», являясь результатом синхронных и/ или асинхронных преобразований «активных» @Published
свойств, но именно они то нас чаще всего и интересуют. Зависимость «пассивных» от «активных» @Published
свойств очень просто описываем с помощью Combine
в ObservableObject
классах, которые выступают в роли View Model
для управления UI
в SwiftUI
.
Отличительной особенностью приложения, представленное в этой статье, является то, что обновление новостного контента будет происходить АВТОМАТИЧЕСКИ без участия пользователя, благодаря внешнему «издателю» Timer
. Для того, чтобы сосредоточиться исключительно на этом, UI
приложения будет максимально упрощен: он не будет содержать никаких «картинок» (images
), кроме того не будет возможности детального исследования историй. Зато время, прошедшее с момента появления истории на сайте Hacker News, будет постоянно обновляться. Поступление каждой новой истории будет оперативно отражаться на UI
и сопровождаться звуковым сигналом.
Спустя 4 минут мы увидим такой экран:
Код приложения для данной статьи находится на Github.
Модель данных и API сервиса Hacker News.
Сервис Hacker News позволяет выбирать информацию о самых последних, топовых, самых интересных историях [Story]
и информацию о конкретной истории Story
по ее идентификатору id
. Наша Модель данных будет очень простой, она находится в файле Story.swift:
import Foundation
struct Story: Codable, Identifiable {
let id: Int
let title: String
let by: String
let time: TimeInterval
let url: String
}
История Story
будет содержать идентификатор id
, название title
, описание description
, автора by
, дату публикации time
и URL
истории url
. Структура Story
является Codable
, что позволит нам буквально одной строкой кода декодировать JSON
данные в Модель. Структура Story
должна быть еще и Identifiable
, если мы хотим облегчить себе отображение массива историй [Story]
в виде списка List
в SwiftUI
. Протокол Identifiable
требует присутствия Hashable
свойства id
, которое у нас уже есть, так что никаких дополнительных усилий от нас не потребуется.
Теперь рассмотрим, какой нам нужен API
для сервиса Hacker News , и разместим его в файле NewsAPI.swift. Центральной частью нашего API
является класс NewsAPI
, в котором представлены два метода выборки данных из агрегатора новостей Hacker News — истории Story
с фиксированным идентификатором id
и интересующих нас историй [Story]
согласно endpoint
:
story (id: Int) -> AnyPublisher<Story, Never>
— выборка историиStory
с идентификаторомid
,stories (from endpoint: Endpoint) -> AnyPublisher<[Story], Never>
— выборка историй[Story]
на основе параметраendpoint
.
В контексте нового фреймворка Combine
эти методы возвращают не просто историю Story
или массив историй [Story]
, а соответствующих «издателей» Publisher
. Оба «издателя» не возвращают никакой ошибки — Never
, а если ошибка выборки или кодирования все-таки имела место, то возвращается пустой массив историй [Story]()
или пустой «издатель» Empty
без каких-либо сообщений, почему этот массив историй или соответствующая история оказались пустыми.
То, какую информацию мы хотим выбрать с сервера Hacker News, будем указывать с помощью перечисления enum Endpoint
:
enum Endpoint {
static let baseURL =
URL(string: "https://hacker-news.firebaseio.com/v0/")!
case newstories, topstories, beststories
case story(Int)
var url: URL {
switch self {
case .newstories:
return Endpoint.baseURL.appendingPathComponent("newstories.json")
case .topstories:
return Endpoint.baseURL.appendingPathComponent("topstories.json")
case .beststories:
return Endpoint.baseURL.appendingPathComponent("beststories.json")
case .story(let id):
return Endpoint.baseURL.appendingPathComponent("item/\(id).json")
}
}
}
Это :
- последние новости
.newstories
, которые обновляются через 1-2 минуты, - топовые новости
.topstories
, которые обновляются каждые 1-2 часа, - самые значительные новости
.beststories
обновляются несколько раз в день, - определенная история
.story(Int)
с идентификаторомid
.
Для облегчения инициализации нужной нам опции добавим в перечисление Endpoint
инициализатор init?
для различного рода новостей :
init? (index: Int) {
switch index {
case 0: self = .newstories
case 1: self = .topstories
case 2: self = .beststories
default: return nil
}
}
В классе NewsAPI
рассмотрим более подробно первый метод story (id: Int) -> AnyPublisher<Story, Never>
, который выбирает историю Story
на основе её идентификатора id
:
- на основе
id
формируемURL
Endpoint.story(id).url
для запроса нужной истории и используем «издателя»dataTaskPublisher(for:)
, у которого выходным значением является кортеж(data: Data, response: URLResponse)
, а ошибкой —URLError
,
- с помощью map { } берем из кортежа
(data: Data, response: URLResponse)
для дальнейшей обработки только данныеdata
, - декодируем
JSON
данныеdata
непосредственно в Модель, которая представлена структуройStory
, - при возникновении каких-либо ошибок на предыдущих шагах немедленно возвращаем пустого «издателя»
Empty
с помощью «издателя»catch { }
, - «стираем» ТИП «издателя» с помощью
eraseToAnyPublisher()
и возвращаем экземплярAnyPublisher
.
Задача выборки историй [Story]
возложена на второй метод stories (from endpoint: Endpoint) -> AnyPublisher<[Story], Never>
, который нам предстоит собрать из кусочков.
Если мы повторим последовательность действий, представленную в предыдущем методе, но для другого URL
endpoint.url
, …
… то получим массив целых чисел [Int]
, соответствующий идентификаторам ids
историй наподобие :
Но на самом деле их будет значительно больше — 500.
Нам нужно превратить эти идентификаторы историй ids
в сами истории. Для этого мы создадим новый метод mergedStories (ids:)
, который будет получать для каждого заданного идентификатора истории id
«издателя» AnyPublisher<Story, Never>
и объединять их все вместе :
func mergedStories(ids storyIDs: [Int])
-> AnyPublisher<Story, Never>{
. . . . . . . . . . . . .
}
По существу, этот метод будет вызывать story(id:)
для каждого заданного идентификатора из массива ids
и затем «выравнивать» (flatten
) результат в единый поток выходных значений.
Прежде всего, уменьшим количество обращений к серверу и будем использовать только первые maxStories
ids
из заданного списка ids
:
func mergedStories(ids storyIDs: [Int])
-> AnyPublisher<Story, Never>{
let storyIDs = Array(storyIDs.prefix(maxStories))
precondition(!storyIDs.isEmpty)
. . . . . . . . . . . . .
}
С помощью story(id:)
создадим начального «издателя» initialPublisher
, который выбирает историю Story
с первым id
в списке ids
:
func mergedStories(ids storyIDs: [Int])
-> AnyPublisher<Story, Never>{
let storyIDs = Array(storyIDs.prefix(maxStories))
precondition(!storyIDs.isEmpty)
let initialPublisher = story(id: storyIDs[0])
let remainder = Array(storyIDs.dropFirst())
. . . . . . . . . . . . .
}
Затем мы используем reduce(_:_:)
из стандартной библиотеки Swift
, который оперирует над оставшимися ids
, чтобы добавлять каждого следующего «издателя» с идентификатором id
к начальному «издателю» initialPublisher
:
func mergedStories(ids storyIDs: [Int])
-> AnyPublisher<Story, Never> {
let storyIDs = Array(storyIDs.prefix(maxStories))
precondition(!storyIDs.isEmpty)
let initialPublisher = story(id: storyIDs[0])
let remainder = Array(storyIDs.dropFirst())
return remainder.reduce(initialPublisher) {
(combined, id) -> AnyPublisher<Story, Never> in
combined.merge(with: story(id: id))
.eraseToAnyPublisher()
}
}
Окончательный результат — это «издатель», который «публикует» каждую успешно выбранную историю Story
и игнорирует любые ошибки, которые могут возникнуть при выборке каждой отдельной истории.
Теперь мы можем вернуться к методу выборке историй stories (from endpoint: Endpoint) -> AnyPublisher<[Story], Never>
. Мы остановились на том, что повторение последовательности действий для endpoint.url
приводит нас к получению массива идентификатор историй ids
, которую мы должны использовать для получения соответствующих историй одну за другой с сервера Hacker News:
func stories(from endpoint: Endpoint) -> AnyPublisher<[Story], Never> { URLSession.shared.dataTaskPublisher(for: endpoint.url) .map { $0.0 } .decode(type: [Int].self, decoder: JSONDecoder()) .catch { _ in Empty() } . . . . . . . . . . . . . . . . .eraseToAnyPublisher() }
На следующих этапах мы будет использовать некоторые операторы для фильтрации нежелательного контента и для превращения идентификаторов историй ids
в настоящие истории.
Во-первых, отфильтруем пустой массив идентификаторов историй, потому что у метода mergedStories(ids:)
есть предварительное условие precondition
, которое обеспечивает непустой входной параметр:
func stories(from endpoint: Endpoint) -> AnyPublisher<[Story], Never> { URLSession.shared.dataTaskPublisher(for: endpoint.url) .map { $0.0 } .decode(type: [Int].self, decoder: JSONDecoder()) .catch { _ in Empty() } .filter { !$0.isEmpty } . . . . . . . . . . . . . . . . .eraseToAnyPublisher() }
На основе массива идентификатор историй storyIDs
получим реальные истории с помощью flatMap
:
func stories(from endpoint: Endpoint) -> AnyPublisher<[Story], Never> { URLSession.shared.dataTaskPublisher(for: endpoint.url) .map { $0.0 } .decode(type: [Int].self, decoder: JSONDecoder()) .catch { _ in Empty() } .filter { !$0.isEmpty } .flatMap { storyIDs in self.mergedStories(ids: storyIDs)} . . . . . . . . . . . . . . . . .eraseToAnyPublisher() }
Это создаст непрерывный поток значений Story
, причем они будут появляться по мере того, как будут выбраны из интернета. Мы же хотим иметь результат в виде массива историй [Story]
, с которым будет удобнее работать в View Controller
или в SwiftUI
View
.
Превращение потока индивидуальных значений «издателя» в массив таких значений обеспечивается очень удобным оператором collect
:
func stories(from endpoint: Endpoint) -> AnyPublisher<[Story], Never> { URLSession.shared.dataTaskPublisher(for: endpoint.url) .map { $0.0 } .decode(type: [Int].self, decoder: JSONDecoder()) .catch { _ in Empty() } .filter { !$0.isEmpty } .flatMap { storyIDs in self.mergedStories(ids: storyIDs)} .collect(maxStories) . . . . . . . . . . . . . . . . .eraseToAnyPublisher() }
Наконец, мы отсортируем полученные истории по идентификатору id
, а фактически хронологически, с помощью оператора sorted()
. Это поможет нам принять решение о том, что на сайт Hacker News поступила новая история и пора обновлять UI
.
func stories(from endpoint: Endpoint) -> AnyPublisher<[Story], Never> { URLSession.shared.dataTaskPublisher(for: endpoint.url) .map { $0.0 } .decode(type: [Int].self, decoder: JSONDecoder()) .catch { _ in Empty() } .filter { !$0.isEmpty } .flatMap { storyIDs in self.mergedStories(ids: storyIDs)} .collect(maxStories) .map { stories in stories.sorted (by: {$0.id > $1.id})} .eraseToAnyPublisher() }
Как всегда завершаем формирование «издателя» оператором «стирания ТИПА» eraseToAnyPublisher()
, который у нас уже есть:
Учитывая, что в классе NewAPI все три метода — story (id: Int), storyIDs (from endpoint: Endpoint) и stories (from endpoint: Endpoint) — работают схожим образом, мы можем использовать уже знакомую нам по предыдущим приложениям Generic
функцию, возвращающую «издателя» AnyPublisher<T, Never>
, который на основании заданного url
асинхронно получает JSON
информацию, декодирует и размещает её непосредственно в Codable
Модели T
:
Этот код мы применяем для получения конкретного «издателя» Publisher
, если исходными данными для url
является, например, Endpoint
для сервиса Hacker News. Он позволяет сформировать на выходе различные Модели — просто историю Story
, массив историй [Story]
или массив идентификаторов историй [Int]
:
Полученные таким образом «издатели» AnyPublisher
сами по себе «не взлетают», они ничего не поставляют до тех пор, пока на них кто-то не «подпишется». Мы будем использовать их при проектировании UI
в SwiftUI
и «подпишемся» на них в ObservableObject
классе, который АВТОМАТИЧЕСКИ СИНХРОНИЗИРУЕТ выбранные из интернета данные с View
.
«Издатели» Publisher
как View Model в SwiftUI
. Список историй.
Давайте сначала рассмотрим, как в SwiftUI
должны функционировать полученные «издатели» на конкретном примере отображения самых свежих историй с сайта Hacker News.
Мы видим, что с течением времени список свежих историй stories
, выбранных с сайта Hacker News, должен все время обновляться.
Кроме того, мы должны уметь отображать различные виды историй: свежие (news
), топовые (top
) или самые интересные (best
):
Для этого мы создадим очень простой класс StoriesViewModel
, реализующий протокол ObservableObject
с тремя @Published
свойствами:
- одно
@Published var indexEndpoint: Int
— это индексEndpoint
(условно можно назвать его «входом», так как его значение регулируется пользователем наView
), - второе
@Published var currentDate: Date
— это время (условно можно назвать его «входом», так как его значение регулируется наView
внешним «издателем»Timer
), - третье
@Published var stories: [Story]
— список историй (условно «выход», так как он создается путем выборки данных с сайта Hacker News в момент времениcurrentDate
и для определенногоindexEndpoint
).
Как только мы поставили @Published
перед свойством currentDate
, мы можем начать использовать его и как простое свойство currentDate
, и как «издателя» $currentDate
.
В классе StoriesViewModel
, можно не просто декларировать интересующие нас свойства, но и прописать бизнес-логику их взаимодействия. С этой целью при инициализации экземпляра класса StoriesViewModel
в init?
мы можем создать «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса StoriesViewModel
, и реализовать зависимость списка историй stories
от времени currentDate
и от индекса indexEndpoint
.
Для этого в Combine
мы протягиваем цепочку от «издателей» $currentDate
и $indexEndpoint
до выходного «издателя» AnyPublisher<[Story], Never>
, у которого значение — это список историй. Впоследствии мы «подпишемся» на него с помощью «подписчика» sink
и в его замыкании receiveValue
получим нужный нам список историй stories
как «выходное» @Published
свойство, определяющее UI.
Мы должны тянуть цепочку НЕ просто от свойств currentDate
и indexEndpoint
, а именно от «издателей» $currentDate
и $indexEndpoint
, которые будет участвовать в создании UI
и именно там мы будем их изменять с помощью внешнего «издателя» Timer
и Picker
.
Как мы будем это делать?
В нашем арсенале уже есть функция stories (from: Endpoint)
, которая находится в классе NewsAPI
и возвращает «издателя» AnyPublisher<[Story], Never>
, в зависимости от значения Endpoint
, и нам остаётся только каким-то образом использовать значения «издателя» $indexEndpoint
, чтобы превратить его в аргумент этой функции endpoint
, и вызывать ее каждый раз при изменении момента времени $currentDate
.
Cначала объединим «издателей» $indexEndpoint
и $currentDate
. Для этого в Combine
существует оператор Publishers.CombineLatest
:
Перейти к нужному издателю stories (from: Endpoint)
в Combine
нам поможет оператор flatMap
:
Оператор flatMap
создает нового «издателя» на основе данных, полученных от предыдущего «издателя».
Доставляем результат на main
поток, так как предполагаем в дальнейшем использование при проектировании UI
:
Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью «подписчика» sink
и его замыкания receiveValue
, в котором получим нужный нам список историй stories
, но мы не спешим присваивать полученное от «издателя» значение @Published
массиву stories
:
Мы анализируем id
самой свежей истории из вновь загруженного списка историй currentIds.first!
и id
самой свежей истории из списка историй, уже отображенных на экране, oldIds.first!
. Если они не равны, то есть на сайте находится новая история, то мы присваиваем новое значение stories
@Published
массиву stories
, попутно запоминая его в oldStories
и подавая звуковой сигнал. Если нет, то @Published
не обновляется.
Мы только что создали в init( )
АСИНХРОННОГО «издателя» и «подписались» на него, в результате получив AnyCancellable
«подписку», которую мы сохраним в переменной private var subscriptions
:
«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в init( )
, будет сохраняться в течение всего “жизненного цикла” экземпляра класса StoriesViewModel
.
Благодаря созданной «подписке», при любом изменении значений «издателей» $currentDate
и $indexEndpoint
у нас будет обновленный массив историй stories
без каких-либо дополнительных усилий. Такой ObservableObject
класс обычно называют View Model
.
Теперь, когда у нас есть View Model
для наших историй, приступим к созданию пользовательского интерфейса (UI
). В SwiftUI
для синхронизации View
c ObservableObject
Моделью используется @ObservedObject
переменная, ссылающаяся на экземпляр класса этой Модели. Именно эта пара — ObservableObject
класс и @ObservedObject
переменная, ссылающаяся на экземпляр этого класса — управляют изменением пользовательского интерфейса (UI
) в SwiftUI
.
Добавим во вновь созданную структуру StoriesView
переменную var model
, имеющую ТИП StoriesViewModel
, и заменим Text ("Hello, World!")
на список историй List
, в котором разместим истории model.stories
, полученные из нашей View Model
:
В результате получим список статей для фиксированного текущего момента времени currentDate = Date()
и значения indexEndpoint = 0
, то есть это случай свежих новостей .newstories
:
С течением времени на экране ничего меняться не будет, так как мы не изменяем «издателей» $currentDate
и $indexEndpoint
в нашей model
.
Для изменения $currentDate
будем использовать внешний «издатель» Timer
и реакцию на него в onReceive (timer)
:
Теперь наш список историй будет меняться согласно логики изменения историй для опции последних новостей .newstories
, то есть каждые 1-2 минуты, и будет обновляться по мере поступления новых историй, что сопровождается звуковым сигналом:
Мы можем также изменять и «издателя» $indexEndpoint
, если добавить Picker
на наш UI
:
Теперь мы получили возможность обновлять не только истории для последних новостей (news
), но и топовые истории (top
), и лучшие истории (best
):
Просто интенсивность обновления этих списков историй будет различной для разных опций.
Можно добавить заголовок для нашего View
:
Модификация View Model с целью уменьшения количества обращений к сервису Hacker News.
Хотя в предыдущей View Model
мы следим за тем, чтобы обновление экрана производилось только тогда, когда появляется новая история на сервере Hacker News, мы все равно каждый раз, когда срабатывает таймер Timer
, выбираем с сервера список всех историй, соответствующих выбранному массиву их идентификаторов. То есть мы выбираем все истории и только потом сравниваем идентификаторы вновь выбранных историй и идентификаторы «старых» историй. Если среди новых идентификаторов встречается более «свежий», мы обновляем список историй stories
:
На самом деле этот анализ можно провести гораздо раньше, то есть сразу же, как только мы получили список идентификаторов currentIds
историй, уже на этом этапе мы можем сравнивать старые идентификаторы oldIds
с новыми currentIds
, и только потом выбирать соответствующие новым идентификаторам currentIds
истории.
Для этого нам понадобится новый «издатель» AnyPublisher<[Int], Never>
, который поставляет идентификаторы историй. Мы будем получать его с помощью функции func storyIDs(from endpoint: Endpoint) -> AnyPublisher<[Int], Never>
, которую разместим в классе NewsAPI
:
Мы будем использовать его в новой View Model
, которую назовём StoriesViewModelID
и разместим в файле с таким же именем:
Здесь те же самые «входные» и «выходное» @Published
свойства, что и в View Model
с именем StoriesViewModel
, и те же «инициаторы» — «издатели» $currentDate
и $indexEndpoint
, но сама «подписка» в init()
идет по другому сценарию.
Мы действуем в пределах flatMap
и сначала за одно обращение к серверу Hacker News с помощью self.api.storyIDs (from: Endpoint (index: indexEndpoint )! )
мы получаем currentIds
новых историй. Затем реализуем в операторе map
логику сравнения старых идентификаторов oldIds
с полученными идентификаторами currentIds
, и принимаем решение о выборке настоящих историй и отображении их на UI
:
Далее в пределах уже следующего flatMap
, получив идентификаторы storyIDs
историй, выбираем настоящие историй Story
и формируем их поток с помощью «издателя» mergedStories
. Затем собираем их в массив историй [Story]
с помощью оператора collect
, а также фильтруем и сортируем полученный массив историй :
Следующим шагом «подписываемся» на полученного «издателя» с помощью sink
и его замыкания receiveValue
, в котором получаем нужный нам массив историй stories
и присваиваем его значение @Published
свойству stories
:
Не забываем полученную в результате AnyCancellable
«подписку» сохранить в переменной private var subscriptions
:
И это всё. Теперь у нас есть новая View Model
— StoriesViewModelID
, и для того, чтобы её использовать для нашего UI, мы должны в StoriesView
добавить две буквы:
На этом примере видно, как просто реализуются с помощью Combine
вложенные HTTP
запросы. В данном случае это два последовательных оператора flatMap
.
Заключение.
Для создания приложения, взаимодействующего с агрегатора новостей Hacker News, мы воспользовались в точности той же самой технологией, которую мы использовали в предыдущих статьях для работы с базой данных фильмов TMDb и агрегатором новостей NewsAPI.org. Хотя выборка статей или историй, как их называют на ресурсе Hacker News, имеет совершенно другую логику, чем в новостном агрегаторе NewsAPI.org, технология, основанная на выполнении HTTP запросов с помощью Combine
, прекрасно показала свою гибкость и в этой ситуации.
Также как и в предыдущих статьях мы опирались на код простого Generic
«издателя» AnyPublisher<T, Never>
, который асинхронно получает JSON
информацию и размещает её непосредственно в Codable
Модели T
на основании заданного url
:
Мы использовали его для получения «издателя» AnyPublisher<Story, Never>
, публикующего одну историю, и «издателя» AnyPublisher<[Stories], Never>
, публикующего разнообразные списки историй и «издателя» AnyPublisher<[Int], Never>
, публикующего идентификаторы списков историй, с агрегатора новостей Hacker News:
Полученных «издателей» легко «заставить работать» в ObservableObject
классах, которые с помощью своих @Published
свойств управляют UI
, спроектированным с помощью SwiftUI
. Эти классы называются View Model
:
Эта простейшая View Model
позволяет нам постоянно АВТОМАТИЧЕСКИ обновлять новостной контент с агрегатора новостей Hacker News. «Инициатором» этого обновления являются как внешний «издатель» Timer
и появление новых историй на сайте 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» на русском языке)