Swift 3學習指南:重新認識GCD應用


CPUs(中央處理器)問世以來,最棒的改革之一就是發展多核心技術,藉此可運行多個執行緒,這代表著,我們可以在同一時間執行多個任務。

依序執行任務(非並行)或是fake multitasking是多年前使用的運行模式,只要你的年紀稍長,應該可以記得過去的老舊電腦,若是你曾經使用過老舊的系統,應該可以輕易的了解到我在說什麼,但是,不管多少個核心的CPU可以帶來多厲害的效能表現,如果開發者不曉得如何使用這些技術,那都是沒有用的,本文就是要介紹多工與多執行緒的編程如何實作,開發者必須在任何裝置上善用CPU多工(multitasking)的優勢,將程式拆分為多個區塊分配在多個執行緒中平行運作。

使用多執行緒的好處很多,最重要的一點就是縮減執行任務所需時間,提供較佳的使用體驗,在運行時不會有停留在某個畫面的延遲感,請想想看,若是有一個應用程式,它將一大串的images都堆放在主執行緒處理,那UI必須等到全部下載任務完成才會有反應,使用者不會想嘗試使用這樣的應用程式。

在iOS當中,蘋果提供兩個方法來實作多工任務:包含Grand Central Dispatch (GCD)以及NSOperationQueue的frameworks,它們都可以幫助開發者達到多個任務分配給多個執行緒或不同queues(佇列)進行同步運行的需求,本文我們將會專注在GCD實作應用上。不管如何,這邊有一個規則必須要遵守:主執行緒必須總是維持在閒置狀態,用於應付使用者操作觸發的介面變化,任何耗時的任務應該跑在concurrent或是background的佇列,這對於新進開發者來說可能難以消化及應用,但它仍是應該去使用的方法。

GCD(Grand Central Dispatch)在iOS4才第一次被介紹出來,當我們嘗試去執行並發任務時,它提供很有彈性的應用,其實,直到Swift 3推出之前,我認為它有一個很大的缺點:就是很難記得GCD的相關語法,過去它的coding style比較像底層的C語言,與Swift甚至是Objective-C的其他coding style有顯著的差異,這也是為何在過去多數開發者盡可能使用NSOperationQueue,避免使用到GCD的原因,但是在最新的Swift版本中,GCD語法將可以給你很好的使用體驗,讀者可以在web簡單查詢看看。

在Swift 3推出後,它有了很顯著的改變,GCD使用起來不一樣了,完全是Swift風格的語法,GCD最新的語法可以讓開發者更容易使用,這樣的改變,也讓我迫不及待想寫這篇文章,與讀者分享GCD在Swift 3之中,所提供最基礎但也最重要的實作應用,如果你過去已經接觸過GCD舊的coding style(即使只有一點點),接下來使用新的語法對你而言將是小事一樁,若是過去沒有碰觸過GCD,接下來就跟著本文一起學習程式的新篇章。

在我們進入本文的特定主題前,先來談一些具體觀念,首先,GCD裡面的關鍵詞彙為dispatch queue,不論是在主執行緒或是背景執行緒上,一個佇列實際上是一個可同步非同步執行的代碼塊,當一個佇列被生成後,作業系統會將把它分配到CPU上的任一核心上,多個佇列同樣會被相對應管理,這部分的工作開發者不需要去處理,佇列會遵循FIFO的模式(先進先出),代表一個佇列先被執行就會先完成工作(可以想像成在櫃檯前排隊等待的人,排在第一位的人就會先被服務,最後一位則會最後被服務),等下我們會透過實例進行更清楚的解析。

再來,另外一個重點觀念是work item,它是與佇列創建時一起編寫的代碼塊,會被分配到佇列裡面且可以被多次使用,完全如你所想:它是dispatch queue將運行的程式碼,在執行時也遵守FIFO規則,可以選擇同步非同步的執行方式,在同步模式中,應用程式運行時在任務執行結束前將不會離開這個item的代碼塊,另外一方面,若是佇列被安排為非同步運行,應用程式將會呼叫對應的work item代碼塊,並於工作完成後回傳,再次提醒,後面的篇幅將透過實例看到兩者的不同。

