本文主要介紹界面卡頓的原理以及優(yōu)化
界面卡頓
通常來(lái)說(shuō),計(jì)算機(jī)中的顯示過(guò)程是下面這樣的,通過(guò)CPU
出嘹、GPU
協(xié)同工作來(lái)將圖片顯示到屏幕上
1唯咬、CPU計(jì)算好顯示內(nèi)容纱注,提交至GPU
2、GPU經(jīng)過(guò)渲染完成后將渲染的結(jié)果放入
FrameBuffer
(幀緩存區(qū))3胆胰、隨后
視頻控制器
會(huì)按照VSync
信號(hào)逐行讀取FrameBuffer
的數(shù)據(jù)4狞贱、經(jīng)過(guò)可能的數(shù)模轉(zhuǎn)換傳遞給顯示器進(jìn)行顯示
最開(kāi)始時(shí),F(xiàn)rameBuffer只有一個(gè)蜀涨,這種情況下FrameBuffer的讀取和刷新有很大的效率問(wèn)題瞎嬉,為了解決這個(gè)問(wèn)題蝎毡,引入了雙緩存區(qū)
。即雙緩沖機(jī)制
佑颇。在這種情況下顶掉,GPU
會(huì)預(yù)先渲染好一幀放入FrameBuffer
,讓視頻控制器讀取挑胸,當(dāng)下一幀渲染好后痒筒,GPU會(huì)直接將視頻控制器的指針指向第二個(gè)FrameBuffer
。
雙緩存機(jī)制雖然解決了效率問(wèn)題茬贵,但是隨之而言的是新的問(wèn)題簿透,當(dāng)視頻控制器還未讀取完成時(shí),例如屏幕內(nèi)容剛顯示一半解藻,GPU將新的一幀內(nèi)容提交到FrameBuffer老充,并將兩個(gè)FrameBuffer而進(jìn)行交換后,視頻控制器就會(huì)將新的一幀數(shù)據(jù)的下半段顯示到屏幕上螟左,造成屏幕撕裂
現(xiàn)象
為了解決這個(gè)問(wèn)題啡浊,采用了垂直同步信號(hào)機(jī)制
。當(dāng)開(kāi)啟垂直同步后胶背,GPU會(huì)等待顯示器的VSync信號(hào)發(fā)出后巷嚣,才進(jìn)行新的一幀渲染和FrameBuffer更新。而目前iOS設(shè)備中采用的正是雙緩存區(qū)+VSync
屏幕卡頓原因
下面我們來(lái)說(shuō)說(shuō)钳吟,屏幕卡頓的原因
在 VSync
信號(hào)到來(lái)后廷粒,系統(tǒng)圖形服務(wù)會(huì)通過(guò) CADisplayLink
等機(jī)制通知 App,App 主線程開(kāi)始在CPU中計(jì)算顯示內(nèi)容红且。隨后 CPU 會(huì)將計(jì)算好的內(nèi)容提交到 GPU 去坝茎,由GPU進(jìn)行變換、合成暇番、渲染嗤放。隨后 GPU 會(huì)把渲染結(jié)果提交到幀緩沖區(qū)
去,等待下一次 VSync 信號(hào)到來(lái)時(shí)顯示到屏幕上壁酬。由于垂直同步的機(jī)制斤吐,如果在一個(gè) VSync 時(shí)間內(nèi),CPU 或者 GPU 沒(méi)有完成內(nèi)容提交厨喂,則那一幀就會(huì)被丟棄,等待下一次機(jī)會(huì)再顯示
庄呈,而這時(shí)顯示屏?xí)A糁暗膬?nèi)容不變蜕煌。所以可以簡(jiǎn)單理解掉幀
為過(guò)時(shí)不候
如下圖所示,是一個(gè)顯示過(guò)程诬留,第1幀在VSync到來(lái)前斜纪,處理完成贫母,正常顯示,第2幀在VSync到來(lái)后盒刚,仍在處理中腺劣,此時(shí)屏幕不刷新,依舊顯示第1幀因块,此時(shí)就出現(xiàn)了掉幀
情況橘原,渲染時(shí)就會(huì)出現(xiàn)明顯的卡頓現(xiàn)象
從圖中可以看出,CPU和GPU不論是哪個(gè)阻礙了顯示流程涡上,都會(huì)造成掉幀
現(xiàn)象趾断,所以為了給用戶提供更好的體驗(yàn),在開(kāi)發(fā)中吩愧,我們需要進(jìn)行卡頓檢測(cè)
以及相應(yīng)的優(yōu)化
卡頓監(jiān)控
卡頓監(jiān)控的方案一般有兩種:
FPS監(jiān)控
:為了保持流程的UI交互芋酌,App的刷新拼搏應(yīng)該保持在60fps
左右,其原因是因?yàn)?code>iOS設(shè)備默認(rèn)的刷新頻率是60次/秒
雁佳,而1次刷新(即VSync
信號(hào)發(fā)出)的間隔是1000ms/60 = 16.67ms
脐帝,所以如果在16.67ms
內(nèi)沒(méi)有準(zhǔn)備好下一幀數(shù)據(jù),就會(huì)產(chǎn)生卡頓主線程卡頓監(jiān)控
:通過(guò)子線程監(jiān)測(cè)主線程的RunLoop糖权,判斷兩個(gè)狀態(tài)(kCFRunLoopBeforeSources
和kCFRunLoopAfterWaiting
)之間的耗時(shí)是否達(dá)到一定閾值
FPS監(jiān)控
FPS的監(jiān)控堵腹,參照YYKit
中的YYFPSLabel
,主要是通過(guò)CADisplayLink
實(shí)現(xiàn)温兼。借助link
的時(shí)間差秸滴,來(lái)計(jì)算一次刷新刷新所需的時(shí)間,然后通過(guò) 刷新次數(shù) / 時(shí)間差
得到刷新頻次募判,并判斷是否其范圍荡含,通過(guò)顯示不同的文字顏色來(lái)表示卡頓嚴(yán)重程度。代碼實(shí)現(xiàn)如下:
class CJLFPSLabel: UILabel {
fileprivate var link: CADisplayLink = {
let link = CADisplayLink.init()
return link
}()
fileprivate var count: Int = 0
fileprivate var lastTime: TimeInterval = 0.0
fileprivate var fpsColor: UIColor = {
return UIColor.green
}()
fileprivate var fps: Double = 0.0
override init(frame: CGRect) {
var f = frame
if f.size == CGSize.zero {
f.size = CGSize(width: 80.0, height: 22.0)
}
super.init(frame: f)
self.textColor = UIColor.white
self.textAlignment = .center
self.font = UIFont.init(name: "Menlo", size: 12)
self.backgroundColor = UIColor.lightGray
//通過(guò)虛擬類
link = CADisplayLink.init(target: CJLWeakProxy(target:self), selector: #selector(tick(_:)))
link.add(to: RunLoop.current, forMode: RunLoop.Mode.common)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
link.invalidate()
}
@objc func tick(_ link: CADisplayLink){
guard lastTime != 0 else {
lastTime = link.timestamp
return
}
count += 1
//時(shí)間差
let detla = link.timestamp - lastTime
guard detla >= 1.0 else {
return
}
lastTime = link.timestamp
//刷新次數(shù) / 時(shí)間差 = 刷新頻次
fps = Double(count) / detla
let fpsText = "(String.init(format: "%.2f", fps)) FPS"
count = 0
let attrMStr = NSMutableAttributedString(attributedString: NSAttributedString(string: fpsText))
if fps > 55.0 {
//流暢
fpsColor = UIColor.green
}else if (fps >= 50.0 && fps <= 55.0){
//一般
fpsColor = UIColor.yellow
}else{
//卡頓
fpsColor = UIColor.red
}
attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: fpsColor], range: NSMakeRange(0, attrMStr.length - 3))
attrMStr.setAttributes([NSAttributedString.Key.foregroundColor: UIColor.white], range: NSMakeRange(attrMStr.length - 3, 3))
DispatchQueue.main.async {
self.attributedText = attrMStr
}
}
}
如果只是簡(jiǎn)單的監(jiān)測(cè)届垫,使用FPS
足夠了释液。
主線程卡頓監(jiān)控
除了FPS,還可以通過(guò)RunLoop
來(lái)監(jiān)控装处,因?yàn)榭D的是事務(wù)误债,而事務(wù)是交由主線程
的RunLoop
處理的。
實(shí)現(xiàn)思路:檢測(cè)主線程每次執(zhí)行消息循環(huán)的時(shí)間妄迁,當(dāng)這個(gè)時(shí)間大于規(guī)定的閾值時(shí)寝蹈,就記為發(fā)生了一次卡頓。這個(gè)也是微信卡頓三方matrix
的原理
以下是一個(gè)簡(jiǎn)易版RunLoop監(jiān)控的實(shí)現(xiàn)
//
// CJLBlockMonitor.swift
// UIOptimizationDemo
//
// Created by 陳嘉琳 on 2020/12/2.
//
import UIKit
class CJLBlockMonitor: NSObject {
static let share = CJLBlockMonitor.init()
fileprivate var semaphore: DispatchSemaphore!
fileprivate var timeoutCount: Int!
fileprivate var activity: CFRunLoopActivity!
private override init() {
super.init()
}
public func start(){
//監(jiān)控兩個(gè)狀態(tài)
registerObserver()
//啟動(dòng)監(jiān)控
startMonitor()
}
}
fileprivate extension CJLBlockMonitor{
func registerObserver(){
let controllerPointer = Unmanaged<CJLBlockMonitor>.passUnretained(self).toOpaque()
var context: CFRunLoopObserverContext = CFRunLoopObserverContext(version: 0, info: controllerPointer, retain: nil, release: nil, copyDescription: nil)
let observer: CFRunLoopObserver = CFRunLoopObserverCreate(nil, CFRunLoopActivity.allActivities.rawValue, true, 0, { (observer, activity, info) in
guard info != nil else{
return
}
let monitor: CJLBlockMonitor = Unmanaged<CJLBlockMonitor>.fromOpaque(info!).takeUnretainedValue()
monitor.activity = activity
let sem: DispatchSemaphore = monitor.semaphore
sem.signal()
}, &context)
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, CFRunLoopMode.commonModes)
}
func startMonitor(){
//創(chuàng)建信號(hào)
semaphore = DispatchSemaphore(value: 0)
//在子線程監(jiān)控時(shí)長(zhǎng)
DispatchQueue.global().async {
while(true){
// 超時(shí)時(shí)間是 1 秒登淘,沒(méi)有等到信號(hào)量箫老,st 就不等于 0, RunLoop 所有的任務(wù)
let st = self.semaphore.wait(timeout: DispatchTime.now()+1.0)
if st != DispatchTimeoutResult.success {
//監(jiān)聽(tīng)兩種狀態(tài)kCFRunLoopBeforeSources 黔州、kCFRunLoopAfterWaiting耍鬓,
if self.activity == CFRunLoopActivity.beforeSources || self.activity == CFRunLoopActivity.afterWaiting {
self.timeoutCount += 1
if self.timeoutCount < 2 {
print("timeOutCount = (self.timeoutCount)")
continue
}
// 一秒左右的衡量尺度 很大可能性連續(xù)來(lái) 避免大規(guī)模打印!
print("檢測(cè)到超過(guò)兩次連續(xù)卡頓")
}
}
self.timeoutCount = 0
}
}
}
}
使用時(shí)阔籽,直接調(diào)用即可
CJLBlockMonitor.share.start()
也可以直接使用三方庫(kù)
Swift
的卡頓檢測(cè)第三方ANREye[1],其主要思路是:創(chuàng)建子線程進(jìn)行循環(huán)監(jiān)測(cè)牲蜀,每次檢測(cè)時(shí)設(shè)置標(biāo)記置為true笆制,然后派發(fā)任務(wù)到主線程,標(biāo)記置為false涣达,接著子線程睡眠超過(guò)閾值時(shí)在辆,判斷標(biāo)記是否為false,如果沒(méi)有峭判,說(shuō)明主線程發(fā)生了卡頓OC
可以使用 微信matrix[2]开缎、滴滴DoraemonKit[3]
界面優(yōu)化
CPU層面的優(yōu)化
1、盡量
用輕量級(jí)的對(duì)象
代替重量級(jí)的對(duì)象林螃,可以對(duì)性能有所優(yōu)化奕删,例如 不需要相應(yīng)觸摸事件的控件,用CALayer
代替UIView
2疗认、盡量減少對(duì)
UIView
和CALayer
的屬性修改CALayer內(nèi)部并沒(méi)有屬性完残,當(dāng)調(diào)用屬性方法時(shí),其內(nèi)部是通過(guò)運(yùn)行時(shí)
resolveInstanceMethod
為對(duì)象臨時(shí)添加一個(gè)方法横漏,并將對(duì)應(yīng)屬性值保存在內(nèi)部的一個(gè)Dictionary中谨设,同時(shí)還會(huì)通知delegate、創(chuàng)建動(dòng)畫(huà)等缎浇,非常耗時(shí)UIView
相關(guān)的顯示屬性扎拣,例如frame、bounds素跺、transform等二蓝,實(shí)際上都是從CALayer映射來(lái)的,對(duì)其進(jìn)行調(diào)整時(shí)指厌,消耗的資源比一般屬性要大3刊愚、當(dāng)有大量對(duì)象釋放時(shí),也是非常耗時(shí)的踩验,盡量挪到后臺(tái)線程去釋放
4鸥诽、盡量
提前計(jì)算視圖布局
,即預(yù)排版
箕憾,例如cell的行高5牡借、
Autolayout
在簡(jiǎn)單頁(yè)面情況下們可以很好的提升開(kāi)發(fā)效率,但是對(duì)于復(fù)雜視圖而言袭异,會(huì)產(chǎn)生嚴(yán)重的性能問(wèn)題钠龙,隨著視圖數(shù)量的增長(zhǎng),Autolayout帶來(lái)的CPU消耗是呈指數(shù)上升的。所以盡量使用代碼布局
俊鱼。6、文本處理的優(yōu)化:當(dāng)一個(gè)界面有大量文本時(shí)畅买,其行高的計(jì)算并闲、繪制也是非常耗時(shí)的
計(jì)算文本寬高:
[NSAttributedString boundingRectWithSize:options:context:]
文本繪制:
[NSAttributedString drawWithRect:options:context:]
1)如果對(duì)文本沒(méi)有特殊要求,可以使用UILabel內(nèi)部的實(shí)現(xiàn)方式谷羞,且需要放到子線程中進(jìn)行帝火,避免阻塞主線程
2)自定義文本控件,利用
TextKit
或最底層的CoreText
對(duì)文本異步繪制湃缎。并且CoreText
對(duì)象創(chuàng)建好后犀填,能直接獲取文本的寬高等信息,避免了多次計(jì)算(調(diào)整和繪制都需要計(jì)算一次)嗓违。CoreText直接使用了CoreGraphics占用內(nèi)存小九巡,效率高7、圖片處理(解碼 + 繪制)
1)當(dāng)使用
UIImage
或CGImageSource
的方法創(chuàng)建圖片時(shí)蹂季,圖片的數(shù)據(jù)不會(huì)立即解碼冕广,而是在設(shè)置時(shí)解碼(即圖片設(shè)置到UIImageView/CALayer.contents
中,然后在CALayer
提交至GPU渲染前偿洁,CGImage
中的數(shù)據(jù)才進(jìn)行解碼)撒汉。這一步是無(wú)可避免
的,且是發(fā)生在主線程
中的涕滋。想要繞開(kāi)這個(gè)機(jī)制睬辐,常見(jiàn)的做法是在子線程中先將圖片繪制到CGBitmapContext
,然后從Bitmap
直接創(chuàng)建圖片宾肺,例如SDWebImage
三方框架中對(duì)圖片編解碼的處理溯饵。這就是Image的預(yù)解碼
2)當(dāng)使用CG開(kāi)頭的方法繪制圖像到畫(huà)布中,然后從畫(huà)布中創(chuàng)建圖片時(shí)爱榕,可以將圖像的
繪制
在子線程
中進(jìn)行
8瓣喊、圖片優(yōu)化
1)盡量使用
PNG
圖片,不使用JPGE
圖片2)通過(guò)
子線程預(yù)解碼黔酥,主線程渲染
藻三,即通過(guò)Bitmap
創(chuàng)建圖片,在子線程賦值image3)優(yōu)化圖片大小跪者,盡量避免動(dòng)態(tài)縮放
4)盡量將多張圖合為一張進(jìn)行顯示
9棵帽、盡量避免使用透明view
,因?yàn)槭褂猛该鱲iew渣玲,會(huì)導(dǎo)致在GPU中計(jì)算像素時(shí)逗概,會(huì)將透明view下層圖層的像素也計(jì)算進(jìn)來(lái),即顏色混合
處理忘衍。
10逾苫、按需加載
卿城,例如在TableView中滑動(dòng)時(shí)不加載圖片,使用默認(rèn)占位圖铅搓,而是在滑動(dòng)停止時(shí)加載
11瑟押、少使用addView
給cell
動(dòng)態(tài)添加view
GPU層面優(yōu)化
相對(duì)于CPU而言,GPU主要是接收CPU提交的紋理+頂點(diǎn)星掰,經(jīng)過(guò)一系列transform多望,最終混合并渲染,輸出到屏幕上氢烘。
1怀偷、盡量
減少在短時(shí)間內(nèi)大量圖片的顯示
,盡可能將多張圖片合為一張顯示
播玖,主要是因?yàn)楫?dāng)有大量圖片進(jìn)行顯示時(shí)椎工,無(wú)論是CPU的計(jì)算還是GPU的渲染,都是非常耗時(shí)的黎棠,很可能出現(xiàn)掉幀的情況2晋渺、盡量避免圖片的尺寸超過(guò)
4096×4096
,因?yàn)楫?dāng)圖片超過(guò)這個(gè)尺寸時(shí)脓斩,會(huì)先由CPU進(jìn)行預(yù)處理木西,然后再提交給GPU處理,導(dǎo)致額外CPU資源消耗3随静、盡量減少視圖數(shù)量和層次八千,主要是因?yàn)橐晥D過(guò)多且重疊時(shí),GPU會(huì)將其混合燎猛,混合的過(guò)程也是非常耗時(shí)的
4恋捆、盡量避免離屏渲染,
5重绷、異步渲染沸停,例如可以將cell中的所有控件、視圖合成一張圖片進(jìn)行顯示昭卓》呒兀可以參考Graver[4]三方框架