一、ExoPlayer 是什么?

说实话,Android 官方在媒体播放这块,最早给的就是 MediaPlayer。但用过的人都知道,那玩意儿扩展性太差了。你想自定义个渲染流程?改个解码方式?基本没门。

ExoPlayer 就是 Google 为了解决这个问题推出的开源播放器。它不是系统组件,而是一个纯 Java/Kotlin 实现的库。这意味着什么?意味着你可以随意修改它的源码,定制自己的播放逻辑。

我个人习惯把 ExoPlayer 理解成「乐高积木」。它把播放器拆成了一个个模块——数据加载、解码、渲染、音视频同步,每个模块都可以替换。你想想看,这比 MediaPlayer 那个黑盒子强太多了。

核心定位:ExoPlayer 是一个可扩展的、模块化的 Android 媒体播放器库,支持 DASH、HLS、SmoothStreaming 等自适应流媒体协议。

二、ExoPlayer vs MediaPlayer:我该选哪个?

很多新手会纠结这个问题。我直接说结论:新项目无脑选 ExoPlayer。为什么?看下表你就明白了。

对比维度 MediaPlayer ExoPlayer
底层实现 系统 C++ 组件,黑盒 纯 Java/Kotlin,开源可改
自定义能力 几乎为零 高度可定制,可替换任意组件
流媒体支持 仅支持 HLS(有限) 原生支持 DASH、HLS、SS
渲染方式 只能绑定 SurfaceView/TextureView 支持自定义 Renderer,可输出到任意 Surface
版本兼容 依赖系统版本,碎片化严重 统一 API,通过库版本控制
性能 系统级,启动快 稍慢但可优化,功能更丰富

嗯,这里要注意一点。MediaPlayer 在低端机上启动确实快,因为它是系统进程直接拉的。但我在项目中遇到过一个问题:某款国产手机对 MediaPlayer 做了魔改,导致 seek 操作延迟高达 2 秒。换成 ExoPlayer 后,我们自己控制了缓冲策略,问题就解决了。

我的建议:如果你只是播放本地一个 MP4 文件,对自定义没需求,用 MediaPlayer 也行。但只要你涉及流媒体、自定义渲染、或者需要精细控制播放流程,直接上 ExoPlayer。

三、ExoPlayer 核心组件拆解

ExoPlayer 的架构设计得挺巧妙。它把播放流程分成了几个独立模块,每个模块只干一件事。我画了张图,你看完就明白了。

ExoPlayer 核心组件架构图 ExoPlayer TrackSelector(轨道选择器) LoadControl(加载控制器) Renderer 组(渲染器集合) MediaCodecVideoRenderer MediaCodecAudioRenderer TextRenderer(字幕) DataSource(数据源) 数据流方向:DataSource → ExoPlayer → TrackSelector → LoadControl → Renderer 组

从上图你能看到,ExoPlayer 的核心组件包括:

  • ExoPlayer 接口:你直接操作的对象,负责协调所有子模块。
  • TrackSelector:决定播放哪条音视频轨道。比如一个视频有多个音轨,它帮你选。
  • LoadControl:控制缓冲策略。什么时候开始缓冲?缓冲多少?都由它决定。
  • Renderer:真正的渲染器。视频渲染器、音频渲染器、字幕渲染器,各司其职。
  • DataSource:数据源工厂。从文件读、从网络读、从内存读,都靠它。

说白了,ExoPlayer 就是个「调度中心」。它不负责具体干活,而是把任务分发给各个组件。这种设计的好处是——你想改哪个部分,就只改那个部分,不影响其他模块。

四、自定义 Renderer:让视频渲染听你的

好,终于到了本章的重点。为什么要自定义 Renderer?

我在做直播项目时遇到过一个问题:默认的 MediaCodecVideoRenderer 渲染视频帧时,会直接丢给 Surface。但我想在渲染前做一次色彩空间转换,把 BT.709 转成 BT.2020。默认 Renderer 不支持这个操作。

怎么办?自己写一个 Renderer。

4.1 Renderer 的生命周期

自定义 Renderer 之前,你得先了解它的生命周期。ExoPlayer 的 Renderer 有以下几个状态:

  • STATE_DISABLED:初始状态,未启用。
  • STATE_ENABLED:已启用,但还没开始渲染。
  • STATE_STARTED:开始渲染。
  • 渲染过程中会不断收到 renderSample 回调。
  • 播放结束或出错时回到 STATE_DISABLED

