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的严格分离,这是后续控件单元测试和移植的关键。