news 2026/10/15 16:28:53

Qt+FFmpeg视频播放器:解码渲染同步全链路实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Qt+FFmpeg视频播放器:解码渲染同步全链路实现

简介:本资源是一套基于Qt与FFmpeg深度集成的跨平台视频播放器完整实现方案,面向具备C++基础的Qt开发者及音视频初学者,解决Qt原生多媒体模块对H.265、MKV等非标格式支持不足的核心痛点。压缩包共270个文件,包含180个头文件(h)用于接口定义与类声明、22个静态库(lib)和16个导入库(a/def)支撑FFmpeg模块链接、18个动态链接库(dll)实现解码与渲染核心功能,以及UI界面(ui)、资源文件(qrc/ico/png)、示例视频(mp4)和工程配置(pro)等,整体34.26MB,结构清晰、开箱即用。已有1071人学习下载,提供从FFmpeg编译适配、自定义QAbstractMediaService服务、YUV帧渲染到QVideoWidget、滑动条精准跳转、音量控制与元数据解析等全链路代码实现,含大量注释与关键API调用范例,可直接编译运行并作为二次开发基线。

1. Qt + FFmpeg 实现视频播放器:不是调个库就完事,而是亲手把解码、渲染、同步三座大山一并扛起来

你是不是也试过在 Qt 里拖一个QVideoWidget,接上QMediaPlayer,跑通一个 MP4 就以为“视频播放器”做完了?结果一换 H.265 流就卡死,一加音视频同步就跳帧,一上自定义滤镜就崩溃——这不是你代码写得差,是 Qt 原生多媒体栈根本没给你留出底层控制权。这个 Qt + FFmpeg 实现的视频播放器项目,就是为解决这个问题而生:它不封装、不抽象、不隐藏,从 AVPacket 拆包开始,到 OpenGL 纹理上传结束,全程暴露关键路径。它适合三类人:想搞音视频底层开发的 Qt 工程师、需要嵌入式/跨平台硬解能力的工业客户端开发者、以及被QMediaPlaylist循环 bug 折磨到重装 Qt 的实战派。项目已实测支持 Windows x64 / Ubuntu 22.04 / macOS 13(ARM64),能硬解 H.264/H.265(Intel QSV / NVIDIA NVDEC / Apple VideoToolbox),软解全格式无压力,且所有同步逻辑可打断、可插桩、可单步调试——这才是真正能进产线的播放器底座。


2. 为什么非要用 FFmpeg 而不是 Qt Multimedia:解码器粒度、时钟精度与线程模型的三重不可妥协

Qt Multimedia 模块表面简洁,但其内部设计决定了它无法满足真实工业场景的三个硬性要求:解码器选择不可控、音视频时钟抖动超 ±80ms、多路并发时线程调度黑盒化。本项目弃用QMediaPlayer,转而直连 FFmpeg C API,不是为了炫技,而是因为——在某跨平台系统中,客户要求同一台工控机同时解码 4 路 1080p@30fps RTSP 流并做帧级 AI 推理,Qt 原生方案在第三路接入时就触发QAudioOutput缓冲溢出崩溃;而本项目通过分离AVCodecContext实例 + 独立解码线程池,稳定运行超 72 小时无丢帧。下面拆解三个不可替代的技术锚点。

2.1 解码器绑定必须精确到AVCodecID与硬件加速类型

Qt 不允许你指定AV_CODEC_ID_H265用AV_HWDEVICE_TYPE_QSV还是AV_HWDEVICE_TYPE_CUDA,而 FFmpeg 可以。关键在于初始化阶段的显式绑定:

// 初始化硬件设备上下文(以 QSV 为例) AVBufferRef* hw_device_ctx = nullptr; av_hwdevice_ctx_create(&hw_device_ctx, AV_HWDEVICE_TYPE_QSV, "auto", nullptr, 0); if (!hw_device_ctx) { qWarning() << "Failed to create QSV device context"; return false; } // 绑定到解码器上下文 codec_ctx->hw_device_ctx = av_buffer_ref(hw_device_ctx);

提示:"auto"参数会自动探测 Intel GPU,若需指定设备索引(如双显卡环境),应传入"device=1"。av_hwdevice_ctx_create返回失败时,不要 fallback 到软解——必须明确报错并终止,否则后续avcodec_send_packet会静默失败。

