news 2026/9/28 4:03:25

LVGL lv_menu实战:构建动态层级菜单与交互界面

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LVGL lv_menu实战:构建动态层级菜单与交互界面

LVGL的官方控件列表里,lv_menu大概是最容易被忽略的一个。我最初扫文档时甚至以为它只是个带侧边栏的列表容器,直到项目里设置页越堆越多、用lv_list手动维护二级三级菜单把回调写成蜘蛛网之后,才认真去读它的源码。结果发现,这个组件替我们做掉了菜单应用里最麻烦的一部分——树状页面关系的切换与返回状态管理。这篇文就把我在实际项目中用lv_menu构建动态交互界面的完整思路和踩坑过程写出来,内容包括核心机制、代码实现、小屏幕适配和高频问题排查,适合正在用STM32、FreeRTOS或Linux跑LVGL、想在设备里做一个能下钻能返回的层级菜单的开发者参考。

1. 为什么是lv_menu:从lv_list和lv_tabview的痛点说起

1.1 覆盖需求:层级菜单不等于标签页,也不等于线性列表

做嵌入式界面,最常见的需求就是“设置”页面。一个设备通常有网络设置、显示设置、声音设置、系统信息,每个一级项下面还有二级项,比如网络设置下面有Wi-Fi列表、IP地址、DHCP开关。用lv_tabview做出来的是并排标签页,适合一级平铺,但一旦出现“进入子页面再返回”的交互,它就没法处理。用lv_list倒是可以实现,但每一级列表的创建、销毁、返回时状态的保持都要自己写,项目一旦变大,回调之间互相调用,代码会变得很乱。

我最初就是用lv_list硬写的。第一节菜单还好,到了二级、三级页面,每点一个条目就要创建一个新的列表对象、注册一个返回按钮,还要手动维护“当前在第几层”的变量。后来需求变了,要在二级页面的侧边栏实时显示当前选中的一级项,lv_list那套代码几乎推倒重来。

三种常见方案的取舍对比:

方案层级支持返回逻辑侧边栏/导航联动适用场景
lv_list需自行维护需自行实现不支持单层列表、消息流
lv_tabview仅一层标签不涉及标签页可切换并列功能模块
lv_menu树状多级内置返回箭头与事件支持侧边栏联动设置页、文件浏览、向导

1.2 lv_menu的本质:一个封装好的树状页面框架

lv_menu不是简单地把lv_list包装一下,它内部维护了一棵页面树。树的每个节点是一个“菜单项”,每个菜单项可以携带一个子页面作为它的下级内容。点击菜单项时,主区域自动切换到对应的子页面,并显示返回箭头;点击返回箭头,主区域回到上一级。这个过程中,你不需要自己去创建和销毁页面,也不需要手动维护层级计数器,只需把菜单项和页面的关系建立好。

这个设计在源码里体现得很清楚:lv_menu内部维护了root、header、sidebar、main四个部分,main区域会跟随点击或返回操作自动切换显示内容。lv_menu_set_page是核心但也被误解得最多的API——它不只是“设置一个页面”,而是把某个页面作为菜单树中的一个子节点挂到指定菜单项下。

1.3 什么项目适合用lv_menu,什么不适合

我从实践中得到的判断标准很简单:如果交互模型是“从一级列表下钻到二级、再返回到一级”,用户脑子里有一棵清晰的树,那lv_menu就非常适合。典型场景是设备设置页、文件管理器、多级配置向导、协议栈状态查看。

反过来,如果你的界面是仪表盘、实时图表、状态总览这类信息密度大且没有层级关系的页面,lv_menu就不是最优解。这类界面更适合用lv_tabview做平级切换,甚至直接用lv_obj配合lv_layout自由拼装。硬套lv_menu反而会因为侧边栏和返回头占据屏幕空间而让信息展示变得局促。

还有一类情况要特别注意:如果你的业务只有一两层菜单,且不太会有子页面之间的状态联动,用lv_list反而比lv_menu更轻量,因为lv_menu内部的对象结构更复杂,内存占用会多出一截。在小内存单片机上,这多出来的开销不一定值得。

2. 拆解lv_menu的三个核心对象:item、page和控制器

