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[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
без каких-либо сообщений, почему этот массив историй или соответствующая история оказались пустыми. 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
историй наподобие: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
.$indexEndpoint
и $currentDate
. Для этого в Combine
существует оператор Publishers.CombineLates
t:stories (from: Endpoint)
в Combine
нам поможет оператор flatMap
:flatMap
создает нового «издателя» на основе данных, полученных от предыдущего «издателя». 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, мы все равно каждый раз, когда срабатывает таймер 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
.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, так и желание пользователя узнать о разных типах историй: самых свежих, топовых или самых лучших.
Комментарии (0):