从零构建嵌入式菜单库(二):架构设计——从函数到结构体,从单文件到模块

系列定位:这是一套编写教程——我们将一起从零构建一个基于 U8g2 的嵌入式菜单库。
上一篇我们从一段原始原型出发,本文要解决原型暴露的第一个大问题:如何把散落的全局变量组织成良好的库结构。


前言:原型的遗产与债务

回顾上一篇的 oled_display_menu() 原型——它在一个函数里用 static 变量承载所有状态:

// 原型的"状态管理"——遍布全局作用域
static u8g2_uint_t _position = 0;
static u8g2_uint_t _rowHeight = 0;
u8g2_uint_t position = 0;
u8g2_uint_t spe = 3;

这段代码能跑——但只能跑一次。如果要支持两个独立的菜单实例(比如一个主菜单 + 一个设置面板),第二组 static 变量无处安放。

本文的目标:用结构体收纳状态,用多文件划分职责,建立菜单库的正式骨架。


知识点预备

1.1 结构体即"对象"

C 语言没有类,但结构体 + 函数指针可以实现类似面向对象的效果:

// 状态(数据)封装在结构体中
typedef struct {
    int x, y;
    int width, height;
} Rect;

// 方法(行为)接收结构体指针
void rect_draw(Rect *r) {
    // 通过 r-> 访问状态
    u8g2_DrawFrame(u8g2, r->x, r->y, r->width, r->height);
}

// 多实例
Rect r1 = {0, 0, 64, 32};
Rect r2 = {64, 32, 64, 32};
rect_draw(&r1);  // 两个独立实例互不干扰
rect_draw(&r2);

关键原则:方法接收指针,数据存结构体。

1.2 函数指针与多态

// 声明一个函数指针类型
typedef void (*draw_cb)(u8g2_t *);

// 注册不同的绘制函数
void draw_hello(u8g2_t *u8g2) { u8g2_DrawStr(u8g2, 0, 0, "Hello"); }
void draw_world(u8g2_t *u8g2) { u8g2_DrawStr(u8g2, 0, 0, "World"); }

// 运行时切换行为
draw_cb current_draw = draw_hello;
current_draw(u8g2);  // 输出 "Hello"
current_draw = draw_world;
current_draw(u8g2);  // 输出 "World"

这就是"选择器可替换"和"效果器可替换"的基础。

1.3 模块化原则

一个好的 C 模块遵循:

  1. 头文件暴露接口(函数声明 + 结构体定义 + 宏),不暴露实现细节
  2. 源文件包含实现,用 static 隐藏内部函数
  3. 每个 .c 文件职责单一

2. 核心设计决策

决策 1:结构体 u8g2_menu_t —— “万物归一”

原型的状态散落在 6 个全局变量中。最终库把它们收纳为一个结构体——这是整个架构最重要的一个决定

typedef struct u8g2_menu_struct u8g2_menu_t;

struct u8g2_menu_struct {
    u8g2_t *u8g2;                   // 绑定的 U8g2 实例
    menuItem_cb menuItem;           // 当前表项绘制回调
    menuSelector_cb menuSelector;   // 选择器绘制回调
    u8g2_menu_effect_t menuEffect;  // 动画效果实例

    u8g2_int_t currentItem;         // 当前选中的项索引
    u8g2_int_t currentDrawItem;     // 当前正在绘制的项索引
    u8g2_int_t currentSetValue;     // -1=未选中, 否则=当前项的编辑状态

    u8g2_int_t currentX, currentY;  // 菜单区域左上角
    u8g2_int_t currentWidth, currentHeight;  // 菜单区域尺寸
    u8g2_int_t totalLength;         // 内容总高度(用于滑块)

    float positionOffset;           // 水平滚动目标位置
    float _positionOffset;          // 水平滚动实际位置
    // ... 还有约 30 个成员
};

收益

  • 一个 u8g2_menu_t menu1, menu2; 就是两个完全独立的菜单实例
  • 所有 API 统一接收 u8g2_menu_t * 指针
  • memset(menu, 0, sizeof(...)) 一行代码完成初始化

代价

  • 结构体臃肿——约 40 个成员,内存占用约 500+ 字节
  • 新增功能就必须往结构体里加字段,ABI 不稳定

替代方案与取舍

