用于在Swift 5中使用Combine并在SwiftUI中使用HTTP请求的现代代码。第1部分



查询HTTP是开发iOS应用程序时需要获得的最重要的技能之一。在早期版本Swift(版本5之前)中,无论您是从头开始生成这些请求还是使用知名的Alamofire框架,最终都会产生来自该callback 类型的  复杂代码completionHandler: @escaping(Result<T, APIError>) -> Void功能反应式编程新框架中的

外观与现有结合在一起为您提供了独立编写非常紧凑的代码以从Internet提取数据所需的所有工具。 在本文中,按照概念,我们将创建“发布者”Swift 5CombineURLSessionCodable

CombinePublisher可以从Internet中选择数据,将来我们可以轻松地“订阅” UI这些数据,  UIKit并在有帮助的情况下进行  设计时使用SwiftUI

由于  SwiftUI它看起来更简洁,更有效,因为“出版商”的作用  Publisher并不限于样本数据,并进一步延伸到用户界面控件(UI)。事实是,使用具有属性的SwiftUIView 执行数据分离  ObservableObject@Published该类的更改会  SwiftUI自动监控并完全重绘View

在这些  ObservableObject类中,您可以非常简单地放置应用程序的某些业务逻辑,如果其中一些@Published 属性是同步和/或异步变换其他的结果@Published  可以直接改变所述用户接口的这样的“活性”的元素(属性UI),为文本框TextFieldPickerStepperToggle等。

为了清楚说明问题所在,我将举一些具体例子。现在,NewsAPI.org  和Hacker News之类的许多服务都提供新闻聚合器,以  使用户可以根据他们的兴趣选择不同的文章集。对于NewsAPI.org新闻  聚合器,它可以是最新新闻也可以是某些类别的新闻-“体育”,“健康”,“科学”,“技术”,“商业”或特定信息源“ CNN”的新闻,ABC新闻,彭博社等用户通常以Endpoint形成对他必要的形式的形式来“表达”他对服务的期望URL

因此,使用该框架  Combine,您可以ObservableObject 类使用非常紧凑的代码(在大多数情况下不超过10-12行)一次以Endpoint“被动” @Published 属性到“主动” @Published 属性的“订阅”形式形成对商品列表的同步和/或异步依赖  。该“订阅”将在ObservableObject 实例的整个“生命周期”内有效  。然后,SwiftUI 您将使用户有机会仅管理@Published 表单中的“活动” 属性Endpoint,即他希望看到的内容:是最新消息的文章还是“健康”部分的文章。UI 这些ObservableObject 类及其“被动” @Published属性将自动提供  文章本身的外观以及“健康”部分中的最新新闻或文章。在代码中SwiftUI 您将不需要显式请求选择文章,因为ObservableObject发挥作用的类负责它们在屏幕上的正确和同步显示  View Model在一系列文章中,

我将向您展示如何与  NewsAPI.org  和Hacker News以及TMDb电影数据库一起使用。在这三种情况下,大约都可以使用相同的使用模式  Combine,因为在这种应用程序中,您始终必须创建电影或文章的清单,选择它们随附的“照片”(图像),然后使用搜索栏搜索所需电影或文章的数据库。

访问此类服务时,例如由于您指定了错误的密钥API-key或超出了允许的请求数量或其他原因,可能会发生错误。您需要处理此类错误,否则您将冒着使用户完全陷入空白屏幕的风险。因此,您不仅需要能够使用 Combine 从Internet中选择数据,而且还需要报告采样期间可能发生的错误,并控制它们在屏幕上的显示。

我们通过开发与NewsAPI.org新闻聚合器交互的应用程序来开始制定策略  。我必须说,在此应用程序中,SwiftUI它将最小程度地使用它,而没有任何多余的装饰,仅仅是为了说明如何Combine与“发布者”一起使用Publisher和“订阅” Subscription受到影响UI

建议您在NewsAPI.org网站上注册并接收API完成对NewsAPI.org服务的所有请求所需的密钥您必须将其放置在结构中的NewsAPI.swift文件APIConstants

本文的应用程序代码在Github上

NewsAPI.org服务数据模型和API


NewsAPI.org 服务  使可以选择有关当前新闻文章 [Article]及其来源的信息  [Source]我们的数据模型将非常简单,它位于Articles.swift文件中 

import Foundation

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

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

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

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

