Código moderno para fazer solicitações HTTP no Swift 5 usando Combine e usá-las no SwiftUI. Parte 1



A consulta HTTPé uma das habilidades mais importantes que você precisa obter ao desenvolver iOSaplicativos. Nas versões anteriores Swift(até a versão 5), independentemente de você ter gerado essas solicitações "do zero" ou usando a conhecida estrutura Alamofire , você acabou com um código de callback tipo  complexo e confuso completionHandler: @escaping(Result<T, APIError>) -> Void.

A aparência na Swift 5nova estrutura da programação reativa funcional Combineem conjunto com a existente URLSessione Codablefornece a você todas as ferramentas necessárias para a gravação independente de códigos muito compactos para buscar dados da Internet.

Neste artigo, de acordo com o conceito, Combinecriaremos "editores"Publisherpara selecionar dados da Internet, nos quais podemos "facilmente assinar" no futuro e usar ao projetar UIcom  UIKite com ajuda  SwiftUI.

Como  SwiftUIparece mais conciso e mais eficaz, porque a ação dos "editores"  Publishernão se limita apenas aos dados de amostra e se estende até o controle da interface do usuário ( UI). O fato é que SwiftUIa separação de dados é  View realizada usando ObservableObjectclasses com @Publishedpropriedades, cujas alterações são  SwiftUImonitoradas AUTOMATICAMENTE e redesenhadas completamente View.

Nessas  ObservableObjectclasses, você pode simplesmente colocar uma certa lógica comercial do aplicativo, se alguns deles@Published propriedades são o resultado de síncronos e / ou assíncronos de transformação outras @Published  propriedades que podem ser alterados directamente tais elementos "activos" da interface de utilizador ( UI) como caixas de texto TextField, Picker, Stepper, Toggleetc.

Para deixar claro o que está em jogo, darei exemplos específicos. Agora, muitos serviços, como NewsAPI.org  e Hacker News, oferecem agregadores de notícias para  oferecer aos usuários a escolha de diferentes conjuntos de artigos, dependendo do que lhes interessa. No caso do agregador de notícias  NewsAPI.org, podem ser as últimas notícias ou notícias de alguma categoria - "esporte", "saúde", "ciência", "tecnologia", "negócios" ou notícias de uma fonte de informação específica "CNN" , Notícias da ABC, Bloomberg, etc. O usuário geralmente "expressa" seus desejos por serviços na forma Endpointque for necessária URL.

Então, usando a estrutura  Combine, você podeObservableObject classes usando um código muito compacto (na maioria dos casos, não mais que 10 a 12 linhas) uma vez para formar uma dependência síncrona e / ou assíncrona da lista de artigos  Endpointna forma de uma "assinatura" de @Published propriedades "passivas" para propriedades "ativas" @Published . Essa "assinatura" será válida durante todo o "ciclo de vida" da instância da  ObservableObject classe. E então, SwiftUI você dará ao usuário a oportunidade de gerenciar apenas as @Published propriedades "ativas" no formulário Endpoint, ou seja, O QUE ele deseja ver: se serão artigos com as últimas notícias ou artigos na seção "saúde". A aparência dos artigos em si, com as últimas notícias ou artigos na seção "saúde", UI será fornecida AUTOMATICAMENTE por essas  ObservableObject classes e suas propriedades @ passivas "publicadas". Em códigoSwiftUI você nunca precisará solicitar explicitamente uma seleção de artigos, porque as ObservableObjectclasses que desempenham um papel são responsáveis ​​por sua exibição correta e síncrona na tela  View Model.

Vou mostrar como isso funciona com o  NewsAPI.org  e o Hacker News e o banco de dados de filmes TMDb em uma série de artigos. Nos três casos, aproximadamente o mesmo padrão de uso funcionará  Combine, porque em aplicativos desse tipo você sempre deve criar LISTAS de filmes ou artigos, escolha as “FOTOS” (imagens) que os acompanham, PESQUISA nas bases de dados dos filmes ou artigos necessários usando a barra de pesquisa.

Ao acessar esses serviços, podem ocorrer erros, por exemplo, devido ao fato de você ter especificado a chave errada API-keyou ter excedido o número permitido de solicitações ou outra coisa. Você precisa lidar com esse tipo de erro, caso contrário, corre o risco de deixar o usuário completamente perdido com uma tela em branco. Portanto, você precisa não apenas selecionar  Combine dados da Internet usando , mas também reportar erros que podem ocorrer durante a amostragem e controlar sua aparência na tela.

Começaremos a desenvolver nossa estratégia, desenvolvendo um aplicativo que interaja com o agregador de notícias  NewsAPI.org . Devo dizer que, neste aplicativo, ele SwiftUIserá usado no mínimo, sem frescuras e apenas para mostrar como, Combinecom seus "editores"Publishere "assinatura" são Subscriptionafetados UI.

