江红伟 米洪

摘要:文章建立在抽象思维的基础上,对观察者模式进行了系统研究,并将这种良好的思维模式应用于实际的游戏引擎开发实例中。阐述了基于观察者模式的委托、事件、消息传递与响应的关系,实现程序中各类对象之间协同工作,弱化具体类之间的耦合关系,使得某些相互有联系的对象间不需要依赖对方而实现必要的通信与交互。最后在Unity3D中通过具体实例讲述了回调系统内置事件方法,以及.net泛型委托方法之间的差异,并通过事件传递消息的方法给出了游戏引擎开发中对象间完全解耦的解决方案。

关键词:观察者模式;委托;事件;消息传递

中图分类号:TP311.1      文献标识码:A

文章编号:1009-3044(2022)22-0083-04

1 引言

Unity3D是现在主流的3D游戏引擎,它支持多种面向对象的语言,其中C#语言的应用最为广泛,是典型的游戏开发和虚拟现实开发的代表。在一个游戏系统的设计中,事件和响应机制的应用是不可回避的技术。在开发中,什幺时候回调引擎基类MonoBehavior的事件、什幺时候使用.net平台的事件系统,是编程逻辑时刻需要解决的问题。

本文重点探讨和研究C#语言中基于观察者模式的事件和响应机制。一方面要尽量实现类的单一性原则,即一个类应该专注于做一件事情;另一方面,应该把程序中不变的部分分离出来形成抽象层、变动的部分形成各个不关联的具体执行层、减少耦合度,以达到程序中对象间的“低耦合、高内聚”的目标。

2 接口和抽象类

在面向对象的概念中,所有的对象都是通过类来描绘的,但是反过来并不是所有的类都是用来描绘对象的。如果一个类中没有包含具体的实现,这样的类就是抽象类。抽象类基本是用来表征在对问题领域进行分析、设计中得出的抽象概念,是对一系列看上去不同,但是本质上相同的具体概念的抽象[1]。一般来说,可以构造出一个固定的具有一组行为的抽象描述,但是这组行为却能够拥有任意多个可能的具体实现。这个抽象的描述就是抽象类,而这一组任意多个可能的具体实现则表现为所有可能的派生类。接口可以被看作是抽象类的变体,接口中所有的方法都是抽象的,可以通过接口来间接地实现多重继承。

接口和抽象类是面向对象编程的基石,赋予了C#语言强大的面向对象的能力,为类的封装、继承和多态提供良好的设计基础。在程序设计中,各类对象协同工作,对象之间通过封装和继承等方式进行通信,良好的协同工作是需要以接口和抽象类为基础而展开的。

根据里氏代换原则,派生类可以实现基类的抽象方法,而且基类应该不需要知道派生类的具体行为。接口和抽象类都属于基类,是对业务逻辑的抽象,将派生类的方法进行了一定的规范,具体的实现是由派生类来完成的。当需求有变化时,只需建立新的子类来实现接口或抽象类就可以扩展出新的功能,而原有的依赖于该基类的派生类则不需要修改,做到了程序间的“低耦合”性,甚至“无耦合”性。这样即可实现“开闭原则”,使得程序具有较好的扩展性的同时,又可以实现对子类修改的关闭,从而避免了程序员各自写的子类之间的相互影响,减少子类修改对于整个系统带来的影响。

总之,接口和抽象类都是对客户端程序的一种承诺,应该做到相对不变[2]。同时根据接口隔离原则,接口应当为客户提供尽可能小的规范[3],以明确地区分各个接口和抽象类的分工和特征。

3 观察者模式

在一个软件系统中,各类对象之间是协同工作的,对象之间可以通过继承、实例等进行消息传递。假如一个对象的行为发生变化时,和这个对象有关联的其他对象都需要在程序结构上进行重写,则这种情况就导致了对象之间的完全依赖、形成紧密的耦合。所以好的设计模式需要借以优秀的,可以方便开发者复用的程序设计模式[4]来构建良好的程序关系。

