4. MediaPlayer + SurfaceView:MediaPlayer基础使用、MediaPlayer状态机、SurfaceView作为显示载体、音视频同步基础概念
好,咱们正式开始进入实战环节。
这一章,我打算把 MediaPlayer + SurfaceView 这套组合拳讲透。说白了,这是 Android 视频渲染最经典、最基础的一套方案。你想想看,很多播放器、短视频 App,底层逻辑都离不开它。
我个人习惯,学任何技术先搞懂两个东西:它是什么,以及它的状态怎么变。MediaPlayer 也不例外。
4.1 MediaPlayer 基础使用
MediaPlayer 是 Android 官方提供的多媒体播放器 API。它支持音频、视频,甚至流媒体。用起来其实不复杂,核心就几步:
- 创建实例:new 一个 MediaPlayer 对象。
- 设置数据源:可以是本地文件、网络 URL,或者 ContentProvider 的 Uri。
- 准备播放:同步调用
prepare(),或者异步调用prepareAsync()。 - 启动播放:调用
start()。 - 释放资源:用完后记得
release()。
嗯,这里要注意:千万别在主线程调用 prepare()。为什么?因为它会阻塞 UI 线程,一旦网络慢或者文件大,你的界面就卡死了。我早期做项目时就踩过这个坑,用户反馈播放视频时界面点不动,查了半天才发现是同步 prepare 惹的祸。
来看一段最基础的代码:
MediaPlayer mediaPlayer = new MediaPlayer();
try {
// 设置数据源,这里用网络视频
mediaPlayer.setDataSource("https://example.com/video.mp4");
// 异步准备,避免阻塞主线程
mediaPlayer.prepareAsync();
// 设置准备完成的监听
mediaPlayer.setOnPreparedListener(new MediaPlayer.OnPreparedListener() {
@Override
public void onPrepared(MediaPlayer mp) {
// 准备完成,开始播放
mp.start();
}
});
} catch (IOException e) {
e.printStackTrace();
}
这段代码看起来简单,但背后藏着不少门道。比如 prepareAsync() 之后,MediaPlayer 内部会启动一个后台线程去加载数据,加载完了回调 onPrepared。这个回调是在主线程执行的,所以你可以放心地在里面更新 UI。
4.2 MediaPlayer 状态机
MediaPlayer 内部维护了一个状态机。不理解状态机,你写出来的代码很容易出 bug。我曾经见过一个同事,在 stop() 之后直接调 start(),结果抛了 IllegalStateException。为什么?因为状态不对。
官方文档给了一张状态图,我把它简化了一下,画成 SVG 方便你理解:
这张图里,有几个关键点我必须强调:
- Idle 状态:刚 new 出来的时候。此时什么都不能做,必须先 setDataSource。
- Initialized 状态:设置完数据源后。此时可以调用 prepare 或 prepareAsync。
- Prepared 状态:准备完成。可以 start、stop,或者 seekTo。
- Started 状态:正在播放。可以 pause、stop。
- Paused 状态:暂停了。可以 start 恢复,或者 stop 停止。
- Stopped 状态:停止了。不能直接 start,必须重新 prepare。
- PlaybackCompleted 状态:播放完了。可以 start 重播,或者 seekTo 跳转。
- End 状态:调用了 release()。此时 MediaPlayer 对象不能再用了。
Stopped 状态直接调 start()!必须先 prepare() 回到 Prepared 状态。否则会抛 IllegalStateException。我当年调试这个异常调了一下午,最后发现是状态机没搞懂。
4.3 SurfaceView 作为显示载体
MediaPlayer 本身只负责解码和播放,它不负责渲染画面。那画面显示到哪里去?答案是 SurfaceView。
SurfaceView 和普通的 View 不太一样。它有一个独立的 Surface,这个 Surface 运行在单独的线程里。MediaPlayer 可以直接把视频帧渲染到这个 Surface 上,效率非常高。
使用方式很简单:
// 在布局中定义 SurfaceView
// <SurfaceView android:id="@+id/surface_view" ... />
SurfaceView surfaceView = findViewById(R.id.surface_view);
SurfaceHolder holder = surfaceView.getHolder();
// 设置 Surface 创建完成的监听
holder.addCallback(new SurfaceHolder.Callback() {
@Override
public void surfaceCreated(SurfaceHolder holder) {
// Surface 创建好了,把 MediaPlayer 的显示载体设置给它
mediaPlayer.setDisplay(holder);
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
// Surface 尺寸或格式变了,可以在这里做适配
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
// Surface 销毁了,记得释放 MediaPlayer 的显示资源
mediaPlayer.setDisplay(null);
}
});
这里有个细节:一定要在 surfaceCreated 之后才调用 setDisplay。如果 Surface 还没创建好就设置,MediaPlayer 会找不到显示目标,画面就黑屏了。我见过不少新手犯这个错。
surfaceCreated 回调里调用 mediaPlayer.start(),而不是在 onPrepared 里。为什么?因为如果 Surface 还没准备好就 start,画面会卡在第一帧,直到 Surface 可用。提前 start 反而可能导致黑屏。
4.4 音视频同步基础概念
音视频同步,简称 AVSync。说白了,就是让声音和画面在时间上对齐。你想想看,如果视频里人物张嘴说话,声音却慢了半拍,那体验多糟糕。
MediaPlayer 内部已经帮我们做了基础的音视频同步。它默认以音频时钟为基准。为什么?因为人耳对声音的延迟更敏感。画面稍微快一点或慢一点,人眼可能察觉不到,但声音不同步,耳朵一下就听出来了。
同步的基本原理是这样的:
- MediaPlayer 解码音频和视频时,会给每一帧打上时间戳(PTS,Presentation Time Stamp)。
- 播放时,音频和视频各自独立解码,但渲染时会根据时间戳对齐。
- 如果视频帧比音频快了,就丢帧或者等待;如果慢了,就加速渲染或者重复显示上一帧。
MediaPlayer 的同步策略是:
| 情况 | 处理方式 |
|---|---|
| 视频帧比音频早到 | 等待,直到音频时钟追上 |
| 视频帧比音频晚到 | 丢弃该帧,直接渲染下一帧 |
| 音频帧比视频早到 | 音频继续播放,视频加速追赶 |
| 音频帧比视频晚到 | 音频等待,视频减速或重复帧 |
嗯,这里要注意:MediaPlayer 的同步是自动的,你不需要手动干预。但如果你自己写解码器(比如用 MediaCodec),那音视频同步就得自己实现了。那是后面章节的内容,这里先有个概念就行。
setDisplay() 连接起来,剩下的交给系统。
最后,我再说一个实战中容易忽略的点:生命周期管理。Activity 或 Fragment 销毁时,一定要记得 release() MediaPlayer,否则会内存泄漏。我习惯在 onDestroy() 里做这件事:
@Override
protected void onDestroy() {
super.onDestroy();
if (mediaPlayer != null) {
mediaPlayer.release();
mediaPlayer = null;
}
}
好了,这一章的内容就到这里。MediaPlayer 的状态机、SurfaceView 的配合、音视频同步的概念,都是后面实战的基础。把这些搞懂了,后面写代码会顺手很多。
公众号:蓝海资料掘金营,微信deep3321