Hacker News, чей
API
мы собираемся использовать в этой статье, является социальным сайтом, сфокусированным на компьютерах и предпринимательстве. Если вы с ним ещё не знакомы, вы найдёте там много интересного.В предыдущих статьях на примере базы данных фильмов TMDb и агрегатора новостей NewsAPI.org была представлена стратегия применения
Combine
для формирования HTTP
запросов и использования их во View Model
для управления UI
, спроектированного с помощью SwiftUI
. В этой статье мы в точности воспроизведем ту же самую стратегию для разработки приложения, взаимодействующего с агрегатором новостей Hacker News, но добавим работу с «внешним» издателем Timer
и для простоты исключим обработку ошибок.Надо сказать, что выборка статей на ресурсе Hacker News имеет совершенно другую логику, чем в новостном агрегаторе NewsAPI.org, но технология, основанная на выполнении
HTTP
запросов с помощью Combine
, прекрасно показывает свою гибкость и в этой ситуации. Кроме того, информация на сайте Hacker News очень часто обновляется и использование внешнего «издателя» Timer
позволит автоматически отслеживать поступающие на сайт новые истории (Story
), именно так их называют на этом ресурсе.API
агрегатора новостей Hacker News можно использовать совершенно свободно и не требуется никакой регистрации для аккаунта разработчика. Это здорово, потому что вы можете сразу начать работать над кодом без длительной регистрации, как мы делали это с другими public APIs
. Наша стратегия состоит в том, что мы создаём с помощью
Combine
«издателей» Publisher
для выборки данных из интернета, на которые затем «подписываемся» в ObservableObject
классах с @Published
свойствами, изменения которых SwiftUI
АВТОМАТИЧЕСКИ отслеживает и полностью «перерисовывает» свои View
.В эти
ObservableObject
классы мы закладываем определенную бизнес-логику приложения, пользуясь тем, что некоторые из этих @Published
свойств могут напрямую меняться либо такими «активными» элементами пользовательского интерфейса (U
I) как текстовые поля TextField
, Picker
, Steppe
r, 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
и для определенногоindexEndpoin
t).
Как только мы поставили
@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.CombineLates
t:Перейти к нужному издателю
stories (from: Endpoint)
в Combine
нам поможет оператор flatMap
:Оператор
flatMap
создает нового «издателя» на основе данных, полученных от предыдущего «издателя».Доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании
UI
:Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью «подписчика»
sink
и его замыкания receiveValue
, в котором получим нужный нам список историй stories
, но мы не спешим присваивать полученное от «издателя» значение массиву @Published stories
:Мы анализируем
id
самой свежей истории из вновь загруженного списка историй currentIds.first!
и id
самой свежей истории из списка историй, уже отображенных на экране, oldIds.first!
. Если они не равны, то есть на сайте находится новая история, то мы присваиваем новое значение stories
нашему @Published
массиву stories
, попутно запоминая его в oldStories
и подавая звуковой сигнал. Если нет, то @Published stories
не обновляется.Мы только что создали в
init( )
АСИНХРОННОГО «издателя» и «подписались» на него, в результате получив AnyCancellable
«подписку», которую мы сохраним в переменной private var subscriptions
:«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в
init( )
, будет сохраняться в течение всего “жизненного цикла” экземпляра класса StoriesViewMode
l.Благодаря созданной «подписке» при любом изменении значений «издателей»
$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, имеет совершенно другую логику, основанную на идентификаторах историй, технология, основанная на Combine, показала свою невероятную гибкость.
Также как и в предыдущих статьях мы опирались на код простого
Generic
«издателя» AnyPublisher<T, Never>
, который асинхронно получает JSON
информацию и размещает её непосредственно в Codable
Модели T
на основании заданного url
:Мы использовали его для получения «издателя»
AnyPublisher<Story, Never>
, публикующего одну историю, и «издателя» AnyPublisher<[Stories], Never>
, публикующего разнообразные списки историй и «издателя» AnyPublisher<[Int], Never>
, публикующего идентификаторы списков историй, с агрегатора новостей Hacker News:Полученные «издатели» прекрасно работают в
ObservableObject
классах, которые с помощью своих @Published
свойств АВТОМАТИЧЕСКИ СИНХРОНИЗИРУЕТ выбранные из интернета данные с View
:Эта простейшая
View Model
позволяет нам постоянно АВТОМАТИЧЕСКИ обновлять новостной контент с агрегатора новостей Hacker News. «Инициаторами» этого обновления являются как внешний «издатель» Timer
и появление новых историй на сайте Hacker News, так и желание пользователя узнать о разных типах историй: самых свежих, топовых или самых лучших.Код приложения для данной статьи находится на Github.
Ссылки:
Combine: Asynchronous Programming with Swift
«SwiftUI & Combine: Лучше вместе»
Introducing Combine — WWDC 2019 — Videos — Apple Developer. session 722
Combine in Practice — WWDC 2019 — Videos — Apple Developer. session 721