TXW82x 平台相册功能开发文档
项目: TXW82x 相册功能
日期: 2026-06-30
基于提交: 72ce4df (更新相册功能) + eab804e (添加照片选中功能)
技术栈: LVGL 8.x / FatFS (SD卡) / MSI (Media Stream Interface) / 硬件JPEG解码 + CSC硬件
目录
- 功能概述
- 文件变更清单
- 数据结构详解
- MSI 流水线架构
- 核心函数逐分析
- UI 界面布局
- 按键系统与焦点导航
- 全屏预览机制
- CSC 动态格式配置
- JPEG 解码器扩展
- 内存管理策略
- 数据流完整跟踪
- 操作说明
- 内存泄漏修复回顾
一、功能概述
相册功能为 TXW82x 平台提供基于 SD 卡的 JPEG 图片浏览能力。整体包含两大功能模块:
第一版(提交 72ce4df)— 相册功能骨架
- 从 SD 卡
IMG/ 目录扫描 *.jpg 文件
- 缩略图网格展示(自动根据屏幕分辨率计算网格行列数)
- 支持多页翻页(按键、触摸按钮、左右滑动手势)
- 点击缩略图进入全屏预览(使用 VIDEO_P1 硬件层)
- MSI 流水线:
S_LVGL_PHOTO → SR_OTHER_JPG → S_JPG_DECODE → R_CSC_MSI → R_RGB_MSI
- CSC 硬件从固定 RGB565→YUV420P 改造为支持动态格式配置
- JPEG 解码器新增输出尺寸动态配置能力
第二版(提交 eab804e)— 焦点选中增强
- 方向键焦点导航(上/下/左/右移动选中框)
- 红色边框高亮当前聚焦缩略图
- 焦点移至网格边界时自动翻页
- 短按确认键进入预览、长按确认键退出相册
- 预览模式独立按键映射(AD_PRESS → ESC)
- 触摸点击时同步更新焦点状态,保证触摸与按键操作一致性
二、文件变更清单
2.1 新增文件
| # |
文件 |
行数 |
说明 |
| 1 |
sdk/app/ui/album_ui.c |
~800 行 |
相册 UI 全部逻辑(新增) |
2.2 修改文件
| # |
文件 |
提交1变更 |
提交2变更 |
说明 |
| 1 |
sdk/app/video_app/video_app_csc_msi.c |
+168 / -20 行 |
— |
CSC 动态格式支持 |
| 2 |
sdk/app/decode/jpg_decode_msg_msi.c |
+16 行 |
— |
新增 OUT_SIZE / STEP 命令 |
| 3 |
sdk/app/algorithm/stream_frame/stream_define.h |
+3 行 |
— |
新增 R_RGB_MSI 和两个 MSI 命令枚举 |
| 4 |
sdk/include/lib/multimedia/framebuff.h |
+1 行 |
— |
新增 FRAMEBUFF_SOURCE_JPG |
| 5 |
sdk/app/ui/lvgl_ui.h |
+1 行 |
— |
声明 album_ui() |
| 6 |
sdk/app/ui/main_ui.c |
+1 行 |
— |
注册相册入口按钮 |
三、数据结构详解
3.1 struct album_ui_s — 相册状态结构体
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| struct album_ui_s { lv_group_t *last_group; lv_obj_t *base_ui; lv_group_t *now_group; lv_obj_t *now_ui;
struct msi *photo_s; struct msi *other_s; struct msi *decode_s; struct msi *csc_s; struct msi *rgb_s;
struct fbpool tx_pool;
uint32_t cols; uint32_t rows; uint32_t img_per_page; uint32_t img_loaded_cnt; uint32_t cur_page; uint32_t total_files;
lv_obj_t *title; lv_obj_t *page_label; lv_obj_t *img_cont; lv_obj_t *imgs[12];
lv_img_dsc_t *img_dsc[12];
lv_obj_t *preview_obj;
int32_t focus_idx; lv_obj_t *focus_img;
uint8_t csc_need_destroy; };
|
3.2 常量定义
1 2 3 4 5 6
| #define CHECK_DIR "IMG" #define EXT_NAME "*jpg" #define IMG_W 160 #define IMG_H 120 #define IMG_GAP 20 #define ALBUM_MAX_IMG 12
|
3.3 内存分配宏
1 2 3 4 5 6 7 8 9
| #define STREAM_MALLOC av_psram_malloc #define STREAM_FREE av_psram_free #define STREAM_ZALLOC av_psram_zalloc
#define STREAM_LIBC_MALLOC av_malloc #define STREAM_LIBC_FREE av_free #define STREAM_LIBC_ZALLOC av_zalloc
|
设计理由:PSRAM 空间大但速度较慢,适合存放图像帧数据;SRAM 速度快但空间有限,适合存放管理结构体。
四、MSI 流水线架构
4.1 流水线拓扑
1 2 3 4 5 6 7 8 9 10 11 12 13
| ┌──────────────────────────────────────────┐ │ 缩略图流水线 │ │ (album_load_page 时激活) │ │ │ SD卡 ──→ S_LVGL_PHOTO ──→ SR_OTHER_JPG ──→ S_JPG_DECODE ──→ R_CSC_MSI ──→ R_RGB_MSI ──→ LVGL (JPEG) (photo_s) (other_s) (decode_s) (csc_s) (rgb_s) 显示 │ │ │ 预览流水线 │ │ (enter_photo_preview_ui 时激活) │ │ │ SD卡 ──→ S_LVGL_PHOTO ──→ SR_OTHER_JPG ──→ S_JPG_DECODE ──→ R_VIDEO_P1 ──→ LCD 视频层 (JPEG) (photo_s) (other_s) (decode_s) (硬件视频层)
|
4.2 各 MSI 节点详解
photo_s — 图片源 (S_LVGL_PHOTO)
| 属性 |
值 |
| 创建方式 |
msi_new(S_LVGL_PHOTO, 0, NULL) |
| 输出目标 |
SR_OTHER_JPG |
| 回调函数 |
album_photo_msi_action |
| 私有数据 |
指向 struct album_ui_s |
| 帧缓冲池 |
ui_s->tx_pool |
职责:从 SD 卡读取 JPEG 文件,包装为 struct framebuff 后喂入流水线。
album_photo_msi_action 回调处理:
| 命令 |
行为 |
MSI_CMD_PRE_DESTROY |
空操作(预留) |
MSI_CMD_POST_DESTROY |
销毁帧缓冲池 fbpool_destroy |
MSI_CMD_FREE_FB |
释放 fb->data (PSRAM),归还 fb 到池中 |
other_s — JPEG 解码消息配置 (SR_OTHER_JPG)
| 属性 |
值 |
| 创建方式 |
jpg_decode_msg_msi(SR_OTHER_JPG, out_w, out_h, step_w, step_h, filter) |
| 输出目标 |
S_JPG_DECODE |
职责:配置 JPEG 硬件解码参数。通过 MSI 命令动态调整:
1 2 3 4 5 6 7
| msi_do_cmd(other_s, MSI_CMD_DECODE_JPEG_MSG, MSI_JPEG_DECODE_OUT_SIZE, 160 << 16 | 120);
msi_do_cmd(other_s, MSI_CMD_DECODE_JPEG_MSG, MSI_JPEG_DECODE_OUT_SIZE, 320 << 16 | 240);
|
decode_s — JPEG 硬件解码 (S_JPG_DECODE)
| 属性 |
值 |
| 创建方式 |
jpg_decode_msi(S_JPG_DECODE) |
| 输出目标(缩略图) |
R_CSC_MSI |
| 输出目标(预览) |
R_VIDEO_P1 (临时切换) |
csc_s — 色彩空间转换 (R_CSC_MSI)
| 属性 |
值 |
| 创建方式 |
video_app_csc_msi_init() 或 msi_find() 复用已有 |
| 生命周期管理 |
ui_s->csc_need_destroy 标记是否由本模块创建 |
复用逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13
| ui_s->csc_s = msi_find(R_CSC_MSI, 1); if (!ui_s->csc_s) { video_app_csc_msi_init(R_CSC_MSI, CSC_YUV420P, CSC_RGB565, IMG_W, IMG_H); ui_s->csc_s = msi_find(R_CSC_MSI, 1); ui_s->csc_need_destroy = 1; } else { ui_s->csc_need_destroy = 0; }
|
rgb_s — RGB 输出节点 (R_RGB_MSI)
| 属性 |
值 |
| 创建方式 |
msi_new(R_RGB_MSI, 1, NULL) |
| 输入源 |
CSC 的输出连接到本节点 |
职责:作为 CSC 转换后的 RGB 数据消费者,LVGL 从这里 msi_get_fb 获取 RGB565 帧数据显示缩略图。
4.3 流水线的创建与销毁
创建 (album_msi_init → 在 enter_album_ui 中调用):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| album_msi_init(ui_s) ├── msi_new(S_LVGL_PHOTO, 0, NULL) → photo_s │ ├── fbpool_init(&tx_pool, img_per_page) │ ├── photo_s->action = album_photo_msi_action │ └── msi_add_output(photo_s → SR_OTHER_JPG) │ ├── jpg_decode_msg_msi(SR_OTHER_JPG, ...) → other_s │ └── msi_add_output(other_s → S_JPG_DECODE) │ ├── jpg_decode_msi(S_JPG_DECODE) → decode_s │ └── msi_add_output(decode_s → R_CSC_MSI) │ ├── msi_find(R_CSC_MSI) → 检查是否存在 → csc_s │ └── 不存在则 video_app_csc_msi_init() │ └── msi_new(R_RGB_MSI, 1, NULL) → rgb_s └── msi_add_output(NULL → R_CSC_MSI → R_RGB_MSI)
|
销毁 (album_msi_destroy → 在 album_exit 中调用):
1 2 3 4 5 6 7 8 9
| album_msi_destroy(ui_s) ├── msi_del_output(decode_s → R_CSC_MSI) // 清理通路避免影响菜单 ├── msi_del_output(NULL → R_CSC_MSI → R_RGB_MSI) ├── msi_destroy(photo_s) ├── msi_destroy(other_s) ├── msi_destroy(decode_s) ├── msi_destroy(rgb_s) ├── msi_put(csc_s) └── if (csc_need_destroy) msi_destroy(csc_s)
|
五、核心函数逐分析
5.1 album_ui() — 相册入口
1
| lv_obj_t *album_ui(lv_group_t *group, lv_obj_t *base_ui)
|
调用时机:主菜单初始化时调用,main_pocket_camera_ui() 中注册:
1
| btn = album_ui(group, base_ui);
|
函数流程:
1 2 3 4 5 6 7 8 9 10 11
| album_ui(group, base_ui) ├── 参数检查 (group == NULL || base_ui == NULL → return NULL) ├── STREAM_LIBC_ZALLOC(sizeof(struct album_ui_s)) → ui_s (SRAM分配) ├── ui_s->last_group = group // 保存上级按键组 ├── ui_s->base_ui = base_ui // 保存上级界面 ├── lv_list_add_btn(base_ui, NULL, "album") → btn │ └── 失败 → STREAM_LIBC_FREE(ui_s) → return NULL ├── lv_group_add_obj(group, btn) // 按钮加入按键组 ├── lv_obj_add_event_cb(btn, enter_album_ui, LV_EVENT_SHORT_CLICKED, ui_s) │ // 点击按钮 → enter_album_ui └── return btn
|
设计要点:
album_ui 只负责创建入口按钮和分配 ui_s 结构体
- 实际进入相册的耗时操作(MSI 初始化、UI 创建、文件扫描)都延迟到
enter_album_ui 进行
ui_s 一直存活到相册退出,album_exit 中通过 lv_obj_del(now_ui) 间接触发 LVGL 对象释放
- 注意:
ui_s 结构体本身在退出时没有显式释放——这是潜在的内存泄漏,因为 album_exit 没有 STREAM_LIBC_FREE(ui_s)
5.2 enter_album_ui() — 进入相册
1
| static void enter_album_ui(lv_event_t *e)
|
触发条件:用户点击主菜单中的 “album” 按钮。
完整流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| enter_album_ui(e) │ ├── 1. 按键注册 │ set_lvgl_get_key_func(album_self_key) // 接管按键事件 │ lv_obj_add_flag(base_ui, LV_OBJ_FLAG_HIDDEN) // 隐藏菜单 │ ├── 2. 布局计算 │ album_calc_layout(ui_s) │ │ ├── lv_disp_get_hor_res() → 获取屏幕宽度 │ │ ├── lv_disp_get_ver_res() → 获取屏幕高度 (-30px 标题栏) │ │ ├── cols = (avail_w + 20) / (160 + 20) // 计算列数 │ │ ├── rows = (avail_h + 20) / (120 + 20) // 计算行数 │ │ ├── img_per_page = cols × rows (≤ 12) │ │ └── os_printf 打印布局参数 │ │ │ ├── 以 800×480 屏幕为例: │ │ cols = (800 + 20) / 180 = 4 │ │ rows = (450 + 20) / 140 = 3 │ │ img_per_page = 4 × 3 = 12 │ │ │ ├── 3. MSI 流水线初始化 │ album_msi_init(ui_s) │ │ ├── 创建 photo_s (S_LVGL_PHOTO) │ │ ├── 创建 other_s (SR_OTHER_JPG, 参数: 160×120) │ │ ├── 创建/复用 csc_s (R_CSC_MSI, YUV420P→RGB565) │ │ ├── 创建 decode_s (S_JPG_DECODE) │ │ └── 创建 rgb_s (R_RGB_MSI) │ │ │ ├── 4. 创建 UI 界面 │ │ │ ├── 主界面: lv_obj_create(lv_scr_act()) │ │ ├── 白色背景, 无边框, 全屏 │ │ ├── 关闭可滚动, 关闭手势冒泡 │ │ └── ui_s->now_ui = ui │ │ │ ├── 顶部标题栏: lv_obj_create(ui) │ │ ├── 高度 30px, 白色背景 │ │ ├── 左: "Album" 标签 (lv_label, font_28) │ │ ├── 中: 页码标签 ui_s->page_label │ │ ├── 右: ">" 下一页按钮 (lv_btn 40×28) │ │ └── 右二: "<" 上一页按钮 │ │ │ ├── 分割线: lv_obj, 高2px, 黑色 │ │ │ ├── 图片容器: lv_obj_create(ui) │ │ ├── 宽 = cols×160 + (cols-1)×20 + 20 │ │ ├── 高 = rows×120 + (rows-1)×20 + 20 │ │ ├── LV_ALIGN_TOP_MID, y偏移38 (标题栏下方) │ │ ├── Flexbox: LV_FLEX_FLOW_ROW_WRAP (自动换行) │ │ ├── pad_all = 10, pad_gap = 20 │ │ └── ui_s->img_cont │ │ │ ├── 按键组: lv_group_create() │ │ ├── lv_indev_set_group(indev_keypad, group) │ │ ├── lv_group_add_obj(group, ui) │ │ └── ui_s->now_group = group │ │ │ └── 事件注册: │ ├── ui → LV_EVENT_KEY → album_key_handler │ └── now_ui → LV_EVENT_GESTURE → album_gesture_handler │ └── 5. 加载第 1 页 ui_s->cur_page = 1 album_load_page(ui_s)
|
5.3 album_load_page() — 加载当前页面
1
| static void album_load_page(struct album_ui_s *ui_s)
|
完整流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| album_load_page(ui_s) │ ├── 1. 开启 RGB 节点 │ if (ui_s->rgb_s) ui_s->rgb_s->enable = 1 │ ├── 2. 清除焦点状态 │ ui_s->focus_idx = -1 │ ui_s->focus_img = NULL │ ├── 3. 清理旧页面 │ ├── lv_obj_clean(ui_s->img_cont) // LVGL 删除所有 img 对象 │ │ └── 通过 LV_EVENT_DELETE 回调自动释放文件名副本 │ │ │ └── 手动释放 img_dsc 数据 │ for i = 0 to img_loaded_cnt: │ if img_dsc[i]: │ if img_dsc[i]->data: STREAM_FREE(data) // PSRAM │ STREAM_LIBC_FREE(img_dsc[i]) // SRAM │ img_dsc[i] = NULL │ imgs[i] = NULL │ img_loaded_cnt = 0 │ ├── 4. 计算文件范围 │ start = (cur_page - 1) × img_per_page │ end = start + img_per_page │ ├── 5. 单遍扫描 SD 卡 │ f_findfirst(&dir, &finfo, "IMG", "*jpg") │ while (fr == FR_OK && finfo.fname[0] != 0): │ │ if file_idx ≥ start && file_idx < end && loaded < per_page: │ │ │ if album_show(finfo.fname, ui_s) == 0: │ │ │ img_loaded_cnt++ │ │ file_idx++ │ │ f_findnext(&dir, &finfo) │ f_closedir(&dir) │ total_files = file_idx │ │ ★ 设计要点: 单遍扫描,同时计数文件和加载当前页图片 │ 避免了先扫描一遍计数再扫描一遍加载的两遍扫描开销。 │ ├── 6. 更新页码显示 │ total_pages = (file_idx + per_page - 1) / per_page │ sprintf(buf, "%d/%d", cur_page, total_pages) │ lv_label_set_text(page_label, buf) │ ├── 7. 关闭 RGB 节点 │ if (ui_s->rgb_s) ui_s->rgb_s->enable = 0 │ ├── 8. 默认聚焦第一个缩略图 │ if (img_loaded_cnt > 0): │ album_apply_focus(ui_s, 0) │ └── 9. 日志输出 os_printf("album page %d/%d, total files %d, loaded %d\r\n", ...)
|
5.4 album_show() — 显示单个缩略图
1
| static int album_show(const char *filename, struct album_ui_s *ui_s)
|
返回值:0 成功,-1 失败(调用者根据返回值决定是否递增 img_loaded_cnt)。
流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| album_show(filename, ui_s) ├── idx = ui_s->img_loaded_cnt │ ├── lv_img_create(img_cont) → img │ ├── lv_obj_set_size(img, 160, 120) │ ├── 边框: 灰色 #CCCCCC, 1px │ ├── LV_OBJ_FLAG_CLICKABLE // 可点击 │ └── 清除 LV_OBJ_FLAG_SCROLLABLE │ ├── 文件名拷贝 → user_data │ name_copy = STREAM_LIBC_MALLOC(strlen+1) │ strcpy(name_copy, filename) │ lv_obj_set_user_data(img, name_copy) │ lv_obj_add_event_cb(img, album_img_cleanup, LV_EVENT_DELETE, NULL) │ // ★ LV_EVENT_DELETE → img 被删除时自动 free user_data │ ├── 注册点击事件 │ lv_obj_add_event_cb(img, enter_photo_preview_ui, │ LV_EVENT_SHORT_CLICKED, ui_s) │ ├── 创建缩略图 │ dsc = album_thumbnail_create(filename, ui_s, img) │ ├── 成功分支 │ if dsc != NULL: │ │ ui_s->img_dsc[idx] = dsc │ │ ui_s->imgs[idx] = img │ │ return 0 │ └── 失败分支 if dsc == NULL: lv_obj_del(img) // 删除 LVGL 对象 return -1
|
5.5 album_thumbnail_create() — 创建缩略图
1 2 3
| static lv_img_dsc_t *album_thumbnail_create(const char *filename, struct album_ui_s *ui_s, lv_obj_t *img)
|
核心流程 — JPEG 解码 + CSC 转换 → LVGL 图像描述符:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| album_thumbnail_create(filename, ui_s, img) │ ├── 1. 加载 JPEG 文件 │ album_jpeg_file_load(filename, ui_s, FRAMEBUFF_SOURCE_JPG) │ │ ├── osal_fopen("0:IMG/xxx.jpg", "rb") │ │ ├── STREAM_MALLOC(filesize) → photo_buf │ │ ├── osal_fread(photo_buf, filesize) │ │ ├── fbpool_get → data_s (从 tx_pool 取一个 fb) │ │ ├── data_s->data = photo_buf │ │ ├── data_s->mtype = F_JPG │ │ ├── data_s->srcID = FRAMEBUFF_SOURCE_JPG ★ 关键标记 │ │ ├── msi_output_fb(photo_s, data_s) │ │ │ └── JPEG 数据进入流水线: │ │ │ photo_s → other_s → decode_s → csc_s → rgb_s │ │ └── osal_fclose(fp) │ │ │ ├── 2. 获取 RGB 输出 │ │ msi_get_fb(rgb_s, 100) → rgb_fb (超时 100 ticks) │ │ │ │ │ └── if rgb_fb != NULL && rgb_fb->data != NULL: │ │ │ │ │ ├── 3. 创建 LVGL 图像描述符 │ │ │ STREAM_LIBC_MALLOC(sizeof(lv_img_dsc_t)) → dsc │ │ │ dsc->header = { w:160, h:120, cf:TRUE_COLOR } │ │ │ dsc->data_size = 160 × 120 × 2 = 38400 │ │ │ │ │ │ │ ├── STREAM_MALLOC(38400) → dsc->data (PSRAM) │ │ │ │ │ │ │ ├── sys_dcache_invalid_range(rgb_fb->data, 38400) │ │ │ │ // DMA 写入的 buffer 需要刷新 cache │ │ │ │ │ │ │ ├── memcpy(dsc->data, rgb_fb->data, 38400) │ │ │ │ // ★ 深拷贝!避免后续帧冲掉数据 │ │ │ │ │ │ │ └── lv_img_set_src(img, dsc) │ │ │ │ │ └── msi_delete_fb(rgb_s, rgb_fb) // 归还 fb │ │ │ └── return dsc (or NULL on failure)
|
5.6 album_jpeg_file_load() — JPEG 文件加载
1 2
| static int album_jpeg_file_load(const char *filename, struct album_ui_s *ui_s, uint8_t srcID)
|
参数:
filename: 文件名(不含路径)
ui_s: 相册状态
srcID: 帧来源标记 (FRAMEBUFF_SOURCE_JPG)
返回值:0 成功,-1 失败。
详细流程(含错误处理):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| album_jpeg_file_load(filename, ui_s, srcID) │ ├── 构建路径 "0:IMG/xxx.jpg" │ ├── photo_s == NULL → return -1 │ ├── fbpool_get(&tx_pool, 0, photo_s) → data_s │ └── 失败 → return -1 │ ├── data_s->data = NULL │ ├── osal_fopen(path, "rb") → fp │ └── 失败 → msi_delete_fb + return -1 │ ├── osal_fsize(fp) → filesize │ ├── STREAM_MALLOC(filesize) → photo_buf │ └── 失败 → msi_delete_fb + fclose + return -1 │ ├── osal_fread(photo_buf, 1, filesize, fp) │ └── 长度不符 → STREAM_FREE + msi_delete_fb + fclose + return -1 │ ├── 填充 framebuff │ data_s->data = photo_buf // JPEG 文件数据 │ data_s->mtype = F_JPG // 标记为 JPEG 类型 │ data_s->stype = FSTYPE_YUV_P1 // YUV 平面1格式 │ data_s->len = filesize // 数据长度 │ data_s->srcID = srcID // ★ srcID 传递给 CSC 判断逻辑 │ ├── msi_output_fb(photo_s, data_s) // 喂入流水线! │ ├── osal_fclose(fp) └── return 0
|
错误处理覆盖:本函数有 4 个失败路径,每种都正确释放已分配资源。
六、UI 界面布局
6.1 界面结构树
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| lv_scr_act() (活动屏幕) │ └── now_ui (lv_obj, 白色全屏背景) │ ├── top_bar (lv_obj, 高度30px, 标题栏) │ ├── title (lv_label, "Album", font_28, 黑色) │ ├── page_label (lv_label, "1/3", font_28, 黑色) │ ├── prev_btn (lv_btn, 40×28, "<" 符号, 上一页) │ └── next_btn (lv_btn, 40×28, ">" 符号, 下一页) │ ├── line (lv_obj, 高2px, 黑色分割线, y=32) │ └── img_cont (lv_obj, 图片容器, Flexbox 布局) ├── img[0] (lv_img, 160×120) ├── img[1] (lv_img, 160×120) ├── ... └── img[N-1] (lv_img, 160×120)
|
6.2 网格布局计算
公式:
1 2 3 4 5
|
cols = (avail_w + IMG_GAP) / (IMG_W + IMG_GAP); rows = (avail_h + IMG_GAP) / (IMG_H + IMG_GAP); img_per_page = min(cols × rows, ALBUM_MAX_IMG);
|
示例计算结果:
| 屏幕分辨率 |
cols |
rows |
img_per_page |
| 800×480 |
4 |
3 |
12 |
| 480×272 |
2 |
1 |
2 |
| 640×480 |
3 |
3 |
9 |
容器尺寸:
1 2
| cont_w = cols × 160 + (cols - 1) × 20 + 20; cont_h = rows × 120 + (rows - 1) × 20 + 20;
|
例如 4×3 网格:cont_w = 4×160 + 3×20 + 20 = 720, cont_h = 3×120 + 2×20 + 20 = 420
6.3 缩略图外观
| 状态 |
边框颜色 |
边框宽度 |
| 默认(无焦点) |
#CCCCCC (浅灰) |
1px |
| 聚焦选中 |
#FF0000 (红色) |
3px |
七、按键系统与焦点导航
7.1 按键映射
相册模式 (album_self_key)
| 硬件按键 |
事件类型 |
映射值 |
操作 |
| AD_UP |
KEY_EVENT_SUP (短按) |
'u' |
焦点上移 |
| AD_DOWN |
KEY_EVENT_SUP (短按) |
'd' |
焦点下移 |
| AD_LEFT |
KEY_EVENT_SUP (短按) |
'l' |
焦点左移 |
| AD_RIGHT |
KEY_EVENT_SUP (短按) |
'r' |
焦点右移 |
| AD_PRESS |
KEY_EVENT_SUP (短按) |
'e' |
确认 / 进入预览 |
| AD_PRESS |
KEY_EVENT_LDOWN (长按) |
'q' |
退出相册 |
按键映射设计说明:
- 第一版中 AD_LEFT=’q’(退出), AD_UP=’n’(下一页), AD_DOWN=’p’(上一页)
- 第二版重映射为方向键导航,长按 AD_PRESS 替代退出
- 按键码通过
(val & 0xff) 获取事件类型,(val >> 8) 获取按键值
预览模式 (preview_self_key)
| 硬件按键 |
事件类型 |
映射值 |
操作 |
| AD_PRESS |
KEY_EVENT_SUP (短按) |
0x1B (ESC) |
退出预览 |
7.2 焦点导航算法
焦点高亮 (album_apply_focus)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| album_apply_focus(ui_s, idx) ├── 有效性检查 (idx < 0 || idx >= loaded_cnt → return) │ ├── 清除旧焦点高亮 │ if focus_img != NULL: │ border_color = #CCCCCC (灰色) │ border_width = 1 │ └── 设置新焦点高亮 img = imgs[idx] if img != NULL: border_color = #FF0000 (红色) border_width = 3 focus_img = img focus_idx = idx
|
四方向移动 + 自动翻页
上移 (album_focus_up):
1 2 3 4 5 6 7 8
| new_idx = focus_idx - cols
if new_idx >= 0: album_apply_focus(ui_s, new_idx) // 正常上移 else: if cur_page > 1: cur_page-- album_load_page(ui_s) // 已到第一行 → 上翻一页
|
下移 (album_focus_down):
1 2 3 4 5 6 7 8 9
| new_idx = focus_idx + cols
if new_idx < loaded_cnt: album_apply_focus(ui_s, new_idx) // 正常下移 else: total_pages = ceil(total_files / per_page) if cur_page < total_pages: cur_page++ album_load_page(ui_s) // 已到最后一行 → 下翻一页
|
左移 (album_focus_left):
1 2 3 4 5 6 7 8
| new_idx = focus_idx - 1
if new_idx >= 0: album_apply_focus(ui_s, new_idx) // 正常左移 else: if cur_page > 1: cur_page-- album_load_page(ui_s) // 已在第一个 → 上翻一页
|
右移 (album_focus_right):
1 2 3 4 5 6 7 8 9
| new_idx = focus_idx + 1
if new_idx < loaded_cnt: album_apply_focus(ui_s, new_idx) // 正常右移 else: total_pages = ceil(total_files / per_page) if cur_page < total_pages: cur_page++ album_load_page(ui_s) // 已在最后一个 → 下翻一页
|
7.3 按键事件处理 (album_key_handler)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| album_key_handler(e) ├── c = *(int32_t *)lv_event_get_param(e) │ ├── switch(c): │ case 'q': album_exit(ui_s) │ case 'u': album_focus_up(ui_s) │ case 'd': album_focus_down(ui_s) │ case 'l': album_focus_left(ui_s) │ case 'r': album_focus_right(ui_s) │ case 'e': │ if (ui_s->focus_img): │ lv_event_send(focus_img, LV_EVENT_SHORT_CLICKED, NULL) │ │ └── default: break
|
7.4 手势翻页
1 2 3 4 5 6 7 8 9 10
| album_gesture_handler(e) ├── lv_indev_get_act() → indev ├── lv_indev_get_gesture_dir(indev) → dir │ ├── switch(dir): │ case LV_DIR_LEFT: cur_page++; album_load_page() │ case LV_DIR_RIGHT: cur_page--; album_load_page() │ default: break │ └── lv_indev_wait_release(indev)
|
设计要点:now_ui 设置了 LV_OBJ_FLAG_GESTURE_BUBBLE 清除,阻止手势冒泡。img_cont 设置了 LV_OBJ_FLAG_GESTURE_BUBBLE 让手势可以冒泡到 now_ui 处理。top_bar 也设置了 LV_OBJ_FLAG_GESTURE_BUBBLE。
八、全屏预览机制
8.1 进入预览 (enter_photo_preview_ui)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| 触发方式: 1. 触摸点击缩略图 → LV_EVENT_SHORT_CLICKED 2. 按键确认('e') → lv_event_send(focus_img, LV_EVENT_SHORT_CLICKED)
enter_photo_preview_ui(e) │ ├── 0. 同步焦点(触摸时同步更新焦点高亮) │ for i = 0 to loaded_cnt: │ if imgs[i] == img: │ album_apply_focus(ui_s, i) │ break │ ├── 1. 获取文件名 │ filename = (char *)lv_obj_get_user_data(img) │ name_copy = STREAM_LIBC_MALLOC(strlen+1) // 拷贝文件名 │ strcpy(name_copy, filename) │ ├── 2. 按键接管 │ set_lvgl_get_key_func(preview_self_key) // 预览按键映射 │ ├── 3. 创建预览界面 │ preview = lv_obj_create(lv_scr_act()) │ ├── 全屏 (100% × 100%) │ ├── 无边框, 无滚动条 │ ├── LV_EVENT_CLICKED → exit_photo_preview_ui (触摸退出) │ ├── LV_EVENT_KEY → exit_photo_preview_ui (按键退出) │ ├── lv_group_add_obj(now_group, preview) // 加入按键组 │ ├── lv_group_focus_obj(preview) // 聚焦预览对象 │ └── ui_s->preview_obj = preview │ ├── 4. MSI 通路切换 │ │ │ ├── msi_add_output(decode_s → R_VIDEO_P1) // 解码输出→视频层 │ ├── msi_del_output(decode_s → R_CSC_MSI) // 断开→CSC通路 │ │ // ★ 避免预览帧通过 CSC 干扰 P2(菜单)显示 │ │ │ └── msi_cmd(R_VIDEO_P1, MSI_CMD_LCD_VIDEO, MSI_VIDEO_ENABLE, 1) │ // 开启 VIDEO_P1 硬件视频层显示 │ ├── 5. 设置预览分辨率 (320×240) │ msi_do_cmd(other_s, MSI_CMD_DECODE_JPEG_MSG, │ MSI_JPEG_DECODE_OUT_SIZE, 320 << 16 | 240) │ ├── 6. 执行预览 │ album_preview_show(name_copy, ui_s) │ │ └── album_jpeg_file_load(filename, ui_s, FRAMEBUFF_SOURCE_JPG) │ │ └── JPEG → decode_s → R_VIDEO_P1 → LCD 显示 │ └── 7. 释放文件名副本 STREAM_LIBC_FREE(name_copy)
|
8.2 退出预览 (exit_photo_preview_ui)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| 触发方式: 1. 触摸任意位置 → LV_EVENT_CLICKED 2. 按键 AD_PRESS(短按) → 0x1B(ESC) → LV_EVENT_KEY
exit_photo_preview_ui(e) │ ├── 1. 恢复相册按键 │ set_lvgl_get_key_func(album_self_key) │ ├── 2. 删除预览界面 │ lv_obj_del(preview_obj) │ preview_obj = NULL │ ├── 3. 刷新 LVGL 显示 │ lv_refr_now(NULL) │ ├── 4. 关闭 VIDEO_P1 │ msi_cmd(R_VIDEO_P1, MSI_CMD_LCD_VIDEO, MSI_VIDEO_ENABLE, 0) │ ├── 5. 恢复 decode→CSC 通路(恢复菜单显示) │ msi_add_output(decode_s, NULL, R_CSC_MSI) │ ├── 6. 将输出分辨率调回缩略图参数 │ msi_do_cmd(other_s, MSI_CMD_DECODE_JPEG_MSG, │ MSI_JPEG_DECODE_OUT_SIZE, 160 << 16 | 120) │ └── 7. 删除 decode→VIDEO_P1 通路 msi_del_output(decode_s, NULL, R_VIDEO_P1)
|
8.3 预览模式 MSI 通路切换图示
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| ┌───────── 缩略图模式 ─────────┐ │ │ │ decode_s ──→ R_CSC_MSI ──→ rgb_s ──→ LVGL显示 │ │ (连接) │ │ │ │ decode_s R_VIDEO_P1 │ │ (断开) │ └──────────────────────────────┘
┌───────── 预览模式 ───────────┐ │ │ │ decode_s R_CSC_MSI │ │ (断开) │ │ │ │ decode_s ──→ R_VIDEO_P1 ──→ LCD显示 │ │ (连接) │ └──────────────────────────────┘
|
关键设计:解码后的 JPEG 帧在缩略图模式下经过 CSC 转换为 RGB565 供 LVGL 显示,在预览模式下直接输出到 VIDEO_P1 硬件视频层(不经 CSC),实现全屏显示。
九、CSC 动态格式配置
9.1 改造背景
原 video_app_csc_msi.c 固定将输入 RGB565 转换为输出 YUV420P,用于 VIDEO2 通路(LVGL 菜单→CSC→YUV→LCD P2 层)。相册功能需要反向转换(JPEG 解码的 YUV420P → RGB565),因此需要对 CSC 驱动进行泛化改造。
9.2 新增辅助函数
1
| static uint8_t csc_is_rgb_format(uint32_t fmt)
|
支持的 RGB 格式:CSC_BGR565, CSC_RGB565, CSC_BGR888, CSC_RGB888, CSC_RGB888P。
csc_get_convert_type() — 自动推导转换类型
1
| static uint8_t csc_get_convert_type(uint32_t input_fmt, uint32_t output_fmt)
|
| 输入 |
输出 |
返回值 |
含义 |
| YUV |
RGB |
0 |
YUV→RGB |
| RGB |
RGB 或 YUV→YUV |
1 |
同色域 |
| RGB |
YUV |
2 |
RGB→YUV |
csc_get_plane_offsets() — 计算平面偏移
1 2
| static void csc_get_plane_offsets(uint32_t fmt, uint32_t w, uint32_t h, uint32_t *off1, uint32_t *off2)
|
不同的颜色格式有不同的内存布局:
| 格式 |
平面1 (Y/luma) |
平面2 (U/Cb) |
平面3 (V/Cr) |
| RGB565 / YUYV422 / YUV444 (packed) |
基址 + 0 |
0 |
0 |
| YUV420P |
基址 + 0 |
+ w×h |
+ w×h + w×h/4 |
| YUV422P |
基址 + 0 |
+ w×h |
+ w×h + w×h/2 |
| YUV422SP |
基址 + 0 |
+ w×h |
0 (interleaved UV) |
| RGB888P / YUV444P |
基址 + 0 |
+ w×h |
+ w×h×2 |
根据格式自动计算三个平面地址并调用 csc_set_input_addr(dev, y_addr, u_addr, v_addr)。
csc_output_buf_size() — 计算缓冲区大小
1
| static uint32_t csc_output_buf_size(uint32_t w, uint32_t h, uint32_t fmt)
|
| 格式类 |
计算公式 |
| 16-bit packed (RGB565, YUYV422 等) |
w × h × 2 |
| YUV422 planar/semi-planar |
w × h × 2 |
| YUV420P |
w × h × 3 / 2 |
| 24-bit packed (RGB888, YUV444 等) |
w × h × 3 |
| 24-bit planar (RGB888P, YUV444P 等) |
w × h × 3 |
9.3 CSC 工作线程动态分支
video_app_csc_msi_work() 中根据 fb->srcID 分支:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| switch (csc_priv->current_rx_fb->srcID) { case FRAMEBUFF_SOURCE_JPG: in_fmt = CSC_YUV420P; out_fmt = CSC_RGB565; csc_w = 160; csc_h = 120; break;
default: in_fmt = CSC_RGB565; out_fmt = CSC_YUV420P; csc_w = csc_priv->width; csc_h = csc_priv->height; break; }
|
9.4 CSC 初始化改进
改造前(固定 YUV420P 输出缓冲区大小):
1 2
| uint8_t *csc_output_addr = STREAM_MALLOC(width * height * 3 / 2); FBPOOL_SET_INFO(&tx_pool, i, csc_output_addr, width * height * 3 / 2, yuv_msg);
|
改造后(根据输出格式计算):
1 2 3
| uint32_t out_buf_size = csc_output_buf_size(width, height, output_format); uint8_t *csc_output_addr = STREAM_MALLOC(out_buf_size); FBPOOL_SET_INFO(&tx_pool, i, csc_output_addr, out_buf_size, yuv_msg);
|
9.5 MSI_CMD_TRANS_FB 增加对 JPG 源的支持
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| case MSI_CMD_TRANS_FB: { struct framebuff *fb = (struct framebuff *)param1; switch (fb->srcID) { case FRAMEBUFF_SOURCE_CSC: case FRAMEBUFF_SOURCE_JPG: ret = RET_OK; break; default: ret = RET_OK + 1; break; } }
|
十、JPEG 解码器扩展
10.1 新增 MSI 命令
在 jpg_decode_msg_msi.c 中新增两个 MSI 命令处理:
1 2 3 4 5 6 7 8 9 10 11
| case MSI_JPEG_DECODE_OUT_SIZE: decode_msg->out_w = arg >> 16; decode_msg->out_h = arg & 0xffff; break;
case MSI_JPEG_DECODE_STEP: decode_msg->step_w = arg >> 16; decode_msg->step_h = arg & 0xffff; break;
|
10.2 stream_define.h 新增定义
1 2 3 4 5 6 7 8 9 10 11 12
| #define R_RGB_MSI "rgb_msi"
enum MSI_JPEG_DECODE_MSG { MSI_JPEG_DECODE_X_Y, MSI_JPEG_DECODE_FORCE_TYPE, MSI_JPEG_DECODE_MAGIC, MSI_JPEG_DECODE_OUT_SIZE, MSI_JPEG_DECODE_STEP, };
|
10.3 使用方式
1 2 3 4 5 6 7
| msi_do_cmd(other_s, MSI_CMD_DECODE_JPEG_MSG, MSI_JPEG_DECODE_OUT_SIZE, 160 << 16 | 120);
msi_do_cmd(other_s, MSI_CMD_DECODE_JPEG_MSG, MSI_JPEG_DECODE_OUT_SIZE, 320 << 16 | 240);
|
参数编码:宽高编码在单个 uint32_t 中,高16位=宽度,低16位=高度。
十一、内存管理策略
11.1 内存分区
1 2 3 4 5 6
| PSRAM (大容量, 稍慢) SRAM (小容量, 快速) ──────────────────────── ──────────────────────── JPEG 文件数据 (photo_buf) lv_img_dsc_t 结构体 RGB565 像素数据 (dsc->data) 文件名副本 (name_copy) CSC 输出缓冲池 ui_s 结构体 yuv_arg_s 结构体
|
11.2 各资源生命周期
| 资源 |
分配函数 |
分配位置 |
释放时机 |
释放函数 |
ui_s 结构体 |
STREAM_LIBC_ZALLOC |
album_ui() |
退出相册 (album_exit) |
(遗漏) |
| JPEG 文件 buffer |
STREAM_MALLOC |
album_jpeg_file_load() |
MSI FreeFB 回调 |
STREAM_FREE |
| LVGL 图像描述符 |
STREAM_LIBC_MALLOC |
album_thumbnail_create() |
翻页/退出 |
STREAM_LIBC_FREE |
| 描述符内 RGB 数据 |
STREAM_MALLOC |
album_thumbnail_create() |
翻页/退出 |
STREAM_FREE |
| 文件名副本 (user_data) |
STREAM_LIBC_MALLOC |
album_show() |
LV_EVENT_DELETE → album_img_cleanup |
STREAM_LIBC_FREE |
| 预览文件名副本 |
STREAM_LIBC_MALLOC |
enter_photo_preview_ui() |
函数末尾立即释放 |
STREAM_LIBC_FREE |
| CSC tx_pool buffer |
STREAM_MALLOC |
video_app_csc_msi_init() |
MSI POST_DESTROY |
STREAM_FREE |
| CSC yuv_arg_s |
STREAM_LIBC_ZALLOC |
video_app_csc_msi_init() |
MSI POST_DESTROY |
STREAM_LIBC_FREE |
| CSC priv 结构体 |
STREAM_LIBC_ZALLOC |
video_app_csc_msi_init() |
MSI POST_DESTROY |
STREAM_LIBC_FREE |
11.3 关键内存管理设计
1. 深拷贝 RGB 数据
1 2 3
| dsc->data = STREAM_MALLOC(IMG_W * IMG_H * 2); memcpy((void *)dsc->data, rgb_fb->data, IMG_W * IMG_H * 2);
|
- 不从 CSC 输出 buffer 直接引用数据,而是拷贝一份
- 原因:CSC 输出 buffer 会被后续帧覆盖,深拷贝保证缩略图数据持久有效
2. LV_EVENT_DELETE 自动释放
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| lv_obj_add_event_cb(img, album_img_cleanup, LV_EVENT_DELETE, NULL);
static void album_img_cleanup(lv_event_t *e) { lv_obj_t *img = lv_event_get_target(e); void *user_data = lv_obj_get_user_data(img); if (user_data) { lv_obj_set_user_data(img, NULL); STREAM_LIBC_FREE(user_data); } }
|
- 翻页时
lv_obj_clean(img_cont) 批量删除 img 对象
- LVGL 在删除对象时会发送
LV_EVENT_DELETE,触发自动释放
- 防止翻页时文件名泄漏
3. photo_s MSI_CMD_FREE_FB 回调
1 2 3 4 5 6 7 8 9 10 11
| case MSI_CMD_FREE_FB: { struct framebuff *fb = (struct framebuff *)param1; if (fb->data) { STREAM_FREE(fb->data); fb->data = NULL; } fbpool_put(&ui_s->tx_pool, fb); ret = RET_OK + 1; }
|
RET_OK + 1 阻止 MSI 框架层重复释放 fb->data
- fb 归还到 tx_pool 供下次使用
4. 翻页时内存清理顺序
1 2 3 4 5
| lv_obj_clean(img_cont) // 第1步:删除 LVGL 对象(触发 DELETE 回调释放文件名) ↓ 手动释放 img_dsc[] // 第2步:释放图像描述符和数据 ↓ 重置计数器 // 第3步:img_loaded_cnt = 0
|
重要:必须先删除 LVGL 对象再释放 dsc,因为 LVGL 对象还在引用 dsc。如果先释放 dsc 再删除对象,会导致 LVGL 访问已释放的内存。
5. 退出相册时的完整清理
album_exit() 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| lv_indev_set_group(indev_keypad, last_group); lv_group_del(now_group); lv_obj_del(now_ui);
album_msi_destroy(ui_s);
for (i = 0; i < img_loaded_cnt; i++) { if (img_dsc[i]) { if (img_dsc[i]->data) STREAM_FREE(data); STREAM_LIBC_FREE(img_dsc[i]); } }
|
11.4 已知问题
ui_s 结构体泄漏:album_ui() 中分配的 struct album_ui_s 在 album_exit() 中没有释放。虽然整个结构体很小(SRAM 中几十字节),但理论上存在内存泄漏。修复方法:在 album_exit() 末尾增加 STREAM_LIBC_FREE(ui_s)。
十二、数据流完整跟踪
12.1 缩略图加载数据流
以加载 IMG/photo001.jpg 为例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| Step 1: album_load_page() └── album_show("photo001.jpg", ui_s) └── album_thumbnail_create("photo001.jpg", ui_s, img)
Step 2: album_jpeg_file_load("photo001.jpg", ui_s, FRAMEBUFF_SOURCE_JPG) ├── 打开文件: osal_fopen("0:IMG/photo001.jpg", "rb") ├── 获取大小: osal_fsize() → 例如 25600 bytes ├── 分配内存: STREAM_MALLOC(25600) → photo_buf (PSRAM地址 0x6xxxxxxx) ├── 读取文件: osal_fread(photo_buf, 25600) ├── 获取 fb: fbpool_get(&tx_pool, 0, photo_s) → data_s ├── 填充属性: │ data_s->data = photo_buf (0x6xxxxxxx) │ data_s->mtype = F_JPG │ data_s->stype = FSTYPE_YUV_P1 │ data_s->len = 25600 │ data_s->srcID = FRAMEBUFF_SOURCE_JPG │ └── 喂入流水线: msi_output_fb(photo_s, data_s)
Step 3: MSI 流水线处理 (异步) photo_s → SR_OTHER_JPG → S_JPG_DECODE → R_CSC_MSI → R_RGB_MSI │ │ │ │ │ │ │ │ │ └─ rgb_s 收到 RGB565 帧 │ │ │ │ (160×120×2 = 38400 bytes) │ │ │ │ │ │ │ └─ CSC 硬件: YUV420P → RGB565 │ │ │ 输入: JPEG 解码的 YUV420P (160×120×1.5) │ │ │ 输出: RGB565 (160×120×2) │ │ │ │ │ └─ jpg_decode_msi: 硬件解码 JPEG → YUV420P │ │ 输入: JPEG 文件数据 (25600 bytes) │ │ 输出: YUV420P 图像 (160×120×1.5 = 28800 bytes) │ │ │ └─ jpg_decode_msg_msi: 配置解码参数 │ 输出宽高: 160×120 │ 步进宽高: 160×120 │ 过滤器: FSTYPE_YUV_P1 │ └─ photo_s: 数据已送出, 等待 FreeFB 回调释放 photo_buf
Step 4: msi_get_fb(rgb_s, 100) → rgb_fb (等待 RGB 输出) ├── rgb_fb != NULL && rgb_fb->data != NULL │ ├── STREAM_LIBC_MALLOC(sizeof(lv_img_dsc_t)) → dsc (SRAM 0x1xxxxxxx) ├── dsc->header = { w:160, h:120, cf:LV_IMG_CF_TRUE_COLOR } ├── dsc->data_size = 38400 ├── STREAM_MALLOC(38400) → dsc->data (PSRAM 0x6xxxxxxx) │ ├── sys_dcache_invalid_range(rgb_fb->data, 38400) // cache 刷新 ├── memcpy(dsc->data, rgb_fb->data, 38400) // 深拷贝 │ ├── lv_img_set_src(img, dsc) // 设置 LVGL 图像源 │ └── msi_delete_fb(rgb_s, rgb_fb) // 归还 rgb fb
Step 5: 返回 album_show ├── ui_s->img_dsc[idx] = dsc ├── ui_s->imgs[idx] = img └── return 0
Step 6: 异步释放 JPEG buffer MSI 框架调用 album_photo_msi_action(MSI_CMD_FREE_FB) └── STREAM_FREE(photo_buf) // 释放 PSRAM └── fbpool_put(&tx_pool, fb) // 归还 fb
|
12.2 全屏预览数据流
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| enter_photo_preview_ui(e) │ ├── 通路切换 │ decode_s → R_VIDEO_P1 (连接) │ decode_s → R_CSC_MSI (断开) │ VIDEO_P1 ENABLE = 1 │ ├── 分辨率配置: OUT_SIZE = 320×240 │ └── album_preview_show("photo001.jpg", ui_s) │ └── album_jpeg_file_load("photo001.jpg", ui_s, FRAMEBUFF_SOURCE_JPG) │ └── msi_output_fb(photo_s, data_s) // srcID = FRAMEBUFF_SOURCE_JPG │ └── photo_s → other_s → decode_s → R_VIDEO_P1 → LCD (配置320×240) (硬件解码) (视频层显示)
|
12.3 CSC 格式选择流程图
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| msi_get_fb(csc_s) 触发 CSC 工作线程 │ └── csc_priv->current_rx_fb->srcID │ ├── FRAMEBUFF_SOURCE_JPG ──→ in_fmt = YUV420P │ out_fmt = RGB565 │ size = 160×120 │ tx_fb->mtype = F_RGB │ tx_fb->stype = LVGL_RGB │ └── default ──→ in_fmt = RGB565 out_fmt = YUV420P size = csc_priv->width × csc_priv->height tx_fb->mtype = F_YUV tx_fb->stype = FSTYPE_YUV_P0
|
十三、操作说明
13.1 缩略图浏览模式
| 操作方式 |
操作 |
功能 |
| 按键 |
↑ (AD_UP) |
焦点上移;已在第一行则上翻一页 |
| 按键 |
↓ (AD_DOWN) |
焦点下移;已在最后一行则下翻一页 |
| 按键 |
← (AD_LEFT) |
焦点左移;已在第一个则上翻一页 |
| 按键 |
→ (AD_RIGHT) |
焦点右移;已在最后一个则下翻一页 |
| 按键 |
短按确认 (AD_PRESS) |
进入当前聚焦缩略图的预览 |
| 按键 |
长按确认 (AD_PRESS) |
退出相册返回主菜单 |
| 触摸 |
点击缩略图 |
进入该图片预览 |
| 触摸 |
点按 “<” 按钮 |
上一页 |
| 触摸 |
点按 “>” 按钮 |
下一页 |
| 触摸 |
左右滑动 |
左滑→下一页,右滑→上一页 |
13.2 全屏预览模式
| 操作方式 |
操作 |
功能 |
| 按键 |
短按确认 (AD_PRESS) |
退出预览返回缩略图 |
| 触摸 |
点击任意位置 |
退出预览返回缩略图 |
十四、内存泄漏修复回顾
以下记录来自 /memories/repo/album_ui_leaks.md,是相册功能开发过程中的历史修复记录。
14.1 view_photo_ctx 双重释放 / Use-After-Free
- 症状: 多次点击同一缩略图后系统卡死
- 根因:
view_photo() 中释放了 view_photo_ctx,但 img 对象的点击回调仍持有悬空指针。再次点击触发 use-after-free + double-free,堆损坏后 av_free/av_malloc 死锁
- 修复: 移除
view_photo_ctx 结构体。文件名直接挂在 img user_data 上,事件 user_data 传 ui_s
14.2 翻页时文件名泄漏
- 根因:
album_load_page → lv_obj_clean(img_cont) 删除 img 对象时,LVGL 不会自动释放 user_data
- 修复: 注册
LV_EVENT_DELETE 回调 (album_img_cleanup),img 被删除时自动释放文件名副本
14.3 rgb_fb 泄漏
- 根因:
msi_get_fb 返回的 rgb_fb 非 NULL 但 rgb_fb->data 为 NULL 时,跳过了 msi_delete_fb 调用
- 修复: 确保
msi_delete_fb 在任何分支都能被执行到
14.4 缺少防重入保护
- 根因: 快速重复点击 “album” 按钮可能重复创建 MSI pipeline
- 修复: 在
enter_album_ui 开头增加防重入检查 (if (now_ui) return;)
14.5 退出清理不彻底
- 修复:
lv_obj_del(now_ui) 后增加 now_ui = NULL
附录
A. commit 信息
1 2 3 4 5 6 7 8 9 10 11
| commit 72ce4df71923913fe6b60683ce1f61fdbbdb3529 Author: zhongxu <z18568601031@gmail.com> Date: Mon Jun 29 15:54:16 2026 +0800 更新相册功能 Files: 7 files changed, 1000 insertions(+), 20 deletions(-)
commit eab804e36ea504b5cb5760e6b46fb8978108aae6 Author: zhongxu <z18568601031@gmail.com> Date: Mon Jun 29 20:23:14 2026 +0800 添加照片选中功能 Files: 1 file changed, 260 insertions(+), 66 deletions(-)
|
B. 关键文件路径
| 文件 |
路径 |
| 相册 UI |
sdk/app/ui/album_ui.c |
| CSC 驱动 |
sdk/app/video_app/video_app_csc_msi.c |
| JPEG 解码消息 |
sdk/app/decode/jpg_decode_msg_msi.c |
| 流定义 |
sdk/app/algorithm/stream_frame/stream_define.h |
| Framebuff 定义 |
sdk/include/lib/multimedia/framebuff.h |
| LVGL UI 头文件 |
sdk/app/ui/lvgl_ui.h |
| 主菜单 UI |
sdk/app/ui/main_ui.c |