c语言-优雅的多级菜单设计与实现

一、写在前面

很多嵌入式设备都是采用c语言编写,而很多涉及到人机交互的设备都避免不了菜单设计,虽然(包括我本人),以前都是写循环嵌套这种方式的菜单,在简单的项目中这种做法快速,但是如果在大型项目中,比如实现三级菜单,就会显得整个代码太庞杂,可维护性基本没有,还会被同事歧视,本文记录我学习和实现一个多级菜单的过程,涉及C 语言的结构体、函数指针以及栈结构,代码运行平台是espidf

二、灵活应用Struct

学过面向对象的人可能都会觉得,这个只需要定义一个类来封装菜单不就行了,在C语言中,虽然没有直接的类概念,但可以通过结构体和函数指针来模拟类的行为。

例如,在 C 语言中,类可以用结构体来模拟。结构体中可以包含属性(成员变量)和方法(函数指针)

c 复制代码
// 定义一个结构体作为类
typedef struct {
    // 属性
    int age;
    char name[50];

    // 方法(函数指针)
    void (*sayHello)(void*); // 用于打招呼的方法
} Man;

// 方法的实现
void sayHelloFunc(void* Man) {
    Man* c = (Man*)Man;
    printf("Meow! My name is %s and I'm %d years old.\n", c->name, c->age);
}

// 初始化 Man 类对象
void initMan(Man* Man, const char* name, int age) {
    Man->age = age;
    strcpy(Man->name, name);
    Man->sayHello = sayHelloFunc; // 将方法指针指向实现函数
}

同样的,我们也可以把一个菜单抽象起来,比如一个菜单应该具有名称,下属子节点数量(属性),也包括触发功能(方法函数)

c 复制代码
// 定义动作回调函数类型:相当于 C++ 中的虚函数接口
typedef void (*menu_action_t)(void);

// 菜单项结构体:相当于"类"的数据成员定义
typedef struct menu_item {
    const char *name;              // 显示名称
    struct menu_item *children;    // 指向子菜单数组的指针(构成树状结构)
    int child_count;               // 子菜单的项目数量
    menu_action_t action;          // 绑定的执行动作(叶子节点行为)
} menu_item_t;

为了满足嵌套,此处使用了递归定义的结构体,children 指针指向同类型的结构体数组,从而实现无限套娃

三、栈实现导航逻辑

为了支持"进入子菜单"和"返回上一层"的导航逻辑,系统必须记录用户的路径。使用"栈"结构是管理分层状态的最佳实践。

我们在操作菜单的时候,其实就是一个栈操作,比如

  • 用户在"主菜单"选择了"设置"(压栈,Push)。
  • 在"设置"里选择了"WiFi"(再压栈,Push)。
  • 用户按"返回"键,期望回到"设置"(出栈,Pop)。
  • 再次按"返回",期望回到"主菜单"(再出栈,Pop)。

"栈"的作用就是"保存现场"。 当我们深入下一级时,必须把当前这一级的状态(我是谁?光标停在哪里?)封存起来。当我们回来时,解封这个状态,用户就能无缝继续操作。如果不使用栈,当你从"WiFi设置"返回时,你可能被迫回到"主菜单"的第1个位置,或者丢失刚才的光标位置,这会让用户体验极其糟糕。

c 复制代码
#define MAX_MENU_DEPTH 5  // 最大支持层级

// 菜单运行时状态上下文
typedef struct {
    menu_item_t *menu_list; // 当前层级的菜单项列表
    int count;              // 当前层级项目总数
    int cursor;             // 当前选中的索引
} menu_state_t;

// 历史状态栈
static menu_state_t s_menu_stack[MAX_MENU_DEPTH];
static int s_stack_ptr = 0; // 栈顶指针

这段代码有点难理解,代入业务场景中理解比较好

设定:多级菜单结构

  1. 一级(主界面) :[图片, 音乐, 文档, 设置]
  2. 二级(文档) :[工作, 学习, 娱乐]
  3. 三级(学习) :[C语言, Python, Java]

第一阶段:在主界面选择"文档"

(状态:ptr = 0)

  • 用户操作 :用户按"下"键,光标移到了第3个选项 "文档"(索引为 2)。
  • 内存定格
    • 此时 s_menu_stack[0].cursor = 2

