从零开发一个基于Esp32智能手表的项目总结

项目地址:Github仓库
项目演示:B站视频链接

1. 开发工具与依赖管理

本项目采用 VSCode + PlatformIO 构建,大幅简化了环境搭建和组件管理流程。

[env:onehorse32dev]
platform = espressif32
board = onehorse32dev
framework = arduino

# 修改默认分区表。
# 放弃 OTA 功能,释放主程序空间,以容纳 WiFi 协议栈、蓝牙组件及中文字库。
board_build.partitions = huge_app.csv 

# 依赖云端管理。
# 无需手动下载源码和配置路径,高度解耦,便于快速迭代。
lib_deps = 
    olikraus/U8g2
    mathertel/OneButton
    bblanchon/ArduinoJson
    adafruit/Adafruit Unified Sensor
    adafruit/Adafruit MPU6050
    adafruit/Adafruit BusIO

基于 Arduino 框架开发,复用了其丰富的社区生态,缩短了底层验证周期,从而能更专注于上层业务逻辑的构建。

2. 总体架构概览

2.1 HAL 层:硬件抽象与防腐隔离

HAL(硬件抽象层)作为系统交互的地基,主要封装了屏幕显示、按键以及 IMU 传感器。其核心目的是实现接口隔离与防腐层设计。
以屏幕驱动为例,项目使用了 0.96 寸 OLED 屏幕,底层通过 SPI 协议通信。借助 Arduino 生态,直接实例化 U8g2 的硬件子类对象即可完成配置:

U8G2_SSD1306_128X64_NONAME_F_4W_HW_SPI u8g2;
2.1.1 接口隔离:防腐层设计

U8g2 库的接口非常庞大。如果上层 APP 的业务逻辑直接调用 U8g2 的原生方法,会导致业务代码与底层驱动深度耦合。
防腐层的作用:通过构建抽象层,将复杂的原生方法裁剪为项目专属的标准接口。这不仅降低了上层 UI 开发的心智负担,且若未来需要更换屏幕硬件,上层代码无需修改,仅重写底层实现即可完成无缝切换。

2.1.2 按键的封装与回调机制

本项目基于 OneButton 库实现按键的防抖与长/短按检测。由于底层 C 风格的事件绑定接口无法直接接收 C++ 类的非静态成员函数指针,项目引入了“静态跳板”设计模式。通过将类静态成员函数作为中转站,并将当前对象的 this 指针作为上下文参数(Context)传入底层,在触发时进行指针类型还原,完美解决了 C 库与 C++ 面向对象架构的兼容问题。

1.统一回调接口 (APP 层设计)
在 InputHAL 中通过 库定义通用的事件容器 EventCallback。这允许上层 APP 传入包含具体业务逻辑的 Lambda 表达式,而无需暴露 APP 层的内部结构。

btnUp.attachClick([this](){ this->onKeyUp(); }); // 将 onKeyUp 逻辑捆绑到 btnUp 的单击事件上

2.实例绑定与地址传递 (HAL 层向下)
InputHAL 在初始化时,不仅配置引脚,还会将自身的实例地址(this 指针)作为参数传递给 OneButton 的底层接口:

btn.attachClick(_staticClickHandler, this);

3.静态跳板与指针还原 (触发链路)
主循环调用 tick() 监测引脚。当物理按键触发时,底层触发静态函数 _staticClickHandler(void* scope)。
静态函数接收到传入的无类型内存地址(scope)后,通过 static_cast 将其强制还原为 InputHAL 对象指针。随后,打开该对象内部存储的 EventCallback 容器,精准执行 APP 层绑定的业务代码(如向上翻页)。

通过这种封装,按键层完美隐藏了底层的指针转换逻辑,为应用层提供了极其现代、简洁的 C++ 事件注册接口。
核心代码实现:

class InputHAL {
public:
    using EventCallback = std::function<void()>;        // 通用回调函数类型
    InputHAL(uint8_t pin);                              
    void begin();                                       
    void tick();                                        // 放入主循环轮询
    bool isPressed();                                   
    void attachClick(EventCallback cb);                 // 开放给应用层的注册接口

private:
    OneButton btn;
    uint8_t _pin;
    
    EventCallback _onClickCb = nullptr;                 // 存储 APP 层传来的逻辑
    static void _staticClickHandler(void* scope);       // 负责对接底层的静态跳板
};

// ---------------- 以上为 InputHAL.h ----------------

#include "InputHAL.h"

InputHAL::InputHAL(uint8_t pin) : btn(pin, true, true), _pin(pin) {}

void InputHAL::begin() {
    // 将静态跳板函数与当前对象的 this 指针塞给底层库
    btn.attachClick(_staticClickHandler, this);                      
}

void InputHAL::tick() {
    btn.tick();
}

bool InputHAL::isPressed() {
    return digitalRead(_pin) == LOW;
}

void InputHAL::attachClick(EventCallback cb) {
    _onClickCb = cb;
}

// 静态跳板函数实现
void InputHAL::_staticClickHandler(void* scope) {
    if (scope) {
        InputHAL* hal = static_cast<InputHAL*>(scope);               // 1. 指针还原
        if (hal->_onClickCb) {
            hal->_onClickCb();                                       // 2. 执行业务逻辑
        }
    }
}
2.1.3 IMU 传感器封装与架构反思

本项目对 MPU6050 六轴传感器进行了封装(ImuHAL),实现了底层数据滤波、基础的姿态解算(俯仰角/翻滚角),以及简单的计步防抖和抬腕亮屏检测。
架构优化方向:
前期为了快速跑通原型,将计步防抖、抬腕判断等业务算法直接耦合在了底层硬件驱动中。这种设计破坏了代码的单一职责,若有后续功能的增加(如手势识别、睡眠监测),极易导致 HAL 层代码臃肿腐化。
未来的重构重点在于职责抽离:需要将 ImuHAL 削薄,使其仅负责提取纯物理六轴数据,而将复杂的算法与状态判断统一上移至独立的 MotionAlgorithm(算法中间件)。通过彻底隔离硬件层与业务层,大幅提升系统的可维护性与扩展性。

2.2 Model & Service 层:数据总线与后台引擎

完成了底层的硬件封装之后,系统需要一套机制来处理网络请求、蓝牙通信以及数据存储,同时保证这些后台任务不会与前台 UI 产生死锁或相互污染。

2.2.1 Model 层:统一的数据总线(AppData)

在单片机开发中,数据的传递尤为重要。如果各个模块之间相互传参,会出现全局变量漫天飞、代码深度耦合的情况,很容易写成一团乱麻。不仅如此,数据也面临着“掉电保存”与“易失性缓存”的区分。
为此,本项目在 Model 层严格区分了数据的“图纸”与“实体”。首先在 ConfigModels.h 里进行数据结构体的规范定义,例如:

// 用户配置
struct UserConfig {
    char wifi_ssid  [32] = "";
    char wifi_pass  [64] = "";
    char weather_key[64] = "";
};

随后,在 AppData.h 中建立了一个包含所有模块配置的 AppDataModel 结构,并通过 extern 关键字声明为全局唯一的实例,充当系统的“共享数据总线”:

// -- 数据模型结构 -----------------------------
struct AppDataModel {
    // 掉电需保存的数据
    SystemConfig systemConfig;        // 系统配置
    UserConfig userConfig;            // 用户配置
    // ... 其他持久化数据 ...

    // 实时数据
    int batteryLevel = 0;           // 当前电量百分比 (0-100)
    bool isWifiConnected = false;   // WiFi 连接状态
    // ... 其他实时数据 ...
};

extern AppDataModel AppData;

