Swift 中的 Async/Await ——代碼實(shí)例詳解
?前言
async-await 是在 WWDC 2021 期間的 Swift 5.5 中的結(jié)構(gòu)化并發(fā)變化的一部分。Swift 中的并發(fā)性意味著允許多段代碼同時(shí)運(yùn)行。這是一個(gè)非常簡(jiǎn)化的描述,但它應(yīng)該讓你知道 Swift 中的并發(fā)性對(duì)你的應(yīng)用程序的性能是多么重要。有了新的 async 方法和 await 語句,我們可以定義方法來進(jìn)行異步工作。
你可能讀過 Chris Lattner 的 Swift 并發(fā)性宣言 Swift Concurrency Manifesto by Chris Lattner[1],這是在幾年前發(fā)布的。Swift社區(qū)的許多開發(fā)者對(duì)未來將出現(xiàn)的定義異步代碼的結(jié)構(gòu)化方式感到興奮?,F(xiàn)在它終于來了,我們可以用 async-await 簡(jiǎn)化我們的代碼,使我們的異步代碼更容易閱讀。
什么是 async?
async 是異步的意思,可以看作是一個(gè)明確表示一個(gè)方法是執(zhí)行異步工作的一個(gè)屬性。這樣一個(gè)方法的例子看起來如下:
fetchImages 方法被定義為異步且可以拋出異常,這意味著它正在執(zhí)行一個(gè)可失敗的異步作業(yè)。如果一切順利,該方法將返回一組圖像,如果出現(xiàn)問題,則拋出錯(cuò)誤。
async 如何取代完成回調(diào)閉包
async 方法取代了經(jīng)??吹降耐瓿苫卣{(diào)。完成回調(diào)在 Swift 中很常見,用于從異步任務(wù)中返回,通常與一個(gè)結(jié)果類型的參數(shù)相結(jié)合。上述方法一般會(huì)被寫成這樣:
在如今的 Swift 版本中,使用完成閉包來定義方法仍然是可行的,但它有一些缺點(diǎn),async 卻剛好可以解決。
你必須確保自己在每個(gè)可能的退出方法中調(diào)用完成閉包。如果不這樣做,可能會(huì)導(dǎo)致應(yīng)用程序無休止地等待一個(gè)結(jié)果。
閉包代碼比較難閱讀。與結(jié)構(gòu)化并發(fā)相比,對(duì)執(zhí)行順序的推理并不那么容易。
需要使用弱引用weak references 來避免循環(huán)引用。
實(shí)現(xiàn)者需要對(duì)結(jié)果進(jìn)行切換以獲得結(jié)果。無法從實(shí)現(xiàn)層面使用try catch 語句。
這些缺點(diǎn)是基于使用相對(duì)較新的 Result 枚舉的閉包版本。很可能很多項(xiàng)目仍然在使用完成回調(diào),而沒有使用這個(gè)枚舉:
像這樣定義一個(gè)方法使我們很難推理出調(diào)用者一方的結(jié)果。value? 和 error 都是可選的,這要求我們?cè)谌魏吻闆r下都要進(jìn)行解包。對(duì)這些可選項(xiàng)解包會(huì)導(dǎo)致更多的代碼混亂,這對(duì)提高可讀性沒有幫助。
什么是 await?
await 是用于調(diào)用異步方法的關(guān)鍵字。你可以把它們 (async-await) 看作是 Swift 中最好的朋友,因?yàn)橐粋€(gè)永遠(yuǎn)不會(huì)離開另一個(gè),你基本上可以這樣說:
"Await 正在等待來自他的伙伴 async 的回調(diào)"
盡管這聽起來很幼稚,但這并不是騙人的! 我們可以通過調(diào)用我們先前定義的異步方法 fetchImages 方法來看一個(gè)例子:
也許你很難相信,但上面的代碼例子是在執(zhí)行一個(gè)異步任務(wù)。使用 await? 關(guān)鍵字,我們告訴我們的程序等待 fetchImages 方法的結(jié)果,只有在結(jié)果到達(dá)后才繼續(xù)。這可能是一個(gè)圖像集合,也可能是一個(gè)在獲取圖像時(shí)出了什么問題的錯(cuò)誤。
什么是結(jié)構(gòu)化并發(fā)?
使用 async-await 方法調(diào)用的結(jié)構(gòu)化并發(fā)使得執(zhí)行順序的推理更加容易。方法是線性執(zhí)行的,不用像閉包那樣來回走動(dòng)。
為了更好地解釋這一點(diǎn),我們可以看看在結(jié)構(gòu)化并發(fā)到來之前,我們?nèi)绾握{(diào)用上述代碼示例:
正如你所看到的,調(diào)用方法在獲取圖像之前結(jié)束。最終,我們收到了一個(gè)結(jié)果,然后我們回到了完成回調(diào)的流程中。這是一個(gè)非結(jié)構(gòu)化的執(zhí)行順序,可能很難遵循。如果我們?cè)谕瓿苫卣{(diào)中執(zhí)行另一個(gè)異步方法,毫無疑問這會(huì)增加另一個(gè)閉包回調(diào):
每一個(gè)閉包都會(huì)增加一層縮進(jìn),這使得我們更難理解執(zhí)行的順序。
通過使用 async-await 重寫上述代碼示例,最好地解釋了結(jié)構(gòu)化并發(fā)的作用。
執(zhí)行的順序是線性的,因此,容易理解,容易推理。當(dāng)我們有時(shí)還在執(zhí)行復(fù)雜的異步任務(wù)時(shí),理解異步代碼會(huì)更容易。
調(diào)用異步方法
在一個(gè)不支持并發(fā)的函數(shù)中調(diào)用異步方法
在第一次使用 async-await 時(shí),你可能會(huì)遇到這樣的錯(cuò)誤。
當(dāng)我們?cè)噲D從一個(gè)不支持并發(fā)的同步調(diào)用環(huán)境中調(diào)用一個(gè)異步方法時(shí),就會(huì)出現(xiàn)這個(gè)錯(cuò)誤。我們可以通過將我們的 fetchData 方法也定義為異步來解決這個(gè)錯(cuò)誤:
然而,這將把錯(cuò)誤轉(zhuǎn)移到另一個(gè)地方。相反,我們可以使用 Task.init 方法,從一個(gè)支持并發(fā)的新任務(wù)中調(diào)用異步方法,并將結(jié)果分配給我們視圖模型中的一個(gè)屬性:
使用尾隨閉包的異步方法,我們創(chuàng)建了一個(gè)環(huán)境,在這個(gè)環(huán)境中我們可以調(diào)用異步方法。一旦異步方法被調(diào)用,獲取數(shù)據(jù)的方法就會(huì)返回,之后所有的異步回調(diào)都會(huì)在閉包內(nèi)發(fā)生。
采用 async-await
在一個(gè)現(xiàn)有項(xiàng)目中采用 async-await
當(dāng)在現(xiàn)有項(xiàng)目中采用 async-await 時(shí),你要注意不要一下子破壞所有的代碼。在進(jìn)行這樣的大規(guī)模重構(gòu)時(shí),最好考慮暫時(shí)維護(hù)舊的實(shí)現(xiàn),這樣你就不必在知道新的實(shí)現(xiàn)是否足夠穩(wěn)定之前更新所有的代碼。這與 SDK 中被許多不同的開發(fā)者和項(xiàng)目所使用的廢棄方法類似。
顯然,你沒有義務(wù)這樣做,但它可以使你更容易在你的項(xiàng)目中嘗試使用 async-await。除此之外,Xcode 使重構(gòu)你的代碼變得超級(jí)容易,還提供了一個(gè)選項(xiàng)來創(chuàng)建一個(gè)單獨(dú)的 async 方法:
每個(gè)重構(gòu)方法都有自己的目的,并導(dǎo)致不同的代碼轉(zhuǎn)換。為了更好地理解其工作原理,我們將使用下面的代碼作為重構(gòu)的輸入:
將函數(shù)轉(zhuǎn)換為異步 (Convert Function to Async)
第一個(gè)重構(gòu)選項(xiàng)將 fetchImages 方法轉(zhuǎn)換為異步變量,而不保留非異步變量。如果你不想保留原來的實(shí)現(xiàn),這個(gè)選項(xiàng)將很有用。結(jié)果代碼如下:
添加異步替代方案 (Add Async Alternative)
添加異步替代重構(gòu)選項(xiàng)確保保留舊的實(shí)現(xiàn),但會(huì)添加一個(gè)可用(available) 屬性:
可用屬性對(duì)于了解你需要在哪里更新你的代碼以適應(yīng)新的并發(fā)變量是非常有用的。雖然,Xcode 提供的默認(rèn)實(shí)現(xiàn)并沒有任何警告,因?yàn)樗鼪]有被標(biāo)記為廢棄的。要做到這一點(diǎn),你需要調(diào)整可用標(biāo)記,如下所示:
使用這種重構(gòu)選項(xiàng)的好處是,它允許你逐步適應(yīng)新的結(jié)構(gòu)化并發(fā)變化,而不必一次性轉(zhuǎn)換你的整個(gè)項(xiàng)目。在這之間進(jìn)行構(gòu)建是很有價(jià)值的,這樣你就可以知道你的代碼變化是按預(yù)期工作的。利用舊方法的實(shí)現(xiàn)將得到如下的警告。
你可以在整個(gè)項(xiàng)目中逐步改變你的實(shí)現(xiàn),并使用Xcode中提供的修復(fù)按鈕來自動(dòng)轉(zhuǎn)換你的代碼以利用新的實(shí)現(xiàn)。
添加異步包裝器 (Add Async Wrapper)
最后的重構(gòu)方法將使用最簡(jiǎn)單的轉(zhuǎn)換,因?yàn)樗鼘⒑?jiǎn)單地利用你現(xiàn)有的代碼:
新增加的方法利用了 Swift 中引入的 withCheckedThrowingContinuation? 方法,可以不費(fèi)吹灰之力地轉(zhuǎn)換基于閉包的方法。不拋出的方法可以使用 withCheckedContinuation,其工作原理與此相同,但不支持拋出錯(cuò)誤。
這兩個(gè)方法會(huì)暫停當(dāng)前任務(wù),直到給定的閉包被調(diào)用以觸發(fā) async-await 方法的繼續(xù)。換句話說:你必須確保根據(jù)你自己的基于閉包的方法的回調(diào)來調(diào)用 continuation? 閉包。在我們的例子中,這歸結(jié)為用我們從最初的 fetchImages 回調(diào)返回的結(jié)果值來調(diào)用繼續(xù)。
為你的項(xiàng)目選擇正確的 async-await 重構(gòu)方法
這三個(gè)重構(gòu)選項(xiàng)應(yīng)該足以將你現(xiàn)有的代碼轉(zhuǎn)換為異步的替代品。根據(jù)你的項(xiàng)目規(guī)模和你的重構(gòu)時(shí)間,你可能想選擇一個(gè)不同的重構(gòu)選項(xiàng)。不過,我強(qiáng)烈建議逐步應(yīng)用改變,因?yàn)樗试S你隔離改變的部分,使你更容易測(cè)試你的改變是否如預(yù)期那樣工作。
解決錯(cuò)誤
解決 "Reference to captured parameter ‘self’ in concurrently-executing code "錯(cuò)誤
在使用異步方法時(shí),另一個(gè)常見的錯(cuò)誤是下面這個(gè):
“Reference to captured parameter ‘self’ in concurrently-executing code”
這大致意思是說我們正試圖引用一個(gè)不可變的self實(shí)例。換句話說,你可能是在引用一個(gè)屬性或一個(gè)不可變的實(shí)例,例如,像下面這個(gè)例子中的結(jié)構(gòu)體:
不支持從異步執(zhí)行的代碼中修改不可變的屬性或?qū)嵗?/p>
可以通過使屬性可變或?qū)⒔Y(jié)構(gòu)體更改為引用類型(如類)來修復(fù)此錯(cuò)誤。
枚舉的終點(diǎn)
async-await 將是Result枚舉的終點(diǎn)嗎?
我們已經(jīng)看到,異步方法取代了利用閉包回調(diào)的異步方法。我們可以問自己,這是否會(huì)是 Swift 中 Result 枚舉[2]的終點(diǎn)。最終我們會(huì)發(fā)現(xiàn),我們真的不再需要它們了,因?yàn)槲覀兛梢岳?try-catch 語句與 async-await 相結(jié)合。
Result 枚舉不會(huì)很快消失,因?yàn)樗匀辉谡麄€(gè) Swift 項(xiàng)目的許多地方被使用。然而,一旦 async-await 的采用率越來越高,我就不會(huì)驚訝地看到它被廢棄。就我個(gè)人而言,除了完成回調(diào),我沒有在其他地方使用結(jié)果枚舉。一旦我完全使用 async-await,我就不會(huì)再使用這個(gè)枚舉了。
結(jié)論
Swift 中的 async-await 允許結(jié)構(gòu)化并發(fā),這將提高復(fù)雜異步代碼的可讀性。不再需要完成閉包,而在彼此之后調(diào)用多個(gè)異步方法的可讀性也大大增強(qiáng)。一些新的錯(cuò)誤類型可能會(huì)發(fā)生,通過確保異步方法是從支持并發(fā)的函數(shù)中調(diào)用的,同時(shí)不改變?nèi)魏尾豢勺兊囊?,這些錯(cuò)誤將可以得到解決。
參考資料
[1]Swift Concurrency Manifesto by Chris Lattner: https://gist.github.com/lattner/31ed37682ef1576b16bca1432ea9f782
[2]Result 枚舉: https://www.avanderlee.com/swift/result-enum-type/