背景
Apple 在今年推出了支持 ProMotion 屏幕的 iPhone 設(shè)備,讓 App 在 iPhone 13 Pro 和 iPhone 13 Pro Max 上的最大刷新幀率可到達 120Hz,極大優(yōu)化了應(yīng)用滑動/動畫的流暢度體驗。
ProMotion 并不是一個新的概念,早在 2017 年,Apple 推出的第二代 iPad Pro 便搭載了這一刷新率最高可達 120Hz 的屏幕。在 iPad 上,高刷新率默認(rèn)對所有 App 啟用。而也許是出于能耗的考慮,在 iPhone 上,Apple 并未將這個能力自動對所有 App 啟用,而是需要開發(fā)者手動添加配置項來進行適配。
近期有消息指出 iOS 15.4 beta ??修正了這一行為??,經(jīng)過筆者驗證額外的配置項依然是需要的,并且本文內(nèi)容依然適用。
本文介紹了在 iPhone 上對 ProMotion 動態(tài)幀率的適配時觀察到的現(xiàn)象和遇到的問題,嘗試推測了背后的原理,并探討了解決問題的可能思路,最終基于調(diào)研結(jié)果在國際化短視頻業(yè)務(wù)上線優(yōu)化方案,取得了核心業(yè)務(wù)指標(biāo)的收益。
什么是幀率
在深入探究 ProMotion 屏幕所帶來的變化之前,我們先回顧一個似乎耳熟能詳?shù)母拍睿?/p>
什么是幀率?
眾所周知,顯示器并不能顯示真正動態(tài)的畫面,所有動畫效果都是靠高速播放一幀幀靜態(tài)畫面欺騙人類視覺所造成的假象。那么幀率最基本的定義便是屏幕內(nèi)容的變化頻率,是一個物理意義上的指標(biāo)。這種變化頻率又由以下兩個值共同決定:
- 刷新幀率:由屏幕硬件規(guī)格控制,傳統(tǒng)顯示設(shè)備一般為 59.94Hz,決定了幀率的上限。
- 渲染幀率:由 CPU -> GPU 渲染管線的執(zhí)行速率控制,決定了幀率的下限。
理想情況下,渲染幀率和刷新幀率最好完全匹配,或者渲染幀率是刷新幀率的整數(shù)倍,這樣實際展現(xiàn)的內(nèi)容不會出現(xiàn)任何異常。但現(xiàn)實中二者往往會出現(xiàn)不匹配的情況,卡頓就是其中之一:
卡頓
當(dāng) CPU -> GPU 的渲染管線遇到瓶頸,導(dǎo)致某一幀的渲染耗時大于屏幕的刷新間隔時,上一幀畫面會在屏幕上多停留數(shù)幀的時間。當(dāng)這個滯留時間過長,用戶感知到畫面更新的延遲,這稱為卡頓。這也是 iOS 開發(fā)過程中會遇到的主要性能問題之一。
實際幀率
幀率并不等同于刷新率,它和所展示的內(nèi)容息息相關(guān):
- 展示靜態(tài)畫面時,理想情況只需要進行一次渲染,盡管屏幕仍然以 60Hz 或者更高的頻率進行刷新,每次刷新所展示的內(nèi)容(FrameBuffer)也未改變,用戶感知到的實際幀率依然接近 0。
- 展示固定幀率的元素,例如 24FPS 的電影視頻時,用戶感知到的實際幀率自然也是 24 FPS 左右。
- 展示超高幀率的內(nèi)容,例如 CS:GO 不鎖幀跑 >200 FPS,但由于顯示設(shè)備刷新率限制,用戶感知到的幀率依然不會超過硬件幀率的上限。
什么是動態(tài)刷新率
ProMotion 本質(zhì)上是對 Adaptive-Sync 顯示標(biāo)準(zhǔn)的一種實現(xiàn)。
Ref: https://en.wikipedia.org/wiki/Variable_refresh_rate
根據(jù) Apple 官方文檔顯示,ProMotion 屏幕支持的刷新率是可變的。
具體來說,對 iPhone 而言:
- The iPhone 13 Pro and iPhone 13 Pro Max ProMotion displays can present content on the display using the following refresh rates and timings:
- 120Hz (8ms), 80Hz (12ms), 60Hz (16ms), 48Hz (20ms), 40Hz (25ms), 30Hz (33ms), 24Hz (41ms), 20Hz (50ms), 16Hz (62ms), 15Hz (66ms), 12Hz (83ms), 10Hz (100ms)
而對 iPad Pro 來說:
- The iPad Pro’s ProMotion display can present content on the display using the following refresh rates and timings:
- 120Hz (8ms), 60Hz (16ms), 40Hz (25ms), 30Hz (33ms), 24Hz (41ms)
這其實是 Apple 對 VESA 定制的 Adaptive-Sync 技術(shù)標(biāo)準(zhǔn)的一種實現(xiàn),在游戲業(yè)界已經(jīng)實裝多年,類似的實現(xiàn)還有 AMD 的 FreeSync 和 Nivida 的 G-Sync。這種新的顯示技術(shù)有著以下優(yōu)點:
減少可感知的卡頓
對于固定刷新率的屏幕而言,當(dāng)某一幀的渲染耗時出現(xiàn)異常,在 VSync 信號到來之后才完成渲染,那么當(dāng)前內(nèi)容便會滯留在屏幕上,這一幀需要再等一次 VSync 信號才能被渲染展示給用戶。
而 Adaptive-Sync 技術(shù)可以避免這一點,在該幀渲染結(jié)束后盡快進行展示,從而減少顯示卡頓時長:
減少移動設(shè)備的屏幕功耗
在搭載了固定刷新率屏幕的設(shè)備上,當(dāng)顯示靜態(tài)內(nèi)容或者幀率較低(例如視頻)的內(nèi)容時,GPU 的渲染頻率比實際頻率刷新率會更低。但是固定刷新率的屏幕依然會已最高速率進行刷新,重復(fù)展示之前的內(nèi)容,造成了額外的電量消耗。
ProMotion 屏幕在這種情況下可以主動降低刷新率,減少屏幕功耗,這對于移動設(shè)備來說尤其重要。
動態(tài)刷新率的表現(xiàn)形式
The iPhone 13 Pro, the iPhone 13 Pro Max, and the iPad Pro ProMotion displays are capable of dynamically switching between:
- Faster refresh rates up to 120Hz
- Slower refresh rates down to 24Hz or 10Hz
已知,ProMotion 屏幕的刷新幀率并不固定,系統(tǒng)會實時地根據(jù)當(dāng)前顯示內(nèi)容的類型和狀態(tài)來動態(tài)切換屏幕的刷新幀率。為了更好地理解這種動態(tài)幀率的表現(xiàn)形式,筆者分別在
- iPhone XR - 無 ProMotion
- iPhone 13 Pro - 有 ProMotion 默認(rèn)鎖頻
上對一些典型渲染場景進行了測試,發(fā)現(xiàn)搭載了 ProMotion 屏幕的設(shè)備上運行 App 時,不同的場景下的各種統(tǒng)計口徑的幀率指標(biāo)確實展示出了有趣的變化。
具體而言,筆者分別在以下幾種場景:
測試場景
靜態(tài)頁面
靜態(tài)的 UIView,無動畫/視頻等元素
滑動中的頁面
包含靜態(tài) Cell 的 UITableView,僅觀察滑動中的表現(xiàn)
Core Animation 默認(rèn)刷新率動畫
顯示基于 CABasicAnimation 實現(xiàn)的簡單位移動畫
Core Animation 120Hz 高刷新率動畫
僅在 ProMotion 設(shè)備上測試,基于 CABasicAnimation 實現(xiàn)的簡單位移動畫,同時解鎖了 CADisableMinimumFrameDurationOnPhone 和 preferredFrameRateRange 幀率限制。(關(guān)于此限制下文會有具體介紹)
Metal 渲染 30Hz/60Hz 視頻
使用基于 MTKView 進行渲染的播放器,播放源幀率分別為 30Hz/60Hz 的視頻文件
并使用以下幾種統(tǒng)計口徑的幀率指標(biāo)進行測試:
測試指標(biāo)
CADisplayLink 計算幀率
iOS 中主要的幀率統(tǒng)計手段。
根據(jù) CADisplayLink.h 頭文件中描述,CADisplayLink 是一個 ”Class representing a timer bound to the display vsync “。在回調(diào)中比較當(dāng)前幀/前一幀的時間戳,可以計算出上一幀的渲染耗時(ts),其倒數(shù)(1/ts)即為當(dāng)前的實時幀率。
Xcode GPU Report 幀率
Xcode -> Show Debug Navigator -> FPS 中顯示的幀率。這個只能統(tǒng)計當(dāng)前應(yīng)用直接通過 OpenGL ES 或者 Metal 進行繪制的幀率,例如游戲渲染/視頻播放,無法統(tǒng)計 Core Animation 的幀率(眾所周知,后者通過 backboardd 進行繪制)。
Instruments Core Animation FPS
Instruments 中 Core Animation FPS 工具所顯示的幀率。這個統(tǒng)計的是 Core Animation 的幀率,即 Render Server backboardd 繪制的頻率。目前該工具有 BUG 無法顯示高于 60 FPS 的幀率。
Instruments Display/VSync 信號頻率
Instruments 中 Display 工具所顯示的 Surface/VSync 信號時間戳。如下圖所示:
- Display:指對應(yīng)顯示器的單個 Surface 上屏持續(xù)的時間,對應(yīng) CPU-GPU 管線的渲染頻率
- VSync:指垂直同步信號時間戳,對應(yīng)屏幕硬件的刷新頻率
在 60Hz 屏幕上,iOS 設(shè)備默認(rèn)采用雙緩沖刷新機制,也就是前幀緩存和后幀緩存。GPU 總是在后幀緩存上進行當(dāng)前幀的繪制。當(dāng) VSync 信號到來時,交換前后幀緩存的指針(Swap FrameBuffer),屏幕刷新顯示新的內(nèi)容。
而當(dāng)屏幕以 120Hz 顯示內(nèi)容時,iOS 會切換成三緩沖刷新機制(見上圖中三種顏色的 Surface),這減少渲染管線的壓力,但同時會增加一定的渲染上屏延遲。
Metal 應(yīng)用可以通過設(shè)置 -[CAMetalLayer setMaximumDrawableCount:] 為 2 來在 120Hz 屏幕上強制啟用雙緩沖機制,避免這種延遲。
如果屏幕顯示內(nèi)容未發(fā)生變化,Surface 則不會發(fā)生交換,一個 Surface 的 Display 可能持續(xù)數(shù)個 VSync 間隔,但多余的 VSync 信號依然代表著硬件層額外的屏幕刷新,造成額外的電量消耗。
非 ProMotion 設(shè)備
首先讓我們看看傳統(tǒng)的固定刷新率的設(shè)備的情況。
VSync 信號間隔固定為 16.67ms
XR 的屏幕刷新率為固定的 60Hz,這一點對應(yīng)的具體指標(biāo)是 VSync 信號的間隔,而在任何場景下,XR 的 VSync 信號的間隔均為固定的 16.67ms。
此外,在顯示靜態(tài)內(nèi)容時,由于視圖 Layer Tree 無變化,Core Animation 不會有提交新的事務(wù)提交,backboardd 不會進行刷新,所以對應(yīng)這一幀的 Surface 也長時間(數(shù)十秒)未被交換下去,Core Animation FPS 的值顯示為 0。
但由于 VSync 信號仍然以 60Hz 的頻率持續(xù)觸發(fā),屏幕此時正在不停重復(fù)展示同樣的 Frame Buffer,消耗了額外的電量。
CADisplayLink 基本完全跟隨 VSync 信號
根據(jù)過去對 iOS 系統(tǒng)的認(rèn)知,我們知道 CADisplayLink 是由 VSync 信號驅(qū)動的:
默認(rèn)配置的 CADisplayLink 的回調(diào)應(yīng)該與 VSync 信號基本同時。
這一點在 XR 上得到了驗證,用 Instruments 記錄一次主線程發(fā)生的卡頓,得到:
其中:
- 第一行 runloop 記錄每次 RunLoop AfterWaiting -> BeforeWaiting 的間隔
- 第二行 tick 記錄默認(rèn)配置的 CADisplayLink 回調(diào)間的間隔
- 最下面則是硬件 Display/VSync 事件時序圖
可以觀察到下述現(xiàn)象,符合我們之前的對 DisplayLink 的認(rèn)識:
- 沒有卡頓的情況下,VSync 信號和 RunLoop 的喚醒 & CADisplayLink 回調(diào)的觸發(fā)嚴(yán)格一一對應(yīng)。
- RunLoop 卡頓,無法處理 Source 1 信號,DisplayLink 回調(diào)被延遲到卡頓結(jié)束時。
- 在此過程中 VSync 信號間隔始終保持不變。
ProMotion 設(shè)備
下面看看 ProMotion 設(shè)備的測試結(jié)果。
VSync 信號間隔可變
在 ProMotion 屏幕上 VSync 信號間隔是可變的,具體而言:
- 顯示靜態(tài)內(nèi)容時,屏幕降頻,最低以 10Hz 的頻率進行刷新
- 顯示 Core Animation 動畫時,系統(tǒng)會適配動畫的幀率設(shè)置改變刷新率
- *通過 preferredFrameRateRange 可以設(shè)置 hint 請求高刷,但并不一定生效,詳見下文“動態(tài)幀率的應(yīng)用場景”部分。
- 顯示滑動中內(nèi)容時,刷新率在 80Hz 左右波動,并且跟隨滑動速度變化而變化??旎瑫r刷新率升高,慢滑時降低。
顯示視頻時,刷新率和視頻幀率維持一致
可以看到 VSync 信號間隔能主動跟隨顯示內(nèi)容的渲染幀率的改變而改變。
減少卡頓造成的顯示延遲
在主線程發(fā)生卡頓導(dǎo)致滑動中某一幀渲染耗時過長時,系統(tǒng)會改變這一幀所對應(yīng)的 VSync 信號間隔(下圖 Surface 5),減小從渲染到展示的延時,從而減緩用戶感知到的卡頓時長。
DisplayLink 不完全跟隨 VSync 信號
如圖是一張滑動中場景的 CADisplayLink 回調(diào) 和 Display/VSync 事件對照記錄。和之前不同的是,再 ProMotion 設(shè)備上 DisplayLink 和 VSync 信號之間沒有表現(xiàn)出明顯的跟隨關(guān)系:
具體而言:
- 第三個箭頭所指向的 DisplayLink 的回調(diào)并不及時。在這之前主線程的卡頓已經(jīng)結(jié)束,并且額外執(zhí)行了兩次 RunLoop,但直到第三次才調(diào)用了 DisplayLink 的回調(diào)。
- 不僅僅是時機不匹配,也存在收到 VSync 但不觸發(fā) DisplayLink 回調(diào)的情況(并且主線程處于空閑狀態(tài)),例如上圖中的 ? 處。
解除 DisplayLink 的幀數(shù)限制
我們知道,在 iOS 15 上 Apple 對第三方應(yīng)用的顯示幀率默認(rèn)做了限制。第三方應(yīng)用需要在 Info.plist 中添加CADisableMinimumFrameDurationOnPhone 字段才可以解鎖 120Hz 的刷新率。
于此同時,在 iOS 15 中,CADisplayLink 等動畫相關(guān) API 也新增了一個用于配置偏好幀率的屬性:
/* Defines the range of desired callback rate in frames-per-second for this
display link. If the range contains the same minimum and maximum frame rate,
this property is identical as preferredFramesPerSecond. Otherwise, the actual
callback rate will be dynamically adjusted to better align with other
animation sources. */
@property(nonatomic) CAFrameRateRange preferredFrameRateRange
API_AVAILABLE(ios(15.0), watchos(8.0), tvos(15.0));
為了進一步探究新設(shè)備上 DisplayLink 和 VSync 信號之間的關(guān)系,筆者將測試 App 的 Core Animation 的幀率限制解除,并配置對應(yīng)的 API,分別在不同的場景重新進行測試:
顯示動態(tài)內(nèi)容的場景
動畫場景
展示一個速度中等的位移動畫,得到下圖:
可以很直觀地發(fā)現(xiàn),DisplayLink 解鎖幀率后的屏幕刷新率基本穩(wěn)定在 120Hz。并且 VSync 和 DisplayLink 的關(guān)系似乎又重新一一對應(yīng)了起來。
但是,將動畫速度減慢,筆者發(fā)現(xiàn)這種對應(yīng)關(guān)系發(fā)生了變化:
可以觀察到在播放慢速動畫時,DisplayLink 的頻率依然是配置的 120Hz,但是實際的屏幕刷新率卻只有 30Hz。
滑動場景
讓我們換一種場景再次進行測試,快速滑動視圖,在 Instruments 中得到下圖:
可以發(fā)現(xiàn),DisplayLink 解鎖幀率后,屏幕刷新率同樣基本穩(wěn)定在 120Hz,僅在丟幀時有降頻。
- 需要注意的是筆者在 CADisplayLink 的回調(diào)中除了調(diào)用 os_signpost 上報 log 外無任何 UI 改動。
- 即便筆者展示的 TableView 極其簡單,上圖中仍然可以觀察到丟幀,無法在滑動中完美穩(wěn)定 120Hz。這也許說明 UIKit 的渲染性能在 120Hz 下會有某種程度上的原生瓶頸。
然后降低滑動屏幕的速度,得到了和慢速動畫相似的結(jié)果,盡管 DisplayLink 回調(diào)速度不減,但是 VSync 信號頻率一直保持在較低的水平:
卡頓場景
上面兩次測試都接近理想情況,即整個 Render Loop 執(zhí)行幾乎沒有延遲與卡頓。但是現(xiàn)實中應(yīng)用的運行總是有著各種各樣的或大或小的卡頓問題。
為了驗證更接近現(xiàn)實情況下,DisplayLink 和 VSync 信號之間的關(guān)系,在連續(xù)滑動的情況下筆者人為加入了一個 20ms 的微小卡頓進行測試:
上圖中可以看到,ProMotion 屏幕很好的處理了這次卡頓,由于三緩沖機制的存在,再 Render Loop 渲染 Surface 4 卡頓期間,通過改變 VSync 間隔,系統(tǒng)嘗試將緩沖區(qū)中的 Surface 283 與 Surface 250 延遲上屏,盡量縮短了用戶看到靜止畫面的時長。
隨后,主線程恢復(fù)執(zhí)行,可以看到 DisplayLink 的回調(diào)頻率很快恢復(fù)至卡頓前的高水平。而此時 VSync 信號由于前述卡頓減緩機制的存在頻率其實有所降低。此時二者頻率并不吻合。
這和之前播放慢速動畫/慢速滑動的情況很相似,由于卡頓加上緩沖機制的存在導(dǎo)致短時間內(nèi)系統(tǒng)將屏幕的刷新頻率降低,但在 CPU 側(cè)依然維持了 DisplayLink 的高速回調(diào),滿足了使用方對 preferredFrameRateRange 這一 API 的設(shè)置。
為了進一步分析了這種機制的本質(zhì),筆者接下來會嘗試逆向分析 iOS 15 中的系統(tǒng)庫相關(guān)實現(xiàn)的改動。
逆向分析
- DisplayLink 驅(qū)動方式的變化
在 CADisplayLink 回調(diào)方法上設(shè)置斷點,分別在 iOS 14 和 15 ProMotion 設(shè)備上運行,可以得到:
1、在 iOS 14 上,CADisplayLink 是通過 Source 1 mach_port 直接接受 VSync 信號驅(qū)動的
2、在 iOS 15 ProMotion 設(shè)備上,CADisplayLink 不再由 VSync 信號驅(qū)動,而是由一個 UIKit 內(nèi)部的 Source0 信號驅(qū)動
在 15 中,CADisplayLink 第一次創(chuàng)建并添加至 RunLoop 的時候,會注冊一個 Source 1 信號,這和 14 中行為一致。
其 callout 回調(diào)地址對應(yīng)符號為同樣為 display_timer_callback,同樣和 14 中的一致。
這也可以解釋為什么 15 上 VSync 信號確實會喚醒一次 RunLoop,只是這次喚醒并不一定觸發(fā) DisplayLink 的回調(diào),這就說明 display_timer_callback 行為和 14 相比一定發(fā)生了某種變化。
- display_timer_callback 邏輯的變化
使用 Hopper 分析 display_timer_callback 的實現(xiàn),發(fā)現(xiàn) 15 和 14 的實現(xiàn)并無區(qū)別。使用 LLDB 進行 debug,逐步分析,觀察到后續(xù)調(diào)用函數(shù)為 CA::Display::DisplayLink::callback,其關(guān)鍵反匯編代碼如下圖所示:
觀察反匯編代碼可以發(fā)現(xiàn),如果 CA::display_link_will_fire_handler 這個 block 返回了 NO,則這次 VSync 信號回調(diào)不會觸發(fā)后續(xù)的 CA::DisplayLink::dispatch_items 調(diào)用。
實際上在 LLDB 中也驗證了這點:
注意上圖中的 _CFRunLoopCurrentIsMain 和上圖紅框代碼接近,后續(xù)的 blraa 指令看起來很明顯是調(diào)用了一個 block(上面的 ldr x9 [x8, #0x10] 就是把 invoke 指針從 block 結(jié)構(gòu)體中取出的意思)。tbz 指令中 w0 寄存器為 block 執(zhí)行的返回值,為 0(即 NO)時跳轉(zhuǎn)至 0x1848dbc08,而 0x1848dbc08 剛好在 dispatch_items 的調(diào)用之后,跳過了該調(diào)用。
通過對上圖中 blraa 指令 step in,我們發(fā)現(xiàn)這個 block 實際上是由 UIKitCore 注冊的:
找到引用了該符號的 UIKit 的私有方法 __UIUpdateCycleSchedulerStart ,反匯編結(jié)果也驗證了這點。
同時發(fā)現(xiàn)這個 block 的返回值固定為 0x0。
而同樣的 symbol 在之前的 iOS 版本上并不存在,也就是說這個應(yīng)該是 iOS 15 的變動。換安裝了 iOS 15 的非 ProMotion 設(shè)備,重走上面的逆向流程發(fā)現(xiàn),該設(shè)備的 CA::display_link_will_fire_handler 為 nil,未注冊:
這里 cbz 執(zhí)行了跳轉(zhuǎn),說明 x0 為 nil,而 x0 是由 ldr x0, [x8, #0x1c8] 得到。
可以看到 x0 就是 CA::display_link_will_fire_handler。繼續(xù)分析之前找到的私有符號 __UIUpdateCycleSchedulerStart 的相關(guān)實現(xiàn),可以知道這是因為在非 ProMotion 設(shè)備上 _UIUpdateCycleEnabled 返回了 NO 導(dǎo)致的。
在返回 NO 的情況下 __UIUpdateCycleSchedulerStart 方法不會執(zhí)行,CA::display_link_will_fire_handler 也就不會被注冊。
- _UIUpdateCycleEnabled 所帶來的變化
繼續(xù)研究 _UIUpdateCycleEnabled 相關(guān)的代碼,筆者發(fā)現(xiàn)這個的改動并不是僅僅影響 DisplayLink 驅(qū)動方式那么簡單。
當(dāng) _UIUpdateCycleEnabled 返回 YES 時,UIKit 會在 UIApplicationMain 中執(zhí)行 _UIUpdateCycleSchedulerStart。分析該函數(shù),發(fā)現(xiàn) _UIUpdateCycleEnabled 啟用時會調(diào)用 [CATransaction setDisableRunLoopObserverCommits:YES]。
Core Animation 是絕大部分 iOS 應(yīng)用的渲染引擎,熟悉 iOS 渲染流程的同學(xué)想必都知道它的執(zhí)行也是由 MainRunLoop 驅(qū)動,大致為:
1、MainRunLoop 因為用戶操作/Timer/GCD 等被喚醒,派發(fā)相應(yīng)的事件/回調(diào)
2、回調(diào)中應(yīng)用修改 Layer Tree,觸發(fā) setNeedsLayout 或 setNeedsDisplay
3、MainRunLoop 即將完成本次執(zhí)行,在即將休眠前向 Observer 派發(fā) BeforeWaiting 事件
4、BeforeWaiting 中觸發(fā) Core Animation 注冊的 MainRunLoop Observer,觸發(fā)事務(wù)提交 CA::Transaction::commit():
- 自頂向下觸發(fā)各種 Layout/Display 等邏輯,更新布局/內(nèi)容
- Core Animation 將更新后的 Layer Tree 打包發(fā)送給 Render Server
5、隨后 MainRunLoop 進入休眠
6、Render Server 將打包好的 Layer Tree 解碼,生成并提交對應(yīng)的 draw calls
7、GPU 執(zhí)行渲染指令,渲染出 FrameBuffer,待后續(xù) VSync 信號來臨時上屏展示
上圖中 +[CATransaction setDisableRunLoopObserverCommits:YES] 這個調(diào)用給了筆者提示,讓我們驗證一下 CA::Transaction::commit() 在 iOS 15 ProMotion 設(shè)備上的執(zhí)行時機,會發(fā)現(xiàn)確實不再由 BeforeWaiting 事件驅(qū)動了:
實際上同樣的 Source 0 信號同時也驅(qū)動了 CADisplayLink 的回調(diào):
關(guān)注這個 Source 0 的回調(diào)符號 runloopSourceCallback,會發(fā)現(xiàn)這個 Source0 是由 signalChanges 函數(shù)驅(qū)動:
而 signalChanges 又是由多個回調(diào)所驅(qū)動:
其中:
- runloopObserverCallback 為一個 BeforeWaiting 的 MainRunLoop observer 驅(qū)動。
- runloopTimerCallback 由 mk_timer 驅(qū)動,對應(yīng)的 mach_port 不明,測試發(fā)現(xiàn)其回調(diào)頻率在 1Hz 左右,但也會不斷變化,猜測是某種系統(tǒng)計時器。
- inputGroupSignaledCallback 由 mk_timer 驅(qū)動,對應(yīng)的 mach_port 正是 VSync 信號。
- requestRegistrySignaledCallback 由 UIScrollView 在即將開始滑動時驅(qū)動。
通過上面的分析,筆者有理由認(rèn)為在 iOS 15 上應(yīng)用的渲染驅(qū)動機制出現(xiàn)了比較大的變化。其中之一便是 DisplayLink 的驅(qū)動源的改變。
結(jié)論
1、iOS 15 上 Apple 改變了在 ProMotion 設(shè)備的渲染事件循環(huán)的驅(qū)動方式,CoreAnimation 的事務(wù)提交不再由完全由 RunLoop 驅(qū)動,而是涉及了多個信號源
2、系統(tǒng)動態(tài)幀率選擇的機制會綜合考慮使用方設(shè)置的 API(如 preferredFrameRateRange)和實際展示的內(nèi)容的變化頻率。具體對 CADisplayLink 而言:
- 內(nèi)容低速變化時,CADisplayLink 解鎖高刷新率僅影響自身的回調(diào)頻率,系統(tǒng)仍可能選擇較低的屏幕刷新率來降低功耗
- 內(nèi)容中高速變化時,CADisplayLink 解鎖高刷新率可以讓系統(tǒng)選擇更高的刷新頻率,甚至實現(xiàn)鎖定 120Hz 的刷新
關(guān)于如何界定低速/中高速,筆者在下文中 CAAnimation 設(shè)置動態(tài)幀率 部分做了一些試驗,可作為參考。
同時,默認(rèn)配置的 CADisplayLink 回調(diào)頻率最高為 60Hz,無法監(jiān)控更高頻率的刷新事件。
3、ProMotion 設(shè)備中,DisplayLink 不再由 VSync 信號直接驅(qū)動,而是在新引入的渲染事件循環(huán)中執(zhí)行。新版本 iOS 系統(tǒng)實現(xiàn)了某種更復(fù)雜的機制來盡可能滿足使用者設(shè)置的偏好頻率進行回調(diào),但并不保證它與 VSync 信號的強關(guān)聯(lián)性。這意味著默認(rèn)的 CADisplayLink 的回調(diào)頻率與實際幀率并不匹配,之前基于 CADisplayLink 進行幀率監(jiān)控的方案在 ProMotion 設(shè)備上變得不再可行。
動態(tài)幀率的應(yīng)用場景
監(jiān)控動態(tài)幀率下的流暢度表現(xiàn)
業(yè)界中一般采用 CADisplayLink 對應(yīng)用的流暢度進行監(jiān)控。由于 CADisplayLink 的行為在 iOS 15 上的變化,原先的監(jiān)控方案無法評估 ProMotion 屏幕在超過 60Hz 時的表現(xiàn)。
根據(jù)上面的探索結(jié)論,目前筆者設(shè)想了三種針對 ProMotion 設(shè)備的兼容性修改方案:
方案一 [Pass]
對于任何設(shè)備都以 60Hz 為優(yōu)化目標(biāo),只考慮刷新間隔長于 16.67ms 的情況。換句話說,在屏幕以 120Hz 刷新時,對于丟 1 幀的情況也認(rèn)為不丟幀,因為此時兩幀之間的間隔仍然小于 16.67ms,理論上用戶感知不大。
優(yōu)點:
- 方案簡單,僅需設(shè)置 preferredFramesPerSecond 為固定值 60 即可
- 兼容之前的指標(biāo)。依然可以計算 FPS 指標(biāo),對于刷新率高于 60Hz 的情況統(tǒng)一認(rèn)為刷新率為 60Hz
缺點:
- 由于只能監(jiān)控最高 60Hz 的情況,無法評估更高刷新率下一些微小丟幀對用戶體驗帶來的影響,也無法評估對高刷屏的一些優(yōu)化所帶來的技術(shù)影響
- 在低刷新率時,MainRunLoop 依然會以 60Hz 運行,對功耗有一定影響
方案二 [Pass]
通過一些手段,可以替換驅(qū)動 display_timer_callback 的 Source 1 信號的回調(diào),使用它來準(zhǔn)確監(jiān)聽 VSync 信號,實現(xiàn)對動態(tài)幀率的準(zhǔn)確監(jiān)控。
優(yōu)點:
- 理論上最精確的監(jiān)控方案
- 對功耗的影響最小,回調(diào)頻率只有在屏幕刷新率實際升高時才會隨之提升
缺點:
- 使用了私有 API
- FPS 指標(biāo)從此不再適用
- VSync 信號目前和渲染流程不完全匹配,雖然精確但不一定實用
方案三 [Pick]
通過在 CADisplayLink 回調(diào)中確認(rèn) duration 參數(shù),計算得到當(dāng)前屏幕的實時刷新率,并修改 preferredFrameRateRange 來進行跟蹤。
優(yōu)點:
- 方案相對簡單,只需在每次回調(diào)中更新 DisplayLink 對象的 preferredFrameRateRange 屬性即可
缺點:
- 由于動態(tài)幀率的存在,F(xiàn)PS 指標(biāo)可以反映實時屏幕刷新情況,但是聚合后的意義不大,消費時需要區(qū)分特定機型/場景
- 觀察到目前的最小回調(diào)頻率為 60Hz,也就是說無法確認(rèn) ProMotion 屏幕在 48Hz、30Hz 甚至更低刷新率下的表現(xiàn)
- 在低刷新率時,MainRunLoop 依然會以 60Hz 運行,對功耗有一定影響
需要注意的是,CADisplayLink 的 preferredFrameRateRange 需要以類似一下格式進行設(shè)置:
NSInteger currentFPS = (NSInteger)ceil(1.0 / displayLink.duration);
displayLink.preferredFrameRateRange = CAFrameRateRangeMake(10.0, currentFPS, 0.0);
CAFrameRateRange.minimum 傳最小值 10.0,preferred 傳 0.0,可以讓該 CADisplayLink 只用于監(jiān)控當(dāng)前的系統(tǒng)幀率,而不影響幀率的動態(tài)選擇。
相比前兩個方案,方案三改動小,不使用私有 API,監(jiān)控準(zhǔn)確性也較高,缺點相對來說可以接受。
FPS 的替代指標(biāo)
考慮到在 ProMotion 屏幕上 FPS 指標(biāo)不再與應(yīng)用運行是否流暢直接相關(guān),它的聚合值參考價值不大,有必要尋找一個新指標(biāo)作為替換。
Apple 官方在 WWDC20 - 10077 Eliminate animation hitches with XCTest 中介紹了 Hitch Time Ratio 這一概念,并著重說明了它比單純的 FPS 更能適配不同刷新率的場景。
在 XCTest 框架中,蘋果提供了 API XCTOSSignpostMetric 幫助開發(fā)者在單測中即時地獲取該指標(biāo),但相關(guān) API 盡在單測中提供,線上無法使用。而 MetricKit 中的 MXAnimationMetric 盡管可以在線上獲取,但卻不是實時的,無法滿足大型 App 對不同場景的監(jiān)控需求。
因此,遵循下面 Apple 對 Hitch Ratio 的定義:
Hitch time:
- Time in ms that a frame is late to display.
Hitch time ratio:
- Hitch time in ms per second for a given duration.
筆者嘗試實現(xiàn)了基于 CADisplayLink 的 (Scroll) Hitch Time Ratio 的計算方案:
- 計算上一幀的幀時間戳與上上一幀的目標(biāo)幀時間戳得到上一幀的 Hitch Time
- 確定該幀是否是在滑動中渲染
- 累計得到整體的 Hitch Frame,與累積的幀間隔相比,得到 (Scroll) Hitch Time Ratio
關(guān)鍵場景提升幀率
在測試過程中筆者發(fā)現(xiàn),系統(tǒng) App 滑動時是穩(wěn)定以最高刷新率 120Hz 運行的:
而第三方 App 即便設(shè)置了 CADisableMinimumFrameDurationOnPhone 為 true 也無法穩(wěn)定以滿幀率滑動(經(jīng)過驗證,這一點在 iOS 15.4 beta 系統(tǒng)上依然成立)。
通過利用 iOS 15 引入的新 API,我們可以在關(guān)鍵場景如滑動、轉(zhuǎn)場、動畫過程中主動解鎖更高/限制更低的動態(tài)幀率,從而優(yōu)化流暢度或者優(yōu)化功率,提升用戶體驗?zāi)繕?biāo)。
滑動中穩(wěn)定 120Hz
首先,筆者希望非系統(tǒng) App 也可以盡可能實現(xiàn)滑動中穩(wěn)定 120Hz 刷新。
結(jié)合上述分析,這一點可以用 CADisplayLink 來實現(xiàn)。這里筆者提出兩種可能方案僅供參考:
創(chuàng)建 CADisplayLink,配置其 preferredFramesPerSecond 為 120,然后將其添加到 UITrackingRunLoopMode 中。
CADisplayLink *dp =
dp.preferredFramesPerSecond = 120;
// 或者
dp.preferredFrameRateRange = CAFrameRateRangeMake(120.0, 120.0, 0.0);
[dp addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode];
在滑動中,該 CADisplayLink 被激活,系統(tǒng)鎖定當(dāng)前幀率為最高 120Hz(僅在內(nèi)容中高速變化時生效)。停止滑動時則恢復(fù)正常幀率。
添加 CADisplayLink 至 CommonModes 中,分別在開始/停止滑動時啟用/暫停 CADisplayLink,并修改對應(yīng)的 preferredFramesPerSecond等屬性,觸發(fā)幀率變化。
CADisplayLink *dp =
dp.paused = YES;
[dp addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
CFRunLoopAddObserver(CFRunLoopGetMain(),
CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopEntry | kCFRunLoopExit, YES, 0,
^(CFRunLoopObserverRef observer, CFRunLoopActivity activity) {
if (activity == kCFRunLoopEntry) {
dp.paused = NO;
dp.preferredFramePerSecond = 120;
} else {
dp.paused = YES;
dp.preferredFramePerSecond = 0;
}
}), (__bridge CFStringRef)UITrackingRunLoopMode);
在實踐中,由于也存在需要在非滑動狀態(tài)下解鎖幀率上限的情況,所以方案 2 的通用性會更好。
CAAnimation 設(shè)置動態(tài)幀率
目前蘋果只提供了修改 CAAnimation 動畫幀率的 API,設(shè)置 CAAnimation.preferredFrameRateRange 即可改變其對屏幕刷新率的影響。
- 對于用戶感知明顯的,如轉(zhuǎn)場動畫,可以設(shè)置為 120Hz。
- 對于感知不明顯的,如旋轉(zhuǎn)動畫,可以降低其幀率,比如設(shè)置為 30Hz。
但是,和 DisplayLink 相同,過上述 API 的設(shè)置雖然會“影響”系統(tǒng)的動態(tài)幀率的選擇,但這種影響并不是絕對的。在實際使用中,筆者發(fā)現(xiàn)屏幕選擇的刷新率和 CAAnimation 在屏幕上變化的速度有關(guān)。
關(guān)于此點,以 iPhone 13 Pro 為例,筆者使用了一個簡單的、偏好幀率為固定 120Hz 平移動畫進行說明:
CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"transform.translation.y"];
CGFloat speed = 170.0/330.0;
anim.toValue = @(100);
anim.fromValue = @(0);
anim.duration = 10.0;
anim.repeatCount = FLT_MAX;
anim.preferredFrameRateRange = CAFrameRateRangeMake(120, 120, 120);
其中 speed 變量為平移的速度,單位為 pt/s,試驗發(fā)現(xiàn):
- speed 取 (0, 160] 時,屏幕刷新率為 60Hz
- speed 取 [161, 320] 時,屏幕刷新率為 80Hz
- speed 取 [321, +∞) 時,屏幕刷新率為 120Hz
筆者僅在 iPhone 13 Pro 上測試了平移動畫的場景,以上數(shù)據(jù)僅供參考。
最后,對于其他的常見的動畫 API,例如 UIView.animateWithDuration、UIViewPropertyAnimator 等,則沒有提供對應(yīng) API 進行修改。理論上也可以通過某些手段拿到這些上層 API 所創(chuàng)建的 CAAnimation 對象來實現(xiàn)修改。
手勢/轉(zhuǎn)場等其他場景解鎖 120Hz
其他場景需要控制動態(tài)幀率的也可以通過手動修改 CADisplayLink 的 preferredFramePerSecond/preferredFrameRateRange 屬性來實現(xiàn),其實現(xiàn)和通過監(jiān)聽 RunLoop 來修改滑動幀率基本相同。
UIGestureRecognizer 常被用于實現(xiàn)的交互式動畫。經(jīng)過測試,發(fā)現(xiàn)在觸發(fā)手勢回調(diào)的同時啟用一個解鎖了頻率的 CADisplayLink 也可以間接提高 UIGestureRecognizer 的回調(diào)頻率,從而實現(xiàn)更高幀率的交互動畫。
對于轉(zhuǎn)場的場景,一個簡單的方案是 swizzle UIViewController 的生命周期消息,在出現(xiàn)/消失的節(jié)點啟用/停用 CADisplayLink 幀率的解鎖,從而實現(xiàn)通用的頁面轉(zhuǎn)場動畫幀率解鎖方案。
Flutter 官方也計劃提供類似 API 讓應(yīng)用側(cè)可以針對不同的場景(滑動、動畫 etc)動態(tài)切換屏幕刷新率:https://github.com/flutter/flutter/issues/90675
上線收益
基于上述思路,筆者所在團隊在國際化短視頻業(yè)務(wù)落地了優(yōu)化項目,經(jīng)過實驗驗證:
- 大盤滑動幀率 P50 從 81.57 上升至 112.2
- 核心業(yè)務(wù)指標(biāo)也有一定收益
結(jié)語
近年來,Apple 生態(tài)中軟硬件的發(fā)展日新月異,有軟件層的 dyld 的持續(xù)優(yōu)化和 iOS 15 新引入的 Prewarm 機制,也有新的 ProMotion 屏幕,可以看到 Apple 一直致力于打造更絲滑流暢的用戶體驗。
Apple 提供的系統(tǒng)級優(yōu)化方案一般通用而無感知,但通用往往也意味著一定的局限性,可能預(yù)留了額外優(yōu)化空間,應(yīng)用開發(fā)者們可以進一步去研究如何更好地適配。
例如本文中,筆者通過研究新引入的 ProMotion 屏幕背后的機制,透過表象/深入?yún)R編管中窺豹看到一部分本質(zhì),最終落地了監(jiān)控 + 優(yōu)化的方案,讓大盤滑動幀率 P50 從 80 上升至 112 左右,取得了額外的業(yè)務(wù)收益。
最后,筆者認(rèn)為,我們普通開發(fā)者作為 Apple 生態(tài)鏈中的一環(huán),在享受系統(tǒng)級別優(yōu)化自動帶來的收益的同時,也應(yīng)該主動去了解上述優(yōu)化背后的底層原理。一方面,了解與學(xué)習(xí) Apple 的成熟優(yōu)化思路可以提升我們作為工程師的眼界。另一方面,對系統(tǒng)底層原理的了解可以拓充我們的“彈藥庫”,對業(yè)務(wù)價值交付的全鏈路了解越廣越深,越有可能抓住潛在的優(yōu)化點,從而在性能優(yōu)化工程師這條職業(yè)道路上走得更遠更好。