news 2026/8/7 18:21:50

STM32 OLED菜单系统设计:数据驱动与状态机实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 OLED菜单系统设计:数据驱动与状态机实现

1. 项目概述:从零到一构建OLED菜单系统

在嵌入式开发中,尤其是基于STM32这类MCU的项目,人机交互界面(HMI)的设计往往是决定产品用户体验的关键一环。当你的项目需要一个简单的菜单来切换功能、调整参数时,一个稳定、清晰且易于维护的菜单系统就变得至关重要。很多开发者,尤其是刚接触STM32的朋友,面对如何在小小的OLED屏幕上实现一个流畅的菜单,常常感到无从下手——是每个页面写死一堆if-else?还是用状态机硬扛?代码越写越乱,后期添加一个功能就得大动干戈。

今天,我想和你分享的,就是我在多个STM32项目(从智能家居控制面板到便携式测量设备)中沉淀下来的一套OLED菜单编程思路与通用模板。这套方法的核心,是将菜单视为一个由“页面”和“事件”驱动的状态机,通过结构化的数据来定义菜单,从而将界面逻辑与业务逻辑彻底解耦。它不是什么高深的框架,而是一种清晰、可复用的编程思想。无论你用的是0.96寸的SSD1306,还是其他I2C或SPI接口的OLED,这套思路都能帮你快速搭建起一个层次清晰、响应灵敏、易于扩展的菜单系统,让你摆脱界面开发的泥潭,专注于核心功能的实现。

2. 核心设计思路:数据驱动与状态迁移

在动手写代码之前,我们必须先想清楚菜单的本质是什么。一个典型的菜单系统包含几个基本元素:多个显示页面(如主界面、设置页、关于页)、页面内的可选项(如“温度设置”、“返回”)、用户的输入事件(如按键按下、旋转编码器转动)以及页面之间的跳转关系。最原始的写法可能是这样的:在main函数的while(1)循环里,用一个全局变量current_page记录当前页面,然后写一长串switch-case,每个case里又嵌套一堆if-else来处理该页面下的绘制和按键响应。这种方法在页面少于3个时还能应付,一旦功能增多,代码就会变得像意大利面条一样难以维护。

2.1 状态机模型:让菜单逻辑清晰可控

更好的方法是引入有限状态机(FSM)的思想。我们把每个菜单页面看作一个“状态”,用户的按键操作(上、下、确定、返回)就是触发状态迁移的“事件”。我们的任务就是明确定义每个状态下,接收到不同事件后,应该迁移到哪个新状态,以及迁移过程中需要执行什么动作(比如更新某个参数、保存设置)。

但直接裸写状态机代码依然繁琐。更优雅的解决方案是数据驱动。我们用一个结构体数组来定义整个菜单的“地图”。每个菜单项(一个可交互的条目)都是一个结构体,它至少包含:该项显示的文字、该项的类型(是普通选项、数值设置项、还是开关项)、该项关联的回调函数或参数地址、以及它的“邻居”关系(按上/下键会跳到哪个项)。而一个菜单页面,就是一系列具有相同父节点的菜单项的集合。

这样,我们的程序主循环就变得非常简洁:

  1. 根据当前选中的菜单项ID,从结构体数组中取出其信息。
  2. 调用统一的OLED_DrawMenu函数,根据菜单项类型(普通、数值、开关)绘制当前页面,并高亮选中项。
  3. 等待并获取按键事件。
  4. 根据按键事件和当前菜单项定义好的“邻居”关系,计算新的选中项ID,或者执行关联的回调函数(如进入子菜单、修改数值)。
  5. 刷新显示。

所有的跳转逻辑都隐含在菜单结构体数组的数据关系里,而不是散落在大量的条件判断语句中。要新增一个功能页,你只需要在数组里添加几个结构体并设置好关联关系,绘制和交互逻辑都是通用的。

2.2 菜单数据结构设计:构建菜单的骨架

下面我们来定义一个最核心的菜单项结构体。这是模板的基石。