该文章Article将包含标识符id,标题 title,描述 description,作者author,“图片”的URL urlToImage,出版日期和出版物publishedAt来源source。在文章上方[Article]是一个加载项,NewsResponse在该加载项中,我们仅对property属性感兴趣,该属性articles是一系列文章。根结构NewsResponse和结构 ArticleCodable,这将使我们能够从字面上看两行代码,将JSON数据解码为模型。如果我们想使自己更轻松地将一组文章显示为列表 中的列表  ,则结构  Article 也应该是Identifiable这样。协议要求存在一个属性[Article]ListSwiftUIIdentifiable id我们将提供一个人工的唯一标识符UUID()

信息来源  Source将包含标识符 id,名称 name,描述  description,国家 country,出版物来源类别category,站点URL url。在信息源上方  [Source] 有一个加载项,SourcesResponse在该加载项  中,我们仅对一个属性感兴趣,该属性sources是一组信息源。根结构SourcesResponse和结构  SourceCodable,这将使我们能够非常轻松地将 JSON数据解码为模型。该结构  Source 也应该是Identifiable,如果我们要促进信息源阵列的显示 [Source]在列表的形式 List 在SwiftUI该协议Identifiable要求存在id我们已经拥有的属性,因此不需要我们付出额外的努力。

现在考虑我们  APINewsAPI.org服务的需求  ,并将其放置在NewsAPI.swift文件中  我们的中心部分API 是一个类NewsAPI,提供了两种从NewsAPI.org新闻聚合器中选择数据的方法   -文章  [Article]和信息来源 [Source]

  • fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never>  - [Article]根据参数选择文章  endpoint
  • fetchSources (for country: String) -> AnyPublisher<[Source], Never>- [Source]为特定国家/地区选择信息来源 country

这些方法不仅返回文章数组  [Article] 或信息源数组 [Source],而且还返回Publisher 新框架的相应“发布者”  Combine两家出版商均未返回任何错误- Never并且如果仍然发生抽样或编码​​错误,则将返回空的文章[Article]() 或信息源  数组,  [Source]()而没有任何消息说明这些数组为空。 

我们希望从NewsAPI.org服务器中选择哪些文章或信息源,我们将使用枚举进行指示enum Endpoint:

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

它:

  • 最新消息  .topHeadLines
  • 某一类别的新闻(体育,健康,科学,商业,技术)  .articlesFromCategory(_ category: String)
  • 来自特定信息来源的新闻(CNN,ABC新闻,Fox新闻等) .articlesFromSource(_ source: String)
  • 任何.search (searchFilter: String)符合特定条件的新闻  searchFilter
  • .sources (country:String)特定国家的信息来源country

为了方便我们需要的选项的初始化,我们根据索引和字符串对各种文章和信息源列表的枚举添加了一个Endpoint初始化器,这对于不同的枚举选项具有不同的含义:init?index  text

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

让我们回到该类 NewsAPI 并更详细地考虑第一种方法fetchArticles (from endpoint: Endpoint)-> AnyPublisher<[Article], Never>,该方法  [Article]根据参数选择文章endpoint,并且不返回任何错误- Never

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

  • 根据请求endpoint 形式URL获得所需的文章列表endpoint.absoluteURL,如果无法完成,则返回一个空的文章数组[Article]()
  • «» dataTaskPublisher(for:), Output (data: Data, response: URLResponse),   Failure - URLError,
  • map { } (data: Data, response: URLResponse)  data
  • JSON  data ,  NewsResponse, articles: [Atricle]
  • map { } articles
  • - [ ],
  • main , UI,
  • «» «» eraseToAnyPublisher() AnyPublisher.

选择信息源的任务已分配给第二种方法- fetchSources (for country: String) -> AnyPublisher<[Source], Never>这是第一种方法的精确语义副本,只是这次[Article]我们将选择信息源而不是文章[Source]

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

它以AnyPublisher <[Source], Never>一个信息源数组的形式返回的“发布者” [Source] 并且没有错误 Never (如果有错误,则返回一个空的源数组  [ ])。

我们将挑选出这两种方法的共同部分,安排它作为一个Generic函数fetch(_ url: URL) -> AnyPublisher<T, Error>返回的  Generic“发行” AnyPublisher<T, Error>基础上URL

