Arduino 中的设计模式应用






4.62/5 (10投票s)
一个使用Arduino的示例项目。
引言
我有一个上小学的女儿。作为父亲,我当然有一些责任。我喜欢当父亲,这一点我没问题。让我困扰的是那些没完没了的数学学习时间。有时真的让我抓狂。一遍又一遍地2+2,5+6,4+3。那是开始,现在轮到减法了:5-4,10-4……大家都知道这没完没了。我说够了,决定利用技术。你知道,技术是为了人们,现在是为了我。来吧,我亲爱的Arduino,我需要你。
最初我曾希望这个项目会很容易实现。我只需要编写一些函数来显示数字,可能再加一个蜂鸣器和一些LED让它更有趣。然而,在我开始 细致地 思考它之后,情况发生了变化。出现了一些硬件管理问题,然后是UI管理,接着是内容管理。我们这个小小的Arduino应用程序变成了一个需要更认真对待的东西,这促使我写了这篇文章。让我们从需求开始。
项目需求
- 系统应在显示屏上显示一个菜单,用于基本运算:加、减、乘、除。
- 用户(我的女儿)可以使用键盘从菜单中选择一个算术运算进行学习。
- 应有几个难度级别。选择运算后,显示屏上会显示难度级别选择。
- 根据难度级别,显示屏上会显示随机题目,用户可以使用键盘回答题目。
- 用户可以在输入答案前进行更正。
- 输入答案后,将显示一条消息,说明答案是否正确。
- 连续回答错误3次后,将显示正确答案。
- 用户可以浏览菜单(菜单向上或选择菜单项)。
- 系统应具有音频和视觉警告基础设施。消息将伴随这些基础设施。
- 每项运算都将有一个限时测验部分。
- 测验部分将开始随机提问,从最简单的级别开始,然后上升到更高的难度级别。
- 测验结束后将显示统计数据(提问多少道题,答对多少道)。
- 当她过来时,系统可以引起她的注意。
- 除了数学题之外,还应该有一些有趣的部分,引导用户做一些事情,比如“唱歌”、“亲吻你的爸爸”等等。否则,用户验收测试绝对会失败!
- 警告基础设施可以作为娱乐部分有趣的材料。
硬件
本项目需要什么
有关硬件设计,请参阅以下内容。
关于零件不匹配的注意事项
- LCD是串行LCD。
- Arduino UNO应替换为Mega。
(我开始这个项目时使用的是Arduino UNO,但由于内存需求更大,我决定继续使用Arduino Mega。起初,Arduino UNO一切都很好。然而,当代码开始变大时,过了某个点,我就无法将RAM使用量控制在Arduino UNO容量以下了,正如你所料,最终换成了Arduino Mega,它拥有8K的SRAM。)
软件设计
图1:设计概述。
系统可以分为两个主要部分。正如你在图1中看到的,第一部分负责硬件管理。
- 输入系统:我们有两种不同的键盘,它们被合并成一个,如其他组件所示。统一键盘在任何键盘(矩阵键盘或adkeyboard)按下任何按键时通知已注册的客户端。
- 输出系统:具有附加功能的串行LCD。
- 信号:统一信号子系统。它由LED和数字蜂鸣器组成,并提供为客户端代码定义不同信号模式的能力。客户端代码可以根据需要启动任何模式。
- 运动检测:带PIR传感器的运动检测能力。当检测到有人时,它会触发信号以引起注意。
表示层负责用户交互。它包含视觉项目(菜单和页面)的基础设施和内容管理子系统。
- UI管理:在此子系统中,定义了视觉项目。菜单在显示屏上列出项目供用户选择。菜单项可能代表子菜单或页面。用户可以通过按键盘上相应的键,使用显示的索引选择菜单项。通过按“Escape”键可以向上导航菜单。如果选定的菜单项是页面,则显示该页面。页面负责显示与之关联的内容。页面等待用户输入以更改其内容,如果按“Escape”键,则会显示所有者菜单。在页面中,按“Enter”键将评估答案。如果用户犯了错误,可以通过按“Backspace”键删除其答案。根据答案的正确性,会触发相应的信号。
- 内容管理:此子系统提供要在屏幕上显示的内容。提供了不同级别算术运算的算法。客户端代码(页面)从此子系统请求内容。
简化的类图如下。这些图像显示了基本结构,以便于理解实际的类实现。
硬件管理
MFK_InputDevice
将Keypad2
和AdKeyboard
合并为一个设备。它处理它们的事件,并为客户端生成一个新事件,其中包含一组新的按键,如下所示。
图2:输入子系统。
按键映射
键盘 | Button | 键 | 值(十六进制) |
矩阵 | 0 | '0' | 0x30 |
矩阵 | 1 | '1' | 0x31 |
矩阵 | 2 | '2' | 0x32 |
矩阵 | 3 | '3' | 0x33 |
矩阵 | 4 | '4' | 0x34 |
矩阵 | 5 | '5' | 0x35 |
矩阵 | 6 | '6' | 0x36 |
矩阵 | 7 | '7' | 0x37 |
矩阵 | 8 | '8' | 0x38 |
矩阵 | 9 | '9' | 0x39 |
矩阵 | * | 转义 | 0x1B |
矩阵 | # | Enter | 0x0D |
AD | S1 | 退格键 | 0x08 |
AD | S2 | F1 | 0x80 |
AD | S3 | F2 | 0x81 |
AD | S4 | F3 | 0x82 |
AD | S5 | F4 | 0x83 |
MFK_OutputDevice
派生自SerialLCD
类。它通过组合SerialLCD
的功能来增强该项目的SerialLCD
。
图3:输出子系统。
信号模式是通过使用信号源构建的。模式应与索引一起存储在信号控制器中。启动信号模式的唯一方法是通过其索引从信号控制器调用它。
图4:信号子系统。
在硬件管理层之上,放置了MFK_Hardware
类。它协调所有其他硬件设备,并向客户端隐藏冗余的复杂性。例如,PIRMotion和SignalController不向客户端公开。但是,输入和输出设备必须暴露给外部世界,因为UI系统需要直接访问它们的功能。信号模式也在该类中构建。它们可以通过其命名的索引准备好使用。
图5:硬件管理
演示
此层负责用户交互。它提供视觉元素和内容。
ContentFactory
根据ContentTypeEnum
和ContentLevelEnum
创建ContentProvider
。客户端获取内容工厂的实例,然后可以请求内容提供程序。
图6:内容管理
VisualItem
是视觉元素(菜单和页面)的基类。它还将硬件管理和表示绑定在一起。“show
”和“msgbox
”函数使用输出设备(MFK_OutputDevice
)和输入设备(MFK_InputDevice
)调用VisualItem
提供的回调函数。“msgbox
”函数还能够通过调用硬件(MFK_Hardware
)的“signal
”函数来启动信号模式。
Menu
顾名思义,提供一个菜单项列表供选择。用户可以通过其索引选择一个项目,菜单会显示该VisualItem
。
Page
是显示内容的视觉项。除了趣味内容外,它期望用户输入。用户输入答案,然后显示一条消息通知用户。消息显示后,需要从内容管理中获取新内容。
Chapter
是ContentProvider
和Page
之间的中间类。当第一次显示页面时,会创建其章节和相关的ContentProvider
。用户答案被定向到章节,并在章节中评估答案的正确性。Chapter
还保存了该页面学习会话的统计数据。FunChapter
是一种章节,用户不需要对其进行回答。QuizChapter
是限时章节。在测验章节中,会一直提问直到时间结束。
图7:UI管理
实现
希望系统的总体结构清晰。现在,是时候深入研究代码了,这正是本文标题的意义所在。
我想从MathForKid.ino开始。这是上传到Arduino Mega的主代码。
// File: MathForKid.ino
// hardware management
MFK_Hardware* hw;
// presentation
Menu* mainMenu;
void setup() {
// for debugging purposes
Serial.begin(9600);
// get the instance and initialize it
hw = MFK_Hardware::getInstance();
hw->begin();
// create user interface
CreateUI();
// show the main menu
mainMenu->show();
}
void loop() {
// update hardware
hw->update();
// update active visual item
VisualItem *v = VisualItem::getActiveItem();
if(v!=NULL)
v->update();
}
就这些了。在Arduino上运行你的应用程序。好吧,也许稍微解释一下会更好
正如我在“软件设计”部分的开头所说,我们有两个主要组件:一个用于硬件管理,一个用于表示。它们在代码顶部全局定义,并在“setup
”函数中进行初始化。 “loop
”函数调用代码来更新它们。
实际上,CreateUI
函数也在此文件中实现。它创建用户交互发生的用户界面。在此函数中创建mainMenu
、所有子菜单和所有页面,并为页面指定章节属性。
void CreateUI() { mainMenu = new Menu("main"); // addition Menu* m = new Menu("+"); mainMenu->addMenuItem(m); // level-1 page Page* p = new Page("L1"); p->setChapterProperties(Chapter::NormalChapter, \ ContentFactory::Addition, ContentFactory::Level1Content); m->addMenuItem(p); // level-2 page p = new Page("L2"); p->setChapterProperties(Chapter::NormalChapter, \ ContentFactory::Addition, ContentFactory::Level2Content); m->addMenuItem(p); ...
让我们继续讨论具体的应用设计模式。
正如你可能预料到的,MFK_Hardware
是**外观(facade)**模式的一个例子。它隐藏了底层硬件管理问题的复杂性,并为客户端提供了一个干净的接口。它也是一个**单例(singleton)**,因为在整个系统中它只有一个实例。为了实现这一点,它的构造函数、复制和赋值运算符在类的声明的私有部分被声明。
// File: MFK_Hardware.h
// private constructor to achieve singleton pattern
MFK_Hardware();
MFK_Hardware(MFK_Hardware const&); // copy disabled
void operator=(MFK_Hardware const&); // assigment disabled
你只能使用getInstance
静态函数来访问它,它在公共部分。
// File: MFK_Hardware.h
// static method to get the instance
static MFK_Hardware* getInstance() {
static MFK_Hardware hw;
return &hw;
};
来自 http://www.dofactory.com/Patterns/Patterns.aspx
- 外观(Facade):一个单一类,代表整个子系统(结构型模式)。
- 单例(Singleton):一个类,只能存在一个实例(创建型模式)。
MFK_InputDevice
也是**外观(facade)**模式的一个很好的例子。它将两个不同的输入设备(矩阵键盘和adkeyboard)合并为一个。此外,它还扮演着**观察者(observer)**模式的主题(subject)角色。它是一个类型为MFK_InputDeviceClient
的ClientOwner
,客户端可以向MFK_InputDevice
注册/注销。因此,当状态发生变化时(在这种情况下是按键),所有客户端都会收到通知。我在这个项目中很多地方都使用了这种模式。
// File: MFK_InputDevice.h
template<>
class MFK_InputDevice<MFK_InputDeviceClient>:
public ClientOwner<MFK_InputDeviceClient> {
private:
...
// informs registered clients
void informClients(char);
...
// File: MFK_InputDevice.cpp
void MFK_InputDevice<MFK_InputDeviceClient>::informClients(char c) {
for(int i=0; i<5; i++) {
if(this->client[i]!=NULL)
this->client[i]->invokeMFKInputCallback(c);
}
}
ClientOwner
实现了注册/注销函数。MFK_InputDeviceClient
覆盖了invokeMFKInputCallback
函数并将其自身注册到ClientOwner
。然后MFK_InputDevice
可以通过它们提供的回调函数通知它们。VisualItem
继承自MFK_InputDeviceClient
,因此视觉项可以注册到MFK_InputDevice
以观察它。
来自 http://www.dofactory.com/Patterns/Patterns.aspx
- 观察者(Observer):一种将变化通知给多个类的方法(行为型模式)。
ContentFactory
和ContentProvider
类也有一些有趣的特性。ContentFactory
是**单例(singleton)**,可以被认为是某种形式的**工厂(factory)**模式。它为客户端构建内容提供程序,但内容提供程序的具体实现根据客户端的需求而变化。
// File: ContentFactory.cpp
ContentProvider* ContentFactory::getContentProvider(ContentTypeEnum op,\
ContentLevelEnum level) {
if(this->contentProvider[op][level] != NULL)
return this->contentProvider[op][level];
ContentProvider *p=NULL;
switch(op) {
case Addition:
switch(level) {
case Level1Content:
p = new ContentProvider(ContentP_0_0);
break;
...
return p;
}
来自 http://www.dofactory.com/Patterns/Patterns.aspx
- 工厂(Factory):创建多个派生类实例(创建型模式)。
ContentProvider
可以被认为是与Chapter
一起的**策略(strategy)**模式的一个例子。章节是内容提供程序所在的上下文。ContentProvider
为每个实例提供不同的算法结果(在这种情况下是问题和答案)。
// File: Chapter.cpp
char* Chapter::next() {
char *retval=NULL;
...
retval = this->contentP[0]->getContent(Chapter::CONTENT, \
Chapter::ANSWER);
...
return retval;
}
来自 http://www.dofactory.com/Patterns/Patterns.aspx
- 策略(Strategy):将算法封装在一个类中(行为型模式)。
结论
你知道,开发应用程序不仅仅是编码。更好的需求收集 leads to 更好的设计。更好的设计 leads to 更好的软件,而“更好”在许多软件工程书籍中都有定义(这不是本文的主要主题)。为了开发更好的软件,最好了解设计模式。(顺便问一下,这段话里有多少个“更好”?)
顾名思义,设计模式是解决一类问题的方案的泛化。因此,为了不重复造轮子,了解设计模式的基本知识可以使我们的工作更轻松。
然而,将设计模式应用于问题与了解它不同。你不能强迫你的问题去匹配设计模式。当你试图将你的实际问题变成别的东西时,情况会变得更糟。我所做的是根据我的需求量身定制模式。如果我在当前问题域中不需要某个结构,我不会因为对未来版本的期望而做任何事情。你可能认为这个陈述非常明显,但我见过许多项目因此而延期。在软件开发中,时间不仅仅是金钱,而是字面意义上的金钱。你过度的任何行为都会导致金钱损失,无论是对你还是对你的客户。
在codeproject上有很多关于设计模式的好文章。我特别推荐你阅读
我将继续研究这个项目。我知道有些地方可以做得更好,任何建议都将不胜感激。(趣味部分仍然缺失,我不得不注释掉它们,因为flash
库 flash lib或我的代码中的调用存在问题。)
参考文献
- http://www.dofactory.com/Patterns/Patterns.aspx
- http://www.cplusplus.com/doc/tutorial/
- http://arduino.cc/
- http://arduiniana.org/libraries/flash/
历史
这是一个正在进行中的项目。源代码可能在本文发布后发生更改。你可以从 https://github.com/ozanoner/arduino 下载最新版本。