Compare commits

...

3 Commits

Author SHA1 Message Date
da64d3e930 feat: 启用 BLE 5.0 2M PHY 图传加速 + BLE 断连内存泄漏修复 + 滑动跳过无效图片
1、ble.c 新增 BLE 5.0 2M PHY 请求(连接时自动协商,不支持则回退 1M);
2、ble.c 新增 PHY 更新事件日志(tx_phy/rx_phy: 1=1M, 2=2M, 3=Coded);
3、ble.c 断连时清理未完成的图片传输状态,释放 img_data/filepath/file_img 防止内存泄漏;
4、sdkconfig 启用 BLE 5.0 全部子特性 + 保留 BLE 4.2 兼容;
5、update_ui_ImgBle 返回类型 void → bool,滑动时自动跳过解码失败的无效图片;

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:23:15 +08:00
6711a24a68 feat: 新增ScreenChar角色动画界面 + 启用GIF解码器
- 新增ui_ScreenChar:LVGL基元绘制动漫角色(金黄头发/脸/眼/嘴/身体/胳膊)
- 序列帧动画:BOOT按键触发眨眼+说话动画,再按停止
- ScreenImg上滑导航至ScreenChar(保留原Home路径可切换)
- BOOT按键逻辑:ScreenChar界面切换动画,其他界面返回Home
- 启用CONFIG_LV_USE_GIF=y支持GIF动图显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:05:54 +08:00
9223fd5a7d feat: BLE传输优化 + GIF条件编译 + 自定义GIF播放器
一、BLE 蓝牙优化
- 设备名称改为动态名称 Airhub_MAC(基于BLE MAC地址)
- 广播数据拆分为 ADV + Scan Response 两包
- 图片接收完成后数据直通显示(跳过SPIFFS重读,减少200-500ms延迟)
- BLE耗时操作(NVS写入+导航显示)转移到独立FreeRTOS任务,避免BTC_TASK栈溢出
- 缩短BLE连接间隔(min=7.5ms, max=20ms),提升传输吞吐量
- 减少传输日志输出(每100包打印一次),提升传输速度

二、显示性能优化
- LVGL绘制缓冲区从DMA 30行改为PSRAM 120行大缓冲,减少flush次数
- CPU最大频率从160MHz提升到240MHz,提升解码性能

三、GIF动图支持(条件编译,当前默认关闭)
- 实现自定义GIF播放器:Palette LUT查表 + TRUE_COLOR无Alpha + 后台线程解码流水线
- 使用 #if LV_USE_GIF 条件编译包裹所有GIF代码,sdkconfig中CONFIG_LV_USE_GIF=n时零开销
- 启用GIF时需设置 CONFIG_LV_USE_GIF=y 即可

四、图片管理优化
- BLE接收新图片后直接追加到列表(避免重扫SPIFFS目录)
- SPIFFS图片扫描支持.gif扩展名(条件编译控制)

五、文档更新
- 设备运行日志:GIF性能瓶颈分析与优化方案

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 09:43:02 +08:00
13 changed files with 1352 additions and 104 deletions

View File

