阅读 1023

工作记录#重构播放器

最近在做播放器重构的需求,写过播放器的朋友一定知道,播放器中代码繁多,包括很多控件(进度条、播放按钮、错误提示等),以及需要处理各种用户操作(播放、暂停、拖动进度条等),而且需要注意页面切换与资源释放等等,如果没有良好的代码结构,会看起来很费力。

我们项目中的代码耦合性极高,简单描述一下,播放器所有的功能都写到了一个类中,充斥着各种变量,各种监听接口。工作量预估是15天,参考PlayerBase进行重构。为什么选它呢,它的代码对播放器中的角色、行为有着很好的抽象,比如将所有遮罩控件都定义为“事件接收者”;将Wifi断/连产生事件抽象为了“事件生产者”等。正因为这些恰当的抽象(接口),使各个类分工更加明确。

重构代码就像拧魔方,不是一下子拆开重组,而是一步步将它还原到一个合理的样子,每走一步都需要验证系统的稳定性,不然改出了Bug都不知道是哪一步出了问题。这里我无法将所有代码展示出,只说一下重构的主要步骤。

一、基础代码重构

  1. 移动常量以及部分参数到Config类、移动静态方法到工具类。
  2. 各种“public”的参数尽量改成“private”或者“protected”,即在类以外调用类中参数时需使用方法调用,这样方便参数的相关逻辑整合。
  3. 对于各类中的方法、参数,明确意义并添加注释,必要时进行重命名或合并(这里不能对原逻辑有任何修改,对,是任何,不然重构之后你可能会发现少了什么东西),最后将无用的代码去除掉。
  4. 参数打包,比如播放器的入参可以打包为“DataSource”(包括视频url、视频标题、屏幕种类(横/竖全屏、小屏、悬浮窗等)等)。
  5. 方法拆分,尽量控制一个方法只代表一种行为,尽管某些方法只调用一次,这样方便后期抽象类的行为为接口
  6. 对于现有的类,确认职责范围,重复的功能可以抽象出父类或功能类。

以上步骤会在重构的过程中不断进行,因为代码间存在着各种引用,需要一步步拆分,而且有些代码还需要明确意图,或者,整理出完整的逻辑才行,不然极易重构出各种Bug。

这里要提出原有代码的两个问题,比较简单,但是很容易犯错:

  • 父类未完整实现的功能,没有暴露出来

    原有代码有着VideoPlayer与BaseViewPlayer(抽象类)两个类,其中BaseViewPlayer拥有着getLayoutId 的抽象方法,BaseViewPlayer中没有指定布局,却进行了“findViewById”,这就让我们在实现BaseViewPlayer时,不得不去确认它都“find”了哪些Id。所以,父类的功能必须完整实现,否则请以抽象方法或者回调接口的形式暴露出来,BaseViewPlayer的“不完整”在于xml定义的缺失。

  • 方法、参数的命名以当前上下文作为命名依据

    原本代码PlayerUtil中有个静态方法叫backPress(),它在一些Activiy的onBackPress方法中调用(这是依据的当前上下文),这种命名会让人摸不着头脑,这方法到底是干嘛用的?这个方法的作用其实是退出全屏或者悬浮窗,最后我改名为“exitFloatWindowOrFullScreen()”(依据功能目的命名)。所以,方法、参数的命名尽量以其作用,产生的效果作为命名依据

二、解耦播放器遮罩(Cover)

1、抽取遮罩(Cover)为单独的View

我们需要将每个Cover所涉及的逻辑都抽取到对应的Cover类中(包括点击事件、对View的操控等,都通过Cover类来实现)。

主控遮罩 加载遮罩
手势遮罩 广告遮罩

这些抽取出来的Cover将在视频组件生成时,通过addView动态添加到组件中(比如根据屏幕种类、场景生成不同的遮罩组合)。

// 播放器类
public class MyPlayer extends FrameLayout{
    // 比如我们有两种样式的进度条,需要在不同种类的屏幕中使用
    // 播放器配置方法,给该方法传入数据源,进行播放器的组装、配置。
    public void setUp(DataSource dataSource){
        switch(dataSource.getScreenType()){
            case Config.TYPE_VERTICAL_FULL_SCREEN:// 竖向全屏
                VertivalProgressCover vCover = new VerticalProgressCover(getContext());
                addView(vCover);
                break;
            case Config.TYPE_HORIZON_FULL_SCREEN:// 横向全屏
                HorizonProgressCover hCover = new HorizonProgressCover(getContext());
                addView(hCover);
                break;
                ......
        }
    }
}
复制代码

