联调平台从上至下分为游戏侧和SDK侧两层,游戏侧是指游戏开发,通常是在PC环境进行,SDK侧是指移动SDK环境,需要在Android/iOS平台运行。首先游戏开发人员在游戏侧发起某个GSDK接口的调用,针对不同的游戏引擎(Unity/Unreal),我们提供了Unity与Unreal的插件支持,并在底层封装成统一的C++接口层,最终将分发至不同的终端平台,这个过程是由宏定义来控制是走原生平台还是联调平台,针对游戏侧接入,我们提供了Bridge和Common两种接入方式,两种方式对应的协议内容、实现方式、接入方式都各有不同,下文会详细介绍两种方式的原理细节。接口请求数据继续向下会分发到联调平台的EngineProxy层,EngineProxy层封装了联调平台在游戏侧的核心实现,包括数据协议处理、同步等待、NetTool(联调平台网络工具)封装、Callback处理等,处理后的数据包会通过NetTool分发到SDK侧,NetTool是整个通信的基石,通过长连接实现,支持局域网和公网两种方式,这样就完成了数据从PC端到移动端的流转。
移动端SDK需要在真实APP环境并且安装到手机上才能运行,因此联调平台还为游戏开发人员提供了管理平台,在管理平台可以完成对应SDK版本APP的构建,APP侧通过NetTool接收到接口请求数据,首先在NativeProxy层对消息进行解析,判断游戏侧接入类型,验证通过后构建真实的接口请求,获取请求的方法名和对应的参数,分发到GSDKBridge层通过反射来完成GSDK方法的调用,如果是同步方法,则在方法执行后将结果进行协议封装,通过NetTool发送到游戏侧,沿着相反的路径最终返回至最初EngineCalls调用的地方,至此完成了整体数据双向收发的流程。同时Proxy层还提供了可视化的界面,让游戏开发者可以实时便捷的查看每个接口的细节,以及对数据进行筛选、搜索、历史查看等操作,使得整个游戏开发体验非常的高效。
接下来会重点介绍各模块的方案实现细节与原理。
Unity/UnrealBridge方案是指在引擎侧接入了SDK提供的引擎Bridge插件,该Bridge插件和NativeBridge层约定好了通信协议和交互方式,协议载体是JSON,请求和JSON协议一一对应,由引擎侧组装好业务JSON数据包后,联调平台Proxy层会通过NetTool转发到手机SDK侧,NativeProxy侧处理后获取完整的业务数据包,进而通过SDKBridge层完成接口请求。
由于方法的调用本身需要通过网络,是异步的操作,因此对于部分有同步返回值的接口方法,Engine端在调用接口后,必须要同步等待,因此在Engine端需要Hold当前线程,来模拟出实际接口的使用情况。具体的调用时序图:
统一的C++Interface中已经定义好了通用的数据协议,但是针对联调平台,还需要额外增加网络层的若干字段,以下列出该方案不同类型的协议字段:
根据NetTool应用层的头部协议,如果是同步方法,会把方法执行后的结果进行返回协议封装,通过NetTool发送回引擎侧,引擎侧取到实际的值后释放等待的信号量,实现方法的返回,完成一次方法调用。针对Event和Callback类型,也添加了对应的消息类型,在双端根据msg_id维护对应的映射关系,实现事件的响应和回调处理。
Common方案是指在引擎侧没有接入Bridge插件的游戏,本方案基于一个大前提:Unity/Unreal引擎侧调用Native的方法都是通过C方法进行调用,因此要实现通用方案就转变成了如何实现动态对任意一个包含任意参数的C函数进行调用。
C函数导出的符号约定为前缀增加下划线,如
voidtestMethod();方法的符号为:
_testMethod该符号所对应的地址实际上会被包含在macho文件中,我们只需要找到对应的符号名称即可找到对应的符号地址。
动态链接器这里已经为我们提供了这样的能力:
externvoid*dlsym(void*__handle,constchar*__symbol)__DYLDDL_DRIVERKIT_UNAVAILABLE;通过dlsym我们可以通过符号找到对应的地址,比如这样:
void*functionPtr=dlsym(RTLD_DEFAULT,"testMethod");针对这样无参数无返回类型的函数,直接对函数指针发起调用即可:
void(*funcPointer)()=functionPtr;funcPointer();对任意参数进行调用那么找到函数所在地址后,对于有参数有返回值的函数,该如何动态传参呢?
通常来说函数调用要用到的两条基本的指令:CALL指令和RET指令。CALL指令将当前的指令指针(这个指针指向紧接在CALL指令后面的那条指令)压入堆栈,然后执行一条无条件转移指令转移到新的代码地址。RET是与CALL指令配合使用的指令,在绝大多数函数中它是最后一条指令。RET指令弹出返回地址(就是早些时候CALL指令压入堆栈的地址)并将其加载到EIP寄存器中,然后从这个地址开始继续执行。
我们都知道函数调用中参数的传递实际上是参数的压栈和出栈的过程,所以问题的本质在于针对需要传入参数的函数,我们如何对参数进行压栈,以及压栈后,我们该如何在函数调用时使用这些参数,并在使用完成返回时依次弹出这些参数。
由于在C语言层面,直接去操作压栈与出栈的参数十分困难,因此需要使用到汇编去帮助我们完成。
那么我们采用LibFFI库来帮助我们实现这个复杂的能力,大致分为这几步:
这种方案可以支持绝大部分的参数类型调用,但是针对输入参数是函数指针的情况,就无法做到了。
在个别游戏项目里,所有的回调方法都是通过C#中构造Action然后在调用C方法时,传入该Action的函数指针。由于联调平台所有数据都是通过网络层建立桥接完成调用的,因此在实际调用中,Native端根本不存在这个函数指针,也就是说,必须要动态构建出一个函数指针。
由于在打包macho时,所有的函数地址绑定的符号必须要打包在可执行文件中,因此想要能够动态生成一个函数是无法做到的。
那么还有其他方法可以动态完成函数的创建、调用时进行压栈操作、出栈操作吗?
其实我们可以事先定义好一个通用的函数实现,并通过汇编代码,在执行时,去手动的收集输入的参数以及返回值参数,于是我们也可以利用LibFFI特性来实现。
总结起来的流程就是:
通过这个方法还可以通过userData传入的方式,去标识和辨别每一个创建的C函数。
NetTool承载了联调平台所有网络(包含局域网与广域网)的收发能力,在联调方案中,无论针对同步和异步的场景都需要被动通知的能力,因此我们需要采用长连接方案,NetTool是整个联调平台的核心层,直接关乎接口调用的成功与否。
对于局域网场景,是不需要外界网络参与的,所以在手机APP侧需要具备Server端的能力,为PC游戏侧提供接口服务,采用本地Socket长连,由PC游戏侧完成Client端能力接入,局域网环境非常的稳定和高效,是NetTool首选的连接方式,在该场景中,PC和手机需要保持在相同局域网环境,手机APP会建立长连接的Server,监听对应的端口,PC侧作为Client根据Server端的IP地址和端口号建立长连接,根据既定协议进行数据传送。
以局域网场景为例,上图为NetTool整体的数据处理流程,主要包括如下步骤:
上面阐述了联调平台整体的设计思路和方案原理细节,分析了从引擎侧到SDK侧整个链路的数据流向和处理过程,游戏开发者借助该平台可以在PC侧高效完成移动SDK能力的接入,提高了数倍的接入效率和接入体验,让游戏工作室更专注游戏玩法的实现,目前该平台已在字节内部和外部二十几款游戏落地,全部是正向反馈,我们也会不断去优化和迭代联调平台的易用性和稳定性,不断扩展平台功能,该平台为效率而生,希望日后可以解决游戏开发过程中的所有效率问题,并为游戏开发者带来全新的开发体验。