iOS效能優化系列篇之“列表流暢度優化”

Hello_Vincent發表於2018-08-14

這一篇文章是iOS效能優化系列文章的的第二篇,主要內容是關於列表流暢度的優化。在具體內容的闡述過程中會結合效能優化的總體原則進行闡述,所以建議大家在閱讀這篇文章前先閱讀一下上一篇文章:iOS效能優化系列篇之“優化總體原則”

由於平時工作比較忙,兩篇之間的間隔有點久。但這兩篇文章出乎我意料地受到了大家的喜歡,所以我希望後面有時間能把這個系列更新下去,下一步準備寫一篇關於iOS記憶體相關的優化文章。也希望這篇列表流暢度優化的文章能夠給大家帶來一點點啟示。

和上一篇綜述性質的文章不同,這一篇文章工程實用性更強一些,更多的是一些優化技術細節。文中討論了許多可能影響列表流暢度的因素,由於2018 WWDC裡面講述了大量的關於效能優化相關的內容,因此本文也在相關的內容裡面加入2018 WWDC的效能優化部分。

讀者可將本體提及的優化手段或者原理應用到自己的專案中去。但是希望大家在優化過程中,要結合自己的專案具體問題具體分析,因為本文討論的影響流暢度的因素,可能並不是你的應用流暢性不佳的瓶頸,根據我的經驗,大部分流暢的問題都是業務邏輯導致的,反倒什麼離屏渲染啊之類大家耳熟能詳的流暢度的影響因素在實際專案中並沒有想象的那麼大。如果不經實地測量就盲目應用一些優化手段,可能會導致過度優化,事倍功半。

卡頓產生的原因

在總體原則篇中提到,五大原則中的其中一個就是要理解優化任務的底層執行機制,因為只有深入瞭解底層機制才能更好的有針對性的提出更優的解決方案,所以在進行列表流暢度優化前,我們一定要弄清楚一個view從建立到顯示到螢幕上都經歷了那些過程,在這些過程中那些方面可能會導致效能瓶頸,以及造成卡頓的底層原因是什麼。

我們知道iOS裝置大部分情況下,螢幕重新整理頻率是60hz(ProMotion下是120hz),也就是每隔16.67ms會進行一次螢幕重新整理。每次重新整理時,需要CPU和GPU配合完成一次影象顯示。其主要流程如下:

應用內:

  • 佈局。CPU建立view,設定其屬性(frame、background color等等)
  • 建立backing images。setContents將一個image傳給layer或者通過 drawRect:或 drawLayer:inContext繪製
  • 準備。Core Animation將layer傳送到render server前的一些準備工作,比如圖片解碼等。
  • 提交。Core animation將layers打包通過 IPC (Inter-Process Communication) 傳送到render server

應用外(render server):

  • 設定用來渲染的OpenGL triangles(如果是有動畫,還需計算動畫layer的屬性的中間值)。
  • 渲染這些可見的triangles,將結果提交到視訊緩衝區
  • 視訊控制器以60hz頻率讀取緩衝區內容顯示到顯示器,如果在16.67ms內沒有完成提交,則會被丟棄。

iOS效能優化系列篇之“列表流暢度優化”

從上面的圖中可以看到,在view顯示的過程中,CPU和GPU都各自承擔了不同的任務,CPU和GPU不論哪個阻礙了顯示流程,都會造成掉幀現象。所以優化方法也需要分別對CPU和GPU壓力進行評估和優化,在CPU和GPU壓力之間找到效能最優的平衡點, 無論過度優化哪一方導致另一方壓力過大都會造成整體FPS效能的下降。而尋找平衡點的過程則因專案特點不同而不同,並沒有一套通用的方法,需要我們用instrument等效能評測工具,根據實際app的效能度量結果去做優化,不能憑空亂猜。

CPU優化

我們先看table view在滑動過程中CPU佔用的情況。

instruments 截圖

從上圖可以看出,在滑動過程中CPU佔用特點是:

  • 滑動時CPU佔用率高、空閒時CPU佔用率底
  • 主執行緒CPU佔用高、子執行緒CPU佔底

根據上述特點我們可以做如下優化:

預載入,空間換時間