第二阶段:进入"文档"文件夹

(状态:用户按 Enter -> ptr 变成 1)

  • 动作:屏幕切换显示"文档"文件夹的内容。
  • 用户操作 :用户想找资料,按"下"键,选中了第2个选项 "学习"(索引为 1)。
  • 内存定格
    • s_menu_stack[0].cursor 依然是 2(被冻结在第0层)。
    • s_menu_stack[1].cursor 变成了 1

第三阶段:进入"学习"文件夹

(状态:用户按 Enter -> ptr 变成 2)

  • 动作:屏幕切换,显示"C语言, Python, Java"。
  • 用户操作:用户选中了"Python"(索引 1)。
  • 内存定格
    • s_menu_stack[0].cursor = 2 (还在冻结)
    • s_menu_stack[1].cursor = 1 (还在冻结)
    • s_menu_stack[2].cursor = 1 (当前正在操作的)

不管你在第2层(学习文件夹)如何操作、甚至翻了100页的时候,第1层(文档文件夹)的索引 cursor = 1 就像被锁在保险柜里一样,完全不会受到影响。

第四阶段:返回上一级

(状态:用户发现进错目录了,按 Back -> ptr 变成 1)

  • 系统动作
    1. ptr 从 2 减到了 1。这意味着系统不再关心第2层发生了什么(第2层的数据被"抛弃"了)。
    2. 系统现在看向 s_menu_stack[1]
  • 数据复原
    • 此时系统检索第一层的索引,解除冻结
  • 屏幕渲染
    • 重画"文档"文件夹的列表:[工作, 学习, 娱乐]。
    • 根据 cursor = 1,直接高亮 "学习"

通过这样一个实际的业务逻辑,就能理解为什么要这样设计

三、 静态表的初始化构建

利用 C 语言的静态初始化特性,我们可以清晰地看到菜单的拓扑结构。同时减少ram的使用

c 复制代码
/* --- 模拟业务层回调函数 --- */
void Handler_SystemInfo(void) { LOG_INFO("执行:查看系统信息"); }
void Handler_WiFiConfig(void) { LOG_INFO("执行:WiFi配置"); }
void Handler_FactoryReset(void) { LOG_INFO("执行:恢复出厂设置"); }

/* --- 2. 定义子菜单 (数据段) --- */
// 设置子菜单
static menu_item_t s_submenu_settings[] = {
    {"WiFi配置",   NULL, 0, Handler_WiFiConfig},
    {"恢复出厂",   NULL, 0, Handler_FactoryReset}
};

/* --- 1. 定义根菜单 --- */
static menu_item_t s_main_menu[] = {
    {"系统信息",   NULL, 0, Handler_SystemInfo}, // 叶子节点:直接执行动作
    {"系统设置",   s_submenu_settings, 2, NULL} // 目录节点:包含子菜单
};

通过这种方式,数据(菜单结构)与逻辑(调度器)实现了物理上的分离。

四、 调度器

调度器(Engine)是整个系统的核心,它不关心具体的业务(是设置WiFi还是调节音量),它只负责处理输入事件并操作栈。

c 复制代码
void menu_task_loop(sys_event_t event) {
    // 获取当前栈顶的状态(Context)
    menu_state_t *curr = &s_menu_stack[s_stack_ptr]; 

    switch (event) {
        case KEY_UP:
            // 循环光标逻辑,取模运算来实现光标的循环滚动
            curr->cursor = (curr->cursor - 1 + curr->count) % curr->count;
            render_ui(curr); // 刷新界面
            break;

        case KEY_DOWN:
            curr->cursor = (curr->cursor + 1) % curr->count;
            render_ui(curr);
            break;

        case KEY_ENTER:
        {
            menu_item_t *selected = &curr->menu_list[curr->cursor];
            
            // 压栈进入
            if (selected->children != NULL) {
                if (s_stack_ptr < MAX_MENU_DEPTH - 1) {
                    s_stack_ptr++; // 栈深 +1
                    // 状态入栈
                    s_menu_stack[s_stack_ptr].menu_list = selected->children;
                    s_menu_stack[s_stack_ptr].count = selected->child_count;
                    s_menu_stack[s_stack_ptr].cursor = 0; 
                    render_ui(&s_menu_stack[s_stack_ptr]);
                }
            } 
            // 或执行多态回调
            else if (selected->action != NULL) {
                selected->action(); // 调用函数指针
            }
            break;
        }

        case KEY_BACK:
            if (s_stack_ptr > 0) {
                s_stack_ptr--; // 出栈,恢复上一级状态
                render_ui(&s_menu_stack[s_stack_ptr]);
            }
            break;
    }
}

