載入速度慢?Android Cocos 最佳化實戰

針對 Cocos 遊戲存在載入速度慢的問題,技術團隊進行了最佳化。不僅僅提升了使用者體驗的提升,而且最佳化了項目結構,還為未來遊戲-原生跨環境業務發展提供了底層支持。

作者 | 胡駿麒 荔枝集團業務技術中心高級Android工程師

責編 | 王子彧

隨著荔枝集團發展越來越快,集團旗下產品也上線了遊戲複合型產品,但目前產品線上 Cocos 遊戲存在載入速度慢的問題。從線上資料可以看得出,約 37% 的資料花了 4000 毫秒以上的時間才能夠進入到遊戲,約 54% 耗時則在 2000 毫秒到 4000 毫秒之間,僅有 8.25% 的資料耗時少於 2000 毫秒,並且總體的耗時中位數是 3380.5 毫秒。

總體來說,Android Cocos 原生化遊戲的初始化-進入遊戲自上線之後就被產運以及技術團隊內詬病,是一個極度影響使用者體驗的關鍵點,也可能會有使用者在等待進入遊戲過久導致轉化率降低。

Android-Cocos 遊戲存在的問題

自 Android 從 Cocos Web 方案接入了 Cocos 原生化方案之後,Cocos 遊戲載入速度從正常變成了目前很慢的速度。原因於在程式碼層面上之前的 Web 的接入方式完全不一樣,不僅是載入的方式不一樣了,原有的頁面結構也需要大改,比如原有的首頁 Activity 需要繼承 CocosActivity,遊戲會因為 CocosActivity 的生命週期回調被暫停或恢復。

這也是跟 Cocos 官方提供的接入方式有關,Cocos 官方的使用場景是整個 App 就是一個遊戲,但是與我們的產品相悖,我們的 Android App 是一個複合 App 包含原生,flutter,H5,Cocos 遊戲多重技術棧,這就導致遊戲存在一定的問題,比如目前絕大部分 Android App 都是以 Activity 作為主要頁面元件,當 CocosActivity 進入後臺之後,遊戲就會被暫停導致無法進行遊戲預載入,這就會明顯導致進入遊戲場景的速度很慢,明顯影響到使用者體驗。

Cocos 是如何被 App 控制的?

Cocos 是如何被 App 控制的?

以 Cocos 3.6.1 版本為準,其他版本可能存在差異

Activity 生命週期的簡化圖示

Activity 生命週期的簡化圖示

Android 是以 Activity 的形式作為頁面載體,Activity 不僅僅是一個 UI 層面的元件,它還是一個重要的具有 IPC 跨進程通訊功能元件,並且有許多生命週期回調比如初始化 onCreate 等回調,並且這些回調也表明了相對應的狀態。

Activity 是如何被 AMS(ActivityManagerService)創建的

但是Activity的創建,各個運行狀態都是通過AMS(ActivityManagerService)管理的,也可以簡單的說開發者是不無法通過正常手段自行管理 Activity 的創建,運行以及銷燬。

那為什麼要先介紹 Activity 的基本知識呢?因為 Cocos 在 Android 平臺的原生化是完全依賴到了 Activity,應該說是依賴了 Google Android Game Development Kit 裡的 GameActivity。

Google Android Game Development Kit 是什麼?

它有扮演了什麼角色?

Android Game Development Kit (AGDK) 包含一套工具和庫,可幫助您開發和最佳化 Android 遊戲,同時還能與現有遊戲開發平臺和工作流程集成。

GameActivity 是一個 Jetpack 庫,旨在幫助 Android 遊戲在應用的 C/C++ 程式碼中處理應用週期命令、輸入事件和文字輸入。GameActivity 是 NativeActivity 的直接後代,具有類似的架構:

如上圖所示,GameActivity 執行以下功能:

  • 通過 Java 端元件同 Android 框架進行互動。

  • 將應用週期命令、輸入事件和輸入文字傳遞到原生端。

  • 將 C/C++ 源程式碼建模為三個邏輯元件:

  1. GameActivity 的 JNI 函數,直接支持 GameActivity 的 Java 功能,並會將事件加入 native_app_glue 中的隊列。

  2. native_app_glue,主要在自己的原生執行緒(不同於應用的主執行緒)中運行,並且使用其 Looper 執行任務。

  3. 應用的遊戲程式碼,負責輪詢和處理在 native_app_glue 內排隊的事件,並在 native_app_glue 執行緒中執行遊戲程式碼。

藉助 GameActivity,您可以專注於核心遊戲開發,並避免花費過多時間處理 JNI 程式碼。

那麼 Cocos 引擎是如何對接到 AGDK 裡的呢?我以暫停為例:

Java 層 GameActivity 將 Stop 生命週期回調通過 JNI 調用到 C++ 層GameActivity的onNativeStop到android_native_app_glue裡的onPause,android_native_app_glue 通過 Pipe 管道將 APP_CMD_STOP 事件從主執行緒切換到遊戲的主執行緒,將事件傳遞給了 AndroidPlatform,並且AndroidPlatform 則把 _isVisible 修改成 false。

每當 AndroidPlatform 的一層循環調用的時候會檢查 _isVisible && _hasWindow 狀態,如果 TRUE 就會繼續遊戲主執行緒邏輯,否則跳過。

總結:

1. GameActivity 成為了 Java 層與 C++ 層標準化橋樑,提供了一套對接方法。

2. Cocos 遊戲主執行緒會受 GameActivity 生命回調暫停或恢復主執行緒邏輯。

3.大部分 Android App是以多個Activity作為頁面棧進行管理,CocosActivity 自然會因為生命週期調用被暫停。

Cocos 是如何獲得 Surface?

從上圖可以看得到 Cocos 利用 GameActivity 同步 Java 層生命週期調用,並且根據 _isVisible&& _hasWindow 狀態,onStop 控制 _isVisible,那 _hasWindow 是又是被誰控制呢?

ViewRootImpl 被調用 performTraversals 後通過 mWindowSession 請求 WMS(WindowManagerService) 對 Window 進行 relayout。當 Native的 Surface 真正被創建之後,ViewRootImpl 調用 notifySurfaceCreated,將回調調用到 Cocos 的 SurfaceView,SurfaceView 才調用到註冊了SurfaceHolder 的 GameActivity。

從上圖可以得出:

1.Surface 是由 WMS 管理,Activity 進入前臺則會獲取,反之 Activity 進入後臺則會被釋放。

2.Cocos 引擎根據 surface 是否有效暫停或恢復主執行緒邏輯。

小結

小結

從以上分析,我們可以得出以下結論:

1. GameActivity 成為了 Java 層與 C++ 層標準化橋樑,提供了一套對接方法。

2. Cocos 遊戲主執行緒會受 GameActivity 生命回調暫停或恢復主執行緒邏輯。

3.絕大部分Android App是以多個Activity作為頁面棧進行管理,CocosActivity 自然會因為生命週期調用被暫停。

4. Surface 是由 WMS 管理,Activity 進入前臺則會獲取,反之 Activity 進入後臺則會被釋放。

5. Cocos 引擎根據 surface 是否有效暫停或恢復主執行緒邏輯。

而導致遊戲被暫停從而致使遊戲初始化-進入遊戲的時間耗時的原因則是:

1.遊戲開啟需要切換到首頁,在切換到首頁之前,遊戲無法恢復主執行緒運行

2. Surface 獲取需要時間,且需要切換到首頁之後才能夠被獲取,遊戲無法恢復主執行緒運行

只要解決以上兩點,那就可以在遊戲載入上有巨大的提升。

解決方案

解決方案

目前問題最緊迫的是遊戲載入速度過慢的問題,提高使用者體驗,儘可能需要有一個成本最低的方案且對後續 Cocos 升級不會有產生影響。

與 GameActivty 解耦

1. GameActivity 成為了 Java 層與 C++ 層標準化橋樑,提供了一套對接方法。

2. Cocos 遊戲主執行緒會受 GameActivity 生命回調暫停或恢復主執行緒邏輯。

