原演講地址
Switching to ExoPlayer: Better Video on Android
在 360|AnDev 的演講中,Effie Barak展示了Udemy從MediaPlayer轉(zhuǎn)型到Exoplayer的過程,包括了基本的實現(xiàn),除此之外,還有ExoPlayer的一些高級視頻功能,比如后臺播放,可變播放速度,字幕,以及不同分辨率播放等功能。
開場介紹
Udemy 最開始的播放器使用的是 MediaPlayer 。在六個月前,我們決定轉(zhuǎn)型到 ExoPlayer 。ExoPlayer是Android上一個應用級別的媒體播放器,它提供了可選的方案來播放媒體文件而不是僅僅使用 MediaPlayer。這是一個Google出品的第三方的播放器的library,完全使用java編寫,并且只依賴低級的媒體編碼API。
Udemy APP 主要是用來觀看教學課程以及視頻講座,所以媒體部分是整個應用最核心的功能。一個穩(wěn)定可靠的播放器是十分重要的。同時,我們也需要一些可自定義的部分,除了基本的播放、暫停、下一個之外的一些更酷炫的功能。
MediaPlayer對比ExoPlayer
mediaPlayer.setDataSource(url);
mediaPlayer.prepare();
mediaPlayer.start();
MediaPlayer 有一些優(yōu)點,最主要的就是開始使用很簡單,你只需要上面這三行代碼就能播放大部分的文件。
缺點就是可定制性不高,不易擴展。隨著功能迭代,我們需要app添加一些更多的功能,比如支持HLS流,播放速率可變,這些都是 MediaPlayer 所不能立即完成的。
除此之外 MediaPlayer 也有一些其他的缺點:這是一個黑盒,并且內(nèi)部都是native的方法,很難去debug和弄清楚到底異常是怎么出現(xiàn)的。而且 MediaPlayer 作為framework級別的解決方案,這樣在不同的版本,不同的ROM上表現(xiàn)會有差異,我們不能控制和擔保到底使用的是什么樣的版本。而且 MediaPlayer 會有一些各種奇怪的異常code,很難確信這些crash是怎么產(chǎn)生的。
ExoPlayer 解決了上述這些提到的問題,它具有強大的可擴展性,但是一開始的學習曲線比較陡峭。好在這是開源的,源碼易于閱讀,并且容易debug。實現(xiàn)上基于 MediaCodec,能夠處理HLS流,同時也支持后臺播放,播放速率,分辨率可配置。
ExoPlayer基礎
不同于 MediaPlayer,使用 ExoPlayer 需要更多的一些代碼來實現(xiàn)播放視頻,主要分為兩部分:一個是UI部分來控制播放器的行為(播放,暫停,下一個),第二個核心部分就是獲取數(shù)據(jù)流,解碼,處理流。
播放器
player = ExoPlayer.Factory.newInstance(
PlayerConstants.RENDERER_COUNT,
MIN_BUFFER_MS,
MIN_REBUFFER_MS);
playerControl = new PlayerControl(player);
上面是一個播放器被初始化的例子,下面的 playerControl 是一個默認組件用來和播放器一起工作。為了從播放組件得到各種返回的信息來做更多的行為,比如失敗后的重試,我們可以通過引擎組件的一系列監(jiān)聽達成這樣的效果
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 來獲取流,并且會根據(jù)編碼類型有一個對應的實際流處理(比如是MP4,就是Mp4Extractor),這些Extractor通過將視頻和音頻文件解碼成原始的信息,通過Render的方式給播放器來進行播放。
還有另一種方式,播放器直接向renders要緩沖buffer,這樣它就會說,“我沒有可以放的東西啦,ExtractorSampleSource,再給我一些數(shù)據(jù)”,然后 ExtractorSampleSource 就會說,“我需要獲取一些數(shù)據(jù),會通過默認的 URI 數(shù)據(jù)源來拿數(shù)據(jù)”。
第一眼看過去這些操作需要不少代碼,但我們真的需要寫這么多東西么?
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過來,而且能直接使用,所以并不需要我們?nèi)戇@些代碼。
// 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 自定義的一些結(jié)構(gòu)
Udemy 對于基本結(jié)構(gòu)做了一些優(yōu)化,比如減少了緩沖區(qū)buffer的大小以及單一片段 segment的大小,因為在一些低端機型上會出現(xiàn)內(nèi)存不足的異常。
而且在使用上,一般我們已經(jīng)知道了我們要播放的媒體文件類型,我們可以只需要相應類型的extractor ,讓程序更加的精簡,比如播放一個MP4文件只需要下面這些配置
mp4Extractor = new Mp4Extractor();
mp3Extractor = new Mp3Extractor();
sampleSource = new ExtractorSampleSource(..., mp4Extractor, mp3Extractor);
HLS流
對于播放HLS還是有一點復雜的,HLS是一種特殊協(xié)議流,將原始數(shù)據(jù)分割成很多小的序列文件來下載。每個下載都是整體流中的一小塊,在播放時,客戶端可以從不同的數(shù)據(jù)流中選擇可以替換的流,并且允許流基于帶寬等邏輯來進行切換。
在流會話的開始,會有一個 m3u8的文件,這個文件就是存儲了不同分片ts文件的列表,HLS會根據(jù)帶寬來決定使用哪一個chunk。然后剩下的就比較相似了,選擇好了哪個質(zhì)量的流之后,這些流會被 HlsChunkSource 和 HlsSampleSource 處理,一般我們不需要寫額外的邏輯,使用默認的就好了,除非想自定義一些行為。
DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter();
PtsTimestampAdjusterProvider timestampAdjusterProvider =
new PtsTimestampAdjusterProvider();
HlsChunkSource chunkSource = new HlsChunkSource(...,
uriDataSource,
url,...,
bandwidthMeter,
timestampAdjusterProvider,
HlsChunkSource.ADAPTIVE_MODE_SPLICE);
上面這段初始化了一個新的 HlsChunkSource ,首先通過一個 bandwidthMeter 來計算當前有效的帶寬,同時還有一個 PtsTimestamp 的對象,Pts是一個用來度量解碼后的視頻幀什么時候播放的參數(shù),這里也用默認的就行了。
最后一個 HlsChunkSource 構(gòu)造函數(shù)的參數(shù)是一個mode,總共有三種mode:none,splice,abrupt。
none 沒什么特殊的,這樣選擇從一開始到最后都是一樣的
splice 意味著在chunk間切換時,會同時下載老的以及新的,以免出現(xiàn)前一個還沒結(jié)束后一個已經(jīng)開始,這樣會導致切換產(chǎn)生噪點。
abrupt 這個只會下載新的,老的不管,如果出現(xiàn)時間戳不匹配的話,就容易在分片切換時出現(xiàn)噪點
綜合來看使用splice是比較好的選擇,當然會下載兩份流量上有一些偏多。
sampleSource = new HlsSampleSource(chunkSource, ...);
然后需要一個 HlsSampleSource ,持有一個上面初始化的 chunkSource 對象,上面的這些是一個例子展示了一個 HlsChunkSource 怎么拿到 metafile,并組成一個可供選擇的流列表,并最終展示出來。
在 Udemy ,我們有時候需要重寫這個例子,比如有時只給用戶一個選擇,只有一種質(zhì)量的視頻,不需要動態(tài)改變視頻質(zhì)量,那么我們就能將 mode 轉(zhuǎn)變?yōu)?ADAPTIVE_MODE_NONE
HlsChunkSource chunkSource = new HlsChunkSource(..., HlsChunkSource.ADAPTIVE_MODE_NONE);
而且我們有時候也需要告訴播放器從哪種質(zhì)量開始,如果沒有設置,播放器總是選擇一個默認的值,但是手動設置改變它是比較麻煩的,因為這個參數(shù)是一個私有private變量 private int selectedVariantIndex,這個變量貫穿了整個類,特別是 getChunkOperation 方法,所以我們不得不創(chuàng)建一個自己的類實例來添加這個功能,這也算是設計的一個瑕疵了
// 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 的時候其實已經(jīng)實現(xiàn)了這樣的功能,但是很麻煩,因為需要service和activity之間不斷的通信。
player.blockingSendMessage(
videoRenderer,
MediaCodecVideoTrackRenderer.MSG_SET_SURFACE,
null);
而 ExoPlayer 內(nèi)置了后臺播放音頻的能力,這讓一切變得很簡單。當切換到后臺時第一件事就是清除播放器的surface,這樣就能不attach到view上,這樣音頻就能在后臺保持播放的狀態(tài)。
上面幾行代碼展示了發(fā)送一個消息給播放器,設置surface一個占位,如果你直接跑官方的sample app,會發(fā)現(xiàn)可能在后臺播放一段時間后就掛掉了,這是因為整個app的進程被系統(tǒng)殺死了,通過一個service可以避免這種現(xiàn)象,同樣的在service中,我們照樣可以創(chuàng)建 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 狀態(tài)時,我們可以重新將surface和view結(jié)合在一起,無縫的繼續(xù)播放。
setPlayerSurface(surfaceView.getHolder().getSurface());
public void setSurface(Surface surface) {
this.surface = surface;
pushSurface(false);
}
字幕
Udemy app同樣也能支持字幕,而且基于此給ExoPlayer 提了一些issue,比如:
- 不支持 .srt 文件格式 ,這是一種比較通用的字幕格式,
- 如果想要字幕,必須實例化另一個render,這也將導致可能出現(xiàn)的不同步,比如視頻crash了,而字幕依舊在播放,所以需要在視頻出錯的情況下不會播放字幕。
- 希望能支持更多的格式,不僅是UTF-8,我們有許多不同的語言的字幕,這個功能十分的重要,現(xiàn)在的解決方案是手動的去填充字幕
對于視頻的步調(diào)和時間戳,我們使用了一個 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。
這里我們需要繼承實現(xiàn)音頻的渲染器
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 第一次被實例化的時候會調(diào)用,在這個方法里我們將需要處理的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中,然后從返回里面讀到新的根據(jù)我們設置速率改變后的buffer,然后使用 superclass ,讓父類實現(xiàn)去使用它。
@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, ...);