方案 优点 缺点 决策
全局变量 零开销 单实例 ❌ 不支持
动态分配 灵活 malloc 不可靠(嵌入式) ❌ 不安全
结构体值传递 简单 拷贝成本高 ❌ 浪费
结构体指针传递 多实例 + 低开销 需手动管理生命周期 ✅ 采用

决策 2:回调注册制 —— “用户写绘制逻辑,库负责调度”

// 用户写:
void myMenuItem(void) {
    u8g2_MenuDrawUTF8("Settings");
    u8g2_MenuDrawUTF8("About");
}

// 库调用:
void u8g2_CreateMenu(u8g2_t *u8g2, u8g2_menu_t *u8g2_menu,
                      menuItem_cb menuItem)
{
    memset(u8g2_menu, 0, sizeof(u8g2_menu_t));
    u8g2_menu->u8g2 = u8g2;
    u8g2_menu->menuItem = menuItem;   // 注册回调
    // ...
}

void u8g2_DrawMenu(u8g2_menu_t *u8g2_menu, ...) {
    if (u8g2_menu->menuItem)
        u8g2_menu->menuItem();   // 调度回调
}

为什么是 void menuItem(void) 而不是传参数?

原型是传参数的:

u8g2_uint_t menuItem(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y, u8g2_uint_t rowHeight);

最终库改为无参:

typedef void (*menuItem_cb)(void);

原因:上下文由库通过隐式全局单例 currentMenu 提供,用户通过 u8g2_MenuGetCurrentMenu() 获取。这样用户写回调时不需要关心库内部的状态传递。

// 用户在回调里这样获取上下文:
void myMenuItem(void) {
    u8g2_menu_t *menu = u8g2_MenuGetCurrentMenu();
    u8g2_t *u8g2 = u8g2_MenuGetU8g2(menu);
    // 现在可以绘制了
}

收益:回调签名极简,扩展不破坏接口
代价:隐式全局 currentMenu,非纯函数,调试略费劲

决策 3:选择器与菜单项解耦

选择器(菜单项左侧的选中标识)被独立为一个 menuSelector_cb 回调:

typedef void (*menuSelector_cb)(u8g2_menu_t *u8g2_menu);