“观察者模式(Observer mode) ”或称“发布-订阅模式(Publish/Subscribe) ”属于设计模式中的行为模型,可以弱化具体类之间的耦合关系,使得某些相互有联系的对象间不需要依赖对方而实现必要的通信与交互。该模式定义了对象间的一对多的依赖关系,让多个观察者对象同时监听某一个主题对象[5]。这个目标对象在状态发生变化时,会通知所有的观察者对象,使它们能够自动更新[6]。且主题发出通知并不需要知道具体的观察者对象,观察者之间也不需要知道其他观察者的存在。观察者模式在降低程序间耦合度的同时能够维持好对象间行动的一致性,保证程序间的高度协作性。

如图1所示:Subject类(目标类,是广播者) 一般被定义为抽象类,其中保存了Observer类(观察者类,是订阅者) 的集合,所以可以让多个观察者同时监听该目标。此外它提供给ConcreteSubject类(具体目标类) 需要实现的抽象方法,以规范目标类中具体方法的实现。

Observer类(观察者类) 一般定义为接口,可以使得多个ConcreteObserver类(具体观察者类) 继承于该接口。这样使得各个具体观察者对象可以被保存在Subject类的Observer集合中,使得具体目标类可以遍历到各个具体观察者对象,并对其发送通知,实现多播效果。最后具体观察者接收到通知并执行各自的实现方法。

观察者模式实现了目标类与观察者类之间的抽象耦合,目标类只需要保存观察者对象的引用,并不需要知道具体观察者是谁,具体观察者只需遵守接口的约定即可。通过传统的抽象类和接口的方式可以实现该模式,也可以通过.net框架中的委托(Delegates) 与事件(Event) 机制来实现,相对来说委托和事件机制能进一步弱化目标类和观察者类之间的依赖关系[7]。

4 委托与事件

所有的委托类型都派生于基类System.Delegate。使用委托的时候,广播者类包含一个委托字段,广播者通过调用委托来决定什幺时候进行广播;观察者类(订阅者类) 是方法目标的接收者,通过在委托上调用“+=”开始进行监听、调用“-=”结束监听[7];一个订阅者不需要知道也不会干扰其他的订阅者,以实现订阅者之间的解耦。

实际上,委托是不可变的,使用“+=”或“-=”操作符时,其实是创建了新的委托实例,并把它赋值给了当前的委托变量,初始状态时这个委托变量可以是null,如图2所示。

在实际的使用中,泛型委托可以提供了更好的便捷性,下面的程序中定义了一个返回类型和传参类型都为Object的泛型委托,并且创建了一个发布者类,声明了这个泛型委托的实例对象OnPublisher,利用发布方法。

Publish调用了该对象委托的方法,并使用一个公有字段“output”接收了该委托的返回值,如图3所示。

委托的方法便是观察者类的具体实现方法,当然可以同时委托多个观察者以不同的实现方法,只需这些方法的签名和委托类的约定一致就行[8],这里示例了一个观察者类的实现方法,用来传入一个整数并返回一个整型数值,如图4所示。

最后在客户端程序中将观察者的实现方法赋值给委托对象,调用委托的发布方法并进行传值。这样几个程序间相互独立、各司其职,发布者和观察者之间解耦了,如图5所示。

.net3.5以后还提供了两类通用的delegates,如果方法有返回值,则使用Func或者Func<>;如果方法没有返回值,则使用Action或者Action<>。所以上例中的泛型委托就可以直接由Func<>创建委托对象了,而不用首先定义委托。<>中左侧是传参类型、右侧是返回值类型,最多可以有16个传参,如图6所示。

本例打印输出结果为“9”。如果使用不带返回值的泛型委托得到同样的输出结果,则可以使用Action<>型委托,<>中定义的是传值类型,最多也可以有16个。重构这三个类,如图7、图8、图9所示。

