阅读 225

[SwiftUI 100 天] Hot Prospects - 理解 Swift 的 Result 类型

译自 www.hackingwithswift.com/books/ios-s…

更多内容,欢迎关注公众号 「Swift花园」

喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀

理解 Swift 的 Result 类型

让一个函数在执行成功时返回某些数据,执行失败时返回某个错误是很常见的做法。我们通常会利用抛出错误的函数来实现这个要求,一旦函数抛出错误运行 catch 块,这样就独立地处理成功和失败的逻辑。但是假如函数并不是立即返回的呢?

我们可以回顾一下之前使用过的 URLSession 的网络代码,然后在一个新的默认工程里看看下面这样一个例子:

Text("Hello, World!")
    .onAppear {
        let url = URL(string: "https://www.apple.com")!
        URLSession.shared.dataTask(with: url) { data, response, error in
            if data != nil {
                print("We got data!")
            } else if let error = error {
                print(error.localizedDescription)
            }
        }.resume()
    }
复制代码

文本视图呈现的时候,网络请求就会启动,从 apple.com 获取数据,然后根据网络请求的执行情况打印两条消息中的一条。

回忆一下,我们说过完成闭包要么会设置 data,要么会设置 error —— 不能两者都设置,也不能两者都不设置,因为这两种情况都不合理。但是由于 URLSession 并没有强制这个约束,我们不得不写代码处理不可能的情况,只是为了让所有的代码分支能被覆盖。

Swift 对此提供了一种解决方案,它是一个叫 Result 的专用数据类型。它能帮我们实现非此即彼的行为,同时也很好适用于非阻塞式的函数 —— 这是一种异步执行工作的函数,因此它们不会阻塞主要代码的执行。作为额外的好处,它允许我们返回特定类型的错误,这就让出错时排查错误变得更加容易。

语法初看会有一点奇怪,这也是为什么我要慢慢地给你热身的原因 —— 这玩意相当地有用,但是如果你一开始就一头扎进去,可能会事倍功半。

我们要做的是给上面的网络代码添加一层封装,让它是利用 Swift 的 Result 类型,也就是说,你可以很清楚地看到改造前后的差异。

首先,我们要定义可能被抛出的错误的类型。如果你愿意,可以定义任意多,但在这里,我们假定只有 URL 错误,请求失败和未知错误三种情况。把下面这个枚举放到 ContentView 结构体外面:

enum NetworkError: Error {
    case badURL, requestFailed, unknown
}
复制代码

接下来,我们要写一个方法,这个方法能够返回一个 Result。记住,Result 是用于代表某种成功或者失败的情况。在这个例子里,我们说成功的情况是某个从网络返回的字符串,而错误的情况就是 NetworkError 的某一种。

我们要逐渐加大难度,把同一个方法的写法升级四次。系好安全带,你会看到东西是怎么建起来的。先从最简单的版本开始,我们直接返回一个 URL 错误的版本,像下面这样:

func fetchData(from urlString: String) -> Result<String, NetworkError> {
    .failure(.badURL)
}
复制代码

如你所见,方法的返回类型是 Result<String, NetworkError>,也就说,要么是一个代表成功的字符串,要么是代表失败的某个 NetworkError。注意,这个时候函数还是阻塞式的调用,一个非常快的调用。

当我们实际上要的是一个非阻塞式的函数,也就是说,我们不能返回一个 Result。取而代之的是,我们需要让我们的方法接收两个参数:一个用于 URL 请求,另一个是带一个执行参数的完成闭包。这意味着函数本身不返回任何东西,它的数据会被返回给完成闭包,这个闭包是在未来某个节点被调用。

再一次,为了让事情简化,我们还是直接使用 URL 错误的失败作为默认的实现:

func fetchData(from urlString: String, completion: (Result<String, NetworkError>) -> Void) {
    completion(.failure(.badURL))
}
复制代码

我们使用完成闭包的目的是让方法变成非阻塞式的:在方法里面,我们可以启动一些异步的工作,让方法直接返回,以便后面的代码能够继续运行,然后在未来某个时间调用完成闭包。

这里面有一个难点,我之前简要提过,现在变得很重要了。当我们把一个闭包传给一个函数时,Swift 需要知道这个闭包是被立刻使用还是可能稍后才被使用。如果它是被立即使用的 —— 也就是默认的情况 —— Swift 很欣然接受代码,然后运行闭包。但如果它是稍后才使用的,那么很有可能创建闭包的东西在闭包被调用时已经被销毁掉,不再存在于内存中,这个时候闭包也会被销毁,不被执行。