@ -15,6 +15,7 @@ idf_component_register(
"./ui/screens/ui_ScreenHome.c"
"./ui/screens/ui_ScreenSet.c"
"./ui/screens/ui_ScreenImg.c"
"./ui/screens/ui_ScreenChar.c"
"./ui/ui_helpers.c"
"./ui/images/ui_img_s1_png.c"
"./ui/images/ui_img_s6_png.c"

View File

@ -1,6 +1,8 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_bt.h"
#include "esp_gap_ble_api.h"
@ -29,7 +31,8 @@ static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_
static const char *CONN_TAG = "CONN_BLE";
static const char device_name[] = "MY-BLE";
static char ble_device_name[32];
static uint8_t adv_raw_len = 0;
static uint16_t conn_id;
@ -53,6 +56,22 @@ MegStatus SendStatus = {false,0};
uint8_t *img_data = 0;
FILE *file_img;
// BLE 图片处理任务NVS 写入 + 导航显示在独立任务中执行,避免 BTC_TASK 栈溢出)
static TaskHandle_t ble_process_task_handle = NULL;
static char ble_pending_filename[24];
static uint8_t *ble_pending_data = NULL; // 传输完成的图片数据(直通显示,跳过 SPIFFS 重读)
static size_t ble_pending_data_size = 0;
static void ble_process_task(void *arg) {
while (1) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
nvs_change_img(ble_pending_filename);
ble_image_navigate_with_data(ble_pending_filename, ble_pending_data, ble_pending_data_size);
ble_pending_data = NULL; // 所有权已转移,不再释放
ble_pending_data_size = 0;
}
}
static uint8_t attr_value_write[512] = {0};
static uint8_t attr_value_edit[20] = {0};
@ -101,12 +120,12 @@ static esp_ble_adv_params_t adv_params = {
};
static uint8_t adv_raw_data[] = {
0x02, ESP_BLE_AD_TYPE_FLAG, 0x06,
0x07, ESP_BLE_AD_TYPE_NAME_CMPL, 'M','Y','-','B','L','E',
0x02, ESP_BLE_AD_TYPE_TX_PWR, 0x09,
0x03, ESP_BLE_AD_TYPE_16SRV_CMPL, 0xB0, 0x00,
0x07, ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE,0x4C,0x44,0x64,0x7A,0x62,0x6A
static uint8_t adv_raw_data[31];
// Scan Response 数据:厂商标识 + 服务UUID
static uint8_t scan_rsp_data[] = {
0x07, ESP_BLE_AD_MANUFACTURER_SPECIFIC_TYPE, 0x4C, 0x44, 0x64, 0x7A, 0x62, 0x6A, // "LDdzbj"
0x03, ESP_BLE_AD_TYPE_16SRV_CMPL, 0x00, 0x0B, // 服务UUID 0x0B00
};
@ -156,15 +175,54 @@ void ble_init(void)
ESP_LOGE(CONN_TAG, "set local MTU failed, error code = %x", ret);
return;
}
ret = esp_ble_gap_set_device_name(device_name);
// 获取 BLE MAC 地址并构建设备名称: Airhub_xx:xx:xx:xx:xx:xx
const uint8_t *ble_addr = esp_bt_dev_get_address();
if (ble_addr) {
snprintf(ble_device_name, sizeof(ble_device_name),
"Airhub_%02x:%02x:%02x:%02x:%02x:%02x",
ble_addr[0], ble_addr[1], ble_addr[2],
ble_addr[3], ble_addr[4], ble_addr[5]);
ESP_LOGI(CONN_TAG, "BLE MAC: %02x:%02x:%02x:%02x:%02x:%02x",
ble_addr[0], ble_addr[1], ble_addr[2],
ble_addr[3], ble_addr[4], ble_addr[5]);
} else {
strcpy(ble_device_name, "Airhub_BLE");
ESP_LOGW(CONN_TAG, "获取BLE MAC失败使用默认名称: %s", ble_device_name);
}
ret = esp_ble_gap_set_device_name(ble_device_name);
if (ret) {
ESP_LOGE(CONN_TAG, "set device name failed, error code = %x", ret);
return;
}
ret = esp_ble_gap_config_adv_data_raw(adv_raw_data, sizeof(adv_raw_data));
ESP_LOGI(CONN_TAG, "蓝牙设备名称: %s", ble_device_name);
// 构建广播数据: Flags + Complete Local Name
uint8_t name_len = strlen(ble_device_name);
int offset = 0;
adv_raw_data[offset++] = 0x02;
adv_raw_data[offset++] = ESP_BLE_AD_TYPE_FLAG;
adv_raw_data[offset++] = 0x06;
adv_raw_data[offset++] = name_len + 1;
adv_raw_data[offset++] = ESP_BLE_AD_TYPE_NAME_CMPL;
memcpy(&adv_raw_data[offset], ble_device_name, name_len);
offset += name_len;
adv_raw_len = offset;
ret = esp_ble_gap_config_adv_data_raw(adv_raw_data, adv_raw_len);
if (ret) {
ESP_LOGE(CONN_TAG, "config adv data failed, error code = %x", ret);
}
// 配置 Scan Response 数据(厂商标识 "dzbj" + 服务UUID
ret = esp_ble_gap_config_scan_rsp_data_raw(scan_rsp_data, sizeof(scan_rsp_data));
if (ret) {
ESP_LOGE(CONN_TAG, "config scan response data failed, error code = %x", ret);
}
// 创建图片处理任务8KB 栈,足够 SPIFFS 扫描 + LVGL + GIF 解码)
xTaskCreate(ble_process_task, "ble_img", 8192, NULL, 5, &ble_process_task_handle);
}
@ -174,6 +232,10 @@ static void esp_gap_cb(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *par
switch (event) {
case ESP_GAP_BLE_ADV_DATA_RAW_SET_COMPLETE_EVT:
ESP_LOGI(CONN_TAG, "Advertising data set, status %d", param->adv_data_raw_cmpl.status);
// ADV 数据设置完成,等待 Scan Response 也设置完成后再开始广播
break;
case ESP_GAP_BLE_SCAN_RSP_DATA_RAW_SET_COMPLETE_EVT:
ESP_LOGI(CONN_TAG, "Scan response data set, status %d", param->scan_rsp_data_raw_cmpl.status);
esp_ble_gap_start_advertising(&adv_params);
break;
case ESP_GAP_BLE_ADV_START_COMPLETE_EVT:
@ -196,6 +258,13 @@ static void esp_gap_cb(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t *par
param->update_conn_params.latency,
param->update_conn_params.timeout);
break;
case ESP_GAP_BLE_PHY_UPDATE_COMPLETE_EVT:
ESP_LOGI(CONN_TAG, "PHY update, status %d, tx_phy %d, rx_phy %d",
param->phy_update.status,
param->phy_update.tx_phy,
param->phy_update.rx_phy);
// tx_phy/rx_phy: 1=1M, 2=2M, 3=Coded
break;
default:
break;
}
@ -268,9 +337,12 @@ static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_
ESP_LOGI(CONN_TAG,"传输通道建立成功,数据指针:%p,文件名称:%s,文件大小:%d",img_data,firstMeg.filename,(int)firstMeg.len);
}
}else if(SendStatus.isSend){
ESP_LOGI(CONN_TAG, "获取到数据:第:%d包,长度:%d,是否结束:%d",*value+1,(int)param->write.len,*(value+1));
uint8_t pkt_no = *value;
uint8_t isEnd = *(value + 1);
uint8_t port = *(value);
// 每 100 包或最后一包打印日志(减少串口输出提升传输速度)
if (pkt_no % 100 == 0 || isEnd) {
ESP_LOGI(CONN_TAG, "获取到数据:第:%d包,长度:%d,是否结束:%d", pkt_no+1, (int)param->write.len, isEnd);
}
uint8_t *data = value + 2;
memcpy(img_data + SendStatus.port,data,(int)param->write.len-2);
SendStatus.port += param->write.len-2;
@ -281,12 +353,15 @@ static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_
fclose(file_img);
SendStatus.isSend = false;
SendStatus.port = 0;
ESP_LOGI(CONN_TAG,"图片接收成功");
nvs_change_img(firstMeg.filename);
// 导航到ScreenImg显示新图片内部刷新列表+设置索引+切换界面)
ble_image_navigate(firstMeg.filename);
free(img_data);
// img_data 不释放,传给显示任务直通显示(跳过 SPIFFS 重读)
ble_pending_data = img_data;
ble_pending_data_size = firstMeg.len;
img_data = NULL; // 转移所有权
free(filepath);
ESP_LOGI(CONN_TAG,"图片接收成功,数据直通显示(%d字节)", (int)ble_pending_data_size);
strncpy(ble_pending_filename, firstMeg.filename, sizeof(ble_pending_filename) - 1);
ble_pending_filename[sizeof(ble_pending_filename) - 1] = '\0';
xTaskNotifyGive(ble_process_task_handle);
}
}
}// 图片编辑特征写入事件
@ -296,8 +371,10 @@ static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_
uint8_t type = *(value + param->write.len - 1);
memcpy(imgName, value, 23);
if(type == 0xff){
nvs_change_img(imgName);
ble_image_navigate(imgName);
// 耗时操作转移到独立任务执行
strncpy(ble_pending_filename, imgName, sizeof(ble_pending_filename) - 1);
ble_pending_filename[sizeof(ble_pending_filename) - 1] = '\0';
xTaskNotifyGive(ble_process_task_handle);
}else if(type == 0xF1){
remove(filepath);
SendStatus.isSend = false;
@ -311,17 +388,33 @@ static void gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt_if_t gatts_
esp_ble_conn_update_params_t conn_params = {0};
memcpy(conn_params.bda, param->connect.remote_bda, sizeof(esp_bd_addr_t));
conn_params.latency = 0;
conn_params.max_int = 32;
conn_params.min_int = 16;
conn_params.max_int = 16; // 16 × 1.25ms = 20ms缩短连接间隔提升传输吞吐量
conn_params.min_int = 6; // 6 × 1.25ms = 7.5ms
conn_params.timeout = 400;
conn_id = param->connect.conn_id;
ESP_LOGI(CONN_TAG, "Connected, conn_id %u, remote "ESP_BD_ADDR_STR"",
param->connect.conn_id, ESP_BD_ADDR_HEX(param->connect.remote_bda));
esp_ble_gap_update_conn_params(&conn_params);
// 请求 2M PHY 提升传输速度(对端不支持时自动回退 1M不影响兼容性
esp_ble_gap_set_preferred_phy(param->connect.remote_bda,
ESP_BLE_GAP_NO_PREFER_TRANSMIT_PHY | ESP_BLE_GAP_NO_PREFER_RECEIVE_PHY,
ESP_BLE_GAP_PHY_2M_PREF_MASK,
ESP_BLE_GAP_PHY_2M_PREF_MASK,
ESP_BLE_GAP_PHY_OPTIONS_NO_PREF);
break;
case ESP_GATTS_DISCONNECT_EVT:
ESP_LOGI(CONN_TAG, "Disconnected, remote "ESP_BD_ADDR_STR", reason 0x%02x",
ESP_BD_ADDR_HEX(param->disconnect.remote_bda), param->disconnect.reason);
// 清理未完成的传输,防止内存泄漏
if (SendStatus.isSend) {
ESP_LOGW(CONN_TAG, "传输中断,已接收 %d/%d 字节",
(int)SendStatus.port, (int)firstMeg.len);
SendStatus.isSend = false;
SendStatus.port = 0;
if (img_data) { free(img_data); img_data = NULL; }
if (filepath) { free(filepath); filepath = NULL; }
if (file_img) { fclose(file_img); file_img = NULL; }
}
esp_ble_gap_start_advertising(&adv_params);
break;
default:

View File

@ -109,12 +109,12 @@ void lvgl_lcd_init(){
};
lvgl_port_init(&lvgl_cfg);
#define LVGL_DRAW_BUF_LINES 30
#define LVGL_DRAW_BUF_LINES 120
size_t buffer_size = LCD_WID * LVGL_DRAW_BUF_LINES;
ESP_LOGI(LCD_TAG, "LVGL buffer size: %d bytes (W: %d, Lines: %d)",
buffer_size * 2, LCD_WID, LVGL_DRAW_BUF_LINES);
const lvgl_port_display_cfg_t disp_cfg = {
.io_handle = io_handle,
.panel_handle = panel_handle,
@ -129,7 +129,8 @@ void lvgl_lcd_init(){
.mirror_y = false,// 垂直镜像
},
.flags = {
.buff_dma = true,// 使用DMA传输显示缓冲区
.buff_dma = false,
.buff_spiram = true,// PSRAM 120行大缓冲每帧仅3次flushGIF最流畅
}
};
disp_handle = lvgl_port_add_disp(&disp_cfg);

View File

@ -22,6 +22,7 @@
#include "ui/ui.h"
#include "ui/screens/ui_ScreenSet.h"
#include "ui/screens/ui_ScreenImg.h"
#include "ui/screens/ui_ScreenChar.h"
// #include "axis.h"
@ -71,11 +72,27 @@ void boot_btn_handler(int gpio_num, void *usr_data) {
ESP_LOGI("BTN_HANDLER", "BOOT按键低功耗模式仅唤醒屏幕");
sleep_mgr_notify_activity(); // 唤醒屏幕,恢复亮度
} else {
// 正常模式下返回ScreenHome界面
// 正常模式下的按键处理
lv_obj_t *current_screen = lv_scr_act();
// 在ScreenChar界面切换角色动画不返回Home
if (current_screen == ui_ScreenChar) {
sleep_mgr_notify_activity();
if (char_anim_is_playing()) {
char_anim_stop();
ESP_LOGI("BTN_HANDLER", "BOOT按键停止角色动画");
} else {
char_anim_start();
ESP_LOGI("BTN_HANDLER", "BOOT按键启动角色动画");
}
return;
}
// 其他界面返回ScreenHome
ESP_LOGI("BTN_HANDLER", "BOOT按键正常模式返回ScreenHome");
// 检查当前是否在ScreenImg界面如果是则先隐藏ContainerDle
lv_obj_t *current_screen = lv_scr_act();
if (current_screen == ui_ScreenImg) {
ui_ScreenImg_hide_delete_container();
ESP_LOGI("BTN_HANDLER", "从ScreenImg离开已隐藏ContainerDle");
@ -213,13 +230,13 @@ void app_main(void)
// 配置 Power Management低功耗管理
esp_pm_config_t pm_config = {
.max_freq_mhz = 160, // 最大频率 160MHz与当前CPU频率一致
.max_freq_mhz = 240, // 最大频率 240MHzGIF解码需要高算力
.min_freq_mhz = 40, // 最小频率 40MHz保证LVGL正常刷新
.light_sleep_enable = true // 启用自动 Light Sleep
};
esp_err_t pm_err = esp_pm_configure(&pm_config);
if (pm_err == ESP_OK) {
ESP_LOGI("MAIN", "2.1 Power Management已启用40-160MHz动态频率 + 自动Light Sleep");
ESP_LOGI("MAIN", "2.1 Power Management已启用40-240MHz动态频率 + 自动Light Sleep");
} else {
ESP_LOGW("MAIN", "2.1 Power Management启用失败%s", esp_err_to_name(pm_err));
}

View File

@ -1,6 +1,7 @@
#include "esp_err.h"
#include <stdint.h>
#include <stdbool.h>
#include "lvgl.h"
void app_test_display(); // 测试显示
void app_img_display(); // 显示图片
@ -19,3 +20,7 @@ void init_spiffs_image_list(void); // 初始化/扫描SPIFFS图片列表
void free_spiffs_image_list(void); // 重置图片列表
bool set_image_index_by_name(const char *name); // 根据文件名设置当前图片索引
void ble_image_navigate(const char *filename); // BLE接收后导航到ScreenImg显示
void ble_image_navigate_with_data(const char *filename, uint8_t *data, size_t data_size); // BLE接收后直通显示跳过SPIFFS重读
#if LV_USE_GIF
void pages_cleanup_gif(void); // 清理 GIF 控件资源
#endif

View File

@ -1,4 +1,11 @@
#include "lvgl.h"
#if LV_USE_GIF
#include "extra/libs/gif/lv_gif.h"
#include "extra/libs/gif/gifdec.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#endif
#include "esp_heap_caps.h"
#include "fatfs.h"
#include "driver/ledc.h"
#include "gpio.h"
@ -29,6 +36,29 @@ static int spiffs_image_count = 0;
static int current_image_index = 0;
static bool image_list_initialized = false;
#if LV_USE_GIF
// === 自定义 GIF 播放器(替代 lv_gif性能优化 ===
// 优化: 1.Palette LUT查表 2.TRUE_COLOR无Alpha 3.后台线程解码流水线
static gd_GIF *gif_decoder = NULL; // gifdec 解码句柄
static lv_obj_t *gif_img_obj = NULL; // 普通 lv_img 控件(替代 lv_gif
static uint16_t *gif_rgb565_buf[2] = {NULL, NULL}; // 双缓冲 RGB565 帧 (PSRAM)
static lv_img_dsc_t gif_frame_dsc; // LVGL 图片描述符TRUE_COLOR无Alpha
static uint16_t gif_palette_lut[256]; // 调色板 RGB565 查找表
static volatile uint8_t gif_front_idx = 0; // 当前显示的缓冲区索引
static lv_timer_t *gif_play_timer = NULL; // LVGL 播放定时器
static TaskHandle_t gif_decode_task_handle = NULL; // 后台解码任务句柄
static volatile bool gif_playing = false; // 播放状态标志
static volatile bool gif_new_frame_ready = false; // 新帧就绪标志
static uint32_t gif_last_frame_ms = 0; // 上一帧显示时间戳
static uint8_t *gif_psram_buf = NULL; // GIF 文件数据PSRAM
static bool current_is_gif = false; // 当前是否为 GIF 模式
// 前向声明
static bool is_gif_file(const char *filename);
static void gif_player_start(void);
static void gif_player_stop(void);
#endif // LV_USE_GIF
// 当前亮度值(用于休眠恢复)
static uint8_t current_brightness = 50;
@ -600,7 +630,11 @@ void init_spiffs_image_list(void) {
if(len > 4 && len < MAX_FILENAME_LEN) {
const char *ext = name + len - 4;
if(strcasecmp(ext, ".jpg") == 0 || strcasecmp(ext, ".jpeg") == 0 ||
strcasecmp(ext, ".png") == 0 || strcasecmp(ext, ".bmp") == 0) {
strcasecmp(ext, ".png") == 0 || strcasecmp(ext, ".bmp") == 0
#if LV_USE_GIF
|| strcasecmp(ext, ".gif") == 0
#endif
) {
// 存储图片文件名到静态缓冲区
strncpy(spiffs_image_files[spiffs_image_count], name, MAX_FILENAME_LEN - 1);
spiffs_image_files[spiffs_image_count][MAX_FILENAME_LEN - 1] = '\0';
@ -686,12 +720,25 @@ bool set_image_index_by_name(const char *name) {
// BLE接收图片后导航到ScreenImg显示
void ble_image_navigate(const char *filename) {
// 刷新图片列表
free_spiffs_image_list();
init_spiffs_image_list();
// 设置当前索引为新接收的图片
set_image_index_by_name(filename);
// 将新文件直接追加到列表(避免重扫 SPIFFS 目录,节省 ~200ms
if (!image_list_initialized) {
init_spiffs_image_list();
}
// 检查文件是否已在列表中(避免重复)
bool found = false;
for (int i = 0; i < spiffs_image_count; i++) {
if (strcmp(spiffs_image_files[i], filename) == 0) {
current_image_index = i;
found = true;
break;
}
}
if (!found && spiffs_image_count < MAX_IMAGE_FILES) {
strncpy(spiffs_image_files[spiffs_image_count], filename, MAX_FILENAME_LEN - 1);
spiffs_image_files[spiffs_image_count][MAX_FILENAME_LEN - 1] = '\0';
current_image_index = spiffs_image_count;
spiffs_image_count++;
}
// 检查是否已在ScreenImg界面
lvgl_port_lock(0);
@ -710,6 +757,82 @@ void ble_image_navigate(const char *filename) {
ESP_LOGI("IMG_LIST", "BLE导航到ScreenImg显示: %s", filename);
}
// BLE接收图片后导航显示携带预加载数据跳过 SPIFFS 重读)
void ble_image_navigate_with_data(const char *filename, uint8_t *data, size_t data_size) {
// 将新文件追加到列表
if (!image_list_initialized) {
init_spiffs_image_list();
}
bool found = false;
for (int i = 0; i < spiffs_image_count; i++) {
if (strcmp(spiffs_image_files[i], filename) == 0) {
current_image_index = i;
found = true;
break;
}
}
if (!found && spiffs_image_count < MAX_IMAGE_FILES) {
strncpy(spiffs_image_files[spiffs_image_count], filename, MAX_FILENAME_LEN - 1);
spiffs_image_files[spiffs_image_count][MAX_FILENAME_LEN - 1] = '\0';
current_image_index = spiffs_image_count;
spiffs_image_count++;
}
#if LV_USE_GIF
// 如果有预加载数据且是 GIF直接用内存数据显示跳过 SPIFFS 重读)
if (data && data_size > 0 && is_gif_file(filename)) {
// 停止旧 GIF 播放器
gif_player_stop();
if (gif_psram_buf) {
free(gif_psram_buf);
}
// BLE 数据直接作为 GIF 源(所有权转移)
gif_psram_buf = data;
// 打开 gifdec 解码器(从 PSRAM 内存源)
gif_decoder = gd_open_gif_data(gif_psram_buf);
if (!gif_decoder) {
ESP_LOGE("GIF", "gifdec 打开失败");
free(gif_psram_buf);
gif_psram_buf = NULL;
return;
}
// 确保在 ScreenImg 界面
lvgl_port_lock(0);
bool already_on_screen = (lv_scr_act() == ui_ScreenImg);
if (!already_on_screen) {
_ui_screen_change(&ui_ScreenImg, LV_SCR_LOAD_ANIM_NONE, 0, 0, &ui_ScreenImg_screen_init);
}
lvgl_port_unlock();
// 启动自定义 GIF 播放器
gif_player_start();
ESP_LOGI("IMG_LIST", "BLE GIF直通显示(优化): %s", filename);
return;
}
#endif // LV_USE_GIF
// 非 GIF 或无预加载数据,释放 BLE 数据,走常规 SPIFFS 路径
if (data) {
free(data);
}
lvgl_port_lock(0);
bool already_on_screen = (lv_scr_act() == ui_ScreenImg);
if (!already_on_screen) {
_ui_screen_change(&ui_ScreenImg, LV_SCR_LOAD_ANIM_NONE, 0, 0, &ui_ScreenImg_screen_init);
}
lvgl_port_unlock();
if (already_on_screen) {
update_ui_ImgBle(filename);
}
ESP_LOGI("IMG_LIST", "BLE导航到ScreenImg显示: %s", filename);
}
// 获取当前图片文件名
const char* get_current_image(void) {
if(!image_list_initialized || spiffs_image_count == 0) {
@ -770,71 +893,365 @@ bool delete_current_image(void) {
return true;
}
// 更新ui_ImgBle控件的图片
void update_ui_ImgBle(const char *img_name) {
#if LV_USE_GIF
// 判断文件是否为 GIF 格式
static bool is_gif_file(const char *filename) {
int len = strlen(filename);
if (len < 4) return false;
return strcasecmp(filename + len - 4, ".gif") == 0;
}
// === GIF 播放器内部函数 ===
// 构建调色板 RGB565 查找表(每次帧解码前调用,处理局部调色板)
static void gif_build_palette_lut(gd_Palette *palette) {
for (int i = 0; i < palette->size; i++) {
uint8_t r = palette->colors[i * 3 + 0];
uint8_t g = palette->colors[i * 3 + 1];
uint8_t b = palette->colors[i * 3 + 2];
lv_color_t c = lv_color_make(r, g, b); // 自动处理 LV_COLOR_16_SWAP
gif_palette_lut[i] = c.full;
}
}
// 快速渲染帧到 canvaspalette LUT 替代逐像素 lv_color_make
static void gif_render_frame_fast(gd_GIF *gif) {
int i = gif->fy * gif->width + gif->fx;
for (int j = 0; j < gif->fh; j++) {
for (int k = 0; k < gif->fw; k++) {
uint8_t index = gif->frame[(gif->fy + j) * gif->width + gif->fx + k];
if (!gif->gce.transparency || index != gif->gce.tindex) {
uint16_t c = gif_palette_lut[index];
gif->canvas[(i + k) * 3 + 0] = c & 0xff;
gif->canvas[(i + k) * 3 + 1] = (c >> 8) & 0xff;
gif->canvas[(i + k) * 3 + 2] = 0xff;
}
}
i += gif->width;
}
}
// canvas (RGB565+Alpha 3字节/像素) → RGB565 (2字节/像素) 快速拷贝
static void gif_canvas_to_rgb565(gd_GIF *gif, uint16_t *out) {
uint8_t *canvas = gif->canvas;
int total = gif->width * gif->height;
for (int i = 0; i < total; i++) {
out[i] = (uint16_t)canvas[i * 3] | ((uint16_t)canvas[i * 3 + 1] << 8);
}
}
// 后台解码任务FreeRTOS与 LVGL 显示并行)
static void gif_decode_task(void *pvParameters) {
while (1) {
// 等待解码请求
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
if (!gif_playing || !gif_decoder) break;
// LZW 解码(含 dispose 处理)
int ret = gd_get_frame(gif_decoder);
if (ret == 0) {
// GIF 循环播放
gd_rewind(gif_decoder);
ret = gd_get_frame(gif_decoder);
}
if (ret < 0 || !gif_playing) break;
// 更新调色板 LUT可能有局部调色板变化
gif_build_palette_lut(gif_decoder->palette);
// 快速渲染到 canvas + 拷贝到后台 RGB565 缓冲
gif_render_frame_fast(gif_decoder);
uint8_t back = 1 - gif_front_idx;
gif_canvas_to_rgb565(gif_decoder, gif_rgb565_buf[back]);
// 标记新帧就绪
gif_new_frame_ready = true;
}
gif_decode_task_handle = NULL;
vTaskDelete(NULL);
}
// LVGL 定时器回调(检查新帧并切换显示)
static void gif_play_timer_cb(lv_timer_t *t) {
if (!gif_playing || !gif_decoder || !gif_img_obj) return;
// 检查 GIF 帧延时
uint32_t delay_ms = gif_decoder->gce.delay * 10;
if (delay_ms < 20) delay_ms = 20;
uint32_t elapsed = lv_tick_elaps(gif_last_frame_ms);
if (elapsed < delay_ms) return;
if (!gif_new_frame_ready) return;
gif_last_frame_ms = lv_tick_get();
gif_new_frame_ready = false;
// 切换前后缓冲
uint8_t back = 1 - gif_front_idx;
gif_front_idx = back;
// 更新 LVGL 图片源TRUE_COLOR无 Alpha 混合)
gif_frame_dsc.data = (uint8_t *)gif_rgb565_buf[gif_front_idx];
lv_img_cache_invalidate_src(&gif_frame_dsc);
lv_obj_invalidate(gif_img_obj);
// 通知后台线程解码下一帧
if (gif_decode_task_handle) {
xTaskNotifyGive(gif_decode_task_handle);
}
}
// 启动自定义 GIF 播放器
static void gif_player_start(void) {
if (!gif_decoder || !gif_psram_buf) return;
uint16_t w = gif_decoder->width;
uint16_t h = gif_decoder->height;
size_t buf_size = w * h * sizeof(uint16_t);
// 分配双缓冲 RGB565 帧 (PSRAM)
for (int i = 0; i < 2; i++) {
if (!gif_rgb565_buf[i]) {
gif_rgb565_buf[i] = heap_caps_malloc(buf_size, MALLOC_CAP_SPIRAM);
if (!gif_rgb565_buf[i]) {
ESP_LOGE("GIF", "RGB565 缓冲分配失败: %d", i);
return;
}
}
memset(gif_rgb565_buf[i], 0, buf_size);
}
// 初始化调色板 LUT
gif_build_palette_lut(gif_decoder->palette);
// 同步解码第一帧(确保立即显示)
int ret = gd_get_frame(gif_decoder);
if (ret <= 0) {
ESP_LOGE("GIF", "首帧解码失败");
return;
}
gif_render_frame_fast(gif_decoder);
gif_canvas_to_rgb565(gif_decoder, gif_rgb565_buf[0]);
gif_front_idx = 0;
gif_new_frame_ready = false;
gif_last_frame_ms = lv_tick_get();
// 配置 LVGL 图片描述符TRUE_COLOR无 Alpha
gif_frame_dsc.header.cf = LV_IMG_CF_TRUE_COLOR;
gif_frame_dsc.header.always_zero = 0;
gif_frame_dsc.header.reserved = 0;
gif_frame_dsc.header.w = w;
gif_frame_dsc.header.h = h;
gif_frame_dsc.data_size = buf_size;
gif_frame_dsc.data = (uint8_t *)gif_rgb565_buf[0];
// 创建 lv_img 控件 + LVGL 播放定时器
lvgl_port_lock(0);
lv_obj_add_flag(ui_ImgBle, LV_OBJ_FLAG_HIDDEN);
gif_img_obj = lv_img_create(ui_ScreenImg);
lv_obj_set_size(gif_img_obj, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_align(gif_img_obj, LV_ALIGN_CENTER);
lv_obj_clear_flag(gif_img_obj, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_move_background(gif_img_obj);
lv_img_set_src(gif_img_obj, &gif_frame_dsc);
gif_play_timer = lv_timer_create(gif_play_timer_cb, 10, NULL);
lvgl_port_unlock();
// 启动后台解码任务
gif_playing = true;
current_is_gif = true;
xTaskCreatePinnedToCore(gif_decode_task, "gif_dec", 4096, NULL, 5, &gif_decode_task_handle, 1);
// 触发后台解码第2帧
xTaskNotifyGive(gif_decode_task_handle);
ESP_LOGI("GIF", "播放器启动: %dx%d, 双缓冲 %dKB×2", w, h, buf_size / 1024);
}
// 停止自定义 GIF 播放器
static void gif_player_stop(void) {
// 停止后台解码任务
gif_playing = false;
if (gif_decode_task_handle) {
xTaskNotifyGive(gif_decode_task_handle);
// 等待任务退出(最多 500ms
for (int i = 0; i < 50 && gif_decode_task_handle; i++) {
vTaskDelay(pdMS_TO_TICKS(10));
}
}
// 删除 LVGL 定时器和控件
lvgl_port_lock(0);
if (gif_play_timer) {
lv_timer_del(gif_play_timer);
gif_play_timer = NULL;
}
if (gif_img_obj) {
lv_obj_del(gif_img_obj);
gif_img_obj = NULL;
}
lvgl_port_unlock();
// 关闭 gifdec 解码器
if (gif_decoder) {
gd_close_gif(gif_decoder);
gif_decoder = NULL;
}
// 释放 RGB565 双缓冲
for (int i = 0; i < 2; i++) {
if (gif_rgb565_buf[i]) {
free(gif_rgb565_buf[i]);
gif_rgb565_buf[i] = NULL;
}
}
current_is_gif = false;
gif_new_frame_ready = false;
}
// 清理 GIF 资源(公开接口,供界面切换时调用)
void pages_cleanup_gif(void) {
gif_player_stop();
// 释放 GIF 源文件缓冲区
if (gif_psram_buf) {
free(gif_psram_buf);
gif_psram_buf = NULL;
}
}
#endif // LV_USE_GIF
// 更新ui_ImgBle控件的图片支持 JPEG
bool update_ui_ImgBle(const char *img_name) {
if(!img_name) {
ESP_LOGE("IMG_UI", "图片名为空");
return;
return false;
}
if(!ui_ImgBle) {
ESP_LOGE("IMG_UI", "ui_ImgBle控件不存在");
return;
return false;
}
static uint8_t *ui_img_data = NULL;
static lv_img_dsc_t ui_image;
// 释放之前的图片数据
if(ui_img_data) {
free(ui_img_data);
ui_img_data = NULL;
ESP_LOGI("IMG_UI", "释放之前的图片数据");
}
// 构建图片路径
snprintf(img_path, sizeof(img_path), "/spiflash/%s", img_name);
ESP_LOGI("IMG_UI", "准备显示图片: %s, 路径: %s", img_name, img_path);
// 检查文件是否存在
struct stat file_stat;
if(stat(img_path, &file_stat) != 0) {
ESP_LOGE("IMG_UI", "文件不存在: %s", img_path);
return;
return false;
}
ESP_LOGI("IMG_UI", "文件大小: %ld 字节", file_stat.st_size);
// 解码图片
esp_jpeg_image_output_t ui_outdata;
esp_err_t ret = DecodeImg(img_path, &ui_img_data, &ui_outdata);
if(ret == ESP_OK) {
ESP_LOGI("IMG_UI", "图片解码成功,宽度: %d, 高度: %d", ui_outdata.width, ui_outdata.height);
// 检查解码后的数据
if(ui_img_data == NULL) {
ESP_LOGE("IMG_UI", "解码数据为空");
return;
#if LV_USE_GIF
if (is_gif_file(img_name)) {
// === GIF 显示路径自定义播放器Palette LUT + 无Alpha + 后台解码) ===
// 释放之前的 JPEG 数据
if(ui_img_data) {
free(ui_img_data);
ui_img_data = NULL;
}
// 配置图片数据
ui_image.header.cf = LV_IMG_CF_TRUE_COLOR;
ui_image.header.always_zero = 0;
ui_image.header.reserved = 0;
ui_image.header.w = ui_outdata.width;
ui_image.header.h = ui_outdata.height;
ui_image.data_size = ui_outdata.output_len;
ui_image.data = ui_img_data;
// 更新ui_ImgBle控件的图片
// 停止旧 GIF 播放器 + 释放旧 GIF 数据
gif_player_stop();
if (gif_psram_buf) {
free(gif_psram_buf);
gif_psram_buf = NULL;
}
// 将 GIF 文件整体读入 PSRAM
FILE *gif_file = fopen(img_path, "rb");
if (!gif_file) {
ESP_LOGE("IMG_UI", "GIF文件打开失败: %s", img_path);
return false;
}
gif_psram_buf = heap_caps_malloc(file_stat.st_size, MALLOC_CAP_SPIRAM);
if (!gif_psram_buf) {
ESP_LOGE("IMG_UI", "PSRAM分配失败: %ld 字节", file_stat.st_size);
fclose(gif_file);
return false;
}
fread(gif_psram_buf, 1, file_stat.st_size, gif_file);
fclose(gif_file);
// 打开 gifdec 解码器(从 PSRAM 内存源)
gif_decoder = gd_open_gif_data(gif_psram_buf);
if (!gif_decoder) {
ESP_LOGE("IMG_UI", "gifdec 打开失败: %s", img_name);
free(gif_psram_buf);
gif_psram_buf = NULL;
return false;
}
// 启动自定义 GIF 播放器Palette LUT + 双缓冲流水线)
gif_player_start();
ESP_LOGI("IMG_UI", "GIF显示启动(优化): %s", img_name);
return true;
} else
#endif // LV_USE_GIF
{
// === JPEG 显示路径 ===
#if LV_USE_GIF
// 停止 GIF 播放器并释放资源
gif_player_stop();
if (gif_psram_buf) {
free(gif_psram_buf);
gif_psram_buf = NULL;
}
// 恢复 JPEG 控件显示
lvgl_port_lock(0);
lv_img_set_src(ui_ImgBle, &ui_image);
lv_obj_clear_flag(ui_ImgBle, LV_OBJ_FLAG_HIDDEN);
lvgl_port_unlock();
ESP_LOGI("IMG_UI", "ui_ImgBle图片更新成功: %s", img_name);
} else {
ESP_LOGE("IMG_UI", "图片解码失败,错误码: %d", ret);
ui_img_data = NULL;
#endif
// 释放之前的图片数据
if(ui_img_data) {
free(ui_img_data);
ui_img_data = NULL;
ESP_LOGI("IMG_UI", "释放之前的图片数据");
}
// 解码图片
esp_jpeg_image_output_t ui_outdata;
esp_err_t ret = DecodeImg(img_path, &ui_img_data, &ui_outdata);
if(ret == ESP_OK) {
ESP_LOGI("IMG_UI", "图片解码成功,宽度: %d, 高度: %d", ui_outdata.width, ui_outdata.height);
if(ui_img_data == NULL) {
ESP_LOGE("IMG_UI", "解码数据为空");
return false;
}
// 配置图片数据
ui_image.header.cf = LV_IMG_CF_TRUE_COLOR;
ui_image.header.always_zero = 0;
ui_image.header.reserved = 0;
ui_image.header.w = ui_outdata.width;
ui_image.header.h = ui_outdata.height;
ui_image.data_size = ui_outdata.output_len;
ui_image.data = ui_img_data;
lvgl_port_lock(0);
lv_img_set_src(ui_ImgBle, &ui_image);
lvgl_port_unlock();
ESP_LOGI("IMG_UI", "JPEG图片更新成功: %s", img_name);
return true;
} else {
ESP_LOGE("IMG_UI", "图片解码失败,错误码: %d", ret);
ui_img_data = NULL;
return false;
}
}
return false;
}

View File

@ -0,0 +1,381 @@
// 动漫角色界面 - LVGL图形基元绘制 + 序列帧动画
// 默认静态显示BOOT按键触发眨眼+说话动画
// 零图片资源,纯代码绘制
#include "../ui.h"
#include "ui_ScreenChar.h"
#include "esp_log.h"
static const char *TAG = "CHAR";
lv_obj_t *ui_ScreenChar = NULL;
// === 角色配色 ===
#define COL_SKIN 0xFFD5B8 // 肤色
#define COL_HAIR 0xFFD54F // 金黄色头发
#define COL_EYE_WHITE 0xFFFFFF // 眼白
#define COL_PUPIL 0x1565C0 // 蓝色瞳孔
#define COL_MOUTH 0xE57373 // 粉红嘴巴
#define COL_BLUSH 0xFFAB91 // 腮红
#define COL_SHIRT 0x42A5F5 // 蓝色衣服
#define COL_COLLAR 0xFFFFFF // 白色衣领
#define COL_BG 0x1A1A2E // 深蓝背景
// === 角色布局坐标基于360x360屏幕===
// 角色居中偏上,留出底部空间给提示文字
#define CHAR_CX 180 // 角色中心X
#define FACE_CY 120 // 脸部中心Y
#define FACE_R 72 // 脸部半径
// 眼睛
#define EYE_Y 110 // 眼睛Y
#define EYE_L_X 152 // 左眼X
#define EYE_R_X 208 // 右眼X
#define EYE_W 26 // 眼白宽度
#define EYE_H 26 // 眼白高度(眨眼时变小)
#define PUPIL_R 7 // 瞳孔半径
// 嘴巴
#define MOUTH_Y 155 // 嘴巴Y
#define MOUTH_W 24 // 嘴巴宽度
#define MOUTH_H_IDLE 6 // 静态嘴巴高度(微笑线)
#define MOUTH_H_TALK 18 // 说话时嘴巴高度
// 身体
#define NECK_Y 185 // 脖子顶部Y
#define NECK_W 28 // 脖子宽度
#define NECK_H 18 // 脖子高度
#define BODY_Y 203 // 身体顶部Y
#define BODY_W 160 // 肩宽
#define BODY_H 160 // 身体高度(延伸到屏幕外)
#define SHOULDER_R 25 // 肩部圆角
// 胳膊
#define ARM_W 28 // 胳膊宽度
#define ARM_H 100 // 胳膊长度
#define ARM_Y 215 // 胳膊顶部Y肩膀下方
#define ARM_L_X (CHAR_CX - BODY_W / 2 - ARM_W / 2 + 8) // 左胳膊X
#define ARM_R_X (CHAR_CX + BODY_W / 2 + ARM_W / 2 - 8) // 右胳膊X
#define HAND_R 16 // 手掌半径
// === 角色控件指针 ===
static lv_obj_t *hair_obj = NULL; // 头发
static lv_obj_t *face_obj = NULL; // 脸
static lv_obj_t *eye_l_white = NULL; // 左眼白
static lv_obj_t *eye_r_white = NULL; // 右眼白
static lv_obj_t *pupil_l = NULL; // 左瞳孔
static lv_obj_t *pupil_r = NULL; // 右瞳孔
static lv_obj_t *blush_l = NULL; // 左腮红
static lv_obj_t *blush_r = NULL; // 右腮红
static lv_obj_t *mouth_obj = NULL; // 嘴巴
static lv_obj_t *neck_obj = NULL; // 脖子
static lv_obj_t *body_obj = NULL; // 身体/衣服
static lv_obj_t *collar_l = NULL; // 左衣领
static lv_obj_t *collar_r = NULL; // 右衣领
static lv_obj_t *arm_l = NULL; // 左胳膊
static lv_obj_t *arm_r = NULL; // 右胳膊
static lv_obj_t *hand_l = NULL; // 左手
static lv_obj_t *hand_r = NULL; // 右手
static lv_obj_t *hint_label = NULL; // 提示文字
// === 动画状态 ===
static lv_timer_t *blink_timer = NULL; // 眨眼定时器
static lv_timer_t *mouth_timer = NULL; // 说话定时器
static bool anim_playing = false; // 动画是否在播放
// 眨眼动画状态
static uint8_t blink_phase = 0; // 0=睁眼 1=半闭 2=闭眼 3=半闭
static uint32_t next_blink_ms = 0; // 下次眨眼时间
// 说话动画状态
static uint8_t mouth_phase = 0; // 嘴巴帧索引
static const lv_coord_t mouth_heights[] = {6, 12, 18, 14, 8, 14, 18, 12}; // 嘴巴高度序列
#define MOUTH_FRAMES (sizeof(mouth_heights) / sizeof(mouth_heights[0]))
// === 创建圆形对象的辅助函数 ===
static lv_obj_t *create_circle(lv_obj_t *parent, lv_coord_t cx, lv_coord_t cy,
lv_coord_t w, lv_coord_t h, uint32_t color) {
lv_obj_t *obj = lv_obj_create(parent);
lv_obj_remove_style_all(obj);
lv_obj_set_size(obj, w, h);
lv_obj_set_pos(obj, cx - w / 2, cy - h / 2);
lv_obj_set_style_bg_color(obj, lv_color_hex(color), 0);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);
lv_obj_set_style_radius(obj, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_border_width(obj, 0, 0);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE);
return obj;
}
// 创建矩形/圆角矩形
static lv_obj_t *create_rect(lv_obj_t *parent, lv_coord_t x, lv_coord_t y,
lv_coord_t w, lv_coord_t h, uint32_t color, lv_coord_t radius) {
lv_obj_t *obj = lv_obj_create(parent);
lv_obj_remove_style_all(obj);
lv_obj_set_size(obj, w, h);
lv_obj_set_pos(obj, x, y);
lv_obj_set_style_bg_color(obj, lv_color_hex(color), 0);
lv_obj_set_style_bg_opa(obj, LV_OPA_COVER, 0);
lv_obj_set_style_radius(obj, radius, 0);
lv_obj_set_style_border_width(obj, 0, 0);
lv_obj_clear_flag(obj, LV_OBJ_FLAG_SCROLLABLE | LV_OBJ_FLAG_CLICKABLE);
return obj;
}
// === 绘制角色 ===
static void draw_character(lv_obj_t *screen) {
// 1. 胳膊(最底层,被身体部分遮挡)
// 左胳膊(衣袖)
arm_l = create_rect(screen, ARM_L_X - ARM_W / 2, ARM_Y,
ARM_W, ARM_H, COL_SHIRT, ARM_W / 2);
// 右胳膊(衣袖)
arm_r = create_rect(screen, ARM_R_X - ARM_W / 2, ARM_Y,
ARM_W, ARM_H, COL_SHIRT, ARM_W / 2);
// 左手(肤色圆形)
hand_l = create_circle(screen, ARM_L_X, ARM_Y + ARM_H - 5,
HAND_R * 2, HAND_R * 2, COL_SKIN);
// 右手(肤色圆形)
hand_r = create_circle(screen, ARM_R_X, ARM_Y + ARM_H - 5,
HAND_R * 2, HAND_R * 2, COL_SKIN);
// 2. 身体/衣服
body_obj = create_rect(screen,
CHAR_CX - BODY_W / 2, BODY_Y,
BODY_W, BODY_H, COL_SHIRT, SHOULDER_R);
// 3. 衣领V形用两个斜矩形模拟
collar_l = create_rect(screen,
CHAR_CX - 18, BODY_Y,
20, 28, COL_COLLAR, 3);
lv_obj_set_style_transform_angle(collar_l, 200, 0); // 旋转20度
collar_r = create_rect(screen,
CHAR_CX - 2, BODY_Y,
20, 28, COL_COLLAR, 3);
lv_obj_set_style_transform_angle(collar_r, -200, 0); // 旋转-20度
// 3. 脖子
neck_obj = create_rect(screen,
CHAR_CX - NECK_W / 2, NECK_Y,
NECK_W, NECK_H, COL_SKIN, 5);
// 4. 头发(脸后面的半圆,稍大于脸)
hair_obj = create_circle(screen, CHAR_CX, FACE_CY - 5,
FACE_R * 2 + 16, FACE_R * 2 + 16, COL_HAIR);
// 头发刘海(额头上方的矩形)
create_rect(screen,
CHAR_CX - FACE_R - 2, FACE_CY - FACE_R - 10,
FACE_R * 2 + 4, 35, COL_HAIR, 8);
// 5. 脸(圆形)
face_obj = create_circle(screen, CHAR_CX, FACE_CY,
FACE_R * 2, FACE_R * 2, COL_SKIN);
// 6. 腮红
blush_l = create_circle(screen, EYE_L_X - 5, EYE_Y + 18, 22, 12, COL_BLUSH);
lv_obj_set_style_bg_opa(blush_l, LV_OPA_60, 0);
blush_r = create_circle(screen, EYE_R_X + 5, EYE_Y + 18, 22, 12, COL_BLUSH);
lv_obj_set_style_bg_opa(blush_r, LV_OPA_60, 0);
// 7. 眼睛(眼白 + 瞳孔)
eye_l_white = create_circle(screen, EYE_L_X, EYE_Y, EYE_W, EYE_H, COL_EYE_WHITE);
eye_r_white = create_circle(screen, EYE_R_X, EYE_Y, EYE_W, EYE_H, COL_EYE_WHITE);
pupil_l = create_circle(screen, EYE_L_X, EYE_Y + 2, PUPIL_R * 2, PUPIL_R * 2, COL_PUPIL);
pupil_r = create_circle(screen, EYE_R_X, EYE_Y + 2, PUPIL_R * 2, PUPIL_R * 2, COL_PUPIL);
// 眼睛高光(小白点)
create_circle(screen, EYE_L_X + 3, EYE_Y - 1, 5, 5, COL_EYE_WHITE);
create_circle(screen, EYE_R_X + 3, EYE_Y - 1, 5, 5, COL_EYE_WHITE);
// 8. 眉毛(深棕色,黄毛配深眉更协调)
lv_obj_t *brow_l = create_rect(screen, EYE_L_X - 14, EYE_Y - 20, 22, 4, 0x5D4037, 2);
lv_obj_set_style_transform_angle(brow_l, -50, 0); // 微微倾斜
lv_obj_t *brow_r = create_rect(screen, EYE_R_X - 8, EYE_Y - 20, 22, 4, 0x5D4037, 2);
lv_obj_set_style_transform_angle(brow_r, 50, 0);
// 9. 鼻子(小三角,用一个很小的圆角矩形)
create_circle(screen, CHAR_CX, FACE_CY + 12, 6, 6, 0xFFBCA0);
// 10. 嘴巴(椭圆形,高度可变实现说话效果)
mouth_obj = create_circle(screen, CHAR_CX, MOUTH_Y,
MOUTH_W, MOUTH_H_IDLE, COL_MOUTH);
}
// === 眨眼动画回调 ===
static void blink_timer_cb(lv_timer_t *t) {
if (!anim_playing || !eye_l_white || !eye_r_white) return;
uint32_t now = lv_tick_get();
if (blink_phase == 0) {
// 睁眼状态,等待下次眨眼
if (now < next_blink_ms) return;
blink_phase = 1;
}
// 眨眼帧序列睁眼26 → 半闭12 → 闭眼3 → 半闭12 → 睁眼26
static const lv_coord_t eye_h[] = {12, 3, 12, EYE_H};
lv_coord_t h = eye_h[blink_phase - 1];
// 更新眼睛高度(模拟眨眼)
lv_obj_set_height(eye_l_white, h);
lv_obj_set_y(eye_l_white, EYE_Y - h / 2);
lv_obj_set_height(eye_r_white, h);
lv_obj_set_y(eye_r_white, EYE_Y - h / 2);
// 瞳孔跟随(闭眼时隐藏)
if (h <= 5) {
lv_obj_add_flag(pupil_l, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_flag(pupil_r, LV_OBJ_FLAG_HIDDEN);
} else {
lv_obj_clear_flag(pupil_l, LV_OBJ_FLAG_HIDDEN);
lv_obj_clear_flag(pupil_r, LV_OBJ_FLAG_HIDDEN);
}
blink_phase++;
if (blink_phase > 4) {
blink_phase = 0;
// 随机间隔2-4秒后下次眨眼
next_blink_ms = now + 2000 + (lv_tick_get() % 2000);
}
}
// === 说话动画回调 ===
static void mouth_timer_cb(lv_timer_t *t) {
if (!anim_playing || !mouth_obj) return;
lv_coord_t h = mouth_heights[mouth_phase];
lv_obj_set_height(mouth_obj, h);
lv_obj_set_y(mouth_obj, MOUTH_Y - h / 2);
mouth_phase = (mouth_phase + 1) % MOUTH_FRAMES;
}
// === 手势回调 ===
static void ui_event_ScreenChar(lv_event_t *e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_GESTURE) {
lv_dir_t dir = lv_indev_get_gesture_dir(lv_indev_get_act());
// 下滑返回ScreenImg
if (dir == LV_DIR_BOTTOM) {
lv_indev_wait_release(lv_indev_get_act());
char_anim_stop();
_ui_screen_change(&ui_ScreenImg, LV_SCR_LOAD_ANIM_NONE, 0, 0, &ui_ScreenImg_screen_init);
}
// 左滑/右滑返回Home
if (dir == LV_DIR_LEFT || dir == LV_DIR_RIGHT) {
lv_indev_wait_release(lv_indev_get_act());
char_anim_stop();
_ui_screen_change(&ui_ScreenHome, LV_SCR_LOAD_ANIM_NONE, 0, 0, &ui_ScreenHome_screen_init);
}
}
}
// === 界面初始化 ===
void ui_ScreenChar_screen_init(void) {
ui_ScreenChar = lv_obj_create(NULL);
lv_obj_clear_flag(ui_ScreenChar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_style_bg_color(ui_ScreenChar, lv_color_hex(COL_BG), LV_PART_MAIN);
lv_obj_set_style_bg_opa(ui_ScreenChar, 255, LV_PART_MAIN);
// 绘制角色
draw_character(ui_ScreenChar);
// 提示标签
hint_label = lv_label_create(ui_ScreenChar);
lv_label_set_text(hint_label, "BOOT: Talk");
lv_obj_set_align(hint_label, LV_ALIGN_BOTTOM_MID);
lv_obj_set_y(hint_label, -15);
lv_obj_set_style_text_color(hint_label, lv_color_hex(0x666688), LV_PART_MAIN);
lv_obj_set_style_text_font(hint_label, &lv_font_montserrat_14, LV_PART_MAIN);
// 手势下滑返回Img左右滑返回Home
lv_obj_add_flag(ui_ScreenChar, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_flag(ui_ScreenChar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_event_cb(ui_ScreenChar, ui_event_ScreenChar, LV_EVENT_ALL, NULL);
ESP_LOGI(TAG, "ScreenChar界面初始化完成");
}
void ui_ScreenChar_screen_destroy(void) {
char_anim_stop();
if (ui_ScreenChar) lv_obj_del(ui_ScreenChar);
ui_ScreenChar = NULL;
// 所有子控件随屏幕一起销毁,只需清空指针
face_obj = hair_obj = NULL;
eye_l_white = eye_r_white = NULL;
pupil_l = pupil_r = NULL;
blush_l = blush_r = NULL;
mouth_obj = neck_obj = body_obj = NULL;
collar_l = collar_r = NULL;
arm_l = arm_r = hand_l = hand_r = NULL;
hint_label = NULL;
}
// === 动画控制接口 ===
void char_anim_start(void) {
if (anim_playing) return;
anim_playing = true;
blink_phase = 0;
mouth_phase = 0;
next_blink_ms = lv_tick_get() + 500; // 0.5秒后第一次眨眼
// 眨眼定时器50ms间隔眨眼帧切换速度
blink_timer = lv_timer_create(blink_timer_cb, 50, NULL);
// 说话定时器80ms间隔嘴巴变化速度
mouth_timer = lv_timer_create(mouth_timer_cb, 80, NULL);
// 更新提示
if (hint_label) {
lv_label_set_text(hint_label, "Speaking...");
lv_obj_set_style_text_color(hint_label, lv_color_hex(0x42A5F5), LV_PART_MAIN);
}
ESP_LOGI(TAG, "角色动画启动(序列帧)");
}
void char_anim_stop(void) {
if (!anim_playing) return;
anim_playing = false;
// 删除定时器
if (blink_timer) {
lv_timer_del(blink_timer);
blink_timer = NULL;
}
if (mouth_timer) {
lv_timer_del(mouth_timer);
mouth_timer = NULL;
}
// 恢复静态状态
if (eye_l_white) {
lv_obj_set_height(eye_l_white, EYE_H);
lv_obj_set_y(eye_l_white, EYE_Y - EYE_H / 2);
}
if (eye_r_white) {
lv_obj_set_height(eye_r_white, EYE_H);
lv_obj_set_y(eye_r_white, EYE_Y - EYE_H / 2);
}
if (pupil_l) lv_obj_clear_flag(pupil_l, LV_OBJ_FLAG_HIDDEN);
if (pupil_r) lv_obj_clear_flag(pupil_r, LV_OBJ_FLAG_HIDDEN);
if (mouth_obj) {
lv_obj_set_height(mouth_obj, MOUTH_H_IDLE);
lv_obj_set_y(mouth_obj, MOUTH_Y - MOUTH_H_IDLE / 2);
}
// 恢复提示
if (hint_label) {
lv_label_set_text(hint_label, "BOOT: Talk");
lv_obj_set_style_text_color(hint_label, lv_color_hex(0x666688), LV_PART_MAIN);
}
ESP_LOGI(TAG, "角色动画停止");
}
bool char_anim_is_playing(void) {
return anim_playing;
}

View File

@ -0,0 +1,25 @@
// 动漫角色界面 - LVGL图形基元绘制 + 序列帧动画
#ifndef UI_SCREENCHAR_H
#define UI_SCREENCHAR_H
#ifdef __cplusplus
extern "C" {
#endif
#include "lvgl.h"
// SCREEN: ui_ScreenChar
extern void ui_ScreenChar_screen_init(void);
extern void ui_ScreenChar_screen_destroy(void);
extern lv_obj_t *ui_ScreenChar;
// 角色动画控制BOOT按键触发
void char_anim_start(void); // 开始眨眼+说话动画
void char_anim_stop(void); // 停止动画,恢复静态
bool char_anim_is_playing(void); // 查询动画是否在播放
#ifdef __cplusplus
} /*extern "C"*/
#endif
#endif

View File

@ -9,7 +9,7 @@
#include "esp_log.h" // 用于日志输出
extern void init_spiffs_image_list(void);
extern void update_ui_ImgBle(const char *img_name);
extern bool update_ui_ImgBle(const char *img_name);
extern void free_spiffs_image_list(void);
extern const char* get_next_image(void);
extern const char* get_prev_image(void);
@ -104,13 +104,22 @@ if ( event_code == LV_EVENT_SCREEN_LOADED ) {
if ( event_code == LV_EVENT_GESTURE && lv_indev_get_gesture_dir(lv_indev_get_act()) == LV_DIR_TOP ) {
lv_indev_wait_release(lv_indev_get_act());
// 离开界面前隐藏容器
// 离开界面前清理资源和隐藏容器
#if LV_USE_GIF
pages_cleanup_gif();
#endif
ui_ScreenImg_hide_delete_container();
_ui_screen_change( &ui_ScreenHome, LV_SCR_LOAD_ANIM_NONE, 0, 0, &ui_ScreenHome_screen_init);
// 上滑进入角色动画界面
_ui_screen_change( &ui_ScreenChar, LV_SCR_LOAD_ANIM_NONE, 0, 0, &ui_ScreenChar_screen_init);
// 原有上滑返回Home如需切换回来注释上面一行取消下面一行注释
// _ui_screen_change( &ui_ScreenHome, LV_SCR_LOAD_ANIM_NONE, 0, 0, &ui_ScreenHome_screen_init);
}
if ( event_code == LV_EVENT_GESTURE && lv_indev_get_gesture_dir(lv_indev_get_act()) == LV_DIR_BOTTOM ) {
lv_indev_wait_release(lv_indev_get_act());
// 离开界面前隐藏容器
// 离开界面前清理资源和隐藏容器
#if LV_USE_GIF
pages_cleanup_gif();
#endif
ui_ScreenImg_hide_delete_container();
// 设置返回到Img界面
ui_ScreenSet_set_previous(&ui_ScreenImg, &ui_ScreenImg_screen_init);
@ -118,16 +127,22 @@ lv_indev_wait_release(lv_indev_get_act());
}
if ( event_code == LV_EVENT_GESTURE && lv_indev_get_gesture_dir(lv_indev_get_act()) == LV_DIR_LEFT ) {
lv_indev_wait_release(lv_indev_get_act());
const char *next_img = get_next_image();
if(next_img) {
update_ui_ImgBle(next_img);
// 解码失败时自动跳过,最多尝试全部图片避免死循环
for(int try = 0; try < 10; try++) {
const char *next_img = get_next_image();
if(!next_img) break;
if(update_ui_ImgBle(next_img)) break;
ESP_LOGW("ScreenImg", "跳过无效图片,继续下一张");
}
}
if ( event_code == LV_EVENT_GESTURE && lv_indev_get_gesture_dir(lv_indev_get_act()) == LV_DIR_RIGHT ) {
lv_indev_wait_release(lv_indev_get_act());
const char *prev_img = get_prev_image();
if(prev_img) {
update_ui_ImgBle(prev_img);
// 解码失败时自动跳过,最多尝试全部图片避免死循环
for(int try = 0; try < 10; try++) {
const char *prev_img = get_prev_image();
if(!prev_img) break;
if(update_ui_ImgBle(prev_img)) break;
ESP_LOGW("ScreenImg", "跳过无效图片,继续上一张");
}
}
}
@ -196,6 +211,9 @@ lv_obj_add_event_cb(ui_ScreenImg, ui_event_ScreenImg, LV_EVENT_ALL, NULL);
void ui_ScreenImg_screen_destroy(void)
{
#if LV_USE_GIF
pages_cleanup_gif();
#endif
if (ui_ScreenImg) lv_obj_del(ui_ScreenImg);
// NULL screen variables

View File

@ -21,7 +21,7 @@ extern lv_obj_t *ui_ImageDel;
extern lv_obj_t *ui_ImageReturn;
extern void init_spiffs_image_list(void);
extern void update_ui_ImgBle(const char *img_name);
extern bool update_ui_ImgBle(const char *img_name);
extern void free_spiffs_image_list(void);
extern const char* get_next_image(void);
extern const char* get_prev_image(void);

View File

@ -19,6 +19,7 @@ extern "C" {
#include "screens/ui_ScreenHome.h"
#include "screens/ui_ScreenSet.h"
#include "screens/ui_ScreenImg.h"
#include "screens/ui_ScreenChar.h"
///////////////////// VARIABLES ////////////////////

View File

@ -14,6 +14,7 @@ CONFIG_SOC_GDMA_SUPPORTED=y
CONFIG_SOC_AHB_GDMA_SUPPORTED=y
CONFIG_SOC_GPTIMER_SUPPORTED=y
CONFIG_SOC_LCDCAM_SUPPORTED=y
CONFIG_SOC_LCDCAM_CAM_SUPPORTED=y
CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED=y
CONFIG_SOC_LCDCAM_RGB_LCD_SUPPORTED=y
CONFIG_SOC_MCPWM_SUPPORTED=y
@ -101,7 +102,7 @@ CONFIG_SOC_CPU_HAS_FPU=y
CONFIG_SOC_HP_CPU_HAS_MULTIPLE_CORES=y
CONFIG_SOC_CPU_BREAKPOINTS_NUM=2
CONFIG_SOC_CPU_WATCHPOINTS_NUM=2
CONFIG_SOC_CPU_WATCHPOINT_MAX_REGION_SIZE=64
CONFIG_SOC_CPU_WATCHPOINT_MAX_REGION_SIZE=0x40
CONFIG_SOC_SIMD_PREFERRED_DATA_ALIGNMENT=16
CONFIG_SOC_DS_SIGNATURE_MAX_BIT_LEN=4096
CONFIG_SOC_DS_KEY_PARAM_MD_IV_LENGTH=16
@ -208,7 +209,7 @@ CONFIG_SOC_RTCIO_INPUT_OUTPUT_SUPPORTED=y
CONFIG_SOC_RTCIO_HOLD_SUPPORTED=y
CONFIG_SOC_RTCIO_WAKE_SUPPORTED=y
CONFIG_SOC_LP_IO_CLOCK_IS_INDEPENDENT=y
CONFIG_SOC_SDM_GROUPS=y
CONFIG_SOC_SDM_GROUPS=1
CONFIG_SOC_SDM_CHANNELS_PER_GROUP=8
CONFIG_SOC_SDM_CLK_SUPPORT_APB=y
CONFIG_SOC_SPI_PERIPH_NUM=3
@ -369,6 +370,9 @@ CONFIG_SOC_BLE_DEVICE_PRIVACY_SUPPORTED=y
CONFIG_SOC_BLUFI_SUPPORTED=y
CONFIG_SOC_ULP_HAS_ADC=y
CONFIG_SOC_PHY_COMBO_MODULE=y
CONFIG_SOC_LCDCAM_CAM_SUPPORT_RGB_YUV_CONV=y
CONFIG_SOC_LCDCAM_CAM_PERIPH_NUM=1
CONFIG_SOC_LCDCAM_CAM_DATA_WIDTH_MAX=16
CONFIG_IDF_CMAKE=y
CONFIG_IDF_TOOLCHAIN="gcc"
CONFIG_IDF_TOOLCHAIN_GCC=y
@ -831,11 +835,17 @@ CONFIG_BT_ALLOCATION_FROM_SPIRAM_FIRST=y
# CONFIG_BT_BLE_ACT_SCAN_REP_ADV_SCAN is not set
CONFIG_BT_MAX_DEVICE_NAME_LEN=32
CONFIG_BT_BLE_RPA_TIMEOUT=900
# CONFIG_BT_BLE_50_FEATURES_SUPPORTED is not set
CONFIG_BT_BLE_50_FEATURES_SUPPORTED=y
CONFIG_BT_BLE_50_EXTEND_ADV_EN=y
CONFIG_BT_BLE_50_PERIODIC_ADV_EN=y
CONFIG_BT_BLE_50_EXTEND_SCAN_EN=y
CONFIG_BT_BLE_50_EXTEND_SYNC_EN=y
CONFIG_BT_BLE_50_DTM_TEST_EN=y
CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y
CONFIG_BT_BLE_42_DTM_TEST_EN=y
CONFIG_BT_BLE_42_ADV_EN=y
CONFIG_BT_BLE_42_SCAN_EN=y
CONFIG_BT_BLE_VENDOR_HCI_EN=y
# CONFIG_BT_BLE_HIGH_DUTY_ADV_INTERVAL is not set
# CONFIG_BT_ABORT_WHEN_ALLOCATION_FAILS is not set
# end of Bluedroid Options
@ -1057,6 +1067,7 @@ CONFIG_ESP_TLS_USE_DS_PERIPHERAL=y
# CONFIG_ESP_TLS_SERVER_MIN_AUTH_MODE_OPTIONAL is not set
# CONFIG_ESP_TLS_PSK_VERIFICATION is not set
# CONFIG_ESP_TLS_INSECURE is not set
CONFIG_ESP_TLS_DYN_BUF_STRATEGY_SUPPORTED=y
# end of ESP-TLS
#
@ -1083,6 +1094,12 @@ CONFIG_ESP_ERR_TO_NAME_LOOKUP=y
CONFIG_ESP_ALLOW_BSS_SEG_EXTERNAL_MEMORY=y
# end of Common ESP-related
#
# ESP-Driver:Camera Controller Configurations
#
# CONFIG_CAM_CTLR_DVP_CAM_ISR_CACHE_SAFE is not set
# end of ESP-Driver:Camera Controller Configurations
#
# ESP-Driver:GPIO Configurations
#
@ -1403,8 +1420,11 @@ CONFIG_ESP_PHY_RF_CAL_PARTIAL=y
# CONFIG_ESP_PHY_RF_CAL_NONE is not set
# CONFIG_ESP_PHY_RF_CAL_FULL is not set
CONFIG_ESP_PHY_CALIBRATION_MODE=0
CONFIG_ESP_PHY_PLL_TRACK_PERIOD_MS=1000
# CONFIG_ESP_PHY_PLL_TRACK_DEBUG is not set
# CONFIG_ESP_PHY_RECORD_USED_TIME is not set
CONFIG_ESP_PHY_IRAM_OPT=y
# CONFIG_ESP_PHY_DEBUG is not set
# end of PHY
#
@ -1473,9 +1493,9 @@ CONFIG_SPIRAM_ALLOW_NOINIT_SEG_EXTERNAL_MEMORY=y
# ESP System Settings
#
# CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_80 is not set
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y
# CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240 is not set
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=160
# CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160 is not set
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=240
#
# Cache config
@ -2084,6 +2104,7 @@ CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
# CONFIG_MBEDTLS_X509_TRUSTED_CERT_CALLBACK is not set
# CONFIG_MBEDTLS_SSL_CONTEXT_SERIALIZATION is not set
CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE=y
# CONFIG_MBEDTLS_SSL_KEYING_MATERIAL_EXPORT is not set
CONFIG_MBEDTLS_PKCS7_C=y
# end of mbedTLS v3.x related
@ -2564,9 +2585,8 @@ CONFIG_LV_COLOR_CHROMA_KEY_HEX=0x00FF00
#
# Memory settings
#
# CONFIG_LV_MEM_CUSTOM is not set
CONFIG_LV_MEM_SIZE_KILOBYTES=64
CONFIG_LV_MEM_ADDR=0x0
CONFIG_LV_MEM_CUSTOM=y
CONFIG_LV_MEM_CUSTOM_INCLUDE="stdlib.h"
CONFIG_LV_MEM_BUF_MAX_NUM=16
# CONFIG_LV_MEMCPY_MEMSET_STD is not set
# end of Memory settings
@ -2631,7 +2651,6 @@ CONFIG_LV_ASSERT_HANDLER_INCLUDE="assert.h"
# Others
#
# CONFIG_LV_USE_PERF_MONITOR is not set
# CONFIG_LV_USE_MEM_MONITOR is not set
# CONFIG_LV_USE_REFR_DEBUG is not set
# CONFIG_LV_SPRINTF_CUSTOM is not set
# CONFIG_LV_SPRINTF_USE_FLOAT is not set
@ -2802,14 +2821,17 @@ CONFIG_LV_USE_GRID=y
# 3rd Party Libraries
#
# CONFIG_LV_USE_FS_STDIO is not set
# CONFIG_LV_USE_FS_POSIX is not set
CONFIG_LV_USE_FS_POSIX=y
CONFIG_LV_FS_POSIX_LETTER=83
CONFIG_LV_FS_POSIX_PATH="/spiflash/"
CONFIG_LV_FS_POSIX_CACHE_SIZE=0
# CONFIG_LV_USE_FS_WIN32 is not set
# CONFIG_LV_USE_FS_FATFS is not set
# CONFIG_LV_USE_FS_LITTLEFS is not set
# CONFIG_LV_USE_PNG is not set
# CONFIG_LV_USE_BMP is not set
# CONFIG_LV_USE_SJPG is not set
# CONFIG_LV_USE_GIF is not set
CONFIG_LV_USE_GIF=y
# CONFIG_LV_USE_QRCODE is not set
# CONFIG_LV_USE_FREETYPE is not set
# CONFIG_LV_USE_TINY_TTF is not set
@ -3067,6 +3089,7 @@ CONFIG_BT_NIMBLE_COEX_PHY_CODED_TX_RX_TLIM_DIS=y
CONFIG_SW_COEXIST_ENABLE=y
CONFIG_ESP32_WIFI_SW_COEXIST_ENABLE=y
CONFIG_ESP_WIFI_SW_COEXIST_ENABLE=y
# CONFIG_CAM_CTLR_DVP_CAM_ISR_IRAM_SAFE is not set
# CONFIG_MCPWM_ISR_IN_IRAM is not set
# CONFIG_EVENT_LOOP_PROFILING is not set
CONFIG_POST_EVENTS_FROM_ISR=y
@ -3095,9 +3118,9 @@ CONFIG_ESP32S3_SPIRAM_SUPPORT=y
CONFIG_DEFAULT_PSRAM_CLK_IO=30
CONFIG_DEFAULT_PSRAM_CS_IO=26
# CONFIG_ESP32S3_DEFAULT_CPU_FREQ_80 is not set
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160=y
# CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240 is not set
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_MHZ=160
# CONFIG_ESP32S3_DEFAULT_CPU_FREQ_160 is not set
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y
CONFIG_ESP32S3_DEFAULT_CPU_FREQ_MHZ=240
CONFIG_SYSTEM_EVENT_QUEUE_SIZE=32
CONFIG_SYSTEM_EVENT_TASK_STACK_SIZE=2304
CONFIG_MAIN_TASK_STACK_SIZE=3584

View File

@ -0,0 +1,266 @@
一、GIF卡顿原因深度分析
1. 核心瓶颈定位
瓶颈点
具体问题
对360×360屏幕的影响
CPU开销
图片中列出LZW+调色板+缩放+RGBA→565转换每帧需多次转换
每帧约5-10ms计算时间15fps需6.7ms/帧,已接近极限
循环重播​
dispose+rewind+重解码首帧,每次循环额外开销
每次循环增加10-20ms延迟导致循环衔接卡顿
闪烁问题​
Canvas重置时的可见空白帧
用户感知明显的"闪一下"
BLE延迟
SPIFFS写→SPIFFS读→解码I/O操作过多
传输到显示延迟达200-500ms
内存占用​
Canvas约504KB + 解码缓冲区 + 文件缓冲
总内存需求约1.5-2MB挤占其他功能
2. 您当前配置的极限分析
ESP32-S3-WROOM-1-N16R88MB PSRAM的资源分配
火山RTC SDK2-2.5MB(音频处理+网络缓冲)
LVGL基础1-1.5MB(框架+字体+UI缓存
GIF显示
Canvas缓冲区504KB
解码工作区300-500KB
文件缓冲100-200KB
小计约1-1.2MB
系统基础1-1.5MB
合计5.2-6.7MB → 剩余仅1.3-2.8MB缓冲
关键发现剩余PSRAM不足导致
频繁GC内存碎片整理导致卡顿
无预解码空间:无法缓存下一帧
I/O阻塞频繁读写SPIFFS
二、市面上电子吧唧配置对比
1. 主流产品硬件方案
产品/方案
主控配置
内存规格
显示方案
GIF优化技术
EchoEar喵伴
ESP32-S3-WROOM-1-N16R16V
16MB PSRAM
360×360 QSPI
硬件缩放+直接帧传输
四博智联方案​
ESP32-S3-WROOM-1-N16R16VA
16MB PSRAM
240×240 SPI
预解码+多级缓存
利尔达AI豆
ESP32-S3-WROOM-2-N32R16V
16MB PSRAM+硬件加速
320×320 RGB接口
专用DMA通道
NN家族徽章
定制芯片+外置DDR
32-64MB
电子墨水屏
云端预渲染+本地流式更新
2. 与您配置的关键差异
您的配置 vs 市场主流配置
┌─────────────────┬─────────────────────────┬─────────────────────────┐
│ 维度 │ 您的配置 (N16R8) │ 市场主流 (N16R16V+) │
├─────────────────┼─────────────────────────┼─────────────────────────┤
│ PSRAM │ 8MB (紧张) │ 16MB (充裕) │
│ GIF处理策略 │ 实时解码+文件中转 │ 预解码+内存直出 │
│ 显示接口 │ 可能为SPI │ 多为QSPI/RGB并行 │
│ 传输延迟 │ 200-500ms (SPIFFS中转) │ 50-100ms (内存直传) │
│ 可缓存帧数 │ 0-1帧 (无预缓冲) │ 3-5帧 (流畅预缓冲) │
│ 循环优化 │ 每次重解码 │ 首帧缓存+增量更新 │
└─────────────────┴─────────────────────────┴─────────────────────────┘
三、他们为何不卡顿?关键技术优化
1. 内存优化策略
// 市场方案典型的内存分配16MB PSRAM
#define GIF_POOL_SIZE (3 * 1024 * 1024) // 3MB GIF专用池
#define FRAME_BUFFER_SIZE (2 * 1024 * 1024) // 2MB双缓冲
#define DECODE_BUFFER_SIZE (1 * 1024 * 1024) // 1MB解码工作区
// 您的配置对比8MB PSRAM
// 难以分配专用池,需与其他功能共享
2. 显示流水线优化
市场方案的四级流水线:
接收级BLE直接写入PSRAM绕过文件系统
预解码级后台线程预解码3-5帧
显示级专用DMA从PSRAM直传屏幕
清理级:显示完成后异步释放
您的现状:单线程串行处理,每步都阻塞。
3. 解码算法优化
市场方案使用NEON指令集加速LZW硬件RGBA→565转换
您的现状软件解码CPU占用高
四、具体优化建议基于8MB PSRAM
1. 内存管理重构
// 创建专用内存池,避免碎片
static uint8_t gif_pool[2 * 1024 * 1024] __attribute__((section(".spiram")));
// 替换malloc/free
void* gif_malloc(size_t size) {
static size_t offset = 0;
if(offset + size > sizeof(gif_pool)) return NULL;
void* ptr = &gif_pool[offset];
offset += size;
return ptr;
}
2. 绕过文件系统中转
// 原流程BLE→SPIFFS→读文件→解码→显示
// 新流程BLE→PSRAM→解码→显示
// BLE接收直接写入PSRAM缓冲区
void ble_on_receive(uint8_t* data, size_t len) {
static uint8_t* gif_buffer = heap_caps_malloc(MAX_GIF_SIZE, MALLOC_CAP_SPIRAM);
memcpy(gif_buffer + offset, data, len);
offset += len;
if(offset == total_size) {
// 直接解码PSRAM中的GIF
decode_gif_from_ram(gif_buffer, total_size);
}
}
3. 降低GIF规格
必须妥协的参数:
分辨率240×240在360×360屏幕上居中显示
帧率10-12fps人眼可接受下限
颜色64色从256色降低
循环:单次播放,避免重解码开销
效果内存需求从1.2MB降至约400KB。
4. 显示技术优化
// 使用LVGL的部分刷新
lv_disp_set_draw_buffers(disp, buf1, buf2, 360 * 120, LV_DISP_RENDER_MODE_PARTIAL);
// 启用脏区域标记
lv_obj_add_flag(gif_obj, LV_OBJ_FLAG_DIRTY_UPDATE);
五、终极解决方案:模组升级
升级到N16R16V16MB PSRAM的收益
性能指标
升级前
升级后
改善幅度
可用PSRAM
1.3-2.8MB
8-10MB
+300%
GIF缓存帧
0-1帧
3-5帧
流畅循环
传输延迟​
200-500ms
50-100ms
-80%
解码并发​
串行
流水线并行
帧率提升2-3倍
系统稳定性​
频繁GC卡顿
稳定运行
用户体验质变
成本分析:
N16R8模组约25-30元
N16R16V模组约30-35元
增量成本5元左右但体验提升显著
六、实施路线图
短期1-2周软件优化
实现BLE直存PSRAM绕过SPIFFS
降低GIF规格240×24010fps64色
使用LVGL部分刷新减少绘制区域
中期2-4周架构优化
实现双缓冲显示流水线
添加后台预解码线程
优化内存管理,减少碎片
长期(如有必要):硬件升级
更换为ESP32-S3-WROOM-1-N16R16V
重新设计PCB优化布局
考虑QSPI屏幕接口提升传输速度
总结
卡顿的根本原因是8MB PSRAM在多任务RTC+LVGL+BLE+GIF下资源不足导致必须通过文件系统中转、无预解码缓冲、频繁内存回收。
市场主流产品通过以下方式避免卡顿:
硬件升级16MB+ PSRAM提供充足缓冲
架构优化:内存直传+预解码流水线
显示优化QSPI/RGB接口+硬件加速
对您的建议:
立即实施软件优化方案特别是绕过SPIFFS
评估升级如果优化后仍不满足需求强烈建议升级到N16R16V
平衡妥协适当降低GIF规格在现有硬件上获得最佳体验
5元左右的模组升级成本可换来用户体验的质变在产品化阶段是值得的投资。