百宝箱 IoT 如何定制设备 UI

简介: 本文介绍圆屏设备UI适配实践,基于xiaozhi框架定制Display模块,结合LVGL实现GIF动画背景与布局优化,并通过工具链压缩资源、提升性能,适用于嵌入式场景的圆形屏幕显示方案。

在圆形屏幕设备上,受限于形态与可用显示空间,UI 设计与方形界面存在显著差异。本文总结了若干 UI 适配的实践经验。

说明:本文所涉及的完整源码点击此处进行获取。

定制 Display

在 Xiaozhi 框架的设计中,Board 类封装了与具体硬件板相关的功能模块,并通过继承体系实现共性复用。例如:

  • 显示模块采用 SpiLcdDisplay → LcdDisplay → Display 的继承链;
  • 主板抽象则通过 XXXBoard → WiFiBoard 等层次进行扩展。

在 Board 所管理的众多模块中,Display 负责界面渲染与显示逻辑,因此圆形屏幕的适配工作从该模块切入。以 蚂蚁公仔 S3 Board 为例,Display 定制实现如下。

实现 MayiS3LCDDisplay

/**
 * @brief Mayi S3 LCD 显示实现
 * 继承LcdDisplay,添加GIF表情支持
 */
class MayiS3LCDDisplay : public SpiLcdDisplay {
public:
    /**
     * @brief 构造函数,参数与SpiLcdDisplay相同
     */
    MayiS3LCDDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, int width,
                     int height, int offset_x, int offset_y, bool mirror_x, bool mirror_y,
                     bool swap_xy, DisplayFonts fonts);
    virtual ~MayiS3LCDDisplay() = default;
    // 重写表情设置方法
    virtual void SetEmotion(const char* emotion) override;
    // 重写聊天消息设置方法
    virtual void SetChatMessage(const char* role, const char* content) override;
    void SetupHighTempWarningPopup();
    void UpdateHighTempWarning(float chip_temp, float threshold = 85.0f);
    void ShowHighTempWarning();
    void HideHighTempWarning();
protected:
    /**
     * @brief 设置UI界面
     * 重写父类方法,添加GIF表情容器
     */
    void SetupUI();
    void UpdateGifIfNecessary(const lv_img_dsc_t* new_img_dsc);
private:
    lv_obj_t* emotion_gif_;  ///< GIF表情组件
    void * last_emotion_gif_desc_;
    lv_obj_t* high_temp_popup_ = nullptr;  // 高温警告弹窗
    lv_obj_t* high_temp_label_ = nullptr;  // 高温警告标签
    // 表情映射
    struct EmotionMap {
        const char* name;
        const lv_img_dsc_t* gif;
    };
};

构建主 UI 布局

