29、开源项目实战:ExoPlayer 集成、FFmpeg 封装、自定义 MediaCodec
这一章,我们终于要动手了。
前面讲了那么多 MediaCodec、OMX 组件、音视频同步的理论,说实话,光看不动手,很容易忘。我个人习惯是,学完一个知识点,必须找个开源项目把它串起来,才能真正吃透。
今天我们就拿三个最常用的东西来练手:ExoPlayer、FFmpeg、自定义 MediaCodec。把它们揉在一起,做一个能播、能转、能自定义解码的播放器核心模块。
29.1 为什么选这三个项目?
你想想看,Android 上做多媒体开发,绕不开这三样:
- ExoPlayer:Google 官方推荐的播放器框架,比 MediaPlayer 灵活太多。它本身就是基于 MediaCodec 封装的。
- FFmpeg:音视频界的瑞士军刀。解封装、转码、滤镜,什么都能干。但它是 C 写的,要封装成 Android 能用的 Java/Kotlin 接口。
- MediaCodec:底层硬件编解码接口。ExoPlayer 内部用它,但我们也可以自己写一个自定义的 MediaCodec 实现,比如加个水印、做实时滤镜。
说白了,这三个东西是层层递进的关系:ExoPlayer 调用 MediaCodec,FFmpeg 处理数据,自定义 MediaCodec 做特殊效果。
核心思路:用 ExoPlayer 做播放框架,用 FFmpeg 做解封装和预处理,用自定义 MediaCodec 做渲染前的最后一公里处理。
29.2 ExoPlayer 集成:不只是加个依赖
很多人集成 ExoPlayer,就是加一行依赖,然后 new 一个 PlayerView 就完事了。嗯,这样确实能播,但离「实战」还差得远。
我在项目中遇到过一个问题:客户要求播放 HLS 流,但某些 CDN 返回的 m3u8 文件里,TS 分片的时间戳是乱的。ExoPlayer 默认的播放器直接卡死。后来怎么解决的?
我写了一个自定义的 MediaSource,在数据进入 ExoPlayer 之前,先用 FFmpeg 把时间戳重新整理一遍。
来看核心代码:
// 自定义 MediaSource,集成 FFmpeg 预处理
class FfmpegPreprocessingMediaSource(
private val uri: Uri,
private val extractorFactory: ExtractorsFactory = DefaultExtractorsFactory()
) : BaseMediaSource() {
override fun createPeriod(
id: MediaPeriodId,
allocator: Allocator,
startPositionUs: Long
): MediaPeriod {
// 这里用 FFmpeg 对 uri 指向的文件做预处理
val processedUri = FfmpegProcessor.preprocess(uri)
// 然后用处理后的 uri 创建真正的 MediaPeriod
val actualSource = ProgressiveMediaSource.Factory(extractorFactory)
.createMediaSource(MediaItem.fromUri(processedUri))
return (actualSource as BaseMediaSource).createPeriod(id, allocator, startPositionUs)
}
}
我的经验:ExoPlayer 的扩展点非常多。不要只停留在 PlayerView 层面。学会自定义 MediaSource、RenderersFactory、LoadControl,才能真正驾驭它。
29.3 FFmpeg 封装:从 C 到 Java 的桥接
FFmpeg 是 C 写的,Android 是 Java/Kotlin 的世界。怎么让它们对话?
常用的方案有两种:
- JNI 直接调用:自己写 .so 库,用 JNI 暴露接口。灵活,但开发成本高。
- 用 MobileFFmpeg 或 FFmpegKit:社区封装好的库,直接调用命令行。简单,但性能有损耗。
我个人习惯用第一种。为什么?因为第二种本质上是把 FFmpeg 当命令行工具用,每次调用都要启动进程、传参、等结果。对于实时性要求高的场景,比如边播边转,根本扛不住。
来看一个我封装过的 JNI 接口:
// Java 层接口
public class FfmpegWrapper {
static {
System.loadLibrary("ffmpeg_wrapper");
}
// 初始化解码器
public native long initDecoder(String inputPath);
// 读取一帧数据,返回 byte[]
public native byte[] readFrame(long handle);
// 释放资源
public native void release(long handle);
}
C 层对应的实现:
// C 层实现
JNIEXPORT jlong JNICALL
Java_com_example_FfmpegWrapper_initDecoder(JNIEnv *env, jobject thiz, jstring inputPath) {
const char *path = (*env)->GetStringUTFChars(env, inputPath, NULL);
AVFormatContext *fmt_ctx = avformat_alloc_context();
if (avformat_open_input(&fmt_ctx, path, NULL, NULL) != 0) {
return 0; // 失败返回 0
}
avformat_find_stream_info(fmt_ctx, NULL);
// ... 找到视频流,打开解码器
(*env)->ReleaseStringUTFChars(env, inputPath, path);
return (jlong)fmt_ctx;
}
注意:JNI 层的内存管理要特别小心。我曾经因为忘记释放 AVFrame,导致内存泄漏,播放 10 分钟后 App 直接 OOM 崩溃。建议用 RAII 思想,在 C 层做好资源管理。
29.4 自定义 MediaCodec:给视频加个水印
ExoPlayer 默认的 MediaCodec 实现,只能做标准的解码和渲染。但如果我们想在视频上叠加一个时间戳水印,或者做实时美颜,怎么办?
答案就是:自定义 MediaCodec。
ExoPlayer 允许你通过 RenderersFactory 替换默认的 MediaCodec 实现。我们只需要继承 MediaCodecAdapter,重写 queueInputBuffer 和 dequeueOutputBuffer 方法,在中间插入我们的处理逻辑。
来看一个简化版的水印实现:
class WatermarkMediaCodecAdapter(
private val delegate: MediaCodecAdapter
) : MediaCodecAdapter by delegate {
override fun queueInputBuffer(
index: Int,
offset: Int,
size: Int,
presentationTimeUs: Long,
flags: Int
) {
// 获取输入缓冲区
val inputBuffer = delegate.getInputBuffer(index) ?: return
// 在这里对 inputBuffer 做处理,比如叠加水印
// 注意:这个操作要在 ByteBuffer 层面做,不能直接改 Surface
// 实际项目中,我会用 RenderScript 或 OpenGL 做 GPU 加速
addWatermark(inputBuffer)
// 然后交给真正的 MediaCodec 去编码/解码
delegate.queueInputBuffer(index, offset, size, presentationTimeUs, flags)
}
private fun addWatermark(buffer: ByteBuffer) {
// 这里只是示意,实际要解析 YUV 数据,在指定位置修改像素值
// 比如在左上角画一个红色的点
buffer.put(0, 0xFF.toByte()) // R
buffer.put(1, 0x00.toByte()) // G
buffer.put(2, 0x00.toByte()) // B
}
}
避坑指南:我曾经直接在 ByteBuffer 里逐像素修改,结果 720p 的视频都卡成幻灯片。后来改用 OpenGL 的 Fragment Shader 做水印叠加,性能提升了 10 倍。记住:像素级操作一定要用 GPU。
29.5 把它们串起来:一个完整的播放流程
好了,三个组件都讲完了。怎么把它们串成一个完整的播放器?
流程是这样的:
- ExoPlayer 加载一个视频源(比如本地 MP4 文件)。
- 数据进入 FFmpeg 预处理模块,解封装,提取音视频流,必要时修正时间戳。
- 音视频数据分别送入 自定义 MediaCodec,在解码前/后做特效处理(比如加水印)。
- 处理后的数据送到 SurfaceView 渲染,同时音频送到 AudioTrack 播放。
代码层面,核心就是自定义 RenderersFactory:
class CustomRenderersFactory(
private val context: Context
) : RenderersFactory {
override fun createRenderers(
eventHandler: Handler,
videoRendererEventListener: VideoRendererEventListener,
audioRendererEventListener: AudioRendererEventListener,
textRendererOutput: TextOutput,
metadataRendererOutput: MetadataOutput
): Array<Renderer> {
// 创建自定义的 MediaCodec 适配器
val codecAdapterFactory = object : MediaCodecAdapter.Factory {
override fun createAdapter(configuration: MediaCodecAdapter.Configuration): MediaCodecAdapter {
val delegate = DefaultMediaCodecAdapterFactory().createAdapter(configuration)
return WatermarkMediaCodecAdapter(delegate)
}
}
// 使用自定义适配器创建视频渲染器
val videoRenderer = MediaCodecVideoRenderer(
context,
codecAdapterFactory,
MediaCodecSelector.DEFAULT,
eventHandler,
videoRendererEventListener,
MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY
)
// 音频渲染器保持默认
val audioRenderer = MediaCodecAudioRenderer(
context,
MediaCodecSelector.DEFAULT,
eventHandler,
audioRendererEventListener
)
return arrayOf(videoRenderer, audioRenderer)
}
}
关键点:ExoPlayer 的 RenderersFactory 是扩展的入口。你可以在里面替换任意一个 Renderer,甚至加入自己的自定义 Renderer(比如用 FFmpeg 软解)。
29.6 性能与稳定性:实战中的坑
项目做完了,能跑了,但上线后问题就来了。我遇到过几个典型问题:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 播放卡顿,掉帧严重 | 自定义 MediaCodec 里做了 CPU 密集操作 | 改用 GPU(OpenGL/RenderScript)处理 |
| 某些手机黑屏 | MediaCodec 的 ColorFormat 不兼容 | 在配置时检查支持的格式,做 fallback |
| FFmpeg 预处理太慢 | 每次播放都重新解封装 | 加缓存层,预处理结果存本地 |
| 内存泄漏 | JNI 层没释放 AVFrame | 用智能指针,确保异常路径也释放 |
嗯,这里要注意:不要为了炫技而过度自定义。如果只是普通播放,ExoPlayer 默认的 MediaCodec 已经够用了。只有在需要特殊效果(水印、滤镜、变速)时,才考虑自定义。
29.7 小结
这一章我们做了三件事:
- 把 ExoPlayer 的扩展点摸透了,不只是用 PlayerView,而是自定义 MediaSource 和 RenderersFactory。
- 用 JNI 封装 FFmpeg,实现了 C 层和 Java 层的桥接,解决了实时预处理的问题。
- 通过 自定义 MediaCodec,在解码流程中插入了水印处理逻辑。
这三个技能,是 Android 多媒体开发从「会用」到「会改」的分水岭。你想想看,掌握了这些,以后遇到再奇葩的需求,你都能从底层去解决,而不是等着 Google 给你现成的 API。
最后说一句:代码写完了,一定要在真机上跑。模拟器上 MediaCodec 的行为和真机差很多,尤其是硬件解码的支持情况。我吃过这个亏,希望你别再踩一遍。
公众号:蓝海资料掘金营,微信deep3321