为了处理这种情况,Swift 允许我们给闭包参数标记 @escaping(逃逸闭包),它的意思是“这个闭包可能会脱离当前方法的运行周期被使用,所以请在内存中保留它,直到我们把事情做完。”

以我们的方法为例,我们将先执行一个异步的工作,然后调用在该工作做完时调用你闭包。这个调用动作可能立即发生,也可能需要几分钟。但我们不关心这一点,关键是闭包在方法返回之后还需要保留,因此我们必须把它标记为@escaping。你可能会担心自己遗漏这一点,大可不必担心:如果你不加 @escaping 属性的话 Swift 实际上回拒绝编译。

下面是函数的第三个版本,使用了 @escaping 的闭包,以便我们可以异步调用:

func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
    DispatchQueue.main.async {
        completion(.failure(.badURL))
    }
}
复制代码

记住,完成闭包是在未来某个时点被调用的。

最后是第四个版本:我们要讲 URLSession 的 code 合入之前的 Result。这个版本的函数签名不变 —— 仍是接收一个字符串和一个闭包,不返回任何东西 —— 但这次我们调用完成闭包的方式不同:

  1. 如果 URL 非法,我们调用 completion(.failure(.badURL))
  2. 如果我们从请求的返回中得到合法的数据,则将其转换成字符串并调用 completion(.success(stringData))
  3. 如果我们从请求得到错误,则调用 completion(.failure(.requestFailed))
  4. 如果既没有得到数据,也没有得到错误,则调用 completion(.failure(.unknown))

这里头唯一的新知识点是将 Data 实例转换成字符串。回忆一下,你知道如何从字符串构建 Data: let data = Data(someString.utf8),而从 DataString 的代码是相似的:

let stringData = String(decoding: data, as: UTF8.self)
复制代码

好了,下面是完整的代码:

func fetchData(from urlString: String, completion: @escaping (Result<String, NetworkError>) -> Void) {
    // check the URL is OK, otherwise return with a failure
    guard let url = URL(string: urlString) else {
        completion(.failure(.badURL))
        return
    }

    URLSession.shared.dataTask(with: url) { data, response, error in
        // the task has completed – push our work back to the main thread
        DispatchQueue.main.async {
            if let data = data {
                // success: convert the data to a string and send it back
                let stringData = String(decoding: data, as: UTF8.self)
                completion(.success(stringData))
            } else if error != nil {
                // any sort of network failure
                completion(.failure(.requestFailed))
            } else {
                // this ought not to be possible, yet here we are
                completion(.failure(.unknown))
            }
        }
    }.resume()
}
复制代码

讲完四个版本的函数费了不少篇幅,之所以一步一步解释的原因在于需要理解消化的内容着实不少。 最后的代码实现了一个更清爽的 API,借助它我们可以确保要么得到字符串,要么得到某个错误 —— 不可能同时得到两者或两者都得不到,这正是 Result 的特点。更棒的是,我们得到错误的话,必定是 NetworkError 的某一条 case,这使得错误处理更加容易。

目前为止我们实现了使用 Result 的函数,但还没有编写处理 Result 的函数。无论何种情况,Result 总是携带两部分的信息:结果的类型(成功或者失败),以及内部包含的东西。对于我们而言,这东西就是字符串或者某个 NetworkError

在幕后,Result 实际上是一个有关联值的枚举,Swift 对此提供了特别的语法:我们可以对 Result 使用 switch,编写像 .success(let str) 这样的代码来表示 “如果成功,取出字符串放进一个叫 str 的新常量中。” 这样的意思。

在实例中更容易明白我的意思,就让我们在文本视图的 onAppear 闭包里处理所有可能的情况:

Text("Hello, World!")
    .onAppear {
        self.fetchData(from: "https://www.apple.com") { result in
            switch result {
            case .success(let str):
                print(str)
            case .failure(let error):
                switch error {
                case .badURL:
                    print("Bad URL")
                case .requestFailed:
                    print("Network problems")
                case .unknown:
                    print("Unknown error")
                }
            }
        }
    }
复制代码

希望你能发现这么做的益处:我们不仅消除了对于返回的数据做检查的不确定因素,也完全消除了可选性。对于错误处理,甚至不再需要 default 的 case,因为 NetworkError 的所有 case 都会被覆盖到。


我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~

Swift花园微信公众号