使用UnrealScript实现基于观察者模式的事件系统

观察者模式

观察者模式是一种经典的设计模式,具体到事件系统上,可以使用下图表述:

每个需要广播事件的对象(称为广播者Broadcaster),可以拥有一个或多个事件Event)。每个事件用来描述一件特定的事情,并且可以附带额外的信息。任何对这一事件感兴趣的对象(称为监听者Listener)可以提供相符的句柄Handler)来监听它。每当事件发生(称为‘被触发’)时,广播者会通过监听者提供的句柄来通知监听者。

不同于传统的单路回调模式,上述模式是支持多路派分的。也就是说,可以有多个监听者同时监听同一事件。当事件发生时,所有监听者会被依次通知到。

以上图为例,广播者Pawn提供了三个事件:Died、OwnerChanged和HPChanged。这三个事件演示了三种常见的应用场景:

-          AudioEffectManager(音效管理器)需要在角色死亡时播放一段语音,因此它监听了Pawn的Died事件。这与传统的回调模式的用法类似;

-          没有任何子系统关注角色的所有者改变事件(OwnerChanged),因此没有人监听它。当该事件发生时,也不会有任何其他对象得到通知;

-          HUD(游戏主界面)和StatisticsService(战术统计服务)对角色的生命值改变(HPChanged)事件比较关注,因此同时监听了这一事件。当Pawn的生命值发生改变时,会通过HUD提供的两个句柄(UpdateHPDisplay,用于更新生命值的显示,以及UpdateFinalStrikeNotification,用于更新补刀提示)来通知HUD;以及通过UpdateStatistics句柄来通知StatisticsService。通知的顺序取决于它们监听的先后次序。

在传统的实现方式中,通常会在Pawn的实现里显式地通知其他各个子系统执行相应的任务(例如调用HUD的UpdateHPDisplay函数来刷新生命值显示),这会使Pawn与其他子系统产生耦合性,换言之,如果缺乏其他子系统,Pawn就无法独立工作。观察者模式则是对传统实现方式的控制反转(Inversion of Control,IoC):对象(在本例中,Pawn)只需要提供这些事件(生命值发生改变),并且在恰当的时机触发它们,而无需关心谁(HUD)会对此做出何种响应(刷新生命显示)。如此一来,便解除了它们之间的耦合性:即使HUD子系统被移除,Pawn依然可以正常工作,而不是卡在通知HUD刷新生命值显示的地方。

这种模式在Unreal的网络架构下还有一些额外的好处。在Unreal中,服务器和客户端的代码是写在一起的。在Dedicated Server模式下,服务器里依然会混杂着客户端的代码(例如更新界面;而这些代码是无法移除的,因为在Standalone模式下会用到它们),只不过这些代码会被自动跳过——而这种方式无疑是丑陋的,因为开发者并不容易区分哪些是服务器代码,哪些是客户端代码,哪些代码会在服务器执行时被跳过。运用基于观察者模式的事件系统,则可以用一种更美观的方式来表现这一机制:在需要调用可能尽在客户端执行的代码时,并不直接调用,而是抛出事件。如果此时游戏运行在Standalone模式,那么客户端的相应组件(例如界面)会被初始化以监听这些事件;反之如果运行在Dedicated Server模式下,客户端组件不会被初始化,这些事件也就无人监听,不会产生任何副作用了(事件系统的管理开销除外)。

实现

事件系统可以有很多种实现方式,在Unreal的native(C++)代码里就实现了一套比较简陋的事件系统(参考FCallbackEventDevice接口)。大体上我们可以把实现方式分成两类:集权制和自主制。

集权制需要一个调度器来维护所有的事件及其监听句柄,事件提供者需要向调度器注册自己所能提供的事件(FCallbackEventDevice的实现更为简陋,所有可触发的事件都是在一个枚举里写死的,不具备扩展性),而监听者也需要告知调度器自己想要监听什么事件,并提供句柄。当然,触发事件也是需要通过调度器来达成的。

自主制则是由事件的提供者自行维护事件结构和监听句柄,触发事件也与调用自身的成员方法别无二致。监听者如果想要监听某个事件,首先需要找到事件的提供者,然后提供自己的监听句柄。C#中的事件便是这种模式。

