为 M5StickC-Plus2 构建模块化入门套件:从混乱代码到清洁架构

发布: (2025年12月15日 GMT+8 05:35)
8 min read
原文: Dev.to

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_____          (侧面,反向)

经过多次布局测试,我最终采用了下面的方案:

状态PWRAB(短按)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,开始构建吧!

Back to Blog

相关文章

阅读更多 »

决策过多,策略不足

《Decisões demais, estratégia de menos》的封面图片 https://media2.dev.to/dynamic/image/width=1000,height=420,fit=cover,gravity=auto,format=auto/https%3A%2F%2F...