跨越时间的EventLoop

作者:shitake
2016-12-27
9 14 6

前言

这篇文章的标题大概来自一篇叫凌驾时间的逻辑的将如何写一个有着跨帧操作的FSM的文章,而内容记录了我在给游戏开发GUI时,对事件处理上的一些思考。

message

如果接触过 Windows 开发的话,那么对于 Message 肯定不会陌生。整个 Windows 就是基于消息机制的。如果你在 Windows 下做 GUI 开发,你需要在窗口启动后构建一个窗口处理函数,然后开始执行一个消息循环。通过捕获不同消息来让程序做出不同反应。直到遇到 WM_QUIT 结束消息循环。

消息机制是一个足够简单的模型,当程序启动后,在一个 loop 里不断去访问消息队列,然后将消息再发给制定的程序去处理。

var winList = [];
var msgList = [];
var _id = 0;
function id () {
  return _id += 1;
};

win = {
    init: function () {
        this.hwnd = id();
        this.isExit = false;
        winList.push(this);
        var hello_msg = {hwnd:this.hwnd, name: "hello", type:"system", param:"world", time: Date.now()};
        msgList.push(hello_msg);
    },
    winProc: function (msg) {
        if(msg.name == "hello") console.log("hello " + msg.param);
    }
}

function msgLoop (winList) {
    while(true) {
        msgList.forEach(function (msg) {
            winList.forEach(function (win) { if(win.hwnd == msg.hwnd) win.winProc(msg)});
        });
        msgList.splice(msgList.indexOf(msg), 1);
    }
};


win.init()
msgLoop(winList);

上边是一个消息模型的示例。实际在Windows上要复杂的多。诸如 msgLoop 这样的东西也不会直接暴露出来。而 win.init 里的一些东西也会在别的地方实现(比如父类)。

如果在游戏里想要一个类似的东西,我们可以把 msgLoop 里的具体调用放到游戏主循环里。而游戏里的对象(对应这里的win)则可以采取树形结构,winProc 可以更抽象一些,简单的 msg 也可以增加一些别的属性。

实际上有游戏框架就是这么做的,比如 cocos2d。在 cocos2d 里面,winProc 变成了 EventListenermsg 直接成了 Event,而 msgLoop 有点变化。cocos2d 里所有对象构成一个对象树。每个节点都有一个函数来给节点添加 EventListener。而在引擎的主循环里,会依次调用节点的更新函数。节点的更新函数则会调用一个类似上边的 eventLoop 一样,叫做 EventDispatcher 的东西,将 event 分发给对应的 eventListener。(顺便吐槽一下某白鹭,虽然和 cocos2d 争得天翻地覆,这套事件处理的机制却也大同小异,参考链接见这里)。

callback

实际上,还有种叫做时间回调的方法来处理这个问题。我们给一个动作添加一个回调,当这个动作被执行的时候,则调用这个回调。再添加回调就是传递回调函数的指针(在js里直接传函数名就行)。

win = {
    actions:{},
    init: function () {
        this.actions["hello"]("world!");
    },
    actionCallback: function (action, callback) {
        this.actions[action] = callback;
    }
};

win.actionCallback("hello", function (info) {
    console.log("hello " + info);
});
win.init();

如果需要响应鼠标和键盘,我们只需要在主循环监听键盘和鼠标,然后在执行相应的动作就行。

signal & slot

听说 signal/slot 的机制是QT独创的,我没考证过。关于它的介绍我也不多说什么。 signal/slot 的机制可以看做消息的更进一步,在消息里所有的消息都在一个消息队列里,而 signal/slot 则通过一个叫connect的函数来链接起来。你可以单独编写signal和slot,然后再用connect连接起来,链接的时候也不需一对一。而且相较于callback, signal/slot 的方式耦合更低。

trigger & on