2.1 lv_menu_item:菜单项的“最小单元”

lv_menu_item是你在构建菜单时打交道最多的对象,它负责两件事:显示一个可点击的条目,以及承载指向子页面的引用。创建API是lv_menu_item_create(menu),创建后可以设置文本、图标、是否作为主返回头等属性。

细看源码会发现,lv_menu_item有一个重要的标志位字段,用来标记这是一个普通条目还是内置返回箭头条目。官方提供了LV_MENU_ITEM_BUILT_IN_HEADER这类标志,创建返回头时就要用它。这个标志位的行为是:当页面层级大于0时,内置返回头所在的菜单项左侧会自动显示返回箭头,并发出特定事件。你可以在此基础上挂回调,也可以完全不管,让默认的返回逻辑生效。

有一个地方容易绕晕:菜单项和页面不是同一个东西。菜单项显示在一级列表里,它“指向”的二级页面是另一个独立的lv_menu_page对象,页面上再挂多个菜单项。这种“菜单项-页面”松耦合设计,让动态删减节点变得非常灵活。

2.2 lv_menu_page:每一级菜单背后的页面容器

lv_menu_page是一个特殊的LVGL对象,它专门用来容纳一组菜单项。你可以通过lv_menu_page_create(menu)创建页面,然后用lv_menu_cont_create(page)创建页面内的容器,再往容器里挂菜单项。

为什么中间还要多一层lv_menu_cont_create?因为页面内的每一项可能不仅仅是单行文本,有些菜单项需要右侧显示当前值,比如“音量:60%”,或者需要右侧放一个开关控件。容器(cont)就是用来提供这种灵活排版的,它作为一个子节点挂在页面上,容器内部可以自由组合文本、图标、开关、滑块等控件。

这里我建议在工程上强制约定:每个菜单项外面一定包一层cont,不要直接给page添加裸的item。这样后期要在菜单项右边加一个“当前值”标签时,只需要在对应的cont里追加一个label,而不需要改动菜单项本身的属性。

2.3 控制器如何把root、header、sidebar、main串起来

lv_menu控件本身是一个控制容器,它内部拆成四个可视/逻辑分区:

  • main:主内容区,显示当前层级的页面。点击菜单项后,这里切换内容。
  • header:主内容区顶部的标题栏,显示当前页面的标题,也可以放返回箭头。
  • sidebar:侧边栏,显示根级菜单。当你下钻到子页面时,侧边栏可以保持显示根级菜单,让用户始终知道自己从哪来的。
  • root:根页面,菜单树的顶层。它一般同时用作侧边栏数据和主内容区的初始页面。

实际使用中,lv_menu_set_sidebar_page(menu, root_page)把根页面绑定到侧边栏,lv_menu_set_page(menu, target_page)把主内容区切换到某个子页面。这两个调用就是控制菜单导航的核心。lv_menu之所以看起来比lv_list有“高级感”,正是因为它把“当前位置”这个概念内建了,而不是靠外部变量。

2.4 事件流向:从点击item到完成页面切换的完整链路

事件是怎么流动的,决定了你写回调时在哪里插手。lv_menu_item本身是一个对象,它有自己的事件回调。当你点击一个菜单项时,LVGL会发出LV_EVENT_CLICKED,lv_menu内部也要监听这个事件来完成页面切换。这一点很多开发者会搞混:以为点击事件只会送到我们自己的回调里。

实际链路是:菜单项点击后,lv_menu内部的事件处理先执行,它会将该菜单项绑定的子页面设置到主内容区,更新header标题,刷新返回箭头状态;之后,用户注册的点击事件回调才会执行。所以,在用户回调里读lv_menu_get_cur_main_page()时,当前页面已经是新页面了。

有一个常用的技巧是,在创建菜单项时用lv_obj_add_event_cb(item, menu_event_handler, LV_EVENT_CLICKED, user_data)把业务标识作为user_data传进去。这样在回调里就能直接区分点击的是哪个菜单项,而不需要逐个比较对象指针。

3. 实战:动态搭建一个可交互的多级菜单系统

3.1 初始化menu并绑定root页面

先把整体骨架搭起来。后面所有示例代码基于LVGL 8.x语法,如果你用的是9.x,重点留意API的命名调整,思路一致。