//     URL
     func fetch<T: Decodable>(_ url: URL) -> AnyPublisher<T, Error> {
                   URLSession.shared.dataTaskPublisher(for: url)             // 1
                    .map { $0.data}                                          // 2
                    .decode(type: T.self, decoder: APIConstants.jsonDecoder) // 3
                    .receive(on: RunLoop.main)                               // 4
                    .eraseToAnyPublisher()                                   // 5
    }

这将简化前两种方法:

//   
     func fetchArticles(from endpoint: Endpoint)
                                     -> AnyPublisher<[Article], Never> {
         guard let url = endpoint.absoluteURL else {
                     return Just([Article]()).eraseToAnyPublisher() // 0
         }
         return fetch(url)                                          // 1
             .map { (response: NewsResponse) -> [Article] in        // 2
                             return response.articles }
                .replaceError(with: [Article]())                    // 3
                .eraseToAnyPublisher()                              // 4
     }
    
    //    
    func fetchSources(for country: String)
                                       -> AnyPublisher<[Source], Never> {
        guard let url = Endpoint.sources(country: country).absoluteURL
            else {
                    return Just([Source]()).eraseToAnyPublisher() // 0
        }
        return fetch(url)                                         // 1
            .map { (response: SourcesResponse) -> [Source] in     // 2
                            response.sources }
               .replaceError(with: [Source]())                    // 3
               .eraseToAnyPublisher()                             // 4
    }

这样获得的“发布者”在有人“订阅”之前不会交付任何东西。我们可以在设计时做到这一点UI

“出版商” Publisher作为View Model SwiftUI文章清单。


现在介绍一下运作的逻辑SwiftUI 他们所响应的“外部变化”

SwiftUI唯一抽象View是“发布者” Publisher。 “外部更改”可以理解为计时器 TimerNotificationCenter 带有Model对象的通知,使用协议ObservableObject可以将其转换为外部单个“真理来源”(真理来源)。 

对于普通的“发布者”类型TimerNotificationCenter View使用方法进行反应onReceive (_: perform:)Timer我们稍后将在关于Hacker News的应用程序的第三篇文章中介绍使用“发布者”的示例

在本文中,我们将重点介绍如何为 SwiftUI外部“真理之源”(真理之源)。

首先,让我们看一下在SwiftUI显示各种类型文章的特定示例中,最终的“发布者”应如何发挥作用:

.topHeadLines-最新新闻,  .articlesFromCategory(_ category: String) -特定类别的 .articlesFromSource(_ source: String) 新闻-特定信息源的新闻,.search (searchFilter: String) -根据特定条件选择的新闻。



根据 Endpoint选择的用户,我们需要更新articlesNewsAPI.org选择的文章列表。为此,我们将创建一个非常简单的类 ArticlesViewModel该类实现ObservableObject 具有三个  @Published属性的协议
 


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

一旦设置了  @Published 属性indexEndpoint  searchString,我们就可以将它们同时用作简单属性  indexEndpoint 和  searchString,以及作为“发布者”  $indexEndpoint和  $searchString

在一个类中  ArticlesViewModel,您不仅可以声明我们感兴趣的属性,还可以规定它们交互的业务逻辑。为此,在初始化类的实例时  ArticlesViewModel ,init我们可以创建一个“订阅”,该“订阅”将在该类实例的整个“生命周期”中起作用,  ArticlesViewModel并重现文章列表articles对索引  indexEndpoint 和搜索字符串的依赖性searchString

为此,Combine我们扩展了“发布者”链$indexEndpoint 并$searchString输出“发布者”AnyPublisher<[Article], Never>其值是文章列表  articles。然后,我们使用运算符“订阅”它,assign (to: \.articles, on: self)并获得所需的文章列表articles 作为@Published 定义的“输出”  属性UI

我们必须从性质拉动链条不要直接  indexEndpointsearchString,即从“出版商” $indexEndpoint$searchString谁参与创作UI的帮助下SwiftUI,我们将有使用用户界面元素更改它们  PickerTextField

我们将如何做?

我们fetchArticles (from: Endpoint)的类库中  已经有一个函数,该函数根据值NewsAPI返回“发布者”AnyPublisher<[Article], Never>Endpoint,而我们只能以某种方式使用“发布者”的值  $indexEndpoint并将  $searchString其转换endpoint为该函数的参数。 

首先,将“发布者” $indexEndpoint 和  结合在一起  $searchString。为此,该Combine操作员存在Publishers.CombineLatest



