Android 突破螢幕重新整理的桎梏

ES2049發表於2022-01-17

背景

隨著智慧手機的普及,現代生活中,我們漸漸擺脫不了對手機的依賴。出行、購物、醫療、住房、社交等社會各個層面的需求都離不開藉助智慧手機去實現更高效、便捷的目的。短短十幾年,依託於智慧手機的發展也出現了層出不窮的網際網路公司、手機品牌廠商以及數不清的應用程式,而隨著相關網際網路產業市場漸漸達到人口瓶頸,基於存量市場的爭奪也迎來了白熱化的階段。產業內卷的一角,是各大手機品牌的競爭,除了以“換殼為主”作為主要的出新策略,各大廠商也紛紛絞盡腦汁推陳出新,諸如摺疊屏、高重新整理、快充、億級相機畫素等軟硬體迭代也屢見於各種新機釋出會上。據不完全統計,僅 2021年,Android 陣營的品牌廠商在全球範圍內就釋出了 502 款機型裝置(資料來源:gsmarena)。先不討論內卷的背後是對現實的焦慮,國產手機品牌對於新技術、新硬體的探索以及略顯激進的將其應用,個人認為已經很“上進”了(此處不得不 cue 一下現在連殼都懶得換的 Apple【手動狗頭.jpg】)。

迴歸本文的主題,作為一名 Android 開發者,除了對各類五花八門碎片化嚴重的機型和系統進行適配以外,還有一塊重點工作是讓自己開發的應用程式能用上品牌廠商們主打的新特性來增強應用的使用者體驗。像對摺疊屏的適配,利用多屏小視窗的特性來增加使用者多工的互動感。

oppo find n.gif

(網路圖,侵刪)

對相機超高畫素的適配來豐富使用者拍照的體驗以及針對支援高重新整理率的手機螢幕,對應用和遊戲進行適配,增加使用者的流暢體驗。本文從這些新特性中選取了一點,來重點講述基於 Android 系統的重新整理機制,如何來適配目前市面上的一些高重新整理率手機,以獲得更好的使用者體驗。

high_refresh_rate.gif

(網路圖,侵刪)

眾所周知,現在市面上大部分主流的機型的螢幕重新整理率還停留在 60Hz,即螢幕以 1000ms/60 約為 16.6ms 的速度重新整理一次。而現在一些高配手機已經可以達到 90Hz 甚至是 120Hz,從上個動圖也可以看出,不同的重新整理率,頻率越高,給使用者的感官體驗也更加順滑。這裡穿插一個小知識,大家知道電影也是由一幀幀連續畫面製作而成,那麼電影的重新整理幀率是多少?24fps!也就是說,只要用每秒 24 個畫面的速度去播放連續單個畫面片段,大腦就會自動聯想成是一個連續的畫面。那麼為什麼李安導演還要嘗試拍攝 120fps 的《比利·林恩的中場戰事》(《Billy Lynn's Long Halftime Walk》)?從受眾角度出發,120fps 電影帶來的感受遠比 24fps 來的震撼和深入人心,特別對於一些巨集大的戰爭場面或者敘事佈景,24fps 的高速畫面在過渡的時候會出現模糊(動態模糊,motion blur)現象,但 120fps 能讓這些模糊過渡以更清晰的畫面呈現在觀眾面前,觀眾能更有代入感。對於一些支援高重新整理的遊戲也是同理。

螢幕重新整理機制

在 Android 系統中,針對螢幕的 UI 渲染重新整理流水線(rendering pipeline)可以大致分為 5 個階段:

  • 階段1:應用的 UI 執行緒處理輸入事件、調起屬於應用的相關回撥以及更新記錄了相關繪畫指令的 View 層次結構列表;
  • 階段2:應用的渲染執行緒(RenderThread)將處理後指令傳送給 GPU;
  • 階段3:GPU 繪製該幀資料;
  • 階段4:SurfaceFlinger 是負責在螢幕上顯示不同應用視窗的系統服務,它會組合出螢幕應該最終顯示出的內容,並將幀資料提交給螢幕的硬體抽象層 (HAL);
  • 階段5:螢幕顯示該幀內容。

image.png

Android 採用的是雙緩衝策略,通過垂直同步訊號 vsync 來保證前後快取的最佳交換時機。前面有提到兩個概念,一個是幀率,一個是重新整理頻率。一種比較理想的狀態是,幀率和重新整理頻率保持一致,GPU 剛處理完一幀,刷到螢幕上,下一幀就準備好了,這時候前後幀資料的連續完整的。但資料從 CPU 傳遞給 GPU 過程不可控,當螢幕重新整理時,如果取到的幀 buffer 並非是完整 ready 的狀態,就會出現很早之前非智慧手機或者老舊電視機常出現的螢幕畫面撕裂的問題(比如螢幕中上一半是之前的畫面,下一半是新的畫面)。雙緩衝的策略解決的就是這個問題,通過維護兩個緩衝區,讓前緩衝區負責將幀資料運送到螢幕上的同時,在後緩衝區準備下一幀的渲染物件,通過 vsync 訊號來開關是否將後緩衝區資料交換到前緩衝區。

framebuffer.png

我們再來看一下 Android 的原始碼實現
Choreographer$FrameDisplayEventReceiver):

