查看原文
其他

Unity3D游戏丨《腾讯桌球》客户端开发经验总结(上)

2016-06-27 tylerzhu Gad-腾讯游戏开发者平台
本次分享总结,起源于腾讯桌球项目,但是不仅仅限于项目本身。虽然基于Unity3D,很多东西同样适用于Cocos。

架构设计
好的架构利用大规模项目的多人团队开发和代码管理,也利用查找错误和后期维护。
框架的选择:需要根据团队、项目来进行选择,没有最好的框架,只有最合适的框架。
框架的使用:统一的框架能规范大家的行为,互相之间可以比较平滑切换,可维护性大大提升。除此之外,还能代码解耦。
例如StrangeIOC是一个超轻量级和高度可扩展的控制反转(IoC)框架,专门为C#和Unity编写。
已知公司内部使用StrangeIOC框架的游戏有:腾讯桌球、欢乐麻将、植物大战僵尸Online。
依赖注入(Dependency Injection,简称DI),是一个重要的面向对象编程的法则来削减计算机程序的耦合问题。依赖注入还有一个名字叫做控制反转(Inversionof Control,英文缩写为IoC)。
依赖注入是这样一个过程:由于某客户类只依赖于服务类的一个接口,而不依赖于具体服务类,所以客户类只定义一个注入点。在程序运行过程中,客户类不直接实例化具体服务类实例,而是客户类的运行上下文环境或专门组件负责实例化服务类,然后将其注入到客户类中,保证客户类的正常运行。
即对象在被创建的时候,由一个运行上下文环境或专门组件将其所依赖的服务类对象的引用传递给它。也可以说,依赖被注入到对象中。
所以,控制反转是,关于一个对象如何获取他所依赖的对象的引用,这个责任的反转。
StrangeIOC采用MVCS(数据模型 Model,展示视图 View,逻辑控制 Controller,服务Service)结构,通过消息/信号进行交互和通信。
整个MVCS框架跟flash的robotlegs基本一致(忽略语言不一样)。

数据模型 Model:主要负责数据的存储和基本数据处理,
展示视图 View:主要负责UI界面展示和动画表现的处理,
逻辑控制 Controller:主要负责业务逻辑处理,
服务Service:主要负责独立的网络收发请求等的一些功能。
消息/信号:通过消息去解耦Model View Controller这三种模块,他们之间通过消息/信号进行交互。
绑定器Binder:负责绑定消息处理,接口与实例对象,View与Mediator的对应关系。
MVCS Context:可以理解为MVC各个模块存在的上下文, 负责MVC绑定和实例的创建工作。
腾讯桌球项目框架
 代码目录的组织:一般客户端用得比较多的MVC框架,怎么划分目录?
先按业务功能划分,再按照 MVC 来划分。“蛋糕心语”就是使用的这种方式。
先按 MVC 划分,再按照业务功能划分。“D9”、“宝宝斗场”、“魔法花园”、“腾讯桌球”、“欢乐麻将”使用的这种方式。
根据使用习惯,可以自行选择。个人推荐“先按业务功能划分,再按照 MVC 来划分”,使得模块更聚焦(高内聚),第二种方式用多了发现随着项目的运营模块增多,没有第一种那么好维护。

