转型到ExoPlayer,实现更多的自定义功能

原演讲地址

Switching to ExoPlayer: Better Video on Android

360|AnDev 的演讲中,Effie Barak展示了Udemy从MediaPlayer转型到Exoplayer的过程,包括了基本的实现,除此之外,还有ExoPlayer的一些高级视频功能,比如后台播放,可变播放速度,字幕,以及不同分辨率播放等功能。

开场介绍

Udemy 最开始的播放器使用的是 MediaPlayer 。在六个月前,我们决定转型到 ExoPlayer 。ExoPlayer是Android上一个应用级别的媒体播放器,它提供了可选的方案来播放媒体文件而不是仅仅使用 MediaPlayer。这是一个Google出品的第三方的播放器的library,完全使用java编写,并且只依赖低级的媒体编码API。
Udemy APP 主要是用来观看教学课程以及视频讲座,所以媒体部分是整个应用最核心的功能。一个稳定可靠的播放器是十分重要的。同时,我们也需要一些可自定义的部分,除了基本的播放、暂停、下一个之外的一些更酷炫的功能。

MediaPlayer对比ExoPlayer

mediaPlayer.setDataSource(url);
mediaPlayer.prepare();
mediaPlayer.start();

MediaPlayer 有一些优点,最主要的就是开始使用很简单,你只需要上面这三行代码就能播放大部分的文件。
缺点就是可定制性不高,不易扩展。随着功能迭代,我们需要app添加一些更多的功能,比如支持HLS流,播放速率可变,这些都是 MediaPlayer 所不能立即完成的。
除此之外 MediaPlayer 也有一些其他的缺点:这是一个黑盒,并且内部都是native的方法,很难去debug和弄清楚到底异常是怎么出现的。而且 MediaPlayer 作为framework级别的解决方案,这样在不同的版本,不同的ROM上表现会有差异,我们不能控制和担保到底使用的是什么样的版本。而且 MediaPlayer 会有一些各种奇怪的异常code,很难确信这些crash是怎么产生的。
ExoPlayer 解决了上述这些提到的问题,它具有强大的可扩展性,但是一开始的学习曲线比较陡峭。好在这是开源的,源码易于阅读,并且容易debug。实现上基于 MediaCodec,能够处理HLS流,同时也支持后台播放,播放速率,分辨率可配置。

ExoPlayer基础

不同于 MediaPlayer,使用 ExoPlayer 需要更多的一些代码来实现播放视频,主要分为两部分:一个是UI部分来控制播放器的行为(播放,暂停,下一个),第二个核心部分就是获取数据流,解码,处理流。

播放器
player = ExoPlayer.Factory.newInstance(
  PlayerConstants.RENDERER_COUNT,
  MIN_BUFFER_MS,
  MIN_REBUFFER_MS);

playerControl = new PlayerControl(player);

上面是一个播放器被初始化的例子,下面的 playerControl 是一个默认组件用来和播放器一起工作。为了从播放组件得到各种返回的信息来做更多的行为,比如失败后的重试,我们可以通过引擎组件的一系列监听达成这样的效果

public abstract class UdemyBaseExoplayer
  implements ExoPlayer.Listener,
    ChunkSampleSource.EventListener,
    HlsSampleSource.EventListener,
    DefaultBandwidthMeter.EventListener,
    MediaCodecVideoTrackRenderer.EventListener,
    MediaCodecAudioTrackRenderer.EventListener
player.addListener(this);
核心

如果我们想要处理一些非自适应的流类型(比如MP3或者MP4)会有一点不一样,如果是HLS和Dash流就更复杂一些。
流的来源一般是一个URI,然后通过一个 ExtractorSampleSource 来获取流,并且会根据编码类型有一个对应的实际流处理(比如是MP4,就是Mp4Extractor),这些Extractor通过将视频和音频文件解码成原始的信息,通过Render的方式给播放器来进行播放。
还有另一种方式,播放器直接向renders要缓冲buffer,这样它就会说,“我没有可以放的东西啦,ExtractorSampleSource,再给我一些数据”,然后 ExtractorSampleSource 就会说,“我需要获取一些数据,会通过默认的 URI 数据源来拿数据”。
第一眼看过去这些操作需要不少代码,但我们真的需要写这么多东西么?

Allocator allocator = new DefaultAllocator(BUFFER_SEGMENT_SIZE);
Handler mainHandler = player.getMainHandler();

DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(mainHandler, null);
DataSource dataSource = new DefaultUriDataSource(
  context, bandwidthMeter, Util.getUserAgent(mContext, Constants.UDEMY_NAME));
ExtractorSampleSource sampleSource = new ExtractorSampleSource(uri, dataSource, allocator,
  BUFFER_SEGMENT_COUNT * BUFFER_SEGMENT_SIZE, mainHandler, player, 0);
MediaCodecVideoTrackRenderer videoRenderer = new MediaCodecVideoTrackRenderer(context,
  sampleSource, MediaCodecSelector.DEFAULT, MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 5000,
  mainHandler, player, 50);
MediaCodecAudioTrackRenderer audioRenderer = new MediaCodecAudioTrackRenderer(sampleSource,
  MediaCodecSelector.DEFAULT, null, true, mainHandler, player,
  AudioCapabilities.getCapabilities(context), AudioManager.STREAM_MUSIC);

TrackRenderer[] renderers = new TrackRenderer[PlayerConstants.RENDERER_COUNT];
renderers[PlayerConstants.TYPE_VIDEO] = videoRenderer;
renderers[PlayerConstants.TYPE_AUDIO] = audioRenderer;
player.onRenderers(renderers, bandwidthMeter);

这些代码还是需要的,但是写起来并不困难,我直接从官方的demo app中copy过来,而且能直接使用,所以并不需要我们去写这些代码。

// Allocator allocator = new DefaultAllocator(BUFFER_SEGMENT_SIZE);
// Handler mainHandler = player.getMainHandler();
// DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter(mainHandler, null);
// DataSource dataSource = new DefaultUriDataSource(context, bandwidthMeter,
//                         Util.getUserAgent(mContext, Constants.UDEMY_NAME));

ExtractorSampleSource sampleSource = new ExtractorSampleSource(uri, dataSource, allocator,
  BUFFER_SEGMENT_COUNT * BUFFER_SEGMENT_SIZE, mainHandler, player, 0);

MediaCodecVideoTrackRenderer videoRenderer = new MediaCodecVideoTrackRenderer(context,
  sampleSource, MediaCodecSelector.DEFAULT, MediaCodec.VIDEO_SCALING_MODE_SCALE_TO_FIT, 5000,
  mainHandler, player, 50);

MediaCodecAudioTrackRenderer audioRenderer = new MediaCodecAudioTrackRenderer(sampleSource,
  MediaCodecSelector.DEFAULT, null, true, mainHandler, player,
  AudioCapabilities.getCapabilities(context), AudioManager.STREAM_MUSIC);

TrackRenderer[] renderers = new TrackRenderer[PlayerConstants.RENDERER_COUNT];
renderers[PlayerConstants.TYPE_VIDEO] = videoRenderer;
renderers[PlayerConstants.TYPE_AUDIO] = audioRenderer;
player.onRenderers(renderers, bandwidthMeter);

有了上面的代码后,我们还需要一些代码

Build Extractors
DefaultUriDataSource uriDataSource = new DefaultUriDataSource
    (context, bandwidthMeter, userAgent);
ExtractorSampleSource sampleSource = new ExtractorSampleSource
    (uri, uriDataSource, allocator,
    PlayerConstants.BUFFER_SEGMENT_COUNT * PlayerConstants.BUFFER_SEGMENT_SIZE);
Build Renderers
TrackRenderer[] renderers =
    new TrackRenderer[PlayerConstants.RENDERER_COUNT];
    
MediaCodecAudioTrackRenderer audioRenderer =
  new MediaCodecAudioTrackRenderer(
    sampleSource, MediaCodecSelector.DEFAULT,
      null, true, player.getMainHandler(), player,
    AudioCapabilities.getCapabilities(context),
    AudioManager.STREAM_MUSIC);
    
renderers[PlayerConstants.TYPE_AUDIO] = audioRenderer;
Connect Renderers to the Player
player.prepare(renderers);
Udemy 自定义的一些结构

Udemy 对于基本结构做了一些优化,比如减少了缓冲区buffer的大小以及单一片段 segment的大小,因为在一些低端机型上会出现内存不足的异常。
而且在使用上,一般我们已经知道了我们要播放的媒体文件类型,我们可以只需要相应类型的extractor ,让程序更加的精简,比如播放一个MP4文件只需要下面这些配置

mp4Extractor = new Mp4Extractor();
mp3Extractor = new Mp3Extractor();
sampleSource = new ExtractorSampleSource(..., mp4Extractor, mp3Extractor);

