iOS拾遺——為什麼必須在主執行緒操作UI

杜瑋發表於2019-01-17

在開發過程中,我們或多或少會不經意在後臺執行緒中呼叫了UIKit框架的內容,可能是在網路回撥時直接imageView.image = anImage,也有可能是不小心在後臺執行緒中呼叫了UIApplication.sharedApplication。而這個時候編譯器會報出一個runtime錯誤,我們也會迅速的對其進行修正。

但仔細去思考,究竟為什麼一定要在主執行緒操作UI呢?如果在後臺執行緒對UI進行操作會發生什麼?在後臺執行緒對UI進行操作不是可以更好的避免卡頓嗎?這篇文章就是基於這樣一些疑問而產生的。

太長不看版:

UIKit並不是一個 執行緒安全 的類,UI操作涉及到渲染訪問各種View物件的屬性,如果非同步操作下會存在讀寫問題,而為其加鎖則會耗費大量資源並拖慢執行速度。另一方面因為整個程式的起點UIApplication是在主執行緒進行初始化,所有的使用者事件都是在主執行緒上進行傳遞(如點選、拖動),所以view只能在主執行緒上才能對事件進行響應。而在渲染方面由於影像的渲染需要以60幀的重新整理率在螢幕上 同時 更新,在非主執行緒非同步化的情況下無法確定這個處理過程能夠實現同步更新。


iOS拾遺——為什麼必須在主執行緒操作UI

從UIKit執行緒不安全說起

在UIKit中,很多類中大部分的屬性都被修飾為nonatomic,這意味著它們不能在多執行緒的環境下工作,而對於UIKit這樣一個龐大的框架,將其所有屬性都設計為執行緒安全是不現實的,這可不僅僅是簡單的將nonatomic改成atomic或者是加鎖解鎖的操作,還涉及到很多的方面:

  • 假設能夠非同步設定view的屬性,那我們究竟是希望這些改動能夠同時生效,還是按照各自runloop的進度去改變這個view的屬性呢?
  • 假設UITableView在其他執行緒去移除了一個cell,而在另一個執行緒卻對這個cell所在的index進行一些操作,這時候可能就會引發crash。
  • 如果在後臺執行緒移除了一個view,這個時候runloop週期還沒有完結,使用者在主執行緒點選了這個“將要”消失的view,那麼究竟該不該響應事件?在哪條執行緒進行響應?

仔細思考,似乎能夠多執行緒處理UI並沒有給我們開發帶來更多的便利,假如你代入了這些情景進行思考,你很容易得出一個結論: “我在一個序列佇列對這些事件進行處理就可以了。” 蘋果也是這樣想的,所以UIKit的所有操作都要放到主執行緒序列執行。

Thread-Safe Class Design一文提到:

It’s a conscious design decision from Apple’s side to not have UIKit be thread-safe. Making it thread-safe wouldn’t buy you much in terms of performance; it would in fact make many things slower. And the fact that UIKit is tied to the main thread makes it very easy to write concurrent programs and use UIKit. All you have to do is make sure that calls into UIKit are always made on the main thread.

大意為把UIKit設計成執行緒安全並不會帶來太多的便利,也不會提升太多的效能表現,甚至會因為加鎖解鎖而耗費大量的時間。事實上併發程式設計也沒有因為UIKit是執行緒不安全而變得困難,我們所需要做的只是要確保UI操作在主執行緒進行就可以了。


iOS拾遺——為什麼必須在主執行緒操作UI

好吧,那假設我們用黑魔法祝福了UIKit,這個UIKit能夠完美的解決我們上面提到的問題,並能夠按照開發者的想法隨意展現不同的形態。那這個時候我們可以在後臺執行緒操作UI了嘛?

很可惜,還是不行。

Runloop 與繪圖迴圈

道理我們都懂,那這個究竟跟我們不能在後臺執行緒操作UI有什麼關係呢?

UIApplication在主執行緒所初始化的Runloop我們稱為Main Runloop,它負責處理app存活期間的大部分事件,如使用者互動等,它一直處於不斷處理事件和休眠的迴圈之中,以確保能儘快的將使用者事件傳遞給GPU進行渲染,使使用者行為能夠得到響應,畫面之所以能夠得到不斷重新整理也是因為Main Runloop在驅動著。

而每一個view的變化的修改並不是立刻變化,相反的會在當前run loop的結束的時候統一進行重繪,這樣設計的目的是為了能夠在一個runloop裡面處理好所有需要變化的view,包括resize、hide、reposition等等,所有view的改變都能在同一時間生效,這樣能夠更高效的處理繪製,這個機制被稱為繪圖迴圈(View Drawing Cycle)

