第三章:软件架构设计——分层架构、事件驱动、状态机与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 把这些模式组合起来

在实际项目中,这四个模式是协同工作的。我给大家画个蓝图:

  1. HAL层封装MCU外设操作,比如SPI读写LCD。
  2. Driver层基于HAL实现LCD驱动、触摸驱动。
  3. Middleware层运行GUI引擎(如LVGL),它内部使用事件驱动模型处理输入。
  4. Application层用状态机管理页面切换,用MVC模式组织每个页面的逻辑。

事件从触摸屏产生,经过Driver层解析,投递到Middleware层的事件队列。Middleware层处理后,调用Application层的状态机。状态机根据当前状态和事件类型,决定切换到哪个页面。每个页面内部,Controller处理用户操作,更新Model,Model变化后通知View刷新。

这一套组合拳打下来,代码结构清晰,维护起来特别舒服。我后来带团队做项目,都是按这个模板来。新同事上手也快,因为每一层的职责都很明确。

一个小建议:刚开始不要追求完美。先搭一个简单的分层框架,跑通一个页面。然后逐步加入事件驱动、状态机、MVC。一口吃不成胖子,架构也是迭代出来的。

好了,这一章的内容就到这里。下一章咱们聊聊GUI的渲染引擎,看看那些漂亮的界面是怎么一帧一帧画出来的。