游戏开发效能工具之「联调平台」的探索之路

avatar
@字节跳动

作者:字节游戏中台客户端团队 - 周暄承、刘继玺

引言

中重度游戏通常是基于Unity、Unreal等成熟引擎进行开发,游戏开发人员会专注于游戏本身玩法的实现,技术栈主要集中在游戏领域,针对游戏中的通用能力,如登录、支付、推送、分享、录屏等功能会选择使用移动端(Android/iOS)SDK来实现,以下简称“Game SDK”或“GSDK”,Game SDK对游戏侧暴露统一的接口,把功能细节和平台差异都封闭在SDK内部,并且游戏开发相比SDK在技术栈和开发语言上具有明显差异性,因此这样的开发模式极大提高了游戏侧开发效率。但是游戏开发人员通常是在PC上进行开发,Game SDK需要移动平台的运行环境,以Unity为例,需要导入SDK的产物,然后导出Android/iOS工程,最后进行打包验证,并安装到手机上才能看到效果,如果发现功能不符合预期,需要修改代码继续重复上述步骤,该步骤会耗时几分钟到几十分钟不等,这样的联调效率在开发过程是致命的,因此我们需要改善这种糟糕的联调体验,让游戏开发人员在Editor环境开发游戏过程中可以实时高效的去联调SDK接口,即改即所得,因此「联调平台」在此背景下应运而生,联调平台又称“Ender平台”,联调平台赋予游戏开发在PC上联调真实环境的能力,其中的数据都是真实且有效的,使得在PC上联调成功后,导出到手机环境中,直接可以正常运行。

整体概述

架构图如下:

联调平台从上至下分为游戏侧和SDK侧两层,游戏侧是指游戏开发,通常是在PC环境进行,SDK侧是指移动SDK环境,需要在Android/iOS平台运行。首先游戏开发人员在游戏侧发起某个GSDK接口的调用,针对不同的游戏引擎(Unity/Unreal),我们提供了Unity与Unreal的插件支持,并在底层封装成统一的C++接口层,最终将分发至不同的终端平台,这个过程是由宏定义来控制是走原生平台还是联调平台,针对游戏侧接入,我们提供了Bridge和Common两种接入方式,两种方式对应的协议内容、实现方式、接入方式都各有不同,下文会详细介绍两种方式的原理细节。接口请求数据继续向下会分发到联调平台的Engine Proxy层,Engine Proxy层封装了联调平台在游戏侧的核心实现,包括数据协议处理、同步等待、Net Tool(联调平台网络工具)封装、Callback处理等,处理后的数据包会通过Net Tool分发到SDK侧,Net Tool是整个通信的基石,通过长连接实现,支持局域网和公网两种方式,这样就完成了数据从PC端到移动端的流转。

移动端SDK需要在真实APP环境并且安装到手机上才能运行,因此联调平台还为游戏开发人员提供了管理平台,在管理平台可以完成对应SDK版本APP的构建,APP侧通过Net Tool接收到接口请求数据,首先在Native Proxy层对消息进行解析,判断游戏侧接入类型,验证通过后构建真实的接口请求,获取请求的方法名和对应的参数,分发到GSDK Bridge层通过反射来完成GSDK方法的调用,如果是同步方法,则在方法执行后将结果进行协议封装,通过Net Tool发送到游戏侧,沿着相反的路径最终返回至最初Engine Calls调用的地方,至此完成了整体数据双向收发的流程。同时Proxy层还提供了可视化的界面,让游戏开发者可以实时便捷的查看每个接口的细节,以及对数据进行筛选、搜索、历史查看等操作,使得整个游戏开发体验非常的高效。

接下来会重点介绍各模块的方案实现细节与原理。

Unity/Unreal Bridge方案原理

Unity/Unreal Bridge方案是指在引擎侧接入了SDK提供的引擎Bridge插件,该Bridge插件和Native Bridge层约定好了通信协议和交互方式,协议载体是JSON,请求和JSON协议一一对应,由引擎侧组装好业务JSON数据包后,联调平台Proxy层会通过Net Tool转发到手机SDK侧,Native Proxy侧处理后获取完整的业务数据包,进而通过SDK Bridge层完成接口请求。

同步等待