É recomendável que você se registre no site NewsAPI.org e receba a chave APInecessária para concluir qualquer solicitação ao serviço NewsAPI.org . Você deve colocá-lo no arquivo NewsAPI.swift na estrutura APIConstants.

O código do aplicativo para este artigo está no Github .

Modelo de dados de serviço e API do NewsAPI.org


O serviço  NewsAPI.org permite selecionar informações sobre os artigos de notícias atuais [Article]e suas fontes  [Source]. Nosso modelo de dados será muito simples, está localizado no arquivo  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?
}

O artigo Articleconterá o identificador id, título title, descrição  description, autor author, URL da “imagem” urlToImage, data da publicação publishedAte fonte da publicação source. Acima dos artigos, [Article]há um complemento NewsResponseno qual estaremos interessados ​​apenas na propriedade articles, que é uma variedade de artigos. A estrutura raiz NewsResponsee a estrutura  Articlesão Codable, o que nos permitirá literalmente duas linhas de código decodificando os JSONdados no Modelo. A estrutura  Article também deve ser Identifiable, se queremos tornar mais fácil para nós para exibir uma série de artigos [Article]como uma lista  List em SwiftUI. Protocolo Identifiable requer a presença de uma propriedadeidque forneceremos com um identificador exclusivo artificial UUID().

A fonte de informações  Sourceconterá um identificador id, nome name, descrição  description, país country, categoria de fonte de publicação category, URL do site url. Acima das fontes de informação,  [Source] há um complemento  SourcesResponseno qual estaremos interessados ​​apenas em uma propriedade sources, que é uma matriz de fontes de informação. A estrutura SourcesResponsee a  estrutura raiz Sourcesão Codable, o que nos permitirá decodificar com muita facilidade os JSONdados em um modelo. A estrutura  Source também deve ser Identifiable, se quisermos facilitar a exibição de uma matriz de fontes de informação  [Source]na forma de uma lista  List emSwiftUI. O protocolo Identifiablerequer a presença da propriedade idque já possuímos, portanto nenhum esforço adicional será necessário de nós.

Agora considere o que precisamos  APIpara o serviço  NewsAPI.org e coloque-o no arquivo  NewsAPI.swift . A parte central da nossa API é uma classe NewsAPIque apresenta dois métodos para selecionar dados do agregador de notícias   NewsAPI.org - artigos  [Article]e fontes de informação  [Source]:

  • fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never>  - seleção de artigos com  [Article]base no parâmetro endpoint,
  • fetchSources (for country: String) -> AnyPublisher<[Source], Never>- Uma seleção de fontes de informação [Source]para um país específico country.

Esses métodos retornam não apenas uma variedade de artigos  [Article] ou uma variedade de fontes de informações  [Source], mas os "publicadores" correspondentes da  Publisher nova estrutura Combine. Ambos os editores não retornam nenhum erro - Nevere se ainda ocorreu um erro de amostragem ou codificação, uma matriz vazia de artigos [Article]() ou fontes de informação é  retornada  [Source]()sem nenhuma mensagem sobre por que essas matrizes estavam vazias. 

Quais artigos ou fontes de informação que queremos selecionar no servidor NewsAPI.org , indicaremos usando a enumeraçãoenum 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"
        }
    }
}

Isto:

  • as últimas notícias  .topHeadLines,
  • notícias de uma determinada categoria (esportes, saúde, ciência, negócios, tecnologia)  .articlesFromCategory(_ category: String),
  • notícias de uma fonte específica de informações (CNN, ABC News, Fox News etc.)  .articlesFromSource(_ source: String),
  • qualquer notícia  .search (searchFilter: String)que atenda a uma determinada condição searchFilter,
  • fontes de informação .sources (country:String)para um país em particular country.

Para facilitar a inicialização da opção de que precisamos, adicionamos um Endpointinicializador init?à enumeração para várias listas de artigos e fontes de informações, dependendo do índice index e da string text, que tem significados diferentes para diferentes opções de enumeração:

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
        }
    }

Vamos voltar à classe NewsAPI e considerar mais detalhadamente o primeiro método  fetchArticles (from endpoint: Endpoint)-> AnyPublisher<[Article], Never>, que seleciona artigos com [Article]base no parâmetro endpointe não retorna nenhum erro - 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
    }

  • com base no endpoint formulário URLpara a solicitação, a lista desejada de artigos endpoint.absoluteURL, se isso não puder ser feito, retorne uma matriz vazia de artigos[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.

A tarefa de selecionar fontes de informação é atribuída ao segundo método - fetchSources (for country: String) -> AnyPublisher<[Source], Never>que é uma cópia semântica exata do primeiro método, exceto que, desta vez, em vez de artigos [Article], escolheremos as fontes de informação [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
    }

Ele retorna para nós o "publicador" AnyPublisher <[Source], Never>com um valor na forma de uma matriz de fontes de informação [Source] e a ausência de um erro  Never (no caso de erros, uma matriz vazia de fontes é retornada  [ ]).

Vamos destacar a parte comum desses dois métodos, organizá-la como uma Genericfunção fetch(_ url: URL) -> AnyPublisher<T, Error>que retorna o  Generic"editor" com AnyPublisher<T, Error>base em 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
    }

