Code moderne pour effectuer des requêtes HTTP dans Swift 5 à l'aide de Combine et les utiliser dans SwiftUI. Partie 1



L'interrogation HTTPest l'une des compétences les plus importantes que vous devez acquérir lors du développement d' iOSapplications. Dans les versions antérieures Swift(avant la version 5), que vous ayez généré ces requêtes à partir de zéro ou que vous utilisiez le cadre Alamofire bien connu , vous vous êtes retrouvé avec du code complexe et déroutant du callback type  completionHandler: @escaping(Result<T, APIError>) -> Void.

L'apparition dans Swift 5le nouveau cadre de la programmation réactive fonctionnelle Combineen conjonction avec l'existant URLSession, et Codablevous fournit tous les outils nécessaires à l'écriture indépendante de code très compact pour récupérer des données sur Internet.

Dans cet article, conformément au concept, Combinenous allons créer des «éditeurs»Publisherpour sélectionner des données sur Internet, auxquelles nous pouvons facilement «souscrire» à l'avenir et utiliser lors de la conception UIavec  UIKitet avec l'aide  SwiftUI.

Comme  SwiftUIil semble plus concis et plus efficace, car l'action des "éditeurs"  Publisherne se limite pas à des exemples de données et s'étend plus loin jusqu'au contrôle de l'interface utilisateur ( UI). Le fait est que SwiftUIla séparation des données est  View effectuée à l'aide de ObservableObjectclasses avec des @Publishedpropriétés, dont les modifications  SwiftUIsuivent AUTOMATIQUEMENT et «redessinent» complètement les leurs View.

Dans ces  ObservableObjectclasses, vous pouvez très simplement mettre une certaine logique métier de l'application, si certains d'entre eux@Published propriétés sont le résultat de la transformation synchrones et / ou asynchrones d' autres @Published  propriétés qui peuvent être modifiés directement ces éléments « actifs » de l'interface utilisateur ( UI) en tant que zones de texte TextField, Picker, Stepper, Toggleetc.

Pour clarifier les enjeux, je vais donner des exemples précis. Maintenant, de nombreux services tels que NewsAPI.org  et Hacker News proposent des agrégateurs de nouvelles pour  offrir aux utilisateurs de choisir différents ensembles d'articles en fonction de ce qui les intéresse. Dans le cas de l' agrégateur d' actualités  NewsAPI.org, il peut s'agir des dernières nouvelles, ou des nouvelles dans une catégorie - «sport», «santé», «science», «technologie», «entreprise», ou des nouvelles d'une source d'information spécifique «CNN» , ABC news, Bloomberg, etc. L'utilisateur «exprime» généralement ses désirs de services sous la forme Endpointqui lui est nécessaire URL.

Donc, en utilisant le cadre  Combine, vous pouvezObservableObject classes utilisant un code très compact (dans la plupart des cas pas plus de 10-12 lignes) une fois pour former une dépendance synchrone et / ou asynchrone de la liste d'articles  Endpointsous la forme d'un "abonnement" de @Published propriétés "passives" à des propriétés "actives" @Published . Cet «abonnement» sera valide pendant tout le «cycle de vie» de l'instance de  ObservableObject classe. Et ensuite, SwiftUI vous donnerez à l'utilisateur la possibilité de gérer uniquement les @Published propriétés «actives» dans le formulaire Endpoint, c'est-à-dire CE qu'il veut voir: qu'il s'agisse d'articles avec les dernières nouvelles ou d'articles dans la section «santé». L'apparition des articles eux-mêmes avec les dernières nouvelles ou articles dans la section "santé" sur le vôtre UI sera fournie AUTOMATIQUEMENT par ces  ObservableObject classes et leurs propriétés "passives" @Published. Dans du codeSwiftUI vous n'aurez jamais besoin de demander explicitement une sélection d'articles, car les ObservableObjectclasses qui jouent un rôle sont responsables de leur affichage correct et synchrone à l'écran  View Model.

Je vais vous montrer comment cela fonctionne avec  NewsAPI.org  et Hacker News et la base de données de films TMDb dans une série d'articles. Dans les trois cas, le même schéma d'utilisation fonctionnera approximativement  Combine, car dans les applications de ce type, vous devez toujours créer des LISTES de films ou d'articles, choisissez les «IMAGES» (images) qui les accompagnent, RECHERCHEZ les bases de données des films ou articles nécessaires à l'aide de la barre de recherche.

Lors de l'accès à ces services, des erreurs peuvent se produire, par exemple, du fait que vous avez spécifié la mauvaise clé API-keyou dépassé le nombre autorisé de demandes ou autre chose. Vous devez gérer ce type d'erreur, sinon vous courez le risque de laisser l'utilisateur complètement à perte avec un écran vide. Par conséquent, vous devez être en mesure non seulement de sélectionner des  Combine données sur Internet à l' aide , mais également de signaler les erreurs qui peuvent survenir lors de l'échantillonnage et de contrôler leur apparence à l'écran.

Nous allons commencer à développer notre stratégie en développant une application qui interagit avec l' agrégateur d' actualités  NewsAPI.org . Je dois dire que dans cette application, il SwiftUIsera utilisé dans une mesure minimale sans fioritures et uniquement pour montrer comment Combineavec ses "éditeurs"Publisheret "abonnement" sont Subscriptionaffectés UI.

