📦 项目地址 : github.com/anilbeesett...
这份文档旨在为开发者提供 nextlib 的全景视图,涵盖模块设计、FFmpeg 原生层深度原理、关键渲染流程及开发注意事项。
目录 (Table of Contents)
- 核心架构概览
- 模块详解
- [A. media3ext 模块 (核心解码与播放)](#A. media3ext 模块 (核心解码与播放) "#a-media3ext-%E6%A8%A1%E5%9D%97%E6%A0%B8%E5%BF%83%E8%A7%A3%E7%A0%81%E4%B8%8E%E6%92%AD%E6%94%BE")
- [B. mediainfo 模块 (媒体分析与帧提取)](#B. mediainfo 模块 (媒体分析与帧提取) "#b-mediainfo-%E6%A8%A1%E5%9D%97%E5%AA%92%E4%BD%93%E5%88%86%E6%9E%90%E4%B8%8E%E5%B8%A7%E6%8F%90%E5%8F%96")
- [FFmpeg 核心库深度解析](#FFmpeg 核心库深度解析 "#3-ffmpeg-%E6%A0%B8%E5%BF%83%E5%BA%93%E6%B7%B1%E5%BA%A6%E8%A7%A3%E6%9E%90-native-libraries-deep-dive")
- 构建系统与依赖管理
- 关键技术笔记与原理说明
- 核心流程示例
- 开发避坑指南
- 音视频开发基础知识
- 常见问题与调试技巧
1. 核心架构概览
nextlib 是一个基于 FFmpeg 增强 Android Media3 (ExoPlayer) 功能的库,填补了原生 Media3 在格式兼容性(如无损音频、视频软解支持)上的空白。
模块拓扑 (Module Topology)
设计哲学 (Design Philosophy)
- 模块化分离 :
media3ext专注实时播放解码,mediainfo专注元数据分析与缩略图提取,两者共享 FFmpeg 底层但互不依赖。 - Feed-and-Drain 解码模型 :采用 Media3 的
SimpleDecoder框架,实现异步的avcodec_send_packet/avcodec_receive_frame解码循环。 - JNI 高效内存访问:大量使用 Direct ByteBuffer 实现 Java 与 C++ 之间的零拷贝数据传输。
- 硬件加速渲染 :视频通过
ANativeWindow直接写入屏幕缓冲区,利用系统硬件完成最终的色彩转换。
2. 模块详解 (Module Deep Dive)
A. media3ext 模块 (核心解码与播放)
用途与定位
提供 Media3 官方不直接支持的音视频格式的软件解码支持。该模块是 nextlib 的核心播放组件,通过 FFmpeg 实现对 MKV、FLAC、Vorbis、TrueHD 等格式的软解码。
Java/Kotlin 层核心类
| 类名 | 职责 | 关键方法/特性 |
|---|---|---|
FfmpegVideoDecoder |
视频软解码器,继承 SimpleDecoder |
ffmpegInitialize(), ffmpegSendPacket(), ffmpegReceiveFrame(), ffmpegRenderFrame() |
FfmpegAudioDecoder |
音频软解码器,继承 SimpleDecoder |
ffmpegInitialize(), ffmpegDecode(), growOutputBuffer() (动态缓冲区) |
FfmpegVideoRenderer |
视频渲染器,继承 DecoderVideoRenderer |
创建 FfmpegVideoDecoder、管理 Surface、格式支持检测 |
FfmpegAudioRenderer |
音频渲染器,继承 DecoderAudioRenderer |
创建 FfmpegAudioDecoder、PCM 格式输出决策(16bit vs Float) |
FfmpegLibrary |
库加载器/守门员 | setLibraries(), isAvailable(), getVersion(), supportsFormat(), getCodecName() |
NextRenderersFactory |
推荐使用的渲染器工厂 | 继承 DefaultRenderersFactory,在系统解码器基础上追加 FFmpeg 解码器 |
FFmpegOnlyRenderersFactory |
纯 FFmpeg 渲染器工厂 | 仅使用 FFmpeg 解码器(不使用系统 MediaCodec) |
NextTextRenderer |
文本/字幕渲染器 | 包装 TextRenderer 并继承 OffsetRenderer,支持字幕时间轴校正 |
OffsetRenderer |
时间偏移渲染器基类 | syncOffsetMilliseconds (固定延迟), syncSpeedMultiplier (速度倍率) |
解码器工作流程 (Decoder Workflow)
视频解码器 (FfmpegVideoDecoder):
scss
初始化 (Constructor)
↓
ffmpegInitialize(codecName, extraData, threads) → 返回 nativeContext
↓
decode() 循环:
├── ffmpegSendPacket(nativeContext, inputData, timeUs)
│ └── 调用 avcodec_send_packet() 将压缩数据送入解码器
└── ffmpegReceiveFrame(nativeContext, outputMode, outputBuffer, decodeOnly)
└── 调用 avcodec_receive_frame() 获取解码后的 YUV 帧
↓
renderToSurface(outputBuffer, surface)
└── ffmpegRenderFrame() → 通过 ANativeWindow 渲染到屏幕
音频解码器 (FfmpegAudioDecoder):
scss
初始化 (Constructor)
↓
ffmpegInitialize(codecName, extraData, outputFloat, sampleRate, channelCount)
↓
decode() 循环:
├── ffmpegDecode(nativeContext, inputData, outputBuffer)
│ ├── 调用 avcodec_send_packet() + avcodec_receive_frame()
│ ├── 调用 swr_convert() 进行音频重采样
│ └── 如缓冲区不足,回调 growOutputBuffer() 扩大缓冲区
└── 获取 channelCount / sampleRate (首次解码后确定)
ExtraData 处理 (初始化数据提取)
某些编码格式需要额外的初始化数据(ExtraData)才能正确解码。这些数据包含了解码器工作所必需的参数,如分辨率、帧率、编码配置等。
| 格式 | ExtraData 内容 | 处理方式 |
|---|---|---|
| H.264 | SPS + PPS | 连接 initializationData[0] (SPS) 和 initializationData[1] (PPS) |
| H.265/HEVC | VPS + SPS + PPS | 使用 initializationData[0] |
| AAC | AudioSpecificConfig | 使用 initializationData[0] |
| Opus | OpusHead + OpusTags | 使用 initializationData[0] |
| ALAC | ALAC Atom (含 Magic Cookie) | 将 initializationData[0] 包装为 ALAC Atom 格式 |
| Vorbis | Header0 + Header1 | 拼接两个头部,添加长度前缀 |
什么是 SPS、PPS、VPS?
在 H.264 和 H.265 编码中,解码器需要一些**参数集(Parameter Sets)**来初始化解码器上下文。这些参数集包含了视频流的配置信息,必须在解码任何实际视频帧之前提供给解码器。
SPS (Sequence Parameter Set - 序列参数集):
SPS 包含了适用于整个视频序列的全局参数,是解码器工作的"基础配置"。
scss
SPS 包含的关键信息:
├── profile_idc → 编码档次 (Baseline/Main/High)
├── level_idc → 编码级别 (如 4.0 对应 1080p30)
├── seq_parameter_set_id → SPS 的 ID 标识
├── log2_max_frame_num → 帧号最大值
├── pic_order_cnt_type → 画面顺序计数类型
├── max_num_ref_frames → 最大参考帧数量 (影响 B/P 帧解码)
├── pic_width_in_mbs → 画面宽度 (宏块单位)
├── pic_height_in_map_units → 画面高度 (宏块单位)
├── frame_mbs_only_flag → 是否只有帧编码
├── vui_parameters → 可选的用户信息 (帧率、宽高比等)
└── ...
为什么需要 SPS:
- 解码器需要知道视频的分辨率才能分配正确的缓冲区大小
- 需要知道编码档次和级别才能确定使用哪些解码工具
- 需要知道最大参考帧数量才能正确管理参考帧队列
PPS (Picture Parameter Set - 图像参数集):
PPS 包含了适用于单个图像的参数,是对 SPS 的补充。
objectivec
PPS 包含的关键信息:
├── pic_parameter_set_id → PPS 的 ID 标识
├── seq_parameter_set_id → 引用的 SPS ID
├── entropy_coding_mode_flag → 熵编码模式 (CAVLC/CABAC)
├── num_slice_groups_minus1 → 切片组数量
├── num_ref_idx_l0_default_active → L0 列表默认参考帧数
├── num_ref_idx_l1_default_active → L1 列表默认参考帧数
├── weighted_pred_flag → 是否启用加权预测
├── pic_init_qp_minus26 → 初始量化参数 (QP)
├── deblocking_filter_control → 去块滤波器控制
└── ...
为什么需要 PPS:
- 熵编码模式决定了如何解析压缩数据
- 切片信息影响解码的并行处理能力
- 量化参数影响解码后的图像质量
VPS (Video Parameter Set - 视频参数集):
VPS 是 H.265/HEVC 引入的新概念,用于支持可伸缩视频编码 (SVC) 和多图层编码。
erlang
VPS 包含的关键信息:
├── vps_parameter_set_id → VPS 的 ID 标识
├── vps_max_layers_minus1 → 最大图层数量
├── vps_max_sub_layers_minus1 → 最大子图层数量
├── vps_num_layer_sets_minus1 → 图层集合数量
├── 图层依赖关系 → 哪些图层依赖哪些参考层
├── 各图层的分辨率/帧率信息 → 不同质量层的参数
└── ...
为什么需要 VPS:
- H.265 支持多层编码(如基础层 720p + 增强层 1080p)
- VPS 描述了各层之间的依赖关系
- 解码器根据 VPS 决定解码哪些层
三者的关系:
makefile
H.264:
┌─────────────────────────────────────┐
│ SPS (序列参数集) │
│ - 全局配置 (分辨率、编码档次) │
│ - 一个视频序列通常只有一个 SPS │
└──────────────┬──────────────────────┘
│ 引用
┌──────────────▼──────────────────────┐
│ PPS (图像参数集) │
│ - 图像级配置 (熵编码、切片) │
│ - 一个序列可能有多个 PPS │
└──────────────┬──────────────────────┘
│ 引用
┌──────────────▼──────────────────────┐
│ 实际视频帧 (I/P/B 帧) │
│ - 引用 PPS 获取解码参数 │
│ - PPS 间接引用 SPS │
└─────────────────────────────────────┘
H.265/HEVC:
┌─────────────────────────────────────┐
│ VPS (视频参数集) │
│ - 多层编码配置 │
└──────────────┬──────────────────────┘
│ 引用
┌──────────────▼──────────────────────┐
│ SPS (序列参数集) │
│ - 单层的全局配置 │
└──────────────┬──────────────────────┘
│ 引用
┌──────────────▼──────────────────────┐
│ PPS (图像参数集) │
│ - 图像级配置 │
└──────────────┬──────────────────────┘
│ 引用
┌──────────────▼──────────────────────┐
│ 实际视频帧 │
└─────────────────────────────────────┘
在 nextlib 中的处理:
java
// FfmpegVideoDecoder.java
private static byte[] getExtraData(String mimeType, List<byte[]> initializationData) {
if (initializationData.isEmpty()) return null;
switch (mimeType) {
case MimeTypes.VIDEO_H264 -> {
// 对于 H.264,连接 SPS 和 PPS
byte[] sps = initializationData.get(0);
byte[] pps = initializationData.get(1);
byte[] extraData = new byte[sps.length + pps.length];
System.arraycopy(sps, 0, extraData, 0, sps.length);
System.arraycopy(pps, 0, extraData, sps.length, pps.length);
return extraData;
}
case MimeTypes.VIDEO_H265 -> {
// 对于 H.265,使用完整的 initializationData[0] (已包含 VPS+SPS+PPS)
return initializationData.get(0);
}
default -> null;
}
}
在 C++ 中的使用:
cpp
// ffvideo.cpp - createVideoContext()
if (extraData) {
jsize size = env->GetArrayLength(extraData);
codecContext->extradata_size = size;
// 分配额外空间 (FFmpeg 要求 extraData 后有 AV_INPUT_BUFFER_PADDING_SIZE 的填充)
codecContext->extradata = (uint8_t *) av_malloc(size + AV_INPUT_BUFFER_PADDING_SIZE);
env->GetByteArrayRegion(extraData, 0, size, (jbyte *) codecContext->extradata);
}
什么是 ALAC Atom?
ALAC (Apple Lossless Audio Codec) 是苹果开发的无损音频编码格式。与 FLAC 类似,它可以完全还原原始音频数据,压缩率约为 50-60%。
Magic Cookie (魔法曲奇):
ALAC 编码需要一个特殊的配置数据块,称为 "Magic Cookie"。它包含了:
arduino
Magic Cookie 内容:
├── 音频格式版本
├── 最大每帧样本数
├── 兼容版本
├── 位深度 (16/20/24 bit)
├── 平均比特率
├── 采样率
├── 声道数
├── 声道布局
└── 其他解码器特定参数
ALAC Atom 结构:
在 MP4/M4A 容器中,Magic Cookie 被包装在一个称为 "alac" 的 Atom(MP4 的数据块)中:
scss
ALAC Atom 结构:
┌─────────────────────────────────────┐
│ atom_size (4 字节) │
│ → 整个 Atom 的大小 (12 + cookie) │
├─────────────────────────────────────┤
│ atom_type (4 字节) │
│ → 0x616c6163 = "alac" (ASCII) │
├─────────────────────────────────────┤
│ version (1 字节) + flags (3 字节) │
│ → 通常为 0 │
├─────────────────────────────────────┤
│ Magic Cookie 数据 │
│ → 实际的解码器配置参数 │
└─────────────────────────────────────┘
为什么需要包装为 ALAC Atom:
FFmpeg 的 ALAC 解码器期望 ExtraData 是完整的 ALAC Atom 格式,而不是原始的 Magic Cookie。这是因为:
- 历史原因:早期 FFmpeg 实现直接解析 MP4 容器中的 alac Atom
- 版本兼容:Atom 头部包含版本信息,解码器可以据此调整行为
- 完整性检查:Atom 大小字段可用于验证数据完整性
在 nextlib 中的处理:
java
// FfmpegAudioDecoder.java
private static byte[] getAlacExtraData(List<byte[]> initializationData) {
// initializationData[0] 只包含 Magic Cookie
// 需要将其包装为完整的 ALAC Atom
byte[] magicCookie = initializationData.get(0);
int alacAtomLength = 12 + magicCookie.length; // 4(size) + 4(type) + 4(ver/flags) + cookie
ByteBuffer alacAtom = ByteBuffer.allocate(alacAtomLength);
// 写入 Atom 大小 (大端序)
alacAtom.putInt(alacAtomLength);
// 写入 Atom 类型 "alac" (0x616c6163)
alacAtom.putInt(0x616c6163);
// 写入版本和标志 (全 0)
alacAtom.putInt(0);
// 写入 Magic Cookie 数据
alacAtom.put(magicCookie, 0, magicCookie.length);
return alacAtom.array();
}
图示对比:
css
Media3 提供的 initializationData[0]:
┌──────────────────────────┐
│ Magic Cookie (可变长度) │
│ → 仅包含解码器参数 │
└──────────────────────────┘
FFmpeg 期望的 ALAC Atom:
┌────────┬────────┬────────┬──────────────────┐
│ 大小 │ 类型 │ 版本 │ Magic Cookie │
│ 4 字节 │ 4 字节 │ 4 字节 │ (可变长度) │
│ │ "alac" │ │ │
└────────┴────────┴────────┴──────────────────┘
其他格式的 ExtraData 说明
AAC - AudioSpecificConfig:
AAC 的 ExtraData 称为 AudioSpecificConfig (ASC),包含:
bash
ASC 内容:
├── audio_object_type → 编码档次 (AAC-LC/HE-AAC/etc)
├── sampling_frequency_index → 采样率索引
├── channel_configuration → 声道配置
├── frame_length_flag → 帧长度标志
├── depends_on_core_coder → 是否依赖核心编码器
└── ...
Opus - OpusHead + OpusTags:
Opus 的 ExtraData 包含两个部分:
scss
OpusHead (必须):
├── 魔数 "OpusHead"
├── 版本号
├── 声道数
├── 预跳过样本数
├── 输入采样率
└── 输出增益
OpusTags (可选):
├── 供应商字符串
├── 用户评论
└── 元数据
Vorbis - 头部拼接:
Vorbis 需要三个头部中的前两个:
css
Header 0 (Identification Header):
├── 魔数
├── 版本号
├── 声道数
├── 采样率
├── 最大比特率
├── 标称比特率
└── 最小比特率
Header 1 (Comment Header):
├── 供应商
├── 用户评论
└── 元数据
Header 2 (Setup Header) → 通常不在此处传递
MIME 到 FFmpeg 解码器映射表
FfmpegLibrary.getCodecName() 实现了 MIME 类型到 FFmpeg 解码器名称的映射:
音频映射:
AUDIO_AAC→aacAUDIO_MPEG→mp3AUDIO_AC3→ac3AUDIO_E_AC3→eac3AUDIO_TRUEHD→truehdAUDIO_DTS→dcaAUDIO_VORBIS→vorbisAUDIO_OPUS→opusAUDIO_FLAC→flacAUDIO_ALAC→alacAUDIO_MLAW→pcm_mulawAUDIO_ALAW→pcm_alaw
视频映射:
VIDEO_H264→h264VIDEO_H265→hevcVIDEO_MPEG→mpegvideoVIDEO_MPEG2→mpeg2videoVIDEO_VP8→libvpxVIDEO_VP9→libvpx-vp9
NextRenderersFactory 扩展渲染器插入策略
NextRenderersFactory 通过重写 buildAudioRenderers()、buildVideoRenderers() 和 buildTextRenderers() 方法,将 FFmpeg 解码器插入到渲染器列表中:
kotlin
// 扩展渲染器插入位置策略
when (extensionRendererMode) {
EXTENSION_RENDERER_MODE_OFF → 不插入 FFmpeg 解码器
EXTENSION_RENDERER_MODE_PREFER → 插入到系统解码器之前 (index = size - 1)
EXTENSION_RENDERER_MODE_ON → 插入到系统解码器之后 (index = size)
}
推荐配置 :使用 EXTENSION_RENDERER_MODE_PREFER 可以让 FFmpeg 解码器优先于系统解码器工作,从而支持更多格式。
原生层 (C++) 架构
| 文件 | 职责 | 关键函数 |
|---|---|---|
ffmain.cpp |
JNI 入口,库版本查询 | JNI_OnLoad(), ffmpegGetVersion(), ffmpegHasDecoder() |
ffcommon.h/cpp |
公共工具函数 | releaseContext(), getCodecByName(), logError() |
ffvideo.cpp |
视频解码 JNI 实现 | createVideoContext(), ffmpegSendPacket(), ffmpegReceiveFrame(), ffmpegRenderFrame() |
ffaudio.cpp |
音频解码 JNI 实现 | createContext(), decodePacket(), ffmpegDecode() |
关键数据结构
JniContext (视频):
cpp
struct JniContext {
AVCodecContext *codecContext; // FFmpeg 解码上下文
SwsContext *swsContext; // 色彩转换上下文 (YUV → YV12)
ANativeWindow *native_window; // Android 原生窗口
jobject surface; // Java Surface 引用
// ... 缓存的 JNI 字段和方法 ID
};
B. mediainfo 模块 (媒体分析与帧提取)
用途与定位
不依赖播放器上下文,快速获取媒体文件的元数据(格式、时长、流信息)和视频帧缩略图。启动速度极快,适合用于文件浏览器、媒体库预览等场景。
Kotlin 层核心类
| 类名 | 职责 | 关键方法/特性 |
|---|---|---|
MediaInfo |
媒体信息数据类 | 包含格式、时长、视频流、音频流、字幕流、章节列表 |
MediaInfoBuilder |
媒体信息构建器 | from(filePath/descriptor/uri), build(), JNI 回调方法 |
MediaThumbnailRetriever |
缩略图检索器 | getEmbeddedPicture(), getFrameAtTime(), getFrameAtIndex() |
FrameLoader |
帧加载器 | nativeLoadFrame(), nativeGetFrame(), nativeRelease() |
VideoStream |
视频流数据类 | 索引、标题、编解码器、语言、码率、帧率、宽高、旋转角度 |
AudioStream |
音频流数据类 | 索引、标题、编解码器、语言、采样格式、采样率、声道数 |
SubtitleStream |
字幕流数据类 | 索引、标题、编解码器、语言 |
Chapter |
章节数据类 | 索引、起始时间、结束时间、标题 |
PathUtil |
URI 转路径工具 | 处理 StorageProvider、DownloadsProvider、MediaProvider 等 |
JNI 回调机制
MediaInfoBuilder 通过 JNI 回调逐步构建 MediaInfo 对象:
kotlin
// JNI 回调方法 (由 C++ 层调用)
@Keep
private fun onMediaInfoFound(fileFormatName: String, duration: Long)
private fun onVideoStreamFound(index, title, codecName, language, disposition,
bitRate, frameRate, frameWidth, frameHeight, rotation,
frameLoaderContext)
private fun onAudioStreamFound(index, title, codecName, language, disposition,
bitRate, sampleFormat, sampleRate, channels, channelLayout)
private fun onSubtitleStreamFound(index, title, codecName, language, disposition)
private fun onChapterFound(index, title, start, end)
private fun onError()
数据源支持
MediaInfoBuilder.from() 支持三种数据源:
- 文件路径 :
from(filePath: String)→ 直接调用avformat_open_input() - FileDescriptor :
from(descriptor: ParcelFileDescriptor)→ 使用pipe:协议 - URI :
from(context, uri)→ 智能解析,优先尝试转本地路径,失败则使用 FileDescriptor
缩略图提取流程
scss
MediaThumbnailRetriever.getFrameAtTime(timeMs)
↓
FrameLoader.nativeGetFrame(frameLoaderContextHandle, timeMs)
↓
frame_extractor_get_frame() (C++)
├── av_seek_frame() → Seek 到目标时间
├── read_frame() → 循环读取 AVPacket 并解码
├── sws_getContext() → 创建缩放上下文 (任意格式 → RGBA)
├── AndroidBitmap_lockPixels() → 锁定 Bitmap 像素
├── sws_scale() → 缩放并转换为 RGBA
└── AndroidBitmap_unlockPixels() → 解锁并返回 Bitmap
原生层 (C++) 架构
| 文件 | 职责 | 关键函数 |
|---|---|---|
main.cpp |
JNI 入口 | JNI_OnLoad(), JNI_OnUnload() |
mediainfo.cpp |
媒体信息解析核心 | media_info_build(), onVideoStreamFound(), onAudioStreamFound() |
frame_extractor.cpp |
帧提取器核心 | read_frame(), frame_extractor_load_frame(), frame_extractor_get_frame() |
media_thumbnail_retriever.cpp |
缩略图检索器 JNI | getEmbeddedPicture(), getFrameAtTime(), getFrameAtIndex() |
frame_loader_context.h/cpp |
帧加载上下文 | FrameLoaderContext 结构体,handle 与指针互转 |
utils.h/cpp |
JNI 工具 | 缓存 Java 方法 ID,变参方法调用辅助 |
log.h |
日志宏 | LOGV/LOGD/LOGI/LOGW/LOGE |
视频旋转角度处理
mediainfo.cpp 中通过两种方式获取视频旋转角度:
- Metadata Rotate Tag :读取流元数据中的
"rotate"字段 - Display Matrix :通过
av_stream_get_side_data()获取AV_PKT_DATA_DISPLAYMATRIX,调用av_display_rotation_get()计算旋转角度
cpp
// 优先级:Display Matrix > Metadata Rotate
int rotation = 0;
AVDictionaryEntry *rotateTag = av_dict_get(stream->metadata, "rotate", nullptr, 0);
if (rotateTag && *rotateTag->value) {
rotation = atoi(rotateTag->value);
}
uint8_t *displaymatrix = av_stream_get_side_data(stream, AV_PKT_DATA_DISPLAYMATRIX, nullptr);
if (displaymatrix) {
double theta = av_display_rotation_get((int32_t *) displaymatrix);
rotation = (int) (-theta) % 360; // 取反并归一化
}
3. FFmpeg 核心库深度解析 (Native Libraries Deep Dive)
💡 命名规范 (Prefix Convention):
av: 代表 Audio/Video 。例如AVFrame(原始帧)、AVPacket(压缩包)。sw: 代表 Software (软件算法实现)。这暗示了该操作在 CPU 上完成,而非 GPU 硬件加速。
sws= Software Scaling (libswscale)swr= Software Resampling (libswresample)
各子库详尽职责:
1. libavformat (封装/解封装库)
- 核心职责 : 处理媒体文件的容器层(Container Format)。它不关心音视频内容本身,只负责"打开"文件(如 MP4, MKV)并从中分离出独立的音频流、视频流和字幕流。这个过程称为解封装 (Demuxing)。
- 工作流程 : 当接收到一个媒体文件时,
libavformat会首先探测其格式,然后读取文件头信息,定位到各个数据流。之后,它可以按顺序读取每个流的数据块,并将其打包成AVPacket结构体。AVPacket包含压缩后的原始数据和时间戳等元信息。 - 核心 API :
avformat_open_input():打开媒体文件,创建AVFormatContextavformat_find_stream_info():获取流信息(编解码器参数、时长等)av_read_frame():读取一个AVPacketav_seek_frame():Seek 到指定时间戳avformat_close_input():关闭文件,释放资源
- 项目应用 : 在
mediainfo模块中解析文件元数据;在播放流程中提取音频和视频的AVPacket。
2. libavcodec (编解码库)
- 核心职责 : FFmpeg 最核心、最庞大的部分,负责将
AVPacket(压缩数据)解码成原始的视频帧(AVFrame)或音频采样。这个过程称为解码 (Decoding)。 - 工作流程 : 接收
AVPacket后,libavcodec会查找对应的解码器(如 H.264, AAC),然后通过avcodec_send_packet和avcodec_receive_frame的异步机制,将压缩数据转化为原始数据。 - 核心 API :
avcodec_find_decoder():根据 codec ID 查找解码器avcodec_alloc_context3():创建解码器上下文avcodec_open2():打开解码器avcodec_send_packet():发送压缩数据到解码器(异步)avcodec_receive_frame():从解码器获取原始数据(异步)avcodec_flush_buffers():清空内部缓冲区(用于 Seek 后重置)avcodec_free_context():释放解码器上下文
- 项目应用 : 在
ffvideo.cpp和ffaudio.cpp中通过avcodec_receive_frame解码出原始数据。
3. libswscale (视频图像转换库)
- 核心职责 : 专门处理视频图像的缩放 (Scaling) 、裁剪 (Cropping) 和像素格式转换 (Pixel Format Conversion)。
- 原理 : 视频解码出的格式(如 YUV420P)往往不能直接显示。
sws_scale负责将其转换为系统能理解的特定像素格式(如 AndroidANativeWindow要求的 YV12),并处理分辨率适配。 - 核心 API :
sws_getContext():创建缩放上下文sws_scale():执行缩放和格式转换sws_freeContext():释放缩放上下文
- 项目应用 : 在
ffvideo.cpp中将各种解码后的 YUV 格式转换为 Android 兼容的 YV12;在frame_extractor.cpp中将任意像素格式转换为 RGBA 用于 Bitmap。
4. libswresample (音频重采样库)
- 核心职责 : 专门处理音频数据的重采样 (Resampling) 、声道布局转换 和样本格式转换。
- 原理 : Android
AudioTrack只能接受特定格式,swr_convert确保解码出的各种音频流都能被系统正确播放。 - 核心 API :
swr_alloc():分配重采样上下文av_opt_set_chlayout():设置输入/输出声道布局av_opt_set_int():设置采样率、样本格式等参数swr_init():初始化重采样上下文swr_convert():执行音频格式转换swr_free():释放重采样上下文
- 项目应用 : 在
ffaudio.cpp中将音频统一转换为 Android 播放器要求的 PCM 格式(16bit 或 Float)。
5. libavutil (通用工具库)
- 核心职责: 这是一个基础库,为 FFmpeg 其他所有库提供底层的公共函数(内存分配、日志记录、数学运算等)。
- 核心 API :
av_malloc()/av_free():内存分配(对齐优化)av_packet_alloc()/av_packet_free():AVPacket 分配av_frame_alloc()/av_frame_free():AVFrame 分配av_err2str():错误码转字符串av_rescale_q():时间戳换算(不同 time_base 之间)av_get_sample_fmt_name():获取音频格式名称av_channel_layout_describe():描述声道布局
- 项目应用: 作为所有 C++ 文件的基础依赖。
核心数据结构详解
AVPacket (压缩数据包)
cpp
typedef struct AVPacket {
int64_t pts; // 显示时间戳 (Presentation Time Stamp)
int64_t dts; // 解码时间戳 (Decode Time Stamp)
uint8_t *data; // 压缩数据指针
int size; // 数据大小
int stream_index; // 流索引 (用于区分音频/视频/字幕流)
int flags; // 标志位 (AV_PKT_FLAG_KEY 表示关键帧)
// ...
} AVPacket;
AVFrame (原始数据帧)
cpp
typedef struct AVFrame {
uint8_t *data[4]; // 原始数据指针 (视频: Y/U/V 平面, 音频: 声道数据)
int linesize[4]; // 每行字节数 (视频: 步长/Stride)
int width, height; // 视频宽高
int64_t pts; // 时间戳
AVPixelFormat format; // 像素格式 (如 AV_PIX_FMT_YUV420P)
int nb_samples; // 音频样本数 (仅音频)
// ...
} AVFrame;
AVCodecContext (解码器上下文)
cpp
typedef struct AVCodecContext {
const AVCodec *codec; // 编解码器
int width, height; // 视频宽高
AVPixelFormat pix_fmt; // 像素格式
int sample_rate; // 音频采样率
AVChannelLayout ch_layout; // 声道布局
uint8_t *extradata; // 额外数据 (SPS/PPS 等)
int extradata_size; // 额外数据大小
void *opaque; // 用户自定义指针 (项目中用于缓存 SwrContext)
// ...
} AVCodecContext;
4. 构建系统与依赖管理
FFmpeg 构建流程
ffmpeg/setup.sh 是自动化构建脚本,负责下载并编译所有依赖:
css
setup.sh 执行流程:
↓
1. 下载源码
├── mbedtls 3.4.1 (HTTPS/TLS 支持)
├── libvpx 1.13.0 (VP8/VP9 支持)
└── FFmpeg 6.0
↓
2. 编译依赖库 (按 ABI 分别编译)
├── buildMbedTLS() → CMake 构建
└── buildLibVpx() → configure + make 构建
↓
3. 编译 FFmpeg
└── buildFfmpeg() → configure + make 构建
├── 启用解码器: vorbis, opus, flac, alac, truehd, h264, hevc, libvpx_vp8, libvpx_vp9 等
├── 启用协议: file, http, https, rtmp, rtp, tls 等
├── 启用库: libswresample, libswscale, libavformat, libavcodec, libavutil
└── 链接 mbedtls 和 libvpx
↓
4. 输出产物
├── ffmpeg/output/include/<abi>/ → 头文件
└── ffmpeg/output/lib/<abi>/ → .so 动态库
支持的 ABI 与架构
| ABI | 架构 | 工具链 | 特殊配置 |
|---|---|---|---|
armeabi-v7a |
ARM 32位 | armv7a-linux-androideabi21-clang |
禁用 NEON |
arm64-v8a |
ARM 64位 | aarch64-linux-android21-clang |
默认启用 NEON |
x86 |
x86 32位 | i686-linux-android21-clang |
禁用汇编优化 |
x86_64 |
x86 64位 | x86_64-linux-android21-clang |
禁用 SSE/AVX |
支持的解码器列表
音频解码器:
- 无损格式:
flac,alac,truehd,mlp - 有损格式:
vorbis,opus,aac,mp3,ac3,eac3,dca,amrnb,amrwb - PCM 格式:
pcm_mulaw,pcm_alaw
视频解码器:
- 主流格式:
h264,hevc(H.265),mpeg2video,mpegvideo - WebM 格式:
libvpx_vp8,libvpx_vp9
Gradle 集成
media3ext/build.gradle.kts 中注册了 ffmpegSetup 任务,在 preBuild 前自动触发 FFmpeg 编译:
kotlin
val ffmpegSetup by tasks.registering(Exec::class) {
workingDir = file("../../ffmpeg")
commandLine("bash", "setup.sh")
}
tasks.named("preBuild").configure {
dependsOn(ffmpegSetup)
}
注意 :mediainfo 模块的 ffmpegSetup 任务默认注释,因为它依赖 media3ext 模块先构建 FFmpeg。
依赖版本 (gradle/libs.versions.toml)
| 依赖 | 版本 |
|---|---|
| Android Gradle Plugin | 9.1.0 |
| Kotlin | 2.3.20 |
| Media3 | 1.10.0 |
| CMake | 3.22.1 |
| NDK | 25.2.9519653 |
5. 关键技术笔记与原理说明 (Key Technical Notes)
A. 为什么选择 YUV 渲染而不是 RGB?
-
效率优势 :由于人眼对亮度(Luma, Y)比色彩(Chroma, U/V)更敏感,YUV 4:2:0 采样比 RGB888 节省约 50% 的带宽和内存占用。具体来说:
- RGB888:每像素 3 字节 (24 bit)
- YUV420P:每像素 1.5 字节 (Y 全采样,U/V 各四分之一)
-
硬件加速 (Hardware Path) :Android 显示系统(SurfaceFlinger)能直接处理 YUV 数据。在
ffvideo.cpp中,我们将解码后的数据转为YV12,利用ANativeWindow直接交给系统,利用 GPU 或专用显示硬件完成最终的 RGB 转换,更加省电且高效。 -
YV12 格式布局 :
scssY 平面:stride × height 字节 V 平面:(stride/2) × (height/2) 字节 (对齐到 16 字节边界) U 平面:(stride/2) × (height/2) 字节 (对齐到 16 字节边界)
B. Dolby TrueHD 的特殊处理逻辑
- 特性:无损压缩编码,通常作为 Dolby Atmos 的载体。
- 问题 :由于其复杂的内部状态,FFmpeg 的标准
avcodec_flush_buffers在 Seek(进度跳转)后有时无法完全重置 TrueHD 解码器状态。这会导致 Seek 后播放出现爆音、静音或解码错误。 - 方案 :在
ffaudio.cpp中,针对 TrueHD 采取了销毁并根据 ExtraData 重新创建解码上下文的策略,强制重置解码器的内部状态机,以确保跳转后的播放稳定性。
cpp
// ffaudio.cpp 中的 TrueHD 特殊处理
if (context->codec_id == AV_CODEC_ID_TRUEHD) {
AVCodecID codecId = context->codec_id;
AVSampleFormat reqFmt = context->request_sample_fmt;
releaseContext(context); // 销毁旧上下文
auto *codec = const_cast<AVCodec *>(avcodec_find_decoder(codecId));
return (jlong) createContext(env, codec, extra_data, (reqFmt == OUTPUT_FORMAT_PCM_FLOAT), -1, -1);
}
avcodec_flush_buffers(context); // 其他格式使用标准 flush
C. 如何新增一种解码格式 (以 VP10 为例)
- FFmpeg 重新编译 :在
ffmpeg/setup.sh的ENABLED_DECODERS变量中添加vp10,确保底层库编译脚本开启了--enable-decoder=vp10。 - MIME 映射 :在
FfmpegLibrary.java的getCodecName()中建立映射关系,例如MimeTypes.VIDEO_VP10 -> "libvpx-vp10"。 - 配置数据提取 :在
FfmpegVideoDecoder.java的getExtraData()中处理该格式所需的初始化数据(如果有)。 - 重新构建 :运行
./gradlew :media3ext:assemble触发 FFmpeg 重新编译。
D. Feed-and-Drain 解码模型详解
这是 Media3 SimpleDecoder 框架的核心模式,也是理解本项目解码流程的关键:
scss
输入队列 (Input Buffers) 解码器 (Decoder) 输出队列 (Output Buffers)
┌─────────────────┐ ┌──────────────┐ ┌──────────────────┐
│ Buffer 0 (编码) │──SendPacket──▶│ │ │ │
│ Buffer 1 (编码) │──SendPacket──▶│ FFmpeg │──Receive─▶│ Buffer 0 (解码帧)│
│ Buffer 2 (编码) │──SendPacket──▶│ Decoder │──Receive─▶│ Buffer 1 (解码帧)│
│ ... │ │ │ │ ... │
└─────────────────┘ └──────────────┘ └──────────────────┘
关键特性:
- 异步解码 :
avcodec_send_packet()和avcodec_receive_frame()是异步的。发送一个 Packet 后,不一定立即能收到 Frame(B 帧需要参考后续帧才能解码)。 - 队列管理:Java 层维护固定数量的输入/输出缓冲区,通过循环实现持续解码。
- EAGAIN 处理 :当
avcodec_send_packet()返回AVERROR(EAGAIN)时,表示解码器内部队列已满,需要先调用avcodec_receive_frame()取出已解码的帧。
E. 时间戳处理 (PTS/DTS)
- PTS (Presentation Time Stamp):帧应该被显示的时间戳。
- DTS (Decode Time Stamp):帧应该被解码的时间戳。
- 区别:对于 B 帧(双向预测帧),解码顺序和显示顺序不同,因此 DTS ≠ PTS。
- 项目中的处理 :在
ffvideo.cpp中,packet.pts = input_time将 Java 层传入的微秒级时间戳直接赋值给 PTS。FFmpeg 内部会处理 DTS 的换算。
F. 音频重采样 (Resampling) 原理
为什么需要音频重采样?
音频重采样是音频解码流程中必不可少的环节,主要原因包括:
1. 解码器输出格式不固定
不同的音频编码格式解码后会产生不同的样本格式:
| 编码格式 | 解码后常见格式 | 说明 |
|---|---|---|
| AAC | AV_SAMPLE_FMT_FLTP |
32位浮点,平面存储 |
| MP3 | AV_SAMPLE_FMT_S16P |
16位整数,平面存储 |
| FLAC | AV_SAMPLE_FMT_S32P |
32位整数,平面存储 |
| Opus | AV_SAMPLE_FMT_FLT |
32位浮点,交错存储 |
| Vorbis | AV_SAMPLE_FMT_FLTP |
32位浮点,平面存储 |
问题 :Android AudioTrack 只接受特定格式:
ENCODING_PCM_16BIT(16位整数)ENCODING_PCM_FLOAT(32位浮点)
解决 :必须通过 swr_convert() 将解码后的各种格式统一转换为 Android 能接受的格式。
2. 采样率不匹配
不同音频流的采样率各不相同:
yaml
常见采样率:
├── 8000 Hz → 电话语音
├── 22050 Hz → 低质量网络音频
├── 44100 Hz → CD 标准
├── 48000 Hz → 视频音频标准
└── 96000 Hz → 高清音频
问题:
- 视频中的音频可能是 48000 Hz
- 但音频硬件/声卡可能只支持 44100 Hz 输出
- 或者应用需要统一所有音频到固定采样率
解决:重采样将源采样率转换为目标采样率。
3. 声道布局转换
音频流的声道数量差异很大:
scss
声道布局示例:
├── Mono (1.0) → 单声道
├── Stereo (2.0) → 立体声(左+右)
├── 5.1 Surround → 6声道环绕声
└── 7.1 Surround → 8声道环绕声
问题 :用户用普通耳机听 5.1 声道音频时,需要将 6 声道下混(Downmix)为 2 声道立体声。
解决 :swr_convert() 可以自动处理声道布局转换。
4. 平面格式 vs 交错格式
FFmpeg 支持两种数据存储方式:
scss
平面格式 (Planar): 交错格式 (Interleaved):
┌─────┬─────┬─────┐ ┌─────┬─────┬─────┬─────┐
│ LLL │ RRR │ ... │ │ L R │ L R │ L R │ ... │
└─────┴─────┴─────┘ └─────┴─────┴─────┴─────┘
每个声道独立存储 声道数据交替存储
问题 :FFmpeg 解码器通常输出平面格式 (如 FLTP),而 Android AudioTrack 需要交错格式 (如 S16)。
解决:重采样同时完成格式转换和存储方式转换。
重采样涉及的三个维度
音频重采样涉及三个维度的转换:
- 采样格式转换 :如
AV_SAMPLE_FMT_FLTP(32-bit Float Planar) →AV_SAMPLE_FMT_S16(16-bit Signed Integer) - 采样率转换:如 48000 Hz → 44100 Hz
- 声道布局转换:如 5.1 声道 → 立体声 (Stereo)
nextlib 中的实际应用
在 ffaudio.cpp 中,通过 context->request_sample_fmt 指定输出格式:
cpp
// 根据 Java 层要求决定输出格式
context->request_sample_fmt = outputFloat
? AV_SAMPLE_FMT_FLT // 32位浮点 (ENCODING_PCM_FLOAT)
: AV_SAMPLE_FMT_S16; // 16位整数 (ENCODING_PCM_16BIT)
重采样上下文被这样初始化:
cpp
SwrContext *resampleContext;
if (context->opaque) {
resampleContext = (SwrContext *) context->opaque; // 复用已有上下文
} else {
resampleContext = swr_alloc(); // 首次创建
// 设置输入/输出声道布局
av_opt_set_chlayout(resampleContext, "in_chlayout", &context->ch_layout, 0);
av_opt_set_chlayout(resampleContext, "out_chlayout", &context->ch_layout, 0);
// 设置输入/输出采样率
av_opt_set_int(resampleContext, "in_sample_rate", context->sample_rate, 0);
av_opt_set_int(resampleContext, "out_sample_rate", context->sample_rate, 0);
// 设置输入/输出样本格式
av_opt_set_int(resampleContext, "in_sample_fmt", context->sample_fmt, 0);
av_opt_set_int(resampleContext, "out_sample_fmt", context->request_sample_fmt, 0);
swr_init(resampleContext);
context->opaque = resampleContext; // 缓存到 opaque 指针
}
关键优化 :SwrContext 被缓存在 context->opaque 指针中,避免每次解码都重新初始化,显著提升性能。
不重采样的后果
如果不进行重采样,会出现以下问题:
| 问题 | 现象 |
|---|---|
| 格式不匹配 | 播放出来是噪音/爆音(数据被错误解析) |
| 采样率不匹配 | 播放速度变快/变慢,音调异常 |
| 声道不匹配 | 只有部分声道有声音,或者声音混乱 |
| 存储方式不匹配 | 左右声道交错错误,声音失真 |
重采样流程总结
scss
解码器输出 ──→ [重采样 swr_convert()] ──→ 播放器可接受的格式
(任意格式) (固定格式)
↓ ↓
FLTP/S32P/... S16/FLT
48000Hz 44100Hz
5.1声道 立体声
平面存储 交错存储
重采样是音频解码流程中的"翻译官",确保不同来源、不同格式的音频数据能够被统一的播放硬件正确播放。
6. 核心流程示例 (Workflow Examples)
视频帧渲染流程:
scss
1. FfmpegVideoDecoder.decode() (Java)
↓ 从输入队列获取编码数据
2. ffmpegSendPacket(nativeContext, inputData, timeUs) (JNI)
↓ 调用 avcodec_send_packet()
3. ffmpegReceiveFrame(nativeContext, outputMode, outputBuffer) (JNI)
↓ 调用 avcodec_receive_frame() 获取 AVFrame (YUV)
↓ 将 YUV 数据拷贝到 Java VideoDecoderOutputBuffer 的 Direct ByteBuffer
4. FfmpegVideoDecoder.renderToSurface(outputBuffer, surface) (Java)
↓
5. ffmpegRenderFrame(nativeContext, surface, outputBuffer) (JNI)
├── ANativeWindow_fromSurface() → 获取 Native Window
├── ANativeWindow_setBuffersGeometry() → 设置 YV12 格式
├── sws_getContext() → 创建色彩转换上下文
├── ANativeWindow_lock() → 锁定屏幕缓冲区
├── sws_scale() → 将源 YUV 转换为 YV12 并拷贝到屏幕缓冲区
└── ANativeWindow_unlockAndPost() → 显示画面
音频解码流程:
scss
1. FfmpegAudioDecoder.decode() (Java)
↓ 从输入队列获取编码数据
2. ffmpegDecode(nativeContext, inputData, outputBuffer) (JNI)
├── avcodec_send_packet() → 发送编码数据
└── avcodec_receive_frame() → 获取解码后的音频帧
3. 音频重采样 (在 decodePacket() 内部)
├── 检查 context->opaque 是否有缓存的 SwrContext
├── 如无,创建并初始化 SwrContext
└── swr_convert() → 转换为 PCM 16bit 或 Float
4. 如输出缓冲区不足
↓ 回调 Java 层 growOutputBuffer()
↓ 重新分配 Direct ByteBuffer
5. 返回解码后的字节数
媒体信息解析流程:
scss
1. MediaInfoBuilder.from(filePath) (Kotlin)
↓
2. nativeCreateFromPath(filePath) (JNI)
↓
3. media_info_build() (C++)
├── avformat_open_input() → 打开文件
├── avformat_find_stream_info() → 获取流信息
├── onMediaInfoFound() → 回调格式和时长
└── 遍历所有流:
├── AVMEDIA_TYPE_VIDEO → onVideoStreamFound()
├── AVMEDIA_TYPE_AUDIO → onAudioStreamFound()
└── AVMEDIA_TYPE_SUBTITLE → onSubtitleStreamFound()
└── 遍历所有章节:
└── onChapterFound()
缩略图提取流程:
scss
1. MediaThumbnailRetriever.getFrameAtTime(timeMs)
↓
2. FrameLoader.nativeGetFrame(frameLoaderContextHandle, timeMs)
↓
3. frame_extractor_get_frame() (C++)
├── 计算 Seek 位置 (考虑 time_base 换算)
├── av_seek_frame() → Seek 到目标位置
├── read_frame() 循环:
│ ├── av_read_frame() → 读取 AVPacket
│ ├── avcodec_send_packet() → 发送数据包
│ └── avcodec_receive_frame() → 获取解码帧
├── 如 Seek 后第一帧解码失败,回退到文件开头重新解码
├── sws_getContext() → 创建缩放上下文 (源格式 → RGBA)
├── AndroidBitmap_lockPixels() → 锁定 Bitmap 像素缓冲区
├── av_image_fill_arrays() → 将 Bitmap 缓冲区包装为 AVFrame
├── sws_scale() → 执行缩放和格式转换
└── AndroidBitmap_unlockPixels() → 解锁并返回 Bitmap
7. 开发避坑指南 (Developer's Survival Guide)
A. 内存管理:JNI 的"陷阱"
Direct ByteBuffer 使用注意事项
项目大量使用 env->GetDirectBufferAddress 实现 Java 与 C++ 之间的高效数据传输。
- 优势:直接访问 Java 堆外内存,避免数据拷贝,速度极快。
- 风险 :
- GC 回收问题 :必须确保 Java 层的 Buffer 对象在 C++ 处理期间不被 GC 回收。Media3 的
SimpleDecoder框架通过维护缓冲队列保证了这一点,但自定义代码中需特别注意。 - 空指针检查 :
GetDirectBufferAddress可能返回nullptr(如 Buffer 不是 Direct Buffer 或已被回收),使用前必须检查。 - 容量检查 :C++ 层无法自动知道 Buffer 的容量,需从 Java 层传入
limit或capacity。
- GC 回收问题 :必须确保 Java 层的 Buffer 对象在 C++ 处理期间不被 GC 回收。Media3 的
JNI 引用管理
- 局部引用 (Local Reference) :
- 生命周期:当前 JNI 方法调用结束前有效。
- 限制:默认局部引用表容量有限(通常 512 个),在循环中创建大量局部引用会导致溢出。
- 解决方案 :在循环中使用
env->DeleteLocalRef(obj)及时释放。
- 全局引用 (Global Reference) :
- 生命周期:手动创建和释放,跨 JNI 调用有效。
- 用途:缓存 Java Class 对象(如
VideoDecoderOutputBuffer类)。 - 注意 :必须在
JNI_OnUnload或适当时机调用env->DeleteGlobalRef()释放,否则导致内存泄漏。
代码示例:ffvideo.cpp 中的潜在 Bug
cpp
// 以下代码存在 Bug:yuvPlanesU 和 yuvPlanesV 被错误地使用了 yuvPlanesY
auto *planeU = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(yuvPlanesY));
auto *planeV = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(yuvPlanesY));
// 修复:重新正确获取
planeU = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(yuvPlanesU));
planeV = reinterpret_cast<uint8_t *>(env->GetDirectBufferAddress(yuvPlanesV));
B. 性能瓶颈:对齐 (Alignment)
- 现象 :在
ffvideo.cpp中,你会看到ALIGN(native_window_buffer.stride / 2, 16)。 - 原因 :现代 CPU 指令集(如 ARM NEON)处理对齐数据速度远快于非对齐数据。步长不对齐可能会导致:
- 视频花屏(画面出现条纹或错位)
- 性能下降(CPU 需要额外处理非对齐内存访问)
- 在某些架构上甚至会导致崩溃(如 ARM 的严格对齐要求)
- 对齐规则 :
- Y 平面步长:通常对齐到 16 字节
- UV 平面步长:通常为 Y 步长的一半,也对齐到 16 字节
- 高度:UV 平面高度为
(height + 1) / 2(向上取整)
C. 线程安全
- FFmpeg 解码上下文(
AVCodecContext)是非线程安全的。 - 核心原则 :
- 严禁在多个线程同时操作同一个解码器实例。
avcodec_send_packet()和avcodec_receive_frame()必须在同一个线程中调用。- 项目中,Media3 的
SimpleDecoder框架保证了所有解码操作在单线程中执行。
- SwrContext 和 SwsContext:同样是非线程安全的,不能跨线程共享。
D. Seek 后的解码状态处理
-
问题:Seek 后,FFmpeg 解码器内部可能残留旧的解码状态,导致首帧解码失败或输出错误数据。
-
标准处理 :调用
avcodec_flush_buffers()清空内部缓冲区。 -
特殊情况:TrueHD 格式不支持简单的 flush,需要销毁并重建解码上下文(见第 5 章 B 节)。
-
视频 Seek 策略 :在
frame_extractor.cpp中,Seek 后如果第一帧解码失败,会回退到文件开头重新解码:cppav_seek_frame(..., seekPosition, 0); bool result = read_frame(...); if (!result) { av_seek_frame(..., 0, 0); // 回退到开头 result = read_frame(...); }
E. 错误处理与日志
- FFmpeg 错误码 :FFmpeg 函数通常返回负数表示错误,可使用
av_err2str(result)转换为可读字符串。 - 常见错误码 :
AVERROR(EAGAIN):需要更多输入或输出(非致命,正常流程)AVERROR_EOF:文件结束(正常流程)AVERROR_INVALIDDATA:数据损坏(可跳过)AVERROR(ENOMEM):内存不足(致命)
- 项目日志 :使用
LOGE()宏输出错误信息,可在 logcat 中过滤nextlib标签查看。
F. 缓冲区动态扩展
-
音频解码的特殊性:某些音频格式(如多声道高采样率)解码后的数据量可能超过预设缓冲区大小。
-
解决方案 :在
ffaudio.cpp中,通过GrowOutputBufferCallback回调 Java 层的growOutputBuffer()方法,动态扩展 Direct ByteBuffer:cppif (outSize + bufferOutSize > outputSize) { outputSize = outSize + bufferOutSize; outputBuffer = growBuffer(outputSize); // 回调 Java 重新分配 }
8. 音视频开发基础知识 (Audio/Video Development Basics)
本章节为新手开发者提供音视频开发的核心概念和术语,帮助理解 nextlib 的工作原理。
8.1 媒体文件的基本结构
一个完整的媒体文件(如 MP4、MKV)通常包含以下层次:
arduino
┌─────────────────────────────────────────────────┐
│ 容器 (Container) │
│ 如: MP4, MKV, AVI, FLV, WebM │
│ 职责: 组织和管理多个流,提供索引和元数据 │
├─────────────────────────────────────────────────┤
│ ┌───────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ 视频流 │ │ 音频流 │ │ 字幕流 │ │
│ │ (Stream 0)│ │ (Stream 1)│ │ (Stream 2) │ │
│ └───────────┘ └───────────┘ └──────────────┘ │
│ ↓ ↓ ↓ │
│ ┌───────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ H.264 │ │ AAC │ │ SRT/ASS │ │
│ │ HEVC │ │ FLAC │ │ WebVTT │ │
│ │ VP9 │ │ Opus │ │ PGS │ │
│ └───────────┘ └───────────┘ └──────────────┘ │
│ ↓ ↓ │
│ 压缩数据 (Encoded) 压缩数据 (Encoded) │
└─────────────────────────────────────────────────┘
关键概念:
- 容器 (Container/Muxer Format):负责将多个流"打包"在一起的文件格式。容器不关心内容如何编码,只负责组织和索引。
- 流 (Stream):媒体文件中的独立数据通道,如视频流、音频流、字幕流。
- 编解码器 (Codec):负责压缩和解压缩数据的算法,如 H.264、AAC、FLAC。
8.2 视频编码基础
为什么需要压缩?
未经压缩的 1080p60 视频数据量约为:
yaml
1920 × 1080 × 3 字节/像素 × 60 帧/秒 ≈ 373 MB/秒
这远远超出了存储和传输能力。视频编码通过消除冗余将数据压缩到原来的 1/100 ~ 1/1000。
帧类型 (Frame Types)
| 帧类型 | 全称 | 特点 | 压缩率 | 解码依赖 |
|---|---|---|---|---|
| I 帧 | Intra-coded Frame | 关键帧,完整图像 | 低 (类似 JPEG) | 无依赖,可独立解码 |
| P 帧 | Predictive Frame | 前向预测帧 | 中 | 依赖前面的 I 帧或 P 帧 |
| B 帧 | Bidirectional Frame | 双向预测帧 | 高 | 依赖前面和后面的参考帧 |
GOP (Group of Pictures):两个 I 帧之间的一组帧。GOP 长度影响 Seek 精度和压缩率。
less
解码顺序: I → P → B → B → P → B → B → I → ...
显示顺序: I → B → B → P → B → B → P → I → ...
常见视频编解码器
| 编解码器 | 标准 | 特点 | 应用场景 |
|---|---|---|---|
| H.264/AVC | MPEG-4 Part 10 | 广泛兼容,硬件支持好 | 流媒体、蓝光、视频会议 |
| H.265/HEVC | MPEG-H Part 2 | 比 H.264 压缩率高 50% | 4K/8K 视频 |
| VP8 | 开源,WebM 容器 | YouTube、Web 视频 | |
| VP9 | 比 VP8 压缩率高 40% | YouTube 4K、WebM | |
| AV1 | AOMedia | 最新开源,压缩率最高 | 下一代流媒体标准 |
像素格式 (Pixel Format)
视频解码后的原始数据通常使用 YUV 格式:
| 格式 | 说明 | 采样方式 | 每像素字节 |
|---|---|---|---|
| YUV420P | 平面格式,Y/U/V 分开存储 | Y 全采样,U/V 各 1/4 | 1.5 |
| NV12 | 半平面格式,Y 平面 + 交错的 UV | 同 YUV420P | 1.5 |
| NV21 | Android 相机默认格式 | 同 YUV420P,UV 顺序相反 | 1.5 |
| YV12 | Android Surface 要求格式 | 同 YUV420P,V/U 顺序相反 | 1.5 |
| RGB888 | 真彩色 | 每像素 24 位 | 3 |
YUV 的优势:
- 人眼对亮度(Y)敏感,对色彩(U/V)不敏感
- 降低色彩采样率(4:2:0)可节省 50% 带宽,但视觉差异很小
8.3 音频编码基础
采样参数
| 参数 | 说明 | 常见值 |
|---|---|---|
| 采样率 (Sample Rate) | 每秒采样次数 | 44100 Hz (CD), 48000 Hz (视频) |
| 位深度 (Bit Depth) | 每个样本的位数 | 16 bit (CD), 24 bit (高清) |
| 声道数 (Channels) | 独立声道数量 | 1 (Mono), 2 (Stereo), 6 (5.1) |
数据量计算:
ini
立体声 16bit 44100Hz = 44100 × 2 × 2 = 176,400 字节/秒 ≈ 172 KB/s
音频编码格式分类
| 类型 | 格式 | 特点 | 码率 |
|---|---|---|---|
| 无损压缩 | FLAC, ALAC, TrueHD | 完全保留原始数据 | 500-1500 kbps |
| 有损压缩 | AAC, MP3, Opus, Vorbis | 去除人耳不敏感的信息 | 64-320 kbps |
| 未压缩 | PCM, WAV | 原始采样数据 | 1411 kbps (CD) |
声道布局 (Channel Layout)
| 布局 | 声道 | 说明 |
|---|---|---|
| Mono | 1.0 | 单声道 |
| Stereo | 2.0 | 立体声 (左 + 右) |
| 5.1 | 6 声道 | 前左 + 前右 + 中置 + 低音 + 后左 + 后右 |
| 7.1 | 8 声道 | 5.1 + 侧左 + 侧右 |
8.4 时间戳与同步
时间基准 (Time Base)
FFmpeg 中使用分数表示时间基准:
cpp
AVRational time_base = {1, 1000}; // 1/1000 秒 = 1 毫秒
int64_t pts = 5000; // 5000 个 time_base 单位 = 5000 ms
时间戳换算:
cpp
// 从 AV_TIME_BASE (微秒) 转换为流的 time_base
int64_t stream_pts = av_rescale_q(time_ms * 1000, AV_TIME_BASE_Q, stream->time_base);
// 从流的 time_base 转换为毫秒
int64_t time_ms = av_rescale_q(stream_pts, stream->time_base, AV_TIME_BASE_Q);
音视频同步 (A/V Sync)
播放器必须确保音频和视频同步播放。Media3 使用音频时钟作为主时钟:
视频播放速度 → 跟踪音频时钟 → 如视频快了则等待,慢了则丢帧
常见问题:
- 音画不同步:通常由解码延迟、渲染延迟或时间戳错误引起。
- 字幕漂移:字幕文件的时间轴与视频帧率不匹配(如 PAL 25fps vs NTSC 23.976fps)。
8.5 容器格式详解
MP4 (MPEG-4 Part 14)
- 特点:广泛兼容,支持流式播放(Moov Atom 在文件头部时)。
- 支持的编码:H.264, H.265, AAC, MP3, AC3 等。
- 限制:不支持某些开源编码(如 VP9、FLAC 需特殊处理)。
MKV (Matroska)
- 特点:开源、灵活,支持几乎任何编码格式。
- 优势:支持多音轨、多字幕、章节、附件(字体)。
- 限制:部分硬件播放器不支持。
WebM
- 特点:Google 开发的简化版 MKV,专为 Web 设计。
- 支持的编码:VP8, VP9, AV1 (视频) + Vorbis, Opus (音频)。
8.6 Android 媒体架构
MediaCodec (硬件编解码器)
- Android 提供的硬件编解码器接口。
- 优势:性能高、功耗低。
- 限制:依赖设备芯片,格式支持有限(如不支持 FLAC、Vorbis 软解)。
Media3 (ExoPlayer)
- Google 开发的新一代播放器框架。
- 架构 :
ExoPlayer→Renderer→Decoder→MediaCodec/FFmpeg - 扩展点 :通过
RenderersFactory可自定义解码器。
nextlib 的定位
scss
┌──────────────────────────────────────────────┐
│ 应用层 (App) │
├──────────────────────────────────────────────┤
│ Media3 (ExoPlayer) │
├────────────────┬─────────────────────────────┤
│ 系统解码器 │ nextlib FFmpeg 解码器 │
│ (MediaCodec) │ (软解,支持更多格式) │
├────────────────┴─────────────────────────────┤
│ FFmpeg 原生库 (.so) │
├──────────────────────────────────────────────┤
│ Android NDK / Linux Kernel │
└──────────────────────────────────────────────┘
9. 常见问题与调试技巧 (FAQ & Debugging Tips)
9.1 编译与构建问题
Q: FFmpeg 编译失败怎么办?
排查步骤:
- 检查环境变量:
ANDROID_NDK_HOME和ANDROID_SDK_HOME是否正确设置。 - 检查 CMake 版本:需要 3.22.1 或更高版本。
- 查看
ffmpeg/sources/ffmpeg-6.0/config.log获取详细错误信息。 - 清理构建缓存:删除
ffmpeg/build和ffmpeg/output目录后重新编译。
常见错误:
clang: command not found→ NDK 工具链路径错误libvpx not found→ 需要先编译 libvpx 依赖mbedtls not found→ 需要先编译 mbedtls 依赖
Q: 如何添加新的解码器支持?
步骤:
- 编辑
ffmpeg/setup.sh,在ENABLED_DECODERS变量中添加解码器名称。 - 如需要外部库(如 libvpx),添加下载和编译函数。
- 在
FfmpegLibrary.java的getCodecName()中添加 MIME 映射。 - 如需要 ExtraData,在
FfmpegVideoDecoder.java或FfmpegAudioDecoder.java的getExtraData()中添加处理逻辑。 - 重新编译:
./gradlew :media3ext:assemble
9.2 播放问题
Q: 某些视频无法播放或只有声音没有画面?
可能原因:
- 解码器未启用 :检查 logcat 中是否有
No xxx decoder available警告。 - ExtraData 缺失 :H.264/H.265 需要 SPS/PPS,检查
getExtraData()是否正确提取。 - 像素格式不支持:某些特殊像素格式(如 10-bit)可能无法正确转换为 YV12。
调试方法:
bash
# 查看 FFmpeg 支持的解码器列表
adb logcat | grep -i "ffmpeg"
# 检查当前使用的解码器
adb logcat | grep -i "NextRenderersFactory"
Q: 音频爆音或杂音?
可能原因:
- TrueHD Seek 问题:TrueHD 格式 Seek 后需要重建解码上下文(项目已处理)。
- 采样率不匹配 :检查
swr_convert是否正确配置了输入/输出采样率。 - 缓冲区溢出 :检查
growOutputBuffer()是否正确处理了缓冲区扩展。
Q: 音画不同步?
排查步骤:
- 检查视频时间戳(PTS)是否正确。
- 检查是否有 B 帧解码顺序问题。
- 尝试使用
EXTENSION_RENDERER_MODE_PREFER让 FFmpeg 优先解码。
9.3 JNI 调试
Q: JNI 崩溃 (SIGSEGV) 如何定位?
工具:
-
NDK Stack 解析:
bash# 使用 ndk-stack 解析崩溃堆栈 adb logcat | $NDK/ndk-stack -sym $PROJECT_PATH/media3ext/build/intermediates/cmake/debug/obj -
AddressSanitizer (ASan):
kotlin// build.gradle.kts 中启用 ASan externalNativeBuild { cmake { cppFlags += "-fsanitize=address -fno-omit-frame-pointer" } } -
常见崩溃原因:
- 空指针解引用(
GetDirectBufferAddress返回nullptr) - 数组越界(
linesize计算错误) - 已释放的指针(Use-After-Free)
- JNI 引用表溢出
- 空指针解引用(
Q: 如何查看 FFmpeg 内部日志?
方法 :在 ffmain.cpp 中注册 FFmpeg 日志回调:
cpp
#include <libavutil/log.h>
void ffmpeg_log_callback(void *ptr, int level, const char *fmt, va_list vl) {
char line[1024];
vsnprintf(line, sizeof(line), fmt, vl);
__android_log_print(ANDROID_LOG_DEBUG, "FFmpeg", "%s", line);
}
JNIEXPORT jint JNI_OnLoad(JavaVM *vm, void *reserved) {
av_log_set_callback(ffmpeg_log_callback);
// ...
}
9.4 性能优化
Q: 如何优化解码性能?
建议:
- 优先使用硬件解码器 :通过
EXTENSION_RENDERER_MODE_PREFER让系统解码器优先。 - 调整解码线程数 :
FfmpegVideoDecoder的threads参数可控制 FFmpeg 解码线程数。 - 减少缓冲区拷贝:使用 Direct ByteBuffer 避免额外拷贝。
- 对齐优化:确保 YUV 步长对齐到 16 字节(项目已处理)。
Q: 内存占用过高?
排查:
- 检查 Direct ByteBuffer 是否正确释放。
- 检查 JNI 全局引用是否泄漏(使用
adb shell dumpsys meminfo <package>)。 - 减少输入/输出缓冲区数量(
numInputBuffers/numOutputBuffers)。
9.5 缩略图提取问题
Q: getFrameAtTime() 返回空或错误的帧?
可能原因:
- Seek 精度问题:FFmpeg Seek 到关键帧,可能不是精确时间戳。
- 像素格式不支持:某些特殊像素格式无法转换为 RGBA。
- 文件损坏 :流信息不完整,
avformat_find_stream_info()失败。
解决方案:
- 尝试 Seek 到文件开头:
getFrameAtTime(0) - 使用
getFrameAtIndex(0)获取第一帧 - 检查 logcat 中的 FFmpeg 错误日志
9.6 调试命令速查
bash
# 查看设备 ABI
adb shell getprop ro.product.cpu.abi
# 查看 .so 库是否打包到 APK
unzip -l app-debug.apk | grep libnextlib
# 查看 FFmpeg 版本
adb logcat | grep "FfmpegLibrary"
# 查看解码器使用情况
adb logcat | grep "NextRenderersFactory"
# 查看内存使用
adb shell dumpsys meminfo <package_name>
# 查看 JNI 引用表
adb shell setprop debug.malloc.check_jni 1
# 捕获崩溃堆栈
adb logcat -d > crash.log
附录:参考资源
FFmpeg 官方文档
- FFmpeg 官网:ffmpeg.org/
- FFmpeg 文档:ffmpeg.org/documentati...
- FFmpeg API 示例:ffmpeg.org/doxygen/tru...
Media3 (ExoPlayer) 文档
- Media3 官网:developer.android.com/guide/topic...
- Media3 GitHub:github.com/androidx/me...
Android NDK 开发
音视频学习资源
- 《FFmpeg 从入门到精通》
- 《音视频开发进阶指南:基于 Android 与 iOS 平台的实践》
- Learn FFmpeg libav the Hard Way:github.com/leandromore...