Swift 中的 async/await ——程式碼例項詳解

語言: CN / TW / HK

前言

async-await 是在 WWDC 2021 期間的 Swift 5.5 中的結構化併發變化的一部分。Swift 中的併發性意味著允許多段程式碼同時執行。這是一個非常簡化的描述,但它應該讓你知道 Swift 中的併發性對你的應用程式的效能是多麼重要。有了新的 async 方法和 await 語句,我們可以定義方法來進行非同步工作。

你可能讀過 Chris Lattner 的 Swift 併發性宣言 Swift Concurrency Manifesto by Chris Lattner,這是在幾年前釋出的。Swift社群的許多開發者對未來將出現的定義非同步程式碼的結構化方式感到興奮。現在它終於來了,我們可以用 async-await 簡化我們的程式碼,使我們的非同步程式碼更容易閱讀。

什麼是 async?

async 是非同步的意思,可以看作是一個明確表示一個方法是執行非同步工作的一個屬性。這樣一個方法的例子看起來如下:

swift func fetchImages() async throws -> [UIImage] { // .. 執行資料請求 }

fetchImages 方法被定義為非同步且可以丟擲異常,這意味著它正在執行一個可失敗的非同步作業。如果一切順利,該方法將返回一組影象,如果出現問題,則丟擲錯誤。

async 如何取代完成回撥閉包

async 方法取代了經常看到的完成回撥。完成回撥在 Swift 中很常見,用於從非同步任務中返回,通常與一個結果型別的引數相結合。上述方法一般會被寫成這樣:

swift func fetchImages(completion: (Result<[UIImage], Error>) -> Void) { // .. 執行資料請求 }

在如今的 Swift 版本中,使用完成閉包來定義方法仍然是可行的,但它有一些缺點,async 卻剛好可以解決。

  • 你必須確保自己在每個可能的退出方法中呼叫完成閉包。如果不這樣做,可能會導致應用程式無休止地等待一個結果。
  • 閉包程式碼比較難閱讀。與結構化併發相比,對執行順序的推理並不那麼容易。
  • 需要使用弱引用 weak references 來避免迴圈引用。
  • 實現者需要對結果進行切換以獲得結果。無法從實現層面使用 try catch 語句。

這些缺點是基於使用相對較新的 Result 列舉的閉包版本。很可能很多專案仍然在使用完成回撥,而沒有使用這個列舉:

swift func fetchImages(completion: ([UIImage]?, Error?) -> Void) { // .. 執行資料請求 }

像這樣定義一個方法使我們很難推理出呼叫者一方的結果。valueerror 都是可選的,這要求我們在任何情況下都要進行解包。對這些可選項解包會導致更多的程式碼混亂,這對提高可讀性沒有幫助。

什麼是 await?

await 是用於呼叫非同步方法的關鍵字。你可以把它們 (async-await) 看作是 Swift 中最好的朋友,因為一個永遠不會離開另一個,你基本上可以這樣說:

"Await 正在等待來自他的夥伴 async 的回撥"

儘管這聽起來很幼稚,但這並不是騙人的! 我們可以通過呼叫我們先前定義的非同步方法 fetchImages 方法來看一個例子:

swift do { let images = try await fetchImages() print("Fetched \(images.count) images.") } catch { print("Fetching images failed with error \(error)") }

也許你很難相信,但上面的程式碼例子是在執行一個非同步任務。使用 await 關鍵字,我們告訴我們的程式等待 fetchImages 方法的結果,只有在結果到達後才繼續。這可能是一個影象集合,也可能是一個在獲取影象時出了什麼問題的錯誤。

什麼是結構化併發?

使用 async-await 方法呼叫的結構化併發使得執行順序的推理更加容易。方法是線性執行的,不用像閉包那樣來回走動。

為了更好地解釋這一點,我們可以看看在結構化併發到來之前,我們如何呼叫上述程式碼示例:

swift // 1. 呼叫這個方法 fetchImages { result in // 3. 非同步方法內容返回 switch result { case .success(let images): print("Fetched \(images.count) images.") case .failure(let error): print("Fetching images failed with error \(error)") } } // 2. 呼叫方法結束

正如你所看到的,呼叫方法在獲取影象之前結束。最終,我們收到了一個結果,然後我們回到了完成回撥的流程中。這是一個非結構化的執行順序,可能很難遵循。如果我們在完成回撥中執行另一個非同步方法,毫無疑問這會增加另一個閉包回撥:

```swift // 1. 呼叫這個方法 fetchImages { result in // 3. 非同步方法內容返回 switch result { case .success(let images): print("Fetched (images.count) images.")

    // 4. 呼叫 resize 方法
    resizeImages(images) { result in
        // 6. Resize 方法返回
        switch result {
        case .success(let images):
            print("Decoded \(images.count) images.")
        case .failure(let error):
            print("Decoding images failed with error \(error)")
        }
    }
    // 5. 獲圖片方法返回
case .failure(let error):
    print("Fetching images failed with error \(error)")
}

} // 2. 呼叫方法結束 ```

每一個閉包都會增加一層縮排,這使得我們更難理解執行的順序。

通過使用 async-await 重寫上述程式碼示例,最好地解釋了結構化併發的作用。

```swift do { // 1. 呼叫這個方法 let images = try await fetchImages() // 2.獲圖片方法返回

// 3. 呼叫 resize 方法
let resizedImages = try await resizeImages(images)
// 4.Resize 方法返回

print("Fetched \(images.count) images.")

} catch { print("Fetching images failed with error (error)") } // 5. 呼叫方法結束 ```

執行的順序是線性的,因此,容易理解,容易推理。當我們有時還在執行復雜的非同步任務時,理解非同步程式碼會更容易。

呼叫非同步方法

在一個不支援併發的函式中呼叫非同步方法

在第一次使用 async-await 時,你可能會遇到這樣的錯誤。

當我們試圖從一個不支援併發的同步呼叫環境中呼叫一個非同步方法時,就會出現這個錯誤。我們可以通過將我們的 fetchData 方法也定義為非同步來解決這個錯誤:

swift func fetchData() async { do { try await fetchImages() } catch { // .. handle error } }

然而,這將把錯誤轉移到另一個地方。相反,我們可以使用 Task.init 方法,從一個支援併發的新任務中呼叫非同步方法,並將結果分配給我們檢視模型中的一個屬性:

```swift final class ContentViewModel: ObservableObject {

@Published var images: [UIImage] = []

func fetchData() {
    Task.init {
        do {
            self.images = try await fetchImages()
        } catch {
            // .. handle error
        }
    }
}

} ```

使用尾隨閉包的非同步方法,我們建立了一個環境,在這個環境中我們可以呼叫非同步方法。一旦非同步方法被呼叫,獲取資料的方法就會返回,之後所有的非同步回撥都會在閉包內發生。

採用 async-await

在一個現有專案中採用 async-await

當在現有專案中採用 async-await 時,你要注意不要一下子破壞所有的程式碼。在進行這樣的大規模重構時,最好考慮暫時維護舊的實現,這樣你就不必在知道新的實現是否足夠穩定之前更新所有的程式碼。這與 SDK 中被許多不同的開發者和專案所使用的廢棄方法類似。

顯然,你沒有義務這樣做,但它可以使你更容易在你的專案中嘗試使用 async-await。除此之外,Xcode 使重構你的程式碼變得超級容易,還提供了一個選項來建立一個單獨的 async 方法:

每個重構方法都有自己的目的,並導致不同的程式碼轉換。為了更好地理解其工作原理,我們將使用下面的程式碼作為重構的輸入:

swift struct ImageFetcher { func fetchImages(completion: @escaping (Result<[UIImage], Error>) -> Void) { // .. 執行資料請求 } }

將函式轉換為非同步 (Convert Function to Async)

第一個重構選項將 fetchImages 方法轉換為非同步變數,而不保留非非同步變數。如果你不想保留原來的實現,這個選項將很有用。結果程式碼如下:

swift struct ImageFetcher { func fetchImages() async throws -> [UIImage] { // .. 執行資料請求 } }

新增非同步替代方案 (Add Async Alternative)

新增非同步替代重構選項確保保留舊的實現,但會新增一個可用(available) 屬性:

```swift struct ImageFetcher { @available(*, renamed: "fetchImages()") func fetchImages(completion: @escaping (Result<[UIImage], Error>) -> Void) { Task { do { let result = try await fetchImages() completion(.success(result)) } catch { completion(.failure(error)) } } }

func fetchImages() async throws -> [UIImage] {
    // .. 執行資料請求
}

} ```

可用屬性對於瞭解你需要在哪裡更新你的程式碼以適應新的併發變數是非常有用的。雖然,Xcode 提供的預設實現並沒有任何警告,因為它沒有被標記為廢棄的。要做到這一點,你需要調整可用標記,如下所示:

swift @available(*, deprecated, renamed: "fetchImages()")

使用這種重構選項的好處是,它允許你逐步適應新的結構化併發變化,而不必一次性轉換你的整個專案。在這之間進行構建是很有價值的,這樣你就可以知道你的程式碼變化是按預期工作的。利用舊方法的實現將得到如下的警告。

你可以在整個專案中逐步改變你的實現,並使用Xcode中提供的修復按鈕來自動轉換你的程式碼以利用新的實現。

新增非同步包裝器 (Add Async Wrapper)

最後的重構方法將使用最簡單的轉換,因為它將簡單地利用你現有的程式碼:

```swift struct ImageFetcher { @available(*, renamed: "fetchImages()") func fetchImages(completion: @escaping (Result<[UIImage], Error>) -> Void) { // .. 執行資料請求 }

func fetchImages() async throws -> [UIImage] {
    return try await withCheckedThrowingContinuation { continuation in
        fetchImages() { result in
            continuation.resume(with: result)
        }
    }
}

} ```

新增加的方法利用了 Swift 中引入的 withCheckedThrowingContinuation 方法,可以不費吹灰之力地轉換基於閉包的方法。不丟擲的方法可以使用 withCheckedContinuation,其工作原理與此相同,但不支援丟擲錯誤。

這兩個方法會暫停當前任務,直到給定的閉包被呼叫以觸發 async-await 方法的繼續。換句話說:你必須確保根據你自己的基於閉包的方法的回撥來呼叫 continuation 閉包。在我們的例子中,這歸結為用我們從最初的 fetchImages 回撥返回的結果值來呼叫繼續。

為你的專案選擇正確的 async-await 重構方法

這三個重構選項應該足以將你現有的程式碼轉換為非同步的替代品。根據你的專案規模和你的重構時間,你可能想選擇一個不同的重構選項。不過,我強烈建議逐步應用改變,因為它允許你隔離改變的部分,使你更容易測試你的改變是否如預期那樣工作。

解決錯誤

解決 "Reference to captured parameter ‘self’ in concurrently-executing code "錯誤

在使用非同步方法時,另一個常見的錯誤是下面這個:

“Reference to captured parameter ‘self’ in concurrently-executing code”

這大致意思是說我們正試圖引用一個不可變的self例項。換句話說,你可能是在引用一個屬性或一個不可變的例項,例如,像下面這個例子中的結構體:

不支援從非同步執行的程式碼中修改不可變的屬性或例項。

可以通過使屬性可變或將結構體更改為引用型別(如類)來修復此錯誤。

列舉的終點

async-await 將是Result列舉的終點嗎?

我們已經看到,非同步方法取代了利用閉包回撥的非同步方法。我們可以問自己,這是否會是 Swift 中 Result 列舉的終點。最終我們會發現,我們真的不再需要它們了,因為我們可以利用 try-catch 語句與 async-await 相結合。

Result 列舉不會很快消失,因為它仍然在整個 Swift 專案的許多地方被使用。然而,一旦 async-await 的採用率越來越高,我就不會驚訝地看到它被廢棄。就我個人而言,除了完成回撥,我沒有在其他地方使用結果列舉。一旦我完全使用 async-await,我就不會再使用這個枚舉了。

結論

Swift 中的 async-await 允許結構化併發,這將提高複雜非同步程式碼的可讀性。不再需要完成閉包,而在彼此之後呼叫多個非同步方法的可讀性也大大增強。一些新的錯誤型別可能會發生,通過確保非同步方法是從支援併發的函式中呼叫的,同時不改變任何不可變的引用,這些錯誤將可以得到解決。

本文正在參加「金石計劃 . 瓜分6萬現金大獎」