Pull to refresh

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

Reading time27 min
Views19K


Выполнение HTTP запросов — это один из самых важных навыков, которые необходимо получить при разработке iOS приложений. В более ранних версиях Swift (до версии 5) вне зависимости от того, формировали ли вы эти запросы «с нуля» или с использование известного фреймворка Alamofire,  вы в конечном итоге получали сложный и запутанный код с callback типа completionHandler: @escaping(Result<T, APIError>) -> Void.

Появление в Swift 5 нового фреймворка функционального реактивного программирования Combine в сочетании с уже существующими URLSession и Codable предоставляет вам все необходимые инструменты для самостоятельного написания очень компактного кода для выборки данных из интернета.

В этой статье в соответствии с концепцией Combine мы будем создавать «издателей» Publisher для выборки данных из интернета, на которые в дальнейшем сможем легко «подписаться» и использовать при проектировании UI как с помощью UIKit, так и с помощью SwiftUI.

В SwiftUI это выглядит более лаконично и более эффектно, так как действие «издателей» Publisher не ограничивается просто выборкой данных, а простирается дальше, вплоть до управления пользовательским интерфейсом (UI). Дело в том, что в SwiftUI разделение данных и View осуществляется с помощью ObservableObject классов с @Published свойствами, изменения которых SwiftUI АВТОМАТИЧЕСКИ отслеживает и полностью «перерисовывает» свои View.

В эти ObservableObject классы можно очень просто заложить определенную бизнес-логику приложения, если некоторые из этих @Published свойств являются результатом синхронных и/ или асинхронных преобразований других @Published  свойств, которые можно напрямую менять такими «активными» элементами пользовательского интерфейса (UI) как текстовые поля TextField, Picker, Stepper, Toggle и т.д.

Чтобы было понятно, о чём идет речь, приведу конкретные примеры. Сейчас многие сервисы типа агрегаторов новостей NewsAPI.org и Hacker News предлагают пользователям выбирать различные наборы статей в зависимости от того, что их интересует. В случае  агрегатора новостей NewsAPI.org это могут быть последние новости, или новости в какой-нибудь категории — «спорт», «здоровье», «наука», «технологии», «бизнес», или новости от определенного информационного источника «CNN», «ABC news», «Bloomberg» и т.д. Свои желания для сервисов пользователь обычно «высказывает» в виде Endpoint, которые формируют для него нужный URL.

Так вот, используя фреймворк Combine, вы можете в ObservableObject классах с помощью очень компактного кода (в большинстве случаев не более 10-12 строк) однократно сформировать синхронную и/или асинхронную зависимость списка статей от Endpoint в виде «подписки» «пассивных» @Published свойств на «активные» @Published свойства. Эта «подписка» будет действовать на протяжении всего «жизненного цикла» экземпляра ObservableObject класса. А далее в SwiftUI вы предоставите пользователю возможность управлять только «активными» @Published свойства в виде Endpoint, то есть тем, ЧТО он хочет видеть: то ли это будут статьи с последними новостями или статьи в разделе «здоровье». Появление же самих статей с последними новостями или статей в разделе «здоровье» на вашем UI будет обеспечиваться АВТОМАТИЧЕСКИ этими ObservableObject классами и их «пассивными» @Published свойствами.  В коде SwiftUI у вас никогда не возникнет необходимости явно запрашивать выборку статей, ибо за их правильное и синхронное отображение на экране ответственны ObservableObject классы, которые исполняют роль View Model.

Я покажу, как это работает на примере агрегаторов новостей NewsAPI.org и Hacker News и базы данных фильмов TMDb в серии статей. Во всех трех случаях будет действовать примерно одна и та же схема использования Combine, ибо в приложениях такого рода всегда приходится формировать СПИСКИ фильмов или статей, выбирать сопровождающие их «КАРТИНКИ» (images), ИСКАТЬ в базах данных нужные фильмы или статьи с помощью поисковой строки.

При обращении к такого рода сервисам могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ API-key или превысили допустимое количество запросов или еще что-то. Необходимо обрабатывать такого рода ошибки, иначе вы рискуете оставить пользователя в полном недоумении с пустым экраном.  Поэтому надо уметь не только выбирать с помощью Combine данные из интернета, но и сообщать об ошибках, которые могут возникнуть при выборке, и управлять их появлением на экране.

Начнём выработку стратегии с разработки приложения, взаимодействующего с агрегатором новостей NewsAPI.org. Надо сказать, что в этом приложении SwiftUI будет использоваться в минимальном объеме без всяких изысков и исключительно для того, чтобы показать как Combine с его «издателями» Publisher и «подпиской» Subscription воздействуют на UI.

Рекомендуется зарегистрироваться на веб-сайте NewsAPI.org и получить ключ API, который необходим для выполнения любых запросов к сервису NewsAPI.org. Вы должны разместить его в файле NewsAPI.swift в структуре APIConstants.

Код приложения для данной статьи находится на Github.

Модель данных и API сервиса NewsAPI.org


Сервис NewsAPI.org позволяет выбирать информацию об актуальных новостных статьях [Article] и их источниках [Source]. Наша Модель данных будет очень простой, она находится в файле Articles.swift:

import Foundation

struct NewsResponse: Codable {
    let status: String?
    let totalResults: Int?
    let articles: [Article]
}

struct Article: Codable, Identifiable {
    let id = UUID()
    let title: String
    let description: String?
    let author: String?
    let urlToImage: String?
    let publishedAt: Date?
    let source: Source
}