Isso simplificará os dois métodos anteriores:

//   
     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
    }

Os "publicadores" assim obtidos não entregam nada até que alguém os "assine". Podemos fazer isso ao projetar UI.

"Editores" Publishercomo View Model em SwiftUI. Lista de artigos.


Agora um pouco sobre a lógica do funcionamento SwiftUI.

A SwiftUI única abstração das "mudanças externas" às quais eles respondem Viewsão os "editores" Publisher. “Alterações externas” podem ser entendidas como um temporizador Timer, uma notificação NotificationCenter ou seu objeto Modelo, que usando o protocolo ObservableObjectpode ser transformado em uma única “fonte de verdade” externa (fonte de verdade). 

Para "publishers" ordinários tipo Timerou NotificationCenter Viewreage usando o método onReceive (_: perform:). Um exemplo do uso do "editor" Timerque apresentaremos posteriormente no terceiro artigo sobre a criação de um aplicativo para o Hacker News .

Neste artigo, focaremos em como criar nosso modelo para SwiftUI"fonte da verdade" externa (fonte da verdade).

Vamos primeiro ver como os SwiftUI"editores" recebidos devem funcionar em um exemplo específico de exibição de vários tipos de artigos:

.topHeadLines- as últimas notícias,  .articlesFromCategory(_ category: String) - notícias para uma categoria específica,  .articlesFromSource(_ source: String) - notícias para uma fonte específica de informações, .search (searchFilter: String) - notícias selecionadas por uma determinada condição.



Dependendo de qual Endpointusuário escolher, precisamos atualizar a lista de artigos articlesselecionados no NewsAPI.org . Para fazer isso, criaremos uma classe muito simples  ArticlesViewModelque implementa um protocolo ObservableObject com três  @Publishedpropriedades:
 


  • @Published var indexEndpoint: IntEndpoint ( «», View),  
  • @Published var searchString: String — ,   ( «», View  TextField),
  • @Published var articles: [Article] - ( «», NewsAPI.org, «»).

Assim que definir  @Published propriedades indexEndpoint ou searchString, podemos começar a usá-los tanto como propriedades simples  indexEndpoint e  searchString, e como "editores"  $indexEndpoint$searchString.

Em uma classe  ArticlesViewModel, você pode não apenas declarar as propriedades de interesse para nós, mas também prescrever a lógica de negócios de sua interação. Para esse propósito, ao inicializar uma instância de uma classe  ArticlesViewModel , initpodemos criar uma "assinatura" que atuará durante todo o "ciclo de vida" da instância da classe  ArticlesViewModele reproduzirá a dependência da lista de artigos articlesno índice  indexEndpoint e na cadeia de pesquisa searchString.

Para fazer isso, Combineestendemos a cadeia de "editores" $indexEndpoint e produzimos $searchString"editores"AnyPublisher<[Article], Never>cujo valor é uma lista de artigos  articles. Em seguida, "assinamos" usando o operador assign (to: \.articles, on: self)e obtemos a lista de artigos que precisamos articles como uma @Published propriedade "output"  que define UI.

Nós devemos puxar a cadeia NÃO simplesmente das propriedades  indexEndpointe searchString, principalmente dos "editores" $indexEndpointe $searchStringque participam da criação UIcom a ajuda de, SwiftUIe nós os alteraremos lá usando os elementos da interface do usuário  Pickere TextField.

Como faremos isso?

Já temos uma função em nosso arsenal fetchArticles (from: Endpoint)que está na classe  NewsAPIe retorna um "editor" AnyPublisher<[Article], Never>, dependendo do valorEndpoint, e só podemos usar de alguma forma os valores dos "editores"  $indexEndpoint$searchStringtransformá-los em um argumento para endpointessa função. 

Primeiro, combine os "editores"  $indexEndpoint e  $searchString. Para fazer isso, o Combineoperador existe Publishers.CombineLatest :



Para criar um novo "editor" com base nos dados recebidos do "editor" anterior Combine , o operador é usado  flatMap:



Em seguida, "assinamos" esse "editor" recém-recebido usando um "assinante" muito simples  assign (to: \.articles, on: self)e atribuímos o recebido de " publisher "à  @Published matriz  articles:



acabamos de criar uma init( )" editora "ASSÍNCRONA e" assinada "a ela, como resultado deAnyCancellable“Assinatura” e é fácil verificar se mantemos nossa “assinatura” constante let subscription:



