自定义事件驱动系统

自定义事件驱动系统

  在程序设计里有一种模型叫做“事件驱动模型”,这个模型最初是用于异步处理之间的冲突而出现的,后来被用到各种程序中,成为了一种重要的程序设计模型,广为流传。


基于事件驱动的程序结构

  在最早没有事件驱动的程序中,要获取到外部设备的输入,比如键盘或者传感器之类的设备输入,则必须做设备轮询,即按照一定频率轮流访问输入设备,检查是否存在有效输入,如果有则将其读出,随后向后轮询;若没有有效输入则跳过当前设备继续轮询。
  这种工作机制并没有什么问题,事实上在CPU工作层面上,中断机制出现之前这种轮询就是最主要的设备访问方式,在满足基本需求方面是完全可以接受的。
  然而这种机制有着先天的巨大缺陷,首先它很消耗硬件性能,一个无时无刻不在运行的轮询程序十分浪费CPU的计算资源;其次它的灵活性和适应性很差,在被轮询设备都很简单的时候这套机制没有问题,而当轮询到的设备有各种各样积极地或者消极的情况发生时,轮询机制就会十分被动。
  如果轮询时遇到响应设备即阻塞CPU进程处理输入,那么对其它的输入和整个系统的运作会产生较大影响,而且一旦设备失去响应会卡死整个系统;如果采用多线程技术将需要处理输入的设备放入另外的线程中执行则在响应时间的同步以及数据的一致性上会出现问题。
  因此,随着硬件设备的发展,轮询机制慢慢跟不上时代,需要更有效的方式来保证计算机对于多种设备的访问能力。
  这个方式便是“事件驱动”。
  所谓事件驱动,即表示输入设备自行激发一个“事件”,然后由统一的分发模块将事件送达目标设备,随后目标设备即可进行处理。在这个过程中不存在对设备的轮询,转而交由分发模块对所有的“事件”统一进行轮询和发放,设备之间解耦,逻辑交互将只出现在设备与事件分发模块之间。
  这套机制在传统GUI程序设计中非常常见,它有个“别名”就叫“消息队列”,这个名字想必是很多人熟知的,常见的Windows平台编程中就采用了消息队列的模式。
  事件驱动模型的大体思路如下

  • 输入方激发一个事件
  • 事件被送往分发模块
  • 分发模块维护一个事件队列,并按一定频率查询队列是否存在待分发事件
  • 一旦发现待分发事件,则按照既定规则将事件发送到指定模块
  • 事件接收方收到事件后进行相应处理

  这种程序设计模型对于异步性要求较高的程序能起到非常积极的作用,比如常见的操作界面编程,没有事件驱动模型的话基本无法在流畅的前端界面和耗时的后端处理之间达到较好的平衡。
  转到Unity3D的游戏制作上,事件驱动模型其实在现在其自带的UGUI系统中就有所体现,但GUI系统所使用的机制是专为GUI系统本身设计的,如果希望能在游戏项目中也使用这样一套事件驱动模型,那么就要考虑自己动手丰衣足食了。


Unity3D中的事件驱动

  Unity3D的主要元素是“游戏对象”GameObject,每个GameObject上会挂载不同的Component,而用于逻辑处理的代码就放在其中一种Component中,一般称之为“脚本”。
  由于Unity3D引擎会自动维护场景中的GameObject,而按照规则编写的脚本会自动执行其中的Update方法,所以整个Unity3D游戏场景就像是一个拥有多个“设备”(游戏对象)的系统,而游戏的运行恰恰就是这些“设备”发生相互作用的过程。
  这种相互作用有很多种,碰撞,射线,鼠标点击,键盘还有摇杆等都可能成为需要响应的输入,而每种输入又会有不同的触发方式,不同的响应规则,不同的处理方法等。
  一般而言,简单的场景中不需要考虑太多,只要按照Unity给出的准则编写逻辑就能很好地实现游戏运行,但也仅限于简单游戏,一旦游戏系统变得复杂,这些各种各样的输入响应和处理方法会很快变为一场程序灾难,制作人员需要从纷繁杂乱的响应和处理方法中找到需要修改的部分,也要一再检查新加入的处理程序有没有引起冲突,维护成本将呈几何级上升,最终达到无法接受的地步。
  Unity3D为所有的GameObject提供了一套sendMessage方法,这个方法可以向特定的GameObject发送一个请求,让目标执行某个方法,方法名和参数通过sendMessage的参数给出。这是最基本的游戏对象间通信方式,有这样一套方法在,不同的游戏对象之间就能协调状态,统筹运行逻辑,方便实现所需的效果。
  但必须考虑到的是,这种方式很基础而且效率不高,首先,SendMessage会导致引擎去查找目标对象上的所有脚本中的目标方法,其次Unity3D引擎在处理sendMessage系列方法时用到了反射机制,而且寻找方法并不精确导致无法区分多个脚本中的同名方法。
  更何况sendMessage依然和GameObject是耦合的,发送者至少需要拿到接收者的GameObject引用才能进行Message发送,因此它还是不适合于在整个项目中广泛使用,一般用于碰撞检测或者特定GameObject集群之间的通信。
  那么如果需要一个事件驱动框架,从何处入手呢?首先要看看事件驱动所需的模块。

