从零开发一个基于Esp32智能手表的项目总结
从零开发一个基于Esp32智能手表的项目总结
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,距离真正的工业级嵌入式产品还有巨大的鸿沟。冷静审视整个研发过程,系统存在以下核心局限性与痛点:
- 未涉及功耗管理(核心硬伤):作为一款智能手表项目,功耗控制是衡量其能否商用的核心指标。本项目在开发过程中完全未对 ESP32 的睡眠机制(Light Sleep / Deep Sleep)、外设电源域切断以及动态调频(DFS)进行设计,本质上属于全性能运行的“不计功耗”状态。
- 框架依赖与平台移植性差:项目深度依赖 Arduino 生态及相关第三方社区库。虽然在系统内部通过 HAL 层和双向数据总线尝试了应用级的解耦(注:由于数据读写频率极低,此处暂未引入互斥锁保护),但这套架构的眼光和格局仍局限于“针对本硬件叠加功能和 App 插件式开发”。如果未来需要将固件整体无缝移植到其他开发板上,整个底层支撑框架将面临彻底失效的隐患,远未达到优秀开源项目那种完全剥离硬件的通用性。
- “简单模式”下的工程妥协:PlatformIO + Arduino 的组合极大地弱化了底层寄存器配置、时钟树初始化、编译链构建等嵌入式开发的底层硬核细节。在享受到快速实现红利的同时,也意味着系统其实是运行在高度封装的“沙盒”内,对底层硬件细节的掌控力依然不足。
总结:
这个项目是我从传统 STM32 面向过程开发走向面向对象架构的一次试水。尽管通过这次复盘,我理清了“输入 -> 有限状态机(FSM) -> UI 驱动”的交互闭环,也规范了模块化分层的逻辑,但它暴露出的眼光局限和技术断层同样明显。承认不完美是走向深度的开始,后续的重点将放在脱离框架的底层底座构建、功耗精细化控制以及更通用的平台级解耦设计上,继续向业内成熟的开源架构看齐。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)