思考并回答以下问题:
- 享元模式的官方定义是什么?
- Flyweight的中文是“轻量级”的意思,为什么用这个词命名这个模式?
- 享元就是共享本元的意思,怎么理解?
- 一定要把共同点抽取成父类和接口,这样的架构就是好架构。怎么理解?
- 属性指的是什么?IWeapon、IWeponAttr、ICharacter、ICharacterAttr接口的关系是什么?为什么要定义这么多接口?
- 要使用享元模式,就想到新建一个工厂类。怎么理解?
- 享元模式必须用吗?为什么?
- 怎么看客户端的代码就知道使用了享元模式?
本章涵盖:
- 游戏属性的管理
- 享元模式
- 享元模式的定义
- 享元模式的说明
- 享元模式的实现范例
- 使用享元模式实现游戏
- SceneState的实现
- 实现说明
- 使用享元模式的优点
- 享元模式的实现说明
- 享元模式面对变化时
- 结论
游戏属性的管理
在《P级阵地》中,除了双方角色使用“属性(生命力、移动速度)”作为能力区分外,武器系统也使用“武器属性(攻击力、攻击距离)”作为武器强度的区分,如图1所示。
图1 武器系统使用武器属性的示意图
事实上,一款游戏的可玩度和角色平衡,都需要针对这些属性精心设计及调整,游戏策划人员会通过“公式计算”或“实际测试”等方式找出最佳的游戏属性。而这些调整完成的游戏属性,在游戏系统中需要有一个管理方式来建立和存储它们,让其可以随着游戏的进行被游戏系统使用,如图2所示。
图2 各个系统获取属性的示意图
在前面章节中,我们已经定义了角色属性基础类(ICharacterAttr),以及记录双方角色攻守差异所需要的属性子类(SoldierAttr、EnemyAttr),在此列出相关程序代码,帮读者回顾一下:
Listing1 角色属性类
1 | // 角色属性接口 |
另外,在角色与武器的实现-桥接模式介绍桥接模式(Bridge)时,定义了武器接口类IWeapon。在当时的实现中,武器属性:攻击力(m_Atk)和攻击距离(m_Range)都是直接声明在武器接口中的:1
2
3
4
5
6
7
8
9// 武器接口
public abstract class IWeapon
{
// 属性
protected int m_AtkPlusValue = 0; // 额外增加的攻击力
protected int m_Atk = 0; // 攻击力
protected float m_Range = 0.0f; // 攻击距离
...
}
《P级阵地》中先仿照角色属性的设计方式,将“武器属性”部分从武器接口中独立出来:
Listing2 重构后的武器属性类
1 | public class WeaponAttr |
武器属性可以运用在任何一种武器类上,而且不会像角色属性那样具有攻守差异,所以单纯以一个类进行定义即可,暂不使用继承的方式来实现。更换后的武器接口类,将原有的属性以一个武器属性类(WeaponAttr)对象成员m_WeaponAttr进行取代,并提供对应的操作方法:
Listing3 新的武器接口
1 | public abstract class IWeapon |
重构后的角色(ICharacter)、角色属性(ICharacterAttr)、武器(IWeapon)、武器属性(IWeaponAtr)等类结构图的关系如图3所示。
图3 重构后的角色和武器等类的类结构图
在《P级阵地》的实现上,玩家阵营角色属性(SoldierAttr)、敌方阵营角色属性(EnemyAttr)及武器属性(WeaponAttr)3个属性类,都是由属性工厂(IAttrFactory)负责产生。而在实现时,通常会将一个属性类的对象,以一个唯一属性来代表,让其他系统可以利用这个数字,获取对应的属性对象。
所以,三种属性对象由属性工厂(IAttrFactory)产生,并由其他系统将获取的属性对象设置给有需要的游戏功能。让我们再来看一次在角色的组装-建造者模式中关于“角色属性设置”的程序代码,就能了解相关的流程:
Listing4 SoldierBuilder建造者类中设置角色属性
1 | // Soldier各个部位的建立 |
在EnemyBuilder建造者类中,设置敌人角色属性:
Listing5 EnemyBuilder建造者类中设置角色属性
1 | // Enemy各个部位的建立 |
武器工厂(WeaponFactory)产生武器时,也会获取武器属性设置给武器:
Listing6 设置武器属性
1 | // 武器工厂 |
所以,配合产生对象的属性工厂(IAttrFactory)会按照参数传入的“属性编号(AttrID)”来产生对应的属性对象,可能会使用下列方式实现:
Listing7 实现产生游戏用的属性
1 | public class AttrFactory : IAttrFactory |
使用switch case语句判断传入的属性编号(AttrID)后,产生对应的角色属性(SoldierAttr)对象,让每一个属性编号(AttrID)都能够正确对应一个属性对象。而switch case的最后也添加了default区段来防止未规划的编号产生对象。但过长的switch case语句会造成不易阅读的问题,而且游戏将来需要大量增加游戏属性数据时,这种实现方式会很难适应游戏后期的更改,并且比较容易造成程序代码过长、不易阅读等问题。
另外“两种角色属性”与“武器属性”的类对象,在应用上也有一些差异(如图4所示):
- 两个阵营的角色属性类(SoliderAttr、EnemyAttr):类成员中包含现在生命力(NowHP)、等级(Lv)等,这些是会随着游戏过程而改变的属性字段,所以必须针对每个角色类设置一组新的属性对象。但是角色的最大生命力(MaxHP)和移动速度(MoveSpeed)等属性,则是基本属性不会随游戏变动,只须保留一份对象即可。
- 武器属性类(WeaponAttr):类成员包含攻击力(Atk)与攻击距离(Range),这两个属性一经设置后就不会再更改,不会随着游戏过程而改变。也就是说,每一个编号对应的武器属性(WeaponAttr),只需要产生一个对象即可。
图4 非共享部分的属性与会随游戏时间演进而变化的属性
所以,有两个待解决的问题:
1、方便的管理游戏属性的方法,让产生的属性对象能够有好的管理架构,更加方便获取和设置。
2、共同的属性部分只能够维持一份,但随着(1)不断重新产生的角色属性(SoldierAttrEnemyAttr)和(2)只需维持一个武器属性(WeaponAttr)对象,需要有不同的设置和替换方式。
针对上述两个问题,属性工厂(IAttrFactory)可用享元模式来解决。
享元模式
享元模式是用来解决“大量且重复的对象”的管理问题,尤其是程序设计师最常忽略的“虽小但却大量重复的对象”。随着计算机设备的升级,程序设计师渐渐遗忘了在内存受限制环境下,对每一个字节(Byte)都很计较的程序编写方式。但近几年来,由于移动设备App的兴起,有大小限制的内存环境又成为程序设计师必须考虑的设计条件之一,善用享元模式可以解决大部分对象共享的问题。进一步参考享元模式。
享元模式的定义
GoF中享元模式(Flyweight)的定义是:1
使用共享的方式,让一大群小规模对象能更有效地运行。
定义中的两个重点:“共享”与“一大群小规模对象”。
首先,“一大群小规模对象”指的是:虽然有时候类的组成很简单,可能只有几个类型为int的类成员,但如果这些类成员的属性是相同而且可以共享的,那么当系统产了一大群类的对象时,这些重复的部分就都是浪费的,因为它们只需要存在一份即可,如图5所示。
图5 大量重复的共享空间
而“共享”指的是使用“管理结构”来设计信息的存取方式,让可以被共享的信息,只需要产生一份对象,而这个对象能够被引用到其他对象中,如图6所示。
图6 使用共享的方式来使用重复的数据
但必须注意的是,既然可以被多个对象“共享”,那么对于共享对象的“修改”就必须加以限制,因为被多个对象共享之后,任何更改共享对象中的属性,都可能导致其他引用对象的错误。
因此在设计上,对象中那些“只能读取而不能写入”的共享部分被称为“内在(intrinsic)状态”,就如前一节中提到的最大生命力(MaxHP)、移动速度(MoveSpeed)、攻击力(Atk)、攻击距离(Range)这些值。而对象中“不能被共享”的部分,如当前的生命力(NowHP)、等级(LV)、爆击率(CritRate)等,这些属性会随着游戏运行的过程而变化,则称为“外在(extrinsic)状态”。
享元模式提供的解决方案是:产生对象时,将能够共享的“内在(intrinsic)状态”加以管理,并且将属于各对象能自由更改的“外部(extrinsic)状态”也一起设置给新产生的对象中。
享元模式的说明
享元模式的结构如图7所示。
图7 采用享元模式时的类结构示意图
GoF参与者的说明如下:
- FlyweightFactory(工厂类)
- 负责产生和管理Flyweight的组件。
- 内部通常使用容器类来存储共享的Flyweight组件。
- 提供工厂方法产生对应的组件,当产生的是共享组件时,就加入到Flyweight管理容器内。
- Flyweight(组件接口)
- 定义组件的操作接口
- ConcreteFlyweight(可以共享的组件)
- 实现Flyweight接口。
- 产生的组件是可以共享的,并加入到Flyweight管理器中。
- UnsharedConcreteFlyweight(不可以共享的组件)
- 实现Flyweight接口,也可以选择不继承自Flyweight接口。
- 可以定义为单独的组件,不包含任何共享资源。
- 也可以将一些共享组件定义为类的成员,成为内部状态;并另外定义其他不被共享的成员,作为外部状态使用。
享元模式的实现范例
先定义Flyweight(组件接口):
Listing8 可以被共享的Flyweight接口(Flyweight.cs)
1 | public abstract class Flyweight |
在类声明中,包含了一个m_Content成员,用来代表共享的信息。
ConcreteFlyweight实现Flyweight接口,用来代表之后要被共享的组件:
Listing9 共享的组件(Flyweight.cs)
1 | using UnityEngine; |
而UnsharedConcreteFlyweight,则是用来代表一个包含共享资源和不共享资源的类:
Listing10 不共享的组件(可以不必继承)(Flyweight.cs)
1 | public class UnsharedConcreteFlyweight //: Flyweight |
在不使用继承的实现方式下,利用组合的方式声明了一个可指向共享组件的引用m_Flyweight,并且定义了由自己维护的不共享的信息成员m_UnSharedContent,并提供方法SetFlyweight来设置共享的组件给类对象。工厂类FlyweightFactory则提供了管理容器和3个工厂方法来产生各种组合方式的对象:
Listing11 负责产生Flyweight的工厂接口(Flyweight.cs)
1 | public class FlyweightFactory |
在工厂类FlyweightFactory的内部,使用C#的泛型容器Dictionary类来管理共享组件,应用Dictionary类的Key-Value对应方式,可以确保组件的唯一性,即使用一个Key值来代表一个共享组件(Value),相同的Key不可能对应到两个共享组件,所以只要利用相同的Key值来获取Dictionary内的组件,即保证返回的是同一个共享组件。
在GetFlyweight方法中,会先判断Dictionary内是否已包含Key,若已产生过了,则返回现有Key值所对应的Flyweight组件;如果Key值不存在,则产生一个新的共享组件并返回。
在测试范例中,先产生组件工厂,接着产生3个共享组件:
Listing12 测试享元模式(FlyweightTest.cs)
1 | void UnitTest() |
信息窗口上,会反应产生的3个共享组件:
执行结果
1 | New ConcreteFlyweight Key[1] Content[共享组件1] |
之后获取共享组件:1
2
3// 获取一个共享组件
Flyweight theFlyweight = theFactory.GetFlyweight("1", "");
theFlyweight.Operator();
虽然第二个字段并未设置任何信息,但由于是共享组件的关系,所以会获取之前已经产生Key值为“1”的组件:1
ConcreteFlyweight.Content[共享组件1]
之后测试产生一个非共享组件。获取对象后,调用Operator方法来显示它的外部状态(非共享的信息)。1
2
3// 产生不共享的组件
UnsharedConcreteFlyweight theUnshared1 = theFactory.GetUnsharedFlyweight("不共享的信息1");
theUnshared1.Operator();
信息显示为:1
UnsharedConcreteFlyweight.Content[不共享的信息1]
后续将共享组件theFlyweight1设置给theUnshared1,接着产生theUnshared2。不过,这次直接指定要共享的组件“1”:1
2
3
4
5
6
7
8
9// 设置共享组件
theUnshared1.SetFlyweight(theFlyweight);
// 产生不共享的组件2,并指定使用共享组件1
UnsharedConcreteFlyweight theUnshared2 = theFactory.GetUnsharedFlyweight("1", "", "不共享的信息2");
// 同时显示
theUnshared1.Operator();
theUnshared2.Operator();
最后,同时显示两个非共享组件,输出的信息是:1
2UnsharedConcreteFlyweight.Content[不共享的信息1]包含了: 共享组件1
UnsharedConcreteFlyweight.Content[不共享的信息2]包含了: 共享组件1
而共享组件1在整个测试程序中始终只维持一个,最后的内存示意图如图8所示。
图8 测试程序最后的内存示意图
使用享元模式实现游戏
在理解了享元模式后,接下来,让我们将这个模式运用到《P级阵地》游戏中。
SceneState的实现
将《P级阵地》中的属性工厂运用享元模式时,需要先将现有的程序代码重构一下,分析现有的角色属性类ICharacterAttr,就会发现其中不会改动的角色属性有:
- 最大生命力(MaxHP)、移动速度(MoveSpeed)、属性名称(AttrName)等;
- 敌方阵营角色属性类(EnemyAttr)中的爆击率(InitCritRate)初始值。
以上是不会改动的属性,应该成为共享组件(类),成为属性类的内在状态。而随着游戏进行会变动的属性有:
- 角色的现有生命力(NowHP),被攻击时会减少;
- 玩家阵营角色属性类(SoldierAttr)中,等级(SoldierLv)及生命力加成(AddMaxHP),会随兵营等级提升而改变角色的等级;
- 敌方阵营角色属性类(EnemyAttr)中,爆击率(CritRate)现值,会因为成功发生爆击之后而减半。
这些会变动的属性将成为外在状态,保留在各自的对象之中互不影响,所以先将这共享的部分从原有的角色属性类(ICharacterAttr)中独立出来,成为一个新的类:
Listing13 可以被共享的基本角色属性(BaseAttr.cs)
1 | public class BaseAttr |
该类只包含基本属性字段和获取信息的方法,除了通过建造者之外,没有任何方式可以设置这3个属性。
针对角色属性对象的产生,下面是属性工厂IAttrFactory在运用享元模式后的结构,如图9所示。
图9 属性工厂IAttrFactory在运用享元模式后的类结构图
参与者的说明如下:
- BaseAttr:定义角色属性中,不会变更可共享的部分。
- EnemyBaseAttr:敌方阵营的角色有爆击率的功能,用来强化攻击时的优势,按照游戏设计需求,必须开放给策划作设置,所以用一个新类来增加这个设置,而不在BaseAttr中增加。
- ICharacterAttr、SoldierAttr、EnemyAttr:定义角色属性中,会按游戏执行而变化的部分,属于各角色对象自己管理的一部分。
- SoldierBuilder、EnemyBuilder:双方阵营角色的建造者,实际运行时,会调用属性工厂AttrFactory的方法来获取角色属性对象。
- AttrFactory:属性工厂,定义了两个Dictionary容器,用来管理唯一的BaseAttr和EnemyBaseAttr组件。
武器属性的部分比较简单,只有共享的类WeaponAttr,而没有变动的部分,如图10所示。
图10 武器属性部分的类结构图
参与者的说明如下:
- WeaponAttr:定义武器属性中不会变更可以共享的部分。
- IWeapon:武器类中,声明了一个引用,用来指向共享的武器属性。
- AttrFactory:属性工厂,定义了Dictionary容器用来管理唯一的WeaponAttr。
- WeaponFactory:武器工厂在实际运行时,会调用属性工厂AttrFactory的方法来获取武器属性对象,然后继续产生武器对象的步骤。
实现说明
将共享部分独立出去后的角色属性类ICharacterAttr,除了增加BaseAttr的类引用成员外,也将相关的角色属性存取方法做了一些改动,改为存取存储在BaseAttr引用对象的属性:
Listing14 角色属性接口(ICharacterAttr.cs)
1 | public abstract class ICharacterAttr |
ICharacterAttr子类也重新定义了几个方法,将其定义为虚函数(Virtual Function)提供给两个子类重新实现,以达到特殊化的目的:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75// Soldier属性
public class SoldierAttr : ICharacterAttr
{
protected int m_SoldierLv; // Soldier等级
protected int m_AddMaxHP; // 因为等级新增的HP值
public SoldierAttr()
{}
// 设置角色属性
public void SetSoldierAttr(BaseAttr BaseAttr)
{
// 共享组件
base.SetBaseAttr( BaseAttr );
// 外部参数
m_SoldierLv = 1;
m_AddMaxHP = 0;
}
// 设置等级
public void SetSoldierLv(int Lv)
{
m_SoldierLv = Lv;
}
// 获取等级
public int GetSoldierLv()
{
return m_SoldierLv ;
}
// 最大HP
public override int GetMaxHP()
{
return base.GetMaxHP() + m_AddMaxHP;
}
// 设置新增的最大生命力
public void AddMaxHP(int AddMaxHP)
{
m_AddMaxHP = AddMaxHP;
}
} // SoldierAttr.cs
// Enemy属性
public class EnemyAttr : ICharacterAttr
{
protected int m_CritRate = 0; // 暴击概率
public EnemyAttr()
{}
// 设置角色属性(包含外部参数)
public void SetEnemyAttr(EnemyBaseAttr EnemyBaseAttr)
{
// 共享组件
base.SetBaseAttr( EnemyBaseAttr );
// 外部参数
m_CritRate = EnemyBaseAttr.GetInitCritRate();
}
// 暴击率
public int GetCritRate()
{
return m_CritRate;
}
// 减少暴击率
public void CutdownCritRate()
{
m_CritRate -= m_CritRate/2;
}
} // EnemyAttr.cs
两个子类中,包含因游戏执行而会变化的属性:等级(SoliderLv)、新增的生命力(AddMaxHP)、爆击概率(CritRate)等,并提供方法让外界能够修改它们。
运用享元模式的属性工厂AttrFactory,包含了3个Dictionary容器,分别来管理记录游戏中的3个属性对象:
Listing15 实现产生游戏用的属性(AttrFactory.cs)
1 | public class AttrFactory : IAttrFactory |
在属性方法的构造函数中,分别调用了3个初始函数,这3个初始函数分别将当前游戏中会使用的角色属性和武器属性先加入到管理容器内,让后续的工厂方法能够在产生属性对象的过程中,获取对应的共享属性对象:
Listing16 实现产生游戏用的属性(AttrFactory.cs))
1 | public class AttrFactory : IAttrFactory |
获取角色属性的工厂方法(GetSoldierAttr、GetEnemyAttr),都是先判断指定的基本属性是否存在,如果没有,就会显示提示信息(可以要求实现或测试人员注意相关信息,并且了解信息发生的原因);如果存在,则先产生对应的角色属性(SoldierAttr、EnemyAttr)对象,将共享的属性设置给新生的对象,作为内在状态,而每一个新产生的角色属性对象,都各自拥有外在状态的属性,供游戏执行运算时使用,如图11所示。
图11 新版的角色使用共享的属性对象
使用享元模式的优点
新版运用享元模式的属性工厂AttrFactory,将属性设置集以更简短的格式呈现,免去了使用switch case的一长串语句,方便策划人员阅读和设置。此外,因为共享属性的部分(BaseAttr),每一个编号对应的属性对象,在整个游戏执行中只会产生一份,不像旧方法那样会产生重复的对象而增加内存的负担,对于游戏性能有所提升。
享元模式的实现说明
就笔者的经验来说,享元模式在游戏开发领域中,最常被应用到的地方就是属性系统。每一款游戏无论规模大小,都需要属性系统协助调整游戏平衡,如角色等级属性、装备属性、武器属性、宠物属性、道具属性等,而每一种属性设置数据又可能多达上百或上千之多,当这些属性设置都成为对象并存在游戏之中时,即符合了享元模式定义中所说的“一大群小规模对象”,每一项属性可能只包含3、4个字段,也可能包含多达数十个字段,若不采用“共享”的方式管理,很容易造成系统的问题和实现上的困难,应用上也会产生相关的问题。游戏开发在实现属性系统时,会遇到的问题及解决方式如下:
有大量属性设置数据时
因为在《P级阵地》中,使用的属性设置并不多,所以将属性设置直接写在程序代码中,但若是就一般中小规模的网络在线型游戏或移动平台游戏而言,需要使用的游戏设置数据通常达到数百笔以上,假设还是采取像本章范例那样,直接写在程序代码中的话,就不是一种好的实现方式。最好的实现方式是,将这些属性设计分开建档,属性工厂改为读取各个文件,将每一笔属性数据取出,再建立共享属性组件。
加载的时间过长
当属性设置从文件读入时,会因为属性设置存放的格式(Json、XML…)及反串行化的工具而有性能上的差异,尤其是在手机等移动平台上执行时,过多的设置数据在手机平台读入时可能需要花到1秒的时间,但当所有配置文件加起来一起读入,可能会花去数十秒的时间,此时若不想让玩家等待太久才进入游戏,那么属性的初始化就可以用“延后初始化(Lazy Initialization)”的策略。就是当某一个属性需要被使用到时,才执行读入属性设置的操作,这样就可省去前期加载的时间,另外玩家不见得会使用到每一个道具,当角色身上只使用5、6个道具时,只需要初始化这5、6个道具的属性,这样对于应用程序的内存使用,也会有明显的优化效果。
直接记录Key值,不记录Reference
通常如果已经知道属性使用的Key值,那么在使用的类中,只记录这个Key即可,当真正的属性需要被使用时,才通过这个Key值,去向工厂类获取共享的对象。不过这样一来,由于每次计算都需要重新查询,所以会增加系统计算的时间,这也是使用这个方式时的缺点。故而,可以按照游戏系统设计的不同,来决定记录属性对象的方式。
若是采用记录Key值的实现方式,“属性工厂(AttrFactory)”就会扮演另一个角色“游戏设置数据库”,任何与游戏有关的属性设置,都可以通过对数据库进行查询来获取属性,而就笔者多年的开发经验来说,习惯上,会在游戏设计一开始先将这个数据库的存取架构、数据加载及属性管理设置等功能完成,因为这是游戏开发中策划最常接触到的一部分,也就是“策划开发工具”中所需要的功能之一,如图12所示。
图12 大量属性数据设置及加载流程图(策划开发工具->文件/DataBase->工具读入)
享元模式面对变化时
当游戏属性系统以《P级阵地》的属性工厂方式实现时,对于游戏中属性的调整,就变得非常直观和方便。由于《P级阵地》的属性不多,策划在调整时,只需修改AttrFactory.cs程序代码当中各个初始化方法的部分,即可完成修改。
但若项目的设置数据较多时,则建议采用策划工具输出成文件的方式。这样一来,对于属性的修改,将不会牵扯到任何程序代码的修改,只需要设置好新的属性,产生新的属性数据文件,让游戏重新读入即可完成修改和调整。
对于属性字段需要新增时,只要进行下列调整即可:
- 在类下新增属性字段;
- 增加反串行化需要的程序代码;
- 策划工具新增属性字段和串行化操作。
经过上述调整后,就可以将新增的属性字段应用在游戏实现上。
结论
将有可能散布在程序代码中,零碎的游戏属性对象进行统一管理,是享元模式应用在游戏开发领域带来的好处之一。
与其他模式的合作
每一个阵营的角色建造函数(Builder),在设置武器属性和角色属性时,都会通过属性工厂(AttrFactory)来获取属性,而这些属性则是使用享元模式产生的,在游戏的执行过程中只会存在一份。
其他应用方式
在射击游戏中,画面上出现的子弹或导弹,大多会使用“对象”的方式来代表。而为了让游戏系统能够有效地产生和管理这些子弹、导弹对象,可使用享元模式来建立子弹对象池(Bullet Object Pool),让其他游戏系统也使用对象池内的子弹,减少因为重复处理产生子弹对象、删除子弹对象所导致的性能损失。