观察者模式最佳实践,构建自己的一套事件分发系统

栏目: IT技术 · 发布时间: 4年前

内容简介:试想这样一个问题,当某个事件发生时,比如在游戏中A模块修改了用户的金币数,而B模块和C模块提供的功能都依赖于用户的金币数,那么,A模块在修改金币数的同时,就需要通知B模块和C模块。常规的方法就是A模块持有B模块和C模块的对象,然后分别通过调用对象接口的方式告诉它们,“嘿,我修改了用户的金币数,改成了10金币”。但这样就带来了许多问题:为了解决上面的问题,我们自然想到了观察者模式。

前言

试想这样一个问题,当某个事件发生时,比如在游戏中A模块修改了用户的金币数,而B模块和C模块提供的功能都依赖于用户的金币数,那么,A模块在修改金币数的同时,就需要通知B模块和C模块。常规的方法就是A模块持有B模块和C模块的对象,然后分别通过调用对象接口的方式告诉它们,“嘿,我修改了用户的金币数,改成了10金币”。

但这样就带来了许多问题:

  • A模块引用了B模块和C模块,耦合严重
  • A模块修改金币数的方法中调用了B,C模块的方法,当这两个模块发生变化时(比如B模块接收金币数的接口名称改变了,或是C模块不再需要知道金币数改变了),A模块也要修改
  • 当又出现一个D模块也需要知道金币数的变化时,同样需要修改A模块以适应这种需求

为了解决上面的问题,我们自然想到了观察者模式。

观察者模式

这里简单说一下什么是观察者模式:定义对象之间的一对多依赖,这样一来,当一个对象改变状态时,它的所有依赖者(称之为观察者)都会接收到通知并自动更新。

观察者模式的好处是,对象之间是松耦合的,当一个对象改变状态时,它并不需要知道自己的观察者是谁,只需要发布通知即可。任何时候都可以增加或删除观察者,不会影响到发布通知的对象。

而事件分发系统就是观察者模式的一个具体实现

事件分发系统

事件分发系统核心需要提供的功能主要包括以下几个部分:

  • 当一个对象发生改变时,可以认为此时产生了一个事件,提供一个派发事件的接口,以通知所有的观察者
  • 需要提供注册监听事件的接口,以让观察者可以订阅自己需要接收的事件
  • 还需提供反注册监听事件接口,以让观察者可以取消自己的订阅
  • 最好还能在订阅的时候设置优先级,优先级越高的可以越先被通知

使用事件分发系统解决问题

首先,来看看使用事件分发系统处理上面提到的问题,会是什么样的效果。

A模块只需要派发金币修改事件,B,C模块只需要订阅金币修改事件,之后便可以收到通知了。是不是很简单呢

local B = class()
function B:on_money_change( money )
    print(money, "B receive event")
end
-- 订阅金币修改事件
EventSystem:on(Event.MoneyChanged, B.on_money_change, {target = B})

local C = class()
function C:on_money_change( money )
    print(money, "C receive event")
end
EventSystem:on(Event.MoneyChanged, C.on_money_change, {target = C})
-- 在A模块中派发金币修改事件,当前金币为10
EventSystem:emit(Event.MoneyChanged, 10)

接下来会仔细解读一下这个 EventSystem 事件分发系统的 Lua 实现代码。

实现事件分发系统时,需要小心一些特殊情况,比如有以下几个坑,读者可以留意一下代码中对这几个坑的处理

  • 在事件派发的过程中订阅该事件,订阅还有优先级,需要小心处理 排序 问题
  • 在事件派发的过程中取消订阅该事件,需要采用标记移除,不能直接移除
  • 在事件派发的过程中又派发了该事件,如何确定事件派发完成

为了便于讲解,下面的代码省略了一些非关键性的代码,用 --- ... 代替。

注册监听事件接口

function EventSystem:on( event, func, params )
    --- ...
    local event_listener = self._listeners[event]
    params = params or {}
    local priority = params.priority or 0
    local target = params.target
    --- ...
    local cb = {target = target, func = func, id = id, priority = priority}
    table.insert(event_listener.list, cb)
    id = id + 1
    if priority > 0 then
        event_listener.need_sort = true
        self:sort(event_listener)
    end
end

on 方法中 event 参数表示要注册监听的事件名称, func 参数表示当事件发生时要触发的回调函数, params 表示额外参数,可以设置注册监听的目标 target (可以利用它反注册所有与其相关的监听),也可以设置要注册监听的优先级,优先级越高的越先执行

on 方法的实现还是比较简单的,主要就是将注册的相关信息插入到 event_listener 表中,但是明明注册的监听是有优先级的,却仍然只是调用 table.insert 将信息插入到表的末尾,这是为什么呢?读者可以先留意一下,后面会有详细解释。

还需要格外注意的是 sort 方法