為什麼要預載入:

  • 滑動時CPU佔用過高,16.67ms內無法完成內容提交—>導致卡頓
  • 滑動時CPU佔用率高,但空閒時CPU佔用率底—>CPU佔用分佈特點
  • 利用CPU空閒時間預載入,降低滑動時CPU佔用峰值—>解決卡頓

通過預載入我們希望達到的CPU理想佔用效果如下:

iOS效能優化系列篇之“列表流暢度優化”

預載入內容:

靜態資源預載入

  • 如何預載入:建立列表前找時機載入。如啟動時、viewDidLoad、runloop空閒時等等
  • 載入內容:快取在磁碟的網路資料、圖片、其他滑動時需要的耗時的資源
  • 注意事項:在預載入帶來的滑動效能提升和記憶體佔用增加之間權衡

動態資源預載入

  • 如何預載入:

    • 在iOS10以後,UITableView和UICollectionView提供了預載入機制,iOS12開始prefeatching做了優化,不再與cell的載入同時併發進行,而是cell載入完成之後序列開始prefeatch,從而優化了流暢度
    • iOS10以前,也可以自己實現類似機制,主要利用的機制有:
      • UIScrollViewDelegate 提供滑動開始、結束、速度時機回撥
      • indexPathsForRowsInRect 和layoutAttributesForElementsInRect 提供預載入的indexPath
      • 可根據滑動速度動態調整載入的量
  • 載入內容:

    • Cell的高度、subView的佈局計算
    • 拉取網路資料
    • 網路圖片
    • 其他耗時的資源
  • 注意事項:

    • 在預載入帶來的滑動效能提升和記憶體佔用增加之間權衡
    • 注意資料過期的問題

WWDC 2018中講到了一個iOS12的底層優化點,蘋果工程師在效能調優的時候發現一個導致丟幀的奇怪case,在沒有其他後臺執行緒執行、只有滑動的情況下,會比有少量的後臺執行緒的情況更容易掉幀。通過調研CPU的排程演算法發現,在僅有滑動的情況下,為了省電,CPU佔用會保持比較底,但是這樣CPU會花更多的時間來計算,就會導致可能錯過這一幀。所以iOS12中,會把UIKit框架上所有的資訊(滑動資訊以及滑動frame的關鍵時間點)傳遞給底層CPU效能控制器,這樣CPU可以更智慧排程以在frame截止的時機內完成CPU計算。這部分屬於系統底層的優化,對於應用開發者只要應用執行在iOS12就可以獲得這部分優化。

多執行緒

為什麼要多執行緒:

  • UIKit 大部分API只能在主執行緒呼叫, 特別是一些耗時的操作,如view的建立,佈局和渲染預設都是在主執行緒上完成
  • 主執行緒任務過多,16.67ms內無法完成,導致卡頓
  • 將非主執行緒必須的任務,移到子執行緒中,減輕主執行緒負擔
  • 多核處理器,多執行緒可以發揮多核併發優勢,提高效能

最終通過多執行緒,我們希望CPU佔用達到如下效果:

iOS效能優化系列篇之“列表流暢度優化”

使用多執行緒注意事項:

  • 主執行緒最大程度上減少非主執行緒必須的任務
  • 控制子執行緒數量在合理的範圍內,防止執行緒爆炸,一定要根據專案實際CPU佔用特點,有針對的使用多執行緒。

可在子執行緒中進行的任務

  • 圖片解碼
  • 文字渲染,UILabel和UITextview都是在主執行緒渲染的,當顯示大量文字時,CPU的壓力會非常大。特別是對於一些資訊類應用,這部分耗時相當大,對流暢度的影響也十分明顯。對此可以自定義文字控制元件,用TextKit或最底層的CoreText對文字非同步繪製。儘管這實現起來非常麻煩,但其帶來的優勢也非常大,CoreText物件建立好後,能直接獲取文字的寬高等資訊,避免了多次計算(調整 UILabel 大小時算一遍、UILabel 繪製時內部再算一遍);CoreText物件佔用記憶體較少,可以快取下來以備稍後多次渲染。用 [NSAttributedString boundingRectWithSize:options:context:] 來計算文字寬高,用 -[NSAttributedString drawWithRect:options:context:] 來繪製文字。儘管這兩個方法效能不錯,但仍舊需要放到後臺執行緒進行以避免阻塞主執行緒。
  • UIView的drawRect, 由於 CoreGraphic 方法通常都是執行緒安全的,所以影象的繪製可以很容易的放到後臺執行緒進行
  • 耗時的業務邏輯

