文檔更新說明
- 最后更新 2020年03月22日
- 首次更新 2020年03月27日
前言
現在的iPhone性能越來越好, 正常開發(fā)一個界面都很少會遇到影響體驗的卡頓. 但是如果把APP放到比較老的型號上, 卡頓就非常常見了. 利用這篇文章, 結合一下實際的案例QQ音樂首頁, 聊一聊解決卡頓的基本思想和方法論.
這是QQ音樂的界面


這是Demo的界面, 部分素材找不到就臨時用別的代替一下, 效果基本一致


演示的機器是iPhone 6 Plus, iOS 10.2, Xcode 11.3
源碼下載
首頁的實現思路
整體UI結構
先用一個UITableView實現界面的整體, 而每一個能夠進行左右滾動的UITableViewCell, 都嵌套一個UICollectionView來做.
雖然說UICollectionView比較重量級, 不過我在老古董iPhone6 Plus上看, CPU占有率只有10%左右, 完全可以接受的.
至于其他的能支持橫向滾動并且復用視圖的組件, 這東西我個人認為, 只有系統(tǒng)提供的視圖無法優(yōu)化到滿意的情況下, 再去造輪子或者用新輪子, 要看看額外做的工作和得到的收益是不是值得.

布局方式
先用Auto Layout + XIB文件的形式開發(fā)視圖. 自動布局相比手動布局, 好處就是速度快一些, 現在第一個版本用的是自動布局, 假如后面優(yōu)化之后還有明顯卡頓的話, 再考慮代碼布局.
首頁類型劃分
頂部搜索框
QQ音樂的搜索框會隨著頁面向上移動而移動, 但是頁面向下移動的時候, 搜索框則固定不動. 所以這里采用一個獨立的UIView, 存放搜索框也左邊的音樂館label, 以及右邊的Logo

并且監(jiān)聽了TableView的contentOffset屬性, 根據滾動的偏移量來設置搜索視圖的位置. 這里用到了我之前做過的一個支持自動釋放的便捷觀察者類庫 "NSObject+CCEasyKVO.h" , 有興趣可以看代碼.
Banner
搜索框下面是一個可以左右滾動的Banner, 網上輪子很多, 這里就不重新做了.
固定內容的視圖
這部分界面有5個圖標, 因為是固定不變也不可以滾動的, 所以可以直接用普通的UIView或者UIStackView來做, 這里我直接用UICollectionView實現.
再用另一個UITableVIewCell存放下方的歌單新碟, 數字專輯 兩個普通的UIView.


橫向瀑布流
#話題部分 Topic是一個橫向瀑布流視圖, 采用自定義UICollectionViewFlowLayout實現.

創(chuàng)建TopicWalterfallFlowLayout類, 繼承自UICollectionViewFlowLayout, 重寫prepareLayout方法, 算好每一個Topic的文本寬度并且緩存起來, 這樣TopicWalterfallFlowLayout就可以算出全部CollectionCell的位置了. 效果如上圖.
各種不同的CollectionViewCell
往下的可以橫向滾動的視圖都用UICollectionView實現, 其中分為多種不同的Cell. QQ音樂首頁的Cell種類是后臺配置的, 我這里只挑選其中幾種實現, 其他的都是一樣的道理.



其中歌單的Cell, 因為要在圖片上顯示白色的文本, 所以我在圖片上加了一個灰色漸變蒙版, 這樣底部的數字看起來才會清晰. 不然遇到白色圖片文字就看不清了. 另外兩個Cell也是同理 不過截圖沒體現出來. 這些圓角都使用下面兩行代碼搞定
self.maskV.layer.masksToBounds = YES;
self.maskV.layer.cornerRadius = 10.f;
到這里基本就把首頁的UI結構介紹完畢了. 這個版本的代碼可以從tag v1獲取.
代碼優(yōu)化
興匆匆地運行了一下tag v1代碼, 在iPhone xs max上挺流暢的, 有點失望, 這不是沒得優(yōu)化嗎??.
換個手機, 在iPhone6p上跑了一下, 問題來了. 好卡, 略興奮, 卡頓還挺嚴重的, 這種會影響到用戶體驗, 沒優(yōu)化好肯定不能上線的.
不過有一點讓我覺得奇怪的是, 屏幕上顯示的FPS一直是60, CPU占有率只有15%, 這種卡頓很容易讓人猜出來是GPU處理不過來. 因為FPS指示器用的是CADisplayLink加一個整形變量實現的, 計算出CADisplayLink每秒調用的次數就是幀率. 既然這個FPS一直是60, 那么意味著CPU還是能處理的過來的.
借助性能調試工具Instruments中的Core animation, 可以看到真實的幀率.