從上面的分析可以得出: GameActivity 其實是是一個標準化的橋樑,是一個控制器,但只是因為 Activity 是被 AMS 管理才無法控制,那有沒有可能通過技術手段將 GameActivity 被我控制?

Android Activity 是由 AMS 管理,並且又由 ActivityThread 用Classloader 動態載入 Class,並且在創建的過程中會創建 PhoneWindow 以及 attach 比如 Application 等,但我們可以換個角度去思考這個事情。

我們將 GameActivity 看作為一個控制器,Activity 已經幫我處理好了各種狀態,但如果直接將 Activity 拿來用的話是會出問題的,會出現類似這樣的崩潰。

Plain Text2023-03-13 10:26:47.510 31052-31052/com.whodm.ww E/AndroidRuntime: FATAL EXCEPTION: mainProcess: com.whodm.ww, PID: 31052java.lang.RuntimeException: Unable to start activity ComponentInfo{com.whodm.ww/com.example.myapplication.MainActivity}: java.lang.NullPointerException: Attempt to invoke virtual method 'android.content.res.Resources android.content.Context.getResources()' on a null object referenceat android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3869)at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:4011)at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:111)at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2466)at android.os.tekiapm.ProxyHandlerCallback.handleMessage(ProxyHandlerCallback.kt:47)at android.os.Handler.dispatchMessage(Handler.java:102)at android.os.Looper.loopOnce(Looper.java:240)at android.os.Looper.loop(Looper.java:351)at android.app.ActivityThread.main(ActivityThread.java:8364)at java.lang.reflect.Method.invoke(Native Method)at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:584)at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1013)Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'android.content.res.Resources android.content.Context.getResources()' on a null object referenceat android.content.ContextWrapper.getResources(ContextWrapper.java:121)at android.view.ContextThemeWrapper.getResourcesInternal(ContextThemeWrapper.java:134)at android.view.ContextThemeWrapper.getResources(ContextThemeWrapper.java:128)at androidx.appcompat.app.AppCompatActivity.getResources(AppCompatActivity.java:577)at com.example.myapplication.NextActivity.onCreate(NextActivity.kt:45)at com.example.myapplication.MainActivity.onCreate(MainActivity.kt:108)at android.app.Activity.performCreate(Activity.java:8397)at android.app.Activity.performCreate(Activity.java:8370)at android.app.Instrumentation.callActivityOnCreate(Instrumentation.java:1403)at android.app.ActivityThread.performLaunchActivity(ActivityThread.java:3842)at android.app.ActivityThread.handleLaunchActivity(ActivityThread.java:4011)at android.app.servertransaction.LaunchActivityItem.execute(LaunchActivityItem.java:111)at android.app.servertransaction.TransactionExecutor.executeCallbacks(TransactionExecutor.java:135)at android.app.servertransaction.TransactionExecutor.execute(TransactionExecutor.java:95)at android.app.ActivityThread$H.handleMessage(ActivityThread.java:2466)at android.os.tekiapm.ProxyHandlerCallback.handleMessage(ProxyHandlerCallback.kt:47)at android.os.Handler.dispatchMessage(Handler.java:102)at android.os.Looper.loopOnce(Looper.java:240)at android.os.Looper.loop(Looper.java:351)at android.app.ActivityThread.main(ActivityThread.java:8364)at java.lang.reflect.Method.invoke(Native Method)at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:584)at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1013)

還記得上面的說的:

在創建的過程中會創建 PhoneWindow 以及 attach 比如 Application 等

針對以上兩個點,我們只需要兩點處理,通過以下方式則可規避掉崩潰的問題:

1.利用外部的 Activity 提供的 PhoneWindow。

2.通過反射的方式,將 Application 設置到 GameActivity。

Javapublic class GameActivity extends Activity implements SurfaceHolder.Callback2, Listener,OnApplyWindowInsetsListener{public GameActivity(AppCompatActivity activity, Application application) {mParent = activity;this.mApplication = application;this.attachBaseContext(application);try {Field fieldApplication = Activity.class.getDeclaredField("mApplication");fieldApplication.setAccessible(true);fieldApplication.set(this, application);} catch (Exception e) {e.printStackTrace();}}public Window getWindow() {return mParent.getWindow();}}