个人体会

这个代码的实现是真的好,代码我都看得懂,觉得写得精妙,但让我自己从零开始写,我只会写 switch-case 堆砌的流水账。我想这和我平时写嵌入式都是面向过程有关,就是先想第一步,再想第二步,整套代码就变得特别臃肿了。在此之后,应该在写代码之前,先想好怎么去设计数据结构,怎么抽象出来一个对象或者类,用struct表示,可能会更好。

当然我阅读源码的机会也很少,所以就不停地这样写流水账,现在多数操作都是用lvgl实现,大多数人也不用手搓菜单,但是这种应用还是很重要的,是时候把读源码给提上日程了

还有就是本人不是专科出生,对于计算机的一些架构设计缺失不太明白,我想《C嵌入式编程设计模式》和《嵌入式系统设计》也应该看一下

代码

c 复制代码
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

// 模拟 FreeRTOS/系统依赖,方便读者理解
#define LOG_TAG "MENU_CORE"
#define LOG_INFO(fmt, ...)  printf("[%s] " fmt "\n", LOG_TAG, ##__VA_ARGS__)

// --- 核心结构体定义 ---

// 1. 定义动作回调函数指针 (C语言模拟多态)
typedef void (*menu_action_t)(void);

// 2. 菜单项结构体 (树形结构节点)
typedef struct menu_item {
    const char *name;              // 菜单显示名称
    struct menu_item *children;    // 子菜单指针 (如果为NULL,则说明是功能项)
    int child_count;               // 子菜单节点数量
    menu_action_t action;          // 具体执行的动作 (如果是文件夹,此项为NULL)
} menu_item_t;

// 3. 菜单运行时状态 (保存现场)
#define MAX_MENU_DEPTH 5           // 支持的最大层级深度
typedef struct {
    menu_item_t *menu_list;        // 当前层级的菜单表
    int count;                     // 当前层级总数
    int cursor;                    // 当前光标位置
} menu_state_t;

// --- 全局状态管理 ---
static menu_state_t s_menu_stack[MAX_MENU_DEPTH]; // 历史状态栈
static int s_stack_ptr = 0;                       // 栈顶指针 (0表示根目录)


// --- 模拟业务功能函数 (实际项目中替换为具体逻辑) ---
void Action_WiFi_Scan(void)    { LOG_INFO("执行: 扫描WiFi网络..."); }
void Action_WiFi_Reset(void)   { LOG_INFO("执行: 重置网络配置"); }
void Action_Audio_Mute(void)   { LOG_INFO("执行: 静音"); }
void Action_Audio_Max(void)    { LOG_INFO("执行: 最大音量"); }
void Action_Sys_Reboot(void)   { LOG_INFO("执行: 系统重启"); }
void Action_Sys_Info(void)     { LOG_INFO("执行: 显示版本号 v1.0.0"); }

// --- 菜单树构建 (静态表驱动) ---

// 二级菜单:网络设置
static menu_item_t s_submenu_net[] = {
    {"扫描网络", NULL, 0, Action_WiFi_Scan},
    {"重置网络", NULL, 0, Action_WiFi_Reset}
};

// 二级菜单:音频设置
static menu_item_t s_submenu_audio[] = {
    {"静音模式", NULL, 0, Action_Audio_Mute},
    {"最大音量", NULL, 0, Action_Audio_Max}
};

// 一级菜单 (根目录)
static menu_item_t s_main_menu[] = {
    {"网络设置", s_submenu_net,   2, NULL},            // 文件夹节点
    {"音频设置", s_submenu_audio, 2, NULL},            // 文件夹节点
    {"系统信息", NULL,            0, Action_Sys_Info}, // 功能节点
    {"重启设备", NULL,            0, Action_Sys_Reboot}// 功能节点
};

