思考并回答以下问题:
- 模板方法模式的官方定义是什么?
- 模板方法模式属于什么类型的模式?应用场景有哪些?
本章涵盖:
- 武器的攻击流程
- 模板方法模式
- 模板方法模式的定义
- 模板方法模式的说明
- 模板方法模式的实现范例
- 使用模板方法模式实现攻击与击中流程
- 攻击与击中流程的实现
- 实现说明
- 运用模板方法模式的优点
- 修改击中流程的实现
- 模板方法模式面对变化时
- 结论
武器的攻击流程
在角色与武器的实现-桥接模式一章中,《P级阵地》的武器系统在运用桥接模式(Bridge)之后,产生了一系列的武器类(WeaponGun、WeaponRifle、WeaponRocket),而这些类都重新实现了父类IWeapon的“攻击目标Fire()”方法:
Listing1 每一个武器类实现的攻击目标方法
1 | public class WeaponGun : IWeapon |
因为游戏的需求,每一次武器攻击目标时,都要先进行:①开火/枪口特效;②子弹特效;③武器音效,之后再通知目标被击中了。所以在现有的实现方式下可以看到,每种武器类的攻击目标Fire方法中的实现方式都“非常类似”,差别仅在于每种武器所需要的特效不一样而已。
在上面的范例程序中可以看出“重复”是最大的缺点,虽然IWeapon类已经将大部分的重复功能:ShowShootEffect、ShowBulletEffect…,写成了类方法并提供参数让子类调用,但从中仍可以看到,攻击目标时的“流程”重复了。
重复的缺点在于,如果面临“演算流程需要改动”,那么势必要将所有相同演算流程的程序代码一起修正,但有些演算流程动辄数十行以上,实在不容易分别将其找出来修改。
所以,改进的关键在于如何让这些流程(或称为算法)只需要编写一遍,当需要变化时,就由实现的类来负责变化。遇到这样的需求,可以使用GoF中的模板方法模式来解决。
模板方法模式
程序代码中的“流程”,有时候不太容易观察出来,尤其是当原有的程序代码还没有经过适当重构。有个很好的判断技巧,如果程序设计师发现更新一段程序代码之后,还有另一段程序代码也使用相同的“演算流程”,且实现的内容不太一样,那么这两段程序代码就可以用模板方法模式加以重写。
模板方法模式的定义
GoF对于模板方法模式(Template Method)的定义是:
1 | 在一个操作方法中定义算法的流程,其中某些步骤由子类完成。模板方法模式让子类在不变更原有算法流程的情况下,还能够重新定义其中的步骤。 |
从上述的定义来看,模板方法模式包含以下两个概念:
1.定义一个算法的流程,即是很明确地定义算法的每一个步骤,并写在父类的方法中,而每一个步骤都可以是一个方法的调用。
2.某些步骤由子类完成,为什么父类不自己完成,却要由子类去实现呢?
- 定义算法的流程中,某些步骤需要由执行时“当下的环境”来决定。
- 定义算法时,针对每一个步骤都提供了预设的解决方案,但有时候会出现“更好的解决方法”,此时就需要让这个更好的解决方法,能够在原有的架构中被使用。
以下提供几个例子跟大家说明:
以面包的配方和制作方法为例,大概是这样写的:
食材:A1.xxx、A2.xxx、A3.xxxx…B1.yyy、B2.yyy
步骤:
(1) 将材料A1-A5混合在一起搅拌至光滑;
(2) 置于密闭空间醒面30-50分钟;
(3) 分成5等份,整形滚圆再静置约10-20分钟;
(4) 包入B1-B3内馅,整形成长条形状;
(5) 置于密闭空间做二次发酵,约30-50分钟;
(6) 烤焙:预热180°,进炉降温至165℃(或上火150℃/下火180℃),烘烤15-20分钟至表面上色即可。
如果将面包配方和制作方法看成是“算法的流程”,那么其中的1-6就是每一个步骤,而且每一个步骤遵循着一定的先后顺序。做过面包的读者应该可以了解,面包要好吃,发酵的时间长度是关键,而温度、湿度等都会影响发酵所需的时间。所以,上述制作面包的步骤中,第2、3、5项是需要实现面包的人按照当天的环境情况来决定发酵的时间,这也是为什么食谱上常出现xx-xx分钟,而不是明确告诉你一定要多少分钟,也就是GoF定义中所提示的“定义算法的流程中,某些步骤需要由执行时‘当下的环境’来决定”。
3D渲染技术(Shader)是现代3D计算机绘图重要的功能之一。它在整个3D成像的过程中,开放出两个步骤:Vertices Shader和Pixels Shader,让开发者能够加入自己编写的Shader Code,来优化游戏所需要呈现的视觉效果,如图1所示。
图1 3D计算机绘图中的Shader技术流程图
所以绘图引擎(DirectX、OpenGL)中,都事先定义了所有的绘图流程,并且开放两个步骤给程序设计师进行优化设计,让更好的成像效果能在原有的架构中被使用。近十年来,电子游戏的视觉效果越来越好,其原因之一是除了绘图引擎(DirectX、OpenGL)本身不断强化之外,也从原有的成像流程中(Rendering Pipeline)开放出两个步骤,让实现者进行优化,以达到最佳效果。
Unity3D除了提供默认的材质功能外,也提供了让开发者自行编写渲染程序(Shader Code )的功能,而这些渲染程序会在上述两个步骤中扮演重要的角色,如图2所示。
图2 Unity3D的Shader编辑环境
模板方法模式的说明
模板方法模式的类结构如图3所示。
图3 模板方法模式的类结构图
参与者的说明如下:
- AbstractClass(算法定义类)
- 定义算法架构的类。
- 可以在某个操作方法(TemplateMethod)中定义完整的流程。
- 定义流程中会调用到的方法(PrimitiveOperation),这些方法将由子类重新实现。
- ConcreteClass(算法步骤的实现类)
- 重新实现父类中定义的方法,并可按照子类的执行情况反应步骤实际的内容。
模板方法模式的实现范例
模板方法模式在实现上并不复杂,首先将算法架构定义于AbstractClass中:
Listing2 定义完整算法的各个步骤及执行顺序(TemplateMethod.cs)
1 | public abstract class AbstractClass |
类中定义了一个方法TemplateMethod,其中将算法流程定义为两个步骤:PrimitiveOperation1和PrimitiveOperation2,这两个方法也接着被声明为抽象方法,让继承的子类重新实现这两个方法。
声明两个子类来实现AbstractClass类中的各个步骤:
1 | // TemplateMethod.cs |
每个子类都重新实现了AbstractClass类中的两个抽象方法。测试程序简单地产生对象,并且通过调用父类的TemplateMethod来让子类重新实现的方法能够被执行:
Listing4 测试模板方法模式(TemplateMethodTest.cs)
1 | void UnitTest() |
执行结果
1 | ConcreteClassA.PrimitiveOperation1 |
使用模板方法模式实现攻击与击中流程
很难找出程序代码中相同的演算流程,是程序设计放弃使用模板方法模式的原因之一;另一种更常见的情况是,有时这些演算流程中会有一些小变化,也是因为这些小变化,导致程序设计放弃使用模板方法模式。而那个小变化可能是,A流程中有一个if判断语句用以决定是否执行某项功能,但在B流程中却没有这个if判断语句。当笔者在遇到这种情况时,会连同这个if判断语句一起设置为步骤的一部分,只是重构后的B类(B流程)不去重新定义这一步骤所调用的方法。
攻击与击中流程的实现
在《P级阵地》中,我们先将IWeapon类中原本一定要由子类重新实现的“攻击目标Fire”方法,设计为将原本在子类中实现的代码移到IWeapon类中,并找出需要由子类去执行的步骤,将这些步骤声明为“抽象方法”。而原本继承的子类(WeaponGun、WeaponRifle、WeaponRocket)则改成重新实现这些新的步骤方法。运用模板方法模式后的结构图并无改变,但是多了一些必须重新实现的抽象方法,如图4所示。
图4 多了重新实现的抽象方法的模板方法模式类结构图
参与者的说明如下:
- IWeapon:在攻击目标Fire方法中定义流程,也就是要执行的各个步骤,并将这些步骤声明为抽象方法。
- WeaponGun、WeaponRifle、WeaponRocket:实现IWeapon类中需要重新实现的抽象方法。
实现说明
运用模板方法模式后,IWeapon类如下:
Listing5 使用模板方法模式的武器接口(IWeapon.cs)
1 | public abstract class IWeapon |
攻击目标Fire方法将武器攻击目标分为4个执行步骤。这4个步骤都以“方法调用”的方式来完成,其中显示武器子弹特效DoShowBulletEffect和播放音效DoShowSoundEffect两个方法需要由子类来重新实现,所以声明为抽象方法。而原本继承IWeapon的3个武器类,也都要重新实现这两个方法,并且删除攻击目标Fire的程序代码:
Listing6 使用模板方法模式的武器子类
1 | public class WeaponGun : IWeapon |
运用模板方法模式的优点
在IWeapon类中,将“攻击目标Fire方法”重新修改后,攻击目标的“算法”只被编写了一次,需要变化的部分,则由实现的子类负责,这样一来,原本需要在子类中“重复实现算法”的缺点就不会再出现了。
修改击中流程的实现
在《P级阵地》中,除了IWeapon及其子类采用模板方法模式来设计之外,原本实现在角色类ICharacter中的角色受击方法UnderAttack也同时一起修改,并实现几项游戏设计的需求:
- 受到攻击时反应。
- 当玩家阵营角色(ISoldier)受到攻击后,只有阵亡时才产生特效和音效,以提示玩家有我方角色阵亡;
- 敌方阵营角色(IEnemy)受到攻击时,必定产生特效和音效,以提示玩家有敌方角色受到有效攻击。
- 不同类型的单位产生的特效和音效是不同的。
关于第一点的实现,只要将范例中角色类ICharacter中的角色受击方法UnderAttack改为抽象方法,并要求两个子类重新定义各自的受击流程即可:
Listing7 使用模板方法模式的角色接口(ICharacter.cs)
1 | public abstract class ICharacter |
ISoldier类和IEnemy类都重新实现了UnderAttack方法,且两个方法都运用了模板方法模式,各自要求继承的子类必须重新实现相关的抽象方法。这些抽象方法将满足新增的第二项需求:“不同类型的单位所产生的特效和音效是不同的”。
另外,由于游戏设计的需要,让双方阵营各自拥有3种角色类型:
- ISoldier阵营:Captain、Rookie、Sergeant;
- IEnemy阵营:Elf、Ogre、Troll。
重新实现后的结构如图5所示。
图5 用模板方法模式重新实现后的类结构图
模板方法模式面对变化时
小程将游戏架构修正完成,过了几天之后……
策划:“小程啊……”
小程头抬了一下,这个语调听起来像是有什么麻烦事了……,
小程:“什么事?”
策划:“你会不会觉得攻击时的枪口特效太明显了,不容易看到子弹从武器发出去的位置?”
小程:“嗯…是有点。”
策划:“那可以先关掉吗?我现在只能一个个地调,这样做很慢,等看完后再调回来。”
小程此时心想:“还好我先将武器的攻击目标方法用模板方法模式重构过了,只要将方法中的ShowShootEffect()先注释起来就好了,这样所有的武器都不会发出枪口特效了。”1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 攻击目标
public void Fire(ICharacter theTarget)
{
// 显示武器发射/枪口特效
// ShowShootEffect();
// 显示武器子弹特效(子类实现)
DoShowBulletEffect(theTarget);
// 播放音效(子类实现)
DoShowSoundEffect();
// 直接命中攻击
theTarget.UnderAttack(m_WeaponOwner);
}
修改好之后,提交推送给策划去测试了。
又过了没多久……
策划:“小程。”
小程:“测试完要改回来了吗?”
策划:“差不多了,但是我发现新的问题。”
小程: “是Bug吗?!”
策划:“不是,不用那么紧张,我是觉得武器的音效好像慢了一点,怎么好像是特效出来后,音效延迟了一下才出来。”
小程:“哦~因为流程上,是先显示特效再播放音效,所以有可能是因为加载延迟的关系。”
策划:“那可以改一下流程,让我测试看看吗?”
小程:“那是要…..”
策划:“就是先播放音效再显示特效,可以吗?”
小程:“哈~还好我前阵子重构过了,如果你更早之前找我,这个修改大概要变动3个类的程序代码,现在只要改一个就可以了。”
小程指的是,只要在IWeapon的攻击方法Fire中将DoShowSoundEffect的执行位置往前移至最前面,就可以一次性完成所有武器的攻击流程的修改:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// 攻击目标
public void Fire(ICharacter theTarget)
{
// 播放音效(子类实现)
DoShowSoundEffect();
// 显示武器发射/枪口特效
// ShowShootEffect();
// 显示武器子弹特效(子类实现)
DoShowBulletEffect(theTarget);
// 直接命中攻击
theTarget.UnderAttack(m_WeaponOwner);
}
如果是在之前,那么所有的子类(WeaponGun、WeaponRifle、WeaponRocket)都要一起修改,而重新设计后的架构,还可以因为测试结果没有新的变化,再更改回原本的流程。所以,只需要修改算法的结构而不必更改子类的程序代码,这是减少“重复流程”程序代码后带来的好处。
结论
运用模板方法模式的优点是,将可能出现重复的“算法流程”,从子类提升到父类中,减少重复的发生,并且也开放子类参与算法中各个步骤的执行或优化。但如果“算法流程”开放太多的步骤,并要求子类必须全部重新实现的话,反而会造成实现的困难,也不容易维护。
其他应用方式
- 奇幻类角色扮演游戏(RPG),对于游戏角色要施展一个法术时,会有许多特定的检查条件,如魔力是否足够、是否还在冷却时间内、对象是否在法术施展范围内等。如果这些检查条件会按照施展法术的类型而有所不同,那么就可以使用模板方法模式将检查流程固定下来,真正检查的功能则交给各法术子类去实现。另外,一个法术的施展流程和击中计算也可以如同本章范例一样,将流程固定下来,细节交给各法术子类去实现。
- 在线游戏的角色登录,也可以使用模板方法模式将登录流程固定下来,例如:显示登录画面、选择登录方法、输入账号密码、向Server请求登录等,然后让登录功能的子类去重新实现其中的步骤。另外,也可以实现不同的登录流程样板来对应不同的登录方式(OpenID、自动创建、快速登录等)。