☰
Android轻量级AVPlayer封装:基于MediaPlayer的播放器实战
2026/9/25 6:19:59 网站建设 项目流程

1. 为什么我要自己撸一个Android AVPlayer

做Android开发这些年,多媒体播放这块踩的坑实在太多了。系统自带的MediaPlayer看着简单,真到项目里用起来各种格式兼容性问题、seek不准、状态回调混乱,尤其是产品那边提需求说“要支持倍速、要能切字幕、要能记住播放进度”,MediaPlayer直接歇菜。ExoPlayer功能倒是全,但引入依赖之后包体积蹭蹭往上涨,一个简单的音频播放场景,为了几个API把整个库拖进来,说实话有点杀鸡用牛刀的感觉。

后来我干脆自己封装了一套轻量级的AVPlayer组件,核心思路就一个:用最少的代码把MediaPlayer和SurfaceView/TextureView组合起来,对外暴露一套干净、好理解的API。这套东西我前后在三个项目里用过,从音频播客到短视频预览都扛得住,代码量控制在几百行以内,没有第三方依赖,直接拷进项目就能跑。

这篇文章我会把这套AVPlayer的设计思路、核心实现、踩过的坑和排查技巧全部摊开讲。适合谁看?如果你正在做Android多媒体相关的功能,或者你受够了MediaPlayer那套难用的状态机,又不想引入重型播放库,那这篇内容应该能帮你省不少时间。我会尽量把每个设计决策背后的“为什么”讲清楚,让你看完能直接复现一套属于自己的播放器。

2. 整体架构设计与核心思路拆解

2.1 为什么选MediaPlayer而不是ExoPlayer或IJKPlayer

先说选型这件事。市面上Android端做播放的方案无非就那么几种:系统MediaPlayer、Google的ExoPlayer、B站的IJKPlayer,还有基于FFmpeg自己编译的。我选MediaPlayer的理由很直接:

  • 零依赖:不用加任何gradle依赖,不用管so库的ABI兼容问题,APK体积不会因为播放器涨哪怕1KB。
  • 系统级支持:MediaPlayer底层走的是系统多媒体框架,硬解码支持是天然的,功耗和性能在大多数场景下够用。
  • API简单:虽然状态机有点绕,但核心方法就那么几个,封装一层之后对外可以做到很好用。

当然它也有明显的短板:不支持DASH、HLS的自适应码率切换(HLS在部分版本上可以播但控制力弱),不支持自定义渲染器,字幕支持基本为零。所以我的定位很明确——这套AVPlayer适合“播放本地或直链媒体文件”的场景,比如音频播放器、短视频预览、课程视频播放这类需求。如果你要做直播或者复杂的流媒体,那还是老老实实上ExoPlayer。

注意:MediaPlayer在Android 9(API 28)之后对明文HTTP请求做了限制,如果你的媒体地址是http开头的,记得在manifest里配置android:usesCleartextTraffic="true",或者直接用https。

2.2 封装层次与模块划分

我的封装分三层:

第一层是AVPlayerCore,直接持有MediaPlayer实例,负责所有底层调用和状态管理。这一层不涉及任何UI,纯逻辑。

第二层是AVPlayerView,继承自FrameLayout,内部包含一个SurfaceView(或TextureView)和一个进度条。它持有AVPlayerCore的引用,把播放画面和控制器绑定在一起。

第三层是AVPlayerController,一个接口层,定义了播放、暂停、seek、设置数据源等操作,方便在Activity或Fragment里调用,也方便做单元测试时mock。

这样分层的好处是:如果你只需要音频播放,可以只用AVPlayerCore,不引入任何View相关的代码;如果你需要自定义UI,可以只复用Core层,自己写控制界面。

2.3 状态机的设计:把MediaPlayer的坑填平

MediaPlayer最让人头疼的就是它的状态机。你如果在错误的状态下调用了错误的方法,它不会给你友好的提示,直接抛IllegalStateException。比如你在Idle状态下调start(),或者在Preparing状态下调seekTo(),都会崩。