事件(event) 是对委托进一步封装的结果,让委托只暴露特定的部分子集,防止订阅者之间相互干扰,可以安全地实现广播者/订阅者模式[8]。在Subject类外,只能通过“+=”和“-=”来注册和注销事件、即事件访问器只能通过“+=” 和“-=”来实现。只需引入“event”关键字就可以将委托封装为事件。如上例,将“public static Action OnPublisher”改为“public static event Action OnPublisher”,即可。

5 Unity3D的事件和响应

在Unity3D中为了响应一个GameObject的事件分发,常规的做法是回调系统相关的内置事件。而MonoBehaviour是Unity中所有脚本的基类,使用C#需要显式的从MonoBehaviour继承系统内置的事件[9]。

为了讲述事件与响应的关系,在场景制作了3个三维物体作为按钮对象,并给它们配置好Collider碰撞组件。设计目标是让鼠标和这3个按钮对象之间产生互动效果,如图10所示。

建立一个类继承自MonoBehaviour基类,分别回调鼠标滑过事件(OnMouseOver) 、鼠标退出事件(OnMouseExit) 。

Unity3D中继承自MonoBehaviour的类形成实例的方法是将它作为组件挂载给游戏对象[9],所以这3个按钮对象必须分别挂载这个继承类。

这种做法虽然很轻松就实现了这3个三维按钮的鼠标交互效果,但是这种程序结构很不友好。谁是广播者、谁是订阅者似乎无法分辨。程序所有的功能都被写在了这一个类里,全耦合且毫无内聚性可言,这样导致项目几乎没有扩展与维护的可能性,牵一发则动全身。而且程序是被挂载到场景中的每个游戏对象上的,后期想要修改游戏对象的行为,则要人工地逐个去检查、修改每一个对象的各个实现方法。当场景变大后,这个修改工作将是庞大而烦琐的过程。这个回调系统事件类的基本样式如图11所示。

上文中已经探讨了“发布-订阅”模式的优势,再借助.net事件机制,完全可以设计出较为优秀的程序结构。基本原理是:写一个发布者类,利用场景中的主摄像机发出射线与场景中的Collider组件对象发生碰撞,这种碰撞有三种状态,分别是射线进入某对象、停留在某对象上和离开某对象;程序设计上可以把这个三个状态分别定义为三个事件OnRayEnter、OnRayStay、OnRayExit,而这些事件只需要传递参数、不需要有返回值,所以可以使用Action<>型的委托事件来实现;传递的参数就是由不同的事件而捕捉到的射线碰撞对象,然后由一个观察者类来接收这些事件所传递出来的参数,根据传递过来的不同的Collider组件对象来做一些具体的事务。

因为发布者类和观察者类都不需要回调Unity3D系统的内置事件,所以它们是无须继承系统基类的,这样也就无需将这两个类挂载给场景对象来形成实例。只需在客户端程序对这两个类实例化后,直接将对应的观察者方法注册给发布者的对应事件,就建立了发布者事件和观察者实现方法之间的联系。发布者类和观察者类的基本样式如图12、图13所示。

而客户端程序仍然需要继承MonoBehaviour基类,因为它必须回调Unity3D系统的Start()和Update()函数。在Start()函数中对事件进行注册,在Update()函数中回调发布者类的射线碰撞方法,并进行事件发布。客户端类的基本样式如图14所示。

最后将这个客户端类挂载给场景中的一个空物体上,让它形成一个实例。前例中,程序都被分散地挂载在各个游戏对象上,导致管理上非常混乱。而这里,场景中的资源被这个“空物体”统一化处理了,规范化了场景资源的管理。

利用.net事件机制,发布者和观察者之间一定程度上实现了解耦,发布者和观察者各自只做自己该做的事、实现了高内聚,也大大方便了项目后期的增、删、改、查。

6 Unity3D中为事件传递消息

上文已经实现了发布者模式的程序结构,但是还需要继续完善一下,因为发布者还是需要传参给观察者的,导致这两个类之间没有完全解耦。这时,可以考虑事件的消息传递机制,以达到完全解耦的目标。

