《腾讯桌球:客户端总结》css
本次分享总结,起源于腾讯桌球项目,可是不只仅限于项目自己。虽然基于Unity3D,不少东西一样适用于Cocos。本文从如下10大点进行阐述: html
1.架构设计java
好的架构利用大规模项目的多人团队开发和代码管理,也利用查找错误和后期维护。android
依赖注入(Dependency Injection,简称DI),是一个重要的面向对象编程的法则来削减计算机程序的耦合问题。依赖注入还有一个名字叫作控制反转(Inversion of Control,英文缩写为IoC)。依赖注入是这样一个过程:因为某客户类只依赖于服务类的一个接口,而不依赖于具体服务类,因此客户类只定义一个注入点。在程序运行过程当中,客户类不直接实例化具体服务类实例,而是客户类的运行上下文环境或专门组件负责实例化服务类,而后将其注入到客户类中,保证客户类的正常运行。即对象在被建立的时候,由一个运行上下文环境或专门组件将其所依赖的服务类对象的引用传递给它。也能够说,依赖被注入到对象中。因此,控制反转是,关于一个对象如何获取他所依赖的对象的引用,这个责任的反转。ios
StrangeIOC采用MVCS(数据模型 Model,展现视图 View,逻辑控制 Controller,服务Service)结构,经过消息/信号进行交互和通讯。整个MVCS框架跟flash的robotlegs基本一致,(忽略语言不同)详细的参考<http://www.cnblogs.com/skynet/archive/2012/03/21/2410042.html>。 git
腾讯桌球客户端项目框架 github
根据使用习惯,能够自行选择。我的推荐"先按业务功能划分,再按照 MVC 来划分",使得模块更聚焦(高内聚),第二种方式用多了发现随着项目的运营模块增多,没有第一种那么好维护。 算法
其中,Plugins支持Plugins/{Platform}这样的命名规范: 编程
若是存在Plugins/{Platform},则加载Plugins/{Platform}目录下的文件,不然加载Plugins目录下的,也就是说,若是存在{Platform}目录,Plugins根目录下的DLL是不会加载的。 windows
另外,资源组织采用分文件夹存储"成品资源"及"原料资源"的方式处理:防止无关资源参与打包,RawResource即原始资源,Resource即成品资源。固然并不限于RawResource这种形式,其余Unity规定的特殊文件夹均可以这样,例如Raw Standard Assets。
目前咱们的腾讯桌球、四国军棋都接入了apollo,可是若是服务器不采用apollo框架,不建议客户端接apollo,而是直接接msdk减小二次封装信息的丢失和带来的错误,方便之后升级维护,而且减小导入无用的代码。
2.原生插件/平台交互
虽然大多时候使用Unity3D进行游戏开发时,只须要使用C#进行逻辑编写。但有时候不可避免的须要使用和编写原生插件,例如一些第三方插件只提供C/C++原生插件、复用已有的C/C++模块等。有一些功能是Unity3D实现不了,必需要调用Android/iOS原生接口,好比获取手机的硬件信息(UnityEngine.SystemInfo没有提供的部分)、调用系统的原生弹窗、手机震动等等
2.1C/C++插件
编写和使用原生插件的几个关键点:
那么C#与原生插件之间是如何实现互相调用的呢?在弄清楚这个问题以前,咱们先看下C#代码(.NET上的程序)的执行的过程:(更详细一点的介绍能够参见我以前写的博客:http://www.cnblogs.com/skynet/archive/2010/05/17/1737028.html)
注:CLR(公共语言运行时,Common Language Runtime)和Java虚拟机同样也是一个运行时环境,它负责资源管理(内存分配和垃圾收集),并保证应用和底层操做系统之间必要的分离。
为了提升平台的可靠性,以及为了达到面向事务的电子商务应用所要求的稳定性级别,CLR还要负责其余一些任务,好比监视程序的运行。按照.NET的说法,在CLR监视之下运行的程序属于"托管"(managed)代码,而不在CLR之下、直接在裸机上运行的应用或者组件属于"非托管"(unmanaged)的代码。
这几个过程我总结为下图:
图 .NET上的程序运行
回调函数是托管代码C#中的定义的函数,对回调函数的调用,实现从非托管C/C++代码中调用托管C#代码。那么C/C++是如何调用C#的呢?大体分为2步,能够用下图表示:
相比较托管调用非托管,回调函数方式稍微复杂一些。回调函数很是适合重复执行的任务、异步调用等状况下使用。
由上面的介绍能够知道CLR提供了C#程序运行的环境,与非托管代码的C/C++交互调用也由它来完成。CLR提供两种用于与非托管C/C++代码进行交互的机制:
平台调用依赖于元数据在运行时查找导出的函数并封送(Marshal)其参数。 下图显示了这一过程。
注意:1.除涉及回调函数时之外,平台调用方法调用从托管代码流向非托管代码,而毫不会以相反方向流动。 虽然平台调用的调用只能从托管代码流向非托管代码,可是数据仍然能够做为输入参数或输出参数在两个方向流动。2.图中DLL表示动态库,Windows平台指.dll文件、Linux/Android指.so文件、Mac OS X指.dylib/framework文件、iOS中只能使用.a。后文都使用DLL代指,而且DLL使用C/C++编写。
当"平台调用"调用非托管函数时,它将依次执行如下操做:
|
只在第一次调用函数时,才会查找和加载 DLL 并查找函数在内存中的地址。iOS中使用的是.a已经静态打包到最终执行文件中。 |
2.2Android插件
Java一样提供了这样一个扩展机制JNI(Java Native Interface),可以与C/C++互相通讯。
注:
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中方法:
因此,咱们只须要在编写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),你能够像下面这样作:
相似地,要调用一个方法,你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中。
2.3iOS插件
iOS编写插件比Android要简单不少,由于Objective-C也是 C-compatible的,彻底兼容标准C语言。这些就能够很是简单的包一层 extern "c"{},用C语言封装调用iOS功能,暴露给Unity3D调用。而且能够跟原生C/C++库同样编成.a插件。C#与iOS(Objective-C)通讯的原理跟C/C++彻底同样:
除此以外,Unity iOS支持插件自动集成方式。全部位于Asset/Plugings/iOS文件夹中后缀名为.m , .mm , .c , .cpp的文件都将自动并入到已生成的Xcode项目中。然而,最终编进执行文件中。后缀为.h的文件不能被包含在Xcode的项目树中,但他们将出如今目标文件系统中,从而使.m/.mm/.c/.cpp文件编译。这样编写iOS插件,除了须要对iOS Objective-C有必定了解以外,与C/C++插件没有差别,反而更简单。
3.版本与补丁
任何游戏(端游、手游)都应该提供游戏内更新的途径。通常游戏分为全量更新/整包更新、增量更新、资源更新。
android游戏内完整安装包下载(ios跳转到AppStore下载)
Unity3D经过使用AssetBundle便可实现动态更新资源的功能。
手游在实现这块时须要注意的几点:
没有运营经验的人会选择二进制,认为二进制安全、更小,这对端游/手游外网只存在一个版本的游戏适合,对通常不强升版本的手游并不适合,反而会对更新和维护带来很大的麻烦。
4.用脚本,仍是不用?这是一个问题
方便更新,减小Crash(特别是使用C++的cocos引擎)
经过上面一节【版本与补丁】知道要实现代码更新是很是困难的,正式这个缘由客户端开发的压力是比较大的,若是出现了比较严重的BUG必须发强制更新版本,使用脚本能够解决这个问题。
因为Unity3D手游更新成本比较大,并且目前腾讯桌球要求不能强制更新,这致使新版本的活动覆盖率提高比较慢、出现问题以后难以修复。针对这个状况,考虑引入lua进行活动开发,后续发布活动及修复bug只须要发布lua资源,进行资源更新便可,大大下降了发布和修复问题的成本。
可选方案还有使用Html5进行活动开发,目前游戏中已经预埋了Html5活动入口,而且已经用来发过"玩家调查"、"腾讯棋牌宣传"等。可是与lua对比,不能作到与Unity3D的深度融合,体验不如使用lua,例如不能操做游戏中的ui、不能完成复杂界面的制做、不能复用已有的功能、玩家付费充值跟已有的也会有差别
游戏脚本之王——Lua
在公司内部魔方比较喜欢用lua,火隐忍者(手游)unity+ulua,全民水浒cocos2d-x+lua等等都有使用lua进行开发。咱们可使用公司内部的xlua组件,也可使用ulua<http://ulua.org/>、UniLua<https://github.com/xebecnan/UniLua>等等。
5.资源管理
5.1资源管理器
业务不要直接使用引擎或者系统原生接口,而是封装一个资源管理器负责:资源加载、卸载
兼容Resource.Load与AssetBundle资源互相变动需求,开发期间使用Resource.Load方式而没必要打AB包效率更高
加载资源时,无论是同步加载仍是异步加载,最好是使用异步编码方式(回调函数或者消息通知机制)。若是哪一天资源由本地加载改成从服务器按需加载,而游戏中的逻辑都是同步方式编码的,改起来将很是痛苦。其实异步编码方式很简单,不比同步方式复杂。
5.2资源类型
5.3图片-文件格式与纹理格式
经常使用的图像文件格式有BMP,TGA,JPG,GIF,PNG等;
经常使用的纹理格式有R5G6B5,A4R4G4B4,A1R5G5B5,R8G8B8, A8R8G8B8等。
文件格式是图像为了存储信息而使用的对信息的特殊编码方式,它存储在磁盘中,或者内存中,可是并不能被GPU所识别,由于以向量计算见长的GPU对于这些复杂的计算无能为力。这些文件格式当被游戏读入后,仍是须要通过CPU解压成R5G6B5,A4R4G4B4,A1R5G5B5,R8G8B8, A8R8G8B8等像素格式,再传送到GPU端进行使用。
纹理格式是能被GPU所识别的像素格式,能被快速寻址并采样。举个例子,DDS文件是游戏开发中经常使用的文件格式,它内部能够包含A4R4G4B4的纹理格式,也能够包含A8R8G8B8的纹理格式,甚至能够包含DXT1的纹理格式。在这里DDS文件有点容器的意味。OpenGL ES 2.0支持以上提到的R5G6B5,A4R4G4B4,A1R5G5B5,R8G8B8,A8R8G8B8等纹理格式,其中 R5G6B5,A4R4G4B4,A1R5G5B5每一个像素占用2个字节(BYTE),R8G8B8每一个像素占用3个字节,A8R8G8B8每一个像素占用 4个字节。
基于OpenGL ES的压缩纹理有常见的以下几种实现:
1)ETC1(Ericsson texture compression),ETC1格式是OpenGL ES图形标准的一部分,而且被全部的Android设备所支持。
2)PVRTC (PowerVR texture compression),支持的GPU为Imagination Technologies的PowerVR SGX系列。
3)ATITC (ATI texture compression),支持的GPU为Qualcomm的Adreno系列。
4)S3TC (S3 texture compression),也被称为DXTC,在PC上普遍被使用,可是在移动设备上仍是属于新鲜事物。支持的GPU为NVIDIA Tegra系列。
5.4资源工具
有了规范就能够作工具检查,从源头到打包
6.性能优化
掉帧主要针对GPU和CPU作分析;内存占用大主要针对美术资源,音效,配置表,缓存等分析;卡顿也须要对GPU和CPU峰值分析,另外IO或者GC也易致使。
6.1工欲善其事,必先利其器
6.2CPU:最佳原则减小计算
6.3GPU:最佳原则减小渲染
6.4内存:最佳原则减小内存分配/碎片、及时释放
6.5IO:最佳原则减小/异步io
6.6网络:其实也是IO的一种
使用单线程——共用UI线程,经过事件/UI循环驱动;仍是多线程——单独的网络线程?
6.6包大小
6.7耗电
下面影响耗电的几个因素和影响度摘自公司内部的一篇文章。
7.异常与Crash
7.1防护式编程
防不胜防,无论如何防护总有失手的时候,这就须要异常捕获和上报。
7.2异常捕获
异常捕获已经有不少第三组件可供接入,这里不介绍组件的而接入,而是简单谈一下异常捕获的原理。
因为不少错误并非发生在开发工做者调试阶段,而是在用户或测试工做者使用阶段;这就须要相关代码维护工做者对于程序异常捕获收集现场信息。异常与Crash的监控和上报,这里不介绍Bugly的使用,按照apollo或者msdk的文档接入便可,没有太多能够说的。这里主要透过Bugly介绍手游的几类异常的捕获和分析:
public void HandleLog(string logString, string stackTrace, LogType type) { if (logString == null || logString.StartsWith(cLogPrefix)) { return; }
ELogLevel level = ELogLevel.Verbose; switch (type) {
case LogType.Exception: level = ELogLevel.Error; break; default: return; }
if (stackTrace != null) { Print(level, ELogTag.UnityLog, logString + "\n" + stackTrace); } else { Print(level, ELogTag.UnityLog, logString); } } |
try…catch显式的捕获异常通常是不引发游戏Crash的,它又称为编译时异常,即在编译阶段被处理的异常。编译器会强制程序处理全部的Checked异常,由于Java认为这类异常都是能够被处理(修复)的。若是没有try…catch这个异常,则编译出错,错误提示相似于"Unhandled exception type xxxxx"。
UnChecked异常又称为运行时异常,因为没有相应的try…catch处理该异常对象,因此Java运行环境将会终止,程序将退出,也就是咱们所说的Crash。那为何不会加在try…catch呢?
Uncaught异常会致使应用程序崩溃。那么当崩溃了,咱们是否能够作些什么呢,就像Application.RegisterLogCallback注册回调打印日志、上报服务器、弹窗提示用户?Java提供了一个接口给咱们,能够完成这些,这就是UncaughtExceptionHandler,该接口含有一个纯虚函数:
public abstract void uncaughtException (Thread thread, Throwableex) |
Uncaught异常发生时会终止线程,此时,系统便会通知UncaughtExceptionHandler,告诉它被终止的线程以及对应的异常,而后便会调用uncaughtException函数。若是该handler没有被显式设置,则会调用对应线程组的默认handler。若是咱们要捕获该异常,必须实现咱们本身的handler,并经过如下函数进行设置:
public static void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler) |
特别注意:屡次调用setDefaultUncaughtExceptionHandler设置handler,后面注册的会覆盖前面注册的,以最后一次为准。实现自定义的handler,只须要继承UncaughtExceptionHandler该接口,并实现uncaughtException方法便可。
static class MyCrashHandler implements UncaughtExceptionHandler{ @Override public void uncaughtException(Thread thread, final Throwable throwable) { // Deal this exception } } |
在任何线程中,均可以经过setDefaultUncaughtExceptionHandler来设置handler,但在Android应用程序中,全局的Application和Activity、Service都同属于UI主线程,线程名称默认为"main"。因此,在Application中应该为UI主线程添加UncaughtExceptionHandler,这样整个程序中的Activity、Service中出现的UncaughtException事件均可以被处理。
捕获Exception以后,咱们还须要知道崩溃堆栈的信息,这样有助于咱们分析崩溃的缘由,查找代码的Bug。异常对象的printStackTrace方法用于打印异常的堆栈信息,根据printStackTrace方法的输出结果,咱们能够找到异常的源头,并跟踪到异常一路触发的过程。
public static String getStackTraceInfo(final Throwable throwable) { String trace = ""; try { Writer writer = new StringWriter(); PrintWriter pw = new PrintWriter(writer); throwable.printStackTrace(pw); trace = writer.toString(); pw.close(); } catch (Exception e) { return ""; } return trace; } |
跟Android、Unity相似,iOS也提供NSSetUncaughtExceptionHandler 来作异常处理。
#import "CatchCrash.h"
@implementation CatchCrash
void uncaughtExceptionHandler(NSException *exception) { // 异常的堆栈信息 NSArray *stackArray = [exception callStackSymbols]; // 出现异常的缘由 NSString *reason = [exception reason]; // 异常名称 NSString *name = [exception name]; NSString *exceptionInfo = [NSString stringWithFormat:@"Exception reason:%@\nException name:%@\nException stack:%@",name, reason, stackArray]; NSLog(@"%@", exceptionInfo);
NSMutableArray *tmpArr = [NSMutableArray arrayWithArray:stackArray]; [tmpArr insertObject:reason atIndex:0];
[exceptionInfo writeToFile:[NSString stringWithFormat:@"%@/Documents/error.log",NSHomeDirectory()] atomically:YES encoding:NSUTF8StringEncoding error:nil]; }
@end |
可是内存访问错误、重复释放等错误引发崩溃就无能为力了,由于这种错误它抛出的是信号,因此还必需要专门作信号处理。
8.适配与兼容
8.1UI适配
8.2兼容
9.调试及开发工具
9.1日志及跟踪
事实证实,打印日志(printf调试法)是很是有效的方法。一个好用的日志调试,必备如下几个功能:
9.2调试用绘图工具
调试绘图用工具指开发及调试期间为了可视化的绘图用工具,如腾讯桌球开发调试时会使用VectrosityScripts可视化球桌的物理模型(实际碰撞线)帮助调试。这类工具能够节省大量时间及快速定位问题。一般调试用绘图工具包含:
9.3游戏内置菜单/做弊工具
在开发调试期间提供游戏进行中的一些配置选项及做弊工具,以方便调试和提升效率。例如腾讯桌球游戏中提供:
注意游戏内的全部开发调试用的工具,都须要经过编译宏开关,保证发布版本不会把工具代码包含进去。
9.4Unity扩展
Untiy引擎提供了很是强大的编辑器扩展功能,基于Unity Editor能够实现很是多的功能。公司内部、外部都有很是的开源扩展可用
公司外部,如GitHub上的:
…
公司内部:
TUT、BeautyUnity、UnityDependencyBy
10.项目运营
公司内部接入SODA便可,建议搭建本身的构建机,开发期间每日N Build排队会死人的,另外也能够搭建本身的搭建构建平台
上线前的checklist
项目 |
要点 |
说明 |
指标 |
灯塔上报 |
1. 灯塔自带统计信息 |
灯塔里面包含不少统计数据,须要检查是否ok |
1. 版本/渠道分布 |
信鸽推送 |
可以针对单个玩家,全部玩家推送消息 |
||
米大师支付 |
正常支付 |
||
安全组件 |
1. TSS组件接入 |
根据安全中心提供的文档完成全部项 |
接入安全组件,并经过安全中心的验收 |
稳定性 |
crash率 |
用户crash率:发生CRASH的用户数/使用用户数 |
低于3% |
弱网络 |
断线重连考虑,缓存消息,重发机制等等 |
客户端的核心场景必须有断线重连机制,并在有网络抖动、延时、丢包的网络场景下,客户端需达到如下要求: |
|
兼容性 |
经过适配测试 |
||
游戏更新 |
1. 整包更新 |
特别说明:iOS送审版本支持连特定环境,与正式环境区别开,须要经过服务器开关控制 |
|
性能 |
内存、CPU、帧率、流量、安装包大小 |
【内存占用要求】Android平台:在对应档次客户端最低配置以上,均需知足如下内存消耗指标(PSS):1档机型指标:最高PSS<=300MB (PSS高于这个标准会影响28%用户的体验,约1800万)2档机型指标:最高PSS<=200MB(PSS高于这个标准会影响45%用户的体验,约3000万)3档机型指标:最高PSS<=150MB(PSS高于这个标准会影响27%用户的体验,约1800万)iOS平台:在对应档次客户端最低配置以上,均需知足如下内存消耗指标(PSS):1档机型指标:消耗内存(real mem)不大于250MB(高于这个标准会影响53%用户的体验,约1900万)2档机型指标:消耗内存(real mem)不大于200MB(高于这个标准会影响47%用户的体验,约1700万)【CPU占用要求】Android平台:CPU占用(90%)小于60%iOS平台:CPU占用(90%)小于80%【帧率要求】1档机型(CPU为四核1.4GHZ,RAM为2G)或以上机型:游戏核心玩法中,最小FPS应不小于25帧/秒2档机型(CPU为两核1.1GHZ,RAM为768M)或以上机型:游戏核心玩法中,最小FPS应不小于25帧/秒3档机型(CPU为1GHZ,RAM为768M)或以上机型:游戏核心玩法中,最小FPS应不小于18帧/秒【流量消耗要求】游戏核心玩法流量消耗状况(非一次性消耗)应知足如下条件:1.对于分局的游戏场景,单局消耗流量不超过200KB2.对于不分局游戏场景或流量与局时有关的场景,10分钟消耗流量不超过500KB |