基于 ESP-IDF 5.2 + LVGL 8.4 + MVL 0.1.0 实现带UI的 wifi 扫描
2026/9/12 16:25:16 网站建设 项目流程

基于 ESP-IDF 5.2 + LVGL 8.4 + MVL 0.1.0 实现带UI的 wifi 扫描

后台 WiFi 扫描任务与 LVGL 界面完全解耦,UI 永不阻塞、永不崩线程。

MVL 仓库:https://github.com/smallerxuan/mvl
ESP-IDF 5.2 文档:https://docs.espressif.com/projects/esp-idf/zh_CN/v5.2/
LVGL 8.4 文档:https://docs.lvgl.io/8.4/
完整工程链接: https://download.csdn.net/download/smallerxuan/93377507

0. 我们要做什么

功能一句话:屏幕上有一个Scan 按钮和一个AP 列表。设备启动后自动
扫描一次周围 WiFi 并展示;点击按钮重新扫描;扫描中 / 失败 / 完成状态在界面上
有明确提示。

功能本身很简单,值得写的是工程结构

  • WiFi 扫描在后台任务 / 系统事件回调里完成,完全不认识 UI
  • UI 刷新只发生在 LVGL 主任务,完全不认识 WiFi
  • 两者之间只靠「事件 ID + 契约类型 + Model 快照」通信。

这正是 MVL(MVVM-Lite)要解决的问题:LVGL 8.x 不是线程安全的,所有lv_*()
调用必须集中在同一个任务(单写者原则);MVL 用消息队列mvl_msg+ 事件总线
mvl_evt把这条约束变成架构,再用 MVVM 分层把「谁该知道谁」解耦清楚。

技术栈版本:

组件版本说明
ESP-IDF5.2构建与运行环境
LVGL8.4(lvgl/lvgl: ^8.4.0经 ESP Component Registry 拉取
esp_lvgl_port^2.4.3LVGL 与 LCD/触摸的粘合层
MVL0.1.0本教程的主角

1. 整体架构与数据流

两条链路:

  • 状态链路(后台 → UI):扫描完成 →mvl_model_set_wifi_scan_results()
    写 Model → 自动发布EVT_WIFI_SCAN_UPDATED→ ViewModel 回调在 LVGL 上下文
    执行 → 经 View 接口刷新列表;
  • 命令链路(UI → 后台):用户点 Scan → View 回调发布EVT_CMD_WIFI_SCAN
    → 事件按订阅声明路由到 wifi_scan 任务的私有队列 → 任务取出后执行
    wifi_scan_rescan()

关键点:回调绝不在发布者上下文执行。EVT_WIFI_SCAN_UPDATED的订阅者声明
context: lvgl,事件经mvl_msg队列投递到 LVGL 主任务再回调;View 里的
lv_*()因此永远不会跑错线程。

2. 工程结构

wifi_scan_ui/main/ ├── CMakeLists.txt # 组件注册(见下) ├── idf_component.yml # LVGL 等 Registry 依赖 ├── wifi_scan_ui.c # app_main:装配与启动顺序 ├── lvgl_options/ # LCD/触摸/LVGL 初始化(板级,自行适配) ├── wifi_scan/ # 后台:WiFi 扫描(不认识 UI) ├── wifi_ui/ # LVGL 控件层:按钮 + 列表(不认识后台) └── mvvm_lite/ # MVL 库(vendored 拷贝)+ 生成物 ├── include/mvl/ # 库头文件 mvl_msg.h / mvl_evt.h / mvl_port.h / mvl_config.h ├── src/ # mvl_msg.c / mvl_evt.c ├── port/esp_idf/ # esp_idf 移植(SMP 持锁临界区) └── mvl/ # mvl-gen 生成物 + 接线设计(唯一事实源) ├── wifi_scan_ui_mvl_project.yaml ├── mvl_events.h # 事件 ID 表(自动生成,勿手改) ├── mvl_model.c/h # Model 状态中心 ├── mvl_vm.c/h # ViewModel ├── mvl_view_wifi.c/h # View 接口层 ├── mvl_task_wifi_scan.c# wifi_scan 任务的事件消费骨架 └── wiring_report.md # 接线报告(生成时附带)

main/CMakeLists.txt把库源码与生成物一起编进 main 组件:

set(_srcs "lvgl_options/lvgl_options.c" "wifi_scan/wifi_scan.c" "wifi_ui/wifi_ui.c" "mvvm_lite/src/mvl_msg.c" "mvvm_lite/src/mvl_evt.c" "mvvm_lite/port/esp_idf/mvl_port.c" "mvvm_lite/mvl/mvl_model.c" "mvvm_lite/mvl/mvl_vm.c" "mvvm_lite/mvl/mvl_view_wifi.c" "mvvm_lite/mvl/mvl_task_wifi_scan.c") set(_include_dirs "lvgl_options" "wifi_scan" "wifi_ui" "mvvm_lite/include" "mvvm_lite/mvl") idf_component_register(SRCS "wifi_scan_ui.c" ${_srcs} INCLUDE_DIRS "." ${_include_dirs})

idf_component.yml只需 LVGL 相关依赖(MVL 当前为源码拷贝接入,上架
ESP Component Registry 后可改为依赖声明):

dependencies:atanisoft/esp_lcd_touch_xpt2046:"^1.0.4"espressif/esp_lvgl_port:"^2.4.3"lvgl/lvgl:"^8.4.0"idf:version:">=4.1.0"

3. 第一步:接线设计(YAML,唯一事实源)

不写代码,先设计「谁产生什么状态、谁发什么事件、谁订阅、在哪个上下文执行」。
全部接线关系收敛在一个 YAML 里(wifi_scan_ui_mvl_project.yaml):

project:wifi_scan_uimvl_version:0.1config:lvgl_job_pool:8# LVGL 投递池深度(C3 静态检查依据)types:|# 契约类型:Model/View/生产者三方共享,故意脱离 esp_wifi.h#define MVL_WIFI_AP_MAX 10typedef struct{char ssid[33]; int8_t rssi; uint8_t authmode;}mvl_wifi_ap_t; typedef enum{MVL_WIFI_SCAN_IDLE = 0,MVL_WIFI_SCAN_SCANNING,MVL_WIFI_SCAN_DONE,MVL_WIFI_SCAN_FAILED,}mvl_wifi_scan_state_t;model:# 状态中心 mvl_state_t 的字段-{name:wifi_scan_state,type:mvl_wifi_scan_state_t,doc:扫描状态}-{name:wifi_ap_total,type:uint16_t,doc:本次扫描实际发现的 AP 总数}-{name:wifi_ap_count,type:uint16_t,doc:"已保存条数(<= MVL_WIFI_AP_MAX)"}-{name:wifi_aps,type:"mvl_wifi_ap_t[10]",doc:扫描结果列表}setters:# Model 写接口:写状态 + 自动发布绑定事件-name:wifi_scan_resultsargs:"const mvl_wifi_ap_t *aps, uint16_t count, uint16_t total"event:EVT_WIFI_SCAN_UPDATEDdoc:写入扫描结果(状态置 DONE)-name:wifi_scan_stateargs:"mvl_wifi_scan_state_t state"event:EVT_WIFI_SCAN_UPDATEDdoc:更新扫描状态(扫描中 / 失败)events:-id:EVT_WIFI_SCAN_UPDATED# 状态事件(后台 → UI)segment:32payload:none# 大数组不走载荷,走「事件 + Model 快照」doc:WiFi 扫描结果或状态更新publishers:-{module:wifi_scan,context:sys_evt}subscribers:-{handler:on_wifi_scan_updated,context:lvgl}# ViewModel,LVGL 上下文回调-id:EVT_CMD_WIFI_SCAN# 命令事件(UI → 后台)segment:96payload:nonedoc:用户点击 Scan 按钮,请求重新扫描publishers:-{module:view_wifi,context:lvgl}subscribers:-{task:wifi_scan,context:task_queue}# wifi_scan 任务队列消费view_interfaces:-page:wififunctions:-name:show_scan_resultsargs:"const mvl_wifi_ap_t *aps, uint16_t count, uint16_t total, mvl_wifi_scan_state_t state"doc:展示扫描结果列表(SCANNING/FAILED 时给出提示)

设计要点:

  • 契约类型脱离 esp_wifi.hmvl_wifi_ap_t只含 ssid/rssi/authmode 三个字段。
    View 层和 Model 都不 include ESP-IDF 的 WiFi 头文件,三方共享的只是这份契约;
  • 载荷为 none:AP 数组这种大数据不走事件载荷(载荷固定 8 字节值拷贝),
    走「事件 + Model 快照」——事件只表达"状态变了",数据从快照读;
  • 上下文在订阅侧声明context: lvgl表示回调分发进 LVGL 主任务执行;
    context: task_queue表示事件进订阅者自己的队列,任务自己消费。

4. 第二步:生成骨架(mvl-gen)

接线设计定稿后,代码骨架由工具生成,不需要手写样板:

# 静态检查(C1~C8:事件分段冲突、上下文非法、投递池容量等)mvl-gen check main/mvvm_lite/mvl/wifi_scan_ui_mvl_project.yaml# 生成骨架代码 + 接线报告mvl-gen generate main/mvvm_lite/mvl/wifi_scan_ui_mvl_project.yaml-omain/mvvm_lite/mvl/

(也可用图形编辑器mvl-studio勾勾选选完成同一份 YAML。)

生成物中分两类代码:

  • 全生成区:事件表mvl_events.hmvl_state_t定义、订阅注册、任务队列
    骨架等——结构性的,重新生成会被覆盖;
  • USER CODE 段:每个需要业务逻辑的口子都留了
    /* USER CODE BEGIN xxx */ ... /* USER CODE END xxx */标记,
    重新生成时手写代码原样保留——这是 mvl-gen 与"代码生成器一改就丢"的最大区别。

5. 第三步:填充各层实现

骨架生成后,只在 USER CODE 段里填业务。

5.1 Model:写状态 + 发事件

mvl_model.c的 setter 三段式(互斥锁内写状态 → 解锁 → 发事件)已由生成器
搭好,只需填"怎么写":

voidmvl_model_set_wifi_scan_results(constmvl_wifi_ap_t*aps,uint16_tcount,uint16_ttotal){mvl_port_mutex_lock(s_lock);/* USER CODE BEGIN set_wifi_scan_results */if(count>MVL_WIFI_AP_MAX){count=MVL_WIFI_AP_MAX;}if(aps&&count){memcpy(s_state.wifi_aps,aps,count*sizeof(mvl_wifi_ap_t));}s_state.wifi_ap_count=count;s_state.wifi_ap_total=total;s_state.wifi_scan_state=MVL_WIFI_SCAN_DONE;/* USER CODE END set_wifi_scan_results */mvl_port_mutex_unlock(s_lock);/* 先写后发:订阅者读到事件时,快照必已是新值 */mvl_evt_publish(EVT_WIFI_SCAN_UPDATED,NULL);}

「先写后发」的顺序是生成器保证的:订阅者在事件回调里读快照时,拿到的必然是
包含本次变更的新值。

5.2 ViewModel:订阅回调,读快照、调 View 接口

mvl_vm.c里订阅注册已生成(MVL_EVT_CTX_LVGL),回调体填一行:

staticvoidon_wifi_scan_updated(constmvl_evt_t*evt){(void)evt;/* USER CODE BEGIN on_wifi_scan_updated */mvl_state_ts=mvl_model_snapshot();mvl_view_wifi_show_scan_results(s.wifi_aps,s.wifi_ap_count,s.wifi_ap_total,s.wifi_scan_state);/* USER CODE END on_wifi_scan_updated */}

这个回调经mvl_msg队列调度,运行在 LVGL 主任务上下文,可以安全地一路
调到 View。

5.3 View 接口层:全工程唯一认识 UI 控件的地方

mvl_view_wifi.c把语义化调用转给控件层:

/* USER CODE BEGIN view_wifi_includes */#include"wifi_ui.h"/* LVGL 控件层(扫描按钮 + AP 列表) *//* USER CODE END view_wifi_includes */voidmvl_view_wifi_show_scan_results(constmvl_wifi_ap_t*aps,uint16_tcount,uint16_ttotal,mvl_wifi_scan_state_tstate){/* USER CODE BEGIN wifi_show_scan_results */wifi_ui_show_scan_results(aps,count,total,state);/* USER CODE END wifi_show_scan_results */}

5.4 View:按钮回调发布命令事件

wifi_ui.c里 Scan 按钮的点击回调不直接碰 WiFi,只发事件:

staticvoidscan_btn_event_cb(lv_event_t*e){if(lv_event_get_code(e)==LV_EVENT_CLICKED){mvl_evt_publish(EVT_CMD_WIFI_SCAN,NULL);}}

5.5 后台任务:从自己的队列消费命令

mvl_task_wifi_scan.c的队列创建、订阅注册、取事件循环都已生成,
在 case 里填一行:

/* USER CODE BEGIN task_wifi_scan_header */#include"wifi_scan.h"/* wifi_scan_rescan() *//* USER CODE END task_wifi_scan_header */switch(evt.id){caseEVT_CMD_WIFI_SCAN:/* USER CODE BEGIN wifi_scan_EVT_CMD_WIFI_SCAN */wifi_scan_rescan();/* USER CODE END wifi_scan_EVT_CMD_WIFI_SCAN */break;}

5.6 生产者:扫描完成写 Model

wifi_scan.c在扫描完成事件回调里,把wifi_ap_record_t转成契约类型后
写 Model(写入即自动发布事件,UI 随后自动刷新):

mvl_wifi_ap_taps[DEFAULT_SCAN_LIST_SIZE];for(inti=0;(i<DEFAULT_SCAN_LIST_SIZE)&&(i<number);i++){strncpy(aps[i].ssid,(constchar*)ap_info[i].ssid,sizeof(aps[i].ssid)-1);aps[i].ssid[sizeof(aps[i].ssid)-1]='\0';aps[i].rssi=ap_info[i].rssi;aps[i].authmode=(uint8_t)ap_info[i].authmode;}mvl_model_set_wifi_scan_results(aps,number,ap_count);/* 自动发布 EVT_WIFI_SCAN_UPDATED */

注意wifi_scan.c只 includemvl_model.h,不认识任何 UI 头文件。

6. 第四步:装配与启动顺序(app_main)

顺序有讲究,照抄即可:

voidapp_main(void){/* ……nvs / netif / 默认事件循环等 IDF 常规初始化…… *//* 1. MVL 基础设施(须先于任何订阅与事件发布) */mvl_msg_init();mvl_evt_init();mvl_model_init();/* 2. LCD/触摸/LVGL 初始化,创建界面 */lvgl_options_init();wifi_ui_create();/* 3. 挂载 MVL 消费点:LVGL 任务上下文里创建 lv_timer 周期分发 */lvgl_port_lock(0);lv_timer_create(mvl_dispatch_timer_cb,MVL_MSG_DISPATCH_PERIOD_MS,NULL);lvgl_port_unlock();/* 4. 全部订阅注册(须在事件到达前完成) */mvl_vm_init();mvl_task_wifi_scan_init();/* 5. 创建 wifi_scan 任务(从自己的队列消费命令事件) */xTaskCreate((TaskFunction_t)mvl_task_wifi_scan_loop,"mvl_wifi_scan",4096,NULL,5,NULL);/* 6. 触发首次扫描 */wifi_scan();}

消费点就是一个 5~16ms 周期的lv_timerMVL_MSG_DISPATCH_PERIOD_MS),
回调里一句mvl_msg_process(),把队列里的待办在 LVGL 上下文逐个执行。
因为lv_timer本身就跑在 LVGL 任务里,天然满足单写者原则。