Unity项目目录的组织:结合Unity规定的一些特殊的用途的文件夹,我们建议Unity项目文件夹组织方式如下。
其中,Plugins支持Plugins\{Platform}这样的命名规范:
Plugins/x86Plugins/x86_64Plugins/AndroidPlugins/iOS
如果存在Plugins\{Platform},则加载Plugins\{Platform}目录下的文件,否则加载Plugins目录下的,也就是说,如果存在{Platform}目录,Plugins根目录下的DLL是不会加载的。
另外,资源组织采用分文件夹存储“成品资源”及“原料资源”的方式处理:防止无关资源参与打包,RawResource即原始资源,Resource即成品资源。当然并不限于RawResource这种形式,其他Unity规定的特殊文件夹都可以这样,例如Raw Standard Assets。 公司组件msdk(sns、支付midas、推送灯塔、监控Bugly)apolloapollo voicexlua
目前我们的腾讯桌球、四国军棋都接入了apollo,但是如果服务器不采用apollo框架,不建议客户端接apollo,而是直接接msdk减少二次封装信息的丢失和带来的错误,方便以后升级维护,并且减少导入无用的代码。 第三方插件选型NGUIDoTweenGIFGAFVectrosityScriptsPoolManagerMad Level Manger 
原生插件/平台交互
虽然大多时候使用Unity3D进行游戏开发时,只需要使用C#进行逻辑编写。但有时候不可避免的需要使用和编写原生插件。
例如一些第三方插件只提供C/C++原生插件、复用已有的C/C++模块等。有一些功能是Unity3D实现不了,必须要调用Android/iOS原生接口,比如获取手机的硬件信息(UnityEngine.SystemInfo没有提供的部分)、调用系统的原生弹窗、手机震动等等
c/c++插件
编写和使用原生插件的几个关键点:创建C/C++原生插件导出接口必须是C ABI-compatible函数函数调用约定在C#中标识C/C++的函数并调用标识 DLL 中的函数。至少指定函数的名称和包含该函数的 DLL 的名称。
创建用于容纳 DLL 函数的类。可以使用现有类,为每一非托管函数创建单独的类,或者创建包含一组相关的非托管函数的一个类。
在托管代码中创建原型。使用 DllImportAttribute 标识 DLL 和函数。 用 static 和 extern 修饰符标记方法。
调用 DLL 函数。像处理其他任何托管方法一样调用托管类上的方法。
在C#中创建回调函数,C/C++调用C#回调函数创建托管回调函数。
创建一个委托,并将其作为参数传递给 C/C++函数。平台调用会自动将委托转换为常见的回调格式。确保在回调函数完成其工作之前,垃圾回收器不会回收委托。 那么C#与原生插件之间是如何实现互相调用的呢?在弄清楚这个问题之前,我们先看下C#代码(.NET上的程序)的执行的过程:将源码编译为托管模块;将托管模块组合为程序集;加载公共语言运行时CLR;执行程序集代码。
注:CLR(公共语言运行时,Common Language Runtime)和Java虚拟机一样也是一个运行时环境,它负责资源管理(内存分配和垃圾收集),并保证应用和底层操作系统之间必要的分离。
为了提高平台的可靠性,以及为了达到面向事务的电子商务应用所要求的稳定性级别,CLR还要负责其他一些任务,比如监视程序的运行。
按照.NET的说法,在CLR监视之下运行的程序属于“托管”(managed)代码,而不在CLR之下、直接在裸机上运行的应用或者组件属于“非托管”(unmanaged)的代码。
这几个过程我总结为下图:
图 .NET上的程序运行