分发模块
事件队列

  这两个是核心,那么接着考虑整个事件驱动框架的工作方式。

  • 发送者要能将事件送往分发模块
  • 分发模块负责维护事件队列并且以一定频率取出事件进行处理
  • 处理事件时要能将事件发送到接收者手中

  为了实现这些目的,考虑如下的设计

  • 分发模块以独立GameObject脚本方式存在,借助Unity3D引擎自动调用Update方法的机制来方便地处理事件队列
  • 分发模块中以队列结构组织待处理事件列表
  • 分发模块中维护接收者的分发表,使用特定的ID来区分不同的接收者
  • 分发模块提供注册方法,让接收者可以将自身注册到分发表中

  然后考虑接收者的设计,初步需要如下几个特点

  • 接收者应该在特定时刻自动将本身注册到分发模块中
  • 接收者应当有接收和处理事件的方法

  最后设计一个事件基类,它至少需要包括

  • 事件唯一标识
  • 事件来源
  • 事件目标

  有了以上的设计思路,初步的代码便可以写出

事件类设计

public class MBaseEvent {
    protected long eventID;
    public string eventSource;
    public string dispatchTarget;

    public MBaseEvent(int type) {
        eventID = (long)ts.TotalMilliseconds; // 打上时间戳
    }
}

事件系统中游戏对象的公共基类

public abstract class BaseEventObject : MonoBehaviour, IMEventHandler {

    public abstract string getTagName(); // 获取当前子类对应的事件分发表键名
    public abstract void handleEvent(MBaseEvent e); // 事件处理方法,子类实现
    protected EventCenter eventCenter; // 事件分发中心引用

    void Start() {
        eventCenter = GameObject.Find("EventCenter").GetComponent<EventCenter>();
        // 自动注册本类实例到事件分发表
        eventCenter.registerEventObject(getTagName(), this);
    }

    void Update() {
        //
    }

    private void OnDestroy() {
        eventCenter.unregisterEventObject(getTagName(), this);
    }
}

事件处理接口设计

public interface IMEventHandler {
    void handleEvent(MBaseEvent e); // 事件处理方法
}

事件分发中心

public class EventCenter : MonoBehaviour {

    private Queue<MBaseEvent> dispatchQueue; // 事件分发队列
    private Dictionary<string, IMEventHandler> dispatchTable;

    void Start() {
        dispatchQueue = new Queue<MBaseEvent>();
        dispatchTable = new Dictionary<string, IMEventHandler>();
    }

    // ------ 每一帧都进行分发
    void Update() {
        MBaseEvent dispatchEvent = dispatchDequeue();
        if(dispatchEvent != null) { // 队列为空时不分发
            IMEventHandler dispatch = dispatchTable.TryGetElement(dispatchEvent.dispatchTarget);
            if(dispatch != null) {
                dispatch.handleEvent(dispatchEvent);
            }
        }
    }

    // ------ 注册分发入口
    public void registerEventObject(string tag, BaseEventObject obj) { 
        dispatchTable[tag] = obj;
    }

    // ------ 反注册分发入口
    public void unregisterEventObject(string tag, IMEventHandler obj) {
        if (dispatchTable.ContainsKey(tag)) {
            dispatchTable.Remove(tag);
        }
    }

    // ------ 分发事件入口
    public void dispatchEnqueue(MBaseEvent e) {
        lock(dispatchQueue) {
            dispatchQueue.Enqueue(e);
        }
    }