两种事件模式各有优劣。对于集权制来说,通过合理的设计,它可以进一步解除监听者和广播者之间的单向耦合,实现完全的控制反转;另外,提供一些群体性的事件也更容易(例如Pawn的创建事件);但显著的缺点是难以实现对事件参数的静态检查——在UnrealScript这种不支持泛型的语言中更为困难。自主制的事件系统则完全相反:监听者必然单向耦合于广播者;对于群体性事件,有的必须通过额外的管理层来提供(如使用额外的PawnManager来提供Pawn的创建事件,因为在Pawn创建之前没人可以访问到它,也就没人能监听到它自身提供的创建事件),有的宜用额外的管理层来更方便和高效地处理(例如,如果关心所有Pawn的死亡事件,如果没有一个PawnManager来统一提供,那么就需要分别监听每一个Pawn)。而由于事件可以被实现为自身的成员,因此可以很方便地实现参数的静态类型检查。

实现前者并不复杂,本文不再赘述;而在不支持泛型的UnrealScript中实现自主制的事件系统,还是有一点挑战的。

鉴于我们实现的是一个非常底层的系统,可能在上层被大规模的使用,因此首先要考虑到以下几点需求:

-          管理开销(overhead)低。额外的管理机制带来了额外的性能开销,由于事件系统被设计为一个通用、使用广泛的系统,因此降低管理开销极为重要。

-          通用。事件系统不应该仅设计于满足某个特定的系统(例如UI系统),因为其他系统也会有类似的需求,如果不能做到通用,就可能导致每个系统都有自己的事件体系,难于管理。

-          使用简便。强大而复杂的系统无法战胜快速而丑陋(quick and dirty)的开发方式,因此在设计事件系统时,一定要注重使用上的便利性。

首先想到的是借鉴C#的事件模式:每个事件都是一个对象,每个监听句柄都是一个delegate(因此可以通过重载操作符来实现event += handler这样的语法,真棒!)——而恰巧UnrealScript也提供了delegate,一切都看起来水到渠成。

delegate委托可以理解为函数指针,但是它是类型安全的。一个delegate就是一个特定的函数类型,这类函数都具有相同的签名(相同的参数数量和类型,以及相同的返回值类型)。利用delegate,可以在变量之间安全地传递函数指针;调用一个delegate就像调用一个函数指针所指向的函数一样,但是delegate保存了函数的签名信息,也就可以在编译时进行类型检查,确保类型安全。

那么我们来设计一下事件对象。它应该提供一个容器,记录所有的监听句柄,而UnrealScript提供的唯一的容器是泛型数组。不过数组里面的类型应该填什么呢?

……实在想不出来,因为事件的参数类型和个数是未知的,也就无法确定它对应的delegate类型。

那么只好退而求其次,我们约定一个事件最多能带一个参数(或者两个、三个,随便你),并且我们放弃对它的静态类型检查,使用Object作为参数的类型:

delegate EventHandler(object arg);

这样应该至少能实现事件系统的基本功能吧。不过很快,我们就遭遇到了两个致命性的打击:

  1. 在UnrealScript里,诸如int、string之类的基本类型并非从Object派生。也就是说,这个事件无法用于传递基本类型的参数;
  2. 通用起见,我们把这个delegate的声明放到了Core\Object.uc中,以便其他所有对象都能方便的访问到。不过在编译脚本时,编译器却崩溃掉了。原因不去深究,反正在Object.uc里声明delegate似乎不太实际。

那么……可不可以不用delegate?比如我好像记得,可以用function作为参数的类型,虽然这仅仅适用于native函数,至少我们可以把function传给native代码来调用。至于参数类型检查,如果做不到静态检查,至少可以做动态的。

遗憾的是这也是不可行的,因为在UnrealScript中无法将一个函数传递给function类型的参数,这个参数就是个花瓶,在UnrealScript中毫无用处。

总之,寄希望于UnrealScript本身的语言特性似乎是没什么希望了。不过换个角度想想,虽然UnrealScript如此简陋,但实现观察者模式至少是没有问题的,而我们只是在寻求一种更简洁更统一的语法。不如我们先来看看最坏的情况,如果我们不借助事件系统,手写每一个事件,代码应该是什么样子。以Pawn的HPChanged事件作为例子:

首先声明事件。事件本质上是其监听句柄的容器,因此可以声明为Pawn类的一个成员变量:

var array<delegate<OnHPChanged> > HPChanged;

OnHPChanged这个delegate自然也需要声明:

delegate OnHPChanged(object sender, int HP);

其中sender永远指代事件的触发者,在这里,多数情况下会是相应的Pawn。