幀率在40幀左右, GPU使用率高達90%, 說明我猜的沒錯, 下面要做的事情就是平衡GPU和CPU的工作量.
下面分別從這兩個角度來談代碼優(yōu)化問題.
GPU 優(yōu)化
一說到優(yōu)化, 很多人都知道圓角這些會影響性能, 可以用帶圓角的圖片啊, 用CGContext畫帶圓角圖片之類的來取代對視圖圓角的設置, 但是并不知道為什么要這么解決. 這會導致無法對出現的卡頓現象做比較深入的分析, 無法精準解決問題.
比如一開始我就對Topic部分帶圓角的視圖設置了masksToBounds=YES , 然后胡亂打開了光柵化等, 沒有指導思想碰運氣式地解決問題, 效率并不高.
GPU使用率過高, 常見的原因有下面幾個
- 太多紋理(texture)要處理, 比如一個View有太多子Layer.
- 渲染的視圖有陰影, 圓角.
- Layer上有Mask.
- View采用模糊顯示, 比如用了UIVisualEffectView.
- 柵格化(shouldRasterize)圖層緩存命中率過低.
上面這幾個比較常見.
其中陰影,圓角, Mask, Effect, shouldRasterize這幾個會觸發(fā)GPU離屏渲染, 優(yōu)化GPU的大部分方式, 就是如何處理好離屏渲染. 離屏渲染是GPU的性能殺手, 這里有必要去了解一下.
iOS的渲染過程
從CPU計算好視圖內容, 到顯示在屏幕上給用戶觀看, iOS的UI渲染一共經歷了下面幾個過程.

我們的代碼運行在Application層, CPU計算好視圖信息(座標尺寸, 視圖文本信息, 圖層關系等), 會把數據提交到Render Server層, 接著進入GPU渲染, 再顯示到屏幕上.
實際過程比這個復雜, 可以找一下資料看看這個具體過程
什么是渲染? 光柵化?
一定要先搞明白什么叫渲染, 不然對這個渲染知識點只會是似懂非懂. 這里只討論2D領域.
所謂的渲染, 粗魯地說, 就是把幾何圖形, 圖片數據, 文本等一大堆用來表達視圖內容的東西, 計算成像素圖(位圖), 并且把像素圖放到frame buffer中, 這個過程就叫渲染! 顯示器就可以讀取frame buffer的數據, 顯示到屏幕上.
渲染里面經??吹焦鈻呕@個詞, 它指的是把幾何圖形像素化, 粗淺理解, 光柵化可以等同于渲染.
這部分知識點應該足夠我們做UI性能優(yōu)化了...
看到一個很有意思的比喻, 如果把渲染比作做菜, 那么你起鍋擺盤就是光柵化。
什么是GPU渲染, 什么是CPU渲染?
上面說的視圖信息其實就是用的CALayer來表示, 由Core Animation這個框架負責傳給GPU渲染(硬件渲染), 這就是為啥說用CALayer及其子類(CAShapeLayer等)來展示視圖信息效率高, 因為它最后會由GPU渲染.
而平時我們可能會自己用CoreGraphics這個框架, 創(chuàng)建一個圖形上下文CGContext, 畫啊畫, 再得到一個UIImage, 賦值給layer.contents, 這個步驟其實就是我們自己手動用CPU渲染(軟件渲染)出像素數據, 這樣Core Animation就會直接把這個contents的內容放入到frame buffer中, 顯示器直接讀取frame buffer, 就可以把它里面一個一個像素打到屏幕上了.