快取

快取的內容可以是

  • UIView。 view的建立代價很大,一些可以複用的view可以cache。例如UITableView為我們實現的了cell的複用。
  • 圖片。 圖片涉及磁碟IO和解碼,十分耗時,可以考慮快取。
  • 佈局。其實不僅僅是cell的高度可以快取,如果cell裡面有大量的文字圖片等複雜元素,cell的subView的佈局也可以在第一次計算好,用Model的key來快取。避免頻繁多次的調整佈局屬性。在滑動列表(UITableView和UICollectionView)中強烈不建議使用Autolayout。隨著檢視數量的增長,Autolayout帶來的 CPU 消耗會呈指數級上升。具體資料可以看這個文章:pilky.me/36/。在WWDC20…
  • 資料, 網路拉取的資料或者db中的資料
  • 其他建立耗時,可重複利用的資源。 如NSDateFormatter等

更優的實現方式

這裡說的更優的實現方式,主要是指為了實現同一功能或者效果,CPU佔用更小的實現方式。這部分包括的內容其實非常多,也很雜。受限於篇幅和水平有限,這裡筆者僅羅列一些比較常見的點,並針對其中比較重要的drawRect優化和圖片優化內容做進一步的講解。

  • drawRect優化
  • 圖片優化
  • 演算法的時間複雜度優化。我們知道演算法的時間複雜 O(1) < O(log n) < O (n) < O(n^2)... 大家可能覺得iOS開發過程中使用的演算法並不多,演算法對效能影響並不明顯。其實不然,舉iOS中的一個例子:IGListDiff採用空間換時間的方式,使得比較的演算法複雜度從 O(n^2) 變成 O(n)。IGListKit-diff-實現簡析 。還比如不同容器的選擇,會帶來不同的查詢、插入、刪除的時間複雜度,在大的資料量下也會帶來不同的效能表現。
  • storyboard VS 程式碼建立view
  • frame VS autolayout autolayout效能度量iOS12優化了autolayout的效能,耗時由指數變為線性耗時
  • UIView VS CAlayer 後者更輕量,在不需要處理觸控事件的場景可以考慮使用CAlayer。UIView層級太多,會導致建立、佈局等較耗時,可以儘量扁平化,甚至可以非同步在子執行緒畫到一個Image上。
  • UIImageView animationImages VS CAAnimation
  • NSDateFormatter dateFromString VS NSDate dateWithTimeIntervalSince1970:
  • 更優的業務邏輯。大家平時在效能優化的時候,已經要優先去排查業務邏輯這塊,仔細梳理。個人經驗很多效能問題都是由不合理的業務邏輯導致的。使用Instruments的time profiler工具仔細觀察耗時的業務邏輯,做好梳理和優化工作。
  • 其他

下面詳細講下drawRect優化和圖片優化

drawRect優化

  • 首選使用CAShapeLayer替代drawRect,在大多數場景下,都可以使用CAShapeLayer替代drawRect。二者對比:

    • CAShapeLayer使用GPU硬體加速,更快。GPU對高度並行的浮點運算做了優化。而drawRect使用CPU繪圖,相比之下會很慢,而且十分耗CPU
    • CAShapeLayer佔用記憶體更少。因為不會建立寄宿圖,因此無論多大都不會佔用太多記憶體。而drawRect圖層每次重繪的時候都需要重新抹掉記憶體然後重新分配,十分佔用記憶體。詳見記憶體惡鬼drawRect
    • CAShapeLayer不會被圖層邊界剪裁掉
    • CAShapeLayer不會出現畫素化,通過向量圖繪製而不是bitmap
    • CAShapeLayer有很多屬性可以方便的做動畫,比如使用strokeStart和strokeEnd可以做出了很漂亮的動畫
  • 非同步繪製。可以使用非同步繪製的方式,在子執行緒繪製好獲得image,然後交給主執行緒。

  • Dirty Rectangles: 可以使用setNeedsDisplayInRect標記Dirty Rectangles,僅重繪指定區域,也會極大提升效能。