.net平台中定义了一个基类EventArgs专门用来为事件传递消息。还定义了一个泛型委托EventHandler,可传递两个参数,第一个类型是Object类,是发布者;第二类型便是EventArgs类,是消息传递类[8]。

所以,可以建立一个继承EventArgs的类,专门用作事件的消息传递,作为发布者类和观察者类之间信息传递的桥梁。这样观察者就不必知道发布者所传递的是什幺了,便可实现发布者和观察者之间的完全解耦。

首先创建一个继承EventArgs基类的消息传递类PublisherEventArgs,其中定义两个公开的属性,分别是上一帧的碰撞信息、当前帧的碰撞信息。并通过构造函数对相应属性进行赋值,如图15所示。

发布者类中,重新定义三个事件为EventHandler型委托事件,并创建消息传递类PublisherEventArg的变量e,如图16所示。

对于事件的发布方法CollisionProcess(),首先对变量e进行实例化“e = new PublisherEventArgs(colliderOld, current)”;事件发布器的参数改为泛型委托EventHandler所规定的两类型参数“OnRayExit?.Invoke(this, e)”、“OnRayStay?.Invoke(this, e)”、“OnRayExit?.Invoke(this, e)”。这样就可以让相应事件发生时的碰撞对象参数传递给PublisherEventArgs类的构造函数。

游戏运行过程中,每帧都会传两个参数过去,第一个参数是上一帧的碰撞信息、第二个是当前帧的碰撞信息。这样就无需像上例那样,针对不同的事件发布器还要人为判断所传参数到底是上一帧的碰撞对象还是当前帧的,避免了人工判断可能导致的失误。

此时,观察者就完全与发布者无关了,只与消息传递类PublisherEventArgs有关系,其实现方法RayInputIn()和RayInputOut()写法大致如图17所示。

最后客户端程序也变得简单了,只需对事件进行注册,无须考虑事件要传什幺参数给委托方法。因为事件根本就没有传参给所委托的方法,而是直接传给了消息传递类,这时发布者和观察者之间就完全解耦了。事件注册写法如“publisherSubject.OnRayEnter += observerObject.RayInputIn”,其他两个事件的注册和此写法类同。

7 结束语

游戏引擎的事件和响应的机制,归根究底还是要从面向对象的根本出发,带着抽象思维去思考问题;从软件设计模式出发,借助优秀的、可以方便开发者复用的程序设计模式来构建良好的程序关系。

基于观察者模式的程序设计思维,利用好委托、事件和消息传递机制,这些对于实现观察者模式有的独到的支撑和便利的工具,来设计和优化Unity3D的程序模块,可以很好地做到对象间“低耦合”甚至“无耦合”的目标。

参考文献:

[1] 曹步清,金瓯.Java中的Abstract Class与Interface技术研究[J].计算机技术与发展,2006,16(8):110-112,115.

[2] 耿祥义,张跃平.Java设计模式[M].北京:清华大学出版社,2009:132-134.

[3] 李航.基于MDA的BSS计费系统设计与实现[D].哈尔滨:哈尔滨工业大学,2012:24.

[4] Gamma E,Helm R,Johns R,et al.Design patterns: elements of reusable object-oriented software[M].Beijing:China Machine Press,2002.

[5] 孟婷婷,何利力.Observer设计模式在手机导航软件中的应用[J].电脑知识与技术,2014,10(19):4579-4582.

[6] Gamma E,Helm R,Johns R.设计模式:可复用面向对象软件的基础[M].李英军,马晓星,蔡敏,等译.北京:机械工业出版社,2005:89.

[7] 吴清寿.基于事件机制的观察者模式及应用[J].重庆理工大学学报(自然科学版),2012,26(9):100-104.

[8] 微软公司.基于C#的.NET Framework程序设计[M].北京:高等教育出版社,2004:149-152.

[9] 杨秀杰,杨丽芳.虚拟现实(VR)交互程序设计[M].北京:中国水利水电出版社,2019:34-38.

【通联编辑:谢媛媛】