好看視頻Android重構(gòu)——圍繞于播放器的重構(gòu)實(shí)踐

一、背景介紹


二、好看視頻歷史回顧——單播放器
ViewPager和上下翻頁(yè)的RecyclerView(豎劃翻頁(yè)配合使用了PagerSnapHelper)同時(shí)移動(dòng)。mViewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
mVideoView.moveVideoViewByX(positionOffsetPixels);
}
}
mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
mVideoView.moveVideoViewByY(dy);
}
});
1、業(yè)務(wù)耦合嚴(yán)重,開發(fā)效率低
播放器和業(yè)務(wù)代碼耦合嚴(yán)重,多個(gè)核心類代碼1萬(wàn)+行,維護(hù)成本高,對(duì)新人極其不友好。播放器在初始化時(shí)就有221個(gè)View,各個(gè)View之間的隱藏和顯示邏輯復(fù)雜,函數(shù)括號(hào)嵌套層次非常深,維護(hù)成本極高。Feed列表只承載視頻封面圖,導(dǎo)致廣告/直播等第三方業(yè)務(wù)既要負(fù)責(zé)holder的展示,又要獨(dú)立創(chuàng)建高層級(jí)的播放器進(jìn)行控制,代碼復(fù)雜度極高。 播放器狀態(tài)控制復(fù)雜紊亂,從Activity、Fragment、ViewPager、RecyclerView、RecyclerViewAdapter、RecyclerViewHolder、每個(gè)View都能直接控制全局的單例播放器,生命周期難以追蹤,播放相關(guān)的bug和用戶反饋定位十分困難。
2、性能問(wèn)題尾大不掉
由于播放器是飄在所有View的最上層,導(dǎo)致某些業(yè)務(wù)的View如果需要在最頂層,只能放在播放器內(nèi)部再重新實(shí)現(xiàn)一遍。 RecyclerViewHolder中的某些View,既要在holder中又要在播放器的View中,再加上歷史的陳舊代碼,線上大量出現(xiàn)播放器View初始化時(shí)的ANR和卡頓


Feed列表滑動(dòng)需要同步播放器進(jìn)行卡尺滑動(dòng)(包括播放器復(fù)位等),導(dǎo)致啟播速度人為劣化。 低級(jí)別組件需要持有Activity級(jí)別的句柄,非常容易產(chǎn)生內(nèi)存泄漏。 無(wú)法直接獲取Activity句柄的業(yè)務(wù),大量通過(guò)EventBus分發(fā)消息和控制邏輯,導(dǎo)致播放控制混亂(EventBus事件混亂和組件生命周期事件沖突等)。EventBus不僅加劇了內(nèi)存泄漏的風(fēng)險(xiǎn),還導(dǎo)致一些列的性能問(wèn)題。
三、好看視頻重構(gòu)項(xiàng)目——多播放器
在holder內(nèi)實(shí)現(xiàn)播放器狀態(tài)自洽管理,直播/廣告等業(yè)務(wù)僅在holder就可以實(shí)現(xiàn)自身業(yè)務(wù)(包括播放控制等),減少無(wú)用邏輯,降低代碼耦合。 通過(guò)LifecycleLite分發(fā)播放相關(guān)事件,降低對(duì)EventBus的依賴,降低組件間耦合和內(nèi)存泄漏風(fēng)險(xiǎn)。 利用自定義PageSnapHelper等組件,集中優(yōu)化Feed列表啟播/預(yù)加載等核心播放體驗(yàn)。