这意味着:

  • 可以运行时切换选择器样式(u8g2_MenuReplaceSelector
  • 用户可以完全自定义选择器
  • 选择器接收 u8g2_menu_t *,能获取菜单项坐标和状态
// 内置的默认选择器——圆形标识
void u8g2_MenuSelectorRotundity(u8g2_menu_t *u8g2_menu) {
    u8g2_t *u8g2 = u8g2_MenuGetU8g2(u8g2_menu);
    u8g2_MenuSetPosition(u8g2_menu, 16, 0, 0);  // 设置左边距

    u8g2_int_t x = u8g2_MenuGetX(u8g2_menu);
    u8g2_int_t y = u8g2_MenuGetY(u8g2_menu);
    u8g2_int_t h = u8g2_MenuGetH(u8g2_menu);

    switch (u8g2_MenuGetAttribute(u8g2_menu)) {
    case MENU_None:   break;                              // 未选中:不画
    case MENU_Fix:    u8g2_DrawCircle(u8g2, x-10, y+h/2, 5, ...); break;  // 空心
    case MENU_Writable: u8g2_DrawCircle(...);            // 双圈
                       u8g2_DrawCircle(...); break;
    case MENU_WritableSelect: u8g2_DrawDisc(...); break;  // 实心
    }
}

决策 4:多文件拆分——“按功能切模块”

原型是单文件。最终库拆分为 14 个 .c 文件:

u8g2_menu.c           核心调度(Create/Draw/Replace)
u8g2_meun_selector.c  选择器(默认/圆形/方形)
u8g2_meun_effect.c    动画效果(展开/滚动)
u8g2_meun_itemValue.c 变量绑定(12 种数值类型 + 特殊类型)
u8g2_meun_keys.c      按键处理(消抖/长按/键值映射)
u8g2_meun_event.c     事件系统(环形队列)
u8g2_meun_message.c   消息框
u8g2_meun_drawStr.c   字符串绘制(UTF8/密码/多行文本)
u8g2_meun_drawChart.c 图表绘制(折线/散点/柱状)
u8g2_meun_drawValueBar.c 滑块/进度条
u8g2_meun_drawPic.c   图片绘制(XBM/XBMP)
u8g2_meun_drawBoard.c 自定义画板
u8g2_meun_layer.c     图层叠加
u8g2_meun_weak.c      弱定义回调(10 个可重写钩子)

拆分原则

  • 每个 .c 包含一个 #include "u8g2_menu.h"
  • 模块间通过 u8g2_menu.h 中声明的 API 通信
  • 实现细节用 static 隐藏

收益:修改按键逻辑不需要看图表代码;新人读代码从 u8g2_menu.c 入口即可
代价:编译单位增多,链接稍慢;函数调用多了一层间接


3. 绘制调度:原型逻辑到正式 API 的映射

将原型的主绘制函数与最终库的 u8g2_DrawMenu 并排对比——核心逻辑不变,但组织形式截然不同:

原型

void oled_display_menu(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y,
                       u8g2_uint_t w, u8g2_uint_t h,
                       menuItem_cb menuItem)
{
    // 裁剪窗口
    u8g2_SetClipWindow(u8g2, x, y, x + w - 6, y + h);
    // 动画
    if (ABS(position - _position) > spe) { ... }
    // 行高
    if (_rowHeight < maxCharHeight) _rowHeight += 3;
    // 绘制
    totalLength = menuItem(u8g2, x, y - _position, _rowHeight);
    // 滑块
    u8g2_DrawVSliderBar(...);
}

最终库

void u8g2_DrawMenu(u8g2_menu_t *u8g2_menu, u8g2_uint_t x,
                    u8g2_uint_t y, u8g2_uint_t w, u8g2_uint_t h)
{
    currentMenu = u8g2_menu;          // 设置隐式全局上下文

    u8g2_menu->currentX = x;          // 记录区域参数
    u8g2_menu->currentY = y;
    u8g2_menu->currentWidth  = w - 6; // 留 6px 给滑块
    u8g2_menu->currentHeight = h;

    u8g2_menu->currentDrawItem = 0;
    u8g2_menu->totalLength = 0;

    // ← enter/leave 回调 + 事件处理在此插入

    if (u8g2_menu->menuItem)
        u8g2_menu->menuItem();    // 调用用户注册的回调

    // ← 子菜单跳转处理在此插入

    u8g2_menuMessageBoxCall(u8g2_menu);  // 消息框(后续文章)

    u8g2_menuEffect_run_call(u8g2_menu); // 动画推理

    // 边界检查 → 恢复裁剪 → 绘制滑块
    if (u8g2_menu->totalLength > h)
        u8g2_DrawVSliderBar(...);
    currentMenu = NULL;
}

可以看到骨架完全一致:裁剪 → 动画 → 绘制 → 滑块。但最终库在每个环节都留下了"插槽",供后续模块插入逻辑。


4. 菜单项绘制包围模型

原型里每一行靠 y += rowHeight 自增。最终库抽象为:

// 每一项的绘制被 Start/End 包围
u8g2_menu_t *menu = u8g2_MenuDrawItemStart();
// ... 绘制内容 ...
u8g2_MenuDrawItemEnd(menu);

Start 负责:更新 totalLength、记录选择器状态
End 负责:累加行间距、自增 currentDrawItem、恢复裁剪窗口、应用效果器

这样用户不需要手动管理 Y 坐标和裁剪——库全权负责几何布局。


5. 小结:架构设计的得与失

设计决策 收益 代价
结构体统一状态 多实例、API 统一 结构体臃肿
回调注册制 完全解耦 隐式全局上下文
选择器独立 可替换、可自定义 多一层间接调用
多文件拆分 职责清晰、可维护 编译单位增多
Start/End 包围模型 用户不关心几何 API 调用顺序耦合

核心教训:架构重构不改变功能,只改变组织形式。但好的组织形式决定了未来能走多远——原型的代码 200 行后就乱套,而最终库撑到了 3500+ 行依然条理清晰。

下一篇,我们将进入第一个"硬核"模块:变量绑定系统——如何让菜单项直接编辑内存中的变量。


上一篇:从零构建嵌入式菜单库(一):原型探索
下一篇:从零构建嵌入式菜单库(三):变量绑定系统——让菜单"活"起来

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