作為一款VR實時操作遊戲App,我們需要根據重力感應系統,實時監控手機的角度,並渲染出相應位置的VR影象,因此在不同 Android 裝置之間,由於使用的晶片組和不同架構的GPU,遊戲效能會因此受到影響。舉例來說:遊戲在 Galaxy S20+ 上可能以 60fps 的速度渲染,但它在HUAWEI P50 Pro上的表現可能與前者大相徑庭。 由於新版本的手機具有良好的配置,而遊戲需要考慮基於底層硬體的執行情況。
如果玩家遇到幀速率下降或載入時間變慢,他們很快就會對遊戲失去興趣。
如果遊戲耗盡電池電量或裝置過熱,我們也會流失處於長途旅行中的遊戲玩家。
如果提前預渲染不必要的遊戲素材,會大大增加遊戲的啟動時間,導致玩家失去耐心。
如果幀率和手機不能適配,在執行時會由於手機自我保護機制造成閃退,帶來極差的遊戲體驗。
基於此,我們需要對程式碼進行最佳化以適配市場上不同手機的不同幀率執行。
所遇到的挑戰
首先我們使用Streamline 獲取在 Android 裝置上執行的遊戲的配置檔案,在執行測試場景時將 CPU 和 GPU效能計數器活動視覺化,以準確瞭解裝置處理 CPU 和 GPU 工作負載,從而去定位幀速率下降的主要問題。
以下的幀率分析圖表顯示了應用程式如何隨時間執行。
在下面的圖中,我們可以看到執行引擎週期與 FPS 下降之間的相關性。顯然GPU 正忙於算術運算,並且著色器可能過於複雜。
為了測試在不同裝置中的幀率情況,使用友盟+U-APM測試不同機型上的卡頓狀況,發現在onSurfaceCreated函式中進行渲染時出現卡頓, 應證了前文的分析,可以確定GPU是在算數運算過程中發生了卡頓:
因為不同裝置有不同的效能預期,所以需要為每個裝置設定自己的效能預算。例如,已知裝置中 GPU 的最高頻率,並且提供目標幀速率,則可以計算每幀 GPU 成本的絕對限制。
數學公式: $ 每幀 GPU 成本 = GPU 最高頻率 / 目標幀率 $
CPU到 GPU 的排程存在一定的約束,由於排程上存在限制所以我們無法達到目標幀率。
另外,由於 CPU-GPU 介面上的工作負載序列化,渲染過程是非同步進行的。
CPU 將新的渲染工作放入佇列,稍後由 GPU 處理。
資料資源問題
CPU控制渲染過程並且實時提供最新的資料,例如每一幀的變換和燈光位置。然而,GPU 處理是非同步的。這意味著資料資源會被排隊的命令引用,並在命令流中停留一段時間。而程式中的OpenGL ES 需要渲染以反映進行繪製呼叫時資源的狀態,因此在引用它們的 GPU 工作負載完成之前無法修改資源。
除錯過程
我們曾做出嘗試,對引用資源進行程式碼上的編輯最佳化,然而當我們嘗試修改這部分內容時,會觸發該部分的新副本的建立。這將能夠一定程度上實現我們的目標,但是會產生大量的 CPU 開銷。
於是我們使用Streamline查明高 CPU 負載的例項。在圖形驅動程式內部libGLES_Mali.so路徑函式, 檢視中看到極高的佔用時間。
由於我們希望在不同手機上適配不同幀率執行,所以需要查明libGLES_Mali.so是否在不同機型的裝置上都產生了極高的佔用時間,此處採用了友盟+U-APM來檢測使用者在不同機型上的函式佔用比例。
經友盟+ U-APM自定義異常測試,下列機型會產生高libGLES_Mali.so佔用的問題,因此我們需要基於底層硬體的執行情況來解決流暢性問題,同時由於存在問題的機型不止一種,我們需要從記憶體層面著手,考慮如何呼叫較少的記憶體快取區並及時釋放記憶體。
解決方案及最佳化
基於前文的分析,我們首先嚐試從緩衝區入手進行最佳化。
單緩衝區方案
• 使用glMapBufferRange和GL_MAP_UNSYNCHRONIZED.然後使用單個緩衝區內的子區域構建旋轉。這避免了對多個緩衝區的需求,但是這一方案仍然存在一些問題,我們仍需要處理管理子區域依賴項,這一部分的程式碼給我們帶來了額外的工作量。
多緩衝區方案
• 我們嘗試在系統中建立多個緩衝區,並以迴圈方式使用緩衝區。透過計算我們得到了適合的緩衝區的數目,在之後的幀中,程式碼可以去重新使用這些迴圈緩衝區。由於我們使用了大量的迴圈緩衝區,那麼大量的日誌記錄和資料庫寫入是非常有必要的。但是有幾個因素會導致此處的效能不佳:
1. 產生了額外的記憶體使用和GC壓力
2. Android 作業系統實際上是將日誌訊息寫入日誌而並非檔案,這需要額外的時間。
3. 如果只有一次呼叫,那麼這裡的效能消耗微乎其微。但是由於使用了迴圈緩衝區,所以這裡需要用到多次呼叫。
我們會在基於c#中的 Mono 分析器中啟用記憶體分配跟蹤函式用於定位問題:
$ adb shell setprop debug.mono.profile log:calls,alloc
我們可以看到該方法在每次呼叫時都花費時間:
Method call summary Total(ms) Self(ms) Calls Method name 782 5 100 MyApp.MainActivity:Log (string,object[]) 775 3 100 Android.Util.Log:Debug (string,string,object[]) 634 10 100 Android.Util.Log:Debug (string,string)
在這裡定位到我們的日誌記錄花費了大量時間,我們的下一步方向可能需要改進單個呼叫,或者尋求全新的解決方案。
log:alloc還讓我們看到記憶體分配;日誌呼叫直接導致了大量的不合理記憶體分配:
Allocation summary Bytes Count Average Type name 41784 839 49 System.String 4280 144 29 System.Object[]
硬體加速
最後嘗試引入硬體加速,獲得了一個新的繪圖模型來將應用程式渲染到螢幕上。它引入了DisplayList 結構並且記錄檢視的繪圖命令以加快渲染速度。
同時,可以將 View 渲染到螢幕外緩衝區並隨心所欲地修改它而不用擔心被引用的問題。此功能主要適用於動畫,非常適合解決我們的幀率問題,可以更快地為複雜的檢視設定動畫。
如果沒有圖層,在更改動畫屬性後,動畫檢視將使其無效。對於複雜的檢視,這種失效會傳播到所有的子檢視,它們反過來會重繪自己。
在使用由硬體支援的檢視層後,GPU 會為檢視建立紋理。因此我們可以在我們的螢幕上為複雜的檢視設定動畫,並且使動畫更加流暢。
程式碼示例:
// Using the Object animator view.setLayerType(View.LAYER_TYPE_HARDWARE, null); ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(view, View.TRANSLATION_X, 20f); objectAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationEnd(Animator animation) { view.setLayerType(View.LAYER_TYPE_NONE, null); } }); objectAnimator.start(); // Using the Property animator view.animate().translationX(20f).withLayer().start();
另外還有幾點在使用硬體層中仍需注意:
(1)在使用之後進行清理:
硬體層會佔用GPU上的空間。在上面的 ObjectAnimator程式碼中,偵聽器會在動畫結束時移除圖層。在 Property animator 示例中,withLayers() 方法會在開始時自動建立圖層並在動畫結束時將其刪除。
(2)需要將硬體層更新視覺化:
使用開發人員選項,可以啟用“顯示硬體層更新”。
如果在應用硬體層後更改檢視,它將使硬體層無效並將檢視重新渲染到該螢幕外緩衝區。
硬體加速最佳化
但是由此帶來了一個問題是,在不需要快速渲染的介面,比如滾動欄, 硬體層也會更快地渲染它們。當將 ViewPager 滾動到兩側時,它的頁面在整個滾動階段會以綠色突出顯示。
因此當我滾動 ViewPager 時,我使用 DDMS 執行 TraceView,按名稱對方法呼叫進行排序,搜尋“android/view/View.setLayerType”,然後跟蹤它的引用:
ViewPager#enableLayers(): private void enableLayers(boolean enable) { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { final int layerType = enable ? ViewCompat.LAYER_TYPE_HARDWARE : ViewCompat.LAYER_TYPE_NONE; ViewCompat.setLayerType(getChildAt(i), layerType, null); } }
該方法負責為 ViewPager 的孩子啟用/禁用硬體層。它從 ViewPaper#setScrollState() 呼叫一次:
private void setScrollState(int newState) { if (mScrollState == newState) { return; } mScrollState = newState; if (mPageTransformer != null) { enableLayers(newState != SCROLL_STATE_IDLE); } if (mOnPageChangeListener != null) { mOnPageChangeListener.onPageScrollStateChanged(newState); } }
正如程式碼中所示,當滾動狀態為 IDLE 時硬體被禁用,否則在 DRAGGING 或 SETTLING 時啟用。PageTransformer 旨在“使用動畫屬性將自定義轉換應用於頁面檢視”(Source)。
基於我們的需求,只在渲染動畫的時候啟用硬體層,所以我想覆蓋ViewPager 方法,但由於它們是私有的,我們無法修改這個方法。
所以我採取了另外的解決方案:在 ViewPage#setScrollState() 上,在呼叫enableLayers() 之後,我們還會呼叫OnPageChangeListener#onPageScrollStateChanged()。所以我設定了一個監聽器,當 ViewPager 的滾動狀態不同於 IDLE 時,它將所有 ViewPager 的孩子的圖層型別重置為 NONE:
@Override public void onPageScrollStateChanged(int scrollState) { // A small hack to remove the HW layer that the viewpager add to each page when scrolling. if (scrollState != ViewPager.SCROLL_STATE_IDLE) { final int childCount = <your_viewpager>.getChildCount(); for (int i = 0; i < childCount; i++) <your_viewpager>.getChildAt(i).setLayerType(View.LAYER_TYPE_NONE, null); } }
這樣,在 ViewPager#setScrollState() 為頁面設定了一個硬體層之後——我將它們重新設定為 NONE,這將禁用硬體層,因此而導致的幀率區別主要顯示在 Nexus上。
作者:陳可心
原文連結:http://click.aliyun.com/m/1000306394/
本文為阿里雲原創內容,未經允許不得轉載。