void MayiS3LCDDisplay::SetupUI() {
    DisplayLockGuard lock(this);
    auto screen = lv_screen_active();
    lv_obj_set_style_text_font(screen, fonts_.text_font, 0);
    lv_obj_set_style_text_color(screen, current_theme_.text, 0);
    lv_obj_set_style_bg_color(screen, current_theme_.background, 0);
    // 创建 GIF 动画容器
    emotion_gif_ = lv_gif_create(screen);
    lv_obj_set_size(emotion_gif_, LV_HOR_RES, LV_VER_RES);
    lv_obj_set_style_border_width(emotion_gif_, 0, 0);
    lv_obj_set_style_bg_color(emotion_gif_, lv_color_white(), 0);
    // 重点:将 GIF 移动到背景层,使其不参与 flex 布局
    lv_obj_move_background(emotion_gif_);
    /* Container */
    container_ = lv_obj_create(screen);
    lv_obj_set_size(container_, LV_HOR_RES, LV_VER_RES);
    lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_style_pad_all(container_, 0, 0);
    lv_obj_set_style_border_width(container_, 0, 0);
    lv_obj_set_style_pad_row(container_, 0, 0);
    lv_obj_set_style_bg_opa(container_, LV_OPA_TRANSP, 0);
    // lv_obj_set_style_bg_color(container_, current_theme_.background, 0);
    lv_obj_set_style_border_color(container_, current_theme_.border, 0);
    /* Status bar */
    status_bar_ = lv_obj_create(container_);
    lv_obj_set_size(status_bar_, LV_HOR_RES, fonts_.text_font->line_height);
    lv_obj_set_style_radius(status_bar_, 0, 0);
    // lv_obj_set_style_bg_opa(status_bar_, LV_OPA_TRANSP, 0);
    lv_obj_set_style_bg_color(status_bar_, current_theme_.background, 0);
    lv_obj_set_style_text_color(status_bar_, current_theme_.text, 0);
    /* Content */
    content_ = lv_obj_create(container_);
    lv_obj_set_scrollbar_mode(content_, LV_SCROLLBAR_MODE_OFF);
    lv_obj_set_style_radius(content_, 0, 0);
    lv_obj_set_width(content_, LV_HOR_RES);
    lv_obj_set_flex_grow(content_, 1);
    lv_obj_set_style_pad_all(content_, 5, 0);
    // 增加 20px 的底部填充,避免显示过下圆形屏上看不见
    lv_obj_set_style_pad_bottom(content_, 20, 0);
    lv_obj_set_style_bg_opa(content_, LV_OPA_TRANSP, 0);
    // lv_obj_set_style_bg_color(content_, current_theme_.chat_background, 0);
    lv_obj_set_style_border_color(content_, current_theme_.border, 0); // Border color for content
    lv_obj_set_flex_flow(content_, LV_FLEX_FLOW_COLUMN); // 垂直布局(从上到下)
    lv_obj_set_flex_align(content_, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); // 子对象居中对齐,等距分布
    chat_message_label_ = lv_label_create(content_);
    lv_label_set_text(chat_message_label_, "");
    lv_obj_set_size(chat_message_label_, LV_HOR_RES * 0.7, fonts_.text_font->line_height * 2 + 10);
    lv_label_set_long_mode(chat_message_label_, LV_LABEL_LONG_WRAP);
    lv_obj_set_style_text_align(chat_message_label_, LV_TEXT_ALIGN_CENTER, 0);
    lv_obj_set_style_text_color(chat_message_label_, current_theme_.text, 0);
    /* Status bar */
    lv_obj_set_flex_flow(status_bar_, LV_FLEX_FLOW_ROW);
    lv_obj_set_style_pad_all(status_bar_, 0, 0);
    lv_obj_set_style_border_width(status_bar_, 0, 0);
    lv_obj_set_style_pad_column(status_bar_, 0, 0);
    lv_obj_set_style_pad_left(status_bar_, 2, 0);
    lv_obj_set_style_pad_right(status_bar_, 2, 0);
    network_label_ = lv_label_create(status_bar_);
    lv_label_set_text(network_label_, "");
    lv_obj_set_style_text_font(network_label_, fonts_.icon_font, 0);
    lv_obj_set_style_text_color(network_label_, current_theme_.text, 0);
    notification_label_ = lv_label_create(status_bar_);
    lv_obj_set_flex_grow(notification_label_, 1);
    lv_obj_set_style_text_align(notification_label_, LV_TEXT_ALIGN_CENTER, 0);
    lv_obj_set_style_text_color(notification_label_, current_theme_.text, 0);
    lv_label_set_text(notification_label_, "");
    lv_obj_add_flag(notification_label_, LV_OBJ_FLAG_HIDDEN);
    status_label_ = lv_label_create(status_bar_);
    lv_obj_set_flex_grow(status_label_, 1);
    lv_label_set_long_mode(status_label_, LV_LABEL_LONG_SCROLL_CIRCULAR);
    lv_obj_set_style_text_align(status_label_, LV_TEXT_ALIGN_CENTER, 0);
    lv_obj_set_style_text_color(status_label_, current_theme_.text, 0);
    lv_label_set_text(status_label_, Lang::Strings::INITIALIZING);
    mute_label_ = lv_label_create(status_bar_);
    lv_label_set_text(mute_label_, "");
    lv_obj_set_style_text_font(mute_label_, fonts_.icon_font, 0);
    lv_obj_set_style_text_color(mute_label_, current_theme_.text, 0);
    battery_label_ = lv_label_create(status_bar_);
    lv_label_set_text(battery_label_, "");
    lv_obj_set_style_text_font(battery_label_, fonts_.icon_font, 0);
    lv_obj_set_style_text_color(battery_label_, current_theme_.text, 0);
    low_battery_popup_ = lv_obj_create(screen);
    lv_obj_set_scrollbar_mode(low_battery_popup_, LV_SCROLLBAR_MODE_OFF);
    lv_obj_set_size(low_battery_popup_, LV_HOR_RES * 0.9, fonts_.text_font->line_height * 2);
    lv_obj_align(low_battery_popup_, LV_ALIGN_BOTTOM_MID, 0, 0);
    lv_obj_set_style_bg_color(low_battery_popup_, current_theme_.low_battery, 0);
    lv_obj_set_style_radius(low_battery_popup_, 10, 0);
    low_battery_label_ = lv_label_create(low_battery_popup_);
    lv_label_set_text(low_battery_label_, Lang::Strings::BATTERY_NEED_CHARGE);
    lv_obj_set_style_text_color(low_battery_label_, lv_color_white(), 0);
    lv_obj_center(low_battery_label_);
    lv_obj_add_flag(low_battery_popup_, LV_OBJ_FLAG_HIDDEN);
    // 为圆形屏幕微调
    lv_obj_set_size(status_bar_, LV_HOR_RES, fonts_.text_font->line_height * 2 + 10);
    lv_obj_set_style_layout(status_bar_, LV_LAYOUT_NONE, 0);
    lv_obj_set_style_pad_top(status_bar_, 10, 0);
    lv_obj_set_style_pad_bottom(status_bar_, 1, 0);
    // 针对圆形屏幕调整位置
    //      network  battery  mute     //
    //               status            //
    lv_obj_align(battery_label_, LV_ALIGN_TOP_MID, -2.5*fonts_.icon_font->line_height, 0);
    lv_obj_align(network_label_, LV_ALIGN_TOP_MID, -0.5*fonts_.icon_font->line_height, 0);
    lv_obj_align(mute_label_, LV_ALIGN_TOP_MID, 1.5*fonts_.icon_font->line_height, 0);
    
    lv_obj_align(status_label_, LV_ALIGN_BOTTOM_MID, 0, 0);
    lv_obj_set_flex_grow(status_label_, 0);
    lv_obj_set_width(status_label_, LV_HOR_RES * 0.75);
    lv_label_set_long_mode(status_label_, LV_LABEL_LONG_SCROLL_CIRCULAR);
    lv_obj_align(notification_label_, LV_ALIGN_BOTTOM_MID, 0, 0);
    lv_obj_set_width(notification_label_, LV_HOR_RES * 0.75);
    lv_label_set_long_mode(notification_label_, LV_LABEL_LONG_SCROLL_CIRCULAR);
    lv_obj_align(low_battery_popup_, LV_ALIGN_BOTTOM_MID, 0, -20);
    lv_obj_set_style_bg_color(low_battery_popup_, lv_color_hex(0xFF0000), 0);
    lv_obj_set_width(low_battery_label_, LV_HOR_RES * 0.75);
    lv_label_set_long_mode(low_battery_label_, LV_LABEL_LONG_SCROLL_CIRCULAR);
    ESP_LOGI(TAG, "MayiS3LCDDisplay finished SetupUI...");
}
// 设置具体表情
void MayiS3LCDDisplay::SetEmotion(const char* emotion) {
    if (!emotion || !emotion_gif_) {
        return;
    }
    DisplayLockGuard lock(this);
    const lv_img_dsc_t* emotion_img = otto_emoji_gif_get_by_name(emotion);
    if (emotion_img != NULL) {
        UpdateGifIfNecessary(emotion_img);
    } else {
        // 使用默认表情
        ESP_LOGW(TAG, "OttoEmojiDisplay 找不到表情 '%s', 设置为默认 staticstate", emotion);
        UpdateGifIfNecessary(&staticstate);
    }
}