private final class FrameDisplayEventReceiver extends DisplayEventReceiver implements Runnable {
        
    private boolean mHavePendingVsync;
    private long mTimestampNanos;
    private int mFrame;

    public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
            super(looper, vsyncSource);
    }
        
    @Override
    public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
        // 將 vsync 事件通過 handler 傳送
        long now = System.nanoTime();
        if (timestampNanos > now) {
            Log.w(TAG, "Frame time is " + ((timestampNanos - now) * 0.000001f)
                        + " ms in the future!  Check that graphics HAL is generating vsync "
                        + "timestamps using the correct timebase.");
            timestampNanos = now;
        }
        // 判斷是否還有掛起的訊號未被處理
        if (mHavePendingVsync) {
            Log.w(TAG, "Already have a pending vsync event.  There should only be "
                        + "one at a time.");
        } else {
            mHavePendingVsync = true;
        }

        mTimestampNanos = timestampNanos;
        // 替換新的幀資料
        mFrame = frame;
        Message msg = Message.obtain(mHandler, this);
        msg.setAsynchronous(true);
        mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
        }

    @Override
    public void run() {
        mHavePendingVsync = false;
        // 幀資料處理
        doFrame(mTimestampNanos, mFrame);
    }
}

我們再從應用的角度通過 systrace 工具再直觀的感受一下 Android 的重新整理機制:

image.png

上圖中,每一個垂直灰度區為一次 vsync 訊號的同步,每個灰度區為 16.6ms。換句話說,只要 UI thread 以及 RenderThread 的一系列方法引用和執行在一個灰度區內執行完成了,那麼這一幀的資料渲染就可以在 16.6ms 內完成。我們再看下圖將這些訊號放大後的一段異常渲染邏輯,紅框中的渲染明顯已經超過了一個 vsync 訊號的邊界,也就是說這幀資料的渲染耗時過長,在一個訊號週期內沒有執行完成,那麼這一幀的執行反映到程式碼執行就是有問題的。Android 一般會通過分析相關函式的堆疊來定位問題出現的地方,如紅框中的問題是由 RPDetectCoreView 這個自定義檢視引起。如果應用執行一段時間內,這種情況頻繁出現,造成的使用者觀感就是應用的卡頓和掉幀現象。

image.png

高刷的適配

瞭解了 Android 的基本重新整理機制,我們再看一下如何來適配目前帶了高刷屬性的機型。應用或者遊戲可以通過 Android 官方的 SDK/NDK API 來影響螢幕的重新整理率,為什麼說是“影響”而不是“決定”?前面也提到了 CPU/GPU 的寫入速度是不可控的,所以這些方法也只能是去影響螢幕的幀率。

使用 Android 自帶的 SDK

適配高刷的時候,我們會像一般註冊裝置感測器那樣,先知道裝置本身支援哪些感測器,再進行具體的採集動作。同樣,對於螢幕重新整理率,我們也得先知道螢幕支援的重新整理率以及當前的重新整理率,再去可控地調節成應用想要的重新整理率。<br /

那麼獲取重新整理率的方法有如下兩種,兩種都是通過註冊監聽器實現:

  1. DisplayManager.DisplayListener 通過 Display.getRefreshRate() 查詢重新整理率
// 註冊螢幕變化監聽器
public void registerDisplayListener(){
     
    DisplayManager displayManager = (DisplayManager) mContext.getSystemService(Context.DISPLAY_SERVICE);
    displayManager.registerDisplayListener(new DisplayManager.DisplayListener() {
        @Override
        public void onDisplayAdded(int displayId) {

        }

        @Override
        public void onDisplayRemoved(int displayId) {

        }

        @Override
        public void onDisplayChanged(int displayId) {

        }
    }, dealingHandler);
}