Il est recommandé de vous inscrire sur le site Web NewsAPI.org et de recevoir la clé APInécessaire pour répondre à toute demande au service NewsAPI.org . Vous devez le placer dans le fichier NewsAPI.swift de la structure APIConstants.

Le code d'application de cet article est sur Github .

API et modèle de données de service NewsAPI.org


Le service  NewsAPI.org vous permet de sélectionner des informations sur les articles d'actualité [Article]et leurs sources  [Source]. Notre modèle de données sera très simple, il se trouve dans le fichier  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?
}

L'article Articlecontiendra l'identifiant id, le titre title, la description  description, l'auteur author, l'URL de «l'image» urlToImage, la date de publication publishedAtet la source de publication source. Au-dessus des articles se [Article]trouve un complément NewsResponsedans lequel nous ne nous intéresserons qu'à la propriété articles, qui est un tableau d'articles. La structure racine NewsResponseet la structure  Articlesont Codable, ce qui nous permettra littéralement deux lignes de code décodant les JSONdonnées dans le modèle. La structure  Article devrait également être la suivante Identifiable, si nous voulons faciliter l'affichage d'un tableau d'articles [Article]sous forme de liste  List dans SwiftUI. Le protocole Identifiable nécessite la présence d'un bienidque nous fournirons avec un identifiant unique artificiel UUID().

La source d'informations  Sourcecontiendra un identifiant id, un nom name, une description  description, un pays country, une catégorie de source de publication categoryet une URL de site url. Au-dessus des sources d'informations,  [Source] il existe un complément  SourcesResponsedans lequel nous ne nous intéresserons qu'à une propriété sources, qui est un tableau de sources d'informations. La structure racine SourcesResponseet la structure  Sourcesont Codable, ce qui nous permettra de décoder très facilement les JSONdonnées dans un modèle. La structure  Source devrait également être Identifiable, si nous voulons faciliter l'affichage d'un tableau de sources d'information  [Source]sous la forme d'une liste  List dansSwiftUI. Le protocole Identifiablenécessite la présence de la propriété idque nous avons déjà, donc aucun effort supplémentaire ne sera requis de notre part.

Considérez maintenant ce dont nous avons besoin  APIpour le service  NewsAPI.org et placez-le dans le fichier  NewsAPI.swift . La partie centrale de la nôtre API est une classe NewsAPIqui présente deux méthodes pour sélectionner les données de l' agrégateur de news   NewsAPI.org - articles  [Article]et sources d'information  [Source]:

  • fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never>  - sélection d'articles en  [Article]fonction du paramètre endpoint,
  • fetchSources (for country: String) -> AnyPublisher<[Source], Never>- Une sélection de sources d'informations [Source]pour un pays particulier country.

Ces méthodes renvoient non seulement un tableau d'articles  [Article] ou un tableau de sources d'informations  [Source], mais les "éditeurs" correspondants du  Publisher nouveau cadre Combine. Les deux éditeurs ne renvoient aucune erreur - Neveret si une erreur d'échantillonnage ou de codage s'est toujours produite, un tableau vide d'articles [Article]() ou de sources d'informations est  renvoyé  [Source]()sans aucun message expliquant pourquoi ces tableaux étaient vides. 

Quels articles ou sources d'informations nous voulons sélectionner sur le serveur NewsAPI.org , nous indiquerons en utilisant l'énumérationenum 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"
        }
    }
}

Il:

  • les dernières nouvelles  .topHeadLines,
  • l'actualité d'une certaine catégorie (sport, santé, science, business, technologie)  .articlesFromCategory(_ category: String),
  • l'actualité d'une source d'information spécifique (CNN, ABC News, Fox News, etc.)  .articlesFromSource(_ source: String),
  • toute nouvelle  .search (searchFilter: String)qui remplit une certaine condition searchFilter,
  • sources d'information .sources (country:String)pour un pays particulier country.

Pour faciliter l'initialisation de l'option dont nous avons besoin, nous ajouterons un Endpointinitialiseur init?à l' énumération pour diverses listes d'articles et de sources d'informations en fonction de l'index index et de la ligne text, ce qui a différentes significations pour différentes options d'énumération:

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

Revenons à la classe NewsAPI et considérons plus en détail la première méthode  fetchArticles (from endpoint: Endpoint)-> AnyPublisher<[Article], Never>, qui sélectionne les articles en [Article]fonction du paramètre endpointet ne renvoie aucune erreur - 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
    }

  • sur la base du endpoint formulaire URLde demande la liste d'articles souhaitée endpoint.absoluteURL, en cas d'échec, puis renvoyer un tableau vide d'articles[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 tâche de sélection des sources d'information est assignée à la deuxième méthode - fetchSources (for country: String) -> AnyPublisher<[Source], Never>qui est une copie sémantique exacte de la première méthode, sauf que cette fois au lieu d'articles, [Article]nous choisirons des sources d'information [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
    }

Il nous renvoie "l'éditeur" AnyPublisher <[Source], Never>avec une valeur sous forme de tableau de sources d'informations [Source] et l'absence d'erreur  Never (en cas d'erreur, un tableau de sources vide est retourné  [ ]).

