背景
在一起圆屏设备上,受现状、可用空间影响,UI 设计与方形界面有较大差异,本文用于展示 UI 适配的若干经验。
完整源码请在 alipaytbox.yuque.com/sxs0ba/doc/… 获取。
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);
}
}
其中关键的 emotion
gif
_ 是一个 lvgl gif 组件,作为整个 UI 的背景,其他元素堆叠在它上面。
Gif 动画优化
如果项目涉及动画资源较多,可以通过 idf.py size-components 来确认 gif 占用 flash 空间。若过大,可以遵循以下方式来压缩体积,获得效果和包体之间的平衡。
首先是针对分辨率、有损编码做一定处理。macos 上如果是 macos 15(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
✨ 亮点速览:
✅ 限时福利:即日起至12月31日,官网/扫码进群即可每月领取10亿 Tokens
✅ API/SDK全兼容:Java/Python…无缝集成,大模型/智能体能力快速接入
✅ 模型盲测排行榜:不同模型效果对比打分,完美匹配不同业务诉求
✅ 灵活授权管理:令牌验证权限和身份信息,保证数据和信息安全
🎁 立即行动:访问平台官网 www.tbox.cn/open/open-i…
➡️ 产品详情查看:alipaytbox.yuque.com/sxs0ba/doc/…