當然渲染并不是一次完成, 比如一個視圖有很多個子視圖, 渲染的時候就要從最下層開始, 一層一層把視圖內容渲染到frame buffer中, 這種方式, 稱為畫家算法.
PS. 有關資料顯示, iOS采用雙緩沖技術, 實際上是有兩個frame buffer, 用來加快渲染效率, 不管它有多少個, 原理都是一樣. frame buffer(緩沖區(qū)), 就是一塊內存區(qū)域, 用來存放即將顯示到屏幕上的像素數據.
為什么會出現離屏渲染?
上面說到渲染就像在畫畫一樣, 一層一層畫, 前面畫上去的東西就不能修改了.
這就導致有些視圖是無法直接渲染到frame buffer中, 比如有圓角, Mask, 陰影這些.
帶圓角需要裁剪的視圖, 它的所有子視圖也需要跟著裁剪, 要提高裁剪效率, 最好的做法就是把全部圖層依次畫到frame buffer中, 然后再裁剪. 不過前面已經說了, 畫進去的東西就不能改了, 所以GPU只能在另一個地方開辟一個新的frame buffer用來存放臨時的渲染結果, 然后再把最終結果復制到frame buffer. 這塊新的frame buffer也叫離屏緩沖區(qū), 自然這個過程就叫做離屏渲染了.
可以看到, 離屏渲染需要GPU不停地切換工作環(huán)境, 從一個frame buffer切換到另一個frame buffer, GPU的工作環(huán)境稱為上下文, 不停切換上下文, 會嚴重降低GPU的工作效率. 這塊涉及到GPU的工作原理, 不是我的專業(yè)范圍就不多說了.

Mask和陰影這些也是同個道理, 只有把全部視圖都畫好了, 才能知道裁剪的形狀或者陰影的路徑, 所以這個渲染的方式會轉化成離屏渲染.

CALayer有個shadowPath, 設置好它GPU就可以事先知道陰影路徑, 就不需要離屏渲染了. 可以看上圖, 紅色陰影就是用shadowPath實現的; 而圓角的設置, 如果不需要裁剪子視圖的話, 把masksToBounds設置成NO, 也不會造成離屏渲染. 下文會講到這個.
注意, 不同版本iOS系統(tǒng)對渲染的處理會有差異, 如果能找到一次性渲染好視圖的算法, 就不需要離屏了, 所以判斷是不是離屏必須用專門的工具, 而不能單憑直覺
開始優(yōu)化 tag v1
上面這部分知識點, 是優(yōu)化的核心指導思想.
開啟離屏檢測, 看看首頁的渲染情況
Debug->View Debugging->Rendering->Color Offscreen-Rendered Yellow

和預期的一樣, 所有圓角區(qū)域都是離屏渲染.
嘗試開啟光柵化, 設置CALayer.shouldRasterize=YES, 這樣視圖只需要離屏渲染一次, 就會把
內容緩存起來供下次使用, 提升性能.

開啟光柵化后CollectionView里面的視圖都是紅色的, 說明光柵化后無法得到有效緩存, 這樣實際上機器性能消耗, 并沒有好處.
UITableView和UICollectionView這些視圖, 都是在反復利用那幾個Cell, 同時刷新Cell的內容, 這種會復用視圖的, 就會不停更新Layer內容導致緩存命中率超低, 不適合開啟光柵化.
所以通過光柵化并沒法解決問題, 反而界面的幀率只剩下30了.

