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 定义为 [ [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, [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 {
        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 {
        searchLocation(query: query)
            .flatMap { locations -> AnyPublisher 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中使用我们定义的函数吧


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

你可能感兴趣的:(Combine实现连续请求)