Cover样式因产品而异,不同Cover可能有相同的功能,比如两个Cover都有播放暂停按钮、都有全屏按钮,拆分代码时,可以直接将重复功能的代码直接复制多份,不要省略这个步骤,这样你可以少犯错。遵循先拆分,后合并的原则,在之后的步骤中再提取相同的代码为基类(父类)或者功能类(XXXHelper)。

拆分出的Cover先直接声明在播放器中:

// 播放器类
public class MyPlayer extends FrameLayout{
    // 声明的Cover
    private ImageCover imageCover; // 封面图遮罩
    private ControllerCover controllerCover; // 主控遮罩
    ......
    
    // 播放器进入准备状态
    private void onParpare(){
        // 遮罩的相关行为都封装在遮罩类中,比如这个加载封面的行为,这里通过一个方法调用
        imageCover.loadImage(dataSource.getImageUrl);
        ......
    }
    ......
}
复制代码

2、解耦遮罩(Cover)对具体播放器的依赖(1)

在拆分Cover相关代码的时候我们会发现有些属于播放器的行为也被移动到了Cover中,尤其是点击事件的代码,很容易就调用到了播放器本身的逻辑(比如静音、倍速等)。为了方便解耦,一开始我是直接给每个Cover赋值一个播放器的引用,这样可以随意调用播放器中的方法,这样我不用考虑太多就能很快地把所有Cover拆分成一个个独立的View。

(以下展示的代码都省略了部分代码)

// 比如这个视频封面遮罩,一开始的调用方式
public class ImageCover extends AppCompatImageView implements View.OnClickListener{
    // 简单粗暴地直接引用当前播放器
    private Player player;
    
    public void init(){
        setOnCLickListener(this);
    }

    // 绑定播放器
    public void bind(Player player){
        this.player = player;
    }
    
    // 点击封面图(响应点击行为属于封面遮罩的行为)
    @Override
    public void onClick(View v){
        // 播放视频(这属于播放器的行为)
        player.startPlay();
    }
    ......
}
复制代码

上面的做法简单粗暴,但是算是重构的一个步骤,传入“player”相当于搭建了一个脚手架,后面还要拆除

这样的代码中播放器与遮罩还存在着耦合,遮罩中还是存在一个播放器引用的硬编码。我们还需要下一步——接口隔离给每个遮罩都定义一个自己的回调“Callback”,将播放器行为以接口的形式传入,这样,这些遮罩就没有直接引用播放器了,达到解耦的目的。

// 还是视频封面遮罩,代码修改一下
public class ImageCover extends LinearLayout{
    private ImageCoverCallback callback;
    
    public void init(){
        setOnCLickListener(this);
    }
    
    // 不再传player,而是传入一个回调接口
    public void setCallback(ImageCoverCallback callback){
        this.callback = callback;
    }    
    
    @Override
    public void onClick(View v){
        // 使用回调,而不是直接调用player的相关方法
        if(callback != null){
            callback.onFullScreen();
        }
    }
    
    // 定义Callback
    public interface ImageCoverCallback{
        void onImageClick();
    }
}
复制代码
// 在播放器中,我们这样创建Cover
ImageCover imageCover = new ImageCover(getContext());
imageCover.setCallback(this::startPlay);// lamada表达式,调用startPlay方法
复制代码

这算是一个“简单粗暴”的接口隔离,此时遮罩已经不会依赖具体的播放器了(当然,这些“Callback”如果以匿名内部类的方式传入,还是会持有播放器的引用的,不过没有硬编码的引用就行)

但是播放器中仍有具体遮罩的引用,比如当播放器准备好播放的时候,调用生命周期方法onParpare,改变一些Cover的样式:

private void onParpare(){
    // 遮罩在播放器中的行为,比如加载封面
    imageCover.loadImage(dataSource.getImageUrl());
    loadingCover.setVisiblity(GONE);
    ......
}
复制代码

我们能不能再将这些调用具体遮罩的方法抽象为接口呢?让播放器不依赖于具体的遮罩,达到我们的希望的解耦。

3、解耦播放器对具体遮罩(Cover)的依赖

对于遮罩在播放器中的一些行为,比如:

//播放器类
public class MyPlayer extends FrameLayout{
    private ImageCover imageCover; // 封面图遮罩
    private LoadingCover loadingCover; // 加载进度条
    ......
    
    // 播放器进入准备状态
    private void onParpare(){
        // 遮罩在播放器中的行为,比如加载封面
        imageCover.loadImage(dataSource.getImageUrl());
        loadingCover.setVisiblity(GONE);
        ......
    }
    ......
}
复制代码

onParpare方法中使用了具体的遮罩对象进行方法调用,我们能否将这些行为再抽象为接口呢?此时需要理解调用处的上下文,对播放器此时的行为进行抽象。通过观察发现,这些遮罩的变化大体上随着播放器的生命周期变化,那我们可以定义一个遮罩生命周期的接口:

public interface ICoverLifeCycle{
    void onParpare();
    void onStart();
    void onStop();
    ......
}
复制代码

再让每个遮罩都去实现这个接口:

//封面遮罩,实现ICoverLifeCycle接口
public ImageCover extends AppCompactImageView implement ICoverLifeCycle, View.OnClickListener{
    private String imageUrl;

    // 初始化数据、样式的方法
    public void onSetUp(String imageUrl){
        this.imageUrl = imageUrl
    }
    
    @Override
    public void onParpare(){
        loadImage(imageUrl);
    }
    ......
}
复制代码

再在播放器中调用,我们将所有遮罩合并为一个集合:

public class MyPlayer extends FrameLayout{
    private List<ICoverLifeCycle> covers;
    ......
    
    // 初始化数据、样式的方法
    public void setUp(DataSource dataSource){
        ImageCover imageCover = new imageCover(getContext());
        imageCover.onSetUp(dataSource.getImageUrl);
        covers.add(imageCover);
        ......
    }
    
    // 播放器进入准备状态
    private void onParpare(){
        // 调用所有遮罩的onParpare方法
        for(ICoverLifeCycle cover : covers){
            cover.onParpare();
        }
    }
    ......
}
复制代码

这样,播放器就依赖于我们的抽象接口ICoverLifeCycle,而不是具体的遮罩了。

这里可能有一个疑问,为什么不直接定义播放器生命周期的接口,而是定义了遮罩生命周期的接口?原因是播放器的生命周期和遮罩的生命周期还是有一些差异的。比如手指触碰播放器时会显示播放进度条,再触碰一下会让播放进度条消失,这个行为归纳为遮罩的生命周期更准确。

但是又有一个问题,面对这么多的回调方法(遮罩生命周期要定义10个以上的方法,至少的),每个遮罩都要去实现所有方法,会产生很多空实现的方法。好了,这个还是可以忍受的,当你要新增方法时呢?接口加一个方法,每个实现了这个接口的遮罩都加一下这个方法,太麻烦。

此时我们可以将回调的具体接口合并为一个接口,并为不同回调定义常量type进行区分

public interface IEventReceiver{
    // 不同的回调事件
    int ON_PARPARE;// 准备
    int ON_LOADING;// 开始加载
    int ON_START;// 开始播放
    ......
    
    String EXTRA_CURRENT_STATE = "EXTRA_CURRENT_STATE";// 播放器状态 extra name
    ......
    
    void onEvent(int type, Bundle data);
}
复制代码

type定义回调事件,data作为传输数据(如果有)。这样,我们增删事件都会变得简单,只要在所需要相应的遮罩中加一个case判断即可:

public ImageCover extends AppCompactImageView implement IEventReceiver, View.OnClickListener{
    private String imageUrl;

    // 初始化数据、样式的方法
    public void onSetUp(String imageUrl){
        this.imageUrl = imageUrl
    }
    
    @Override
    public onEvent(int type, Bundle data){
        switch(type){
            case ON_PREPARE:// 准备时加载封面图
                setVisibility(VISIBLE);
                loadImage(imageUrl);
                break;
            case ON_START:// 开始播放时隐藏封面图
                setVisibility(GONE);
                break;
                ......
        }
    }
    ......
}
复制代码

这样定义之后,我们将播放器中对遮罩的操作全部以事件发送的方式实现,这样就做到了不依赖具体的遮罩。

// 播放器类
public class MyPlayer extends Framelayout{
    private ArrayList<IEventReceiver> coverReceivers = new ArrayList<>();
  
    // 举例:播放器生命周期onPrepare
    private void onPrepare(){
        // 给所有遮罩发送消息
        onCoverEvent(IEventReceiver.ON_PARPARE);
    }
  
    // 发送消息方法
    private void onCoverEvent(int type) {
        Bundle bundle = new Bundle();
        switch (type) {
            case IEventReceiver.ON_PREPARE:
                bundle.putInt(IEventReceiver.EXTRA_CURRENT_STATE, currentState);// 传输当前状态
                break;
            ......
            default:
        }

        // 遍历所有遮罩
        for (IEventReceiver receiver : coverReceivers) {
            receiver.onEvent(type, bundle);
        }
    }
}
复制代码

这样做还有一个好处,就是我们不需要关心哪个遮罩存在或不存在,我们只需要将事件发送出去;而对于遮罩,只需要关心遇到对应的事件应该做出怎样的响应

4、解耦遮罩(Cover)对具体播放器的依赖(2)

