Código moderno para realizar solicitudes HTTP en Swift 5 usando Combine y usándolas en SwiftUI. Parte 1



La consulta HTTPes una de las habilidades más importantes que debe adquirir al desarrollar iOSaplicaciones. En versiones anteriores Swift(antes de la versión 5), independientemente de si generó estas solicitudes desde cero o si utilizó el conocido marco de Alamofire , terminó con un código complejo y confuso del callback tipo  completionHandler: @escaping(Result<T, APIError>) -> Void.

La aparición en Swift 5el nuevo marco de la programación reactiva funcional Combineen conjunto con la existente URLSession, y le Codableproporciona todas las herramientas necesarias para la escritura independiente de código muy compacto para obtener datos de Internet.

En este artículo, de acuerdo con el concepto, Combinecrearemos "editores"Publisherpara seleccionar datos de Internet, a los que podemos "suscribirnos" fácilmente en el futuro y utilizarlos al diseñar UIcon  UIKity con ayuda  SwiftUI.

Como  SwiftUIparece más conciso y más efectivo, porque la acción de los "editores"  Publisherno se limita solo a datos de muestra, y se extiende aún más hasta el control de la interfaz de usuario ( UI). El hecho es que SwiftUIla separación de datos se  View lleva a cabo utilizando ObservableObjectclases con @Publishedpropiedades, cuyos cambios se  SwiftUImonitorean AUTOMÁTICAMENTE y se redibujan completamente View.

En estas  ObservableObjectclases, puede simplemente poner una cierta lógica comercial de la aplicación, si alguna de estas@Published propiedades son el resultado de síncronos y / o asíncronos de transformación otras @Published  propiedades que se pueden cambiar directamente tales elementos "activos" de la interfaz de usuario ( UI) como cuadros de texto TextField, Picker, Stepper, Toggleetc.

Para aclarar lo que está en juego, daré ejemplos específicos. Ahora, muchos servicios como NewsAPI.org  y Hacker News ofrecen agregadores de noticias para  ofrecer a los usuarios elegir diferentes conjuntos de artículos según lo que les interese. En el caso del agregador de noticias  NewsAPI.org, pueden ser las últimas noticias o noticias en alguna categoría: "deporte", "salud", "ciencia", "tecnología", "negocios" o noticias de una fuente de información específica "CNN" , ABC news, Bloomberg, etc. El usuario generalmente "expresa" sus deseos de servicios en la forma Endpointque le sea necesaria URL.

Entonces, usando el marco  Combine, puedesObservableObject clases que utilizan un código muy compacto (en la mayoría de los casos, no más de 10-12 líneas) una vez para formar una dependencia síncrona y / o asíncrona de la lista de artículos  Endpointen forma de "suscripción" de @Published propiedades "pasivas" a propiedades "activas" @Published . Esta "suscripción" será válida durante todo el "ciclo de vida" de la instancia de  ObservableObject clase. Y luego SwiftUI , le dará al usuario la capacidad de administrar solo las @Published propiedades "activas" en el formulario Endpoint, es decir, QUÉ quiere ver: si serán artículos con las últimas noticias o artículos en la sección "salud". La aparición de los propios artículos con las últimas noticias o artículos en la sección "salud" UI será proporcionada AUTOMÁTICAMENTE por estas  ObservableObject clases y sus propiedades "pasivas" publicadas. En codigoSwiftUI nunca necesitará solicitar explícitamente una selección de artículos, porque las ObservableObjectclases que juegan un papel son responsables de su visualización correcta y sincrónica en la pantalla  View Model.

Te mostraré cómo funciona esto con  NewsAPI.org  y Hacker News y la base de datos de películas TMDb en una serie de artículos. En los tres casos, funcionará aproximadamente el mismo patrón de uso  Combine, porque en aplicaciones de este tipo siempre debe crear LISTAS de películas o artículos, elija las "IMÁGENES" (imágenes) que las acompañan, BUSQUE en las bases de datos de las películas o artículos necesarios utilizando la barra de búsqueda.

Al acceder a dichos servicios, pueden producirse errores, por ejemplo, debido a que especificó la clave incorrecta API-keyo excedió el número permitido de solicitudes u otra cosa. Debe manejar este tipo de error, de lo contrario corre el riesgo de dejar al usuario completamente perdido con una pantalla en blanco. Por lo tanto, debe poder no solo seleccionar  Combine datos de Internet usando , sino también informar errores que pueden ocurrir durante el muestreo y controlar su apariencia en la pantalla.

Comenzaremos a desarrollar nuestra estrategia desarrollando una aplicación que interactúe con el agregador de noticias  NewsAPI.org . Debo decir que en esta aplicación se SwiftUIutilizará en un grado mínimo sin adornos y únicamente para mostrar cómo Combinecon sus "editores"Publishery "suscripción" se Subscriptionven afectados UI.

