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

系列定位:这是一套编写教程——我们将一起从零构建一个基于 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 由这个返回值反算。

优点

  • 简单直观,一个函数指针搞定
  • 调用者完全控制绑定的上下文变量(counttimer 等)

缺点

  • 返回 Y 坐标的方式过于原始——如果回调里要绘制不同高度的菜单项,调用者得自己算每行间距
  • sprintf 每次都要手动拼字符串,类型不安全

这个"行模型"后来被重构为 menuItem_cbvoid 返回 + 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 是当前实际显示位置。每次调用时 _positionposition 逼近 spe 个单位。

时间轴: t0    t1    t2    t3    t4
目标:  100   100   100   100   100
实际:  0     3     6     9     12  ... 最终追到 100

优点

  • 计算量极小(三次比较 + 一次加减)
  • 效果自然——加速启动、减速停止

缺点

  • 追到目标后就"粘住"了,没有弹性或回弹(但这对于菜单来说反而是优点)
  • spe 是固定步长,长距离滚动时速度恒定,不够平滑

演化:最终库中这个逻辑被封装进 u8g2_menu_effect_trun 回调,支持替换。

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. 为什么原型仍然重要?

原型虽然简陋,但它完成了一件最关键的事:验证了整个模型可行

  • ✅ 裁剪窗口 + 回调模型 → 菜单可以滚动
  • ✅ 追击算法 → 动画可以平滑
  • ✅ 滑块映射 → 滚动位置可视化

验证了这三个核心理念之后,后续所有的重构——结构体化、模块化、事件化——都是在稳固的地基上盖楼。

教训:先写一段能跑的原型代码验证核心假设,再考虑架构和抽象。过早优化是万恶之源,但从不优化是慢性死亡。

在下一篇中,我们将把这堆全局变量和静态变量搬进一个结构体,把单文件拆成多文件,建立菜单库的正式架构。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