要基于从先前“发布者”接收到的数据创建一个新的“发布Combine 者,请使用该操作符  flatMap



接下来,我们使用非常简单的“订阅者”“订阅”该新接收的“发布者”,  assign (to: \.articles, on: self)并从“由于@Published 数组的  值是发布者“的值  articles



我们刚刚创建了一个init( )异步的发布者”,并“订阅了”AnyCancellable“订阅”,这很容易验证我们是否将我们的“订阅”保持为常数let subscription“订阅”



的主要属性AnyCancellable是,一旦它离开范围,它所占用的内存就会自动释放。因此,一旦init( ) 完成,该“预订”将被删除ARC,而没有时间将接收到的具有时延的异步信息分配给阵列articles。从字面上看,异步信息根本就无处“着陆”,“地球已经从它的脚下掉下来了”。

为了保存这样的“订阅”,有必要创建一个超出初始化程序的 init() 变量,该变量将在类实例的整个“生命周期” var cancellableSet中将我们的AnyCancellable“订阅” 保存  在该变量中  ArticlesViewMode。 

因此,我们删除了常数let subscription,并记住我们的AnyCancellable“订阅”中的变量  cancellableSet使用操作.store ( in: &self.cancellableSet)



“订阅”异步“发行人”,我们创造了init( )整个类实例的整个“生命周期”将被保留  ArticlesViewModel

我们可以任意更改``发布者'' $indexEndpoint和/或  的含义  searchString,并且始终要感谢创建的``订阅'',我们将articles无需花费额外的精力就可以拥有与这两个发布者的价值相对应的一系列文章  。这个  ObservableObject类通常被称为  View Model

为了减少键入搜索字符串时服务器的调用次数searchString,我们不应该使用搜索字符串本身的“发布者” $searchString,以及它的修改版本validString



现在,我们已经View Model准备好撰写文章,让我们开始创建用户界面(UI)。为了 模型SwiftUI同步,View使用了ObservableObject一个@ObservedObject变量,该变量引用此模型的类的实例。这是对-  ObservableObject 类和@ObservedObject引用该类实例的  变量-控制in中用户界面(UI)  的更改SwiftUI

我们以变量的形式将结构的ContentView 实例添加到结构ArticleViewModel 中,var articleViewModel并替换Text ("Hello, World!")为文章列表,ArticlesList在其中放置articlesViewModel.articles从我们的文章中获得  的文章 View Model。结果,我们获得了固定索引和默认索引的文章列表  indexEndpoint = 0,即.topHeadLines 最新新闻:



屏幕上添加一个  UI元素,以控制要显示的文章集。我们将使用  Picker索引更改$articlesViewModel.indexEndpoint。符号的存在是  $强制性的,因为这意味着@Published “发布者” 提供的值将发生更改  。该“发布者”的“订阅”会立即触发,我们在此启动init (),“输出”  @Published“发布者”  articles 将更改,并且我们将在屏幕上看到不同的文章列表:



这样,我们可以接收所有三个选项的文章数组-“ topHeadLines”,“搜索” “和”来自类别“:



...,但使用固定和默认的搜索字符串searchString = "sports"(需要时):



但是,对于此选项,  "search" 您必须为用户提供SearchView用于输入搜索字符串的文本字段



结果,用户可以通过键入的搜索字符串来搜索任何新闻:



对于该选项,  "from category" 有必要向用户提供有机会选择一个类别,我们开始与类别science



作为一个结果,用户可以搜索任何新闻对新闻选择的类别- ,science health,  business 我们可以看到一个非常简单的   模型,有两个用户控制功能-  和technology



ObservableObject@Published indexEndpointsearchString-使您可以从NewsAPI.org网站中选择各种信息 

信息来源清单


让我们看一下SwiftUI NewsAPI类中接收到的信息源“发布者”将如何运行fetchSources (for country: String) -> AnyPublisher<[Source], Never>

我们将获得不同国家/地区的信息资源列表:



...以及按名称搜索信息的能力:



...以及有关选定来源的详细信息:其名称,类别,国家/地区,简短说明和网站链接: 



如果单击链接,我们将转到此网站信息来源。

为了使所有这些都能起作用,您需要一个非常简单的  ObservableObjectModel,该Model仅具有两个用户控制的@Published属性- searchString 和  country



