標(biāo)題有點嚇人,但是對于drawRect的評價倒是一點都不過分。在平日的開發(fā)中,隨意覆蓋drawRect方法,稍有不慎就會讓你的程序內(nèi)存暴增。下面我們來看一個例子。
去年的某天午后,北京的霧霾依舊像現(xiàn)在這樣醇厚,我的同事輝哥像往常一樣與我樓下約煙。我見輝哥表情凝重,便詢問究竟。輝哥做了一個畫板功能,但是苦于內(nèi)存問題一直得不到解決。畫板功能很簡單,就是記錄手指觸摸的軌跡然后繪制在屏幕上。下面我們來看一張效果圖:
如圖我們看到左側(cè)內(nèi)存的狀況隨著手指的繪制逐漸惡化。另外細(xì)心的同學(xué)可以觀察到,點擊圖中藍(lán)色矩形按鈕之后,便會彈出畫板,而這時并沒有進(jìn)行任何的手指繪制,內(nèi)存就突變?yōu)?114 MB ,然后每當(dāng)手指繪制開始時,內(nèi)存立即增加到 300 MB 左右穩(wěn)定下來。對于正常的 iOS App 來講,這么大的內(nèi)存消耗是不能容忍的。
下面分析一下原因:
可能的原因有兩個,一是在手指繪制的過程中創(chuàng)建的大量點對象沒有及時釋放或者其他資源沒有及時釋放。
二是系統(tǒng)在繪制的過程中開始大量消耗內(nèi)存。
第一個原因,手指繪制的過程中創(chuàng)建的大量點對象沒有及時釋放或者其他資源沒有及時釋放。這一點我們暫時排除以節(jié)省時間,因為這個畫板功能工程是用ARC寫的,并且我們已經(jīng)做過代碼檢查和使用Instruments工具來檢測內(nèi)存使用情況,這里并沒有所謂的對象沒有及時釋放的問題存在。
第二個原因,系統(tǒng)在繪制的過程中開始大量消耗內(nèi)存。首先我們曾經(jīng)注意到一個詭異并且不尋常的事情就是,當(dāng)黃色的畫板剛剛彈出的時候內(nèi)存就瞬間從 18MB 暴增至 114MB 。這一點更加說明第一個原因不是問題所在,因為這時手指還沒有進(jìn)行任何繪制,也就是說不存在任何點與線的對象,那么內(nèi)存怎么會暴增呢?
這時我們要考慮這個畫板功能是如何實現(xiàn)的,畫板分為兩步,第一步記錄用戶手指的軌跡,這一步會生成大量點的對象(已排除嫌疑)。第二步繪制到視圖或者圖層上,我們平常使用頻繁的繪圖方式基本上是 Quarz2D
的那套 C 語言框架,而繪制代碼所在的地點在哪呢?我們今天的主角終于上場了--drawRect
。
下面我們來看一段畫板功能繪制的代碼:
- (void)drawRect:(CGRect)rect
{
if (!self.paths.count) return;
CGContextRef ctx = UIGraphicsGetCurrentContext();
for (BHBPaintPath *path in self.paths) {
CGContextSaveGState(ctx);
[[UIColor blackColor] set];
[path stroke]; // 關(guān)鍵的一步繪制
CGContextRestoreGState(ctx);
}
}
去掉繪圖上下文棧和其余判斷邊界的代碼,我們只是在當(dāng)前view
上繪制了n
條黑色的線。看起來普普通通的繪圖方式,怎么會導(dǎo)致內(nèi)存的劇增呢?我們現(xiàn)在說罪魁禍?zhǔn)资?code>drawRect證據(jù)并不充分。我們回想畫板剛彈出時的內(nèi)存狀況,接下來我們注釋掉drawRect
所有的代碼。運行的效果圖如下:
效果立竿見影, 注釋掉drawRect
之后,內(nèi)存立刻恢復(fù)正常,我們終于抓到了消耗內(nèi)存的惡鬼,問題就出在對drawRect
方法的覆蓋。 那么抓到了犯人,本文是否應(yīng)該完結(jié)了?非也非也,我們雖說知道了內(nèi)存暴增的原因,但是我們并沒有深入的去分析drawRect
為什么對內(nèi)存的影響這么大,而且我們也沒有給出問題的解決方案。請接著往下看。
那么現(xiàn)在我們分析一下drawRect
導(dǎo)致內(nèi)存暴增的真正原因:重寫drawRect
為何會導(dǎo)致內(nèi)存大量上漲?
要想搞明白這個問題,我們需要擼一擼在 iOS 程序上圖形顯示的原理。在 iOS 系統(tǒng)中所有顯示的視圖都是從基類UIView
繼承而來的,同時UIView
負(fù)責(zé)接收用戶交互。 但是實際上你所看到的視圖內(nèi)容,包括圖形等,都是由UIView
的一個實例圖層屬性來繪制和渲染的,那就是CALayer
。
CALayer
類的概念與UIView
非常類似,它也具有樹形的層級關(guān)系,并且可以包含圖片文本、背景色等。它與UIView
最大的不同在于它不能響應(yīng)用戶交互,可以說它根本就不知道響應(yīng)鏈的存在,它的 API 雖然提供了 “某點是否在圖層范圍內(nèi)的方法”,但是它并不具有響應(yīng)的能力。
在每一個UIView
實例當(dāng)中,都有一個默認(rèn)的支持圖層,UIView
負(fù)責(zé)創(chuàng)建并且管理這個圖層。實際上這個CALayer
圖層才是真正用來在屏幕上顯示的,UIView
僅僅是對它的一層封裝,實現(xiàn)了CALayer
的delegate
,提供了處理事件交互的具體功能,還有動畫底層方法的高級 API。
可以說CALayer
是UIView
的內(nèi)部實現(xiàn)細(xì)節(jié)。
腦補(bǔ)了這么多,它與今天的主題drawRect
有何關(guān)系呢?別著急,我們既然已經(jīng)確定CALayer
才是最終顯示到屏幕上的,只要順藤摸瓜,即可分析清楚。CALayer
其實也只是 iOS 當(dāng)中一個普通的類,它也并不能直接渲染到屏幕上,因為屏幕上你所看到的東西,其實都是一張張圖片。而為什么我們能看到CALayer
的內(nèi)容呢,是因為CALayer
內(nèi)部有一個contents
屬性。contents
默認(rèn)可以傳一個id
類型的對象,但是只有你傳CGImage
的時候,它才能夠正常顯示在屏幕上。 所以最終我們的圖形渲染落點落在contents
身上。 如圖。
contents
也被稱為寄宿圖,除了給它賦值CGImage
之外,我們也可以直接對它進(jìn)行繪制,繪制的方法正是這次問題的關(guān)鍵,通過繼承UIView
并實現(xiàn)-drawRect:
方法即可自定義繪制。-drawRect:
方法沒有默認(rèn)的實現(xiàn),因為對UIView
來說,寄宿圖并不是必須的,UIView
不關(guān)心繪制的內(nèi)容。如果UIView
檢測到-drawRect:
方法被調(diào)用了,它就會為視圖分配一個寄宿圖,這個寄宿圖的像素尺寸等于視圖大小乘以contentsScale
(這個屬性與屏幕分辨率有關(guān),我們的畫板程序在不同模擬器下呈現(xiàn)的內(nèi)存用量不同也是因為它) 的值。
那么回到我們的畫板程序,當(dāng)畫板從屏幕上出現(xiàn)的時候,因為重寫了-drawRect:
方法,-drawRect :
方法就會自動調(diào)用。 生成一張寄宿圖后,方法里面的代碼利用Core Graphics
去繪制 n 條黑色的線,然后內(nèi)容就會緩存起來,等待下次你調(diào)用-setNeedsDisplay
時再進(jìn)行更新。
畫板視圖的-drawRect:
方法的背后實際上都是底層的CALayer
進(jìn)行了重繪和保存中間產(chǎn)生的圖片,CALayer
的delegate
屬性默認(rèn)實現(xiàn)了CALayerDelegate
協(xié)議,當(dāng)它需要內(nèi)容信息的時候會調(diào)用協(xié)議中的方法來拿。當(dāng)畫板視圖重繪時,因為它的支持圖層CALayer
的代理就是畫板視圖本身,所以支持圖層會請求畫板視圖給它一個寄宿圖來顯示,它此刻會調(diào)用:
- (void)displayLayer:(CALayer *)layer;
如果畫板視圖實現(xiàn)了這個方法,就可以拿到layer
來直接設(shè)置contents
寄宿圖,如果這個方法沒有實現(xiàn),支持圖層CALayer
會嘗試調(diào)用:
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx;
這個方法調(diào)用之前,CALayer
創(chuàng)建了一個合適尺寸的空寄宿圖(尺寸由bounds
和contentsScale
決定)和一個Core Graphics
的繪制上下文環(huán)境,為繪制寄宿圖做準(zhǔn)備,它作為ctx
參數(shù)傳入。在這一步生成的空寄宿圖內(nèi)存是相當(dāng)巨大的,它就是本次內(nèi)存問題的關(guān)鍵,一旦你實現(xiàn)了CALayerDelegate
協(xié)議中的-drawLayer:inContext:
方法或者UIView
中的-drawRect:
方法(其實就是前者的包裝方法),圖層就創(chuàng)建了一個繪制上下文,這個上下文需要的內(nèi)存可從這個公式得出:
圖層寬
* 圖層高
*4 字節(jié)
寬高的單位均為像素。而我們的畫板程序因為要支持像猿題庫一樣兩指挪動的效果,我們開辟的畫板大小為:
_myDrawer = [[BHBMyDrawer alloc] initWithFrame: CGRectMake(0, 0, SCREEN_SIZE.width*5, SCREEN_SIZE.height*2)];
我們的畫板程序的畫板視圖它在iPhone6s plus
機(jī)器上的上下文內(nèi)存量就是
1920*2
*
1080*5
*
4 字節(jié)
, 相當(dāng)于79MB
內(nèi)存,圖層每次重繪的時候都需要重新抹掉內(nèi)存然后重新分配。它就是我們畫板程序內(nèi)存暴增的真正原因。
最終我們將內(nèi)存暴增的原因找出來了,那么我們有沒有合理的解決方案呢?
我認(rèn)為最合理的辦法處理類似于畫板這樣畫線條的需求直接用專有圖層CAShapeLayer
。讓我們看看它是什么:
CAShapeLayer
是一個通過矢量圖形而不是bitmap
來繪制的圖層子類。用CGPath
來定義想要繪制的圖形,CAShapeLayer
會自動渲染。它可以完美替代我們的直接使用Core Graphics
繪制layer
,對比之下使用CAShapeLayer
有以下優(yōu)點:
渲染快速。
CAShapeLayer
使用了硬件加速,繪制同一圖形會比用Core Graphics
快很多。高效使用內(nèi)存。一個
CAShapeLayer
不需要像普通CALayer
一樣創(chuàng)建一個寄宿圖形,所以無論有多大,都不會占用太多的內(nèi)存。不會被圖層邊界剪裁掉。
不會出現(xiàn)像素化。
所以最終我們的畫板程序使用CAShapeLayer
來實現(xiàn)線條的繪制,性能非常穩(wěn)定,效果圖如下:
總結(jié)一下繪制性能優(yōu)化原則:
繪制圖形性能的優(yōu)化最好的辦法就是不去繪制。
利用專有圖層代替繪圖需求。
不得不用到繪圖盡量縮小視圖面積,并且盡量降低重繪頻率。
異步繪制,推測內(nèi)容,提前在其他線程繪制圖片,在主線程中直接設(shè)置圖片。
本文最后一個效果圖為仿寫猿題庫練題畫板功能,demo請在github搜索BHBDrawBoarderDemo
。或者直接戳這里: https://github.com/bb-coder/BHBDrawBoarderDemo。
好了,就是這么多,如有紕漏請不吝指出!
good luck!
原文地址
參考:iOS Core Animation: Advanced Techniques
感謝:AttackOnDobby 及其翻譯團(tuán)隊。