重構(gòu)前后的掉幀率對(duì)比:
輕微掉幀次數(shù)/10分鐘 | 嚴(yán)重掉幀次數(shù)/10分鐘 | |
重構(gòu)前 | 350 | 77 |
重構(gòu)后 | 150 | 18 |
關(guān)于起播時(shí)間的優(yōu)化
1、關(guān)于播放器創(chuàng)建的時(shí)機(jī)
onBindViewHolder準(zhǔn)備頁(yè)面和數(shù)據(jù),所以可以在RecyclerViewHolder的onBind時(shí)就初始化下一個(gè)待播放視頻的播放器。@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (holder instanceof ImmersiveBaseHolder) {
((ImmersiveBaseHolder) holder).onBind(getData(position), position);
holder.createPlayer();
}
}
2、關(guān)于播放器開始播放(start)的時(shí)機(jī)
RecyclerView的onScrollStateChanged中判斷列表滑動(dòng)的狀態(tài),當(dāng)RecyclerView滑動(dòng)停止時(shí)再起播,并結(jié)束上一個(gè)視頻的播放。mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
if (newState == SCROLL_STATE_SETTLING) {
currentHolder.player.prepareAysnc();
lastHolder.player.stopAndRelease();
}
}
});
PagerSnapHelper會(huì)計(jì)算要跳轉(zhuǎn)的視頻,并根據(jù)速度和剩余的滑動(dòng)距離計(jì)算時(shí)間,通過(guò)SmoothScroller做慣性滾動(dòng)動(dòng)畫——我們考慮下,如果在松開手指的一刻,換句話說(shuō),當(dāng)我們明確知道了下一個(gè)待播放的視頻時(shí),就趕緊播放它,會(huì)有什么效果?幾乎秒播
onInfo的MEDIA_INFO_VIDEO_RENDERING_START回調(diào)),大概需要300-500ms,而從手指開屏幕到滑動(dòng)結(jié)束,也接近200-300ms。一般來(lái)說(shuō),起播速度在200ms左右用戶幾乎可以認(rèn)為是”秒開“,所以提前起播對(duì)用戶體驗(yàn)的提升巨大。// PagerSnapHelper.java
@Override
protected LinearSmoothScroller createSnapScroller(RecyclerView.LayoutManager layoutManager) {
return new LinearSmoothScroller(mRecyclerView.getContext()) {
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
int nextPosition = state.getTargetScrollPosition();
adapter.getHolder(nextPosition).player.start();
adapter.getHolder(currentPosition).player.stopAndRelease();
}
}
}
// 重點(diǎn)在 onTargetFound 此時(shí)已經(jīng)成功定位被選擇的holder
onBindViewHolder中創(chuàng)建播放器后,立即prepare播放器,但不調(diào)用start。此類優(yōu)化需要對(duì)播放器的生命周期掌握極其熟練,處理不當(dāng)很容易導(dǎo)致多個(gè)視頻同時(shí)播放或者其他的隱藏bug,需要格外小心。3、更早的起播
attachToWindow中關(guān)于新架構(gòu)的整體收益
四、淺談播放器預(yù)加載
1、關(guān)于預(yù)加載的文件大小問(wèn)題
$pip install qtfaststart
$qtfaststart -l 曾經(jīng)的你.mp4
ftyp (32 bytes)
moov (6891 bytes)
free (8 bytes)
mdat (3244183 bytes)
$ffprobe 曾經(jīng)的你.mp4 -show_frames | grep -E 'pict_type|coded_picture_number|pkt_size'
pkt_size=28604
pict_type=I
coded_picture_number=0
pkt_size=145
pkt_size=479
pkt_size=568
pict_type=B
coded_picture_number=3
pkt_size=476
pkt_size=531
pkt_size=1224
pict_type=B
coded_picture_number=2
pkt_size=703
2、關(guān)于預(yù)加載的時(shí)機(jī)問(wèn)題
3、關(guān)于預(yù)加載庫(kù)AndroidVideoCache
計(jì)算機(jī)科學(xué)領(lǐng)域的任何問(wèn)題都可以通過(guò)增加一個(gè)間接的中間層來(lái)解決。
Any Problem in computer science can be sovled by another layer of indircetion.