2.2 音视频同步必须绕过 Qt 事件循环,采用 PTS/DTS 手动对齐

Qt 的QMediaObject::positionChanged信号基于后端缓冲估算,误差随播放时长累积。本项目采用「视频主时钟 + 音频差值补偿」双轨策略:以视频帧 PTS 为基准时钟,音频解码后计算其 PTS 与视频 PTS 的差值 Δt,再通过swr_convert动态增删采样点实现毫秒级对齐。核心逻辑在AudioResampler::resampleWithCompensation()中:

// 计算当前音频帧应匹配的视频时间戳(单位:微秒) int64_t video_pts_us = m_videoClock->currentPTS() * 1000; int64_t audio_pts_us = (m_audioFrame->pts * 1000000) / m_audioStream->time_base.den; int64_t diff_us = audio_pts_us - video_pts_us; if (llabs(diff_us) > 20000) { // 超过 20ms 强制补偿 int compensation_samples = static_cast<int>( (diff_us / 1000.0) * m_audioCodecCtx->sample_rate / 1000.0 ); // 插入或裁剪 samples,详见 swr_set_compensation() }

参数说明:diff_us是音画偏差,单位微秒;compensation_samples是需动态调整的采样点数。该值必须严格按sample_rate × Δt(ms) / 1000计算,不能四舍五入取整——浮点误差累积会导致 10 分钟后音画偏移达 1.2 秒。

2.3 多实例隔离必须靠AVFormatContext独立生命周期管理

Qt 的QMediaContent共享底层资源,导致 4 路 RTSP 同时播放时,一路断开会触发全局QMediaService重置。本项目为每路流创建独立AVFormatContext,并在析构函数中严格按逆序释放:

PlayerCore::~PlayerCore() { // 1. 停止所有线程(解码/渲染/同步) m_decodeThread.quit(); m_decodeThread.wait(); // 2. 释放解码器上下文(必须在 format ctx 之前!) if (m_videoCodecCtx) { avcodec_free_context(&m_videoCodecCtx); // 内部会 avcodec_close } if (m_audioCodecCtx) { avcodec_free_context(&m_audioCodecCtx); } // 3. 最后释放格式上下文(含 IO buffer) if (m_formatCtx) { avformat_close_input(&m_formatCtx); // 自动 avio_close } // 4. 清理硬件设备(必须最后!因 codec ctx 引用它) if (m_hwDeviceCtx) { av_buffer_unref(&m_hwDeviceCtx); } }

注意:avformat_close_input()会自动调用avio_close(),无需手动调avio_close_context();若提前调用,会导致AVFormatContext->pb成为野指针,av_read_frame()后续崩溃。


3. 核心模块拆解:从 AVPacket 解包到 OpenGL 纹理更新的七步链路

整个播放流程不是黑匣子,而是可打断、可注入、可单步的七段式流水线。每一环节都对应一个可独立测试的类,且全部通过 RAII 管理资源。下面以视频解码主线为例,逐段还原真实执行路径。

3.1 步骤一:IO 层封装 —— 自定义AVIOContext支持内存/网络/RTSP 混合源

Qt 不提供底层 IO 替换接口,而 FFmpeg 允许你完全接管数据读取。本项目实现CustomIOContext类,将QByteArray、QTcpSocket或librtsp回调无缝注入:

// 自定义读回调(RTSP 场景下从 socket recv) static int io_read_callback(void* opaque, uint8_t* buf, int buf_size) { auto* io = static_cast<CustomIOContext*>(opaque); qint64 ret = io->socket->read((char*)buf, buf_size); return (ret < 0) ? AVERROR(EIO) : static_cast<int>(ret); } // 构建 AVIOContext uint8_t* io_buffer = (uint8_t*)av_malloc(32768); AVIOContext* avio = avio_open_dyn_buf(&io_buffer); // ... 设置回调后,传给 avformat_open_input

逻辑说明:avio_open_dyn_buf创建内存 buffer,io_read_callback在av_read_frame()内部被调用。关键点在于opaque参数必须指向有效对象生命周期——若CustomIOContext在AVFormatContext存活期间析构,回调将访问野指针。

3.2 步骤二:格式探测 ——avformat_find_stream_info()的超时与容错

avformat_find_stream_info()是阻塞操作,网络流可能卡死。本项目添加 5 秒超时机制,并允许跳过部分流分析:

AVDictionary* opts = nullptr; av_dict_set(&opts, "analyzeduration", "5000000", 0); // 5s in microseconds av_dict_set(&opts, "probesize", "32768", 0); // 减小探测数据量 int ret = avformat_find_stream_info(m_formatCtx, &opts); if (ret < 0) { qWarning() << "Stream info probe failed, trying with reduced params"; av_dict_set(&opts, "probesize", "8192", 0); avformat_find_stream_info(m_formatCtx, &opts); }

参数说明:analyzeduration单位为微秒,设为5000000即 5 秒;probesize是探测最大字节数,不能低于 4096,否则 H.265 SPS/PPS 可能截断,导致avcodec_open2()失败。

3.3 步骤三:解码线程 ——AVPacket → AVFrame的零拷贝流转

解码不走 Qt 信号槽,而用QWaitCondition+QMutex实现生产者-消费者模型。关键优化是复用AVFrame:

// 预分配 16 个 AVFrame(覆盖典型 B-frame 结构) for (int i = 0; i < 16; ++i) { AVFrame* frame = av_frame_alloc(); av_frame_get_buffer(frame, 32); // 对齐 32 字节 m_framePool.push(frame); } // 解码循环中直接取帧,避免 malloc/free AVFrame* frame = m_framePool.pop(); int ret = avcodec_receive_frame(m_codecCtx, frame); if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) { // 无输出,继续送包 } else if (ret < 0) { // 解码错误,丢弃此帧 av_frame_unref(frame); m_framePool.push(frame); }

避坑点:av_frame_get_buffer()必须在avcodec_open2()之后调用,否则frame->data[0]为 null;av_frame_unref()仅清空引用计数,不会释放data缓冲区,所以必须m_framePool.push(frame)归还。

3.4 步骤四:色彩空间转换 ——sws_scale()的线程安全与缓存复用

YUV420P → RGB 转换是性能热点。本项目预创建SwsContext并复用:

// 初始化一次,全局复用(线程安全!) m_swsCtx = sws_getCachedContext( m_swsCtx, m_codecCtx->width, m_codecCtx->height, m_codecCtx->pix_fmt, m_codecCtx->width, m_codecCtx->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr ); if (!m_swsCtx) { qFatal("sws_getCachedContext failed"); } // 转换时直接调用(无锁!) uint8_t* data[4] = { m_rgbBuffer.data(), nullptr, nullptr, nullptr }; int linesize[4] = { m_codecCtx->width * 3, 0, 0, 0 }; sws_scale(m_swsCtx, frame->data, frame->linesize, 0, m_codecCtx->height, data, linesize);

注意:sws_getCachedContext()是线程安全的,但m_swsCtx必须是成员变量而非局部变量,否则每次调用都重建上下文,性能下降 40% 以上。

3.5 步骤五:OpenGL 纹理上传 ——glTexSubImage2D的像素对齐陷阱

Qt 的QOpenGLWidget默认使用GL_RGBA,但 FFmpeg 输出常为AV_PIX_FMT_RGB24。本项目强制使用GL_RGB+GL_UNSIGNED_BYTE,并确保宽度 4 字节对齐:

// OpenGL 纹理初始化(一次) glGenTextures(1, &m_textureId); glBindTexture(GL_TEXTURE_2D, m_textureId); glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_RGB, GL_UNSIGNED_BYTE, nullptr); // 上传前检查对齐(RGB24 每行字节数 = width * 3) int row_bytes = width * 3; int alignment = (row_bytes % 4 == 0) ? 4 : 1; glPixelStorei(GL_UNPACK_ALIGNMENT, alignment); // 上传(注意:data 指向 RGB24 数据起始地址) glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, width, height, GL_RGB, GL_UNSIGNED_BYTE, m_rgbBuffer.data());

血泪经验:若width=1920,则row_bytes=5760,5760 % 4 == 0,alignment=4;但若width=1281,则row_bytes=3843,3843 % 4 == 3,此时必须设alignment=1,否则glTexSubImage2D读取越界,纹理花屏。

3.6 步骤六:渲染线程 ——QOpenGLWidget::paintGL()的帧率锁定

Qt 的update()不保证帧率,本项目用QTimer+QElapsedTimer实现硬帧率控制:

void VideoWidget::paintGL() { static QElapsedTimer timer; static double last_render_time = 0.0; double now = timer.elapsed() / 1000.0; // ms → s double target_interval = 1000.0 / m_targetFps; // e.g., 33.33ms for 30fps if (now - last_render_time >= target_interval) { // 从帧队列取最新帧(非阻塞) AVFrame* frame = m_frameQueue.tryPop(); if (frame) { uploadTexture(frame); // 调用 3.5 节逻辑 av_frame_unref(frame); m_framePool.push(frame); } last_render_time = now; } }

玄学细节:QElapsedTimer::elapsed()返回毫秒整数,除以 1000.0 转 double,避免整数除法截断;tryPop()必须是非阻塞的,否则paintGL()卡住会导致整个 UI 线程冻结。

3.7 步骤七:销毁清理 ——QOpenGLFunctions上下文绑定失效问题

QOpenGLWidget的makeCurrent()在~VideoWidget()中可能已失效,导致glDeleteTextures()崩溃。本项目在initializeGL()中缓存函数指针,并在paintGL()中动态绑定:

// initializeGL() 中 m_funcs = context()->functions(); // paintGL() 开头强制绑定 if (!context()->makeCurrent(this)) { qWarning() << "Failed to make OpenGL context current"; return; } m_funcs->glBindTexture(GL_TEXTURE_2D, m_textureId); // ... 渲染逻辑

翻车现场:若在析构函数中直接调m_funcs->glDeleteTextures(),因context()已销毁,m_funcs成为悬空指针。正确做法是在closeEvent()中调用cleanupGL(),并确保此时context()仍有效。


4. 避坑指南:五个让开发者凌晨三点还在 gdb 里爬的致命陷阱

这些坑不是文档里写的“注意事项”,而是某开发者在某跨平台系统中连续 36 小时调试后记下的血泪清单。每一条都对应一个真实崩溃现场、一个 gdb backtrace 截图、一个最小复现 demo。

4.1 现象:avcodec_send_packet()返回 -11(AVERROR(EAGAIN)),但avcodec_receive_frame()死锁

原因:未正确处理AVERROR(EAGAIN)的语义——它表示解码器输入缓冲区满,需先调avcodec_receive_frame()取出已解帧,再送新包。但很多教程误写成“收到 EAGAIN 就重试 send”,形成无限循环。
解决:严格遵循 FFmpeg 官方范式:send→ 检查返回值 → 若为EAGAIN或EOF,立即receive;若receive返回EAGAIN,说明输入不足,才应等待新包。本项目DecodeThread::run()中有完整状态机实现。

4.2 现象:H.265 硬解在 Windows 上成功初始化,但avcodec_receive_frame()返回 -35(AVERROR(ENOSYS))

原因:Intel QSV 要求AVCodecParameters->profile必须设为FF_PROFILE_HEVC_MAIN或FF_PROFILE_HEVC_MAIN_10,而某些 RTSP 流的 SPS 中 profile_idc 为 0,FFmpeg 不自动映射。
解决:在avformat_find_stream_info()后手动修正:

if (stream->codecpar->codec_id == AV_CODEC_ID_HEVC) { stream->codecpar->profile = FF_PROFILE_HEVC_MAIN; }

4.3 现象:OpenGL 纹理显示为纯绿色,且glGetError()返回GL_INVALID_OPERATION

原因:glTexSubImage2D()的data参数指向了已被av_frame_unref()释放的内存。根源在于AVFrame的data[0]指向AVBufferRef,而av_frame_unref()仅减少引用计数,缓冲区实际释放时机不可控。
解决:禁用 FFmpeg 的自动内存管理,改用av_frame_move_ref()+ 手动av_buffer_unref():

AVFrame* local_frame = av_frame_alloc(); av_frame_move_ref(local_frame, frame); // 转移所有权 // ... 上传纹理 av_frame_free(&local_frame); // 此时才真正释放

4.4 现象:音视频同步开启后,播放 5 分钟后音画偏差从 0ms 漂移到 +400ms

原因:swr_convert()的补偿采样点未考虑音频重采样器的内部延迟。swr_get_delay()返回的是当前重采样器积压的样本数,必须叠加到compensation_samples中。
解决:在AudioResampler::resampleWithCompensation()中加入:

int64_t swr_delay = swr_get_delay(m_swrCtx, m_audioCodecCtx->sample_rate); int64_t total_compensation = compensation_samples + swr_delay; swr_set_compensation(m_swrCtx, total_compensation, m_audioCodecCtx->sample_rate);

4.5 现象:macOS 上启用 VideoToolbox 硬解后,avcodec_open2()成功,但首帧avcodec_receive_frame()返回 -1(AVERROR_UNKNOWN)

原因:Apple VideoToolbox 要求AVCodecContext->framerate必须非零,且time_base必须能整除framerate。某些 RTSP 流framerate.num=0,FFmpeg 不自动推导。
解决:在打开解码器前强制设置:

if (m_codecCtx->framerate.num == 0) { m_codecCtx->framerate = av_guess_frame_rate(m_formatCtx, stream, nullptr); if (m_codecCtx->framerate.num == 0) { m_codecCtx->framerate = (AVRational){30, 1}; } } // 确保 time_base 匹配 m_codecCtx->time_base = av_inv_q(m_codecCtx->framerate);

5. 进阶技巧:如何用 3 行代码实现「播放任意帧」与「逐帧调试」能力

工业场景常需精准定位某帧(如 AI 推理失败帧),但 FFmpeg 默认不支持随机访问 I 帧。本项目通过av_seek_frame()+ 关键帧定位 + 解码器 flush 实现毫秒级跳转,且全程不中断播放线程。

5.1 帧号 → 时间戳的精确映射:构建 GOP 索引表

FFmpeg 的av_seek_frame()仅支持时间戳(us),而用户要的是“第 12345 帧”。本项目在初始化时构建轻量索引:

struct GOPIndex { int64_t pts; // 该 GOP 首帧 PTS(us) int64_t dts; // 该 GOP 首帧 DTS(us) int frame_count; // 该 GOP 内帧数(含 B 帧) }; QVector<GOPIndex> m_gopIndex; // 在解码循环中,每遇到 I 帧就记录 if (frame->key_frame) { GOPIndex idx; idx.pts = av_rescale_q(frame->pts, stream->time_base, AV_TIME_BASE_Q); idx.dts = av_rescale_q(frame->dts, stream->time_base, AV_TIME_BASE_Q); idx.frame_count = 0; // 后续累加 m_gopIndex.append(idx); } // 同时统计 GOP 内帧数(直到下一个 I 帧) if (!frame->key_frame && !m_gopIndex.isEmpty()) { m_gopIndex.last().frame_count++; }

参数说明:av_rescale_q()将流时间基转换为微秒(AV_TIME_BASE_Q = {1, 1000000}),确保所有 PTS 统一单位;frame_count从 0 开始计,因此第 N 帧所在 GOP 的索引为findGOPByFrame(N)。

5.2 毫秒级跳转:三步原子操作(不卡 UI、不丢状态)

跳转不是简单av_seek_frame(),而是「清空管道 + 定位 + 重同步」三步:

bool PlayerCore::seekToFrame(int target_frame) { // Step 1: 停止解码线程,清空所有队列 m_decodeThread.quit(); m_decodeThread.wait(); m_frameQueue.clear(); m_packetQueue.clear(); // Step 2: 定位到目标 GOP 的 I 帧 PTS int64_t target_pts = findGOPByFrame(target_frame).pts; int ret = av_seek_frame(m_formatCtx, m_videoStreamIndex, target_pts, AVSEEK_FLAG_BACKWARD); if (ret < 0) return false; // Step 3: 重新打开解码器(flush 内部状态) avcodec_flush_buffers(m_videoCodecCtx); m_decodeThread.start(); // 重启线程 return true; }

关键点:AVSEEK_FLAG_BACKWARD确保定位到前一个 I 帧,而非最近帧;avcodec_flush_buffers()是必须的,否则解码器内部 B 帧依赖链未清除,首帧必花屏。

5.3 逐帧调试:QShortcut绑定Step Forward/Backward的底层实现

Qt 的QShortcut默认在 GUI 线程触发,但解码在独立线程。本项目用QMetaObject::invokeMethod()跨线程调用:

// 在 PlayerCore 构造函数中 QShortcut* stepForward = new QShortcut(QKeySequence(Qt::Key_Period), this); connect(stepForward, &QShortcut::activated, this, [this]() { QMetaObject::invokeMethod(this, [this]() { m_stepMode = STEP_FORWARD; m_decodeThread.wakeOne(); // 唤醒解码线程 }, Qt::QueuedConnection); }); // 解码线程 run() 中检测 if (m_stepMode == STEP_FORWARD) { // 仅解码一帧,然后挂起 decodeOneFrame(); m_stepMode = STEP_IDLE; m_decodeThread.quit(); }

验证方法:启动播放器,按.键,观察VideoWidget是否只刷新一帧;按,键(STEP_BACKWARD),检查是否回退到上一 I 帧(需 GOP 索引支持)。这是某图像处理 Demo 中调试 ROI 标注错误的必备功能。

从那以后我每次做音视频模块,都会先写一个最小avformat_open_input+av_read_frame循环验证流可用性,再动 Qt 界面——因为 90% 的问题其实出在 FFmpeg 层,而不是 QWidget。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 16:28:45

密钥管理与硬件安全:从生命周期到HSM、TPM、SE选型实战

1. 密钥不是一串字符&#xff0c;而是整个安全体系的信任锚点很多人第一次接触密钥管理&#xff0c;脑子里浮现的画面就是"生成一串随机字符&#xff0c;存到配置文件里&#xff0c;用的时候读出来"。这个理解不能说错&#xff0c;但只停留在最表层。密钥真正的角色&…

作者头像 李华
网站建设 2026/10/15 16:28:22

Spring Boot同名类Bean名冲突:原因分析与五套解决方案

我最近在做一个多模块的项目改造&#xff0c;把原来单体应用里的一堆Service拆到不同工程下&#xff0c;结果其中某个微服务在启动时直接给我抛了个红色异常。核心一句话&#xff1a;The bean emailService, defined in class path resource [com/example/notification/EmailSe…

作者头像 李华
网站建设 2026/10/15 16:28:07

Qt 6.5 Linux BLE特征值稳定读写实战指南

简介&#xff1a;本资源是一套基于Qt框架的低功耗蓝牙&#xff08;BLE&#xff09;特征值读写测试例程&#xff0c;面向嵌入式开发、IoT应用及跨平台桌面端蓝牙通信初学者与进阶开发者&#xff0c;解决BLE设备调试中服务发现、GATT特征值交互等核心实践难点。压缩包为9KB的ZIP文…

作者头像 李华
网站建设 2026/10/15 16:27:37

通用AI智能体成本优势被挤压?拆解成本结构与应对策略

前阵子跟几个朋友聊起Agent的选型问题&#xff0c;发现大家都被账单折磨得不轻。一边是大模型厂商隔三差五推出更便宜的专用模型&#xff0c;逻辑推理、代码生成、信息抽取&#xff0c;各管一摊&#xff0c;单价一个比一个低&#xff1b;另一边是我们自己搭的OpenClaw这类通用型…

作者头像 李华
网站建设 2026/10/15 16:27:18

STM32+华为云IoT+Qt:从零搭建端云屏物联网全链路实战

1. 项目缘起与整体架构设计智能家居这个词已经被说烂了&#xff0c;但真正自己动手从零搭一套能跑通“端-云-屏”的完整系统&#xff0c;和买几个成品模块拼一拼完全是两码事。我前后花了大概三周时间&#xff0c;把STM32作为终端采集控制节点、云平台作为数据中转与远程指令通…

作者头像 李华
网站建设 2026/10/15 16:26:57

从 Flex 三行到 Grid 一行:place-items 居中实战解析

起因是昨天在代码评审里看到一段很眼熟的写法&#xff1a;display: flexjustify-content: centeralign-items: center&#xff0c;三行代码&#xff0c;只为了让按钮里的图标居中。放在三年前我会觉得没问题&#xff0c;但放到现在&#xff0c;我会直接建议改成一行place-items…

作者头像 李华