29、开源项目实战:ExoPlayer 集成、FFmpeg 封装、自定义 MediaCodec

这一章,我们终于要动手了。

前面讲了那么多 MediaCodec、OMX 组件、音视频同步的理论,说实话,光看不动手,很容易忘。我个人习惯是,学完一个知识点,必须找个开源项目把它串起来,才能真正吃透。

今天我们就拿三个最常用的东西来练手:ExoPlayerFFmpeg自定义 MediaCodec。把它们揉在一起,做一个能播、能转、能自定义解码的播放器核心模块。

29.1 为什么选这三个项目?

你想想看,Android 上做多媒体开发,绕不开这三样:

  • ExoPlayer:Google 官方推荐的播放器框架,比 MediaPlayer 灵活太多。它本身就是基于 MediaCodec 封装的。
  • FFmpeg:音视频界的瑞士军刀。解封装、转码、滤镜,什么都能干。但它是 C 写的,要封装成 Android 能用的 Java/Kotlin 接口。
  • MediaCodec:底层硬件编解码接口。ExoPlayer 内部用它,但我们也可以自己写一个自定义的 MediaCodec 实现,比如加个水印、做实时滤镜。

说白了,这三个东西是层层递进的关系:ExoPlayer 调用 MediaCodec,FFmpeg 处理数据,自定义 MediaCodec 做特殊效果

核心思路:用 ExoPlayer 做播放框架,用 FFmpeg 做解封装和预处理,用自定义 MediaCodec 做渲染前的最后一公里处理。

ExoPlayer + FFmpeg + 自定义 MediaCodec 架构 输入源(本地/网络) ExoPlayer 框架 FFmpeg 解封装 自定义 MediaCodec(水印/滤镜) SurfaceView / TextureView 渲染 数据流方向:输入 → 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,重写 queueInputBufferdequeueOutputBuffer 方法,在中间插入我们的处理逻辑。

来看一个简化版的水印实现:

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 把它们串起来:一个完整的播放流程

好了,三个组件都讲完了。怎么把它们串成一个完整的播放器?

流程是这样的:

  1. ExoPlayer 加载一个视频源(比如本地 MP4 文件)。
  2. 数据进入 FFmpeg 预处理模块,解封装,提取音视频流,必要时修正时间戳。
  3. 音视频数据分别送入 自定义 MediaCodec,在解码前/后做特效处理(比如加水印)。
  4. 处理后的数据送到 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