同样,我们使用相同的方案:在初始化类的SourcesViewModel 实例时 init我们创建了一个“订阅”,该订阅将在类实例的整个“生命周期”中运行, SourcesViewModel并确保信息源列表取决于  sources国家  country和搜索字符串 searchString

在帮助下,  Combine我们将链从“发布者”中拉出,$searchString $country输出“发布者” AnyPublisher<[Source], Never>,其值是信息源列表。我们使用运算符“订阅”它assign (to: \.sources, on: self),得到所需信息源的列表  sources。并使用运算符将收到的AnyCancellable“订阅” 记住  在变量中  现在我们有了信息源,让我们开始创建。 B 同步ccancellableSet.store ( in: &self.cancellableSet)

View Model UISwiftUI ViewObservableObject 该模型使用一个@ObservedObject变量,变量引用此Model类的实例。

ContentViewSources 类实例SourcesViewModel变量的形式添加到结构  var sourcesViewModel,删除  Text ("Hello, World!") 并放置View3个@Published属性中的每个  属性 sourcesViewModel :

 
  • SearchView搜索栏的  文本框  searchString
  •  Picker 为了国家country
  • SourcesList 信息来源清单 



结果,我们得到了所需的信息View



在此屏幕上,我们仅使用文本框管理搜索字符串,并使用来管理SearchView“国家/地区”  Picker,其余的将自动进行。

信息源列表仅SourcesList包含有关每个源的最少信息-名称source.name 和简短说明source.description



... ...但是,您可以使用以下链接获得有关所选源的更详细的信息:在该链接NavigationLink中,destination我们指示  DetailSourceView哪些源数据是信息源  source以及所需的类实例ArticlesViewModel,得到他的文章清单articles



看看我们如何在信息来源列表中获得选定信息来源的文章列表  SourcesList我们的老朋友对我们有帮助- ArticlesViewModel我们必须为这两个类  设置“输入”  @Published属性:

  • index  indexEndpoint = 3,即.articlesFromSource (_source:String)对应于固定来源文章选择的选项  source
  • 字符串  searchString 作为源本身(或更确切地说是其标识符)source.id 



通常,如果您查看整个NewsApp应用程序,则不会看到我们明确要求NewsNews.org网站上提供文章或信息来源的任何地方  我们仅管理  @Published 数据,但会View Model 做我们的工作:选择所需的文章和信息来源。

下载UIImage文章图片Article


本文的模型  Article 包含一个URL附带图像  urlToImage



基于此,  URL将来我们必须UIImage 从NewsAPI.org网站上  获取图像本身

