Современный код для выполнения HTTP запросов в Swift 5 с помощью Combine и применение их в SwiftUI. Часть 3. Новости Hacker News.

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

  1. story (id: Int) -> AnyPublisher<Story, Never> — выборка истории Story с идентификатором id,
  2. 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:

  1. на основе id формируем URL Endpoint.story(id).url для запроса нужной истории и используем «издателя» dataTaskPublisher(for:), у которого выходным значением является кортеж (data: Data, response: URLResponse), а ошибкой — URLError,
  2. с помощью map { } берем из кортежа (data: Data, response: URLResponse) для дальнейшей обработки только данные data
  3. декодируем JSON данные data непосредственно в Модель, которая представлена структурой Story
  4. при возникновении каких-либо ошибок на предыдущих шагах немедленно возвращаем пустого «издателя» Empty с помощью «издателя» catch {  },
  5. «стираем» ТИП «издателя» с помощью 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» на русском языке)