Scratch 拓展最佳实践 -- 以 Cozmo 为例

Keep It Simple, Stupid (KISS)

背景

本文写作有 3 个背景。

其一是 教育从业者 @jinlei 基于 Cozmo 已经顺利实施了很长时间的 AI 教育,学生们热爱 Cozmo,他们使用它构建项目时, 对 AI 领域的许多基础概念有了直观的认识,过程愉快而有趣。Cozmo 作为教育机器人,如 CMU 的 David S. Touretzky 教授所言: 一骑绝尘 ,这正是诸多组织,从 CMU、MIT、Google 到AI4ALL 将其用于 AI 教育的原因。@jinlei 希望使用 CodeLab Adapter 和 CodeLab Scratch 将 Cozmo 从官方 APP 中解放出来,使其能够可以与 STEM 领域的更多其他事物互动,以便于实施更广阔的教育场景。由于 @jinlei 团队比 CodeLab 更理解 Cozmo 在 AI 教育所能发挥的作用,所以我们计划将 Cozmo 接入 Scratch 相关的所有源码都开放出来(包括 Adapter cozmo extensionScratch cozmo extension),并在本文里说明设计思路,以便于 @jinlei 团队能够基于我们的工作继续前进,使 Cozmo 在 AI 教育上走得更远。

背景之二是, 每周会收到若干来自 STEM/编程教育从业者 的邮件,他们对 CodeLab 目前在做的事情予以慷慨鼓励,认为 CodeLab 在做一些 "真正有野心和想象力的事情", 认为我们正在改变这个行业的基本理念和基础设施,但他们同时也好奇我们是如何开展技术工作的,从思路到实施,希望 CodeLab 能分享与 Adapter/Scratch 相关的更多实施细节和讨论文章(教育设备厂商则希望获得将设备接入 Adapter/Scratch 的引导)。

背景之三是, 偶然发现酷玛实验室(Culmart STEAM Labs) 将 CodeLab Adapter 收纳到 STEM 教育 wiki 中, 将其与伯克利大学的 S4A 以及 MIT 官方的 Scratch Link 并列为Scratch的扩展机制,所以我们计划讨论关于扩展的最佳实践。正如我们与 MIT Scratch Team 碰面时聊到的,我们相信 Adapter 是一种比 Scratch Link 更为灵活的架构,其基于消息的设计,能为 Scratch 重新引入 Smalltalk 的灵活性和更多编程乐趣,同时,可理解性也远高于 Scratch Link,我们希望学习者随着能力的提升,能够不段在系统中探险,开发自己的插件,而不是只作为黑盒工具的使用者。本文想以 Cozmo 为具体案例,讨论这种灵活性是如何构建的,希望更多人采用 CodeLab Adapter 去扩展 Scratch。

Cozmo 是什么?

Cozmo 是来自未来的智能机器人。

Cozmo图片

David S. Touretzky 教授此前在社区里发帖说尽管 Cozmo 推出了一年多,依然一骑绝尘,他将其作为教科书级别的典范用于 CMU 的机器人教学,现在是 2020 年,时间又过去了 3,4 年,在 AI 教育机器人这个话题下, 遗憾的是, Cozmo 依然一骑绝尘,唯一的对手或许是它的第二代: Vector。构建它们的团队,可真是梦之队。

如果你对 Cozmo 不熟悉,可以翻阅我在 2017 年写的一篇的文章cozmo 系列之入门 - 有性格且可编程的机器人, 从中引一段过来:

这个憨态可掬的机器人,有些像微缩版的瓦力(正是由瓦力的设计师设计了它的外观),不过它可没瓦力乖巧
它从睡眼惺忪中醒来,伸伸懒腰,便下床(充电座)自顾自地玩耍,它有自个儿的玩具(发光方块),如果你有时间,愿意陪它做游戏,它会很开心,赢了得意忘形,输了就捶胸顿足,得失心这么重,恐怕不适合炒股
如果你没空陪它,也无妨,它闲庭信步,吹吹口哨、哼哼小曲儿;闲着无聊,便来回搬运自己的玩具,堆叠起来或是一把推翻,自得其乐。除了不尿裤子,其他方面都像极了你六岁时的样子
想找你玩,而你又没空的时候,它会来一出苦情戏,走到桌子边缘,假装要掉下去,真站到边缘,又会被自己吓一跳,忙往回缩,这样的演技恐怕只适合拍偶像剧了