function EventSystem:sort( listener )
    if listener.need_sort == true and listener.emit_count == 0 then
        table.sort(listener.list, function ( a, b )
            if a.priority == b.priority then
                return a.id < b.id
            else
                return a.priority > b.priority
            end
        end)
        listener.need_sort = false;
    end
end

可以看到 sort 方法必须在 listener.emit_count == 0 时才会进行排序, listener.emit_count == 0 表示的是当前的事件没有处于派发状态,后面讲到派发接口时会详细解释,这里读者只需要知道其表示的含义即可。

事件处于派发状态时不能进行优先级排序原因是可能会造成回调的重复触发。

比如当前事件有4个回调 a, b, c, d,派发事件是顺序执行回调,当执行到第3个回调c时

如果在c回调中又注册了一个优先级最高的回调e,立刻排序的话,e插入到第一位,c会被挤到第4位,顺序执行到第4个回调时,导致c又被调用一次

反注册事件监听接口

function EventSystem:off( event, func, params )
    --- ...
    local event_listener = self._listeners[event]
    params = params or {}
    for i,cb in ipairs(event_listener.list) do
        if cb.func == func and cb.target == params.target then
            if event_listener.emit_count > 0 then
                -- 派发过程中只进行标记删除
                cb.need_remove = true
                event_listener.need_clean = true
            else
                table.remove(event_listener.list, i)
            end
            break;
        end
    end
end

off 方法用于取消事件监听,当事件未处于派发过程中时,直接调用 table.remove 移除注册信息即可,但当事件处于派发过程中时,不能直接移除,只能先进行标记

在事件处于派发过程中时不能直接移除的原因是可能导致遗漏触发某些回调

比如当前事件有5个回调 a, b, c, d, e,顺序执行到第3个回调c时

如果在c回调中调用了 off 方法取消自己的监听,此时直接移除c的话,会导致d回调移动到第3位,e移动到第4位,顺序执行到第4个回调时,调用的是e而遗漏了d

事件派发接口

function EventSystem:emit( event, ... )
    --- ...
    local event_listener = self._listeners[event]
    local interrupt = false
    local length = #event_listener.list
    -- 这里不能使用ipairs,确保不会触发在派发过程中注册的事件
    -- 只取当前已经注册的事件数量,如果在派发过程中再注册(调用了table.insert),本次派发也不会调用
    for i = 1, length do
        if interrupt == true then
            break
        end
        local cb = event_listener.list[i]
        if cb.func and cb.need_remove ~= true then
            event_listener.emit_count = event_listener.emit_count + 1
            if cb.target then
                interrupt = cb.func(cb.target, ...)
            else
                interrupt = cb.func(...)
            end
            event_listener.emit_count = event_listener.emit_count - 1
        end
    end
    self:sort(event_listener);
    self:clean(event_listener);
    return interrupt
end

emit 方法负责派发一个事件,顺序执行 event_listener 中注册的回调。事件的派发支持中断,当执行某个回调时,如果这个回调返回了 true 则可以中断当前事件的派发。

值得一提的是,代码通过对应的 event_listener.emit_count = event_listener.emit_count + 1event_listener.emit_count = event_listener.emit_count - 1 来记录事件的派发状态,当 emit_count > 0 则表明事件还在派发过程中。当 emit_count == 0 则表明事件派发完成。

不能使用 event_listener.is_emiting = trueevent_listener.is_emiting = false 代替的原因是如果在触发的回调中又派发了事件,形成了递归,那么二次派发事件结束时会直接将 event_listener.is_emiting 置为 flase ,导致一次派发事件对应的派发状态被标记错误

更多

事件分发系统的完整源码可以点击 这里 查看,测试用例可以点击 这里 查看

更多Lua相关的设计与使用,比如面向对象(代码中用到的class关键字),组件系统,分模块加载等等,可以查看GitHub仓库 LuaKit


以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

JavaScript DOM编程艺术

JavaScript DOM编程艺术

Jeremy Keith / 杨涛、王建桥、杨晓云 / 人民邮电出版社 / 2006年12月 / 39.00元

本书讲述了JavaScript和DOM的基础知识,但重点放在DOM编程技术背后的思路和原则:预留退路、循序渐进和以用户为中心等,这些概念对于任何前端Web开发工作都非常重要。本书将这些概念贯穿在书中的所有代码示例中,使你看到用来创建图片库页面的脚本、用来创建动画效果的脚本和用来丰富页面元素呈现效果的脚本,最后结合所讲述的内容创建了一个实际的网站。 本书适合Web设计师和开发人员阅读。一起来看看 《JavaScript DOM编程艺术》 这本书的介绍吧!

在线进制转换器
在线进制转换器

各进制数互转换器

URL 编码/解码
URL 编码/解码

URL 编码/解码

SHA 加密
SHA 加密

SHA 加密工具