struct SourcesResponse: Codable {
    let status: String
    let sources: [Source]
}

struct Source: Codable,Identifiable {
    let id: String?
    let name: String?
    let description: String?
    let country: String?
    let category: String?
    let url: String?
}

Статья Article будет содержать идентификатор id, название title, описание description, автора author, URL «картинки» urlToImage, дату публикации publishedAt и источник публикации source. Над статьями [Article] находится надстройка NewsResponse, в которой нас будет интересовать только свойство articles, которое и представляет собой массив статей. Корневая структура NewsResponse и структура Article являются Codable, что позволит нам буквально двумя строками кода декодировании JSON данные в Модель. Структура Article должна быть еще и Identifiable, если мы хотим облегчить себе отображение массива статей [Article] в виде списка List в SwiftUI. Протокол Identifiable требует присутствия свойства id, которое мы обеспечим искусственным уникальным идентификатором UUID().

Источник информации Source будет содержать идентификатор id, название name, описание description, страну country, категорию источника публикации category, URL сайта url. Над источниками информации [Source] находится надстройка SourcesResponse, в которой нас будет интересовать только свойство sources, которое и представляет собой массив источников информации. Корневая структура SourcesResponse и структура Source являются Codable, что позволит нам очень просто декодировании JSON данные в Модель. Структура Source должна быть еще и Identifiable, если мы хотим облегчить себе отображение массива источников информации [Source] в виде списка List в SwiftUI. Протокол Identifiable требует присутствия свойства id, которое у нас уже есть, так что никаких дополнительных усилий от нас не потребуется.

Теперь рассмотрим, какой нам нужен API для сервиса NewsAPI.org, и разместим его в файле NewsAPI.swift. Центральной частью нашего API является класс NewsAPI, в котором представлены два метода выборки данных из агрегатора новостей  NewsAPI.org — статей [Article] и источников информации [Source]:

  • fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never>  - выборка статей [Article] на основе параметра endpoint,
  • fetchSources (for country: String) -> AnyPublisher<[Source], Never> - выборка источников информации[Source] для определенной страны country.

Эти методы возвращают не просто массив статей [Article] или массив источников информации [Source], а соответствующих «издателей» Publisher нового  фреймворка Combine. Оба издателя не возвращают никакой ошибки — Never, а если ошибка выборки или кодирования все-таки имела место, то возвращается пустой массив статей [Article]() или источников информации [Source]() без каких-либо сообщений, почему эти массивы оказались пустыми. 

То, какие статьи или источники информации мы хотим выбирать с сервера NewsAPI.org, будем указывать с помощью перечисления enum Endpoint:

enum Endpoint {
    case topHeadLines
    case articlesFromCategory(_ category: String)
    case articlesFromSource(_ source: String)
    case search (searchFilter: String)
    case sources (country: String)
    
    var baseURL:URL {URL(string: "https://newsapi.org/v2/")!}
    
    func path() -> String {
        switch self {
        case .topHeadLines, .articlesFromCategory:
            return "top-headlines"
        case .search,.articlesFromSource:
            return "everything"
        case .sources:
            return "sources"
        }
    }
}

Это:

  • последние новости .topHeadLines,
  • новости определенной категории (sports, healthy, science, business, technology) .articlesFromCategory(_ category: String),
  • новости определенного источника информации (CNN, ABC News, Fox News и т.д.) .articlesFromSource(_ source: String),
  • любые новости .search (searchFilter: String), отвечающие определенному условию searchFilter,
  • источники информации .sources (country:String) для определённой страны country.

Для облегчения инициализации нужной нам опции добавим в перечисление Endpoint инициализатор init? для различного рода списков статей и источников информации в зависимости от индекса index и строки text, которая имеет различный смысл для разных опций перечисления:

init? (index: Int, text: String = "sports") {
        switch index {
        case 0: self = .topHeadLines
        case 1: self = .search(searchFilter: text)
        case 2: self = .articlesFromCategory(text)
        case 3: self = .articlesFromSource(text)
        case 4: self = .sources (country: text)
        default: return nil
        }
    }

Вернемся в класс NewsAPI и рассмотрим более подробно первый метод fetchArticles (from endpoint: Endpoint)-> AnyPublisher<[Article], Never>, который выбирает статьи [Article] на основе параметра endpoint и не возвращает никакой ошибки — Never:

func fetchArticles(from endpoint: Endpoint) -> AnyPublisher<[Article], Never> {
        guard let url = endpoint.absoluteURL else {              // 0
                    return Just([Article]()).eraseToAnyPublisher()
        }
           return
            URLSession.shared.dataTaskPublisher(for:url)        // 1
            .map{$0.data}                                       // 2
            .decode(type: NewsResponse.self,                    // 3
                    decoder: APIConstants .jsonDecoder)
            .map{$0.articles}                                   // 4
            .replaceError(with: [])                             // 5
            .receive(on: RunLoop.main)                          // 6
            .eraseToAnyPublisher()                              // 7
    }

  • на основе endpoint формируем URL для запроса нужного списка статей endpoint.absoluteURL, если этого не удалось сделать, то возвращаем пустой массив статей [Article]()
  • используем «издателя» dataTaskPublisher(for:), у которого выходным значением Output является кортеж (data: Data, response: URLResponse), а ошибкой Failure - URLError,
  • с помощью map { } берем для дальнейшей обработки из кортежа (data: Data, response: URLResponse) только данные data
  • декодируем JSON данные data непосредственно в Модель, которая представлена структурой NewsResponse, содержащей массив статей articles: [Atricle]
  • с помощью map { } для дальнейшей обработки берем только данные о статьях - articles
  • при возникновении каких-либо ошибок на предыдущих шагах возвращаем пустой массив [ ],
  • доставляем результат на main поток, так как предполагаем в дальнейшем его использование при проектировании UI,
  • «стираем» ТИП «издателя» с помощью eraseToAnyPublisher() и возвращаем экземпляр AnyPublisher.