我们已经熟悉此任务。在该类中ImageLoader,使用函数fetchImage(for url: URL?) -> AnyPublisher<UIImage?, Never>创建一个AnyPublisher<UIImage?, Never>具有图像值  UIImage? 且没有错误  的“发布者”  Never(实际上,如果发生错误,则返回图像 nil)。UIImage? 在设计用户界面(UI时,您可以“订阅”该“发布者”以接收图像  该函数的源数据fetchImage(for url: URL?)是  url,我们有:



如果我们知道, 让我们详细考虑在 Combine“发布者” 的帮助下如何进行AnyPublisher <UIImage?, Never>url

  1. 如果url相等nil,返回Just(nil)
  2. 基于url“发布者” 形式dataTaskPublisher(for:),其输出值为Output元组(data: Data, response: URLResponse)和错误 FailureURLError
  3. 我们采取仅数据map {}从元组(data: Data, response: URLResponse)以供进一步处理  data,和形式UIImage
  4. 如果前面的步骤返回错误nil
  5. 我们将结果传递到main流中,因为我们假定在设计中进一步使用它UI
  6. “擦除”“发布者”的类型并返回副本AnyPublisher

您会看到代码非常紧凑且可读性强,没有任何代码callbacks

让我们开始View Model 为图像创建  UIImage?这是一个ImageLoader实现protocol 的类ObservableObject,具有两个  @Published属性:

  • @Published url: URL? 是URL图像
  • @Published var image: UIImage? 是来自NewsAPI.org的图片本身



再一次,初始化类的实例时,  ImageLoader 我们必须将链从输入“ publisher”  $url 延伸到输出“ publisher” AnyPublisher<UIImage?, Never>,稍后将 “订阅” 到输出并获得所需的图像image



我们使用运算符 flatMap和一个非常简单的“ subscriber” assign (to: \image, on: self)将其分配给来自“ publisher”的接收者  ``属性值@Published image



再次使用操作符cancellableSet 存储在变量  AnyCancellable''预订''中  store(in: &self.cancellableSet)

此“图像下载器”的逻辑是,您从 nil URL没有预加载的图像之外的其他位置下载图像,即image == nil。如果在下载过程中检测到任何错误,则图像将不存在,即image它将保持相等nil

SwiftUI我们的帮助ArticleImage下,imageLoader 该类的实例使用此图像来显示图像ImageLoader。如果他的图像图像不相等nil,则使用来显示 Image (...),但如果相等nil,则根据图像的相等性显示url 任何内容EmptyView(),或者显示Rectangle带有旋转文本T 的矩形ext("Loading...")



此逻辑适用于这种情况当您肯定知道的时候(  url除了  nil获得图片)image,电影电影数据库就是这种情况。 TMDb。使用NewsAPI.org,新闻聚合器    就不同了。某些信息来源的文章与nil URL图像提供了不同的信息  ,但是对它的访问是封闭的,我们得到了一个Rectangle带有旋转文本的矩形该矩形Text("Loading...")将永远不会被替换:



在这种情况下,如果 URL图像不同于  nil,则nil图像  的相等性  image可能意味着图像正在加载以及加载时发生错误的事实,我们将永远不会得到图片image。为了区分这两种情况,我们向该类中ImageLoader 的两个现有@Published属性添加了  一个: 

 @Published var noData = false -这是一个布尔值,通过它我们将指示由于选择过程中的错误而导致图像数据不存在的情况:



创建“订阅”时,我们将 init捕获 Error加载图像时发生的所有错误并在@Published属性中累积它们的存在  self.noData = true。如果下载成功,则获得图像image 我们 基于以下   函数创建

“发布者”  我们通过初始化“发布者”  开始创建方法,该方法可用于使用闭包异步获取单个TYPE值。闭包有一个参数- 这是TYPE的函数  我们将 结果转换为AnyPublisher<UIImage?, Error>urlfetchImageErr (for url: URL?)



fetchImageErrFutureResultPromise(Result<Output, Failure>) → Void



FutureAnyPublisher <UIImage?, Error>在“擦除类型”运算符的帮助下eraseToAnyPublisher()

接下来,我们将执行下列步骤,考虑到所有可能出现的错误(我们不会识别错误,那简直是重要的,我们知道有一个错误):

0检查url用于nil 和  noDatatrue如果是这样,则返回错误,如果不是,传输:url通过进一步链,
1.创建一个dataTaskPublisher(for:)输入为- 的“发布者” url,并且输出值为Output一个元组(data: Data, response: URLResponse)和一个错误  URLError
2.使用 tryMap { } 结果元组进行分析(data: Data, response: URLResponse):如果它response.statusCode在范围内200...299,则为了进行进一步处理,我们仅获取数据  data。否则,我们会“抛出”错误(无论如何),
3.我们map { }转换数据dataUIImage
4传递结果到main流,因为我们认为我们将在设计中用到它的过程UI
-我们“订阅”接收到的“发行人”用sink自己的封闭receiveCompletionreceiveValue
- 5。如果我们receiveCompletion 发现错误在封闭  error,我们报告使用它promise (.failure(error)))
-在封闭6,  receiveValue 我们告知成功接收的使用物品的阵列 promise (.success($0))
在可变7.我们记得收到“订阅”  cancellableSet,以保证类实例的“生命周期”内的生存能力ImageLoader
8大家“擦除”的“出版商” TYPE并返回实例AnyPublisher

我们回到ArticleImage使用新  @Published变量的地方noData如果没有图像数据,那么我们将不显示任何内容,即EmptyView ()



最后,我们将把显示NewsAPI.org新闻聚合器数据的所有可能性打包TabView



NewsAPI.org服务器获取和解码JSON数据时显示错误 


访问NewsAPI.org服务器时,  可能会发生错误,例如,由于您指定了错误的密钥,API-key或者由于开发人员的费用不计任何费用,超出了允许的请求数量或其他原因。同时,NewsAPI.org服务器   为您提供HTTP代码和相应的消息:



必须处理这种服务器错误。否则,您的应用程序用户将陷入突然的情况,无缘无故,NewsAPI.org服务器   停止处理任何请求,使用户完全茫然,出现空白屏幕。