Se recomienda que se registre en el sitio web NewsAPI.org y reciba la clave APIque se requiere para completar cualquier solicitud al servicio NewsAPI.org . Debe colocarlo en el archivo NewsAPI.swift en la estructura APIConstants.

El código de aplicación para este artículo está en Github .

Modelo de datos del servicio NewsAPI.org y API


El servicio  NewsAPI.org le permite seleccionar información sobre artículos de noticias actuales [Article]y sus fuentes  [Source]. Nuestro modelo de datos será muy simple, se encuentra en el archivo  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?
}

El artículo Articlecontendrá el identificador id, título title, descripción  description, autor author, URL de la "imagen" urlToImage, fecha de publicación publishedAty fuente de publicación source. Encima de los artículos [Article]hay un complemento NewsResponseen el que solo nos interesará la propiedad articles, que es una variedad de artículos. La estructura raíz NewsResponsey la estructura  Articleson Codable, lo que nos permitirá literalmente dos líneas de código que decodifican los JSONdatos en el Modelo. La estructura  Article debe ser también Identifiable, si queremos que sea más fácil para nosotros para mostrar una gran variedad de artículos [Article]como una lista  List en SwiftUI. El protocolo Identifiable requiere la presencia de una propiedadidque le proporcionaremos un identificador único artificial UUID().

La fuente de información  Sourcecontendrá un identificador id, nombre name, descripción  description, país country, categoría de fuente de publicación category, URL del sitio url. Encima de las fuentes de información  [Source] hay un complemento  SourcesResponseen el que solo nos interesará una propiedad sources, que es una variedad de fuentes de información. La estructura raíz SourcesResponsey la estructura  Sourceson Codable, lo que nos permitirá decodificar muy fácilmente los JSONdatos en un modelo. La estructura  Source también debería ser Identifiable, si queremos facilitar la visualización de una serie de fuentes de información  [Source]en forma de una lista  List enSwiftUI. El protocolo Identifiablerequiere la presencia de la propiedad idque ya tenemos, por lo que no se requerirá ningún esfuerzo adicional de nuestra parte.

Ahora considere lo que necesitamos  APIpara el servicio  NewsAPI.org y colóquelo en el archivo  NewsAPI.swift . La parte central de la nuestra API es una clase NewsAPIque presenta dos métodos para seleccionar datos del agregador de noticias   NewsAPI.org : artículos  [Article]y fuentes de información  [Source]:

  • fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never>  - selección de artículos en  [Article]función del parámetro endpoint,
  • fetchSources (for country: String) -> AnyPublisher<[Source], Never>- Una selección de fuentes de información [Source]para un país en particular country.

Estos métodos devuelven no solo una serie de artículos  [Article] o una variedad de fuentes de información  [Source], sino los "editores" correspondientes del  Publisher nuevo marco Combine. Ambos editores no devuelven ningún error, Nevery si todavía se produce un error de muestreo o codificación, [Article]() se devuelve una matriz vacía de artículos  o fuentes de información  [Source]()sin ningún mensaje de por qué estas matrices estaban vacías. 

Qué artículos o fuentes de información queremos seleccionar del servidor NewsAPI.org , indicaremos utilizando la enumeraciónenum 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"
        }
    }
}

Eso:

  • las últimas noticias  .topHeadLines,
  • noticias de cierta categoría (deportes, salud, ciencia, negocios, tecnología)  .articlesFromCategory(_ category: String),
  • noticias de una fuente de información específica (CNN, ABC News, Fox News, etc.)  .articlesFromSource(_ source: String),
  • cualquier noticia  .search (searchFilter: String)que cumpla una determinada condición searchFilter,
  • fuentes de información .sources (country:String)para un país en particular country.

Para facilitar la inicialización de la opción que necesitamos, agregamos un Endpointinicializador init?a la enumeración para varias listas de artículos y fuentes de información dependiendo del índice index y la cadena text, que tiene diferentes significados para diferentes opciones de enumeración:

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

Volvamos a la clase NewsAPI y consideremos con más detalle el primer método  fetchArticles (from endpoint: Endpoint)-> AnyPublisher<[Article], Never>, que selecciona artículos [Article]basados ​​en el parámetro endpointy no devuelve ningún error 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
    }

  • sobre la base del endpoint formulario URLpara la solicitud, la lista de artículos deseada endpoint.absoluteURL, si esto no se puede hacer, devuelva una matriz vacía de artículos[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.

La tarea de seleccionar fuentes de información se asigna al segundo método, fetchSources (for country: String) -> AnyPublisher<[Source], Never>que es una copia semántica exacta del primer método, excepto que esta vez, en lugar de artículos [Article], elegiremos las fuentes de información [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
    }

Nos devuelve el "editor" AnyPublisher <[Source], Never>con un valor en forma de una matriz de fuentes de información [Source] y la ausencia de un error  Never (en caso de errores, se devuelve una matriz vacía de fuentes  [ ]).

Seleccionaremos la parte común de estos dos métodos, organícela como una Genericfunción fetch(_ url: URL) -> AnyPublisher<T, Error>que devuelve el  Generic"editor" en AnyPublisher<T, Error>función de 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
    }

