针对 Cocos 游戏存在加载速度慢的问题,技术团队进行了优化。不仅仅提升了用户体验的提升,而且优化了项目结构,还为未来游戏-原生跨环境业务发展提供了底层支持。
作者 | 胡骏麒 荔枝集团业务技术中心高级Android工程师
责编 | 王子彧
出品 | CSDN(ID:CSDNnews)
随着荔枝集团发展越来越快,集团旗下产品也上线了游戏复合型产品,但目前产品线上 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 3.6.1 版本为准,其他版本可能存在差异
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++ 源代码建模为三个逻辑组件:
GameActivity 的 JNI 函数,直接支持 GameActivity 的 Java 功能,并会将事件加入 native_app_glue 中的队列。
native_app_glue,主要在自己的原生线程(不同于应用的主线程)中运行,并且使用其 Looper 执行任务。
应用的游戏代码,负责轮询和处理在 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 Text
2023-03-13 10:26:47.510 31052-31052/com.whodm.ww E/AndroidRuntime: FATAL EXCEPTION: main
Process: com.whodm.ww, PID: 31052
java.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 reference
at 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 reference
at 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。
Java
public 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 的获取是根据上层决定的,当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