到目前为止,NewsAPI.org服务器  选择文章  [Article]和信息来源时 [Source] 我们忽略了所有错误,并且在错误出现的情况下返回空数组[Article](),  结果是  [Source]()

从错误处理开始,让我们基于现有的fetchArticles (from endpoint: Endpoint) -> AnyPublisher<[Article], Never> 文章选择NewsAPI 方法在类中创建另一个方法,该  方法fetchArticlesErr (from endpoint: Endpoint) -> AnyPublisher<[Article], NewsError>不仅将返回文章数组[Article],而且还可能返回错误  NewsError

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

. . . . . . . .
}

此方法与方法一样fetchArticles,在输入处接受endpoint 并返回带有文章数组形式的值的“发布者” [Article],但不是没有错误Never,而是由枚举定义了一个错误NewsError



我们通过初始化“发布者”开始创建新方法  Future,该方法用于Result使用闭包异步获取单个TYPE值闭包有一个参数- Promise这是TYPE的函数  (Result<Output, Failure>) -> Void我们将使用“ TYPE Erase”运算符



接收Future到的信息转换成我们需要的“ publisher”  在新方法中,我们将重复执行该方法中的所有步骤 AnyPublisher <[Article], NewsError>eraseToAnyPublisher()

fetchArticlesErrfetchArticles,但我们会考虑所有可能的错误:



  • 0. endpoint  URL endpoint.absoluteURL , url nil: nil, .urlError, — url ,
  • 1. «» dataTaskPublisher(for:), — url, Output (data: Data, response: URLResponse)  URLError,
  • 2. tryMap { }  (data: Data, response: URLResponse): response.statusCode 200...299,  data. «» .responseError,   data, String, ,
  • 3. JSON ,  NewsResponse,
  • 4. main , UI
  • «» «» sink receiveCompletion receiveValue,
    • 5.    receiveCompletion  error, promise (.failure(...))),
    • 6.   receiveValue promise (.success($0.articles)),
     
  • 7.  «»  var subscriptions, « » NewsAPI,
  • 8. «» «» AnyPublisher.

应该注意的是,“发布者”  dataTaskPublisher(for:) 它的原型的不同之处dataTask在于,如果不在response.statusCode 范围内时发生服务器错误200...299,它仍然以元组的形式传递成功的值(data: Data, response: URLResponse),而不是形式上的错误(Error, URLResponse?)。在这种情况下,真实的服务器错误信息包含在中data如果客户端发生错误(无法与服务器联系,安全系统禁令等),则“发布者” dataTaskPublisher(for:) 将发送错误  如果要在中显示错误,则需要相应的错误我们将其称为  实现协议的类,这次我们有四个  属性:URLErrorATS

SwiftUIView ModelArticlesViewModelErr



 ArticlesViewModelErrObservableObject @Published

  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 .

当您初始化一个类的实例时ArticlesViewModelErr,我们必须再次从输入“ publishers” 到输出“ publisher”  扩展一条链,$indexEndpoint用“ Subscriber”对其进行“签名”,从而得到很多文章或错误  在我们的课堂上,我们已经构造了  一个根据值返回``发布者''  的函数,我们只需要以某种方式使用``发布者''的值  并将  它们变成该函数的参数即可。  首先,我们将结合“发布者”   和  。为此,有一个运算符$searchStringAnyPublisher<[Article],NewsError>sinkarticlesarticlesError

NewsAPIfetchArticlesErr (from endpoint: Endpoint)AnyPublisher<[Article], NewsError>endpoint$indexEndpoint$searchStringendpoint

$indexEndpoint$searchStringCombinePublishers.CombineLatest



然后,我们必须将错误类型TYPE“ publisher”设置为所需的值  NewsError



接下来,我们要使用fetchArticlesErr (from endpoint: Endpoint) 类中的函数  NewsAPI。像往常一样,我们将在操作员的帮助下执行此操作flatMap该操作员  将根据从先前“发布者”接收到的数据创建一个新的“发布者”:



然后,在“订阅者”的帮助下“订阅”该新收到的“发布者” sink并使用其闭包receiveCompletionreceiveValue以便从“发布者”接收文章数组的值  articles或错误articlesError



