240×240 小屏不是把手机页面缩小。当前小鸿界面要同时容纳状态栏、语音焦点、状态提示、对话文本、音量反馈和配网/升级页面,还要在 WS63 的 SRAM 与 LiteOS-M 任务模型下稳定刷新。界面代码因此被拆成布局常量、对象创建、视觉状态、事件消费和长文本分页五个部分,而不是在一个循环里不断清屏重画。

本文依据当前 lcd_st7789_driver.hlvgl_ui_layout.h/.clvgl_task.c 和显示事件枚举整理。所有尺寸、缓冲区大小、颜色、页面时长和状态文案都来自实际源码。最新 UI 固件包已存在并记录哈希,但本轮没有重新烧录后拍摄实机屏幕,所以文中使用的是根据代码确定性渲染的结构图,不把设计示意图冒充设备照片;源码验证与实机视觉验收仍然分开。

显示尺寸由 ST7789 驱动向布局层传递

面板驱动头文件定义 240×240,lvgl_ui_layout.h 再把 LCD_PANEL_PIXEL_X/Y 映射成 DISP_WIDTH/HEIGHT。如果驱动没有给出尺寸,预处理直接报错,不允许布局悄悄使用另一个默认值。这样布局常量和 LVGL display 创建共享同一尺寸来源。

#if defined(LCD_PANEL_PIXEL_X)
  #define DISP_WIDTH  LCD_PANEL_PIXEL_X
#else
  #error LCD_PANEL_PIXEL_X unknown
#endif

#if defined(LCD_PANEL_PIXEL_Y)
  #define DISP_HEIGHT  LCD_PANEL_PIXEL_Y
#else
  #error LCD_PANEL_PIXEL_Y unknown
#endif

当前 UI 留出 8 像素安全边界,状态栏从 y=4 开始,高 22;焦点环位于 64、36,尺寸 112;中心核心为 90×90。提示文字位于 y=149,对话摘要从 y=179 开始。这些位置围绕 240 像素高度手工组织,目标是优先让用户看清“设备现在在做什么”。

常态页面由状态栏、焦点区和两段文字组成

常态布局上方是 Wi-Fi、音量、亮度和电池状态;中间 112×112 区域包含 12 段环形状态点、90×90 核心、咕咕嘎嘎图像或 Emoji,以及监听时使用的五根波形条;下方两段文字分别承担状态提示和操作/对话摘要。

#define UI_STATUS_POS_Y                (4)
#define UI_STATUS_HEIGHT               (22)
#define UI_FOCUS_POS_X                 (64)
#define UI_FOCUS_POS_Y                 (36)
#define UI_FOCUS_SIZE                  (112)
#define UI_FOCUS_CORE_SIZE             (90)
#define UI_PROMPT_POS_Y                (149)
#define UI_CHAT_TXT_POS_Y              (179)
#define UI_CHAT_TXT_HEIGHT             (49)

这种布局不会在回答长文本时继续强行保留大焦点环。进入回答态后,中心视觉让位给阅读区域,只保留顶部状态和“正在回答”提示。小屏界面的优先级必须随任务改变,而不是让所有组件永久同时可见。

对象只创建一次,状态变化通过隐藏与样式完成

disp_create_ui 在 LVGL 任务初始化时创建状态栏、焦点环、12 个环段、核心区、图像、5 个波形条、互动文字、回答页装饰、全屏文字和音量覆盖层。运行时不为每个 Agent 状态重复 new 一套对象,而是使用 LV_OBJ_FLAG_HIDDEN 切换可见性,并更新文本、颜色、尺寸。

status_bar_obj = lv_label_create(lv_scr_act());
s_status_divider_obj = lv_obj_create(lv_scr_act());
s_focus_ring_obj = lv_obj_create(lv_scr_act());
for (uint8_t i = 0U; i < ASSISTANT_RING_SEGMENT_COUNT; i++) {
    s_focus_ring_segments[i] = lv_obj_create(s_focus_ring_obj);
}
s_focus_core_obj = lv_obj_create(lv_scr_act());

