如何避免游戏架构中出现很多单例?


55

我使用cocos2d-x游戏引擎来创建游戏。引擎已经使用了很多单例。如果有人使用过,那么他们应该熟悉其中的一些:

Director
SimpleAudioEngine
SpriteFrameCache
TextureCache
EventDispatcher (was)
ArmatureDataManager
FileUtils
UserDefault

还有更多内容,总共约16个课程。您可以在此页面上找到类似的列表:Cocos2d-html5 v3.0中的单例对象但是当我要编写游戏时,我需要更多单例:

PlayerData (score, lives, ...)
PlayerProgress (passed levels, stars)
LevelData (parameters per levels and level packs)
SocialConnection (Facebook and Twitter login, share, friend list, ...)
GameData (you may obtain some data from server to configure the game)
IAP (for in purchases)
Ads (for showing ads)
Analytics (for collecting some analytics)
EntityComponentSystemManager (mananges entity creation and manipulation)
Box2dManager (manages the physics world)
.....

为什么我认为它们应该是单例?因为在游戏中我需要在非常不同的位置使用它们,所以共享访问将非常方便。换句话说,我不是要在某个地方创建它们并将指针传递到我的所有体系结构,因为这将非常困难。这些都是我只需要的东西。无论如何,我都需要几个,我也可以使用Multiton模式。但是最糟糕的是,Singleton是最受批评的模式,原因是:

 - bad testability
 - no inheritance available
 - no lifetime control
 - no explicit dependency chain
 - global access (the same as global variables, actually)
 - ....

您可以在这里找到一些想法:https : //stackoverflow.com/questions/137975/what-is-so-bad-about-singletonshttps://stackoverflow.com/questions/4074154/when-should-the-singleton除了显而易见的之外还没有使用的模式

因此,我认为我做错了事。我认为我的代码有异味。:)我比较伤心的是,如何有更多经验丰富的游戏开发人员解决此体系结构问题?我想检查一下,也许在游戏开发中拥有30个以上的singleton仍然是正常的,考虑到那些已经在游戏引擎中。

我曾经考虑过使用Singleton-Facade,它将具有我需要的所有这些类的实例,但是它们中的每一个都不会是singletons。这将消除很多问题,而我只有一个单例,即Facade本身。但是在这种情况下,我将遇到另一个设计问题。立面将成为神的对象。我想这也闻起来。因此,我找不到适合这种情况的好的设计解决方案。请指教。


26
大多数反对Singleton的人似乎并没有意识到,通过所有代码传播某些数据/功能所需的工作可能比使用Singleton更像是一个错误源。因此,基本上他们没有进行比较,只是拒绝==>我怀疑反单身主义是一种姿势:-)那么,单身人士有缺陷,因此,如果可以避免,请这样做,这样可以避免随之而来的问题,现在,如果不能的话,请不要回头。毕竟,Cocos2d的16个Singletons似乎工作得很好...
GameAlchemist 2014年

6
提醒一下,这是有关如何执行特定任务的问题。这不是要求对单例进行赞成/反对讨论的问题(无论如何,这里都将成为话题)。此外,请记住,应该使用评论来要求问询者进行澄清,而不是作为讨论单身人士利弊的平台。如果您想提供输入,那么正确的方法是为问题提供合理的答案,在其中您可以针对单例模式提出建议或针对建议进行调整。
乔许

我假设这些单例然后可以全局访问?这就是避免这种模式的原因之一。
彼得-恢复莫妮卡2014年

Answers:


41

我在主应用程序类中初始化服务,然后将它们作为指针传递给通过构造函数或函数使用它们的任何需要。这很有用,有两个原因。

一,初始化和清除的顺序很简单明了。没有办法像单例那样意外地在其他地方初始化一项服务。

第二,很明显,谁取决于什么。我可以很容易地从类声明中看到哪些类需要什么服务。

您可能会认为将所有这些对象传递给您很烦人,但是如果系统设计良好,那么它的确还不错,并且使事情变得更加清晰(无论如何对我来说)。


34
+1。此外,必须将引用传递给系统的“烦恼”有助于抵消依赖的蔓延。如果您必须将某些东西传递给“ 一切 ”,这是一个很好的信号,表明您的设计中存在一个严重的耦合问题,并且这种方式比从各处对所有事物进行混杂的全局访问要更加明确。
乔什

2
这实际上并没有提供解决方案……因此,您的程序类中包含一堆单例对象,而现在,场景中深处的10层代码需要这些单例中的一个……如何传递它?
战争