callback 的方式在一些情况下很容易傻逼。比如你之前给某个动作添加了回调,之后你需要在执行一些额外动作。你就只能通过一些很麻烦的方式来解决这个问题。而 trigger & on 则完全包装来了 callback。在 trigger & on 里,你通过 trigger 产生一个事件,然后通过 on 定义的回调来消费。你可以针对一个事件定义多个回调,在 trigger & on 会有一个每个事件对应的回调列表。

eventManger = {
    events: {},
    on: function (name, callback) {
        if(this.events[name] == undefined) this.events[name] = [];
        this.events[name].push(callback);
    },
    trigger: function (name, info) {
        if(this.events[name] != undefined) {
      this.events[name].forEach(function (callback) { callback(info) });
    }
    }
}

eventManger.on("hello", function (info) { console.log("hello " + info) });

// do somethings

eventManger.trigger("hello", " world!");

可以看出,trigger & on 和 callback 有着几乎一样的内核,完全是对前者的一种更好的封装。

跨越时间的事件处理

上面基本讲常见的事件处理方法都简单说了一下,但是,这里却有着很大的问题——异步,或者说跨帧。

问题场景:点击一个 button 打印一条 log
  • 2b方式:直接传递回调函数(类似于写 onclick=f
  • 普通方式(典型的如 jQuery):产生(trigger)事件,消费(on/bind)事件
  • 文艺方式(FRP):产生事件流(EventStream),组合、修饰事件流,消费事件流

好问题场景有了,3种方案也出了,现在咱们来看看区别。

如果你问我3种运行起来效果有什么不同?回答是没有任何不同。那么区别在哪儿呢?在于应对变化的能力不一样,怎么说?那就得再看几个需求变化的场景了。

  • 需求变更1:点击该button的时候,不仅要打印一条log了,我还想再弹出一个对话框(alert什么的)。那么对这个需求2b方式就得修改f的代码,而普通方式和文艺方式只需要添加(on)一个新回调函数就行了。一个是修改,一个是添加,2b方式为何2b不言自明。
  • 需求变更2.1:限制打印log的频率,即1s内最多打印一条日志
  • 需求变更2.2:当点击button-b的时候同样打印这条日志
  • 需求变更2.3:点击button后延迟1s再打印日志

上边是知乎上反应式编程话题下的某个问题的回答(作者罗宸,原文链接见这里)。需求2.1和2.3都要求我们的事件能做跨帧处理。

在传统的事件处理模型里,事件的消费操作其生存周期只有一帧,而这样的模式显然不能满足其要求。更好的方法是给事件回调套上一层壳,然后手动维护回调函数的生存周期。以 trigger & on 为例来看看需求2.3该怎么解决:

var eventManger = {
    events: {},
    callbacks: [],
    on: function (name, callback) {
        if(this.events[name] == undefined) this.events[name] = [];
        this.events[name].push(callback);
    },
    update: function () {
        if(this.callbacks == []) return;
        this.callbacks.forEach(function (callback) {
            if(callback.isAlive){
                callback.func(callback, callback.info)
            } else {
                this.callbacks.splice(this.callbacks.indexOf(callback), 1);
            }
        })
    },
    trigger: function (name, info) {
        if(this.events[name] != undefined) {
            self = this;
            this.events[name].forEach(function (callback) {
                callback = {func: callback, isAlive: true, info: info};
                self.callbacks.push(callback);
            });
        }
    }
};

eventManger.on("hello", function (self, info) {
    if(self.index == undefined){
        console.log("hello " + info);
        self.time = Date.now();
        self.index = 1;
    }
    if(Date.now() - self.time > 1000) {
        console.log("wait 1 sec");
        self.isAlive = false;
    }
});

eventManger.trigger("hello", " world!");

while(true) {
    eventManger.update();
}

在这段代码里,我们将每次事件消费包裹并添加表示存活状态和函数执行位置的变量,然后放进一个列表里,每帧都对这个列表做论询。对于存活的回调对象,调用他们的回调函数,否则将其从列表里删除。在回调函数内部,通过选择结构加执行位置变量的方式决定每次执行那些内容。在执行完所有代码后,将其存活状态修改为死亡从而结束此次调用。用这样的方法顺利完成事件回调的跨帧操作。

更好的跨越——协程

虽然在上一节里,我们实现了事件回调函数的跨帧操作,但是把每个回调都改成选择结构加执行位置变量的方式会既麻烦又难懂。一个更好的办法是使用协程。

协程通常被描述为轻量级的线程。协程使用栈来保存函数调用的过程,如果遇到yield,就跳出执行。然后在调用某个方法(在下面的实例里是run)后则从跳出的位置继续执行,直到执行完成。

下面则是 Tim Shen 在知乎关于协程本质的回答:

在协程里,我们用fiber来包裹事件回调(要注意的是,fibers 是 nodejs 下的一个模块,使用前需要用npm安装):

var Fiber = require('fibers');

var eventManger = {
    events: {},
    callbackFibers: [],
    on: function (name, callback) {
        if(this.events[name] == undefined) this.events[name] = [];
        this.events[name].push(callback);
    },
    update: function () {
        self = this;
        this.callbackFibers.forEach(function (callbackFiber) {
            if(callbackFiber != null) {
                callbackFiber.run();
            } else {
                self.callbackFibers.splice(self.callbackFibers.indexOf(callbackFiber), 1)
            }
        })
    },
    trigger: function (name, info) {
        if(this.events[name] != undefined) {
            self = this;
            this.events[name].forEach(function (callback) {
                var fiber = new Fiber(function () {
                    callback(info);
                    self.callbackFibers[self.callbackFibers.indexOf(fiber)] = null;
                });
                self.callbackFibers.push(fiber);
            });
        }
    }
};

eventManger.on("hello", function (info) {
    console.log("hello " + info);
    var time = Date.now();
    while(Date.now() - time < 1000){
        Fiber.yield();
    }
    console.log("wait 1 sec");
});

eventManger.trigger("hello", " world!");

while(true) {
    eventManger.update();
}

这段程序和上边的没太大差别,将我们的手动包裹换成了用 fiber。边事件回调里的time部分还可以进一步封装,详情可以查看我在 github 上的一个基于 fiber 的 nodejs 的事件库 svent。

这样,我们利用 fiber,顺利的以一种较为优雅的方式来实现了事件回调的跨帧操作。而且基于 fiber 的特性,还可以实现更多的异步方法(filter/times等)。

需要说明的是,这里不能使用ES 6里的 Generator 函数来做,所以很遗憾这些代码只能跑在 nodejs 里(Generator 函数要求 yield 必须明确的和一个 Generator 对应,而不能像上边那样)。

扩展

有一种全新的方法叫做函数式响应型编程(Functional Reactive Programming)。利用其特性可以很好的实现事件处理的跨帧操作。一个很好的例子就是 elm 语言。更多关于FRP的知识可以阅读 elm 的文档来了解,这里就不多做介绍。

近期点赞的会员

 分享这篇文章

您可能还会对这些文章感兴趣

参与此文章的讨论

  1. 至尊小夜猫 2016-12-27

    膜拜程序猿

  2. ba0ch3ng 2016-12-30

    看完了,受益匪浅,感谢作者的写的文章。
    一个小错误,trigger & on 中 trigger 方法里的 this.evnets[name] == undefined 应该改成 != 。

  3. gowinder 2016-12-30

    不知道c语言有没有好的fiber方式

  4. shitake 2016-12-30

    @ba0ch3ng:噗 果然写完就应该跑一下ORZ。恩,其实更好的写法是 this.evnets[name] != void 0 。用void 0来代替 undefined。这样可以避免 undefined 被用重定义。另外还能在压缩代码的时候减少长度【囧 写前端不容易啊 整天得和文件的大小做斗争

    最近由 shitake 修改于:2016-12-30 13:30:13
  5. shitake 2016-12-30

    @gowinder:云风早年间写过一个:https://github.com/cloudwu/coroutine/

  6. anubiskong 2017-07-16

    不懂,需求2.3为什么不用setTimeout?

您需要登录或者注册后才能发表评论

登录/注册