本文借鑒于:https://mp.weixin.qq.com/s/4eniMFuwoDDDfStDJ8o5Hw
在開(kāi)發(fā)過(guò)程中裆甩,我們或多或少會(huì)不經(jīng)意在后臺(tái)線程中調(diào)用了UIKit框架的內(nèi)容身冬,可能是在網(wǎng)絡(luò)回調(diào)時(shí)直接imageView.image = anImage油宜,也有可能是不小心在后臺(tái)線程中調(diào)用了UIApplication.sharedApplication洼专。而這個(gè)時(shí)候編譯器會(huì)報(bào)出一個(gè)runtime錯(cuò)誤啊研,我們也會(huì)迅速的對(duì)其進(jìn)行修正。
但仔細(xì)去思考缴川,究竟為什么一定要在主線程操作UI呢?如果在后臺(tái)線程對(duì)UI進(jìn)行操作會(huì)發(fā)生什么描馅?在后臺(tái)線程對(duì)UI進(jìn)行操作不是可以更好的避免卡頓嗎把夸?這篇文章就是基于這樣一些疑問(wèn)而產(chǎn)生的。
太長(zhǎng)不看版:
UIKit并不是一個(gè) 線程安全 的類铭污,UI操作涉及到渲染訪問(wèn)各種View對(duì)象的屬性恋日,如果異步操作下會(huì)存在讀寫問(wèn)題,而為其加鎖則會(huì)耗費(fèi)大量資源并拖慢運(yùn)行速度况凉。另一方面因?yàn)檎麄€(gè)程序的起點(diǎn)UIApplication是在主線程進(jìn)行初始化,所有的用戶事件都是在主線程上進(jìn)行傳遞(如點(diǎn)擊各拷、拖動(dòng))刁绒,所以view只能在主線程上才能對(duì)事件進(jìn)行響應(yīng)。而在渲染方面由于圖像的渲染需要以60幀的刷新率在屏幕上 同時(shí) 更新烤黍,在非主線程異步化的情況下無(wú)法確定這個(gè)處理過(guò)程能夠?qū)崿F(xiàn)同步更新知市。
從UIKit線程不安全說(shuō)起
在UIKit中,很多類中大部分的屬性都被修飾為nonatomic速蕊,這意味著它們不能在多線程的環(huán)境下工作嫂丙,而對(duì)于UIKit這樣一個(gè)龐大的框架,將其所有屬性都設(shè)計(jì)為線程安全是不現(xiàn)實(shí)的规哲,這可不僅僅是簡(jiǎn)單的將nonatomic改成atomic或者是加鎖解鎖的操作跟啤,還涉及到很多的方面:
- 假設(shè)能夠異步設(shè)置view的屬性,那我們究竟是希望這些改動(dòng)能夠同時(shí)生效,還是按照各自runloop的進(jìn)度去改變這個(gè)view的屬性呢隅肥?
- 假設(shè)UITableView在其他線程去移除了一個(gè)cell竿奏,而在另一個(gè)線程卻對(duì)這個(gè)cell所在的index進(jìn)行一些操作,這時(shí)候可能就會(huì)引發(fā)crash腥放。
- 如果在后臺(tái)線程移除了一個(gè)view泛啸,這個(gè)時(shí)候runloop周期還沒(méi)有完結(jié),用戶在主線程點(diǎn)擊了這個(gè)“將要”消失的view秃症,那么究竟該不該響應(yīng)事件候址?在哪條線程進(jìn)行響應(yīng)?
仔細(xì)思考种柑,似乎能夠多線程處理UI并沒(méi)有給我們開(kāi)發(fā)帶來(lái)更多的便利岗仑,假如你代入了這些情景進(jìn)行思考,你很容易得出一個(gè)結(jié)論: “我在一個(gè)串行隊(duì)列對(duì)這些事件進(jìn)行處理就可以了莹规∨馄眩” 蘋果也是這樣想的,所以UIKit的所有操作都要放到主線程串行執(zhí)行良漱。
在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設(shè)計(jì)成線程安全并不會(huì)帶來(lái)太多的便利舞虱,也不會(huì)提升太多的性能表現(xiàn),甚至?xí)驗(yàn)榧渔i解鎖而耗費(fèi)大量的時(shí)間母市。事實(shí)上并發(fā)編程也沒(méi)有因?yàn)閁IKit是線程不安全而變得困難矾兜,我們所需要做的只是要確保UI操作在主線程進(jìn)行就可以了。
好吧患久,那假設(shè)我們用黑魔法祝福了UIKit椅寺,這個(gè)UIKit能夠完美的解決我們上面提到的問(wèn)題,并能夠按照開(kāi)發(fā)者的想法隨意展現(xiàn)不同的形態(tài)蒋失。那這個(gè)時(shí)候我們可以在后臺(tái)線程操作UI了嘛返帕?
很可惜,還是不行篙挽。
Runloop 與繪圖循環(huán)
道理我們都懂荆萤,那這個(gè)究竟跟我們不能在后臺(tái)線程操作UI有什么關(guān)系呢?
UIApplication在主線程所初始化的Runloop我們稱為Main Runloop铣卡,它負(fù)責(zé)處理app存活期間的大部分事件链韭,如用戶交互等,它一直處于不斷處理事件和休眠的循環(huán)之中煮落,以確保能盡快的將用戶事件傳遞給GPU進(jìn)行渲染敞峭,使用戶行為能夠得到響應(yīng),畫(huà)面之所以能夠得到不斷刷新也是因?yàn)镸ain Runloop在驅(qū)動(dòng)著蝉仇。
而每一個(gè)view的變化的修改并不是立刻變化旋讹,相反的會(huì)在當(dāng)前run loop的結(jié)束的時(shí)候統(tǒng)一進(jìn)行重繪殖蚕,這樣設(shè)計(jì)的目的是為了能夠在一個(gè)runloop里面處理好所有需要變化的view,包括resize骗村、hide嫌褪、reposition等等,所有view的改變都能在同一時(shí)間生效胚股,這樣能夠更高效的處理繪制笼痛,這個(gè)機(jī)制被稱為繪圖循環(huán)(View Drawing Cycle)。
假設(shè)這個(gè)時(shí)候我們應(yīng)用了我們的魔法UIKit琅拌,并愉快的在一條后臺(tái)線程操作UI缨伊,但當(dāng)我們需要對(duì)設(shè)備進(jìn)行旋轉(zhuǎn)并重新布局的時(shí)候,問(wèn)題來(lái)了进宝,因?yàn)楦鱾€(gè)線程之間不同步刻坊,這時(shí)候各個(gè)view修改的請(qǐng)求時(shí)機(jī)是零碎的,所以所有的旋轉(zhuǎn)變化并不能在Main Runloop的一個(gè)runloop里面處理完党晋,這就導(dǎo)致設(shè)備旋轉(zhuǎn)之后還有一些view遲遲沒(méi)有旋轉(zhuǎn)谭胚。
另一方面,因?yàn)槲覀兊哪Х║IKit并不是在主線程未玻,所以Main Runloop中的事件需要跨線程進(jìn)行傳輸灾而,這樣會(huì)導(dǎo)致顯示與用戶事件并不同步。試想一下我們用我們的魔法UIKit寫了一個(gè)游戲扳剿,用戶如果在圖片還沒(méi)有加載出來(lái)的時(shí)候按下了按鈕旁趟,他們就能勝利,于是我們寫出了這樣的代碼:
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];
};
}
因?yàn)槲覀兺昝赖哪Х║IKit庇绽,在后臺(tái)執(zhí)行imageView.image = xxx并不會(huì)產(chǎn)生任何問(wèn)題锡搜。游戲上線,在你還為后臺(tái)處理UI而沾沾自喜的時(shí)候瞧掺,用戶投訴了他們明明沒(méi)有看到圖片顯示耕餐,點(diǎn)擊的時(shí)候還是告訴他們輸了,于是你的產(chǎn)品就這樣撲街了辟狈。
這是因?yàn)辄c(diǎn)擊等事件是由系統(tǒng)傳遞給UIApplication中肠缔,并在Main Runloop中進(jìn)行處理與響應(yīng),但是由于UI在后臺(tái)線程中進(jìn)行處理上陕,所以他跟事件響應(yīng)并不同步桩砰。即使在UI所在的后臺(tái)線程也自己維護(hù)了一個(gè)Runloop拓春,在Runloop結(jié)束時(shí)候進(jìn)行渲染释簿,但可能用戶已經(jīng)進(jìn)行了點(diǎn)擊操作并開(kāi)始辱罵你的游戲了。
好吧硼莽,那假設(shè)我天賦異稟庶溶,把整套UIApplication的機(jī)制全都重寫了煮纵,也用黑魔法祝福了我的新UIApplication,這個(gè)時(shí)候它能完美的解決線程同步的問(wèn)題偏螺,這個(gè)時(shí)候我可以在后臺(tái)操作UI了嗎行疏?
很可惜,還是不能套像。
理解iOS的渲染流程
要回答這個(gè)問(wèn)題酿联,我們要先從最底層的渲染說(shuō)起。
渲染系統(tǒng)框架:
- UIKit:包含各種控件夺巩,負(fù)責(zé)對(duì)用戶操作事件的響應(yīng)贞让,本身并不提供渲染的能力
- Core Animation:負(fù)責(zé)所有視圖的繪制、顯示與動(dòng)畫(huà)效果
- OpenGL ES:提供2D與3D渲染服務(wù)
- Core Graphics:提供2D渲染服務(wù)
- Graphics Hardware:指GPU
所以在iOS中柳譬,所有視圖的現(xiàn)實(shí)與動(dòng)畫(huà)本質(zhì)上是由 Core Animation 負(fù)責(zé)喳张,而不是UIKit。
Core Animation Pipeline 流水線:
Core Animation的繪制是通過(guò)Core Animation Pipeline實(shí)現(xiàn)美澳,它以流水線的形式進(jìn)行渲染销部,具體分為四個(gè)步驟:
- Commit Transaction:
可以細(xì)分為
- Layout:構(gòu)建視圖布局如addSubview等操作
- Display:重載drawRect:進(jìn)行時(shí)圖繪制,該步驟使用CPU與內(nèi)存
- Prepare:主要處理圖像的解碼與格式轉(zhuǎn)換等操作
- Commit:將Layer遞歸打包并發(fā)送到Render Server
- Render Server:
- 負(fù)責(zé)渲染工作制跟,會(huì)解析上一步Commit Transaction中提交的信息并反序列化成渲染樹(shù)(render tree)舅桩,隨后根據(jù)layer的各種屬性生成繪制指令,并在下一次VSync信號(hào)到來(lái)時(shí)調(diào)用OpenGL進(jìn)行渲染凫岖。
- GPU:
- GPU會(huì)等待顯示器的VSync信號(hào)發(fā)出后才進(jìn)行OpenGL渲染管線江咳,將3D幾何數(shù)據(jù)轉(zhuǎn)化成2D的像素圖像和光柵處理,隨后進(jìn)行新的一幀的渲染哥放,并將其輸出到緩沖區(qū)歼指。
- Dispaly:
- 從緩沖區(qū)中取出畫(huà)面,并輸出到屏幕上甥雕。
知識(shí)補(bǔ)充:iOS的VSync與雙緩沖機(jī)制
VSync:
VSync(vertical sync)是指垂直同步踩身,在玩游戲的時(shí)候在設(shè)置的時(shí)候應(yīng)該會(huì)看見(jiàn)過(guò)這個(gè)選項(xiàng),這個(gè)機(jī)制能夠讓顯卡和顯示器保持在一個(gè)相同的刷新率從而避免畫(huà)面撕裂社露。在iOS中挟阻,屏幕具有60Hz的刷新率,這意味著它每秒需要顯示60張不同的圖片(幀)峭弟,但GPU并沒(méi)有一個(gè)確定的刷新率附鸽,在某些時(shí)候GPU可能被要求更強(qiáng)力的數(shù)據(jù)輸出來(lái)確保渲染能力,這時(shí)候他們可能比屏幕刷新率(60Hz)更快瞒瘸,就會(huì)導(dǎo)致屏幕不能完整的渲染所有GPU給他的數(shù)據(jù)坷备,因?yàn)樗粔蚩欤聊坏纳弦粠€沒(méi)渲染完情臭,下一幀就已經(jīng)到來(lái)了省撑,這就導(dǎo)致畫(huà)面的撕裂赌蔑。
這個(gè)時(shí)候我們就要引入VSync了,簡(jiǎn)單來(lái)說(shuō)它就是讓顯卡保持他的輸出速率不高于屏幕的刷新率竟秫,啟用了VSync后娃惯,GPU不再會(huì)給你可憐的60Hz屏幕每秒發(fā)送100幀了,它會(huì)增加每一幀的發(fā)送間隔肥败,確保顯示器能夠有充足的時(shí)間去處理每一幀趾浅。
雙緩沖機(jī)制:
雙緩沖機(jī)制是用于避免或減少畫(huà)面閃爍的問(wèn)題,在單緩沖的情況下馒稍,GPU輸出了一幀畫(huà)面潮孽,緩沖區(qū)就需要馬上獲取這個(gè)畫(huà)面,并交給顯示屏去顯示筷黔,而這段時(shí)間GPU輸出的畫(huà)面就全都丟失了往史,因?yàn)闆](méi)有緩沖區(qū)去承載這些畫(huà)面,就會(huì)造成畫(huà)面的閃爍佛舱。
而在雙緩沖機(jī)制下有一個(gè)Back Frame Buffer和一個(gè)Front Frame Buffer椎例,在GPU繪制完成后,它會(huì)將圖像先保存到Back Frame Buffer中请祖,操作完畢后订歪,會(huì)調(diào)用一個(gè)交換函數(shù),讓繪制完成的Back Frame Buffer上的圖像交換到Front Frame Buffer上肆捕。由于雙緩沖利用了更多顯存與CPU消耗時(shí)間刷晋,從而避免了畫(huà)面的閃爍。
So慎陵?
相信大家都會(huì)遇到過(guò)應(yīng)用卡頓眼虱,卡頓的原因就是因?yàn)閮蓭乃⑿聲r(shí)間間隔大于60幀每秒(約16.67ms),導(dǎo)致用戶感覺(jué)點(diǎn)擊或者滑動(dòng)時(shí)席纽,界面沒(méi)有及時(shí)的響應(yīng)捏悬。
前面提到Core Animation Pipeline是以流水線的形式工作的,在理想的狀況下我們希望它能夠在1/60s內(nèi)完成圖層樹(shù)的準(zhǔn)備工作并提交給渲染進(jìn)程润梯,而渲染進(jìn)程在下一次VSync信號(hào)到來(lái)的時(shí)候提交給GPU進(jìn)行渲染过牙,并在1/60s內(nèi)完成渲染,這樣就不會(huì)產(chǎn)生任何的卡頓纺铭。
但是由于我們使用了我們的魔法UIKit寇钉,所以我們?cè)谠S多后臺(tái)線程進(jìn)行了UI操作,在runloop的結(jié)尾準(zhǔn)備進(jìn)行渲染的時(shí)候舶赔,不同線程提交了不同的渲染信息扫倡,于是我們就擁有了更多的繪制事務(wù),這個(gè)時(shí)候Core Animation Pipeline會(huì)不斷將信息提交顿痪,讓GPU進(jìn)行渲染镊辕,由于繪制事件的不同步導(dǎo)致了GPU渲染的不同步,可能在上一幀是需要渲染一個(gè)label消失的畫(huà)面蚁袭,下一幀卻又需要渲染這個(gè)label改變了文字征懈,最終導(dǎo)致的是界面的不同步。
(如果你真的想要這樣的效果揩悄,可以嘗試一下使用我的DWAnimatedLabel)
另一方面卖哎,在VSync和雙緩沖機(jī)制我們可以看出渲染其實(shí)是一個(gè)十分消耗系統(tǒng)資源的操作(占用顯存與CPU),所以可能會(huì)因?yàn)榇罅康氖聞?wù)和線程之間頻繁的上下文切換導(dǎo)致了GPU無(wú)法處理删性,反而影響了性能亏娜,從而導(dǎo)致在1/60s中無(wú)法完成圖層樹(shù)的提交,導(dǎo)致了嚴(yán)重的卡頓蹬挺。
但我真的很想在后臺(tái)線程操作UI维贺,我能再用黑魔法嗎?
好吧巴帮,其實(shí)是有辦法的溯泣。
Texture or ComponentKit
AsyncDisplayKit(現(xiàn)命名為Texture) 是Facebook開(kāi)源的一個(gè)用于保持iOS界面流暢的框架。
ComponentKit是Facebook開(kāi)源的一個(gè)基于React思想的iOS原生UI開(kāi)發(fā)框架榕茧。它通過(guò)函數(shù)式和聲明的方式構(gòu)建UI垃沦。
讓我們撤銷掉我們對(duì)UIKit施展的各種魔法,回到這個(gè)UI只能在主線程進(jìn)行操作的世界吧用押。這兩個(gè)框架其實(shí)并不是真正的在后臺(tái)線程操作UI肢簿,而是用了更巧妙的方法將一些耗時(shí)的操作異步執(zhí)行,從而繞開(kāi)了UIKit只能在主線程操作的限制蜻拨。
比如Texture創(chuàng)建了各類Node池充,在node中包含了UIView,而Node本身是線程安全的缎讼,所以允許在后臺(tái)線程對(duì)Node進(jìn)行修改纵菌,隨后在第一次主線程訪問(wèn)View的時(shí)候它才會(huì)在內(nèi)部生成對(duì)應(yīng)的View,當(dāng)node的屬性發(fā)生改變的時(shí)候休涤,他也不會(huì)馬上進(jìn)行修改咱圆,而是在適當(dāng)?shù)臅r(shí)機(jī)一次性的在主線程為內(nèi)部的View進(jìn)行設(shè)置。(有點(diǎn)類似于繪圖循環(huán))
而ComponentKit則是通過(guò)創(chuàng)建Component來(lái)描述UI功氨,它也是一個(gè)線程安全的類序苏。可以將Component認(rèn)為是一個(gè)刻板捷凄,而UIView是刻板下的一張紙忱详,渲染則是噴墨的過(guò)程。當(dāng)我們生成了一個(gè)Component的時(shí)候跺涤,就等于生成了一個(gè)View的模版匈睁,在進(jìn)行渲染的時(shí)候只要按照模版進(jìn)行繪制就可以了监透。復(fù)雜的界面可以通過(guò)各種簡(jiǎn)單的Component來(lái)組成。(類似于Flutter的widget)
但是我……
總結(jié)
UIKit不能在主線程進(jìn)行操作航唆,這一個(gè)鐵律只要是熟悉iOS開(kāi)發(fā)的都會(huì)有所耳聞胀蛮,但是往深一層其實(shí)這個(gè)涉及到很多的東西,包括軟件糯钙、整體UIKit框架的實(shí)現(xiàn)粪狼、硬件等等,很多細(xì)節(jié)的東西往往是我們?cè)谄匠S兴雎缘娜伟丁再榄?赡芪覀冎啦荒茉谥骶€程操作,卻不知道其內(nèi)在原因享潜;可能我們知道怎么排查困鸥、處理卡頓,卻不知道其真正的成因剑按;可能我們知道drawRect:方法會(huì)導(dǎo)致CPU飆升窝革,卻不知道原因是上下文的切換導(dǎo)致……