Esto simplificará los dos 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
    }

Los "editores" así obtenidos no entregan nada hasta que alguien se "suscribe" a ellos. Podemos hacer esto al diseñar UI.

"Editores" Publishercomo View Model en SwiftUI. Listado de artículos.


Ahora un poco sobre la lógica del funcionamiento SwiftUI.

La SwiftUI única abstracción de los "cambios externos" a los que responden Viewson los "editores" Publisher. Por "cambios externos" puede comprender un temporizador Timer, una notificación NotificationCenter o su objeto Modelo, que mediante el protocolo ObservableObjectpuede convertirse en una única "fuente de verdad" externa. 

Para los "editores" comunes, escriba Timero NotificationCenter Viewreaccione utilizando el método onReceive (_: perform:). Un ejemplo del uso del "editor" Timerlo presentaremos más adelante en el tercer artículo sobre la creación de una aplicación para Hacker News .

En este artículo, nos centraremos en cómo hacer nuestro modelo para SwiftUI"fuente de verdad" externa (fuente de verdad).

Veamos primero cómo SwiftUIdeberían funcionar los "editores" resultantes en un ejemplo específico de visualización de varios tipos de artículos:

.topHeadLines- las últimas noticias,  .articlesFromCategory(_ category: String) - noticias para una categoría específica,  .articlesFromSource(_ source: String) - noticias para una fuente de información específica, .search (searchFilter: String) - noticias seleccionadas por una determinada condición.



Según el Endpointusuario que elija, necesitamos actualizar la lista de artículos articlesseleccionados de NewsAPI.org . Para hacer esto, crearemos una clase muy simple  ArticlesViewModelque implemente un protocolo ObservableObject con tres  @Publishedpropiedades:
 


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

Tan pronto como nos propusimos  @Published propiedades indexEndpoint o searchString, podemos empezar a usarlos tanto como propiedades simples  indexEndpoint y  searchString, y como "editores"  $indexEndpoint$searchString.

En una clase  ArticlesViewModel, no solo puede declarar las propiedades que nos interesan, sino también prescribir la lógica comercial de su interacción. Para este propósito, al inicializar una instancia de una clase  ArticlesViewModel en, initpodemos crear una "suscripción" que actuará durante todo el "ciclo de vida" de la instancia de la clase  ArticlesViewModely reproducirá la dependencia de la lista de artículos articlesen el índice  indexEndpoint y la cadena de búsqueda searchString.

Para hacer esto, Combineampliamos la cadena de "editores" $indexEndpoint y la $searchStringsalida "editor"AnyPublisher<[Article], Never>cuyo valor es una lista de artículos  articles. Luego nos "suscribimos" a él utilizando el operador assign (to: \.articles, on: self)y obtenemos la lista de artículos que necesitamos articles como una @Published propiedad de "salida"  que define UI.

Debemos extraer la cadena NO simplemente de las propiedades  indexEndpointy searchString, es decir, de los "editores" $indexEndpointy $searchStringque participan en la creación UIcon la ayuda de SwiftUIy los cambiaremos allí utilizando los elementos de la interfaz de usuario  Pickery TextField.

¿Como haremos esto?

Ya tenemos una función en nuestro arsenal fetchArticles (from: Endpoint)que está en la clase  NewsAPIy devuelve un "editor" AnyPublisher<[Article], Never>, según el valorEndpoint, y solo podemos de alguna manera usar los valores de los "publicadores"  $indexEndpoint$searchStringconvertirlos en un argumento para endpointesta función. 

Primero, combine los "editores"  $indexEndpoint y  $searchString. Para hacer esto, el Combineoperador existe Publishers.CombineLatest :



para crear un nuevo "editor" basado en los datos recibidos del "editor" anterior Combine , se utiliza el operador  flatMap: a



continuación, nos "suscribimos" a este "editor" recién recibido utilizando un "suscriptor" muy simple  assign (to: \.articles, on: self)y asignamos lo recibido de " editor "valor para la  @Published matriz  articles:



acabamos de crear un init( )" editor "ASINCRÓNICO y" suscrito "a él, como resultado deAnyCancellable"Suscripción" y esto es fácil de verificar si mantenemos nuestra "suscripción" en una constante let subscription:



la propiedad principal de una AnyCancellable"suscripción" es que tan pronto como abandona su alcance, la memoria ocupada se libera automáticamente. Por lo tanto, tan pronto como se init( ) complete, esta "suscripción" se eliminará ARC, sin tener tiempo para asignar la información asincrónica recibida con un retraso de tiempo a la matriz articles. La información asincrónica simplemente no tiene ningún lugar para "aterrizar", en su sentido literal, "la tierra ha pasado de debajo de sus pies".

Para guardar dicha "suscripción", es necesario crear una init() variable MÁS ALLÁ del inicializador var cancellableSetque guardará nuestra  AnyCancellable"suscripción" en esta variable durante todo el "ciclo de vida" de la instancia de clase  ArticlesViewMode

Por lo tanto, eliminamos la constante let subscriptiony recordamos nuestra AnyCancellable"suscripción" en la variable  cancellableSetusando el operador .store ( in: &self.cancellableSet):



"Suscripción" al "editor" ASINCRÓNICO que creamos init( )se conservará durante todo el "ciclo de vida" de la instancia de clase  ArticlesViewModel.

Podemos cambiar arbitrariamente el significado de "editores"  $indexEndpointy / o  searchString, y siempre gracias a la "suscripción" creada, tendremos una serie de artículos correspondientes a los valores de estos dos editores  articlessin ningún esfuerzo adicional. Esta  ObservableObjectclase generalmente se llama  View Model.

Para reducir el número de llamadas al servidor al escribir una cadena de búsqueda searchString, no debemos usar el "editor" de la cadena de búsqueda en sí $searchString, y su versión modificada validString:



ahora que tenemos View Modelpara nuestros artículos, comencemos a crear la interfaz de usuario ( UI). Para SwiftUIsincronizar Viewcon el ObservableObject Modelo, @ObservedObjectse usa una variable que se refiere a una instancia de la clase de este Modelo. Es este par, la  ObservableObject clase y la  @ObservedObjectvariable que hace referencia a la instancia de esta clase, que controlan el cambio en la interfaz de usuario ( UI) en  SwiftUI.

Agregamos a la estructura una ContentView instancia de la clase ArticleViewModel en forma de variable var articleViewModely la reemplazamos Text ("Hello, World!")con una lista de artículos ArticlesListen la que colocamos los artículos  articlesViewModel.articlesobtenidos de nuestro View Model. Como resultado, obtenemos una lista de artículos para un índice fijo y predeterminado  indexEndpoint = 0, es decir, para las .topHeadLines últimas noticias:



agregue un UIelemento a nuestra pantalla  para controlar qué conjunto de artículos queremos mostrar. Utilizaremos el  Pickercambio de índice $articlesViewModel.indexEndpoint. La presencia de un símbolo es  $obligatoria, ya que esto significa un cambio en el valor suministrado por el  @Published "editor". La "suscripción" a este "editor" se activa de inmediato, que iniciamos en init (), el " @Publishededitor" de "salida"   articles cambiará y veremos una lista diferente de artículos en la pantalla: por lo



tanto, podemos recibir conjuntos de artículos para las tres opciones: "topHeadLines", "buscar "Y" de la categoría ":



... pero para una cadena de búsqueda fija y predeterminada searchString = "sports"(donde se requiere):



Sin embargo, para la opción,  "search" debe proporcionar al usuario un campo de texto SearchViewpara ingresar la cadena de búsqueda:



Como resultado, el usuario puede buscar cualquier noticia por la cadena de búsqueda escrita:



para la opción,  "from category" es necesario proporcionar al usuario la oportunidad de elegir una categoría y que se inicia con la categoría science:



como resultado, el usuario puede buscar cualquier noticia de la categoría elegida de noticias - science, healthbusiness, technology:



podemos ver cómo un simple  ObservableObject modelo que tiene dos controlados por el usuario @Published características - indexEndpoint ysearchString- le permite seleccionar una amplia gama de información del sitio web  NewsAPI.org .

Lista de fuentes de información.


Veamos cómo funcionará el SwiftUI "editor" de las fuentes de información recibidas en la clase NewsAPI fetchSources (for country: String) -> AnyPublisher<[Source], Never>.

Obtendremos una lista de fuentes de información para diferentes países:



... y la capacidad de buscarlos por nombre:



... así como información detallada sobre la fuente seleccionada: su nombre, categoría, país, breve descripción y enlace al sitio: 



si hace clic en el enlace, iremos al sitio web de este Fuente de información.

Para que todo esto funcione, necesita un ObservableObjectModelo extremadamente simple  que tenga solo dos @Publishedpropiedades controladas por el usuario , searchString y  country:



Y nuevamente, usamos el mismo esquema: al inicializar una instancia de una clase de una SourcesViewModel clase en initcreamos una "suscripción" que operará durante todo el "ciclo de vida" de la instancia de clase  SourcesViewModely nos aseguraremos de que la lista de fuentes de información dependa  sourcesdel país  countryy la cadena de búsqueda  searchString.