接下来还需要编写一个函数,用于触发这个事件。基本逻辑是遍历HPChanged数组,然后依次调用其中的每个元素。这里我们可以利用UnrealScript的一个特性:delegate也可以拥有函数体。此时,delegate不仅是一个类型声明,还可以是一个函数(这点与C#类似,只不过C#的delegate不能直接带有函数体,而且可以承载多个函数引用):

delegate OnHPChanged(object sender, int HP)
{
    local delegate<OnHPChanged> handler;
    foreach HPChanged(handler) 
    { 
        handler(sender, HP);
    } 
}

于是监听这个事件就变成了:

function OnPawnHPChanged(object sender, int HP)
{
    // do stuff
}
SomePawn.HPChanged.AddItem(OnPawnHPChanged);

而触发事件更是简单:

OnHPChanged(self, self.HP);

对于每个事件来说,其基本模式都是这样的。很容易就可以发现,在上面的这些代码中,对于不同的事件,只有其名称和参数类型、参数数量,也就是我用粗斜体标明的部分,是不同的。如果要做一套通用的事件系统,只需要把这几个元素抽象出来。

这样一来,思路就很清晰了,对于OnHPChanged这样的名称,显然很适合用宏来拼接。至于参数,用宏来生成应该也不是大问题。

声明一个事件:

`define Event(eventName, argtype) \
var array<delegate<On`eventName> > `eventName; \n \
delegate On`eventName(object sender`if(`argtype), `argtype arg`endif)\n \
{ \n \
    local delegate<On`eventName> handler; \n \
    foreach `eventName()(handler) \n \
    { \n \
        handler(sender`if(`argtype), arg`endif); \n \
    } \n \
}

对于不熟悉UnrealScript的预处理器的同学来,这里做一些简单的说明:

  1. 使用一个宏,或者引用一个宏参数,都需要以`符号开头。这个符号叫“caret”,位于标准键盘上主键盘区数字键1的左边;
  2. UnrealScript的预处理器不太聪明,如果引用一个宏参数,它会以为这也是一个宏,因此如果后面紧跟着括号,括号里的内容会被当作这个参数的参数(而被吃掉)。如果需要在宏参数后面紧跟括号,有两种方式:a) 空一格; b) 在中间插入一对空括号;
  3. 行末的反斜杠\是续行符,表示下一行依然是这个宏的内容;
  4. `if(`argtype), `argtype() arg`endif这句话的意思是说,如果指定了argtype参数,则展开后面直到`endif的内容到代码流。这种机制是实现可变参数的关键。本文给出的示例代码较为简略,只允许事件带有零个或一个参数;但通过这种机制,可以允许事件附带更多的参数。

不过这样写还是存在问题,因为UnrealScript有一个(脑残的)约束:成员变量的声明必须在所有其他成员声明之前。由于在我们的设计里,事件由一个成员变量和一个成员函数(delegate)构成,因此这两个部分必须拆开来写,不能使用同一个宏来生成。所以我们只能把上述宏一分为二:

`define Event(eventName) var array<delegate<On`eventName> > `eventName;
`define EventBody(eventName, argtype) \
delegate On`eventName(object sender`if(`argtype), `argtype arg`endif)\n \
{ \n \
    local delegate<On`eventName> handler; \n \
    foreach `eventName()(handler) \n \
    { \n \
        handler(sender`if(`argtype), arg`endif); \n \
    } \n \
}

如此一来,声明一个事件需要有两个步骤:

  1. 在成员变量声明区使用`Event宏:
    `Event(HPChanged);
  2. 在成员变量声明区之后使用`EventBody宏:
    `EventBody(HPChanged, int);

     

我会选择在成员变量声明区的最后声明事件,这样可以尽可能地把两个部分写到一起。

相应的,定义监听和触发事件的宏就变得很简单了:

`define Listen(eventName, listener) `eventName.AddItem(`listener)
`define Raise(eventName, arg) On`eventName()(self`if(`arg), `arg`endif)

监听事件:

`Listen(SomePawn.HPChanged, OnPawnHPChanged);

触发事件:

`Raise(HPChanged);

至此,这个事件系统就算完工了。虽然算不上完美,但是在UnrealScript的框架下,也许没有更好的实现方式了。借助宏的强大威力,这套事件系统获得了犹如泛型加持的灵活性,语法噪音很小,管理开销也很低,并且对引擎代码没有侵入性。可以作为所有Unreal项目的基础设施加以应用;由于完全使用UnrealScript实现,UDK项目使用起来也毫无差别。最后补充两点提醒:

  1. 宏的定义可以放在Globals.uci文件中,以便整个项目都可以调用到;
  2. 借助`if宏的强大威力,可以扩展这些宏以使其支持更多的事件参数;
  3. 文中没有提及如何删除一个监听句柄,它的实现方式与`Listen非常相似,不再赘述。

 

 

P.S. 在Unreal 4中,Epic终于做了一个明智的决定,放弃了UnrealScript。因此原本准备好的一大通对UnrealScript的吐槽就不再发布了。愿它安息。

 

posted @ 2012-08-22 16:01  hillin  阅读(1266)  评论(1编辑  收藏  举报