圖片優化

在大多數app中,圖片絕對是使用最頻繁的資源之一,我們知道磁碟和網路的載入速度和記憶體比要慢很多,而一般圖片都比較大,I/O十分耗時。而且圖片還涉及解碼,也是一項十分消耗CPU的工作,因此圖片的優化對app的效能有著十分關鍵的作用。談談iOS中圖片的解壓縮

在之前將的優化總體原則的時候,我們說過需要理解優化物件的執行機制,我們先了解下圖片顯示原理:

  • 從磁碟或者網路載入一張圖片,此時圖片未解碼
  • 圖片賦值給UIImageView
  • 在主執行緒中解碼,非常耗時的 CPU 操作
  • CATransaction捕捉到layer tree的變化
  • 在main run loop, 提交transaction:
    • 如果圖片資料沒對齊,Core Animation會拷貝一份資料,進行位元組對齊
    • GPU處理點陣圖資料,進行渲染

針對上面的過程,我們的優化手段主要有:

  • 非同步下載/讀取圖片,這樣可以防止這項十分耗時的操作阻塞主執行緒。
  • 預處理圖片大小。如果UIImage大小和UIImageview的size不同的話,CPU需要提前預處理,這是一項十分消耗CPU的工作,特別是在一些縮圖的場景下,如果使用了十分大的圖片,不僅會帶來很大的CPU效能問題,還會導致記憶體問題。我們可以用instruments Core Animation 的Misaligned Image debug選項來發現此問題。這裡可以使用ImageIO中的CGImageSourceCreateThumbnailAtIndex等相關方法進行後臺非同步downsample,可以在CPU和記憶體上獲得很好的效能。
  • UIImageView frame取整。檢視或圖片的點數(point),不能換算成整數的畫素值(pixel),導致顯示檢視的時候需要對沒對齊的邊緣進行額外混合計算,影響效能。藉助ceilf()、floorf()、CGRectIntegral()等將小數點後資料除去即可。我們可以用instruments Core Animation 的Misaligned Image debug選項來發現此問題
  • 使用mmap,避免mmcpy。解碼圖片 iOS從磁碟載入一張圖片,使用UIImageVIew顯示在螢幕上,需要經過以下步驟:從磁碟拷貝資料到核心緩衝區、從核心緩衝區複製資料到使用者空間。使用mmap記憶體對映,省去了上述第2步資料從核心空間拷貝到使用者空間的操作,具體可以參考FastImageCache的實現
  • 子執行緒解碼。如果我們使用imgView.image = img; 如果圖片沒有解碼,則會在主執行緒進行解碼等操作,會極大影響滑動的流暢性。
  • 位元組對齊,如果資料沒有位元組對齊,Core Animation會再拷貝一份資料,進行位元組對齊,也是十分消耗CPU。
  • iOS 12引入了Automatic Backing Store這項技術。通過在保證色彩不失真的基礎上,使用更少的資料量,去表達一個畫素的顏色。在UIView.draw()、UIGraphicsImageRenderer、UIGraphicsImageRenderer.Range中是預設開啟的。其實我們自己可以針對圖片的特點,採用更少的byte來標示一個畫素佔用的空間,FastImageCache就是使用這種優化手段,有興趣的讀者可以去了解一下。
  • 我們日常開發中可以使用一些比較經典的圖片快取庫,比如SDWebImage、 FastImageCache、YYImage等。這些第三方庫替我們完成的大部分優化的工作,而且介面也十分友好。我們可也使用這些第三方庫幫助我們獲得更好的效能體驗。

GPU優化

CPU和GPU之所以大不相同,是由於其設計目標的不同,它們分別針對了兩種不同的應用場景。CPU需要很強的通用性來處理各種不同的資料型別,同時又要邏輯判斷又會引入大量的分支跳轉和中斷的處理。這些都使得CPU的內部結構異常複雜。而GPU面對的則是型別高度統一的、相互無依賴的大規模資料和不需要被打斷的純淨的計算環境。所以CPU擅長邏輯控制,序列的運算。和通用型別資料運算不同,GPU擅長的是大規模併發計算,這也正是密碼破解等所需要的。所以GPU除了影象處理,也越來越多的參與到計算當中來。參考