我们在第二步的时候为每个Cover都定义了Callback,虽然Cover内代码没有了对播放器的依赖,但是每个Cover都要创建一个Callback再回调播放器中的方法很是麻烦:

// 在播放器中,我们这样创建Cover
ImageCover imageCover = new ImageCover(getContext());
imageCover.setCallback(this::startPlay);// lamada表达式,调用startPlay方法
复制代码

类似IEventReceiver,我们可以整合这些Callback为一个接口——IPlayerReceiver,在播放器中使用匿名内部类创建对象,再传入每个Cover,回调播放器中的相应方法。

public interface IPlayerReceiver {
  int FULLSCREEN = 0;
  int CHANGE_DEFINITION = 2;
  int CHANGE_SPEED = 3;
  ......

  String EXTRA_SEEK_TO = "EXTRA_SEEK_TO";
  String EXTRA_IS_MUTE = "EXTRA_IS_MUTE";
  ......
  
  void onEvent(int type, Bundle data);
}
复制代码

三、事件整合

我们在第二步中进行了Cover的解耦,最后定义了coverReceivers,作为Cover事件的接收者:

private ArrayList<IEventReceiver> coverReceivers
复制代码

举一反三,播放器中的消息传递还有那些?

  • 播放器内核 --> Cover
  • Cover --> 播放器内核
  • Cover --> Cover
  • 封装的播放器 --> 外界
  • 外界 --> 封装的播放器(暂时未做,可参考PlayerBase中的EventProducer)

所以在IEventReceiver的基础之上,我定义了其他的事件接收,并将它们组合成了ReceiverGroup:

1、MyVideoPlayer

首先我们看到绿色的MyVideoPlayer,它是我们最终封装的播放器,其中包含了两个回调接口的实例——internalStateGetter & internalPlayerReceiver。

StateGetter用于获取播放器的实时数据,比如当前状态、播放进度等:

PlayerReceiver用于接收其他组件发起的播放器事件:

2、ReceiverGroup

然后,在播放器创建的同时,会创建一个ReceiverGroup,将以上两个回调接口传入其中。并在生成Cover的同时,将各个Cover加入ReceiverGroup的coverReceivers中(下图是ReceiverGroup中的addCoverReceiver方法)。

因为Progress响应事件比较频繁,所以单独设立一个回调。还有两个OuterReceiver由播放器外部传入,可以让外部接收Cover、Progress事件。

3、BaseReceiver

最后,我们看到右侧橙色的三个类。我们定义了BaseReceiver,它持有了ReceiverGroup与StateGetter,他们都是在MyVideoPlayer中创建的实例,同时BaseCover继承于BaseReceiver,也就是说,每个Cover都可以发起事件,传递给其他Cover、Player与播放器外部。

BaseCover持有rootView,即Cover的视图,BaseCover作为这个视图的操作者

4、Cover实现的一个例子

最后我们来看一个ImageCover是如何实现的:

ImageCover实现IEventReceiver的onEvent方法;

ImageCover实现onClick方法(使用receiverGroup发送了ON_PLAY_CLICK事件给播放器);

四、模块化

将Glide、Gson等第三方框架,以及可自定义的内容以接口形式回调,放到播放器以外,将播放器独立模块(通俗的讲,就是将播放器无关的引用(import)全部通过接口回调的方式移出,在播放器外部实现)。

这里的接口有静态的全局接口(接口变量声明为静态的,对所有对象生效)与播放器的实例接口(接口变量声明为非静态,只对当前对象生效)。

1、全局接口

比如定义IUserAction接口,它定义了用户操作播放器的行为,包括各种点击、滑动事件。

// 用户行为回调
public interface IUserActionListener{
    void onPlayClick();// 点击播放
    void onStopClick();// 点击暂停
    ......
}

//在播放器中调用
public class Player extends FrameLayout{
    private static IUserActionListener userActionListener;
    
    public static setUserActionLisetener(UserActionListener userActionListener){
        this.userActionListener = userActionListener
    }
    
    public void onPlayClick(){
        if(userActionListener != null){
            userActionListener.onPlayClick();
        }
    }
    ......
}
复制代码

这个回调就可以让开发者很方便的实现自己的埋点逻辑。我们可以在Application的onCreate中调用setUserActionLisetener,设置我们自定义的回调行为。

2、实例接口

实例接口可以回调很多种播放器行为,比如播放器生命周期、比如点击事件。比如前面所说的OuterReceiver就是实例接口,是针对于每个播放器实例的。比如:

这里的回调会对事件进行消费,并且再没有wifi的情况下发送 cover event,显示流量消耗提示。

还比如这里回调了ImageCover的加载图片事件与广告Cover的“了解更多”点击事件(下图使用了链式调用):


end