Con la ayuda  Combine, extraemos la cadena de los "editores" $searchString y $countryde la salida "editor" AnyPublisher<[Source], Never>, cuyo valor es una lista de fuentes de información. Nos "suscribimos" a él utilizando el operador assign (to: \.sources, on: self), obtenemos la lista de fuentes de información que necesitamos  sources. y recuerde la AnyCancellable"suscripción" recibida  en una variable  cancellableSetusando el operador .store ( in: &self.cancellableSet).

Ahora que tenemos View Modelpara nuestras fuentes de información, comencemos a crear UI. B SwiftUIpara sincronizar ViewcObservableObject El modelo usa una @ObservedObjectvariable que se refiere a una instancia de la clase de este modelo.

Agregue la ContentViewSources instancia de clase a la estructura  SourcesViewModelen forma de variable var sourcesViewModel, elimine  Text ("Hello, World!") y coloque la suya propia Viewpara cada una de las 3  @Publishedpropiedades  sourcesViewModel :

 
  • cuadro de texto  SearchViewpara la barra de búsqueda  searchString,
  •  Picker para el país country,
  • lista de  SourcesList fuentes de información



Como resultado, obtenemos lo que necesitamos View:



en esta pantalla, solo administramos la cadena de búsqueda usando el cuadro de texto SearchViewy el "país" con  Picker, y el resto sucede AUTOMÁTICAMENTE.

La lista de fuentes de información SourcesListcontiene información mínima sobre cada fuente: el nombre source.name y una breve descripción source.description:



... pero le permite obtener información más detallada sobre la fuente seleccionada utilizando el enlace NavigationLinken el que destinationindicamos  DetailSourceViewqué datos de origen es la fuente de información  sourcey la instancia deseada de la clase ArticlesViewModel, lo que permite obtener una lista de sus artículos articles:



Vea cuán elegantemente obtenemos la lista de artículos para la fuente seleccionada de fuente de información en la lista de fuentes  SourcesList. Nuestro viejo amigo nos ayuda: una clase  ArticlesViewModelpara la que debemos establecer ambas @Publishedpropiedades de "entrada"  :

  • índice  indexEndpoint = 3, es decir, una opción  .articlesFromSource (_source:String)correspondiente a la selección de artículos para una fuente fija source,
  • cadena  searchString como la fuente misma (o más bien su identificador) source.id :



En general, si mira toda la aplicación NewsApp , no verá en ningún lado que solicitemos explícitamente una selección de artículos o fuentes de información del sitio web  NewsAPI.org . Solo gestionamos  @Published datos, pero View Model hacemos nuestro trabajo: selecciona los artículos y las fuentes de información que necesitamos.

Descargar imagen UIImagepara artículo Article.


El modelo del artículo  Article contiene una URLimagen urlToImageque lo acompaña  : en



base a esto,  URLen el futuro debemos obtener las imágenes UIImage del sitio web  NewsAPI.org .

Ya estamos familiarizados con esta tarea. En la clase ImageLoader, utilizando la función, fetchImage(for url: URL?) -> AnyPublisher<UIImage?, Never>cree un "editor"  AnyPublisher<UIImage?, Never>con el valor de la imagen  UIImage? y sin errores  Never(de hecho, si se producen errores, se devuelve la imagen nil). Puede "suscribirse" a este "editor" para recibir imágenes  UIImage? al diseñar la interfaz de usuario ( UI). La fuente de datos para la función fetchImage(for url: URL?)es  url, que tenemos:



Consideremos en detalle cómo se lleva a cabo la formación con la ayuda del Combine"editor" AnyPublisher <UIImage?, Never>, si sabemos url:

  1. Si urlla igualdad nil, el retorno Just(nil),
  2. en base a la urlforma de la "editor" dataTaskPublisher(for:), cuyo valor de salida Outputes una tupla (data: Data, response: URLResponse)y un error  FailureURLError,
  3. tomamos solo datos map {}de la tupla (data: Data, response: URLResponse)para su posterior procesamiento  datay forma UIImage,
  4. si se produce el error de devolución de los pasos anteriores nil,
  5. entregamos el resultado a la maintransmisión, ya que suponemos un mayor uso en el diseño UI,
  6. "Borre" el TIPO del "editor" y devuelva la copia AnyPublisher.

Verá que el código es bastante compacto y bien legible, no hay ninguno callbacks.

Comencemos a crear  View Model para la imagen UIImage?. Esta es una clase ImageLoaderque implementa el protocolo ObservableObject, con dos  @Publishedpropiedades:

  • @Published url: URL? son URLimágenes
  • @Published var image: UIImage? es la imagen misma de NewsAPI.org :