说明:

其中关键的 emotiongif_ 是一个基于 LVGL 的 GIF 组件,用作整个 UI 的背景,其他界面元素均叠加其上。

优化 GIF 动画

如果项目涉及动画资源较多,可以通过 idf.py size-components 来确认 GIF 动画所占用 flash 空间的大小。若过大,可以通过以下方式来压缩动画体积,实现显示效果和包体之间的平衡。

首先是针对分辨率、有损编码做一定处理,代码如下所示。

说明:若您使用 MacOS 且为 MacOS15(beta 版本),需要安装 ImageMagick 源码。

#!/bin/bash
# ==============================================================================
# batch_optimize_gifs.sh
#
# 功能: 查找当前目录下的所有 .gif 文件, 将其分辨率宽高各减半,
#       并进行极限压缩,然后保存到 'output' 目录中。
#
# 依赖: Gifsicle, ImageMagick (用于获取原始尺寸)
# ==============================================================================
# --- 设置输出目录 ---
OUTPUT_DIR="output"
# --- 检查依赖工具是否存在 ---
if ! command -v gifsicle &> /dev/null; then
    echo "错误: 未找到 'gifsicle'。请先安装 Gifsicle。"
    echo "  - Ubuntu/Debian: sudo apt install gifsicle"
    echo "  - macOS (Homebrew): brew install gifsicle"
    exit 1