Nous allons identifier la partie commune de ces deux méthodes, l'organiser comme une Genericfonction fetch(_ url: URL) -> AnyPublisher<T, Error>qui renvoie  Generic«l'éditeur» en AnyPublisher<T, Error>fonction 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
    }

Cela simplifiera les deux méthodes précédentes:

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

Les «éditeurs» ainsi obtenus ne livrent rien tant que quelqu'un ne s'y «abonne» pas. Nous pouvons le faire lors de la conception UI.

"Éditeurs" Publishercomme View Model dans SwiftUI. Liste d'articles.


Maintenant un peu sur la logique de fonctionnement SwiftUI.

La SwiftUI seule abstraction des «changements externes» auxquels ils répondent Viewest les «éditeurs» Publisher. Les «changements externes» peuvent être compris comme une minuterie Timer, une notification avec NotificationCenter ou votre objet modèle, qui en utilisant le protocole ObservableObjectpeut être transformé en une seule «source de vérité» externe (source de vérité). 

Aux "éditeurs" ordinaires, tapez Timerou NotificationCenter Viewréagissez en utilisant la méthode onReceive (_: perform:). Un exemple d'utilisation de "l'éditeur" que Timernous présenterons plus loin dans le troisième article sur la création d'une application pour Hacker News .

Dans cet article, nous allons nous concentrer sur la façon de faire notre modèle pour SwiftUI"source de vérité" externe (source de vérité).

Voyons d'abord comment les SwiftUI«éditeurs» résultants devraient fonctionner dans un exemple spécifique d'affichage de divers types d'articles:

.topHeadLines- les dernières nouvelles,  .articlesFromCategory(_ category: String) - les nouvelles pour une catégorie spécifique,  .articlesFromSource(_ source: String) - les nouvelles pour une source d'information spécifique, .search (searchFilter: String) - les nouvelles sélectionnées par une certaine condition.



Selon l' Endpointutilisateur qui choisit, nous devons mettre à jour la liste des articles articlessélectionnés sur NewsAPI.org . Pour ce faire, nous allons créer une classe très simple  ArticlesViewModelqui implémente un protocole ObservableObject avec trois  @Publishedpropriétés:
 


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

Dès que nous définissons des  @Published propriétés indexEndpoint ou searchString, nous pouvons commencer à les utiliser à la fois comme propriétés simples  indexEndpoint et  searchString, et comme "éditeurs"  $indexEndpointet  $searchString.

Dans une classe  ArticlesViewModel, vous pouvez non seulement déclarer les propriétés qui nous intéressent, mais également prescrire la logique métier de leur interaction. Dans ce but, lors de l'initialisation d'une instance d'une classe  ArticlesViewModel dans, initnous pouvons créer un «abonnement» qui agira tout au long du «cycle de vie» de l'instance de la classe  ArticlesViewModelet reproduira la dépendance de la liste d'articles articlessur l'index  indexEndpoint et la chaîne de recherche searchString.

Pour ce faire, Combinenous étendons la chaîne des "éditeurs" $indexEndpoint et $searchStringen sortie "éditeur"AnyPublisher<[Article], Never>dont la valeur est une liste d'articles  articles. Ensuite, nous «nous y abonnons» en utilisant l'opérateur assign (to: \.articles, on: self)et obtenons la liste des articles dont nous avons besoin en articles tant @Published que propriété de «sortie»  qui définit UI.

Nous devons tirer la chaîne PAS simplement des propriétés  indexEndpointet searchString, à savoir des "éditeurs" $indexEndpointet $searchStringqui participent à la création UIavec l'aide de SwiftUIet nous les changerons là en utilisant les éléments de l'interface utilisateur  Pickeret TextField.

Comment allons-nous procéder?

Nous avons déjà une fonction dans notre arsenal fetchArticles (from: Endpoint)qui est dans la classe  NewsAPIet renvoie un "éditeur" AnyPublisher<[Article], Never>, selon la valeurEndpoint, et nous ne pouvons que d'une manière ou d'une autre utiliser les valeurs des "éditeurs"  $indexEndpointet  $searchStringles transformer en argument de endpointcette fonction. 

Tout d'abord, combinez les "éditeurs"  $indexEndpoint et  $searchString. Pour ce faire, l' Combineopérateur existe Publishers.CombineLatest :



Pour créer un nouvel «éditeur» sur la base des données reçues de l'ancien «éditeur» Combine , l'opérateur est utilisé  flatMap:



Ensuite, nous «souscrivons» à cet «éditeur» nouvellement reçu à l'aide d'un «abonné» très simple  assign (to: \.articles, on: self)et attribuons le reçu de « éditeur "valeur pour le  @Published tableau  articles:



Nous venons de créer un init( )" éditeur "ASYNCHRONE et" souscrit "à lui, à la suite deAnyCancellable«Abonnement» et cela est facile à vérifier si nous gardons notre «abonnement» dans une constante let subscription:



La propriété principale d'un AnyCancellable«abonnement» est que dès qu'il quitte son périmètre, la mémoire qu'il occupe est automatiquement libérée. Par conséquent, dès qu'il sera init( ) terminé, cet «abonnement» sera supprimé ARC, et sans avoir le temps d'attribuer les informations asynchrones reçues avec un délai à la baie articles. L'information asynchrone n'a tout simplement nulle part où «atterrir», dans son sens littéral, «la terre est passée de sous ses pieds».

Pour enregistrer un tel «abonnement», il est nécessaire de créer une init() variable AU-DELÀ de l'initialiseur var cancellableSetqui enregistrera notre  AnyCancellable«abonnement» dans cette variable tout au long du «cycle de vie» de l'instance de classe  ArticlesViewMode

Par conséquent, nous supprimons la constante let subscriptionet nous nous souvenons de notre AnyCancellable«abonnement» dans la variable  cancellableSetà l'aide de l'opérateur .store ( in: &self.cancellableSet):



«Abonnement» à l '«éditeur» ASYNCHRONE que nous avons créé dans init( )sera conservé tout au long du «cycle de vie» de l'instance de classe  ArticlesViewModel.

Nous pouvons changer arbitrairement le sens de «éditeurs»  $indexEndpointet / ou  searchString, et toujours grâce à la «souscription» créée, nous aurons un éventail d'articles correspondant aux valeurs de ces deux éditeurs  articlessans aucun effort supplémentaire. Cette  ObservableObjectclasse est généralement appelée  View Model.

Afin de réduire le nombre d'appels au serveur lors de la saisie d'une chaîne de recherche searchString, nous ne devons pas utiliser «l'éditeur» de la chaîne de recherche elle-même $searchString, et sa version modifiée validString:



Maintenant que nous avons View Modelpour nos articles, commençons à créer l'interface utilisateur ( UI). Pour SwiftUIse synchroniser Viewavec le ObservableObject modèle, une @ObservedObjectvariable est utilisée qui fait référence à une instance de la classe de ce modèle. C'est cette paire - la  ObservableObject classe et la  @ObservedObjectvariable qui fait référence à l'instance de cette classe - qui contrôle le changement dans l'interface utilisateur ( UI) dans  SwiftUI.

Nous ajoutons à la structure une ContentView instance de la classe ArticleViewModel sous la forme d'une variable var articleViewModelet la remplaçons Text ("Hello, World!")par une liste d'articles ArticlesListdans laquelle nous plaçons les articles  articlesViewModel.articlesobtenus de notre View Model. En conséquence, nous obtenons une liste d'articles pour un index fixe et par défaut  indexEndpoint = 0, c'est-à-dire pour les .topHeadLines dernières nouvelles:



Ajoutez un UIélément à notre écran  pour contrôler quel ensemble d'articles nous voulons afficher. Nous utiliserons le  Pickerchangement d'index $articlesViewModel.indexEndpoint. La présence d'un symbole est  $obligatoire, car cela signifie une modification de la valeur fournie par  @Published "l'éditeur". La «souscription» à cet «éditeur» est déclenchée immédiatement, que nous avons initiée init (), la «sortie»  @Published«l'éditeur»  articles changera et nous verrons une liste d'articles différente à l'écran:



De cette façon, nous pouvons recevoir des tableaux d'articles pour les trois options - «topHeadLines», «recherche "Et" de la catégorie ":



... mais pour une chaîne de recherche fixe et par défaut searchString = "sports"(là où elle est requise):



Cependant, pour l'option,  "search" vous devez fournir à l'utilisateur un champ de texte SearchViewpour entrer la chaîne de recherche:



Par conséquent, l'utilisateur peut rechercher n'importe quelle actualité par la chaîne de recherche tapée:



Pour l'option,  "from category" il est nécessaire de fournir l'utilisateur la possibilité de choisir une catégorie et nous commençons par catégorie science:



par conséquent, l'utilisateur peut rechercher des nouvelles de la catégorie choisie de nouvelles - science, healthbusiness, technology:



on peut voir comment un simple  ObservableObject modèle qui a deux contrôlées par l' utilisateur @Published caractéristiques - indexEndpoint etsearchString- vous permet de sélectionner un large éventail d'informations sur le site Web  NewsAPI.org .

Liste des sources d'information


Voyons comment fonctionnera SwiftUI «l'éditeur» de sources d'informations reçues dans la classe NewsAPI fetchSources (for country: String) -> AnyPublisher<[Source], Never>.

Nous obtiendrons une liste de sources d'informations pour différents pays:



... et la possibilité de les rechercher par nom:



... ainsi que des informations détaillées sur la source sélectionnée: son nom, catégorie, pays, brève description et lien vers le site: 



Si vous cliquez sur le lien, nous irons sur le site Web de ce source d'information.

Pour que tout cela fonctionne, vous avez besoin d'un ObservableObjectmodèle extrêmement simple  qui n'a que deux @Publishedpropriétés contrôlées par l'utilisateur - searchString et  country:



Et encore une fois, nous utilisons le même schéma: lors de l'initialisation d'une instance d'une classe de SourcesViewModel classe dans initnous créons un «abonnement» qui fonctionnera tout au long du «cycle de vie» de l'instance de classe  SourcesViewModelet nous assurerons que la liste des sources d'informations dépend  sourcesdu pays  countryet de la chaîne de recherche  searchString.

