Combine实现连续请求

这篇文章给大家演示通过Combine来处理连续的网络请求,场景是这样子:首先进行A请求,然后通过A请求的结果进行B请求,最后展示B请求返回的结果。 大家可以回想一下,在没有Combine的时代,我们是通过closure嵌套来实现这种需求,接下来通过使用Combine来实现,大家可以对比一下两种方式的优劣。
我们演示的 demo 网络请求接口使用的是 http://www.MetaWeather.com 提供的测试API。

struct ClientWeather {
    // url: https://www.metaweather.com/api/location/search/?query=san
    // 通过query进行地址搜索,返回搜索到的地址集合
    static func searchLocation(query: String) -> AnyPublisher<[Location], Never> {
        var components = URLComponents(string: "https://www.metaweather.com/api/location/search")!
        components.queryItems = [URLQueryItem(name: "query", value: query)]
        
        return URLSession.shared.dataTaskPublisher(for: components.url!)
            .map { data, _ in data }
            .decode(type: [Location].self, decoder: jsonDecoder)
            .catch { _ in Just([]) }
            .eraseToAnyPublisher()
    }
}

首先来看这部分代码, 我们一步一步的进行分析:

  1. 返回值 AnyPublisher<[Location], Never> 代表什么意思?
    AnyPublisher<Output, Failure>是一个泛型类,Output代表的是管道中流动的数据类型,Failure代表的是管道中可能会发生的错误。我们这个搜索函数返回的是地址集合,所以 Output 定义为 [ [Location],我们先简单来做,忽略发生的错误,所以 Failure 定义为了 Never,代表永远不会发生错误,至于为什么是AnyPublisher我们下面再说。

  2. URLSession.shared.dataTaskPublisher(for: components.url!)
    Combine对URLSession扩展了dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher函数,顾名思义函数返回的是一个Publisher,当我看到一个API会返回一个Publisher的时候,我第一件事就是要去确定Publisher携带的数据类型以及可能会产生的错误类型。
    public typealias Output = (data: Data, response: URLResponse)
    public typealias Failure = URLError
    通过这两段代码可以确定URLSession.DataTaskPublisher中携带的数据类型以及会产生的错误类型,至此我们对URLSession.DataTaskPublisher已经很明确了。

  3. 为什么要对URLSession.DataTaskPublisher进行map操作?
    我们先来简略看一下map操作符,在平时我们使用map操作大多数场景是对数据类型进行转换,比如[Int] -> [String]。其实在Combine的世界里,map操作的意思也非常类似,通过map操作符我们可以对Publisher携带的数据类型进行转换。
    map { data, _ in data }
    我们来分析下这行代码,上面我们提到过URLSession.DataTaskPublisher携带的数据类型是(Data, URLResponse)元祖类型,因为我们先从简单起步,忽略了网络请求过程中会发生的错误,认为网络请求一定会成功且数据准确,所以我们不关心URLResponse,我们只需要拿到Data进行Decode,解析为最终我们想要的[Location]类型。所以我们要对URLSession.DataTaskPublisher携带的数据类型进行转换:(Data, URLResponse) -> Data。

  4. 为什么要使用catch操作符?
    我们需求Publisher永远不会产生Error,但是无论是dataTaskPublisher还是decode都有可能产生相应的错误(比如URL404,decode失败等),那么catch操作符的作用就是来捕获这些错误并且要求返回值是一个Publisher。当产生错误时,我们默认返回一个空的Location集合,所以我们就使用Just([]),有的同学目前可能不理解Just,我们可以简单理解就是一个立马发出初始值的Publisher,它和catch配合是很常见的场景。通过catch操作,我们保证了我们的管道中不会向订阅者发出任何错误,这样我们把Error定义了Never也获得了编译器的同意。

  5. eraseToAnyPublisher
    最后解释为什么要eraseToAnyPublisher,erase 顾名思义是擦除的意思,我们怎么理解这个操作符呢。经过上面几种操作符的使用,我们的Publisher其实是一个很复杂的类型了
    Publishers.Catch<Publishers.Decode<Publishers.Map<URLSession.DataTaskPublisher, JSONDecoder.Input>, [Location], JSONDecoder>, Just<[Location]>>
    看到这种类型的你是否开始有点不舒服了,对于函数的使用者来说只需要知道返回的是一个Publisher以及Output和Error的类型,我能去订阅就好了,我不需要知道你Publisher具体是什么类型,所以我们通过eraseToAnyPublisher操作把Publisher类型转换为AnyPublisher。

怎么样,看到这里,你是否对searchLocation函数每一步的操作都非常明确了呢?接下来,我们再来定义一个函数,根据LocationId获取当前的天气信息

    //   https://www.metaweather.com/api/location/2487956/
    static func weather(with locationId: Int) -> AnyPublisher<LocationWeather, ClientWeatherError> {
        let url = URL(string: "https://www.metaweather.com/api/location/\(locationId)")!
        
        return URLSession.shared.dataTaskPublisher(for: url)
            .tryMap { data, response in
                guard let response = response as? HTTPURLResponse,
                      response.statusCode == 200 else {
                          throw URLError(.badServerResponse)
                      }
                return data
            }
            .decode(type: LocationWeather.self, decoder: jsonDecoder)
            .mapError { error -> ClientWeatherError in
                switch error {
                case is URLError:
                    return .serverResponse(error)
                case is DecodingError:
                    return .decodeFailed
                default:
                    return .unknown
                }
            }
            .eraseToAnyPublisher()
    }

    enum ClientWeatherError: Error, CustomStringConvertible {
        case decodeFailed
        case serverResponse(Error)
        case unknown
        
        var description: String {
            switch self {
            case .decodeFailed:
                return "数据解码失败"
            case .serverResponse(let error):
                return "服务相应错误:\(error.localizedDescription)"
            case .unknown:
                return "未知错误"
            }
        }
    }

这部分代码和上面我们讲解的方法类似,不同的地方就是这个函数增加了对错误的处理。接下来,我们主要解释如何对错误进行处理。

  1. tryMap vs map
    tryMap 和 map 意义是一样的,唯一的不同是tryMap允许闭包中throw错误。在这里,我们简单判断下响应码==200,其余的都抛出URLError(.badServerResponse)异常。如果响应码==200,我们就继续对data进行decoder,得到相应的数据。

  2. mapError
    顾名思义,mapError是用来对Error类型进行转换,转换为我们自己定义的ClientWeatherError类型,这样我们就可以遍历Error case,展示不同的错误界面。

至此,我们两个网络请求的函数都已经定义完毕,它们都是返回相应的Publisher。在Combine之前,它们应该都要接受一个escaping closure,当网络请求结束回来,调用closure。

接下来,让我们来把两个网络请求进行串联起来。我们定义一个函数,根据输入的地址名字搜索,用搜索的结果第一个地址获取当前的天气状况

static func searchLocationWeather(with query: String) -> AnyPublisher<LocationWeather, ClientWeatherError> {
        searchLocation(query: query)
            .flatMap { locations -> AnyPublisher<LocationWeather, ClientWeatherError> in
                if let location = locations.first {
                    return weather(with: location.id)
                } else {
                    return Fail(outputType: LocationWeather.self, failure: ClientWeatherError.searchEmpty).eraseToAnyPublisher()
                }
            }
            .eraseToAnyPublisher()
    }

这个函数难以理解的就是flatMap,这个操作符允许把searchLocation(query: query)输出的值转换为另外一个Publisher,在我们的需求就是转换为weather(with),另外我们还需要判断 searchLocation是否查询的为空,如果为空,我们直接返回Fail,它也是一个Publisher,只不过它只是会发出一个Error。

到现在,我们就使用Combine完成了两个网络请求的串联。大家可以回顾一下和我们平时用的closure的不同和优劣点。
最终,给大家截图看下如何在SwiftUI中使用我们定义的函数吧


WX20220119-232335@2x.png

如果你有什么疑问或者更好的思路,请在留言区提出,谢谢!

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,951评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,606评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,601评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,478评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,565评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,587评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,590评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,337评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,785评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,096评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,273评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,935评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,578评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,199评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,440评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,163评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,133评论 2 352

推荐阅读更多精彩内容