Y nuevamente, al inicializar una instancia de la clase,  ImageLoader debemos estirar la cadena desde el "editor" de entrada hasta el "editor"  $url de salida AnyPublisher<UIImage?, Never>, al cual nos  "suscribiremos" más tarde y obtendremos la imagen que necesitamos image:



utilizamos el operador  flatMapy un "suscriptor" muy simple  assign (to: \image, on: self)para asignarlo al recibido del "editor" "Valores de la propiedad @Published image:



Y nuevamente en la variable  " suscripción "se cancellableSet almacena AnyCancellableutilizando el operador  store(in: &self.cancellableSet).

La lógica de este "descargador de imágenes" es que descargue una imagen de algo diferente a la que nil URLsiempre que no se haya precargado, es decirimage == nil. Si durante el proceso de descarga se detecta algún error, la imagen estará ausente, es decir, imagepermanecerá igual nil.

En SwiftUI, mostramos la imagen con la ayuda ArticleImageque una instancia de la imageLoader clase utiliza para esto ImageLoader. Si la imagen de su imagen no es igual nil, entonces se muestra usando Image (...), pero si es igual nil, dependiendo de a qué es igual url , o no se muestra nada EmptyView(), o se muestra un rectángulo Rectanglecon texto rotativo T ext("Loading..."):



esta lógica funciona bien para el caso cuando sabe con certeza que para  url, además de  nilobtener una imagen image, como es el caso con la base de datos de películas TMDb . Con NewsAPI.org, el agregador de noticias    es diferente. Los artículos de algunas fuentes de información dan una diferente de la  nil URLimagen, pero el acceso a ella está cerrado y obtenemos un rectángulo Rectanglecon texto giratorio Text("Loading...")que nunca será reemplazado:



en esta situación, si la  URLimagen es diferente de  nil, entonces la igualdad de la  nilimagen  imagepuede significar que la imagen se está cargando , y el hecho de que ocurrió un error durante la carga y nunca obtendremos una imagen image. Para distinguir entre estas dos situaciones, agregamos una más ImageLoader a las dos @Publishedpropiedades existentes en la clase 

 @Published var noData = false - este es un valor booleano con el que indicaremos la ausencia de datos de imagen debido a un error durante la selección:



al crear una "suscripción", initdetectamos todos los errores Errorque ocurren al cargar la imagen y acumulamos su presencia en la  @Publishedpropiedad self.noData = true. Si la descarga fue exitosa, obtenemos la imagen image. Creamos el

"Editor"  AnyPublisher<UIImage?, Error> sobre la base de la  url función fetchImageErr (for url: URL?):



Comenzamos a crear un método fetchImageErrinicializando el "editor"  Future, que puede usarse para obtener de forma asincrónica un único valor de TIPO Resultutilizando un cierre. El cierre tiene un parámetro, Promiseque es una función de TYPE  (Result<Output, Failure>) → Void: convertiremos el



resultado FutureenAnyPublisher <UIImage?, Error>con la ayuda del operador "Erase TYPE" eraseToAnyPublisher().

A continuación, se llevará a cabo los pasos siguientes, teniendo en cuenta todos los errores posibles (no vamos a identificar los errores, es simplemente importante para nosotros saber que hay un error):

0. cheque urlpara nil y  noDatasobre true: si es así, a continuación, devolver el error, si no, la transferencia urlaún más por cadena,
1. cree un "publicador" dataTaskPublisher(for:)cuya entrada sea - url, y el valor de salida Outputsea ​​una tupla (data: Data, response: URLResponse)y un error  URLError,
2. analice usando la tryMap { } tupla resultante (data: Data, response: URLResponse): si response.statusCodeestá en el rango 200...299, entonces para el procesamiento posterior tomamos solo los datos  data. De lo contrario, "arrojamos" un error (no importa qué),
3. map { }transformamos los datos dataen UIImage,
4. entregamos el resultado a la mainsecuencia, ya que suponemos que lo usaremos más adelante en el proceso de diseño UI
- nos "suscribimos" al "editor" recibido usando sinksus cierres receiveCompletiony receiveValue,
- 5. si receiveCompletion encontramos un error en el cierre  error, informamos usándolo promise (.failure(error))),
- 6. en el cierre,  receiveValue informamos sobre la recepción exitosa de una serie de artículos usando promise (.success($0)),
7. recordamos la "suscripción" recibida en la variable  cancellableSetpara asegurar su viabilidad dentro de la "vida útil" de la instancia de clase ImageLoader,
8. "borramos" el TIPO "editor" y devolver la instancia AnyPublisher.

Regresamos a ArticleImagedonde usaremos la nueva  @Publishedvariable noData. Si no hay datos de imagen, entonces no mostraremos nada, es decir EmptyView ():