// in HttpProxyCacheServer.java
static final int PRELOAD_CACHE_SIZE = 300 * 1024;
public void preload(Context context, String url, int preloadSize) {
socketProcessor.submit(new PreloadProcessorRunnable(url, preloadSize));
}
private final class PreloadProcessorRunnable implements Runnable {
private final String url;
private int preloadSize = PRELOAD_CACHE_SIZE;
public PreloadProcessorRunnable(String url, int preloadSize) {
this.url = url;
this.preloadSize = preloadSize;
}
@Override
public void run() {
processPreload(url, preloadSize);
}
}
private void processPreload(String url, int preloadSize) {
try {
HttpProxyCacheServerClients clients = getClients(url);
clients.processPreload(preloadSize);
clientsMap.remove(url);
} catch (ProxyCacheException | IOException e) {
e.printStackTrace();
}
}
public void stopPreload(String url) {
try {
HttpProxyCacheServerClients clients = getClientsWithoutNew(url);
if(clients != null) {
clients.shutdown();
}
} catch (ProxyCacheException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}
// HttpProxyCacheServerClients.java
public void processPreload(int preloadSize) throws ProxyCacheException, IOException {
startProcessRequest();
try {
clientsCount.incrementAndGet();
proxyCache.processPreload(preloadSize);
} finally {
finishProcessRequest();
ProxyLogUtil.d(TAG, "processPreload finishProcessRequest");
}
}
// HttpProxyCache.java
public void processPreload(int preloadSize) throws IOException, ProxyCacheException {
long cacheAvailable = cache.available();
if (cacheAvailable < preloadSize) {
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int readBytes;
long offset = cacheAvailable;
while ((readBytes = read(buffer, offset, buffer.length)) != -1) {
offset += readBytes;
if (offset > preloadSize) break;
}
ProxyLogUtil.d(TAG, "preloaded url = " + source.getUrl() + ", offset = " + offset + ", preloadSize = " + preloadSize);
}
}
// 僅供學(xué)習(xí)使用,不適用生產(chǎn)環(huán)境
五、淺談播放器卡頓

1 long ijkmp_get_duration(IjkMediaPlayer *mp)
2 {
3 assert(mp);
4 pthread_mutex_lock(&mp->mutex);
5 long retval = ijkmp_get_duration_l(mp);
6 pthread_mutex_unlock(&mp->mutex);
7 return retval;
8 }
addr2line或者ndk-stack定位到有大量崩潰發(fā)生在第5行,mp為空指針導(dǎo)致crash。這個(gè)不難猜測(cè),既然App沒(méi)有crash在第3行的assert語(yǔ)句而崩潰在了后面,說(shuō)明必定發(fā)生了在這把鎖控制之外的線程問(wèn)題。一個(gè)簡(jiǎn)單的解決方案是再次加入判空處理,但此方案依然不能完全杜絕crash。static long ijkmp_get_duration_l(IjkMediaPlayer *mp)
{
if (mp == NULL) {
return 0;
}
return ffp_get_duration_l(mp->ffplayer);
}
// NOTICE: 此方案仍存在線程沖突問(wèn)題
isPlayerReleased,在播放器銷毀之前將此變量置為true,后面對(duì)播放器的所有操作都要直接忽略;// in https://github.com/bilibili/ijkplayer/blob/master/android/ijkplayer/ijkplayer-java/src/main/java/tv/danmaku/ijk/media/player/IjkMediaPlayer.java
private static class EventHandler extends Handler {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MEDIA_PREPARED:
player.notifyOnPrepared();
return;
case MEDIA_PLAYBACK_COMPLETE:
player.stayAwake(false);
player.notifyOnCompletion();
return;
case MEDIA_BUFFERING_UPDATE:
long bufferPosition = msg.arg1;
if (bufferPosition < 0) {
bufferPosition = 0;
}
...
isPlayerReleased控制,在重新編譯播放器內(nèi)核之后,線上跟播放器相關(guān)的crash幾乎消失六、架構(gòu)、性能優(yōu)化的意義

技術(shù)交流,歡迎加我微信:ezglumes ,拉你入技術(shù)交流群。
推薦閱讀:
音視頻開發(fā)工作經(jīng)驗(yàn)分享 || 視頻版
開通專輯 | 細(xì)數(shù)那些年寫過(guò)的技術(shù)文章專輯
NDK 學(xué)習(xí)進(jìn)階免費(fèi)視頻來(lái)了
推薦幾個(gè)堪稱教科書級(jí)別的 Android 音視頻入門項(xiàng)目
覺(jué)得不錯(cuò),點(diǎn)個(gè)在看唄~

評(píng)論
圖片
表情