簡單介紹dispatch queues和work items之後,該是時候更深入介紹佇列,它可以設為serial或是concurrent,在第一個情境下,一個work item必須等待前一個結束後才會開始執行(除非它第一個被排入到佇列),第二個情境則是可以讓各個work item同時運行。

你在把任務配發到應用程式主佇列(main queue)時都必須很小心,它應該要總是維持在閒置可用的狀態,藉以應付使用者操作帶來介面變動需求,順帶一提,每個UI變更的動作都必須要在主執行緒執行,如果你曾經嘗試在背景執行緒(background thread)更新你的UI,會發現畫面更新的動作無法保證會在何時啟動,這將會讓你得到不愉快的體驗,然而,在UI創立或更新前必須先完成的工作,絕對要在背景進行作業,舉例來說,你可以在背景執行緒進行圖片下載工作,但是要將更新image view的工作放在主執行緒進行。

請記住,我們不總是需要去建立自己的佇列,系統會建立global dispatch queues供你指定的任務使用,至於佇列會在哪一個執行緒運行,iOS設有一個a pool of threads(執行緒池),意指除主執行緒之外的執行緒集合,系統會挑選一個以上的執行緒來使用(依照你建立的佇列數量,以及建立方式),哪一個執行緒會被使用並不是由開發者定義,操作系統會依據並發任務的數量,處理器上的負載等事項來決定,但是說實話,誰想處理上述所有工作呢?

我們的測試環境

本文中,接下來我們將會使用的幾個小範例來介紹GCD觀念,一般來說,我們只要使用Xcode Playground即可達到示範工作,不需要特別實作一個demo app,但是在使用GCD時無法如願,因為在Playgrounds裡面無法從不同的執行緒中呼叫函式,儘管我們的一些範例可以在這上面運行,但不是全部,所以本文仍將透過一個專案實作來克服潛藏的問題,你可以在這裡下載並且打開它。

這個專案幾乎是空的,除了下列兩個檔案以外:

  1. ViewController.swift檔案中看將看到一串函式被定義,但是尚未被實作。每一個都會帶領我們認識GCD的新特性,你要做的事情就是在viewDidAppear(_:)裡面將註解符號拿掉,讓這些函式被喚醒。
  2. Main.storyboard裡面的ViewController畫面中,你將發現一個image view被加入,而且對應的IBOutlet已經連結到ViewController類別中。
    晚一點我們將會需要那個image view供一個真實案例使用。

現在讓我們開始吧。

認識 Dispatch Queues

在Swift 3裡面,建立一個新的dispatch queue最簡單的方式如下:

你唯一要做的就是提供一個唯一的label給這個佇列,反向的DNS符號(com.appcoda.myqueue)很容易建立出獨一無二的labels,甚至連Apple也建議這樣的寫法,儘管如此,但它並不是強制性的,你可以使用任何你想要的字串,只是必須維持它的唯一性,除此之外,上述程式碼不只是佇列的初始化作業,你也可以提供更多參數在初始化動作中,我們將會在之後篇幅談論到它。

佇列一旦被建立後,我們就可以透過程式碼使用它,同步則使用sync函式,若非同步則呼叫async函式,當我們開始時,先提供一段code當做一個block(closure),接著,我們將初始化它,並使用dispatch work items的物件 (DispatchWorkItem)去取代block(請注意,在佇列裡面block也被當作是一個work item),我們將以同步的方式開始執行,要做的只是列出0~9的數字:

code-snippet-1

使用紅點可以讓我們在console內較容易識別,尤其是當我們添加更多的佇列或任務去執行時。