Finalmente, agruparemos todas nuestras posibilidades de mostrar datos del agregador de noticias NewsAPI.org en TabView:



Mostrar errores al recuperar y decodificar datos JSON del servidor  NewsAPI.org .


Al acceder al servidor NewsAPI.orgpueden producirse errores , por ejemplo, debido a que especificó la clave incorrecta API-keyo, al tener una tarifa de desarrollador que no cuesta nada, excedió el número permitido de solicitudes u otra cosa. Al mismo tiempo, el servidor  NewsAPI.org le  proporciona el HTTPcódigo y el mensaje correspondiente:



es necesario manejar este tipo de error del servidor. De lo contrario, el usuario de su aplicación caerá en una situación en la que de repente, sin ninguna razón, el servidor  NewsAPI.org  dejará de procesar cualquier solicitud, dejando al usuario completamente perdido con una pantalla en blanco.

Hasta ahora, al seleccionar artículos  [Article]y fuentes de información  [Source]del servidor  NewsAPI.org ignoramos todos los errores y, en caso de que aparecieran, devolvimos matrices vacías [Article]()como resultado  [Source]().

Comenzando con el manejo de errores, creemos  otro método fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never> en la clase basado en el NewsAPImétodo de selección de artículos  existente fetchArticlesErr (from endpoint: Endpoint) -> AnyPublisher<[Article], NewsError>que devolverá no solo una serie de artículos [Article], sino también un posible error  NewsError:

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

. . . . . . . .
}

Este método, así como el método fetchArticles, acepta endpoint y devuelve el "editor" en la entrada con un valor en forma de una matriz de artículos [Article], pero en lugar de la ausencia de un error Never, podemos tener un error definido por la enumeración NewsError:



comencemos a crear un nuevo método inicializando el "editor"  Future, que puede ser use para obtener asincrónicamente un solo valor de TYPE Resultusando un cierre. El cierre tiene un parámetro, Promiseque es una función de TYPE  (Result<Output, Failure>) -> Void: convertiremos lo



recibido Futureen el "editor" que necesitamos  AnyPublisher <[Article], NewsError>usando el operador "TYPE Erase" eraseToAnyPublisher().

Además en el nuevo método, fetchArticlesErrrepetiremos todos los pasos que tomamos en el método fetchArticles, pero tendremos en cuenta todos los posibles errores:



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

Cabe señalar que el "editor"  dataTaskPublisher(for:) difiere de su prototipo dataTasken que en caso de un error del servidor cuando response.statusCode NO está en el rango 200...299, aún entrega el valor exitoso en forma de tupla (data: Data, response: URLResponse), y no un error en el formulario (Error, URLResponse?). En este caso, la información de error real del servidor está contenida en data. El "editor" dataTaskPublisher(for:) entrega un error  URLErrorsi ocurre un error en el lado del cliente (incapacidad para contactar al servidor, prohibición del sistema de seguridad ATS, etc.).

Si queremos mostrar errores SwiftUI, entonces necesitamos el correspondiente View Model, al que llamaremos  ArticlesViewModelErr:



En la clase  ArticlesViewModelErrque implementa el protocolo ObservableObject , esta vez tenemos CUATRO  @Publishedpropiedades:

  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 .

Al inicializar una instancia de una clase ArticlesViewModelErr, de nuevo debemos extender una cadena de entrada "editores" $indexEndpointy $searchStringla salida "editor"  AnyPublisher<[Article],NewsError>, a la que "firmado" con el "Suscriptor" sinky obtener una gran cantidad de artículos articleso de error  articlesError.

En nuestra clase, NewsAPIya hemos construido una función  fetchArticlesErr (from endpoint: Endpoint)que devuelve un "publicador"  AnyPublisher<[Article], NewsError>, dependiendo del valor endpoint, y solo necesitamos de alguna manera usar los valores de los "publicadores"  $indexEndpoint$searchStringconvertirlos en un argumento para esta función endpoint

Para empezar, combinaremos los "editores"  $indexEndpoint y  $searchString. Para hacer esto, Combinehay un operador Publishers.CombineLatest:



Luego debemos establecer el tipo de error TIPO "editor" igual al requerido  NewsError:



A continuación, queremos usar la función  fetchArticlesErr (from endpoint: Endpoint) de nuestra clase NewsAPI. Como de costumbre, haremos esto con la ayuda de un operador  flatMapque crea un nuevo "editor" sobre la base de los datos recibidos del "editor" anterior:



luego "suscribimos" a este "editor" recién recibido con la ayuda de un "suscriptor" sinky usamos sus cierres receiveCompletiony receiveValuepara recibir del "editor" el valor de una serie de artículos  articleso errores articlesError:



Naturalmente, es necesario recordar la "suscripción" resultante en alguna init()variable externa cancellableSet. De lo contrario, no podremos obtener el valor de forma asincrónicaarticleso un error articlesError después de la finalización init():