Avec l'aide,  Combinenous tirons la chaîne des "éditeurs" $searchString et $countryde la sortie "éditeur" AnyPublisher<[Source], Never>, dont la valeur est une liste de sources d'information. Nous «nous y abonnons» en utilisant l'opérateur assign (to: \.sources, on: self), nous obtenons la liste des sources d'information dont nous avons besoin  sources. et rappelez-vous AnyCancellable"l'abonnement" reçu  dans une variable en  cancellableSetutilisant l'opérateur .store ( in: &self.cancellableSet).

Maintenant que nous avons View Modelpour nos sources d'informations, commençons à créer UI. B SwiftUIpour synchroniser ViewcObservableObject Le modèle utilise une @ObservedObjectvariable qui fait référence à une instance de la classe de ce modèle.

Ajoutez l' ContentViewSources instance de classe à la structure  SourcesViewModelsous la forme d'une variable var sourcesViewModel, supprimez  Text ("Hello, World!") et placez la vôtre Viewpour chacune des 3  @Publishedpropriétés  sourcesViewModel :

 
  • zone de texte  SearchViewpour la barre de recherche  searchString,
  •  Picker pour le pays country,
  • liste des  SourcesList sources d'information



En conséquence, nous obtenons ce dont nous avons besoin View:



Sur cet écran, nous gérons uniquement la chaîne de recherche en utilisant la zone de texte SearchViewet le «pays» avec  Picker, et le reste se fait AUTOMATIQUEMENT.

La liste des sources d'informations SourcesListcontient un minimum d'informations sur chaque source - le nom source.name et une brève description source.description:



... mais elle vous permet d'obtenir des informations plus détaillées sur la source sélectionnée en utilisant le lien NavigationLinkdans lequel destinationnous indiquons  DetailSourceViewquelles données source sont la source d'informations  sourceet l'instance de classe requise ArticlesViewModel, ce qui permet obtenir une liste de ses articles articles:



Voyez avec quelle élégance nous obtenons la liste des articles pour la source d'information sélectionnée dans la liste des sources  SourcesList. Notre vieil ami nous aide - une classe  ArticlesViewModelpour laquelle nous devons définir les deux @Publishedpropriétés «d'entrée»  :

  • index  indexEndpoint = 3, c'est-à-dire une option  .articlesFromSource (_source:String)correspondant à la sélection d'articles pour une source fixe source,
  • chaîne  searchString comme source elle-même (ou plutôt son identifiant) source.id :



En général, si vous regardez l'intégralité de l'application NewsApp , vous ne verrez nulle part que nous demandons explicitement une sélection d'articles ou de sources d'informations sur le site Web  NewsAPI.org . Nous ne gérons que des  @Published données, mais View Model faisons notre travail: sélectionne les articles et les sources d'information dont nous avons besoin.

Téléchargez l'image UIImagepour l'article Article.


Le modèle de l'article  Article contient une URLimage urlToImagequi l' accompagne  :



Sur cette base,  URLnous devrons à l'avenir obtenir les images elles-mêmes UIImage sur le site Web  NewsAPI.org .

Nous connaissons déjà cette tâche. Dans la classe ImageLoader, en utilisant la fonction, fetchImage(for url: URL?) -> AnyPublisher<UIImage?, Never>créez un «éditeur»  AnyPublisher<UIImage?, Never>avec la valeur de l'image  UIImage? et aucune erreur  Never(en fait, si des erreurs se produisent, l'image est retournée nil). Vous pouvez vous «abonner» à cet «éditeur» pour recevoir des images  UIImage? lors de la conception de l'interface utilisateur ( UI). Les données source pour la fonction fetchImage(for url: URL?)est  url, que nous avons:



Examinons en détail comment se déroule la formation avec l'aide de Combine"l'éditeur" AnyPublisher <UIImage?, Never>, si nous savons url:

  1. si urlégal nil, retournez Just(nil),
  2. basé sur la urlforme de "l'éditeur" dataTaskPublisher(for:), dont la valeur de sortie Outputest un tuple (data: Data, response: URLResponse)et une erreur  FailureURLError,
  3. nous ne prenons que les données map {}du tuple (data: Data, response: URLResponse)pour un traitement ultérieur  dataet la forme UIImage,
  4. si l'erreur de retour des étapes précédentes se produit nil,
  5. nous fournissons le résultat au mainflux, car nous supposons une utilisation ultérieure dans la conception UI,
  6. «Effacez» le TYPE de «l'éditeur» et renvoyez la copie AnyPublisher.

Vous voyez que le code est assez compact et bien lisible, il n'y en a pas callbacks.

Commençons à créer  View Model pour l'image UIImage?. Il s'agit d'une classe ImageLoaderqui implémente le protocole ObservableObject, avec deux  @Publishedpropriétés:

  • @Published url: URL? sont des URLimages
  • @Published var image: UIImage? est l'image elle-même de NewsAPI.org :



Et encore une fois, lors de l'initialisation d'une instance de la classe,  ImageLoader nous devons étirer la chaîne de l'entrée "éditeur"  $url à la sortie "éditeur" AnyPublisher<UIImage?, Never>, à laquelle nous nous  "abonnerons" plus tard et obtiendrons l'image dont nous avons besoin image:



nous utilisons l'opérateur  flatMapet un "abonné" très simple  assign (to: \image, on: self)pour l'assigner aux reçus de "l'éditeur" "Valeurs de la propriété @Published image:



Et encore dans la variable  " souscription "est cancellableSet stockée en AnyCancellableutilisant l'opérateur  store(in: &self.cancellableSet).

La logique de ce «téléchargeur d'images» est que vous téléchargez une image à partir de quelque chose d'autre que celle à nil URLcondition qu'elle ne précharge pas, c'est-à-direimage == nil. Si pendant le processus de téléchargement une erreur est détectée, l'image sera absente, c'est-à-dire qu'elle imagerestera égale nil.

Dans SwiftUInous montrons l'image avec l'aide ArticleImagequ'une instance de la imageLoader classe utilise pour cela ImageLoader. Si l'image de son image n'est pas égale nil, alors elle est affichée en utilisant Image (...), mais si elle est égale nil, alors en fonction de ce qu'elle est égale url , soit rien n'est affiché EmptyView(), soit un rectangle Rectangleavec un texte rotatif T s'affiche ext("Loading..."):



Cette logique fonctionne très bien pour le cas quand vous savez avec certitude que pour  url, autre que  nilvous obtenez une image image, comme c'est le cas avec la base de données de films TMDb . Avec NewsAPI.org, l' agrégateur de nouvelles    est différent. Les articles de certaines sources d'information en donnent une différente de l'  nil URLimage, mais l'accès est fermé, et nous obtenons un rectangle Rectangleavec du texte en rotation Text("Loading...")qui ne sera jamais remplacé:



Dans cette situation, si l'  URLimage est différente de  nil, alors l'égalité de l'  nilimage  imagepeut signifier que l'image se charge , et le fait qu'une erreur s'est produite lors du chargement et que nous n'obtiendrons jamais d'image image. Afin de distinguer ces deux situations, nous en ajoutons une de plus ImageLoader aux deux @Publishedpropriétés existantes de la classe 

 @Published var noData = false - il s'agit d'une valeur booléenne avec laquelle nous indiquerons l'absence de données d'image due à une erreur lors de la sélection:



Lors de la création d'un "abonnement", nous initinterceptons toutes les erreurs Errorqui se produisent lors du chargement de l'image et accumulons leur présence dans la  @Publishedpropriété self.noData = true. Si le téléchargement a réussi, nous obtenons l'image image. Nous créons le

«Publisher»  AnyPublisher<UIImage?, Error> sur la base de la  url fonction fetchImageErr (for url: URL?):



Nous commençons à créer une méthode fetchImageErren initialisant le «publisher»  Future, qui peut être utilisé pour obtenir de manière asynchrone une seule valeur TYPE à l' Resultaide d'une fermeture. La fermeture a un paramètre - Promisequi est une fonction de TYPE  (Result<Output, Failure>) → Void: Nous transformerons le



résultat FutureenAnyPublisher <UIImage?, Error>avec l'aide de l'opérateur "Erase TYPE" eraseToAnyPublisher().

Ensuite, nous allons effectuer les étapes suivantes, en prenant en compte toutes les erreurs possibles (nous ne nommerai pas les erreurs, il est tout simplement important pour nous de savoir qu'il ya une erreur):

0. vérification urlpour nil et  noDatasur true: le cas échéant, puis renvoyer l'erreur, sinon, le transfert urlplus par chaîne,
1. créez un "éditeur" dataTaskPublisher(for:)dont l'entrée est - url, et la valeur de sortie Outputest un tuple (data: Data, response: URLResponse)et une erreur  URLError,
2. analysez en utilisant le tryMap { } tuple résultant (data: Data, response: URLResponse): s'il response.statusCodeest dans la plage 200...299, alors pour un traitement ultérieur, nous prenons uniquement les données  data. Sinon, nous "jetons" une erreur (quoi qu'il arrive),
3. nous map { }convertirons les données dataen UIImage,
4. livrons le résultat au mainflux, car nous supposons que nous les utiliserons plus tard dans la conception UI
- nous nous «abonnons» à «l'éditeur» reçu en utilisant sinkses fermetures receiveCompletionet receiveValue,
- 5. si nous receiveCompletion trouvons une erreur dans la fermeture  error, nous signalons en l'utilisant promise (.failure(error))),
- 6. dans la fermeture,  receiveValue nous vous informons de la réception réussie d'un tableau d'articles en utilisant promise (.success($0)),
7. nous nous souvenons de l '«abonnement» reçu dans la variable  cancellableSetpour assurer sa viabilité pendant la «durée de vie» de l'instance de classe ImageLoader,
8. nous «effaçons» le «éditeur» TYPE et renvoyer l'instance AnyPublisher.

Nous revenons à l' ArticleImageendroit où nous allons utiliser la nouvelle  @Publishedvariable noData. S'il n'y a pas de données d'image, nous n'afficherons rien, c'est-à-dire EmptyView ():



Enfin, nous regrouperons toutes nos possibilités d'affichage des données de l' agrégateur d' actualités NewsAPI.org dans TabView:



Afficher les erreurs lors de la récupération et du décodage des données JSON à partir du serveur  NewsAPI.org .