代碼改回去, 繼續(xù)優(yōu)化, 先針對Topic Collection View優(yōu)化.
觀察一下, Topic 的每一個cell里面雖然也有圓角, 但是只包含了文本, 并沒有圖片, 顯示圓角的背景色并不需要設置masksToBounds.
官方說了這個問題, 指針對contents的圓角, 才需要設置masksToBounds.
Setting the radius to a value greater than 0.0 causes the layer to begin drawing rounded corners on its background. By default, the corner radius does not apply to the image in the layer’s contents property; it applies only to the background color and border of the layer. However, setting the masksToBounds property to true causes the content to be clipped to the rounded corners.
把Topic視圖相關的masksToBounds =YES代碼移除掉, 重新運行一下, 滾動到topic這個區(qū)間幀數明顯提升, 代碼可以看tag v1.1
開始優(yōu)化 tag v1.1
開始優(yōu)化各種帶圓角圖片的Cell. 這里的指導思想, 就是平衡CPU和GPU的使用率.
GPU不夠, CPU來湊.
iPhone6 Plus的GPU確實不怎么好, 圓角一多占有率飆升到90%了. 結合上面的知識點, 要做的事情就是把部分GPU的工作交給CPU處理.
利用CoreGraphics框架, 使用CPU渲染帶圓角的圖片, 在設置給layer.contents, 同時關閉masksToBounds, 這樣即可減輕GPU的工作量.
這里我利用YYAsyncLayer來實現CPU異步渲染, YYAsyncLayer的原理很簡單, 當layer需要display的時候, 開啟一個異步線程, 創(chuàng)建CGContextRef畫布, 用戶可以在這個異步線程里把視圖內容畫到CGContextRef里, 然后YYAsyncLayer會在主線程幫你把渲染好的內容賦值給layer.contents.
YYAsyncLayer內部根據CPU核數定義了若干串行隊列, 放到隊列池里, 每次要渲染的時候就從池里一次按順序取出一個串行隊列, 異步執(zhí)行CoreGraphics渲染代碼, 這樣做的好處就是能控制并發(fā)線程數. 不過我覺得用NSOperationQueue來實現就可以了, 沒必要搞這么復雜.
這個庫還提供了一個事務類YYTransaction, 這個類在Runloop上注冊了觀察者, 當Runloop處于kCFRunLoopBeforeWaiting狀態(tài)時觸發(fā), 優(yōu)先級非常低, 適合在程序有空閑的時候處理業(yè)務邏輯, 后面CPU優(yōu)化部分會用到.
這里我封裝了一個支持異步CPU渲染圓角圖片和灰色漸變的類AsyncImageView , 核心代碼如下
- (YYAsyncLayerDisplayTask *)newAsyncDisplayTask {
// 在主線程訪問bounds屬性
CGRect bounds = self.bounds;
YYAsyncLayerDisplayTask *task = [YYAsyncLayerDisplayTask new];
task.willDisplay = ^(CALayer *layer) {};
task.display = ^(CGContextRef context, CGSize size, BOOL (^isCancelled)(void)) {
if (isCancelled()) return;
CGContextAddPath(context, [UIBezierPath bezierPathWithRoundedRect:bounds cornerRadius:self.asyncCornerRadius].CGPath);
// 注意必須在把圖片繪制到上下文之前就切割好繪制區(qū)域. 否則切割只對后續(xù)的繪制生效, 對已經繪制好的圖片不生效.
CGContextClip(context);
CGContextSaveGState(context);
CGContextTranslateCTM(context, 0, bounds.size.height);
CGContextScaleCTM(context, 1.0, -1.0);
CGContextDrawImage(context, bounds, self.image.CGImage);
CGContextRestoreGState(context);
if (self.drawMask) {
CGColorSpaceRef rgb = CGColorSpaceCreateDeviceRGB();
CGGradientRef gradient = CGGradientCreateWithColorComponents(rgb, self->_colors, NULL, self.maskColors.count);
CGContextDrawLinearGradient(context, gradient, CGPointMake(size.width / 2, 0), CGPointMake(size.width / 2, size.height), 0);
CGGradientRelease(gradient);
CGColorSpaceRelease(rgb);
}
};
task.didDisplay = ^(CALayer *layer, BOOL finished) {};
return task;
}
前面提到說QQ音樂首頁會在圖片上放一些白色的文本, 一開始的做法是添加一個灰色漸變的視圖蓋在圖片上, 然后文本放灰色視圖上.
這里我順便給AsyncImageView類增加了繪制漸變蒙版的功能, 這樣就不需要額外疊加灰色圖層了, 能提高效率.
利用AsyncImageView替換掉UICollectionViewCell上的UIImageView.

現在豎向滾動的時候, GPU從45幀提高到55~59幀了, 肉眼只能偶爾看到輕微卡頓, 完全可以接受的. 已經到達上線標準了.
滾動時的CPU使用率從之前的15%提升30~50%, GPU從90%下降到28%左右.
由此可見, 通過正確的指導思想, 確實讓CPU核GPU的使用率更加平衡, 用戶體驗也會更好. 這個版本的代碼可以在tag v2.0獲得.
GPU的其他優(yōu)化
上面的優(yōu)化主要是處理離屏渲染, 我覺得離屏渲染是GPU優(yōu)化的重點, 工作量最少, 提升最大. 其他的優(yōu)化, 可以從減少紋理的角度出發(fā).
比如減少透明圖層的使用. 能合并的圖層, 可以先合并到一起. 比如下面這個界面, 是可以從圖片的角度上, 直接提供一張圖片即可