那面對 C++ 層對 Java 的調用該怎麼處理呢?

因為 C++ 調用 Java 是通過反射的方式去調用的,只要 class 和 method 的方法名是正確的,我們就可以正確做到橋接。

以上,就已完成了 GameActivity 的基本解耦,當然還有別的地方需要改動,但方法論並沒有變。

規避Surface獲取的困難

規避Surface獲取的困難

規避Surface獲取的困難

從上面的流程圖其實可以看得出,Surface 的獲取是根據上層決定的,當Activity 進入到後臺之後(有一個新的 Activity 覆蓋或 App 進入到後臺), Surface 就會銷燬,所以想要遊戲能在不一樣的 Activity 上運行,在對GameActivity 解耦之後,僅僅需要將 SurfaceView 進行 addView 到具體的Activity 裡的 ViewGroup 裡即可。

為了提升遊戲的載入速度,採取了一套預先載入的策略:

1.在匹配玩家過程中將 SurfaceView 添加到匹配頁面。

2.在匹配頁面等待遊戲載入完成,達成只要切換頁面遊戲即可載入完成的目的。

以上方式還解決了一個問題,那就是 Cocos 引擎初始化以及進入默認場景的主執行緒被原有的 GameActivity 中斷的問題。比如使用者的行為是不可阻礙的,任何頁面切換都導致 GameActivity 進入到了後臺,那會暫停 Cocos 引擎並且 Surface 也會被釋放。所以利用匹配玩家過程中的耗時去同樣消耗 Cocos 引擎初始化以及進入默認場景的耗時,這樣就可以避免上述問題的發生。

成果

成果

資料採集方式:

模擬使用者行為:App 進入到首頁之後就點選檔位選擇頁並且進行匹配遊戲,每次進入完遊戲之後,殺掉 App 再進行測試。

中低端機代表:三星 A13 5G

最佳化前

最佳化後

第一次

3969ms

1525ms

第二次

3824ms

1505ms

第三次

3838ms

1773ms

第四次

4028ms

1565ms

高端機代表:一加10

最佳化前

最佳化後

第一次

5154ms

1140ms

第二次

4411ms

1072ms

第三次

2965ms

1083ms

第四次

3606ms

1121ms

從資料以及體感上來看的話,Cocos 遊戲改造最佳化後帶來的提升十分的明顯,也恢復到了正常且較為優秀的載入速度了。

線上資料

1.耗時小於 2000 毫秒從原有的 8.25% 大幅升至 48.86%,且從原先的最少區間變為最大區間。

2.耗時小於 4000 毫秒從原有 62.28% 大幅升至 82.37%,絕大部分資料都在4000 毫秒以內。

3.資料中位數從 3380.5 毫秒減少到 2033.5 毫秒,普遍具有一秒以上的速度提升。

結論

結論

通過系統性對整個框架的分析,得出一套僅在 Java 做修改就可以極大提升遊戲載入速度的方案,並且與 GameActivity 解耦,承載遊戲的 SurfaceView 能夠在任意 Activity 正確顯示,僅改動了 Android 平臺上層程式碼,Android 端實現了遊戲執行緒自主控制,不僅僅是使用者體驗的提升,最佳化了項目結構,還為未來遊戲-原生跨環境業務發展提供了底層支持。

引用:

Android Game Development Kit:https://developer.android.google.cn/games/agdk/overview?hl=zh-cn

相關文章

React Native 開發真有那麼好?!

React Native 開發真有那麼好?!

整理 | 蘇宓 兜兜轉轉,不少開發者還是發現 React Native 的真相定律。 日前,國外知名聊天軟體 Discord 於官方部落格上...

React-Native 開發實用指南

React-Native 開發實用指南

【CSDN 編者按】本文主要介紹 React-Native 的實際使用經驗,對於想要快速入門的同學是有幫助的。 作者 | 佐玉 整體介紹 首...