从 MP4 里拆出视频帧:用 AMediaExtractor 理解解复用
上一章我们搭好了播放器骨架。这一章开始进入第一条真实数据链路:从 MP4 这类媒体文件里,拆出能喂给解码器的压缩帧。
这一步叫解复用,英文是 demux。它不是解码。解复用只是“拆包”,解码才是“把压缩数据还原成图像或声音”。
先用生活直觉理解容器
一个 MP4 文件可以想成一个快递箱。箱子里可能有几种东西。
MP4 容器
视频轨:H.264/H.265 压缩帧
音频轨:AAC/Opus 压缩帧
字幕轨:文字或图片字幕
元信息:时长、宽高、旋转角度、码率
AMediaExtractor 就是拆箱工具。它帮我们打开容器、查看里面有哪些轨道、选择需要的轨道,然后一帧一帧读出来。
关键术语
Track 是轨道。视频、音频、字幕通常是不同轨道。
Sample 是容器层读出来的一小段数据。对视频来说,它通常是一帧或接近一帧的压缩数据。
PTS 是这一帧应该显示的时间,单位是微秒。1000000us 等于 1s。
Key Frame 是关键帧。视频压缩不是每一帧都完整保存,很多帧只记录“和前后帧的差异”。关键帧像一个完整快照,seek 通常要先落到关键帧附近。
AMediaExtractor 的职责边界
AMediaExtractor 做三件事。
设置数据源
读取轨道格式
推进当前 sample
它不做这些事。
不会把 H.264 解成图像
不会帮你做音视频同步
不会帮你处理 Surface
不会自动管理播放器状态
把边界记清楚,后面排查问题会轻松很多。
打开数据源
本地文件在 Android 上不总是能直接用路径读。更稳定的方式是 Kotlin/Java 层通过 ContentResolver 或 AssetFileDescriptor 拿到文件描述符,然后传给 native。
val afd = contentResolver.openAssetFileDescriptor(uri, "r") ?: return
val fd = afd.parcelFileDescriptor.detachFd()
nativeOpenFd(fd, afd.startOffset, afd.length)
native 层使用 AMediaExtractor_setDataSourceFd。
class ExtractorOwner {
public:
ExtractorOwner() : extractor_(AMediaExtractor_new()) {}
~ExtractorOwner() {
if (extractor_ != nullptr) {
AMediaExtractor_delete(extractor_);
}
}
AMediaExtractor* get() const { return extractor_; }
private:
AMediaExtractor* extractor_ = nullptr;
};
bool openExtractor(AMediaExtractor* extractor, int fd, int64_t offset, int64_t length) {
media_status_t status = AMediaExtractor_setDataSourceFd(extractor, fd, offset, length);
return status == AMEDIA_OK;
}
这里用了 RAII。意思是“对象活着时资源有效,对象销毁时自动释放资源”。NDK 工程里要尽量用这种方式管理 C API 指针。
读取轨道
官方 API 提供 AMediaExtractor_getTrackCount 和 AMediaExtractor_getTrackFormat。我们先枚举轨道。
struct TrackInfo {
int index = -1;
std::string mime;
int32_t width = 0;
int32_t height = 0;
};
bool readCString(AMediaFormat* format, const char* key, std::string* out) {
const char* raw = nullptr;
if (!AMediaFormat_getString(format, key, &raw) || raw == nullptr) {
return false;
}
*out = raw;
return true;
}
std::optional<TrackInfo> findVideoTrack(AMediaExtractor* extractor) {
const size_t count = AMediaExtractor_getTrackCount(extractor);
for (size_t i = 0; i < count; ++i) {
AMediaFormat* format = AMediaExtractor_getTrackFormat(extractor, i);
if (format == nullptr) continue;
std::string mime;
bool hasMime = readCString(format, AMEDIAFORMAT_KEY_MIME, &mime);
TrackInfo info;
info.index = static_cast<int>(i);
info.mime = mime;
AMediaFormat_getInt32(format, AMEDIAFORMAT_KEY_WIDTH, &info.width);
AMediaFormat_getInt32(format, AMEDIAFORMAT_KEY_HEIGHT, &info.height);
AMediaFormat_delete(format);
if (hasMime && mime.rfind("video/", 0) == 0) {
return info;
}
}
return std::nullopt;
}
注意 AMediaExtractor_getTrackFormat 返回的 AMediaFormat* 需要手动释放。官方文档也明确要求调用方释放返回的 format。
选择轨道
找到视频轨后,必须调用 AMediaExtractor_selectTrack。
bool selectVideoTrack(AMediaExtractor* extractor, const TrackInfo& track) {
media_status_t status = AMediaExtractor_selectTrack(extractor, track.index);
return status == AMEDIA_OK;
}
选轨之后,readSampleData、getSampleTrackIndex、getSampleTime 只会返回已选轨道范围内的信息。这个语义很重要:如果你没有选择音频轨,就不会读到音频 sample。
一帧一帧读取 sample
官方 API 的核心顺序是:
readSampleData
getSampleTime
getSampleFlags
getSampleTrackIndex
advance
readSampleData 读取当前 sample。
getSampleTime 读取当前 sample 的展示时间。
advance 推进到下一个 sample。
struct Packet {
std::vector<uint8_t> data;
int64_t ptsUs = 0;
uint32_t flags = 0;
int trackIndex = -1;
};
std::optional<Packet> readOnePacket(AMediaExtractor* extractor) {
const ssize_t sampleSize = AMediaExtractor_getSampleSize(extractor);
if (sampleSize < 0) {
return std::nullopt;
}
Packet packet;
packet.data.resize(static_cast<size_t>(sampleSize));
ssize_t readSize = AMediaExtractor_readSampleData(
extractor,
packet.data.data(),
packet.data.size()
);
if (readSize < 0) {
return std::nullopt;
}
packet.data.resize(static_cast<size_t>(readSize));
packet.ptsUs = AMediaExtractor_getSampleTime(extractor);
packet.flags = AMediaExtractor_getSampleFlags(extractor);
packet.trackIndex = AMediaExtractor_getSampleTrackIndex(extractor);
AMediaExtractor_advance(extractor);
return packet;
}
这里的顺序不能随意换。advance 一旦调用,extractor 当前 sample 就变成下一帧。你如果先 advance 再取时间戳,就拿错帧了。
把 packet 放进有界队列
不要无限缓存压缩帧。视频文件很大,解码线程如果卡住,demux 线程会把内存吃爆。
class PacketQueue {
public:
explicit PacketQueue(size_t capacity) : capacity_(capacity) {}
bool push(Packet packet) {
std::unique_lock<std::mutex> lock(mutex_);
if (queue_.size() >= capacity_) {
return false;
}
queue_.push(std::move(packet));
cv_.notify_one();
return true;
}
bool pop(Packet* out) {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [&] { return !queue_.empty() || stopped_; });
if (queue_.empty()) return false;
*out = std::move(queue_.front());
queue_.pop();
return true;
}
void flush() {
std::lock_guard<std::mutex> lock(mutex_);
std::queue<Packet> empty;
queue_.swap(empty);
}
private:
std::mutex mutex_;
std::condition_variable cv_;
std::queue<Packet> queue_;
size_t capacity_ = 0;
bool stopped_ = false;
};
这个队列的意义是形成 backpressure。解码慢时,解复用不能无限往前跑。
Seek 为什么要落到关键帧
视频压缩常用“参考帧”思想。某一帧可能只记录“相对上一帧变化了什么”。如果 seek 直接落到非关键帧,解码器缺少前面的参考信息,就可能花屏或黑屏。
所以 seek 常用:
AMediaExtractor_seekTo(
extractor,
targetUs,
AMEDIAEXTRACTOR_SEEK_PREVIOUS_SYNC
);
PREVIOUS_SYNC 的意思是落到目标时间之前最近的同步帧。同步帧通常就是关键帧。
常见问题
黑屏但没有 crash:大概率是拿到 packet 但后续 codec 格式不匹配,先检查 mime、宽高、csd-0/csd-1。
seek 后画面跳一下:大概率是旧 packet 没清空,新旧时间线混在一起。
内存越来越高:大概率是 packet 队列无上限,或者 AMediaFormat* 没释放。
本章实验
先不要解码,只打印 sample 信息。
track=0 size=4211 ptsUs=0 flags=1
track=0 size=932 ptsUs=33333 flags=0
track=0 size=887 ptsUs=66666 flags=0
你需要验证这些结果。
ptsUs 大体递增
sampleSize 没有长期为 0
trackIndex 是你选择的视频轨
读取到末尾时 advance 返回 false 或 sampleSize 小于 0
工程风险与观测
demux 阶段要重点观测队列和时间戳。
packet_queue_size
sample_pts_us
sample_track_index
read_timeout_count
seek_serial
如果队列一直增长,说明下游 decode 变慢,需要 backpressure。
如果 PTS 倒退,说明 seek 或轨道读取有问题。
如果 trackIndex 不稳定,说明轨道选择或多轨读取策略不清楚。
出现读失败时,不要立刻进入 fatal error。先区分短暂超时、文件损坏、权限失败和真正 EOS。可恢复错误可以重试,不可恢复错误才上抛到状态机。
本章结论
解复用不是播放器里最炫的部分,但它决定后续链路是否干净。干净的 demux 输出应该包含数据、轨道、时间戳、flags,并且受队列容量控制。