但是這種操作工作量比較大, 首先要修改UICollectionViewCell的視圖結構, 然后還要讓服務器把兩個圖片合成一張, 或者在APP里, 找個主線程空閑時間把兩個圖合成一張再保存起來, 篇幅有限我就不做了, 如果你的程序優(yōu)化了離屏渲染還是很卡, 那就有必要做了.
下面開始著手CPU優(yōu)化, CPU優(yōu)化也有很多指導思想.
CPU 優(yōu)化
CPU的幾種常見優(yōu)化思路
在優(yōu)化之前, 可以用Instruments工具里的Time Profiler時間分析工具, 很方便查看各行代碼的CPU執(zhí)行時間.
常見的CPU優(yōu)化指導思想, 總結起來大概就是這兩點,
- 時間不闊綽, 任務提前做, 就是預處理
- 大事化小, 小事化了, 就是拆分任務
具體到編碼上, 有下面幾種方法
- 文件資源提前加載, 就是預加載
- 取消自動布局, 提前計算視圖frame, 就是預排版
- 提前緩存像素圖, 供下次直接使用, 就是預渲染
- 空間換時間, 就是緩存, 預渲染也屬于緩存的一種.
- 限制線程數, 就是并發(fā)控制
- 代碼布局, 放棄xib, StoryBoard, 就是很麻煩
保持界面流暢, 還要時刻注意不要在主線程上做太多事情, 主線程每一幀只有16.67ms.
此外應該還有其他, 比如對象的釋放放到后臺隊列(這個在YYAsyncLayer里面可以看到YYAsyncLayerGetReleaseQueue), 其他的暫時想不到了.
開始優(yōu)化 v2.0
有了指導思想, 下面開始用Time Profiler找一下哪些任務占用較多CPU資源, 如果能預處理的, 就先預處理.
預處理的時候, 多利用runloop提供的觀察者模式, 盡量把預處理的代碼放到runloop即將休眠的時候處理, 而且每次只處理一個任務, 把任務拆分成多個子任務處理, 盡量避免在一幀的時間內做太多事情.
先關閉FPS視圖, 避免Timer干擾分析.
運行程序


從程序啟動后了1秒左右的時間里, 可以看到消耗CPU的地方幾種在下面幾個.
- 主線程主要工作量在TableViewCell和CollectionViewCell的加載
- 非主線程主要工作量是AsyncImageView的CPU渲染, SDImageCache的圖片加載