沃迪:为什么他们需要单身?当然,大多数情况下您可能会绕过特定实例,但是使单例成为单例的原因是您不能使用其他实例。使用注入,您可以为一个对象传递一个实例,为另一个对象传递另一个实例。仅仅因为您出于某种原因最终不会这样做,并不意味着您就不会这样做。
2014年

3
他们不是单身人士。它们是常规对象,就像具有定义良好的所有者的其他任何事物一样,所有者控制着init / cleanup。如果您无法访问的级别是10层,则可能是设计问题。并非所有内容都需要或应该访问渲染器,音频等。这样可以使您考虑设计。对于那些测试他们的代码(what ??)的人来说,额外的好处是,单元测试也变得更加容易。
megadan 2014年

2
是的,我认为依赖注入(带有或不带有像Spring这样的框架)是处理此问题的完美解决方案。我发现这是对于那些想知道一个伟大的文章如何更好地构建代码以避免到处传递的东西:misko.hevery.com/2008/10/21/...
megadan

17

我不会讨论单例背后的邪恶,因为互联网可以比我做得更好。

在我的游戏中,我使用“ 服务定位器”模式来避免产生大量的Singletons / Manager。

这个概念很简单。您只有一个Singleton,其作用类似于唯一用作Singleton的界面。您现在只有一个,而不是几个Singleton。

像这样的电话:

FlyingToasterManager.GetInstance().Toast();
SmokeAlarmManager.GetInstance().Start();

使用Service Locator模式,如下所示:

SvcLocator.FlyingToaster.Toast();
SvcLocator.SmokeAlarm.Start();

如您所见,每个Singleton都包含在服务定位器中。

要获得更详细的说明,建议您阅读此页有关如何使用“定位器模式”的信息

[ 编辑 ]:正如评论中指出的那样,站点gameprogrammingpatterns.com 上的Singleton也包含一个非常有趣的部分

希望对您有所帮助。


4
链接到此处的网站gameprogrammingpatterns.com也有一个有关Singletons章节,这似乎很相关。
2014年

28
服务定位器只是将所有正常问题移至运行时而不是编译时的单例。但是,您所建议的只是一个巨大的静态注册表,这几乎更好,但实际上仍然是大量的全局变量。如果许多级别需要访问相同的对象,这只是架构不良的问题。这里需要适当的依赖注入,没有与当前全局名称不同的全局名称。
Magus 2014年

2
您能否详细介绍“适当的依赖注入”?
蓝色巫师

17
这并不能真正解决问题。本质上,是将许多单例合并为一个巨型单例。根本的结构性问题都没有得到解决,甚至没有得到解决,该代码现在仍然发臭。
ClassicThunder 2014年

4
@classicthunder尽管这不能解决每个问题,但与Singletons相比,这是一个很大的改进,因为它确实解决了多个问题。特别是,您编写的代码不再耦合到单个类,而是耦合到类的接口/类别。现在,您可以轻松地交换各种服务的不同实现。
登场

12

阅读所有指出的所有答案,评论和文章,尤其是这两篇精彩的文章,

最终,我得出以下结论,这是对我自己问题的一种回答。最好的方法是不要偷懒并直接传递依赖关系。这是显式的,可测试的,没有全局访问权限,如果您必须在许多地方非常深入地传递委托,则可以指示错误的设计。但是在编程中,一个大小并不适合所有情况。因此,仍然存在直接传递它们不方便的情况。例如,如果我们创建了一个游戏框架(一个代码模板,它将被用作开发不同类型游戏的基本代码),并且其中包含许多服务。在这里,我们不知道每个服务可以传递的深度,以及它将需要多少个地方。它取决于特定的游戏。那么在这种情况下我们该怎么办?我们使用服务定位器设计模式代替Singleton,

  1. 您不编写实现的程序,而是编写接口的程序。就OOP主体而言,这是非常必要的。在运行时,您可以使用Null Object模式更改甚至禁用服务。这不仅在灵活性方面很重要,而且直接有助于测试。您可以禁用模拟功能,也可以使用Decorator模式来添加一些日志记录和其他功能。这是非常重要的属性,而Singleton没有。
  2. 另一个巨大的优势是您可以控制对象的生存期。在Singleton中,您仅控制通过惰性初始化模式生成对象的时间。但是,一旦生成了对象,它将一直存在直到程序结束。但是在某些情况下,您想更改服务。甚至关闭它。特别是在游戏中,这种灵活性非常重要。
  3. 它将多个Singleton组合/包装到一个紧凑的API中。我认为您可能还会使用诸如服务定位器之类的Facade,对于单个操作,它可能一次使用多个服务。
  4. 您可能会争辩说,我们仍然具有全局访问权限,这是Singleton的主要设计问题。我同意,但是仅当我们需要某些全局访问时,才需要这种模式。如果我们认为直接依赖注入对我们的情况不利,我们就不应抱怨全局访问,而我们需要全局访问。但是即使对于这个问题,也可以进行改进(请参阅上面的第二篇文章)。您可以将所有服务设为私有,并且可以从Service Locator类继承您认为它们应该使用服务的所有类。这可以大大缩小访问范围。并且请考虑在Singleton的情况下,由于私有构造函数,您不能使用继承。