Задача выборки источников информации возложена на второй метод - fetchSources (for country: String) -> AnyPublisher<[Source], Never>, который является точной семантической копией первого метода за исключением того, что на этот раз вместо статей [Article] мы будем выбирать источники информации [Source]:

func fetchSources() -> AnyPublisher<[Source], Never> {
        guard let url = Endpoint.sources.absoluteURL else {      // 0
                       return Just([Source]()).eraseToAnyPublisher()
           }
              return
               URLSession.shared.dataTaskPublisher(for:url)      // 1
               .map{$0.data}                                     // 2
               .decode(type: SourcesResponse.self,               // 3
                       decoder: APIConstants .jsonDecoder)
               .map{$0.sources}                                  // 4
               .replaceError(with: [])                           // 5
               .receive(on: RunLoop.main)                        // 6
               .eraseToAnyPublisher()                            // 7
    }

Он возвращает нам «издателя» AnyPublisher <[Source], Never> со значением в виде массива источников информации [Source] и отсутствием ошибки Never (в случае ошибок возвращается пустой массив источников [ ]).

Мы выделим общую часть этих двух методов, оформим ее в виде Generic функции fetch(_ url: URL) -> AnyPublisher<T, Error>, возвращающей Generic «издателя» AnyPublisher<T, Error> на основании URL:

// Асинхронная выборка на основе URL
     func fetch<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
                   URLSession.shared.dataTaskPublisher(for: url)             // 1
                    .map { $0.data}                                          // 2
                    .decode(type: T.self, decoder: APIConstants.jsonDecoder) // 3
                    .receive(on: RunLoop.main)                               // 4
                    .eraseToAnyPublisher()                                   // 5
    }

Это позволит упростить предыдущие два метода:

// Асинхронная выборка статей
     func fetchArticles(from endpoint: Endpoint)
                                     -> AnyPublisher<[Article], Never> {
         guard let url = endpoint.absoluteURL else {
                     return Just([Article]()).eraseToAnyPublisher() // 0
         }
         return fetch(url)                                          // 1
             .map { (response: NewsResponse) -> [Article] in        // 2
                             return response.articles }
                .replaceError(with: [Article]())                    // 3
                .eraseToAnyPublisher()                              // 4
     }
    
    // Асинхронная выборка источников информации
    func fetchSources(for country: String)
                                       -> AnyPublisher<[Source], Never> {
        guard let url = Endpoint.sources(country: country).absoluteURL
            else {
                    return Just([Source]()).eraseToAnyPublisher() // 0
        }
        return fetch(url)                                         // 1
            .map { (response: SourcesResponse) -> [Source] in     // 2
                            response.sources }
               .replaceError(with: [Source]())                    // 3
               .eraseToAnyPublisher()                             // 4
    }

Полученные таким образом «издатели» ничего не поставляют до тех пор, пока на них кто-то не «подпишется». Мы можем сделать это при проектировании UI.

«Издатели» Publisher как View Model в SwiftUI. Список статей.


Сейчас совсем немного о логики функционирования SwiftUI.

В SwiftUI единственной абстракцией «внешних изменений», на которую реагируют View, являются «издатели» Publisher. Под «внешними изменениями» можно понимать таймер Timer, уведомление с NotificationCenter или ваш объект Модели, который с помощью протокола ObservableObject можно превратить во внешний единственный «источник истины» (source of truth). 

На обычных «издателей» типа Timer или NotificationCenter View реагирует с помощью метода onReceive (_: perform:). Пример использования «издателя» Timer мы представим позже, в третьей статье, посвященной созданию приложения для Hacker News.

В этой статье мы сосредоточимся на том, как сделать нашу Модель для SwiftUI внешним «источником истины» (source of truth).

Давайте сначала рассмотрим, как в SwiftUI должны функционировать полученные «издатели» на конкретном примере отображения различного рода статей:

.topHeadLines — последних новостей, .articlesFromCategory(_ category: String) - новостей для определенной категории, .articlesFromSource(_ source: String) - новостей для определенного источника информации, .search (searchFilter: String) - новостей, выбранных по определенному условию.



В зависимости от того, какой Endpoint выберет пользователь, нам нужно обновлять список статей articles, выбранных с сайта NewsAPI.org. Для этого мы создадим очень простой класс ArticlesViewModel, реализующий протокол ObservableObject с тремя @Published свойствами:
 


  • @Published var indexEndpoint: Int — это индекс Endpoint (условно можно назвать его «входом», так как его значение регулируется пользователем на View),  
  • @Published var searchString: String — это строка, с помощью которой осуществляется поиск произвольных статей или указывается категория интересующих нас статей  (также условно можно назвать его «входом», так как его значение регулируется пользователем на View с помощью текстового поля TextField),
  • @Published var articles: [Article] - список соответствующих статей (условно «выход», так как он создается путем выборки данных с сайта NewsAPI.org, определяемых «входами»).

Как только мы поставили @Published перед свойствами indexEndpoint или searchString, мы можем начать использовать их и как простые свойства indexEndpoint и searchString, и как «издателей» $indexEndpoint и $searchString.

