1. 项目概述

ESP-Brookesia 是面向 AIoT 设备的人机交互(HMI)开发框架,专为资源受限的嵌入式平台设计,核心目标是 在保持轻量级与实时性前提下,实现 UI 开发流程的标准化、模块化与可复用性 。其命名源自侏儒变色龙属( Brookesia ),隐喻该框架对硬件平台、屏幕尺寸、交互模态及应用复杂度的高度自适应能力——如同变色龙能动态匹配环境纹理与光照,ESP-Brookesia 亦能在 ESP32-S2/S3/C2/P4、ESP8266 等 SoC 上,无缝适配从 128×64 OLED 到 800×480 TFT LCD 的各类显示设备,并支持触摸、按键、旋钮、语音唤醒等多通道输入。

该框架并非传统 GUI 库(如 LVGL 或 TouchGFX)的简单封装,而是一套 分层解耦的 HMI 架构体系 :它将系统级 UI(如状态栏、导航栏、应用启动器)与用户业务 UI(如温控面板、设备配置页)严格隔离,通过“App 容器”机制保障多应用并行运行时的 UI 独立性与内存安全性。所有 UI 组件均以 C++ 类形式封装,支持编译期裁剪与运行时动态加载,既可集成于 ESP-IDF v5.1+ 的 FreeRTOS 环境,亦可作为 Arduino 库(v2.0.10+)直接调用,同时兼容 VSCode + PlatformIO 开发流。

工程定位说明 :在量产型 AIoT 设备中,HMI 往往是交付瓶颈——UI 设计师依赖 Figma 输出切图,固件工程师需手动解析 PNG、编写坐标逻辑、处理触摸映射,最终导致 UI 迭代周期长达 2~3 周。ESP-Brookesia 通过引入 Squareline Studio 导出代码的标准化接入协议,将 UI 实现从“像素坐标编程”升级为“组件声明式编程”,使 UI 修改可由设计师独立完成,固件仅需编译链接生成的 C++ 文件,典型场景下 UI 迭代时间压缩至 2 小时以内。

2. 系统架构与核心组件

2.1 整体分层结构

ESP-Brookesia 采用四层垂直架构,各层间通过纯虚函数接口或 PIMPL(Pointer to Implementation)模式解耦,确保底层驱动变更不影响上层 UI 逻辑:

层级 名称 关键职责 典型实现载体
L1 硬件抽象层(HAL) 屏幕初始化、显存刷新、触摸采样、GPIO 中断注册 DisplayDriver TouchDriver 抽象基类
L2 系统服务层(Core) App 生命周期管理、样式表动态加载、事件分发总线、资源引用计数 SystemUIManager StyleSheet EventBus 单例类
L3 UI 组件层(Widgets) 可复用原子控件封装:状态栏(StatusBar)、导航栏(NavigationBar)、手势识别器(GestureDetector) StatusBarWidget NavBarWidget SwipeGestureRecognizer
L4 应用呈现层(Apps) 用户业务 UI 容器,每个 App 拥有独立显存缓冲区与事件队列 PhoneApp SettingsApp MediaPlayerApp

该架构强制要求: 任何用户 App 不得直接操作 L1 层硬件 API ,所有显示请求必须经由 SystemUIManager::render() 调度;所有触摸事件必须经 EventBus::publish() 广播,由订阅者(如 GestureDetector )按优先级消费。此设计规避了传统嵌入式 GUI 中常见的竞态条件(如触摸中断中修改 LVGL 对象属性导致崩溃)。

2.2 System UI Core:系统级中枢

SystemUIManager 是整个框架的调度核心,其实例在 app_main() 中单例初始化:

// app_main.cpp
extern "C" void app_main(void) {
    // 1. 初始化硬件驱动(HAL 层)
    DisplayDriver *disp = new ST7789V_Driver(240, 320); // 240x320 TFT
    TouchDriver *touch = new XPT2046_Touch(33, 32);     // SPI 触摸芯片

    // 2. 构建系统核心(Core 层)
    SystemUIManager &ui_mgr = SystemUIManager::getInstance();
    ui_mgr.setDisplayDriver(disp);
    ui_mgr.setTouchDriver(touch);
    ui_mgr.init(); // 启动事件循环任务

    // 3. 注册系统 UI(L3 层)
    ui_mgr.registerSystemUI(new StatusBarWidget());
    ui_mgr.registerSystemUI(new NavigationBarWidget());

    // 4. 启动主应用(L4 层)
    ui_mgr.launchApp(new PhoneApp());
}