s_penguin_image_obj = lv_image_create(s_focus_core_obj);
for (uint8_t i = 0U; i < 5U; i++) {
    s_wave_bars[i] = lv_obj_create(s_focus_core_obj);
}

固定对象树减少频繁分配和销毁,也让 LVGL 样式与定时器更容易管理。创建完成后,代码默认隐藏互动区和焦点区,先显示全屏启动文字“咕咕嘎嘎 / 正在醒来”,待系统事件到达后再切入常态页面。

五种视觉模式与 Agent 状态对齐

当前视觉枚举包含空闲、连接、倾听、思考和回答五态。它们不是网络协议状态的完整复制,而是 UI 需要表达的最小集合。监听态和思考态都可能来自 AGENT_STATE_LISTENING,再通过 agent_is_waiting_response() 区分语音采集尚在进行,还是语音已上传、正在等待服务端。

typedef enum {
    ASSISTANT_VISUAL_IDLE = 0,
    ASSISTANT_VISUAL_CONNECTING,
    ASSISTANT_VISUAL_LISTENING,
    ASSISTANT_VISUAL_THINKING,
    ASSISTANT_VISUAL_SPEAKING,
    ASSISTANT_VISUAL_INVALID
} assistant_visual_mode_t;

每种模式设置不同的焦点背景、边框和前景色。空闲使用暖黄色,连接偏紫,倾听使用青色,思考使用琥珀色,回答使用绿色。12 段焦点环可以固定点亮或旋转,五根波形条则按预设高度序列动画,帮助用户不读文字也能分辨设备状态。

状态文案来自真实分支而不是设计稿

显示任务处理 eDisp_Prompt_Update 时读取 Agent 状态。空闲文案为“咕咕嘎嘎 / 按中键和我说话,长按5秒进入配网”;连接态显示“正在连接 / 网络连接中,请稍候”;监听态显示“我在听 / 请开始说话”;等待返回时显示“让我想一想 / 语音已上传,请稍候”;回答态提示“正在回答”。

assistant_visual_set_mode(waiting_response ?
                          ASSISTANT_VISUAL_THINKING :
                          ASSISTANT_VISUAL_LISTENING);

lv_label_set_text(
    interact_txt_prompt,
    waiting_response ? "让我想一想" : "我在听");
lv_label_set_text(
    interact_txt_chat,
    waiting_response ? "语音已上传,请稍候" : "请开始说话");

这段逻辑解决了“已经说完但界面还显示我在听”的语义错误。状态文案与颜色必须由同一个状态判断驱动,否则用户会看到环在思考、文字却提示继续说话。

OpenHarmony 构建只使用 16 行局部缓冲

240×240 RGB565 全帧缓冲需要 115,200 字节。当前 OpenHarmony 路径把局部刷新缓冲设为 16 行,即 240 × 16 × 2 = 7,680 字节;非 OpenHarmony 分支使用 80 行、38,400 字节。代码在注释中明确说明:缓冲越大刷新可能更快,但 SRAM 压力也更高。

#if SUPPORT_OHOS
#define LVGL_PARTIAL_BUF_LINES         (16)
#else
#define LVGL_PARTIAL_BUF_LINES         (80)
#endif

static uint8_t disp_buffer[
    DISP_WIDTH * LVGL_PARTIAL_BUF_LINES * 2];

LVGL display 使用 LV_DISPLAY_RENDER_MODE_PARTIAL 注册该缓冲。局部缓冲不是“屏幕只有 16 行”,而是 LVGL 分块绘制后通过 flush 回调送到 ST7789。文章给出精确字节数,避免把帧缓冲、对象内存和外部 Flash 混在一起。

LVGL tick 与对象更新必须留在显示任务

当前代码专门注明:不能在硬件定时器 ISR 中调用 lv_tick_inc(),否则会与 LvglTask 并发并破坏 LVGL 内部状态。实际循环在同一任务中根据内核 tick 推进 LVGL,再调用 lv_timer_handler(),然后以它返回的下一定时器间隔等待显示消息。