技术层面的目标

本文的主题是讨论 Scratch 拓展最佳实践, 在技术层面上,希望实现以下 2 个目标:

目标 1

像我们之前在两种硬件编程风格的比较所做的,区分出领域问题是什么?领域问题内的竞争解决方案,各自有哪些优劣势?什么是最佳实践,理由是什么?

不少 CodeLab 访客以及 编程教育从业者 与我们讨论两种硬件编程风格的比较,根据大家的意见,我们丰富了一些论据,时间过去一年多,我们对这个问题持有相同的论点。很开心这篇文章被当前领域的出色开发者引用:

文章中提到的"灌入式风格"和"交互式风格"是我们当时生造的词,但后来被人们用做讨论时采用的词汇, 这种区分如果是对的,它会帮助人们意识到领域问题。 我们希望本文也能起到类似作用。

此外,需要说明的是,本文的一些论点依赖于 两种硬件编程风格的比较,所以推荐你阅读它。

目标 2

本文第二个目标是希望揭示出构建 Scratch 拓展的开发过程所包含的共性。虽然 Cozmo 是一个硬件设备,但是我们想指出,无论是接入硬件还是软件,无论是 AI 还是 IoT,其构建过程、需要关注的问题、编程模式和面临的困难都是相似的。你很可能会需要处理并发、阻塞、同步、异步、代码生成... 之类的问题,这些问题并不简单。但好在,如果你使用 CodeLab Adapter,就可以直接利用 CodeLab 踩了很多坑,总结出的解决方案。

实际上,当试图设计一种可扩展架构,同时允许接入硬件和软件时,或许会得出一个有趣的观点: 硬件和软件的区分是不重要的,硬件可以看作软件的一个特例。这个观点看起来有些惊世骇俗,但它很可能是你的切身感受,只是怕说出来被嘲笑无知。斯坦福大学维护的斯坦福哲学百科全书计算机哲学条目下里列出了这个观点。

接下来,让我们潜入到问题中。


看几个例子

按惯例,先从几个具体的例子开始, 感受一下在 CodeLab Scratch 中为 Cozmo 编程是怎样的感觉。

积木消失了

Cozmo 依次说的是:

  • 3 号积木消失了
  • 1 号积木消失了
  • 2 号积木消失了

不知道你听出来没有:)

Cozmo 目前还没有中文发音引擎,我让它用英文口音读拼音,特有喜感,很喜欢。

以下 CodeLab Scratch 里对应的程序

程序的逻辑是: 当 Cozmo 视野中(机器视觉) 积木(cube)消失之后,Cozmo 说出消失的积木编号。

"存在即是被感知"

房间里的灯,在 Cozmo 闭上眼睛之后就消失了,从此它信奉贝克莱主教说的 "存在就是被感知" (罗素《哲学问题》里对这个话题的讨论很有趣)

以下是对应程序:

程序的逻辑是: 当 Cozmo 视野中的 1 号积木消失之后(Cozmo 视线被我的手挡住),将房间里的灯关闭(关于可编程空间参考CodeLab 暗号之可编程空间)。

Scratch 扩展

Scratch3.0 支持扩展,如果你不了解 Scratch 扩展,推荐阅读我们之前的文章:创建你的第一个 Scratch3.0 Extension

假设你已经对 Scratch 扩展比较熟悉,本文的主题是 最佳实践, 所以不会谈论太多入门知识,但会尽量避免太多术语。

Scratch 扩展里的三类积木