HLS流

对于播放HLS还是有一点复杂的,HLS是一种特殊协议流,将原始数据分割成很多小的序列文件来下载。每个下载都是整体流中的一小块,在播放时,客户端可以从不同的数据流中选择可以替换的流,并且允许流基于带宽等逻辑来进行切换。
在流会话的开始,会有一个 m3u8的文件,这个文件就是存储了不同分片ts文件的列表,HLS会根据带宽来决定使用哪一个chunk。然后剩下的就比较相似了,选择好了哪个质量的流之后,这些流会被 HlsChunkSourceHlsSampleSource 处理,一般我们不需要写额外的逻辑,使用默认的就好了,除非想自定义一些行为。

DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter();
PtsTimestampAdjusterProvider timestampAdjusterProvider =
  new PtsTimestampAdjusterProvider();
HlsChunkSource chunkSource = new HlsChunkSource(...,
  uriDataSource,
  url,...,
  bandwidthMeter,
  timestampAdjusterProvider,
  HlsChunkSource.ADAPTIVE_MODE_SPLICE);

上面这段初始化了一个新的 HlsChunkSource ,首先通过一个 bandwidthMeter 来计算当前有效的带宽,同时还有一个 PtsTimestamp 的对象,Pts是一个用来度量解码后的视频帧什么时候播放的参数,这里也用默认的就行了。
最后一个 HlsChunkSource 构造函数的参数是一个mode,总共有三种mode:nonespliceabrupt
none 没什么特殊的,这样选择从一开始到最后都是一样的
splice 意味着在chunk间切换时,会同时下载老的以及新的,以免出现前一个还没结束后一个已经开始,这样会导致切换产生噪点。
abrupt 这个只会下载新的,老的不管,如果出现时间戳不匹配的话,就容易在分片切换时出现噪点
综合来看使用splice是比较好的选择,当然会下载两份流量上有一些偏多。

sampleSource = new HlsSampleSource(chunkSource, ...);

然后需要一个 HlsSampleSource ,持有一个上面初始化的 chunkSource 对象,上面的这些是一个例子展示了一个 HlsChunkSource 怎么拿到 metafile,并组成一个可供选择的流列表,并最终展示出来。
在 Udemy ,我们有时候需要重写这个例子,比如有时只给用户一个选择,只有一种质量的视频,不需要动态改变视频质量,那么我们就能将 mode 转变为 ADAPTIVE_MODE_NONE

HlsChunkSource chunkSource = new HlsChunkSource(..., HlsChunkSource.ADAPTIVE_MODE_NONE);

而且我们有时候也需要告诉播放器从哪种质量开始,如果没有设置,播放器总是选择一个默认的值,但是手动设置改变它是比较麻烦的,因为这个参数是一个私有private变量 private int selectedVariantIndex,这个变量贯穿了整个类,特别是 getChunkOperation 方法,所以我们不得不创建一个自己的类实例来添加这个功能,这也算是设计的一个瑕疵了

// The index in variants of the currently selected variant.
private int selectedVariantIndex;