a principal propriedade de uma AnyCancellable“assinatura” é que, assim que sai de seu escopo, a memória ocupada por ela é automaticamente liberada. Portanto, assim que for init( ) concluída, essa "assinatura" será excluída ARC, sem tempo para atribuir as informações assíncronas recebidas com atraso na matriz articles. Informações assíncronas simplesmente não têm para onde "pousar", em seu sentido literal, "a Terra se escondeu".

Para salvar essa "assinatura", é necessário criar uma init() variável ALÉM do inicializador var cancellableSetque salvará nossa  AnyCancellable"assinatura" nessa variável durante todo o "ciclo de vida" da instância da classe  ArticlesViewMode

Portanto, removemos a constante let subscriptione lembramos da nossa AnyCancellable"assinatura" na variável  cancellableSetusando o operador .store ( in: &self.cancellableSet):



"Subscription" para o "editor" ASSÍNCRONO em que criamos init( )será preservado durante todo o "ciclo de vida" da instância da classe  ArticlesViewModel.

Podemos alterar arbitrariamente o significado de "editores"  $indexEndpointe / ou  searchString, e sempre graças à "assinatura" criada, teremos uma variedade de artigos correspondentes aos valores desses dois editores  articlessem nenhum esforço adicional. Essa  ObservableObjectclasse geralmente é chamada  View Model.

Para reduzir o número de chamadas ao servidor ao digitar uma string de pesquisa searchString, não devemos usar o "editor" da própria string de pesquisa $searchStringe sua versão modificada validString:



agora que temos View Modelnossos artigos, vamos começar a criar a interface do usuário ( UI). Para SwiftUIsincronizar Viewcom o ObservableObject Modelo, @ObservedObjecté usada uma variável que se refere a uma instância da classe deste Modelo. É esse par - a  ObservableObject classe e a  @ObservedObjectvariável que referencia a instância dessa classe - que controla a alteração na interface do usuário ( UI) em  SwiftUI.

Adicionamos à estrutura uma ContentView instância da classe ArticleViewModel na forma de uma variável var articleViewModele a substituímos Text ("Hello, World!")por uma lista de artigos ArticlesListna qual colocamos os artigos  articlesViewModel.articlesobtidos de nossa View Model. Como resultado, obtemos uma lista de artigos para um índice fixo e padrão  indexEndpoint = 0, ou seja, para as .topHeadLines últimas notícias:



Adicione um UIelemento à nossa tela  para controlar qual conjunto de artigos queremos exibir. Usaremos a Pickeralteração do  índice $articlesViewModel.indexEndpoint. A presença de um símbolo é  $obrigatória, pois isso significa uma alteração no valor fornecido pelo  @Published "editor". A "assinatura" deste "editor" é acionada imediatamente, e iniciámos init ()o " @Publishededitor" de "saída"   articles mudará e veremos uma lista diferente de artigos na tela:



Dessa forma, podemos receber matrizes de artigos para as três opções - "topHeadLines", "pesquisar "E" da categoria ":



... mas para uma sequência de pesquisa fixa e padrão searchString = "sports"(onde é necessária): no



entanto, para a opção,  "search" você deve fornecer ao usuário um campo de texto SearchViewpara inserir a sequência de pesquisa:



Como resultado, o usuário pode procurar qualquer notícia pela sequência de pesquisa digitada:



Para a opção,  "from category" é necessário fornecer ao usuário a oportunidade de escolher uma categoria e começamos com categoria science:



como resultado, o usuário pode procurar qualquer notícia sobre a categoria escolhida de notícias - science, healthbusiness, technology:



podemos ver como uma forma muito simples  ObservableObject modelo que tem duas controladas pelo usuário @Published recursos - indexEndpoint esearchString- permite selecionar uma ampla variedade de informações no site  NewsAPI.org .

Lista de fontes de informação


Vamos ver como o SwiftUI "editor" de fontes de informações recebidas na classe NewsAPI funcionará fetchSources (for country: String) -> AnyPublisher<[Source], Never>.

Obteremos uma lista de fontes de informações para diferentes países:



... e a capacidade de procurá-las por nome:



... bem como informações detalhadas sobre a fonte selecionada: nome, categoria, país, descrição resumida e link para o site: 



Se você clicar no link, acessaremos o site deste site. fonte de informação.

Para que tudo isso funcione, você precisa de um ObservableObjectModelo extremamente simples  que possua apenas duas @Publishedpropriedades controladas pelo usuário - searchString e  country:



E, novamente, usamos o mesmo esquema: ao inicializar uma instância de uma classe de uma SourcesViewModel classe em initcriamos uma “assinatura” que funcionará durante todo o “ciclo de vida” da instância da classe  SourcesViewModele garantimos que a lista de fontes de informações dependa  sourcesdo país  countrye da string de pesquisa  searchString.

