前言

标题中的图形化编程是指:

  • Scratch
  • Snap!
  • MicroBlocks
  • GP

“帽子"积木

Scratch

在 Scratch 中, 有许多 “帽子”(Hat) 积木, 它们看起来像一顶帽子。

图中的黄色积木都来自 “事件” 分类, “帽子” 积木通常用于响应事件: 当某个事件发生时, 就运行帽子底下的积木。

图中的绿色积木来自插件, 开发者可以创建自定义的"帽子” 积木。

Snap! / MicroBlocks / GP

Snap! / MicroBlocks / GP 内置了一些"帽子"积木:

但它们仨都不允许用户自定义"帽子"积木。 而是提供了一个通用的"帽子"积木: 当(when)

当(when) 积木的功能是: 接受某个有返回值(true/false)的积木(椭圆(reporter)或者六边形(predicate))作为参数, 当返回值为 true 时, 则运行其下的积木。

Snap! / MicroBlocks / GP 的洞察是: hat = when + reporter

移植 Scratch 的"帽子"积木

当我们打算将 Scratch 插件迁移为 Snap! 插件时, 会遇到这样的问题: 怎么迁移"帽子"积木?

让我们来考察两个 Scratch 插件:

micro:bit 插件

先考察下 micro:bit 插件的这个"帽子"积木: 当按下 A 按钮

这个积木的功能是: 当 micro:bit 上的 A 按钮被按下时, 触发这个积木。

我们首先将碰到这个问题: 如何知道 A 按钮被按下 ?

micro:bit 插件作者的想法是, 在 micro:bit 里持续运行一段程序(固件), 这段程序不停地观察 micro:bit 的状态(如 A 按键是否被按下), 并把这些状态以每秒几十次的频率传递给 Scratch。至于传递的方式, 可能通过 usb 串口也可能通过 BLE(蓝牙) 。 以一定的频率不断发送 micro:bit 自身状态 的想法受到 S4A 的影响。

在 Scratch 一边, 不断地收到 micro:bit 发来的状态数据, 不停地观察(在一个持续运行的循环中)这些数据中表示按钮的位置, 如果这个数据表明按下, 则运行当按下 A 按钮帽子下的积木(执行它的语义功能)。以下是它的具体实现:

接收来自 micro:bit 的状态数据(_onMessage):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    _onMessage (base64) {
        // parse data
        const data = Base64Util.base64ToUint8Array(base64);

        this._sensors.tiltX = data[1] | (data[0] << 8);
        if (this._sensors.tiltX > (1 << 15)) this._sensors.tiltX -= (1 << 16);
        this._sensors.tiltY = data[3] | (data[2] << 8);
        if (this._sensors.tiltY > (1 << 15)) this._sensors.tiltY -= (1 << 16);

        this._sensors.buttonA = data[4];
        this._sensors.buttonB = data[5];

        this._sensors.touchPins[0] = data[6];
        this._sensors.touchPins[1] = data[7];
        this._sensors.touchPins[2] = data[8];

        this._sensors.gestureState = data[9];

        // cancel disconnect timeout and start a new one
        window.clearTimeout(this._timeoutID);
        this._timeoutID = window.setTimeout(
            () => this._ble.handleDisconnectError(BLEDataStoppedError),
            BLETimeout
        );
    }

当按下 A 按钮 具体的实现代码(whenButtonPressed):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    whenButtonPressed (args) {
        if (args.BTN === 'any') {
            return this._peripheral.buttonA | this._peripheral.buttonB;
        } else if (args.BTN === 'A') {
            return this._peripheral.buttonA;
        } else if (args.BTN === 'B') {
            return this._peripheral.buttonB;
        }
        return false;
    }

值得注意的是, _onMessagewhenButtonPressed 是两个独立的线程, 前者会不断写入(更新) buttonA 的状态, 后者则不断读取 buttonA 的状态。

_onMessage 中的状态消息是源源不断的, buttonA 变量相当于一个暂存它的槽( 只存一个最近的状态 )。而 whenButtonPressed 每次只能读到 最近一次存到槽里的数据

移植到 Snap! 中

一旦弄懂这些, 将 当按下 A 按钮 移植到 Snap! 中就不难了。

