這片文章基於開源項目: VideoPlayerManager。
所有的代碼和示例都在那里。本文將跳過許多東西。因此如果你要真正理解它是如何工作的,最好下載源碼,並結合源代碼一起閱讀本文。但是即便是沒有看源代碼,本文也能幫助你理解我們在干什么。
兩個問題
要實現我們需要的功能,我們必須解決兩個問題:
-
我們需要管理視頻的播放。在安卓中,我們有一個和SurfaceView 一起工作的MediaPlayer.class 類可以播放視頻。但是它有許多缺陷。我們不能在列表中使用普通的VideoView 。VideoView 繼承自SurfaceView,而SurfaceView並沒有UI同步緩沖區。這就導致了在列表滾動的時候,正在播放的視頻需要跟上滾動的步伐。TextureView 中有同步緩沖區,但是在Android SDK version 15 中沒有基於TextureView 的VideoView。因此我們需要一個繼承自TextureView 並和Android MediaPlayer一起工作的View。幾乎所有MediaPlayer中的方法(prepare, start, stop 等等…)都調用和硬件相關的本地方法。當做了長於16ms的工作時(必然會),硬件會非常棘手然后我們就會看到一個卡頓的列表。這就是為什么我們需要從后台線程調用它們。
-
我們還需要知道滾動列表中的哪個View當前處於活動狀態以切換播放的視頻。所以我們需要跟蹤滾動並定義可視范圍最大的view。
管理視頻播放
我們的目標是提供以下功能:
假設視頻正在播放。用戶滾動列表,一個新的item替代正在播放的item成為可視范圍最大的view。那么現在我們需要停止當前視頻的播放並開始新的視頻。
主要功能就是:停止前一個播放,並僅在舊的播放停止之后才開始新的播放。
以下是一個例子:當你按下視頻的縮略圖-當前播放的視頻停止播放,另一個視頻開始播放。
VideoPlayerView
我們要做的第一件事就是實現基於TextureView的VideoView 。我們不能在滾動列表中使用VideoView 。這是因為如果在播放的過程中用戶滾動了列表,視頻的渲染會混亂。
我將把這個任務分為幾部分:
1.創建一個ScalableTextureView,它是TextureView 的子類,同時它還知道如何調整SurfaceTexture (視頻的播放就是運行在SurfaceTexture 上),並提供幾個類似於ImageView scaleType的選項。
public enum ScaleType { CENTER_CROP, TOP, BOTTOM, FILL }
2.創建一個VideoPlayerView,它是ScalableTextureView 的子類,含有跟MediaPlayer.class相關的所有功能。這個自定義view封裝了MediaPlayer.class並提供了和VideoView十分類似的API。它具有MediaPlayer的所有方法:setDataSource, prepare, start, stop, pause, reset, release。
Video Player Manager and Messages Handler Thread
Video Playback Manager和 MessagesHandlerThread 一起工作,負責調用MediaPlayer的方法。我們需要在單獨的線程中調用例如prepare(), start()等這樣的方法是因為它們直接和設備的硬件關聯。我們也做過在UI線程中調用MediaPlayer.reset(),但是player出了問題,而且這個方法對UI線程的阻塞幾乎有4分鍾!這就是為什么我們不必使用異步的MediaPlayer.prepareAsync,而使用同步的MediaPlayer.prepare。我們讓每件事情都在一個單獨的線程里做。
至於開始一個新的播放的流程,這里是MediaPlayer要做的幾個步驟:
停止前一個播放。調用MediaPlayer.stop() 方法來完成。
調用MediaPlayer.reset()方法來重設MediaPlayer 。這么做的原因是在滾動列表中,view可能會被重用,我們希望所有的資源都能被釋放。
調用MediaPlayer.release() 方法來釋放MediaPlayer
清除MediaPlayer的實例。當應該播放新的視頻的時候,新的MediaPlayer實例將被創建。
為可視范圍最大的view創建MediaPlayer實例。
調用MediaPlayer.setDataSource(String url)來為新的MediaPlayer 設置數據源。
調用MediaPlayer.prepare(),這里沒有必要調用異步的MediaPlayer.prepareAsync()。
調用MediaPlayer.start()
等待實際的視頻開始。
所有的這些操作都被封裝在了在一個獨立線程中處理的Message里面,假如這是Stop message,將調用VideoPlayerView.stop(),而它最終調用的是MediaPlayer.stop()。我們需要自定義的messages是因為這樣我們就能設置當前狀態。我們可以知道它是正在停止還是已經停止或者其它狀態。它幫助我們控制當前處理的是什么message,如果需要,我們可以對它做點什么,比如,開始新的播放
/** * This PlayerMessage calls {@link MediaPlayer#stop()} on the instance that is used inside {@link VideoPlayerView} */ public class Stop extends PlayerMessage { public Stop(VideoPlayerView videoView, VideoPlayerManagerCallback callback) { super(videoView, callback); } @Override protected void performAction(VideoPlayerView currentPlayer) { currentPlayer.stop(); } @Override protected PlayerMessageState stateBefore() { return PlayerMessageState.STOPPING; } @Override protected PlayerMessageState stateAfter() { return PlayerMessageState.STOPPED; } }
如果我們需要開始一個新的播放,我們只需調用VideoPlayerManager中的一個方法。它向MessagesHandlerThread中添加了如下消息組合。
// pause the queue processing and check current state // if current state is "started" then stop old playback mPlayerHandler.addMessage(new Stop(mCurrentPlayer, this)); mPlayerHandler.addMessage(new Reset(mCurrentPlayer, this)); mPlayerHandler.addMessage(new Release(mCurrentPlayer, this)); mPlayerHandler.addMessage(new ClearPlayerInstance(mCurrentPlayer, this));// set new video player view mPlayerHandler.addMessage(new SetNewViewForPlayback(newVideoPlayerView, this)); // start new playback mPlayerHandler.addMessages(Arrays.asList( new CreateNewPlayerInstance(videoPlayerView, this), new SetAssetsDataSourceMessage(videoPlayerView, assetFileDescriptor, this), // I use local file for demo new Prepare(videoPlayerView, this), new Start(videoPlayerView, this) )); // resume queue processing
息的運行是同步的,因此我們可以在任意時刻暫停隊列的處理,比如:
當前的視頻處於准備狀態(MedaiPlayer.prepare()被調用, MediaPlayer.start() 在隊列中等待) ,用戶滾動別表因此我們需要在一個新的view上開始播放視頻。在這種情況下,我們:
-
暫停隊列的處理
-
移除所有掛起的消息
-
把“Stop”, “Reset”, “Release”, “Clear Player instance” 發送到隊列。它們將在我們從“Prepare”返回的時候立即被調用。
-
發送 “Create new Media Player instance”, “Set Current Media Player”(這個消息改變執行messages的MediaPlayer對象), “Set data source”, “Prepare”, “Start”消息。這些消息將在新的view上開始視頻的播放。
好了,這樣我們就有了按照我們需求運行視頻播放的工具:停止前一個播放然后顯示下一個。
這里是library的gradle 依賴:
dependencies { compile 'com.github.danylovolokh:video-player-manager:0.2.0' }
識別list中可見范圍最大的view.List Visibility Utils
第一個問題是管理視頻的播放問題。第二個問題則是跟蹤哪個item的可見范圍最大並把播放切換到那個view。
這里有一個名叫ListItemsVisibilityCalculator 的接口和它的實現SingleListViewItemActiveCalculator 就是做這個工作的。
為了計算列表中item的可見度,adapter中使用的model class必須實現ListItem interface 。
/** * A general interface for list items. * This interface is used by {@link ListItemsVisibilityCalculator} * * @author danylo.volokh */ public interface ListItem { /** * When this method is called, the implementation should provide a * visibility percents in range 0 - 100 % * @param view the view which visibility percent should be * calculated. * Note: visibility doesn't have to depend on the visibility of a * full view. * It might be calculated by calculating the visibility of any * inner View * * @return percents of visibility */ int getVisibilityPercents(View view); /** * When view visibility become bigger than "current active" view * visibility then the new view becomes active. * This method is called */ void setActive(View newActiveView, int newActiveViewPosition); /** * There might be a case when not only new view becomes active, * but also when no view is active. * When view should stop being active this method is called */ void deactivate(View currentView, int position); }
ListItemsVisibilityCalculator 跟蹤滾動的方向並在運行時計算item的可視度。item的可見度可能取決於列表中單個item里面的任意view。由你來實現getVisibilityPercents() 方法。
在sample demo app中有一個默認的實現:
/** * A general interface for list items. * This interface is used by {@link ListItemsVisibilityCalculator} * * @author danylo.volokh */ public interface ListItem { /** * When this method is called, the implementation should provide a * visibility percents in range 0 - 100 % * @param view the view which visibility percent should be * calculated. * Note: visibility doesn't have to depend on the visibility of a * full view. * It might be calculated by calculating the visibility of any * inner View * * @return percents of visibility */ int getVisibilityPercents(View view); /** * When view visibility become bigger than "current active" view * visibility then the new view becomes active. * This method is called */ void setActive(View newActiveView, int newActiveViewPosition); /** * There might be a case when not only new view becomes active, * but also when no view is active. * When view should stop being active this method is called */ void deactivate(View currentView, int position); }
ListItemsVisibilityCalculator 跟蹤滾動的方向並在運行時計算item的可視度。item的可見度可能取決於列表中單個item里面的任意view。由你來實現getVisibilityPercents() 方法。
在sample demo app中有一個默認的實現:
/** * This method calculates visibility percentage of currentView. * This method works correctly when currentView is smaller then it's enclosure. * @param currentView - view which visibility should be calculated * @return currentView visibility percents */ @Override public int getVisibilityPercents(View currentView) { int percents = 100; currentView.getLocalVisibleRect(mCurrentViewRect); int height = currentView.getHeight(); if(viewIsPartiallyHiddenTop()){ // view is partially hidden behind the top edge percents = (height - mCurrentViewRect.top) * 100 / height; } else if(viewIsPartiallyHiddenBottom(height)){ percents = mCurrentViewRect.bottom * 100 / height; } return percents; }
每個 view都需要知道如何計算它的可見百分比。滾動發生的時候,SingleListViewItemActiveCalculator將從每個view 索取這個值,所有這里的實現不能太復雜。
當某個鄰居的可見度超過了當前活動item,setActive 方法將被調用。就在這時應該切換播放。
還有一個作為ListItemsVisibilityCalculator 和 ListView 或者 RecyclerView之間適配器的ItemsPositionGetter。這樣ListItemsVisibilityCalculator 就不需要知道這到底是一個ListView 還是RecyclerView。它只是做自己的工作。但是它需要知道一些ItemsPositionGetter提供的信息:
/** * This class is an API for {@link ListItemsVisibilityCalculator} * Using this class is can access all the data from RecyclerView / * ListView * * There is two different implementations for ListView and for * RecyclerView. * RecyclerView introduced LayoutManager that's why some of data moved * there * * Created by danylo.volokh on 9/20/2015. */ public interface ItemsPositionGetter { View getChildAt(int position); int indexOfChild(View view); int getChildCount(); int getLastVisiblePosition(); int getFirstVisiblePosition(); }
考慮到業務邏輯和model分離的原則,把那樣的邏輯放在model 中是有點亂。但是做一些修改的也許能做到分離。不過雖然現在不怎么好看,但是運行起來還是沒有問題。
下面是這個library的 gradle dependency:
dependencies { compile 'com.github.danylovolokh:list-visibility-utils:0.2.0' }
Combination of Video Player Manager and List Visibility Utils to implement video playback in the scrolling list.
現在我們已經有了兩個能解決我們所有問題的library。讓我們把它們結合起來實現我們需要的功能。
這里是取自使用了RecyclerView的fragment 中的代碼:
1.初始化ListItemsVisibilityCalculator,並傳遞一個list的引用給它。
/** * Only the one (most visible) view should be active (and playing). * To calculate visibility of views we use {@link SingleListViewItemActiveCalculator} */ private final ListItemsVisibilityCalculator mVideoVisibilityCalculator = new SingleListViewItemActiveCalculator( new DefaultSingleItemCalculatorCallback(), mList);
DefaultSingleItemCalculatorCallback 只是在活動view改變的時候調用了 ListItem.setActive 方法,但是你可以自己重寫它,做自己想做的事情:
/** * Methods of this callback will be called when new active item is found {@link Callback#activateNewCurrentItem(ListItem, View, int)} * or when there is no active item {@link Callback#deactivateCurrentItem(ListItem, View, int)} - this might happen when user scrolls really fast */ public interface Callback<T extends ListItem>{ void activateNewCurrentItem(T item, View view, int position); void deactivateCurrentItem(T item, View view, int position); }
2. 初始化VideoPlayerManager。
/** * Here we use {@link SingleVideoPlayerManager}, which means that only one video playback is possible. */ private final VideoPlayerManager<MetaData> mVideoPlayerManager = new SingleVideoPlayerManager(new PlayerItemChangeListener() { @Override public void onPlayerItemChanged(MetaData metaData) { } });
3. 為RecyclerView設置on scroll listener 並傳遞scroll events 到 list visibility utils。
@Override public void onScrollStateChanged(RecyclerView view, int scrollState) { mScrollState = scrollState; if(scrollState == RecyclerView.SCROLL_STATE_IDLE && mList.isEmpty()){ mVideoVisibilityCalculator.onScrollStateIdle( mItemsPositionGetter, mLayoutManager.findFirstVisibleItemPosition(), mLayoutManager.findLastVisibleItemPosition()); } } @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { if(!mList.isEmpty()){ mVideoVisibilityCalculator.onScroll( mItemsPositionGetter, mLayoutManager.findFirstVisibleItemPosition(), mLayoutManager.findLastVisibleItemPosition() - mLayoutManager.findFirstVisibleItemPosition() + 1, mScrollState); } } });
4. 創建ItemsPositionGetter。
ItemsPositionGetter mItemsPositionGetter =
new RecyclerViewItemPositionGetter(mLayoutManager, mRecyclerView);
5.同時我們在onResume 中調用一個方法以便在我們打開屏幕的時候馬上開始計算可見范圍最大的item。
@Override public void onResume() { super.onResume(); if(!mList.isEmpty()){ // need to call this method from list view handler in order to have filled list mRecyclerView.post(new Runnable() { @Override public void run() { mVideoVisibilityCalculator.onScrollStateIdle( mItemsPositionGetter, mLayoutManager.findFirstVisibleItemPosition(), mLayoutManager.findLastVisibleItemPosition()); } }); } }
這樣我們就得到了一組在列表中播放的視頻。
總的來說,這只是對最重要部分的解釋。在sample app中有更多的代碼:
https://github.com/danylovolokh/VideoPlayerManager
要了解更多細節請查看源代碼。