Com a ajuda  Combine, extraímos a cadeia dos "editores" $searchString e produzimos $country"editores" AnyPublisher<[Source], Never>, cujo valor é uma lista de fontes de informação. Nós "assinamos" usando o operador assign (to: \.sources, on: self), obtemos a lista de fontes de informações de que precisamos  sources. e lembre-se da AnyCancellable"assinatura" recebida  em uma variável  cancellableSetusando o operador .store ( in: &self.cancellableSet).

Agora que temos View Modelnossas fontes de informação, vamos começar a criar UI. B SwiftUIpara sincronizar ViewcObservableObject O modelo usa uma @ObservedObjectvariável que se refere a uma instância da classe deste modelo.

Adicione a ContentViewSources instância da classe à estrutura  SourcesViewModelna forma de uma variável var sourcesViewModel, remova  Text ("Hello, World!") e coloque a sua Viewpara cada uma das 3  @Publishedpropriedades  sourcesViewModel :

 
  • caixa de texto  SearchViewpara a barra de pesquisa  searchString,
  •  Picker para o país country,
  • lista de  SourcesList fontes de informação



Como resultado, obtemos o que precisamos View:



Nesta tela, gerenciamos apenas a sequência de pesquisa usando a caixa de texto SearchViewe o “país” com  Picker, e o resto acontece automaticamente.

A lista de fontes de informação SourcesListcontém informações mínimas sobre cada fonte - o nome source.name e uma breve descrição source.description:



... mas permite obter informações mais detalhadas sobre a fonte selecionada usando o link NavigationLinkno qual destinationindicamos  DetailSourceViewquais dados de fonte são a fonte de informação  sourcee a instância desejada da classe ArticlesViewModel, permitindo obtenha uma lista de seus artigos articles:



Veja como elegantemente obtemos a lista de artigos para a fonte selecionada de fonte de informações na lista de fontes  SourcesList. Nosso velho amigo nos ajuda - uma classe  ArticlesViewModelpara a qual devemos definir as duas @Publishedpropriedades de "entrada"  :

  • índice  indexEndpoint = 3, ou seja, uma opção  .articlesFromSource (_source:String)correspondente à seleção de artigos para uma fonte fixa source,
  • string  searchString como a própria fonte (ou melhor, seu identificador) source.id :



Em geral, se você olhar para todo o aplicativo NewsApp , não verá nenhum lugar em que solicitamos explicitamente uma seleção de artigos ou fontes de informação no site  NewsAPI.org . Nós gerenciamos apenas os  @Published dados, mas View Model fazemos o nosso trabalho: seleciona os artigos e as fontes de informação que precisamos.

Faça o download da imagem UIImagepara o artigo Article.


O modelo do artigo  Article contém uma URLimagem urlToImageque o acompanha  : com



base nisso,  URLno futuro, devemos obter as imagens UIImage no site  NewsAPI.org .

Já estamos familiarizados com esta tarefa. Na classe ImageLoader, usando a função, fetchImage(for url: URL?) -> AnyPublisher<UIImage?, Never>crie um "editor"  AnyPublisher<UIImage?, Never>com o valor da imagem  UIImage? e sem erros  Never(na verdade, se ocorrerem erros, a imagem será retornada nil). Você pode "assinar" este "editor" para receber imagens  UIImage? ao projetar a interface do usuário ( UI). Os dados de origem para a função fetchImage(for url: URL?)são  url:



Vamos considerar em detalhes como está acontecendo a formação, com a ajuda do Combine"editor" AnyPublisher <UIImage?, Never>, se soubermos url:

  1. se for urligual nil, retorne Just(nil),
  2. com base na urlforma do "editor" dataTaskPublisher(for:), cujo valor de saída Outputé uma tupla (data: Data, response: URLResponse)e um erro  FailureURLError,
  3. coletamos apenas dados map {}da tupla (data: Data, response: URLResponse)para processamento  datae formulário adicionais UIImage,
  4. se ocorrer o erro de retorno das etapas anteriores nil,
  5. entregamos o resultado ao mainfluxo, pois assumimos um uso adicional no design UI,
  6. "Apague" o TIPO do "editor" e devolva a cópia AnyPublisher.

Você vê que o código é bastante compacto e bem legível, não há nenhum callbacks.

Vamos começar a criar  View Model para a imagem UIImage?. Esta é uma classe ImageLoaderque implementa o protocolo ObservableObject, com duas  @Publishedpropriedades:

  • @Published url: URL? são URLimagens
  • @Published var image: UIImage? é a própria imagem do NewsAPI.org :



E, novamente, ao inicializar uma instância da classe,  ImageLoader precisamos estender a cadeia da entrada "publisher"  $url para a saída "publisher" AnyPublisher<UIImage?, Never>, na qual  assinaremos mais tarde e obteremos a imagem de que precisamos image:



Usamos o operador  flatMape um "assinante" muito simples  assign (to: \image, on: self)para atribuí-la ao recebido do "publicador" "Valores para a propriedade @Published image:



E novamente na variável  " assinatura "é cancellableSet armazenada AnyCancellableusando o operador  store(in: &self.cancellableSet).

A lógica desse “downloader de imagens” é que você baixa uma imagem de algo diferente daquele, nil URLdesde que não tenha sido pré-carregado, ou seja,image == nil. Se durante o processo de download for detectado algum erro, a imagem estará ausente, ou seja, imagepermanecerá igual nil.

Em SwiftUImostramos a imagem com a ajuda ArticleImageque uma instância da imageLoader classe usa para isso ImageLoader. Se a imagem dele não for igual nil, ela será exibida usando Image (...), mas se for igual nil, dependendo do que for igual url , nada será mostrado EmptyView()ou um retângulo Rectanglecom texto rotativo T será exibido ext("Loading..."):



Essa lógica funciona bem para o caso quando você tiver certeza de que  url, além de  nilobter uma imagem image, como é o caso do banco de dados de filmes TMDb . Com o NewsAPI.org, o agregador de notícias    é diferente. Os artigos de algumas fontes de informação dão uma diferente da  nil URLimagem, mas o acesso é fechado e obtemos um retângulo Rectanglecom texto rotativo Text("Loading...")que nunca será substituído:



Nesta situação, se a  URLimagem for diferente  nil, a igualdade da  nilimagem  imagepode significar que a imagem está sendo carregada. , e o fato de ocorrer um erro ao carregar e nunca obteremos uma imagem image. Para distinguir entre essas duas situações, adicionamos mais uma ImageLoader às duas @Publishedpropriedades existentes na classe 

 @Published var noData = false - este é um valor booleano com o qual indicaremos a ausência de dados da imagem devido a um erro durante a seleção:



Ao criar uma "assinatura", initcapturamos todos os erros Errorque ocorrem ao carregar a imagem e acumulamos sua presença na  @Publishedpropriedade self.noData = true. Se o download foi bem-sucedido, obtemos a imagem image. Criamos o

"Publicador"  AnyPublisher<UIImage?, Error> com base na  url função fetchImageErr (for url: URL?):



Começamos a criar um método fetchImageErrinicializando o "publicador"  Future, que pode ser usado para obter de forma assíncrona um único valor TYPE Resultusando um fechamento. O fechamento possui um parâmetro - Promiseque é uma função do TYPE  (Result<Output, Failure>) → Void: Transformaremos o



resultado FutureemAnyPublisher <UIImage?, Error>com a ajuda do operador “Erase TYPE” eraseToAnyPublisher().

Em seguida, vamos realizar os seguintes passos, tendo em conta todos os erros possíveis (não vamos identificar erros, é simplesmente importante para nós saber que há um erro):

0. verificação urlde nil e  noDataem true: em caso afirmativo, em seguida, retornar o erro, se não, a transferência de urlmais longe, cadeia,
1. crie um "publicador" dataTaskPublisher(for:)cuja entrada seja - urle o valor de saída Outputseja uma tupla (data: Data, response: URLResponse)e um erro  URLError,
2. analise usando a tryMap { } tupla resultante (data: Data, response: URLResponse): se response.statusCodeestiver no intervalo 200...299, para processamento adicional, coletamos apenas os dados  data. Caso contrário, "descartamos" um erro (não importa o quê),
3. vamos map { }converter os dados datapara UIImage,
4. entregar o resultado para o mainfluxo, uma vez que assumimos que vamos usá-lo mais tarde no projeto UI
- que “assinar” para o “publisher” recebeu usando sinkseus fechamentos receiveCompletione receiveValue,
- 5. se receiveCompletion encontrar um erro no fechamento  error, relatamos usando-o promise (.failure(error))),
- 6. no fechamento,  receiveValue informamos sobre o recebimento bem-sucedido de uma variedade de artigos usando promise (.success($0)),
7. lembramos a "assinatura" recebida na variável  cancellableSetpara garantir sua viabilidade durante o "tempo de vida" da instância da classe ImageLoader,
8. apagamos "o" editor "TYPE e retorne a instância AnyPublisher.

Retornamos para ArticleImageonde usaremos a nova  @Publishedvariável noData. Se não houver dados de imagem, não exibiremos nada, ou seja EmptyView ():



Finalmente, reuniremos todas as nossas possibilidades de exibir dados do agregador de notícias NewsAPI.org em TabView:



Exibir erros ao buscar e decodificar dados JSON do servidor  NewsAPI.org .