我们可以复用 micro:bit 的固件, 因为从前头的分析得知 micro:bit 的固件负责源源不断往外发送 micro:bit 的状态, 至于发给谁它并不关心, 既然可以发到 Scratch, 当然也可以发到 Snap! 也行。

以下是 Snap! 中实现 当按下 A 按钮 的相关代码:

有以下几点值得注意:

  1. Snap! 通过 BLE(蓝牙) 连接 micro:bit, 通过订阅 BLE 广播来接收 micro:bit 持续发出的状态信息。
  2. 将收到的状态信息存放到变量 mb_notify_datas 里, 相当于 Scratch 中存储最新消息的变量, 只存下 最近的一条状态信息
  3. 查看 A 按钮状态 被实现为 predicate 积木(六边形, 我将它视为一种返回布尔值的 reporter), 通过与 当when 积木的组合,而得到"帽子"积木。
  4. 一切都在 Snap! 里实现!

micro:bit more 插件

Scratch 扩展插件中的"帽子"积木, 采用的都是"状态流"的风格, 外部设备源源不断地传递"状态流"到 Scratch 里(受到 S4A 的影响)。

有一类信息天生适合使用"状态流"来传递, 诸如陀螺仪的数据。

但许多情况下,要传递外部设备或系统的信息,采用事件是更好的选择。诸如将 MQTT 引入图形化编程系统。

让我们分别采用"状态流"和事件两种视角来看待 当按下 A 按钮

  1. 状态流: 这种视角下, micro:bit 源源不断地报告 A 按钮的状态: true false false false false true false
  2. 事件: 这种视角下, micro:bit 只在上述情况中(true false false false false true false)两次状态为 true 的时候, 各发布一个事件, 其余情况保持沉默(沉默表示 false)。这种方式显然更加高效。

micro:bit more 插件 正是采用了事件的风格。

让我们看看基于事件的 当按下 A 按钮 的实现(whenButtonEvent):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    whenButtonEvent (args) {
        if (!this.updateLastButtonEventTimer) {
            this.updateLastButtonEventTimer = setTimeout(() => {
                this.updatePrevButtonEvents();
                this.updateLastButtonEventTimer = null;
            }, this.runtime.currentStepTime);
        }
        const buttonName = args.NAME;
        const eventName = args.EVENT;
        const lastTimestamp =
            this._peripheral.getButtonEventTimestamp(buttonName, eventName);
        if (lastTimestamp === null) return false;
        if (!this.prevButtonEvents[buttonName]) return true;
        return lastTimestamp !== this.prevButtonEvents[buttonName][eventName];
    }

这里有 2 点值得注意:

  1. lastTimestamp 的使用。前边说过帽子积木(whenButtonEvent)在一个循环里持续不断地运行, 进来的事件会存放在一个变量(槽)里, whenButtonEvent 需要知道槽里的事件是否之前被访问过。有很多方式可以做到这点, 方案 1 是为事件添加一个时间戳(这种方式通常是最佳选择), 并且给 whenButtonEvent 分配一个变量记住最近一个事件的时间。 方案 2 是在每次访问事件(槽)之后, 清空它(EIM 插件 使用了这个技巧) 。
  2. 由于帽子积木(whenButtonEvent)在一个循环里持续不断地运行, 这导致任何有状态的变量需要存储在 this 里。

移植到 Snap! 中

实际上, 我并没有将 micro:bit more 插件移植到 Snap!(因为我们有更强大和通用的 MicroBlocks 插件), 但要这么做是很简单的。

在 Snap! 中对事件以及 lastTimestamp 的处理非常容易, 我们通过以下的示例来说明:

值得注意的是这里的 lastTimestamp 是 块变量 , 块变量 在积木多次运行过程中保持自身的状态, 类似于前边 Scratch whenButtonEvent 函数中与 this 有关的变量。

设置 buttonAEvent 模拟了事件流入 Snap! 的过程。在真实的情况中, 事件可能是通过 usb 串口、BLE 广播(如前边 BLE 广播的例子) 或者经过网络(如 MQTT, WebSocket)进入 Snap! 的。 值得注意的是, buttonAEvent 使用一个列表存储消息, 列表的第 2 个元素是我们收到消息时的时间戳。这个时间戳究竟是事件本身携带的, 还是 Snap! 收到消息时添加的,并不重要。