由于方法的调用本身需要通过网络,是异步的操作,因此对于部分有同步返回值的接口方法,Engine端在调用接口后,必须要同步等待,因此在Engine端需要Hold当前线程,来模拟出实际接口的使用情况。具体的调用时序图:

数据协议

统一的C++ Interface中已经定义好了通用的数据协议,但是针对联调平台,还需要额外增加网络层的若干字段,以下列出该方案不同类型的协议字段:

Engine Call (引擎侧调用方法协议)

{

    "msg_id":"",//消息的唯一编号

    "callback_id":"",//消息回调的唯一id

    "return_sync_id":"",//同步返回的唯一id

    "method_name":"",//方法名

    "source":0,//用于标记消息的起始源头,1-engine,2-native

    "param":{},//json

}

Engine Return (引擎侧调用同步方法返回值协议)

{

    "msg_id":"",//消息的唯一编号

    "return_sync_id":"",//同步返回的唯一id

    "code":0,//返回码,0为正常

    "failMsg":"",//错误原因

    "data":{},

}

Engine Callback (引擎侧调用同步方法回调协议)

{

    "msg_id":"",//消息的唯一编号

    "callback_id":"",//消息回调的唯一id

    "code":0,//返回码,0为正常

    "failMsg":"",//错误原因

    "data":{},

}

Engine Event (引擎侧接收Event事件协议)

{

    "msg_id":"",//消息的唯一编号

    "method_name":""//方法名

    "code":0,//返回码,0为正常

    "failMsg":"",//错误原因

    "data":{},



}

接口调用

引擎侧的接口协议通过Net Tool和Native Proxy层处理后会分发到SDK Bridge层,Bridge层基于SDK之上,是SDK对外的接入层,Bridge层针对不同的引擎设计了不同的Channel,相互隔离,使得SDK可以同时提供给多个引擎使用。SDK各模块会把各模块的对外接口注册到Bridge层,并且支持按需注册,Native Proxy会根据需要选择对应的Channel实例,根据协议中method_name和param来获取已注册的方法实例,通过反射机制完成方法的调用。

根据Net Tool应用层的头部协议,如果是同步方法,会把方法执行后的结果进行返回协议封装,通过Net Tool发送回引擎侧,引擎侧取到实际的值后释放等待的信号量,实现方法的返回,完成一次方法调用。针对Event和Callback类型,也添加了对应的消息类型,在双端根据msg_id维护对应的映射关系,实现事件的响应和回调处理。

Common方案原理

Common方案是指在引擎侧没有接入Bridge插件的游戏,本方案基于一个大前提:Unity/Unreal引擎侧调用Native的方法都是通过C方法进行调用,因此要实现通用方案就转变成了如何实现动态对任意一个包含任意参数的C函数进行调用。

动态调用任意C函数(找到符号地址)

C函数导出的符号约定为前缀增加下划线,如

void testMethod();

方法的符号为:

_testMethod

该符号所对应的地址实际上会被包含在macho文件中,我们只需要找到对应的符号名称即可找到对应的符号地址。

动态链接器这里已经为我们提供了这样的能力:

