从零构建嵌入式菜单库(一):原型探索——从一段单函数代码开始

从零构建嵌入式菜单库(一):原型探索——从一段单函数代码开始 从零构建嵌入式菜单库一原型探索——从一段单函数代码开始系列定位这是一套编写教程——我们将一起从零构建一个基于 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){totalLengthmenuItem(u8g2,x,y,rowHeight);// 不确定调用的是哪个函数}这就是菜单库框架与业务逻辑解耦的基石。2. 原型代码一段能跑的单函数菜单以下是比仓库第一次正式提交更早的原型。它只有一个函数所有逻辑混在一起但它能跑——这就是一切的开端。charoutBuf[64];#ifndefABS#defineABS(s)((s)0?-(s):(s))#endifu8g2_uint_tposition0;// 目标滚动位置u8g2_uint_tspe3;// 滚动速度u8g2_uint_tmaxCharHeight0;// 最大字符高度u8g2_uint_ttotalLength;// 菜单内容总高度u8g2_uint_twindowHeight0;// 菜单窗口高度// 菜单内容绘制回调u8g2_uint_tmenuItem(u8g2_t*u8g2,u8g2_uint_tx,u8g2_uint_ty,u8g2_uint_trowHeight){sprintf(outBuf,c:%d,count);u8g2_DrawStr(u8g2,x,yrowHeight,outBuf);sprintf(outBuf,t:%d,timer);u8g2_DrawStr(u8g2,x,yrowHeight,outBuf);returny;// 返回最后一行的 Y 坐标}// 垂直滑块条voidu8g2_DrawVSliderBar(u8g2_t*u8g2,u8g2_uint_tx,u8g2_uint_ty,u8g2_uint_tw,u8g2_uint_th,floatschedule){if(schedule1)schedule1;if(schedule0)schedule0;u8g2_DrawVLine(u8g2,xw/2,y,h);u8g2_DrawBox(u8g2,x,yh*0.7*schedule,w,h*0.3);}// 翻页voidpageUp(){if(position)position-maxCharHeight;}voidpageDown(){if(positiontotalLength-windowHeight)positionmaxCharHeight;}// 主绘制函数voidoled_display_menu(u8g2_t*u8g2,u8g2_uint_tx,u8g2_uint_ty,u8g2_uint_tw,u8g2_uint_th,menuItem_cb menuItem){staticu8g2_uint_t_position0;// 当前实际滚动位置staticu8g2_uint_t_rowHeight0;// 当前实际行高if(w10)return;// 第一步设置裁剪窗口u8g2_SetClipWindow(u8g2,x,y,xw-6,yh);// 第二步平滑滚动动画if(ABS(position-_position)spe){if(position_position)_positionspe;if(position_position)_position-spe;}else{_positionposition;}// 第三步行高动画maxCharHeightu8g2_GetMaxCharHeight(u8g2);if(_rowHeightmaxCharHeight)_rowHeight3;if(_rowHeightmaxCharHeight)_rowHeight-1;// 第四步绘制菜单内容totalLengthmenuItem(u8g2,x,y-_position,_rowHeight)_position-y;windowHeighth;// 第五步恢复裁剪u8g2_SetMaxClipWindow(u8g2);// 第六步绘制垂直滑块if(totalLengthh){u8g2_DrawVSliderBar(u8g2,xw-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,yrowHeight,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)_positionspe;if(position_position)_position-spe;}else{_positionposition;}知识点这是一个最简单的线性追击算法。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(_rowHeightmaxCharHeight)_rowHeight3;// 展开if(_rowHeightmaxCharHeight)_rowHeight-1;// 收起更慢这里3和-1的不对称设计是有意的菜单展开要快用户想看到内容收起稍慢留一点视觉残留。缺点3和-1是魔法数字不可配置不可替换。这是原型最需要重构的部分之一。3.4 垂直滑块条——位置映射u8g2_DrawVSliderBar(u8g2,xw-5,y,5,h,(float)_position/(totalLength-h));滑块位置 当前滚动位置 / 可滚动总范围。这是一个归一化到 [0, 1] 的简单映射最终库中保留了这个核心公式。4. 原型暴露的核心问题清单带着这个原型跑了几天后以下问题开始变得无法忍受#问题症状根因1单实例不能同时有两个菜单static全局变量2类型混乱变量修改逻辑散落在回调中没有统一的变量绑定接口3魔法数字3/-1/spe3动画硬编码4无导航子菜单靠全局变量手动管理没有调用链追溯5按键耦合pageUp/pageDown裸函数没有按键抽象层6字符串拼装sprintf(outBuf, ...)没有格式化输出封装7选择器缺失选中的菜单项无视觉反馈没有选择器概念8无法编辑菜单项只能看不能改没有编辑状态管理这 8 个问题就是接下来 6 篇文章要逐个解决的。5. 为什么原型仍然重要原型虽然简陋但它完成了一件最关键的事验证了整个模型可行。✅ 裁剪窗口 回调模型 → 菜单可以滚动✅ 追击算法 → 动画可以平滑✅ 滑块映射 → 滚动位置可视化验证了这三个核心理念之后后续所有的重构——结构体化、模块化、事件化——都是在稳固的地基上盖楼。教训先写一段能跑的原型代码验证核心假设再考虑架构和抽象。过早优化是万恶之源但从不优化是慢性死亡。在下一篇中我们将把这堆全局变量和静态变量搬进一个结构体把单文件拆成多文件建立菜单库的正式架构。下一篇从零构建嵌入式菜单库二架构设计——从函数到结构体从单文件到模块