iOS拾遺——爲何必須在主線程操做UI

在開發過程當中,咱們或多或少會不經意在後臺線程中調用了UIKit框架的內容,多是在網絡回調時直接imageView.image = anImage,也有多是不當心在後臺線程中調用了UIApplication.sharedApplication。而這個時候編譯器會報出一個runtime錯誤,咱們也會迅速的對其進行修正。html

但仔細去思考,究竟爲何必定要在主線程操做UI呢?若是在後臺線程對UI進行操做會發生什麼?在後臺線程對UI進行操做不是能夠更好的避免卡頓嗎?這篇文章就是基於這樣一些疑問而產生的。node

太長不看版:ios

UIKit並非一個 線程安全 的類,UI操做涉及到渲染訪問各類View對象的屬性,若是異步操做下會存在讀寫問題,而爲其加鎖則會耗費大量資源並拖慢運行速度。另外一方面由於整個程序的起點UIApplication是在主線程進行初始化,全部的用戶事件都是在主線程上進行傳遞(如點擊、拖動),因此view只能在主線程上才能對事件進行響應。而在渲染方面因爲圖像的渲染須要以60幀的刷新率在屏幕上 同時 更新,在非主線程異步化的狀況下沒法肯定這個處理過程可以實現同步更新。git


從UIKit線程不安全提及

在UIKit中,不少類中大部分的屬性都被修飾爲nonatomic,這意味着它們不能在多線程的環境下工做,而對於UIKit這樣一個龐大的框架,將其全部屬性都設計爲線程安全是不現實的,這可不只僅是簡單的將nonatomic改爲atomic或者是加鎖解鎖的操做,還涉及到不少的方面:github

  • 假設可以異步設置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操做在主線程進行就能夠了。多線程


好吧,那假設咱們用黑魔法祝福了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結束時候進行渲染,但可能用戶已經進行了點擊操做並開始辱罵你的遊戲了。


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

……

……

很惋惜,仍是不能。

理解iOS的渲染流程

要回答這個問題,咱們要先從最底層的渲染提及。

渲染系統框架

  • UIKit: 包含各類控件,負責對用戶操做事件的響應,自己並不提供渲染的能力
  • Core Animation: 負責全部視圖的繪製、顯示與動畫效果
  • OpenGL ES: 提供2D與3D渲染服務
  • Core Graphics: 提供2D渲染服務
  • Graphics Hardware: 指GPU

因此在iOS中,全部視圖的現實與動畫本質上是由 Core Animation 負責,而不是UIKit。

Core Animation Pipeline 流水線

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中沒法完成圖層樹的提交,致使了嚴重的卡頓。


但我真的很想在後臺線程操做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)


可是我……

閉嘴吧你

總結

UIKit不能在主線程進行操做,這一個鐵律只要是熟悉iOS開發的都會有所耳聞,可是往深一層其實這個涉及到不少的東西,包括軟件、總體UIKit框架的實現、硬件等等,不少細節的東西每每是咱們在日常有所忽略的。可能咱們知道不能在主線程操做,殊不知道其內在緣由;可能咱們知道怎麼排查、處理卡頓,殊不知道其真正的成因;可能咱們知道drawRect:方法會致使CPU飆升,殊不知道緣由是上下文的切換致使……

寫代碼歷來都不是一件簡單而顯而易見的事情。

更多的內容能夠查看個人博客

參考資料

相關文章
相關標籤/搜索