双向数据总线的优势
这种设计形成了一个公共的“黑板(Blackboard)”模型。数据流不再是单向的:Service 层不仅会将拉取的天气写入其中,也会从中读取 WiFi 密码去发起连接;App 层的 UI 不仅从中读取时间进行渲染,在用户设置闹钟或打破游戏记录时,也会直接修改其中的数据。所有模块互不相识,它们只与 AppData 发生交互,从而实现了彻底的业务解耦。

2.2.2 Service 层:协议封装与异步中间件

有了 AppData 作为数据中心,Service 层的作用就是充当“后台引擎”,处理所有复杂的非 UI 逻辑。其核心价值在于隔离底层协议栈的复杂性,并引入多任务并发。
泛型存储封装
对 Arduino 原生的 NVS 存储库(Preferences)进行了二次封装。提供“基础变量直存”与“结构体泛型存储”两套接口。通过 C++ 的 template 泛型模板,实现了对任意复杂结构体计算体积并一键落盘,彻底消除了新增配置项时逐个变量进行序列化的冗余代码。

template <typename T>
void saveStruct(const char* key, const T& data) {
    // 这里的 T 会自动变成 SystemConfig 或 UserConfig
    prefs.putBytes(key, &data, sizeof(T)); 
}

隔离软件复杂性
以网络服务为例,底层实际需要经历 TCP 握手、HTTP 请求发送,以及使用 ArduinoJson 在极小的 RAM 限制下过滤解析数百行 JSON 文本。Service 层将其包装为一个极简的 API(如 NetworkService.getWeatherResult()),向上层屏蔽了所有网络协议细节。
FreeRTOS 异步调度
智能手表极度依赖 UI 响应的实时性。若在主线程中阻塞等待 HTTP 响应或解析长串 JSON,会导致屏幕严重卡顿。为此,Service 层利用 ESP32 的 RTOS 特性引入了 xTaskCreate:

天气刷新:采用动态任务分配。到达更新周期时在后台动态创建独立 Task。为了规避常驻任务对系统堆(Heap)内存的持续占用,任务在执行完 JSON 解析后,通过 vTaskDelete(NULL) 显式触发自销毁,由 FreeRTOS 空闲任务(Idle Task)异步回收其任务堆栈,保障系统可用内存的动态平衡。
蓝牙配网:考虑到 BLE 协议栈开销极大,项目采用了按需冷启动策略。仅在进入配网模式时初始化协议栈并创建轮询监听 Task,退出时彻底销毁释放。通过这种底层的时间片轮转(Round-Robin)调度,将沉重的脏活累活剥离至后台,绝对保障了前台 UI 线程的丝滑运行。

2.3 View 层:基于 Page 接口的模块化渲染框架

View 层在本项目中被设计为“纯渲染层(Stateless View)”。其核心职责是监听数据总线(AppData)的变化,并将其转化为 OLED 屏幕上的视觉像素。该层不负责处理系统级状态切换,以保证界面逻辑的纯粹性与高复用性。

2.3.1 逻辑与渲染的彻底分离

通过定义高度抽象的基类 Page,系统确立了基础的渲染规范:

强制性渲染入口 (draw):定义为纯虚函数,强制所有派生子类必须实现自身的绘制逻辑,从接口层面保证所有页面均可被 AppController 统一调度渲染。
可选事件分发 (onButton):定义为带有默认空实现的虚函数。它并非用于独立应用(App 有更底层的事件接管机制),而是作为 Page 体系的“预留扩展插槽”。系统级的状态流转(如确认键进入菜单)统一由 AppController 拦截,而当某个页面未来需要处理自身特有的局部交互(如表盘按键切换主题)时,Controller 可通过此接口将事件向下透传。当前纯展示的表盘默认忽略按键,遵循最小干扰原则。

2.3.2 复杂界面的模块化绘制

主表盘等复杂页面在设计之初采用了模块化策略。为了避免 draw 函数内部的排版坐标计算过于臃肿,界面渲染被严格拆分为三个独立的私有方法:

状态栏 (drawStatusBar):独立负责顶部图标与电量的显示。
时间区 (drawTimeArea):负责时钟字体的排版。
信息栏 (drawWeatherStepBar):负责底部天气与步数的更新。

draw 函数仅负责顺序调用这些私有方法。这种物理层面的功能抽离保证了各区域坐标计算的独立性,提升了后期排版微调的代码可维护性。

2.3.3 数据驱动的视图复用逻辑

菜单系统严格执行了数据与视图分离的原则。渲染模板本身不具备逻辑功能,它们仅根据 AppController 下发的数据类型(MenuPage)进行被动图形输出:

水平图标菜单 (PageHorizontalMenu):负责渲染一级菜单,采用抛物线算法动态计算图标 Y 轴坐标,模拟圆柱体滚动的视觉深度。
纵向列表菜单 (PageVerticalMenu):负责渲染二级纯文本列表,内置虚拟摄像机 (CameraY) 逻辑实现平滑滚动,并配合跑马灯机制解决窄屏幕下的长文本遮挡问题。

2.3.4 独立于 Page 的全局悬浮层

SystemToast 未继承 Page,而是作为全局 Overlay(覆盖层)独立运行:

渲染优先级:脱离了页面路由机制,在主渲染管线的最后一步被直接调用,确保通知内容固定悬浮于所有当前页面的顶层。
物理曲线动画:抛弃了基于帧数的线性过渡,采用基于时间戳(millis())的 easeOutBack 回弹缓动算法。这保障了即使在系统帧率波动的情况下,动画的物理时长依然恒定,且具有连贯的视觉反馈。

2.4 Controller 层:系统控制与调度中心

在 ESP32 这种典型的嵌入式环境中,严格的模块解耦往往意味着更多的内存开销与通信延迟。因此,本项目在 Controller 层的设计上做出了务实的工程折中:采用高内聚的 AppController 作为微型内核,统一收口底层硬件状态与上层应用路由。

2.4.1 主循环与状态维护

智能手表的首要诉求是前台 UI 的高帧率与后台任务的稳定运行。AppController 放弃了传统的阻塞式流程,在主循环中基于 millis() 时间戳实现了轻量级的时间片轮询:

时间与网络检测:定时检查跨天清零、闹钟触发等功能,并在后台监控 WiFi 状态以发起时间同步。
息屏与省电控制:记录用户最后操作时间,超时后通过发送指令关闭 OLED 显示输出,并在循环中加入适当延时以降低 CPU 功耗。

2.4.2 按键拦截与分发控制

系统对物理按键的响应权限进行了严格的分级管理:

全局控制:在系统桌面或菜单中,由 AppController 直接拦截按键,执行进入菜单、上下翻页或长按保存等系统级指令。
应用接管:当通过 startApp() 启动独立应用(如天气、游戏)时,应用实例会覆盖底层的引脚回调函数。此时 AppController 放弃按键控制权,由应用直接处理输入逻辑,实现了应用操作的独立性。

2.4.3 防御性内存管理与动态重载

考虑到单片机有限的 RAM(特别是运行多级菜单时),必须严格防范指针泄漏:

项目引入了半自动的垃圾回收机制。AppController 维护了一个 pageList 向量,所有由工厂 new 出来的页面指针均登记在册。
当系统发生关键状态变更(如动态切换多语言界面)时,AppController 通过 destroyMenuTree() 统一遍历并 delete 所有历史页面指针,随后触发 MenuFactory 重新装配。这种“销毁即重建”的集中释放策略,从根源上杜绝了因指针失控导致的内存泄漏(Memory Leak)风险。

2.4.4 菜单装配工厂(MenuFactory)

为了防止 AppController 因菜单项的增加而无限膨胀,项目引入了 MenuFactory 负责界面装配:

MenuFactory 及各类 Builder 负责在内存中将菜单名称、图标与执行具体动作的 Lambda 表达式组合成完整的树状结构。
AppController 只需接管装配好的根节点(rootMenu)即可运行整个菜单系统,极大地降低了新增功能时的代码耦合度。

