在滚动列表中实现视频的播放(ListView & RecyclerView)

英文原文:Implementing video playback in a scrolled list (ListView & RecyclerView) 

本文将讲解如何在列表中实现视频播放。类似于诸如 Facebook, Instagram 或者 Magisto这些热门应用的效果:

Facebook:

Magisto:

Instagram:

这片文章基于开源项目: VideoPlayerManager

所有的代码和示例都在那里。本文将跳过许多东西。因此如果你要真正理解它是如何工作的,最好下载源码,并结合源代码一起阅读本文。但是即便是没有看源代码,本文也能帮助你理解我们在干什么。

两个问题

要实现我们需要的功能,我们必须解决两个问题:

  1. 我们需要管理视频的播放。在安卓中,我们有一个和SurfaceView 一起工作的MediaPlayer.class 类可以播放视频。但是它有许多缺陷。我们不能在列表中使用普通的VideoView 。VideoView 继承自SurfaceView,而SurfaceView并没有UI同步缓冲区。这就导致了在列表滚动的时候,正在播放的视频需要跟上滚动的步伐。TextureView 中有同步缓冲区,但是在Android SDK version 15 中没有基于TextureView 的VideoView。因此我们需要一个继承自TextureView 并和Android MediaPlayer一起工作的View。几乎所有MediaPlayer中的方法(prepare, start, stop 等等…)都调用和硬件相关的本地方法。当做了长于16ms的工作时(必然会),硬件会非常棘手然后我们就会看到一个卡顿的列表。这就是为什么我们需要从后台线程调用它们。

  2. 我们还需要知道滚动列表中的哪个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要做的几个步骤:

  1. 停止前一个播放。调用MediaPlayer.stop() 方法来完成。

  2. 调用MediaPlayer.reset()方法来重设MediaPlayer 。这么做的原因是在滚动列表中,view可能会被重用,我们希望所有的资源都能被释放。

  3. 调用MediaPlayer.release() 方法来释放MediaPlayer

  4. 清除MediaPlayer的实例。当应该播放新的视频的时候,新的MediaPlayer实例将被创建。

  5. 为可视范围最大的view创建MediaPlayer实例。

  6. 调用MediaPlayer.setDataSource(String url)来为新的MediaPlayer 设置数据源。

  7. 调用MediaPlayer.prepare(),这里没有必要调用异步的MediaPlayer.prepareAsync()。

  8. 调用MediaPlayer.start()

  9. 等待实际的视频开始。

所有的这些操作都被封装在了在一个独立线程中处理的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上开始播放视频。在这种情况下,我们:

  1. 暂停队列的处理

  2. 移除所有挂起的消息

  3. 把“Stop”, “Reset”, “Release”, “Clear Player instance” 发送到队列。它们将在我们从“Prepare”返回的时候立即被调用。

  4. 发送 “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中有一个默认的实现:

/**
 * 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

要了解更多细节请查看源代码。

Cheers ;)

来自:UI实验室