在 Scratch 扩展中,一般会涉及到三类积木:

  • COMMAND
  • REPORTER (BOOLEAN 和 REPORTER 有很多相似出,就只谈 REPORTER)
  • HAT

先简单介绍一下三类积木,之后它们在扩展中的最佳实践是什么。

  • COMMAND 积木最为简单,如其字面意思,它用来执行某个 command。在前头的《积木消失了》案例里,让 Cozmo 说话的积木 ,就是 COMMAND 类型的积木,它的功能是让 Cozmo 执行 说话的功能。COMMAND 类型的积木也是 Scratch 里最多的一类积木。Scratch 原生的 moveturn都是 COMMAND 积木。有过传统编程语言经验的开发者,或许会把 COMMAND 积木想象为 没有返回值的函数调用
  • REPORTER 积木,像一个圆角按钮, 按其字面意思,指的是有返回值的积木(BOOLEAN 类似),它 返回某个值,所以可以嵌入到其他积木里。在前头的案例里没有使用它。 但在《积木消失了》程序截图中,你能看到 head_angle 积木,运行它,将实时返回 Cozmo 的头部倾角。有过传统编程语言经验的开发者,或许会把 REPORTER 积木想象为 有返回值的函数调用
  • HAT 积木是所有看起来像帽子,这是它名字的来源。语义是 当(when)某件事发生,然后(then)运行其下程序。最有名的 HAT 积木是的 小绿旗,一般用于程序的开始,当用户点击小绿旗时, 然后开始运行其下程序。在前边《积木消失了》和《"存在即是被感知"》程序截图中,都有 HAT 积木,"当视野里的积木消失时,然后...",如果你熟悉事件驱动,它有回调函数的味道,但在实现细节上,它基于轮训实现,我们将在后边细说。

刚才站在 Scratch 使用者视角,从语义层面对这三类积木做了简单介绍。接下来让我们站在实现角度看待它们。

Scratch 3.0 采用 JavaScript 构建,每一个被具体定义的积木,无论是哪种类型(COMMAND/REPORTER/HAT), 它实际上都映射到一个 JavaScript 函数。

COMMAND 和 REPORTER 很简单,COMMAND 积木映射到无返回值的函数(也可以给返回值,但返回值只是提示信息,无法传递给其他积木),REPORTER 映射到有返回值的函数。

至于 HAT 积木,它用起来像回调函数。在实现上,它绑定到一个函数中,这个函数不停运行,返回 true/false,如果返回 ture,则触发其下程序,语义即是当(when)某件事为真(发生),然后(then)运行其下程序。可以看出,每个 hat 积木对应的函数都被抛到一个简单的事件循环里。你需要深入到事件背后的实现里。

如果你有 blockly 经验,会发现,Scratch 积木似乎是 blockly 积木的简化版本,从积木类型和组合方式来看来看,确实是的。

最佳实践之争