回调函数是托管代码C#中的定义的函数,对回调函数的调用,实现从非托管C/C++代码中调用托管C#代码。
那么C/C++是如何调用C#的呢?大致分为2步,可以用下图表示:
将回调函数指针注册到非托管C/C++代码中(C#中回调函数指委托delegate)

调用注册过的托管C#函数指针
相比较托管调用非托管,回调函数方式稍微复杂一些。回调函数非常适合重复执行的任务、异步调用等情况下使用。
由上面的介绍可以知道CLR提供了C#程序运行的环境,与非托管代码的C/C++交互调用也由它来完成。CLR提供两种用于与非托管C/C++代码进行交互的机制:
平台调用(Platform Invoke,简称PInvoke或者P/Invoke),它使托管代码能够调用从非托管DLL中导出的函数。
COM 互操作,它使托管代码能够通过接口与组件对象模型 (COM) 对象交互。考虑跨平台性,Unity3D不使用这种方式。
 平台调用依赖于元数据在运行时查找导出的函数并封送(Marshal)其参数。 下图显示了这一过程。

注意:
1.除涉及回调函数时以外,平台调用方法调用从托管代码流向非托管代码,而绝不会以相反方向流动。 虽然平台调用的调用只能从托管代码流向非托管代码,但是数据仍然可以作为输入参数或输出参数在两个方向流动。

2.图中DLL表示动态库,Windows平台指.dll文件、Linux/Android指.so文件、Mac OS X指.dylib/framework文件、iOS中只能使用.a。后文都使用DLL代指,并且DLL使用C/C++编写。

当“平台调用”调用非托管函数时,它将依次执行以下操作:查找包含该函数的DLL。将该DLL加载到内存中。查找函数在内存中的地址并将其参数推到堆栈上,以封送所需的数据(参数)。
注意
只在第一次调用函数时,才会查找和加载 DLL 并查找函数在内存中的地址。iOS中使用的是.a已经静态打包到最终执行文件中。
将控制权转移给非托管函数。
Android插件
Java同样提供了这样一个扩展机制JNI(Java Native Interface),能够与C/C++互相通信。
注:
1、JNIwiki-https://en.wikipedia.org/wiki/Java_Native_Interface,这里不深入介绍JNI,有兴趣的可以自行去研究。如果你还不知道JNI也不用怕,就像Unity3D使用C/C++库一样,用起来还是比较简单的,只需要知道这个东西即可。
并且Unity3D对C/C++桥接器这块做了封装,提供AndroidJNI/AndroidJNIHelper/AndroidJavaObject/AndroidJavaClass/AndroidJavaProxy方便使用等,具体使用后面在介绍。JNI提供了若干的API实现了Java和其他语言的通信(主要是C&C++)。
从Java1.1开始,JNI标准成为java平台的一部分,它允许Java代码和其他语言写的代码进行交互,保证本地代码能工作在任何Java 虚拟机环境下。”
2、作为知识扩展,提一下Android Java虚拟机。Android的Java虚拟机有2个,最开始是Dalvik,后面Google在Android 4.4系统新增一种应用运行模式ART。
ART与Dalvik 之间的主要区别是其具有提前 (AOT) 编译模式。 根据 AOT 概念,设备安装应用时,DEX 字节代码转换仅进行一次。 
相比于 Dalvik,这样可实现真正的优势,因为 Dalvik 的即时 (JIT) 编译方法需要在每次运行应用时都进行代码转换。下文中用Java虚拟机代指Dalvik/ART。
C#/Java都可以和C/C++通信,那么通过编写一个C/C++模块作为桥接,就使得C#与Java通信成为了可能,如下图所示:

注:C/C++桥接器本身跟Unity3D没有直接关系,不属于Android和Unity3D,图中放在Unity3D中是为了代指libunity.so中实现的桥接器以表示真实的情况。
通过JNI既可以用于Java代码调用C/C++代码,也可用于C/C++代码与Java(Dalvik/ART虚拟机)的交互。JNI定义了2个关键概念/结构:JavaVM、JNIENV。
JavaVM提供虚拟机创建、销毁等操作,Java中一个进程可以创建多个虚拟机,但是Android一个进程只能有一个虚拟机。
JNIENV是线程相关的,对应的是JavaVM中的当前线程的JNI环境,只有附加(attach)到JavaVM的线程才有JNIENV指针,通过JNIEVN指针可以获取JNI功能,否则不能够调用JNI函数。

C/C++要访问的Java代码,必须要能访问到Java虚拟机,获取虚拟机有2中方法:
在加载动态链接库的时候,JVM会调用JNI_OnLoad(JavaVM* jvm, void* reserved),第一个参数会传入JavaVM指针。
在C/C++中调用JNI_CreateJavaVM(&jvm, (void**)&env, &vm_args)创建JavaVM指针
所以,我们只需要在编写C/C++桥接器so的时候定义JNI_OnLoad(JavaVM*jvm, void* reserved)方法即可,然后把JavaVM指针保存起来作为上下文使用。
获取到JavaVM之后,还不能直接拿到JNI函数去获取Java代码,必须通过线程关联的JNIENV指针去获取。
所以,作为一个好的开发习惯在每次获取一个线程的JNI相关功能时,先调用AttachCurrentThread();又或者每次通过JavaVM指针获取当前的JNIENV:java_vm->GetEnv((void**)&jni_env, version),一定是已经附加到JavaVM的线程。
通过JNIENV可以获取到Java的代码,例如你想在本地代码中访问一个对象的字段(field),你可以像下面这样做:
对于类,使用jni_env->FindClass获得类对象的引用对于字段,使用jni_env->GetFieldId获得字段ID使用对应的方法(例如jni_env->GetIntField)获取字段的值
类似地,要调用一个方法,你step1.得获得一个类对象的引用obj,step2.是方法methodID。这些ID通常是指向运行时内部数据结构。查找到它们需要些字符串比较,但一旦你实际去执行它们获得字段或者做方法调用是非常快的。step3.调用jni_env->CallVoidMethodV(obj, methodID, args)。
从上面的示例代码,我们可以看出使用原始的JNI方式去与Android(Java)插件交互是多的繁琐,要自己做太多的事情,并且为了性能需要自己考虑缓存查询到的方法ID,字段ID等等。幸运的是,Unity3D已经为我们封装好了这些,并且考虑了性能优化。Unity3D主要提供了一下2个级别的封装来帮助高效编写代码:
注:Unity3D中对应的C/C++桥接器包含在libunity.so中。

Level 1:AndroidJNI、AndroidJNIHelper,原始的封装相当于我们上面自己编写的C# Wrapper。AndroidJNIHelper 和AndroidJNI自动完成了很多任务(指找到类定义,构造方法等),并且使用缓存使调用java速度更快。
AndroidJavaObject和AndroidJavaClass基于AndroidJNIHelper 和AndroidJNI创建,但在处理自动完成部分也有很多自己的逻辑,这些类也有静态的版本,用来访问java类的静态成员。
更详细接口参考帮助文档:http://docs.unity3d.com/ScriptReference/AndroidJNI.html,http://docs.unity3d.com/ScriptReference/AndroidJNIHelper.html
Level 2:AndroidJavaObject、AndroidJavaClass、AndroidJavaProxy,这个3个类是基于Level1的封装提供了更高层级的封装使用起来更简单,这个在第三部分详细介绍。 iOS插件
iOS编写插件比Android要简单很多,因为Objective-C也是 C-compatible的,完全兼容标准C语言。这些就可以非常简单的包一层 extern "c"{},用C语言封装调用iOS功能,暴露给Unity3D调用。
并且可以跟原生C/C++库一样编成.a插件。C#与iOS(Objective-C)通信的原理跟C/C++完全一样:

除此之外,UnityiOS支持插件自动集成方式。所有位于Asset/Plugings/iOS文件夹中后缀名为.m , .mm , .c , .cpp的文件都将自动并入到已生成的Xcode项目中。
然而,最终编进执行文件中。后缀为.h的文件不能被包含在Xcode的项目树中,但他们将出现在目标文件系统中,从而使.m/.mm/.c/.cpp文件编译。
这样编写iOS插件,除了需要对iOS Objective-C有一定了解之外,与C/C++插件没有差异,反而更简单。 
版本与补丁
任何游戏(端游、手游)都应该提供游戏内更新的途径。一般游戏分为全量更新/整包更新、增量更新、资源更新。
全量游戏内完整安装包下载(ios跳转到AppStore下载) 增量:主要指android省流量更新可以使用bsdiff生成patch包应用宝也提供增量更新sdk可供接入
资源手游在实现这块时需要注意的几点:
游戏发布出一定要提供游戏内更新的途径。即使是删掉测试,保不准这期间需要进行资源或者BUG修复更新。
很多玩家并不知道如何更新,而且Android手机应用分发平台多样,分发平台本身也不会跟官方同步更新(特别是小的分发平台)。 更新功能要提供强制更新、非强制更新配置化选项,并指定哪些版本可以不强更,哪些版本必须强更。 当游戏提供非强制更新功能之后,现网一定会存在多个版本。如果需要针对不同版本做不同的更新,例如配置文件A针对1.0.0.1修改了一项,针对1.0.0.2修改了另一项,2个版本需要分别更新对应的修改,需要自己实现更新策略IIPS不提供这个功能。
当需要复杂的更新策略,推荐自己编写更新服务器和客户端逻辑,不使用iips组件(其实自己实现也很简单)。

没有运营经验的人会选择二进制,认为二进制安全、更小,这对端游/手游外网只存在一个版本的游戏适合,对一般不强升版本的手游并不适合,反而会对更新和维护带来很大的麻烦。
配置使用XML或者JSON等文本格式,更利于多版本的兼容和更新。最开始腾讯桌球客户端使用的二进制格式(由excel转换而来),但是随着运营配置格式需要增加字段,这样老版本程序就解析不了新的二进制数据,给兼容和更新带来了很大的麻烦。
这样就要求上面提到的针对不同步做不同更新,又或者配置一开始就预留好足够的扩展项,其实不管怎么预留扩展也很难跟上需求的变化,而且一开始会把配置表复杂化但是其实只有一张或者几张才会变更结构。 iOS版本的送审版本需要连接特定的包含新内容的服务器,现网服务器还不包含新内容。送审通过之后,上架游戏现网服务器会进行更新,iOS版本需要连接现网服务器而非送审服务器,但是这期间又不能修改客户度,这个切换需要通过服务器下发开关进行控制。
例如通过指定送审的iOS游戏版本号,客户端判断本地版本号是否为送审版本,如果是连接送审服务器,否则连接现网服务器。
近期热文:
Unity3D教程:Shader简介游戏开发者必读!这些游戏网络知识你需要知道
腾讯游戏开发者平台长按,识别二维码,加关注
经验分享丨项目实践项目孵化丨渠道发行做有梦想的游戏人-GAME AND DREAM-

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存