3. 渐进式的应用扩展生态

App 层是系统交付业务价值的终端。为了在不增加系统框架负担的前提下支持多样化的功能,系统抽象出了三级梯度的应用开发范式,允许根据业务复杂度选择最优的接入成本。

3.1 纯菜单绑定的轻量功能

针对系统设置、开关控制等纯信息类交互,系统提供了极简的接入方案。

工程实现:开发者无需编写任何 UI 渲染代码,直接利用 SettingsBuilder 组装系统内置的 PageVerticalMenu 模板,并通过 Lambda 绑定变量修改逻辑。
特点:无需创建任何新类或 Page 实例。它完全复用了系统的列表渲染模板,开发成本极低,且不占用额外的堆内存来维持页面状态

3.2 菜单嵌套的应用启动

针对需要独立运行但入口相对固定的功能,通过二级/多级菜单作为“启动器”来挂载应用。

实现逻辑:以 GamesBuilder 为例,菜单页本身是标准的列表结构,但其条目绑定的动作是 sys->startApp(new GameDino())。
特点:实现了“菜单导航”与“应用运行”的无缝切换。在进入应用前,用户依然处于 MenuController 的路由管辖下;一旦进入,则由 AppController::startApp 完成多态切换,将控制权移交给继承自 AppBase 的具体子类。

3.3 完全独立的定制化开发

针对天气预报、闹钟等具有复杂交互或独特渲染需求的功能,进行全量级的独立开发。

实现逻辑:此类应用(如 WeatherApp、AlarmApp)完全继承并重写了 AppBase 的生命周期函数。
特点:这是架构的最高权限层。应用不仅能接管屏幕渲染,还能在生命周期内直接重新绑定底层的引脚回调,接管物理按键的原始事件,实现了完全不受系统框架限制的沉浸式交互。

4. 项目局限性与客观反思

完成整个项目的开发与全量复盘后,跳出代码本身来看,本项目本质上仍是一个处于实验阶段的 Demo,距离真正的工业级嵌入式产品还有巨大的鸿沟。冷静审视整个研发过程,系统存在以下核心局限性与痛点:

  1. 未涉及功耗管理(核心硬伤):作为一款智能手表项目,功耗控制是衡量其能否商用的核心指标。本项目在开发过程中完全未对 ESP32 的睡眠机制(Light Sleep / Deep Sleep)、外设电源域切断以及动态调频(DFS)进行设计,本质上属于全性能运行的“不计功耗”状态。
  2. 框架依赖与平台移植性差:项目深度依赖 Arduino 生态及相关第三方社区库。虽然在系统内部通过 HAL 层和双向数据总线尝试了应用级的解耦(注:由于数据读写频率极低,此处暂未引入互斥锁保护),但这套架构的眼光和格局仍局限于“针对本硬件叠加功能和 App 插件式开发”。如果未来需要将固件整体无缝移植到其他开发板上,整个底层支撑框架将面临彻底失效的隐患,远未达到优秀开源项目那种完全剥离硬件的通用性。
  3. “简单模式”下的工程妥协:PlatformIO + Arduino 的组合极大地弱化了底层寄存器配置、时钟树初始化、编译链构建等嵌入式开发的底层硬核细节。在享受到快速实现红利的同时,也意味着系统其实是运行在高度封装的“沙盒”内,对底层硬件细节的掌控力依然不足。

总结
这个项目是我从传统 STM32 面向过程开发走向面向对象架构的一次试水。尽管通过这次复盘,我理清了“输入 -> 有限状态机(FSM) -> UI 驱动”的交互闭环,也规范了模块化分层的逻辑,但它暴露出的眼光局限和技术断层同样明显。承认不完美是走向深度的开始,后续的重点将放在脱离框架的底层底座构建、功耗精细化控制以及更通用的平台级解耦设计上,继续向业内成熟的开源架构看齐。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