para reducir el número de llamadas al servidor al escribir una cadena de búsqueda searchString, no debemos usar el "editor" de la barra de búsqueda en sí  $searchString, sino su versión modificada validString:



"Suscribirse" al "editor" ASINCRÓNICO que creamos init( )será persisten durante todo el "ciclo de vida" de la instancia de clase  ArticlesViewModelErr:



procedemos a la corrección de la nuestra UIpara mostrar posibles errores de muestreo de datos en ella. En SwiftUI, en la estructura existente,  ContentVieArticles  usamos otro, recién obtenido  View Model, simplemente agregando las letras "Err" en el nombre. Esta es una instancia de la clase.  ArticlesViewModelErr, que "detecta" el error de seleccionar y / o decodificar los datos del artículo del servidor  NewsAPI.org :



y también agregamos la visualización de un mensaje de emergencia  Alert en caso de error.

Por ejemplo, si la clave API incorrecta es:

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

... entonces recibiremos el siguiente mensaje:



Si el límite de solicitudes se ha agotado, recibiremos el siguiente mensaje:



Volviendo al método de selección de artículos  [Article] con un posible error  NewsError, podemos simplificar su código si usamos el  Generic "editor" AnyPublisher<T,NewsError>,que, según el conjunto,  urlrecibe JSONinformación de forma asincrónica , la coloca directamente en el CodableModelo T e informa un error  NewsError:



Como sabemos, este código es muy fácil de usar para obtener un "editor" específico si la fuente de datos urles un agregador deEndpoint noticias   o país  NewsAPI.orgcountry fuente de información, y la salida requiere varios modelos, por ejemplo, una lista de artículos o fuentes de información:





Conclusión


Aprendimos lo fácil que es cumplir HTTPcon las solicitudes con la ayuda de Combinesu URLSession"editor" dataTaskPublishery Codable. Si no necesita realizar un seguimiento de los errores, obtiene un Genericcódigo de 5 líneas muy simple para el "editor" AnyPublisher<T, Never>, que recibe JSON información de forma asíncrona y la coloca directamente en el Codable Modelo en T función de lo dado  url:



Este código es muy fácil de usar para obtener un editor específico, si los datos de origen url son, por ejemplo Endpoint, y la salida requiere varios modelos, por ejemplo, un conjunto de artículos o una lista de fuentes de información.

Si necesita tener en cuenta los errores, el código para el  Generic"editor" será un poco más complicado, pero aún así será un código muy simple sin devoluciones de llamada:



Usando la tecnología de ejecución de HTTPconsultas Combine, ¿puede crear un "editor" AnyPublisher<UIImage?, Never>que seleccione datos asincrónicamente y reciba una imagen UIImage? basado en URL. Los ImageLoadedescargadores de imágenes r se almacenan en memoria caché para evitar la recuperación de datos asincrónicos repetidos.

Todo tipo de "publicadores" obtenidos se pueden "hacer funcionar" muy fácilmente en las clases de ObservableObject, que usan sus propiedades @Published para controlar su IU diseñada usando SwiftUI. Estas clases generalmente desempeñan el papel del modelo de vista, ya que tienen las llamadas propiedades de "entrada" @Published que corresponden a los elementos activos de la interfaz de usuario (TextField, Stepper, cuadros de texto del selector, botones de radio de alternancia, etc.) y "output" @Published propiedades , que consiste principalmente en elementos de interfaz de usuario pasivos (textos de texto, imágenes de imágenes, formas geométricas de círculo (), rectángulo (), etc.

Esta idea impregna toda la aplicación de agregador de noticias NewsAPI.org presentada en este artículo. Resultó ser bastante universal y se usó cuandodesarrollando una aplicación para la base de datos de películas TMDb y el agregador de noticias  Hacker News , que se discutirá en futuros artículos.

El código de aplicación para este artículo está en Github .

PD

1. Quiero llamar su atención sobre el hecho de que si usa el simulador para la aplicación presentada en este artículo, sepa que NavigationLinkel simulador funciona con un error. Puedes usarNavigationLinken el simulador solo 1 vez. Aquellos. usaste el enlace, volviste, haz clic en el mismo enlace y no pasa nada. Hasta que use otro enlace, el primero no funcionará, pero el segundo será inaccesible. Pero esto solo se observa en el simulador, en un dispositivo real todo funciona bien.

2. Algunas fuentes de información todavía se utilizan httpen su lugar httpspara "imágenes" de sus artículos. Si definitivamente desea ver estas "imágenes", pero no puede controlar la fuente de su apariencia, debe configurar el sistema de seguridad ATS ( App Transport Security)para recibir estas http"imágenes", pero esto, por supuesto, no es una buena idea . Puede usar opciones más seguras .

Referencias


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