获取游戏服务的唯一对象--单例模式

思考并回答以下问题:

  • 单例模式的官方定义是什么?
  • 什么时候使用单例模式?
  • 管理类要使用单例模式吗?为什么?
  • 单例继承是什么意思?

本章涵盖:

  • 游戏实现中的唯一对象
  • 单例模式
    • 单例模式的定义
    • 单例模式的说明
    • 单例模式的实现范例
  • 使用单例模式获取唯一的游戏服务对象
    • 游戏服务类的单例模式实现
    • 实现说明
    • 使用单例模式后的比较
    • 反对使用单例模式的原因
  • 少用单例模式时如何方便地引用到单一对象
  • 结论

游戏实现中的唯一对象

生活中的许多物品都是唯一的,地球是唯一的、太阳是唯一的等。软件设计上也会有唯一对象的需求,例如:服务器端的程序只能连接到一个数据库、只能有一个日志产生器;游戏世界也是一样的,同时间只能有一个关卡正在进行、只能连线到一台游戏服务器、只能同时操作一个角色(RTS不是)等。

在《P级阵地》中,也存在唯一的对象,例如游戏主要类--外观模式提到的,用来包含所有游戏子系统的PBaseDefenseGame类,它负责游戏中几个主要功能之间的串接,外界通过它的接口就能存取《P级阵地》的主要游戏服务,可以称这个PBaseDefenseGame类为“游戏服务”的提供者。因为它提供了运行这个游戏所需要的功能,所以PBaseDefenseGame类的对象只需要一个,并且由这个唯一的对象来负责游戏的运行。另外,PBaseDefenseGame类实现了外观模式(Facade),包含了游戏中大部分的操作接口。因此,在实际应用上,会希望有一种方法能够快速获取这个唯一的对象。在实现上,程序设计师会希望这个PBaseDefenseGame类具备两项特质:

(1)同时间只存在一个对象;
(2)提供一个快速获取这个对象的方法。

如果使用比较直接的方式来实现,可能会使用程序设计语言中的“全局静态变量”功能来满足上述两项需求。若以C#来编写的话,可能会是如下这样:

Listing 1 使用全局静态对象的实现方式

1
2
3
4
5
public static class GlobalObject
{
public static PBaseDefenseGame GameInstance = new PBaseDefenseGame();
}
GlobalObject.GameInstance.Update(); // 使用方法

在一个静态类中,声明类对象为一个静态成员,这样实现的方式虽然可以满足“容易获取对象”的需求,但无法避免刻意或无意地产生第二个对象,而且像这种使用全局变量的实现方式,也容易产生全局变量命名重复的问题。

所以,最好的实现方式是,让PBaseDefenseGame类只产生一个对象,并提供便利的方法来获取这唯一的对象。GoF中的单例模式讲述的就是如何满足上述需求的模式。

单例模式

单例模式是笔者过去常使用的设计模式,单例模式确实令人“着迷”,因为它能够让程序设计师快速获取提供某项服务或功能的对象,可以省去层层传递对象的困扰。

单例模式的定义

单例模式(Singleton)在GoF中的定义是:

1
确认类只有一个对象,并提供一个全局的方法来获取这个对象。

单例模式在实现时,需要程序设计语言的支持。只要具有静态类属性、静态类方法和重新定义类构造函数存取层级(private)这3项语句功能的程序设计语言,就可以实现出单例模式。C#具备这3项条件,可以用来实现单例模式。

不过单例模式也是许多设计模式推广人士不建议大量使用的模式,详细的原因会在本章后面加以说明。

单例模式的说明

单例模式的结构如图1所示。

图1 单例模式的结构示意图

Singleton参与的角色说明如下:

  • 能产生唯一对象的类,并且提供“全局方法”让外界可以方便获取唯一的对象。
  • 通常会把唯一的类对象设置为“静态类属性”。
  • 习惯上会使用Instance作为全局静态方法的名称,通过这个静态函数可能获取“静态类属性”。

单例模式的实现范例

