最近在做播放器重构的需求,写过播放器的朋友一定知道,播放器中代码繁多,包括很多控件(进度条、播放按钮、错误提示等),以及需要处理各种用户操作(播放、暂停、拖动进度条等),而且需要注意页面切换与资源释放等等,如果没有良好的代码结构,会看起来很费力。
我们项目中的代码耦合性极高,简单描述一下,播放器所有的功能都写到了一个类中,充斥着各种变量,各种监听接口。工作量预估是15天,参考PlayerBase进行重构。为什么选它呢,它的代码对播放器中的角色、行为有着很好的抽象,比如将所有遮罩控件都定义为“事件接收者”;将Wifi断/连产生事件抽象为了“事件生产者”等。正因为这些恰当的抽象(接口),使各个类分工更加明确。
重构代码就像拧魔方,不是一下子拆开重组,而是一步步将它还原到一个合理的样子,每走一步都需要验证系统的稳定性,不然改出了Bug都不知道是哪一步出了问题。这里我无法将所有代码展示出,只说一下重构的主要步骤。
一、基础代码重构
- 移动常量以及部分参数到Config类、移动静态方法到工具类。
- 各种“public”的参数尽量改成“private”或者“protected”,即在类以外调用类中参数时需使用方法调用,这样方便参数的相关逻辑整合。
- 对于各类中的方法、参数,明确意义并添加注释,必要时进行重命名或合并(这里不能对原逻辑有任何修改,对,是任何,不然重构之后你可能会发现少了什么东西),最后将无用的代码去除掉。
- 参数打包,比如播放器的入参可以打包为“DataSource”(包括视频url、视频标题、屏幕种类(横/竖全屏、小屏、悬浮窗等)等)。
- 方法拆分,尽量控制一个方法只代表一种行为,尽管某些方法只调用一次,这样方便后期抽象类的行为为接口。
- 对于现有的类,确认职责范围,重复的功能可以抽象出父类或功能类。
以上步骤会在重构的过程中不断进行,因为代码间存在着各种引用,需要一步步拆分,而且有些代码还需要明确意图,或者,整理出完整的逻辑才行,不然极易重构出各种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