从零构建嵌入式菜单库(一):原型探索——从一段单函数代码开始
从零构建嵌入式菜单库(一):原型探索——从一段单函数代码开始
系列定位:这是一套编写教程——我们将一起从零构建一个基于 U8g2 的嵌入式菜单库,分析每一步的设计决策、收益与代价。
最终产物:u8g2_menu,一个 3500+ 行、14 模块、12 示例工程的开源菜单库。
前言:在一切开始之前
2024 年 6 月,我面对一块 128×64 的 OLED 屏幕和几个按键。U8g2 已经正常驱动这块屏幕,能画线、画圆、显示字符。但仅此而已——没有菜单系统,没有页面切换,没有任何交互框架。
当时的代码大概是这样的:
// 主循环里直接硬编码
u8g2_ClearBuffer(&u8g2);
u8g2_DrawStr(&u8g2, 0, 10, "1. Settings");
u8g2_DrawStr(&u8g2, 0, 30, "2. About");
u8g2_DrawStr(&u8g2, 0, 50, "3. Exit");
u8g2_SendBuffer(&u8g2);
每加一个页面就要在主循环里塞一堆 if/else,上下翻页靠全局变量 currentPage 来回切——不出三天,main.c 就变成了意大利面条。
我需要一个菜单库。但我不想只是"用"一个菜单库——我想写一个菜单库,并且把这个过程记录下来。
知识点预备
在阅读本文之前,需要先理解几个概念。
1.1 U8g2 的绘制模型
U8g2 是一个面向帧缓冲的图形库。它不是"画一根线屏幕就立刻显示",而是:
ClearBuffer() → [绘制操作] → SendBuffer()
所有绘制操作(DrawStr、DrawLine、DrawBox 等)都作用在一个内存缓冲区上,最后调用 SendBuffer() 一次性推送到屏幕。这带来一个关键约束:每一帧的绘制逻辑必须集中完成。
1.2 裁剪窗口 (Clip Window)
U8g2 提供 u8g2_SetClipWindow(u8g2, x0, y0, x1, y1),限制绘制操作只在指定矩形区域内生效。这是实现"菜单在固定窗口内滚动"的基础。
u8g2_SetClipWindow(u8g2, 0, 0, 128, 64); // 只在屏幕范围内绘制
u8g2_DrawStr(u8g2, 0, 80, "hidden"); // 超出裁剪区,不会显示
u8g2_SetMaxClipWindow(u8g2); // 恢复全屏裁剪
1.3 回调函数 (Callback)
回调函数就是把函数指针作为参数传递,让被调用者在合适的时机"回调"这个函数。在 C 中这样声明:
// 声明一个函数指针类型
typedef u8g2_uint_t (*menuItem_cb)(u8g2_t *, u8g2_uint_t, u8g2_uint_t, u8g2_uint_t);
// 接收这个函数指针
void oled_display_menu(..., menuItem_cb menuItem) {
totalLength = menuItem(u8g2, x, y, rowHeight); // 不确定调用的是哪个函数
}
这就是菜单库"框架"与"业务逻辑"解耦的基石。
2. 原型代码:一段能跑的单函数菜单
以下是比仓库第一次正式提交更早的原型。它只有一个函数,所有逻辑混在一起,但它能跑——这就是一切的开端。
char outBuf[64];
#ifndef ABS
# define ABS(s) ((s) < 0 ? -(s) : (s))
#endif
u8g2_uint_t position = 0; // 目标滚动位置
u8g2_uint_t spe = 3; // 滚动速度
u8g2_uint_t maxCharHeight = 0; // 最大字符高度
u8g2_uint_t totalLength; // 菜单内容总高度
u8g2_uint_t windowHeight = 0; // 菜单窗口高度
// 菜单内容绘制回调
u8g2_uint_t menuItem(u8g2_t *u8g2, u8g2_uint_t x,
u8g2_uint_t y, u8g2_uint_t rowHeight)
{
sprintf(outBuf, "c:%d", count);
u8g2_DrawStr(u8g2, x, y += rowHeight, outBuf);
sprintf(outBuf, "t:%d", timer);
u8g2_DrawStr(u8g2, x, y += rowHeight, outBuf);
return y; // 返回最后一行的 Y 坐标
}
// 垂直滑块条
void u8g2_DrawVSliderBar(u8g2_t *u8g2, u8g2_uint_t x, u8g2_uint_t y,
u8g2_uint_t w, u8g2_uint_t h, float schedule)
{
if (schedule > 1) schedule = 1;
if (schedule < 0) schedule = 0;
u8g2_DrawVLine(u8g2, x + w / 2, y, h);
u8g2_DrawBox(u8g2, x, y + h * 0.7 * schedule, w, h * 0.3);
}
// 翻页
void pageUp() { if (position) position -= maxCharHeight; }
void pageDown() {
if (position < totalLength - windowHeight)
position += maxCharHeight;
}
// 主绘制函数
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)
{
static u8g2_uint_t _position = 0; // 当前实际滚动位置
static u8g2_uint_t _rowHeight = 0; // 当前实际行高
if (w < 10) return;
// 第一步:设置裁剪窗口
u8g2_SetClipWindow(u8g2, x, y, x + w - 6, y + h);
// 第二步:平滑滚动动画
if (ABS(position - _position) > spe) {
if (position > _position) _position += spe;
if (position < _position) _position -= spe;
} else {
_position = position;
}
// 第三步:行高动画
maxCharHeight = u8g2_GetMaxCharHeight(u8g2);
if (_rowHeight < maxCharHeight) _rowHeight += 3;
if (_rowHeight > maxCharHeight) _rowHeight -= 1;
// 第四步:绘制菜单内容
totalLength = menuItem(u8g2, x, y - _position, _rowHeight)
+ _position - y;
windowHeight = h;
// 第五步:恢复裁剪
u8g2_SetMaxClipWindow(u8g2);
// 第六步:绘制垂直滑块
if (totalLength > h) {
u8g2_DrawVSliderBar(u8g2, x + w - 5, y, 5, h,
(float)_position / (totalLength - h));
}
}
void oled_display(u8g2_t *u8g2) {
oled_display_menu(u8g2, 0, 0, 128, 32, menuItem);
}
3. 逐段拆解:每一行在做什么
3.1 菜单内容回调——“行模型”
u8g2_uint_t menuItem(u8g2_t *u8g2, u8g2_uint_t x,
u8g2_uint_t y, u8g2_uint_t rowHeight)
{
sprintf(outBuf, "c:%d", count);
u8g2_DrawStr(u8g2, x, y += rowHeight, outBuf);
return y;
}
设计思路:把菜单的每一行抽象为"按给定 Y 坐标和行高绘制"。回调函数不需要知道滚动位置,只需要在传入的 y 坐标上逐行绘制,然后返回最后的 Y。totalLength 由这个返回值反算。
优点:
- 简单直观,一个函数指针搞定
- 调用者完全控制绑定的上下文变量(
count、timer等)
缺点:
- 返回 Y 坐标的方式过于原始——如果回调里要绘制不同高度的菜单项,调用者得自己算每行间距
sprintf每次都要手动拼字符串,类型不安全
这个"行模型"后来被重构为
menuItem_cb的void返回 +u8g2_MenuDrawItemStart/End的包围模式。
3.2 平滑滚动动画——追击算法
if (ABS(position - _position) > spe) {
if (position > _position) _position += spe;
if (position < _position) _position -= spe;
} else {
_position = position;
}
知识点:这是一个最简单的"线性追击"算法。position 是目标位置,_position 是当前实际显示位置。每次调用时 _position 向 position 逼近 spe 个单位。
时间轴: t0 t1 t2 t3 t4
目标: 100 100 100 100 100
实际: 0 3 6 9 12 ... 最终追到 100
优点:
- 计算量极小(三次比较 + 一次加减)
- 效果自然——加速启动、减速停止
缺点:
- 追到目标后就"粘住"了,没有弹性或回弹(但这对于菜单来说反而是优点)
spe是固定步长,长距离滚动时速度恒定,不够平滑
演化:最终库中这个逻辑被封装进 u8g2_menu_effect_t 的 run 回调,支持替换。
3.3 行高动画——手写的展开/收起
if (_rowHeight < maxCharHeight) _rowHeight += 3; // 展开
if (_rowHeight > maxCharHeight) _rowHeight -= 1; // 收起(更慢)
这里 +3 和 -1 的不对称设计是有意的:菜单展开要快(用户想看到内容),收起稍慢(留一点视觉残留)。
缺点:+3 和 -1 是魔法数字,不可配置,不可替换。这是原型最需要重构的部分之一。
3.4 垂直滑块条——位置映射
u8g2_DrawVSliderBar(u8g2, x + w - 5, y, 5, h,
(float)_position / (totalLength - h));
滑块位置 = 当前滚动位置 / 可滚动总范围。这是一个归一化到 [0, 1] 的简单映射,最终库中保留了这个核心公式。
4. 原型暴露的核心问题清单
带着这个原型跑了几天后,以下问题开始变得无法忍受:
| # | 问题 | 症状 | 根因 |
|---|---|---|---|
| 1 | 单实例 | 不能同时有两个菜单 | static 全局变量 |
| 2 | 类型混乱 | 变量修改逻辑散落在回调中 | 没有统一的变量绑定接口 |
| 3 | 魔法数字 | +3/-1/spe=3 |
动画硬编码 |
| 4 | 无导航 | 子菜单靠全局变量手动管理 | 没有调用链追溯 |
| 5 | 按键耦合 | pageUp/pageDown 裸函数 |
没有按键抽象层 |
| 6 | 字符串拼装 | sprintf(outBuf, ...) |
没有格式化输出封装 |
| 7 | 选择器缺失 | 选中的菜单项无视觉反馈 | 没有选择器概念 |
| 8 | 无法编辑 | 菜单项只能看不能改 | 没有编辑状态管理 |
这 8 个问题,就是接下来 6 篇文章要逐个解决的。
5. 为什么原型仍然重要?
原型虽然简陋,但它完成了一件最关键的事:验证了整个模型可行。
- ✅ 裁剪窗口 + 回调模型 → 菜单可以滚动
- ✅ 追击算法 → 动画可以平滑
- ✅ 滑块映射 → 滚动位置可视化
验证了这三个核心理念之后,后续所有的重构——结构体化、模块化、事件化——都是在稳固的地基上盖楼。
教训:先写一段能跑的原型代码验证核心假设,再考虑架构和抽象。过早优化是万恶之源,但从不优化是慢性死亡。
在下一篇中,我们将把这堆全局变量和静态变量搬进一个结构体,把单文件拆成多文件,建立菜单库的正式架构。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)