將上圖內的程式碼複製並貼在ViewController.swift檔案的simpleQueues()函式內,確認這個函式在viewDidAppear(_:)裡面沒有被註解掉,接者把這個專案跑起來,請看一下Xcode console,發現結果並無法讓我們對GCD的運作做出任何結論,所以請更新simpleQueues()函式內的程式碼,在佇列的closure後面加入另一個代碼塊,它用來呈現100~109數字(僅用於區別數字不同)。

上述的forloop迴圈將會在主佇列被執行,第一個迴圈則將在背景運行,程式執行的動作會在佇列的block中停止,且直到佇列任務完成前,它將不會繼續主執行緒迴圈,無法呈現100到109的數字,這是因為我們使用同步執行,讀者可以在console中看到輸出結果:

Operation Queue Sync

但是如果我們使用async函式運行會發生什麼事情呢?請將佇列裡的程式塊以非同步方式執行,這個案例中,程式不會先等到佇列任務完成才進行下一步,它將立即返回到主執行緒,而第二個for loop迴圈也將與佇列迴圈同時執行,在我們看看會發生什麼事之前,先將佇列改為使用async執行。

code-snippet-async

現在,將專案行起來,並且看看Xcode的console輸出結果:

t57_2_sample2_queue_async

相較於同步執行,這個範例看起來有趣多了,你可以看到程式碼在主佇列(第二個for loop迴圈)以及dispatch queue裡面的程式碼是同時運行的,這個自行定義佇列實際上花費較多運行時間,但這就是優先處理事項的排列關係(接下來將會看到),這裡主要想強調的是,當我們另一個任務在背景執行時,主佇列是閒置隨時準備使用的狀態,這個運作模式不會出現在同步執行的佇列。

即使上述的範例相當簡單,但已經很清楚的展示了一個程式的佇列在同步以及非同步情況下的運作方式,我們將在接下來的範例繼續保持富有色彩的console輸出效果,請記住,特定顏色代表特定的佇列內部程式碼運行結果,不同顏色就代表不同的佇列。

Quality Of Service (QoS) and Priorities

在使用GCD與dispatch queues時,我們經常會需要告訴系統,你的應用程式中哪個任務比較重要,需要優先去執行,當然,由於主佇列都是用來處理UI接收到的指令,所以跑在主執行緒的任務是最優先執行的,在任何情況下,都必須根據自身需求提供佇列執行的優先順序以及其他需要的資訊(例如在CPU上的執行時間)給系統,雖然所有的任務最終都將完成。然而,區別在於哪些任務會更快完成,哪些任務會較晚完成。

關於任務的重要性及優先順序的資訊在GCD稱為Quality of Service (QoS),QoS是一個包含特定情境的enum,根據你想要的優先順序,提供一個適當的QoS值在佇列初始化作業,如果沒有特別定義,佇列則預設為 default priority,可使用的相關選項介紹可參考這裡, 下列則整理QoS可選擇的項目,它們被稱做QoS classes,第一個class代表最高順位,最後一個則代表最低順位。

  • userInteractive
  • userInitiated
  • default
  • utility
  • background
  • unspecified

現在回到專案中,我們將使用queuesWithQoS()進行作業,先宣告並初始化下列兩個新的dispatch queues:>

注意,這邊對它們設定相同的QoS class,所以在執行時的優先順序是相同的,就像我之前做的,第一個佇列將包含一個for loop 迴圈,用來呈現0~9的數字(外加一個紅點),而在第二個佇列我們將執行另一個for loop迴圈,展示100~109數字(附加一個藍點)。

code-snippet-2

看到執行結果可以知道它們佇列被設定相同的優先順序(QoS class相同)-不要忘記在viewDidAppear(_:)裡將queuesWithQos()的註解取消(uncomment):

t57_3_sample3_qos_same

很輕易的可以看到上面截圖的任務被”均勻” 的執行,這就是我們預期的結果,現在,將queue2的QoS class改為utility(較低的順序),如下圖所示:

看看現在發生了什麼事:

