本文由loveltyoic(博客)翻譯自raywenderlich,原文:Grand Central Dispatch Tutorial for Swift: Part 1/2
歡迎來到本GCD教程的第二同時也是最終部分!
在第一部分中,你學到了并發,線程以及GCD的工作原理。通過使用dispatch_barrrier和dispatch_sync,你做到了讓PhotoManager單例在讀寫照片時是線程安全的。除此之外,你用到dispatch_after來提示用戶,優化了用戶體驗。還有,使用dispatch_async異步執行CPU密集型任務,從而為視圖控制器初始化過程減負。
如果你跟著教程做,現在可以從第一部分的示例工程繼續。如果你沒有完成第一部分或不想再用你的工程,可以下載第一部分的完成文件。
是時候進一步探索GCD了!
糾正過早出現的彈窗
你可能注意到,當你通過 Le Internet 選項添加照片時,會有提示框在圖片下載完成之前就彈出,如下圖:
錯誤在于 PhotoManager 里的 downloadPhotosWithCompletion:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) { var storedError: NSError! for address in [OverlyAttachedGirlfriendURLString, SuccessKidURLString, LotsOfFacesURLString] { let url = NSURL(string: address) let photo = DownloadPhoto(url: url!) { image, error in if error != nil { storedError = error } } PhotoManager.sharedManager.addPhoto(photo) } if let completion = completion { completion(error: storedError) } } |
這里在方法的最后調用completion閉包——你會想當然的認為所有圖片都下載完了。但不幸的是,在此時無法保證。
DownloadPhoto類的實例方法從一個URL下載圖片并且不等下載完成就立即退出。換言之,downloadPhotosWithCompletion在最后調用completion閉包,就好像其中的所有方法都在順序執行,并且在每個方法完成后才執行下一個。
然而,DownloadPhoto(url:)是異步并且立即返回的——所以目前的方式不能正常工作。
downloadPhotosWithCompletion應該在所有圖片下載任務都完成后再調用自己的completion閉包。問題是:你怎么監視并發的異步事件呢?你不知道它們何時完成,以何種順序。
也許你可以用多個Bool值來追蹤下載情況,但那不容易擴展。而且坦白講,那是很丑陋的代碼。
幸運的是,dispatch groups就是專為監視多個異步任務的完成情況而設計的。
調度組(Dispatch Groups)
調度組在一組任務都完成后會發出通知。這些任務可以是異步或同步的,甚至可以分布在不同的隊列。調度組還可以通過同步或異步的方式來通知。因為任務在不同的隊列中,disptch_group_t實例用來追蹤隊列中的不同任務。
在組內所有事件都完成時,GCD API提供了兩種方式發送通知。
第一種是dispatch_group_wait,它會阻塞當前進程,直到所有任務都完成或是等待超時。這正是我們的例子中需要的方式。
打開 PhotoManager.swift ,替換downloadPhotosWithCompletion:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) { dispatch_async(GlobalUserInitiatedQueue) { // 1 var storedError: NSError! var downloadGroup = dispatch_group_create() // 2 for address in [OverlyAttachedGirlfriendURLString, SuccessKidURLString, LotsOfFacesURLString] { let url = NSURL(string: address) dispatch_group_enter(downloadGroup) // 3 let photo = DownloadPhoto(url: url!) { image, error in if let error = error { storedError = error } dispatch_group_leave(downloadGroup) // 4 } PhotoManager.sharedManager.addPhoto(photo) } dispatch_group_wait(downloadGroup, DISPATCH_TIME_FOREVER) // 5 dispatch_async(GlobalMainQueue) { // 6 if let completion = completion { // 7 completion(error: storedError) } } } } |
逐一來看注釋:
因為使用dispatch_group_wait阻塞了當前進程,要用dispatch_async將整個方法放到后臺隊列,才能保證主線程不被阻塞。
創建一個調度組,作用好比未完成任務的計數器。
dispatch_group_enter通知調度組一個任務已經開始。你必須保證dispatch_group_enter和dispatch_group_leave是成對調用的,否則程序會崩潰。
通知任務已經完成。再一次,這里保持進和出相匹配。
dispatch_group_wait等待所有任務都完成直到超時。如果在任務完成前就超時了,函數會返回一個非零值??梢酝ㄟ^返回值來判斷是否等待超時;不過,這里你用DISPATCH_TIME_FOREVER來表示一直等待。這意味著,它會永遠等待!沒關系,因為圖片總是會下載完的。
此時,你可以保證所有圖片任務都完成或是超時了。接下來在主隊列中加入完成閉包。閉包晚些時候會在主線程中執行。
執行閉包。
運行app,下載幾張圖片,留意你的app是如何表現的。
Note:如果網速太快以至于分辨不出何時執行的閉包,你可以修改設備的設置。在 Setting 中的Developer Section 。打開 Network Link Conditioner,選擇“Very Bad Network”。
如果在模擬器上,用工具變更網速。這是你武器庫中一個很好的工具,它讓你清楚在不佳的網絡下你的app會發生什么。
這個方案目前不錯,但最好能避免阻塞進程。你下一步的工作是重寫這個方法來異步通知下載完成。
在學習下一個調度組的用法前,先看看怎樣在不同的隊列類型下使用調度組。
自定義順序隊列:好選擇。當一組任務完成時用它發送通知。
主隊列(順序):在當前情景下是不錯的選擇。但你要謹慎地在主隊列中使用,因為同步等待所有任務會阻塞主線程。然而,當一個需要較長時間的任務(比如網絡請求)完成時,異步更新UI是很好的選擇。
并發隊列:好選擇。用于調度組和通知。
調度組,再來一次
做的不錯,但是異步調度到另一個隊列然后用 dispatch_group_wait 阻塞還是有一些笨拙。還有另一種方式…
在 PhotoManager.swift 中找到downloadPhotosWithCompletion并替換之:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) { // 1 var storedError: NSError! var downloadGroup = dispatch_group_create() for address in [OverlyAttachedGirlfriendURLString, SuccessKidURLString, LotsOfFacesURLString] { let url = NSURL(string: address) dispatch_group_enter(downloadGroup) let photo = DownloadPhoto(url: url!) { image, error in if let error = error { storedError = error } dispatch_group_leave(downloadGroup) } PhotoManager.sharedManager.addPhoto(photo) } dispatch_group_notify(downloadGroup, GlobalMainQueue) { // 2 if let completion = completion { completion(error: storedError) } } } |
異步方法是如何工作的:
新的實現不需要把方法放進dispatch_async中,因為你并沒有阻塞主線程。
dispatch_group_notify異步執行閉包。當調度組內沒有剩余任務的時候閉包才執行。同樣要指明在哪個隊列中執行閉包。當下,你需要在主隊列中執行閉包。
這是更優雅的方法,并且不會阻塞任何進程。
并發過多帶來的危險
通過支配這些新工具,你應該將每件事都線程化,對嗎?
看看PhotoManager中的downloadPhotosWithCompletion。你會發現通過for循環下載了三張圖片?,F在來看看能否通過并發執行for循環來提速。
是時候請出dispatch_apply了。
dispatch_apply像for循環一樣,只不過它會并發地執行循環過程。這個函數是同步的,所以像普通的for循環一樣,dispatch_apply在所有工作都完成后才返回。
要注意循環的最佳次數,如果有太多循環但每個循環內只有很小的工作量,那么額外的開銷會抹殺掉并發帶來的好處。 步進 (striding)可以幫助到你。它讓你在每次循環中做多件工作。
什么時候用dispatch_apply合適?
自定義順序隊列:在順序隊列中使用dispatch_apply完全無意義;它的效果和for循環一樣。
主隊列(順序):理由同上,用for循環就可以了。
并發隊列:明智之選,尤其是你需要追蹤任務進度時。
替換downloadPhotosWithCompletion如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) { var storedError: NSError! var downloadGroup = dispatch_group_create() let addresses = [OverlyAttachedGirlfriendURLString, SuccessKidURLString, LotsOfFacesURLString] dispatch_apply(UInt(addresses.count), GlobalUserInitiatedQueue) { i in let index = Int(i) let address = addresses[index] let url = NSURL(string: address) dispatch_group_enter(downloadGroup) let photo = DownloadPhoto(url: url!) { image, error in if let error = error { storedError = error } dispatch_group_leave(downloadGroup) } PhotoManager.sharedManager.addPhoto(photo) } dispatch_group_notify(downloadGroup, GlobalMainQueue) { if let completion = completion { completion(error: storedError) } } } |
現在你的循環可以并發執行了;調用 dispatch_apply 時,第一個參數是循環的次數,第二個參數是執行任務的隊列,第三個參數是閉包。
盡管你的代碼在添加圖片時是線程安全的,但是圖片的順序取決于線程完成的順序。
運行app,用 Le Internet 添加一些圖片,發現不同了嗎?
在真機上運行新的代碼會發現 些許 的速度提升。但是這值得嗎?
實際上,在這里并不值得這么做。原因如下:
你很可能因為并行而花費了比for循環更多的開銷。你應該結合合適的步長對 非常大 的集合使用dispatch_apply。
開發app的時間有限——不要花時間過早優化。如果你想優化,那么就優化那些值得優化的東西。用Instruments測試app以找到最耗時間的方法。如何使用Instruments。
一般說來,代碼優化會讓你的代碼變得更復雜。你要確定帶來的好處值得你增加復雜性。
記住,不要癡迷于優化。否則只會讓你自己為難,也讓看你代碼的人抓狂。
取消調度塊
iOS 8 和 OS X Yosemite引入了 調度對象塊 (dispatch block object)。它們實現起來就像對閉包再包裝一層。調度對象塊可以做到很多事情,比如為隊列中的對象設置QoS等級來決定優先級,但最顯著的能力是可以取消塊的執行。要明白對象塊只有在輪到它執行之前才可以取消(一旦開始執行就不能取消了)。
為了說明這個問題,首先用 Le Internet 下載一些圖片,然后取消它們。替換 PhotoManager.swift 中的downloadPhotosWithCompletion:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | func downloadPhotosWithCompletion(completion: BatchPhotoDownloadingCompletionClosure?) { var storedError: NSError! let downloadGroup = dispatch_group_create() var addresses = [OverlyAttachedGirlfriendURLString, SuccessKidURLString, LotsOfFacesURLString] addresses += addresses + addresses // 1 var blocks: [dispatch_block_t] = [] // 2 for i in 0 ..< addresses.count { dispatch_group_enter(downloadGroup) let block = dispatch_block_create(DISPATCH_BLOCK_INHERIT_QOS_CLASS) { // 3 let index = Int(i) let address = addresses[index] let url = NSURL(string: address) let photo = DownloadPhoto(url: url!) { image, error in if let error = error { storedError = error } dispatch_group_leave(downloadGroup) } PhotoManager.sharedManager.addPhoto(photo) } blocks.append(block) dispatch_async(GlobalMainQueue, block) // 4 } for block in blocks[3 ..< blocks.count] { // 5 let cancel = arc4random_uniform(2) // 6 if cancel == 1 { dispatch_block_cancel(block) // 7 dispatch_group_leave(downloadGroup) // 8 } } dispatch_group_notify(downloadGroup, GlobalMainQueue) { if let completion = completion { completion(error: storedError) } } } |
擴展addresses數組,將每個地址復制3份。
這個數組用來保存接下來創建的對象塊。
dispatch_block_create創建一個對象塊。第一個參數是一個表明了塊特征的標志。此處的標志讓塊從它進入的隊列那里繼承QoS等級。第二個參數是閉包形式的塊定義。
塊被異步的調度到全局主隊列。這里用全局主隊列是因為它是一個順序隊列,可以方便我們取消對象塊。當前代碼已經在主線程中執行著,所以你可以保證下載任務將在此之后才執行(也就是這個downloadPhotosWithCompletion返回后才輪到下載任務執行)。
取數組中第三個到結尾的部分。
arc4random_uniform會隨機返回一個0到上界之間(不含上界)的整數。以2為上界會得到0或1,像投硬幣一樣。
如果隨機數是1,則取消塊。前提是,塊還在隊列中并且沒開始。塊在執行的過程中是不可以取消的。
因為所有塊都加入調度組了,不要忘記移除被取消的那些塊。
運行,從 Le Internet 添加圖片。你會看到app下載3張圖片,以及隨機數量的額外圖片。那些沒下載的圖片是因為在加入隊列 后 被取消了。這是一個刻意設計的例子,但是很好的演示了怎樣使用調度對象塊以及如何取消它。
調度對象塊能做更多事情,別忘了查看文檔。
五花八門的GCD趣用
等等!還有更多!下面展示一些常規用途之外的功能。盡管你不會經常使用這些工具,但他們可能在特定情況下非常有用。
測試異步代碼
這聽起來很瘋狂,但是你知道Xcode擁有測試功能嗎?:]我知道,有時我喜歡假裝它不存在,但是編寫和運行測試對構建復雜的代碼很重要。
Xcode中的測試運行在XCTestCase的子類之下,它會運行所有以test開頭的方法。測試跑在主線程下,所以你可以認為測試是順序執行的。
一旦給定的測試方法返回了,XCTest 會認為這個測試完成了而去做下一個測試。這就是說,在下一個測試執行過程中,前一個測試中的異步代碼也在繼續執行。
網路請求通常是異步的,因為你不想阻塞主線程。一旦測試方法返回,測試也就結束了,因此很難對網絡請求做測試。
我們簡單看一下兩種普遍的測試異步代碼的方法:信號量(semaphores)和 期望(expectations)。
信號量
信號量是一個古老學院派的線程概念,它是由謙遜的Edsger W. Dijkstra提出的。信號量是很復雜的話題,因為它建立在錯綜復雜的操作系統函數之上。
如果你想了解更多信號量的知識,查閱細說信號量原理。如果你是學院派,有一個用到了信號量的經典軟件開發問題叫做哲學家進餐問題。
信號量讓你控制多個消費者對有限資源的獲取。例如,如果你創建一個信號量來控制擁有2個資源的資源池,那么同一時刻最多有兩個線程可以進入臨界區。其它也想使用資源的線程必須在FIFO隊列中等待。
打開 GooglyPuffTests.swift 并替換掉 downloadImageURLWithString:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | func downloadImageURLWithString(urlString: String) { let url = NSURL(string: urlString) let semaphore = dispatch_semaphore_create(0) // 1 let photo = DownloadPhoto(url: url!) { image, error in if let error = error { XCTFail( "/(urlString) failed. /(error.localizedDescription)" ) } dispatch_semaphore_signal(semaphore) // 2 } let timeout = dispatch_time(DISPATCH_TIME_NOW, DefaultTimeoutLengthInNanoSeconds) if dispatch_semaphore_wait(semaphore, timeout) != 0 { // 3 XCTFail( "/(urlString) timed out" ) } } |
以上代碼中信號量的工作原理:
1. 創建信號量。參數表明信號量起始值。這個值代表了起始階段可以獲取信號量的線程數目(增加信號量就是發信號,用0做初始值代表當前沒有線程可以獲取信號量)。 2. 在完成閉包中,你告訴信號量不再需要資源。這會使信號量增加,同時給其他等待資源的任務發信號,通知當前信號量可用。
3. 等待信號量并設置超時時間。這個調用會阻塞當前進程直到收到信號。非0返回表示等待已超時。在這種情況下,測試失敗,因為網絡請求不應該超過10秒——相當合理的假設!
(譯者注:說下我的理解:首先創建了信號量,但此時因為信號量是0,沒有線程可以獲取它,注釋3中對信號量的等待會阻塞。只有在圖片下載好了以后,才會發送一個信號量,那么注釋3對信號量的獲取就成功了,并退出等待。但如果圖片下載失敗呢?就不會調用注釋2這句觸發信號的語句,那么注釋3就會等待超時,從而測試失敗。)
PRoduct/Test 或 cmd+U 運行測試。測試應該成功。
斷掉網絡連接并再次測試;如果在真機測試,請開啟飛行模式。如果在模擬器上,直接斷網就好了。測試在10秒后會返回失敗的結果。很好,起作用了!
這是相當微不足道的測試,但是如果你和服務端團隊一起工作,這些基礎測試可以避免一些涉及網絡問題的無端指責。
期望(expectations)
XCTest框架提供了另一種使用 期望 來測試異步代碼的方法。這種特性讓你首先設置你的期望——你希望發生的事——然后再開始異步任務。接下來測試會一直等待,直到異步任務將期望標記為 已完成 。
替換 GooglyPuffTests.swift 中的downloadImageURLWithString:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | func downloadImageURLWithString(urlString: String) { let url = NSURL(string: urlString) let downloadExpectation = expectationWithDescription( "Image downloaded from /(urlString)" ) // 1 let photo = DownloadPhoto(url: url!) { image, error in if let error = error { XCTFail( "/(urlString) failed. /(error.localizedDescription)" ) } downloadExpectation.fulfill() // 2 } waitForExpectationsWithTimeout(10) { // 3 error in if let error = error { XCTFail(error.localizedDescription) } } } |
工作原理:
1. 用expectationWithDescription生成期望。測試會在日志上顯示其中的字符串參數,所以請描述你期望發生的事。
2. 在異步執行的閉包中調用fulfill來標記期望已達成。
3. 調用線程用waitForExpectationsWithTimeout等待期望達成。如果等待超時會視為出錯。
運行測試。結果和使用信號量沒什么不同,但使用XCTest框架是更清晰易讀的方案。
調度源(Dispatch Sources)
GCD中存在一個特別有趣的特性叫調度源,它是一個包含底層功能的百寶囊,幫助你響應或監控Unix信號,文件描述符(file descriptors),Mach端口,VFS Nodes,以及其他復雜的東西。所有這些都超出了本教程的范圍,但是你可以嘗試著使用一下調度源對象。
第一次使用調度源的用戶可能會迷失其中,所以你首先要理解dispatch_source_create的工作原理。下面是創建它的函數原型:
1 2 3 4 5 | func dispatch_source_create( type: dispatch_source_type_t, handle: UInt, mask: UInt, queue: dispatch_queue_t!) -> dispatch_source_t! |
第一個參數type: dispatch_source_type_t是最重要的參數,因為它描述了句柄(handle)和掩碼(mask)參數。你需要查看Xcode文檔來弄清楚dispatch_source_type_t的參數有哪些可選項。
這里你會監視DISPATCH_SOURCE_TYPE_SIGNAL。如文檔所述:
調度源監控當前進程的信號。句柄(handle)是信號數字(int)。掩碼(mask)沒用到(傳0)。
Unix信號列表可以從signal.h找到。在頂部有一串#define。在這些信號列表中,你將要監控SIGSTOP信號。這個信號會在進程接收到不可抗拒的掛起指令時被發送。這個信號與你用LLDB debugger調試程序時發送的信號相同。
進入 PhotoCollectionViewController.swift ,在viewDidLoad附近添加下面的代碼。你需要為類添加兩個私有屬性,并在viewDidLoad的開始處添加段代碼,在調用superclass和ALAssetLibrary之間:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | #if DEBUG private var signalSource: dispatch_source_t! private var signalOnceToken = dispatch_once_t() #endif override func viewDidLoad() { super .viewDidLoad() #if DEBUG // 1 dispatch_once(&signalOnceToken) { // 2 let queue = dispatch_get_main_queue() self.signalSource = dispatch_source_create(DISPATCH_SOURCE_TYPE_SIGNAL, UInt(SIGSTOP), 0, queue) // 3 if let source = self.signalSource { // 4 dispatch_source_set_event_handler(source) { // 5 NSLog( "Hi, I am: /(self.description)" ) } dispatch_resume(source) // 6 } } #endif // The other stuff } |
這段代碼有點難懂,因此逐個注釋來講解:
1. 最好只在DEBUG模式下編譯這段代碼,因為這可能讓不懷好意者洞見很多信息。:] 在 Project Settings –> Build Settings –> Swift Compiler – Custom Flags –> Other Swift Flags –> Debug 下添加 -D DEBUG 。
2. 用dispatch_once一次性初始化調度源。
3. 初始化signalSource變量。你指明對信號感興趣并且提供SIGSTOP做第二個參數。除此之外,你用主隊列處理接收到的事件——稍后你會發現為什么。
4. 如果參數錯誤,調度源對象不會被創建。因此,你應該在使用它之前確保調度源是有效的。
5. dispatch_source_set_event_handler注冊了一個事件處理閉包,當你接收到監控的信號時會調用這個閉包。
6. 默認情況下,所有調度源在開始都處于掛起狀態。當你想監視事件時,必須讓源對象繼續執行。
運行app;暫停調試器然后立即恢復。檢查控制臺(console),你會看到類似下面的信息:
1 | 2014-08-12 12:24:00.514 GooglyPuff[24985:5481978] Hi, I am: |
你的app現在可以感知到調試(debugging-aware)了!這真棒,但在現實中怎樣用它呢?
你可以用它調試一個對象并在恢復app時展示數據;你也可以自定義一些安全邏輯來保護app,當惡意攻擊者在你的程序上附著調試器的時候。
有趣的想法是把這個方法當做堆棧追蹤工具,來找到你想要在調試器中修改的對象。
設想一下這樣的場景。當你意外地停掉調試器時,你很難處在期望的棧幀上。而現在你可以在任意時刻停止調試器并讓代碼執行到你期望的位置。這很有用,當你想執行一段從調試器很難達到的代碼。試一試!
在viewDidLoad中的NSLog語句處設置斷點。暫停調試器,然后再開始;app會命中你剛剛設置的斷點。現在你已經深入到PhotoCollectionViewController方法中了?,F在你可以隨心所欲地使用PhotoCollectionViewController實例了。多么便捷!
注意:如果在調試器中你不知道哪個線程是哪個,來看一下。主線程總是第一個,libdispatch,GCD的協調器是第二個。剩下的線程要看硬件當時在做什么樣的工作。
在調試器中,輸入:
1 | po self.navigationItem.prompt = "WOOT!" |
然后繼續執行app。你會看到如下所示:
通過這個方法,你可以更新UI,探查類的屬性,甚至執行方法——無需重啟app來進入特定的工作流狀態。很巧妙。
下一步?
我不想重提,但是你真的應該看一下怎樣使用Instruments。如果你想優化app,絕對需要這個。Instruments可以概述程序中哪些代碼相對其它代碼執行更久。如果你想知道代碼實際的執行時間,很可能需要一些自制的解決方案。
同時學習如何在Swift中使用NSOperations和NSOperationQueue,一種基于GCD的并發技術。實際上,這是使用GCD的最佳實踐。NSOperations提供更好的控制,處理最多的并發操作,在犧牲一定速度的情況下更加面向對象。
記住,除非你有特別的理由深入底層,你應該始終嘗試并堅持使用更高層的API。只在你想學習更多或做一些非常非常“有趣”的事時才進入到Apple的“暗黑藝術”(dark art)中探險。:]
祝你好運,盡情歡樂!
新聞熱點
疑難解答