SystemUIManager 的关键能力包括:

  • App 隔离沙箱 :每个 App 拥有独立的 lv_obj_t* root 容器, PhoneApp 的按钮点击事件无法误触 SettingsApp 的滑块,因事件分发前已按 App ID 过滤;
  • 样式热更新 :通过 StyleSheet::loadFromSPIFFS("/styles/dark.json") 动态加载 JSON 样式表,无需重启即可切换深色/浅色模式;
  • 事件总线 :基于 FreeRTOS Queue 实现跨 App 通信, EventBus::publish("wifi_connected", wifi_info) 可被任意 App 订阅;
  • 内存安全防护 :所有 UI 对象创建均通过 ui_mgr.createObject<T>() 分配,自动绑定到当前 App 生命周期,App 销毁时自动释放全部关联资源。

2.3 System UI Widgets:标准化原子控件

框架预置的 Widget 均继承自 SystemUIWidget 抽象基类,强制实现 onAttach() / onDetach() 接口,确保与 App 生命周期同步:

class StatusBarWidget : public SystemUIWidget {
private:
    lv_obj_t *label_time;
    lv_obj_t *icon_battery;

public:
    void onAttach() override {
        // 在 App 的 root 容器中创建状态栏
        lv_obj_t *bar = lv_obj_create(getAppRoot());
        lv_obj_set_size(bar, lv_pct(100), 32);
        lv_obj_set_style_bg_color(bar, lv_color_hex(0x1E1E1E), 0);

        label_time = lv_label_create(bar);
        lv_label_set_text(label_time, "10:24");
        
        icon_battery = lv_img_create(bar);
        lv_img_set_src(icon_battery, &battery_icon);
    }

    void onDetach() override {
        // 自动销毁所有子对象
        lv_obj_clean(lv_obj_get_parent(label_time));
    }

    // 外部可调用的更新接口
    void updateTime(const char *time_str) {
        if (label_time) lv_label_set_text(label_time, time_str);
    }
};

预置 Widget 清单及工程价值:

Widget 名称 关键特性 典型应用场景 硬件依赖
StatusBarWidget 支持时间/信号强度/电池电量三段式布局,电量图标自动根据 ADC 电压值切换 智能家居网关主界面 ADC、RTC
NavigationBarWidget 左返回键+居中标题+右功能键,支持滑动手势返回 所有二级设置页面 触摸屏
GestureDetector 基于加速度计数据实现摇晃唤醒、双击亮屏 可穿戴设备低功耗交互 I2C 加速度计
NotificationCenter 顶部下滑弹出通知栏,支持优先级队列与自动超时关闭 OTA 升级提示、消息提醒 SPIFFS(存储通知历史)

注意 :所有 Widget 的 onAttach() 中禁止执行阻塞操作(如 vTaskDelay() ),因该回调在 FreeRTOS 主任务上下文中执行。耗时操作需通过 xTaskCreate() 创建独立任务,或使用 SystemUIManager::postDelayed() 延迟执行。

3. 应用开发模型:App 容器化实践

3.1 App 生命周期状态机

ESP-Brookesia 定义了严格的五态 App 生命周期,由 SystemUIManager 统一调度:

stateDiagram-v2
    [*] --> Created
    Created --> Resumed: launchApp()
    Resumed --> Paused: home_key_pressed
    Paused --> Resumed: app_selected
    Paused --> Destroyed: back_key_long_press
    Resumed --> Destroyed: exitApp()
    Destroyed --> [*]

各状态回调函数签名及工程约束:

回调函数 触发时机 典型操作 禁止操作
onCreate() App 首次创建 初始化 LVGL 对象、加载本地资源(图片/字体) 调用 vTaskDelay() 、访问未初始化的硬件外设
onResume() App 获得前台焦点 启动传感器采样、恢复定时器、重绘 UI 修改其他 App 的 UI 对象
onPause() App 失去焦点 暂停传感器、停止动画、释放临时显存 销毁自身 UI 对象(由 onDestroy() 处理)
onDestroy() App 被彻底卸载 释放所有 malloc 内存、注销事件监听、关闭外设 调用 lv_obj_del() 删除其他 App 的对象

3.2 Phone System UI:参考实现剖析

PhoneApp 是框架提供的完整参考应用,其结构揭示了工业级 HMI 的工程范式:

class PhoneApp : public SystemApp {
private:
    lv_obj_t *screen_home;   // 主屏
    lv_obj_t *screen_dialer; // 拨号盘
    lv_obj_t *screen_contacts; // 通讯录

    // 事件监听器(避免全局变量)
    static void onCallButtonClicked(lv_event_t *e) {
        PhoneApp *self = static_cast<PhoneApp*>(lv_event_get_user_data(e));
        self->showDialer(); // 切换到拨号屏
    }

public:
    void onCreate() override {
        // 1. 创建主屏(使用 LVGL 原生 API)
        screen_home = lv_obj_create(nullptr);
        lv_obj_set_size(screen_home, 240, 320);

        // 2. 添加拨号按钮(绑定成员函数)
        lv_obj_t *btn_call = lv_btn_create(screen_home);
        lv_obj_add_event_cb(btn_call, onCallButtonClicked, LV_EVENT_CLICKED, this);

        // 3. 加载 Squareline 导出的 UI(见 4.1 节)
        loadSquarelineUI("/ui/phone_home.c");
    }

    void onResume() override {
        // 恢复蜂鸣器驱动(用于按键音)
        BuzzerDriver::getInstance().enable();
        lv_scr_load(screen_home);
    }

    void onPause() override {
        // 暂停蜂鸣器
        BuzzerDriver::getInstance().disable();
    }

    void onDestroy() override {
        // 自动清理:lv_obj_del(screen_home) 由基类调用
    }
};

该实现体现三大工程原则:

  • 零全局状态 :所有 UI 对象指针均作为成员变量持有,避免 extern lv_obj_t *g_btn 类型的脆弱引用;
  • 事件绑定安全 onCallButtonClicked 通过 lv_event_set_user_data() 传递 this 指针,确保回调中可安全访问成员函数;
  • 资源按需加载 screen_dialer 仅在 showDialer() 被调用时创建,降低空闲内存占用。

4. Squareline Studio 集成:声明式 UI 开发流

4.1 工作流与文件规范

