本文主要介紹界面卡頓的原理以及優化
界面卡頓
通常來說,計算機中的顯示過程是下面這樣的,通過CPU
、GPU
、顯示器
協同工作來將圖片顯示到屏幕上
1、CPU計算好顯示內容,提交至GPU
2、GPU經過渲染完成后將渲染的結果放入
FrameBuffer
(幀緩存區)3、隨后
視頻控制器
會按照VSync
信號逐行讀取FrameBuffer
的數據4、經過可能的數模轉換傳遞給顯示器進行顯示
最開始時,FrameBuffer只有一個,這種情況下FrameBuffer的讀取和刷新有很大的效率問題,為了解決這個問題,引入了雙緩存區
。即雙緩沖機制
。在這種情況下,GPU
會預先渲染好一幀放入FrameBuffer
,讓視頻控制器讀取,當下一幀渲染好后,GPU會直接將視頻控制器的指針指向第二個FrameBuffer
。
雙緩存機制雖然解決了效率問題,但是隨之而言的是新的問題,當視頻控制器還未讀取完成時,例如屏幕內容剛顯示一半,GPU將新的一幀內容提交到FrameBuffer,并將兩個FrameBuffer而進行交換后,視頻控制器就會將新的一幀數據的下半段顯示到屏幕上,造成屏幕撕裂
現象
為了解決這個問題,采用了垂直同步信號機制
。當開啟垂直同步后,GPU會等待顯示器的VSync信號發出后,才進行新的一幀渲染和FrameBuffer更新。而目前iOS設備中采用的正是雙緩存區+VSync
更多的關于屏幕卡頓渲染流程,請查看二、屏幕卡頓 及 iOS中的渲染流程解析文章
屏幕卡頓原因
下面我們來說說,屏幕卡頓的原因
在 VSync
信號到來后,系統圖形服務會通過 CADisplayLink
等機制通知 App,App 主線程開始在CPU中計算顯示內容。隨后 CPU 會將計算好的內容提交到 GPU 去,由GPU進行變換、合成、渲染。隨后 GPU 會把渲染結果提交到幀緩沖區
去,等待下一次 VSync 信號到來時顯示到屏幕上。由于垂直同步的機制,如果在一個 VSync 時間內,CPU 或者 GPU 沒有完成內容提交,則那一幀就會被丟棄,等待下一次機會再顯示
,而這時顯示屏會保留之前的內容不變。所以可以簡單理解掉幀
為過時不候
如下圖所示,是一個顯示過程,第1幀在VSync到來前,處理完成,正常顯示,第2幀在VSync到來后,仍在處理中,此時屏幕不刷新,依舊顯示第1幀,此時就出現了掉幀
情況,渲染時就會出現明顯的卡頓現象
從圖中可以看出,CPU和GPU不論是哪個阻礙了顯示流程,都會造成掉幀
現象,所以為了給用戶提供更好的體驗,在開發中,我們需要進行卡頓檢測
以及相應的優化
卡頓監控
卡頓監控的方案一般有兩種:
FPS監控
:為了保持流程的UI交互,App的刷新拼搏應該保持在60fps
左右,其原因是因為iOS
設備默認的刷新頻率是60次/秒
,而1次刷新(即VSync
信號發出)的間隔是1000ms/60 = 16.67ms
,所以如果在16.67ms
內沒有準備好下一幀數據,就會產生卡頓主線程卡頓監控
:通過子線程監測主線程的RunLoop,判斷兩個狀態(kCFRunLoopBeforeSources
和kCFRunLoopAfterWaiting
)之間的耗時是否達到一定閾值
FPS監控
FPS的監控,參照YYKit
中的YYFPSLabel
,主要是通過CADisplayLink
實現。借助link
的時間差,來計算一次刷新刷新所需的時間,然后通過 刷新次數 / 時間差
得到刷新頻次,并判斷是否其范圍,通過顯示不同的文字顏色來表示卡頓嚴重程度。代碼實現如下:
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
//通過虛擬類
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
//時間差
let detla = link.timestamp - lastTime
guard detla >= 1.0 else {
return
}
lastTime = link.timestamp
//刷新次數 / 時間差 = 刷新頻次
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
}
}
}
如果只是簡單的監測,使用FPS
足夠了。
主線程卡頓監控
除了FPS,還可以通過RunLoop
來監控,因為卡頓的是事務,而事務是交由主線程
的RunLoop
處理的。
實現思路:檢測主線程每次執行消息循環的時間,當這個時間大于規定的閾值時,就記為發生了一次卡頓。這個也是微信卡頓三方matrix
的原理
以下是一個簡易版RunLoop監控的實現
//
// 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(){
//監控兩個狀態
registerObserver()
//啟動監控
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(){
//創建信號
semaphore = DispatchSemaphore(value: 0)
//在子線程監控時長
DispatchQueue.global().async {
while(true){
// 超時時間是 1 秒,沒有等到信號量,st 就不等于 0, RunLoop 所有的任務
let st = self.semaphore.wait(timeout: DispatchTime.now()+1.0)
if st != DispatchTimeoutResult.success {
//監聽兩種狀態kCFRunLoopBeforeSources 、kCFRunLoopAfterWaiting,
if self.activity == CFRunLoopActivity.beforeSources || self.activity == CFRunLoopActivity.afterWaiting {
self.timeoutCount += 1
if self.timeoutCount < 2 {
print("timeOutCount = \(self.timeoutCount)")
continue
}
// 一秒左右的衡量尺度 很大可能性連續來 避免大規模打印!
print("檢測到超過兩次連續卡頓")
}
}
self.timeoutCount = 0
}
}
}
}
使用時,直接調用即可
CJLBlockMonitor.share.start()
也可以直接使用三方庫
Swift
的卡頓檢測第三方ANREye,其主要思路是:創建子線程進行循環監測,每次檢測時設置標記置為true,然后派發任務到主線程,標記置為false,接著子線程睡眠超過閾值時,判斷標記是否為false,如果沒有,說明主線程發生了卡頓OC
可以使用 微信matrix、滴滴DoraemonKit
界面優化
CPU層面的優化
1、盡量
用輕量級的對象
代替重量級的對象,可以對性能有所優化,例如 不需要相應觸摸事件的控件,用CALayer
代替UIView
-
2、盡量減少對
UIView
和CALayer
的屬性修改CALayer內部并沒有屬性,當調用屬性方法時,其內部是通過運行時
resolveInstanceMethod
為對象臨時添加一個方法,并將對應屬性值保存在內部的一個Dictionary中,同時還會通知delegate、創建動畫等,非常耗時UIView
相關的顯示屬性,例如frame、bounds、transform等,實際上都是從CALayer映射來的,對其進行調整時,消耗的資源比一般屬性要大
3、當有大量對象釋放時,也是非常耗時的,盡量挪到后臺線程去釋放
4、盡量
提前計算視圖布局
,即預排版
,例如cell的行高5、
Autolayout
在簡單頁面情況下們可以很好的提升開發效率,但是對于復雜視圖而言,會產生嚴重的性能問題,隨著視圖數量的增長,Autolayout帶來的CPU消耗是呈指數上升的。所以盡量使用代碼布局
。如果不想手動調整frame等,也可以借助三方庫,例如Masonry(OC)、SnapKit(Swift)、ComponentKit、AsyncDisplayKit等
-
6、文本處理的優化:當一個界面有大量文本時,其行高的計算、繪制也是非常耗時的
- 1)如果對文本沒有特殊要求,可以使用UILabel內部的實現方式,且需要放到子線程中進行,避免阻塞主線程
計算文本寬高:
[NSAttributedString boundingRectWithSize:options:context:]
文本繪制:
[NSAttributedString drawWithRect:options:context:]
- 2)自定義文本控件,利用
TextKit
或最底層的CoreText
對文本異步繪制。并且CoreText
對象創建好后,能直接獲取文本的寬高等信息,避免了多次計算(調整和繪制都需要計算一次)。CoreText直接使用了CoreGraphics占用內存小,效率高
- 1)如果對文本沒有特殊要求,可以使用UILabel內部的實現方式,且需要放到子線程中進行,避免阻塞主線程
-
7、圖片處理(解碼 + 繪制)
1)當使用
UIImage
或CGImageSource
的方法創建圖片時,圖片的數據不會立即解碼,而是在設置時解碼(即圖片設置到UIImageView/CALayer.contents
中,然后在CALayer
提交至GPU渲染前,CGImage
中的數據才進行解碼)。這一步是無可避免
的,且是發生在主線程
中的。想要繞開這個機制,常見的做法是在子線程中先將圖片繪制到CGBitmapContext
,然后從Bitmap
直接創建圖片,例如SDWebImage
三方框架中對圖片編解碼的處理。這就是Image的預解碼
當使用CG開頭的方法繪制圖像到畫布中,然后從畫布中創建圖片時,可以將圖像的
繪制
在子線程
中進行
-
8、圖片優化
1)盡量使用
PNG
圖片,不使用JPGE
圖片2)通過
子線程預解碼,主線程渲染
,即通過Bitmap
創建圖片,在子線程賦值image3)優化圖片大小,盡量避免動態縮放
4)盡量將多張圖合為一張進行顯示
9、盡量
避免使用透明view
,因為使用透明view,會導致在GPU中計算像素時,會將透明view下層圖層的像素也計算進來,即顏色混合
處理,可以參考六、OpenGL 渲染技巧:深度測試、多邊形偏移、 混合這篇文章中提及的混合
10、
按需加載
,例如在TableView中滑動時不加載圖片,使用默認占位圖,而是在滑動停止時加載11、少使用
addView
給cell
動態添加view
GPU層面優化
相對于CPU而言,GPU主要是接收CPU提交的紋理+頂點,經過一系列transform,最終混合并渲染,輸出到屏幕上。
1、盡量
減少在短時間內大量圖片的顯示
,盡可能將多張圖片合為一張顯示
,主要是因為當有大量圖片進行顯示時,無論是CPU的計算還是GPU的渲染,都是非常耗時的,很可能出現掉幀的情況2、盡量避免圖片的尺寸超過
4096×4096
,因為當圖片超過這個尺寸時,會先由CPU進行預處理,然后再提交給GPU處理,導致額外CPU資源消耗3、盡量減少視圖數量和層次,主要是因為視圖過多且重疊時,GPU會將其混合,混合的過程也是非常耗時的
4、盡量避免離屏渲染,可以查看這篇文章四、深入剖析【離屏渲染】原理
5、異步渲染,例如可以將cell中的所有控件、視圖合成一張圖片進行顯示。可以參考Graver三方框架
注:上述這些優化方式的落地實現,需要根據自身項目進行評估,合理的使用進行優化