身為一名在汽車產業裡的 iOS 開發者,我花了不少時間處理即時資料。現今許多 App 都需要有效率地處理連續的資料流量,為了確保不會卡住使用者介面,你很可能需要使用多執行緒來開發。
處理即時的資料流量非常有趣,因為你會不斷收到可以用來更新視覺畫面的新資料。這也是開發時最困難、最令人沮喪的事情,因為 iOS 裝置有一些硬體上的限制。幸運的是,Apple 透過非常容易使用的 GCD (Grand Central Dispatch) 介面,提供多執行緒 (Multi-Thread) 功能。你可能會對下面的程式碼感到熟悉:
DispatchQueue.main.async {
// Place a work item in the GCD main queue and then
// move on to the next statement in your code.
}
如果沒有明確地將程式碼放入到其他佇列 (Queue) 的話,你大部分的程式碼都會在主佇列 (Main Queue) 執行。主佇列是一個有次序的佇列,也就是說它會選擇這行佇列的第一個項目來執行,然後等到執行結束後再將其釋放,接著再選擇佇列裡的下一個項目,如此類推。
多執行緒 (Multi-threaded) 與並行 (Concurrency)
然而,主佇列並不是 GCD 內唯一可以使用的佇列。當中有一些預先定義好的佇列,而它們有著不同的優先層級。你也可以創建自己特定的佇列,像是這樣:
let myConcurrentQueue = DispatchQueue(label: "ConcurrentQueue",
qos: .background,
attributes: .concurrent,
autoreleaseFrequency: .workItem,
target: nil)
你會注意到我們創建的佇列中有個 .concurrent
屬性,這表示佇列不會等待一個項目執行結束後才執行下一個項目。它會把項目放入一個執行緒並開始執行,接著不論第一個項目是否已經完成,都會移動到下一個項目。
讓我們說點技術性的東西 ⋯⋯
比方說,你正在處理採樣率為 20Hz 左右的資料流,這意味著你會有大約 50 毫秒的時間來解析及解譯資料、將資料加進資料結構、並通知視圖來呈現。如果你的 iOS 裝置嘗試在主執行緒上執行此操作,它就只有很少的時間去確認使用者是否正試著跟 App 互動,而且 App 也會變得反應遲鈍。所以,我們就要在這裡轉用多執行緒了。
假設我們正在用一個簡單的資料結構,來儲存收到的資料樣本,像是一個普通的整數陣列。然後,我們可能會想創建一個佇列並使用它,就像這樣:
// 這是先前的資料佇列,不過它有一個更高優先權的標記
let myDataQueue = DispatchQueue(label: "DataQueue",
qos: .userInitiated,
attributes: .concurrent,
autoreleaseFrequency: .workItem,
target: nil)
// 我們的資料結構,可能已在某處的 Data Manager 預先初始化了
var dataArray = [Int] ()
// 收到資料後,我們呼叫解析、儲存、更新的程式碼
myDataQueue.async {
let parsedData = parseData(data)
dataArray.append(parsedData)
DispatchQueue.main.async {
updateViews()
}
}
這有用嗎?
這看起來不錯,對吧?現在我們正讓所有資料在背景執行緒上處理,而主執行緒只是用來更新視覺畫面。然而,這樣鐵定會造成閃退,為甚麼呢?這答案有點技術性,但這一點非常重要。
因為佇列是同步執行的,所以它會把工作項目丟到執行緒上,以作平行執行。我們正使用一個陣列來儲存資料,而 Swift 陣列是屬於結構型別 (Struct Type),也就是一種數值型別 (Value Type)。當你試著加入數值到陣列時,執行緒就會:
- 分配新的陣列,並從舊陣列中複製數值;
- 加入新資料;
- 將新的參照寫回至變數;
- 系統釋放舊陣列所使用的記憶體。
試想一下,如果兩個執行緒將相同的陣列複製過去,會發生甚麼事?它們會分別加入自己的資料到複製的陣列上,然後寫回新的參照到變數裡,可能一個執行緒先加入資料然後到另一個、又或是兩個執行緒同時加入資料。前者會讓我們的資料出錯,因為第一個執行緒寫入的資料並不會出現於第二個執行緒的陣列;而後者則會導致 App 閃退,因為兩個執行緒無法同時獲得對已分配記憶體的寫入權限。
考慮到這一點,我們可以使用 DispatchQuere
類別中一個相當聰明的架構 ── flag。現在,我們可以將程式碼改寫為:
let myDataQueue = DispatchQueue(label: "DataQueue",
qos: .userInitiated,
attributes: .concurrent,
autoreleaseFrequency: .workItem,
target: nil)
var dataArray = [Int] ()
// .barrier flag 告訴佇列,這個特定工作項目需要在沒有其他平行執行的項目時執行
myDataQueue.async(flags: .barrier) {
let parsedData = parseData(data)
dataArray.append(parsedData)
DispatchQueue.main.async {
// 這個方法可能會需要在某個時刻讀取資料,並且需要特定的方式來執行//
// 看看下面的實作
updateViews()
}
}
func updateViews() {
let dataForViews = return myDataQueue.sync { return dataArray }
// 使用 dataForViews 變數來進行更新
// 因為即使在更新期間更改了資料陣列
// 它也能保持不變
}
這可能看起來有點複雜,讓我解釋一下。
使用了 .barrier flag 後,每當我們添加一個透過寫入來更改資料結構的項目時,我們都會告訴佇列這個特定的工作項目需要單獨執行。這表示佇列需要等到所有正在運作的執行緒結束後,才執行這個項目,然後等這個項目執行完畢,再開始平行執行程式碼。
當主執行緒需要讀取資料來更新視圖時,它需要透過一個同步呼叫來完成資料佇列。不然,一個正在寫入的執行緒就有可能破壞主執行緒正在讀取的資料。
總結
希望你明白本篇文章,並獲得一些新知識。在幾天後再閱讀一次本篇文章能夠幫助你理解當中的概念,也讓你有機會去反思一下。
如果你有任何疑問,歡迎在下面留言。
想學習更多關於 iOS 開發相關的知識,你可以看看我之前的文章 “iOS Development and the Wrong Kind of MVC”。