美文网首页程序员
Android播放器基础封装库PlayerBase

Android播放器基础封装库PlayerBase

作者: android框架安卓 | 来源:发表于2018-07-05 14:38 被阅读49次

    简述

    视频播放是我们开发中比较常见的场景。这两年关于视频方面的热度不断提升,可以说前两年是直播年,今年是小视频年,各种短视频应用铺天盖地。对于视频的业务场景也越来越丰富,功能也越来越多。对于我们开发来说播放相关组件的代码变得也越来越复杂,管理维护成本也越来越高,面对不断迭代的业务,我们需要一种有效的方案来应对这种频繁的业务变化。

    这几年一直在做视频相关的业务,手机端和TV端均做过适配开发。MediaPlayerexoplayerijkplayerVLCFFmpeg等都摸索使用过。这一路遇到很多问题……说多了都是泪,为了适应多变的产品需求,中间重构了N多个版本。最终PlayerBase也就诞生了。PlayerBase3版本进行了完整重构设计,目前大致框架基本已稳定下来。对于大部分应用视频播放组件场景都能轻松处理。

    ^_^ star传送门--->项目地址github.com/jiajunhui/P…

    QQ交流群:600201778,有问题群里直接提出,看到后会一一解答。

    P图技术有限,文中图片就凑合着看吧!

    框架简介

    请注意!请注意!请注意!PlayerBase区别于大部分播放器封装库。

    PlayerBase是一种将解码器和播放视图组件化处理的解决方案框架。您需要什么解码器实现框架定义的抽象引入即可,对于视图,无论是播放器内的控制视图还是业务视图,均可以做到组件化处理。将播放器的开发变得清晰简单,更利于产品的迭代。

    PlayerBase不会为您做任何多余的功能业务组件,有别于大部分播放器封装库的通过配置或者继承然后重写然后定制你需要的功能组件和屏蔽你不需要的功能组件(这种之前我也经历过,上层可能需要经常改动,感觉很low!!!)。正确的方向应该是需要什么组件就拓展添加什么组件,不需要时移除即可,而不是已经提供了该组件去选择用不用。

    功能特色

    视图的组件化处理

    视图组件的高复用、低耦合

    解码方案的组件化、配置化管理

    视图组件的完全定制

    视图组件的热插拔,用时添加不用时移除

    自定义接入各种解码方案

    解码方案的切换

    支持倍速播放

    支持Window模式播放

    支持Window模式的无缝续播

    支持列表模式的无缝续播

    支持跨页面无缝续播

    支持调整画面显示比例

    支持动态调整渲染视图类型

    支持VideoView切角处理,边缘阴影效果

    提供自定义数据提供者

    统一的事件下发机制

    扩展事件的添加

    等功能……

    部分使用示例

    解码配置和框架初始化

    publicclassAppextendsApplication{@OverridepublicvoidonCreate(){//...//如果您想使用默认的网络状态事件生产者,请添加此行配置。//并需要添加权限 android.permission.ACCESS_NETWORK_STATEPlayerConfig.setUseDefaultNetworkEventProducer(true);//设置默认解码器intdefaultPlanId =1;        PlayerConfig.addDecoderPlan(newDecoderPlan(defaultPlanId, IjkPlayer.class.getName(),"IjkPlayer"));PlayerConfig.setDefaultPlanId(defaultPlanId);//初始化库PlayerLibrary.init(this);    }}

    组装组件(添加您需要的组件【组件来自用户自定义,框架不提供任何视图组件】)

    ReceiverGroup receiverGroup =newReceiverGroup();//Loading组件receiverGroup.addReceiver(KEY_LOADING_COVER,newLoadingCover(context));//Controller组件receiverGroup.addReceiver(KEY_CONTROLLER_COVER,newControllerCover(context));//CompleteCover组件receiverGroup.addReceiver(KEY_COMPLETE_COVER,newCompleteCover(context));//Error组件receiverGroup.addReceiver(KEY_ERROR_COVER,newErrorCover(context));

    设置组件启动播放

    BaseVideoView videoView = findViewById(R.id.videoView);videoView.setReceiverGroup(receiverGroup);DataSource data =newDataSource("http://url...");videoView.setDataSource(data);videoView.start();

    事件的监听

    //player eventvideoView.setOnPlayerEventListener(newOnPlayerEventListener(){@OverridepublicvoidonPlayerEvent(inteventCode, Bundle bundle){//...}});//receiver eventvideoView.setOnReceiverEventListener(newOnReceiverEventListener(){@OverridepublicvoidonReceiverEvent(inteventCode, Bundle bundle){//...}});

    详细使用示例请参阅github项目主页及wiki介绍

    框架的设计

    视图的处理

    别小看一个小小的播放器,里面真的是别有洞天。有时视图组件复杂到你怀疑人生。

    我们先看下播放器开发时常见的一些视图场景:

    以上是我们最常见到的一些视图(其实还有很多,比如清晰度切换、视频列表、播放完成提示页等等),这些视图如果没有一个行之有效的方案来进行管理,将逐渐会乱到失控。

    上面只是列出了控制器视图、加载视图、手势视图、错误视图、弹幕视图和广告视图,这一股脑的视图都是和播放紧密相连的,完全由播放状态驱动,视图之间可能共存、可能制约。

    那么这些视图如何进行统一的管理呢?光布局文件就够喝一壶了吧,即便用include来管理依然摆脱不了显示层级的管理问题。要是一股脑全写到一个xml中,想想都可怕……,改进型的一般都是把每个组件封装成View了,然后再分别写到布局中,显然比前一种要轻松一些。但是,但是播放器和组件间的通信、组件与组件间的通信是个问题。依然有问题存在:

    组件布局的层级完全由布局文件决定了,想调整只能去修改布局文件。并不友好。

    组件和播放器完全捆绑了,耦合度相当高,播放器和组件,组件和组件间的通信完全直接使用引用去操作,如果产品说某个组件要去掉或者大改,你就哭吧,改不好手一哆嗦就有可能带来一堆bug。组件耦合度高,并不支持插拔。这是最大阻碍。

    组件的复用困难。

    接下来,且看PlayerBase如何做。

    接收者Receiver与覆盖层Cover的概念

    做过播放器开发的应该都很清楚一点,所有视图的工作都是由状态事件来驱动的,这是一条主线。有可能是来自播放器的事件(比如解码器出错了),也有可能是来自某个视图的事件(比如手势调节播放进度),还有可能是外部事件(比如网络状态变化)。

    这些信息我们可以归结为

    视图是事件接收者,也是事件的生产者

    解码器是事件生产者

    可能有外来的事件生产者

    也就是说我们把视图当做事件接收者,同时视图具备发送事件的能力。

    解码器不断发出自己工作状态的事件要传递给视图。

    外部的某些事件也需要传递给视图

    至此,框架内部定义了事件接收者的概念,接收者作为事件消费者的同时也能生产事件,而覆盖层继承自接收者引入了视图View。

    publicabstractclassBaseReceiverimplementsIReceiver{//...protectedfinalvoidnotifyReceiverEvent(inteventCode, Bundle bundle){//..}/**

        * all player event dispatch by this method.

        */voidonPlayerEvent(inteventCode, Bundle bundle);/**

        * error event.

        */voidonErrorEvent(inteventCode, Bundle bundle);/**

        * receivers event.

        */voidonReceiverEvent(inteventCode, Bundle bundle);/**    * you can call this method dispatch private event for a receiver.    *    *@returnBundle Return value after the receiver's response, nullable.    */@NullableBundleonPrivateEvent(inteventCode, Bundle bundle);    }

    publicabstractclassBaseCoverextendsBaseReceiver{//...publicabstractViewonCreateCoverView(Context context);//...}

    且看代码,有播放器的事件、有错误事件、有组件(Receiver)间的事件。这众多事件如何下发呢,如果有N多个接收者呢,如何破?

    接收者组管理(ReceiverGroup)

    ReceiverGroup的出现目的就是对众多接收者进行统一的管理,统一的事件下发,当然还有下面的数据共享问题。来张图:

    在ReceiverGroup中包含Cover(其实也是Receiver)和Receiver,提供了Receiver的添加、移除、遍历、销毁等操作。当有事件需要下发时,便可通过ReceiverGroup进行统一的遍历下发。

    publicinterfaceIReceiverGroup{voidsetOnReceiverGroupChangeListener(OnReceiverGroupChangeListener onReceiverGroupChangeListener);/**    * add a receiver, you need put a unique key for this receiver.    *@paramkey    *@paramreceiver    */voidaddReceiver(String key, IReceiver receiver);/**    * remove a receiver by key.    *@paramkey    */voidremoveReceiver(String key);/**    * loop all receivers    *@paramonLoopListener    */voidforEach(OnLoopListener onLoopListener);/**    * loop all receivers by a receiver filter.    *@paramfilter    *@paramonLoopListener    */voidforEach(OnReceiverFilter filter, OnLoopListener onLoopListener);/**    * get receiver by key.    *@paramkey    *@param    *@return*/TgetReceiver(String key);/**    * get the ReceiverGroup group value.    *@return*/GroupValuegetGroupValue();/**

        * clean receivers.

        */voidclearReceivers();}

    组件间数据共享(GroupValue)

    播放器开发中很多时候我们需要依据某个视图的状态来限制另外视图的功能或状态,比如当处于加载中时禁止拖动进度条或者播放出错显示error后禁止其他视图操作等等。这些都属于状态上的相互制约。

    GroupValue就相当于提供了一个共享的数据池,当某个数据被刷新时,监听该数据的回调接口能及时收到通知,当然也可以直接去主动获取数据状态。你可以指定你要监听那些数据的更新事件,如果您注册了您要监听的数据的key值,其对应的value被更新时,您就会收到回调。然后您可以在回调中进行UI视图的控制。

    publicclassCustomCoverextendsBaseCover{//...@OverridepublicvoidonReceiverBind(){super.onReceiverBind();        getGroupValue().registerOnGroupValueUpdateListener(mOnGroupValueUpdateListener);    }//...privateIReceiverGroup.OnGroupValueUpdateListener mOnGroupValueUpdateListener =newIReceiverGroup.OnGroupValueUpdateListener() {@OverridepublicString[] filterKeys() {returnnewString[]{ DataInter.Key.KEY_COMPLETE_SHOW };        }@OverridepublicvoidonValueUpdate(String key, Object value){//...}    };//...@OverridepublicvoidonReceiverUnBind(){super.onReceiverUnBind();        getGroupValue().unregisterOnGroupValueUpdateListener(mOnGroupValueUpdateListener);    }}

    视图的布局管理(分级填充)

    上文中常见的视图组件,我们在使用中肯定会遇到覆盖优先级的问题。举个栗子,比如Error视图出现后其他的视图一概不可见,也就是说Error视图的优先级是最高的,谁都不能挡着它,我们创建了一个个的Cover视图,对于视图的放置就需要一个视图的优先级标量(CoverLevel)来进行控制,不同的LevelCover视图会被放置于不同级别的容器内。

    总结为以下:

    指定Cover的优先级CoverLevel

    Cover组件被添加时自动根据Level值进行分别放置

    示意图

    代码示例

    publicclassCustomCoverextendsBaseCover{//...@OverridepublicintgetCoverLevel(){returnICover.COVER_LEVEL_LOW;    }//...}

    默认的视图容器管理器

    publicclassDefaultLevelCoverContainerextendsBaseLevelCoverContainer{//...@OverrideprotectedvoidonAvailableCoverAdd(BaseCover cover){super.onAvailableCoverAdd(cover);switch(cover.getCoverLevel()){caseICover.COVER_LEVEL_LOW:                mLevelLowCoverContainer.addView(cover.getView(),getNewMatchLayoutParams());break;caseICover.COVER_LEVEL_MEDIUM:                mLevelMediumCoverContainer.addView(cover.getView(),getNewMatchLayoutParams());break;caseICover.COVER_LEVEL_HIGH:                mLevelHighCoverContainer.addView(cover.getView(),getNewMatchLayoutParams());break;        }    }//...}

    组件的层级关系

    如图:

    事件生产者(EventProducer)

    顾名思义,就是它是产生事件的源。比如系统网络状态发生了变化,发出了通知,然后各个应用根据自己的情况来调整显示或设置等。又或者电池电量的变化和低电量预警通知事件等。

    再比如,我们上文中的弹幕视图中需要显示弹幕数据,弹幕数据来自服务器,我们需要源源不断的从服务器上取数据,然后显示在弹幕视图。取回数据传给视图的这个过程我们可以将其看作是一个事件生产者在不断生产弹幕数据更新事件,弹幕数据更新时不断将事件发送给弹幕视图来刷新显示。

    框架内自带了一个网络变化事件生产者的示例:

    publicclassNetworkEventProducerextendsBaseEventProducer{//...privateHandler mHandler =newHandler(Looper.getMainLooper()){@OverridepublicvoidhandleMessage(Message msg){super.handleMessage(msg);switch(msg.what){caseMSG_CODE_NETWORK_CHANGE:intstate = (int) msg.obj;//...将网络状态发送出去getSender().sendInt(InterKey.KEY_NETWORK_STATE, state);                    PLog.d(TAG,"onNetworkChange : "+ state);break;            }        }    };//...publicNetworkEventProducer(Context context){//...}//...publicstaticclassNetChangeBroadcastReceiverextendsBroadcastReceiver{@OverridepublicvoidonReceive(Context context, Intent intent){//...//post state message}//...}}

    由于事件生产者所发出的事件是针对Receiver的,所以会被回调到onReceiverEvent()中,如果发送的是key-value的数据,会被放置于GroupValue中。如下代码:

    publicclassCustomCoverextendsBaseCover{//...@OverridepublicvoidonReceiverEvent(inteventCode, Bundle bundle){//...}//...}

    数据提供者(DataProvider)

    DataProvider是为了播控的统一以及使用上的优雅而设计的。

    在开发中,我们可能会遇到如下场景:你拿到的数据源可能只是个id之类的标识,并不是能直接播放的uri或者url,需要你再用这个id去请求一个接口才能拿到播放的源地址。通常我们都是先去请求接口,然后在成功回调中用拿到的源数据再设置给播放器去播放。

    DataProvider的设计就是为了将此过程独立出来包装为一个数据提供者(其实也可以叫数据生产者),拿到数据后发送出去即可。而您只需要把那个id标识给DataProvider即可,接下来的过程就由DataProvider来完成了。DataProvider的具体实现需要由用户完成。

    publicclassMonitorDataProviderextendsBaseDataProvider{//...publicMonitorDataProvider(){//...}privateHandler mHandler =newHandler(Looper.getMainLooper());@OverridepublicvoidhandleSourceData(DataSource sourceData){this.mDataSource = sourceData;//...provider startonProviderDataStart();//...//...将数据回调出去onProviderMediaDataSuccess(bundle);//...//...异常时onProviderError(-1,null)    }//...@Overridepublicvoidcancel(){//...cancel something}@Overridepublicvoiddestroy(){//...destroy something}}

    注意:数据提供者必须要设置在启动播放前。

    功能使用

    VideoView的使用

    大致归结为以下步骤:

    初始化VideoView,并设置相应的监听事件或者数据提供者等

    使用ReceiverGroup组装需要的组件Cover和Receiver

    把组件设置给VideoView

    设置数据启动播放

    暂停恢复播放等操作

    销毁播放器

    publicclassVideoViewActivityextendsAppCompatActivityimplementsOnPlayerEventListener{//...BaseVideoView mVideoView;@OverridepublicvoidonCreate(Bundle saveInstance){super.onCreate(saveInstance);        mVideoView = findViewById(R.id.videoView);        mVideoView.setOnPlayerEventListener(this);//设置数据提供者 MonitorDataProviderMonitorDataProvider dataProvider =newMonitorDataProvider();        mVideoView.setDataProvider(dataProvider);//...ReceiverGroup receiverGroup =newReceiverGroup();//Loading组件receiverGroup.addReceiver(KEY_LOADING_COVER,newLoadingCover(context));//Controller组件receiverGroup.addReceiver(KEY_CONTROLLER_COVER,newControllerCover(context));//CompleteCover组件receiverGroup.addReceiver(KEY_COMPLETE_COVER,newCompleteCover(context));//Error组件receiverGroup.addReceiver(KEY_ERROR_COVER,newErrorCover(context));//...DataSource data =newDataSource("monitor_id");        videoView.setDataSource(data);        videoView.start();    }//...publicvoidonPlayerEvent(inteventCode, Bundle bundle){switch(eventCode){caseOnPlayerEventListener.PLAYER_EVENT_ON_VIDEO_RENDER_START://...break;caseOnPlayerEventListener.PLAYER_EVENT_ON_PLAY_COMPLETE://...break;        }    }//...@OverridepublicvoidonPause(){super.onPause();        mVideoView.pause();//...}@OverridepublicvoidonResume(){super.onResume();        mVideoView.onResume();//...}@OverridepublicvoidonDestroy(){super.onDestroy();        mVideoView.stopPlayback();//...}}

    AVPlayer的使用

    如果您想直接使用AVPlayer自己进行处理播放,那么大致步骤如下:

    初始化一个AVPlayer对象。

    初始化一个SuperContainer对象,将ReceiverGroup设置到SuperContainer中。

    使用SuperContainer设置一个渲染视图Render,然后自己处理RenderCallBack并关联解码器。

    SuperContainer mSuperContainer =newSuperContainer(context);ReceiverGroup receiverGroup =newReceiverGroup();//...add some coversreceiverGroup.addReceiver(KEY_LOADING_COVER,newLoadingCover(context));mSuperContainer.setReceiverGroup(receiverGroup);//...finalRenderTextureView render =newRenderTextureView(mAppContext);render.setTakeOverSurfaceTexture(true);//....mPlayer.setOnPlayerEventListener(newOnPlayerEventListener() {@OverridepublicvoidonPlayerEvent(inteventCode, Bundle bundle){//...此处需要根据事件自行实现一些特定的设置//...比如视频的尺寸需要传递Render刷新测量或者视频的角度等等//将事件分发给子视图mSuperContainer.dispatchPlayEvent(eventCode, bundle);    }});mPlayer.setOnErrorEventListener(newOnErrorEventListener() {@OverridepublicvoidonErrorEvent(inteventCode, Bundle bundle){//将事件分发给子视图mSuperContainer.dispatchErrorEvent(eventCode, bundle);    }});//...render.setRenderCallback(newIRender.IRenderCallback() {@OverridepublicvoidonSurfaceCreated(IRender.IRenderHolder renderHolder,intwidth,intheight){        mRenderHolder = renderHolder;        bindRenderHolder(mRenderHolder);    }@OverridepublicvoidonSurfaceChanged(IRender.IRenderHolder renderHolder,intformat,intwidth,intheight){    }@OverridepublicvoidonSurfaceDestroy(IRender.IRenderHolder renderHolder){        mRenderHolder =null;    }});mSuperContainer.setRenderView(render.getRenderView());mPlayer.setDataSource(dataSource);mPlayer.start();

    如果非必须,请尽量使用框架封装好的BaseVideoView进行播放,框架相对来说处理的比较完善且提供了丰富的回调和定制性。

    关联助手的使用(RelationAssist)

    现在的短视频应用都有这样的场景:

    列表中播放

    列表跳详情自然过渡无停顿播放

    对于第一条在列表中播放,理论上VideoView就能完成,但是VideoView用在列表中量级较重,不太适合。需要一个轻量化处理的方案。

    而对于第二条,VideoView就不行了,VideoView是对解码器进行了包装,当跳到下一个页面时,是一个新的页面自然有新的视图,无法使用前一个页面的播放器实例去渲染当前页面播放。

    其实对于这种无缝的续播,原理很简单。就是不同的渲染视图使用同一个解码实例即可。可以简单比作一个MediaPlayer去不断设置不同的surface呈现播放。如果自己处理这个过程的话想对比较繁琐,你需要处理Render的回调并关联给解码器,还需要自己处理Render的测量以及显示比例、角度等等问题。

    RelationAssist就是为了简化这个过程而设计的。在不同的页面或视图切换播放时,您只需要提供并传入对应位置的视图容器(ViewGroup类型)即可。内部复杂的设置项和关联由RelationAssist完成。

    publicclassTestActivityextendsAppcompatActivity{//...RelationAssist mAssist;    ViewGroup view2;publicvoidonCreate(Bundle saveInstance){super.onCreate(saveInstance);//...mAssist =newRelationAssist(this);    mAssist.setEventAssistHandler(eventHandler);    mReceiverGroup = ReceiverGroupManager.get().getLiteReceiverGroup(this);    mAssist.setReceiverGroup(mReceiverGroup);    DataSource dataSource =newDataSource();    dataSource.setData("http://...");    dataSource.setTitle("xxx");    mAssist.setDataSource(dataSource);    mAssist.attachContainer(mVideoContainer);    mAssist.play();//...switchPlay(view2);    }//...privatevoidswitchPlay(ViewGroup container){    mAssist.attachContainer(container);    }}

    如果您想跨页面进行关联,只需要自己将RelationAssist包装为一个单例即可。此处不做代码展示,详细代码可参见github项目demo代码。

    事件助手处理器(EventAssistHandler)

    视图中的一些基本操作,比如暂停播放、重播、重试、恢复播放等等,这些事件最终都要传递给解码器进行相关操作。可能还有用户自定义的事件比如播放下一个或上一个等。

    对于基本的操作事件(暂停、恢复、重播等),框架内部可自动完成,而用户自定的事件需要让用户自行处理。框架内部BaseVideoViewRelationAssist均做了EventAssistHandler的对接,使用时需要传入一个可用的事件处理器对象,可根据不同的事件参数进行相应处理。如下代码:

    mVideoView.setOnVideoViewEventHandler(newOnVideoViewEventHandler(){@OverridepublicvoidonAssistHandle(BaseVideoView assist,inteventCode, Bundle bundle){//基本的事件处理已在父类super中完成,如果需要重写,重写相应方法即可。super.onAssistHandle(assist, eventCode, bundle);switch(eventCode){caseDataInter.Event.EVENT_CODE_REQUEST_NEXT://...播放下一个break;            }        }});

    Window模式播放

    我们有时可能为了不打断用户的浏览需要小窗播放。框架特意设计了window播放的使用。框架提供了两种window相关的组件。

    WindowVideoView

    FloatWindow

    WindowVideoView使用上几乎和VideoView是一样的,只不过WindowVideoView是以window的形式呈现的。window默认是可以拖动的,如果您不需要,可以禁止,window的每个设置项都有默认值,window的设置示例代码:

    FloatWindowParams windowParams =newFloatWindowParams();windowParams.setWindowType(WindowManager.LayoutParams.TYPE_TOAST)            .setFormat(PixelFormat.RGBA_8888)            .setFlag(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE)            .setDefaultAnimation(true)            .setX(100)            .setY(100)            .setWidth(width)            .setHeight(height)            .setGravity(Gravity.TOP | Gravity.LEFT));mWindowVideoView =newWindowVideoView(this,windowParams);//...

    FloatWindow只是一个悬浮窗View,您可以传入您要显示的布局View。可以用于窗口切换播放时的无缝续播。此处不做代码示例展示。

    一些样式设置(StyleSetter)

    样式的设置是针对VideoViewWindowVideoViewFloatWindow的。当然框架提供的StyleSetter您也可以用于别处。提供了如下的样式设置:

    publicinterfaceIStyleSetter{//设置圆角@TargetApi(Build.VERSION_CODES.LOLLIPOP)voidsetRoundRectShape(floatradius);@TargetApi(Build.VERSION_CODES.LOLLIPOP)voidsetRoundRectShape(Rect rect,floatradius);//设置为圆形@TargetApi(Build.VERSION_CODES.LOLLIPOP)voidsetOvalRectShape();@TargetApi(Build.VERSION_CODES.LOLLIPOP)voidsetOvalRectShape(Rect rect);//清除样式设置@TargetApi(Build.VERSION_CODES.LOLLIPOP)voidclearShapeStyle();//设置阴影//注意阴影的设置要求对应的View对象必须要有背景色(不能是TRANSPARENT)//如果您没设置,框架内部会自定设置为黑色voidsetElevationShadow(floatelevation);voidsetElevationShadow(intbackgroundColor,floatelevation);}

    解码器的接入

    框架自带了系统的MediaPlayer的解码实现,项目demo中示例接入了ijkplayer和exoplayer,如果您想接入其他的解码器,请参见示例代码,以下为简单示例,更详细的请参见项目源码。

    接入步骤

    继承自BaseInternalPlayer

    实现定义的抽象方法

    配置引入您的解码器

    publicclassXXXPlayerextendsBaseInternalPlayer{publicXXXPlayer(){//...}//...//implements some abstract methods.}

    通过配置设置使用该解码器。

    intplanId =2;PlayerConfig.addDecoderPlan(newDecoderPlan(planId, XXXPlayer.class.getName(),"XXXPlayer"));PlayerConfig.setDefaultPlanId(planId);

    以上对于PlayerBase的讲解基本完成。码字好累!!!

    主要的模块差不多就这么多了,更详细的可参见项目源码。

    相关文章

      网友评论

        本文标题:Android播放器基础封装库PlayerBase

        本文链接:https://www.haomeiwen.com/subject/yqyluftx.html