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

发布时间:2026/9/4 6:15:48
基于 ESP-IDF 5.2 + LVGL 8.4 + MVL 0.1.0 实现带UI的 wifi 扫描 基于 ESP-IDF 5.2 LVGL 8.4 MVL 0.1.0 实现带UI的 wifi 扫描后台 WiFi 扫描任务与 LVGL 界面完全解耦UI 永不阻塞、永不崩线程。MVL 仓库https://github.com/smallerxuan/mvlESP-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/933775070. 我们要做什么功能一句话屏幕上有一个Scan 按钮和一个AP 列表。设备启动后自动扫描一次周围 WiFi 并展示点击按钮重新扫描扫描中 / 失败 / 完成状态在界面上有明确提示。功能本身很简单值得写的是工程结构WiFi 扫描在后台任务 / 系统事件回调里完成完全不认识 UIUI 刷新只发生在 LVGL 主任务完全不认识 WiFi两者之间只靠「事件 ID 契约类型 Model 快照」通信。这正是 MVLMVVM-Lite要解决的问题LVGL 8.x 不是线程安全的所有lv_*()调用必须集中在同一个任务单写者原则MVL 用消息队列mvl_msg 事件总线mvl_evt把这条约束变成架构再用 MVVM 分层把「谁该知道谁」解耦清楚。技术栈版本组件版本说明ESP-IDF5.2构建与运行环境LVGL8.4lvgl/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.4espressif/esp_lvgl_port:^2.4.3lvgl/lvgl:^8.4.0idf:version:4.1.03. 第一步接线设计YAML唯一事实源不写代码先设计「谁产生什么状态、谁发什么事件、谁订阅、在哪个上下文执行」。全部接线关系收敛在一个 YAML 里wifi_scan_ui_mvl_project.yamlproject: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 totalevent:EVT_WIFI_SCAN_UPDATEDdoc:写入扫描结果状态置 DONE-name:wifi_scan_stateargs:mvl_wifi_scan_state_t stateevent:EVT_WIFI_SCAN_UPDATEDdoc:更新扫描状态扫描中 / 失败events:-id:EVT_WIFI_SCAN_UPDATED# 状态事件后台 → UIsegment:32payload:none# 大数组不走载荷走「事件 Model 快照」doc:WiFi 扫描结果或状态更新publishers:-{module:wifi_scan,context:sys_evt}subscribers:-{handler:on_wifi_scan_updated,context:lvgl}# ViewModelLVGL 上下文回调-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 statedoc:展示扫描结果列表SCANNING/FAILED 时给出提示设计要点契约类型脱离 esp_wifi.hmvl_wifi_ap_t只含 ssid/rssi/authmode 三个字段。View 层和 Model 都不 include ESP-IDF 的 WiFi 头文件三方共享的只是这份契约载荷为 noneAP 数组这种大数据不走事件载荷载荷固定 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.h、mvl_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(countMVL_WIFI_AP_MAX){countMVL_WIFI_AP_MAX;}if(apscount){memcpy(s_state.wifi_aps,aps,count*sizeof(mvl_wifi_ap_t));}s_state.wifi_ap_countcount;s_state.wifi_ap_totaltotal;s_state.wifi_scan_stateMVL_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_tsmvl_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 */#includewifi_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 */#includewifi_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 生产者扫描完成写 Modelwifi_scan.c在扫描完成事件回调里把wifi_ap_record_t转成契约类型后写 Model写入即自动发布事件UI 随后自动刷新mvl_wifi_ap_taps[DEFAULT_SCAN_LIST_SIZE];for(inti0;(iDEFAULT_SCAN_LIST_SIZE)(inumber);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].rssiap_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. 关键设计点回顾单写者原则全工程只有 LVGL 主任务执行lv_*()其余上下文一律「投消息 / 发事件」由消费点统一执行契约共享、实现不共享三方只共享事件 ID、mvl_wifi_ap_t等契约类型与View 接口签名View 不认识后台队列后台不认识控件大数据走快照事件载荷固定 8 字节值拷贝AP 列表这类数据用「事件 mvl_model_snapshot()」传递根除指针生命周期问题先写后发setter 内互斥写状态 → 解锁 →mvl_evt_publish()订阅者读到事件时快照必为新值YAML 是唯一事实源接线关系改 YAML 重新生成USER CODE 段不丢mvl_events.h等全生成文件不要手改丢事件可观测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。