4、控件架构设计:MVC模式在HMI中的应用、控件基类设计、事件驱动模型
4.1 MVC模式在HMI中的适配与落地
在工业HMI开发中,MVC(Model-View-Controller)模式并非简单的Web框架移植,而是需要针对嵌入式环境、实时性要求和图形渲染特性进行裁剪。其核心目的是将数据逻辑、显示逻辑与交互逻辑解耦,提升控件的可维护性与复用性。
- Model(模型):负责存储控件的核心数据与状态。例如一个“温度仪表”控件,Model包含当前温度值、报警阈值、单位(℃/℉)等。Model不关心数据如何绘制,只提供数据访问与变更通知接口。
- View(视图):负责将Model中的数据渲染到屏幕上。在HMI中,View通常直接操作底层图形库(如Qt for MCU、emWin、LVGL的绘图API)。View从Model读取数据,并决定绘制颜色、形状、动画效果。
- Controller(控制器):负责处理用户输入(触摸、按键)或外部通信(PLC变量写入),并更新Model。Controller不直接修改View,而是通过Model的接口触发数据变更,再由Model通知View刷新。
HMI中的典型数据流:
PLC/寄存器 → Controller(解析) → Model(更新数据) → Model发出信号 → View(重绘)
用户触摸 → Controller(坐标映射) → Model(修改选中状态) → Model发出信号 → View(高亮显示)
关键设计原则:
- 单向数据流:避免View直接修改Model,所有数据变更必须经过Controller或Model自身的方法。
- 最小刷新:View只重绘Model中发生变化的区域,而非全屏刷新,这对嵌入式性能至关重要。
- 模型独立:Model不依赖任何图形库头文件,使其可被单元测试或跨平台复用。
4.2 控件基类设计:从抽象到具体
所有自定义HMI控件应继承自一个统一的基类(例如 HmiControlBase),该基类定义了控件的生命周期、公共属性和虚函数接口。以下是基类的核心设计要素:
4.2.1 基类核心接口
| 接口名称 | 类型 | 说明 |
|---|---|---|
Initialize() |
虚函数 | 控件创建后的初始化,如分配内存、注册回调 |
Render() |
纯虚函数 | 强制子类实现绘制逻辑,传入当前画布上下文 |
HandleEvent(Event&) |
虚函数 | 处理输入事件,默认实现为空,子类可重写 |
SetPosition(x, y) |
非虚函数 | 设置控件在屏幕上的绝对坐标 |
SetSize(w, h) |
非虚函数 | 设置控件尺寸,触发内部布局重算 |
Invalidate() |
非虚函数 | 标记控件需要重绘,由框架在下一帧执行 |
GetModel() |
纯虚函数 | 返回指向Model对象的指针,用于外部数据绑定 |
4.2.2 基类代码骨架示例(C++风格)
// HmiControlBase.h
class HmiControlBase {
public:
HmiControlBase() : m_x(0), m_y(0), m_width(100), m_height(50), m_dirty(true) {}
virtual ~HmiControlBase() = default;
// 生命周期
virtual void Initialize() {}
virtual void Render(Canvas& canvas) = 0; // 纯虚函数
virtual void HandleEvent(const TouchEvent& event) { /* 默认不处理 */ }
// 公共属性设置
void SetPosition(int x, int y) { m_x = x; m_y = y; Invalidate(); }
void SetSize(int w, int h) { m_width = w; m_height = h; OnSizeChanged(); Invalidate(); }
// 刷新机制
void Invalidate() { m_dirty = true; }
bool IsDirty() const { return m_dirty; }
void ClearDirty() { m_dirty = false; }
// 模型访问
virtual BaseModel* GetModel() = 0;
protected:
virtual void OnSizeChanged() { /* 子类可重写以调整内部布局 */ }
int m_x, m_y, m_width, m_height;
bool m_dirty;
};
4.2.3 控件派生示例:数值显示控件
class NumericDisplay : public HmiControlBase {
public:
NumericDisplay() : m_model(new NumericModel()) {}
void Render(Canvas& canvas) override {
// 从Model读取数据
float value = m_model->GetValue();
// 绘制背景、边框、文本(使用canvas API)
canvas.DrawRect(m_x, m_y, m_width, m_height, Color::Black);
canvas.DrawText(m_x + 5, m_y + 5, std::to_string(value).c_str(), Color::Green);
}
BaseModel* GetModel() override { return m_model.get(); }
private:
std::unique_ptr<NumericModel> m_model;
};
4.3 事件驱动模型:从触摸到响应
工业HMI的事件驱动模型需要处理两类主要事件:用户输入事件(触摸、按键)和系统事件(定时器、数据更新、通信中断)。设计目标是低延迟、可扩展、避免阻塞。
4.3.1 事件类型定义
enum class EventType {
TouchDown,
TouchUp,
TouchMove,
KeyPress,
TimerTick,
DataUpdate, // PLC变量变化
Custom // 用户自定义事件
};
struct Event {
EventType type;
union {
struct { int x, y; } touch; // 触摸坐标
struct { int keyCode; } key; // 按键码
struct { uint32_t timerId; } timer;
struct { int dataId; void* data; } data;
};
};
4.3.2 事件分发机制
采用层级分发策略:事件首先到达屏幕管理器(ScreenManager),然后根据坐标命中测试(HitTest)将事件传递给最上层的可见控件。如果控件不处理,则向上冒泡至父容器。
// 简化的事件循环
void EventLoop::Dispatch(const Event& evt) {
// 1. 坐标命中测试:找到触摸点下的控件
HmiControlBase* target = HitTest(evt.touch.x, evt.touch.y);
if (target) {
// 2. 调用控件的HandleEvent
target->HandleEvent(evt);
// 3. 标记控件重绘
target->Invalidate();
}
// 4. 处理系统事件(如定时器)
ProcessSystemEvents(evt);
}
4.3.3 数据驱动的事件触发
当PLC变量更新时,不应直接调用控件的绘制函数,而是通过事件机制通知Model更新,再由Model触发View刷新。典型实现使用观察者模式:
// Model基类
class BaseModel {
public:
using Callback = std::function<void()>;
void Attach(Callback cb) { m_callbacks.push_back(cb); }
protected:
void Notify() { for (auto& cb : m_callbacks) cb(); }
private:
std::vector<Callback> m_callbacks;
};
// 在控件构造函数中绑定
NumericDisplay::NumericDisplay() {
m_model->Attach([this]() { this->Invalidate(); });
}
4.3.4 事件处理的性能考量
- 避免在事件处理中执行耗时操作:如文件读写、复杂计算应放入后台任务或定时器回调。
- 使用事件队列:在中断服务程序(ISR)中仅将事件压入队列,主循环中顺序处理,防止竞态。
- 合并连续重绘请求:如果一帧内多次调用
Invalidate(),只执行一次实际绘制。
实践建议:在嵌入式HMI中,MVC的“Controller”层往往与事件分发器合并,因为硬件输入源单一(触摸屏+物理按键)。但务必保持Model与View的严格分离,这是后续控件单元测试和移植的关键。