lv_obj_t *menu = lv_menu_create(lv_scr_act()); lv_obj_set_size(menu, lv_disp_get_hor_res(NULL), lv_disp_get_ver_res(NULL)); lv_obj_center(menu); /* 创建根页面:菜单树的顶层 */ lv_obj_t *root_page = lv_menu_page_create(menu); lv_menu_set_sidebar_page(menu, root_page); lv_menu_set_page(menu, root_page);

这一步做完,屏幕上会出现一个带侧边栏和主内容区域的空菜单。lv_menu_set_sidebar_page把根页面放到侧边栏,lv_menu_set_page让主内容区初始显示根页面。对多数应用来说,根页面在侧边栏和主内容区各放一份是合理的,用户既能看到顶层菜单的全貌,又能在主区域看到同等的可点击列表。

3.2 构建第一层菜单:创建item并挂到根页面

接下来往根页面里添加菜单项。每个菜单项外面套一个容器:

lv_obj_t *cont = lv_menu_cont_create(root_page); lv_obj_t *item = lv_menu_item_create(menu); lv_obj_t *label = lv_label_create(item); lv_label_set_text(label, "网络设置"); lv_obj_add_flag(item, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_pad_all(item, 16, 0);

lv_menu_item_create创建的item只是一个“壳”,需要往里塞label或icon来显示内容。这也是lv_menu和lv_list不太一样的地方:它不会自动为item创建文本,而是把文本内容的决定权交给你。

如果你的菜单项右侧要显示当前值,可以在同一个cont里再创建一个对齐到右侧的label。这样一排下来,左侧是入口名称,右侧是当前状态,视觉上就和很多设备设置页一致了。

3.3 构建二级页面:把item的sub-page接上

二级页面和一级页面结构完全相同,只是内容更具体。以“网络设置”为例:

/* 创建二级页面 */ lv_obj_t *net_page = lv_menu_page_create(menu); /* 二级页面里的菜单项:Wi-Fi列表 */ lv_obj_t *wifi_cont = lv_menu_cont_create(net_page); lv_obj_t *wifi_item = lv_menu_item_create(menu); lv_label_set_text(lv_label_create(wifi_item), "Wi-Fi 列表"); /* 二级页面里的菜单项:IP 地址 */ lv_obj_t *ip_cont = lv_menu_cont_create(net_page); lv_obj_t *ip_item = lv_menu_item_create(menu); lv_label_set_text(lv_label_create(ip_item), "IP 地址"); /* 把二级页面挂到一级菜单项上 */ lv_menu_set_page(menu, net_page, item);

注意最后一个lv_menu_set_page的参数:第一个是menu对象,第二个是二级页面对象,第三个是承载这个子页面的菜单项对象。这个调用建立的是“点击item后,主内容区显示net_page”的关系。对网络设置这个一级项做完绑定后,点它就能下钻到二级页面。

3.4 事件回调里判断点击来源并响应

菜单的树状关系建好后,你只需要关心哪些点击需要触发业务动作。比如点击“IP地址”要弹出一个设置对话框;点击“Wi-Fi列表”要扫描并显示热点。统一回调可以这样写:

static void menu_event_handler(lv_event_t *e) { lv_obj_t *obj = lv_event_get_target(e); void *user_data = lv_event_get_user_data(e); if (user_data == (void *)ITEM_WIFI_LIST) { /* 触发Wi-Fi扫描,更新菜单项右侧的状态文本 */ lv_label_set_text(status_label_wifi, "扫描中..."); } else if (user_data == (void *)ITEM_IP_ADDR) { /* 弹出IP配置对话框 */ } }

这里用lv_event_get_user_data而不是比较对象指针的原因很实际:对象指针在动态删除和重建后会复用,比较指针有风险。用一个枚举常量作为user_data,即使对象被销毁重建,回调逻辑依然不会错。

3.5 运行时动态增删菜单项

“动态交互”不仅体现在页面切换,还体现在菜单本身可以随时增删。比如设备运行中检测到一个新的传感器节点,要在“设备列表”下动态加一项。

static void add_sensor_entry(const char *name) { if (sensor_page == NULL) { sensor_page = lv_menu_page_create(menu); lv_menu_set_page(menu, sensor_page, sensor_item); } lv_obj_t *cont = lv_menu_cont_create(sensor_page); lv_obj_t *item = lv_menu_item_create(menu); lv_label_set_text(lv_label_create(item), name); /* 记录下cont指针,便于删除 */ sensor_conts[sensor_count++] = cont; }

删除时不能直接删item,要从cont这个父容器入手:

static void remove_sensor_entry(int index) { lv_obj_del(sensor_conts[index]); sensor_conts[index] = NULL; }

直接lv_obj_del(item)往往会把容器内部布局打乱或残留空白占位,因为item和cont是两个层级。删除整个cont是最稳妥的清理方式。如果你把item直接丢在page上而不是包一层cont,删除item也会出问题——item内部的lv_obj结构并不适合单独作为对齐的布局主体,删除后可能出现显示残留,需要手动触发页面重绘。

3.6 动态切换菜单文字、图标与显示状态

设备联网后,菜单项文字要变成“已连接”并显示绿色圆点。这种更新需求在设备状态菜单里很常见。

/* 修改菜单项的文本 */ lv_label_set_text(item_label, "已连接"); /* 修改菜单项图标的颜色,前提是图标用 label 的 text 实现 */ lv_obj_set_style_text_color(item_icon, lv_palette_main(LV_PALETTE_GREEN), 0);

这里要记住:item本身不是一个普通容器,你在lv_menu_item_create之后往它内部塞的label、icon,互相之间是平级的。修改文案时,最好保存label的指针,用一个结构体把item、label、icon、当前值label管理起来,而不是每次都lv_obj_get_child去查找。指针查找在层级浅时没问题,但菜单项一多,可读性和维护性会明显下降。

3.7 侧边栏:让返回层级关系更直观

二级页面对应的一级项,在侧边栏会自动高亮吗?并不会,侧边栏只是把根页面显示出来,高亮逻辑需要自己实现。

我的做法是:menu_event_handler里在LV_EVENT_CLICKED时,用lv_menu_get_sidebar_page(menu)获取侧边栏页面对象,遍历它的子项,找到与当前业务对应的那一个,给它的容器加上一个特殊样式类。这样用户不管下钻到哪一层,侧边栏都能指出入口在根菜单的哪个位置。

如果你嫌侧边栏占地方,可以动态启停它:

lv_menu_set_sidebar_gadget(menu, false);

关闭后侧边栏不显示,菜单变成纯主内容区的层级下钻效果,类似手机设置里的二级页面推进。小屏设备上我建议直接关掉侧边栏,只保留main区域,否则160px宽的屏上再拆出一列侧边栏,主区域只剩不到一半,可读性很差。

4. 小屏幕下的布局、字体、图标与样式改造

4.1 影响观感的三块区域:header、sidebar、main

lv_menu默认样式只是保证“能用”,离“好看”还有很大距离。改造样式之前,先要理解三个区域各自负责什么:

  • header:顶部横条,显示当前页面标题和返回箭头。默认高度大约和字体高度+padding相关,如果你的标题文案比较长,可以单独给它设置更紧凑的高度。
  • sidebar:侧边栏。背景色通常比主区域深一点,用来从视觉上区隔“导航”和“内容”。
  • main:主内容区。承载菜单项的滚动列表,它的padding、滚动条样式直接影响操作体验。

有一个相对省事的统一做法:先给整个menu设置一套字体和主色调,再做局部微调。因为菜单项内部的label默认继承父级字体,菜单项的padding也可以用lv_obj_set_style_pad_all统一调整。

4.2 利用padding、高度和间距做紧凑布局

嵌入式设备屏幕一般不大,常见的分辨率是240x320、320x240、甚至128x160。菜单项如果默认padding很大,一屏只能显示两三项就很糟糕。

/* 压缩菜单项的上下留白 */ lv_obj_set_style_pad_ver(item, 8, 0); lv_obj_set_style_pad_hor(item, 12, 0); /* 设置菜单项最小高度,避免触摸区域太小 */ lv_obj_set_style_min_height(item, 36, 0);

触摸屏设备上,菜单项最小高度我建议做到36~40px以上,否则用户手指容易误触。按键屏(比如实体按键或旋转编码器)则可以适当缩到28px左右,给一屏塞下更多项。

如果设备屏幕只有128px宽,侧边栏就应该彻底关掉,并且header上的返回箭头不要占太多宽度。解决方案是把返回箭头和标题做成同一行,标题左偏移量减小。

4.3 中文字体与内置图标:显示中文不乱码

lv_menu本身不内置任何字体。默认字体只覆盖ASCII字符,直接往label里塞中文会显示成方框。处理中文字体有两个思路:

  • 思路一:用官方推荐的字体转换工具,把项目中用到的中文字符做成子集字体。
  • 思路二:用编码方式动态加载字体,适合字库较大、需要支持用户输入的场景。

如果你只是让设置页面的固定菜单项显示中文,子集字体就够了。把“网络”“显示”“声音”这些固定用字全部放进子集,转换出来的字体文件通常只有几KB,不会对单片机Flash造成明显压力。

图标方面,LVGL内置了一套符号字体,直接用LV_SYMBOL_*宏。例如:

lv_obj_t *icon = lv_label_create(item); lv_label_set_text(icon, LV_SYMBOL_SETTINGS); lv_obj_set_style_text_color(icon, lv_palette_main(LV_PALETTE_BLUE_GREY), 0);

这套内置符号覆盖了齿轮、Wi-Fi、蓝牙、文件夹、返回箭头等常见图标,能省去单独做图标字体的工作量。需要自定义图标时再走字体转换流程,把小尺寸的PNG转成字体文件挂进工程。

4.4 深色主题快速适配

设备屏幕如果是深色外壳,浅色菜单会显得突兀。lv_menu的样式是标准的LVGL对象样式体系,换深色主题时,重点改三个地方:

  • 背景与文字对比度:背景颜色、卡片颜色、文字颜色三者的对比度要拉开,避免灰色字配灰色底。
  • 主色:菜单项选中高亮、返回箭头、侧边栏高亮使用同一个主色,视觉更统一。
  • 滚动条:深色背景下默认滚动条可能看不清,可以加宽并改成半透明白色。

实际项目中建议直接开启lv_conf.h里的LV_THEME_DEFAULT_DARK或在全局主题配置里统一切换。lv_menu的子对象会自动继承大部分背景和文字颜色,你只需要针对header区域和侧边栏做微调。

5. 踩坑排查:lv_menu遇到的5类高发问题

5.1 点击菜单项不跳转,页面没有任何反应

这个问题最常见的原因有两个。

第一个原因:没有调用lv_menu_set_page(menu, sub_page, item)建立“菜单项->子页面”的绑定关系。只创建了page和item,两者没有关联,点击自然不跳转。我排查这类问题时,会先在创建处看到底有没有执行lv_menu_set_page这个调用,没有的话一切都白搭。

第二个原因:item失去了点击能力。不知从什么时候起,在很多示例代码里会看到给item添加LV_OBJ_FLAG_CLICKABLE的写法。如果你用的LVGL版本里lv_menu_item_create默认没有这个flag,item上是点了没反应的。直接检查对象flag,补上这一行:

lv_obj_add_flag(item, LV_OBJ_FLAG_CLICKABLE);

遇到过比较隐蔽的情况是:菜单项外面套的cont覆盖住了item的点击区域。因为cont是父对象,item是子对象,如果cont的尺寸计算异常,item可能被后续创建的同级对象遮住。检查时可以在调试器里临时给每个cont设置不同的背景色,确认实际点击到的对象是不是item本身。

5.2 返回箭头消失或标题栏不显示

返回箭头是lv_menu自动管理的,但它出现的条件是:菜单项作为页面入口且当前页面不是根页面。有一种情况会让返回箭头消失:你没有使用内置返回头能力,而是自己创建了一个“返回”菜单项放到子页面顶部。

这种做法和lv_menu内部的返回机制是冲突的。lv_menu认为当前页面是从某个顶层项下钻进来的,应该在header区域提供返回;但你自己做的那个返回项可能并不触发lv_menu_back,导致用户点击后只在当前页面内做视觉变化,却不返回上一级。

正确做法是,先检查item创建时的标志位,把子页面的第一个item设置成内置返回项(对应LV_MENU_ITEM_BUILT_IN_HEADER),或者直接依赖header上的返回箭头。如果需要完全自绘返回按钮,用lv_menu_back(menu)手动触发返回动作。

5.3 动态删除页面后偶发崩溃或残留花屏

动态删除menu里的页面节点时,最容易踩的坑是事件回调里还持有已经删除对象的指针。场景是这样的:

void event_handler(lv_event_t *e) { lv_obj_t *obj = lv_event_get_target(e); if (obj == sensor_cont_list[0]) { lv_obj_del(sensor_cont_list[0]); } }

触发第一次点击时,sensor_cont_list[0]被删除。如果这段代码在循环里再次匹配到同一索引,就会对已删除对象调用lv_obj_del,直接崩。即便不崩,对象清空后列表末尾会留下空白间距,看起来像花屏。

解决思路:删除节点后将指针置空,并且在回调里判断空指针;同时删除后调用lv_obj_invalidate(menu)强制刷新。菜单项增删本身不会很频繁,没必要追求极致的性能优化,把安全判断做足更实际。

5.4 在FreeRTOS任务中操作lv_menu导致界面卡死

这是多线程环境下最经典的坑。很多人的工程结构是一个GUI任务负责UI,一个业务任务负责逻辑,业务任务里直接调用lv_menu_set_page或lv_label_set_text来更新界面。

LVGL默认不是线程安全的。一个任务调用页面切换,另一个任务正在渲染,就可能出现对象链表被同时修改的竞态。轻则菜单卡一下,重则直接HardFault。

我的习惯是,所有lv_menu相关操作锁进lvgl的上下文临界区里。因为LVGL 8.x提供了lvgl_lock这类锁机制,业务任务更新菜单文本或动态增删菜单项时先拿锁:

extern lv_mutex_t lvgl_mutex; void business_task(void *arg) { while (1) { /* 业务逻辑处理 */ lv_mutex_lock(&lvgl_mutex); lv_label_set_text(menu_item_label, new_status); lv_mutex_unlock(&lvgl_mutex); vTaskDelay(pdMS_TO_TICKS(100)); } }

如果你用的LVGL版本没有现成锁接口,可以自己定义一个互斥量,把“创建菜单项、删除菜单项、切换页面”这几种操作全包进去。只有纯UI绘制阶段才允许不持锁。

5.5 编码器/按键无法聚焦到菜单项上

LVGL的输入机制分指针和按键两类。触摸屏点击天然能命中对象,但旋转编码器或实体按键是通过lv_group来管理焦点的。如果不把lv_menu的核心对象加入group,按键操作没有焦点目标,菜单自然无法响应。

把侧边栏和主内容区都加进group,再设置默认焦点:

lv_group_t *g = lv_group_create(); /* 把menu内部的主要滚动区域加进去 */ lv_group_add_obj(g, main_page); lv_group_add_obj(g, sidebar_page); lv_group_focus_obj(main_page);

按键导航时,焦点子对象在菜单项之间移动,LV_EVENT_FOCUSED会触发样式变化。工程上可以在聚焦样式里加一个边框或背景色,让用户知道当前选中了哪一项。这个交互细节看起来不起眼,但在纯按键设备上直接影响可用性。

我自己在多次改造中,慢慢形成了一个习惯:凡是菜单系统,都要在开发早期确认输入方式是触摸还是按键。这个决定会直接影响lv_menu的侧边栏是否启、菜单项最小高度和group焦点管理。如果让lv_menu的页面树结构和输入方式一起在后期改,改动量会非常大。还有一个调试技巧值得分享:先在PC模拟器上把菜单树和回调逻辑跑通,再进单片机调屏幕尺寸和字体。lv_menu的页面切换、返回逻辑在模拟器和真机行为完全一致,能省掉大量烧录调试时间。用好了这个流程,你会发现lv_menu其实比手搓列表更可靠,因为你不再需要维护那套随时可能出错的自制层级状态机了。

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

Java+MySQL病房管理系统数据库课设:从建表到实验报告避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 4:00:32

嵌入式串行总线对比:I2C、SPI、UART、I2S的原理与选型

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华