Designing a learning system without a solid understanding of the principles in this book is like designing a mechanical system without understanding "the lever". Or "gravity". If you are reading this essay (and I'm pretty sure you are!) then you need to read "Mindstorms". -- Bret Victor 《LEARNABLE PROGRAMMING Designing a programming system for understanding programs》

接下来我们来着手论述,基于这三类积木,将外部设备(如 Cozmo)接入 Scratch,其最佳实践是什么?

如果只有一种策略,便无所谓最佳实践。但将外部设备接入 Scratch 的机制是多样的,这些方法并不彼此等效,有些比另一些更好。在论述什么是最佳实践时,我们先来看看,该领域里都有那些做法。

目前有 3 大类做法:

  1. 从积木中严格生成代码,再将代码烧录到硬件设备中,这是灌入式
  2. 混合灌入式与交互式。
  3. 交互式风格
    • Scratch Link, 策略是将 BLE 的read write notify分别映射到积木上
    • CodeLab Adapter, 采用异步消息,充当中立的消息系统。支持灵活的通信模式。但推荐插件实现者关注CQRS(Command Query Responsibility Segregation)原则。

第 1 种做法,属于灌入式风格,其缺陷我们已经在两种硬件编程风格的比较做过批评了。

blockly 那边转过来的开发者,喜欢将积木视为 严格的代码生成器

将 Scratch 积木当作 blockly 积木来用是不划算的,甚至是愚蠢的,但这种愚蠢却并不显而易见。

将 Scratch 积木用作严格的代码生成器将同时失去两者的优点: 既失去 blockly 轻巧的优点,又失去 Scratch 以复杂结构为代价所构建的动态运行时(runtime)。 却同时抱有它们的缺点: Scratch 的笨重身体、blockly 的单薄灵魂(blockly 只做一件事)。

涉及 Scratch 和 blockly 的比较讨论,参考我们此前的文章 :Blockly 与 Scratch3.0 的比较分析及选型建议

反对第 1 种策略的主要理由来自可理解性,因为它用于教育场景,可理解性至关重要。 如我们在两种硬件编程风格的比较论述的:

灌入式阵营的小伙伴可能会反驳说,你们 generate 的代码并不是真正用于运行的。我会回答说: 对,我恰恰认为,不该把真正执行的代码 generate 出来给用户看。积木之所以是个好工具,正是因为它能自如地隐藏复杂度,暴露出合适粒度的概念颗粒,积木并不只是帮助我们省去记忆语法规则,更重要的是,它允许我们根据学生所处的阶段,给予他不同抽象程度的积木。我很喜欢来自 lisp 社区的忠告:表达你的意图,而不是操作过程,这样有助于我们能站在更高的抽象层面上。

这里还有一条写给开发团队的建议,将积木视为严格的代码生成器,这种观点其实是想完全采用宏(Macro)来编程, 宏(Macro)是强大的,但也是危险的,随着系统复杂度的增加,约束之间的彼此冲突,会给开发团队带来极大的痛苦,失去一致性之后,很容易被复杂度拖垮。此外你将失去可扩展性,它的动态性将很差,难以实现 REPL ,而且难以实现晚绑定(lazy bind)。

策略 2 也被广泛使用。这种折衷策略,能暂时隐藏许多问题,所以对它的批评更为困难些,但它却更为危险,因为混合了许多问题, 我们之后将专门写一篇文章,从一致性和可理解性角度进行批评。在此不细说。

现在假设你同意交互式是正途,问题并未就此结束。

Scratch Link 和 CodeLab Adapter 都使用交互式,我们暂且抛开 Scratch Link 仅支持 BLE 带来的问题(该问题在此做了说明)。在此也不讨论 CodeLab Adapter 架构上的开放性和可扩展性(实际上,可以在 CodeLab Adapter 中实现一个 Scratch Link extension ,从而将 Scratch Link 看作 CodeLab Adapter 的一个子集。 @wangshub之前已经做了类似的工作,他构建了 Adapter BLE GATT dongle)

交互模式

此前分析过 Scratch Link 与 micro:bit 通信的细节: 分析 scratch3.0 与 micro:bit 的通信, 与 Scratch Link 配合的 Scratch 积木(micro:bit/LEGO...)多是这种风格。

在 Scratch Link micro:bit 插件中,REPORTER 和 HAT 积木都依赖于 notify,HAT 依赖于信息流可以理解,但 REPORTER 也依赖于信息流,有点奇怪。更自然的想法似乎是,REPORTER 的返回值通过主动查询得到,而不是由硬件按照某个频率源源不断地广播过来(notify)。 Scratch Link micro:bit 把事件流和数据流看作一类事物。

CodeLab 的 usbMicrobit 插件深受 Scratch Link micro:bit 影响,也采用了 notify 流推送风格。 但我们现在认为,主动查询是一种更优方案。

原因有两点:

  • 功耗更低
  • 实现更简单

Cozmo 插件

所以我们在 Cozmo 插件中采用了这个策略, 以 head_angle 积木为例, 这一个 REPORTER 类型的积木,源码如下

Scratch cozmo 扩展里

get_head_angle(args) {
content = "robot.head_angle.degrees";
return this.adapter_client.emit_with_messageid(NODE_ID, content);
}

Adapter Cozmo 插件里:

payload = self.q.get()
self.logger.info(f'python: {payload}')
message_id = payload.get("message_id")
python_code = payload["content"]
try:
output = eval(python_code, {"__builtins__": None}, {
"cozmo": cozmo,
"robot": robot
})
except Exception as e:
output = e
self.pub_notification(str(e), type="ERROR")
payload["content"] = str(output)
message = {"payload": payload}
self.publish(message)

后者可以看作通用的 REPL kernel,既能回答 query 问题,又能执行 command。

至于 HAT 类积如何实现,采用的是主动发送事件(Cozmo 有 event handle)。值得提醒的是,许多硬件并没有实现event handle,在这种情况下可以使用连续的状态广播(notify),前文有谈及。

Adapter Cozmo 插件里相关源码:

robot.add_event_handler(cozmo.objects.EvtObjectTapped, self.onObjectTapped)

Scratch cozmo 扩展里相关源码:

if (message_type === "device_event") {
console.log("device_event:", msg.message.payload.content);
this.event_name = msg.message.payload.content.event_name;
this.event_param = msg.message.payload.content.event_param;
}

来自 Jupyter 的启发

需要指出的是,上述通过查询获取值的策略,也正是 jupyter/ipython kernel 采用的机制(REPL 程序大多是这样做的,read 和 write 的执行机制是一样的,这样一来,非常简单)。

jupyter 给我们的另一个启发是,在消息中传递代码,并不一定是坏想法,它很可能比 RPC 灵活,把这个想法加上我们之前提到的 积木可以生成代码,就得到了我们目前的策略: 积木生成代码 + 消息通信 + REPL, 这是目前我们接入 Cozmo 的策略。这个策略有极强的灵活性, 同时却没有严格生成代码带来的许多风险。

这种模式允许我们构建 能够运行代码的积木

这有什么用呢? 这允许 Scratch 用户,不触及 Scratch 源码,却能在自定义积木上得到极高灵活性,ta 可以使用 Python 去拓展 Scratch。 ta 甚至可以使用 10 块以内的积木在 Scratch 里构建一个 Python REPL。

这是我们目前找到的最佳实践之一。 虽然 CodeLab Adapter 并不限制你采用什么模式,它高度灵活,但我们会把探索出来的最佳实践分享给社区。

异步消息 + 同步积木

关于最佳实践的第二个部分,涉及同步与异步。

关于同步、异步、阻塞、非阻塞,没有过分布式系统开发经验的开发者会常常会将它们混为一谈,这篇文章,做了很好梳理: 怎样理解阻塞非阻塞与同步异步的区别?

同步和异步关注的是消息通信机制 (synchronous communication/ asynchronous communication)。所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回。但是一旦调用返回,就得到返回值了... 而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。

阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态...阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

在此我们的论点是,在 Scratch 内的积木尽量使用同步阻塞风格,而它的实现却应该是异步非阻塞的。 具体实现部分主要是:emit_with_messageid及与之相关的代码。

之所以应该尽量使用同步阻塞风格,是因为 Scratch 默认支持多任务,所以让积木采用同步阻塞风格并不会破坏 Scratch 的并发能力。除非你有正当理由使用非阻塞风格(诸如 Scratch 里的非阻塞播放音乐),否则同步积木应该是默认选择,由于 Javascript 默认是异步非阻塞,在消息通信中如何实现同步,使用 promise,细节参考我们开放出来的 Scratch 插件源码。

在 Adapter 架构层面,我们将同步看作异步的一个特例,这是受 ROS 影响的结果。这是我们拒绝使用 RPC 的一个原因。当然 RPC 可以异步实现,注意这是个陷阱,虽然 RPC 可以异步实现,但不意味着你使用 RPC 框架的时候是异步的,这里混杂很多的问题。或许只有在实践中,才会意识到。

异步与同步相关的机制,你最好自己去实现它(尽管你最后可能使用某些框架),并理解其中的细节。意识到当你处理异步同步之类的问题时,你并不处于业务逻辑层,如果你不深入实现机制内部,去管理同步异步的机制,通常会陷入各种困境里。当然主要原因也是今天的这个领域还不是很成熟,底层库和语言层都在变动。 如果你已经踩了很多坑,我们开放的 Cozmo 源码相信会是你的好朋友,如果这些坑你都没踩过,推荐使用 CodeLab Adapter,我们已经解决了许多问题,它们并不容易处理,甚至不容易意识到。

Cozmo 插件

经过漫长的讨论,此刻你可能想深入 Cozmo 插件细节里。

如果你已经理解上述这些讨论,源码应该比较好读, 并且你会理解它为何这样实现,为何在众多策略选择中,偏偏选择这种看起来很奇怪的机制。

在你深入源码之前,我们做如下提醒:

  • 有三大类积木:
    • COMMAND
    • REPORTER (BOOLEAN 和 REPORTER 相似)
    • HAT

分别把它们映射到

  • command
  • query
  • event

关注 CQRS 原则。 关注积木的语义而不是它的实现。

有时你可能希望使用 notify,来传递值到 REPORTER 积木里(诸如你希望有很好的实时性),在消息层面很容实现,你应该能够轻松在 Adapter 里自己实现它。除非必要,否则我们比较推荐使用 query,因为它更简单。

理解 Cozmo 插件的核心是理解 REPL。

如果你正在使用 Cozmo,希望为它增加更多有趣的积木,欢迎给我们提 Pull Request。

附上一些相关的讨论。

交互式 与 消息编程

如果你认同两种硬件编程风格的比较一文的观点,当你要实施它时,最佳实践可能是基于消息风格的编程, 这正是CodeLab Adapter所做的事情。使用CodeLab Adapter,你将免费得到灵活性,而且它很简单,副作用小,对减少掉头发很有用。

如果你继续深究,会发现扩展 Scratch 时,如果你只是接入自家的一款设备,不关注未来的可扩展性,可以使用各种静态硬编码方法,但是,如果你希望保留灵活性和互操作能力,通常会导向交互式风格及其最佳实现方法: 消息,这是为何?原因在于 Scratch 依然保持其最初实现语言: Smalltalk 的风格架构,只不过 3.0 是在 JavaScript 里模拟它。而 Smalltalk 的核心是 消息对象 (Scratch 中的角色(源码中的 target)),如 Alan Kay 说的 消息 是 Smalltalk 真正的 big idea,可是主流领域却认为 Smalltalk 的革命性在于面向对象。

COMMAND 和 REPORTER 的 区别

你可能会想到函数和子程序的区别,前者有返回值,后者没有。

我建议从 可组合性 这个角度看待它们之间的区别。 你可以把 COMMAND 积木想象为序列颗粒,可以整合到运行流中。而 REPORTER 积木只能作为 嵌入到其他积木里。

关于愚蠢策略

我们之所以理解文中的愚蠢策略,并不是因为我们更聪明,而是因为我们愚蠢且固执。我们曾抱有相同的愚蠢,不愿舍弃,层层深入,痛苦不堪,曾以各种方式试图在 Blockly 中构建一个动态系统,这在软件层面还勉强可行,当试图驱动硬件设备(树莓派),试图与其他系统互操作,试图映射越来越多的功能,它最终崩溃在复杂性的大杂烩里。灵活性、互操作性以及可理解性是难以用蛮力和人力堆出来的。它需要认真的思考和设计。

我们每个人在内心深处都怀有一个梦想: 希望创造出一个鲜活的世界,一个宇宙。处在我们生活的中间、被训练为架构师的那些人,拥有这样的渴望: 在某一天,在某一个地方,因为某种原因,创造出了一个不可思议的、美丽的、摄人心魄的场所,在那里人们可以漫步,可以梦想,历经很多世纪绵延不绝. –- Christopher Alexander

参考