t57_4_sample4_qos_utility

不要懷疑,由於被賦予更高的優先權,第一個dispatch queue(queue1)較第二個更快被執行,即使queue2在第一個佇列開始運行後隨即得到執行的機會,但由於第一個佇列被標記為較重要的任務,所以系統將資源主要提供給它,當它完成後,系統才會去關心第二個佇列。

讓我們進行另一個練習,是時候將第一個佇列的QoS class更改為background:

它被賦予的優先權幾乎是最低的,所以讓我們看一下程式碼運行時發生了什麼事情:

t57_5_sample5_qos_background

由於QoS class設定為utilitybackground擁有更高的優先權,因此,這次第二個佇列比較快跑完。

上述的範例讓我們清楚了解QoS classes如何運作,但是如果我們將任務跑在主佇列會發生什麼事情呢?請將下列的程式碼加入到我們的函式尾端:

同時,也將第一個佇列的QoS class做更改,將優先順序設定為更高層級:

下圖為輸出結果:

t57_6_sample6_qos_mainqueue

我們再一次看到主佇列預設有較高的優先權,queue1的dispatch queue與主要的佇列並發執行,queue2則是最後完成,而且當另外兩個佇列內的任務正在執行時,它沒有太多機會可以被執行到,這就是因為被設定成較低的執行順序。

Concurrent Queues

在目前為止,我們分別看到了dispatch queues同步與非同步的作業情況,以及系統設定的Quality of Service class如何影響執行的優先順序,先前的範例都是將我們的佇列設為serial,這表示如果我們賦予一個以上的任務給任何的佇列,這些任務將是依序被執行,而非一起被執行,接下來,我們將看到如何在同一時間運行多個工作任務(work items),換句話說,下面我們將會實作concurrent queue。

在這個專案中,我們將使用concurrentQueues()函式(請在viewDidAppear(_:)將對應的程式碼取消註解),在這個新的函式將會建立一個新的佇列:

現在,將下列的任務(或是稱為work items)丟給這個佇列:

code-snippet-3

當程式碼運行時,這些任務將依序被執行,下面截圖可以看得更清楚:

t57_7_sample7_serial_queue

接下來,請修改anotherQueue的初始化方法:

上面的初始化動作有添加一個新的參數: attributes。這個參數我們帶入concurrent,所以全部的任務在這個佇列上會同時被執行,如果你不使用這個參數,佇列會被設定為serial,事實上,QoS參數也並不是必須的,若是在初始化作業中,我們將這些參數拿掉也是沒有任何問題的。

再一次運行這個app,我們可以注意到,任務都被並發執行了:

t57_8_sample8_concurrent_queue

注意,改變任務執行的QoS class也會被影響,儘管如此,只要在初始化時將佇列設定為concurrent,這些任務將會以並發方式執行,它們會擁有各自的運行時間。

attributes參數也可以接受另外一個稱為initiallyInactive的值,如果使用這個值,這些任務不會被自動執行,開發者必須去觸發這個執行動作,我們接下來將會說明,但需要先做一些修改。首先,聲明一個名為inactiveQueue </ code>的類別屬性,如下圖所示:

現在初始化佇列,並將它賦值給inactiveQueue:

在這個情況中使用類別屬性(class property)是必須的,因為anotherQueue被定義在concurrentQueues()函式中,只能在這裡面使用,應用程序不知道它何時退出該函式,我們將無法喚醒這個佇列,但最重要的事情是,運行時可能會閃退。

該是時候再次運行我們的應用程式了,但你將會看到這裡沒有任何輸出,這是原本就預期的結果,我們可以在 viewDidAppear(_:)函式添加下列程式碼,藉以觸發這個dispatch queue:

DispatchQueue類別內的activate()函式將觸發這個任務,注意,當這個queue並未被設定為concurrent,則它們將會以序列化方式執行:

t57_9_sample9_inactive_serial

