深入解析 Promises 輕鬆控制 Parallel Programming (平行程式設計)


本篇原文(標題: Parallel programming with Swift: Promises )刊登於作者 Medium,由 Jan Olbrich 所著,並授權翻譯及轉載。

並行 (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 的版本)並不一樣。

本篇原文(標題: Parallel programming with Swift: Promises )刊登於作者 Medium,由 Jan Olbrich 所著,並授權翻譯及轉載。
作者簡介:Jan Olbrich,一名 iOS 開發者,專注於質量和持續交付 (Continuous Delivery)。
譯者簡介:HengJay,iOS 初學者,閒暇之餘習慣透過線上 MOOC 資源學習新的技術,喜歡 Swift 平易近人的語法也喜歡狗狗,目前參與生醫領域相關應用的 App 開發,希望分享文章的同時也能持續精進自己的基礎。

LinkedIn: https://www.linkedin.com/in/hengjiewang/
Facebook: https://www.facebook.com/hengjie.wang

此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。

blog comments powered by Disqus
Shares
Share This