另外,我们应该考虑在Unity,LibGDX,XNA中使用了这种模式。这不是优势,但这是该模式可用性的证明。考虑到这些引擎是由许多聪明的开发人员长期开发的,在引擎的发展阶段,他们仍然找不到更好的解决方案。

总之,我认为Service Locator对于我的问题中描述的方案可能非常有用,但如果可能的话,仍应避免使用。根据经验-如有必要,请使用它。

编辑:经过一年的工作并使用直接DI并遇到庞大的构造函数和大量代表团的问题,我进行了更多研究,发现了一篇不错的文章,谈到了有关DI和Service Locator方法的优缺点。引用马丁·福勒(Martin Fowler)的这篇文章(http://www.martinfowler.com/articles/injection.html),该文章解释了服务定位器实际上是什么时候不好:

因此,主要问题是正在编写预期将在编写者无法控制的应用程序中使用的代码的人员。在这些情况下,即使是关于服务定位器的最小假设也是一个问题。

但是无论如何,如果您想第一次与我们进行DI,您应该阅读此http://misko.hevery.com/2008/10/21/dependency-injection-myth-reference-passing/文章(由@megadan指出) ,请非常注意Demeter(LoD)律或最低知识原则


我对服务定位器的不宽容态度主要是因为我尝试测试使用它们的代码。为了进行黑盒测试,您可以调用构造函数来创建要测试的实例。可能立即或在任何方法调用上引发异常,并且可能没有公共属性导致您缺少依赖项。如果您可以访问源,那么这可能很小,但是仍然违反了“最小惊喜原则”(Singletons经常出现的问题)。您已建议传入服务定位器,以减轻部分负担,为此值得赞赏。
Magus 2014年

3

将代码库的某些部分视为基石对象或基础类并不少见,但这并不能证明将其生命周期指定为Singleton是合理的。

程序员通常将Singleton模式作为一种便利和纯粹的懒惰手段,而不是采用替代方法,变得更加冗长,并在明确的庄园中强加彼此之间的对象关系。

因此,问问自己哪个更容易维护。

如果您像我一样,我更喜欢能够打开头文件并略过构造函数参数和公共方法,以查看对象所需的依赖关系,而不必检查实现文件中的数百行代码。

如果您将一个对象做成Singleton,后来又意识到您需要能够支持该对象的多个实例,请想象一下,如果该类是您在整个代码库中大量使用的东西,则需要进行全面的更改。更好的选择是使依赖项显式,即使对象仅分配一次,并且如果需要支持多个实例,也可以放心地这样做而无需担心。

正如其他人指出的那样,使用Singleton模式也会混淆耦合,这种耦合通常表示较差的设计和模块之间的高内聚性。这种内聚性通常是不希望的,并且会导致难以维护的易碎代码。


-1:此答案错误地描述了单例模式,其所有参数都描述了该模式实施不佳的问题。正确的单例一个接口,它避免了您描述的每个问题(包括转向非单一性,这是GoF明确解决的情况)。如果很难重构单例的使用,那么重构显式对象引用的使用只会更困难,而不是更少。如果单例以某种方式导致更多的代码耦合,那就干错了。
塞斯·巴丁

1

辛格尔顿(Singleton)是一个著名的模式,但是很高兴知道它的用途以及它的优缺点。

如果没有关系,那真的很有意义。
如果您可以使用完全不同的对象(没有强依赖性)来处理组件,并期望具有相同的行为,则单例可能是一个不错的选择。

另一方面,如果您需要小的功能(仅在特定时间使用),则应该首选传递引用

如您所述,单例没有生命周期控制。但是优点是它们的初始化很懒。
如果您在应用程序生命周期中未调用该单例,它将不会被实例化。但是我怀疑您是否建立单例并且不使用它们。

您必须看到像服务一样的单例而不是组件

Singletons工作,他们从未中断任何应用程序。但是他们的不足(您给的四个)是您不会成为其中一个的原因。


1

引擎已经使用了很多单例。如果有人使用过,那么他们应该熟悉其中的一些:

陌生不是避免单身人士的原因。

为什么有必要或不可避免的原因有很多充分的理由。游戏框架通常使用单例,因为这是只有一个有状态硬件的必然结果。曾经想通过它们各自的处理程序的多个实例来控制这些硬件没有任何意义。图形表面是外部有状态硬件,意外地初始化图形子系统的第二个副本无非是灾难性的,因为现在这两个图形子系统将在争夺谁来绘制以及何时不受控地覆盖彼此之间进行斗争。同样,使用事件队列系统,他们将争夺谁以不确定的方式获取鼠标事件。当处理有状态的外部硬件(其中只有一个)时,不可避免地要避免冲突。

Singleton明智的另一个地方是缓存管理器。缓存是一种特殊情况。即使他们使用与Singletons相同的所有技术来永远活着,也不应真正将其视为Singleton。缓存服务是透明服务,不应更改程序的行为,因此,如果将缓存服务替换为空缓存,则该程序应该仍然可以运行,只是运行速度较慢。高速缓存管理器是单例的例外的主要原因是因为在关闭应用程序本身之前关闭高速缓存服务没有意义,因为这也将丢弃高速缓存的对象,这使将其作为单身人士。

这些都是为什么这些服务是单例的很好理由。但是,拥有单例的充分理由都不适用于您列出的类。

因为在游戏中我需要在非常不同的位置使用它们,所以共享访问将非常方便。

这些不是单例的原因。这就是全球问题的原因。如果您需要在各种系统中传递很多东西,这也表明设计不佳。必须通过传递指示高耦合,这可以通过良好的OO设计来避免。

仅仅从查看您认为需要成为Singleton的课程列表中,我可以说其中一半确实不应该是Singleton,而另一半似乎根本就不应该是Singleton。您需要传递对象似乎是由于缺少适当的封装,而不是单例的实际好用例。

让我们一点一点地看一下您的课程:


PlayerData (score, lives, ...)
LevelData (parameters per levels and level packs)
GameData (you may obtain some data from server to configure the game)

仅从类名称来看,我会说这些类闻起来像Anemic Domain Model反模式。贫血对象通常会导致需要传递大量数据,这会增加耦合,并使其余代码繁琐。同样,贫乏的类掩盖了一个事实,即您可能仍在程序上思考而不是使用面向对象来封装细节。


IAP (for in purchases)
Ads (for showing ads)

为什么这些类需要为单例?在我看来,这些类应该是短暂的类,应在需要它们时提出,并在用户完成购买或不再需要展示广告时进行解构。


EntityComponentSystemManager (mananges entity creation and manipulation)

换句话说,构造函数和服务层?为什么这个班级一开始就没有明确的界限和目的?


PlayerProgress (passed levels, stars)

为什么播放器进度与播放器类分开?Player类应该知道如何跟踪自己的进度,如果要在与Player类不同的类中实现进度跟踪以分离责任,则PlayerProgress应该落后于Player。


Box2dManager (manages the physics world)

在不知道该类的实际作用的情况下,我无法对这一类进行任何进一步的评论,但很明显的一点是,该类的名称不正确。


Analytics (for collecting some analytics)
SocialConnection (Facebook and Twitter login, share, friend list, ...)

这似乎是Singleton可能合理的唯一类,因为社交关系对象实际上并不是您的对象。社交关系只是存在于远程服务中的外部对象的阴影。换句话说,这是一种缓存。只要确保将缓存部分与外部服务的服务部分清楚地分开即可,因为后者不必是单例。

避免传递这些类的实例的一种方法是使用消息传递。而不是直接调用这些类的实例,而是以异步方式发送给Analytic and SocialConnection服务的消息,并且订阅了这些服务以接收这些消息并对该消息进行操作。由于事件队列已经是一个单例,因此通过踩踏该调用,可以避免在与单例通信时传递实际实例。


-1

我的单身人士总是很糟糕。

在我的体系结构中,我创建了一个由游戏类管理的GameServices集合。我可以根据需要搜索添加到此收藏夹和从其中删除。

通过说某事是在全球范围内,您在上面提供的示例中基本上是在说“这是上帝,没有主人”,我会说其中大多数是助手类或GameServices,在这种情况下,游戏将对它们负责。

但是,这只是我在引擎中进行的设计,您的情况可能有所不同。

更直接地说...唯一真正的单例是游戏,因为它拥有代码的每个部分。

答案是基于问题的主观性质,并且完全基于我的观点,对于那里的所有开发人员来说可能都不正确。

编辑:根据要求...

好的,这是从我的脑海中冒出来的,因为我现在还没有代码,但基本上任何游戏的根本都是Game类,它确实是单身人士……必须如此!

所以我添加了以下内容...

class Game
{
   IList<GameService> Services;

   public T GetService<T>() where T : GameService
   {
       return Services.FirstOrDefatult(s => s is T);
   }
}

现在,我还有一个系统类(静态),其中包含整个程序中的所有单例(仅包含很少的游戏和配置选项,仅此而已)...

public static class Sys
{
    // only the main game assembly can set this (done by the game ctor)
    // anything can get
    public static Game Game { get; internal set; }
}

和用法...

我可以在任何地方做类似的事情

var tService = Sys.Game.getService<T>();
tService.Something();

这种方法意味着我的游戏“拥有”东西通常被认为是“单个”,并且我可以根据需要扩展基本的GameService类。我有类似IUpdateable的界面,我的游戏会根据特定情况根据需要检查并调用实现该功能的任何服务。

我还拥有针对特定场景场景继承/扩展其他服务的服务。

例如 ...

我有一个物理服务可以处理我的物理模拟,但是根据场景,物理模拟可能需要一些稍微不同的行为。


1
游戏服务不应该实例化一次吗?如果是这样,那只是在类级别上没有强制执行的单例模式,这比正确实现的单例更糟糕。
GameAlchemist 2014年

2
@Wardy我不清楚您所做的工作,但听起来像我所描述的Singleton-Facade,它应该成为GOD对象,对吗?
Narek 2014年

10
这只是在游戏级别实施的Singleton模式。因此,基本上,它最有趣的方面是允许您假装不使用Singleton。如果您的老板来自反模式教会,这很有用。
GameAlchemist 2014年

2
我不确定这与使用Singletons有何不同。您访问这种类型的单个现有服务的方式不是唯一的区别吗?即,var tService = Sys.Game.getService<T>(); tService.Something();而不是跳过该getService部分并直接访问它?
基督教徒

1
@克里斯蒂安:的确,这正是服务定位器反模式。显然,该社区仍然认为这是推荐的设计模式。
Magus 2014年

-1

单例的反对者经常不考虑的单例消除的基本要素是添加额外的逻辑,以处理当单例让位给实例值时对象封装的极为复杂的状态。确实,即使在用实例变量替换单例之后定义对象的状态也可能很困难,但是用实例变量替换单例的程序员应该准备好处理它。

例如,将Java HashMap与.NET 进行比较Dictionary。两者十分相似,但它们有一个关键的区别:Java的HashMap是硬编码的使用equalshashCode(它有效地采用了单比较),而.NET的Dictionary可以接受的实例IEqualityComparer<T>作为构造函数的参数。保持的运行时成本IEqualityComparer最小,但引入的复杂性却没有。

因为每个Dictionary实例都封装了一个IEqualityComparer,所以其状态不仅是其实际包含的键与其关联值之间映射,而且还包括键与比较器定义的等价集及其关联值之间映射。如果两个实例d1d2Dictionary持有引用到相同的IEqualityComparer,将有可能产生一个新的实例d3,例如,对于任何xd3.ContainsKey(x)将等于d1.ContainsKey(x) || d2.ContainsKey(x)。但是,如果d1d2可能持有不同的任意平等comparers引用,将有一般没有办法将它们合并,以确保条件成立。

添加实例变量以消除单例现象,但是未能正确考虑这些实例变量可能暗示的可能的新状态,可能会导致代码变得比单例情况更脆弱。如果每个对象实例都有一个单独的字段,但是除非它们都标识相同的对象实例,否则事情将严重地出错,那么所有新字段购买的东西都会增加出错的可能。


1
尽管这绝对是讨论中一个有趣的方面,但我认为它实际上并未解决OP实际提出的问题。
乔什

1
@JoshPetrie:对原始帖子的评论(例如,Magus的建议)表明,携带引用以避免使用单例的运行时开销不是很大。因此,为了避免单例,我将考虑使每个对象封装引用的语义代价。在可以便宜又轻松地避免单例的情况下,应该避免使用单例,但是并不是公开要求单例的代码,但是一旦存在多个实例,则可能会中断,这比使用单例的代码差。
supercat 2014年

2
答案不是为了解决评论,而是直接回答问题。
ClassicThunder

@ClassicThunder:您是否更喜欢编辑?
2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.