現在的問題是,當它一開始未被喚醒前,我們如何先將其設定為concurrent queue?最簡單的做法,我們提供一個array將兩個值存放進去,做為attributes的參數,代替原本僅提供單一數值的方法:

t57_10_sample10_inactive_concurrent

延遲執行

有時候應用程式在代碼塊內運行一個work item時會有延遲執行需求,GCD允許開發者透過特定的方法,讓指定任務在一段設定的時候後才被執行。

這次我們將程式碼寫在queueWithDelay() 函式內,它已經寫在我們的初始專案內,請開始將下列代碼添加到裡面:

剛開始我們就像之前一樣建立一個新的DispatchQueue;將在下一步使用它。然後,我們印出當前日期,以便將來驗證任務的等待時間,最後我們指定等待時間,延遲時間通常是一個DispatchTimeInterval枚舉值(enum value),它被添加到DispatchTime以指定延遲效果。在範例中,替該任務設定兩秒的執行等待時間,這裡我們使用seconds方法,除此之外還提供以下幾種:

  • microseconds
  • milliseconds
  • nanoseconds

現在開始使用這個佇列:

now()方法會回傳目前時間,我們另外把想要延遲的時間添加進來,如果運行這個應用程式,我們可以在console看到下列結果:

t57_11_sample11_delay

的確,這個dispatch queue內的任務在兩秒後被執行,但請注意,這裡有另一個替代方式指定等待時間,如果您不想使用上述任何預定義方法,可以直接向當前時間添加Double值:

在這個案例中,任務將會在專案運行後的0.75秒被執行,同時,你也可以避免使用now()方法,但必須提供自行一個DispatchTime的值當作參數,上面顯示的是佇列內work item延遲執行最簡單的方法,但實際上也不需要任何其他東西。

訪問Main和Global Queues

在之前的所有案例中,我們手動創建了要使用的dispatch queues,儘管如此,它並不總是需要這樣做,尤其是如果你不想要改變dispatch queue的屬性值,就像我們在文章一開始所說,系統會建立一個background dispatch queues集合,也稱為global queues,你可以像使用自定義佇列一樣自由地使用它們,只是記住不要濫用系統,請勿極盡所能使用global queues。

訪問global queue非常簡單:

你可以使用它,就像迄今為止看到的任何其他佇列:

Coda Snipet Async

當我們在使用global queues時,沒有太多屬性可以供你修改,儘管如此,開發者仍可以指定想要使用的Quality of Service class。

如果你沒有指定QoS class(就像我們第一個實作範例),那預設情況下會以default當成預設值。

不論你使不使用global queues,都需要經常訪問主佇列,這點無庸置疑,最可能的情境就是更新UI。從任何其他佇列訪問主佇列很簡單,就如同下面的代碼,並且在調用時指定是同步執行還是異步執行:

事實上,你可以藉由輸入DispatchQueue.main.,看到主佇列全部可用的選項, Xcode將會自動列出在main queue中可以呼叫的全部方法,最上面顯示的會是大多數時間所需要的(事實上,這是一般情況都適用的,任何佇列的可用方法在輸入佇列名稱並按”.”符號後,Xcode自動建議的 ),你還可以根據上一部分中所看到的內容,對代碼執行區塊添加延遲效果。

現在有一個真實的範例,我們可以使用主佇列來刷新UI介面,在你作業的初始專案中,位於Main.storyboard檔案裡面的ViewController畫面包含了一個image view,而且對應的 IBOutlet屬性已連結至ViewController類別中,在這裡,我們將進入fetchImage() (目前仍是空的)函式,需要透過程式碼去下載Appcoda的logo,並且將它展示在image view上面,下面的代碼完成了上述動作(我不會在這裡針對URLSession類別做相關討論,以及介紹它如何使用):

注意,我們其實不是在主佇列上刷新UI介面,我們試圖在背景執行緒使用dataTask(...)方法的completion handler block來替代處理,現在請編譯並運行這個應用程式,看看會發生什麼事(不要忘記呼叫fetchImage()函式)。

