图文详解|荔枝集团旗下产品Android-Cocos游戏优化分享

背景

随着荔枝集团发展越来越快,集团旗下产品也上线了游戏复合型产品,但目前产品线上Cocos游戏存在加载速度慢的问题。从线上数据可以看得出,约37%的数据花了4000毫秒以上的时间才能够进入到游戏,约54%耗时则在2000毫秒到4000毫秒之间,仅有8.25%的数据耗时少于2000毫秒,并且总体的耗时中位数是3380.5毫秒。

总体来说,Android Cocos 原生化游戏初始化-进入游戏 自上线之后就被产运以及技术团队内诟病,是一个极度影响用户体验的关键点,也可能会有用户在等待进入游戏过久导致转化率降低。

问题所在

自Android从Cocos Web方案接入了Cocos 原生化方案之后,Cocos游戏加载速度从正常变成了目前很慢的速度。原因于在代码层面上之前的Web的接入方式完全不一样,不仅是加载的方式不一样了,原有的页面结构也需要大改,比如 原有的首页Activity需要继承CocosActivity,游戏会因为CocosActivity的生命周期回调被暂停或恢复

这也是跟Cocos官方提供的接入方式有关,Cocos官方的使用场景是整个App就是一个游戏,但是与我们的产品相悖,我们的AndroidApp是一个复合App包含原生,flutter,H5,Cocos游戏多重技术栈,这就导致游戏存在一定的问题,比如目前绝大部分AndroidApp都是以Activity作为主要页面组件,当CocosActivity进入后台之后, 游戏就会被暂停导致无法进行游戏预加载,这就会明显导致进入游戏场景的速度很慢,明显影响到用户体验

分析

以Cocos 3.6.1版本为准,其他版本可能存在差异

Cocos是如何被App控制的?


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++ 代码中处理应用周期命令、输入事件和文本输入。 GameActivityNativeActivity 的直接后代,具有类似的架构:
1280X1280-2
如上图所示, 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管理,Activiyt进入前台则会获取,反之Activity进入后台则会被释放。

  5. Cocos引擎根据surface是否有效暂停或恢复主线程逻辑。

而导致游戏被暂停从而致使游戏 初始化-进入游戏 的时间耗时的原因则是:

  1. 游戏开启需要切换到首页,在切换到首页之前,游戏无法恢复主线程运行

  2. Surface获取需要时间,且需要切换到首页之后才能够被获取,游戏无法恢复主线程运行

只要解决以上2个点,那就可以在游戏加载上有巨大的提升。

解决方案

目前问题最紧迫的是游戏加载速度过慢的问题,提高用户体验,尽可能需要有一个成本最低的方案且对后续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拿来用的话是会出问题的,会出现类似这样的崩溃。

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

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:Android Game Development Kit  |  Android 开发者  |  Android Developers

关于荔枝集团

荔枝集团打造了综合性的全球化音频生态系统,致力于通过多样化产品组合满足用户对于音频娱乐以及在线社交的需求,使每个人都能在全球化的音频生态系统中通过声音连接与互动。荔枝公司于2020年1月在纳斯达克上市。

作者:胡骏麒,荔枝集团业务技术中心高级Android工程师,邮箱:hujunqi@lizhi.fm

招聘信息

目前随着荔枝集团业务发展,我们需要更多优秀的人才加入到荔枝大家庭之中,我们提供年轻化、扁平化的工作环境,开放简单的交流氛围,和优秀的人一起努力打造更好的声音产品。

Cocos Creator 研发工程师

LIZHI社招内推码: B95D7YM

投递链接: LIZHI内推

高级Android开发工程师

LIZHI社招内推码: B95D7YM

投递链接: LIZHI内推

高级iOS开发工程师

LIZHI社招内推码: B95D7YM

投递链接: LIZHI内推

7赞

:+1:,学习了

强,这个问题困扰我们团队挺久了,学习了! :+1:

mark一下

大佬,求个demo吧

无敌!!!

mark!