public void getChunkOperation(...) {
  int nextVariantIndex;
  ...
  if (adaptiveMode == ADAPTIVE_MODE_NONE) {
    nextVariantIndex = selectedVariantIndex;
    switchingVariantSpliced = false;
  } else {
... }
...

后台播放

Udemy app支持后台服务播放media,我们希望在应用退到后台时音频文件依然能播放,并且展示一个通知栏给用户操作。在使用 MediaPlayer 的时候其实已经实现了这样的功能,但是很麻烦,因为需要service和activity之间不断的通信。

player.blockingSendMessage(
  videoRenderer,
  MediaCodecVideoTrackRenderer.MSG_SET_SURFACE,
  null);

而 ExoPlayer 内置了后台播放音频的能力,这让一切变得很简单。当切换到后台时第一件事就是清除播放器的surface,这样就能不attach到view上,这样音频就能在后台保持播放的状态。
上面几行代码展示了发送一个消息给播放器,设置surface一个占位,如果你直接跑官方的sample app,会发现可能在后台播放一段时间后就挂掉了,这是因为整个app的进程被系统杀死了,通过一个service可以避免这种现象,同样的在service中,我们照样可以创建 notification来控制整个播放器。

<service android:name="com.udemy.android.player.exoplayer.UdemyExoplayerService"
    android:exported="true" android:label="@string/app_name" android:enabled="true">
    <intent-filter>
      <action android:name="ccom.udemy.android.player.exoplayer.UdemyExoplayerService">
      </action>
    </intent-filter>
</service>

这里service和activity共用同一个player的实例,所以不需要通信来同步,这里以前的 MediaPlayer 的一个问题,当activity的生命周期变成 resume 状态时,我们可以重新将surface和view结合在一起,无缝的继续播放。

setPlayerSurface(surfaceView.getHolder().getSurface());
public void setSurface(Surface surface) {
  this.surface = surface;
  pushSurface(false);
}

字幕

Udemy app同样也能支持字幕,而且基于此给ExoPlayer 提了一些issue,比如:

  1. 不支持 .srt 文件格式 ,这是一种比较通用的字幕格式,
  2. 如果想要字幕,必须实例化另一个render,这也将导致可能出现的不同步,比如视频crash了,而字幕依旧在播放,所以需要在视频出错的情况下不会播放字幕。
  3. 希望能支持更多的格式,不仅是UTF-8,我们有许多不同的语言的字幕,这个功能十分的重要,现在的解决方案是手动的去填充字幕

对于视频的步调和时间戳,我们使用了一个 subtitle conversion library.。

public void displayExoplayerSubtitles(
    File file,
    final  MediaController.MediaPlayerControl playerControl,
    final ViewGroup subtitleLayout,
    final Context context) {
  convertFileCaptionList(file, context);
  runnableCode = new Runnable() {
    @Override
    public void run() {
      displayForPosition(playerControl.getCurrentPosition(), subtitleLayout, context);
      handler.postDelayed(runnableCode, 200);
    }
};
  handler.post(runnableCode);
}

改变回放速率

回放的速率可以变化是一个非常重要的功能,这是 MediaPlayer 所不能做到的。
在 ExoPlayer 中,视频一般都会有音频一起,在逻辑上我们保持音视频同步,一般都是视频跟着音频走,如果把音频的速率加快,这样视频就能跟着一起加快。
这里使用的另一个 library ,Sonic ,它可以拿到一个已有的音频的buffer,然后让其变得更快或是更慢,然后返回一个新的 buffer。
这里我们需要继承实现音频的渲染器

public class VariableSpeedAudioRenderer  extends MediaCodecAudioTrackRenderer

// Method to override
private byte[] sonicInputBuffer;
private byte[] sonicOutputBuffer;

@Override
protected void onOutputFormatChanged(final MediaFormat format) {

我们需要告诉音频渲染器 不要使用以前的buffer ,将这个buffer 给 Sonic ,然后返回一个新的 buffer, 直接先继承 MediaCodecAudioTrackRenderer,并且覆写两个方法。
第一个方法就是 onOutputFormatChanged ,它会在 track 第一次被实例化的时候会调用,在这个方法里我们将需要处理的buffer,以及Sonic本身都放进来,然后刷新整个流,设置用户选择的一个速度。

// Two samples per frame * 2 to support audio speeds down to 0.5
final int bufferSizeBytes = SAMPLES_PER_CODEC_FRAME * 2 * 2 * channelCount;

this.sonicInputBuffer = new byte[bufferSizeBytes];
this.sonicOutputBuffer = new byte[bufferSizeBytes];

this.sonic = new Sonic(
  format.getInteger(MediaFormat.KEY_SAMPLE_RATE),
  format.getInteger(MediaFormat.KEY_CHANNEL_COUNT));

this.lastInternalBuffer = ByteBuffer.wrap(sonicOutputBuffer, 0, 0);

sonic.flushStream();
sonic.setSpeed(audioSpeed);

第二个覆写的方法就是 processOutputBuffer,这里是真正拿到buffer,并且播放的地方,在这里我们拿到buffer后,将buffer写到Sonic中,然后从返回里面读到新的根据我们设置速率改变后的buffer,然后使用 superclass ,让父类实现去使用它。

@Override
protected boolean processOutputBuffer(..., final ByteBuffer buffer,...)
private ByteBuffer lastInternalBuffer;

buffer.get(sonicInputBuffer, 0, bytesToRead);
sonic.writeBytesToStream(sonicInputBuffer, bytesToRead);
sonic.readBytesFromStream(sonicOutputBuffer, sonicOutputBuffer.length);

return super.processOutputBuffer(..., lastInternalBuffer, ...);
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容