C/C++音视频编解码实战:从FFmpeg原理到播放器开发 1. 项目概述从“播放器”到“编解码器”的认知跃迁很多刚接触音视频开发的朋友第一反应往往是“我要做一个播放器”。这没错播放器是音视频技术最直观的集大成者。但当你真正开始动手打开一个开源播放器项目比如FFmpeg的ffplay或者VLC的源码扑面而来的那些avcodec_send_packet、sws_scale、SDL_OpenAudio函数以及各种AVPacket、AVFrame结构体可能会让你瞬间懵掉。你会发现播放器的核心其实是一个精密的“数据流水线”而这条流水线的起点和最关键的一环就是音视频编解码。我最初也是从写一个简单的命令行播放器入坑的。当时我的目标只是能播出一个MP4文件的声音和画面。但很快我就发现直接读取MP4文件的数据是没法送给扬声器和显示器播放的。那些数据是被压缩编码过的目的是为了节省存储空间和网络带宽。一个1080p、30帧的视频原始数据量大约是1920 * 1080 * 3RGB * 30 ≈ 178 MB/s一分钟就超过10GB。而一个常见的.mp4文件一分钟可能只有几十MB。这背后巨大的数据压缩就是编解码技术的功劳。所以理解编解码是理解整个音视频技术栈的基石。它决定了数据如何被高效地“打包”和“拆包”是所有上层应用——无论是抖音的视频流、视频会议的实时通话还是你手机里的本地播放——赖以生存的基础。本次实践我们将聚焦于C/C这一音视频领域的“系统级”语言深入编解码技术的腹地。我们不只停留在调用API的层面而是会结合FFmpeg这一事实上的行业标准库去剖析编解码的核心流程、关键数据结构和性能优化点。目标是让你不仅能写出一个可用的编解码程序更能理解每一行代码背后的数据流向和设计哲学从而具备解决实际项目中复杂音视频问题的能力。2. 核心原理数据压缩的“道”与“术”在动手写代码之前我们必须先搞清楚编解码到底在做什么。简单说编码Encode就是压缩解码Decode就是解压缩。但音视频的压缩可不是用ZIP打包那么简单它融合了信号处理、心理学、信息论等多学科知识。2.1 为何而编——压缩的必要性与目标原始的音视频信号称为PCM音频和YUV/RGB视频数据量庞大存在大量冗余。压缩的目标就是在尽可能保持主观质量的前提下剔除这些冗余。冗余主要分三类空间冗余一帧图像内相邻像素的颜色和亮度往往非常接近。比如一片蓝天很多像素的RGB值几乎一样。时间冗余相邻的视频帧之间内容变化通常很小。比如人物说话的场景背景几乎不变只有嘴部在动。视觉/听觉冗余人眼和人耳对某些信息不敏感。例如人眼对亮度变化敏感对颜色细节相对不敏感人耳对极高和极低频率的声音不敏感。可以适当舍弃这些不敏感的信息。编码器就是利用各种算法“术”来消除这些冗余。衡量一个编解码器的核心指标有两个码率Bitrate和质量Quality。码率是每秒处理的数据量如1 Mbps质量通常用主观评测或客观指标如PSNR, SSIM, VMAF来衡量。编码器的核心艺术就是在给定的码率下追求最高的质量或者在要求的质量下使用最低的码率。2.2 编解码器的“心脏”预测、变换与熵编码现代视频编码标准如H.264/AVC, H.265/HEVC, AV1都遵循类似的混合编码框架其核心流程可以概括为“预测 - 变换 - 量化 - 熵编码”。预测Prediction帧内预测利用当前帧内已编码部分的数据来预测当前块。主要消除空间冗余。比如一个块的上方和左侧的像素已经被编码了编码器会尝试用这些像素来预测当前块的内容然后只编码预测的“残差”真实值减去预测值。残差的数据量通常远小于原始数据。帧间预测利用已编码的参考帧来预测当前帧。这是消除时间冗余的关键。编码器会在参考帧中为当前块寻找一个最匹配的区域运动估计记录下两者之间的位移运动矢量。同样只编码当前块与参考块之间的残差。这就是为什么视频中静止的背景几乎不占码率的原因。变换Transform 将残差数据从空间域转换到频域。常用的是离散余弦变换DCT。经过变换后信号的能量会集中到少数低频系数上而大量高频系数接近于零。这为后续的量化步骤创造了条件。量化Quantization 这是编码过程中唯一会带来不可逆信息损失有损压缩的步骤。量化器用一个“步长”去除变换后的系数并将结果取整。步长越大取整后为零的系数就越多压缩率越高但丢失的信息也越多质量下降越严重。编码器通过调整量化参数QP来控制压缩率和质量的平衡。熵编码Entropy Coding 这是最后一步无损压缩。它将量化后的系数、运动矢量、预测模式等信息按照其出现的概率分配不同长度的码字。出现概率高的符号用短码概率低的用长码从而进一步压缩数据。常用的算法有指数哥伦布编码和算术编码。注意理解这个“预测-变换-量化-熵编码”的流水线至关重要。当你调试编码质量或性能问题时你的调整如QP值、GOP结构、运动搜索范围最终都是在这个流水线的不同环节起作用。音频编码的原理类似但也因其一维时序信号的特性而有所不同。例如AAC编码会利用人耳的听觉掩蔽效应一个强音会掩盖同时刻的弱音在频域上进行心理声学模型分析有选择性地量化不同频段的信号从而实现高效压缩。3. 工具与生态FFmpeg——音视频领域的“瑞士军刀”在C/C音视频开发中FFmpeg是绕不开的名字。它不是一个单一的软件而是一套完整的、跨平台的解决方案包含了库libavcodec, libavformat, libavutil等和工具ffmpeg, ffplay, ffprobe。对于我们开发者而言主要使用的是它的库。3.1 FFmpeg核心库简介libavcodec编解码库。提供了数百种音视频编解码器的实现是我们本次实践的核心。你调用avcodec_send_packet()和avcodec_receive_frame()就是和这个库打交道。libavformat封装格式库。处理多媒体容器格式如MP4, MKV, FLV, MPEG-TS负责“解复用”Demux从容器中分离出音视频流和“复用”Mux将音视频流打包进容器。libavutil工具库。包含哈希、数学运算、内存管理等通用例程。定义了许多核心数据结构如AVDictionary键值对、AVRational分数。libswscale图像缩放与色彩空间转换库。比如将解码后的YUV420P图像转换为RGB24以便显示。libswresample音频重采样库。转换音频的采样率、声道布局或样本格式。3.2 开发环境搭建以VSCode CMake为例虽然Visual Studio功能强大但在跨平台和开源项目协作中CMake是更通用的选择。结合VSCode可以获得轻量且高效的开发体验。安装FFmpeg开发库Windows推荐使用MSYS2。在MSYS2终端中运行pacman -S mingw-w64-x86_64-ffmpeg即可安装包含头文件和链接库的完整开发包。库文件通常位于/mingw64/lib头文件在/mingw64/include。macOS使用Homebrewbrew install ffmpeg。Linux使用包管理器如Ubuntu/Debiansudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libswresample-dev。配置VSCode的C/C环境安装扩展C/C(Microsoft),CMake,CMake Tools。创建一个项目文件夹里面新建CMakeLists.txt文件。编写CMakeLists.txtcmake_minimum_required(VERSION 3.10) project(AudioVideoCodecDemo) set(CMAKE_CXX_STANDARD 11) # 查找FFmpeg组件 find_package(PkgConfig REQUIRED) pkg_check_modules(AVCODEC REQUIRED libavcodec) pkg_check_modules(AVFORMAT REQUIRED libavformat) pkg_check_modules(AVUTIL REQUIRED libavutil) pkg_check_modules(SWSCALE REQUIRED libswscale) pkg_check_modules(SWRESAMPLE REQUIRED libswresample) # 添加可执行文件 add_executable(demo main.cpp) # 包含头文件目录 target_include_directories(demo PRIVATE ${AVCODEC_INCLUDE_DIRS} ${AVFORMAT_INCLUDE_DIRS} ${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS} ${SWRESAMPLE_INCLUDE_DIRS} ) # 链接库 target_link_libraries(demo ${AVCODEC_LIBRARIES} ${AVFORMAT_LIBRARIES} ${AVUTIL_LIBRARIES} ${SWSCALE_LIBRARIES} ${SWRESAMPLE_LIBRARIES} )对于Windows MSYS2环境find_package可能不工作可以直接指定路径include_directories(/mingw64/include) link_directories(/mingw64/lib) target_link_libraries(demo avcodec avformat avutil swscale swresample)配置VSCode的c_cpp_properties.json 按CtrlShiftP输入C/C: Edit Configurations (UI)在Include path中添加你的FFmpeg头文件路径如/mingw64/include。这样代码提示和跳转就正常了。实操心得在Windows上环境配置是第一个“坑”。强烈建议使用MSYS2MinGW-w64这套工具链它更贴近Linux的开发体验编译出的二进制文件依赖关系也相对清晰。避免使用Visual Studio自带的编译器直接编译FFmpeg源码那会引入大量复杂的运行时库问题。4. 解码实战拆开一个视频文件的“黑盒”让我们从一个最经典的任务开始解码一个MP4视频文件并将其YUV原始数据保存下来。这个过程就像拆开一个快递包裹MP4容器取出里面的商品压缩的H.264数据然后拆掉商品的内部包装解码得到你可以直接使用的物品YUV数据。4.1 解码流程与核心数据结构FFmpeg的解码流程是典型的“推模式”Send/Receive。核心数据结构有三个AVFormatContext封装格式的上下文。包含文件或流的所有信息如流数量、时长、元数据等。通过avformat_open_input创建。AVCodecContext编解码器的上下文。每个音视频流都有一个。包含了该流编解码所需的所有参数如编码类型、宽度、高度、采样率等。通过avcodec_alloc_context3和avcodec_parameters_to_context创建和初始化。AVPacket压缩编码后的数据包。从容器中读取出的原始压缩数据单元。AVFrame解码后的原始数据帧。包含一帧图像YUV/RGB或一段音频PCM的样本。解码的主循环逻辑如下// 伪代码展示流程 avformat_open_input(fmt_ctx, filename, NULL, NULL); // 打开文件 avformat_find_stream_info(fmt_ctx, NULL); // 探测流信息 // 找到视频流索引 video_stream_index AVCodecParameters *codecpar fmt_ctx-streams[video_stream_index]-codecpar; // 根据codecpar中的编码器ID如AV_CODEC_ID_H264找到解码器 const AVCodec *codec avcodec_find_decoder(codecpar-codec_id); AVCodecContext *codec_ctx avcodec_alloc_context3(codec); avcodec_parameters_to_context(codec_ctx, codecpar); // 用流参数初始化解码上下文 avcodec_open2(codec_ctx, codec, NULL); // 打开解码器 AVPacket *pkt av_packet_alloc(); AVFrame *frame av_frame_alloc(); while (av_read_frame(fmt_ctx, pkt) 0) { // 循环读取压缩包 if (pkt-stream_index video_stream_index) { // 发送压缩包到解码器 avcodec_send_packet(codec_ctx, pkt); // 循环接收解码后的帧 while (avcodec_receive_frame(codec_ctx, frame) 0) { // 此时 frame 里就是解码好的YUV数据 // frame-data[0], frame-data[1], frame-data[2] 分别对应Y, U, V平面 // frame-linesize[0] 等是每行的字节数 process_decoded_frame(frame); // 处理帧例如保存为YUV文件 } } av_packet_unref(pkt); // 释放包数据准备读下一个 } // 刷新解码器发送NULL包 avcodec_send_packet(codec_ctx, NULL); while (avcodec_receive_frame(codec_ctx, frame) 0) { process_decoded_frame(frame); } // 清理资源...4.2 关键代码解析与YUV保存process_decoded_frame函数的一个常见任务是将YUV数据写入文件。YUV420P是一种常见的格式其内存排列是三个平面先存所有Y亮度再存所有U色度最后存所有V色度。U和V的宽高通常是Y的一半4:2:0下采样。void save_frame_to_yuv420p(AVFrame *frame, FILE *outfile) { // 写入Y平面 for (int y 0; y frame-height; y) { fwrite(frame-data[0] y * frame-linesize[0], 1, frame-width, outfile); } // 写入U平面 (高度是Y的一半) for (int y 0; y frame-height / 2; y) { fwrite(frame-data[1] y * frame-linesize[1], 1, frame-width / 2, outfile); } // 写入V平面 (高度是Y的一半) for (int y 0; y frame-height / 2; y) { fwrite(frame-data[2] y * frame-linesize[2], 1, frame-width / 2, outfile); } }这里有一个极易出错的点frame-linesize[i]是每一行数据对齐后的字节数它可能大于实际的图像宽度frame-width。这是因为内存对齐如16字节对齐可以提高SIMD指令访问效率。所以不能简单地用fwrite(frame-data[0], 1, frame-width * frame-height, outfile)来写Y平面那样会把对齐的“填充字节”也写进去导致文件错误。必须像上面那样按行写入。踩坑记录早期我直接用memcpy按总大小拷贝YUV数据结果用YUV播放器打开时图像错位、颜色异常。调试了很久才发现是linesize在作祟。FFmpeg的很多函数如sws_scale也要求你正确传入linesize。记住处理AVFrame的数据时永远要考虑到linesize可能存在的对齐填充。5. 编码实战将原始数据“打包”发送理解了解码编码就是它的逆过程。我们将从YUV文件读取原始帧编码成H.264码流并封装进MP4容器。5.1 编码器初始化与参数配置编码比解码需要配置更多的参数因为你需要告诉编码器你想要什么样的输出。// 1. 找到编码器这里用libx264软件H.264编码器 const AVCodec *codec avcodec_find_encoder_by_name(libx264); if (!codec) { // 尝试用ID查找 codec avcodec_find_encoder(AV_CODEC_ID_H264); } // 2. 创建编码器上下文 AVCodecContext *enc_ctx avcodec_alloc_context3(codec); // 3. 设置核心参数 enc_ctx-width 1280; enc_ctx-height 720; enc_ctx-time_base (AVRational){1, 25}; // 帧率25fps time_base 1/25 enc_ctx-framerate (AVRational){25, 1}; enc_ctx-pix_fmt AV_PIX_FMT_YUV420P; // 输入像素格式 enc_ctx-bit_rate 1000000; // 目标码率 1 Mbps // 4. 设置GOP (Group of Pictures)结构 enc_ctx-gop_size 50; // 每50帧一个关键帧I帧 enc_ctx-max_b_frames 2; // 允许最多2个B帧 // 5. 对于H.264可以设置一些高级参数通过AVDictionary AVDictionary *opts NULL; av_dict_set(opts, preset, medium, 0); // 编码速度与质量的权衡 av_dict_set(opts, tune, zerolatency, 0); // 针对低延迟场景优化 // 6. 打开编码器 if (avcodec_open2(enc_ctx, codec, opts) 0) { // 错误处理 } av_dict_free(opts);参数选择背后的逻辑time_base和framerate决定了时间戳的精度和帧率。time_base是时间的基本单位PTS呈现时间戳 帧序号 *time_base。设置错误会导致播放速度异常。bit_rate目标码率。编码器会努力使输出码率接近这个值。在VBR可变码率模式下这是一个平均目标。gop_size关键帧间隔。I帧是独立编码的帧解码不依赖其他帧。GOP越大压缩率越高因为P/B帧更高效但随机访问快进/拖动的延迟也越大网络传输中丢帧的影响也越大。直播场景通常设置很小的GOP甚至全I帧。presetx264编码器特有的参数从ultrafast到veryslow。越慢的preset编码器会做更复杂的分析决策在相同码率下获得更好的质量但编码速度慢。这是一个典型的“时间换质量/压缩率”的权衡。5.2 编码循环与数据封装编码循环与解码对称但数据流向相反。AVFrame *frame av_frame_alloc(); AVPacket *pkt av_packet_alloc(); FILE *yuv_file fopen(input.yuv, rb); FILE *mp4_file fopen(output.mp4, wb); // 初始化输出格式上下文和流用于封装此处省略详见下文 // AVFormatContext *ofmt_ctx ...; // AVStream *out_stream avformat_new_stream(ofmt_ctx, NULL); int frame_count 0; while (!feof(yuv_file)) { // 1. 从YUV文件读取一帧数据到AVFrame read_yuv420p_frame(frame, yuv_file, enc_ctx-width, enc_ctx-height); // 2. 设置帧的序号和时间戳 frame-pts frame_count; // PTS frame_index frame_count; // 3. 发送原始帧到编码器 int ret avcodec_send_frame(enc_ctx, frame); if (ret 0) { break; } // 4. 循环接收编码后的包 while (ret 0) { ret avcodec_receive_packet(enc_ctx, pkt); if (ret AVERROR(EAGAIN) || ret AVERROR_EOF) { break; // 需要更多输入或编码器已刷新完毕 } else if (ret 0) { // 真实错误 break; } // 5. 重要重新计算包的时间戳从编码时间基转换到流时间基 av_packet_rescale_ts(pkt, enc_ctx-time_base, out_stream-time_base); pkt-stream_index out_stream-index; // 6. 将包写入输出文件或封装格式上下文 // av_interleaved_write_frame(ofmt_ctx, pkt); // 封装时用 fwrite(pkt-data, 1, pkt-size, mp4_file); // 直接写裸流 av_packet_unref(pkt); } } // 7. 刷新编码器发送NULL帧 avcodec_send_frame(enc_ctx, NULL); while (avcodec_receive_packet(enc_ctx, pkt) 0) { // ... 处理最后的编码包 }关键点avcodec_send_frame和avcodec_receive_packet的返回值需要仔细处理。EAGAIN表示编码器需要更多输入帧才能产生输出包这是正常情况不是错误。时间戳转换编码器工作在enc_ctx-time_base下但最终写入容器如MP4时容器流有自己的时间基out_stream-time_base。必须用av_packet_rescale_ts进行转换否则播放器无法正确理解帧的播放时序。直接写裸流 vs 封装上面的例子直接将H.264裸流Annex B格式通常以00 00 00 01为NALU起始码写入文件。这种文件可以被一些特殊播放器识别但更通用的做法是将其封装进MP4等容器。封装需要创建AVFormatContext添加流写入头信息并使用av_interleaved_write_frame按正确时序交错写入音视频包。6. 项目应用场景与高级话题掌握了基础的编解码我们就可以将其应用到更复杂的项目中。6.1 场景一实时音视频通话RTC在WebRTC或自研RTC系统中编解码是核心组件。与文件编码不同实时编码有严苛的延迟要求。低延迟编码参数设置tunezerolatencyprofilebaseline减少B帧解码依赖简单gop_size很小或为无穷大全I帧避免因丢帧导致后续帧无法解码。码率控制使用实时码率控制模式如x264的VBV或abr。编码器需要根据网络反馈动态调整码率防止网络拥塞。硬件编码为了降低CPU负载和进一步减少延迟会使用硬件编码器如Intel的QSV、NVIDIA的NVENC、AMD的AMF。在FFmpeg中可以通过指定特定的编码器名称如h264_qsv,h264_nvenc来使用。硬件编码的API调用流程与软件编码基本一致但初始化和参数设置可能有所不同。6.2 场景二短视频处理与转码类似抖音的视频处理后台需要对用户上传的视频进行转码生成多种清晰度如360p, 720p的版本以适应不同网络条件。滤镜链在解码后、编码前可以插入libavfilter滤镜链进行处理。例如添加水印、缩放分辨率、调整亮度对比度、进行美颜等。FFmpeg的滤镜系统功能强大可以用字符串描述复杂的处理流程。多路输出一个解码器连接多个不同参数的编码器和输出格式上下文实现“一次解码多路转码”。性能优化多线程解码/编码设置codec_ctx-thread_count。对于解码FFmpeg支持帧级和切片级多线程。对于x264编码可以设置av_dict_set(opts, threads, auto, 0)。硬件加速使用hwaccel如CUDA, DXVA2, VideoToolbox来加速解码使用硬件编码器加速编码。这能极大提升吞吐量是云转码服务的标配。6.3 场景三自定义流媒体协议与封装有时需要将音视频流封装进自定义的传输协议中。例如将H.264和AAC流打包成特定的RTP负载或自定义一种文件格式。理解复用器Muxer你需要实现一个FFmpeg的AVOutputFormat并注册它。这需要你深刻理解封装格式的规范如MP4的ftyp,moov,mdat盒子结构。时间戳同步音视频流有各自独立的时间轴AVStream-time_base。复用器必须根据PTS呈现时间戳和DTS解码时间戳正确地将音视频包交错排列确保播放器能平滑播放并保持音画同步。音画不同步的根源往往就是时间戳处理错误。7. 调试、性能分析与避坑指南音视频编程调试起来比较“玄学”因为错误可能表现为花屏、卡顿、音画不同步等而非简单的程序崩溃。7.1 常用调试工具FFprobe你的第一道诊断工具。ffprobe -show_streams input.mp4可以详细显示容器内所有流的编码信息、时间基、时长、码率等。当你的程序输出文件有问题时先用ffprobe看看和正常文件有何不同。Visual Studio Code / GDB用于设置断点查看AVPacket和AVFrame内部的数据。特别关注pts,dts,size,width/height,linesize等字段。性能分析器如perf(Linux),Instruments(macOS),VTune(Windows/Linux)。编码解码是计算密集型任务需要定位热点函数。你可能会发现大部分时间花在x264核心编码函数或sws_scale色彩转换上。7.2 典型问题排查表问题现象可能原因排查思路与解决方案解码后花屏/绿屏1. 像素格式不匹配。2.linesize处理错误。3. 解码器未正确初始化或参数错误。4. 输入数据不完整或损坏。1. 检查AVFrame-format是否为预期的YUV420P等。2. 确认读写数据时按linesize逐行操作。3. 用ffprobe检查源文件编码信息与代码中AVCodecParameters对比。4. 尝试用ffmpeg命令行解码同一文件对比结果。编码输出文件无法播放1. 缺少关键头信息如H.264的SPS/PPS。2. 时间戳错误或未设置。3. 封装格式容器头信息写入错误。1. 确保编码器在输出第一个数据包前已通过extradata输出编码头。检查pkt-flags AV_PKT_FLAG_KEY。2. 检查并正确设置AVFrame-pts并确认在封装时进行了时间戳转换。3. 确保正确调用了avformat_write_header。直接写裸流时确保文件以SPS/PPS NALU开头。音画不同步1. 音频和视频流的时间基time_base不一致或转换错误。2. 视频帧率不稳定或编码/解码引入延迟。3. 音频重采样或视频缩放耗时不一致导致处理延迟不同。1.根本原因99%是时间戳。仔细检查解码后AVFrame-pts编码前后AVPacket-pts/dts以及封装时的av_packet_rescale_ts。2. 使用固定的帧率模式CFR避免可变帧率VFR。3. 测量并平衡音视频处理流水线的耗时。编码速度慢1. 编码器preset设置过慢如veryslow。2. 分辨率或帧率过高。3. 未启用多线程。4. 使用了软件编码未启用硬件加速。1. 根据业务需求调整preset实时应用用veryfast或ultrafast。2. 考虑降低分辨率或帧率。3. 设置enc_ctx-thread_count或x264的threads参数。4. 调研并切换到硬件编码器。内存泄漏AVPacket,AVFrame,AVFormatContext等结构体未正确释放。1. 对于av_xxx_alloc分配的对象必须用对应的av_xxx_free释放。2. 对于avformat_open_input打开的上下文用avformat_close_input释放。3. 使用Valgrind等内存检测工具定期检查。7.3 我的几点实操心得从命令行开始验证在写C代码实现一个复杂流程前先用ffmpeg命令行实现相同的功能。命令行的参数就是对你代码逻辑的验证。例如你想测试编码参数先用ffmpeg -i input.mp4 -c:v libx264 -preset medium -b:v 1M output.mp4如果命令行成功了再将其翻译成C代码。理解“引用计数”FFmpeg很多对象使用引用计数管理内存。av_packet_ref和av_packet_unref,av_frame_ref和av_frame_unref必须成对使用。浅拷贝一个AVPacket后修改其数据会影响原包。当需要长时间持有一个包或帧时考虑使用av_packet_clone或av_frame_clone进行深拷贝。错误处理要细致FFmpeg函数返回值丰富AVERROR(EAGAIN),AVERROR_EOF都不是真正的错误而是流程状态。一定要根据每个函数的文档仔细处理返回值不能简单地认为0就是失败。线程安全AVCodecContext通常不是线程安全的。如果要在多线程中同时编码多个流请为每个流创建独立的上下文。解码器可以设置thread_count来内部多线程解码单个流。持续学习音视频编解码标准H.266/VVC, AV1和硬件加速技术Vulkan Video, Intel oneVPL在快速发展。关注FFmpeg的更新日志和邮件列表了解新API和废弃的旧API。