4.2 自定义视频 Renderer 示例

下面我写一个最简单的自定义视频 Renderer。它继承自 BaseRenderer,只做一件事:把视频帧渲染到 Surface 上,同时打印日志。

public class CustomVideoRenderer extends BaseRenderer {

    private static final String TAG = "CustomVideoRenderer";
    private static final int NUM_INPUT_BUFFERS = 4;
    private MediaCodec mediaCodec;
    private Surface outputSurface;

    public CustomVideoRenderer() {
        super(C.TRACK_TYPE_VIDEO);
    }

    @Override
    public String getName() {
        return "CustomVideoRenderer";
    }

    @Override
    protected void onEnabled(boolean joining) throws ExoPlaybackException {
        super.onEnabled(joining);
        Log.d(TAG, "Renderer 已启用");
        // 在这里初始化 MediaCodec
        try {
            String mimeType = "video/avc"; // H.264
            MediaFormat format = MediaFormat.createVideoFormat(mimeType, 1920, 1080);
            mediaCodec = MediaCodec.createDecoderByType(mimeType);
            mediaCodec.configure(format, outputSurface, null, 0);
            mediaCodec.start();
        } catch (IOException e) {
            throw ExoPlaybackException.createForRenderer(e, getName());
        }
    }

    @Override
    protected void onStarted() throws ExoPlaybackException {
        super.onStarted();
        Log.d(TAG, "Renderer 开始工作");
    }

    @Override
    public void render(long positionUs, long elapsedRealtimeUs) throws ExoPlaybackException {
        // 核心渲染逻辑
        // 从 MediaCodec 取出输出缓冲区,渲染到 Surface
        MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();
        int outputIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0);
        if (outputIndex >= 0) {
            mediaCodec.releaseOutputBuffer(outputIndex, true);
            Log.d(TAG, "渲染了一帧,时间戳: " + bufferInfo.presentationTimeUs);
        }
    }

    @Override
    protected void onDisabled() {
        super.onDisabled();
        Log.d(TAG, "Renderer 已禁用");
        if (mediaCodec != null) {
            mediaCodec.stop();
            mediaCodec.release();
            mediaCodec = null;
        }
    }

    @Override
    public boolean isReady() {
        return mediaCodec != null;
    }

    @Override
    public boolean isEnded() {
        return false; // 实际项目中需要根据流状态判断
    }
}

注意:上面的代码只是一个演示骨架。实际项目中,你还需要处理格式协商、输入缓冲区的填充、以及 MediaCodec 的异步回调模式。我曾经因为没处理好 onInputBufferAvailable 回调,导致视频解码卡顿了一整天。

4.3 如何接入自定义 Renderer?

写好了 Renderer,怎么让 ExoPlayer 用上它?很简单,在构建播放器时传入你的 Renderer 数组:

// 创建自定义渲染器列表
Renderer[] renderers = new Renderer[] {
    new CustomVideoRenderer(),  // 你的视频渲染器
    new MediaCodecAudioRenderer(...), // 音频渲染器用默认的
};

// 构建 ExoPlayer
ExoPlayer player = new ExoPlayer.Builder(context)
    .setRenderers(renderers)
    .build();

嗯,这里有个坑。ExoPlayer 会根据 TRACK_TYPE 来匹配渲染器和轨道。如果你的自定义 Renderer 返回了 C.TRACK_TYPE_VIDEO,它就会接管所有视频轨道的渲染。默认的 MediaCodecVideoRenderer 就不会被使用了。

小技巧:如果你只想在某些特定场景下使用自定义 Renderer,可以在 onEnabled 方法里判断当前轨道的 MIME 类型。比如只对 H.265 视频使用自定义渲染,其他情况回退到默认。

五、本章小结

这一章我们聊了 ExoPlayer 是什么、它和 MediaPlayer 的区别、核心组件有哪些,以及如何自定义 Renderer。说白了,ExoPlayer 的强大之处就在于它的「可插拔」设计。你不需要动整个播放器,只需要替换一个 Renderer,就能实现完全不同的渲染效果。

下一章我们会深入 SurfaceView 和 ExoPlayer 的绑定细节,包括如何避免黑屏、如何优化首帧渲染速度。这些都是我在实际项目中踩过的坑,到时候一一分享给你。


公众号:蓝海资料掘金营,微信deep3321