1. 为什么要在LVGL模拟器里播放AVI视频?
你可能已经用LVGL做过不少漂亮的界面了,按钮、图表、动画都玩得很溜。但有没有想过,能不能在你的嵌入式UI界面上,直接播放一段小视频?比如,在产品启动时放个炫酷的Logo动画,或者在设备状态展示时嵌入一段操作指引视频。这个需求听起来很酷,但一上手就容易懵:视频文件怎么读?数据怎么解码?帧率怎么控制才能不卡顿?
别担心,今天我就带你实现在Windows上用LVGL模拟器流畅播放AVI视频。为什么选Windows模拟器?因为它调试起来太方便了!你不用反复烧录嵌入式板子,在电脑上就能快速验证逻辑、调整参数,效率提升不止一倍。等模拟器上跑顺了,再移植到真正的硬件平台,那就是水到渠成的事。
这里我们用的核心“武器”是一个叫 avilib 的轻量级库。它专门用来读写AVI文件,接口简单,特别适合在资源受限的环境里使用。不过有个关键前提:你的AVI视频必须是 MJPG编码 的。简单说,MJPG就是把每一帧画面都压缩成一张独立的JPEG图片,然后把这些图片串成一个视频文件。这样一来,我们读取视频就变成了读取一连串的JPEG图片,而LVGL社区正好有一个强大的 sjpg解码器 来处理JPEG数据。两者结合,完美!
我最初尝试的时候,也踩了不少坑。比如,视频打开后第一帧就闪退,或者播放起来一卡一卡的。经过一番折腾,总算摸清了从文件解析、内存管理到实时渲染的全套流程。这篇文章,我就把这些实战经验、代码细节和避坑指南,毫无保留地分享给你。跟着步骤走,你也能在LVGL的界面上,让视频动起来。
2. 动手前的准备工作:搭建你的“视频播放实验室”
在开始写代码之前,咱们得先把“厨房”收拾好,把“食材”和“工具”备齐。这个过程其实不复杂,但一步都不能错。
2.1 获取核心武器:avilib库
avilib库并不是一个随处可见的标准库,我们需要从开源社区获取它。通常,你可以在一些代码托管平台找到它的源码。我建议直接获取一个已经为Windows环境编译好的静态库文件(比如 avilib.lib)以及对应的头文件(avilib.h)。这样最省事。
如果你拿到的是源码,也别慌。它通常就是一个 C 文件和一个头文件。你需要在你的IDE(比如VS Code、Visual Studio)或者使用MinGW的gcc,把它编译成静态库。对于新手,我强烈建议直接寻找预编译好的库文件,把精力集中在集成和应用上。
关键一步: 将 avilib.h 头文件放到你的项目头文件搜索路径下,将 avilib.lib 静态库文件放到你的项目库文件搜索路径下。并在你的工程设置里,添加对这个库的链接。以Visual Studio为例,你需要在项目属性 -> 链接器 -> 输入 -> 附加依赖项里,加上 avilib.lib。
2.2 准备“食材”:制作MJPG编码的AVI视频
avilib库只认MJPG编码的AVI,所以你的视频源必须符合这个格式。如果你手头的视频是MP4、MOV或者其他格式,就需要进行转码。
这里推荐一个免费又强大的工具:FFmpeg。它是处理音视频的瑞士军刀。安装好FFmpeg后,打开命令行,用下面这条命令就能完成转换:
ffmpeg -i input.mp4 -c:v mjpeg -q:v 2 -an output.avi
我来解释一下这几个参数:
-i input.mp4:指定你的输入视频文件。-c:v mjpeg:这是关键!告诉FFmpeg使用MJPG编码视频流。-q:v 2:设置视频质量,范围是2-31,数字越小质量越高(文件也越大)。通常2-5之间画质就非常不错了。-an:去掉音频流。因为我们目前只关心画面播放,而且LVGL模拟环境处理音频会复杂很多。output.avi:输出的目标文件名。
转换完成后,你可以把生成的 output.avi 文件放到你的LVGL模拟器项目目录里,比如一个叫 avi 的文件夹中,方便代码里直接读取。
2.3 配置LVGL模拟器环境
假设你已经有一个能在Windows上运行的LVGL PC模拟器项目了(比如基于SDL的)。如果没有,可以去LVGL官方GitHub仓库下载 lv_port_pc_eclipse 或 lv_port_visual_studio 等模拟器项目模板,它们已经配置好了基础的绘图和输入驱动。
你需要确保这个模拟器项目已经启用了 sjpg 解码器支持。因为MJPG视频帧就是JPEG图片,LVGL需要sjpg库来解码它们。检查你的 lv_conf.h 配置文件,确保以下宏定义是开启的:
#define LV_USE_SJPG 1
同时,确认LVGL的图片缓存、定时器等基础功能都是打开的。这些是视频流畅播放的基石。
3. 庖丁解牛:深入理解avilib的核心函数
工欲善其事,必先利其器。要想用好avilib,必须彻底搞明白它提供的几个关键函数。别怕,它们比看起来要简单。
3.1 打开与关闭:AVI_open_input_file 和 AVI_close
播放视频的第一步,当然是打开文件。AVI_open_input_file 函数就是干这个的。
avi_t *AVI_open_input_file(const char *filename, int getIndex);
filename:视频文件的路径字符串,比如"./avi/my_video.avi"。getIndex:这个参数通常设为1,让库去读取文件的索引信息,这样后续随机定位帧会更快。- 返回值:一个指向
avi_t结构体的指针。这个指针非常重要!它代表了这次“视频播放会话”的句柄,之后所有的操作——读帧、跳转、获取信息——都要用到它。如果打开失败,它会返回NULL,所以一定要检查返回值。
当你播放完毕或者中途想退出时,必须用 AVI_close 函数来关闭文件,释放资源。这是一个好习惯,能避免内存泄漏。
void AVI_close(avi_t *AVI);
3.2 获取视频“身份证”:AVI_video_frames 和 AVI_frame_rate
打开视频后,我们得先了解一下它的基本信息,就像看一本书要先看目录和厚度。
long AVI_video_frames(avi_t *AVI):这个函数直接告诉你这个视频总共有多少帧。对于MJPG AVI来说,这就是JPEG图片的张数。它是我们控制播放循环的核心依据。double AVI_frame_rate(avi_t *AVI):它返回视频的帧率,单位是FPS(帧每秒)。比如返回25.0,就表示每秒播放25帧。这个值决定了我们定时器刷新的间隔。理想情况下,定时器周期(毫秒)= 1000 / 帧率。
3.3 精准操控:AVI_set_video_position 和 AVI_read_frame
这是播放控制最核心的两个函数,一个负责“定位”,一个负责“读取”。
定位到某一帧:
int AVI_set_video_position(avi_t *AVI, long frame);
frame:你想跳转到的帧的索引号,从0开始计数。比如想从第15帧开始播放,这里就传15。- 返回值:0表示成功,非0表示出错(比如帧号超出了总范围)。这个函数调用后,库内部的一个“读指针”就指向了你指定的那一帧,下一次
AVI_read_frame就会从这里读起。
读取当前帧的数据:
long AVI_read_frame(avi_t *AVI, char *vidbuf, int *keyframe);
vidbuf:这是一个由你提供的缓冲区指针,用来存放读取出来的JPEG图片数据。这里有个大坑要注意:缓冲区必须足够大! 你需要根据视频分辨率提前估算一帧可能的最大大小。一个640x480的MJPG帧,可能就需要几十KB。保险起见,可以分配100KB甚至更大的空间。keyframe:这是一个输出参数。函数执行后,它会告诉你读到的这一帧是不是关键帧(I帧)。对于MJPG编码,理论上每一帧都是关键帧(因为都是独立压缩的JPEG),所以这个参数在我们这个场景下用处不大,但接口要求我们传一个指针进去。- 返回值:返回实际读取到的这一帧数据的字节数。这个值非常重要!它告诉我们
vidbuf里前多少个字节是有效的图片数据。我们后续要把这个大小告诉LVGL。
4. 实战代码拆解:让视频在LVGL里动起来
理论说了一堆,现在来看真刀真枪的代码。我会把关键部分拆开,一句一句讲清楚。
4.1 全局变量与状态管理
首先,我们需要一些全局变量来记录播放状态,这比把所有东西都传来传去要清晰。
// 全局状态变量
static lv_img_dsc_t g_img_desc; // LVGL图片描述符,核心!
lv_obj_t * g_img_obj; // 用于显示图片的LVGL对象
int g_avi_opened = 0; // 标记AVI文件是否已打开
char g_frame_buffer[150 * 1024]; // 帧缓冲区,我分配了150KB,应对大部分情况
int g_current_frame_index = 0; // 当前播放到的帧索引
int g_total_frames = 0; // 视频总帧数
double g_frame_rate = 0; // 视频帧率
avi_t * g_avi_handler = NULL; // avilib的文件操作句柄
重点说说 lv_img_dsc_t g_img_desc。这个结构体是LVGL用来描述一张图片的“身份证”。我们需要在播放过程中,不断更新它的 data(指向图片数据的指针)和 data_size(数据大小),然后告诉LVGL图像对象去刷新显示。这是一种动态更新图片内容的标准方法。
4.2 初始化与打开视频文件
我们用一个初始化函数来打开视频并设置好一切。
void lv_avi_player_init(const char* file_path) {
// 1. 尝试打开AVI文件
g_avi_handler = AVI_open_input_file(file_path, 1);
if(g_avi_handler == NULL) {
LV_LOG_ERROR("Failed to open AVI file: %s", file_path);
return;
}
// 2. 获取视频基本信息
g_total_frames = AVI_video_frames(g_avi_handler);
g_frame_rate = AVI_frame_rate(g_avi_handler);
int width = (int)(g_avi_handler->width);
int height = (int)(g_avi_handler->height);
LV_LOG_USER("AVI Info: %dx%d, Frames:%d, FPS:%.2f", width, height, g_total_frames, g_frame_rate);
// 3. 初始化LVGL图片描述符
g_img_desc.header.always_zero = 0;
g_img_desc.header.reserved = 0;
g_img_desc.header.cf = LV_IMG_CF_RAW; // 注意:这里先用RAW格式
g_img_desc.header.w = width;
g_img_desc.header.h = height;
g_img_desc.data_size = 0; // 初始为空
g_img_desc.data = NULL;
// 4. 创建一个LVGL图像对象来显示视频帧
g_img_obj = lv_img_create(lv_scr_act());
lv_obj_align(g_img_obj, LV_ALIGN_CENTER, 0, 0);
// 先设置一个空源,后续在定时器里更新
lv_img_set_src(g_img_obj, &g_img_desc);
// 5. 设置起始帧(避开可能的初始问题)
g_current_frame_index = 10; // 为什么从10开始?后面会讲这个“坑”
if(AVI_set_video_position(g_avi_handler, g_current_frame_index) != 0) {
LV_LOG_WARN("Failed to set initial video position.");
// 如果设置失败,回退到第0帧试试
g_current_frame_index = 0;
AVI_set_video_position(g_avi_handler, g_current_frame_index);
}
// 6. 预先读取第一帧,让画面有内容
int keyframe_ignored;
int frame_size = AVI_read_frame(g_avi_handler, g_frame_buffer, &keyframe_ignored);
if(frame_size > 0 && frame_size <= sizeof(g_frame_buffer)) {
g_img_desc.data_size = frame_size;
g_img_desc.data = (const uint8_t*)g_frame_buffer;
lv_img_set_src(g_img_obj, &g_img_desc); // 更新显示
lv_refr_now(NULL); // 强制立即刷新一次屏幕
}
g_avi_opened = 1; // 标记为已打开
LV_LOG_USER("AVI player initialized successfully.");
}
几个关键点解释:
LV_IMG_CF_RAW:为什么图片颜色格式先设为RAW?因为MJPG数据是压缩过的JPEG流,它不是RGB像素数组。LVGL的sjpg解码器认识这种“原始”的JPEG数据流,并会自动识别和解码它。这是一种特殊的用法。- 起始帧设为10:这是我在实战中遇到的一个诡异问题。直接从第0帧开始读取并显示,有时会导致模拟器闪退或卡死。但跳过前面几帧(比如10帧)就一切正常。我怀疑某些AVI文件开头的元数据或第一帧编码有些特殊,avilib库或sjpg解码器在处理时可能有不兼容的情况。这算是一个经验性的避坑技巧。
- 预读第一帧:在启动定时器前,先手动读取并显示一帧。这样界面一上来就有画面,而不是黑屏等待定时器触发,体验更好。
4.3 核心引擎:驱动播放的定时器回调函数
视频播放的本质,就是在固定的时间间隔(根据帧率计算)里,读取下一帧数据并更新屏幕。LVGL的定时器 lv_timer 完美胜任这个工作。
static void avi_playback_timer_cb(lv_timer_t * timer) {
// timer->user_data 可以用于传递参数,这里我们直接用全局变量
if(!g_avi_opened || g_avi_handler == NULL) {
lv_timer_del(timer); // 如果视频没开或出错了,就停止定时器
return;
}
// 1. 检查是否播放完了
if(g_current_frame_index >= g_total_frames - 1) {
LV_LOG_USER("Playback finished.");
AVI_close(g_avi_handler);
g_avi_handler = NULL;
g_avi_opened = 0;
lv_timer_del(timer);
// 这里可以添加播放完成后的逻辑,比如隐藏播放器、显示提示等
return;
}
// 2. 读取下一帧数据
int keyframe_ignored;
int frame_size = AVI_read_frame(g_avi_handler, g_frame_buffer, &keyframe_ignored);
// 3. 检查读取是否有效
if(frame_size <= 0) {
LV_LOG_WARN("Failed to read frame at index %d", g_current_frame_index);
} else if(frame_size > sizeof(g_frame_buffer)) {
LV_LOG_ERROR("Frame size (%d) exceeds buffer size! Buffer overflow risk!", frame_size);
// 发生这种情况,说明缓冲区分配小了,需要增大g_frame_buffer
} else {
// 4. 更新LVGL图片描述符
g_img_desc.data_size = frame_size;
g_img_desc.data = (const uint8_t*)g_frame_buffer; // 指向新数据
// 5. 通知LVGL图像对象更新显示
// 注意:我们直接更新了g_img_desc,但需要重新设置源来触发刷新
lv_img_set_src(g_img_obj, &g_img_desc);
// 实际上,由于g_img_desc的地址没变,只是内部数据指针变了,
// 在某些情况下LVGL可能不会自动检测到变化。更稳妥的方法是:
// lv_img_invalidate(g_img_obj); // 标记对象为需要重绘
// 但lv_img_set_src通常会触发重绘,所以这里先这样用。
}
// 6. 帧索引递增,准备下一次读取
// 注意:AVI_read_frame调用后,avilib内部的读指针已经自动指向下一帧了。
// 所以我们不需要再调用AVI_set_video_position,除非要跳转。
g_current_frame_index++;
// 调试信息,可以关闭
// LV_LOG_USER("Frame: %d/%d, Size: %d bytes", g_current_frame_index, g_total_frames, frame_size);
}
这里有一个非常重要的细节: AVI_read_frame 函数在成功读取一帧后,会自动将内部的位置指针移动到下一帧。这意味着在顺序播放时,我们不需要在每次循环里都调用 AVI_set_video_position。这大大简化了代码逻辑。只有当我们想实现“快进”、“跳转”功能时,才需要用到 AVI_set_video_position。
4.4 组装与启动:创建定时器,开始播放
初始化完成后,我们根据视频的帧率来创建一个LVGL定时器,让它驱动播放流程。
void start_avi_playback(void) {
if(!g_avi_opened) {
LV_LOG_ERROR("AVI not opened. Call init first.");
return;
}
// 计算定时器周期(毫秒)。1000毫秒 / 帧率。
// 加0.5是为了四舍五入,避免精度损失。
uint32_t timer_period_ms = (uint32_t)(1000.0 / g_frame_rate + 0.5);
// 创建定时器,回调函数就是我们上面写的avi_playback_timer_cb
lv_timer_t * playback_timer = lv_timer_create(avi_playback_timer_cb, timer_period_ms, NULL);
// 设置定时器重复次数。如果想让视频循环播放,可以设为LV_TIMER_REPEAT_INFINITE。
// 这里我们播放一次就停止,次数设为总帧数(减去已播放的起始帧)。
int frames_to_play = g_total_frames - g_current_frame_index;
lv_timer_set_repeat_count(playback_timer, frames_to_play);
LV_LOG_USER("Playback started. Timer period: %d ms, Frames to play: %d", timer_period_ms, frames_to_play);
}
把上面所有函数组合起来,然后在你的主程序里(比如某个按钮的事件回调里)按顺序调用:
lv_avi_player_init("./avi/demo.avi");start_avi_playback();
理论上,你的LVGL模拟器窗口里,视频就应该开始流畅播放了!
5. 性能优化与避坑指南:从“能播”到“流畅播”
代码跑起来只是第一步。在实际项目中,我们还得追求流畅、稳定、低占用。下面是我踩过坑后总结的几个优化要点。
5.1 内存与缓冲区管理:防止卡顿与崩溃
视频播放是数据密集型的操作,内存管理不好,分分钟卡死或崩溃。
- 缓冲区大小是命门:
g_frame_buffer的大小必须足够容纳最大的一帧。估算公式可以很简单:宽度 * 高度 * 3(RGB)* 压缩率。对于MJPG,压缩率可能从几到几十不等。一个保险的做法是先用工具(如FFmpeg)分析一下你的视频文件,看看它的最大帧大小是多少。或者,更粗暴有效的方法是:直接分配一个足够大的空间,比如300KB或500KB。在PC模拟器上,内存不是大问题,宁可多分一点,求个稳定。 - 避免频繁内存分配:绝对不要在定时器回调函数里动态分配内存(如
malloc)来存放帧数据!这会导致内存碎片和不可预测的延迟。一定要像我们上面做的那样,使用预先分配好的全局或静态缓冲区。 - 双缓冲甚至三缓冲:如果你的播放有轻微卡顿,可以尝试双缓冲。即准备两个缓冲区A和B。当定时器触发时,如果A正在被LVGL解码/渲染,那么就把下一帧数据读到B里,然后交换指针。这能有效避免读写冲突,提升流畅度。不过对于LVGL+sjpg这种流程,通常单缓冲顺序处理也够用,可以先试试看。
5.2 帧率控制与同步:让播放速度“刚刚好”
用定时器控制帧率看似简单,但实际可能因为系统调度、解码耗时等原因产生漂移。
- 理论周期与实际误差:我们根据
1000/fps计算定时器周期。如果fps是25,周期就是40ms。但LVGL定时器不是硬实时,如果系统繁忙,回调可能被延迟几毫秒。长此以往,播放就会变慢。 - 简单的补偿策略:可以在定时器回调函数里记录一个“预期播放时间”。每次回调,不仅更新画面,还计算一下实际时间与预期时间的差值。如果发现落后了(比如累计慢了100ms),可以在下一帧“丢帧”,即连续调用两次
AVI_read_frame但只显示后面一帧,快速追上进度。对于嵌入式UI播放,偶尔丢一帧的视觉影响不大,但能保证整体节奏。 - 使用更精准的时钟:在Windows上,可以使用
QueryPerformanceCounter等高精度时钟来驱动播放循环,而不是完全依赖LVGL定时器。但这需要把播放逻辑放到一个独立的线程或更底层的驱动里,复杂度较高。作为入门,先用LVGL定时器是没问题的。
5.3 解码与渲染优化:减轻CPU负担
解码JPEG和LVGL渲染都是CPU操作,优化它们能降低系统负载。
- 利用LVGL的缓存机制:LVGL的图片对象有缓存功能。但对于我们这种每秒变化几十次的动态视频帧,缓存意义不大,因为每一帧都是新数据。确保
lv_img_set_src调用后,旧的图片数据内存不会被错误释放即可(我们用的是静态缓冲区,所以没问题)。 - 检查sjpg解码性能:LVGL的sjpg解码是纯软件实现。在PC上播放一个稍大分辨率(比如800x480)的视频,可能会占用不少CPU。你可以打开任务管理器看看CPU使用率。如果过高,可以考虑:
- 降低视频分辨率。这是最有效的方法。
- 降低视频帧率。比如从30fps降到20fps。
- 如果LVGL版本支持,查看是否有启用SIMD指令加速的sjpg解码选项。
5.4 常见问题排查(FAQ)
- 打开文件失败:检查文件路径是否正确(相对路径还是绝对路径)。确认文件确实是MJPG编码的AVI。可以用FFmpeg命令
ffprobe your_video.avi查看编码信息。 - 播放时画面撕裂或错位:这通常是缓冲区大小不足,导致
AVI_read_frame读入了不完整的数据,sjpg解码器无法识别。请务必确保g_frame_buffer数组大小远大于实际帧大小。可以在读取后打印frame_size,观察其最大值。 - 只有第一帧显示,后面不动:首先检查定时器是否成功创建并运行。在定时器回调函数开头加个打印,看是否被周期性调用。其次,检查
g_current_frame_index和g_total_frames的值,看是否因为索引错误提前结束了播放。最后,检查AVI_read_frame的返回值,看是否读取失败。 - 播放速度明显过快或过慢:核对
g_frame_rate的值是否正确。检查定时器周期计算是否正确。在定时器回调里打印实际执行的时间间隔,看看是不是因为其他任务阻塞导致定时器不准确。 - 初始帧闪退问题:这就是我前面提到的,从第0帧开始可能有问题。这几乎是这个方案的一个“特性”。我的解决方案就是简单粗暴地从第10帧开始播放。如果你发现从第0帧开始没问题,那当然更好。如果不行,尝试跳过前几帧(5, 10, 15)试试。这背后的具体原因我还没深究,可能与特定AVI文件的封装格式或初始帧头信息有关。
6. 功能扩展:不止于基础播放
掌握了基础播放后,你可以基于这个框架,轻松实现更酷的功能:
- 播放控制:在界面上添加“暂停/继续”按钮。暂停很简单,在定时器回调里加一个状态判断,如果暂停就直接
return,不读取新帧。继续就是恢复定时器运行。注意,暂停时最好lv_timer_pause定时器,节省CPU。 - 进度条与跳转:添加一个Slider(滑块)控件,绑定到
g_current_frame_index。拖动滑块时,调用AVI_set_video_position跳转到指定帧,并立即读取和显示该帧画面。这需要处理好滑块事件和当前播放状态的同步。 - 多视频切换:维护一个视频文件列表和对应的
avi_t句柄。播放完一个或用户切换时,先AVI_close当前文件,再AVI_open_input_file新的文件,重新初始化播放状态即可。 - 叠加UI元素:LVGL的强大之处在于,视频播放层(
lv_img对象)只是一个普通的对象。你可以在它上面创建标签、按钮、图表等其他控件,实现视频与UI交互的融合。只需要注意对象的层级(Z-order),确保视频在底层,UI控件在上层。
整个工程源码的结构应该是清晰的:一个核心的C文件(比如 lvgl_avi_player.c)包含上述所有函数和全局变量,一个对应的头文件声明外部接口,然后在你的主文件中调用初始化函数并触发播放。通过这样的模块化设计,你可以很方便地把这个播放器功能集成到任何LVGL项目中去,无论是在Windows模拟器上快速原型开发,还是在最终的嵌入式硬件上部署。
350



被折叠的 条评论
为什么被折叠?