typedef enum { MENU_ITEM_TYPE_NORMAL, // 普通选项,点击后执行回调或进入子菜单 MENU_ITEM_TYPE_NUMERIC, // 数值设置项,可以按左右键增减数值 MENU_ITEM_TYPE_TOGGLE, // 开关项,点击切换ON/OFF MENU_ITEM_TYPE_BACK, // 返回项,固定功能 } MenuItemType; typedef struct { const char* displayText; // 在OLED上显示的文本 MenuItemType type; // 菜单项类型 int16_t value; // 当前值(用于数值或开关项) int16_t minValue; // 最小值 int16_t maxValue; // 最大值 void (*actionCallback)(void); // 点击“确定”后的回调函数 void* linkedVariable; // 可选:关联的变量指针,用于自动同步值 uint8_t parentId; // 父菜单项的ID uint8_t firstChildId; // 第一个子菜单项的ID uint8_t prevSiblingId; // 上一个兄弟菜单项的ID uint8_t nextSiblingId; // 下一个兄弟菜单项的ID } MenuItem_t;

这个结构体包含了定义一个菜单项所需的所有信息:

  • displayText:显示内容,如“系统设置”。
  • type:决定该项如何被交互和绘制。
  • value/min/max:对于数值/开关项,定义其取值范围和当前值。
  • actionCallback:一个函数指针,当用户在该项上按下“确定”键时被调用。可以用于执行具体任务或进入子菜单(通过改变当前菜单索引)。
  • linkedVariable:这是一个非常实用的技巧。你可以将一个全局变量(如int targetTemperature)的地址赋给它。在数值修改后,模板会自动将value写回这个变量,实现界面与数据的自动同步。
  • parentId/firstChildId/prevSiblingId/nextSiblingId:这四个字段定义了菜单的树形拓扑关系。parentId指向父项,firstChildId指向其子菜单列表的第一项。prevnext则定义了同一层级下的兄弟关系,构成了一个双向链表,使得“上/下”键导航的逻辑计算变得非常简单直接——只需要按照nextSiblingIdprevSiblingId跳转即可。

有了这个结构体,整个菜单系统就可以用一个MenuItem_t数组来完全描述。菜单的导航、交互逻辑,都将基于这个数组进行计算。

注意linkedVariable的使用需要谨慎,确保类型匹配(例如int16_t*指向int16_t变量)。这是一个提升开发效率的利器,但初始化时要确保指针有效。

3. 核心模块实现与驱动适配

设计好数据结构后,我们需要几个核心的函数模块来让整个系统运转起来。这些模块与具体的OLED驱动和按键驱动是解耦的,你只需要实现几个简单的接口。

3.1 菜单引擎:状态处理的核心

菜单引擎模块是大脑,它维护当前状态并处理输入事件。我们首先需要定义一些全局状态变量。

// menu_engine.c static MenuItem_t* menuList; // 指向菜单结构体数组的指针 static uint16_t menuItemCount; // 菜单项总数 static uint16_t currentItemId; // 当前选中的菜单项ID static uint16_t currentParentId; // 当前所在层级的父项ID(用于绘制标题) void Menu_Init(MenuItem_t* list, uint16_t count) { menuList = list; menuItemCount = count; currentItemId = 0; // 通常指向根菜单的第一项 currentParentId = 0xFF; // 根菜单的父ID设为特殊值 } void Menu_HandleKeyPress(KeyEvent_t key) { MenuItem_t* currentItem = &menuList[currentItemId]; switch(key) { case KEY_UP: // 导航到上一个兄弟节点 if (currentItem->prevSiblingId != 0xFF) { currentItemId = currentItem->prevSiblingId; } break; case KEY_DOWN: // 导航到下一个兄弟节点 if (currentItem->nextSiblingId != 0xFF) { currentItemId = currentItem->nextSiblingId; } break; case KEY_ENTER: // 执行确定操作 if (currentItem->type == MENU_ITEM_TYPE_NORMAL) { if (currentItem->actionCallback != NULL) { currentItem->actionCallback(); } else if (currentItem->firstChildId != 0xFF) { // 如果有子菜单,则进入 currentParentId = currentItemId; currentItemId = currentItem->firstChildId; } } else if (currentItem->type == MENU_ITEM_TYPE_NUMERIC || currentItem->type == MENU_ITEM_TYPE_TOGGLE) { // 对于数值/开关项,ENTER键可能用于进入编辑模式或直接切换 // 这里以直接切换开关为例 if (currentItem->type == MENU_ITEM_TYPE_TOGGLE) { currentItem->value = !(currentItem->value); // 同步到关联变量 if (currentItem->linkedVariable != NULL) { *(int16_t*)(currentItem->linkedVariable) = currentItem->value; } } } break; case KEY_BACK: // 返回上一级菜单 if (menuList[currentItemId].parentId != 0xFF) { currentItemId = menuList[currentItemId].parentId; currentParentId = menuList[currentItemId].parentId; } break; case KEY_LEFT: case KEY_RIGHT: // 处理数值增减 if (currentItem->type == MENU_ITEM_TYPE_NUMERIC) { int16_t step = (key == KEY_RIGHT) ? 1 : -1; int16_t newVal = currentItem->value + step; if (newVal >= currentItem->minValue && newVal <= currentItem->maxValue) { currentItem->value = newVal; if (currentItem->linkedVariable != NULL) { *(int16_t*)(currentItem->linkedVariable) = newVal; } } } break; } } uint16_t Menu_GetCurrentItemId(void) { return currentItemId; } uint16_t Menu_GetCurrentParentId(void) { return currentParentId; }

这个引擎模块不负责任何绘制,只负责根据按键事件更新currentItemIdcurrentParentId,以及执行回调、修改变量值。它完全独立于硬件。

3.2 显示渲染器:将数据转化为像素

渲染器模块负责将当前的菜单状态绘制到OLED屏幕上。它需要调用你项目里具体的OLED驱动函数(如OLED_ShowString)。

// menu_renderer.c void Menu_Render(void) { MenuItem_t* currentItem = &menuList[currentItemId]; uint16_t parentId = currentParentId; // 1. 清屏 OLED_Clear(); // 2. 绘制标题栏(例如显示父菜单项的文字) if (parentId != 0xFF) { OLED_ShowString(0, 0, (uint8_t*)menuList[parentId].displayText, 12); OLED_DrawLine(0, 13, 127, 13); // 画一条分隔线 } // 3. 获取当前层级的兄弟链表,用于绘制列表 uint16_t startId = currentItemId; // 找到当前层级的第一个兄弟(链表头) while (menuList[startId].prevSiblingId != 0xFF && menuList[menuList[startId].prevSiblingId].parentId == parentId) { startId = menuList[startId].prevSiblingId; } // 4. 遍历兄弟链表,绘制最多N个项目(根据屏幕高度) uint16_t drawY = 15; // 起始Y坐标 uint16_t iterId = startId; uint8_t itemIndex = 0; const uint8_t maxVisibleItems = 4; // 一屏最多显示4项 while (iterId != 0xFF && itemIndex < maxVisibleItems) { MenuItem_t* item = &menuList[iterId]; uint8_t isSelected = (iterId == currentItemId); // 绘制背景(选中项反色) if (isSelected) { OLED_FillRect(0, drawY, 127, drawY+12, OLED_COLOR_REVERSE); } // 绘制文本 char displayBuffer[32]; sprintf(displayBuffer, "%s", item->displayText); OLED_ShowString(2, drawY+2, (uint8_t*)displayBuffer, 12); // 根据类型绘制附加值 if (item->type == MENU_ITEM_TYPE_NUMERIC) { char valBuffer[8]; sprintf(valBuffer, "<%d>", item->value); OLED_ShowString(90, drawY+2, (uint8_t*)valBuffer, 12); } else if (item->type == MENU_ITEM_TYPE_TOGGLE) { const char* state = item->value ? "ON" : "OFF"; OLED_ShowString(100, drawY+2, (uint8_t*)state, 12); } drawY += 14; // 行高 itemIndex++; iterId = item->nextSiblingId; // 确保下一个兄弟仍在同一层级 if (iterId != 0xFF && menuList[iterId].parentId != parentId) { break; } } // 5. 更新屏幕 OLED_Refresh(); }

渲染器的工作是纯被动的,它仅仅根据currentItemIdcurrentParentId去查找菜单数组并绘制。它需要处理文本截断、滚动(如果一屏显示不完)等细节,上面的示例是一个简化版本。

3.3 按键驱动与主循环集成

最后,我们需要一个简单的主循环将一切串联起来。假设你有一个Key_GetEvent()函数,它会返回KEY_NONE,KEY_UP,KEY_DOWN,KEY_ENTER,KEY_BACK等事件。

// main.c MenuItem_t myMenuList[] = { // ID, 显示文本, 类型, 当前值, 最小值, 最大值, 回调函数, 关联变量, 父ID, 首子ID, 前兄弟ID, 后兄弟ID {0, "主菜单", MENU_ITEM_TYPE_NORMAL, 0,0,0, NULL, NULL, 0xFF, 1, 0xFF, 0xFF}, // 根项,不显示 {1, "启动设备", MENU_ITEM_TYPE_NORMAL, 0,0,0, StartDevice, NULL, 0, 0xFF, 0xFF, 2}, {2, "参数设置", MENU_ITEM_TYPE_NORMAL, 0,0,0, NULL, NULL, 0, 3, 1, 3}, {3, "关于", MENU_ITEM_TYPE_NORMAL, 0,0,0, ShowAboutPage, NULL, 0, 0xFF, 2, 0xFF}, // 参数设置的子菜单 {4, "温度设置", MENU_ITEM_TYPE_NUMERIC, 25,0,50, NULL, &targetTemp, 2, 0xFF, 0xFF, 5}, {5, "开关模式", MENU_ITEM_TYPE_TOGGLE, 1,0,1, NULL, &powerMode, 2, 0xFF, 4, 6}, {6, "返回", MENU_ITEM_TYPE_BACK, 0,0,0, NULL, NULL, 2, 0xFF, 5, 0xFF}, }; int targetTemp = 25; int powerMode = 1; void StartDevice(void) { // 实际的启动代码 OLED_ShowString(0,0, (uint8_t*)"Device Started!", 16); HAL_Delay(1000); } void ShowAboutPage(void) { OLED_Clear(); OLED_ShowString(0,0, (uint8_t*)"Firmware V1.0", 16); OLED_ShowString(0,20, (uint8_t*)"Author: You", 16); OLED_Refresh(); HAL_Delay(2000); } int main(void) { // 硬件初始化 HAL_Init(); SystemClock_Config(); OLED_Init(); Key_Init(); // 菜单初始化 Menu_Init(myMenuList, sizeof(myMenuList)/sizeof(MenuItem_t)); while (1) { // 1. 处理按键 KeyEvent_t key = Key_GetEvent(); if (key != KEY_NONE) { Menu_HandleKeyPress(key); } // 2. 渲染菜单 Menu_Render(); // 3. 其他后台任务 // ... 可以在这里处理传感器读取、通信等 HAL_Delay(50); // 简单的延时,也可用定时器 } }

这就是整个系统的工作流程。初始化后,主循环不断检测按键、更新菜单状态、重绘界面。所有复杂的跳转逻辑都隐藏在Menu_HandleKeyPress函数和myMenuList数组的定义中。

4. 高级技巧与深度优化

基础框架搭建好后,我们可以从以下几个方面进行优化,让菜单系统更健壮、更美观、更省资源。

4.1 动态内容与实时数据刷新

上面的例子中,菜单项文本是固定的。但在很多场景下,我们需要显示动态信息,比如“当前温度:25.6℃”。有两种实现方式:

  1. 回调函数生成文本:为MenuItem_t增加一个getDisplayTextCallback函数指针。在渲染时,如果这个回调不为空,就调用它来获取要显示的字符串。

    typedef const char* (*GetDisplayTextFunc)(void); // 在结构体中添加 GetDisplayTextFunc getDisplayTextCallback; // 示例:显示温度 const char* GetTemperatureText(void) { static char buffer[20]; // 注意用静态或全局数组,避免返回栈地址 float temp = Read_Temperature_Sensor(); sprintf(buffer, "Temp: %.1fC", temp); return buffer; } // 在菜单数组中:{"", ..., GetTemperatureText, ...}

    实操心得:用于生成动态文本的缓冲区必须是静态或全局的,因为该字符串指针会被返回并在后续被使用。切勿在回调函数内部定义局部数组并返回其地址。

  2. 脏矩形刷新与局部更新:频繁清屏重绘整个菜单(OLED_Clear())在低刷新率下可能引起闪烁。更高效的方法是“脏矩形”更新。为每个菜单项记录其上次绘制的区域和内容,本次渲染时,只重绘那些内容发生改变的区域。对于OLED这种像素自发光屏幕,局部更新能有效减少闪烁和提升响应速度。你可以封装一个OLED_UpdateArea(x, y, w, h)函数,只更新这一块区域。

4.2 多级菜单与路径管理

当菜单层级很深时,用户可能迷失。一个好的实践是提供清晰的路径提示。

  • 面包屑导航:在屏幕顶部或底部显示如“主菜单 > 设置 > 系统”这样的路径。这需要你在状态迁移时(进入/退出子菜单)维护一个路径ID栈。
  • 滚动列表与视觉焦点:当一屏显示不下所有兄弟项时,需要实现滚动。关键在于维护一个“视图偏移量”(viewOffset)。currentItemId是逻辑选中项,viewOffset决定从第几项开始显示。当选中项即将移出视图时,调整viewOffset。渲染时,只绘制从viewOffset开始的N个项目。
  • 动画与过渡效果:在资源允许的情况下,简单的动画能极大提升体验。例如,进入子菜单时,旧页面向左滑出,新页面从右侧滑入。这可以通过在连续几帧中逐步偏移绘制坐标来实现。虽然STM32性能有限,但对于简单的位图移动,利用硬件SPI快速传输帧缓冲区数据,是可以实现流畅动画的。

4.3 资源受限下的优化策略

在SRAM紧张的STM32F1系列(如F103C8T6只有20KB RAM)上,需要精打细算。

  • 使用constPROGMEM(如果支持):将菜单结构体数组、所有显示字符串字面量等只读数据声明为const,编译器会将其放入Flash,节省宝贵的RAM。
  • 压缩存储:如果菜单项很多,可以考虑压缩字段。例如,parentId,siblingId可以用uint8_t(如果菜单项少于255)。displayText可以不存整个字符串,而是存字符串在常量池中的索引。
  • 避免动态内存:绝对不要使用malloc。所有内存都在编译时静态分配。
  • 简化渲染:使用更小的字体(如6x8像素),减少每次刷新需要传输的字节数。如果驱动支持,可以只更新变化的行,而不是整个屏幕。

5. 常见问题排查与调试心得

即使有了模板,在实际集成到项目时,你仍可能会遇到一些典型问题。这里记录几个我踩过的坑和解决方法。

5.1 菜单响应卡顿或错乱

  • 症状:按键后菜单反应慢,或者跳转到了错误的项。
  • 排查
    1. 检查按键消抖:这是最常见的原因。机械按键在闭合和断开时会产生毛刺。必须在驱动层进行消抖处理,通常采用“延时采样”或“状态机滤波”的方式,确保Key_GetEvent()返回的是稳定、单一的事件。
    2. 检查主循环延迟:如果HAL_Delay(50)放在渲染之后,且渲染本身很耗时(比如清屏并重绘大量内容),会导致按键检测间隔过长。可以尝试将按键检测放在更快的定时器中断中,或者使用非阻塞的方式检查按键状态。
    3. 验证菜单数据完整性:仔细检查myMenuList数组。确保每个项的ID是唯一的,并且parentIdsiblingId构成了正确的闭环链表。一个常见的错误是链表断裂,导致nextSiblingId指向一个不存在的ID或错误层级的ID。写一个简单的Menu_Validate()函数,在初始化后遍历检查所有链接关系。
    4. 检查渲染函数中的边界条件:在Menu_Render的遍历绘制循环中,确保iterId不会越界(小于menuItemCount),并且当parentId为特殊值(如0xFF)时,查找兄弟节点的逻辑正确。

5.2 显示异常、花屏或残留

  • 症状:屏幕显示乱码,或者上次的内容没有完全清除。
  • 排查
    1. OLED驱动初始化:确认OLED_Init()序列正确,特别是对比度、扫描方向、显示开关等命令。不同厂商的OLED驱动芯片(SSD1306, SH1106)初始化序列可能有细微差别。
    2. 帧缓冲区管理:如果你使用了软件帧缓冲区(一个代表屏幕的数组),确保OLED_Clear()函数是真正将整个缓冲区清零,而不是仅仅发送清屏命令。OLED_Refresh()函数需要将整个缓冲区数据发送到屏幕。检查发送的数据长度是否正确(128x64屏是1024字节)。
    3. 字体数据:确保你使用的字模数据是完整的,并且字模提取函数能正确索引。乱码往往是取字模时地址计算错误导致的。
    4. 电源稳定性:OLED屏幕对电源比较敏感。如果电源纹波过大,可能导致显示异常。在MCU和OLED的VCC引脚附近并联一个10uF以上的电解电容和一个0.1uF的陶瓷电容,可以有效改善。

5.3 数值修改不生效或关联变量不同步

  • 症状:在菜单里修改了数值,但实际功能没变化,或者退出再进入后数值恢复了。
  • 排查
    1. 指针类型与强制转换:这是最隐蔽的坑。linkedVariablevoid*类型。当你用*(int16_t*)(currentItem->linkedVariable) = newVal;进行赋值时,必须确保linkedVariable指向的确实是一个int16_t(或兼容类型)的变量。如果关联的是uint8_tfloat,就会发生内存覆盖错误。建议使用联合体(union)或更安全的类型检查方法。
    2. 回调函数未更新实际参数:对于MENU_ITEM_TYPE_NORMAL类型,其actionCallback可能负责修改某些全局参数。确保回调函数确实修改了正确的变量。
    3. EEPROM/Flash存储:如果数值需要掉电保存,修改后需要触发存储操作。最好不要在每次数值变动时都写入(Flash寿命有限),可以设置一个“保存”菜单项,或者在退出菜单时统一保存所有标记为“已修改”的参数。

5.4 扩展功能时的结构维护难题

  • 症状:增加或删除一个菜单项后,需要手动计算和修改一大堆ID和兄弟指针,容易出错。
  • 解决方案:放弃手动维护ID和指针。可以编写一个简单的“菜单生成脚本”(用Python或Excel)。你只需要在一个表格或文本文件中以层级方式列出菜单(如YAML格式),脚本自动为你计算并生成出myMenuList数组的C代码。这是项目菜单复杂后必做的自动化工作,能节省大量时间并避免人为错误。

最后,分享一个我个人的小习惯:在开发初期,我会在Menu_Render函数里,把当前currentItemIdcurrentParentId以很小的字体显示在屏幕角落。这相当于一个实时调试信息,在排查导航问题时一目了然,非常有用。当菜单稳定后,再把这行调试信息去掉。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/7 18:19:29

js-pptx如何解决JavaScript环境中PowerPoint文件处理的技术瓶颈

js-pptx如何解决JavaScript环境中PowerPoint文件处理的技术瓶颈 【免费下载链接】js-pptx Pure Javascript reader/writer for PowerPoint 项目地址: https://gitcode.com/gh_mirrors/js/js-pptx 在现代Web应用和企业级系统中&#xff0c;动态生成和编辑PowerPoint演示文…

作者头像 李华
网站建设 2026/8/7 18:11:17

KeyCastr:macOS按键可视化解决方案实现操作透明化与教学协作

KeyCastr&#xff1a;macOS按键可视化解决方案实现操作透明化与教学协作 【免费下载链接】keycastr KeyCastr, an open-source keystroke visualizer 项目地址: https://gitcode.com/gh_mirrors/ke/keycastr KeyCastr是一款面向macOS系统的开源按键可视化工具&#xff0…

作者头像 李华
网站建设 2026/8/7 18:11:15

GodMode9深度解析:3DS全权限文件浏览器的架构设计与实战应用

GodMode9深度解析&#xff1a;3DS全权限文件浏览器的架构设计与实战应用 【免费下载链接】GodMode9 GodMode9 Explorer - A full access file browser for the Nintendo 3DS console :godmode: 项目地址: https://gitcode.com/gh_mirrors/go/GodMode9 GodMode9是一款专为…

作者头像 李华
网站建设 2026/8/7 18:09:56

Blender四边形重构终极指南:QRemeshify让你轻松获得专业级拓扑

Blender四边形重构终极指南&#xff1a;QRemeshify让你轻松获得专业级拓扑 【免费下载链接】QRemeshify A Blender extension for an easy-to-use remesher that outputs good-quality quad topology 项目地址: https://gitcode.com/gh_mirrors/qr/QRemeshify 你是否曾在…

作者头像 李华