t57_12_update_ui_bg

即使我們得到image已經被下載完成的訊息,但是我們卻無法在image view看到它,因為UI介面沒有被更新,最可能的是,圖像將在初始消息出現的幾分鐘後顯示(但是如果其他任務也在應用程序中執行,上述情況不保證會發生),問題不僅如此,你也將會獲得一長串的error log,抱怨UI更新的動作被放在背景執行緒執行。

現在,讓我們改變這個有問題的行為,使用主佇列來修改UI介面。 在編輯上述方法時,改變下面所示的部分,並且注意我們如何使用主佇列:

再一次運行這個應用程式,會看到image view在下載作業完成後獲得image,主佇列真的被調用並且更新我們的UI。

使用 DispatchWorkItem 物件

DispatchWorkItem是一個代碼塊,可以在任何的佇列上面被調用,因此,裡面的程式碼可以在背景執行,或是在主執行緒運行,想想它真的很簡單,一堆代碼你可以直接調用,而不是像我們在之前所寫的代碼塊。

使用這種work item最簡單的方法如下所示:

讓我們透過一個小範例來看看DispatchWorkItem物件如何使用,前往 useWorkItem()函式,並添加以下代碼:

我們work item的目的是將value變量的值增加5,我們使用workItem物件去呼叫perform(),如下所示:

這一行程式碼將會在主執行緒上面調用work item,但是你也可以總是使用其他的佇列,讓我們看看下面範例:

這樣也可以完善地達成工作,儘管如此,另外也有比較快的方式可以調用work item,DispatchQueue類別為此目的提供了一種方便的方法:

當一個work item被調用後,你可以通知你的主佇列(或是其他任何你想要的佇列),如下所示:

上面程式碼將在console印出value變數的值,並且當work item被調用時進行呼叫,現在將所有的東西放在一起,useWorkItem()函式內的程式碼如下:

這裡你將開始運行這個應用程式(並且在viewDidAppear(_:))呼叫上述這個函式):

t57_13_dispatch_work_item

總結

大多數時候,這篇文章中看到的都是你做多工任務和並發編程所需要的。但是,請記住,仍有GCD概念本教程中沒有涉及,或者已經討論過的一些其他概念,但是沒有深入細節的部分。目的是想保持本篇文章簡單易讀的特性,讓內容是比較好理解的,適合所有級別和技能的開發人員。 如果你沒有使用GCD的習慣,請認真考慮一下,嘗試從主佇列中卸載較繁重的任務;如果有任務可以在背景執行,那麼在背景發送它們。在任何情況下,使用GCD並不困難,且結果一定是正面的,將讓你的應用程式能反應更迅速,享受與GCD互動的樂趣吧!>

範例專案,你可以在這個GitHub看到它

譯者簡介:陳奕先-過去為平面財經記者,專跑產業新聞,2015年起跨進軟體開發世界,希望在不同領域中培養新的視野,於新創學校ALPHA Camp畢業後,積極投入iOS程式開發,目前任職於國內電商公司。聯絡方式:電郵chenyishain@gmail.com。

FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS

原文Grand Central Dispatch (GCD) and Dispatch Queues in Swift 3


資深軟體開發員,從事相關工作超過二十年,專門在不同的平台和各種程式語言去解決軟體開發問題。自2010年中,Gabriel專注在iOS程式的開發,利用教程與世界上每個角落的人分享知識。可以在Google+或推特關注 Gabriel。

blog comments powered by Disqus
訂閲電子報

訂閲電子報

AppCoda致力於發佈優質iOS程式教學,你不必每天上站,輸入你的電子郵件地址訂閱網站的最新教學文章。每當有新文章發佈,我們會使用電子郵件通知你。

已收你的指示。請你檢查你的電郵,我們已寄出一封認證信,點擊信中鏈結才算完成訂閱。

Shares
Share This