引言
游戏开发一直是热门的领域,掌握良好的游戏编程模式是开发人员的应备技能,本书细致地讲解了游戏开发需要用到的各种编程模式,并提供了丰富的示例。本章关于解耦模式的介绍。
一旦你掌握了编程语言,编写想要写的东西就会变得相当容易。 困难的是编写适应需求变化的代码,在我们用文本编辑器开火之前,通常没有完美的特性表供我们使用。
能让我们更好地适应变化的工具是解耦。 当我们说两块代码“解耦”时,是指修改一块代码一般不会需要修改另一块代码。 当我们修改游戏中的特性时,需要修改的代码越少,就越容易。
组件模式将一个实体拆成多个,解耦不同的领域。 事件序列解耦了两个互相通信的事物,稳定而且及时。 服务定位器让代码使用服务而无需绑定到提供服务的代码。
组件模式
意图
允许单一的实体跨越多个领域而不会导致这些领域彼此耦合。
动机
让我们假设我们正在制作平台跳跃游戏。 意大利水管工已经有人做了,因此我们将出动丹麦面包师,Bjorn。 照理说,会有一个类来表示友好的糕点厨师,包含他在游戏中做的一切。
像这样的游戏创意导致了我是程序员而不是设计师。
由于玩家控制着他,这意味着需要读取控制器的输入然后转化为动作。 而且他当然需要与关卡进行互动,所以要引入物理和碰撞。 一旦这样做了,他就必须在屏幕上出现,所以要引入动画和渲染。 他可能还会播放一些声音。
等一下,这一切正在失控。软件体系结构101课程告诉我们,程序的不同领域应保持分离。 如果我们做一个文字处理器,处理打印的代码不应该受加载和保存文件的代码影响。 游戏和企业应用程序的领域不尽相同,但该规则仍然适用。
我们希望AI,物理,渲染,声音和其他领域域尽可能相互不了解, 但现在我们将所有这一切挤在一个类中。 我们已经看到了这条路通往何处:5000行的巨大代码文件,哪怕是你们团队中最勇敢的程序员也不敢打开。
这工作对能驯服他的少数人来说是有趣的,但对其他人而言是地狱。 这么大的类意味着,即使是看似微不足道的变化亦可有深远的影响。 很快,为类添加错误的速度会明显快于添加功能的速度。
一团乱麻
比起单纯的规模问题,更糟糕的是耦合。 在游戏中,所有不同的系统被绑成了一个巨大的代码球:
if (collidingWithFloor() && (getRenderState() != INVISIBLE)) |
任何试图改变上面代码的程序员,都需要物理,图形和声音的相关知识,以确保没破坏什么。
这样的耦合在任何游戏中出现都是个问题,但是在使用并发的现代游戏中尤其糟糕。 在多核硬件上,让代码同时在多个线程上运行是至关重要的。 将游戏分割为多线程的一种通用方法是通过领域划分——在一个核上运行AI代码,在另一个上播放声音,在第三个上渲染,等等。
这两个问题互相混合;这个类涉及太多的域,每个程序员都得接触它, 但它又太过巨大,这就变成了一场噩梦。 如果变得够糟糕,程序员会黑入代码库的其他部分,仅仅为了躲开这个像毛球一样的Bjorn
类。
快刀斩乱麻
我们可以像亚历山大大帝一样解决这个问题——快刀斩乱麻。 按领域将 Bjorn
类割成相互独立的部分。 例如,抽出所有处理用户输入的代码,将其移动到一个单独的 InputComponent
类。 Bjorn
拥有这个部件的一个实例。我们将对 Bjorn
接触的每个领域重复这一过程。
一旦你这么做了,在领域间保持解耦就是至关重要的,这是为了避免死锁或者其他噩梦般的并发问题。 如果某个函数从一个线程上调用
UpdateSounds()
方法,从另一个线程上调用RenderGraphics()
方法,那它是在自找麻烦。
当完成后,我们就将 Bjorn
大多数的东西都抽走了。 剩下的是一个薄壳包着所有的组件。 通过将类划分为多个小类,我们已经解决了这个问题。但我们所完成的远不止如此。
宽松的结果
我们的组件类现在解耦了。 尽管Bjorn
有PhysicsComponent
和GraphicsComponent
, 但这两部分都不知道对方的存在。 这意味着处理物理的人可以修改组件而不需要了解图形,反之亦然。
在实践中,这些部件之间需要有一些相互作用。 例如,AI组件可能需要告诉物理组件Bjorn试图去哪里。 然而,我们可以将这种交互限制在确实需要交互的组件之间, 而不是把它们围在同一个围栏里。
绑到一起
这种设计的另一特性是,组件现在是可复用的包。 到目前为止,我们专注于面包师,但是让我们考虑几个游戏世界中其他类型的对象。 装饰 是玩家看到但不能交互的事物:灌木,杂物等视觉细节。 道具 像装饰,但可以交互:箱,巨石,树木。 区域 与装饰相反——无形但可互动。 它们是很好的触发器,比如在Bjorn进入区域时触发过场动画。
当面向对象语言第一次接触这个场景时,继承是它箱子里最闪耀的工具。 它被认为是代码无限重用之锤,编程者常常挥舞着它。 然而我们痛苦地学到,事实上它是一把重锤。 继承有它的用处,但对简单的代码重用来说太过复杂。
相反,在今日软件设计的趋势是尽可能使用组件代替继承。 不是让两个类继承同一类来分享代码,而是让它们拥有同一个类的实例。
现在,考虑如果不用组件,我们将如何建立这些类的继承层次。第一遍可能是这样的:
我们有GameObject
基类,包含位置和方向之类的通用部分。 Zone
继承它,增加了碰撞检测。 同样,Decoration
继承GameObject
,并增加了渲染。 Prop
继承Zone
,因此它可以重用碰撞代码。 然而,Prop
不能同时继承Decoration
来重用渲染, 否则就会造成致命菱形结构。
“致命菱形”发生在类继承了多个类,而这多个类中有两个继承同一基类时。 介绍它造成的痛苦超过了本书的范围,但它被说成“致命”是有原因的。
我们可以反过来让Prop
继承Decoration
,但随后不得不重复碰撞检测代码。 无论哪种方式,没有干净的办法重用碰撞和渲染代码而不诉诸多重继承。 唯一的其他选择是一切都继承GameObject
, 但随后Zone
会浪费内存在并不需要的渲染数据上, Decoration
在物理效果上有同样的浪费。
现在,让我们尝试用组件。子类将彻底消失。 取而代之的是一个GameObject
类和两个组件类:PhysicsComponent
和GraphicsComponent
。 装饰是个简单的GameObject
,包含GraphicsComponent
但没有PhysicsComponent
。 区域与其恰好相反,而道具包含两种组件。 没有代码重复,没有多重继承,只有三个类,而不是四个。
可以拿饭店菜单打比方。如果每个实体是一个类,那就只能订套餐。 我们需要为每种可能的组合定义各自的类。 为了满足每位用户,我们需要十几种套餐。
组件是照单点菜——每位顾客都可以选他们想要的,菜单记录可选的菜式。
对对象而言,组件是即插即用的。 将不同的可重用部件插入对象,我们就能构建复杂且具有丰富行为的实体。 就像软件中的战神金刚。
模式
单一实体跨越了多个领域。为了保持领域之间相互分离,将每部分代码放入各自的组件类中。 实体被简化为组件的容器。
“组件”,就像“对象”,在编程中意味任何东西也不意味任何东西。 正因如此,它被用来描述一些概念。 在商业软件中,“组件”设计模式描述通过网络解耦的服务。
何时使用
我试图从游戏中找到无关这个设计模式的另一个名字,但“组件”看来是最常用的术语。 由于设计模式是记录已存的实践,我没有创建新术语的余地。 所以,跟着XNA,Delta3D和其他人的脚步,我称之为“组件”。
组件通常在定义游戏实体的核心部分中使用,但它们在其他地方也有用。 这个模式应用在在如下情况中:
- 有一个涉及了多个领域的类,而你想保持这些领域互相隔离。
- 一个类正在变大而且越来越难以使用。
- 想要能定义一系列分享不同能力的类,但是使用继承无法让你精确选取要重用的部分。
记住
组件模式比简单地向类中添加代码增加了一点点复杂性。 每个概念上的“对象”要组成真正的对象需要实例化,初始化,然后正确地连接。 不同组件间沟通会有些困难,而控制它们如何使用内存就更加复杂。
对于大型代码库,为了解耦和重用而付出这样的复杂度是值得的。 但是在使用这种模式之前,保证你没有为了不存在的问题而“过度设计”。
这是硬币的两面。组件模式通常可以增进性能和缓存一致性。 组件让使用数据局部性模式的CPU更容易组织数据。
使用组件的另一后果是,需要多一层跳转才能做要做的事。 拿到容器对象,获得相应的组件,然后你才能做想做的事情。 在性能攸关的内部循环中,这种跳转也许会导致糟糕的性能。
示例代码
我写这本书的最大挑战之一就是搞明白如何隔离各个模式。 许多设计模式包含了不属于这种模式的代码。 为了将提取模式的本质,我尽可能地消减代码, 但是在某种程度上,这就像是没有衣服还要说明如何整理衣柜。
说明组件模式尤其困难。 如果看不到它解耦的各个领域的代码,你就不能获得正确的体会, 因此我会多写一些有关于Bjorn的代码。 这个模式事实上只关于将组件变为类,但类中的代码可以帮助表明类是做什么用的。 它是伪代码——它调用了其他不存在的类——但这应该可以让你理解我们正在做什么。
单块类
为了清晰的看到这个模式是如何应用的, 我们先展示一个Bjorn
类, 它包含了所有我们需要的事物,但是没有使用这个模式:
我应指出在代码中使用角色的名字总是个坏主意。市场部有在发售之前改名字的坏习惯。 “焦点测试表明,在11岁到15岁之间的男性不喜欢‘Bjorn’,请改为‘Sven‘”。
这就是为什么很多软件项目使用内部代码名。 而且比起告诉人们你在完成“Photoshop的下一版本”,告诉他们你在完成“大电猫”更有趣。
class Bjorn |
Bjorn
有个每帧调用的update()
方法。
void Bjorn::update(World& world, Graphics& graphics) |
它读取操纵杆以确定如何加速面包师。 然后,用物理引擎解析新位置。 最后,将Bjorn渲染至屏幕。
这里的示例实现平凡而简单。 没有重力,动画,或任何让人物有趣的其他细节。 即便如此,我们可以看到,已经出现了同时消耗多个程序员时间的函数,而它开始变得有点混乱。 想象增加到一千行,你就知道这会有多难受了。
分离领域
从一个领域开始,将Bjorn
的代码去除一部分,归入分离的组件类。 我们从首个执行的领域开始:输入。 Bjorn
做的头件事就是读取玩家的输入,然后基于此调整它的速度。 让我们将这部分逻辑移入一个分离的类:
class InputComponent |
很简单吧。我们将Bjorn
的update()
的第一部分取出,放入这个类中。 对Bjorn
的改变也很直接:
class Bjorn |
Bjorn
现在拥有了一个InputComponent
对象。 之前它在update()
方法中直接处理用户输入,现在委托给组件:
input_.update(*this); |
我们才刚开始,但已经摆脱了一些耦合——Bjorn
主体现在已经与Controller
无关了。这会派上用场的。
将剩下的分割出来
现在让我们对物理和图像代码继续这种剪切粘贴的工作。 这是我们新的 PhysicsComponent
:
class PhysicsComponent |
为了将物理行为移出Bjorn
类,你可以看到我们也移出了数据:Volume
对象已经是组件的一部分了。
最后,这是现在的渲染代码:
class GraphicsComponent |
我们几乎将所有的东西都移出来了,所以面包师还剩下什么?没什么了:
class Bjorn |
Bjorn
类现在基本上就做两件事:拥有定义它的组件,以及在不同域间分享的数据。 有两个原因导致位置和速度仍然在Bjorn
的核心类中: 首先,它们是“泛领域”状态——几乎每个组件都需要使用它们, 所以我们想要提取它出来时,哪个组件应该拥有它们并不明确。
第二,也是更重要的一点,它给了我们无需让组件耦合就能沟通的简易方法。 让我们看看能不能利用这一点。
机器人Bjorn
到目前为止,我们将行为归入了不同的组件类,但还没将行为抽象出来。 Bjorn
仍知道每个类的具体定义的行为。让我们改变这一点。
取出处理输入的部件,将其藏在接口之后,将InputComponent
变为抽象基类。
class InputComponent |
然后,将现有的处理输入的代码取出,放进一个实现接口的类中。
class PlayerInputComponent : public InputComponent |
我们将Bjorn
改为只拥有一个指向输入组件的指针,而不是拥有一个内联的实例。
class Bjorn |
现在当我们实例化Bjorn
,我们可以传入输入组件使用,就像下面这样:
Bjorn* bjorn = new Bjorn(new PlayerInputComponent()); |
这个实例可以是任何实现了抽象InputComponent
接口的类型。 我们为此付出了代价——update()
现在是虚方法调用了,这会慢一些。这一代价的回报是什么?
大多数的主机需要游戏支持“演示模式”。 如果玩家停在主菜单没有做任何事情,游戏就会自动开始运行,直到接入一个玩家。 这让屏幕上的主菜单看上去更有生机,同时也是销售商店里很好的展示。
隐藏在输入组件后的类帮我们实现了这点, 我们已经有了具体的PlayerInputComponent
供玩游戏时使用。 现在让我们完成另一个:
class DemoInputComponent : public InputComponent |
当游戏进入演示模式,我们将Bjorn和一个新组件连接起来,而不像之前演示的那样构造它:
Bjorn* bjorn = new Bjorn(new DemoInputComponent()); |
现在,只需要更改组件,我们就有了为演示模式而设计的电脑控制的玩家。 我们可以重用所有Bjorn的代码——物理和图像都不知道这里有了变化。 也许我有些奇怪,但这就是每天能让我起床的事物。
那个,还有咖啡。热气腾腾的咖啡。
删掉Bjorn?
如果你看看现在的Bjorn
类,你会意识到那里完全没有“Bjorn”——那只是个组件包。 事实上,它是个好候选人,能够作为每个游戏中的对象都能继承的“游戏对象”基类。 我们可以像弗兰肯斯坦一样,通过挑选拼装部件构建任何对象。
让我们将剩下的两个具体组件——物理和图像——像输入那样藏到接口之后。
class PhysicsComponent |
然后将Bjorn
改为使用这些接口的通用GameObject
类。
class GameObject |
有些人走的更远。 不使用包含组件的
GameObject
,游戏实体只是一个ID,一个数字。 每个组件都知道它们连接的实体ID,然后管理分离的组件。
我们现有的具体类被重命名并实现这些接口:
class BjornPhysicsComponent : public PhysicsComponent |
现在我们无需为Bjorn建立具体类,就能构建拥有所有Bjorn行为的对象。
GameObject* createBjorn() |
这个
createBjorn()
函数当然就是经典的GoF工厂模式的例子。
通过用不同组件实例化GameObject
,我们可以构建游戏需要的任何对象。
设计决策
这章中你最需要回答的设计问题是“我需要什么样的组件?” 回答取决于你游戏的需求和风格。 引擎越大越复杂,你就越想将组件划分得更细。
除此之外,还有几个更具体的选项要回答:
对象如何获取组件?
一旦将单块对象分割为多个分离的组件,就需要决定谁将它们拼到一起。
如果对象创建组件:
- 这保证了对象总是能拿到需要的组件。 你永远不必担心某人忘记连接正确的组件然后破坏了整个游戏。容器类自己会处理这个问题。
- 重新设置对象比较困难。 这个模式的强力特性之一就是只需重新组合组件就可以创建新的对象。 如果对象总是用硬编码的组件组装自己,我们就无法利用这个特性。
如果外部代码提供组件:
- 对象更加灵活。 我们可以提供不同的组件,这样就能改变对象的行为。 通过共用组件,对象变成了组件容器,我们可以为不同目的一遍又一遍地重用它。
- 对象可以与具体的组件类型解耦。
如果我们允许外部代码提供组件,好处是也可以传递派生的组件类型。 这样,对象只知道组件接口而不知道组件的具体类型。这是一个很好的封装结构。
组件之间如何通信?
完美解耦的组件不需要考虑这个问题,但在真正的实践中行不通。 事实上组件属于同一对象暗示它们属于需要相互协同的更大整体的一部分。 这就意味着通信。
所以组件如何相互通信呢? 这里有很多选项,但不像这本书中其他的“选项”,它们相互并不冲突——你可以在一个设计中支持多种方案。
通过修改容器对象的状态:
保持了组件解耦。 当我们的
InputComponent
设置了Bjorn的速度,而后PhysicsComponent
使用它, 这两个组件都不知道对方的存在。在它们的理解中,Bjorn的速度是被黑魔法改变的。需要将组件分享的任何数据存储在容器类中。 通常状态只在几个组件间共享。比如,动画组件和渲染组件需要共享图形专用的信息。 将信息存入容器类会让所有组件都获得这样的信息。
更糟的是,如果我们为不同组件配置使用相同的容器类,最终会浪费内存存储不被任何对象组件需要的状态。 如果我们将渲染专用的数据放入容器对象中,任何隐形对象都会无益地消耗内存。
这让组件的通信基于组件运行的顺序。 在同样的代码中,原先一整块的
update()
代码小心地排列这些操作。 玩家的输入修改了速度,速度被物理代码使用并修改位置,位置被渲染代码使用将Bjorn绘制到所在之处。 当我们将这些代码划入组件时,还是得小心翼翼地保持这种操作顺序。如果我们不那么做,就引入了微妙而难以追踪的漏洞。 比如,我们先更新图形组件,就错误地将Bjorn渲染在他上一帧而不是这一帧所处的位置上。 如果你考虑更多的组件和更多的代码,那你可以想象要避免这样的错误有多么困难了。
通过它们之间相互引用:
这里的思路是组件有要交流的组件的引用,这样它们直接交流,无需通过容器类。
这样被大量代码读写相同数据的共享状态很难保持正确。 这就是为什么学术界花时间研究完全函数式语言,比如Haskell,那里根本没有可变状态。
假设我们想让Bjorn跳跃。图形代码想知道它需要用跳跃图像还是不用。 这可以通过询问物理引擎它当前是否在地上来确定。一种简单的方式是图形组件直接知道物理组件的存在:
class BjornGraphicsComponent |
当构建Bjorn的GraphicsComponent
时,我们给它相应的PhysicsComponent
引用。
- 简单快捷。 通信是一个对象到另一个的直接方法调用。组件可以调用任一引用对象的方法。做什么都可以。
- 两个组件紧绑在一起。 这是做什么都可以带来的坏处。我们向使用整块类又退回了一步。 这比只用单一类好一点,至少我们现在只是把需要通信的类绑在一起。
通过发送消息:
这是最复杂的选项。我们可以在容器类中建小小的消息系统,允许组件相互发送消息。
下面是一种可能的实现。我们从每个组件都会实现的
Component
接口开始:class Component
{
public:
virtual ~Component() {}
virtual void receive(int message) = 0;
};它有一个简单的
receive()
方法,每个需要接受消息的组件类都要实现它。 这里,我们使用一个int
来定义消息,但更完整的消息实现应该可以附加数据。然后,向容器类添加发送消息的方法。
class ContainerObject
{
public:
void send(int message)
{
for (int i = 0; i < MAX_COMPONENTS; i++)
{
if (components_[i] != NULL)
{
components_[i]->receive(message);
}
}
}
private:
static const int MAX_COMPONENTS = 10;
Component* components_[MAX_COMPONENTS];
};现在,如果组件能够接触容器,它就能向容器发送消息,直接向所有的组件广播。 (包括了原先发送消息的组件,小心别陷入无限的消息循环中!)这会造成一些结果:
同级组件解耦。 通过父级容器对象,就像共享状态的方案一样,我们保证了组件之间仍然是解耦的。 使用了这套系统,组件之间唯一的耦合是它们发送的消息。
如果你真的乐意,甚至可以将消息存储在队列中,晚些发送。 要知道更多,看看事件队列。
- 容器类很简单。 不像使用共享状态那样,容器类无需知道组件使用了什么数据,它只是将消息发送出去。 这可以让组件发送领域特有的数据而无需打扰容器对象。
GoF称之为中介模式——两个或更多的对象通过中介对象通信。 现在这种情况下,容器对象本身就是中介。
不出意料的,这里没有最好的答案。这些方法你最终可能都会使用一些。 共享状态对于每个对象都有的数据是很好用的——比如位置和大小。
有些不同领域仍然紧密相关。想想动画和渲染,输入和AI,或物理和粒子。 如果你有这样一对分离的组件,你会发现直接相互引用也许更加容易。
消息对于“不那么重要”的通信很有用。对物理组件发现事物碰撞后发送消息让音乐组件播放声音这种事情来说,发送后不管的特性是很有效的。
就像以前一样,我建议你从简单的开始,然后如果需要的话,加入其他的通信路径。
参见
Unity核心架构中
GameObject
类完全根据这样的原则设计components。开源的Delta3D引擎有
GameActor
基类通过ActorComponent
实现了这种模式。微软的XNA游戏框架有一个核心的
Game
类。它拥有一系列GameComponent
对象。我们在游戏实体层使用组件,XNA在游戏主对象上实现了这种模式,但意图是一样的。这种模式与GoF的策略模式类似。 两种模式都是将对象的行为取出,划入单独的重述对象。 与对象模式不同的是,分离的策略模式通常是无状态的——它封装了算法,而没有数据。 它定义了对象如何行动,但没有定义对象是什么。
组件更加重要。它们经常保存了对象的状态,这有助于确定其真正的身份。 但是,这条界限很模糊。有一些组件也许根本没有任何状态。 在这种情况下,你可以在不同的容器对象中使用相同的组件实例。这样看来,它的行为确实更像一种策略。
事件序列
意图
解耦发出消息或事件的时间和处理它的时间。
动机
除非还呆在一两个没有互联网接入的犄角旮旯,否则你很可能已经听说过“事件序列”了。 如果没有,也许“消息队列”或“事件循环”或“消息泵”可以让你想起些什么。 为了唤醒你的记忆,让我们了解几个此模式的常见应用吧。
这章的大部分里,我交替使用“事件”和“消息”。 在两者的意义有区别时,我会表明的。
GUI事件循环
如果你曾做过任何用户界面编程,你就会很熟悉事件。 每当用户与你的程序交互——点击按钮,拉出菜单,或者按个键——操作系统就会生成一个事件。 它会将这个对象扔给你的应用程序,你的工作就是获取它然后将其与有趣的行为相挂钩。
这个程序风格非常普遍,被认为是一种编程范式:事件驱动编程。
为了获取这些事件,代码底层是事件循环。它大体上是这样的:
while (running) |
调用getNextEvent()
将一堆未处理的用户输入传到应用程序中。 你将它导向事件处理器,之后应用魔术般获得了生命。 有趣的部分是应用在它想要的时候获取事件。 操作系统在用户操作时不是直接跳转到你应用的某处代码。
相反,操作系统的中断确实是直接跳转的。 当中断发生时,操作系统中断应用在做的事,强制它跳到中断处理。 这种唐突的做法是中断很难使用的原因。
这就意味着当用户输入进来时,它需要到某处去, 这样操作系统在设备驱动报告输入和应用去调用getNextEvent()
之间不会漏掉它。 这个“某处”是一个队列。
当用户输入抵达时,操作系统将其添加到未处理事件的队列中。 当你调用getNextEvent()
时,它从队列中获取最旧的事件然后交给应用程序。
中心事件总线
大多数游戏不是像这样事件驱动的,但是在游戏中使用事件循环来支撑中枢系统是很常见的。 你通常听到用“中心”“全局”“主体”描述它。 它通常被用于想要相互保持解耦的高层模块间通信。
如果你想知道为什么它们不是事件驱动的,看看游戏循环一章。
假设游戏有新手教程系统,在某些特定游戏事件后显示帮助框。 举个例子,当玩家第一次击败了邪恶野兽,你想要一个显示着“按X拿起战利品!”的小气泡。
新手教程系统很难优雅地实现,大多数玩家很少使用游戏内的帮助,所以这感觉上吃力不讨好。 但对那些使用教程的玩家,这是无价之宝。
游戏玩法和战斗代码也许像上面一样复杂。 你最不想做的就是检查一堆教程的触发器。 相反,你可以使用中心事件队列。 任何游戏系统都可以发事件给队列,这样战斗代码可以在砍倒敌人时发出“敌人死亡”事件。
类似地,任何游戏系统都能从队列接受事件。 教程引擎在队列中注册自己,然后表明它想要收到“敌人死亡”事件。 用这种方式,敌人死了的消息从战斗系统传到了教程引擎,而不需要这两个系统直接知道对方的存在。
实体可以发送和收到消息的模型很像AI界的blackboard systems。
我本想将这个作为这章其他部分的例子,但是我真的不喜欢这样巨大的全局系统。 事件队列不需要在整个游戏引擎中沟通。在一个类或者领域中沟通就足够有用了。
你说什么?
所以说点别的,让我们给游戏添加一些声音。 人类是视觉动物,但是听觉强烈影响到情感系统和空间感觉。 正确模拟的回声可以让漆黑的屏幕感觉上是巨大的洞穴,而适时的小提琴慢板可以让心弦拉响同样的旋律。
为了获得优秀的音效表现,我们从最简单的解决方法开始,看看结果如何。 添加一个“声音引擎”,其中有使用标识符和音量就可以播放音乐的API:
我总是离单例模式远远的。 这是少数它可以使用的领域,因为机器通常只有一个声源系统。 我使用更简单的方法,直接将方法定为静态。
class Audio |
它负责加载合适的声音资源,找到可靠的播放频道,然后启动它。 这章不是关于某个平台真实的音频API,所以我会假设在其他某处魔术般实现了一个。 使用它,我们像这样写方法:
void Audio::playSound(SoundId id, int volume) |
我们签入以上代码,创建一些声音文件,然后在代码中加入一些对playSound()
的调用。 举个例子,在UI代码中,我们在选择菜单项变化时播放一点小音效:
class Menu |
这样做了之后,我们注意到有时候你改变菜单项目,整个屏幕就会冻住几帧。 我们遇到了第一个问题:
问题一:API在音频引擎完成对请求的处理前阻塞了调用者。
我们的playSound()
方法是同步的——它在从播放器放出声音前不会返回调用者。 如果声音文件要从光盘上加载,那就得花费一定时间。 与此同时,游戏的其他部分被卡住了。
现在忽视这一点,我们继续。 在AI代码中,我们增加了一个调用,在敌人承受玩家伤害时发出痛苦的低号。 没有什么比在虚拟的生物身上施加痛苦更能温暖玩家心灵的了。
这能行,但是有时玩家打出暴击,他在同一帧可以打到两个敌人。 这让游戏同时要播放两遍哀嚎。 如果你了解一些音频的知识,那么就知道要把两个不同的声音混合在一起,就要加和它们的波形。 当这两个是同一波形时,它与一个声音播放两倍响是一样的。那会很刺耳。
我在完成Henry Hatsworth in the Puzzling Adventure时遇到了同样的问题。解决方法和这里的很相似。
在Boss战中有个相关的问题,当有一堆小怪跑动并制造伤害时。 硬件只能同时播放一定数量的音频。当数量超过限度时,声音就被忽视或者切断了。
为了处理这些问题,我们需要获得音频调用的整个集合,用来整合和排序。 不幸的是,音频API独立处理每一个playSound()
调用。 看起来这些请求像是从针眼穿过一样,一次只能有一个。
问题二:请求无法合并处理。
这个问题与下面的问题相比只是小烦恼。 现在,我们在很多不同的游戏系统中散布了playSound()
调用。 但是游戏引擎是在现代多核机器上运行的。 为了使用多核带来的优势,我们将系统分散在不同线程上——渲染在一个,AI在另一个,诸如此类。
由于我们的API是同步的,它在调用者的线程上运行。 当从不同的游戏系统调用时,我们从多个线程同时使用API。 看看示例代码,看到任何线程同步性吗?我也没看到。
当我们想要分配一个单独的线程给音频,这个问题就更加严重。 当其他线程都忙于互相跟随和制造事物,它只是傻傻待在那里。
问题三:请求在错误的线程上执行。
音频引擎调用playSound()
意味着,“放下任何东西,现在就播放声音!”立即就是问题。 游戏系统在它们方便时调用playSound()
,但是音频引擎不一定能方便去处理这个请求。 为了解决这点,我们需要将接受请求和处理请求解耦。
模式
事件队列在队列中按先入先出的顺序存储一系列通知或请求。 发送通知时,将请求放入队列并返回。 处理请求的系统之后稍晚从队列中获取请求并处理。 这解耦了发送者和接收者,既静态又及时。
何时使用
如果你只是想解耦接收者和发送者,像观察者模式 和命令模式都可以用较小的复杂度进行处理。 在解耦某些需要及时处理的东西时使用队列。
我在之前的几乎每章都提到了,但这值得反复提。 复杂度会拖慢你,所以要将简单视为珍贵的财宝。
用推和拉来考虑。 有一块代码A需要另一块代码B去做些事情。 对A自然的处理方式是将请求推给B。
同时,对B自然的处理方式是在B方便时将请求拉入。 当一端有推模型另一端有拉模型,你需要在它们之间设置缓存。 这就是队列比简单的解耦模式多提供的部分。
队列给了代码对拉取的控制权——接收者可以延迟处理,合并或者忽视请求。 但队列做这些事是通过将控制权从发送者那里拿走完成的。 发送者能做的就是向队列发送请求然后祈祷。 当发送者需要回复时,队列不是好的选择。
记住
不像本书中的其他模式,事件队列很复杂,会对游戏架构产生广泛影响。 这就意味着你得仔细考虑如何——或者要不要——使用它。
中心事件队列是一个全局变量
这个模式的常用方法是一个大的交换站,游戏中的每个部分都能将消息送到这里。 这是很有用的基础架构,但是有用并不代表好用。
可能要走一些弯路,但是我们中的大多数最终学到了全局变量是不好的。 当有一小片状态,程序的每部分都能接触到,会产生各种微妙的相关性。 这个模式将状态封装在协议中,但是它还是全局的,仍然有全局变量引发的全部危险。
世界的状态可以因你改变
假设在虚拟的小怪结束它一生时,一些AI代码将“实体死亡”事件发送到队列中。 这个事件在队列中等待了谁知有多少帧后才排到了前面,得以处理。
同时,经验系统想要追踪英雄的杀敌数,并对他的效率加以奖励。 它接受每个“实体死亡”事件,然后决定英雄击杀了何种怪物,以及击杀的难易程度,最终计算出合适的奖励。
这需要游戏世界的多种不同状态。 我们需要死亡的实体以获取击杀它的难度。 我们也许要看看英雄的周围有什么其他的障碍物或者怪物。 但是如果事件没有及时处理,这些东西都会消失。 实体可能被清除,周围的东西也有可能移开。
当你接到事件时,得小心,不能假设现在的状态反映了事件发生时的世界。 这就意味着队列中的事件比同步系统中的事件需要存储更多数据。 在后者中,通知只需说“某事发生了”然后接收者可以找到细节。 使用队列时,这些短暂的细节必须在事件发送时就被捕获,以方便之后使用。
会陷于反馈系统环路中
任何事件系统和消息系统都得担心环路:
- A发送了一个事件
- B接收然后发送事件作为回应。
- 这个事件恰好是A关注的,所以它收到了。为了回应,它发送了一个事件。
- 回到2.
当消息系统是同步的,你很快就能找到环路——它们造成了栈溢出并让游戏崩溃。 使用队列,它会异步地使用栈,即使虚假事件晃来晃去,游戏仍然可以继续运行。 避免这个的通用方法就是避免在处理事件的代码中发送事件。
在你的事件系统中加一个小小的漏洞日志也是一个好主意。
示例代码
我们已经看到一些代码了。它不完美,但是有基本的正确功能——公用的API和正确的底层音频调用。 剩下需要做的就是修复它的问题。
第一个问题是我们的API是阻塞的。 当代码播放声音时,它不能做任何其他事情,直到playSound()
加载完音频然后真正地开始播放。
我们想要推迟这项工作,这样 playSound()
可以很快地返回。 为了达到这一点,我们需要具体化播放声音的请求。 我们需要一个小结构存储发送请求时的细节,这样我们晚些时候可以使用:
struct PlayMessage |
下面我们需要给Audio
一些存储空间来追踪正在播放的声音。 现在,你的算法专家也许会告诉你使用激动人心的数据结构, 比如Fibonacci heap或者skip list或者最起码链表。 但是在实践中,存储一堆同类事物最好的办法是使用一个平凡无奇的经典数组:
算法研究者通过发表对新奇数据结构的研究获得收入。 他们不鼓励使用基本的结构。
- 没有动态分配。
- 没有为记录信息造成的额外的开销或者多余的指针。
- 对缓存友好的连续存储空间。
更多“缓存友好”的内容,见数据局部性一章。
所以让我们开干吧:
class Audio |
我们可以将数组大小设置为最糟情况下的大小。 为了播放声音,简单地将新消息插到最后:
void Audio::playSound(SoundId id, int volume) |
这让playSound()
几乎是立即返回,当然我们仍得播放声音。 那块代码在某处,即update()
方法中:
class Audio |
现在我们需要在方便时候调用。 这个“方便”取决于你的游戏。 它也许要从主游戏循环中或者专注于音频的线程中调用。
就像名字暗示的,这是更新方法模式。
这可行,但是这假定了我们在对update()
的单一调用中可以处理每个声音请求。 如果你做了像在声音资源加载后处理异步请求的事情,这就没法工作了。 update()
一次处理一个请求,它需要有完成一个请求后从缓存中再拉取一个请求的能力。 换言之,我们需要一个真实的队列。
环状缓存
有很多种方式能实现队列,但我最喜欢的是环状缓存。 它保留了数组的所有优点,同时能让我们不断从队列的前方移除事物。
现在,我知道你在想什么。 如果我们从数组的前方移除东西,不是需要将所有剩下的部分都移动一次吗?这不是很慢吗?
这就是为什么要学习链表——你可以从中移除一个节点,而无需移动东西。 好吧,其实你可以用数组实现一个队列而无需移动东西。 我会展示给你看,但是首先预习一些术语:
- 队列的头部是读取请求的地方。头部存储最早发出的请求。
- 尾部是另一端。它是数组中下个写入请求的地方。注意它指向队列终点的下一个位置。你可以将其理解为一个半开半闭区间,如果这有帮助的话。
由于 playSound()
向数组的末尾添加了新的请求,头部开始指向元素0而尾部向右增长。
让我们开始编码。首先,我们显式定义这两个标记在类中的意义:
class Audio |
在 playSound()
的实现中,numPending_
被tail_
取代,但是其他都是一样的:
void Audio::playSound(SoundId id, int volume) |
更有趣的变化在update()
中:
void Audio::update() |
我们在头部处理,然后通过将头部指针向右移动来消除它。 我们定义头尾之间没有距离的队列为空队列。
这就是为什么我们让尾部指向最后元素之后的那个位置。 这意味着头尾相等则队列为空。
现在,我们获得了一个队列——我们可以向尾部添加元素,从头部移除元素。 这里有很明显的问题。在我们让队列跑起来后,头部和尾部继续向右移动。 最终tail_
碰到了数组的尾部,欢乐时光结束了。 接下来是这个方法的灵巧之处。
你想结束欢乐时光吗?不,你不想。
注意当尾部移动时,头部 也是如此。 这就意味着在数组开始部分的元素不再被使用了。 所以我们做的就是,当抵达末尾时,将尾部折回到数组的头部。 这就是为什么它被称为环状缓存,它表现得像是一个环状的数组。
这个的实现非常简单。 当我们入队一个事物时,只需要保证尾部在抵达末尾的时候折回到数组的开头:
void Audio::playSound(SoundId id, int volume) |
替代tail++
,将增量设为数组长度的模,这样可将尾部回折回来。 另一个改变是断言。我们得保证队列不会溢出。 只要这里有少于MAX_PENDING
的请求在队列中,在头部和尾部之间就有没有使用的间隔。 如果队列满了,那就不会有间隔了,就像古怪的衔尾蛇一样,尾部会遇到头部然后覆盖它。 断言保证了这不会发生。
在update()
中,头部也折回了:
void Audio::update() |
这样就好——没有动态分配,没有数据拷贝,缓存友好的简单数组实现的队列完成了。
如果最大容量影响了你,你可以使用增长的数组。 当队列满了后,分配一块当前数组两倍大的数组(或者更多倍),然后将对象拷进去。
哪怕你在队列增长时拷贝,入队仍然有常数级的摊销复杂度。
合并请求
现在有队列了,我们可以转向其他问题了。 首先来解决多重请求播放同一音频,最终导致音量过大的问题。 由于我们知道哪些请求在等待处理,需要做的所有事就是将请求和早先等待处理的请求合并:
void Audio::playSound(SoundId id, int volume) |
当有两个请求播放同一音频时,我们将它们合并成只保留声音最大的请求。 这一“合并”非常简陋,但是我们可以用同样的方法做很多有趣的合并。
注意在请求入队时合并,而不是处理时。 在队列中处理更加容易,因为不需要在最终会被合并的多余请求上浪费时间。 这也更加容易被实现。
但是,这确实将处理的职责放在了调用者肩上。 对playSound()
的调用返回前会遍历整个队列。 如果队列很长,那么会很慢。 在update()
中合并也许更加合理。
避免O(n) 的队列扫描代价的另一种方式是使用不同的数据结构。 如果我们将
SoundId
作为哈希表的键,那么我们就可以在常量时间内检查重复。
这里有些要记住的要点。 我们能够合并的“同步”请求窗口只有队列长度那么大。 如果我们快速处理请求,队列长度就会保持较短,我们就有更少的机会合并东西。 同样地,如果处理慢了,队列满了,我们能找到更多的东西合并。
这个模式隔离了请求者和请求何时被处理,但如果你将整个队列交互视为与数组结构交互, 那么发出请求和处理它之间的延迟会显式地影响行为。 确认在这么做之前保证了这不会造成问题。
分离线程
最终,最险恶的问题。 使用同步的音频API,调用playSound()
的线程就是处理请求的线程。 这通常不是我们想要的。
在今日的多核硬件上,你需要不止一个线程来最大程度使用芯片。 有无数的编程范式在线程间分散代码,但是最通用的策略是将每个独立的领域分散到一个线程——音频,渲染,AI等等。
我们很容易就能做到这一点是因为三个关键点:
- 请求音频的代码与播放音频的代码解耦。
- 有队列在两者之间整理它们。
- 队列与程序其他部分是隔离的。
单线程代码同时只在一个核心上运行。 如果你不使用线程,哪怕做了流行的异步风格编程,能做的极限就是让一个核心繁忙,那也只发挥了CPU能力的一小部分。
服务器程序员将他们的程序分割成多个独立进程作为弥补。 这让系统在不同的核上同时运行它们。 游戏几乎总是单进程的,所以增加线程真的有用。
剩下要做的事情就是写修改队列的方法——playSound()
和update()
——使之线程安全。 通常,我会写一写具体代码完成之,但是由于这是一本关于架构的书,我不想着眼于一些特定的API或者锁机制。
从高层看来,我们只需保证队列不是同时被修改的。 由于playSound()
只做了一点点事情——基本上就是声明字段——不会阻塞线程太长时间。 在update()
中,我们等待条件变量之类的东西,直到有请求需要处理时才会消耗CPU循环。
设计决策
很多游戏使用事件队列作为交流结构的关键部分,你可以花很多时间设计各种复杂的路径和消息过滤器。 但是在构建洛杉矶电话交换机之类的东西之前,我推荐你从简单的开始。这里是几个需要在开始时思考的问题:
队列中存储了什么?
到目前为止,我交替使用“事件”和“消息”,因为大多时候两者的区别并不重要。 无论你在队列中塞了什么都可以获得解耦和合并的能力,但是还是有几个地方不同。
如果你存储事件:
“事件”或者“通知”描绘已经发生的事情,比如“怪物死了”。 你入队它,这样其他对象可以对这个事件作出回应,有点像异步的观察者模式。
- 很可能允许多个监听者。 由于队列包含的是已经发生的事情,发送者可能不关心谁接受它。 从这个层面来说,事件发生在过去,早已被遗忘。
- 访问队列的模块更广。 事件队列通常广播事件到任何感兴趣的部分。为了尽可能允许所有感兴趣的部分访问,队列一般是全局可见的。
如果你存储消息:
“消息”或“请求”描绘了想要发生在未来的事情,比如“播放声音”。可以将其视为服务的异步API。
更可能只有一个监听者。 在这个例子中,存储的消息只请求音频API播放声音。如果引擎的随便什么部分都能从队列中拿走消息,那可不好。
另一个描述“请求”的词是“命令”,就像在命令模式中那样,队列也可以在那里使用。
我在这里说“更可能”,因为只要像期望的那样处理消息,消息入队时可以不必担心哪块代码处理它。 这样的话,你在做的事情类似于服务定位器。
谁能从队列中读取?
在例子中,队列是密封的,只有Audio
类可以从中读取。 在用户交互的事件系统中,你可以在核心内容中注册监听器。 有时可以听到术语“单播”和“广播”来描述它,两者都很有用。
单播队列:
在队列是类API的一部分时,单播是很自然的。 就像我们的音频例子,从调用者的角度来说,它们只能看到可以调用的playSound()
方法。
队列变成了读取者的实现细节。 发送者知道的所有事就是发条消息。
队列更封装。 其他都一样时,越多封装越方便。
无须担心监听者之间的竞争。 使用多个监听者,你需要决定队列中的每个事物一对多分给全部的监听者(广播) 还是队列中的每个事物一对一分给单独的监听者(更加像工作队列)。
在两种情况下,监听者最终要么做了多余的事情要么在相互干扰,你得谨慎考虑想要的行为。 使用单一的监听者,这种复杂性消失了。
广播队列:
这是大多数“事件”系统工作的方法。如果你有十个监听者,一个事件进来,所有监听者都能看到这个事件。
事件可能无人接收。 前面那点的必然推论就是如果有零个监听者,没有谁能看到这个事件。 在大多数广播系统中,如果处理事件时没有监听者,事件就消失了。
也许需要过滤事件。 广播队列经常对程序的所有部分可见,最终你会获得一系列监听者。 很多事件乘以很多监听者,你会获取一大堆事件处理器。
为了削减大小,大多数广播事件系统让监听者筛出其需要接受的事件。 比如,可能它们只想要接受鼠标事件或者在某一UI区域内的事件。
工作队列:
类似广播队列,有多个监听器。不同之处在于队列中的每个东西只会投到监听器其中的一个。 常应用于将工作打包给同时运行的线程池。
- 你得规划。 由于一个事物只有一个监听器,队列逻辑需要指出最好的选项。 这也许像round robin算法或者乱序选择一样简单,或者可以使用更加复杂的优先度系统。
谁能写入队列?
这是前一个设计决策的另一面。 这个模式兼容所有可能的读/写设置:一对一,一对多,多对一,多对多。
你有时听到用“扇入”描述多对一的沟通系统,而用“扇出”描述一对多的沟通系统。
使用单个写入器:
这种风格和同步的观察者模式很像。 有特定对象收集所有可接受的事件。
- 你隐式知道事件是从哪里来的。 由于这里只有一个对象可向队列添加事件,任何监听器都可以安全地假设那就是发送者。
- 通常允许多个读取者。 你可以使用单发送者对单接收者的队列,但是这样沟通系统更像纯粹的队列数据结构。
使用多个写入器:
这是例子中音频引擎工作的方式。 由于playSound()
是公开的方法,代码库的任何部分都能给队列添加请求。“全局”或“中心”事件总线像这样工作。
- 得更小心环路。 由于任何东西都有可能向队列中添加东西,这更容易意外地在处理事件时添加事件。 如果你不小心,那可能会触发反馈循环。
- 很可能需要在事件中添加对发送者的引用。 当监听者接到事件时,它不知道是谁发送的,因为可能是任何人。 如果它确实需要知道发送者,你得将发送者打包到事件对象中去,这样监听者才可以使用它。
对象在队列中的生命周期如何?
使用同步的通知,当所有的接收者完成了消息处理才会返回发送者。 这意味着消息本身可以安全地存在栈的局部变量中。 使用队列,消息比让它入队的调用活得更久。
如果你使用有垃圾回收的语言,你无需过度担心这个。 消息存到队列中,会在需要它的时候一直存在。 而在C或C++中,得由你来保证对象活得足够长。
传递所有权:
这是手动管理内存的传统方法。当消息入队时,队列拥有了它,发送者不再拥有它。 当它被处理时,接收者获取了所有权,负责销毁他。
在C++中,
unique_ptr<T>
给了你同样的语义。共享所有权:
现在,甚至C++程序员都更适应垃圾回收了,分享所有权更加可接受。 这样,消息只要有东西对其有引用就会存在,当被遗忘时自动释放。
同样的,C++的风格是使用
shared_ptr<T>
。队列拥有它:
另一个选项是让消息永远存在于队列中。 发送者不再自己分配消息的内存,它向内存请求一个“新的”消息。 队列返回一个队列中已经在内存的消息的引用,接收者引用队列中相同的消息。
换言之,队列存储的背后是一个对象池模式。
参见
我在之前提到了几次,很大程度上, 这个模式是广为人知的观察者模式的异步实现。
就像其他很多模式一样,事件队列有很多别名。 其中一个是“消息队列”。这通常指代一个更高层次的实现。 事件队列在应用中,消息队列通常在应用间交流。
另一个术语是“发布/提交”,有时被缩写为“pubsub”。 就像“消息队列”一样,这通常指代更大的分布式系统,而不是现在关注的这个模式。
确定状态机,很像GoF的状态模式,需要一个输入流。如果想要异步响应,可以考虑用队列存储它们。
当你有一对状态机相互发送消息时,每个状态机都有一个小小的未处理队列(被称为一个信箱), 然后你需要重新发明actor model。
Go语言内建的“通道”类型本质上是事件队列或消息队列。
服务定位器
提供服务的全局接入点,避免使用者和实现服务的具体类耦合。
动机
一些游戏中的对象或者系统几乎出现在程序库中的每一个角落。 很难找到游戏中的哪部分永远不需要内存分配,记录日志,或者随机数字。 像这样的东西可以被视为整个游戏都需要的服务。
我们考虑音频作为例子。 它不需要接触像内存分配这么底层的东西,但是仍然要接触一大堆游戏系统。 滚石撞击地面(物理)。 NPC狙击手开了一枪,射出子弹(AI)。 用户选择菜单项需要响一声确认(用户界面)。
每处都需要用像下面这样的东西调用音频系统:
// 使用静态类? |
尽管每种都能获得想要的结果,但是我们会绊倒在一些微妙的耦合上。 每个调用音频系统的游戏部分直接引用了具体的AudioSystem
类,和访问它的机制——是静态类还是一个单例。
这些调用点,当然,需要耦合到某些东西上来播放声音, 但是直接接触到具体的音频实现,就好像给了一百个陌生人你家的地址,只是为了让他们在门口放一封信。 这不仅仅是隐私问题,在你搬家后,需要告诉每个人新地址是个更加痛苦的问题。
有个更好的解决办法:一本电话薄。 需要联系我们的人可以在上面查找并找到现在的地址。 当我们搬家时,我们通知电话公司。 他们更新电话薄,每个人都知道了新地址。 事实上,我们甚至无需给出真实的地址。 我们可以列一个转发信箱或者其他“代表”我们的东西。 通过让调用者查询电话薄找我们,我们获得了一个控制找我们的方法的方便地方。
这就是服务定位模式的简短介绍——它解耦了需要服务的代码和服务由谁提供(哪个具体的实现类)以及服务在哪里(我们如何获得它的实例)。
模式
服务 类定义了一堆操作的抽象接口。 具体的 服务提供者 实现这个接口。 分离的 服务定位器 提供了通过查询获取服务的方法,同时隐藏了服务提供者的具体细节和定位它的过程。
何时使用
当你需要让某物在程序的各处都能被访问时,你就是在找麻烦。 这是单例模式的主要问题,这个模式也没有什么不同。 我对何时使用服务定位器的最简单建议是:少用。
与其使用全局机制让某些代码接触到它,不如首先考虑将它传给代码。 这超简单,也明显保持了解耦,能覆盖你大部分的需求。
但是…… 有时候手动传入对象是不可能的或者会让代码难以阅读。 有些系统,比如日志或内存管理,不该是模块公开API的一部分。 传给渲染代码的参数应该与渲染相关,而不是与日志之类的相关。
同样,代表外设的系统通常只存在一个。 你的游戏可能只有一个音频设备或者显示设备。 这是周围环境的属性,所以将它传过十个函数让一个底层调用能够使用它会为代码增加不必要的复杂度。
如果是那样,这个模式可以帮忙。 就像我们将看到的那样,它是更加灵活、更加可配置的单例模式。 如果用得好,它能以很小的运行时开销,换取很大的灵活性。
相反,如果用得不好,它会带来单例模式的所有缺点以及更多的运行时开销。
记住
使用服务定位器的核心难点是它将依赖——在两块代码之间的一点耦合——推迟到运行时再连接。 这有了更大的灵活度,但是代价是更难在阅读代码时理解你依赖的是什么。
服务必须真的可定位
如果使用单例或者静态类,我们需要的实例不可能不可用。 调用代码保证了它就在那里。但是由于这个模式是在定位服务,我们也许要处理失败的情况。 幸运的是,我们之后会介绍一种处理它的策略,保证我们在需要时总能获得某些服务。
服务不知道谁在定位它
由于定位器是全局可访问的,任何游戏中的代码都可以请求服务,然后使用它。 这就意味着服务必须在任何环境下正确工作。 举个例子,如果一个类只能在游戏循环的模拟部分使用,而不能在渲染部分使用,那它不适合作为服务——我们不能保证在正确的时间使用它。 所以,如果你的类只期望在特定上下文中使用,避免模式将它暴露给整个世界更安全。
示例代码
重回我们的音频系统问题,让我们通过服务定位器将代码暴露给代码库的剩余部分。
服务
我们从音频API开始。这是我们服务要暴露的接口:
class Audio |
当然,一个真实的音频引擎比这复杂得多,但这展示了基本的理念。 要点在于它是个没有实现绑定的抽象接口类。
服务提供者
只靠它自己,我们的音频接口不是很有用。 我们需要具体的实现。这本书不是关于如何为游戏主机写音频代码,所以你得想象这些函数中有实际的代码,了解原理就好:
class ConsoleAudio : public Audio |
现在我们有接口和实现了。 剩下的部分是服务定位器——那个将两者绑在一起的类。
一个简单的定位器
下面的实现是你可以定义的最简单的服务定位器:
class Locator |
静态函数getAudio()
完成了定位工作。 我们可以从代码库的任何地方调用它,它会给我们一个Audio
服务实例使用:
这里用的技术被称为依赖注入,一个简单思路的复杂行话表示。 假设你有一个类依赖另一个。 在例子中,是我们的
Locator
类需要Audio
的实例。 通常,定位器负责构造实例。 依赖注入与之相反,它指外部代码负责向对象注入它需要的依赖。
Audio *audio = Locator::getAudio(); |
它“定位”的方式十分简单——依靠一些外部代码在任何东西使用服务前已注册了服务提供者。 当游戏开始时,它调用一些这样的代码:
ConsoleAudio *audio = new ConsoleAudio(); |
这里需要注意的关键部分是调用playSound()
的代码没有意识到任何具体的ConsoleAudio
类; 它只知道抽象的Audio
接口。 同样重要的是,定位器 类没有与具体的服务提供者耦合。 代码中只有初始化代码唯一知道哪个具体类提供了服务。
这里有更高层次的解耦: Audio
接口没有意识到它在通过服务定位器来接受访问。 据它所知,它只是常见的抽象基类。 这很有用,因为这意味着我们可以将这个模式应用到现有的类上,而那些类无需为此特殊设计。 这与单例形成了对比,那个会影响“服务”类本身的设计。
一个空服务
我们现在的实现很简单,而且也很灵活。 但是它有巨大的缺点:如果我们在服务提供者注册前使用服务,它会返回NULL
。 如果调用代码没有检查,游戏就崩溃了。
我有时听说这被称为“时序耦合”——两块分离的代码必须以正确的顺序调用,才能让程序正确运行。 有状态的软件某种程度上都有这种情况,但是就像其他耦合一样,减少时序耦合让代码库更容易管理。
幸运的是,还有一种设计模式叫做“空对象”,我们可用它处理这个。 基本思路是在我们没能找到服务或者程序没以正确的顺序调用时,不返回NULL
, 而是返回一个特定的,实现了请求对象一样接口的对象。 它的实现什么也不做,但是它保证调用服务的代码能获取到对象,保证代码就像收到了“真的”服务对象一样安全运行。
为了使用它,我们定义另一个“空”服务提供者:
class NullAudio: public Audio |
就像你看到的那样,它实现了服务接口,但是没有干任何实事。 现在,我们将服务定位器改成这样:
class Locator |
调用代码永远不知道“真正的”服务没找到,也不必担心处理NULL
。 这保证了它永远能获得有效的对象。
你也许注意到我们用引用而非指针返回服务。 由于C++中的引用(理论上)永远不是
NULL
,返回引用是提示用户:总可以期待获得一个合法的对象。
这对故意找不到服务也很有用。 如果我们想暂时停用系统,现在有更简单的方式来实现这点了: 很简单,不要在定位器中注册服务,定位器会默认使用空服务提供器。
日志装饰器
现在我们的系统非常强健了,让我们讨论这个模式允许的另一个好处——装饰服务。 我会举例说明。
在开发过程中,记录有趣事情发生的小小日志系统可助你查出游戏引擎正处于何种状态。 如果你在处理AI,你要知道哪个实体改变了AI状态。 如果你是音频程序员,你也许想记录每个播放的声音,这样你可以检查它们是否是以正确的顺序触发。
通常的解决方案是向代码中丢些对log()
函数的调用。 不幸的是,这是用一个问题取代了另一个——现在我们有太多日志了。 AI程序员不关心声音在什么时候播放,声音程序员也不在乎AI状态转换,但是现在都得在对方的日志中跋涉。
理念上,我们应该可以选择性地为关心的事物启动日志,而游戏成品中,不应该有任何日志。 如果将不同的系统条件日志改写为服务,那么我们就可以用装饰器模式。 让我们定义另一个音频服务提供者的实现:
class LoggedAudio : public Audio |
如你所见,它包装了另一个音频提供者,暴露同样的接口。 它将实际的音频行为转发给内部的提供者,但它也同时记录每个音频调用。 如果程序员需要启动音频日志,他们可以这样调用:
void enableAudioLogging() |
现在,对音频服务的任何调用在运行前都会记录下去。 同时,当然,它和我们的空服务也能很好地相处,你能启用音频,也能继续记录音频被启用时将会播放的声音。
设计决策
我们讨论了一种典型的实现,但是对核心问题的不同回答有着不同的实现方式:
服务是如何被定位的?
外部代码注册:
这是样例代码中定位服务使用的机制,这也是我在游戏中最常见的设计方式:
简单快捷。
getAudio()
函数简单地返回指针。这通常会被编译器内联,所以我们几乎没有付出性能损失就获得了很好的抽象层。可以控制如何构建提供者。 想想一个接触游戏控制器的服务。我们使用两个具体的提供者:一个是给常规游戏,另一个给在线游戏。 在线游戏跨过网络提供控制器的输入,这样,对游戏的其他部分,远程玩家好像是在使用本地控制器。
为了能正常工作,在线的服务提供者需要知道其他远程玩家的IP。 如果定位器本身构建对象,它怎么知道传进来什么?
Locator
类对在线的情况一无所知,更不用说其他用户的IP地址了。外部注册的提供者闪避了这个问题。定位器不再构造类,游戏的网络代码实例化特定的在线服务提供器, 传给它需要的IP地址。然后把服务提供给定位器,而定位器只知道服务的抽象接口。
可以在游戏运行时改变服务。 我们也许在最终的游戏版本中不会用到这个,但是这是个在开发过程中有效的技巧。 举个例子,在测试时,即使游戏正在运行,我们也可以切换音频服务为早先提到的空服务来临时地关闭声音。
定位器依赖外部代码。 这是缺点。任何访问服务的代码必须假定在某处的代码已经注册过服务了。 如果没有做初始化,要么游戏会崩溃,要么服务会神秘地不工作。
在编译时绑定:
这里的思路是使用预处理器,在编译时间处理“定位”。就像这样:
class Locator |
像这样定位服务暗示了一些事情:
- 快速。 所有的工作都在编译时完成,在运行时无需完成任何东西。 编译器很可能会内联
getAudio()
调用,这是我们能达到的最快方案。 - 能保证服务是可用的。 由于定位器现在拥有服务,在编译时就进行了定位,我们可以保证游戏如果能完成编译,就不必担心服务不可用。
- 无法轻易改变服务。 这是主要的缺点。由于绑定发生在编译时,任何时候你想要改变服务,都得重新编译并重启游戏。
在运行时设置:
企业级软件中,如果你说“服务定位器”,他们脑中第一反应就是这个方法。 当服务被请求时,定位器在运行时做一些魔法般的事情来追踪请求的真实实现。
反射 是一些编程语言在运行时与类型系统打交道的能力。 举个例子,我们可以通过名字找到类,找到它的构造器,然后创建实例。
像Lisp,Smalltalk和Python这样的动态类型语言自然有这样的特性,但新的静态语言比如C#和Java同样支持它。
通常而言,这意味着加载设置文件确认提供者,然后使用反射在运行时实例化这个类。这为我们做了一些事情:
我们可以更换服务而无需重新编译。 这比编译时绑定多了小小的灵活性,但是不像注册那样灵活,那里你可以真正地在运行游戏的时候改变服务。
非程序员也可改变服务。 这对于设计师是很好的,他们想要开关某项游戏特性,但修改源代码并不舒服。 (或者,更可能的,编程者 对设计者介入感到不舒服。)
同样的代码库可以同时支持多种设置。 由于从代码库中完全移出了定位处理,我们可以使用相同的代码来同时支持多种服务设置。
这就是这个模型在企业网站上广泛应用的原因之一: 只需要修改设置,你就可以在不同的服务器上发布相同的应用。 历史上看来,这在游戏中没什么用,因为主机硬件本身是好好标准化了的, 但是很多游戏的目标是大杂烩般的移动设备,这点就很有关系了。
复杂。 不像前面的解决方案,这个方案是重量级的。 你得创建设置系统,也许要写代码来加载和粘贴文件,通常要做些事情来定位服务。 花时间写这些代码,就没法花时间写其他的游戏特性。
加载服务需要时间。 现在你会眉头紧蹙了。在运行时设置意味着你在消耗CPU循环加载服务。 缓存可以最小化消耗,但是仍暗示着在首次使用服务时,游戏需要暂停花点时间完成。 游戏开发者讨厌消耗CPU循环在不能提高游戏体验的地方。
如果服务不能被定位怎么办?
让使用者处理它:
最简单的解决方案就是把责任推回去。如果定位器不能找到服务,只需返回NULL
。这暗示着:
- 让使用者决定如何掌控失败。 使用者也许在收到找不到服务的关键错误时应该暂停游戏。 其他时候可能可以安全地忽视并继续。 如果定位器不能定义全面的策略应对所有的情况,那么就将失败传回去,让每个使用者决定什么是正确的回应。
- 使用服务的用户必须处理失败。 当然,这个的必然结果是每个使用者都必须检查服务的失败。 如果它们都以相同方式来处理,在代码库中就有很多重复的代码。 如果一百个中有一个忘了检查,游戏就会崩溃。
挂起游戏:
我说过,我们不能保证服务在编译时总是可用的,但是不意味着我们不能声明可用性是游戏定位器运行的一部分。 最简单的方法就是使用断言:
class Locator |
如果服务没有被找到,游戏停在试图使用它的后续代码之前。 这里的assert()
调用没有解决无法定位服务的问题,但是它确实明确了问题是什么。 通过这里的断言,我们表明,“无法定位服务是定位器的漏洞。”
如果你没见过
assert()
函数,单例模式一章中有解释。
那么这为我们做了什么呢?
- 使用者不必处理缺失的服务。 简单的服务可能在成百上千的地方被使用,这节约了很多代码。 通过声明定位器永远能够提供服务,我们节约了使用者处理它的精力。
- 如果服务没有找到,游戏会挂起。 在极少的情况下,服务真的找不到,游戏就会挂起。 强迫我们解决定位服务的漏洞是好事(比如一些本该调用的初始化代码没有被调用), 但被阻塞的所有人都得等到漏洞修复时。与大型开发团队工作时,当这种事情发生,会增加痛苦的停工时间。
返回空服务:
我们在样例中实现中展示了这种修复。使用它意味着:
使用者不必处理缺失的服务。 就像前面的选项一样,我们保证了总是会返回可用的服务,简化了使用服务的代码。
如果服务不可用,游戏仍将继续。 这有利有弊。让我们在没有服务的情况下依然能运行游戏是很有用的。 在大团队中,当我们工作依赖的其他特性或者依赖的其他系统还没有就位时,这也是很有用的。
缺点在于,较难查找无意缺失服务的漏洞。 假设游戏用服务去获取数据,然后基于数据做出决策。 如果我们无法注册真正的服务,代码获得了空服务,游戏也许不会像期望的那样行动。 需要在这个问题上花一些时间,才能发现我们以为可用的服务是不存在的。
在这些选项中,我看到最常使用的是会找到服务的简单断言。 在游戏发布的时候,它经历了严格的测试,会在可信赖的硬件上运行。 无法找到服务的机会非常小。
我们可以让空服务被调用时打印一些debug信息来缓和这点。
在更大的团队中,我推荐使用空服务。 这不会花太多时间实现,可以减少开发中服务不可用的缺陷。 这也给你了一个简单的方式去关闭服务,无论它是有漏洞还是干扰到了现在的工作。
服务的服务范围有多大?
到目前为止,我们假设定位器给任何需要服务的地方提供服务。 当然这是这个模式的典型的使用方式,另一选项是服务范围限制到类和它的依赖类中,就像这样:
class Base |
通过这样,对服务的访问被收缩到了继承Base
的类。这两种各有千秋:
如果全局可访问:
- 鼓励整个代码库使用同样的服务。 大多数服务都被设计成单一的。 通过允许整个代码库接触到相同的服务,我们可以避免代码因不能获取“真正的”服务而到处实例化提供者。
- 我们失去了何时何地使用服务的控制权。 这是让某物全局化的明显代价——任何东西都能接触它。单例模式一章讲了全局变量是多么的糟糕。
如果接触被限制在某个类中:
我们控制了耦合。 这是主要的优点。通过显式限制服务到继承树的一个分支上,应该解耦的系统保持了解耦。
可能导致重复的付出。 潜在的缺点是如果一对无关的类确实需要接触服务,每个类都要拥有服务的引用。 无论是谁定位或者注册服务,它也需要在这些类之间重复处理。
另一个选项是改变类的继承层次,给这些类一个公共的基类,但这引起的麻烦也许多于收益。)
我的通用准则是,如果服务局限在游戏的一个领域中,那么限制它的服务范围在一个类上面。 举个例子,获取网络接口的服务可能限制于在线联网类中。 像日志这样应用更加广泛的服务应该是全局的。
参见
- 服务定位模式在很多方面是单例模式的兄弟,在应用前值得看看哪个更适合你的需求。
- Unity框架在它的
GetComponent()
方法中使用这个模式,协调它的组件模式 - 微软的XNA游戏开发框架在它的核心
Game
类中内建了这种模式。 每个实体都有一个GameServices
对象可以用来注册和定位任何种类的服务。