В классе ArticlesViewModel, можно не просто декларировать интересующие нас свойства, но и прописать бизнес-логику их взаимодействия. С этой целью при инициализации экземпляра класса ArticlesViewModel в init мы можем создать «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса ArticlesViewModel и воспроизводить зависимость списка статей articles от индекса indexEndpoint и строки поиска searchString.

Для этого в Combine мы протягиваем цепочку от «издателей» $indexEndpoint и $searchString до выходного «издателя» AnyPublisher<[Article], Never>, у которого значение — это список статей articles. Затем мы «подпишемся» на него с помощью оператора assign (to: \.articles, on: self) и получим нужный нам список статей articles как «выходное» @Published свойство, определяющее UI.

Мы должны тянуть цепочку НЕ просто от свойств indexEndpoint и searchString, а именно от «издателей» $indexEndpoint и $searchString, которые участвуют в создании UI с помощью SwiftUI и именно их мы там будем изменять с помощью элементов  пользовательского интерфейса Picker и TextField.

Как мы будем это делать?

В нашем арсенале уже есть функция fetchArticles (from: Endpoint), которая находится в классе NewsAPI и возвращает «издателя» AnyPublisher<[Article], Never>, в зависимости от значения Endpoint, и нам остаётся только каким-то образом использовать значения «издателей» $indexEndpoint и $searchString, чтобы превратить их в аргумент endpoint этой функции. 

Cначала объединим «издателей» $indexEndpoint и $searchString. Для этого в Combine существует оператор Publishers.CombineLatest :



Для создания нового «издателя» на основе данных, полученных от предыдущего «издателя» в Combine используется оператор flatMap:



Далее мы «подписываемся» на этого вновь полученного «издателя» с помощью очень простого «подписчика» assign (to: \.articles, on: self) и присваиваем полученное от «издателя» значение @Published массиву articles:



Мы только что создали в init( ) АСИНХРОННОГО «издателя» и «подписались» на него, в результате получив AnyCancellable «подписку» и это легко проверить, если мы сохраним нашу «подписку» в константе let subscription:



Основное свойство AnyCancellable «подписки» состоит в том, что как только она покидает свою область действия, занятая ею память автоматически освобождается. Поэтому, как только init( ) завершится, эта «подписка» будет удалена ARC, так и не успев присвоить полученную с задержкой по времени асинхронную информацию массиву articles. Асинхронной информации попросту некуда «приземляться», у неё в прямом смысле «земля ушла из-под ног».

Для сохранения такой «подписки» необходимо создать ЗА ПРЕДЕЛАМИ инициализатора init() переменную var cancellableSet, которая сохранит нашу AnyCancellable «подписку» в этой переменной в течении всего “жизненного цикла” экземпляра класса  ArticlesViewMode

Поэтому убираем константу let subscription и запоминается нашу AnyCancellable «подписку» в переменной cancellableSet с помощью оператора .store ( in: &self.cancellableSet):



«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в init( ), будет сохраняться в течение всего “жизненного цикла” экземпляра класса ArticlesViewModel.

Мы можем как угодно менять значение «издателей» $indexEndpoint и /или searchString, и всегда благодаря созданной «подписке» у нас будет соответствующий  значениям этих двух издателей массив статей articles без каких-либо дополнительных усилий. Такой ObservableObject класс обычно называют View Model.

Для того чтобы сократить число обращений к серверу при наборе поисковой строки searchString, мы должны использовать не непосредственно самого «издателя» строки поиска $searchString, а его модифицированный вариант validString:



Теперь, когда у нас есть View Model для наших статей, приступим к созданию пользовательского интерфейса (UI). В SwiftUI для синхронизации View c ObservableObject Моделью используется @ObservedObject переменная, ссылающаяся на экземпляр класса этой Модели. Именно эта пара - ObservableObject класс и @ObservedObject переменная, ссылающаяся на экземпляр этого класса — управляют изменением  пользовательского интерфейса (UI) в SwiftUI.

Добавим в структуру ContentView экземпляр класса ArticleViewModel в виде переменной var articleViewModel и заменим Text ("Hello, World!") на список статей ArticlesList, в котором разместим статьи articlesViewModel.articles, полученные  из нашей View Model. В результате получим список статей для фиксированного и заданного по умолчанию индекса indexEndpoint = 0, то есть для .topHeadLines - последние новости:



Добавим на наш экран UI элемент для управления тем, какой набор статей мы хотим показывать. Будем с помощью Picker изменять индекса $articlesViewModel.indexEndpoint. Присутствие символа $ обязательно, так как это означает изменение значения, поставляемого @Published «издателем». Сразу же срабатывает «подписка» на этого «издателя», инициированная нами в init (),  «выходной» @Published «издатель» articles изменится и на экране мы увидим другой список статей:



Таким образом мы можем получать массивы статей для всех трех опций — «topHeadLines», «search» и «from category»:



...  но для фиксированной и заданной по умолчанию поисковой строки searchString = "sports" (там, где она требуется):



Однако для опции "search" необходимо предоставить пользователю текстовое поле SearchView для ввода поисковой строки:



В результате пользователь сможем искать любые новости по набранной  поисковой строке:



Для опции "from category" необходимо предоставить пользователю возможность выбрать категорию и начинаем мы с категории science:



В результате пользователь сможем искать любые новости по выбранной категории новостей — science, healthbusiness, technology:



Мы видим, как очень простая ObservableObject Модель, имеющая два управляемых пользователем @Published свойства — indexEndpoint и searchString — позволяет выбрать широкий спектр информации с сайта NewsAPI.org.