    // ------ 取分发事件出队
    private MBaseEvent dispatchDequeue() {
        MBaseEvent result = null;
        lock(dispatchQueue) {
            if(dispatchQueue.Count > 0) {
                result = dispatchQueue.Dequeue();
            }
        }
        return result;
    }
}

  实际使用中只需要将EventCenter脚本挂载到任意GameObject让它运行起来,然后所有其他需要用到事件驱动的GameObject上挂载的脚本都继承BaseEventObject类,需要通信时创建一个MBaseEvent类对象发送即可。
  有了这么一套设计,任何时候一个GameObject需要和另一个GameObject进行通信,告诉对方需要做些什么,只要简单地创建一个对应的事件对象,然后放入EventCenter所维护的事件队列中即可;EventCenter会在每帧调用Update的时候取出队列中的待发送事件,根据事件的目标取得IMEventHandler接口,然后调用handleEvent方法处理事件。


优化事件驱动设计

  前文中编写的事件驱动系统是个很简单的版本,它虽然可以工作,但有不少缺陷和不方便的地方;比如它针对每个BaseEventObject要求唯一标识,但实际使用中可能出现多个重复的GameObject,此时系统只能取得最后一个注册的GameObject;还有因为EventCenter在Update方法中直接执行了handleEvent,因此在效率上不算很好,而且这种跨脚本执行代码的方式也破坏了GameObject的独立性。
  因此有很多值得优化的地方。
  首先需要优化的是事件对象结构,前文提到的事件基类包含的信息过于简单了,对于一些需要额外信息的事件还要在子类中自定义数据结构,实际上只要约定好事件中包含的数据储存方式,可以将这些代码放入基类中。

事件抽象基类优化设计

public class MBaseEvent {
    protected long eventID; // 事件ID
    protected int eventType; // 事件类型
    public int Type {
        get {
            return eventType;
        }
    }
    public int what; // 事件携带的基础信息
    public object obj;
    public bool needWakeUp = false; // 是否需要唤醒接收方,可用于将非活动状态的物体唤起
    public bool isBroadcast = false; // 是否广播,一般而言不建议和needWakeUp同时为True,广播事件将会向所有对象发送
    public string eventSource; // 事件源(来自分发表键名)
    public string dispatchTarget; // 事件目标(来自分发表键名)
    protected Dictionary<string, object> extra; // 事件携带的额外信息

    protected MBaseEvent(int type) { // 构造方法设置为保护类型,避免直接创建基类对象
            eventID = DateTime.Now.Ticks; // 打上时间戳
            eventType = type;
    }

    public void putExtra(string key, Object value) { // 放入额外信息
        if(extra == null) {
            extra = new Dictionary<string, Object>();
        }
        extra[key] = value;
    }

    public object getExtra(string key) { // 获取额外信息
        object result = null;
        if(extra != null) {
            result = extra.TryGetElement(key);
        }
        return result;
    }

    public void setupTarget(string src, string target) { // 设置事件源和目标
        eventSource = src;
        dispatchTarget = target;
    }
}

  这样一来事件能包含的数据就丰富多了。
  然后可以优化的是EventCenter的分发表结构,前文中编写的分发表是一个单纯的字符串与IMEventHandler的映射表,这对于存在多个相同类型GameObject的场景非常不友好,而实际使用中这样的场景比比皆是,因此这里应该进行优化。

事件分发中心优化

public class EventCenter : MonoBehaviour {

    private Queue<MBaseEvent> dispatchQueue = new Queue<MBaseEvent>(); // 事件分发队列
    private Dictionary<string, List<BaseEventObject>> dispatchTable = new Dictionary<string, List<BaseEventObject>>(); // 事件分发表
    private bool isDispatching = true;

    void Start() {
        //
    }

    // ------ 每一帧都进行分发
    void Update() {
        if(isDispatching) {
            MBaseEvent dispatchEvent = dispatchDequeue();
            if(dispatchEvent != null) { // 队列为空时不分发
                if(dispatchEvent.isBroadcast) { // 广播事件不需要考虑目标
                    foreach(List<BaseEventObject> dispatch in dispatchTable.Values) {
                        foreach(BaseEventObject obj in dispatch) {
                            if(obj.gameObject.activeSelf) {
                                obj.EnqueueEvent(dispatchEvent);
                            }
                        }
                    }
                } else { // 非广播事件需要取到目标对象表
                    List<BaseEventObject> dispatch = dispatchTable.TryGetElement(dispatchEvent.dispatchTarget);
                    if(dispatch != null) {
                        foreach (BaseEventObject obj in dispatch) {
                            obj.EnqueueEvent(dispatchEvent);
                        }
                    }
                }
            }
        }
    }

    // ------ 注册分发入口
    public void registerEventObject(string tag, BaseEventObject obj) { 
        List<BaseEventObject> tempList;
        if(dispatchTable.ContainsKey(tag)) {
            tempList = dispatchTable[tag];
        } else {
            tempList = new List<BaseEventObject>();
        }
        tempList.Add(obj);
        dispatchTable[tag] = tempList;
    }