Lors de l'accès au serveur NewsAPI.org , des  erreurs peuvent se produire, par exemple, du fait que vous avez spécifié la mauvaise clé API-keyou, ayant un tarif développeur qui ne coûte rien, a dépassé le nombre autorisé de demandes ou autre chose. Dans le même temps, le serveur  NewsAPI.org  vous fournit le HTTPcode et le message correspondant:



Il est nécessaire de gérer ce type d'erreur de serveur. Sinon, l'utilisateur de votre application sera dans une situation où soudainement, sans raison, le serveur  NewsAPI.org  cessera de traiter toutes les demandes, laissant l'utilisateur complètement perdu avec un écran vide.

Jusqu'à présent, lors de la sélection d'articles  [Article]et de sources d'informations  [Source]sur le serveur  NewsAPI.org nous avons ignoré toutes les erreurs et, en cas d'apparition, nous avons renvoyé des tableaux vides [Article]()et  par conséquent  [Source]().

Pour commencer avec la gestion des erreurs, fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never> créons NewsAPI une autre méthode dans la classe basée sur la méthode de sélection d'articles  existante fetchArticlesErr (from endpoint: Endpoint) -> AnyPublisher<[Article], NewsError>qui renverra non seulement un tableau d'articles [Article], mais aussi une erreur possible  NewsError:

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

. . . . . . . .
}

Cette méthode, ainsi que la méthode fetchArticles, accepte endpoint et renvoie le «publisher» à l'entrée avec une valeur sous la forme d'un tableau d'articles [Article], mais au lieu de l'absence d'erreur Never, nous pouvons avoir une erreur définie par l'énumération NewsError:



Commençons par créer une nouvelle méthode en initialisant le «publisher»  Future, qui peut être utiliser pour obtenir de manière asynchrone une seule valeur TYPE à l' Resultaide d'une fermeture. La fermeture a un paramètre - Promisequi est une fonction de TYPE  (Result<Output, Failure>) -> Void: Nous transformerons le



reçu Futureen "éditeur" dont nous avons besoin en  AnyPublisher <[Article], NewsError>utilisant l'opérateur "TYPE Erase" eraseToAnyPublisher().

Plus loin dans la nouvelle méthode, fetchArticlesErrnous allons répéter toutes les étapes que nous avons suivies dans la méthode fetchArticles, mais nous prendrons en compte toutes les erreurs possibles:



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

Il convient de noter que l '"éditeur"  dataTaskPublisher(for:) diffère de son prototype dataTasken ce qu'en cas d'erreur de serveur lorsqu'il n'est response.statusCode pas dans la plage 200...299, il fournit toujours la valeur réussie sous la forme d'un tuple (data: Data, response: URLResponse), et non d'une erreur dans le formulaire (Error, URLResponse?). Dans ce cas, les informations réelles sur l'erreur du serveur sont contenues dans data. "Publisher" dataTaskPublisher(for:) délivre une erreur  URLErrorsi une erreur se produit côté client (impossibilité de contacter le serveur, interdiction du système de sécurité ATS, etc.).

Si nous voulons afficher les erreurs dans SwiftUI, alors nous avons besoin de celle correspondante View Model, que nous appellerons  ArticlesViewModelErr:



Dans la classe  ArticlesViewModelErrqui implémente le protocole ObservableObject , cette fois nous avons QUATRE  @Publishedpropriétés:

  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 .

Lorsque vous initialisez une instance d'une classe ArticlesViewModelErr, nous devons à nouveau étendre une chaîne à partir des "éditeurs" d'entrée $indexEndpointet $searchStringde la sortie de "l'éditeur"  AnyPublisher<[Article],NewsError>, à laquelle nous avons "signé" avec "l'abonné" sinket nous obtenons beaucoup d'articles articlesou d'erreur  articlesError.

Dans notre classe, NewsAPInous avons déjà construit une fonction  fetchArticlesErr (from endpoint: Endpoint)qui renvoie un «éditeur»  AnyPublisher<[Article], NewsError>, en fonction de la valeur endpoint, et nous avons seulement besoin d'utiliser en quelque sorte les valeurs des «éditeurs»  $indexEndpointet  $searchStringde les transformer en argument pour cette fonction endpoint

Pour commencer, nous allons combiner les "éditeurs"  $indexEndpoint et  $searchString. Pour ce faire, Combineil existe un opérateur Publishers.CombineLatest:



Ensuite, nous devons définir le type d'erreur TYPE "éditeur" égal au requis  NewsError:



Ensuite, nous voulons utiliser la fonction  fetchArticlesErr (from endpoint: Endpoint) de notre classe NewsAPI. Comme d'habitude, nous le ferons avec l'aide d'un opérateur  flatMapqui crée un nouvel «éditeur» sur la base des données reçues de l'ancien «éditeur»:



Ensuite, nous «souscrivons» à cet «éditeur» nouvellement reçu avec l'aide d'un «abonné» sinket utilisons ses fermetures receiveCompletionet receiveValueafin de recevoir de "l'éditeur" soit la valeur d'un tableau d'articles  articlessoit une erreur articlesError:



Naturellement, il faut se souvenir de "l'abonnement" résultant dans une init()variable externe cancellableSet. Sinon, nous ne pourrons pas obtenir la valeur de manière asynchronearticlesou une erreur articlesError après la fin init():