Список источников информации


Давайте рассмотрим, как в SwiftUI будет функционировать полученный в классе NewsAPI «издатель» источников информации fetchSources (for country: String) -> AnyPublisher<[Source], Never>.

Мы получим список источников информации для различных стран:



… и возможность поиска их по названию:



… а также детальную информацию о выбранном источнике: его имя, категорию, страну, краткое описание и ссылку на сайт: 



Если кликнуть на ссылке, то попадем на сайт этого источника информации.

Для того, чтобы все это работало нужна предельно простая ObservableObject Модель, имеющая всего два управляемых пользователем @Published свойств — searchString и country:



И опять используем ту же схему: при инициализации экземпляра класса SourcesViewModel класса в init мы  создаём «подписку», которая будет действовать на протяжении всего «жизненного цикла» экземпляра класса SourcesViewModel и обеспечивать зависимость списка источников информации sources от страны country и строки поиска searchString.

С помощью Combine мы тянем цепочку от «издателей» $searchString и $country до выходного «издателя» AnyPublisher<[Source], Never>, значением которого является список источников информации. Мы «подписываемся» на него с помощью оператора assign (to: \.sources, on: self), получаем нужный нам список источников информации sources. и запоминаем полученную AnyCancellable «подписку» в переменной cancellableSet с помощью оператора .store ( in: &self.cancellableSet).

Теперь, когда у нас есть View Model для наших источников информации, приступим к созданию UI. В SwiftUI для синхронизации View c ObservableObject Моделью используется @ObservedObject переменная, ссылающаяся на экземпляр класса этой Модели.

Добавим в структуру ContentViewSources экземпляр класса SourcesViewModel в виде переменной var sourcesViewModel, уберём Text ("Hello, World!") и разместим свой View для каждого из 3-х @Published свойств sourcesViewModel :

 
  • текстовое поле SearchView для строки поиска searchString,
  •  Picker для страны country,
  • список SourcesList источников информации



В результате получим нужное нам View:



На этом экране мы управляем только строкой поиска с помощью текстового поля SearchView и «страной» с помощью Picker, а остальное происходит АВТОМАТИЧЕСКИ.

Список источников информации SourcesList содержит минимальные сведения о каждом источники -  наименование source.name и краткое описание source.description:



… но позволяет получить более подробную информацию о выбранном источнике с помощью ссылки NavigationLink, в которой в качестве destination мы указываем DetailSourceView, у которого исходными данными являются сам источник информации source и нужный экземпляр класса ArticlesViewModel, позволяющий получить список его статей articles:



Посмотрите, как изящно мы получаем список статей для выбранного источника информации source в списке источников SourcesList. Нам помогает наш старый знакомый — класс ArticlesViewModel, для которого мы должны задать оба «входных» @Published свойства:

  • индекс indexEndpoint = 3, то есть опцию .articlesFromSource (_source:String), соответствующую выборке статей для фиксированного источника source,
  • строку searchString в качестве самого источника (а точнее его идентификатора) source.id :



Вообще, если вы посмотрите на всё приложение NewsApp, то нигде не увидите, чтобы мы явно запрашивали выборку статей или источников информации с сайта NewsAPI.org. Мы управляем только @Published данными, а View Model делает свою работу: выбирает нужные нам статьи и источники информации.

Загрузка изображения UIImage для статьи Article.


Модель статьи Article содержит URL сопровождающего её изображения urlToImage:



На основании этого URL мы в дальнейшем должны получить само изображения UIImage с сайта NewsAPI.org.

Нам уже знакома эта задача. В классе ImageLoader с помощью функции fetchImage(for url: URL?) -> AnyPublisher<UIImage?, Never> создадим «издателя»  AnyPublisher<UIImage?, Never> со значением изображения UIImage? и отсутствием ошибки Never (в действительности, если ошибки возникают, то в качестве изображения возвращается nil). На этого «издателя» можно «подписаться» для получения изображения UIImage? при проектировании пользовательского интерфейса (UI). Исходными данными для функции fetchImage(for url: URL?) является url, которым мы располагаем:



Рассмотрим подробно, как идет формирование с помощью Combine «издателя» AnyPublisher <UIImage?, Never>, если нам известен url:

  1. если url равен nil, то возвращаем Just(nil),
  2. на основе url формируем «издателя» dataTaskPublisher(for:), у которого выходным значением Output является кортеж (data: Data, response: URLResponse) и ошибкой FailureURLError,
  3. берем с помощью map {} из кортежа (data: Data, response: URLResponse) для дальнейшей обработки только данные data, и формируем UIImage,
  4. при возникновении ошибок на предыдущих шагах возвращаем nil,
  5. доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании UI,
  6. «стираем» ТИП «издателя» и возвращаем экземпляр AnyPublisher.

Вы видите, что код достаточно компактный и хорошо читаемый, нет никаких callbacks.

Приступим к созданию View Model для изображения UIImage?. Это класс ImageLoader, реализующий протокол ObservableObject, с двумя @Published свойствами:

  • @Published url: URL? - это URL изображения,
  • @Published var image: UIImage? - это само изображение с сайта NewsAPI.org:



И опять при инициализации экземпляра класса ImageLoader мы должны протянуть цепочку от входного «издателя» $url до выходного «издателя» AnyPublisher<UIImage?, Never>, на которого в последствии мы «подпишемся» и получим нужное нам изображение image:



Используем оператор flatMap и очень простого «подписчика» assign (to: \image, on: self) с целью присваивания полученного от «издателя» значения свойству @Published image:



И опять в переменной cancellableSet запоминается AnyCancellable «подписка» с помощью оператора store(in: &self.cancellableSet).

Логика этого «загрузчика изображений» состоит в том, что вы загружаете изображение из отличного от nil URL при условии, что оно предварительно не загружалось, то есть image == nil. Если в процессе загрузки будет обнаружена какая-либо ошибка, то изображение будет отсутствовать, то есть image останется равным nil.

В SwiftUI мы показываем изображение с помощью ArticleImage, который использует для этого экземпляр imageLoader класса ImageLoader. Если его изображение image не равно nil, то оно показывается с помощью Image (...), а вот если оно равно nil, то в зависимости от того, чему равно его url — либо ничего не показывается — EmptyView(), либо показывается прямоугольник Rectangle с вращающемся текстом Text("Loading..."):



Эта логика прекрасно работает только для случая, когда вы точно знаете, что для url, отличного от nil вы получите  изображение image, как в случае с базой данных фильмов TMDb. С агрегатором новостей NewsAPI.org  дело обстоит иначе. В статьях некоторых источников информации дается отличный от nil URL изображения, но доступ к нему закрыт, и мы получим прямоугольник Rectangle с вращающимся текстом Text("Loading..."), который никогда не будет заменен:



В этой ситуации, если URL изображения отличается от nil, то равенство nil изображения image может означать как то, что изображение загружается, так и то, что при загрузке произошла ошибка и мы никогда не получим  изображение image. Для того, чтобы различить эти две ситуации мы добавляем в классе ImageLoader к двум уже имеющимся @Published свойствам ещё одно: 

 @Published var noData = false - это булево значение, с помощью которого мы будем обозначать отсутствие данных изображения по причине возникшей ошибке при выборке:



При создании «подписки» в init ловим все ошибки Error, возникающие при загрузке изображения, и аккумулируем их присутствие в @Published свойстве self.noData = true.  Если загрузка прошла успешно, то получаем изображение image.

«Издателя» AnyPublisher<UIImage?, Error> на основе url создаем в функции fetchImageErr (for url: URL?):



Мы начинаем создание метода fetchImageErr с инициализации «издателя» Future, который можно использовать для асинхронного получения единственного значения ТИПА Result с помощью замыкания. У замыкания один параметр — Promise, который является функцией ТИПа (Result<Output, Failure>) → Void:



Полученное Future мы превратим в AnyPublisher <UIImage?, Error> с помощью оператора «стирания ТИПА» eraseToAnyPublisher().

Далее мы выполним следующие шаги, учитывая при этом все возможные ошибки (мы не будем идентифицировать ошибки, нам просто важно знать, что ошибка есть):

0. проверяем url на nil и noData на true: если это так, то возвращаем ошибку, если нет — передаем url далее по цепочке,
1. создаем «издателя» dataTaskPublisher(for:), у которого на входе — url, а выходным значением Output является кортеж (data: Data, response: URLResponse) и ошибка URLError,
2. анализируем с помощью tryMap { } полученный кортежа (data: Data, response: URLResponse): если response.statusCode находится в диапазоне 200...299, то для дальнейшей обработки берем только данные data. В противном случае «выбрасываем» ошибку (неважно какую),
3. в map { }преобразуем данные data в UIImage,
4. доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании UI
— «подписываемся» на полученного «издателя» с помощью sink и его замыканий receiveCompletion и receiveValue,
— 5. если в замыкании receiveCompletion обнаруживаем ошибку error, то сообщаем о ней с помощью promise (.failure(error))),
— 6. в замыкании receiveValue сообщаем об успешном получении массива статей с помощью promise (.success($0)),
7. запоминаем полученную «подписку» в переменной cancellableSet, чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра класса ImageLoader,
8. «стираем» ТИП «издателя» и возвращаем экземпляр AnyPublisher.

Возвращаемся к ArticleImage, в котором будем использовать новую @Published переменную noData. Если данных изображения нет, то мы ничего не будем отображать, то есть EmptyView ():



Напоследок мы упакуем все наши возможности показа данных с агрегатора новостей NewsAPI.org в TabView:



Отображение ошибок при выборке и декодировании JSON данных с сервера NewsAPI.org.


При обращении к серверу NewsAPI.org могут возникать ошибки, например, связанные с тем, что вы задали неправильный ключ API-key или, имея тариф разработчика, который ничего не стоит, превысили допустимое количество запросов или еще что-то. При этом сервер NewsAPI.org снабжает вас HTTP кодом и соответствующим сообщением:



Необходимо обрабатывать такого рода ошибки сервера. Иначе пользователь вашего приложения попадёт в ситуацию, когда вдруг ни с того, ни с сего, сервер NewsAPI.org перестанет обрабатывать какие-либо запросы, оставляя пользователя в полном недоумении с пустым экраном.

До сих пор при выборке статей [Article] и источников информации [Source] с сервера NewsAPI.org мы игнорировали все ошибки, и в случае их появления возвращали в качестве результата пустые массивы [Article]() и [Source]().

Приступая к обработке ошибок, давайте на основе уже существующего  метода fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never> выборки статей создадим в классе NewsAPI другой метод fetchArticlesErr (from endpoint: Endpoint) -> AnyPublisher<[Article], NewsError>, который будет возвращать не только массив статей [Article], но и возможную ошибку NewsError:

func fetchArticlesErr(from endpoint: Endpoint) ->
                            AnyPublisher<[Article], NewsError> {

. . . . . . . .
}