7. 关键设计点回顾

  1. 单写者原则:全工程只有 LVGL 主任务执行lv_*();其余上下文一律
    「投消息 / 发事件」,由消费点统一执行;
  2. 契约共享、实现不共享:三方只共享事件 ID、mvl_wifi_ap_t等契约类型与
    View 接口签名;View 不认识后台队列,后台不认识控件;
  3. 大数据走快照:事件载荷固定 8 字节值拷贝,AP 列表这类数据用
    「事件 +mvl_model_snapshot()」传递,根除指针生命周期问题;
  4. 先写后发:setter 内互斥写状态 → 解锁 →mvl_evt_publish()
    订阅者读到事件时快照必为新值;
  5. YAML 是唯一事实源:接线关系改 YAML 重新生成,USER CODE 段不丢;
    mvl_events.h等全生成文件不要手改;
  6. 丢事件可观测mvl_evt_drop_count()正常应恒为 0,调试期接日志,
    非 0 说明lvgl_job_pool/ 任务队列深度需要回炉评估。

8. 验证

  • idf.py build flash monitor:启动日志后界面自动出现 AP 列表;
  • 点 Scan 按钮:列表刷新,扫描中状态有提示;串口可见按钮回调日志,
    无 HardFault / LoadProhibited;
  • 长时间反复点击 + 开关 WiFi 环境变化,观察mvl_evt_drop_count()恒 0。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询