C#支持实现单例模式的特性,以下是使用C#实现的方式之一:

Listing 2 单例模式的实现范例(Singleton.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton
{
public string Name {get; set;}

private static Singleton _instance;
public static Singleton Instance
{
get {
if (_instance == null)
{
Debug.Log("产生Singleton");
_instance = new Singleton();
}
return _instance;
}
}

private Singleton(){}
}

在类内定义一个Singleton类的“静态属性成员”_instance,并定义一个“静态成员方法”Instance,用来获取_instance属性。这里也应用了C#的getter存取运算符功能来实现Instance方法,让原本Singleton.Instance()的调用方式可以改为Singleton.Instance方式,虽然只是少了对小括号(),但以笔者的开发经验来说,少打一对小括号对于程序编写及后续维护上仍有不少的帮助。

Instance的getter存取运算符中,先判断_instance是否已被产生过,如果没有,才继续下面的new Singleton(),之后再返回_instance。

最后,将构造函数Singleton()声明为私有成员,这个声明主要是让构造函数Singletion()无法被外界调用。一般来说,有了这个声明就可以确保该类只能产生一个对象,因为构造函数是私有成员无法被调用,因此可以防止其他客户端有意或无意地产生其他类对象。

打开测试类SingletonTest,测试程序代码如下:

Listing 3 单例模式测试方法(SingletonTest.cs)

1
2
3
4
5
6
7
8
9
10
void UnitTest()
{
Singleton.Instance.Name = "Hello";
Singleton.Instance.Name = "World";
Debug.Log(Singleton.Instance.Name);

//Singleton TempSingleton = new Singleton();
/*错误error CS0122:
`DesignPattern_Singleton.Singleton.Singleton()` is inaccessible due to its protection level */
}

在范例中,分别使用Singleton.Instance来获取类属性Name,从输出信息中可以看到:

执行结果:产生Singleton测试范例产生的信息

1
World

使用两次Singleton.Instance只会产生一个对象,从Name属性最后显示的是World也可以证实存取的是同一个对象。

测试程序代码,最后试着再产生另一个Singleton对象:

执行结果 再产生另一个Singleton对象时,产生的错误信息

1
error CS0122: 'DesignPattern_Singleton.Singleton.Singleton()' is inaccessible due to its protection level.

但从C#编译报错的信息可以看出,构建式Singleton()是在保护阶段,无法被调用,所以无法产生对象。

使用单例模式获取唯一的游戏服务对象

游戏系统中哪些类适合以单例模式实现,必须经过挑选,至少要确认的是,它只能产生一个对象且不能够被继承。笔者过去的许多经验中,都会遇到必须将原本是单例模式的类改回非单例模式,而且还必须开放继承的情况。强制修改之下会使得程序代码变得不易维护,所以分析上需要多加注意。

游戏服务类的单例模式实现

在《P级阵地》中,因为PBaseDefenseGame类包含了游戏大部分的功能和操作,因此希望只产生一个对象,并提供方便的方法来取用PBaseDefenseGame功能,所以将该类运用单例模式,设计如图2所示。

图2 游戏服务类的单例模式实现示例

参与者的说明如下:

  • PBaseDefenseGame
    • 游戏主程序,内部包含了类型为PBaseDefenseGame的静态成员属性_instance,作为该类唯一的对象。
    • 提供使用C# getter实现的静态成员方法Instance,用它来获取唯一的静态成员属性_instance。
  • BattleState
    • PBaseDefenseGame类的客户端,使用PBaseDefenseGame.Instance来获取唯一的对象。

实现说明

在《P级阵地》范例中,只针对PBaseDefenseGame类运用单例模式,实现方式如下:

Listing 4 将游戏服务类以单例模式实现(PBaseDefenseGame.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class PBaseDefenseGame
{
// Singleton模式
private static PBaseDefenseGame _instance;
public static PBaseDefenseGame Instance
{
get
{
if(_instance == null)
_instance = new PBaseDefenseGame();
return _instance;
}
}
...
private PBaseDefenseGame()
{}
}

按照之前说明的步骤,实现时先声明一个PBaseDefenseGame类的静态成员属性_instance,同时提供一个用来存取这个静态成员属性的“静态成员方法”Instance。在静态成员方法中,实现时必须确保只会有一个PBaseDefenseGame类对象被产生出来。最后,将构造函数PBaseDefenseGame()设置为私有成员。

在实际应用中,直接通过PBaseDefenseGame.Instance获取对象,立即可以使用类功能:

Listing 5 战斗状态中以单例的方式使用PBaseDefenseGame对象(BattleState.cs)

1
2
3
4
5
6
7
8
9
10
public class BattleState : ISceneState
{
...
// 开始
public override void StateBegin()
{
PBaseDefenseGame.Instance.Initinal();
}
...
}

在《P级阵地》中,除了BattleState类会使用到PBaseDefenseGame对象之外,在后续的说明中也会看到其他类的使用情况。以下是另一个使用的例子:

Listing 6 兵营用户界面中以单例的方式使用PBaseDefenseGame对象(SoldierClickScript.cs)

1
2
3
4
5
6
7
8
9
public class SoldierOnClick: MonoBehaviour
{
...
public void OnClick()
{
// Debug.Log("CharacterOnClick.OnClick:" + gameObject.name);
PBaseDefenseGame.Instance.ShowSoldierInfo(Soldier);
}
}

在SoliderOnClick中完全不需要设置PBaseDefenseGame对象的引用来源,直接调用PBaseDefenseGame.Instance就可以马上获取对象并调用类方法。

使用单例模式后的比较

对于需要特别注意“对象产生数量”的类,单例模式通过将“类构造函数私有化”,让类对象只能在“类成员方法”中产生,再配合“静态成员属性”,在每一个类中只会存在一个的限制,让系统可以有效地限制产生数量(有需要时可以放宽一个的限制)。在两者配合下,单例模式可以有效地限制类对象产生的地点和时间,也可以防止类对象被任意产生而造成系统错误。

反对使用单例模式的原因

按照笔者过去的开发经验,单例模式好用的原因之一是:可以马上获取类对象,不必为了“安排对象传递”或“设置引用”而伤脑筋,想使用类对象时,调用类的Instance方法就可以马上获取对象,非常方便。

如果不想使用单例模式或全局变量,最简单的对象引用方式就是:将对象当成“方法参数”,一路传递到最后需要使用该对象的方法中。但此时若存在设计不当的程序代码,那么方法的参数数量就会容易失控而变多,造成难以维护的情况。

而程序设计师一旦发现这个“马上获取”的好处时,就很容易在整个项目中看到许多单例模式的应用(包含实现与调用),这种情况使开发者过于沉迷于使用单例模式。

很不幸,笔者在过去的开发经验中也有过这个“症状”,大多是为了想“省略参数传递”及“能够快速获取唯一对象”等原因。所以在实现上,只要发现“游戏子系统类”或“用户界面类”的对象,在整个游戏运行中是唯一时,就会将单例模式运用在该类上,因此项目内处处可见标示为Singleton的类。

单例模式之所以被滥用,是开发时过度使用“全局变量”及不仔细思考对象的“适当可视性”所造成的产物,因此这是可以避免的。下面提供另一种模型来避开使用单例模式。

归咎滥用单例模式的主要原因,多数还是认为是在设计上出现了问题。大多数情况是不需要使用单例模式的,开发者只需要再多花点时间重新思考、更改设计,就可避免使用。

再深入探讨的话,单例模式还违反了“开—闭原则(OCP)”。因为,通过Instance方法获取对象是“实现类”而不是“接口类”,该方法返回的对象包含了实现细节的实体类。因此,当设计变更或需求增加时,程序设计师无法将其替代为其他类,只能更改原有实现类内的程序代码,所以无法满足“对修改关闭”的要求。

当然,如果真的要让单例模式返回接口类——即父类为单例模式类型,并让子类继承实现,并不是没有办法,有以下两种方式可以实现:

  • 子类向父类注册实体对象,让父类的Instance方法返回对象时,按条件查表返回对应的子类对象。
  • 每个子类都实现单例模式,再由父类的Instance去获取这些子类。(《P级阵地》采用类似的方式来实现)。

不过“返回子类的单例模式的对象”有时会引发“白马非马”的逻辑诡辩问题——返回的对象是否就能代表父类呢?举一个实例来说明会发生逻辑诡辩的设计方式:

今天服务器端的系统有项设计需求,需要连线到某一个数据库服务,并要求同时只能存在一个连接。程序设计师们经过设计分析后,决定使用单例模式只能产生唯一对象的特性,来满足只能存在一条连接的需求。接着定义数据库连接操作的接口,并运用单例模式,如图3所示。

图3 实现数据库服务唯一连接的单例模式示例

又因为服务器端可以支持MySQL和Oracle这两种数据库连接,所以定义两个子类,并实现“子类向父类注册实体对象”的方式,让IDBConnect.Instance()方法可以返回对应的子类,如图4所示。

图4 实现“子类向父类注册实体对象”的方式

客户端现在可以按当前的设计结果,获取某一种数据库的连接对象,并同时确保只存在一条连接。

经过多次需求追加后,服务器数据库功能的操作需求也增加了,这次希望的需求是:每次的数据库操作能够被记录下来,即当数据库完成操作后必须将操作记录写入“日志数据库”中。但由于“日志数据库”具有“只写不读”的特性,在实现上会选择再启用另一条连接,连接到另一组数据库(有针对“只写不读”特性进行优化的数据库),这样除了可以减少每次操作记录写入时的延迟,也不会增加主数据库的负担。

所以,如果要在不更改原有接口的要求下实现新的功能,最简单的方式就是再从MySQLConnect和OracleConnect各自继承一个子类,并在子类中增加另一条“日志数据连接”和日志操作方法,类结构如图5所示。

图5 从MySQLConnect和OracleConnect各自继承一个子类后的类结构图

现在获取的MySQLConnectWithLog或OracleConnectWithLog对象,是否还是IDBConnect对象?从IDBConnect的设计需求来看:

  • 数据库连接——有;
  • 只能有一个对象——有;
  • 一个对象代表一条连接——没有。

因为有两条连接存在……,所以MySQLConnectWithLog还是IDBConnect对象吗?

赞同方会说:“因为单例模式负责产生的对象只有一个,不会去管数据库的连接数量,所以还是单例对象”;反对方则会说:“当初就是希望利用单例模式只能产生唯一对象的特性,来限制数据库的同时连接数,现在子类却有两条连接,所以当然不是”……,白马是不是马的辩论就这样产生了。而笔者认为,花点时间修改原有的设计,让这种辩论消失才是真正解决的方式。

少用单例模式时如何方便地引用到单一对象

单例模式包含两个重要特性:唯一的对象和容易获取对象。那么要如何减少单例模式的使用呢?可以从分析类的“使用需求”开始,先确认程序设计师在使用这个类时,是希望同时获得上述两个好处还是只需要其中一个。若是只需要其中一个,那么下面几种方式可以用来设计系统。

让类具有计数功能来限制对象数量

在有数量限制的类中加上“计数器”(静态成员属性)。每当类构造函数被调用时,就让计数器增加1,然后判断有没有超过限制的数量,如果超过使用上限,那么该对象就会被标记为无法使用,后续的对象功能也不可以被执行。适当地在类构造函数中加入警告或Assert,也有助于调试分析,范例程序如下:

Listing 7 有计数功能的类(ClassWithCounter.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ClassWithCounter
{
protected static int m_ObjCounter = 0;
protected bool m_bEnable = false;

public ClassWithCounter()
{
m_ObjCounter++;
m_bEnable = (m_ObjCounter = 1 ) ? true: false;

if( m_bEnable == false)
{
Debug.LogError("当前对象数["+m_ObjCounter+"]超过1个!!");
}
}

public void Operator()
{
if( m_bEnable == false)
return ;
Debug.Log("可以执行");
}
}

Listing 8 有计数功能类的测试方法(SingletonTest.cs)

1
2
3
4
5
6
7
8
9
10
11
void UnitTest_ClassWithCounter() 
{
// 有计数功能的类
ClassWithCounter pObj1 = new ClassWithCounter();
pObj1.Operator();

ClassWithCounter pObj2 = new ClassWithCounter();
pObj2.Operator();

pObj1.Operator();
}

设置成为类的引用,让对象可以被取用

某个类的功能被大量使用时,可以将这个类对象设置为其他类中的成员,方便直接引用这些类。而这种实现方法是“依赖性注入”的方式之一,可以让被引用的对象不必通过参数传递的方式,就能被类的其他方法引用。按照设置的方式又可以分为“分别设置”和“指定类静态成员”两种。

1.分别设置

在《P级阵地》中,PBaseDefenseGame是最常被引用的。虽然已经运用了单例模式,但笔者还是以此来示范如何通过设置它成为其他类引用的方式,来减少对单例模式的使用。

由于在《P级阵地》中,每个游戏子系统都会使用PBaseDefenseGame类的功能,所以在各个游戏系统初始化设置时,就将PBaseDefenseGame对象指定给每一个游戏系统,并让游戏系统设置为类成员。那么,后面若有游戏系统的方法需要使用PBaseDefenseGame的功能时,就可以直接使用这个类成员来调用PBaseDefenseGame的方法:

Listing 9 将PBaseDefenseGame设置为其他类中的对象引用

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
public class PBaseDefenseGame
{
...
// 初始化P-BaseDefense游戏相关设置
public void Initinal()
{
...
// 游戏系统
m_GameEventSystem = new GameEventSystem(this); // 游戏事件系统
m_CampSystem = new CampSystem(this); // 兵营系统
m_StageSystem = new StageSystem(this); // 关卡系统
...
}
} // PBaseDefenseGame.cs

// 游戏子系统共享接口
public abstract class IGameSystem
{
protected PBaseDefenseGame m_PBDGame = null;
public IGameSystem(PBaseDefenseGame PBDGame)
{
m_PBDGame = PBDGame;
}
public virtual void Initialize(){}
public virtual void Release(){}
public virtual void Update(){}
} // IGameSystem.cs

// 兵营系统
public class CampSystem : IGameSystem
{
...
public CampSystem (PBaseDefenseGame PBDGame) : base (PBDGame)
{
Initialize();
}
...
// 显示场景中的俘兵营
public void ShowCaptiveCamp ()
{
m_CaptiveCamps[ENUM_Enemy.Elf].SetVisible(true);
m_PBDGame.ShowGameMsg("获得俘兵营");
}
...
} // CampSystem.cs

在上面的范例中,兵营系统的构造函数将传入的PBaseDefenseGame对象设置类成员m_PBDGame,并在有需求时(ShowCaptiveCamp)通过m_PBDGame来调用PBaseDefenseGame的方法。

2.指定类的静态成员

A类的功能中若需要使用到B类的方法,并且A类在产生其对象时具有下列几种情况:

  • 产生对象的位置不确定;
  • 有多个地方可以产生对象;
  • 生成的位置无法引用到;
  • 有众多子类。

当满足上述情况之一时,可以直接将B类对象设置为A类中的“静态成员属性”,让该类的对象都可以直接使用:

Listing 10 将PBaseDefenseGame设置为类的静态引用成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PBaseDefenseGame
{
...
// 初始P-BaseDefense游戏相关设置
public void Initinal()
{
m_StageSystem = new StageSystem(this); // 关卡系统
...
// 注入其他系统
EnemyAI.SetStageSystem(m_StageSystem);
...
}
...
} // PBaseDefenseGame.cs

举例来说,敌方单位AI类(EnemyAI),在运行时需要使用关卡系统(StageSystem)的信息,但EnemyAI对象产生的位置是在敌方单位建造者(EnemyBuilder)之下:

Listing 11 Enemy各部位的建立

1
2
3
4
5
6
7
8
9
10
11
public class EnemyBuilder : TCharacterBuilder
{
...
// 加入AI
public override void AddAI()
{
EnemyAI theAI = new EnemyAI( m_BuildParam.NewCharacter, m_BuildParam.AttackPosition);
m_BuildParam.NewCharacter.SetAI(theAI);
}
...
} // EnemyBuilder.cs

按照“最少知识原则(LKP)”,会希望敌方单位的建造者(EnemyBuilder)减少对其他无关类的引用。因此,在产生敌方单位AI(EnemyAI)对象时,敌方单位建造者(EnemyBuilder)无法将关卡系统(StageSystem)对象设置给敌方单位AI,这是属于上述“生成的位置无法引用到”的情况。所以,可以在敌方单位AI(EnemyAI)类中,提供一个静态成员属性和静态方法,让关卡系统(StageSystem)对象产生的当下,就设置给敌方单位AI(EnemyAI)类:

Listing 12 敌方AI的类(EnemyAI.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class EnemyAI : ICharacterAI
{
private static StageSystem m_StageSystem = null;
...
// 将关卡系统直接注入给EnemyAI类使用
public static void SetStageSystem(StageSystem StageSystem)
{
m_StageSystem = StageSystem;
}
...
// 是否可以攻击Heart
public override bool CanAttackHeart()
{
// 通知少一个Heart
m_StageSystem.LoseHeart();
return true;
}
...
}

使用类的静态方法

每当增加一个类名称就等同于又少了一个可以使用的全局名称,但如果是在类下增加“静态方法”就不会减少可使用的全局名称数量,而且还能马上增加这个静态类方法的“可视性”——也就是全局都可以引用这个静态类方法。如果在项目开发时,不存在限制全局引用的规则,或者已经没有更好的设计方法时,使用“类静态方法”来获取某一系统功能的接口,应该就是最佳的方式了。它有着单例模式的第二个特性:方便获取对象。

举例来说,在《P级阵地》中,有一个静态类PBDFactory就是按照这个概念去设计的。由于它在《P级阵地》中负责的是所有资源的产生,所以将其定义为“全局引用的类”并不违反这个游戏项目的设计原则。它的每一个静态方法都负责返回一个“资源生成工厂接口”,注意,是“接口”,所以在以后的系统维护更新中,是可以按照需求的改变来替换子类而不影响其他客户端:

Listing 13 获取P-BaseDefenseGame中所使用的工厂(PBDFactory.cs)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static class PBDFactory
{
private static IAssetFactory m_AssetFactory = null;

// 获取将Unity Asset实现化的工厂
public static IAssetFactory GetAssetFactory()
{
if( m_AssetFactory = null)
{
if ( m_bLoadFromResource )
m_AssetFactory = new ResourceAssetFactory();
else
m_AssetFactory = new RemoteAssetFactory();
}
return m_AssetFactory;
}
}

但如果在系统设计的需求上,又要求每个游戏资源工厂都“必须是唯一的”,那么此时可以在各个子类中运用单例模式,或者采取前面提到的“让类具有计数功能来限制对象数量”的方式来满足需求。

结论

单例模式的优点是:可以限制对象的产生数量;提供方便获取唯一对象的方法。单例模式的缺点是容易造成设计思考不周和过度使用的问题,但并不是要求设计者完全不使用这个模式,而是应该在仔细设计和特定的前提之下,适当地采用单例模式。

在《P级阵地》中,只有少数地方引用到单例类PBaseDefenseGame,而引用点可以视为单例模式优点的呈现。

其他应用方式

  • 网络在线游戏的客户端,可以使用单例模式来限制连接数,以预防误用而产生过多连接,避免服务器端因此失效。
  • 日志工具是比较不受项目类型影响的功能之一,所以可以设计为跨项目共享使用。此外,日志工具大多使用在调试或重要信息的输出上,而单例模式能让程序设计师方便快速地获取日志工具,所以是个不错的设计方式。
0%