等待时间被限制在 1~20 ms。过大时动画和滚动发涩,过小时任务空转、发热增加。显示事件通过 g_disp_event_qid 进入,同一线程调用 disp_update_ui,因此其他任务只发枚举,不直接操作 LVGL 对象。

lv_tick_inc(xh_lvgl_elapsed_ms_since(&lv_prev_kernel_tick));
uint32_t next_ms = lv_timer_handler();

uint32_t qwait = next_ms;
if (qwait < 1U) {
    qwait = 1U;
}
if (qwait > 20U) {
    qwait = 20U;
}
ret = osMessageQueueGet(
    g_disp_event_qid, (void *)&msg_recv, NULL, qwait);

回答页用 UTF-8 安全分页而不是按字节硬切

动态回答先写入 512 字节 pending buffer。单页最多复制 144 字节,页面推进步长为 108 字节,相邻页因此保留一部分上下文;每页停留 3.2 秒。这里的 144 和 108 是字节数,不等于固定中文字数。UTF-8 中文通常占 3 字节,Emoji 可能占 4 字节,所以切页必须回退到字符起点。

#define DISP_LVGL_TEXT_PENDING_MAX     (512)
#define DISP_SHOW_TEXT_LINE_LEN        (108)
#define DISP_SHOW_TEXT_MAX_LEN         (144)
#define DISP_SHOW_TEXT_DELAY_MS        (3200)

utf8_safe_rollback 从目标位置向前跳过所有 10xxxxxx continuation byte,找到一个合法字符起点。第一页显示后创建 LVGL timer;后续每次向前移动 108 字节,再安全截取最多 144 字节,并更新右下角 当前页/总页数。最后一页还会完整停留一个周期,再清理播放状态,避免回答刚显示就返回首页。

回答页把 144 像素高度让给正文

回答布局的正文从 x=16、y=64 开始,宽度为 DISP_WIDTH - 28,高度 144;左侧有 3×144 的绿色强调条,页码放在 y=213。相较常态页 49 像素的摘要区,回答页可以显示约五到六行 18 px 中文。顶部仍保留状态栏,用户不会因为进入长文本页面失去网络、电量等信息。

内置 lv_font_Chinese_18_UI 是兜底字体;若 LittleFS 中的外部 font.bin 可用,互动文字可切到流式字体。完整中文字库下载与替换属于第 08 篇范围,本文只确认当前 UI 对字体缺失有回退路径,不声称所有 Unicode 字形都在内置字体中。

音量反馈使用覆盖层,不挤压主状态

收到 eDisp_Volume_Update 后,显示任务先刷新状态栏,再显示位于屏幕下部的音量覆盖层。覆盖层包含音量图标、116×11 的轨道、按百分比计算宽度的填充条和数值。它由定时器自动恢复原界面,不需要重建整个对象树。

填充最大宽度为 112,当前值按 112 × volume / 100 计算;音量为零时隐藏填充条。这个细节比只改状态栏图标更容易给用户及时反馈,也不会永久占用回答区。

从源码到实机还需要三类验收

源码层已经确认 240×240 尺寸、对象层次、五种视觉模式、16 行局部缓冲、单任务 tick、1~20 ms 等待、512 字节 pending buffer、144/108 字节分页和 3.2 秒停留。最新 UI 包 000_BURN_THIS_42_GUGUGAGA_DETAILED_UI_WS63_20260723_2040.fwpkg 的大小和 SHA-256 也已在第 04 篇记录。

但界面验收还应分三类:静态布局检查边缘裁切、字体和页码;动态检查焦点环、波形、音量覆盖层和分页节奏;长时间运行检查任务占用、发热、队列堵塞和 LVGL fault。本轮没有重新烧录和拍摄设备,因此本文结论是“当前 240×240 UI 实现已按源码复核”,不是“今天完成了新包的全部实机视觉验收”。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