自然地,有必要记住某些外部init()变量中的结果“订阅” cancellableSet。否则,我们将无法异步获取该值articles或错误articlesError 完成后init()



为了键入搜索字符串时降低呼叫服务器的数量searchString,我们不应该使用搜索栏本身的“发行人”  $searchString,但它的修改版本validString



“订阅”异步“发行人”,我们创造了init( )将在类实例的整个“生命周期”中都保持不变  ArticlesViewModelErr



我们进行更正 UI以显示其上可能的数据采样错误。在SwiftUI中,在现有结构中,我们  ContentVieArticles  使用另一个刚获得的结构  View Model,只是在名称中添加字母“ Err”。这是该类的一个实例。  ArticlesViewModelErr,它“捕获”了从NewsAPI.org服务器选择和/或解码文章数据的  错误



并且,如果出现错误Alert ,我们还添加了紧急消息的显示 

例如,如果错误的API密钥是:

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

......然后我们会收到以下消息:



如果请求的限制已经用完,我们会收到以下消息:



回到选择文章的方法  [Article] 有可能出现的错误  NewsError,我们可以简化它的代码,如果我们使用  Generic “出版商” AnyPublisher<T,NewsError>,该  url接收JSON信息异步和直接地在Codable模型中T 并报告错误  NewsError



众所周知,如果该代码的来源数据urlNewsAPI.orgEndpoint新闻  汇总者  或国家/地区  ,则很容易使用此代码来获取特定的“发布者”country 信息源,并且输出需要各种模型-例如,文章列表或信息源:





结论


我们了解到HTTP借助 CombineURLSession“发布者” dataTaskPublisher来满足请求是多么容易Codable。如果您不需要跟踪错误,则可 Generic以为“发布者” 获得一个非常简单的5行代码,该代码AnyPublisher<T, Never>可以异步接收JSON 信息并将其 基于给定的  信息直接放置在Codable 模型中 如果源数据是,此代码非常容易获得特定的发布者,例如,并且输出需要各种模型-例如,一组文章或信息源列表。 如果您需要考虑错误,那么“发布者” 的代码  将稍微复杂一些,但是仍然是非常简单的代码,没有任何回调:Turl



url Endpoint

Generic



通过使用使用的HTTP查询执行技术Combine,您可以创建一个“发布者” AnyPublisher<UIImage?, Never>来异步选择数据并接收UIImage图像吗?基于URL。图像ImageLoade下载器r缓存在内存中,以避免重复的异步数据检索。

可以在ObservableObject类中非常容易地使获得的各种“发布者”工作,这些类使用它们的@Published属性来控制使用SwiftUI设计的UI。这些类通常具有视图模型的作用,因为它们具有与活动UI元素(TextField,Stepper,Picker文本框,Toggle单选按钮等)相对应的所谓“ input” @Published属性和输出@Published属性,它主要由被动UI元素(文本文本,图像图像,Circle()几何形状,Rectangle()等)组成。

这种想法贯穿了本文介绍的整个NewsAPI.org新闻聚合器应用程序。相当普遍,在TMDb 电影数据库Hacker News新闻聚合器  开发应用程序,将在以后的文章中进行讨论。

本文的应用程序代码在Github上

PS

1.我想引起您的注意的事实是,如果您对本文中介绍的应用程序使用模拟器,则应该知道NavigationLink模拟器会出现错误。您可以使用NavigationLink在模拟器上只有1次。那些。您使用了链接,然后返回,单击相同的链接-没有任何反应。在使用另一个链接之前,第一个链接将不起作用,但是第二个链接将变得不可访问。但这只能在模拟器上观察到,在真实设备上一切正常。

2.一些信息源仍然http代替https其文章的“图片”。如果您确实想查看这些“图片”,但无法控制其外观的来源,则必须配置安全系统ATS ( App Transport Security)以接收这些http“图片”,但这当然不是一个好主意您可以使用更安全的选项

参考文献:


HTTP Swift 5 Combine SwiftUI. 1 .
Modern Networking in Swift 5 with URLSession, Combine and Codable.
URLSession.DataTaskPublisher’s failure type
Combine: Asynchronous Programming with Swift
«SwiftUI & Combine: »
Introducing Combine — WWDC 2019 — Videos — Apple Developer. session 722
( 722 « Combine» )
Combine in Practice — WWDC 2019 — Videos — Apple Developer. session 721
( 721 « Combine» )

All Articles