    // ------ 反注册分发入口
    public void unregisterEventObject(string tag, BaseEventObject obj) {
        List<BaseEventObject> tempList;
        if (dispatchTable.ContainsKey(tag)) {
            tempList = dispatchTable[tag];
            tempList.Remove(obj);
            dispatchTable[tag] = tempList;
        }
    }

    // ------ 分发事件入口
    public void dispatchEnqueue(MBaseEvent e) {
        lock(dispatchQueue) {
            dispatchQueue.Enqueue(e);
        }
    }

    // ------ 取分发事件出队
    private MBaseEvent dispatchDequeue() {
        MBaseEvent result = null;
        lock(dispatchQueue) {
            if(dispatchQueue.Count > 0) {
                result = dispatchQueue.Dequeue();
            }
        }
        return result;
    }

    // ------ 分发开关
    public void dispatchSwitch(bool isOn) {
        isDispatching = isOn;
    }
}

  将分发表中的值改为BaseEventObject的列表,也可以考虑用集合替代,而后的注册和反注册入口都需要相应的修改,分发时一次性向整个列表中的每一个对象分发事件。
  这样的修改除了将原来的单映射优化为多映射之外,还提供了唤醒功能,可以通过判断当前分发事件的字段值来决定是否唤醒目标对象(这是因为在Unity3D中,当一个GameObject的activeSelf属性返回值为false的时候,它是不会每帧自动执行Update方法的,而这个情况会影响到后面针对BaseEventObject类的一项优化,因此针对特定事件提供了唤醒目标的功能。)
  接着优化GameObject脚本的自定义基类BaseEventObject,前文中编写的基类只有很简单的自动注册与反注册功能,考虑到EventCenter直接调用handleEvent并不好,因此在BaseEventObject内增加一个自有队列,做二级缓冲,同时也把耦合性降低。

事件系统中游戏对象的公共基类优化

public abstract class BaseEventObject : MonoBehaviour {
    protected Queue<MBaseEvent> eventQueue = new Queue<MBaseEvent>(); // 事件队列
    protected EventCenter eventCenter; // 事件分发中心引用

    // ------ 事件进入缓冲队列
    public void EnqueueEvent(MBaseEvent e) {
        lock(eventQueue) {
            eventQueue.Enqueue(e);
            if(e.needWakeUp && !gameObject.activeSelf) {
                setActive(true);
            }
        }
    }

    // ------ 从缓冲队列中取事件
    protected MBaseEvent DequeueEvent() {
        MBaseEvent result = null;
        lock(eventQueue) {
            if(eventQueue.Count > 0) {
                result = eventQueue.Dequeue();
            }
        }
        return result;
    }

    protected abstract void execute(); // 帧更新时调用的执行方法,子类实现
    public abstract string getTagName(); // 获取当前子类对应的事件分发表键名
    protected abstract void handleEvent(MBaseEvent e); // 事件处理方法,子类实现
    protected virtual void release() { // 子类可重写用于对象回收时释放资源
        lock(eventQueue) {
            eventQueue.Clear();
        }
    }

    public void setActive(bool active) {
        gameObject.SetActive(active);
    }

    void Start() {
        eventCenter = GameObject.Find("EventCenter").GetComponent<EventCenter>();
        // 自动注册本类实例到事件分发表
        eventCenter.registerEventObject(getTagName(), this);
    }

    void Update() {
        try {
            execute(); // 子类实现更新调用
            MBaseEvent e = DequeueEvent(); // 取出一个待处理事件
            if(e != null) {
                handleEvent(e);
            }
        } catch (Exception e) {
            Debug.LogError(e.StackTrace);
        }
    }

    private void OnDestroy() {
        release();
        eventCenter.unregisterEventObject(getTagName(), this);
    }
}

  二级缓冲同样使用队列实现,取出事件进行处理的流程放入Update方法,声明抽象方法execute方便子类实现Update方法的自定义。
  这样一来就保证了handleEvent方法一定是在当前脚本的Update方法中执行的,有效降低了不同脚本间的耦合性,EventCenter脚本只能接触到BaseEventObject的事件队列。
  至此一个可用的事件驱动系统就编写完毕了,实际使用中这套系统工作得很好,在事件数量不大的时候对程序效率的影响微乎其微。

猜你喜欢

转载自blog.csdn.net/soul900524/article/details/79002288
今日推荐