思考并回答以下问题:
- 加载的Unity Asset资源会自动实例化成游戏对象吗?要怎么做?
- 代理模式的官方定义是什么?
- 代理模式的类图是什么样的?
- 代理者模式是结构型模式吗?
- 什么是资源管理“高速缓存”功能,为什么要使用这个功能?如何使用?
- 举例如何使用“高速缓存”这个例子描述代理模式。
- 如何用总经理和秘书来类比代理模式?这是保护代理吗?为什么?
- 和原始对象实现同一个接口并做前置判断是什么意思?
- 客户端全部调用代理者的方法来执行怎么理解?
- 代理模式的优点是可判断是否要将原始类的工作交由代理者类来执行,如此则可以免去修改原始类的接口及实现,怎么理解?
- 代理模式最重要的是新增一个代理类,然后在客户端把调用原始对象类的代码改成调用代理类的代码。怎么理解?
本章涵盖:
- 最后的系统优化
- 代理模式
- 代理模式的定义
- 代理模式的说明
- 代理模式的实现范例
- 使用代理模式测试和优化加载速度
- 优化加载速度的架构设计
- 实现说明
- 使用代理模式的优点
- 实现代理模式时的注意事项
- 代理模式面对变化时
- 结论
最后的系统优化
当游戏项目完成到某一个阶段,准备进入大量测试之前,程序人员会进行所谓的“优化”工作。而优化工作通常指的是,在当前的项目基础上,专心致力于找出游戏执行时的瓶颈点,针对现有的功能或执行时的效果加以调整或找出问题点,包含:
- 整体每秒画面更新频率(FPS,即每秒帧数)是否可以再往上增加;
- 游戏执行过程是否会突然停顿;
- 游戏使用的系统资源是否过多,例如使用的内存是否过多、网络传送的信息是否过多…;
- 游戏加载时间是否过长;
- 其他等等。
当然,以笔者过往的开发经验来说,多半不会建议在游戏的最后阶段才开始进行游戏的优化工作。因为如果在最后阶段才发现,先前的某项美术规格设置错误或程序实现的架构有问题,而且又非修改不可的话,就得花费非常多的成本和时间去做修正,例如:
- 调整3D角色模型面数;
- 减少2D角色动作数、每个动作的张数;
- 减少界面贴图的大小;
- 减少企划设计的数据笔数,或者使用共享数据的方式;
- 调整音效采样频率、压缩方式、压缩比;
- 重新规划游戏的资源分配方式,或者延后加载游戏资源;
- 其他等等。
我们虽然不希望这些问题是在开发的最后阶段才被发现,但问题是,这些问题在开发过程中也不太容易被发觉,主要的原因是游戏资源通常不会一次到位。它们是会随着开发进度慢慢增加的。一开始时可能同时要加载的模块、接口没那么多,所以不会有使用过多内存的问题;或是企划设计的数据笔数没那么大,所以也不会有加载速度的问题;程序开发人员还没编写那么多的游戏功能一起运行,所以也看不出设计架构有什么问题。
但是,当游戏项目进入后期,届时美术资源已全部完成、企划信息设置完成、音效全部录制完毕、游戏功能全部上线了,这个时候项目才会将问题呈现出来。所以常常是最后一次将所有资源全部集结完成的时刻,就是游戏产生瓶颈的时候,接着就会导致性能上的问题:
- 系统无法承载所有资源的加载或加载时间过长;
- 画面更新的频率过慢,每秒帧数(FPS)低于30帧以下,甚至是更差的10帧以下;
- 与游戏服务器交换的信息过多,使得网络连线质量太差而无法实时响应;
- 最严重的情况是游戏无法执行,或者进行到一半时游戏就失效而宕机。
上述的情况,都会让玩家留下很不好的游戏体验。
当然也有些人认为问题未能及早发现,是因为:随着项目日渐增大,效率慢慢变差,而开发人员也被“同化”了,就像温水中的青蛙一样渐渐地无感觉,非等到最后系统崩溃时,才会察觉问题的严重性。
在面对这种几乎难以避免的问题时,“经验”还是最好的解答。我们可以从几个方面来努力避免问题的产生:针对每种平台上的软硬件特效进行了解,引用其他项目的游戏资源规格设计,或是自己从其他平台上学习到的知识。事前做好游戏资源的规格设置,或提前设计较容易修改的程序架构,让后续的性能优化上能有较佳的调校环境。
话虽如此,即便我们有了足够的经验,问题仍旧可能发生。当真的遇到非修改不可的情况时,对于美术、企划、音效人员来说,都有手边的开发工具可以协助进行调整,有了工具至少问题的解法就有了依据,只不过需要再多花点工夫就是了。而对于程序设计师来说呢?程序设计师遇到这种情况,可能需要做的是:调整软件系统架构、修改类接口、调整系统执行流程等的工作。而这些工作牵一发而动全身,对于没有做好准备的程序人员来说,是最不愿意遇到的工作。
不过,对于已经准备好的程序人员或项目来说,情况将有所不同。如果程序人员在项目实现时,都已经编写了“测试单元”而且“测试覆盖率”还不算低的话,那么肯定对于系统执行的稳定度具有一定的信心,因此,对于这样的项目,程序人员会比较敢于修改和调整。此外,如果软件系统在开始设计的初期,就已经考虑到后续的修改和变化,而采用了较好的设计方式(例如运用设计模式),那么对于游戏项目后期因为需要而修改的问题,也不会过于烦恼。
载入资源的优化
当然,有些设计模式是可以作为软件系统调整时的解决办法。就以《P级阵地》在优化阶段遇到的问题为例:需要优化的功能发生在“资源加载工厂(IAssetFactory)”中,以《P级阵地》当前的实现来说,资源加载工厂(IAssetFactory)共有3个子类,分别用来代表存放在不同地点的UnityAsset资源(请回顾游戏角色的产生-工厂方法模式的介绍):
- ResourceAssetFactory:从项目的Resource中将Unity Asset实例化成GameObject的工厂类;
- LocalAssetFactory:从本地(存储设备)中,将Unity Asset实例化成GameObject的工厂类;
- RemoteAssetFactory:从远程(网络WebServer)中,将Unity Asset实例化成GameObject的工厂类。
以ResourceAssetFactory的实现为例,从Unity3D的资源目录中加载时,需要经过以下两个步骤:
- 1.从Resource中载入Unity Asset资源:这个步骤在LoadGameObjectFromResourcePath方法中实现。
- 2.加载的Unity Asset资源实例化成游戏对象:这个步骤在InstantiateGameObject方法中实现。
Listing1 从项目的Resource中,将Unity Asset实例化成GameObject的工厂类(ResourceAssetFactory.cs)
1 | public class ResourceAssetFactory : IAssetFactory |
上述程序代码中,需要优化的点在于:每当角色训练完成后出现在战场时,资源加载工厂(IAssetFactory)就必须从资源目录加载一次,而从资源目录加载包含了向操作系统加载文件的操作,一般认为这个操作是比较消耗系统性能的操作,所以应该避免不必要的调用。
所以,资源加载工厂(IAssetFactory)优化的方向是:让已经加载过的Unity Asset资源存放在一个资源管理容器中,如果下次需要再取用时,就先查看资源管理容器内是否已经有相同的UnityAsset资源,如果有,则直接使用这个资源产生游戏对象(GameObject),不必再重新加载一次。(注:可使用字典存储)资源加载工厂的优化示意图如图1所示。
图1 资源加载工厂的优化示意图
一般会将这个资源管理称为“高速缓存(Cache)”功能,用来暂存之后可能会使用到的对象,不必每次都必须从目录系统中读取。
如果只是单纯想在资源加载工厂(IAssetFactory)中加入高速缓存功能,其实很简单:
Listing2 从项目的Resource中,将Unity Asset实例化成GameObject的工厂类
1 | public class ResourceAssetFactory : IAssetFactory |
上面的修改方式,并没有更改到接口,只是增加了内部成员及修改方法就达到了目的。这种修改方式虽然简单,但当我们考虑的再深一点时,似乎就不太管用:
- 虽然只是调整了方法内的实现,但是如果是处于项目完成阶段,除非是程序错误(Bug)需要修正,否则对于功能的调整都必须更加谨慎。
- 因为可能只是猜测会有性能上的瓶颈,所以会想要“先测试看看”,或者比较修改前后的性能差异,但如果将要修改的功能直接实现在原有的功能上,会让“测试”工作变得不好进行,可能需要提供额外的方法来进行功能的关闭。
- 如果测试结果发现并无影响,所以最终决定不加入高速缓存功能,也可能会有以下的决定:恢复成修改前的类,那么下次要再测试时,又要将程序代码加回来,这中间会增加许多产生错误的机会;利用开关将功能关闭,那么这个功能也可能后续都不会再使用,而这些新加入的程序代码就会变成“无用”的程序代码,因此增加了维护的难度。
- 破坏了原有类封装时的概念,因为当初设计时,就没有考虑到“高速缓存”功能,因此额外加上的功能会破坏原有系统对于ResourceAsseFactory类的抽象定义。
因此,在考虑上述延伸问题的情况下,想要增加高速缓存功能,就要采用不改变原有类的接口及实现的方式。也就是将高速缓存功能实现在另外一个类中,当要获取资源时,必须先通过这个类判断后,才决定资源的加载方式,但是这个新增的类又不能更改原有客户端的实现。在这些修改条件的限制之下, GoF的代理模式符合我们对于修改的需求。
代理模式
笔者在学习代理模式时,最大的疑问是它和装饰模式(Decorator)的差异是什么?好像都是在原有的功能上增加某个功能。如同之前提到的优化范例,感觉像是在资源加载功能中“加上”一个高速缓存功能。其实,有一个地方很容易就可以将两者区分开来,对代理模式来说,它可以“选择”新功能是否要执行,而装饰模式则是除了原有功能之外,也“一定要执行”新功能。
代理模式的定义
代理模式(Proxy)在GoF中的说明为:1
提供一个代理者位置给一个对象,好让代理者可以控制存取这个对象。
定义中说明了两个角色之间的关系,“原始对象”及一个“代理者”,如果假设用总经理和秘书当作是这两个角色来解释定义,就很容易理解:1
提供一个秘书位置给总经理,好让秘书可以先过滤要转接给总经理的电话。
因为秘书有“控制来电是否要转接给总经理”的职责,所以在秘书的职责上就会定义“什么样的来电内容需要转接”。而由于有秘书代理者这个职务负责过滤,所以总经理接到的电话一定是重要且不会浪费时间的。
像秘书这种为总经理先行过滤电话再进行转换的代理行为,在GoF的定义中属于“保护代理(Protection Proxy)”,GoF一共列举了4种代理模式经常使用到的场景:
- 远程代理(Remote Proxy): 常见于网页浏览器中代理服务器(Proxy Server)的设置。代理服务器是用来暂存其他不同地址上的网页服务器内容。
- 虚拟代理(Virtual Proxy):可以作为“延后加载”功能的实现,让资源可以在真正要使用时,才进行加载操作,在其他情况下都只是虚拟代理所呈现的一个“假象”。
- 保护代理(Protection Proxy):代理者有职权可以控制是否要真正取用原始对象的资源。
- 智能引用(Smart Reference):主要用于强化C/C++语言对于指针控制的功能,减少内存遗失(Memory Leak)和空指针(Null Pointer)等问题。
代理模式的说明
代理模式让原始对象及代理者能同时运行,并让客户端使用相同的接口进行沟通,客户端无法分辨两者,类结构图如图2所示。
图2 代理模式实现时的类结构图
GoF参与者的说明如下:
- Subject(操作接口):定义让客户端可以操作的接口。
- RealSubject(功能执行):真正执行客户端预期功能的类。
- Proxy(代理者)
- 拥有一个RealSubject(功能执行)对象。
- 实现Subject定义的接口,所以可以用来取代RealSubject出现的地方,让原客户端来操作。
- 实现Subject所定义的接口,但不重复实现RealSubject内的功能,仅就Proxy当时所代表的功能,做前置判断的工作,必要时才转为调用RealSubject的方法。
- Proxy所做的前置工作,会按上一小节所说的四种应用方式,而有不同的判断和操作。
代理模式的实现范例
按照最原始的定义来实现代理模式并不会太复杂,首先定义Subject接口:
Listing3 制订RealSubject和Proxy共同遵循的接口(Proxy.cs)
1 | public abstract class Subject |
再实现真正执行功能的类:
Listing4 定义Proxy所代表的真正对象(Proxy.cs)
1 | public class RealSubject : Subject |
最后是代理者类的实现:
Listing5 持有指向RealSubject对象的引用以便存取真正的对象(Proxy.cs)
1 | public class Proxy : Subject |
在代理者类中包含了一个RealSubject对象,并增加了一个模拟权限控管的开关成员(ConnectRemote),只有当权限被设置为开启时,Proxy类才会将请求转给RealSubject对象,在其他情况下,就直接由Proxy类接手处理。
测试程序代码扮演客户端的行为,测试开启权限后Proxy是否正确转移信息给RealSubject执行:
Listing6 测试代理模式(ProxyTest.cs)
1 | void UnitTest() |
执行结果
1 | Proxy.Request |
虽然范例中Proxy类的判断非常简单,但真正实现时,Proxy类会是关键所在。为了应对四种常见情况,每个Proxy的判断方式也会根据各种不同的需求而有所差异,而《P级阵地》仅就保护代理(Protection Proxy)来进行实现。
使用代理模式测试和优化加载速度
回到《P级阵地》对于优化资源加载工厂(IAssetFactory)的需求上。因为将高速缓存功能直接实现在原有的ResourceAssetFactory会延伸出其他的问题,所以新的修正方式,改为将高速缓存功能实现在一个代理者类上。
优化加载速度的架构设计
实现一个代理者类来将加载速度进行优化时,可以从问题点来着手。我们认为调用原类ResourceAssetFactory直接获取目录中的Unity Asset资源是比较“昂贵”的,所以代理者的工作就是要分辨出:哪些请求是真正需要使用ResourceAssetFactory类从目录中获取资源,而哪些请求则不用。
所以,这个代理者执行的是“保护代理”(Protection Proxy)的工作,它判断权限的依据在于:这次要求加载的Unity Asset资源是否曾经被加载过?如果是没有被加载过的Unity Asset资源,它才会放行给ResourceAssetFactory类去执行,否则,就直接把这个代理者高速缓存容器中的资源返回。按照这个想法修改后的资源加载工厂(IAssetFactory)如图3所示。
图3 按代理模式修改后的资源加载工厂(IAssetFactory)
参与者的说明如下:
- IAssetFactory:资源加载工厂。
- ResourceAssetFactory:从项目的Resource中,将Unity Asset实例化成GameObject的工厂类。
- ResourceAssetProxyFactory:ResourceAssetFactory的代理者内部包含了一个ResourceAssetFactory对象及Unity Asset资源容器。代理者必须判断资源加载需求是否要通过原始类ResourceAssetFactory来执行,只有未被加载过的Unity Asset资源,才会放行ResourceAssetFactory类去执行。
实现说明
使用代理模式进行优化实现时,只需要增加一个代理者类,并且修改客户端获取资源加载工厂(IAssetFactory)对象的程序代码即可。而代理者类ResourceAssetProxyFactory的实现如下:
Listing7 作为ResourceAssetFactory的Proxy代理者(ResourceAssetProxyFactory.cs)
1 | // ResourceAssetFactory会记录已经加载过的资源 |
要求加载每一种Unity Asset资源时,代理类都会先判断之前是否已经加载过了(是否存在于管理容器内)。对于没有加载过的Unity Asset资源,会先调用原始类ResourceAssetFactory中的方法,实际从目录系统中加载Unity Asset资源,然后先放入管理容器中,最后才返回给客户端。ResourceAssetProxyFactory作为一个保护代理(Protection Proxy),是利用管理容器的记录作为控制外界向原始类获取资源的依据。
对原本的客户端来说,因为《P级阵地》只有一个地方想要获取IAssetFactory对象,也就是PBDFactory中。因此,后续的修改非常简单,只要改为获取代理者ResourceAssetProxyFactory的对象就可以了:
Listing8 获取P-BaseDefenseGame中所使用的工厂(PBDFactory.cs)
1 | public static class PBDFactory |
使用代理模式的优点
使用代理模式可以避免原有实现版本的缺点,好处在于:
- 使用新增类的方式来强化原有功能,对原本的实现不进行更改。
- 对于只是想“测试”可能产生性能瓶颈的地方,如果测试后发现并无差异,或是想要采用旧方法的话,在恢复成旧有实现方式时非常方便。
- 若将功能开启与否,改为使用配置文件来设置,也可以让代理者的实现不需要改动到任何原有的类接口。
- 将高速缓存功能由代理者实现,不会破坏原有类封装时的概念。
实现代理模式时的注意事项
代理模式虽不难理解,但实现时也有些细节要注意。并且代理模式和装饰模式(Decorator)以及适配器模式(Adapter)是不一样的,在使用上,应该先想清楚要运用的是哪一种模式。
资源Cache与享元模式(Flyweight)
ResourceAssetProxyFactory内部使用了一个Dictionary泛型容器,来管理已经加载过的Unity Asset资源。而Unity Asset资源在加入游戏场景时,会因为资源类型的不同而可能有不同的处理方式。
以3D模式的Unity Asset资源来说,在获取存放在管理容器内的资源时,必须再经过实例化(GameObject.Instance)的操作,才能将Unity Asset资源转换成游戏对象(GameObject)放入场景中,这与游戏属性管理功能-享元模式实现属性工厂(IAttrFactory)时所应用的享元模式(Flyweight)很类似。不同的是由享元模式管理的属性对象会被很多角色同时引用,但ResourceAssetProxyFactory类中管理的Unity Asset资源,经过实例化(GameObject.Instance)后,就会产生一个新的游戏对象,因此在管理容器内的Unity Asset资源不会被其他角色对象引用。但是,AudioClip和Sprite类型的资源却可以不经实例化(GameObject.Instance)的操作就可以被加入到游戏中播放或显示。针对这两种类型的资源采用的就是享元模式管理的方式,这样存放的UnityAsset资源就会被许多对象引用。
装饰模式(Decorator)与代理模式的差别
Proxy会知道代理的对象是哪个子类,并拥有该子类的对象,而Decorator则是拥有父类对象(被装饰对象)的引用。Proxy会按“职权”来决定是不是需要将需求转给原始类,所以Proxy有“选择”要不要执行原有功能的权利。但Decorator是一个“增加”的操作,必须在原始类被调用的之前或之后,再按照自己的职权“增加”原始类没有的功能。Decorator与Proxy的差异如图4所示。
图4 Decorator与Proxy的差异
适配器模式(Adapter)与代理模式的差异
Proxy类(图中的C)与原始类(图中的B)同属一个父类,所以客户端不需要做任何变动,只需决定是否要采用代理者。而Adapter中的Adaptee类(图中的B)及Target类(图中的C)则分属不同的类群组,着重在于“不同实现的转换”,Adapter与Proxy的差异如图5所示。
图5 Adapter与Proxy的差异
代理模式面对变化时
游戏上市前的优化阶段,重点在于找出系统性能的瓶颈点,因此会采取多种不同的测试方案来实现。应用代理模式的概念,可以将优化测试功能都增加在Proxy类中,既不影响原有系统的实现类,也可以了解各个优化功能的实现原理(因为都实现在Proxy类中),而优化的项目有时会因为各项目的属性不同而有所差异,可能在A项目发生的性能瓶颈不会发生在B项目中,所以保有原始类的实现,将优化功能独立出来,以便于不同项目之间转移应用。
笔者认为代理模式是非常好用的模式,主要是模式中的代理者可以担任多项任务,《P级阵地》因为系统架构相对简单,所以无法展现代理模式的强大功能,这是比较可惜的地方,但笔者会在最后一章中,介绍它还可以应用在游戏设计的哪些地方。
结论
代理模式的优点是:可判断是否要将原始类的工作交由代理者类来执行,如此则可以免去修改原始类的接口及实现。
其他应用方式
近年来,大型多人在线角色扮演游戏(MMORPG)在客户端(Client)多使用无接缝地图的实现,用以提升玩家对游戏的体验感。但在游戏服务器(Game Server)的实现上,还是会将整个游戏世界切分为数个区块,而每一个区块必须交由一个“地图服务器”来管理。当玩家在跨越两个地图服务器之间移动或进行打怪战斗时,就必须在邻近的地图服务器上建立一个“代理人”。地图服务器就利用这个“代理人”来同步与其他地图服务器之间的信息传送。
在网页游戏(Web Game)的应用上,由于网络资源下载的速度不一致,为了要让玩家体验更好的游戏顺畅感,对于画面上还没有被下载成功的“游戏资源”,如场景建筑物、NPC角色、角色装备道具,大多会使用一个“资源代理人”先呈现在画面上,让玩家知道当前有个游戏资源还在下载。如果游戏资源是个3D角色的话,那么多半会使用一个通用的角色模式来代表一个3D角色正在加载中。待游戏资源可以重新呈现时,就会直接使用原本的游戏资源类来显示。