並行 (concurrency) 的概念與我們日常開發工作越來越息息相關。在上兩篇文章中(Swift 平行程式設計:基礎 (Basic)、操作 (Operations)),我們已經探討過 Apple 所提供的工具。這次,我們將重點放在非官方所支援的工具上。
讓我們回顧一下:
並行 (concurrency) 是指在同一時間執行工作的能力。
想像一下,現在有一個花費時間較長的任務,它需要執行一段時間,而任務結束後我們可以得到一個結果;比如說下載檔案這樣,而圖檔就是我們的結果。在下載的同時,我們要將圖檔設置到 UIImageView 之中,那要如何實作呢?
最簡單的方法就是透過 NSURLSession 來下載檔案,並且給予一個閉包 (closure)。
URLSession.shared.dataTask(with: url!) { data, _, error in
if error != nil {
print(error)
return
}
DispatchQueue.main.async {
imageView.image = UIImage(data: data!)
}
}.resume()
我們都知道該怎樣做了,所以這部分應該沒有問題。不過如果情況複雜一點呢?假如我們沒有檔案連結,而需要從我們的後端來提交請求。這樣,我們就要先請求連結,然後將回應解析成為我們預期的格式,最後請求圖像並進行設置。
URLSession.shared.dataTask(with: backendurl!) { data, _, error in
if error != nil {
print(error)
return
}
let imageurl = String(data: data!, encoding: String.Encoding.utf8) as String! // For simplicity response is only the URL
URLSession.shared.dataTask(with: URL(string:imageurl!)!) { (data, response, error) in
if error != nil {
print(error)
return
}
DispatchQueue.main.async {
imageView.image = UIImage(data: data!)
}
}.resume()
}.resume()
這樣一來,像這麼簡單的範例也會變得越來越複雜,最後墮入回呼地獄 (Callback-Hell) 之中。
我們可以創建一個委派 (delegate) 來處理這種情況;但若請求多於兩個,事情就會變得很難處理。
public class ApiRequestDelegate:NSObject, URLSessionDelegate, URLSessionTaskDelegate,
URLSessionDataDelegate{
// called once as soon as a response returns
public func urlSession(session: URLSession, dataTask: URLSessionDataTask,
didReceiveResponse response: URLResponse,
completionHandler: (URLSession.ResponseDisposition) -> Void) {
// store Response to further process it and call completion Handler to continue
}
// called when finished
public func urlSession(session: URLSession, task: URLSessionTask,
didCompleteWithError error: NSError?) {
// handle errors and e.g. call a completion handler so you can continue with your tasks or start a different request
}
// called if data is not returned in one block
public func urlSession(_: URLSession, dataTask: URLSessionDataTask,
didReceive data: NSData) {
}
}
接著看看 Operations,我們將這些任務分拆,並透過以下這種方式編寫。
class NetworkOperation: Operation {
var targetURL: String?
var resultData: Data?
var _finished = false
override var isFinished: Bool {
get {
return _finished
}
set (newValue) {
willChangeValue(for: \.isFinished)
_finished = newValue
didChangeValue(for: \.isFinished)
}
}
convenience init(url: String) {
self.init()
targetURL = url
}
override func start() {
if isCancelled {
isFinished = true
return
}
guard let url = URL(string: targetURL!) else {
fatalError("Failed URL")
}
URLSession.shared.dataTask(with: url) { data, _, error in
if error != nil {
print (error)
return
}
self.resultData = data
self.isFinished = true
}.resume
}
}
let operationQueue = //...
let backendOperation = NetworkOperation(url: <Backend-URL>)
let imageOperation = NetworkOperation()
let adapter = BlockOperation() { [unowned backendOperation, unowned imageOperation] in
imageOperation.targetUrl = backendOperation.data // let's imagine this is already converted to string
}
backendOperation => adapter => imageOperation => adapter2 => setImageOperation // we skip the setImage part, as you can see the gist of it
但是如你所見,這方式會讓範例邏輯變得更難理解。那有沒有其他更好的方法呢?
Promise
其實有一個方法,可以創建包含未來值的變數,藉此達到我們的目的。它不需要包含當下的值,但到了某個時候,它將會擁有一個值,讓我們在它以上執行程式碼。
這就是 Promise:一種在某個時間點提供值的保證。有了 Promise,你編寫程式碼時,變數將在某個時間點包含一個值或一個錯誤。而它只有在履行承諾後,才會被執行。
組合 (Composition) 及創建 (Creation)
一個 Promise 是由一個閉包及兩個回呼函式 (Callback) 所組成,你可以呼叫 fulfill()
及相對應的值來履行 Promise;你也可以使用錯誤來回絕 Promise。
Promise() { fulfill, reject in
// code
}
要創建一個 Promise,你只需要打包用來創造值的程式碼。
Promise() { fulfill, reject in
return "Hello World" //or other async code
}
用法
Promise 通常可以分為幾個部分來看:Promise 本身、順利執行的程式碼、以及發生錯誤情況的程式碼。
fetchPromise().then { value in
// do something
}.catch { error in {
// in case of error
}
要是沒有程式碼回應 promise 的值,promise 就不會執行任何程式碼。我們使用 .then()
,就可以加入要執行的程式碼;但這還沒有決定要執行的時間點,這只代表著 promise 可以隨時執行程式碼。
由於我們經常需要創解一連串的程式碼,我們不但可以回傳數值,也可以回傳 promise。
fetchPromise().then { value in
return fetchPromise2(value)
}.then { value in
// do something
}.catch { error in
// error
}
一旦在回應的傳遞鏈中發生錯誤,執行流程就會跳到 catch 敘述句,並不會繼續執行剩下的 then()
閉包。
fetchPromise().then { value in
return errorPromise(value) // this will throw an error
}.then { value in
//this will not execute on error
}.catch { error in
//we got an error
}
PromiseKit
Promise 有許多相關的函式庫可以使用。就在不久之前,Google 也發佈了他們自己的版本。我將會使用 PromiseKit,因為它相當成熟,而且已經推出多年,以我自己的經驗來說,他們對問題的反應非常快,可能只需要幾個小時。此外,PromiseKit 還提供了許多額外的 iOS 擴展 (extension),讓你用起來過程更輕鬆。不過由於 Swift 及他們對 promise 的解釋,它確實會包含一些特殊情況。無論如何,讓我們研究一下如何使用它。
安裝
幾乎所有方法都支援 PromiseKit 的安裝。你可以透過 Cocoapods 來安裝:
use_frameworks!
target "target" do
pod "PromiseKit", "~> 6.0"
end
也可以用 Carthage:
github "mxcl/PromiseKit" ~> 6.0
或是 SwiftPM:
package.dependencies.append(
.Package(url: "https://github.com/mxcl/PromiseKit", majorVersion: 6)
)
另外,你也可以進行手動安裝,就看你喜歡哪種方式! 一旦安裝完成後,我們就能準備開始了!
創建 Promise
如同先前所述,一個 Promise 是由一個閉包及兩個回呼函式所組成的,一個回呼函式用來履行 promise、而另一個就是用來回絕的。不過在 PromisKit 中,就有一點不一樣了。我們有一個封裝的物件,它擁有多個方法,包含了 fulfill(結果)以及 reject(錯誤)。同時它也包含了 resolve(結果、物件),讓它自動判斷目前 promise 的狀態如何。
要從後端請求一張圖像,我們可以這樣寫程式碼:
func fetch(url: URL) -> Promise<Data> {
return Promise { seal in
URLSession.shared.dataTask(with: url!) { data, _, error in
seal.resolve(data, error)
}.resume()
}
}
如你所見,promise 總可以選擇失敗;但我們也有不會失敗的情況(像是回傳一個靜態文本)。對於這種情況,PromiseKit 提供了一個獨特的 Promise ── guarantee:
Guarantee { seal in
seal("Hello World")
}
Promise 傳遞鏈 (chain)
創建完 Promise 之後,現在我們可以透過 then()
來激活它。
fetch(url: <backend-url>).then { value in
// value
}
then()
的回傳值總是與 promise 收到的值不同。如果它的主體只有一行,Swift 會嘗試進行類型推斷。遺憾的是,這多數都起不了作用,而我們會收到一則相當不明確的錯誤訊息:
Cannot invoke ‘then’ with an argument list of type ‘(() -> _)
我們可以指定閉包參數並回傳型別來修正這個錯誤。你的傳遞鏈可以以 done()
來作結,這是傳遞鏈成功部分典型的作結方式,而你也沒辦法回傳 promise。
通常你的傳遞鏈最後一部分會是 catch()
(除了使用 guarantee 的時候),這是用來回應執行期間發生的任何錯誤。假如傳遞鏈中有任何 promise 被回絕,整個傳遞鏈就會停止,並跳到 catch 的閉包之中。在這個閉包中,你可以加入自己的錯誤處理,例如向使用者顯示錯誤訊息等。
一如既往,這些情況也會有例外。有時候你不希望錯誤串疊,並且擁有預設值,你就可以使用 recover()
來完成。
fetch(url: <imageurl>).recover { error -> Promise<UIImage> in
return UIImage(name: <placeholder>)
}...
我們前一個範例從後端獲取 imageuel
,而圖像本身就會像是這樣:
func fetch(url: URL) -> Promise<Data> {
return Promise { seal in
URLSession.shared.dataTask(with: url!) { data, _, error in
seal.resolve(data, error)
}.resume()
}
}
fetch(url: <backendURL>).then { data in
return JSONParsePromise(data) // we skip the wrapping of JSONParsing
}.then { imageurl in
return fetch(url: imageurl)
}.then { data in
imageView.image = UIImage(data: data)
}.catch { error in
// in case we have an error
}
更多傳遞鏈元素
我們已經看過 promise 傳遞鏈的基本元素,現在一起將它們提升到另一個層次吧!我們可以利用 firstly()
這個語法來開始傳遞鏈。
firstly {
return fetch(url:url)
}.then{
...
}
假如我們想要某些程式碼在 promise 的結尾執行,我們可以使用 ensure。
firstly {
fetch(url: url)
}.ensure {
cleanup()
}.catch {
handle(error: $0)
}
回頭看一下我們的範例,我們可以加入網路指示器 (network indicator):
func fetch(url: URL) -> Promise<Data> {
return Promise { seal in
URLSession.shared.dataTask(with: url!) { data, _, error in
seal.resolve(data, error)
}.resume()
}
}
firstly {
UIApplication.shared.isNetworkActivityIndicatorVisible = true
return fetch(url: <backendURL>)
}.then { data in
return JSONParsePromise(data) // we skip the wrapping of JSONParsing
}.then { imageurl in
return fetch(url: imageurl)
}.then { data in
imageView.image = UIImage(data: data)
}.ensure {
UIApplication.shared.isNetworkActivityIndicatorVisible = false
}.catch { error in
// in case we have an error
}
有時候我們想要回傳一些已經收到的 promise,這時候就可以利用 get()
。
firstly {
fetch(url: url)
}.get { data in
//…
}.done { data in
// same data!
}
這在我們需要依照同一結果執行多個指令時特別有用。
等待執行多個非同步指令不是很慢,就是很困難。如果我們以同步的方式執行指令就會很慢,而如果我們試著處理所有不同回呼函式的選項,就會很困難。在 PromiseKit 中,when()
就派上用場了。在 when()
之中,你可以加入所有你想同一時間執行的 promise,它會等待所有 promise 後才繼續執行。
firstly {
when(fulfilled: promise1(), promise2())
}.done { result1, result2 in
//…
}
我們一直在最後幾個 Promise 中使用 done()
而非 then()
,done()
基本上告訴了 promise 傳遞鏈在這裡結束,而且不存在回傳值;而 then()
總是會有一個回傳值。
其實傳遞鏈中還有更多元素,例如:
map()
:它要求你回傳一個物件或是數值型別compactMap
:它要求你回傳一個可選型別,而 Nil 是錯誤
執行緒 (Threading)
如同往常一樣,以非同步方式來執行任務時,我們必須考慮執行緒。讓我們看看執行緒如何與 promise 一起運作:所有 promise 都會在背景執行緒運作,不過傳遞鏈本身(then()
、catch()
、map()
等)會在主執行緒執行。這一點很重要,因為這可能會導致某些未預期行為。試想一下,你將 promise 的回應解析到持久層中,如果回應相當小,那麼在主執行緒上執行應該是沒有問題的;但我們無法保證情況總是如此,較大的回應可能導致幀率 (frame) 下降、甚至螢幕凍結。要解決這個問題,你可以將解析編寫為 promise,也可以手動更改 then()
閉包的執行緒。
fetch(url: url).then(on: .global(QoS: .userInitiated)) {
//then closure executes on different thread
}
這裡我有一個小小的建議,你可以將處理執行緒的部分一併編寫在 promise 之中,這樣的方式比較不會出錯。
特殊模式 (Special Patterns)
為了簡化 promise 的轉換,讓我們來看一下開發者習慣使用的一些模式:
延遲 (Delay)
有時候我們會遇到一種情況,我們想要延遲執行一段特定的時間,這時候就可以使用 after()
。
let waitAtLeast = after(seconds: 0.3)
firstly {
fetch(url: url)
}.then {
waitAtLeast
}.done {
//…
}
暫停 (Timeout)
既然瞭解了 after()
,我們當然也可以創造暫停。當然,我們可以為多種不同情況設定暫停(例如:NSURLSession 中的網路請求),不過有時候我們會想要將它改為較小的值,或是在預設不會暫停的 promise 之中加入暫停。這時,我們就可以使用 race()
來完成。
let timeout = after(seconds: 4)
race(when(fulfilled: fetch(url:url)).asVoid(), timeout).then {
//…
}
不過請注意,race 之中的兩個 promise 都需要回傳同樣的數值。我們可以輕易地使用 .asVoid()
達到這個目的。
重試 (Retry)
在網路請求中,經常會發生連線中斷的情況,那我們就會想要重新嘗試請求,這可以利用 recover()
來完成。
func attempt<T>(maximumRetryCount: Int = 3, delayBeforeRetry: DispatchTimeInterval = .seconds(2), _ body: @escaping () -> Promise<T>) -> Promise<T> {
var attempts = 0
func attempt() -> Promise<T> {
attempts += 1
return body().recover { error -> Promise<T> in
guard attempts < maximumRetryCount else { throw error }
return after(delayBeforeRetry).then(on: nil, attempt)
}
}
return attempt()
}
attempt(maximumRetryCount: 3) {
fetch(url: url)
}.then {
//…
}.catch { _ in
// we still failed
}
委派 (Delegate)
UIKit 中其中一個主要的設計模式就是委派模式 (delegate pattern),我們可以將它打包在 promise 之中,不過請注意,這樣做的結果可能不會完全符合你的要求。Promise 只需要解決一次,所以嘗試包裝 UIButton 就會導致問題。如果你只需要一個回應,你可以通過儲存封裝物件,並將其解析為你的委派來實現。
extension Foo {
static func promise() -> Promise<FooResult> {
return PromiseWithDelegate().promise
}
}
class PromiseWithDelegate: FooDelegate {
let (promise, seal) = Promise<FooResult>.pending()
private let foo = Foo()
init() {
super.init()
retainCycle = self
foo.delegate = self // does not retain hence the `retainCycle` property
promise.ensure {
// ensure we break the retain cycle
self.retainCycle = nil
}
}
func fooSuccess(data: FooResult) {
seal.fulfill(data)
}
func fooError(error: FooError) {
seal.reject(error)
}
}
還有一點值得注意,在你儲存封裝物件的時候,任何時候都只能有一個這樣的請求。
儲存先前的結果
有時候,你會想把先前 promise 的結果拿到下一個 promise 中使用,我們可以利用 Swift 的 tuples 來完成。試想一下,有一個你首次登錄的登錄序列,你想要儲存具有存取權杖 (Access Tokens) 的使用者名稱,你可以這樣做:
login().then { username in
getTokens(for: username).map { ($0, username) }
}.then { tokens, username in
//…
}
分支鏈 (Branched Chains)
Promise 其中一個有趣的部分,就是它是單次執行的,如果你將兩個不同的鏈加入到 promise 之中,它只會執行一次,不過你在這次執行中可以有兩個不同的流程(這也包括了 catch()
):
let p1 = promise1.then {
...
}
let p2 = promise2.then {
...
}
when(fulfilled: p1, p2).catch { error in
...
}
取消 (Cancellation)
Promise 與 Operations 的不同,是 promise 並沒有內建取消的機制,想要有這個功能,我們必須自己實現它:
func foo() -> (Promise<Void>, cancel: () -> Void) {
let task = Task(…)
var cancelme = false
let promise = Promise<Void> { seal in
task.completion = { value in
guard !cancelme else { reject(NSError.cancelledError) }
seal.fulfill(value)
}
task.start()
}
let cancel = {
cancelme = true
task.cancel()
}
return (promise, cancel)
}
UI
在 ViewController 顯示與打包委派有點相似,不同的是在 ViewController 顯示,你會儲存了 promise 本身。此外,顯示者必須知道如何展示自己本身、及如何解除自己,這可算是一種不幸。不過如果你想要將所有東西打包到 promise 之中,你也可以這樣做:
class ViewController: UIViewController {
private let (promise, seal) = Guarantee<...>.pending()
func show(in: UIViewController) -> Promise<…> {
in.show(self, sender: in)
return promise
}
func done() {
dismiss(animated: true)
seal.fulfill(…)
}
}
// use:
ViewController().show(in: self).done {
...
}.catch { error in
...
}
結論
在這篇文章中,我們深入解析 promise,藉此掌握並行。Promise 有比 operations 更好的優點,但同時也有缺點。有些人會開始爭論 state,他們覺得這在使用 promise 時更佳,但是我覺得這不是使用它們的理由。雖然它們增加了可讀性,並讓並行更容易控制;但我認為它們隱藏的太多了,開發者需要知道他們在做甚麼,而不是希望框架能夠解決他們所有的錯誤。
使用 promise 一段時間之後,我可以告訴你只要瞭解甚麼是 Promise,它可以讓你的程式碼變得更容易;但如果你不瞭解,Promise 還是會比 operations 容易理解。
還有最後一點要注意,因為這不是語言本身的特性,它與其他 promise 的實作(像是ECMA-Script 的版本)並不一樣。