Unity客户端架构设计心得

移动游戏开发的历史及现状

国内的游戏市场,在12年以前还是以PC为主,主要有以下原因:

  • 基础设施跟不上,都是2G,3G。2013年年底才正式发放4G网络。
  • 移动设备性能不高,多数还是以打电话为主。而且市面上还有相当体量的老年机。
  • 国内游戏的研发团队整体水平偏低,而且也没有生存空间,当时的对于游戏的认知基本上是:上网吧玩游戏的都是坏孩子,成年人玩就是不务正业、精神鸦片、balala…不听话就杨教授电你,所以和国外相比还是有很大的差距。
    游戏的一些优秀作品例如:《暗黑破坏神2》《魔兽争霸3》《魔兽世界》不管是从设计还是技术,基本上都是吊打国内的研发团队。而且对于移动端的游戏,国外也不认为这是游戏,更像是玩具。

但随着4G网络的开放,IOS、Android等移动设备基本以摩尔定律的方式进行快速迭代,以及整个经济的快速上升,让原来不能的事情变得可能。
在游戏方面:

  • 2013年1月11日正式公测 我叫MT手游
  • 2013.08 微信5.0上线 游戏:打飞机。
  • 2013.09.16 腾讯的天天跑酷,以及随后的天天系列手游。

file
file

伴随着这些游戏的成功,以及国内对于手游的巨大市场需求,拉开了手游红利期的序幕。
中国手游也正是入局,china game pay to win的模式宣告上线,告诉外界什么叫做玩具也能赚的盆满钵满。

随着游戏的红利以及大量成功案例的出现,手游开发商也都卷起来,项目不管是从品质、游戏的细分品类、参与的人数、复杂度都直线上升。所以原本5-6个人做个游戏并且还很赚钱的情况急转直下。按照原来那种车库文化或者作坊模式来开发更复杂的游戏,其维护成本越来越高,所以对于项目的管控也越来越被关注。而且游戏开发有其自身特点,部门多、角色多、迭代速度快、并行开发、从业经验分布广、人员变动大等特点。为了保障项目能够按照约定的时间交付,就需要以看待工程而非科研的的方式来推进开发流程,本质也是降低人的不确定性带来的影响。什么是人的不确定性,擅长和非擅长项(有合适的人吗?),沟通的对象间的顺畅程度,人员的变动,方案的更改、用户的喜爱、政策环境等等。

目前可以确定几个点:

  • 角色多:引擎、渲染、战斗、系统、工具、性能优化、版本等等。甚至专门搞物理、布料之类得,非程序岗的角色就更多了。
  • 虽然都说希望复合型人才,但是真正能落到实际情况下,一个人能做的事还是非常有限的,也就是说一个人就算游戏的群流程都可以自己搞定,但是在有限的时间内他做不完,这时候就需要分工。
  • 在多角色、多部门下需要同时推进,并行开发,尽量的减少不可压缩工期的出现。
    也就引出下面的议题,一个什么样子的架构能满足上述的这种工作模式。

俗话说:他山之石,可以攻玉,我们可以看一看,非游戏行业,那些大型软件系统是如何运作开发的。


软件架构

  • 什么是软件架构?软件架构是有关软件整体结构与组件的抽象描述,会包括软体组件、组件之间的关系,组件特性以及组件间关系的特性。软件架构这项工作的实质就是规划如何将系统切分成组件,并安排好组件之间的关系,以及组件之间互相通信的方式。
    例如我们常用到的MVC、MVP、MVVM、ECS、SOA(面向服务的架构)及实现ESB、微服务架构、云原生架构、DDD分层架构等等。

架构模型

  • 有了架构为什么还要建模?因为架构具有抽象、不可见和理解上的差异性,为了能够更好的沟通、快速抓住关键事物之间的关系或走向并且使其具象化,我们就需要建模。日常中一些UML、流程图、视图模型乃至一些伪代码,其实都是模型。
    以下列举一些在软件架构中提到架构的对应模型

file
file
file

ECS架构

file
file
file
file
file

Unity开发中可以应用的架构(架构风格)

在现有的架构风格中有哪些能尽量满足游戏开发这种:部门多、角色多、并行开发,迭代速度快、从业经验分布广、人员变动大等特点呢?分层,按照不同抽象程度的分层。

分层架构的优缺点

