JavaScript-ESP32-和-ESP8266-物联网开发教程-全-
JavaScript ESP32 和 ESP8266 物联网开发教程(全)
一、入门指南
本章将带您收集本书所需的所有硬件和软件,并在微控制器上运行您的第一个 JavaScript 应用程序。同时,本章还展示了如何使用 JavaScript 源代码级调试器xsbug的有用特性。
安装所有的软件工具和设置您的开发环境需要一点时间,但是一旦您可以运行一个示例,您就可以运行本书中的任何示例了。您还将拥有开始使用可修改的 SDK 编写自己的应用程序所需的一切。
硬件要求
本书中的大多数示例只需要很少的硬件,但您至少需要以下内容:
-
带有 USB 端口的电脑(macOS Sierra 版本 10.12 或更高版本、Windows 7 Pro SP1 或更高版本,或者 Linux)
-
微型 USB 电缆(高速、支持数据同步)
-
ESP32 节点 MCU 模块或 ESP8266 节点 MCU 模块
Note
所有的例子都在 ESP32 或 ESP8266 上运行,除了在第四章中讨论的使用蓝牙低能量(BLE)的例子只在 ESP32 上运行,因为 ESP8266 不支持 BLE。如果你对本书中的 BLE 例子感兴趣,你需要使用 ESP32。
如图 1-1 所示,使用 ESP32 和 ESP8266 模块对示例进行了测试。

图 1-1
ESP32(左)和 ESP8266(右)
使用传感器和致动器的示例(第六章和第七章)需要一些额外的组件:
-
触觉按钮
-
三色 LED(共阳极)
-
三个 330 欧姆电阻
-
微型伺服系统
-
TMP36 温度传感器
-
TMP102 温度传感器
-
迷你金属扬声器(8 欧姆,0.5W)
-
跳线
这些硬件组件如图 1-2 所示。在讨论它们的章节中提供了关于在哪里可以买到它们的更多信息。

图 1-2
章节 6 和 7 的硬件部件
使用 Poco 渲染器(第九章)或 Piu 用户界面框架(第十章)的示例可以在您计算机上的硬件模拟器上运行,但是强烈建议您使用实际的显示器并在您的 ESP32 或 ESP8266 上运行它们。如果您喜欢在试验板上将元件连接在一起,以下是您需要的:

图 1-3
ILI9341 QVGA 触摸显示器
-
ILI9341 QVGA 触摸显示屏(图 1-3 ),可在易贝和其他地方在线购买;搜索“spi display 2.4 touch”,你应该会找到几个不贵的选项。请注意,尽管这种显示效果很好,但还有许多其他选择。可修改的 SDK 包括对其他几种不同成本和质量的显示器的内置支持;有关更多信息,请参见可修改 SDK 的
documentation/displays目录。 -
一块试验板。
-
公母跳线。
如果你不想自己布线,你可以从可修改的网站上购买一个可修改的或两个可修改的。可修改的是一个 ESP8266 有线电容触摸屏;Moddable Two 是一个连接到同一个触摸屏的 ESP32。两者都是紧凑外形的现成开发套件。图 1-4 显示了一个可修改的。

图 1-4
可修改的
可修改的 SDK 还支持基于 ESP32 的开发套件,带有内置屏幕。一个流行的选择是 M5Stack FIRE,如图 1-5 所示。有关支持的开发工具包的更多信息,请参见 GitHub 上的可修改的 SDK 库。

图 1-5
M5 栈火灾
软件需求
您需要以下软件:
-
代码编辑器
-
示例代码文件
-
可修改的 SDK
-
ESP32 和/或 ESP8266 的构建工具
您可以选择自己喜欢的代码编辑器。有许多 JavaScript 友好的编辑器,包括 Visual Studio 代码、Sublime Text 3 和 Atom。
接下来的部分将解释如何下载示例代码文件,以及如何为您的设备设置可修改的 SDK 和构建工具。
下载示例代码
所有的例子都可以在 https://github.com/Moddable-OpenSource/iot-product-dev-book 找到。您可以使用git命令行工具下载示例代码。
Note
在本书中,您在命令行上输入的命令前面有一个>符号。该符号不是命令的一部分;包含它只是为了阐明每个单独的命令从哪里开始。
在 macOS/Linux 上,使用终端:
> cd ~/Projects
> git clone https://github.com/Moddable-OpenSource/ iot-product-dev-book
在 Windows 上,使用命令提示符(将<username>改为您的用户名):
> cd C:\Users\<username>\Projects
> git clone https://github.com/Moddable-OpenSource/ iot-product-dev-book
您还需要设置EXAMPLES环境变量来指向示例存储库的本地副本,如下所示:
-
在 macOS/Linux 上:
> export EXAMPLES=~/Projects/iot-product-dev-book -
在 Windows 上:
> set EXAMPLES=C:\Users\<username>\Projects\ iot-product-dev-book
设置您的构建环境
在构建和运行示例之前,请遵循可修改 SDK 的documentation目录中的“可修改 SDK–入门”文档中的说明。本文档提供了安装、配置和构建适用于 macOS、Linux 和 Windows 的可修改 SDK 的分步说明,以及安装使用 ESP32 和 ESP8266 所需工具的说明。
使用xsbug
xsbug调试器提供运行在 XS JavaScript 引擎上的 JavaScript 代码的源代码级调试。它通过 USB 连接到设备,并具有图形用户界面(如图 1-6 所示)以使其易于使用。

图 1-6
xsbug调试器
与其他调试器类似,xsbug支持设置断点和浏览源代码、调用栈和变量。它还提供实时工具来跟踪内存使用情况,并分析应用程序和资源消耗。
当您为微控制器开发时,在目标设备上启动应用程序之前,构建系统会自动打开xsbug。
当开发桌面模拟器时,您需要自己打开xsbug,方法是双击它的应用程序图标或从命令行打开它,如下所示:
-
在 macOS 上:
> open $MODDABLE/build/bin/mac/release/xsbug.app -
在 Windows/Linux 上:
> xsbug
本书中示例的重要特征
这本书不经常参考xsbug,因为例子都已经调试好了。然而,在您创建自己的应用程序时,xsbug是一个非常有价值的工具。本书中使用的最重要的xsbug特征如下:

图 1-7
xsbug机器标签和控制按钮
-
机器选项卡–连接到
xsbug的每个 XS 虚拟机在窗口的左上角都有自己的选项卡(如图 1-7 中虚线边框所突出显示的)。单击选项卡会将左侧窗格切换到 machine 选项卡视图,您可以在其中查看仪器、使用控制按钮等。 -
控制按钮–这些位于机器选项卡视图顶部的图标按钮(在图中用虚线边框突出显示)控制虚拟机。从左到右分别是杀、破、跑、步、进、步。
-
控制台–能够在应用程序执行期间查看诊断信息通常很有用。
trace函数将消息写入xsbug右下角的调试控制台。
关于xsbug所有特性的完整文档,请参见可修改 SDK 的documentation/xs目录下的xsbug文档。
运行示例
本书存储库中的例子是按章节组织的,每一章都有几个例子。为了更快地构建和启动示例,每个章节都有自己的主机,它包含运行该章节的示例所需的软件环境;主机是 JavaScript 模块、配置变量和其他可供应用程序使用的软件的集合。因为微控制器的空间非常有限,所以不可能有一台主机包含本书示例中使用的所有模块。
您可以将主机视为基本的应用程序。在 web 浏览器中运行 JavaScript 时,web 浏览器是主机;在 web 服务器上运行 JavaScript 时,Node.js 是主机。
单独安装主机,而不是将主机和示例一起安装,通过最大限度地减少需要下载的软件数量,大大加快了开发速度。在您的设备上安装主机通常需要 30 到 90 秒。完成后,您可以在几秒钟内安装大多数示例,因为主机已经包含了示例所需的设备固件和 JavaScript 模块。
接下来的小节将带您完成安装主机的整个过程,然后是一个示例,从helloworld开始。请注意,在本书的上下文中,安装应用程序会导致该应用程序在设备上运行。
安装主机
第一步是刷新设备以安装主机。如果你好奇的话,可以在host目录中找到每一章主机的源代码。要使用主机,您真正需要知道的是,它包含了相应示例所需的所有模块。
您使用mcconfig命令行工具来刷新设备。
mcconfig
mcconfig命令行工具在微控制器或模拟器上构建和安装应用程序。这里提供了用于在每个支持的平台上安装本章主机的命令。
在 ESP32 上,使用以下命令:
-
在 macOS/Linux 上:
> cd $EXAMPLES/ch1-gettingstarted/host > mcconfig -d -m -p esp32 -
在 Windows 上:
> cd %EXAMPLES%\ch1-gettingstarted\host > mcconfig -d -m -p esp32
在 ESP8266 上,使用以下命令:
-
在 macOS/Linux 上:
> cd $EXAMPLES/ch1-gettingstarted/host > mcconfig -d -m -p esp -
在 Windows 上:
> cd %EXAMPLES%\ch1-gettingstarted\host > mcconfig -d -m -p esp
确认主机已安装
一旦主机安装完毕,它会将图 1-8 所示的消息写入调试控制台。

图 1-8
xsbug中来自主机的消息
安装helloworld
helloworld示例只包含三行 JavaScript 代码:
debugger;
let message = "Hello, World";
trace(message + "\n");
这个例子使用了两个重要的特性:
-
debugger语句,它停止执行并中断到xsbug。 -
trace函数,它将消息写入调试控制台。注意trace不会自动在消息末尾添加一个换行符(\n)。这使您能够使用几个trace语句来生成一行的输出。确保在行尾包含换行符,以便文本在xsbug中正确显示。
您使用mcrun来安装示例。
mcrun
mcrun命令行工具构建并安装额外的 JavaScript 模块和资源,改变微控制器或模拟器上可修改应用的行为或外观。mcconfig和mcrun都构建脚本和资源。与mcrun不同,mcconfig也构建本地代码。用 JavaScript 术语来说,mcconfig构建主机。
使用mcrun安装示例后,设备会重新启动。这将重新启动主机,主机将运行您安装的示例。
使用以下命令安装helloworld示例。确保您将<platform>更改为适合您设备的正确平台,无论是esp32还是esp。
-
在 macOS/Linux 上:
> cd $EXAMPLES/ch1-gettingstarted/helloworld > mcrun -d -m -p <platform> -
在 Windows 上:
> cd %EXAMPLES%\ch1-gettingstarted\helloworld > mcrun -d -m -p <platform>
收尾工作
一旦安装了应用程序,您应该立即进入xsbug。点击运行按钮,可以看到写入调试控制台的消息Hello, World,如图 1-9 所示。

图 1-9
Hello、World写入xsbug中的控制台
如果一切顺利,如果您使用的是裸 NodeMCU 板,那么您可以进入本章的“结论”部分。如果您想要添加显示器(强烈建议),请继续“添加显示器”一节。
如果您遇到了问题,请参阅下一节。
解决纷争
当你试图安装一个应用程序时,你可能会遇到错误或警告形式的障碍;本节解释了一些常见问题以及如何解决这些问题。
设备未连接/未识别
错误消息
error: cannot access /dev/cu.SLAB_USBtoUART
意味着该设备未连接到您的电脑,或者电脑无法识别该设备。发生这种情况有几个原因:
-
您的设备没有插入计算机。运行构建命令时,请确保它已接通电源。
-
您有一根 USB 电缆,只能通电。确保您使用的是支持数据同步的 USB 电缆。
-
计算机无法识别您的设备。要解决此问题,请参阅您的操作系统下面的说明。
macOS/Linux
要测试您的计算机是否识别您的设备,请拔下设备并输入以下命令:
> ls /dev/cu*
然后插入设备并重复相同的命令。如果没有新内容出现,则设备未被检测到。确保您安装了正确的 VCP 驱动程序。
如果看到了,您现在就有了设备名称,并且需要编辑UPLOAD_PORT环境变量。输入以下命令,用系统上的设备名称替换/dev/cu.SLAB_USBtoUART:
> export UPLOAD_PORT=/dev/cu.SLAB_USBtoUART
Windows 操作系统
检查设备管理器中的 USB 设备列表。如果您的设备显示为未知设备,请确保您安装了正确的 VCP 驱动程序。
如果您的设备出现在 COM3 以外的 COM 端口上,您需要编辑UPLOAD_PORT环境变量。输入以下命令,将COM3替换为适合您系统的设备 COM 端口:
> set UPLOAD_PORT=COM3
不兼容的波特率
以下警告消息是正常的,不必担心:
warning: serialport_set_baudrate: baud rate 921600 may not work
但是,有时上传开始但没有完成。当跟踪到控制台的进度条达到 100%时,您可以判断上传完成。例如:
...................................................... [ 16% ]
...................................................... [ 33% ]
...................................................... [ 49% ]
...................................................... [ 66% ]
...................................................... [ 82% ]
...................................................... [ 99% ]
.. [ 100% ]
上传可能中途失败有几个原因:
-
你的 USB 线有问题。
-
您的 USB 电缆不支持更高的波特率。
-
您使用的主板要求的波特率低于可修改 SDK 使用的默认波特率。
要解决最后两个问题,您可以更改为较慢的波特率,如下所示:
-
如果您正在使用 ESP32,请打开
moddable/tools/mcconfig/make.esp32.mk;如果是 ESP8266,打开moddable/tools/mcconfig/make.esp.mk。 -
找到这一行,它将上传速度设置为 921,600:
UPLOAD_SPEED ?= 921600
- 将速度设置为较小的数值。例如:
UPLOAD_SPEED ?= 115200
设备不处于引导加载模式
如果您使用某些基于 ESP32 的主板,这个问题并不罕见。当您尝试刷新设备时,会短暂停止跟踪状态消息,几秒钟后您会收到以下错误消息:
A fatal error occurred: Failed to connect to ESP32: Timed out waiting for packet header
如果设备不处于引导加载程序模式,则不能刷新设备。如果您使用的是 NodeMCU 模块,请在每次刷新时遵循以下步骤:

图 1-10
ESP32 上的启动按钮
-
拔下设备插头。
-
按住开机按钮(图 1-10 中圈出)。
-
将设备插入电脑。
-
输入
mcconfig命令。 -
等待几秒钟,然后松开启动按钮。
添加显示器
虽然本书中的大多数示例都不需要显示器,但在 ESP32 或 ESP8266 中添加显示器可以极大地改善用户体验。它使您能够执行以下操作:
-
显示比几个闪烁的灯更多的信息
-
创建现代用户界面
-
添加功能
本书中的示例是为分辨率为 240 x 320 或 320 x 240 的显示器设计的(例如,QVGA)。这些显示器的尺寸通常在 2.2 英寸到 3.5 英寸之间,在早期的智能手机中很常见。其他尺寸的显示器也可以连接到这些微控制器,但这个尺寸与这些微控制器的性能非常匹配。
以下部分说明如何将 ILI9341 QVGA 触摸显示器连接到 ESP32 或 ESP8266。如果您使用的是 Moddable One 或 Moddable Two 这样的开发板,您可以跳到“安装 helloworld-gui”一节。
将显示器连接到 ESP32
表 1-1 和图 1-11 显示了如何将显示器连接到 ESP32。

图 1-11
将显示器连接到 ESP32 的接线图
表 1-1
将显示器连接到 ESP32 的接线
|ILI9341 显示器
|
ESP32
|
| --- | --- |
| SDO/MISO | GPIO12 |
| 发光二极管 | 3.3V |
| 血清肌酸激酶 | GPIO14 |
| SDI/MOSI | GPIO13 |
| 特许测量员 | GPIO15 |
| 直流电 | GPIO2 |
| 重置 | 3.3V |
| 地线 | 地线 |
| VCC | 3.3V |
| 唐岛 | GPIO12 |
| T_DIn | GPIO13 |
| S7-1200 可编程控制器 | GPIO14 |
| T_IRQ | GPIO23 |
| S7-1200 可编程控制器 | GPIO18 |
将显示器连接到 ESP8266
表 1-2 和图 1-12 显示了如何将显示器连接到 ESP8266。

图 1-12
将显示器连接到 ESP8266 的接线图
表 1-2
将显示器连接到 ESP8266 的接线
|ILI9341 显示器
|
ESP8266
|
| --- | --- |
| SDO/MISO | GPIO12 |
| 发光二极管 | 3.3V |
| 血清肌酸激酶 | GPIO14 |
| SDI/MOSI | GPIO13 |
| 特许测量员 | GPIO15 |
| 直流电 | GPIO2 |
| 重置 | 3.3V |
| 地线 | 地线 |
| VCC | 3.3V |
| 唐岛 | GPIO12 |
| T_DIn | GPIO13 |
| S7-1200 可编程控制器 | GPIO14 |
| T_IRQ | GPIO16 |
| S7-1200 可编程控制器 | GPIO0 |
安装helloworld-gui
helloworld-gui示例是在屏幕上显示文本的helloworld的一个版本。如果您自己将显示器连接到设备,使用helloworld-gui应用程序刷新设备是测试连接是否正确的好方法。
要使用的命令与用于安装helloworld的命令非常相似。唯一的区别是平台标识符。平台标识符告诉构建系统包括适当的显示和触摸驱动程序。如果您使用的是可修改的,平台标识符是esp/moddable_one;对于一个可修改的二,它是esp32/moddable_two。如果您根据前面章节的说明添加了一个显示器,平台标识符为esp32/moddable_zero或esp/moddable_zero。
使用以下命令安装helloworld-gui示例。确保将<platform>更改为适合您设备的正确平台。
-
在 macOS/Linux 上:
> cd $EXAMPLES/ch1-gettingstarted/helloworld-gui > mcconfig -d -m -p <platform> -
在 Windows 上:
> cd %EXAMPLES%\ch1-gettingstarted\helloworld-gui > mcconfig -d -m -p <platform>
如果您指定了正确的平台并且您的接线正确,您将看到如图 1-13 所示的屏幕。

图 1-13
图形helloworld应用程序
结论
既然您的开发环境已经设置好,并且您已经熟悉了在您的设备上安装本章示例的过程,那么您已经准备好尝试更多的示例了!
此时,你已经掌握了跟随第二章到第十章的所有材料和技能。这些章节彼此独立,所以你可以按任何顺序阅读。当您开始使用某一章中的示例时,一定要安装该章的主机,否则在启动示例时会遇到错误。一旦你对可修改的 SDK 的 API 感到满意,你就可以进入第十一章了,这一章涵盖了更高级的主题。
二、面向嵌入式 C 和 C++程序员的 JavaScript
本章是对已经熟悉 C 或 C++的开发人员的快速、实用的 JavaScript 介绍。它假设您已经知道如何编程,并且可能有一些嵌入式系统的开发经验。这里介绍的 JavaScript 语言与 web 上使用的语言完全相同。但是因为这里的重点是嵌入式系统而不是 web 浏览器,所以这一章讲述了 JavaScript 的一些方面,这些方面很少被从事 web 工作的开发人员使用。例如,考虑到如果不操作二进制数据(比如一个字节数组),编写嵌入式软件几乎是不可能的;JavaScript 通过内置的类型化数组类支持二进制数据,但大多数 web 开发人员从未使用过该特性,因为在构建网页时没有必要。因此,即使你熟悉 web 上的 JavaScript,你也可能想阅读这一章来熟悉嵌入式系统上比 web 上更常见的语言特性。
C 和 C++程序员在入门 JavaScript 时有一个很大的优势,因为这种语言看起来与 C 相当相似,这不是偶然的:JavaScript 编程语言被设计成与 Java 编程语言相似;Java 是由 C++演化而来的;创建 C++是为了将面向对象编程引入 C。许多相似之处将帮助您快速读写 JavaScript 代码。尽管如此,这两种语言在许多方面也是不同的。本章以相似之处为基础,向您介绍一些不同之处。
JavaScript 已经有 20 多年的历史了,而且还在不断发展。本章介绍了现代 JavaScript,包括 2019 版 JavaScript 中的功能以及一些(如私有字段)正在纳入未来版本的功能。这里只描述作为标准语言一部分的 JavaScript 特性。由于 JavaScript 历史悠久,某些特性不再推荐使用;本章指出了其中的一些。特别是,2012 年标准化的 JavaScript 第五版引入了严格模式 、,消除了一些令人困惑和低效的特性。这些原始行为在松散模式、中仍然可用,这主要是为了网站的向后兼容性,但这本书专门使用严格模式。
基本语法
本节介绍一些基础知识,比如如何使用 JavaScript 进行函数调用、声明变量,以及使用if、switch、for和while语句控制执行流程。在 C 和 JavaScript 中,所有这些都非常相似,但是在这个过程中,您将了解到一些重要的区别。
“你好,世界”
学习 C 语言的传统起点是 Kernighan 和 Ritchie 的书《C 编程语言》中的hello, world程序。在 JavaScript 中,只有一行:
trace("hello, world\n");
这里,C printf函数被来自可修改 SDK 的trace函数所取代。(在网络上使用 JavaScript 的开发者使用console.log而不是trace。)与 C #一样,函数的参数在括号内传递,语句以分号结束。传递给该函数的字符串文字也是相同的——一个用双引号括起来的字符串——并且使用 C 语言中常见的反斜杠(\)符号来转义特殊字符,比如这里的换行符。
分号
C 和 C++的一个显著区别是,由于自动分号插入(ASI)特性,语句结尾的分号在 JavaScript 中是可选的。以下代码在 JavaScript 中是允许的,但在 C #中却失败了:
trace("hello, ")
trace("world")
trace("\n")
虽然这很方便,因为它省去了一次击键,并悄悄地纠正了省略分号的常见错误,但在某些模糊的情况下,它会产生歧义,从而导致错误。因此,您应该始终以分号结束语句,而不是依赖 ASI。JavaScript linters,比如 ESLint,包含了对缺少分号的检查。
声明变量和常数
JavaScript 中的变量是用let语句声明的:
let a = 12;
let b = "hello";
let c = false;
与 C 语言不同,变量声明不包含任何类型信息(如int、bool或char *)。这是因为任何变量都可以包含任何类型。这种动态类型,将在本章后面进一步解释,是 JavaScript 的一个特性,需要 C 程序员花一点时间来适应。
JavaScript 中的变量名通常遵循 C 惯例:它们区分大小写,所以i和I是不同的名称,并且变量名的长度没有限制。JavaScript 变量名也可能包含 Unicode 字符,如下例所示:
let garçon = "boy";
let 東京都 = "Tokyo";
let $ = "dollar";
let under_score = "_";
你用const声明常量值:
const PRODUCT_NAME = "Light Sensor";
const LUMEN_REGISTER = 2;
const USE_OPTIMIZATIONS = true;
任何向常数赋值的尝试都会产生错误;但是,与 C #不同,此错误是在运行时而不是在构建时生成的。
如清单 2-1 所示,用let和const做的声明遵守与 c 中声明相同的作用域规则
let x = 1;
const y = 2;
let z = 3;
if (true) {
const x = 2;
let y = 1;
trace(x); // output: 2
trace(y); // output: 1
trace(z); // output: 3
y = 4;
z += y;
}
trace(x); // output: 1
trace(y); // output: 2
trace(z); // output: 7
Listing 2-1.
JavaScript 还允许使用var来声明变量,这仍然很常见,因为let是一个相对较新的添加。然而,这本书建议只使用let,因为var不遵守与 c 语言中声明相同的作用域规则
if声明
JavaScript 中的if语句与 C 语言中的结构相同,如清单 2-2 所示。
if (x) {
trace("x is true\n");
}
else {
trace("x is false\n");
}
Listing 2-2.
像在 C 中一样,当if或else块是一个单独的语句时,可以省略分隔该块的大括号:
if (!x)
trace("x is false\n");
else
trace("x is true\n");
清单 2-2 中if语句的条件简单来说就是x。在 C 中,这意味着如果x为 0,则条件为假;否则就是真的。在 JavaScript 中,这更复杂,因为变量x可能是任何类型,而不仅仅是数字(或者指针,但是指针在 JavaScript 中是不存在的)。JavaScript 定义了以下规则来评估给定值是真还是假:
-
对于一个布尔值,这个决定很简单:这个值要么是
true要么是false。 -
对于一个数字,JavaScript 遵循 C 的规则,将 0 值视为
false,其他所有值视为true。 -
空字符串(长度为 0 的字符串)计算结果为
false,所有非空字符串计算结果为true。
在 JavaScript 中,在一个条件中评估为true的值称为“真值”,评估为false的值称为“假值”。
一种紧凑形式的if语句,即条件(三元)运算符,在 JavaScript 中可用,并且具有与 C 中相同的结构:
let x = y ? 2 : 3;
switch声明
如清单 2-3 所示,JavaScript 中的switch语句看起来非常像 c 语言中的语句
switch (x) {
case 0:
trace("zero\n");
break;
case 1:
trace("one\n");
break;
default:
trace("unexpected!\n");
break;
}
Listing 2-3.
然而,有一个重要的区别:case关键字后面的值不限于整数值。例如,您可以使用一个浮点数(参见清单 2-4 )。
switch (x) {
case 0.25:
trace("one quarter\n");
break;
case 0.5:
trace("one half\n");
break;
}
Listing 2-4.
也可以使用字符串(列表 2-5 )。
switch (x) {
case "zero":
case "Zero":
trace("0\n");
break;
case "one":
case "One":
trace("1\n");
break;
default:
trace("unexpected!\n");
break;
}
Listing 2-5.
此外,JavaScript 允许您在case语句中混合不同类型的值,尽管这很少是必要的。
环
JavaScript 既有for又有while循环,看起来与 C 语言中的循环相似(参见清单 2-6 )。
for (i = 0; i < 10; i++)
trace(i);
let j = 12;
while (j--)
trace(j);
Listing 2-6.
JavaScript 循环同时支持continue和break(列举 2-7 )。
for (i = 0; i < 10; i++) {
if (i & 1)
continue; // Skip odd numbers
trace(i);
}
let j = 0;
do {
let jSquared = j * j;
if (jSquared > 100)
break;
trace(jSquared);
j++;
} while (true);
Listing 2-7.
类型
JavaScript 只有少数内置类型,所有其他类型都是从这些类型中创建的。这些类型中有许多是 C 和 C++程序员所熟悉的,比如Boolean、Number和String,尽管这些类型在 JavaScript 版本中有所不同,但您需要理解它们。其他类型,比如undefined,在 C 或 C++中没有对等的。
请注意,本节没有介绍所有类型。例如,它省略了RegExp、BigInt和Symbol,因为它们在嵌入式系统的 JavaScript 开发中并不常用;但是,如果您的项目需要它们,它们是可用的。
undefined
在 C 和 C++中,一个操作可以有一个语言没有定义的结果。例如,如果您忘记在下面的代码中初始化x,则y的值是未知的:
int x;
int y = x + 1; // ??
同样,如果您忘记包含一个return语句,函数的结果是未知的:
int add(int a, int b) {
int result = a + b;
}
int z = add(1, 2); // ??
您的 C 或 C++编译器通常会检测到这类错误并发出警告,以便您可以修复问题。尽管如此,在 C 和 C++中仍有许多方法会导致代码出现不可预知的结果。
在 JavaScript 中,永远不会出现结果不可预测的情况。实现这一点的方法之一是使用特殊值undefined,它表示没有赋值。在 C 中,出于类似的目的,0 有时被用作无效值,但在 0 是有效值的情况下,这是不明确的。
当你定义一个新的局部变量时,它的值是undefined,直到你给它赋值。如果你的函数在没有return语句的情况下退出,它的返回值是undefined。在本章中你会看到undefined的其他用法。
严格来说,JavaScript 有一个undefined类型,它总是有值undefined。
布尔值
JavaScript 中的布尔值是true和false。这些和 C 中的 1 和 0 不一样;它们是由语言定义的截然不同的价值观。
let x = 42;
let y = x == 42; // true
let z = x == "dog"; // false
民数记
JavaScript 中的每个数字值都被定义为双精度(64 位)IEEE 754 浮点值。在您对微控制器的性能影响感到恐惧之前,要知道 Moddable SDK 中使用的 XS JavaScript 引擎在内部将数字存储为整数,并在可能的情况下对它们执行整数数学运算。这确保了在没有 FPU 的微控制器上的实现是高效的,同时保持了与标准 JavaScript 的完全兼容性。
let x = 1;
let y = -2.3;
let z = 5E2; // 500
将每个数字都定义为 64 位浮点有一些好处。首先,整数溢出的可能性要小得多。如果整数运算的结果溢出 32 位整数,它会自动提升为浮点值。64 位浮点值在失去精度之前可以存储多达 53 位的整数。如果您碰巧执行了生成分数结果的数学运算,例如,将一个奇数除以 2,JavaScript 会以浮点值的形式返回精确的分数结果。
Infinity和NaN
JavaScript 对数字有一些特殊的值:
-
除以 0 不会产生错误,而是返回
Infinity。 -
试图执行一个无意义的操作会返回
NaN,意思是“不是一个数字”例如,5 / "a string"和5 + undefined返回NaN,因为用一个字符串值除一个整数或者给一个整数加上undefined是没有意义的。
基础
JavaScript 对十六进制和二进制常量有特殊的表示法:
-
一个数字的前缀意味着它是十六进制的,就像在 c 语言中一样。
-
一个数字的前缀意味着它是二进制的,如 C++14 所支持的。
这些前缀在处理二进制数据时非常有用,如下例所示:
let hexMask = 0x0F;
let bitMask = 0b00001111;
与 C 不同,JavaScript 不支持以 0 开头的八进制数,如0557;如果您尝试使用它,它会在构建时生成错误。形式0o557支持八进制数值。
数字分隔符
JavaScript 允许使用下划线字符(_)作为数字分隔符来分隔数字。分隔符不会改变数字的值,但可以使它更容易阅读。C++14 也有一个数字分隔符,但它使用单引号字符(')代替。
let mask = 0b0101101011110000;
let maskWithSeparators = 0b0101_1010_1111_0000;
按位运算符
JavaScript 为数字提供了按位运算符,包括:
-
~–按位非 -
&–按位 AND -
|–按位或 -
^–按位异或
它还提供了这些用于移位的按位运算符:
-
>>–带符号右移 -
>>>–无符号右移 -
<<–向左移位
没有无符号左移,因为左移一个非零值总是会丢弃符号位。当执行任何按位运算时,JavaScript 总是首先将值转换为 32 位整数;丢弃任何小数部分或附加位。
Math物体
Math对象提供了许多 C 程序员从math.h头文件中使用的函数。除了常见的常量如Math.PI、Math.SQRT2和Math.E之外,它还包括常见的函数,如清单 2-8 所示。
let x = Math.min(1, 2, 3); // minimum = 1
let y = Math.max(2, 3); // maximum = 3
let r = Math.random(); // random number between 0 and 1
let z = Math.abs(-3.2); // absolute value = 3.2
let a = Math.sqrt(100); // square root = 10
let b = Math.round(3.9); // rounded value = 4
let c = Math.trunc(3.9); // truncated value = 3
let z = Math.cos(Math.PI); // cosine of pi = -1
Listing 2-8.
关于由Math对象提供的常量值和函数的完整列表,请查阅 JavaScript 参考资料。
将数字转换为字符串
在 C 中,将数字转换成字符串的一种常见方法是使用sprintf将数字打印到字符串缓冲区。在 JavaScript 中,通过调用数字的toString方法将数字转换为字符串(是的,在 JavaScript 中,甚至数字也是一个对象!):
let a = 1;
let b = a.toString(); // "1"
toString的默认基数是 10;要转换成非十进制值,比如十六进制或二进制,将基数作为参数传递给toString:
let a = 240;
let b = a.toString(16); // "f0"
let c = a.toString(2); // "11110000"
要转换成浮点记数法,使用toFixed代替toString,并指定小数点后的位数:
let a = 24.328;
let b = a.toFixed(1); // "24.3"
let c = a.toFixed(2); // "24.33"
let d = a.toFixed(4); // "24.3280"
函数toExponential和toPrecision为将数字转换成字符串提供了额外的格式选项。
将字符串转换为数字
在 C 中,将字符串转换为数字的一种常见方法是使用sscanf。在 JavaScript 中,根据您希望结果是整数还是浮点值,使用parseInt或parseFloat:
let a = parseInt("12.3"); // 12
let b = parseFloat("12.30"); // 12.3
parseInt的默认基数是 10,除非字符串以0x开头,在这种情况下,默认值是 16。parseInt函数采用可选的第二个参数来表示基数。下面的示例分析一个十六进制值:
let a = parseInt("F0", 16); // 240
您也可以通过Number.parseInt和Number.parseFloat访问parseInt和parseFloat的功能;然而,这并不常见。
用线串
在 C 语言中,字符串不是一个独特的类型,而只是一个 8 位字符的数组。因为字符串如此普遍,C 标准库提供了许多使用它们的函数。尽管如此,在 C 语言中处理字符串并不容易,很容易导致安全错误,比如缓冲区溢出。C++解决了一些问题,尽管使用字符串仍然不容易或不安全。相比之下,JavaScript 有一个内置的String类型,它被设计成易于程序员使用和安全使用;这反映了 JavaScript 作为网络语言的起源,其中字符串操作在构建网页时很常见。
在 JavaScript 中,字符串在许多方面不同于普通的 C 字符串。字符串是 16 位 Unicode 字符(UTF-16)的序列,而不是 8 位字符的数组。使用 Unicode 表示字符串可以确保所有应用程序都能可靠地处理任何语言的字符串值。虽然这些字符在概念上是 16 位 Unicode,但是 JavaScript 引擎可以在内部以任何表示形式存储它们。XS 引擎将字符串存储在 UTF-8 中,因此对于来自通用 7 位 ASCII 字符集的字符来说没有额外的内存开销。
访问单个字符
JavaScript 字符串不是数组;但是,它们支持 C 语言的数组语法来访问单个字符。但与 C 不同,结果不是字符的 Unicode(数字)值,而是包含该索引处字符的单字符字符串。
let a = "garçon";
let b = a[3]; // "ç"
let c = a[4]; // "o"
在 C #中,访问无效索引(例如,超过字符串末尾)会返回一个未定义的值。对于前面代码中声明的a,a[100]将访问字符串开始后 100 字节内存中发生的任何事情。通过访问未映射的内存,这种访问甚至可能导致内存故障。在 JavaScript 中,试图读取字符串有效范围之外的字符会返回值undefined。
要获得给定索引处字符的 Unicode 值,使用charCodeAt函数:
let a = "garçon";
let b = a.charCodeAt(3); // 231
let c = a.charCodeAt(4); // 111
let d = a.charCodeAt(100); // NaN
修改字符串
c #允许你读写字符串中的字符。JavaScript 字符串是只读的,也叫不可变;您不能“就地”修改字符串例如,以下代码中对a[0]的赋值在 JavaScript 中不起任何作用:
let a = "a string";
a[0] = "A";
对于来自 C 的人来说,这种限制可能有点难以适应,但是对于使用提供的许多方法来操作字符串的一些经验来说,这就变得很熟悉了。
确定字符串的长度
要确定 C 中字符串的长度,可以使用strlen函数,该函数返回字符串中的字节数。它通过扫描值为 0 的字节来确定长度,因为 C 中的字符串被定义为以 0 字节结束。在 JavaScript 中,字符串是 Unicode 字符序列,没有终止空字符;JavaScript 引擎知道序列中的字符数,可以通过length属性获得。
let a = "hello";
let b = a.length; // 5
strlen的一个问题是,当字符是 8 位 ASCII 字符时,字符串中的字节数只等于字符串的长度。对于 Unicode 字符,strlen不提供字符数。当然,也有其他函数会这样做,但是 C 程序员经常会错误地对字符串使用strlen来获取字符数,从而导致错误。JavaScript length属性避免了这个问题,因为它总是返回一个字符数。
清单 2-9 中的例子使用length属性来计算字符串中的空格数。
let a = "zéro un deux";
let spaces = 0;
for (let i = 0; i < a.length; i++) {
if (a[i] == " ")
spaces += 1;
}
trace(spaces);
Listing 2-9.
嵌入引号和控制字符
到目前为止,本章中的字符串文字值都使用双引号(")来定义字符串的开始和结束。由双引号分隔的字符串可以包含单引号(')。
let a = "Let's eat!";
与 C #中一样,这样的字符串不能包含双引号。与 C #不同,您可以用单引号而不是双引号来分隔字符串,这对于包含双引号的字符串来说很方便。
let a = '"This is a test," she said.';
由单引号或双引号分隔的字符串必须完全包含在一行中。通过使用\n指定换行符,可以在字符串中包含换行符;反斜杠(\)让您可以像在 c 中一样对字符进行转义。
let a = 'line 1\nline 2\nline 3\n';
在 JavaScript 中描述字符串的另一种方法是使用反斜杠字符(```js)。以这种方式定义的字符串被称为模板文字,并且有几个有用的属性,包括它们可以跨越多行(潜在地使你的字符串更可读;将清单 2-10 与之前的示例进行比较。
let a =
`line 1
line 2
line 3
`;
Listing 2-10.
```js
#### 字符串替换
模板文字提供了一种替换机制,对于由几个部分组成一个字符串很有用。这提供的功能非常类似于在 C 中使用带有格式化字符串的`printf`。然而,C 将格式化字符串与要格式化的值分开,而 JavaScript 将它们合并。这一开始可能会让人感到陌生,但是将需要格式化的值直接放入字符串中不容易出错。
let a = "one";
let b = "two";
let c = ${a}, ${b}, three; // "one, two, three"
在模板文本中,`${`和`}`之间的字符被计算为 JavaScript 表达式,这使您能够执行计算并将格式应用于结果:
let a = 2 + 2 = ${2 + 2}; // "2 + 2 = 4"
let b = Pi to three decimals is ${Math.PI.toFixed(3)};
// "Pi to three decimals is 3.142"
一个被称为*标签*的特殊特性使函数能够修改模板文字的默认行为。例如(正如第四章将演示的),您可以使用这个特性将 UUID 的字符串表示转换成二进制数据。带标签的模板文本如何工作的细节超出了本章的范围,但是使用它们很容易:只需将标签放在模板文本之前。
let a = uuid1805;
let b = uuid9CF53570-DDD9-47F3-BA63-09ACEFC60415;
#### 添加字符串
您可以使用加法运算符(`+`)在 JavaScript 中组合字符串:
let a = "one";
let b = "two";
let c = a + ", " + b + ", three"; // "one, two, three"
JavaScript 允许您将字符串添加到非字符串值中,如数字。它的工作规则通常会给你预期的结果,但并不总是这样:
let a = "2 + 2 = " + 4; // "2 + 2 = 4"
let b = 2 + 2 + " = 2 + 2"; // "4 = 2 + 2"
let c = "2 + 2 = " + 2 + 2; // "2 + 2 = 22"
因为在字符串添加过程中记住所有关于类型转换的规则可能很困难,所以建议您使用模板文字,这更容易预测,通常也更容易阅读。
#### 转换字符串大小写
在 C 语言中,将字符串转换成大写或小写是很有挑战性的,尤其是当您使用完整的 Unicode 字符集时。JavaScript 有内置的函数来完成这些转换。
let a = "Garçon";
let b = a.toUpperCase(); // "GARÇON"
let c = a.toLowerCase(); // "garçon"
请注意,`toUpperCase`和`toLowerCase`函数不会修改存储在前面示例中的变量`a`中的原始字符串,而是返回一个带有修改值的新字符串。所有操作字符串的 JavaScript 函数都是这样的,因为所有的字符串都是不可变的。
#### 提取部分字符串
要将一个字符串的一部分提取到另一个字符串中,使用`slice`函数。它的参数是开始和结束索引,其中结束索引是索引*,在此之前*结束提取。如果省略结束索引,则使用字符串的长度。
let a = "hello, world!";
let b = a.slice(0, 5); // "hello"
let c = a.slice(7, 12); // "world"
let d = a.slice(7); // "world!"
JavaScript 还有一个`substr`函数,它提供了与`slice`相似的功能,但参数略有不同。然而,`slice`比`substr`更受青睐,后者主要是为 web 上的遗留代码维护的。
#### 重复字符串
要创建一个特定值重复几次的字符串,使用`repeat`函数:
let a = "-";
let b = a.repeat(3); // "---"
let c = ".-";
let d = c.repeat(2); // ".-.-"
#### 修剪琴弦
解析字符串时,您通常希望删除开头或结尾的空白(空格字符、制表符、回车符、换行符等)。修剪功能只需一个步骤即可删除空白:
let a = " JS ";
let b = a.trim(); // "JS"
let c = a.trimStart(); // "JS "
let d = a.trimEnd(); // " JS"
trim 函数可以完全在 JavaScript 中实现(大多数字符串函数也可以),但是将它们构建到语言中意味着它们的实现要快得多,并且它们的行为在所有应用程序中都是一致的。
#### 搜索字符串
C 中的`strstr`函数在一个字符串中找到另一个字符串。JavaScript 中的`indexOf`函数类似于`strstr`。如清单 2-11 所示,`indexOf`的第一个参数是要搜索的子串,可选的第二个参数是开始搜索的字符索引,函数的结果是找到子串的索引,如果没有找到则为-1。
let string = "the cat and the dog";
let a = string.indexOf("cat"); // 4
let b = string.indexOf("frog"); // –1
let c = string.indexOf("the"); // 0
let d = string.indexOf("the", 2); // 12
Listing 2-11.
有时您希望找到子字符串的最后一个匹配项。在 C 中,这需要多次调用`strstr`,直到找不到进一步的匹配。JavaScript 为这种情况提供了`lastIndexOf`函数。
let string = "the cat and the dog";
let a = string.lastIndexOf("the"); // 12
let b = string.lastIndexOf("the", a - 1); // 0
在计算字符串时,检查字符串是以特定的字符串开始还是结束是很有用的。在 c 语言中,你使用`strcmp`和`strncmp`来做这件事。这种情况很常见,以至于 JavaScript 提供了专用的`startsWith`和`endsWith`函数。
if (string.startsWith("And "))
trace(Don't start sentence with "and");
if (string.endsWith("..."))
trace(Don't end sentence with ellipsis);
### 功能
JavaScript 当然也有函数,c 也一样,两种语言中有些函数非常相似。
function add(a, b) {
return a + b;
}
#### 函数参数
因为 JavaScript 变量可以保存任何类型的值,所以没有给出参数的类型,只给出了它们的名称。与 C 和 C++不同的是,这里没有函数声明;您只需将源代码写入该函数,然后任何可以访问该函数的代码都可以调用它。这种特别的方法允许更快的编码。
在 C 和 C++中,可以使用指针通过引用传递参数值,但在 JavaScript 中,必须始终通过值传递参数。因此,JavaScript 函数永远不会改变传递给它的变量值。例如,清单 2-12 中的`add`函数不会改变`x`的值。
function add(a, b) {
a += b;
return a;
}
let x = 1;
let y = 2;
let z = add(x, y);
Listing 2-12.
当您将对象传递给函数时,函数可以修改对象的属性,但不能修改保存该对象的调用的局部变量。这类似于在 c 中传递一个指向数据结构的指针。在清单 2-13 中,`setName`函数将`name`属性添加到传递给它的对象中。它对一个新的空对象的参数`a`的赋值不会改变`b`的值。
function setName(a, name) {
a.name = name;
a = {};
}
let b = {};
setName(b, "thermostat");
// b.name is "thermostat"
Listing 2-13.
在 C 和 C++中,函数的实现可以决定传递给它的参数的数量,并且可以使用`va_start`、`va_end`和`va_arg`来访问每个参数。这些是强大的工具,但使用起来可能会很复杂。JavaScript 还提供了处理函数参数的工具。调用者没有传递的任何参数都被设置为`undefined`,因此(如清单 2-14 中对`b`所做的那样),您可以检查一个参数是否没有被传递。
function add(a, b) {
if (b == undefined)
return NaN;
return a + b;
}
add(1);
Listing 2-14.
访问传递给函数的参数的另一种方法是使用特殊的`arguments`变量,它的行为类似于包含参数的数组。这种方法类似于使用`va_arg`,额外的好处是知道参数的数量。在清单 2-15 中,`add`函数接受任意数量的参数并返回它们的总和。
function add() {
let result = 0;
for (let i = 0; i < arguments.length; i++)
result += arguments[i];
return result;
}
let c = add(1, 2);
let d = add(1, 2, 3);
Listing 2-15.
在 JavaScript 中使用`arguments`很常见,但并不是在所有情况下都可用。这里介绍它是因为您可能会在代码中看到它。现代 JavaScript 有一个额外的特性,叫做 *rest 参数*,它提供类似的功能,总是可用的,并且更加灵活(参见清单 2-16 )。
function add(...values) {
let result = 0;
for (let i = 0; i < values.length; i++)
result += values[i];
return result;
}
Listing 2-16.
这里的`...values`表示所有剩余的参数(在这个例子中是所有的参数)将被放入一个名为`values`的数组中。清单 2-17 中的代码添加了一个`round`参数来控制值在求和前是否应该四舍五入。
function addR(round, ...values) {
let result = 0;
for (let i = 0; i < values.length; i++)
result += round ? Math.round(values[i]) : values[i];
return result;
}
let c = addR(false, 1.1, 2.9, 3.5); // c = 7.5
let d = addR(true, 1.1, 2.9, 3.5); // d = 8
Listing 2-17.
正如 rest 参数将几个参数组合成一个数组一样, *spread 语法*将数组的内容分隔成单独的参数。Spread 语法使用与 rest 参数相同的三点语法。清单 2-18 中的函数对其参数的绝对值求和;它首先获取参数的绝对值,然后使用 spread 语法调用`add`函数来计算总和。
function addAbs(...values) {
for (let i = 0; i < values.length; i++)
values[i] = Math.abs(values[i]);
return add(...values);
}
let c = addAbs(-1, -2, 3); // c = 6
Listing 2-18.
spread 语法还有许多其他用途,例如,克隆一个数组:
let a = [1, 2, 3, 4];
let b = [...a]; // b = [1, 2, 3, 4]
还可以使用 spread 语法连接两个数组:
let a = [1, 2];
let b = [3, 4];
let c = [...a, ...b]; // c = [1, 2, 3, 4]
在某些情况下,为参数提供默认值很有用。这在 C 中是不可能的,但在 C++中可以做到,使用的语法与 JavaScript 中的相同。在 JavaScript 中,由于调用者没有传递的参数被设置为`undefined`,所以您可以为任何具有该值的参数提供默认值。清单 2-19 中的函数接受一个温度值;如果没有指定单位,则使用默认的摄氏温度。
function setCelsiusTemperature(temperature) {
trace(setCelsiusTemperature ${temperature}\n);
}
function setTemperature(temperature, units = "Celsius") {
switch (units) {
case "Fahrenheit":
temperature -= 32;
temperature /= 1.8;
break;
case "Kelvin":
temperature -= 273.15;
break;
case "Celsius":
// no conversion needed
break;
}
setCelsiusTemperature(temperature);
}
setTemperature(14); // units argument defaults to Celsius
setTemperature(14, "Celsius");
setTemperature(57, "Fahrenheit");
Listing 2-19.
与 C 语言不同,JavaScript 中的每个函数都有返回值;如果没有明确定义的返回值,函数就无法退出。考虑清单 2-20 中显示的三个函数。
function a() {
return undefined;
}
function b() {
return;
}
function c() {
}
Listing 2-20.
函数`a`显式返回值`undefined`。函数`b`没有向`return`语句提供任何值,因此返回`undefined`。函数`c`没有`return`语句,但像函数`b`一样返回`undefined`,因为`undefined`是所有函数返回的默认值。根据代码作者的偏好,您可以在 JavaScript 代码中找到所有这三种形式。函数的调用者无法区分它们。
相比之下,下面的代码在 c 中是允许的。函数`c`的结果是为返回值保留的内存或寄存器中的任何值。
int c(void) {
}
int b = c(); // b is unknown
#### 将函数作为参数传递
在 C 语言中,向一个函数传递指向另一个函数的指针是很常见的,这使您能够自定义被传递函数的行为,例如,提供一个在排序时使用的比较函数。类似地,JavaScript 函数可以作为参数传递给另一个函数,如清单 2-21 所示。
function square(a) {
return a * a;
}
function circleArea(r) {
return Math.PI * r * r;
}
function sum(filter, ...values) {
let result = 0;
for (let i = 0; i < values.length; i++)
result += filter(values[i]);
return result;
}
let a = sum(square, 1, 2, 3); // 14
let b = sum(circleArea, 1); // 3.14...
Listing 2-21.
还可以将内置函数作为参数传递。例如,下面的代码计算其余参数的平方根之和:
let c = sum(Math.sqrt, 1, 4, 9); // 6
通常,当传递一个函数时,该函数只在那个地方使用。在 C 语言中,函数的实现经常不在它被调用的地方,这损害了可读性。与 C 不同,JavaScript 允许匿名(未命名)内联函数。以下示例调用清单 2-21 中定义的`sum`函数,使用匿名内嵌函数作为过滤器来计算等边三角形的面积之和:
let a = sum(function(a) {
return a * (a / 2);
}, 1, 2, 3); // 7
在 JavaScript 代码中,匿名函数被广泛用于各种回调。将函数的源代码视为函数调用的参数有点不寻常,但是您会习惯的。如果您喜欢将函数实现与函数调用分开,可以使用嵌套函数。在清单 2-22 中,函数`triangleArea`只在函数`main`内部可见。使用嵌套函数可以使过滤函数的实现靠近使用它的地方,这通常可以提高代码的可维护性。
function main() {
function triangleArea(a) {
return a * (a / 2);
}
let a = sum(triangleArea, 1, 2, 3); // 7
}
Listing 2-22.
#### 声明函数
如前所述,与 C 和 C++不同,JavaScript 中没有函数声明:当您在 JavaScript 中声明一个函数时,实际上是在声明一个变量。下面一行代码使用声明函数的通用语法,创建了一个名为`example`的局部变量:
function example() {}
下面的代码行还创建了一个名为`example`的局部变量,并为它分配了一个匿名函数:
let example = function() {};
这两行代码是等价的,两个函数都可以用相同的方式调用。但是因为两种形式都创建一个局部变量,所以不能有同名的函数和局部变量。但是,您可以更改局部变量引用的函数,如清单 2-23 所示。
function log(a) {
trace(a);
}
log("one");
// Disable logging
let originalLog = log;
log = function(a) {}
log("two");
// Reenable logging
log = originalLog;
log("three");
Listing 2-23.
#### 关闭
JavaScript 函数最强大的特性之一是闭包。它们通常用于回调函数。一个*闭包*将一个函数和函数外的一组变量绑定在一起。对外部变量的引用在闭包的整个生命周期中都存在。闭包在 C 中并不存在,只是在 2011 年才作为 lambda 表达式被添加到 C++中;因此,许多使用 C 和 C++的开发人员对它们并不熟悉。尽管名字晦涩难懂,但是闭包非常容易使用,以至于很容易忘记自己正在使用它们。
清单 2-24 使用闭包来实现计数器。`makeCounter`函数返回一个函数。在 C 中,你可以让一个函数返回一个指向另一个函数的指针,但是这里有一个不同:返回的匿名函数引用了一个名为`value`的变量,而这个变量不是匿名函数的本地变量;相反,它是包含匿名函数的`makeCounter`函数中的一个局部变量。
function makeCounter() {
let value = 0;
return function() {
value += 1;
return value;
}
}
Listing 2-24.
每次调用由`makeCounter`返回的函数时,它增加`value`并返回该值。它是这样工作的:当一个函数引用它自己的局部范围之外的变量时,它被称为“关闭”这些变量,自动创建一个闭包。在这个例子中,在匿名函数中使用变量`value`创建一个闭包,让它从`makeCounter`访问局部变量`value`。JavaScript 使得使用局部变量变得安全,即使在`makeCounter`返回并且`makeCounter`的栈帧已经被释放之后(参见清单 2-25 )。
let counter = makeCounter();
let a = counter(); // 1
let b = counter(); // 2
let c = counter(); // 3
Listing 2-25.
清单 2-25 中的例子做了你期望的事情:`makeCounter`函数返回一个计数器函数;每次调用 counter 函数时,它都会递增计数器并返回新值。但是如果你调用两次`makeCounter`会怎么样呢?第二个调用是返回一个单独的计数器还是对第一个计数器的引用?答案见清单 2-26 。
let counterOne = makeCounter();
let counterTwo = makeCounter();
let a = counterOne(); // 1
let b = counterOne(); // 2
let c = counterTwo(); // 1
let d = counterTwo(); // 2
let e = counterOne(); // 3
let f = counterTwo(); // 3
Listing 2-26.
如您所见,每次调用`makeCounter`时,它返回的函数都有一个新的闭包,其中有一个单独的`value`副本。
如果现在很难想象如何在自己的代码中使用闭包,不要担心;许多程序员甚至没有意识到自己在使用它们。闭包在使用回调函数的 API 中很常见;安装回调函数时,它通常会关闭调用回调函数时使用的变量。
如果您有面向对象编程的经验,您可能会认为以这种方式使用的闭包类似于对象实例,事实上它们可以用于这种目的。然而,JavaScript 有更好的选择,使用类(在本章后面介绍)。
### 目标
JavaScript 是一种面向对象的编程语言;c 不是。很少有不使用对象而使用 JavaScript 的实用方法。在本章的前几节中,即使是对数字和字符串的普通操作也需要调用 number 和 string 对象的方法。C++是一种面向对象的语言,但是 C++和 JavaScript 对对象采取非常不同的方法。例如,C++有类模板、运算符重载和多重继承——这些都不是 JavaScript 的一部分。如果你来自 C 语言,你需要学习一些关于对象的知识。如果你来自 C++,你需要学习 JavaScript 更紧凑的对象方法。好消息是,数百万开发者已经成功地使用 JavaScript 中的对象来构建网页、web 服务、移动应用和嵌入式固件。
要在 JavaScript 中创建对象,可以像在 C++中一样使用`new`关键字。JavaScript 中的所有对象都源自内置对象`Object`。下面几行创建了一个`Object`的实例:
let a = new Object();
let b = new Object;
`Object`是一种特殊的函数,称为`constructor`。当用`new`调用`Object`构造函数时,会创建一个`Object`的实例,并执行构造函数来初始化该实例。如果没有参数传递给构造函数,参数列表的括号是可选的。因此,前面两行是相同的;使用哪种形式是个人编码风格的问题。
JavaScript 中内置了许多其他对象。清单 2-27 展示了如何为其中一些调用构造函数的例子。关于这些和其他内置对象的详细信息将在本章的后面部分提供。
let a = new Array(10); // array of length 10
let b = new Date("September 6, 2019");
let c = new Date; // current date and time
let d = new ArrayBuffer(128); // 128-byte buffer
let e = new Error("bad value");
Listing 2-27.
基本对象`Object`本身并没有做太多事情。尽管如此,它在 JavaScript 代码中还是很常见的,因为它可以用作特别记录。在 C 中,你用一个结构(`struct`)来保存一组值;在 C++中,你要么使用结构,要么使用类(`struct`或`class`)。与 C 或 C++中的结构不同,JavaScript 对象不是一组固定的字段。C 调用的字段在 JavaScript 中被称为*属性*。如清单 2-28 所示,你可以随时给一个对象添加属性;它们不需要事先声明。
let a = new Object;
a.one = 1;
a.two = "two";
a.object = new Object;
a.add = function(a, b) {
return a + b;
};
Listing 2-28.
因为创建这些特设对象是如此常见,JavaScript 提供了一个快捷方式:您可以使用`{}`代替`new Object`。结果是一样的,但是代码更紧凑。您可以通过枚举大括号内的属性来初始化对象的属性。以下内容等同于前面的示例:
let a = {one: 1, two: "two", object: {},
add: function(a, b) {return a + b;}};
JavaScript 开发人员倾向于使用大括号风格(这本书几乎专门使用它),因为它更紧凑,可读性更好。
#### 对象速记
通常将几次计算的结果存储在局部变量中,然后将它们放入一个对象中。当局部变量与对象的属性同名时,代码看起来是多余的,如清单 2-29 中的例子所示。
let one = 1;
let two = "two";
let object = {};
let add = function(a, b) {return a + b;};
let result = {one: one, two: two, object: object, add: add};
Listing 2-29.
因为这种情况经常发生,所以 JavaScript 为它提供了一个捷径。清单 2-30 中的代码等同于前面的例子。
let one = 1;
let two = "two";
let object = {};
let add = function(a, b) {return a + b;};
let result = {one, two, object, add};
Listing 2-30.
另一种快捷方式可用于定义以函数为值的属性。清单 2-31 展示了简单明了的方法。
let object = {
add: function(a, b) {
return a + b;
},
subtract: function(a, b) {
return a - b;
}
};
Listing 2-31.
清单 2-32 显示了快捷版本,它删除了冒号(`:`)和`function`关键字。
let object = {
add(a, b) {
return a + b;
},
subtract(a, b) {
return a - b;
}
};
Listing 2-32.
除了更加简洁和易读之外,您很快就会看到,在 JavaScript 中,同样的语法也用于定义类。
#### 删除属性
您不仅可以随时向 JavaScript 对象添加属性,还可以删除它们。使用`delete`关键字删除属性:
delete a.one;
delete a.missing;
一旦删除了一个属性,从对象中获取它就会得到值`undefined`。您可能还记得,当您试图访问一个超出字符串长度的字符时,返回的是同一个值。在对象没有的属性上使用`delete`不是错误。例如,如前所示删除(具有属性`one`、`two`和`object` ) `a.missing`的给定对象`a`时,不会产生错误。
C++程序员熟悉用`delete`来销毁对象,因此可能认为删除一个属性会销毁该属性所引用的对象;然而,JavaScript 中的关键字`delete`是不同的,这将在“内存管理”一节中讨论。
#### 检查属性
因为属性可以随时出现和消失,所以有时您需要检查某个对象上是否存在某个特定的属性。有两种方法可以做到这一点。因为任何缺失的属性都有值`undefined`,所以您可以检查获取属性是否会给出值`undefined`。
if (a.missing == undefined)
trace("a does not have property 'missing'");
但是不要那么做!可能会出现几个微妙的问题。例如,考虑以下代码:
let a = {missing: undefined};
if (a.missing == undefined)
trace("a does not have property 'missing'");
这里,对象有一个值为`undefined`的`missing`属性。还有其他方法可以使这个检查失败,但是现在这个例子足以证明需要一个更好的解决方案。使用关键字`in`是检查一处房产是否存在的更好方法。以下示例适用于所有情况:
if (!("missing" in a))
trace("a does not have property 'missing'");
#### 向函数添加属性
JavaScript 中的函数是对象,这意味着您可以像处理任何其他对象一样添加和删除函数的属性。清单 2-33 定义了一个名为`calculate`的函数,它支持三种运算,每种运算对应于一个被赋予常数的函数的属性:`add`是 1,`subtract`是 2,`multiply`是 3。这里定义的操作类似于 C 或 C++中的枚举。然而,在 C 或 C++中,操作值不是作为`enum`与`calculate`函数分开定义的,而是直接附加到使用它们的函数中。这种为常量提供名称的方式在可修改的 SDK 的某些部分中使用。
function calculate(operation, a, b) {
if (calculate.add == operation)
return a + b;
if (calculate.subtract == operation)
return a - b;
if (calculate.multiply == operation)
return a * b;
}
calculate.add = 1;
calculate.subtract = 2;
calculate.multiply = 3;
let a = calculate(calculate.add, 1, 2); // 3
let b = calculate(calculate.subtract, 1, 2); // -1
Listing 2-33.
#### 冷冻物体
有些情况下,您希望确保对象的属性不能被更改。您可能会尝试使用`const`来实现这一点:
const a = {
b: 1
};
然而,那是行不通的。使用`const`不会使常量声明中`=`右侧的对象成为只读的;在本例中,它只将`a`设为只读。考虑这些后续任务:
a = 3; // generates an error
a.b = 2; // OK - can change existing property
a.c = 3; // OK - can add new property
为了防止修改作为常量值的对象,您可以使用`Object.freeze`,这是一个内置函数,它使对象的所有现有属性变为只读,并防止添加新属性。正如您在清单 2-34 中看到的,试图改变冻结对象中的属性值或向对象添加新属性会产生错误。
const a = Object.freeze({
b: 1
});
a = 3; // generates an error
a.b = 2; // error - can't change existing property
a.c = 3; // error - can't add new property
Listing 2-34.
注意,`Object.freeze`返回传递给它的对象,这在本例中很方便,因为它避免了添加一行代码。`Object.freeze`现在很少在 web 的 JavaScript 中使用,但可修改的 SDK 广泛使用它,因为它使对象能够有效地存储在嵌入式设备的 ROM 或闪存中,从而节省有限的 RAM。
`Object.freeze`是一个浅层操作,这意味着它不会冻结嵌套的对象。例如,在清单 2-35 中,分配给属性`c`的嵌套对象没有被冻结。
const a = Object.freeze({
b: 1,
c: {
d: 2
}
});
a.c.d = 3; // OK
a.c.e = 4; // OK
a.b = 2; // error - can't change existing property
a.e = 3; // error - can't add new property
Listing 2-35.
您可以显式冻结`c`,但是这样会变得冗长且容易出错,如清单 2-36 所示。
const a = Object.freeze({
b: 1,
c: Object.freeze({
d: 2
})
});
Listing 2-36.
因为冻结对象有助于优化嵌入式设备上的内存使用,所以在可修改的 SDK 中使用的 XS JavaScript 引擎使用可选的第二个参数扩展了`Object.freeze`,从而实现了深度冻结——也就是说,递归地冻结所有嵌套的对象(参见清单 2-37 )。
const a = Object.freeze({
b: 1,
c: {
d: 2
}
}, true);
a.c.d = 3; // error - can't change existing property
a.c.e = 4; // error - can't add new property
Listing 2-37.
注意,`Object.freeze`的这个扩展不是 JavaScript 语言标准的一部分,所以它在大多数环境中不工作。然而,它确实解决了嵌入式开发中的一个常见需求。也许 JavaScript 语言的未来版本将支持这一功能。
如果你的代码需要知道一个对象是否被冻结,你可以使用`Object.isFrozen`。和`Object.freeze`一样,这是一个浅层操作,所以它不会告诉你是否有任何嵌套的对象被冻结。
if (!Object.isFrozen(a)) {
a.b = 2;
a.c = 3;
}
冻结对象是单向操作:没有`Object.unfreeze`。这是因为`Object.freeze`有时被用作一种安全措施来防止不受信任的客户端代码篡改对象。如果不受信任的代码可以解冻对象,它将使安全措施被破坏。
### `null`
像 C 和 C++一样,JavaScript 代码使用值`null`。在 C 和 C++中,这被写成`NULL`,表示它是用宏定义的;在 JavaScript 中,`null`是一个内置值。
c 使用`NULL`作为当前不引用任何东西的指针的值。JavaScript 没有指针,所以这个意思没有意义。在 JavaScript 中,`null`是一个值,表示没有对对象的引用。值`null`被认为是一个特殊的空对象,因此具有类型`Object`。
很容易把`null`和`undefined`搞混。它们很相似,但不完全相同:`undefined`表示没有给出任何值;`null`明确声明没有对象引用,这意味着变量或属性将在执行过程中的某个时刻保存一个对象。通常,当一个局部变量或对象属性打算引用一个对象时,在没有对象时赋值`null`。
## 比较
在 C #中比较两个值在很大程度上是很简单的,因为你通常是在比较两个相同类型的值。在少数情况下,C 语言会在比较之前应用类型转换。例如,这使您能够将一个`uint8_t`值与一个`uint32_t`值进行比较,而不必显式转换任一值的类型。C++通过提供运算符重载使比较变得更加强大,使程序员能够为他们定义的类型提供自己的比较运算符实现。在这方面,JavaScript 更像 C 而不是 c++;它不支持操作符重载,所以比较的行为完全由 JavaScript 语言定义。
与 C 类似,JavaScript 在使用等号运算符(`==`)进行比较时会隐式转换某些类型。清单 2-38 显示了几个例子。
let a = 1 == "1"; // true
let b = 0 == ""; // true
let c = 0 == false; // true
let d = "0" == false; // true
let e = 1 == true; // true
let f = 2 == true; // false
let g = Infinity == "Infinity"; // true
Listing 2-38.
正如您所看到的,在比较中如何转换类型的规则并不总是您所期望的那样。出于这个原因,JavaScript 程序员通常通过使用*严格相等运算符* ( `===`)来避免隐式转换,如清单 2-39 所示。严格相等运算符从不执行类型转换;如果这两个值是不同类型的,它们总是不相等的。
let a = 1 === "1"; // false
let b = 0 === ""; // false
let c = 0 === false; // false
let d = "0" === false; // false
let e = 1 === true; // false
let f = 2 === true; // false
let g = Infinity === "Infinity"; // false
Listing 2-39.
JavaScript 还提供了一个*严格不等式运算符* ( `!==`),可以用来代替不等式运算符(`!=`),避免类型转换:
let a = 1 !== "1"; // true
let b = 0 !== ""; // true
let c = 0 !== false; // true
在许多情况下,使用`==`和`!=`而不是严格版本没有坏处。然而,行为不同的边缘情况会引入难以追踪的错误。因此,JavaScript 编程中当前的最佳实践是始终使用严格版本的操作符。
本章中介绍严格比较运算符之前的一些例子使用了`==`和`!=`。既然您已经了解了这些操作符的严格版本以及为什么它们是首选,本书剩余部分中的示例将只使用严格操作符。
### 比较对象
在 JavaScript 中比较两个对象时,只有当它们引用同一个实例时才相等。这通常是您所期望的,尽管有时开发人员错误地期望如果两个不同实例的所有属性都相等,则相等比较的结果是`true`。JavaScript 没有直接提供这种深度比较,但是如果需要的话,可以在应用程序中实现。
let a = {b: 1};
let b = a === {b: 1}; // false
let c = a;
let d = a === c; // true
在 C++中,比较对象的默认行为与 JavaScript 中的相同。使用运算符重载,如果类实现了支持,C++程序员可以执行深度比较。
## 错误和异常
JavaScript 包含一个内置的`Error`类型,用于报告执行过程中出现的问题。错误几乎只与 JavaScript 的异常机制一起使用,这在许多方面与 C++异常类似。C 语言不包含异常,尽管类似的功能经常使用 C 标准库中的`setjmp`和`longjmp`来构建。
要创建一个错误,调用`Error`构造函数。为了帮助调试,您可以提供一个可选的错误消息。
let a = new Error;
let b = new Error("invalid value");
还有其他种类的错误,用于指示特定的问题。这些包括`RangeError`、`TypeError`和`ReferenceError`。你使用它们的方式和`Error`一样。最常见的是简单地使用`Error`,但是如果其他的适合你的情况,你也可以使用。
一旦你有一个错误,你使用一个`throw`语句报告它(清单 2-40 )。
function setTemperature(value) {
if (value < 0)
throw new RangeError("too cold");
...
}
Listing 2-40.
您可以在`throw`语句后指定任何值,尽管按照惯例,该值通常是一个错误的实例。
当抛出异常时,当前执行路径结束。在栈上的第一个`catch`块处继续执行。如果栈上没有`catch`块,该异常被认为是未处理的异常。未处理的异常被忽略,这意味着宿主不会尝试处理异常。为了捕捉异常,你可以像在 C++中一样编写`try`和`catch`块;清单 2-41 来自前面的例子来说明这一点。
try {
setTemperature(-1); // throws an exception
// Execution never reaches here
displayMessage("Temperature set to -1\n");
}
catch (e) {
trace(setTemperature failed: ${e}\n);
}
Listing 2-41.
在本例中,当`setTemperature`生成异常时,执行跳转到`catch`块,跳过对`displayMessage`的调用。清单 2-40 中所示的`throw`语句的参数——由`setTemperature`函数创建的`RangeError`实例——在这里提供在名为`e`的局部变量中,该变量在`catch`关键字后面的括号中指定。如果您的`catch`块不使用那个值,您可以省略`catch`后面的括号,如清单 2-42 所示。
try {
setTemperature(-1); // throws an exception
// Execution never reaches here
displayMessage("Temperature set to -1\n");
}
catch {
trace("setTemperature failed\n");
}
Listing 2-42.
在捕获错误之后,您可以选择传播它,就像它没有被捕获一样。如果您希望在错误发生时执行清理,并且错误还需要由调用栈中更高层的代码来处理,这将非常有用。为了传播异常,使用`catch`块中的`throw`语句(列出了 2-43 )。
try {
setTemperature(-1); // throws an exception
// Execution never reaches here
displayMessage("Temperature set to -1");
}
catch (e) {
trace(setTemperature failed: ${e}\n);
throw e;
}
Listing 2-43.
您的异常处理可能还包括一个`finally`块,如清单 2-44 所示。(标准 C++不提供`finally`,但它是微软 C++方言的一部分。)无论异常是如何处理的,或者即使它没有被`catch`块捕获,总是会调用`finally`块。
try {
setTemperature(-1);
}
catch (e) {
trace(setTemperature failed: ${e}\n);
throw e;
}
finally {
displayMessage(Temperature set to ${getTemperature()}\n); // always executes
}
Listing 2-44.
在清单 2-44 中,不管`setTemperature`是否抛出异常,对`displayMessage`的调用都会发生。当使用`finally`时,可以省略`catch`块(清单 2-45 ,在这种情况下,在`finally`块执行后,异常将继续在栈中向上传播。
try {
setTemperature(-1);
}
finally {
displayMessage(Temperature set to ${getTemperature()});
}
Listing 2-45.
当一个异常没有被处理时——例如,当`setTemperature`在清单 2-44 和 2-45 中抛出异常时——一个警告被追踪到调试控制台。让一个异常被捕获并不一定是错误的,但是它可能是一个问题的标志。警告可以包括检测到未捕获异常的函数的名称;这是一个本地函数,通常是可修改的 SDK 运行时的一部分,所以这个名字可能不熟悉。
虽然这些例子在`try`块中只有几行代码,但真实世界的代码通常在单个`try`块中有大量代码。这使您能够将处理错误的代码保持在较小且独立的范围内,而不是像 c #中那样,让它成为每个函数调用的一部分。
`try`、`catch`和`finally`块的组合为您的代码如何响应或不响应异常提供了很大的灵活性。当你开始使用它们时,不要太担心。编写没有异常处理的代码,然后在处理失败案例时添加它,这是很常见的。
## 班级
像 C++一样,JavaScript 允许你通过定义类来创建你自己的对象。在 JavaScript 中,使用`class`关键字来定义和实现类。JavaScript 中的类比 C++中的要简单得多。即使您不期望创建自己的类,您也应该熟悉 JavaScript 类,这样您就能够理解他人编写的代码。
JavaScript 的早期版本没有`class`关键字,这使得创建类更加困难。2015 年第六版语言标准(通常称为“ES6”)中引入了该关键字。在此之前,JavaScript 开发人员使用底层方法创建类,包括`Object.create`,或者直接操纵对象的`prototype`属性。虽然这些技术仍然有效,并且在 web 中的遗留代码中很常见,但本节将重点介绍现代 JavaScript,其中`class`使代码更具可读性,并且对运行时性能没有影响。
### 类构造函数和方法
清单 2-46 显示了一个简单的类`Bulb`,代表一个可以开也可以关的灯泡。
class Bulb {
constructor(name) {
this.name = name;
this.on = false;
}
turnOn() {
this.on = true;
}
turnOff() {
this.on = false;
}
toString() {
return "${this.name}" is ${this.on ? "on" : "off"};
}
}
Listing 2-46.
不像在 C++中,没有类的声明;只有一个实现。用于定义类中函数的语法与您已经看到的类外函数的语法相同(在“对象速记”一节中)。然而,与函数在普通对象中被定义为属性不同,在类中函数之间没有逗号。
正如你在清单 2-46 中看到的,`Bulb`类是一个函数集合。类中名为`constructor`的函数是特殊的;创建对象时会自动调用它。构造函数在将新实例返回给创建者之前执行任何必要的初始化。下面的代码创建了一个`Bulb`的实例:
let wallLight = new Bulb("wall light");
wallLight.turnOn();
JavaScript 类中的另一个特殊函数是`toString`。当 JavaScript 需要对象的字符串表示时,会自动调用这个函数。`Bulb`的`toString`方法提供了当前状态的摘要,这对调试很有用。
let wallLight = new Bulb("wall light");
wallLight.turnOn();
trace(wallLight);
// output: "wall light" is on
因为`trace`函数输出字符串,所以它将其参数转换为字符串,这将调用`toString`方法。你也可以直接给`toString`打电话,比如`wallLight.toString()`。
`toString`方法是 JavaScript 中的一个特例;没有其他转换函数,比如`toNumber`。
Note
类构造函数的调用必须发生在定义类之后。这意味着你只能在定义了清单 2-46 中的类之后调用`new Bulb`,而不是之前。在此之前调用它会抛出一个运行时异常,并显示消息`get Bulb: not initialized yet!`。
### 静态方法
与 C++一样,JavaScript 类可能包含静态方法,这意味着通过类而不是实例来访问函数。静态方法的一个简单例子是返回实现的版本(清单 2-47 )。
class Bulb {
... // as earlier
static getVersion() {
return 1.2;
}
}
Listing 2-47.
静态方法附加到该类,因此甚至可以在创建实例之前调用。
if (Bulb.getVersion() < 1.5)
throw new Error("incompatible version");
### 子类
类的大部分功能来自于创建子类的能力。在 JavaScript 中,使用`extends`关键字创建一个子类。清单 2-48 中的代码将`DimmableBulb`实现为清单 2-46 中定义的`Bulb`类的子类。
class DimmableBulb extends Bulb {
constructor(name) {
super(name);
this.dimming = 100;
}
setDimming(value) {
if ((value < 0) || (value > 100))
throw new RangeError("bad dimming value");
this.dimming = value;
}
}
Listing 2-48.
正如你对子类的期望,`DimmableBulb`类从`Bulb`继承了`turnOff`和`turnOn`方法。`constructor`函数需要一些解释。它立即用传递给它的相同参数调用`super`。在 JavaScript 类中,`super`是对超类的构造函数的引用——这里是`Bulb`的构造函数。因此,`DimmableBulb`构造函数执行的第一个任务是构造它的超类`Bulb`。
虽然子类的构造函数可以在调用其超类的构造函数之前执行计算,但它最终必须调用它。在此之前,`this`是未定义的,因此任何获取或设置实例属性的尝试都将失败。例如,修改清单 2-49 所示的`DimmableBulb`构造函数,当它试图设置`dimming`属性时会产生一个异常,因为`this`还不可用。
class DimmableBulb extends Bulb {
constructor(name) {
this.dimming = 100; // throws an exception
super(name);
}
...
}
Listing 2-49.
`DimmableBulb`实现也从`Bulb`继承了`toString`方法。`toString`的实现为`Bulb`不打印调光等级;`DimmableBulb`(清单 2-50 )的`toString`的实现通过首先调用`Bulb`(由`super`指定)中的`toString`方法,然后将调光级别附加到结果中,从而添加调光级别。
class DimmableBulb extends Bulb {
...
toString() {
return super.toString() +
with dimming ${this.dimming};
}
}
Listing 2-50.
内置的`Object`类是所有 JavaScript 类的最终超类。`Bulb`类直接继承自`Object`。这是由它的实现中缺少一个`extends`子句所暗示的,但是它也可以被明确地声明,如清单 2-51 所示。
class Bulb extends Object {
constructor(name) {
super();
this.name = name;
this.on = false;
}
...
}
Listing 2-51.
注意,因为`Bulb`现在显式扩展了`Object`,`Bulb`构造函数必须通过调用`super`来调用它所扩展的类的构造函数。如果省略对`super`的调用,访问`this`会抛出一个异常,并显示消息`Bulb: this is not initialized yet!`。
为了保持源代码简洁,直接从`Object`继承的类通常不这样写。但是这个例子暗示了 JavaScript 类的另一个特性:继承内置对象的能力。清单 2-52 中的例子子类化了内置的`Array`类(你将很快了解到更多)来添加寻找数组中值的总数和平均值的方法。
class MyArray extends Array {
sum() {
let total = 0;
for (let i = 0; i < this.length; i++)
total += this[i];
return total;
}
average() {
return this.sum() / this.length;
}
}
let a = new MyArray;
a[0] = 1;
a[1] = 2;
let b = a.sum(); // 3
let c = a.average(); // 1.5
Listing 2-52.
在构建产品时,您可能有不止一个`Bulb`实例。例如,您可能正在制作一个控制几个灯泡的灯开关,并且您可能将灯的列表保存在一个数组中。为此,您可以创建一个`Array`的子类,该子类(清单 2-53 中的例子中的`Bulbs`)提供对灯泡的批处理操作。
class Bulbs extends Array {
allOn() {
for (let i = 0; i < this.length; i++)
this[i].turnOn();
}
allOff() {
for (let i = 0; i < this.length; i++)
this[i].turnOff();
}
}
let bulbs = new Bulbs;
bulbs[0] = new Bulb("hall light");
bulbs[1] = new DimmableBulb("wall light");
bulbs[2] = new DimmableBulb("floor light");
bulbs.allOn();
Listing 2-53.
在`Bulbs`中有一个`dimAll`方法当然很好,但是这只适用于`DimmableBulb`的实例;在`Bulb`的实例上调用`setDimming`会抛出异常,因为该方法不存在。JavaScript `instanceof`操作符在这里很有帮助,它使您能够确定一个实例是否对应于一个特定的类(清单 2-54 )。
let a = new Bulb("hall light");
let b = new DimmableBulb("wall light");
let c = a instanceof Bulb; // true
let d = b instanceof Bulb; // true
let e = a instanceof DimmableBulb; // false
let f = b instanceof DimmableBulb; // true
Listing 2-54.
如您所见,`instanceof`检查指定的类,包括它的超类。在清单 2-54 的例子中,这意味着`b`是`DimmableBulb`和`Bulb`的实例,因为`Bulb`是`DimmableBulb`的超类。有了这些知识,现在就可以实现`dimAll`(清单 2-55 )。
class Bulbs extends Array {
...
dimAll(value) {
for (let i = 0; i < this.length; i++) {
if (this[i] instanceof DimmableBulb)
this[i].setDimming(value);
}
}
}
Listing 2-55.
`Bulb`实例的属性是普通的 JavaScript 属性,使它们对类实现和使用该类的代码都可用:
let wallLight = new Bulb("wall light");
wallLight.turnOn();
trace(Light on: ${wallLight.on}\n);
这很有用,但有时您希望在实现中使用与 API 中不同的值表示。例如,`setDimming`方法接受从 0 到 100 的值,因为百分比是描述调光水平的自然方式;然而,实现可能更喜欢存储从 0 到 1.0 的值,因为这样对其内部计算更有效。JavaScript 类支持对这类转换有用的 getters 和 setters。清单 2-56 中的实现用`dimming`属性的 getter 和 setter 替换了`setDimming`方法。
class DimmableBulb extends Bulb {
constructor(name) {
super(name);
this._dimming = 1.0;
}
set dimming(value) {
if ((value < 0) || (value > 100))
throw new RangeError("bad dimming value");
this._dimming = value / 100;
}
get dimming() {
return this._dimming * 100;
}
}
let a = new DimmableBulb("hall light");
a.dimming = 50;
a.dimming = a.dimming / 2;
Listing 2-56.
该类的用户将`dimming`属性作为普通的 JavaScript 属性来访问。然而,当设置属性时,调用该类的`set dimming` setter 方法,当读取属性时,调用`get dimming` getter 方法。
### 私有字段
清单 2-56 中的 getter 和 setter 将值存储在名为`_dimming`的属性中。JavaScript 代码长期以来一直在属性名的开头使用下划线(`_`)来表示它们仅供内部使用。与 C++不同,JavaScript 没有在类中提供私有字段。将私有字段添加到 JavaScript 标准的工作即将完成;本节介绍私有字段,因为它们被认为是 JavaScript 标准的一部分。XS JavaScript 引擎支持私有字段,以便在嵌入式开发中使用。
JavaScript 中的私有字段通过在字段名前加一个散列字符(`#`)来表示。私有字段必须在类体中声明。清单 2-57 显示了清单 2-56 中`DimmableBulb`的版本,它被重写为使用一个名为`#dimming`的私有字段来代替`_dimming`。
class DimmableBulb extends Bulb {
#dimming = 1.0;
set dimming(value) {
if ((value < 0) || (value > 100))
throw new RangeError("bad dimming value");
this.#dimming = value / 100;
}
get dimming() {
return this.#dimming * 100;
}
}
let a = new DimmableBulb("hall light");
a.dimming = 50;
a.dimming = a.dimming / 2;
a.#dimming = 100; // error
Listing 2-57.
注意私有字段`#dimming`在类体的声明中被初始化为 1.0。这是可选的;相反,它可以在构造函数中初始化。在初始化之前,它的值为`undefined`。
还要注意清单 2-57 中的例子完全删除了构造函数。这在这里是可能的,因为`#dimming`已经被初始化了。由于`DimmableBulb`继承自`Bulb`,当`DimmableBulb`上没有构造函数时,创建实例时会自动调用`Bulb`的构造函数。正如你对 C++的期望,类外的代码不能访问私有字段;因此,试图给`#dimming`赋值的示例的最后一行产生了一个错误。
JavaScript 不支持 C++ `friends`或`protected`类特性。类的私有属性只能由类体内的代码直接访问。私有字段是真正私有的,即使对子类和超类也是不可见的。
### 私有方法
除了私有字段,JavaScript 语言标准还增加了*私有方法*——只能从类的实现中调用的函数。例如,清单 2-58 中的`DimmableBulb`类有一个私有的`#log`方法。
class DimmableBulb extends Bulb {
#dimming = 1.0;
set dimming(value) {
if ((value < 0) || (value > 100))
throw new RangeError("bad dimming value");
this.#dimming = value / 100;
this.#log(`set dimming ${this.#dimming}`);
}
get dimming() {
this.#log("get dimming");
return this.#dimming * 100;
}
#log(msg) {
trace(msg);
}
}
let a = new DimmableBulb("hall light");
a.#log("test"); // error
Listing 2-58.
### 在类中使用回调函数
有时,类实现会将函数作为回调传递给 API。一个常见的例子是当一个 API 使用一个定时器来延迟一个动作到未来。JavaScript web 开发人员通常使用`setTimeout`来实现这个目的;在嵌入式 JavaScript 中,对应的是`Timer.set`。清单 2-59 中的例子向`Bulb`类添加了一个方法,在指定的时间间隔过后打开或关闭灯。
class Bulb {
...
setOnAfter(value, delayInMS) {
let bulb = this;
Timer.set(function() {
if (value)
bulb.turnOn();
else
bulb.turnOff();
}, delayInMS);
}
}
Listing 2-59.
`setOnAfter`方法用两个参数调用`Timer.set`:一个在定时器到期后执行的匿名函数和以毫秒为单位的等待时间。回调函数使用闭包来访问`bulb`;这是必要的,因为回调中的`this`的值不是调用`setOnAfter`的灯泡的实例,而是全局对象(即`globalThis`)。这段代码可以工作,但是 JavaScript 有更好的工具来实现这个功能。
像现代 C++一样,现代 JavaScript 也有 *lambda 函数*——通常称为 *arrow 函数*,因为用于声明它们的`=>`语法。像闭包一样,箭头函数有点难理解,但是很容易使用。当一个箭头函数被调用时,它的`this`值与定义该箭头函数的函数的`this`值相同。箭头函数的这一特性被称为*词法* `this`,因为箭头函数内的`this`的值取自封闭函数。
箭头函数很受欢迎,因为它们保持了`this`的值,并且在源代码中更简洁。清单 2-60 中的例子展示了使用`function`关键字和箭头函数语法的相同函数。
function randomTo100() {
return Math.random() * 100;
}
let randomTo100 = () => Math.random() * 100;
function cube(a) {
return a * a * a;
}
let cube = a => a * a * a;
function add(a, b) {
return a + b;
}
let add = (a, b) => a + b;
function upperFirst(str) {
let first = str[0].toUpperCase();
return first + str.slice(1);
}
let upperFirst = str => {
let first = str[0].toUpperCase();
return first + str.slice(1);
};
Listing 2-60.
清单 2-60 中的所有示例对在功能上都是等价的,除了函数中`this`的值;但是,示例中没有使用`this`。清单 2-61 中的代码使用了一个箭头函数,通过利用词法`this`消除局部变量`bulb`来改进`setOnAfter`(在清单 2-59 中)的实现。使用这种方法,回调的代码能够像类方法一样使用`this`。
class Bulb {
...
setOnAfter(value, delayInMS) {
Timer.set(
() => value ? this.turnOn() : this.turnOff(),
delayInMS
);
}
}
Listing 2-61.
熟悉箭头函数很重要,因为它们在 JavaScript 中非常常见。你会在本书的一些例子中遇到它们。请记住,箭头函数不仅仅是编写函数源代码的一种替代方式;它们还会改变函数中`this`的值。
## 模块
模块是 JavaScript 中打包代码库的机制。C 和 C++中的 JavaScript 模块和共享或动态库有一些相似之处:两者都指定了共享有限数量的类、函数和值的导出;两者都可以从其他库中导入类、函数和值。像 C 中的动态库一样,JavaScript 模块是在运行时加载的。还有许多不同之处,包括没有与静态链接 C 库等价的 JavaScript。
### 从模块导入
要使用模块提供的功能,必须首先导入相应的类、函数或值。从一个模块导入有许多不同的方法,这种方法非常灵活,可以让您控制导入的内容以及如何命名这些导入。
上一节中的例子使用了`Timer`类,但没有显示它的来源。`Timer`类包含在`timer`模块中。要从一个模块导入,可以使用`import`语句。
import Timer from "timer";
Timer.set(() => trace("done"), 1000);
在 JavaScript 中,`import`语句的特殊之处在于它在所有其他代码之前执行。习惯上把`import`语句放在源代码的顶部,就像 C 语言中的`include`语句一样,但是即使它们不是第一个,它们仍然会首先执行。
前面形式的`import`语句由两部分组成:
* 存储导入的变量的名称。这里是`Timer`,但是你可以用任何你喜欢的名字。选择名称的能力有助于避免名称冲突,尤其是在处理许多模块时。
* 在`from`关键字之后,是模块说明符。这里是`"timer"`。
像`"timer"`一样不是路径的模块说明符被称为*裸模块说明符* *。*对于嵌入式 JavaScript,这些比较常见;事实上,这本书只使用了裸模块说明符。原因之一是嵌入式设备中通常没有文件系统来解析路径。相比之下,web 上的 JavaScript 目前只使用模块说明符的路径,所以你会看到带有`from`子句的`import`语句,比如`from "./modules/timer.js"`。
前面展示的`import`语句的形式导入了`timer`模块的默认导出。每个模块都有一个默认的导出。一些模块有额外的出口;例如,在第三章中使用的`http`模块导出了一个`Request`类和一个`Server`类。清单 2-62 展示了从`http`导入这些非默认导出的不同方式。
import {Server} from "http"; // server only
new Server;
import {Request} from "http"; // client only
new Request;
import {Server, Request} from "http";
new Server;
new Request;
Listing 2-62.
您可以使用`as`关键字来重命名您导入的非默认导出。清单 2-63 将`Server`更名为`HTTPServer`,`Request`更名为`HTTPClient`。
import {Server as HTTPServer, Request as HTTPClient} from "http";
new HTTPServer;
new HTTPClient;
new Request; // fails, Request is undefined
new Server; // fails, Server is undefined
Listing 2-63.
如果您喜欢可读性,您可以在多个`import`语句中使用相同的模块说明符:
import {Server as HTTPServer} from "http";
import {Request as HTTPClient} from "http";
您也可以从模块导入所有导出。执行此操作时,您将导入分配给一个对象。通过避免名称冲突,JavaScript 的这一特性起到了与 C++中的名称空间相似的作用。
import * as HTTP from "http";
new HTTP.Server;
new HTTP.Request;
一旦从模块中导入了一个类,就可以像在同一个源文件中声明的类或 JavaScript 内置类一样使用它。如您所见,您可以用`new`操作符实例化该类。您也可以创建导入类的子类:
import {Request} from "http";
class MyRequest extends Request {
...
}
### 从模块导出
当你开始编写自己的类时,你会想把它们打包到你自己的模块中;这些模块需要导出它们的类,以便它们可以被其他代码使用(并且函数和值同样可以被导出)。下面一行使用`export`语句提供`Bulb`类作为模块的默认导出:
export default Bulb;
您可以选择将`export`语句放在类的声明之前,这意味着您可以将`export default`与`Bulb`类的定义结合起来,如下所示:
export default class Bulb {
...
}
这种方法是有效的,但不太常见。当前的 JavaScript 最佳实践建议将所有的`import`语句放在源文件的开头,将所有的`export`语句放在末尾,这样代码更容易阅读和维护。
以下示例显示了提供`Bulb`和`DimmableBulb`的非默认导出的两种方式:
export {Bulb};
export {DimmableBulb};
export {Bulb, DimmableBulb};
像`import`语句一样,`export`语句可以使用`as`来执行重命名。当您希望导出与实现中使用的名称不同的名称时,这很有用。
export {Bulb as BULB, DimmableBulb as DIMMABLEBULB};
一个模块访问另一个模块内容的唯一方式是通过它的导出。不能直接访问未导出的类、函数和值。它们相当于 C 和 C++中使用`static`关键字定义的类、函数和值,但是有一个重要的区别:在 C 和 C++中,默认情况下,除非声明了`static`,否则所有内容都会被导出,而在 JavaScript 中,除了由`export`语句指示的内容,其他内容都不会被导出。JavaScript 方法——一个白名单,而不是 C 语言中的黑名单——通过避免意外的导出,有助于安全性和可维护性。
### ECMAScript 模块与 CommonJS 模块
本书中使用的模块是 JavaScript 语言规范的一部分。它们有时被称为 ECMAScript 模块,或 ESM。在模块被添加到官方规范之前,一个名为 CommonJS 的模块系统在一些环境中使用,特别是在 Node.js 中。然而,它们不能在本书使用的主机中工作,大多数环境(包括 Node.js)都在向标准 JavaScript 模块迁移。
## 全球
像 C 和 C++一样,JavaScript 也有全局变量。其中一些你已经用过了,比如`Object`、`Array`和`ArrayBuffer`。这些内置类被分配给与类同名的全局变量。您只需使用它们的名称就可以访问这些全局变量。如果该名称不在当前范围内,则使用全局变量。如果没有该名称的全局变量,将会生成一个错误。这类似于在 c #中访问不存在的全局变量时产生的链接错误。
function example() {
let a = Date; // OK, Date is built in
let b = DateTime; // error, DateTime is not defined
}
在 C 和 C++中,通过在源代码文件的顶级范围声明变量来创建全局变量。除非标记为静态,否则该变量对静态链接到该文件的所有代码都是可见的。在 JavaScript 中,你必须明确地创建一个全局变量。要在 JavaScript 中定义一个新的全局变量,需要将它添加到一个名为`globalThis`的对象中。下面的代码行创建了一个名为`AppName`的全局变量,并设置了它的初始值:
globalThis.AppName = "light bulb";
一旦定义了全局变量,您可以通过只声明它的名称来隐式地访问它,或者通过从`globalThis`读取属性来显式地访问它:
AppName = "Light Bulb";
globalThis.AppName += " App";
如果你想知道一个特定的全局变量是否已经被定义,使用本章“对象”一节中介绍的`in`关键字。
if ("AppName" in globalThis)
trace(AppName is ${AppName}\n);
else
trace("AppName not available");
与使用`delete`操作符从对象中删除属性的方式相同,您可以删除全局变量:
delete globalThis.AppName;
注意,`globalThis`对象的原名是`global`,更容易记忆和键入;出于兼容性原因,对其进行了更改。一些环境支持将`global`作为`globalThis`的别名。
当你使用模块时,它们似乎有全局变量。考虑清单 2-64 中的模块示例。
let counter = 0;
function count() {
return ++counter;
}
export default count;
Listing 2-64.
在顶级作用域声明`counter`变量的方式,看起来像是 C 或 C++中的全局变量声明,但事实并非如此。`counter`变量是模块私有的,因为它没有被显式导出。这些变量是模块的局部变量。在 C 或 C++中,通过在变量声明前加上`static`来限制其对当前源代码文件的可见性,可以获得相同的结果。
## 数组
在 C 和 C++中,任何指向类型(如`char *`)或结构(如`struct DataRecord *`)的指针都可以访问单个元素(如`*ptr`)或数组(如`ptr[0]`、`ptr[1]`)。指针的这些使用可能会导致错误,例如,写入超出为数组保留的内存末尾的索引。为了帮助避免使用数组的一些危险,C++提供了一个`std:array`类模板,它还提供了迭代器和其他常见的帮助函数。JavaScript 的内置`Array`对象更像 C++中的`std:array`,因为它被设计为安全的,并且它提供了许多帮助函数来执行常见的操作。
与 C 和 C++不同,JavaScript 不会在数组实例化时永久设置元素的数量;数组可能包含数量可变的元素。调用构造函数时,可以选择指示元素的数量。
let a = new Array; // empty array
let b = new Array(10); // 10-element array
正如您可能已经猜到的,所有的数组条目都被初始化为`undefined`。请注意,创建数组时,没有指明它将保存的数据类型。这是因为每个数组元素可能包含任何值;这些值不需要属于同一类型。
### 数组速记
因为创建数组非常常见,所以 JavaScript 提供了一种快捷方式:
let a = []; // empty array
使用此快捷语法,您可以提供数组的初始值:
let a = [0, 1, 2];
let b = [undefined, 1, "two", {three: 3}, [4]];
### 访问数组的元素
访问数组元素使用与 C 和 C++中相同的语法。元素从 0 开始编号。
let a = [0, 1, 2];
a[0] += 1;
trace(a[0]);
a[1] = a[2];
读取数组末尾以外的值会返回`undefined`。在数组末尾之外写入一个值会创建该值,从而扩展数组的长度。
let sparse = [0, 1, 2];
sparse[3] = 3;
sparse[1_000_000] = "big array";
您可能会认为,在内存有限的微控制器上,对数组的第 100 万个元素的赋值将会失败:ESP8266 只有大约 64 KB 的 RAM,因此它如何保存包含 100 万个元素的数组呢?然而赋值成功了,访问`sparse[1_000_000]`返回`"big array"`。这是怎么回事?
JavaScript 中的数组可能是*稀疏的,*意味着不是所有的元素都必须存在。任何不存在的元素的值都是`undefined`。在这里的数组`sparse`中,只有五个元素,它们恰好位于索引 0、1、2、3 和 1,000,000 处。
数组有一个`length`属性,它指示数组中元素的数量。例如,长度用于迭代数组中的元素。对于稀疏数组,`length`不是具有赋值的元素的数量,而是比具有赋值的最高索引多 1。在这里的数组`sparse`的例子中,`length`是 1,000,001,尽管只有五个元素被赋值。
设置`length`属性会改变数组。将其设置为较小的值会截断数组。以下代码将前面的`sparse`数组截断为四个元素:
sparse.length = 4; // [0, 1, 2, 3]
将数组的`length`属性设置为更大的值不会改变数组的内容。
### 遍历数组
如清单 2-65 所示,您可以使用`length`属性通过`for`循环迭代数组中的元素,就像在 C 和 C++中一样。
let a = [0, 1, 2, 3, 4, 5];
let total = 0;
for (let i = 0; i < a.length; i++)
total += a[i];
Listing 2-65.
不使用 C 风格的`for`循环,可以使用 JavaScript `for` - `of`循环。
for (let value of a)
total += value;
`for` - `of`循环方法更加紧凑,消除了管理值`i`和在数组`a[i]`中查找值的代码。C 风格的`for`循环和`for` - `in`循环都遍历从索引 0 到数组长度的所有值,即使对于有未赋值的值的稀疏数组也是如此。因为未赋值的值有一个值`undefined`,所以在清单 2-66 的代码末尾`total`有一个值`NaN`。
let a = [0, 1, 2, 3, 4, 5];
a[1_000_000] = 6;
let total = 0;
for (let i in a)
total += a[i];
Listing 2-66.
您可以修改此代码以忽略值为`undefined`的数组元素,如下所示:
for (let i in a)
total += (undefined === a[i]) ? 0 : a[i];
另一个解决方案是使用一个`for` - `of`循环来迭代数组中的值,这个循环只包含有赋值的数组元素,如清单 2-67 所示;这段代码末尾的`total`的值是 21,而不是清单 2-66 中的`NaN`。
let a = [0, 1, 2, 3, 4, 5];
a[1_000_000] = 6;
let total = 0;
for (let value of a)
total += value;
Listing 2-67.
`Array`对象也有以多种不同方式迭代数组的方法,每种方法都使用一个回调函数。`forEach`方法类似于`for` - `in`循环(见清单 2-68 )。像`for` - `of`循环一样,这个方法跳过没有赋值的数组元素。
let a = [0, 1, 2, 3, 4, 5];
let total = 0;
a.forEach(function(value) {
total += value;
});
Listing 2-68.
使用箭头函数将迭代代码减少到一行:
let a = [0, 1, 2, 3, 4, 5];
let total = 0;
a.forEach(value => total += value);
正如您可能猜到的,在 JavaScript 中,并不是所有迭代数组的方法都同样有效。例如,`forEach`方法是最紧凑的代码,但是需要对每个元素进行函数调用,这会增加开销。对于小型数组,使用最方便的方法;对于大型阵列,测量不同方法的性能以找到最快的方法是值得的。
当您需要对数组的每个元素执行操作时,`map`方法非常有用。它对每个元素调用回调,并返回包含结果的新数组。下面的示例创建一个包含原始数组中值的平方的数组。为每个元素调用的 arrow 函数使用求幂运算符(`**`)来计算平方。
let a = [-2, -1, 0, 1, 2];
let b = a.map(value => value ** 2); // [4, 1, 0, 1, 4]
### 添加和移除数组元素
因为 JavaScript 数组不是固定长度的,所以它们的用途不仅仅是简单的有序列表。`push`和`pop`函数使你能够使用一个数组作为栈(后进先出),如清单 2-69 所示。
let stack = [];
stack.push("a");
stack.push("b");
stack.push("c");
let c = stack.pop(); // "c"
stack.push("d");
let d = stack.pop(); // "d"
let b = stack.pop(); // "b"
Listing 2-69.
使用`unshift`和`pop`函数,您可以将数组用作队列(先进先出)。函数的作用是:将数值添加到一个数组的开头;见清单 2-70 。(还有`shift`,它从数组中移除第一项。)
let queue = [];
queue.unshift("first");
queue.unshift("second");
let a = queue.pop(); // "first"
queue.unshift("third");
let b = queue.pop(); // "second"
Listing 2-70.
使用`unshift`和`pop`来添加和删除队列的元素是有用的,但并不完全直观。如果这些函数的名字对队列来说更有意义,那么它们会更容易使用;你可以通过创建`Array`的子类来实现,如清单 2-71 所示。
class Queue extends Array {
add(element) {
this.unshift(element);
}
remove(element) {
return this.pop();
}
}
let queue = new Queue;
queue.add("first");
queue.add("second");
let a = queue.remove(); // "first"
queue.add("third");
let b = queue.remove(); // "second"
Listing 2-71.
要将一个数组的一部分提取到另一个数组中,使用`slice`函数。当使用`slice`提取字符串的一部分时,需要两个参数:起始和结束索引(其中结束索引是结束提取之前的索引)。如果省略结束索引,则使用字符串的长度。`slice`函数从不改变它所操作的数组的内容。
let a = [0, 1, 2, 3, 4, 5];
let b = a.slice(0, 2); // [0, 1]
let c = a.slice(2, 4); // [2, 3]
要删除数组的一部分,使用`splice`。名字`splice`与`slice`非常相似,两者的操作也很相似:它们采用相同的参数,并且两个函数都返回一个数组,该数组包含由参数标识的数组部分。然而,`splice`也会从原始数组中删除元素。
let a = [0, 1, 2, 3, 4, 5];
let b = a.splice(0, 2); // [0, 1]
let c = a.splice(0, 2); // [2, 3]
// a = [4, 5] here
### 搜索数组
在数组中搜索特定的值是很常见的,有几个函数可以帮助实现这一点。如清单 2-72 所示,您可以使用`indexOf`从数组的开头开始搜索,或者使用`lastIndexOf`从末尾开始搜索。第一个参数是要搜索的值;可选的第二个参数指示在数组中开始搜索的索引。如果没有找到值,两个函数都返回–1。
let a = [0, 1, 2, 3, 2, 1, 0];
let b = a.indexOf(1); // 1
let c = a.lastIndexOf(1); // 5
let d = a.indexOf(1, 3); // 5
let e = a.lastIndexOf(1, 3); // 1
let f = a.indexOf("one"); // –1
Listing 2-72.
`indexOf`和`lastIndexOf`函数使用严格的等式运算符来测试是否找到匹配。如果您想应用不同的测试,使用`findIndex`函数,它调用一个回调函数来测试匹配。以下示例执行不区分大小写的匹配:
let a = ["Zero", "One", "Two"];
let search = "one";
let b = a.findIndex(value =>
value.toLowerCase() === search); // 1
### 排序数组
排序是对数组的另一种常见操作。数组上的`sort`函数类似于 C 和 C++中的`qsort`函数,尽管它可能使用不同的排序算法来实现。像`qsort`一样,JavaScript 的`sort`就地运行,所以没有创建新的数组。内置的`sort`函数的默认行为是将数组值作为字符串进行比较。
let a = ["Zero", "One", "Two"];
a.sort();
// ["One", "Two", "Zero"]
要实现其他行为,您需要提供一个回调函数来执行比较。比较类似于 C 和 C++ `qsort`函数中回调函数的比较,接收两个值进行比较,根据比较结果返回负数、0 或正数。例如,以下代码对数字数组进行排序:
let a = [0, 1, 2, 3, 2, 1, 0];
a.sort((x, y) => x - y);
// [0, 0, 1, 1, 2, 2, 3]
清单 2-73 中的例子使用了一个更复杂的比较函数来对字符串进行不区分大小写的排序。
let a = ["Zero", "zero", "two", "Two"];
a.sort();
// ["Two", "Zero", "two", "zero"]
a.sort((x, y) => {
x = x.toLowerCase();
y = y.toLowerCase();
if (x > y)
return +1;
if (x < y)
return -1;
return 0;
});
// ["Two", "two", "Zero", "zero"]
Listing 2-73.
## 二进制数据
JavaScript 并不总是支持二进制数据,不像 C,C 从一开始就支持包含原生整数类型的内存缓冲区。在 C 语言中,你首先要学习的事情之一是如何用`malloc`分配内存,以及如何用数组和其他数据结构填充内存。直接操作内存缓冲区的能力对于许多类型的嵌入式开发来说是必不可少的,例如,在各种网络和硬件协议中处理二进制消息时。JavaScript 支持与您在用 C 和 C++编写代码时所习惯的相同类型的操作,尽管您执行这些操作的方式非常不同。
在 JavaScript 中使用二进制数据的另一个好处是可以减少项目的内存使用。JavaScript 的一个基本特性是任何值都可以包含任何类型,但是这个强大的特性是有代价的:每个值都需要额外的内存来存储值的类型。C 语言中的布尔值只有一个字节(或一个位,使用位字段),而 JavaScript 中的布尔值可以更多,例如 8 或 16 个字节。使用 JavaScript 中的二进制数据,只需做一点工作,就可以在一个字节(甚至一个位)中存储一个布尔值。如果您的项目在内存中维护大量数据,可以考虑使用 JavaScript 的二进制数据特性,因为这样可以节省大量内存。使用标准的`Array`对象创建一个包含 1000 个元素的 JavaScript 布尔值数组可能需要 16 KB 的 RAM,比 ESP8266 上可用的内存还多,但是使用`Uint8Array`对象创建它只需要 1 KB 的 RAM——与 c 语言中完全一样。
### `ArrayBuffer`
与`calloc`对应的 JavaScript 是`ArrayBuffer`类。一个`ArrayBuffer`是一个固定字节数的内存块。内存最初设置为 0,以避免未初始化内存带来的任何意外。
let a = new ArrayBuffer(10); // 10 bytes
如果因为没有足够的空闲内存而无法分配缓冲区,`ArrayBuffer`构造函数会抛出一个异常。
要检索一个`ArrayBuffer`中的字节数,获取`byteLength`属性。包含在一个`ArrayBuffer`实例中的字节数在创建时是固定的。没有相当于`realloc`的;您不能设置`ArrayBuffer`的`byteLength`属性。
let a = new ArrayBuffer(16);
let b = a.byteLength; // 16
a.byteLength = 20; // exception thrown
与数组一样,使用`slice`方法将缓冲区的一部分提取到一个新的`ArrayBuffer`实例中:
let a = new ArrayBuffer(16);
let b = a.slice(0, 8); // copy first half
let c = a.slice(8, 16); // copy second half
let d = a.slice(0); // clone entire buffer
您可能希望能够使用数组语法(例如,`a[0]`)来访问`ArrayBuffer`的内容,但事实并非如此。一个`ArrayBuffer`只是一个字节的缓冲区。因为没有与数据相关的类型,JavaScript 不知道如何解释字节——例如,字节值是有符号的还是无符号的。要访问`ArrayBuffer`中的数据,您需要将它包装在一个视图中。下面几节介绍两种视图:类型化数组和数据视图。
### 类型化数组
JavaScript *类型化数组*是一个类的集合,它允许你使用存储在`ArrayBuffer`中的整数数组和浮点值数组。您不直接使用`TypedArray`类,而是使用其特定类型的子类,例如`Int8Array`、`Uint16Array`和`Float32Array`。使用类型化数组类似于用`calloc`在 C 中创建一个内存缓冲区,并将结果赋给一个整型或浮点型的指针。
您可以创建一个包装现有`ArrayBuffer`的类型化数组。以下示例将一个`ArrayBuffer`包装到一个`Uint8Array`中:
let a = new ArrayBuffer(16);
let b = new Uint8Array(a);
现在您已经有了缓冲区的视图,您可以像预期的那样使用数组括号语法来访问内容:
b[0] = 12;
b[1] += b[0];
类型化数组,比如前面例子中的`Uint8Array`,有一个`byteLength`属性,和`ArrayBuffer`一样,但是它们也有一个`length`属性,指示数组中元素的数量。当元素是字节时,这两个值是相等的,但是对于更大的类型,它们是不同的(参见清单 2-74 )。
let a = new ArrayBuffer(24);
let b = new Uint8Array(a);
let c = new Uint16Array(a);
let d = new Uint32Array(a);
let e = b.length; // 24
let f = c.length; // 12
let g = d.length; // 6
Listing 2-74.
这里一个单独的`ArrayBuffer`被几个视图所包装;这是允许的。在 C 语言中,这被称为“别名”,这是很危险的,因为它会干扰某些编译器优化。在 JavaScript 中,这是安全的,尽管您应该小心使用它,以避免在读取和写入重叠视图时出现意外。
您可以创建引用缓冲区子集的类型化数组视图,方法是将偏移量(以字节为单位)包含到视图的开头,并包含视图中元素的数量。这就像在内存缓冲区中间给一个整数指针赋值一样。然而,在 JavaScript 中,当读取超过缓冲区末尾时,没有不可预测的结果;它总是返回`undefined`(参见清单 2-75 )。
let a = new ArrayBuffer(18)
let b = new Int16Array(a);
b[0] = 0;
b[1] = 1;
b[2] = 2;
b[3] = 3;
let c = new Int16Array(a, 6, 1);
// c begins 6 bytes into a and has one element
let d = c[0]; // 3
let e = c[1]; // undefined (read past end of view)
Listing 2-75.
在清单 2-75 中为变量`c`创建的`Int16Array`视图从偏移量 6 开始,但是它可以从任何偏移量开始,包括奇数。访问该数组中的 16 位值需要错位读取。不是所有的微控制器都支持错位读写;ESP8266 是一种不支持错位内存访问的微控制器。当 C 代码执行未对齐的读或写操作时,会产生硬件异常,导致微控制器复位。JavaScript 代码没有这个问题,因为这种语言保证未对齐的操作与对齐的操作产生相同的结果——这是 JavaScript 使嵌入式产品上的编码更容易的另一种方式。
#### 类型化数组速记
创建小整数数组是很常见的。在 C 和 C++中,可以很容易地在栈上声明静态数组。
static uint16_t values[] = {0, 1, 2, 3};
在 JavaScript 中,您可以在类型化数组上使用 static `of`方法获得相同的结果:
let a = Uint16Array.of(0, 1, 2, 3);
let b = a.byteLength; // 8
let c = a.length; // 4
`of`函数自动创建一个存储值所需大小的`ArrayBuffer`。您可以通过获取类型化数组的`buffer`属性来访问由`of`创建的`ArrayBuffer`。该缓冲区可用于其他视图,如数据视图。
let a = Uint16Array.of(0, 1, 2, 3);
let b = a.buffer;
let c = b.byteLength; // 8
#### 复制类型化数组
在 C 和 C++中,使用`memcpy`和`memmove`在单个缓冲区内或两个缓冲区之间复制数据值。您已经看到了如何使用 JavaScript 中的`ArrayBuffer`上的`slice`将部分或全部缓冲区复制到一个新的缓冲区;您可以使用`copyWithin`在单个缓冲区内复制值,使用`set`将值从一个缓冲区复制到另一个缓冲区。在 C 中,当源和目标重叠时,在单个缓冲区内复制时需要特别小心,而 JavaScript 的`copyWithin`方法保证了结果的可预测性和正确性。`copyWithin`的第一个参数是目标索引,第二个和第三个参数是要复制的开始和结束源索引(其中结束索引是结束之前的索引)。
let a = Uint16Array.of(0, 1, 2, 3, 4, 5, 6);
a.copyWithin(4, 1, 3);
// [0, 1, 2, 3, 1, 2, 6]
方法将一个类型化数组写入另一个类型化数组。第一个参数是要写入的源数据,第二个参数是开始写入数据的索引。
let a = Int16Array.of(0, 1, 2, 3, 4, 5, 6);
let b = Int16Array.of(-2, -3);
a.set(b, 2);
// [0, 1, -2, -3, 4, 5, 6]
要仅写入源数据的子集,您需要创建另一个视图。`subarray`方法对此很方便,如清单 2-76 所示。给定一个类型化数组的起始和结束索引,`subarray`返回一个新的类型化数组,该数组只引用那些索引。注意`subarray`没有分配新的`ArrayBuffer`;它引用了同一个`ArrayBuffer`。
let a = Int16Array.of(0, 1, 2, 3, 4, 5, 6);
let b = Int16Array.of(0, -1, -2, -3, -4, -5, -6);
let c = b.subarray(2, 4);
a.set(c, 2);
// [0, 1, -2, -3, 4, 5, 6]
Listing 2-76.
您可以使用`slice`代替`subarray`来复制新`Int16Array`中的子集,但是这会临时使用额外的内存,所以在这种情况下最好使用`subarray`。
`TypedArray`类不是`Array`的子类;它们是完全独立的类,但是它们被设计成共享公共 API。例如,您学习的用于类型化数组的`copyWithin`方法在`Array`中可用。类似地,许多`Array`方法,包括`map`、`forEach`、`indexOf`、`lastIndexOf`、`findIndex`和`sort`,也可用于类型化数组。
#### 填充类型化数组
另一个对`Array`和`TypedArray`都有用的方法是`fill`,类似于 C 和 C++中的`memset`。但是`memset`只对字节值进行操作,`fill`对类型化数组的类型值进行操作。如清单 2-77 所示,`fill`的第一个参数是要赋值的值,可选的第二个和第三个参数是要填充的开始和结束索引(其中结束索引是结束填充之前的索引)。如果没有提供可选参数,则填充整个数组。
let a = new Uint16Array(4);
a.fill(0x1234);
// [0x1234, 0x1234, 0x1234, 0x1234]
a.fill(0, 1, 3);
// [0x1234, 0, 0, 0x1234]
let b = new Uint32Array(2);
b.fill(0x12345678);
// [0x12345678, 0x12345678]
Listing 2-77.
#### 写入类型化数组值
将值写入类型化数组通常像在 c 中一样。例如,如果将 16 位值写入 8 位类型化数组,则使用最低有效的 8 位(参见清单 2-78 )。
let a = new Uint32Array(1);
a[0] = 0x12345678; // 0x12345678
let b = new Uint16Array(1);
b[0] = 0x12345678; // 0x5678
let c = new Uint8Array(1);
c[0] = 0x12345678; // 0x78
Listing 2-78.
JavaScript 还有一个`Uint8ClampedArray`,它实现了一种不同的行为:它不是获取最低有效位,而是将输入值固定在 0 和类型化数组实例可以存储的最大值之间的一个值。
let a = new Uint8ClampedArray(1);
a[0] = 5; // 5
a[0] = 256; // 255
a[0] = -1; // 0
#### 浮点类型数组
浮点型数组有两种:`Float32Array`和`Float64Array`。由于 JavaScript 中的数字值是 64 位 IEEE 754 浮点型的,`Float64Array`能够存储这些值而不损失精度。`Float32Array`降低可能存储的值的精度和范围,但在某些情况下已经足够。
Note
类型化数组类不保证存储值时的字节顺序(即,是大端还是小端)。JavaScript 引擎实现可以自由地以它选择的任何方式存储值,只要保持值的准确性。它通常按照与主机微控制器相同的顺序存储它们,以获得最高效率。要控制值的字节顺序,请使用数据视图(接下来将讨论)。
### 数据视图
`DataView`类为`ArrayBuffer`提供了另一种视图。与所有值都是同一类型的类型化数组不同,*数据视图*用于在缓冲区中读写不同大小的整数和浮点值。您可以使用`DataView`来访问对应于包含不同类型值的 C 或 C++ `struct`的二进制数据。
您可以通过向`DataView`构造函数传递一个`ArrayBuffer`来实例化数据视图,以便视图进行包装,就像您可以向类型化数组构造函数传递一个`ArrayBuffer`一样:
let a = new ArrayBuffer(16);
let b = new DataView(a);
与类型化数组一样,您可以将偏移量和大小传递给`DataView`构造函数,以将视图限制为总缓冲区的一个子集。这种能力对于访问嵌入在较大内存缓冲区中的数据结构很有用。
let a = new ArrayBuffer(16);
let b = new DataView(a, 4, 12);
// b may only access bytes 4 through 12 of a
#### 访问数据视图的值
一个`DataView`实例能够获取和设置与类型化数组相同的所有类型,如清单 2-79 所示。getter 和 setter 方法都将视图中的偏移量作为第一个参数。setter 方法的第二个参数指定要设置的值。
let a = new DataView(new ArrayBuffer(8));
a.setUint8(0, 0);
a.setUint8(1, 1);
a.setUint16(2, 0x1234);
a.setUint32(4, 0x01020304);
Listing 2-79.
因为默认情况下`DataView`方法以大端字节顺序写入多字节值,所以在清单 2-79 中的示例执行后,缓冲区`a`包含以下十六进制字节:
00 01 12 34 01 02 03 04
使用相应的 getter 方法读回这些值。以下示例假设前面显示的`DataView`实例`a`:
let b = a.getUint8(0); // 0
let c = a.getUint8(1); // 1
let d = a.getUint16(2); // 0x1234
let e = a.getUint32(4); // 0x01020304
`DataView`方法有一个可选的最终参数来控制字节顺序。如果省略参数或`false`,字节顺序为 big-endian;如果是`true`,那就是小端(见清单 2-80 )。
let a = new DataView(new ArrayBuffer(8));
a.setUint8(0, 0);
a.setUint8(1, 1);
a.setUint16(2, 0x1234, true);
a.setUint32(4, 0x01020304, true);
Listing 2-80.
因为`setUint8`写的是单字节值,没有字节顺序,所以第三个参数是不必要的。对清单 2-80 中的`setUint16`和`setUint32`的调用将字节顺序参数设置为`true`,因此输出是 little-endian。
00 01 34 12 04 03 02 01
要读取以小端顺序存储的值,将`true`作为最终参数传递给 getter 方法:
let b = a.getUint16(2, true); // 0x1234 (little-endian get)
let c = a.getUint16(2); // 0x3412 (big-endian get)
`DataView`类包括对应于`TypedArray`中所有可用类型的 getter 和 setter 方法:`Int8`、`Int16`、`Uint8`、`Uint16`、`Uint32`、`Float32`和`Float64`。
`DataView`类是操纵二进制数据结构的一种非常灵活的方式,但是代码不是特别可读。不要像在 C 中那样写`a.value`来访问字段,你必须写类似`a.getUint16(6, true)`的东西。提高可读性和减少错误可能性的一种方法是为数据结构创建一个子类`DataView`。假设您有清单 2-81 中所示的 C 数据结构,用于您想在 JavaScript 中使用的网络数据包报头。为简单起见,假设字段之间没有填充。
typedef struct Header {
uint8_t kind;
uint8_t priority;
uint16_t sequenceNumber;
uint32_t value;
}
Listing 2-81.
清单 2-82 中的 JavaScript `Header`类继承了`DataView`来实现对 C `Header`结构的简单访问。因为网络数据包通常使用大端字节排序,所以多字节值以大端顺序写入。
class Header extends DataView {
constructor(buffer = new ArrayBuffer(8)) {
super(buffer);
}
get kind() {return this.getUint8(0);}
set kind(value) {this.setUint8(0, value);}
get priority() {return this.getUint8(1);}
set priority(value) {this.setUint8(1, value);}
get sequenceNumber() {return this.getUint16(2);}
set sequenceNumber(value) {this.setUint16(2, value);}
get value() {return this.getUint32(4);}
set value(value) {this.setUint32(4, value);}
}
Listing 2-82.
因为该类使用 getters 和 setters,所以该类用户的结果代码类似于 c。清单 2-83 中的示例使用`Header`类从变量`p`中接收的数据包中读取值。
let a = new Header(p);
let b = a.kind;
let c = a.priority;
let d = a.sequenceNumber;
let e = a.value;
Listing 2-83.
清单 2-84 创建一个新的包,初始化值,并调用一个`send`函数来传输`Header`实例使用的`ArrayBuffer a.buffer`进行存储。
let a = new Header;
a.kind = 1;
a.priority = 2;
a.sequenceNumber = 3;
a.value = 4;
send(a.buffer);
Listing 2-84.
正如您所看到的,定义一个表示二进制数据结构的类使得处理该数据结构的代码更加清晰。处理二进制数据是 C 在代码紧凑性方面具有优势的一个领域;尽管如此,用 JavaScript 的可读代码也可以达到同样的效果。JavaScript 在这里也有好处:考虑到从网络上接收的数据中读取的代码通常是脆弱的。在本例中,如果收到的数据包只有四个字节,而不是所需的八个字节,那么读取`value`字段会产生一个未定义的结果,这可能会泄漏私有数据,甚至导致崩溃。如果在 JavaScript 中出现这种情况,使用`getUint32`读取`value`的尝试会失败,并出现异常,因为读取超出了范围。
## 内存管理
内存管理是 JavaScript 与 C 和 C++显著不同的地方。在 C 和 C++中,你用`malloc`、`calloc`和`realloc`显式分配内存,用`free`释放内存。这些内存分配和释放函数不在语言本身中,而是在标准库中。在 C++中,当你使用`new`实例化一个类时,你也分配内存,当你使用`delete`调用类的析构函数时,你释放内存。
JavaScript 在语言中内置了内存管理。当您创建一个对象、字符串、`ArrayBuffer`或任何其他需要内存的内置对象时,JavaScript 引擎会透明地分配内存。如您所料,该语言还会释放内存;然而,JavaScript 并不要求您的代码进行类似于`free`的调用或者使用 C++ `delete`操作符,而是在确定这样做是安全的时候自动释放内存。这种内存管理方法是使用垃圾收集器实现的。在特定时间点,JavaScript 引擎运行垃圾收集器,该收集器扫描引擎分配的所有内存,识别不再被引用的任何分配,并释放任何未被引用的内存块。
考虑以下代码:
let a = "this is a test";
a = {};
a = new ArrayBuffer(16);
此示例执行以下操作:
1. 第一行分配一个字符串,并将其赋给`a`。因为字符串被`a`引用,所以不能被垃圾回收。
2. 第二行将一个空对象分配给`a`,删除对字符串的引用。因为没有其他变量或属性引用该字符串,所以它有资格被垃圾回收。
3. 在第三行的`ArrayBuffer`赋值之后,空对象就可以进行垃圾收集了。
JavaScript 语言没有定义垃圾收集器何时运行。可修改的 SDK 中使用的 XS 引擎中的垃圾收集器会在内存不足时运行;这可能永远不会发生,一小时一次,或者一秒钟多次,取决于运行的代码。
垃圾收集器非常适合管理内存。它减少了您需要编写的代码量,因为分配和释放都是自动发生的。它消除了忘记释放内存导致内存泄漏的 bug 这是嵌入式系统中的一个主要问题,许多嵌入式系统必须一次运行数月或数年,因为定期发生的少量内存泄漏最终会导致系统故障。垃圾收集器还消除了读取已被释放的内存的错误,因为如果代码仍然能够引用,内存就不会被释放。
尽管垃圾收集器有很多好处,但它并不是资源管理的通用解决方案。考虑清单 2-85 ,它打开一个文件两次,第一次是写模式,第二次是只读模式。
let f = new File("/foo.txt", 1); // 1 for write
f.write("this is a test");
f = undefined;
...
let g = new File("/foo.txt"); // read-only
Listing 2-85.
在本例中,当`undefined`被分配给`f`时,对应于为写访问而打开的文件的`File`类的实例有资格进行垃圾收集。在大多数文件系统中,当文件被打开进行写访问时,该访问是独占的,这意味着该文件不能被第二次打开。因为垃圾收集器可能随时运行,所以以只读模式打开文件的调用可能成功,也可能失败,这取决于是否已经收集了写访问文件对象。因此,用于表示非内存资源的对象(如打开的文件)通常提供了一种显式释放资源的方法。在可修改的 SDK 中,`close`方法用于释放资源,类似于在 C++中使用`delete`操作符。
let f = new File("/foo.txt", 1); // 1 for write
f.write("this is a test");
f.close();
对`close`的调用会立即关闭文件。任何向`f`中的实例写入的进一步尝试都将失败。该文件现在可以以读或写模式再次打开。
## `Date`类
C 标准库提供了`gettimeofday`和`localtime`函数来确定当前日期、时间、时区和夏令时偏移量。同一个库中的`strftime`函数使用格式字符串将日期和时间转换为文本格式。JavaScript 在内置的`Date`类中提供了等效的功能。
下面的代码创建了一个`Date`类的实例。该实例包含一个时间值,当不带参数调用`Date`构造函数时,该值被初始化为当前时间。
let now = new Date;
trace(now.toString());
// Tue Sep 24 2019 11:18:26 GMT-0700 (PDT)
`Date`构造函数接受参数将值初始化为当前时间以外的值。您可以从字符串中初始化它,但是不建议这样做,因为字符串格式很容易出错。
let d = new Date("Tue Sep 24 2019 11:18:26 GMT-0700 (PDT)");
相反,您可以将时间的组成部分(小时、分钟、年等)作为参数传递给构造函数:
let d = new Date(2019, 8, 24);
// September 24 2019 midnight
let e = new Date(2019, 8, 24, 11, 18, 26);
// September 24 2019 11:18:26
请注意,9 月份的值是 8,而不是您预期的 9。这是因为 JavaScript `Date` API 中的月份数字从 0 开始,而不是从 1 开始;这是在 JavaScript 开发的早期决定的,以匹配 Java 语言的`java.util.Date`对象。还要注意,第二个声明中指定的时间是本地时间,而不是 UTC(协调世界时)。要指定 UTC 时间,使用`Date.UTC`函数和`Date`构造函数。
let d = new Date(Date.UTC(2019, 8, 24));
// September 24 2019 midnight UTC
一个`Date`实例存储一个以毫秒为单位的时间值,并且总是以 UTC 时间为单位。要检索该值,调用`getTime`方法。
let now = new Date;
let utcTimeInMS = now.getTime();
如果您的代码需要经常检索时间,前面的例子是低效的,因为它在每次需要当前时间时创建一个新的`Date`实例。对于这种情况,静态方法`now`以毫秒为单位返回当前的 UTC 时间。
let utcTimeInMS = Date.now();
`Date`类提供了对组成日期和时间的所有部分的访问(参见清单 2-86 )。
let now = new Date;
let ms = now.getMilliseconds(); // 0 to 999
let seconds = now.getSeconds(); // 0 to 59
let minutes = now.getMinutes(); // 0 to 59
let hours = now.getHours(); // 0 to 23
let day = now.getDay(); // 0 (Sunday) to 6 (Saturday)
let date = now.getDate(); // 1 to 31
let month = now.getMonth(); // 0 (January) to 11 (December)
let year = now.getFullYear();
Listing 2-86.
清单 2-86 中返回的值是当地时间,应用了时区和夏令时偏移。用于 UTC 值的相同函数的版本也是可用的;它们以`getUTC`开头,如`getUTCMilliseconds`、`getUTCSeconds`等等。
还有对应于所有 getter 方法的 setter 方法。清单 2-87 创建一个日期对象,并将其修改为下一个元旦的午夜。
let d = new Date;
d.setMilliseconds(0);
d.setSeconds(0);
d.setMinutes(0);
d.setHours(0);
d.setDate(1);
d.setMonth(0);
d.setFullYear(d.getFullYear() + 1);
Listing 2-87.
`setHours`和`setFullYear`方法支持额外的参数,使得清单 2-87 中的例子写得更简洁:
let d = new Date;
d.setHours(0, 0, 0, 0);
d.setFullYear(d.getFullYear() + 1, 0, 1);
要从 UTC 时间检索当前时区偏移量,请调用`getTimezoneOffset`方法。返回值以分钟为单位,并应用了当前夏令时偏移量。
let timeZoneOffset = d.getTimezoneOffset();
// timeZoneOffset = 420 (offset in minutes from UTC)
如本节前面所示,`Date`对象的`toString`方法提供了一个字符串,表示应用了时区和夏令时偏移量的本地时间。对于某些情况,例如网络,用 UTC 时间表示字符串是很有帮助的。使用`toUTCString`方法创建一个表示 UTC 时间的字符串。
let d = new Date;
trace(d.toUTCString());
// "Tue, 24 Sep 2019 18:18:26 GMT"
许多标准使用的另一种时间和日期格式是 ISO 8601。`toISOString`方法以字符串形式提供日期的 ISO 8601 兼容版本。
let d = new Date;
trace(d.toISOString());
// "2019-09-24T18:18:26.000Z"
虽然`toUTCString`和`toISOString`很方便,但是您可以使用 JavaScript 日期和字符串知识来生成项目所需的任何格式的字符串。
## 事件驱动编程
嵌入式程序,尤其是那些运行在功能较弱的设备上的程序,通常是围绕一个连续执行的循环来组织的。清单 2-88 显示了一个简单的例子。
while (true) {
if (readButton())
lightOn();
else
lightOff();
}
Listing 2-88.
这种编程风格适用于非常简单的嵌入式设备。然而,对于具有许多不同输入和输出的大型系统来说,这种方法并不适用;对于这样的系统,事件驱动编程是首选。事件驱动程序等待事件的发生,比如按钮的按下。当事件发生时,调用回调来响应它。JavaScript 是为事件驱动程序设计的,因为这是 web 浏览器的工作方式。
清单 2-89 是上例中无限循环的事件驱动版本。这里,当按钮改变时调用`onRead`回调,这样代码就不需要不断地轮询按钮状态。
let button = new Button;
button.onRead = function(value) {
if (value)
lightOn();
else
lightOff();
}
Listing 2-89.
通常,只有当微控制器空闲时,才会调用传递事件的回调。当 JavaScript 代码执行时,回调被延迟,直到代码完成。在清单 2-88 中,因为循环是无限的,所以不能调用回调。因此,通常不可能使用单个循环作为 JavaScript 应用程序的基础;您必须采用事件驱动的编程风格。
如果你以前没有做过很多事件驱动的编程,不要担心。本书中的例子都是为了向您展示如何在事件驱动的编程风格中使用嵌入式 JavaScript APIs。稍加练习,它应该成为第二天性。
## 结论
有了对 JavaScript 的介绍,您就可以继续阅读本书了。剩下的章节是关于如何在嵌入式系统上使用 JavaScript,使用可修改的 SDK 提供的特性来创建物联网产品。
JavaScript 语言规范非常庞大,超过 750 页。这本书不可能解释这种语言的每一个特性和细微差别,但是许多优秀的资源可以帮助你学习更多。Mozilla 的 MDN Web Docs ( [`developer.mozilla.org`](http://developer.mozilla.org) )是 JavaScript 语言事实上的参考。这是最新的标准,提供了大量的例子,非常详细。对于嵌入式开发人员来说,这是一个很好的资源,因为即使您不是 web 开发人员,也可以理解它提供的许多示例。
# 三、网络
物联网设备种类繁多,从恒温器到门锁,从智能手表到智能灯泡,从洗衣机到安全摄像头,人们很容易忘记它们都有一个共同点:网络。物联网设备与普通日常设备的区别在于它与网络的连接。本章讲述的都是这种连接,从不同的网络连接方式开始。
一旦您的设备连接到网络,它可以通过多种不同的方式进行通信。本章向您展示了如何使用与您的计算机和电话上的 web 浏览器相同的 HTTP 网络协议进行通信。它还展示了如何使用 WebSocket 协议进行交互式双向通信,以及如何使用 MQTT 协议进行发布和订阅。
保护通信对于许多产品来说是必不可少的,因此您还将了解如何结合使用 TLS(传输层安全性)和 HTTP、WebSocket 和 MQTT 等协议来建立安全连接。
本章以两个高级主题结束。首先是如何将你的设备变成 Wi-Fi 基站,这是许多商业物联网产品为了便于配置而使用的技术。您可以将电脑、电话和其他设备连接到此专用 Wi-Fi 基站,而无需安装任何特殊软件。第二个高级主题是如何在网络 API 中使用 JavaScript promises。
## 关于网络
这本书重点介绍了使用 Wi-Fi 连接到网络的硬件。你的 Wi-Fi 接入点*、*也称为*基站*或*路由器、*将你的 Wi-Fi 网络连接到互联网。接入点还创建了一个本地网络,允许连接到它的设备相互通信。HTTP、MQTT 和 WebSocket 协议用于与互联网上的服务器通信,但它们也可用于本地 Wi-Fi 网络上的设备之间的通信。设备之间的直接通信速度更快,也更私密,因为您的数据永远不会离开您的 Wi-Fi 网络。它消除了云服务的成本。使用 mDNS 网络协议可以使本地网络上的设备轻松地直接相互通信。
本章所有的组网例子都是*非阻塞*(或*异步*)。这意味着,例如,当您使用 HTTP 协议从网络请求数据时,您的应用程序会在发出请求的同时继续运行。这与您在 web 上使用 JavaScript 时联网的工作方式相同,但与嵌入式环境中的许多联网实现不同。由于种种原因,很多嵌入式开发环境用*阻塞*组网代替;这使得设备在网络操作期间对用户输入没有响应,除非还使用了更复杂和更占用内存的技术,例如线程。
可修改的 SDK 中实现网络功能的类使用回调函数来提供状态和传递网络数据。回调实现起来很简单,即使在处理能力和内存相对较小的硬件上,它们也能有效地运行。在 web 上,开发人员很早就开始使用回调来进行网络操作。最近,JavaScript 的一个名为*的特性承诺*已经成为某些情况下回调的流行替代。因为承诺需要更多的资源,所以在这里很少使用。为可修改的 SDK 提供动力的 XS 引擎支持承诺。本章中介绍的网络功能可能适用于使用承诺;本章末尾关于承诺的部分有一个例子。
## 连接到 Wi-Fi
你已经知道如何连接你的电脑和电话(甚至你的电视!)连接到互联网,这种体验将有助于您编写连接设备的代码。你还需要学习一些新东西,因为物联网设备并不总是有屏幕,没有屏幕,用户就不能简单地点击 Wi-Fi 网络的名称来连接。
本节描述了连接到 Wi-Fi 的三种不同方式:
* 从命令行
* 用简单的代码连接到一个已知的无线接入点
* 通过扫描开放的 Wi-Fi 接入点
每一个都适用于不同的情况;你会为你的项目选择最好的一个。使用命令行对于开发来说是非常好的,但是当您从实验转向构建复杂的原型和真实产品时,需要另外两种方法。
Note
本节使用了一种不同于你在第一章中学到的安装模式:不是用`mcconfig`安装主机,然后用`mcrun`安装示例,而是用`mcconfig`安装示例。
### 从命令行连接
在第一章中,你学会了使用`mcconfig`命令行工具来构建和安装主机。`mcconfig`命令可以定义变量。如以下命令所示,您可以通过用 Wi-Fi 接入点名称的值定义变量`ssid`来连接到 Wi-Fi 接入点。 *SSID* 代表*服务集标识符*,是 Wi-Fi 基站提供的 Wi-Fi 网络的人类可读名称的技术术语。
```js
> mcconfig -d -m -p esp ssid="my wi-fi"
以这种方式定义ssid会导致一个配置变量被添加到您的应用程序中,设备的基本网络固件会使用该变量在设备启动时自动连接到 Wi-Fi。建立 Wi-Fi 连接后,您的应用程序就可以运行了。这很方便,因为这意味着您的应用程序可以假设网络总是可用的。
如果您的 Wi-Fi 接入点需要密码,请在命令行中将其作为password变量的值:
> mcconfig -d -m -p esp ssid="my wi-fi" password="secret"
在 Wi-Fi 连接过程中,调试控制台中会显示诊断跟踪消息。观察消息有助于诊断连接故障。下面是一个成功连接的例子:
Wi-Fi connected to "Moddable"
IP address 10.0.1.79
如果 Wi-Fi 存取点拒绝 Wi-Fi 密码,会显示以下信息:
Wi-Fi password rejected
所有其他不成功的连接尝试都会显示以下消息:
Wi-Fi disconnected
在您的设备上安装$EXAMPLES/ch3-network/wifi-command-line示例来测试这种连接方法。
用代码连接
使用命令行选项来定义您的 Wi-Fi 凭证便于开发,但是对于您与其他人共享的项目,您通常会想要将 Wi-Fi 凭证储存在偏好设置中。本节介绍连接到应用程序中定义的 Wi-Fi 接入点的代码。(管理首选项在第五章中描述。)
wifi模块包含用于管理 Wi-Fi 网络连接的 JavaScript 类。要在您的代码中使用wifi模块,首先从其中导入WiFi类:
import WiFi from "wifi";
使用WiFi类的静态connect方法连接到 Wi-Fi 网络。在$EXAMPLES/ch3-network/wifi-code的例子中,SSID 和密码作为字典中的属性传递给构造函数(清单 3-1 )。
WiFi.connect({
ssid: "my wi-fi",
password: "secret"
}
);
Listing 3-1.
这个调用开始了建立连接的过程。调用是异步的,这意味着实际的连接工作在后台进行;建立连接时,应用程序会继续运行。这就像在你的手机上一样,当 Wi-Fi 连接建立时,你可以继续使用应用程序。在物联网设备中,您通常希望知道网络连接何时可用,以便您知道您的应用程序何时可以连接到其他设备和互联网。
为了监控连接状态,创建一个WiFi类的实例,并提供一个监控回调函数(清单 3-2 ),当连接状态改变时调用该函数。
let wifiMonitor = new WiFi({
ssid: "my wi-fi",
password: "secret"
},
function(msg) {
switch (msg) {
case WiFi.gotIP:
trace("network ready\n");
break;
case WiFi.connected:
trace("connected\n");
break;
case WiFi.disconnected:
trace("connection lost\n");
break;
}
}
);
Listing 3-2.
根据连接状态,使用以下三种消息之一调用回调函数:
-
connected–您的设备已连接到 Wi-Fi 接入点。然而,它还不能使用,因为它还没有收到它的 IP 地址。当您看到此消息时,您知道 SSID 和密码是有效的。 -
gotIP–您的设备已收到其 IP 地址,现在可以与本地网络和互联网上的其他设备进行通信。 -
disconnected–您的设备失去了网络连接。在某些设备上,您会在收到connect消息之前收到此消息。
一些项目让WiFi对象一直保持活动状态,以监控网络断开。如果您不需要监控掉线的网络连接,您应该关闭WiFi对象来释放它正在使用的内存。
wifiMonitor.close();
关闭WiFi对象不会断开与 Wi-Fi 网络的连接。这仅仅意味着您的回调函数将不再被调用,并且不会收到关于回调状态的通知。
要断开与 Wi-Fi 网络的连接,调用WiFi类的静态disconnect方法:
WiFi.disconnect();
要测试此连接方法,请执行以下步骤:
-
在文本编辑器中打开
$EXAMPLES/ch3-network/wifi-code/main.js。 -
更改第 4 行和第 5 行,使
ssid和password与您的网络凭证相匹配。 -
使用
mcconfig从命令行在您的设备上安装$EXAMPLES/ch3-network/wifi-code示例。
如果连接成功,您将看到跟踪到调试控制台的以下消息:
connected
network ready
如果连接不成功,您将会看到connection lost重复显示。
连接到任何开放的接入点
有时,您会希望您的物联网设备连接到任何可用的开放 Wi-Fi 接入点(例如,不需要密码的接入点)。从安全角度来看,连接到未知网络并不是一个好主意,但在某些情况下,便利性更重要。
要连接到开放的接入点,第一步是找到一个。WiFi类提供了静态的scan方法来寻找访问点。清单 3-3 中的代码执行一次访问点扫描,将结果记录到调试控制台。它从accessPoint的rssi属性中获取信号强度。 RSSI 代表接收信号强度指示,是对从 Wi-Fi 接入点接收到的信号强度的测量。它的值是负数,信号越强,RSSI 值越接近 0。
WiFi.scan({}, accessPoint => {
if (!accessPoint) {
trace("scan complete\n");
return;
}
let name = accessPoint.ssid;
let open = "none" === accessPoint.authentication;
let signal = accessPoint.rssi;
trace(`${name}: open=${open}, signal=${signal}\n`);
});
Listing 3-3.
下面是这段代码的输出示例:
ESP_E5C7AF: open=true, signal=-62
Large Conf.: open=false, signal=-85
Expo 2.4: open=false, signal=-74
PAB: open=true, signal=-77
Kanpai: open=false, signal=-66
Moddable: open=false, signal=-70
scan complete
扫描持续时间通常少于 5 秒,因设备而有所不同。在扫描过程中,该示例跟踪接入点的名称、它是否打开以及它的信号强度。当扫描完成时,调用scan回调函数,将accessPoint参数设置为undefined,并跟踪消息scan complete。
如果您所在的位置有许多接入点,单次扫描可能无法发现每个可用的接入点。要构建一个完整的列表,您的应用程序可以合并几次扫描的结果。有关示例,请参见可修改的 SDK 中的wifiscancontinuous。
选择 Wi-Fi 接入点进行连接的用户通常会选择强度最大的接入点。$EXAMPLES/ch3-network/wifi-open-ap示例使用清单 3-4 中的代码执行相同的选择过程。
let best;
WiFi.scan({}, accessPoint => {
if (!accessPoint) {
if (!best) {
trace("no open access points found\n");
return;
}
trace(`connecting to ${best.ssid}\n`);
WiFi.connect({ssid: best.ssid});
return;
}
if ("none" !== accessPoint.authentication)
return; // not open
if (!best) {
best = accessPoint; // first open access point found
return;
}
if (best.rssi < accessPoint.rssi)
best = accessPoint; // new best
});
Listing 3-4.
这段代码使用变量best来跟踪扫描期间信号强度最强的开放接入点。扫描完成后,代码连接到该接入点。
要测试这种方法,请在您的设备上安装wifi-open-ap示例。
安装网络主机
主机在$EXAMPLES/ch3-network/host目录中。从命令行导航到这个目录,用mcconfig安装它。
安装示例
本章中的示例仅在设备连接到 Wi-Fi 接入点时才能正常工作。在本章的前面,您学习了如何通过在mcconfig命令中定义变量来指定接入点的 SSID 和密码。在示例运行之前,您可以在mcrun命令中使用这些相同的变量将您的设备连接到 Wi-Fi。
> mcrun -d -m -p esp ssid="my wi-fi"
> mcrun -d -m -p esp ssid="my wi-fi" password="secret"
获取网络信息
使用网络时,您可能需要有关网络接口或网络连接的信息,以便进行调试或实现功能。该信息可从net模块获得。
import Net from "net";
使用静态的get方法从Net对象中检索信息。此示例检索设备所连接的 Wi-Fi 接入点的名称:
let ssid = Net.get("SSID");
以下是您可以检索到的一些其他信息:
-
IP–网络连接的 IP 地址;例如,10.0.1.4 -
MAC–网络接口的 MAC 地址;例如,A4:D1:8C:DB:C0:20 -
SSID–无线接入点的名称 -
BSSID–Wi-Fi 接入点的 MAC 地址;例如,18:64:72:47:d4:32 -
RSSI–Wi-Fi 信号强度
发出 HTTP 请求
互联网上最常用的协议是 HTTP,它的流行有许多很好的理由:它相对简单,得到广泛支持,适用于少量和大量数据,已被证明非常灵活,并且可以在多种设备上得到支持,包括许多物联网产品中相对便宜的设备。本节展示了如何向 HTTP 服务器发出不同种类的 HTTP 请求。(下一节将展示如何保护这些连接。)
基本原则
http模块包含对发出 HTTP 请求和创建 HTTP 服务器的支持。要发出 HTTP 请求,首先从模块中导入Request类:
import {Request} from "http";
Request类使用字典来配置请求。字典中只有两个必需的属性:
-
一个
host属性或一个address属性来定义要连接的服务器,其中host通过名称指定服务器(例如www.example.com)address通过 IP 地址定义服务器(例如10.0.1.23) -
一个
path属性,用于指定要访问的 HTTP 资源的路径(例如,/index.html或/data/lights.json)
所有其他属性都是可选的;您发出的 HTTP 请求的类型决定了它们是否存在以及它们的值是什么。下面几节将介绍许多可选属性。
除了配置字典之外,每个 HTTP 请求都有一个回调函数,在请求的各个阶段都会调用这个函数。回调接收对应于当前阶段的消息。以下是 HTTP 请求各个阶段的完整列表:
-
requestFragment–回调被要求提供请求体的下一部分。 -
status–已收到 HTTP 响应的状态行。HTTP 状态代码(例如,200、404 或 301)可用。状态代码指示请求是成功还是失败。 -
header–已收到 HTTP 响应报头。对于收到的每个 HTTP 头,都会重复此消息。 -
headersComplete–在收到最终 HTTP 响应头和响应体之间收到此消息。 -
responseFragment–此消息提供了 HTTP 响应的一个片段,可能会被多次接收。 -
responseComplete–此消息在所有 HTTP 响应片段之后接收。 -
error–处理 HTTP 请求时出现故障。
如果这看起来势不可挡,不要担心;许多 HTTP 请求只使用这些消息中的一两条。其中两个消息requestFragment和responseFragment仅用于处理太大而不适合设备内存的 HTTP 数据。接下来的部分展示了如何使用许多可用的消息。
GET
最常见的 HTTP 请求是GET,它检索一段数据。来自$EXAMPLES/ch3-network/http-get示例的清单 3-5 中的代码执行 HTTP GET从 web 服务器www.example.com获取主页。
let request = new Request({
host: "www.example.com",
path: "/",
response: String
});
request.callback = function(msg, value) {
if (Request.responseComplete === msg)
trace(value, "\n");
}
Listing 3-5.
对Request构造函数的调用中的response属性指定了您希望如何返回响应的主体。在这种情况下,您指定它应该作为 JavaScript 字符串返回。当收到响应——整个网页——时,回调函数接收到responseComplete消息。网页存储在value参数中。对trace的调用在调试控制台中显示了源 HTML。
您可以在项目中使用这种方法来检索文本数据。如果想要检索二进制数据,可以通过为response属性传递一个值ArrayBuffer而不是String来实现,如清单 3-6 所示。
let request = new Request({
host: "httpbin.org",
path: "/bytes/1024",
response: ArrayBuffer
});
Listing 3-6.
只要设备上有足够的内存来保存它,一次获取整个 HTTP 响应就能很好地工作。如果没有足够的内存,请求就会失败,并显示一条error消息。下一节将解释如何检索大于可用内存的资源。
流式传输GET
在对 HTTP 请求的响应不适合可用内存的情况下,您可以发出一个流 HTTP GET请求。这只是稍微复杂一点,如清单$EXAMPLES/ch3-network/http-streaming-get示例中的 3-7 所示。
let request = new Request({
host: "www.bing.com",
path: "/"
});
request.callback = function(msg, value, etc) {
if (Request.responseFragment === msg)
trace(this.read(String), "\n");
else if (Request.responseComplete === msg)
trace(`\n\nTransfer complete.\n\n`);
}
Listing 3-7.
注意,在对构造函数的调用中,response属性并不存在。该属性的缺失告诉 HTTP Request类在收到响应体的每个片段时,用responseFragment消息将其传递给回调。在本例中,回调将数据作为字符串读取,以跟踪调试控制台,但它也可以将数据作为ArrayBuffer读取。回调可能会将数据写入文件,而不是跟踪到调试控制台;你将在第五章中学习如何做到这一点。
当您流式传输一个 HTTP 请求时,响应的主体不在带有responseComplete消息的value参数中提供。
Request类支持 HTTP 协议的分块传输编码特性。该特性通常用于提供大量响应。HTTP Request类在调用回调函数之前对块进行解码。因此,回调函数不需要解析块头,从而简化了代码。
GET上
物联网产品通常不会请求网页,除非它们正在抓取页面以提取数据;相反,他们使用 REST APIs,通常用 JSON 来响应。因为 JSON 是 JavaScript 的一个非常小的纯数据子集,所以在 JavaScript 代码中使用它非常方便。清单 3-8 是一个请求休息天气服务的例子。$EXAMPLES/ch3-network/http-get-json中使用的应用 ID 只是一个例子;你应该在 openweathermap.org 注册自己的应用 ID ( APPID)来代替使用。
const APPID = "94de4cda19a2ba07d3fa6450eb80f091";
const zip = "94303";
const country = "us";
let request = new Request({
host: "api.openweathermap.org",
path: `/data/2.5/weather?appid=${APPID}&` +
`zip=${zip},${country}&units=imperial`
response: String
});
request.callback = function(msg, value) {
if (Request.responseComplete === msg) {
value = JSON.parse(value);
trace(`Location: ${value.name}\n`);
trace(`Temperature: ${value.main.temp} F\n`);
trace(`Weather: ${value.weather[0].main}.\n`);
}
}
Listing 3-8.
注意,在传递给Request构造函数的字典中,response被设置为String,就像前面的GET例子一样。响应被请求为String,因为 JSON 是一种文本格式。一旦响应可用,回调就会接收到responseComplete消息,然后使用JSON.parse将接收到的字符串转换成 JavaScript 对象。最后,它从对调试控制台的响应中跟踪三个值。
如果您想知道天气服务返回的所有可用值,您可以阅读它们的文档或者直接在调试控制台中查看响应。要查看调试器,在第一个trace调用时设置一个断点;当在断点处停止时,展开value属性查看值,如图 3-1 所示。

图 3-1
扩展的 JSON 天气响应如xsbug所示
如图所示,服务器返回的 JSON 包含许多 JavaScript 代码不使用的属性,比如clouds和visibility。在某些情况下,设备上有足够的内存来保存整个 JSON 文本,但没有足够的内存来保存通过调用JSON.parse创建的 JavaScript 对象。由于 JavaScript 对象在内存中的存储方式,对象可能比文本使用更多的内存。为了帮助解决这个问题,XS JavaScript 引擎支持调用JSON.parse的第二个可选参数。如果第二个参数是一个数组,那么 JSON 只解析数组中的属性名。这可以显著减少所使用的内存,解析也运行得更快。下面是如何更改前面示例中对JSON.parse的调用,以便只解码该示例使用的属性:
value = JSON.parse(value, ["main", "name", "temp", "weather"]);
创建 HTTP 请求的子类
HTTP Request类是一个低级类,它以很高的效率提供了大量的功能,为广泛的物联网场景提供了必要的功能和灵活性。尽管如此,对于任何给定的情况,代码的功能目的可能会被与 HTTP 协议相关的细节所掩盖。考虑上一节的清单 3-8 中的代码:输入是邮政编码和国家,输出是当前的天气状况,但是其他的都是实现细节。
简化代码的一个好方法是创建一个子类。一个设计良好的子类提供了一个集中的、易于使用的 API,它只接受相关的输入(例如,邮政编码),只提供想要的输出(例如,天气状况)。$EXAMPLES/ch3-network/http-get-subclass示例(清单 3-9 )显示了前一节中天气请求的子类设计。
const APPID = "94de4cda19a2ba07d3fa6450eb80f091";
class WeatherRequest extends Request {
constructor(zip, country) {
super({
host: "api.openweathermap.org",
path: `/data/2.5/weather?appid=${APPID}&` +
`zip=${zip},${country}&units=imperial`,
response: String
});
}
callback(msg, value) {
if (Request.responseComplete === msg) {
value = JSON.parse(value,
["main", "name", "temp", "weather"]);
this.onReceived({
temperature: value.main.temp,
condition: value.weather[0].main}
);
}
}
}
Listing 3-9.
使用这个WeatherRequest子类很容易(清单 3-10 ,因为 HTTP 协议、 openweathermap.org API 和 JSON 解析的所有细节都隐藏在子类的实现中。
let weather = new WeatherRequest(94025, "us");
weather.onReceived = function(result) {
trace(`Temperature is ${result.temperature}\n`);
trace(`Condition is ${result.condition}\n`);
}
Listing 3-10.
设置请求标题
HTTP 协议使用报头向服务器传递关于请求的附加信息。例如,在标准 HTTP 头之一的User-Agent头中包含发出 HTTP 请求的产品的名称和版本是很常见的。您还可以在请求中包含非标准的 HTTP 头,以便将信息传递给特定的云服务。
清单 3-11 展示了如何给 HTTP 请求添加头。它添加了标准的User-Agent标题和自定义的X-Custom标题。标头以数组的形式提供,每个标头的名称后跟其值。
let request = new Request({
host: "api.example.com",
path: "/api/status",
response: String,
headers: [
"User-Agent", "my_iot_device/0.1 example/1.0",
"X-Custom", "my value"
]
});
Listing 3-11.
在数组中而不是在字典或Map对象中指定头有点不寻常。在这里这样做是因为这样更高效,并且减少了物联网设备上所需的资源。
获取响应标头
HTTP 协议使用标头向客户端传达有关响应的附加信息。常见的头标是Content-Type,表示响应的数据类型(如text/plain、application/json或image/png)。响应头通过header消息传递给回调函数。一次传送一个报头,以通过避免将所有接收的报头一次存储在存储器中的需要来减少存储器的使用。当接收到所有的响应头时,用headersComplete消息调用回调。
清单 3-12 检查接收到的所有报头是否有Content-Type报头。如果找到一个,它的值被存储在变量contentType中。在收到所有的头之后,代码检查是否收到了一个Content-Type头(也就是说,contentType不是undefined)并且内容类型是text/plain。
let contentType;
request.callback = function(msg, value, etc) {
if (Request.header === msg) {
if ("content-type" === value)
contentType = etc;
}
else if (Request.headersComplete === msg) {
trace("all headers received\n");
if ((undefined === contentType) ||
!contentType.toLowerCase().startsWith("text/plain"))
this.close();
}
}
Listing 3-12.
根据定义,HTTP 头的名称是不区分大小写的,所以Content-Type、content-type和CONTENT-TYPE都指同一个头。HTTP Request类将头的名称转换成小写,所以回调在头名称比较中总是可以使用小写字母。
POST
到目前为止,所有 HTTP 请求的例子都使用了默认的 HTTP 请求方法GET,并且有一个空的请求体。HTTP Request类支持将请求方法设置为任何值,比如POST,并提供请求体。
$EXAMPLES/ch3-network/http-post示例(清单 3-13 )使用 JSON 请求体对 web 服务器进行POST调用。字典的method属性定义了 HTTP 请求方法,body属性定义了请求体的内容。请求体可以是一个字符串或一个ArrayBuffer。请求被发送到服务器,服务器回显 JSON 响应。回调函数将回显的 JSON 值跟踪到调试控制台。
let request = new Request({
host: "httpbin.org",
path: "/post",
method: "POST",
body: JSON.stringify({string: "test", number: 123}),
response: String
});
request.callback = function(msg, value) {
if (Request.responseComplete === msg) {
value = JSON.parse(value);
trace(`string: ${value.json.string}\n`);
trace(`number: ${value.json.number}\n`);
}
}
Listing 3-13.
此示例将整个请求体存储在内存中。在某些情况下,没有足够的可用内存来存储请求体,例如在上载大文件时。HTTP Request类支持请求体的流式传输;有关这方面的示例,请参见可修改 SDK 中的examples/network/http/httppoststreaming示例。
处理错误
有时 HTTP 请求失败,可能是由于网络故障或请求有问题。在所有情况下,故障都是不可恢复的。因此,您需要决定如何以适合您的物联网产品的方式处理错误,例如向用户报告、立即重试、稍后重试或忽略错误。如果您还没有准备好将错误处理添加到项目中,添加错误诊断跟踪是一个好的开始,因为它有助于您在开发过程中发现失败。
当故障是由于网络错误——网络故障、DNS 故障或服务器故障——时,您的回调将通过error消息调用。以下示例显示了跟踪调试控制台故障的回调:
request.callback = function(msg, value) {
if (Request.error === msg)
trace(`http request failed: ${value}\n`);
}
如果失败是由于请求的问题(格式不正确、路径无效或您没有适当的授权),服务器会以 HTTP 状态代码中的错误作为响应。HTTP Request类在status消息中向回调提供状态代码。对于许多 web 服务来说,从 200 到 299 的状态代码意味着请求成功,而其他的则表示失败。清单 3-14 演示了如何处理 HTTP 状态代码。
request.callback = function(msg, value) {
if (Request.status === msg) {
if ((value < 200) || (value > 299))
trace(`http status error: ${value}\n`);
}
}
Listing 3-14.
保护与 TLS 的连接
安全通信是大多数物联网产品的重要组成部分。它有助于维护产品生成的数据的隐私,并防止数据在从设备转移到服务器时被篡改。在网络上,大多数通信都是使用传输层安全或 TLS 来保护的,它取代了安全套接字层(SSL)。TLS 是一种低级工具,用于保护与许多不同协议的通信。本节解释如何将 TLS 与 HTTP 协议一起使用。同样的方法也适用于 WebSocket 和 MQTT 协议,这将在后面描述。
由于内存、处理能力和存储空间的减少,在嵌入式设备上使用 TLS 比在计算机、服务器或移动设备上更具挑战性。事实上,建立安全的 TLS 连接是许多物联网产品执行的计算要求最高的任务。
将 TLS 与SecureSocket类一起使用
SecureSocket类以一种可用于各种网络协议的方式实现 TLS。要使用SecureSocket,您必须首先导入它:
import SecureSocket from "securesocket";
要发出一个安全的 HTTP 请求(HTTPS),添加一个值为SecureSocket的Socket属性,它告诉 HTTP Request类使用安全套接字而不是默认的标准套接字。清单 3-15 摘自$EXAMPLES/ch3-network/https-get示例,该示例显示了之前 HTTP GET示例(清单 3-5 )中的字典,该字典被修改为发出 HTTPS 请求。
let request = new Request({
host: "www.example.com",
path: "/",
response: String,
Socket: SecureSocket
});
Listing 3-15.
回调与原始示例没有变化。
公共证书
证书是 TLS 提供安全性的一个重要部分:它们使客户端能够验证服务器的身份。证书内置于物联网产品的软件中,就像它们内置于 web 浏览器中一样,但有一点不同:web 浏览器可以存储数百个证书,足以验证互联网上所有公共可用服务器的身份,而物联网产品没有足够的存储空间来保存这么多证书。幸运的是,物联网产品通常只与少数服务器通信,因此您可以只包含您需要的证书。
证书是数据,因此它们存储在应用程序可以访问的资源中,而不是存储在代码中。HTTPS GET示例的清单包括验证www.example.com身份所需的证书(列表 3-16 )。
"resources": {
"*": [
"$(MODULES)/crypt/data/ca107"
]
}
Listing 3-16.
如果您尝试访问一个网站,而证书的资源不可用,TLS 实现会抛出如图 3-2 所示的错误。

图 3-2
xsbug中的 TLS 证书错误消息
该错误显示了缺少的资源的编号,因此您可以修改清单以包含该资源(清单 3-17 )。
"resources": {
"*": [
"$(MODULES)/crypt/data/ca106"
]
}
Listing 3-17.
这是可行的,因为可修改的 SDK 包括大多数公共网站的证书。下一节将描述如何连接到使用私有证书的服务器。
私有证书
私有证书通过确保只有拥有私有证书的物联网产品才能连接到服务器来提供额外的安全性。私有证书通常在扩展名为.der的文件中提供。要在您的项目中使用私有证书,首先将证书放在与您的清单相同的目录中,并修改清单以包含它(清单 3-18 )。请注意,清单不包括文件扩展名.der。
"resources": {
"*": [
"./private_certificate"
]
}
Listing 3-18.
接下来,如清单 3-19 所示,您的应用程序从资源中加载证书,并将其传递给构造器字典的secure属性中的 HTTP 请求。
import Resource from "resource";
let cert = new Resource("private_certificate.der");
let request = new Request({
host: "iot.privateserver.net",
path: "/",
response: String,
Socket: SecureSocket,
secure: {
certificate: cert
}
});
Listing 3-19.
创建 HTTP 服务器
在您的物联网产品中包含 HTTP 服务器带来了许多可能性,例如使您的产品能够实现以下功能:
-
为同一网络上的用户提供网页,这是为没有显示器的产品提供用户界面的好方法
-
为应用程序和其他设备提供 REST API 进行通信
基本原则
要创建 HTTP 服务器,首先从http模块导入Server类:
import {Server} from "http";
像 HTTP Request类一样,HTTP Server类也配置了一个 dictionary 对象。字典中没有必需的属性。和 HTTP Request一样,HTTP Server使用回调函数在响应 HTTP 请求的不同阶段传递消息。以下是 HTTP 请求各个阶段的完整列表:
-
connection–服务器接受了新的连接。 -
status–HTTP 请求的状态行已收到。请求路径和请求方法可用。 -
header–已收到 HTTP 请求报头。对于收到的每个 HTTP 头,都会重复此消息。 -
headersComplete–在收到最终 HTTP 请求头和请求体之间收到此消息。 -
requestFragment–(仅适用于流式请求正文)请求正文的片段可用。 -
requestComplete–已收到整个请求正文。 -
prepareResponse–服务器准备好开始发送响应。回调返回描述响应的字典。 -
responseFragment–(仅适用于流响应)回调通过提供响应的下一个片段来响应此消息。 -
responseComplete–整个响应已成功交付。 -
error–在 HTTP 响应完全传递之前出现故障。
下面的例子展示了如何使用这些消息。大多数使用 HTTP Server类的应用程序只使用其中的一部分。
回应请求
HTTP 服务器响应各种不同的请求。清单 3-20 摘自$EXAMPLES/ch3-network/http-server-get示例,它用明文响应每个请求,指明响应所用的 HTTP 方法(通常是GET)和请求的 HTTP 资源的路径。方法和路径都通过status消息提供给回调。回调函数存储这些值,以便在接收到prepareResponse消息时将它们返回到文本中。
let server = new Server({port: 80});
server.callback = function(msg, value, etc) {
if (Server.status === msg) {
this.path = value;
this.method = etc;
}
else if (Server.prepareResponse === msg) {
return {
headers: ["Content-Type", "text/plain"],
body: `hello. path "${this.path}".
method "${this.method}".`
};
}
}
Listing 3-20.
运行此示例时,设备的 IP 地址显示在调试控制台中,如下所示:
Wi-Fi connected to "Moddable"
IP address 10.0.1.5
显示 IP 地址后,您可以使用同一网络上的 web 浏览器连接到 web 服务器。当您在浏览器的地址栏中输入http://10.0.1.5/test.html时,您会收到以下响应:
hello. path "/test.html". method "GET".
注意,回调没有设置Content-Length字段。当您使用body属性时,服务器实现会自动添加Content-Length头。
本例中的body属性是一个字符串,但它也可以是一个用二进制数据响应的ArrayBuffer。
正在响应 JSON PUT
通常,REST API 在请求体中以 JSON 的形式接收输入,并在响应体中以 JSON 的形式提供输出。$EXAMPLES/ch3-network/http-server-put示例是一个 JSON echo 服务器,它通过发回消息来回复收到的每条消息。该示例期望客户端使用PUT方法发送一个 JSON 对象。响应将该 JSON 对象嵌入到一个更大的 JSON 对象中,该对象也包含一个error属性。
当收到status消息时,服务器验证它是一个PUT方法;否则,服务器会关闭连接以拒绝请求。当回调接收到status消息时,它返回String,表示它希望整个请求体同时作为一个字符串。改为以二进制数据接收请求体,它可能返回ArrayBuffer。
为了响应requestComplete消息,服务器解析 JSON 输入并将其嵌入到用于生成响应的对象中。当收到prepareResponse消息时,清单 3-21 中的服务器以字符串形式返回响应体 JSON,并将Content-Type头设置为application/json。
let server = new Server;
server.callback = function(msg, value, etc) {
switch (msg) {
case Server.status:
if ("PUT" !== etc)
this.close();
return String;
case Server.requestComplete:
this.json = {
error: "none",
request: JSON.parse(value)
};
break;
case Server.prepareResponse:
return {
headers: ["Content-Type", "application/json"],
body: JSON.stringify(this.json)
};
}
}
Listing 3-21.
因为这个例子没有将字典传递给Server构造函数,所以使用默认的端口 80。
您可以使用下面的命令来尝试使用curl命令行工具的http-server-put示例。您需要更改<IP_address>来匹配您的开发板的 IP 地址(例如,192.168.1.45)。该命令将简单的 JSON 消息以--data参数的形式发送到服务器,并将结果显示到调试控制台。
> curl http://<IP_address>/json
--request PUT
--header "Content-Type: application/json"
--data '{"example": "data", "value": 101}'
接收流式传输请求
当一个大的请求体被发送到 HTTP 服务器时,它可能太大而不适合内存。例如,当您上传数据以存储在文件中时,可能会发生这种情况。解决方案是分段接收请求正文,而不是一次全部接收。来自$EXAMPLES/ch3-network/http-server-streaming-put示例的清单 3-22 向调试控制台记录任意大的文本请求。为了让 HTTP Server类分段传递请求体,回调将true返回给prepareRequest消息。这些片段随requestFragment消息一起交付,并跟踪到调试控制台。requestComplete消息表明所有的请求主体片段已经被交付。
let server = new Server;
server.callback = function(msg, value) {
switch (msg) {
case Server.status:
trace("\n ** begin upload to ${value} **\n");
break;
case Server.prepareRequest:
return true;
case Server.requestFragment:
trace(this.read(String));
break;
case Server.requestComplete:
trace("\n ** end of file **\n");
break;
}
}
Listing 3-22.
您可以修改这个示例,在应用程序需要的地方写入接收到的数据,而不是写入调试控制台。例如,在第五章中,你将学习将数据写入文件的 API。
要尝试这个例子,使用如下所示的curl命令行工具。您需要为您的配置更改<directory_path>和<IP_address>。
> curl --data-binary "@/users/<directory_path>/test.txt"
http://<IP_address>/test.txt -v
发送流响应
如果对 HTTP 请求的响应太大,内存容纳不下,可以用流传输响应。这种方法适用于文件下载。如清单 3-23 所示,$EXAMPLES/ch3-network/http-server-streaming-get示例生成一个随机长度的响应,包含从 1 到 100 的随机整数。为了指示响应体将被流式传输,回调将从prepareResponse消息返回的字典中的body属性设置为true。服务器使用responseFragment消息反复调用回调,以获得响应的下一部分。回调返回undefined表示响应结束。
let server = new Server;
server.callback = function(msg, value) {
if (Server.prepareResponse === msg) {
return {
headers: ["Content-Type", "text/plain"],
body: true
};
}
else if (Server.responseFragment === msg) {
let i = Math.round(Math.random() * 100);
if (0 === i)
return;
return i + "\n";
}
}
Listing 3-23.
这个例子返回响应体的字符串值,但是它也可以返回ArrayBuffer值来提供二进制数据。当收到responseFragment消息时,回调的value参数指示服务器准备接受的这个片段的最大字节数。当您流式传输文件时,这可以用作从文件中读取片段的字节数。
HTTP Server类使用分块传输编码发送流响应正文。对于长度已知的响应正文,服务器使用默认的identity编码发送正文,不包含传输编码头,但包含一个Content-Length头。
mDNS
多播 DNS ,或 mDNS ,是一个功能集合,使设备更容易在本地网络上协同工作。您可能知道 DNS(域名系统)协议,因为它是您的 web 浏览器查找您在地址栏中输入的网站的网络地址的方式(例如,它是浏览器将www.example.com转换为93.184.216.34的方式)。DNS 是为整个互联网而设计的。相比之下,mDNS 只适用于您的本地网络,例如,适用于所有连接到 Wi-Fi 接入点的设备。DNS 是一种集中式设计,依靠权威服务器将域名映射到 IP 地址,而 mDNS 是完全分散的,每台设备都响应将其域名映射到 IP 地址的请求。
在本节中,您将学习如何使用 mDNS 为您的物联网设备命名,如porch-light.local,以便其他设备可以通过名称找到它,而不必知道它的 IP 地址。您还将学习使用 mDNS 的另一部分 DNS-SD (DNS 服务发现)来查找设备提供的服务(例如查找所有打印机或所有 web 服务器)并在本地网络上公布您设备的服务。
mdns模块包含 JavaScript 类,用于在应用程序中处理 mDNS 和 DNS-SD。要在您的代码中使用mdns模块,首先按如下方式导入它:
import MDNS from "mdns";
Note
mDNS 在 macOS、Android、iOS 和 Linux 上都得到了很好的支持。Windows 10 还不完全支持 mDNS,所以你可能需要安装额外的软件才能在那里使用。
要求一个名字
mDNS 通常用于为本地网络中使用的设备命名。mDNS 域名总是在.local域名中,比如在thermostat.local中。您可以为设备选择任何您喜欢的名称。设备必须检查该名称是否已经被使用,因为让多个设备响应同一个名称是行不通的。检查的过程叫做认领 。申请过程持续几秒钟。如果发现冲突,mDNS 会定义一个协商过程。协商结束时,只有一台设备拥有请求的名称,而另一台设备选择了未使用的名称。例如,如果你试图认领iotdevice不成功,你可能会以iotdevice-2告终。
$EXAMPLES/ch3-network/mdns-claim-name示例展示了声明名称的过程(参见清单 3-24 )。用一个字典调用MDNS构造函数,该字典包含带有所需名称值的hostName属性。有一个回调函数在申请过程中接收进度消息。当收到带有非空的value的name消息时,所声明的名称被追踪到调试控制台。
let mdns = new MDNS({
hostName: "iotdevice"
},
function(msg, value) {
if ((MDNS.hostName === msg) && value)
trace(`Claimed name ${value}.\n`);
}
);
Listing 3-24.
一旦设备声明了名称,您就可以使用该名称来访问该设备。例如,您可以使用ping命令行工具来确认设备是否在线。
> ping iotdevice.local
寻找服务
通过声明一个名字,你的设备变得更容易交流,但是这个名字最多只能提供一点关于这个设备做什么的提示。了解设备是灯、恒温器、扬声器还是 web 服务器会很有帮助,这样您就可以在没有任何配置的情况下编写与它一起工作的代码。这就是 DNS-SD 解决的问题:这是一种在本地网络上宣传您的物联网产品功能的方式。
每种 DNS-SD 服务都有一个唯一的名称。例如,web 服务器服务名为http,网络文件系统名为nfs。$EXAMPLES/ch3-network/mdns-discover的例子展示了如何搜索所有在本地网络上发布广告的 web 服务器。网络上可能有您不知道的 web 服务器,因为许多打印机都有用于配置和管理的内建 web 服务器。
如清单 3-25 所示,mdns-discover示例创建了一个MDNS实例,但没有声明名称。它安装了一个监控回调函数,当发现一个http服务时会通知它。对于找到的每个服务,它都会向设备的主页发出 HTTP 请求,并跟踪其 HTTP 头到调试控制台。
let mdns = new MDNS;
mdns.monitor("_http._tcp", function(service, instance) {
trace(`Found ${service}: "${instance.name}" @ ` +
`${instance.target} ` +
`(${instance.address}:${instance.port})\n`);
let request = new Request({
host: instance.address,
port: instance.port,
path: "/"
});
request.callback = function(msg, value, etc) {
if (Request.header === msg)
trace(` ${value}: ${etc}\n`);
else if (Request.responseComplete === msg)
trace("\n\n");
else if (Request.error === msg)
trace("error \n\n");
};
});
Listing 3-25.
回调函数的instance参数有几个用于设备的属性:
-
name–设备的可读名称 -
target–设备的 mDNS 名称(例如lightbulb.local) -
address–设备的 IP 地址 -
port–用于连接服务的端口
以下是该示例找到一台带有http服务的惠普打印机时的输出:
Found _http._tcp: "HP ENVY 7640 series"
@hpprinter.local (192.168.1.223:80)
server: HP HTTP Server; HP ENVY 7640 series - E4W44A;
content-type: text/html
last-modified: Mon, 23 Jul 2018 10:53:51 GMT
content-language: en
content-length: 658
为服务做广告
您的设备可以使用 DNS-SD 来宣传它提供的服务,这使得同一网络上的其他设备能够找到并使用这些服务。
$EXAMPLES/ch3-network/mdns-advertise示例定义了它在变量httpService中存储的 JavaScript 对象中提供的服务。服务描述说这个例子支持http服务,并使它在端口 80 上可用。清单 3-26 定义了 DNS-SD 的 HTTP 服务。
let httpService = {
name: "http",
protocol: "tcp",
port: 80
};
Listing 3-26.
然后,该示例创建一个MDNS实例来声明名称server。一旦名称被声明,清单 3-27 中的脚本将添加http服务。在声明名称之前不能添加服务,因为 DNS-SD 要求每个服务与一个 mDNS 名称相关联。
let mdns = new MDNS({
hostName: "server"
},
function(msg, value) {
if ((MDNS.hostName === msg) && value)
mdns.add(httpService);
}
);
Listing 3-27.
添加服务后,其他设备可能会找到它,如前面的“查找服务”一节所示
完整的mdns-advertise示例还包含一个简单的 web 服务器,它监听端口 80。当您运行这个示例时,您可以在 web 浏览器中输入server.local来查看 web 服务器的响应。
WebSocket
当您需要在设备之间频繁进行双向通信时, WebSocket 协议是 HTTP 的一个很好的替代方案。当两个设备使用 WebSocket 进行通信时,它们之间的网络连接保持打开,从而能够高效地进行简短消息的通信,例如发送传感器读数或开灯命令。在 HTTP 中,一个设备是客户端,一个是服务器;只有客户端可以发出请求,服务器总是会响应。另一方面,WebSocket 是一个对等协议,使两个设备都能够发送和接收消息。对于需要发送很多小消息的物联网产品,往往是个不错的选择。但是,因为它在两个设备之间始终保持连接,所以它通常比 HTTP 需要更多的内存。
WebSocket 协议由websocket模块实现,该模块同时包含 WebSocket 客户端和 WebSocket 服务器支持。您的项目可以根据需要导入一个或两个。
import {Client} from "websocket";
import {Server} from "websocket";
import {Client, Server} from "websocket";
因为 WebSocket 是一个对等协议,所以客户端和服务器的代码非常相似。主要区别在于初始设置。
连接到 WebSocket 服务器
$EXAMPLES/ch3-network/websocket-client示例使用了一个 WebSocket echo 服务器,它通过发回消息来回复收到的每条消息。WebSocket Client类构造函数接受一个配置字典。唯一需要的属性是host,服务器的名称。如果没有指定port属性,则假定 WebSocket 默认值为 80。
let ws = new Client({
host: "echo.websocket.org"
});
您可以通过为Socket属性传递SecureSocket来使用 TLS 建立一个安全的连接,正如前面“将 TLS 与SecureSocket类一起使用”一节中所解释的
您提供了一个回调函数来接收来自 WebSocket Client类的消息。WebSocket 协议比 HTTP 简单,所以回调也更简单。在websocket-client示例中,connect和close消息只是跟踪一条消息。WebSocket 协议的连接过程由两个步骤组成:当客户端和服务器之间建立网络连接时接收到connect消息,当客户端和服务器同意使用 WebSocket 进行通信时接收到handshake消息,表示连接已准备就绪。
当示例接收到handshake消息时,它发送第一条消息,一个带有count和toggle属性的 JSON 字符串。当 echo 服务器发回那个 JSON 时,清单 3-28 中的回调会用receive消息调用。它将字符串解析回 JSON,修改count和toggle值,并将修改后的 JSON 发送回 echo 服务器。这个过程无限重复,每次增加count。
ws.callback = function(msg, value) {
switch (msg) {
case Client.connect:
trace("connected\n");
break;
case Client.handshake:
trace("handshake success\n");
this.write(JSON.stringify({
count: 1,
toggle: true
}));
break;
case Client.receive:
trace(`received: ${value}\n`);
value = JSON.parse(value);
value.count += 1;
value.toggle = !value.toggle;
this.write(JSON.stringify(value));
break;
case Client.disconnect:
trace("disconnected\n");
break;
}
}
Listing 3-28.
下面是这段代码的输出:
connected
handshake success
received: {"count":1,"toggle":true}
received: {"count":2,"toggle":false}
received: {"count":3,"toggle":true}
received: {"count":4,"toggle":false}
...
对write的每个调用发送一个 WebSocket 消息。您可以在收到handshake消息后的任何时间发送消息,而不仅仅是从回调内部:
ws.write("hello");
ws.write(Uint8Array.of(1, 2, 3).buffer);
消息要么是字符串,要么是ArrayBuffer。当您收到一个 WebSocket 消息时,它要么是一个字符串,要么是一个ArrayBuffer,这取决于发送的内容。清单 3-29 展示了如何检查接收到的消息value的类型。
if (typeof value === "string")
...; // a string
if (value instanceof ArrayBuffer)
...; // an ArrayBuffer, binary data
Listing 3-29.
创建 WebSocket 服务器
$EXAMPLES/ch3-network/websocket-server示例实现了一个 WebSocket echo 服务器(也就是说,每当服务器接收到一条消息时,它都会发回相同的消息)。WebSocket Server类是用没有必需属性的字典配置的。可选的port属性表示监听新连接的端口;默认为 80。
let server = new Server;
清单 3-30 中的服务器回调函数接收与客户端相同的消息。在这个例子中,所有的消息都只是跟踪调试控制台的状态,除了receive,它将接收到的消息回显。
server.callback = function(msg, value) {
switch (msg) {
case Server.connect:
trace("connected\n");
break;
case Server.handshake:
trace("handshake success\n");
break;
case Server.receive:
trace(`received: ${value}\n`);
this.write(value);
break;
case Server.disconnect:
trace("closed\n");
break;
}
}
Listing 3-30.
这个服务器支持多个同时连接,当回调被调用时,每个连接都有一个惟一的this值。如果您的应用程序需要跨连接维护状态,它可以向this添加属性。当一个新的连接建立时,接收到connect消息;当连接结束时,接收到disconnect消息。
MQTT
消息队列遥测传输协议,或 MQTT ,是一种发布-订阅协议,旨在供轻量级物联网客户端设备使用。服务器(在 MQTT 中有时称为“代理”)更复杂,因此通常不会在资源受限的设备上实现。进出 MQTT 服务器的消息被组织成主题。一个特定的服务器可能支持许多主题,但是一个客户端只接收它订阅的主题的消息。
MQTT 协议的客户端由mqtt模块实现:
import MQTT from "mqtt";
连接到 MQTT 服务器
MQTT构造函数由一个带有三个必需参数的字典配置而成:host属性表示要连接的 MQTT 服务器,port是要连接的端口号,id是这个设备的惟一 ID。具有相同 ID 的两个设备连接到 MQTT 服务器是错误的,所以要注意确保它们是真正唯一的。清单 3-31 中的$EXAMPLES/ch3-network/mqtt示例摘录使用设备的 MAC 地址作为唯一 ID。
let mqtt = new MQTT({
host: "test.mosquitto.org",
port: 1883,
id: "iot_" + Net.get("MAC")
});
Listing 3-31.
如果 MQTT 服务器需要认证,那么user和password属性将被添加到配置字典中。密码总是二进制数据,所以清单 3-32 使用ArrayBuffer.fromString静态方法将字符串转换为ArrayBuffer。
let mqtt = new MQTT({
host: "test.mosquitto.org",
port: 1883,
id: "iot_" + Net.get("MAC"),
user: "user name",
password: ArrayBuffer.fromString("secret")
});
Listing 3-32.
要使用加密的 MQTT 连接,可以像前面“使用 TLS 保护连接”一节中描述的那样,通过向字典添加一个Socket属性和可选的secure属性来使用 TLS。
一些服务器使用 WebSocket 协议来传输 MQTT 数据。如果您使用的是这样的服务器,您需要指定path属性来告诉 MQTT 类端点要连接到哪个类,如清单 3-33 所示。通过 WebSocket 连接传输 MQTT 没有任何好处,并且会使用更多的内存和网络带宽,因此只有在远程服务器需要时才应该使用它。
let mqtt = new MQTT({
host: "test.mosquitto.org",
port: 8080,
id: "iot_" + Net.get("MAC"),
path: "/"
});
Listing 3-33.
MQTT 客户机有三个回调函数(清单 3-34 )。当成功建立到服务器的连接时,调用onReady回调;当收到消息时,调用onMessage;当连接丢失时,调用onClose。
mqtt.onReady = function() {
trace("connection established\n");
}
mqtt.onMessage = function(topic, data) {
trace("message received\n");
}
mqtt.onClose = function() {
trace("connection lost\n");
}
Listing 3-34.
一旦调用了onReady回调,MQTT 客户机就可以订阅消息主题和发布消息了。
订阅主题
要订阅主题,请向服务器发送要订阅的主题的名称。您的客户端可以通过多次调用subscribe来订阅多个客户端。
mqtt.subscribe("test/string");
mqtt.subscribe("test/binary");
mqtt.subscribe("test/json");
对于您的客户端已经订阅的所有主题,消息都被传递到onMessage回调函数。topic参数是主题的名称,data参数是完整的消息。
mqtt.onMessage = function(topic, data) {
trace(`received message on topic "${topic}"\n`);
}
data参数总是以二进制形式提供,作为一个ArrayBuffer。如果知道消息是字符串,可以转换成字符串;如果知道字符串是 JSON,可以将其转换成 JavaScript 对象。
data = String.fromArrayBuffer(data);
data = JSON.parse(data);
String.fromArrayBuffer是 XS 的一个特性,使应用程序更容易处理二进制数据。有一个并行的ArrayBuffer.fromString功能。这些都不是 JavaScript 语言标准的一部分。
发布到主题
要向主题发送消息,请使用字符串或ArrayBuffer调用publish:
mqtt.publish("test/string", "hello");
mqtt.publish("test/binary", Uint8Array.of(1, 2, 3).buffer);
要发布 JSON,首先将其转换为字符串:
mqtt.publish("test/json", JSON.stringify({
message: "hello",
version: 1
}));
网络时间协议
简单网络时间协议,或者 SNTP ,是一种检索当前时间的轻量级方法。您的电脑可能使用 SNTP(或其父 NTP)在幕后设置时间。与你的物联网设备不同,你的电脑也有一个由电池支持的实时时钟,所以它总是知道当前时间。如果您需要物联网设备上的当前时间,您需要检索它。如果您使用命令行方法连接到 Wi-Fi,并且您在命令行上指定了时间服务器,则一旦建立 Wi-Fi 连接,就会检索当前时间。
> mcconfig -d -m -p esp ssid="my wi-fi" sntp="pool.ntp.org"
当用代码连接 Wi-Fi 时,你还需要写一些代码来设置你的物联网设备的时钟。您使用 SNTP 协议获得当前时间,该协议在sntp模块中实现,您使用time模块设置设备的时间。
import SNTP from "sntp";
import Time from "time";
清单 3-35 显示了$EXAMPLES/ch3-network/sntp在 pool.ntp.org 向时间服务器请求当前时间的例子。当收到时间时,设备的时间被设置并在调试控制台中以 UTC(协调世界时)显示。由于不再需要,SNTP实例会关闭自己来释放它正在使用的资源。
new SNTP({
host: "pool.ntp.org"
},
function(msg, value) {
if (SNTP.time !== msg)
return;
Time.set(value);
trace("UTC time now: ",
(new Date).toUTCString(), "\n");
}
);
Listing 3-35.
大多数物联网产品都有一个 SNTP 服务器列表,以备其中一个不可用时使用。SNTP类支持这种场景,而不需要创建SNTP类的额外实例。参见可修改的 SDK 中的examples/network/sntp示例,了解如何使用这个故障转移特性。
高级主题
本节介绍两个高级主题:如何将您的设备变成一个私人 Wi-Fi 基站,以及如何将 JavaScript promises 与网络 API 结合使用。
创建 Wi-Fi 接入点
有时,您不想将您的物联网产品连接到整个互联网,但您确实希望让人们连接到您的设备来配置它或检查它的状态。在其他时候,您确实想将您的设备连接到互联网,但是您还没有 Wi-Fi 接入点的名称和密码。在这两种情况下,创建一个私人 Wi-Fi 接入点可能是一个解决方案。除了作为连接到其他接入点的 Wi-Fi 客户端之外,许多物联网微控制器(包括 ESP32 和 ESP8266)也可以作为接入点。
您可以通过调用WiFi类的静态accessPoint方法,将您的物联网设备变成接入点:
WiFi.accessPoint({
ssid: "South Village"
});
ssid属性定义了接入点的名称,并且是唯一必需的属性。如清单 3-36 所示,可选属性使您能够设置密码,选择要使用的 Wi-Fi 信道,并隐藏接入点使其不出现在 Wi-Fi 扫描中。
WiFi.accessPoint({
ssid: "South Village",
password: "12345678",
channel: 8,
hidden: false
});
Listing 3-36.
设备可以是接入点,也可以是接入点的客户端。两者不能同时存在,所以一旦进入接入点模式,就无法访问互联网。
您可以在接入点上提供一个 web 服务器,如前面的“响应请求”一节所示在清单 3-37 中,从$EXAMPLES/ch3-network/accesspoint的例子来看,HTTP Server类的导入有一点不同,因为它将类重命名或别名为HTTPServer,以避免与 DNS 服务器的名称冲突(在下面的例子中介绍)。
import {Server as HTTPServer} from "http";
(new HTTPServer).callback = function(msg, value) {
if (HTTPServer.prepareResponse === msg) {
return {
headers: ["Content-Type", "text/plain"],
body: "hello"
};
}
}
Listing 3-37.
其他设备如何知道您的 web 服务器的地址以便连接到它?你可以用 mDNS 来命名一个地方。但由于你的物联网产品是接入点,它现在也是网络的路由器,所以它可以解析 DNS 请求。这意味着每当网络上的一个设备查找一个名字,比如www.example.com,您的应用程序就可以将请求定向到您的 HTTP 服务器。清单 3-38 是一个简单的 DNS 服务器,它就是这样做的。
import {Server as DNSServer} from "dns/server";
new DNSServer(function(msg, value) {
if (DNSServer.resolve === msg)
return Net.get("IP");
});
Listing 3-38.
DNS Server类构造函数将回调函数作为其唯一的参数。每当连接到接入点的任何设备试图解析 DNS 名称时,回调函数就用resolve消息调用。作为响应,回调提供自己的 IP 地址。当大多数电脑或手机连接到新的 Wi-Fi 点时,它们会执行检查以查看它们是否已连接到互联网,或者是否需要登录。当在您的访问点上执行此检查时,它将导致您的 web 服务器的访问点被调用以显示网页。在这个例子中,它将简单地显示hello,但是你可以改变它来显示设备状态,配置 Wi-Fi,或者任何你喜欢的东西。
承诺和异步函数
承诺是 JavaScript 的一个特性,可以用回调函数简化编程。回调函数简单而高效,这也是为什么它们被广泛使用的原因。承诺可以提高使用回调函数执行一系列步骤的代码的可读性。
本节并不是对承诺和异步函数的完整介绍。如果您不熟悉这些 JavaScript 特性,请通读这一部分,看看它们是否对您的项目有用;如果是的话,网上有很多优秀的资源可以帮助你了解更多。
清单 3-39 中的$EXAMPLES/ch3-network/http-get-with-promise示例摘录基于 HTTP Request类来实现一个fetch函数,该函数以字符串形式返回一个完整的 HTTP 请求。
function fetch(host, path = "/") {
return new Promise((resolve, reject) => {
let request = new Request({host, path, response: String});
request.callback = function(msg, value) {
if (Request.responseComplete === msg)
resolve(value);
else if (Request.error === msg)
reject(-1);
}
});
}
Listing 3-39.
fetch函数的实现很复杂,需要深入理解承诺在 JavaScript 中是如何工作的。但是使用fetch函数很容易(列出 3-40 )。
function httpTrace(host, path) {
fetch(host, path)
.then(body => trace(body, "\n"))
.catch(error => trace("http get failed\n"));
}
Listing 3-40.
阅读httpTrace的代码,您可能会认为 HTTP 请求是同步发生的,但事实并非如此,因为所有的网络操作都是非阻塞的。当请求完成时,传递给.then和.catch调用的箭头函数被执行——如果调用成功,则执行.then,如果调用失败,则执行.catch。
JavaScript 的最新版本提供了另一种编写代码的方式:作为异步函数。清单 3-41 显示了在异步函数中重写的对fetch的调用。除了关键字async和await,代码看起来像普通的 JavaScript。
async function httpTrace(host, path) {
try {
let body = await fetch(host, path);
trace(body, "\n");
}
catch {
trace("http get failed\n");
}
}
Listing 3-41.
httpTrace函数是异步的,所以它在被调用时会立即返回。在调用fetch之前的关键字await告诉 JavaScript 语言,当fetch返回一个承诺时,应该暂停httpTrace的执行,直到承诺准备好(解决或拒绝)。
承诺和异步函数是强大的工具,它们在 JavaScript 代码中用于更强大的系统,包括 web 服务器和计算机。它们可用于您的物联网项目,甚至在资源受限的设备上,因为您使用的是 XS JavaScript 引擎。尽管如此,在大多数情况下,回调函数是首选,因为它们需要更少的代码,执行速度更快,并且使用更少的内存。在构建项目时,您需要确定使用它们的便利性是否超过了所使用的额外资源。
结论
在本章中,您已经了解了物联网设备通过网络进行通信的各种方式。本章中描述的不同协议都遵循相同的基本 API 模式:
-
协议的类提供了一个构造器,它接受一个字典来配置连接。
-
回调函数将信息从网络传递到应用程序。
-
通信总是异步的,以避免阻塞,这是物联网产品的一个重要考虑因素,这些产品并不总是拥有多线程执行的奢侈。
-
使用小的助手函数可以将回调转化为承诺,这样应用程序就可以使用现代 JavaScript 中的异步函数。
你作为一个物联网产品的开发者,需要决定它支持的通信方式。有许多因素要考虑。如果您希望您的设备与云通信,HTTP、WebSocket 和 MQTT 都是可能的选择,它们都支持使用 TLS 的安全通信。对于直接的设备到设备通信,mDNS 是一个很好的起点,使设备能够宣传它们的服务,而 HTTP 是一种在设备之间交换消息的轻量级方法。
当然,您的产品不必只选择一种网络协议进行通信。从本章的例子开始,你已经准备好尝试不同的 protools 来找到最适合你的设备需求的。
四、蓝牙低能耗(BLE)
有许多方法可以实现设备之间的无线通信。第三章介绍了许多通过 Wi-Fi 连接与世界上任何地方的设备通信的协议。本章重点介绍蓝牙低能耗或 BLE ,这是一种广泛应用于两个相互靠近的设备之间的无线通信。如果最大限度地减少能源使用特别重要,例如在电池供电的产品中,以及当与另一设备(如手机)直接通信是互联网接入的可接受替代方案时,产品会选择使用 BLE 而不是 Wi-Fi。从心率监测器到电动牙刷到烤箱,许多物联网产品都使用 BLE。产品制造商通常提供移动应用程序或桌面配套应用程序来监控或控制这些产品。
BLE 是蓝牙标准的第 4 版,于 2010 年首次推出。最初的蓝牙是在 2000 年标准化的,用于短距离发送数据流。BLE 显著降低了原始蓝牙的能耗,使其在单次充电后能够工作更长时间。BLE 部分通过减少传输的数据量来实现这一点。传输距离越短,消耗的能量也越少;BLE 设备的覆盖范围通常不超过 100 米,而 Wi-Fi 的覆盖范围要大得多。BLE 的低功耗和低成本使其非常适合许多物联网产品。
使用本章中的信息,你可以构建自己的运行在微控制器上的 BLE 设备。
Note
本章中的示例适用于 ESP32。如果您尝试为 ESP8266 构建它们,构建将会失败,因为 ESP8266 没有 BLE 硬件。然而,这些例子确实可以在其他集成了可修改的 SDK 支持的 BLE 的设备上运行,包括高通的 QCA4020 和硅实验室的 Blue Gecko。
BLE 基础知识
如果您是使用 BLE 的新手,本节中的信息是必不可少的,因为它解释了本章其余部分中使用的概念。如果你熟悉 BLE,仍然可以考虑快速浏览一下这一部分,以熟悉本书中使用的术语,以及它与可修改 SDK 的 BLE API 的关系。
gap 中心和外围设备
通用访问配置文件或间隙定义了设备如何自我宣传,它们如何彼此建立连接,以及安全性。GAP 定义的两个主要角色是中枢和外围 。
中央扫描充当外围设备的设备,并发起与外围设备建立新连接的请求。充当中央设备的设备通常具有相对较高的处理能力和大量内存,例如智能手机、平板电脑或电脑,而外围设备通常体积较小,使用电池供电。外围设备自我广告并接受建立连接的请求。
BLE 规范允许一个中心连接到多个外围设备,一个外围设备连接到多个中心。中央处理器同时连接到几个外围设备是很常见的。例如,您可以使用智能手机连接到您的心率监测器、智能手表和灯。一个外围设备同时连接到多个中央设备是不常见的;大多数外设不允许多个并发连接。可修改的 SDK 的 BLE API 允许外围设备一次连接一个中央设备。
GATT 客户端和服务器
通用属性配置文件,或 GATT ,定义了 BLE 设备之间建立连接后来回传输数据的方式——客户端-服务器关系。
一个 GATT 客户端是一个通过发送读/写请求从远程 GATT 服务器访问数据的设备。一个 GATT 服务器是一个本地存储数据,接收读/写请求,并通知远程 GATT 客户机其特性值的变化的设备。在本章中,术语服务器用于表示 GATT 服务器,而客户机表示 GATT 客户机。
GAP 与关贸总协定
许多 BLE 教程错误地将术语中央和客户端互换使用,并将术语外围设备和服务器互换使用。这是因为中央系统通常承担客户端角色,而外围设备通常承担服务器角色。然而,BLE 规范指出,中心或外围设备可以担当客户机、服务器或两者的角色。
中央和外围是由 GAP 定义的术语,告诉您 BLE 连接是如何管理的。客户端和服务器是 GATT 定义的术语,告诉你连接建立后数据的存储和流动。只有在 GAP 所定义的广告和连接过程完成之后,GATT 才会出现。
概况、服务和特征
关贸总协定还定义了数据的格式,有一个层次的简档、服务、和特征。如图 4-1 所示,层级的顶层是一个概要。

图 4-1
关贸总协定概况层次结构
轮廓
一个配置文件定义了 BLE 在多个设备间通信的特定用途,包括相关设备的角色及其一般行为。例如,标准健康温度计配置文件定义了温度计设备(传感器)和收集器设备的角色;温度计装置测量温度,收集器装置从温度计接收温度测量值和其他数据。该配置文件指定温度计必须实例化的服务(健康温度计服务和设备信息服务),并声明该配置文件的预期用途是在医疗保健应用中。
BLE 设备上不存在配置文件;相反,它们是由 BLE 设备实现的规范。官方采用的 BLE 简介列表可在 bluetooth.com/specifications/gatt 获得。当您实现自己的 BLE 设备时,最好检查是否有满足您产品需求的标准配置文件。如果有的话,您将受益于与支持该标准的其他产品的互操作性,从而节省您设计新概要文件的时间。
服务
一个服务是描述 BLE 设备一部分行为的特征集合。例如,健康温度计服务提供来自温度计设备的温度数据。服务可以具有一个或多个特征,并通过 UUID 来区分。官方采用的 BLE 服务有 16 位 UUIDs。健康温度计服务的编号为0x1809。您可以通过为它们提供 128 位 UUID 来创建自己的自定义服务。
服务由 BLE 设备进行广告。官方采用的 BLE 服务列表可在 bluetooth.com/specifications/gatt/services 获得。
特征
一个特征是一个提供 GATT 服务信息的单个值或数据点。数据的格式取决于特征;例如,心率服务使用的心率测量特征提供整数形式的心率测量值,而设备名称特征提供字符串形式的设备名称。
官方采用的 BLE 特征列表可在 bluetooth.com/specifications/gatt/characteristics 获得。与服务一样,官方采用的 BLE 特征有 16 位 UUID,您可以使用 128 位 UUID 创建自己的 uuid。
可修改的 SDK 的 BLE API
对于 GAP 和 GATT 定义的角色,可修改的 SDK 在其 BLE API 中没有明确的类。相反,它在单个BLEClient类中为 Centrals 和 GATT 客户机提供功能,在单个BLEServer类中为外设和 GATT 服务器提供功能。此类组织反映了 BLE 设备的两种最常见的配置:充当中心并承担客户端角色的设备,以及充当外围设备并承担服务器角色的设备。
BLEClient类
BLEClient类提供了用于创建 Centrals 和 GATT 客户端的函数。中心的功能执行以下操作:
-
扫描外围设备。
-
发起与外围设备建立连接的请求。
GATT 客户端的函数执行这些操作:
-
找到感兴趣的关贸总协定服务。
-
在每项服务中寻找感兴趣的特征。
-
读取、写入和启用每个服务中特征的通知。
您可以子类化BLEClient类来实现特定的 BLE 设备,该设备支持您的设备所需的操作。子类调用BLEClient类的方法来启动前面的操作。由BLEClient执行的所有 BLE 操作都是异步的,以避免在不确定的时间内阻塞执行。因此,BLEClient类的实例通过回调接收结果。例如,BLEClient类有一个startScanning方法,您可以调用它来开始扫描外设,还有一个onDiscovered回调函数,当发现外设时会自动调用。
您只需要实现处理设备所需的外围设备、服务和特性所需的回调。
BLEServer类
BLEServer类提供了用于创建外设和 GATT 服务器的函数。外围设备的功能执行以下操作:
-
广告以便中心可以发现外围设备。
-
接受来自中心的连接请求。
GATT 服务器的功能执行这些操作:
-
部署服务。
-
响应来自客户端的特征读取和写入请求。
-
接受来自客户端的特征值更改通知请求。
-
通知客户特征值的变化。
您可以实施标准的 BLE 模式,如心率或您自己定制的模式,以支持您产品的独特功能。在这两种情况下,首先在 JSON 文件中定义 GATT 服务,然后子类化BLEServer类来实现特定的 BLE 设备。子类调用BLEServer类的方法来启动前面的操作。由BLEServer执行的所有 BLE 操作都是异步的,以避免在不确定的时间内阻塞执行。因此,BLEServer类的实例通过回调接收结果。
安装 BLE 主机
本章中的示例使用第一章中描述的模式进行安装:您使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。
主机在$EXAMPLES/ch4-ble/host目录中。从命令行导航到这个目录,用mcconfig安装它。
创建 BLE 扫描仪
$EXAMPLES/ch4-ble/scanner示例实现了一个中心,它扫描附近的外围设备,并跟踪它们的名称到控制台。它是使用BLEClient类实现的。清单 4-1 展示了这个例子的大部分源代码。
class Scanner extends BLEClient {
onReady() {
this.startScanning();
}
onDiscovered(device) {
let scanResponse = device.scanResponse;
let completeName = scanResponse.completeName;
if (completeName)
trace(`${completeName}\n`);
}
}
Listing 4-1.
Scanner类实现了两个BLEClient回调:
-
当 BLE 栈准备好使用时,调用
onReady回调。在这个例子中,onReady回调函数调用startScanning来扫描附近的外设。 -
对于每个被发现的外设,
onDiscovered回调被调用一次或多次。在这个例子中,onDiscovered回调跟踪发现的外围设备的名称到控制台。
在这个简单的例子中,您的中心会发现您周围的外围设备,并告诉您它们的名称。现在您可以更进一步了:下一个例子演示了如何使用BLEClient类的其他特性来创建一个与虚拟外设通信的 BLE 设备。
创造双向交流
$EXAMPLES/ch4-ble/text-client示例实现了一个连接到外设并通过特征值变化通知接收文本数据的中心。
要查看示例的工作情况,您需要一个提供文本数据特征的外设。你可以使用 Bluefruit 创建一个,这是一个在 iOS 和 Android 设备上免费提供的移动应用程序。要创建外设,采取以下步骤,如图 4-2 和 4-3 所示:

图 4-2
蓝果的周边模式
- 下载打开 Bluefruit,进入外设模式。在广告信息部分,将本地名称字段更改为
esp。

图 4-3
UART 服务使能
- 确定 UART 服务已打开。
接下来的部分解释了在 ESP32 上运行以实现中央设备的代码。
连接到外围设备
应用程序顶部的常数对应于您在 Bluefruit 中设置的设备名称以及 UART 服务使用的服务和特征 UUID:
const PERIPHERAL_NAME = 'esp';
const SERVICE_UUID = uuid`6E400001B5A3F393E0A9E50E24DCCA9E`;
const CHARACTERISTIC_UUID = uuid`6E400003B5A3F393E0A9E50E24DCCA9E`;
和scanner例子一样,这个例子实现了onReady和onDiscovered回调,如清单 4-2 所示。但是这个例子并不只是跟踪设备的名称到控制台,而是检查每个发现的外围设备的名称,看它是否与PERIPHERAL_NAME常量匹配。如果是,它停止扫描外围设备并调用connect方法,该方法在BLEClient和目标外围设备之间发起连接请求。
class TextClient extends BLEClient {
onReady() {
this.startScanning();
}
onDiscovered(device) {
if (PERIPHERAL_NAME ===
device.scanResponse.completeName) {
this.stopScanning();
this.connect(device);
}
}
...
Listing 4-2.
connect的参数是Device类的一个实例,代表一个外围设备。BLEClient在发现外设时自动创建Device类的实例;应用程序不会直接实例化它们。然而,应用程序确实与Device类的实例直接交互——例如,通过调用方法来执行 GATT 服务和特征发现。
onConnected回调
onConnected方法是当中央处理器连接到外设时调用的回调。这个例子调用了device对象的discoverPrimaryService方法来从外围设备获取主要的 GATT 服务。discoverPrimaryService的参数是要发现的服务的 UUID。
onConnected(device) {
device.discoverPrimaryService(SERVICE_UUID);
}
您可以使用discoverAllPrimaryServices方法发现外围设备的所有主要服务。例如,onConnected回调可以写成如下形式:
onConnected(device) {
device.discoverAllPrimaryServices();
}
onServices回调
onServices方法是服务发现完成时调用的回调。services参数是一组service对象——Service类的实例——每个对象都提供对单个服务的访问。如果调用discoverPrimaryService来查找单个服务,服务数组只包含找到的一个服务。
如清单 4-3 所示,该示例检查外设是否提供了与SERVICE_UUID常量定义的 UUID 相匹配的服务。如果是的话,它调用service对象的discoverCharacteristic方法来寻找 UUID 与CHARACTERISTIC_UUID常量定义的服务特征相匹配的服务特征。
onServices(services) {
let service = services.find(service =>
service.uuid.equals(SERVICE_UUID));
if (service) {
trace(`Found service\n`);
service.discoverCharacteristic(CHARACTERISTIC_UUID);
}
else
trace(`Service not found\n`);
}
Listing 4-3.
您可以使用discoverAllCharacteristics方法发现所有的服务特征。例如,onServices回调可以用下面的代码行替换调用discoverCharacteristic的代码行:
service.discoverAllCharacteristics();
onCharacteristics回调
onCharacteristics方法是在特性发现完成时调用的回调。characteristics参数是一组characteristic对象——Characteristic类的实例——每个对象都提供对单个服务特征的访问。如果调用discoverCharacteristic来查找单个特征,那么特征数组包含所找到的单个特征。
当找到所需的特性时,该示例调用characteristic对象的enableNotifications方法,以在特性的值改变时启用通知,如清单 4-4 所示。
onCharacteristics(characteristics) {
let characteristic = characteristics.find(characteristic => characteristic.uuid.equals(CHARACTERISTIC_UUID));
if (characteristic) {
trace(`Enabling notifications\n`);
characteristic.enableNotifications();
}
else
trace(`Characteristic not found\n`);
}
Listing 4-4.
如果您正确设置了外围设备,当您运行text-client应用程序时,您将在调试控制台中看到以下消息:
Found service
Enabling notifications
接收通知
启用通知后,您可以通过从智能手机更改外围设备的特征值来向客户端发送通知。要更改该值,点击 UART 按钮。这将带您进入图 4-4 所示的屏幕。在屏幕底部的输入字段中输入文本,点击发送更新特征值。

图 4-4
Bluefruit 中的 UART 屏幕
特征值通过ArrayBuffer中的onCharacteristicNotification回调传递给客户端。这个例子假设值是一个字符串,所以它使用String.fromArrayBuffer(XS 的一个特性,使应用程序更容易处理二进制数据)将ArrayBuffer转换成一个字符串。有一个平行的ArrayBuffer.fromString。这些都不是 JavaScript 语言标准的一部分。
onCharacteristicNotification(characteristic, buffer) {
trace(String.fromArrayBuffer(buffer)+"\n");
}
创建心率监测器
现在您已经了解了实现从服务器接收通知的客户机的基本知识,这个例子将向您展示如何使用BLEServer类的特性来实现一个外围设备,该外围设备在连接到中央服务器之后承担服务器的角色。
$EXAMPLES/ch4-ble/hrm示例宣传标准心率和电池服务,接受来自 Centrals 的连接请求,发送模拟心率值的通知,并响应来自客户端的模拟电池电量的读取请求。接下来的几节解释了如何使用BLEServer类实现它。
定义和部署服务
GATT 服务是在位于主机的bleservices目录中的 JSON 文件中定义的。JSON 在构建时会自动转换成特定于平台的本机代码,编译后的目标代码会链接到应用程序。
每个 GATT 服务都是在自己的 JSON 文件中定义的。清单 4-5 显示了标准心率服务。
{
"service": {
"uuid": "180D",
"characteristics": {
"bpm": {
"uuid": "2A37",
"maxBytes": 2,
"type": "Array",
"permissions": "read",
"properties": "notify"
}
}
}
}
Listing 4-5.
以下是对一些重要属性的解释:
-
对象的属性是 GATT 规范分配给服务的号码。心率服务有 UUID
180F。 -
characteristics对象描述了服务支持的每个特征。每个直接属性是一个特征的名称。在这个例子中,只有一个特征:bpm,代表每分钟的节拍数。 -
一个
characteristic对象的uuid属性是由 GATT 规范分配给特征的唯一数字。心率服务的bpm特征有 UUID2A37。 -
属性指定了 JavaScript 代码中使用的特征值的类型。
BLEServer类使用type属性的值将客户端传输的二进制数据转换为 JavaScript 类型。这为您的服务器代码节省了在不同类型的数据之间来回转换的工作(ArrayBuffer、String、Number等等)。 -
permissions属性定义特性是只读、只写还是读/写,以及访问特性是否需要加密连接。bpm属性是只读的,因为在心率监视器中,每分钟的心跳数是由传感器读数决定的,因此不能由客户端写入。read权限表示客户端可以通过未加密或加密的连接读取特征;当值只能通过加密连接访问时,使用readEncrypted。类似地,使用write或writeEncrypted获得写权限。要表明某个特征同时支持读取和写入,请在权限字符串中包含读取和写入值,用逗号分隔,例如"readEncrypted,writeEncrypted"。 -
properties属性定义了特征的属性。可能是read、write、notify、indicate,或者是这些的组合(逗号分隔)。read和write值允许读取和写入特征值,notify允许服务器通知客户端特征值的变化,而无需请求和确认通知已被接收,并且indicate与notify相同,除了它需要确认通知已被接收,然后才能发送另一个指示。
一旦 BLE 栈完成初始化,它就调用onReady回调。onReady的hrm示例实现发起广告,使其服务能够被客户端发现。下一节解释了当广告激活时子类如何管理。
广告
外围设备广播广告数据来宣告它们自己。BLEServer类有一个startAdvertising方法开始广播广告包,还有一个stopAdvertising方法停止广播。
当 BLE 栈准备好使用时,以及当到中心的连接丢失时,hrm示例调用startAdvertising。当startAdvertising被调用时,外设广播它的广告数据类型标志值、它的名称和它的服务(心率和电池),如清单 4-6 所示。心率和电池服务的 UUIDs 来自 GATT 规范。
this.startAdvertising({
advertisingData: {
flags: 6,
completeName: this.deviceName,
completeUUID16List: [uuid`180D`, uuid`180F`]
}
});
Listing 4-6.
当成功建立到中心的连接时,外围设备停止发送广告数据包,因为它一次只支持一个连接:
onConnected() {
this.stopAdvertising();
}
当连接丢失时,外围设备再次开始广告。
BLE 广告可能包含附加数据,例如,实现 BLE 信标。BLE 信标为许多中心做广告,让他们不用连接就能看到数据。清单 4-7 中的代码来自可修改 SDK 中的examples/network/ble/uri-beacon示例,它实现了一个为可修改网站做广告的 UriBeacon。这里的 UUID 来自于赋值的数字规范(见 bluetooth.com/specifications/assigned-numbers/16-bit-uuids-for-members )。encodeData方法以 UriBeacon 规范指定的格式对 URI 进行编码。源代码见uri-beacon示例。
this.startAdvertising({
advertisingData: {
completeUUID16List: [uuid`FED8`],
serviceDataUUID16: {
uuid: uuid`FED8`,
data: this.encodeData("http://www.moddable.com")
}
}
});
Listing 4-7.
广告数据只传输数据;没有办法回复。双向通信要求一台设备承担 GATT 客户端角色,另一台设备承担 GATT 服务器角色。在这发生之前,由 GAP 定义的连接过程必须完成。
建立连接
一旦心律外围设备开始发布广告,它就会等待中央设备请求与其连接。你可以使用各种各样的移动应用程序来创建一个这样的中心。在本节中,您将使用 LightBlue,它可以在 iOS 和 Android 设备上免费获得。LightBlue 具有中央模式,使您能够扫描、连接外围设备并向其发送读/写请求。通过执行以下步骤,您可以将其用作外围设备的客户端:

图 4-5
浅蓝色外设列表中的心率监测器外设
-
在您的 ESP32 上运行该示例。
-
下载并打开 LightBlue,等待心率监测器外设出现,如图 4-5 所示。
-
轻触心率监测器外围设备,与其建立连接。
当中央处理器连接到心率外设时,会调用onConnected回调。在本例中,它停止广播广告并响应扫描响应包,如清单 4-8 所示。
class HeartRateService extends BLEServer {
...
onConnected() {
this.stopAdvertising();
}
...
}
Listing 4-8.
发送通知
BLE 客户端可以请求具有notify属性的特征的通知,例如本例中的bpm特征。启用通知时,服务器会通知客户端特性值的更改,而无需服务器请求该值。通知节省了能量,这是 BLE 设计的一个关键特性,因为它消除了客户端轮询服务器以了解特征变化的需要。
要接收浅蓝色的模拟心率通知,请采取以下步骤(如图 4-6 、 4-7 和 4-8 所示):

图 4-6
带有心率测量按钮的心率监护仪特征屏幕
- 点击心率测量特性。

图 4-7
带有收听通知按钮的心率测量屏幕
- 轻触收听通知以启用通知。

图 4-8
出现在通知值部分的心率值
- 观察模拟心率值的出现。
现在让我们看看在服务器端实现心率服务通知的代码。onCharacteristicNotifyEnabled方法是一个回调函数,当客户端启用某个特性的通知时,就会调用这个函数。onCharacteristicNotifyDisabled方法是一个回调函数,当客户端禁用某个特性的通知时会调用这个函数。两者的characteristic参数都是Characteristic类的一个实例,它提供了对单个服务特征的访问。
onCharacteristicNotifyEnabled方法(如清单 4-9 所示)调用notifyValue方法,该方法以 1000 毫秒(1 秒)的间隔向连接的客户端发送特征值变化通知。这模拟了心率传感器,尽管真实的心率监测器不会发送定期更新;相反,当值实际改变时,它会发送一个通知。
onCharacteristicNotifyEnabled(characteristic) {
this.bump = +1;
this.timer = Timer.repeat(id => {
this.notifyValue(characteristic, this.bpm);
this.bpm[1] += this.bump;
if (this.bpm[1] === 65) {
this.bump = -1;
this.bpm[1] = 64;
}
else if (this.bpm[1] === 55) {
this.bump = +1;
this.bpm[1] = 56;
}
}, 1000);
}
Listing 4-9.
onCharacteristicNotifyDisabled方法(清单 4-10 )通过调用stopMeasurements方法结束通知的发送。
onCharacteristicNotifyDisabled(characteristic) {
this.stopMeasurements();
}
...
stopMeasurements() {
if (this.timer) {
Timer.clear(this.timer);
delete this.timer;
}
this.bpm = [0, 60]; // flags, beats per minute
}
Listing 4-10.
响应读取请求
客户端可能会请求支持read属性的特征值,例如本例中的电池服务。要发送读取浅蓝色模拟电池电量值的请求,请采取以下步骤(如图 4-9 、 4-10 和 4-11 所示):

图 4-9
带电池电量按钮的心率监测器特征屏幕
- 点击电池电量特性。

图 4-10
电池电量屏幕,带有再次读取按钮
- 点击再次读取。

图 4-11
显示在读取值部分的电池电量值
- 观察模拟电池电量的显示。
现在让我们来看看处理电池电量服务通知的代码。onCharacteristicRead方法(清单 4-11 )是一个回调函数,当客户端根据需要读取服务特征值时会调用它。BLEServer实例负责处理读请求。在本例中,电池电量从 100 开始;每次读取该值时,回调函数都会返回值并减 1。
onCharacteristicRead(params) {
if (params.name === "battery") {
if (this.battery === 0)
this.battery = 100;
return this.battery--;
}
}
Listing 4-11.
建立安全通信
可修改的 SDK 支持蓝牙核心规范 4.2 版中引入的增强安全特性:通过数字比较、密钥输入和 Just Works 配对方法实现安全连接。BLEClient和BLEServer类都有一个可选的securityParameters属性,请求设备建立一个 LE 安全连接。使用的配对方法取决于设备的功能和在securityParameters属性中设置的选项。安全回调函数由BLEClient和BLEServer类托管。下一节将介绍一个简单的例子。
安全心率监测器
$EXAMPLES/ch4-ble/hrm-secure示例是$EXAMPLES/ch4-ble/hrm示例的安全版本,它需要输入密码进行配对。
同样,您可以使用 LightBlue 作为客户端。按照与之前相同的步骤,当心率监测器提示您输入密码时(如图 4-12 ,在xsbug中输入追踪到控制台的密钥(如图 4-13 )。

图 4-13
调试控制台中的密钥

图 4-12
以浅蓝色提示输入代码
现在,您可以像以前一样启用心率值通知并按需读取电池值,但服务器和客户端之间的连接是安全的。
这段代码与$EXAMPLES/ch4-ble/hrm的例子有一些不同。如清单 4-12 所示,onReady回调包含配置设备安全需求和外设 I/O 能力的附加代码。
this.securityParameters = {
bonding: true,
mitm: true,
ioCapability: IOCapability.DisplayOnly
};
Listing 4-12.
此代码中的属性指定了以下内容:
-
属性启用绑定,这意味着两个设备将在下次连接时存储和使用它们交换的密钥。如果不启用绑定,设备每次连接时都必须进行配对。
-
mitm属性请求中间人保护,这意味着两个配对设备之间交换的数据被加密,以防止不受信任的设备窃听。 -
ioCapability属性指示与确认密钥相关的设备的用户界面功能。这个设备没有显示器,但是它有显示功能,因为它可以跟踪调试控制台。其他外围设备可能具有更多的输入/输出能力(例如,带有键盘的设备)或更少的输入/输出能力(例如,无法显示文本的设备)。两个设备的ioCapability属性用于确定配对方法。例如,如果两个设备都没有键盘或显示器,则使用 Just Works 配对方法。
实现了另外两个BLEServer类的回调函数(参见清单 4-13 ):
-
当您试图建立与外设的连接时,会调用
onPasskeyDisplay回调。在这种情况下,当您点击浅蓝色外围设备的名称时,就会调用它。正如您之前看到的,这个示例跟踪调试控制台的密钥。 -
设备配对成功后会调用
onAuthenticated回调。这个例子简单地改变了authenticated属性来表明一个安全的连接已经建立。
onPasskeyDisplay(params) {
let passkey = this.passkeyToString(params.passkey);
trace(`server display passkey: ${passkey}\n`);
}
onAuthenticated() {
this.authenticated = true;
}
Listing 4-13.
当客户端启用通知时,服务器检查是否设置了authenticated属性。if块中的代码看起来与hrm示例中的onCharacteristicNotifyEnabled方法相同。
onCharacteristicNotifyEnabled(characteristic) {
if (this.authenticated) {
...
}
服务器还定义了一个额外的助手方法,名为passkeyToString。密钥值是整数,向用户显示时必须始终包含六位数字。此方法在必要时用前导零填充密钥以供显示。
passkeyToString(passkey) {
return passkey.toString().padStart(6, "0");
}
结论
现在你已经理解了这些例子的要点,你可以在你的 ESP32 上用 BLE 做很多事情。您可以连接到家中的 BLE 产品,而不是连接到您在 LightBlue 中创建的虚拟外围设备。您可以从自己喜欢的现成传感器发送实际的传感器数据,而不是像心率监测器示例那样发送模拟数据。
如果你想尝试更多 BLE 的例子,可以在 GitHub 上的 Moddable SDK 中查看examples/network/ble目录。让您的设备成为 URI 传输信标、将您的设备与 iPhone 音乐应用程序配对,等等。如果你想了解更多关于可修改的 SDK 的 BLE API,请看documentation/network/ble目录。
五、文件和数据
几乎每个产品都有一些数据需要确保在设备重启时可用,即使断电也是如此。在微控制器上,闪存通常用于这种非易失性存储(NVS)存储器。保存应用程序代码的同一闪存也存储应用程序使用的数据和它创建的数据。以下是您的应用程序可能存储的一些数据类型:
-
只读数据,例如构成产品用户界面的图像或包含由嵌入式 web 服务器提供的静态网页的文件
-
读写的小块数据—例如,用户偏好和其他长期状态
-
产品监控操作时创建的大量数据,例如,从传感器收集数据时
在计算机和移动设备上,通常使用文件系统来满足大多数(如果不是全部)数据存储需求。然而,由于嵌入式系统的限制——代码大小限制、高度受限的 RAM 和严格的性能约束——固件通常甚至不包括文件系统。
本章解释了在嵌入式系统上处理存储数据的三种不同方式:文件、首选项和资源。最后一节介绍对闪存的直接访问,这是一种提供最大灵活性的高级技术。
构建产品时,选择最符合您需求的数据存储方法。在假设文件是正确的选择之前,请考虑首选项和资源,它们是处理存储数据的轻量级方式。
安装文件和数据主机
你可以按照第一章中描述的模式运行本章提到的所有示例:使用mcconfig在你的设备上安装主机,然后使用mcrun安装示例应用。
主机在$EXAMPLES/ch5-files/host目录中。从命令行导航到这个目录,用mcconfig安装它。
文件
ESP32 和 ESP8266 使用 SPIFFS(串行外设接口闪存文件系统)作为其闪存中的文件系统。SPIFFS 专门设计用于许多微控制器使用的 NOR(非或)闪存。虽然 SPIFFS 的功能远不如计算机上的文件系统全面,但它提供了您需要的所有基本功能。
在嵌入式设备上使用文件时,一定要记住文件系统实现的这些限制:
-
SPIFFS 是一个平面文件系统,这意味着没有真正的目录。所有文件都在 SPIFFS 根目录下。
-
文件名限制为 32 个字节。
-
没有文件权限或锁定。所有文件都可以被读取、写入和删除。
-
写操作的时间长度是不可预测的。它通常很快,但是当文件系统需要整合数据块时,它可能会阻塞一段时间。
本节重点介绍如何使用 SPIFFS 访问文件,SPIFFS 不需要添加任何硬件,代码量相对较小。在 ESP32 上,这些相同的 API 也可用于访问使用 FAT32 文件系统格式化的 SD 存储卡。
文件类别
对文件系统的所有访问都是使用file模块中的类来完成的:
import {File, Iterator, System} from "file";
file模块导出这三个类,下面几节将详细解释:
-
File类对单个文件执行操作,包括读、写、删除和重命名。 -
Iterator类返回目录的内容。在 SPIFFS 这样的平面文件系统上,Iterator只对根目录可用。 -
System类提供关于文件系统存储的信息,包括存储总量和可用空间。
文件路径
文件路径是用来标识文件和目录的字符串。file模块使用斜杠字符(/)来分隔路径的各个部分,就像在/spiffs/data.txt中一样。
虽然 SPIFFS 是一个没有子目录的平面文件系统,但它是通过根/spiffs/而不是/来访问的,以支持具有多个文件系统的嵌入式设备——例如,内置的闪存文件系统和外部 SD 卡。
在桌面模拟器上,根因主机平台而异。比如在 macOS 上,默认的文件系统根目录是/Users/Shared/。当您编写想要在多个环境中工作的代码时,您可以使用mc/config模块中的预定义值来查找您的主机平台的根。
import config from "mc/config";
const root = config.file.root;
因为可能有多个文件系统,这个根目录只是一个方便的文件默认位置,不一定是唯一可用的文件系统。
每个文件系统对文件名或目录名的长度可能有不同的限制。使用System.config静态方法检索指定根中名称的最大长度。
const spiffsConfig = System.config("/spiffs/");
let name = "this is a very long file name.txt";
if (name.length > spiffsConfig.maxPathLength)
throw new Error("file name too long");
文件操作
本节描述对文件执行操作的方法,包括删除、创建和打开。没有方法来读取或写入文件的全部内容,因为这通常会由于内存限制而失败;后面的章节介绍了阅读和写作的技巧。
确定文件是否存在
使用File类的静态exists方法来确定文件是否存在:
if (File.exists(root + "test.txt"))
trace("file exists\n");
else
trace("files does not exist\n");
删除文件
要删除一个文件,使用File类的静态delete方法:
File.delete(root + "goaway.txt");
如果成功,delete方法返回true,否则返回false。如果文件不存在,delete会返回true而不是抛出一个错误,所以不需要用try / catch块包围它的调用。如果删除操作失败,方法确实会引发错误,但这种情况只在极少数情况下发生,例如当闪存磨损或文件系统数据结构损坏时。
重命名文件
要重命名文件,使用File类的静态rename方法。第一个参数是要重命名的文件的完整路径,而第二个参数只是新名称。
File.rename(root + "oldname.txt", "newname.txt");
Note
rename方法仅用于重命名文件。在支持子目录的文件系统上,rename不能用于将文件从一个目录移动到另一个目录。
打开文件
要打开一个文件,创建一个File类的实例。File构造函数的第一个参数是要打开的文件的完整路径。可选的第二个参数是以写模式打开的true(如果文件不存在,则创建文件),或者不存在,或者是以读模式打开的false。以下是以读取模式打开文件的示例:
let file = new File(root + "test.txt");
以下示例以写模式打开一个文件,如果该文件不存在,则创建该文件:
let file = new File(root + "test.txt", true);
如果打开文件时出现错误,比如试图以读取模式打开一个不存在的文件,那么File构造函数会抛出一个错误。
访问完文件后,关闭文件实例以释放它正在使用的系统资源:
file.close();
写入文件
本节介绍将数据写入文件的技术。您可以使用File类来写入文本和二进制数据。文件必须以写模式打开,否则写操作将引发错误。要以写模式打开,将true作为第二个参数传递给File构造函数。
当您写入的数据超过当前大小时,文件系统会自动增大文件。不支持截断文件。要减小文件的大小,请创建另一个文件,并将所需数据从原始文件复制到其中。
书写文本
File类的write方法根据您传递给调用的 JavaScript 对象的类型来确定您想要写入的数据类型。若要写入文本,请传递一个字符串。以下来自$EXAMPLES/ch5-files/files示例的代码将单个字符串写入文件:
let file = new File(root + "test.txt", true);
file.write("this is a test");
file.close();
字符串总是被写成 UTF 8 数据。
写入二进制数据
要将二进制数据写入文件,请将一个ArrayBuffer传递给write。以下来自$EXAMPLES/ch5-files/files示例的代码将五个 32 位无符号整数写入一个文件。这些值在一个Uint32Array中,它使用一个ArrayBuffer进行存储。对write的调用从bytes数组的buffer属性中获取ArrayBuffer。
let bytes = Uint32Array.of(0, 1, 2, 3, 4);
let file = new File(root + "test.bin", true);
file.write(bytes.buffer);
file.close();
要写字节(8 位无符号值),传递一个整数值作为参数(参见清单 5-1 )。
let file = new File(root + "test.bin", true);
file.write(1);
file.write(2);
file.write(3);
file.close();
Listing 5-1.
获取文件大小
要确定文件的字节大小,首先要打开文件,然后检查它的length属性:
let file = new File(root + "test.txt");
let length = file.length;
trace(`test.txt is ${length} bytes\n`);
file.close();
length属性是只读的。不能设置它来更改文件的大小。
编写混合类型
write方法允许您传递多个参数,以便在一次调用中写入几段数据。这样执行起来会快一点,代码也会小一点。以下示例在对write的一次调用中写入一个ArrayBuffer、四个字节和一个字符串:
let bytes = Uint32Array.of(0x01020304, 0x05060708);
let file = new File(root + "test.bin", true);
file.write(bytes.buffer, 9, 10, 11 12, "ONE TWO!");
file.close();
写入后文件的十六进制转储如下所示:
04 03 02 01 08 07 06 05 .... ....
09 0A 0B 0C 79 78 69 32 .... ONE
84 87 79 33 TWO!
您可能希望前四个字节是01 02 03 04,但是请记住,包含Uint32Array的TypedArray的实例是按照主机平台的字节顺序存储的,而 ESP32 和 ESP8266 微控制器都是小端设备。
从文件中读取
本节介绍从文件中检索数据的技术。您可以使用File类来读取文本和二进制数据。大多数文件都是二进制或文本数据,尽管这不是必需的。
File类支持分段读取文件,这使您能够控制从文件读取时使用的最大内存。
阅读文本
有时,将文件的全部内容作为一个文本字符串进行检索是很有用的。您可以通过调用带有单个参数String的read方法来实现这一点,该方法告诉文件实例从当前位置读取到文件末尾,并将结果放入一个字符串中。下面来自$EXAMPLES/ch5-files/files示例的代码从前面创建的test.txt文件中读取内容:
let file = new File(root + "test.txt");
let string = file.read(String);
trace(string + "\n");
file.close();
read方法总是从当前位置开始读取。在这种情况下,由于文件刚刚被打开,所以当前位置是 0,即文件的开头。
分段阅读文本
您还可以使用read方法来检索文件的一部分,以最大限度地减少峰值内存的使用。read的可选第二个参数表示要读取的最大字节数。这是读取的字节数,但有一个例外:如果读取请求的字节数会超过文件的结尾,则从当前位置到文件结尾的文本被读取。
清单 5-2 中的例子读取一个十字节的文件并跟踪它们到控制台。它将position属性与length属性进行比较,以确定何时从文件中读取了所有数据。
let file = new File(root + "test.txt");
while (file.position < file.length) {
let string = file.read(String, 10);
trace(string + "\n");
}
file.close();
Listing 5-2.
在计算机上,你可以对文件进行内存映射以简化对数据的访问;然而,这种方法在微控制器上通常不可用,因为它们通常缺少 MMU(内存管理单元)来执行映射。如果你想对只读数据进行内存映射,资源是一个很好的选择,这将在本章后面解释。
读取二进制数据
要将整个文件作为二进制数据读取,请使用单个参数ArrayBuffer调用read。下面来自$EXAMPLES/ch5-files/files示例的代码从前面创建的test.bin文件中读取内容:
let file = new File(root + "test.bin");
let buffer = file.read(ArrayBuffer);
file.close();
与读取文本时一样,二进制读取从当前位置(文件打开时为 0)开始,一直持续到文件的末尾。数据在ArrayBuffer中返回。以下示例将返回的缓冲区包装在一个Uint8Array中,并在控制台上显示十六进制字节值:
let bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.length; i++)
trace(bytes[i].toString(16).padStart(2, "0"), "\n");
分段读取二进制数据
read方法也可以用来从文件中的任意位置获取二进制数据。清单 5-3 中的例子读取文件的最后四个字节,并将结果显示为一个 32 位无符号整数。读取位置是通过将position属性设置为距离文件末尾四个字节来指定的。
let file = new File(root + "test.bin");
file.position = file.length - 4;
let buffer = file.read(ArrayBuffer, 4);
file.close();
let value = (new Uint32(buffer))[0];
Listing 5-3.
目录
SPIFFS 文件系统只实现一个目录,即根目录。其他文件系统,比如 FAT32,支持任意数量的子目录。在所有情况下,都使用file模块的Iterator类来列出目录中包含的文件和子目录。
遍历目录
检索目录中的项目列表是一个两步过程。首先,为要迭代的目录创建一个Iterator类的实例;然后调用迭代器的next方法来检索每一项。当所有条目都被返回时,迭代器返回undefined。来自$EXAMPLES/ch5-files/files示例的清单 5-4 跟踪根目录中包含的文件和目录到控制台。
let iterator = new Iterator(root);
let item;
while (item = iterator.next()) {
if (undefined === item.length)
trace(`${item.name.padEnd(32)} directory\n`);
else
trace(`${item.name.padEnd(32)} file ${item.length}` +
"bytes\n");
}
Listing 5-4.
next方法返回一个具有描述该项目的属性的对象。name属性总是存在。length属性只存在于文件中,表示文件中的字节数。没有单独的属性来指示该项是文件还是目录,因为有了length属性就足够了。
迭代器实例有一个close方法,可以调用它来释放迭代器使用的系统资源。然而,这通常是不必要的,因为迭代器实现在到达项目末尾时会自动释放所有系统资源。
Iterator类一次返回一个项目,而不是所有项目的列表,以保持内存使用最小化。项目返回的顺序取决于底层文件系统的实现。例如,在一般情况下,您不能假定项目是按字母顺序返回的,或者目录是在文件之前返回的。
用 JavaScript 迭代器迭代
JavaScript 语言提供了迭代器特性,使得编写使用迭代器的代码更加容易。例如,您可以使用for - of循环语法来遍历条目。这个语言特性适用于任何实现了迭代器协议的实例,而file模块的Iterator类就是这样做的。这种方法对您来说更简洁,但代价是使用更多的内存和 CPU 时间。清单 5-5 改编清单 5-4 以使用 JavaScript 迭代器。
for (let item of new Iterator(root)) {
if (undefined === item.length)
trace(`${item.name.padEnd(32)} directory\n`);
else
trace(`${item.name.padEnd(32)} file ${item.length}` +
"bytes\n");
}
Listing 5-5.
迭代器真正出彩的地方是作为操作迭代器的函数的输入。例如,如果您需要一个包含目录中所有项目的数组,您可以简单地将迭代器实例传递给Array.from。
let items = Array.from(new Iterator(root));
获取文件系统信息
file模块的System对象包含一个info方法来提供关于每个文件系统根的信息。您可以使用此方法来确定可用存储的总字节数和当前使用的字节数。
let info = System.info(root);
trace(`Used ${info.used} of ${info.total}\n`);
偏好;喜好;优先;参数选择
首选项是在物联网产品的微控制器上存储数据的另一个工具。它们比文件更有效,但也更有限。文件非常适合存储大量信息,而首选项只存储少量信息。通常在你的产品中,你只需要记录少量的用户设置,在这些情况下你只需要偏好;您甚至可以将文件系统从您的产品中完全排除。
使用首选项的另一个优点是它们的可靠性。ESP32 和 ESP8266 首选项的实现采取措施确保首选项数据不会被破坏,即使在更新首选项时断电也是如此。在文件系统中更难达到这种级别的可靠性,因为数据结构更复杂。
Preference类
preference模块提供对首选项的访问。要在代码中使用首选项,请从preference模块中导入Preference类。
import Preference from "preference";
本章介绍的 JavaScript 首选项 API 在微控制器之间是相同的;然而,底层实现是不同的。例如,在 ESP32 上,首选项是使用 ESP32 IDF SDK 中的 NVS 库实现的,而在 ESP8266 上,首选项是由可修改的 SDK 实现的,因为没有系统提供的等效项。因为实现不同,所以行为也不同。以下部分指出了您需要记住的差异。
首选项名称
每个首选项由两个值标识,一个域和一个名称。这些类似于一个简单的文件系统路径:域就像目录名,名称就像文件名。例如,考虑一个 Wi-Fi 灯,您想要保存用户设置以便在打开电源时恢复。你可以使用一个light域作为所有灯光状态的首选项,用on、brightness和color作为名称。灯可以将统计数据(例如灯被打开的次数)保存在另一个域中,例如stats。
首选项的域名和名称值始终是字符串。ESP32 上的名称限制为 15 个字节,ESP8266 上的名称限制为 31 个字节。
偏好数据
首选项不是用来替换文件系统的;试图那样使用它们是一个常见的错误。因为每个单个首选项的大小是有限的,所有首选项可用的总存储空间也是有限的,所以它们远不如文件系统通用。
每个首选项都有一个数据类型:布尔值、32 位有符号整数、字符串或ArrayBuffer。不支持浮点数值。字符串类型通常是最方便使用的类型,但也是存储空间使用效率最低的类型。如果您需要在一个首选项中组合几个值,可以考虑使用一个ArrayBuffer。
当您写入一个值时,该值的类型是基于所提供的数据建立的。要更改类型,请再次写入值。当您读取一个值时,返回的值与写入的值具有相同的类型。
请注意 ESP32 和 ESP8266 上的偏好数据之间的差异:
-
在 ESP32 上,首选项数据空间是可配置的,在本书使用的主机中设置为 16 KB。在 ESP8266 上,偏好数据的空间为 4 KB。
-
在 ESP32 上,每个首选项最多可以有 4,000 字节的数据;在 ESP8266 上,该值限制为 64 字节。如果您正在编写希望在几种不同的微控制器平台上运行的代码,您需要为 64 字节数据大小设计您的首选值。
阅读和写作偏好
因为首选项只是具有某种类型的小块数据,所以它们比文件更容易读取和写入。来自$EXAMPLES/ch5-files/preferences示例的清单 5-6 将四个首选项写入example域。每个值的类型用作首选项名称。set实现基于第三个参数中传递的值来确定首选项的类型。
Preference.set("example", "boolean", true);
Preference.set("example", "integer", 1);
Preference.set("example", "string", "my value");
Preference.set("example", "arraybuffer",
Uint8Array.of(1, 2, 3).buffer);
Listing 5-6.
使用静态的get调用来检索偏好值,如清单 5-7 所示。返回值的类型与set调用中使用的值的类型相匹配。
let a = Preference.get("example", "boolean"); // true
let b = Preference.get("example", "integer"); // 1
let c = Preference.get("example", "string"); // "my value"
let d = Preference.get("example", "arraybuffer");
// ArrayBuffer of [1, 2, 3]
Listing 5-7.
如果没有找到具有指定域名和名称的首选项,get调用返回undefined:
let on = Preference.get("light", "on");
if (undefined === on)
on = false;
删除首选项
使用delete方法删除首选项:
Preference.delete("example", "integer");
如果找不到具有指定域名和名称的首选项,则不会引发错误。如果在更新闪存以删除首选项时出现错误,delete会抛出一个错误。
不要用 JSON
当用 JavaScript 为 web 或计算机构建产品时,通常使用 JSON 存储参数——这是一种非常容易编码且非常灵活的方法。当使用 JavaScript 创建一个嵌入式产品时,很容易做同样的事情;然而,尽管它在某些产品中有效,但并不推荐使用,因为它更有可能在开发过程的后期导致失败。请考虑以下几点:
-
将首选项存储在 JSON 文件中要求您的项目包含一个文件系统——大量代码会占用您的闪存中有限的空间。
-
JSON 对象必须一次加载到内存中,这意味着访问一个参数值需要足够的内存来保存所有参数值。
-
从文件中加载 JSON 字符串数据,然后将它解析成 JavaScript 对象,这比只从首选项中加载一个值要花费更多的时间。
-
文件系统对电源故障的容错能力通常不如首选项。因此,用户设置丢失的可能性更大。
使用 JSON 似乎也是在单个首选项中存储多个值的好方法。这确实有效,但是它有两个限制,这使得它在许多情况下不是一个明智的选择。首先,因为在某些设备上,首选项数据被限制为只有 64 个字节,所以不能以这种方式组合很多值。其次,JSON 格式的开销几乎肯定意味着偏好数据比其他方法使用更多的存储空间。例如,以下代码使用 24 字节的存储空间将三个小整数值存储为 JSON:
Preference.set("example", "json",
JSON.stringify({a: 1, b: 2, c: 3}));
相比之下,这个例子通过使用Uint8Array只需要三个字节:
Preference.set("example", "bytes",
Uint8Array.of(1, 2, 3).buffer);
从 JSON 版本中读取值更容易:
let pref = JSON.parse(Preference.get("example", "json"));
从存储效率更高的版本中读取值需要一行额外的代码:
let pref = new Uint8Array(Preference.get("example", "bytes"));
pref = {a: pref[0], b: pref[1], c: pref[2]};
安全
preference模块不保证偏好数据的安全性。域、名称和值可以“明文”存储,无需任何加密或混淆。与文件中的用户数据一样,您应该在产品中采取适当的步骤来确保用户数据得到充分的保护。物联网产品中通常存储的敏感用户数据包括 Wi-Fi 密码和云服务帐户标识符。至少,您应该考虑对这些值应用某种形式的加密,以便扫描设备闪存的攻击者无法读取它们。
一些主机确实为偏好数据提供加密存储。例如,通过额外的配置,这在 ESP32 上是可用的。
资源
资源是处理只读数据的工具。它们是在项目中嵌入大量数据的最有效方式。资源通常在存储它们的闪存中就地访问,因此无论资源数据有多大,都不使用 RAM。可修改的 SDK 将资源用于许多不同的目的,包括 TLS 证书、图像和音频,但是对可以存储在资源中的数据种类没有限制。
$EXAMPLES/ch5-files/resources示例托管一个由mydata.dat定义的简单 web 页面,它作为一个资源包含在内。运行该示例后,打开 web 浏览器并输入设备的 IP 地址,您将看到一个显示“Hello,world”的网页。
向项目添加资源
在项目中包含资源需要两个步骤:
-
将包含资源数据的文件添加到项目中。资源文件通常放在子目录中,比如
assets、data或者resources,但是你可以把它们放在你喜欢的任何地方。 -
您将文件添加到清单的
resources部分,告诉构建工具将文件的数据复制到资源中。
清单 5-8 来自resources示例的清单。它只包括一个资源mydata.dat,来自包含清单的目录。
"resources": {
"*": [
"./mydata"
],
},
Listing 5-8.
数据文件必须有一个.dat扩展名。但是,清单中的文件名不得包含扩展名;构建工具会自动定位扩展名为.dat的文件。重要的是,不要包含几个同名但扩展名不同的文件(例如,mydata.dat和mydata.bin),因为工具可能无法首先找到您期望的文件。
本章描述了直接从输入文件复制到输出二进制文件的资源数据,没有任何改变。构建工具还能够对数据进行转换,例如将图像转换为针对目标微控制器优化的格式;第八章解释了如何使用资源转换。
访问资源
要访问资源,从resource模块导入Resource类:
import Resource from "resource";
使用清单中的资源路径调用Resource类构造函数。注意,在这种情况下,路径总是包含文件扩展名— .dat。
let data = new Resource("mydata.dat");
如果Resource构造函数找不到请求的资源,就会抛出一个错误。如果您想在调用构造函数之前检查资源是否存在,请使用静态的exists方法:
if (Resource.exists("mydata.dat")) {
let data = new Resource("mydata.dat");
...
}
使用资源
Resource构造函数将二进制数据作为HostBuffer返回。HostBuffer类似于ArrayBuffer,但与ArrayBuffer不同的是,HostBuffer的数据可能是只读的,因此可能位于闪存中。
要获得资源中的字节数,使用byteLength属性,就像使用ArrayBuffer一样:
let r1 = new Resource("mydata.dat");
let length = r1.byteLength;
与ArrayBuffer一样,您不能直接访问HostBuffer的数据,而必须将其包装在类型化数组或数据视图中。以下示例将资源包装在一个Uint8Array中,并将值跟踪到控制台:
let r1 = new Resource("mydata.dat");
let bytes = new Uint8Array(r1);
for (let i = 0; i < bytes.length; i++)
trace(bytes[i], "\n");
此示例将资源包装在一个DataView对象中,以大端 32 位无符号整数的形式访问其内容:
let r1 = new Resource("mydata.dat");
let view = new DataView(r1);
for (let i = 0; i < view.byteLength; i += 4)
trace(view.getUint32(i, false), "\n");
有时您想要修改资源中的数据。因为数据是只读的,你需要做一个拷贝。由Resource构造函数返回的HostBuffer有一个slice方法,可以用来复制资源数据,与ArrayBuffer实例上的slice方法相同。例如,您可以将整个资源复制到 RAM 中的ArrayBuffer中,如下所示:
let r1 = new Resource("mydata.dat");
let clone = r1.slice(0);
slice的第一个参数是要复制的数据的起始偏移量。可选的第二个参数是要复制的结束偏移量;如果省略,数据将复制到资源的末尾。以下示例从字节 20 开始提取 10 个字节的资源数据:
let r1 = new Resource("mydata.dat");
let fragment = r1.slice(20, 30);
slice方法支持可选的第三个参数,ArrayBuffer没有提供这个参数。该参数控制是否将数据复制到 RAM 中。如果它被设置为false,那么slice返回一个HostBuffer,引用资源数据的一个片段,这在您想要将资源的一部分与一个对象相关联而不将其数据复制到 RAM 中时非常有用。例如,如果在资源的偏移量 32 处有一个包含五个无符号 16 位数据的数组,您可以创建一个引用它的Uint16Array,如下所示:
let r1 = new Resource("mydata.dat");
let values = new Uint16Array(r1.slice(32, 10, false));
您可以通过使用Uint16Array构造函数的可选参数byteOffset和length获得类似的结果:
let r1 = new Resource("mydata.dat");
let values = new Uint16Array(r1, 32, 10);
使用slice的优点是它确保了不受信任的代码无法使用全部资源来访问values数组。在前面两个例子的第一个中,values.buffer可以访问整个资源,而在第二个例子中,它只能用来访问Uint16Array中的五个值。
直接访问闪存
本章中描述的所有用于存储和检索数据的模块— files、preferences和resources—都使用连接到控制器的闪存来存储数据。每种处理闪存中数据的方法都有自己的优点和局限性。在大多数情况下,这些方法中的一种非常适合您的产品需求;在某些情况下,更专业的方法可能更有效。flash模块可让您直接访问闪存。用好它需要更多的工作,但在某些情况下这是值得的。
Warning
这是一个高级话题。直接访问闪存是危险的。您可能会使设备崩溃或损坏您的数据。您甚至可能损坏闪存,使您的设备无法使用。小心行事!
闪存硬件基础
为了能够使用由flash模块提供的 API,理解闪存硬件的基础很重要。
ESP32 和 ESP8266 微控制器使用的闪存通过 SPI(串行外设接口)总线连接。虽然访问速度相当快,但仍然比访问 RAM 中的数据慢很多倍。
闪存被组织成块(也称为“扇区”)。块的大小取决于所使用的闪存组件。常见的值是 4,096 字节。当你读写闪存时,你通常不需要知道块的大小。然而,在初始化闪存时,块的大小很重要。
闪存使用 NOR 技术来存储数据。这有一个奇怪的含义,即闪存的一个擦除字节的所有位都设置为 1,而通常认为擦除存储器设置为 0。您可能认为可以简单地将新擦除的字节设置为全零,但正如您将看到的,这对于 NOR 闪存来说并不是一个好主意。
当写入 NOR 闪存时,您只写入 0 位。因为闪存被擦除为全 1 位,所以这在第一次写入时无关紧要。考虑两个字节(16 位)的闪存。它们开始时被擦除为全 1 位。
11111111 11111111
向其中写入两个字节,1 和 2,结果很简单:
00000001 00000010
接下来就是结果出乎意料的地方了。下面是将两个字节 2 和 1 写入同一位置时的情况:
00000000 00000000
结果是两个字节都被设置为 0。为什么呢?请记住,对于 NOR 闪存,写操作仅设置 0 位。flash 存储器中已经设置为 0 的任何位都不能通过写操作变回 1。
-
闪 0。写入 0 = >闪存 0。
-
闪 0。写入 1 = >闪存 0。
-
闪电侠 1。写入 0 = >闪存 0。
-
闪电侠 1。写入 1 = >闪存 1。
如果写操作只能将位从 1 更改为 0,位如何从 0 更改为 1?你用 flash erase的方法做到这一点。与read和write可以直接访问闪存中的任何字节不同,erase是一个批量操作,将闪存块中的所有位设置为 1。擦除与块大小边界对齐的块,这意味着字节 0 到 4,095 或字节 4,096 到 8,191,而不是 1 到 4,096,因为它们没有与块的开始对齐,也不是字节 1 到 2,因为它们不是完整的块。
如果你想改变一个位,你可以将整个块读入 RAM,擦除块,改变 RAM 中的位,然后将块写回。这可行,但是很慢,因为erase是一个相对较慢的操作——比read和write慢很多倍。这种方法还需要足够的 RAM 来容纳一个完整的模块,而在资源受限的微控制器上并不总是有那么多内存。然而,最大的问题是闪存会磨损。每个块仅可被擦除特定次数,此后该块不再可靠地存储数据;为了保护设备,您需要尽量减少擦除每个块的次数。
好消息是,您的 ESP32 或 ESP8266 中的闪存支持成千上万的擦除操作。preference和file模块实施了解 NOR 闪存的限制和特性,并采取措施尽量减少擦除。如果你在一个打算使用多年的产品中直接访问闪存,你也需要这样做。
一种常用的策略是增量写入。在这种方法中,当前值被置零,新值被写入块中的零之后。这使得单个值可以多次更新,而无需擦除。此方法由preference模块使用。本节后面频繁更新的整数示例探究了增量写入的详细信息。
另一种常见策略是损耗均衡。这种方法试图在产品的生命周期内以相同的次数擦除每个闪存存储块,以确保没有任何块(例如,第一个块)会因为更频繁的访问而比其他块磨损得更快。模块底层的 SPIFFS 文件系统使用了这种技术。
访问闪存分区
使用flash模块中的Flash类访问微控制器可用的闪存:
import Flash from "flash";
闪存分为称为分区的段。例如,一个分区包含您的项目代码,另一个包含偏好数据,另一个存储 SPIFFS 文件系统。每个分区由一个名称标识。
要访问分区中的字节,用分区名实例化Flash类。当您使用第一章中介绍的mcrun安装示例应用程序时,应用程序的字节码存储在xs分区中。下面一行实例化了Flash类来访问它:
let xsPartition = new Flash("xs");
代码可用的分区根据微控制器和主机实现而有所不同。包含用mcrun安装的应用程序的xs分区总是可用的。用于 SPIFFS 文件系统的区域名为storage,通常也总是可用的;如果您的项目中没有使用 SPIFFS 文件系统,您可以将它用于其他目的。尽管这些分区都存在,但它们的大小因设备而异。
在 ESP32 上,来自 Espressif 的 ESP32 IDF 定义了分区。IDF 提供了一种灵活的分区机制,使您可以定义自己的分区。在 ESP8266 上,可修改的 SDK 定义了分区,并且它们不容易重新配置。
在 ESP32 上,Flash构造函数搜索 IDF 分区图以匹配所请求的分区名。因此,您可以访问包含 ESP32 首选项(在 NVS 库中实现)的分区,其名称为nvs,如分区图中所声明的(IDF 项目中的partitions.csv文件)。
let nvsPartition = new Flash("nvs");
获取分区信息
Flash类的一个实例有两个只读属性,提供关于分区的重要信息:blockSize和byteLength。
blockSize属性表示闪存硬件的单个块中的字节数。这个值通常是 4,096,但是为了保证健壮性,您应该使用blockSize属性,而不是在代码中硬编码一个常量值。这样,您的代码可以在包含不同 flash 硬件组件的硬件上不加更改地工作。
let storagePartition = new Flash("storage");
let blockSize = storagePartition.blockSize;
blockSize属性很重要,因为它告诉您分区上擦除操作的对齐方式和大小。
byteLength属性提供分区中可用的字节总数。以下示例计算分区中的块数:
let blocks = storagePartition.byteLength / blockSize;
byteLength属性的值总是blockSize属性的值的整数倍,所以块数总是整数。
从闪存分区读取
使用read方法从闪存分区获取字节。read方法有两个参数:分区中的偏移量和要读取的字节数。read调用的结果是一个ArrayBuffer。以下是摘自$EXAMPLES/ch5-files/flash-readwrite的例子:
let buffer = partition.read(0, 10);
let bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++)
trace(bytes[i] + "\n");
这段代码从分区中检索前十个字节。它将返回的ArrayBuffer包装在一个Uint8Array中,以跟踪到控制台的字节值。
除了需要在分区内之外,对偏移量和要读取的字节数没有限制。具体来说,对read的单次调用可能会跨越块边界。
read调用将请求的数据从分区复制到新的ArrayBuffer。因此,您应该以小片段读取闪存,以尽可能少地使用 RAM。
擦除闪存分区
使用erase方法将闪存分区中的所有位重置为 1。该方法采用一个参数,即要重置的块数。这一行擦除分区的第一个块:
partition.erase(0);
下面的代码重置整个分区。擦除操作相对较慢;对于大型分区,例如 ESP8266 上的存储分区,此操作需要几秒钟的时间。
let blocks = partition.byteLength / partition.blockSize;
for (let block = 0; block < blocks; block++)
partition.erase(block);
写入闪存分区
使用write方法改变闪存分区中存储的值。这个方法有三个参数:将数据写入分区的偏移量、要写入的字节数和包含数据的ArrayBuffer。当要写入的字节数小于ArrayBuffer的大小时,只写入该数量的字节。以下示例将分区的前十个字节设置为 1 到 10 之间的整数:
let buffer = Uint8Array.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).buffer;
partition.write(0, 10, buffer);
请记住,写操作只会设置 0 位,如“闪存硬件基础”部分所述。因此,可能有必要在调用write之前执行擦除。
映射闪存分区
在 ESP32 上,您可以选择内存映射分区,这允许您使用类型化数组或数据视图构造函数对分区的内容进行只读访问。要对一个分区进行内存映射,调用map方法。以下代码摘自$EXAMPLES/ch5-files/flash-map示例:
let partition = new Flash("storage");
let buffer = partition.map();
let bytes = new Uint8Array(buffer);
map属性返回一个HostBuffer,它可以被传递给类型化数组或数据视图构造函数来访问数据。在某些情况下,内存映射分区是比read调用更方便的数据访问方式。此外,因为分区中的数据不是通过map方法复制到 RAM 的,所以 RAM 的使用被最小化。
由于硬件限制,ES8266 上的map方法不可用,硬件限制只允许闪存第一兆字节的内存映射,这是为存储固件而保留的区域。
示例:频繁更新的整数
本节给出了一个直接访问 flash 存储器的例子,与使用文件或首选项相比,它可以更有效地维护 32 位值。该示例适用于您的产品需要频繁更新闪存存储中的值,以确保在产品重启后仍能可靠地维护该值的情况。
该示例使用单块闪存。这通常是 4,096 字节,比存储的 32 位(4 字节)值大 1,024 倍。该示例利用附加存储来减少擦除操作的次数,从而延长闪存的寿命。为了方便起见,使用的块是storage分区的第一个块,这使得这个例子不能用于 SPIFFS 文件系统。
完整的频繁更新整数示例可在$EXAMPLES/ch5-files/flash-frequentupdate获得。
正在初始化块
第一步是打开存储分区:
let partition = new Flash("storage");
如清单 5-9 所示,下一步是检查该块是否已经初始化。这是通过在块的开始处寻找唯一的签名来完成的。如果找不到签名,则擦除该块并写入签名。
const SIGNATURE = 0xa82aa82a;
let signature = partition.read(0, 4);
signature = (new Uint32Array(signature))[0];
if (signature !== SIGNATURE)
initialize(partition);
function initialize(partition) {
let signature = Uint32Array.of(SIGNATURE);
partition.erase(0);
partition.write(0, 4, signature.buffer);
}
Listing 5-9.
更新值
签名后,该块有空间存储计数器的 1023 个副本。清单 5-10 显示了一个更新计数器值的write函数。它在块中搜索第一个未使用的 32 位整数,并将值写入其中。回想一下,当一个块被擦除时,所有的位都被设置为 1。这意味着任何未使用的条目都包含值0xFFFFFFFF(一个所有位都设置为 1 的 32 位整数)。如果该块已满,它会重新初始化该块,并将值写入第一个空闲位置。
function write(partition, newValue) {
for (let i = 1; i < 1024; i++) {
let currentValue = partition.read(i * 4, 4);
currentValue = (new Uint32Array(currentValue))[0];
if (0xFFFFFFFF === currentValue) {
partition.write(i * 4, 4, Uint32Array.of(newValue).buffer);
return;
}
}
initialize(partition);
partition.write(4, 4, Uint32Array.of(newValue).buffer);
}
Listing 5-10.
读取数值
最后一部分是read函数,如清单 5-11 所示。像write函数一样,它搜索第一个自由条目。找到后,read返回前一个条目的值。如果搜索到达了块的末尾,则返回块中的最后一个值。
function read(partition) {
let i;
for (i = 1; i < 1024; i++) {
let currentValue = partition.read(i * 4, 4);
currentValue = (new Uint32Array(currentValue))[0];
if (0xFFFFFFFF === currentValue)
break;
}
let result = partition.read((i - 1) * 4, 4);
return (new Uint32Array(result))[0];
}
Listing 5-11.
益处和未来工作
这个例子有效地将一个整数值存储在闪存中。在该块需要被擦除之前,该值可以被更新 1023 次。为了理解这种影响,考虑一个每分钟更新一次该值的产品。这相当于每年 514 次擦除操作(60 * 24 * 365,即每年 525,600 分钟,除以每次擦除的 1,023 次更新,结果为 514 次)。使用支持 10,000 次擦除操作(保守估计)的闪存芯片,该产品的寿命约为 19.5 年。如果每次写入操作都需要擦除,则同一产品仅用 7 天就会耗尽(60 * 24 * 7 等于每周 10,080 次写入)。
细心的读者注意到了这个例子的两个局限性:如果在擦除之后和写入之前在write函数中断电,那么当前值将会丢失;并且该值不能被设置为0xffffffff,因为该值用于标识块中未使用的条目。这些缺点的解决方案是可能的,留给读者作为练习。
结论
在本章中,您学习了在嵌入式产品中存储信息的几种不同方法。文件、首选项和资源是存储数据的三种主要方式,每种方式都针对不同的存储用途进行了优化。您可以在您的产品中使用这些方法的任意组合。设计产品时,请考虑您的存储需求,以确定使用哪种方法来最佳利用可用存储。有些情况非常特殊,没有一种标准存储技术是最佳的;为了解决这些情况,本章展示了闪存如何工作,以便您可以创建自己的存储方法。
六、硬件
传感器和执行器是几乎所有物联网产品不可或缺的组成部分。传感器从环境中收集数据,如温度、湿度和光照水平,并将其转化为微控制器或其他系统可以做出反应的电信号。致动器的作用正好相反:它们接收电信号,并将其转化为物理动作,比如启动马达、打开灯或播放声音。
正如有不同的网络协议定义如何在网络上共享数据一样,也有不同的硬件协议定义传感器和执行器如何与其连接的微控制器通信。可修改的 SDK 包括用于各种硬件协议的 JavaScript APIs,包括数字、模拟、PWM、伺服和 I 2 C。这些 API 使您能够从 ESP32 或 ESP8266 与现成的硬件或您自己的电路进行交互。
在本章中,您将学习如何开始编写自己的 JavaScript 代码来与硬件交互。本章包括许多例子,只需要几个简单,广泛可用,廉价的传感器和执行器。
本章中的代码使用不同的硬件协议直接与硬件通信。一旦您学会了如何使用一些常见的硬件协议,您就拥有了将使用这些协议的许多硬件组件整合到您自己的项目中所需的知识。当给计算机安装新硬件时,通常需要安装软件驱动程序,即知道如何通过底层硬件协议与硬件交互的软件;实际上,本章教你如何为各种硬件组件编写软件驱动程序。以这种方式直接控制硬件的物联网产品有很多好处,包括更精确的控制、更小的代码和更低的延迟。当然,许多组件也有软件驱动程序;在可修改的 SDK 中,您可以在modules/drivers中找到它们。
安装硬件主机
本章中的示例使用第一章中描述的模式进行安装:您使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。
主机在$EXAMPLES/ch6-hardware/host目录中。从命令行导航到这个目录,用mcconfig安装它。
接线注意事项
与本书中的其他章节不同,这一章要求你在运行大多数示例之前对你的设备进行额外的设置:你需要将各种传感器和执行器连接到你的设备上。如果你是新手,开始时可能会感到困惑。如果您以前这样做过,您会知道很容易出错,并且故障排除有时需要时间。本节提供了在运行示例之前应该了解的关于连接的重要信息。
遵循接线说明
本章提供了示例中使用的大多数传感器和致动器的接线表和接线图。布线图显示了 NodeMCU 板的布线,因此也显示了 NodeMCU 管脚号,如 D6 或 D7。这些标签不一定与代码中使用的 GPIO 号匹配。如果您使用的是不同的开发板,请确保查看接线表,该表提供了 GPIO 号,例如 GPIO12 或 GPIO13,以及括号中的 NodeMCU 引脚号。所有开发板的引脚标签都不同,因此您必须相应地映射引脚。可修改开发板标有“GP ”,后面是代码中使用的 GPIO 号,例如 GP12 或 GP13,因此,如果接线表显示某个引脚应连接到 GPIO12,请将其插入可修改板上标有 GP12 的引脚。
布线问题故障排除
仔细遵循接线说明很重要。如果您在布线中出错,可能会发生一些事情:
-
会引发一个错误。这很常见,通常也是最容易解决的问题。例如,如果您交换 I 2 C 传感器的 SDA 和 SCL 引脚,您将在读取和写入时出错。利用
xsbug并使用错误消息来诊断您的问题。有时候你只需要修理你的电线;其他时候,您可能有一个有故障的传感器或执行器。 -
该应用程序可以工作,但会产生意想不到的结果。这也很常见,但是很难捕捉到。例如,如果您将传感器的数字引脚插入开发板上的错误引脚,应用程序会从没有连接任何东西的引脚读取数据;它不会抛出错误,但会给出意外的结果。如果你按下一个按钮,应用程序没有像预期的那样响应,或者你正在写一个三色 LED 的引脚,颜色没有更新,请仔细检查你的接线。
-
你破坏了你的传感器或执行器。与前两个问题相比,这种情况不太常见,但也有可能发生。例如,当传感器设计为接受 3.3V 电压时,用 5V 电压供电会损坏传感器上的电子元件。
LED 闪烁
您可以使用 ESP32 或 ESP8266 创建的最简单的物理输出是打开和关闭板上 LED(图 6-1 )。ESP32 和 ESP8266 NodeMCU 板都有一个板载 LED 连接到引脚 2。

图 6-1
ESP8266(顶部)和 ESP32(底部)上的板载 LED
Digital类提供对设备上 GPIO 引脚的访问:
import Digital from "pins/digital";
您可以为输入或输出配置数字引脚。配置完成后,引脚的值可以是 1,表示电压为高电平,也可以是 0,表示电压为低电平。$EXAMPLES/ch6-hardware/blink示例使用Digital类和一个定时器来闪烁板载 LED。如清单 6-1 所示,该示例使用了Digital类的静态write方法,该方法将引脚(由第一个参数指定)设置为Digital.Output模式,并将其值设置为 0 或 1(第二个参数)。
let blink = 1;
Timer.repeat(() => {
blink = blink ^ 1;
Digital.write(2, blink);
}, 200);
Listing 6-1
或者,您可以构造一个Digital类的实例,并调用该实例的write方法。使用构造函数允许完整配置 pin。当您调用构造函数时,您传入一个带有pin和mode属性的字典。以下模式值可用于数字输出引脚:
Digital.Output
Digital.OutputOpenDrain
清单 6-2 展示了编写blink例子的另一种方式,使用Digital构造函数。
let led = new Digital({
pin: 2,
mode: Digital.Output
});
let blink = 1;
Timer.repeat(() => {
blink = blink ^ 1;
led.write(blink);
}, 200);
Listing 6-2.
使用Digital的实例比使用静态Digital.write方法更有效;构造函数初始化 pin 一次,而Digital.write必须在每次写操作时初始化它。Digital.write对于不频繁的写入很方便,但是如果您的项目频繁地写入数字输出,则创建一次实例并写入它。
阅读按钮
按钮是向项目添加物理输入的一种简单方式。ESP32 和 ESP8266 NodeMCU 模块内置了两个按钮。其中一个按钮连接到数字引脚 0,可以在您的项目中用作数字输入;根据您使用的模块,此按钮标记为 FLASH、BOOT 或 IO0。
$EXAMPLES/ch6-hardware/button示例使用了Digital类和一个计时器来读取板上按钮。如清单 6-3 所示,该示例使用了Digital类的静态read方法,该方法将引脚(由第一个参数指定)设置为Digital.Input模式并读取其值,返回 0 或 1。每次按下按钮时,该示例都会跟踪到调试控制台。它还维护按钮按下次数的计数,并将其包含在输出中。
let previous = 1;
let count = 0;
Timer.repeat(id => {
let value = Digital.read(0);
if (value !== previous) {
if (value)
trace(`button pressed: ${++count}\n`);
previous = value;
}
}, 100);
Listing 6-3
或者,您可以构造一个Digital类的实例,并调用该实例的read方法。使用构造函数允许完整配置 pin。当您调用构造函数时,您传入一个带有pin和mode属性的字典。以下模式值可用于数字输入引脚:
Digital.Input
Digital.InputPullUp
Digital.InputPullDown
Digital.InputPullUpDown
清单 6-4 展示了如何重写button示例以使用Digital构造函数。
let button = new Digital({
pin: 0,
mode: Digital.Input
});
let previous = 1;
let count = 0;
Timer.repeat(id => {
let value = button.read();
if (value !== previous) {
if (value)
trace(`button pressed: ${++count}\n`);
previous = value;
}
}, 100);
Listing 6-4.
其他数字输入模式
模式Digital.InputPullUp、Digital.InputPullDown和Digital.InputPullUpDown用于使能上拉和下拉电阻,这些电阻内置于 ESP32 和 ESP8266 的一些 GPIO 引脚中。这并不总是必要的,但对于如图 6-2 所示的按钮很有用,这需要一个下拉电阻来防止它在未按下的状态下接收随机噪声。你可以从 SparkFun(产品 ID COM-10302)和 Adafruit(产品 ID 1009)获得类似这样的按钮。

图 6-2
触觉按钮
$EXAMPLES/ch6-hardware/external-button示例与button示例具有相同的功能,但它使用的是类似图 6-2 中的按钮,而不是内置按钮。如果您想要运行此示例,首先按照此处给出的接线说明将其连接到您的 ESP32 或 ESP8266。
ESP32 接线说明
表 6-1 和图 6-3 显示了如何将按钮连接到 ESP32。

图 6-3
将按钮连接到 ESP32 的接线图
表 6-1
将按钮连接到 ESP32 的接线
|纽扣
|
ESP32
|
| --- | --- |
| 压水反应堆 | 3V3 |
| 联邦德国工业标准 | GPIO16 (RX2) |
ESP8266 接线说明
表 6-2 和图 6-4 显示了如何将按钮连接到 ESP8266。

图 6-4
将按钮连接到 ESP8266 的接线图
表 6-2
将按钮连接到 ESP8266 的接线
|纽扣
|
ESP8266
|
| --- | --- |
| 压水反应堆 | 3V3 |
| 联邦德国工业标准 | GPIO16 (D0) |
理解external-button代码
external-button示例使用了Digital构造函数,如下面的代码所示。它用模式Digital.InputPullDown配置引脚 16,使能引脚 16 上的内置下拉电阻。
let button = new Digital({
pin: 16,
mode: Digital.InputPullDown
});
代码的其余部分与重写的button示例(列表 6-4 )非常相似,除了一些小的改动以说明下拉电阻的使用。
关于上拉和下拉电阻的更多信息
ESP32 和 ESP8266 都在引脚 16 上有一个内置下拉电阻,这就是为什么external-button示例可以在任一引脚上运行,而无需对代码进行任何更改。也就是说,您可以修改它,使其使用内置下拉电阻的任何引脚,并且您构建的其他应用程序也可以使用其他引脚。ESP32 在除引脚 34–39 之外的所有 GPIO 引脚上都有内置下拉电阻,而 ESP8266 仅在引脚 16 上有内置下拉电阻。
其他传感器可能需要上拉电阻。除了引脚 34–39,ESP32 在所有 GPIO 引脚上都有内置上拉电阻;ESP8266 在 GPIO 引脚 1–15 上有内置上拉电阻。
除了使用内置电阻,您还可以将上拉或下拉电阻直接添加到传感器。如果这样做,您可以使用任何 GPIO 引脚,而不仅仅是带有内置电阻的引脚。还要注意,如果你这样做,你应该总是使用模式Digital.Input。换句话说,如果向传感器本身添加下拉电阻,不要使能内置下拉电阻;同样,如果向传感器本身添加上拉电阻,也不要使能内置上拉电阻。
监控变更
通过使用 digital Monitor类,可以更有效地检测数字输入值的变化。它使用微控制器的功能来监控变化,而不是定期轮询。Monitor的实例被配置为触发从 0 到 1 的变化(即上升沿)和/或从 1 到 0 的变化(下降沿)。当硬件检测到一个触发事件时,Monitor类调用一个回调函数。
清单 6-5 展示了button示例如何使用Monitor类。请注意,这个版本要小得多。
let monitor = new Monitor({
pin: 0,
mode: Digital.Input,
edge: Monitor.Rising
});
let count = 0;
monitor.onChanged = function() {
trace(`button pressed: ${++count}\n`);
}
Listing 6-5.
最初的button示例使用了value和previous变量来跟踪按钮的状态;使用Monitor类大大简化了代码,因为该类跟踪按钮本身的状态,仅在状态改变时通知应用程序。
像Digital构造函数一样,Monitor构造函数接受一个带有pin和mode属性的字典。它还包括一个edge属性,指定触发onChanged回调的事件;edge可能是Monitor.Rising、Monitor.Falling,也可能是Monitor.Rising | Monitor.Falling。应用程序必须在实例上安装一个onChanged回调,在指定的边缘事件发生时被调用。
使用Monitor类代替轮询的好处不仅仅是简化了代码。因为该类使用微控制器的内置硬件来检测变化,所以不需要运行任何代码来监视变化,从而为其他工作释放 CPU 周期。此外,监视器会立即检测到更改,而轮询方法每隔 100 毫秒才检查一次更改。当然,您可以更频繁地轮询,但是这需要更多的 CPU 周期。此外,轮询方法会错过两次读取之间发生的快速按键,而监视器总是处于活动状态,因此不会错过任何按键。
控制三色 LED
与前面的blink示例中的基本开/关单色 led 不同,三色 LED(也称为 RGB LED)将三种 LED(红色、绿色和蓝色)结合到一个封装中,使您能够精确控制颜色和亮度。控制三色 LED 的三种颜色需要四个引脚:一个引脚控制三个 LED 中的每一个,加上一个由所有颜色共享的电源引脚。
本节中的示例假设您使用的是普通阳极 LED,如图 6-5 所示,可从 SparkFun(产品 ID COM-10821)和 Adafruit(产品 ID 159)获得。

图 6-5
三色 LED
在运行示例之前,请按照说明设置三色 LED,并按照接线说明将其连接到 ESP32 或 ESP8266。
LED 设置
如图 6-6 所示,LED 要求在除电源引脚之外的所有引脚上增加限流电阻,以防止它们消耗过多的电流。使用 330 欧姆的电阻。

图 6-6
带限流电阻的三色 LED
ESP32 接线说明
表 6-3 和图 6-7 显示了如何将 LED 连接到 ESP32。

图 6-7
将 LED 连接到 ESP32 的接线图
表 6-3
将 LED 连接到 ESP32 的接线
|发光二极管
|
ESP32
|
| --- | --- |
| 压水反应堆 | 3V3 |
| 稀有 | GPIO12 (D12) |
| G | GPIO13 (D13) |
| B | GPIO14 (D14) |
ESP8266 接线说明
表 6-4 和图 6-8 显示了如何将三色 LED 连接到 ESP8266。

图 6-8
将 LED 连接到 ESP8266 的接线图
表 6-4
将 LED 连接到 ESP8266 的接线
|发光二极管
|
ESP8266
|
| --- | --- |
| 压水反应堆 | 3V3 |
| 稀有 | GPIO12 (D6) |
| G | GPIO13 (D7) |
| B | GPIO14 (D5) |
使用带三色 LED 的Digital
三色 LED 的红色、绿色和蓝色引脚连接到数字输出。在$EXAMPLES/ch6-hardware/tricolor-led-digital示例中,可以使用与在blink示例中用于控制简单单色 led 的相同的Digital类来单独控制它们。如清单 6-6 所示,区别在于你可以通过混合三原色来显示八种不同的颜色。
let r = new Digital(12, Digital.Output);
let g = new Digital(13, Digital.Output);
let b = new Digital(14, Digital.Output);
Timer.repeat(() => {
// black (all off)
r.write(1);
g.write(1);
b.write(1);
Timer.delay(100);
// red (red on)
r.write(0);
Timer.delay(100);
// magenta (red and blue on)
b.write(0);
Timer.delay(100);
// white (all on)
g.write(0);
Timer.delay(100);
}, 1);
Listing 6-6.
三色 LED 不仅可以显示黑色、白色、红色、绿色、蓝色、品红色、青色和黄色的原色和二次色。为了实现这一点,你需要更多的控制,而不仅仅是简单地打开和关闭红色、绿色和蓝色发光二极管;您需要能够将它们设置为 on 和 off 之间的值,即 0 和 1 之间的值。数字输出不能做到这一点,因为它的输出总是 0 或 1。在下一节中,您将学习如何克服这个限制。
使用带三色 LED 的PWM
为了显示更大范围的颜色和亮度,三色 led 可以改为使用脉宽调制或 PWM 来控制,这是一种常用于电机和 LED(包括三色 LED)的特殊类型的数字信号。PWM 大致相当于模拟输出,但使用数字信号产生。更具体地说,数字引脚输出具有不同高低值宽度的方波。随着时间的推移,取这些高和低脉冲的平均值,产生与脉冲宽度成比例的高和低值之间的功率水平。结果是,您可以输出介于 0 和 1 之间的任何值,而不是仅限于 0 和 1 作为输出值。
PWM类提供对 PWM 输出引脚的访问。$EXAMPLES/ch6-hardware/tricolor-led-pwm的例子使用了PWM和一个定时器来循环显示不同的颜色。
import PWM from "pins/pwm";
该示例需要三个PWM类的实例,三色 LED 上的每根电线一个,用于控制一种颜色的亮度。PWM构造函数获取一个指定 pin 号的字典。
let r = new PWM({pin: 12});
let g = new PWM({pin: 13});
let b = new PWM({pin: 14});
write方法设置引脚的当前值。您传递的值是一个从 0 到 1023 的数字,要合成的模拟值。较低的值对应较高的亮度。当应用程序运行时,它使 LED 变成绿色。PWM 值为 0 相当于数字输出设为 0,PWM 值为 1023 相当于数字输出设为 1。以下代码通过将绿色 LED 设置为全亮度并将红色和蓝色 LED 设置为关闭,将三色 LED 设置为绿色:
r.write(1023);
g.write(0);
b.write(1023);
如清单 6-7 所示,代码通过调整单个管脚的值来循环显示颜色。首先,它通过降低蓝色引脚的值将颜色从绿色变为青色。在对write的调用之间,Timer类的delay方法用于将执行延迟 50 毫秒。
while (bVal >= 21) {
bVal -= 20;
b.write(bVal);
Timer.delay(50);
}
b.write(1);
Listing 6-7.
从绿色渐变为青色后,LED 从青色渐变为蓝色,蓝色渐变为洋红色,最后洋红色渐变为红色(列表 6-8 )。
while (gVal <= 1003) {
gVal += 20;
g.write(gVal);
Timer.delay(50);
}
g.write(1023);
while (rVal >= 21) {
rVal -= 20;
r.write(rVal);
Timer.delay(50);
}
r.write(0);
while (bVal <= 1003) {
bVal += 20;
b.write(bVal);
Timer.delay(50);
}
b.write(1023);
Listing 6-8.
旋转伺服系统
伺服电机是控制旋转输出的电机。输出可以精确地转向一个弧内的特定位置,通常为 180 度。伺服系统通常用于机器人学中,以控制机器人的运动,并用于旋转物体,如照相机的镜头,以控制聚焦和变焦。图 6-9 显示了 Adafruit 提供的微型伺服系统(产品 ID 169)。像这样的微型伺服系统可以使用 ESP32 或 ESP8266 供电。也有更大,更强大的伺服移动更大的物体;这些伺服系统需要比微控制器所能提供的更多的功率来运行,因此需要外部电源。

图 6-9
Adafruit 的微伺服系统
伺服系统配置有Servo类,它使用数字引脚来控制伺服电机。
$EXAMPLES/ch6-hardware/servo示例将伺服从 0 度旋转到 180 度,每次 2.5 度。在运行该示例之前,请按照这里给出的接线说明将其连接到您的 ESP32 或 ESP8266。
ESP32 接线说明
表 6-5 和图 6-10 显示了如何将伺服连接到 ESP32。

图 6-10
将伺服连接到 ESP32 的接线图
表 6-5
将伺服机构连接到 ESP32 的接线
|伺服系统
|
ESP32
|
| --- | --- |
| 压水反应堆 | 3V3 |
| 地线 | 地线 |
| 伺服(DOUT) | GPIO14 (D14) |
ESP8266 接线说明
表 6-6 和图 6-11 显示了如何将伺服连接到 ESP8266。

图 6-11
将伺服连接到 ESP8266 的接线图
表 6-6
将伺服机构连接到 ESP8266 的接线
|伺服系统
|
ESP8266
|
| --- | --- |
| 压水反应堆 | 3V3 |
| 地线 | 地线 |
| 伺服(DOUT) | GPIO14 (D5) |
理解servo代码
Servo类使用数字引脚来控制伺服电机:
import Servo from "pins/servo";
servo示例创建了一个Servo类的实例,并通过调用实例的write方法定期改变位置。如清单 6-9 所示,write方法的参数是旋转到的角度;请注意,这可能是一个分数。
let servo = new Servo({pin: 14});
let angle = 0;
Timer.repeat(() => {
angle += 2.5;
if (angle > 180)
angle -= 180;
servo.write(angle);
}, 250);
Listing 6-9.
伺服旋转到新位置需要时间;时间的长短取决于你使用的伺服。根据伺服机构的不同,使用较短的时间间隔可能不会使伺服机构旋转得更快,而是可能会导致混乱的行为,因为伺服机构会尽最大努力跟上变化,而变化的速度会超过其运行速度。
Servo类也有一个writeMicroseconds方法,通过让您提供信号脉冲的微秒数(而不是度数),它允许更高的精度。可接受值的范围因伺服机构而异;将脉冲长度设置得太低或太高都会损坏伺服系统,所以一定要检查伺服系统的数据表。
获取温度
测量温度是物联网产品的一项常见任务,传感器制造商已经创建了许多不同的温度传感器。这些传感器使用各种硬件协议与微控制器通信。本节解释了两种易于使用且广泛可用的方法:

图 6-12
TMP36 传感器
- TMP36(图 6-12 )使用模拟值来传达温度。这是两个传感器中较为简单的一个,它只有一个输出,即连接到微控制器模拟输入的模拟输出,并且没有配置选项。它可以从 SparkFun(产品 ID SEN-10988)和 Adafruit(产品 ID 165)获得。

图 6-13
TMP102 传感器
- TMP102(图 6-13 )使用 I 2 C 总线进行温度通信。它使用 I 2 C 硬件协议进行连接,这比模拟输入复杂得多,但使传感器能够提供额外的功能和配置选项。它可以从 SparkFun 获得(产品 ID SEN-13314)。
本节还说明了如何使用传感器的数据手册来理解传感器提供的数据,并将其转换为人类可读的格式。
TMP36
$EXAMPLES/ch6-hardware/tmp36示例从 TMP36 传感器读取温度,并将以摄氏度为单位的值跟踪到调试控制台。在运行该示例之前,请按照这里给出的接线说明将 TMP36 连接到您的 ESP32 或 ESP8266。
ESP32 接线说明
表 6-7 和图 6-14 显示了如何将 TMP36 连接到 ESP32。

图 6-14
连接 TMP36 和 ESP32 的接线图
表 6-7
连接 TMP36 和 ESP32 的接线
|TMP36
|
ESP32
|
| --- | --- |
| 压水反应堆 | 3V3 |
| 模拟的 | NodeMCU 板上的 ADC0 (VP)可修改 2 上的 ADC7 (GP35) |
| 地线 | 地线 |
ESP8266 接线说明
表 6-8 和图 6-15 显示了如何将 TMP36 连接到 ESP8266。

图 6-15
连接 TMP36 和 ESP8266 的接线图
表 6-8
连接 TMP36 和 ESP8266 的接线
|TMP36
|
ESP8266
|
| --- | --- |
| 压水反应堆 | 3V3 |
| 模拟的 | ADC0 (A0) |
| 地线 | 地线 |
理解tmp36代码
TMP36 上的模拟引脚输出与温度成比例的电压。Analog类提供对设备上模拟输入的访问:
import Analog from "pins/analog";
与其他硬件协议类不同,Analog类从不被实例化。它只提供了一个静态方法:一个对指定管脚的值进行采样的read方法,返回值从 0 到 1023。tmp36示例调用read方法,并将返回的电压转换为温度。
TMP36 ( learn.adafruit.com/tmp36-temperature-sensor/overview )的优秀 Adafruit 教程提供了以下将电压转换为温度的公式:
温度单位为摄氏度= [(Vout 单位为毫伏)–500]/10
tmp36的例子就是基于这个公式,正如你在这里看到的:
let value = (Analog.read(0) / 1023) * 330 - 50;
trace(`Celsius temperature: ${value}\n`);
Note
如果你运行在 Moddable Two 上,由于接线不同,你需要将 pin 号从 0 改为 7。
TMP36 设计用于精确测量–40°C 至+125°C 之间的温度,对于超出此范围的温度,它会返回读数,但精度较低。模拟输入具有 10 位分辨率,读数精度约为 0.25°c,这一精度足以满足多种用途,但并非所有用途;如下所述,TMP102温度传感器为温度测量提供了更高的分辨率。
TMP102
$EXAMPLES/ch6-hardware/tmp102示例从 TMP102 传感器读取温度,并将以摄氏度为单位的值跟踪到调试控制台。在运行该示例之前,请按照这里给出的接线说明将 TMP102 连接到您的 ESP32 或 ESP8266。
本节参考 TMP102 原理图和 TMP102 数据手册,二者均可在 SparkFun 网站的 TMP102 产品页面上找到: sparkfun.com/products/13314 。
ESP32 接线说明
表 6-9 和图 6-16 显示了如何将 TMP102 连接到 ESP32。

图 6-16
连接 TMP102 和 ESP32 的接线图
表 6-9
连接 TMP102 和 ESP32 的接线
|TMP102
|
ESP32
|
| --- | --- |
| 地线 | 地线 |
| VCC | 3V3 |
| 国家药品监督管理局 | GPIO21 (D21) |
| 圣地亚哥 | GPIO22 (D22) |
ESP8266 接线说明
表 6-10 和图 6-17 显示了如何将 TMP102 连接到 ESP8266。

图 6-17
连接 TMP102 和 ESP8266 的接线图
表 6-10
连接 TMP102 和 ESP8266 的接线
|TMP102
|
ESP8266
|
| --- | --- |
| 地线 | 地线 |
| VCC | 3V3 |
| 国家药品监督管理局 | GPIO5 (D1) |
| 圣地亚哥 | GPIO4 (D2) |
理解tmp102代码
tmp102示例从 TMP102 检索温度数据,并将其转换为摄氏度,以输出到调试控制台。这个特殊的例子值得仔细研究,因为它引入了在大量传感器中使用的I2C硬件协议。I 2 C 是一种串行协议,用于将多个设备连接到单条双线总线。
一旦你学会了在 JavaScript 中使用 I 2 C 的基本原理,你就可以根据硬件数据表或示例实现(如 Arduino 草图)快速编写代码与新的传感器进行通信。使用 I 2 C 的 SMBus 子集的另一种方法将在下一节讨论。了解如何使用 I 2 C 和 SMBus 将使你能够探索大量可用传感器的许多选项。
请注意,本章介绍了 TMP102 的许多功能,但并非全部。数据手册是了解任何传感器性能的最佳途径。对 TMP102 的进一步研究表明,它包括了用于恒温器等的特性。
I 2 C 受欢迎的一个特点是,因为它是一条总线,它允许几个不同的传感器连接到相同的两个微控制器引脚。每个传感器都有一个唯一的地址,可以独立访问。将总线用于这种硬件协议减少了连接多个传感器所需的引脚总数,这是很有价值的,因为可用引脚的数量通常是有限的。
I2C类提供对连接到一对管脚的 I 2 C 总线的访问:
import I2C from "pins/i2c";
tmp102示例创建了一个I2C类的实例。传递给构造函数的字典包含目标设备的 I 2 C 地址。您可以通过指定sda和scl属性在字典中包含 pin 号;此示例使用目标设备的默认管脚,因此不在字典中指定管脚号。ESP32 或 ESP8266 的默认引脚号与上图中的接线相匹配。电路板的地址0x48在 TMP102 原理图中给出。
let sensor = new I2C({address: 0x48});
这个I2C类的实例现在可以访问 I 2 C 总线上地址为0x48的传感器。TMP102 有四个 16 位寄存器,通过 I 2 C 使用读写来访问。寄存器如表 6-11 所示。(另请参见 TMP102 数据手册的表 1 和表 2。)
表 6-11
TMP102 寄存器
|注册号码
|
寄存器名
|
目的
|
| --- | --- | --- |
| Zero | 温度 | 读取最近的温度 |
| one | 配置 | 读取或设置温度转换率、电源管理等选项 |
| Two | T 低电平 | 使用内置比较器时,读取或设置低温 |
| three | T 高电平 | 使用内置比较器时,读取或设置高温 |
要读取或写入寄存器,首先要将目标寄存器编号写入器件。一旦完成,你就可以读取或写入寄存器值。该示例读取温度(寄存器 0),因此它首先将值 0 写入传感器,然后读取两个字节。
const TEMPERATURE_REG = 0;
sensor.write(TEMPERATURE_REG);
let value = sensor.read(2);
read方法返回名为value的Uint8Array实例中的字节。它可以从目标设备读取多达 40 个字节,尽管大多数 I 2 C 读取的只是几个字节。
在读取的两个字节中,第一个字节是最高有效字节。第二个字节(最低有效位)的低 4 位设为 0,分辨率为 12 位。以下代码行将value数组中的两个字节组合成一个 12 位整数值:
value = (value[0] << 4) | (value[1] >> 4);
该值的格式如 TMP102 数据手册表 5 所示。负值以二进制补码格式表示。如果value的第一位为 1,则温度低于 0 ℃,需要额外的计算(列表 6-10 )来生成正确的负值。
if (value & 0x800) {
value -= 1;
value = ~value & 0xFFF;
value = -value;
}
Listing 6-10.
最后一步是将该值转换为摄氏度,并跟踪到调试控制台。由于总分辨率为 12 位,其中 4 位用于数值的小数部分,因此 TMP102 提供的温度值精确到 0.0625°C 以内,温度读数的精确范围为–55°C 至+128°C。
value /= 16;
trace(`Celsius temperature: ${value}\n`);
使用 SMBus
系统管理总线或 SMBus 协议建立在 I 2 C 之上。它使用 I 2 C 定义的方法子集来形式化许多基于寄存器的 I 2 C 设备使用的约定,包括 TMP102。如前所述,TMP102 使用四个寄存器在传感器和微控制器之间读写值。要读取或写入寄存器,首先要发送寄存器号,然后发送读或写命令。
您可以像前面一样使用 I 2 C 与 SMBus 设备通信,但是由于 SMBus 设备非常常见,为了方便起见,Moddable SDK 包含了一个SMBus类:
import SMBus from "pins/smbus";
SMBus类是I2C类的子类,它的构造函数接受相同的字典参数。SMBus增加了对I2C的额外调用,以直接读写寄存器。在tmp102的例子中,使用I2C直接读取寄存器需要两次调用:一次写操作将寄存器设置为读取,然后是实际读取。SMBus将这两个调用合并成一个单独的readWord调用。
let sensor = new SMBus({address: 0x48});
let value = sensor.readWord(TEMPERATURE_REG, true) >> 4;
readWord方法有两个参数:首先是要读取的寄存器,然后是true(如果两个字节是大端顺序)或false(如果是小端顺序)(默认)。因为这里返回的第一个字节是最重要的字节,值是 big-endian,所以第二个参数是true。由于这两个字节已经组合成一个整数,剩下的就是右移 4 位来生成 12 位值。
SMBus类提供readByte来读取单个字节,提供readBlock来读取指定数量的字节。它还提供了相应的写入方法writeByte、writeWord和writeBlock。
配置 TMP102
TMP102 能够支持各种配置选项,因为它通过 I 2 C 进行通信,这是一种灵活且可扩展的硬件协议。本节讨论四个这样的选项。
注意,为了简化代码,本节中的例子使用了I2C的SMBus子类,而不是直接使用I2C。
使用扩展模式读取更高的温度
TMP102 可以测量最高 150°C 的温度,但要做到这一点,需要将分辨率从默认的 12 位提高到 13 位,这可以通过使能扩展模式来实现。与 TMP102 的大多数选项一样,该模式由 16 位配置寄存器(寄存器 1)控制。要使能扩展模式,需要将配置寄存器中的 EM 位设为 1。
因为配置寄存器控制许多选项,为了避免无意中更改选项,代码(清单 6-11 )首先读取配置寄存器的当前值,然后设置 EM 位,最后写回该值。
const CONFIGURATION_REG = 1;
const EM_MASK = 0b0000_0000_0001_0000;
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG, configuration | EM_MASK,
true);
Listing 6-11.
在您自己的物联网产品中,您可能已经知道配置寄存器的值,而不需要读取它。在这种情况下,您可以直接设置它,无需初始读取。
使能扩展模式时,温度寄存器返回 13 位值,而不是 12 位值,这需要在摄氏度的计算中稍作调整。在 SMBus 版本中,右移位值从 4 变为 3,负数的计算也发生变化。清单 6-12 显示了修改后的代码。
let sensor = new SMBus({address: 0x48});
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG, configuration | EM_MASK,
true);
let value = sensor.readWord(TEMPERATURE_REG, true) >> 3;
if (value & 0x1000) {
value -= 1;
value = ~value & 0x1FFF;
value = -value;
}
value /= 16;
trace(`Celsius temperature: ${value}\n`);
Listing 6-12.
设置转换率
转换速率是指 TMP102 每秒完成一次温度测量并更新温度寄存器中的值的次数。TMP102 完成一次温度测量大约需要 26 毫秒。默认情况下,转化率为每秒四次。从读取数据到开始下一次读取数据之间的 224 毫秒内,TMP102 进入低功耗模式,功耗降低约 94%,从 40 μA 降至 2.2 μA。
了解转换率对您的应用很重要。如果从传感器读取温度的频率高于温度更新的频率,您会收到相同的值,不必要地使用有限的 CPU 周期。另一方面,如果传感器执行温度读数的频率高于您的应用要求,那么它使用的能量会超过需要,因为它会产生未使用的读数。
转换速率由配置寄存器中的 2 位控制,因此有四种可能的值,如下所示(以及数据手册的表 8):
-
00–每 4 秒一次(0.25 赫兹)
-
01–每秒一次(1 赫兹)
-
10–每秒四次(4 Hz,默认)
-
11–每秒八次(8 赫兹)
清单 6-13 中的代码将转换率设置为每秒 8 次。
const CONVERSION_RATE_SHIFT = 6;
const CONVERSION_RATE_MASK = 0b0000_0000_1100_0000;
let configuration = sensor.readWord(CONFIGURATION_REG, true);
configuration &= ~CONVERSION_RATE_MASK;
sensor.writeWord(CONFIGURATION_REG,
configuration | (0b11 << CONVERSION_RATE_SHIFT), true);
Listing 6-13.
关机模式节能
降低温度转换的频率可以节省能源。然而,最低的频率仍然是每 4 秒转换一次,这可能比您的物联网产品要求的频率更高。TMP102 提供关断模式,完全禁用温度转换硬件,将功耗降至 0.5 μA,您的应用可以在读数间隔期间进入关断模式,然后重新使能转换。
清单 6-14 中的代码通过设置配置寄存器中的关机模式位进入关机模式。
const SHUTDOWN_MODE_MASK = 0b0000_0001_0000_0000;
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG,
configuration | SHUTDOWN_MODE_MASK, true);
Listing 6-14.
退出关断模式类似于进入关断模式,但会清除关断模式位,而不是将其置位:
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG,
configuration & ~SHUTDOWN_MODE_MASK, true);
退出关断模式时需要记住一个重要细节,因为转换大约需要 26 毫秒,所以退出关断模式后立即读取温度寄存器会返回一个陈旧值。要在不阻止执行的情况下等待新的温度读数完成,请使用一个计时器,如清单 6-15 所示。
Timer.set(() => {
let value = sensor.readWord(TEMPERATURE_REG);
// Perform conversion to Celsius as before
...
}, 27);
Listing 6-15.
获取一次性温度读数
至此,您已经将 TMP102 传感器配置为定期连续获取温度读数。TMP102 还支持单次触发模式,只读取一个读数(参见清单 6-16 )。单次触发功能仅在器件处于关断模式时可用,读取完成后,TMP102 返回关断状态。这使得它成为最节能的方式来获取不频繁的读数,例如,如果您的产品每小时获取一次读数,或者只响应用户按下的按钮。
const ONESHOT_MODE_MASK = 0b1000_0000_0000_0000;
let configuration = sensor.readWord(CONFIGURATION_REG, true);
sensor.writeWord(CONFIGURATION_REG,
configuration | ONESHOT_MODE_MASK, true);
Listing 6-16.
启用单次触发模式后,需要等待读数准备就绪,大约需要 26 毫秒。然而,您可以使用一次性模式的一个特殊功能,让您知道何时可以读取,而不是等待一个固定的时间间隔。这一点很重要,因为读取读数所需的实际时间因当前温度而异。在配置寄存器中将单触发位设置为 1 后,轮询该位以了解新读数何时准备好;当温度读数正在进行时,TMP102 返回 0,当读数可用时,返回 1。清单 6-17 显示了等待读数准备就绪的代码。
while (true) {
let configuration = sensor.readWord(CONFIGURATION_REG, true);
if (configuration & ONESHOT_MODE_MASK)
break;
}
// new temperature reading now available
Listing 6-17.
前面的代码在等待温度读数时阻止执行。这对于某些产品来说是可以接受的,但对于其他产品来说却不是。要执行非阻塞轮询,请使用计时器(清单 6-18 )。
Timer.repeat(id => {
let configuration = sensor.readWord(CONFIGURATION_REG, true);
if (!(configuration & ONESHOT_MODE_MASK))
return;
Timer.clear(id);
// new temperature reading now available
}, 1);
Listing 6-18.
一次性模式还有另一个有趣的用途。由于读取温度需要大约 26 毫秒,理论上每秒可以读取大约 38 个读数。但是,请记住,配置寄存器支持的最大转换速率是每秒 8 次。使用连续、连续的单次读数,可以在硬件支持的情况下尽可能快地获取温度读数,这对于想要精确捕捉温度随时间变化的情况非常有用。
结论
既然您已经了解了一些硬件协议的基础知识,并且知道如何与一些传感器和执行器交互,那么您可以做很多事情来使提供的简单示例更加有趣。例如,你可以让执行器响应来自传感器的输入,或者利用你在第三章中学到的关于与云服务通信的知识,用它将数据从你的传感器传输到云端。在第 8 、 9 和 10 章中,你将学习如何使用触摸屏,这对于显示传感器数据和构建与硬件配合使用的用户界面非常有用。
在网上和电子商店里可以买到不计其数的其他传感器和执行器。本章使用了来自 SparkFun 和 Adafruit 的一些内容,这两个网站都是电子初学者的优秀资源。除了提供许多传感器和致动器及其数据表,他们还提供了许多产品的教程,这是编写自己的 JavaScript 模块与它们进行交互的有用起点。
七、声音
声音是向设备用户传达信息的一种很好的方式。您可以使用声音为用户操作(如点击按钮)提供反馈,在后台任务(如计时器或下载)完成时提醒用户,等等。
ESP32 和 ESP8266 都支持音频回放。包括 M5Stack FIRE 在内的一些开发板都内置了扬声器。如果您的主板没有扬声器,您可以自己安装一个。在本章中,您将学习如何使用便宜的扬声器播放声音,这种扬声器很容易直接连接到 ESP32 或 ESP8266。您还将了解如何使用外部 I 2 S 音频驱动器实现更高质量的音频回放,以及如何为您的项目选择最佳音频格式,平衡质量和存储空间。
扬声器选项
如果您没有使用带有内置扬声器的开发板,您需要在运行示例之前将扬声器连接到您的设备。
图 7-1 显示了 Adafruit(产品 ID 1890)生产的迷你金属扬声器,可与 ESP32 和 ESP8266 配合使用。这是一个简单的模拟扬声器,阻抗为 8 欧姆,功耗为 0.5W。你可以找到许多类似的不同阻抗和功耗的产品。8 欧姆、0.5W 扬声器是一个很好的起点,因为它可以与 ESP32 和 ESP8266 使用相同的电源,而更大的扬声器需要外部电源。

图 7-1
Adafruit 的迷你金属扬声器
迷你金属扬声器可以直接连接到您的设备上,这是一种快速入门的简单方法。但是,你可以通过增加一个 I 2 S 芯片来获得更好的音质。图 7-2 显示了一个来自 Adafruit 的 I 2 S 芯片(产品 ID 3006)。这个芯片也能放大声音。

图 7-2
我从阿达果公司买了 2 块芯片
I 2 S 芯片本身不播放声音;你还得给它装一个扬声器。迷你金属扬声器与 I 2 S 芯片一起工作;然而,廉价的扬声器会影响质量。对于更高质量的音频,使用更高质量的扬声器,例如图 7-3 所示的 Adafruit 公司生产的单声道封闭式扬声器(产品 ID 3351)。

图 7-3
Adafruit 的单声道封闭式扬声器
一个 I 2 S 芯片增加了额外的成本,但如果你的产品需要高质量的声音,这可能是必要的。此外,使用 I 2 S 芯片在 ESP8266 上的 CPU 开销更低,这也可能使其物有所值。你可以决定哪个选项最适合你。
如果您只是想尝试一下可修改的 SDK 的音频播放功能,最快的方法是从模拟扬声器开始。如果你后来决定想要更高质量的音频,你可以随时切换到使用 I 2 S 芯片和单声道封闭式扬声器。无论您选择哪种设置,播放音频的 JavaScript APIs 都是相同的,因此您不必更改应用程序代码。但是,您必须为每个选项配置不同的音频设置。本章的主持人负责他们的manifest.json文件中的音频配置。他们假设你正在使用图 7-1 所示的扬声器或者图 7-2 和 7-3 所示的 I 2 S 芯片和扬声器。
添加模拟扬声器
本节说明如何将模拟扬声器连接到您的 ESP32 或 ESP8266。
ESP32 接线说明
表 7-1 和图 7-4 显示了如何将扬声器连接到 ESP32。
表 7-1
将扬声器连接到 ESP32 的接线
|扬声器
|
ESP32
|
| --- | --- |
| 电线 1 | GPIO25 (D25) |
| 电线 2 | 地线 |
扬声器的哪根线连接到 GPIO25,哪根线连接到 ESP32 上的 GND,都无关紧要。

图 7-4
将扬声器连接到 ESP32 的接线图
ESP8266 接线说明
表 7-2 和图 7-5 显示了如何将扬声器连接到 ESP8266。

图 7-5
将扬声器连接到 ESP8266 的接线图
表 7-2
将扬声器连接到 ESP8266 的接线
|扬声器
|
ESP8266
|
| --- | --- |
| 电线 1 | GPIO3 (RX) |
| 电线 2 | 地线 |
请注意,ESP8266 上的 GPIO3 用于与您的计算机进行串行通信,用于安装和调试。这意味着您不能使用xsbug来调试音频示例,并且安装音频示例需要一些额外的步骤:
-
断开扬声器与 GPIO3 的连接。
-
照常安装一个例子。
-
将扬声器重新连接到 GPIO3。
-
重置 ESP8266 以运行该示例。
如果你使用的是 Moddable One,GPIO3 在你连接编程适配器的小连接器上。安装音频示例后,断开编程适配器,连接扬声器,并使用 USB 电缆为可修改的音频示例供电。
在 ESP8266 上,扬声器的哪根线连接到 GPIO3,哪根线连接到 GND 都无关紧要。
增加一个 I 2 S 芯片和数字扬声器
本节介绍如何将 I 2 S 芯片连接到您的 ESP32 或 ESP8266,以及如何将数字扬声器连接到 I 2 S 芯片。
ESP32 接线说明
表 7-3 显示了如何将 I 2 S 芯片连接到 ESP32。
表 7-3
将 I 2 S 芯片连接到 ESP32 的接线
|我的芯片
|
ESP32
|
| --- | --- |
| 法律改革委员会(Law Reform Commission) | GPIO12 (D12) |
| BCLK 公司 | GPIO13 (D13) |
| 联邦德国工业标准 | GPIO14 (D14) |
| 地线 | 地线 |
| 酒 | 3V3 |
表 7-4 显示了如何将扬声器连接到 I 2 S 芯片。
表 7-4
将扬声器连接到 I 2 S 芯片的接线
|扬声器
|
我的芯片
|
| --- | --- |
| 黑线 | – |
| 红线 | + |
图 7-6 显示了完整设置的接线图。

图 7-6
扬声器、I 2 S 芯片和 ESP32 的接线图
ESP8266 接线说明
表 7-5 显示了如何将 I 2 S 芯片连接到 ESP8266。请注意,GPIO2 和 GPIO15 在可修改的设备上不可用,因此您不能在可修改的设备上使用 I 2 S。
表 7-5
将 I 2 S 芯片连接到 ESP8266 的接线
|我的芯片
|
ESP8266
|
| --- | --- |
| 法律改革委员会(Law Reform Commission) | GPIO2 (D4) |
| BCLK 公司 | GPIO15 (D8) |
| 联邦德国工业标准 | GPIO3 (RX) |
| 地线 | 地线 |
| 酒 | 3V3 |
表 7-6 显示了如何将扬声器连接到 I 2 S 芯片。
表 7-6
将扬声器连接到 I 2 S 芯片的接线
|扬声器
|
我的芯片
|
| --- | --- |
| 黑线 | – |
| 红线 | + |
图 7-7 显示了完整设置的接线图。

图 7-7
扬声器、I 2 S 芯片和 ESP8266 的接线图
安装音频主机
本章中的示例使用第一章中描述的模式进行安装:您使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。
在$EXAMPLES/ch7-audio/host-pdm和$EXAMPLES/ch7-audio/host-i2s目录中有两个可用的主机应用程序。两者的区别在于它们如何配置音频设置。如果您使用 I 2 S 芯片和扬声器组合,请使用host-i2s,如果您仅使用扬声器,请使用host-pdm。从命令行导航到目录,用mcconfig安装。
AudioOut类
使用AudioOut类将声音传送到扬声器:
import AudioOut from "pins/audioout";
AudioOut类支持以每样本 8 或 16 位回放未压缩的单声道或立体声音频,以及回放使用 IMA ADPCM 算法压缩的单声道音频。内置混音器可以组合多达四个声道的音频进行同步播放。它可以在音频回放期间的指定点生成回调,例如,将屏幕绘图与音频回放同步。AudioOut产生 8 位或 16 位音频输出,并将其发送至伪模拟输出或数字 I 2 S 数模转换器。
有了这么多的功能,使用音频需要了解可用选项的权衡,以帮助您决定在产品中播放音频的最佳方式。
AudioOut配置
本节描述了音频硬件协议、数据格式和AudioOut类的其他配置选项。对于本章中的示例,设置是在主机的清单中配置的。
音频硬件协议
如本节所述,AudioOut类支持两种不同的硬件协议,PDM 和 I 2 。
脉冲密度调制(PDM)
脉冲密度调制或 PDM ,是 PWM 的一种变体,它快速切换数字输出引脚,以创建与所需输出信号相对应的能量水平。这种播放音频的方式有时被称为模拟音频输出,因为 PDM 转换会合成一个信号,该信号经过一段时间的平均后,与模拟信号输出的能量水平相匹配。
PDM 的优势在于它只与微控制器的内置数字输出硬件一起工作。PDM 的一个缺点是音频质量较低;因此,PDM audio 主要用于用户界面或游戏中的音效,而不是音乐或口语。
ESP32 具有内置硬件,可将音频数据转换为 PDM,因此使用该协议时没有 CPU 开销。然而,ESP8266 没有 PDM 转换硬件;转换在软件中进行,因此会占用一些 CPU 周期。
清单的defines部分配置 PDM 输出。对于 ESP32,它看起来像清单 7-1 。
"defines": {
"audioOut": {
"i2s": {
"DAC": 1
}
}
}
Listing 7-1.
当设置为 1 时,DAC属性告诉AudioOut实现使用 PDM 输出。没有指定输出引脚,因为只有 ESP32 上的数字引脚 25 支持 PDM 输出。
对于 ESP8266,清单部分略有不同(清单 7-2 )。
"defines": {
"audioOut": {
"i2s": {
"pdm": 32
}
}
}
Listing 7-2.
非零值的pdm属性表示使用 PDM 输出。该值必须是 32、64 或 128。值 32 指定在转换中不应执行过采样;这需要较少的时间和内存,但会导致较低质量的输出。值越大,质量越好。
一秒钟
AudioOut类支持的另一个硬件协议是 I 2 S,一个专为连接数字音频设备而设计的协议。I 2 S 通过数字连接将未修改的音频样本从微控制器传输到专用音频输出组件,该组件使用专门的算法和硬件执行数模转换,以生成高质量的结果。ESP32 和 ESP8266 都有内置的硬件支持,可使用 I 2 传输音频数据,因此微控制器上播放音频的 CPU 开销非常小。
使用 I 2 S 需要一个外部元件,这是一个额外的成本,并且使用至少两个,通常是三个数字引脚,而 PDM 输出仅使用一个数字引脚。另一方面,I 2 S 音频硬件产生了非常高质量的音频输出,因此质量的限制因素变成了用于输出的扬声器,而不是数字样本转换成模拟信号的方式。
I 2 S 零件千差万别。一些没有配置选项,而另一些包括 I 2 C 连接来配置器件,并且在配置完成之前不能正常工作。本节假设您正在使用不需要配置或已经配置的 I 2 S 部件。
清单的defines部分配置 I 2 的输出。对于 ESP32,这看起来像清单 7-3 。
"defines": {
"audioOut": {
"i2s": {
"bck_pin": 13,
"lr_pin": 12,
"dataout_pin": 14,
"bitsPerSample": 16
}
}
}
Listing 7-3.
bck_pin、lr_pin、dataout_pin属性对应于 I 2 S 硬件协议的三个管脚。默认值分别为 26、25 和 22。bitsPerSample属性表示通过 I 2 S 连接传输的样本大小(以位为单位)。对于许多 I 2 S 组件,这是 16,默认值,但对于其他组件,需要 32 位。
对于 ESP8266,清单部分要简单得多,如清单 7-4 所示,因为 I 2 S 引脚是在硬件中定义的,不能更改。将pdm属性设置为 0 会禁用 PDM 输出,而使用 I 2 S 硬件协议。I 2 S 引脚为 15 ( bck_pin)、2 ( lr_pin)和 3 ( dataout_pin)。
"defines": {
"audioOut": {
"i2s": {
"pdm": 0
}
}
}
Listing 7-4.
ESP8266 实现只支持 16 位样本输出,所以没有bitsPerSample属性。
音频数据格式
您的应用程序播放的音频数据必须以与AudioOut类和连接到微控制器的音频输出硬件兼容的格式存储。为了最大化效率和简单性,AudioOut使用自定义数据格式来存储数字音频;这种格式被称为 MAUD,是 Moddable Audio 的缩写。它由一个简单的标题和音频样本组成。您用来构建应用程序的工具知道如何将包含未压缩音频的标准 WAVE 音频文件(文件扩展名为.wav的文件)转换为 MAUD 资源,无需您自己创建 MAUD 文件。转换工具名为wav2maud,由mcconfig和mcrun自动调用。如果您的音频以另一种格式存储,例如 MP3,您必须先将其转换为 WAVE 文件;免费的 Audacity 应用程序是完成这项任务的好工具。
为简单起见,AudioOut类要求所有播放的音频样本具有与音频输出相同的每样本位数、通道数和采样率。这消除了在微控制器上用软件执行格式转换的需要。这些AudioOut属性在清单中配置,如清单 7-5 所示。
"defines": {
"audioOut": {
"bitsPerSample": 16,
"numChannels": 1,
"sampleRate": 11025
}
}
Listing 7-5.
属性可以是 8 或 16,尽管 16 更常见。同样的,numChannels属性可能是 1(单声道)或者 2(立体声);然而,很少在微控制器上为用户界面交互播放立体声,因此该值通常为 1。
要在应用程序中包含音频数据,需要将它们作为资源添加到清单中,如清单 7-6 所示。
"resources": {
"*": [
"./bflatmajor"
]
},
Listing 7-6.
当mcconfig和mcrun处理清单时,它们调用wav2maud将文件bflatmajor.wav转换成 MAUD 格式的资源。音频被转换,使得 MAUD 资源中音频的每样本比特数、通道数和采样率与清单的audioOut部分中定义的相匹配。根据前面的示例,音频样本是 16 位单声道,采样速率为 11,025 Hz。
音频压缩
音频数据会占用大量的存储空间。10 秒钟 8 KHz 的 16 位单声道音频使用 160,000 字节的存储空间,约为 ESP8266 的 1 MB 闪存地址空间的 15%,仍仅相当于模拟电话呼叫的质量。音频压缩通常用于减小存储在数字设备上并通过互联网传输的音频的大小。那里使用的算法,包括 MP3、AAC 和 Ogg,几乎不能在大多数微控制器上运行,所以它们在这里不实用。IMA ADPCM(自适应差分脉码调制)是一种更简单的音频压缩格式,可对 16 位音频样本进行 4:1 压缩,复杂度远低于 MP3、AAC 或 Ogg,适合在 ESP32 和 ESP8266 上实时使用。
要使用 IMA ADPCM,将format属性添加到您的manifest.json文件的audioOut部分:
"audioOut": {
... // other audioOut configuration
"format": "ima"
}
在构建过程中,您的音频会自动压缩。前面提到的 10 秒 16 位单声道 8 KHz 音频从 160,000 字节缩减到 40,000 字节。质量有所下降,但对于许多目的来说,例如,用户界面声音效果,这种差异可能是不明显的。
设置音频队列长度
音频队列的长度在构建时是固定的,通过在修改队列时消除对内存分配的需求来提高音频回放的运行时效率。默认的队列长度是八个条目,这对于大多数目的来说已经足够了,包括本章中的所有例子。如果需要更多的队列条目,可以通过在清单的audioOut部分定义queueLength属性来更改队列长度。
"audioOut": {
... // other audioOut configuration
"queueLength": 20
}
每个队列条目都使用一些内存(在撰写本文时为 24 个字节),所以您不应该分配超过您需要的内存。如果您的项目简单地使用音频,您可以降低默认值来恢复该内存。
使用AudioOut播放音频
AudioOut类提供了各种不同的音频回放功能,帮助您将音频反馈整合到项目的用户体验中。回放引擎能够无缝地回放样本序列。它提供了一种回调机制来同步音频和用户体验的其他部分。它甚至支持多通道音频的实时混合,这种功能在微控制器上很少见。本节将介绍这些功能以及许多其他功能。
实例化AudioOut
AudioOut构造函数接受一个字典来配置音频输出。$EXAMPLES/ch7-audio/sound示例如下配置AudioOut实例:
let speaker = new AudioOut({streams: 1});
流的数量表示可以同时播放的声音的数量,最多四个。因为每个流都使用一些额外的内存,所以最好只根据需要配置AudioOut实例。基本的sound示例播放单个声音,因此它只需要一个流。
采样率、每个样本的位数和通道数是在清单中定义的,所以它们不会作为属性在字典中传递来配置AudioOut实例。音频资源以相同的格式存储,因为mcconfig、mcrun和wav2maud执行任何需要的格式转换。
播放单个声音
要播放声音,首先使用enqueue方法将一个音频样本加入到AudioOut实例的流中。$EXAMPLES/ch7-audio/sound示例将音频资源bflatmajor.maud排入流 0,如下所示:
speaker.enqueue(0, AudioOut.Samples,
new Resource("bflatmajor.maud"));
要开始播放排队的音频样本,请调用start方法:
speaker.start();
要停止AudioOut实例上的所有音频回放,调用stop方法:
speaker.stop();
重复一个声音
如果您想不止一次地播放一个声音,您可以向enqueue方法传递一个可选的repeat参数。以下是如何播放一个声音四次:
speaker.enqueue(0, AudioOut.Samples,
new Resource("bflatmajor.maud"), 4);
要无限重复声音,为repeat值传递Infinity:
speaker.enqueue(0, AudioOut.Samples,
new Resource("bflatmajor.maud"), Infinity);
使用回调来同步音频
enqueue方法不仅可以用于声音入队;例如,您可以将回调排队,以便在流回放的特定点调用。回调入队对于触发其他事件以响应声音的完成非常有用,就像在屏幕动画中一样。在清单 7-7 中,回调只是追踪到调试控制台,并在声音播放结束时闪烁一次板上 LED。
speaker.enqueue(0, AudioOut.Samples,
new Resource("bflatmajor.maud"));
speaker.callback = function() {
trace("Sound finished\n");
Digital.write(2, 1);
Timer.delay(500);
Digital.write(2, 0);
};
speaker.enqueue(0, AudioOut.Callback, 0);
Listing 7-7.
使用命令改变音量
您还可以将命令加入队列,以调整单个声音的音量。该命令会更改其后排队的样本量;它不会改变已经排队的样本量。清单 7-8 中的代码连续播放声音三次:一次以最低音量(1),一次以中等音量(128),一次以最大音量(256)。
let bFlatMajor = new Resource("bflatmajor.maud");
speaker.enqueue(0, AudioOut.Volume, 1);
speaker.enqueue(0, AudioOut.Samples, bFlatMajor);
speaker.enqueue(0, AudioOut.Volume, 128);
speaker.enqueue(0, AudioOut.Samples, bFlatMajor);
speaker.enqueue(0, AudioOut.Volume, 256);
speaker.enqueue(0, AudioOut.Samples, bFlatMajor);
Listing 7-8.
播放一系列声音
$EXAMPLES/ch7-audio/sound-sequence示例播放一系列声音。因为它一次只播放一个声音,所以它只需要一个流,所以用与基本的sound示例中的AudioOut实例相同的设置进行配置。
let speaker = new AudioOut({streams: 1});
然后使用AudioOut实例的enqueue方法将每个声音排队。如清单 7-9 所示,sound-sequence示例中的所有声音都在同一个流中排队,使得它们按照排队的顺序依次播放。
speaker.callback = function() {
speaker.enqueue(0, AudioOut.Samples,
new Resource("ding.maud"));
speaker.enqueue(0, AudioOut.Samples,
new Resource("tick-tock.maud"));
speaker.enqueue(0, AudioOut.Samples,
new Resource("tada.maud"));
speaker.enqueue(0, AudioOut.Callback, 0);
}
speaker.callback();
speaker.start();
Listing 7-9.
回调在样本之后排队;一旦所有样本都播放完毕,回调就会被调用,并再次将样本排入队列,从而导致序列重复播放。
同时播放声音
$EXAMPLES/ch7-audio/ sound-simultaneous示例同时播放两个声音,因此AudioOut实例需要两个流。
let speaker = new AudioOut({streams: 2});
调用一次AudioOut实例的enqueue方法,将滴答声排入流 0。这种声音不断重复。
speaker.enqueue(0, AudioOut.Samples,
new Resource("tick.maud"), Infinity);
speaker.start();
然后,该示例设置一个重复计时器,其回调将一个丁丁声排入流 1。因为声音和滴答声在不同的流中排队,所以两种声音同时播放。
Timer.repeat(() => {
speaker.enqueue(1, AudioOut.Samples,
new Resource("ding.maud"));
}, 5000);
播放声音的一部分
这个例子演示了如何播放一段声音。使用与基本的sound示例相同的设置来配置AudioOut实例。
let speaker = new AudioOut({streams: 1});
tick-tock音频文件是一个时钟的录音。首先播放完整的声音。
let tickTock = new Resource("tick-tock.maud");
speaker.enqueue(0, AudioOut.Samples, tickTock);
然后前半秒打两遍。要播放部分声音,您需要指定enqueue方法的可选repeat、offset和count参数。在下面一行中,repeat是 2,所以声音播放两次;offset为 0,所以从音的开头开始;而count是 11,025/2,所以半秒播放:
speaker.enqueue(0, AudioOut.Samples, tickTock, 2, 0, 11025 / 2);
刷新音频队列
在某些情况下,您想要停止播放一个频道上的音频,而继续播放其他频道上的音频。方法是刷新您想要停止的流的音频队列。
speaker.enqueue(0, AudioOut.Flush);
一种有用的情况是,当一个通道使用通道 0 无限重复播放背景声音效果,而使用通道 1 播放交互式音频声音效果。您可以通过刷新频道 0 来停止背景声音效果,这将允许频道 1 继续播放而不会中断。这与在AudioOut实例上调用stop形成对比,后者会立即结束所有通道上的回放。
结论
现在您已经了解了如何配置音频设置并使用AudioOut类的许多功能来播放音频,您已经准备好开始向您的项目添加声音了。本章中的信息在与其他章节的信息结合使用时最为有用:
-
在第五章中,您学习了如何与传感器和执行器交互。现在,您可以触发声音效果来响应传感器读数或指示执行器何时执行某个操作。
-
在接下来的几章中,您将学习如何使用触摸屏。您可以为屏幕上的用户操作提供音频反馈,或者添加提醒声音以将用户的注意力吸引回显示屏。音频反馈和视觉反馈的结合提供了更完整的用户体验。
八、图形基础
本章和接下来的两章将向您展示只使用低成本微控制器和小型廉价触摸屏来创建现代用户界面是多么简单。本章首先阐述如何为物联网产品添加显示器,从而提供更好的用户体验,并比过去更具成本效益和实用性。之后的章节涵盖了微控制器上图形的基础知识,包括优化和约束的重要背景知识、如何将图形素材添加到项目中的信息,以及各种绘图方法的介绍。在接下来的两章中提供了更详细的信息,这两章描述了可修改的 SDK 中的以下内容:
-
Poco,一个用于嵌入式系统的渲染引擎,您可以使用它在显示器上绘图
-
Piu,一个面向对象的用户界面框架,使用 Poco 进行绘图,简化了创建复杂用户交互的过程
有了这些知识,你就可以开始构建内置显示器的物联网产品,并向你的朋友和同事解释这一目标对你的产品来说触手可及。
为什么要加显示器?
在今天的电脑和手机上,具有漂亮用户界面的显示器被认为是理所当然的;然而,它们在物联网产品上仍然很少见。你可能很熟悉设置和使用用户界面极其有限的物联网产品有多困难,例如只有一个按钮和几个闪烁灯的设备。很明显,在这些产品中增加显示器可以提供更好的用户体验,使产品对客户更有价值。以下是需要考虑的一些好处:
-
显示屏传达的信息远比几个脉冲状态灯或警报声要多。显示屏向用户详细展示产品正在做什么,如果有问题,它会告诉用户哪里出了问题。
-
显示屏可以包含产品所有功能的完整配置选项。用几个按钮和旋钮通常无法实现如此高的配置精度。
-
显示器使用户能够直接执行复杂的交互,而不需要其他设备。相比之下,用户下载并安装移动应用程序以与产品交互,并在能够开始配置之前将应用程序与产品配对。
-
丰富的图形显示让您可以将图像和动画结合起来,为产品带来风格和特色,使用户更加愉快,并强化制造商的品牌形象。
集成显示器有这么多好处,为什么更多的物联网产品不包括一个呢?主要原因是成本。包含显示器的物联网产品往往是高端型号,通常是所谓的“英雄”产品,旨在展示品牌,但预计不会卖出多少台。但是给产品加个屏幕真的贵得让人望而却步吗?曾经,答案是肯定的。以下是制造商不在产品中添加显示屏的一些常见原因:
-
显示器本身很贵。在添加微控制器和通信组件之前,一个小触摸屏的价格可以轻松达到 20 美元。
-
与显示器交互的软件需要增加更多的 RAM,用于构建用户界面的图形素材(图像和字体)需要增加更多的存储空间。
-
为了获得可接受的动画帧速率,需要一个具有硬件图形加速功能的特殊微处理器,即 GPU。
-
微控制器的图形编程需要高度专业化的技能,这使得找到合格的工程师更加困难,雇佣他们也更加昂贵。
-
微控制器的图形和用户界面 SDK 的许可成本太高。
-
为嵌入式系统准备图形素材既耗时又容易出错。
这些在过去都是合理的理由,但今天的情况完全不同。不幸的是,大多数从事物联网产品的产品规划者、设计师和工程师都没有意识到,可以用不到 10 美元的价格获得触摸屏、微控制器、RAM 和 ROM 来提供精美的现代用户界面,即使是非常小批量的产品(轻松地低于 10,000 台)。此外,同一微控制器还可以提供 Wi-Fi 和蓝牙支持。软件和素材问题由 Poco 和 Piu 解决。
克服硬件限制
今天的计算机和移动电话中的硬件被设计成以惊人的效率执行极其复杂的图形操作。这种非凡的性能是通过复杂的硬件和软件的结合实现的。不足为奇的是,典型的微控制器没有相同的图形硬件,也缺乏运行相同复杂图形算法的速度和内存。
这些差异的自然结果是,当由微控制器驱动的物联网产品包含显示器时,它们提供的用户界面往往显得非常原始,就像 20 世纪 80 年代和 90 年代初个人计算机时代初期的计算机和视频游戏一样。在某些方面,这是有道理的,因为像早期的个人电脑和视频游戏一样,这些微控制器远不如现代电脑强大。但今天的 ESP32 运行速度比顶级 1992 年 Macintosh IIfx 中的微处理器快 6 倍,因此现代微控制器的性能足以达到或超过早期硬件的性能。
可修改的 SDK 通过应用在现代高速显示总线、大量内存和 GPU 之前的早期硬件上使用的技术,在微控制器上实现了出色的图形效果。这些实现的灵感来自于已经成功用于计算机动画、视频游戏、字体等的经典技术。现代微控制器仍然受内存限制,但它们更快,因此更多的计算是可能的。这使得一些旧硬件上没有的技术成为可能。
这些技术如何工作的细节超出了本书的范围。如果您有兴趣了解更多,可以在可修改的 SDK 中找到实现它们的所有代码。这本书着重于如何使用这些功能为你的产品构建一个优秀的用户界面。
像素速率影响帧速率
在现代移动应用和网页中,帧速率是图形性能的基本衡量标准。帧速率越高,动画越流畅。电脑和手机中的 GPU 非常强大,它能够更新每一帧显示的每一个像素。出于多种原因,微控制器无法做到这一点;然而,可以实现每秒 30 帧甚至 60 帧(fps)的动画。
因为微控制器在更新整个显示器时不能呈现高帧速率,所以解决方案是仅更新显示器的子集。您可以设计您的用户界面,以便每次只更新相对较小的显示部分。这大大减少了微控制器所需的工作,因此用户可以看到流畅的高帧率动画,就像在移动应用程序或网页上一样。
为了使用微控制器实现高帧速率,从像素速率(每秒更新的像素数)的角度考虑会有所帮助。ESP32 和 ESP8266 使用 SPI 总线与显示器通信,这种连接以 40 MHz 的频率运行,像素速率约为每秒 1,000,000 像素,约为 15 fps。由于其他因素,实现全部理论像素速率通常是不可能的;尽管如此,如果您的应用程序仅更新每帧中约 40%的像素(每帧约 30,000 像素的像素速率),它可以实现 30 fps 的可靠帧速率。在本书中使用的 QVGA (320 x 240)显示器上,30,000 像素约占总显示面积的 40%,这足以创建一个流畅、引人注目的用户界面。每帧仅更新 10,000 个像素可实现 60 fps。
您可能希望在每一帧上更新的屏幕区域必须是一个矩形。这将通过将运动限制在显示器的一个区域来限制设计的可能性。幸运的是,情况并非如此。您很快就会知道,您可以同时更新几个不同的区域,这可以让用户在整个屏幕上产生运动的印象,即使实际像素中只有一小部分在变化。
绘图框架
大多数用于微控制器的图形库都是即时模式API,这意味着当您调用绘图函数时,渲染器会执行所请求的绘图操作。另一方面,Poco 是一个保留模式渲染器,其工作方式如下:
-
你告诉 Poco 你什么时候开始画画。
-
当您调用绘图函数时,它们不会立即绘制,而是添加到绘图命令列表中。
-
当你告诉 Poco 你已经画好了,它会执行所有的绘图命令。
显然,保留模式呈现更复杂,维护绘图命令列表需要额外的内存。通常,在微控制器上,您试图保持软件简单和内存使用尽可能少,但保留模式的以下优势证明了其成本的合理性:
-
保留模式渲染消除了闪烁。例如,当您在即时模式渲染器中绘制屏幕背景时,屏幕的所有像素都以背景色绘制;当您随后绘制组成用户界面的控件、文本和图片时,用户可能会首先看到没有这些用户界面元素的背景屏幕,从而导致令人分心的瞬间闪烁。由于保留模式渲染会在将结果发送到屏幕之前执行所有绘制命令,因此它会在将背景擦除与微控制器上用户界面元素的绘制传输到显示器之前,将它们结合起来,从而消除闪烁。
-
保留模式通过减少从微控制器传输到显示器的像素数量来提高性能。考虑到在每个用户界面中都有一些重叠的像素,例如,按钮的背景和它的文本标签。在即时模式渲染器中,重叠像素被发送到显示器两次,而在保留模式渲染器中,每个像素每帧仅被发送一次。因为渲染像素比将其传输到显示器要快得多,所以这提高了整体像素速率。
-
保留模式通过启用高效的像素混合来提高渲染质量。现代计算机图形大量使用混合来平滑对象的边缘,例如,使用反锯齿字体来消除锐边(“锯齿”)。这就是为什么今天的电脑和手机上的文字看起来比 20 世纪 80 年代的屏幕文字清晰得多。混合在计算上更加复杂,而且有足够的性能来完成它,因为微控制器要快得多;然而,混合也需要访问当前正在绘制的像素后面的像素。在典型的微控制器硬件中,前一个像素存储在显示器的存储器中,而不是微控制器的存储器中,这使得它要么完全不可用,要么访问速度慢得不切实际。保留模式渲染器,因为它只在完全渲染时将像素传输到显示器,所以内存中始终有可用的像素的当前值,因此能够有效地执行混合。
保留模式渲染器还有其他优点,但这三个优点应该足以让您相信,内存和复杂性成本证明使用像 Poco 这样的保留模式渲染器而不是更常见的即时模式渲染器是合理的。用户界面渲染的质量如此之高,以至于用户感觉他们正在使用一种更高质量的产品——一种属于他们的计算机和手机而不是计算机历史博物馆的产品。
扫描线渲染
QVGA 显示器有 76,800 个像素,这意味着 16 位像素的显示器需要 153,600 字节的内存来存储一个完整的帧。ESP8266 的总内存约为 80 KB,如果您的物联网产品不使用任何其他内存,则仅够半帧使用!ESP32 有更多,但在启动时,在内存中保存整个帧会占用总空闲内存的 50%或更多。本书中使用的显示器包括单帧的内存,因此微控制器不必存储整个帧,但它需要内存来呈现帧。为了最大限度地减少所需的内存,Poco 渲染器实现了扫描线渲染,这是一种将帧分成水平条带的技术,小至单行像素;在每一条被渲染后,它立即被传输到显示器。这种方法比一次渲染整个帧更复杂,但它将单个 16 位 QVGA 显示器的渲染内存需求从 153,600 字节减少到 480 字节,即一条 240 像素扫描线每像素两个字节,内存节省了 99.68%!
每个渲染的条带都有一些性能开销,因此通过增加条带的大小来减少条带的数量是有好处的——但当然这也会增加所需的内存。随着每一行被添加到一个条中,性能优势会有所下降,因此增加超过大约八条扫描线通常是不值得的。如果您的项目有一些空闲内存或需要最高性能的渲染,您可能希望让 Poco 一次渲染几条扫描线;接下来的章节将解释如何进行配置。
许多现代微控制器,包括 ESP32 和 ESP8266,都能够使用 SPI 异步向显示器传输数据,这意味着微控制器可以在传输数据的同时执行其他工作。Poco 使用异步 SPI 传输来呈现显示器的下一部分,同时将前一部分传输到显示器,这种简单的并行处理可以显著提升性能。要使用这种技术,Poco 必须有足够的内存来容纳至少两条扫描线:正在传输的先前渲染的扫描线和正在渲染的当前扫描线。因为这种技术提供了如此显著的性能提升,所以默认情况下,Poco 分配两条扫描线。
限制绘图区域
正如您在“像素速率影响帧速率”和“扫描线渲染”部分看到的,微控制器上图形使用的一项关键技术是部分更新显示,而不是一次更新全部。请注意 Poco 和 Piu 中该技术的以下方面:
-
在 Poco 中——Poco 渲染引擎支持将绘图限制在显示器的子部分的特性被称为剪辑。Poco 用单个矩形来描述裁剪区域;每个绘制操作中与该裁剪矩形相交的部分被绘制,而落在裁剪矩形之外的任何操作部分都不被绘制。Poco 使用此功能实现扫描线渲染(Piu 使用此功能实现部分帧更新)。它也可用于您的应用程序中,例如,绘制图像的子集。
-
在 Piu 中–更新显示器的最小可能区域可提高微控制器的渲染性能;然而,在一般情况下,确定要更新的最小可能区域是相当困难的。Poco 无法为您确定最佳的绘图区域,因为作为一个渲染引擎,它不知道您的代码正在绘制什么。另一方面,Piu 是一个用户界面框架,它完全了解组成屏幕显示的不同对象。因此,Piu 能够在后台自动为您计算最小的可能更新区域。
为了理解计算最小可能更新区域的挑战,让我们看一个弹跳球的例子。在每一帧中,球移动一定数量的像素。在图 8-1 中,球向右下方移动了几个像素。包围球的先前和当前位置的最小矩形是对屏幕上要更新的最小可能区域的良好的第一估计。

图 8-1
球轻微移动,一个更新矩形
现在考虑球移动更长距离的情况(图 8-2 ):包围先前和当前位置的最小矩形包括许多实际上没有改变的像素,但是它们被重新绘制,因为它们包含在要更新的区域中。

图 8-2
球移动得更远,一个更新矩形
如图 8-3 所示,在这种情况下,Piu 认为更新两个独立的区域更有效:包围球的先前位置的区域,填充为背景色,以及包围球的当前位置的区域。

图 8-3
球移动得更远,两个更新矩形
Piu 实际上更进一步。在第一个示例中,球只移动了一小段距离——这段距离导致当前位置与前一个位置重叠——Piu 识别出单个封闭矩形不是最小的可能更新区域;因此(如图 8-4 所示),在这种情况下,它更新三个单独的矩形,这避免了不必要地更新许多没有改变的背景像素。

图 8-4
球轻微移动,三个更新矩形
优化单个弹跳球的绘图区域所涉及的计算已经非常复杂,在有多个弹跳球有时会重叠的应用程序中,这些计算甚至会更加复杂。Piu 自动为您计算最小矩形集;这确实需要时间和内存,但是它带来的性能提升是值得的。这是因为渲染性能在很大程度上受到应用程序像素速率的限制,Piu 会自动最小化代码的像素速率。
像素
每个显示器都包含像素,但并非所有显示器都有相同种类的像素。像素有不同的大小和颜色。这种情况一直存在,但很容易忘记,因为几乎所有现代计算机和移动设备都支持相同的 24 位彩色像素格式。像嵌入式开发的许多领域一样,像素格式的多样性在一定程度上是试图保持低硬件成本的结果。能够显示颜色的显示器往往价格更高,但除了价格之外,还有其他因素会影响所使用的像素格式。例如,ePaper 显示器(通常指的是开发它的公司的名字,E Ink)使用的技术只能显示几种颜色,通常是黑色、白色和一些灰色阴影,不需要存储多种颜色的像素格式。
像素格式
大多数显示器支持单一类型的像素。本书大多数示例中使用的 QVGA 彩色显示器使用 16 位彩色像素,其中 5 位用于红色,6 位用于绿色,5 位用于蓝色。你的手机可能有 24 位的彩色像素,红色、绿色和蓝色各有 8 位。虽然这两种像素都足以显示全色用户体验,但 24 位彩色像素能够显示 256 倍多的颜色(16,777,216 对 65,536)。这种差异意味着嵌入式设备上的图像可能没有那么精致,特别是在充满相似颜色的区域,如日落。对于照片来说,这可能是个问题,但对于由微控制器驱动的用户界面来说,如果界面设计考虑到这一限制,这通常不是问题。
除了 16 位颜色,少数显示器仅支持 8 位颜色。这是非常有限的,只允许 256 种颜色。每个像素包含 3 位红色、3 位绿色和 2 位蓝色。使用这种像素类型的显示器可以构建一个合理的用户界面,但在限制范围内仔细选择看起来不错的颜色需要一些工作。在某些情况下,在支持 16 位像素的显示器上使用 8 位彩色像素可能是有益的。这显然不会提高质量,但它确实减少了资源所需的存储空间和渲染图像所需的时间。如果您发现您的项目很难适应可用的存储空间,或者如果渲染性能不是您所需要的,在 16 位显示器上使用 8 位彩色像素可能是一个可行的解决方案。
也有 4 位彩色像素,但用这些很难达到专业的效果,所以这里不讨论。然而,4 位灰度像素——可以显示 14 级灰度加上黑白——非常有用。不能显示颜色的 ePaper 显示器只需要灰色像素;由于大多数 ePaper 显示器只能显示几级灰度,所以 4 位灰度像素就足够了。灰度渲染甚至比彩色渲染更快。您可以在 16 位彩色显示器上使用 4 位灰色像素,以节省更多存储空间。还有 8 位灰度像素,可以显示 254 级灰度加黑白;这些提供了极好的质量,但是对于许多实际目的,4 位灰度渲染在质量上与 8 位灰度像素几乎没有区别。
有些显示器只是黑白的。这些显示器往往体积小、质量低,更多地用于工业物联网产品,而不是消费物联网产品。对于这些显示器来说,1 位像素就足够了;然而,以每像素 1 位的速度渲染是非常困难的。Poco 渲染器不直接支持 1 位像素显示。相反,显示驱动器接收 4 位灰色像素,然后在将图像传输到显示器时将其缩小为 1 位。
为像素格式配置主机
在第一章中,你学习了如何使用mcconfig命令行工具构建一个主机。在命令行上,使用-p选项传递目标硬件平台的名称——例如,-p esp32来构建 ESP32。对于包含显示器的设备目标,如 Moddable 和 M5Stack 的开发板,会自动为您配置默认像素格式。例如,当你为 Moddable One、Moddable Two 或 M5Stack FIRE 构建时,像素格式设置为rgb565le,为 16 位彩色像素;对于带有 ePaper 显示屏的 Moddable Three,它被设置为gray16,用于 4 位灰色像素。
16 位像素最常见的显示驱动程序是 ILI9341 驱动程序,它实现了 Moddable 和 M5Stack 开发板中的显示控制器所使用的 MIPI 显示标准。硬件使用 16 位像素,但驱动程序也支持其他像素格式。您可以通过使用-f选项在命令行上指定格式来试验不同的像素格式。例如,要使用 4 位灰色像素:
> mcconfig -d -m -p esp32/moddable_two -f gray16
以这种方式配置主机时,ILI9341 驱动程序会在将 Poco 渲染的 4 位灰色像素传输到显示器时,将其转换为 16 位彩色像素。但是还有更多的变化在发生:
-
当您更改像素格式时,Poco 渲染器本身会重新编译。在此示例中,对 16 位像素渲染的所有支持都被移除,并替换为对 4 位灰色像素渲染的支持。这是 Poco 使用的一种技术,它在保持代码小的同时仍然支持许多不同的像素格式。
-
Poco 要求某些图形素材以与显示器相同的像素格式存储,这通常要求您以兼容的格式重新创建图形。但是因为这是乏味的、耗时的、容易出错的,
mcconfig自动调用可修改的 SDK 中的其他工具来将您的素材转换成兼容的格式。这意味着您可以通过指定不同的格式来切换像素格式,这就像重新构建您的项目来尝试不同的格式并权衡利弊一样简单。
ILI9341 驱动程序还支持 8 位彩色和 8 位灰色像素。您可以通过在-f命令行选项中分别指定rgb332和gray256来使用带有mcconfig的选项。
如果您发现最适合您产品的像素格式不同于默认格式,您可以在项目清单中指定您的首选格式。这样,您就不需要在每次构建时都记得在命令行中包含它。为此,在清单的config部分定义一个format属性:
"config": {
"format": "gray256"
},
选择显示器的自由
虽然大量可用的像素格式看起来令人困惑,但它在创建产品时为您提供了多种选择。您可以选择最符合质量、成本和尺寸要求的显示器。Poco 能够渲染适合您显示器的像素,因此您不必根据软件栈的限制来选择显示器。在下一节中,您将学习如何自动转换项目中的图形资源,以匹配您正在使用的显示器。
图形素材
使用 Poco 和 Piu 构建的用户界面由三种不同的元素组成:矩形、位图图像和文本。这就是一切;没有绘制直线、圆、圆角矩形、圆弧、样条曲线或渐变的图形操作。起初,这似乎有点太简单了,您可能会得出结论,用这么少的绘图操作来构建一个现代化的用户界面是不可能的。在接下来的章节中,您将看到如何将这些元素结合起来,以创建一个在廉价微控制器上运行良好的丰富用户体验。这一节重点介绍图形素材,即用于构建用户界面的图像和字体。
面具
使用 Poco 和 Piu 构建用户界面时,最常见的资源类型是遮罩。蒙版是灰度图像;你可以把它想象成一个形状。因为蒙版包含灰色像素,而不仅仅是黑色和白色像素,所以它可以具有平滑的边缘。图 8-5 显示了一个圆的两个版本,第一个是灰度蒙版,第二个是简单的 1 位蒙版,它们的边缘被放大以示区别;请注意灰度蒙版放大中的灰色边缘。

图 8-5
灰度掩码(左)和 1 位掩码(右)
当 Poco 渲染灰度遮罩时,它不会将其绘制为图像。如果是这样,白色像素会隐藏背景,如图 8-6 所示。

图 8-6
如果绘制为图像,则使用灰度遮罩
相反,Poco 通过将黑色像素视为纯色(完全不透明),将白色像素视为透明(完全不可见),并将两者之间的灰度级别视为不同的混合级别来渲染遮罩。对应于图 8-6 的结果如图 8-7 所示,其中黑色圆圈叠加在背景上(通过透明白色像素可见),圆圈的灰色边缘与背景融合,消除了任何锯齿边缘。

图 8-7
绘制为蒙版的灰度蒙版
你可能想在你的用户界面中包含颜色,在这种情况下,灰色图像似乎不是一个显而易见的解决方案。但是,Poco 允许您以任何颜色绘制灰度蒙版。黑色像素被您选择的颜色替换,灰色像素将该颜色与背景混合。图 8-8 显示了用蓝色绘制的相同圆形遮罩(在本书的印刷版本中显示为灰色)。

图 8-8
绘制为彩色蒙版的灰度蒙版
以各种颜色绘制单个灰度遮罩的能力非常强大,因为它使单个图形资源能够以不同的颜色显示。这减少了所需资源的数量,节省了项目中的存储空间。
图 8-9 显示了一些用作用户界面元素的灰度遮罩示例。

图 8-9
用作用户界面元素的灰度遮罩
正如您在“像素格式”一节中所知道的,Poco 定义了两种不同的灰度像素:4 位和 8 位。所有 Poco 遮罩都是 4 位灰度,这允许最小的存储大小和最快的渲染,而不会牺牲太多质量。
将遮罩添加到项目中
您可以将遮罩作为 PNG 文件添加到项目中,PNG 文件是桌面应用程序、移动应用程序和用户界面元素网页使用的同一种图像文件。能够在您的项目中使用 PNG 文件是很方便的;但是,ESP32 和 ESP8266 不能有效地处理 PNG 图像,因为解码 PNG 图像需要内存和 CPU 开销。相反,构建工具将您的 PNG 文件转换成可以在这些微控制器上有效处理的格式。由于这种自动转换,您没有必要理解这些非标准图像格式的细节(尽管细节可以在可修改的 SDK 中找到)。
要在您的项目中包含一个 PNG 蒙版图像,将其添加到您的项目清单文件的resources部分,如清单 8-1 所示。
"resources": {
"*-mask": [
"./assets/arrow",
"./assets/thermometer"
]
}
Listing 8-1.
请记住,清单中指定的资源不包括文件扩展名。在清单 8-1 的例子中,图像文件的文件名为arrow.png和thermometer.png。
掩模压缩
灰度掩模足够小,可以用在针对微控制器的产品中。之前在图 8-9 中显示的温度计图像存储为 4 位灰度掩模时为 2458 字节。尽管如此,如果再小一点就好了。Poco 有一个解决方案:它包括一个专门针对 4 位灰度图像的压缩算法。该算法针对微控制器进行了优化,因此不需要太多的 CPU 时间或额外的内存。
对于温度计图像,压缩算法将数据大小减少到 813 字节,比原始未压缩版本小 67%。压缩率因图像而异。对于包含较大连续黑白区域的图像,Poco 蒙版压缩率会有所提高。
未压缩的遮罩
在绘制用户界面的遮罩时,将几个相关元素组合在一个图形文件中通常会很方便。许多图形设计师更喜欢这种工作方式,因为它使修改蒙版更快更容易。因为 Poco 支持裁剪渲染,所以它在绘制时只能使用遮罩的一部分,所以您可以选择以这种方式组织图形文件。图 8-10 中的遮罩显示了 Wi-Fi 连接的几种不同状态,它们被组合在一个图形文件中。

图 8-10
多个遮罩组合在一个图形文件中
如前所述,您可以压缩这些组合的遮罩图像。但是,对包含多个图像的遮罩使用压缩会有性能损失。这是因为要渲染压缩图像的一部分,解压缩器必须跳过目标区域上方和左侧的图像部分,这需要额外的时间。对于某些项目,压缩带来的存储大小减少的好处比性能降低更重要。您可以通过将掩码添加到清单的*-alpha部分而不是*-mask部分来保持其不被压缩(参见清单 8-2 )。当然,您的清单可能同时包含*-mask和*-alpha,以压缩一些遮罩,而不压缩其他遮罩。
"resources": {
"*-alpha": [
"./assets/wifi-states"
]
}
Listing 8-2.
字体
字体是嵌入式开发中一个独特的挑战。你的电脑和手机有几十种,甚至几百种内置字体。这些字体中的一种或多种包括 Unicode 标准中定义的几乎所有字符,这意味着没有您的设备不能显示的文本字符。在微控制器上,没有内置字体;项目中唯一可用的字体是您包含在项目中的字体。
有许多字体可供您的计算机使用,如果能够在您的物联网产品中使用这些相同的字体,将会非常方便。您电脑上的大多数字体(如果不是全部的话)都是以基于由 Apple 创建的 TrueType 可缩放字体技术的格式存储的(OpenType 字体格式是 TrueType 的衍生)。在微控制器上渲染这些字体是可能的,但具有挑战性,渲染所需的代码、内存和 CPU 资源的数量使其在许多项目中不切实际。本书中的示例使用了一种更简单的字体格式,一种高质量的位图字体。ESP32 上提供了 TrueType 兼容的渲染器,本节将对此进行介绍。
将 TrueType 字体转换为位图字体
尽管在所有项目中使用 TrueType 字体可能不切实际,但您仍然可以在您的计算机上使用这些字体,方法是使用您的计算机将 TrueType 字体转换为微控制器可以轻松处理的格式。TrueType 字体以特定的磅值呈现在位图中,所有字符都存储在一个位图中。位图使用 4 位灰色像素,而不是黑白像素,以保持原始字体的抗锯齿效果。此外,还会生成一个.fnt文件,该文件在 Unicode 字符代码和字体位图中的矩形之间进行映射。这种结合了位图图像和地图文件的字体格式被称为 BMFont ,意为“位图字体”BMFont 有几种变体;可修改的 SDK 使用二进制 BMFont 格式。图 8-11 显示了 16 磅大小的开放 Sans 字体在 BMFont 格式下的样子。

图 8-11
BMFont 格式的字体字符图像
请注意,字符的排列顺序不同于 Unicode 或 ASCII 标准中的顺序。例如,字母 A、B 和 C 不会按顺序出现。相反,字符是按高度排列的,通过最大限度地减少未使用的空白空间,使位图图像尽可能小。
可用于创建这些位图文件的工具不是可修改 SDK 的一部分。71 Squared 的字形设计器很好用。可修改的 SDK 包括一套 BMFont 格式的预建字体,因此您可以开始开发,而无需在工具上进行任何额外投资。
BMFont 格式的每种字体都有两个文件:一个图像文件,通常是 PNG 格式,一个字体映射文件,文件扩展名为.fnt。这两个文件应该有相同的名称,不同的文件扩展名,如OpenSans-Regular-16.png和OpenSans-Regular-16.fnt。要将这些添加到您的项目中,请在您的项目清单中包含该名称,如清单 8-3 所示。
"resources": {
"*-mask": [
"./assets/OpenSans-Regular-16"
]
}
Listing 8-3.
请注意,*-mask部分与用于压缩灰度蒙版的部分相同。以这种方式包含的字体也被压缩;然而,不是压缩整个图像,而是单独压缩每个字符。这使得每个字符能够被直接解压缩,避免了跳过每个字形上方和左侧的像素所需的开销。
压缩的字形与来自.fnt文件的数据合并成一个资源。这导致紧凑的字体文件仍然保持优良的质量,并可以有效地渲染。前面的 Open Sans 16-point 字体示例总共仅使用 6228 字节的存储空间来存储压缩字符以及布局和呈现所需的字体度量信息。此外,因为字体使用与灰度蒙版相同的压缩格式存储,所以它们也可以用任何颜色渲染。
BMFont 格式不要求字体为灰度。这种格式很受游戏设计者的欢迎,因为它使他们能够在游戏中包含创造性的、丰富多彩的字体。Poco 和 Piu 支持全色字体。它们不常用在微控制器上,因为它们需要更多的存储空间。如果您想尝试一下,可修改的 SDK 包含一些示例,可以帮助您入门。
使用可缩放字体
BMFont 格式既方便又高效,但是它消除了 TrueType 字体的一个主要优点:将字体缩放到任意大小的能力。如果您的项目使用三种不同大小的相同字体,您需要包括它的三个不同版本,每个版本对应一个点大小。可以在一些更强大的微控制器上直接使用可缩放字体,包括 ESP32。Monotype Imaging 是一家领先的字体和字体技术提供商,它提供了一种针对微控制器优化的可缩放 TrueType 字体的高质量实现。Monotype Spark 可缩放字体渲染器已经与可修改的 SDK 集成,因此可以与 Poco 和 Piu 一起使用。有关更多信息,请联系 Moddable 或 Monotype。
版权字体
对于商业产品,您需要确保您有权使用您产品中包含的任何字体。就像书籍和计算机软件一样,字体可以由它们的创作者拥有版权。幸运的是,在公共领域或在自由和开源软件许可下,有许多优秀的字体可用。谷歌为 Android 创建的 Open Sans 字体就是这样一种字体,在物联网产品的用户界面中工作良好。
彩色图像
虽然灰度蒙版是构建用户界面的强大工具,但有时您需要全色图像。Poco 使用未压缩的位图来支持彩色图像。这些位图提供了极好的质量和性能;然而,它们可能非常大,因此通常在微控制器的接口中很少使用。
对于彩色图像,可以使用标准的 JPEG 和 PNG 文件。与灰度遮罩一样,mcconfig在构建时将它们转换为构建目标的最佳格式。要在您的项目中包含彩色图像,请将它们添加到清单的resources部分的*-color部分(参见清单 8-4 )。请注意,.jpg或.png文件扩展名被省略。
"resources": {
"*-color": [
"./quack"
]
}
Listing 8-4.
全色图像是完全不透明的;它们没有混合或透明区域。图 8-12 显示了在一个简单的用户界面中呈现的来自前面清单片段的quack JPEG 图像。

图 8-12
全色图像的渲染
该形状是一个矩形,因为图像中的所有像素都是绘制的。存储在 PNG 文件中的图像可能包含可选的 alpha 通道。alpha 通道就像一个灰度蒙版:它指示图像中的哪些像素应该被绘制,哪些应该被跳过,以及哪些应该与背景混合。Alpha 通道通常是在使用 Adobe Photoshop 等工具编辑图像时创建的。Poco 支持渲染 alpha 通道;您通过将图像放入清单的 resources 部分的*-alpha部分来表明您想要保留 alpha 通道(参见清单 8-5 )。
"resources": {
"*-alpha": [
"./quack-with-alpha"
]
}
Listing 8-5.
图 8-13 显示了结果。鸭子图像与图 8-12 中使用的图像相同;但是,增加了一个 alpha 通道来遮盖背景。因此,在渲染图像时,只绘制了鸭子。

图 8-13
用 alpha 通道渲染全色图像
当您在项目中包含带有 alpha 通道的图像时,构建工具会创建两个单独的图像:全色图像(就像您将图像放入了*-color部分一样)和未压缩的 alpha 通道(作为 4 位灰度蒙版)。颜色资源命名为quack-color.bmp,压缩后的蒙版资源命名为quack-alpha.bm4。图 8-14 显示了用于遮蔽鸭子图像绘制的 alpha 通道。

图 8-14
图 8-13 中使用的阿尔法通道
当 Poco 和 Piu 渲染图像时,它们同时使用彩色图像和遮罩。您将在接下来的两章中学习如何做到这一点。
显示旋转
每个显示器都有一个自然方向,这意味着有一个边缘是“顶部”这个方向由硬件绘制的第一个像素的位置和从那里开始绘制的方向来定义。自然方向由硬件决定,不能更改。不过,为了有效地旋转显示器上的图像,通常需要将屏幕的不同边缘视为顶部。这在自动旋转显示器上的图像以匹配用户手持设备的方向的移动设备上很常见。这种能力也存在于大多数 LCD 电视中,因此用户可以最方便地安装显示器,然后手动调整方向以“正面朝上”显示图像
虽然许多物联网产品不允许用户通过配置或旋转设备来改变方向,但这些产品可能仍然需要旋转显示器,例如,当产品的设计要求横向方向,但显示器的原生方向是纵向模式时,或者当显示器在产品中颠倒安装以节省空间时(这似乎不常见,但确实会发生)。此外,有时硬件设计人员错误地将显示器装反了,为了节省时间,要求软件团队进行补偿。出于这些原因以及更多原因,在 0 度、90 度、180 度和 270 度方向渲染用户界面的能力对于许多物联网产品来说是必要的。
如以下部分所述,旋转显示器有两种不同的方法:适用于所有显示器的软件方法和适用于某些显示器的硬件方法。
在软件中旋转
旋转用户界面的最常见技术是将整个界面绘制到屏幕外存储缓冲区中,就好像显示器处于旋转的方向一样。然后,当像素被传输到显示器时,它们被转换以匹配硬件方向。这种方法在低成本的微控制器上是不可行的,因为没有足够的内存在屏幕外缓冲区中存储一个完整的帧。
Poco 采用了一种非常不同的方法:它在构建期间将所有图像素材旋转到所需的方向,这样它们就不需要在运行时旋转。这种旋转与任何所需的像素格式转换同时执行。然后,当嵌入式设备上的应用程序进行绘图调用时,Poco 只需要将绘图坐标旋转到目标方向。完成这两个步骤后,Poco 照常渲染,结果在显示器上旋转显示。这种方法几乎没有可测量的运行时开销——不使用额外的内存,并且只运行少量的额外代码来执行坐标转换——因此它几乎是一个零成本的特性。因为软件旋转完全在 Poco 渲染器中实现,所以它适用于所有显示器。
当使用软件旋转时,您可以通过将-r命令行选项改为mcconfig来改变方向。支持的旋转值为 0、90、180 和 270。
> mcconfig -d -m -p esp/moddable_one -r 90
与像素格式配置一样,您也可以在项目清单中指定软件旋转:
"config": {
"rotation": 90
}
软件轮换有一个明显的限制:轮换在构建时是固定的,因此在运行时不能改变。因此,这种技术适用于物联网产品用户界面需要与显示器的自然方向不同的方向的情况,但不适用于需要响应用户操作(如转动屏幕)的情况。硬件轮换(如果可用)克服了这一限制。
硬件旋转
当显示器从微控制器接收像素时,硬件旋转使用显示控制器的功能来旋转图像。使用硬件旋转要求显示控制器和显示驱动程序都支持该功能。对于 MIPI 兼容的显示控制器,ILI9341 驱动程序完全支持硬件旋转。
硬件轮换完全是在运行时执行的,所以在您的构建命令行或项目清单中没有什么需要定义的。事实上,不要在项目中同时使用硬件和软件轮换,这一点很重要;它们不是为协同工作而设计的,所以结果是不可预测的。
使用硬件轮换时,您在运行时设置轮换,而不是在构建时配置它。您使用screen全局变量与显示驱动程序通信。对于支持硬件旋转的显示器,screen全局有一个rotation属性;您可以通过查看是否定义了该属性来检查是否支持硬件旋转。
if (screen.rotation === undefined)
trace("no hardware rotation\n");
else
trace("hardware rotation!\n");
要更改旋转,请设置rotation属性:
screen.rotation = 270;
您的代码可以读取screen.rotation来检索当前旋转:
trace(`Rotation is now ${screen.rotation}\n`);
当硬件旋转改变时,显示不变。在用户看到改变的方向之前,必须重新绘制显示的全部内容。如果您在更改旋转后只更新了屏幕的一部分,用户将看到以原始方向绘制的部分显示和以新方向绘制的其他部分。
M5Stack 目标的主机支持使用 Piu 自动旋转项目的用户界面,以匹配硬件方向。这导致了与移动电话相同的行为,该移动电话具有根据用户如何握持该设备来调整的显示器。该功能之所以成为可能,是因为 M5Stack 内置了一个加速度传感器,可提供当前的设备方向。对于不想使用此功能的 M5Stack 项目,可以在项目清单中禁用它。
"config": {
"autorotate": false
}
差不多吧?
在本章中,您已经了解了用于图形的 Poco 渲染引擎和 Piu 用户界面框架,这两者都可以用于构建运行在廉价微控制器(包括 ESP32 和 ESP8266)上的物联网产品的用户界面。Poco 和 Piu 具有相似的图形功能,因为 Piu 使用 Poco 进行渲染。当您开始创建自己的项目时,您必须决定是使用 Poco API、Piu API,还是两者都使用。本节解释了一些不同之处,以帮助您做出选择。
Poco 和 Piu 是本质上不同的 API:
-
Poco 是一个图形 API。您进行函数调用,最终导致部分屏幕被绘制。
-
Piu 是一个面向对象的 API,用于构建用户体验。您可以使用 Piu 创建用户界面对象,如文本标签、按钮和图像。将这些对象添加到应用程序会导致部分屏幕被绘制;您不需要自己调用绘图函数。
Piu 会为您处理许多细节,因此您可能会编写较少的代码;例如,它调用 Poco 在必要时呈现用户界面对象。因为您告诉 Piu 当前屏幕上所有活动的用户界面对象,所以当您移动、更改、显示或隐藏一个元素时,Piu 能够最小化所需的绘图量。例如,使用 Piu,您只需一行代码就可以使用遮罩来更改用户界面元素的颜色;Piu 确定屏幕上哪些像素必须更新,并自动绘制改变的元素以及与其相交的任何对象。相比之下,Poco 不了解应用程序的用户界面,因此您必须编写代码来刷新屏幕并最小化更新区域。这样做的代码通常从简单开始,但是随着用户界面变得越来越复杂,维护起来就变得越来越困难。
Piu 使用内存来跟踪活动的用户界面对象,因此比单独使用 Poco 使用更多的内存。当然,如果您不使用 Piu,您的代码必须跟踪活动的用户界面对象本身,这也需要内存。
Piu 内置了对触摸事件的响应支持。事实上,Piu 自动支持多点触控。(可改装一号和可改装二号的显示屏都支持两个触摸点。)作为图形引擎,Poco 专注于画图,不支持触摸输入,所以你的应用必须直接与触摸输入驱动交互;虽然这并不难做到,但是您需要编写和维护更多的代码。
也许使用 Piu 的最大好处是,作为一个框架,它提供了你的项目的基本结构。以下预定义的对象为您的项目提供了一个定义明确、设计良好的组织,由 Piu 本身的高效实现提供支持:
-
Application对象维护全局状态,并在整个应用程序生命周期内存在。 -
Texture和Skin对象组织您的图形资源。 -
Style对象使用级联样式表(如 web 上的 CSS)来管理字体、大小和样式。 -
Container和Content对象定义了用户界面的元素。 -
Behavior对象将用于特定目的的事件处理程序组合在一起,例如提供触摸按钮行为。 -
每个对象都实现了一个独特的过渡,或者是整个显示,或者是部分显示。
当您使用 Poco 时,您必须自己设计和实现应用程序结构。如果您的项目用户界面看起来有点类似于移动应用程序、桌面应用程序或网页,使用 Piu 可能是个好主意,因为它是为此而设计和优化的。如果您喜欢编写用户界面框架,或者如果您的用户体验非常不同(例如,一个游戏),那么直接使用 Poco 可能是正确的选择。
有些项目有标准的用户界面风格,但需要提供部分屏幕的特殊呈现。这方面的一个例子是显示传感器数据实时图表的物联网产品;屏幕上的按钮和标签非常适合 Piu,但是使用 Poco 可以更有效地呈现图形。像这样的项目的解决方案是在屏幕上使用 Piu,为了绘制图形,嵌入一个 Piu Port对象,它允许您在 Piu 布局中发出类似于 Poco 的绘制命令。
结论
下一章将进一步讨论 Poco 及其图形框架 Commodetto,下一章将讨论 Piu。当您通读这两章时,请考虑您自己项目的需求,以及是高级 Piu 用户界面 API 还是低级 Poco 图形渲染 API 更合适。Poco 和 Piu 使用起来非常不同,因此值得对它们进行试验,以了解哪一个最适合您的需求。
九、使用 Poco 绘制图形
Poco 渲染器是本书中所有图形和用户界面代码的核心。正如您在第八章中了解到的,Poco 的设计和实施经过优化,可在许多物联网产品中使用的廉价微控制器上提供高质量、高性能的图形。本章通过一系列例子介绍了 Poco API 的所有主要功能。Poco 这个名称是古典音乐中的一个术语,意思是“一点点”,反映了渲染引擎的紧凑大小和范围。
Poco 是 Commodetto、的一部分,Commodetto 是一个图形库,它提供位图、来自资源的图形素材实例、屏幕外图形缓冲区、显示驱动程序等等。本章中的一些例子使用了这些 Commodetto 特性。Commodetto 这个名字也是一个来自古典音乐的术语,意思是“悠闲”,反映了图形库的易用性。
安装 Poco 主机
您可以按照第一章中描述的模式运行本章的所有示例:使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。
所有 Poco 示例都需要使用屏幕,这使得您的mcconfig命令行必须为您的开发板指定一个带有屏幕驱动程序的平台。这些示例旨在在分辨率为 240 x 320 的屏幕上运行。以下命令行用于可修改 1、可修改 2 和 M5Stack FIRE:
> mcconfig -d -m -p esp/moddable_one
> mcconfig -d -m -p esp32/moddable_two
> mcconfig -d -m -p esp32/m5stack_fire
如果使用试验板和跳线将屏幕连接到开发板,请遵循第一章中的说明。为 ESP32 提供的接线与esp32/moddable_zero目标一起工作;对于 ESP8266 和esp/moddable_zero目标也是如此。
如果你的设备没有屏幕,你可以在可修改的 SDK 提供的桌面模拟器上运行本章的例子。以下命令行适用于 macOS、Windows 和 Linux:
> mcconfig -d -m -p mac
> mcconfig -d -m -p win
> mcconfig -d -m -p lin
本章的主机在$EXAMPLES/ch9-poco/host目录中。从命令行导航到这个目录,用mcconfig安装它。
如果您正在使用桌面模拟器,请确保在安装示例之前将屏幕尺寸更改为 240 x 320。你可以通过从应用程序工具栏的尺寸菜单中选择 240 x 320 来实现。
准备画画
要使用 Poco 渲染器,您需要从commodetto/Poco模块导入Poco类:
import Poco from "commodetto/Poco";
Poco 是一个通用渲染器。它呈现的像素可以发送到屏幕、内存缓冲区、文件或网络。Poco 不知道如何向这些目的地发送像素;相反,它将像素输出到PixelsOut类的实例,并且PixelsOut的每个子类都知道如何将像素发送到特定的目的地。例如,显示驱动程序是PixelsOut的一个子类,它知道如何向屏幕发送像素。PixelsOut的另一个子类BufferOut,将像素发送到一个内存缓冲区(你将在本章的“高效渲染渐变”一节中看到)。
当您实例化Poco时,您为 Poco 提供了一个PixelsOut类的实例来调用渲染像素。本章的主持人自动为开发板的显示驱动程序创建一个PixelsOut的实例,并将其存储在screen全局变量中。要使用屏幕,只需将screen传递给Poco构造函数。
let poco = new Poco(screen);
显示驱动程序的像素格式和显示尺寸在主机清单中配置。screen实例有width和height属性,但是这些不包括软件旋转的影响。相反,在使用 Poco 时,使用Poco实例的width和height属性来获取应用了任何旋转调整(硬件或软件)的显示边界。
trace(`Display width is ${poco.width} pixels.`);
trace(`Display height is ${poco.height} pixels.`);
正如在第八章中提到的,Poco 是一个保留模式渲染器,这意味着它不是立即执行绘图命令,而是建立一个绘图操作列表来一次渲染所有内容。这个显示列表需要内存。默认显示列表是 1,024 字节。如果图形溢出了显示列表分配,则需要增加显示列表分配。如果您的项目没有使用所有默认的显示列表分配,您可以减少它以释放内存供其他用途。以下示例将显示列表调整为 4 KB:
let poco = new Poco(screen, {displayListLength: 4096});
您可以通过查看xsbug中仪表板的“Poco display list used”行来监控您的项目使用了多少显示列表(参见图 9-1 )。

图 9-1
在xsbug仪表板中使用的监控显示列表
Poco 还为渲染分配内存。默认渲染缓冲区是两条硬件扫描线。一条硬件扫描线的宽度从screen.width开始。如果你的产品内存非常紧张,你可以减少到一条扫描线,尽管不能更小。
let poco = new Poco(screen, {pixels: screen.width});
当 Poco 一次渲染几条扫描线时,它能够更快地渲染。下面的代码将渲染缓冲区增加到八条完整的扫描线,同时将显示列表设置为 2 KB。
let poco = new Poco(screen,
{displayListLength: 2048, pixels: screen.width * 8});
作为优化,Poco 共享为显示列表和渲染缓冲区分配的内存。如果正在渲染的帧的显示列表没有完全填满,Poco 会在渲染缓冲区中包含那些未使用的字节,这通常会使渲染速度稍快一些。
Poco 提供的三种基本绘图操作是绘制矩形、位图和文本。正如在第八章中提到的,这听起来可能不多,但是你可以结合这些元素来创造丰富的用户体验。下一节将详细介绍它们。
绘制矩形
绘制矩形是 Poco 提供的三种基本绘制操作中最简单的一种。在介绍第一个绘图操作时,本节还介绍了一些使用 Poco 绘图的基础知识。
填满屏幕
$EXAMPLES/ch9-poco/rectangle示例简单地用纯色填充整个屏幕。代码如清单 9-1 所示。
let poco = new Poco(screen);
let white = poco.makeColor(255, 255, 255);
poco.begin();
poco.fillRectangle(white, 0, 0, poco.width, poco.height);
poco.end();
Listing 9-1.
第一行调用Poco构造函数来创建Poco的实例。该实例将渲染像素传送给screen。这一步对于本章中的所有示例都是通用的,因此将从这里显示的其余示例中省略。
让我们依次看看本例中调用的每个方法:
-
poco.makeColor的三个参数接收红色、绿色和蓝色分量,每个分量的范围从 0(无)到 255(全)。这里指定的颜色是白色,所以红色、绿色和蓝色分量都是 255。makeColor方法将这三个值组合成一个值,这个值最适合呈现给目的地(本例中为screen)。根据目的地,Poco 使用不同的算法从颜色分量创建颜色值。因此,您应该只将由makeColor返回的值传递给创建它的同一个Poco实例。 -
对
poco.begin的调用告诉 Poco 你开始渲染一个新的帧。在此之后发生的所有绘图操作都将添加到框架的显示列表中。 -
调用
poco.fillRectangle向显示列表添加一个命令来绘制一个全屏白色矩形。颜色是第一个参数,接着是 x 和 y 坐标,然后是宽度和高度。坐标平面将(0,0)放在屏幕的左上角,高度和宽度向下和向右移动。 -
对
poco.end的调用告诉 Poco 您已经完成了对该帧的绘制操作。Poco 然后渲染像素,并发送给screen;这可能需要一些时间,取决于显示器的大小、微控制器的速度以及渲染帧的难度。在可修改的一个或可修改的两个上,它很快完成。
Important
Poco 不会自动用颜色填充背景,因为这会降低渲染性能。这意味着您的代码必须绘制到帧中的每个像素。如果不为像素指定颜色,Poco 会输出未定义的颜色。确保您的代码用一种颜色填充背景,如本例所示,或者确保您进行的绘图调用组合覆盖每个像素。
更新部分屏幕
当您调用begin方法时,您可以选择指定要更新的屏幕区域。您可能还记得,更新屏幕的较小部分是实现更高帧速率的一种技术。
下面的示例用红色填充 20 x 20 像素的正方形;显示器上的其他像素保持不变。如果将这段代码添加到前面的rectangle示例中,屏幕将是白色的,除了左上角的一个红色小方块。
let red = poco.makeColor(255, 0, 0);
poco.begin(0, 0, 20, 20);
poco.fillRectangle(red, 0, 0, 20, 20);
poco.end();
在这里,对begin的调用定义了要绘制的区域——称为更新区域— ,即显示屏左上角的 20 x 20 的正方形。仅绘制更新区域中的像素,因此更新区域外的白色像素保持不变。当您不带参数调用begin时,就像在rectangle的例子中,更新区域是整个屏幕。在这个例子中,对fillRectangle的调用使用了与对begin的调用相同的坐标和尺寸,用红色像素填充了整个更新区域。
如前所述,begin和end之间的代码必须进行覆盖每个像素的绘制调用,以生成正确的结果——但是如果代码在对begin的调用中指定的区域之外绘制,会发生什么呢?考虑下面的例子,它用指定全屏的参数调用fillRectangle:
let red = poco.makeColor(255, 0, 0);
poco.begin(0, 0, 20, 20);
poco.fillRectangle(red, 0, 0, poco.width, poco.height);
poco.end();
此示例产生的结果与前面的示例完全相同。Poco 没有响应fillRectangle的全屏绘制请求,而是将fillRectangle的输出限制在对begin的调用中指定的更新区域。这种方法对于许多呈现情况都很方便,尤其是对于动画,因为它使您能够限制要更新的区域,而无需更改代码来将其绘制限制在更新区域。
绘制随机矩形
一个经典的计算机图形演示是在随机位置连续渲染随机大小的随机彩色矩形。$EXAMPLES/ch9-poco/random-rectangles示例正是这样做的,通过在对 Poco 的begin方法的调用中指定坐标,将绘制限制到当前正在绘制的矩形。如果你运行这个例子,你会看到如图 9-2 所示的动画版本。

图 9-2
从random-rectangles动画渲染
第一步是实例化Poco并清空屏幕:
let black = poco.makeColor(0, 0, 0);
poco.begin();
poco.fillRectangle(black, 0, 0, poco.width, poco.height);
poco.end();
接下来,一个重复计时器(列表 9-2 )被安排以每秒 60 帧的速度运行。当计时器触发时,随机坐标和矩形尺寸连同随机颜色一起生成。begin方法将绘制限制在矩形区域。
Timer.repeat(function() {
let x = Math.random() * poco.width;
let y = Math.random() * poco.height;
let width = (Math.random() * 50) + 5;
let height = (Math.random() * 50) + 5;
let color = poco.makeColor(255 * Math.random(),
255 * Math.random(), 255 * Math.random());
poco.begin(x, y, width, height);
poco.fillRectangle(color, 0, 0, poco.width,
poco.height);
poco.end();
}, 16);
Listing 9-2.
随机值都是浮点的,因为对Math.random的调用返回一个从 0 到 1 的数。所有 Poco 函数都期望坐标的整数值,因此makeColor和begin自动将提供的浮点数四舍五入为最接近的整数。在第十一章中,你将学习如何添加你自己的随机整数函数,通过消除这些浮点运算来提高性能。
绘制混合矩形
到目前为止绘制的矩形都是实心的:像素是完全不透明的,完全遮住了后面的像素。混合矩形将单一颜色与其后面的像素结合在一起,产生了一种如同戴着有色眼镜一样的效果。混合矩形在用户界面中用于提供分层效果和绘制阴影。
要绘制一个混合矩形,使用blendRectangle方法。参数与fillRectangle类似,增加了混合级别作为第二个参数。混合级别是一个从 0 到 255 的数字,其中 0 表示完全透明(完全不可见),255 表示完全不透明。下面的线条以 128 (50%)的混合级别在整个屏幕上混合红色。像所有其他绘图操作一样,这必须发生在调用begin和end之间。
poco.blendRectangle(red, 128, 0, 0, poco.width, poco.height);
如果你给blendRectangle一个 0 的混合级别,它会完全忽略绘制操作,甚至不会添加一个条目到显示列表。如果您传递的混合级别为 255,blendRectangle的行为与fillRectangle完全一样。
为了探究混合矩形的外观及其渲染性能,$EXAMPLES/ch9-poco/blended-rectangle示例制作了一个混合矩形的动画。图 9-3 显示了混合矩形在屏幕上几个位置的图像。

图 9-3
来自blended-rectangle动画的渲染图
动画的背景由四个彩色条组成—白色、红色、绿色和蓝色。这些条由drawBars辅助函数绘制,如清单 9-3 所示。
function drawBars(poco) {
let w = poco.width;
let h = poco.height / 4;
poco.fillRectangle(poco.makeColor(255, 255, 255),
0, 0, w, h);
poco.fillRectangle(poco.makeColor(255, 0, 0),
0, h, w, h);
poco.fillRectangle(poco.makeColor(0, 255, 0),
0, h * 2, w, h);
poco.fillRectangle(poco.makeColor(0, 0, 255),
0, h * 3, w, h);
}
Listing 9-3.
当示例开始时,它通过绘制彩色条覆盖整个屏幕。请注意,drawBars并不是以一次调用fillRectangle来用纯色填充整个屏幕开始的,而是绘制了四个独立的条带,它们组合起来覆盖了整个屏幕区域。
poco.begin();
drawBars(poco);
poco.end();
接下来,定义变量来控制混合黑盒的动画,该黑盒从屏幕的顶部中心下降到底部(参见清单 9-4 )。
let boxSize = 30;
let boxBlend = 64;
let boxStep = 2;
let boxColor = poco.makeColor(0, 0, 0);
let x = (poco.width - boxSize) / 2, y = 0;
Listing 9-4.
以像素为单位的框的大小由boxSize定义。混合水平是 64 (25%)。在动画的每一帧上,盒子步进两个像素,如boxStep所定义的。boxColor变量定义了要用黑色绘制的盒子。最后,盒子左上角的初始坐标被设置在x和y变量中。
盒子的运动通过一个重复的计时器来激活,如清单 9-5 所示。对begin的调用指定了一个包含盒子的当前和先前位置的绘图区域,确保在一次操作中完全擦除先前位置并完全绘制新位置。对drawBars的调用指定了填充屏幕的坐标,但是这些坐标仅限于传递给begin的更新区域。定时器回调函数结束时, y 坐标增加boxStep。一旦盒子滑离屏幕底部,y 坐标将被重置为 0,以继续从屏幕顶部开始制作动画。
Timer.repeat(function() {
poco.begin(x, y - boxStep, boxSize, boxSize + boxStep * 2);
drawBars(poco);
poco.blendRectangle(boxColor, boxBlend, x, y, boxSize,
boxSize);
poco.end();
y += boxStep;
if (y >= poco.height)
y = 0;
}, 16);
Listing 9-5.
该动画在 ESP32 和 ESP8266 上以每秒 60 帧的速度流畅运行。这是因为代码优化了绘图区域,因此微控制器每秒仅向显示器发送约 60,000 像素,或不到一个完整的帧。这些像素的渲染和传输分布在 60 个帧中。与渲染全帧相比,这将渲染和传输的像素数量减少了 98.6%。通过更改控制动画的变量进行实验,以查看更改长方体大小、混合级别和长方体颜色的效果。
当运行这个示例时,当盒子返回到顶部时,您可能会注意到在屏幕底部有一个小的盒子假象。可以修改代码来消除工件,但是这样做会使代码更加复杂。这是 Piu 自动处理的细节之一,您将在第十章中看到。
绘制位图
绘制位图是 Poco 提供的第二个基本绘制操作。它用于遮罩位图和图像位图。因为有这么多不同种类的位图,而且在构建用户界面时位图有这么多的用途,所以有几个不同的函数来绘制位图。本节向您介绍一些最常用的函数。
绘画蒙版
正如您在第八章中了解到的,掩码是微控制器构建用户界面时最常用的位图类型。原因有很多:它们提供了出色的质量,因为它们支持抗锯齿,它们可以用不同的颜色渲染,它们可以快速渲染,并且它们可以被压缩以最小化存储需求。
掩码存储在资源中。您可以通过将遮罩图像包含在项目清单中来选择要在项目中使用的遮罩图像,如清单 9-6 所示(正如您在第八章的“向项目添加遮罩”一节中所学)。
"resources": {
"*-mask": [
"./assets/mask"
]
}
Listing 9-6.
要使用蒙版位图,您必须首先访问存储它的资源。资源只是数据;使用 Poco API 渲染遮罩需要 Poco 位图对象。Commodetto 提供了从资源数据创建 Poco 对象的函数。
要从压缩蒙版实例化一个位图对象,使用 Commodetto 的parseRLE函数。(“RLE”代表“游程编码”,用于压缩掩码的算法。)下面的代码检索资源并使用parseRLE创建位图对象:
import parseRLE from "commodetto/parseRLE";
let mask = parseRLE(new Resource("mask-alpha.bm4"));
在这个小例子中,有一些重要的细节需要理解:
-
正如您在第五章中看到的,
Resource构造函数引用闪存中的资源数据,而不是将其加载到 RAM 中。parseRLE函数还引用数据,而不是将数据从闪存复制到 RAM 然而,parseRLE确实为引用该数据的 Poco 位图对象分配了少量的 RAM。 -
注意,加载资源的路径是
mask-alpha.bm4,而不是mask.png。记住,在构建时运行的工具将 PNG 文件转换成微控制器的优化格式,这些工具将优化的图像数据放入bm4类型的文件中。因为图像被用作 alpha 通道,所以名称后会附加-alpha。因此,运行在微控制器上的代码需要加载不同于原始名称的数据。(Piu 会自动为您使用正确的名称和分机。)
一旦有了蒙版的位图对象,就可以通过调用drawGray方法来绘制蒙版:
poco.drawGray(mask, red, 10, 20);
第一个参数是遮罩,第二个参数是要应用的颜色,最后两个参数是 x 和 y 坐标。请注意,您没有指定尺寸;Poco 总是以原始大小渲染位图,而不应用任何缩放。这样做是因为高质量的缩放会使用更多的 CPU 时间,并增加 Poco 中的渲染代码量。
由parseRLE返回的蒙版位图对象具有width和height属性,以像素为单位给出位图的尺寸。当您更改图形资源的尺寸时,通过使绘图自动适应,这些在您的绘图中会很有用。例如,下面的代码在蒙版后面的区域绘制了一个蓝色矩形,因此蒙版没有绘制的任何像素都是蓝色的,并且蒙版中具有透明度的任何像素都与蓝色背景混合。蓝色背景矩形的大小总是与蒙版的大小精确匹配。
poco.fillRectangle(blue, 10, 20, mask.width, mask.height);
poco.drawGray(mask, red, 10, 20);
使用未压缩的遮罩
从第八章到你知道,只画一个压缩蒙版的子集会有一些低效,因为解压缩器必须跳过你想要画的图像上面和左边的部分。您可以使用未压缩的遮罩。为此,将掩码图像放在清单资源的*-alpha部分(而不是*-mask部分),使其以未压缩的形式存储。然后,不使用parseRLE来加载它,而是使用资源扩展为.bmp的parseBMP。
import parseBMP from "commodetto/parseBMP";
let mask = parseBMP(new Resource("mask-alpha.bmp"));
在压缩和未压缩遮罩之间切换时,请记住执行以下操作:
-
将资源放在正确的部分:
*-alpha表示未压缩,*-mask表示压缩。 -
使用正确的加载函数实例化位图:
parseBMP表示未压缩,parseRLE表示压缩。 -
在资源名中使用正确的扩展名:
.bmp表示未压缩,而.bm4表示压缩。
一旦你有了位图,你就可以使用drawGray来渲染蒙版,不管它们是压缩的还是未压缩的。
绘制遮罩的一部分
图 9-4 (你在第八章中第一次看到的)中的图像是一张未压缩的蒙版图像,其中包含描绘几种不同 Wi-Fi 状态的图标。

图 9-4
Wi-Fi 图标条
这个图像的一个明显用途是绘制一个反映当前 Wi-Fi 状态的图标。您的应用程序将希望一次只绘制一个图标,以反映当前状态。如前一节所述,出于效率的原因,组合不同状态的图像不应被压缩。
要仅绘制位图的一部分,您需要指定一个源矩形,即要使用的位图区域。在$EXAMPLES/ch9-poco/wifi-icons示例中,源矩形的 x 和 y 坐标、宽度和高度作为绘图坐标后的可选参数传递给drawGray。每个单独的状态图标为 27 像素见方。来自wifi-icons示例的以下代码绘制了四个状态图标,如图 9-5 所示:

图 9-5
从 Wi-Fi 图标条创建的图标
poco.drawGray(mask, black, 10, 20, 0, 0, 27, 27); // top left
poco.drawGray(mask, black, 37, 20, 0, 27, 27, 27); // bottom left
poco.drawGray(mask, black, 10, 47, 112, 0, 27, 27); // top right
poco.drawGray(mask, black, 37, 47, 112, 27, 27, 27); // bottom right
淡入淡出遮罩
淡入或淡出图像是用户界面中常见的过渡。drawGray方法有一个将蒙版与背景像素混合的选项。这与混合矩形的想法是一样的,但是使用遮罩可以混合任何形状。$EXAMPLES/ch9-poco/fade-mask示例淡入淡出一个音量图标,如图 9-6 所示。

图 9-6
来自fade-mask动画的渲染图
混合级别在drawGray的可选第九个参数中指定。与blendRectangle中一样,混合级别是一个从 0 到 255 的数字,其中 0 表示完全透明,255 表示完全不透明。
清单 9-7 显示了来自fade-mask示例的代码,它将遮罩资源从透明渐变为不透明。与blended-rectangle示例中相同的drawBars函数(列表 9-3 )在背景上绘制蒙版。
let mask = parseRLE(new Resource("mask-alpha.bm4"));
let maskBlend = 0;
let blendStep = 4;
let maskColor = poco.makeColor(0, 0, 255);
Timer.repeat(function() {
let y = (poco.height / 4) - (mask.height / 2);
poco.begin(30, y, mask.width, mask.height);
drawBars(poco);
poco.drawGray(mask, maskColor, 30, y,
0, 0, mask.width, mask.height, maskBlend);
poco.end();
maskBlend += blendStep;
if (maskBlend > 255)
maskBlend = 0;
}, 16);
Listing 9-7.
请注意,要使用混合级别,您还必须提供源矩形,即使是在绘制整个蒙版时。位图矩形的尺寸—在本例中为mask.width和mask.height—用于源矩形;这确保了当素材的维度改变时,代码不需要改变。
绘制彩色图像
您可以使用 JPEG 和 PNG 文件将彩色图像添加到项目中。构建工具将它们转换为未压缩的位图,以便在设备上呈现,因为在微控制器上使用 JPEG 和 PNG 压缩格式来构建高性能用户界面通常是不切实际的。位图存储在 BMP 文件中(扩展名为.bmp),因为没有压缩,所以可能会很大。例如,对于使用 16 位像素的显示器,40 像素见方的图像占用 3200 字节的存储空间。
如前所述,使用parseBMP函数为 BMP 图像创建 Poco 位图,并使用drawBitmap方法绘制它,将绘制图像的位置的 x 和 y 坐标作为参数传递。
let image = parseBMP(new Resource("quack-color.bmp"));
poco.drawBitmap(image, 30, 40);
与drawGray一样,通过指定源矩形,您可以选择只绘制图像的一部分。以下示例仅绘制图像的左上象限:
poco.drawBitmap(image, 30, 40, 0, 0,
image.width / 2, image.height / 2);
绘制 JPEG 图像
由于它们的内存和 CPU 需求,压缩的 JPEG 图像不是在微控制器上存储图像的好的通用方式;但是,当您需要在相对较小的空间内存储大量图像时,它们非常有用,例如,幻灯片放映或在用户界面中使用的图像集合。Commodetto 包括一个 JPEG 解压缩器,您可以与 Poco 一起使用,在您的项目中绘制 JPEG 图像。本节解释了两种不同的方法。
在资源中存储 JPEG 数据
如您所知,构建工具会自动将清单中的图像转换为 BMP 文件。如果您想保持 JPEG 文件的原始压缩格式,请将 JPEG 图像放在清单的data部分,而不是resources部分(参见清单 9-8 )。data部分的内容总是被复制而没有任何转换。
"data": {
"*": [
"./piano"
]
}
Listing 9-8.
下一节介绍的绘制 JPEG 图像的方法与软件显示旋转不兼容。这是因为软件旋转依赖于在构建时旋转映像,这里清单告诉构建工具不要转换映像。这些绘制 JPEG 图像的技术仅在您使用硬件旋转或软件旋转为 0 度时有效。
从内存中绘制 JPEG 图像
在电脑和手机上,JPEG 图像通常会被解压缩为屏幕外位图;然后,当需要 JPEG 图像时,绘制位图。这种方法提供了出色的渲染性能,因为解压缩 JPEG 图像的复杂操作只发生一次。但是,存储解压缩的 JPEG 图像会占用大量内存。因此,这种方法通常只适用于相对较小图像的微控制器。
以下示例使用loadJPEG函数将包含 JPEG 数据的资源解压缩为 Poco 位图。一旦图像在位图中,您使用drawBitmap来渲染它,如前所述。
import loadJPEG from "commodetto/loadJPEG";
let piano = loadJPEG(new Resource("piano.jpg"));
poco.drawBitmap(piano, 0, 0);
对loadJPEG的调用需要一些时间来完成,因为解压缩 JPEG 图像对于微控制器来说是一个相对困难的操作。该时间因图像大小、压缩级别和微控制器性能而异。
在解压缩过程中绘制 JPEG 图像
如果您没有足够的内存来保存完整的解压缩 JPEG 图像,您仍然可以显示图像,方法是在解压缩时按块显示图像。这个例子演示了如何将一个全屏(240 x 320)的 JPEG 图像直接解压缩到屏幕上。当你运行这个例子时,你会看到如图 9-7 所示的屏幕。

图 9-7
来自draw-jpeg示例的 JPEG 图像
首先使用JPEG类为 JPEG 图像创建一个 Poco 位图:
import JPEG from "commodetto/readJPEG";
let jpeg = new JPEG(new Resource("harvard.jpg"));
JPEG 解压缩程序总是一次解码一个块。块的大小因 JPEG 图像的压缩方式而异,介于 8 x 8 和 16 x 16 像素之间。随着块被解压缩,您的代码可以将它们直接绘制到屏幕上。
清单 9-9 显示了来自draw-jpeg示例的代码,它将 JPEG 图像解压缩到屏幕上。read方法解压缩图像的一个块,并将其作为 Poco 位图返回。位图对象包括提供 JPEG 图像中块的坐标的x和y属性,以及提供块的尺寸的width和height属性。当有更多的块要显示时,JPEG类的ready属性返回true,当所有的块都被解码后返回false。
while (jpeg.ready) {
let block = jpeg.read();
poco.begin(block.x, block.y, block.width, block.height);
poco.drawBitmap(block, block.x, block.y);
poco.end();
}
Listing 9-9.
用彩色图像填充
用纹理填充屏幕区域可以创建比纯色更有趣的用户界面。$EXAMPLES/ch9-poco/pattern-fill示例演示了如何平铺一幅地球图像来覆盖屏幕的一部分,如图 9-8 所示。

图 9-8
来自pattern-fill示例的重复地球纹理
使用纹理图案的大图像需要更多的存储空间。一个好的替代方法是使用可以平铺的小图案。您的代码可以简单地多次绘制小图像;然而,向drawBitmap发出所有这些调用需要时间,这样做可能会溢出 Poco 的显示列表。更好的选择是使用 Poco 的fillPattern方法,用 Poco 位图平铺一个矩形区域。例如,下面是如何用存储在名为tile的变量中的位图填充整个屏幕:
poco.fillPattern(tile, 0, 0, poco.width, poco.height);
位图后的参数是要填充的矩形的 x 和 y 坐标、宽度和高度。fillPattern方法还支持可选的源矩形,这使您可以只使用位图的一部分来显示图块。例如(如图 9-9 所示),来自pattern-fill示例的图像组合了同一纹理的 11 个不同版本,每个版本位于动画的不同步骤。

图 9-9
图片来自pattern-fill示例
pattern-fill示例使用源矩形用动画图案填充屏幕区域。清单 9-10 显示了创建动画的代码。定时器用于在组合图像中的八个不同图像中顺序移动。phase变量跟踪动画模式的八个步骤中的哪一个要绘制。
let tile = parseBMP(new Resource("tiles-color.bmp"));
let size = 30;
let x = 40, y = 50;
let phase = 0;
Timer.repeat(function() {
poco.begin(x, y, size * 5, size * 5);
poco.fillPattern(tile, x, y, size * 5, size * 5,
phase * size, 0, size, size);
poco.end();
phase = (phase + 1) % 8;
}, 66);
Listing 9-10.
绘制蒙版彩色图像
通过蒙版(alpha 通道)绘制彩色图像是移动应用程序和网页中的一种常见技术。正如你在第八章中看到的,它能让你画出任何形状的全色图像,而不仅仅是矩形。使用 Poco 的drawMasked方法,可以通过未压缩的灰度蒙版绘制未压缩的彩色图像。
调用接受许多参数,除了一个之外,其余都是必需的。这些是按顺序排列的参数:
-
image–彩色位图图像。 -
x,y–要绘制的坐标。 -
sx、sy、sw、sh–要从彩色位图中使用的源矩形。 -
mask–掩膜位图(未压缩的 4 位灰度;不支持压缩掩码)。 -
mask_sx,mask_sy–要从掩码位图中使用的源矩形左上角的坐标。(宽度和高度与彩色位图源矩形的宽度和高度相同。) -
blend–(可选)混合等级,从 0 到 255;默认为 255(完全不透明)。
要尝试通过蒙版绘制彩色图像,您需要一个图像和一个蒙版。$EXAMPLES/ch9-poco/masked-image示例使用图 9-10 中的圆形蒙版,用图 9-11 中的火车图像创建聚光灯效果。

图 9-11
来自masked-image示例的列车图像

图 9-10
来自masked-image示例的圆形遮罩
蒙版和彩色图像是用parseBMP加载的,因为它们都是未压缩的:
let image = parseBMP(new Resource("train-color.bmp"));
let mask = parseBMP(new Resource("mask_circle.bmp"));
如以下代码所示,绘图位置被设置为x和y变量中的坐标(30,30)。变量sx是源矩形的左侧;它被初始化到图像的右侧,因此火车渲染从火车的前面开始。step变量被设置为 2,以在每帧上将训练推进两个像素。
let x = 30, y = 30;
let sx = image.width - mask.width;
let step = 2;
清单 9-11 显示了制作动画的代码。计时器被用来定时开动火车。画的位置总是不变的,火车穿过遮罩。通过调整图像源矩形的左边缘sx,火车移动。
Timer.repeat(function() {
poco.begin(x, y, mask.width, mask.height);
poco.fillRectangle(gray, x, y, mask.width, mask.height);
poco.drawMasked(image, x, y,
sx, 0, mask.width, mask.height, mask, 0, 0);
poco.end();
sx -= step;
if (sx <= 0)
sx = image.width - mask.width;
}, 16);
Listing 9-11.
图 9-12 显示了通过蒙版绘制部分列车的结果。请注意,蒙版的边缘与灰色背景融合在一起。

图 9-12
具有默认混合级别(255)的掩蔽序列
drawMasked的可选blend参数改变每个像素的相对不透明度。图 9-13 显示了使用 128(大约 50%)的混合级别渲染的相同火车图像。请注意,所有的像素,不仅仅是边缘,都与背景融为一体。

图 9-13
混合等级为 128 的掩蔽序列
绘图文本
Poco 支持的第三个也是最后一个基本绘图操作是绘制文本。要绘制文本,首先需要一种字体。字体存储为位图,通常被压缩。
在您的应用程序中,使用parseBMF函数从资源中加载字体。对于压缩字体,扩展名为.bf4。本章根据在用 Piu 构建的应用程序中通常使用的惯例,确定一个名称由连字符分隔部分组成的字体资源(如第十章中进一步描述的)。
import parseBMF from "commodetto/parseBMF";
let regular16 = parseBMF(new Resource("OpenSans-Regular-16.bf4"));
let bold28 = parseBMF(new Resource("OpenSans-Semibold-28.bf4"));
Poco 对您的项目可能包含的字体数量没有限制。当然,目标微控制器上可用的闪存存储空间会限制项目中字体的数量和大小。
字体中的字符是灰度遮罩,因此可以用任何颜色绘制。drawText方法需要文本字符串、字体、颜色和绘图坐标作为参数。坐标指定绘制的第一个字符左上角的位置。下面一行从屏幕的左上角开始,用 16 磅、常规粗细的黑色 Open Sans 绘制字符串Hello:
poco.drawText("Hello", regular16, black, 0, 0);
绘制文本阴影
您可以通过绘制文本两次来实现投影效果,每次使用不同的坐标,首先作为阴影,然后作为主要文本。$EXAMPLES/ch9-poco/text-shadow示例首先在主文本要去的地方的右边向下画阴影颜色的文本,然后用在主坐标上画的主颜色的相同字符串覆盖它。这导致文本如图 9-14 所示。

图 9-14
由text-shadow示例绘制的文本
let text = "Drop Shadow";
poco.drawText(text, bold28, lightGray, 0 + 2, 100 + 2);
poco.drawText(text, bold28, blue, 0, 100);
测量文本
所绘制文本的高度与字体的高度相同,字体的高度包含在 font 对象的height属性中。使用getTextWidth方法确定所绘制文本的宽度。下面的代码在绘制文本之前用绿色填充文本后面的区域:
let text = "Hello";
let width = poco.getTextWidth(text, regular16);
poco.fillRectangle(green, 0, 0, width, regular16.height);
poco.drawText(text, regular16, black, 0, 0);
Note
字体被传递给getTextWidth,因为它包含每个字符的尺寸。注意不要用一种字体量,用另一种字体画;它们的测量值可能不同,因此您可能会得到意想不到的结果。
截断文本
如果要绘制的文本宽度超过了可用空间,常见的解决方案是绘制省略号(...)在文字被截断的地方。当您告诉方法可用于绘制的宽度时,drawText方法会自动为您完成这项工作。
下面的示例在单行上绘制一个句子,将其截断到屏幕的宽度。结果如图 9-15 所示。

图 9-15
两种不同字体的截断文本
let text = "JavaScript is one of the world's most widely used
programming languages.";
poco.drawText(text, regular16, black, 0, 0, poco.width);
poco.drawText(text, bold28, black, 0, 40, poco.width);
环绕文本
在某些情况下,您可能想要在显示器的多行上绘制文本。在支持来自世界各地的书面语言的一般情况下,这种自动换行是有挑战性的。这个例子展示了一种基本的方法,这种方法足以应付使用罗马字符编写的语言的常见情况。
该示例使用String对象的split方法创建一个包含字符串单词的数组:
let text = "JavaScript is one of the world's most widely used
programming languages.";
text = text.split(" ");
然后它遍历所有的单词,一次一个,如清单 9-12 所示。如果行上有足够的空间来容纳当前单词,或者如果单词比整行宽,则绘制文本;否则,width被重置为全行宽度,y增加字体高度,以便在下一行继续绘图。
let width = poco.width;
let y = 0;
let font = regular16;
let spaceWidth = poco.getTextWidth(" ", font);
while (text.length) {
let wordWidth = poco.getTextWidth(text[0], font);
if ((wordWidth < width) || (width === poco.width)) {
poco.drawText(text[0], font, black, poco.width - width, y);
text.shift();
}
width -= wordWidth + spaceWidth;
if (width <= 0) {
width = poco.width;
y += font.height;
}
}
Listing 9-12.
图 9-16 显示了字体分别设置为regular16和bold28时运行该示例的结果。

图 9-16
text-wrap字体大小为 16(左)和 28(右)的示例
其他绘图技术
Poco 和 Commodetto 提供了许多工具来简化和优化特定需求的绘图。下面几节介绍其中的三种:使用裁剪将文本限制在一个框内,使用原点轻松地重用绘图代码,以及在屏幕外绘制以有效地呈现渐变。
将文本限制在一个框中
如你所知,Poco 不在你调用 Poco 的begin方法时定义的更新区域外绘制;通过将初始剪辑区域设置为与更新区域相同,它剪辑到该区域。您的代码还可以在绘制过程中调整剪辑区域。剪辑区域总是被begin定义的更新区域所限制;您可以缩小剪裁区域,但可以将其扩展到初始绘图区域之外。
一个有用的地方是滚动条——适合屏幕一部分的滚动文本信息。文本决不能画在滚动条的边界之外,而是应该一直画到它的边缘。$EXAMPLES/ch9-poco/text-ticker示例演示了如何做到这一点;图 9-17 显示了该示例的渲染图。

图 9-17
由text-ticker示例绘制的滚动条
列表 9-13 显示了一些贯穿绘图代码的变量。外面有一个黑色的框架,它的像素大小存储在frame变量中。框架内有一个小的空白,不能画文字;它的像素大小存储在margin变量中。为跑马灯文本保留的区域的宽度存储在tickerWidth中。总的width和height由这些值计算得出。
let frame = 3;
let margin = 2;
let x = 10, y = 60;
let tickerWidth = 200;
let width = tickerWidth + frame * 2 + margin * 2;
let height = regular16.height + frame * 2 + margin * 2;
Listing 9-13.
在开始绘制之前,将对文本进行一次测量,以避免渲染过程中的冗余计算。结果存储在textWidth中。
let text = "JavaScript is one of the world's most widely used
programming languages.";
let textWidth = poco.getTextWidth(text, regular16);
变量dx存储文本相对于滚动条文本区域左边缘的当前水平偏移量。文本从右边缘开始,并从那里滚动。
let dx = tickerWidth;
股票代码由两部分组成。首先,绘制黑色框架和黄色股票背景:
poco.fillRectangle(black, x, y, width, height);
poco.fillRectangle(yellow, x + frame, y + frame,
tickerWidth + margin * 2,
regular16.height + margin * 2);
接下来,绘制文本(列表 9-14 )。该示例首先使用clip方法来更改剪辑区域。它用裁剪矩形的 x 和 y 坐标、宽度和高度调用clip。这会将当前剪辑区域推送到栈上,然后将其与所请求的剪辑相交。不带参数调用clip弹出剪辑栈并恢复前一个剪辑。这种方法使得嵌套裁剪区域更改变得容易。
poco.clip(x + frame + margin, y + frame + margin, tickerWidth,
regular16.height);
poco.drawText(text, regular16, black, x + frame + margin + dx,
y + frame);
poco.clip();
Listing 9-14.
最后,滚动条的水平偏移量被提前,为下一个动画帧做准备。当文本完全滚离左边缘时,它会重置为从右边缘再次滚入。
dx -= 2;
if (dx < -textWidth)
dx = tickerWidth;
轻松重用绘图代码
在调用 Poco 的begin方法后,绘图的原点(0,0)位于屏幕的左上角,到目前为止,在所有示例中原点都保持在那里。您可以使用origin方法来偏移原点。这简化了编写在屏幕上不同位置绘制用户界面元素的函数。$EXAMPLES/ch9-poco/origin示例使用origin方法在不同位置绘制相同的带黑框的黄色矩形,如图 9-18 所示。

图 9-18
由origin示例绘制的矩形
以下来自origin示例的函数绘制了一个带有黑框的黄色矩形:
function drawFrame() {
poco.fillRectangle(black, 0, 0, 20, 20);
poco.fillRectangle(yellow, 2, 2, 16, 16);
}
在此功能中,绘图在原点完成。在调用drawFrame之前移动原点会导致图形出现在屏幕上的不同位置。清单 9-15 显示了来自origin示例的代码,该示例在每次调用drawFrame之前调用origin方法来偏移原点。结果就是你在图 9-19 中看到的四个矩形。
drawFrame();
poco.origin(20, 20);
drawFrame();
poco.origin(20, 20);
drawFrame();
poco.origin();
poco.origin();
poco.origin(0, 65);
drawFrame();
poco.origin();
Listing 9-15.
原点从(0,0)开始。对poco.origin(20, 20)的第一次调用将原点移动到(20,20)。因为这些值是相对的,所以第二次调用poco.origin(20, 20)会将原点移动到(40,40)。
方法将当前原点存储在栈上。不带参数调用origin弹出原点栈,恢复之前的原点。与clip方法一样,这种方法使得嵌套原点的改变变得容易。在这个例子中,对poco.origin(0, 65)的调用发生在栈上的所有项目都被移除之后,所以原点回到(0,0)。调用后,原点在(0,65)。
虽然最后一次调用origin可能看起来没有必要,因为没有执行进一步的绘制,但是如果在调用end方法之前没有完全清除原点或剪辑栈,Poco 认为这是一个错误。如果出现这种不平衡的情况,end方法会报告一个错误。
高效渲染渐变
您的项目不限于在构建时创建的位图;您也可以在项目运行时创建位图。您已经看到了一个这样的例子:loadJPEG函数在内存中从压缩的 JPEG 数据创建一个位图。因为这些位图必须存储在 RAM 中,所以它们受到可用内存量的限制。您可以使用BufferOut类在运行时创建一个位图,这也为位图创建了一个虚拟屏幕。这使您能够像在物理屏幕上绘图一样,使用 Poco 在屏幕外绘制位图。
import BufferOut from "commodetto/BufferOut";
$EXAMPLES/ch9-poco/offscreen示例创建一个屏幕外位图,给位图绘制一个简单的渐变,然后在屏幕上将位图动画化。创建屏幕外位图时,需要指定其宽度和高度以及新位图的像素格式。这里像素格式设置为poco.pixelsOut.pixelFormat,这样屏幕外位图和屏幕具有相同的像素格式。
let offscreen = new BufferOut({width: 64, height: 64,
pixelFormat: poco.pixelsOut.pixelFormat});
这个屏幕外位图是一个 64 x 64 像素的正方形。为了绘制它,您创建另一个绑定到offscreen的Poco实例,而不是像第一个实例那样绑定到screen。
let pocoOff = new Poco(offscreen);
然后,该示例使用pocoOff绘制位图,就像绘制到屏幕上一样。清单 9-16 显示了它用来绘制如图 9-19 所示的灰度渐变的代码。

图 9-19
由offscreen示例绘制的灰度渐变
pocoOff.begin();
for (let i = 64; i >= 1; i--) {
let gray = (i * 4) - 1;
let color = pocoOff.makeColor(gray, gray, gray);
pocoOff.fillRectangle(color, 0, 0, i, i);
}
pocoOff.end();
Listing 9-16.
附加到offscreen的位图可从其bitmap属性中获得。下面一行将屏幕外位图绘制到屏幕上:
poco.drawBitmap(offscreen.bitmap, 0, 0);
渲染这个屏幕外位图的内容需要绘制 64 个不同的矩形,每个矩形的大小和颜色都略有不同。在动画中一遍又一遍地绘制这些矩形对于微控制器来说计算量太大。幸运的是,绘制屏幕外位图要容易得多。
offscreen的例子通过以不同的速度左右滑动屏幕外位图的 19 个副本来制作动画。清单 9-17 显示了动画代码,图 9-20 显示了动画的渲染。

图 9-20
offscreen动画的渲染
let step = 1;
let direction = +1;
Timer.repeat(function() {
poco.begin(0, 0, 240, 240);
poco.fillRectangle(white, 0, 0, poco.width, poco.height);
for (let i = 0; i < 19; i += 1)
poco.drawBitmap(offscreen.bitmap, i * step, i * 10);
step += direction;
if (step > 40) {
step = 40;
direction = -1;
}
else if (step < 1) {
step = 0;
direction = +1;
}
poco.end();
}, 33);
Listing 9-17.
触摸输入
如果您使用 Poco 来绘制产品的用户界面,并且希望加入触摸功能,您需要通过直接从触摸输入驱动程序中读取来实现对触摸输入的支持。当您使用 Piu 时,触摸输入会自动为您处理。幸运的是,阅读触摸输入并不十分困难。
访问触摸驱动程序
最常见的电容式触摸输入是 FocalTech FT6206。该部件用于可修改的一个和可修改的两个板。您将 touch 驱动程序导入到项目中,并创建一个实例,如下所示:
import FT6206 from "ft6206";
let touch = new FT6206;
旧的电阻式触摸屏通常使用 XPT2046 触摸控制器。
import XPT2046 from "xpt2046";
let touch = new XPT2046;
两个触摸驱动程序实现了相同的 API,所以一旦你实例化了驱动程序,你从它们读取的代码对两者来说是相同的。
读取触摸输入
要从触摸驱动程序中检索触摸点,您需要调用read方法。您将一个接触点数组传递给read调用,驱动程序更新这些点。通常,在实例化触摸驱动程序以最小化内存管理器和垃圾收集器所做的工作之后,分配一次触摸点。下面的代码分配了一个只有一个接触点的数组。该数组被分配给触摸输入驱动程序实例的points属性。
touch.points = [{}];
要检索当前触摸点,用点数组调用read:
touch.read(touch.points);
驱动程序为每个触摸点设置state属性。state属性的值如下:
-
0–无接触
-
1–触摸输入开始(手指向下)
-
2–触摸输入继续(手指仍向下)
-
3–触摸输入端(手指抬起)
对于除 0 之外的所有状态值,触摸点的x和y属性指示当前触摸位置。清单 9-18 中的代码,摘自$EXAMPLES/ch9-poco/touch,每秒钟对触控驱动进行 30 次采样,将当前状态输出到调试控制台。
Timer.repeat(function() {
let points = touch.points;
let point = points[0];
touch.read(points);
switch (point.state) {
case 0:
trace("no touch\n");
break;
case 1:
trace(`touch begin @ ${point.x}, ${point.y}\n`);
break;
case 2:
trace(`touch continue @ ${point.x}, ${point.y}\n`);
break;
case 3:
trace(`touch end @ ${point.x}, ${point.y}\n`);
break;
}
}, 33);
Listing 9-18.
FT6206 的某些版本不能可靠地产生触摸结束状态。运行该示例时,可以看到组件的行为。如果没有产生触摸结束状态,当触摸点进入状态 0(无触摸)时,可以确定触摸序列已经结束。
使用多点触控
read方法采用点的数组而不是单个点的原因是它可以支持多点触摸。FT6206 电容式触摸传感器支持两个同时触摸点,只要它们不是靠得太近。要使用多点触摸,只需要传递一个包含两个点的数组。
touch.points = [{}, {}];
touch.read(touch.points);
应用旋转
触摸驱动程序总是提供既没有应用硬件旋转也没有应用软件旋转的点。如果您使用旋转,您需要将其应用到触摸点。如您所料,Piu 会为您旋转接触点。
您可以使用清单 9-19 中的代码来转换 90 度、180 度和 270 度旋转的坐标。
if (90 === rotation) {
const x = point.x;
point.x = point.y;
point.y = screen.height - x;
}
else if (180 === rotation) {
point.x = screen.width - point.x;
point.y = screen.height - point.y;
}
else if (270 === rotation) {
const x = point.x;
point.x = screen.width - point.y;
point.y = x;
}
Listing 9-19.
结论
Poco 渲染器提供了构建物联网产品用户界面所需的所有基本工具。您可以使用许多不同的选项来绘制矩形、位图和文本。渲染功能包括消除文本锯齿、以任何颜色绘制的灰度蒙版,以及通过 alpha 通道蒙版渲染彩色图像。您可以使用裁剪来限制更新的屏幕区域,从而优化渲染性能。
Poco 给了你很大的控制权——但是这种权力也带来了一些不便。您必须加载资源并调用适当的函数来解析它们,您必须计算要更新的屏幕区域,并且您必须注意旋转的一些细节。下一章将介绍 Piu 用户界面框架,它将为您处理这些细节。
十、使用 Piu 构建用户界面
Piu 是一个面向对象的用户界面框架,它简化了创建复杂用户界面的过程。它使用 Poco 渲染器进行绘制。本章概述了 Piu 的工作原理,并通过一系列例子介绍了它的一些主要功能。Piu 这个名字在古典音乐中的意思是“更多”,反映了 Piu 在 Poco 基础上构建的非常丰富的功能集。
请记住,学习一个新的用户界面框架可能具有挑战性。每个框架都有自己的方式来解决构建用户界面的问题,并有自己的 API 套件来解决这个问题。要完全理解 Piu 的复杂性,仅仅遵循本章中的例子是不够的。本章的目的是教你 Piu 最重要和最常用的特性,展示它们的简单使用示例,并充分解释它们,以便你可以在自己的产品的用户界面中使用它们。
如果你已经知道层叠样式表,或者 CSS ,一种定义样式的语言——例如,文本——最常用于设计用 HTML 编写的网页,那么 Piu 的某些部分对你来说会很熟悉。Piu 和 CSS 的相似之处绝非偶然;Piu 整合了许多 CSS 约定,为开发 web 和物联网产品的开发人员提供一致性。
关键概念
在深入研究代码之前,理解 Piu 背后的一些关键概念是很重要的。如果您是使用面向对象的用户界面框架的新手,本节中的信息尤其重要,因为它能让您以正确的心态使用 Piu。如果您已经习惯了使用面向对象的框架,这一节仍然很重要,因为它介绍了 Piu 特有的信息。
一切都是物体
需要掌握的最重要的概念是,Piu 应用程序中用户界面的每个元素都有相应的 JavaScript 对象。JavaScript 对象是 Piu 提供的类的实例。Piu 与本书中介绍的其他可修改的 SDK 特性不同,因为您不必导入大多数 Piu 类。相反,Piu 将常用类的构造函数存储在全局变量中,使您可以在应用程序的任何模块中轻松使用它们。
每个 Piu 应用程序都从同一个对象开始:Piu Application类的一个实例。本章的宿主创建实例,因此本章中的示例都不需要创建它。清单 10-1 展示了主机如何通过调用Application构造函数来实例化Application实例。
new Application(null, {
displayListLength: 8192,
commandListLength: 4096,
skin: new Skin({fill: "white"}),
Behavior: AppBehavior
});
Listing 10-1.
现在不要担心各种属性的细节。请注意,这里使用了 Poco 的displayListLength属性,因为 Piu 使用 Poco 进行绘图。
作为Application构造函数的一部分,Piu 将实例存储在application全局变量中。这些例子通过application全局变量访问Application实例。
application对象是 Piu 应用程序的根。可以把它想象成一个容器,容纳所有出现在屏幕上的图形元素。添加到容器中的图形元素被称为内容对象。要在屏幕上显示一个内容对象,需要创建它的一个实例,并将其添加到application对象中。例如,为了显示一行文本,您创建 Piu 的Label类的一个实例,一种内容对象,并将其添加到application对象中。
Note
本章通过类的大写名称引用类,如“the Label class”,并通过未大写的类名引用类的实例,如“a label object”(或简称为“a label”)。
您可以创建 Piu 内容对象,而不需要将它们添加到application对象中,但是它们只有在添加后才会被绘制。当你使用 Piu 时,你不会自己调用绘图函数。内容对象知道如何绘制自己;它们会根据需要调用绘图函数来更新屏幕。
当然,您也可以从屏幕上删除内容对象。正如您可能已经猜到的,您可以通过将它们从application对象中移除来实现这一点。
每个用户界面元素都是一个内容对象
如您所知,Piu 应用程序中用户界面的每个元素都与一个内容对象相关联。更具体地说,每个用户界面元素都与一个从Content类继承的类的实例相关联。这样的类有很多,包括前面提到的Label类。在本章中,您将了解各种类型的内容对象。
Note
在本章中,“content对象”指的是Content类的实例,而通用术语内容对象指的是从Content类继承的任何类的实例。
创建内容对象时,在 JavaScript 字典中指定其属性。对于label对象,属性包括标签的字符串和文本样式。字典被传递给类的构造函数。
let sampleLabel = new Label(null, {
style: textStyle,
string: "Hello"
});
内容对象的属性可以随时更改。通过在实例中设置属性的值来更改属性,通常使用调用构造函数时用于初始化属性的相同属性名。
sampleLabel.style = OpenSansBold12;
sampleLabel.string = "Goodbye";
当您更改添加到application对象的内容对象的属性时,屏幕会自动更新。Piu 通过使显示的适当部分无效来进行更新,内容对象调用所需的绘图函数来更新屏幕。
并非所有 Piu 对象都是内容对象
除了内容对象之外,Piu 还有其他几种对象,本节将介绍其中最常见的对象。它们都用于以某种方式修改内容对象——它们的外观、行为方式或动画方式。这些对象都不是从Content类继承的。定义它们的类将在这里介绍,并在本章后面更详细地描述。
定义外观
Skin、Texture和Style类修改内容对象的外观:skin和texture对象被内容对象用来用颜色和图像填充区域,而style对象定义文本的外观,包括它的字体和颜色。例如,在前面的部分中,sampleLabel用一个字典实例化,该字典包含一个设置为名为textStyle的style对象的style属性。style对象不与单个内容对象相关联;相反,它可以应用于一个或多个label对象和其他内容对象。
类似地,skin对象通过内容对象的skin属性与内容对象相关联,并且像style对象一样,它们可以由许多内容对象共享。另一方面,Texture类不被内容对象直接使用;texture对象通过skin对象的texture属性与skin对象相关联,并且可以被多个skin对象共享。
与内容对象一样,您可以使用传递给构造函数的字典来指定skin、texture和style对象的属性。与内容对象不同,skin、texture和style对象的属性不能更改。这意味着,例如,要更改标签使用的字体,您需要更改label对象的style属性,而不是style对象的font属性。
控制行为
行为执行动作以响应事件,例如屏幕上的轻击、传感器值的变化或定时器的到期。内容对象的行为由Behavior类的子类定义。行为是 Piu 实现事件驱动编程风格的一部分。如果你是事件驱动编程的新手,不要担心;本章详细解释了behavior对象和事件如何在 Piu 中工作。
内容对象必须具有分配给它的行为,才能响应事件。内容对象不需要有指定的行为,但是如果没有行为,它就不会响应事件。通常一个内容对象有自己的一个Behavior子类的实例,尽管多个内容对象可能共享一个behavior实例。
鼓舞
要制作内容对象的动画,可以使用Timeline和Transition类。您可以通过更改内容对象的属性使其移动、改变颜色、淡入或淡出等来制作动画,并且可以将一个内容对象替换为另一个内容对象,例如,在屏幕之间移动。
内容对象没有timeline或transition属性;相反,timeline和transition对象是指它们动画化的内容对象。
安装 Piu 主机
您可以按照第一章中描述的模式运行本章的所有示例:使用mcconfig在您的设备上安装主机,然后使用mcrun安装示例应用程序。
所有 Piu 示例都需要使用屏幕,这使得您的mcconfig命令行必须为您的开发板指定一个带有屏幕驱动程序的平台。这些示例旨在 320 x 240 分辨率的屏幕上运行。以下命令行用于可修改 1、可修改 2 和 M5Stack FIRE:
> mcconfig -d -m -p esp/moddable_one
> mcconfig -d -m -p esp32/moddable_two
> mcconfig -d -m -p esp32/m5stack_fire
如果使用试验板和跳线将屏幕连接到开发板,请遵循第一章中的说明。为 ESP32 提供的接线与esp32/moddable_zero目标一起工作;对于 ESP8266 和esp/moddable_zero目标也是如此。
可修改零、可修改一、可修改二和 M5Stack FIRE 的屏幕驱动程序支持使用硬件旋转。主机配置屏幕旋转,使其以横向(320 x 240)方向呈现像素,而不是默认的纵向(240 x 320)方向。
如果你的设备没有屏幕,你可以在可修改的 SDK 提供的桌面模拟器上运行本章的例子。以下命令行适用于 macOS、Windows 和 Linux:
> mcconfig -d -m -p mac
> mcconfig -d -m -p win
> mcconfig -d -m -p lin
本章的主机在$EXAMPLES/ch10-piu/host目录中。从命令行导航到这个目录,用mcconfig安装它。
如果您正在使用桌面模拟器,请确保在安装示例之前将屏幕尺寸更改为 320 x 240。你可以通过从应用程序工具栏的尺寸菜单中选择 320 x 240 来实现。
Piu 的“你好,世界”
当您运行$EXAMPLES/ch10-piu/helloworld示例时,您会看到如图 10-1 所示的屏幕。

图 10-1
helloworld举例
这不是最令人兴奋的用户界面,但它是演示创建 Piu 对象以构建简单屏幕的基础的良好起点。显示的文本在一个label对象中定义,示例中的第一行代码创建了一个样式—Style类的一个实例— ,以定义文本的外观。
const textStyle = new Style({
font: "24px Open Sans"
});
下一节将介绍font属性的细节。现在,请注意,style对象的属性是在传递给Style类的构造函数的字典中定义的。正如您之前所了解的,这是 Piu 内容对象和定义其外观的对象的约定。每个 Piu 对象都需要指定某些属性,而其他属性是可选的。例如,Style构造函数需要一个font属性,但是与颜色、水平和垂直对齐以及行高相关的属性是可选的。
然后,helloworld示例创建一个内容对象:一个名为sampleLabel的label对象(参见清单 10-2 )。一个label对象用一种样式在一行上呈现文本。
const sampleLabel = new Label(null, {
style: textStyle,
string: "Hello, World",
top: 0, bottom: 0, left: 0, right: 0
});
Listing 10-2.
string属性指定标签显示的文本,style属性定义文本的样式(textStyle,如前所述)。top、bottom、left和right属性通过指定label对象和其容器application对象之间的边距来定义标签的位置;将这些全部设置为 0 会使label对象充满整个屏幕。默认情况下,文本水平和垂直居中,因此文本绘制在屏幕中央。
Note
请记住,top、bottom、left和right不是绝对坐标,而是指定从父容器的相应边缘开始的边距。
正如您在前面所学的,简单地创建一个内容对象并不能让它出现在屏幕上。您必须将内容对象添加到application对象中,以便 Piu 绘制它们。这是通过调用application.add来完成的。
application.add(sampleLabel);
在这个例子中,textStyle仅被附加到一个内容对象,但是回想一下,一个style对象可以被附加到多个内容对象。您可以添加第二个标签,该标签使用相同的样式,但在不同的位置显示不同的文本。例如,将清单 10-3 中的代码添加到示例中,会在屏幕的右下角显示文本Second string。
application.add(new Label(null, {
style: textStyle,
string: "Second string",
bottom: 0, right: 0
}));
Listing 10-3.
注意,这个标签没有指定top和left属性。如果指定了标签的bottom属性,但没有指定top,反之亦然,标签的高度就是其style属性指定的样式中文本的高度。同样,如果您只指定了left和right中的一个,标签的宽度就是其样式中文本的宽度。
字体
当style应用于内容对象时,style对象的font属性指示用于绘制文本的字体。字体通常是存储在资源中的压缩位图,如第八章所述。Piu 不包含任何内置字体;相反,宿主和应用程序可以在其清单中包含字体。本章的宿主提供了两种字体,所有绘制文本的示例都使用这两种字体。清单 10-4 显示了包含字体的清单片段。
"resources": {
"*-alpha": [
"./OpenSans-Regular-24",
"./OpenSans-Semibold-16"
]
},
Listing 10-4.
字体名称
Piu 使用样式的font属性来定位要使用的字体资源。本节介绍字体的命名约定,下一节解释这些名称如何映射到字体资源。
在helloworld示例中,字体名称由字符串"24px Open Sans"指定。字体名称的 Piu 格式是 CSS 字体名称格式的子集。Piu 字体名称由五部分组成,顺序如下:
表 10-2
大小关键字
|关键字
|
等效尺寸
|
| --- | --- |
| xx-small | 9px |
| x-small | 10px |
| small | 13px |
| medium | 16px |
| large | 18px |
| x-large | 24px |
| xx-large | 32px |
表 10-1
权重关键词
|关键字
|
等价数
|
| --- | --- |
| ultralight | 100 |
| thin | 200 |
| light | 300 |
| normal | 400 |
| medium | 500 |
| semibold | 600 |
| bold | 700 |
| heavy | 800 |
| black | 900 |

图 10-2
字体大小
-
样式–(可选)字体样式,指定为
italic,正常则省略。 -
粗细–(可选)字体的粗细。您可以使用与 CSS 中相同的关键字和数值(例如,
light、bold或800)。每个关键字都有一个等价的数值;比如重量light相当于300,bold就是700。表 10-1 列出了重量关键字及其等效数值。如果省略了字体名称的这一部分,则使用默认值normal(400)。 -
拉伸–(可选)字符之间的间距,指定为
condensed或正常时省略。 -
大小–字体的高度,以像素为单位。高度从下行字母的底部延伸到典型大写字母的顶部,如图 10-2 所示。您可以使用 CSS 中的绝对大小关键字(例如,
x-small或medium)或以像素为单位指定大小(如在24px)。请注意,实际高度因字体系列而异。表 10-2 列出了尺寸关键字及其对应的像素尺寸。 -
系列–字体系列的名称(例如
Times New Roman或Open Sans)。
表 10-3 列出并解释了可能在文本样式的font属性中指定的字体名称示例。
表 10-3
字体名称示例
|更多字体 Name
|
说明
|
| --- | --- |
| 24px Open Sans | 字体家族为Open Sans,大小为24px。没有指定弹力,重量,或者款式,所以都是正常的。 |
| bold 16px Fira Sans | 字体家族为Fira Sans,大小为16px,粗细为bold(相当于700)。没有指定拉伸或样式,所以它们都是正常的。 |
| italic bold medium Open Sans | 字体系列为Open Sans,字号为medium,或16px。重量为bold(相当于700),样式为italic。没有指定拉伸,所以很正常。 |
| italic bold condensed small Open Sans | 字体系列为Open Sans,字号为small,或13px。拉伸为condensed,重量为bold(相当于700,款式为italic。 |
字体资源
字体名24px Open Sans是指存储在名为OpenSans-Regular-24.fnt的资源中的字体。尽管字体名称和资源名称明显相似,但它们并不完全相同。Piu 通过应用一组规则从字体名称创建资源名称,从字体名称获取字体的资源数据。您需要了解这些规则,以便将代码中指定的字体名称与项目清单中包含的字体资源相匹配。
下面的列表按顺序显示了资源名的各个部分(不包括.fnt扩展名),并解释了 Piu 如何从字体名生成它们。
Note
这里的关键字(比如Light和Regular)是区分大小写的,所以它们的大写是有意义的。
-
系列–字体系列的名称,去掉任何空格。比如
Open Sans变成了OpenSans。 -
连字符(
-)–将字体系列名称与其后面的部分分开的连字符。 -
拉伸–字体拉伸正常时省略;否则,
Condensed。 -
粗细–字体粗细正常时省略;否则,字体粗细—例如,
Light、Bold,或数值,如200。 -
样式——字体样式正常时省略;否则,
Italic。 -
Regular–如果 stretch、weight 和 style 都正常,资源名称将包含关键字Regular来代替这三个关键字。 -
连字符(
-)–将弹力、重量和款式(或Regular)与后面的尺码分开的连字符。 -
大小–以像素为单位的高度,以数字表示——例如,
16或24。
表 10-4 给出了 Piu 字体名称和它们映射到的资源名称的例子。
表 10-4
映射到资源名称的字体名称示例
|更多字体 Name
|
资源名称
|
笔记
|
| --- | --- | --- |
| 24px Open Sans | OpenSans-Regular-24.fnt | 字体系列名称中的空格将被删除。因为 stretch、weight 和 style 都是正常的,所以在资源名称中使用了Regular来代替这三个部分。 |
| bold 16px Fira Sans | FiraSans-Bold-16.fnt | 字体大小移动到末尾,样式bold在资源名称中大写。 |
| italic bold 16px Open Sans | OpenSans-BoldItalic-16.fnt | 虽然字体名称将italic放在bold之前,但是资源名称指定了BoldItalic,因为权重总是在样式之前。还要注意在Bold和Italic之间没有空格或连字符。 |
当创建您自己的位图字体文件时,根据 Piu 的资源命名约定命名文件。这样做可以确保当您的代码指定字体名称时,Piu 会在您的资源中找到相应的字体数据。
关于字体的附加说明
Piu 从 CSS 中提取的字体命名约定是为了方便开发人员,同时又有足够的表现力来构建复杂的用户界面。它们还为 web 开发人员提供了一致性。然而,尽管 CSS 功能强大,但一些开发人员发现它更令人困惑,而不是有所帮助。如果您愿意,您可以简单地使用字体资源的名称作为字体名称。例如,helloworld示例中的textStyle可以定义如下:
const textStyle = new Style({
font: "OpenSans-Regular-24"
});
请记住,只有那些包含在清单中或由宿主提供的字体才可用于您的项目。在许多情况下,那只是一些字体。如果您指定了未安装的字体,Piu 将无法渲染它。这不同于桌面和 web 开发环境,在那里总是有后备字体。
因为每个字体资源只对应一个系列、宽度、粗细、样式和大小,所以每个变体都需要一个单独的资源。如果你创建了一个文本样式,它的font属性是24px Open Sans,你必须有一个名为OpenSans-Regular-24.fnt的字体资源。即使您有相关的字体资源可用,如OpenSans-Regular-12.fnt,Piu 也无法调整其大小以匹配您的文本样式中指定的24px大小。这也不同于桌面和 web 开发环境,在这些环境中,可调整大小的字体很常见。
添加颜色
$EXAMPLES/ch10-piu/helloworld-color示例为helloworld示例添加了颜色,使其更加有趣。它与helloworld相比有一些变化。
首先,helloworld-color中的style对象指定了一个color属性,该属性使标签用黄色绘制字符串:
const textStyle = new Style({
font: "24px Open Sans",
color: "yellow"
});
该示例还创建了一个名为labelBackground的skin对象。皮肤控制内容对象背景的绘制。这里的skin对象用十六进制表示法指定了fill属性,颜色为蓝色的#1932ab。
const labelBackground = new Skin({
fill: "#1932ab"
});
sampleLabel对象(清单 10-5 )添加了一个skin属性来设置其背景,使得标签用labelBackground中指定的蓝色阴影填充其背景。
const sampleLabel = new Label(null, {
left: 0, right: 0, top: 0, bottom: 0,
style: textStyle,
string: "Hello, World",
skin: labelBackground
});
Listing 10-5.
当您在设备上运行helloworld-color示例时,您会看到与helloworld相同的文本和布局,但是是蓝色背景上的黄色文本,而不是白色背景上的黑色文本。
当没有指定skin属性时,如在helloworld示例中,标签不为其背景绘制任何东西,导致文本出现在它后面的任何东西的前面。背景是白色的,因为在没有皮肤的情况下,文本出现在application对象本身的前面(由主机创建,如“一切都是对象”一节所示);因为主机将应用程序的skin属性设置为白色,这是整个屏幕的背景色。
指定颜色
helloworld-color示例的style对象中的color属性被设置为一个颜色名称,而skin对象中的fill属性用十六进制表示一种颜色。如本节所述,您可以用任何一种方式为这两个属性指定颜色。
示例的style对象中的color属性被设置为字符串"yellow"。Piu 支持 18 种颜色名称:black、silver、gray、white、maroon、red、purple、fuchsia、green、lime、olive、yellow、navy、blue、teal、aqua、orange和transparent。颜色及其 RGB 值取自 CSS Level 2 规范。
示例的skin对象中的fill属性是"#1932ab",用十六进制表示法指定的蓝色阴影。如清单 10-6 所示,Piu 支持将颜色指定为四种十六进制符号中任意一种的字符串:"#RGB"、"#RGBA"、"#RRGGBB"和"#RRGGBBAA"。在这些符号中,A代表“alpha 通道”,代表颜色的透明度:alpha 值为0xFF表示完全不透明,0 表示完全透明,介于两者之间的值执行混合。(alpha 值与某些 Poco 渲染函数中使用的混合级别相同,如blendRectangle和drawGray。)
const redSkin = new Skin({
fill: "#f00"
});
const blendedRedSkin = new Skin({
fill: "#f008"
});
const greenSkin = new Skin({
fill: "#00ff00"
});
const blendedGreenSkin = new Skin({
fill: "#00ff0080"
});
Listing 10-6.
CSS 中也使用了所有这些形式的十六进制颜色符号。
根据状态改变颜色
在本章的前面,你已经知道了skin和style对象的属性是不能改变的。因此,例如,您不能通过改变内容对象的skin和style对象的color属性来改变内容对象的颜色;相反,您创建一个不同的skin或style对象来改变颜色。然而,还有另一种更常见、更方便的改变颜色的方法。
通常你想改变用户界面元素的颜色是为了显示它的当前状态。例如,一个按钮可能有三种状态:禁用、启用但未被点击,以及启用并被点击。或者显示传感器读数的标签可以具有当读数在目标值的 5%以内、在目标值的 15%以内以及偏离目标值超过 15%时的状态。为了支持这些情况,每个内容对象都有一个state属性,一个指示其当前状态的数字。Piu 使用state属性和style对象的属性来改变用户界面元素的外观。
state属性只是一个数字;数字对应的州由你作为开发者决定。当内容对象改变状态时,用户界面如何改变也由您决定。例如,您可以选择使按钮在禁用时为浅灰色,在启用但未被点击时为绿色,在启用并被点击时为深绿色。
改变内容对象颜色的一个简单方法是使用它们的state属性作为skin和style对象中属性的索引。通过将skin或style对象的fill或color属性设置为两种、三种或四种颜色的数组,而不是表示单一颜色的字符串,可以做到这一点。例如:
const blackAndWhiteStyle = new Style({
color: ["black", "white"]
});
根据这个例子,清单 10-7 创建了一个带有黑色文本的标签,因为状态为 0,索引 0 处的颜色为"black"。
const sampleLabel = new Label(null, {
top: 0, bottom: 0, left: 0, right: 0,
style: blackAndWhiteStyle,
state: 0,
string: "Hello, World"
});
Listing 10-7.
当您更改state属性时,用户界面元素将使用其样式中的相应颜色重新绘制。将此处的状态更改为 1 会导致用白色文本重新绘制标签。
sampleLabel.state = 1;
您也可以对状态使用非整数值,从而混合周围状态的颜色。例如,您可以将本例中的文本设置为灰色,如下所示:
sampleLabel.state = 0.5;
从概念上来说,为状态指定小数值的能力似乎有些奇怪;例如,一个按钮处于禁用和启用状态之间意味着什么?然而,有一些有趣的用途,比如当你在两种状态之间制作动画时:你可以创建一个有两种颜色的style,并通过以小的增量将标签的state从 0 变到 1,将标签从第一种颜色慢慢淡化到第二种颜色。
用行为回应事件
一旦在屏幕上有了一些内容对象,下一步就是让它们执行动作来响应事件。您可以对behavior对象执行此操作。
对象是方法的集合。通过设置内容对象的behavior属性,可以将一个behavior对象附加到内容对象上。当内容对象接收到一个事件时,它在它的behavior对象中查找对应于该事件的方法;如果找到与事件同名的方法,它将调用该方法来处理事件。
Piu 定义了一组根据需要触发的事件。例如,当手指放在内容对象上时触发onTouchBegan事件,当手指移开时触发onTouchEnded事件。清单 10-8 中的TraceBehavior类包含通过跟踪调试控制台来响应 Piu 的onTouchBegan和onTouchEnded事件的方法。
class TraceBehavior extends Behavior {
onTouchBegan(label) {
trace("touch began\n");
}
onTouchEnded(label) {
trace("touch ended\n");
}
}
Listing 10-8.
由 Piu 定义和触发的事件称为低级事件。你也可以定义你自己的事件,使用任何你喜欢的名字;这些被称为高水平赛事。例如,您可以创建一个onSensorValueChanged事件,当传感器的值改变时,您的应用程序会触发该事件。本节的其余部分介绍一些常用的低级事件;在本章的后面,你将学习如何定义和触发你自己的高级事件。
“你好,世界”与行为
$EXAMPLES/ch10-piu/helloworld-behavior示例向helloworld示例添加了一个行为,使字符串"Hello, World"在您点击屏幕时一次显示一个字符。这个简单的行为展示了 Piu 如何将事件传递给内容对象。
在helloworld-behavior例子中的sampleLabel对象(列表 10-9 )类似于来自helloworld的对象。然而,有三个重要的区别:
-
sampleLabel的string属性以空字符串开始,因此它可以响应点击一次填充一个字符。 -
active属性被设置为true。此属性指定内容对象是否应响应触摸事件。如果设置为true,Piu 将触发onTouchBegan等触摸相关事件。默认值是false,所以你必须显式设置active到true来使内容可点击。 -
在传递给构造函数的字典中指定了一个
Behavior属性。这将sampleLabel的行为设置为LabelBehavior类。
const sampleLabel = new Label(null, {
top: 0, bottom: 0, left: 0, right: 0,
style: textStyle,
string: "",
active: true,
Behavior: LabelBehavior
});
sampleLabel.message = "Hello, World";
Listing 10-9.
LabelBehavior是扩展内置Behavior类的类:
class LabelBehavior extends Behavior {
...
}
当sampleLabel被创建时,Piu 也会创建一个LabelBehavior的实例,并将其赋给sampleLabel的behavior属性。注意,Behavior属性在传递给Label构造函数的字典中是大写的,而创建的实例的behavior属性是小写的;这是因为属性名遵循与它们所取值相同的大小写约定:类被传递给Behavior属性中的构造函数(按照约定,JavaScript 中的类名是大写的),而sampleLabel的behavior属性包含类的一个实例(按照约定,JavaScript 中的实例名是小写的)。
LabelBehavior只有一个方法onTouchBegan,如清单 10-10 所示。这个方法的参数是label对象本身。行为中调用的所有事件处理程序方法的第一个参数是它们所附加的内容对象。当被调用时,该方法将字符串"Hello, World"中的下一个字符添加到label对象中,直到添加完所有字符。然后,它将label对象的active属性设置为false,以阻止其接收进一步的触摸事件。
onTouchBegan(label) {
const message = label.message;
label.string = message.substring(0, label.string.length + 1);
if (label.string === message)
label.active = false;
}
Listing 10-10.
这就是实现一个基本触摸行为所要做的一切。当您运行该示例并点击label对象(覆盖整个屏幕)时,Piu 触发该对象的onTouchBegan事件。然后,label对象检查它的行为,看它是否有一个onTouchBegan方法;确实如此,所以它调用该方法,将对自身的引用作为第一个参数传递。
许多低级事件都有可能对您的项目有用的附加参数。例如,onTouchBegan事件也传递这四个参数:
-
id–用于支持多点触摸的触摸点的标识符。这个例子只支持一个触摸点,所以id总是 0。id值是一个来自触摸控制器的数字,使您能够区分屏幕上的不同触摸点。 -
x和y–事件的全局坐标,即触摸点的坐标,以像素为单位。 -
ticks–事件的全局时间,以毫秒为单位。该值不是一天中的时间,与 UTC 无关;它仅用于确定两个事件之间经过的时间。
当您第一次处理一个事件时,很好地理解它的一个好方法是向行为添加一个方法来跟踪它接收到的调试控制台的参数。例如,为了观察如何以及何时调用onTouchBegan的细节,将helloworld-behavior示例更改为清单 10-11 中所示的函数。
onTouchBegan(label, id, x, y, ticks) {
trace(`id: ${id}\n`);
trace(`{x, y}: {${x}, ${y}}\n`);
trace(`ticks: ${ticks}\n`);
}
Listing 10-11.
onTimeChanged和onDisplaying事件
本节介绍这些常用的低级事件:
-
onTimeChanged事件让您可以访问内置于每个 Piu 内容对象中的时钟。 -
在内容对象出现在屏幕上之前,
onDisplaying事件给你的行为一个自我配置的机会。
这些事件是通过$EXAMPLES/ch10-piu/helloworld-ticking示例引入的,它与helloworld-behavior示例相似,都是一次将一个角色"Hello, World"添加到屏幕上;然而,它不是在轻击屏幕时添加字符,而是每隔一段时间添加一次。请注意以下关于此示例的内容:
-
除了其
active属性没有设置为true之外,sampleLabel对象与helloworld-behavior中的对象相同,因为它不响应触摸事件。 -
LabelBehavior类包括onDisplaying和onTimeChanged方法(清单 10-12 )而不是onTouchBegan方法。他们的第一个参数是对与行为相关的label对象的引用,就像 Piu 定义的所有事件一样。
class LabelBehavior extends Behavior {
onDisplaying(label) {
...
}
onTimeChanged(label) {
...
}
}
Listing 10-12.
在内容对象被添加到application对象之后,但在用户看到它之前,onDisplaying事件被触发。这对于初始化对象的状态非常有用,尤其是那些可能被隐藏并在以后显示多次的内容对象。onDisplaying事件的一个常见用途是启动一个计时器,该计时器用于动画显示内容对象的外观。
因为动画是现代用户界面中如此普遍的一部分,Piu 给每个内容对象一个内置时钟。时钟以内容对象的interval属性指定的时间间隔“滴答”作响。属性和时钟都以毫秒表示时间。每当时钟滴答声响起,它就会产生一个onTimeChanged事件。时钟并不总是运行,最初是停止的;您使用内容对象的start和stop方法来控制它的时钟何时运行。
在这个例子中,行为的onDisplaying方法(清单 10-13 )通过重置index属性开始,该属性存储在任何给定时间label对象中的字符串的字符数。代码将interval属性设置为 250 毫秒,以请求每四分之一秒生成一次onTimeChanged事件。最后,该方法通过调用其start方法启动标签的时钟。
onDisplaying(label) {
this.index = 0;
label.interval = 250;
label.start();
}
Listing 10-13.
行为的onTimeChanged方法(清单 10-14 )在每个间隔向label对象的string属性添加一个新字符。它使用了substring方法,返回字符串的一部分。substring的参数分别指定要包含的第一个字符和要排除的第一个字符的索引。当显示完完整的字符串后,onTimeChanged调用标签的stop方法来阻止时钟滴答作响,这样onTimeChanged就不再被触发。
onTimeChanged(label) {
const message = label.message;
this.index += 1;
if (this.index > message.length)
label.stop();
else
label.string = message.substring(0, this.index);
}
Listing 10-14.
后面的例子展示了如何使用内容对象的时钟来驱动动画。
添加图像
图像是构建用户界面的基础部分。正如 Piu 使用skin对象用纯色填充屏幕区域一样,它也使用它们用图像填充屏幕区域,使任何内容对象都能绘制图像。skin对象中的纹理指定了要使用的图像。
为了展示如何呈现图像,$EXAMPLES/ch10-piu/js-icon示例绘制了 JavaScript 徽标。该示例绘制了如图 10-3 所示的屏幕。

图 10-3
js-icon举例
一个skin对象用于创建图标。第一步是通过实例化一个texture对象来创建对要使用的图像文件的引用。传递给Texture构造函数的字典的path属性是包含图像的资源的名称。
const jsLogoTexture = new Texture({
path: "js.png"
});
注意资源名有一个.png扩展名,而不是第九章中 Poco 的.bmp。虽然 PNG 图像仍被转换为另一种格式以在微控制器上呈现,但 Piu 知道这种转换,并自动将.png扩展名更改为设备的正确扩展名。
在helloworld-color示例中,您使用了一个带有fill属性的skin对象来创建纯色背景。在这个例子中,您使用了一个texture属性以及height和width属性来创建一个皮肤jsLogoSkin,它使用jsLogoTexture来填充内容对象。height和width属性被设置为匹配js.png图像文件的尺寸,100 x 100 像素。
const jsLogoSkin = new Skin({
texture: jsLogoTexture,
height: 100, width: 100
});
最后一步是创建一个引用jsLogoSkin的content对象:
const jsLogo = new Content(null, {
skin: jsLogoSkin
});
因为皮肤被定义为 100 x 100 像素,所以默认情况下,jsLogo内容对象具有相同的尺寸。
绘制图像的一部分
您可能想知道为什么之前必须在Skin构造函数中指定height和width属性。为什么皮肤没有默认使用整个图像?原因是skin对象的一个特性,它允许你只绘制部分纹理。要指定要绘制的纹理区域,可以使用x、y、height和width属性以像素为单位定义源矩形。x和y属性默认为 0,但是height和width属性是必需的。
清单 10-15 中的代码是js-icon示例中jsLogoSkin的替代。在这里,皮肤被定义为从图像的右下角开始绘制一个 70 x 70 像素的正方形。结果如图 10-4 所示。

图 10-4
js-icon裁剪示例jsLogoSkin
const jsLogoSkin = new Skin({
texture: jsLogoTexture,
x: 24, y: 30,
height: 70, width: 70
});
Listing 10-15.
很少绘制单个图标的一部分。毕竟,如果你只想画右下角,不妨把图像文件裁剪掉,节省一些存储空间。然而,在单个图像中存储几个图标通常很方便,在这种情况下,能够绘制图像的一部分非常有用。下一节将介绍一个例子。
从一幅图像中绘制多个图标
回想一下图 10-5 中的图标,你第一次看到这些图标是在第八章中。这些图标显示 Wi-Fi 连接的几种不同状态,并组合成一个图像。

图 10-5
Wi-Fi 图标
图标被组织成统一的网格,其中的列和行如下:
-
每列是 Wi-Fi 图标的不同状态,代表信号强度从弱到强。
-
每一行都是 Wi-Fi 图标的不同变体。顶行是开放式 Wi-Fi 接入点变体,底行是安全 Wi-Fi 接入点变体。
正如 Piu 使用内容对象的state属性来确定从样式中绘制哪种颜色一样,它可以使用内容对象的state和variant属性来确定从包含图标网格的纹理中绘制哪个图标。为此,这里包含纹理的皮肤必须指定每列的宽度和每行的高度,分别使用用于创建皮肤的字典中的states和variants属性(参见清单 10-16 )。
const wifiTexture = new Texture({
path: "wifi-strip.png"
});
const wifiSkin = new Skin({
texture: wifiTexture,
width: 28, height: 28,
states: 28,
variants: 28
});
Listing 10-16.
本例中的图像包含 28 像素见方的图标,因此states和variants属性都是 28。此外,height和width属性都被设置为 28,这样皮肤的大小正好是一个图标的大小。
$EXAMPLES/ch10-piu/wifi-status示例一次从该图像中绘制一个图标,每秒改变一次图标。它从左上角的图标开始(state和variant都是 0),如清单 10-17 所示。
const wifiIcon = new Content(null, {
skin: wifiSkin,
state: 0,
variant: 0,
Behavior: WifiIconBehavior
});
Listing 10-17.
内容对象的state和variant属性可以随时更新。本示例将它们更改为从左到右一次移动一个图标,首先穿过顶行,然后穿过底行;然后,它返回到最上面一行,无限期地重复这个过程。如清单 10-18 所示,wifiIcon行为中的onDisplaying和onTimeChanged事件处理程序使用内容对象的内置时钟来驱动动画(正如您在helloworld-ticking示例中看到的那样):该行为在每个 tick 上改变variant属性,移动一行图标;当到达一行中的最后一个图标时,它改变state属性以切换到另一行。
class WifiIconBehavior extends Behavior {
onDisplaying(content) {
content.interval = 1000;
content.start();
}
onTimeChanged(content) {
let variant = content.variant + 1;
if (variant > 4) {
variant = 0;
content.state = content.state ? 0 : 1;
}
content.variant = variant;
}
}
Listing 10-18.
使用面具
压缩的灰度蒙版比全彩色位图图像更有效地存储灰度图像,而且(正如你在第八章中了解到的)蒙版可以用任何颜色绘制。用户界面中绘制的许多图标只有一种颜色,因此可以存储为遮罩。一个texture对象可以引用一个蒙版图像资源和一个彩色位图资源,使你的用户界面可以同时包含这两种资源。
向应用程序添加蒙版图像与添加全色位图非常相似。$EXAMPLES/ch10-piu/mask-icon示例显示一个存储为遮罩的图标。当你点击图标时,它会改变颜色。
这个例子的texture和skin属性(清单 10-19 )应该看起来很熟悉。关键的区别在于,maskSettingsSkin用两种颜色指定了一个color属性,"orange"用于当内容对象的state属性的值为 0 时,"yellow"用于当内容对象的值为 1 时。(注意,指定一个皮肤的颜色有两种不同的方法:当你使用一个皮肤绘制蒙版纹理时,你指定color属性;要创建纯色背景,需要指定fill属性。)
const maskSettingsTexture = new Texture({
path: "settings-mask.png"
});
const maskSettingsSkin = new Skin({
texture: maskSettingsTexture,
width: 80, height: 80,
color: ["orange", "yellow"]
});
Listing 10-19.
像往常一样,您必须创建一个引用皮肤的内容对象。清单 10-20 展示了本例中创建的对象:一个content对象,它也有一个行为和一个active属性(设置为true,这样该对象可以接收触摸事件)。
const maskSettingsIcon = new Content(null, {
skin: maskSettingsSkin,
state: 0,
active: true,
Behavior: SettingsIconBehavior
});
Listing 10-20.
第一次绘制图标时,遮罩被绘制为橙色,因为state值为 0 意味着它使用了color属性中数组索引 0 处的颜色。
如清单 10-21 所示,该示例使用在点击开始和结束时触发的onTouchBegan和onTouchEnded事件来提供触摸反馈:
-
当
maskSettingsIcon接收到一个onTouchBegan事件时,它的行为将它的状态设置为 1,导致它用其color属性的索引 1 处的颜色重画——在本例中是黄色。 -
当
maskSettingsIcon接收到一个onTouchEnded事件时,它的行为将其状态更改为 0,使图标再次变为橙色。
class SettingsIconBehavior extends Behavior {
onTouchBegan(content) {
content.state = 1;
}
onTouchEnded(content) {
content.state = 0;
}
}
Listing 10-21.
平铺图像
您可以通过平铺皮肤纹理来绘制重复的图案。这是减少存储空间的另一种方法,因为您可以使用单个背景拼贴而不是整个屏幕大小的图像文件。
平铺单个图像
$EXAMPLES/ch10-piu/tiled-background示例使用图 10-6 中的图像创建如图 10-7 所示的平铺背景。

图 10-7
tiled-background举例

图 10-6
图片来自tiled-background示例
像图标的皮肤一样,平铺的皮肤使用一个texture对象并定义height和width属性来指定要绘制的纹理区域(在本例中是所有区域)。如清单 10-22 所示,您还包含了一个tiles属性——一个具有left、right、top和bottom属性的对象,指示纹理的不同部分要平铺;这里它们都是 0,因为这个例子使用整个图像作为重复的图块。(下一节,关于绘制 9 片图像,解释了如何将这四个属性与其他值一起使用。)
const tileTexture = new Texture({
path: "tile.png"
});
const tileSkin = new Skin({
texture: tileTexture,
height: 50, width: 50,
tiles: {
left: 0, right: 0, top: 0, bottom: 0
}
});
Listing 10-22.
当你将tileSkin附加到一个全屏content对象上时,它会绘制如图 10-7 所示:
const background = new Content(null, {
left: 0, right: 0, top: 0, bottom: 0,
skin: tileSkin
});
绘制 9-用拼贴修补图像
9 片图像用于有效地绘制不同大小的矩形,例如圆角矩形。术语“9 补丁”来自 Android 移动操作系统,尽管这个概念在其他地方也广泛使用;它指的是将图像素材分成九个部分的方式,稍后您会看到这一点。许多有趣的效果可以用 9 片图像来创建。Piu 通过使用平铺的表皮融入了这一概念。
回想一下前面的内容,一个tiles对象的属性表示纹理的不同部分需要平铺。更具体地说,这些属性通过指定距离图像边缘的像素数来定义 9 片图像的各个部分,如图 10-8 所示,其中一个tiles对象的属性都指定为 14。图中的浅灰色线条描绘了九个部分,并为每个部分分配了一个编号。整个图像是 56 像素的正方形。

图 10-8
用九个部分描绘的圆角矩形
该图像的平铺皮肤将如清单 10-23 中所定义。
const tileSkin = new Skin({
texture: tileTexture,
height: 56, width: 56,
tiles: {
left: 14, right: 14, top: 14, bottom: 14
}
});
Listing 10-23.
当此皮肤应用于内容对象时,Piu 使用以下规则绘制图像的九个部分:
-
区域 1、3、7 和 9 分别在内容对象的相应角落绘制一次。
-
区域 2 和 8 分别在内容对象的顶部和底部水平重复。
-
区域 4 和 6 分别沿着内容对象的左侧和右侧垂直重复。
-
区域 5 垂直和水平重复,以填充内容对象中间未被其他区块覆盖的空间。
图 10-9 显示了这个平铺的皮肤是如何被具有以下尺寸(从左到右)的内容对象渲染的:28 x 28、56 x 56、110 x 100 和 70 x 165。请注意,图像的九个部分只是重复,从不调整大小。

图 10-9
tileSkin以不同的尺寸渲染
$EXAMPLES/ch10-piu/rounded-buttons示例使用一个简单的纯色圆角矩形来创建不同大小的按钮(图 10-10 )。

图 10-10
rounded-buttons举例
本例中的皮肤定义如清单 10-24 所示。它看起来类似于前面的皮肤示例,但是图像素材更小,并且tiles对象的left、right、top和bottom属性都被设置为 5。它还指定了一个color属性;平铺的皮肤可以使用遮罩。
const roundedTexture = new Texture({
path: "button.png"
});
const roundedSkin = new Skin({
texture: roundedTexture,
width: 30, height: 30,
color: ["#ff9900", "#ffd699"],
tiles: {
top: 5, bottom: 5, left: 5, right: 5
}
});
Listing 10-24.
这个例子中的三个按钮是label和text对象(列表 10-25 )。它们有不同的高度和宽度,但是roundedSkin如前所述平铺其纹理以适应所有不同的尺寸。
const button1 = new Label(null, {
top: 10, left: 10,
skin: roundedSkin,
style: smallTextStyle,
string: "Option 1",
active: true,
Behavior: ButtonBehavior
});
const button2 = new Label(null, {
top: 60, left: 10,
skin: roundedSkin,
style: textStyle,
string: "Option 2",
active: true,
Behavior: ButtonBehavior
});
const button3 = new Text(null, {
top: 120, left: 10, width: 90,
skin: roundedSkin,
style: textStyle,
string: "Option 3",
active: true,
Behavior: ButtonBehavior
});
Listing 10-25.
回想一下,label对象在单行上呈现文本;这个例子为第三个按钮使用了一个text对象来说明text对象,不同于label对象,可以在多行上呈现文本。
这个例子中的行为ButtonBehavior与mask-icon例子中的SettingsIconBehavior是相同的,当点击按钮时onTouchBegan和onTouchEnded方法提供反馈。
构建复合用户界面元素
真实产品的用户界面由更复杂的元素组成,而不仅仅是屏幕中间的一串文本或一个图标。本章最初的例子使用一个简单的结构来介绍基本的 Piu 概念;现在,您已经准备好将这些元素放在一起构建更复杂的界面了。
向application对象添加内容对象创建了一个名为包容层次的树形数据结构。到目前为止,简单的例子已经创建了一个两层的容器层次结构,其中application对象位于根,内容对象作为叶,但是层次结构中可以有许多层。
容器层次结构通过将内容对象放入称为容器的组中来组织用户界面中的内容对象。容器由Container类实现,这是一个关键的内置 Piu 类。application对象本身是一个容器,这就是它能够保存其他内容对象的方式。包容层次结构不仅仅是将内容对象组合在一起;它还会影响对象的绘制方式以及它们接收事件的方式。
如果您曾经用 HTML 或其他面向对象的用户界面框架构建过用户界面,那么对包容层次结构的概念应该很熟悉。如果没有,下一节中的例子将引导您完成构建包容层次结构的步骤。
创建标题
像本章前面的例子一样,$EXAMPLES/ch10-piu/header例子向屏幕添加了文本和图标。但是它没有像那些例子中那样把它们当作独立的元素,而是把它们组合成一个复合用户界面元素,如图 10-11 所示的标题。

图 10-11
header举例
在header示例中的jsLogo和headerText对象(列表 10-26 )类似于前面示例中的content和label对象。
const jsLogo = new Content(null, {
left: 10,
skin: jsLogoSkin
});
const headerText = new Label(null, {
style: textStyle,
string: "Example"
});
Listing 10-26.
header对象(清单 10-27 )是Container类的一个实例。Container类继承了Content类,并扩展了它持有其他内容对象的能力。
const header = new Container(null, {
top: 0, height: 50, left: 0, right: 0,
skin: headerSkin,
contents: [
jsLogo,
headerText
]
});
Listing 10-27.
header对象包含jsLogo和headerText对象,它们被放在contents属性数组中。skin属性给header对象一个蓝色背景(因为headerSkin有一个fill属性"#1932ab")。
因为jsLogo和headerText对象包含在header对象中,当header对象被添加到application对象中时,所有的元素——蓝色背景、图标和文本——都会出现在屏幕上:
application.add(header);
同样,移除header对象会使所有元素消失,在屏幕上移动header对象会同时移动它包含的所有元素。
当一个内容对象被添加到一个容器中时,该内容对象被称为该容器的一个子对象,或者简称为子对象;相应地,容器被称为内容对象的父对象,或者简称为父对象。在本例中,标题是父容器,文本和图标是标题的子对象。
您可以使用对象的container属性来访问其父容器对象,并使用length属性来确定容器中子对象的数量。如果一个对象没有父容器,它的container属性就是null。如果没有子对象,length属性为 0。本章后面的“访问容器中的内容对象”一节将介绍几种不同的访问容器中的对象的方法。
相对和绝对坐标
如您所知,传递给内容对象的构造函数的left、right、top和bottom属性通过指定对象与其容器之间的边距来定义内容对象的位置。因为这些属性表达了点相对于父容器的位置,所以它们被称为相对坐标。比如,当你用值 10 传递left时,并不一定意味着内容对象在绘制时会离屏幕左侧 10 个像素;这意味着无论内容放在哪个容器中,它的左边都是 10 个像素。
一旦内容对象被绘制在屏幕上,它的坐标就被称为绝对坐标,它将点的位置表示为离屏幕边缘的距离。当容器是整个屏幕时,这通常是application对象的情况,容器子对象的相对和绝对坐标是相同的。
当容器移动时,Piu 会调整容器的所有子内容对象的绝对坐标。这使得制作复合用户界面元素的动画更加容易,比如header例子中的标题,因为你的代码只需要移动复合元素的容器,而不是每个单独的内容元素。
添加和删除容器内容
容器的内容不是固定的。正如您可以从application对象中添加和删除对象一样,您可以随时从container对象中添加和删除对象。Container类和所有从它继承的类都有add和remove方法,您可以用它们来修改它们的contents数组。Application类是一个继承自Container类的公共类。
您可以随时调用容器的add方法,不管该容器是否是容器层次结构的一部分。例如,不是在创建header对象时将一个contents数组传递给构造函数(如前面的清单 10-27 所示),而是在实例化所有对象之后,在将标题添加到application对象之前,将每个内容对象添加到标题中(参见清单 10-28 )。
const header = new Container(null, {
top: 0, height: 50, left: 0, right: 0,
skin: headerSkin
});
header.add(jsLogo);
header.add(headerText);
application.add(header);
Listing 10-28.
无论哪种方式,结果都是一样的:jsLogo和headerText被header包含,header被application对象包含。这就创建了一个三层的包含层次结构,其中application对象是根,header是分支,jsLogo和headerText是叶子。
下面是如何使用remove方法从标题的子列表中取出jsLogo:
header.remove(jsLogo);
方法从容器中移除所有子元素。这在您需要重新构建复合节点的内容时非常有用,比如移动到另一个屏幕时(您将在后面的“应用程序逻辑”一节中看到)。
header.empty();
每个内容对象一个容器
一个内容对象在任何时候只能是一个容器的子对象。您可以在容器中添加和移除对象,次数不限,也可以通过从当前容器中移除对象并将其添加到新容器中,将对象移动到新容器中。但是,您不能同时将相同的内容对象添加到多个容器中。如果您试图将一个容器中已经存在的内容对象添加到另一个容器中,Piu 会抛出一个错误。
这可能看起来很奇怪。您可能认为将同一个对象添加到多个容器中只会创建进入不同容器的相同对象,但事实并非如此。屏幕上显示的每个图形元素都与单个内容对象相关联。
如果这看起来仍然很奇怪,一个真实世界的隐喻可能会帮助你理解它。假设你有两个盒子和一个实物,比如一支笔。你可以把笔放在任何一个盒子里,但不能同时放在两个盒子里。同样的规则也适用于 Piu 中的内容对象和容器。
当然,您总是可以创建相同的内容,并将它们放在不同的容器中。在本章的后面,你将学习一种简单的方法来创建相似或相同的内容,使用模板。
构建响应式布局
图 10-12 所示的屏幕显示了一个由三个按钮组成的导航栏,每个按钮都有一个图标和文本来标识其用途。如果被要求描述这些按钮的位置,大多数人会说,“在屏幕中间有一排均匀间隔的按钮。”很少有人会说,“有一个按钮距离屏幕左侧 20 像素,距离屏幕顶部 74 像素,一个距离屏幕左侧 120 像素,距离屏幕顶部 74 像素,另一个距离屏幕左侧 220 像素,距离屏幕顶部 74 像素。”换句话说,人们最可能描述的是布局规则,而不是每个按钮的坐标。

图 10-12
居中导航栏中的一行按钮
布局规则是描述如何在容器中排列内容对象的简洁方式。布局规则可以独立于当前的容器大小,根据当前的大小进行调整。例如,无论容器(屏幕)的宽度是 320 还是 480 像素,这里显示的布局都可以均匀地分隔按钮。智能调整其父容器大小的布局规则被称为响应布局。
如果你有网页设计或编写移动应用的背景,你可能对响应式布局的概念很熟悉。无论浏览器窗口或屏幕的大小如何,好的网页都设计得很好;换句话说,它们对大小的差异做出反应。许多移动应用基于屏幕的方向旋转;也就是说,它们对方向的变化做出反应。
Piu 还具有使您能够创建响应性布局的功能。这些功能通常是有用的,如下面几个例子所示,即使产品的所有型号的屏幕尺寸都相同。
行和列布局
$EXAMPLES/ch10-piu/nav-bar示例显示如图 10-12 所示的导航栏。一个column对象将三个复合按钮元素的图标和标签组合在一起。清单 10-29 显示了最左边按钮的代码。其他两个按钮的代码遵循相同的模式,但每个按钮有不同的皮肤和标签。(为了保持示例简单,每个按钮的行为都被省略了。)
const settingsButton = new Column(null, {
skin: outlineSkin, width: 80,
contents: [
Content(null, {
top: 5,
skin: settingsSkin
}),
Label(null, {
top: 0,
style: textStyle,
string: "Settings"
})
]
});
Listing 10-29.
Column类用一个布局规则扩展了Container类,以在垂直列中排列其内容。在本例中,content对象的上边距为 5,label对象的上边距为 0。如果你把它们放在一个容器里,它们会重叠;但是,因为它们在一个column对象中,content对象的上边距相对于column对象,而label对象的上边距相对于content对象的下边距。如果添加另一个对象,其上边距将相对于label对象的底部,依此类推。
所有三个按钮的column对象都放在一个row对象中,如清单 10-30 所示。Row类是Container类的另一个子类。
const navBar = new Row(null, {
left: 0, right: 0,
contents: [
Content(null, {left: 0, right: 0}),
settingsButton,
Content(null, {left: 0, right: 0}),
weatherButton,
Content(null, {left: 0, right: 0}),
timeButton,
Content(null, {left: 0, right: 0})
]
});
Listing 10-30.
一个row对象水平排列它的内容,并且像一个column对象一样,相对于彼此。在一个row对象的contents数组中,第一项的左边距相对于行的左边,第二项的左边距相对于第一项,依此类推。
您可能想知道为什么清单 10-30 中有content个对象。以下是一些需要注意的重要事项:
-
它们没有皮肤,因此是透明的。它们表示该行中按钮周围的空白区域。
-
没有为它们指定宽度;相反,
row对象计算按钮周围的空白空间。 -
它们的
left和right边距(和三个按钮一样)都是 0;否则,边距会像row对象一样被计算在内,这通常不是你想要的。
为了进一步理解这一点,让我们先来看看如果从行中删除了content对象,只留下三个按钮,结果会是什么。因为这些按钮都有一个 80 的定义宽度,但是没有左右边距,所以将它们单独放置在navBar行会导致它们被一起推到屏幕左侧的 240 个像素中,如图 10-13 所示。

图 10-13
navBar不带content物体
如果你给每个按钮留出 20 的左边距,你会在 320 x 240 的屏幕上得到想要的布局,如图 10-12 所示。但是现在想象使用不同尺寸的屏幕——比如 480 x 320 图 10-14 显示了这种情况下的结果。

图 10-14
navBar不带content对象,但有页边距,屏幕更大
content对象使布局能够响应不同的屏幕尺寸。由于content对象没有宽度,因此row对象计算出它们的宽度,以实现所需的布局:它计算三个按钮各自占用的宽度——在本例中为 240——并且该行中剩余的可用像素均匀分布在剩余的内容中(导致第一个按钮之前、按钮之间以及最后一个按钮之后的空间量相同)。在图 10-12 的 320 x 240 屏幕上,这达到(320–240)/4,或者每个content对象 20 个像素;在一个 480 x 320 的屏幕上(图 10-15 ),每个像素为 60。

图 10-15
navBar在大屏幕上,适当居中
如果您希望本例中的按钮相距正好 20 像素,但仍在屏幕中央,您可以为两个中间的content对象指定宽度 20。然后,row对象将只计算第一个按钮之前和最后一个按钮之后的空间量。
如果你确定屏幕的大小不会改变或旋转,添加透明的content对象是没有必要的;您可以根据需要定义项目的左边距和右边距。不过,知道你是否在为多种屏幕尺寸设计是一个有用的技巧。
滚动内容
当内容太多而无法一次显示在屏幕上时,一种常见的解决方案是使用滚动来浏览内容。$EXAMPLES/ch10-piu/scrolling-text示例使用滚动来显示太大而不适合 320 x 240 屏幕的内容。图 10-16 显示了最初出现的屏幕。

图 10-16
scrolling-text举例
本例滚动标题、灰色条和样本文本,分别由label、content和text对象定义。这些对象放在一个column容器中,垂直放置。该列是一个scroller对象的contents数组中的第一项,如清单 10-31 所示。
const sampleVerticalScroller = new Scroller(null, {
left: 0, right: 0, top: 0, bottom: 0,
contents: [
Column(null, {
left: 0, right: 0, top: 0,
contents: [
sampleHeader,
grayBar,
sampleText
]
})
],
active: true,
Behavior: VerticalScrollerBehavior
});
Listing 10-31.
Scroller类用一个布局规则扩展了Container类,该规则滚动其contents数组中的第一项,同时让其他内容(本例中为 none)遵循默认的容器布局行为。Scroller类可以水平、垂直或两者滚动;本示例垂直滚动。一个scroller物体滚动的方式是由其行为决定的。
本例中的行为VerticalScrollerBehavior(清单 10-32 )使用触摸输入来控制滚动。当您触摸屏幕并上下拖动时,滚动条会上下移动内容。onTouchMoved事件是一个低级事件,当手指在屏幕上移动时触发。一个内容对象可以在一个onTouchBegan事件之后(和在onTouchEnded事件之前,如果有的话)接收许多onTouchMoved事件。
class VerticalScrollerBehavior extends Behavior {
onTouchBegan(scroller, id, x, y, ticks) {
this.initialScrollY = scroller.scroll.y;
this.initialY = y;
scroller.captureTouch(id, x, y, ticks);
}
onTouchMoved(scroller, id, x, y, ticks) {
const dy = y - this.initialY;
scroller.scrollTo(0, this.initialScrollY - dy);
}
}
Listing 10-32.
请注意以下关于此代码的内容:
-
onTouchBegan方法调用scroller对象的captureTouch方法,防止其他内容对象触发与触摸相关的触摸事件。这在这里是不必要的,因为没有其他活动的内容对象来接收触摸事件,但是包含它是因为它使行为更可重用。 -
onTouchMoved方法调用scroller对象的scrollTo方法,根据手指移动垂直滚动内容。最好使用scrollTo而不是改变内容的坐标;scrollTo防止内容移出屏幕,因此您不必编写额外的代码来避免这样做。 -
没有
onTouchEnded方法,因为触摸结束时没有反馈。
内容对象的模板
用户界面经常在许多地方使用相同的元素,有时会有小的变化。例如,一个应用程序的每个屏幕可能使用一个标题,标题中有相同的图标,但有不同的文本,或者导航条中的每个按钮可能有不同的图标和文本,如nav-bar示例所示。为了创建三个按钮中的每一个,nav-bar示例使用了基本相同的代码。Piu 模板是达到相同结果的更简洁有效的方法。模板是使用内容对象的template方法创建的类。像这样在运行时创建类的能力是 Piu 构建的 JavaScript 的一个强大特性。
创建按钮模板类
回想一下,nav-bar示例使用三个column对象创建按钮行,每个对象包含一个content对象(用于图标)和一个label对象。这些column对象的不同之处仅在于content对象的skin属性和label对象的string属性。按钮与不可见的content对象一起被放置在一个row对象中,以使布局响应多种屏幕尺寸。
编写几乎相同的代码来创建三个按钮中的每一个似乎并不不合理,但是想象一下您想要创建十个按钮:您将有超过一百行看起来相似的代码。如果你决定让每个按钮都变宽几个像素,那么单独改变每个width属性将会很乏味并且容易出错。
$EXAMPLES/ch10-piu/nav-bar-template示例为nav-bar按钮创建了一个Button类。它通过调用Column类的静态template方法来做到这一点,如清单 10-33 所示。
const Button = Column.template($ => ({
skin: outlineSkin,
width: 80,
contents: [
Content(null, {
top: 5,
skin: $.skin
}),
Label(null, {
top: 0,
style: textStyle,
string: $.string
})
]
}));
Listing 10-33.
这里调用的template方法为Button类创建并返回一个构造函数。这个新类扩展了Column,因为template方法是Column类的一部分。所有 Piu 内容对象都有一个静态的template方法。
即使Button类不是使用class关键字创建的,您仍然可以使用new关键字创建实例,就像在new Button中一样。在开始讨论如何使用Button类之前,让我们先看看这个类的实现。
Column.template的唯一参数是一个返回对象的函数。语法有点不寻常,因为 arrow 函数体是一个值,而不是一系列语句;该值成为函数的返回值。为了用一个简单的例子来说明这一点,下面的代码定义了一个名为test的箭头函数:
let test = () => ({one: 1});
test(); // returns {one: 1}
当调用 arrow 函数时,它返回对象。现在考虑这个例子,它定义了一个接受单个参数的版本test:
let test = $ => ({one: $});
test(1); // returns {one: 1}
arrow 函数的参数被分配给一个名为$的变量。尽管$是一个不常见的变量名,但它是有效的 JavaScript,并且$变量的行为和其他变量一样。(注意这与用于字符串替换的模板文字中使用的$无关,如第二章所述。)
类似地,在清单 10-33 所示的Button类实现中,Column.template的参数是一个匿名箭头函数,它返回一个对象,该对象的一些属性值取自传入的$变量。当您调用Button构造函数时,如下面代码中的settingsButton所示,您传递一个字典,该字典包含要在使用$变量的模板中替换的属性,这里替换$.skin和$.string;构造函数调用在它的实现中指定的 arrow 函数,传递这里显示的字典作为$参数:
const settingsButton = new Button({
skin: settingsSkin,
string: "Settings"
});
使用Button模板,每个额外的按钮都是通过简洁调用Button构造函数创建的,如清单 10-34 所示。(创建导航栏的其余代码与nav-bar示例中的相同。)
const weatherButton = new Button({
skin: sunSkin,
string: "Weather"
});
const timeButton = new Button({
skin: clockSkin,
string: "Time"
});
Listing 10-34.
如您所见,定义模板类有以下优点:
-
它通过消除定义每个按钮的冗余代码,显著提高了代码的可读性(这也节省了闪存)。
-
这使得你的代码更容易维护。要更改一个公共属性,比如每列的宽度,您所要做的就是更改模板的该属性。
内容构造函数参数
您可能已经注意到,nav-bar-template示例中的Button构造函数调用看起来与前面示例中的内容对象构造函数不同:对Button构造函数的调用将字典作为第一个参数而不是null传递,并且它们省略了第二个参数(如果存在的话,就是配置对象的字典)。通过更仔细地观察这两个参数(每个 Piu 内容构造器都采用这两个参数),本节将解释这些差异。
实例化数据参数
内容构造器的第一个参数叫做实例化数据的。这一概念在使用模板时最为相关。例如,前面创建的Button模板类使用作为第一个参数传递的数据来创建字典,从字典中实例化该类(换句话说,字典通常作为第二个参数传递)。
*实例化数据可以是任何 JavaScript 值或对象。您实例化的类决定了什么数据是有效的。例如,清单 10-33 中定义的Button类模板期望实例化数据是具有skin和string属性的对象。清单 10-35 显示了Button类的另一个实现。
const Button = Column.template($ => ({
skin: outlineSkin,
width: 80,
contents: [
Label(null, {
top: 0,
style: textStyle,
string: $
})
]
}));
Listing 10-35.
这个Button类没有图标,并期望一个字符串作为$参数传递,如下例所示:
const weatherButton = new Button("Weather");
const timeButton = new Button("Time");
实例化数据还有另一个有趣的属性:它被传递给所创建实例的行为的onCreate方法。例如,清单 10-36 展示了从清单 10-35 实现Button类的另一种方式。
const Button = Column.template($ => ({
skin: outlineSkin,
width: 80,
contents: [
Label(null, {
top: 0,
style: textStyle
})
],
Behavior: class extends Behavior {
onCreate(column, $) {
column.first.string = $;
}
}
}));
Listing 10-36.
实例化数据的功能不限于模板;任何内容对象构造器都可以使用它。例如,清单 10-37 创建了一个带有字符串"Hello, World"的标签。
const sampleLabel = new Label("Hello, World", {
top: 0, bottom: 0, left: 0, right: 0,
style: textStyle,
Behavior: class extends Behavior {
onCreate(label, data) {
label.string = data;
}
}
});
Listing 10-37.
在本章的后面,您将学习实例化数据参数的高级用法:定义内容锚。
内容字典参数
内容构造函数的第二个参数是一个字典,它定义了创建的实例的属性。您在这个内容字典中包含的属性与您正在实例化的内容类的内置属性相关联,例如,实例的皮肤或宽度。除了前面展示的Button模板例子,本章所有例子都定义了内容字典参数;然而,它是可选的,默认为undefined。
清单 10-37 展示了使用实例化数据参数和内容字典参数来创建一个label对象。$EXAMPLES/ch10-piu/colored-squares示例演示了如何在调用模板构造函数时使用这两个参数来创建如图 10-17 所示的彩色方块。

图 10-17
colored-squares举例
清单 10-38 显示了创建模板和构建方块的代码。
const Square = Content.template($ => ({
width: 80, height: 80,
skin: new Skin({fill: $})
}));
const redSquare = new Square("red", {left: 20, top: 20});
const yellowSquare = new Square("yellow");
const blueSquare = new Square("blue", {right: 20, bottom: 20});
Listing 10-38.
在此示例中,实例化数据是定义正方形填充颜色的字符串。红色和蓝色方块的位置由第二个字典参数定义,而黄色方块省略了第二个参数,因此默认位于其父容器的中心。
访问容器中的内容对象
在目前为止看到的例子中,您已经通过局部变量访问了内容对象,但是您还没有看到当您在局部变量中没有对内容对象的引用时,如何访问内容对象。在许多情况下,您可能需要直接从容器层次结构中访问对象,例如在处理使用模板创建的复合对象时。
您已经了解到一个容器对象包含一个子对象列表,其数量可以从容器的length属性中获得。以下部分介绍了访问容器层次结构中的内容对象的几种方法。
使用first、last、next和previous
您可以使用容器的first属性检索它的第一个子容器,使用last属性检索它的最后一个子容器。如果一个容器没有子对象,first和last就是null。
每个内容对象都有一个next属性,您可以使用它来访问其容器中的下一个内容对象,如果没有属性,则为null。同样,previous属性返回前面的内容对象(或null)。
使用这些属性是访问容器中内容的简单方法。它们适用于某些情况,但不是所有情况。例如,使用first和next访问一个名为myContainer的容器的第四个子容器的代码很难阅读,编写起来也很繁琐。
let button = myContainer.first.next.next.next;
下一节将介绍针对这些情况的更好的解决方案。
通过索引和名称访问子项
content方法通过索引提供对容器子对象的访问。索引值从 0 开始,因此您可以访问名为myContainer的容器中的第三个子节点,如下所示:
myContainer.content(2);
像first、last、next和previous属性一样,这种访问子对象的方法很简单,但是当容器中内容的顺序改变时,需要修改代码。或者,您可以使用content方法通过名称访问子对象。您在传递给构造函数的字典中为内容对象定义了一个name属性。
let myContent = new Content(null, {
name: "foo"
});
如果myContent是myContainer的子节点,您可以如下访问它:
let foo = myContainer.content("foo");
这种方法适用于许多容器层次结构,但是请注意,内容对象必须是容器的直接子对象才能工作。您不能使用content方法来访问容器的孙辈、曾孙辈等等。
使用锚点访问内容
锚是对作为属性保存在内容对象的实例化数据中的内容对象的引用。锚是访问复杂接口中的内容的最佳方法,这些复杂接口在其包含层次中有许多级别;然而,它们是最难理解的。试图从概念上解释锚往往是令人困惑而非有益的,所以让我们通过一个例子来看看它们。
这个例子演示了使用锚点创建一个动画用户界面的基本方法。当你点击开始按钮时,背景和一个彩色方块在两种不同的颜色之间闪烁。图 10-18 显示了点击开始按钮时屏幕切换的两种状态。

图 10-18
anchors举例
该界面由三个内容对象组成:
-
开始按钮(一个
StartButton类的实例) -
一个彩色正方形(
AnimatedSquare类的一个实例) -
用颜色填充背景的背景对象(
MainContainer类的一个实例),如清单 10-39 所示,包含开始按钮和彩色方块
const MainContainer = Container.template($ => ({
...
contents: [
new StartButton($),
new AnimatedSquare($)
],
...
}));
Listing 10-39.
注意,所有三个对象都通过$变量传递了相同的实例化数据。在这个例子中,实例化数据从一个空字典开始。
let instantiatingData = {};
application.add(new MainContainer(instantiatingData));
当内部时钟运行时,即当每个对象的start方法被调用并且对象开始接收onTimeChanged事件时,彩色正方形和背景对象具有每秒改变其填充颜色两次的行为。点击开始按钮负责调用这些对象的start方法;彩色方块和背景对象创建锚点,以便开始按钮可以引用它们来完成此操作。
要为内容对象创建锚点,需要在传递给构造函数的字典中指定一个anchor属性。MainContainer模板将anchor属性设置为字符串"BACKGROUND",如清单 10-40 所示。
const MainContainer = Container.template($ => ({
...
anchor: "BACKGROUND",
...
}));
Listing 10-40.
同样,AnimatedSquare模板将anchor属性设置为字符串"SQUARE"(列表 10-41 )。
const AnimatedSquare = Content.template($ => ({
...
anchor: "SQUARE",
...
}));
Listing 10-41.
当实例化带有anchor属性的内容对象时,Piu 将实例分配给实例化数据中带有锚点名称的属性。回想一下instantiatingData一开始是一个空字典;如果使用锚,实例化数据必须是一个字典,这样锚就可以添加到其中。在彩色方块和背景对象被实例化后,instantiatingData看起来是这样的:
{
BACKGROUND: <reference to the background object>,
SQUARE: <reference to the colored square object>
}
instantiatingData的BACKGROUND和SQUARE属性是背景和彩色正方形对象的锚点。任何可以访问instantiatingData的东西都可以使用这些锚来引用这些对象。在这个例子中, Start 按钮使用锚点来触发背景和方块动画的开始。使用锚点和触发动画的代码都包含在StartButton模板的行为中。
如您所知,传递给内容对象的构造函数的实例化数据被传递给所创建内容的行为的onCreate方法。StartButtonBehavior在data属性中保存对实例化数据的引用,以便它可以在其他方法中使用。
class StartButtonBehavior extends Behavior {
onCreate(label, data) {
this.data = data;
}
然后,StartButtonBehavior在它的onTouchEnded方法(清单 10-42 )中使用它的data属性来访问背景和彩色方块的锚点,这样它就可以调用它们的start方法,这又会导致动画开始。
onTouchEnded(label) {
...
this.data.SQUARE.start();
this.data.BACKGROUND.start();
}
Listing 10-42.
请注意,当您使用锚点时,容器层次结构中内容对象的级别并不重要。在这个例子中,开始按钮和彩色方块都是背景对象的子对象,但是您可以重新安排容器层次结构——例如,您可以使彩色方块成为application对象的子对象——而不必改变StartButtonBehavior的实现来触发动画。这种灵活性使得锚点在创建可能改变的包容层次结构时非常有用。
定义和触发您自己的事件
您已经看到了几个使用behavior对象响应由 Piu 定义和触发的低级事件的例子。您的应用程序可能需要其他事件,即 Piu 未定义的高级事件;例如,当传感器检测到变化时,连接有传感器的产品可以触发onSensorValueChanged事件,以便应用程序可以更新显示或将变化报告给网络服务。要处理高级别事件,您可以像处理低级别的事件一样,向您的行为添加方法。
通常几个内容对象需要响应一个事件。例如,当传感器值改变时,用户界面中的多个元素可能需要更新。一个对象的事件处理程序可以将事件(它接收的事件或它创建的其他事件)传播到整个容器层次结构中的其他对象。Piu 提供了用于传播事件的delegate、distribute和bubble方法。
本节展示了如何定义和触发您自己的事件。它还引入了将事件传播到容器层次结构中的一个或多个内容对象的方法。
触发内容对象上的事件
$EXAMPLES/ch10-piu/counter示例将一个计数器存储在一个label对象中,并启用另一个对象,在本例中是一个按钮,使用一个高级increment事件来递增计数器。图 10-19 显示了示例中的步骤,计数器从 0 开始,用户触摸按钮,最后当触摸结束时计数器增加到 1。

图 10-19
counter举例
如清单 10-43 所示,计数器是一个行为名为CounterBehavior的label对象。
const counter = new Label(null, {
top: 70, height: 30, left: 0, right: 0,
style: textStyle,
string: "0",
Behavior: CounterBehavior
});
Listing 10-43.
计数器存储在标签行为的count属性中(列表 10-44 ),并由CounterBehavior的onDisplaying事件处理程序初始化为 0。该行为还实现了一个increment事件处理程序,它递增label对象的计数器,并用新值更新其string属性。
class CounterBehavior extends Behavior {
onDisplaying(label) {
this.count = 0;
}
increment(label) {
label.string = ++this.count;
}
}
Listing 10-44.
incrementButton对象(清单 10-45 )也是一个label对象,其行为名为IncrementButtonBehavior。
const incrementButton = new Label(null, {
top: 120, height: 40, left: 140, width: 40,
style: textStyle,
string: "+",
skin: buttonSkin,
active: true,
Behavior: IncrementButtonBehavior
});
Listing 10-45.
当点击按钮时,IncrementButtonBehavior(清单 10-46 )通过在onTouchBegan和onTouchEnded方法中改变按钮的state属性来提供反馈。onTouchEnded方法也将increment事件委托给counter对象。content 对象的delegate方法会立即触发该方法第一个参数中指定的事件。这里的increment事件是在counter对象上触发的。
class IncrementButtonBehavior extends Behavior {
onTouchBegan(label) {
label.state = 1;
}
onTouchEnded(label) {
label.state = 0;
counter.delegate("increment");
}
}
Listing 10-46.
您可以将附加参数传递给事件处理程序,方法是将它们传递给事件名称后面的delegate方法;例如,onSensorValueChanged事件可以接收新的传感器读数作为事件的一部分。要将counter示例更改为递增任意数字,您可以更改清单 10-44 中的increment方法,以接受一个额外的参数来指定递增的数量,如清单 10-47 所示。
class CounterBehavior extends Behavior {
...
increment(label, delta) {
this.count += delta;
label.string = this.count;
}
}
Listing 10-47.
然后在onTouchEnded方法中将一个数字传递给delegate方法。例如:
counter.delegate("increment", 1); // increments by 1
counter.delegate("increment", 5); // increments by 5
在容器内分发事件
$EXAMPLES/ch10-piu/color-scheme示例提供了在亮暗模式之间改变应用程序外观的按钮。当用户点击亮或暗按钮时,按钮触发应用程序容器内所有对象的事件。对象通过将其颜色更新为所请求的模式来做出响应。图 10-20 显示界面以亮模式启动,轻击暗按钮,界面处于暗模式。按钮上方显示的文本表示当前模式。

图 10-20
color-scheme举例
亮和暗按钮触发名为onModeChanged的事件。每个按钮都是一个ModeButton的实例,一个基于Label的模板,如清单 10-48 所示。
const ModeButton = Label.template($ => ({
top: 110, height: 40, width: 120,
skin: buttonSkin,
active: true,
Behavior: ModeButtonBehavior
}));
Listing 10-48.
ModeButtonBehavior(清单 10-49 )通过在onTouchBegan和onTouchEnded方法中改变按钮的state属性,在点击按钮时提供反馈。通过调用application对象的distribute方法,onTouchEnded方法还在整个application容器中分发事件。distribute方法触发容器中每个内容对象的事件。在对application.distribute的调用中,ModeButtonBehavior传递按钮的名称,在本例中是"Light"或"Dark",作为指示要更改到的模式的参数。
class ModeButtonBehavior extends Behavior {
onTouchBegan(label) {
label.state = 1;
}
onTouchEnded(label) {
label.state = 0;
application.distribute("onModeChanged", label.string);
}
}
Listing 10-49.
所有容器对象都有一个distribute方法,它触发容器和容器层次结构中所有向下的内容对象上的指定事件。当事件被传递给容器中的所有对象时,或者当其中一个事件处理程序返回true指示事件已经被完全处理时,事件的分发结束。您可以将distribute方法视为一种向容器内容广播事件的方式。在这个例子中,用行为中的onModeChanged处理程序直接调用几个内容对象上的delegate会很容易;然而,随着应用程序变得越来越复杂,使用distribute方法自动遍历容器中的所有内容变得更加容易。
既然您已经知道了distribute如何触发容器内容上的事件,让我们看看内容对象如何响应这些事件。state属性起着关键的作用。保存按钮和文本字符串的LightDarkScreen容器有一个皮肤,当其state属性为 0 时是白色的,当其state属性为 1 时是黑色的。
const backgroundSkin = new Skin({
fill: ["white", "black"]
});
文本字符串是一个label对象,它的样式正好相反,当它的state属性为 0 时文本是黑色的,当它的state属性为 1 时文本是白色的。(参见清单 10-50 。)
const textStyle = new Style({
font: "24px Open Sans",
color: ["black", "white"],
top: 10, bottom: 10, left: 10, right: 10
});
Listing 10-50.
LightDarkScreen的代码如清单 10-51 所示。
const LightDarkScreen = new Container(null, {
top: 0, bottom: 0, left: 0, right: 0,
skin: backgroundSkin,
style: textStyle,
contents: [
Label(null, {
top: 50, height: 30, left: 0, right: 0,
string: "Light",
Behavior: TextBehavior
}),
ModeButton(null, {
left: 30,
string: "Dark"
}),
ModeButton(null, {
right: 30,
string: "Light"
})
],
Behavior: LightDarkScreenBehavior
});
Listing 10-51.
当收到一个onModeChanged事件时,LightDarkScreen和它包含的label对象都有改变它们的state属性的行为。标签改变它的string属性来反映哪个按钮被点击了。清单 10-52 展示了这些行为。
class LightDarkScreenBehavior extends Behavior {
onModeChanged(container, mode) {
container.state = (mode === "Dark")? 1 : 0;
}
}
class TextBehavior extends Behavior {
onModeChanged(label, mode) {
label.state = (mode === "Dark")? 1 : 0; label.string = mode;
}
}
Listing 10-52.
使事件在容器层次结构中冒泡
$EXAMPLES/ch10-piu/background-color示例提供了改变屏幕背景颜色的按钮。当用户点击按钮时,它们触发容器层次结构中向上的事件。按钮的父容器横跨整个屏幕,并更新其skin属性以响应事件。图 10-21 显示背景为初始白色状态,轻点按钮为黄色,变为黄色后的背景。

图 10-21
background-color举例
如清单 10-53 所示,按钮是用一个模板创建的,该模板创建一个带有名为ColorButtonBehavior的行为的label对象。
const ColorButton = Label.template($ => ({
height: 40, left: 10, right: 10,
skin: buttonSkin,
active: true,
Behavior: ColorButtonBehavior
}));
Listing 10-53.
ColorButtonBehavior(清单 10-54 )通过在onTouchBegan和onTouchEnded方法中改变按钮的state属性,在点击按钮时提供反馈。onTouchEnded方法还通过调用bubble方法并将其作为事件处理程序的参数传递给按钮的string属性— "Yellow"、"Red"或"Blue"—使onColorSelected事件在容器层次结构中冒泡。
class ColorButtonBehavior extends Behavior {
onTouchBegan(label) {
label.state = 1;
}
onTouchEnded(label) {
label.state = 0;
label.bubble("onColorSelected", label.string);
}
}
Listing 10-54.
所有内容对象都有一个bubble方法,这使得它们、它们的父容器以及容器层次结构中所有向上的容器对象触发一个指定的事件。当事件已经被传递到所有对象直到application对象时,或者当一个事件处理程序返回true指示事件已经被完全处理时,事件的传播结束。与delegate和distribute方法一样,事件由名称指定,并作为第一个参数传递给bubble方法。
既然您已经知道了如何使用bubble方法来触发事件,那么在探究onColorSelected事件如何在这个特定的容器层次结构中传播的细节之前,让我们来看看这个例子的容器层次结构是如何组织的。
按钮包含在一个row对象中。这一行是名为colorScreen的container对象的一部分,它被添加到application对象中。如清单 10-55 所示,该行没有与之关联的行为,但是colorScreen引用了一个名为ColorScreenBehavior的行为。
const colorScreen = new Container(null, {
top: 0, bottom: 0, left: 0, right: 0,
skin: whiteSkin,
style: textStyle,
contents: [
Row(null, {
height: 50, width: 320,
contents: [
new ColorButton(null, {string: "Red"}),
new ColorButton(null, {string: "Yellow"}),
new ColorButton(null, {string: "Blue"})
]
})
],
Behavior: ColorScreenBehavior
});
application.add(colorScreen);
Listing 10-55.
ColorScreenBehavior接收到onColorSelected事件时改变背景颜色;如清单 10-56 所示,新的颜色作为一个参数被传递。每个按钮字符串的第一个字母都是大写的("Red"),但是 CSS 颜色都是小写的,所以事件处理程序使用toLowerCase将字符串转换成全部小写的字母。
class ColorScreenBehavior extends Behavior {
onColorSelected(container, color) {
container.skin = new Skin({ fill: color.toLowerCase() });
}
}
Listing 10-56.
点击其中一个按钮会发生以下情况:
-
onColorSelected事件首先在按钮本身上触发。按钮的行为没有对应的onColorSelected方法,所以事件会上升到它的父容器。 -
按钮的父容器是
row对象。这个对象没有行为,因此也没有onColorSelected方法,所以事件转移到行的父容器。 -
该行的父容器是
colorScreen容器。这个容器的行为有一个onColorSelected方法,所以当行为触发onColorSelected事件时,这个方法被调用。然后,事件转移到该容器的父容器。 -
colorScreen容器的父容器是application对象。这个对象没有onColorSelected方法,并且是容器层次结构的根,所以遍历是完整的。
和其他传播事件的例子一样,简单地将事件委托给行为中有相应的onColorSelected方法的所有内容是很容易的。但是在容器层次结构中有许多级别的应用程序可以使用 content objects 的bubble方法来简化传播事件的代码,并在容器层次结构改变时最小化所需的代码更改。
动画
将动画整合到用户界面中可以显著改善用户体验。动画用于有意义的功能目的,例如当用户点击按钮时提供反馈。它们也用于美学目的,给产品一种特殊的感觉——例如,在屏幕之间移动时创建动画过渡。
放松方程式
线性修改内容对象属性的动画通常看起来不自然。缓动方程式是实现感觉更自然的动画或添加视觉样式的常用工具。
Piu 用 Robert Penner 著名的放松方程扩展了 JavaScript Math对象。Piu 中这些函数的名称是不言自明的——例如,bounceEaseInOut在动画开始和结束时创建一个反弹效果。有关彭纳方程的详细信息,请访问 robertpenner.com/easing/ 。
这些缓动方程的 Piu 实现都采用单个参数,即范围[0,1]内的一个数字,并返回应用了缓动函数的范围[0,1]内的一个数字。这些方程式在所有类型的动画中广泛使用。输入值是已经完成的动画的分数;缓动函数将分数调整为另一个值,然后用于计算动画中值的状态。在接下来的章节中,您将会看到这样的例子。
一些缓动方程创建了一个微妙的效果,使动画感觉更自然。例如,四重缓动功能— Math.quadEaseIn、Math.quadEaseOut和Math.quadEaseInOut—在整个动画持续时间内稍微改变速度,以使动画的开始和/或结束不那么突然。其他人创造了一个大胆的效果。例如,反弹缓动功能— Math.bounceEaseIn、Math.bounceEaseOut和Math.bounceEaseInOut—使对象在动画开始和/或结束时反弹。
当然,您并不局限于默认包含的缓解功能;您可以轻松添加自己的缓动方程,以满足您产品的需求。创建你自己的放松方程的细节超出了本书的范围,但是如果你认为这对你的产品是必要的,网上有大量的信息。
动画内容对象
helloworld-ticking示例展示了如何使用内容对象的内置时钟来创建一个简单的动画。创建更复杂的动画,尤其是那些同时独立移动几个界面元素的动画,是很困难的。
这个例子演示了如何在屏幕上创建一个包含多个对象的动画序列。本例中的动画很简单,但是理解代码将为您创建自己的更复杂的动画打下基础。图 10-22 显示了动画中几个点的用户界面。

图 10-22
timeline举例
示例中的接口由一个名为animatedContainer(列表 10-57 )的container对象组成,其中包含一个label对象和一个content对象。
const animatedContainer = new Container(null, {
top: 0, bottom: 0, left: 0, right: 0, skin: whiteSkin,
contents: [
new Label(null, {
style: textStyle, top: 80, left: 0, right: 0, string: "Hello, World"
}),
new Content(null, {
top: 115, height: 3, left: 0, width: 320, skin: colorfulSkin
})
],
Behavior: TimelineBehavior
});
Listing 10-57.
动画由TimelineBehavior驱动,即animatedContainer的行为。TimelineBehavior在其onDisplaying事件处理程序中实例化一个timeline对象。Piu 提供了Timeline类来简化和构建实现动画的代码。这个类既可以用于动画单个屏幕中的元素,也可以用于动画屏幕之间的过渡。使用Timeline类通常是组织和实现多个内容对象动画的最佳方式;例如,它很容易处理每个内容对象开始动画的时间交错的情况。Piu Timeline类的 API 基于 GreenSock 的 timeline lite API,这是一个流行的 JavaScript 库,用于制作网页动画。
onDisplaying事件处理程序还初始化reverse属性,该属性用于使时间轴动画向前和向后运行。清单 10-58 显示了相关代码。
class TimelineBehavior extends Behavior {
onDisplaying(container) {
let timeline = this.timeline = new Timeline();
this.reverse = false;
...
Listing 10-58.
一个timeline对象由一组补间组成,每个补间描述一个内容对象的一个或多个属性如何从初始值和结束值变化。补间通过时间轴的from和to方法添加到时间轴中,这两个方法基于以下参数定义补间:
-
target–要制作动画的内容对象 -
properties–一个字典,其关键字是要制作动画的目标对象的属性 -
duration–补间的持续时间,以毫秒为单位 -
easing–(可选)用于补间的缓动函数 -
delay–(可选)时间轴中上一个补间完成后,此补间应开始的毫秒数;默认为 0
由时间轴的from方法添加的补间(称为 from-tween— )在duration毫秒内将target对象的属性从properties对象中指定的值缓和为target对象的原始值。清单 10-58 中的onDisplaying方法继续添加以下表格。在这个例子中,label对象从屏幕顶部的y位置移动到距离屏幕顶部 80 像素的原始位置。同时,它的状态从状态 1 变为状态 0,使它从白色变为黑色。注意,这里的标签被访问为container.first,因为它是添加到容器中的第一个内容对象。补间的持续时间为 750 毫秒,并使用quadEaseOut缓动功能。
timeline.from(container.first, {
y: -container.first.height,
state: 1
}, 750, Math.quadEaseOut, 0);
如下面的代码所示,对时间轴的from方法的第二次调用将添加一个补间,以将颜色条从屏幕左边缘的x位置移动到距左边缘 0 像素的原始位置。对from的每次调用都会延长时间轴的动画时长,除非使用delay参数,否则对from的下一次调用所添加的补间动画会从时间轴的末尾开始。为了使两个补间同时运行,本示例将delay属性设置为–750 毫秒,这使得它与第一个补间同时开始。此补间不会更改时间轴的持续时间,因为它与第一个补间同时结束。
timeline.from(container.last, {
x: -320
}, 750, Math.linearEase, -750);
由时间轴的to方法添加的补间(称为 to-tween— )在duration毫秒内将target对象的属性从其当前值缓和到在properties对象中指定的目标值。onDisplaying方法继续添加补间动画,如下所示。在本例中,颜色条从其当前状态 0 变为状态 1。这里没有指定delay属性,所以它默认为 0,这使得这个补间在前一个补间完成后立即开始。
timeline.to(container.last, {
state: 1
}, 750, Math.linearEase, 0);
添加完所有补间后,时间轴就可以使用了,如下面的代码所示,这些代码是对onDisplaying方法的其余调用。时间轴有一个当前时间,在 0 和时间轴的持续时间之间,表示动画的进度,可以使用它的seekTo方法设置。像duration属性(和一个内容对象的时钟)一样,seekTo以毫秒表示时间。此示例通过使用seekTo将时间轴的当前时间设置为 0,将时间轴倒回到起点。然后,它使用内容对象的时钟(在本例中是容器的时钟)来驱动动画:在将容器的持续时间设置为与时间轴的持续时间相匹配后,它会倒带容器的时钟并开始计时。
timeline.seekTo(0);
container.duration = timeline.duration;
container.time = 0;
container.start();
TimelineBehavior包括两个额外的事件处理程序,onTimeChanged和onFinished(列表 10-59 ):
-
随着时钟滴答作响,
onTimeChanged被定期调用。因为时间轴的持续时间等于容器时钟的持续时间,onTimeChanged使用seekTo将时间轴与容器时钟的time属性同步。 -
当容器的时钟到达其持续时间时,触发
onFinished事件。这也意味着动画序列是完整的。在本例中,时间轴到达终点后会反向移动,并无限循环往复。
onTimeChanged(container) {
let time = container.time;
if (this.reverse) time = container.duration - time;
this.timeline.seekTo(time);
}
onFinished(container) {
this.reverse = !this.reverse;
this.timeline.seekTo(0);
container.time = 0;
container.start();
}
Listing 10-59.
动画过渡
Piu Transition类提供了另一种实现动画的方法。它最常用于在容器层次结构中用一个内容对象替换另一个内容对象——例如,在屏幕之间移动。本节重点介绍内置的擦拭和梳理过渡,它们是Transition类的子类。与修改内容对象的属性的时间线动画不同,擦拭和梳理过渡是在显示器的像素上操作的纯图形操作。因为它们经过优化,可以最大限度地减少每帧中绘制的像素数量,所以这些过渡可以实现高帧速率,即使在 ESP8266 微控制器上也是如此。你也可以通过子类化Transition类来创建你自己的过渡,但是这超出了本书的范围。
您可以从模块中导入擦拭和梳理过渡类:
import WipeTransition from "piu/WipeTransition";
import CombTransition from "piu/CombTransition";
擦除过渡从屏幕的边缘或角落开始显示新屏幕。此转换的构造函数具有以下参数来控制擦除:
-
以毫秒为单位的持续时间
-
宽松的等式
-
水平方向,如
"center"、"left"或"right" -
垂直方向,如
"middle"、"top"或"bottom"
水平和垂直方向决定了过渡开始的位置。例如,如果它们是center和top,过渡从顶部边缘开始;如果他们是right和bottom,过渡从右下角开始。
const wipeFromCenter = new WipeTransition(250,
Math.quadEaseOut, "center", "top");
const wipeFromTopRight = new WipeTransition(250,
Math.quadEaseOut, "right", "bottom");
梳状过渡通过一系列交错的条显示新屏幕,这些条从屏幕的顶部和底部边缘或屏幕的左侧和右侧边缘出现。梳状转换的构造函数具有以下参数:
-
以毫秒为单位的持续时间
-
宽松的等式
-
方向,如
"horizontal"或"vertical" -
酒吧的数量
如果方向设置为horizontal,条从左右边缘出现;如果设置为vertical,条从顶部和底部边缘出现。
const horizontalComb = new CombTransition(250, Math.quadEaseOut, "horizontal", 4);
const verticalComb = new CombTransition(250, Math.quadEaseOut, "vertical", 8);
一旦有了一个过渡实例,就调用要过渡的对象的父容器的run方法,将过渡、要过渡的内容对象和要过渡的内容对象作为参数传递。转换异步运行,因此不会阻止代码的执行。当过渡完成时,要从其过渡的内容对象在容器层次结构中被要过渡到的内容对象替换。例如,清单 10-60 中的代码运行wipeFromTopRightTransition转换,用nextScreen替换firstScreen。
const firstScreen = new Content(...);
const nextScreen = new Content(...);
const sampleContainer = new Container(null, {
...
contents: [
firstScreen
]
});
sampleContainer.run(wipeFromTopRightTransition, firstScreen, nextScreen);
Listing 10-60.
$EXAMPLES/ch10-piu/transitions示例显示了擦拭和梳理过渡的几种变化。它定期在两个屏幕之间切换。
实时绘制图表
有时,使用 Poco 提供的绘图功能来呈现用户界面的某些元素会更方便、更高效,而不是像 Piu 那样创建和更新对象。例如,假设您想要创建一个如图 10-23 所示的条形图,它根据传感器的读数实时更新。

图 10-23
实时更新的条形图
您可以使用 Piu 内容对象,但这不是最有效的实现。您需要跟踪和更新许多内容对象——图表中的每个条形和图表的背景至少有一个content对象,再加上一个或多个label对象作为 y 轴上的标签。每个对象都会占用一些内存,所以你的内存使用量会很快增加。
幸运的是,您不必在 Piu 和 Poco 的方法之间进行选择;您可以通过使用 Piu 的Port类来组合它们。端口是一个内容对象,允许您在 Piu 布局中发出类似于 Poco 的绘制命令,这对于图形等用户界面元素来说非常有用,否则这些用户界面元素将需要许多内容对象。
$EXAMPLES/ch10-piu/graph示例使用单个port对象高效地渲染实时条形图,如图 10-23 所示:
const graph = new Port(null, {
top: 0, bottom: 0, left: 0, right: 0,
Behavior: GraphBehavior
});
这个端口的行为,GraphBehavior(列表 10-61 )维护一个样本值列表,并将其绘制到存储在values属性中的数组中。每隔 100 毫秒,onTimeChanged事件处理程序删除列表中的第一个值,并用一个从 0 到 100 的随机数替换它。这些随机数是模拟的传感器读数。在生成一个新值后,onTimeChanged调用端口的invalidate方法,该方法告诉 Piu 需要重画端口。
class GraphBehavior extends Behavior {
onDisplaying(port) {
this.values = new Array(20);
this.values.fill(0);
port.interval = 100;
port.start();
}
onTimeChanged(port) {
this.values.shift();
this.values.push(Math.random() * 100);
port.invalidate();
}
Listing 10-61.
对invalidate的调用导致port对象触发自身的onDraw事件。请注意,onDraw不是从对invalidate方法的调用中调用的,而是在一小段时间后调用的。如清单 10-62 所示,在这种情况下onDraw事件处理程序用白色填充背景,绘制 y 轴标签和相应的灰色线条,然后为每个随机生成的值绘制一个蓝色条。
onDraw(port, x, y, width, height) {
port.fillColor(WHITE, x, y, width, height);
for (let i = 100, yOffset = 0; yOffset < height;
yOffset += height / 5, i -= 20) {
port.drawString(i, textStyle, "black",
30 - textStyle.measure(i).width,
yOffset);
port.fillColor(GRAY, 35, yOffset + 10, width, 1);
}
let xOffset = 35;
const values = this.values;
for (let i = 0; i < values.length; i++) {
let value = values[i];
let barHeight = (value / 100) * (height - 10);
port.fillColor(BLUE, xOffset, height - barHeight,
12, barHeight);
xOffset += 14;
}
}
Listing 10-62.
这个例子使用了由port内容对象提供的两种绘制方法:
-
它调用
drawString按照label对象的方式绘制一行文本,并指定样式和颜色。调用textStyle对象的measure方法来计算标签字符串的宽度,以便精确定位它们。 -
它调用
fillColor以指定的颜色绘制一个矩形。
port对象有几个其他的绘制方法,包括用drawTexture绘制由纹理指定的图像,用drawSkin绘制带有皮肤的矩形,就像任何内容对象一样。关于所有可用于port对象的绘图命令的详细信息,参见可修改 SDK 中的 Piu 文档。
添加屏幕键盘
在许多物联网产品中,有些情况需要用户输入文本,例如,在设置产品时输入 Wi-Fi 密码。如今,这种操作通常是在手机上的配套应用程序中完成的,需要用户安装新的移动应用程序,并遵循复杂、容易出错的流程来配置 Wi-Fi。在包含触摸屏的物联网产品上,用户可以直接在产品上配置 Wi-Fi,并为其他目的输入文本。要做到这一点,你只需要一个屏幕键盘。
挑战在于,当键盘更大时,准确打字更容易,但更大的触摸屏更贵。为了解决这个问题,可修改的 SDK 包括一个提供扩展屏幕键盘的模块,使在小触摸屏上准确输入文本成为可能。在这个键盘上输入一个字符是一个两步的过程:首先你在你想要输入的字符附近轻击(或者在那个字符上或者在那个字符附近);键盘会在您轻按的位置周围展开,然后您轻按您想要的字符。输入完文本后,点击 OK 。
您可以通过运行$EXAMPLES/ch10-piu/keyboard示例来尝试一下。当该示例启动时,您会看到键盘处于未展开状态(图 10-24 ,在键盘上方的文本字段中有一个闪烁的光标。

图 10-24
未展开的键盘
在图 10-25 中,左图显示了你点击字母 a 或其附近后键盘如何展开,右图显示了你点击字母 g 或其附近后键盘如何展开。

图 10-25
键盘围绕字母 a(左)和 g(右)展开
然后轻按您想要的字符,该字符出现在文本栏中闪烁的光标之前,键盘返回到未展开状态。(注意在展开状态下, OK 按钮变为显示键盘图标;如果你根本不想输入字符,而是想返回到未展开的键盘和 OK 按钮,你可以点击它。)
有两种扩展键盘可供选择:VerticalExpandingKeyboard用于 240 像素宽的屏幕,而HorizontalExpandingKeyboard用于 320 像素宽的屏幕。keyboard示例使用了水平变量,所以它从键盘模块导入了HorizontalExpandingKeyboard和KeyboardField对象。
import {HorizontalExpandingKeyboard} from "keyboard";
import {KeyboardField} from "common/keyboard";
这些模块是可修改的 SDK 的一部分,因此您可以看到它们的源代码和完整文档。既然你已经阅读了这一章,模块中的一切看起来都很熟悉;键盘的所有元素都是用你学过的 Piu 类构建的,包括Port、Timeline和Behavior。本节不描述键盘模块的实现,而只关注如何使用这些模块将键盘合并到您的项目中。
KeyboardContainer模板(清单 10-63 )是开始探索这个例子的好地方。其内容中的第一项是从common/keyboard模块导入的内容对象类KeyboardField的实例。此字段是您键入文本的位置。KeyboardField类有一个响应文本输入和闪烁光标的行为。第二项是容纳键盘的容器,尽管它最初是一个空容器。注意,这两个内容对象都有一个anchor属性,所以它们的锚是在实例化数据中创建的。
const KeyboardContainer = Column.template($ => ({
left: 0, right: 0, top: 0, bottom: 0,
contents: [
KeyboardField($, {
anchor: "FIELD",
left: 32, right: 0, top: 0, bottom: 0,
style: fieldStyle
}),
Container($, {
anchor: "KEYBOARD",
left: 0, right: 0, bottom: 0, height: 164
})
],
active: true,
Behavior: KeyboardContainerBehavior
}));
Listing 10-63.
在KeyboardContainerBehavior(清单 10-64 )中,与onDisplaying和onTouchEnded事件相关联的方法(你已经熟悉了)都做同样的事情:它们调用addKeyboard方法。
class KeyboardContainerBehavior extends Behavior {
...
onDisplaying(column) {
this.addKeyboard();
}
onTouchEnded(column) {
this.addKeyboard();
}
...
}
Listing 10-64.
方法addKeyboard(清单 10-65 )检查由data.KEYBOARD引用的container对象是否已经包含一个键盘。如果没有,该方法将根据传入的三个参数向空的container对象添加一个HorizontalExpandingKeyboard实例:
-
样式是键盘按键上字符的样式。
-
目标是当一个键被点击时应该接收事件的对象,在本例中是由
data.FIELD引用的KeyboardField对象。 -
doTransition参数指定键盘是否应该过渡。如果true,键盘一次一行地过渡进来;如果false,它会立刻出现。
addKeyboard() {
if (1 !== this.data.KEYBOARD.length) {
this.data.KEYBOARD.add(HorizontalExpandingKeyboard( this.data, {
style: keyboardStyle,
target: this.data.FIELD,
doTransition: true
}
));
}
}
Listing 10-65.
当用户点击 OK 按钮时,键盘将onKeyboardOK事件分配给application容器,容器中包含用户输入的文本字符串。在本例中,KeyboardContainerBehavior通过跟踪输入的字符串并隐藏显示字符串和光标的字段来响应事件。
onKeyboardOK(application, string) {
trace(`User entered: ${string}\n`);
this.data.FIELD.visible = false;
}
当用户点击 OK 时,键盘出现滑入过渡,滑出消失。当这些转换中的任何一个完成时,键盘产生一个带有参数的onKeyboardTransitionFinished事件,该参数指示转换是针对键盘的出现还是消失。您的代码可以使用这些事件来执行操作,例如在使用键盘时隐藏用户界面元素。
在这个例子中,onKeyboardTransitionFinished方法(清单 10-66 )通过从容器层次结构中移除键盘来响应键盘的消失,并且该方法通过使键盘上方的文本字段可见来响应键盘的出现。
onKeyboardTransitionFinished(application, out) {
if (out) {
let keyboard = this.data.KEYBOARD;
keyboard.remove(keyboard.first);
}
else
this.data.FIELD.visible = true;
}
Listing 10-66.
请注意,键盘在被转移出来后不必从容器层次结构中移除;您可以不断地将同一个实例移入和移出视图。然而,在许多应用程序中,点击 OK 会触发到另一个屏幕的转换,因此最好将键盘从容器层次结构中移除,以便可以对其进行垃圾收集。
使用模块组织用户界面代码
本章中的每个例子都包含在一个模块中,也就是一个源代码文件中。随着您的应用程序变得越来越复杂——具有多个屏幕、与云服务和其他设备的交互等等——您可能希望将代码划分到多个模块中。将代码分成模块有以下好处:
-
重用代码更容易,因为不特定于某个产品的代码可以存储在单独的源代码文件中。键盘模块就是一个例子。
-
当代码被组织在逻辑模块中时,编辑和维护代码更容易。
-
在团队中分配工作更容易。
本节讨论的$EXAMPLES/ch10-piu/multiple-screens示例展示了一种组织用户界面的常用方法。这是一个简单的应用程序,有两个屏幕:闪屏和主屏幕,如图 10-26 所示。该应用程序首先显示一个动画启动屏幕,然后转换到一个主屏幕,该屏幕上有一个重启按钮和标签。点击重启按钮返回到初始屏幕。在此过程中,该示例展示了构建具有多个模块和屏幕的可维护、高内存效率的应用程序的有用技术。

图 10-26
multiple-screens示例中的闪屏(左)和主屏幕(右)
模块
multiple-screens示例由三个模块组成:
-
example.js–在屏幕间导航的应用程序逻辑 -
整个应用程序中使用的
assets.js–texture、skin和style对象 -
screens.js–应用程序两个屏幕的模板
在这个例子中,assets和screens模块并不是特别长,因此将它们分开可能看起来很奇怪,因为assets模块导出只有screens模块使用的对象。然而,在较大的应用程序中,这通常是一种有用的分离,因为您只需要修改一个文件就可以更改所有屏幕上使用的颜色和资源。当你在建立一系列相似品牌的产品时,这也很有用;你可以通过创建一个共享的assets文件来定义你的屏幕使用的通用纹理、皮肤和风格,从而给你所有的产品一个一致的外观和感觉。
在本章中你已经看到了许多关于texture、skin和style对象的例子,所以这里不再详细描述assets模块。接下来的部分集中在example和screens模块以及它们如何交互。
应用程序逻辑
example模块包含所有特定于应用程序的逻辑,在这个应用程序中是在屏幕之间移动的简单逻辑。在启动时,该示例实例化了MainContainer模板(清单 10-67 )并将其添加到application对象中。这个容器是示例用来保存屏幕的。
const MainContainer = Container.template($ => ({
top: 0, bottom: 0, left: 0, right: 0,
Behavior: MainContainerBehavior
}));
application.add(new MainContainer({}));
Listing 10-67.
MainContainer的实例最初是空的。它的行为是添加和删除在screens模块中定义的屏幕。如清单 10-68 所示,该行为通过调用带有屏幕名称"SPLASH"的switchScreen方法,在onDisplaying事件处理程序中添加第一个屏幕。
class MainContainerBehavior extends Behavior {
onCreate(container, data) {
this.data = data;
}
onDisplaying(container) {
this.switchScreen(container, "SPLASH");
}
...
}
Listing 10-68.
行为中的下一个事件处理程序是switchScreen,应用程序每次需要切换到新屏幕时都会调用它。switchScreen方法触发doSwitchScreen事件,以便移动到新屏幕;然而,它没有使用delegate方法触发事件,而是使用了defer方法,后者将事件的交付推迟到事件循环的下一次迭代。defer和delegate的唯一区别是事件传递的时间。
switchScreen(container, nextScreenName) {
container.defer("doSwitchScreen", nextScreenName);
}
您希望延迟事件交付的一个原因是为了避免栈溢出。微控制器上的栈很小,创建屏幕的代码通常会占用相当多的栈空间。如果您立即切换屏幕,一些栈已经被调用您的行为的事件处理程序的调用所使用。通过延迟事件的传递,您的事件处理程序在几乎空的栈上运行,从而减少了栈的使用峰值。
推迟事件交付的另一个原因是为了减少切换屏幕时的内存使用高峰。由于垃圾收集的工作方式,如果您立即提交doSwitchScreen事件,垃圾收集器会将前一个和下一个屏幕都保存在内存中一小段时间。使用defer可以在实例化下一个屏幕之前先释放上一个屏幕。MainContainer的doSwitchScreen方法(清单 10-69 )就是这么做的,如下:
-
它使用
empty方法清空当前屏幕。因为这是通过延迟事件完成的,所以与该屏幕关联的对象有资格进行垃圾回收。 -
它调用
application.purge,释放 Piu 创建的缓存并运行垃圾收集器,从旧屏幕中释放对象使用的内存。 -
它实例化并添加下一个屏幕。
doSwitchScreen(container, nextScreenName) {
container.empty();
application.purge();
switch (nextScreenName) {
case "SPLASH":
container.add(new SCREENS.SplashScreen(this.data));
break;
case "HOME":
container.add(new SCREENS.HomeScreen(this.data));
break;
}
}
Listing 10-69.
这个过程是管理应用程序 RAM 使用的好方法,因为它有助于确保 RAM 中永远不会同时有两个屏幕的对象。将切换屏幕的逻辑放在MainContainer的行为中也是有用的,因为它避免了您必须在每个屏幕模板的行为中重复它;取而代之的是,当到了转到新屏幕的时候,每个屏幕可以简单地委托switchScreen事件。
闪屏
像许多移动和 web 应用程序一样,这个例子在应用程序启动时显示一个简单的闪屏。如清单 10-70 所示,这个屏幕上的标志是通过层叠三个content对象创建的,这使得每一个部分都可以用一个timeline对象单独制作动画。屏幕上的标题是一个简单的label物体。
const SplashScreen = Container.template($ => ({
top: 0, bottom: 0, left: 0, right: 0,
skin: ASSETS.backgroundSkin,
contents: [
Content($, {
anchor: "LOGO1",
top: 30,
skin: ASSETS.logoSkin1
}),
Content($, {
anchor: "LOGO2",
top: 30,
skin: ASSETS.logoSkin2
}),
Content($, {
anchor: "LOGO3",
top: 30,
skin: ASSETS.logoSkin3
}),
Label($, {
anchor: "TITLE",
top: 155,
style: ASSETS.bigTextStyle,
string: "lorem ipsum"
})
],
Behavior: SplashScreenBehavior
}));
Listing 10-70.
像往常一样,时间线是在行为中定义的(清单 10-71 ),由container对象的内部时钟驱动。
class SplashScreenBehavior extends Behavior {
...
onDisplaying(container) {
let data = this.data;
let timeline = this.timeline = new Timeline;
...
}
onTimeChanged(container) {
this.timeline.seekTo(container.time);
}
}
Listing 10-71.
当动画完成时,行为的onFinished方法(清单 10-72 )执行以下操作:
-
它删除屏幕上所有内容对象的锚点。注意,这并没有删除内容对象本身,而只是删除了在
data对象中对它们的引用。删除这些引用很重要,因为data对象由MainContainer对象共享,并传递给它创建的所有屏幕;如果引用没有被删除,当在doSwitchScreen方法中调用application.purge时,垃圾收集器将不能释放与内容对象相关的 RAM。 -
然后它使
switchScreen事件冒泡,最终到达MainContainer对象。它将字符串"HOME"作为第二个参数传递,因此MainContainer接下来加载主屏幕。
onFinished(container) {
let data = this.data;
// Delete anchors
delete data.LOGO1;
delete data.LOGO2;
delete data.LOGO3;
delete data.TITLE;
// Transition to next screen
container.bubble("switchScreen", "HOME");
}
Listing 10-72.
主屏幕
主屏幕(列表 10-73 )是一个以重启按钮和标签为中心的行。重启按钮和主屏幕分别有名为RestartButtonBehavior和HomeScreenBehavior的行为。
const HomeScreen = Row.template($ => ({
top: 0, bottom: 0, left: 0, right: 0,
skin: ASSETS.backgroundSkin,
contents: [
Content($, {
left: 0, right: 0
}),
Container($, {
anchor: "BUTTON",
skin: ASSETS.buttonBackgroundSkin,
contents: [
Content($, {
skin: ASSETS.restartArrowSkin
})
],
active: true,
Behavior: RestartButtonBehavior
}),
Label($, {
anchor: "TEXT",
left: 10,
style: ASSETS.bigTextStyle,
string: "Restart",
left: 0, right: 0
})
],
Behavior: HomeScreenBehavior
}));
Listing 10-73.
HomeScreenBehavior类的onDisplaying事件处理程序激活重启按钮和标签,如清单 10-74 所示。
class HomeScreenBehavior extends Behavior {
onCreate(container, data) {
this.data = data;
}
onDisplaying(container) {
let data = this.data;
let timeline = this.timeline = new Timeline();
...
container.start();
}
...
}
Listing 10-74.
与闪屏不同,主屏幕在动画出现后不会自动切换屏幕。相反,它等待接收一个animateOut事件;其行为的animateOut方法(清单 10-75 )创建一个timeline对象并将transitioningOut属性设置为true。
animateOut(container) {
let data = this.data;
this.transitioningOut = true;
let timeline = this.timeline = new Timeline();
...
container.start();
}
Listing 10-75.
当onFinished事件在动画结束时被触发,相应的事件处理程序(列表 10-76 )检查transitioningOut属性以决定采取哪一个动作:
-
如果
transitioningOut是true,按钮和标签的锚点被删除,switchScreen事件被冒泡到MainContainer对象。 -
如果
transitioningOut为false,则删除timeline属性,使timeline对象符合垃圾回收条件。由于垃圾收集器仅在需要释放 RAM 时运行,并且在输入和输出转换之间没有实例化其他对象,所以垃圾收集器不会运行,所以这里没有必要删除timeline属性。尽管如此,养成删除不再使用的对象的引用的习惯还是有好处的。
onFinished(container) {
if (this.transitioningOut) {
let data = this.data;
// Delete anchors
delete data.BUTTON;
delete data.TEXT;
// Transition to next screen
container.bubble("switchScreen", "SPLASH");
}
else
delete this.timeline;
}
Listing 10-76.
重启按钮的行为(清单 10-77 )只响应一个事件:onTouchEnded。行为的onTouchEnded方法只是将onAnimateOut事件委托给按钮的容器,这是HomeScreen模板的一个实例。正如你刚才看到的,这触发了动画,并最终导致过渡回闪屏。
class RestartButtonBehavior extends Behavior {
onTouchEnded(content) {
content.container.delegate("animateOut");
}
}
Listing 10-77.
添加更多屏幕
既然您已经知道如何在两个屏幕之间切换,那么添加更多屏幕就很简单了。这些是步骤:
-
为新屏幕定义模板。
-
将其添加到
screens模块的默认导出中。 -
在
example模块中,给MainContainerBehavior的doSwitchScreen方法中的switch语句添加一个 case,实例化屏幕模板并添加到MainContainer。 -
在代码中根据需要触发
switchScreen事件,在switch语句中传递您用于新屏幕的名称。
结论
在本章中,您学习了使用 Piu 构建用户界面的基础知识,包括如何添加图形和文本、赋予它们事件驱动的行为以及创建动画。您学习了几种节省 RAM 的技术,比如重用纹理和皮肤,以及删除对未使用对象的引用。您还学习了保存 ROM 的技巧,包括使用模板。根据本章的信息,你可以用便宜的硬件构建漂亮的现代用户界面。
本章介绍了用于构建嵌入式产品用户界面的 Piu 的主要特性。Piu 还有许多其他特性,您可能会发现这些特性在您的产品中很有用——例如,支持为必须支持多种语言的产品有效地本地化文本字符串。有关 Piu 所有特性的详细文档以及使用这些特性的示例链接,请参见可修改 SDK 中的 Piu 文档。*
十一、添加本机代码
有时候,JavaScript 并不是实现物联网产品的最佳语言。幸运的是,您不需要选择 JavaScript 或 C(或 C++)来构建您的产品:您可以两者都选。C 中的 XS 是由 XS JavaScript 引擎提供的一个低级 C API,这样你就可以将 C 代码集成到你的 JavaScript 项目中(或者将 JavaScript 代码集成到你的 C 项目中!).
以下是在项目中使用本机代码的三个常见原因:
-
性能——包括 JavaScript 在内的高级语言,在高性能任务上无法胜过优化后的原生代码。您可以添加自己优化的本机函数,并从 JavaScript 代码中调用它们。
-
访问 硬件特性–作为一种通用编程语言,JavaScript 并不具备对主机硬件独特特性的内置支持。您可以实现自己的函数和类来配置和使用它们。
-
重用现有的本机代码–您可能有大量适用于您的产品的现有本机代码,并且您不希望必须用 JavaScript 重写。您可以在 JavaScript 项目中使用该代码,方法是在 C 中使用 XS 在它和您的 JavaScript 代码之间架起一座桥梁。
C 中的 XS 允许您使用 C 中的 JavaScript 特性。如您所知,JavaScript 具有 C 不直接支持的功能,如动态类型和对象。在 C 语言中使用 XS 来处理这些特性可能会很笨拙,但是随着您的实践和学习一些常见的模式,它会变得简单明了。本章通过一系列例子介绍了 C 语言中的 XS,这些例子展示了在 JavaScript 和 C 代码之间搭建桥梁的不同技术。
请注意,许多实现高级编程语言的引擎都提供了 API 来桥接该语言和本机代码。Java 语言为此定义了 Java 本地接口(JNI ), V8 JavaScript 引擎提供了一个 C++ API。
Important
本章介绍的信息是一个高级主题。它假设您熟悉 C 语言编程,并且对本书中讨论的基本 JavaScript 概念有坚实的理解。
安装主机
本章不需要安装主机,因为所有本机代码都必须是主机本身的一部分;因此,您将本章中的每个示例都构建为独立的主机。您可以使用mcconfig,而不是使用mcrun来安装示例。下列命令列分别适用於 ESP32 和 ESP8266 目标:
> mcconfig -d -m -p esp32
> mcconfig -d -m -p esp
这些命令行没有指定开发板(例如,esp32/moddable_two),因为这些示例仅使用微控制器的通用特性,并不依赖于开发板的特定特性。
当您使用mcconfig构建示例时,JavaScript 和 C 代码都会被构建。如果构建过程中出现错误,就会报告给命令行。
生成随机整数
第一个本机代码集成的例子生成随机整数。你在第九章看到了random-rectangles例子使用了 JavaScript 内置函数Math.random生成的随机数。这个例子效率不高,因为Math.random返回一个浮点值,迫使 Poco 为每个矩形将几个浮点值转换成整数。浮点运算在微控制器上通常很慢,在这里它们没有任何好处。C 标准库的rand函数生成随机整数,$EXAMPLES/ch11-native/random-integer示例从使用rand为 JavaScript 代码生成随机整数开始。
创建本机函数
第一步是创建一个 JavaScript 函数,JavaScript 代码可以调用这个函数来调用 C 函数。random-integer示例在main.js源代码文件中声明了一个randomInt函数。
function randomInt() @ "xs_randomInt";
该语法创建了一个名为randomInt的 JavaScript 函数,当调用该函数时,它会调用本机函数xs_randomInt,本质上是建立了一个从 JavaScript 到 c 的桥梁。这里使用的@不是标准的 JavaScript 语法,而是 XS 提供的一个语言扩展,用于简化向项目添加本机代码。因此,这些代码不太可能与其他 JavaScript 引擎一样编译或运行。
创建函数后,您可以像调用任何其他 JavaScript 函数一样调用它。main.js模块调用它 100 次,跟踪结果到调试控制台。
for (let i = 0; i < 100; i++)
trace(randomInt(), "\n");
实现本机函数
xs_randomInt的实现包含在main.c中。当您构建一个扩展名为.js的文件时,mcconfig也会构建一个扩展名为.c的同名文件。清单 11-1 显示了main.c的全部内容。
#include "xsmc.h"
void xs_randomInt(xsMachine *the)
{
xsmcSetInteger(xsResult, rand());
}
Listing 11-1.
include预处理器命令引入 c 语言中 XS 的头文件。(文件名xsmc,代表“XS 微控制器”。))还有一个由一些代码使用的xs.h头文件。这两个头文件提供了相同的功能,但是xsmc.h头文件中的功能更有效,因此更适合在微控制器上使用。
xs_randomInt的原生函数原型用于所有在 C 中使用 XS 实现原生方法的函数。JavaScript 参数不会作为参数传递给 C 函数。在本章的后面你会看到如何访问参数。
这个例子需要返回值——调用rand的结果。rand的结果是一个整数,所以这个例子使用了xsmcSetInteger,一个将本地 32 位整数值赋给 JavaScript 值的函数。这里的 JavaScript 值是xsResult,指的是 JavaScript 栈上函数的返回值。
使用硬件随机数生成器
您已经看到了声明、调用和实现一个简单的本机函数是多么简单。当您运行random-integer示例时,您会看到从 0 到 2,147,483,647 的 100 个随机数被跟踪到调试控制台。但是,当您重新启动微控制器并第二次运行该示例时,您会看到完全相同的数字列表。这不是很随意。为什么会这样?
rand函数是一个伪随机数生成器。这是一种产生随机数字的算法。然而,当您重新启动微控制器时,您也会重新启动伪随机数生成器算法,使其生成相同的数字序列。您可以使用srand功能让算法启动不同的序列,但是您必须在每次重启时为srand提供不同的起始点。初始化序列最常见的方法是使用当前时间。不幸的是,许多微控制器,包括 ESP32 和 ESP8266,在启动时不知道时间,所以这种技术不能应用。
幸运的是,许多微控制器,包括 ESP32 和 ESP8266,都有硬件来生成随机数,这些值比rand生成的值更随机。$EXAMPLES/ch11-native/random-integer-esp示例展示了如何使用硬件随机数生成器。
Important
并非所有的随机数都保证不可预测,可以安全地用于安全解决方案,例如保护网络连接的 TLS 协议。(具有这种保证的随机数被称为密码安全的。)您应该始终验证您使用的随机数来源是否符合项目的安全要求。这不容易做到,但很重要,因为弱随机数生成器是项目整体安全性的一个漏洞。
在 ESP32 上,访问硬件随机数生成器只需将调用rand替换为调用 ESP-IDF 函数esp_random。esp_random提供的随机性程度取决于许多因素,包括无线电(Wi-Fi 或蓝牙)是否启用。
xsmcSetInteger(xsResult, esp_random());
在 ESP8266 上,有一个未记录的硬件随机数生成器,似乎工作得很好。它应该小心使用,因为它的精确特性是未知的。要访问随机数生成器,可以直接读取它的硬件寄存器。
uint32_t random = *(volatile uint32_t *)0x3FF20E44;
清单 11-2 显示了使用本地随机数生成器的修改后的本地实现。因为在 ESP32 和 ESP8266 上对生成器的访问方式不同,所以 C 代码使用条件编译来选择正确的版本,并在为不受支持的目标编译代码时生成错误。
void xs_randomInt(xsMachine *the)
{
#if ESP32
xsmcSetInteger(xsResult, esp_random());
#elif defined(__ets__)
xsmcSetInteger(xsResult, (*(volatile unt32_t *)0x3FF20E44));
#else
#error Unsupported platform
#endif
}
Listing 11-2.
使用此randomInt功能有两个问题:
-
ESP32 和 ESP8266 硬件随机数生成器都返回 32 位无符号值。
xsmcSetInteger函数需要一个 32 位有符号值。因此,使用硬件随机数技术会更改 JavaScriptrandomInt函数的结果,以返回从–2,147,483,648 到 2,147,483,647 的值范围。回想一下,当你使用rand时,所有的值都是正的。您可以使用xsmcSetNumber来返回作为浮点数的无符号 32 位值;然而,这与将随机数作为整数值返回的目标背道而驰。 -
通常你想要一个一定范围内的随机数,生成一个范围内的值需要除法或者模运算。除法运算通常需要浮点运算,因为结果可能有小数部分。如果两个操作数都是整数,模运算可以使用整数除法。然而,不需要
randomInt的调用者有效地将返回值限制在期望的范围内,您可以修改本地函数来实现这一点。
下一节将讨论这些问题。
将随机数限制在一个范围内
$EXAMPLES/ch11-native/random-integer-esp-range示例将随机数限制在一个范围内。第一步是声明一个接受随机值范围的函数。randomIntRange函数接受一个表示随机值范围的参数,从 0 开始,到max结束。
function randomIntRange(max) @ "xs_randomIntRange";
更新main.js中的调用代码以传入范围,在本例中是 1000。
for (let i = 0; i < 100; i++)
trace(randomIntRange(1000), "\n");
本机函数必须首先检索作为第一个参数传递的范围。使用xsArg通过索引访问参数。参数从 0 开始编号,因此第一个参数作为xsArg(0)被访问。如果调用者没有传递任何参数,xsArg(0)抛出一个异常;因此,本机代码通常不需要检查传递的参数数量。(如果您的函数需要知道参数的数量,请使用xsmcArgc整数值。)XS 在 C 中抛出的异常是普通的 JavaScript 异常,可能会被 JavaScript 代码中熟悉的try和catch块捕捉到。
C 代码不能对参数的类型做出任何假设,因为 JavaScript 不会强制执行任何关于传递给函数的参数类型的规则。C #中的 XS 提供了将 JavaScript 值转换成特定本机类型的函数。在xs_randomIntRange函数(清单 11-3 )中,对xsmcToInteger的调用要求 XS 将 JavaScript 属性转换为有符号的 32 位整数。如果 XS 能够执行转换,它将返回结果;否则,它会抛出一个 JavaScript 异常。例如,传递一个字符串值"100"或一个数字值100.1会成功,因为 JavaScript 知道如何将它们转换成整数;然而,传递一个空对象{}会失败。
void xs_randomIntRange(xsMachine *the)
{
int range = xsmcToInteger(xsArg(0));
if (range < 2)
xsRangeError("invalid range");
...
}
Listing 11-3.
本地函数实现接下来验证所请求的范围。小于两个值的范围对于整数随机数没有意义。如果范围无效,函数调用xsRangeError抛出 JavaScript 错误RangeError。前面的 C 代码相当于这几行 JavaScript 代码:
if (range < 2)
throw new RangeError("invalid range");
在连接 JavaScript 和 C 代码的本机代码中包含错误检查非常重要。JavaScript 程序员希望这种语言是安全的——应该没有办法使设备崩溃或损坏——JavaScript 引擎和运行时尽最大努力实现这一目标。您的本机代码也必须这样做。例如,如果 JavaScript 代码为该范围传递 0,则 C 语言不会定义结果。ESP32 右侧为 0 的模运算产生一个IntegerDivideByZero异常,ESP8266 产生一个Illegal Instruction异常,两者都会复位微控制器。
剩下的xs_randomIntRange(清单 11-4 )的实现很简单。模运算符(%)将随机值限制在指定的范围内,而不是直接返回 32 位无符号整数值。
#if ESP32
xsmcSetInteger(xsResult, esp_random() % range);
#elif defined(__ets__)
xsmcSetInteger(xsResult,
(*(volatile uint32_t *)0x3FF20E44) % range);
#else
#error Unsupported platform
#endif
Listing 11-4.
比较随机数方法
原生randomIntRange函数只是几行原生代码,但与内置的Math.random函数相比,这几行代码对于物联网开发具有许多优势:
-
返回值是整数,而不是浮点,允许在微控制器上更有效地执行。
-
返回值被有效地限制在请求的范围内。
-
这些数字更加随机,因为它们使用硬件随机数生成器。
当然,也有缺点:
-
本机代码是不可移植的。它只在两个微控制器上成功构建。
-
您必须将本机代码作为宿主的一部分来构建。
-
本机代码的实现和调试更加复杂,需要额外的专业知识。
当您可以选择将本机功能添加到项目中时,您应该在权衡利弊的基础上做出决定。
BitArray类
JavaScript 类型化数组,比如Uint8Array和Uint32Array,使您能够使用最少的内存处理 8 位、16 位和 32 位整数值的数组。BitArray类实现了一个 1 位数组——也就是说,一个只存储值 0 和 1 的数组。这对于有效存储从数字输入接收的大量样本非常有用。
本节介绍了BitArray的两种变体,每一种都有相同的 JavaScript API。第一个使用 JavaScript ArrayBuffer来存储位,而第二个使用 C calloc函数分配的本地内存。
BitArray类构造函数接受一个参数:数组需要存储的位数。该类提供了get和set方法来访问数组中的值。清单 11-5 显示了使用BitArray类的测试代码。
import BitArray from "bitarray";
let bits = new BitArray(128);
bits.set(2, 1);
bits.set(3, bits.get(3) ? 0 : 1);
Listing 11-5.
get和set的第一个参数是数组中要获取或设置的位的索引。第一个数组元素的索引为 0。该示例的最后一行切换索引 3 处的位的值。
使用由ArrayBuffer分配的内存
清单 11-6 显示了$EXAMPLES/ch11-native/bitarray-arraybuffer示例中BitArray的实现。它从用 JavaScript 声明类开始。与前面的随机整数示例一样,特殊的 XS @语法用于将 JavaScript 函数连接到本地 C 函数。注意,构造函数是用 JavaScript 实现的,而get和set方法是用 C 实现的。不要求类完全用 JavaScript 或 C 实现;您可以选择最适合每种方法的语言。
class BitArray {
constructor(count) {
this.buffer = new ArrayBuffer(Math.ceil(count / 8));
}
get(index) @ "xs_bitarray_get";
set(index, value) @ "xs_bitarray_set";
}
export default BitArray;
Listing 11-6.
构造函数分配一个ArrayBuffer来保存位值。因为新的ArrayBuffer的存储器总是被初始化为 0,所以不需要进一步的初始化。要存储的位数除以 8,以确定所需的字节数,然后使用Math.ceil四舍五入,以确保当位数不能被 8 整除时,有足够的字节分配。ArrayBuffer被分配给BitArray实例的buffer属性。get和set的本地实现使用buffer属性访问内存。
get功能
get函数的本机实现xs_bitarray_get从检索位的索引开始,位是函数的第一个参数。它使用index参数来计算byteIndex,即包含该位的字节的索引,以及bitIndex,即该字节中该位的索引。
int index = xsmcToInteger(xsArg(0));
int byteIndex = index >> 3;
int bitIndex = index & 0x07;
接下来,xs_bitarray_get获取一个指针,指向存储在buffer属性中的由ArrayBuffer分配的内存。为此,它首先在 JavaScript 栈上分配一个临时 JavaScript 变量,通过调用带有参数1的xsmcVars来指定临时变量的数量。
xsmcVars(1);
使用xsmcVars分配的变量通过xsVar访问,这类似于xsArg,但是访问的是本地临时变量,而不是函数的参数。当分配变量的本机函数(在本例中为xs_bitarray_get)返回时,变量被自动释放。你应该在一个函数中只调用xsmcVars一次,一次分配所有需要的临时变量。
xs_bitarray_get的实现通过调用xsmcGet来检索对其实例的buffer属性的引用。buffer属性的值放在xsVar(0)中。
xsmcGet(xsVar(0), xsThis, xsID_buffer);
这里的第二个参数xsThis告诉xsmcGet您想要从哪个对象中检索属性。第三个参数,这里的xsID_buffer,指定您想要检索的属性的名称是buffer。
前面的步骤在 c 语言中使用了许多来自 XS 的不熟悉的调用。它们在 JavaScript 中非常简单,但在 c 语言中表达起来要冗长得多。与调用xsmcVars和xsmcGet等效的 JavaScript 如下:
let var0;
var0 = this.buffer;
buffer属性不是指向ArrayBuffer实例使用的内存缓冲区的指针;这是对实例的引用。正如使用xsmcToInteger将 JavaScript 值转换成整数一样,使用xsmcToArrayBuffer将 JavaScript 值转换成本机指针。如果 JavaScript 值不是一个ArrayBuffer实例,那么对xsmcToArrayBuffer的调用会抛出一个异常。
uint8_t *buffer = xsmcToArrayBuffer(xsVar(0));
现在xs_bitarray_get有了缓冲区指针,它使用之前计算的byteIndex和bitIndex值来读取该位,并将 JavaScript 函数调用的返回值设置为 0 或 1。
if (buffer[byteIndex] & (1 << bitIndex))
xsmcSetInteger(xsResult, 1);
else
xsmcSetInteger(xsResult, 0);
set功能
xs_bitarray_set中set函数(清单 11-7 的实现与get的实现非常相似。以同样的方式确定byteIndex、bitIndex和buffer的值。唯一的区别是第二个参数的值(通过xsArg(1)访问)用于确定是否设置或清除指定的位。
int value = xsmcToInteger(xsArg(1));
if (value)
buffer[byteIndex] |= 1 << bitIndex;
else
buffer[byteIndex] &= ~(1 << bitIndex);
Listing 11-7.
安全漏洞
使用由ArrayBuffer分配的内存的BitArray的实现工作得很好,但是它有一个致命的缺陷,使它不适合在实际产品中安全使用。get和set函数不验证index参数是否在分配的内存范围内。这使得使用此BitArray实现的代码能够读写嵌入式设备上的任意内存,这可能导致崩溃或被用作隐私攻击的基础。有多种方法可以解决这个问题;下一节将讨论其中之一。
使用由calloc分配的内存
在$EXAMPLES/ch11-native/bitarray-calloc示例中BitArray的实现解决了刚才讨论的bitarray-arraybuffer示例所带来的安全问题。它存储构造函数分配的位数,然后根据存储的值验证传递给get和set调用的索引。
bitarray-calloc示例中的BitArray实现使用calloc而不是ArrayBuffer来分配内存。这两种方法分配的内存来自两个不同的内存池:由calloc分配的内存来自本机系统内存堆,而由ArrayBuffer分配的内存在由 XS 管理的内存堆内。一些主机在其中一个池中配置了比另一个池更多的可用空间,这可能会影响您决定从哪里分配内存。使用由calloc分配的内存需要的代码要少一些,尽管这种差别可能并不明显。
bitarray-calloc示例展示了将本地代码集成到项目中的一些重要技术。除了一个本地构造函数,这个BitArray类还有一个本地析构函数,当类的一个实例被垃圾收集时执行清理。在 XS,具有本机析构函数的对象被称为宿主对象。
类声明
清单 11-8 展示了类声明。与来自bitarray-arraybuffer示例的实现不同,BitArray的实现主要使用本地方法。请注意,实现析构函数的本机 C 函数的名称xs_bitarray_destructor跟在类名声明的后面。
class BitArray @ "xs_bitarray_destructor" {
constructor(count) @ "xs_bitarray_constructor";
close() @ "xs_bitarray_close";
get(index) @ "xs_bitarray_get";
set(index, value) @ "xs_bitarray_set";
get length() @ "xs_bitarray_get_length";
set length(value) {
throw new Error("read-only");
}
}
Listing 11-8.
get和set方法的声明与前面的例子相同,尽管实现有些不同。
本机构造函数、析构函数和close函数密切相关。接下来的几节将依次讨论这两个问题。
构造函数
清单 11-9 中的本地构造函数开始很像 JavaScript 实现,通过计算存储请求的位数所需的字节数,然后分配这些字节。构造函数分配整数大小的额外空间来保存位数。如果分配失败,构造函数调用xsUnknownError抛出异常。在名称xsUnknownError中使用Unknown意味着这是一个通用错误,它使用 JavaScript Error类,而不是一个特定错误,如RangeError。
void xs_bitarray_constructor(xsMachine *the)
{
int bitCount = xsmcToInteger(xsArg(0));
int byteCount = (bitCount + 7) / 8;
uint8_t *bytes = calloc(byteCount + sizeof(int), 1);
if (!bytes)
xsUnknownError("no memory");
*(int *)bytes = bitCount;
xsmcSetHostData(xsThis, bytes);
}
Listing 11-9.
一旦分配了内存,请求的位数就存储在块的开始处。因为存储器是使用calloc分配的,所以所有位都被初始化为 0。
对xsmcSetHostData的调用存储了一个对分配给这个主机对象的内存的引用。然后,通过调用xsmcGetHostData,这个指针对该对象的所有本机方法都可用。您可能想简单地将bytes指针存储在一个全局变量中;然而,当对象有多个实例时,这种方法就失败了,因为两个对象不能共享一个 C 全局变量。使用xsmcSetHostData存储数据指针意味着BitArray的实现支持任意数量的并发实例。
析构函数
这是本书中你第一次看到析构函数。它们在 C++中处理对象时很常见,但在 JavaScript 语言中并不可见。相反,当对象被垃圾收集时,JavaScript 会自动释放它们所使用的内存。JavaScript 引擎不知道如何释放你的主机对象分配的资源,比如用calloc分配的内存。因此,您必须实现析构函数。
对于BitArray,析构函数(清单 11-10 简单的调用free来释放calloc分配的内存。
void xs_bitarray_destructor(void *data)
{
if (data)
free(data);
}
Listing 11-10.
下面是实现析构函数时需要注意的一些细节:
-
析构函数的函数原型不同于常规的本机方法调用。它没有被传递一个对 XS 虚拟机的引用作为
the,而是有一个数据指针的参数,与你传递给xsmcSetHostData的值相同。 -
因为没有对 XS 虚拟机的引用(没有
the参数),所以不能在 c 中调用 XS,比如不能调用xsmcGetHostData,这就是为什么数据指针总是传递给析构函数。这也意味着你的析构函数不能创建新的对象,改变属性值,或者对对象进行函数调用。这些限制是必要的,因为当这样的操作不安全时,析构函数是从垃圾收集器内部调用的。 -
data的值可以是NULL。例如,当构造函数中的内存分配失败时,就会发生这种情况。正如您将在下一节看到的,这也发生在调用close方法之后。因此,一个好的做法是,在使用析构函数之前,总是检查一下data参数是否是NULL,就像这个例子一样。
close功能
第三章和第五章包含了拥有close方法的 JavaScript 对象的例子。此方法释放对象拥有的任何本机资源—内存、文件句柄、网络套接字等。如果对象没有被显式关闭,当垃圾收集器确定对象不再被使用时,这些资源最终会被释放。然而,没有办法知道垃圾收集器什么时候会做出决定,这意味着可能需要很长时间才能释放资源。close调用通过为代码提供一种显式释放这些资源的方式来解决这个问题。
许多主机对象都有一个类似于BitArray的close实现(清单 11-11 )。
void xs_bitarray_close(xsMachine *the)
{
uint8_t *buffer = xsmcGetHostData(xsThis);
xs_bitarray_destructor(buffer);
xsmcSetHostData(xsThis, NULL);
}
Listing 11-11.
下面是这几行代码的作用:
-
对
xsmcGetHostData的调用检索在构造函数中分配的数据指针,并通过对xsmcSetHostData的调用与该对象相关联。 -
数据指针被传递给析构函数,析构函数负责释放资源。
-
对
xsmcSetHostData的调用将保存的数据指针设置为NULL。这确保了如果close被调用两次,数据指针只被释放一次。
get和set功能
xs_bitarray_get的实现计算位和字节索引值的方式与get的ArrayBuffer版本相同:
int index = xsmcToInteger(xsArg(0));
int byteIndex = index >> 3;
int bitIndex = index & 0x07;
如清单 11-12 所示,xs_bitarray_get使用xsmcGetHostData来检索数据缓冲区。如果缓冲区是NULL,则表明实例已经被关闭,get抛出错误。分配的比特数的计数存储在缓冲器的第一个整数中;它被提取到本地变量bitCount,然后缓冲区指针前进指向位数组值。
uint8_t *buffer = xsmcGetHostData(xsThis);
int bitCount;
if (NULL == buffer)
xsUnknownError("closed");
bitCount = *(int *)buffer;
buffer += sizeof(int);
Listing 11-12.
在访问所请求的位之前,该实现首先检查该值是否在范围内。因为索引是一个有符号的整数,所以它检查它是否大于分配的位数,并且索引不为负。
if ((index >= bitCount) || (index < 0))
xsRangeError("invalid bit index");
检查完成后,读取所请求的位并设置返回值与之前的版本相同:
if (buffer[byteIndex] & (1 << bitIndex))
xsmcSetInteger(xsResult, 1);
else
xsmcSetInteger(xsResult, 0);
set的实现应用了本节中针对get描述的相同变化,因此这里不再重复。
length属性
类型化数组类在它们的实例中包含一个length属性,与Array的实例一样,它指示数组中元素的数量。这个值在迭代数组时很有用。因为BitArray的这个实现存储了分配的位数,所以它也可以提供一个length属性。
length属性是用 getter 和 setter 实现的,这两种特殊的 JavaScript 函数在代码访问属性时被调用。使用length的 getter 和 setter 使您能够编写如下代码,将所有位初始化为 1:
let bits = new BitArray(55);
for (let i = 0; i < bits.length; i++)
bits.set(i, 1);
实现length属性的第一步是向BitArray类添加 getter 和 setter。这里的 getter 是xs_bitarray_get_length本地函数。length属性是只读的,所以 setter 实现不是本地代码,而是一个总是抛出异常的 JavaScript 函数。请注意,主机对象可能有 JavaScript 方法。
get length() @ "xs_bitarray_get_length";
set length(value) {
throw new Error("read-only");
}
清单 11-13 中所示的xs_bitarray_get_length的实现非常简单。它使用xsmcGetHostData来检索在构造函数中创建的数据指针。如果实例已经关闭——也就是说,如果buffer是NULL——它抛出一个异常;否则,它将返回值设置为从数据指针开始处提取的位计数。
void xs_bitarray_get_length(xsMachine *the)
{
uint8_t *buffer = xsmcGetHostData(xsThis);
if (NULL == buffer)
xsUnknownError("closed");
int bitCount = *(int *)buffer;
xsmcSetInteger(xsResult, bitCount);
}
Listing 11-13.
这种方法的优点
使用由calloc分配的内存的BitArray的第二种实现比第一种版本有许多优点:
-
它验证输入值,消除了草率代码导致崩溃和恶意代码侵犯隐私的可能性。
-
它提供了一个
length属性,使得使用起来更加方便。 -
它使用系统内存来存储位数据,减少了 JavaScript 引擎管理的内存堆中使用的内存。
-
它使用 C #中 XS 的主机数据特性来跟踪内存缓冲区,比使用 JavaScript 属性需要的代码更少,运行速度更快。
Wi-Fi 信号通知
您已经学习了如何实现一个类来将本机资源作为宿主对象进行管理。下一个例子展示了如何从 C 代码调用回 JavaScript,以及如何使用字典配置主机对象。这两种技术都被可修改的 SDK 中的许多主机对象所使用。
$EXAMPLES/ch11-native/wifi-rssi-notify示例实现了WiFiRSSINotify类,它允许您注册在 Wi-Fi 信号强度超过或低于指定阈值时调用的回调函数。您可以在您的产品中使用这一点,以向用户指示 Wi-Fi 何时可能运行良好,或者在信号较弱时抑制您生成的网络流量。这个类可以完全用 JavaScript 实现,使用Timer和第三章的“获取网络信息”一节中介绍的net模块。这种使用本机代码的实现更加高效,并且提供了一个方便的起点来展示如何从字典配置您的主机对象以及如何调用回调函数。
当您运行这个示例时,您必须为微控制器指定一个要连接的 Wi-Fi 接入点。这是因为 RSSI 测量你的微控制器和它所连接的接入点之间的信号强度;如果没有联系,就没什么可衡量的。下面是构建和运行此示例的典型命令行:
> mcconfig -d -m -p esp32 ssid="My Wi-Fi" password="secret"
测试代码
WiFiRSSINotify类遵循一种常见的模式,即构造函数接受配置选项的字典对象。清单 11-14 显示了main.js中构建该类实例的测试代码。您需要指定 RSSI 阈值,低于该阈值的信号被视为弱信号,高于该阈值的信号被视为强信号。可选的poll属性配置检查信号强度的频率;在本例中,它被设置为 1000 毫秒。默认轮询频率是 5000 毫秒。
import WiFiRSSINotify from "wifirssinotify";
let notify = new WiFiRSSINotify({
threshold: -66,
poll: 1000
});
Listing 11-14.
一旦创建了通知实例,您就可以安装一个onWeakSignal和/或onStrongSignal回调,如清单 11-15 所示。当 RSSI 达到或低于指定的阈值时,调用onWeakSignal回调,当 RSSI 超过阈值时,调用onStrongSignal。这些函数在超过阈值时调用,而不是每次轮询 RSSI 时调用。当前的 RSSI 值被传递给回调函数。
notify.onWeakSignal = function(rssi) {
trace(`Weak Wi-Fi signal. RSSI ${rssi}.\n`);
}
notify.onStrongSignal = function(rssi) {
trace(`Strong Wi-Fi signal. RSSI ${rssi}.\n`);
}
Listing 11-15.
WiFiRSSINotify类
WiFiRSSINotify的 JavaScript 类只是一个宿主对象,它有析构函数、构造函数和close函数,都是用本机代码实现的:
class WiFiRSSINotify @ "xs_wifirssinotify_destructor" {
constructor(options) @ "xs_wifirssinotify_constructor";
close() @ "xs_wifirssinotify_close";
}
用于onWeakSignal和onStrongSignal回调的默认函数不是该类的一部分。在调用回调之前,WiFiRSSINotify确认实例有一个与回调同名的属性。
原生RSSINotifyRecord结构
WiFiRSSINotify类需要维护状态来执行它的工作。该状态存储在名为RSSINotifyRecord的 C 语言结构中,如清单 11-16 所示。您可以将这种数据结构视为 JavaScript 实例中属性的 C 等价物。
struct RSSINotifyRecord {
int threshold;
int state;
modTimer timer;
xsMachine *the;
xsSlot obj;
};
Listing 11-16.
在查看使用这种数据结构的代码之前,回顾一下每个字段的用法是很有帮助的:
-
threshold –RSSI 阈值,低于该阈值信号被认为是弱信号,高于该阈值信号被认为是强信号。 -
state –``WiFiRSSINotify实例总是处于三种状态之一:创建时是kRSSIUnknown,然后是kRSSIWeak或kRSSIStrong。该状态用于在状态没有改变时消除多余的回调。 -
用于实现轮询的本地定时器。
-
the –对包含WiFiRSSINotify实例的 XS 虚拟机的引用。它用于从计时器中调用回调。 -
obj –对用于从计时器调用回调的WiFiRSSINotify对象的引用。这个字段的类型xsSlot,被 XS 用来保存任何 JavaScript 值。您已经知道的xsArg、xsVar和xsGet函数返回类型xsSlot的值。
以下部分提供了有关如何使用这些字段的更多详细信息。
为了方便起见,该实现还将RSSINotify定义为指向RSSINotifyRecord的指针:
typedef struct RSSINotifyRecord *RSSINotify;
构造函数
WiFiRSSINotify构造函数首先为RSSINotifyRecord结构分配存储空间。一旦这个结构被完全初始化,它就被使用xsmcSetHostData附加到对象上。通常,数据结构在初始化之前不会附加到对象上,以避免在构造函数执行期间发生错误时拥有部分初始化的结构。
RSSINotify rn = calloc(sizeof(RSSINotifyRecord), 1);
if (!rn)
xsUnknownError("no memory");
接下来,构造函数初始化state、the和obj字段:
rn->state = kRSSIUnknown;
rn->obj = xsThis;
rn->the = the;
构造函数执行几个可能失败的操作。当它们失败时,它们会抛出一个错误,调用 JavaScript 代码可以捕捉到这个错误。因为构造函数执行的第一个操作是分配内存,所以如果发生异常,它需要释放内存。如果不这样做,内存就会被孤立,导致内存泄漏,最终导致系统故障。为了防止这种情况,构造函数用xsTry包围这些操作,用xsCatch捕捉任何异常。捕捉到异常后,构造函数释放存储在rn中的内存,然后使用xsThrow再次抛出错误。在 C 语言中,xsTry和xsCatch使用结构如清单 11-17 所示。
xsTry {
...
}
xsCatch {
free(rn);
xsThrow(xsException);
}
Listing 11-17.
回想一下,C 中的 XS 提供了在 C 代码中访问和实现基本 JavaScript 功能的方法。xsTry - xsCatch的 C 代码类似于 JavaScript 版本的代码,如清单 11-18 所示。
try {
...
}
catch(e) {
...
throw e;
}
Listing 11-18.
xsTry块首先声明一个局部变量poll,以保存字典参数请求的轮询间隔,并使用xsmcVars为 JavaScript 栈上的临时值保留空间:
int poll;
xsmcVars(1);
如清单 11-19 所示,构造函数然后调用xsmcHas来查看字典参数是否包含poll属性。如果是,则检索属性,转换成整数,并赋给局部变量poll;否则,将使用默认值 5,000。
if (xsmcHas(xsArg(0), xsID_poll)) {
xsmcGet(xsVar(0), xsArg(0), xsID_poll);
poll = xsmcToInteger(xsVar(0));
}
else
poll = 5000;
Listing 11-19.
xsmcHas函数类似于 JavaScript 中使用的in操作符。前面的代码与清单 11-20 中的 JavaScript 代码大致相同。
let poll;
if ("poll" in options)
poll = options.poll;
else
poll = 5000;
Listing 11-20.
构造函数接下来再次调用xsmcHas,这次是为了确认所需的threshold属性是否存在。如果没有,它抛出一个错误;否则,将检索 JavaScript 的threshold属性,将其转换为整数,并分配给rn的threshold字段。
if (!xsmcHas(xsArg(0), xsID_threshold))
xsUnknownError("threshold required");
xsmcGet(xsVar(0), xsArg(0), xsID_threshold);
rn->threshold = xsmcToInteger(xsVar(0));
最后,xsTry模块使用来自可修改 SDK 的modTimerAdd分配一个本地定时器。您可以在这里使用另一个定时器机制,一个特定于您的微控制器的机制。为了方便起见,这段代码使用了modTimerAdd,因为它可用于 ESP32 和 ESP8266 设备。如果计时器不能被分配——例如,因为没有足够的可用内存——构造函数抛出一个异常。
rn->timer = modTimerAdd(1, poll, checkRSSI, &rn, sizeof(rn));
if (!rn->timer)
xsUnknownError("no timer");
对modTimerAdd的调用创建了一个定时器,它首先在 1 毫秒后触发,然后在poll指定的时间间隔触发。当定时器触发时,它调用checkRSSI本地函数,传递给它rn的值。后面一节将展示本机回调如何检索这个值并调用 JavaScript 回调。
这就是xsTry块的结尾。即使在这个相对简单的对象中,也有两个异常是由构造函数本身生成的。此外,当传递一个不能转换成整数的值时,对xsmcToInteger的调用会抛出异常。这些潜在的异常使得构造函数确保在抛出异常时没有内存或其他资源成为孤儿变得非常重要。使用xsTry和xsCatch通常有助于解决这个问题。
构造函数中还剩下两个步骤。第一个是用对象存储rn数据指针:
xsmcSetHostData(xsThis, rn);
第二是确保只有在 JavaScript 代码对对象调用了close之后,对象才被垃圾收集。对于支持回调的 JavaScript 宿主对象来说,这种行为很常见。为此,构造函数用存储在RSSINotifyRecord中的对象调用xsRemember函数。
xsRemember(rn->obj);
你只能在你的代码分配的存储器中传递一个值。如果您用诸如xsThis、xsArg(0)、xsVar(1)或其他 XS 提供的值来调用xsRemember,它会悄悄地失败。如您所料,有一个相应的xsForget调用需要在close中调用。存储对象的内存,这里是rn->obj,必须持续到xsForget被调用,因此不能是构造函数中的局部变量。
析构函数
WiFiRSSINotify(清单 11-21 )的析构函数类似于本章中的其他析构函数,只是增加了代码来释放在构造函数中分配的计时器。为了访问RSSINotifyRecord结构中的定时器,数据指针参数被转换成一个RSSINotify指针。当rn为非NULL时,构造函数实现保证timer字段永远不会成为析构函数中的NULL。因此,在调用modTimerRemove之前,不需要检查rn->timer是否为非NULL。
void xs_wifirssinotify_destructor(void *data)
{
RSSINotify rn = data;
if (rn) {
modTimerRemove(rn->timer);
free(rn);
}
}
Listing 11-21.
close功能
WiFiRSSINotify(清单 11-22 )的close方法也遵循一种熟悉的模式。然而,除此之外,它必须调用xsForget来使对象符合垃圾收集的条件,抵消对构造函数中xsRemember的调用。因为对xsForget的调用访问rn的obj字段,所以close实现必须通过检查xsmcGetHostData是否返回非NULL值来防止被调用多次。
void xs_wifirssinotify_close(xsMachine *the)
{
RSSINotify rn = xsmcGetHostData(xsThis);
if (rn) {
xsForget(rn->obj);
xs_wifirssinotify_destructor(rn);
xsmcSetHostData(xsThis, NULL);
}
}
Listing 11-22.
对xsForget的调用不能在析构函数中进行,因为析构函数不能在 C 中使用 XS,如前所述。
回电
清单 11-23 中显示的checkRSSI函数是WiFiRSSINotify类的核心。它在轮询间隔被调用,以检测 RSSI 值何时超过指定的阈值。该函数从恢复rn的值开始,该值是指向在构造函数中分配的RSSINotifyRecord结构的指针。因为checkRSSI回调不是由 XS 直接调用的,而是由modTimer调用的,所以指针不能像往常一样使用xsmcGetHostData来检索,而是通过解引用refcon参数来检索。
void checkRSSI(modTimer timer, void *refcon, int refconSize)
{
RSSINotify rn = *(RSSINotify *)refcon;
...
}
Listing 11-23.
下一步是获取当前 RSSI 值,这在 ESP32 和 ESP8266 上以不同方式完成。清单 11-24 对每个目标都有条件情况,对其他目标有一个错误。
int rssi = 0;
#if ESP32
wifi_ap_record_t config;
if (ESP_OK == esp_wifi_sta_get_ap_info(&config))
rssi = config.rssi;
#elif defined(__ets__)
rssi = wifi_station_get_rssi();
#else
#error Unsupported target
#endif
Listing 11-24.
如清单 11-25 所示,轮询函数使用当前的 RSSI 值来决定是否需要调用onStrongSignal或onWeakSignal JavaScript 回调函数。它检查当前值是高于还是低于存储在rn->threshold中的指定阈值。如果 RSSI 值与前一次检查在阈值的同一侧,checkRSSI立即返回;否则,它会将rn->state更新为新状态,并将要调用的回调的 IDxsID_onStrongSignal或xsID_onWeakSignal赋给局部变量callbackID。
if (rssi > rn->threshold) {
if (kRSSIStrong == rn->state)
return;
rn->state = kRSSIStrong;
callbackID = xsID_onStrongSignal;
}
else {
if (kRSSIWeak == rn->state)
return;
rn->state = kRSSIWeak;
callbackID = xsID_onWeakSignal;
}
Listing 11-25.
从本机代码调用 JavaScript 函数需要有效的 JavaScript 栈框架。当 JavaScript 调用本地方法时,XS 已经创建了栈框架。checkRSSI函数不是由 XS 调用的,而是由modTimer调用的,因此必须自己设置栈框架。它通过在回调之前调用xsBeginHost来做到这一点。它随后调用xsEndHost来移除xsBeginHost创建的栈帧。这两个函数都将引用 JavaScript 虚拟机的the作为唯一的参数。在xsBeginHost和xsEndHost之间,你可以照常用 C 打电话到 XS。
清单 11-26 中的代码使用xsmcVars(1)创建一个临时的 JavaScript 变量,并使用xsmcSetInteger为其分配一个整数值rssi。然后它调用xsmcHas来确认对象有回调函数。如果是,它使用xsCall调用回调函数,传递存储在xsVar(0)中的 RSSI 值。
xsBeginHost(rn->the);
xsmcVars(1);
xsmcSetInteger(xsVar(0), rssi);
if (xsmcHas(rn->obj, callbackID))
xsCall1(rn->obj, callbackID, xsVar(0));
xsEndHost(rn->the);
Listing 11-26.
使用xsCall1调用带有一个参数的函数(使用xsCall0调用不带参数的函数,使用xsCall2调用带有两个参数的函数,依此类推,直到xsCall9)。
其他技术
现在,您已经知道如何从 JavaScript 代码中调用本机代码,以及如何从本机代码中调用 JavaScript 代码,这使您能够以最适合您的项目的方式集成本机代码和脚本。本节简要介绍了几个重要的主题,在将本机代码集成到您自己的 JavaScript 支持的产品中时,您可能会发现这些主题很有用。除了讨论帮助您在本机代码和 JavaScript 代码之间搭建桥梁的各种技术之外,它还包括一些常见错误的警告。
调试本机代码
随着您开发越来越复杂的本机代码,您可能需要调试这些代码。尽管您可能没有可用的本机调试器,但您的代码可以与xsbug交互。
一种常见的调试技术是将诊断输出发送到调试控制台。在嵌入式 JavaScript 中,您使用trace来做这件事。在 C 中使用 XS,你可以用xsTrace做同样的事情。
xsTrace("about to get RSSI\n");
xsTrace的参数是一个字符串,便于输出函数的进度。如果您需要输出更详细的信息,使用xsLog,它提供了printf风格的功能。
xsLog("RSSI is %d.\n", rssi);
xsTrace和xsLog都需要有效的 XS 栈帧;因此,它们必须要么从 XS 直接调用的方法中调用,要么在xsBeginHost - xsEndHost对之间调用。例如,要从checkRSSI回调中将当前的 RSSI 级别输出到调试控制台,可以使用以下代码:
xsBeginHost(rn->the);
xsLog("RSSI is %d.\n", rssi);
xsEndHost(rn->the);
在本机代码的xsbug中触发一个断点会很有用,这样可以查看导致调用本机函数的栈帧以及传递给它的参数。虽然不能使用xsbug在本机代码中设置断点,但是可以通过在 C 代码中调用xsDebugger来触发断点。
xsDebugger();
访问全局变量
您的代码可以直接获取和设置全局变量的值。所有的全局变量都是全局对象的一部分,在 JavaScript 中使用globalThis来访问全局对象。在 C 语言的 XS 中,全局对象作为xsGlobal对本地代码可用。您可以像使用任何其他对象一样在本机代码中使用xsGlobal。例如,您使用xsmcSet*函数为一个全局变量赋值,下面几行代码将全局变量status设置为0x8012:
xsmcSetInteger(xsVar(0), 0x8012);
xsmcSet(xsGlobal, xsID_status, xsVar(0));
使用xsmcGet可以得到一个全局的值:
xsmcGet(xsVar(0), xsGlobal, xsID_status);
int status = xsmcToInteger(xsVar(0));
下面的代码检查是否有一个名为onRestart的全局变量。如果有,它调用存储在onRestart全局中的函数。
if (xsmcHas(xsGlobal, xsID_onRestart))
xsCall0(xsGlobal, xsID_onRestart);
获取函数的返回值
当您使用xsCall*函数族从 C 调用 JavaScript 函数时,您可以通过将结果赋给一个 JavaScript 值来访问返回值。例如,下面的代码调用this的callback属性上的函数,并将结果跟踪到控制台:
xsmcVars(1);
xsVar(0) = xsCall0(xsThis, xsID_callback);
xsTrace(xsVar(0));
获取值
本章中的例子使用xsmcToInteger从 JavaScript 值中获取一个整数值。从 JavaScript 值中获取布尔值、浮点数、字符串和ArrayBuffer也有类似的函数,如清单 11-27 所示。
uint8_t boolean = xsmcToBoolean(xsArg(0));
double number = xsmcToNumber(xsArg(1));
const char *str = xsmcToString(xsArg(2));
uint8_t *buffer = xsmcToArrayBuffer(xsArg(3));
int bufferLength = xsmcGetArrayBufferLength(xsArg(3));
Listing 11-27.
如果 JavaScript 值不能转换成请求的类型,所有这些函数都会失败。例如,如果值是字符串,xsmcToArrayBuffer将失败。
使用指向字符串的指针和ArrayBuffer指针时需要特别小心。有关详细信息,请参见“确保缓冲区指针有效”一节。
设置值
您已经看到了如何使用xsmcSetInteger将 JavaScript 属性设置为整数值。此外,还有用于设置其他基本 JavaScript 值的xsmcSet*函数,如清单 11-28 所示。
xsmcSetNull(xsResult);
xsmcSetUndefined(xsVar(0));
xsmcSetBoolean(xsVar(2), value);
xsmcSetTrue(xsVar(3));
xsmcSetFalse(xsResult);
xsmcSetNumber(xsResult, 1.2);
xsmcSetString(xsResult, "off");
const char *string = "a dog!";
xsmcSetStringBuffer(xsResult, string + 2, 3); // "dog"
Listing 11-28.
您也可以在 c 中使用 XS 创建对象。下面的代码创建一个 16 字节的ArrayBuffer对象,并将第一个字节设置为 1:
xsmcSetArrayBuffer(xsResult, NULL, 16);
uint8_t *buffer = xsmcToArrayBuffer(xsResult);
buffer[0] = 1;
清单 11-29 创建一个对象并给它添加几个属性。使用这种方法,您的代码可以像File类的next方法一样返回对象。
xsmcSetNewObject(xsResult);
xsmcSetString(xsVar(0), "test.txt");
xsmcSet(xsResult, xsID_name, xsVar(0));
xsmcSetInteger(xsVar(0), 1024);
xsmcSet(xsResult, xsID_length, xsVar(0));
Listing 11-29.
该代码的 JavaScript 等效代码如下:
return {name: "test.txt", length: 1024};
清单 11-30 创建一个包含八个元素的数组,并使用xsmcSet将每个数组元素设置为其索引的平方。你已经看到了xsmcSet用于设置一个对象的属性值;这里它用来通过传递元素的索引而不是一个xsID_*风格的符号标识符来设置数组元素的值。
xsmcSetNewArray(xsResult, 8);
for (i = 0; i < 8; i++) {
xsmcSetInteger(xsVar(0), i * i);
xsmcSet(xsResult, i, xsVar(0));
}
Listing 11-30.
确定值的类型
您的本机代码有时需要知道 JavaScript 值的类型。例如,一些函数根据参数是对象还是数字来改变它们的行为。您使用xsmcTypeOf来确定一个值的基本类型。
int typeOf = xsmcTypeOf(xsArg(1));
if (xsStringType == typeOf)
...;
xsmcTypeOf返回的类型有xsUndefinedType、xsNullType、xsBooleanType、xsIntegerType、xsNumberType、xsStringType、xsReferenceType。其中大多数都直接对应于您已经熟悉的 JavaScript 类型。但是,请注意,整数和数字(浮点值)都有类型。虽然 JavaScript 本身对两者都使用了Number类型,但作为一种优化,XS 将它们存储为不同的类型。如果您的本地代码检查一个 JavaScript 值是否属于类型Number,它需要检查xsIntegerType和xsNumberType。
类型xsReferenceType对应于一个 JavaScript 对象。这个单一类型的常量用于所有 JavaScript 对象。您使用xsmcIsInstanceOf函数来确定对象是否是特定类的实例。类型xsmcIsInstanceOf类似于 JavaScript 的instanceof操作符。XS 为内置对象定义值,例如,xsArrayPrototype。如果本地方法的第一个参数是数组,下面的代码将变量isArray设置为 1,否则设置为 0:
int typeOf = xsmcTypeOf(xsArg(0));
int isArray = (xsReferenceType == typeOf) &&
xsmcIsInstanceOf(xsArg(0), xsArrayPrototype);
如果对象是指定类型的子类,xsmcIsInstanceOf函数返回true。例如,第二章中的“访问数据视图的值”一节将Header类定义为DataView的子类。将Header的实例传递给下面的调用会返回true:
if (xsmcIsInstanceOf(xsArg(0), xsDataViewPrototype))
...; // is a data view
XS 定义的其他可用的原型包括xsFunctionPrototype、xsDatePrototype、xsErrorPrototype和xsTypedArrayPrototype。有关完整列表,请参见可修改 SDK 中的xs.h头文件。
使用字符串
JavaScript 中经常使用字符串。因为 XS 以 UTF-8 编码存储字符串,所以在 c 语言中处理字符串很方便。以下是一些需要记住的细节:
-
你保证你从 XS 收到的字符串是有效的 UTF-8。您必须确保传递给 XS 的任何字符串也是有效的 UTF-8。
-
XS 将空字符(ASCII 0)视为字符串的结尾,所以不要在字符串中包含任何空字符。(因为 C 语言也使用空字符来结束字符串,所以这应该很熟悉。)您的代码可能不会故意创建无效的 UTF-8 字符串或在字符串中包含空字符,但当您从文件或网络连接中导入字符串时,它们可能会溜进来;在将这些字符串传递给 XS 之前,验证它们是一个很好的做法。
-
在 JavaScript 中,字符串是只读的。没有提供改变字符串内容的函数。您可以选择在本机代码中打破这条规则,但是不要这样做!这样做会打破 JavaScript 程序员所依赖的一个基本假设。此外,它可能会导致崩溃,因为一些字符串存储在只读闪存中,试图写入这些字符串会导致微控制器复位。
-
当您在 c 中使用 XS 进行其他调用时,从
xsmcToString返回的字符串指针可能会失效。下一节将解释细节。
确保缓冲区指针有效
当你调用xsmcToString或xsmcToArrayBuffer时,它们不返回数据的副本;它们返回一个指向 XS 数据结构的指针。这种行为在微控制器上很重要,在微控制器上制作一份拷贝所需的额外时间和内存是不可接受的。当您在 C 中调用 XS 导致垃圾收集器运行时,该指针可能会变得无效。垃圾收集器无法释放ArrayBuffer或字符串,因为它们正在被使用。但是,垃圾收集器在压缩内存堆时可能会移动数据结构,通过合并空闲空间区域来释放更多空间。
如以下方法所示,只要小心,就可以避免垃圾收集器压缩堆时出现任何问题:
-
永远不要在 C 中再次调用 XS 后使用 XS 返回的指针,这看起来很有挑战性,但是本章中所有的例子都是这样做的。
-
复制一份数据。虽然这种方法不是最佳的,但偶尔也是必要的。
当你使用指向字符串的指针和ArrayBuffer指针时,有两个函数会有所帮助。xsmcToStringBuffer函数类似于xsmcToString,但是它不是返回一个字符串指针,而是将字符串复制到一个缓冲区。如果缓冲区太小,无法容纳字符串,就会抛出一个RangeError错误。
char str[40];
xsmcToStringBuffer(xsArg(0), str, sizeof(str));
xsmcGetArrayBufferData函数将全部或部分ArrayBuffer复制到另一个缓冲区。第二个参数是开始复制数据的ArrayBuffer偏移量(以字节为单位),第三个参数是目标缓冲区,最后一个参数是目标缓冲区的大小(以字节为单位)。这个例子将从偏移量 10 开始的五个字节从一个ArrayBuffer复制到本地变量buffer。
uint8_t buffer[5];
xsmcGetArrayBufferData(xsResult, 10, buffer, sizeof(buffer));
与 C++集成
C 语言中的 XS 不仅能让你在 C 和 JavaScript 代码之间架起一座桥梁,还能让你在 C++和 JavaScript 代码之间架起一座桥梁。虽然 JavaScript 和 C++都支持对象,但是它们实现对象的细节和特性却大不相同。因此,试图在 C++类和 JavaScript 类之间建立直接映射通常是不现实的。相反,设计 JavaScript 类时要让 JavaScript 程序员明白,设计 C++类时要让 C++程序员明白。你用 C 语言中的 XS 编写的桥代码可以在两者之间进行转换。
使用线程
JavaScript 是单线程语言;因此,XS JavaScript 引擎也是单线程的。这意味着对单个 JavaScript 虚拟机的所有调用,如由the表示的对本地代码的调用,应该由同一个线程或任务进行。在 C 语言中,你不应该从一个中断或线程中调用 XS,而不是从创建虚拟机的线程中调用。
提供 JavaScript 代码多任务执行的技术,比如 Web Workers 类,是在 JavaScript 语言之外构建的。可修改的 SDK 支持 ESP32 上 Web Workers 类的子集,这使得几个 JavaScript 虚拟机可以共存,每个虚拟机都有自己的线程。每个虚拟机都是单线程的,但是几个机器可以并行运行。ESP32 的 Web Workers 实现考虑了每个单独的 JavaScript 虚拟机都是单线程的要求。
结论
使用 C API 中的 XS 在 JavaScript 和本机代码之间架起桥梁的能力为您的项目打开了许多新的可能性之门。它使您能够优化内存使用、提高性能、重用现有的 C 和 C++代码库,以及访问独特的硬件功能。然而,在 C 中使用 XS 比在 JavaScript 中要困难得多,因此也更容易出错。通常,尽可能少地使用本机代码有助于最小化风险。
为了帮助您了解更多关于在 C #中使用 XS 的知识,可以使用以下两个优秀的资源:
-
C #中的 XS 文档是对该 API 的完整参考。它是可修改 SDK 的一部分。
-
可修改的 SDK 中访问本机功能的所有类都是使用 c 中的 XS 实现的。如果您对它们的工作方式感到好奇,源代码可供您阅读和学习。


浙公网安备 33010602011771号