最新文章
泰课在线 | 微信拼团成功后如何获取课程?
08-09 17:57
Unity教程 | 使用ARKit为iOS开发AR应用
07-31 17:23
Unity Pro专业版7折订阅四选一工具包之VR开发与艺术设计
07-28 11:47
网友使用虚幻UE4实现CAVE 多通道立体渲染的沉浸式环境
07-27 11:57
VR晕动症调查:未来5年内大部分VR晕动症将得到解决
07-27 11:26
AMD CEO:未来3-5年最重要 希望5年达1亿VR用户
07-27 10:44
Unity3D手游开发桌球项目分享
以下分享总结源于一个桌球项目,但不仅局限于该项目本身。虽基于Unity3D开发,很多内容同样适用于Cocos。本文将从以下10个方面进行详细阐述:
- 架构设计
- 原生插件/平台交互
- 版本与补丁
- 用脚本,还是不用?这是一个问题
- 资源管理
- 性能优化
- 异常与Crash
- 适配与兼容
- 调试及开发工具
- 项目运营
1. 架构设计
良好的架构有利于大规模项目的多人团队开发、代码管理,也便于查找错误和后期维护。
框架的选择与使用
- 选择:需根据团队和项目的实际情况进行选择,没有绝对最好的框架,只有最合适的框架。
- 使用:统一的框架能规范团队成员的行为,使成员之间的工作切换更加平滑,可维护性大幅提升,还能实现代码解耦。例如,StrangeIOC是一个超轻量级且高度可扩展的控制反转(IoC)框架,专为C#和Unity编写。已知腾讯桌球、欢乐麻将、植物大战僵尸Online等公司内部游戏使用了StrangeIOC框架。
依赖注入(Dependency Injection,简称DI)
依赖注入是一个重要的面向对象编程法则,用于削减计算机程序的耦合问题,也被称为控制反转(Inversion of Control,英文缩写为IoC)。其过程如下:某客户类仅依赖于服务类的一个接口,而非具体服务类,所以客户类只定义一个注入点。在程序运行时,客户类不直接实例化具体服务类实例,而是由客户类的运行上下文环境或专门组件负责实例化服务类,并将其注入到客户类中,以保证客户类的正常运行。即对象在创建时,由运行上下文环境或专门组件将其所依赖的服务类对象的引用传递给它,也就是依赖被注入到对象中。因此,控制反转是指对象获取其所依赖对象引用的责任的反转。
StrangeIOC的MVCS结构
StrangeIOC采用MVCS(数据模型 Model,展示视图 View,逻辑控制 Controller,服务 Service)结构,通过消息/信号进行交互和通信,整个MVCS框架与flash的robotlegs基本一致。
- 数据模型 Model:主要负责数据的存储和基本数据处理。
- 展示视图 View:主要负责UI界面展示和动画表现的处理。
- 逻辑控制 Controller:主要负责业务逻辑处理。
- 服务 Service:主要负责独立的网络收发请求等功能。
- 消息/信号:用于解耦Model、View、Controller、Service这四种模块,它们之间通过消息/信号进行交互。
- 绑定器Binder:负责绑定消息处理、接口与实例对象、View与Mediator的对应关系。
- MVCS Context:可理解为MVC各个模块存在的上下文,负责MVC绑定和实例的创建工作。
代码目录的组织
一般客户端常用的MVC框架,有以下两种目录划分方式:
- 先按业务功能划分,再按MVC划分:例如“蛋糕心语”采用的就是这种方式。
- 先按MVC划分,再按业务功能划分:“D9”、“宝宝斗场”、“魔法花园”、“腾讯桌球”、“欢乐麻将”等项目使用的是这种方式。
可根据使用习惯自行选择,个人推荐“先按业务功能划分,再按MVC划分”,这种方式能使模块更聚焦(高内聚)。随着项目运营,模块增多,第二种方式的维护难度会相对较大。
Unity项目目录的组织
结合Unity规定的一些特殊用途的文件夹,建议Unity项目文件夹组织方式如下: Plugins支持Plugins/{Platform}这样的命名规范,例如:
- Plugins/x86
- Plugins/x86_64
- Plugins/Android
- Plugins/iOS
若存在Plugins/{Platform}目录,则加载该目录下的文件;否则,加载Plugins目录下的文件。也就是说,若存在{Platform}目录,Plugins根目录下的DLL将不会被加载。
资源组织采用分文件夹存储“成品资源”及“原料资源”的方式,以防止无关资源参与打包。RawResource即原始资源,Resource即成品资源。当然,并不限于RawResource这种形式,其他Unity规定的特殊文件夹也可如此处理,例如Raw Standard Assets。
公司组件
- msdk(sns、支付midas、推送灯塔、监控Bugly)
- apollo
- apollo voice
- xlua
目前腾讯桌球、四国军棋都接入了apollo,但如果服务器不采用apollo框架,不建议客户端接入apollo,而是直接接入msdk,以减少二次封装信息的丢失和错误,方便后续升级维护,并减少导入无用的代码。
第三方插件选型
- NGUI
- DoTween
- GIF
- GAF
- VectrosityScripts
- PoolManager
- Mad Level Manger
2. 原生插件/平台交互
虽然在大多数情况下,使用Unity3D进行游戏开发时只需使用C#编写逻辑,但有时不可避免地需要使用和编写原生插件。例如,一些第三方插件只提供C/C++原生插件,或者需要复用已有的C/C++模块。此外,一些功能Unity3D无法实现,必须调用Android/iOS原生接口,如获取手机的硬件信息(UnityEngine.SystemInfo未提供的部分)、调用系统的原生弹窗、手机震动等。
2.1 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#与原生插件如何互相调用之前,先了解C#代码(.NET上的程序)的执行过程:
- 将源码编译为托管模块。
- 将托管模块组合为程序集。
- 加载公共语言运行时CLR。
- 执行程序集代码。
注:CLR(公共语言运行时,Common Language Runtime)和Java虚拟机一样,也是一个运行时环境,负责资源管理(内存分配和垃圾收集),并保证应用和底层操作系统之间必要的分离。为了提高平台的可靠性和达到面向事务的电子商务应用所要求的稳定性级别,CLR还负责监视程序的运行。按照.NET的说法,在CLR监视下运行的程序属于“托管”(managed)代码,而不在CLR之下、直接在裸机上运行的应用或组件属于“非托管”(unmanaged)代码。
回调函数是托管代码C#中定义的函数,通过回调函数可实现从非托管C/C++代码中调用托管C#代码。C/C++调用C#回调函数大致分为两步:
- 将回调函数指针注册到非托管C/C++代码中(C#中回调函数指委托delegate)。
- 调用注册过的托管C#函数指针。
相比托管调用非托管,回调函数方式稍复杂,但非常适合重复执行的任务、异步调用等情况。
CLR提供了C#程序运行的环境,也负责与非托管代码的C/C++交互调用。CLR提供两种与非托管C/C++代码进行交互的机制:
- 平台调用(Platform Invoke,简称PInvoke或者P/Invoke),使托管代码能够调用从非托管DLL中导出的函数。
- COM互操作,使托管代码能够通过接口与组件对象模型 (COM) 对象交互。考虑跨平台性,Unity3D不使用这种方式。
平台调用依赖于元数据在运行时查找导出的函数并封送(Marshal)其参数。当“平台调用”调用非托管函数时,将依次执行以下操作:
- 查找包含该函数的DLL。
- 将该DLL加载到内存中。
- 查找函数在内存中的地址并将其参数推到堆栈上,以封送所需的数据(参数)。
- 注意:只在第一次调用函数时,才会查找和加载DLL并查找函数在内存中的地址。iOS中使用的是.a已经静态打包到最终执行文件中。
- 将控制权转移给非托管函数。
2.2 Android插件
Java提供了JNI(Java Native Interface)扩展机制,可实现与C/C++的互相通信。 注:JNI wiki - 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++)的通信。从Java 1.1开始,JNI标准成为Java平台的一部分,允许Java代码和其他语言写的代码进行交互,保证本地代码能在任何Java虚拟机环境下工作。
作为知识扩展,Android的Java虚拟机最初是Dalvik,后来Google在Android 4.4系统新增了一种应用运行模式ART。ART与Dalvik的主要区别是具有提前(AOT)编译模式。根据AOT概念,设备安装应用时,DEX字节代码转换仅进行一次。相比于Dalvik的即时(JIT)编译方法(每次运行应用时都需进行代码转换),ART具有明显优势。下文中用Java虚拟机代指Dalvik/ART。
由于C#/Java都能和C/C++通信,因此通过编写一个C/C++模块作为桥接,可实现C#与Java的通信。
JNI定义了两个关键概念/结构:JavaVM和JNIENV。JavaVM提供虚拟机的创建、销毁等操作,Java中一个进程可创建多个虚拟机,但Android一个进程只能有一个虚拟机。JNIENV是线程相关的,对应JavaVM中当前线程的JNI环境,只有附加(attach)到JavaVM的线程才有JNIENV指针,通过JNIEVN指针可获取JNI功能,否则无法调用JNI函数。
C/C++要访问Java代码,必须获取Java虚拟机,有两种方法:
- 在加载动态链接库时,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)获取字段的值。
类似地,调用方法的步骤如下:
- 获得一个类对象的引用obj。
- 获取方法methodID。这些ID通常指向运行时内部数据结构,查找它们需要进行字符串比较,但实际执行获取字段或方法调用时速度很快。
- 调用jni_env->CallVoidMethodV(obj, methodID, args)。
可以看出,使用原始的JNI方式与Android(Java)插件交互非常繁琐,需要自己处理很多事情,并且为了性能还需自己考虑缓存查询到的方法ID、字段ID等。幸运的是,Unity3D已对这些进行了封装,并考虑了性能优化。Unity3D主要提供了以下两个级别的封装来帮助高效编写代码:
- Level 1:AndroidJNI、AndroidJNIHelper,原始的封装类似于自己编写的C# Wrapper。AndroidJNIHelper和AndroidJNI自动完成了很多任务(如找到类定义、构造方法等),并使用缓存使调用Java速度更快。AndroidJavaObject和AndroidJavaClass基于AndroidJNIHelper和AndroidJNI创建,但在处理自动完成部分也有自己的逻辑,这些类也有静态版本,用于访问Java类的静态成员。
- Level 2:AndroidJavaObject、AndroidJavaClass、AndroidJavaProxy,这三个类基于Level 1的封装,提供了更高层级的封装,使用起来更简单,后续会详细介绍。
2.3 iOS插件
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下载)。
- 增量:主要指android省流量更新。可以使用bsdiff生成patch包,应用宝也提供增量更新sdk可供接入。
- 资源:Unity3D通过使用AssetBundle即可实现动态更新资源的功能。
手游实现更新功能时需注意以下几点:
- 游戏发布后一定要提供游戏内更新的途径。即使删掉测试版本,期间也可能需要进行资源或BUG修复更新。很多玩家不知道如何更新,且Android手机应用分发平台多样,分发平台本身不会与官方同步更新(特别是小的分发平台)。
- 更新功能要提供强制更新、非强制更新配置化选项,并指定哪些版本可以不强更,哪些版本必须强更。
- 当游戏提供非强制更新功能后,现网会存在多个版本。若需要针对不同版本进行不同更新,例如配置文件A针对1.0.0.1修改了一项,针对1.0.0.2修改了另一项,两个版本需要分别更新对应的修改,需自己实现更新策略,IIPS不提供此功能。当需要复杂的更新策略时,推荐自己编写更新服务器和客户端逻辑,不使用iips组件(其实自己实现也很简单)。
- 没有运营经验的人可能会选择二进制,认为其安全、体积小,这适用于端游/手游外网只存在一个版本的游戏,但对于一般不强升版本的手游并不合适,反而会给更新和维护带来很大麻烦。
- 配置使用XML或JSON等文本格式,更利于多版本的兼容和更新。腾讯桌球客户端最初使用二进制格式(由excel转换而来),但随着运营配置格式需要增加字段,老版本程序无法解析新的二进制数据,给兼容和更新带来了很大麻烦。这就要求针对不同版本进行不同更新,或者配置一开始就预留足够的扩展项,但无论如何预留扩展,都很难跟上需求的变化,而且一开始会使配置表复杂化,实际上只有少数配置表会变更结构。
- iOS版本的送审版本需要连接特定的包含新内容的服务器,现网服务器还不包含新内容。送审通过后,上架游戏现网服务器会进行更新,iOS版本需要连接现网服务器而非送审服务器,但这期间不能修改客户端,这个切换需要通过服务器下发开关进行控制。例如,通过指定送审的iOS游戏版本号,客户端判断本地版本号是否为送审版本,如果是则连接送审服务器,否则连接现网服务器。
4. 用脚本,还是不用?这是一个问题
使用脚本有助于方便更新,减少Crash(特别是使用C++的cocos引擎)。从前面的“版本与补丁”部分可知,实现代码更新非常困难,这给客户端开发带来了较大压力。如果出现严重的BUG,必须发布强制更新版本。而使用脚本可以解决这个问题。
由于Unity3D手游更新成本较大,且目前腾讯桌球要求不能强制更新,导致新版本的活动覆盖率提升较慢,出现问题后难以修复。针对这种情况,考虑引入lua进行活动开发,后续发布活动及修复bug只需发布lua资源,进行资源更新即可,大大降低了发布和修复问题的成本。
另一个可选方案是使用Html5进行活动开发,目前游戏中已预埋了Html5活动入口,并已用于发布“玩家调查”、“腾讯棋牌宣传”等活动。但与lua相比,Html5不能与Unity3D深度融合,体验不如lua,例如无法操作游戏中的UI、不能完成复杂界面的制作、不能复用已有的功能,玩家付费充值方式也与现有方式有差异。
在公司内部,很多项目喜欢使用lua进行开发,如火影忍者(手游)使用unity + ulua,全民水浒使用cocos2d - x + lua等。可以使用公司内部的xlua组件,也可以使用ulua、UniLua等。
5. 资源管理
5.1 资源管理器
(原文此处未展开,待后续补充完整相关内容)