优点:

  1. 开发人员可以只关注整个结构中的其中某一层
  2. 可以很容易的用新的实现来替换原有层次的实现
  3. 可以降低层与层之间的依赖
  4. 有利于标准化
  5. 利于各层逻辑的复用
  6. 扩展性强。不同层负责不同的层面
  7. 项目结构更清楚,分工更明确,有利于后期维护和升级

缺点:

  1. 严格的分层可能导致性能和维护问题(具体取决于层数)
  2. 建立清晰的分层架构并不总是很容易,因为需求是变动的。

注:实际中架构的选择,基本都是混用,很难找到只使用一种架构的软件,基本都是以一种为主,其它为辅助的模式。

Unity客户端的架构模型(T型)

file

依赖的方向是单向有序,因为在设计要避免循环依赖的出现(设计上的大忌),因为有依赖就设计到依赖的迁移性。
在应对循环依赖的时候常使用控制反转的方式打断依赖循环。
Inversion of Control Containers and the Dependency Injection pattern

T型的竖向

第一层

主要是Unity引擎自带的基础系统,例如资产及文件系统、物理、渲染、动画模型、核心运算库等。
如果有Unity源码,此部分魔改工作一般都是有引擎组独立负责。

第二层

file

包含Unity自带package和第三方Package。其特点:独立部署,相互无依赖,无时序耦合(同层启动顺序不敏感),可按语义化版本控制规范迭代发布,易于剥离共享,变动频率很低。各个Package多数由专人负责,多伴随不可压缩工期。

第三层

file

基于第一层和第二层的自定义包装,例如UI框架,资源管理框架、网络、音频、SDK等。其特点:相互无依赖,同层启动顺序不敏感,易于剥离共享,变动频率低。各个Server多数由专人负责也是同样多伴随不可压缩工期。

第四层

file

基于本地运行的环境,选择第三层各个Server之间的依赖关系或者依赖项。其特点:无业务逻辑,只做匹配选择,变动频率低。

第五层

file
file

主要分为三部分:

  • 系统对应的MVC
  • 战斗对应的ECS
  • 共有部分Common
    其特点:系统、战斗、Common常分别放到不同的物理位置(文件夹),模块内聚合度高,同层内由众多小模块拼凑而成,零碎且复杂,变动频率非常高。
T型模型竖向设计核心:以单向有序的依赖方式进行架构分层。

T型的横向

  • 模块划分
    file
  1. 进出命令,本质上是利用命令模式的无状态,将模块一部分非运行时代码剥离(职责分离),减少了运行逻辑的代码量,做改动后进行风险评估其波及范围也会缩小。
  2. 数据容器,模块退出时可以选择是否保存此模块的独有数据,达到了数据可以独立于模块,在二次进入模块时可以根据历史数据做恢复性动作,例如对有回溯要求的模块很友好。
  3. 运行时逻辑,将模块按照职责拆分成多个逻辑块(脚本),并伴随对应的绑定列表,带来的好处一是职责清晰,二是屏蔽某一逻辑块直接在绑定列表中注销即可。
  4. 自动生成代码,此代码中一般无业务逻辑,常为由编辑器按照指定规则自动生成的代码,例如批量查找组件。
  • 消息系统

file

消息系统可以向关注的逻辑块内发送指定消息,也可以触发对应的Command
消息中心中的三个功能

  • 过滤:可以根据和名单屏蔽指定的消息,这样在运行时就可以阻止一些模块的进入
  • 重定向:可以将指定消息和参数(都是字符串)替换为另外的消息名称和参数。对于线上报错的模块处理更为友好。例如:模块A线上出现了问题,我们可以把模块A的命令(消息名称)替换成Tips的命令,并配以说明,让玩家知道运营已经发现问题并在加急修复中,详情可以关注公告等,这样可以第一时间减少客诉量。
  • 分发:因为系统、战斗或者一些特殊的模块在消息触发的时机不一致,可能是同步或者是异步,经过上两层的操作后可以根据消息的类型分发到不同的渠道,以应对其不同的触发机制。
    而且根据消息的发送时间可顺序,可以推断出用户的行为偏好。
T型模型横向设计核心:基于消息以无状态命令的方式触发逻辑跳转。
  • 无状态:全部重新下载;有状态:断点续传

优化及带来的影响

