为 M5StickC-Plus2 构建模块化入门套件:从混乱代码到清洁架构
Source: Dev.to
为什么要再做一个 M5Stack 项目?
当我第一次拿到 M5StickC‑Plus2 时,我很兴奋想做点酷东西。
但和很多开发者一样,我很快就被一堆枯燥的初始化工作卡住了:配置按钮、管理显示坐标、处理菜单、处理电源管理、设置定时器……在我能开始有趣的部分之前,我已经写了上百行基础设施代码。
现有库的问题
- 黑盒子 – 你看不到也无法修改内部实现。
- 过度抽象 – 有时需要细粒度的控制。
- 学习曲线 – 每个库都有自己的 API。
- 依赖过多 – 一个库会拉进很多其他库。
你能得到的
- ✅ 完整的源代码,随时阅读和理解
- ✅ 可修改的示例代码
- ✅ 可扩展的架构模式
- ✅ 没有隐藏的依赖或魔法
如果你不喜欢菜单滚动的方式?改它。想要不同的颜色?修改显示处理器。需要不同的按钮布局?更新控制逻辑。
切换到 VSCode + PlatformIO
我最初在 Arduino IDE(很多人也是这么做的)里开始,但很快就切换到了 VSCode + PlatformIO。原因如下:
// 这个函数定义在哪里?
// 它来自哪个库?
// 祝你好运去找它...
M5.Lcd.setCursor(10, 80);
- IntelliSense – 实际可用的自动补全
- 转到定义 – 跳转到任意函数的源码
- 项目结构 – 合理的文件组织(对我来说是最大的问题)
- 库管理 – 清晰的依赖处理
- 现代 C++ – 完整的 C++11/14/17 支持
这次切换花了一个小时,却在之后为我省下了数十个小时。
页面管理
一开始我需要多个“屏幕”或“页面”。最朴素的做法很快就变得乱七八糟:
// ❌ 乱糟糟的写法
if (currentPage == 0) {
drawClock();
} else if (currentPage == 1) {
drawMenu();
} else if (currentPage == 2) {
drawSettings();
}
引入 PageManager
class PageBase {
public:
virtual void setup() = 0; // 进入页面时调用
virtual void loop() = 0; // 每帧调用
virtual void cleanup() = 0; // 离开页面时调用
};
每个页面现在自行管理:
void ClockPage::setup() {
display->clearScreen();
clockHandler->drawClock(0);
}
void ClockPage::loop() {
if (hasActiveMenu()) return; // 菜单打开时暂停
// 每秒更新一次时钟
}
经验教训: 给每个组件自己的生命周期。不要把所有东西都放在 main() 里管理。
使用栈管理菜单
菜单出乎意料地难。我需要:
- 主菜单
- 子菜单(例如,设置 → 显示设置 → 亮度)
- 一个能够正确工作的“返回”按钮
基于栈的 MenuManager
class MenuManager {
private:
MenuHandler* menuStack[MAX_MENU_STACK];
int stackSize = 0;
public:
void pushMenu(MenuHandler* menu) {
menuStack[stackSize++] = menu;
menu->draw();
}
void popMenu() {
if (stackSize == 0) return;
--stackSize;
if (stackSize > 0) {
menuStack[stackSize - 1]->draw(); // 重绘上一个菜单
}
}
};
导航结果:
Clock Page
→ 打开菜单
→ 设置
→ 显示
→ [返回]
→ [返回]
→ [返回]
Clock Page (恢复)
经验教训: 选对数据结构。栈天然适合处理嵌套导航。
使用 std::function 的回调
我想要能够访问类成员的菜单回调:
// 期望的写法:
mainMenu->addItem("Start Timer", [this]() {
clockHandler->startTimer(); // 访问类成员
});
旧的 MenuItem 使用 C 风格的函数指针,无法捕获 this:
// ❌ 旧写法
struct MenuItem {
void (*callback)(); // 不能捕获 'this'!
};
现代 C++ 解决方案
// ✅ 新写法
#include <functional>
struct MenuItem {
std::function<void()> callback; // 可以捕获任何东西!
};
现在 lambda 可以工作了:
mainMenu->addItem("Settings", [this]() {
openSettingsSubmenu(); // 捕获了 'this',完美运行
});
经验教训: 在现代 C++ 中使用 std::function 进行回调。它灵活且支持带捕获的 lambda。
显示抽象
早期代码底层且重复:
// ❌ 这到底是什么意思?
M5.Lcd.setCursor(10, 80);
M5.Lcd.setTextSize(3);
M5.Lcd.print("Hello");
我引入了 DisplayHandler,提供语义化的方法:
display->displayMainTitle("Hello");
display->displaySubtitle("Subtitle");
display->displayStatus("Ready", MSG_SUCCESS);
实现示例:
void displayMainTitle(const char* text, MessageType type) {
M5.Lcd.setTextSize(SIZE_TITLE);
M5.Lcd.setTextColor(getColorForType(type));
int x = (SCREEN_WIDTH - textWidth) / 2; // 自动居中
int y = ZONE_CENTER_Y - 20;
M5.Lcd.setCursor(x, y);
M5.Lcd.print(text);
}
经验教训: 抽象底层细节。以后你会感谢自己的远见。
深度睡眠电源管理
M5StickC‑Plus2 若正确使用深度睡眠可以实现极佳的续航。
失效的尝试
// ❌ 唤醒时崩溃
esp_deep_sleep_start();
问题在于:GPIO4 必须在睡眠期间保持 HIGH,否则设备会失去电源。
可行的方案
void M5deepSleep(uint64_t microseconds) {
// 关键:保持电源引脚高电平
pinMode(4, OUTPUT);
digitalWrite(4, HIGH);
gpio_hold_en(GPIO_NUM_4);
gpio_deep_sleep_hold_en();
esp_sleep_enable_timer_wakeup(microseconds);
esp_deepSleep_start();
}
这可以实现番茄钟:睡眠 25 分钟,唤醒后响铃并显示时钟。
经验教训: 硬件特有的怪癖需要对应的硬件解决方案。不要以为标准 API 能直接使用。
按钮布局
M5StickC‑Plus2 有三个按钮:
______PWR (侧面)
A (正面)
___B_____ (侧面,反向)
经过多次布局测试,我最终采用了下面的方案:
| 状态 | PWR | A | B(短按) | B(长按) |
|---|---|---|---|---|
| 无菜单 | 切换页面 | 打开菜单 | 页面特定操作(如启动计时器) | – |
| 菜单激活 | 向下导航 | 选中项 | 向上导航 | 关闭菜单 |
为何这样布局?
- 侧面按钮用于导航:在握持设备时更易按下
- 中间按钮用于操作:最重要的功能放在最佳位置
- 长按用于“返回”:防止误操作
经验教训: 按钮的人体工学非常重要。一定要在真实硬件上测试,而不是只在脑子里想象。
项目结构
lib/
├── display_handler.h # 显示抽象
├── menu_handler.h # 单个菜单逻辑
├── menu_manager.h # 菜单栈
├── page_manager.h # 页面生命周期
├── clock_handler.h # 时间与计时器
├── battery_handler.h # 电源管理
└── pages/
├── page_base.h # 抽象基类
└── clock_page.h # 默认时钟页面
命名约定
*_handler.h– 专注、可复用的组件*_manager.h– 复杂的编排*_utils.h– 工具函数
每个页面都会收到 DisplayHandler* 和 MenuManager*。它们 组合 功能,而不是通过继承显示逻辑。
最后感想
- 避免使用 C 风格函数指针 作为回调;改用
std::function。 - 尽早测试电源管理——它依赖硬件,容易导致整个项目崩溃。
- 在写代码前先画出按钮布局;后期改动会波及整个项目。
- 从 VSCode + PlatformIO 起步;迁移的收益会很快显现。
完整的 starter kit 已在 GitHub 上发布。克隆仓库,烧录到你的 M5StickC‑Plus2,开始构建吧!