从零构建嵌入式菜单库(一):原型探索——从一段单函数代码开始
从零构建嵌入式菜单库(一):原型探索——从一段单函数代码开始
系列定位:这是一套编写教程——我们将一起从零构建一个基于 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 中这样声明:
// 声明一个函数指针类型typedefu8g2_uint_t(*menuItem_cb)(u8g2_t*,u8g2_uint_t,u8g2_uint_t,u8g2_uint_t);// 接收这个函数指针voidoled_display_menu(...,menuItem_cb menuItem){totalLength=menuItem(u8g2,x,y,rowHeight);// 不确定调用的是哪个函数}这就是菜单库"框架"与"业务逻辑"解耦的基石。
2. 原型代码:一段能跑的单函数菜单
以下是比仓库第一次正式提交更早的原型。它只有一个函数,所有逻辑混在一起,但它能跑——这就是一切的开端。
charoutBuf[64];#ifndefABS#defineABS(s)((s)<0?-(s):(s))#endifu8g2_uint_tposition=0;// 目标滚动位置u8g2_uint_tspe=3;// 滚动速度u8g2_uint_tmaxCharHeight=0;// 最大字符高度u8g2_uint_ttotalLength;// 菜单内容总高度u8g2_uint_twindowHeight=0;// 菜单窗口高度// 菜单内容绘制回调u8g2_uint_tmenuItem(u8g2_t*u8g2,u8g2_uint_tx,u8g2_uint_ty,u8g2_uint_trowHeight){sprintf(outBuf,"c:%d",count);u8g2_DrawStr(u8g2,x,y+=rowHeight,outBuf);sprintf(outBuf,"t:%d",timer);u8g2_DrawStr(u8g2,x,y+=rowHeight,outBuf);returny;// 返回最后一行的 Y 坐标}// 垂直滑块条voidu8g2_DrawVSliderBar(u8g2_t*u8g2,u8g2_uint_tx,u8g2_uint_ty,u8g2_uint_tw,u8g2_uint_th,floatschedule){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);}// 翻页voidpageUp(){if(position)position-=maxCharHeight;}voidpageDown(){if(position<totalLength-windowHeight)position+=maxCharHeight;}// 主绘制函数voidoled_display_menu(u8g2_t*u8g2,u8g2_uint_tx,u8g2_uint_ty,u8g2_uint_tw,u8g2_uint_th,menuItem_cb menuItem){staticu8g2_uint_t_position=0;// 当前实际滚动位置staticu8g2_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));}}voidoled_display(u8g2_t*u8g2){oled_display_menu(u8g2,0,0,128,32,menuItem);}3. 逐段拆解:每一行在做什么
3.1 菜单内容回调——“行模型”
u8g2_uint_tmenuItem(u8g2_t*u8g2,u8g2_uint_tx,u8g2_uint_ty,u8g2_uint_trowHeight){sprintf(outBuf,"c:%d",count);u8g2_DrawStr(u8g2,x,y+=rowHeight,outBuf);returny;}设计思路:把菜单的每一行抽象为"按给定 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. 为什么原型仍然重要?
原型虽然简陋,但它完成了一件最关键的事:验证了整个模型可行。
- ✅ 裁剪窗口 + 回调模型 → 菜单可以滚动
- ✅ 追击算法 → 动画可以平滑
- ✅ 滑块映射 → 滚动位置可视化
验证了这三个核心理念之后,后续所有的重构——结构体化、模块化、事件化——都是在稳固的地基上盖楼。
教训:先写一段能跑的原型代码验证核心假设,再考虑架构和抽象。过早优化是万恶之源,但从不优化是慢性死亡。
在下一篇中,我们将把这堆全局变量和静态变量搬进一个结构体,把单文件拆成多文件,建立菜单库的正式架构。
下一篇:从零构建嵌入式菜单库(二):架构设计——从函数到结构体,从单文件到模块