项目中有时需要对一些设计进行性能等方面的优化,优化后一些可以量化的性能参数多数会提升不少,但往往会忽略一些不可量化或者难以量化的方面,而这些方面带来的负面影响反而超过了收益,以下以消息系统为例,分析优化前后其所带来的影响。

可以优化的地方

  1. 优化消息名称
  2. 优化消息内容
  3. 优化查询速度
  4. 由基于低频发送的命令无状态模式改为高频

1.消息名称改为ushort(65,535)两字节,例如原来:nameof(InventoryCommand)就16字节,数据变小,引用类型改为值类型,避免引发GC
2.原来是bsondocument(json的变种)改为protobuf那种只含有varint(Tag-leg-value)或者我们再优化,直接吧tag-leg砍掉,口头约定好取值的规范,按照默认的规范解析。
3.直接用泛型缓存,比hash查找直接快几十甚至上百倍

第一个优化引发的问题

1.可读性变差,或者说没有可读性,原来看名字直接知道对应的命令现在不知道
2.使用语法糖nameof(InventoryCommand)后续更改名称可以一秒全局更改,优化后这种方式行不通了
3.在阅读代码或者查找bug的时候,可以快捷键转到定义,滑轮翻阅代码这种方式无法使用,由原来的一键查看变成,看消息编号-根据消息号去列表找对应的命令是什么-找到命令-查阅,这仅仅是一条命令的查找,如果4-5条或者更多呢?人的瞬时记忆也就3-5条
4.在合并的时候容易发生冲突(用一个ID)

第二个优化引发的问题

GM面板输入的参数输入和解析都需要额外处理(原来直接输入字符串)
在断点调试的时候同样不可读。

第三个优化引发的问题

因为泛型缓存之只很对类型,所以就要重新生成对应消息的新类,例如Class_Message_001
带来的结果就是消息名称的更改都要重新生成代码并且编译。
基于编号的充足就回造成生成类的冲突

第四个优化引发的问题

因为是高频发送,所以就不能每次生成command类,这个时候就要做对象池,但是又要达到无状态,所以在入池的就要清洗,因为每个类都是对一个模块维护者自己写的,其数据结构我们无法掌控,这时候就要留出清洗接口,但清洗实现就要编写者额外操作(工作量开始增加),当然也可以用反射,但是性能消耗更到,对象池就没意义了。而且如何回收也是个问题。
清洗的时候回造成额外的性能开销,这个时候就要做清洗队列,然后基于时间片段定量的清洗。
密码学中有个柯克霍夫原则
中心思想:如果一个密码需要保密的越多,这套机制就越脆弱,反之系统也是一样,如果为了达成一个目的需要做的工作越多,则越不稳定,因为其中任何一个环节出错,其对应需要支撑的目标就无法达成(线性非并行关系)
线性的成功概率:成功率 X 成功率 X 成功率… 0.5 X 0.5 X 0.5=0.125
并行的成功率概率:1-0.125(失败率乘积)=0.875(成功率)

如果游戏按照60帧平均每帧发送10条消息的规模算(一秒发600条),基于优化后的机制性能消耗一定会小非常多,但是,每条消息的背后都对应着一系列的逻辑,不仅仅是纯数据的操作,可能是界面的刷新,模块的跳转,界面的打开/关闭等等、等等,所以这种高频发送的消息机制真的合适吗或者说能应用到逻辑层面的通知吗?这是否是过度设计?这个值得人思考
在真正实施的时候可能还会出现一些目前没有考虑到的问题