Afin de réduire le nombre d'appels au serveur lors de la saisie d'une chaîne de recherche searchString, nous ne devons pas utiliser l '"éditeur" de la barre de recherche elle $searchString- même  , mais sa version modifiée validString:



"S'abonner" à l' "éditeur" ASYNCHRONE que nous avons créé init( )sera persister tout au long du «cycle de vie» de l'instance de classe  ArticlesViewModelErr:



nous procédons à la correction de la nôtre UIafin d'y afficher d'éventuelles erreurs d'échantillonnage de données. Dans SwiftUI, dans la structure existante, nous  ContentVieArticles  utilisons un autre, juste obtenu  View Model, en ajoutant simplement les lettres «Err» dans le nom. Ceci est une instance de la classe.  ArticlesViewModelErr, qui «capture» l'erreur de sélection et / ou de décodage des données d'article du serveur  NewsAPI.org :



Et nous ajoutons également l'affichage d'un message d'urgence  Alert en cas d'erreur.

Par exemple, si la mauvaise clé API est:

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

... alors nous recevrons le message suivant:



Si la limite de demandes a été épuisée, nous recevrons le message suivant:



Revenant à la méthode de sélection des articles  [Article] avec une erreur possible  NewsError, nous pouvons simplifier son code si nous utilisons  Generic "l'éditeur" AnyPublisher<T,NewsError>,qui, basé sur l'ensemble,  urlreçoit des JSONinformations de manière asynchrone , le place directement dans le Codablemodèle T et signale une erreur  NewsError:



comme nous le savons, ce code est très facile à utiliser pour obtenir un «éditeur» spécifique si les données source urlsont un agrégateur deEndpoint nouvelles  NewsAPI.org  ou un pays country source d'information, et la sortie nécessite différents modèles - par exemple, une liste d'articles ou de sources d'information:





Conclusion


Nous avons appris à quel point il est facile de répondre aux HTTPdemandes avec l'aide de Combineson URLSession«éditeur» dataTaskPublisheret Codable. Si vous n'avez pas besoin de suivre les erreurs, vous obtenez un Genericcode à 5 lignes très simple pour "l'éditeur" AnyPublisher<T, Never>, qui reçoit de manière asynchrone les JSON informations et les place directement dans le Codable modèle en T fonction des données données  url:



ce code est très facile à utiliser pour obtenir un éditeur spécifique, si les données source url sont, par exemple Endpoint, et la sortie nécessite différents modèles - par exemple, un ensemble d'articles ou une liste de sources d'informations.

Si vous devez prendre en compte les erreurs, le code de  Generic"l'éditeur" sera un peu plus compliqué, mais ce sera quand même un code très simple sans aucun rappel:



En utilisant la technologie d'exécution des HTTPrequêtes à l'aide Combine, pouvez-vous créer un «éditeur» AnyPublisher<UIImage?, Never>qui sélectionne de manière asynchrone les données et reçoit une image UIImage? basé sur URL. Les ImageLoadetéléchargeurs d' images r sont mis en mémoire cache pour éviter la récupération répétée de données asynchrones.

Toutes sortes de «éditeurs» obtenus peuvent très facilement être «mis au travail» dans les classes ObservableObject, qui utilisent leurs propriétés @Published pour contrôler votre interface utilisateur conçue à l'aide de SwiftUI. Ces classes jouent généralement le rôle du modèle d'affichage, car elles ont les propriétés @Published dites «d'entrée» qui correspondent aux éléments d'interface utilisateur actifs (TextField, Stepper, zones de texte Picker, boutons radio Toggle, etc.) et les propriétés @Published «de sortie». , constitué principalement d'éléments d'interface utilisateur passifs (textes, images, formes géométriques Circle (), Rectangle (), etc.).

Cette idée imprègne l'ensemble de l' application d' agrégateur d'actualités NewsAPI.org présentée dans cet article. assez universel et a été utilisé lorsquedévelopper une application pour la base de données de films TMDb et l'agrégateur de nouvelles  Hacker News , qui seront discutées dans de futurs articles.

Le code d'application de cet article est sur Github .

PS

1. Je veux attirer votre attention sur le fait que si vous utilisez le simulateur pour l'application présentée dans cet article, sachez que NavigationLinkle simulateur fonctionne avec une erreur. Vous pouvez utiliserNavigationLinksur le simulateur une seule fois. Ceux. vous avez utilisé le lien, êtes revenu en arrière, cliquez sur le même lien - et rien ne se passe. Tant que vous n'utiliserez pas un autre lien, le premier ne fonctionnera pas, mais le second deviendra inaccessible. Mais cela n'est observé que sur le simulateur, sur un vrai appareil, tout fonctionne bien.

2. Certaines sources d'information utilisent encore à la httpplace httpsdes "images" de leurs articles. Si vous voulez vraiment voir ces «images», mais ne pouvez pas contrôler la source de leur apparence, vous devez configurer le système de sécurité ATS ( App Transport Security)pour recevoir ces http«images», mais ce n'est bien sûr pas une bonne idée . Vous pouvez utiliser des options plus sécurisées .

Références:


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