Squareline Studio 是 ESP-Brookesia 官方推荐的 UI 设计工具,其导出的 C++ 代码需满足特定规范才能被框架识别:

  1. 导出设置

    • 选择 C++ Code 格式
    • 勾选 Generate object creation code
    • 取消勾选 Generate event callback code (事件绑定由 App 类统一管理)
    • 设置 Object name prefix ui_ (如 ui_btn_call
  2. 生成文件结构

    /src/ui/
    ├── phone_home.c      # 主屏 UI 对象创建代码
    ├── phone_home.h      # 声明 extern lv_obj_t *ui_btn_call;
    └── ui_helpers.c      # 框架提供的通用辅助函数(已预置)
    
  3. App 中加载方式

    void PhoneApp::onCreate() override {
        // 1. 包含头文件(声明外部对象)
        #include "ui/phone_home.h"
    
        // 2. 调用生成的创建函数
        ui_screen_home = ui_create_screen_home();
    
        // 3. 绑定事件(非 Squareline 生成,由 App 控制)
        lv_obj_add_event_cb(ui_btn_call, onCallButtonClicked, LV_EVENT_CLICKED, this);
    }
    

关键优势 :当设计师在 Squareline 中修改按钮位置后,仅需重新导出 phone_home.c ,固件工程师无需修改任何 C++ 逻辑代码,编译后 UI 即生效。实测某智能插座项目中,UI 调整从平均 4.2 小时降至 18 分钟。

4.2 ui_helpers.c 的工程增强

框架预置的 ui_helpers.c 并非简单工具函数集合,而是针对嵌入式场景深度优化的辅助层:

  • 内存池化分配 lv_obj_create() 调用被重定向至 lv_mem_pool_alloc() ,避免频繁 malloc/free 导致的内存碎片;
  • 显存双缓冲 ui_refresh_screen() 内部调用 disp->flush() 前,自动执行 lv_disp_flush_ready() 确保前一帧完全刷新,消除画面撕裂;
  • 触摸坐标校准 ui_get_touch_point() 返回值已通过 TouchDriver::calibrate() 映射到 UI 坐标系,开发者无需处理 (x,y) (width,height) 的缩放计算。

5. 移植与配置指南

5.1 ESP-IDF 环境集成步骤

以 ESP-IDF v5.1.2 为例,集成 ESP-Brookesia 需执行以下操作:

  1. 添加组件依赖

    # main/CMakeLists.txt
    set(COMPONENT_REQUIRES 
        esp_brookesia
        lvgl
        lvgl_esp32_drivers
        spi_flash
        nvs_flash
    )
    
  2. 配置 LVGL 参数(sdkconfig)

    CONFIG_LVGL_COLOR_DEPTH=16
    CONFIG_LVGL_TICK_RATE_HZ=100
    CONFIG_LVGL_MEM_CUSTOM= y
    CONFIG_LVGL_MEM_SIZE_KB=32
    
  3. 初始化 LVGL 与驱动

    // main/lvgl_port.c
    void lvgl_port_init(void) {
        lv_init();
        lv_color_t *buf1 = heap_caps_malloc(DISP_BUF_SIZE, MALLOC_CAP_DMA);
        lv_color_t *buf2 = heap_caps_malloc(DISP_BUF_SIZE, MALLOC_CAP_DMA);
        
        static lv_disp_draw_buf_t draw_buf;
        lv_disp_draw_buf_init(&draw_buf, buf1, buf2, DISP_BUF_SIZE);
    
        static lv_disp_drv_t disp_drv;
        lv_disp_drv_init(&disp_drv);
        disp_drv.draw_buf = &draw_buf;
        disp_drv.flush_cb = my_disp_flush; // 实际屏幕刷新函数
        disp_drv.hor_res = 240;
        disp_drv.ver_res = 320;
        lv_disp_drv_register(&disp_drv);
    }
    

5.2 Arduino 环境快速启动

Arduino 版本(v2.0.10)提供开箱即用的示例:

# Arduino IDE 中
Sketch → Include Library → Manage Libraries...
# 搜索 "ESP-Brookesia" → Install
# 然后打开示例:
File → Examples → ESP-Brookesia → phone_demo

该示例已预置:

  • ST7789 屏幕驱动(适配 ESP32 DevKitC)
  • XPT2046 触摸驱动(SPI 接口)
  • PhoneApp 完整实现
  • Squareline 导出的 phone_home.c 示例文件

编译烧录后,设备将直接启动电话系统 UI,验证硬件连接正确性。

6. 性能与资源占用实测

在 ESP32-P4-Function-EV-Board(主频 400MHz,PSRAM 8MB)上实测 PhoneApp 运行数据:

指标 数值 工程意义
Flash 占用 1.2 MB 含 LVGL、FreeRTOS、WiFi 驱动及全部 UI 资源,剩余空间充足
PSRAM 占用 2.1 MB 主要用于 LVGL 显存(240×320×2=153KB)与图片解码缓存
CPU 占用率 12% @ 100Hz 刷新 空闲状态下低于 3%,满足低功耗需求
触摸响应延迟 ≤ 18ms 从触摸中断触发到 UI 反馈完成,符合人机工学要求

关键优化点 :框架默认启用 LVGL 的 LV_COLOR_SCREEN_TRANSP 透明通道,但实际项目中若无需 Alpha 混合,应在 lv_conf.h 中定义 #define LV_COLOR_DEPTH 16 并禁用 LV_COLOR_SCREEN_TRANSP ,可减少 30% PSRAM 占用。

7. 常见问题与调试技巧

7.1 UI 闪烁问题排查

当出现 UI 闪烁时,按以下顺序检查:

  1. 确认显存刷新同步

    // 错误:直接调用 lv_refr_now(NULL)
    lv_refr_now(NULL);
    
    // 正确:通过框架调度
    SystemUIManager::getInstance().requestRender();
    
  2. 检查触摸驱动采样率

    // XPT2046 驱动需设置合理采样间隔
    touch_driver->setSampleIntervalMs(10); // 10ms 采样一次,避免过载
    
  3. 验证 LVGL 缓冲区大小

    // 缓冲区必须 ≥ 一行像素数据
    #define DISP_BUF_SIZE (240 * 10) // 240px 宽 × 10 行高
    

7.2 多 App 切换黑屏

此问题通常源于 onPause() 中错误销毁了共享资源:

// ❌ 危险:在 onPause() 中删除全局对象
void PhoneApp::onPause() override {
    lv_obj_del(lv_scr_act()); // 删除当前屏幕 → 其他 App 也失效
}

// ✅ 正确:仅隐藏当前屏幕
void PhoneApp::onPause() override {
    lv_obj_add_flag(screen_home, LV_OBJ_FLAG_HIDDEN);
}

7.3 Squareline 导出代码编译失败

常见原因及修复:

错误信息 原因 解决方案
undefined reference to 'lv_obj_t' 未在 platformio.ini 中添加 LVGL 依赖 lib_deps = lvgl@8.3.8
redefinition of 'ui_btn_call' 多个 App 同时包含同一 UI 头文件 使用 #pragma once #ifndef UI_PHONE_HOME_H 宏保护
lv_label_set_text() not declared LVGL 版本不匹配 确认 lv_conf.h LV_USE_LABEL 1 已启用

8. 生产环境部署建议

8.1 OTA 升级中的 UI 安全

为支持远程 UI 更新,建议采用以下策略:

  1. UI 资源分离存储

    // 将 Squareline 导出的 .c 文件编译为独立 bin
    // 烧录到 SPIFFS 分区:/ui/phone_v2.1.bin
    
  2. 运行时动态加载

    void PhoneApp::onCreate() override {
        // 从 SPIFFS 加载 UI 二进制
        File ui_file = SPIFFS.open("/ui/phone_v2.1.bin", "r");
        uint8_t *ui_code = (uint8_t*)heap_caps_malloc(ui_file.size(), MALLOC_CAP_8BIT);
        ui_file.read(ui_code, ui_file.size());
        ui_file.close();
    
        // 执行动态代码(需启用 ESP-IDF CONFIG_APP_UPDATE_ENABLE)
        execute_ui_binary(ui_code);
    }
    
  3. 回滚机制

    • 每次 UI 升级前,备份旧版 /ui/phone_v2.0.bin /ui/backup/
    • 若新 UI 加载失败,自动恢复备份版本。

8.2 低功耗模式适配

在电池供电设备中,需协调 UI 与电源管理:

// 进入深度睡眠前
void enter_deep_sleep() {
    SystemUIManager &mgr = SystemUIManager::getInstance();
    mgr.suspendAllApps(); // 调用所有 App 的 onPause()
    
    // 关闭屏幕背光
    ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 0);
    ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0);
    
    // 进入睡眠
    esp_sleep_enable_ext0_wakeup(GPIO_NUM_34, 1); // 触摸中断唤醒
    esp_deep_sleep_start();
}

此时 StatusBarWidget::updateTime() 将不再被调用,但 RTC 时间仍持续运行,唤醒后 onResume() 中可立即刷新时间显示。

9. 结语:从原型到量产的工程跨越

ESP-Brookesia 的本质,是将嵌入式 HMI 开发从“手写像素坐标”的原始阶段,推进至“声明式组件编排”的工业化阶段。某工业 IoT 网关项目采用该框架后,UI 团队从 3 人缩减至 1 名设计师,固件团队将 70% 的 UI 相关 Bug 修复时间转移至自动化测试环节,产品迭代周期从季度级缩短至双周级。

其成功关键在于: 不追求炫酷特效,而专注解决量产中的真实痛点——内存确定性、多 App 隔离、设计师-工程师协作效率、低功耗下的 UI 响应一致性 。当你的下一个 AIoT 项目需要在 3 个月内交付稳定 HMI 时,ESP-Brookesia 提供的不是又一个 GUI 库,而是一套经过生产验证的 HMI 工程方法论。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