// 獲取當前重新整理率
public double getRefreshRate() {
    
  return ((WindowManager) mContext
      .getSystemService(Context.WINDOW_SERVICE))
      .getDefaultDisplay()
      .getRefreshRate();
}
  1. 還可以通過 NDK 的 AChoreographer_registerRefreshRateCallback API
void AChoreographer_registerRefreshRateCallback(
  AChoreographer *choreographer,
  AChoreographer_refreshRateCallback,
  void *data
)

void AChoreographer_unregisterRefreshRateCallback(
  AChoreographer *choreographer,
  AChoreographer_refreshRateCallback,
  void *data
)

在獲取了螢幕可用重新整理率之後,就可以嘗試根據業務需求去設定重新整理率,方法都很簡單,這裡不再做 sample code說明:

  1. 使用 SDK 的 setFrameRate() 方法

    1. Surface.setFrameRate
    2. SurfaceControl.Transaction.setFrameRate
  2. 使用 NDK 的 _setFrameRate 函式

    1. ANativeWindow_setFrameRate
    2. ASurfaceTransaction_setFrameRate

      FramePacingLibrary(FPL,幀同步庫)

這裡再介紹一下 FramePacingLibrary (別名 Swappy)。Swappy 可以幫助基於 OpenGL 以及 Vulkan 渲染 API 的遊戲進行流暢的渲染和幀同步。這裡又有一個幀同步的概念需要說明一下。幀同步,上文我們提到,Android 的整個渲染管道是 CPU 到 GPU 再到 HAL 螢幕顯示硬體,這裡的幀同步就是指 CPU 的邏輯運算和 GPU 的渲染與作業系統的顯示子系統和底層顯示硬體之間的同步。
為什麼 Swappy 適用於遊戲,因為遊戲包含大量的 CPU 計算以及渲染工作,這些計算策略通過從 0 到 1 的開發顯然是成本很高的一個工作,所以 Swappy 像市面上大多數的遊戲引擎那樣(Unity、Unreal 等等)提供了現成的策略機制來幫助遊戲更好更容易的進行開發。

它可以做到:

  • 給每幀渲染新增呈現的時間戳,按時來顯示幀畫面,補償由於遊戲幀較短而出現的卡頓現象
  • 將鎖機制注入到程式中,讓顯示的流水線能跟上進度,不會積累過多導致長幀的卡頓和延遲(同步柵欄)
  • 提供了幀統計資訊來除錯和剖析程式

通過一些圖片來簡單描述一下它的原理:下圖是一個理想的在 60Hz 裝置上執行 30Hz 的幀同步,這裡每一幀(A\B\C\D)都“恰如其分”地正常渲染到了螢幕上。Untitled.png但現實中並不都是這種情況,對與一些短的遊戲幀,如下圖中的 C 幀,由於耗時更短,導致 B 幀還沒有完全顯示應有的幀數就被 C 幀搶先,同時 B 幀的 NB 訊號又觸發了 C 幀的再次顯示,就像跑步的人被路上的小石子絆倒,以一個固定的姿勢摔出幾米一樣,後續都會顯示 C 幀,導致卡頓。Untitled 3.png

而 Swappy 庫通過增加呈現時間戳解決了這個問題,就像給每一幀資料設定了一個鬧鐘,鬧鐘不響就不允許顯示在螢幕上:Untitled 2.png

總結

雖然適配高刷的特性不需要做太多的程式碼適配,但是還是必須要仔細考慮下面幾方面問題:

  1. 在通過 setFrameRate() 方法設定螢幕重新整理率時,還是會存在設定無法生效的情況,像有更高優先順序的 Surface 有不同的幀率設定,或者裝置處於省電模式等,因此我們在開發程式時也必須考慮到如果設定失效的情況下,程式也能正常執行才可以;
  2. 避免頻繁呼叫 setFrameRate() 方法,在每幀過渡的時候,如果頻繁呼叫會引起掉幀問題,我們需要提前通過 API 獲取應有的資訊來一次性調整到正確的幀率;
  3. 不要固定寫死特定的幀率,而應該根據實際的業務場景來調整幀率的設定策略,目標是無縫地在高低螢幕重新整理率之間過渡。

參考

High Refresh Rate Rendering on Android

Frame Pacing Library

Android Frame-rate

作者:ES2049 / 拂曉

文章可隨意轉載,但請保留此原文連結。

非常歡迎有激情的你加入 ES2049 Studio,簡歷請傳送至 caijun.hcj@alibaba-inc.com

相關文章