Этот метод, также, как и метод fetchArticles, на входе принимает endpoint и возвращает «издателя» со значением в виде массива статей [Article], но вместо отсутствия ошибки Never, у нас может присутствовать ошибка, определяемая перечислением NewsError:



Начнем создание нового метода с инициализации «издателя» Future, который можно использовать для асинхронного получения единственного значения ТИПА Result с помощью замыкания. У замыкания один параметр — Promise, который является функцией ТИПа (Result<Output, Failure>) -> Void:



Полученное Future мы превратим в нужного нам «издателя» AnyPublisher <[Article], NewsError> с помощью оператора «стирания ТИПА» eraseToAnyPublisher().

Далее в новом методе fetchArticlesErr мы повторим все шаги, которые мы делали в методе fetchArticles, но при этом будем учитывать все возможные ошибки:



  • 0. на основе endpoint формируем URL endpoint.absoluteURL для запроса нужной коллекции статей, проверяем url на nil: если nil, то возвращаем ошибку .urlError, если нет — передаем url далее по цепочке,
  • 1. создаем «издателя» dataTaskPublisher(for:), у которого на входе — url, а выходным значением Output является кортеж (data: Data, response: URLResponse) и ошибка URLError,
  • 2. анализируем с помощью tryMap { } полученный кортежа (data: Data, response: URLResponse): если response.statusCode находится в диапазоне 200...299, то для дальнейшей обработки берем только данные data. В противном случае «выбрасываем» ошибку .responseError, снабжая её данные data, преобразованными в строку String, содержащую истинную ошибку сервера,
  • 3. декодируем JSON данные непосредственно в Модель, которая представлена структурой NewsResponse,
  • 4. доставляем результат на main поток, так как предполагаем в дальнейшем использование при проектировании UI
  • «подписываемся» на полученного «издателя» с помощью sink и его замыканий receiveCompletion и receiveValue,
    • 5. если в замыкании receiveCompletion обнаруживаем ошибку error, то сообщаем о ней с помощью promise (.failure(...))),
    • 6. в замыкании receiveValue сообщаем об успешном получении массива статей с помощью promise (.success($0.articles)),
     
  • 7. запоминаем полученную «подписку» в переменной var subscriptions, чтобы обеспечить её жизнеспособность в пределах «времени жизни» экземпляра класса NewsAPI,
  • 8. «стираем» ТИП «издателя» и возвращаем экземпляр AnyPublisher.

Следует отметить, что «издатель» dataTaskPublisher(for:) отличается от своего прототипа dataTask тем, что в случае ошибки сервера, когда response.statusCode НЕ находится в диапазоне 200...299, он всё равно поставляет успешное значения в виде кортежа (data: Data, response: URLResponse), а не ошибку в виде (Error, URLResponse?). В этом случае реальная информация об ошибке сервера содержится в data. «Издатель» dataTaskPublisher(for:) поставляет ошибку URLError, если возникает ошибка на клиентской стороне (невозможность связаться с сервером, запрет системы безопасности ATS и т.д.).

Если мы хотим отображать ошибки в SwiftUI, то нужна соответствующая View Model, которую мы назовем ArticlesViewModelErr:



В классе ArticlesViewModelErr, реализующем протокол ObservableObject , у нас на этот раз ЧЕТЫРЕ @Published свойства:

  1. @Published var indexEndpoint: Int — это индекс Endpoint (условно можно назвать его «входным», так как регулируется пользователем на View), 
  2. @Published var searchString: String — это поисковая строка, смысл которой зависит от Endpoint: это может быть «категория» новостей, источник информации или действительно поисковая строка (условно это свойство также можно назвать «входным», так как оно будет регулироваться пользователем на View), 
  3.  @Published var articles: [Article] - список соответствующих статей (условно «выходное», так как получается путем выборки данных с сайта NewsAPI.org )
  4.  свойство @Published var articlesError: NewsError? - это ошибка, которая может возникнуть на любом этапе  выборки данных с сайта NewsAPI.org .

При инициализации экземпляра класса ArticlesViewModelErr мы опять должны протянуть цепочку от входных «издателей» $indexEndpoint и $searchString до выходного «издателя» AnyPublisher<[Article],NewsError>, на которого мы «подписываемся» с помощью «подписчика» sink и получаем либо массив статей articles, либо ошибку articlesError.

В нашем классе NewsAPI мы уже сконструировали  функцию fetchArticlesErr (from endpoint: Endpoint), которая возвращает «издателя» AnyPublisher<[Article], NewsError>, в зависимости от значения endpoint, и нам нужно только каким-то образом использовать значения «издателей» $indexEndpoint и $searchString, чтобы превратить их в аргумент этой функции endpoint

Для начала объединим «издателей» $indexEndpoint и $searchString. Для этого в Combine существует оператор Publishers.CombineLatest:



Затем мы должны установить для полученного «издателя» ТИП ошибки равный требуемому NewsError:



Далее мы хотим воспользоваться функцией fetchArticlesErr (from endpoint: Endpoint) из нашего класса NewsAPI. Как обычно, мы будем это делать с помощью оператора flatMap, который создает нового «издателя» на основе данных, полученных от предыдущего «издателя»:



Затем мы «подписываемся» на этого вновь полученного «издателя» с помощью «подписчика» sink и используем его замыкания receiveCompletion и receiveValue для получения от «издателя» либо значения массива статей articles, либо ошибки articlesError:



Естественно, необходимо запомнить полученную в результате «подписку» в некоторой внешней по отношению к init() переменной cancellableSet. Иначе мы не сможем асинхронно получить значение articles или ошибку articlesError после завершения init():



Для того чтобы сократить число обращений к серверу при наборе поисковой строки searchString, мы должны использовать не непосредственно самого «издателя» строки поиска $searchString, а его модифицированный вариант validString:



«Подписка» на АСИНХРОННОГО «издателя», которую мы создали в init( ), будет сохраняться в течение всего “жизненного цикла” экземпляра класса ArticlesViewModelErr:



Приступим к коррекции нашего UI с целью отображения на нем возможных ошибок выборки данных. В SwiftUI в уже имеющейся структуре ContentVieArticles  используем другую, только что полученную View Model, лишь добавив в названии буквы «Err». Это экземпляр класса  ArticlesViewModelErr, который «улавливает» ошибку выборки и/или декодирования данных о статьях с сервера NewsAPI.org:



И ещё добавляем показ экстренного сообщения Alert для случая появления ошибки.

Например, если неверный API ключ:

struct APIConstants {
    // News  API key url: https://newsapi.org
    static let apiKey: String = "API_KEY" 
    
   .  .  .  .  .  .  .  .  .  .  .  .  .
}

… то мы получим такое сообщение:



Если лимит запросов исчерпан,  то  мы получим такое сообщение:



Возвращаясь к методу  выборки статей [Article] с возможной ошибкой NewsError,  мы можем упростить его код,  если будем использовать Generic «издателя» AnyPublisher<T,NewsError>, который на основании заданного url асинхронно получает JSON информацию, размещает её непосредственно в Codable Модели T и сообщает об ошибке NewsError:



Как мы знаем, этот код очень легко использовать для получения конкретного «издателя», если исходными данными для url является Endpoint для агрегатора новостей NewsAPI.org или страна country источника информации, а на выходе требуются различные Модели — например, список статей или источников информации:





Заключение.


Мы узнали, как просто выполнять HTTP запросы с помощью Combine, его URLSession «издателя» dataTaskPublisher и Codable. Если не нужно отслеживать ошибки, то получается очень простой Generic код из 5 строк, для «издателя» AnyPublisher<T, Never>, который асинхронно получает JSON информацию и размещает её непосредственно в Codable Модели T на основании заданного url:



Этот код очень легко использовать для получения конкретного издателя, если исходными данными для url является, например Endpoint, а на выходе требуются различные Модели — например, набор статей или список источников информации.

Если нужно учитывать ошибки, то код для Generic «издателя» немного усложнится, но все равно это будет очень простой код без каких-либо callbacks:



Используя технологию выполнения HTTP запросы с помощью Combine, можно создать «издателя» AnyPublisher<UIImage?, Never>, который асинхронно выбирает данные и получает изображение UIImage? на основе URL. Загрузчики изображений ImageLoader кэшируются в памяти для того, чтобы избежать повторной асинхронной выборки данных.

Полученные всевозможные «издатели» можно очень просто «заставить работать» в ObservableObject классах, которые с помощью своих @Published свойств управляют вашим UI, спроектированным с помощью SwiftUI. Эти классы обычно играют роль View Model, так как в них есть так называемые «входные» @Published свойства, соответствующие активным UI элементам (текстовым полям TextField, Stepper, Picker, переключатели Toggle и т.д.) и «выходные» @Published свойства, состоящие в основном из пассивных UI элементов (текстов Text, изображений Image, геометрических фигур Circle(), Rectangle() и т.д.

Этой идеей пронизано всё приложение для работы с агрегатором новостей NewsAPI.org, представленное в этой статье. Она оказалась достаточно универсальной и использовалась при разработке приложения для базы данных фильмов TMDb и агрегатора новостей Hacker News, о которых будет рассказано в следующих статьях.

Код приложения для данной статьи находится на Github.

P.S.

1. Хочу обратить ваше внимание на то, что если вы пользуетесь симулятором для приложения, представленного в этой статье, то знай, что NavigationLink на симуляторе работает с ошибкой. Вы можете воспользоваться NavigationLink на симуляторе только 1 раз. Т.е. вы использовали ссылку, вернулись назад, кликаете на ту же ссылку — и ничего не происходит. До тех пор пока вы не воспользуетесь другой ссылкой, первая не заработает, зато вторая станет недоступной. Но такое наблюдается только на симуляторе, на реальном устройстве все работает нормально.

2. Некоторые источники информации все еще используют http вместо https для «картинок» своих статей. Если вы определённо хотите увидеть эти «картинки», но не можете контролировать источник их появления, то вам придётся настроить систему безопасности ATS ( App Transport Security) на получение этих http «картинок», но это, конечно, не самая хорошая идея. Можно воспользоваться более безопасными вариантами.

Ссылки:


Современный код для выполнения HTTP запросов в Swift 5 с помощью Combine и применение его в SwiftUI. Часть 1 Фильмы.
Modern Networking in Swift 5 with URLSession, Combine and Codable.
URLSession.DataTaskPublisher’s failure type
Combine: Asynchronous Programming with Swift
«SwiftUI & Combine: Лучше вместе»
Introducing Combine — WWDC 2019 — Videos — Apple Developer. session 722
(конспект сессии 722 «Введение в Combine» на русском языке)
Combine in Practice — WWDC 2019 — Videos — Apple Developer. session 721
(конспект сессии 721 «Практическое применение Combine» на русском языке)
Tags:
Hubs:
+5
Comments1

Articles

Change theme settings