Compare commits

...

1 Commits

Author SHA1 Message Date
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
8 changed files with 868 additions and 86 deletions

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:
@ -268,9 +330,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 +346,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 +364,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,8 +381,8 @@ 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"",

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

@ -213,13 +213,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,32 +893,255 @@ bool delete_current_image(void) {
return true;
}
// 更新ui_ImgBle控件的图片
#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
void update_ui_ImgBle(const char *img_name) {
if(!img_name) {
ESP_LOGE("IMG_UI", "图片名为空");
return;
}
if(!ui_ImgBle) {
ESP_LOGE("IMG_UI", "ui_ImgBle控件不存在");
return;
}
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) {
@ -803,37 +1149,104 @@ void update_ui_ImgBle(const char *img_name) {
return;
}
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", "解码数据为空");
#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;
}
// 停止旧 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;
}
// 配置图片数据
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_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;
}
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;
}
// 启动自定义 GIF 播放器Palette LUT + 双缓冲流水线)
gif_player_start();
ESP_LOGI("IMG_UI", "GIF显示启动(优化): %s", img_name);
} 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;
}
// 配置图片数据
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);
} else {
ESP_LOGE("IMG_UI", "图片解码失败,错误码: %d", ret);
ui_img_data = NULL;
}
}
}

View File

@ -104,13 +104,19 @@ 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);
}
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);
@ -196,6 +202,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

@ -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
@ -836,6 +840,7 @@ 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 +1062,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 +1089,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 +1415,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 +1488,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 +2099,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 +2580,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 +2646,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,7 +2816,10 @@ 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
@ -3067,6 +3084,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 +3113,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元左右的模组升级成本可换来用户体验的质变在产品化阶段是值得的投资。