// --- 核心逻辑引擎 ---

// 辅助:模拟UI渲染或TTS播报
static void render_ui_state() {
    menu_state_t *state = &s_menu_stack[s_stack_ptr];
    const char *current_name = state->menu_list[state->cursor].name;
    
    // 实际项目中这里是驱动屏幕刷新或发送TTS指令
    printf(">> UI渲染: [层级:%d] 选中: [%s] (%d/%d)\n", 
           s_stack_ptr, 
           current_name, 
           state->cursor + 1, 
           state->count);
}

// 菜单系统初始化
void menu_service_init() {
    s_stack_ptr = 0;
    s_menu_stack[0].menu_list = s_main_menu;
    s_menu_stack[0].count = sizeof(s_main_menu) / sizeof(menu_item_t);
    s_menu_stack[0].cursor = 0;
    LOG_INFO("菜单系统已就绪");
    render_ui_state();
}

// 菜单调度任务 (通常在 while(1) 或 消息队列回调中调用)
// 参数 event 模拟按键事件: 0:UP, 1:DOWN, 2:ENTER, 3:BACK
void menu_process_event(int event_id) {
    menu_state_t *curr = &s_menu_stack[s_stack_ptr]; // 获取当前栈顶状态

    switch (event_id) {
        // --- 导航:上移 ---
        case 0: // KEY_UP
            curr->cursor--;
            if (curr->cursor < 0) curr->cursor = curr->count - 1; // 循环卷动
            render_ui_state();
            break;

        // --- 导航:下移 ---
        case 1: // KEY_DOWN
            curr->cursor++;
            if (curr->cursor >= curr->count) curr->cursor = 0;    // 循环卷动
            render_ui_state();
            break;

        // --- 动作:确认 ---
        case 2: // KEY_ENTER
        {
            menu_item_t *selected = &curr->menu_list[curr->cursor];

            // 分支逻辑A: 如果是文件夹 -> 压栈 (Push Stack)
            if (selected->children != NULL) {
                if (s_stack_ptr < MAX_MENU_DEPTH - 1) {
                    s_stack_ptr++; // 栈深 +1
                    s_menu_stack[s_stack_ptr].menu_list = selected->children;
                    s_menu_stack[s_stack_ptr].count = selected->child_count;
                    s_menu_stack[s_stack_ptr].cursor = 0; // 进入子菜单默认选中第1个
                    LOG_INFO("进入子菜单: %s", selected->name);
                    render_ui_state();
                } else {
                    LOG_INFO("错误: 达到最大层级深度");
                }
            }
            // 分支逻辑B: 如果是功能 -> 执行回调 (Callback)
            else if (selected->action != NULL) {
                LOG_INFO("触发功能: %s", selected->name);
                selected->action(); 
            }
            break;
        }

        // --- 动作:返回 ---
        case 3: // KEY_BACK
            if (s_stack_ptr > 0) {
                s_stack_ptr--; // 出栈 (Pop Stack),自动恢复上一级的光标位置
                LOG_INFO("返回上一级");
                render_ui_state();
            } else {
                LOG_INFO("已在根目录,无法返回");
            }
            break;
    }
}
相关推荐
geekmice43 分钟前
thymeleaf处理参数传递问题
开发语言·lua
LNN20221 小时前
Qt 5.8.0 下实现触摸屏热插拔功能的探索与实践(2)
开发语言·qt
董世昌411 小时前
箭头函数和普通函数有什么区别
开发语言·javascript·ecmascript
AI科技星1 小时前
张祥前统一场论:引力场与磁矢势的关联,反引力场生成及拉格朗日点解析(网友问题解答)
开发语言·数据结构·经验分享·线性代数·算法
β添砖java1 小时前
python第一阶段第八章文件操作
开发语言·python
C雨后彩虹1 小时前
最少交换次数
java·数据结构·算法·华为·面试
-森屿安年-1 小时前
二叉平衡树的实现
开发语言·数据结构·c++
脑极体1 小时前
蓝河入海:Rust先行者vivo的开源之志
开发语言·后端·rust·开源
foxsen_xia1 小时前
go(基础01)——协程
开发语言·算法·golang