ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于Qt与FFmpeg打造稳定RTSP视频流播放器实战指南

基于Qt与FFmpeg打造稳定RTSP视频流播放器实战指南 简介一套基于Qt 5.9.6与FFmpeg的RTSP视频流播放器工程源码面向具备C/Qt基础、需要接入实时流媒体的开发者可解决多路RTSP流同步播放、暂停及画面截图等典型需求。压缩包内共123个文件约11MB以83个头文件、8个cpp源文件为核心并含pro/ui/qrc等工程配置、5个DLL与5个a链接库打开即可编译运行。源码围绕frmmain主窗口与qffmpeg封装类展开清晰展示三通道同时播放的实现创建多个播放器实例绑定不同RTSP地址通过截图按钮调用快照保存接口FFmpeg解码库的接入方法也体现在工程文件中。已有5520人学习适合远程监控、视频会议等场景的参考与二次开发对照头文件与源码可快速掌握播放器框架及FFmpeg集成要点。1. 为什么用Qt做RTSP播放器需求与选型拿到“Qt实现RTSP视频流播放器”这个题目很多人第一反应是直接拖个QMediaPlayer不就行了实际动手就会发现海康威视、小米摄像头、各种公网RTSP测试源QMediaPlayer在Windows上对RTSP协议的支持非常看人品经常出现能出声音不出画面、播放几秒就卡死、换个编码格式直接黑屏等情况。尤其是公司要对接的摄像头可能带着H.265编码、私有流格式内置解码器的播放方案基本等于废掉。所以这个项目要解决的核心问题不是“写一个能播视频的界面”而是要拿到RTSP流之后自己控制解码、渲染、缓存、断线重连这一整条链路保证摄像头画面在Qt窗口里稳定、低延迟、随时能截帧和回放。从技术角度看这个项目的本质是“拉流解码渲染”三件事。拉流走RTSP协议业内最成熟的方式是用FFmpeg的libavformat去解析RTSP地址底层会自动处理TCP/UDP传输和RTSP信令解码用libavcodecH.264/H.265都能解甚至MPEG4、MJPEG也能顺手搞定渲染有两种主流做法一是拿解码出的YUV帧转成QImage直接画在QWidget上二是走OpenGL纹理上屏。考虑到大多数Windows工控机的显卡性能一般而且项目偏业务功能而非极致画质用QPainter绘制QImage是最直观、最不容易出问题的方案也是新手最容易上手的路径。这个项目适合谁如果你已经在用Qt做上位机或桌面工具突然被分配了“把摄像头画面集成到软件里”的需求或者你想搞懂视频流从网络到屏幕到底经历了什么不想再靠别人封装的现成组件那这篇内容正好合适。我会把我实际踩过的坑、验证过的方案、以及最终能在Windows和Linux上稳定跑的代码骨架整理出来。下面先说说选型因为这一步选错后面全是在给架构还债。1.1 解码方案取舍FFmpeg、QtAV还是GStreamer我把常用的几套方案都试过一遍逐个说结论。首先是Qt自带的QMediaPlayer优点是调用简单缺点非常明显它依赖系统自带的多媒体框架Windows上走的是WMF对RTSP的支持不稳定而且没法拿到解码后的原始帧意味着你不能做自定义分析、不能截帧更不要想叠加OSD。如果只是本地MP4播放用它没问题但做RTSP播放器我建议直接跳过。第二套是QtAV这是一套基于FFmpeg封装的Qt多媒体库接口比原生FFmpeg友好支持QML和Widgets截图、GPU渲染这些功能都有。问题在于项目维护不算活跃遇到新版Qt比如5.15、6.x总要自己编译适配一旦摄像头传的是比较新的H.265封装它内部的一些处理逻辑又可能跟不上。作为学习参考不错但做产品交付我会更谨慎。第三套是GStreamer在Linux嵌入式和工控机上很流行配合gst-rtsp-server还能自己搭个流媒体服务端。可它最大的问题是Windows端部署体积大、环境变量和插件路径容易搞出幺蛾子团队如果平时不搞Linux学习成本偏高。我的最终选择是直接用FFmpeg的C API配合Qt做界面和线程管理。FFmpeg库本身不管界面正好发挥Qt在跨平台界面上的优势两边各干各的责任清晰出问题也好定位。而且FFmpeg的api在4.x和5.x/6.x之间保持得比较稳定写一版代码能支撑很多年。1.2 播放器功能边界与性能目标动手前先明确功能边界否则做着做着就容易跑偏。我这里定义的目标是能输入RTSP地址并播放、支持暂停和恢复、画面能自适应窗口缩放、延迟控制在1秒以内、断流后能自动重连、能手动截取当前帧保存为图片。这基本覆盖了大部分安防集成和设备调试场景。至于回放录像、云台控制、音频播放这些功能初期可以留出接口但不必一上来就做不然项目很容易烂尾。性能方面要重点盯住两个指标。一个是解码耗时解码一帧1080p H.264平均应该控制在10到30毫秒如果超过50毫秒界面会明显掉帧这时候要考虑是不是开了太多调试输出或者丢包导致解码器频繁等待。另一个是内存占用长期播放时内存应该稳定在300MB以内如果持续上涨大概率是队列积压或者QImage没有及时释放。这两条我用后面的代码框架都能实现只要线程结构不走样。2. 播放器核心架构与解码流程设计RTSP播放器最忌讳的就是把解码、渲染、网络操作全塞进UI线程。网络稍有抖动界面直接卡死用户第一反应就是“垃圾软件”。所以架构上必须具备三个独立线程主线程负责UI事件解码线程负责拉流、解码、输出图像帧渲染线程或者定时器负责把帧画到控件上。三者之间用队列传递数据队列满了就丢旧帧保证实时性。控制关系上用户点击播放按钮后主线程启动一个CaptureThread它内部打开RTSP地址并循环读取AVPacket解码成AVFrame后转为QImage塞进一个有限的帧队列。画面控件通过Qt的QTimer以固定频率从队列里取最新一帧并更新这样即使解码速度偶尔抖动界面也不会等帧等到卡死。暂停功能其实很简单解码线程正常跑但渲染定时器不再取帧这样再恢复时画面不会跳跃太多同时也保持了解码器的实时状态。2.1 模块划分我把整个工程拆成四个模块网络会话模块、解码模块、渲染模块、控制模块。网络会话模块负责RTSP的打开、参数设置、网络协议选择TCP/UDP和重连逻辑解码模块负责把AVPacket转成AVFrame并处理像素格式转换渲染模块只关心QImage怎么画到控件上不管数据从哪来控制模块把按钮事件映射成对线程和队列的操作。这样拆分的好处是以后如果想支持本地文件播放只需要新增一个读取本地文件的会话实现解码和渲染完全不用动。模块间依赖关系尽量单向控制模块依赖网络会话和解码解码产出的帧交给渲染渲染不知道前两级的存在。这样做还有一个很实际的好处——单测好写。我试过把FFmpeg的打开过程封装成一个虚接口测试时直接注入一个假的会话模块返回本地测试视频流整个播放器的逻辑就能在CI环境里跑起来不用依赖真实摄像头。2.2 RTSP拉流与解码流程串讲用FFmpeg打开RTSP时核心步骤其实比很多人想象中简单调用avformat_open_input打开地址avformat_find_stream_info找出视频流索引然后用对应的解码器参数avcodec_find_decoder找解码器avcodec_open2打开解码器最后循环调用av_read_frame拿包、avcodec_send_packet发包、avcodec_receive_frame收帧。这个过程对文件和对RTSP是一样的区别只在于打开时设置的超时参数和传输协议选项。一个特别容易忽略的点是RTSP拉流默认可能走UDP而UDP在跨网段、公网环境下丢包会直接导致花屏或卡死。我的建议是在avformat_open_input之前通过AVOption设置打开视频流的超时时间为5秒传输层强制设为tcp。TCP模式虽然会稍微增加延迟但稳定性比UDP好太多尤其是调试阶段能省掉一大半莫名的“画面不显示”问题。另外在获取流信息后最好手动把AVStream的avg_frame_rate算一下用av_q2d转成实际的帧率值后续做延迟控制和帧同步都用得到。2.3 音视频同步的简化处理如果是做视频监控类播放器音频往往不是第一优先级很多摄像头默认不带音频流。所以我先不铺开讲复杂的音视频同步算法只说一个原则如果不需要音频就用帧率做基准渲染定时器按1000 / fps毫秒的间隔刷新一帧如果需要音频让视频去贴合音频时钟因为人耳对声音的敏感程度远高于眼睛。监控场景下我建议初期直接忽略音频把精力放在画面质量和延迟控制上性能压力会小很多。3. 实操落地基于FFmpeg的播放器实现下面进入能跑的代码阶段。我的环境是Windows 10 Qt 5.15.2MSVC2019 64位 FFmpeg 5.1。Qt安装时选MSVC套件对应要装好Visual Studio 2019或2022的C开发工具否则编译会报找不到编译器的错。FFmpeg我比较推荐去gyan.dev或者BtbN下载编译好的Windows版本因为自己从源码编译FFmpeg在Windows上不是一般的折腾还要处理nasm、msys这些依赖对做应用层开发的人来说性价比太低。3.1 环境准备与工程配置下载回来的FFmpeg压缩包解压后目录结构是bin、include、lib三部分。bin里有avcodec-*.dll、avformat-*.dll、avutil-*.dll、swscale-*.dll这几个关键的动态库程序运行时必须能找到它们要么复制到exe旁边要么把bin目录加到系统PATH。在Qt的.pro文件里我是这样配置的INCLUDEPATH D:/3rdparty/ffmpeg/include LIBS -LD:/3rdparty/ffmpeg/lib \ -lavcodec \ -lavformat \ -lavutil \ -lswscale用CMake也类似关键在于链接库的路径别写错以及确保编译器的位数和库的位数一致。我见过不少同学报unresolved external symbol查到最后发现是下载了32位的FFmpeg库而Qt用的是64位的MSVC套件两个架构搭不上自然链接不过。运行时你会发现光靠Qt的windeployqt不会帮你把FFmpeg的动态库带进去因为它只识别Qt自身的运行库。我通常会写一个发布脚本用windeployqt处理完Qt依赖后再把FFmpeg的bin目录下那几个dll手工复制到发布目录顺手用dumpbin或者Dependencies工具看一眼动态依赖避免漏掉某个dll导致用户电脑上启动时弹“找不到avcodec-59.dll”。3.2 解码线程的代码骨架我直接给出一个能跑通核心流程的代码框架。首先是头文件里声明的核心类RtspCaptureThread它继承QThread暴露startPlay(const QString url)、pause()、resume()、stop()这几个槽函数同时通过信号frameReady(const QImage image)把解码后的图像发出去。注意我这里用的QImage是值传递表面看多了一次拷贝实际Qt的隐式共享机制会在这类场景下减少不必要的深拷贝性能可以接受。class RtspCaptureThread : public QThread { Q_OBJECT public: explicit RtspCaptureThread(QObject *parent nullptr); ~RtspCaptureThread() override; void startPlay(const QString url); void pause(); void resume(); void stop(); signals: void frameReady(const QImage frame); protected: void run() override; private: void openStream(const QString url, AVFormatContext **fmtCtx, AVCodecContext **codecCtx, int *videoIndex); void decodeLoop(AVFormatContext *fmtCtx, AVCodecContext *codecCtx, int videoIndex); QString m_url; QAtomicInt m_paused; QAtomicInt m_stopped; };run函数是整个线程的主循环。我一般不在run里直接写死业务而是拆成openStream和decodeLoop两个私有方法方便后期替换成本地文件播放或者HLS播放。openStream的职责是打开URL和初始化解码器decodeLoop则一个包一个包地读、解、转格式并发送信号。3.3 打开RTSP流与初始化解码器这是最关键的环节我把代码贴出来注释写清楚每一步的作用特别是几个毁掉无数项目的参数。void RtspCaptureThread::openStream(const QString url, AVFormatContext **fmtCtx, AVCodecContext **codecCtx, int *videoIndex) { AVFormatContext *ctx nullptr; AVDictionary *options nullptr; // 设置打开超时5s避免地址不可达时长时间阻塞 av_dict_set(options, stimeout, 5000000, 0); // 强制走TCP公网播放更稳 av_dict_set(options, rtsp_transport, tcp, 0); // 减少内部缓冲降低延迟 av_dict_set(options, buffer_size, 1024000, 0); int ret avformat_open_input(ctx, url.toStdString().c_str(), nullptr, options); av_dict_free(options); if (ret 0) { emit errorOccurred(QStringLiteral(打开失败错误码: %1).arg(ret)); return; } ret avformat_find_stream_info(ctx, nullptr); if (ret 0) { avformat_close_input(ctx); emit errorOccurred(QStringLiteral(获取流信息失败)); return; } *videoIndex av_find_best_stream(ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if (*videoIndex 0) { avformat_close_input(ctx); emit errorOccurred(QStringLiteral(未找到视频流)); return; } AVCodec *decoder avcodec_find_decoder(ctx-streams[*videoIndex]-codecpar-codec_id); if (!decoder) { avformat_close_input(ctx); emit errorOccurred(QStringLiteral(找不到对应解码器)); return; } *codecCtx avcodec_alloc_context3(decoder); avcodec_parameters_to_context(*codecCtx, ctx-streams[*videoIndex]-codecpar); // 部分解码器需要设置线程数以加快解码 (*codecCtx)-thread_count 4; ret avcodec_open2(*codecCtx, decoder, nullptr); if (ret 0) { avcodec_free_context(codecCtx); avformat_close_input(ctx); emit errorOccurred(QStringLiteral(打开解码器失败)); return; } *fmtCtx ctx; }注意stimeout的单位是微秒所以我写了5000000代表5秒。这个参数特别重要不然设备断电后程序可能会卡在avformat_open_input里几十秒用户体验极差。另外avformat_find_stream_info有时会比较慢如果手头RTSP源足够干净也可以跳过但我不建议跳过因为后续很多参数依赖codecpar里的信息比如宽高、编码格式。3.4 解码循环与图像格式转换decodeLoop里的逻辑围绕av_read_frame展开。注意RTSP流里除了视频包还有音频包和字幕包判断packet.stream_index videoIndex可以只处理视频包。拿到AVPacket后交给解码器解码avcodec_send_packet和avcodec_receive_frame是FFmpeg新接口的标准姿势老接口avcodec_decode_video2已经废弃网上很多教程还在用照着写会让编译器报一长串deprecation警告。void RtspCaptureThread::decodeLoop(AVFormatContext *fmtCtx, AVCodecContext *codecCtx, int videoIndex) { AVPacket *pkt av_packet_alloc(); AVFrame *frame av_frame_alloc(); AVFrame *rgbFrame av_frame_alloc(); SwsContext *swsCtx sws_getContext( codecCtx-width, codecCtx-height, codecCtx-pix_fmt, codecCtx-width, codecCtx-height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); int rgbSize av_image_get_buffer_size(AV_PIX_FMT_RGB24, codecCtx-width, codecCtx-height, 1); QByteArray rgbBuffer(rgbSize, Qt::Uninitialized); av_image_fill_arrays(rgbFrame-data, rgbFrame-linesize, reinterpret_castconst uint8_t *(rgbBuffer.constData()), AV_PIX_FMT_RGB24, codecCtx-width, codecCtx-height, 1); while (!m_stopped.loadRelaxed()) { if (m_paused.loadRelaxed()) { msleep(20); continue; } int ret av_read_frame(fmtCtx, pkt); if (ret 0) { // 读到结束或错误交给外层处理重连 break; } if (pkt-stream_index videoIndex) { ret avcodec_send_packet(codecCtx, pkt); if (ret 0 || ret AVERROR(EAGAIN)) { while (avcodec_receive_frame(codecCtx, frame) 0) { sws_scale(swsCtx, frame-data, frame-linesize, 0, codecCtx-height, rgbFrame-data, rgbFrame-linesize); QImage image(rgbBuffer.constData(), codecCtx-width, codecCtx-height, QImage::Format_RGB888); emit frameReady(image.copy()); } } } av_packet_unref(pkt); } sws_freeContext(swsCtx); av_frame_free(rgbFrame); av_frame_free(frame); av_packet_free(pkt); }这里有个小细节QImage image(...)直接指向rgbBuffer的内存这个buffer在函数栈上信号发出时如果在线程内直接消费没有问题但跨线程传递就必须image.copy()做一次深拷贝否则接收方看到的是一块已经失效的内存。我之前犯过这个错画面时常花掉排查了好久才发现是image生命周期的问题。rgbBuffer用Qt::Uninitialized而不是QByteArray(size, \0)是为了避免一次多余的内存清零视频帧数据马上会被sws_scale覆盖清零是纯浪费。3.5 界面渲染与帧率控制接收端我用了QTimer定时从最新帧队列里取图。队列可以用QQueueQImage加锁或者用QSharedPointer传帧。更简单的做法是让解码线程直接信号发QImage到主线程在连接信号时用Qt::QueuedConnection然后在主线程的槽函数里直接更新QLabel或QWidget。但这种方式有一个问题如果解码速度比显示速度快队列里堆积的事件会让UI越追越累内存也会上涨。所以我在实际项目里用了一个带最大长度的环形队列解码线程往里塞UI定时器每次取最新一帧队列满了就淘汰最旧的那帧。这样延迟始终被压制在一两帧以内UI稳定不膨胀。void PlayerWidget::showVideo() { QImage frame m_frameQueue-latestFrame(); if (frame.isNull()) return; if (m_keepAspect) { QPixmap pix QPixmap::fromImage(frame); pix pix.scaled(ui-videoLabel-size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); ui-videoLabel-setPixmap(pix); } else { ui-videoLabel-setPixmap(QPixmap::fromImage(frame)); } }帧率控制方面我一般设置QTimer间隔为33毫秒左右对应约30fps。注意如果是25fps的摄像头用33毫秒也能覆盖因为取的是最新帧所以显示频率略高不会造成重复播放只是空转几次性能影响很小。窗口拉伸时用Qt::KeepAspectRatio防止画面变形这对16:9的监控画面尤其重要。4. 常见问题与排查技巧实录这部分我按真实项目中踩坑频率排序挑几个最容易让新手崩溃的问题讲。每个问题我都会给出现象、原因和解决方案方便你当速查表用。4.1 程序发布后提示“no Qt platform plugin could be initialized”这个错误常年出现在Windows部署场景现象是双击exe直接弹窗或者命令行运行时报no Qt platform plugin could be initialized, reinstalling the application may fix this problem。原因很简单Qt的platform插件比如qwindows.dll没有在exe同级的platforms目录下。解决办法是运行windeployqt并确保它与你编译用的Qt版本完全一致。命令大概是windeployqt your_app.exe它会在exe所在目录自动生成platforms、styles、imageformats等文件夹并拷贝必需的Qt运行库。如果用了FFmpeg还要手动把FFmpeg的dll拷过去前面已经提过。我这边实际遇到过一个更隐蔽的情况在开发机上没问题拷到别人的电脑上就报这个错最后发现是原来系统里装过别的Qt版本环境变量PATH里残留了另一个版本的Qt bin目录程序启动时加载到了错误的qwindows.dll。解决方式是发布时做成免安装绿色版并且尽量不带依赖系统路径把发布目录复制到干净环境里验证。4.2 RTSP地址不对导致画面一直出不来海康威视摄像头的RTSP地址有标准格式rtsp://用户名:密码IP地址:554/Streaming/Channels/101。其中101表示主码流102表示子码流大华等品牌也有类似但不同的路径规则。如果地址里密码包含特殊字符比如、:一定要做URL编码否则地址解析直接错乱。小米摄像头取流则相对灵活你可以在米家App里开启RTSP服务然后把生成的地址配到播放器里。这种源本身没问题但很多家用摄像头码率不高主码流可能只有2Mbps左右解码难度很低。如果你发现播放器连本机localhost的RTSP流卡顿反而应该先检查Wi-Fi信号和码率设置而不是怀疑FFmpeg。我建议调试阶段先用公开的RTSP测试地址验通代码比如网络上常见的海康演示源、大华演示源或者自己用FFmpeg推一个本地RTSP流服务。先用ffplay rtsp://xxx确认这个地址在电脑上能播放再放到Qt程序里能少排查一半的问题。项目刚起步时不要直接用客户的摄像头因为一旦拉不到流你分不清是网络问题、摄像头配置问题还是自己代码问题。4.3 播放花屏、绿屏和崩溃花屏有很多种诱因。最常见的是编码数据不完整这往往发生在RTSP走UDP且网络丢包严重的时候解决方案就是之前在openStream里设置rtsp_transporttcp可以让丢包率大幅下降。其次是解码后的像素格式处理错了比如解码器输出AV_PIX_FMT_YUV420P但你用sws_scale转到RGB24时参数写错导致画面错位。这时候可以先用ffprobe或者打印codecCtx-pix_fmt确认实际格式。崩溃问题要多留个心眼。我遇到过的最典型崩溃是视频宽高为0时调用sws_getContext直接触发空指针访问。原因是一些特殊视频流在find_stream_info后依然没有填上宽高或者设备端传过来的偶发错误帧。建议在初始化swsContext之前加一个判断width 0 || height 0就跳过解码等待下一帧。另外av_read_frame返回错误后要立刻break而不是继续循环否则会出现无效包和无限循环打转的坑。4.4 延迟越来越大如何优化监控场景对延迟要求不算苛刻但要做到低延迟还是有几条路。首先把FFmpeg打开时的buffer_size调小一点但别太小否则网络抖动会让画面频繁卡顿。其次在显示端用“取最新帧”而不是“排队按序播放”即使偶尔跳过几帧老画面视觉上也比越拖越慢舒服得多。也可以尝试把avcodec_receive_frame循环改成每读1个包就最多解出来1帧牺牲一点解包效率换更低的缓冲。如果用的是GStreamer方案还可以参考它的rtpjitterbuffer属性和syncfalse设置但既然选型已经定了FFmpegQt就在我这里说的几个参数上找空间。实测下来普通的1080p摄像头通过TCP拉流、限制缓冲区、最新帧显示延迟稳定在300到500毫秒是完全没问题的肉眼感觉基本是实时的。4.5 与Qt绘图功能扩展的联动播放器跑起来之后很多人会顺手在画面上叠加一些动态曲线或状态信息。比如从摄像头解码出的图像数据本身不做分析但工控场景里经常要把传感器温度、电工参数以时域波形或者频域频谱的形式叠加到监控界面旁边。这时候Qt自带的QCustomPlot就很好用我见过一个项目把本课题的播放器再接一路物理量采集用定时器刷新心电波形效果很直观。Qt里时域图转频域图一般用kissfft或FFTW做FFT再把结果灌给QCustomPlot的graph接口和视频渲染完全是两码事互不干扰值得作为播放器成型后的扩展方向。5. 一些实用的工程建议最后说几点对实际项目更有价值的经验不想看详细代码的人可以直接从这里收获。第一线程停止一定要用原子变量而不是terminate()。QThread::terminate()会直接终止线程FFmpeg正在解码时内部可能持有锁强行终止轻则内存泄漏重则整个进程崩溃。我这里是设m_stopped标志并且在av_read_frame阻塞前设置超时这样即使当前正在等待网络数据也能在5秒内醒过来判断退出标志。第二RTSP断线重连不能简单地在decodeLoop里加个死循环去重连因为FFmpeg打开失败本身可能有5秒超时如果断网时间久了线程会一直阻塞在打开流程里UI上无法处理退出的请求。我的做法是在解码线程发现av_read_frame返回错误后发送一个connectionLost信号由主线程统一决策是停止播放、弹窗提示还是启动一个独立的定时器在后台延时重连。真实产品里我通常用指数退避策略1秒、2秒、4秒……最多30秒重试一次避免频繁重连把摄像头端打崩。第三FFmpeg日志在Qt里很吵默认会往stderr打一堆信息。调试时确实有用但发布时建议用av_log_set_level(AV_LOG_ERROR)关掉大部分输出或者注册一个回调函数把日志重定向到自己的文件系统。不然客户现场一旦出问题跨线程打日志还可能引入新的时序问题排查起来会麻烦很多。这个项目做到最后给我的感觉是RTSP播放器真正的难点不在界面而在工程细节。你用QTimer刷新一帧图很容易难的是把解码线程和渲染线程的生命周期管好把异常路径全都设计到位。按我上面这套结构功能扩展性也很强后续接音频、接录像、接AI分析都能在现有模块上做加法。如果你也正在被“摄像头画面怎么嵌进Qt界面”这个问题困扰建议先不要急着抄一堆代码把一个最简单的RTSP地址用FFmpeg命令行ffplay播出来再一步步挪到你的工程里踩坑的成本会低很多。本文还有配套的精品资源点击获取
返回列表