从零构建嵌入式菜单库(二):架构设计——从函数到结构体,从单文件到模块
从零构建嵌入式菜单库(二):架构设计——从函数到结构体,从单文件到模块
系列定位:这是一套编写教程——我们将一起从零构建一个基于 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 模块遵循:
- 头文件暴露接口(函数声明 + 结构体定义 + 宏),不暴露实现细节
- 源文件包含实现,用
static隐藏内部函数 - 每个 .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+ 行依然条理清晰。
下一篇,我们将进入第一个"硬核"模块:变量绑定系统——如何让菜单项直接编辑内存中的变量。
更多推荐
所有评论(0)