iOS中GPU在顯示方面的工作主要是:接收提交的紋理(Texture)和頂點描述(三角形),進行變換(transform)、混合並渲染,然後輸出到螢幕上。螢幕上的內容,主要也就是紋理(圖片)和形狀(三角模擬的向量圖形)兩類。一般來說,CALayer的大多數屬性都是使用GPU來繪製的。雖然GPU在處理影象等渲染是速度很快,但如果開發過程中使用不當,仍會導致GPU佔用過高,渲染速度跟不上螢幕重新整理導致卡頓。

對GPU消耗比較高的操作有:

  • 紋理的渲染

    所有的 Bitmap,包括圖片、文字、柵格化的內容,最終都要由記憶體提交到視訊記憶體,繫結為 GPU Texture。不論是提交到視訊記憶體的過程,還是 GPU 調整和渲染 Texture 的過程,都要消耗不少 GPU 資源。當在較短時間顯示大量圖片時(比如 TableView 存在非常多的圖片並且快速滑動時),CPU 佔用率很低,GPU 佔用非常高,介面仍然會掉幀。避免這種情況的方法只能是儘量減少在短時間內大量圖片的顯示,儘可能將多張圖片合成為一張進行顯示。

    圖片過大,超過 GPU 的最大紋理尺寸時,圖片需要先由 CPU 進行預處理,這對 CPU 和 GPU 都會帶來額外的資源消耗。目前來說,iPhone 4S 以上機型,紋理尺寸上限都是 4096x4096,更詳細的資料可以看這裡:iosres.com。所以,儘量不要讓圖片和檢視的大小超過這個值。

  • 檢視的混合 (Composing)

    當多個檢視(或者說 CALayer)重疊在一起顯示時,GPU 會首先把他們混合到一起。如果檢視結構過於複雜,混合的過程也會消耗很多 GPU 資源。為了減輕這種情況的 GPU 消耗,應用應當儘量減少檢視數量和層次,並在不透明的檢視裡標明 opaque 屬性以避免無用的 Alpha 通道合成。當然,這也可以用上面的方法,把多個檢視預先渲染為一張圖片來顯示。

  • 圖形的生成

    CALayer 的 border、圓角、陰影、遮罩(mask),CASharpLayer 的向量圖形顯示,通常會觸發離屏渲染(offscreen rendering),而離屏渲染通常發生在 GPU 中。當一個列表檢視中出現大量圓角的 CALayer,並且快速滑動時,可以觀察到 GPU 資源已經佔滿,而 CPU 資源消耗很少。這時介面仍然能正常滑動,但平均幀數會降到很低。為了避免這種情況,可以嘗試開啟 CALayer.shouldRasterize 屬性,但這會把原本離屏渲染的操作轉嫁到 CPU 上去。對於只需要圓角的某些場合,也可以用一張已經繪製好的圓角圖片覆蓋到原本檢視上面來模擬相同的視覺效果。最徹底的解決辦法,就是把需要顯示的圖形在後臺執行緒繪製為圖片,避免使用圓角、陰影、遮罩等屬性。

常用優化手段

  • 減少檢視數量和層次,可把多個檢視預先渲染為一張圖片

  • 不要讓圖片和檢視超過GPU可渲染的最大尺寸

  • 檢視不透明

  • 防止離屏渲染 OpenGL 中,GPU 螢幕渲染有以下兩種方式:

    • On-Screen Rendering 意為當前螢幕渲染,指的是 GPU 的渲染操作是在當前用於顯示的螢幕緩衝區中進行。
    • Off-Screen Rendering 意為離屏渲染,指的是 GPU 在當前螢幕緩衝區以外新開闢一個緩衝區進行渲染操作。

    相比於當前螢幕渲染,離屏渲染的代價是很高的,主要體現在兩個方面:

    • 建立新緩衝區 要想進行離屏渲染,首先要建立一個新的緩衝區。
    • 上下文切換 離屏渲染的整個過程,需要多次切換上下文環境:先是從當前螢幕(On-Screen)切換到離屏(Off-Screen);等到離屏渲染結束以後,將離屏緩衝區的渲染結果顯示到螢幕上有需要將上下文環境從離屏切換到當前螢幕。而上下文環境的切換是要付出很大代價的。

    所以在圖形生成的步驟我們要儘可能的避免離屏渲染