点击尝试 whenButtonAEvent

更多

当我将 EIM 插件从 Scratch 迁移到 Snap! 时, 我将基于清空事件(槽)的机制改为基于事件时间戳的机制。

这是之前 Scratch 的机制:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    isTargetTopicMessage(targerNodeId, targetContent) {
        if (
            (targetContent === this.adapter_node_content_hat || targetContent === "_any") &&
            targerNodeId === this.node_id
        ) {
            setTimeout(() => {
                this.adapter_node_content_hat = null; // 每次清空
                this.node_id = null;
            }, 1); //1ms,  todo: 改为 this.runtime.currentStepTime
            return true;
        }
    }

这是目前 Snap! 的机制:

思考

我们在前边遇到的问题, 可以采用 缓冲区(buffer) 视角看待它(流(Stream)>)也是看待这个问题的一个有趣视角)。 我们将来自 micro:bit 的事件或状态流存储在 Scratch 或 Snap!的一个变量(buffer)里, 然后在 帽子/predicate 积木中根据 buffer 里的数据给出判断(A 按钮是否按下)。

在计算机科学中,数据缓冲区(或简称缓冲区(buffer))是用于临时存储数据的内存区域,当数据从一个地方移动到另一个地方时会使用 –wiki: Data buffer

由于我们识别到了问题, 得到了关键词: buffer, 把这把钥匙塞到 Google 的钥匙孔里, 就可以打开许多扇门, 里头尽是计算机领域长期积累的经验。

在典型的 Scratch/Snap! 插件中, 要处理来自外部系统的输入数据, 使用单一变量作为缓冲区就足够了, 因为 Scratch/Snap! 处理数据的速度比外部系统传入数据的速度更快。

缓冲区更典型的用途是, 在某些时间里, 进入系统的数据快过消耗它的速度。 此时需要构建更为复杂的缓冲区, 诸如环形缓冲区, John Maloney 在 MicroBlocks 使用了 环形缓冲区 来处理消息广播。

MicroBlocks 中的情形

这篇文章讨论 Scratch 和 Snap! 居多, 讨论 MicroBlocks 的部分很少。 原因之一是我最近的工作涉及将插件从 Scratch 迁移到 Snap! ; 原因之二是 MicroBlocks 在这方面的设计与 Snap! 很相似。

前边提到:

whenButtonEvent 需要知道槽里的事件是否之前被访问过。有很多方式可以做到这点, 方案 1 是为事件添加一个时间戳(这种方式通常是最佳选择), 并且给 whenButtonEvent 分配一个变量记住最近一个事件的时间。 方案 2 是在每次访问事件(槽)之后, 清空它

MicroBlocks 在涉及 buffer 的情况下(诸如处理网络消息),也需要处理这个问题, 但 MicroBlocks 选择了不同的方案。

在 MicroBlocks 存在两种风格处理这个问题:

其一以 wifi 广播库(以及 micro:bit radio 库)为代表:

这种风格里, 使用一个变量(wifi广播消息已收到?)标记 buffer 的消息是否已经读取过, 使用另一个变量(wifi最后的数字)存储最后收到的wifi广播消息, 即使最近只收到一条消息,多次点击这个积木,每次都会返回最后一条消息。

我们可以使用 当(when) 来重写上图的代码:

其二以 UDP 库为代表:

这种风格里, 只使用一个变量: UDP 接受数据包, 如果最近只收到一条消息, 第一次运行这个积木, 将返回这条消息, 再次运行返回 false。 这种风格类似我们之前提到的方案 2:

方案 2 是在每次访问事件(槽)之后, 清空它

这种风格的积木,无法与 当(when) 配合使用。

从上边的例子可以看到,无论哪种处理方式,都只是摆弄不同的变量而已,buffer 也只是变量。 变量(memory)在计算(computing)中如此重要有其深刻原因。变量(memory) 几乎是计算(computing)中最重要的两个要素之一,另一个要素是移动读写头,它们共同构成图灵机,摆弄变量(memory) 相当于读写图灵机的纸带。

参考