fi
if ! command -v magick &> /dev/null && ! command -v identify &> /dev/null; then
    echo "错误: 未找到 ImageMagick 命令 ('magick' 或 'identify')。"
    echo "请先安装 ImageMagick。"
    echo "  - Ubuntu/Debian: sudo apt install imagemagick"
    echo "  - macOS (Homebrew): brew install imagemagick"
    exit 1
fi
# --- 创建输出目录 ---
if [ ! -d "$OUTPUT_DIR" ]; then
    echo "创建输出目录: $OUTPUT_DIR"
    mkdir "$OUTPUT_DIR"
fi
# --- 启用 shell 选项 ---
# nullglob: 如果没有匹配的文件,循环就不会执行
# nocaseglob: 匹配文件名时不区分大小写 (.gif, .GIF, .GiF 等)
shopt -s nullglob nocaseglob
# --- 变量初始化 ---
file_count=0
total_original_size=0
total_compressed_size=0
echo "开始批量处理 GIF 文件..."
echo "----------------------------------------"
# --- 循环处理当前目录下的所有 .gif 文件 ---
for file in *.gif; do
    # 检查这确实是一个文件
    if [ -f "$file" ]; then
        ((file_count++))
        echo "($file_count) 正在处理: $file"
        # --- 计算目标尺寸 (宽高减半) ---
        target_width=360
        target_height=360
        # 确保尺寸至少为 1px
        [ "$target_width" -eq 0 ] && target_width=1
        [ "$target_height" -eq 0 ] && target_height=1
    
        echo "  - 原始尺寸: ${original_width}x${original_height}"
        echo "  - 目标尺寸: ${target_width}x${target_height}"
        # --- 定义输出文件路径 ---
        output_file="$OUTPUT_DIR/$file"
        num_colors=4
        # --- 核心压缩命令 ---
        gifsicle \
            --resize "${target_width}x${target_height}" \
            --colors "${num_colors}" \
            --dither \
            --optimize=3 \
            --lossy=80 \
            "$file" \
            -o "$output_file"
        # --- 统计文件大小 ---
        original_size=$(stat -f \"%z\" "$file")
        compressed_size=$(stat -f \"%z\" "$output_file")
        ((total_original_size+=original_size))
        ((total_compressed_size+=compressed_size))
        echo "  - 压缩完成 -> $output_file"
        echo "" # 添加空行以分隔
    fi
done
# 恢复 shell 默认行为
shopt -u nullglob nocaseglob
gifsicle \
--resize "360x360" --colors 64 --dither \
--optimize=3 \
--lossy=80 \
"network_setup.gif" \
-o output/network_setup_new.gif
# --- 输出总结报告 ---
echo "----------------------------------------"
if [ "$file_count" -eq 0 ]; then
    echo "未在当前目录找到任何 .gif 文件。"
else
    echo "批量处理完成!共处理了 $file_count 个 GIF 文件。"
    echo "所有优化后的文件已保存到 '$OUTPUT_DIR' 目录中。"
    echo ""
    echo "--- 压缩效果总结 ---"
  
    # 转换为 KB 或 MB 以方便阅读
    orig_kb=$((total_original_size / 1024))
    comp_kb=$((total_compressed_size / 1024))
  
    echo "总原始大小: $orig_kb KB"
    echo "总压缩后大小: $comp_kb KB"
    if [ "$total_original_size" -gt 0 ]; then
        reduction_percent=$(echo "scale=2; (1 - $total_compressed_size / $total_original_size) * 100" | bc)
        echo "总体积减小: $reduction_percent %"
    fi
fi

接下来,通过在线工具将其转化为 C 语言数据格式,便于代码形式的嵌入。

说明:

由于 network_setup.gif 颜色较为丰富,不同于常见的黑白图像,因此在分辨率减半的同时,需在量化环节保留更多颜色信息,以维持视觉质量。实现代码如下:

gifsicle \
--resize "180x180" --colors 64 --dither \
--optimize=3 \
--lossy=90 \
"network_setup.gif" \
-o output/network_setup_new.gif
相关文章
|
1月前
|
人工智能 自然语言处理 搜索推荐
AI时代的新引擎:Geo专家于磊老师深度解析Geo优化中的技术要点
在AI时代,传统SEO正被Geo优化(GEO)重塑。于磊老师提出“人性化Geo”理念,强调E-E-A-T、结构化数据与语义适配,助力企业提升AI引用率,实现获客提效,引领数字营销新变革。
359 159
AI时代的新引擎:Geo专家于磊老师深度解析Geo优化中的技术要点
|
2月前
|
搜索推荐 API 开发工具
百宝箱开放平台 ✖️ Python SDK
百宝箱提供Python SDK,支持开发者集成其开放能力。需先发布应用,安装Python 3.6+环境后,通过pip安装tboxsdk,即可调用对话型、生成型智能体及文件上传等功能。
757 87
百宝箱开放平台 ✖️  Python SDK
|
1月前
|
监控 数据挖掘 UED
1688运营实战指南:从入门到精通的学习路径全解析!
在当今电商环境下,1688作为国内领先的B2B平台,已成为众多企业不可或缺的销售渠道。无论是源头工厂、批发商,还是寻求优质货源的创业者,掌握专业的1688运营技能都显得尤为重要。本文将为大家系统梳理1688运营的学习路径和实战方法,帮助商家少走弯路,快速提升店铺运营效果。
|
2月前
|
人工智能 Java Nacos
基于 Spring AI Alibaba + Nacos 的分布式 Multi-Agent 构建指南
本文将针对 Spring AI Alibaba + Nacos 的分布式多智能体构建方案展开介绍,同时结合 Demo 说明快速开发方法与实际效果。
2057 65
|
关系型数据库 MySQL
Keepalived 简介
Keepalived 采用 VRRP CVirtual Router Redundancy Protocol , 虚拟路由冗余协议) ,以 软件 的形式实现服务的热备功能 。
351 0
|
1月前
|
监控 数据可视化 数据挖掘
1688运营实战指南:1688系统化学习与进阶技巧
本文系统解析1688平台运营核心策略,涵盖店铺建设、流量获取、转化提升、数据分析与客户管理五大模块,助力企业打造专业形象,实现精准引流、高效转化与持续复购,推动B2B电商业务稳步增长。
|
2月前
|
自然语言处理 开发工具 Android开发
百宝箱开放平台 ✖️ 友盟+ SDK 接入准备
开发者可通过集成SDK,将百宝箱智能体接入友盟App,实现智能答疑与数据分析。本文详述在友盟创建App、获取Appkey,及在百宝箱创建智能体、获取TboxAgentID的完整流程,并提供iOS与Android平台集成指引,助力提升应用智能化服务能力。(239字)
121 0
百宝箱开放平台 ✖️ 友盟+ SDK 接入准备
|
2月前
|
存储 JavaScript API
百宝箱开放平台 ✖️ Node.js SDK
开发者可以通过安装 Node.js SDK 的方式将百宝箱的 OpenAPI 集成到自有系统中,从而在外部系统中发起智能体对话。
204 0
百宝箱开放平台 ✖️ Node.js SDK
|
2月前
|
API 开发工具 开发者
百宝箱开放平台 ✖️ Web SDK
本服务支持开发者将智能体以网页悬浮窗形式集成至Web页面,通过引入SDK并配置agentId等参数实现交互,需先完成应用发布。
181 0
|
1月前
|
编解码 人工智能 安全
百宝箱 IoT 如何定制动画
本文档指导开发者定制动画及主题,涵盖动画文件命名、使用场景、分辨率适配(如160x160px)、尺寸压缩脚本(gifsicle/ImageMagick)及字体背景配置,助力高效开发。
216 0

热门文章

最新文章