原演講地址
Switching to ExoPlayer: Better Video on Android
在 360|AnDev 的演講中慈缔,Effie Barak展示了Udemy從MediaPlayer轉(zhuǎn)型到Exoplayer的過程减途,包括了基本的實(shí)現(xiàn),除此之外舒萎,還有ExoPlayer的一些高級(jí)視頻功能程储,比如后臺(tái)播放,可變播放速度臂寝,字幕章鲤,以及不同分辨率播放等功能。
開場(chǎng)介紹
Udemy 最開始的播放器使用的是 MediaPlayer 咆贬。在六個(gè)月前败徊,我們決定轉(zhuǎn)型到 ExoPlayer 。ExoPlayer是Android上一個(gè)應(yīng)用級(jí)別的媒體播放器掏缎,它提供了可選的方案來播放媒體文件而不是僅僅使用 MediaPlayer皱蹦。這是一個(gè)Google出品的第三方的播放器的library,完全使用java編寫眷蜈,并且只依賴低級(jí)的媒體編碼API沪哺。
Udemy APP 主要是用來觀看教學(xué)課程以及視頻講座,所以媒體部分是整個(gè)應(yīng)用最核心的功能端蛆。一個(gè)穩(wěn)定可靠的播放器是十分重要的凤粗。同時(shí),我們也需要一些可自定義的部分今豆,除了基本的播放嫌拣、暫停、下一個(gè)之外的一些更酷炫的功能呆躲。
MediaPlayer對(duì)比ExoPlayer
mediaPlayer.setDataSource(url);
mediaPlayer.prepare();
mediaPlayer.start();
MediaPlayer 有一些優(yōu)點(diǎn)异逐,最主要的就是開始使用很簡(jiǎn)單,你只需要上面這三行代碼就能播放大部分的文件插掂。
缺點(diǎn)就是可定制性不高灰瞻,不易擴(kuò)展。隨著功能迭代辅甥,我們需要app添加一些更多的功能酝润,比如支持HLS流,播放速率可變璃弄,這些都是 MediaPlayer 所不能立即完成的要销。
除此之外 MediaPlayer 也有一些其他的缺點(diǎn):這是一個(gè)黑盒,并且內(nèi)部都是native的方法夏块,很難去debug和弄清楚到底異常是怎么出現(xiàn)的疏咐。而且 MediaPlayer 作為framework級(jí)別的解決方案纤掸,這樣在不同的版本,不同的ROM上表現(xiàn)會(huì)有差異浑塞,我們不能控制和擔(dān)保到底使用的是什么樣的版本借跪。而且 MediaPlayer 會(huì)有一些各種奇怪的異常code,很難確信這些crash是怎么產(chǎn)生的酌壕。
ExoPlayer 解決了上述這些提到的問題掏愁,它具有強(qiáng)大的可擴(kuò)展性,但是一開始的學(xué)習(xí)曲線比較陡峭卵牍。好在這是開源的托猩,源碼易于閱讀,并且容易debug辽慕。實(shí)現(xiàn)上基于 MediaCodec京腥,能夠處理HLS流,同時(shí)也支持后臺(tái)播放溅蛉,播放速率公浪,分辨率可配置。
ExoPlayer基礎(chǔ)
不同于 MediaPlayer船侧,使用 ExoPlayer 需要更多的一些代碼來實(shí)現(xiàn)播放視頻欠气,主要分為兩部分:一個(gè)是UI部分來控制播放器的行為(播放,暫停镜撩,下一個(gè))预柒,第二個(gè)核心部分就是獲取數(shù)據(jù)流,解碼袁梗,處理流宜鸯。
播放器
player = ExoPlayer.Factory.newInstance(
PlayerConstants.RENDERER_COUNT,
MIN_BUFFER_MS,
MIN_REBUFFER_MS);
playerControl = new PlayerControl(player);
上面是一個(gè)播放器被初始化的例子,下面的 playerControl 是一個(gè)默認(rèn)組件用來和播放器一起工作遮怜。為了從播放組件得到各種返回的信息來做更多的行為淋袖,比如失敗后的重試,我們可以通過引擎組件的一系列監(jiān)聽達(dá)成這樣的效果
public abstract class UdemyBaseExoplayer
implements ExoPlayer.Listener,
ChunkSampleSource.EventListener,
HlsSampleSource.EventListener,
DefaultBandwidthMeter.EventListener,
MediaCodecVideoTrackRenderer.EventListener,
MediaCodecAudioTrackRenderer.EventListener
player.addListener(this);
核心
如果我們想要處理一些非自適應(yīng)的流類型(比如MP3或者M(jìn)P4)會(huì)有一點(diǎn)不一樣锯梁,如果是HLS和Dash流就更復(fù)雜一些即碗。
流的來源一般是一個(gè)URI,然后通過一個(gè) ExtractorSampleSource
來獲取流陌凳,并且會(huì)根據(jù)編碼類型有一個(gè)對(duì)應(yīng)的實(shí)際流處理(比如是MP4剥懒,就是Mp4Extractor),這些Extractor通過將視頻和音頻文件解碼成原始的信息,通過Render的方式給播放器來進(jìn)行播放合敦。
還有另一種方式初橘,播放器直接向renders要緩沖buffer,這樣它就會(huì)說,“我沒有可以放的東西啦壁却,ExtractorSampleSource,再給我一些數(shù)據(jù)”裸准,然后 ExtractorSampleSource
就會(huì)說展东,“我需要獲取一些數(shù)據(jù),會(huì)通過默認(rèn)的 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 對(duì)于基本結(jié)構(gòu)做了一些優(yōu)化榔昔,比如減少了緩沖區(qū)buffer的大小以及單一片段 segment的大小驹闰,因?yàn)樵谝恍┑投藱C(jī)型上會(huì)出現(xiàn)內(nèi)存不足的異常。
而且在使用上撒会,一般我們已經(jīng)知道了我們要播放的媒體文件類型嘹朗,我們可以只需要相應(yīng)類型的extractor ,讓程序更加的精簡(jiǎn)诵肛,比如播放一個(gè)MP4文件只需要下面這些配置
mp4Extractor = new Mp4Extractor();
mp3Extractor = new Mp3Extractor();
sampleSource = new ExtractorSampleSource(..., mp4Extractor, mp3Extractor);
HLS流
對(duì)于播放HLS還是有一點(diǎn)復(fù)雜的屹培,HLS是一種特殊協(xié)議流,將原始數(shù)據(jù)分割成很多小的序列文件來下載怔檩。每個(gè)下載都是整體流中的一小塊褪秀,在播放時(shí),客戶端可以從不同的數(shù)據(jù)流中選擇可以替換的流薛训,并且允許流基于帶寬等邏輯來進(jìn)行切換溜歪。
在流會(huì)話的開始,會(huì)有一個(gè) m3u8的文件许蓖,這個(gè)文件就是存儲(chǔ)了不同分片ts文件的列表蝴猪,HLS會(huì)根據(jù)帶寬來決定使用哪一個(gè)chunk。然后剩下的就比較相似了膊爪,選擇好了哪個(gè)質(zhì)量的流之后自阱,這些流會(huì)被 HlsChunkSource
和 HlsSampleSource
處理,一般我們不需要寫額外的邏輯米酬,使用默認(rèn)的就好了沛豌,除非想自定義一些行為。
DefaultBandwidthMeter bandwidthMeter = new DefaultBandwidthMeter();
PtsTimestampAdjusterProvider timestampAdjusterProvider =
new PtsTimestampAdjusterProvider();
HlsChunkSource chunkSource = new HlsChunkSource(...,
uriDataSource,
url,...,
bandwidthMeter,
timestampAdjusterProvider,
HlsChunkSource.ADAPTIVE_MODE_SPLICE);
上面這段初始化了一個(gè)新的 HlsChunkSource
,首先通過一個(gè) bandwidthMeter
來計(jì)算當(dāng)前有效的帶寬加派,同時(shí)還有一個(gè) PtsTimestamp
的對(duì)象叫确,Pts是一個(gè)用來度量解碼后的視頻幀什么時(shí)候播放的參數(shù),這里也用默認(rèn)的就行了芍锦。
最后一個(gè) HlsChunkSource 構(gòu)造函數(shù)的參數(shù)是一個(gè)mode竹勉,總共有三種mode:none
,splice
娄琉,abrupt
次乓。
none
沒什么特殊的,這樣選擇從一開始到最后都是一樣的
splice
意味著在chunk間切換時(shí)孽水,會(huì)同時(shí)下載老的以及新的票腰,以免出現(xiàn)前一個(gè)還沒結(jié)束后一個(gè)已經(jīng)開始,這樣會(huì)導(dǎo)致切換產(chǎn)生噪點(diǎn)女气。
abrupt
這個(gè)只會(huì)下載新的杏慰,老的不管,如果出現(xiàn)時(shí)間戳不匹配的話炼鞠,就容易在分片切換時(shí)出現(xiàn)噪點(diǎn)
綜合來看使用splice是比較好的選擇逃默,當(dāng)然會(huì)下載兩份流量上有一些偏多。
sampleSource = new HlsSampleSource(chunkSource, ...);
然后需要一個(gè) HlsSampleSource
簇搅,持有一個(gè)上面初始化的 chunkSource 對(duì)象完域,上面的這些是一個(gè)例子展示了一個(gè) HlsChunkSource 怎么拿到 metafile,并組成一個(gè)可供選擇的流列表瘩将,并最終展示出來吟税。
在 Udemy ,我們有時(shí)候需要重寫這個(gè)例子姿现,比如有時(shí)只給用戶一個(gè)選擇肠仪,只有一種質(zhì)量的視頻,不需要?jiǎng)討B(tài)改變視頻質(zhì)量备典,那么我們就能將 mode 轉(zhuǎn)變?yōu)?ADAPTIVE_MODE_NONE
HlsChunkSource chunkSource = new HlsChunkSource(..., HlsChunkSource.ADAPTIVE_MODE_NONE);
而且我們有時(shí)候也需要告訴播放器從哪種質(zhì)量開始异旧,如果沒有設(shè)置,播放器總是選擇一個(gè)默認(rèn)的值提佣,但是手動(dòng)設(shè)置改變它是比較麻煩的吮蛹,因?yàn)檫@個(gè)參數(shù)是一個(gè)私有private變量 private int selectedVariantIndex
,這個(gè)變量貫穿了整個(gè)類拌屏,特別是 getChunkOperation
方法潮针,所以我們不得不創(chuàng)建一個(gè)自己的類實(shí)例來添加這個(gè)功能,這也算是設(shè)計(jì)的一個(gè)瑕疵了
// 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 {
... }
...
后臺(tái)播放
Udemy app支持后臺(tái)服務(wù)播放media倚喂,我們希望在應(yīng)用退到后臺(tái)時(shí)音頻文件依然能播放每篷,并且展示一個(gè)通知欄給用戶操作。在使用 MediaPlayer 的時(shí)候其實(shí)已經(jīng)實(shí)現(xiàn)了這樣的功能,但是很麻煩焦读,因?yàn)樾枰猻ervice和activity之間不斷的通信子库。
player.blockingSendMessage(
videoRenderer,
MediaCodecVideoTrackRenderer.MSG_SET_SURFACE,
null);
而 ExoPlayer 內(nèi)置了后臺(tái)播放音頻的能力,這讓一切變得很簡(jiǎn)單矗晃。當(dāng)切換到后臺(tái)時(shí)第一件事就是清除播放器的surface仑嗅,這樣就能不attach到view上,這樣音頻就能在后臺(tái)保持播放的狀態(tài)喧兄。
上面幾行代碼展示了發(fā)送一個(gè)消息給播放器,設(shè)置surface一個(gè)占位啊楚,如果你直接跑官方的sample app吠冤,會(huì)發(fā)現(xiàn)可能在后臺(tái)播放一段時(shí)間后就掛掉了,這是因?yàn)檎麄€(gè)app的進(jìn)程被系統(tǒng)殺死了恭理,通過一個(gè)service可以避免這種現(xiàn)象拯辙,同樣的在service中,我們照樣可以創(chuàng)建 notification來控制整個(gè)播放器颜价。
<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共用同一個(gè)player的實(shí)例涯保,所以不需要通信來同步,這里以前的 MediaPlayer 的一個(gè)問題周伦,當(dāng)activity的生命周期變成 resume 狀態(tài)時(shí)夕春,我們可以重新將surface和view結(jié)合在一起,無縫的繼續(xù)播放专挪。
setPlayerSurface(surfaceView.getHolder().getSurface());
public void setSurface(Surface surface) {
this.surface = surface;
pushSurface(false);
}
字幕
Udemy app同樣也能支持字幕及志,而且基于此給ExoPlayer 提了一些issue,比如:
- 不支持 .srt 文件格式 寨腔,這是一種比較通用的字幕格式速侈,
- 如果想要字幕,必須實(shí)例化另一個(gè)render迫卢,這也將導(dǎo)致可能出現(xiàn)的不同步倚搬,比如視頻crash了,而字幕依舊在播放乾蛤,所以需要在視頻出錯(cuò)的情況下不會(huì)播放字幕每界。
- 希望能支持更多的格式,不僅是UTF-8家卖,我們有許多不同的語言的字幕盆犁,這個(gè)功能十分的重要,現(xiàn)在的解決方案是手動(dòng)的去填充字幕
對(duì)于視頻的步調(diào)和時(shí)間戳篡九,我們使用了一個(gè) 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);
}
改變回放速率
回放的速率可以變化是一個(gè)非常重要的功能,這是 MediaPlayer 所不能做到的。
在 ExoPlayer 中伊佃,視頻一般都會(huì)有音頻一起窜司,在邏輯上我們保持音視頻同步,一般都是視頻跟著音頻走航揉,如果把音頻的速率加快塞祈,這樣視頻就能跟著一起加快。
這里使用的另一個(gè) library 帅涂,Sonic 议薪,它可以拿到一個(gè)已有的音頻的buffer,然后讓其變得更快或是更慢媳友,然后返回一個(gè)新的 buffer斯议。
這里我們需要繼承實(shí)現(xiàn)音頻的渲染器
public class VariableSpeedAudioRenderer extends MediaCodecAudioTrackRenderer
// Method to override
private byte[] sonicInputBuffer;
private byte[] sonicOutputBuffer;
@Override
protected void onOutputFormatChanged(final MediaFormat format) {
我們需要告訴音頻渲染器 不要使用以前的buffer ,將這個(gè)buffer 給 Sonic 醇锚,然后返回一個(gè)新的 buffer哼御, 直接先繼承 MediaCodecAudioTrackRenderer
,并且覆寫兩個(gè)方法焊唬。
第一個(gè)方法就是 onOutputFormatChanged
恋昼,它會(huì)在 track 第一次被實(shí)例化的時(shí)候會(huì)調(diào)用,在這個(gè)方法里我們將需要處理的buffer赶促,以及Sonic本身都放進(jìn)來液肌,然后刷新整個(gè)流,設(shè)置用戶選擇的一個(gè)速度鸥滨。
// 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);
第二個(gè)覆寫的方法就是 processOutputBuffer
矩屁,這里是真正拿到buffer,并且播放的地方爵赵,在這里我們拿到buffer后吝秕,將buffer寫到Sonic中,然后從返回里面讀到新的根據(jù)我們?cè)O(shè)置速率改變后的buffer空幻,然后使用 superclass 烁峭,讓父類實(shí)現(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, ...);