extern void * dlsym(void * __handle, const char * __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库来帮助我们实现这个复杂的能力,大致分为这几步:

  1. 通过 dlsym 拿到函数指针。
  2. 给每个参数申请内存空间,按 ffi 要求把参数数据组装成数组。(用alloca()申请空间,不需要free()去释放)
  3. 用函数参数个数/参数类型/返回值类型组装成 cif 对象,表示这个函数原型。(有点像OC的methodSignature)
  4. 申请内存空间用于保存函数返回值。
  5. 把 cif 函数原型,函数指针,返回值内存指针,参数数据 传入 ffi_call调用这个函数。

这种方案可以支持绝大部分的参数类型调用,但是针对输入参数是函数指针的情况,就无法做到了。

针对输入参数中包含函数指针类型的情况

在个别游戏项目里,所有的回调方法都是通过C#中构造Action然后在调用C方法时,传入该Action的函数指针。由于联调平台所有数据都是通过网络层建立桥接完成调用的,因此在实际调用中,Native端根本不存在这个函数指针,也就是说,必须要动态构建出一个函数指针。

由于在打包macho时,所有的函数地址绑定的符号必须要打包在可执行文件中,因此想要能够动态生成一个函数是无法做到的。

那么还有其他方法可以动态完成函数的创建、调用时进行压栈操作、出栈操作吗?

其实我们可以事先定义好一个通用的函数实现,并通过汇编代码,在执行时,去手动的收集输入的参数以及返回值参数,于是我们也可以利用LibFFI特性来实现。

总结起来的流程就是:

  1. 准备一个函数实体 CInterpreter。
  2. 根据函数参数个数/参数类型/返回值类型组装成 cif 对象,表示这个函数原型。
  3. 准备一个函数指针 _cPtr ,用于调用。
  4. 通过 ffi_closure 把 函数原型_cifPtr / 函数实体CInterpreter / 上下文对象self / 函数指针_cPtr 关联起来。

通过这个方法还可以通过userData传入的方式,去标识和辨别每一个创建的C函数。

Net Tool原理

Net Tool承载了联调平台所有网络(包含局域网与广域网)的收发能力,在联调方案中,无论针对同步和异步的场景都需要被动通知的能力,因此我们需要采用长连接方案,Net Tool是整个联调平台的核心层,直接关乎接口调用的成功与否。

局域网场景

对于局域网场景,是不需要外界网络参与的,所以在手机APP侧需要具备Server端的能力,为PC游戏侧提供接口服务,采用本地Socket长连,由PC游戏侧完成Client端能力接入,局域网环境非常的稳定和高效,是Net Tool首选的连接方式,在该场景中,PC和手机需要保持在相同局域网环境,手机APP会建立长连接的Server,监听对应的端口,PC侧作为Client根据Server端的IP地址和端口号建立长连接,根据既定协议进行数据传送。

广域网场景

对于广域网场景,PC游戏侧与手机App侧都是Client,由公网服务器实现长连接Server,建立公网长连接后,对PC与App侧的数据进行透传,来达到数据收发的目的,服务端需要维护两端的映射关系,不做任何业务相关的逻辑,数据协议和局域网场景保持一致。

数据处理流程

以局域网场景为例,上图为Net Tool整体的数据处理流程,主要包括如下步骤:

  • PC侧接口请求协议数据分发到Net Tool层后,首先会进行数据包组装,增加应用层头部协议,追加CallID(接口唯一标识)、CallType(方案类型)、HasRetValue(是否有返回值)字段
  • 接下来会增加网络层头部协议,因为底层是通过二进制长连接通道进行消息收发,是一个流式发送的通道,在上层完整的数据包可能会被分割和组合成不同的数据包进行发送,但是在接收方需要获取每个完整的数据包,因此在数据头部加入[A-B][json-data]格式的协议,用来在网络传输过程中标识数据包长度和类型,解决网络传输过程中粘包和拆包问题
  • 消息发送采用的是生产者和消费者模型,消息会以线程安全的形式加入缓存队列,并采用Loop机制不断的去处理队列中的数据包,并调用底层接口完成消息的发送
  • 游戏引擎侧接口数据通过二进制长连接通道发送到手机APP侧,在Native Net tool层会持续监听9898端口,把收到的数据持续的添加到缓存队列中,解析出网络层的头部协议,获取完整数据包的长度和类型,如果当前接收的数据包长度小于总长度则会缓存该数据包继续接收,直到加上下次数据包的长度满足完整数据包的长度要求后,会截取出完整数据包的长度,经过处理后分发到下层
  • 同时会采用长度判断、正则校验、消息重试等机制来增加消息收发的容错能力,防止出现因为网络问题导致的丢包,进而影响到SDK接口的访问

总结

上面阐述了联调平台整体的设计思路和方案原理细节,分析了从引擎侧到SDK侧整个链路的数据流向和处理过程,游戏开发者借助该平台可以在PC侧高效完成移动SDK能力的接入,提高了数倍的接入效率和接入体验,让游戏工作室更专注游戏玩法的实现,目前该平台已在字节内部和外部二十几款游戏落地,全部是正向反馈,我们也会不断去优化和迭代联调平台的易用性和稳定性,不断扩展平台功能,该平台为效率而生,希望日后可以解决游戏开发过程中的所有效率问题,并为游戏开发者带来全新的开发体验。