Ao acessar o servidor NewsAPI.orgpodem ocorrer erros , por exemplo, devido ao fato de você ter especificado a chave errada API-keyou, por ter uma tarifa de desenvolvedor que não custa nada, exceder o número permitido de solicitações ou outra coisa. Ao mesmo tempo, o servidor  NewsAPI.org  fornece o HTTPcódigo e a mensagem correspondente:



É necessário lidar com esse tipo de erro no servidor. Caso contrário, o usuário do seu aplicativo entrará em uma situação em que, repentinamente, sem motivo, o servidor  NewsAPI.org  interromperá o processamento de quaisquer solicitações, deixando o usuário completamente perdido com uma tela em branco.

Até agora, ao selecionar artigos  [Article]e fontes de informações  [Source]no servidor  NewsAPI.org ignoramos todos os erros e, no caso de sua aparência, retornamos matrizes vazias [Article]()e,  como resultado  [Source]().

Começando com o tratamento de erros, vamos fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never> criar NewsAPI outro método na classe com base no método de seleção de artigo  existente fetchArticlesErr (from endpoint: Endpoint) -> AnyPublisher<[Article], NewsError>que retornará não apenas uma matriz de artigos [Article], mas também um possível erro  NewsError:

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

. . . . . . . .
}

Esse método, assim como o método fetchArticles, aceita endpoint e retorna o "publicador" na entrada com um valor na forma de uma matriz de artigos [Article], mas, em vez da ausência de um erro Never, podemos ter um erro definido pela enumeração NewsError:



Começamos a criar um novo método inicializando o "publicador"  Future, que use para obter assincronamente um único valor TYPE Resultusando um fechamento. O fechamento possui um parâmetro - Promiseque é uma função do TYPE  (Result<Output, Failure>) -> Void: Transformaremos os



recebidos Futureno "editor" necessário,  AnyPublisher <[Article], NewsError>usando o operador "TYPE Erase" eraseToAnyPublisher().

Mais adiante no novo método, fetchArticlesErrrepetiremos todas as etapas que executamos no método fetchArticles, mas consideraremos todos os erros possíveis:



  • 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.

Deve-se observar que o "publicador"  dataTaskPublisher(for:) difere de seu protótipo dataTask, pois, no caso de um erro do servidor quando response.statusCode NÃO está no intervalo 200...299, ele ainda fornece o valor bem-sucedido na forma de uma tupla (data: Data, response: URLResponse), e não um erro no formulário (Error, URLResponse?). Nesse caso, as informações reais de erro do servidor estão contidas data. O "publicador" dataTaskPublisher(for:) gera um erro  URLErrorse ocorrer um erro no lado do cliente (incapacidade de entrar em contato com o servidor, proibição do sistema de segurança ATSetc.).

Se queremos exibir erros SwiftUI, precisamos do correspondente View Model, que chamaremos  ArticlesViewModelErr:



Na classe  ArticlesViewModelErrque implementa o protocolo ObservableObject , desta vez temos QUATRO  @Publishedpropriedades:

  1. @Published var indexEndpoint: IntEndpoint ( «», View), 
  2. @Published var searchString: String — ,  Endpoint: «» , ( «», View), 
  3.  @Published var articles: [Article] - ( «»,  NewsAPI.org )
  4.   @Published var articlesError: NewsError? - ,    NewsAPI.org .

Quando você inicializa uma instância de uma classe ArticlesViewModelErr, devemos estender novamente uma cadeia de entrada "editores" $indexEndpointe $searchStringsaída "publisher"  AnyPublisher<[Article],NewsError>, à qual "assinamos" com o "Assinante" sinke recebemos muitos artigos articlesou erros  articlesError.

Em nossa classe, NewsAPIjá construímos uma função  fetchArticlesErr (from endpoint: Endpoint)que retorna um "publicador"  AnyPublisher<[Article], NewsError>, dependendo do valor endpoint, e só precisamos usar de alguma forma os valores dos "publicadores"  $indexEndpoint$searchStringtransformá-los em argumento para essa função endpoint

Para começar, combinaremos os "editores"  $indexEndpoint e  $searchString. Para fazer isso, Combineexiste um operador Publishers.CombineLatest:



Em seguida, devemos definir o tipo de erro TYPE "publisher" igual ao necessário  NewsError:



Em seguida, queremos usar a função  fetchArticlesErr (from endpoint: Endpoint) da nossa classe NewsAPI. Como de costume, faremos isso com a ajuda de um operador  flatMapque cria um novo "editor" com base nos dados recebidos do "editor" anterior:



"Assinamos" esse "editor" recém-recebido com a ajuda de um "assinante" sinke usamos seus fechamentos receiveCompletione receiveValuepara receber do "editor" o valor de uma matriz de artigos  articlesou erros articlesError:



Naturalmente, é necessário lembrar a "assinatura" resultante em alguma init()variável externa cancellableSet. Caso contrário, não conseguiremos obter o valor de forma assíncronaarticlesou um erro articlesError após a conclusão init():



para reduzir o número de chamadas para o servidor ao digitar uma sequência de pesquisa searchString, não devemos usar o "editor" da própria barra de pesquisa  $searchString, mas sua versão modificada validString:



"Inscrevendo-se" no "editor" ASSÍNCRONO que criamos init( )será persistir durante todo o “ciclo de vida” da instância da classe  ArticlesViewModelErr:



Procuramos a correção do nosso UIpara exibir possíveis erros de amostragem de dados nele. Em SwiftUI, na estrutura existente,  ContentVieArticles  usamos outra, apenas obtida  View Model, apenas adicionando as letras “Err” no nome. Esta é uma instância da classe.  ArticlesViewModelErr, que “captura” o erro de selecionar e / ou decodificar os dados do artigo no servidor  NewsAPI.org :



Além disso, adicionamos a exibição de uma mensagem de emergência  Alert em caso de erro.

Por exemplo, se a chave da API errada for:

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

... então receberemos a seguinte mensagem:



Se o limite de solicitações estiver esgotado, receberemos a seguinte mensagem:



Voltando ao método de seleção de artigos  [Article] com um possível erro  NewsError, podemos simplificar seu código se usarmos o  Generic "editor" AnyPublisher<T,NewsError>,que, com base no conjunto,  urlrecebe JSONinformações de forma assíncrona , as coloca diretamente no Codablemodelo T e relata um erro  NewsError:



como sabemos, esse código é muito fácil de usar para obter um "editor" específico se os dados de origem urlforem um  país ou  agregador deEndpoint notícias  NewsAPI.orgcountry fonte de informação e a saída requer vários modelos - por exemplo, uma lista de artigos ou fontes de informação:





Conclusão


Aprendemos como é fácil atender a HTTPsolicitações com a ajuda de Combineseu URLSession"editor" dataTaskPublishere Codable. Se você não precisa rastrear erros, obtém um Genericcódigo de 5 linhas muito simples para o "editor" AnyPublisher<T, Never>, que recebe JSON informações de forma assíncrona e as coloca diretamente no Codable modelo com T base no dado  url:



Este código é muito fácil de usar para obter um editor específico, se os dados de origem url forem, por exemplo Endpoint, e a saída requer vários modelos - por exemplo, um conjunto de artigos ou uma lista de fontes de informação.

Se você precisar levar em conta os erros, o código do  Generic"editor" será um pouco mais complicado, mas ainda assim será um código muito simples, sem retornos de chamada:



Usando a tecnologia de execução de HTTPconsultas Combine, você pode criar um "editor" AnyPublisher<UIImage?, Never>que seleciona dados de forma assíncrona e recebe uma imagem UIImage? baseado em URL. Os ImageLoadedownloaders de imagem r são armazenados em cache na memória para evitar a recuperação repetida de dados assíncronos.

Todos os tipos de "editores" obtidos podem muito facilmente ser "feitos para funcionar" nas classes ObservableObject, que usam suas propriedades @Published para controlar sua interface do usuário projetada usando o SwiftUI. Essas classes geralmente desempenham o papel do modelo de exibição, pois possuem as chamadas propriedades @Published "entrada" que correspondem aos elementos ativos da interface do usuário (caixas de texto TextField, Stepper, Picker, botões de opção Alternar etc.) e propriedades @Published "output" , consistindo principalmente em elementos passivos da interface do usuário (textos, imagens, imagens, formas geométricas em círculo (), retângulo () etc.).

Essa idéia permeia todo o aplicativo agregador de notícias NewsAPI.org apresentado neste artigo. bastante universal e foi usado quandodesenvolvimento de um aplicativo para o banco de dados de filmes TMDb e o agregador de notícias  Hacker News , que serão discutidos em artigos futuros.

O código do aplicativo para este artigo está no Github .

PS

1. Quero chamar sua atenção para o fato de que, se você usar o simulador para o aplicativo apresentado neste artigo, saiba que NavigationLinko simulador funciona com um erro. Você pode usarNavigationLinkno simulador apenas 1 vez. Essa. você usou o link, voltou, clique no mesmo link - e nada acontece. Até você usar outro link, o primeiro não funcionará, mas o segundo ficará inacessível. Mas isso é observado apenas no simulador, em um dispositivo real tudo funciona bem.

2. Algumas fontes de informação ainda usam httpem vez httpsde "imagens" dos seus artigos. Se você definitivamente deseja ver essas “imagens”, mas não pode controlar a fonte de sua aparência, é necessário configurar o sistema de segurança ATS ( App Transport Security)para receber essas http“imagens”, mas isso, obviamente, não é uma boa ideia . Você pode usar opções mais seguras .

Referências:


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» )

All Articles