# DTU-HMI 系统架构设计文档 ## 1. 文档目的 本文档用于描述 `DTU-HMI` 工程的整体架构、核心模块职责、关键数据流、线程与通信模型、构建与测试体系,作为后续开发、联调、测试与维护的统一基线。 ## 2. 系统概述 `DTU-HMI` 是一个基于纯 C 实现的 PC 端 HMI 菜单逻辑模拟系统,目标是复现现场 DTU 设备的人机界面行为。 系统支持本地菜单交互与远程显示协议(RemoDispBus)通信,主要包含: - 菜单树构建与路由(多级菜单、同级循环导航) - LCD 显存与绘制(160x160 单色像素缓冲) - 按键输入(本地/远程按键注入) - TCP 服务(远程显示数据交互) - 跨平台线程与网络适配(Windows/Linux) ## 3. 架构目标与设计原则 - **可移植性**:通过 `tcp.c`、`thread_utils.c` 封装平台差异。 - **可维护性**:按模块职责划分(菜单/显示/网络/线程/输入)。 - **可测试性**:优先抽取并覆盖纯逻辑函数,逐步推进集成测试。 - **低耦合高内聚**:上层业务通过明确接口调用下层能力。 ## 4. 系统分层架构 ```mermaid block-beta columns 1 a["菜单"] block:textdraw columns 2 a1["文字显示"] a2["图像显示"] end block:draw columns 2 block:text_group columns 2 block:left_text columns 1 b["Lcd_ShowStr"] block:showstr columns 2 s1["Lcd_Pub_Ascii"] s2["Lcd_Pub_UTF8"] end end b1["utf8_next"] end block:draw_group columns 4 c1["Lcd_LineH"] c2["Lcd_LineV"] c3["Lcd_Invert"] c4["Lcd_FillRect"] end end block:lcd lcda["Lcd_Init"] lcdb["Lcd_SetPixel"] lcdc["Lcd_GetPixel"] end ``` ### 4.1 应用层 - 文件:`src/main.c` - 职责: - 系统初始化(菜单、按键、TCP 线程) - 主循环调度(菜单路由、周期显示刷新) - 生命周期管理(线程退出、网络清理) ### 4.2 菜单业务层 - 文件:`src/Drv/menu/`、`src/Drv/menu/app/menu.h` - 职责: - 采用 MVP 分层组织菜单模块(`Model / Presenter / View`) - 基于静态菜单模型构建运行时菜单树 - 处理按键驱动的菜单状态迁移与路径重建 - 执行菜单显示坐标计算与多级菜单渲染调度 ### 4.3 多页面管理层设计 **设计思想:** 基于经典的 MVP 范式,彻底解耦**数据、视图、控制逻辑**,解决复杂 UI 与业务逻辑的协同维护问题。 - **Model(模型层)**:纯业务数据与状态管理,负责系统参数、设备状态、采集数据的读写、校验、存储,与 UI 完全无关。 - **View(视图层)**:纯渲染显示,仅根据 Model 的数据绘制菜单界面、焦点高亮、弹窗、动画,不处理任何业务逻辑。 - **Presenter(控制层)**:核心调度中枢,接收输入事件、更新 Model 数据、控制 View 刷新、处理菜单跳转逻辑,是 Model 与 View 的唯一桥梁。 目录结构: `src/Drv/pages`: 这个文件夹下面放不同的页面 `src/Drv/pages/global`: 全局状态管理器,页面之间交互的中间件 `src/Drv/pages/global/renderer_lcd.c`: 这个文件是抽象底层的lcd,给所有页面提供一个统一的调用接口。 `src/Drv/pages/global/renderer_lcd.h`: 这个文件是抽象底层的lcd,给所有页面提供一个统一的调用接口。 `src/Drv/pages/menu`: 项目的菜单逻辑 `src/Drv/pages/menu/model`: 菜单的模型层 `src/Drv/pages/menu/view`: 菜单的视图层 `src/Drv/pages/menu/Presenter`: 菜单的控制层 `src/Drv/pages/menu/page.c`: 菜单的页面逻辑 `src/Drv/pages/menu/page.h`: 菜单的页面逻辑 `src/Drv/pages/...`: 其他页面 整个多页面管理系统分为 5 个核心模块,从上到下形成完整的调度闭环,与底层 MVP 架构无缝衔接 ``` ┌─────────────────────────────────────────────────────────────┐ │ 全局状态管理器(Global Model) │ │ (跨页面共享数据、持久化配置、系统全局状态、观察者通知) │ ├─────────────────────────────────────────────────────────────┤ │ 页面管理器核心(Page Manager) │ │ ┌─────────────────────────────────────────────────────────┐│ │ │ 页面栈(Page Stack 静态数组) ││ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││ │ │ │ 栈顶页面 │ │ 后台页面1 │ │ 后台页面2 │ ... ││ │ │ │(前台显示) │ │(暂停状态) │ │(暂停状态) │ ││ │ │ └─────────────┘ └─────────────┘ └─────────────┘ ││ │ └─────────────────────────────────────────────────────────┘│ │ 生命周期调度器 | 事件分发器 | 页面跳转核心接口 │ ├─────────────────────────────────────────────────────────────┤ │ 标准化页面抽象(Page 接口层) │ │ 每个页面独立封装:生命周期钩子 + 专属MVP三元组 │ │ ┌─────────────────────────────────────────────────────────┐│ │ │ Page 基础属性:页面ID、缓存标志、创建状态 ││ │ │ 生命周期钩子:on_create/on_enter/on_exit/on_destroy ││ │ │ 运行入口:on_event(事件处理)、on_loop(主循环) ││ │ │ 专属MVP三元组:Model(私有) → Presenter → View ││ │ └─────────────────────────────────────────────────────────┘│ ├─────────────────────────────────────────────────────────────┤ │ 底层支撑层(与MVP架构无缝衔接) │ │ 显示HAL | 输入HAL | 基础图形库 | 硬件驱动 │ └─────────────────────────────────────────────────────────────┘ ``` #### 4.3.1 标准化页面抽象(Page 接口层) 这是整个系统的基础单元,**每个独立业务页面对应一个 Page 实例,每个 Page 内部封装一套完全独立的 MVP 三元组**,对外仅暴露标准接口,页面管理器仅通过标准接口与页面交互,完全不关心页面内部的 MVP 实现,实现页面间的彻底解耦。 ```c // 编译期静态配置(根据MCU资源调整) #define MAX_PAGE_STACK_DEPTH 5 // 最大页面栈深度,防止栈溢出 #define MAX_PAGE_COUNT 8 // 系统支持的最大页面总数 #define EVENT_QUEUE_LENGTH 8 // 输入事件队列长度 // 页面唯一ID枚举(所有页面必须在此定义,用于页面查找、防重复入栈) typedef enum { PAGE_ID_NONE = 0, PAGE_ID_HOME, // 首页(主页面,栈底常驻) PAGE_ID_SETTINGS, // 设置页面 PAGE_ID_TEMP_DETAIL, // 温度详情页面 PAGE_ID_ABOUT, // 关于页面 PAGE_ID_MAX } page_id_t; // 事件处理结果枚举 typedef enum { EVENT_UNHANDLED = 0, // 事件未被消费,继续分发 EVENT_HANDLED = 1 // 事件已被消费,终止分发 } event_result_t; // 页面结构体前置声明 typedef struct page_t page_t; ``` #### 4.3.2 页面标准接口与生命周期定义 嵌入式场景下,生命周期钩子的设计必须严格对应资源的申请 / 释放时机,避免 RAM 浪费和低功耗异常,每个钩子的执行时机、职责完全固定,禁止越权操作。 ```c // 页面标准接口结构体 struct page_t { // ===================== 页面基础属性 ===================== page_id_t page_id; // 页面唯一ID,不可重复 bool is_cached; // 缓存标志:true=出栈时不销毁,仅暂停;false=出栈时彻底销毁 bool is_created; // 内部状态标志:页面是否已执行on_create,外部只读 // ===================== 生命周期钩子函数 ===================== /** * @brief 页面第一次创建时执行,仅执行1次 * @note 职责:MVP三元组初始化、静态资源申请(字体/图标缓冲区)、回调绑定 * @note 禁止:耗时操作、屏幕绘制、动态内存申请 */ void (*on_create)(page_t *page); /** * @brief 页面进入前台(成为栈顶)时执行,每次进入都执行 * @note 职责:从Model拉取最新数据、全量UI绘制、开启定时器/传感器、注册事件回调 * @note 禁止:资源销毁、MVP初始化操作 */ void (*on_enter)(page_t *page); /** * @brief 页面离开前台(被覆盖/出栈)时执行,每次离开都执行 * @note 职责:关闭定时器/传感器、保存临时数据、注销事件回调、低功耗准备 * @note 禁止:屏幕绘制、MVP初始化、耗时操作 */ void (*on_exit)(page_t *page); /** * @brief 页面彻底销毁时执行,仅执行1次 * @note 职责:释放所有申请的资源、MVP实例销毁、回调注销 * @note 仅非缓存页面出栈时会触发,缓存页面不会触发 */ void (*on_destroy)(page_t *page); // ===================== 运行时入口 ===================== /** * @brief 事件处理入口,页面管理器仅将事件分发给栈顶页面的此函数 * @note 职责:将事件转发给内部View,由View判断命中并转发给Presenter * @return 事件是否被消费 */ event_result_t (*on_event)(page_t *page, input_event_t *event); /** * @brief 主循环入口,页面管理器仅调用栈顶页面的此函数 * @note 职责:执行页面Presenter的主循环逻辑、UI动画刷新、状态机轮询 * @note 禁止:阻塞操作、耗时计算 */ void (*on_loop)(page_t *page); // ===================== 页面专属MVP三元组 ===================== // 每个页面独立拥有,与其他页面完全隔离,页面管理器不直接访问 void *presenter; // 页面对应的Presenter实例指针 void *view; // 页面对应的View实例指针 void *model; // 页面对应的私有Model实例(仅当前页面使用) }; ``` #### 4.3.3 页面 MVP 三元组的绑定规则 - **完全隔离**:每个页面的 Model 仅管理当前页面的私有数据,禁止跨页面访问;View 仅负责当前页面的 UI 绘制;Presenter 仅调度当前页面的业务逻辑 - **绑定时机**:MVP 三元组的初始化、绑定,必须在页面的`on_create`钩子中完成,保证页面创建时 MVP 已就绪 - **销毁时机**:MVP 实例的资源释放,必须在页面的`on_destroy`钩子中完成,避免资源泄漏 - **跨页面交互**:禁止页面 A 的 Presenter 直接调用页面 B 的任何接口,所有跨页面数据同步必须通过全局状态管理器完成 #### 4.3.4 栈式页面管理器(核心调度中枢) 这是整个系统的核心,通过静态数组实现的栈结构管理所有页面,负责页面跳转的核心逻辑、生命周期的精准调度、输入事件的统一分发、主循环的统一调度。 **页面管理器结构体定义** ```c // 页面管理器结构体 typedef struct { page_t *page_stack[MAX_PAGE_STACK_DEPTH]; // 页面栈,静态数组实现 int8_t stack_top; // 栈顶指针,初始值为-1(空栈) } page_manager_t; // 全局页面管理器单例(整个系统仅一个实例) extern page_manager_t g_page_manager; ``` **基础接口:初始化与栈状态查询** ```c // 全局页面管理器实例 page_manager_t g_page_manager; /** * @brief 页面管理器初始化,系统启动时调用1次 */ void page_manager_init(page_manager_t *manager) { memset(manager, 0, sizeof(page_manager_t)); manager->stack_top = -1; // 初始化为空栈 } /** * @brief 获取当前栈顶页面(前台显示页面) * @return 栈顶页面指针,空栈返回NULL */ page_t* page_manager_get_top(page_manager_t *manager) { if (manager->stack_top < 0) { return NULL; } return manager->page_stack[manager->stack_top]; } ``` **页面入栈(Push,页面跳转)** - 入参校验:检查栈是否已满、新页面是否合法、是否重复入栈 - 触发旧栈顶页面的`on_exit`:当前页面被覆盖,执行暂停逻辑 - 新页面入栈,栈顶指针 + 1 - 若新页面未创建,触发`on_create`:执行 MVP 初始化,仅执行 1 次 - 触发新页面的`on_enter`:新页面进入前台,执行 UI 绘制与业务启动 - 新页面成为前台页面,接收事件与主循环调度 ```c /** * @brief 页面入栈(跳转到新页面) * @param manager 页面管理器实例 * @param new_page 待入栈的新页面实例 * @return 0=成功,负数=错误码 */ int page_manager_push(page_manager_t *manager, page_t *new_page) { // 入参合法性校验 if (manager == NULL || new_page == NULL) { return -1; } // 栈满校验,防止栈溢出 if (manager->stack_top >= MAX_PAGE_STACK_DEPTH - 1) { return -2; } // 防重复入栈:当前栈顶已是该页面,无需重复操作 page_t *current_top = page_manager_get_top(manager); if (current_top != NULL && current_top->page_id == new_page->page_id) { return 0; } // ===================== 生命周期执行 ===================== // 1. 触发当前栈顶页面的on_exit(离开前台) if (current_top != NULL && current_top->on_exit != NULL) { current_top->on_exit(current_top); } // 2. 新页面入栈 manager->stack_top++; manager->page_stack[manager->stack_top] = new_page; // 3. 页面首次创建,触发on_create(仅执行1次) if (!new_page->is_created) { if (new_page->on_create != NULL) { new_page->on_create(new_page); } new_page->is_created = true; } // 4. 触发新页面的on_enter(进入前台) if (new_page->on_enter != NULL) { new_page->on_enter(new_page); } return 0; } ``` **页面出栈(Pop,页面返回)** - 入参校验:检查栈是否为空、是否为栈底首页(禁止出栈) - 触发当前栈顶页面的`on_exit`:当前页面离开前台,执行退出准备 - 若当前页面为非缓存页面,触发`on_destroy`:彻底销毁页面,释放资源 - 栈顶指针 - 1,上一个页面恢复为栈顶 - 触发新栈顶页面的`on_enter`:页面恢复前台,执行数据刷新与 UI 重绘 - 新栈顶页面成为前台页面,接收事件与主循环调度 ```c /** * @brief 页面出栈(返回上一页) * @param manager 页面管理器实例 * @return 0=成功,负数=错误码 */ int page_manager_pop(page_manager_t *manager) { // 入参校验 if (manager == NULL) { return -1; } // 禁止空栈出栈、禁止栈底首页出栈(保证首页常驻) if (manager->stack_top <= 0) { return -2; } // ===================== 生命周期执行 ===================== // 1. 获取当前栈顶页面(待出栈页面) page_t *current_page = manager->page_stack[manager->stack_top]; // 2. 触发当前页面的on_exit(离开前台) if (current_page->on_exit != NULL) { current_page->on_exit(current_page); } // 3. 非缓存页面:触发on_destroy,彻底销毁,释放资源 if (!current_page->is_cached) { if (current_page->on_destroy != NULL) { current_page->on_destroy(current_page); } current_page->is_created = false; } // 4. 栈顶指针减1,页面出栈 manager->stack_top--; // 5. 触发新栈顶页面的on_enter(恢复前台) page_t *new_top = manager->page_stack[manager->stack_top]; if (new_top->on_enter != NULL) { new_top->on_enter(new_top); } return 0; } ``` **统一事件分发机制** 嵌入式多页面 GUI 的核心痛点之一是事件误响应,必须保证**仅当前前台页面(栈顶)可接收输入事件**,后台页面完全无法接收事件,避免出现「设置页面按键触发首页逻辑」的 bug - 底层 Input HAL 在中断中采集输入事件(按键、触摸、编码器),投递到全局静态环形事件队列 - 页面管理器在主循环中从事件队列取出事件 - 获取当前栈顶前台页面,将事件分发给页面的`on_event`入口 - 页面将事件转发给内部 View,View 判断事件命中,转发给 Presenter 处理 - 若页面返回`EVENT_UNHANDLED`(事件未被消费),则执行全局事件处理(例如全局返回键、电源键 ```c // 全局事件处理(示例:全局返回键) static void global_event_handle(input_event_t *event) { // 全局返回键:无论哪个页面,按返回键都执行出栈 if (event->type == EVENT_TYPE_KEY_DOWN && event->key_id == KEY_ID_BACK) { page_manager_pop(&g_page_manager); } } // 页面管理器事件分发逻辑(集成在主循环中) static void page_manager_dispatch_event(page_manager_t *manager) { input_event_t event; // 从事件队列取出事件 if (!input_event_get(&event)) { return; } // 获取当前栈顶页面 page_t *current_page = page_manager_get_top(manager); if (current_page == NULL) { return; } // 分发给当前页面 event_result_t result = EVENT_UNHANDLED; if (current_page->on_event != NULL) { result = current_page->on_event(current_page, &event); } // 页面未消费,执行全局事件处理 if (result == EVENT_UNHANDLED) { global_event_handle(&event); } } ``` #### 4.3.4 全局状态管理器(跨页面数据同步) 多页面场景下,必然存在跨页面数据共享的需求(例如设置页修改温度单位,首页、详情页都要同步更新),如果直接让页面间互相调用,会彻底破坏 MVP 的解耦原则,导致代码耦合混乱。 全局状态管理器的核心设计思路是:**实现一个全局单例的 Model(GlobalModel),作为跨页面数据的唯一可信源,通过观察者模式实现数据变化的同步通知,完全不破坏各页面 MVP 的独立性** #### 4.3.5 完整页面实现示例 以下是一个完整的页面实现,包含生命周期钩子、MVP 三元组绑定、页面跳转逻辑,可直接落地使用。 **设置页面 MVP 定义** ```c // ===================== 设置页面Model ===================== typedef struct { float temp_offset; // 温度校准偏移 uint8_t temp_unit; // 温度单位 bool backlight_auto; // 自动背光开关 // 数据变化回调 void (*on_data_change)(void *context); void *context; } settings_model_t; // ===================== 设置页面View接口 ===================== typedef struct { void (*update_temp_offset)(void *view, float offset); void (*update_temp_unit)(void *view, uint8_t unit); void (*update_backlight_state)(void *view, bool auto_on); void (*draw)(void *view); } settings_view_interface_t; // ===================== 设置页面Presenter ===================== typedef struct { page_t *page; settings_model_t *model; settings_view_interface_t *view; } settings_presenter_t; ``` **设置页面实例与生命周期实现** ```c // 静态页面实例与MVP实例(静态内存,无动态分配) static page_t settings_page; static settings_model_t settings_model; static settings_view_interface_t settings_view; static settings_presenter_t settings_presenter; // ===================== 生命周期钩子实现 ===================== // 页面创建:MVP初始化,仅执行1次 static void settings_page_on_create(page_t *page) { // 1. 初始化Model memset(&settings_model, 0, sizeof(settings_model_t)); // 从Flash读取持久化配置 settings_model.temp_unit = global_model_get_temp_unit(); settings_model.temp_offset = flash_read_temp_offset(); settings_model.backlight_auto = flash_read_backlight_auto(); // 绑定Model回调 settings_model.context = &settings_presenter; settings_model.on_data_change = settings_presenter_on_model_change; // 2. 初始化View接口 memset(&settings_view, 0, sizeof(settings_view_interface_t)); settings_view.update_temp_offset = settings_view_update_temp_offset; settings_view.update_temp_unit = settings_view_update_temp_unit; settings_view.update_backlight_state = settings_view_update_backlight_state; settings_view.draw = settings_view_draw; // 3. 初始化Presenter,绑定MVP memset(&settings_presenter, 0, sizeof(settings_presenter_t)); settings_presenter.page = page; settings_presenter.model = &settings_model; settings_presenter.view = &settings_view; // 4. 绑定MVP到页面 page->model = &settings_model; page->view = &settings_view; page->presenter = &settings_presenter; } // 页面进入前台:刷新UI,开启定时器 static void settings_page_on_enter(page_t *page) { settings_presenter_t *presenter = (settings_presenter_t *)page->presenter; // 从Model拉取数据,刷新UI presenter->view->update_temp_offset(presenter->view, presenter->model->temp_offset); presenter->view->update_temp_unit(presenter->view, presenter->model->temp_unit); presenter->view->update_backlight_state(presenter->view, presenter->model->backlight_auto); // 全量绘制页面 presenter->view->draw(presenter->view); // 开启背光定时器 backlight_timer_start(); } // 页面离开前台:保存数据,关闭定时器 static void settings_page_on_exit(page_t *page) { settings_model_t *model = (settings_model_t *)page->model; // 关闭背光定时器 backlight_timer_stop(); // 保存配置到Flash flash_write_temp_offset(model->temp_offset); flash_write_backlight_auto(model->backlight_auto); // 同步全局状态 global_model_set_temp_unit(model->temp_unit); } // 页面销毁:释放资源 static void settings_page_on_destroy(page_t *page) { memset(page->model, 0, sizeof(settings_model_t)); memset(page->presenter, 0, sizeof(settings_presenter_t)); page->model = NULL; page->view = NULL; page->presenter = NULL; } // 事件处理入口 static event_result_t settings_page_on_event(page_t *page, input_event_t *event) { settings_view_interface_t *view = (settings_view_interface_t *)page->view; // 转发事件给View处理 return settings_view_handle_event(view, event); } // 主循环入口 static void settings_page_on_loop(page_t *page) { settings_presenter_t *presenter = (settings_presenter_t *)page->presenter; // 执行Presenter主循环逻辑(长按检测、动画刷新等) settings_presenter_loop(presenter); } // ===================== 页面实例获取接口 ===================== page_t* settings_page_get_instance(void) { memset(&settings_page, 0, sizeof(page_t)); settings_page.page_id = PAGE_ID_SETTINGS; settings_page.is_cached = true; // 缓存页面,出栈不销毁 // 绑定生命周期钩子 settings_page.on_create = settings_page_on_create; settings_page.on_enter = settings_page_on_enter; settings_page.on_exit = settings_page_on_exit; settings_page.on_destroy = settings_page_on_destroy; settings_page.on_event = settings_page_on_event; settings_page.on_loop = settings_page_on_loop; return &settings_page; } ``` **页面跳转调用示例** ```c // 首页点击设置按钮,跳转到设置页面 void home_presenter_on_settings_btn_click(home_presenter_t *presenter) { page_t *settings_page = settings_page_get_instance(); page_manager_push(&g_page_manager, settings_page); } // 设置页面点击返回按钮,返回首页 event_result_t settings_view_handle_event(settings_view_interface_t *view, input_event_t *event) { if (event->type == EVENT_TYPE_TOUCH_DOWN) { // 判断返回按钮点击 if (is_point_in_rect(event->x, event->y, BACK_BTN_X, BACK_BTN_Y, BACK_BTN_W, BACK_BTN_H)) { // 调用出栈接口,返回上一页 page_manager_pop(&g_page_manager); return EVENT_HANDLED; } } return EVENT_UNHANDLED; } ``` #### 4.3.6 当前项目重构落地规划(已执行) 为解决 `MenuProc_See_AppInfo`、`MenuProc_See_YC` 这类“页面入口被函数化”的扩展瓶颈,本项目按以下路径重构,并要求每个模块均可独立测试: 1. **目录与职责重构(已完成)** - 新增 `src/Drv/pages` 作为多页面管理根目录; - 新增 `src/Drv/pages/global`,统一承载跨页面能力(`global_state`、`renderer_lcd`); - 新增 `src/Drv/pages/menu/page.c`,将现有菜单 MVP 封装成标准页面; - 新增示例业务页面 `src/Drv/pages/app_info`、`src/Drv/pages/yc`,用于验证页面跳转链路与生命周期。 2. **核心调度中枢(已完成)** - 新增 `page.h` 定义标准页面抽象(ID、缓存策略、生命周期钩子、事件入口、循环入口、私有 MVP 指针); - 新增 `page_manager.c`,实现: - 静态页面注册(防重复、上限保护); - 栈式 `push/pop` 生命周期调度; - 栈顶独占事件分发; - 全局返回键兜底(页面未消费时触发 `pop`); - 栈深度查询接口(用于诊断与测试)。 3. **菜单页面化改造(已完成)** - `MenuApp_Init` 改为: - 初始化全局状态; - 初始化并激活 `PageManager`; - 注册 `Menu/AppInfo/YC` 页面; - 默认入栈 `PAGE_ID_MENU`; - `MenuApp_HandleInput` 改为统一输入事件分发; - `MenuApp_Render` 改为调度当前栈顶页面 `on_loop`; - `MenuProc_See_AppInfo`、`MenuProc_See_YC` 改为页面跳转入口(非业务实现函数); - `MenuProc_See_YC` 跳转前通过 `GlobalModel` 同步当前菜单参数,实现跨页面上下文传递。 4. **可靠性约束(已完成)** - 全部页面实例采用静态内存,避免运行期动态分配; - 页面栈深度与页面注册数均有编译期上限; - 生命周期调用顺序固定:`exit -> (destroy) -> enter`; - 禁止后台页面收事件,仅栈顶可处理输入。 5. **单元可测性设计(目标 > 80%,已落地测试入口)** - 新增 `test_p1_page_manager.c`:覆盖页面注册/入栈/出栈/全局返回事件/循环调度; - 新增 `test_p1_menu_page_actions.c`:覆盖菜单页跳转 `AppInfo/YC` 与返回链路; - 保留并复用原 `menu tree/layout/navigator/runtime startup` 测试,保障回归稳定。 6. **后续扩展规范(作为后续页面开发模板)** - 新页面必须以 `src/Drv/pages//page.c` 实现标准生命周期; - 页面间禁止直接互调,只允许通过 `GlobalModel` 同步共享状态; - 菜单项 `pfnWinProc` 仅作为“页面路由入口”,不再承载页面业务实现。 ### 4.4 输入层 - 文件:`src/Drv/key.c`、`src/Drv/key.h` - 职责: - 提供按键读写状态控制(消费式读取) - 接收远程模块写入的按键事件并供菜单模块读取 ### 4.5 远程显示通信层 - 文件:`src/remoteDisplay.c`、`src/remoteDisplay.h` - 职责: - 实现 RemoDispBus 协议解析与回复 - 提供 TCP 服务器线程入口与启动逻辑 - 处理保活、初始化、按键下发、显存上传等命令 ### 4.6 平台适配层 - 文件:`src/TCP/tcp.c`、`src/thread_utils.c` - 职责: - 提供跨平台 socket 与线程封装 - 隔离 Windows/Linux API 差异 ## 5. 核心数据结构设计 ### 5.1 菜单模型与菜单树 - 静态菜单模型:`tagMenuModel`(定义于 `src/Drv/pages/menu/model/display.h`,数据在 `src/Drv/pages/menu/model/menu_model.c`) - 运行时菜单项:`tagMenuItem`(定义于 `src/Drv/pages/menu/view/menu_view.h`,实例由 `src/Drv/pages/menu/page.c` 管理) - 运行时控制:`tagMenuCtrl`、`tagDspCtrl`(定义于 `src/Drv/pages/menu/view/menu_view.h`,实例为 `src/Drv/pages/menu/page.c` 内部私有) 关键关系: - `ptHigher` / `ptLower`:父子层级关系 - `ptBefore` / `ptBehind`:同级双向关系(首尾成环) - `ptRoute[]`:当前路径缓存(0~3 级) ### 5.2 显示控制结构 - `tagScreenControl g_tCVsr`: - 显存缓冲 `pwbyLCDMemory` - 前景/背景色 - ASCII 与汉字字体参数 ### 5.3 远程按键结构 - `tagRKeyCtrl g_tRemoteKey`: - `byKeyValid`:是否有新按键 - `byKeyValue`:按键值 - `bUseRkey`:远程按键开关(当前实现中初始化为启用) ## 6. 关键业务流程 ### 6.1 主循环流程 ```text [系统初始化] | v [MenuApp_PollInput] | v [Sleep 20ms] | v [计数器累加] | v [是否到刷新周期?] --否--> [MenuApp_PollInput] | +--是--> [MenuApp_Render] --> [MenuApp_PollInput] ``` ### 6.2 菜单交互流程 - 输入来源:`Key_Read()`(含远程写入按键) - 行为: - 上/下:同级循环移动 - 左/ESC:回退上级或退回主层 - 右/确认:进入子级或执行叶子回调 - 渲染: - `MenuApp_Render` 调用 Presenter/View,根据路径进行增量刷新或全量刷新 ### 6.4 当前 Menu 详细设计(MVP) #### 6.4.1 模块拆分 - `src/Drv/menu/app/menu.c` - 菜单应用 Facade(`MenuApp_*` 对外接口) - 负责 PageManager 初始化与 `MenuPage` 生命周期托管 - `src/Drv/pages/menu/presenter/menu_presenter.c` - 控制调度中枢:处理输入事件、调用导航器、触发重建路径与刷新 - `src/Drv/pages/menu/model/menu_model.c` - 模型初始化:树构建、菜单名修饰、初始状态建立 - `src/Drv/pages/menu/view/menu_view.c` - 纯渲染:顶部栏、多级菜单框、高亮反显、全量/增量刷新策略 - `src/Drv/pages/menu/presenter/menu_navigator.c` - 纯导航状态机:按键到 `MenuNavResult`(是否刷新、是否跳过渲染) - `src/Drv/pages/menu/view/menu_layout.c` - 菜单布局计算:宽度统计、层级矩形定位、越界回退策略 - `src/Drv/pages/menu/model/menu_model.c`(包含静态菜单表数据) - 静态菜单模型表 `g_tMenuModelTab`(业务菜单定义) #### 6.4.2 运行时调用时序 ```text MenuApp_Init -> MenuPresenter_Setup -> MenuPresenter_InitModel -> MenuModel_Bootstrap -> MenuTree_0LevelNumCal -> MenuTree_MainCreate -> MenuView_Layout -> MenuLayout_PositionCal 主循环: MenuApp_PollInput -> Key_Read -> MenuApp_HandleInput(key) -> MenuPresenter_HandleInput -> MenuNavigator_ProcessKey -> (needRefresh) MenuNavigator_RebuildRoute -> MenuPresenter_Refresh -> MenuView_RenderByState ``` #### 6.4.3 关键状态数据 - `tagMenuItem` - 菜单节点实体,包含树关系(`ptHigher/ptLower/ptBefore/ptBehind`)与显示矩形 - `tagMenuCtrl` - 导航核心状态(`ptCurrent`、`ptRoute[4]`、`ptCurBak`、`pt0Level` 等) - `tagDspCtrl` - 显示控制状态(当前主要使用首帧标记 `bFirst`) - `MenuNavState / MenuNavResult` - Presenter 与 Navigator 之间的状态快照与处理结果 #### 6.4.4 对外数据接口(当前基线) - `void MenuApp_Init(void)` - 初始化菜单应用(Presenter/Model/View) - `void MenuApp_HandleInput(uint8_t keyVal)` - 注入输入事件并驱动导航状态变更 - `void MenuApp_PollInput(void)` - 从 `Key_Read()` 读取按键并转发到 `MenuApp_HandleInput` - `void MenuApp_Render(void)` - 主动触发一次渲染(用于周期刷新或外部强制重绘) - `const tagMenuItem *MenuApp_GetMenuItems(uint16_t *count)` - 只读导出当前菜单项数组(调试/测试使用) #### 6.4.5 内部接口边界(模块间) - Model -> View - 仅通过控制状态与布局结果共享,不直接调用绘制原语 - Presenter -> Model - 仅在初始化阶段触发 `MenuModel_Bootstrap` - Presenter -> View - 通过 `MenuPresenter_Refresh` 驱动渲染,不暴露底层 LCD 细节 - Presenter -> Navigator - 通过 `MenuNavState` 快照交互,避免导航器直接操作外部全局变量 ### 6.3 远程显示协议流程 ```text [Accept客户端] | v [接收缓冲区累积] | v [parse_frame 校验解析] | +--成功--------> [按 cmd 分发] --> [send_reply 回包] --> [接收缓冲区累积] | +--失败/不完整--> [接收缓冲区累积] ``` 命令语义(RemoDispBus): - `CMD_INIT`:返回 LCD 宽高与显存尺寸 - `CMD_LCDMEM`:返回显存数据(支持起始地址) - `CMD_KEY`:注入远程按键到 `g_tRemoteKey` - `CMD_KEEPLIVE`:保活响应 ## 7. 并发与线程模型 - 主线程: - 负责菜单路由与本地显示刷新 - TCP 服务器线程: - 监听连接、解析协议、处理远程请求 共享状态: - `g_tCVsr.pwbyLCDMemory`(远程读取 + 本地写入) - `g_tRemoteKey`(远程写入 + 菜单读取) 当前实现未使用锁机制,依赖业务访问模式降低冲突风险。 后续若并发复杂度提升,建议引入细粒度互斥或无锁缓冲策略。 ## 8. 构建与运行架构 - 构建系统:CMake(`C_STANDARD 99`) - 可执行目标:`DTU-HMI` - 平台链接: - Windows:`ws2_32` - Linux/macOS:`pthread` - 可选调试:`ENABLE_DEBUG=ON` 自动定义 `DEBUG` 宏 ## 9. 测试架构 测试目录:`tests/` - 框架:`ctest + 自定义断言宏` - 分层策略: - P0:纯逻辑单元测试(协议解析、UTF-8 解析、字库查找) - P1:状态/计算单测(按键、菜单、LCD 基础像素操作) - P2:集成测试(TCP 回环) 建议执行命令: ```bash cmake -S . -B build cmake --build build ctest --test-dir build -C Debug --output-on-failure ``` ## 10. 模块依赖关系(代码级) - `main.c` 依赖:`menu`、`key`、`remoteDisplay`、`tcp`、`thread_utils` - `src/Drv/menu/app/menu.c` 依赖:`pages/page_manager`、`pages/menu/page`、`key` - `src/Drv/pages/menu/presenter/menu_presenter.c` 依赖:`model/menu_model`、`view/menu_view`、`presenter/menu_navigator` - `src/Drv/pages/menu/view/menu_view.c` 依赖:`view/menu_layout`、`view/menu_render_port`、`lcd` - `remoteDisplay.c` 依赖:`lcd`、`key`、`tcp`、`thread_utils` - `lcd.c` 依赖:`ascii` - `src/Drv/pages/menu/model/menu_model.c` 提供:静态菜单表(被 `MenuPage` 使用) ## 11. 已知风险与改进建议 - **并发一致性风险**:远程线程与主线程共享状态无锁访问。 - 建议:为显存快照与按键事件引入互斥保护或双缓冲。 - **协议缓冲鲁棒性**:当前异常数据采用清空缓冲策略,存在丢包窗口。 - 建议:增加更精细的帧边界恢复策略与统计日志。 - **可测试性边界**:部分逻辑仍与全局状态耦合较深。 - 建议:逐步引入接口注入(如 `TcpOps`、`delay_ms`)降低耦合。 ## 12. 版本与维护 - 文档版本:v1.0 - 适配代码基线:当前 `DTU-HMI` 仓库主干实现 - 维护建议: - 每次新增模块或调整主流程时同步更新本文档 - 测试策略更新需同步维护“第 9 章 测试架构” ## 13. 菜单重构故障复盘(2026-03) ### 13.1 现象 - 菜单分层重构后,程序在启动阶段(当前入口为 `MenuApp_Init`)出现访问冲突,表现为“运行即崩溃”。 ### 13.2 根因 - 根因位于菜单树构建模块 `MenuTree_MainCreate`。 - 在“层级回退(`byCurClass > byNextClass`)”分支中,缺少对中间层级链表的及时收口(首尾成环)处理。 - 后续 `Menu_PositionCal` 在遍历同级链表时访问到异常节点,导致崩溃。 ### 13.3 修复措施 - 恢复并对齐原稳定逻辑: - 当层级回退时,立即对回退区间层级执行首尾成环收口。 - 每轮处理后更新 `ptCurrent = ptNextNode`,保证状态推进一致。 - 循环结束后按当前实际层级执行最终收口。 ### 13.4 回归防线 - 新增启动路径集成回归用例:`tests/test_p2_menu_runtime_startup.c`。 - 覆盖最易回归的启动路径: - `MenuApp_Init()` - `Key_Init()` - 首次 `MenuApp_PollInput()` - `MenuApp_Render()` - 该用例用于防止“菜单树可编译但启动崩溃”的问题再次进入主干。