假設這個時候我們應用了我們的魔法UIKit,並愉快的在一條後臺執行緒操作UI,但當我們需要對裝置進行旋轉並重新佈局的時候,問題來了,因為各個執行緒之間不同步,這時候各個view修改的請求時機是零碎的,所以所有的旋轉變化並不能在Main Runloop的一個runloop裡面處理完,這就導致裝置旋轉之後還有一些view遲遲沒有旋轉。

另一方面,因為我們的魔法UIKit並不是在主執行緒,所以Main Runloop中的事件需要跨執行緒進行傳輸,這樣會導致顯示與使用者事件並不同步。試想一下我們用我們的魔法UIKit寫了一個遊戲,使用者如果在圖片還沒有載入出來的時候按下了按鈕,他們就能勝利,於是我們寫出了這樣的程式碼:

game.m

- (void)didClickButton:(UIButton *)button
{
	if (self.imageView.image != nil) {
		// User lose!
	} else {
		// User Win!
	}
}

- (void)loadImageInBackgroundThread
{
	dispatch_async(dispatch_queue_create("BackgroundQueue", NULL), ^{
		self.imageView.image = [self downloadedImage];
	};
}

複製程式碼

因為我們完美的魔法UIKit,在後臺執行imageView.image = xxx並不會產生任何問題。遊戲上線,在你還為後臺處理UI而沾沾自喜的時候,使用者投訴了他們明明沒有看到圖片顯示,點選的時候還是告訴他們輸了,於是你的產品就這樣撲街了。

這是因為點選等事件是由系統傳遞給UIApplication中,並在Main Runloop中進行處理與響應,但是由於UI在後臺執行緒中進行處理,所以他跟事件響應並不同步。即使在UI所在的後臺執行緒也自己維護了一個Runloop,在Runloop結束時候進行渲染,但可能使用者已經進行了點選操作並開始辱罵你的遊戲了。


iOS拾遺——為什麼必須在主執行緒操作UI

好吧,那假設我天賦異稟,把整套UIApplication的機制全都重寫了,也用黑魔法祝福了我的新UIApplication,這個時候它能完美的解決執行緒同步的問題,這個時候我可以在後臺操作UI了嗎?

……

……

很可惜,還是不能。

理解iOS的渲染流程

要回答這個問題,我們要先從最底層的渲染說起。

渲染系統框架

iOS拾遺——為什麼必須在主執行緒操作UI

  • UIKit: 包含各種控制元件,負責對使用者操作事件的響應,本身並不提供渲染的能力
  • Core Animation: 負責所有檢視的繪製、顯示與動畫效果
  • OpenGL ES: 提供2D與3D渲染服務
  • Core Graphics: 提供2D渲染服務
  • Graphics Hardware: 指GPU

所以在iOS中,所有檢視的現實與動畫本質上是由 Core Animation 負責,而不是UIKit。

Core Animation Pipeline 流水線

iOS拾遺——為什麼必須在主執行緒操作UI

Core Animation的繪製是通過Core Animation Pipeline實現,它以流水線的形式進行渲染,具體分為四個步驟:

  • Commit Transaction:

    可以細分為

    • Layout: 構建檢視佈局如addSubview等操作
    • Display: 過載drawRect:進行時圖繪製,該步驟使用CPU與記憶體
    • Prepare: 主要處理影像的解碼與格式轉換等操作
    • Commit: 將Layer遞迴打包併傳送到Render Server
  • Render Server:

    負責渲染工作,會解析上一步Commit Transaction中提交的資訊並反序列化成渲染樹(render tree),隨後根據layer的各種屬性生成繪製指令,並在下一次VSync訊號到來時呼叫OpenGL進行渲染。

  • GPU:

    GPU會等待顯示器的VSync訊號發出後才進行OpenGL渲染管線,將3D幾何資料轉化成2D的畫素影像和光柵處理,隨後進行新的一幀的渲染,並將其輸出到緩衝區。

  • Dispaly:

    從緩衝區中取出畫面,並輸出到螢幕上。

知識補充:iOS的VSync與雙緩衝機制

VSync:

VSync(vertical sync)是指垂直同步,在玩遊戲的時候在設定的時候應該會看見過這個選項,這個機制能夠讓顯示卡和顯示器保持在一個相同的重新整理率從而避免畫面撕裂。在iOS中,螢幕具有60Hz的重新整理率,這意味著它每秒需要顯示60張不同的圖片(幀),但GPU並沒有一個確定的重新整理率,在某些時候GPU可能被要求更強力的資料輸出來確保渲染能力,這時候他們可能比螢幕重新整理率(60Hz)更快,就會導致螢幕不能完整的渲染所有GPU給他的資料,因為它不夠快,螢幕的上一幀還沒渲染完,下一幀就已經到來了,這就導致畫面的撕裂。

這個時候我們就要引入VSync了,簡單來說它就是讓顯示卡保持他的輸出速率不高於螢幕的重新整理率,啟用了VSync後,GPU不再會給你可憐的60Hz螢幕每秒傳送100幀了,它會增加每一幀的傳送間隔,確保顯示器能夠有充足的時間去處理每一幀。

雙緩衝機制:

雙緩衝機制是用於避免或減少畫面閃爍的問題,在單緩衝的情況下,GPU輸出了一幀畫面,緩衝區就需要馬上獲取這個畫面,並交給螢幕去顯示,而這段時間GPU輸出的畫面就全都丟失了,因為沒有緩衝區去承載這些畫面,就會造成畫面的閃爍。

而在雙緩衝機制下有一個Back Frame Buffer和一個Front Frame Buffer,在GPU繪製完成後,它會將影像先儲存到Back Frame Buffer中,操作完畢後,會呼叫一個交換函式,讓繪製完成的Back Frame Buffer上的影像交換到Front Frame Buffer上。由於雙緩衝利用了更多視訊記憶體與CPU消耗時間,從而避免了畫面的閃爍。

So?

相信大家都會遇到過應用卡頓,卡頓的原因就是因為兩幀的重新整理時間間隔大於60幀每秒(約16.67ms),導致使用者感覺點選或者滑動時,介面沒有及時的響應。

前面提到Core Animation Pipeline是以流水線的形式工作的,在理想的狀況下我們希望它能夠在1/60s內完成圖層樹的準備工作並提交給渲染程式,而渲染程式在下一次VSync訊號到來的時候提交給GPU進行渲染,並在1/60s內完成渲染,這樣就不會產生任何的卡頓。

但是由於我們使用了我們的魔法UIKit,所以我們在許多後臺執行緒進行了UI操作,在runloop的結尾準備進行渲染的時候,不同執行緒提交了不同的渲染資訊,於是我們就擁有了更多的繪製事務,這個時候Core Animation Pipeline會不斷將資訊提交,讓GPU進行渲染,由於繪製事件的不同步導致了GPU渲染的不同步,可能在上一幀是需要渲染一個label消失的畫面,下一幀卻又需要渲染這個label改變了文字,最終導致的是介面的不同步。

(如果你真的想要這樣的效果,可以嘗試一下使用我的DWAnimatedLabel

另一方面,在VSync和雙緩衝機制我們可以看出渲染其實是一個十分消耗系統資源的操作(佔用視訊記憶體與CPU),所以可能會因為大量的事務和執行緒之間頻繁的上下文切換導致了GPU無法處理,反而影響了效能,從而導致在1/60s中無法完成圖層樹的提交,導致了嚴重的卡頓。


iOS拾遺——為什麼必須在主執行緒操作UI

但我真的很想在後臺執行緒操作UI,我能再用黑魔法嗎?

……

……

……

……

好吧,其實是有辦法的。

Texture or ComponentKit

AsyncDisplayKit(現命名為Texture) 是Facebook開源的一個用於保持iOS介面流暢的框架。

ComponentKit是Facebook開源的一個基於React思想的iOS原生UI開發框架。它通過函式式和宣告的方式構建UI。

讓我們撤銷掉我們對UIKit施展的各種魔法,回到這個UI只能在主執行緒進行操作的世界吧。這兩個框架其實並不是真正的在後臺執行緒操作UI,而是用了更巧妙的方法將一些耗時的操作非同步執行,從而繞開了UIKit只能在主執行緒操作的限制。

比如Texture建立了各類Node,在node中包含了UIView,而Node本身是執行緒安全的,所以允許在後臺執行緒對Node進行修改,隨後在第一次主執行緒訪問View的時候它才會在內部生成對應的View,當node的屬性發生改變的時候,他也不會馬上進行修改,而是在適當的時機一次性的在主執行緒為內部的View進行設定。(有點類似於繪圖迴圈)

而ComponentKit則是通過建立Component來描述UI,它也是一個執行緒安全的類。可以將Component認為是一個刻板,而UIView是刻板下的一張紙,渲染則是噴墨的過程。當我們生成了一個Component的時候,就等於生成了一個View的模版,在進行渲染的時候只要按照模版進行繪製就可以了。複雜的介面可以通過各種簡單的Component來組成。(類似於Flutter的widget)


iOS拾遺——為什麼必須在主執行緒操作UI

但是我……

閉嘴吧你

總結

UIKit不能在主執行緒進行操作,這一個鐵律只要是熟悉iOS開發的都會有所耳聞,但是往深一層其實這個涉及到很多的東西,包括軟體、整體UIKit框架的實現、硬體等等,很多細節的東西往往是我們在平常有所忽略的。可能我們知道不能在主執行緒操作,卻不知道其內在原因;可能我們知道怎麼排查、處理卡頓,卻不知道其真正的成因;可能我們知道drawRect:方法會導致CPU飆升,卻不知道原因是上下文的切換導致……

寫程式碼從來都不是一件簡單而顯而易見的事情。

更多的內容可以檢視我的部落格

參考資料

相關文章