第三章:软件架构设计——分层架构、事件驱动、状态机与MVC模式
各位工程师朋友,这一章咱们聊聊GUI的软件架构。说实话,我见过太多嵌入式项目,功能做出来了,但代码乱成一锅粥。改一个按键响应,要翻遍整个工程。为什么会这样?说白了,就是架构没设计好。
我个人习惯,在动手写第一行代码之前,先把架构图画清楚。今天要讲的这四个东西——分层架构、事件驱动、状态机、MVC模式,是工业级GUI的四大支柱。咱们一个一个来拆。
3.1 分层架构:把代码像抽屉一样整理
分层架构,说白了就是把代码按职责拆成几层。每层只管自己的事,层与层之间通过接口通信。我最早做的一个项目,把所有代码都塞在一个文件里,后来改一个LCD驱动,结果把菜单逻辑搞崩了。从那以后,我再也不敢不分层了。
工业级GUI通常分四层:
| 层级 | 职责 | 典型内容 |
|---|---|---|
| Application | 业务逻辑、界面流程 | 页面切换、数据展示、用户交互逻辑 |
| Middleware | GUI引擎、协议栈 | LVGL/emWin、触摸校准算法、字体引擎 |
| Driver | 硬件抽象接口 | LCD驱动、触摸驱动、Flash驱动 |
| HAL | MCU外设封装 | SPI、I2C、GPIO、DMA操作函数 |
你想想看,如果哪天要换一块LCD屏,只需要改Driver层。Application层的代码完全不用动。这就是分层的好处。
核心原则:上层可以调用下层,但下层绝不能反向依赖上层。这是铁律。
举个例子,Application层想显示一个温度值,它调用Middleware层的GUI绘制函数,Middleware再调用Driver层的LCD写像素函数,Driver再调用HAL层的SPI发送函数。每一层都只做自己该做的事。
3.2 事件驱动模型:别让CPU空转
嵌入式GUI最忌讳的就是轮询。我记得有个项目,用while(1)循环里不断扫描按键,结果CPU占用率飙到80%,动画卡得不行。后来改成事件驱动,CPU占用率直接降到15%。
事件驱动模型的核心思想:系统没事做的时候就休眠,有事件来了才处理。事件可以是按键按下、触摸屏点击、定时器超时、串口收到数据等等。
一个典型的事件驱动框架长这样:
// 事件结构体
typedef struct {
uint8_t type; // 事件类型
uint16_t param1; // 参数1
uint16_t param2; // 参数2
} Event_t;
// 事件队列
Event_t eventQueue[32];
uint8_t head = 0, tail = 0;
// 投递事件
void PostEvent(uint8_t type, uint16_t p1, uint16_t p2) {
eventQueue[tail].type = type;
eventQueue[tail].param1 = p1;
eventQueue[tail].param2 = p2;
tail = (tail + 1) % 32;
}
// 主循环
void main_loop(void) {
while(1) {
if (head != tail) {
Event_t evt = eventQueue[head];
head = (head + 1) % 32;
ProcessEvent(&evt);
} else {
__WFI(); // 进入休眠,等待中断唤醒
}
}
}
我的经验:事件队列的大小要根据项目评估。按键多、触摸频繁的项目,队列至少设64个。我曾经因为队列太小,在高频触摸时丢事件,用户点按钮没反应,排查了两天才找到原因。
3.3 状态机设计模式:让界面逻辑清晰可见
GUI的本质是什么?就是状态切换。从主菜单到设置页面,从设置页面到WiFi配置页面,每个页面就是一个状态。状态机,就是管理这些状态切换的利器。
我见过有人用一堆if-else来管理页面切换,代码写到后面自己都看不懂。用状态机,一张状态转移图就能说清楚所有逻辑。
一个简单的状态机实现:
typedef enum {
STATE_MAIN_MENU,
STATE_SETTINGS,
STATE_WIFI_CONFIG,
STATE_ABOUT,
STATE_MAX
} GUI_State_t;
GUI_State_t currentState = STATE_MAIN_MENU;
void GUI_StateMachine(Event_t *evt) {
switch(currentState) {
case STATE_MAIN_MENU:
if (evt->type == EVT_BUTTON_SETTINGS) {
currentState = STATE_SETTINGS;
EnterSettingsPage();
}
break;
case STATE_SETTINGS:
if (evt->type == EVT_BUTTON_BACK) {
currentState = STATE_MAIN_MENU;
EnterMainMenu();
} else if (evt->type == EVT_BUTTON_WIFI) {
currentState = STATE_WIFI_CONFIG;
EnterWifiConfigPage();
}
break;
// 其他状态处理...
}
}
注意:状态机一定要有超时处理。我曾经遇到一个情况,用户在WiFi配置页面卡住了,因为网络连接失败,但状态机没有超时退出机制,用户只能重启设备。后来我每个状态都加了超时事件处理。
3.4 MVC模式在GUI中的应用
MVC模式,Model-View-Controller,说白了就是把数据、显示、控制逻辑分开。你想想看,如果温度显示控件既负责读取传感器数据,又负责绘制界面,还要处理用户点击事件,那这个控件得有多臃肿?
在嵌入式GUI中,我通常这样划分:
- Model(模型):存放数据,比如温度值、WiFi状态、系统设置。它不关心数据怎么显示。
- View(视图):负责绘制界面。它从Model读取数据,然后画到屏幕上。
- Controller(控制器):处理用户输入。用户点击按钮,Controller更新Model,Model变化后通知View刷新。
举个例子,一个温度显示页面:
// Model
typedef struct {
float temperature;
uint8_t unit; // 0:摄氏, 1:华氏
} TempModel_t;
TempModel_t tempModel;
// View
void TempView_Update(void) {
char buf[16];
sprintf(buf, "%.1f°C", tempModel.temperature);
LCD_DrawString(10, 10, buf);
}
// Controller
void TempController_HandleEvent(Event_t *evt) {
if (evt->type == EVT_BUTTON_UNIT_SWITCH) {
tempModel.unit = !tempModel.unit;
// 单位切换后,重新计算温度值
if (tempModel.unit == 1) {
tempModel.temperature = tempModel.temperature * 1.8 + 32;
} else {
tempModel.temperature = (tempModel.temperature - 32) / 1.8;
}
TempView_Update(); // 通知视图刷新
}
}
关键点:View和Model之间不要直接耦合。View通过观察者模式监听Model的变化。Model变化时,广播通知所有注册的View更新。这样,一个Model可以对应多个View,比如数字显示和仪表盘显示同一个温度值。
3.5 把这些模式组合起来
在实际项目中,这四个模式是协同工作的。我给大家画个蓝图:
- HAL层封装MCU外设操作,比如SPI读写LCD。
- Driver层基于HAL实现LCD驱动、触摸驱动。
- Middleware层运行GUI引擎(如LVGL),它内部使用事件驱动模型处理输入。
- Application层用状态机管理页面切换,用MVC模式组织每个页面的逻辑。
事件从触摸屏产生,经过Driver层解析,投递到Middleware层的事件队列。Middleware层处理后,调用Application层的状态机。状态机根据当前状态和事件类型,决定切换到哪个页面。每个页面内部,Controller处理用户操作,更新Model,Model变化后通知View刷新。
这一套组合拳打下来,代码结构清晰,维护起来特别舒服。我后来带团队做项目,都是按这个模板来。新同事上手也快,因为每一层的职责都很明确。
一个小建议:刚开始不要追求完美。先搭一个简单的分层框架,跑通一个页面。然后逐步加入事件驱动、状态机、MVC。一口吃不成胖子,架构也是迭代出来的。
好了,这一章的内容就到这里。下一章咱们聊聊GUI的渲染引擎,看看那些漂亮的界面是怎么一帧一帧画出来的。