实际开发中的一些技巧

  • 关注的消息建议放在一起,对应的回调由统一函数接收。这样便于量化一个运行逻辑块是否职责过于多,常规消息5-8条,如果再多,就要考虑是否需要拆分了
  • 关注消息在一起也方便消息的注销,避免了忘记注销回调为空的情况发生。

    public void Enter_0()
    {
        MessageManager.Instance.Register<T>("messageName_0", CallBack_0);
        MessageManager.Instance.Register<T>("messageName_1", CallBack_1);
        MessageManager.Instance.Register<T>("messageName_2", CallBack_2);
        MessageManager.Instance.Register<T>("messageName_3", CallBack_3);
    
    }
    
    public void Exit_0() 
    {
        MessageManager.Instance.Remove<T>("messageName_0", CallBack_0);
        MessageManager.Instance.Remove<T>("messageName_1", CallBack_1);
        MessageManager.Instance.Remove<T>("messageName_2", CallBack_2);
        MessageManager.Instance.Remove<T>("messageName_3", CallBack_3);
    }
        public override string[] ListNotificationInterests()
        {
            List<string> listNotificationInterests = new List<string>();
            listNotificationInterests.Add(Notification.CloseHomePanel);
            listNotificationInterests.Add(Notification.OpenHomePanel);
            return listNotificationInterests.ToArray();
        }

        public override void HandleNotification(INotification notification)
        {
            switch (notification.Name)
            {
                case Notification.OpenHomePanel:
                    {
                        GetHomePanel.OpenHomePanel();
                        break;
                    }
                case Notification.CloseHomePanel:
                    {
                        GetHomePanel.CloseHomePanel();
                        break;
                    }
            }
        }
  • 消息同步异步问题,避免因时序问题引发的异常。
  • 运行模块内的调用应该直接引用,跨模块的逻辑跳转用消息,避免消息的网状结构发生。
  • 消息传参数据结构建议用通用型数据结构(在下三层里面的)。
  • 避免服务定位这种反模式。
  • API的管控。
  • 命令查询职责分离模式(Command Query Responsibility Segregation,CQRS)。CQRS最早来自于Betrand Meyer(Eiffel语言之父,开-闭原则OCP提出者)
    命令(Command):不返回任何结果(void),但会改变对象的状态。
    查询(Query):返回结果,但是不会改变对象的状态,对系统没有副作用。
  • 单例的控制。
  • 代码审核机制(标记被删除引发hash不一致问题)。
  • 服务总线机制。
    public void InitializedSever()
    {
        //Asset
        SeverManger.Instance.Register<IAsset>(() => new AssetServer_PC())("pc");
        SeverManger.Instance.Register<IAsset>(() => new AssetServer_Xbox())("xbox");
        SeverManger.Instance.Register<IAsset>(() => new AssetServer_PS5())("ps5");
        SeverManger.Instance.Register<IAsset>(() => new AssetServer_Mobile())("mobile");

        //Audio
        SeverManger.Instance.Register<IAudio>(() => new Audio_Wwise())("Wwise");
        SeverManger.Instance.Register<IAudio>(() => new Audio_FMOD())("FMOD");
        SeverManger.Instance.Register<IAudio>(() => new Audio_CRIWARE())("CRIWARE");

        //OtherServer....
    }

    public void ServerWork()
    {
        var assetServer=  SeverManger.Instance.Retrieval<IAsset>();
        assetServer.LoadAssetAsync<GameObject>("prefab",obj=> { });

        var audioServer = SeverManger.Instance.Retrieval<IAudio>();
        audioServer.PlayAudio("audioName");
    }
  • 工具整合(类似飞书工作台)。

未来发展趋势的展望

下层基础决定上层建筑,目前使用的各种技术还是很依赖于市面上的商用硬件的,研究所、军方可能有特例,他们是不计成本的投入,和商业项目没有可比性。
目前摩尔定律面临终结以及无线通讯速率的上升,以云储存为基础、多核计算提高运算速度的这种方式,已经逐渐深入到我们的生活中,再有就是如微软对多平台的整合,猜测也是对未来全面上云的提前布局(详见:跨平台定位),所以其对应的设计和技术其价值也会越来越高。例如:ECS、云游戏,云渲染等。

结语

说了这么多,讲了架构(架构风格)、架构模型、以及Unity中具体的应用与所带来的好处。这时有个疑问,不用行不行?
答案:是可以的。
架构是经过多个行业、众多项目多年沉淀下来,用以管控复杂软件开发的最佳实践,以降低代码中的混乱(熵)。但它所有的方法和策略都是以推荐为主,非强制。就像常识一样,一些所谓的常识,都是基于一定的可行性理论为基础,伴以大量实践为前提得出来的,但是不这么做,也未必有什么问题。
稳定的需求是软件开发的圣杯,圣杯可遇不可求。所以我们讨论函数命名、变量命名、数据类型和控制结构、代码布局等编程的最基本要素,也讨论了防御式编程、表驱动法、协同构建、开发者测试、性能优化、软件架构及模型等有效开发实践,所做的这一切是为了让代码更简单。就像Steve McConnell(史蒂夫·迈克康奈尔)说的那样:软件的首要技术使命:管理复杂度。
注:Steve McConnell,《代码大全》作者。

发表评论