工作记录#重构播放器

3,074 阅读13分钟

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

我们项目中的代码耦合性极高,简单描述一下,播放器所有的功能都写到了一个类中,充斥着各种变量,各种监听接口。工作量预估是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