我的做法是在AVPlayerCore里维护一个自己的状态枚举:

public enum PlayerState { IDLE, // 初始状态 INITIALIZED, // setDataSource之后 PREPARING, // prepareAsync调用后 PREPARED, // onPrepared回调后 STARTED, // start调用后 PAUSED, // pause调用后 STOPPED, // stop调用后 COMPLETED, // 播放完成 ERROR // 出错 }

每次对外暴露操作之前,先检查当前状态是否允许该操作。比如seekTo只在PREPARED、STARTED、PAUSED、COMPLETED这几个状态下才执行,其他状态直接忽略并打日志。这样上层调用者就不用关心MediaPlayer那套复杂的状态约束了。

2.4 渲染方案:SurfaceView还是TextureView

这是个老生常谈的问题。SurfaceView的优点是性能好,它有自己的独立绘图层,不占用主线程的绘制资源;缺点是它不在View的层级里,做动画、变换、圆角裁剪都很麻烦。TextureView正好相反,它像普通View一样参与绘制,可以做各种变换,但性能略差,而且需要硬件加速支持。

我的选择是默认用SurfaceView,但保留TextureView的切换能力。具体做法是在AVPlayerView的构造函数里读一个自定义属性,让使用者在XML里就能指定用哪种:

<com.example.avplayer.AVPlayerView android:layout_width="match_parent" android:layout_height="wrap_content" app:render_type="surface" />

对于短视频列表这种需要频繁滑动的场景,SurfaceView更合适;对于需要做画面旋转、缩放动画的场景,TextureView更灵活。

3. 核心细节解析与实操要点

3.1 MediaPlayer的生命周期管理

MediaPlayer对象是很重的资源,它持有解码器、音频输出通道等系统资源。如果你不及时释放,轻则内存泄漏,重则导致其他应用无法使用音频设备。

我的做法是在AVPlayerCore里严格遵循“谁创建谁释放”的原则,并且把release()方法做成幂等的——多次调用不会出问题:

public void release() { if (mediaPlayer != null) { try { mediaPlayer.reset(); mediaPlayer.release(); } catch (Exception e) { Log.e(TAG, "release error", e); } finally { mediaPlayer = null; currentState = PlayerState.IDLE; } } }

然后在AVPlayerView的onDetachedFromWindow()里调用release(),确保View从窗口移除时播放器也被释放。这里有个细节:不要直接在onDestroy()里释放,因为onDetachedFromWindow的调用时机更准确,能覆盖View被移除但Activity还没销毁的情况。

实操心得:我见过很多项目在onPause()里调release(),然后在onResume()里重新创建。这样做不是不行,但用户体验很差——切个后台回来播放进度就没了。更好的做法是在onPause()里只调pause(),在onDestroy()或onDetachedFromWindow()里才release()。

3.2 异步准备与回调线程问题

MediaPlayer的prepareAsync()是异步的,它的回调onPrepared()是在主线程的Looper里执行的。这一点很重要,因为很多人在回调里直接操作UI,如果回调不在主线程就会崩。但如果你在回调里做了耗时操作,又会阻塞主线程。

我的处理方式是在AVPlayerCore里定义一个OnPlayerStateListener接口,把onPrepared、onCompletion、onError这些回调统一转发出去,并且明确文档说明这些回调都在主线程执行。如果调用者需要做耗时操作,自己切到子线程去。

mediaPlayer.setOnPreparedListener(mp -> { currentState = PlayerState.PREPARED; if (stateListener != null) { stateListener.onPrepared(); } // 如果设置了自动播放,准备完成后直接开始 if (autoPlay) { start(); } });

这里有个坑:setOnPreparedListener必须在prepareAsync()之前调用,否则可能错过回调。我一般是在setDataSource()之后立刻设置所有监听器,然后再调prepareAsync()。

3.3 进度更新机制的设计

播放进度更新看起来简单,其实有很多细节。最朴素的做法是开一个Handler,每隔500ms发一条消息去读getCurrentPosition()。但这样做有几个问题:

  • 频率太高浪费资源,频率太低进度条卡顿。
  • 暂停时不应该继续更新。
  • seek之后进度要立刻刷新,不能等下一个周期。

我的方案是用一个Runnable配合Handler.postDelayed(),间隔设为300ms(实测下来这个值在流畅度和性能之间平衡得最好)。在start()时启动轮询,在pause()、stop()、release()时移除回调。seek操作完成后手动触发一次进度更新。

private final Runnable progressUpdater = new Runnable() { @Override public void run() { if (currentState == PlayerState.STARTED) { int current = mediaPlayer.getCurrentPosition(); int duration = mediaPlayer.getDuration(); if (progressListener != null) { progressListener.onProgress(current, duration); } handler.postDelayed(this, PROGRESS_INTERVAL); } } };

注意:getDuration()在PREPARED状态之前调用会返回-1,所以进度更新一定要在onPrepared之后才开始。

3.4 音频焦点处理

如果你的应用要播放音频,必须处理音频焦点。否则会出现“我的应用在放歌,突然来电话了,歌还在响”这种尴尬情况。

处理音频焦点的标准做法是使用AudioManager请求焦点,并注册OnAudioFocusChangeListener:

private boolean requestAudioFocus() { AudioManager am = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE); int result = am.requestAudioFocus( focusChangeListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN ); return result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED; }

在焦点丢失时(比如来电),应该暂停播放;焦点恢复时,根据业务需求决定是否自动恢复播放。我的默认策略是:短暂丢失(AUDIOFOCUS_LOSS_TRANSIENT)时暂停,恢复后自动继续;永久丢失(AUDIOFOCUS_LOSS)时暂停并释放焦点。

4. 完整实操过程与核心环节实现

4.1 项目结构搭建

先在Android Studio里新建一个项目,minSdkVersion我建议设成21(Android 5.0),这样能覆盖绝大多数设备,同时可以用一些较新的API。然后在项目里新建一个module或者直接在app module里建包:

com.example.avplayer ├── core │ ├── AVPlayerCore.java │ ├── PlayerState.java │ └── OnPlayerStateListener.java ├── view │ ├── AVPlayerView.java │ └── AVPlayerController.java └── util └── TimeUtil.java

不需要在gradle里加任何额外依赖,纯原生实现。

4.2 AVPlayerCore的完整实现

先定义状态枚举和监听接口:

public enum PlayerState { IDLE, INITIALIZED, PREPARING, PREPARED, STARTED, PAUSED, STOPPED, COMPLETED, ERROR } public interface OnPlayerStateListener { void onPrepared(); void onCompletion(); void onError(int what, int extra); void onBufferingUpdate(int percent); }

然后是核心类的骨架:

public class AVPlayerCore { private static final String TAG = "AVPlayerCore"; private MediaPlayer mediaPlayer; private PlayerState currentState = PlayerState.IDLE; private OnPlayerStateListener stateListener; private boolean autoPlay = false; private Handler handler = new Handler(Looper.getMainLooper()); public AVPlayerCore() { initMediaPlayer(); } private void initMediaPlayer() { mediaPlayer = new MediaPlayer(); mediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); mediaPlayer.setOnPreparedListener(mp -> { currentState = PlayerState.PREPARED; if (stateListener != null) stateListener.onPrepared(); if (autoPlay) start(); }); mediaPlayer.setOnCompletionListener(mp -> { currentState = PlayerState.COMPLETED; if (stateListener != null) stateListener.onCompletion(); }); mediaPlayer.setOnErrorListener((mp, what, extra) -> { currentState = PlayerState.ERROR; if (stateListener != null) stateListener.onError(what, extra); return true; }); mediaPlayer.setOnBufferingUpdateListener((mp, percent) -> { if (stateListener != null) stateListener.onBufferingUpdate(percent); }); } }

设置数据源的方法要处理本地文件和网络URL两种情况:

public void setDataSource(String path) { try { mediaPlayer.reset(); if (path.startsWith("http") || path.startsWith("rtsp")) { mediaPlayer.setDataSource(path); } else { mediaPlayer.setDataSource(path); } currentState = PlayerState.INITIALIZED; } catch (IOException e) { Log.e(TAG, "setDataSource failed", e); currentState = PlayerState.ERROR; } }

播放控制方法:

public void prepareAsync() { if (currentState != PlayerState.INITIALIZED) { Log.w(TAG, "prepareAsync called in wrong state: " + currentState); return; } currentState = PlayerState.PREPARING; mediaPlayer.prepareAsync(); } public void start() { if (currentState == PlayerState.PREPARED || currentState == PlayerState.PAUSED || currentState == PlayerState.COMPLETED) { mediaPlayer.start(); currentState = PlayerState.STARTED; startProgressUpdate(); } } public void pause() { if (currentState == PlayerState.STARTED) { mediaPlayer.pause(); currentState = PlayerState.PAUSED; stopProgressUpdate(); } } public void seekTo(int positionMs) { if (currentState == PlayerState.PREPARED || currentState == PlayerState.STARTED || currentState == PlayerState.PAUSED || currentState == PlayerState.COMPLETED) { mediaPlayer.seekTo(positionMs); } }

4.3 AVPlayerView的UI集成

AVPlayerView继承FrameLayout,内部动态添加SurfaceView和进度条:

public class AVPlayerView extends FrameLayout { private SurfaceView surfaceView; private SurfaceHolder surfaceHolder; private AVPlayerCore playerCore; private SeekBar seekBar; private boolean surfaceReady = false; public AVPlayerView(Context context, AttributeSet attrs) { super(context, attrs); init(context, attrs); } private void init(Context context, AttributeSet attrs) { // 读取自定义属性决定渲染方式 TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.AVPlayerView); int renderType = ta.getInt(R.styleable.AVPlayerView_render_type, 0); ta.recycle(); if (renderType == 0) { surfaceView = new SurfaceView(context); } else { // 使用TextureView surfaceView = new SurfaceView(context); // 简化处理 } LayoutParams params = new LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); addView(surfaceView, params); surfaceHolder = surfaceView.getHolder(); surfaceHolder.addCallback(new SurfaceHolder.Callback() { @Override public void surfaceCreated(SurfaceHolder holder) { surfaceReady = true; if (playerCore != null) { playerCore.setDisplay(holder); } } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {} @Override public void surfaceDestroyed(SurfaceHolder holder) { surfaceReady = false; } }); } }

这里有个关键点:SurfaceView的surfaceCreated回调时机和MediaPlayer的prepare时机可能不一致。如果MediaPlayer先准备好了,但Surface还没创建,视频就播不出来。我的处理方式是在AVPlayerCore里缓存SurfaceHolder,等surfaceCreated之后再setDisplay。

4.4 进度条与手势控制

进度条我用的是系统SeekBar,但做了两层处理:一层是播放进度,一层是缓冲进度。缓冲进度通过setSecondaryProgress()来设置,数据来源是onBufferingUpdate回调。

手势控制方面,我实现了三个常用手势:

  • 左右滑动:快进/快退,滑动距离映射到seek位置。
  • 上下滑动左侧:调节亮度。
  • 上下滑动右侧:调节音量。
@Override public boolean onTouchEvent(MotionEvent event) { switch (event.getAction()) { case MotionEvent.ACTION_DOWN: downX = event.getX(); downY = event.getY(); break; case MotionEvent.ACTION_MOVE: float deltaX = event.getX() - downX; float deltaY = event.getY() - downY; if (Math.abs(deltaX) > Math.abs(deltaY)) { // 横向滑动,处理快进快退 int seekDelta = (int) (deltaX / getWidth() * duration); playerCore.seekTo(currentPosition + seekDelta); } break; } return true; }

实操心得:手势的阈值要设得合理,我一般用ViewConfiguration.get(context).getScaledTouchSlop()作为最小滑动距离,避免误触。

4.5 在Activity中的调用示例

public class MainActivity extends AppCompatActivity { private AVPlayerView playerView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); playerView = findViewById(R.id.player_view); playerView.setDataSource("https://example.com/video.mp4"); playerView.setAutoPlay(true); playerView.prepareAsync(); } @Override protected void onPause() { super.onPause(); playerView.pause(); } @Override protected void onDestroy() { super.onDestroy(); playerView.release(); } }

5. 常见问题与排查技巧实录

5.1 播放黑屏但有声音

这是最常见的问题,九成以上是Surface没有正确绑定。排查步骤:

  1. 检查surfaceCreated回调是否触发。如果没触发,说明SurfaceView没有正确添加到布局里,或者被其他View遮挡了。
  2. 检查setDisplay()是否在prepareAsync()之前调用。如果顺序反了,MediaPlayer可能已经准备好但找不到渲染目标。
  3. 检查视频编码格式是否被设备支持。有些设备不支持H.265硬解,会黑屏但有声音。

我的解决方案是在AVPlayerCore里加一个pendingSurfaceHolder字段,如果setDisplay时Surface还没准备好,就先存起来,等surfaceCreated后再设置。

5.2 seek之后进度回调不更新

MediaPlayer的seekTo是异步的,调用之后不会立刻生效。如果你在seek后马上读getCurrentPosition(),拿到的还是旧值。正确的做法是监听OnSeekCompleteListener:

mediaPlayer.setOnSeekCompleteListener(mp -> { // seek完成,手动触发一次进度更新 if (progressListener != null) { progressListener.onProgress( mediaPlayer.getCurrentPosition(), mediaPlayer.getDuration() ); } });

5.3 播放网络视频时缓冲卡顿

网络视频的缓冲策略可以通过MediaPlayer.setBufferSize()来调整,但这个API在API 26才加入。低版本设备上只能靠系统默认策略。我的经验是:

  • 对于短视频(<5分钟),可以先把整个文件下载到本地缓存再播放,体验最稳。
  • 对于长视频,建议用ExoPlayer,它的缓冲控制更精细。
  • 如果坚持用MediaPlayer,可以在onBufferingUpdate里根据percent动态显示加载动画,至少让用户知道在缓冲。

5.4 常见问题速查表

问题现象可能原因解决方案
播放崩溃IllegalStateException在错误状态调用了方法检查状态机,加状态判断
有声音无画面Surface未绑定或格式不支持检查setDisplay时机,确认编码格式
进度条不动未启动进度轮询或getDuration返回-1在onPrepared后启动轮询
切后台回来播放停止onPause里调了release改为只pause,onDestroy才release
音频焦点丢失后不恢复未处理AUDIOFOCUS_GAIN回调在焦点恢复回调里判断是否继续播放
内存泄漏MediaPlayer未释放在onDetachedFromWindow里release

5.5 性能优化建议

最后分享几个实测有效的优化点:

  • 复用MediaPlayer实例:如果是在列表里播放,不要每个item都new一个MediaPlayer,用一个全局的播放器,切换数据源时调reset()即可。
  • 避免频繁seek:seek操作很重,如果用户拖动进度条,不要每次move都seek,等ACTION_UP时再seek一次。
  • 合理设置缓冲区:对于本地文件,可以把缓冲区设小一点,减少内存占用;对于网络文件,适当加大。
  • 使用硬解码:MediaPlayer默认走硬解码,但如果你发现某些格式播放卡顿,可以尝试在setDataSource之前设置mediaPlayer.setVideoScalingMode(MediaPlayer.VIDEO_SCALING_MODE_SCALE_TO_FIT)。

这套AVPlayer我在实际项目里跑了两年多,从简单的音频播放到短视频预览都覆盖过,稳定性没问题。代码量不大,但每个细节都是踩坑踩出来的。如果你也在做类似的东西,希望这些经验能帮你少走点弯路。后面如果要做更复杂的功能,比如弹幕、字幕、多音轨切换,那确实得上ExoPlayer,但在那之前,这套轻量方案足够撑住大多数场景了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询