優化工具

iOS開發中,在GPU優化上,我們一般使用instruments中的Core Animation工具來進行滑動流暢度優化,在Core Animation中我們可也看到列表滑動過程中的FPS,其中有一些很有用的debug選項,幫助我們找到程式碼中有效能問題的程式碼。下面是一些常用的選項:

  • Color Blended Layers

    Color Blended Layers是用來檢測個半透明圖層的混合區,渲染程度對螢幕中的混合區域進行綠到紅的高亮。因為計算混合區的顏色時,導致overdraw,消耗一定的GPU資源,是導致滑動效能的一個因素。所以儘量要儘量避免

    在開發過程中,避免Blended Layers的手段有:

    • 設定opaque屬性YES
    • View背景顏色不透明
    • Image不含有透明通道
    • 需要特別注意的是,在iOS8之後,UILabel使用的是CALayer作為底圖層,而在iOS8開始,UILabel的底圖層變成了_UILabelLayer,繪製文字也有所改變。UILabel顯示中文時,還需masksToBounds = YES。
  • Color Hits Green and Misses Red Color Hits Green and Misses Red用來檢測是否正確使用shouldRasterize,當快取需要重新生成時,紅色高亮rasterized layers,當設定shouldRasterize=YES,會將layer預先渲染成點陣圖,並快取。以提高效能。但是如果cache頻繁重複地生成,表示shouldRasterize可能帶來的是負面的效能影響。因此shouldRasterize適用於渲染耗時、影象內容不變的情況,在列表中由於內容要頻繁變化,因此不推薦使用此屬性

  • Color Copied Images

    大多數時,Core Animation只需要提交原始圖片的指標到render server,不涉及記憶體copy。但是一些情況下,Core Animation不得不copy一份圖片傳送到render server。蘋果的GPU只解析32bit的顏色格式,如果圖片顏色格式不對,CPU會預先格式轉換。copy images是非常耗CPU的操作,一定要避免。

  • Color Misaligned Images 被拉伸縮放的圖片、無法正確對齊到畫素的圖片(可能有不是整數的的座標)。是耗CPU的操作

  • Color Offscreen-Rendered Yellow

    GPU在當前螢幕緩衝區外開闢新的緩衝區進行渲染, 螢幕外緩衝區和當前螢幕緩衝區上下文切換是十分耗時的操作

    引起Offscreen-Rendered的操作有:

      - 圓角 cornerRadius masksToBounds同時設定
      - 設定shadow
      - 開啟光柵化 shouldRasterize=YES.CALayer 有一個 shouldRasterize 屬性,將這個屬性設定成 true 後就開啟了光柵化。開啟光柵化後會將圖層繪製到一個螢幕外的影象,然後這個影象將會被快取起來並繪製到實際圖層的 contents 和子圖層,對於有很多的子圖層或者有複雜的效果應用,這樣做就會比重繪所有事務的所有幀來更加高效。但是光柵化原始影象需要時間,而且會消耗額外的記憶體。光柵化也會帶來一定的效能損耗,是否要開啟就要根據實際的使用場景了,圖層內容頻繁變化時不建議使用。最好還是用 Instruments 比對開啟前後的 FPS 來看是否起到了優化效果。
      - 圖層蒙板
    複製程式碼

避免Offscreen-Rendered的方式可以其他方式實現圓角、shadow + shadowPath等。

總結

本文的講了一些造成卡頓的原因,以及CPU和GPU優化的常用技巧和工具,大家在優化的時候可以作為參考。但不要把優化手段侷限在這些方面,不同的應用有各自不同的特點,一定要具體問題具體分析。甚至可以跳出技術範疇,在互動方面做一些文章,比如在減少列表每次從伺服器獲取的資料數量、採用使用者手動點選觸發獲取更多資料而不是滑動過程中自動獲取、使用互動動畫等都可以極大改善使用者的滑動體驗。

最後還是要強調一下我上一篇文章講的優化時候需要注意的幾大原則,這樣才能在優化過程中有更好的全域性觀,儘量少走彎路,希望大家能夠在優化過程中時刻牢記。

相關文章