通過Time Profiler的代碼分析功能, 可以輕松看到具體的代碼細節(jié), 其中SongListCell中的UICollectionViewCell的xib文件的加載消耗26ms.
一個線程中的AsyncImageView的圖片繪制占用了149ms, 陰影繪制占用了36ms.
一個線程中的SDImageCache緩存加載主要是在圖片解碼的地方, 消耗了119ms.
可以看出來, 首頁消耗CPU的地方就是加載xib文件和圖片解碼, CPU渲染圖片, 因為Dome比較簡單, 所以這個情況是符合預期的.
圖片解碼這塊已經使用了SDWebImage這個框架, 他把解碼操作放到非主線程了, 如果要加載的圖片是在磁盤中有的, 加載后在一個串行IO隊列中解碼, 這個不會有線程爆炸問題. 如果圖片在磁盤沒有的, 需要聯(lián)網下載的, 下載和解碼的邏輯被放到下載隊列中執(zhí)行, 最大并發(fā)數是6, 所以SDWebImage針對當前這個Demo來說, 不會有線程問題.
_ioQueue = dispatch_queue_create("com.hackemist.SDWebImageCache", DISPATCH_QUEUE_SERIAL);
_downloadQueue = [NSOperationQueue new];
_downloadQueue.maxConcurrentOperationCount = _config.maxConcurrentDownloads; // 默認是6
_downloadQueue.name = @"com.hackemist.SDWebImageDownloader";
上線滾動和左右滾動, 都可以看到性能消耗的地方主要是對AsyncImageView的渲染. 滾動時加載Cell后會設置URL, 而AsyncImageView只要被設置了URL, 馬上開始圓角Image的渲染, 同一個圖片來回滾動會被重復渲染, 所以這個地方可以優(yōu)化一下.
思路就是把URL作為key, 渲染出圓角的圖片作為value, 一起保存到內存中. 同時在得到圓角的value后, 還要把SDWebImage從內存緩存里的相同URL的圖片刪除, 避免原圖和圓角圖同時存在內容, 浪費內存空間.
關于緩存類的選擇, 我在NSCache, YYCache, SDMemoryCache中, 選擇了SDMemoryCache. 原因就是這里暫時不需要追求極致性能, SDMemoryCache比較適合緩存圖片.
創(chuàng)建內存緩存對象AsyncImageCache, 繼承自SDMemoryCache, SDMemoryCache繼承自NSCache, 它除了提供系統(tǒng)的緩存功能之外, 還特別適合緩存圖片.
這是因為SDMemoryCache內部定義了一個NSMapTable類型的weakCache, MapTable支持對值弱引用, 這樣做的好處就是如果系統(tǒng)發(fā)起內存警告時, 父類NSCache會把緩存釋放掉, 這樣用戶從緩存里獲取圖片的時候, 如果在weakCache里還存在圖片的話, 說明圖片還顯示在屏幕上, 這時候直接把屏幕上的圖片寫入緩存并返回即可, 效率更高.
- (void)setImageURL:(NSURL *)imageURL {
_imageURL = imageURL;
UIImage *cacheImage = [AsyncImageCache.shareCache objectForKey:imageURL.absoluteString];
if (cacheImage) {
self.layer.contents = (id)cacheImage.CGImage;
} else {
__weak __typeof(self) wself = self;
[SDWebImageManager.sharedManager loadImageWithURL:imageURL options:0 progress:nil completed:^(UIImage *_Nullable image, NSData *_Nullable data, NSError *_Nullable error, SDImageCacheType cacheType, BOOL finished, NSURL *_Nullable imageURL) {
if (!error) {
wself.image = image;
[wself.layer setNeedsDisplay];
}
}];
}
}
- (YYAsyncLayerDisplayTask *)newAsyncDisplayTask {
// 在主線程訪問bounds屬性
CGRect bounds = self.bounds;
YYAsyncLayerDisplayTask *task = [YYAsyncLayerDisplayTask new];
task.willDisplay = ^(CALayer *layer) {};
task.display = 略
task.didDisplay = ^(CALayer *layer, BOOL finished) {
if (finished) {
UIImage *image = [UIImage imageWithCGImage:(__bridge CGImageRef)layer.contents];
[AsyncImageCache.shareCache setObject:image forKey:self.imageURL.absoluteString];
[SDWebImageManager.sharedManager.imageCache removeImageForKey:self.imageURL.absoluteString cacheType:SDImageCacheTypeMemory completion:nil];
}
};
return task;
}
緩存好已渲染的圖片后, 現在滾動時CPU基本保持在20%以下了. 界面相比之前更流暢了, 偶爾會有輕微卡頓.

這個版本的代碼, 可以看 tag v2.1
CPU的其他優(yōu)化
其他優(yōu)化, 比如把xib布局換成代碼布局, 而且取消自動布局, 直接手動計算frame的大小, 再緩存好, 這樣就不用在滾動的時候讓CPU去計算了.
另外也可以考慮一下提前拉取首頁圖片的數據, 先渲染好并緩存起來, 這樣滾動的時候就不需要再去計算了.
上面說的這些預處理預渲染, 可以使用YYTransaction這個對象, 里面封裝好了runloop觀察者, 在runloop快要休眠的時候, 一次性處理已經提交到靜態(tài)transactionSet集合的YYTransaction對象.
當然也可以自己注冊觀察者, 然后弄一個隊列, 每次runloop要休眠的時候就執(zhí)行一下隊頭一個任務即可.
這些篇幅有限精力優(yōu)先, 我就不做了, 本文如有錯誤, 還請指正謝謝.